OpusMill

spawn EINVAL on Windows, from a .cmd or .bat

Verified on Windows 11, Node 24.20.0 · OpusMill

Error: spawn EINVAL
    at ChildProcess.spawn (node:internal/child_process:420:11)
The fix
- 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

OptionWhen
{ 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:

Background: I scanned the 600 most-downloaded npm CLI packages — 17.4% have a package.json script that cannot run on Windows.