fix(scripts): find the manifest without exports, quote shell args on Windows

Copilot review round 2 (suppressed comments), all three real:

- `require.resolve("<pkg>/package.json")` is governed by the package's
  `exports`, and Node keeps no special case for `./package.json` — so a
  package declaring an `exports` map without it threw
  ERR_PACKAGE_PATH_NOT_EXPORTED even though its bin was installed and
  spawnable, a false "not installed" pointing at `npm install` for a
  package already on disk. Walk `resolve.paths()` instead, which never
  consults `exports`. The docblock claiming the opposite is corrected.
- `shell: true` (needed at all on Windows to start the `.cmd` shims) makes
  Node hand `cmd.exe` one space-joined string, so an argument holding a
  path with a space splits in two. Every generated path in
  pack-and-verify lives under `tmpdir()`, which sits beneath the user
  profile. Quote them via `shellArgs()` at all four spawn sites, the
  command path included — the hazard predates this PR, so fix the class.
- Document the new module: `scripts/lib/resolve-node-bin.mjs` in AGENTS.md's
  scripts/ tree, its tests in the README's `test:scripts` row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01387r3MotGk52hrkfjzHFsN
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-17 00:01:51 -04:00
commit b399bc64e1

View file

@ -98,12 +98,28 @@ function step(message) {
console.log(`\npack:verify — ${message}`);
}
// Windows needs `shell: true` to start the `npm`/`npx` `.cmd` shims at all
// (#1939), and with a shell Node hands the argv to `cmd.exe` as ONE
// space-joined string — so an argument holding a path with a space is split
// into two. That is not hypothetical here: every generated path in this file
// lives under `tmpdir()`, which on Windows sits beneath the user profile and
// so contains a space whenever the account name does. Quote each argument
// that needs it; `cmd.exe` (and the CRT parser behind it) strips the quotes
// back off, so the child sees exactly the argument we passed.
const WIN_SHELL = process.platform === "win32";
function shellArgs(args) {
if (!WIN_SHELL) return args;
return args.map((arg) =>
/[\s&|<>^"]/.test(arg) ? `"${arg.replace(/"/g, '""')}"` : arg,
);
}
/** Run a command to completion, inheriting stdio. Returns the exit status. */
function runInherit(command, args, cwd = repoRoot) {
const r = spawnSync(command, args, {
const r = spawnSync(command, shellArgs(args), {
cwd,
stdio: "inherit",
shell: process.platform === "win32",
shell: WIN_SHELL,
});
return r.status;
}
@ -136,10 +152,17 @@ if (runInherit("npm", ["run", "build"]) !== 0) {
step("packing the publishable tarball (npm pack)...");
const pack = spawnSync(
"npm",
["pack", "--json", "--ignore-scripts", "--pack-destination", tmpdir()],
// npm is npm.cmd on Windows, which needs a shell to resolve (#1939) — the
// same idiom as runInherit/runBin below.
{ cwd: repoRoot, encoding: "utf8", shell: process.platform === "win32" },
// same idiom as runInherit/runBin. `shellArgs` quotes `tmpdir()`, which the
// shell would otherwise split on a space in the user profile path.
shellArgs([
"pack",
"--json",
"--ignore-scripts",
"--pack-destination",
tmpdir(),
]),
{ cwd: repoRoot, encoding: "utf8", shell: WIN_SHELL },
);
if (pack.status !== 0) {
fail(`\`npm pack\` failed:\n${pack.stderr || pack.stdout}`);
@ -253,11 +276,13 @@ try {
/** Run the installed bin. Returns { status, output }. */
const runBin = (args, extraEnv = {}) => {
const r = spawnSync(bin, args, {
// `bin` itself is quoted too: it lives under the throwaway consumer's
// `tmpdir()` path, and with a shell the command is joined with the argv.
const r = spawnSync(shellArgs([bin])[0], shellArgs(args), {
cwd: work,
encoding: "utf8",
env: { ...process.env, ...extraEnv },
shell: process.platform === "win32",
shell: WIN_SHELL,
});
return {
status: r.status,
@ -378,7 +403,8 @@ async function verifyWeb(bin, cwd) {
const host = "127.0.0.1";
const port = process.env.PACK_VERIFY_WEB_PORT ?? "6399";
const token = "pack-verify-token";
const child = spawn(bin, ["--web"], {
// Quoted for the same reason as runBin: `bin` sits under a `tmpdir()` path.
const child = spawn(shellArgs([bin])[0], ["--web"], {
cwd,
env: {
...process.env,
@ -388,7 +414,7 @@ async function verifyWeb(bin, cwd) {
MCP_AUTO_OPEN_ENABLED: "false",
},
stdio: ["ignore", "inherit", "inherit"],
shell: process.platform === "win32",
shell: WIN_SHELL,
});
// Expose the child so fail() can kill it if a check below exits the process
// (process.exit skips the `finally { stop() }`).