inspector/scripts/lib/resolve-node-bin.mjs
cliffhall 4b143ce043 fix(scripts): validate the bin name and that its file exists
Copilot review on #1997:

- A string-form `bin` is npm's shorthand for ONE command, named after the
  package, so ignoring `binName` let a typo silently resolve the package's
  only executable while bypassing the documented "declares no such bin"
  failure. Match it against the manifest's unscoped name instead.
- A declared bin whose file is absent (partial install) was returned
  unchecked. `process.execPath <missing>` still spawns and exits 1 with
  empty stdout, which `rawProjectListing` records as "no diagnostic
  captured" — reproducing the bogus every-file-uncovered report this
  helper exists to eliminate. Throw here, where the remedy is actionable.

Also restores `rawProjectListing`'s docblock, which the new `tscEntry`
block had orphaned above itself.

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>
2026-08-16 23:43:24 -04:00

65 lines
3.2 KiB
JavaScript

// Shared resolver for spawning a package's CLI cross-platform (#1939).
//
// On Windows, `npx`/`npm` are `.cmd` shims, not executables — a shell-free
// `execFileSync`/`spawnSync` cannot start one (Node refuses `.cmd`/`.bat`
// spawns without `shell: true` since the CVE-2024-27980 hardening) and throws
// `ENOENT`. GitHub CI runs Linux, so the gate stayed green there while being
// unrunnable for any Windows contributor. Instead of shelling through `npx`,
// resolve the JS entry behind the package's bin and run it with
// `process.execPath`: cross-platform, shell-free (no quoting hazards), faster
// (no npx resolution), and pinned to the locally installed package exactly as
// `npx --no-install` was.
import { existsSync, readFileSync } from "node:fs";
import { createRequire } from "node:module";
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).
*
* 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 manifest = JSON.parse(readFileSync(pkgPath, "utf8"));
const { bin } = manifest;
// A string-form `bin` is npm's shorthand for ONE command, named after the
// package (unscoped). It does not make every requested `binName` valid, so
// match it rather than accepting whatever was asked for — otherwise a typo
// silently resolves the package's only executable instead of failing.
const rel =
typeof bin === "string"
? unscopedName(manifest.name ?? pkg) === binName
? bin
: undefined
: bin?.[binName];
if (typeof rel !== "string")
throw new Error(`${pkg} declares no "${binName}" bin in its package.json`);
const entry = path.join(path.dirname(pkgPath), rel);
// A declared bin whose file is absent is a partial install, and it fails
// *silently* downstream: `process.execPath <missing>` still spawns fine and
// exits 1 with nothing on stdout, which `rawProjectListing` records as "no
// diagnostic captured" and turns into the bogus every-file-uncovered report
// this helper exists to eliminate. Fail here, where the remedy is actionable.
if (!existsSync(entry))
throw new Error(
`${pkg}'s "${binName}" bin points at a missing file: ${entry}`,
);
return entry;
}
/** `@scope/name` → `name`; an unscoped name is returned unchanged. */
function unscopedName(name) {
return name.startsWith("@") ? name.slice(name.indexOf("/") + 1) : name;
}