usagemax 0.2.0 → 0.3.1
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 +82 -11
- package/package.json +4 -1
- package/src/archives.js +86 -0
- package/src/cli.js +247 -72
- package/src/core.js +220 -1
- package/src/resume.js +58 -0
- package/src/sources.js +171 -7
- package/src/transport.js +54 -0
package/README.md
CHANGED
|
@@ -14,9 +14,10 @@ bunx usagemax@latest link UMX-XXXX-XXXX-XXXX-XXXX
|
|
|
14
14
|
|
|
15
15
|
The link code expires after ten minutes and can be used once. Create a new code
|
|
16
16
|
for each Mac, Windows PC, Linux computer, and WSL distribution. All linked
|
|
17
|
-
collectors roll up into the same profile.
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
collectors roll up into the same profile. When run inside WSL, UsageMax includes
|
|
18
|
+
readable supported-provider homes under `/mnt/c/Users` automatically. Use the
|
|
19
|
+
WSL collector as the single collector for that Windows PC instead of linking the
|
|
20
|
+
same host history again from Windows.
|
|
20
21
|
|
|
21
22
|
The CLI stores the resulting collector key in a user-only config file, then runs
|
|
22
23
|
a one-shot full-history sync. Running it again, changing the display name, or
|
|
@@ -29,13 +30,17 @@ installation identity remains stable.
|
|
|
29
30
|
bunx usagemax # sync changed usage
|
|
30
31
|
bunx usagemax sync # same as above
|
|
31
32
|
bunx usagemax sync --full # reconcile all retained local history
|
|
33
|
+
bunx usagemax sync --archives # one-time compressed-history recovery
|
|
34
|
+
bunx usagemax sync --dry-run --explain
|
|
35
|
+
# inspect the exact bounded plan; upload nothing
|
|
32
36
|
bunx usagemax link UMX-… --no-sync # link without uploading yet
|
|
33
37
|
bunx usagemax status # show link and last-sync state
|
|
34
38
|
bunx usagemax doctor # metadata-only source check
|
|
35
|
-
bunx usagemax doctor --deep
|
|
39
|
+
bunx usagemax doctor --deep --json # machine-readable retained-history audit
|
|
36
40
|
bunx usagemax report # open ccusage's local daily report
|
|
37
41
|
bunx usagemax report session --breakdown
|
|
38
42
|
bunx usagemax unlink # remove the local collector key
|
|
43
|
+
bunx usagemax unlink --revoke # disable future uploads, then remove locally
|
|
39
44
|
```
|
|
40
45
|
|
|
41
46
|
UsageMax pins [ccusage v20.0.20](https://github.com/ccusage/ccusage/releases/tag/v20.0.20)
|
|
@@ -51,6 +56,20 @@ The source inventory follows ccusage's environment overrides, including
|
|
|
51
56
|
`QWEN_DATA_DIR`, `COPILOT_OTEL_FILE_EXPORTER_PATH`, `GEMINI_DATA_DIR`, and
|
|
52
57
|
`GROK_HOME`. It also follows XDG Claude configuration and Windows Goose storage.
|
|
53
58
|
|
|
59
|
+
UsageMax also discovers Claude Desktop local-agent sessions, `.cc-mirror`,
|
|
60
|
+
recognizable renamed Claude/Codex backup folders, and supported Windows homes
|
|
61
|
+
from WSL. Discovery is bounded to known locations and immediate home entries;
|
|
62
|
+
normal syncs do not crawl the whole disk.
|
|
63
|
+
On a multi-user WSL host, automatic Windows-home discovery stays off unless
|
|
64
|
+
there is exactly one provider-bearing profile; set `USAGEMAX_ADDITIONAL_HOME`
|
|
65
|
+
to the intended mounted home explicitly.
|
|
66
|
+
|
|
67
|
+
If `doctor` reports compressed provider archives, run `sync --archives` once.
|
|
68
|
+
Recovery extracts only safe Claude `projects/*.jsonl` members into a private
|
|
69
|
+
temporary directory, performs one full deduplicated reconciliation, and removes
|
|
70
|
+
the temporary files before exit. Weekly and incremental syncs never unpack
|
|
71
|
+
archives.
|
|
72
|
+
|
|
54
73
|
Local files are only one coverage layer. Cursor, Windsurf, Aider, Continue,
|
|
55
74
|
Cline, Roo Code, direct provider API traffic, hosted agents, and enterprise
|
|
56
75
|
billing systems do not all expose a stable local token ledger. Capture those
|
|
@@ -59,24 +78,45 @@ connector. UsageMax never invents usage that the source did not retain.
|
|
|
59
78
|
|
|
60
79
|
## Privacy, correctness, and load
|
|
61
80
|
|
|
62
|
-
|
|
63
|
-
|
|
81
|
+
Interrupted uploads resume with `bunx usagemax sync`. Server upload runs expire
|
|
82
|
+
after 30 idle days. If the server reports an expired run, use `sync --restart`:
|
|
83
|
+
this preserves the last committed checkpoint and performs a fresh full scan.
|
|
84
|
+
It never automatically replays an old authoritative deletion against newer data.
|
|
85
|
+
|
|
86
|
+
- The sync payload contains aggregate token counts,
|
|
87
|
+
model/provider names, costs, source names, dates, coverage state, and opaque
|
|
88
|
+
SHA-256 session identities.
|
|
64
89
|
- It does not upload prompts, completions, source code, file contents, project
|
|
65
90
|
paths, or provider credentials.
|
|
66
91
|
- Sync is one-shot. There is no resident scanner or high-frequency polling loop.
|
|
67
|
-
- A
|
|
68
|
-
|
|
92
|
+
- A successful previous parse with a complete, unchanged metadata inventory skips
|
|
93
|
+
parsing and uploading again that day. Inventory stability is independent of
|
|
94
|
+
deletion authority; a no-change result retains the previous partial coverage
|
|
95
|
+
label. New sources, date rollover, explicit full/archive requests and weekly
|
|
96
|
+
reconciliation still trigger the appropriate scan.
|
|
69
97
|
- Normal changed syncs parse today or today plus yesterday. A bounded weekly
|
|
70
98
|
full reconciliation catches restored files, parser changes, and older logs.
|
|
71
|
-
- Full history
|
|
72
|
-
|
|
99
|
+
- Full history scans retained local history from 2024 onward. Inventory success
|
|
100
|
+
does not prove every file parsed. Until the parser certifies source/day coverage,
|
|
101
|
+
uploads are marked partial and any row with a decreasing counter retains its
|
|
102
|
+
entire previous vector. This includes explicit zeros and missing sources.
|
|
103
|
+
Older checkpoints survive incremental windows. Authoritative corrections remain
|
|
104
|
+
supported by the planner but are not claimed by this parser integration.
|
|
105
|
+
Deleted or never-persisted usage requires a provider export.
|
|
73
106
|
- Source totals that cannot be assigned to a model are retained as
|
|
74
107
|
`unattributed` rather than silently discarded.
|
|
75
108
|
- The collector key is written with user-only permissions where the operating
|
|
76
109
|
system supports them.
|
|
110
|
+
- The package contains no shared service credential or private deployment URL.
|
|
111
|
+
It talks to the versioned `https://usagemax.com/api` contract. The random
|
|
112
|
+
per-installation write token is the only local secret; the service stores only
|
|
113
|
+
its hash and applies device binding, replay checks, payload caps, and quotas.
|
|
77
114
|
- A private random installation ID survives collector rotation, relinking, and
|
|
78
115
|
display-name changes. It is not a hardware fingerprint; the server stores only
|
|
79
|
-
its SHA-256 hash.
|
|
116
|
+
its SHA-256 hash. A local config lock prevents overlapping commands from
|
|
117
|
+
overwriting pending runs; server receipts make repeated uploads idempotent.
|
|
118
|
+
- The server, not the local checkpoint, owns the accounting baseline. A lost
|
|
119
|
+
response or interrupted run can be retried without adding the same partition twice.
|
|
80
120
|
- Do not point two different installations at the same copied or network-mounted
|
|
81
121
|
log tree. Cross-installation copied-history deduplication is inherently
|
|
82
122
|
ambiguous and intentionally not guessed.
|
|
@@ -84,5 +124,36 @@ connector. UsageMax never invents usage that the source did not retain.
|
|
|
84
124
|
Use `USAGEMAX_CONFIG_DIR` to select another config directory. Development and
|
|
85
125
|
self-hosted installations may set `USAGEMAX_LINK_ENDPOINT` before linking.
|
|
86
126
|
|
|
127
|
+
## Interrupted uploads and protocol 0.3.1
|
|
128
|
+
|
|
129
|
+
Before uploading, the CLI saves the exact run, ordered request payloads and next
|
|
130
|
+
checkpoint in the private config. `sync` resumes this journal before scanning new
|
|
131
|
+
data. Local snapshots advance only after completion is acknowledged. A resumed
|
|
132
|
+
command finishes the saved scan; run `sync` again to collect subsequent changes.
|
|
133
|
+
`sync --dry-run` reports pending work without uploading. Do not delete config.json
|
|
134
|
+
to retry a failed upload. A successful relink or unlink replaces/removes the local
|
|
135
|
+
journal along with its credential.
|
|
136
|
+
|
|
137
|
+
Network failures, malformed success responses, HTTP 429 and 5xx get at most five
|
|
138
|
+
attempts per request with exponential jitter. Retry-After seconds and HTTP dates
|
|
139
|
+
are honored up to 60 seconds; longer waits stop with an instruction to retry later.
|
|
140
|
+
Only completion's `snapshot_run_incomplete` HTTP 409 is retried, because server
|
|
141
|
+
cleanup can still be pending. Authentication, validation and other conflicts fail
|
|
142
|
+
with an actionable message and retain the journal. JSON `accepted` is null for
|
|
143
|
+
uploads because lost responses/replays cannot reliably reconstruct that count;
|
|
144
|
+
`changedRows` is the local planned count, not a server accounting receipt.
|
|
145
|
+
|
|
146
|
+
Each sorted source/day is sent as ordered chunks of at most 100 rows. Each has a
|
|
147
|
+
unique partitionId, zero-based chunkIndex and shared chunkCount. The payload hash
|
|
148
|
+
covers `{source, day, complete, pricingVersion, chunkIndex, chunkCount, rows}` in
|
|
149
|
+
that order. `partitionCount` counts transmitted chunks. The server must accept
|
|
150
|
+
this protocol, receipt replays, and finish omitted-row cleanup before completing
|
|
151
|
+
an authoritative run.
|
|
152
|
+
|
|
153
|
+
An abrupt process kill can leave `collector.lock` in the config directory. The
|
|
154
|
+
next command reports the owner PID and exact path. Confirm that process has exited
|
|
155
|
+
before removing only that lock file, then rerun sync. Never remove an active lock
|
|
156
|
+
or the saved config to recover. Normal completion and handled failures release it.
|
|
157
|
+
|
|
87
158
|
See the [collector coverage audit](../../docs/collector-coverage-audit.md) for
|
|
88
159
|
the full support matrix and known boundaries.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "usagemax",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Link local coding-agent usage to your UsageMax profile",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "UsageMax",
|
|
@@ -18,10 +18,13 @@
|
|
|
18
18
|
"usagemax": "src/cli.js"
|
|
19
19
|
},
|
|
20
20
|
"files": [
|
|
21
|
+
"src/archives.js",
|
|
21
22
|
"src/cli.js",
|
|
22
23
|
"src/core.js",
|
|
23
24
|
"src/installation.js",
|
|
24
25
|
"src/sources.js",
|
|
26
|
+
"src/transport.js",
|
|
27
|
+
"src/resume.js",
|
|
25
28
|
"README.md",
|
|
26
29
|
"LICENSE"
|
|
27
30
|
],
|
package/src/archives.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { mkdir, mkdtemp, readdir, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
|
|
7
|
+
import { ccusageHome, discoverProviderArchives } from "./sources.js";
|
|
8
|
+
|
|
9
|
+
const executeFile = promisify(execFile);
|
|
10
|
+
const MAX_ARCHIVE_LIST_BYTES = 100 * 1024 * 1024;
|
|
11
|
+
|
|
12
|
+
function archiveFlags(filePath, mode) {
|
|
13
|
+
const compressed = /(?:\.tar\.gz|\.tgz)$/i.test(filePath);
|
|
14
|
+
return mode === "list" ? (compressed ? "-tzf" : "-tf") : (compressed ? "-xzf" : "-xf");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function safeArchiveMember(value, source = "claude") {
|
|
18
|
+
const normalized = String(value || "").replaceAll("\\", "/");
|
|
19
|
+
const parts = normalized.split("/").filter((item) => item && item !== ".");
|
|
20
|
+
const providerDirectory = source === "codex"
|
|
21
|
+
? parts.includes("sessions") || parts.includes("archived_sessions")
|
|
22
|
+
: parts.includes("projects");
|
|
23
|
+
return !normalized.startsWith("/") && !parts.includes("..") && providerDirectory && normalized.endsWith(".jsonl");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function findProviderRoots(root, maxDirectories = 10_000) {
|
|
27
|
+
const claude = [];
|
|
28
|
+
const codex = [];
|
|
29
|
+
const stack = [root];
|
|
30
|
+
let visited = 0;
|
|
31
|
+
while (stack.length && visited < maxDirectories) {
|
|
32
|
+
const current = stack.pop();
|
|
33
|
+
visited += 1;
|
|
34
|
+
let children;
|
|
35
|
+
try {
|
|
36
|
+
children = await readdir(current, { withFileTypes: true });
|
|
37
|
+
} catch {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
for (const child of children) {
|
|
41
|
+
if (!child.isDirectory()) continue;
|
|
42
|
+
const childPath = join(current, child.name);
|
|
43
|
+
if (child.name === "projects") claude.push(childPath);
|
|
44
|
+
else if (child.name === "sessions" || child.name === "archived_sessions") codex.push(current);
|
|
45
|
+
else stack.push(childPath);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return { claude, codex };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function prepareArchiveRecovery(baseEnv) {
|
|
52
|
+
const detected = await discoverProviderArchives({ env: baseEnv, home: ccusageHome(baseEnv) });
|
|
53
|
+
const archives = detected.filter((item) => /(?:\.tar(?:\.gz)?|\.tgz)$/i.test(item.path));
|
|
54
|
+
const unsupported = detected.length - archives.length;
|
|
55
|
+
if (!archives.length) return { archives: 0, cleanup: async () => undefined, env: baseEnv, unsupported };
|
|
56
|
+
|
|
57
|
+
const temporary = await mkdtemp(join(tmpdir(), "usagemax-recover-"));
|
|
58
|
+
try {
|
|
59
|
+
for (let index = 0; index < archives.length; index += 1) {
|
|
60
|
+
const archive = archives[index];
|
|
61
|
+
const destination = join(temporary, `archive-${index + 1}`);
|
|
62
|
+
await mkdir(destination, { recursive: true, mode: 0o700 });
|
|
63
|
+
const { stdout } = await executeFile("tar", [archiveFlags(archive.path, "list"), archive.path], { encoding: "utf8", maxBuffer: MAX_ARCHIVE_LIST_BYTES });
|
|
64
|
+
const members = stdout.split(/\r?\n/).filter((member) => safeArchiveMember(member, archive.source));
|
|
65
|
+
for (let offset = 0; offset < members.length; offset += 80) {
|
|
66
|
+
await executeFile("tar", [archiveFlags(archive.path, "extract"), archive.path, "-C", destination, "--", ...members.slice(offset, offset + 80)], { maxBuffer: 4 * 1024 * 1024 });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const roots = await findProviderRoots(temporary);
|
|
70
|
+
const existingClaude = String(baseEnv.CLAUDE_CONFIG_DIR || "").split(",").map((item) => item.trim()).filter(Boolean);
|
|
71
|
+
const existingCodex = String(baseEnv.CODEX_HOME || "").split(",").map((item) => item.trim()).filter(Boolean);
|
|
72
|
+
return {
|
|
73
|
+
archives: archives.length,
|
|
74
|
+
cleanup: async () => rm(temporary, { force: true, recursive: true }),
|
|
75
|
+
env: {
|
|
76
|
+
...baseEnv,
|
|
77
|
+
CLAUDE_CONFIG_DIR: [...new Set([...existingClaude, ...roots.claude])].join(","),
|
|
78
|
+
CODEX_HOME: [...new Set([...existingCodex, ...roots.codex])].join(","),
|
|
79
|
+
},
|
|
80
|
+
unsupported,
|
|
81
|
+
};
|
|
82
|
+
} catch (error) {
|
|
83
|
+
await rm(temporary, { force: true, recursive: true });
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -9,17 +9,20 @@ 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, scanPolicy, sourceSummary, validHttpsUrl } from "./core.js";
|
|
13
14
|
import { stableInstallationId } from "./installation.js";
|
|
14
|
-
import {
|
|
15
|
+
import { requestSnapshot } from "./transport.js";
|
|
16
|
+
import { resumeUpload, restartExpiredUpload, withConfigLock } from "./resume.js";
|
|
17
|
+
import { CCUSAGE_VERSION, ccusageEnvironment, ccusageHome, discoverProviderArchives, SOURCE_INVENTORY_VERSION, sourceInventory, SUPPORTED_SOURCES } from "./sources.js";
|
|
15
18
|
|
|
16
19
|
const require = createRequire(import.meta.url);
|
|
17
20
|
const executeFile = promisify(execFile);
|
|
18
|
-
const VERSION = "0.
|
|
19
|
-
const
|
|
21
|
+
const VERSION = "0.3.1";
|
|
22
|
+
const PUBLIC_API_ORIGIN = "https://usagemax.com/api";
|
|
23
|
+
const DEFAULT_LINK_ENDPOINT = `${PUBLIC_API_ORIGIN}/v1/devices/link`;
|
|
20
24
|
const CONFIG_FILE = "config.json";
|
|
21
25
|
const MAX_REPORT_BYTES = 100 * 1024 * 1024;
|
|
22
|
-
const FULL_RECONCILE_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
23
26
|
|
|
24
27
|
function configDirectory() {
|
|
25
28
|
if (process.env.USAGEMAX_CONFIG_DIR) return process.env.USAGEMAX_CONFIG_DIR;
|
|
@@ -31,6 +34,18 @@ function configPath() {
|
|
|
31
34
|
return join(configDirectory(), CONFIG_FILE);
|
|
32
35
|
}
|
|
33
36
|
|
|
37
|
+
function isLegacyDirectApi(config) {
|
|
38
|
+
try {
|
|
39
|
+
const ingest = new URL(config.ingestUrl);
|
|
40
|
+
const profile = new URL(config.profileUrl || "https://usagemax.com");
|
|
41
|
+
return profile.hostname === "usagemax.com"
|
|
42
|
+
&& ingest.hostname.endsWith(".convex.site")
|
|
43
|
+
&& ingest.pathname === "/v1/telemetry/llm";
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
34
49
|
async function readConfig() {
|
|
35
50
|
try {
|
|
36
51
|
const parsed = JSON.parse(await readFile(configPath(), "utf8"));
|
|
@@ -38,6 +53,16 @@ async function readConfig() {
|
|
|
38
53
|
if (!validHttpsUrl(parsed.ingestUrl, { allowLocalhost: true })) return null;
|
|
39
54
|
if (typeof parsed.deviceId !== "string" || !parsed.deviceId) return null;
|
|
40
55
|
parsed.snapshots = parsed.snapshots && typeof parsed.snapshots === "object" ? parsed.snapshots : {};
|
|
56
|
+
if (isLegacyDirectApi(parsed)) {
|
|
57
|
+
parsed.ingestUrl = `${PUBLIC_API_ORIGIN}/v1/telemetry/llm`;
|
|
58
|
+
parsed.snapshotUrl = `${PUBLIC_API_ORIGIN}/v2/usage/snapshots`;
|
|
59
|
+
parsed.revokeUrl = `${PUBLIC_API_ORIGIN}/v1/devices/revoke`;
|
|
60
|
+
await writeConfig(parsed);
|
|
61
|
+
}
|
|
62
|
+
parsed.snapshotUrl = validHttpsUrl(parsed.snapshotUrl, { allowLocalhost: true })
|
|
63
|
+
|| parsed.ingestUrl.replace(/\/v1\/telemetry\/llm$/, "/v2/usage/snapshots");
|
|
64
|
+
parsed.revokeUrl = validHttpsUrl(parsed.revokeUrl, { allowLocalhost: true })
|
|
65
|
+
|| parsed.ingestUrl.replace(/\/v1\/telemetry\/llm$/, "/v1/devices/revoke");
|
|
41
66
|
return parsed;
|
|
42
67
|
} catch {
|
|
43
68
|
return null;
|
|
@@ -79,19 +104,22 @@ function help() {
|
|
|
79
104
|
process.stdout.write(" usagemax Sync changed local usage\n");
|
|
80
105
|
process.stdout.write(" usagemax link <one-use-code> Link and sync this computer\n");
|
|
81
106
|
process.stdout.write(" [--no-sync] [--name <name>]\n");
|
|
82
|
-
process.stdout.write(" usagemax sync [--full] [--
|
|
107
|
+
process.stdout.write(" usagemax sync [--full] [--archives] [--restart] [--dry-run] [--explain] [--json]\n");
|
|
108
|
+
process.stdout.write(" Reconcile once; --archives performs one-time recovery\n");
|
|
83
109
|
process.stdout.write(" usagemax status Show local link status\n");
|
|
84
|
-
process.stdout.write(" usagemax doctor [--deep]
|
|
110
|
+
process.stdout.write(" usagemax doctor [--deep] [--json]\n");
|
|
111
|
+
process.stdout.write(" Check source coverage; --deep parses full history\n");
|
|
85
112
|
process.stdout.write(" usagemax report [...args] Run a local ccusage report\n");
|
|
86
|
-
process.stdout.write(" usagemax unlink
|
|
113
|
+
process.stdout.write(" usagemax unlink [--revoke] Remove locally; --revoke also disables uploads\n");
|
|
87
114
|
}
|
|
88
115
|
|
|
89
116
|
function ccusageCliPath() {
|
|
90
117
|
return join(dirname(require.resolve("ccusage/package.json")), "src", "cli.js");
|
|
91
118
|
}
|
|
92
119
|
|
|
93
|
-
async function ccusageJson(config, { full = false } = {}) {
|
|
120
|
+
async function ccusageJson(config, { full = false, env } = {}) {
|
|
94
121
|
const args = [ccusageCliPath(), "daily", "--json", "--offline", "--mode", "calculate", "--timezone", "UTC", "--by-agent", "--order", "asc"];
|
|
122
|
+
args.push("--sections", "daily,session");
|
|
95
123
|
// Reconcile yesterday once after the UTC date changes. All other incremental
|
|
96
124
|
// scans parse only today; a metadata fingerprint avoids invoking ccusage when
|
|
97
125
|
// no supported local source changed at all.
|
|
@@ -104,11 +132,33 @@ async function ccusageJson(config, { full = false } = {}) {
|
|
|
104
132
|
const { stdout } = await executeFile(process.execPath, args, {
|
|
105
133
|
encoding: "utf8",
|
|
106
134
|
maxBuffer: MAX_REPORT_BYTES,
|
|
107
|
-
env: { ...await ccusageEnvironment(), NO_COLOR: "1" },
|
|
135
|
+
env: { ...(env || await ccusageEnvironment()), NO_COLOR: "1" },
|
|
108
136
|
});
|
|
109
137
|
return JSON.parse(stdout);
|
|
110
138
|
}
|
|
111
139
|
|
|
140
|
+
function snapshotEndpoint(config) {
|
|
141
|
+
return validHttpsUrl(config.snapshotUrl, { allowLocalhost: true })
|
|
142
|
+
|| config.ingestUrl.replace(/\/v1\/telemetry\/llm$/, "/v2/usage/snapshots");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function snapshotRequest(config, operation, payload, timeout = 30_000) {
|
|
146
|
+
return requestSnapshot(snapshotEndpoint(config), config, operation, payload, { timeout });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function newerVersion(recommended) {
|
|
150
|
+
if (!/^\d+\.\d+\.\d+$/.test(recommended || "")) return false;
|
|
151
|
+
const current = VERSION.split(".").map(Number);
|
|
152
|
+
const next = recommended.split(".").map(Number);
|
|
153
|
+
return next.some((value, index) => value > current[index] && next.slice(0, index).every((prior, priorIndex) => prior === current[priorIndex]));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function warnVersion(body) {
|
|
157
|
+
if (newerVersion(body?.recommendedCliVersion)) {
|
|
158
|
+
process.stderr.write(`UsageMax ${body.recommendedCliVersion} is available. Run \`bunx usagemax@latest\` for current coverage fixes.\n`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
112
162
|
async function link(args) {
|
|
113
163
|
const code = normalizeLinkCode(args[0]);
|
|
114
164
|
if (!code) throw new Error("Paste the one-use UMX link code shown at usagemax.com/account.");
|
|
@@ -129,13 +179,17 @@ async function link(args) {
|
|
|
129
179
|
const body = await response.json().catch(() => ({}));
|
|
130
180
|
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.");
|
|
131
181
|
const ingestUrl = validHttpsUrl(body.ingestUrl, { allowLocalhost: true });
|
|
132
|
-
|
|
182
|
+
const snapshotUrl = validHttpsUrl(body.snapshotUrl, { allowLocalhost: true }) || ingestUrl?.replace(/\/v1\/telemetry\/llm$/, "/v2/usage/snapshots");
|
|
183
|
+
const revokeUrl = validHttpsUrl(body.revokeUrl, { allowLocalhost: true }) || ingestUrl?.replace(/\/v1\/telemetry\/llm$/, "/v1/devices/revoke");
|
|
184
|
+
if (!/^umx_[a-f0-9]{64}$/.test(body.token || "") || !ingestUrl || !snapshotUrl || !revokeUrl) throw new Error("UsageMax returned an invalid link response.");
|
|
133
185
|
const profileHandle = typeof body.profileHandle === "string" ? body.profileHandle : undefined;
|
|
134
186
|
const sameAccount = Boolean(previous && previous.profileHandle && previous.profileHandle === profileHandle);
|
|
135
187
|
const config = {
|
|
136
188
|
version: 1,
|
|
137
189
|
token: body.token,
|
|
138
190
|
ingestUrl,
|
|
191
|
+
snapshotUrl,
|
|
192
|
+
revokeUrl,
|
|
139
193
|
profileUrl: validHttpsUrl(body.profileUrl) || "https://usagemax.com/account",
|
|
140
194
|
profileHandle,
|
|
141
195
|
deviceId,
|
|
@@ -144,6 +198,7 @@ async function link(args) {
|
|
|
144
198
|
snapshots: sameAccount ? previous.snapshots : {},
|
|
145
199
|
};
|
|
146
200
|
await writeConfig(config);
|
|
201
|
+
warnVersion(body);
|
|
147
202
|
process.stdout.write(`Linked ${name} to ${config.profileHandle ? `@${config.profileHandle}` : "UsageMax"}.\n`);
|
|
148
203
|
if (args.includes("--no-sync")) {
|
|
149
204
|
process.stdout.write("No usage was uploaded. Run `bunx usagemax sync --full` when you are ready.\n");
|
|
@@ -154,67 +209,145 @@ async function link(args) {
|
|
|
154
209
|
}
|
|
155
210
|
|
|
156
211
|
async function sync(args, suppliedConfig) {
|
|
212
|
+
const baseEnv = await ccusageEnvironment();
|
|
213
|
+
const recovery = args.includes("--archives")
|
|
214
|
+
? await prepareArchiveRecovery(baseEnv)
|
|
215
|
+
: { archives: 0, cleanup: async () => undefined, env: baseEnv, unsupported: 0 };
|
|
216
|
+
try {
|
|
217
|
+
return await syncPrepared(args, suppliedConfig, recovery);
|
|
218
|
+
} finally {
|
|
219
|
+
await recovery.cleanup();
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function syncPrepared(args, suppliedConfig, recovery) {
|
|
157
224
|
const config = suppliedConfig || await readConfig();
|
|
158
225
|
if (!config) throw new Error("This computer is not linked. Open https://usagemax.com/account and create a link code.");
|
|
159
226
|
config.deviceId = await stableInstallationId(configDirectory(), config.deviceId);
|
|
160
227
|
const requestedFull = args.includes("--full");
|
|
161
|
-
const
|
|
228
|
+
const requestedArchives = args.includes("--archives");
|
|
229
|
+
const dryRun = args.includes("--dry-run");
|
|
230
|
+
const explain = args.includes("--explain");
|
|
231
|
+
const json = args.includes("--json");
|
|
232
|
+
if (args.includes("--restart") && !dryRun) {
|
|
233
|
+
restartExpiredUpload(config);
|
|
234
|
+
await writeConfig(config);
|
|
235
|
+
}
|
|
236
|
+
if (config.pendingSync) {
|
|
237
|
+
if (dryRun) {
|
|
238
|
+
const result = { ...config.pendingSync.result, dryRun: true, pendingRunId: config.pendingSync.runId };
|
|
239
|
+
process.stdout.write(json ? `${JSON.stringify(result)}\n` : `Dry run: saved run ${config.pendingSync.runId} awaits resume; no upload.\n`);
|
|
240
|
+
return result;
|
|
241
|
+
}
|
|
242
|
+
const result = await resumeUpload(config, { save: writeConfig, request: snapshotRequest, warn: warnVersion });
|
|
243
|
+
process.stdout.write(json ? `${JSON.stringify(result)}\n` : "Resumed and completed the saved sync. Run sync again to scan newer local changes.\n");
|
|
244
|
+
return result;
|
|
245
|
+
}
|
|
246
|
+
const inventory = await sourceInventory({ env: recovery.env, home: ccusageHome(recovery.env) });
|
|
162
247
|
const today = new Date().toISOString().slice(0, 10);
|
|
163
248
|
const knownSources = Array.isArray(config.knownSources) ? config.knownSources : [];
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
const full = requestedFull || fullDue;
|
|
171
|
-
if (!full && inventory.complete && config.lastSyncComplete && config.lastReconciledDay === today && config.sourceFingerprint === inventory.fingerprint) {
|
|
172
|
-
const result = { accepted: 0, changedRows: 0, sources: inventory.sources, regressions: 0, scanned: false, full: false };
|
|
173
|
-
if (args.includes("--json")) process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
249
|
+
const { bootstrap, full, skip, inventoryStable } = scanPolicy(config, inventory, {
|
|
250
|
+
today, now: Date.now(), inventoryVersion: SOURCE_INVENTORY_VERSION, requestedFull, requestedArchives,
|
|
251
|
+
});
|
|
252
|
+
if (skip) {
|
|
253
|
+
const result = { accepted: 0, changedRows: 0, sessions: 0, sources: inventory.sources, corrections: 0, scanned: false, full: false, coverage: config.lastCoverage || "partial" };
|
|
254
|
+
if (json) process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
174
255
|
else process.stdout.write("Already up to date. Local usage files have not changed; no logs were parsed or uploaded.\n");
|
|
175
256
|
return;
|
|
176
257
|
}
|
|
177
|
-
const report = await ccusageJson(config, { full });
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
258
|
+
const report = await ccusageJson(config, { env: recovery.env, full });
|
|
259
|
+
// ccusage v20 exposes aggregates, not proof that every discovered file was
|
|
260
|
+
// parsed. Inventory success alone cannot authorize destructive corrections.
|
|
261
|
+
const authoritative = false;
|
|
262
|
+
const legacySnapshotBootstrap = bootstrap
|
|
263
|
+
&& Object.keys(config.snapshots || {}).length > 0;
|
|
264
|
+
const runId = randomUUID();
|
|
265
|
+
const revision = Date.now();
|
|
266
|
+
const pricingVersion = `ccusage@${CCUSAGE_VERSION}`;
|
|
267
|
+
const { partitions, nextSnapshots, regressions } = buildSnapshotPlan(report, config.snapshots, {
|
|
268
|
+
bootstrap,
|
|
269
|
+
complete: authoritative,
|
|
270
|
+
full,
|
|
271
|
+
pricingVersion,
|
|
272
|
+
revision,
|
|
273
|
+
runId,
|
|
274
|
+
});
|
|
275
|
+
const sessions = buildSessionPlan(report, config.deviceId);
|
|
276
|
+
const sources = sourceSummary(report);
|
|
277
|
+
const days = Array.isArray(report?.daily)
|
|
278
|
+
? report.daily.map((row) => row?.period).filter((day) => /^\d{4}-\d{2}-\d{2}$/.test(day || "")).sort()
|
|
279
|
+
: [];
|
|
280
|
+
const result = {
|
|
281
|
+
accepted: null,
|
|
282
|
+
changedRows: partitions.reduce((sum, partition) => sum + partition.rows.filter((row) => JSON.stringify(row.previous) !== JSON.stringify(row.current)).length, 0),
|
|
283
|
+
sessions: sessions.length,
|
|
284
|
+
sources,
|
|
285
|
+
corrections: authoritative ? regressions.length : 0,
|
|
286
|
+
protectedRegressions: authoritative ? 0 : regressions.length,
|
|
287
|
+
partitions: partitions.length,
|
|
288
|
+
scanned: true,
|
|
289
|
+
full,
|
|
290
|
+
coverage: authoritative ? "complete" : "partial",
|
|
291
|
+
coverageReason: "Parser does not certify complete source/day coverage; decreases and deletions are protected.",
|
|
292
|
+
range: { from: days[0], to: days.at(-1) },
|
|
293
|
+
};
|
|
294
|
+
if (requestedArchives) {
|
|
295
|
+
result.archives = recovery.archives;
|
|
296
|
+
result.unsupportedArchives = recovery.unsupported;
|
|
202
297
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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} protected regression(s). ${result.coverageReason}\n`);
|
|
303
|
+
}
|
|
304
|
+
return result;
|
|
305
|
+
}
|
|
306
|
+
const requests = [{ operation: "begin", payload: {
|
|
307
|
+
runId,
|
|
308
|
+
mode: requestedArchives ? "archives" : full ? "full" : "incremental",
|
|
309
|
+
baselineMode: legacySnapshotBootstrap ? "adopt-current" : "apply",
|
|
310
|
+
sourceCount: sources.length,
|
|
311
|
+
partitionCount: partitions.length,
|
|
312
|
+
inventoryComplete: authoritative,
|
|
313
|
+
inventoryErrors: inventory.errors,
|
|
314
|
+
inventoryTruncated: inventory.truncated,
|
|
315
|
+
coverageStartDay: days[0],
|
|
316
|
+
coverageEndDay: days.at(-1),
|
|
317
|
+
} }];
|
|
318
|
+
for (let offset = 0; offset < sessions.length; offset += 100) {
|
|
319
|
+
requests.push({ operation: "sessions", payload: { runId, sessions: sessions.slice(offset, offset + 100) } });
|
|
320
|
+
}
|
|
321
|
+
for (let offset = 0; offset < partitions.length; offset += 10) {
|
|
322
|
+
requests.push({ operation: "partitions", payload: { runId, partitions: partitions.slice(offset, offset + 10) } });
|
|
323
|
+
}
|
|
324
|
+
requests.push({ operation: "complete", payload: { runId } });
|
|
325
|
+
const syncedAt = new Date().toISOString();
|
|
326
|
+
config.pendingSync = {
|
|
327
|
+
version: 1, runId, cursor: 0, requests, result,
|
|
328
|
+
checkpoint: {
|
|
329
|
+
snapshots: nextSnapshots, snapshotProtocolVersion: 2,
|
|
330
|
+
lastSyncAt: syncedAt, lastReconciledDay: today, lastSyncComplete: authoritative,
|
|
331
|
+
lastScanSucceeded: true, lastCoverage: result.coverage,
|
|
332
|
+
sourceInventoryVersion: SOURCE_INVENTORY_VERSION,
|
|
333
|
+
knownSources: [...new Set([...knownSources, ...inventory.sources, ...sources])].sort(),
|
|
334
|
+
sourceFingerprint: inventoryStable ? inventory.fingerprint : null,
|
|
335
|
+
...(full ? { lastFullSyncAt: syncedAt } : {}),
|
|
336
|
+
},
|
|
337
|
+
};
|
|
338
|
+
config.lastSyncComplete = false;
|
|
211
339
|
await writeConfig(config);
|
|
212
|
-
|
|
213
|
-
if (
|
|
340
|
+
await resumeUpload(config, { save: writeConfig, request: snapshotRequest, warn: warnVersion });
|
|
341
|
+
if (json) process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
214
342
|
else {
|
|
215
|
-
process.stdout.write(
|
|
216
|
-
|
|
343
|
+
process.stdout.write(partitions.length || sessions.length
|
|
344
|
+
? `Completed ${partitions.length} usage chunk(s) and ${sessions.length} session identifier(s) from ${sources.join(", ") || "local agents"}${full ? " across retained history" : ""}.\n`
|
|
345
|
+
: `Already up to date. No usage rows changed${full ? " after a full-history reconciliation" : ""}.\n`);
|
|
346
|
+
if (regressions.length) process.stdout.write(`${regressions.length} local row(s) moved backward; prior counter dimensions were preserved because coverage is incomplete.\n`);
|
|
347
|
+
if (!authoritative) process.stdout.write(`${result.coverageReason}\n`);
|
|
348
|
+
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`);
|
|
217
349
|
}
|
|
350
|
+
return result;
|
|
218
351
|
}
|
|
219
352
|
|
|
220
353
|
async function status() {
|
|
@@ -231,23 +364,48 @@ async function status() {
|
|
|
231
364
|
process.stdout.write(`Linked: ${config.deviceName || deviceLabel()}${config.profileHandle ? ` → @${config.profileHandle}` : ""}\n`);
|
|
232
365
|
process.stdout.write(`Last sync: ${config.lastSyncAt || "never"}\n`);
|
|
233
366
|
process.stdout.write(`Last full reconciliation: ${config.lastFullSyncAt || "never"}\n`);
|
|
367
|
+
if (config.pendingSync) process.stdout.write(`Pending sync: ${config.pendingSync.runId}; rerun sync to resume\n`);
|
|
234
368
|
process.stdout.write(`Profile: ${config.profileUrl || "https://usagemax.com/account"}\n`);
|
|
235
369
|
}
|
|
236
370
|
|
|
237
371
|
async function doctor(args = []) {
|
|
238
372
|
const config = await readConfig();
|
|
239
|
-
const
|
|
373
|
+
const env = await ccusageEnvironment();
|
|
374
|
+
const inventory = await sourceInventory({ env, home: ccusageHome(env) });
|
|
375
|
+
const archives = await discoverProviderArchives({ env, home: ccusageHome(env) });
|
|
376
|
+
const homes = String(env.USAGEMAX_DISCOVERED_HOMES || ccusageHome(env)).split(",").filter(Boolean);
|
|
377
|
+
const result = {
|
|
378
|
+
linked: Boolean(config),
|
|
379
|
+
homes,
|
|
380
|
+
detectedSources: inventory.sources,
|
|
381
|
+
files: inventory.files,
|
|
382
|
+
inventoryComplete: inventory.complete,
|
|
383
|
+
inventoryErrors: inventory.errors,
|
|
384
|
+
inventoryTruncated: inventory.truncated,
|
|
385
|
+
supportedSources: SUPPORTED_SOURCES,
|
|
386
|
+
archives: archives.length,
|
|
387
|
+
environment: platform() === "linux" && process.env.WSL_DISTRO_NAME ? `WSL ${process.env.WSL_DISTRO_NAME}` : platform(),
|
|
388
|
+
mode: args.includes("--deep") ? "deep" : "metadata-only",
|
|
389
|
+
};
|
|
390
|
+
if (args.includes("--deep")) {
|
|
391
|
+
const report = await ccusageJson(config, { env, full: true });
|
|
392
|
+
result.parsedSources = sourceSummary(report);
|
|
393
|
+
result.sessions = buildSessionPlan(report, config?.deviceId || "unlinked").length;
|
|
394
|
+
}
|
|
395
|
+
if (args.includes("--json")) {
|
|
396
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
240
399
|
process.stdout.write(`Collector: ${config ? "linked" : "not linked"}\n`);
|
|
400
|
+
process.stdout.write(`Discovered homes: ${homes.length} (${homes.join(", ")})\n`);
|
|
241
401
|
process.stdout.write(`Detected sources: ${inventory.sources.join(", ") || "none"} (${inventory.files}${inventory.truncated ? "+" : ""} data files)\n`);
|
|
242
402
|
process.stdout.write(`Supported sources: ${SUPPORTED_SOURCES.join(", ")} (+ named pi-format stores)\n`);
|
|
243
403
|
if (platform() === "linux" && process.env.WSL_DISTRO_NAME) {
|
|
244
|
-
process.stdout.write(`Environment: WSL ${process.env.WSL_DISTRO_NAME};
|
|
404
|
+
process.stdout.write(`Environment: WSL ${process.env.WSL_DISTRO_NAME}; readable Windows provider homes are included automatically\n`);
|
|
245
405
|
}
|
|
406
|
+
if (archives.length) process.stdout.write(`Recovery: ${archives.length} compressed provider archive(s) detected; run \`bunx usagemax sync --archives\` once to reconcile them\n`);
|
|
246
407
|
if (!inventory.complete) process.stdout.write(`Inventory: incomplete (${inventory.errors} read error(s)${inventory.truncated ? ", file limit reached" : ""}); no-change shortcut disabled\n`);
|
|
247
|
-
if (
|
|
248
|
-
const report = await ccusageJson(config, { full: true });
|
|
249
|
-
process.stdout.write(`Parsed sources: ${sourceSummary(report).join(", ") || "none"}\n`);
|
|
250
|
-
}
|
|
408
|
+
if (result.parsedSources) process.stdout.write(`Parsed sources: ${result.parsedSources.join(", ") || "none"}; ${result.sessions} private session identifiers\n`);
|
|
251
409
|
process.stdout.write(`Mode: one-shot, metadata no-op check, ${args.includes("--deep") ? "deep local parse" : "no log parsing"}\n`);
|
|
252
410
|
}
|
|
253
411
|
|
|
@@ -264,15 +422,28 @@ async function report(args) {
|
|
|
264
422
|
if (code !== 0) process.exitCode = code;
|
|
265
423
|
}
|
|
266
424
|
|
|
267
|
-
async function removeLink() {
|
|
425
|
+
async function removeLink(args = []) {
|
|
268
426
|
const path = configPath();
|
|
269
427
|
const config = await readConfig();
|
|
270
428
|
if (!config) {
|
|
271
429
|
process.stdout.write("This computer is not linked.\n");
|
|
272
430
|
return;
|
|
273
431
|
}
|
|
432
|
+
if (args.includes("--revoke")) {
|
|
433
|
+
const endpoint = validHttpsUrl(config.revokeUrl, { allowLocalhost: true });
|
|
434
|
+
if (!endpoint) throw new Error("This collector does not have a valid revoke endpoint. Revoke it at https://usagemax.com/account.");
|
|
435
|
+
const response = await fetch(endpoint, {
|
|
436
|
+
method: "POST",
|
|
437
|
+
headers: { authorization: `Bearer ${config.token}`, "content-type": "application/json" },
|
|
438
|
+
body: JSON.stringify({ deviceId: config.deviceId }),
|
|
439
|
+
signal: AbortSignal.timeout(15_000),
|
|
440
|
+
});
|
|
441
|
+
if (!response.ok) throw new Error("UsageMax could not revoke this collector. It remains linked locally.");
|
|
442
|
+
}
|
|
274
443
|
await unlink(path);
|
|
275
|
-
process.stdout.write(
|
|
444
|
+
process.stdout.write(args.includes("--revoke")
|
|
445
|
+
? "Revoked this collector and removed its local key. Existing usage totals were retained.\n"
|
|
446
|
+
: "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");
|
|
276
447
|
}
|
|
277
448
|
|
|
278
449
|
async function main() {
|
|
@@ -280,12 +451,16 @@ async function main() {
|
|
|
280
451
|
const command = args[0] || "sync";
|
|
281
452
|
if (["--help", "-h", "help"].includes(command)) return help();
|
|
282
453
|
if (["--version", "-v"].includes(command)) return process.stdout.write(`${VERSION}\n`);
|
|
283
|
-
if (command === "link") return link(args.slice(1));
|
|
284
|
-
if (command === "sync") return sync(args.slice(1));
|
|
285
|
-
if (command === "status") return status();
|
|
286
|
-
if (command === "doctor") return doctor(args.slice(1));
|
|
287
454
|
if (command === "report") return report(args.slice(1));
|
|
288
|
-
if (
|
|
455
|
+
if (["link", "sync", "status", "doctor", "unlink"].includes(command)) {
|
|
456
|
+
return withConfigLock(configDirectory(), async () => {
|
|
457
|
+
if (command === "link") return link(args.slice(1));
|
|
458
|
+
if (command === "sync") return sync(args.slice(1));
|
|
459
|
+
if (command === "status") return status();
|
|
460
|
+
if (command === "doctor") return doctor(args.slice(1));
|
|
461
|
+
return removeLink(args.slice(1));
|
|
462
|
+
});
|
|
463
|
+
}
|
|
289
464
|
throw new Error(`Unknown command: ${command}. Run usagemax --help.`);
|
|
290
465
|
}
|
|
291
466
|
|
package/src/core.js
CHANGED
|
@@ -43,6 +43,82 @@ function providerFor(model, source) {
|
|
|
43
43
|
return source;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
const snapshotCounterFields = [
|
|
47
|
+
"inputTokens",
|
|
48
|
+
"outputTokens",
|
|
49
|
+
"cacheReadTokens",
|
|
50
|
+
"cacheWriteTokens",
|
|
51
|
+
"reasoningTokens",
|
|
52
|
+
"unclassifiedTokens",
|
|
53
|
+
"totalTokens",
|
|
54
|
+
"costMicros",
|
|
55
|
+
"requests",
|
|
56
|
+
"errors",
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
function snapshotCounters(value = {}) {
|
|
60
|
+
const inputTokens = number(value.inputTokens);
|
|
61
|
+
const outputTokens = number(value.outputTokens);
|
|
62
|
+
const cacheReadTokens = number(value.cacheReadTokens);
|
|
63
|
+
const cacheWriteTokens = number(value.cacheWriteTokens);
|
|
64
|
+
const reasoningTokens = number(value.reasoningTokens);
|
|
65
|
+
const suppliedTotal = number(value.totalTokens);
|
|
66
|
+
const classified = inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens + reasoningTokens;
|
|
67
|
+
const unclassifiedTokens = value.unclassifiedTokens === undefined
|
|
68
|
+
? Math.max(0, suppliedTotal - classified)
|
|
69
|
+
: number(value.unclassifiedTokens);
|
|
70
|
+
return {
|
|
71
|
+
inputTokens,
|
|
72
|
+
outputTokens,
|
|
73
|
+
cacheReadTokens,
|
|
74
|
+
cacheWriteTokens,
|
|
75
|
+
reasoningTokens,
|
|
76
|
+
unclassifiedTokens,
|
|
77
|
+
totalTokens: classified + unclassifiedTokens,
|
|
78
|
+
costMicros: number(value.costMicros, MAX_SAFE_COST_MICROS),
|
|
79
|
+
requests: number(value.requests),
|
|
80
|
+
errors: number(value.errors),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function sameCounters(left, right) {
|
|
85
|
+
return snapshotCounterFields.every((field) => left[field] === right[field]);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function snapshotKey(source, period, provider, model) {
|
|
89
|
+
return `${source}\u001f${period}\u001f${provider}\u001f${model}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function readSnapshotKey(key) {
|
|
93
|
+
const parts = String(key).split("\u001f");
|
|
94
|
+
if (parts.length === 4) return { source: parts[0], period: parts[1], provider: parts[2], model: parts[3] };
|
|
95
|
+
if (parts.length === 3) return { source: parts[0], period: parts[1], provider: providerFor(parts[2], parts[0]), model: parts[2] };
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function normalizedSnapshotRows(report) {
|
|
100
|
+
const rows = new Map();
|
|
101
|
+
for (const row of currentRows(report)) {
|
|
102
|
+
const provider = providerFor(row.model, row.source);
|
|
103
|
+
const key = snapshotKey(row.source, row.period, provider, row.model);
|
|
104
|
+
const current = snapshotCounters(row.current);
|
|
105
|
+
const existing = rows.get(key);
|
|
106
|
+
if (existing) {
|
|
107
|
+
for (const field of snapshotCounterFields) existing.current[field] += current[field];
|
|
108
|
+
} else {
|
|
109
|
+
rows.set(key, {
|
|
110
|
+
key,
|
|
111
|
+
source: row.source,
|
|
112
|
+
period: row.period,
|
|
113
|
+
provider,
|
|
114
|
+
model: row.model,
|
|
115
|
+
current,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return rows;
|
|
120
|
+
}
|
|
121
|
+
|
|
46
122
|
function sha256(value) {
|
|
47
123
|
return createHash("sha256").update(value).digest("hex");
|
|
48
124
|
}
|
|
@@ -186,7 +262,7 @@ export function buildDeltaPlan(report, priorSnapshots, deviceId, pricingVersion
|
|
|
186
262
|
pricingVersion,
|
|
187
263
|
status: "ok",
|
|
188
264
|
state: "synced",
|
|
189
|
-
occurredAt: `${row.period}
|
|
265
|
+
occurredAt: `${row.period}T00:00:00.000Z`,
|
|
190
266
|
completeness: "estimated",
|
|
191
267
|
},
|
|
192
268
|
});
|
|
@@ -195,6 +271,149 @@ export function buildDeltaPlan(report, priorSnapshots, deviceId, pricingVersion
|
|
|
195
271
|
return { plan, regressions };
|
|
196
272
|
}
|
|
197
273
|
|
|
274
|
+
export function buildSnapshotPlan(report, priorSnapshots, {
|
|
275
|
+
bootstrap = false,
|
|
276
|
+
complete = true,
|
|
277
|
+
full = false,
|
|
278
|
+
pricingVersion = "ccusage",
|
|
279
|
+
revision = Date.now(),
|
|
280
|
+
runId = randomPlanId(),
|
|
281
|
+
} = {}) {
|
|
282
|
+
const rows = normalizedSnapshotRows(report);
|
|
283
|
+
const priorRows = new Map();
|
|
284
|
+
const snapshots = priorSnapshots && typeof priorSnapshots === "object" ? priorSnapshots : {};
|
|
285
|
+
for (const [rawKey, rawValue] of Object.entries(snapshots)) {
|
|
286
|
+
const identity = readSnapshotKey(rawKey);
|
|
287
|
+
if (!identity || !/^\d{4}-\d{2}-\d{2}$/.test(identity.period)) continue;
|
|
288
|
+
const key = snapshotKey(identity.source, identity.period, identity.provider, identity.model);
|
|
289
|
+
priorRows.set(key, {
|
|
290
|
+
key,
|
|
291
|
+
...identity,
|
|
292
|
+
current: snapshotCounters(rawValue),
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const currentPartitions = new Set([...rows.values()].map((row) => `${row.source}\u001f${row.period}`));
|
|
297
|
+
const allKeys = new Set(rows.keys());
|
|
298
|
+
for (const [key, prior] of priorRows) {
|
|
299
|
+
if (full || currentPartitions.has(`${prior.source}\u001f${prior.period}`)) allKeys.add(key);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const grouped = new Map();
|
|
303
|
+
// Carry forward history outside this scan's window. Only reconciled keys may
|
|
304
|
+
// replace or remove a checkpoint, including legacy three-part identities.
|
|
305
|
+
const nextSnapshots = Object.fromEntries([...priorRows].map(([key, row]) => [key, row.current]));
|
|
306
|
+
const regressions = [];
|
|
307
|
+
for (const key of allKeys) {
|
|
308
|
+
const currentRow = rows.get(key);
|
|
309
|
+
const priorRow = priorRows.get(key);
|
|
310
|
+
const identity = currentRow || priorRow;
|
|
311
|
+
if (!identity) continue;
|
|
312
|
+
const previous = priorRow?.current ?? snapshotCounters();
|
|
313
|
+
let current = currentRow?.current ?? snapshotCounters();
|
|
314
|
+
const regressed = snapshotCounterFields.some((field) => current[field] < previous[field]);
|
|
315
|
+
if (regressed) regressions.push(key);
|
|
316
|
+
// A larger total can conceal a missing dimension. Keep the coherent prior
|
|
317
|
+
// vector (including in the checkpoint) until coverage is authoritative.
|
|
318
|
+
if (!complete && regressed) current = { ...previous };
|
|
319
|
+
if (!sameCounters(current, snapshotCounters())) nextSnapshots[key] = current;
|
|
320
|
+
else delete nextSnapshots[key];
|
|
321
|
+
const partitionKey = `${identity.source}\u001f${identity.period}`;
|
|
322
|
+
const rowsForPartition = grouped.get(partitionKey) ?? [];
|
|
323
|
+
rowsForPartition.push({
|
|
324
|
+
snapshotKey: key,
|
|
325
|
+
provider: identity.provider,
|
|
326
|
+
model: identity.model,
|
|
327
|
+
previous,
|
|
328
|
+
current,
|
|
329
|
+
costBasis: "estimated",
|
|
330
|
+
contentHash: sha256(`${key}\u001f${JSON.stringify(current)}`),
|
|
331
|
+
// A daily aggregate has no exact event time. Use the start of its UTC day
|
|
332
|
+
// so today's partition is valid even when the collector runs before noon.
|
|
333
|
+
lastUsedAt: Date.parse(`${identity.period}T00:00:00.000Z`),
|
|
334
|
+
});
|
|
335
|
+
grouped.set(partitionKey, rowsForPartition);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const partitions = [];
|
|
339
|
+
for (const [partitionKey, partitionRows] of grouped) {
|
|
340
|
+
const [source, day] = partitionKey.split("\u001f");
|
|
341
|
+
const changed = partitionRows.some((row) => !sameCounters(row.previous, row.current));
|
|
342
|
+
if (!bootstrap && !full && !changed) continue;
|
|
343
|
+
partitionRows.sort((left, right) => `${left.provider}/${left.model}`.localeCompare(`${right.provider}/${right.model}`));
|
|
344
|
+
const wireRows = partitionRows.map(({ provider, model, previous, current, costBasis, contentHash, lastUsedAt }) => ({
|
|
345
|
+
provider,
|
|
346
|
+
model,
|
|
347
|
+
previous,
|
|
348
|
+
current,
|
|
349
|
+
costBasis,
|
|
350
|
+
contentHash,
|
|
351
|
+
lastUsedAt,
|
|
352
|
+
}));
|
|
353
|
+
const chunkCount = Math.ceil(wireRows.length / 100);
|
|
354
|
+
for (let chunkIndex = 0; chunkIndex < chunkCount; chunkIndex += 1) {
|
|
355
|
+
const rows = wireRows.slice(chunkIndex * 100, (chunkIndex + 1) * 100);
|
|
356
|
+
const payloadHash = sha256(JSON.stringify({ source, day, complete, pricingVersion, chunkIndex, chunkCount, rows }));
|
|
357
|
+
partitions.push({
|
|
358
|
+
partitionId: `${runId}:${sha256(partitionKey).slice(0, 24)}:${chunkIndex}`,
|
|
359
|
+
payloadHash,
|
|
360
|
+
revision,
|
|
361
|
+
source,
|
|
362
|
+
day,
|
|
363
|
+
complete,
|
|
364
|
+
pricingVersion,
|
|
365
|
+
chunkIndex,
|
|
366
|
+
chunkCount,
|
|
367
|
+
rows,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
partitions.sort((left, right) => left.day === right.day ? left.source.localeCompare(right.source) : left.day.localeCompare(right.day));
|
|
372
|
+
return { partitions, nextSnapshots, regressions };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function randomPlanId() {
|
|
376
|
+
return `run-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Inventory stability certifies that repeating a successful parse is unnecessary;
|
|
380
|
+
// it does not certify authority to remove server history. Bootstrap is separate.
|
|
381
|
+
export function scanPolicy(config, inventory, { today, now, inventoryVersion, requestedFull = false, requestedArchives = false }) {
|
|
382
|
+
const knownSources = Array.isArray(config.knownSources) ? config.knownSources : [];
|
|
383
|
+
const lastFull = Date.parse(config.lastFullSyncAt || "");
|
|
384
|
+
const bootstrap = config.snapshotProtocolVersion !== 2;
|
|
385
|
+
const full = requestedFull || requestedArchives || bootstrap
|
|
386
|
+
|| config.sourceInventoryVersion !== inventoryVersion
|
|
387
|
+
|| !Number.isFinite(lastFull) || now - lastFull >= 7 * 24 * 60 * 60 * 1000
|
|
388
|
+
|| inventory.sources.some((source) => !knownSources.includes(source));
|
|
389
|
+
const inventoryStable = inventory.complete && !inventory.truncated && inventory.errors === 0;
|
|
390
|
+
const skip = !config.pendingSync && !full && inventoryStable && config.lastScanSucceeded === true
|
|
391
|
+
&& config.lastReconciledDay === today && config.sourceFingerprint === inventory.fingerprint;
|
|
392
|
+
return { bootstrap, full, skip, inventoryStable };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export function buildSessionPlan(report, deviceId) {
|
|
396
|
+
const sessionRows = Array.isArray(report?.session) ? report.session : [];
|
|
397
|
+
const sessions = new Map();
|
|
398
|
+
for (const row of sessionRows) {
|
|
399
|
+
if (!row || typeof row !== "object") continue;
|
|
400
|
+
const source = text(row.agent, "unknown", 60).toLowerCase();
|
|
401
|
+
const stableIdentity = text(row.period ?? row.sessionId ?? row.id, "", 1000);
|
|
402
|
+
if (!stableIdentity) continue;
|
|
403
|
+
const metadata = row.metadata && typeof row.metadata === "object" ? row.metadata : {};
|
|
404
|
+
const firstActivityAt = Date.parse(metadata.firstActivity ?? metadata.createdAt ?? "");
|
|
405
|
+
const lastActivityAt = Date.parse(metadata.lastActivity ?? row.lastActivity ?? "");
|
|
406
|
+
const sessionKey = sha256(`${deviceId}\u001f${source}\u001f${stableIdentity}`);
|
|
407
|
+
sessions.set(`${source}\u001f${sessionKey}`, {
|
|
408
|
+
source,
|
|
409
|
+
sessionKey,
|
|
410
|
+
...(Number.isFinite(firstActivityAt) ? { firstActivityAt } : {}),
|
|
411
|
+
...(Number.isFinite(lastActivityAt) ? { lastActivityAt } : {}),
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
return [...sessions.values()].sort((left, right) => `${left.source}/${left.sessionKey}`.localeCompare(`${right.source}/${right.sessionKey}`));
|
|
415
|
+
}
|
|
416
|
+
|
|
198
417
|
export function batchId(deviceId, events) {
|
|
199
418
|
const identity = events.map((event) => event.eventKey).join("\u001f");
|
|
200
419
|
return `cli:${deviceId}:${sha256(identity).slice(0, 32)}`;
|
package/src/resume.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// A pending run is saved before network I/O. Persisting the acknowledged cursor
|
|
2
|
+
// after each request permits replays when the response or local write is lost.
|
|
3
|
+
// The server must receipt begin, chunks, sessions and complete idempotently.
|
|
4
|
+
export async function resumeUpload(config, { save, request, warn = () => {} }) {
|
|
5
|
+
const pending = config.pendingSync;
|
|
6
|
+
if (!pending || pending.version !== 1 || !Array.isArray(pending.requests)) {
|
|
7
|
+
throw new Error("Invalid saved sync; preserve the config for recovery.");
|
|
8
|
+
}
|
|
9
|
+
try {
|
|
10
|
+
for (let index = pending.cursor; index < pending.requests.length; index += 1) {
|
|
11
|
+
const { operation, payload } = pending.requests[index];
|
|
12
|
+
const response = await request(config, operation, payload, operation === "partitions" ? 60_000 : 30_000);
|
|
13
|
+
warn(response);
|
|
14
|
+
pending.cursor = index + 1;
|
|
15
|
+
await save(config);
|
|
16
|
+
}
|
|
17
|
+
// Commit local baseline and remove the journal in the same atomic write.
|
|
18
|
+
const next = { ...config, ...pending.checkpoint };
|
|
19
|
+
delete next.pendingSync;
|
|
20
|
+
await save(next);
|
|
21
|
+
Object.assign(config, next);
|
|
22
|
+
delete config.pendingSync;
|
|
23
|
+
return { ...pending.result, resumed: true };
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if (error?.code === "snapshot_run_expired") {
|
|
26
|
+
pending.terminalError = "snapshot_run_expired";
|
|
27
|
+
await save(config);
|
|
28
|
+
throw new Error("Saved upload expired. Run usagemax sync --restart to rescan retained history while preserving the last committed checkpoint. Already accepted usage remains on the server.", { cause: error });
|
|
29
|
+
}
|
|
30
|
+
throw new Error(`${error instanceof Error ? error.message : "Upload failed."} Run usagemax sync again to resume saved run ${pending.runId}; its payload and checkpoints have been retained.`, { cause: error });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function restartExpiredUpload(config) {
|
|
35
|
+
if (config.pendingSync?.terminalError !== "snapshot_run_expired") throw new Error("Only an expired upload can restart. Run sync normally to resume or verify its status first.");
|
|
36
|
+
delete config.pendingSync;
|
|
37
|
+
config.lastFullSyncAt = undefined;
|
|
38
|
+
config.sourceFingerprint = null;
|
|
39
|
+
}
|
|
40
|
+
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
41
|
+
import { join } from "node:path";
|
|
42
|
+
|
|
43
|
+
export async function withConfigLock(directory, action) {
|
|
44
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
45
|
+
const path = join(directory, "collector.lock");
|
|
46
|
+
try {
|
|
47
|
+
await writeFile(path, `${process.pid}\n`, { flag: "wx", mode: 0o600 });
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (error.code !== "EEXIST") throw error;
|
|
50
|
+
const owner = (await readFile(path, "utf8").catch(() => "unknown")).trim();
|
|
51
|
+
throw new Error(`Collector config is locked by PID ${/^\d+$/.test(owner) ? owner : "unknown"}. If that process has exited, remove only ${path} and rerun sync; keep config.json for resume.`);
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
return await action();
|
|
55
|
+
} finally {
|
|
56
|
+
await unlink(path);
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/sources.js
CHANGED
|
@@ -4,7 +4,7 @@ import { homedir } from "node:os";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
|
|
6
6
|
export const CCUSAGE_VERSION = "20.0.20";
|
|
7
|
-
export const SOURCE_INVENTORY_VERSION =
|
|
7
|
+
export const SOURCE_INVENTORY_VERSION = 3;
|
|
8
8
|
export const SUPPORTED_SOURCES = [
|
|
9
9
|
"amp",
|
|
10
10
|
"claude",
|
|
@@ -25,6 +25,10 @@ export const SUPPORTED_SOURCES = [
|
|
|
25
25
|
];
|
|
26
26
|
|
|
27
27
|
const MAX_FINGERPRINT_FILES = 50_000;
|
|
28
|
+
const WINDOWS_SYSTEM_PROFILES = /^(?:all users|default(?: user)?|defaultuser0|public|temp(?:\.|$)|umfd-)/i;
|
|
29
|
+
const BACKUP_DIRECTORY = /(?:claude|codex).*(?:backup|archive|old|copy|mirror)|(?:backup|archive|old|copy|mirror).*(?:claude|codex)|superclaude/i;
|
|
30
|
+
const ARCHIVE_EXTENSION = /(?:\.tar(?:\.gz)?|\.tgz|\.zip|\.7z)$/i;
|
|
31
|
+
const ARCHIVE_FILE = /(?:claude|codex).*(?:\.tar(?:\.gz)?|\.tgz|\.zip|\.7z)$/i;
|
|
28
32
|
|
|
29
33
|
function has(env, name) {
|
|
30
34
|
return Object.prototype.hasOwnProperty.call(env, name);
|
|
@@ -45,6 +49,127 @@ function expandTilde(value, home, pathApi) {
|
|
|
45
49
|
return value;
|
|
46
50
|
}
|
|
47
51
|
|
|
52
|
+
function uniquePaths(values, pathApi) {
|
|
53
|
+
const seen = new Set();
|
|
54
|
+
return values.filter((value) => {
|
|
55
|
+
if (typeof value !== "string" || !value.trim()) return false;
|
|
56
|
+
const normalized = pathApi.normalize(value.trim());
|
|
57
|
+
if (seen.has(normalized)) return false;
|
|
58
|
+
seen.add(normalized);
|
|
59
|
+
return true;
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function entries(value) {
|
|
64
|
+
try {
|
|
65
|
+
return await readdir(value, { withFileTypes: true });
|
|
66
|
+
} catch {
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function providerBearingHome(home, pathApi) {
|
|
72
|
+
const markers = [".claude", ".codex", ".factory", ".gemini", ".openclaw", ".hermes", ".grok"];
|
|
73
|
+
const checks = await Promise.all(markers.map((marker) => existsDirectory(pathApi.join(home, marker))));
|
|
74
|
+
return checks.some(Boolean);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function discoverWslWindowsHomes(env, platform, pathApi) {
|
|
78
|
+
if (platform !== "linux" || !String(env.WSL_DISTRO_NAME ?? "").trim()) return [];
|
|
79
|
+
const usersRoot = String(env.USAGEMAX_WSL_USERS_DIR ?? "/mnt/c/Users").trim();
|
|
80
|
+
const candidates = [];
|
|
81
|
+
for (const entry of await entries(usersRoot)) {
|
|
82
|
+
if (!entry.isDirectory() || WINDOWS_SYSTEM_PROFILES.test(entry.name)) continue;
|
|
83
|
+
const candidate = pathApi.join(usersRoot, entry.name);
|
|
84
|
+
if (await providerBearingHome(candidate, pathApi)) candidates.push(candidate);
|
|
85
|
+
}
|
|
86
|
+
// A WSL distro can see every Windows profile. Auto-select only when there is
|
|
87
|
+
// one unambiguous provider-bearing profile; multi-user systems opt in with
|
|
88
|
+
// USAGEMAX_ADDITIONAL_HOME so one employee never absorbs another's usage.
|
|
89
|
+
return candidates.length === 1 ? candidates : [];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function discoverBackupRoots(home, pathApi) {
|
|
93
|
+
const claude = [];
|
|
94
|
+
const codex = [];
|
|
95
|
+
const containers = [
|
|
96
|
+
{ path: home, requireProviderName: true },
|
|
97
|
+
{ path: pathApi.join(home, ".claude", "backups"), requireProviderName: false },
|
|
98
|
+
{ path: pathApi.join(home, ".codex", "backups"), requireProviderName: false },
|
|
99
|
+
];
|
|
100
|
+
for (const container of containers) {
|
|
101
|
+
for (const entry of await entries(container.path)) {
|
|
102
|
+
if (!entry.isDirectory() || (container.requireProviderName && !BACKUP_DIRECTORY.test(entry.name))) continue;
|
|
103
|
+
const candidate = pathApi.join(container.path, entry.name);
|
|
104
|
+
const claudeRoots = [candidate, pathApi.join(candidate, ".claude"), pathApi.join(candidate, "config")];
|
|
105
|
+
for (const root of claudeRoots) {
|
|
106
|
+
if (await existsDirectory(pathApi.join(root, "projects"))) claude.push(root);
|
|
107
|
+
}
|
|
108
|
+
const codexRoots = [candidate, pathApi.join(candidate, ".codex")];
|
|
109
|
+
for (const root of codexRoots) {
|
|
110
|
+
if (await existsDirectory(pathApi.join(root, "sessions")) || await existsDirectory(pathApi.join(root, "archived_sessions"))) codex.push(root);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return { claude, codex };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function discoverNestedClaudeRoots(home, pathApi, maxDirectories = 4_096) {
|
|
118
|
+
const bases = [
|
|
119
|
+
pathApi.join(home, "Library", "Application Support", "Claude", "local-agent-mode-sessions"),
|
|
120
|
+
pathApi.join(home, "AppData", "Roaming", "Claude", "local-agent-mode-sessions"),
|
|
121
|
+
];
|
|
122
|
+
const roots = [];
|
|
123
|
+
for (const base of bases) {
|
|
124
|
+
if (!await existsDirectory(base)) continue;
|
|
125
|
+
const stack = [{ directory: base, depth: 0 }];
|
|
126
|
+
let visited = 0;
|
|
127
|
+
while (stack.length && visited < maxDirectories) {
|
|
128
|
+
const { directory, depth } = stack.pop();
|
|
129
|
+
visited += 1;
|
|
130
|
+
for (const entry of await entries(directory)) {
|
|
131
|
+
if (!entry.isDirectory() || entry.name === "node_modules" || entry.name === ".git") continue;
|
|
132
|
+
const child = pathApi.join(directory, entry.name);
|
|
133
|
+
if (entry.name === ".claude") {
|
|
134
|
+
if (await existsDirectory(pathApi.join(child, "projects"))) roots.push(child);
|
|
135
|
+
} else if (depth < 7) {
|
|
136
|
+
stack.push({ directory: child, depth: depth + 1 });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return roots;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function setDiscoveredList(effective, name, defaults, additions, pathApi) {
|
|
145
|
+
if (has(effective, name) && !String(effective[name] ?? "").trim()) return;
|
|
146
|
+
const configured = has(effective, name) ? commaList(effective[name]) : defaults;
|
|
147
|
+
effective[name] = uniquePaths([...configured, ...additions], pathApi).join(",");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function discoverProviderArchives({ env = process.env, home = ccusageHome(env), pathApi = path } = {}) {
|
|
151
|
+
const homes = uniquePaths([home, ...commaList(env.USAGEMAX_DISCOVERED_HOMES), ...commaList(env.USAGEMAX_ADDITIONAL_HOME)], pathApi);
|
|
152
|
+
const archives = [];
|
|
153
|
+
for (const candidateHome of homes) {
|
|
154
|
+
const roots = [
|
|
155
|
+
{ path: candidateHome, source: null },
|
|
156
|
+
{ path: pathApi.join(candidateHome, ".claude", "backups"), source: "claude" },
|
|
157
|
+
{ path: pathApi.join(candidateHome, ".codex", "backups"), source: "codex" },
|
|
158
|
+
];
|
|
159
|
+
for (const root of roots) {
|
|
160
|
+
for (const entry of await entries(root.path)) {
|
|
161
|
+
if (!entry.isFile() || !ARCHIVE_EXTENSION.test(entry.name) || (!root.source && !ARCHIVE_FILE.test(entry.name))) continue;
|
|
162
|
+
const filePath = pathApi.join(root.path, entry.name);
|
|
163
|
+
try {
|
|
164
|
+
const metadata = await stat(filePath);
|
|
165
|
+
archives.push({ path: filePath, size: metadata.size, source: root.source || (/codex/i.test(entry.name) ? "codex" : "claude") });
|
|
166
|
+
} catch {}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return archives.filter((archive, index) => archives.findIndex((item) => item.path === archive.path) === index);
|
|
171
|
+
}
|
|
172
|
+
|
|
48
173
|
export function ccusageHome(env = process.env, fallback = homedir()) {
|
|
49
174
|
for (const value of [env.HOME, env.USERPROFILE]) {
|
|
50
175
|
if (typeof value === "string" && value.trim()) return value;
|
|
@@ -156,13 +281,52 @@ export function sourceDefinitions({ env = process.env, home = ccusageHome(env),
|
|
|
156
281
|
|
|
157
282
|
export async function ccusageEnvironment({ env = process.env, platform = process.platform, pathApi = path } = {}) {
|
|
158
283
|
const effective = { ...env };
|
|
159
|
-
|
|
160
|
-
|
|
284
|
+
const home = ccusageHome(effective);
|
|
285
|
+
const configuredHomes = commaList(effective.USAGEMAX_ADDITIONAL_HOME).map((item) => expandTilde(item, home, pathApi));
|
|
286
|
+
const wslHomes = await discoverWslWindowsHomes(effective, platform, pathApi);
|
|
287
|
+
const additionalHomes = uniquePaths([...configuredHomes, ...wslHomes], pathApi).filter((item) => pathApi.normalize(item) !== pathApi.normalize(home));
|
|
288
|
+
const homes = [home, ...additionalHomes];
|
|
289
|
+
const backups = await Promise.all(homes.map((candidate) => discoverBackupRoots(candidate, pathApi)));
|
|
290
|
+
const nestedClaude = (await Promise.all(homes.map((candidate) => discoverNestedClaudeRoots(candidate, pathApi)))).flat();
|
|
291
|
+
const mirroredClaude = [];
|
|
292
|
+
for (const candidate of homes) {
|
|
293
|
+
const mirror = pathApi.join(candidate, ".cc-mirror", "mclaude", "config");
|
|
294
|
+
if (await existsDirectory(pathApi.join(mirror, "projects"))) mirroredClaude.push(mirror);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const xdgClaude = pathApi.join(has(effective, "XDG_CONFIG_HOME") ? String(effective.XDG_CONFIG_HOME) : pathApi.join(home, ".config"), "claude");
|
|
298
|
+
setDiscoveredList(effective, "CLAUDE_CONFIG_DIR", [xdgClaude, pathApi.join(home, ".claude")], [
|
|
299
|
+
...additionalHomes.flatMap((candidate) => [pathApi.join(candidate, ".config", "claude"), pathApi.join(candidate, ".claude")]),
|
|
300
|
+
...mirroredClaude,
|
|
301
|
+
...backups.flatMap((item) => item.claude),
|
|
302
|
+
...nestedClaude,
|
|
303
|
+
], pathApi);
|
|
304
|
+
setDiscoveredList(effective, "CODEX_HOME", [pathApi.join(home, ".codex")], [
|
|
305
|
+
...additionalHomes.map((candidate) => pathApi.join(candidate, ".codex")),
|
|
306
|
+
...backups.flatMap((item) => item.codex),
|
|
307
|
+
], pathApi);
|
|
308
|
+
|
|
309
|
+
const additions = (segments) => additionalHomes.map((candidate) => pathApi.join(candidate, ...segments));
|
|
310
|
+
setDiscoveredList(effective, "OPENCODE_DATA_DIR", [pathApi.join(home, ".local", "share", "opencode")], additions([".local", "share", "opencode"]), pathApi);
|
|
311
|
+
setDiscoveredList(effective, "AMP_DATA_DIR", [pathApi.join(home, ".local", "share", "amp")], additions([".local", "share", "amp"]), pathApi);
|
|
312
|
+
setDiscoveredList(effective, "DROID_SESSIONS_DIR", [pathApi.join(home, ".factory", "sessions")], additions([".factory", "sessions"]), pathApi);
|
|
313
|
+
setDiscoveredList(effective, "CODEBUFF_DATA_DIR", ["manicode", "manicode-dev", "manicode-staging"].map((channel) => pathApi.join(home, ".config", channel)), additionalHomes.flatMap((candidate) => ["manicode", "manicode-dev", "manicode-staging"].map((channel) => pathApi.join(candidate, ".config", channel))), pathApi);
|
|
314
|
+
setDiscoveredList(effective, "HERMES_HOME", [pathApi.join(home, ".hermes")], additions([".hermes"]), pathApi);
|
|
315
|
+
setDiscoveredList(effective, "PI_AGENT_DIR", [pathApi.join(home, ".pi", "agent", "sessions")], additions([".pi", "agent", "sessions"]), pathApi);
|
|
316
|
+
setDiscoveredList(effective, "OPENCLAW_DIR", [".openclaw", ".clawdbot", ".moltbot", ".moldbot"].map((name) => pathApi.join(home, name)), additionalHomes.flatMap((candidate) => [".openclaw", ".clawdbot", ".moltbot", ".moldbot"].map((name) => pathApi.join(candidate, name))), pathApi);
|
|
317
|
+
setDiscoveredList(effective, "KILO_DATA_DIR", [pathApi.join(home, ".local", "share", "kilo")], additions([".local", "share", "kilo"]), pathApi);
|
|
318
|
+
setDiscoveredList(effective, "KIMI_DATA_DIR", [pathApi.join(home, ".kimi"), pathApi.join(home, ".kimi-code")], additionalHomes.flatMap((candidate) => [pathApi.join(candidate, ".kimi"), pathApi.join(candidate, ".kimi-code")]), pathApi);
|
|
319
|
+
setDiscoveredList(effective, "QWEN_DATA_DIR", [pathApi.join(home, ".qwen")], additions([".qwen"]), pathApi);
|
|
320
|
+
setDiscoveredList(effective, "GEMINI_DATA_DIR", [pathApi.join(home, ".gemini", "tmp")], additions([".gemini", "tmp"]), pathApi);
|
|
321
|
+
|
|
322
|
+
effective.USAGEMAX_DISCOVERED_HOMES = homes.join(",");
|
|
323
|
+
|
|
324
|
+
if (platform === "win32" && !String(effective.GOOSE_PATH_ROOT ?? "").trim() && String(effective.APPDATA ?? "").trim()) {
|
|
325
|
+
const gooseRoot = pathApi.join(String(effective.APPDATA).trim(), "Block", "goose");
|
|
326
|
+
try {
|
|
327
|
+
if ((await stat(pathApi.join(gooseRoot, "data", "sessions", "sessions.db"))).isFile()) effective.GOOSE_PATH_ROOT = gooseRoot;
|
|
328
|
+
} catch {}
|
|
161
329
|
}
|
|
162
|
-
const gooseRoot = pathApi.join(String(effective.APPDATA).trim(), "Block", "goose");
|
|
163
|
-
try {
|
|
164
|
-
if ((await stat(pathApi.join(gooseRoot, "data", "sessions", "sessions.db"))).isFile()) effective.GOOSE_PATH_ROOT = gooseRoot;
|
|
165
|
-
} catch {}
|
|
166
330
|
return effective;
|
|
167
331
|
}
|
|
168
332
|
|
package/src/transport.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
2
|
+
|
|
3
|
+
export function retryAfterMs(value, now = Date.now()) {
|
|
4
|
+
if (value == null || value === "") return 0;
|
|
5
|
+
if (/^\d+(?:\.\d+)?$/.test(value)) return Number(value) * 1000;
|
|
6
|
+
const date = Date.parse(value);
|
|
7
|
+
return Number.isFinite(date) ? Math.max(0, date - now) : 0;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Only snapshot operations have server receipts. Never automatically replay a
|
|
11
|
+
// one-use link request or apply this policy to arbitrary POST operations.
|
|
12
|
+
export async function requestSnapshot(endpoint, config, operation, payload, {
|
|
13
|
+
timeout = 30_000, attempts = 5, maxDelayMs = 60_000,
|
|
14
|
+
fetchImpl = fetch, sleep = delay, random = Math.random, now = Date.now,
|
|
15
|
+
} = {}) {
|
|
16
|
+
const body = JSON.stringify({ operation, ...payload });
|
|
17
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
18
|
+
let response;
|
|
19
|
+
let result;
|
|
20
|
+
let failure;
|
|
21
|
+
let retryable = true;
|
|
22
|
+
try {
|
|
23
|
+
response = await fetchImpl(endpoint, {
|
|
24
|
+
method: "POST",
|
|
25
|
+
headers: { authorization: `Bearer ${config.token}`, "content-type": "application/json", "x-usagemax-device-id": config.deviceId },
|
|
26
|
+
body,
|
|
27
|
+
signal: AbortSignal.timeout(timeout),
|
|
28
|
+
});
|
|
29
|
+
result = await response.json().catch(() => null);
|
|
30
|
+
if (response.ok && result?.ok === true) return result;
|
|
31
|
+
retryable = response.ok || response.status === 429 || response.status >= 500
|
|
32
|
+
|| (operation === "complete" && response.status === 409
|
|
33
|
+
&& String(result?.error).toLowerCase() === "snapshot_run_incomplete");
|
|
34
|
+
const code = typeof result?.error === "string" ? result.error.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 80) : "invalid_response";
|
|
35
|
+
const advice = response.status === 401 ? " Link this computer again."
|
|
36
|
+
: response.status === 403 ? " Check workspace membership and collector permissions."
|
|
37
|
+
: response.status === 409 ? " The saved run needs reconciliation; do not delete its checkpoint or repeatedly start new runs."
|
|
38
|
+
: response.status === 400 ? " Check CLI/server protocol compatibility."
|
|
39
|
+
: "";
|
|
40
|
+
failure = new Error(`Snapshot ${operation} failed (HTTP ${response.status}: ${code}).${advice}`);
|
|
41
|
+
failure.code = code;
|
|
42
|
+
} catch {
|
|
43
|
+
failure = new Error(`Snapshot ${operation} could not reach UsageMax or timed out. Check your network connection.`);
|
|
44
|
+
}
|
|
45
|
+
if (!retryable || attempt + 1 >= attempts) throw failure;
|
|
46
|
+
const retryAfter = retryAfterMs(response?.headers?.get("retry-after"), now());
|
|
47
|
+
// Never retry earlier than the server's requested interval. Long backoffs
|
|
48
|
+
// remain durably resumable instead of blocking the command indefinitely.
|
|
49
|
+
if (retryAfter > maxDelayMs) throw new Error(`${failure.message} Server requested a longer wait; retry sync after ${Math.ceil(retryAfter / 1000)} seconds.`);
|
|
50
|
+
const jitter = Math.min(maxDelayMs, 1000 * 2 ** attempt) * (0.5 + random() * 0.5);
|
|
51
|
+
await sleep(Math.max(retryAfter, jitter));
|
|
52
|
+
}
|
|
53
|
+
throw new Error("Snapshot retry budget is empty.");
|
|
54
|
+
}
|