OpusMill

I scanned the 600 most-downloaded npm CLI packages for Windows bugs

7 September 2026 · OpusMill

The short version

599 packages, 33,402 source files. 91.7% of their JavaScript came back with nothing at all. The checker flagged 48 bugs across 18 packages.

Then I read all 48 by hand, which is the part that actually mattered. Three are real. Thirty-eight are deliberately Linux-only code my tool cannot tell apart from a mistake. Seven were my tool being wrong.

I originally published this saying eleven were real. Then I kept reading, and it collapsed to three. That correction is the most useful thing in here, so it is documented below rather than quietly edited out.

Then I noticed I had been reading the wrong file the whole time. Checking package.json as well as the JavaScript found a Windows-hostile build script in 104 of the 599 packages — 17.4%, against 3.0% for the JavaScript. That is the last section, including why the number is less alarming than it sounds.

Why do this at all

I maintain a small static checker called winbreak. It looks for the handful of patterns that make Node code work on macOS and fail on Windows: spawning a .cmd without a shell, spawning an extensionless node_modules/.bin shim, shelling out to a command Windows does not have, hardcoding /tmp.

A checker with no calibration is worthless. Anyone can write a regex that reports something in every file, and the resulting number tells you nothing except that the regex is loose. So the question I wanted answered was not "how much npm code is broken." It was "is my threshold sane?"

The method, so you can re-run it

I searched the npm registry for packages tagged cli, devtools, build-tool, scaffold, generator and process, which gave 1,417 unique names. I ranked those by real weekly download counts from the npm downloads API — not by the registry's own popularity score, which returned an identical value for every package and is useless for ranking — and took the top 600.

Each one was fetched as its published tarball, extracted, scanned including dist/, and deleted before the next batch. One fetch failed, leaving 599.

The harness is in the repository, so you can check this rather than believe it: github.com/Hackierz/winbreak/survey. python run_survey.py 600 and it does the whole thing. The exact ranked list of packages is checked in too — without it you would be scanning a different sample and could not reproduce anything, because download counts move. I have had to correct this write-up three times in public, and those corrections are only worth something if somebody else can reach the numbers independently.

599packages scanned
33,402source files
548completely clean
48bugs reported

The headline result

OutcomePackagesShare
Nothing found at all54991.7%
Smells only (fragile, not broken)325.3%
At least one bug183.0%

That 91.7% is the number I actually wanted. A tool that lit up on most of the ecosystem would have a broken threshold, and a tool that found nothing anywhere would be doing nothing. Mature, widely-used packages are mostly fine, which is what you would hope and is worth saying out loud.

This table is about JavaScript only, which is what I was scanning at the time. Counting the package.json findings from the last section as well, 459 of the 599 packages (76.6%) are clean on both axes.

Then I read all forty-eight

This is where a survey usually stops and publishes the percentage. It is also where it stops being honest, because a count of findings is not a count of bugs.

3 real   38 deliberately Linux-only   7 my checker being wrong

The correction, because it is the interesting part

My first pass through these findings put eleven in the “real” column. I published that. Then I went back to write each one up properly, which meant opening the actual source rather than the single line my own tool had shown me. Four of the eleven evaporated:

  • create-storybook — the ps call is there, but one function up getProcessAncestry dispatches on os.platform() === "win32" and only reaches it on Unix.
  • pi-subagents — the caller sets posixGroupOwned = process.platform !== "win32" and returns "unsupported-platform" before the call.
  • metaharness — an if (platform === 'win32') { …powershell…; return } sits directly above the ps call.
  • disk — the /tmp path is set as an environment variable for a remote process. It is not a path on this machine at all.

Three of those four hide the guard in a different function. That is cross-function analysis, and a tool that reads text one file at a time is not going to do it. I fixed the fourth — the block-form early return — and left the rest as a stated limitation rather than pretending otherwise.

The three that survived

agent-cli-detector, at 4.5M downloads a week, accounts for two. It calls execFileSync("ps", …) inside a try/catch that returns an empty string, and there is no mention of platform, win32, darwin or linux anywhere in the 326-line file. On Windows the call throws, the catch swallows it, and the entire process-tree detection strategy quietly finds nothing.

The third is pmx, part of the pm2 family, and it is the whole article in five lines:

childProcess.execFile(/^win/.test(process.platform) ? 'npm.cmd' : 'npm',
  ['ls', '--json', '--production'],
  { windowsHide: true, maxBuffer: 1024 * 1024 },
  function (error, stdout, stderr) {
    // if we can't spawn the npm binary, stop here
    if (error) return

Someone thought carefully about Windows here — there is a platform check, and windowsHide is a Windows-only option. But since Node 18.20.2 / 20.12.2 shipped the fix for CVE-2024-27980, handing a .cmd to execFile without shell: true throws EINVAL. The callback catches it and returns. So on Windows this quietly collects no dependency data, forever, and nothing anywhere logs a thing.

The thirty-eight that are not bugs

The largest group by far, and the most interesting one. This code is POSIX-only on purpose, and my checker has no way to know that.

PackageFlaggedWhy it is fine
pm213/etc/init.d, /etc/systemd, /etc/rc.d, /etc/logrotate.d, /etc/passwd. pm2 generates Linux init scripts. The feature is Linux.
oclif6All in pack/deb.jsln -s, sudo chown. It is building a Debian package. You cannot do that on Windows anyway.
skills, simple-bin-help8A bundled xdg-basedir falling back to /usr/local/share when $XDG_DATA_DIRS is unset. That is what the library is for.
terminal-kit4/usr/share/terminfo, and a Unix terminal-detection helper that rejects rather than failing silently.
vite, sandbox3Reading /etc/wsl.conf to detect WSL. The read is expected to fail elsewhere.
inspect-webkit2/var/run/usbmuxd, a Unix domain socket.
others2An allowlist of install directories, an /etc/codex probe, and a /root fallback that already tries USERPROFILE first.

There is no clever fix for this. execSync('rm -rf ...') in a script that only ever runs on a Linux build agent is indistinguishable, as text, from the same call in a cross-platform CLI. One is fine and one is a bug, and the difference is intent. A checker that reads text cannot see intent, and pretending otherwise is how you get a tool nobody trusts.

The seven that were my fault

The four from the correction above, plus firebase-tools, flagged for "/home/firebase/app/…" — a path inside a Docker image, not on the machine running the code — and generify, flagged for a /tmp path in its example.js, which is documentation.

That is a 15% false-positive rate on findings, and I would rather print it than have you discover it. Every checker has one. Most do not tell you what theirs is.

Seven bugs in my own checker

Getting from the first run to the numbers above meant fixing the checker seven times. This was the survey's actual yield.

  1. whoami was on my list of commands Windows does not have. It ships with Windows, and has since Vista. Two packages were reported for perfectly good code. The general trap is worth naming: "this is a Unix command" is not the same as "Windows does not have it." Windows also has find, sort, more, where, tasklist and taskkill.
  2. Guard detection did not understand a platform name. It looked for process.platform and isWindows. agent-browser stores the platform in a local variable and writes if (os === 'linux'), with an os === 'win32' branch immediately below. Correct code, reported as a bug.
  3. And when I fixed that, it still failed. To stop braces inside strings from confusing the brace counter, the code stripped string literals before recording a block header — and then recorded the stripped version. So the guard arrived as if (os === '') with the platform name deleted. It now counts on the stripped line and keeps the original.
  4. An early return is a guard. if (win32) return; followed by the POSIX call does not enclose anything, so walking the enclosing blocks never found it.
  5. Handing a .cmd to cmd.exe was reported as a bug. That is the recommended fix. projen does exactly the right thing and got told off for it.
  6. A platform branch that returns is a guard too, even spread over a block. The single-line if (win32) return; was handled; the same thing written as if (win32) { …; return } above the call was not, because that block does not enclose the call, it only leaves before it. metaharness was reported for exactly this.
  7. rm -rf matched anywhere in 2,000 characters of extracted call text. sails-generate was flagged because a console.log("rm -rf node_modules && npm install") — advice printed for a human to read — fell inside a nearby call's window.

There was an eighth, of a different kind: findings on minified lines. Next.js ships cross-spawn compiled to a single line, and my checker reported it as a .cmd spawning bug. cross-spawn is the library that exists to fix .cmd spawning. A finding on line 1 of a 200KB line is useless even when it is correct, so those are suppressed now.

Then I checked the file I had been ignoring

Everything above is about JavaScript. Three real bugs in 599 packages is a reassuring number, and I was ready to publish it as the finding.

Then it occurred to me that the most common Windows portability bug in the Node ecosystem does not live in JavaScript at all. It lives in the scripts block of package.json. The evidence is cross-env: a package that exists for no purpose other than working around NODE_ENV=production node build.js not being valid on Windows, and which is downloaded 25 million times a week. Nobody installs that many copies of a workaround for a rare problem.

My checker read .js files. It had never opened a package.json. So I taught it three rules — inline environment variables, POSIX-only commands in command position, and $VAR shell expansion — and re-ran the same 600.

104packages with a hostile script
17.4%of the survey
214script findings
2a user would ever hit

17.4% against 3.0%. The bug I could not see was roughly six times more common than the one I could, and it was sitting in a file I had walked past 599 times.

RuleFindingsWhat it catches
npm-script-posix-command 119 rm, cp, sed, grep — commands cmd.exe does not have. rm alone accounts for 80 of them.
npm-script-inline-env 73 NODE_ENV=test node --test. cmd.exe has no VAR=value command syntax at all.
npm-script-shell-var 22 $FILE, ${i/.js/.txt}, and for loops written in sh.

What it looks like in packages you already have installed

These are not obscure. Ordered by weekly downloads, the first few are:

PackageWeeklyScript
jackspeak117M for i in examples/*.js ; do node $i -h > ${i/.js/.txt}; done
is-callable102M eclint check $(git ls-files | xargs find | grep -vE ...)
ora91M xo && NODE_ENV=test node --test test.js
pure-rand75M find . -name '*.d.ts' -exec cp --parents {} ../ \;
listr245M NODE_OPTIONS='--no-warnings ...' jest
nodemon14M for FILE in test/**/*.test.js; do ... TEST=1 mocha $FILE; ...

The fix, for 142 of the 214

Naming a problem 214 times without offering the repair is half a tool, so the checker now applies it. winbreak --fix rewrites only the scripts block, and only the problems the ecosystem has already settled on an answer to:

- "clean": "rm -rf dist"
+ "clean": "rimraf dist"

- "build": "rm -rf dist && NODE_ENV=production rollup -c"
+ "build": "rimraf dist && cross-env NODE_ENV=production rollup -c"

- "assets": "cp -R ./src/assets ./lib/"
+ "assets": "shx cp -R ./src/assets ./lib/"

Run over every finding in this survey, that repairs 142 of the 214 (66%). The rest are $npm_package_config_*, docker invocations, || true, and shell programs.

What it refuses is the more important half. A for loop, $(...), ${VAR/a/b} or a pipe into sed is a shell program. There is no mechanical translation of one into something cmd.exe runs, and a wrong repair to a build script fails later, in a different place, in a file nobody re-reads. So it lists those by name with the reason and leaves them alone. It also rewrites only the changed values rather than reserializing your file, reparses before saving, and does not run npm install for you.

You can try it with nothing installed: paste a package.json into the browser checker and it hands back the repaired file to copy, along with what it would not touch.

Why this is less alarming than 17.4% sounds

This is the part it would be dishonest to leave out, and it is the reason I am not calling this "104 broken packages."

Almost none of these ever run on your machine. npm executes a package's own scripts for you only at install time — preinstall, install, postinstall, prepare. Everything else runs only for someone who has cloned the repository and is working on it. The most-flagged script names are clean (31), build (27) and test (20). Exactly two of the 214 are install-time scripts, and both are the || true idiom, whose only consequence on Windows is failing to swallow an error it was meant to swallow.

So the correct reading of 17.4% is not "one in six npm packages is broken on Windows." It is:

One in six of the most-downloaded npm CLI packages ships perfectly well to Windows users, and cannot be contributed to from a Windows machine without the contributor first repairing the build.

That is a real cost, but it falls on would-be contributors, silently, and it never arrives as a bug report — because the person who hit it was not a contributor yet, and gave up.

How confident am I in the 214

I read 28 of them in full: the top twelve by download count, and a random sample of sixteen from the middle of the distribution. All 28 were genuine. That is a far better hit rate than my JavaScript rules managed, and the reason is that an npm script gives you almost no context to misread. There is no enclosing if (process.platform === 'win32') to miss, because package.json has no if. Either the string runs in cmd.exe or it does not.

The command list is deliberately narrow for the same reason. echo, mkdir, find and test all exist in cmd.exe in some form, so none of them are flagged, even though a POSIX purist would point out that find is a completely different program there. Commands match only in command position, and cross-env, rimraf, shx, del and npm-run-all are allowlisted — reaching for the workaround is the fix, not the bug.

One caution I ran into while checking this, worth knowing if you try to reproduce it: on my Windows machine sed, grep and true all resolve perfectly happily from a shell — to C:\Program Files\Git\usr\bin. That directory is on Git Bash's PATH. It is on neither the machine PATH nor the user PATH, so it does not exist for cmd.exe, which is what npm uses to run scripts on Windows. I nearly talked myself out of an entire rule on the strength of a test run in the wrong shell.

I scanned tarballs. Contributors run the repository.

This survey reads each package's published tarball. But the person this bug actually hurts has cloned the repository, and those are not guaranteed to be the same file — tools like clean-publish can strip the scripts block on the way to npm. If that were common, I would be measuring the wrong artefact entirely.

So I checked twenty of them, comparing the scripts in the tarball against the scripts in the repository's package.json on its default branch:

ResultCount
Scripts stripped on publish0the failure mode that would invalidate this
Identical to the repository15of 19 comparable
Differ by a few keys4consistent with release drift — the tarball is a released version and main has moved on. knip's tarball has six more scripts than current main, so the drift runs both ways.
Not comparable1monorepo, no package.json at the repository root

Nobody stripped anything. The published scripts block is a fair proxy for what a contributor would find after cloning, so the 17.4% is measuring the thing it claims to. Twenty is a small check, but stripping is the kind of failure that would show up as an obvious zero, and there were none.

What this sample leaves out, and whether it matters

Not one of the 599 packages is a scoped @scope/name package. Zero, out of all 1,412 names the keyword search returned — so this is a property of how I gathered the list, not a filter I applied on purpose. Scoped packages are a large and growing part of npm, so that is a real limitation and you should know about it before trusting the headline.

So I measured it rather than leaving it as a caveat. I took 34 scoped CLI and build-tooling packages — @babel/cli, @typescript-eslint/parser, @swc/core, @playwright/test, @nestjs/cli and so on, matched to this survey's subject area — and ran the same scan:

Hostile npm script95% interval
599 unscoped (this survey)17.4%14.5–20.6%
34 scoped14.7% (5 of 34)6.4–30.1%

The intervals overlap heavily, so there is no evidence that leaving scoped packages out moved the headline number. That is the useful conclusion, and it is about as much as 34 packages can support — the scoped interval is wide enough to drive a bus through, and the sample is hand-picked rather than random, because the registry's search endpoint rate-limited me and I stopped rather than hammer it.

One thing I am not claiming: the scoped sample also produced more raw JavaScript findings (5 of 34 against 18 of 599). I have not hand-read those the way I read all 48 above, and the whole lesson of this write-up is that a raw finding count is not a bug count — the 48 became three. So that number stays here as an observation and does not become a claim.

What I would take away from this

Most of what a text-based checker flags in mature code is deliberate. Four fifths of my findings were people knowingly writing Linux-only code in a Linux-only path. If you build something like this, the default assumption for a finding in a well-used package should be "this is intentional and I do not understand the context yet," not "I found a bug."

The survey audited the tool, not the ecosystem. I set out to measure npm and measured myself. Every one of those six bugs was invisible until real code walked into it, and none would have been caught by more unit tests, because I would have written the tests with the same wrong assumptions.

Check whether you are looking in the right file at all. I spent days tuning the precision of rules that read JavaScript, and the whole time the more common bug was one package.json away — in a file my directory walker was already opening and throwing away. No amount of refining an answer helps if the question has the wrong scope.

Calibrate against code you believe is correct. It is tempting to test a checker on things you suspect are broken, because finding something feels like success. The useful signal is the opposite: point it at code you are confident about and see what it says. Every improvement here came from a finding I did not want.

Look up a package

Every one of the 599 results is searchable at opusmill.com/packages, with my classification of each finding and a link you can share for any single package.

Try it yourself

The checker is MIT licensed, has no dependencies, and its own test suite runs on Windows, macOS and Linux across Node 18, 20 and 22. Every case above is a regression test now.

Paste code into the browser version — nothing is uploaded, it runs entirely on your machine — or scan a whole repository with npx github:Hackierz/winbreak. The source is at github.com/Hackierz/winbreak.

About

OpusMill, a one-person shop in Singapore making small developer tools. Also here: a census of Coinbase's x402 marketplace, and the time I accused nodemon of a bug it does not have. The shop is here.