Bunli
Guides

Distribution

Package and distribute your CLI to users

Building Your CLI

Development vs Production

During development, you run your CLI directly:

# Development
bunli dev
./src/index.ts --help

For distribution, you need to build optimized binaries:

# Production build
bunli build

Build Options

# Build traditional JS (requires Bun runtime)
bunli build

# Build standalone executable for current platform
bunli build --targets native

# Build for specific platforms
bunli build --targets darwin-arm64,linux-x64

# Build for all platforms
bunli build --targets all

# Build with custom options
bunli build \
  --targets darwin-arm64,linux-x64 \
  --minify \
  --sourcemap \
  --outdir ./dist

Platform Targets

Bunli supports building for multiple platforms:

  • darwin-arm64 - macOS Apple Silicon
  • darwin-x64 - macOS Intel
  • linux-arm64 - Linux ARM64
  • linux-x64 - Linux x64
  • windows-x64 - Windows x64

Cross-Platform Builds

Build for all platforms at once:

// bunli.config.ts
import { defineConfig } from "@bunli/core";

export default defineConfig({
  build: {
    targets: ["darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64", "windows-x64"],
    // Optional: create `dist/<target>.tar.gz` archives (for manual distribution).
    // If you're using bunli-releaser, keep this disabled (it expects per-target output dirs).
    compress: true,
  },
});
bunli build --targets all

This creates:

dist/
├── darwin-arm64/
│   └── cli
├── darwin-x64/
│   └── cli
├── linux-arm64/
│   └── cli
├── linux-x64/
│   └── cli
└── windows-x64/
    └── cli.exe

With compression enabled, also:

dist/
├── darwin-arm64.tar.gz
├── darwin-x64.tar.gz
├── linux-arm64.tar.gz
├── linux-x64.tar.gz
└── windows-x64.tar.gz

Standalone Executables

Creating Single-File Executables

Bunli uses Bun's --compile flag to create standalone executables that bundle the runtime:

# Compile for current platform
bunli build --targets native

# Compile for specific platforms
bunli build --targets darwin-arm64,linux-x64

# Compile with optimization
bunli build --targets native --minify --bytecode

Executable Size Optimization

Reduce executable size:

// bunli.config.ts
export default defineConfig({
  build: {
    targets: ["native"], // or specific platforms
    minify: true,
    external: [
      // Exclude large optional dependencies
      "sharp",
      "sqlite3",
      "@prisma/client",
    ],
  },
});

Generated Files in Distribution

When using type generation, the generated .bunli/commands.gen.ts file is automatically included in your build:

// bunli.config.ts
export default defineConfig({
  name: "my-cli",
  version: "1.0.0",
  build: {
    entry: "./src/index.ts",
    outdir: "./dist",
  },
});

The generated types are:

  • Included in builds - Automatically bundled with your CLI
  • Type-safe at runtime - Full TypeScript definitions available
  • Optimized for production - Minified and tree-shaken
  • Cross-platform compatible - Works on all target platforms

Generated type files are automatically included in your distribution builds. No additional configuration needed.

Code Signing (macOS)

Sign your macOS executables:

# Build the executable
bunli build --targets darwin-arm64

# Sign the executable
codesign --sign "Developer ID Application: Your Name" \
  --options runtime \
  --entitlements entitlements.plist \
  dist/darwin-arm64/my-cli

# Verify signature
codesign --verify --verbose dist/darwin-arm64/my-cli

Distribution Methods

For standalone binaries, checksums, and Homebrew updates, the recommended path is to use the bunli-releaser GitHub Action.

Official starter template:

It will:

  • Build cross-platform standalone binaries from a single Ubuntu runner
  • Upload stable, per-target archives plus checksums.txt (sha256) to the GitHub Release for the tag
  • Update a Homebrew tap formula (macOS/Linux only)

If you use bunli-releaser, keep build.compress disabled in bunli.config.ts. The action expects per-target build output directories.

# .github/workflows/release.yml
name: Release

on:
  push:
    tags:
      - "v*.*.*"

permissions:
  contents: write

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Build and publish binaries
        uses: AryaLabsHQ/bunli-releaser@v1
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}

          # Optional Homebrew update:
          # provide both brew-tap and brew-token to enable tap automation
          # brew-tap: <owner>/<homebrew-tap>
          # brew-token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
          # brew-pr: "true"

          # Optional:
          # workdir: "."
          # targets: "all"
          # artifact-name: "my-cli"
          # existing-assets: "fail" # or "skip"

Published assets use stable naming:

  • Unix: ${name}-${version}-${os}-${arch}.tar.gz
  • Windows: ${name}-${version}-${os}-${arch}.zip
  • checksums.txt (sha256)

Notes:

  • v1 only supports tags in the format vX.Y.Z (strict).
  • Existing release asset handling defaults to existing-assets: "fail".
  • Use existing-assets: "skip" to allow reruns without re-uploading already existing assets.

2. npm Distribution

Distribute via npm for easy installation:

// package.json
{
  "name": "my-cli",
  "version": "1.0.0",
  "bin": {
    "my-cli": "./dist/index.js"
  },
  "files": ["dist/"],
  "scripts": {
    "prepublishOnly": "bunli build"
  }
}
# Publish to npm
npm publish

# Users install with
npm install -g my-cli
# or
bunx my-cli

3. Homebrew (macOS/Linux)

If you're using bunli-releaser, it will generate/update your Homebrew formula for you. This is the expected URL and filename shape (derived from the published GitHub Release assets):

# Formula/my-cli.rb
class MyCli < Formula
  desc "Description of my CLI"
  homepage "https://github.com/username/my-cli"
  version "1.0.0"

  on_macos do
    if Hardware::CPU.arm?
      url "https://github.com/username/my-cli/releases/download/v1.0.0/my-cli-1.0.0-darwin-arm64.tar.gz"
      sha256 "abc123..."
    else
      url "https://github.com/username/my-cli/releases/download/v1.0.0/my-cli-1.0.0-darwin-x64.tar.gz"
      sha256 "def456..."
    end
  end

  on_linux do
    if Hardware::CPU.arm?
      url "https://github.com/username/my-cli/releases/download/v1.0.0/my-cli-1.0.0-linux-arm64.tar.gz"
      sha256 "ghi789..."
    else
      url "https://github.com/username/my-cli/releases/download/v1.0.0/my-cli-1.0.0-linux-x64.tar.gz"
      sha256 "jkl012..."
    end
  end

  def install
    bin.install "my-cli"
  end

  test do
    system "#{bin}/my-cli", "--version"
  end
end

4. Docker Distribution

Create a Docker image:

# Dockerfile
FROM oven/bun:1-alpine AS builder

WORKDIR /app
COPY package.json bun.lockb ./
RUN bun install --frozen-lockfile

COPY . .
RUN bunli build --targets native

FROM alpine:latest
RUN apk add --no-cache libstdc++
COPY --from=builder /app/dist/my-cli /usr/local/bin/my-cli

ENTRYPOINT ["my-cli"]
# Build and push
docker build -t username/my-cli:latest .
docker push username/my-cli:latest

# Users run with
docker run username/my-cli --help

5. Package Managers

Scoop (Windows)

// my-cli.json
{
  "version": "1.0.0",
  "description": "Description of my CLI",
  "homepage": "https://github.com/username/my-cli",
  "license": "MIT",
  "architecture": {
    "64bit": {
      "url": "https://github.com/username/my-cli/releases/download/v1.0.0/my-cli-1.0.0-windows-x64.zip",
      "hash": "sha256:abc123..."
    }
  },
  "bin": "my-cli.exe"
}

AUR (Arch Linux)

# PKGBUILD
pkgname=my-cli
pkgver=1.0.0
pkgrel=1
pkgdesc="Description of my CLI"
arch=('x86_64' 'aarch64')
url="https://github.com/username/my-cli"
license=('MIT')
source_x86_64=("$url/releases/download/v$pkgver/my-cli-$pkgver-linux-x64.tar.gz")
source_aarch64=("$url/releases/download/v$pkgver/my-cli-$pkgver-linux-arm64.tar.gz")

package() {
  install -Dm755 my-cli "$pkgdir/usr/bin/my-cli"
}

Automated Releases

Using bunli-releaser (Binaries + Homebrew)

If you want GitHub Release assets (stable per-target archives + checksums.txt) and Homebrew automation, use bunli-releaser on tag pushes:

name: Release

on:
  push:
    tags:
      - "v*.*.*"

permissions:
  contents: write

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: AryaLabsHQ/bunli-releaser@v1
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}

          # Optional Homebrew update (macOS/Linux):
          # brew-tap: <owner>/<homebrew-tap>
          # brew-token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
          # brew-pr: "true"
          # existing-assets: "fail" # or "skip"
Keep build.compress disabled when using bunli-releaser.

Using bunli release (npm + tags)

Bunli also provides a bunli release command for automating version bumps, git tags, and npm publishing. It can also publish binary packages to npm via release.binary (optionalDependencies + generated shim). For GitHub release assets/checksums/Homebrew automation, use bunli-releaser.

# Create a new release
bunli release

# Release with specific version
bunli release --version 2.0.0

# Release without npm publish
bunli release --npm=false

# Dry run
bunli release --dry

When npm publishing is enabled, --dry runs npm publish --dry-run.

Release Configuration

// bunli.config.ts
export default defineConfig({
  release: {
    npm: true, // Publish to npm
    github: true, // Create a GitHub Release entry (notes only; no assets)
    tagFormat: "v{{version}}", // Git tag format used for both git + GitHub tags
    binary: {
      packageNameFormat: "{{name}}-{{platform}}",
      shimPath: "bin/run.mjs",
    },
  },
});

Release Workflow

At a high level, bunli release:

  • Runs tests and your build script
  • Updates package.json version
  • Commits, tags, and pushes
  • Publishes to npm (unless disabled)
  • Optionally creates a GitHub Release entry via gh (no assets)

Installation Scripts

Universal Install Script

Create an install script for easy installation:

#!/bin/sh
# install.sh

set -e

VERSION="1.0.0"
REPO="username/my-cli"
NAME="my-cli"

# Detect OS and architecture
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
ARCH=$(uname -m)

case "$OS" in
  darwin) OS="darwin" ;;
  linux) OS="linux" ;;
  *) echo "Unsupported OS: $OS"; exit 1 ;;
esac

case "$ARCH" in
  x86_64) ARCH="x64" ;;
  aarch64|arm64) ARCH="arm64" ;;
  *) echo "Unsupported architecture: $ARCH"; exit 1 ;;
esac

# bunli-releaser asset naming:
# - Unix: ${NAME}-${VERSION}-${OS}-${ARCH}.tar.gz
# - Windows: ${NAME}-${VERSION}-${OS}-${ARCH}.zip
ASSET="${NAME}-${VERSION}-${OS}-${ARCH}.tar.gz"
URL="https://github.com/$REPO/releases/download/v$VERSION/$ASSET"

# Download and install
echo "Downloading $NAME..."
tmp="$(mktemp -d)"
curl -fsSL "$URL" | tar -xz -C "$tmp"
sudo mv "$tmp/$NAME" /usr/local/bin/
sudo chmod +x /usr/local/bin/"$NAME"

echo "my-cli installed successfully!"
"$NAME" --version

Users install with:

curl -fsSL https://example.com/install.sh | sh

Version Management

Semantic Versioning

Follow semantic versioning (semver):

  • Major (1.0.0 → 2.0.0): Breaking changes
  • Minor (1.0.0 → 1.1.0): New features
  • Patch (1.0.0 → 1.0.1): Bug fixes

Version Display

// src/index.ts
import { createCLI } from "@bunli/core";
import pkg from "../package.json" with { type: "json" };

const cli = await createCLI({
  name: "my-cli",
  version: pkg.version, // Automatically shows with --version
  description: "My awesome CLI",
});

For compiled binaries, prefer embedding the version (like importing package.json as above) rather than reading a runtime env var.

Update Notifications

Notify users of new versions using the npm registry API:

// src/utils/check-update.ts
export async function checkForUpdates(currentVersion: string, packageName: string) {
  try {
    const res = await fetch(`https://registry.npmjs.org/${packageName}/latest`);
    const data = (await res.json()) as { version: string };
    const latest = data.version;
    if (latest !== currentVersion) {
      console.log(`\n  Update available: ${currentVersion} → ${latest}`);
      console.log(`  Run: npm update -g ${packageName}\n`);
    }
  } catch {
    // Ignore update check failures
  }
}

Best Practices

  1. Test Before Release: Run tests on all target platforms
  2. Include License: Add LICENSE file to distributions
  3. Document Installation: Provide clear installation instructions
  4. Version Everything: Tag releases and maintain changelog
  5. Automate Releases: Use CI/CD for consistent releases
  6. Sign Binaries: Code sign for security and trust
  7. Provide Checksums: Include SHA256 checksums for verification

Troubleshooting

Common Issues

Binary not executable:

chmod +x ./my-cli

Missing dependencies:

# Bundle all dependencies
bunli build --targets native

Large binary size:

# Enable optimizations
bunli build --targets native --minify --bytecode

Platform-specific issues:

// Handle platform differences
if (process.platform === "win32") {
  // Windows-specific code
}

Next Steps

On this page