diff --git a/packages/gatekeeper-context/build-app.mjs b/packages/gatekeeper-context/build-app.mjs index ffeeefa2..df1a2e2d 100644 --- a/packages/gatekeeper-context/build-app.mjs +++ b/packages/gatekeeper-context/build-app.mjs @@ -1,8 +1,8 @@ // Build the Context Library SPA into generated single-file HTML for startAppUi(). -import { execFileSync } from "node:child_process"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { execPnpm } from "../../scripts/pnpm-command.mjs"; const pkgDir = resolve(fileURLToPath(import.meta.url), ".."); const watch = process.argv.includes("--watch"); @@ -12,8 +12,7 @@ console.log( ? "watching context library app for changes…" : "building context library app single-file bundle…", ); -execFileSync( - "pnpm", +execPnpm( ["exec", "vite", "build", "-c", "vite.config.ts", ...(watch ? ["--watch"] : [])], { cwd: pkgDir, stdio: "inherit" }, ); diff --git a/packages/gatekeeper-scheduler/build-app.mjs b/packages/gatekeeper-scheduler/build-app.mjs index fa5ccb87..e2dfb933 100644 --- a/packages/gatekeeper-scheduler/build-app.mjs +++ b/packages/gatekeeper-scheduler/build-app.mjs @@ -1,12 +1,11 @@ -import { execFileSync } from "node:child_process"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { execPnpm } from "../../scripts/pnpm-command.mjs"; const packageDirectory = resolve(fileURLToPath(import.meta.url), ".."); const watch = process.argv.includes("--watch"); -execFileSync( - "pnpm", +execPnpm( ["exec", "vite", "build", "-c", "vite.config.ts", ...(watch ? ["--watch"] : [])], { cwd: packageDirectory, stdio: "inherit" }, ); diff --git a/run-dev-server.js b/run-dev-server.js index a5aba890..7297b5dc 100644 --- a/run-dev-server.js +++ b/run-dev-server.js @@ -15,6 +15,7 @@ import { execFileSync, spawn } from "node:child_process"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { parse } from "jsonc-parser"; +import { execPnpm } from "./scripts/pnpm-command.mjs"; import { getWranglerPortFromBackendHost } from "./scripts/dev-server-config.js"; const ROOT = dirname(fileURLToPath(import.meta.url)); @@ -322,7 +323,7 @@ if (backendHost) { console.log(`\nStarting: wrangler dev ${args.join(" ")}\n`); try { - execFileSync("pnpm", ["exec", "wrangler", "dev", ...args], + execPnpm(["exec", "wrangler", "dev", ...args], { stdio: "inherit", cwd: ROOT }); } catch (e) { // wrangler was killed or exited with an error; the output was already shown diff --git a/scripts/generate-worker-types.mjs b/scripts/generate-worker-types.mjs index 24502155..3730715a 100644 --- a/scripts/generate-worker-types.mjs +++ b/scripts/generate-worker-types.mjs @@ -14,10 +14,10 @@ * * `--check` is non-mutating: writes a sibling temp file, compares, deletes it. */ -import { spawnSync } from "node:child_process"; import { readdir, readFile, rm, writeFile } from "node:fs/promises"; import { dirname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { spawnPnpmSync } from "./pnpm-command.mjs"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const packagesDir = join(root, "packages"); @@ -126,11 +126,13 @@ async function generateOne(pkgDir) { if (runtimeOnlyConfig) { args.push("--config", runtimeOnlyConfig, "--include-env", "false"); } - const result = spawnSync( - "pnpm", + const result = spawnPnpmSync( args, { cwd: pkgDir, encoding: "utf8", env: process.env }, ); + if (result.error) { + throw new Error(`failed to spawn pnpm in ${rel}: ${result.error.message}`); + } if (result.status !== 0) { console.error(result.stdout); console.error(result.stderr); diff --git a/scripts/pnpm-command.mjs b/scripts/pnpm-command.mjs new file mode 100644 index 00000000..af471ef1 --- /dev/null +++ b/scripts/pnpm-command.mjs @@ -0,0 +1,37 @@ +// Resolve how to invoke pnpm on Windows without a shell. +// +// On Windows, `pnpm` on PATH is a `.cmd` shim. Node refuses to spawn `.cmd`/`.bat` without a +// shell (EINVAL, CVE-2024-27980), and `shell: true` re-splits arguments so absolute paths +// containing spaces break (see cloudflare/cloudflare-os#19). +// +// When the current process was launched by `pnpm run`, `npm_execpath` points at pnpm's JS entry +// (`.cjs` or `.mjs` depending on install). Running `node …` keeps argv intact with no +// shell. Under `npm run` the same variable points at npm-cli.js — the guard below rejects that +// so the failure stays loud rather than silently using the wrong package manager. + +import { execFileSync, spawnSync } from "node:child_process"; + +/** + * Return `[executable, argv]` for spawning pnpm with the given arguments. + * @param {string[]} args + * @returns {[string, string[]]} + */ +export function pnpmCommand(args) { + const execPath = process.env.npm_execpath ?? ""; + if (process.platform === "win32" && /[\\/]pnpm\.[cm]?js$/i.test(execPath)) { + return [process.execPath, [execPath, ...args]]; + } + return ["pnpm", args]; +} + +/** @type {typeof execFileSync} */ +export function execPnpm(args, options) { + const [file, argv] = pnpmCommand(args); + return execFileSync(file, argv, options); +} + +/** @type {typeof spawnSync} */ +export function spawnPnpmSync(args, options) { + const [file, argv] = pnpmCommand(args); + return spawnSync(file, argv, options); +} diff --git a/scripts/release/build-release.mjs b/scripts/release/build-release.mjs index 8bbf4f12..456fac2b 100644 --- a/scripts/release/build-release.mjs +++ b/scripts/release/build-release.mjs @@ -24,6 +24,7 @@ import { import { findDeployablePackages, generateManifest, readDeployInputs, readWranglerConfig, } from "./manifest-lib.mjs"; +import { execPnpm } from "../pnpm-command.mjs"; const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); const PACKAGES_DIR = join(ROOT, "packages"); @@ -42,6 +43,10 @@ function parseArgs(argv) { function run(command, argv, options = {}) { console.log(`running: ${command} ${argv.join(" ")} ${options.cwd ? `(in ${options.cwd})` : ""}`); + if (command === "pnpm") { + execPnpm(argv, { stdio: "inherit", cwd: ROOT, ...options }); + return; + } execFileSync(command, argv, { stdio: "inherit", cwd: ROOT, ...options }); } diff --git a/scripts/run-local.mjs b/scripts/run-local.mjs index ac0d5898..b1dae481 100644 --- a/scripts/run-local.mjs +++ b/scripts/run-local.mjs @@ -21,6 +21,7 @@ import { execFileSync, spawn } from "node:child_process"; import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join, relative, sep } from "node:path"; import { fileURLToPath } from "node:url"; +import { execPnpm } from "./pnpm-command.mjs"; const ROOT = dirname(dirname(fileURLToPath(import.meta.url))); const STAMP_PATH = join(ROOT, ".run-local-stamp"); @@ -113,21 +114,21 @@ const outputsPresent = existsSync(FRONTEND_DIST) && existsSync(TYPED_STORAGE_DIS const needsBuild = stamp !== sourceHash || !outputsPresent; const needsInstall = needsBuild || !existsSync(NODE_MODULES); -function run(cmd, args) { - console.log(`\n> ${cmd} ${args.join(" ")}`); - execFileSync(cmd, args, { stdio: "inherit", cwd: ROOT }); +function runPnpm(args) { + console.log(`\n> pnpm ${args.join(" ")}`); + execPnpm(args, { stdio: "inherit", cwd: ROOT }); } if (needsInstall) { - run("pnpm", ["install"]); + runPnpm(["install"]); } else { console.log("Dependencies up to date; skipping install."); } if (needsBuild) { // Build only what's required to run locally (no full-repo type-check / no frontend tsc). - run("pnpm", ["--filter", "@gadgets/typed-storage", "build"]); - run("pnpm", ["--filter", "@gadgets/workshop-frontend", "exec", "vite", "build"]); + runPnpm(["--filter", "@gadgets/typed-storage", "build"]); + runPnpm(["--filter", "@gadgets/workshop-frontend", "exec", "vite", "build"]); // Record the stamp only after a successful build so an interrupted build retries next time. writeFileSync(STAMP_PATH, sourceHash + "\n");