usagemax 0.3.3 → 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/src/cli.js CHANGED
@@ -13,18 +13,27 @@ 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
- import { requestSnapshot } from "./transport.js";
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";
22
+
23
+ // Make the short-lived collector recognizable in Activity Monitor and `ps`.
24
+ // Windows may still display the underlying node.exe image name in Task Manager.
25
+ process.title = "UsageMax";
20
26
 
21
27
  const require = createRequire(import.meta.url);
22
28
  const executeFile = promisify(execFile);
23
- const VERSION = "0.3.3";
29
+ const VERSION = "0.3.7";
24
30
  const PUBLIC_API_ORIGIN = "https://usagemax.com/api";
25
31
  const DEFAULT_LINK_ENDPOINT = `${PUBLIC_API_ORIGIN}/v1/devices/link`;
32
+ const DEFAULT_STATUS_ENDPOINT = `${PUBLIC_API_ORIGIN}/v1/devices/status`;
26
33
  const CONFIG_FILE = "config.json";
27
34
  const MAX_REPORT_BYTES = 100 * 1024 * 1024;
35
+ const TOKEN_PATTERN = /^umx_[a-f0-9]{64}$/;
36
+ const DEVICE_PATTERN = /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i;
28
37
 
29
38
  function configDirectory() {
30
39
  if (process.env.USAGEMAX_CONFIG_DIR) return process.env.USAGEMAX_CONFIG_DIR;
@@ -51,18 +60,21 @@ function isLegacyDirectApi(config) {
51
60
  async function readConfig() {
52
61
  try {
53
62
  const parsed = JSON.parse(await readFile(configPath(), "utf8"));
54
- if (!parsed || parsed.version !== 1 || !/^umx_[a-f0-9]{64}$/.test(parsed.token || "")) return null;
63
+ if (!parsed || parsed.version !== 1 || !TOKEN_PATTERN.test(parsed.token || "")) return null;
55
64
  if (!validHttpsUrl(parsed.ingestUrl, { allowLocalhost: true })) return null;
56
65
  if (typeof parsed.deviceId !== "string" || !parsed.deviceId) return null;
57
66
  parsed.snapshots = parsed.snapshots && typeof parsed.snapshots === "object" ? parsed.snapshots : {};
58
67
  if (isLegacyDirectApi(parsed)) {
59
68
  parsed.ingestUrl = `${PUBLIC_API_ORIGIN}/v1/telemetry/llm`;
60
69
  parsed.snapshotUrl = `${PUBLIC_API_ORIGIN}/v2/usage/snapshots`;
70
+ parsed.statusUrl = `${PUBLIC_API_ORIGIN}/v1/devices/status`;
61
71
  parsed.revokeUrl = `${PUBLIC_API_ORIGIN}/v1/devices/revoke`;
62
72
  await writeConfig(parsed);
63
73
  }
64
74
  parsed.snapshotUrl = validHttpsUrl(parsed.snapshotUrl, { allowLocalhost: true })
65
75
  || parsed.ingestUrl.replace(/\/v1\/telemetry\/llm$/, "/v2/usage/snapshots");
76
+ parsed.statusUrl = validHttpsUrl(parsed.statusUrl, { allowLocalhost: true })
77
+ || parsed.ingestUrl.replace(/\/v1\/telemetry\/llm$/, "/v1/devices/status");
66
78
  parsed.revokeUrl = validHttpsUrl(parsed.revokeUrl, { allowLocalhost: true })
67
79
  || parsed.ingestUrl.replace(/\/v1\/telemetry\/llm$/, "/v1/devices/revoke");
68
80
  return parsed;
@@ -108,12 +120,18 @@ function help() {
108
120
  process.stdout.write(" [--no-sync] [--name <name>]\n");
109
121
  process.stdout.write(" usagemax sync [--full] [--archives] [--restart] [--dry-run] [--explain] [--json]\n");
110
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");
111
124
  process.stdout.write(" usagemax status Show local link status\n");
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");
127
+ process.stdout.write(" usagemax token status [--device-id <uuid>] [--json]\n");
128
+ process.stdout.write(" Diagnose a key piped on stdin; never pass it as an argument\n");
112
129
  process.stdout.write(" usagemax service install [--every 15]\n");
113
130
  process.stdout.write(" Opt into lightweight OS-scheduled sync\n");
114
131
  process.stdout.write(" usagemax service status|run|uninstall\n");
115
- process.stdout.write(" usagemax doctor [--deep] [--json]\n");
132
+ process.stdout.write(" usagemax doctor [--deep] [--json] [--quiet|--no-progress]\n");
116
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");
117
135
  process.stdout.write(" usagemax report [...args] Run a local ccusage report\n");
118
136
  process.stdout.write(" usagemax unlink [--revoke] Remove locally; --revoke also disables uploads\n");
119
137
  }
@@ -157,7 +175,7 @@ function newerVersion(recommended) {
157
175
 
158
176
  function warnVersion(body) {
159
177
  if (newerVersion(body?.recommendedCliVersion)) {
160
- 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`);
161
179
  }
162
180
  }
163
181
 
@@ -167,7 +185,8 @@ async function link(args) {
167
185
  const configuredEndpoint = process.env.USAGEMAX_LINK_ENDPOINT || DEFAULT_LINK_ENDPOINT;
168
186
  const endpoint = validHttpsUrl(configuredEndpoint, { allowLocalhost: true });
169
187
  if (!endpoint) throw new Error("USAGEMAX_LINK_ENDPOINT must use HTTPS, except for localhost development.");
170
- const name = (option(args, "--name") || deviceLabel()).trim().slice(0, 80);
188
+ const requestedName = option(args, "--name");
189
+ const name = requestedName?.trim().slice(0, 80) || undefined;
171
190
  const previous = await readConfig();
172
191
  const deviceId = await stableInstallationId(configDirectory(), previous?.deviceId);
173
192
  const headers = { "content-type": "application/json" };
@@ -175,33 +194,36 @@ async function link(args) {
175
194
  const response = await fetch(endpoint, {
176
195
  method: "POST",
177
196
  headers,
178
- body: JSON.stringify({ code, name, platform: platform(), cliVersion: VERSION, deviceId }),
197
+ body: JSON.stringify({ code, ...(name ? { name, nameExplicit: true } : {}), platform: platform(), cliVersion: VERSION, deviceId }),
179
198
  signal: AbortSignal.timeout(15_000),
180
199
  });
181
200
  const body = await response.json().catch(() => ({}));
182
201
  if (!response.ok) throw new Error(body?.error === "invalid_or_expired_link_code" ? "That link code is invalid, expired, or already used." : "UsageMax could not link this computer.");
183
202
  const ingestUrl = validHttpsUrl(body.ingestUrl, { allowLocalhost: true });
184
203
  const snapshotUrl = validHttpsUrl(body.snapshotUrl, { allowLocalhost: true }) || ingestUrl?.replace(/\/v1\/telemetry\/llm$/, "/v2/usage/snapshots");
204
+ const statusUrl = validHttpsUrl(body.statusUrl, { allowLocalhost: true }) || ingestUrl?.replace(/\/v1\/telemetry\/llm$/, "/v1/devices/status");
185
205
  const revokeUrl = validHttpsUrl(body.revokeUrl, { allowLocalhost: true }) || ingestUrl?.replace(/\/v1\/telemetry\/llm$/, "/v1/devices/revoke");
186
- if (!/^umx_[a-f0-9]{64}$/.test(body.token || "") || !ingestUrl || !snapshotUrl || !revokeUrl) throw new Error("UsageMax returned an invalid link response.");
206
+ if (!TOKEN_PATTERN.test(body.token || "") || !ingestUrl || !snapshotUrl || !statusUrl || !revokeUrl) throw new Error("UsageMax returned an invalid link response.");
187
207
  const profileHandle = typeof body.profileHandle === "string" ? body.profileHandle : undefined;
208
+ const savedName = typeof body.deviceName === "string" && body.deviceName.trim() ? body.deviceName.trim().slice(0, 80) : (name || deviceLabel());
188
209
  const sameAccount = Boolean(previous && previous.profileHandle && previous.profileHandle === profileHandle);
189
210
  const config = {
190
211
  version: 1,
191
212
  token: body.token,
192
213
  ingestUrl,
193
214
  snapshotUrl,
215
+ statusUrl,
194
216
  revokeUrl,
195
217
  profileUrl: validHttpsUrl(body.profileUrl) || "https://usagemax.com/account",
196
218
  profileHandle,
197
219
  deviceId,
198
- deviceName: name,
220
+ deviceName: savedName,
199
221
  linkedAt: new Date().toISOString(),
200
222
  snapshots: sameAccount ? previous.snapshots : {},
201
223
  };
202
224
  await writeConfig(config);
203
225
  warnVersion(body);
204
- process.stdout.write(`Linked ${name} to ${config.profileHandle ? `@${config.profileHandle}` : "UsageMax"}.\n`);
226
+ process.stdout.write(`Linked ${savedName} to ${config.profileHandle ? `@${config.profileHandle}` : "UsageMax"}.\n`);
205
227
  if (args.includes("--no-sync")) {
206
228
  process.stdout.write("No usage was uploaded. Run `bunx usagemax sync --full` when you are ready.\n");
207
229
  return;
@@ -211,18 +233,27 @@ async function link(args) {
211
233
  }
212
234
 
213
235
  async function sync(args, suppliedConfig) {
214
- const baseEnv = await ccusageEnvironment();
215
- const recovery = args.includes("--archives")
216
- ? await prepareArchiveRecovery(baseEnv)
217
- : { 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;
218
239
  try {
219
- 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;
220
250
  } finally {
221
- await recovery.cleanup();
251
+ await recovery?.cleanup();
252
+ progress.stop();
222
253
  }
223
254
  }
224
255
 
225
- async function syncPrepared(args, suppliedConfig, recovery) {
256
+ async function syncPrepared(args, suppliedConfig, recovery, progress = createProgress({ noProgress: true })) {
226
257
  const config = suppliedConfig || await readConfig();
227
258
  if (!config) throw new Error("This computer is not linked. Open https://usagemax.com/account and create a link code.");
228
259
  config.deviceId = await stableInstallationId(configDirectory(), config.deviceId);
@@ -236,16 +267,25 @@ async function syncPrepared(args, suppliedConfig, recovery) {
236
267
  await writeConfig(config);
237
268
  }
238
269
  if (config.pendingSync) {
270
+ progress.update("Resuming the saved upload checkpoint…");
239
271
  if (dryRun) {
240
272
  const result = { ...config.pendingSync.result, dryRun: true, pendingRunId: config.pendingSync.runId };
241
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.");
242
275
  return result;
243
276
  }
244
- 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.");
245
284
  process.stdout.write(json ? `${JSON.stringify(result)}\n` : "Resumed and completed the saved sync. Run sync again to scan newer local changes.\n");
246
285
  return result;
247
286
  }
248
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"}.`);
249
289
  const today = new Date().toISOString().slice(0, 10);
250
290
  const knownSources = Array.isArray(config.knownSources) ? config.knownSources : [];
251
291
  const { bootstrap, full, skip, inventoryStable } = scanPolicy(config, inventory, {
@@ -255,8 +295,10 @@ async function syncPrepared(args, suppliedConfig, recovery) {
255
295
  const result = { accepted: 0, changedRows: 0, sessions: 0, sources: inventory.sources, corrections: 0, scanned: false, full: false, coverage: config.lastCoverage || "partial" };
256
296
  if (json) process.stdout.write(`${JSON.stringify(result)}\n`);
257
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.");
258
299
  return;
259
300
  }
301
+ progress.update(`Parsing ${full ? "retained history" : "changed history"} with ccusage…`);
260
302
  const report = await ccusageJson(config, { env: recovery.env, full });
261
303
  // ccusage v20 exposes aggregates, not proof that every discovered file was
262
304
  // parsed. Inventory success alone cannot authorize destructive corrections.
@@ -293,6 +335,7 @@ async function syncPrepared(args, suppliedConfig, recovery) {
293
335
  coverageReason: "Parser does not certify complete source/day coverage; decreases and deletions are protected.",
294
336
  range: { from: days[0], to: days.at(-1) },
295
337
  };
338
+ progress.update(`Prepared ${partitions.length} usage chunk${partitions.length === 1 ? "" : "s"} and ${sessions.length} session identifier${sessions.length === 1 ? "" : "s"}.`);
296
339
  if (requestedArchives) {
297
340
  result.archives = recovery.archives;
298
341
  result.unsupportedArchives = recovery.unsupported;
@@ -303,6 +346,7 @@ async function syncPrepared(args, suppliedConfig, recovery) {
303
346
  process.stdout.write(`Dry run: ${partitions.length} partition(s), ${result.changedRows} changed row(s), ${sessions.length} private session identifiers, no upload.\n`);
304
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`);
305
348
  }
349
+ progress.succeed("Dry run complete; nothing uploaded.");
306
350
  return result;
307
351
  }
308
352
  const requests = [{ operation: "begin", payload: {
@@ -339,7 +383,13 @@ async function syncPrepared(args, suppliedConfig, recovery) {
339
383
  };
340
384
  config.lastSyncComplete = false;
341
385
  await writeConfig(config);
342
- 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"}.`);
343
393
  if (json) process.stdout.write(`${JSON.stringify(result)}\n`);
344
394
  else {
345
395
  process.stdout.write(partitions.length || sessions.length
@@ -352,9 +402,38 @@ async function syncPrepared(args, suppliedConfig, recovery) {
352
402
  return result;
353
403
  }
354
404
 
355
- async function status() {
405
+ function collectorStatusEndpoint(config) {
406
+ return validHttpsUrl(process.env.USAGEMAX_STATUS_ENDPOINT, { allowLocalhost: true })
407
+ || validHttpsUrl(config.statusUrl, { allowLocalhost: true })
408
+ || config.ingestUrl.replace(/\/v1\/telemetry\/llm$/, "/v1/devices/status");
409
+ }
410
+
411
+ function printRemoteStatus(view) {
412
+ process.stdout.write(`Remote credential: ${view.status || "unavailable"}${view.httpStatus ? ` (HTTP ${view.httpStatus})` : ""}\n`);
413
+ if (view.reason) process.stdout.write(`${view.reason}\n`);
414
+ if (view.credentialType) process.stdout.write(`Type: ${view.credentialType}${view.writeOnly ? "; write-only" : ""}\n`);
415
+ if (view.scopes?.length) process.stdout.write(`Scopes: ${view.scopes.join(", ")}\n`);
416
+ if (view.scopeStatus) process.stdout.write(`Ingest scope: ${view.scopeStatus === "valid" ? "authorized" : "missing telemetry:write"}\n`);
417
+ if (view.deviceBinding) process.stdout.write(`Device binding: ${view.deviceBinding}\n`);
418
+ if (view.profileHandle) process.stdout.write(`Profile: @${view.profileHandle}\n`);
419
+ if (view.deviceName) process.stdout.write(`Computer: ${view.deviceName}\n`);
420
+ if (view.platform || view.cliVersion) process.stdout.write(`Runtime: ${view.platform || "unknown"}${view.cliVersion ? ` · CLI ${view.cliVersion}` : ""}\n`);
421
+ if (view.activation) process.stdout.write(`Activation: ${view.activation}\n`);
422
+ if (view.expiresAt === null) process.stdout.write("Expiration: none\n");
423
+ if (view.lastSuccessAt) process.stdout.write(`Last accepted write: ${new Date(view.lastSuccessAt).toISOString()}\n`);
424
+ if (view.lastFailureAt) process.stdout.write(`Last rejected write: ${new Date(view.lastFailureAt).toISOString()}${view.lastFailureCode ? ` · ${view.lastFailureCode}` : ""}\n`);
425
+ }
426
+
427
+ async function status(args = []) {
356
428
  const config = await readConfig();
429
+ const remote = args.includes("--remote");
430
+ const json = args.includes("--json");
431
+ const progress = createProgress({ json, quiet: args.includes("--quiet"), noProgress: args.includes("--no-progress") });
357
432
  if (!config) {
433
+ if (json) {
434
+ process.stdout.write(`${JSON.stringify({ linked: false, remote: remote ? { status: "not_linked" } : undefined })}\n`);
435
+ return;
436
+ }
358
437
  process.stdout.write("Not linked. Open https://usagemax.com/account to connect this computer.\n");
359
438
  return;
360
439
  }
@@ -363,52 +442,140 @@ async function status() {
363
442
  config.deviceId = stableId;
364
443
  await writeConfig(config);
365
444
  }
445
+ const local = {
446
+ linked: true,
447
+ deviceName: config.deviceName || deviceLabel(),
448
+ profileHandle: config.profileHandle || null,
449
+ deviceIdConfigured: Boolean(config.deviceId),
450
+ lastSyncAt: config.lastSyncAt || null,
451
+ lastFullSyncAt: config.lastFullSyncAt || null,
452
+ pendingSync: config.pendingSync?.runId || null,
453
+ profileUrl: config.profileUrl || "https://usagemax.com/account",
454
+ };
455
+ let remoteView;
456
+ if (remote) {
457
+ progress.start("Checking the stored collector credential…");
458
+ try {
459
+ const result = await requestCollectorStatus(collectorStatusEndpoint(config), config);
460
+ remoteView = collectorStatusView(result.httpStatus, result.body, config.token);
461
+ progress.succeed("Remote credential checked.");
462
+ } catch (error) {
463
+ progress.stop();
464
+ remoteView = { tokenFormat: "valid", status: "unavailable", reason: error instanceof Error ? error.message : "Collector status unavailable." };
465
+ }
466
+ if (json) {
467
+ process.stdout.write(`${JSON.stringify({ ...local, remote: remoteView })}\n`);
468
+ return;
469
+ }
470
+ }
471
+ if (json) {
472
+ process.stdout.write(`${JSON.stringify(local)}\n`);
473
+ return;
474
+ }
366
475
  process.stdout.write(`Linked: ${config.deviceName || deviceLabel()}${config.profileHandle ? ` → @${config.profileHandle}` : ""}\n`);
367
476
  process.stdout.write(`Last sync: ${config.lastSyncAt || "never"}\n`);
368
477
  process.stdout.write(`Last full reconciliation: ${config.lastFullSyncAt || "never"}\n`);
369
478
  if (config.pendingSync) process.stdout.write(`Pending sync: ${config.pendingSync.runId}; rerun sync to resume\n`);
370
479
  process.stdout.write(`Profile: ${config.profileUrl || "https://usagemax.com/account"}\n`);
480
+ if (remote) printRemoteStatus(remoteView);
371
481
  }
372
482
 
373
- async function doctor(args = []) {
374
- const config = await readConfig();
375
- const env = await ccusageEnvironment();
376
- const inventory = await sourceInventory({ env, home: ccusageHome(env) });
377
- const archives = await discoverProviderArchives({ env, home: ccusageHome(env) });
378
- const homes = String(env.USAGEMAX_DISCOVERED_HOMES || ccusageHome(env)).split(",").filter(Boolean);
379
- const result = {
380
- linked: Boolean(config),
381
- homes,
382
- detectedSources: inventory.sources,
383
- files: inventory.files,
384
- inventoryComplete: inventory.complete,
385
- inventoryErrors: inventory.errors,
386
- inventoryTruncated: inventory.truncated,
387
- supportedSources: SUPPORTED_SOURCES,
388
- archives: archives.length,
389
- environment: platform() === "linux" && process.env.WSL_DISTRO_NAME ? `WSL ${process.env.WSL_DISTRO_NAME}` : platform(),
390
- mode: args.includes("--deep") ? "deep" : "metadata-only",
391
- };
392
- if (args.includes("--deep")) {
393
- const report = await ccusageJson(config, { env, full: true });
394
- result.parsedSources = sourceSummary(report);
395
- result.sessions = buildSessionPlan(report, config?.deviceId || "unlinked").length;
483
+ async function readTokenFromStdin() {
484
+ if (process.stdin.isTTY) throw new Error("Pipe the collector token on stdin; never pass it as a command-line argument.");
485
+ // fs.promises.readFile does not consistently accept file descriptor 0
486
+ // across the Node versions supported by the CLI. Read the pipe as a stream
487
+ // instead, and bound it so an accidental large stdin cannot be buffered.
488
+ let input = "";
489
+ for await (const chunk of process.stdin) {
490
+ input += String(chunk);
491
+ if (input.length > 256) throw new Error("stdin did not contain a valid UsageMax collector token.");
396
492
  }
397
- if (args.includes("--json")) {
398
- process.stdout.write(`${JSON.stringify(result)}\n`);
399
- return;
493
+ const token = input.trim();
494
+ if (!TOKEN_PATTERN.test(token)) throw new Error("stdin did not contain a valid UsageMax collector token (expected umx_ plus 64 lowercase hexadecimal characters).");
495
+ return token;
496
+ }
497
+
498
+ async function tokenStatus(args = []) {
499
+ const requestedDeviceId = option(args, "--device-id");
500
+ if (requestedDeviceId && !DEVICE_PATTERN.test(requestedDeviceId)) throw new Error("--device-id must be a UUID.");
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
+ }
511
+ const configuredEndpoint = process.env.USAGEMAX_STATUS_ENDPOINT || DEFAULT_STATUS_ENDPOINT;
512
+ const endpoint = validHttpsUrl(configuredEndpoint, { allowLocalhost: true });
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;
400
526
  }
401
- process.stdout.write(`Collector: ${config ? "linked" : "not linked"}\n`);
402
- process.stdout.write(`Discovered homes: ${homes.length} (${homes.join(", ")})\n`);
403
- process.stdout.write(`Detected sources: ${inventory.sources.join(", ") || "none"} (${inventory.files}${inventory.truncated ? "+" : ""} data files)\n`);
404
- process.stdout.write(`Supported sources: ${SUPPORTED_SOURCES.join(", ")} (+ named pi-format stores)\n`);
405
- if (platform() === "linux" && process.env.WSL_DISTRO_NAME) {
406
- process.stdout.write(`Environment: WSL ${process.env.WSL_DISTRO_NAME}; readable Windows provider homes are included automatically\n`);
527
+ }
528
+
529
+ async function doctor(args = []) {
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;
407
578
  }
408
- if (archives.length) process.stdout.write(`Recovery: ${archives.length} compressed provider archive(s) detected; run \`bunx usagemax sync --archives\` once to reconcile them\n`);
409
- if (!inventory.complete) process.stdout.write(`Inventory: incomplete (${inventory.errors} read error(s)${inventory.truncated ? ", file limit reached" : ""}); no-change shortcut disabled\n`);
410
- if (result.parsedSources) process.stdout.write(`Parsed sources: ${result.parsedSources.join(", ") || "none"}; ${result.sessions} private session identifiers\n`);
411
- process.stdout.write(`Mode: one-shot, metadata no-op check, ${args.includes("--deep") ? "deep local parse" : "no log parsing"}\n`);
412
579
  }
413
580
 
414
581
  async function report(args) {
@@ -424,6 +591,41 @@ async function report(args) {
424
591
  if (code !== 0) process.exitCode = code;
425
592
  }
426
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
+
427
629
  async function removeLink(args = []) {
428
630
  const path = configPath();
429
631
  const config = await readConfig();
@@ -436,8 +638,10 @@ async function removeLink(args = []) {
436
638
  if (!endpoint) throw new Error("This collector does not have a valid revoke endpoint. Revoke it at https://usagemax.com/account.");
437
639
  const response = await fetch(endpoint, {
438
640
  method: "POST",
439
- headers: { authorization: `Bearer ${config.token}`, "content-type": "application/json" },
440
- body: JSON.stringify({ deviceId: config.deviceId }),
641
+ headers: {
642
+ authorization: `Bearer ${config.token}`,
643
+ "x-usagemax-device-id": config.deviceId,
644
+ },
441
645
  signal: AbortSignal.timeout(15_000),
442
646
  });
443
647
  if (!response.ok) throw new Error("UsageMax could not revoke this collector. It remains linked locally.");
@@ -453,6 +657,8 @@ async function main() {
453
657
  const command = args[0] || "sync";
454
658
  if (["--help", "-h", "help"].includes(command)) return help();
455
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;
456
662
  if (command === "service") {
457
663
  const action = args[1] || "status";
458
664
  const directory = option(args, "--config-dir") || configDirectory();
@@ -474,11 +680,12 @@ async function main() {
474
680
  return;
475
681
  }
476
682
  if (command === "report") return report(args.slice(1));
683
+ if (command === "token" && args[1] === "status") return tokenStatus(args.slice(2));
477
684
  if (["link", "sync", "status", "doctor", "unlink"].includes(command)) {
478
685
  return withConfigLock(configDirectory(), async () => {
479
686
  if (command === "link") return link(args.slice(1));
480
687
  if (command === "sync") return sync(args.slice(1));
481
- if (command === "status") return status();
688
+ if (command === "status") return status(args.slice(1));
482
689
  if (command === "doctor") return doctor(args.slice(1));
483
690
  return removeLink(args.slice(1));
484
691
  });
@@ -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 };