ccdeck 1.43.0 → 1.44.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +208 -12
- package/bin/agent-dag.js +481 -0
- package/bin/deck.js +968 -0
- package/dist/web/assets/index-DBsxIfdM.js +78 -0
- package/dist/web/assets/index-XtT5NdJI.css +1 -0
- package/dist/web/index.html +49 -0
- package/hook/hook.js +407 -0
- package/hook/notify.mjs +104 -0
- package/package.json +42 -9
- package/src/server/args.mjs +58 -0
- package/src/server/brand.mjs +16 -0
- package/src/server/ccusage.mjs +1052 -0
- package/src/server/claude-accounts.mjs +653 -0
- package/src/server/claude-dir.mjs +213 -0
- package/src/server/codex-auth.mjs +383 -0
- package/src/server/codex-dir.mjs +171 -0
- package/src/server/codex-quota.mjs +445 -0
- package/src/server/codex-usage.mjs +353 -0
- package/src/server/cswap-admin.mjs +902 -0
- package/src/server/cswap-auto.mjs +441 -0
- package/src/server/cswap-install.mjs +583 -0
- package/src/server/exec.mjs +850 -0
- package/src/server/index.mjs +3479 -0
- package/src/server/installer.mjs +674 -0
- package/src/server/invoked-as.mjs +144 -0
- package/src/server/log-writer.mjs +283 -0
- package/src/server/npx.mjs +259 -0
- package/src/server/quota.mjs +684 -0
- package/src/server/reset-label.mjs +78 -0
- package/src/server/self-update.mjs +1331 -0
- package/src/server/sound-hook.mjs +518 -0
- package/src/server/supervisor.mjs +235 -0
- package/src/server/system-metrics.mjs +619 -0
- package/src/server/term.mjs +570 -0
- package/src/server/uv-bootstrap.mjs +301 -0
- package/bin/ccdeck.js +0 -194
package/bin/agent-dag.js
ADDED
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Supervisor. Owns one thing: the worker's lifecycle.
|
|
3
|
+
//
|
|
4
|
+
// Why this exists at all: Node caches every module at import, so a deck that is
|
|
5
|
+
// running when an upgrade lands keeps executing the old code until the process
|
|
6
|
+
// is replaced. The deck can now see that (GET /api/version) — this is the half
|
|
7
|
+
// that can act on it.
|
|
8
|
+
//
|
|
9
|
+
// The tempting shortcut is to have the server respawn itself and exit. Every
|
|
10
|
+
// version of that is worse than it looks:
|
|
11
|
+
// • the replacement races the dying listener, and startServer answers
|
|
12
|
+
// EADDRINUSE by binding one of ten RANDOM ports in 4318–4400 — so the tab
|
|
13
|
+
// you are looking at reconnects forever next to a healthy invisible server;
|
|
14
|
+
// • an orphan spawned from a dying parent leaves the shell's foreground
|
|
15
|
+
// process group, so Ctrl+C stops reaching it;
|
|
16
|
+
// • with stdio ignored it also loses the banner, the URL line and every
|
|
17
|
+
// console.error the server writes.
|
|
18
|
+
// A parent that stays alive avoids all three: the child is dead — and its
|
|
19
|
+
// listening socket released — before the next one is spawned, stdio is
|
|
20
|
+
// inherited so the terminal is unchanged, and Ctrl+C keeps working because the
|
|
21
|
+
// process group never changes.
|
|
22
|
+
//
|
|
23
|
+
// The upgrade path has one more constraint, learned the expensive way: the
|
|
24
|
+
// worker must not be torn down to find out whether an upgrade is possible. It
|
|
25
|
+
// used to be — the worker exited 76, and only then did npx discover it was
|
|
26
|
+
// offline — so every failed update cost a full outage and could be retried
|
|
27
|
+
// identically forever. Now the worker ASKS (an `upgrade` message), the fetch
|
|
28
|
+
// happens beside it while it keeps serving, and only a fetch that worked is
|
|
29
|
+
// answered with the exit that gives up the port. See prefetchUpgrade.
|
|
30
|
+
//
|
|
31
|
+
// Everything else the deck does still lives in bin/deck.js. This file must stay
|
|
32
|
+
// boring: it is the one process that is never replaced.
|
|
33
|
+
import { spawn } from "node:child_process";
|
|
34
|
+
import { existsSync } from "node:fs";
|
|
35
|
+
import { connect } from "node:net";
|
|
36
|
+
import { dirname, join } from "node:path";
|
|
37
|
+
import { fileURLToPath } from "node:url";
|
|
38
|
+
import { killTree } from "../src/server/exec.mjs";
|
|
39
|
+
import { invokedAs } from "../src/server/invoked-as.mjs";
|
|
40
|
+
import { npxFailureHint, npxFailureSummary, npxLaunch, npxPrefetch } from "../src/server/npx.mjs";
|
|
41
|
+
import {
|
|
42
|
+
bareSpecName, claimRestartFailureKey, clearRestartFailure, installedName, installedVersion,
|
|
43
|
+
lastKnownLatest, npxRestartSpec, readRestartFailure, recordRestartFailure, successorRoot,
|
|
44
|
+
} from "../src/server/self-update.mjs";
|
|
45
|
+
import { dieOfSignal, replacedNote, upgradeAttempt, upgradeRefusalText, workerExitAction } from "../src/server/supervisor.mjs";
|
|
46
|
+
import { colorProfile, glyphs, palette, unicodeOK } from "../src/server/term.mjs";
|
|
47
|
+
import { PRODUCT } from "../src/server/brand.mjs";
|
|
48
|
+
|
|
49
|
+
const BIN_DIR = dirname(fileURLToPath(import.meta.url));
|
|
50
|
+
const WORKER = join(BIN_DIR, "deck.js");
|
|
51
|
+
const PKG_ROOT = dirname(BIN_DIR);
|
|
52
|
+
|
|
53
|
+
const VERSION = installedVersion(PKG_ROOT) ?? "?";
|
|
54
|
+
|
|
55
|
+
// Which of the three published commands the user typed, when that is knowable.
|
|
56
|
+
// This process is the one the shell exec'd, so its argv[1] is the only place
|
|
57
|
+
// the answer exists — the worker's is `…/bin/deck.js` under every one of them —
|
|
58
|
+
// and launch() carries it down rather than letting the worker ask a question it
|
|
59
|
+
// cannot answer. Null wherever it cannot be proven, which is a Windows global
|
|
60
|
+
// install and a git checkout; see src/server/invoked-as.mjs for why those two
|
|
61
|
+
// have to stay silent rather than guess.
|
|
62
|
+
const INVOKED_AS = invokedAs({ pkgRoot: PKG_ROOT, argv1: process.argv[1], platform: process.platform });
|
|
63
|
+
|
|
64
|
+
// The supervisor prints exactly one line of its own — the fetch — and it has to
|
|
65
|
+
// look like it came from the same product as the worker's rows. That is all the
|
|
66
|
+
// presentation this file gets: it is the process that is never replaced, and a
|
|
67
|
+
// colour profile plus a glyph tier is the most it can carry without becoming
|
|
68
|
+
// something that can fail. Everything else it says is an error, on stderr, in
|
|
69
|
+
// plain text, where it belongs.
|
|
70
|
+
const P = palette(colorProfile({ isTTY: Boolean(process.stdout.isTTY) }));
|
|
71
|
+
const G = glyphs(unicodeOK());
|
|
72
|
+
|
|
73
|
+
// Who the restart-failure note below belongs to. Several decks of the same
|
|
74
|
+
// package run out of one home directory — two `npx ccdeck` runs even share the
|
|
75
|
+
// _npx directory and therefore the version — so a note named after the package
|
|
76
|
+
// alone was read by every one of them, and a deck that had never asked for an
|
|
77
|
+
// update reported someone else's failed npx as its own. Our pid is unique among
|
|
78
|
+
// the decks alive on the machine, and putting it in our own environment is what
|
|
79
|
+
// carries it to the worker: launch() spawns with a copy of it.
|
|
80
|
+
claimRestartFailureKey();
|
|
81
|
+
|
|
82
|
+
// The port the worker actually bound, which is not necessarily the one it was
|
|
83
|
+
// asked for — the first launch falls back to a random port when 4317 is taken.
|
|
84
|
+
// Re-launching without this is how a restart silently moves the deck out from
|
|
85
|
+
// under an open tab.
|
|
86
|
+
let boundPort = null;
|
|
87
|
+
let restarts = 0;
|
|
88
|
+
let child = null;
|
|
89
|
+
// The npx process fetching a replacement, while the worker above keeps serving.
|
|
90
|
+
// Held separately from `child` for exactly that reason: for the length of a
|
|
91
|
+
// fetch there are two of them, and only one is the deck.
|
|
92
|
+
let fetching = null;
|
|
93
|
+
// The upgrade this supervisor has committed to, from the moment the pre-flight
|
|
94
|
+
// allowed it until the replacement is serving or the attempt has been written
|
|
95
|
+
// off. giveUp runs long after the decision that permitted the attempt and has
|
|
96
|
+
// to record that decision's count, not a fresh one.
|
|
97
|
+
let attempting = null;
|
|
98
|
+
// Set by the signal handlers, and the first thing every exit path asks. Without
|
|
99
|
+
// it, Ctrl+C during an npx fetch looks exactly like a failed fetch, and a
|
|
100
|
+
// worker that was already exiting 75 or 76 when the signal landed reads as a
|
|
101
|
+
// restart request — both resurrect the deck the user just stopped.
|
|
102
|
+
let stopping = false;
|
|
103
|
+
|
|
104
|
+
function launch(respawn) {
|
|
105
|
+
// Asked on every spawn rather than once at boot: the deletion this catches
|
|
106
|
+
// happens while the deck is running, so a constant read at import time would
|
|
107
|
+
// be the one answer that cannot see it. See supervisor.mjs for the whole case.
|
|
108
|
+
const replaced = replacedNote({
|
|
109
|
+
workerExists: existsSync(WORKER),
|
|
110
|
+
moved: successorRoot(PKG_ROOT),
|
|
111
|
+
product: PRODUCT,
|
|
112
|
+
command: INVOKED_AS ?? PRODUCT,
|
|
113
|
+
});
|
|
114
|
+
if (replaced) {
|
|
115
|
+
console.error(replaced);
|
|
116
|
+
process.exit(1);
|
|
117
|
+
}
|
|
118
|
+
const args = [WORKER, ...process.argv.slice(2)];
|
|
119
|
+
// Appended last so it wins: the worker's parser keeps the final --port.
|
|
120
|
+
if (respawn && boundPort != null) args.push("--port", String(boundPort));
|
|
121
|
+
|
|
122
|
+
const worker = spawn(process.execPath, args, {
|
|
123
|
+
// stdio inherited so the child owns the same terminal the user started:
|
|
124
|
+
// same banner, same colours, same Ctrl+C. The fourth slot adds an IPC
|
|
125
|
+
// channel — the only way the worker can tell us which port it got, since
|
|
126
|
+
// parsing its stdout would be guesswork.
|
|
127
|
+
stdio: ["inherit", "inherit", "inherit", "ipc"],
|
|
128
|
+
env: {
|
|
129
|
+
...process.env,
|
|
130
|
+
// Boot did the slow, once-per-session work already (hook install, the
|
|
131
|
+
// claude-swap probe with its 8s timeout, the ccusage prime). Repeating it
|
|
132
|
+
// is what would make a restart feel like a restart.
|
|
133
|
+
AGENTS_DECK_RESPAWN: respawn ? "1" : "",
|
|
134
|
+
AGENTS_DECK_RESTARTS: String(restarts),
|
|
135
|
+
// Empty for "we could not tell", which knownCommand reads as unknown just
|
|
136
|
+
// like an absent one. Set on the worker's environment only: the npx
|
|
137
|
+
// relaunch below spawns a whole new supervisor, and that one has to ask
|
|
138
|
+
// its own _npx directory rather than inherit an answer about a cache
|
|
139
|
+
// directory it is not running from.
|
|
140
|
+
AGENTS_DECK_INVOKED_AS: INVOKED_AS ?? "",
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
child = worker;
|
|
145
|
+
|
|
146
|
+
worker.on("message", (m) => {
|
|
147
|
+
if (!m || typeof m !== "object") return;
|
|
148
|
+
if (m.type === "listening" && typeof m.port === "number") boundPort = m.port;
|
|
149
|
+
// The worker asking to be replaced, while it is still serving. Answered by
|
|
150
|
+
// prefetchUpgrade, which is the whole of this file's new shape: the fetch
|
|
151
|
+
// happens here, and only then does that worker exit — see UPGRADE_CODE.
|
|
152
|
+
else if (m.type === "upgrade") prefetchUpgrade(worker);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
worker.on("exit", (code, signal) => {
|
|
156
|
+
child = null;
|
|
157
|
+
// A fetch is for the worker that asked for it. That worker is gone, so the
|
|
158
|
+
// download is spent effort and the process holding it has to be stopped:
|
|
159
|
+
// left running it would keep writing into the npx cache directory the next
|
|
160
|
+
// attempt reads, minutes after the deck stopped waiting for it.
|
|
161
|
+
if (fetching) { killTree(fetching); fetching = null; attempting = null; }
|
|
162
|
+
// `stopping` outranks the exit code — see supervisor.mjs. A restart and a
|
|
163
|
+
// Ctrl+C can land together, and honouring the code first is how the deck
|
|
164
|
+
// came back to life after the user stopped it.
|
|
165
|
+
const next = workerExitAction(code, stopping);
|
|
166
|
+
if (next.relaunch === "disk") {
|
|
167
|
+
restarts++;
|
|
168
|
+
launch(true);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (next.relaunch === "npx") {
|
|
172
|
+
restarts++;
|
|
173
|
+
launchNpx();
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
// Anything else is the worker's own verdict and belongs to whoever started
|
|
177
|
+
// us — including the ccdeck wrapper, which exits with our code in turn. A
|
|
178
|
+
// worker killed by a signal is reported by dying of the same one; doing
|
|
179
|
+
// that naively re-enters the handlers below and exits 0 — see
|
|
180
|
+
// supervisor.mjs.
|
|
181
|
+
if (signal) dieOfSignal(signal);
|
|
182
|
+
else process.exit(next.code);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
worker.on("error", (err) => {
|
|
186
|
+
console.error(`${PRODUCT}: could not start ${WORKER}: ${err.message}`);
|
|
187
|
+
process.exit(1);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Drop the two flags launchNpx sets itself. `--port` takes a value, and both
|
|
192
|
+
* spellings npm's parser accepts (`--port 4317`, `--port=4317`) have to go. */
|
|
193
|
+
function withoutPortAndOpen(args) {
|
|
194
|
+
const out = [];
|
|
195
|
+
for (let i = 0; i < args.length; i++) {
|
|
196
|
+
const a = args[i];
|
|
197
|
+
if (a === "--no-open") continue;
|
|
198
|
+
if (a === "--port") { i++; continue; } // and its value
|
|
199
|
+
if (a.startsWith("--port=")) continue;
|
|
200
|
+
out.push(a);
|
|
201
|
+
}
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Which package an npx upgrade here names, and the spec that installs it — or
|
|
207
|
+
* null when this deck was not started by npx and there is nothing to re-run.
|
|
208
|
+
*
|
|
209
|
+
* One function because there is one answer, and the worker above us reaches it
|
|
210
|
+
* independently: `upgradeName` in self-update.mjs resolves the npx case as
|
|
211
|
+
* `bareSpecName(npxRestartSpec(pkgRoot, installedName(pkgRoot)))`, and
|
|
212
|
+
* everything registry-shaped on that side — the dist-tag it fetched, the marker
|
|
213
|
+
* it cached the answer in, the failure note the browser reads — is keyed by it.
|
|
214
|
+
* This supervisor keyed the same four things off `npxRestartSpec(PKG_ROOT)`
|
|
215
|
+
* with the module's own default, which is the literal string "agents-deck".
|
|
216
|
+
*
|
|
217
|
+
* Both read `_npx/<hash>/package.json`, so they agreed for as long as that
|
|
218
|
+
* metadata was readable, and #340 made the disagreement matter when it is not:
|
|
219
|
+
* the three names are one tarball now, so the manifest under a `npx ccdeck` run
|
|
220
|
+
* genuinely says `ccdeck` where it once always said `agents-deck`. A cache
|
|
221
|
+
* directory written by an npm whose `_npx.packages` layout differs — or a
|
|
222
|
+
* truncated file — split the two apart: the worker asked npm about `ccdeck` and
|
|
223
|
+
* read `.restart-failed-ccdeck-<pid>` while this process wrote
|
|
224
|
+
* `.restart-failed-agents-deck-<pid>`, took `lastKnownLatest("agents-deck")`
|
|
225
|
+
* off a marker nobody had written — so `target` was null and upgradeAttempt's
|
|
226
|
+
* per-target cap degraded to "any failure counts" — and relaunched
|
|
227
|
+
* `npx -y agents-deck@latest`, moving a `npx ccdeck` user onto a different
|
|
228
|
+
* published name. The note went where the browser never looks, which is the
|
|
229
|
+
* exact class of bug NOTE_PREFIX's naming exists to prevent.
|
|
230
|
+
*
|
|
231
|
+
* So the fallback is the name npm's rename wrote into this build's own manifest,
|
|
232
|
+
* which is what the worker falls back to as well. Read on each call rather than
|
|
233
|
+
* once at import for the reason installedVersion gives: the manifest is a file
|
|
234
|
+
* on disk, and this process outlives more than one of them.
|
|
235
|
+
*/
|
|
236
|
+
function npxUpgrade() {
|
|
237
|
+
const self = installedName(PKG_ROOT);
|
|
238
|
+
const spec = npxRestartSpec(PKG_ROOT, self);
|
|
239
|
+
return spec ? { spec, pkgName: bareSpecName(spec) ?? self } : null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** The note the browser reads, written by the only process that knows why an
|
|
243
|
+
* upgrade did not happen. `failedAt` is when the FETCH failed, which a refusal
|
|
244
|
+
* restating an earlier failure carries forward — see upgradeAttempt. */
|
|
245
|
+
function noteFailure({ pkgName, spec, error, target, attempts, failedAt = Date.now() }) {
|
|
246
|
+
recordRestartFailure({
|
|
247
|
+
name: pkgName,
|
|
248
|
+
command: `npx -y ${spec}`,
|
|
249
|
+
error,
|
|
250
|
+
version: VERSION === "?" ? null : VERSION,
|
|
251
|
+
target,
|
|
252
|
+
attempts,
|
|
253
|
+
failedAt,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Fetch the replacement while the deck this supervisor started keeps serving.
|
|
259
|
+
*
|
|
260
|
+
* The order used to be: kill the working deck, attempt, fail, rebuild the deck.
|
|
261
|
+
* Every failed upgrade was therefore a real interruption — the SSE stream
|
|
262
|
+
* dropped, hook events fired into the gap lost outright, the canvas back with
|
|
263
|
+
* tools stuck in flight — and it was paid in full even when the update never
|
|
264
|
+
* had a chance of working. Reported from a terminal that had been left alone:
|
|
265
|
+
* the same version, the same ETARGET, the same teardown, four times over.
|
|
266
|
+
*
|
|
267
|
+
* npm can resolve and download without the deck dying. So the fetch is done
|
|
268
|
+
* first, and the worker is only asked to exit once there is something to hand
|
|
269
|
+
* the port to. There is never a second server: npxPrefetch installs the package
|
|
270
|
+
* and runs nothing out of it, and the replacement is spawned only after this
|
|
271
|
+
* process has watched the old worker's exit.
|
|
272
|
+
*
|
|
273
|
+
* A refusal costs nothing but the click. The deck keeps its port, its stream
|
|
274
|
+
* and its hooks; the failure reaches the browser through the note, exactly as a
|
|
275
|
+
* failed fetch always has.
|
|
276
|
+
*/
|
|
277
|
+
async function prefetchUpgrade(worker) {
|
|
278
|
+
if (stopping || fetching || attempting) return;
|
|
279
|
+
const reply = (msg) => { try { worker.send?.(msg); } catch { /* the worker is gone */ } };
|
|
280
|
+
|
|
281
|
+
const upgrade = npxUpgrade();
|
|
282
|
+
// Not an npx run after all, so there is nothing to fetch and the files on
|
|
283
|
+
// disk are already the newest this deck can reach.
|
|
284
|
+
if (!upgrade) { reply({ type: "upgrade-refused", error: "this deck was not started by npx" }); return; }
|
|
285
|
+
const { spec, pkgName } = upgrade;
|
|
286
|
+
// What the banner offered, straight off the marker the version check writes.
|
|
287
|
+
// The note is keyed by it so that "this exact version already failed here" is
|
|
288
|
+
// a question anything can answer — and so a newer release clears the slate.
|
|
289
|
+
const target = lastKnownLatest(pkgName);
|
|
290
|
+
|
|
291
|
+
const note = readRestartFailure(pkgName);
|
|
292
|
+
const decision = upgradeAttempt({ note, target, now: Date.now() });
|
|
293
|
+
if (!decision.allow) {
|
|
294
|
+
const error = upgradeRefusalText(decision, target);
|
|
295
|
+
// Re-stamped even though nothing was attempted: the tab ends its attempt on
|
|
296
|
+
// a failure note it has not seen before, so a refusal that left the note
|
|
297
|
+
// untouched would leave the button reading "fetching…" for the full three
|
|
298
|
+
// minutes it allows, over a deck that never went anywhere.
|
|
299
|
+
noteFailure({
|
|
300
|
+
pkgName, spec, error, target,
|
|
301
|
+
attempts: decision.attempt,
|
|
302
|
+
failedAt: typeof note?.failedAt === "number" ? note.failedAt : Date.now(),
|
|
303
|
+
});
|
|
304
|
+
reply({ type: "upgrade-refused", error });
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
attempting = { pkgName, spec, target, attempt: decision.attempt };
|
|
309
|
+
process.stdout.write(`\n ${P.warn}${G.restart}${P.reset} ${P.muted}fetching ${spec}${G.ellipsis}${P.reset}\n`);
|
|
310
|
+
const got = await npxPrefetch(spec, { onChild: (c) => { fetching = c; } });
|
|
311
|
+
fetching = null;
|
|
312
|
+
// Ctrl+C, or a worker that died on its own while npm was working: either way
|
|
313
|
+
// the deck this fetch was for is not there to be replaced, and the exit path
|
|
314
|
+
// that noticed has already had its say.
|
|
315
|
+
if (stopping || child !== worker) { attempting = null; return; }
|
|
316
|
+
if (got.ok) { reply({ type: "upgrade-ready" }); return; }
|
|
317
|
+
|
|
318
|
+
const error = [got.error, got.hint].filter(Boolean).join(" — ");
|
|
319
|
+
noteFailure({ pkgName, spec, error, target, attempts: decision.attempt });
|
|
320
|
+
attempting = null;
|
|
321
|
+
reply({ type: "upgrade-refused", error });
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Come back on a newer version, for a deck that npx started.
|
|
326
|
+
*
|
|
327
|
+
* There is nothing to install here: npx unpacks each spec into its own
|
|
328
|
+
* content-addressed directory under _npx, so `npm i -g` would upgrade something
|
|
329
|
+
* this process could never reach. `npx -y <spec>@latest` resolves fresh, gets a
|
|
330
|
+
* DIFFERENT directory, and starts the deck there — on the port we hand it, so
|
|
331
|
+
* the tab that asked for the update reconnects to the same URL.
|
|
332
|
+
*
|
|
333
|
+
* This process stays as the parent rather than exec-ing, for the same reasons
|
|
334
|
+
* the file's header gives, plus one more: a fetch can fail (offline, registry
|
|
335
|
+
* down), and someone has to bring the working copy back when it does.
|
|
336
|
+
*/
|
|
337
|
+
function launchNpx() {
|
|
338
|
+
const upgrade = npxUpgrade();
|
|
339
|
+
if (!upgrade) { launch(true); return; } // not an npx run after all
|
|
340
|
+
// The same package prefetchUpgrade fetched and the worker asked npm about —
|
|
341
|
+
// one answer, so the spec that is run, the note that records it and the
|
|
342
|
+
// marker its target came from all name one package. See npxUpgrade.
|
|
343
|
+
const { spec, pkgName } = upgrade;
|
|
344
|
+
// Our two are appended, so the originals are dropped rather than left to be
|
|
345
|
+
// overridden — `--port 4317 --no-open --port 4317 --no-open` works, but it is
|
|
346
|
+
// what the next person reads in `ps`.
|
|
347
|
+
const args = ["-y", spec, ...withoutPortAndOpen(process.argv.slice(2))];
|
|
348
|
+
if (boundPort != null) args.push("--port", String(boundPort));
|
|
349
|
+
// The tab that asked for this is open and reconnecting; a second one would be
|
|
350
|
+
// the deck talking over itself.
|
|
351
|
+
args.push("--no-open");
|
|
352
|
+
|
|
353
|
+
// A retry answers for itself: whatever the last attempt left on disk is about
|
|
354
|
+
// to be replaced by this attempt's outcome, and leaving it there would keep
|
|
355
|
+
// the browser showing an old failure over a running fetch.
|
|
356
|
+
clearRestartFailure(pkgName);
|
|
357
|
+
|
|
358
|
+
// No "fetching…" line here any more: prefetchUpgrade printed it and the
|
|
359
|
+
// tarball is already unpacked, so this resolves out of the npx cache and the
|
|
360
|
+
// next thing on screen is the new deck's own banner.
|
|
361
|
+
//
|
|
362
|
+
// npxLaunch prefers npm's own npx-cli.js next to this Node binary, which
|
|
363
|
+
// needs no PATH lookup and no batch shim; the PATH shim is the fallback and
|
|
364
|
+
// still goes through cmd.exe with each argument quoted.
|
|
365
|
+
//
|
|
366
|
+
// Not `shell: true` on either path. Everything after the spec is the user's
|
|
367
|
+
// own argv, and Node would paste it into one command line unquoted:
|
|
368
|
+
// `--workspace C:\Users\John Smith\proj` would reach the new deck as two
|
|
369
|
+
// arguments, the second one silently dropped by its parser — an upgraded deck
|
|
370
|
+
// watching the wrong directory.
|
|
371
|
+
const { file, args: argv, opts } = npxLaunch(args);
|
|
372
|
+
// stderr is piped rather than inherited so a crash can be summarised instead
|
|
373
|
+
// of dumped: npm's MODULE_NOT_FOUND stack, with its `requireStack` and its
|
|
374
|
+
// caret line, is not something a user of a DAG dashboard can act on. stdout
|
|
375
|
+
// stays inherited — the new deck's banner, colours and URL line are the whole
|
|
376
|
+
// point of the supervisor staying out of the way.
|
|
377
|
+
const started = spawn(file, argv, { stdio: ["inherit", "inherit", "pipe"], ...opts });
|
|
378
|
+
child = started;
|
|
379
|
+
|
|
380
|
+
// Held, not discarded: the moment the replacement is serving, everything it
|
|
381
|
+
// wrote goes to the terminal and every later byte passes straight through.
|
|
382
|
+
// Until then it is only evidence for a failure that may not happen.
|
|
383
|
+
let tail = "";
|
|
384
|
+
let teeing = false;
|
|
385
|
+
const tee = () => {
|
|
386
|
+
if (teeing) return;
|
|
387
|
+
teeing = true;
|
|
388
|
+
if (tail) process.stderr.write(tail);
|
|
389
|
+
};
|
|
390
|
+
started.stderr?.on("data", (d) => {
|
|
391
|
+
const s = String(d);
|
|
392
|
+
if (teeing) { process.stderr.write(s); return; }
|
|
393
|
+
tail = (tail + s).slice(-8000);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
// Whether the replacement ever got as far as serving. An npx that cannot
|
|
397
|
+
// resolve exits in seconds having bound nothing; a deck the user stops with
|
|
398
|
+
// Ctrl+C exits non-zero too, and only this tells the two apart.
|
|
399
|
+
let served = false;
|
|
400
|
+
const probe = setInterval(() => {
|
|
401
|
+
if (boundPort == null || served) return;
|
|
402
|
+
const sock = connect({ port: boundPort, host: "127.0.0.1" });
|
|
403
|
+
sock.setTimeout(1000);
|
|
404
|
+
sock.on("connect", () => { served = true; tee(); sock.destroy(); });
|
|
405
|
+
sock.on("timeout", () => sock.destroy());
|
|
406
|
+
sock.on("error", () => { /* not up yet */ });
|
|
407
|
+
}, 1000);
|
|
408
|
+
probe.unref?.();
|
|
409
|
+
|
|
410
|
+
const giveUp = (why) => {
|
|
411
|
+
clearInterval(probe);
|
|
412
|
+
child = null;
|
|
413
|
+
if (stopping || served) return; // the user stopped it, or it ran and ended
|
|
414
|
+
const summary = npxFailureSummary(tail);
|
|
415
|
+
const hint = npxFailureHint(tail);
|
|
416
|
+
console.error(`${PRODUCT}: ${why} — staying on v${VERSION}`);
|
|
417
|
+
if (summary) console.error(` ${summary}`);
|
|
418
|
+
if (hint) console.error(` ${hint}`);
|
|
419
|
+
// Left for the worker about to be launched: it is the only way the browser
|
|
420
|
+
// learns this happened at all. See recordRestartFailure.
|
|
421
|
+
//
|
|
422
|
+
// This is the outage the pre-flight cannot spare anyone: the fetch worked,
|
|
423
|
+
// the port was handed over, and the replacement still did not serve. It
|
|
424
|
+
// counts against the same target as any other failed attempt, so a copy
|
|
425
|
+
// that starts and dies is not offered forever either.
|
|
426
|
+
noteFailure({
|
|
427
|
+
pkgName, spec,
|
|
428
|
+
error: [summary ?? why, hint].filter(Boolean).join(" — "),
|
|
429
|
+
target: attempting?.target ?? null,
|
|
430
|
+
attempts: attempting?.attempt ?? 1,
|
|
431
|
+
});
|
|
432
|
+
attempting = null;
|
|
433
|
+
restarts++;
|
|
434
|
+
launch(true);
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
started.on("exit", (code, signal) => {
|
|
438
|
+
clearInterval(probe);
|
|
439
|
+
child = null;
|
|
440
|
+
if (stopping || served) {
|
|
441
|
+
if (signal) dieOfSignal(signal);
|
|
442
|
+
else process.exit(code ?? 0);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
giveUp(`npx ${spec} exited ${code ?? signal}`);
|
|
446
|
+
});
|
|
447
|
+
started.on("error", (err) => giveUp(`could not run npx: ${err.message}`));
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Ctrl+C already reaches the child directly — it shares this process group — so
|
|
451
|
+
// forwarding would deliver it twice. These handlers exist only to keep the
|
|
452
|
+
// supervisor alive long enough for the child's own graceful shutdown to run and
|
|
453
|
+
// for its exit code to arrive.
|
|
454
|
+
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
455
|
+
process.on(sig, () => {
|
|
456
|
+
// On Windows none of these are delivered to a Node process, which is fine:
|
|
457
|
+
// there the console kills the whole tree and sweepStaleDiscovery cleans up
|
|
458
|
+
// on the next boot.
|
|
459
|
+
const second = stopping; // an impatient user pressing Ctrl+C again
|
|
460
|
+
stopping = true;
|
|
461
|
+
// A fetch in flight is not the deck and does not stop with it. On POSIX the
|
|
462
|
+
// Ctrl+C that reached us reached it too — it is in this process group — but
|
|
463
|
+
// a plain `kill` of the supervisor, or a systemd stop, is not, and on
|
|
464
|
+
// Windows the shim path leaves npm as a grandchild of cmd.exe that only
|
|
465
|
+
// killTree can reach. Stopped before the worker, since the worker's own
|
|
466
|
+
// exit path is what ends this process.
|
|
467
|
+
if (fetching) { killTree(fetching, second ? "SIGKILL" : sig); fetching = null; }
|
|
468
|
+
if (!child) { process.exit(0); return; }
|
|
469
|
+
try { child.kill(second ? "SIGKILL" : sig); } catch { /* already gone */ }
|
|
470
|
+
// The npx step is a shell that exec's the new deck, and a signal can land in
|
|
471
|
+
// the gap: the shell dies, the process replacing it never saw it. One more
|
|
472
|
+
// attempt a moment later catches that; it is a no-op when the first worked.
|
|
473
|
+
if (!second) {
|
|
474
|
+
setTimeout(() => {
|
|
475
|
+
if (stopping && child) { try { child.kill("SIGTERM"); } catch { /* gone */ } }
|
|
476
|
+
}, 2500).unref();
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
launch(false);
|