Bunli
Guides

Distribution

Package and distribute your CLI to users

Distribution Guide

Learn how to build, package, and distribute your Bunli CLI application to users across different platforms.

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:

// src/utils/check-update.ts
import { getLatestVersion } from '@bunli/utils'

export async function checkForUpdates(currentVersion: string) {
  try {
    const latest = await getLatestVersion('my-cli')
    if (latest > currentVersion) {
      console.log(`\n  Update available: ${currentVersion} → ${latest}`)
      console.log('  Run: npm update -g my-cli\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