diff --git a/plugin/management_test.go b/plugin/management_test.go
index 60f0ff1..df3333f 100644
--- a/plugin/management_test.go
+++ b/plugin/management_test.go
@@ -3,6 +3,7 @@ package plugin
import (
"context"
"encoding/json"
+ "fmt"
"net/http"
"net/http/httptest"
"strings"
@@ -74,8 +75,8 @@ func TestHandleManagement_QuotaResource(t *testing.T) {
if !strings.Contains(bodyStr, "用量配额") {
t.Errorf("Body does not contain expected menu text 用量配额")
}
- if !strings.Contains(bodyStr, "v0.4.4") {
- t.Errorf("Body does not contain version badge v0.4.4")
+ if !strings.Contains(bodyStr, "v0.4.5") {
+ 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"}
}}`
+// 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
// OpenCode handler and NOT swallowed by the generic "/usage" suffix match
// (which would route it to the Command Code handler).
@@ -194,7 +213,7 @@ func TestHandleManagement_OpencodeUsageRoute(t *testing.T) {
}
sawAuthHeader = true
w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write([]byte(mockOpencodeUsageJSON))
+ _, _ = w.Write([]byte(mockOpencodeUsageJSONFuture()))
}))
defer ts.Close()
diff --git a/plugin/plugin.go b/plugin/plugin.go
index 36b9f0b..9c41622 100644
--- a/plugin/plugin.go
+++ b/plugin/plugin.go
@@ -13,7 +13,7 @@ import (
const (
PluginID = "commandcode"
PluginName = "commandcode"
- PluginVersion = "0.4.4"
+ PluginVersion = "0.4.5"
PluginAuthor = "zgs225"
PluginRepo = "https://github.com/zgs225/cliproxy-plugin-commandcode"
PluginLogo = "https://raw.githubusercontent.com/zgs225/cliproxy-plugin-commandcode/main/assets/logo.svg"
diff --git a/plugin/quota_page.go b/plugin/quota_page.go
index 172656b..8c6de03 100644
--- a/plugin/quota_page.go
+++ b/plugin/quota_page.go
@@ -918,7 +918,7 @@ const QuotaPageHTML = `
@@ -1234,6 +1234,8 @@ const QuotaPageHTML = `
const lastUpdated = document.getElementById("lastUpdated");
let fiveHourTargetTime = null;
+ let monthlyTargetTime = null;
+
let weeklyTargetTime = null;
let timerInterval = null;
diff --git a/scripts/pagecheck.js b/scripts/pagecheck.js
index 9fb416f..26d8c45 100644
--- a/scripts/pagecheck.js
+++ b/scripts/pagecheck.js
@@ -21,3 +21,197 @@ try {
process.exit(1);
}
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(/(?/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)"
+);