Adding a bin Field for CLI Packages
The bin field turns a package into a command: after install, npm, pnpm and Yarn create an executable shim that runs your script, whether the user installed globally, ran it once with npx, or called it from an npm script. Getting it wrong produces some of the least helpful errors in the ecosystem — Permission denied, command not found, or a script that opens in a text editor on Windows. This guide builds a CLI package that installs cleanly on every platform, explains how the shims are created, and shows how to test it before publishing.
Exact symptoms and error messages
Broken CLI packages fail in a few recognisable ways. Missing or wrong shebang lines are the most common:
$ npx your-cli init
/home/dev/.npm/_npx/4f1c/node_modules/.bin/your-cli: line 1: import: command not found
/home/dev/.npm/_npx/4f1c/node_modules/.bin/your-cli: line 3: syntax error near unexpected token `('
That is bash trying to execute JavaScript, because the file does not start with #!/usr/bin/env node. On Windows the same mistake can open the file in the default editor or print 'your-cli' is not recognized as an internal or external command.
Other failures point at the path or the file mode:
sh: 1: your-cli: Permission denied
npm warn bin-links Failed to link bin for your-cli: ENOENT: no such file or directory, chmod '/app/node_modules/your-cli/dist/cli.js'
The first means the file was published without its executable bit and the package manager could not fix it; the second means the path in bin does not exist in the tarball — typically because dist/ was never built before publishing or was excluded by the file list.
Root cause analysis
At install time the package manager reads bin, and for each entry it creates a link in a .bin directory: node_modules/.bin for local installs, the global prefix's bin for global installs. On macOS and Linux that link is a symlink to your file, which the operating system executes directly — so the file needs a shebang and an executable mode. On Windows, where symlinks cannot run scripts, the package manager writes three small wrapper files (your-cli.cmd, your-cli.ps1 and an extensionless shell script) that read the shebang and invoke node explicitly. The shebang therefore matters on every platform.
npm run and npx prepend node_modules/.bin to PATH, which is why a locally installed CLI works in "scripts" without a path prefix — and why it does not work in your interactive shell unless installed globally. That lookup is covered in more depth in Fixing 'command not found' for Local Binaries in npm Scripts.
Resolution and configuration patch
A correct CLI package needs four things: a bin entry that points at a file inside the tarball, a shebang, a build that preserves both, and an entry file that loads the rest of the program.
{
"name": "your-cli",
"version": "1.0.0",
"type": "module",
"bin": {
"your-cli": "./dist/cli.js",
"ycli": "./dist/cli.js"
},
"files": ["dist"],
"engines": { "node": ">=18.18" },
"scripts": {
"build": "tsup src/cli.ts --format esm --target node18 --clean",
"prepack": "npm run build"
}
}
#!/usr/bin/env node
// src/cli.ts — keep this file tiny: parse args, then import the real work lazily
import { argv, exit } from 'node:process';
const [command = 'help', ...rest] = argv.slice(2);
try {
const { run } = await import('./commands/index.js');
exit(await run(command, rest));
} catch (error) {
console.error(error instanceof Error ? error.message : error);
exit(1);
}
Implementation steps:
- Use the object form of
bin. A string"bin": "./dist/cli.js"works, but the command name is then taken from the package name — awkward for scoped packages such as@acme/cli, where the command becomescli. - Put the shebang on line 1 of the source file. esbuild, tsup, Rollup and
tscall preserve a leading#!line. If your bundler moves it, set its banner option (banner: { js: '#!/usr/bin/env node' }) instead. - Build before packing. The
prepackscript runs fornpm packandnpm publish, so the file named inbinalways exists in the tarball. See Choosing Between prepare, prepack and prepublishOnly for whyprepackbeatsprepublishOnlyhere. - Declare
engines.node. CLIs are run in whatever Node.js the user has installed, so state your floor; npm warns when it is not met. - Exit with meaningful codes. CI systems and shell scripts depend on a non-zero exit when your command fails.
Keeping startup fast
A CLI's first impression is its startup time. Node.js has to parse everything your entry file imports before the first line of output, so a CLI that eagerly imports a large framework can take a second to print --help. The pattern in src/cli.ts above — a thin entry that dynamically imports the command it needs — keeps the cold path short. Bundling the CLI into a single file with its dependencies (and marking it "dependencies": {} accordingly) further cuts filesystem lookups, which matters under pnpm's deep symlink trees and on Windows.
Handling CommonJS, TypeScript and native dependencies
The entry file named in bin is executed directly by Node.js, so everything that affects module loading applies to it. Three situations need extra care.
CommonJS CLIs in a "type": "module" package. If the package is ESM by default but your CLI is CommonJS — common when the CLI predates the migration — name the file cli.cjs. The shebang runs the file with Node.js, which chooses the module system from the extension first and the nearest type field second. Using .cjs or .mjs explicitly for the bin target removes any dependency on which package.json Node.js finds.
TypeScript sources. Never point bin at a .ts file for a published package. Node.js 23.6 and later can strip types from .ts files, but type stripping is deliberately disabled for files inside node_modules, so a published .ts entry fails with ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING. Compile to JavaScript and ship the output.
Native or platform-specific dependencies. CLIs that wrap a native binary — bundlers, formatters, database tools — usually publish one package per platform and list them as optionalDependencies, with the bin entry being a small JavaScript launcher that finds the right binary and spawns it. If you take this route, the launcher must produce a clear error when the platform package is missing, because users on unusual platforms or with --omit=optional installs will hit that path. The failure mode is covered in Fixing Missing Platform Binaries in optionalDependencies.
Versioning and deprecating commands
Command names are part of your public API, just like exports. Renaming your-cli to ycli breaks every CI script and npm script that calls the old name, so treat it as a breaking change: add the new name as a second key in bin, print a deprecation notice when the old name is used (the entry can inspect process.argv[1] to see which link invoked it), and remove the old key in the next major. The same applies to flags and subcommands — a CLI that follows semantic versioning for its command surface is far easier to depend on in automation than one that treats the CLI as an implementation detail.
CLI validation and debug commands
Test the command exactly the way users will get it — from a packed tarball, not from your working tree:
npm pack
# Local install into a fixture, run via npx
mkdir -p /tmp/cli-fixture && cd /tmp/cli-fixture && npm init -y >/dev/null
npm install /path/to/your-cli-1.0.0.tgz
npx your-cli --help
ls -l node_modules/.bin/your-cli # symlink -> ../your-cli/dist/cli.js
head -1 node_modules/your-cli/dist/cli.js # must print the shebang
# Global install from the tarball, then clean up
npm install -g /path/to/your-cli-1.0.0.tgz && your-cli --version && npm uninstall -g your-cli
# One-off execution without installing
npx --yes --package /path/to/your-cli-1.0.0.tgz your-cli --version
On Windows runners, check that the wrapper files were generated: dir node_modules\.bin\your-cli* should list .cmd and .ps1 shims.
Prevention and CI/CD guardrails
- Run the packed CLI on Linux, macOS and Windows in CI. A three-OS matrix job that installs the tarball and runs
your-cli --versioncatches shebang, path and wrapper problems in one step. - Assert the shebang in a test:
head -1 dist/cli.js | grep -q '^#!/usr/bin/env node'. - Keep
bintargets insidefiles. publint warns when abinpath is not included in the tarball. - Measure startup time.
time node dist/cli.js --helpin CI, with a budget, stops slow imports creeping into the entry file.
Frequently Asked Questions
Should a CLI package be ESM or CommonJS?
Either works, because the shebang runs the file with Node.js and the type field decides how it is parsed. ESM is the better default for new CLIs on Node.js 18 and later, especially if you depend on ESM-only libraries.
Why does my CLI work with npm link but not after publishing?
npm link symlinks your working tree, including source files and an already-built dist/. The published tarball contains only what your file list allows, so a missing build step or an excluded folder shows up only after publishing. Test from npm pack output instead.
Can one package expose several commands?
Yes. Add more keys to the bin object. They can point to the same file (aliases such as your-cli and ycli) or to different entry files.
Related
- Understanding package.json Fields describes
binalongsideexports,enginesandfiles. - Fixing 'command not found' for Local Binaries in npm Scripts explains how
.binends up onPATH. - Choosing Between the files Field and .npmignore keeps the
bintarget inside the tarball. - Smoke-Testing a Tarball with npm pack is the right harness for testing a CLI before release.