ccdeck 3.29.10 → 3.30.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 +1 -1
- package/bin/agent-dag.js +67 -118
- package/bin/cli/help.js +67 -0
- package/bin/cli/login-item.js +182 -0
- package/bin/cli/one-shot.js +254 -0
- package/bin/cli/package.js +39 -0
- package/bin/cli/pulse.js +98 -0
- package/bin/cli/restart.js +179 -0
- package/bin/cli/screen.js +166 -0
- package/bin/cli/second-start.js +120 -0
- package/bin/cli/startup.js +459 -0
- package/bin/cli/uninstall.js +169 -0
- package/bin/deck.js +112 -1559
- package/dist/web/assets/BrowserWatchModal-DP3YMtEi.js +1 -0
- package/dist/web/assets/UsageHistoryModal-CcdPVXAw.js +1 -0
- package/dist/web/assets/index-3vS6A0vt.css +1 -0
- package/dist/web/assets/index-BVTWHDta.js +298 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +15 -14
- package/package.json +1 -1
- package/release-notes.json +28 -0
- package/src/server/account-projects.mjs +25 -11
- package/src/server/account-routes.mjs +254 -0
- package/src/server/activity.mjs +3 -3
- package/src/server/append-budget.mjs +185 -0
- package/src/server/append-failures.mjs +147 -0
- package/src/server/args.mjs +37 -5
- package/src/server/atomic-write.mjs +300 -0
- package/src/server/block-notify.mjs +0 -5
- package/src/server/boot-lock.mjs +1 -1
- package/src/server/brand.mjs +7 -6
- package/src/server/browser-react.mjs +10 -2
- package/src/server/browser-watch-decks.mjs +146 -0
- package/src/server/browser-watch-feed.mjs +69 -0
- package/src/server/browser-watch-log.mjs +173 -0
- package/src/server/browser-watch-mtime.mjs +18 -0
- package/src/server/browser-watch-relay.mjs +112 -0
- package/src/server/browser-watch-routes.mjs +172 -0
- package/src/server/browser-watch-seen.mjs +127 -0
- package/src/server/browser-watch-store.mjs +14 -143
- package/src/server/browser-watch.mjs +278 -626
- package/src/server/canonical-path.mjs +150 -0
- package/src/server/ccusage-failure.mjs +64 -0
- package/src/server/ccusage-install.mjs +459 -0
- package/src/server/ccusage-runner.mjs +271 -0
- package/src/server/ccusage.mjs +21 -756
- package/src/server/claude-accounts.mjs +220 -464
- package/src/server/claude-collector.mjs +189 -0
- package/src/server/claude-dir.mjs +2 -2
- package/src/server/claude-identity.mjs +5 -4
- package/src/server/claude-reset-credits.mjs +130 -0
- package/src/server/claude-verdicts.mjs +174 -0
- package/src/server/clear-route.mjs +191 -0
- package/src/server/codex-auth.mjs +4 -4
- package/src/server/codex-dir.mjs +52 -8
- package/src/server/codex-enrichment.mjs +310 -0
- package/src/server/codex-quota.mjs +5 -3
- package/src/server/codex-translate.mjs +407 -0
- package/src/server/codex-usage.mjs +8 -5
- package/src/server/codex-watch.mjs +496 -0
- package/src/server/cswap-auto-loop.mjs +342 -0
- package/src/server/cswap-auto-readers.mjs +303 -0
- package/src/server/cswap-auto.mjs +51 -609
- package/src/server/cswap-install.mjs +69 -265
- package/src/server/cswap-layout.mjs +242 -0
- package/src/server/deck-home.mjs +1 -1
- package/src/server/deck-prefs.mjs +51 -184
- package/src/server/deck-scope.mjs +52 -0
- package/src/server/desktop-update-routes.mjs +75 -0
- package/src/server/detach.mjs +78 -0
- package/src/server/discovery.mjs +298 -0
- package/src/server/event-log.mjs +328 -0
- package/src/server/event-pipeline.mjs +235 -0
- package/src/server/event-ring.mjs +214 -0
- package/src/server/event-routes.mjs +352 -0
- package/src/server/event-sink.mjs +26 -0
- package/src/server/exec-children.mjs +128 -0
- package/src/server/exec-not-found.mjs +213 -0
- package/src/server/exec-output.mjs +23 -0
- package/src/server/exec-spec.mjs +341 -0
- package/src/server/exec.mjs +74 -661
- package/src/server/health-route.mjs +42 -0
- package/src/server/hook-challenge.mjs +37 -0
- package/src/server/http-io.mjs +104 -0
- package/src/server/index.mjs +172 -7620
- package/src/server/install-layout.mjs +506 -0
- package/src/server/installer.mjs +79 -608
- package/src/server/jsonl-chunks.mjs +139 -0
- package/src/server/lan-accounts.mjs +27 -0
- package/src/server/lan-beacon.mjs +457 -0
- package/src/server/lan-call.mjs +239 -0
- package/src/server/lan-copies.mjs +270 -0
- package/src/server/lan-deck.mjs +583 -0
- package/src/server/lan-dials.mjs +161 -0
- package/src/server/lan-engine.mjs +475 -1024
- package/src/server/lan-hearing.mjs +49 -0
- package/src/server/lan-inbound.mjs +100 -0
- package/src/server/lan-invite-offer.mjs +77 -0
- package/src/server/lan-invite.mjs +207 -0
- package/src/server/lan-lines.mjs +73 -0
- package/src/server/lan-manifest.mjs +74 -0
- package/src/server/lan-reach.mjs +82 -69
- package/src/server/lan-requests.mjs +249 -0
- package/src/server/lan-round-record.mjs +45 -0
- package/src/server/lan-round-timer.mjs +62 -0
- package/src/server/lan-routes.mjs +143 -0
- package/src/server/lan-serve.mjs +112 -0
- package/src/server/lan-socket.mjs +243 -925
- package/src/server/lan-sync.mjs +58 -1026
- package/src/server/lan-tailnet-poll.mjs +57 -0
- package/src/server/lan-turns.mjs +80 -0
- package/src/server/lan-wire.mjs +620 -0
- package/src/server/lifecycle.mjs +421 -0
- package/src/server/listen.mjs +110 -0
- package/src/server/live-decks.mjs +138 -0
- package/src/server/load-average.mjs +32 -0
- package/src/server/log-election.mjs +235 -0
- package/src/server/log-replay.mjs +374 -0
- package/src/server/log-writer.mjs +72 -557
- package/src/server/login-service.mjs +3 -2
- package/src/server/macmon.mjs +1 -1
- package/src/server/memory-files.mjs +163 -0
- package/src/server/memory-metrics.mjs +178 -0
- package/src/server/metrics-history.mjs +94 -0
- package/src/server/metrics-run.mjs +100 -0
- package/src/server/music-routes.mjs +134 -0
- package/src/server/network-metrics.mjs +5 -3
- package/src/server/network-sampler.mjs +256 -0
- package/src/server/npm-latest.mjs +385 -0
- package/src/server/npm-upgrade.mjs +352 -0
- package/src/server/npx.mjs +41 -7
- package/src/server/output-watch.mjs +5 -4
- package/src/server/pinned-build.mjs +108 -0
- package/src/server/prefs-path.mjs +14 -0
- package/src/server/prefs-refusal.mjs +77 -0
- package/src/server/prefs-routes.mjs +76 -0
- package/src/server/prefs-set-aside.mjs +86 -0
- package/src/server/prefs-state.mjs +51 -0
- package/src/server/process-command.mjs +104 -0
- package/src/server/process-list.mjs +547 -0
- package/src/server/pulse-line.mjs +132 -0
- package/src/server/purge-key.mjs +7 -7
- package/src/server/quota-cli.mjs +174 -0
- package/src/server/quota-oauth.mjs +190 -0
- package/src/server/quota-shape.mjs +229 -0
- package/src/server/quota-store-resets.mjs +206 -0
- package/src/server/quota.mjs +123 -509
- package/src/server/relay-guard.mjs +3 -3
- package/src/server/request-gates.mjs +441 -0
- package/src/server/restart-note.mjs +180 -0
- package/src/server/retire-sound-hook.mjs +3 -3
- package/src/server/ring-bounds.mjs +215 -0
- package/src/server/running-deck.mjs +1 -1
- package/src/server/running-version.mjs +24 -0
- package/src/server/self-update.mjs +39 -1371
- package/src/server/session-enrichment.mjs +622 -0
- package/src/server/session-read-gate.mjs +49 -0
- package/src/server/session-tracking.mjs +242 -0
- package/src/server/sse-clients.mjs +222 -0
- package/src/server/stale-discovery.mjs +30 -0
- package/src/server/static-serve.mjs +81 -0
- package/src/server/supervisor.mjs +73 -12
- package/src/server/system-metrics.mjs +169 -1935
- package/src/server/term.mjs +11 -249
- package/src/server/thermal-metrics.mjs +520 -0
- package/src/server/thermal-sampler.mjs +204 -0
- package/src/server/token-redact.mjs +123 -0
- package/src/server/transcript-gate.mjs +107 -0
- package/src/server/transcript-scan.mjs +563 -0
- package/src/server/usage-routes.mjs +93 -0
- package/src/server/uv-bootstrap.mjs +1 -1
- package/src/server/wordmark.mjs +127 -0
- package/dist/web/assets/BrowserWatchModal-CMn28FHb.js +0 -1
- package/dist/web/assets/UsageHistoryModal-C-sPhsKT.js +0 -1
- package/dist/web/assets/index-C1YFI9f4.js +0 -298
- package/dist/web/assets/index-Ck2RAla3.css +0 -1
package/src/server/ccusage.mjs
CHANGED
|
@@ -2,23 +2,16 @@
|
|
|
2
2
|
// ccusage reads the local ~/.claude (and other agent) logs and reports cost +
|
|
3
3
|
// token usage grouped by day.
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
// the
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
import {
|
|
15
|
-
import { spawn } from "node:child_process";
|
|
16
|
-
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
17
|
-
import path from "node:path";
|
|
18
|
-
import os from "node:os";
|
|
19
|
-
import { killTree, pathLookup, shimPath, spawnSpec } from "./exec.mjs";
|
|
20
|
-
import { oneLine, termColumns } from "./term.mjs";
|
|
21
|
-
import { PRODUCT } from "./brand.mjs";
|
|
5
|
+
// Which copy of ccusage runs, and running it, is ccusage-runner.mjs's; the copy
|
|
6
|
+
// is usually the deck's own managed install, which ccusage-install.mjs builds
|
|
7
|
+
// and keeps current; what is said when a run or an install fails is
|
|
8
|
+
// ccusage-failure.mjs's. What is left here is the range: what to ask, how many
|
|
9
|
+
// asks may be in flight, and what the answer means.
|
|
10
|
+
import { note, stamp, tagged } from "./ccusage-failure.mjs";
|
|
11
|
+
import {
|
|
12
|
+
backgroundUpdateDue, installsDisabled, maybeBackgroundUpdate, resolveEntry, startInstall,
|
|
13
|
+
} from "./ccusage-install.mjs";
|
|
14
|
+
import { overrideIsThere, runCcusage, userCcusage } from "./ccusage-runner.mjs";
|
|
22
15
|
|
|
23
16
|
// 60s, and it is the panel's poll interval rather than a number of its own: the
|
|
24
17
|
// Usage panel asks once a minute and expects a reading that has actually moved,
|
|
@@ -26,13 +19,6 @@ import { PRODUCT } from "./brand.mjs";
|
|
|
26
19
|
// lie. Two tabs polling out of phase still share one run, which is what the
|
|
27
20
|
// cache is for here. The modal is manual-open and unaffected either way.
|
|
28
21
|
const CACHE_MS = 60_000;
|
|
29
|
-
const TIMEOUT_MS = 90_000;
|
|
30
|
-
const INSTALL_TIMEOUT_MS = 120_000; // first-run npm install can be slow
|
|
31
|
-
const UPDATE_CHECK_MS = 24 * 3600_000; // check npm for a newer ccusage once/day
|
|
32
|
-
|
|
33
|
-
const CACHE_DIR = path.join(os.homedir(), ".agents-deck", "ccusage");
|
|
34
|
-
const PKG_DIR = path.join(CACHE_DIR, "node_modules", "ccusage");
|
|
35
|
-
const MARKER = path.join(CACHE_DIR, ".last-update-check");
|
|
36
22
|
|
|
37
23
|
const _cache = new Map(); // key `${since}|${until}` → { result, at }
|
|
38
24
|
|
|
@@ -105,727 +91,6 @@ function rememberRange(key, result, at) {
|
|
|
105
91
|
}
|
|
106
92
|
}
|
|
107
93
|
|
|
108
|
-
/**
|
|
109
|
-
* The name to hand cmd.exe for one of npm's Windows shims: its full path when
|
|
110
|
-
* one can be found, and the bare name only when none can.
|
|
111
|
-
*
|
|
112
|
-
* The full path is not a tidiness preference, it is the fix for #456. A shim
|
|
113
|
-
* launched by bare name computes `%~dp0` — which is where it looks for
|
|
114
|
-
* npm-prefix.js, npm-cli.js and npx-cli.js — from the deck's WORKING DIRECTORY
|
|
115
|
-
* rather than from its own, so on a deck started from `C:\Users\vceban` both
|
|
116
|
-
* shims died with `Cannot find module 'C:\Users\vceban\node_modules\npm\bin\…'`
|
|
117
|
-
* on a machine whose npm was perfectly healthy. shimPath in exec.mjs carries
|
|
118
|
-
* the whole account; what matters here is that BOTH the managed install and the
|
|
119
|
-
* npx fallback are launched this way, so both failed, and diagnosing either
|
|
120
|
-
* half alone could never have explained the other.
|
|
121
|
-
*
|
|
122
|
-
* Falling back to the bare name is deliberate: it is exactly what this did
|
|
123
|
-
* before, so a layout shimPath cannot see is no worse off than it was, and on
|
|
124
|
-
* such a machine cmd.exe's own PATH search still gets its turn.
|
|
125
|
-
*/
|
|
126
|
-
const winShim = (name, deps) => shimPath(name, deps) ?? name;
|
|
127
|
-
|
|
128
|
-
// npm is a .cmd shim on Windows, which spawn can only launch through cmd.exe.
|
|
129
|
-
// `shell: true` is the tempting way to get there and the wrong one: Node then
|
|
130
|
-
// joins file and args with single spaces and no quoting, so on a profile like
|
|
131
|
-
// C:\Users\John Smith the `--prefix <CACHE_DIR>` below arrived as
|
|
132
|
-
// `--prefix C:\Users\John` plus a bogus package spec `Smith\.agents-deck\...`,
|
|
133
|
-
// npm exited non-zero, and the managed install never materialised. spawnSpec
|
|
134
|
-
// routes the .cmd through cmd.exe with every argument quoted, and hands back
|
|
135
|
-
// the argument vector untouched everywhere else.
|
|
136
|
-
//
|
|
137
|
-
// POSIX is untouched by any of this: `npm` there is a real executable on PATH,
|
|
138
|
-
// not a batch file, so isBatch is false, viaCmd never runs, and the vector goes
|
|
139
|
-
// to spawn exactly as it always has.
|
|
140
|
-
function npmSpec(args, platform = process.platform, deps) {
|
|
141
|
-
return spawnSpec(platform === "win32" ? winShim("npm.cmd", deps) : "npm", args, platform);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* What `spawn` gets for `npm install ccusage@<spec>`.
|
|
146
|
-
* Exported for tests: the platform is a parameter so the Windows command line
|
|
147
|
-
* can be checked from any OS, and `deps` stands in for the Windows filesystem
|
|
148
|
-
* the shim lookup asks about.
|
|
149
|
-
*/
|
|
150
|
-
export const installSpec = (spec = "latest", platform = process.platform, deps) =>
|
|
151
|
-
npmSpec(["install", `ccusage@${spec}`, "--prefix", CACHE_DIR,
|
|
152
|
-
"--no-save", "--no-audit", "--no-fund", "--loglevel", "error"], platform, deps);
|
|
153
|
-
|
|
154
|
-
// npx is the same kind of shim as npm, and the fallback run needs the same
|
|
155
|
-
// treatment for a second, sharper reason: its argument vector carries a
|
|
156
|
-
// user-supplied `--since`. `shell: true` handed `[file, ...args].join(" ")` to
|
|
157
|
-
// /bin/sh -c, so `GET /api/ccusage?since=1;id;` ran `id` — and because that
|
|
158
|
-
// route is a GET, any page open in the user's browser could aim it at the
|
|
159
|
-
// loopback port with no CORS, no preflight and no need to read the answer.
|
|
160
|
-
// spawnSpec quotes each argument into the cmd.exe line on Windows and spawns
|
|
161
|
-
// the vector untouched everywhere else, so nothing in it is ever parsed as
|
|
162
|
-
// syntax. Naming the file `npx.cmd` on Windows is what makes that work: only a
|
|
163
|
-
// .cmd/.bat file routes through cmd.exe, and a bare `npx` there is not a file
|
|
164
|
-
// spawn can launch at all (PATHEXT is a shell's job), so asking for the
|
|
165
|
-
// extensionless name would trade a shell injection for an ENOENT. What it must
|
|
166
|
-
// NOT be is the bare `npx.cmd` this said until #456 — see winShim.
|
|
167
|
-
function npxSpec(args, platform = process.platform, deps) {
|
|
168
|
-
return spawnSpec(platform === "win32" ? winShim("npx.cmd", deps) : "npx", args, platform);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* What `spawn` gets for the portable `npx -y ccusage@latest <args>` fallback.
|
|
173
|
-
* Exported for tests: the platform is a parameter so the Windows command line
|
|
174
|
-
* can be checked from any OS, and `deps` stands in for the Windows filesystem
|
|
175
|
-
* the shim lookup asks about.
|
|
176
|
-
*/
|
|
177
|
-
export const fallbackSpec = (args = [], platform = process.platform, deps) =>
|
|
178
|
-
npxSpec(["-y", "ccusage@latest", ...args], platform, deps);
|
|
179
|
-
|
|
180
|
-
let _installing = null; // Promise guard so concurrent calls share one install
|
|
181
|
-
let _checkedThisRun = false; // only kick the daily check once per process boot
|
|
182
|
-
|
|
183
|
-
// The last `npm install` that failed, in one line of its own words.
|
|
184
|
-
//
|
|
185
|
-
// Module scope rather than a value thrown out of the install, because the two
|
|
186
|
-
// callers that matter never see that throw. primeCcusage runs the install at
|
|
187
|
-
// boot and only logs the rejection; a second modal open that arrives while an
|
|
188
|
-
// install is in flight awaits the SHARED promise, whose rejection has already
|
|
189
|
-
// been handled by the first. Both then land on the npx fallback with nothing to
|
|
190
|
-
// say about why they were there — which is precisely how three rounds of
|
|
191
|
-
// debugging went to the fallback's stderr while the install's own account
|
|
192
|
-
// stayed on a terminal row the deck had already painted over.
|
|
193
|
-
//
|
|
194
|
-
// One line, not the whole dump: this is written into a sentence in a 46ch box.
|
|
195
|
-
// The full text still goes to the terminal through note() below.
|
|
196
|
-
let _lastInstallError = null;
|
|
197
|
-
const INSTALL_ERROR_ROOM = 240;
|
|
198
|
-
|
|
199
|
-
/**
|
|
200
|
-
* Start the one shared install, remembering how it ended.
|
|
201
|
-
*
|
|
202
|
-
* Deduped through `_installing` the way it always was, so concurrent callers
|
|
203
|
-
* cost one `npm install` between them, and the outcome survives the promise.
|
|
204
|
-
*
|
|
205
|
-
* This used to read `_installing = (async () => { installSync(spec); })()`, and
|
|
206
|
-
* that wrapper was the whole of #476: an async function body runs synchronously
|
|
207
|
-
* up to its first `await`, and there was none, so the IIFE turned a throw into
|
|
208
|
-
* a rejection and bought no asynchrony at all. `installSync` was a `spawnSync`
|
|
209
|
-
* of `npm install` with a two-minute deadline, and it ran on the caller's
|
|
210
|
-
* stack — which at boot is bin/deck.js's, beside jobs that really are async.
|
|
211
|
-
* primeCcusage answered `{ state: "installing" }`, a sentence that says the
|
|
212
|
-
* wait was deferred, while the process could not accept an HTTP connection,
|
|
213
|
-
* write an SSE frame, ingest a hook or repaint the pulse line until npm exited.
|
|
214
|
-
* On a first run, with nothing cached, that is the one boot a new user judges
|
|
215
|
-
* the tool by.
|
|
216
|
-
*
|
|
217
|
-
* `install` below is the fix, and there is now exactly one install mechanism in
|
|
218
|
-
* this module rather than a synchronous one and a background one.
|
|
219
|
-
*/
|
|
220
|
-
function startInstall(spec = "latest") {
|
|
221
|
-
if (!_installing) {
|
|
222
|
-
_installing = install(spec)
|
|
223
|
-
.then(() => { _lastInstallError = null; })
|
|
224
|
-
.catch(e => {
|
|
225
|
-
_lastInstallError = oneLine(e?.message ?? e, INSTALL_ERROR_ROOM);
|
|
226
|
-
note("install failed", e);
|
|
227
|
-
})
|
|
228
|
-
.finally(() => { _installing = null; });
|
|
229
|
-
}
|
|
230
|
-
return _installing;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
// AGENTS_DECK_NO_INSTALL=1 is documented as "never install or update
|
|
234
|
-
// claude-swap / ccusage, and never ask npm about releases", so it has to hold
|
|
235
|
-
// on the lazy path too — opening the usage-history modal must not be a way to
|
|
236
|
-
// pull ccusage off the registry behind the user's back. Read per call rather
|
|
237
|
-
// than once at import, because the module is imported lazily and a test (or an
|
|
238
|
-
// embedder) may set it after load.
|
|
239
|
-
function installsDisabled() {
|
|
240
|
-
return process.env.AGENTS_DECK_NO_INSTALL === "1";
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
// A run can end four ways that mean four different things to whoever opened the
|
|
244
|
-
// modal — installs are forbidden, the deadline expired, the output was not usage
|
|
245
|
-
// data, the CLI exited on its own — and all four used to arrive as one `error`
|
|
246
|
-
// string, leaving the modal nothing to say but whatever `err.message` happened
|
|
247
|
-
// to be. The code rides on the error and comes back out as `reason`; `error`
|
|
248
|
-
// keeps the raw text, which the modal shows only on hover.
|
|
249
|
-
function tagged(reason, message) {
|
|
250
|
-
return Object.assign(new Error(message), { reason });
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
// Everything this module says out loud goes through here, and it says one line.
|
|
254
|
-
//
|
|
255
|
-
// Both failures below carry a subprocess's entire stderr as their `message`,
|
|
256
|
-
// and on a machine whose npm shim is broken that is a fifteen-line Node stack
|
|
257
|
-
// trace. Handed to console.error whole, it landed across the deck's own status
|
|
258
|
-
// rows and its `\r`-repainted pulse line — and because primeCcusage runs at
|
|
259
|
-
// boot, it was the first thing such a machine ever showed (#432). The evidence
|
|
260
|
-
// is not lost by shortening this: a failed RUN carries its full text back to
|
|
261
|
-
// the browser in `error`, which the usage-history modal keeps on the status
|
|
262
|
-
// line's title, one hover away — the same division of labour admin-failure.ts
|
|
263
|
-
// states for claude-swap's output. What this line is for is the operator
|
|
264
|
-
// watching the terminal, who needs to know which of the two things failed and
|
|
265
|
-
// why, not to read a stack.
|
|
266
|
-
//
|
|
267
|
-
// The width is read per call: a terminal can be resized while the deck runs,
|
|
268
|
-
// and this can fire hours in.
|
|
269
|
-
function note(what, err) {
|
|
270
|
-
const head = `${PRODUCT} ccusage: ${what}: `;
|
|
271
|
-
console.error(head + oneLine(err?.message ?? err, termColumns(process.stderr) - head.length));
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
/**
|
|
275
|
-
* Node saying it could not load a file it was pointed at.
|
|
276
|
-
*
|
|
277
|
-
* Both spellings are here because both happen: CommonJS throws
|
|
278
|
-
* `MODULE_NOT_FOUND`, ESM throws `ERR_MODULE_NOT_FOUND` with the wording
|
|
279
|
-
* "Cannot find package" for a bare specifier, and ccusage's entry point is ESM
|
|
280
|
-
* while the npm/npx shims it may be launched through are not.
|
|
281
|
-
*
|
|
282
|
-
* Exported for tests. The one caller is the managed-install branch of
|
|
283
|
-
* runCcusage, where the child is `node <our entry>` and nothing else — so a
|
|
284
|
-
* module Node cannot resolve is by construction a file of OURS that is missing,
|
|
285
|
-
* never the user's npm.
|
|
286
|
-
*/
|
|
287
|
-
export const cannotLoadModule = (text) =>
|
|
288
|
-
/\b(?:ERR_)?MODULE_NOT_FOUND\b|cannot find (?:module|package)/i.test(String(text ?? ""));
|
|
289
|
-
|
|
290
|
-
// ── managed install ─────────────────────────────────────────────────────────
|
|
291
|
-
|
|
292
|
-
// Absolute path to ccusage's CLI entry inside our managed install, or null if
|
|
293
|
-
// not installed. Reads the package's `bin` field (currently "./src/cli.js").
|
|
294
|
-
//
|
|
295
|
-
// `bin` is a string out of a package.json downloaded from the registry, and it
|
|
296
|
-
// is joined onto PKG_DIR and then handed to `spawn(node, [entry])` — so a `bin`
|
|
297
|
-
// of "../../../evil.js" names a file outside the managed install and gets run.
|
|
298
|
-
// existsSync alone does not notice: it is asked whether the escaped path is
|
|
299
|
-
// there, and for an attacker who put something there the answer is yes.
|
|
300
|
-
//
|
|
301
|
-
// The narrowness is worth stating rather than leaving implied: a package that
|
|
302
|
-
// can choose its own `bin` can also ship an install script, so this is not the
|
|
303
|
-
// weak link in a hostile-package scenario. What it does close is the case where
|
|
304
|
-
// only the file is influenced — a tampered or half-written package.json in the
|
|
305
|
-
// cache dir, or a `bin` that walks out of the install by accident — and it
|
|
306
|
-
// costs one comparison.
|
|
307
|
-
//
|
|
308
|
-
// Exported for tests: the containment rule is the whole point of the function
|
|
309
|
-
// and there is no other way to reach it without an install on disk.
|
|
310
|
-
export function resolveEntry() {
|
|
311
|
-
try {
|
|
312
|
-
const pkg = JSON.parse(readFileSync(path.join(PKG_DIR, "package.json"), "utf8"));
|
|
313
|
-
let rel = pkg.bin;
|
|
314
|
-
if (rel && typeof rel === "object") rel = rel.ccusage ?? Object.values(rel)[0];
|
|
315
|
-
if (typeof rel !== "string") return null;
|
|
316
|
-
const entry = path.resolve(PKG_DIR, rel);
|
|
317
|
-
// path.resolve, not path.join, so an ABSOLUTE `bin` is measured as the
|
|
318
|
-
// absolute path it is rather than being silently re-rooted under PKG_DIR
|
|
319
|
-
// and passing on a technicality. The trailing separator is what stops a
|
|
320
|
-
// sibling directory whose name merely starts with PKG_DIR's — and it also
|
|
321
|
-
// rejects PKG_DIR itself, which is a directory and no kind of entry point.
|
|
322
|
-
if (!entry.startsWith(path.resolve(PKG_DIR) + path.sep)) return null;
|
|
323
|
-
return existsSync(entry) ? { entry, version: pkg.version } : null;
|
|
324
|
-
} catch {
|
|
325
|
-
return null;
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
/**
|
|
330
|
-
* Everything a failed install is known to have said, in the order the answer is
|
|
331
|
-
* usually in.
|
|
332
|
-
*
|
|
333
|
-
* The old line read `(r.stderr || "").trim() || r.status`, which threw away the
|
|
334
|
-
* two things most likely to be the whole story. `error` is where a failure to
|
|
335
|
-
* LAUNCH lands — ENOENT for a comspec that is not there, EINVAL for a .cmd Node
|
|
336
|
-
* refuses to spawn directly, and the expired deadline — and it comes with no
|
|
337
|
-
* status at all, so what reached the terminal in every one of those cases was
|
|
338
|
-
* the word "null". And npm has never confined itself to stderr: a shim that
|
|
339
|
-
* dies before npm starts writes wherever Node chose, and `npm ERR!` blocks have
|
|
340
|
-
* landed on stdout across majors.
|
|
341
|
-
*
|
|
342
|
-
* The four fields are spawnSync's, because that is where they were first read
|
|
343
|
-
* off; `install` above now fills the same shape from the 'error' event, the
|
|
344
|
-
* exit status and the two collected streams.
|
|
345
|
-
*/
|
|
346
|
-
function installFailureText(r) {
|
|
347
|
-
const parts = [];
|
|
348
|
-
if (r?.error?.message) parts.push(String(r.error.message));
|
|
349
|
-
const stderr = String(r?.stderr ?? "").trim();
|
|
350
|
-
const stdout = String(r?.stdout ?? "").trim();
|
|
351
|
-
if (stderr) parts.push(stderr);
|
|
352
|
-
if (stdout) parts.push(stdout);
|
|
353
|
-
if (!parts.length) parts.push(r?.status === null || r?.status === undefined
|
|
354
|
-
? "npm exited without a status and said nothing"
|
|
355
|
-
: `npm exited ${r.status} and said nothing`);
|
|
356
|
-
return parts.join(" — ");
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
/**
|
|
360
|
-
* Which level of the managed install `npm install --prefix` actually produced.
|
|
361
|
-
*
|
|
362
|
-
* This is the honest answer to the question nobody could answer from a
|
|
363
|
-
* screenshot: an install that exits 0 and leaves a tree resolveEntry cannot use
|
|
364
|
-
* is reported as SUCCESS by an exit code alone, and #432 found that exact shape
|
|
365
|
-
* once already. Naming the first level that is not there turns "ccusage could
|
|
366
|
-
* not report usage" into a sentence about this machine's disk — whether npm
|
|
367
|
-
* wrote nothing, wrote a node_modules with no ccusage in it, or wrote a package
|
|
368
|
-
* whose entry point this deck refuses.
|
|
369
|
-
*/
|
|
370
|
-
function installTreeReport() {
|
|
371
|
-
const levels = [
|
|
372
|
-
[CACHE_DIR, "the prefix directory"],
|
|
373
|
-
[path.join(CACHE_DIR, "node_modules"), "node_modules under it"],
|
|
374
|
-
[PKG_DIR, "node_modules/ccusage"],
|
|
375
|
-
[path.join(PKG_DIR, "package.json"), "node_modules/ccusage/package.json"],
|
|
376
|
-
];
|
|
377
|
-
for (const [where, name] of levels) {
|
|
378
|
-
if (!existsSync(where)) return `${name} is not there`;
|
|
379
|
-
}
|
|
380
|
-
// Every level exists, so resolveEntry refused for one of its own reasons: an
|
|
381
|
-
// unreadable or bin-less package.json, or a `bin` pointing outside the
|
|
382
|
-
// package. All three are about the package rather than about npm.
|
|
383
|
-
return "the package is there but its bin entry could not be read or does not point inside it";
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
/**
|
|
387
|
-
* Run `npm install ccusage@<spec> --prefix CACHE_DIR`, off this stack.
|
|
388
|
-
*
|
|
389
|
-
* The ONE install in this module, awaited by startInstall and dropped on the
|
|
390
|
-
* floor by the daily update path below. It was two — a `spawnSync` for the cold
|
|
391
|
-
* path and a fire-and-forget `spawn` for the background one — and the sync half
|
|
392
|
-
* is what #476 removes: nothing here needs an answer before the next tick, and
|
|
393
|
-
* a two-minute `spawnSync` at boot is two minutes of a dead process.
|
|
394
|
-
*
|
|
395
|
-
* Everything the sync path was careful about is carried over unchanged, because
|
|
396
|
-
* every one of those cares was bought with a bug report:
|
|
397
|
-
*
|
|
398
|
-
* - the vector is `installSpec`'s, spread into spawn exactly as spawnSync got
|
|
399
|
-
* it, which is what keeps #456's absolute `npm.cmd` and #362's per-argument
|
|
400
|
-
* quoting on Windows. `opts` is spread LAST for the same reason it was
|
|
401
|
-
* before: it carries windowsVerbatimArguments, and nothing above it may win.
|
|
402
|
-
* - `windowsHide`, so no console window flashes up.
|
|
403
|
-
* - INSTALL_TIMEOUT_MS, as a deadline this module enforces itself rather than
|
|
404
|
-
* spawn's own `timeout` option. That option sends one signal to the process
|
|
405
|
-
* it started, and on Windows a `.cmd` runs THROUGH cmd.exe — so the signal
|
|
406
|
-
* would land on the wrapper and leave npm downloading, which is the same
|
|
407
|
-
* distinction exec.mjs's `run` states and the reason killTree exists. Same
|
|
408
|
-
* shape as runOnce below, so this file has one deadline pattern rather than
|
|
409
|
-
* two.
|
|
410
|
-
*
|
|
411
|
-
* Diagnosis is unchanged too: `installFailureText` is handed the same four
|
|
412
|
-
* fields spawnSync used to hand it — a failure to LAUNCH in `error`, the exit
|
|
413
|
-
* status, and both output streams, because npm has never confined itself to
|
|
414
|
-
* stderr.
|
|
415
|
-
*/
|
|
416
|
-
function install(spec = "latest") {
|
|
417
|
-
return new Promise((resolve, reject) => {
|
|
418
|
-
mkdirSync(CACHE_DIR, { recursive: true });
|
|
419
|
-
const { file, args, opts } = installSpec(spec);
|
|
420
|
-
const child = spawn(file, args, { windowsHide: true, ...opts });
|
|
421
|
-
let out = "", err = "", settled = false;
|
|
422
|
-
let timer = null;
|
|
423
|
-
const finish = (fn, value) => {
|
|
424
|
-
if (settled) return;
|
|
425
|
-
settled = true;
|
|
426
|
-
clearTimeout(timer);
|
|
427
|
-
fn(value);
|
|
428
|
-
};
|
|
429
|
-
// A child that dies emits 'error' AND THEN 'close', and a deadline that
|
|
430
|
-
// fires kills the child and so provokes both — hence `settled`. Whichever
|
|
431
|
-
// arrives first is the account that travels.
|
|
432
|
-
const failed = (r) => finish(reject, new Error(`npm install ccusage failed: ${installFailureText(r)}`));
|
|
433
|
-
timer = setTimeout(() => {
|
|
434
|
-
// The verdict is stated before the kill, the order exec.mjs's `run` uses:
|
|
435
|
-
// the answer must not depend on the killed child cooperating.
|
|
436
|
-
failed({
|
|
437
|
-
error: { message: `npm install timed out after ${INSTALL_TIMEOUT_MS}ms` },
|
|
438
|
-
stdout: out,
|
|
439
|
-
stderr: err,
|
|
440
|
-
});
|
|
441
|
-
killTree(child);
|
|
442
|
-
}, INSTALL_TIMEOUT_MS);
|
|
443
|
-
timer.unref?.();
|
|
444
|
-
child.stdout?.on("data", d => { out += d; });
|
|
445
|
-
child.stderr?.on("data", d => { err += d; });
|
|
446
|
-
child.on("error", e => failed({ error: e, stdout: out, stderr: err }));
|
|
447
|
-
child.on("close", status => {
|
|
448
|
-
if (status !== 0) return failed({ status, stdout: out, stderr: err });
|
|
449
|
-
// A zero exit is npm's opinion, not a fact about the disk, and the caller
|
|
450
|
-
// treats "the install resolved" as "there is something to run". Checking
|
|
451
|
-
// here is what stops a silent success: getRunner used to call
|
|
452
|
-
// resolveEntry(), get null, and fall through to the npx fallback with
|
|
453
|
-
// NOTHING recorded anywhere about why — so the modal explained the
|
|
454
|
-
// fallback's stderr and the install's half of the story was never written
|
|
455
|
-
// down at all.
|
|
456
|
-
if (!resolveEntry()) {
|
|
457
|
-
return finish(reject, new Error(
|
|
458
|
-
`npm install ccusage exited 0 but left nothing runnable under ${CACHE_DIR}: `
|
|
459
|
-
+ `${installTreeReport()}`,
|
|
460
|
-
));
|
|
461
|
-
}
|
|
462
|
-
finish(resolve, undefined);
|
|
463
|
-
});
|
|
464
|
-
});
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
// The daily update path's install: the same one above, with nobody listening.
|
|
468
|
-
//
|
|
469
|
-
// It has no shared promise and no `_lastInstallError` on purpose. This runs
|
|
470
|
-
// behind a ccusage that already works, so a failed upgrade is not news the
|
|
471
|
-
// modal should lead with — the copy on disk still answers, and the check comes
|
|
472
|
-
// round again tomorrow.
|
|
473
|
-
function installInBackground(spec = "latest") {
|
|
474
|
-
install(spec).catch(() => { /* best-effort */ });
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
// True at most once per UPDATE_CHECK_MS, gated by the marker file's mtime so the
|
|
478
|
-
// throttle survives restarts.
|
|
479
|
-
function updateCheckDue() {
|
|
480
|
-
try {
|
|
481
|
-
return Date.now() - statSync(MARKER).mtimeMs > UPDATE_CHECK_MS;
|
|
482
|
-
} catch {
|
|
483
|
-
return true; // no marker yet → due
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
// (Re)write the marker so its mtime marks "now" as the last check time.
|
|
487
|
-
function touchMarker() {
|
|
488
|
-
try {
|
|
489
|
-
mkdirSync(CACHE_DIR, { recursive: true });
|
|
490
|
-
writeFileSync(MARKER, String(Date.now()));
|
|
491
|
-
} catch { /* ignore */ }
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
// Non-blocking: compare installed version to npm `latest`; install if newer.
|
|
495
|
-
function maybeBackgroundUpdate(installedVersion) {
|
|
496
|
-
if (installsDisabled()) return; // no `npm view`, no upgrade install
|
|
497
|
-
if (_checkedThisRun || !updateCheckDue()) return;
|
|
498
|
-
_checkedThisRun = true;
|
|
499
|
-
touchMarker();
|
|
500
|
-
try {
|
|
501
|
-
const { file, args, opts } = npmSpec(["view", "ccusage", "version"]);
|
|
502
|
-
const child = spawn(file, args, { windowsHide: true, ...opts });
|
|
503
|
-
let out = "";
|
|
504
|
-
child.stdout.on("data", d => { out += d; });
|
|
505
|
-
child.on("error", () => {});
|
|
506
|
-
child.on("close", () => {
|
|
507
|
-
const latest = out.trim();
|
|
508
|
-
if (latest && latest !== installedVersion) installInBackground(latest);
|
|
509
|
-
});
|
|
510
|
-
} catch { /* ignore */ }
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
// ── the user's own copy ─────────────────────────────────────────────────────
|
|
514
|
-
|
|
515
|
-
/**
|
|
516
|
-
* A ccusage the USER put somewhere, either by naming it outright or by having
|
|
517
|
-
* it on PATH — or null when there is no such thing.
|
|
518
|
-
*
|
|
519
|
-
* This is #433, and the case for it is not that a PATH search is nice to have.
|
|
520
|
-
* The deck has been telling people "put ccusage on PATH yourself" in
|
|
521
|
-
* admin-failure.ts for as long as that sentence has existed, and nothing
|
|
522
|
-
* anywhere ever looked: a user who did exactly what they were told, and could
|
|
523
|
-
* prove it with `ccusage --version` in their own shell, got the identical
|
|
524
|
-
* failure on the next click. Two ways out of that, and the other one is to
|
|
525
|
-
* delete the promise. It is kept because there is a configuration that needs it
|
|
526
|
-
* and has no other:
|
|
527
|
-
*
|
|
528
|
-
* - `AGENTS_DECK_NO_INSTALL=1` is documented as "never install or update
|
|
529
|
-
* claude-swap / ccusage". Someone who sets it AND installs ccusage
|
|
530
|
-
* themselves has done the only thing that flag can sensibly mean, and until
|
|
531
|
-
* now the deck answered "ccusage is not installed" while it was installed
|
|
532
|
-
* and on their PATH. There was NO combination of settings that made a
|
|
533
|
-
* self-managed ccusage work.
|
|
534
|
-
* - The #432 machine — npm and npx both unusable on disk — cannot create the
|
|
535
|
-
* managed install and cannot run the npx fallback. A copy the user already
|
|
536
|
-
* has is the only route left, and it was the one route the deck refused to
|
|
537
|
-
* look down.
|
|
538
|
-
*
|
|
539
|
-
* The override wins over PATH the way AGENTS_DECK_CSWAP does over cswap's own
|
|
540
|
-
* search, and is never remembered, because somebody debugging a bad resolution
|
|
541
|
-
* needs a change to it to take effect on the next click rather than the next
|
|
542
|
-
* restart. Nothing here is cached for the same reason, and it is affordable:
|
|
543
|
-
* a lookup is a handful of stats, once per uncached fetch, against a process
|
|
544
|
-
* spawn that follows it.
|
|
545
|
-
*
|
|
546
|
-
* `platform` and `deps` are parameters, and this is exported, for the reason
|
|
547
|
-
* every other Windows answer in this module is: the PATHEXT walk and the
|
|
548
|
-
* `.cmd` spelling have to be checkable from a machine that cannot run Windows.
|
|
549
|
-
*/
|
|
550
|
-
export function userCcusage(platform = process.platform, deps, env = process.env) {
|
|
551
|
-
const named = env.AGENTS_DECK_CCUSAGE;
|
|
552
|
-
if (named) {
|
|
553
|
-
// A path the user typed is used as typed — pathLookup would refuse it
|
|
554
|
-
// anyway, since re-rooting a name that carries a directory is a way to run
|
|
555
|
-
// something other than what was asked for. Whether it EXISTS is the
|
|
556
|
-
// caller's question, because "the path you gave me is not there" is a
|
|
557
|
-
// different sentence from "you have no ccusage".
|
|
558
|
-
return { file: String(named), named: true };
|
|
559
|
-
}
|
|
560
|
-
const found = pathLookup("ccusage", platform, deps);
|
|
561
|
-
return found ? { file: found, named: false } : null;
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
/** Does the file an explicit override names actually exist? Split out so the
|
|
565
|
-
* existence check is injectable alongside the lookup above. */
|
|
566
|
-
const overrideIsThere = (file, { exists = existsSync } = {}) => {
|
|
567
|
-
try {
|
|
568
|
-
return exists(file);
|
|
569
|
-
} catch {
|
|
570
|
-
return false;
|
|
571
|
-
}
|
|
572
|
-
};
|
|
573
|
-
|
|
574
|
-
// Ensure a runnable ccusage. Returns { kind:"node", entry } for the managed
|
|
575
|
-
// install, { kind:"path", file } for a copy the user provided, or { kind:"npx" }
|
|
576
|
-
// as the portable fallback.
|
|
577
|
-
//
|
|
578
|
-
// The order is the whole design decision, so it is stated rather than implied.
|
|
579
|
-
//
|
|
580
|
-
// An explicit AGENTS_DECK_CCUSAGE is first and is never fallen through: a user
|
|
581
|
-
// who pointed the deck at a file and got silently ignored has been given a
|
|
582
|
-
// setting that does nothing, which is worse than not having one. If it does not
|
|
583
|
-
// resolve, the failure names THAT — "npx is not on this deck's PATH" would be
|
|
584
|
-
// both true and completely beside the point.
|
|
585
|
-
//
|
|
586
|
-
// The managed install then comes BEFORE the PATH copy, which is the opposite of
|
|
587
|
-
// what #433 proposed, and deliberately. Preferring PATH would silently change
|
|
588
|
-
// which ccusage runs on every machine that already has both — a deck that works
|
|
589
|
-
// today would start running a copy it has never run, and an old global ccusage
|
|
590
|
-
// without `daily --json` would turn a working panel into a broken one for no
|
|
591
|
-
// reason the user asked for. The PATH copy is an ESCAPE ROUTE, and it wants to
|
|
592
|
-
// be reached exactly when the managed install is not there; someone who wants
|
|
593
|
-
// their own copy to win over the deck's says so with AGENTS_DECK_CCUSAGE, which
|
|
594
|
-
// is unambiguous in a way a precedence rule never is.
|
|
595
|
-
//
|
|
596
|
-
// PATH comes before INSTALLING, though. Downloading a second copy of a tool
|
|
597
|
-
// that is already on the machine is not something to do to somebody, and it is
|
|
598
|
-
// what makes the order identical with and without AGENTS_DECK_NO_INSTALL=1 —
|
|
599
|
-
// that flag now removes a step rather than changing the sequence.
|
|
600
|
-
async function getRunner() {
|
|
601
|
-
const mine = userCcusage();
|
|
602
|
-
if (mine?.named) {
|
|
603
|
-
if (!overrideIsThere(mine.file)) {
|
|
604
|
-
throw tagged("bad_override",
|
|
605
|
-
`AGENTS_DECK_CCUSAGE points at ${mine.file}, and there is no such file`);
|
|
606
|
-
}
|
|
607
|
-
return { kind: "path", file: mine.file, named: true };
|
|
608
|
-
}
|
|
609
|
-
let resolved = resolveEntry();
|
|
610
|
-
if (resolved) {
|
|
611
|
-
maybeBackgroundUpdate(resolved.version);
|
|
612
|
-
return { kind: "node", entry: resolved.entry };
|
|
613
|
-
}
|
|
614
|
-
if (mine) return { kind: "path", file: mine.file, named: false };
|
|
615
|
-
// Nothing installed, nothing of the user's to run, and installs are
|
|
616
|
-
// forbidden. The npx fallback is not an escape hatch — `npx -y ccusage@latest`
|
|
617
|
-
// downloads and runs the same package — so there is no runner to hand back.
|
|
618
|
-
// Fail with the reason, which the usage-history modal shows, instead of
|
|
619
|
-
// quietly installing.
|
|
620
|
-
if (installsDisabled()) {
|
|
621
|
-
throw tagged("no_install", "ccusage is not installed, and installs are off (AGENTS_DECK_NO_INSTALL=1)");
|
|
622
|
-
}
|
|
623
|
-
// Cold: install once (deduped across concurrent callers).
|
|
624
|
-
await startInstall("latest");
|
|
625
|
-
resolved = resolveEntry();
|
|
626
|
-
if (resolved) { touchMarker(); return { kind: "node", entry: resolved.entry }; }
|
|
627
|
-
// npm unavailable / offline → fall back to npx, carrying WHY the managed
|
|
628
|
-
// install is not here. Without that the modal can only describe the fallback,
|
|
629
|
-
// and the fallback is the second thing that failed.
|
|
630
|
-
return { kind: "npx", installError: _lastInstallError };
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
/** Which of ccusage's three paths a failure came from, in the deck's own words.
|
|
634
|
-
* `stage` travels to the browser; the modal leads with it rather than making
|
|
635
|
-
* the reader guess from a stack trace which half of this module they are
|
|
636
|
-
* looking at. */
|
|
637
|
-
const STAGE = { node: "managed", path: "path", npx: "npx" };
|
|
638
|
-
|
|
639
|
-
/** Mark a failure with the path that produced it, and with the managed
|
|
640
|
-
* install's own account when that is why this path was taken at all. Set once:
|
|
641
|
-
* the retry below re-stamps with the runner that actually failed, and an error
|
|
642
|
-
* that already knows where it came from is not overwritten. */
|
|
643
|
-
function stamp(err, runner) {
|
|
644
|
-
if (err && typeof err === "object") {
|
|
645
|
-
if (!err.stage) err.stage = STAGE[runner?.kind] ?? "npx";
|
|
646
|
-
// Which file, when the answer is a file the deck did not put there. The
|
|
647
|
-
// stage alone says "your own copy failed" and leaves the reader to work out
|
|
648
|
-
// WHICH copy, and on a machine with an override, a PATH entry and a managed
|
|
649
|
-
// install that is exactly the question they cannot answer from here.
|
|
650
|
-
if (runner?.kind === "path" && err.bin === undefined) err.bin = runner.file;
|
|
651
|
-
if (runner?.installError && err.install === undefined) err.install = runner.installError;
|
|
652
|
-
}
|
|
653
|
-
return err;
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
// ── invocation ──────────────────────────────────────────────────────────────
|
|
657
|
-
|
|
658
|
-
// At most one repair per process, so a package that is simply unrunnable —
|
|
659
|
-
// reinstalled and still broken — cannot put this into a loop of npm installs.
|
|
660
|
-
let _repairedThisRun = false;
|
|
661
|
-
|
|
662
|
-
/**
|
|
663
|
-
* Throw away a managed install that resolves but cannot run, so the next
|
|
664
|
-
* attempt builds a new one.
|
|
665
|
-
*
|
|
666
|
-
* This is the only genuinely permanent failure in this module, and it is the
|
|
667
|
-
* one a broken npm creates: `npm install` that dies partway leaves a
|
|
668
|
-
* package.json and an entry file on disk, resolveEntry answers "installed", and
|
|
669
|
-
* getRunner then hands back that entry FOREVER. Nothing re-checks it —
|
|
670
|
-
* maybeBackgroundUpdate only reinstalls when the registry has a newer version,
|
|
671
|
-
* so even a repaired npm never repaired the install. Every run failed
|
|
672
|
-
* identically, the modal said "try again", and the only thing that actually
|
|
673
|
-
* worked was deleting ~/.agents-deck/ccusage by hand, which nothing in the
|
|
674
|
-
* product tells anyone to do.
|
|
675
|
-
*
|
|
676
|
-
* Only the managed branch qualifies: the npx fallback's failures are npm's own
|
|
677
|
-
* and none of this deck's business to delete anything over.
|
|
678
|
-
*
|
|
679
|
-
* Not done under AGENTS_DECK_NO_INSTALL=1. That variable is a promise not to
|
|
680
|
-
* fetch, and removing the only copy on a machine that cannot replace it would
|
|
681
|
-
* turn a broken feature into an absent one.
|
|
682
|
-
*/
|
|
683
|
-
function discardDamagedInstall(runner, err) {
|
|
684
|
-
if (_repairedThisRun || runner.kind !== "node" || installsDisabled()) return false;
|
|
685
|
-
if (!cannotLoadModule(err?.message)) return false;
|
|
686
|
-
// THE FLAG IS SPENT ON A REPAIR THAT HAPPENED, not on one that was attempted
|
|
687
|
-
// (#790). It used to be set here, before the try — and the rm below fails on
|
|
688
|
-
// Windows for the reason its own maxRetries comment gives: the `node <entry>`
|
|
689
|
-
// child that just exited still holds a handle, the unlink marks the file
|
|
690
|
-
// delete-pending, and rmdir answers ENOTEMPTY past all ten retries. The catch
|
|
691
|
-
// returned false with NOTHING removed, and every later modal open and every
|
|
692
|
-
// 60s poll for the life of the deck then short-circuited on this same flag —
|
|
693
|
-
// including seconds later, once the handle was gone and the rm would have
|
|
694
|
-
// worked. The user's only way out was deleting ~/.agents-deck/ccusage by
|
|
695
|
-
// hand, which nothing tells them.
|
|
696
|
-
//
|
|
697
|
-
// The budget exists to stop a loop of INSTALLS. Here it was being consumed by
|
|
698
|
-
// a repair that never happened and never reached an install.
|
|
699
|
-
try {
|
|
700
|
-
// maxRetries because of what has just happened: the deck ran `node <entry>`
|
|
701
|
-
// out of this very directory a moment ago, and on Windows a file any handle
|
|
702
|
-
// still holds cannot be deleted — the unlink marks it delete-pending, the
|
|
703
|
-
// name stays, and rmdir on the parent answers ENOTEMPTY. A child still
|
|
704
|
-
// exiting is exactly that handle. Node retries EBUSY, EMFILE, ENFILE,
|
|
705
|
-
// ENOTEMPTY and EPERM with a linear backoff when asked to; unasked, it
|
|
706
|
-
// tries once and gives up, which turned a repairable install into the
|
|
707
|
-
// permanent failure below.
|
|
708
|
-
rmSync(PKG_DIR, { recursive: true, force: true, maxRetries: 10, retryDelay: 25 });
|
|
709
|
-
} catch {
|
|
710
|
-
// Windows holds a lock on a file inside a directory being removed more
|
|
711
|
-
// readily than POSIX does, and a half-removed install is still a resolvable
|
|
712
|
-
// one. Say the repair did not happen so the caller reports the real failure
|
|
713
|
-
// rather than retrying into the same broken entry point.
|
|
714
|
-
return false;
|
|
715
|
-
}
|
|
716
|
-
const gone = !resolveEntry();
|
|
717
|
-
// Only now. The directory is really gone, so this deck has spent its one
|
|
718
|
-
// repair and the budget is doing its job. A failed `rm` returned false above
|
|
719
|
-
// without touching the flag, so the next poll — seconds later, once the
|
|
720
|
-
// handle is released — is free to try again.
|
|
721
|
-
if (gone) _repairedThisRun = true;
|
|
722
|
-
return gone;
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
/**
|
|
726
|
-
* What `spawn` gets for a ccusage the user provided.
|
|
727
|
-
*
|
|
728
|
-
* `file` is always an absolute path by the time it reaches here — pathLookup
|
|
729
|
-
* resolves the directory and an override is a path the user typed — and that is
|
|
730
|
-
* load-bearing rather than tidy. On Windows the thing on PATH is `ccusage.cmd`,
|
|
731
|
-
* a batch file, so spawnSpec routes it through cmd.exe; a batch file launched
|
|
732
|
-
* by BARE name computes `%~dp0` from the deck's working directory and goes
|
|
733
|
-
* looking for its payload there, which is #456 exactly. Resolving first and
|
|
734
|
-
* quoting second is the same order the npm and npx shims go through.
|
|
735
|
-
*
|
|
736
|
-
* Exported for tests: the platform is a parameter so the Windows command line
|
|
737
|
-
* can be checked from any OS.
|
|
738
|
-
*/
|
|
739
|
-
export const userSpec = (file, args = [], platform = process.platform) =>
|
|
740
|
-
spawnSpec(file, args, platform);
|
|
741
|
-
|
|
742
|
-
/**
|
|
743
|
-
* The platform binary ccusage's own cli.js would run, resolved the way cli.js
|
|
744
|
-
* resolves it (`@ccusage/ccusage-<platform>-<arch>/bin/ccusage[.exe]`, from
|
|
745
|
-
* the package's own directory), or null when there is none.
|
|
746
|
-
*
|
|
747
|
-
* WINDOWS ONLY, and for one reason: cli.js starts that binary WITHOUT hiding
|
|
748
|
-
* its console. The deck runs with no console of its own (detached, or inside
|
|
749
|
-
* the desktop app), so the binary is given a new one — and with Windows
|
|
750
|
-
* Terminal as the default terminal that is a window flashing up on every
|
|
751
|
-
* usage read, measured on a Windows 10 box as a CASCADIA_HOSTING_WINDOW_CLASS
|
|
752
|
-
* hosting ccusage. Running the binary directly, with windowsHide, is what
|
|
753
|
-
* cli.js does minus the window. Elsewhere nothing flashes, and the wrapper
|
|
754
|
-
* stays in the path it has always been in.
|
|
755
|
-
*
|
|
756
|
-
* Exported for tests; `resolve` is injectable.
|
|
757
|
-
*/
|
|
758
|
-
export function nativeCcusage(entry, platform = process.platform, arch = process.arch, resolve = null) {
|
|
759
|
-
if (platform !== "win32" || typeof entry !== "string" || !entry) return null;
|
|
760
|
-
const pkg = arch === "arm64" ? "@ccusage/ccusage-win32-arm64" : arch === "x64" ? "@ccusage/ccusage-win32-x64" : null;
|
|
761
|
-
if (!pkg) return null;
|
|
762
|
-
try {
|
|
763
|
-
const req = resolve ?? createRequire(entry).resolve;
|
|
764
|
-
return req(`${pkg}/bin/ccusage.exe`);
|
|
765
|
-
} catch {
|
|
766
|
-
return null;
|
|
767
|
-
}
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
// One attempt with one runner. No branch gets a shell. The managed install is
|
|
771
|
-
// `node <entry> …`, which never needed one; the user's own copy and the npx
|
|
772
|
-
// fallback are routed through spawnSpec instead — see npxSpec and userSpec, and
|
|
773
|
-
// note that `args` here ends in whatever /api/ccusage was asked for.
|
|
774
|
-
function runOnce(runner, args) {
|
|
775
|
-
const native = runner.kind === "node" ? nativeCcusage(runner.entry) : null;
|
|
776
|
-
const { file, args: full, opts } = native
|
|
777
|
-
? { file: native, args, opts: {} }
|
|
778
|
-
: runner.kind === "node"
|
|
779
|
-
? { file: process.execPath, args: [runner.entry, ...args], opts: {} }
|
|
780
|
-
: runner.kind === "path"
|
|
781
|
-
? userSpec(runner.file, args)
|
|
782
|
-
: fallbackSpec(args);
|
|
783
|
-
return new Promise((resolve, reject) => {
|
|
784
|
-
const child = spawn(file, full, { windowsHide: true, ...opts });
|
|
785
|
-
let out = "", err = "";
|
|
786
|
-
const timer = setTimeout(() => {
|
|
787
|
-
// The npx fallback goes through cmd.exe on Windows, so `child` is the
|
|
788
|
-
// wrapper there and npx is a grandchild that a plain kill would leave
|
|
789
|
-
// downloading.
|
|
790
|
-
killTree(child);
|
|
791
|
-
reject(tagged("timeout", "ccusage timed out"));
|
|
792
|
-
}, TIMEOUT_MS);
|
|
793
|
-
child.stdout.on("data", d => { out += d; });
|
|
794
|
-
child.stderr.on("data", d => { err += d; });
|
|
795
|
-
child.on("error", e => { clearTimeout(timer); reject(e); });
|
|
796
|
-
child.on("close", code => {
|
|
797
|
-
clearTimeout(timer);
|
|
798
|
-
if (code === 0) resolve(out);
|
|
799
|
-
else reject(new Error(err.trim() || `ccusage exited ${code}`));
|
|
800
|
-
});
|
|
801
|
-
});
|
|
802
|
-
}
|
|
803
|
-
|
|
804
|
-
// Run ccusage with the given args, resolve raw stdout AND the runner that
|
|
805
|
-
// produced it. One retry, and only for the one failure that is otherwise
|
|
806
|
-
// permanent — see discardDamagedInstall. The second getRunner() is what rebuilds
|
|
807
|
-
// the install, or falls through to npx when npm cannot.
|
|
808
|
-
//
|
|
809
|
-
// The runner comes back with the output because the caller has to say which
|
|
810
|
-
// path answered: `extractJson` below can fail on a run that started perfectly
|
|
811
|
-
// well, and "ccusage ran but printed no usage data" reads differently depending
|
|
812
|
-
// on which ccusage that was.
|
|
813
|
-
async function runCcusage(args) {
|
|
814
|
-
const runner = await getRunner();
|
|
815
|
-
try {
|
|
816
|
-
return { out: await runOnce(runner, args), runner };
|
|
817
|
-
} catch (err) {
|
|
818
|
-
if (!discardDamagedInstall(runner, err)) throw stamp(err, runner);
|
|
819
|
-
note("managed install was unusable, rebuilding it", err);
|
|
820
|
-
const rebuilt = await getRunner();
|
|
821
|
-
try {
|
|
822
|
-
return { out: await runOnce(rebuilt, args), runner: rebuilt };
|
|
823
|
-
} catch (again) {
|
|
824
|
-
throw stamp(again, rebuilt);
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
|
-
}
|
|
828
|
-
|
|
829
94
|
// ── the per-agent split ─────────────────────────────────────────────────────
|
|
830
95
|
|
|
831
96
|
/**
|
|
@@ -856,8 +121,8 @@ const BY_AGENT = "--by-agent";
|
|
|
856
121
|
* The npx fallback resolves `ccusage@latest` and so is never the stale case,
|
|
857
122
|
* but a managed install that has not had its once-a-day update yet can be, and
|
|
858
123
|
* a copy the user put on PATH themselves can be any age at all. Process-scoped,
|
|
859
|
-
* like `_checkedThisRun` and `_repairedThisRun`: a deck
|
|
860
|
-
* upgrading ccusage asks again.
|
|
124
|
+
* like ccusage-install.mjs's `_checkedThisRun` and `_repairedThisRun`: a deck
|
|
125
|
+
* restarted after upgrading ccusage asks again.
|
|
861
126
|
*/
|
|
862
127
|
let _byAgentUnsupported = false;
|
|
863
128
|
|
|
@@ -1028,14 +293,6 @@ export async function fetchCcusageDaily({ since, until, force = false } = {}) {
|
|
|
1028
293
|
return run;
|
|
1029
294
|
}
|
|
1030
295
|
|
|
1031
|
-
/**
|
|
1032
|
-
* One ccusage run, once the queue has let it through.
|
|
1033
|
-
*
|
|
1034
|
-
* `now` is read here rather than carried in from the call, so `fetchedAt` and
|
|
1035
|
-
* the cache stamp both mean "when this reading was taken" even for a run that
|
|
1036
|
-
* waited its turn. With an empty queue that is the same instant the caller
|
|
1037
|
-
* asked, which is what this always did.
|
|
1038
|
-
*/
|
|
1039
296
|
/**
|
|
1040
297
|
* The same range, grouped by session rather than by day.
|
|
1041
298
|
*
|
|
@@ -1068,6 +325,14 @@ async function readSessions(sinceArg, until) {
|
|
|
1068
325
|
}
|
|
1069
326
|
}
|
|
1070
327
|
|
|
328
|
+
/**
|
|
329
|
+
* One ccusage run, once the queue has let it through.
|
|
330
|
+
*
|
|
331
|
+
* `now` is read here rather than carried in from the call, so `fetchedAt` and
|
|
332
|
+
* the cache stamp both mean "when this reading was taken" even for a run that
|
|
333
|
+
* waited its turn. With an empty queue that is the same instant the caller
|
|
334
|
+
* asked, which is what this always did.
|
|
335
|
+
*/
|
|
1071
336
|
async function readRange(sinceArg, until, key) {
|
|
1072
337
|
const now = Date.now();
|
|
1073
338
|
let result;
|
|
@@ -1207,7 +472,7 @@ export function primeCcusage() {
|
|
|
1207
472
|
const resolved = resolveEntry();
|
|
1208
473
|
if (resolved) {
|
|
1209
474
|
// Already installed: the daily check may still queue a background upgrade.
|
|
1210
|
-
const due =
|
|
475
|
+
const due = backgroundUpdateDue();
|
|
1211
476
|
maybeBackgroundUpdate(resolved.version);
|
|
1212
477
|
return { state: due ? "updating" : "present", version: resolved.version };
|
|
1213
478
|
}
|