Beyond npm publish: 7 Ways to Distribute Your CLI Tool
You built a CLI tool. You ran npm publish. You told people to npm install -g your-tool. And then the support requests started rolling in: "Can I use this without Node?", "Is there a Homebrew formula?", "I just want a binary I can drop in my PATH."
After publishing and maintaining over 30 CLI tools — from websnap-reader (a headless screenshot utility) to ghbounty (a GitHub bounty scanner) to devpitch (an article pitch generator) — I learned that npm publish is just the starting line. The real challenge is getting your tool into users' hands in a way that matches how they actually work.
This guide covers seven distribution methods for Node.js CLI tools, when each one makes sense, and how to set them up. By the end, you'll have a distribution strategy that meets users where they are, not where you wish they were.
1. npm: The Foundation (But Get the Details Right)
Let's start with what you probably already know, but refine it. There are decisions in your package.json that dramatically affect discoverability and adoption.
Scoped vs. Unscoped Packages
An unscoped package like ghbounty lives in the global namespace. Anyone can npm install -g ghbounty. A scoped package like @chengyixu/ghbounty is namespaced under your account.
Use unscoped when you want maximum discoverability and the name is available. Unscoped packages appear in generic npm searches more readily, and the install command is shorter — which matters more than you think for adoption.
Use scoped when the unscoped name is taken, when you're publishing under an organization, or when you want to group related tools. Scoped packages are private by default on npm, so you need to explicitly set access:
{
"name": "@yourorg/tool-name",
"publishConfig": {
"access": "public"
}
}
The bin Field Matters
Your package.json bin field is the difference between a library and a CLI tool:
{
"bin": {
"mytool": "./bin/cli.js"
}
}
Make sure the entry file has a shebang (#!/usr/bin/env node) and is executable (chmod +x bin/cli.js). I've seen tools fail on Linux because the author developed on Windows and forgot the executable bit.
Access Levels and 2FA
npm supports granular access controls. For public CLI tools, enable --provenance during publish to sign your package with your CI's identity:
npm publish --provenance --access public
This gives users a cryptographic chain of trust from your GitHub repo to the published package. Enable 2FA on your npm account — it's mandatory for high-traffic packages and should be standard practice for everything else.
2. npx: Zero-Install Execution
The most underrated distribution channel is no distribution at all. npx lets users run your tool without installing it:
npx websnap-reader https://example.com
For this to work well, your tool needs to:
Start fast. npx downloads the package on every invocation (unless cached). If your tool has 200MB of dependencies, this is painful. Keep your dependency tree lean.
Work with arguments. npx passes everything after the package name to your binary. Test this — some tools break when invoked through npx because they make assumptions about
process.argv.Handle the "Ok to proceed?" prompt. When npx downloads a package for the first time, it asks the user for confirmation. You can't control this, but you can make sure your tool name is clear enough that users know what they're approving.
A pattern I use for all my tools: design for npx first, then optimize for global install. If it works well through npx, it'll work well everywhere.
{
"name": "readme-score",
"version": "1.0.0",
"bin": {
"readme-score": "./bin/cli.js"
},
"engines": {
"node": ">=18"
}
}
The engines field prevents confusing errors when someone with Node 14 tries npx readme-score.
3. Homebrew: First-Class macOS Distribution
A significant portion of developer tools end up on macOS, and macOS developers expect Homebrew. Creating a Homebrew tap is easier than most people think.
Creating a Tap
A "tap" is just a GitHub repository with a specific naming convention:
# Create the repo
gh repo create homebrew-tools --public
# Clone it
git clone https://github.com/yourusername/homebrew-tools
Inside the repo, create a formula in the Formula/ directory:
# Formula/mytool.rb
class Mytool < Formula
desc "A brief description of your tool"
homepage "https://github.com/yourusername/mytool"
url "https://github.com/yourusername/mytool/releases/download/v1.0.0/mytool-darwin-x64.tar.gz"
sha256 "abc123..."
license "MIT"
def install
bin.install "mytool"
end
test do
system "#{bin}/mytool", "--version"
end
end
Now users can install with:
brew tap yourusername/tools
brew install mytool
The Node.js Dependency Problem
If your tool requires Node.js, you have two options in your formula. You can declare a dependency on Node:
depends_on "node"
def install
system "npm", "install", *std_npm_args
bin.install_symlink Dir["#{libexec}/bin/*"]
end
Or — and this is what I recommend — compile your tool to a standalone binary first (see section 6) and distribute that. Users shouldn't need to care about your runtime.
4. Docker: Containerized CLI Tools
Docker distribution shines when your tool has complex system dependencies, when you want reproducible environments, or when your users are already in a Docker-heavy workflow.
Writing a Good CLI Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY . .
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app .
ENTRYPOINT ["node", "bin/cli.js"]
The multi-stage build keeps the image small. Alpine-based images typically land under 100MB for a Node.js CLI tool.
Making It Feel Native
The key to Docker-distributed CLI tools is making them feel like local commands. Provide a shell alias:
alias mytool='docker run --rm -v "$(pwd):/work" -w /work yourusername/mytool'
Now mytool --help works exactly like a native binary. The -v flag mounts the current directory so the tool can read and write local files.
When Docker Makes Sense
Docker adds overhead — both in image size and startup time. A 50MB Alpine image still takes 1-2 seconds to start a container. For tools that run frequently (formatters, linters), this friction compounds. For tools that run occasionally and need specific environments (deployment tools, infrastructure scanners), Docker is perfect.
I containerized compose-viz (a Docker Compose visualization tool) because it already assumes a Docker-centric workflow. I did not containerize websnap-reader because the Docker startup cost defeated the purpose of a quick screenshot utility.
5. GitHub Releases: Binary Distribution
GitHub Releases let you attach compiled binaries to tagged versions. Combined with a tool like pkg or Bun's compiler, you can produce platform-specific binaries that need zero runtime dependencies.
The Release Workflow
# .github/workflows/release.yml
name: Release
on:
push:
tags: ['v*']
jobs:
build:
strategy:
matrix:
include:
- os: ubuntu-latest
target: linux-x64
- os: macos-latest
target: darwin-x64
- os: macos-latest
target: darwin-arm64
- os: windows-latest
target: win-x64
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npx pkg . --target node20-${{ matrix.target }} --output dist/mytool-${{ matrix.target }}
- uses: softprops/action-gh-release@v2
with:
files: dist/mytool-*
Tag a release, and GitHub Actions builds binaries for Linux, macOS (Intel + ARM), and Windows, then attaches them to the release.
Installation Script
Provide a one-liner install script that detects the platform and downloads the right binary:
curl -fsSL https://raw.githubusercontent.com/yourusername/mytool/main/install.sh | sh
Here's a robust install script template:
#!/bin/sh
set -e
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
ARCH=$(uname -m)
case "$ARCH" in
x86_64) ARCH="x64" ;;
aarch64|arm64) ARCH="arm64" ;;
*) echo "Unsupported architecture: $ARCH" && exit 1 ;;
esac
VERSION=$(curl -s https://api.github.com/repos/yourusername/mytool/releases/latest | grep tag_name | cut -d '"' -f 4)
URL="https://github.com/yourusername/mytool/releases/download/${VERSION}/mytool-${OS}-${ARCH}.tar.gz"
echo "Installing mytool ${VERSION} for ${OS}-${ARCH}..."
curl -fsSL "$URL" | tar xz -C /usr/local/bin
chmod +x /usr/local/bin/mytool
echo "mytool installed successfully. Run 'mytool --help' to get started."
This approach has a real security tradeoff: piping curl to shell means users run code they haven't inspected. Mitigate this by hosting the install script in your repository (so it's auditable), signing your releases, and providing SHA256 checksums. Many users prefer downloading the binary manually and verifying the checksum themselves — make sure that workflow is documented too.
Versioning and Update Notifications
One advantage of GitHub Releases over npm is that you can push update notifications into the binary itself. Add a version check that runs periodically:
import { execSync } from 'child_process';
async function checkForUpdate(currentVersion) {
try {
const res = await fetch('https://api.github.com/repos/yourusername/mytool/releases/latest');
const data = await res.json();
const latest = data.tag_name.replace('v', '');
if (latest !== currentVersion) {
console.log(`\nUpdate available: ${currentVersion} → ${latest}`);
console.log('Run: curl -fsSL https://yourusername.github.io/mytool/install.sh | sh\n');
}
} catch {} // Fail silently — never block the user
}
This is something npm gives you for free with npm outdated -g, but when you're distributing binaries, you need to build it yourself.
6. Standalone Executables: No Runtime Required
This is where things get interesting. You can compile your Node.js CLI tool into a single executable that needs no runtime at all.
pkg (by Vercel)
pkg bundles your Node.js app into an executable with a stripped-down Node runtime baked in:
npx pkg bin/cli.js --targets node20-linux-x64,node20-macos-x64,node20-win-x64
The output is three binaries, each around 40-60MB, that run without Node.js installed. The tradeoff is size — you're bundling an entire (stripped) Node runtime.
Bun Compile
Bun's bun build --compile produces significantly smaller binaries and supports cross-compilation:
bun build bin/cli.js --compile --outfile mytool
Bun-compiled binaries are typically 10-30MB and start faster than pkg-compiled ones. The caveat: your code needs to be Bun-compatible, which means no Node-specific APIs that Bun hasn't implemented yet.
nexe
nexe is similar to pkg but gives you more control over the Node.js version bundled:
npx nexe bin/cli.js -o mytool --target linux-x64-20.0.0
When to Use Standalone Binaries
Standalone binaries are ideal when:
- Your users may not have Node.js installed (DevOps teams, designers, non-JS developers)
- You want a single-file distribution with no install step
- You're selling the tool (see section 7) and want a polished delivery experience
- You're distributing through Homebrew and don't want to force a Node dependency
Choosing Between pkg, Bun, and nexe
The decision comes down to your priorities:
- pkg has the widest adoption and best cross-compilation support. It handles native modules reasonably well and produces reliable binaries. The downside is that Vercel's maintenance has been inconsistent — check the issue tracker before committing.
- Bun compile produces the smallest binaries and starts fastest. If your tool is pure JavaScript/TypeScript with no Node-specific native addons, this is the best option in 2026. The caveat is Bun compatibility — test thoroughly.
- nexe gives you the most control over which Node.js version is bundled. Useful if you need a specific Node version for compatibility reasons.
For most tools, I'd recommend trying Bun compile first, then falling back to pkg if you hit compatibility issues. nexe is best for edge cases where you need precise Node version control.
The downside of all three is binary size and the compilation step in your CI pipeline. For small utilities, the 40-60MB binary size (or 10-30MB with Bun) feels disproportionate. For complex tools, it's a non-issue.
7. Selling CLI Tools: Gumroad and Lemon Squeezy
Here's the part most developers skip: you can sell CLI tools. Not everything has to be free and open source.
What Sells
From my experience listing tools on Gumroad, CLI tools that sell well share these traits:
- They save measurable time. "This tool saves you 2 hours per week on X" converts better than "a better way to do X."
-
They solve a problem for a specific audience.
pricemon(a price monitoring CLI) targets e-commerce operators, not general developers. - They come with documentation and support. A paid tool with a one-page README feels like a scam. Write proper docs.
Distribution Mechanics
On Gumroad or Lemon Squeezy, you upload a ZIP containing:
- Pre-compiled binaries for each platform
- A README with installation instructions
- A license key validator (optional but adds perceived value)
// Simple license check
const LICENSE_API = 'https://api.gumroad.com/v2/licenses/verify';
async function validateLicense(key) {
const res = await fetch(LICENSE_API, {
method: 'POST',
body: new URLSearchParams({
product_id: 'YOUR_PRODUCT_ID',
license_key: key
})
});
const data = await res.json();
return data.success;
}
Pricing
Price based on value, not effort. I've seen CLI tools sell at:
- $5-15 for simple utilities (one-off purchase)
- $20-50 for specialized professional tools
- $10-30/month for tools with ongoing API costs or data feeds
The "pay what you want" model on Gumroad works surprisingly well for developer tools. Set a minimum of $5 and a suggested price of $15.
8. GitHub Actions: Auto-Publish on Git Tag
Automate everything. When you push a git tag, your CI should publish to every channel simultaneously.
name: Publish
on:
push:
tags: ['v*']
jobs:
npm:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
registry-url: 'https://registry.npmjs.org'
- run: npm ci
- run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_TOKEN }}
- uses: docker/build-push-action@v5
with:
push: true
tags: yourusername/mytool:latest,yourusername/mytool:${{ github.ref_name }}
homebrew:
needs: [binaries]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
repository: yourusername/homebrew-tools
token: ${{ secrets.GH_PAT }}
- name: Update formula
run: |
VERSION=${GITHUB_REF_NAME#v}
sed -i "s|url \".*\"|url \"https://github.com/yourusername/mytool/releases/download/${GITHUB_REF_NAME}/mytool-darwin-x64.tar.gz\"|" Formula/mytool.rb
# Update SHA256
SHA=$(curl -sL "https://github.com/yourusername/mytool/releases/download/${GITHUB_REF_NAME}/mytool-darwin-x64.tar.gz" | sha256sum | cut -d' ' -f1)
sed -i "s|sha256 \".*\"|sha256 \"${SHA}\"|" Formula/mytool.rb
git commit -am "Update mytool to ${VERSION}"
git push
One git tag v1.2.3 && git push --tags and your tool is published to npm, Docker Hub, and Homebrew simultaneously.
9. Platform-Specific Package Managers
Chocolatey (Windows)
Windows developers use Chocolatey. Creating a Chocolatey package requires a .nuspec file and a chocolateyinstall.ps1 script:
<!-- mytool.nuspec -->
<?xml version="1.0"?>
<package>
<metadata>
<id>mytool</id>
<version>1.0.0</version>
<title>MyTool</title>
<authors>Your Name</authors>
<description>A brief description</description>
<tags>cli developer-tools</tags>
</metadata>
</package>
# tools/chocolateyinstall.ps1
$url = 'https://github.com/yourusername/mytool/releases/download/v1.0.0/mytool-win-x64.exe'
Install-ChocolateyPackage 'mytool' 'exe' '/S' $url
Then submit with choco push. The Chocolatey moderation queue takes 1-7 days for new packages.
Snap (Linux)
For Linux distribution, Snap packages provide auto-updates and sandboxing:
# snap/snapcraft.yaml
name: mytool
version: '1.0.0'
summary: A brief description
description: |
A longer description of your tool.
base: core22
confinement: strict
apps:
mytool:
command: bin/mytool
plugs: [network, home]
parts:
mytool:
plugin: dump
source: https://github.com/yourusername/mytool/releases/download/v1.0.0/mytool-linux-x64.tar.gz
Publish with snapcraft upload --release=stable mytool_1.0.0_amd64.snap.
Comparison: When to Use What
| Method | Best For | Setup Effort | User Friction | Reach |
|---|---|---|---|---|
| npm | JS developers | Low | Low (if Node installed) | High |
| npx | One-off usage, demos | None | Very low | High |
| Homebrew | macOS power users | Medium | Low | Medium |
| Docker | Complex dependencies | Medium | Medium | Medium |
| GitHub Releases | Non-JS users | Medium | Medium | High |
| Standalone binary | Maximum portability | High | Very low | High |
| Gumroad/Lemon Squeezy | Monetization | Low | Medium | Low |
| Chocolatey | Windows users | Medium | Low | Medium |
| Snap | Linux users | Medium | Low | Medium |
| GitHub Actions | Automation | High (initial) | N/A | N/A |
Lessons From 30+ Tools
After distributing over 30 CLI tools across these channels, here's what I've learned:
npm + npx covers 80% of your audience. If your users are JavaScript developers, don't overcomplicate things. Nail your
package.json, write a clear README, and ship.Standalone binaries unlock new audiences. When I compiled
websnap-readerto a standalone binary, downloads tripled — because DevOps engineers and designers could use it without installing Node.Automate from day one. Setting up the GitHub Actions workflow on your first release saves hundreds of hours over the lifetime of a tool. Don't "do it later."
Docker is overused for CLI tools. Unless your tool genuinely needs containerization (system dependencies, reproducible environments), Docker adds friction without enough benefit. Most CLI tools are better served by standalone binaries.
Selling works, but marketing is the bottleneck. Listing a tool on Gumroad takes 10 minutes. Getting people to find and buy it takes months of consistent visibility — writing about it, showing it in talks, posting in communities.
Test every distribution channel in CI. I've shipped broken Homebrew formulas because I updated the binary URL but not the SHA256 hash. Automate the verification.
Meet users where they are. If you're building for data engineers, they expect
pip install. For DevOps, they expect Docker or a curl-pipe-sh installer. For frontend developers, npm is fine. Distribution is a product decision, not just a technical one.
A Practical Distribution Ladder
If you're overwhelmed by options, here's the order I recommend for expanding distribution:
-
Week 1: Publish to npm with a solid
package.json. Ensure it works with npx. This is your minimum viable distribution. - Week 2: Set up GitHub Actions to auto-publish on git tag. This pays for itself immediately.
- Month 1: Add GitHub Releases with compiled binaries for at least Linux x64 and macOS ARM64. Write a one-liner install script.
- Month 2: Create a Homebrew tap if your analytics show macOS users. Add Chocolatey if Windows users are asking for it.
- When it makes sense: Docker (only if users need it), Gumroad (only if the tool has clear commercial value), Snap (only if you're targeting enterprise Linux).
The key insight is that each distribution channel has a cost — not just setup cost, but ongoing maintenance cost. Every release now has to be verified across every channel. Start small and expand based on actual user demand, not hypothetical reach.
Wrapping Up
npm publish is where distribution starts, not where it ends. The distribution channels you choose should match your users' workflows, not your development stack.
Start with npm and npx. Add GitHub Releases with compiled binaries when you want broader reach. Set up Homebrew if your users skew macOS. Consider Docker only if your tool has genuine environment dependencies. And if you've built something genuinely valuable — put a price on it.
The best CLI tool in the world is useless if people can't install it. Make installation the easiest part of the experience.
Top comments (0)