Skip to content

pnpm run-local cannot start on Windows: spawnSync pnpm ENOENT #19

Description

@snowyukitty

Summary

pnpm run-local, the command the README gives for trying the Workshop locally, cannot start on
Windows. It fails on the first nested pnpm call, before anything is installed or built.

> gadgets@1.0.0 run-local D:\...\cloudflare-os
> node scripts/run-local.mjs

> pnpm install
node:internal/child_process:1120
    result.error = new ErrnoException(result.error, 'spawnSync ' + options.file);
                   ^
<ref *1> Error: spawnSync pnpm ENOENT
    at execFileSync (node:child_process:911:15)
    at run (file:///D:/.../cloudflare-os/scripts/run-local.mjs:118:3)

Environment: Windows 11, Node v22.12.0, pnpm 10.6.3, clean clone of main (e1ab8fb).

Cause

On Windows the pnpm on PATH is a .cmd shim. execFileSync starts a process directly instead
of going through a shell, so there is no extensionless pnpm for it to execute and the call fails
with ENOENT. Naming the shim explicitly does not help either — since the fix for CVE-2024-27980,
Node refuses to spawn .bat/.cmd without a shell and returns EINVAL:

execFileSync('pnpm.cmd', ['--version'])   ->  EINVAL  spawnSync pnpm.cmd EINVAL
execFileSync('pnpm',     ['--version'])   ->  ENOENT  spawnSync pnpm ENOENT

Affected call sites

Four sites are on the pnpm run-local / pnpm dev-server path. Fixing only the first just moves
the failure to the next one:

File What it runs
scripts/run-local.mjs (run()) pnpm install, and both builds
run-dev-server.js pnpm exec wrangler dev
packages/gatekeeper-context/build-app.mjs pnpm exec vite build
packages/gatekeeper-scheduler/build-app.mjs pnpm exec vite build

scripts/release/build-release.mjs has the same pattern in its own run() helper, but that path is
CI-only, so it is not part of the failure above.

Everything else in these scripts already spawns process.execPath or git, which are real
executables and unaffected.

Two things worth knowing before fixing it

shell: true is not a safe fix here. It makes the call work, but a shell re-splits the command
line, and run-dev-server.js passes wrangler absolute config paths built from gk.dir. On a
checkout whose path contains a space — C:\Users\Some Name\... — those arguments break apart.

npm_execpath needs a guard. It holds pnpm's own JS entry point, which node can run directly
with no shell, so arguments keep their exact values. But under npm run the same variable points at
npm instead:

pnpm run ...  ->  npm_execpath = ...\pnpm\bin\pnpm.cjs
npm  run ...  ->  npm_execpath = ...\npm\bin\npm-cli.js

Substituting it unchecked would silently run npm install against a pnpm workspace.

Patch

Happy to send this as a PR if you want it — flagging it here first because it lands at about 30
changed lines across five files, which is past the guideline in CONTRIBUTING.md.

A small shared helper keeps it to one copy (packages/* already reference ../../scripts/, e.g.
build:configurator):

// scripts/pnpm-command.js
export function pnpmCommand(args) {
  const execPath = process.env.npm_execpath ?? "";
  return process.platform === "win32" && /[\\/]pnpm\.[cm]?js$/i.test(execPath)
      ? [process.execPath, [execPath, ...args]]
      : ["pnpm", args];
}

used as const [file, argv] = pnpmCommand([...]); execFileSync(file, argv, opts); at the four sites.
It is a no-op off Windows, and on Windows it falls back to today's behaviour whenever the launcher
was not pnpm, so the failure stays loud rather than turning into the wrong package manager.

Verification

On Windows 11 / Node v22.12.0, from a clean clone:

  • before: pnpm run-local dies at pnpm install with the ENOENT above;
  • after: it completes install, @gadgets/typed-storage build and the frontend vite build, runs
    both gatekeeper build-app.mjs bundles, and reaches
    [wrangler:info] Ready on http://127.0.0.1:8787 with the Workshop serving.
  • pnpm lint:check passes.
  • The helper was exercised under pnpm run, npm run, and bare node to confirm the guard picks
    pnpm's entry point only in the first case.

Not verified: Linux/macOS behaviour is unchanged by inspection only (the platform check makes it a
no-op), and I did not run pnpm test — the release-manifest golden test already fails on Windows
before any change, from CRLF line endings.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions