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:
parent
4b143ce043
commit
b399bc64e1
3 changed files with 92 additions and 19 deletions
|
|
@ -18,20 +18,18 @@ import path from "node:path";
|
|||
* Absolute path of the JS entry behind a package's bin (e.g. typescript's
|
||||
* `tsc`, vite's `vite`), resolved from `fromDir` up the node_modules tree —
|
||||
* the same walk `npx --no-install` does, minus the `.cmd` shim a shell-free
|
||||
* spawn can't start on Windows. Resolves `<pkg>/package.json` and reads its
|
||||
* `bin` field (what npx itself does) rather than resolving the bin path
|
||||
* directly, because an `exports` map blocks deep resolution — Vite 8 doesn't
|
||||
* export `./bin/vite.js`, so `require.resolve("vite/bin/vite.js")` throws
|
||||
* `ERR_PACKAGE_PATH_NOT_EXPORTED` (`./package.json` is always exported).
|
||||
* spawn can't start on Windows. Reads the package's manifest and takes the
|
||||
* path out of its `bin` field (what npx itself does) rather than resolving the
|
||||
* bin path directly, because an `exports` map blocks deep resolution — Vite 8
|
||||
* doesn't export `./bin/vite.js`, so `require.resolve("vite/bin/vite.js")`
|
||||
* throws `ERR_PACKAGE_PATH_NOT_EXPORTED`.
|
||||
*
|
||||
* Throws if the package isn't installed from `fromDir`, declares no such bin,
|
||||
* or declares one whose file is missing — the caller decides whether that's a
|
||||
* hard "cannot measure" error or a fallback.
|
||||
*/
|
||||
export function resolveNodeBin(pkg, binName, fromDir) {
|
||||
const pkgPath = createRequire(path.join(fromDir, "package.json")).resolve(
|
||||
`${pkg}/package.json`,
|
||||
);
|
||||
const pkgPath = resolveManifest(pkg, fromDir);
|
||||
const manifest = JSON.parse(readFileSync(pkgPath, "utf8"));
|
||||
const { bin } = manifest;
|
||||
// A string-form `bin` is npm's shorthand for ONE command, named after the
|
||||
|
|
@ -59,6 +57,29 @@ export function resolveNodeBin(pkg, binName, fromDir) {
|
|||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* The package's own `package.json`, found by walking the same `node_modules`
|
||||
* chain Node's resolver would from `fromDir` — deliberately NOT via
|
||||
* `require.resolve("<pkg>/package.json")`.
|
||||
*
|
||||
* That shortcut reads as safe and isn't: subpath resolution is governed by the
|
||||
* package's `exports`, and a package may declare one that omits `./package.json`
|
||||
* (Node dropped its special case for it, so there is no guaranteed export). Such
|
||||
* a package throws `ERR_PACKAGE_PATH_NOT_EXPORTED` here even though its bin is
|
||||
* installed and perfectly spawnable — a false "not installed" that would send
|
||||
* someone to `npm install` for a package already on disk. `resolve.paths()`
|
||||
* gives the search dirs without consulting `exports` at all.
|
||||
*/
|
||||
function resolveManifest(pkg, fromDir) {
|
||||
const require = createRequire(path.join(fromDir, "package.json"));
|
||||
// `resolve.paths` returns null only for builtins, which have no bin to find.
|
||||
for (const dir of require.resolve.paths(pkg) ?? []) {
|
||||
const candidate = path.join(dir, ...pkg.split("/"), "package.json");
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
throw new Error(`cannot find ${pkg} from ${fromDir}`);
|
||||
}
|
||||
|
||||
/** `@scope/name` → `name`; an unscoped name is returned unchanged. */
|
||||
function unscopedName(name) {
|
||||
return name.startsWith("@") ? name.slice(name.indexOf("/") + 1) : name;
|
||||
|
|
|
|||
|
|
@ -127,6 +127,27 @@ test("throws when a declared bin's file is missing (partial install)", () => {
|
|||
}
|
||||
});
|
||||
|
||||
test("resolves a package whose `exports` hides ./package.json", () => {
|
||||
// `require.resolve("<pkg>/package.json")` throws ERR_PACKAGE_PATH_NOT_EXPORTED
|
||||
// for this shape — Node has no special case keeping `./package.json`
|
||||
// exported — which would be a false "not installed" for a package whose bin
|
||||
// is right there on disk. The manifest lookup walks node_modules instead.
|
||||
const dir = fixtureDir(
|
||||
{
|
||||
name: "walledpkg",
|
||||
exports: { ".": "./index.js" },
|
||||
bin: { walled: "bin/walled.js" },
|
||||
},
|
||||
{ createBinFile: true },
|
||||
);
|
||||
try {
|
||||
const entry = resolveNodeBin("walledpkg", "walled", dir);
|
||||
assert.ok(existsSync(entry), `resolved entry does not exist: ${entry}`);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("accepts a scoped package's string-form bin under its unscoped name", () => {
|
||||
const dir = fixtureDir(
|
||||
{ name: "@scope/tool", bin: "bin/tool.js" },
|
||||
|
|
|
|||
|
|
@ -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() }`).
|
||||
|
|
|
|||
Loading…
Reference in a new issue