Distributing CLIs: npm, Homebrew, and Binaries
A practical guide to packaging and distributing Node.js CLI tools through npm, Homebrew taps, standalone binaries, and platform installers.
Distributing CLIs: npm, Homebrew, and Binaries
Building a CLI tool is half the battle. The other half is getting it onto developer machines without friction. Every extra installation step you add loses users. The best CLI tools meet developers where they are โ npm for the Node.js crowd, Homebrew for macOS users, standalone binaries for everyone else.
I have distributed CLI tools through all these channels. Each has tradeoffs in reach, update friction, and build complexity. This guide covers the practical setup for each one, from the simplest npm publish to self-contained binaries.
Prerequisites
- Node.js installed (v14+)
- npm account (for npm publishing)
- macOS with Homebrew (for Homebrew tap testing)
- GitHub account (for releases and taps)
- Basic understanding of package.json
Distributing via npm
npm is the simplest distribution channel for Node.js CLIs. Users install globally with npm install -g yourpackage and the command is immediately available.
Package.json Setup
The bin field in package.json maps command names to entry point files:
{
"name": "deploytool",
"version": "1.2.0",
"description": "Deploy applications to cloud providers",
"bin": {
"deploytool": "./bin/deploytool.js",
"dt": "./bin/deploytool.js"
},
"files": [
"bin/",
"src/",
"README.md",
"LICENSE"
],
"engines": {
"node": ">=14.0.0"
},
"keywords": ["cli", "deploy", "devops"],
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/yourorg/deploytool"
}
}
The entry point must have a shebang line:
#!/usr/bin/env node
var path = require("path");
var pkg = require(path.join(__dirname, "..", "package.json"));
// Version flag
if (process.argv.indexOf("--version") !== -1 || process.argv.indexOf("-V") !== -1) {
console.log(pkg.version);
process.exit(0);
}
// Load and run the CLI
require(path.join(__dirname, "..", "src", "cli.js"));
The files Field
The files field controls what gets published. Without it, npm publishes everything not in .npmignore. Being explicit is better:
{
"files": [
"bin/",
"src/",
"README.md",
"LICENSE"
]
}
Before publishing, always check what will be included:
npm pack --dry-run
Output:
npm notice ๐ฆ [email protected]
npm notice === Tarball Contents ===
npm notice 1.2kB bin/deploytool.js
npm notice 15.4kB src/cli.js
npm notice 8.2kB src/commands/deploy.js
npm notice 4.1kB src/commands/init.js
npm notice 3.8kB src/utils/config.js
npm notice 2.1kB README.md
npm notice 1.1kB LICENSE
npm notice 0.8kB package.json
npm notice === Tarball Details ===
npm notice name: deploytool
npm notice version: 1.2.0
npm notice package size: 12.3 kB
npm notice unpacked size: 36.7 kB
npm notice total files: 8
Publishing to npm
# Login (one-time)
npm login
# Publish
npm publish
# Publish with a tag (for pre-releases)
npm publish --tag beta
# Publish with public access (for scoped packages)
npm publish --access public
npx Support
Users can run your CLI without installing it using npx:
npx deploytool init
For this to work well, keep your package small and avoid heavy dependencies. npx downloads the entire package each time.
For create-* tools, use the convention:
{
"name": "create-myapp",
"bin": {
"create-myapp": "./bin/create-myapp.js"
}
}
Then users can run:
npm init myapp
# equivalent to: npx create-myapp
Scoped Packages
For organizations, use scoped packages:
{
"name": "@yourorg/deploytool",
"bin": {
"deploytool": "./bin/deploytool.js"
}
}
npm install -g @yourorg/deploytool
Pre and Post Install Scripts
Run setup after installation:
{
"scripts": {
"postinstall": "node scripts/postinstall.js"
}
}
// scripts/postinstall.js
var fs = require("fs");
var path = require("path");
var os = require("os");
// Create config directory
var configDir = path.join(os.homedir(), ".deploytool");
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true });
console.log("Created config directory: " + configDir);
}
// Show post-install message
console.log("");
console.log(" \u001b[32mdeploytool\u001b[0m installed successfully!");
console.log("");
console.log(" Get started:");
console.log(" deploytool init");
console.log(" deploytool --help");
console.log("");
Keep postinstall scripts fast and non-blocking. Heavy operations frustrate users on every install.
Distributing via Homebrew
Homebrew is how macOS developers install tools. Creating a Homebrew tap lets users install with brew install yourorg/tap/deploytool.
Creating a Homebrew Tap Repository
A tap is a GitHub repository named homebrew-tap that contains Formula files:
homebrew-tap/
Formula/
deploytool.rb
README.md
Writing a Formula for a Node.js CLI
class Deploytool < Formula
desc "Deploy applications to cloud providers"
homepage "https://github.com/yourorg/deploytool"
url "https://registry.npmjs.org/deploytool/-/deploytool-1.2.0.tgz"
sha256 "abc123def456..." # SHA-256 of the tarball
license "MIT"
depends_on "node"
def install
system "npm", "install", *Language::Node.std_npm_install_args(libexec)
bin.install_symlink Dir["#{libexec}/bin/*"]
end
test do
assert_match "1.2.0", shell_output("#{bin}/deploytool --version")
end
end
Get the SHA-256 hash:
curl -sL https://registry.npmjs.org/deploytool/-/deploytool-1.2.0.tgz | shasum -a 256
Formula for Standalone Binaries
If you distribute pre-built binaries, the formula is simpler:
class Deploytool < Formula
desc "Deploy applications to cloud providers"
homepage "https://github.com/yourorg/deploytool"
version "1.2.0"
license "MIT"
if OS.mac?
if Hardware::CPU.arm?
url "https://github.com/yourorg/deploytool/releases/download/v1.2.0/deploytool-darwin-arm64.tar.gz"
sha256 "abc123..."
else
url "https://github.com/yourorg/deploytool/releases/download/v1.2.0/deploytool-darwin-x64.tar.gz"
sha256 "def456..."
end
elsif OS.linux?
url "https://github.com/yourorg/deploytool/releases/download/v1.2.0/deploytool-linux-x64.tar.gz"
sha256 "789abc..."
end
def install
bin.install "deploytool"
end
test do
assert_match "1.2.0", shell_output("#{bin}/deploytool --version")
end
end
Installing from Your Tap
# Add the tap (one-time)
brew tap yourorg/tap
# Install
brew install deploytool
# Or in one command
brew install yourorg/tap/deploytool
# Update
brew upgrade deploytool
Automating Formula Updates
Script the formula update when you publish a new version:
#!/usr/bin/env node
var fs = require("fs");
var path = require("path");
var childProcess = require("child_process");
var https = require("https");
var crypto = require("crypto");
var pkg = require("./package.json");
var version = pkg.version;
var tarballUrl = "https://registry.npmjs.org/" + pkg.name + "/-/" + pkg.name + "-" + version + ".tgz";
function getSha256(url) {
return new Promise(function(resolve, reject) {
https.get(url, function(response) {
var hash = crypto.createHash("sha256");
response.on("data", function(chunk) { hash.update(chunk); });
response.on("end", function() { resolve(hash.digest("hex")); });
}).on("error", reject);
});
}
getSha256(tarballUrl).then(function(sha) {
var formulaPath = path.join(__dirname, "..", "homebrew-tap", "Formula", pkg.name + ".rb");
var formula = fs.readFileSync(formulaPath, "utf8");
// Update version and SHA
formula = formula.replace(/url ".*"/, 'url "' + tarballUrl + '"');
formula = formula.replace(/sha256 ".*"/, 'sha256 "' + sha + '"');
fs.writeFileSync(formulaPath, formula);
console.log("Updated formula to v" + version + " (sha256: " + sha + ")");
});
Distributing as Standalone Binaries
Standalone binaries let users download and run your tool without Node.js installed. This is the widest-reach distribution method.
Using pkg to Create Binaries
The pkg tool bundles your Node.js app with a Node.js runtime into a single executable:
npm install -g pkg
Configure targets in package.json:
{
"name": "deploytool",
"bin": "bin/deploytool.js",
"pkg": {
"targets": [
"node18-linux-x64",
"node18-macos-x64",
"node18-macos-arm64",
"node18-win-x64"
],
"outputPath": "dist",
"assets": [
"src/**/*.json",
"templates/**/*"
]
}
}
Build:
pkg . --out-path dist
# Or target specific platforms
pkg . --targets node18-linux-x64 --output dist/deploytool-linux
Output:
dist/
deploytool-linux-x64 # 45 MB
deploytool-macos-x64 # 48 MB
deploytool-macos-arm64 # 44 MB
deploytool-win-x64.exe # 42 MB
Reducing Binary Size
Raw pkg binaries are large because they include the entire Node.js runtime. Here are techniques to reduce size:
// build.js - Build script with compression
var childProcess = require("child_process");
var fs = require("fs");
var path = require("path");
var zlib = require("zlib");
var targets = [
{ name: "linux-x64", pkg: "node18-linux-x64" },
{ name: "macos-x64", pkg: "node18-macos-x64" },
{ name: "macos-arm64", pkg: "node18-macos-arm64" },
{ name: "win-x64", pkg: "node18-win-x64" }
];
var distDir = path.join(__dirname, "dist");
if (!fs.existsSync(distDir)) fs.mkdirSync(distDir);
for (var i = 0; i < targets.length; i++) {
var target = targets[i];
var ext = target.name.indexOf("win") !== -1 ? ".exe" : "";
var outputName = "deploytool-" + target.name + ext;
var outputPath = path.join(distDir, outputName);
console.log("Building " + target.name + "...");
childProcess.execSync(
"pkg . --target " + target.pkg + " --output " + outputPath,
{ stdio: "inherit" }
);
// Get file size
var stats = fs.statSync(outputPath);
var sizeMB = (stats.size / (1024 * 1024)).toFixed(1);
console.log(" " + outputName + ": " + sizeMB + " MB");
// Create compressed archive
console.log(" Compressing...");
var archiveName = "deploytool-" + target.name + ".tar.gz";
childProcess.execSync(
"tar -czf " + path.join(distDir, archiveName) + " -C " + distDir + " " + outputName
);
var archiveStats = fs.statSync(path.join(distDir, archiveName));
var archiveSizeMB = (archiveStats.size / (1024 * 1024)).toFixed(1);
console.log(" " + archiveName + ": " + archiveSizeMB + " MB");
}
GitHub Releases with Binaries
Automate releases with a GitHub Actions workflow:
name: Release
on:
push:
tags:
- 'v*'
jobs:
build:
strategy:
matrix:
include:
- os: ubuntu-latest
target: node18-linux-x64
artifact: deploytool-linux-x64
- os: macos-latest
target: node18-macos-x64
artifact: deploytool-macos-x64
- os: macos-latest
target: node18-macos-arm64
artifact: deploytool-macos-arm64
- os: windows-latest
target: node18-win-x64
artifact: deploytool-win-x64.exe
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
- run: npm ci
- run: npx pkg . --target ${{ matrix.target }} --output ${{ matrix.artifact }}
- name: Compress (Unix)
if: runner.os != 'Windows'
run: tar -czf ${{ matrix.artifact }}.tar.gz ${{ matrix.artifact }}
- name: Compress (Windows)
if: runner.os == 'Windows'
run: Compress-Archive -Path ${{ matrix.artifact }} -DestinationPath ${{ matrix.artifact }}.zip
- uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}
path: ${{ matrix.artifact }}.*
release:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
- name: Create Release
uses: softprops/action-gh-release@v1
with:
files: |
**/*.tar.gz
**/*.zip
generate_release_notes: true
Install Script for Quick Setup
Provide a curl-pipe-bash installer for the binary distribution:
#!/bin/bash
# install.sh - Download and install deploytool
set -e
VERSION="${1:-latest}"
INSTALL_DIR="${INSTALL_DIR:-/usr/local/bin}"
# Detect OS and architecture
OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
ARCH="$(uname -m)"
case "$OS" in
linux*) PLATFORM="linux" ;;
darwin*) PLATFORM="macos" ;;
*) echo "Unsupported OS: $OS"; exit 1 ;;
esac
case "$ARCH" in
x86_64) ARCH="x64" ;;
arm64|aarch64) ARCH="arm64" ;;
*) echo "Unsupported architecture: $ARCH"; exit 1 ;;
esac
FILENAME="deploytool-${PLATFORM}-${ARCH}"
if [ "$VERSION" = "latest" ]; then
URL="https://github.com/yourorg/deploytool/releases/latest/download/${FILENAME}.tar.gz"
else
URL="https://github.com/yourorg/deploytool/releases/download/v${VERSION}/${FILENAME}.tar.gz"
fi
echo "Downloading deploytool for ${PLATFORM}/${ARCH}..."
TMPDIR=$(mktemp -d)
trap "rm -rf $TMPDIR" EXIT
curl -fsSL "$URL" -o "$TMPDIR/deploytool.tar.gz"
tar -xzf "$TMPDIR/deploytool.tar.gz" -C "$TMPDIR"
chmod +x "$TMPDIR/$FILENAME"
echo "Installing to ${INSTALL_DIR}..."
sudo mv "$TMPDIR/$FILENAME" "${INSTALL_DIR}/deploytool"
echo ""
echo "deploytool installed successfully!"
echo "Run 'deploytool --help' to get started"
Users install with:
curl -fsSL https://raw.githubusercontent.com/yourorg/deploytool/main/install.sh | bash
Version Checking and Auto-Update
Notify users when a new version is available:
var https = require("https");
var fs = require("fs");
var path = require("path");
var os = require("os");
var CACHE_FILE = path.join(os.homedir(), ".deploytool", "update-check.json");
var CHECK_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours
function checkForUpdate(currentVersion) {
// Skip in CI
if (process.env.CI || process.env.NO_UPDATE_CHECK) return;
// Check cache
try {
var cache = JSON.parse(fs.readFileSync(CACHE_FILE, "utf8"));
if (Date.now() - cache.timestamp < CHECK_INTERVAL) {
if (cache.latest && cache.latest !== currentVersion) {
showUpdateBanner(currentVersion, cache.latest);
}
return;
}
} catch (e) {
// No cache, proceed with check
}
// Async check (non-blocking)
var req = https.get(
"https://registry.npmjs.org/deploytool/latest",
{ timeout: 3000 },
function(res) {
var body = "";
res.on("data", function(chunk) { body += chunk; });
res.on("end", function() {
try {
var data = JSON.parse(body);
var latest = data.version;
// Cache the result
var cacheDir = path.dirname(CACHE_FILE);
if (!fs.existsSync(cacheDir)) {
fs.mkdirSync(cacheDir, { recursive: true });
}
fs.writeFileSync(CACHE_FILE, JSON.stringify({
latest: latest,
timestamp: Date.now()
}));
if (latest !== currentVersion) {
showUpdateBanner(currentVersion, latest);
}
} catch (e) {
// Silently fail
}
});
}
);
req.on("error", function() {}); // Silently fail
req.end();
}
function showUpdateBanner(current, latest) {
var lines = [
"",
"\u001b[33m โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ\u001b[0m",
"\u001b[33m โ\u001b[0m Update available: " + current + " โ \u001b[32m" + latest + "\u001b[0m" + padRight("", 40 - 24 - current.length - latest.length) + "\u001b[33mโ\u001b[0m",
"\u001b[33m โ\u001b[0m Run \u001b[36mnpm install -g deploytool\u001b[0m to update \u001b[33mโ\u001b[0m",
"\u001b[33m โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ\u001b[0m",
""
];
process.stderr.write(lines.join("\n") + "\n");
}
function padRight(str, len) {
while (str.length < len) str += " ";
return str;
}
Output when an update is available:
โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ
โ Update available: 1.2.0 โ 1.3.0 โ
โ Run npm install -g deploytool to update โ
โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ
Complete Working Example: Multi-Channel Release Script
This script handles publishing to npm, creating GitHub releases, and updating the Homebrew formula:
#!/usr/bin/env node
var childProcess = require("child_process");
var fs = require("fs");
var path = require("path");
var crypto = require("crypto");
var https = require("https");
var pkg = require("./package.json");
var version = pkg.version;
function run(cmd, opts) {
console.log(" $ " + cmd);
return childProcess.execSync(cmd, Object.assign({ encoding: "utf8" }, opts)).trim();
}
function sha256File(filePath) {
var content = fs.readFileSync(filePath);
return crypto.createHash("sha256").update(content).digest("hex");
}
function step(msg) {
console.log("\n\u001b[36mโธ\u001b[0m " + msg);
}
function ok(msg) {
console.log(" \u001b[32mโ\u001b[0m " + msg);
}
function main() {
console.log("\n\u001b[1mReleasing deploytool v" + version + "\u001b[0m\n");
// Pre-flight checks
step("Pre-flight checks");
var branch = run("git rev-parse --abbrev-ref HEAD");
if (branch !== "main") {
console.error(" Must be on main branch (currently: " + branch + ")");
process.exit(1);
}
var status = run("git status --porcelain");
if (status) {
console.error(" Working directory is not clean");
process.exit(1);
}
ok("On main branch, clean working directory");
// Run tests
step("Running tests");
run("npm test", { stdio: "inherit" });
ok("All tests passed");
// Build binaries
step("Building binaries");
var targets = [
{ name: "linux-x64", pkg: "node18-linux-x64" },
{ name: "macos-x64", pkg: "node18-macos-x64" },
{ name: "macos-arm64", pkg: "node18-macos-arm64" },
{ name: "win-x64", pkg: "node18-win-x64" }
];
var distDir = path.join(__dirname, "dist");
if (fs.existsSync(distDir)) {
run("rm -rf " + distDir);
}
fs.mkdirSync(distDir);
for (var i = 0; i < targets.length; i++) {
var target = targets[i];
var ext = target.name.indexOf("win") !== -1 ? ".exe" : "";
var binaryName = "deploytool-" + target.name + ext;
var binaryPath = path.join(distDir, binaryName);
run("npx pkg . --target " + target.pkg + " --output " + binaryPath);
var archiveName = "deploytool-" + target.name + (ext ? ".zip" : ".tar.gz");
if (ext) {
run("cd " + distDir + " && zip " + archiveName + " " + binaryName);
} else {
run("tar -czf " + path.join(distDir, archiveName) + " -C " + distDir + " " + binaryName);
}
var size = (fs.statSync(path.join(distDir, archiveName)).size / (1024 * 1024)).toFixed(1);
ok(archiveName + " (" + size + " MB)");
}
// Publish to npm
step("Publishing to npm");
run("npm publish", { stdio: "inherit" });
ok("Published to npm");
// Create git tag
step("Creating git tag");
run("git tag v" + version);
run("git push origin v" + version);
ok("Tagged v" + version);
// Create GitHub release
step("Creating GitHub release");
var releaseFiles = fs.readdirSync(distDir)
.filter(function(f) { return f.endsWith(".tar.gz") || f.endsWith(".zip"); })
.map(function(f) { return path.join(distDir, f); })
.join(" ");
run("gh release create v" + version + " " + releaseFiles +
" --title 'v" + version + "' --generate-notes");
ok("GitHub release created");
// Update Homebrew formula
step("Updating Homebrew formula");
var tapDir = path.join(__dirname, "..", "homebrew-tap");
if (fs.existsSync(tapDir)) {
var macosArchive = path.join(distDir, "deploytool-macos-x64.tar.gz");
var macosArmArchive = path.join(distDir, "deploytool-macos-arm64.tar.gz");
var linuxArchive = path.join(distDir, "deploytool-linux-x64.tar.gz");
var formulaPath = path.join(tapDir, "Formula", "deploytool.rb");
var formula = fs.readFileSync(formulaPath, "utf8");
// Update version and hashes (simplified)
formula = formula.replace(/version ".*"/, 'version "' + version + '"');
fs.writeFileSync(formulaPath, formula);
run("cd " + tapDir + " && git add . && git commit -m 'Update deploytool to v" + version + "' && git push");
ok("Homebrew formula updated");
} else {
console.log(" \u001b[33mโ \u001b[0m Homebrew tap not found, skipping");
}
// Done
console.log("\n\u001b[32m\u001b[1mโ Released deploytool v" + version + "\u001b[0m\n");
console.log(" npm: https://www.npmjs.com/package/deploytool");
console.log(" GitHub: https://github.com/yourorg/deploytool/releases/tag/v" + version);
console.log(" Homebrew: brew upgrade deploytool");
console.log("");
}
main();
Common Issues and Troubleshooting
npm publish fails with 403
You might not have permission to publish to that package name, or the name is taken:
npm ERR! 403 Forbidden - PUT https://registry.npmjs.org/deploytool
Fix: Check if the name is available with npm view deploytool. Use a scoped name like @yourorg/deploytool if it is taken.
Global install does not create the command
The bin field path is wrong or the file does not have the shebang line:
$ npm install -g deploytool
$ deploytool
bash: deploytool: command not found
Fix: Verify bin in package.json points to the correct file, and the file starts with #!/usr/bin/env node. On Linux/macOS, check that the file is executable: chmod +x bin/deploytool.js.
pkg binary cannot find bundled files
When using pkg, dynamic require() and fs.readFileSync with non-literal paths fail:
Error: Cannot find module './templates/default.json'
Fix: List dynamic assets in the pkg.assets field in package.json. Use path.join(__dirname, ...) instead of relative paths so pkg can trace them.
Homebrew formula SHA mismatch
If the tarball on npm changes after you computed the hash (npm rebuild, for example):
Error: SHA256 mismatch
Expected: abc123...
Actual: def456...
Fix: Recompute the SHA from the current tarball. Pin the exact version URL rather than using latest.
Binary size too large
Default pkg output includes the entire Node.js runtime:
deploytool-linux-x64: 85 MB
Fix: Use --compress GZip with pkg. Remove unused dependencies. Consider using esbuild to bundle your source into a single file before passing it to pkg, which reduces the files pkg needs to snapshot.
Best Practices
- Start with npm and add channels as demand grows. npm covers the majority of Node.js developers. Only add Homebrew and binaries when users request it.
- Always include a
filesfield in package.json. Never publish test fixtures, CI configs, or docs you do not want users downloading. - Test the global install before publishing. Run
npm pack && npm install -g ./deploytool-1.2.0.tgzlocally to verify the bin mapping works. - Use semantic versioning strictly. Breaking changes get a major bump. Your users' CI pipelines depend on
^1.0.0not breaking. - Automate the release process. Manual multi-channel releases are error-prone. Script every step and run it from CI.
- Cache update checks. Checking the registry on every invocation adds latency. Cache the result for 24 hours and skip the check in CI environments.
- Provide an install script for non-npm users. A curl-pipe-bash one-liner is the standard for quick binary installs. Detect OS and architecture automatically.
- Sign your binaries. For production tools, code signing builds trust. macOS notarization prevents Gatekeeper warnings.