2 Commits
4 changed files with 255 additions and 9 deletions
+22 -3
View File
@@ -3,6 +3,7 @@ package plugin
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
@@ -74,8 +75,8 @@ func TestHandleManagement_QuotaResource(t *testing.T) {
if !strings.Contains(bodyStr, "用量配额") { if !strings.Contains(bodyStr, "用量配额") {
t.Errorf("Body does not contain expected menu text 用量配额") t.Errorf("Body does not contain expected menu text 用量配额")
} }
if !strings.Contains(bodyStr, "v0.4.3") { if !strings.Contains(bodyStr, "v0.4.5") {
t.Errorf("Body does not contain version badge v0.4.3") t.Errorf("Body does not contain version badge v0.4.5")
} }
} }
} }
@@ -175,6 +176,24 @@ const mockOpencodeUsageJSON = `{"usage":{
"monthly": {"status":"ok","percent":23,"resetsAt":"2026-10-14T09:13:49.000Z"} "monthly": {"status":"ok","percent":23,"resetsAt":"2026-10-14T09:13:49.000Z"}
}}` }}`
// mockOpencodeUsageJSONFuture returns the same usage envelope but with reset
// timestamps relative to now. The hardcoded dates in mockOpencodeUsageJSON
// eventually fall into the past (weekly 2026-09-21 did), which makes
// weekly.ResetInSeconds = 0 and breaks the `want > 0` assertion in
// TestHandleManagement_OpencodeUsageRoute. Use this fixture for tests that
// assert positive reset_in_seconds.
func mockOpencodeUsageJSONFuture() string {
now := time.Now().UTC()
ts := func(d time.Duration) string {
return now.Add(d).Format("2006-01-02T15:04:05.000Z")
}
return fmt.Sprintf(`{"usage":{
"rolling": {"status":"ok","percent":4, "resetsAt":"%s"},
"weekly": {"status":"ok","percent":46,"resetsAt":"%s"},
"monthly": {"status":"ok","percent":23,"resetsAt":"%s"}
}}`, ts(2*time.Hour), ts(72*time.Hour), ts(30*24*time.Hour))
}
// Verifies that /plugins/commandcode/opencode/usage is matched by the dedicated // Verifies that /plugins/commandcode/opencode/usage is matched by the dedicated
// OpenCode handler and NOT swallowed by the generic "/usage" suffix match // OpenCode handler and NOT swallowed by the generic "/usage" suffix match
// (which would route it to the Command Code handler). // (which would route it to the Command Code handler).
@@ -194,7 +213,7 @@ func TestHandleManagement_OpencodeUsageRoute(t *testing.T) {
} }
sawAuthHeader = true sawAuthHeader = true
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(mockOpencodeUsageJSON)) _, _ = w.Write([]byte(mockOpencodeUsageJSONFuture()))
})) }))
defer ts.Close() defer ts.Close()
+1 -1
View File
@@ -13,7 +13,7 @@ import (
const ( const (
PluginID = "commandcode" PluginID = "commandcode"
PluginName = "commandcode" PluginName = "commandcode"
PluginVersion = "0.4.3" PluginVersion = "0.4.5"
PluginAuthor = "zgs225" PluginAuthor = "zgs225"
PluginRepo = "https://github.com/zgs225/cliproxy-plugin-commandcode" PluginRepo = "https://github.com/zgs225/cliproxy-plugin-commandcode"
PluginLogo = "https://raw.githubusercontent.com/zgs225/cliproxy-plugin-commandcode/main/assets/logo.svg" PluginLogo = "https://raw.githubusercontent.com/zgs225/cliproxy-plugin-commandcode/main/assets/logo.svg"
+38 -5
View File
@@ -918,7 +918,7 @@ const QuotaPageHTML = `<!DOCTYPE html>
<div> <div>
<div class="brand-title"> <div class="brand-title">
用量配额 用量配额
<span class="version-tag">v0.4.3</span> <span class="version-tag">v0.4.5</span>
</div> </div>
</div> </div>
</div> </div>
@@ -1234,6 +1234,8 @@ const QuotaPageHTML = `<!DOCTYPE html>
const lastUpdated = document.getElementById("lastUpdated"); const lastUpdated = document.getElementById("lastUpdated");
let fiveHourTargetTime = null; let fiveHourTargetTime = null;
let monthlyTargetTime = null;
let weeklyTargetTime = null; let weeklyTargetTime = null;
let timerInterval = null; let timerInterval = null;
@@ -1625,12 +1627,43 @@ const QuotaPageHTML = `<!DOCTYPE html>
}); });
allBadgeCommandcode.className = "status-badge " + badgeVisualClass(cc.level); allBadgeCommandcode.className = "status-badge " + badgeVisualClass(cc.level);
allBadgeTextCommandcode.textContent = BADGE_TEXT[cc.level] || "-"; allBadgeTextCommandcode.textContent = BADGE_TEXT[cc.level] || "-";
// Plan + 三个限额窗口(百分比/进度条/重置时间),行结构与 OpenCode 卡片一致,
// 倒计时走 data-reset-at/data-reset-secs 属性,由 updateTimers() 统一刷新
const ccWins = [
{ name: "Rolling 5h", o: limits.five_hour || {} },
{ name: "Weekly", o: limits.weekly || {} },
{ name: "Monthly", o: limits.monthly || {} }
];
let ccRows = "";
ccWins.forEach(function (d) {
const w = d.o;
const pct = clampPercent(w.percentage);
const level = levelOf(pct, w.exceeded, "");
let resetAt = "";
let resetSecs = "";
if (w.reset_at) {
const t = new Date(w.reset_at);
if (!isNaN(t.getTime())) resetAt = t.toISOString();
}
if (!resetAt && w.reset_in_seconds > 0) {
resetSecs = String(w.reset_in_seconds);
}
const resetAttr = resetAt
? " data-reset-at=\"" + esc(resetAt) + "\""
: (resetSecs ? " data-reset-secs=\"" + esc(resetSecs) + "\"" : "");
const pctText = pct === null ? "-" : Math.round(pct) + "%";
const pctCls = level === "exceeded" ? " bad" : pct !== null && pct >= 80 ? " warn" : "";
const barCls = level === "exceeded" ? " danger" : level === "warning" ? " warning" : "";
ccRows += '<div class="oc-win-row">' +
'<span class="oc-win-name">' + d.name + '</span>' +
'<div class="progress-track oc-win-bar"><div class="progress-bar' + barCls + '" style="width:' + (pct === null ? 0 : pct) + '%"></div></div>' +
'<span class="oc-win-pct' + pctCls + '">' + pctText + '</span>' +
'<span class="oc-win-reset countdown-timer"' + resetAttr + '>-</span>' +
'</div>';
});
allBodyCommandcode.innerHTML = allBodyCommandcode.innerHTML =
summaryRow("Plan", esc(planName)) + summaryRow("Plan", esc(planName)) +
summaryRow("Total Credits", formatUSD(credits.total_credits)) + '<div class="oc-key-body">' + ccRows + '</div>';
summaryRow("Monthly Credits", formatUSD(credits.monthly_credits)) +
summaryRow("最差窗口", esc(worstName) + " " + (worstPct === null ? "-" : worstPct.toFixed(1) + "%")) +
miniBar(worstPct, worstLevel);
} else { } else {
allBadgeCommandcode.className = "status-badge"; allBadgeCommandcode.className = "status-badge";
allBadgeTextCommandcode.textContent = "尚未加载"; allBadgeTextCommandcode.textContent = "尚未加载";
+194
View File
@@ -21,3 +21,197 @@ try {
process.exit(1); process.exit(1);
} }
console.log("pagecheck: embedded JS syntax OK"); console.log("pagecheck: embedded JS syntax OK");
// --- Undeclared-identifier audit (guards against ReferenceErrors like the
// v0.4.4 `monthlyTargetTime is not defined` bug: a bare identifier read in
// updateTimers() that was never declared and only existed as a global
// property accidentally created by renderUsage()).
//
// Approach (deliberately simple/grep-style, no DOM execution):
// 1. strip comments and string literals
// 2. collect every var/let/const/function declaration name + function/
// callback/catch parameter
// 3. flag candidates: bare assignment targets (x =, x +=, x++, ...),
// bare if()/while() condition identifiers, and for-loop init identifiers
// 4. whitelist known globals; anything left is reported and fails the check
const whitelist = new Set([
// browser builtins referenced by the page
"document", "window", "localStorage", "sessionStorage", "fetch",
"setInterval", "setTimeout", "clearInterval", "clearTimeout", "console",
"alert", "Date", "Math", "Number", "String", "Boolean", "Array",
"Object", "JSON", "parseInt", "parseFloat", "isNaN", "Promise", "Error",
"escape", "unescape", "navigator", "location", "history", "URL",
"URLSearchParams", "FormData", "Headers", "Request", "Response",
"Intl", "Map", "Set", "AbortController", "requestAnimationFrame",
"cancelAnimationFrame", "structuredClone", "globalThis", "arguments",
]);
function stripLiterals(source) {
// Remove comments, string literals, and regex literals; replace with
// harmless placeholders so identifier scanning never sees string content.
// Template literals are NOT fully discarded: their ${...} interpolations
// are kept as "( ... )" so identifiers inside them stay visible.
//
// A "/" only starts a regex literal when it appears in expression
// position (previous significant token is an operator/open bracket or a
// keyword such as return/typeof). Otherwise it is division.
let out = "";
let i = 0;
const n = source.length;
// Stack of lexer contexts. Each entry: { type: "expr", depth: number } or
// { type: "tmpl" }. The initial code runs in a never-ending expr context.
const stack = [{ type: "expr", depth: Infinity }];
// Last significant (non-whitespace) emitted char + last identifier word,
// used for the regex-vs-division heuristic.
let lastSig = "";
let lastWord = "";
const KEYWORDS_BEFORE_REGEX = new Set([
"return", "typeof", "instanceof", "in", "of", "new", "delete", "void",
"do", "else", "case", "throw", "await", "yield",
]);
const emit = (text) => {
for (const ch of text) {
if (/\s/.test(ch)) continue;
if (/[A-Za-z0-9_$]/.test(ch)) {
lastWord = /[A-Za-z0-9_$]/.test(lastSig) ? lastWord + ch : ch;
} else {
lastWord = "";
}
lastSig = ch;
}
out += text;
};
const regexAllowed = () =>
lastSig === "" ||
"(,=:[!&|?+-*/%<>~^;{".includes(lastSig) ||
KEYWORDS_BEFORE_REGEX.has(lastWord);
while (i < n) {
const ctx = stack[stack.length - 1];
const c = source[i];
const next = source[i + 1];
if (ctx.type === "tmpl") {
// Inside template literal text: skip until ` or ${ ... }
if (c === "\\") { i += 2; continue; }
if (c === "`") { i++; stack.pop(); emit(" "); continue; }
if (c === "$" && next === "{") {
i += 2;
stack.push({ type: "expr", depth: 0 });
emit(" ( ");
continue;
}
i++;
continue;
}
// code context (top-level or template ${...} expression)
if (c === "/" && next === "/") {
while (i < n && source[i] !== "\n") i++;
} else if (c === "/" && next === "*") {
i += 2;
while (i < n && !(source[i] === "*" && source[i + 1] === "/")) i++;
i += 2;
} else if (c === "/" && regexAllowed()) {
// regex literal: skip to unescaped closing / (not inside [...])
i++;
let inClass = false;
while (i < n) {
if (source[i] === "\\") { i += 2; continue; }
if (source[i] === "[") { inClass = true; i++; continue; }
if (source[i] === "]") { inClass = false; i++; continue; }
if (source[i] === "/" && !inClass) { i++; break; }
if (source[i] === "\n") break; // malformed; bail out safely
i++;
}
while (i < n && /[a-z]/i.test(source[i])) i++; // flags
emit(" / ");
} else if (c === '"' || c === "'") {
const quote = c;
i++;
while (i < n) {
if (source[i] === "\\") { i += 2; continue; }
if (source[i] === quote) { i++; break; }
if (source[i] === "\n") break;
i++;
}
emit(" " + quote + quote + " ");
} else if (c === "`") {
i++;
stack.push({ type: "tmpl" });
emit(" ");
} else {
if (c === "{") ctx.depth++;
if (c === "}") {
if (ctx.depth === 0) {
// closes a template interpolation: back into template text
stack.pop();
i++;
emit(" ) ");
continue;
}
ctx.depth--;
}
emit(c);
i++;
}
}
return out;
}
function collectDeclarations(clean) {
const declared = new Set();
const addParamList = (raw) => {
for (const p of raw.split(",")) {
const name = p.trim().split(/[\s=]/)[0].replace(/^\.\.\./, "");
if (/^[A-Za-z_$][\w$]*$/.test(name)) declared.add(name);
}
};
for (const m of clean.matchAll(/\b(?:var|let|const)\s+([A-Za-z_$][\w$]*)/g))
declared.add(m[1]);
// function declarations/expressions: name + params
for (const m of clean.matchAll(/\bfunction\s*([A-Za-z_$][\w$]*)?\s*\(([^()]*)\)/g)) {
if (m[1]) declared.add(m[1]);
addParamList(m[2]);
}
// arrow functions: (a, b) => and a =>
for (const m of clean.matchAll(/\(\s*([^()]*?)\s*\)\s*=>/g)) addParamList(m[1]);
for (const m of clean.matchAll(/(?<![\w$.(])\b([A-Za-z_$][\w$]*)\s*=>/g)) declared.add(m[1]);
// catch (e) and destructuring catch
for (const m of clean.matchAll(/\bcatch\s*\(?\s*\{?\s*([A-Za-z_$][\w$]*)/g)) declared.add(m[1]);
return declared;
}
const clean = stripLiterals(js);
const declared = collectDeclarations(clean);
const flagged = new Set();
// 1. bare assignment targets / updates: x =, x +=, x++, x--, x ??=
for (const m of clean.matchAll(/(?:^|[{};\n])\s*([A-Za-z_$][\w$]*)\s*(?:=[^=>]|[+*\/%-]?=[^=]|\+\+|\-\-)/gm)) {
const name = m[1];
if (!declared.has(name) && !whitelist.has(name)) flagged.add(name);
}
// 2. bare if()/while() condition identifiers
for (const m of clean.matchAll(/\b(?:if|while)\s*\(\s*(!*)\s*([A-Za-z_$][\w$]*)\s*(?:\)|&&|\|\||\?)/g)) {
const name = m[2];
if (!declared.has(name) && !whitelist.has(name)) flagged.add(name);
}
// 3. for-loop init without let/var: for (i = 0; ...)
for (const m of clean.matchAll(/\bfor\s*\(\s*([A-Za-z_$][\w$]*)\s*=[^=]/g)) {
const name = m[1];
if (!declared.has(name) && !whitelist.has(name)) flagged.add(name);
}
if (flagged.size > 0) {
console.error("pagecheck: undeclared identifier(s) referenced in embedded JS:");
for (const name of [...flagged].sort()) console.error(" - " + name);
console.error(
"pagecheck: fix by declaring with let/const (see v0.4.5 monthlyTargetTime bug); " +
"if this is a false positive, extend scripts/pagecheck.js"
);
process.exit(1);
}
console.log(
"pagecheck: undeclared-identifier scan OK (" + declared.size + " declarations checked)"
);