agent-dag 1.35.21 → 1.35.22
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/dist/web/index.html
CHANGED
|
@@ -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-
|
|
43
|
+
<script type="module" crossorigin src="/assets/index-nS0lHmpq.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.
|
|
3
|
+
"version": "1.35.22",
|
|
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": {
|
package/src/server/ccusage.mjs
CHANGED
|
@@ -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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
343
|
-
// the one failure that is otherwise
|
|
344
|
-
// second getRunner() is what rebuilds
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/server/exec.mjs
CHANGED
|
@@ -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.
|
package/src/server/npx.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|