Build and distribution
Build and publish your CLI for Bun, Deno, or Node.
Use crust build when your CLI is ready to run without its TypeScript source. One command produces the tree you run locally and publish to npm.
crust build
node .crust/root/bin/my-cli.js --help
crust publish --dry-run
crust publishBuild
crust build
# Runtime: bun (default)
# Manifest: .../.crust/manifest.json (excerpt)crust build writes everything to .crust/, wiping it first. It requires name and version in package.json; add .crust to .gitignore.
.crust/
├── manifest.json # staged packages, publish order, and per-bin build hook output
├── artifacts/ # Extension build hook output, written by crust build
├── root/ # the npm package users install
│ └── bin/my-cli.js # one per bin entry: Node launcher (Bun, Deno) or the bundle itself (Node)
└── linux-x64/ # one platform package per target (Bun and Deno)
└── bin/my-cli-bun-linux-x64 # <command>-<target> per bin entry, .exe on WindowsEvery build prints the runtime it resolved and where it came from, for example Runtime: node (inferred from @types/node). The order is:
"crust": { "runtime": "deno" }inpackage.json- Inference: a
deno.jsonordeno.jsoncin the project selects Deno;@types/nodeindependenciesordevDependencieswithout@types/bunselects Node - Bun
Lockfiles are not a signal, because the package manager that installed your dependencies says nothing about the runtime your CLI runs on.
| Runtime | Staged in .crust/ | Needed by the user |
|---|---|---|
| Bun | Root package with a Node launcher plus one platform package per target | Node |
| Deno | Same layout as Bun; Linux platform packages are glibc only | Node |
| Node | Root package only: bin/<command>.js is the self-contained bundle, so no launcher and no platform packages | Node |
The entry file is the value of bin in package.json: "bin": { "my-cli": "src/main.ts" } builds src/main.ts as the my-cli command, and several keys build several commands (see Commands and entries). Without bin, the build compiles src/cli.ts under the unscoped package name. The runtime, Bun plugins, and included directories live in the crust block; see the crust build options for the flags.
Choose targets
crust build --target bun-linux-x64 --target bun-darwin-arm64Omitting --target builds every supported Bun or Deno target; repeat it for the platforms you ship, or pass --target host for a fast local build of just your own machine's target (host can be mixed with explicit targets, and duplicates are dropped). The launcher only knows the targets that were built, so a release build should include every platform your users run.
| Bun target | Deno target | Platform alias |
|---|---|---|
bun-linux-x64, bun-linux-arm64 | x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu | linux-x64, linux-arm64 |
bun-linux-x64-musl, bun-linux-arm64-musl | none | linux-x64-musl, linux-arm64-musl |
bun-darwin-x64, bun-darwin-arm64 | x86_64-apple-darwin, aarch64-apple-darwin | darwin-x64, darwin-arm64 |
bun-windows-x64, bun-windows-arm64 | x86_64-pc-windows-msvc, aarch64-pc-windows-msvc | windows-x64, windows-arm64 |
Node builds take no --target; the bundle runs wherever Node does.
Linux glibc and musl
The plain Linux targets link against glibc and do not start on musl-based distributions such as Alpine; the -musl targets cover those. The launcher detects the host C library and picks the matching binary, so shipping both is enough for the default node:alpine images.
Bun's musl executables still load libstdc++ and libgcc dynamically. A bare Alpine image needs
apk add libstdc++ libgcc; node:alpine already includes them.
Staged package.json files
The staged package.json files are generated, not copied: they take name, version, and npm metadata (description, license, author, repository, keywords, engines, and similar) from your package.json, and a Bun or Deno root package lists the platform packages as optionalDependencies. Your own bin, files, dependencies, and devDependencies are not carried over, because the binaries and the Node bundle already inline them, so keep the usual split in your project: Crust packages your code imports in dependencies, @crustjs/crust in devDependencies. A Node root package therefore has no dependency fields at all.
Each key of your bin field is a command: the root package gets bin/<command>.js and every platform package gets bin/<command>-<target> (.exe on Windows), so two commands mean two launchers and two binaries per platform, all in the same packages. A string bin or no bin gives one command named after the unscoped package name. Platform package names append the platform alias, such as my-cli-linux-x64 or @scope/my-cli-linux-x64, and Linux packages carry an npm libc field (glibc or musl), so package managers download only the platform package that matches the host. The project's README.md is copied into the root package and its LICENSE into every package.
Run locally
node .crust/root/bin/my-cli.js --help.crust/root/bin/<command>.js runs in place: the Bun and Deno launcher finds the platform binary in the sibling .crust/<platform>/ directory, and the Node bundle needs nothing else. Projects scaffolded by create-crust expose this as the start script. Your project's bin points at the source entry, so npm link in the project runs the TypeScript source through the shebang at the top of the entry (#!/usr/bin/env bun, node, or -S deno run -A); run npm link inside .crust/root to link the built distribution instead.
Publish with crust publish
crust publish --dry-run
# Publish order: linux-x64 -> darwin-arm64 -> root (excerpt)
crust publishThe dry run prints the order and commands without publishing and stays offline. crust publish reads .crust/manifest.json, verifies package metadata, asks the target registry (npm view) which staged packages already have this exact version, publishes the missing ones with npm publish (platform packages before the root; a Node manifest publishes just root), and stops at the first failure. Existing name@version pairs are skipped and never re-tagged, so rerunning after a failure resumes where the previous run stopped; identity is name and version only, not tarball contents or ownership, and the lookup is not a permission check (trusted publishing supplies no read credential), so a half-published release is recoverable rather than prevented. See its options. Because npm does the upload, trusted publishing works from GitHub Actions: give the job id-token: write and configure a trusted publisher for the root package and each platform package.
For a local bootstrap publish, run npm login, then crust publish from a terminal. When stdin is a TTY, npm inherits the terminal so you can complete its browser or one-time-password (2FA) prompts for each package. Without a TTY, output remains captured and forwarded after each publish; use non-interactive credentials or trusted publishing in CI.
npm install -g my-cli
my-cli --helpUsers install the root package; for Bun and Deno builds the package manager pulls only the platform package for their machine, and for Node builds the root package is the whole CLI. Yarn 2+ has no global install, so Yarn users run the CLI once with yarn dlx or add it to a project and run it with yarn my-cli.
npm 10.9.8 can report a successful install but remove the root command shim while skipping incompatible optional platform packages with the same bin name. Use npm 11.19.1, verified on Linux with Node 22.23.2. The minimum fixed npm version has not been established; this does not imply every npm11 release is fixed.
Publishing from CI
npm publish runs inside each .crust/<package> directory, so a project-level .npmrc is not read. Authenticate with trusted publishing, point NPM_CONFIG_USERCONFIG at an .npmrc you write in the workflow, or use ~/.npmrc; --registry overrides the destination. The existence lookup replays the staged publishConfig registry settings (registry, @scope:registry, scope) to npm view and follows npm 10.6+ precedence, where CLI flags win over publishConfig; npm 9 resolves publishConfig first, so there a --registry that conflicts with publishConfig.registry is checked against the wrong registry. npm reads publishConfig.access from each staged package.json, so a scoped public package needs "publishConfig": { "access": "public" } in its package.json, which crust build copies into every staged package.
Custom distribution
crust build --target host # Bun and Deno; Node takes no --target
ls .crust/linux-x64/bin/ # my-cli-bun-linux-x64
cat .crust/manifest.json # every staged directory, its target, and the publish orderEvery build stages into .crust/, so for release assets, containers, or any channel other than npm, build the targets you need and take the files from there: the standalone executable at .crust/<platform alias>/bin/<command>-<target> for Bun and Deno (.exe on Windows), or the executable JavaScript bundle at .crust/root/bin/<command>.js for Node. .crust/manifest.json is the index. Staging requires name and version in package.json and replaces .crust/ on every build, so copy what you need out before building again.
{
"root": { "name": "my-cli", "dir": "root", "bins": ["my-cli"] },
"publishOrder": ["linux-x64", "root"],
"build": {
"my-cli": {
"extensions": [
{
"id": "crust:skills",
"files": ["skills/my-cli/SKILL.md", "skills/my-cli/commands/my-cli.md"]
},
{ "id": "crust:man", "files": ["man/my-cli.1"] }
]
}
}
}build records, per bin entry, every file each Extension build hook produced under .crust/artifacts/, in hook order. It is absent when --no-validate skipped the hooks.
Prepare Extension artifacts
crust build
# Preparing Command Snapshot for my-cli... (excerpt)
# crust:man 1 file man/my-cli.1Before compilation, the build materializes and validates the command tree of every bin entry, checks that its root command is named after the bin key, and runs Extension build hooks. Each hook returns the files it ships; the build validates their paths, writes them to .crust/artifacts/, and records them under build in .crust/manifest.json. The entry module's top-level code runs during this step, but Command Actions and code after await app.execute() do not. Each top-level artifact directory is copied into .crust/root/ (and listed in its files) and into every platform package's bin/, as described in Extension artifacts. A path two hooks of one entry both return fails the build naming both Extensions. With several entries, each entry's hooks run in isolation and their output is merged; a path two entries both write fails the build naming both commands.
Use --no-validate only when preparation cannot run; it skips the name check and Extension build hooks, so nothing is staged from .crust/artifacts/ and the manifest has no build field. bin itself is still validated: missing entries, duplicate entries, and invalid command names fail before .crust/ is touched.
Ship extra directories with crust.include
{
"crust": { "include": ["templates"] }
}crust.include lists directories, relative to the project root, that crust build copies exactly like Extension artifacts: into .crust/root/ (added to its files) and into each platform package's bin/. Entries cannot be inside .crust.
Read Extension artifacts and included directories at runtime with resolveArtifactDir(name) from @crustjs/core: it returns the staged copy for the running layout (compiled binary, Node bundle, or source after a build), so resolveArtifactDir("templates") works in place, after install, and inside a compiled executable.
Add Bun bundler plugins
{
"crust": { "bunPlugins": ["@opentui/solid/bun-plugin", "./build/plugin.ts"] }
}See crust.bunPlugins for resolution rules. Validation runs the entry under Bun's runtime without bundler plugins, so a plugin that transforms sources at import time needs a matching runtime plugin in bunfig.toml preload; otherwise pass --no-validate.
Environment variables
import { Crust } from "@crustjs/core";
const cli = new Crust("my-cli").action(({ stdout }) => {
const token = process.env.API_TOKEN;
if (!token) throw new Error("Missing API_TOKEN");
stdout("Deploying");
});Read secrets and deployment configuration from the runtime environment; Crust adds no abstraction, and Bun environment variables covers Bun's .env loading and precedence.
Build-time constants
//#region runtime-secret
import { Crust } from "@crustjs/core";
const cli = new Crust("my-cli").action(({ stdout }) => {
const token = process.env.API_TOKEN;
if (!token) throw new Error("Missing API_TOKEN");
stdout("Deploying");
});
//#endregion
//#region build-constant
const app = cli.command("origin", (command) =>
command.action(({ stdout }) => {
stdout(process.env.PUBLIC_API_ORIGIN ?? "No public origin configured");
}),
);
await app.execute();
//#endregion
cat > .env.production <<'EOF'
PUBLIC_API_ORIGIN=https://api.example.com
API_TOKEN=private-value
EOF
crust build --env-file .env.production --target host
env -u PUBLIC_API_ORIGIN -u API_TOKEN node .crust/root/bin/my-cli.js origin
# https://api.example.com
grep -a -o 'private-value' .crust/*/bin/my-cli-* || echo 'private-value: not embedded'
# private-value: not embeddedFor Bun and Node builds, --env-file passes explicit files to the compiler, embeds only PUBLIC_* values, and reaches snapshot preparation. The one other name the bundler rewrites is the reserved CRUST_INTERNAL_BUILD marker.
Embedded PUBLIC_* values are visible in the output. Keep secrets in runtime environment
variables without that prefix.
Deno builds reject --env-file, so load their configuration at runtime instead.
Runtime autoloading
Bun executables built by crust build autoload .env from the directory they start in, but not its bunfig.toml.
Set up package.json
These are the files create-crust --runtime bun|node|deno scaffolds, with {{name}} and the version placeholders filled in. Every runtime shares the same flow: build stages .crust/, start runs .crust/root/bin/<command>.js, and release publishes with crust publish. bin maps the command name to its source entry, which crust build compiles and npm link runs directly through the entry's shebang. The staged packages regenerate bin and files and omit dependency fields. The project itself is "private": true: only the staged packages are published, so a stray npm publish in the project directory is refused instead of uploading the source (private is not copied into the staged packages, and npm link ignores it).
{
"$schema": "./node_modules/@crustjs/crust/schema/package.json",
"name": "{{name}}",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "A CLI built with Crust",
"crust": {
"runtime": "bun"
},
"bin": {
"{{name}}": "src/cli.ts"
},
"scripts": {
"dev": "bun run src/cli.ts",
"build": "crust build",
"release": "crust publish",
"start": "bun .crust/root/bin/{{name}}.js",
"check:types": "tsc --noEmit"
},
"dependencies": {
"@crustjs/core": "^{{crustCoreVersion}}",
"@crustjs/extensions": "^{{crustExtensionsVersion}}"
},
"devDependencies": {
"@crustjs/crust": "^{{crustCliVersion}}",
"@types/bun": "latest",
"typescript": "^7.0.2"
}
}
dev runs the TypeScript source with Bun.