agent-dag 1.42.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 +10 -6
- package/bin/agent-dag.js +63 -9
- package/bin/deck.js +46 -11
- package/dist/web/assets/index-DBsxIfdM.js +78 -0
- package/dist/web/assets/index-XtT5NdJI.css +1 -0
- package/dist/web/index.html +2 -2
- package/hook/notify.mjs +104 -0
- package/package.json +2 -2
- package/src/server/ccusage.mjs +114 -2
- package/src/server/claude-accounts.mjs +145 -1
- package/src/server/codex-quota.mjs +95 -3
- package/src/server/codex-usage.mjs +108 -3
- package/src/server/cswap-admin.mjs +180 -6
- package/src/server/cswap-auto.mjs +209 -10
- package/src/server/cswap-install.mjs +238 -17
- package/src/server/exec.mjs +130 -11
- package/src/server/index.mjs +226 -19
- package/src/server/installer.mjs +145 -22
- package/src/server/invoked-as.mjs +16 -14
- package/src/server/quota.mjs +131 -34
- package/src/server/self-update.mjs +262 -21
- package/src/server/sound-hook.mjs +158 -28
- package/src/server/supervisor.mjs +36 -0
- package/src/server/system-metrics.mjs +105 -7
- package/src/server/uv-bootstrap.mjs +48 -11
- package/dist/web/assets/index-CHFnwsds.css +0 -1
- package/dist/web/assets/index-DLihciEi.js +0 -78
- package/hook/notify.js +0 -60
|
@@ -14,7 +14,7 @@ import { run, runDetached } from "./exec.mjs";
|
|
|
14
14
|
// file already imports itself.
|
|
15
15
|
import { isOlder } from "./self-update.mjs";
|
|
16
16
|
import { bootstrapUv, existingBootstrappedUv } from "./uv-bootstrap.mjs";
|
|
17
|
-
import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { existsSync, mkdirSync, readdirSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
18
18
|
import { join, posix as posixPath, win32 as winPath } from "node:path";
|
|
19
19
|
import { homedir } from "node:os";
|
|
20
20
|
|
|
@@ -25,6 +25,42 @@ const INSTALL_TIMEOUT_MS = 180_000; // uv resolves + builds a Python env
|
|
|
25
25
|
const UPDATE_CHECK_MS = 24 * 3600_000;
|
|
26
26
|
const MARKER = join(homedir(), ".agents-deck", ".cswap-update-check");
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* The subdirectories of `dir`, newest-looking first, or [] when it cannot be
|
|
30
|
+
* read at all.
|
|
31
|
+
*
|
|
32
|
+
* Injected into cswapCandidates below rather than called from it, for the reason
|
|
33
|
+
* the platform is a parameter there: a Windows layout has to be describable from
|
|
34
|
+
* a Mac. A missing directory, a disconnected network drive and a profile the
|
|
35
|
+
* process cannot read are all the same answer — nothing here — never a throw,
|
|
36
|
+
* because this runs unprompted at startup for an optional panel.
|
|
37
|
+
*
|
|
38
|
+
* The sort is numeric so `Python313` sorts above `Python39` rather than below
|
|
39
|
+
* it: when two interpreters both have a cswap, the newer one is the one the user
|
|
40
|
+
* most likely installed it with, and the order this returns is the order the
|
|
41
|
+
* caller probes in.
|
|
42
|
+
*
|
|
43
|
+
* Exported for its test rather than for a caller. Every part of it that can be
|
|
44
|
+
* wrong — which names count as an interpreter, what order they come back in,
|
|
45
|
+
* what a directory that cannot be read answers — is invisible from
|
|
46
|
+
* cswapCandidates, which injects a substitute precisely so its own Windows
|
|
47
|
+
* layout can be checked from a Mac. Driven against real directories in
|
|
48
|
+
* cswap-admin.test.ts, on whichever OS is running the suite.
|
|
49
|
+
*/
|
|
50
|
+
export function pythonVersionDirs(dir) {
|
|
51
|
+
try {
|
|
52
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
53
|
+
.filter(e => e.isDirectory() || e.isSymbolicLink())
|
|
54
|
+
.map(e => e.name)
|
|
55
|
+
// `Python312`, and the tagged builds the installer also writes:
|
|
56
|
+
// `Python312-32`, `Python313-arm64`.
|
|
57
|
+
.filter(n => /^Python\d[\w.-]*$/i.test(n))
|
|
58
|
+
.sort((a, b) => b.localeCompare(a, "en", { numeric: true }));
|
|
59
|
+
} catch {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
28
64
|
/**
|
|
29
65
|
* How to invoke cswap: the bare name when PATH resolves it, otherwise an
|
|
30
66
|
* absolute path to where its installers actually put it.
|
|
@@ -40,12 +76,14 @@ const MARKER = join(homedir(), ".agents-deck", ".cswap-update-check");
|
|
|
40
76
|
* up without a restart.
|
|
41
77
|
*/
|
|
42
78
|
/**
|
|
43
|
-
* Every place an installer is known to leave cswap.
|
|
44
|
-
*
|
|
45
|
-
* way this list stays right, since it exists entirely
|
|
46
|
-
* not sitting at.
|
|
79
|
+
* Every place an installer is known to leave cswap. The platform is a parameter
|
|
80
|
+
* and the directory listing is injected, so the Windows list can be checked from
|
|
81
|
+
* a Mac — which is the only way this list stays right, since it exists entirely
|
|
82
|
+
* for machines the author is not sitting at.
|
|
47
83
|
*/
|
|
48
|
-
export function cswapCandidates(platform = process.platform, env = process.env, home = homedir()
|
|
84
|
+
export function cswapCandidates(platform = process.platform, env = process.env, home = homedir(), {
|
|
85
|
+
versionDirs = pythonVersionDirs,
|
|
86
|
+
} = {}) {
|
|
49
87
|
// The path flavour follows the PLATFORM ARGUMENT, not the host: node's `join`
|
|
50
88
|
// would emit forward slashes when this is exercised from a Mac, which is both
|
|
51
89
|
// wrong for the caller and invisible in a test.
|
|
@@ -60,8 +98,31 @@ export function cswapCandidates(platform = process.platform, env = process.env,
|
|
|
60
98
|
if (platform === "win32") {
|
|
61
99
|
// pipx before 1.5, and any `pip install --user`. APPDATA is respected when
|
|
62
100
|
// set because a roaming profile moves it off the home directory.
|
|
63
|
-
|
|
64
|
-
|
|
101
|
+
//
|
|
102
|
+
// THE VERSION SEGMENT IS NOT OPTIONAL (#552). CPython on Windows always
|
|
103
|
+
// puts the interpreter between the root and `Scripts`:
|
|
104
|
+
//
|
|
105
|
+
// %APPDATA%\Python\Python312\Scripts pip install --user
|
|
106
|
+
// %LOCALAPPDATA%\Programs\Python\Python312\Scripts per-user installer
|
|
107
|
+
//
|
|
108
|
+
// — `{userbase}\Python{version_nodot}\Scripts` is sysconfig's `nt_user`
|
|
109
|
+
// scheme, not a convention. The two paths this used to build omitted it, so
|
|
110
|
+
// NEITHER could exist on a real machine: every candidate missed,
|
|
111
|
+
// `cswapVersion` answered null, `ensureCswap` reported `not_on_path`, and
|
|
112
|
+
// the deck re-ran a whole install attempt on every launch for a user who
|
|
113
|
+
// already had cswap.exe sitting there. The POSIX side never had the bug —
|
|
114
|
+
// `~/.local/bin` carries no version — which is why this stayed a Windows
|
|
115
|
+
// false negative in the one function whose whole purpose is to not depend
|
|
116
|
+
// on PATH.
|
|
117
|
+
//
|
|
118
|
+
// Which versions exist is a fact about the machine, so it is read rather
|
|
119
|
+
// than guessed: enumerating Python38…Python315 would be eight wrong paths
|
|
120
|
+
// and a ninth wrong one next year.
|
|
121
|
+
const appData = env.APPDATA || join(home, "AppData", "Roaming");
|
|
122
|
+
const localAppData = env.LOCALAPPDATA || join(home, "AppData", "Local");
|
|
123
|
+
for (const root of [join(appData, "Python"), join(localAppData, "Programs", "Python")]) {
|
|
124
|
+
for (const version of versionDirs(root)) dirs.push(join(root, version, "Scripts"));
|
|
125
|
+
}
|
|
65
126
|
dirs.push(join(home, "scoop", "shims"));
|
|
66
127
|
} else {
|
|
67
128
|
dirs.push(join(home, ".pyenv", "shims"));
|
|
@@ -73,6 +134,137 @@ export function cswapCandidates(platform = process.platform, env = process.env,
|
|
|
73
134
|
return dirs.map(d => join(d, exe));
|
|
74
135
|
}
|
|
75
136
|
|
|
137
|
+
// The distribution name, which is what both installers key their directories on
|
|
138
|
+
// — `cswap` is only the console script.
|
|
139
|
+
const PKG = "claude-swap";
|
|
140
|
+
|
|
141
|
+
/** True when `child` is `dir` or lives under it, in `platform`'s path flavour. */
|
|
142
|
+
function underDir(child, dir, platform) {
|
|
143
|
+
const { sep, normalize } = platform === "win32" ? winPath : posixPath;
|
|
144
|
+
const norm = p => {
|
|
145
|
+
// Windows paths compare case-insensitively, and `C:\x\` and `C:\x` are one
|
|
146
|
+
// directory.
|
|
147
|
+
let s = normalize(String(p));
|
|
148
|
+
if (platform === "win32") s = s.toLowerCase();
|
|
149
|
+
return s.length > 1 && s.endsWith(sep) ? s.slice(0, -sep.length) : s;
|
|
150
|
+
};
|
|
151
|
+
const c = norm(child), d = norm(dir);
|
|
152
|
+
return c === d || c.startsWith(d + sep);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Where `uv tool install claude-swap` puts the tool's own venv.
|
|
157
|
+
*
|
|
158
|
+
* UV_TOOL_DIR wins outright; otherwise uv's persistent data directory, which is
|
|
159
|
+
* `$XDG_DATA_HOME/uv` or `~/.local/share/uv` on Unix — macOS included, uv does
|
|
160
|
+
* not use `~/Library` — and `%APPDATA%\uv\data` on Windows. The `data` segment
|
|
161
|
+
* is Windows-only and is not optional there.
|
|
162
|
+
*/
|
|
163
|
+
function uvToolVenvs(platform, env, home) {
|
|
164
|
+
const { join } = platform === "win32" ? winPath : posixPath;
|
|
165
|
+
if (env.UV_TOOL_DIR) return [join(env.UV_TOOL_DIR, PKG)];
|
|
166
|
+
if (platform === "win32") {
|
|
167
|
+
const appData = env.APPDATA || join(home, "AppData", "Roaming");
|
|
168
|
+
return [join(appData, "uv", "data", "tools", PKG)];
|
|
169
|
+
}
|
|
170
|
+
return [join(env.XDG_DATA_HOME || join(home, ".local", "share"), "uv", "tools", PKG)];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Where `pipx install claude-swap` puts the package's venv.
|
|
175
|
+
*
|
|
176
|
+
* Read off pipx's own `paths.py`: the venvs are always `<home>/venvs`, and the
|
|
177
|
+
* home is PIPX_HOME when set, else the first EXISTING legacy fallback
|
|
178
|
+
* (`~/.local/pipx`, plus `~/pipx` on Windows), else platformdirs'
|
|
179
|
+
* `user_data_path("pipx")`. Since what gets asked here is whether one specific
|
|
180
|
+
* venv is on disk, every candidate home can simply be tried rather than
|
|
181
|
+
* replaying pipx's precedence.
|
|
182
|
+
*
|
|
183
|
+
* platformdirs on Windows appends the app name twice when no author is given,
|
|
184
|
+
* which pipx does not give — `%LOCALAPPDATA%\pipx\pipx`, not `%LOCALAPPDATA%\
|
|
185
|
+
* pipx`. That doubled segment is real and is the whole path on a modern
|
|
186
|
+
* Windows pipx.
|
|
187
|
+
*/
|
|
188
|
+
function pipxVenvs(platform, env, home) {
|
|
189
|
+
const { join } = platform === "win32" ? winPath : posixPath;
|
|
190
|
+
if (env.PIPX_HOME) return [join(env.PIPX_HOME, "venvs", PKG)];
|
|
191
|
+
const homes = [join(home, ".local", "pipx")];
|
|
192
|
+
if (platform === "win32") {
|
|
193
|
+
homes.push(join(home, "pipx"));
|
|
194
|
+
homes.push(join(env.LOCALAPPDATA || join(home, "AppData", "Local"), "pipx", "pipx"));
|
|
195
|
+
} else if (platform === "darwin") {
|
|
196
|
+
homes.push(join(home, "Library", "Application Support", "pipx"));
|
|
197
|
+
} else {
|
|
198
|
+
homes.push(join(env.XDG_DATA_HOME || join(home, ".local", "share"), "pipx"));
|
|
199
|
+
}
|
|
200
|
+
return homes.map(h => join(h, "venvs", PKG));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function realpathOrSelf(p) {
|
|
204
|
+
try { return realpathSync(p); } catch { return p; }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Which installer OWNS the claude-swap this machine runs — "uv", "pipx", or
|
|
209
|
+
* null when nothing offered here does, or when the evidence is ambiguous.
|
|
210
|
+
*
|
|
211
|
+
* The daily upgrade used to be handed to `findInstaller()`, which returns the
|
|
212
|
+
* first tool that answers `--version`. That is the right question when choosing
|
|
213
|
+
* something to install WITH and the wrong one when upgrading something already
|
|
214
|
+
* installed: on a machine with uv present and claude-swap installed some other
|
|
215
|
+
* way — a `pip install --user` copy, which #574 taught cswapBin to find, or a
|
|
216
|
+
* pipx one — the upgrade went to uv, which answers
|
|
217
|
+
*
|
|
218
|
+
* error: Failed to upgrade claude-swap
|
|
219
|
+
* Caused by: `claude-swap` is not installed; run `uv tool install …`
|
|
220
|
+
*
|
|
221
|
+
* and pipx, given someone else's package, answers "Package is not installed.
|
|
222
|
+
* Expected to find <PIPX_HOME>/venvs/claude-swap, but it does not exist." Both
|
|
223
|
+
* go through runDetached, which reads no output and waits for no exit, so the
|
|
224
|
+
* refusal reached nobody while ensureCswap still reported "upgrading" and the
|
|
225
|
+
* marker was already burned for the day. The version never moved and the deck
|
|
226
|
+
* said it was moving, every launch, forever.
|
|
227
|
+
*
|
|
228
|
+
* Two signals, strongest first. The executable the deck actually runs, with its
|
|
229
|
+
* symlinks followed, sitting inside one installer's directory is decisive —
|
|
230
|
+
* that is the POSIX case, where both installers link `~/.local/bin/cswap` at
|
|
231
|
+
* their own venv. Windows copies the launcher instead, so there the layout
|
|
232
|
+
* question is asked directly: exactly one of the two venv directories exists.
|
|
233
|
+
* Zero means nothing offered here owns it — a `pip install --user` copy is the
|
|
234
|
+
* common shape, and `installers()` deliberately refuses to offer bare pip — and
|
|
235
|
+
* two means the machine has both and the resolved path did not say which is on
|
|
236
|
+
* PATH. Both answer null, because a silent boot is better than a daily sentence
|
|
237
|
+
* that is not true.
|
|
238
|
+
*
|
|
239
|
+
* Pure, and platform/env/home/filesystem all arrive as arguments, for the reason
|
|
240
|
+
* cswapCandidates gives: a Windows layout has to be checkable from a Mac.
|
|
241
|
+
*/
|
|
242
|
+
export function cswapOwner(bin, platform = process.platform, env = process.env, home = homedir(), {
|
|
243
|
+
exists = existsSync,
|
|
244
|
+
realpath = realpathOrSelf,
|
|
245
|
+
} = {}) {
|
|
246
|
+
const roots = [
|
|
247
|
+
...uvToolVenvs(platform, env, home).map(dir => ({ owner: "uv", dir })),
|
|
248
|
+
...pipxVenvs(platform, env, home).map(dir => ({ owner: "pipx", dir })),
|
|
249
|
+
];
|
|
250
|
+
|
|
251
|
+
// cswapBin answers the bare word whenever PATH resolved it, and a bare word
|
|
252
|
+
// points at no layout at all — only a path can be followed.
|
|
253
|
+
if (typeof bin === "string" && /[\\/]/.test(bin)) {
|
|
254
|
+
// BOTH sides get resolved, or the comparison is between two spellings of one
|
|
255
|
+
// directory rather than between two directories. A symlinked home is the
|
|
256
|
+
// ordinary way that happens — /var → /private/var on macOS, a network or
|
|
257
|
+
// container-mounted profile on Linux — and it would silently turn the
|
|
258
|
+
// strongest signal here into no signal at all.
|
|
259
|
+
const real = realpath(bin);
|
|
260
|
+
const hit = roots.find(r => underDir(real, realpath(r.dir), platform));
|
|
261
|
+
if (hit) return hit.owner;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const owners = new Set(roots.filter(r => exists(r.dir)).map(r => r.owner));
|
|
265
|
+
return owners.size === 1 ? [...owners][0] : null;
|
|
266
|
+
}
|
|
267
|
+
|
|
76
268
|
let _bin = null;
|
|
77
269
|
export async function cswapBin() {
|
|
78
270
|
// An explicit path wins over everything and is never cached away — someone
|
|
@@ -196,11 +388,20 @@ async function safePythons() {
|
|
|
196
388
|
* derivation did not recognise fell through to `-m pipx upgrade` — so the
|
|
197
389
|
* bundled uv, which is the only installer present on a machine that had neither
|
|
198
390
|
* uv nor pipx nor a usable python, was asked to run a pipx command it rejects.
|
|
391
|
+
*
|
|
392
|
+
* Every entry also carries the `owner` it speaks for, which is what an upgrade
|
|
393
|
+
* is matched against. It is a field rather than something read back off `via`
|
|
394
|
+
* because deriving behaviour from that label is precisely what went wrong the
|
|
395
|
+
* first time: three of these five spellings are one uv and two are one pipx,
|
|
396
|
+
* and a fourth spelling arriving later must not silently mean "pipx" by
|
|
397
|
+
* default. Several entries can share an owner — the bundled uv upgrades what
|
|
398
|
+
* the system uv installed and vice versa, since both read UV_TOOL_DIR — so the
|
|
399
|
+
* probe still decides WHICH of an owner's spellings runs.
|
|
199
400
|
*/
|
|
200
401
|
async function installers() {
|
|
201
402
|
const out = [
|
|
202
|
-
{ cmd: "uv", probe: ["--version"], args: ["tool", "install", "claude-swap"], upgrade: ["tool", "upgrade", "claude-swap"], via: "uv" },
|
|
203
|
-
{ cmd: "pipx", probe: ["--version"], args: ["install", "claude-swap"], upgrade: ["upgrade", "claude-swap"], via: "pipx" },
|
|
403
|
+
{ cmd: "uv", probe: ["--version"], args: ["tool", "install", "claude-swap"], upgrade: ["tool", "upgrade", "claude-swap"], via: "uv", owner: "uv" },
|
|
404
|
+
{ cmd: "pipx", probe: ["--version"], args: ["install", "claude-swap"], upgrade: ["upgrade", "claude-swap"], via: "pipx", owner: "pipx" },
|
|
204
405
|
];
|
|
205
406
|
// A uv fetched on an earlier run counts as installed tooling from here on.
|
|
206
407
|
const own = existingBootstrappedUv();
|
|
@@ -211,6 +412,7 @@ async function installers() {
|
|
|
211
412
|
args: ["tool", "install", "claude-swap"],
|
|
212
413
|
upgrade: ["tool", "upgrade", "claude-swap"],
|
|
213
414
|
via: "uv (bundled)",
|
|
415
|
+
owner: "uv",
|
|
214
416
|
});
|
|
215
417
|
}
|
|
216
418
|
for (const py of await safePythons()) {
|
|
@@ -220,6 +422,7 @@ async function installers() {
|
|
|
220
422
|
args: ["-m", "pipx", "install", "claude-swap"],
|
|
221
423
|
upgrade: ["-m", "pipx", "upgrade", "claude-swap"],
|
|
222
424
|
via: `${py} -m pipx`,
|
|
425
|
+
owner: "pipx",
|
|
223
426
|
});
|
|
224
427
|
}
|
|
225
428
|
return out;
|
|
@@ -299,17 +502,30 @@ async function latestOnPypi() {
|
|
|
299
502
|
* take tens of seconds, which is not a thing to put in front of the server
|
|
300
503
|
* starting. The running copy keeps working; the new one is there next launch.
|
|
301
504
|
*
|
|
302
|
-
* The command line comes from the installer entry rather than from its label
|
|
303
|
-
*
|
|
304
|
-
*
|
|
505
|
+
* The command line comes from the installer entry rather than from its label,
|
|
506
|
+
* and the ENTRY comes from cswapOwner rather than from whichever tool answers a
|
|
507
|
+
* probe first: runDetached captures nothing, so an upgrade aimed at the wrong
|
|
508
|
+
* tool fails where nobody can see it while the caller still reports
|
|
509
|
+
* "upgrading". Those are the two halves of "aimed at the wrong tool" — a right
|
|
510
|
+
* argv sent to a tool that does not own the package is just as invisible as a
|
|
511
|
+
* wrong argv, and was the longer-lived of the two.
|
|
305
512
|
*/
|
|
306
513
|
function upgradeInBackground({ cmd, upgrade }) {
|
|
307
514
|
runDetached(cmd, upgrade);
|
|
308
515
|
}
|
|
309
516
|
|
|
310
|
-
/**
|
|
311
|
-
|
|
312
|
-
|
|
517
|
+
/**
|
|
518
|
+
* A runnable spelling of the installer that OWNS this claude-swap, or null.
|
|
519
|
+
*
|
|
520
|
+
* The owner is decided from the install layout before anything is probed, and
|
|
521
|
+
* only that owner's entries are then tried — so a uv sitting on a machine whose
|
|
522
|
+
* claude-swap came from pipx is skipped rather than handed an upgrade it will
|
|
523
|
+
* refuse. A null owner probes nothing at all: there is no tool here to ask.
|
|
524
|
+
*/
|
|
525
|
+
async function findUpgrader(owner) {
|
|
526
|
+
if (!owner) return null;
|
|
527
|
+
for (const { cmd, probe, upgrade, via, owner: speaksFor } of await installers()) {
|
|
528
|
+
if (speaksFor !== owner) continue;
|
|
313
529
|
if ((await run(cmd, probe, { timeout: 8_000 })).ok) return { cmd, upgrade, via };
|
|
314
530
|
}
|
|
315
531
|
return null;
|
|
@@ -335,7 +551,12 @@ export async function ensureCswap() {
|
|
|
335
551
|
touchMarker();
|
|
336
552
|
const latest = await latestOnPypi();
|
|
337
553
|
if (latest && existing !== "installed" && isOlder(existing, latest)) {
|
|
338
|
-
|
|
554
|
+
// Who owns it, not what is installed on the machine: an upgrade aimed at
|
|
555
|
+
// a tool that never installed this package is refused where runDetached
|
|
556
|
+
// cannot see it, and "upgrading" would then be a sentence printed daily
|
|
557
|
+
// about nothing. When nobody offered here owns it, "present" is the whole
|
|
558
|
+
// truth and is what gets said.
|
|
559
|
+
const found = await findUpgrader(cswapOwner(await cswapBin()));
|
|
339
560
|
if (found) {
|
|
340
561
|
upgradeInBackground(found);
|
|
341
562
|
return { state: "upgrading", version: existing, latest, via: found.via };
|
package/src/server/exec.mjs
CHANGED
|
@@ -340,13 +340,40 @@ export const tryNext = (err) =>
|
|
|
340
340
|
Boolean(err) && (err.code === "ENOENT" || err.code === "EACCES" ||
|
|
341
341
|
err.code === "EINVAL" || err.code === "UNKNOWN");
|
|
342
342
|
|
|
343
|
-
// The three lines cmd.exe prints when it cannot find what it was
|
|
344
|
-
// anchored to a whole line each. First the two-line pair for a
|
|
345
|
-
// the one it uses when a directory in an explicit path does not
|
|
343
|
+
// The three lines an ENGLISH cmd.exe prints when it cannot find what it was
|
|
344
|
+
// asked to run, anchored to a whole line each. First the two-line pair for a
|
|
345
|
+
// bare name, then the one it uses when a directory in an explicit path does not
|
|
346
|
+
// exist.
|
|
347
|
+
//
|
|
348
|
+
// These are one signal out of three rather than the whole answer — see
|
|
349
|
+
// looksMissing. Windows ships cmd.exe in every language it ships in, and these
|
|
350
|
+
// sentences are translated with it.
|
|
346
351
|
const CMD_UNKNOWN = /^'(.+)' is not recognized as an internal or external command,?$/i;
|
|
347
352
|
const CMD_UNKNOWN_TAIL = /^operable program or batch file\.?$/i;
|
|
348
353
|
const CMD_NO_PATH = /^the system cannot find the (?:path|file) specified\.?$/i;
|
|
349
354
|
|
|
355
|
+
// cmd.exe's own errorlevel for a command token it could not resolve — the one
|
|
356
|
+
// signal here that is not a human sentence, and therefore the only one that is
|
|
357
|
+
// the same on a German install as on an English one. It is the number every CI
|
|
358
|
+
// log in the world prints beside "is not recognized".
|
|
359
|
+
//
|
|
360
|
+
// Not every path reports it: some Windows builds answer a bare `cmd /c missing`
|
|
361
|
+
// with a plain 1 instead, which is why this is a sufficient signal and never a
|
|
362
|
+
// necessary one. A number this large cannot arrive from POSIX at all — a process
|
|
363
|
+
// exit status there is masked to 0-255 before Node ever sees it — so nothing
|
|
364
|
+
// outside cmd.exe can reach it by accident.
|
|
365
|
+
const CMD_NOT_FOUND_EXIT = 9009;
|
|
366
|
+
|
|
367
|
+
/** True when an exit status is cmd.exe saying "no such command", in any locale. */
|
|
368
|
+
export const notFoundExit = (code) => Number(code) === CMD_NOT_FOUND_EXIT;
|
|
369
|
+
|
|
370
|
+
// Every run of text this line wrapped in quotes. cmd.exe quotes the command
|
|
371
|
+
// token it could not find in EVERY locale, and the quote character is the only
|
|
372
|
+
// part of the message that is not a translation: `'…'` in English and French,
|
|
373
|
+
// `"…"` in German, Spanish, Italian, Portuguese, Polish and Russian.
|
|
374
|
+
const quotedRuns = (line) =>
|
|
375
|
+
[...String(line).matchAll(/'([^']+)'|"([^"]+)"/g)].map(m => m[1] ?? m[2]);
|
|
376
|
+
|
|
350
377
|
// cmd.exe echoes the command token exactly as it was given, so an exact match
|
|
351
378
|
// is what we expect; the comparison is only case- and quote-insensitive because
|
|
352
379
|
// Windows paths are.
|
|
@@ -409,20 +436,108 @@ const sameCommand = (quoted, name) => {
|
|
|
409
436
|
*
|
|
410
437
|
* Callers that only have the text (failureText) omit it and get the shape rules
|
|
411
438
|
* alone.
|
|
439
|
+
*
|
|
440
|
+
* ── AND NOT ONLY IN ENGLISH (#552) ──────────────────────────────────────────
|
|
441
|
+
*
|
|
442
|
+
* Everything above was written against three English sentences, and cmd.exe is
|
|
443
|
+
* translated. On a German install with cswap genuinely absent it prints
|
|
444
|
+
*
|
|
445
|
+
* Der Befehl "cswap" ist entweder falsch geschrieben oder konnte nicht
|
|
446
|
+
* gefunden werden.
|
|
447
|
+
*
|
|
448
|
+
* — so this answered false, `run` resolved `{ ok: false, code: 1 }`,
|
|
449
|
+
* claude-accounts.mjs picked `reason: "switch_failed"` over `"no_cswap"` and the
|
|
450
|
+
* panel's install affordance never appeared. failureText then fell through to
|
|
451
|
+
* firstUseful, which puts the LAST line of a localized sentence on screen by
|
|
452
|
+
* itself: #457's symptom reproduced for every non-English locale. It also
|
|
453
|
+
* stopped the candidate loop early, so a `.bat` installed after a missing `.cmd`
|
|
454
|
+
* was never reached.
|
|
455
|
+
*
|
|
456
|
+
* Adding the German sentence, and then the French and the Japanese ones, is not
|
|
457
|
+
* a fix — it is the same defect with a longer list. So two signals that are not
|
|
458
|
+
* sentences carry the answer instead, and the English text is what remains when
|
|
459
|
+
* neither is available:
|
|
460
|
+
*
|
|
461
|
+
* 1. THE EXIT STATUS. `exitCode` 9009 is cmd.exe's own errorlevel for a
|
|
462
|
+
* command token it could not resolve, identical in every language. See
|
|
463
|
+
* CMD_NOT_FOUND_EXIT for why it is not required, and the paragraph below
|
|
464
|
+
* for the two cases where it is not believed either. It is subject to the
|
|
465
|
+
* same "cmd.exe printed nothing else" cap as rule 2, because a status is
|
|
466
|
+
* forwarded as easily as a sentence is.
|
|
467
|
+
*
|
|
468
|
+
* 2. THE SHAPE. cmd.exe quotes the command it could not find, in every locale,
|
|
469
|
+
* and prints that INSTEAD of running anything — so the whole output is at
|
|
470
|
+
* most the two lines of one wrapped sentence. Text of at most two lines
|
|
471
|
+
* whose FIRST line quotes exactly the spelling we launched is cmd.exe's
|
|
472
|
+
* verdict about our command whatever the words around it say.
|
|
473
|
+
*
|
|
474
|
+
* Rule 2 needs `name`, and refuses without it. That is the same principle the
|
|
475
|
+
* paragraphs above argue for and not a limitation bolted on: `Error: "account-9"
|
|
476
|
+
* does not exist` is one line with a quoted token in it, and read as an absence
|
|
477
|
+
* it would send the candidate loop back round to re-run `cswap remove 3`. What
|
|
478
|
+
* makes the rule safe is that the quoted token has to be the exact spelling
|
|
479
|
+
* cmd.exe was handed — `cswap.cmd`, or the absolute path shimPath found — which
|
|
480
|
+
* is a string the tool underneath has no reason to print. The two-line cap is
|
|
481
|
+
* the other half: a Python traceback ending in "The system cannot find the file
|
|
482
|
+
* specified" is four lines and can never qualify.
|
|
483
|
+
*
|
|
484
|
+
* THE SAME TRAP THE ENGLISH RULE ALREADY AVOIDS, NOW FOR THE EXIT STATUS. A
|
|
485
|
+
* `.cmd` shim is itself a batch file, so a shim that EXISTS and whose payload
|
|
486
|
+
* interpreter does not — a scoop or npm-style `cswap.cmd` in front of a python
|
|
487
|
+
* that was uninstalled — has cmd.exe print "is not recognized" about PYTHON and
|
|
488
|
+
* hands the shim's caller that same 9009. Believed on its own, the deck would
|
|
489
|
+
* call the tool absent and re-run the command under the next spelling. So a text
|
|
490
|
+
* that positively names a command OTHER than ours vetoes every rule here,
|
|
491
|
+
* including the status: the check that made #457 safe, applied one level up.
|
|
492
|
+
*
|
|
493
|
+
* What is deliberately still missed: a LOCALIZED "the system cannot find the
|
|
494
|
+
* path specified", which carries no quoted token and no structure to key off.
|
|
495
|
+
* That case only arises when shimPath found a shim that then vanished, and it
|
|
496
|
+
* fails in the safe direction — an honest exit 1 rather than a wrong ENOENT.
|
|
412
497
|
*/
|
|
413
|
-
export function looksMissing(text, name = "") {
|
|
498
|
+
export function looksMissing(text, name = "", exitCode = null) {
|
|
414
499
|
const lines = String(text ?? "").split(/\r?\n/).map(l => l.trim()).filter(Boolean);
|
|
500
|
+
// Whatever cmd.exe named, in whatever language it said the rest — the quoting
|
|
501
|
+
// is the part that is not a translation.
|
|
502
|
+
const named = lines.length ? quotedRuns(lines[0]) : [];
|
|
503
|
+
const namesUs = named.some(q => sameCommand(q, name));
|
|
504
|
+
|
|
505
|
+
// The veto, before anything is believed: a message about somebody else's
|
|
506
|
+
// command is not evidence about ours, and neither is the status that came
|
|
507
|
+
// with it. See the header — a `.cmd` shim in front of a missing interpreter
|
|
508
|
+
// forwards both.
|
|
509
|
+
if (name && named.length > 0 && !namesUs) return false;
|
|
510
|
+
|
|
511
|
+
// cmd.exe says this INSTEAD of running anything, so anything longer than one
|
|
512
|
+
// wrapped sentence came from something that DID run — and that outranks both
|
|
513
|
+
// signals below. A shim can forward its child's 9009 after printing pages of
|
|
514
|
+
// its own; a Python traceback is four lines and one of them quotes the very
|
|
515
|
+
// shim we launched.
|
|
516
|
+
const saidNothingElse = lines.length <= 2;
|
|
517
|
+
|
|
518
|
+
// The signal that is not a sentence. It does not need to READ the text, which
|
|
519
|
+
// is the whole reason it exists: on a non-English install there may be nothing
|
|
520
|
+
// in the text this can read.
|
|
521
|
+
if (saidNothingElse && notFoundExit(exitCode)) return true;
|
|
415
522
|
if (!lines.length) return false;
|
|
523
|
+
|
|
524
|
+
// The English shapes, whole-line anchored, exactly as before.
|
|
525
|
+
let english = true;
|
|
416
526
|
for (const line of lines) {
|
|
417
|
-
const
|
|
418
|
-
if (
|
|
419
|
-
if (name && !sameCommand(
|
|
527
|
+
const unknown = CMD_UNKNOWN.exec(line);
|
|
528
|
+
if (unknown) {
|
|
529
|
+
if (name && !sameCommand(unknown[1], name)) return false;
|
|
420
530
|
continue;
|
|
421
531
|
}
|
|
422
532
|
if (CMD_UNKNOWN_TAIL.test(line) || CMD_NO_PATH.test(line)) continue;
|
|
423
|
-
|
|
533
|
+
english = false;
|
|
534
|
+
break;
|
|
424
535
|
}
|
|
425
|
-
return true;
|
|
536
|
+
if (english) return true;
|
|
537
|
+
|
|
538
|
+
// Otherwise: cmd.exe in some other language, recognised by its shape and by
|
|
539
|
+
// the one word in it that is ours.
|
|
540
|
+
return Boolean(name) && saidNothingElse && namesUs;
|
|
426
541
|
}
|
|
427
542
|
|
|
428
543
|
// How much of a hung child's output the deadline keeps. The full buffers belong
|
|
@@ -485,7 +600,11 @@ export function run(cmd, args, { timeout = 20_000, maxBuffer = 4 << 20, env } =
|
|
|
485
600
|
// be a shell's verdict rather than the tool's own words — spawned
|
|
486
601
|
// directly, a missing file is a plain ENOENT and anything printed came
|
|
487
602
|
// from a tool that ran.
|
|
488
|
-
|
|
603
|
+
// `err.code` is the exit STATUS for a child that ran and failed, which
|
|
604
|
+
// is where cmd.exe's language-independent 9009 arrives; for a spawn
|
|
605
|
+
// failure it is an errno string, and Number() of that is NaN. Either
|
|
606
|
+
// way looksMissing is handed what the attempt actually reported.
|
|
607
|
+
const missing = Boolean(err) && tree && looksMissing(`${stderr ?? ""}\n${stdout ?? ""}`, launch, err.code);
|
|
489
608
|
if (err && (tryNext(err) || missing) && i + 1 < tries.length) return attempt(i + 1);
|
|
490
609
|
// The CANDIDATE is what gets remembered, never the resolved path. The
|
|
491
610
|
// memo is the only entry `candidates` offers afterwards, so recording an
|
|
@@ -663,7 +782,7 @@ export function runInteractive(cmd, args, { timeout = 300_000, maxOutput = 256 <
|
|
|
663
782
|
// batch candidate, which is the only kind launched through a shell, and
|
|
664
783
|
// to output that is cmd.exe's message alone — everything below re-runs
|
|
665
784
|
// the whole command, and these commands remove accounts.
|
|
666
|
-
if (code !== 0 && isBatch(raw) && looksMissing(`${stderr}\n${stdout}`, launch)) {
|
|
785
|
+
if (code !== 0 && isBatch(raw) && looksMissing(`${stderr}\n${stdout}`, launch, code)) {
|
|
667
786
|
if (i + 1 < tries.length) {
|
|
668
787
|
stdout = ""; stderr = ""; pending = "";
|
|
669
788
|
child = null;
|