agent-dag 1.35.21 → 1.35.23

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.
@@ -40,7 +40,7 @@
40
40
  document.documentElement.setAttribute("data-theme", stored === "light" ? "light" : "dark");
41
41
  })();
42
42
  </script>
43
- <script type="module" crossorigin src="/assets/index-DGYih2FE.js"></script>
43
+ <script type="module" crossorigin src="/assets/index-jfKKRYet.js"></script>
44
44
  <link rel="stylesheet" crossorigin href="/assets/index-hLBidJXz.css">
45
45
  </head>
46
46
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-dag",
3
- "version": "1.35.21",
3
+ "version": "1.35.23",
4
4
  "description": "Live deck of Claude Code and Codex agents — watch tool calls, token spend and every Claude Code subagent on one calm canvas. Also available as npx ccdeck and npx agent-dag.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,7 +15,7 @@ import { spawn, spawnSync } from "node:child_process";
15
15
  import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
16
16
  import path from "node:path";
17
17
  import os from "node:os";
18
- import { killTree, spawnSpec } from "./exec.mjs";
18
+ import { killTree, shimPath, spawnSpec } from "./exec.mjs";
19
19
  import { oneLine, termColumns } from "./term.mjs";
20
20
  import { PRODUCT } from "./brand.mjs";
21
21
 
@@ -30,6 +30,26 @@ const MARKER = path.join(CACHE_DIR, ".last-update-check");
30
30
 
31
31
  const _cache = new Map(); // key `${since}|${until}` → { result, at }
32
32
 
33
+ /**
34
+ * The name to hand cmd.exe for one of npm's Windows shims: its full path when
35
+ * one can be found, and the bare name only when none can.
36
+ *
37
+ * The full path is not a tidiness preference, it is the fix for #456. A shim
38
+ * launched by bare name computes `%~dp0` — which is where it looks for
39
+ * npm-prefix.js, npm-cli.js and npx-cli.js — from the deck's WORKING DIRECTORY
40
+ * rather than from its own, so on a deck started from `C:\Users\vceban` both
41
+ * shims died with `Cannot find module 'C:\Users\vceban\node_modules\npm\bin\…'`
42
+ * on a machine whose npm was perfectly healthy. shimPath in exec.mjs carries
43
+ * the whole account; what matters here is that BOTH the managed install and the
44
+ * npx fallback are launched this way, so both failed, and diagnosing either
45
+ * half alone could never have explained the other.
46
+ *
47
+ * Falling back to the bare name is deliberate: it is exactly what this did
48
+ * before, so a layout shimPath cannot see is no worse off than it was, and on
49
+ * such a machine cmd.exe's own PATH search still gets its turn.
50
+ */
51
+ const winShim = (name, deps) => shimPath(name, deps) ?? name;
52
+
33
53
  // npm is a .cmd shim on Windows, which spawn can only launch through cmd.exe.
34
54
  // `shell: true` is the tempting way to get there and the wrong one: Node then
35
55
  // joins file and args with single spaces and no quoting, so on a profile like
@@ -38,18 +58,23 @@ const _cache = new Map(); // key `${since}|${until}` → { result, at }
38
58
  // npm exited non-zero, and the managed install never materialised. spawnSpec
39
59
  // routes the .cmd through cmd.exe with every argument quoted, and hands back
40
60
  // the argument vector untouched everywhere else.
41
- function npmSpec(args, platform = process.platform) {
42
- return spawnSpec(platform === "win32" ? "npm.cmd" : "npm", args, platform);
61
+ //
62
+ // POSIX is untouched by any of this: `npm` there is a real executable on PATH,
63
+ // not a batch file, so isBatch is false, viaCmd never runs, and the vector goes
64
+ // to spawn exactly as it always has.
65
+ function npmSpec(args, platform = process.platform, deps) {
66
+ return spawnSpec(platform === "win32" ? winShim("npm.cmd", deps) : "npm", args, platform);
43
67
  }
44
68
 
45
69
  /**
46
70
  * What `spawn` gets for `npm install ccusage@<spec>`.
47
71
  * Exported for tests: the platform is a parameter so the Windows command line
48
- * can be checked from any OS.
72
+ * can be checked from any OS, and `deps` stands in for the Windows filesystem
73
+ * the shim lookup asks about.
49
74
  */
50
- export const installSpec = (spec = "latest", platform = process.platform) =>
75
+ export const installSpec = (spec = "latest", platform = process.platform, deps) =>
51
76
  npmSpec(["install", `ccusage@${spec}`, "--prefix", CACHE_DIR,
52
- "--no-save", "--no-audit", "--no-fund", "--loglevel", "error"], platform);
77
+ "--no-save", "--no-audit", "--no-fund", "--loglevel", "error"], platform, deps);
53
78
 
54
79
  // npx is the same kind of shim as npm, and the fallback run needs the same
55
80
  // treatment for a second, sharper reason: its argument vector carries a
@@ -62,22 +87,60 @@ export const installSpec = (spec = "latest", platform = process.platform) =>
62
87
  // syntax. Naming the file `npx.cmd` on Windows is what makes that work: only a
63
88
  // .cmd/.bat file routes through cmd.exe, and a bare `npx` there is not a file
64
89
  // spawn can launch at all (PATHEXT is a shell's job), so asking for the
65
- // extensionless name would trade a shell injection for an ENOENT.
66
- function npxSpec(args, platform = process.platform) {
67
- return spawnSpec(platform === "win32" ? "npx.cmd" : "npx", args, platform);
90
+ // extensionless name would trade a shell injection for an ENOENT. What it must
91
+ // NOT be is the bare `npx.cmd` this said until #456 — see winShim.
92
+ function npxSpec(args, platform = process.platform, deps) {
93
+ return spawnSpec(platform === "win32" ? winShim("npx.cmd", deps) : "npx", args, platform);
68
94
  }
69
95
 
70
96
  /**
71
97
  * What `spawn` gets for the portable `npx -y ccusage@latest <args>` fallback.
72
98
  * Exported for tests: the platform is a parameter so the Windows command line
73
- * can be checked from any OS.
99
+ * can be checked from any OS, and `deps` stands in for the Windows filesystem
100
+ * the shim lookup asks about.
74
101
  */
75
- export const fallbackSpec = (args = [], platform = process.platform) =>
76
- npxSpec(["-y", "ccusage@latest", ...args], platform);
102
+ export const fallbackSpec = (args = [], platform = process.platform, deps) =>
103
+ npxSpec(["-y", "ccusage@latest", ...args], platform, deps);
77
104
 
78
105
  let _installing = null; // Promise guard so concurrent calls share one install
79
106
  let _checkedThisRun = false; // only kick the daily check once per process boot
80
107
 
108
+ // The last `npm install` that failed, in one line of its own words.
109
+ //
110
+ // Module scope rather than a value thrown out of installSync, because the two
111
+ // callers that matter never see that throw. primeCcusage runs the install at
112
+ // boot and only logs the rejection; a second modal open that arrives while an
113
+ // install is in flight awaits the SHARED promise, whose rejection has already
114
+ // been handled by the first. Both then land on the npx fallback with nothing to
115
+ // say about why they were there — which is precisely how three rounds of
116
+ // debugging went to the fallback's stderr while the install's own account
117
+ // stayed on a terminal row the deck had already painted over.
118
+ //
119
+ // One line, not the whole dump: this is written into a sentence in a 46ch box.
120
+ // The full text still goes to the terminal through note() below.
121
+ let _lastInstallError = null;
122
+ const INSTALL_ERROR_ROOM = 240;
123
+
124
+ /**
125
+ * Start the one shared install, remembering how it ended.
126
+ *
127
+ * Deduped through `_installing` the way it always was, so concurrent callers
128
+ * cost one `npm install` between them; the only new thing is that the outcome
129
+ * survives the promise.
130
+ */
131
+ function startInstall(spec = "latest") {
132
+ if (!_installing) {
133
+ _installing = (async () => { installSync(spec); })()
134
+ .then(() => { _lastInstallError = null; })
135
+ .catch(e => {
136
+ _lastInstallError = oneLine(e?.message ?? e, INSTALL_ERROR_ROOM);
137
+ note("install failed", e);
138
+ })
139
+ .finally(() => { _installing = null; });
140
+ }
141
+ return _installing;
142
+ }
143
+
81
144
  // AGENTS_DECK_NO_INSTALL=1 is documented as "never install or update
82
145
  // claude-swap / ccusage, and never ask npm about releases", so it has to hold
83
146
  // on the lazy path too — opening the usage-history modal must not be a way to
@@ -174,6 +237,59 @@ export function resolveEntry() {
174
237
  }
175
238
  }
176
239
 
240
+ /**
241
+ * Everything spawnSync knows about a failed install, in the order the answer is
242
+ * usually in.
243
+ *
244
+ * The old line read `(r.stderr || "").trim() || r.status`, which threw away the
245
+ * two things most likely to be the whole story. `r.error` is where spawnSync
246
+ * reports a failure to LAUNCH — ENOENT for a comspec that is not there, EINVAL
247
+ * for a .cmd Node refuses to spawn directly, ETIMEDOUT when the deadline above
248
+ * fired — and it comes with `status: null`, so what reached the terminal in
249
+ * every one of those cases was the word "null". And npm has never confined
250
+ * itself to stderr: a shim that dies before npm starts writes wherever Node
251
+ * chose, and `npm ERR!` blocks have landed on stdout across majors.
252
+ */
253
+ function installFailureText(r) {
254
+ const parts = [];
255
+ if (r?.error?.message) parts.push(String(r.error.message));
256
+ const stderr = String(r?.stderr ?? "").trim();
257
+ const stdout = String(r?.stdout ?? "").trim();
258
+ if (stderr) parts.push(stderr);
259
+ if (stdout) parts.push(stdout);
260
+ if (!parts.length) parts.push(r?.status === null || r?.status === undefined
261
+ ? "npm exited without a status and said nothing"
262
+ : `npm exited ${r.status} and said nothing`);
263
+ return parts.join(" — ");
264
+ }
265
+
266
+ /**
267
+ * Which level of the managed install `npm install --prefix` actually produced.
268
+ *
269
+ * This is the honest answer to the question nobody could answer from a
270
+ * screenshot: an install that exits 0 and leaves a tree resolveEntry cannot use
271
+ * is reported as SUCCESS by an exit code alone, and #432 found that exact shape
272
+ * once already. Naming the first level that is not there turns "ccusage could
273
+ * not report usage" into a sentence about this machine's disk — whether npm
274
+ * wrote nothing, wrote a node_modules with no ccusage in it, or wrote a package
275
+ * whose entry point this deck refuses.
276
+ */
277
+ function installTreeReport() {
278
+ const levels = [
279
+ [CACHE_DIR, "the prefix directory"],
280
+ [path.join(CACHE_DIR, "node_modules"), "node_modules under it"],
281
+ [PKG_DIR, "node_modules/ccusage"],
282
+ [path.join(PKG_DIR, "package.json"), "node_modules/ccusage/package.json"],
283
+ ];
284
+ for (const [where, name] of levels) {
285
+ if (!existsSync(where)) return `${name} is not there`;
286
+ }
287
+ // Every level exists, so resolveEntry refused for one of its own reasons: an
288
+ // unreadable or bin-less package.json, or a `bin` pointing outside the
289
+ // package. All three are about the package rather than about npm.
290
+ return "the package is there but its bin entry could not be read or does not point inside it";
291
+ }
292
+
177
293
  // Run `npm install ccusage@<spec> --prefix CACHE_DIR`. Synchronous variant for
178
294
  // the first-run cold path (we must have a binary before we can answer).
179
295
  function installSync(spec = "latest") {
@@ -185,7 +301,19 @@ function installSync(spec = "latest") {
185
301
  { windowsHide: true, timeout: INSTALL_TIMEOUT_MS, encoding: "utf8", ...opts },
186
302
  );
187
303
  if (r.status !== 0) {
188
- throw new Error(`npm install ccusage failed: ${(r.stderr || "").trim() || r.status}`);
304
+ throw new Error(`npm install ccusage failed: ${installFailureText(r)}`);
305
+ }
306
+ // A zero exit is npm's opinion, not a fact about the disk, and the caller
307
+ // treats "installSync returned" as "there is something to run". Checking here
308
+ // is what stops a silent success: getRunner used to call resolveEntry(), get
309
+ // null, and fall through to the npx fallback with NOTHING recorded anywhere
310
+ // about why — so the modal explained the fallback's stderr and the install's
311
+ // half of the story was never written down at all.
312
+ if (!resolveEntry()) {
313
+ throw new Error(
314
+ `npm install ccusage exited 0 but left nothing runnable under ${CACHE_DIR}: `
315
+ + `${installTreeReport()}`,
316
+ );
189
317
  }
190
318
  }
191
319
 
@@ -256,15 +384,31 @@ async function getRunner() {
256
384
  throw tagged("no_install", "ccusage is not installed, and installs are off (AGENTS_DECK_NO_INSTALL=1)");
257
385
  }
258
386
  // Cold: install once (deduped across concurrent callers).
259
- if (!_installing) {
260
- _installing = (async () => { installSync("latest"); })()
261
- .catch(e => { note("install failed", e); })
262
- .finally(() => { _installing = null; });
263
- }
264
- await _installing;
387
+ await startInstall("latest");
265
388
  resolved = resolveEntry();
266
389
  if (resolved) { touchMarker(); return { kind: "node", entry: resolved.entry }; }
267
- return { kind: "npx" }; // npm unavailable / offline → fall back to npx
390
+ // npm unavailable / offline → fall back to npx, carrying WHY the managed
391
+ // install is not here. Without that the modal can only describe the fallback,
392
+ // and the fallback is the second thing that failed.
393
+ return { kind: "npx", installError: _lastInstallError };
394
+ }
395
+
396
+ /** Which of ccusage's two paths a failure came from, in the deck's own words.
397
+ * `stage` travels to the browser; the modal leads with it rather than making
398
+ * the reader guess from a stack trace which half of this module they are
399
+ * looking at. */
400
+ const STAGE = { node: "managed", npx: "npx" };
401
+
402
+ /** Mark a failure with the path that produced it, and with the managed
403
+ * install's own account when that is why this path was taken at all. Set once:
404
+ * the retry below re-stamps with the runner that actually failed, and an error
405
+ * that already knows where it came from is not overwritten. */
406
+ function stamp(err, runner) {
407
+ if (err && typeof err === "object") {
408
+ if (!err.stage) err.stage = STAGE[runner?.kind] ?? "npx";
409
+ if (runner?.installError && err.install === undefined) err.install = runner.installError;
410
+ }
411
+ return err;
268
412
  }
269
413
 
270
414
  // ── invocation ──────────────────────────────────────────────────────────────
@@ -339,18 +483,28 @@ function runOnce(runner, args) {
339
483
  });
340
484
  }
341
485
 
342
- // Run ccusage with the given args, resolve raw stdout. One retry, and only for
343
- // the one failure that is otherwise permanent — see discardDamagedInstall. The
344
- // second getRunner() is what rebuilds the install, or falls through to npx when
345
- // npm cannot.
486
+ // Run ccusage with the given args, resolve raw stdout AND the runner that
487
+ // produced it. One retry, and only for the one failure that is otherwise
488
+ // permanent — see discardDamagedInstall. The second getRunner() is what rebuilds
489
+ // the install, or falls through to npx when npm cannot.
490
+ //
491
+ // The runner comes back with the output because the caller has to say which
492
+ // path answered: `extractJson` below can fail on a run that started perfectly
493
+ // well, and "ccusage ran but printed no usage data" reads differently depending
494
+ // on which ccusage that was.
346
495
  async function runCcusage(args) {
347
496
  const runner = await getRunner();
348
497
  try {
349
- return await runOnce(runner, args);
498
+ return { out: await runOnce(runner, args), runner };
350
499
  } catch (err) {
351
- if (!discardDamagedInstall(runner, err)) throw err;
500
+ if (!discardDamagedInstall(runner, err)) throw stamp(err, runner);
352
501
  note("managed install was unusable, rebuilding it", err);
353
- return runOnce(await getRunner(), args);
502
+ const rebuilt = await getRunner();
503
+ try {
504
+ return { out: await runOnce(rebuilt, args), runner: rebuilt };
505
+ } catch (again) {
506
+ throw stamp(again, rebuilt);
507
+ }
354
508
  }
355
509
  }
356
510
 
@@ -389,10 +543,12 @@ export async function fetchCcusageDaily({ since, until, force = false } = {}) {
389
543
  if (!force && cached && now - cached.at < CACHE_MS) return cached.result;
390
544
 
391
545
  let result;
546
+ let ran = null; // the runner that answered, for stamping a bad_output failure
392
547
  try {
393
548
  const args = ["daily", "--json", "--since", sinceArg];
394
549
  if (until) args.push("--until", until);
395
- const raw = extractJson(await runCcusage(args));
550
+ ran = await runCcusage(args);
551
+ const raw = extractJson(ran.out);
396
552
  const days = Array.isArray(raw.daily) ? raw.daily : [];
397
553
  result = {
398
554
  ok: true,
@@ -403,15 +559,31 @@ export async function fetchCcusageDaily({ since, until, force = false } = {}) {
403
559
  fetchedAt: now,
404
560
  };
405
561
  } catch (err) {
406
- note("fetch failed", err);
562
+ // `extractJson` throws about a run that started fine, so it arrives here
563
+ // knowing nothing about which ccusage it read. The runner does.
564
+ if (ran) stamp(err, ran.runner);
565
+ // Naming the path in the terminal too. "fetch failed" alone was true of
566
+ // both, and an operator reading a boot report has the same question the
567
+ // modal's reader has: which of the two.
568
+ note(err?.stage === "managed" ? "fetch failed (managed install)"
569
+ : err?.stage === "npx" ? "fetch failed (npx fallback)"
570
+ : "fetch failed", err);
407
571
  // Anything untagged got here from the child itself — a non-zero exit, or a
408
572
  // spawn that never started one — which is exactly what run_failed means.
409
573
  // `error` keeps the child's WHOLE output, stack trace and all: it is the
410
574
  // modal's hover title, which is where the raw bytes are meant to live, and
411
575
  // it is what somebody pastes into an issue. Only the terminal gets a line.
576
+ //
577
+ // `stage` and `install` are the halves that used to be lost. They are what
578
+ // let the modal say WHICH path failed and why, on screen, instead of
579
+ // guessing it from the shape of a stack trace — the guess that shipped two
580
+ // wrong diagnoses in a row (#432, #450). Undefined when nothing ran, and
581
+ // JSON.stringify drops them, so an older browser sees the reply it expects.
412
582
  result = {
413
583
  ok: false,
414
584
  reason: err?.reason ?? "run_failed",
585
+ stage: err?.stage,
586
+ install: err?.install,
415
587
  error: String(err?.message ?? err),
416
588
  fetchedAt: now,
417
589
  };
@@ -448,10 +620,6 @@ export function primeCcusage() {
448
620
  maybeBackgroundUpdate(resolved.version);
449
621
  return { state: due ? "updating" : "present", version: resolved.version };
450
622
  }
451
- if (!_installing) {
452
- _installing = (async () => { installSync("latest"); })()
453
- .catch(e => { note("install failed", e); })
454
- .finally(() => { _installing = null; });
455
- }
623
+ startInstall("latest");
456
624
  return { state: "installing" };
457
625
  }
@@ -19,6 +19,7 @@
19
19
  // the throw escaped the retry path and took the whole process down on Windows
20
20
  // before the server ever started.
21
21
  import { execFile, spawn } from "node:child_process";
22
+ import { existsSync } from "node:fs";
22
23
 
23
24
  // Extensions Windows will execute, most specific first. `.com` is omitted —
24
25
  // nothing ships one, and every extra candidate costs a failed spawn.
@@ -94,6 +95,76 @@ export function shellQuoteArg(arg, platform = process.platform) {
94
95
  return `'${s.split("'").join("'\\''")}'`;
95
96
  }
96
97
 
98
+ /**
99
+ * The absolute path of a Windows command shim, or null when nothing answers to
100
+ * that name.
101
+ *
102
+ * Why a bare name is not good enough, which is the whole of #456. npm's `.cmd`
103
+ * shims locate every file they need relative to THEMSELVES:
104
+ *
105
+ * SET "NPM_PREFIX_JS=%~dp0\node_modules\npm\bin\npm-prefix.js"
106
+ * SET "NPX_CLI_JS=%~dp0\node_modules\npm\bin\npx-cli.js"
107
+ *
108
+ * `%~dp0` is the drive and path of `%0`, and `%0` is the command token cmd.exe
109
+ * was given. Handed `cmd.exe /d /s /c ""npm.cmd" "install" …`, that token
110
+ * carries no directory of its own, so `%~dp0` came out as the deck's WORKING
111
+ * DIRECTORY instead of the shim's. Reported from Windows 10 with the deck
112
+ * started from `C:\Users\vceban`:
113
+ *
114
+ * Error: Cannot find module 'C:\Users\vceban\node_modules\npm\bin\npm-prefix.js'
115
+ * Error: Cannot find module 'C:\Users\vceban\node_modules\npm\bin\npx-cli.js'
116
+ *
117
+ * — two stacks, one defect, and `C:\Users\vceban` is the cwd rather than
118
+ * anything to do with npm: `where npx` on that machine printed
119
+ * `C:\Program Files\nodejs\npx.cmd` and `npx ccdeck` ran perfectly from a
120
+ * prompt. It is ours, introduced when #362 replaced `shell: true` with viaCmd:
121
+ * the quoting was the fix and the bare name was the cost, and it broke the
122
+ * managed install and the npx fallback in the same stroke, which is why every
123
+ * diagnosis of one half kept half-fitting.
124
+ *
125
+ * Node's own directory is tried before PATH because node and npm ship together
126
+ * and that is where the shims are — the same preference npxCliCandidates in
127
+ * npx.mjs states for npm's CLI scripts, so the repo has one rule rather than
128
+ * two. PATH is then walked in its own order, which is what cmd.exe would have
129
+ * done, minus the current directory it searches first: a deck that resolves its
130
+ * npm out of whatever folder it happens to be sitting in is the bug above
131
+ * wearing a hat.
132
+ *
133
+ * The path arithmetic is spelled out rather than done through `node:path` for
134
+ * the reason npxCliCandidates gives: `path` is the platform running the SUITE,
135
+ * so a Windows layout checked from macOS would come back with forward slashes.
136
+ * `execPath`, `pathEnv` and `exists` are injected for the same reason — the
137
+ * Windows answer has to be checkable from an OS that cannot run it.
138
+ */
139
+ export function shimPath(name, {
140
+ execPath = process.execPath,
141
+ pathEnv = process.env.PATH ?? process.env.Path ?? "",
142
+ exists = existsSync,
143
+ } = {}) {
144
+ // A name that already carries a directory needs no lookup, and re-rooting it
145
+ // would be a way to run something else entirely.
146
+ if (typeof name !== "string" || !name || /[\\/]/.test(name)) return null;
147
+ const dirs = [];
148
+ const beside = String(execPath ?? "").split(/[\\/]/).slice(0, -1).join("\\");
149
+ if (beside) dirs.push(beside);
150
+ for (const raw of String(pathEnv ?? "").split(";")) {
151
+ // A PATH entry may be quoted, and may end in a separator; neither is part
152
+ // of the directory, and both would produce a path nothing exists at.
153
+ const dir = raw.trim().replace(/^"|"$/g, "").replace(/[\\/]+$/, "");
154
+ if (dir) dirs.push(dir);
155
+ }
156
+ for (const dir of dirs) {
157
+ const full = `${dir}\\${name}`;
158
+ try {
159
+ if (exists(full)) return full;
160
+ } catch {
161
+ // An entry that cannot even be stat'ed — a disconnected network drive is
162
+ // the usual one — is a miss, not a reason to stop looking.
163
+ }
164
+ }
165
+ return null;
166
+ }
167
+
97
168
  /**
98
169
  * What to hand `spawn`/`execFile` for `file` and `args` on this platform, with
99
170
  * the argument vector intact and no shell.
@@ -9,11 +9,17 @@
9
9
  // SET "NPM_PREFIX_JS=%~dp0\node_modules\npm\bin\npm-prefix.js"
10
10
  //
11
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 — the usual cause is an
13
- // npm global prefix pointed somewhere the shim did not follow. Clicking
12
+ // directory and no `node_modules\npm` existed under it. Clicking
14
13
  // "Update & restart" printed a raw MODULE_NOT_FOUND stack trace and came back
15
14
  // on the same version.
16
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
+ //
17
23
  // Node ships npm, so npx's real entry point can be reached from
18
24
  // `process.execPath` with no PATH lookup, no batch file, and no shim-relative
19
25
  // arithmetic. That is what this prefers. The shim stays as the fallback for
@@ -27,7 +33,7 @@
27
33
  // deck its listener.
28
34
  import { spawn } from "node:child_process";
29
35
  import { existsSync } from "node:fs";
30
- import { killTree, spawnSpec } from "./exec.mjs";
36
+ import { killTree, shimPath, spawnSpec } from "./exec.mjs";
31
37
 
32
38
  /**
33
39
  * Where npm's own `npx-cli.js` sits relative to the running Node binary, most
@@ -74,6 +80,7 @@ export function npxLaunch(args, {
74
80
  platform = process.platform,
75
81
  execPath = process.execPath,
76
82
  exists = existsSync,
83
+ pathEnv,
77
84
  } = {}) {
78
85
  for (const cli of npxCliCandidates(execPath, platform)) {
79
86
  let found = false;
@@ -82,7 +89,14 @@ export function npxLaunch(args, {
82
89
  // nothing resolves a path relative to a shim's own directory.
83
90
  if (found) return { file: execPath, args: [cli, ...args], opts: {}, via: "node", cli };
84
91
  }
85
- const shim = platform === "win32" ? "npx.cmd" : "npx";
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";
86
100
  return { ...spawnSpec(shim, args, platform), via: "shim", cli: null };
87
101
  }
88
102