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/README.md +208 -177
- package/package.json +18 -2
- package/src/cli.js +266 -59
- package/src/progress.js +73 -0
- package/src/resume.js +3 -1
- package/src/service.js +67 -5
- package/src/transport.js +76 -0
- package/src/updates.js +99 -0
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.
|
|
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 ||
|
|
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 \`
|
|
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
|
|
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 (
|
|
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:
|
|
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 ${
|
|
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
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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
|
|
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, {
|
|
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, {
|
|
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
|
-
|
|
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
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
const
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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: {
|
|
440
|
-
|
|
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
|
});
|
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 };
|