@tsa-group/claude-usage 0.4.5 → 0.4.7
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 +22 -2
- package/dist/daemon.js +22 -1
- package/dist/doctor.js +5 -0
- package/dist/install.js +75 -6
- package/dist/paths.js +2 -0
- package/dist/selfupdate.js +185 -0
- package/dist/upload.js +6 -0
- package/package.json +1 -1
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 可能變更。
|
|
@@ -340,8 +355,13 @@ Client 對 server 只用兩個端點,皆為 `application/json`:
|
|
|
340
355
|
`oauth:tokenCacheV2`,以 Chromium 的 `v10` 方案(DPAPI + AES-256-GCM)加密。
|
|
341
356
|
這是**未公開的內部格式**,Anthropic 改版就可能失效 —— 與 `/api/oauth/usage` 同一個
|
|
342
357
|
風險類別。失效時會在 `claude-usage doctor` 顯示卡在哪一步,不會靜默。
|
|
343
|
-
- **Windows
|
|
344
|
-
|
|
358
|
+
- **Windows 筆電請確認電源設定**:`schtasks` 建立的工作預設「只在接上電源時執行」、
|
|
359
|
+
「拔電就中斷」、且「錯過的排程不補跑」。v0.4.6 起 `install` 會自動放寬這三項並讀回
|
|
360
|
+
驗證;**v0.4.5 以前註冊的工作不會自動修正**,請重跑一次 `claude-usage install`。
|
|
361
|
+
`claude-usage status` 會在設定不對時明講。
|
|
362
|
+
(實測:2026-08-29 四台 Win11 因此全數斷線 10.7–18.1 小時,其中一台累計只跑過 7 次。)
|
|
363
|
+
- **重疊的 tick 尚未互斥**:VBS 以非阻塞方式啟動 node,工作排程器認為任務瞬間完成,
|
|
364
|
+
所以 `MultipleInstances` 擋不住重疊的 node 行程。極少數情況下會競寫 `health.json`。
|
|
345
365
|
- **PowerShell 執行原則**:預設 `Restricted` 會同時擋掉 `npm.ps1`(安裝時)與
|
|
346
366
|
`claude-usage.ps1`(執行時)。處理方式見上面 **Windows → 第 0 步**。
|
|
347
367
|
- **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
|
@@ -90,9 +90,57 @@ function macStatus() {
|
|
|
90
90
|
return 0;
|
|
91
91
|
}
|
|
92
92
|
// ────────────────────────────── Windows ──────────────────────────────
|
|
93
|
-
//
|
|
93
|
+
// 實機狀態(2026-08-29,四台 Win11 10.0.26200):
|
|
94
|
+
// ✅ 已驗證:工作註冊成功、VBS 隱藏視窗可用、tick 會執行並成功上傳
|
|
95
|
+
// ✅ 已修正:電源預設值害它在電池下不執行(見 winHardenTask)
|
|
96
|
+
// ⚠️ 未驗證:winHardenTask 本身 —— 寫完當下沒有 Windows 機器可測,所以它**讀回設定
|
|
97
|
+
// 並印出結果**,讓第一個安裝的人一眼看得出有沒有生效。
|
|
98
|
+
//
|
|
99
|
+
// ⚠️ 已知且**本次未解決**:VBS 用 s.Run(..., 0, False) 非阻塞啟動 node,wscript 立刻
|
|
100
|
+
// 返回,所以工作排程器認為任務瞬間完成。這代表 -MultipleInstances IgnoreNew
|
|
101
|
+
// 擋不住重疊的 **node** 行程(它擋的是重疊的「工作」,而工作早就結束了)。
|
|
102
|
+
// 重疊的 tick 會競寫 health.json —— 那正是先前 consecutive_failures 卡在 152 的成因。
|
|
103
|
+
// 要真正解決得在 daemon 內加檔案鎖,不屬於這次的範圍。
|
|
94
104
|
/** 隱藏視窗的啟動器:node 沒有 pythonw 那種無 console 的變體,用 VBS 包一層是標準做法。 */
|
|
95
105
|
const VBS_PATH = () => join(stateDir(), "run-hidden.vbs");
|
|
106
|
+
/**
|
|
107
|
+
* schtasks.exe 建立的工作有三個**預設值會讓它在筆電上幾乎不執行**,而 schtasks 沒有
|
|
108
|
+
* 任何旗標可以覆蓋它們 —— 只能事後用 PowerShell 的 ScheduledTasks cmdlet 改。
|
|
109
|
+
*
|
|
110
|
+
* DisallowStartIfOnBatteries = True → 靠電池時**根本不啟動**
|
|
111
|
+
* StopIfGoingOnBatteries = True → 執行中拔掉電源就中斷
|
|
112
|
+
* StartWhenAvailable = False → 錯過的排程不補跑,開機後不會自己追上
|
|
113
|
+
*
|
|
114
|
+
* ★ 這不是臆測,是實機資料逼出來的:2026-08-29 四台 Windows 全數斷線 10.7–18.1
|
|
115
|
+
* 小時,其中一台安裝後累計只跑過 7 次 tick(約 35 分鐘)。同一時間唯一存活的是
|
|
116
|
+
* macOS 那台(launchd 沒有這些預設)。症狀完全吻合「大部分時間用電池」。
|
|
117
|
+
*
|
|
118
|
+
* ★ 為什麼要讀回來驗證:我們沒有 Windows 機器可以測這段。`Set-ScheduledTask` 失敗
|
|
119
|
+
* 時未必回非零,所以不能只看 rc —— 讓它自己把結果印出來,成不成一眼可見。
|
|
120
|
+
*
|
|
121
|
+
* 失敗不是致命的:工作本身還在,只是接上電源時才會跑。所以回 boolean 由呼叫端決定
|
|
122
|
+
* 要不要當成錯誤,而不是在這裡 return 1 把整個 install 判定成失敗。
|
|
123
|
+
*/
|
|
124
|
+
function winHardenTask() {
|
|
125
|
+
const cmd = `$ErrorActionPreference='Stop';` +
|
|
126
|
+
`Set-ScheduledTask -TaskName '${WIN_TASK}' -Settings (New-ScheduledTaskSettingsSet ` +
|
|
127
|
+
`-AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable ` +
|
|
128
|
+
`-MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Minutes 10)) | Out-Null;` +
|
|
129
|
+
`$s=(Get-ScheduledTask -TaskName '${WIN_TASK}').Settings;` +
|
|
130
|
+
`"$($s.DisallowStartIfOnBatteries)|$($s.StopIfGoingOnBatteries)|$($s.StartWhenAvailable)"`;
|
|
131
|
+
const r = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", cmd], { encoding: "utf8", timeout: 20_000, windowsHide: true });
|
|
132
|
+
const got = (r.stdout ?? "").trim();
|
|
133
|
+
// 期望 False|False|True。任何其他值都代表沒真的套用上去。
|
|
134
|
+
if (r.status === 0 && got === "False|False|True") {
|
|
135
|
+
console.log("schtasks: 已放寬電源限制(電池下照跑、錯過會補跑)");
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
console.error(`! 電源設定沒有套用成功(rc=${r.status}, 讀回 "${got || "無輸出"}")。\n` +
|
|
139
|
+
" 工作仍然存在,但**只有接上電源時才會執行**。手動補上(單行,別換行):\n" +
|
|
140
|
+
` Set-ScheduledTask -TaskName ${WIN_TASK} -Settings (New-ScheduledTaskSettingsSet ` +
|
|
141
|
+
"-AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable)");
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
96
144
|
function winInstall() {
|
|
97
145
|
const vbs = `Set s = CreateObject("Wscript.Shell")\r\n` +
|
|
98
146
|
`s.Run """${NODE}"" ""${CLI}"" daemon-tick", 0, False\r\n`;
|
|
@@ -110,6 +158,7 @@ function winInstall() {
|
|
|
110
158
|
console.log(`wrote ${VBS_PATH()}`);
|
|
111
159
|
if (r.status === 0) {
|
|
112
160
|
console.log(`schtasks: 已建立工作 "${WIN_TASK}",每 ${Math.max(1, Math.floor(BASE_INTERVAL_SEC / 60))} 分鐘執行一次`);
|
|
161
|
+
winHardenTask(); // 建立完才能改設定;失敗只警告,工作本身仍可用
|
|
113
162
|
}
|
|
114
163
|
else {
|
|
115
164
|
// 失敗才印原始輸出。用 latin1 至少保證位元組不失真,並告訴使用者怎麼看到真訊息。
|
|
@@ -119,9 +168,8 @@ function winInstall() {
|
|
|
119
168
|
console.error(` 手動執行這行可看到正確訊息: schtasks ${args.map((a) => (a.includes(" ") ? `"${a}"` : a)).join(" ")}`);
|
|
120
169
|
}
|
|
121
170
|
console.log(`uninstall: schtasks /Delete /TN ${WIN_TASK} /F`);
|
|
122
|
-
console.log("!
|
|
123
|
-
"
|
|
124
|
-
" 裝完請跑 claude-usage status 確認有心跳。");
|
|
171
|
+
console.log("! 裝完請跑 claude-usage status 確認有心跳,並確認電源設定那一行沒有警告。\n" +
|
|
172
|
+
" 若你是筆電使用者,這一步特別重要 —— 舊版的排程在電池下不會執行。");
|
|
125
173
|
return r.status ?? 1;
|
|
126
174
|
}
|
|
127
175
|
function winUninstall() {
|
|
@@ -150,18 +198,39 @@ function winStatus() {
|
|
|
150
198
|
`$i=Get-ScheduledTaskInfo -TaskName '${WIN_TASK}';` +
|
|
151
199
|
`$last=if($i.LastRunTime){$i.LastRunTime.ToString('yyyy-MM-dd HH:mm')}else{'-'};` +
|
|
152
200
|
`$next=if($i.NextRunTime){$i.NextRunTime.ToString('yyyy-MM-dd HH:mm')}else{'-'};` +
|
|
153
|
-
|
|
201
|
+
// 電源設定一併帶回來:已經裝好的機器不會再跑 install,若不在 status 顯示,
|
|
202
|
+
// 那些工作會永遠停在「只有插電才跑」而沒有任何地方看得出來。
|
|
203
|
+
`$s=$t.Settings;` +
|
|
204
|
+
`"$($t.State)|$($i.LastTaskResult)|$last|$next` +
|
|
205
|
+
`|$($s.DisallowStartIfOnBatteries)|$($s.StopIfGoingOnBatteries)|$($s.StartWhenAvailable)"}`;
|
|
154
206
|
const r = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", cmd], { encoding: "utf8", timeout: 15_000, windowsHide: true });
|
|
155
207
|
const line = (r.stdout ?? "").trim();
|
|
156
208
|
if (r.status !== 0 || !line || line === "MISSING") {
|
|
157
209
|
console.log(`bg-task: NOT installed —— 跑 claude-usage install 重新註冊`);
|
|
158
210
|
return 1;
|
|
159
211
|
}
|
|
160
|
-
const [state, resultRaw, last, next] = line.split("|");
|
|
212
|
+
const [state, resultRaw, last, next, noBatt, stopBatt, catchUp] = line.split("|");
|
|
161
213
|
const result = Number(resultRaw);
|
|
162
214
|
const meaning = TASK_RESULT[result] ?? `結束碼 ${resultRaw}`;
|
|
163
215
|
console.log(`bg-task: 已註冊 "${WIN_TASK}" 狀態=${state} 上次=${last} 下次=${next}`);
|
|
164
216
|
console.log(` ${meaning}`);
|
|
217
|
+
// 舊版 client 註冊的工作沒有放寬電源限制 —— 那會表現成「白天在辦公室有心跳、
|
|
218
|
+
// 拔線後整段消失」。這裡把它講成可行動的一句話,而不是留給人去猜為什麼會斷。
|
|
219
|
+
// 欄位可能不存在(極舊的 PowerShell 或未來格式變動)→ 缺欄位就安靜跳過。
|
|
220
|
+
if (noBatt !== undefined) {
|
|
221
|
+
const bad = [];
|
|
222
|
+
if (noBatt === "True")
|
|
223
|
+
bad.push("靠電池時不啟動");
|
|
224
|
+
if (stopBatt === "True")
|
|
225
|
+
bad.push("拔電源就中斷");
|
|
226
|
+
if (catchUp === "False")
|
|
227
|
+
bad.push("錯過的排程不補跑");
|
|
228
|
+
if (bad.length) {
|
|
229
|
+
console.log(` ! 電源設定會讓它在筆電上幾乎不執行:${bad.join("、")}`);
|
|
230
|
+
console.log(` 修正: claude-usage install (會就地重新註冊並放寬限制)`);
|
|
231
|
+
return 1;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
165
234
|
// 只有「成功」與「還沒跑過」算正常;其餘代表背景任務其實沒在做事。
|
|
166
235
|
if (result !== 0 && result !== 267011 && result !== 267009) {
|
|
167
236
|
console.log(` ! 背景任務上次沒有正常結束 —— 細節: schtasks /Query /TN ${WIN_TASK} /V /FO LIST`);
|
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.
|
|
3
|
+
"version": "0.4.7",
|
|
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": {
|