@tsa-group/claude-usage 0.4.6 → 0.4.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -332,6 +332,21 @@ Client 對 server 只用兩個端點,皆為 `application/json`:
332
332
 
333
333
  ---
334
334
 
335
+ ## 自動更新
336
+
337
+ v0.4.7 起支援自我更新,但**由伺服器決定**,不是去問 npm:
338
+
339
+ - 版本資訊搭 `/v1/report` 的回應回來,**零額外網路呼叫**(daemon 每 5 分鐘一次,
340
+ 若各自去問 npm registry 會變成每台每天 288 次)
341
+ - 伺服器指定**確切版本號**,不是 `@latest` —— 有人拿到 npm token 亂發版本也裝不進來
342
+ - 只在**沒有採樣**的那一輪執行,不拖慢本業
343
+ - 24 小時內同一個目標版本只試一次;**不跨 major**
344
+ - npm 全域目錄不可寫(node 裝在系統目錄)時不嘗試,改在 `doctor` 提示人工升級
345
+
346
+ 想關掉:設環境變數 `CU_NO_AUTOUPDATE=1`。
347
+
348
+ `status` 與 `doctor` 都會顯示是否有新版,用的是上次回報快取的結果,不另外打網路。
349
+
335
350
  ## 已知限制
336
351
 
337
352
  - **非官方 endpoint**:額度 % 來自 Claude Code 內部的 `/api/oauth/usage`,Anthropic 可能變更。
@@ -347,6 +362,9 @@ Client 對 server 只用兩個端點,皆為 `application/json`:
347
362
  (實測:2026-08-29 四台 Win11 因此全數斷線 10.7–18.1 小時,其中一台累計只跑過 7 次。)
348
363
  - **重疊的 tick 尚未互斥**:VBS 以非阻塞方式啟動 node,工作排程器認為任務瞬間完成,
349
364
  所以 `MultipleInstances` 擋不住重疊的 node 行程。極少數情況下會競寫 `health.json`。
365
+ - **npm 11 起不跑 postinstall**:安裝時會印 `npm warn allow-scripts`,我們的安裝提示
366
+ 因此不會顯示。同樣的檢查已搬進 `claude-usage install` 與 `doctor`,所以裝完務必
367
+ 跑一次 `install`。
350
368
  - **PowerShell 執行原則**:預設 `Restricted` 會同時擋掉 `npm.ps1`(安裝時)與
351
369
  `claude-usage.ps1`(執行時)。處理方式見上面 **Windows → 第 0 步**。
352
370
  - **Linux 背景任務未實作**(systemd --user timer)。其餘指令可用。
package/dist/daemon.js CHANGED
@@ -11,7 +11,8 @@ import { existsSync } from "node:fs";
11
11
  import { EXIT, loadToken } from "./creds.js";
12
12
  import { healthWarning, lastSnapshot, loadHealth, saveHealth, } from "./health.js";
13
13
  import { devicePath, projectsDir } from "./paths.js";
14
- import { enroll, report } from "./upload.js";
14
+ import { agentChannel, enroll, report } from "./upload.js";
15
+ import { maybeSelfUpdate, updateNotice } from "./selfupdate.js";
15
16
  import { sample } from "./usage.js";
16
17
  import { nowIso, parseIso } from "./util.js";
17
18
  // ── 自適應策略 ──
@@ -200,6 +201,19 @@ export async function tick(opts = {}) {
200
201
  }
201
202
  const upload = explain ? "(skipped)" : await flushUploads();
202
203
  h.last_upload_state = upload;
204
+ // ★ 只在**沒有採樣**的那一輪自我更新。採樣是本業,不能為了自我維護被 npm
205
+ // 拖上幾十秒。skip 的 tick 本來就閒著,那才是做這件事的位置。
206
+ // 也必須在 flushUploads 之後 —— 版本頻道是那次 report 的回應帶回來的。
207
+ let updateNote = "";
208
+ if (!explain && !should) {
209
+ try {
210
+ updateNote = maybeSelfUpdate(agentChannel());
211
+ }
212
+ catch (e) {
213
+ // 更新絕不能影響 tick。吞掉並記錄,不往上拋。
214
+ updateNote = `update: 例外 ${e?.message ?? e}`;
215
+ }
216
+ }
203
217
  const warn = healthWarning(h);
204
218
  h.unhealthy = Boolean(warn);
205
219
  h.warning = warn;
@@ -207,6 +221,9 @@ export async function tick(opts = {}) {
207
221
  saveHealth(h);
208
222
  console.log(`[${ts}] activity=${actS} cadence=${interval}m -> ${action.padEnd(22)} ` +
209
223
  `upload=${upload} . ${reason}`);
224
+ // 只在真的做了什麼時才印,否則每 5 分鐘一行 "skip" 會把 log 洗掉。
225
+ if (updateNote && !updateNote.startsWith("update: skip"))
226
+ console.log(` ${updateNote}`);
210
227
  if (warn) {
211
228
  // 前綴固定,方便 grep daemon.log 或讓 status 指令抓
212
229
  console.error(`[${ts}] UNHEALTHY: ${warn}`);
@@ -235,5 +252,9 @@ export function printHealth() {
235
252
  if (h.last_error)
236
253
  row("last_error", h.last_error);
237
254
  row("status", warn ? `UNHEALTHY - ${warn}` : "ok");
255
+ // 版本提示用上次 report 快取下來的結果,不為了顯示這一行去打網路。
256
+ const note = updateNotice();
257
+ if (note)
258
+ console.log(` ${"update".padEnd(20)}: ${note}`);
238
259
  return warn ? 1 : 0;
239
260
  }
package/dist/doctor.js CHANGED
@@ -19,6 +19,7 @@ import { claudeDir, claudeSettingsPath, configPath, credsPath, devicePath, proje
19
19
  import { healthWarning, loadHealth } from "./health.js";
20
20
  import { readJson } from "./util.js";
21
21
  import { VERSION } from "./version.js";
22
+ import { updateNotice } from "./selfupdate.js";
22
23
  const ok = (s) => ` [ok] ${s}`;
23
24
  const bad = (s) => ` [BAD] ${s}`;
24
25
  const info = (s) => ` [info] ${s}`;
@@ -60,6 +61,10 @@ export async function doctor() {
60
61
  const problems = [];
61
62
  const out = [];
62
63
  out.push(`claude-usage doctor v${VERSION}`);
64
+ // 舊版是很多疑難雜症的根因,所以擺在最上面而不是埋在結論裡。
65
+ const upd = updateNotice();
66
+ if (upd)
67
+ out.push(` ! ${upd}`);
63
68
  out.push("");
64
69
  out.push("環境");
65
70
  out.push(info(`平台 ${platform()} ${release()} node ${process.version}`));
package/dist/install.js CHANGED
@@ -141,7 +141,32 @@ function winHardenTask() {
141
141
  "-AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable)");
142
142
  return false;
143
143
  }
144
+ /**
145
+ * 安裝時檢查 PowerShell 執行原則。
146
+ *
147
+ * ★ 為什麼這段從 postinstall.mjs 搬到這裡:npm 11 起預設**不執行**未核准的
148
+ * postinstall 腳本(安裝時只印 `npm warn allow-scripts`),所以那支檔案在新版 npm
149
+ * 上根本不會跑 —— 它原本的唯一價值(在使用者撞牆前先講)已經失效。
150
+ * `install` 是 `npm i -g` 之後緊接著要跑的指令,把提示放這裡才真的看得到。
151
+ *
152
+ * ★ 為什麼仍然要提示而不是自動修:改動使用者機器的安全設定,該由他自己決定。
153
+ */
154
+ function warnExecutionPolicy() {
155
+ const r = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", "Get-ExecutionPolicy"], { encoding: "utf8", timeout: 10_000, windowsHide: true });
156
+ const policy = r.error || r.status !== 0 ? null : (r.stdout ?? "").trim();
157
+ // 只在**實際會擋**的機器上印。無條件每次都印,就是在訓練人忽略它。
158
+ if (policy !== "Restricted" && policy !== "AllSigned")
159
+ return;
160
+ console.log(`\n! PowerShell 執行原則是 ${policy} —— 直接打 claude-usage 會被擋\n` +
161
+ " (錯誤含 UnauthorizedAccess / PSSecurityException / ExecutionPolicy)\n" +
162
+ " 擇一處理:\n" +
163
+ " 1) Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned\n" +
164
+ " 設定一次、永久生效、免系統管理員。\n" +
165
+ " 2) 不改原則,改叫 claude-usage.cmd(功能完全一樣)\n" +
166
+ " 公司用 GPO 鎖了機器層級原則時 (1) 會被覆寫 —— 直接用 (2)。\n");
167
+ }
144
168
  function winInstall() {
169
+ warnExecutionPolicy(); // 先講,因為接下來每一步都可能因為它而失敗
145
170
  const vbs = `Set s = CreateObject("Wscript.Shell")\r\n` +
146
171
  `s.Run """${NODE}"" ""${CLI}"" daemon-tick", 0, False\r\n`;
147
172
  writeFileSync(VBS_PATH(), vbs, "utf8");
package/dist/paths.js CHANGED
@@ -31,6 +31,8 @@ export const configPath = () => join(stateDir(), "config.json");
31
31
  export const healthPath = () => join(stateDir(), "health.json");
32
32
  /** 上報游標:每個 JSONL 檔讀到第幾個 byte */
33
33
  export const cursorPath = () => join(stateDir(), "cursor.json");
34
+ /** 自我更新的節流狀態。**必須落地**:daemon 每次 tick 都是新行程,記憶體留不住。 */
35
+ export const updatePath = () => join(stateDir(), "update.json");
34
36
  /** daemon 的 stdout/stderr(背景任務寫的) */
35
37
  export const logPath = () => join(stateDir(), "daemon.log");
36
38
  /**
@@ -0,0 +1,185 @@
1
+ /**
2
+ * 自我更新 —— 由 server 指定版本,client 執行。
3
+ *
4
+ * ★ 為什麼版本由 server 給,而不是 client 去問 npm:
5
+ * 1. **安全**:`npm i -g pkg@latest` 等於「誰能發布 npm,誰就決定同事機器上跑什麼」。
6
+ * 由 server 指定確切版本號,權威在我們手上 —— 有人拿到 npm token 發了新版,
7
+ * 只要 server 沒說要,沒有任何一台會裝。
8
+ * 2. **煞車**:發現壞版本時改一個環境變數就全隊停住,比 npm deprecate 快得多。
9
+ * 3. **成本**:版本資訊搭 /v1/report 的回應,零額外網路呼叫。daemon 每 5 分鐘跑一次,
10
+ * 去問 npm registry 會變成每台每天 288 次。
11
+ * 4. **可達性**:公司網路擋 npmjs 時仍然運作(我們的 server 本來就通)。
12
+ *
13
+ * ⚠️ 這個檔案讓「能發布 npm 套件」變成「能在同事機器上執行程式碼」。所有護欄都是
14
+ * 為了這件事存在的,刪任何一條之前先想清楚。
15
+ */
16
+ import { spawnSync } from "node:child_process";
17
+ import { accessSync, constants } from "node:fs";
18
+ import { join } from "node:path";
19
+ import { updatePath } from "./paths.js";
20
+ import { readJson, writeJsonAtomic, nowIso } from "./util.js";
21
+ import { VERSION } from "./version.js";
22
+ /** 兩次自我更新嘗試的最小間隔。壞版本會讓它每 5 分鐘重裝一次,把機器打爛。 */
23
+ export const RETRY_COOLDOWN_HOURS = 24;
24
+ export const loadUpdateState = () => readJson(updatePath()) ?? {};
25
+ export const saveUpdateState = (s) => writeJsonAtomic(updatePath(), s);
26
+ /**
27
+ * 語意化版本比較。回 -1 / 0 / 1。
28
+ * 只比對 major.minor.patch 的數字部分;預發行標籤(-beta.1)一律當成比正式版小。
29
+ */
30
+ export function cmpVersion(a, b) {
31
+ const parse = (v) => {
32
+ const [core = "", pre = ""] = v.split("-", 2);
33
+ const nums = core.split(".").map((x) => Number.parseInt(x, 10) || 0);
34
+ return { nums, pre };
35
+ };
36
+ const A = parse(a), B = parse(b);
37
+ for (let i = 0; i < 3; i++) {
38
+ const d = (A.nums[i] ?? 0) - (B.nums[i] ?? 0);
39
+ if (d !== 0)
40
+ return d > 0 ? 1 : -1;
41
+ }
42
+ if (A.pre === B.pre)
43
+ return 0;
44
+ if (!A.pre)
45
+ return 1; // 正式版 > 預發行
46
+ if (!B.pre)
47
+ return -1;
48
+ return A.pre > B.pre ? 1 : -1;
49
+ }
50
+ /** npm 全域安裝目錄。拿不到就回 null(代表我們無從判斷可不可寫)。 */
51
+ function globalPrefix() {
52
+ const r = spawnSync("npm", ["prefix", "-g"], {
53
+ encoding: "utf8", timeout: 20_000, windowsHide: true, shell: process.platform === "win32",
54
+ });
55
+ if (r.error || r.status !== 0)
56
+ return null;
57
+ return (r.stdout ?? "").trim() || null;
58
+ }
59
+ /**
60
+ * 全域目錄可不可寫。
61
+ *
62
+ * ★ 為什麼一定要先檢查:node 裝在 /usr/local 或 Program Files 時,`npm i -g` 需要
63
+ * sudo/系統管理員。從 daemon 跑必定失敗,而且是每 24 小時失敗一次、永遠不會好。
64
+ * 偵測得出來就不該去撞牆 —— 改成在 status/doctor 提示人工升級。
65
+ */
66
+ export function prefixWritable() {
67
+ const p = globalPrefix();
68
+ if (!p)
69
+ return null;
70
+ try {
71
+ accessSync(p, constants.W_OK);
72
+ return true;
73
+ }
74
+ catch {
75
+ return false;
76
+ }
77
+ }
78
+ /**
79
+ * 要不要更新。**純函式,不做 I/O**,所以護欄邏輯測得到 —— 這是整個功能最需要被測的部分。
80
+ */
81
+ export function decideUpdate(ch, st, now, opts = {}) {
82
+ const cur = opts.current ?? VERSION;
83
+ if (opts.disabled)
84
+ return { act: false, reason: "CU_NO_AUTOUPDATE=1(本機關閉)" };
85
+ if (!ch?.latest)
86
+ return { act: false, reason: "server 未啟用版本頻道" };
87
+ if (ch.autoupdate !== true)
88
+ return { act: false, reason: "server 只告知、未授權自動更新" };
89
+ const c = cmpVersion(ch.latest, cur);
90
+ if (c <= 0)
91
+ return { act: false, reason: `已是最新(本機 ${cur})` };
92
+ // ★ 不跨 major:major 依定義有破壞性變更,該由人決定何時承受。
93
+ const majFrom = Number.parseInt(cur.split(".")[0] ?? "0", 10) || 0;
94
+ const majTo = Number.parseInt(ch.latest.split(".")[0] ?? "0", 10) || 0;
95
+ if (majTo !== majFrom) {
96
+ return { act: false, reason: `跨 major(${cur} → ${ch.latest})需人工升級` };
97
+ }
98
+ // ★ 節流。目標版本換了就重置 —— 否則發了修正版還要再等滿 24 小時才會裝。
99
+ if (st.last_attempt_at && st.last_attempt_version === ch.latest) {
100
+ const hrs = (now.getTime() - Date.parse(st.last_attempt_at)) / 3_600_000;
101
+ if (Number.isFinite(hrs) && hrs < RETRY_COOLDOWN_HOURS) {
102
+ return { act: false, reason: `${RETRY_COOLDOWN_HOURS}h 內已試過 ${ch.latest}(${hrs.toFixed(1)}h 前)` };
103
+ }
104
+ }
105
+ return { act: true, from: cur, to: ch.latest };
106
+ }
107
+ /** 實際執行 npm 安裝。**釘死版本號**,不用 @latest —— 見檔頭。 */
108
+ function runInstall(version) {
109
+ const pkg = `@tsa-group/claude-usage@${version}`;
110
+ const r = spawnSync("npm", ["install", "-g", pkg, "--no-fund", "--no-audit"], {
111
+ encoding: "utf8", timeout: 300_000, windowsHide: true,
112
+ shell: process.platform === "win32", // Windows 上 npm 是 .cmd,需要 shell 才找得到
113
+ });
114
+ if (r.error)
115
+ return { ok: false, detail: `spawn 失敗: ${r.error.message}` };
116
+ if (r.status !== 0) {
117
+ const tail = ((r.stderr ?? "") + (r.stdout ?? "")).trim().split("\n").slice(-3).join(" / ");
118
+ return { ok: false, detail: `npm rc=${r.status}: ${tail.slice(0, 300)}` };
119
+ }
120
+ return { ok: true, detail: `installed ${version}` };
121
+ }
122
+ /**
123
+ * 檢查並(在允許時)自我更新。回一句給 daemon.log 的話。
124
+ *
125
+ * ★ 絕不拋例外:daemon 的本業是採樣,不是自我維護。更新壞掉不能影響 tick。
126
+ */
127
+ export function maybeSelfUpdate(ch) {
128
+ let st;
129
+ try {
130
+ st = loadUpdateState();
131
+ }
132
+ catch {
133
+ st = {};
134
+ }
135
+ // 不論要不要更新,都把 server 說的最新版存起來 —— status/doctor 靠它顯示提示,
136
+ // 而它們不該為了知道版本而自己去打網路。
137
+ if (ch?.latest && st.latest !== ch.latest) {
138
+ st.latest = ch.latest;
139
+ try {
140
+ saveUpdateState(st);
141
+ }
142
+ catch { /* 存不了就算了,下次還會再存 */ }
143
+ }
144
+ const d = decideUpdate(ch, st, new Date(), {
145
+ disabled: process.env["CU_NO_AUTOUPDATE"] === "1",
146
+ });
147
+ if (!d.act)
148
+ return `update: skip (${d.reason})`;
149
+ const w = prefixWritable();
150
+ if (w === false) {
151
+ return "update: skip (npm 全域目錄不可寫 —— 需要人工升級,見 doctor)";
152
+ }
153
+ st.last_attempt_at = nowIso();
154
+ st.last_attempt_version = d.to;
155
+ const res = runInstall(d.to);
156
+ st.last_result = res.ok ? "ok" : res.detail.slice(0, 200);
157
+ try {
158
+ saveUpdateState(st);
159
+ }
160
+ catch { /* 同上 */ }
161
+ return res.ok
162
+ // 不重啟自己:排程指向絕對路徑,npm 就地換檔,下一個 tick 自然用新版。
163
+ ? `update: ${d.from} → ${d.to} 成功(下次 tick 起生效)`
164
+ : `update: ${d.from} → ${d.to} 失敗 — ${res.detail}`;
165
+ }
166
+ /** 給 status / doctor 用的一行提示。沒有新版就回 null。 */
167
+ export function updateNotice() {
168
+ let st;
169
+ try {
170
+ st = loadUpdateState();
171
+ }
172
+ catch {
173
+ return null;
174
+ }
175
+ if (!st.latest || cmpVersion(st.latest, VERSION) <= 0)
176
+ return null;
177
+ const how = prefixWritable() === false
178
+ ? "npm 全域目錄需要權限,請用系統管理員身分執行"
179
+ : "npm i -g @tsa-group/claude-usage@latest";
180
+ const failed = st.last_result && st.last_result !== "ok"
181
+ ? ` (自動更新上次失敗: ${st.last_result})` : "";
182
+ return `有新版 ${st.latest}(本機 ${VERSION})—— ${how}${failed}`;
183
+ }
184
+ /** 供測試覆寫用的路徑組裝(正式路徑在 paths.ts) */
185
+ export const _updateFileName = (dir) => join(dir, "update.json");
package/dist/upload.js CHANGED
@@ -25,6 +25,9 @@ import * as events from "./events.js";
25
25
  import { devicePath, serverUrl } from "./paths.js";
26
26
  import { readJson, writeJsonAtomic } from "./util.js";
27
27
  import { VERSION } from "./version.js";
28
+ /** 最近一次 report 回應帶回來的版本頻道。null = server 未啟用或尚未回報過。 */
29
+ let lastAgentChannel = null;
30
+ export const agentChannel = () => lastAgentChannel;
28
31
  async function post(path, obj, bearer) {
29
32
  const headers = { "Content-Type": "application/json" };
30
33
  if (bearer)
@@ -172,6 +175,9 @@ export async function report(argv = []) {
172
175
  }
173
176
  payload.device_id = dev.device_id;
174
177
  const { status, body } = await post("/v1/report", payload, dev.device_key);
178
+ // server 會在回應帶版本頻道(沒啟用時整個欄位不存在)。存起來給 daemon 決策用 ——
179
+ // 這是**唯一**的版本來源,client 永遠不自己去問 npm registry。見 selfupdate.ts 檔頭。
180
+ lastAgentChannel = body?.agent ?? null;
175
181
  const kind = anyRows ? "report" : "heartbeat";
176
182
  console.log(`${kind} -> ${status}: ${JSON.stringify(body)}`);
177
183
  console.log(` sent: ${n.limit_snapshots} snapshots, ${n.sessions} sessions, ` +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tsa-group/claude-usage",
3
- "version": "0.4.6",
3
+ "version": "0.4.8",
4
4
  "description": "Per-user Claude usage collector — measures Claude Code token detail and account-level rate-limit utilization locally, reports to your own ingest server.",
5
5
  "type": "module",
6
6
  "bin": {
package/postinstall.mjs CHANGED
@@ -1,6 +1,11 @@
1
1
  /**
2
2
  * 安裝後提示:Windows 的 PowerShell 執行原則會擋掉 npm 產生的 .ps1 啟動器。
3
3
  *
4
+ * ⚠️ npm 11 起預設**不執行**未核准的 postinstall(只印 `npm warn allow-scripts`),
5
+ * 所以這支檔案在新版 npm 上不會跑。同樣的檢查已於 v0.4.8 搬進 `install`
6
+ * 與 `doctor`(見 src/install.ts warnExecutionPolicy)。這裡保留是因為在
7
+ * 舊版 npm 上它仍然是唯一能「撞牆之前先說話」的時機。
8
+ *
4
9
  * ★ 為什麼一定要在這裡講:這個失敗發生時**我們的程式碼根本沒機會執行** ——
5
10
  * PowerShell 在 Node 啟動前就拒絕載入 `claude-usage.ps1` 了。所以工具永遠無法在
6
11
  * 出錯當下自我診斷,提示只能前置,而 postinstall 是唯一「撞牆之前還能說話」的