@tsa-group/claude-usage 0.4.6 → 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 +15 -0
- package/dist/daemon.js +22 -1
- package/dist/doctor.js +5 -0
- 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 可能變更。
|
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/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": {
|