Back to core workflows Fix dependency resolution Tune package metadata Validate before publishing

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.

From bin field to runnable command npm install reads the bin map, links or writes wrapper shims into node_modules/.bin, and the shell or npm run finds the command on PATH. package.json bin { "your-cli": "./dist/cli.js" } install chmod +x and link, or write .cmd/.ps1 shims node_modules/.bi n added to PATH by npm run and npx #!/usr/bin/env node tells the OS or shim how to run it
The shebang drives execution on every platform — directly on Unix, and through the generated wrappers on Windows.

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:

  1. 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 becomes cli.
  2. Put the shebang on line 1 of the source file. esbuild, tsup, Rollup and tsc all preserve a leading #! line. If your bundler moves it, set its banner option (banner: { js: '#!/usr/bin/env node' }) instead.
  3. Build before packing. The prepack script runs for npm pack and npm publish, so the file named in bin always exists in the tarball. See Choosing Between prepare, prepack and prepublishOnly for why prepack beats prepublishOnly here.
  4. 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.
  5. Exit with meaningful codes. CI systems and shell scripts depend on a non-zero exit when your command fails.
Common bin mistakes and what they break Matrix of four frequent bin-field mistakes against their effect on Linux/macOS, Windows and npx. Linux / macOS Windows npx one-off No shebang shell runs JS as bash opens in editor or fails fails Path not in tarball ENOENT on link ENOENT on link ENOENT No executable bit fixed by npm on install not needed fixed on install String bin on scoped name odd command name odd command name must use -p flag
A missing shebang breaks every platform; a missing executable bit usually only bites when files are copied without npm.

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.

What happens when a user runs npx your-cli The user runs npx, which resolves and installs the package into a cache, links the bin, and executes it with Node.js via the shebang. Terminal npx npm cache Node.js npx your-cli init resolve and install your-cli linked .bin/your-cli exec via #!/usr/bin/env node output and exit code
npx installs into its own cache, links the bin entry and executes it — the same path a global install uses.

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 --version catches 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 bin targets inside files. publint warns when a bin path is not included in the tarball.
  • Measure startup time. time node dist/cli.js --help in 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