Setting environment variables on Windows npm scripts

Why "NODE_ENV=test" fails on Windows, and the one-line fix with cross-env.

TL;DR: Use cross-env.

A quick one today. I was setting up FINOS git-proxy on a Windows machine and tried to run the test suite. The script in package.json looked like this, from back when the suite still ran on ts-mocha (it later moved to Vitest, though the shell problem is the same whatever the runner):

"test": "NODE_ENV=test ts-mocha './test/**/*.test.js' --exit"

The error I got, was:

'NODE_ENV' is not recognized as an internal or external command

The reason is that npm runs scripts through cmd.exe by default on Windows, and cmd.exe does not understand the Unix shell convention of putting an environment variable assignment inline before a command. So NODE_ENV=test ts-mocha ... gets read as “run the program literally called NODE_ENV=test”, which of course does not exist.

My first attempt was to rewrite it in PowerShell, since that is what I actually use day to day:

"test-windows": "$env:NODE_ENV='test'; ts-mocha './test/**/*.test.js' --exit"

That also fails, with a confusing error:

The filename, directory name, or volume label syntax is incorrect.

The error is from cmd.exe again. npm does not switch to PowerShell just because the script body happens to look like PowerShell. $env:NAME is PowerShell’s own syntax for an environment variable, and npm still feeds the whole line to cmd.exe, which sees $env:NODE_ENV='test' and gives up.

The right fix: cross-env

cross-env is a tiny package that sets environment variables in a portable way, so one script works on every OS your team is on.

npm install --save-dev cross-env

Then the script becomes:

"test": "cross-env NODE_ENV=test ts-mocha './test/**/*.test.js' --exit"

That is the whole fix. Just one word added to the script, one dev dependency. The same line runs whether you are on macOS, Linux, or Windows. CI configs stay clean too, since there is no OS branching at the script level.

The less-ideal fallback: separate scripts per platform

If for some reason you cannot add a dependency (corporate policy, supply-chain restrictions, whatever the reason), you can keep two scripts and use the Windows set builtin instead:

"test": "NODE_ENV=test ts-mocha './test/**/*.test.js' --exit",
"test:win": "set NODE_ENV=test&& ts-mocha './test/**/*.test.js' --exit"

Two things to watch out for. First, there must be no space between test and &&. If you write set NODE_ENV=test && with a space, that space becomes part of the variable value, and process.env.NODE_ENV ends up as "test " with a trailing space rather than "test". That is a painful bug to track down because the value looks right when you log it casually. The behaviour is documented in the Windows set command reference.

Second, you now have two scripts to keep in sync, and your CI workflow has to choose between them based on the runner OS. For one variable that is tolerable, but the moment you have two or three flags it gets messy fast. That is exactly the case cross-env was built for.

If the variables you need are configuration rather than a single test flag, there is now a third option that avoids the shell entirely: Node can read them from a file with --env-file, which behaves the same on every OS because Node is doing the parsing rather than the shell.

For reference, the underlying Node API being set here is process.env, and the npm scripts documentation covers how npm picks which shell to invoke. You can override that with the script-shell npm config if you really want to force PowerShell, but for portability cross-env is still the better answer.

The general shape of this bug outlives the specific fix: the command is fine and the shell reading it is not the one you assumed. Windows CI runners default to PowerShell for the same reason, which is one of the ways a workflow step can fail before it runs a single line of your script.