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,938 @@
1
+ // Running an external command the same way on Linux, macOS and Windows.
2
+ //
3
+ // On POSIX, `spawn("cswap", …)` finds cswap on PATH. On Windows it does not:
4
+ // the thing on PATH is `cswap.exe` or a `cswap.cmd` shim, and Node only
5
+ // applies PATHEXT when it goes through a shell. So the naive call fails with
6
+ // ENOENT on Windows even though the tool is installed and on PATH — which
7
+ // looks exactly like "not installed" and is why this is worth a module.
8
+ //
9
+ // Resolving the extension ourselves keeps the argument vector intact, which
10
+ // blanket `shell: true` would not: it concatenates arguments into a command
11
+ // line, so an argument containing a quote or an ampersand stops being an
12
+ // argument.
13
+ //
14
+ // The exception is .cmd and .bat, which since Node 20.12 CANNOT be spawned
15
+ // without a shell at all — the fix for CVE-2024-27980 makes that throw EINVAL,
16
+ // synchronously, from inside execFile. Those are routed through cmd.exe the
17
+ // same way Node's own `shell: true` does it, with the arguments quoted here
18
+ // rather than pasted together. Getting this wrong is not a degraded feature:
19
+ // the throw escaped the retry path and took the whole process down on Windows
20
+ // before the server ever started.
21
+ //
22
+ // Going through cmd.exe then raises a question a direct spawn never has to ask:
23
+ // under what NAME. A `.cmd` shim finds its own payload relative to `%~dp0`, so a
24
+ // bare name — which carries no directory — makes it look under the deck's
25
+ // working directory instead of its own. Every batch candidate is therefore
26
+ // launched by its full path where one can be found; see shimPath and
27
+ // candidateSpec.
28
+ import { execFile, spawn } from "node:child_process";
29
+ import { existsSync } from "node:fs";
30
+
31
+ // Extensions Windows will execute, most specific first. `.com` is omitted —
32
+ // nothing ships one, and every extra candidate costs a failed spawn.
33
+ const WIN_EXTS = [".exe", ".cmd", ".bat", ""];
34
+
35
+ // Which spelling worked, per command name. A failed spawn is cheap but not
36
+ // free, and these run on a poll.
37
+ const resolved = new Map();
38
+
39
+ /**
40
+ * The spellings to try for `cmd`, best first.
41
+ *
42
+ * Exported with the platform as a parameter for the reason everything else in
43
+ * this file is: the Windows answer decides which candidate becomes a batch one,
44
+ * and a batch candidate is the only kind #457 touches — so a test that wants to
45
+ * follow a caller's bare name all the way to the command line cmd.exe receives
46
+ * has to be able to ask for the Windows list from a machine that is not Windows.
47
+ */
48
+ export function candidates(cmd, platform = process.platform) {
49
+ if (platform !== "win32") return [cmd];
50
+ // An explicit extension is respected as given.
51
+ if (/\.[a-z]+$/i.test(cmd)) return [cmd];
52
+ const known = resolved.get(cmd);
53
+ return known ? [known] : WIN_EXTS.map(ext => cmd + ext);
54
+ }
55
+
56
+ /** Exported for tests: the platform is a parameter so both can be checked. */
57
+ export const isBatch = (file, platform = process.platform) =>
58
+ platform === "win32" && /\.(cmd|bat)$/i.test(file);
59
+
60
+ /**
61
+ * Rewrite a batch-file invocation as a cmd.exe one.
62
+ *
63
+ * Mirrors what Node does internally for `shell: true` on Windows — comspec,
64
+ * /d /s /c, the whole command line as a single quoted argument, and
65
+ * windowsVerbatimArguments so Node does not quote it a second time. Each
66
+ * argument is quoted by shellQuoteArg below, which has to satisfy cmd.exe AND
67
+ * the argv parser of whatever it launches — see the note there, and #624 for
68
+ * the half of that rule this file was missing until a real cmd.exe was asked.
69
+ */
70
+ export function viaCmd(file, args) {
71
+ const line = [file, ...args].map(a => shellQuoteArg(a, "win32")).join(" ");
72
+ return {
73
+ file: process.env.comspec || process.env.ComSpec || "cmd.exe",
74
+ args: ["/d", "/s", "/c", `"${line}"`],
75
+ opts: { windowsVerbatimArguments: true },
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Quote ONE argument into a command line a shell will parse.
81
+ *
82
+ * Everything else in this module exists to avoid needing this: an argument
83
+ * vector is handed to spawn untouched and nothing in it is ever read as syntax.
84
+ * Two callers cannot take that route, and they are the reason this is exported.
85
+ * A Claude Code hook is registered as `{type:"command", command:"<string>"}` —
86
+ * the host CLI runs that string THROUGH A SHELL on every tool call, and the
87
+ * format has no argv form to emit instead. So the string has to be built here,
88
+ * correctly, once.
89
+ *
90
+ * The inputs are not user input in the web sense, but they are not constants
91
+ * either: the hook path is built from $CLAUDE_CONFIG_DIR and homedir(), and the
92
+ * node path from process.execPath. Wrapping those in double quotes — what this
93
+ * used to do — is not escaping on POSIX at all: `$(…)`, a backtick and `\` are
94
+ * all still live inside them, so a config dir named `/tmp/a$(id)b` became shell
95
+ * code written into the user's settings.json and executed on every hook fire.
96
+ * A bare `$` in a path is the same bug wearing a duller hat — `$HOME` expands to
97
+ * nothing, the hook path silently becomes wrong, and hooks stop firing with no
98
+ * error anywhere.
99
+ *
100
+ * POSIX gets single quotes, inside which NOTHING is special, with the one
101
+ * escape that form has: close the quote, emit a backslash-quote, reopen.
102
+ *
103
+ * Windows gets the rule below — the same one viaCmd applies above, so this file
104
+ * has one Windows quoting rule rather than two. It has to satisfy TWO parsers,
105
+ * and that is the whole of its difficulty, because they disagree about the
106
+ * backslash:
107
+ *
108
+ * cmd.exe reads the line only far enough to find where the command ends. It
109
+ * has no escape for a quote at all — a `"` toggles "inside quotes", and `&`,
110
+ * `|`, `<`, `>`, `^`, `(` and `)` are syntax only while outside — and it does
111
+ * not treat `\` as anything. Then it hands the rest of the line on AS TEXT.
112
+ *
113
+ * The program on the other end splits that text into argv itself, and for
114
+ * node that is the UCRT parser, which DOES read `\` as an escape in front of
115
+ * a quote: 2n backslashes before a `"` are n backslashes and a quote that
116
+ * toggles, 2n+1 are n backslashes and a literal `"`.
117
+ *
118
+ * So an embedded `"` is written `""` rather than `\"` — two toggles is no net
119
+ * change to cmd.exe's idea of where it is, while the child's parser turns the
120
+ * pair back into one quote — and every run of backslashes that ends up in front
121
+ * of a quote, INCLUDING THE CLOSING ONE THIS ADDS, is doubled so the child does
122
+ * not read it as an escape.
123
+ *
124
+ * That last clause is #624 and it was missing. `"` + arg + `"` alone turns a
125
+ * path ending in a separator — `C:\Program Files\nodejs\` — into
126
+ * `"C:\Program Files\nodejs\"`, whose final `\"` is an escaped quote to the
127
+ * child: the quoted region never closes, and the argument swallows every
128
+ * argument after it on the line. `--provider claude` in the hook command is
129
+ * exactly what it swallowed. A backslash immediately before an embedded quote
130
+ * was the quieter half of the same defect — it was eaten as the escape.
131
+ *
132
+ * The one residual left, unchanged: cmd.exe expands `%VAR%` inside quotes too,
133
+ * and a command line has no escape for it. That is narrower than it sounds,
134
+ * since `%foo%` with no variable `foo` is left alone, and it is a limit of the
135
+ * platform rather than of this function. `!VAR!` is the same limit on a machine
136
+ * with delayed expansion turned on, which is not the default for `cmd /c`.
137
+ *
138
+ * no-shell-hook-commands.test.ts runs the output of this through a real cmd.exe
139
+ * on the Windows leg of the matrix and compares what the child RECEIVED against
140
+ * what was intended. Four literals said this function agreed with itself for
141
+ * three releases; they could not say whether it agreed with Windows.
142
+ */
143
+ export function shellQuoteArg(arg, platform = process.platform) {
144
+ const s = String(arg ?? "");
145
+ if (platform !== "win32") return `'${s.split("'").join("'\\''")}'`;
146
+ let out = '"';
147
+ let slashes = 0;
148
+ for (const ch of s) {
149
+ if (ch === "\\") { slashes++; continue; }
150
+ if (ch === '"') { out += "\\".repeat(slashes * 2) + '""'; slashes = 0; continue; }
151
+ out += "\\".repeat(slashes) + ch;
152
+ slashes = 0;
153
+ }
154
+ // A run that reaches the end of the argument is in front of the closing quote,
155
+ // which is a quote like any other.
156
+ return `${out}${"\\".repeat(slashes * 2)}"`;
157
+ }
158
+
159
+ /**
160
+ * The absolute path of a Windows command shim, or null when nothing answers to
161
+ * that name.
162
+ *
163
+ * Why a bare name is not good enough, which is the whole of #456. npm's `.cmd`
164
+ * shims locate every file they need relative to THEMSELVES:
165
+ *
166
+ * SET "NPM_PREFIX_JS=%~dp0\node_modules\npm\bin\npm-prefix.js"
167
+ * SET "NPX_CLI_JS=%~dp0\node_modules\npm\bin\npx-cli.js"
168
+ *
169
+ * `%~dp0` is the drive and path of `%0`, and `%0` is the command token cmd.exe
170
+ * was given. Handed `cmd.exe /d /s /c ""npm.cmd" "install" …`, that token
171
+ * carries no directory of its own, so `%~dp0` came out as the deck's WORKING
172
+ * DIRECTORY instead of the shim's. Reported from Windows 10 with the deck
173
+ * started from `C:\Users\vceban`:
174
+ *
175
+ * Error: Cannot find module 'C:\Users\vceban\node_modules\npm\bin\npm-prefix.js'
176
+ * Error: Cannot find module 'C:\Users\vceban\node_modules\npm\bin\npx-cli.js'
177
+ *
178
+ * — two stacks, one defect, and `C:\Users\vceban` is the cwd rather than
179
+ * anything to do with npm: `where npx` on that machine printed
180
+ * `C:\Program Files\nodejs\npx.cmd` and `npx ccdeck` ran perfectly from a
181
+ * prompt. It is ours, introduced when #362 replaced `shell: true` with viaCmd:
182
+ * the quoting was the fix and the bare name was the cost, and it broke the
183
+ * managed install and the npx fallback in the same stroke, which is why every
184
+ * diagnosis of one half kept half-fitting.
185
+ *
186
+ * Node's own directory is tried before PATH because node and npm ship together
187
+ * and that is where the shims are — the same preference npxCliCandidates in
188
+ * npx.mjs states for npm's CLI scripts, so the repo has one rule rather than
189
+ * two. PATH is then walked in its own order, which is what cmd.exe would have
190
+ * done, minus the current directory it searches first: a deck that resolves its
191
+ * npm out of whatever folder it happens to be sitting in is the bug above
192
+ * wearing a hat.
193
+ *
194
+ * The path arithmetic is spelled out rather than done through `node:path` for
195
+ * the reason npxCliCandidates gives: `path` is the platform running the SUITE,
196
+ * so a Windows layout checked from macOS would come back with forward slashes.
197
+ * `execPath`, `pathEnv` and `exists` are injected for the same reason — the
198
+ * Windows answer has to be checkable from an OS that cannot run it.
199
+ *
200
+ * The walk itself now lives in `pathLookup` below, because #433 needed the same
201
+ * one for a tool that is not a shim; this is that walk with the two answers a
202
+ * shim needs — Windows, and node's own directory first.
203
+ */
204
+ export const shimPath = (name, deps) =>
205
+ pathLookup(name, "win32", { besideNode: true, ...deps });
206
+
207
+ /**
208
+ * Where `name` actually lives on PATH, as an absolute path, or null when
209
+ * nothing on PATH answers to it.
210
+ *
211
+ * This is the general form of shimPath above, and it exists because #433 asked
212
+ * for a third way to reach ccusage: the copy a user installed themselves. The
213
+ * deck's own error text has told people to "put ccusage on PATH yourself" since
214
+ * before there was anything that looked, so the choice was to either look or
215
+ * stop saying it — see getRunner in ccusage.mjs for which way that went.
216
+ *
217
+ * Writing it as ONE walk rather than a second one is the point. A PATH search
218
+ * on Windows is not a PATH search plus a note: `ccusage` there is `ccusage.cmd`
219
+ * or `ccusage.exe` and never the bare name, because PATHEXT is a shell's job
220
+ * and spawn is not a shell — which is the whole reason `candidates` exists, so
221
+ * that is what supplies the spellings here. And a `.cmd` found this way is
222
+ * returned as a FULL PATH, which is what keeps #456 fixed: launched by its bare
223
+ * name through cmd.exe, a shim computes `%~dp0` from the deck's working
224
+ * directory and goes hunting for its payload there.
225
+ *
226
+ * `besideNode` is the one thing a shim wants and a tool does not. npm ships
227
+ * beside node, so looking there first is right for `npm.cmd`; for anything else
228
+ * it would quietly overrule the order the user put their own PATH in, which is
229
+ * the one statement of preference they actually made.
230
+ *
231
+ * The check is existence, not executability. That is what `run`'s candidate
232
+ * loop effectively asks too — it spawns and moves on if the spawn fails — and
233
+ * an executable bit is not a thing Windows has. The residue is a directory on
234
+ * PATH that happens to be named after the tool; it would be resolved here and
235
+ * fail on spawn, with the failure naming the path, which is a better place to
236
+ * find out than a silent miss.
237
+ */
238
+ export function pathLookup(name, platform = process.platform, {
239
+ execPath = process.execPath,
240
+ pathEnv = process.env.PATH ?? process.env.Path ?? "",
241
+ exists = existsSync,
242
+ besideNode = false,
243
+ } = {}) {
244
+ // A name that already carries a directory needs no lookup, and re-rooting it
245
+ // would be a way to run something else entirely.
246
+ if (typeof name !== "string" || !name || /[\\/]/.test(name)) return null;
247
+ const win = platform === "win32";
248
+ const sep = win ? "\\" : "/";
249
+ const dirs = [];
250
+ if (besideNode) {
251
+ const beside = String(execPath ?? "").split(/[\\/]/).slice(0, -1).join(sep);
252
+ if (beside) dirs.push(beside);
253
+ }
254
+ // `;` on Windows, `:` everywhere else. Splitting on the wrong one is not a
255
+ // near miss: a POSIX PATH read with `;` is one enormous directory that
256
+ // exists nowhere, so every lookup would answer null and the feature would
257
+ // look like it had never been written.
258
+ for (const raw of String(pathEnv ?? "").split(win ? ";" : ":")) {
259
+ // A PATH entry may be quoted, and may end in a separator; neither is part
260
+ // of the directory, and both would produce a path nothing exists at.
261
+ const dir = raw.trim().replace(/^"|"$/g, "").replace(/[\\/]+$/, "");
262
+ if (dir) dirs.push(dir);
263
+ }
264
+ // On POSIX this is `[name]`, so the inner loop runs once and the cost is one
265
+ // stat per directory, exactly as before.
266
+ const spellings = candidates(name, platform);
267
+ for (const dir of dirs) {
268
+ for (const spelling of spellings) {
269
+ const full = `${dir}${sep}${spelling}`;
270
+ try {
271
+ if (exists(full)) return full;
272
+ } catch {
273
+ // An entry that cannot even be stat'ed — a disconnected network drive
274
+ // is the usual one — is a miss, not a reason to stop looking.
275
+ }
276
+ }
277
+ }
278
+ return null;
279
+ }
280
+
281
+ /**
282
+ * What to hand `spawn`/`execFile` for `file` and `args` on this platform, with
283
+ * the argument vector intact and no shell.
284
+ *
285
+ * The alternative every caller reaches for first — `shell: true`, because a
286
+ * .cmd cannot be spawned any other way — is the one thing that must not be
287
+ * used: Node joins the array into a command line with a single space and no
288
+ * per-argument quoting, so `--workspace C:\Users\John Smith\proj` arrives as
289
+ * two arguments and an `&` in a path ends the command early. Batch files go
290
+ * through cmd.exe with each argument quoted; everything else is spawned as
291
+ * given. The platform is a parameter so both branches can be tested.
292
+ *
293
+ * The one thing quoting cannot cover: cmd.exe expands `%VAR%` inside quotes
294
+ * too, and a command line has no escape for it. Every other metacharacter —
295
+ * `&`, `|`, `>`, `^`, `(` — is inert once quoted.
296
+ */
297
+ export const spawnSpec = (file, args, platform = process.platform) =>
298
+ isBatch(file, platform) ? viaCmd(file, args) : { file, args, opts: {} };
299
+
300
+ /**
301
+ * The same thing for ONE CANDIDATE SPELLING out of the list above — which on
302
+ * Windows means deciding the NAME the shim is launched under before deciding
303
+ * how, because those are not the same question.
304
+ *
305
+ * `spawnSpec` answers "how". This answers what #457 put in front of it. A batch
306
+ * candidate runs THROUGH cmd.exe (see viaCmd), and a `.cmd` shim locates its own
307
+ * payload relative to `%~dp0` — the drive and path of the command token cmd.exe
308
+ * was handed. A bare `claude.cmd` carries no directory at all, so `%~dp0` came
309
+ * out as the deck's WORKING DIRECTORY and the shim went hunting for its
310
+ * JavaScript under whatever folder the deck happened to be started from. #456
311
+ * proved exactly that for ccusage's `npm.cmd` and `npx.cmd`; it left the three
312
+ * helpers below alone, and they carry the shims the panels depend on — the
313
+ * `claude.cmd` behind the quota poll and the sign-in, and whatever `.cmd` a
314
+ * Python installer left for cswap. Same defect, same machine, wider blast
315
+ * radius.
316
+ *
317
+ * `?? raw` is the whole safety story and is not optional: when shimPath can see
318
+ * no layout it answers null, and the candidate stays the bare name today's code
319
+ * already uses, so nothing that works now can start failing. It is the same
320
+ * `?? name` ccusage.mjs and npx.mjs spell, so the repo has one rule rather than
321
+ * three. shimPath also refuses any name that already carries a directory, which
322
+ * is what keeps a caller's own absolute candidate — quotaClaudeBin's
323
+ * `%APPDATA%\npm\claude.cmd`, cswapCandidates' `~/.local/bin/cswap.exe` — from
324
+ * being re-rooted somewhere else entirely.
325
+ *
326
+ * `launch` comes back beside the spec because looksMissing has to be told the
327
+ * spelling cmd.exe was ACTUALLY GIVEN rather than the one the loop started
328
+ * from; its own header explains what breaks otherwise, and that coupling is the
329
+ * reason #456 stopped short of doing this.
330
+ *
331
+ * On POSIX `isBatch` is false, so no lookup happens, no filesystem is touched,
332
+ * `launch` is `raw`, and the spec is the object spawnSpec always returned —
333
+ * byte-identical, which is the point.
334
+ */
335
+ export function candidateSpec(raw, args, platform = process.platform, deps) {
336
+ const launch = isBatch(raw, platform) ? (shimPath(raw, deps) ?? raw) : raw;
337
+ return { ...spawnSpec(launch, args, platform), launch };
338
+ }
339
+
340
+ /**
341
+ * Stop a child AND everything it started.
342
+ *
343
+ * On POSIX the child is the tool, so a signal to it is the whole job and this
344
+ * is exactly the `child.kill()` it replaces. On Windows a .cmd or .bat runs
345
+ * THROUGH cmd.exe (see viaCmd), so the tool — node, for the claude and npm
346
+ * shims — is a grandchild, and a kill is one TerminateProcess against the
347
+ * wrapper. Windows terminates no descendants and libuv's job object lets them
348
+ * break away, so the wrapper vanished and the work carried on: every cancelled
349
+ * or expired `claude auth login` left a node.exe blocked forever on the stdin
350
+ * pipe this process holds, and because that node.exe kept the inherited stdout
351
+ * handle open, the wrapper's 'close' never arrived either — the run that was
352
+ * reported dead never settled.
353
+ *
354
+ * `taskkill /T` is the descendant walk Windows does have, and `/F` is what
355
+ * stops a console app that is not pumping its message queue. Taking it from
356
+ * System32 rather than from PATH matters here: PATH is the user's, and this is
357
+ * the program we hand a pid to kill. If it cannot run at all, the plain kill
358
+ * still happens, which is no worse than before.
359
+ *
360
+ * A process group would be the tidier answer and is the wrong one on Windows:
361
+ * `detached` there only means CREATE_NEW_PROCESS_GROUP, and Node cannot signal
362
+ * a group — `process.kill(-pid)` is POSIX-only.
363
+ */
364
+ export function killTree(child, signal) {
365
+ const plain = () => { try { child?.kill(signal); } catch { /* already gone */ } };
366
+ if (process.platform !== "win32" || !child?.pid) return plain();
367
+ try {
368
+ const root = process.env.SystemRoot || process.env.systemroot;
369
+ const exe = root ? `${root}\\System32\\taskkill.exe` : "taskkill";
370
+ const killer = spawn(exe, ["/pid", String(child.pid), "/T", "/F"], {
371
+ stdio: "ignore", windowsHide: true,
372
+ });
373
+ killer.on("error", plain);
374
+ killer.on("exit", (code) => { if (code !== 0) plain(); });
375
+ killer.unref?.();
376
+ } catch {
377
+ plain();
378
+ }
379
+ }
380
+
381
+ // Reasons to try the next candidate spelling rather than give up. EINVAL and
382
+ // UNKNOWN show up on Windows for a file that exists but cannot be executed the
383
+ // way it was asked for; both mean "not this one", not "no such tool".
384
+ export const tryNext = (err) =>
385
+ Boolean(err) && (err.code === "ENOENT" || err.code === "EACCES" ||
386
+ err.code === "EINVAL" || err.code === "UNKNOWN");
387
+
388
+ // The three lines an ENGLISH cmd.exe prints when it cannot find what it was
389
+ // asked to run, anchored to a whole line each. First the two-line pair for a
390
+ // bare name, then the one it uses when a directory in an explicit path does not
391
+ // exist.
392
+ //
393
+ // These are one signal out of three rather than the whole answer — see
394
+ // looksMissing. Windows ships cmd.exe in every language it ships in, and these
395
+ // sentences are translated with it.
396
+ const CMD_UNKNOWN = /^'(.+)' is not recognized as an internal or external command,?$/i;
397
+ const CMD_UNKNOWN_TAIL = /^operable program or batch file\.?$/i;
398
+ const CMD_NO_PATH = /^the system cannot find the (?:path|file) specified\.?$/i;
399
+
400
+ // cmd.exe's own errorlevel for a command token it could not resolve — the one
401
+ // signal here that is not a human sentence, and therefore the only one that is
402
+ // the same on a German install as on an English one. It is the number every CI
403
+ // log in the world prints beside "is not recognized".
404
+ //
405
+ // Not every path reports it: some Windows builds answer a bare `cmd /c missing`
406
+ // with a plain 1 instead, which is why this is a sufficient signal and never a
407
+ // necessary one. A number this large cannot arrive from POSIX at all — a process
408
+ // exit status there is masked to 0-255 before Node ever sees it — so nothing
409
+ // outside cmd.exe can reach it by accident.
410
+ const CMD_NOT_FOUND_EXIT = 9009;
411
+
412
+ /** True when an exit status is cmd.exe saying "no such command", in any locale. */
413
+ export const notFoundExit = (code) => Number(code) === CMD_NOT_FOUND_EXIT;
414
+
415
+ // Every run of text this line wrapped in quotes. cmd.exe quotes the command
416
+ // token it could not find in EVERY locale, and the quote character is the only
417
+ // part of the message that is not a translation: `'…'` in English and French,
418
+ // `"…"` in German, Spanish, Italian, Portuguese, Polish and Russian.
419
+ const quotedRuns = (line) =>
420
+ [...String(line).matchAll(/'([^']+)'|"([^"]+)"/g)].map(m => m[1] ?? m[2]);
421
+
422
+ // cmd.exe echoes the command token exactly as it was given, so an exact match
423
+ // is what we expect; the comparison is only case- and quote-insensitive because
424
+ // Windows paths are.
425
+ const sameCommand = (quoted, name) => {
426
+ const norm = (s) => String(s).trim().replace(/^"+|"+$/g, "").toLowerCase();
427
+ return norm(quoted) === norm(name);
428
+ };
429
+
430
+ /**
431
+ * cmd.exe's way of saying ENOENT — and only cmd.exe's.
432
+ *
433
+ * A .cmd or .bat candidate is launched THROUGH cmd.exe, and cmd.exe exists — so
434
+ * a missing tool is not a spawn error at all. It is a healthy shell exiting 1
435
+ * after printing two lines:
436
+ *
437
+ * 'cswap' is not recognized as an internal or external command,
438
+ * operable program or batch file.
439
+ *
440
+ * Read as a real failure, that stops the candidate loop early AND puts the
441
+ * second line — on its own, meaningless — in front of the user. Reported from
442
+ * Windows on 2026-08-14: the accounts panel said only "operable program or
443
+ * batch file." when sharing an account.
444
+ *
445
+ * The fussiness is about the other direction, which is worse. "The system
446
+ * cannot find the file specified" is Windows' generic text for ENOENT, and it
447
+ * appears INSIDE the output of tools that ran perfectly well — cswap is a
448
+ * Python CLI, and a FileNotFoundError traceback ends in that exact sentence.
449
+ * Believed there, it threw the real error away, told the user the tool was not
450
+ * on PATH when PATH was fine, and — the dangerous half — sent the candidate
451
+ * loop on to re-run the entire command, which for `cswap remove 3` means asking
452
+ * to delete an account a second time.
453
+ *
454
+ * So the output has to be cmd.exe's message and nothing else: cmd.exe prints it
455
+ * INSTEAD of running anything, so any other line, or any prefix on the line,
456
+ * means something ran and this is its report. And when cmd.exe names the
457
+ * command it could not find, that name must be the candidate we asked for — a
458
+ * tool that shells out itself can forward the message about some other command.
459
+ *
460
+ * `name` is the spelling cmd.exe was ACTUALLY GIVEN — which since #457 is the
461
+ * shim's absolute path whenever shimPath found one, not the bare candidate the
462
+ * loop started from. That distinction is the coupling #456 named as its reason
463
+ * for stopping short, and it is a one-way trap rather than a detail: cmd.exe
464
+ * echoes the command token back exactly as it received it, so a check against
465
+ * the bare name stops matching the instant the token becomes a path. What that
466
+ * costs is not a cosmetic mismatch — it is the honesty of the whole answer. A
467
+ * shim that shimPath saw and that is gone by the time cmd.exe looks for it (a
468
+ * stale memo, an uninstall mid-session, a network drive that dropped, a roaming
469
+ * profile still syncing) would come back as an ordinary exit 1 whose stderr is
470
+ * cmd.exe's two-line "is not recognized / operable program or batch file."
471
+ * instead of the `code: "ENOENT"` every panel keys its "not installed" message
472
+ * off. The user would be told their CLI failed, and shown half a sentence about
473
+ * batch files, when the truthful answer is that it is not there.
474
+ *
475
+ * Feeding it the launch spelling keeps the comparison EXACT, which is what the
476
+ * paragraph above is protecting: matching loosely — on the basename, say —
477
+ * would let a tool that shells out itself have its own child's "is not
478
+ * recognized" read as the tool's absence, re-running a command that may be
479
+ * `cswap remove 3`. So the rule is not "compare less", it is "compare against
480
+ * what was actually asked for".
481
+ *
482
+ * Callers that only have the text (failureText) omit it and get the shape rules
483
+ * alone.
484
+ *
485
+ * ── AND NOT ONLY IN ENGLISH (#552) ──────────────────────────────────────────
486
+ *
487
+ * Everything above was written against three English sentences, and cmd.exe is
488
+ * translated. On a German install with cswap genuinely absent it prints
489
+ *
490
+ * Der Befehl "cswap" ist entweder falsch geschrieben oder konnte nicht
491
+ * gefunden werden.
492
+ *
493
+ * — so this answered false, `run` resolved `{ ok: false, code: 1 }`,
494
+ * claude-accounts.mjs picked `reason: "switch_failed"` over `"no_cswap"` and the
495
+ * panel's install affordance never appeared. failureText then fell through to
496
+ * firstUseful, which puts the LAST line of a localized sentence on screen by
497
+ * itself: #457's symptom reproduced for every non-English locale. It also
498
+ * stopped the candidate loop early, so a `.bat` installed after a missing `.cmd`
499
+ * was never reached.
500
+ *
501
+ * Adding the German sentence, and then the French and the Japanese ones, is not
502
+ * a fix — it is the same defect with a longer list. So two signals that are not
503
+ * sentences carry the answer instead, and the English text is what remains when
504
+ * neither is available:
505
+ *
506
+ * 1. THE EXIT STATUS. `exitCode` 9009 is cmd.exe's own errorlevel for a
507
+ * command token it could not resolve, identical in every language. See
508
+ * CMD_NOT_FOUND_EXIT for why it is not required, and the paragraph below
509
+ * for the two cases where it is not believed either. It is subject to the
510
+ * same "cmd.exe printed nothing else" cap as rule 2, because a status is
511
+ * forwarded as easily as a sentence is.
512
+ *
513
+ * 2. THE SHAPE. cmd.exe quotes the command it could not find, in every locale,
514
+ * and prints that INSTEAD of running anything — so the whole output is at
515
+ * most the two lines of one wrapped sentence. Text of at most two lines
516
+ * whose FIRST line quotes exactly the spelling we launched is cmd.exe's
517
+ * verdict about our command whatever the words around it say.
518
+ *
519
+ * Rule 2 needs `name`, and refuses without it. That is the same principle the
520
+ * paragraphs above argue for and not a limitation bolted on: `Error: "account-9"
521
+ * does not exist` is one line with a quoted token in it, and read as an absence
522
+ * it would send the candidate loop back round to re-run `cswap remove 3`. What
523
+ * makes the rule safe is that the quoted token has to be the exact spelling
524
+ * cmd.exe was handed — `cswap.cmd`, or the absolute path shimPath found — which
525
+ * is a string the tool underneath has no reason to print. The two-line cap is
526
+ * the other half: a Python traceback ending in "The system cannot find the file
527
+ * specified" is four lines and can never qualify.
528
+ *
529
+ * THE SAME TRAP THE ENGLISH RULE ALREADY AVOIDS, NOW FOR THE EXIT STATUS. A
530
+ * `.cmd` shim is itself a batch file, so a shim that EXISTS and whose payload
531
+ * interpreter does not — a scoop or npm-style `cswap.cmd` in front of a python
532
+ * that was uninstalled — has cmd.exe print "is not recognized" about PYTHON and
533
+ * hands the shim's caller that same 9009. Believed on its own, the deck would
534
+ * call the tool absent and re-run the command under the next spelling. So a text
535
+ * that positively names a command OTHER than ours vetoes every rule here,
536
+ * including the status: the check that made #457 safe, applied one level up.
537
+ *
538
+ * What is deliberately still missed: a LOCALIZED "the system cannot find the
539
+ * path specified", which carries no quoted token and no structure to key off.
540
+ * That case only arises when shimPath found a shim that then vanished, and it
541
+ * fails in the safe direction — an honest exit 1 rather than a wrong ENOENT.
542
+ */
543
+ export function looksMissing(text, name = "", exitCode = null) {
544
+ const lines = String(text ?? "").split(/\r?\n/).map(l => l.trim()).filter(Boolean);
545
+ // Whatever cmd.exe named, in whatever language it said the rest — the quoting
546
+ // is the part that is not a translation.
547
+ const named = lines.length ? quotedRuns(lines[0]) : [];
548
+ const namesUs = named.some(q => sameCommand(q, name));
549
+
550
+ // The veto, before anything is believed: a message about somebody else's
551
+ // command is not evidence about ours, and neither is the status that came
552
+ // with it. See the header — a `.cmd` shim in front of a missing interpreter
553
+ // forwards both.
554
+ if (name && named.length > 0 && !namesUs) return false;
555
+
556
+ // cmd.exe says this INSTEAD of running anything, so anything longer than one
557
+ // wrapped sentence came from something that DID run — and that outranks both
558
+ // signals below. A shim can forward its child's 9009 after printing pages of
559
+ // its own; a Python traceback is four lines and one of them quotes the very
560
+ // shim we launched.
561
+ const saidNothingElse = lines.length <= 2;
562
+
563
+ // The signal that is not a sentence. It does not need to READ the text, which
564
+ // is the whole reason it exists: on a non-English install there may be nothing
565
+ // in the text this can read.
566
+ if (saidNothingElse && notFoundExit(exitCode)) return true;
567
+ if (!lines.length) return false;
568
+
569
+ // The English shapes, whole-line anchored, exactly as before.
570
+ let english = true;
571
+ for (const line of lines) {
572
+ const unknown = CMD_UNKNOWN.exec(line);
573
+ if (unknown) {
574
+ if (name && !sameCommand(unknown[1], name)) return false;
575
+ continue;
576
+ }
577
+ if (CMD_UNKNOWN_TAIL.test(line) || CMD_NO_PATH.test(line)) continue;
578
+ english = false;
579
+ break;
580
+ }
581
+ if (english) return true;
582
+
583
+ // Otherwise: cmd.exe in some other language, recognised by its shape and by
584
+ // the one word in it that is ours.
585
+ return Boolean(name) && saidNothingElse && namesUs;
586
+ }
587
+
588
+ // How much of a hung child's output the deadline keeps. The full buffers belong
589
+ // to execFile's callback, which a timed-out run never waits for, and the tail is
590
+ // where a tool puts the line that explains itself.
591
+ const TIMEOUT_TAIL = 8 << 10;
592
+
593
+ /**
594
+ * Run a command and collect its output. Never rejects, and never throws —
595
+ * failures come back as `{ ok: false }`, because every caller here is a poll or
596
+ * a UI action where a missing tool is an expected state rather than an
597
+ * exception. execFile can throw synchronously on Windows, so the call itself is
598
+ * guarded as well as its callback.
599
+ *
600
+ * A run stopped by its deadline answers `{ ok: false, code: "ETIMEDOUT",
601
+ * killed: true, timedOut: true }` — never ok, whatever the child said on its
602
+ * way out.
603
+ *
604
+ * `env` replaces the child's environment wholesale, the way spawn's does; pass
605
+ * `{...process.env, X: "1"}` to add to it. It exists because the quota probe
606
+ * runs a whole Claude Code and has to mark the run as the deck's own, and that
607
+ * marker is what stops every poll drawing itself onto the canvas.
608
+ */
609
+ export function run(cmd, args, { timeout = 20_000, maxBuffer = 4 << 20, env } = {}) {
610
+ const tries = candidates(cmd);
611
+ return new Promise((resolve) => {
612
+ const attempt = (i) => {
613
+ if (i >= tries.length) {
614
+ return resolve({ ok: false, code: "ENOENT", killed: false, timedOut: false, stdout: "", stderr: "" });
615
+ }
616
+ const raw = tries[i];
617
+ // `launch` is what cmd.exe is handed and `raw` is what the loop is
618
+ // reasoning about; on Windows those differ for a batch candidate whose
619
+ // shim was found (#457) and are the same everywhere else.
620
+ const { file, args: argv, opts, launch } = candidateSpec(raw, args);
621
+
622
+ const tree = isBatch(raw);
623
+ let timer = null, timedOut = false;
624
+ // A tail of what the child managed to say. The deadline below answers
625
+ // before execFile's callback does, and the callback owns the full
626
+ // buffers, so without this copy a timed-out run reports nothing at all —
627
+ // and the last line a hung tool printed is usually the only clue why it
628
+ // hung.
629
+ let sawOut = "", sawErr = "";
630
+
631
+ const done = (err, stdout, stderr) => {
632
+ clearTimeout(timer);
633
+ // The deadline already gave this attempt its verdict, and killed the
634
+ // child to make it stop. What the corpse reports is not news, and
635
+ // believing it is what put "cswap export exited 0" in front of the
636
+ // user: execFile calls a signalled exit `code: null`, which `?? 0`
637
+ // turns into a success code, and a tool that handles SIGTERM by
638
+ // exiting 0 — the well-behaved kind — arrives here with no error at
639
+ // all, so the run we cut short came back `ok: true` and got its
640
+ // spelling remembered as one that works.
641
+ if (timedOut) return;
642
+ // cmd.exe's "is not recognized" counts as "not this spelling" too, and
643
+ // it arrives as a normal non-zero exit rather than a spawn error. Only
644
+ // a batch candidate goes through a shell, so only there can the output
645
+ // be a shell's verdict rather than the tool's own words — spawned
646
+ // directly, a missing file is a plain ENOENT and anything printed came
647
+ // from a tool that ran.
648
+ // `err.code` is the exit STATUS for a child that ran and failed, which
649
+ // is where cmd.exe's language-independent 9009 arrives; for a spawn
650
+ // failure it is an errno string, and Number() of that is NaN. Either
651
+ // way looksMissing is handed what the attempt actually reported.
652
+ const missing = Boolean(err) && tree && looksMissing(`${stderr ?? ""}\n${stdout ?? ""}`, launch, err.code);
653
+ if (err && (tryNext(err) || missing) && i + 1 < tries.length) return attempt(i + 1);
654
+ // The CANDIDATE is what gets remembered, never the resolved path. The
655
+ // memo is the only entry `candidates` offers afterwards, so recording an
656
+ // absolute path would pin this process to one install location for its
657
+ // whole life — an upgrade that moves the shim would then fail forever
658
+ // where today it simply gets found again. Re-running the lookup per
659
+ // attempt costs a handful of stats against a process spawn.
660
+ if (!err) resolved.set(cmd, raw);
661
+ resolve({
662
+ ok: !err,
663
+ // A tool cmd.exe could not find is missing, not "exited 1" — callers
664
+ // key their message off this.
665
+ code: missing ? "ENOENT" : (err?.code ?? 0),
666
+ killed: Boolean(err?.killed),
667
+ timedOut: false,
668
+ stdout: String(stdout ?? ""),
669
+ stderr: String(stderr ?? ""),
670
+ });
671
+ };
672
+
673
+ try {
674
+ const cp = execFile(file, argv,
675
+ { timeout: 0, shell: false, windowsHide: true, maxBuffer, ...(env ? { env } : {}), ...opts }, done);
676
+ // Give the child EOF on stdin straight away, which is what this
677
+ // function's contract has always claimed ("run closes stdin", says
678
+ // runInteractive's header) and what execFile does not do: it leaves the
679
+ // pipe open with nobody at the writing end, so a tool that reads stdin
680
+ // waits for a writer that will never arrive. `claude --print /usage`
681
+ // waits three seconds for exactly that before giving up, which is why
682
+ // the shell command it replaced had to end in `< /dev/null`. Closing
683
+ // the pipe is that redirection without a shell to parse it.
684
+ try { cp.stdin?.on("error", () => {}); cp.stdin?.end(); } catch { /* no stdin to close */ }
685
+ cp.stdout?.on("data", (d) => { sawOut = (sawOut + d).slice(-TIMEOUT_TAIL); });
686
+ cp.stderr?.on("data", (d) => { sawErr = (sawErr + d).slice(-TIMEOUT_TAIL); });
687
+ // The deadline states the outcome itself and only then kills, which is
688
+ // the order startUpgrade needs for the same reason: the answer must not
689
+ // depend on the killed child cooperating.
690
+ //
691
+ // execFile's own `timeout` is not used at all. It ends with one signal
692
+ // to the process it started, which for a batch candidate is the cmd.exe
693
+ // wrapper — the deadline was reported as enforced while the tool
694
+ // underneath went on running — and it reports through the callback,
695
+ // which waits for the stdio pipes. On Windows those are held by the
696
+ // grandchild under the wrapper, so when the tree kill could not reach
697
+ // it the callback never came and the run never settled at all: the
698
+ // accounts panel sat on a request that had already timed out.
699
+ timer = setTimeout(() => {
700
+ timedOut = true;
701
+ resolve({ ok: false, code: "ETIMEDOUT", killed: true, timedOut: true, stdout: sawOut, stderr: sawErr });
702
+ killTree(cp);
703
+ }, timeout);
704
+ timer.unref?.();
705
+ } catch (err) {
706
+ // Synchronous throw — the EINVAL case. Same handling as a callback
707
+ // error; letting it propagate here is what crashed the server, because
708
+ // this runs inside the previous attempt's error handler.
709
+ done(err, "", "");
710
+ }
711
+ };
712
+ attempt(0);
713
+ });
714
+ }
715
+
716
+ /**
717
+ * Run a command whose stdin stays open, so the caller can answer it.
718
+ *
719
+ * `run` above closes stdin and waits for the end; that is right for everything
720
+ * that only reports. It is useless for the two commands the accounts panel has
721
+ * to drive: `claude auth login` prints a URL and then blocks reading the code
722
+ * the user pastes back, and `cswap remove` blocks on its own `[y/N]` — there is
723
+ * no `--yes` flag to avoid it. Both need a child that outlives one request and
724
+ * can be written to.
725
+ *
726
+ * Returns immediately with a handle:
727
+ * write(text) — into the child's stdin
728
+ * kill() — give up; `done` settles within killGrace either way
729
+ * onLine(cb) — every complete stdout/stderr line as it arrives
730
+ * done — Promise<{ok, code, killed, timedOut, stdout, stderr}>
731
+ *
732
+ * Never rejects, for the same reason `run` never does. And never stays pending:
733
+ * the deadline answers with `code: "ETIMEDOUT", timedOut: true` at the moment
734
+ * it expires rather than waiting on a 'close' a surviving descendant can hold
735
+ * back forever — see the timer below, and #614 for what that cost. Same Windows
736
+ * candidate resolution, since `claude` and `cswap` are `.cmd` shims there.
737
+ */
738
+ export function runInteractive(cmd, args, { timeout = 300_000, maxOutput = 256 << 10, killGrace = 2_000 } = {}) {
739
+ const tries = candidates(cmd);
740
+ const lineSubs = [];
741
+ let child = null;
742
+ let pending = ""; // partial line carried between chunks
743
+ let stdout = "", stderr = "";
744
+ let timedOut = false, killed = false;
745
+ let settle;
746
+ const done = new Promise((resolve) => { settle = resolve; });
747
+
748
+ // Subscribers get `(text, partial)`. A subscriber must not throw and must
749
+ // tolerate repeats: `partial` is the still-unterminated tail, re-offered as
750
+ // it grows, because a prompt is written WITHOUT a newline —
751
+ // "Paste code here if prompted > " never terminates a line, so a
752
+ // newline-only reader would wait for it forever.
753
+ const emitLines = (text) => {
754
+ pending += text;
755
+ let nl;
756
+ while ((nl = pending.indexOf("\n")) !== -1) {
757
+ const line = pending.slice(0, nl).replace(/\r$/, "");
758
+ pending = pending.slice(nl + 1);
759
+ for (const cb of lineSubs) { try { cb(line, false); } catch { /* a subscriber must not kill the child */ } }
760
+ }
761
+ if (pending) {
762
+ for (const cb of lineSubs) { try { cb(pending, true); } catch { /* ignore */ } }
763
+ }
764
+ };
765
+
766
+ let graceTimer = null;
767
+
768
+ const finish = (code, err) => {
769
+ if (!settle) return;
770
+ const s = settle; settle = null;
771
+ clearTimeout(timer);
772
+ clearTimeout(graceTimer);
773
+ s({ ok: code === 0 && !err && !timedOut, code: err?.code ?? code ?? -1, killed, timedOut, stdout, stderr });
774
+ };
775
+
776
+ // The deadline states the outcome and only then kills — the order `run` uses
777
+ // forty lines above, for the reason its own header already spells out.
778
+ //
779
+ // This used to set the flag, kill, and leave `done` to the child's 'close'.
780
+ // 'close' waits for the stdio pipes, not merely for the exit, so ONE
781
+ // descendant that outlives the kill holding the inherited stdout keeps it
782
+ // from ever arriving: the child is dead, the promise is pending, and it stays
783
+ // pending for the life of the process. On Windows that is the ordinary shape
784
+ // rather than a corner — a `.cmd` shim runs the real tool as a grandchild
785
+ // under cmd.exe, so a taskkill that cannot run reaches only the wrapper — and
786
+ // on macOS/Linux any cswap subprocess still alive when SIGTERM lands does it.
787
+ //
788
+ // What it cost: both callers await this INSIDE withStoreLock, so the pending
789
+ // promise is the accounts mutex. Every later mutation — login, cancel,
790
+ // import, remove, alias, reorder — queued behind a link that would never
791
+ // settle, the HTTP request was never answered, and spawnLogin's
792
+ // process-on-exit handler leaked with the promise. Reported as #614.
793
+ const timer = setTimeout(() => {
794
+ timedOut = true;
795
+ killed = true;
796
+ // Whatever the child managed to print rides along, the way run()'s tail
797
+ // does: it had not finished, but it is all a caller has to go on.
798
+ finish(-1, { code: "ETIMEDOUT" });
799
+ killTree(child);
800
+ }, timeout);
801
+ timer.unref?.();
802
+
803
+ const attempt = (i) => {
804
+ if (i >= tries.length) return finish(-1, { code: "ENOENT" });
805
+ const raw = tries[i];
806
+ // Same split as in `run`: `launch` is the spelling cmd.exe receives — an
807
+ // absolute `.cmd` once shimPath can see one — and `raw` stays the candidate
808
+ // the loop and the memo are about.
809
+ const { file, args: argv, opts, launch } = candidateSpec(raw, args);
810
+ let proc;
811
+ try {
812
+ proc = spawn(file, argv, { stdio: ["pipe", "pipe", "pipe"], shell: false, windowsHide: true, ...opts });
813
+ } catch (err) {
814
+ return tryNext(err) ? attempt(i + 1) : finish(-1, err);
815
+ }
816
+ child = proc;
817
+ // A spelling that fails to spawn emits 'error' AND THEN 'close' — with code
818
+ // -2 after an ENOENT. Once the error handler has moved on to the next
819
+ // candidate, that trailing 'close' is news about a child nobody is waiting
820
+ // for any more, and answering it settled `done` with ok:false while the
821
+ // real child was still running. On Windows that is the normal path, not a
822
+ // corner case: `claude.exe` does not exist, `claude.cmd` does, so the very
823
+ // first login reported "the code was not accepted" while the child it had
824
+ // abandoned went on to complete the OAuth and switch the live account.
825
+ // Every listener below therefore speaks only while its own child is the
826
+ // current one.
827
+ const stale = () => proc !== child;
828
+ proc.on("error", (err) => {
829
+ if (stale()) return;
830
+ // Only retry another spelling while nothing has run yet; a mid-run error
831
+ // is this child's failure, not evidence the name was wrong.
832
+ if (tryNext(err) && !stdout && !stderr) { child = null; return attempt(i + 1); }
833
+ finish(-1, err);
834
+ });
835
+ // Spawning proves a spelling exists — except a .cmd/.bat one, which is
836
+ // launched THROUGH cmd.exe. cmd.exe is always there, so it spawns just as
837
+ // happily for a batch file that is not, and only says so later by exiting
838
+ // non-zero with "is not recognized". Caching at spawn time therefore
839
+ // remembered a spelling that never existed, and since `candidates` then
840
+ // offers only the remembered one, the tool stayed unrunnable — with the
841
+ // false message "not on PATH" — even after it was installed. A batch
842
+ // spelling is confirmed by the clean exit below instead, which is the rule
843
+ // `run` already applies with `if (!err)`.
844
+ if (!isBatch(raw)) proc.on("spawn", () => resolved.set(cmd, raw));
845
+ // Capped so a runaway child cannot grow the heap without bound; the tail is
846
+ // what carries the error, so the head is what gets dropped.
847
+ const keep = (buf, text) => (buf + text).slice(-maxOutput);
848
+ proc.stdout?.on("data", (d) => { if (stale()) return; const t = String(d); stdout = keep(stdout, t); emitLines(t); });
849
+ proc.stderr?.on("data", (d) => { if (stale()) return; const t = String(d); stderr = keep(stderr, t); emitLines(t); });
850
+ proc.on("close", (code) => {
851
+ if (stale()) return;
852
+ // Already answered — by the deadline above, or by kill()'s grace below.
853
+ // A late 'close' has nothing left to report, and this is not merely
854
+ // tidiness: the retry underneath re-runs the WHOLE command, and re-running
855
+ // `cswap remove` on behalf of a promise nobody is waiting for any more is
856
+ // exactly the thing that must not happen.
857
+ if (!settle) return;
858
+ // Same cmd.exe case as in `run`: exit 1 with "is not recognized" means
859
+ // this spelling does not exist, not that the tool failed. Restricted to a
860
+ // batch candidate, which is the only kind launched through a shell, and
861
+ // to output that is cmd.exe's message alone — everything below re-runs
862
+ // the whole command, and these commands remove accounts.
863
+ if (code !== 0 && isBatch(raw) && looksMissing(`${stderr}\n${stdout}`, launch, code)) {
864
+ if (i + 1 < tries.length) {
865
+ stdout = ""; stderr = ""; pending = "";
866
+ child = null;
867
+ return attempt(i + 1);
868
+ }
869
+ return finish(-1, { code: "ENOENT" });
870
+ }
871
+ // Ran to a clean exit, so this spelling is real — the only confirmation a
872
+ // batch one ever gets.
873
+ if (code === 0 && !killed && !timedOut) resolved.set(cmd, raw);
874
+ finish(code ?? -1, null);
875
+ });
876
+ };
877
+ attempt(0);
878
+
879
+ return {
880
+ write(text) {
881
+ try { child?.stdin?.write(text); } catch { /* the child is gone; `done` says so */ }
882
+ },
883
+ /** Close stdin. A command that reads to EOF (`cswap import -`) needs this
884
+ * to start work at all; a prompting one must never see it. */
885
+ end() {
886
+ try { child?.stdin?.end(); } catch { /* already closed */ }
887
+ },
888
+ /** Stop the run. On Windows that means the tool under the cmd.exe wrapper
889
+ * too — see killTree; a cancelled sign-in used to leave it running.
890
+ *
891
+ * `done` settles either way. The kill cannot promise the pipes close —
892
+ * that is the deadline's problem reached from the other side — so if the
893
+ * child's own 'close' has not arrived within killGrace, the handle answers
894
+ * without it. A cancelled sign-in must not be able to wedge the accounts
895
+ * mutex any more than an expired one. */
896
+ kill() {
897
+ killed = true;
898
+ killTree(child);
899
+ if (settle && !graceTimer) {
900
+ graceTimer = setTimeout(() => finish(-1, null), killGrace);
901
+ graceTimer.unref?.();
902
+ }
903
+ },
904
+ onLine(cb) { lineSubs.push(cb); },
905
+ done,
906
+ };
907
+ }
908
+
909
+ /**
910
+ * Start a command and don't wait for it. Same resolution, no output captured.
911
+ * Used where the result lands somewhere else — a file the next poll reads, or
912
+ * a sound the user hears.
913
+ */
914
+ export function runDetached(cmd, args) {
915
+ const tries = candidates(cmd);
916
+ const attempt = (i) => {
917
+ if (i >= tries.length) return;
918
+ const raw = tries[i];
919
+ // Nothing here reads output, so there is no looksMissing call to keep
920
+ // honest — but the shim still has to be launched by its full path, or the
921
+ // detached `cswap list` this exists for computes `%~dp0` from the deck's cwd
922
+ // exactly like every other caller.
923
+ const { file, args: argv, opts } = candidateSpec(raw, args);
924
+ try {
925
+ const child = spawn(file, argv, { stdio: "ignore", shell: false, windowsHide: true, ...opts });
926
+ child.on("error", (err) => { if (tryNext(err)) attempt(i + 1); });
927
+ // Same trap as above: a batch spelling is spawned through cmd.exe, which
928
+ // succeeds whether or not the batch file is there, so only a clean exit
929
+ // proves this one is worth remembering.
930
+ if (isBatch(raw)) child.on("exit", (code) => { if (code === 0) resolved.set(cmd, raw); });
931
+ else child.on("spawn", () => resolved.set(cmd, raw));
932
+ child.unref?.();
933
+ } catch {
934
+ attempt(i + 1);
935
+ }
936
+ };
937
+ attempt(0);
938
+ }