ccdeck 3.29.11 → 3.31.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 +4 -3
- 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-n63GjLhv.js +1 -0
- package/dist/web/assets/UsageHistoryModal-DEwBX4Fk.js +1 -0
- package/dist/web/assets/index-CZJf1Cpp.js +299 -0
- package/dist/web/assets/index-D4q1ElXU.css +1 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +15 -14
- package/package.json +1 -1
- package/release-notes.json +40 -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 +7 -3
- 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 +2 -2
- package/src/server/deck-prefs.mjs +58 -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 +174 -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 +110 -0
- package/src/server/prefs-give-back.mjs +206 -0
- package/src/server/prefs-path.mjs +14 -0
- package/src/server/prefs-refusal.mjs +86 -0
- package/src/server/prefs-routes.mjs +99 -0
- package/src/server/prefs-set-aside.mjs +86 -0
- package/src/server/prefs-state.mjs +70 -0
- package/src/server/process-command.mjs +104 -0
- package/src/server/process-list.mjs +547 -0
- package/src/server/provider-status.mjs +307 -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 +108 -0
- package/src/server/uv-bootstrap.mjs +1 -1
- package/src/server/wordmark.mjs +127 -0
- package/dist/web/assets/BrowserWatchModal-CyAFTvAY.js +0 -1
- package/dist/web/assets/UsageHistoryModal-KJfli0oa.js +0 -1
- package/dist/web/assets/index-Ck2RAla3.css +0 -1
- package/dist/web/assets/index-jmrcEsCj.js +0 -298
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
// The managed ccusage install: where it lives, how npm is asked to build and
|
|
2
|
+
// update it, what is said when that fails, and the one repair a broken one
|
|
3
|
+
// gets. ccusage.mjs decides when to run ccusage and reads what it prints; this
|
|
4
|
+
// is only about there being a copy of the deck's own to run.
|
|
5
|
+
//
|
|
6
|
+
// Performance: we do NOT run `npx -y ccusage@latest` on every call — that hits
|
|
7
|
+
// the npm registry to resolve `@latest` (and re-downloads when the npx cache is
|
|
8
|
+
// cold), so each modal open waited seconds. Instead we keep our OWN managed
|
|
9
|
+
// install under ~/.agents-deck/ccusage and invoke it directly with
|
|
10
|
+
// `node <pkg>/src/cli.js` (no npx, no registry round-trip). A throttled
|
|
11
|
+
// once-per-day background check upgrades it when a newer ccusage ships, while
|
|
12
|
+
// the current call always serves from the already-installed copy. If the
|
|
13
|
+
// managed install is missing/broken we fall back to the old npx path so the
|
|
14
|
+
// feature still works on a fresh machine.
|
|
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, shimPath, spawnSpec } from "./exec.mjs";
|
|
20
|
+
import { oneLine } from "./term.mjs";
|
|
21
|
+
import { note } from "./ccusage-failure.mjs";
|
|
22
|
+
|
|
23
|
+
const INSTALL_TIMEOUT_MS = 120_000; // first-run npm install can be slow
|
|
24
|
+
const UPDATE_CHECK_MS = 24 * 3600_000; // check npm for a newer ccusage once/day
|
|
25
|
+
|
|
26
|
+
const CACHE_DIR = path.join(os.homedir(), ".agents-deck", "ccusage");
|
|
27
|
+
const PKG_DIR = path.join(CACHE_DIR, "node_modules", "ccusage");
|
|
28
|
+
const MARKER = path.join(CACHE_DIR, ".last-update-check");
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The name to hand cmd.exe for one of npm's Windows shims: its full path when
|
|
32
|
+
* one can be found, and the bare name only when none can.
|
|
33
|
+
*
|
|
34
|
+
* The full path is not a tidiness preference, it is the fix for #456. A shim
|
|
35
|
+
* launched by bare name computes `%~dp0` — which is where it looks for
|
|
36
|
+
* npm-prefix.js, npm-cli.js and npx-cli.js — from the deck's WORKING DIRECTORY
|
|
37
|
+
* rather than from its own, so on a deck started from `C:\Users\vceban` both
|
|
38
|
+
* shims died with `Cannot find module 'C:\Users\vceban\node_modules\npm\bin\…'`
|
|
39
|
+
* on a machine whose npm was perfectly healthy. shimPath in exec.mjs carries
|
|
40
|
+
* the whole account; what matters here is that BOTH the managed install and the
|
|
41
|
+
* npx fallback are launched this way, so both failed, and diagnosing either
|
|
42
|
+
* half alone could never have explained the other.
|
|
43
|
+
*
|
|
44
|
+
* Falling back to the bare name is deliberate: it is exactly what this did
|
|
45
|
+
* before, so a layout shimPath cannot see is no worse off than it was, and on
|
|
46
|
+
* such a machine cmd.exe's own PATH search still gets its turn.
|
|
47
|
+
*/
|
|
48
|
+
export const winShim = (name, deps) => shimPath(name, deps) ?? name;
|
|
49
|
+
|
|
50
|
+
// npm is a .cmd shim on Windows, which spawn can only launch through cmd.exe.
|
|
51
|
+
// `shell: true` is the tempting way to get there and the wrong one: Node then
|
|
52
|
+
// joins file and args with single spaces and no quoting, so on a profile like
|
|
53
|
+
// C:\Users\John Smith the `--prefix <CACHE_DIR>` below arrived as
|
|
54
|
+
// `--prefix C:\Users\John` plus a bogus package spec `Smith\.agents-deck\...`,
|
|
55
|
+
// npm exited non-zero, and the managed install never materialised. spawnSpec
|
|
56
|
+
// routes the .cmd through cmd.exe with every argument quoted, and hands back
|
|
57
|
+
// the argument vector untouched everywhere else.
|
|
58
|
+
//
|
|
59
|
+
// POSIX is untouched by any of this: `npm` there is a real executable on PATH,
|
|
60
|
+
// not a batch file, so isBatch is false, viaCmd never runs, and the vector goes
|
|
61
|
+
// to spawn exactly as it always has.
|
|
62
|
+
function npmSpec(args, platform = process.platform, deps) {
|
|
63
|
+
return spawnSpec(platform === "win32" ? winShim("npm.cmd", deps) : "npm", args, platform);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* What `spawn` gets for `npm install ccusage@<spec>`.
|
|
68
|
+
* Exported for tests: the platform is a parameter so the Windows command line
|
|
69
|
+
* can be checked from any OS, and `deps` stands in for the Windows filesystem
|
|
70
|
+
* the shim lookup asks about.
|
|
71
|
+
*/
|
|
72
|
+
export const installSpec = (spec = "latest", platform = process.platform, deps) =>
|
|
73
|
+
npmSpec(["install", `ccusage@${spec}`, "--prefix", CACHE_DIR,
|
|
74
|
+
"--no-save", "--no-audit", "--no-fund", "--loglevel", "error"], platform, deps);
|
|
75
|
+
|
|
76
|
+
let _installing = null; // Promise guard so concurrent calls share one install
|
|
77
|
+
let _checkedThisRun = false; // only kick the daily check once per process boot
|
|
78
|
+
|
|
79
|
+
// The last `npm install` that failed, in one line of its own words.
|
|
80
|
+
//
|
|
81
|
+
// Module scope rather than a value thrown out of the install, because the two
|
|
82
|
+
// callers that matter never see that throw. primeCcusage runs the install at
|
|
83
|
+
// boot and only logs the rejection; a second modal open that arrives while an
|
|
84
|
+
// install is in flight awaits the SHARED promise, whose rejection has already
|
|
85
|
+
// been handled by the first. Both then land on the npx fallback with nothing to
|
|
86
|
+
// say about why they were there — which is precisely how three rounds of
|
|
87
|
+
// debugging went to the fallback's stderr while the install's own account
|
|
88
|
+
// stayed on a terminal row the deck had already painted over.
|
|
89
|
+
//
|
|
90
|
+
// One line, not the whole dump: this is written into a sentence in a 46ch box.
|
|
91
|
+
// The full text still goes to the terminal through note() below.
|
|
92
|
+
let _lastInstallError = null;
|
|
93
|
+
const INSTALL_ERROR_ROOM = 240;
|
|
94
|
+
|
|
95
|
+
/** The last failed install's line, or null once an install has worked — what
|
|
96
|
+
* getRunner hands the npx fallback to carry. */
|
|
97
|
+
export function lastInstallError() { return _lastInstallError; }
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Start the one shared install, remembering how it ended.
|
|
101
|
+
*
|
|
102
|
+
* Deduped through `_installing` the way it always was, so concurrent callers
|
|
103
|
+
* cost one `npm install` between them, and the outcome survives the promise.
|
|
104
|
+
*
|
|
105
|
+
* This used to read `_installing = (async () => { installSync(spec); })()`, and
|
|
106
|
+
* that wrapper was the whole of #476: an async function body runs synchronously
|
|
107
|
+
* up to its first `await`, and there was none, so the IIFE turned a throw into
|
|
108
|
+
* a rejection and bought no asynchrony at all. `installSync` was a `spawnSync`
|
|
109
|
+
* of `npm install` with a two-minute deadline, and it ran on the caller's
|
|
110
|
+
* stack — which at boot is bin/deck.js's, beside jobs that really are async.
|
|
111
|
+
* primeCcusage answered `{ state: "installing" }`, a sentence that says the
|
|
112
|
+
* wait was deferred, while the process could not accept an HTTP connection,
|
|
113
|
+
* write an SSE frame, ingest a hook or repaint the pulse line until npm exited.
|
|
114
|
+
* On a first run, with nothing cached, that is the one boot a new user judges
|
|
115
|
+
* the tool by.
|
|
116
|
+
*
|
|
117
|
+
* `install` below is the fix, and there is now exactly one install mechanism in
|
|
118
|
+
* this module rather than a synchronous one and a background one.
|
|
119
|
+
*/
|
|
120
|
+
export function startInstall(spec = "latest") {
|
|
121
|
+
if (!_installing) {
|
|
122
|
+
_installing = install(spec)
|
|
123
|
+
.then(() => { _lastInstallError = null; })
|
|
124
|
+
.catch(e => {
|
|
125
|
+
_lastInstallError = oneLine(e?.message ?? e, INSTALL_ERROR_ROOM);
|
|
126
|
+
note("install failed", e);
|
|
127
|
+
})
|
|
128
|
+
.finally(() => { _installing = null; });
|
|
129
|
+
}
|
|
130
|
+
return _installing;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// AGENTS_DECK_NO_INSTALL=1 is documented as "never install or update
|
|
134
|
+
// claude-swap / ccusage, and never ask npm about releases", so it has to hold
|
|
135
|
+
// on the lazy path too — opening the usage-history modal must not be a way to
|
|
136
|
+
// pull ccusage off the registry behind the user's back. Read per call rather
|
|
137
|
+
// than once at import, because the module is imported lazily and a test (or an
|
|
138
|
+
// embedder) may set it after load.
|
|
139
|
+
export function installsDisabled() {
|
|
140
|
+
return process.env.AGENTS_DECK_NO_INSTALL === "1";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Node saying it could not load a file it was pointed at.
|
|
145
|
+
*
|
|
146
|
+
* Both spellings are here because both happen: CommonJS throws
|
|
147
|
+
* `MODULE_NOT_FOUND`, ESM throws `ERR_MODULE_NOT_FOUND` with the wording
|
|
148
|
+
* "Cannot find package" for a bare specifier, and ccusage's entry point is ESM
|
|
149
|
+
* while the npm/npx shims it may be launched through are not.
|
|
150
|
+
*
|
|
151
|
+
* Exported for tests. The one caller is the managed-install branch of
|
|
152
|
+
* runCcusage, where the child is `node <our entry>` and nothing else — so a
|
|
153
|
+
* module Node cannot resolve is by construction a file of OURS that is missing,
|
|
154
|
+
* never the user's npm.
|
|
155
|
+
*/
|
|
156
|
+
export const cannotLoadModule = (text) =>
|
|
157
|
+
/\b(?:ERR_)?MODULE_NOT_FOUND\b|cannot find (?:module|package)/i.test(String(text ?? ""));
|
|
158
|
+
|
|
159
|
+
// ── managed install ─────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
// Absolute path to ccusage's CLI entry inside our managed install, or null if
|
|
162
|
+
// not installed. Reads the package's `bin` field (currently "./src/cli.js").
|
|
163
|
+
//
|
|
164
|
+
// `bin` is a string out of a package.json downloaded from the registry, and it
|
|
165
|
+
// is joined onto PKG_DIR and then handed to `spawn(node, [entry])` — so a `bin`
|
|
166
|
+
// of "../../../evil.js" names a file outside the managed install and gets run.
|
|
167
|
+
// existsSync alone does not notice: it is asked whether the escaped path is
|
|
168
|
+
// there, and for an attacker who put something there the answer is yes.
|
|
169
|
+
//
|
|
170
|
+
// The narrowness is worth stating rather than leaving implied: a package that
|
|
171
|
+
// can choose its own `bin` can also ship an install script, so this is not the
|
|
172
|
+
// weak link in a hostile-package scenario. What it does close is the case where
|
|
173
|
+
// only the file is influenced — a tampered or half-written package.json in the
|
|
174
|
+
// cache dir, or a `bin` that walks out of the install by accident — and it
|
|
175
|
+
// costs one comparison.
|
|
176
|
+
//
|
|
177
|
+
// Exported for tests: the containment rule is the whole point of the function
|
|
178
|
+
// and there is no other way to reach it without an install on disk.
|
|
179
|
+
export function resolveEntry() {
|
|
180
|
+
try {
|
|
181
|
+
const pkg = JSON.parse(readFileSync(path.join(PKG_DIR, "package.json"), "utf8"));
|
|
182
|
+
let rel = pkg.bin;
|
|
183
|
+
if (rel && typeof rel === "object") rel = rel.ccusage ?? Object.values(rel)[0];
|
|
184
|
+
if (typeof rel !== "string") return null;
|
|
185
|
+
const entry = path.resolve(PKG_DIR, rel);
|
|
186
|
+
// path.resolve, not path.join, so an ABSOLUTE `bin` is measured as the
|
|
187
|
+
// absolute path it is rather than being silently re-rooted under PKG_DIR
|
|
188
|
+
// and passing on a technicality. The trailing separator is what stops a
|
|
189
|
+
// sibling directory whose name merely starts with PKG_DIR's — and it also
|
|
190
|
+
// rejects PKG_DIR itself, which is a directory and no kind of entry point.
|
|
191
|
+
if (!entry.startsWith(path.resolve(PKG_DIR) + path.sep)) return null;
|
|
192
|
+
return existsSync(entry) ? { entry, version: pkg.version } : null;
|
|
193
|
+
} catch {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Everything a failed install is known to have said, in the order the answer is
|
|
200
|
+
* usually in.
|
|
201
|
+
*
|
|
202
|
+
* The old line read `(r.stderr || "").trim() || r.status`, which threw away the
|
|
203
|
+
* two things most likely to be the whole story. `error` is where a failure to
|
|
204
|
+
* LAUNCH lands — ENOENT for a comspec that is not there, EINVAL for a .cmd Node
|
|
205
|
+
* refuses to spawn directly, and the expired deadline — and it comes with no
|
|
206
|
+
* status at all, so what reached the terminal in every one of those cases was
|
|
207
|
+
* the word "null". And npm has never confined itself to stderr: a shim that
|
|
208
|
+
* dies before npm starts writes wherever Node chose, and `npm ERR!` blocks have
|
|
209
|
+
* landed on stdout across majors.
|
|
210
|
+
*
|
|
211
|
+
* The four fields are spawnSync's, because that is where they were first read
|
|
212
|
+
* off; `install` below now fills the same shape from the 'error' event, the
|
|
213
|
+
* exit status and the two collected streams.
|
|
214
|
+
*/
|
|
215
|
+
function installFailureText(r) {
|
|
216
|
+
const parts = [];
|
|
217
|
+
if (r?.error?.message) parts.push(String(r.error.message));
|
|
218
|
+
const stderr = String(r?.stderr ?? "").trim();
|
|
219
|
+
const stdout = String(r?.stdout ?? "").trim();
|
|
220
|
+
if (stderr) parts.push(stderr);
|
|
221
|
+
if (stdout) parts.push(stdout);
|
|
222
|
+
if (!parts.length) parts.push(r?.status === null || r?.status === undefined
|
|
223
|
+
? "npm exited without a status and said nothing"
|
|
224
|
+
: `npm exited ${r.status} and said nothing`);
|
|
225
|
+
return parts.join(" — ");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Which level of the managed install `npm install --prefix` actually produced.
|
|
230
|
+
*
|
|
231
|
+
* This is the honest answer to the question nobody could answer from a
|
|
232
|
+
* screenshot: an install that exits 0 and leaves a tree resolveEntry cannot use
|
|
233
|
+
* is reported as SUCCESS by an exit code alone, and #432 found that exact shape
|
|
234
|
+
* once already. Naming the first level that is not there turns "ccusage could
|
|
235
|
+
* not report usage" into a sentence about this machine's disk — whether npm
|
|
236
|
+
* wrote nothing, wrote a node_modules with no ccusage in it, or wrote a package
|
|
237
|
+
* whose entry point this deck refuses.
|
|
238
|
+
*/
|
|
239
|
+
function installTreeReport() {
|
|
240
|
+
const levels = [
|
|
241
|
+
[CACHE_DIR, "the prefix directory"],
|
|
242
|
+
[path.join(CACHE_DIR, "node_modules"), "node_modules under it"],
|
|
243
|
+
[PKG_DIR, "node_modules/ccusage"],
|
|
244
|
+
[path.join(PKG_DIR, "package.json"), "node_modules/ccusage/package.json"],
|
|
245
|
+
];
|
|
246
|
+
for (const [where, name] of levels) {
|
|
247
|
+
if (!existsSync(where)) return `${name} is not there`;
|
|
248
|
+
}
|
|
249
|
+
// Every level exists, so resolveEntry refused for one of its own reasons: an
|
|
250
|
+
// unreadable or bin-less package.json, or a `bin` pointing outside the
|
|
251
|
+
// package. All three are about the package rather than about npm.
|
|
252
|
+
return "the package is there but its bin entry could not be read or does not point inside it";
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Run `npm install ccusage@<spec> --prefix CACHE_DIR`, off this stack.
|
|
257
|
+
*
|
|
258
|
+
* The ONE install in this module, awaited by startInstall and dropped on the
|
|
259
|
+
* floor by the daily update path below. It was two — a `spawnSync` for the cold
|
|
260
|
+
* path and a fire-and-forget `spawn` for the background one — and the sync half
|
|
261
|
+
* is what #476 removes: nothing here needs an answer before the next tick, and
|
|
262
|
+
* a two-minute `spawnSync` at boot is two minutes of a dead process.
|
|
263
|
+
*
|
|
264
|
+
* Everything the sync path was careful about is carried over unchanged, because
|
|
265
|
+
* every one of those cares was bought with a bug report:
|
|
266
|
+
*
|
|
267
|
+
* - the vector is `installSpec`'s, spread into spawn exactly as spawnSync got
|
|
268
|
+
* it, which is what keeps #456's absolute `npm.cmd` and #362's per-argument
|
|
269
|
+
* quoting on Windows. `opts` is spread LAST for the same reason it was
|
|
270
|
+
* before: it carries windowsVerbatimArguments, and nothing above it may win.
|
|
271
|
+
* - `windowsHide`, so no console window flashes up.
|
|
272
|
+
* - INSTALL_TIMEOUT_MS, as a deadline this module enforces itself rather than
|
|
273
|
+
* spawn's own `timeout` option. That option sends one signal to the process
|
|
274
|
+
* it started, and on Windows a `.cmd` runs THROUGH cmd.exe — so the signal
|
|
275
|
+
* would land on the wrapper and leave npm downloading, which is the same
|
|
276
|
+
* distinction exec.mjs's `run` states and the reason killTree exists. Same
|
|
277
|
+
* shape as runOnce in ccusage-runner.mjs, so ccusage's children have one
|
|
278
|
+
* deadline pattern rather than two.
|
|
279
|
+
*
|
|
280
|
+
* Diagnosis is unchanged too: `installFailureText` is handed the same four
|
|
281
|
+
* fields spawnSync used to hand it — a failure to LAUNCH in `error`, the exit
|
|
282
|
+
* status, and both output streams, because npm has never confined itself to
|
|
283
|
+
* stderr.
|
|
284
|
+
*/
|
|
285
|
+
function install(spec = "latest") {
|
|
286
|
+
return new Promise((resolve, reject) => {
|
|
287
|
+
mkdirSync(CACHE_DIR, { recursive: true });
|
|
288
|
+
const { file, args, opts } = installSpec(spec);
|
|
289
|
+
const child = spawn(file, args, { windowsHide: true, ...opts });
|
|
290
|
+
let out = "", err = "", settled = false;
|
|
291
|
+
let timer = null;
|
|
292
|
+
const finish = (fn, value) => {
|
|
293
|
+
if (settled) return;
|
|
294
|
+
settled = true;
|
|
295
|
+
clearTimeout(timer);
|
|
296
|
+
fn(value);
|
|
297
|
+
};
|
|
298
|
+
// A child that dies emits 'error' AND THEN 'close', and a deadline that
|
|
299
|
+
// fires kills the child and so provokes both — hence `settled`. Whichever
|
|
300
|
+
// arrives first is the account that travels.
|
|
301
|
+
const failed = (r) => finish(reject, new Error(`npm install ccusage failed: ${installFailureText(r)}`));
|
|
302
|
+
timer = setTimeout(() => {
|
|
303
|
+
// The verdict is stated before the kill, the order exec.mjs's `run` uses:
|
|
304
|
+
// the answer must not depend on the killed child cooperating.
|
|
305
|
+
failed({
|
|
306
|
+
error: { message: `npm install timed out after ${INSTALL_TIMEOUT_MS}ms` },
|
|
307
|
+
stdout: out,
|
|
308
|
+
stderr: err,
|
|
309
|
+
});
|
|
310
|
+
killTree(child);
|
|
311
|
+
}, INSTALL_TIMEOUT_MS);
|
|
312
|
+
timer.unref?.();
|
|
313
|
+
child.stdout?.on("data", d => { out += d; });
|
|
314
|
+
child.stderr?.on("data", d => { err += d; });
|
|
315
|
+
child.on("error", e => failed({ error: e, stdout: out, stderr: err }));
|
|
316
|
+
child.on("close", status => {
|
|
317
|
+
if (status !== 0) return failed({ status, stdout: out, stderr: err });
|
|
318
|
+
// A zero exit is npm's opinion, not a fact about the disk, and the caller
|
|
319
|
+
// treats "the install resolved" as "there is something to run". Checking
|
|
320
|
+
// here is what stops a silent success: getRunner used to call
|
|
321
|
+
// resolveEntry(), get null, and fall through to the npx fallback with
|
|
322
|
+
// NOTHING recorded anywhere about why — so the modal explained the
|
|
323
|
+
// fallback's stderr and the install's half of the story was never written
|
|
324
|
+
// down at all.
|
|
325
|
+
if (!resolveEntry()) {
|
|
326
|
+
// The reason before the path (#1672). startInstall fits this to
|
|
327
|
+
// INSTALL_ERROR_ROOM from the start, and with the path first a long home
|
|
328
|
+
// directory used the room up, so the level that was missing, the one
|
|
329
|
+
// thing this sentence is for, was what got cut.
|
|
330
|
+
return finish(reject, new Error(
|
|
331
|
+
`npm install ccusage exited 0 but left nothing runnable: ${installTreeReport()}, `
|
|
332
|
+
+ `under ${CACHE_DIR}`,
|
|
333
|
+
));
|
|
334
|
+
}
|
|
335
|
+
finish(resolve, undefined);
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// The daily update path's install: the same one above, with nobody listening.
|
|
341
|
+
//
|
|
342
|
+
// It has no shared promise and no `_lastInstallError` on purpose. This runs
|
|
343
|
+
// behind a ccusage that already works, so a failed upgrade is not news the
|
|
344
|
+
// modal should lead with — the copy on disk still answers, and the check comes
|
|
345
|
+
// round again tomorrow.
|
|
346
|
+
function installInBackground(spec = "latest") {
|
|
347
|
+
install(spec).catch(() => { /* best-effort */ });
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// True at most once per UPDATE_CHECK_MS, gated by the marker file's mtime so the
|
|
351
|
+
// throttle survives restarts.
|
|
352
|
+
function updateCheckDue() {
|
|
353
|
+
try {
|
|
354
|
+
return Date.now() - statSync(MARKER).mtimeMs > UPDATE_CHECK_MS;
|
|
355
|
+
} catch {
|
|
356
|
+
return true; // no marker yet → due
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
// (Re)write the marker so its mtime marks "now" as the last check time.
|
|
360
|
+
export function touchMarker() {
|
|
361
|
+
try {
|
|
362
|
+
mkdirSync(CACHE_DIR, { recursive: true });
|
|
363
|
+
writeFileSync(MARKER, String(Date.now()));
|
|
364
|
+
} catch { /* ignore */ }
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Whether the daily check is due: this process has not checked yet, and the
|
|
368
|
+
* marker says the day is up. maybeBackgroundUpdate asks it before going to
|
|
369
|
+
* npm, and primeCcusage asks the same question to report `updating`, so the
|
|
370
|
+
* two cannot disagree about what the boot row says is happening. */
|
|
371
|
+
export function backgroundUpdateDue() {
|
|
372
|
+
return !_checkedThisRun && updateCheckDue();
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Non-blocking: compare installed version to npm `latest`; install if newer.
|
|
376
|
+
export function maybeBackgroundUpdate(installedVersion) {
|
|
377
|
+
if (installsDisabled()) return; // no `npm view`, no upgrade install
|
|
378
|
+
if (!backgroundUpdateDue()) return;
|
|
379
|
+
_checkedThisRun = true;
|
|
380
|
+
touchMarker();
|
|
381
|
+
try {
|
|
382
|
+
const { file, args, opts } = npmSpec(["view", "ccusage", "version"]);
|
|
383
|
+
const child = spawn(file, args, { windowsHide: true, ...opts });
|
|
384
|
+
let out = "";
|
|
385
|
+
child.stdout.on("data", d => { out += d; });
|
|
386
|
+
child.on("error", () => {});
|
|
387
|
+
child.on("close", () => {
|
|
388
|
+
const latest = out.trim();
|
|
389
|
+
if (latest && latest !== installedVersion) installInBackground(latest);
|
|
390
|
+
});
|
|
391
|
+
} catch { /* ignore */ }
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// At most one repair per process, so a package that is simply unrunnable —
|
|
395
|
+
// reinstalled and still broken — cannot put this into a loop of npm installs.
|
|
396
|
+
let _repairedThisRun = false;
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Throw away a managed install that resolves but cannot run, so the next
|
|
400
|
+
* attempt builds a new one.
|
|
401
|
+
*
|
|
402
|
+
* This is the only genuinely permanent failure in this module, and it is the
|
|
403
|
+
* one a broken npm creates: `npm install` that dies partway leaves a
|
|
404
|
+
* package.json and an entry file on disk, resolveEntry answers "installed", and
|
|
405
|
+
* getRunner then hands back that entry FOREVER. Nothing re-checks it —
|
|
406
|
+
* maybeBackgroundUpdate only reinstalls when the registry has a newer version,
|
|
407
|
+
* so even a repaired npm never repaired the install. Every run failed
|
|
408
|
+
* identically, the modal said "try again", and the only thing that actually
|
|
409
|
+
* worked was deleting ~/.agents-deck/ccusage by hand, which nothing in the
|
|
410
|
+
* product tells anyone to do.
|
|
411
|
+
*
|
|
412
|
+
* Only the managed branch qualifies: the npx fallback's failures are npm's own
|
|
413
|
+
* and none of this deck's business to delete anything over.
|
|
414
|
+
*
|
|
415
|
+
* Not done under AGENTS_DECK_NO_INSTALL=1. That variable is a promise not to
|
|
416
|
+
* fetch, and removing the only copy on a machine that cannot replace it would
|
|
417
|
+
* turn a broken feature into an absent one.
|
|
418
|
+
*/
|
|
419
|
+
export function discardDamagedInstall(runner, err) {
|
|
420
|
+
if (_repairedThisRun || runner.kind !== "node" || installsDisabled()) return false;
|
|
421
|
+
if (!cannotLoadModule(err?.message)) return false;
|
|
422
|
+
// THE FLAG IS SPENT ON A REPAIR THAT HAPPENED, not on one that was attempted
|
|
423
|
+
// (#790). It used to be set here, before the try — and the rm below fails on
|
|
424
|
+
// Windows for the reason its own maxRetries comment gives: the `node <entry>`
|
|
425
|
+
// child that just exited still holds a handle, the unlink marks the file
|
|
426
|
+
// delete-pending, and rmdir answers ENOTEMPTY past all ten retries. The catch
|
|
427
|
+
// returned false with NOTHING removed, and every later modal open and every
|
|
428
|
+
// 60s poll for the life of the deck then short-circuited on this same flag —
|
|
429
|
+
// including seconds later, once the handle was gone and the rm would have
|
|
430
|
+
// worked. The user's only way out was deleting ~/.agents-deck/ccusage by
|
|
431
|
+
// hand, which nothing tells them.
|
|
432
|
+
//
|
|
433
|
+
// The budget exists to stop a loop of INSTALLS. Here it was being consumed by
|
|
434
|
+
// a repair that never happened and never reached an install.
|
|
435
|
+
try {
|
|
436
|
+
// maxRetries because of what has just happened: the deck ran `node <entry>`
|
|
437
|
+
// out of this very directory a moment ago, and on Windows a file any handle
|
|
438
|
+
// still holds cannot be deleted — the unlink marks it delete-pending, the
|
|
439
|
+
// name stays, and rmdir on the parent answers ENOTEMPTY. A child still
|
|
440
|
+
// exiting is exactly that handle. Node retries EBUSY, EMFILE, ENFILE,
|
|
441
|
+
// ENOTEMPTY and EPERM with a linear backoff when asked to; unasked, it
|
|
442
|
+
// tries once and gives up, which turned a repairable install into the
|
|
443
|
+
// permanent failure below.
|
|
444
|
+
rmSync(PKG_DIR, { recursive: true, force: true, maxRetries: 10, retryDelay: 25 });
|
|
445
|
+
} catch {
|
|
446
|
+
// Windows holds a lock on a file inside a directory being removed more
|
|
447
|
+
// readily than POSIX does, and a half-removed install is still a resolvable
|
|
448
|
+
// one. Say the repair did not happen so the caller reports the real failure
|
|
449
|
+
// rather than retrying into the same broken entry point.
|
|
450
|
+
return false;
|
|
451
|
+
}
|
|
452
|
+
const gone = !resolveEntry();
|
|
453
|
+
// Only now. The directory is really gone, so this deck has spent its one
|
|
454
|
+
// repair and the budget is doing its job. A failed `rm` returned false above
|
|
455
|
+
// without touching the flag, so the next poll — seconds later, once the
|
|
456
|
+
// handle is released — is free to try again.
|
|
457
|
+
if (gone) _repairedThisRun = true;
|
|
458
|
+
return gone;
|
|
459
|
+
}
|