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/README.md +95 -21
- package/package.json +3 -1
- package/src/archives.js +86 -0
- package/src/cli.js +261 -132
- package/src/core.js +226 -2
- package/src/sources.js +495 -0
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 {
|
|
5
|
-
import { chmod, mkdir, readFile,
|
|
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 {
|
|
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 {
|
|
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.
|
|
18
|
-
const
|
|
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
|
|
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] [--
|
|
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]
|
|
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
|
|
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
|
|
93
|
-
const
|
|
94
|
-
|
|
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 (
|
|
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: { ...
|
|
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
|
-
|
|
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
|
|
230
|
-
const
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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(
|
|
274
|
-
|
|
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
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
process.stdout.write(`
|
|
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], {
|
|
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(
|
|
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
|
|