usagemax 0.1.1 → 0.2.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 +66 -21
- package/package.json +2 -1
- package/src/cli.js +49 -98
- package/src/core.js +38 -2
- package/src/sources.js +331 -0
package/README.md
CHANGED
|
@@ -1,43 +1,88 @@
|
|
|
1
1
|
# UsageMax CLI
|
|
2
2
|
|
|
3
|
-
Connect aggregate coding-agent usage from
|
|
3
|
+
Connect aggregate coding-agent usage from every computer to one private UsageMax workspace.
|
|
4
4
|
|
|
5
5
|
## Quick start
|
|
6
6
|
|
|
7
7
|
1. Sign in at [usagemax.com/account](https://usagemax.com/account).
|
|
8
8
|
2. Choose **Link a computer** and copy the one-time command.
|
|
9
|
-
3. Run it
|
|
9
|
+
3. Run it in each operating-system environment that contains usage history:
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
|
-
bunx usagemax link UMX-XXXX-XXXX-XXXX-XXXX
|
|
12
|
+
bunx usagemax@latest link UMX-XXXX-XXXX-XXXX-XXXX
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
The link code expires after ten minutes and can be used once.
|
|
15
|
+
The link code expires after ten minutes and can be used once. Create a new code
|
|
16
|
+
for each Mac, Windows PC, Linux computer, and WSL distribution. All linked
|
|
17
|
+
collectors roll up into the same profile. Windows and WSL have separate home
|
|
18
|
+
directories, so run UsageMax once in Windows and once inside WSL when both have
|
|
19
|
+
agent history.
|
|
20
|
+
|
|
21
|
+
The CLI stores the resulting collector key in a user-only config file, then runs
|
|
22
|
+
a one-shot full-history sync. Running it again, changing the display name, or
|
|
23
|
+
relinking an installation does not create a second device: the private random
|
|
24
|
+
installation identity remains stable.
|
|
16
25
|
|
|
17
26
|
## Commands
|
|
18
27
|
|
|
19
28
|
```bash
|
|
20
|
-
bunx usagemax
|
|
21
|
-
bunx usagemax sync
|
|
22
|
-
bunx usagemax sync --full
|
|
23
|
-
bunx usagemax link UMX-… --no-sync
|
|
24
|
-
bunx usagemax status
|
|
25
|
-
bunx usagemax doctor
|
|
26
|
-
bunx usagemax doctor --deep
|
|
27
|
-
bunx usagemax report
|
|
29
|
+
bunx usagemax # sync changed usage
|
|
30
|
+
bunx usagemax sync # same as above
|
|
31
|
+
bunx usagemax sync --full # reconcile all retained local history
|
|
32
|
+
bunx usagemax link UMX-… --no-sync # link without uploading yet
|
|
33
|
+
bunx usagemax status # show link and last-sync state
|
|
34
|
+
bunx usagemax doctor # metadata-only source check
|
|
35
|
+
bunx usagemax doctor --deep # parse and verify all retained history
|
|
36
|
+
bunx usagemax report # open ccusage's local daily report
|
|
28
37
|
bunx usagemax report session --breakdown
|
|
29
|
-
bunx usagemax unlink
|
|
38
|
+
bunx usagemax unlink # remove the local collector key
|
|
30
39
|
```
|
|
31
40
|
|
|
32
|
-
UsageMax
|
|
41
|
+
UsageMax pins [ccusage v20.0.20](https://github.com/ccusage/ccusage/releases/tag/v20.0.20)
|
|
42
|
+
and supports all 16 adapters shipped in that release: Amp, Claude Code, Codebuff,
|
|
43
|
+
Codex, GitHub Copilot CLI, Factory Droid, Gemini CLI, Goose, Grok Build, Hermes,
|
|
44
|
+
Kilo Code, Kimi CLI, OpenClaw, OpenCode, Pi, and Qwen Code. Named Pi-format
|
|
45
|
+
stores configured through ccusage are also discovered.
|
|
46
|
+
|
|
47
|
+
The source inventory follows ccusage's environment overrides, including
|
|
48
|
+
`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `OPENCODE_DATA_DIR`, `AMP_DATA_DIR`,
|
|
49
|
+
`DROID_SESSIONS_DIR`, `CODEBUFF_DATA_DIR`, `HERMES_HOME`, `PI_AGENT_DIR`,
|
|
50
|
+
`GOOSE_PATH_ROOT`, `OPENCLAW_DIR`, `KILO_DATA_DIR`, `KIMI_DATA_DIR`,
|
|
51
|
+
`QWEN_DATA_DIR`, `COPILOT_OTEL_FILE_EXPORTER_PATH`, `GEMINI_DATA_DIR`, and
|
|
52
|
+
`GROK_HOME`. It also follows XDG Claude configuration and Windows Goose storage.
|
|
33
53
|
|
|
34
|
-
|
|
54
|
+
Local files are only one coverage layer. Cursor, Windsurf, Aider, Continue,
|
|
55
|
+
Cline, Roo Code, direct provider API traffic, hosted agents, and enterprise
|
|
56
|
+
billing systems do not all expose a stable local token ledger. Capture those
|
|
57
|
+
through UsageMax's native or OTLP endpoint, or through a future provider billing
|
|
58
|
+
connector. UsageMax never invents usage that the source did not retain.
|
|
35
59
|
|
|
36
|
-
|
|
37
|
-
|
|
60
|
+
## Privacy, correctness, and load
|
|
61
|
+
|
|
62
|
+
- The sync payload contains aggregate token counts, model/provider names, costs,
|
|
63
|
+
source names, and dates.
|
|
64
|
+
- It does not upload prompts, completions, source code, file contents, project
|
|
65
|
+
paths, or provider credentials.
|
|
38
66
|
- Sync is one-shot. There is no resident scanner or high-frequency polling loop.
|
|
39
|
-
-
|
|
40
|
-
|
|
41
|
-
-
|
|
67
|
+
- A metadata inventory exits without parsing logs or using the network when
|
|
68
|
+
nothing changed.
|
|
69
|
+
- Normal changed syncs parse today or today plus yesterday. A bounded weekly
|
|
70
|
+
full reconciliation catches restored files, parser changes, and older logs.
|
|
71
|
+
- Full history means all retained local history from 2024 onward. Deleted or
|
|
72
|
+
never-persisted usage requires a provider export; no local tool can reconstruct it.
|
|
73
|
+
- Source totals that cannot be assigned to a model are retained as
|
|
74
|
+
`unattributed` rather than silently discarded.
|
|
75
|
+
- The collector key is written with user-only permissions where the operating
|
|
76
|
+
system supports them.
|
|
77
|
+
- A private random installation ID survives collector rotation, relinking, and
|
|
78
|
+
display-name changes. It is not a hardware fingerprint; the server stores only
|
|
79
|
+
its SHA-256 hash. Concurrent and repeated syncs are idempotent.
|
|
80
|
+
- Do not point two different installations at the same copied or network-mounted
|
|
81
|
+
log tree. Cross-installation copied-history deduplication is inherently
|
|
82
|
+
ambiguous and intentionally not guessed.
|
|
83
|
+
|
|
84
|
+
Use `USAGEMAX_CONFIG_DIR` to select another config directory. Development and
|
|
85
|
+
self-hosted installations may set `USAGEMAX_LINK_ENDPOINT` before linking.
|
|
42
86
|
|
|
43
|
-
|
|
87
|
+
See the [collector coverage audit](../../docs/collector-coverage-audit.md) for
|
|
88
|
+
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.2.0",
|
|
4
4
|
"description": "Link local coding-agent usage to your UsageMax profile",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "UsageMax",
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"src/cli.js",
|
|
22
22
|
"src/core.js",
|
|
23
23
|
"src/installation.js",
|
|
24
|
+
"src/sources.js",
|
|
24
25
|
"README.md",
|
|
25
26
|
"LICENSE"
|
|
26
27
|
],
|
package/src/cli.js
CHANGED
|
@@ -1,25 +1,25 @@
|
|
|
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
12
|
import { batchId, buildDeltaPlan, normalizeLinkCode, sourceSummary, validHttpsUrl } from "./core.js";
|
|
13
13
|
import { stableInstallationId } from "./installation.js";
|
|
14
|
+
import { CCUSAGE_VERSION, ccusageEnvironment, SOURCE_INVENTORY_VERSION, sourceInventory, SUPPORTED_SOURCES } from "./sources.js";
|
|
14
15
|
|
|
15
16
|
const require = createRequire(import.meta.url);
|
|
16
17
|
const executeFile = promisify(execFile);
|
|
17
|
-
const VERSION = "0.
|
|
18
|
+
const VERSION = "0.2.0";
|
|
18
19
|
const DEFAULT_LINK_ENDPOINT = "https://terrific-bobcat-522.convex.site/v1/devices/link";
|
|
19
20
|
const CONFIG_FILE = "config.json";
|
|
20
21
|
const MAX_REPORT_BYTES = 100 * 1024 * 1024;
|
|
21
|
-
const
|
|
22
|
-
const USAGE_EXTENSIONS = new Set([".db", ".json", ".jsonl", ".sqlite", ".sqlite3"]);
|
|
22
|
+
const FULL_RECONCILE_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
23
23
|
|
|
24
24
|
function configDirectory() {
|
|
25
25
|
if (process.env.USAGEMAX_CONFIG_DIR) return process.env.USAGEMAX_CONFIG_DIR;
|
|
@@ -67,6 +67,7 @@ function option(args, name) {
|
|
|
67
67
|
function deviceLabel() {
|
|
68
68
|
if (platform() === "darwin") return "Mac";
|
|
69
69
|
if (platform() === "win32") return "Windows PC";
|
|
70
|
+
if (platform() === "linux" && process.env.WSL_DISTRO_NAME) return `WSL · ${process.env.WSL_DISTRO_NAME}`;
|
|
70
71
|
if (platform() === "linux") return "Linux computer";
|
|
71
72
|
return "Computer";
|
|
72
73
|
}
|
|
@@ -80,7 +81,7 @@ function help() {
|
|
|
80
81
|
process.stdout.write(" [--no-sync] [--name <name>]\n");
|
|
81
82
|
process.stdout.write(" usagemax sync [--full] [--json] Sync usage once, then exit\n");
|
|
82
83
|
process.stdout.write(" usagemax status Show local link status\n");
|
|
83
|
-
process.stdout.write(" usagemax doctor [--deep] Check
|
|
84
|
+
process.stdout.write(" usagemax doctor [--deep] Check source coverage; --deep parses full history\n");
|
|
84
85
|
process.stdout.write(" usagemax report [...args] Run a local ccusage report\n");
|
|
85
86
|
process.stdout.write(" usagemax unlink Remove the local collector key\n");
|
|
86
87
|
}
|
|
@@ -89,91 +90,21 @@ function ccusageCliPath() {
|
|
|
89
90
|
return join(dirname(require.resolve("ccusage/package.json")), "src", "cli.js");
|
|
90
91
|
}
|
|
91
92
|
|
|
92
|
-
function configuredPaths(variable, fallbacks) {
|
|
93
|
-
const configured = process.env[variable];
|
|
94
|
-
return configured
|
|
95
|
-
? configured.split(",").flatMap((group) => group.split(delimiter)).map((value) => value.trim()).filter(Boolean)
|
|
96
|
-
: fallbacks;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function usageRoots() {
|
|
100
|
-
const home = homedir();
|
|
101
|
-
return [
|
|
102
|
-
["claude", configuredPaths("CLAUDE_CONFIG_DIR", [join(home, ".config", "claude", "projects"), join(home, ".claude", "projects")])],
|
|
103
|
-
["codex", configuredPaths("CODEX_HOME", [join(home, ".codex", "sessions"), join(home, ".codex", "archived_sessions")])],
|
|
104
|
-
["opencode", configuredPaths("OPENCODE_DATA_DIR", [join(home, ".local", "share", "opencode")])],
|
|
105
|
-
["hermes", configuredPaths("HERMES_HOME", [join(home, ".hermes", "sessions")])],
|
|
106
|
-
["pi", configuredPaths("PI_AGENT_DIR", [join(home, ".pi", "agent", "sessions")])],
|
|
107
|
-
["copilot", configuredPaths("COPILOT_HOME", [join(home, ".copilot")])],
|
|
108
|
-
["gemini", configuredPaths("GEMINI_DATA_DIR", [join(home, ".gemini", "tmp")])],
|
|
109
|
-
].flatMap(([source, paths]) => paths.map((path) => ({ source, path })));
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
async function sourceInventory() {
|
|
113
|
-
const hash = createHash("sha256");
|
|
114
|
-
const sources = new Set();
|
|
115
|
-
const roots = usageRoots();
|
|
116
|
-
let files = 0;
|
|
117
|
-
let truncated = false;
|
|
118
|
-
for (const root of roots) {
|
|
119
|
-
try {
|
|
120
|
-
const rootStat = await stat(root.path);
|
|
121
|
-
if (!rootStat.isDirectory() && !rootStat.isFile()) continue;
|
|
122
|
-
sources.add(root.source);
|
|
123
|
-
} catch {
|
|
124
|
-
continue;
|
|
125
|
-
}
|
|
126
|
-
const stack = [root.path];
|
|
127
|
-
while (stack.length && files < MAX_FINGERPRINT_FILES) {
|
|
128
|
-
const current = stack.pop();
|
|
129
|
-
let entries;
|
|
130
|
-
try {
|
|
131
|
-
entries = await readdir(current, { withFileTypes: true });
|
|
132
|
-
} catch {
|
|
133
|
-
try {
|
|
134
|
-
const metadata = await stat(current);
|
|
135
|
-
if (metadata.isFile() && USAGE_EXTENSIONS.has(extname(current).toLowerCase())) {
|
|
136
|
-
hash.update(`${root.source}\u0000${current}\u0000${metadata.size}\u0000${metadata.mtimeMs}\n`);
|
|
137
|
-
files += 1;
|
|
138
|
-
}
|
|
139
|
-
} catch {}
|
|
140
|
-
continue;
|
|
141
|
-
}
|
|
142
|
-
for (const entry of entries) {
|
|
143
|
-
const path = join(current, entry.name);
|
|
144
|
-
if (entry.isDirectory()) {
|
|
145
|
-
if (!["node_modules", ".git", "cache", "tmp"].includes(entry.name)) stack.push(path);
|
|
146
|
-
continue;
|
|
147
|
-
}
|
|
148
|
-
if (!entry.isFile() || !USAGE_EXTENSIONS.has(extname(entry.name).toLowerCase())) continue;
|
|
149
|
-
try {
|
|
150
|
-
const metadata = await stat(path);
|
|
151
|
-
hash.update(`${root.source}\u0000${path}\u0000${metadata.size}\u0000${metadata.mtimeMs}\n`);
|
|
152
|
-
files += 1;
|
|
153
|
-
} catch {}
|
|
154
|
-
if (files >= MAX_FINGERPRINT_FILES) {
|
|
155
|
-
truncated = true;
|
|
156
|
-
break;
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
return { fingerprint: hash.digest("hex"), sources: [...sources].sort(), files, truncated };
|
|
162
|
-
}
|
|
163
|
-
|
|
164
93
|
async function ccusageJson(config, { full = false } = {}) {
|
|
165
|
-
const args = [ccusageCliPath(), "daily", "--json", "--offline", "--by-agent", "--order", "asc"];
|
|
94
|
+
const args = [ccusageCliPath(), "daily", "--json", "--offline", "--mode", "calculate", "--timezone", "UTC", "--by-agent", "--order", "asc"];
|
|
166
95
|
// Reconcile yesterday once after the UTC date changes. All other incremental
|
|
167
96
|
// scans parse only today; a metadata fingerprint avoids invoking ccusage when
|
|
168
97
|
// no supported local source changed at all.
|
|
169
|
-
if (
|
|
98
|
+
if (full) {
|
|
99
|
+
args.push("--since", "2024-01-01", "--until", new Date().toISOString().slice(0, 10));
|
|
100
|
+
} else if (config?.lastSyncAt) {
|
|
170
101
|
const today = new Date().toISOString().slice(0, 10);
|
|
171
102
|
args.push("--last", config.lastReconciledDay === today ? "1" : "2");
|
|
172
103
|
}
|
|
173
104
|
const { stdout } = await executeFile(process.execPath, args, {
|
|
174
105
|
encoding: "utf8",
|
|
175
106
|
maxBuffer: MAX_REPORT_BYTES,
|
|
176
|
-
env: { ...
|
|
107
|
+
env: { ...await ccusageEnvironment(), NO_COLOR: "1" },
|
|
177
108
|
});
|
|
178
109
|
return JSON.parse(stdout);
|
|
179
110
|
}
|
|
@@ -226,17 +157,27 @@ async function sync(args, suppliedConfig) {
|
|
|
226
157
|
const config = suppliedConfig || await readConfig();
|
|
227
158
|
if (!config) throw new Error("This computer is not linked. Open https://usagemax.com/account and create a link code.");
|
|
228
159
|
config.deviceId = await stableInstallationId(configDirectory(), config.deviceId);
|
|
229
|
-
const
|
|
160
|
+
const requestedFull = args.includes("--full");
|
|
230
161
|
const inventory = await sourceInventory();
|
|
231
162
|
const today = new Date().toISOString().slice(0, 10);
|
|
232
|
-
|
|
233
|
-
|
|
163
|
+
const knownSources = Array.isArray(config.knownSources) ? config.knownSources : [];
|
|
164
|
+
const foundNewSource = inventory.sources.some((source) => !knownSources.includes(source));
|
|
165
|
+
const lastFullSync = Date.parse(config.lastFullSyncAt || "");
|
|
166
|
+
const fullDue = config.sourceInventoryVersion !== SOURCE_INVENTORY_VERSION
|
|
167
|
+
|| !Number.isFinite(lastFullSync)
|
|
168
|
+
|| Date.now() - lastFullSync >= FULL_RECONCILE_INTERVAL_MS
|
|
169
|
+
|| foundNewSource;
|
|
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 };
|
|
234
173
|
if (args.includes("--json")) process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
235
174
|
else process.stdout.write("Already up to date. Local usage files have not changed; no logs were parsed or uploaded.\n");
|
|
236
175
|
return;
|
|
237
176
|
}
|
|
238
177
|
const report = await ccusageJson(config, { full });
|
|
239
|
-
const { plan, regressions } = buildDeltaPlan(report, config.snapshots, config.deviceId,
|
|
178
|
+
const { plan, regressions } = buildDeltaPlan(report, config.snapshots, config.deviceId, `ccusage@${CCUSAGE_VERSION}`);
|
|
179
|
+
config.lastSyncComplete = false;
|
|
180
|
+
await writeConfig(config);
|
|
240
181
|
let accepted = 0;
|
|
241
182
|
for (let offset = 0; offset < plan.length; offset += 100) {
|
|
242
183
|
const batch = plan.slice(offset, offset + 100);
|
|
@@ -257,20 +198,21 @@ async function sync(args, suppliedConfig) {
|
|
|
257
198
|
accepted += Number(body.accepted || 0);
|
|
258
199
|
for (const item of batch) config.snapshots[item.snapshotKey] = item.snapshot;
|
|
259
200
|
config.lastSyncAt = new Date().toISOString();
|
|
260
|
-
config.lastReconciledDay = today;
|
|
261
|
-
config.sourceFingerprint = inventory.fingerprint;
|
|
262
|
-
await writeConfig(config);
|
|
263
|
-
}
|
|
264
|
-
if (!plan.length) {
|
|
265
|
-
config.lastSyncAt = new Date().toISOString();
|
|
266
|
-
config.lastReconciledDay = today;
|
|
267
|
-
config.sourceFingerprint = inventory.fingerprint;
|
|
268
201
|
await writeConfig(config);
|
|
269
202
|
}
|
|
270
|
-
|
|
203
|
+
config.lastSyncAt = new Date().toISOString();
|
|
204
|
+
config.lastReconciledDay = today;
|
|
205
|
+
config.lastSyncComplete = true;
|
|
206
|
+
config.sourceInventoryVersion = SOURCE_INVENTORY_VERSION;
|
|
207
|
+
config.knownSources = [...new Set([...knownSources, ...inventory.sources, ...sourceSummary(report)])].sort();
|
|
208
|
+
if (inventory.complete) config.sourceFingerprint = inventory.fingerprint;
|
|
209
|
+
else delete config.sourceFingerprint;
|
|
210
|
+
if (full) config.lastFullSyncAt = config.lastSyncAt;
|
|
211
|
+
await writeConfig(config);
|
|
212
|
+
const result = { accepted, changedRows: plan.length, sources: sourceSummary(report), regressions: regressions.length, scanned: true, full };
|
|
271
213
|
if (args.includes("--json")) process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
272
214
|
else {
|
|
273
|
-
process.stdout.write(plan.length ? `Synced ${accepted} changed usage rows from ${result.sources.join(", ") || "local agents"}.\n` :
|
|
215
|
+
process.stdout.write(plan.length ? `Synced ${accepted} changed usage rows from ${result.sources.join(", ") || "local agents"}${full ? " (full history)" : ""}.\n` : `Already up to date. No usage rows were uploaded${full ? " after a full-history reconciliation" : ""}.\n`);
|
|
274
216
|
if (regressions.length) process.stdout.write(`${regressions.length} local row(s) moved backward; UsageMax kept the prior high-water mark to prevent double counting.\n`);
|
|
275
217
|
}
|
|
276
218
|
}
|
|
@@ -288,6 +230,7 @@ async function status() {
|
|
|
288
230
|
}
|
|
289
231
|
process.stdout.write(`Linked: ${config.deviceName || deviceLabel()}${config.profileHandle ? ` → @${config.profileHandle}` : ""}\n`);
|
|
290
232
|
process.stdout.write(`Last sync: ${config.lastSyncAt || "never"}\n`);
|
|
233
|
+
process.stdout.write(`Last full reconciliation: ${config.lastFullSyncAt || "never"}\n`);
|
|
291
234
|
process.stdout.write(`Profile: ${config.profileUrl || "https://usagemax.com/account"}\n`);
|
|
292
235
|
}
|
|
293
236
|
|
|
@@ -296,8 +239,13 @@ async function doctor(args = []) {
|
|
|
296
239
|
const inventory = await sourceInventory();
|
|
297
240
|
process.stdout.write(`Collector: ${config ? "linked" : "not linked"}\n`);
|
|
298
241
|
process.stdout.write(`Detected sources: ${inventory.sources.join(", ") || "none"} (${inventory.files}${inventory.truncated ? "+" : ""} data files)\n`);
|
|
242
|
+
process.stdout.write(`Supported sources: ${SUPPORTED_SOURCES.join(", ")} (+ named pi-format stores)\n`);
|
|
243
|
+
if (platform() === "linux" && process.env.WSL_DISTRO_NAME) {
|
|
244
|
+
process.stdout.write(`Environment: WSL ${process.env.WSL_DISTRO_NAME}; its Linux home is collected separately from Windows\n`);
|
|
245
|
+
}
|
|
246
|
+
if (!inventory.complete) process.stdout.write(`Inventory: incomplete (${inventory.errors} read error(s)${inventory.truncated ? ", file limit reached" : ""}); no-change shortcut disabled\n`);
|
|
299
247
|
if (args.includes("--deep")) {
|
|
300
|
-
const report = await ccusageJson(config, { full:
|
|
248
|
+
const report = await ccusageJson(config, { full: true });
|
|
301
249
|
process.stdout.write(`Parsed sources: ${sourceSummary(report).join(", ") || "none"}\n`);
|
|
302
250
|
}
|
|
303
251
|
process.stdout.write(`Mode: one-shot, metadata no-op check, ${args.includes("--deep") ? "deep local parse" : "no log parsing"}\n`);
|
|
@@ -305,7 +253,10 @@ async function doctor(args = []) {
|
|
|
305
253
|
|
|
306
254
|
async function report(args) {
|
|
307
255
|
const forwarded = args.length ? args : ["daily"];
|
|
308
|
-
const child = spawn(process.execPath, [ccusageCliPath(), ...forwarded], {
|
|
256
|
+
const child = spawn(process.execPath, [ccusageCliPath(), ...forwarded], {
|
|
257
|
+
stdio: "inherit",
|
|
258
|
+
env: await ccusageEnvironment(),
|
|
259
|
+
});
|
|
309
260
|
const code = await new Promise((resolve, reject) => {
|
|
310
261
|
child.once("error", reject);
|
|
311
262
|
child.once("exit", (status) => resolve(status ?? 1));
|
package/src/core.js
CHANGED
|
@@ -22,12 +22,24 @@ function text(value, fallback, maximum = 120) {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
function providerFor(model, source) {
|
|
25
|
-
const
|
|
25
|
+
const displayName = model.toLowerCase().replace(/^\[[^\]]+\]\s*/, "");
|
|
26
|
+
const name = displayName.replace(/^api[-_:]/, "");
|
|
27
|
+
if (name.startsWith("openrouter/")) return "openrouter";
|
|
28
|
+
if (name.startsWith("azure/") || name.startsWith("azure-openai/")) return "azure-openai";
|
|
29
|
+
if (name.startsWith("bedrock/") || name.startsWith("aws/")) return "aws-bedrock";
|
|
30
|
+
if (name.startsWith("vertex/") || name.startsWith("vertex_ai/") || name.startsWith("google/")) return "google";
|
|
26
31
|
if (name.includes("claude")) return "anthropic";
|
|
27
32
|
if (/^(?:gpt|o[1345]|codex)/.test(name) || name.includes("openai")) return "openai";
|
|
28
33
|
if (name.includes("gemini")) return "google";
|
|
29
34
|
if (name.includes("grok")) return "xai";
|
|
30
35
|
if (name.includes("deepseek")) return "deepseek";
|
|
36
|
+
if (name.includes("kimi") || name.includes("moonshot")) return "moonshot";
|
|
37
|
+
if (name.includes("qwen")) return "alibaba";
|
|
38
|
+
if (name.includes("glm") || name.includes("zai") || name.includes("z.ai")) return "zai";
|
|
39
|
+
if (name.includes("mistral") || name.includes("codestral")) return "mistral";
|
|
40
|
+
if (name.includes("llama") || name.includes("meta/")) return "meta";
|
|
41
|
+
if (name.includes("minimax")) return "minimax";
|
|
42
|
+
if (name.includes("command-r") || name.includes("cohere")) return "cohere";
|
|
31
43
|
return source;
|
|
32
44
|
}
|
|
33
45
|
|
|
@@ -74,6 +86,8 @@ function currentRows(report) {
|
|
|
74
86
|
cacheReadTokens: rawAgent.cacheReadTokens,
|
|
75
87
|
cost: rawAgent.totalCost,
|
|
76
88
|
}];
|
|
89
|
+
let allocatedTokens = 0;
|
|
90
|
+
let allocatedCostMicros = 0;
|
|
77
91
|
for (const rawBreakdown of breakdowns) {
|
|
78
92
|
if (!rawBreakdown || typeof rawBreakdown !== "object") continue;
|
|
79
93
|
const model = text(rawBreakdown.modelName, "unknown", 120);
|
|
@@ -82,6 +96,10 @@ function currentRows(report) {
|
|
|
82
96
|
const cacheWriteTokens = number(rawBreakdown.cacheCreationTokens);
|
|
83
97
|
const cacheReadTokens = number(rawBreakdown.cacheReadTokens);
|
|
84
98
|
const costMicros = moneyMicros(rawBreakdown.cost);
|
|
99
|
+
const classifiedTokens = inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens;
|
|
100
|
+
const totalTokens = Math.max(classifiedTokens, number(rawBreakdown.totalTokens));
|
|
101
|
+
allocatedTokens += totalTokens;
|
|
102
|
+
allocatedCostMicros += costMicros;
|
|
85
103
|
rows.push({
|
|
86
104
|
key: `${source}\u001f${period}\u001f${model}`,
|
|
87
105
|
source,
|
|
@@ -92,11 +110,29 @@ function currentRows(report) {
|
|
|
92
110
|
outputTokens,
|
|
93
111
|
cacheReadTokens,
|
|
94
112
|
cacheWriteTokens,
|
|
95
|
-
totalTokens
|
|
113
|
+
totalTokens,
|
|
96
114
|
costMicros,
|
|
97
115
|
},
|
|
98
116
|
});
|
|
99
117
|
}
|
|
118
|
+
const residualTokens = Math.max(0, number(rawAgent.totalTokens) - allocatedTokens);
|
|
119
|
+
const residualCostMicros = Math.max(0, moneyMicros(rawAgent.totalCost) - allocatedCostMicros);
|
|
120
|
+
if (residualTokens > 0 || residualCostMicros > 0) {
|
|
121
|
+
rows.push({
|
|
122
|
+
key: `${source}\u001f${period}\u001funattributed`,
|
|
123
|
+
source,
|
|
124
|
+
period,
|
|
125
|
+
model: "unattributed",
|
|
126
|
+
current: {
|
|
127
|
+
inputTokens: 0,
|
|
128
|
+
outputTokens: 0,
|
|
129
|
+
cacheReadTokens: 0,
|
|
130
|
+
cacheWriteTokens: 0,
|
|
131
|
+
totalTokens: residualTokens,
|
|
132
|
+
costMicros: residualCostMicros,
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
}
|
|
100
136
|
}
|
|
101
137
|
}
|
|
102
138
|
return rows;
|
package/src/sources.js
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile, readdir, stat } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
export const CCUSAGE_VERSION = "20.0.20";
|
|
7
|
+
export const SOURCE_INVENTORY_VERSION = 2;
|
|
8
|
+
export const SUPPORTED_SOURCES = [
|
|
9
|
+
"amp",
|
|
10
|
+
"claude",
|
|
11
|
+
"codebuff",
|
|
12
|
+
"codex",
|
|
13
|
+
"copilot",
|
|
14
|
+
"droid",
|
|
15
|
+
"gemini",
|
|
16
|
+
"goose",
|
|
17
|
+
"grok",
|
|
18
|
+
"hermes",
|
|
19
|
+
"kilo",
|
|
20
|
+
"kimi",
|
|
21
|
+
"openclaw",
|
|
22
|
+
"opencode",
|
|
23
|
+
"pi",
|
|
24
|
+
"qwen",
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const MAX_FINGERPRINT_FILES = 50_000;
|
|
28
|
+
|
|
29
|
+
function has(env, name) {
|
|
30
|
+
return Object.prototype.hasOwnProperty.call(env, name);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function commaList(value) {
|
|
34
|
+
return String(value ?? "").split(",").map((item) => item.trim()).filter(Boolean);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function envList(env, name, fallback, { blankUsesFallback = false } = {}) {
|
|
38
|
+
if (!has(env, name) || (blankUsesFallback && !String(env[name] ?? "").trim())) return fallback;
|
|
39
|
+
return commaList(env[name]);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function expandTilde(value, home, pathApi) {
|
|
43
|
+
if (value === "~") return home;
|
|
44
|
+
if (value.startsWith("~/") || value.startsWith("~\\")) return pathApi.join(home, value.slice(2));
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function ccusageHome(env = process.env, fallback = homedir()) {
|
|
49
|
+
for (const value of [env.HOME, env.USERPROFILE]) {
|
|
50
|
+
if (typeof value === "string" && value.trim()) return value;
|
|
51
|
+
}
|
|
52
|
+
const windowsHome = `${env.HOMEDRIVE ?? ""}${env.HOMEPATH ?? ""}`;
|
|
53
|
+
return windowsHome.trim() || fallback;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function tree(source, root, matcher) {
|
|
57
|
+
return { source, path: root, kind: "tree", matcher };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function file(source, filePath) {
|
|
61
|
+
return { source, path: filePath, kind: "file", matcher: "any" };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function db(source, filePath) {
|
|
65
|
+
return [file(source, filePath), file(source, `${filePath}-wal`), file(source, `${filePath}-shm`)];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function sourceDefinitions({ env = process.env, home = ccusageHome(env), cwd = process.cwd(), pathApi = path } = {}) {
|
|
69
|
+
const join = (...parts) => pathApi.join(...parts);
|
|
70
|
+
const definitions = [];
|
|
71
|
+
|
|
72
|
+
const claudeRoots = has(env, "CLAUDE_CONFIG_DIR")
|
|
73
|
+
? commaList(env.CLAUDE_CONFIG_DIR).map((item) => expandTilde(item, home, pathApi))
|
|
74
|
+
: [join(has(env, "XDG_CONFIG_HOME") ? String(env.XDG_CONFIG_HOME) : join(home, ".config"), "claude"), join(home, ".claude")];
|
|
75
|
+
for (const root of claudeRoots) {
|
|
76
|
+
const projects = pathApi.basename(root) === "projects" ? root : join(root, "projects");
|
|
77
|
+
definitions.push(tree("claude", projects, "jsonl"));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
for (const root of envList(env, "CODEX_HOME", [join(home, ".codex")])) {
|
|
81
|
+
definitions.push({ source: "codex", path: root, kind: "codex-home", matcher: "jsonl" });
|
|
82
|
+
}
|
|
83
|
+
for (const root of envList(env, "OPENCODE_DATA_DIR", [join(home, ".local", "share", "opencode")])) {
|
|
84
|
+
definitions.push(tree("opencode", root, "opencode"));
|
|
85
|
+
}
|
|
86
|
+
for (const root of envList(env, "AMP_DATA_DIR", [join(home, ".local", "share", "amp")])) {
|
|
87
|
+
definitions.push(tree("amp", join(root, "threads"), "json"));
|
|
88
|
+
}
|
|
89
|
+
for (const root of envList(env, "DROID_SESSIONS_DIR", [join(home, ".factory", "sessions")])) {
|
|
90
|
+
definitions.push(tree("droid", root, "droid"));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const codebuffRoots = has(env, "CODEBUFF_DATA_DIR")
|
|
94
|
+
? commaList(env.CODEBUFF_DATA_DIR)
|
|
95
|
+
: ["manicode", "manicode-dev", "manicode-staging"].map((channel) => join(home, ".config", channel));
|
|
96
|
+
for (const root of codebuffRoots) {
|
|
97
|
+
definitions.push(tree("codebuff", pathApi.basename(root) === "projects" ? root : join(root, "projects"), "codebuff"));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
for (const root of envList(env, "HERMES_HOME", [join(home, ".hermes")])) {
|
|
101
|
+
definitions.push(...db("hermes", join(root, "state.db")));
|
|
102
|
+
}
|
|
103
|
+
for (const root of envList(env, "PI_AGENT_DIR", [join(home, ".pi", "agent", "sessions")], { blankUsesFallback: true })) {
|
|
104
|
+
definitions.push(tree("pi", root, "jsonl"));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const gooseRoot = String(env.GOOSE_PATH_ROOT ?? "").trim();
|
|
108
|
+
const gooseDatabases = gooseRoot
|
|
109
|
+
? [join(gooseRoot, "data", "sessions", "sessions.db")]
|
|
110
|
+
: [
|
|
111
|
+
join(home, ".local", "share", "goose", "sessions", "sessions.db"),
|
|
112
|
+
join(home, "Library", "Application Support", "goose", "sessions", "sessions.db"),
|
|
113
|
+
join(home, ".local", "share", "Block", "goose", "sessions", "sessions.db"),
|
|
114
|
+
];
|
|
115
|
+
if (!gooseRoot && String(env.APPDATA ?? "").trim()) {
|
|
116
|
+
gooseDatabases.push(join(String(env.APPDATA).trim(), "Block", "goose", "data", "sessions", "sessions.db"));
|
|
117
|
+
}
|
|
118
|
+
for (const database of gooseDatabases) definitions.push(...db("goose", database));
|
|
119
|
+
|
|
120
|
+
const openClawRoots = envList(env, "OPENCLAW_DIR", [
|
|
121
|
+
join(home, ".openclaw"),
|
|
122
|
+
join(home, ".clawdbot"),
|
|
123
|
+
join(home, ".moltbot"),
|
|
124
|
+
join(home, ".moldbot"),
|
|
125
|
+
], { blankUsesFallback: true });
|
|
126
|
+
for (const root of openClawRoots) definitions.push(tree("openclaw", root, "openclaw"));
|
|
127
|
+
|
|
128
|
+
for (const root of envList(env, "KILO_DATA_DIR", [join(home, ".local", "share", "kilo")])) {
|
|
129
|
+
definitions.push(...db("kilo", join(root, "kilo.db")));
|
|
130
|
+
}
|
|
131
|
+
for (const root of envList(env, "KIMI_DATA_DIR", [join(home, ".kimi"), join(home, ".kimi-code")])) {
|
|
132
|
+
definitions.push(tree("kimi", join(root, "sessions"), "jsonl"), file("kimi", join(root, "config.json")));
|
|
133
|
+
}
|
|
134
|
+
for (const root of envList(env, "QWEN_DATA_DIR", [join(home, ".qwen")])) {
|
|
135
|
+
definitions.push(tree("qwen", join(root, "projects"), "jsonl"));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
definitions.push(tree("copilot", join(home, ".copilot", "otel"), "jsonl"));
|
|
139
|
+
if (String(env.COPILOT_OTEL_FILE_EXPORTER_PATH ?? "").trim()) {
|
|
140
|
+
definitions.push(file("copilot", String(env.COPILOT_OTEL_FILE_EXPORTER_PATH).trim()));
|
|
141
|
+
}
|
|
142
|
+
for (const root of envList(env, "GEMINI_DATA_DIR", [join(home, ".gemini", "tmp")])) {
|
|
143
|
+
definitions.push(tree("gemini", root, "gemini"));
|
|
144
|
+
}
|
|
145
|
+
const grokRoot = String(env.GROK_HOME ?? "").trim() || join(home, ".grok");
|
|
146
|
+
definitions.push(tree("grok", join(grokRoot, "sessions"), "grok"));
|
|
147
|
+
|
|
148
|
+
const configRoots = has(env, "CLAUDE_CONFIG_DIR")
|
|
149
|
+
? commaList(env.CLAUDE_CONFIG_DIR)
|
|
150
|
+
: [join(home, ".config", "claude"), join(home, ".claude")];
|
|
151
|
+
const configPaths = [join(cwd, ".ccusage", "ccusage.json"), ...configRoots.map((root) => join(root, "ccusage.json"))];
|
|
152
|
+
for (const configPath of configPaths) definitions.push(file("ccusage-config", configPath));
|
|
153
|
+
|
|
154
|
+
return definitions;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function ccusageEnvironment({ env = process.env, platform = process.platform, pathApi = path } = {}) {
|
|
158
|
+
const effective = { ...env };
|
|
159
|
+
if (platform !== "win32" || String(effective.GOOSE_PATH_ROOT ?? "").trim() || !String(effective.APPDATA ?? "").trim()) {
|
|
160
|
+
return effective;
|
|
161
|
+
}
|
|
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
|
+
return effective;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function accepts(matcher, filePath, pathApi) {
|
|
170
|
+
const name = pathApi.basename(filePath);
|
|
171
|
+
if (matcher === "any") return true;
|
|
172
|
+
if (matcher === "jsonl") return name.endsWith(".jsonl");
|
|
173
|
+
if (matcher === "json") return name.endsWith(".json");
|
|
174
|
+
if (matcher === "gemini") return name.endsWith(".json") || name.endsWith(".jsonl");
|
|
175
|
+
if (matcher === "codebuff") return name === "chat-messages.json";
|
|
176
|
+
if (matcher === "droid") return name.endsWith(".settings.json") || name.endsWith(".jsonl");
|
|
177
|
+
if (matcher === "openclaw") return name.includes(".jsonl");
|
|
178
|
+
if (matcher === "grok") return name === "updates.jsonl" || name === "summary.json";
|
|
179
|
+
if (matcher === "opencode") return name.endsWith(".db") || name.includes(".db-") || name.endsWith(".json");
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function existsDirectory(value) {
|
|
184
|
+
try {
|
|
185
|
+
return (await stat(value)).isDirectory();
|
|
186
|
+
} catch {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function expandCodexRoots(definitions, pathApi) {
|
|
192
|
+
const expanded = [];
|
|
193
|
+
for (const definition of definitions) {
|
|
194
|
+
if (definition.kind !== "codex-home") {
|
|
195
|
+
expanded.push(definition);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
const sessions = pathApi.join(definition.path, "sessions");
|
|
199
|
+
const archived = pathApi.join(definition.path, "archived_sessions");
|
|
200
|
+
const [hasSessions, hasArchived] = await Promise.all([existsDirectory(sessions), existsDirectory(archived)]);
|
|
201
|
+
if (hasSessions) expanded.push(tree("codex", sessions, "jsonl"));
|
|
202
|
+
if (hasArchived) expanded.push(tree("codex", archived, "jsonl"));
|
|
203
|
+
if (!hasSessions && !hasArchived) expanded.push(tree("codex", definition.path, "jsonl"));
|
|
204
|
+
}
|
|
205
|
+
return expanded;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function configCandidates(definitions) {
|
|
209
|
+
return definitions.filter((item) => item.source === "ccusage-config").map((item) => item.path);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function namedPiStores(definitions, home, pathApi) {
|
|
213
|
+
for (const configPath of configCandidates(definitions)) {
|
|
214
|
+
try {
|
|
215
|
+
const parsed = JSON.parse(await readFile(configPath, "utf8"));
|
|
216
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue;
|
|
217
|
+
const stores = Array.isArray(parsed.pi?.stores) ? parsed.pi.stores : [];
|
|
218
|
+
return stores.flatMap((store) => {
|
|
219
|
+
if (!store || typeof store.name !== "string" || typeof store.path !== "string" || !store.path.trim()) return [];
|
|
220
|
+
return [tree(store.name.trim().toLowerCase(), expandTilde(store.path.trim(), home, pathApi), "jsonl")];
|
|
221
|
+
});
|
|
222
|
+
} catch {}
|
|
223
|
+
}
|
|
224
|
+
return [];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function fileIdentity(source, filePath, metadata) {
|
|
228
|
+
return `${source}\u0000${filePath}\u0000${metadata.size}\u0000${metadata.mtimeMs}\u0000${metadata.ctimeMs}\n`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export async function sourceInventory({ env = process.env, cwd = process.cwd(), home = ccusageHome(env), pathApi = path, maxFiles = MAX_FINGERPRINT_FILES } = {}) {
|
|
232
|
+
const hash = createHash("sha256");
|
|
233
|
+
hash.update(`inventory:${SOURCE_INVENTORY_VERSION}\nccusage:${CCUSAGE_VERSION}\naliases:${env.CCUSAGE_MODEL_ALIASES ?? ""}\n`);
|
|
234
|
+
let definitions = sourceDefinitions({ env, cwd, home, pathApi });
|
|
235
|
+
definitions.push(...await namedPiStores(definitions, home, pathApi));
|
|
236
|
+
definitions = await expandCodexRoots(definitions, pathApi);
|
|
237
|
+
definitions.sort((left, right) => `${left.source}\u0000${left.path}`.localeCompare(`${right.source}\u0000${right.path}`));
|
|
238
|
+
|
|
239
|
+
const sources = new Set();
|
|
240
|
+
const seenRoots = new Set();
|
|
241
|
+
const seenFiles = new Set();
|
|
242
|
+
let files = 0;
|
|
243
|
+
let truncated = false;
|
|
244
|
+
let complete = true;
|
|
245
|
+
let errors = 0;
|
|
246
|
+
|
|
247
|
+
async function addFile(definition, filePath) {
|
|
248
|
+
const identity = `${definition.source}\u0000${filePath}`;
|
|
249
|
+
if (seenFiles.has(identity)) return;
|
|
250
|
+
if (files >= maxFiles) {
|
|
251
|
+
truncated = true;
|
|
252
|
+
complete = false;
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
try {
|
|
256
|
+
const metadata = await stat(filePath);
|
|
257
|
+
if (!metadata.isFile()) return;
|
|
258
|
+
seenFiles.add(identity);
|
|
259
|
+
hash.update(fileIdentity(definition.source, filePath, metadata));
|
|
260
|
+
if (definition.source === "ccusage-config") {
|
|
261
|
+
try {
|
|
262
|
+
hash.update(await readFile(filePath));
|
|
263
|
+
} catch {
|
|
264
|
+
complete = false;
|
|
265
|
+
errors += 1;
|
|
266
|
+
}
|
|
267
|
+
} else {
|
|
268
|
+
sources.add(definition.source);
|
|
269
|
+
}
|
|
270
|
+
files += 1;
|
|
271
|
+
} catch (error) {
|
|
272
|
+
if (error?.code !== "ENOENT") {
|
|
273
|
+
complete = false;
|
|
274
|
+
errors += 1;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
for (const definition of definitions) {
|
|
280
|
+
const rootIdentity = `${definition.source}\u0000${definition.kind}\u0000${definition.path}`;
|
|
281
|
+
if (seenRoots.has(rootIdentity)) continue;
|
|
282
|
+
seenRoots.add(rootIdentity);
|
|
283
|
+
hash.update(`root:${rootIdentity}\n`);
|
|
284
|
+
if (definition.kind === "file") {
|
|
285
|
+
await addFile(definition, definition.path);
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
let rootMetadata;
|
|
289
|
+
try {
|
|
290
|
+
rootMetadata = await stat(definition.path);
|
|
291
|
+
} catch (error) {
|
|
292
|
+
if (error?.code !== "ENOENT") {
|
|
293
|
+
complete = false;
|
|
294
|
+
errors += 1;
|
|
295
|
+
}
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (!rootMetadata.isDirectory()) continue;
|
|
299
|
+
const stack = [definition.path];
|
|
300
|
+
while (stack.length && !truncated) {
|
|
301
|
+
const current = stack.pop();
|
|
302
|
+
let entries;
|
|
303
|
+
try {
|
|
304
|
+
entries = await readdir(current, { withFileTypes: true });
|
|
305
|
+
} catch {
|
|
306
|
+
complete = false;
|
|
307
|
+
errors += 1;
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
311
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
312
|
+
const entry = entries[index];
|
|
313
|
+
const filePath = pathApi.join(current, entry.name);
|
|
314
|
+
if (entry.isDirectory()) stack.push(filePath);
|
|
315
|
+
else if (entry.isFile() && accepts(definition.matcher, filePath, pathApi)) await addFile(definition, filePath);
|
|
316
|
+
if (truncated) break;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
complete,
|
|
323
|
+
errors,
|
|
324
|
+
files,
|
|
325
|
+
fingerprint: hash.digest("hex"),
|
|
326
|
+
sources: [...sources].sort(),
|
|
327
|
+
supportedSources: SUPPORTED_SOURCES,
|
|
328
|
+
truncated,
|
|
329
|
+
version: SOURCE_INVENTORY_VERSION,
|
|
330
|
+
};
|
|
331
|
+
}
|