Cli Tools

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 files field 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.tgz locally to verify the bin mapping works.
  • Use semantic versioning strictly. Breaking changes get a major bump. Your users' CI pipelines depend on ^1.0.0 not 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.

References

Powered by Contentful