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 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@latest link UMX-XXXX-XXXX-XXXX-XXXX
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@latest link UMX-XXXX-XXXX-XXXX-XXXX
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@latest token status \
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.6`. If the public npm tag
124
- does not yet contain `0.3.6`, run `node packages/cli/src/cli.js token status`
125
- from the UsageMax repository until that release is published.
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@latest token status \
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.6",
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.6";
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 \`bunx usagemax@latest\` for current coverage fixes.\n`);
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 baseEnv = await ccusageEnvironment();
232
- const recovery = args.includes("--archives")
233
- ? await prepareArchiveRecovery(baseEnv)
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
- return await syncPrepared(args, suppliedConfig, recovery);
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.cleanup();
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, { save: writeConfig, request: snapshotRequest, warn: warnVersion });
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, { save: writeConfig, request: snapshotRequest, warn: warnVersion });
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 token = await readTokenFromStdin();
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
- if (!endpoint) throw new Error("USAGEMAX_STATUS_ENDPOINT must use HTTPS, except for localhost development.");
468
- const result = await requestCollectorStatus(endpoint, { token, deviceId: requestedDeviceId });
469
- const view = collectorStatusView(result.httpStatus, result.body, token);
470
- if (args.includes("--json")) process.stdout.write(`${JSON.stringify(view)}\n`);
471
- else {
472
- process.stdout.write("Credential format: valid (umx_ + 64 lowercase hexadecimal characters)\n");
473
- printRemoteStatus(view);
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 config = await readConfig();
479
- const env = await ccusageEnvironment();
480
- const inventory = await sourceInventory({ env, home: ccusageHome(env) });
481
- const archives = await discoverProviderArchives({ env, home: ccusageHome(env) });
482
- const homes = String(env.USAGEMAX_DISCOVERED_HOMES || ccusageHome(env)).split(",").filter(Boolean);
483
- const result = {
484
- linked: Boolean(config),
485
- homes,
486
- detectedSources: inventory.sources,
487
- files: inventory.files,
488
- inventoryComplete: inventory.complete,
489
- inventoryErrors: inventory.errors,
490
- inventoryTruncated: inventory.truncated,
491
- supportedSources: SUPPORTED_SOURCES,
492
- archives: archives.length,
493
- environment: platform() === "linux" && process.env.WSL_DISTRO_NAME ? `WSL ${process.env.WSL_DISTRO_NAME}` : platform(),
494
- mode: args.includes("--deep") ? "deep" : "metadata-only",
495
- };
496
- if (args.includes("--deep")) {
497
- const report = await ccusageJson(config, { env, full: true });
498
- result.parsedSources = sourceSummary(report);
499
- result.sessions = buildSessionPlan(report, config?.deviceId || "unlinked").length;
500
- }
501
- if (args.includes("--json")) {
502
- process.stdout.write(`${JSON.stringify(result)}\n`);
503
- return;
504
- }
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`);
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();
@@ -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
+ }