Crust logoCrust

Building & Distribution

Compile your CLI to standalone executables with crust build.

The crust build command compiles your CLI entry file into standalone Bun executables using the Bun.build() API. It supports cross-platform compilation and generates a platform-detecting shell resolver for multi-target distribution — no runtime (Node.js or Bun) required on the end user's machine.

Standalone binaries are the recommended distribution strategy for most CLIs. If you intentionally ship a Bun runtime package instead, use a JS build output like dist/cli.js and point bin to that file.

Basic Usage

# Build for all platforms (default)
crust build

# Build with a custom entry point
crust build --entry src/main.ts

# Build for a specific platform
crust build --target darwin-arm64

# Build with explicit env files for build-time constants
crust build --env-file .env.production

Flags

FlagAliasTypeDefaultDescription
--entry-e"string"src/cli.tsEntry file path
--outfile-o"string"Output file path (single-target only)
--name-n"string"auto-detectedBinary name
--minify"boolean"trueMinify the output
--target-t"string" (repeatable)all platformsTarget platform(s)
--outdir-d"string"distOutput directory for compiled binaries
--resolver-r"string"cliResolver script filename (multi-target only, no extension)
--env-file"string" (repeatable)Explicit env file(s) used for build-time constants
--validate"boolean"truePre-compile validation of command definitions
--package"boolean"falseStage npm packages in dist/npm instead of raw binaries
--stage-dir"string"dist/npmDirectory to stage npm packages when using --package

Supported Targets

Target AliasBun TargetPlatform
linux-x64bun-linux-x64-baselineLinux x86_64
linux-arm64bun-linux-arm64Linux ARM64
darwin-x64bun-darwin-x64macOS Intel
darwin-arm64bun-darwin-arm64macOS Apple Silicon
windows-x64bun-windows-x64-baselineWindows x86_64
windows-arm64bun-windows-arm64Windows ARM64

You can use either the short alias (e.g., linux-x64) or the full Bun target name (e.g., bun-linux-x64-baseline).

Multi-Target Build (Default)

When no --target is specified (or multiple targets are given), crust build produces:

  1. A binary for each platform — named dist/<name>-<target> (e.g., dist/my-cli-bun-darwin-arm64)
  2. A shell resolverdist/cli (customizable via --resolver) that detects the host platform and executes the correct binary, plus a dist/cli.cmd companion for Windows
crust build

Output:

dist/
├── cli                                  # Shell resolver (entry point)
├── cli.cmd                              # Windows batch resolver
├── my-cli-bun-linux-x64-baseline        # Linux x64 binary
├── my-cli-bun-linux-arm64               # Linux ARM64 binary
├── my-cli-bun-darwin-x64                # macOS Intel binary
├── my-cli-bun-darwin-arm64              # macOS Apple Silicon binary
├── my-cli-bun-windows-x64-baseline.exe  # Windows x64 binary
└── my-cli-bun-windows-arm64.exe         # Windows ARM64 binary

The Resolver Script

The resolver (dist/cli by default) is a #!/usr/bin/env bash shell script that:

  1. Detects the platform via uname -s and uname -m
  2. Maps to the correct prebuilt binary
  3. Ensures execute permissions
  4. Replaces itself with the binary via exec (no child process overhead)

A companion dist/cli.cmd Windows batch file is also generated for Windows support.

This is what you point to in your package.json's bin field. No runtime (Node.js or Bun) is required — the resolver directly executes the standalone binary.

Single-Target Build

When exactly one --target is specified, crust build produces a single binary without a resolver:

crust build --target darwin-arm64

Output:

dist/
└── my-cli    # Single binary for macOS Apple Silicon

Custom Output Path

Use --outfile for single-target builds:

crust build --target linux-x64 --outfile ./bin/my-cli

--outfile can only be used with exactly one target. Use --name to set the base binary name when building for multiple targets.

Binary Naming

The binary name is resolved in this priority:

  1. --name flag — Explicit name
  2. package.json name — Read from package.json in the current directory (scoped names like @scope/name are stripped to name)
  3. Entry filename — The entry file name without extension (e.g., cli.tscli)
# Explicit name
crust build --name my-tool

# From package.json
crust build  # Uses package.json "name" field

# From entry
crust build --entry src/main.ts  # Uses "main" as the name

Disabling Minification

Minification is enabled by default. Disable it with:

crust build --no-minify

Environment Variables

crust build keeps runtime env and build-time constants separate.

  • compiled executables continue to use Bun's runtime env behavior
  • Bun's auto-loaded cwd env can provide build-time constants by default
  • --env-file lets you override that with explicit env files
  • only PUBLIC_* values are eligible to be embedded as build-time constants
crust build --env-file .env.production
crust build --env-file .env --env-file .env.local
crust build --package --env-file .env.production

PUBLIC_* values are embedded in the binary and are therefore public.

See Environment Variables for the full env model, including runtime env, Bun .env loading, and recommended config patterns.

Pre-compile Validation

Before compiling, crust build validates your CLI's command definitions by running your entry file in a special validation mode. This catches definition errors — such as flag alias collisions, reserved no- prefix misuse, and other structural issues — before the binary is compiled, rather than at runtime when users invoke it.

The validation works by spawning the current executable in Bun mode with CRUST_INTERNAL_VALIDATE_ONLY=1. When .execute() detects this environment variable, it runs plugin setup and validates the full command tree (including subcommands and plugin-injected flags) without executing any command handlers.

If you pass --env-file, the validation subprocess receives the same explicit env-file inputs as the build step. That keeps plugin setup and command-tree validation aligned with the final compilation.

What it catches

IssueError CodeExample
Flag alias collisionDEFINITIONTwo flags share -v as alias
no- prefixed flag nameDEFINITIONFlag named no-cache instead of cache
no- prefixed aliasDEFINITIONAlias no-store on a flag
Long alias shadowing flag nameDEFINITIONAlias out when another flag is named out

In addition to hard errors, the validation emits warnings for plugin conflicts that are allowed at runtime but may indicate unintentional behavior:

WarningExample
Plugin flag overrides user-defined flagPlugin calls addFlag(cmd, "verbose", ...) but verbose already exists
Plugin subcommand skippedPlugin calls addSubCommand(cmd, "deploy", ...) but deploy already exists

Skipping validation

Validation is enabled by default. To skip it:

crust build --no-validate

This can be useful if the validation subprocess cannot resolve your project's dependencies in a particular environment (e.g., non-standard module resolution setups).

Per-OS Distribution with --package

For distributing your CLI via npm with platform-specific packages, use the --package flag. This creates a root package with optional dependencies on per-platform packages, enabling npm to install only the binary for the user's platform.

# Build and stage npm distribution packages
crust build --package

# Build for specific platforms only
crust build --package --target linux-x64 --target darwin-arm64

# Use a custom staging directory
crust build --package --stage-dir ./npm-packages

What --package Creates

When using --package, instead of raw binaries, Crust creates:

  1. A root package — Contains the JS resolver and declares optional dependencies on platform packages
  2. Per-platform packages — One package per target containing only the binary for that platform
  3. A manifest filemanifest.json listing all staged packages and publish order
dist/npm/
├── manifest.json              # Distribution manifest with publish order
├── root/                      # Root package (resolver + metadata)
│   ├── package.json
│   └── bin/
│       └── my-cli.js          # JS resolver
├── linux-x64/                 # Linux x64 package
│   ├── package.json
│   └── bin/
│       └── my-cli-bun-linux-x64-baseline
├── linux-arm64/               # Linux ARM64 package
│   ├── package.json
│   └── bin/
│       └── my-cli-bun-linux-arm64
├── darwin-x64/                # macOS Intel package
│   ├── package.json
│   └── bin/
│       └── my-cli-bun-darwin-x64
├── darwin-arm64/              # macOS Apple Silicon package
│   ├── package.json
│   └── bin/
│       └── my-cli-bun-darwin-arm64
├── windows-x64/               # Windows x64 package
│   ├── package.json
│   └── bin/
│       └── my-cli-bun-windows-x64-baseline.exe
└── windows-arm64/             # Windows ARM64 package
    ├── package.json
    └── bin/
        └── my-cli-bun-windows-arm64.exe

The root package exposes bin/my-cli.js as its bin entry. npm generates platform launchers from that entry during install, so Crust no longer stages its own .cmd wrapper for --package.

Publishing with crust publish

After staging with --package, use crust publish to publish all packages in the correct order:

# Publish all staged packages
crust publish

# Dry run to see what would be published
crust publish --dry-run

# Publish with a specific tag
crust publish --tag next

# Publish from a custom staging directory
crust publish --stage-dir ./npm-packages

The publish command reads the manifest.json and publishes packages in dependency order (platform packages first, then root package).

Distribution Package Names

Per-platform packages are named by appending the platform alias to your package name:

Root PackagePlatform Package
my-climy-cli-linux-x64, my-cli-darwin-arm64, etc.
@scope/my-cli@scope/my-cli-linux-x64, @scope/my-cli-darwin-arm64, etc.

Platform packages use npm's os and cpu fields so npm only installs the binary matching the user's platform. The root package uses optionalDependencies, so missing platform packages don't break installation.

package.json Setup

package.json
{
  "name": "my-cli",
  "files": [
    "dist"
  ],
  "bin": {
    "my-cli": "dist/cli"
  },
  "scripts": {
    "build": "crust build",
    "prepack": "bun run build"
  }
}

The bin field points to the resolver script, which handles platform detection and runs the correct compiled binary.

package.json
{
  "name": "my-cli",
  "files": [
    "dist"
  ],
  "bin": {
    "my-cli": "dist/cli"
  },
  "scripts": {
    "build": "crust build",
    "package": "crust build --package",
    "publish": "crust publish --stage-dir dist/npm",
    "prepack": "bun run build"
  }
}

After building and staging, publish all platform packages:

# Stage npm packages
bun run package

# Publish all packages
crust publish

Users install your CLI normally with bun add -g my-cli or npm install -g my-cli, and npm automatically installs only the binary for their platform.

package.json
{
  "name": "my-cli",
  "files": [
    "dist"
  ],
  "bin": {
    "my-cli": "dist/cli.js"
  },
  "scripts": {
    "build": "bun build src/cli.ts --target bun --outfile dist/cli.js",
    "prepack": "bun run build"
  }
}

This mode still requires Bun on the end user's machine.

On this page