usagemax 0.3.6 → 0.3.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 +25 -9
- package/package.json +4 -2
- package/src/cli.js +212 -73
- package/src/progress.js +73 -0
- package/src/resume.js +3 -1
- package/src/sources.js +1 -1
- package/src/updates.js +99 -0
package/README.md
CHANGED
|
@@ -16,6 +16,10 @@ integrations, use the documented [OpenAPI contract](https://usagemax.com/openapi
|
|
|
16
16
|
or the public [agent surfaces](https://usagemax.com/?mode=agent); the package
|
|
17
17
|
itself is intended to be invoked as a short-lived local process.
|
|
18
18
|
|
|
19
|
+
See the repository's [SDK and integration surface](../../docs/sdk-ecosystem.md)
|
|
20
|
+
for the supported client matrix. UsageMax does not advertise Python or Go
|
|
21
|
+
packages until they are separately reviewed and published.
|
|
22
|
+
|
|
19
23
|
## Requirements
|
|
20
24
|
|
|
21
25
|
- Node.js 20 or newer
|
|
@@ -26,22 +30,30 @@ itself is intended to be invoked as a short-lived local process.
|
|
|
26
30
|
|
|
27
31
|
```bash
|
|
28
32
|
# Create a one-use code at https://usagemax.com/account.
|
|
29
|
-
bunx usagemax
|
|
33
|
+
bunx usagemax link UMX-XXXX-XXXX-XXXX-XXXX
|
|
30
34
|
|
|
31
35
|
# npm users can run the same one-shot command with npx.
|
|
32
|
-
npx --yes usagemax
|
|
36
|
+
npx --yes usagemax link UMX-XXXX-XXXX-XXXX-XXXX
|
|
33
37
|
|
|
34
38
|
# Preview, then upload changed local usage.
|
|
35
39
|
bunx usagemax sync --dry-run --explain
|
|
36
40
|
bunx usagemax sync
|
|
37
41
|
```
|
|
38
42
|
|
|
43
|
+
The CLI checks npm's `latest` dist-tag at most twice per day and never replaces
|
|
44
|
+
itself silently. Run `usagemax update sync` to hand a command to the current
|
|
45
|
+
release without typing `@latest`, or set `USAGEMAX_AUTO_UPDATE=1` for an
|
|
46
|
+
explicit automatic handoff. Use `--no-update-check` or set
|
|
47
|
+
`USAGEMAX_DISABLE_UPDATE_CHECK=1` in offline environments. Interactive
|
|
48
|
+
terminals show a small stderr progress line; JSON,
|
|
49
|
+
quiet, CI, and scheduled runs remain machine-readable and quiet.
|
|
50
|
+
|
|
39
51
|
Agent-friendly checks can request JSON and keep the secret out of arguments and
|
|
40
52
|
logs. This example only inspects local source coverage:
|
|
41
53
|
|
|
42
54
|
```bash
|
|
43
55
|
set +x
|
|
44
|
-
bunx usagemax doctor --deep --json | jq '{complete, sources:
|
|
56
|
+
bunx usagemax doctor --deep --json | jq '{complete: .inventoryComplete, sources: .detectedSources}'
|
|
45
57
|
```
|
|
46
58
|
|
|
47
59
|
The JSON shape is intended for local automation; unsupported or unavailable
|
|
@@ -80,11 +92,14 @@ bunx usagemax sync --restart # restart an expired saved upload
|
|
|
80
92
|
bunx usagemax status --remote --json # remote check; secret is never printed
|
|
81
93
|
bunx usagemax doctor --deep --json # parse and audit retained history
|
|
82
94
|
bunx usagemax link UMX-… --no-sync # link without uploading yet
|
|
95
|
+
bunx usagemax --version --json # print CLI and ccusage versions
|
|
96
|
+
bunx usagemax update --json # machine-readable release check
|
|
97
|
+
bunx usagemax unlink --json # safe automation result; no secret output
|
|
83
98
|
```
|
|
84
99
|
|
|
85
100
|
## Sources and coverage
|
|
86
101
|
|
|
87
|
-
UsageMax pins [ccusage v20.0.
|
|
102
|
+
UsageMax pins [ccusage v20.0.23](https://github.com/ccusage/ccusage/releases/tag/v20.0.23)
|
|
88
103
|
and supports its 16 adapters: Amp, Claude Code, Codebuff, Codex, GitHub Copilot
|
|
89
104
|
CLI, Factory Droid, Gemini CLI, Goose, Grok Build, Hermes, Kilo Code, Kimi CLI,
|
|
90
105
|
OpenClaw, OpenCode, Pi, and Qwen Code. Named Pi-format stores are discovered as
|
|
@@ -115,14 +130,15 @@ stdin; never pass it as an argument or put it in a URL:
|
|
|
115
130
|
```bash
|
|
116
131
|
set +x
|
|
117
132
|
printf '%s' "$USAGEMAX_COLLECTOR_TOKEN" \
|
|
118
|
-
| bunx usagemax
|
|
133
|
+
| bunx usagemax token status \
|
|
119
134
|
--device-id "$USAGEMAX_INSTALLATION_ID" \
|
|
120
135
|
--json
|
|
121
136
|
```
|
|
122
137
|
|
|
123
|
-
The `token status` command is included in CLI `0.3.
|
|
124
|
-
|
|
125
|
-
from
|
|
138
|
+
The `token status` command is included in CLI `0.3.8`. If a fresh environment
|
|
139
|
+
still has an older npm tag, run `usagemax update token status` or
|
|
140
|
+
`node packages/cli/src/cli.js token status` from this repository until the new
|
|
141
|
+
package is published.
|
|
126
142
|
|
|
127
143
|
The response is read-only and contains only status, type, scopes, profile/name,
|
|
128
144
|
activation state, and a binding result of `unbound`, `bound`, `matched`, or
|
|
@@ -133,7 +149,7 @@ For a numeric-only result suitable for a smoke check:
|
|
|
133
149
|
```bash
|
|
134
150
|
set +x
|
|
135
151
|
printf '%s' "$USAGEMAX_COLLECTOR_TOKEN" \
|
|
136
|
-
| bunx usagemax
|
|
152
|
+
| bunx usagemax token status \
|
|
137
153
|
--device-id "$USAGEMAX_INSTALLATION_ID" --json \
|
|
138
154
|
| jq -r '[.httpStatus, (if .ingestAuthorized then 1 else 0 end)] | @tsv'
|
|
139
155
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "usagemax",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.8",
|
|
4
4
|
"description": "Link local coding-agent usage to your UsageMax profile",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"usagemax",
|
|
@@ -38,6 +38,8 @@
|
|
|
38
38
|
"src/installation.js",
|
|
39
39
|
"src/sources.js",
|
|
40
40
|
"src/transport.js",
|
|
41
|
+
"src/progress.js",
|
|
42
|
+
"src/updates.js",
|
|
41
43
|
"src/resume.js",
|
|
42
44
|
"src/service.js",
|
|
43
45
|
"README.md",
|
|
@@ -55,6 +57,6 @@
|
|
|
55
57
|
"prepublishOnly": "npm test"
|
|
56
58
|
},
|
|
57
59
|
"dependencies": {
|
|
58
|
-
"ccusage": "20.0.
|
|
60
|
+
"ccusage": "20.0.23"
|
|
59
61
|
}
|
|
60
62
|
}
|
package/src/cli.js
CHANGED
|
@@ -13,10 +13,12 @@ import { fileURLToPath } from "node:url";
|
|
|
13
13
|
import { prepareArchiveRecovery } from "./archives.js";
|
|
14
14
|
import { buildSessionPlan, buildSnapshotPlan, normalizeLinkCode, reportDateArgs, scanPolicy, sourceSummary, validHttpsUrl } from "./core.js";
|
|
15
15
|
import { stableInstallationId } from "./installation.js";
|
|
16
|
+
import { createProgress } from "./progress.js";
|
|
16
17
|
import { intervalMinutes, manageService, runScheduledSync } from "./service.js";
|
|
17
18
|
import { collectorStatusView, requestCollectorStatus, requestSnapshot } from "./transport.js";
|
|
18
19
|
import { resumeUpload, restartExpiredUpload, withConfigLock } from "./resume.js";
|
|
19
20
|
import { CCUSAGE_VERSION, ccusageEnvironment, ccusageHome, discoverProviderArchives, SOURCE_INVENTORY_VERSION, sourceInventory, SUPPORTED_SOURCES } from "./sources.js";
|
|
21
|
+
import { checkForUpdate, runLatest } from "./updates.js";
|
|
20
22
|
|
|
21
23
|
// Make the short-lived collector recognizable in Activity Monitor and `ps`.
|
|
22
24
|
// Windows may still display the underlying node.exe image name in Task Manager.
|
|
@@ -24,7 +26,7 @@ process.title = "UsageMax";
|
|
|
24
26
|
|
|
25
27
|
const require = createRequire(import.meta.url);
|
|
26
28
|
const executeFile = promisify(execFile);
|
|
27
|
-
const VERSION = "0.3.
|
|
29
|
+
const VERSION = "0.3.8";
|
|
28
30
|
const PUBLIC_API_ORIGIN = "https://usagemax.com/api";
|
|
29
31
|
const DEFAULT_LINK_ENDPOINT = `${PUBLIC_API_ORIGIN}/v1/devices/link`;
|
|
30
32
|
const DEFAULT_STATUS_ENDPOINT = `${PUBLIC_API_ORIGIN}/v1/devices/status`;
|
|
@@ -118,17 +120,23 @@ function help() {
|
|
|
118
120
|
process.stdout.write(" [--no-sync] [--name <name>]\n");
|
|
119
121
|
process.stdout.write(" usagemax sync [--full] [--archives] [--restart] [--dry-run] [--explain] [--json]\n");
|
|
120
122
|
process.stdout.write(" Reconcile once; --archives performs one-time recovery\n");
|
|
123
|
+
process.stdout.write(" [--quiet|--no-progress] [--check-updates|--no-update-check] Control progress and the cached release check\n");
|
|
124
|
+
process.stdout.write(" [--json] Emit one JSON object per link/sync phase\n");
|
|
121
125
|
process.stdout.write(" usagemax status Show local link status\n");
|
|
122
126
|
process.stdout.write(" --remote [--json] Verify the stored collector credential without printing it\n");
|
|
127
|
+
process.stdout.write(" [--quiet|--no-progress] Disable interactive progress output\n");
|
|
123
128
|
process.stdout.write(" usagemax token status [--device-id <uuid>] [--json]\n");
|
|
124
129
|
process.stdout.write(" Diagnose a key piped on stdin; never pass it as an argument\n");
|
|
125
130
|
process.stdout.write(" usagemax service install [--every 15]\n");
|
|
126
131
|
process.stdout.write(" Opt into lightweight OS-scheduled sync\n");
|
|
127
132
|
process.stdout.write(" usagemax service status|run|uninstall\n");
|
|
128
|
-
process.stdout.write(" usagemax doctor [--deep] [--json]\n");
|
|
133
|
+
process.stdout.write(" usagemax doctor [--deep] [--json] [--quiet|--no-progress]\n");
|
|
129
134
|
process.stdout.write(" Check source coverage; --deep parses full history\n");
|
|
135
|
+
process.stdout.write(" usagemax update [command args] Check npm and optionally run the latest CLI\n");
|
|
136
|
+
process.stdout.write(" [--json] Emit machine-readable update status\n");
|
|
130
137
|
process.stdout.write(" usagemax report [...args] Run a local ccusage report\n");
|
|
131
138
|
process.stdout.write(" usagemax unlink [--revoke] Remove locally; --revoke also disables uploads\n");
|
|
139
|
+
process.stdout.write(" [--json] Emit machine-readable unlink status\n");
|
|
132
140
|
}
|
|
133
141
|
|
|
134
142
|
function ccusageCliPath() {
|
|
@@ -170,13 +178,14 @@ function newerVersion(recommended) {
|
|
|
170
178
|
|
|
171
179
|
function warnVersion(body) {
|
|
172
180
|
if (newerVersion(body?.recommendedCliVersion)) {
|
|
173
|
-
process.stderr.write(`UsageMax ${body.recommendedCliVersion} is available. Run \`
|
|
181
|
+
process.stderr.write(`UsageMax ${body.recommendedCliVersion} is available. Run \`usagemax update sync\` or set USAGEMAX_AUTO_UPDATE=1.\n`);
|
|
174
182
|
}
|
|
175
183
|
}
|
|
176
184
|
|
|
177
185
|
async function link(args) {
|
|
178
186
|
const code = normalizeLinkCode(args[0]);
|
|
179
187
|
if (!code) throw new Error("Paste the one-use UMX link code shown at usagemax.com/account.");
|
|
188
|
+
const json = args.includes("--json");
|
|
180
189
|
const configuredEndpoint = process.env.USAGEMAX_LINK_ENDPOINT || DEFAULT_LINK_ENDPOINT;
|
|
181
190
|
const endpoint = validHttpsUrl(configuredEndpoint, { allowLocalhost: true });
|
|
182
191
|
if (!endpoint) throw new Error("USAGEMAX_LINK_ENDPOINT must use HTTPS, except for localhost development.");
|
|
@@ -218,28 +227,42 @@ async function link(args) {
|
|
|
218
227
|
};
|
|
219
228
|
await writeConfig(config);
|
|
220
229
|
warnVersion(body);
|
|
221
|
-
|
|
230
|
+
if (json) {
|
|
231
|
+
process.stdout.write(`${JSON.stringify({ linked: true, version: VERSION, deviceName: savedName, profileHandle: config.profileHandle || null, profileUrl: config.profileUrl, deviceIdConfigured: true, sync: args.includes("--no-sync") ? "skipped" : "started" })}\n`);
|
|
232
|
+
} else {
|
|
233
|
+
process.stdout.write(`Linked ${savedName} to ${config.profileHandle ? `@${config.profileHandle}` : "UsageMax"}.\n`);
|
|
234
|
+
}
|
|
222
235
|
if (args.includes("--no-sync")) {
|
|
223
|
-
process.stdout.write("No usage was uploaded. Run `bunx usagemax sync --full` when you are ready.\n");
|
|
236
|
+
if (!json) process.stdout.write("No usage was uploaded. Run `bunx usagemax sync --full` when you are ready.\n");
|
|
224
237
|
return;
|
|
225
238
|
}
|
|
226
|
-
process.stdout.write("Running the first one-shot sync…\n");
|
|
227
|
-
await sync(["--full"], config);
|
|
239
|
+
if (!json) process.stdout.write("Running the first one-shot sync…\n");
|
|
240
|
+
await sync(["--full", ...(json ? ["--json"] : [])], config);
|
|
228
241
|
}
|
|
229
242
|
|
|
230
243
|
async function sync(args, suppliedConfig) {
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
: { archives: 0, cleanup: async () => undefined, env: baseEnv, unsupported: 0 };
|
|
244
|
+
const progress = createProgress({ json: args.includes("--json"), quiet: args.includes("--quiet"), noProgress: args.includes("--no-progress") });
|
|
245
|
+
progress.start("Preparing local usage sync…");
|
|
246
|
+
let recovery;
|
|
235
247
|
try {
|
|
236
|
-
|
|
248
|
+
const baseEnv = await ccusageEnvironment();
|
|
249
|
+
recovery = args.includes("--archives")
|
|
250
|
+
? await prepareArchiveRecovery(baseEnv)
|
|
251
|
+
: { archives: 0, cleanup: async () => undefined, env: baseEnv, unsupported: 0 };
|
|
252
|
+
return await syncPrepared(args, suppliedConfig, recovery, progress);
|
|
253
|
+
} catch (error) {
|
|
254
|
+
// Clear the transient line; the shared top-level handler prints one
|
|
255
|
+
// stable error message so failures are not duplicated.
|
|
256
|
+
progress.stop();
|
|
257
|
+
throw error;
|
|
237
258
|
} finally {
|
|
238
|
-
await recovery
|
|
259
|
+
await recovery?.cleanup();
|
|
260
|
+
progress.stop();
|
|
239
261
|
}
|
|
240
262
|
}
|
|
241
263
|
|
|
242
|
-
async function syncPrepared(args, suppliedConfig, recovery) {
|
|
264
|
+
async function syncPrepared(args, suppliedConfig, recovery, progress = createProgress({ noProgress: true })) {
|
|
265
|
+
const startedAt = Date.now();
|
|
243
266
|
const config = suppliedConfig || await readConfig();
|
|
244
267
|
if (!config) throw new Error("This computer is not linked. Open https://usagemax.com/account and create a link code.");
|
|
245
268
|
config.deviceId = await stableInstallationId(configDirectory(), config.deviceId);
|
|
@@ -253,27 +276,42 @@ async function syncPrepared(args, suppliedConfig, recovery) {
|
|
|
253
276
|
await writeConfig(config);
|
|
254
277
|
}
|
|
255
278
|
if (config.pendingSync) {
|
|
279
|
+
progress.update("Resuming the saved upload checkpoint…");
|
|
256
280
|
if (dryRun) {
|
|
257
|
-
const result = { ...config.pendingSync.result, dryRun: true, pendingRunId: config.pendingSync.runId };
|
|
281
|
+
const result = { ...config.pendingSync.result, dryRun: true, pendingRunId: config.pendingSync.runId, cliVersion: VERSION, ccusageVersion: CCUSAGE_VERSION, durationMs: Date.now() - startedAt };
|
|
258
282
|
process.stdout.write(json ? `${JSON.stringify(result)}\n` : `Dry run: saved run ${config.pendingSync.runId} awaits resume; no upload.\n`);
|
|
283
|
+
progress.succeed("Dry run complete; saved upload remains untouched.");
|
|
259
284
|
return result;
|
|
260
285
|
}
|
|
261
|
-
const result = await resumeUpload(config, {
|
|
262
|
-
|
|
263
|
-
|
|
286
|
+
const result = await resumeUpload(config, {
|
|
287
|
+
save: writeConfig,
|
|
288
|
+
request: snapshotRequest,
|
|
289
|
+
warn: warnVersion,
|
|
290
|
+
onProgress: ({ index, total, operation, acknowledged }) => progress.update(`${acknowledged ? "Uploaded" : "Uploading"} ${index}/${total} · ${operation}`),
|
|
291
|
+
});
|
|
292
|
+
const summary = { ...result, cliVersion: VERSION, ccusageVersion: CCUSAGE_VERSION, durationMs: Date.now() - startedAt };
|
|
293
|
+
progress.succeed("Resumed and completed the saved sync.");
|
|
294
|
+
process.stdout.write(json ? `${JSON.stringify(summary)}\n` : "Resumed and completed the saved sync. Run sync again to scan newer local changes.\n");
|
|
295
|
+
return summary;
|
|
264
296
|
}
|
|
265
297
|
const inventory = await sourceInventory({ env: recovery.env, home: ccusageHome(recovery.env) });
|
|
298
|
+
progress.update(`Found ${inventory.sources.length} source${inventory.sources.length === 1 ? "" : "s"} and ${inventory.files}${inventory.truncated ? "+" : ""} local data file${inventory.files === 1 ? "" : "s"}.`);
|
|
266
299
|
const today = new Date().toISOString().slice(0, 10);
|
|
267
300
|
const knownSources = Array.isArray(config.knownSources) ? config.knownSources : [];
|
|
268
301
|
const { bootstrap, full, skip, inventoryStable } = scanPolicy(config, inventory, {
|
|
269
302
|
today, now: Date.now(), inventoryVersion: SOURCE_INVENTORY_VERSION, requestedFull, requestedArchives,
|
|
270
303
|
});
|
|
271
304
|
if (skip) {
|
|
272
|
-
const result = { accepted: 0, changedRows: 0, sessions: 0, sources: inventory.sources, corrections: 0, scanned: false, full: false, coverage: config.lastCoverage || "partial" };
|
|
305
|
+
const result = { accepted: 0, changedRows: 0, sessions: 0, sources: inventory.sources, corrections: 0, scanned: false, full: false, coverage: config.lastCoverage || "partial", skipReason: "inventory_unchanged", cliVersion: VERSION, ccusageVersion: CCUSAGE_VERSION, durationMs: Date.now() - startedAt };
|
|
273
306
|
if (json) process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
274
|
-
else
|
|
307
|
+
else {
|
|
308
|
+
process.stdout.write("Already up to date. Local usage files have not changed; no logs were parsed or uploaded.\n");
|
|
309
|
+
if (explain) process.stdout.write(`Skipped because the source inventory fingerprint is unchanged; coverage remains ${result.coverage}.\n`);
|
|
310
|
+
}
|
|
311
|
+
progress.succeed("No local changes; upload skipped.");
|
|
275
312
|
return;
|
|
276
313
|
}
|
|
314
|
+
progress.update(`Parsing ${full ? "retained history" : "changed history"} with ccusage…`);
|
|
277
315
|
const report = await ccusageJson(config, { env: recovery.env, full });
|
|
278
316
|
// ccusage v20 exposes aggregates, not proof that every discovered file was
|
|
279
317
|
// parsed. Inventory success alone cannot authorize destructive corrections.
|
|
@@ -309,17 +347,22 @@ async function syncPrepared(args, suppliedConfig, recovery) {
|
|
|
309
347
|
coverage: authoritative ? "complete" : "partial",
|
|
310
348
|
coverageReason: "Parser does not certify complete source/day coverage; decreases and deletions are protected.",
|
|
311
349
|
range: { from: days[0], to: days.at(-1) },
|
|
350
|
+
cliVersion: VERSION,
|
|
351
|
+
ccusageVersion: CCUSAGE_VERSION,
|
|
312
352
|
};
|
|
353
|
+
progress.update(`Prepared ${partitions.length} usage chunk${partitions.length === 1 ? "" : "s"} and ${sessions.length} session identifier${sessions.length === 1 ? "" : "s"}.`);
|
|
313
354
|
if (requestedArchives) {
|
|
314
355
|
result.archives = recovery.archives;
|
|
315
356
|
result.unsupportedArchives = recovery.unsupported;
|
|
316
357
|
}
|
|
317
358
|
if (dryRun) {
|
|
359
|
+
result.durationMs = Date.now() - startedAt;
|
|
318
360
|
if (json) process.stdout.write(`${JSON.stringify({ ...result, dryRun: true })}\n`);
|
|
319
361
|
else {
|
|
320
362
|
process.stdout.write(`Dry run: ${partitions.length} partition(s), ${result.changedRows} changed row(s), ${sessions.length} private session identifiers, no upload.\n`);
|
|
321
363
|
if (explain) process.stdout.write(`Coverage ${result.coverage}; ${sources.length} source(s); ${days[0] || "unknown"} to ${days.at(-1) || "unknown"}; ${regressions.length} protected regression(s). ${result.coverageReason}\n`);
|
|
322
364
|
}
|
|
365
|
+
progress.succeed("Dry run complete; nothing uploaded.");
|
|
323
366
|
return result;
|
|
324
367
|
}
|
|
325
368
|
const requests = [{ operation: "begin", payload: {
|
|
@@ -356,7 +399,14 @@ async function syncPrepared(args, suppliedConfig, recovery) {
|
|
|
356
399
|
};
|
|
357
400
|
config.lastSyncComplete = false;
|
|
358
401
|
await writeConfig(config);
|
|
359
|
-
await resumeUpload(config, {
|
|
402
|
+
await resumeUpload(config, {
|
|
403
|
+
save: writeConfig,
|
|
404
|
+
request: snapshotRequest,
|
|
405
|
+
warn: warnVersion,
|
|
406
|
+
onProgress: ({ index, total, operation, acknowledged }) => progress.update(`${acknowledged ? "Uploaded" : "Uploading"} ${index}/${total} · ${operation}`),
|
|
407
|
+
});
|
|
408
|
+
result.durationMs = Date.now() - startedAt;
|
|
409
|
+
progress.succeed(`Sync complete · ${partitions.length} chunk${partitions.length === 1 ? "" : "s"}, ${sessions.length} session identifier${sessions.length === 1 ? "" : "s"}.`);
|
|
360
410
|
if (json) process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
361
411
|
else {
|
|
362
412
|
process.stdout.write(partitions.length || sessions.length
|
|
@@ -395,9 +445,10 @@ async function status(args = []) {
|
|
|
395
445
|
const config = await readConfig();
|
|
396
446
|
const remote = args.includes("--remote");
|
|
397
447
|
const json = args.includes("--json");
|
|
448
|
+
const progress = createProgress({ json, quiet: args.includes("--quiet"), noProgress: args.includes("--no-progress") });
|
|
398
449
|
if (!config) {
|
|
399
450
|
if (json) {
|
|
400
|
-
process.stdout.write(`${JSON.stringify({ linked: false, remote: remote ? { status: "not_linked" } : undefined })}\n`);
|
|
451
|
+
process.stdout.write(`${JSON.stringify({ linked: false, version: VERSION, ccusageVersion: CCUSAGE_VERSION, configPath: configPath(), remote: remote ? { status: "not_linked" } : undefined })}\n`);
|
|
401
452
|
return;
|
|
402
453
|
}
|
|
403
454
|
process.stdout.write("Not linked. Open https://usagemax.com/account to connect this computer.\n");
|
|
@@ -410,20 +461,29 @@ async function status(args = []) {
|
|
|
410
461
|
}
|
|
411
462
|
const local = {
|
|
412
463
|
linked: true,
|
|
464
|
+
version: VERSION,
|
|
465
|
+
ccusageVersion: CCUSAGE_VERSION,
|
|
466
|
+
configPath: configPath(),
|
|
413
467
|
deviceName: config.deviceName || deviceLabel(),
|
|
414
468
|
profileHandle: config.profileHandle || null,
|
|
415
469
|
deviceIdConfigured: Boolean(config.deviceId),
|
|
416
470
|
lastSyncAt: config.lastSyncAt || null,
|
|
417
471
|
lastFullSyncAt: config.lastFullSyncAt || null,
|
|
418
472
|
pendingSync: config.pendingSync?.runId || null,
|
|
473
|
+
coverage: config.lastCoverage || null,
|
|
474
|
+
knownSources: Array.isArray(config.knownSources) ? config.knownSources : [],
|
|
475
|
+
snapshotRows: Object.keys(config.snapshots || {}).length,
|
|
419
476
|
profileUrl: config.profileUrl || "https://usagemax.com/account",
|
|
420
477
|
};
|
|
421
478
|
let remoteView;
|
|
422
479
|
if (remote) {
|
|
480
|
+
progress.start("Checking the stored collector credential…");
|
|
423
481
|
try {
|
|
424
482
|
const result = await requestCollectorStatus(collectorStatusEndpoint(config), config);
|
|
425
483
|
remoteView = collectorStatusView(result.httpStatus, result.body, config.token);
|
|
484
|
+
progress.succeed("Remote credential checked.");
|
|
426
485
|
} catch (error) {
|
|
486
|
+
progress.stop();
|
|
427
487
|
remoteView = { tokenFormat: "valid", status: "unavailable", reason: error instanceof Error ? error.message : "Collector status unavailable." };
|
|
428
488
|
}
|
|
429
489
|
if (json) {
|
|
@@ -436,8 +496,11 @@ async function status(args = []) {
|
|
|
436
496
|
return;
|
|
437
497
|
}
|
|
438
498
|
process.stdout.write(`Linked: ${config.deviceName || deviceLabel()}${config.profileHandle ? ` → @${config.profileHandle}` : ""}\n`);
|
|
499
|
+
process.stdout.write(`CLI: ${VERSION} · ccusage ${CCUSAGE_VERSION}\n`);
|
|
439
500
|
process.stdout.write(`Last sync: ${config.lastSyncAt || "never"}\n`);
|
|
440
501
|
process.stdout.write(`Last full reconciliation: ${config.lastFullSyncAt || "never"}\n`);
|
|
502
|
+
process.stdout.write(`Coverage: ${config.lastCoverage || "unknown"} · ${Object.keys(config.snapshots || {}).length} checkpoint rows\n`);
|
|
503
|
+
if (Array.isArray(config.knownSources) && config.knownSources.length) process.stdout.write(`Known sources: ${config.knownSources.join(", ")}\n`);
|
|
441
504
|
if (config.pendingSync) process.stdout.write(`Pending sync: ${config.pendingSync.runId}; rerun sync to resume\n`);
|
|
442
505
|
process.stdout.write(`Profile: ${config.profileUrl || "https://usagemax.com/account"}\n`);
|
|
443
506
|
if (remote) printRemoteStatus(remoteView);
|
|
@@ -461,58 +524,87 @@ async function readTokenFromStdin() {
|
|
|
461
524
|
async function tokenStatus(args = []) {
|
|
462
525
|
const requestedDeviceId = option(args, "--device-id");
|
|
463
526
|
if (requestedDeviceId && !DEVICE_PATTERN.test(requestedDeviceId)) throw new Error("--device-id must be a UUID.");
|
|
464
|
-
const
|
|
527
|
+
const json = args.includes("--json");
|
|
528
|
+
const progress = createProgress({ json, quiet: args.includes("--quiet"), noProgress: args.includes("--no-progress") });
|
|
529
|
+
progress.start("Checking the collector credential…");
|
|
530
|
+
let token;
|
|
531
|
+
try {
|
|
532
|
+
token = await readTokenFromStdin();
|
|
533
|
+
} catch (error) {
|
|
534
|
+
progress.stop();
|
|
535
|
+
throw error;
|
|
536
|
+
}
|
|
465
537
|
const configuredEndpoint = process.env.USAGEMAX_STATUS_ENDPOINT || DEFAULT_STATUS_ENDPOINT;
|
|
466
538
|
const endpoint = validHttpsUrl(configuredEndpoint, { allowLocalhost: true });
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
process.stdout.write(
|
|
473
|
-
|
|
539
|
+
try {
|
|
540
|
+
if (!endpoint) throw new Error("USAGEMAX_STATUS_ENDPOINT must use HTTPS, except for localhost development.");
|
|
541
|
+
const result = await requestCollectorStatus(endpoint, { token, deviceId: requestedDeviceId });
|
|
542
|
+
const view = collectorStatusView(result.httpStatus, result.body, token);
|
|
543
|
+
progress.succeed("Collector credential checked.");
|
|
544
|
+
if (json) process.stdout.write(`${JSON.stringify(view)}\n`);
|
|
545
|
+
else {
|
|
546
|
+
process.stdout.write("Credential format: valid (umx_ + 64 lowercase hexadecimal characters)\n");
|
|
547
|
+
printRemoteStatus(view);
|
|
548
|
+
}
|
|
549
|
+
} catch (error) {
|
|
550
|
+
progress.stop();
|
|
551
|
+
throw error;
|
|
474
552
|
}
|
|
475
553
|
}
|
|
476
554
|
|
|
477
555
|
async function doctor(args = []) {
|
|
478
|
-
const
|
|
479
|
-
const
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
556
|
+
const json = args.includes("--json");
|
|
557
|
+
const progress = createProgress({ json, quiet: args.includes("--quiet"), noProgress: args.includes("--no-progress") });
|
|
558
|
+
progress.start(args.includes("--deep") ? "Auditing retained source history…" : "Inspecting local source coverage…");
|
|
559
|
+
try {
|
|
560
|
+
const config = await readConfig();
|
|
561
|
+
const env = await ccusageEnvironment();
|
|
562
|
+
const inventory = await sourceInventory({ env, home: ccusageHome(env) });
|
|
563
|
+
progress.update(`Found ${inventory.sources.length} source${inventory.sources.length === 1 ? "" : "s"} and ${inventory.files}${inventory.truncated ? "+" : ""} local data file${inventory.files === 1 ? "" : "s"}.`);
|
|
564
|
+
const archives = await discoverProviderArchives({ env, home: ccusageHome(env) });
|
|
565
|
+
const homes = String(env.USAGEMAX_DISCOVERED_HOMES || ccusageHome(env)).split(",").filter(Boolean);
|
|
566
|
+
const result = {
|
|
567
|
+
cliVersion: VERSION,
|
|
568
|
+
ccusageVersion: CCUSAGE_VERSION,
|
|
569
|
+
configPath: configPath(),
|
|
570
|
+
linked: Boolean(config),
|
|
571
|
+
homes,
|
|
572
|
+
detectedSources: inventory.sources,
|
|
573
|
+
files: inventory.files,
|
|
574
|
+
inventoryComplete: inventory.complete,
|
|
575
|
+
inventoryErrors: inventory.errors,
|
|
576
|
+
inventoryTruncated: inventory.truncated,
|
|
577
|
+
supportedSources: SUPPORTED_SOURCES,
|
|
578
|
+
archives: archives.length,
|
|
579
|
+
environment: platform() === "linux" && process.env.WSL_DISTRO_NAME ? `WSL ${process.env.WSL_DISTRO_NAME}` : platform(),
|
|
580
|
+
mode: args.includes("--deep") ? "deep" : "metadata-only",
|
|
581
|
+
};
|
|
582
|
+
if (args.includes("--deep")) {
|
|
583
|
+
progress.update("Parsing retained history with ccusage…");
|
|
584
|
+
const report = await ccusageJson(config, { env, full: true });
|
|
585
|
+
result.parsedSources = sourceSummary(report);
|
|
586
|
+
result.sessions = buildSessionPlan(report, config?.deviceId || "unlinked").length;
|
|
587
|
+
}
|
|
588
|
+
progress.succeed("Coverage check complete.");
|
|
589
|
+
if (json) {
|
|
590
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
process.stdout.write(`Collector: ${config ? "linked" : "not linked"}\n`);
|
|
594
|
+
process.stdout.write(`Discovered homes: ${homes.length} (${homes.join(", ")})\n`);
|
|
595
|
+
process.stdout.write(`Detected sources: ${inventory.sources.join(", ") || "none"} (${inventory.files}${inventory.truncated ? "+" : ""} data files)\n`);
|
|
596
|
+
process.stdout.write(`Supported sources: ${SUPPORTED_SOURCES.join(", ")} (+ named pi-format stores)\n`);
|
|
597
|
+
if (platform() === "linux" && process.env.WSL_DISTRO_NAME) {
|
|
598
|
+
process.stdout.write(`Environment: WSL ${process.env.WSL_DISTRO_NAME}; readable Windows provider homes are included automatically\n`);
|
|
599
|
+
}
|
|
600
|
+
if (archives.length) process.stdout.write(`Recovery: ${archives.length} compressed provider archive(s) detected; run \`bunx usagemax sync --archives\` once to reconcile them\n`);
|
|
601
|
+
if (!inventory.complete) process.stdout.write(`Inventory: incomplete (${inventory.errors} read error(s)${inventory.truncated ? ", file limit reached" : ""}); no-change shortcut disabled\n`);
|
|
602
|
+
if (result.parsedSources) process.stdout.write(`Parsed sources: ${result.parsedSources.join(", ") || "none"}; ${result.sessions} private session identifiers\n`);
|
|
603
|
+
process.stdout.write(`Mode: one-shot, metadata no-op check, ${args.includes("--deep") ? "deep local parse" : "no log parsing"}\n`);
|
|
604
|
+
} catch (error) {
|
|
605
|
+
progress.stop();
|
|
606
|
+
throw error;
|
|
504
607
|
}
|
|
505
|
-
process.stdout.write(`Collector: ${config ? "linked" : "not linked"}\n`);
|
|
506
|
-
process.stdout.write(`Discovered homes: ${homes.length} (${homes.join(", ")})\n`);
|
|
507
|
-
process.stdout.write(`Detected sources: ${inventory.sources.join(", ") || "none"} (${inventory.files}${inventory.truncated ? "+" : ""} data files)\n`);
|
|
508
|
-
process.stdout.write(`Supported sources: ${SUPPORTED_SOURCES.join(", ")} (+ named pi-format stores)\n`);
|
|
509
|
-
if (platform() === "linux" && process.env.WSL_DISTRO_NAME) {
|
|
510
|
-
process.stdout.write(`Environment: WSL ${process.env.WSL_DISTRO_NAME}; readable Windows provider homes are included automatically\n`);
|
|
511
|
-
}
|
|
512
|
-
if (archives.length) process.stdout.write(`Recovery: ${archives.length} compressed provider archive(s) detected; run \`bunx usagemax sync --archives\` once to reconcile them\n`);
|
|
513
|
-
if (!inventory.complete) process.stdout.write(`Inventory: incomplete (${inventory.errors} read error(s)${inventory.truncated ? ", file limit reached" : ""}); no-change shortcut disabled\n`);
|
|
514
|
-
if (result.parsedSources) process.stdout.write(`Parsed sources: ${result.parsedSources.join(", ") || "none"}; ${result.sessions} private session identifiers\n`);
|
|
515
|
-
process.stdout.write(`Mode: one-shot, metadata no-op check, ${args.includes("--deep") ? "deep local parse" : "no log parsing"}\n`);
|
|
516
608
|
}
|
|
517
609
|
|
|
518
610
|
async function report(args) {
|
|
@@ -528,14 +620,55 @@ async function report(args) {
|
|
|
528
620
|
if (code !== 0) process.exitCode = code;
|
|
529
621
|
}
|
|
530
622
|
|
|
623
|
+
async function update(args = []) {
|
|
624
|
+
const json = args.includes("--json");
|
|
625
|
+
const check = await checkForUpdate(configDirectory(), VERSION, { force: true });
|
|
626
|
+
if (!check.latest) {
|
|
627
|
+
const result = { version: VERSION, latest: null, newer: false, status: "unavailable" };
|
|
628
|
+
process.stdout.write(json ? `${JSON.stringify(result)}\n` : `UsageMax CLI ${VERSION} · update check unavailable.\n`);
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
if (!check.newer) {
|
|
632
|
+
const result = { version: VERSION, latest: check.latest, newer: false, status: "current" };
|
|
633
|
+
process.stdout.write(json ? `${JSON.stringify(result)}\n` : `UsageMax CLI ${VERSION} is current.\n`);
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
const result = { version: VERSION, latest: check.latest, newer: true, status: "available", command: "usagemax update sync" };
|
|
637
|
+
process.stdout.write(json ? `${JSON.stringify(result)}\n` : `UsageMax CLI ${check.latest} is available (current ${VERSION}).\n`);
|
|
638
|
+
const commandArgs = args.filter((arg) => arg !== "--check");
|
|
639
|
+
if (!commandArgs.length || args.includes("--check")) {
|
|
640
|
+
if (!json) process.stdout.write("Run `usagemax update sync` to hand off the next command to the latest npm release.\n");
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
if (!json) process.stdout.write(`Launching UsageMax ${check.latest}…\n`);
|
|
644
|
+
return runLatest(commandArgs);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
async function maybeUpdate(command, args) {
|
|
648
|
+
if (process.env.USAGEMAX_UPDATE_HANDOFF === "1" || process.env.USAGEMAX_DISABLE_UPDATE_CHECK === "1" || args.includes("--no-update-check")) return false;
|
|
649
|
+
if (!["sync", "link", "doctor"].includes(command)) return false;
|
|
650
|
+
const explicitCheck = args.includes("--check-updates");
|
|
651
|
+
if (!explicitCheck && args.includes("--json")) return false;
|
|
652
|
+
const check = await checkForUpdate(configDirectory(), VERSION, { force: explicitCheck });
|
|
653
|
+
if (!check.newer) return false;
|
|
654
|
+
if (process.env.USAGEMAX_AUTO_UPDATE === "1") {
|
|
655
|
+
await runLatest([command, ...args.filter((arg) => !["--check-updates", "--no-update-check"].includes(arg))]);
|
|
656
|
+
return true;
|
|
657
|
+
}
|
|
658
|
+
process.stderr.write(`UsageMax ${check.latest} is available. Run \`usagemax update ${command}\` or set USAGEMAX_AUTO_UPDATE=1.\n`);
|
|
659
|
+
return false;
|
|
660
|
+
}
|
|
661
|
+
|
|
531
662
|
async function removeLink(args = []) {
|
|
532
663
|
const path = configPath();
|
|
533
664
|
const config = await readConfig();
|
|
665
|
+
const json = args.includes("--json");
|
|
534
666
|
if (!config) {
|
|
535
|
-
process.stdout.write("This computer is not linked.\n");
|
|
667
|
+
process.stdout.write(json ? `${JSON.stringify({ unlinked: false, reason: "not_linked" })}\n` : "This computer is not linked.\n");
|
|
536
668
|
return;
|
|
537
669
|
}
|
|
538
|
-
|
|
670
|
+
const revoked = args.includes("--revoke");
|
|
671
|
+
if (revoked) {
|
|
539
672
|
const endpoint = validHttpsUrl(config.revokeUrl, { allowLocalhost: true });
|
|
540
673
|
if (!endpoint) throw new Error("This collector does not have a valid revoke endpoint. Revoke it at https://usagemax.com/account.");
|
|
541
674
|
const response = await fetch(endpoint, {
|
|
@@ -549,16 +682,22 @@ async function removeLink(args = []) {
|
|
|
549
682
|
if (!response.ok) throw new Error("UsageMax could not revoke this collector. It remains linked locally.");
|
|
550
683
|
}
|
|
551
684
|
await unlink(path);
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
685
|
+
if (json) {
|
|
686
|
+
process.stdout.write(`${JSON.stringify({ unlinked: true, revoked, retainedInstallationIdentity: true })}\n`);
|
|
687
|
+
} else {
|
|
688
|
+
process.stdout.write(revoked
|
|
689
|
+
? "Revoked this collector and removed its local key. Existing usage totals were retained.\n"
|
|
690
|
+
: "Removed the local UsageMax collector key. This computer's private installation identity was retained so relinking cannot duplicate its usage. Revoke the collector in your account if this computer is no longer trusted.\n");
|
|
691
|
+
}
|
|
555
692
|
}
|
|
556
693
|
|
|
557
694
|
async function main() {
|
|
558
695
|
const args = process.argv.slice(2);
|
|
559
696
|
const command = args[0] || "sync";
|
|
560
697
|
if (["--help", "-h", "help"].includes(command)) return help();
|
|
561
|
-
if (["--version", "-v"].includes(command)) return process.stdout.write(`${VERSION}\n`);
|
|
698
|
+
if (["--version", "-v"].includes(command)) return process.stdout.write(args.includes("--json") ? `${JSON.stringify({ version: VERSION, ccusageVersion: CCUSAGE_VERSION })}\n` : `${VERSION}\n`);
|
|
699
|
+
if (command === "update") return update(args.slice(1));
|
|
700
|
+
if (await maybeUpdate(command, args)) return;
|
|
562
701
|
if (command === "service") {
|
|
563
702
|
const action = args[1] || "status";
|
|
564
703
|
const directory = option(args, "--config-dir") || configDirectory();
|
package/src/progress.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
const FRAMES = ["·", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
2
|
+
|
|
3
|
+
function canAnimate({ json = false, quiet = false, noProgress = false } = {}) {
|
|
4
|
+
return Boolean(process.stderr.isTTY)
|
|
5
|
+
&& !json
|
|
6
|
+
&& !quiet
|
|
7
|
+
&& !noProgress
|
|
8
|
+
&& process.env.USAGEMAX_NO_PROGRESS !== "1"
|
|
9
|
+
&& process.env.CI !== "true";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A tiny stderr-only progress reporter. JSON and scheduled runs stay silent so
|
|
14
|
+
* stdout remains machine-readable and background jobs do not write a stream.
|
|
15
|
+
*/
|
|
16
|
+
export function createProgress(options = {}) {
|
|
17
|
+
const enabled = canAnimate(options);
|
|
18
|
+
let timer;
|
|
19
|
+
let frame = 0;
|
|
20
|
+
let active = false;
|
|
21
|
+
let last = "";
|
|
22
|
+
let startedAt = 0;
|
|
23
|
+
|
|
24
|
+
const elapsed = () => {
|
|
25
|
+
const seconds = Math.max(0, (Date.now() - startedAt) / 1000);
|
|
26
|
+
return seconds < 10 ? `${seconds.toFixed(1)}s` : `${Math.round(seconds)}s`;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const render = (text) => {
|
|
30
|
+
if (!enabled || !active) return;
|
|
31
|
+
const line = `${FRAMES[frame % FRAMES.length]} ${text} · ${elapsed()}`;
|
|
32
|
+
frame += 1;
|
|
33
|
+
last = line;
|
|
34
|
+
process.stderr.write(`\r\x1b[2K${line}`);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
enabled,
|
|
39
|
+
start(text) {
|
|
40
|
+
if (!enabled) return;
|
|
41
|
+
active = true;
|
|
42
|
+
startedAt = Date.now();
|
|
43
|
+
render(text);
|
|
44
|
+
timer = setInterval(() => render(last.replace(/^[^ ]+ /, "").replace(/ · \d+(?:\.\d+)?s$/, "")), 120);
|
|
45
|
+
timer.unref?.();
|
|
46
|
+
},
|
|
47
|
+
update(text) {
|
|
48
|
+
if (!enabled) return;
|
|
49
|
+
if (!active) this.start(text);
|
|
50
|
+
else render(text);
|
|
51
|
+
},
|
|
52
|
+
succeed(text) {
|
|
53
|
+
if (!enabled) return;
|
|
54
|
+
const duration = elapsed();
|
|
55
|
+
this.stop();
|
|
56
|
+
process.stderr.write(`\r\x1b[2K✓ ${text} (${duration})\n`);
|
|
57
|
+
},
|
|
58
|
+
fail(text) {
|
|
59
|
+
if (!enabled) return;
|
|
60
|
+
const duration = elapsed();
|
|
61
|
+
this.stop();
|
|
62
|
+
process.stderr.write(`\r\x1b[2K✗ ${text} (${duration})\n`);
|
|
63
|
+
},
|
|
64
|
+
stop() {
|
|
65
|
+
if (!enabled) return;
|
|
66
|
+
if (timer) clearInterval(timer);
|
|
67
|
+
timer = undefined;
|
|
68
|
+
if (active) process.stderr.write("\r\x1b[2K");
|
|
69
|
+
active = false;
|
|
70
|
+
startedAt = 0;
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
package/src/resume.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// A pending run is saved before network I/O. Persisting the acknowledged cursor
|
|
2
2
|
// after each request permits replays when the response or local write is lost.
|
|
3
3
|
// The server must receipt begin, chunks, sessions and complete idempotently.
|
|
4
|
-
export async function resumeUpload(config, { save, request, warn = () => {} }) {
|
|
4
|
+
export async function resumeUpload(config, { save, request, warn = () => {}, onProgress = () => {} }) {
|
|
5
5
|
const pending = config.pendingSync;
|
|
6
6
|
if (!pending || pending.version !== 1 || !Array.isArray(pending.requests)) {
|
|
7
7
|
throw new Error("Invalid saved sync; preserve the config for recovery.");
|
|
@@ -9,10 +9,12 @@ export async function resumeUpload(config, { save, request, warn = () => {} }) {
|
|
|
9
9
|
try {
|
|
10
10
|
for (let index = pending.cursor; index < pending.requests.length; index += 1) {
|
|
11
11
|
const { operation, payload } = pending.requests[index];
|
|
12
|
+
onProgress({ index: index + 1, total: pending.requests.length, operation });
|
|
12
13
|
const response = await request(config, operation, payload, operation === "partitions" ? 60_000 : 30_000);
|
|
13
14
|
warn(response);
|
|
14
15
|
pending.cursor = index + 1;
|
|
15
16
|
await save(config);
|
|
17
|
+
onProgress({ index: index + 1, total: pending.requests.length, operation, acknowledged: true });
|
|
16
18
|
}
|
|
17
19
|
// Commit local baseline and remove the journal in the same atomic write.
|
|
18
20
|
const next = { ...config, ...pending.checkpoint };
|
package/src/sources.js
CHANGED
|
@@ -3,7 +3,7 @@ import { readFile, readdir, stat } from "node:fs/promises";
|
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
|
|
6
|
-
export const CCUSAGE_VERSION = "20.0.
|
|
6
|
+
export const CCUSAGE_VERSION = "20.0.23";
|
|
7
7
|
export const SOURCE_INVENTORY_VERSION = 3;
|
|
8
8
|
export const SUPPORTED_SOURCES = [
|
|
9
9
|
"amp",
|
package/src/updates.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
|
|
6
|
+
export const REGISTRY_URL = "https://registry.npmjs.org/usagemax/latest";
|
|
7
|
+
export const UPDATE_CHECK_TTL_MS = 12 * 60 * 60 * 1000;
|
|
8
|
+
|
|
9
|
+
function record(value) {
|
|
10
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function compareVersions(left, right) {
|
|
14
|
+
const a = String(left || "").split(".").map(Number);
|
|
15
|
+
const b = String(right || "").split(".").map(Number);
|
|
16
|
+
if (a.length !== 3 || b.length !== 3 || a.some((part) => !Number.isInteger(part)) || b.some((part) => !Number.isInteger(part))) return 0;
|
|
17
|
+
for (let index = 0; index < 3; index += 1) {
|
|
18
|
+
if (a[index] !== b[index]) return a[index] > b[index] ? 1 : -1;
|
|
19
|
+
}
|
|
20
|
+
return 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function cachePath(directory) {
|
|
24
|
+
return join(directory, "update-check.json");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function readCache(directory) {
|
|
28
|
+
try {
|
|
29
|
+
const value = record(JSON.parse(await readFile(cachePath(directory), "utf8")));
|
|
30
|
+
if (!value || !Number.isFinite(value.checkedAt)) return null;
|
|
31
|
+
return value;
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function writeCache(directory, value) {
|
|
38
|
+
await mkdir(dirname(cachePath(directory)), { recursive: true, mode: 0o700 });
|
|
39
|
+
const path = cachePath(directory);
|
|
40
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
41
|
+
try {
|
|
42
|
+
await writeFile(temporary, `${JSON.stringify(value)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
43
|
+
await rename(temporary, path);
|
|
44
|
+
} finally {
|
|
45
|
+
await unlink(temporary).catch(() => undefined);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function latestVersion({ fetchImpl = fetch, timeout = 1_500 } = {}) {
|
|
50
|
+
try {
|
|
51
|
+
const response = await fetchImpl(REGISTRY_URL, {
|
|
52
|
+
headers: { accept: "application/json" },
|
|
53
|
+
cache: "no-store",
|
|
54
|
+
signal: AbortSignal.timeout(timeout),
|
|
55
|
+
});
|
|
56
|
+
if (!response.ok) return null;
|
|
57
|
+
const body = record(await response.json());
|
|
58
|
+
return /^\d+\.\d+\.\d+$/.test(body?.version || "") ? body.version : null;
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Check npm at most twice a day. A failed check is deliberately non-fatal.
|
|
66
|
+
* The result contains no account data and is safe to cache locally.
|
|
67
|
+
*/
|
|
68
|
+
export async function checkForUpdate(directory, currentVersion, { force = false, now = Date.now(), fetchImpl = fetch } = {}) {
|
|
69
|
+
const cached = await readCache(directory);
|
|
70
|
+
if (!force && cached && now - cached.checkedAt < UPDATE_CHECK_TTL_MS) {
|
|
71
|
+
return { ...cached, newer: compareVersions(cached.latest, currentVersion) > 0 };
|
|
72
|
+
}
|
|
73
|
+
const latest = await latestVersion({ fetchImpl });
|
|
74
|
+
const result = { checkedAt: now, latest: latest || cached?.latest || null };
|
|
75
|
+
await writeCache(directory, result).catch(() => undefined);
|
|
76
|
+
return { ...result, newer: compareVersions(result.latest, currentVersion) > 0 };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function runner() {
|
|
80
|
+
const override = process.env.USAGEMAX_PACKAGE_RUNNER?.trim();
|
|
81
|
+
if (override) return { command: override, prefix: [] };
|
|
82
|
+
if (process.env.npm_execpath) return { command: "npm", prefix: ["exec", "--yes"] };
|
|
83
|
+
return { command: "bunx", prefix: ["--bun"] };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Re-run a command through the current npm dist-tag without requiring @latest. */
|
|
87
|
+
export async function runLatest(args, { spawnImpl = spawn } = {}) {
|
|
88
|
+
const selected = runner();
|
|
89
|
+
const child = spawnImpl(selected.command, [...selected.prefix, "usagemax@latest", "--", ...args], {
|
|
90
|
+
stdio: "inherit",
|
|
91
|
+
env: { ...process.env, USAGEMAX_UPDATE_HANDOFF: "1" },
|
|
92
|
+
});
|
|
93
|
+
const code = await new Promise((resolve, reject) => {
|
|
94
|
+
child.once("error", reject);
|
|
95
|
+
child.once("exit", (status) => resolve(status ?? 1));
|
|
96
|
+
});
|
|
97
|
+
process.exitCode = code;
|
|
98
|
+
return code;
|
|
99
|
+
}
|