Bun Publishes a CLI That Runs Without `node_modules/.bin` When the Binary Field Is Wrong

bun, cli, npm, package-json, publishing

bunx my-cli fails with error: Command "my-cli" not found after bun install or npm install, even though the package published successfully and the tarball contains the source files.

What the installed command resolution depends on

A published CLI package is not executed from its source tree. Package managers create an executable shim when the package exposes a bin entry in package.json.

A minimal CLI package looks like this:

json
{ "name": "@acme/my-cli", "version": "1.0.0", "bin": { "my-cli": "./dist/cli.js" } }

The key is the bin field value. Package managers resolve it to a file inside the package, then link a command name to that file:

If the path in bin does not exist in the published package, the install may still succeed. The command does not. If the file exists but is not executable, or does not start with a shebang, the shim may exist but invoking it fails.

The install-time behavior is different from local source execution. bun run src/cli.ts can work because Bun runs the file directly. Installed package behavior depends on the package tarball and the bin metadata, not on your workspace layout.

How bin is resolved

The bin field supports two forms:

json
{ "bin": "dist/index.js" }

This maps the package name itself to a single executable.

Or:

json
{ "bin": { "my-cli": "dist/cli.js", "my-cli-dev": "dist/dev.js" } }

This maps multiple command names.

During install, the package manager reads package.json from the unpacked package. It then checks the bin target path relative to the package root. That path must be present in the tarball or the package’s unpacked contents.

A wrong path is enough to break the command:

json
{ "name": "@acme/my-cli", "bin": { "my-cli": "./bin/index.js" } }

If the real file is dist/cli.js, the package still publishes. The command shim points at a file that is missing after installation.

The same applies if the path is right in source but the build step does not run before publish. For example, src/cli.ts may exist in the repository, while the published package only contains dist/ files generated by prepack or prepare.

Why a missing shebang breaks execution

A CLI entry file needs an executable header when the package manager invokes it directly as a process target.

For Node-based CLIs, the standard first line is:

js
#!/usr/bin/env node

For Bun-native CLIs, either of these is common:

js
#!/usr/bin/env bun

or

js
#!/usr/bin/env node

if the file is still meant to run under Node.

Without a shebang, the OS does not know which interpreter should execute the file. On Unix-like systems, direct execution usually fails with errors such as:

text
env: node: No such file or directory

or:

text
/usr/bin/env: ‘node’: No such file or directory

If the file is present but lacks execute permission, the failure is different:

text
Permission denied

Package managers generally preserve the executable bit from the packaged file when they create the shim. That means the file must be marked executable in the repository or during the build step before packing.

Bun publishing specifics

Bun can publish packages with bun publish, and it can also pack them with bun pm pack for inspection. The important part is that Bun publishes the contents of the package tarball, not the full workspace. That means any file needed by the CLI must be included in the packed result.

The usual setup is one of these:

For a Bun-targeted CLI, this works:

json
{ "name": "@acme/my-cli", "version": "1.0.0", "type": "module", "bin": { "my-cli": "./dist/cli.js" }, "files": [ "dist" ], "scripts": { "build": "bun build src/cli.ts --outdir dist --target bun", "prepack": "bun run build" } }

And the entry file should start with a shebang:

ts
#!/usr/bin/env bun console.log("hello from bun cli")

If the build output omits the shebang or strips the executable bit, the command may not work after install even though bun run dist/cli.js appears fine.

For Node compatibility, the same pattern applies, but the shebang should be #!/usr/bin/env node, and the code should be valid for the Node runtime that users actually have installed.

Why local runs can succeed while installed runs fail

A local run uses the file path you point at:

bash
bun run src/cli.ts

That bypasses package.json bin resolution entirely.

An installed command uses the package metadata:

bash
bunx my-cli

or:

bash
npm install my-cli

In the installed case, the package manager has only the packaged files to work with. If the bin path is wrong, the shim points to nowhere. If the file is not executable, the shim exists but cannot launch it. If the file lacks a shebang, the kernel does not know how to run it.

This is why local testing is not enough. A CLI can be runnable from source and still ship a broken command name.

A minimal broken package

This package can publish without error, but the command will not work:

json
{ "name": "@acme/my-cli", "version": "1.0.0", "type": "module", "bin": { "my-cli": "./bin/cli.js" }, "files": [ "dist" ], "scripts": { "build": "bun build src/cli.ts --outdir dist --target bun", "prepack": "bun run build" } }

The bug is the bin path. The build emits dist/cli.js, but the manifest points at bin/cli.js.

A second broken case is a missing shebang:

ts
export function main() { console.log("hello") } main()

The file compiles and executes under bun run dist/cli.js, but the installed command may fail because the shell shim invokes it as an executable file.

A third broken case is permissions. If the file is not executable, installation may still place the shim, but the final invocation fails when the OS refuses to launch the target.

Correcting the package manifest

Point bin at the real emitted file, not the source file unless the package is deliberately shipping source and the runtime supports it.

json
{ "name": "@acme/my-cli", "version": "1.0.1", "type": "module", "bin": { "my-cli": "./dist/cli.js" }, "files": [ "dist" ], "scripts": { "build": "bun build src/cli.ts --outdir dist --target bun", "prepack": "bun run build" } }

Then ensure the file begins with a shebang:

ts
#!/usr/bin/env bun export function main() { console.log("hello") } main()

If you compile TypeScript with a separate tool, configure it to preserve the shebang or add it to the generated file. Many bundlers and transpilers will remove or relocate the first line unless configured explicitly.

If the package targets Node instead of Bun, use:

ts
#!/usr/bin/env node console.log("hello")

The runtime in the shebang must match what the installed command expects to execute.

Making the file executable

On Unix-like systems, the command target should have executable permission before packing.

Verify with:

bash
chmod +x dist/cli.js ls -l dist/cli.js

You want to see an executable mode such as -rwxr-xr-x.

If the file is generated during build, add the permission step to the build or prepack process. For example:

json
{ "scripts": { "build": "bun build src/cli.ts --outdir dist --target bun && chmod +x dist/cli.js", "prepack": "bun run build" } }

On Windows, executable bits are not enforced the same way, but the packaged tarball still needs a command file that works for Unix users. Publishing a cross-platform CLI usually means testing on a Unix-like environment as part of the release process.

Verifying the package before publish

The most reliable check is to inspect the packed tarball, not the workspace tree.

With Bun:

bash
bun pm pack tar -tf acme-my-cli-1.0.1.tgz

Confirm that:

Extract and inspect the manifest:

bash
tar -xOf acme-my-cli-1.0.1.tgz package/package.json

Then confirm the binary path exists inside the archive:

bash
tar -tf acme-my-cli-1.0.1.tgz | grep 'dist/cli.js'

You can also install the tarball into a temporary project and test the command exactly as users will run it:

bash
mkdir /tmp/my-cli-test cd /tmp/my-cli-test bun init -y bun add /path/to/acme-my-cli-1.0.1.tgz bunx my-cli

If the command is broken, this test fails in the same way a real install does.

Common failure modes

A few combinations produce similar symptoms but have different causes:

The files field matters because it limits what enters the tarball. A correct bin path is useless if the file is excluded from publish.

For example:

json
{ "files": [ "src" ] }

with bin pointing at dist/cli.js will fail after packing because dist is not included.

A safe release check

Use this sequence before publishing:

bash
bun run build chmod +x dist/cli.js bun pm pack tar -tf *.tgz bun add ./acme-my-cli-1.0.1.tgz bunx my-cli

That verifies the actual publish artifact rather than the local source tree.

If the CLI is intended to run under Bun, make sure the emitted file starts with #!/usr/bin/env bun or a compatible Node shebang if it is Node-compatible. If it is intended for Node users, ship JavaScript that Node can execute without Bun-only syntax.

Practical takeaway

Prefer a bin entry that points at the real built file in the published tarball, with a correct shebang and executable bit, and verify the packed artifact before release. That combination prevents the common failure where a package installs successfully but exposes no working command because the binary path is wrong or the entry file cannot be executed.