usagemax 0.3.6 → 0.3.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 +20 -7
- package/package.json +3 -1
- package/src/cli.js +157 -57
- package/src/progress.js +73 -0
- package/src/resume.js +3 -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,16 +30,24 @@ 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
|
|
|
@@ -115,14 +127,15 @@ stdin; never pass it as an argument or put it in a URL:
|
|
|
115
127
|
```bash
|
|
116
128
|
set +x
|
|
117
129
|
printf '%s' "$USAGEMAX_COLLECTOR_TOKEN" \
|
|
118
|
-
| bunx usagemax
|
|
130
|
+
| bunx usagemax token status \
|
|
119
131
|
--device-id "$USAGEMAX_INSTALLATION_ID" \
|
|
120
132
|
--json
|
|
121
133
|
```
|
|
122
134
|
|
|
123
|
-
The `token status` command is included in CLI `0.3.
|
|
124
|
-
|
|
125
|
-
from
|
|
135
|
+
The `token status` command is included in CLI `0.3.7`. If a fresh environment
|
|
136
|
+
still has an older npm tag, run `usagemax update token status` or
|
|
137
|
+
`node packages/cli/src/cli.js token status` from this repository until the new
|
|
138
|
+
package is published.
|
|
126
139
|
|
|
127
140
|
The response is read-only and contains only status, type, scopes, profile/name,
|
|
128
141
|
activation state, and a binding result of `unbound`, `bound`, `matched`, or
|
|
@@ -133,7 +146,7 @@ For a numeric-only result suitable for a smoke check:
|
|
|
133
146
|
```bash
|
|
134
147
|
set +x
|
|
135
148
|
printf '%s' "$USAGEMAX_COLLECTOR_TOKEN" \
|
|
136
|
-
| bunx usagemax
|
|
149
|
+
| bunx usagemax token status \
|
|
137
150
|
--device-id "$USAGEMAX_INSTALLATION_ID" --json \
|
|
138
151
|
| jq -r '[.httpStatus, (if .ingestAuthorized then 1 else 0 end)] | @tsv'
|
|
139
152
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "usagemax",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.7",
|
|
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",
|
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.7";
|
|
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,15 +120,18 @@ 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");
|
|
121
124
|
process.stdout.write(" usagemax status Show local link status\n");
|
|
122
125
|
process.stdout.write(" --remote [--json] Verify the stored collector credential without printing it\n");
|
|
126
|
+
process.stdout.write(" [--quiet|--no-progress] Disable interactive progress output\n");
|
|
123
127
|
process.stdout.write(" usagemax token status [--device-id <uuid>] [--json]\n");
|
|
124
128
|
process.stdout.write(" Diagnose a key piped on stdin; never pass it as an argument\n");
|
|
125
129
|
process.stdout.write(" usagemax service install [--every 15]\n");
|
|
126
130
|
process.stdout.write(" Opt into lightweight OS-scheduled sync\n");
|
|
127
131
|
process.stdout.write(" usagemax service status|run|uninstall\n");
|
|
128
|
-
process.stdout.write(" usagemax doctor [--deep] [--json]\n");
|
|
132
|
+
process.stdout.write(" usagemax doctor [--deep] [--json] [--quiet|--no-progress]\n");
|
|
129
133
|
process.stdout.write(" Check source coverage; --deep parses full history\n");
|
|
134
|
+
process.stdout.write(" usagemax update [command args] Check npm and optionally run the latest CLI\n");
|
|
130
135
|
process.stdout.write(" usagemax report [...args] Run a local ccusage report\n");
|
|
131
136
|
process.stdout.write(" usagemax unlink [--revoke] Remove locally; --revoke also disables uploads\n");
|
|
132
137
|
}
|
|
@@ -170,7 +175,7 @@ function newerVersion(recommended) {
|
|
|
170
175
|
|
|
171
176
|
function warnVersion(body) {
|
|
172
177
|
if (newerVersion(body?.recommendedCliVersion)) {
|
|
173
|
-
process.stderr.write(`UsageMax ${body.recommendedCliVersion} is available. Run \`
|
|
178
|
+
process.stderr.write(`UsageMax ${body.recommendedCliVersion} is available. Run \`usagemax update sync\` or set USAGEMAX_AUTO_UPDATE=1.\n`);
|
|
174
179
|
}
|
|
175
180
|
}
|
|
176
181
|
|
|
@@ -228,18 +233,27 @@ async function link(args) {
|
|
|
228
233
|
}
|
|
229
234
|
|
|
230
235
|
async function sync(args, suppliedConfig) {
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
: { archives: 0, cleanup: async () => undefined, env: baseEnv, unsupported: 0 };
|
|
236
|
+
const progress = createProgress({ json: args.includes("--json"), quiet: args.includes("--quiet"), noProgress: args.includes("--no-progress") });
|
|
237
|
+
progress.start("Preparing local usage sync…");
|
|
238
|
+
let recovery;
|
|
235
239
|
try {
|
|
236
|
-
|
|
240
|
+
const baseEnv = await ccusageEnvironment();
|
|
241
|
+
recovery = args.includes("--archives")
|
|
242
|
+
? await prepareArchiveRecovery(baseEnv)
|
|
243
|
+
: { archives: 0, cleanup: async () => undefined, env: baseEnv, unsupported: 0 };
|
|
244
|
+
return await syncPrepared(args, suppliedConfig, recovery, progress);
|
|
245
|
+
} catch (error) {
|
|
246
|
+
// Clear the transient line; the shared top-level handler prints one
|
|
247
|
+
// stable error message so failures are not duplicated.
|
|
248
|
+
progress.stop();
|
|
249
|
+
throw error;
|
|
237
250
|
} finally {
|
|
238
|
-
await recovery
|
|
251
|
+
await recovery?.cleanup();
|
|
252
|
+
progress.stop();
|
|
239
253
|
}
|
|
240
254
|
}
|
|
241
255
|
|
|
242
|
-
async function syncPrepared(args, suppliedConfig, recovery) {
|
|
256
|
+
async function syncPrepared(args, suppliedConfig, recovery, progress = createProgress({ noProgress: true })) {
|
|
243
257
|
const config = suppliedConfig || await readConfig();
|
|
244
258
|
if (!config) throw new Error("This computer is not linked. Open https://usagemax.com/account and create a link code.");
|
|
245
259
|
config.deviceId = await stableInstallationId(configDirectory(), config.deviceId);
|
|
@@ -253,16 +267,25 @@ async function syncPrepared(args, suppliedConfig, recovery) {
|
|
|
253
267
|
await writeConfig(config);
|
|
254
268
|
}
|
|
255
269
|
if (config.pendingSync) {
|
|
270
|
+
progress.update("Resuming the saved upload checkpoint…");
|
|
256
271
|
if (dryRun) {
|
|
257
272
|
const result = { ...config.pendingSync.result, dryRun: true, pendingRunId: config.pendingSync.runId };
|
|
258
273
|
process.stdout.write(json ? `${JSON.stringify(result)}\n` : `Dry run: saved run ${config.pendingSync.runId} awaits resume; no upload.\n`);
|
|
274
|
+
progress.succeed("Dry run complete; saved upload remains untouched.");
|
|
259
275
|
return result;
|
|
260
276
|
}
|
|
261
|
-
const result = await resumeUpload(config, {
|
|
277
|
+
const result = await resumeUpload(config, {
|
|
278
|
+
save: writeConfig,
|
|
279
|
+
request: snapshotRequest,
|
|
280
|
+
warn: warnVersion,
|
|
281
|
+
onProgress: ({ index, total, operation, acknowledged }) => progress.update(`${acknowledged ? "Uploaded" : "Uploading"} ${index}/${total} · ${operation}`),
|
|
282
|
+
});
|
|
283
|
+
progress.succeed("Resumed and completed the saved sync.");
|
|
262
284
|
process.stdout.write(json ? `${JSON.stringify(result)}\n` : "Resumed and completed the saved sync. Run sync again to scan newer local changes.\n");
|
|
263
285
|
return result;
|
|
264
286
|
}
|
|
265
287
|
const inventory = await sourceInventory({ env: recovery.env, home: ccusageHome(recovery.env) });
|
|
288
|
+
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
289
|
const today = new Date().toISOString().slice(0, 10);
|
|
267
290
|
const knownSources = Array.isArray(config.knownSources) ? config.knownSources : [];
|
|
268
291
|
const { bootstrap, full, skip, inventoryStable } = scanPolicy(config, inventory, {
|
|
@@ -272,8 +295,10 @@ async function syncPrepared(args, suppliedConfig, recovery) {
|
|
|
272
295
|
const result = { accepted: 0, changedRows: 0, sessions: 0, sources: inventory.sources, corrections: 0, scanned: false, full: false, coverage: config.lastCoverage || "partial" };
|
|
273
296
|
if (json) process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
274
297
|
else process.stdout.write("Already up to date. Local usage files have not changed; no logs were parsed or uploaded.\n");
|
|
298
|
+
progress.succeed("No local changes; upload skipped.");
|
|
275
299
|
return;
|
|
276
300
|
}
|
|
301
|
+
progress.update(`Parsing ${full ? "retained history" : "changed history"} with ccusage…`);
|
|
277
302
|
const report = await ccusageJson(config, { env: recovery.env, full });
|
|
278
303
|
// ccusage v20 exposes aggregates, not proof that every discovered file was
|
|
279
304
|
// parsed. Inventory success alone cannot authorize destructive corrections.
|
|
@@ -310,6 +335,7 @@ async function syncPrepared(args, suppliedConfig, recovery) {
|
|
|
310
335
|
coverageReason: "Parser does not certify complete source/day coverage; decreases and deletions are protected.",
|
|
311
336
|
range: { from: days[0], to: days.at(-1) },
|
|
312
337
|
};
|
|
338
|
+
progress.update(`Prepared ${partitions.length} usage chunk${partitions.length === 1 ? "" : "s"} and ${sessions.length} session identifier${sessions.length === 1 ? "" : "s"}.`);
|
|
313
339
|
if (requestedArchives) {
|
|
314
340
|
result.archives = recovery.archives;
|
|
315
341
|
result.unsupportedArchives = recovery.unsupported;
|
|
@@ -320,6 +346,7 @@ async function syncPrepared(args, suppliedConfig, recovery) {
|
|
|
320
346
|
process.stdout.write(`Dry run: ${partitions.length} partition(s), ${result.changedRows} changed row(s), ${sessions.length} private session identifiers, no upload.\n`);
|
|
321
347
|
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
348
|
}
|
|
349
|
+
progress.succeed("Dry run complete; nothing uploaded.");
|
|
323
350
|
return result;
|
|
324
351
|
}
|
|
325
352
|
const requests = [{ operation: "begin", payload: {
|
|
@@ -356,7 +383,13 @@ async function syncPrepared(args, suppliedConfig, recovery) {
|
|
|
356
383
|
};
|
|
357
384
|
config.lastSyncComplete = false;
|
|
358
385
|
await writeConfig(config);
|
|
359
|
-
await resumeUpload(config, {
|
|
386
|
+
await resumeUpload(config, {
|
|
387
|
+
save: writeConfig,
|
|
388
|
+
request: snapshotRequest,
|
|
389
|
+
warn: warnVersion,
|
|
390
|
+
onProgress: ({ index, total, operation, acknowledged }) => progress.update(`${acknowledged ? "Uploaded" : "Uploading"} ${index}/${total} · ${operation}`),
|
|
391
|
+
});
|
|
392
|
+
progress.succeed(`Sync complete · ${partitions.length} chunk${partitions.length === 1 ? "" : "s"}, ${sessions.length} session identifier${sessions.length === 1 ? "" : "s"}.`);
|
|
360
393
|
if (json) process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
361
394
|
else {
|
|
362
395
|
process.stdout.write(partitions.length || sessions.length
|
|
@@ -395,6 +428,7 @@ async function status(args = []) {
|
|
|
395
428
|
const config = await readConfig();
|
|
396
429
|
const remote = args.includes("--remote");
|
|
397
430
|
const json = args.includes("--json");
|
|
431
|
+
const progress = createProgress({ json, quiet: args.includes("--quiet"), noProgress: args.includes("--no-progress") });
|
|
398
432
|
if (!config) {
|
|
399
433
|
if (json) {
|
|
400
434
|
process.stdout.write(`${JSON.stringify({ linked: false, remote: remote ? { status: "not_linked" } : undefined })}\n`);
|
|
@@ -420,10 +454,13 @@ async function status(args = []) {
|
|
|
420
454
|
};
|
|
421
455
|
let remoteView;
|
|
422
456
|
if (remote) {
|
|
457
|
+
progress.start("Checking the stored collector credential…");
|
|
423
458
|
try {
|
|
424
459
|
const result = await requestCollectorStatus(collectorStatusEndpoint(config), config);
|
|
425
460
|
remoteView = collectorStatusView(result.httpStatus, result.body, config.token);
|
|
461
|
+
progress.succeed("Remote credential checked.");
|
|
426
462
|
} catch (error) {
|
|
463
|
+
progress.stop();
|
|
427
464
|
remoteView = { tokenFormat: "valid", status: "unavailable", reason: error instanceof Error ? error.message : "Collector status unavailable." };
|
|
428
465
|
}
|
|
429
466
|
if (json) {
|
|
@@ -461,58 +498,84 @@ async function readTokenFromStdin() {
|
|
|
461
498
|
async function tokenStatus(args = []) {
|
|
462
499
|
const requestedDeviceId = option(args, "--device-id");
|
|
463
500
|
if (requestedDeviceId && !DEVICE_PATTERN.test(requestedDeviceId)) throw new Error("--device-id must be a UUID.");
|
|
464
|
-
const
|
|
501
|
+
const json = args.includes("--json");
|
|
502
|
+
const progress = createProgress({ json, quiet: args.includes("--quiet"), noProgress: args.includes("--no-progress") });
|
|
503
|
+
progress.start("Checking the collector credential…");
|
|
504
|
+
let token;
|
|
505
|
+
try {
|
|
506
|
+
token = await readTokenFromStdin();
|
|
507
|
+
} catch (error) {
|
|
508
|
+
progress.stop();
|
|
509
|
+
throw error;
|
|
510
|
+
}
|
|
465
511
|
const configuredEndpoint = process.env.USAGEMAX_STATUS_ENDPOINT || DEFAULT_STATUS_ENDPOINT;
|
|
466
512
|
const endpoint = validHttpsUrl(configuredEndpoint, { allowLocalhost: true });
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
process.stdout.write(
|
|
473
|
-
|
|
513
|
+
try {
|
|
514
|
+
if (!endpoint) throw new Error("USAGEMAX_STATUS_ENDPOINT must use HTTPS, except for localhost development.");
|
|
515
|
+
const result = await requestCollectorStatus(endpoint, { token, deviceId: requestedDeviceId });
|
|
516
|
+
const view = collectorStatusView(result.httpStatus, result.body, token);
|
|
517
|
+
progress.succeed("Collector credential checked.");
|
|
518
|
+
if (json) process.stdout.write(`${JSON.stringify(view)}\n`);
|
|
519
|
+
else {
|
|
520
|
+
process.stdout.write("Credential format: valid (umx_ + 64 lowercase hexadecimal characters)\n");
|
|
521
|
+
printRemoteStatus(view);
|
|
522
|
+
}
|
|
523
|
+
} catch (error) {
|
|
524
|
+
progress.stop();
|
|
525
|
+
throw error;
|
|
474
526
|
}
|
|
475
527
|
}
|
|
476
528
|
|
|
477
529
|
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
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
530
|
+
const json = args.includes("--json");
|
|
531
|
+
const progress = createProgress({ json, quiet: args.includes("--quiet"), noProgress: args.includes("--no-progress") });
|
|
532
|
+
progress.start(args.includes("--deep") ? "Auditing retained source history…" : "Inspecting local source coverage…");
|
|
533
|
+
try {
|
|
534
|
+
const config = await readConfig();
|
|
535
|
+
const env = await ccusageEnvironment();
|
|
536
|
+
const inventory = await sourceInventory({ env, home: ccusageHome(env) });
|
|
537
|
+
progress.update(`Found ${inventory.sources.length} source${inventory.sources.length === 1 ? "" : "s"} and ${inventory.files}${inventory.truncated ? "+" : ""} local data file${inventory.files === 1 ? "" : "s"}.`);
|
|
538
|
+
const archives = await discoverProviderArchives({ env, home: ccusageHome(env) });
|
|
539
|
+
const homes = String(env.USAGEMAX_DISCOVERED_HOMES || ccusageHome(env)).split(",").filter(Boolean);
|
|
540
|
+
const result = {
|
|
541
|
+
linked: Boolean(config),
|
|
542
|
+
homes,
|
|
543
|
+
detectedSources: inventory.sources,
|
|
544
|
+
files: inventory.files,
|
|
545
|
+
inventoryComplete: inventory.complete,
|
|
546
|
+
inventoryErrors: inventory.errors,
|
|
547
|
+
inventoryTruncated: inventory.truncated,
|
|
548
|
+
supportedSources: SUPPORTED_SOURCES,
|
|
549
|
+
archives: archives.length,
|
|
550
|
+
environment: platform() === "linux" && process.env.WSL_DISTRO_NAME ? `WSL ${process.env.WSL_DISTRO_NAME}` : platform(),
|
|
551
|
+
mode: args.includes("--deep") ? "deep" : "metadata-only",
|
|
552
|
+
};
|
|
553
|
+
if (args.includes("--deep")) {
|
|
554
|
+
progress.update("Parsing retained history with ccusage…");
|
|
555
|
+
const report = await ccusageJson(config, { env, full: true });
|
|
556
|
+
result.parsedSources = sourceSummary(report);
|
|
557
|
+
result.sessions = buildSessionPlan(report, config?.deviceId || "unlinked").length;
|
|
558
|
+
}
|
|
559
|
+
progress.succeed("Coverage check complete.");
|
|
560
|
+
if (json) {
|
|
561
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
process.stdout.write(`Collector: ${config ? "linked" : "not linked"}\n`);
|
|
565
|
+
process.stdout.write(`Discovered homes: ${homes.length} (${homes.join(", ")})\n`);
|
|
566
|
+
process.stdout.write(`Detected sources: ${inventory.sources.join(", ") || "none"} (${inventory.files}${inventory.truncated ? "+" : ""} data files)\n`);
|
|
567
|
+
process.stdout.write(`Supported sources: ${SUPPORTED_SOURCES.join(", ")} (+ named pi-format stores)\n`);
|
|
568
|
+
if (platform() === "linux" && process.env.WSL_DISTRO_NAME) {
|
|
569
|
+
process.stdout.write(`Environment: WSL ${process.env.WSL_DISTRO_NAME}; readable Windows provider homes are included automatically\n`);
|
|
570
|
+
}
|
|
571
|
+
if (archives.length) process.stdout.write(`Recovery: ${archives.length} compressed provider archive(s) detected; run \`bunx usagemax sync --archives\` once to reconcile them\n`);
|
|
572
|
+
if (!inventory.complete) process.stdout.write(`Inventory: incomplete (${inventory.errors} read error(s)${inventory.truncated ? ", file limit reached" : ""}); no-change shortcut disabled\n`);
|
|
573
|
+
if (result.parsedSources) process.stdout.write(`Parsed sources: ${result.parsedSources.join(", ") || "none"}; ${result.sessions} private session identifiers\n`);
|
|
574
|
+
process.stdout.write(`Mode: one-shot, metadata no-op check, ${args.includes("--deep") ? "deep local parse" : "no log parsing"}\n`);
|
|
575
|
+
} catch (error) {
|
|
576
|
+
progress.stop();
|
|
577
|
+
throw error;
|
|
511
578
|
}
|
|
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
579
|
}
|
|
517
580
|
|
|
518
581
|
async function report(args) {
|
|
@@ -528,6 +591,41 @@ async function report(args) {
|
|
|
528
591
|
if (code !== 0) process.exitCode = code;
|
|
529
592
|
}
|
|
530
593
|
|
|
594
|
+
async function update(args = []) {
|
|
595
|
+
const check = await checkForUpdate(configDirectory(), VERSION, { force: true });
|
|
596
|
+
if (!check.latest) {
|
|
597
|
+
process.stdout.write(`UsageMax CLI ${VERSION} · update check unavailable.\n`);
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
if (!check.newer) {
|
|
601
|
+
process.stdout.write(`UsageMax CLI ${VERSION} is current.\n`);
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
process.stdout.write(`UsageMax CLI ${check.latest} is available (current ${VERSION}).\n`);
|
|
605
|
+
const commandArgs = args.filter((arg) => arg !== "--check");
|
|
606
|
+
if (!commandArgs.length || args.includes("--check")) {
|
|
607
|
+
process.stdout.write("Run `usagemax update sync` to hand off the next command to the latest npm release.\n");
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
process.stdout.write(`Launching UsageMax ${check.latest}…\n`);
|
|
611
|
+
return runLatest(commandArgs);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
async function maybeUpdate(command, args) {
|
|
615
|
+
if (process.env.USAGEMAX_UPDATE_HANDOFF === "1" || process.env.USAGEMAX_DISABLE_UPDATE_CHECK === "1" || args.includes("--no-update-check")) return false;
|
|
616
|
+
if (!["sync", "link", "doctor"].includes(command)) return false;
|
|
617
|
+
const explicitCheck = args.includes("--check-updates");
|
|
618
|
+
if (!explicitCheck && args.includes("--json")) return false;
|
|
619
|
+
const check = await checkForUpdate(configDirectory(), VERSION, { force: explicitCheck });
|
|
620
|
+
if (!check.newer) return false;
|
|
621
|
+
if (process.env.USAGEMAX_AUTO_UPDATE === "1") {
|
|
622
|
+
await runLatest([command, ...args.filter((arg) => !["--check-updates", "--no-update-check"].includes(arg))]);
|
|
623
|
+
return true;
|
|
624
|
+
}
|
|
625
|
+
process.stderr.write(`UsageMax ${check.latest} is available. Run \`usagemax update ${command}\` or set USAGEMAX_AUTO_UPDATE=1.\n`);
|
|
626
|
+
return false;
|
|
627
|
+
}
|
|
628
|
+
|
|
531
629
|
async function removeLink(args = []) {
|
|
532
630
|
const path = configPath();
|
|
533
631
|
const config = await readConfig();
|
|
@@ -559,6 +657,8 @@ async function main() {
|
|
|
559
657
|
const command = args[0] || "sync";
|
|
560
658
|
if (["--help", "-h", "help"].includes(command)) return help();
|
|
561
659
|
if (["--version", "-v"].includes(command)) return process.stdout.write(`${VERSION}\n`);
|
|
660
|
+
if (command === "update") return update(args.slice(1));
|
|
661
|
+
if (await maybeUpdate(command, args)) return;
|
|
562
662
|
if (command === "service") {
|
|
563
663
|
const action = args[1] || "status";
|
|
564
664
|
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/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
|
+
}
|