changelogue is a small CLI that writes a changelog from git history. The whole bug is one line at the bottom of its src/index.ts:
if (import.meta.url === `file://${process.argv[1]}`) {
buildProgram().parse(process.argv)
}
It asks a fair question. Is this file the one node was told to run? If yes, parse the arguments and do the work. If no, something imported the file, so stay quiet and let the importer call buildProgram() itself.
node dist/index.js answers yes. The path node was given and the path of the file it loaded are the same string. Every development run and every test run took that route, and every one of them worked.
npx changelogue answers no. And a no here is silent by design: no parse, no output, no error, exit 0.
What npm does with a bin
npm does not copy a package’s bin into place. It writes a symlink at node_modules/.bin/changelogue that points at ../changelogue/dist/index.js. When node loads that file, it resolves the symlink first, so import.meta.url holds the real path. process.argv[1] keeps whatever you typed, symlink and all. The two sides of that === never agree through the bin entry, and the bin entry is the only one a user has.
You can reproduce it with a seven-line package. I did. The terminal below is that package running, both ways.
Why 98 tests could not see it
The unit suite imports buildProgram() and drives it in-process. That is the right way to test what a CLI does, and it is why the suite could not fail here. Importing the module runs the guard once, with a vitest worker as argv[1], so the guard takes its false branch and does nothing, which is what a test wants. The true branch, the one that runs for a user, is never executed by anything that imports the file.
Nothing that imports a module can test how that module is invoked. That is the scope of every unit suite. Green is evidence about the code the tests reached and nothing past it, which is the same rule as the agent being only as good as the check that grades it.
The check that takes the user’s path
The fix is small. Resolve the symlink before comparing, and build the URL with pathToFileURL instead of gluing file:// onto a string, which also breaks on a path with a space in it.
function isEntryPoint(): boolean {
const invoked = process.argv[1]
if (!invoked) return false
try {
return import.meta.url === pathToFileURL(realpathSync(invoked)).href
} catch {
return false
}
}
The check I kept is scripts/smoke.sh. It packs the package, installs the tarball into a throwaway project, and runs the tool through node_modules/.bin in a throwaway git repo with one commit. It asserts three things: --version prints the manifest version, --help prints usage, and the repo produces output that names the commit. Every assertion goes through the symlink, because that is the path the suite cannot take.
Then I calibrated it. I put the old guard back and ran the check. It failed, with the message written for this bug. I restored the fix and ran it again. It passed. A check I have not watched fail is a comment with an exit code.
What it would have cost
changelogue is the changelog generator I built alongside DeployLog, and it was not on npm yet. I found this on the day I was preparing the first publish, so the cost is the one it avoided. npm does not let you publish over a version, so a silent 0.1.0 would have stayed 0.1.0 on the registry, and the first thing every early user saw would have been a command that does nothing. The suite would have been green the whole time.
The rule I kept
Before you publish a CLI, run it once the way a user will: ./node_modules/.bin/<name> --version from a project that installed the tarball, not node dist/index.js from the repo. If it prints nothing, you have this bug. And if you want the check to keep earning its place, break the guard on purpose and watch it fail once.