Definition
If you have run an installed CLI and it finished with exit code 0 — no error, no output, no result — this is the concept you need.
A main guard (direct-run guard) is the idiom by which a script asks "was I executed directly, or imported by another module?" In Node ESM that is import.meta.url === pathToFileURL(process.argv[1]).href; in Python it is the __name__ == "__main__" family. The problem is that this is a string comparison of paths. If the executed path is a symbolic link, the two values differ, the guard silently evaluates false, and the program exits successfully without running a single line of its body.
Why it matters
A CLI installed with npm i -g is not a real file on your PATH — it is a symbolic link such as ~/.nvm/versions/node/*/bin/<cmd>. When you run it, process.argv[1] is the symlink path while import.meta.url is the real file the link points at. The two strings differ, so the guard blocks execution.
What makes this failure mode especially bad is that there is no failure signal at all. stderr is empty and the exit code is 0, so it passes in CI too. Users never suspect the tool; they assume their own input was wrong and burn time on it.
How it works
npm i -g mytool→ a symlink pointing atbin/mytool.mjsappears on PATH.- The user runs
mytool log. - Node follows the symlink and loads the real file →
import.meta.urlis the real path. - But
process.argv[1]is the symlink path, exactly as typed. import.meta.url === pathToFileURL(process.argv[1]).hrefis false → the body never runs.- Only module top-level code executes, and the process exits 0.
| Value | Under symlinked execution |
|---|---|
process.argv[1] | /Users/me/.nvm/.../bin/mytool |
import.meta.url | file:///Users/me/dev/mytool/bin/mytool.mjs |
| Comparison | false → silent no-op |
In practice
Diagnosis takes three lines.
ls -l "$(which mytool)" # is it a symlink?
node "$(readlink -f "$(which mytool)")" log # run the real path directly
# if it works here, the main guard is confirmed as the cause
The fix normalizes both sides to the real path.
import { realpathSync } from "node:fs";
import { pathToFileURL } from "node:url";
const isDirectRun =
import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
if (isDirectRun) await main(process.argv.slice(2));
A simpler alternative is to remove the guard entirely: let bin/cli.mjs import the library and invoke it immediately, and keep no executable code in the library file. There is then no path left to compare.
Trade-offs
- realpath normalization: one line, but
realpathSyncthrows on a path that does not exist. Guard against REPL execution whereprocess.argv[1]is absent. - A separate bin entry: the most robust and the clearest in intent. Costs one extra file and an edit to the package
binfield. - Keep the guard, add a log: writing one line to stderr when the guard blocks at least prevents the silent failure. Not a real fix.
When not to use it
- A pure executable script that will never be imported as a library. The guard is pointless complexity there.
- A container entrypoint whose execution path is fixed. With no symlink in the path, the problem does not exist.
Common mistakes
- Trusting exit 0 as success. No output and no artifact is not success. Make failures visible.
- Comparing path strings directly. Besides symlinks, this breaks on macOS's
/privateprefix, case-preserving filesystems, and Windows short paths. - Judging from shell output alone. In a shell with wrappers, proxies, or filters in the way, output can be transformed and mislead the diagnosis. Re-check file contents by another means.
- Mistaking the path from
whichfor the real path. You need one more step withreadlink -f.
Related concepts
- shell-word-splitting-quoting — another case where the shell layer inverts a diagnosis
- serverless-stateless-execution — the family of bugs where assumptions about the execution environment break