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,352 @@
|
|
|
1
|
+
// The in-app upgrade: whether this copy may install over itself, and the
|
|
2
|
+
// `npm i -g` that does it.
|
|
3
|
+
//
|
|
4
|
+
// This lived in src/server/self-update.mjs: the policy (upgradeSpec,
|
|
5
|
+
// upgradeBlockedReason, upgradeMode and the filesystem half, upgradeBlock) and
|
|
6
|
+
// the install itself (startUpgrade, the state it reports, and the line of npm's
|
|
7
|
+
// log the banner shows). It is the only part of the self-update code that
|
|
8
|
+
// writes to the user's machine, and it moved here whole. self-update.mjs asks it why an
|
|
9
|
+
// upgrade is refused and how the last one went for the version report, and
|
|
10
|
+
// re-exports every name, so index.mjs still reaches startUpgrade there. The
|
|
11
|
+
// code is unchanged.
|
|
12
|
+
import { inApp } from "./app-host.mjs";
|
|
13
|
+
import { accessSync, constants as FS, unlinkSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import { join, resolve } from "node:path";
|
|
16
|
+
import { killTree, shimPath, spawnSpec } from "./exec.mjs";
|
|
17
|
+
import {
|
|
18
|
+
frozenNameInstall, hostPackage, installedName, isGitCheckout, isNpxInstall,
|
|
19
|
+
PUBLISHED_NAME, successorRoot, upgradeCommand, upgradeName,
|
|
20
|
+
} from "./install-layout.mjs";
|
|
21
|
+
|
|
22
|
+
// ── installing ───────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
const INSTALL_TIMEOUT_MS = 300_000; // a cold global install on a slow line
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* What `spawn` gets for `npm install -g <target>@latest`.
|
|
28
|
+
*
|
|
29
|
+
* This was the last caller still spelling it the way #362 and #456 were written
|
|
30
|
+
* to remove: `spawn("npm.cmd", args, { shell: true })`, with a comment claiming
|
|
31
|
+
* it was "the same pair the ccusage installer uses". ccusage stopped using that
|
|
32
|
+
* pair when #456 fixed it, and this one was never revisited — it does not go
|
|
33
|
+
* through `run`/`runInteractive`/`runDetached`, so #457's sweep of their callers
|
|
34
|
+
* could not see it and exec-shim-callers.test.ts never listed it.
|
|
35
|
+
*
|
|
36
|
+
* Both halves of the old spelling were wrong on Windows and only one of them
|
|
37
|
+
* bites today.
|
|
38
|
+
*
|
|
39
|
+
* The one that bites: a `.cmd` shim locates its payload relative to `%~dp0`,
|
|
40
|
+
* the drive and path of the command token cmd.exe was handed, and a BARE
|
|
41
|
+
* `npm.cmd` carries no directory — so `%~dp0` came out as the deck's working
|
|
42
|
+
* directory and npm's shim went looking for `node_modules\npm\bin\npm-cli.js`
|
|
43
|
+
* underneath it. A user with a global install, deck started from
|
|
44
|
+
* `C:\Users\vceban`, clicks Update now and npm dies with `Cannot find module
|
|
45
|
+
* 'C:\Users\vceban\node_modules\npm\bin\npm-cli.js'` — on a machine whose npm
|
|
46
|
+
* is perfectly healthy, and where typing the same command at the same prompt
|
|
47
|
+
* works. shimPath is the answer, and `?? "npm.cmd"` keeps a layout it cannot see
|
|
48
|
+
* exactly as well off as it was.
|
|
49
|
+
*
|
|
50
|
+
* The one that does not, yet: `shell: true` makes Node join file and args with
|
|
51
|
+
* single spaces and no quoting. These arguments contain no spaces, so it has
|
|
52
|
+
* never mattered here — but it is the #362 defect sitting one argument away, and
|
|
53
|
+
* spawnSpec removes it by quoting every token into the cmd.exe line.
|
|
54
|
+
*
|
|
55
|
+
* POSIX is untouched: `npm` there is a real executable, isBatch is false, and
|
|
56
|
+
* the vector goes to spawn exactly as it always has.
|
|
57
|
+
*
|
|
58
|
+
* Exported for tests: the platform is a parameter so the Windows command line
|
|
59
|
+
* can be checked from any OS, and `deps` stands in for the Windows filesystem
|
|
60
|
+
* the shim lookup asks about. The same shape ccusage.mjs's installSpec has.
|
|
61
|
+
*/
|
|
62
|
+
export function upgradeSpec(target, platform = process.platform, deps) {
|
|
63
|
+
const args = ["install", "-g", `${target}@latest`, "--no-audit", "--no-fund", "--loglevel", "error"];
|
|
64
|
+
const file = platform === "win32" ? (shimPath("npm.cmd", deps) ?? "npm.cmd") : "npm";
|
|
65
|
+
return { ...spawnSpec(file, args, platform), plain: args };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Why an in-app upgrade would be wrong here, or null when it is fine.
|
|
70
|
+
*
|
|
71
|
+
* Pure so the policy can be read and tested on its own — it is the part that
|
|
72
|
+
* decides whether we are allowed to write to the user's machine.
|
|
73
|
+
*/
|
|
74
|
+
export function upgradeBlockedReason({ git, npx, writable, optedOut, frozen }) {
|
|
75
|
+
if (optedOut) return "opted_out";
|
|
76
|
+
// The maintainer's own tree. Its version leads npm's, and installing over it
|
|
77
|
+
// would replace a working copy with a published tarball.
|
|
78
|
+
if (git) return "git_checkout";
|
|
79
|
+
// npx runs from a content-addressed cache directory that is never upgraded in
|
|
80
|
+
// place — `npx agents-deck@latest` fetches a DIFFERENT directory, which this
|
|
81
|
+
// process could not switch to even after restarting.
|
|
82
|
+
if (npx) return "npx";
|
|
83
|
+
// A flat install of a retired name: the one shape where `npm i -g <us>@latest`
|
|
84
|
+
// runs cleanly, reports success, and leaves no deck behind — the registry
|
|
85
|
+
// serves a 5 KB pointer for those names now, and npm's reify removes bin/,
|
|
86
|
+
// src/ and hook/ to make room for it. Every other refusal here is "this
|
|
87
|
+
// install cannot be written over"; this one is "what the write would fetch is
|
|
88
|
+
// not a deck", so it is asked before writability rather than after: on a
|
|
89
|
+
// prefix nobody can write, the remedy is still a different command, and
|
|
90
|
+
// naming the wrong one is what #975 is about.
|
|
91
|
+
if (frozen) return "retired_name";
|
|
92
|
+
// Almost always a root-owned global prefix. Failing inside npm with EACCES
|
|
93
|
+
// tells the user less than declining up front does.
|
|
94
|
+
if (!writable) return "not_writable";
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* How this copy can update itself, if it can at all.
|
|
100
|
+
*
|
|
101
|
+
* "install" — `npm i -g` here and restart into the new files.
|
|
102
|
+
* "npx" — nothing to install: the supervisor re-runs `npx -y <spec>`,
|
|
103
|
+
* which fetches a NEW cache directory and hands the port to it.
|
|
104
|
+
* null — a checkout, an unwritable prefix, or an explicit opt-out; the
|
|
105
|
+
* user gets the command and does it themselves.
|
|
106
|
+
*
|
|
107
|
+
* Pure, so the policy is one readable expression rather than three conditions
|
|
108
|
+
* spread across the server and the UI.
|
|
109
|
+
*/
|
|
110
|
+
export function upgradeMode(blockedReason) {
|
|
111
|
+
if (blockedReason === null || blockedReason === undefined) return "install";
|
|
112
|
+
return blockedReason === "npx" ? "npx" : null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Can this user write into `p`?
|
|
117
|
+
*
|
|
118
|
+
* ON WINDOWS THE QUESTION HAS TO BE ASKED BY WRITING (#795). libuv's
|
|
119
|
+
* `fs__access` short-circuits to success for anything carrying
|
|
120
|
+
* FILE_ATTRIBUTE_DIRECTORY — "Directories cannot be read-only on Windows" — and
|
|
121
|
+
* never consults the ACL; Node documents this. Both arguments this is called
|
|
122
|
+
* with are always directories, so `writable` was unconditionally true there,
|
|
123
|
+
* `upgradeBlockedReason` could never answer `not_writable`, and the banner
|
|
124
|
+
* offered "Update & restart" on the common nvm-windows layout where the global
|
|
125
|
+
* prefix is C:\Program Files\nodejs and a non-elevated shell cannot write it.
|
|
126
|
+
* npm then died with EPERM and the user got a truncated npm log tail in a
|
|
127
|
+
* 46-character box — where a POSIX user in the same position gets "the install
|
|
128
|
+
* directory is not writable by this user" and the command to paste. The comment
|
|
129
|
+
* on upgradeBlockedReason states the intent the platform defeated: "Failing
|
|
130
|
+
* inside npm with EACCES tells the user less than declining up front does."
|
|
131
|
+
*
|
|
132
|
+
* The probe is `createTemp`'s "wx" pattern, synchronously: a name nothing else
|
|
133
|
+
* can be using, created exclusively and removed at once. POSIX keeps
|
|
134
|
+
* `accessSync`, where it is correct and cheaper.
|
|
135
|
+
*/
|
|
136
|
+
function dirWritable(p) {
|
|
137
|
+
if (process.platform !== "win32") {
|
|
138
|
+
try { accessSync(p, FS.W_OK); return true; } catch { return false; }
|
|
139
|
+
}
|
|
140
|
+
// `wx` fails if the name exists, so a collision reads as "not writable"
|
|
141
|
+
// rather than clobbering somebody's file — hence pid and a random suffix.
|
|
142
|
+
const probe = join(p, `.ccdeck-w-${process.pid}-${Math.random().toString(36).slice(2, 8)}`);
|
|
143
|
+
try {
|
|
144
|
+
writeFileSync(probe, "", { flag: "wx" });
|
|
145
|
+
return true;
|
|
146
|
+
} catch {
|
|
147
|
+
return false;
|
|
148
|
+
} finally {
|
|
149
|
+
try { unlinkSync(probe); } catch { /* never created, or already gone */ }
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The same question, answered against the real filesystem and environment. */
|
|
154
|
+
export function upgradeBlock(pkgRoot) {
|
|
155
|
+
// Whatever `npm i -g` would rewrite is what has to be writable, and under the
|
|
156
|
+
// stub layout that is not this directory: `npm i -g ccdeck` replaces
|
|
157
|
+
// <prefix>/lib/node_modules/ccdeck, and the copy running out of its nested
|
|
158
|
+
// node_modules goes with it. In practice both pairs answer the same, since
|
|
159
|
+
// one npm install created them with one owner — but the question is about the
|
|
160
|
+
// tree the command touches, and that tree is the host's.
|
|
161
|
+
//
|
|
162
|
+
// Asked with this build's own name rather than with the default, for the same
|
|
163
|
+
// reason upgradeName is: the host is recognised by the dependency it declares
|
|
164
|
+
// on us, and "us" is whatever the manifest here says — `agents-deck` under the
|
|
165
|
+
// stub npm publishes today, but nothing in this rule should assume that.
|
|
166
|
+
//
|
|
167
|
+
// successorRoot covers the case after such an upgrade has run: pkgRoot is
|
|
168
|
+
// gone, so hostPackage cannot recognise a host that no longer declares us,
|
|
169
|
+
// and asking accessSync about a deleted directory answers ENOENT — which this
|
|
170
|
+
// function reported as `not_writable`, telling the user their npm prefix was
|
|
171
|
+
// read-only when it was not.
|
|
172
|
+
const target = hostPackage(pkgRoot, installedName(pkgRoot))?.root
|
|
173
|
+
?? successorRoot(pkgRoot)
|
|
174
|
+
?? pkgRoot;
|
|
175
|
+
return upgradeBlockedReason({
|
|
176
|
+
git: isGitCheckout(pkgRoot),
|
|
177
|
+
npx: isNpxInstall(pkgRoot),
|
|
178
|
+
// npm -g rewrites the package directory and its parent (the global
|
|
179
|
+
// node_modules), so both have to be ours to write.
|
|
180
|
+
writable: dirWritable(target) && dirWritable(resolve(target, "..")),
|
|
181
|
+
// Inside the desktop app the app updates itself; an `npm i -g` here would
|
|
182
|
+
// install a copy it never runs (app-host.mjs).
|
|
183
|
+
optedOut: process.env.AGENTS_DECK_NO_INSTALL === "1" || inApp(),
|
|
184
|
+
// Whether the package that write would fetch is still a deck. Kept out of
|
|
185
|
+
// the pure rule above, like every other input here, so the policy stays one
|
|
186
|
+
// readable expression and this file owns the filesystem half of it.
|
|
187
|
+
frozen: frozenNameInstall(pkgRoot) !== null,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// One install at a time, per process. State is deliberately coarse: the UI only
|
|
192
|
+
// needs to know whether to show a spinner, a version, or an error.
|
|
193
|
+
let _upgrade = { state: "idle", command: null, error: null, at: 0 };
|
|
194
|
+
|
|
195
|
+
/** Where this process's install stands: its state, the command it runs, and why
|
|
196
|
+
* it failed when it did, stamped with the moment it got there. Every change to
|
|
197
|
+
* the state goes through here, so no path can report one without its time. */
|
|
198
|
+
function setUpgrade(state, command, error = null) {
|
|
199
|
+
_upgrade = { state, command, error, at: Date.now() };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function upgradeStatus() {
|
|
203
|
+
return { ..._upgrade };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Start `npm i -g <name>@latest` in the background.
|
|
208
|
+
*
|
|
209
|
+
* Returns immediately with the accepted command, or a refusal. Never installs
|
|
210
|
+
* anything except this deck — under one of its own three published names, and
|
|
211
|
+
* never at a version the caller chose. The argument vector is fixed here, not
|
|
212
|
+
* assembled from request input: the only thing that varies is which alias, and
|
|
213
|
+
* that is read off the install on disk and confined to ALIAS_PACKAGES.
|
|
214
|
+
*/
|
|
215
|
+
export function startUpgrade({ pkgRoot, name = PUBLISHED_NAME }) {
|
|
216
|
+
if (_upgrade.state === "running") return { ok: true, already: true, command: _upgrade.command };
|
|
217
|
+
const blocked = upgradeBlock(pkgRoot);
|
|
218
|
+
if (blocked) return { ok: false, reason: blocked, command: upgradeCommand(pkgRoot, name) };
|
|
219
|
+
|
|
220
|
+
// The package this install can actually be replaced by — the stub for a
|
|
221
|
+
// `npm i -g ccdeck`, the published name everywhere else. Installing `name`
|
|
222
|
+
// there wrote a tree this process never reads, so the version on disk never
|
|
223
|
+
// moved and the same update was offered forever.
|
|
224
|
+
const target = upgradeName(pkgRoot, name);
|
|
225
|
+
const spec = upgradeSpec(target);
|
|
226
|
+
// The LOGICAL vector, not the cmd.exe line: this string is shown to the user
|
|
227
|
+
// and is the one they can paste. `cmd /d /s /c "…"` is an implementation
|
|
228
|
+
// detail of how this platform reaches npm, and pasting it would be advice
|
|
229
|
+
// about the deck rather than about their install.
|
|
230
|
+
const command = `npm ${spec.plain.join(" ")}`;
|
|
231
|
+
setUpgrade("running", command);
|
|
232
|
+
|
|
233
|
+
let child;
|
|
234
|
+
try {
|
|
235
|
+
child = spawn(spec.file, spec.args, { ...spec.opts, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
236
|
+
} catch (err) {
|
|
237
|
+
setUpgrade("failed", command, err?.message ?? String(err));
|
|
238
|
+
return { ok: false, reason: "spawn_failed", command };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Only the tail is kept: npm's failures put the useful line near the end, and
|
|
242
|
+
// a full buffer of an install log is not something the browser should hold.
|
|
243
|
+
let err = "";
|
|
244
|
+
const keepTail = (s) => { err = (err + s).slice(-4000); };
|
|
245
|
+
child.stdout.on("data", d => keepTail(String(d)));
|
|
246
|
+
child.stderr.on("data", d => keepTail(String(d)));
|
|
247
|
+
|
|
248
|
+
// The deadline states the outcome itself, and only then kills.
|
|
249
|
+
//
|
|
250
|
+
// npm is a .cmd shim on Windows and is therefore spawned through cmd.exe, so
|
|
251
|
+
// `child` is cmd.exe and npm itself is a grandchild — a plain kill would
|
|
252
|
+
// report the install as timed out while it carried on writing to
|
|
253
|
+
// node_modules. killTree is what reaches it (taskkill /T there, the same
|
|
254
|
+
// plain signal everywhere else).
|
|
255
|
+
//
|
|
256
|
+
// Leaving the verdict to 'close' was the other half of the same bug. 'close'
|
|
257
|
+
// waits for the stdio pipes, which the grandchild inherited, so on Windows it
|
|
258
|
+
// could arrive minutes after the deadline — or never, if the tree kill could
|
|
259
|
+
// not run at all — and until it did, /api/version kept reporting
|
|
260
|
+
// `state: "running"` with the UI spinning on "installing…" and the guard at
|
|
261
|
+
// the top of this function refusing every retry. When it finally arrived it
|
|
262
|
+
// carried the killed wrapper's status, so a five-minute timeout was announced
|
|
263
|
+
// to the user as "npm exited null".
|
|
264
|
+
let timedOut = false;
|
|
265
|
+
const timer = setTimeout(() => {
|
|
266
|
+
timedOut = true;
|
|
267
|
+
setUpgrade("failed", command, `timed out after ${Math.round(INSTALL_TIMEOUT_MS / 60_000)} minutes`);
|
|
268
|
+
killTree(child);
|
|
269
|
+
}, INSTALL_TIMEOUT_MS);
|
|
270
|
+
timer.unref?.();
|
|
271
|
+
|
|
272
|
+
child.on("error", (e) => {
|
|
273
|
+
clearTimeout(timer);
|
|
274
|
+
// A kill can make the child emit one of these; whatever it says, the reason
|
|
275
|
+
// this install failed is the deadline that has already been reported.
|
|
276
|
+
if (timedOut) return;
|
|
277
|
+
setUpgrade("failed", command, e?.message ?? String(e));
|
|
278
|
+
});
|
|
279
|
+
child.on("close", (code) => {
|
|
280
|
+
clearTimeout(timer);
|
|
281
|
+
if (code === 0) {
|
|
282
|
+
// A clean exit is a real install even if it lands after the deadline —
|
|
283
|
+
// npm finishing in the same breath as the timer is the one case where the
|
|
284
|
+
// files on disk disagree with the verdict above, and the files win.
|
|
285
|
+
//
|
|
286
|
+
// Deliberately does not restart anything. The new files on disk make
|
|
287
|
+
// installedVersion() disagree with the running one, and the ordinary
|
|
288
|
+
// drift path takes it from there — including its wait for an idle moment.
|
|
289
|
+
setUpgrade("done", command);
|
|
290
|
+
} else if (!timedOut && _upgrade?.state !== "failed") {
|
|
291
|
+
// Not over a failure the 'error' handler already explained. A missing npm
|
|
292
|
+
// emits 'error' with ENOENT and THEN 'close' with a null code, and this
|
|
293
|
+
// branch used to replace "spawn npm ENOENT" with "npm exited -2" — the
|
|
294
|
+
// one message that says what is wrong, overwritten by the one that does
|
|
295
|
+
// not.
|
|
296
|
+
setUpgrade("failed", command, lastMeaningfulLine(err) || `npm exited ${code}`);
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
return { ok: true, command };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// The furniture around a Node crash, none of which is the reason anything
|
|
304
|
+
// failed. npm's own log needs none of this — it is `npm ERR!` lines and a rule —
|
|
305
|
+
// but the two get mixed the moment the thing npm's shim tried to load is
|
|
306
|
+
// missing, which is what #535's bare `npm.cmd` produced on every attempt.
|
|
307
|
+
const CRASH_NOISE = [
|
|
308
|
+
/^\s*at\s/, // stack frames
|
|
309
|
+
/^\s*\^+\s*$/, // the caret under the throw
|
|
310
|
+
/^node:internal\//, // the frame node leads with
|
|
311
|
+
/^\s*throw\s/,
|
|
312
|
+
/^Node\.js v/, // the last line, and the one that was quoted
|
|
313
|
+
/^\s*[{}]\s*$/, // the error object's braces
|
|
314
|
+
/^\s*(code|errno|syscall|path|requireStack|stack):/,
|
|
315
|
+
/^Require stack:/,
|
|
316
|
+
/^-+$/,
|
|
317
|
+
/^A complete log/,
|
|
318
|
+
];
|
|
319
|
+
|
|
320
|
+
/** npm's real complaint, for the banner: the last line that is not furniture.
|
|
321
|
+
*
|
|
322
|
+
* Last rather than first, and that is the whole reason this is not npx.mjs's
|
|
323
|
+
* summariser under another name. The two read different documents. npm's log
|
|
324
|
+
* opens with its codes — `code EACCES`, `syscall mkdir` — and ends with the
|
|
325
|
+
* sentence a person can act on, so the last line wins. An npx failure is a Node
|
|
326
|
+
* crash dump, which opens with the sentence and ends with a stack, a brace and
|
|
327
|
+
* a version banner, so the first signal line wins there. Sharing one function
|
|
328
|
+
* would mean picking one of those and being wrong about the other half the
|
|
329
|
+
* time; sharing the NOISE list would be the copy this comment exists instead
|
|
330
|
+
* of.
|
|
331
|
+
*
|
|
332
|
+
* What was wrong was not the rule but its list. `lastMeaningfulLine` dropped
|
|
333
|
+
* `npm ERR!` prefixes, rules and "A complete log", and nothing else — so when
|
|
334
|
+
* #535's spawn failed with a MODULE_NOT_FOUND dump, the last surviving line was
|
|
335
|
+
* `Node.js v22.11.0`, and that string was the entire explanation the UI gave
|
|
336
|
+
* for a failed upgrade. */
|
|
337
|
+
export function lastMeaningfulLine(text) {
|
|
338
|
+
const lines = String(text ?? "").split(/\r?\n/)
|
|
339
|
+
.map(l => l.replace(/^npm (ERR!|WARN)\s*/, "").trim())
|
|
340
|
+
.filter(l => l && !CRASH_NOISE.some(re => re.test(l)));
|
|
341
|
+
if (!lines.length) return "";
|
|
342
|
+
// A line that names an error outranks a later line that does not, because
|
|
343
|
+
// what survives the filter after one is usually its context rather than its
|
|
344
|
+
// successor: `Require stack:` is furniture, but the paths listed under it are
|
|
345
|
+
// not shaped like furniture and would otherwise be the last thing standing.
|
|
346
|
+
// Still the LAST such line, not the first — npm's own log builds up to its
|
|
347
|
+
// sentence, and picking the first would answer `code EACCES` where the next
|
|
348
|
+
// line says which directory and why.
|
|
349
|
+
const named = lines.filter(l => /(^|\s)[A-Za-z]*Error:/.test(l));
|
|
350
|
+
const pick = named.length ? named[named.length - 1] : lines[lines.length - 1];
|
|
351
|
+
return pick.slice(0, 300);
|
|
352
|
+
}
|
package/src/server/npx.mjs
CHANGED
|
@@ -51,7 +51,7 @@ import { killTree, shimPath, spawnSpec } from "./exec.mjs";
|
|
|
51
51
|
* pick something that is not npm.
|
|
52
52
|
*
|
|
53
53
|
* The path arithmetic is done on the string rather than through `node:path`,
|
|
54
|
-
* for the same reason npxRoot in
|
|
54
|
+
* for the same reason npxRoot in install-layout.mjs does: `path` here is the
|
|
55
55
|
* platform running the SUITE, so a Windows layout checked from macOS would come
|
|
56
56
|
* back with forward slashes and a `..` nothing resolves.
|
|
57
57
|
*/
|
|
@@ -134,6 +134,41 @@ export function npxPrefetchArgs(spec) {
|
|
|
134
134
|
return ["-y", "--package", spec, "--call", "exit 0"];
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
/** How much of an npx run's output is held as evidence. The END of it, because
|
|
138
|
+
* npm and Node both put the sentence worth quoting on their last lines. */
|
|
139
|
+
const OUTPUT_TAIL_CHARS = 8000;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* An npx run's output, held as evidence for a failure that may not happen.
|
|
143
|
+
*
|
|
144
|
+
* `take` is the stream's data listener: each chunk is added to what is held,
|
|
145
|
+
* and only the last OUTPUT_TAIL_CHARS of it are kept. `release` is for a run
|
|
146
|
+
* that turned out to be the deck after all — what was held is written through
|
|
147
|
+
* `write`, and every later chunk goes straight there instead of being kept.
|
|
148
|
+
* `tail` is what was held, which is the text npxFailureSummary reads.
|
|
149
|
+
*
|
|
150
|
+
* The two places that hold an npx run's output kept their own copy of the
|
|
151
|
+
* trim, and the supervisor its own pair of variables for the hand-over; this
|
|
152
|
+
* is both, once.
|
|
153
|
+
*/
|
|
154
|
+
export function holdOutput({ write = null, limit = OUTPUT_TAIL_CHARS } = {}) {
|
|
155
|
+
let held = "";
|
|
156
|
+
let passing = false;
|
|
157
|
+
return {
|
|
158
|
+
take(d) {
|
|
159
|
+
const s = String(d);
|
|
160
|
+
if (passing) { write?.(s); return; }
|
|
161
|
+
held = (held + s).slice(-limit);
|
|
162
|
+
},
|
|
163
|
+
release() {
|
|
164
|
+
if (passing) return;
|
|
165
|
+
passing = true;
|
|
166
|
+
if (held) write?.(held);
|
|
167
|
+
},
|
|
168
|
+
get tail() { return held; },
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
137
172
|
/**
|
|
138
173
|
* Resolve, download and unpack `spec` while the current deck keeps serving.
|
|
139
174
|
*
|
|
@@ -169,13 +204,12 @@ export function npxPrefetch(spec, {
|
|
|
169
204
|
}
|
|
170
205
|
onChild?.(child);
|
|
171
206
|
|
|
172
|
-
|
|
173
|
-
const keep = (d) => { tail = (tail + String(d)).slice(-8000); };
|
|
207
|
+
const output = holdOutput();
|
|
174
208
|
// Both streams, one buffer: npm writes its errors to stderr and its
|
|
175
209
|
// resolution notices to stdout, and which of the two carries the sentence
|
|
176
210
|
// worth quoting depends on the failure.
|
|
177
|
-
child.stdout?.on("data",
|
|
178
|
-
child.stderr?.on("data",
|
|
211
|
+
child.stdout?.on("data", output.take);
|
|
212
|
+
child.stderr?.on("data", output.take);
|
|
179
213
|
|
|
180
214
|
let settled = false;
|
|
181
215
|
const settle = (result) => {
|
|
@@ -203,8 +237,8 @@ export function npxPrefetch(spec, {
|
|
|
203
237
|
if (code === 0) { settle({ ok: true, error: null, hint: null }); return; }
|
|
204
238
|
settle({
|
|
205
239
|
ok: false,
|
|
206
|
-
error: npxFailureSummary(tail) ?? `npx could not fetch ${spec} — exited ${code ?? signal}`,
|
|
207
|
-
hint: npxFailureHint(tail),
|
|
240
|
+
error: npxFailureSummary(output.tail) ?? `npx could not fetch ${spec} — exited ${code ?? signal}`,
|
|
241
|
+
hint: npxFailureHint(output.tail),
|
|
208
242
|
});
|
|
209
243
|
});
|
|
210
244
|
});
|
|
@@ -249,10 +249,11 @@ export function createOutputWatch(io = {}) {
|
|
|
249
249
|
* nothing more unusual than several live sessions on a slow or
|
|
250
250
|
* network-backed transcript directory.
|
|
251
251
|
*
|
|
252
|
-
* Its two siblings
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
252
|
+
* Its two siblings already refused re-entry, both by handing a second caller
|
|
253
|
+
* the one already in flight: scanTranscript in transcript-scan.mjs its read,
|
|
254
|
+
* and codexScanOnce in codex-watch.mjs its scan (a boolean until #994 needed
|
|
255
|
+
* a scan the suite could await). That shape is wrong here — a caller handed the same answer
|
|
256
|
+
* would push the same blocks again — so this is a boolean. An overlapping
|
|
256
257
|
* call answers with nothing and the call already inside answers for both.
|
|
257
258
|
* Nothing is lost by it: whatever lands meanwhile is past the offset the
|
|
258
259
|
* first call writes back, and the next tick reads it. It sits here rather
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// The build this process runs, held in memory before anything can replace it
|
|
2
|
+
// on disk (#1042).
|
|
3
|
+
//
|
|
4
|
+
// This lived in src/server/index.mjs, after the shutdown route. It is the one
|
|
5
|
+
// thing an install has to wait for — the Upgrade press and the away-update
|
|
6
|
+
// both await it before npm is spawned, and the launcher starts it once the
|
|
7
|
+
// boot is over — and none of it touches the server's state, so it moved to a
|
|
8
|
+
// leaf those callers can share. The list and the loader are unchanged.
|
|
9
|
+
import { dirname, join, resolve } from "node:path";
|
|
10
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
11
|
+
|
|
12
|
+
// Resolved from this file's own directory, the way every module in src/server
|
|
13
|
+
// that imports by URL resolves it, so every URL below is the one the lazy
|
|
14
|
+
// imports in src/server name.
|
|
15
|
+
const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Every server module this process reaches only through `import()`, and so
|
|
19
|
+
* reads off disk the first time it is wanted rather than at boot.
|
|
20
|
+
*
|
|
21
|
+
* THAT IS WHAT A SELF-UPDATE BROKE (#1042). Node caches an evaluated module by
|
|
22
|
+
* its URL for the life of the process, so everything already loaded keeps the
|
|
23
|
+
* code it booted with — which is the whole of why startUpgrade can leave this
|
|
24
|
+
* process serving and let the drift path restart it at an idle moment. A module
|
|
25
|
+
* NOT yet loaded had no such protection: PKG_ROOT is a fixed path, and
|
|
26
|
+
* `npm i -g` rewrites the tree under it. So the first look at the accounts panel
|
|
27
|
+
* after an install evaluated the NEW cswap-admin.mjs beside the OLD
|
|
28
|
+
* claude-accounts.mjs and exec.mjs — a mixed build nobody has ever run — and
|
|
29
|
+
* while npm was mid-reify the same `import()` found no file at all, which
|
|
30
|
+
* lan-engine's roundWith catches and files as that PEER's failed round: a
|
|
31
|
+
* credential sync that did not happen, recorded against the other machine
|
|
32
|
+
* rather than against the install. The window is not short. The idle
|
|
33
|
+
* restart waits on presence and activity, and with autoUpdate off it never
|
|
34
|
+
* comes.
|
|
35
|
+
*
|
|
36
|
+
* Reproduced with the tree swap done in a temp copy of the package: after
|
|
37
|
+
* `POST /api/upgrade`, every one of /api/codex-usage, /api/codex-quota,
|
|
38
|
+
* /api/browser-watch and /api/claude-accounts/login answered 500, and each one
|
|
39
|
+
* had evaluated a module out of the tree npm had just written.
|
|
40
|
+
*
|
|
41
|
+
* So the build is pinned instead. Each module below is imported once, after
|
|
42
|
+
* the boot (markDeckReady) and before any install can start (handleUpgrade and
|
|
43
|
+
* awayUpdateTick both await it), and every lazy import in src/server is a cache
|
|
44
|
+
* hit from then on; their own static imports come with them. Measured, the
|
|
45
|
+
* whole list costs about 10ms and 8MB of RSS, and none of it does any work at
|
|
46
|
+
* import — nothing is spawned, read or written — so the laziness still does
|
|
47
|
+
* what it was for, which is keeping all of this off the path between
|
|
48
|
+
* `npx ccdeck` and a listening socket.
|
|
49
|
+
*
|
|
50
|
+
* The list is every local module that an `import()` in src/server names, and
|
|
51
|
+
* self-update-pins-build.test.ts reads the directory to keep it that way: a lazy
|
|
52
|
+
* import added later without a line here is this bug again.
|
|
53
|
+
*/
|
|
54
|
+
const PINNED_MODULES = [
|
|
55
|
+
"self-update.mjs",
|
|
56
|
+
"claude-accounts.mjs",
|
|
57
|
+
"account-health.mjs",
|
|
58
|
+
"cswap-admin.mjs",
|
|
59
|
+
"cswap-auto.mjs",
|
|
60
|
+
"quota.mjs",
|
|
61
|
+
"codex-usage.mjs",
|
|
62
|
+
"codex-quota.mjs",
|
|
63
|
+
"ccusage.mjs",
|
|
64
|
+
"browser-watch.mjs",
|
|
65
|
+
"browser-watch-store.mjs",
|
|
66
|
+
"claude-fm.mjs",
|
|
67
|
+
"fm-station.mjs",
|
|
68
|
+
"lofi-girl.mjs",
|
|
69
|
+
"live-radio-mix.mjs",
|
|
70
|
+
"best-of-nostalgia.mjs",
|
|
71
|
+
"good-life-radio.mjs",
|
|
72
|
+
"cafe-music-bgm.mjs",
|
|
73
|
+
// The thermal readers' two (thermal-metrics.mjs imports both; macmon's
|
|
74
|
+
// download is still started from thermal-sampler.mjs), on the platforms that
|
|
75
|
+
// have them, and the one installer.mjs reaches for while it rewrites the hooks.
|
|
76
|
+
"macmon.mjs",
|
|
77
|
+
"hwmonitor.mjs",
|
|
78
|
+
"retire-sound-hook.mjs",
|
|
79
|
+
// The account-projects report: the rollup and the swap log its attribution
|
|
80
|
+
// reads, plus lan-sync for the account key — all reached only through
|
|
81
|
+
// import() from the report's route.
|
|
82
|
+
"account-projects.mjs",
|
|
83
|
+
"swap-log.mjs",
|
|
84
|
+
"lan-sync.mjs",
|
|
85
|
+
// The providers' status pages (#1311), reached only from their route.
|
|
86
|
+
"provider-status.mjs",
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
let _pinned = null;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Load every module in PINNED_MODULES, once per process.
|
|
93
|
+
*
|
|
94
|
+
* One at a time rather than all together. It costs nothing measurable, and it
|
|
95
|
+
* means no two of them are ever evaluating at once — the shape that turned an
|
|
96
|
+
* import cycle into an empty namespace (see boot-module-graph.test.ts).
|
|
97
|
+
*
|
|
98
|
+
* A module that fails to load is passed over rather than allowed to stop the
|
|
99
|
+
* rest: waiting will not make it load, and one bad file must not leave the
|
|
100
|
+
* others to be read off whatever npm writes next. The promise never rejects.
|
|
101
|
+
*/
|
|
102
|
+
export function pinRunningBuild() {
|
|
103
|
+
_pinned ??= (async () => {
|
|
104
|
+
for (const name of PINNED_MODULES) {
|
|
105
|
+
try { await import(pathToFileURL(join(PKG_ROOT, "src/server", name)).href); }
|
|
106
|
+
catch { /* see above */ }
|
|
107
|
+
}
|
|
108
|
+
})();
|
|
109
|
+
return _pinned;
|
|
110
|
+
}
|