Ship Your CLI Tool Everywhere: npm, Homebrew, Docker, and GitHub Releases
You published your Node.js CLI on npm. That covers JavaScript developers. But what about the Python developer who needs your tool in CI? The DevOps engineer who uses Homebrew? The team that runs everything in Docker?
Distributing a CLI tool through multiple channels is easier than you think. This article shows how to package one Node.js CLI for four distribution methods — covering 95% of how developers install tools.
Channel 1: npm (You're Already Here)
npm install -g mytool
npm is the default for JavaScript tools. If your package.json has a bin field, you're done:
{
"name": "mytool",
"bin": { "mytool": "./bin/mytool.js" },
"files": ["bin", "lib"]
}
npx for Zero-Install Usage
Users can run your tool without installing:
npx mytool scan .
This downloads, runs, and cleans up. Perfect for one-off usage and CI pipelines.
Channel 2: GitHub Releases with Standalone Binaries
Not everyone has Node.js installed. Compile your CLI into a standalone binary using pkg or bun build:
Using bun build --compile
# Build standalone binaries
bun build ./bin/mytool.js --compile --target=bun-linux-x64 --outfile=mytool-linux-x64
bun build ./bin/mytool.js --compile --target=bun-darwin-arm64 --outfile=mytool-darwin-arm64
bun build ./bin/mytool.js --compile --target=bun-windows-x64 --outfile=mytool-windows-x64.exe
Automate with GitHub Actions
# .github/workflows/release.yml
name: Release
on:
push:
tags: ['v*']
jobs:
build:
strategy:
matrix:
include:
- os: ubuntu-latest
target: bun-linux-x64
artifact: mytool-linux-x64
- os: macos-latest
target: bun-darwin-arm64
artifact: mytool-darwin-arm64
- os: windows-latest
target: bun-windows-x64
artifact: mytool-windows-x64.exe
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install
- run: bun build ./bin/mytool.js --compile --target=${{ matrix.target }} --outfile=${{ matrix.artifact }}
- uses: softprops/action-gh-release@v2
with:
files: ${{ matrix.artifact }}
Now users can download binaries directly:
# Linux/macOS
curl -L https://github.com/you/mytool/releases/latest/download/mytool-linux-x64 -o mytool
chmod +x mytool
./mytool scan .
Channel 3: Homebrew
macOS developers expect Homebrew. Create a formula:
# Formula/mytool.rb
class Mytool < Formula
desc "Your tool description"
homepage "https://github.com/you/mytool"
url "https://github.com/you/mytool/releases/download/v1.0.0/mytool-darwin-arm64"
sha256 "abc123..." # shasum -a 256 mytool-darwin-arm64
license "MIT"
def install
bin.install "mytool-darwin-arm64" => "mytool"
end
test do
assert_match "mytool", shell_output("#{bin}/mytool --version")
end
end
Self-Hosted Tap
Create a repo homebrew-tap with your formulas:
# Users install with:
brew tap you/tap
brew install mytool
Auto-Update Formula on Release
# In your release workflow, add:
- name: Update Homebrew formula
env:
TAP_TOKEN: ${{ secrets.TAP_TOKEN }}
run: |
SHA=$(shasum -a 256 mytool-darwin-arm64 | cut -d' ' -f1)
VERSION=${GITHUB_REF#refs/tags/v}
git clone https://x-access-token:${TAP_TOKEN}@github.com/you/homebrew-tap.git
cd homebrew-tap
sed -i '' "s|url \".*\"|url \"https://github.com/you/mytool/releases/download/v${VERSION}/mytool-darwin-arm64\"|" Formula/mytool.rb
sed -i '' "s|sha256 \".*\"|sha256 \"${SHA}\"|" Formula/mytool.rb
git commit -am "Update mytool to ${VERSION}"
git push
Channel 4: Docker
For CI pipelines and teams that containerize everything:
# Dockerfile
FROM node:20-alpine
RUN npm install -g mytool
ENTRYPOINT ["mytool"]
Or with a standalone binary for a tiny image:
FROM debian:bookworm-slim
COPY mytool-linux-x64 /usr/local/bin/mytool
RUN chmod +x /usr/local/bin/mytool
ENTRYPOINT ["mytool"]
# Build and push
docker build -t you/mytool .
docker push you/mytool
# Users run with:
docker run --rm -v $(pwd):/app you/mytool scan /app
GitHub Container Registry
- name: Push to GHCR
run: |
echo ${{ secrets.GITHUB_TOKEN }} | docker login ghcr.io -u ${{ github.actor }} --password-stdin
docker build -t ghcr.io/${{ github.repository }}:latest .
docker push ghcr.io/${{ github.repository }}:latest
The Release Script
Tie everything together:
{
"scripts": {
"release": "npm version $1 && git push --follow-tags",
"build:binaries": "bun build ./bin/mytool.js --compile --target=bun-linux-x64 --outfile=dist/mytool-linux-x64 && bun build ./bin/mytool.js --compile --target=bun-darwin-arm64 --outfile=dist/mytool-darwin-arm64",
"build:docker": "docker build -t mytool ."
}
}
The workflow:
-
npm version patch— bumps version, creates git tag -
git push --follow-tags— pushes code and tag - GitHub Actions triggers on tag → builds binaries → creates release → updates Homebrew → pushes Docker image
- npm publishes via
prepublishOnlyscript
One git push, four distribution channels updated automatically.
Which Channels Do You Need?
| Channel | Best For | Setup Effort |
|---|---|---|
| npm | JS developers, quick prototyping | Already done |
| npx | One-off usage, CI without install | Free with npm |
| GitHub Releases | Non-JS developers, air-gapped environments | Medium |
| Homebrew | macOS developers | Low (with tap) |
| Docker | CI pipelines, containerized teams | Low |
Start with npm. Add GitHub Releases when non-JS users ask for your tool. Add Homebrew when macOS users complain. Add Docker when DevOps teams need it in pipelines.
Conclusion
Distribution is a multiplier. The same tool, available through four channels, reaches four different audiences. The initial setup takes an afternoon. After that, every release automatically updates every channel with a single git push.
Wilson Xu distributes 11+ CLI tools across npm and other channels. Find them at npm and follow at dev.to/chengyixu.
Top comments (0)