ccdeck 1.43.0 → 1.45.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.
@@ -0,0 +1,259 @@
1
+ // How to run npx from a supervisor that cannot afford npx to be broken.
2
+ //
3
+ // The supervisor's upgrade path is `npx -y <spec>@latest`, and it used to
4
+ // resolve npx by bare name — `npx.cmd` on Windows — letting the OS find it on
5
+ // PATH. That trusts a batch shim to be sitting in a real npm install root,
6
+ // because the shim computes everything else from its own directory:
7
+ //
8
+ // SET "NPX_CLI_JS=%~dp0\node_modules\npm\bin\npx-cli.js"
9
+ // SET "NPM_PREFIX_JS=%~dp0\node_modules\npm\bin\npm-prefix.js"
10
+ //
11
+ // Reported from a Windows machine on Node 24 where `%~dp0` was the user's home
12
+ // directory and no `node_modules\npm` existed under it. Clicking
13
+ // "Update & restart" printed a raw MODULE_NOT_FOUND stack trace and came back
14
+ // on the same version.
15
+ //
16
+ // The cause was guessed at here as "an npm global prefix pointed somewhere the
17
+ // shim did not follow", and #456 established it was not: `%~dp0` was the DECK'S
18
+ // WORKING DIRECTORY, because a shim asked for by bare name has no directory in
19
+ // its `%0` to be the drive-and-path of. Nothing about that machine's npm was
20
+ // wrong. The correction lives in shimPath in exec.mjs, and the shim fallback
21
+ // below now asks for the full path.
22
+ //
23
+ // Node ships npm, so npx's real entry point can be reached from
24
+ // `process.execPath` with no PATH lookup, no batch file, and no shim-relative
25
+ // arithmetic. That is what this prefers. The shim stays as the fallback for
26
+ // installs whose npm lives somewhere else entirely, and it still goes through
27
+ // spawnSpec — the cmd.exe quoting there is load-bearing and unchanged.
28
+ //
29
+ // The pre-flight fetch below goes through the very same launcher, for the same
30
+ // reason stated the other way round: a supervisor that cannot afford npx to be
31
+ // broken cannot afford to discover that it is only after it has stopped
32
+ // serving. So the fetch happens first, and only a fetch that worked costs the
33
+ // deck its listener.
34
+ import { spawn } from "node:child_process";
35
+ import { existsSync } from "node:fs";
36
+ import { killTree, shimPath, spawnSpec } from "./exec.mjs";
37
+
38
+ /**
39
+ * Where npm's own `npx-cli.js` sits relative to the running Node binary, most
40
+ * likely first. Pure: the platform and the executable path are parameters, so
41
+ * the Windows layout can be checked from any OS.
42
+ *
43
+ * Windows keeps npm beside node.exe (`C:\Program Files\nodejs\node_modules\npm`,
44
+ * and the same shape under nvm-windows). POSIX puts it in a `lib` next to the
45
+ * `bin` — usually the parent (`/usr/local/bin/node` →
46
+ * `/usr/local/lib/node_modules/npm`, as for nvm, fnm and Volta), but not
47
+ * always: Homebrew's node lives in `/usr/local/Cellar/node/<v>/bin` while its
48
+ * npm stays at the prefix, four levels up. So the `lib` spelling is tried at
49
+ * each of the first four ancestors rather than only at the parent. Every
50
+ * candidate is confirmed by the exact file existing, so a wider search cannot
51
+ * pick something that is not npm.
52
+ *
53
+ * The path arithmetic is done on the string rather than through `node:path`,
54
+ * for the same reason npxRoot in self-update.mjs does: `path` here is the
55
+ * platform running the SUITE, so a Windows layout checked from macOS would come
56
+ * back with forward slashes and a `..` nothing resolves.
57
+ */
58
+ export function npxCliCandidates(execPath = process.execPath, platform = process.platform) {
59
+ if (typeof execPath !== "string" || !execPath) return [];
60
+ const sep = platform === "win32" ? "\\" : "/";
61
+ const dir = execPath.split(/[\\/]/);
62
+ dir.pop(); // the binary itself
63
+ if (!dir.length) return [];
64
+ const beside = [...dir, "node_modules", "npm", "bin", "npx-cli.js"].join(sep);
65
+ const above = [];
66
+ for (let up = 1; up <= 4 && dir.length - up > 0; up++) {
67
+ above.push([...dir.slice(0, -up), "lib", "node_modules", "npm", "bin", "npx-cli.js"].join(sep));
68
+ }
69
+ return platform === "win32" ? [beside, ...above] : [...above, beside];
70
+ }
71
+
72
+ /**
73
+ * What to hand `spawn` to run `npx <args>`.
74
+ *
75
+ * Returns `{ file, args, opts, via }`, where `via` is "node" for the bundled
76
+ * CLI and "shim" for the PATH lookup. `exists` is injected so the choice can be
77
+ * tested without a matching Node install on the machine running the suite.
78
+ */
79
+ export function npxLaunch(args, {
80
+ platform = process.platform,
81
+ execPath = process.execPath,
82
+ exists = existsSync,
83
+ pathEnv,
84
+ } = {}) {
85
+ for (const cli of npxCliCandidates(execPath, platform)) {
86
+ let found = false;
87
+ try { found = exists(cli); } catch { found = false; }
88
+ // Same node, same npm, spawned directly: nothing here reads PATH and
89
+ // nothing resolves a path relative to a shim's own directory.
90
+ if (found) return { file: execPath, args: [cli, ...args], opts: {}, via: "node", cli };
91
+ }
92
+ // The shim, and on Windows by its FULL path. A bare `npx.cmd` is what made
93
+ // the shim compute `%~dp0` — where it looks for npx-cli.js — from the deck's
94
+ // working directory rather than from its own, which is #456; shimPath in
95
+ // exec.mjs carries the account. The bare name stays as the last resort, so a
96
+ // layout the lookup cannot see is exactly as well off as it was before.
97
+ const shim = platform === "win32"
98
+ ? (shimPath("npx.cmd", { execPath, exists, ...(pathEnv === undefined ? {} : { pathEnv }) }) ?? "npx.cmd")
99
+ : "npx";
100
+ return { ...spawnSpec(shim, args, platform), via: "shim", cli: null };
101
+ }
102
+
103
+ // Long enough for a cold tarball on a slow line; short enough that a connection
104
+ // hanging on a dead proxy does not hold the deck's Update button for the three
105
+ // minutes the tab waits before giving up on its own.
106
+ //
107
+ // Not exported (#383): it is the default of `prefetch`'s `timeoutMs` parameter
108
+ // and nothing else. A caller that wants a different budget passes one, and a
109
+ // caller that wants this one passes nothing — there is no third caller for the
110
+ // number to be worth naming across the module boundary.
111
+ const PREFETCH_TIMEOUT_MS = 120_000;
112
+
113
+ /**
114
+ * What to hand npx to DOWNLOAD a spec without running it.
115
+ *
116
+ * The reason this is not simply `["-y", spec]`: that form is the upgrade
117
+ * itself. npx resolves, unpacks AND executes the package's bin in one command,
118
+ * so asking it "can you fetch this?" the obvious way starts a second deck —
119
+ * which either binds the port the running one holds or, worse, does not, and
120
+ * registers itself with the hooks anyway. Both are outages of their own.
121
+ *
122
+ * `--package` is the half that installs; `--call` is the half that runs, and
123
+ * pointing it at a shell builtin is what makes the install the only effect. The
124
+ * spec never appears as a positional argument, so no bin of ours is ever named,
125
+ * let alone started.
126
+ *
127
+ * `exit 0` rather than anything more informative on purpose: npm runs the call
128
+ * through its script shell — `sh -c` on POSIX, cmd.exe on Windows — and both
129
+ * spell that builtin the same way. Anything that needed a program on PATH would
130
+ * fail exactly where PATH is broken, which is the environment from #184 this
131
+ * pre-flight most needs to answer honestly.
132
+ */
133
+ export function npxPrefetchArgs(spec) {
134
+ return ["-y", "--package", spec, "--call", "exit 0"];
135
+ }
136
+
137
+ /**
138
+ * Resolve, download and unpack `spec` while the current deck keeps serving.
139
+ *
140
+ * Answers `{ ok, error, hint }` — never throws, because the caller's whole
141
+ * reason for asking is that it must survive the answer being "no". A refusal
142
+ * here costs the click; the alternative, which is what shipped before, was to
143
+ * exit the worker first and discover offline, a proxy, an ETARGET or a broken
144
+ * npx shim with the port already given up and the SSE stream already dropped.
145
+ *
146
+ * Nothing is echoed: the deck this runs alongside owns the terminal and is
147
+ * still drawing its pulse line over the last row, so npm's progress output
148
+ * would smear across it. The tail is kept only as evidence for a failure that
149
+ * may not happen, and reduced to one line by npxFailureSummary when it does.
150
+ *
151
+ * `spawnFn` and `launch` are injected so the choice of launcher and the stdio
152
+ * it is given can be asserted without a Node install to match, and `onChild`
153
+ * hands the process out so a Ctrl+C arriving mid-fetch has something to kill.
154
+ */
155
+ export function npxPrefetch(spec, {
156
+ launch = npxLaunch,
157
+ spawnFn = spawn,
158
+ timeoutMs = PREFETCH_TIMEOUT_MS,
159
+ onChild = null,
160
+ } = {}) {
161
+ return new Promise((resolve) => {
162
+ const { file, args, opts } = launch(npxPrefetchArgs(spec));
163
+ let child;
164
+ try {
165
+ child = spawnFn(file, args, { stdio: ["ignore", "pipe", "pipe"], ...opts });
166
+ } catch (err) {
167
+ resolve({ ok: false, error: `could not run npx: ${err?.message ?? err}`, hint: null });
168
+ return;
169
+ }
170
+ onChild?.(child);
171
+
172
+ let tail = "";
173
+ const keep = (d) => { tail = (tail + String(d)).slice(-8000); };
174
+ // Both streams, one buffer: npm writes its errors to stderr and its
175
+ // resolution notices to stdout, and which of the two carries the sentence
176
+ // worth quoting depends on the failure.
177
+ child.stdout?.on("data", keep);
178
+ child.stderr?.on("data", keep);
179
+
180
+ let settled = false;
181
+ const settle = (result) => {
182
+ if (settled) return;
183
+ settled = true;
184
+ clearTimeout(timer);
185
+ resolve(result);
186
+ };
187
+ const timer = setTimeout(() => {
188
+ // killTree, not kill: on Windows the shim path puts cmd.exe between us
189
+ // and npm, so a plain kill stops the shell and leaves the download
190
+ // running — writing into the very cache directory the next attempt reads.
191
+ killTree(child);
192
+ settle({ ok: false, error: `fetching ${spec} timed out after ${Math.round(timeoutMs / 1000)}s`, hint: null });
193
+ }, timeoutMs);
194
+ timer.unref?.();
195
+
196
+ child.on("error", err => settle({ ok: false, error: `could not run npx: ${err?.message ?? err}`, hint: null }));
197
+ child.on("exit", (code, signal) => {
198
+ if (code === 0) { settle({ ok: true, error: null, hint: null }); return; }
199
+ settle({
200
+ ok: false,
201
+ error: npxFailureSummary(tail) ?? `npx could not fetch ${spec} — exited ${code ?? signal}`,
202
+ hint: npxFailureHint(tail),
203
+ });
204
+ });
205
+ });
206
+ }
207
+
208
+ // Lines that are structure rather than content: a Node crash prints a stack,
209
+ // the source line it threw on, a caret under it, then the error object's own
210
+ // fields and the runtime version. None of that tells a user of a DAG dashboard
211
+ // what went wrong.
212
+ const NOISE = [
213
+ /^\s*at\s/,
214
+ /^\s*\^+\s*$/,
215
+ /^node:internal\//,
216
+ /^\s*throw\s/,
217
+ /^Node\.js v/,
218
+ /^\s*[{}]\s*$/,
219
+ /^\s*(code|errno|syscall|path|requireStack|stack):/,
220
+ /^A complete log/,
221
+ ];
222
+
223
+ // The line worth quoting, when there is one. npm's failures and Node's both
224
+ // lead with the sentence a person can act on; everything after it is detail.
225
+ const SIGNAL = /(Error:|error code|ERR_|No matching version|ENOENT|EACCES|EPERM|not found|permission denied)/i;
226
+
227
+ /**
228
+ * One line summarising why an npx run failed, or null when its output said
229
+ * nothing. Pure, and separate from the printing, because "do not dump a stack
230
+ * trace at the user" is the rule and a rule worth a bug is worth a test.
231
+ */
232
+ export function npxFailureSummary(output) {
233
+ const lines = String(output ?? "")
234
+ .split(/\r?\n/)
235
+ // A warning is by definition not the reason this failed, and npm emits one
236
+ // for every ordinary exec ("the following package will be installed") whose
237
+ // wording would otherwise outrank the real error below it.
238
+ .filter(l => !/^npm (WARN|warn)\b/.test(l))
239
+ .map(l => l.replace(/^npm (ERR!|error)\s*/i, "").trimEnd())
240
+ .filter(l => l.trim() && !NOISE.some(re => re.test(l)));
241
+ if (!lines.length) return null;
242
+ const pick = lines.find(l => SIGNAL.test(l)) ?? lines[lines.length - 1];
243
+ return pick.trim().slice(0, 300);
244
+ }
245
+
246
+ /**
247
+ * The actionable half, for the one failure shape that is a broken environment
248
+ * rather than a broken network: npx exists, runs, and cannot find the npm it is
249
+ * supposed to load. `npm config get prefix` is the thing to check — a prefix
250
+ * pointing at a directory with no `node_modules/npm` under it leaves exactly
251
+ * this trace.
252
+ */
253
+ export function npxFailureHint(output) {
254
+ const s = String(output ?? "");
255
+ if (/MODULE_NOT_FOUND/.test(s) && /(npx-cli\.js|npm-cli\.js|npm-prefix\.js)/.test(s)) {
256
+ return "the npx on PATH is a shim whose npm install is missing — check `npm config get prefix`";
257
+ }
258
+ return null;
259
+ }