Ship a Bun-compiled CLI on npm like esbuild
How spatz puts a compiled Bun binary on npm: a small Node launcher plus one package per platform, so each user downloads only one binary.
You built a command line tool with Bun and compiled it into a single executable. That file contains your code and the Bun runtime, so users don't need Bun. Now you want people to install it with npm install -g. But the Linux file won't run on a Mac, and the Mac file won't run on Linux. Which file goes into the npm package?
This is how I solved it for spatz (opens in a new tab), a CLI that recommends a model for coding agents.
One binary per platform
A compiled Bun executable is built for one operating system and one CPU type. The spatz release builds five: Linux on x64 and arm64, macOS on arm64 and x64, and an experimental Windows x64 build.
These files are big. The Linux x64 package of spatz 0.1.5 is a 61 MB download and 154 MB after unpacking. The executable alone is 79 MB, and a database library next to it is another 67 MB. Putting all five into one package would make every user download hundreds of megabytes they can't use.
The pattern: a launcher and platform packages
esbuild has the same problem, and its npm package shows the answer. Run npm view esbuild optionalDependencies and you get a list of packages like @esbuild/linux-x64, one per platform. Claude Code's npm package does the same.
spatz copies that pattern with six packages:
@spatz/cliis the package users install. It holds no binary, only a small Node.js script.@spatz/cli-linux-x64and four siblings each hold one binary and the files it needs.
The main package lists the five platform packages as optional dependencies. An optional dependency is one that npm may skip: if it can't be installed, the install still succeeds. Each platform package says which systems it is for:
{
"name": "@spatz/cli-linux-x64",
"version": "0.1.5",
"os": ["linux"],
"cpu": ["x64"],
"libc": ["glibc"]
}
os and cpu name the operating system and processor. libc names the C library the binary was linked against (glibc on most Linux systems). npm compares these fields with your machine and skips every platform package that doesn't match. So a user on a Linux x64 machine gets the main package and exactly one binary.
Think of a mail-order shoe. The order form lists every size, but the warehouse sends only the pair that fits you. The order form is the main package, the sizes are the platform packages, and your foot is your machine. Unlike a shoe shop, you never choose the size. npm reads it from your system.
The launcher
The main package's bin entry points to a Node.js script. npm puts that script on your PATH as spatz. When you run spatz, the script does four things:
- It builds a key like
linux-x64from Node's platform and CPU type and looks up the matching package name. - It asks Node where that package is installed.
- It starts the binary from that folder with the same arguments, and shows its output directly.
- It exits with the binary's exit code. If a signal stopped the binary, it passes that signal on.
The core is a few lines:
const pkg = packages[`${process.platform}-${process.arch}`];
const dir = dirname(require.resolve(`${pkg}/package.json`));
const child = spawnSync(join(dir, "spatz"), process.argv.slice(2), {
stdio: "inherit",
});
process.exit(child.status ?? 1);
require.resolve finds a package the way require would, but returns its path instead of loading it. stdio: "inherit" lets the binary write to your terminal as if you had started it yourself.
The launcher needs Node.js 18 or newer. It doesn't need Bun, because the binary carries its own runtime.
What users have to know
Keep optional dependencies enabled. If the platform package is missing, the launcher can't do anything useful. It prints a message that names the missing package and asks you to reinstall with optional dependencies enabled. I saw that message after deleting the platform package from a test install:
spatz: missing @spatz/cli-linux-x64. Reinstall @spatz/cli with optional dependencies enabled (omit --no-optional / --omit=optional). The experimental Windows package may be unavailable for this release.
glibc only. The Linux packages declare glibc. Alpine Linux uses a different C library called musl, so spatz doesn't support Alpine. Claude Code publishes separate musl packages for that case. spatz doesn't.
Versions match exactly. The main package lists each platform package with an exact version, not a range. So @spatz/cli 0.1.5 always starts binary 0.1.5.
Packing details that matter
The executable bit. On Linux and macOS, a file runs only if its executable permission is set. The build script sets it on the binary right before it writes the package. A test checks the bit in the finished package folder. In my test install, the binary arrived executable and the other files didn't.
Files that must stay together. The spatz binary loads a database library at runtime, and those files have to sit in the same folder. Each platform package keeps them side by side. The build stops with an error if one of them is missing.
Experimental platforms. If the Windows build is missing from a release, the build skips its package with a warning and leaves it out of the optional dependencies. Every other platform is required.
Publish order
The platform packages go to npm first. The main package goes last.
The reason is the exact versions. The main package points to platform packages of the same version. If it appeared on npm first, an install in that moment would not find the platform package. Because that dependency is optional, npm would finish without it, and the user would only see the launcher's error message.
Publishing can also fail halfway. The workflow checks which versions are already on npm and skips them, so a second run continues where the first one stopped.