spawn EINVAL on Windows, from a .cmd or .bat
Verified on Windows 11, Node 24.20.0 · OpusMill
at ChildProcess.spawn (node:internal/child_process:420:11)
- execFile("npm.cmd", ["install"], cb);
+ execFile("npm.cmd", ["install"], { shell: true }, cb);
Then check how you are handling the error, because
this one throws synchronously — see below. If the
arguments can contain anything a user typed, prefer resolving the
real .exe and spawning that, rather than turning a shell
on.
Why it started happening
It is not your code changing. Node 18.20.2, 20.12.2 and
21.7.3 deliberately stopped child_process from
executing .cmd and .bat files without
shell: true. That was the fix for
CVE-2024-27980, a command-injection hole: those files
are interpreted by cmd.exe, and arguments could break out into it.
On Windows almost every CLI you install from npm is a
.cmd shim — npm.cmd,
tsc.cmd, electron.cmd. So code that had
worked for years began throwing on a patch release.
The part that hides it: it throws synchronously
This is the detail worth taking away. EINVAL here is
thrown immediately, from the call itself — not
delivered to your callback. So the usual shape:
execFile("npm.cmd", ["--version"], (error, stdout) => {
if (error) return; // never runs. Nothing is caught here.
});
does not catch it. Verified on Windows 11 / Node 24.20.0:
execFile("npm.cmd", ...) -> SYNCHRONOUS THROW: EINVAL spawn
execFile("node_modules/.bin/mocha") -> no throw; callback gets ENOENT
The two look like the same problem and are not. A .cmd
throws where you called it; an extensionless
node_modules/.bin shim reports
ENOENT through the
callback. Handling for one does not handle the other.
The consequence: if that execFile sits inside a
setTimeout, a promise chain, or an event handler, the
throw is an unhandled exception that can take the process
down, with a stack pointing at Node internals rather than at
the line that asked for it.
Which fix to use
| Option | When |
|---|---|
{ shell: true } |
The arguments are yours and fixed. Simplest fix. Note that on
recent Node, passing shell: true to
execFile raises the DEP0190
deprecation warning — use exec() with a
single command string instead if that matters to you. |
Resolve the real .exe |
Any argument could come from outside your program. This is the option that does not reintroduce the injection risk the CVE was about. |
| Call the JS entry point directly | Often the cleanest for npm tooling: run
node against the package's own bin script rather
than the .cmd wrapper around it. |
Do not guard it with process.platform alone
Adding if (process.platform === "win32") name += ".cmd" is
the common patch, and it is only half of it — on Windows you then
still need shell: true or a resolved executable, or you
are back here. The platform check tells you which name to use, not how
to run it.
Check your own project for the rest of this class of bug.
Paste your code or your package.json into
the browser checker — nothing is uploaded,
it runs on your machine — or run
npx github:Hackierz/winbreak over the whole repository.
--fix repairs the npm scripts that have one obvious
answer and refuses the ones that need a human.
Other errors in the same family:
- The fix for 'NODE_ENV' is not recognized
- The fix for 'rm' is not recognized
- The fix for ENOENT on node_modules/.bin
- Path too long on Windows
- EPERM on Windows
Background: I scanned the 600 most-downloaded
npm CLI packages — 17.4% have a package.json
script that cannot run on Windows.