Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions scripts/lib/resolve-node-bin.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// 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 { 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` or declares no such
* bin — 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 bin = JSON.parse(readFileSync(pkgPath, "utf8")).bin;
// A string-form `bin` names a single command (the package's own name).
const rel = typeof bin === "string" ? bin : bin?.[binName];
if (typeof rel !== "string")
throw new Error(`${pkg} declares no "${binName}" bin in its package.json`);
return path.join(path.dirname(pkgPath), rel);
}
68 changes: 68 additions & 0 deletions scripts/lib/resolve-node-bin.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Tests for `resolve-node-bin.mjs` (#1939) — the shared resolver that replaces
// shelling through `npx`, which is a `.cmd` shim on Windows that a shell-free
// `execFileSync`/`spawnSync` cannot start (ENOENT). Resolution is exercised
// against the packages the callers actually spawn (typescript, vite, prettier),
// installed by the repo's own `npm install`, so the contract is pinned against
// the real `bin`/`exports` shapes rather than fixtures that can drift.

import { test } from "node:test";
import assert from "node:assert/strict";
import { existsSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { resolveNodeBin } from "./resolve-node-bin.mjs";

const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"..",
);

test("resolves typescript's tsc (object-form bin) to an existing JS entry", () => {
const entry = resolveNodeBin("typescript", "tsc", repoRoot);
assert.ok(path.isAbsolute(entry), entry);
assert.ok(existsSync(entry), `resolved entry does not exist: ${entry}`);
assert.match(entry.split(path.sep).join("/"), /\/typescript\/.*tsc/);
});

test("resolves from a client dir, walking node_modules up like `npx --no-install`", () => {
const entry = resolveNodeBin(
"typescript",
"tsc",
path.join(repoRoot, "clients", "cli"),
);
assert.ok(existsSync(entry), `resolved entry does not exist: ${entry}`);
});

test("resolves vite's bin despite Vite 8's exports map (no deep bin export)", () => {
// `require.resolve("vite/bin/vite.js")` throws ERR_PACKAGE_PATH_NOT_EXPORTED
// under Vite 8 — the reason the helper goes through `<pkg>/package.json`.
const entry = resolveNodeBin(
"vite",
"vite",
path.join(repoRoot, "clients", "web"),
);
assert.ok(existsSync(entry), `resolved entry does not exist: ${entry}`);
assert.match(entry.split(path.sep).join("/"), /\/vite\/.*vite\.js$/);
});

test("resolves a string-form bin (prettier), ignoring the binName", () => {
const entry = resolveNodeBin("prettier", "prettier", repoRoot);
assert.ok(existsSync(entry), `resolved entry does not exist: ${entry}`);
assert.match(entry.split(path.sep).join("/"), /\/prettier\//);
});

test("throws when the package is not installed from fromDir", () => {
assert.throws(
() => resolveNodeBin("definitely-not-installed-anywhere", "x", repoRoot),
/definitely-not-installed-anywhere/,
);
});

test("throws when the package declares no such bin", () => {
// typescript's bin map has `tsc`/`tsserver`, not `vite`.
assert.throws(
() => resolveNodeBin("typescript", "vite", repoRoot),
/typescript declares no "vite" bin/,
);
});
4 changes: 3 additions & 1 deletion scripts/pack-and-verify.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,9 @@ step("packing the publishable tarball (npm pack)...");
const pack = spawnSync(
"npm",
["pack", "--json", "--ignore-scripts", "--pack-destination", tmpdir()],
{ cwd: repoRoot, encoding: "utf8" },
// 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" },
);
if (pack.status !== 0) {
fail(`\`npm pack\` failed:\n${pack.stderr || pack.stdout}`);
Expand Down
17 changes: 13 additions & 4 deletions scripts/smoke-cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { mkdtempSync, rmSync, existsSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { resolveNodeBin } from "./lib/resolve-node-bin.mjs";

const repoRoot = resolve(import.meta.dirname, "..");
const launcher = join(repoRoot, "clients", "launcher", "build", "index.js");
Expand All @@ -69,10 +70,18 @@ function fail(message) {
function ensureTestServer() {
if (existsSync(testServer) && existsSync(httpTestServerModule)) return;
console.log("smoke:cli — building test-servers (missing build output)...");
const r = spawnSync("npx", ["tsc", "-p", "test-servers", "--noCheck"], {
cwd: repoRoot,
stdio: "inherit",
});
// The root-installed tsc, run via this Node — `npx` is a `.cmd` shim on
// Windows that a shell-free spawnSync can't start (ENOENT — #1939).
const r = spawnSync(
process.execPath,
[
resolveNodeBin("typescript", "tsc", repoRoot),
"-p",
"test-servers",
"--noCheck",
],
{ cwd: repoRoot, stdio: "inherit" },
);
if (
r.status !== 0 ||
!existsSync(testServer) ||
Expand Down
17 changes: 13 additions & 4 deletions scripts/smoke-tui.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { mkdtempSync, existsSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { removeSafe } from "./lib/child-cleanup.mjs";
import { resolveNodeBin } from "./lib/resolve-node-bin.mjs";

const repoRoot = resolve(import.meta.dirname, "..");
const launcher = join(repoRoot, "clients", "launcher", "build", "index.js");
Expand All @@ -44,10 +45,18 @@ function fail(message) {
function ensureTestServer() {
if (existsSync(testServer)) return;
console.log("smoke:tui — building test-servers (missing build output)...");
const r = spawnSync("npx", ["tsc", "-p", "test-servers", "--noCheck"], {
cwd: repoRoot,
stdio: "inherit",
});
// The root-installed tsc, run via this Node — `npx` is a `.cmd` shim on
// Windows that a shell-free spawnSync can't start (ENOENT — #1939).
const r = spawnSync(
process.execPath,
[
resolveNodeBin("typescript", "tsc", repoRoot),
"-p",
"test-servers",
"--noCheck",
],
{ cwd: repoRoot, stdio: "inherit" },
);
if (r.status !== 0 || !existsSync(testServer)) {
fail(
"could not build the stdio test server (test-servers/build/test-server-stdio.js). " +
Expand Down
17 changes: 13 additions & 4 deletions scripts/smoke-web-app.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import { setTimeout as delay } from "node:timers/promises";
import { join, resolve } from "node:path";
import { startProdWebServer } from "./lib/prod-web-server.mjs";
import { stopChild } from "./lib/child-cleanup.mjs";
import { resolveNodeBin } from "./lib/resolve-node-bin.mjs";

const repoRoot = resolve(import.meta.dirname, "..");
const requireFromWeb = createRequire(
Expand Down Expand Up @@ -140,10 +141,18 @@ function ensureTestServer() {
console.log(
"smoke:web:app — building test-servers (missing build output)...",
);
const r = spawnSync("npx", ["tsc", "-p", "test-servers", "--noCheck"], {
cwd: repoRoot,
stdio: "inherit",
});
// The root-installed tsc, run via this Node — `npx` is a `.cmd` shim on
// Windows that a shell-free spawnSync can't start (ENOENT — #1939).
const r = spawnSync(
process.execPath,
[
resolveNodeBin("typescript", "tsc", repoRoot),
"-p",
"test-servers",
"--noCheck",
],
{ cwd: repoRoot, stdio: "inherit" },
);
if (r.status !== 0 || !existsSync(composableServer)) {
throw new Error(
"could not build the test servers (test-servers/build/server-composable.js). " +
Expand Down
34 changes: 24 additions & 10 deletions scripts/verify-build-gate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { resolveNodeBin } from "./lib/resolve-node-bin.mjs";

const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
Expand Down Expand Up @@ -82,6 +83,20 @@ function escapeRegExp(literal) {
return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

// The repo-pinned Vite's JS entry, resolved from clients/web — the old `npx
// --no-install` guarantee (never a registry fetch) — and spawned via this Node,
// since `npx` is a `.cmd` shim on Windows that a shell-free spawnSync can't
// start (ENOENT — #1939). Resolved up front, BEFORE the probe is injected, so a
// missing install fails actionably without ever touching src/main.tsx.
let viteEntry;
try {
viteEntry = resolveNodeBin("vite", "vite", webDir);
} catch (err) {
fail(
`cannot resolve \`vite\` from clients/web (${err.message}) — run \`npm install\` at the repo root first`,
);
}

// Write the captured original to a backup and fail — the honest remedy when the
// in-place restore can't be trusted, since it preserves any uncommitted edits
// the developer had (unlike `git checkout --`). The backup goes in a fresh
Expand Down Expand Up @@ -229,15 +244,14 @@ try {
console.log(
"verify:build-gate: running a real `vite build` with a node:fs probe (takes a minute)…",
);
// `--no-install` pins to the locally installed (repo-pinned) Vite: the whole
// point is proving the message-keyed gate fires against THIS Vite, so `npx`
// must never silently fetch a different version from the registry when
// clients/web/node_modules is missing/partial. A missing local bin then
// surfaces via the `result.error` check below. `timeout` bounds a hung build:
// spawnSync sets `result.error` (ETIMEDOUT) on timeout, so the same branch
// reports it — otherwise a hang would burn to the GitHub job's 360-min default
// with no output (this step captures rather than inherits stdio).
result = spawnSync("npx", ["--no-install", "vite", "build"], {
// `viteEntry` (resolved above) pins to the locally installed (repo-pinned)
// Vite: the whole point is proving the message-keyed gate fires against THIS
// Vite, so nothing may silently fetch a different version from the registry
// when clients/web/node_modules is missing/partial. `timeout` bounds a hung
// build: spawnSync sets `result.error` (ETIMEDOUT) on timeout, so that branch
// below reports it — otherwise a hang would burn to the GitHub job's 360-min
// default with no output (this step captures rather than inherits stdio).
result = spawnSync(process.execPath, [viteEntry, "build"], {
cwd: webDir,
encoding: "utf8",
timeout: 10 * 60_000,
Expand Down Expand Up @@ -266,7 +280,7 @@ if (afterRestore !== original) {
);
}

// A spawn failure (e.g. `npx` missing) leaves `status` null with no output —
// A spawn failure (or the timeout above) leaves `status` null with no output —
// surface it as itself rather than falling through to the "not via the gate"
// diagnosis, which would send someone chasing a build regression that isn't real.
if (result.error) {
Expand Down
105 changes: 105 additions & 0 deletions scripts/verify-typecheck-coverage.main.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// Regression test for #1939's "doubly silent" failure mode: when the tsc entry
// cannot be resolved (on Windows the old `execFileSync("npx", …)` threw ENOENT;
// today, a missing install), the guard must hard-fail with an actionable
// "cannot measure" error — NOT swallow it, echo "(no diagnostic captured)" per
// project, and then report every tracked source file in the repo as getting no
// tsc pass, which is what shipped before and sent Windows contributors chasing
// a 900-file coverage regression that wasn't real.
//
// The fixture is a throwaway repo with one enrolled client whose `typecheck`
// names a project, but with NO node_modules anywhere up the temp tree — so the
// guard reaches the tsc-entry resolution and it fails. Mirrors the
// `verify-format-coverage.main.test.mjs` / `verify-dep-lockstep.main.test.mjs`
// pattern. Run via `npm run test:scripts`.

import { test } from "node:test";
import assert from "node:assert/strict";
import { execFileSync, spawnSync } from "node:child_process";
import {
cpSync,
mkdirSync,
mkdtempSync,
realpathSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";

const scriptsDir = path.dirname(fileURLToPath(import.meta.url));

test("unresolvable tsc is a hard 'cannot measure' error, not an empty file set", () => {
// realpath'd because the script only executes when `import.meta.url` matches
// `process.argv[1]`, and macOS `tmpdir()` is a symlink — see the note in
// `verify-dep-lockstep.main.test.mjs`.
const dir = realpathSync(mkdtempSync(path.join(tmpdir(), "typecheck-cov-")));
try {
// Root manifest: the full guard cycle is wired so phase 1 gets as far as
// measuring the client (an unwired client is `continue`d past, and the
// resolution would never be reached).
writeFileSync(
path.join(dir, "package.json"),
JSON.stringify({
name: "fixture",
scripts: {
validate:
"npm run verify:format-coverage && npm run verify:typecheck-coverage && npm run verify:dep-lockstep && npm run test:scripts && npm --prefix clients/cli run validate",
"verify:format-coverage": "node scripts/verify-format-coverage.mjs",
"verify:typecheck-coverage":
"node scripts/verify-typecheck-coverage.mjs",
"verify:dep-lockstep": "node scripts/verify-dep-lockstep.mjs",
"test:scripts": 'node --test "scripts/**/*.test.mjs"',
},
}),
);
// One enrolled client with a `typecheck` reachable from `validate`, a
// project for it to name, and a tracked source file — so if the hard error
// ever regresses to the old behavior, the "get no `tsc` pass" report the
// second assertion forbids would actually have a file to list.
mkdirSync(path.join(dir, "clients", "cli", "src"), { recursive: true });
writeFileSync(
path.join(dir, "clients", "cli", "package.json"),
JSON.stringify({
name: "fixture-cli",
scripts: {
validate: "npm run typecheck",
typecheck: "tsc --noEmit -p tsconfig.json",
},
}),
);
writeFileSync(
path.join(dir, "clients", "cli", "tsconfig.json"),
JSON.stringify({ include: ["src"] }),
);
writeFileSync(
path.join(dir, "clients", "cli", "src", "index.ts"),
"export const x = 1;\n",
);
mkdirSync(path.join(dir, "scripts", "lib"), { recursive: true });
for (const rel of [
"verify-typecheck-coverage.mjs",
path.join("lib", "npm-scripts.mjs"),
path.join("lib", "resolve-node-bin.mjs"),
])
cpSync(path.join(scriptsDir, rel), path.join(dir, "scripts", rel));
execFileSync("git", ["init", "-q"], { cwd: dir });
execFileSync("git", ["add", "-A"], { cwd: dir });

const r = spawnSync(
process.execPath,
[path.join(dir, "scripts", "verify-typecheck-coverage.mjs")],
{ cwd: dir, encoding: "utf8" },
);
const out = `${r.stdout}${r.stderr}`;
assert.equal(r.status, 1, out);
assert.match(out, /cannot resolve `typescript` from clients\/cli/, out);
assert.match(out, /npm install/, out);
// The pre-#1939 failure shape: per-project "(no diagnostic captured)"
// warnings followed by every tracked file reported uncovered.
assert.doesNotMatch(out, /get no `tsc` pass/, out);
assert.doesNotMatch(out, /no diagnostic captured/, out);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
Loading
Loading