Distributing Platform-Specific Binaries with npm

Shipping a CLI sounds simple: publish a package, expose a bin, and let users run a command.

In practice, it gets tricky fast, especially when the CLI itself is a native binary and you want it to work reliably across platforms, package managers, and execution modes.

This post explains a recent change to the MagicBell CLI, why installs were sometimes unreliable, and what we changed to make npm i -g magicbell-cli and npx magicbell-cli behave consistently.

The original approach

The MagicBell CLI is implemented as a Go binary and distributed via npm. The standard approach for this setup is to download the platform-specific binary during the package’s postinstall step.

That works well in simple setups. However, MagicBell’s CLI lives inside a monorepo. Running yarn install at the repository root installs all packages, including the CLI, even during local development.

At that point, the CLI version being installed often does not exist yet as a published binary. Downloading it during postinstall would fail.

To avoid this, we disabled postinstall during development and re-enabled it only during publishing. This kept local installs safe and ensured published packages included the correct install script.

Where it broke down

Users reported that installing the CLI globally did not always work:

npm i -g magicbell-cli

In some cases, the binary was never downloaded. Installing from a local tarball, however, worked as expected.

The root cause was subtle. While the published tarball contained a postinstall script, the npm registry metadata did not always reflect that. npm relies on registry metadata to decide whether install scripts should run, and in those cases it skipped the install step entirely.

The result was an inconsistent experience depending on how the package was installed.

The new approach

To make installation reliable everywhere, we removed install-time binary downloads entirely.

Instead, the npm package now ships a small Node.js wrapper as its bin. When the CLI is executed, the wrapper:

  • checks whether the correct platform binary is already available locally
  • downloads it if necessary
  • executes it while preserving stdin, stdout, exit codes, and signals

This means the binary is fetched only when it is actually needed.

If you are curious where things went wrong before, you can observe the difference yourself. npm exposes two sources of truth: registry metadata and the published tarball.

Registry metadata (what npm uses to decide whether lifecycle scripts should run):

npm view magicbell-cli@1.2.0 scripts --json
{
  "_postinstall": "node src/install.js install"
}

And the tarball contents (what actually gets installed):

curl -sL "$(npm view magicbell-cli@1.2.0 dist.tarball)" \
  | tar -xzOf - package/package.json | jq '.scripts'
{
  "postinstall": "node src/install.js install"
}

The mismatch between these two is what caused installs to behave differently depending on how the package was consumed.

What this improves

With this change:

  • npm i -g magicbell-cli works consistently
  • npx magicbell-cli works the same way
  • there are no lifecycle script edge cases
  • local development installs are safe

From the user’s perspective, nothing changes. Commands like:

cat data.json | xargs -0 magicbell broadcast create --data

continue to work exactly as before.

Conclusion

By moving binary downloads from install time to run time, we eliminated an entire class of installation issues. The MagicBell CLI now behaves consistently across npm, npx, Yarn, and pnpm, on Windows, macOS, and Linux, without relying on fragile lifecycle hooks.
If you ever run into issues installing the MagicBell CLI, this change should make those problems a thing of the past.

Ready to try it?

You can install the MagicBell CLI globally via npm:

npm i -g magicbell-cli

Or run it directly without installing:

npx magicbell-cli --help

Both approaches now behave the same way. The binary is fetched on first run and reused on subsequent runs.

The platform-specific binaries are downloaded from our public GitHub releases:
https://github.com/magicbell/homebrew-tap/releases