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.productionFlags
| Flag | Alias | Type | Default | Description |
|---|---|---|---|---|
--entry | -e | "string" | src/cli.ts | Entry file path |
--outfile | -o | "string" | — | Output file path (single-target only) |
--name | -n | "string" | auto-detected | Binary name |
--minify | — | "boolean" | true | Minify the output |
--target | -t | "string" (repeatable) | all platforms | Target platform(s) |
--outdir | -d | "string" | dist | Output directory for compiled binaries |
--resolver | -r | "string" | cli | Resolver script filename (multi-target only, no extension) |
--env-file | — | "string" (repeatable) | — | Explicit env file(s) used for build-time constants |
--validate | — | "boolean" | true | Pre-compile validation of command definitions |
--package | — | "boolean" | false | Stage npm packages in dist/npm instead of raw binaries |
--stage-dir | — | "string" | dist/npm | Directory to stage npm packages when using --package |
Supported Targets
| Target Alias | Bun Target | Platform |
|---|---|---|
linux-x64 | bun-linux-x64-baseline | Linux x86_64 |
linux-arm64 | bun-linux-arm64 | Linux ARM64 |
darwin-x64 | bun-darwin-x64 | macOS Intel |
darwin-arm64 | bun-darwin-arm64 | macOS Apple Silicon |
windows-x64 | bun-windows-x64-baseline | Windows x86_64 |
windows-arm64 | bun-windows-arm64 | Windows 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:
- A binary for each platform — named
dist/<name>-<target>(e.g.,dist/my-cli-bun-darwin-arm64) - A shell resolver —
dist/cli(customizable via--resolver) that detects the host platform and executes the correct binary, plus adist/cli.cmdcompanion for Windows
crust buildOutput:
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 binaryThe Resolver Script
The resolver (dist/cli by default) is a #!/usr/bin/env bash shell script that:
- Detects the platform via
uname -sanduname -m - Maps to the correct prebuilt binary
- Ensures execute permissions
- 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-arm64Output:
dist/
└── my-cli # Single binary for macOS Apple SiliconCustom 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:
--nameflag — Explicit namepackage.jsonname — Read frompackage.jsonin the current directory (scoped names like@scope/nameare stripped toname)- Entry filename — The entry file name without extension (e.g.,
cli.ts→cli)
# 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 nameDisabling Minification
Minification is enabled by default. Disable it with:
crust build --no-minifyEnvironment 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-filelets 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.productionPUBLIC_* 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
| Issue | Error Code | Example |
|---|---|---|
| Flag alias collision | DEFINITION | Two flags share -v as alias |
no- prefixed flag name | DEFINITION | Flag named no-cache instead of cache |
no- prefixed alias | DEFINITION | Alias no-store on a flag |
| Long alias shadowing flag name | DEFINITION | Alias 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:
| Warning | Example |
|---|---|
| Plugin flag overrides user-defined flag | Plugin calls addFlag(cmd, "verbose", ...) but verbose already exists |
| Plugin subcommand skipped | Plugin calls addSubCommand(cmd, "deploy", ...) but deploy already exists |
Skipping validation
Validation is enabled by default. To skip it:
crust build --no-validateThis 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-packagesWhat --package Creates
When using --package, instead of raw binaries, Crust creates:
- A root package — Contains the JS resolver and declares optional dependencies on platform packages
- Per-platform packages — One package per target containing only the binary for that platform
- A manifest file —
manifest.jsonlisting 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.exeThe 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-packagesThe 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 Package | Platform Package |
|---|---|
my-cli | my-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
{
"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.
{
"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 publishUsers 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.
{
"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.