usagemax 0.1.1 → 0.3.0

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
@@ -1,25 +1,27 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { execFile, spawn } from "node:child_process";
4
- import { createHash, randomUUID } from "node:crypto";
5
- import { chmod, mkdir, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
4
+ import { randomUUID } from "node:crypto";
5
+ import { chmod, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
6
6
  import { createRequire } from "node:module";
7
7
  import { homedir, platform } from "node:os";
8
- import { delimiter, dirname, extname, join } from "node:path";
8
+ import { dirname, join } from "node:path";
9
9
  import process from "node:process";
10
10
  import { promisify } from "node:util";
11
11
 
12
- import { batchId, buildDeltaPlan, normalizeLinkCode, sourceSummary, validHttpsUrl } from "./core.js";
12
+ import { prepareArchiveRecovery } from "./archives.js";
13
+ import { buildSessionPlan, buildSnapshotPlan, normalizeLinkCode, sourceSummary, validHttpsUrl } from "./core.js";
13
14
  import { stableInstallationId } from "./installation.js";
15
+ import { CCUSAGE_VERSION, ccusageEnvironment, ccusageHome, discoverProviderArchives, SOURCE_INVENTORY_VERSION, sourceInventory, SUPPORTED_SOURCES } from "./sources.js";
14
16
 
15
17
  const require = createRequire(import.meta.url);
16
18
  const executeFile = promisify(execFile);
17
- const VERSION = "0.1.1";
18
- const DEFAULT_LINK_ENDPOINT = "https://terrific-bobcat-522.convex.site/v1/devices/link";
19
+ const VERSION = "0.3.0";
20
+ const PUBLIC_API_ORIGIN = "https://usagemax.com/api";
21
+ const DEFAULT_LINK_ENDPOINT = `${PUBLIC_API_ORIGIN}/v1/devices/link`;
19
22
  const CONFIG_FILE = "config.json";
20
23
  const MAX_REPORT_BYTES = 100 * 1024 * 1024;
21
- const MAX_FINGERPRINT_FILES = 50_000;
22
- const USAGE_EXTENSIONS = new Set([".db", ".json", ".jsonl", ".sqlite", ".sqlite3"]);
24
+ const FULL_RECONCILE_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000;
23
25
 
24
26
  function configDirectory() {
25
27
  if (process.env.USAGEMAX_CONFIG_DIR) return process.env.USAGEMAX_CONFIG_DIR;
@@ -31,6 +33,18 @@ function configPath() {
31
33
  return join(configDirectory(), CONFIG_FILE);
32
34
  }
33
35
 
36
+ function isLegacyDirectApi(config) {
37
+ try {
38
+ const ingest = new URL(config.ingestUrl);
39
+ const profile = new URL(config.profileUrl || "https://usagemax.com");
40
+ return profile.hostname === "usagemax.com"
41
+ && ingest.hostname.endsWith(".convex.site")
42
+ && ingest.pathname === "/v1/telemetry/llm";
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
34
48
  async function readConfig() {
35
49
  try {
36
50
  const parsed = JSON.parse(await readFile(configPath(), "utf8"));
@@ -38,6 +52,16 @@ async function readConfig() {
38
52
  if (!validHttpsUrl(parsed.ingestUrl, { allowLocalhost: true })) return null;
39
53
  if (typeof parsed.deviceId !== "string" || !parsed.deviceId) return null;
40
54
  parsed.snapshots = parsed.snapshots && typeof parsed.snapshots === "object" ? parsed.snapshots : {};
55
+ if (isLegacyDirectApi(parsed)) {
56
+ parsed.ingestUrl = `${PUBLIC_API_ORIGIN}/v1/telemetry/llm`;
57
+ parsed.snapshotUrl = `${PUBLIC_API_ORIGIN}/v2/usage/snapshots`;
58
+ parsed.revokeUrl = `${PUBLIC_API_ORIGIN}/v1/devices/revoke`;
59
+ await writeConfig(parsed);
60
+ }
61
+ parsed.snapshotUrl = validHttpsUrl(parsed.snapshotUrl, { allowLocalhost: true })
62
+ || parsed.ingestUrl.replace(/\/v1\/telemetry\/llm$/, "/v2/usage/snapshots");
63
+ parsed.revokeUrl = validHttpsUrl(parsed.revokeUrl, { allowLocalhost: true })
64
+ || parsed.ingestUrl.replace(/\/v1\/telemetry\/llm$/, "/v1/devices/revoke");
41
65
  return parsed;
42
66
  } catch {
43
67
  return null;
@@ -67,6 +91,7 @@ function option(args, name) {
67
91
  function deviceLabel() {
68
92
  if (platform() === "darwin") return "Mac";
69
93
  if (platform() === "win32") return "Windows PC";
94
+ if (platform() === "linux" && process.env.WSL_DISTRO_NAME) return `WSL · ${process.env.WSL_DISTRO_NAME}`;
70
95
  if (platform() === "linux") return "Linux computer";
71
96
  return "Computer";
72
97
  }
@@ -78,106 +103,76 @@ function help() {
78
103
  process.stdout.write(" usagemax Sync changed local usage\n");
79
104
  process.stdout.write(" usagemax link <one-use-code> Link and sync this computer\n");
80
105
  process.stdout.write(" [--no-sync] [--name <name>]\n");
81
- process.stdout.write(" usagemax sync [--full] [--json] Sync usage once, then exit\n");
106
+ process.stdout.write(" usagemax sync [--full] [--archives] [--dry-run] [--explain] [--json]\n");
107
+ process.stdout.write(" Reconcile once; --archives performs one-time recovery\n");
82
108
  process.stdout.write(" usagemax status Show local link status\n");
83
- process.stdout.write(" usagemax doctor [--deep] Check sources without parsing logs\n");
109
+ process.stdout.write(" usagemax doctor [--deep] [--json]\n");
110
+ process.stdout.write(" Check source coverage; --deep parses full history\n");
84
111
  process.stdout.write(" usagemax report [...args] Run a local ccusage report\n");
85
- process.stdout.write(" usagemax unlink Remove the local collector key\n");
112
+ process.stdout.write(" usagemax unlink [--revoke] Remove locally; --revoke also disables uploads\n");
86
113
  }
87
114
 
88
115
  function ccusageCliPath() {
89
116
  return join(dirname(require.resolve("ccusage/package.json")), "src", "cli.js");
90
117
  }
91
118
 
92
- function configuredPaths(variable, fallbacks) {
93
- const configured = process.env[variable];
94
- return configured
95
- ? configured.split(",").flatMap((group) => group.split(delimiter)).map((value) => value.trim()).filter(Boolean)
96
- : fallbacks;
97
- }
98
-
99
- function usageRoots() {
100
- const home = homedir();
101
- return [
102
- ["claude", configuredPaths("CLAUDE_CONFIG_DIR", [join(home, ".config", "claude", "projects"), join(home, ".claude", "projects")])],
103
- ["codex", configuredPaths("CODEX_HOME", [join(home, ".codex", "sessions"), join(home, ".codex", "archived_sessions")])],
104
- ["opencode", configuredPaths("OPENCODE_DATA_DIR", [join(home, ".local", "share", "opencode")])],
105
- ["hermes", configuredPaths("HERMES_HOME", [join(home, ".hermes", "sessions")])],
106
- ["pi", configuredPaths("PI_AGENT_DIR", [join(home, ".pi", "agent", "sessions")])],
107
- ["copilot", configuredPaths("COPILOT_HOME", [join(home, ".copilot")])],
108
- ["gemini", configuredPaths("GEMINI_DATA_DIR", [join(home, ".gemini", "tmp")])],
109
- ].flatMap(([source, paths]) => paths.map((path) => ({ source, path })));
110
- }
111
-
112
- async function sourceInventory() {
113
- const hash = createHash("sha256");
114
- const sources = new Set();
115
- const roots = usageRoots();
116
- let files = 0;
117
- let truncated = false;
118
- for (const root of roots) {
119
- try {
120
- const rootStat = await stat(root.path);
121
- if (!rootStat.isDirectory() && !rootStat.isFile()) continue;
122
- sources.add(root.source);
123
- } catch {
124
- continue;
125
- }
126
- const stack = [root.path];
127
- while (stack.length && files < MAX_FINGERPRINT_FILES) {
128
- const current = stack.pop();
129
- let entries;
130
- try {
131
- entries = await readdir(current, { withFileTypes: true });
132
- } catch {
133
- try {
134
- const metadata = await stat(current);
135
- if (metadata.isFile() && USAGE_EXTENSIONS.has(extname(current).toLowerCase())) {
136
- hash.update(`${root.source}\u0000${current}\u0000${metadata.size}\u0000${metadata.mtimeMs}\n`);
137
- files += 1;
138
- }
139
- } catch {}
140
- continue;
141
- }
142
- for (const entry of entries) {
143
- const path = join(current, entry.name);
144
- if (entry.isDirectory()) {
145
- if (!["node_modules", ".git", "cache", "tmp"].includes(entry.name)) stack.push(path);
146
- continue;
147
- }
148
- if (!entry.isFile() || !USAGE_EXTENSIONS.has(extname(entry.name).toLowerCase())) continue;
149
- try {
150
- const metadata = await stat(path);
151
- hash.update(`${root.source}\u0000${path}\u0000${metadata.size}\u0000${metadata.mtimeMs}\n`);
152
- files += 1;
153
- } catch {}
154
- if (files >= MAX_FINGERPRINT_FILES) {
155
- truncated = true;
156
- break;
157
- }
158
- }
159
- }
160
- }
161
- return { fingerprint: hash.digest("hex"), sources: [...sources].sort(), files, truncated };
162
- }
163
-
164
- async function ccusageJson(config, { full = false } = {}) {
165
- const args = [ccusageCliPath(), "daily", "--json", "--offline", "--by-agent", "--order", "asc"];
119
+ async function ccusageJson(config, { full = false, env } = {}) {
120
+ const args = [ccusageCliPath(), "daily", "--json", "--offline", "--mode", "calculate", "--timezone", "UTC", "--by-agent", "--order", "asc"];
121
+ args.push("--sections", "daily,session");
166
122
  // Reconcile yesterday once after the UTC date changes. All other incremental
167
123
  // scans parse only today; a metadata fingerprint avoids invoking ccusage when
168
124
  // no supported local source changed at all.
169
- if (!full && config?.lastSyncAt) {
125
+ if (full) {
126
+ args.push("--since", "2024-01-01", "--until", new Date().toISOString().slice(0, 10));
127
+ } else if (config?.lastSyncAt) {
170
128
  const today = new Date().toISOString().slice(0, 10);
171
129
  args.push("--last", config.lastReconciledDay === today ? "1" : "2");
172
130
  }
173
131
  const { stdout } = await executeFile(process.execPath, args, {
174
132
  encoding: "utf8",
175
133
  maxBuffer: MAX_REPORT_BYTES,
176
- env: { ...process.env, NO_COLOR: "1" },
134
+ env: { ...(env || await ccusageEnvironment()), NO_COLOR: "1" },
177
135
  });
178
136
  return JSON.parse(stdout);
179
137
  }
180
138
 
139
+ function snapshotEndpoint(config) {
140
+ return validHttpsUrl(config.snapshotUrl, { allowLocalhost: true })
141
+ || config.ingestUrl.replace(/\/v1\/telemetry\/llm$/, "/v2/usage/snapshots");
142
+ }
143
+
144
+ async function snapshotRequest(config, operation, payload, timeout = 30_000) {
145
+ const response = await fetch(snapshotEndpoint(config), {
146
+ method: "POST",
147
+ headers: {
148
+ authorization: `Bearer ${config.token}`,
149
+ "content-type": "application/json",
150
+ "x-usagemax-device-id": config.deviceId,
151
+ },
152
+ body: JSON.stringify({ operation, ...payload }),
153
+ signal: AbortSignal.timeout(timeout),
154
+ });
155
+ const body = await response.json().catch(() => ({}));
156
+ if (!response.ok) {
157
+ if (body?.error === "unauthorized") throw new Error("This collector key is no longer valid. Link the computer again.");
158
+ throw new Error(`UsageMax rejected ${operation} (${response.status}${body?.error ? `: ${body.error}` : ""}).`);
159
+ }
160
+ return body;
161
+ }
162
+
163
+ function newerVersion(recommended) {
164
+ if (!/^\d+\.\d+\.\d+$/.test(recommended || "")) return false;
165
+ const current = VERSION.split(".").map(Number);
166
+ const next = recommended.split(".").map(Number);
167
+ return next.some((value, index) => value > current[index] && next.slice(0, index).every((prior, priorIndex) => prior === current[priorIndex]));
168
+ }
169
+
170
+ function warnVersion(body) {
171
+ if (newerVersion(body?.recommendedCliVersion)) {
172
+ process.stderr.write(`UsageMax ${body.recommendedCliVersion} is available. Run \`bunx usagemax@latest\` for current coverage fixes.\n`);
173
+ }
174
+ }
175
+
181
176
  async function link(args) {
182
177
  const code = normalizeLinkCode(args[0]);
183
178
  if (!code) throw new Error("Paste the one-use UMX link code shown at usagemax.com/account.");
@@ -198,13 +193,17 @@ async function link(args) {
198
193
  const body = await response.json().catch(() => ({}));
199
194
  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.");
200
195
  const ingestUrl = validHttpsUrl(body.ingestUrl, { allowLocalhost: true });
201
- if (!/^umx_[a-f0-9]{64}$/.test(body.token || "") || !ingestUrl) throw new Error("UsageMax returned an invalid link response.");
196
+ const snapshotUrl = validHttpsUrl(body.snapshotUrl, { allowLocalhost: true }) || ingestUrl?.replace(/\/v1\/telemetry\/llm$/, "/v2/usage/snapshots");
197
+ const revokeUrl = validHttpsUrl(body.revokeUrl, { allowLocalhost: true }) || ingestUrl?.replace(/\/v1\/telemetry\/llm$/, "/v1/devices/revoke");
198
+ if (!/^umx_[a-f0-9]{64}$/.test(body.token || "") || !ingestUrl || !snapshotUrl || !revokeUrl) throw new Error("UsageMax returned an invalid link response.");
202
199
  const profileHandle = typeof body.profileHandle === "string" ? body.profileHandle : undefined;
203
200
  const sameAccount = Boolean(previous && previous.profileHandle && previous.profileHandle === profileHandle);
204
201
  const config = {
205
202
  version: 1,
206
203
  token: body.token,
207
204
  ingestUrl,
205
+ snapshotUrl,
206
+ revokeUrl,
208
207
  profileUrl: validHttpsUrl(body.profileUrl) || "https://usagemax.com/account",
209
208
  profileHandle,
210
209
  deviceId,
@@ -213,6 +212,7 @@ async function link(args) {
213
212
  snapshots: sameAccount ? previous.snapshots : {},
214
213
  };
215
214
  await writeConfig(config);
215
+ warnVersion(body);
216
216
  process.stdout.write(`Linked ${name} to ${config.profileHandle ? `@${config.profileHandle}` : "UsageMax"}.\n`);
217
217
  if (args.includes("--no-sync")) {
218
218
  process.stdout.write("No usage was uploaded. Run `bunx usagemax sync --full` when you are ready.\n");
@@ -223,56 +223,139 @@ async function link(args) {
223
223
  }
224
224
 
225
225
  async function sync(args, suppliedConfig) {
226
+ const baseEnv = await ccusageEnvironment();
227
+ const recovery = args.includes("--archives")
228
+ ? await prepareArchiveRecovery(baseEnv)
229
+ : { archives: 0, cleanup: async () => undefined, env: baseEnv, unsupported: 0 };
230
+ try {
231
+ return await syncPrepared(args, suppliedConfig, recovery);
232
+ } finally {
233
+ await recovery.cleanup();
234
+ }
235
+ }
236
+
237
+ async function syncPrepared(args, suppliedConfig, recovery) {
226
238
  const config = suppliedConfig || await readConfig();
227
239
  if (!config) throw new Error("This computer is not linked. Open https://usagemax.com/account and create a link code.");
228
240
  config.deviceId = await stableInstallationId(configDirectory(), config.deviceId);
229
- const full = args.includes("--full");
230
- const inventory = await sourceInventory();
241
+ const requestedFull = args.includes("--full");
242
+ const requestedArchives = args.includes("--archives");
243
+ const dryRun = args.includes("--dry-run");
244
+ const explain = args.includes("--explain");
245
+ const json = args.includes("--json");
246
+ const inventory = await sourceInventory({ env: recovery.env, home: ccusageHome(recovery.env) });
231
247
  const today = new Date().toISOString().slice(0, 10);
232
- if (!full && config.lastReconciledDay === today && config.sourceFingerprint === inventory.fingerprint) {
233
- const result = { accepted: 0, changedRows: 0, sources: inventory.sources, regressions: 0, scanned: false };
234
- if (args.includes("--json")) process.stdout.write(`${JSON.stringify(result)}\n`);
248
+ const knownSources = Array.isArray(config.knownSources) ? config.knownSources : [];
249
+ const foundNewSource = inventory.sources.some((source) => !knownSources.includes(source));
250
+ const lastFullSync = Date.parse(config.lastFullSyncAt || "");
251
+ const fullDue = config.snapshotProtocolVersion !== 2
252
+ || config.sourceInventoryVersion !== SOURCE_INVENTORY_VERSION
253
+ || !Number.isFinite(lastFullSync)
254
+ || Date.now() - lastFullSync >= FULL_RECONCILE_INTERVAL_MS
255
+ || foundNewSource;
256
+ const full = requestedFull || requestedArchives || fullDue;
257
+ if (!full && inventory.complete && config.lastSyncComplete && config.lastReconciledDay === today && config.sourceFingerprint === inventory.fingerprint) {
258
+ const result = { accepted: 0, changedRows: 0, sessions: 0, sources: inventory.sources, corrections: 0, scanned: false, full: false, coverage: inventory.complete ? "complete" : "partial" };
259
+ if (json) process.stdout.write(`${JSON.stringify(result)}\n`);
235
260
  else process.stdout.write("Already up to date. Local usage files have not changed; no logs were parsed or uploaded.\n");
236
261
  return;
237
262
  }
238
- const report = await ccusageJson(config, { full });
239
- const { plan, regressions } = buildDeltaPlan(report, config.snapshots, config.deviceId, "ccusage@20.0.20");
240
- let accepted = 0;
241
- for (let offset = 0; offset < plan.length; offset += 100) {
242
- const batch = plan.slice(offset, offset + 100);
243
- const events = batch.map((item) => item.event);
244
- const response = await fetch(config.ingestUrl, {
245
- method: "POST",
246
- headers: {
247
- authorization: `Bearer ${config.token}`,
248
- "content-type": "application/json",
249
- "idempotency-key": batchId(config.deviceId, events),
250
- "x-usagemax-device-id": config.deviceId,
251
- },
252
- body: JSON.stringify({ events }),
253
- signal: AbortSignal.timeout(30_000),
254
- });
255
- const body = await response.json().catch(() => ({}));
256
- if (!response.ok) throw new Error(body?.error === "unauthorized" ? "This collector key is no longer valid. Link the computer again." : `UsageMax rejected a sync batch (${response.status}).`);
257
- accepted += Number(body.accepted || 0);
258
- for (const item of batch) config.snapshots[item.snapshotKey] = item.snapshot;
259
- config.lastSyncAt = new Date().toISOString();
260
- config.lastReconciledDay = today;
261
- config.sourceFingerprint = inventory.fingerprint;
262
- await writeConfig(config);
263
+ const report = await ccusageJson(config, { env: recovery.env, full });
264
+ const legacySnapshotBootstrap = config.snapshotProtocolVersion !== 2
265
+ && Object.keys(config.snapshots || {}).length > 0;
266
+ const runId = randomUUID();
267
+ const revision = Date.now();
268
+ const pricingVersion = `ccusage@${CCUSAGE_VERSION}`;
269
+ const { partitions, nextSnapshots, regressions } = buildSnapshotPlan(report, config.snapshots, {
270
+ bootstrap: config.snapshotProtocolVersion !== 2,
271
+ complete: inventory.complete,
272
+ full,
273
+ pricingVersion,
274
+ revision,
275
+ runId,
276
+ });
277
+ const sessions = buildSessionPlan(report, config.deviceId);
278
+ const sources = sourceSummary(report);
279
+ const days = Array.isArray(report?.daily)
280
+ ? report.daily.map((row) => row?.period).filter((day) => /^\d{4}-\d{2}-\d{2}$/.test(day || "")).sort()
281
+ : [];
282
+ const result = {
283
+ accepted: 0,
284
+ changedRows: partitions.reduce((sum, partition) => sum + partition.rows.filter((row) => JSON.stringify(row.previous) !== JSON.stringify(row.current)).length, 0),
285
+ sessions: sessions.length,
286
+ sources,
287
+ corrections: regressions.length,
288
+ partitions: partitions.length,
289
+ scanned: true,
290
+ full,
291
+ coverage: inventory.complete && !inventory.truncated && inventory.errors === 0 ? "complete" : "partial",
292
+ range: { from: days[0], to: days.at(-1) },
293
+ };
294
+ if (requestedArchives) {
295
+ result.archives = recovery.archives;
296
+ result.unsupportedArchives = recovery.unsupported;
297
+ }
298
+ if (dryRun) {
299
+ if (json) process.stdout.write(`${JSON.stringify({ ...result, dryRun: true })}\n`);
300
+ else {
301
+ process.stdout.write(`Dry run: ${partitions.length} partition(s), ${result.changedRows} changed row(s), ${sessions.length} private session identifiers, no upload.\n`);
302
+ if (explain) process.stdout.write(`Coverage ${result.coverage}; ${sources.length} source(s); ${days[0] || "unknown"} to ${days.at(-1) || "unknown"}; ${regressions.length} downward correction(s).\n`);
303
+ }
304
+ return result;
263
305
  }
264
- if (!plan.length) {
306
+ config.lastSyncComplete = false;
307
+ await writeConfig(config);
308
+ try {
309
+ const begin = await snapshotRequest(config, "begin", {
310
+ runId,
311
+ mode: requestedArchives ? "archives" : full ? "full" : "incremental",
312
+ baselineMode: legacySnapshotBootstrap ? "adopt-current" : "apply",
313
+ sourceCount: sources.length,
314
+ partitionCount: partitions.length,
315
+ inventoryComplete: inventory.complete,
316
+ inventoryErrors: inventory.errors,
317
+ inventoryTruncated: inventory.truncated,
318
+ coverageStartDay: days[0],
319
+ coverageEndDay: days.at(-1),
320
+ });
321
+ warnVersion(begin);
322
+ for (let offset = 0; offset < sessions.length; offset += 100) {
323
+ await snapshotRequest(config, "sessions", { runId, sessions: sessions.slice(offset, offset + 100) });
324
+ }
325
+ for (let offset = 0; offset < partitions.length; offset += 10) {
326
+ const response = await snapshotRequest(config, "partitions", { runId, partitions: partitions.slice(offset, offset + 10) }, 60_000);
327
+ result.accepted += Number(response.changedRows || 0);
328
+ if (!json && process.stderr.isTTY && partitions.length > 10) {
329
+ process.stderr.write(`UsageMax: uploaded ${Math.min(offset + 10, partitions.length)}/${partitions.length} history partitions\r`);
330
+ }
331
+ }
332
+ if (!json && process.stderr.isTTY && partitions.length > 10) process.stderr.write("\n");
333
+ const completed = await snapshotRequest(config, "complete", { runId });
334
+ warnVersion(completed);
335
+ config.snapshots = nextSnapshots;
336
+ config.snapshotProtocolVersion = 2;
265
337
  config.lastSyncAt = new Date().toISOString();
266
338
  config.lastReconciledDay = today;
267
- config.sourceFingerprint = inventory.fingerprint;
339
+ config.lastSyncComplete = true;
340
+ config.sourceInventoryVersion = SOURCE_INVENTORY_VERSION;
341
+ config.knownSources = [...new Set([...knownSources, ...inventory.sources, ...sources])].sort();
342
+ if (inventory.complete) config.sourceFingerprint = inventory.fingerprint;
343
+ else delete config.sourceFingerprint;
344
+ if (full) config.lastFullSyncAt = config.lastSyncAt;
268
345
  await writeConfig(config);
346
+ } catch (error) {
347
+ await snapshotRequest(config, "fail", { runId, failureCode: error instanceof Error ? error.message.slice(0, 80) : "sync_failed" }).catch(() => undefined);
348
+ throw error;
269
349
  }
270
- const result = { accepted, changedRows: plan.length, sources: sourceSummary(report), regressions: regressions.length, scanned: true };
271
- if (args.includes("--json")) process.stdout.write(`${JSON.stringify(result)}\n`);
350
+ if (json) process.stdout.write(`${JSON.stringify(result)}\n`);
272
351
  else {
273
- process.stdout.write(plan.length ? `Synced ${accepted} changed usage rows from ${result.sources.join(", ") || "local agents"}.\n` : "Already up to date. No usage rows were uploaded.\n");
274
- if (regressions.length) process.stdout.write(`${regressions.length} local row(s) moved backward; UsageMax kept the prior high-water mark to prevent double counting.\n`);
352
+ process.stdout.write(partitions.length || sessions.length
353
+ ? `Reconciled ${result.accepted} changed usage row(s) and ${sessions.length} session identifier(s) from ${sources.join(", ") || "local agents"}${full ? " across retained history" : ""}.\n`
354
+ : `Already up to date. No usage rows changed${full ? " after a full-history reconciliation" : ""}.\n`);
355
+ if (regressions.length) process.stdout.write(`${regressions.length} local row(s) moved backward and were submitted as authoritative corrections.\n`);
356
+ if (explain) process.stdout.write(`Coverage ${result.coverage}; ${sources.length} source(s); ${days[0] || "unknown"} to ${days.at(-1) || "unknown"}; ${partitions.length} atomic partition(s).\n`);
275
357
  }
358
+ return result;
276
359
  }
277
360
 
278
361
  async function status() {
@@ -288,24 +371,57 @@ async function status() {
288
371
  }
289
372
  process.stdout.write(`Linked: ${config.deviceName || deviceLabel()}${config.profileHandle ? ` → @${config.profileHandle}` : ""}\n`);
290
373
  process.stdout.write(`Last sync: ${config.lastSyncAt || "never"}\n`);
374
+ process.stdout.write(`Last full reconciliation: ${config.lastFullSyncAt || "never"}\n`);
291
375
  process.stdout.write(`Profile: ${config.profileUrl || "https://usagemax.com/account"}\n`);
292
376
  }
293
377
 
294
378
  async function doctor(args = []) {
295
379
  const config = await readConfig();
296
- const inventory = await sourceInventory();
380
+ const env = await ccusageEnvironment();
381
+ const inventory = await sourceInventory({ env, home: ccusageHome(env) });
382
+ const archives = await discoverProviderArchives({ env, home: ccusageHome(env) });
383
+ const homes = String(env.USAGEMAX_DISCOVERED_HOMES || ccusageHome(env)).split(",").filter(Boolean);
384
+ const result = {
385
+ linked: Boolean(config),
386
+ homes,
387
+ detectedSources: inventory.sources,
388
+ files: inventory.files,
389
+ inventoryComplete: inventory.complete,
390
+ inventoryErrors: inventory.errors,
391
+ inventoryTruncated: inventory.truncated,
392
+ supportedSources: SUPPORTED_SOURCES,
393
+ archives: archives.length,
394
+ environment: platform() === "linux" && process.env.WSL_DISTRO_NAME ? `WSL ${process.env.WSL_DISTRO_NAME}` : platform(),
395
+ mode: args.includes("--deep") ? "deep" : "metadata-only",
396
+ };
397
+ if (args.includes("--deep")) {
398
+ const report = await ccusageJson(config, { env, full: true });
399
+ result.parsedSources = sourceSummary(report);
400
+ result.sessions = buildSessionPlan(report, config?.deviceId || "unlinked").length;
401
+ }
402
+ if (args.includes("--json")) {
403
+ process.stdout.write(`${JSON.stringify(result)}\n`);
404
+ return;
405
+ }
297
406
  process.stdout.write(`Collector: ${config ? "linked" : "not linked"}\n`);
407
+ process.stdout.write(`Discovered homes: ${homes.length} (${homes.join(", ")})\n`);
298
408
  process.stdout.write(`Detected sources: ${inventory.sources.join(", ") || "none"} (${inventory.files}${inventory.truncated ? "+" : ""} data files)\n`);
299
- if (args.includes("--deep")) {
300
- const report = await ccusageJson(config, { full: false });
301
- process.stdout.write(`Parsed sources: ${sourceSummary(report).join(", ") || "none"}\n`);
409
+ process.stdout.write(`Supported sources: ${SUPPORTED_SOURCES.join(", ")} (+ named pi-format stores)\n`);
410
+ if (platform() === "linux" && process.env.WSL_DISTRO_NAME) {
411
+ process.stdout.write(`Environment: WSL ${process.env.WSL_DISTRO_NAME}; readable Windows provider homes are included automatically\n`);
302
412
  }
413
+ if (archives.length) process.stdout.write(`Recovery: ${archives.length} compressed provider archive(s) detected; run \`bunx usagemax sync --archives\` once to reconcile them\n`);
414
+ if (!inventory.complete) process.stdout.write(`Inventory: incomplete (${inventory.errors} read error(s)${inventory.truncated ? ", file limit reached" : ""}); no-change shortcut disabled\n`);
415
+ if (result.parsedSources) process.stdout.write(`Parsed sources: ${result.parsedSources.join(", ") || "none"}; ${result.sessions} private session identifiers\n`);
303
416
  process.stdout.write(`Mode: one-shot, metadata no-op check, ${args.includes("--deep") ? "deep local parse" : "no log parsing"}\n`);
304
417
  }
305
418
 
306
419
  async function report(args) {
307
420
  const forwarded = args.length ? args : ["daily"];
308
- const child = spawn(process.execPath, [ccusageCliPath(), ...forwarded], { stdio: "inherit", env: process.env });
421
+ const child = spawn(process.execPath, [ccusageCliPath(), ...forwarded], {
422
+ stdio: "inherit",
423
+ env: await ccusageEnvironment(),
424
+ });
309
425
  const code = await new Promise((resolve, reject) => {
310
426
  child.once("error", reject);
311
427
  child.once("exit", (status) => resolve(status ?? 1));
@@ -313,15 +429,28 @@ async function report(args) {
313
429
  if (code !== 0) process.exitCode = code;
314
430
  }
315
431
 
316
- async function removeLink() {
432
+ async function removeLink(args = []) {
317
433
  const path = configPath();
318
434
  const config = await readConfig();
319
435
  if (!config) {
320
436
  process.stdout.write("This computer is not linked.\n");
321
437
  return;
322
438
  }
439
+ if (args.includes("--revoke")) {
440
+ const endpoint = validHttpsUrl(config.revokeUrl, { allowLocalhost: true });
441
+ if (!endpoint) throw new Error("This collector does not have a valid revoke endpoint. Revoke it at https://usagemax.com/account.");
442
+ const response = await fetch(endpoint, {
443
+ method: "POST",
444
+ headers: { authorization: `Bearer ${config.token}`, "content-type": "application/json" },
445
+ body: JSON.stringify({ deviceId: config.deviceId }),
446
+ signal: AbortSignal.timeout(15_000),
447
+ });
448
+ if (!response.ok) throw new Error("UsageMax could not revoke this collector. It remains linked locally.");
449
+ }
323
450
  await unlink(path);
324
- process.stdout.write("Removed the local UsageMax collector key. This computer's private installation identity was retained so relinking cannot duplicate its usage. Revoke the collector in your account if this computer is no longer trusted.\n");
451
+ process.stdout.write(args.includes("--revoke")
452
+ ? "Revoked this collector and removed its local key. Existing usage totals were retained.\n"
453
+ : "Removed the local UsageMax collector key. This computer's private installation identity was retained so relinking cannot duplicate its usage. Revoke the collector in your account if this computer is no longer trusted.\n");
325
454
  }
326
455
 
327
456
  async function main() {
@@ -334,7 +463,7 @@ async function main() {
334
463
  if (command === "status") return status();
335
464
  if (command === "doctor") return doctor(args.slice(1));
336
465
  if (command === "report") return report(args.slice(1));
337
- if (command === "unlink") return removeLink();
466
+ if (command === "unlink") return removeLink(args.slice(1));
338
467
  throw new Error(`Unknown command: ${command}. Run usagemax --help.`);
339
468
  }
340
469