A finished command-line tool becomes useful to others when it's installable — npm install -g your-tool or, better, npx your-tool with no install at all. Publishing a CLI is the same as publishing any npm package, with one extra piece: the bin field that maps a command name to your executable file. This page covers the bin field and shebang, making the package cross-platform, controlling what files ship, testing locally with npm link, publishing to npm, and the npx flow that lets users run your tool without installing it.
The `bin` field — what makes it a command
package.json
JSON
{
"name": "my-cli",
"version": "1.0.0",
"type": "module",
"bin": {
"my-cli": "./bin/index.js" // command name → the file npm should run
},
"files": ["bin", "dist"], // what gets published (see below)
"engines": { "node": ">=18" } // declare your minimum Node version
}
bin/index.js — MUST start with the shebang
JS
#!/usr/bin/env node
// ^ required: npm uses this so the OS knows to run the file with node.
import { run } from '../dist/cli.js'
run(process.argv)
The `bin` field maps a command name to a file; npm creates the launcher on install — the file must have the shebang
The **`bin`** field in `package.json` is what turns a package into a command. It maps a **command name** to the **executable file**: `"my-cli": "./bin/index.js"` means that after install, typing `my-cli` runs that file. When a user installs your package, npm creates a launcher in their `PATH` (a symlink on Unix, a small `.cmd`/shim on Windows) pointing at it. Two requirements: the target file **must begin with the shebang** `#!/usr/bin/env node` (npm relies on it, and on Unix the OS does too), and on publish that file must actually be included (see `files` below). If your package exposes a single command matching the package name, `"bin": "./bin/index.js"` (a string) is shorthand for the same thing.
Cross-platform concerns
Always include the shebang#!/usr/bin/env node — Windows ignores it, but npm's generated .cmd shim depends on it being present to know how to launch the file.
Use path.join / path for file paths, never hard-coded / separators — Windows uses \.
Don't assume Unix tools exist — shelling out to grep, rm, cat, or sh breaks on Windows; do the work in Node or use cross-platform packages.
Set "engines" to your minimum Node version so npm warns users on older runtimes.
Ensure the entry file is executable in the published package — committing it with the executable bit (or letting npm handle the bin) avoids permission denied on Unix.
Mind line endings — a CRLF on the shebang line can break execution on Unix; keep bin files LF (a .gitattributes rule helps).
Don't shell out to Unix commands or hard-code `/` paths — they break on Windows; keep the tool pure Node and cross-platform
A CLI published to npm will run on macOS, Linux, *and* Windows, and the most common portability bugs come from assuming a Unix environment. **Shelling out** to `grep`, `sed`, `rm`, `cp`, or `/bin/sh` works on your machine and fails on a user's Windows box where those don't exist — do the work in Node (`fs`, string methods) or use cross-platform npm packages instead. **Hard-coded path separators** (`config/` with `/`) break too; build paths with `path.join`/`path.resolve`. And while the **shebang** is ignored by Windows, you must still include it because npm's generated launcher shim reads it. Test on Windows (or CI matrix across OSes) before publishing — "works on my Mac" is exactly how cross-platform CLI bugs ship.
Control what ships — keep the package lean
Bash
# Preview EXACTLY what will be published BEFORE you publish — no surprises:
npm pack --dry-run # lists every file that would go into the tarball
npm publish --dry-run # full publish simulation
Use the `files` allowlist (or `.npmignore`) so you ship only built output — not tests, source maps, or secrets
By default npm publishes nearly everything in your directory, which is how tools accidentally ship tests, fixtures, `.env` files, or huge source trees. Control it with the **`files`** array in `package.json` — an **allowlist** of what to include (`["bin", "dist"]`), which is safer than a `.npmignore` denylist because new files are excluded by default rather than accidentally shipped. (A few files are *always* included regardless — `package.json`, `README`, `LICENSE` — and `node_modules` is always excluded.) Before every publish, run **`npm pack --dry-run`** to see the exact file list that will be uploaded; this is the single best habit for catching a leaked secret or a bloated package *before* it's public and permanent. Ship only what users need to run the tool.
Test locally with npm link
Bash
# In your CLI project — register it globally as a symlink:
cd my-cli
npm link # now 'my-cli' runs your local code, anywhere
my-cli --help # test it as if installed; edits are picked up live
npm unlink -g my-cli # remove the global link when done
`npm link` symlinks your local package globally so you can run the command as a real install before publishing
Before publishing, test the tool exactly as a user would invoke it. **`npm link`**, run in your project, creates a global symlink so your `bin` command is available system-wide and points at your *local working copy* — so `my-cli` runs your real code (and picks up edits, since it's a link, not a copy). This verifies the things only an install exercises: the `bin` mapping, the shebang, the launcher shim, and relative paths from the entry file. It catches "works with `node ./index.js` but breaks as an installed command" bugs *before* they reach npm. Clean up afterward with `npm unlink -g`. (`npm link` is also how you test a local package as a dependency in another project.)
Publish, and the npx flow
Bash
npm login # authenticate (once)
npm publish # publish (add --access public for a @scoped package)
# Users can now run it WITHOUT installing — npx fetches & runs the latest:
npx my-cli --help
# ...or install globally for repeated use:
npm install -g my-cli
`npm publish` ships it; `npx my-cli` lets users run the latest version with zero install — the modern default
Publishing is `npm publish` (after `npm login`); for a **`@scope/name`** package add `--access public` or it defaults to private. Once published, the most important consequence is that the `bin` command works through **`npx`**: `npx my-cli` downloads the latest version to a cache and runs it **without a global install** — which is how `create-react-app`, `create-vite`, and most scaffolders are run today (always the current version, nothing left installed). Users *can* still `npm install -g` for a frequently-used tool, but `npx` is the modern default and a reason to keep your package small and its startup fast. Bump the **version** (semver) on every publish — npm refuses to overwrite an existing version — and consider automating publish from CI on a tagged release.
Next
Add static types to your Node code: [Using TypeScript with Node](/nodejs/typescript-intro).