DEV Community

Wilson Xu
Wilson Xu

Posted on

Beyond npm publish: 7 Ways to Distribute Your CLI Tool

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

The bin Field Matters

Your package.json bin field is the difference between a library and a CLI tool:

{
  "bin": {
    "mytool": "./bin/cli.js"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

For this to work well, your tool needs to:

  1. 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.

  2. 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.

  3. 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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Now users can install with:

brew tap yourusername/tools
brew install mytool
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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-*
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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."
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. They save measurable time. "This tool saves you 2 hours per week on X" converts better than "a better way to do X."
  2. They solve a problem for a specific audience. pricemon (a price monitoring CLI) targets e-commerce operators, not general developers.
  3. 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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode
# tools/chocolateyinstall.ps1
$url = 'https://github.com/yourusername/mytool/releases/download/v1.0.0/mytool-win-x64.exe'
Install-ChocolateyPackage 'mytool' 'exe' '/S' $url
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. 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.

  2. Standalone binaries unlock new audiences. When I compiled websnap-reader to a standalone binary, downloads tripled — because DevOps engineers and designers could use it without installing Node.

  3. 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."

  4. 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.

  5. 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.

  6. 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.

  7. 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:

  1. Week 1: Publish to npm with a solid package.json. Ensure it works with npx. This is your minimum viable distribution.
  2. Week 2: Set up GitHub Actions to auto-publish on git tag. This pays for itself immediately.
  3. Month 1: Add GitHub Releases with compiled binaries for at least Linux x64 and macOS ARM64. Write a one-liner install script.
  4. Month 2: Create a Homebrew tap if your analytics show macOS users. Add Chocolatey if Windows users are asking for it.
  5. 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)