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
2 changed files with 92 additions and 19 deletions

View file

@ -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;

View file

@ -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" },