Build and distribution
Configure, build, test, and publish your CLI.
crust build turns your TypeScript entry into a runtime package (JavaScript for the user's installed runtime) or standalone executables (with the runtime embedded). The examples below use the my-cli command from Quick Start.
Building does not commit you to npm. Distribute the same binaries through npm, release downloads, Homebrew, Scoop, or your own installer. Running a binary directly needs no separately installed Node.js, Bun, or Deno. Node.js is required only by the generated npm launcher. crust publish handles npm; other channels use the built files with your own release tooling.
Set up package.json
Scaffolded projects already have the build tooling and scripts. For an existing project, install the tool:
npm install -D @crustjs/crustMerge these fields into your package.json, keeping your dependencies:
{
"name": "my-cli",
"version": "0.1.0",
"private": true,
"type": "module",
"bin": { "my-cli": "src/cli.ts" },
"crust": { "runtime": "bun", "artifact": "binary" },
"scripts": {
"build": "crust build",
"start": "node .crust/root/bin/my-cli.js",
"release": "crust publish"
}
}The bin key must match your root command: new Crust("my-cli"). "private": true prevents accidentally publishing the source project; Crust generates separate publishable packages in .crust/.
crust.runtime is the runtime your CLI runs on; without it Crust infers one. crust.artifact is required and chooses what the build distributes, independently of the runtime:
| Runtime | "artifact": "package" | "artifact": "binary" |
|---|---|---|
bun | Bun-targeted JavaScript; users need Bun | Standalone executable per platform, with Bun embedded |
node | Node.js-targeted JavaScript; users need Node.js | Standalone executable per platform, with Node.js embedded |
deno | Experimental Deno-bundled JavaScript; users need Deno | Standalone executable per platform, with Deno embedded |
Crust stages npm metadata for either artifact kind, even if you never publish to npm. Binary builds include a Node.js launcher for npm distribution; direct binary distribution does not use it. Runtime packages contain no embedded runtime: for example, Bun packages use #!/usr/bin/env bun, whether installed by npm or Bun. --artifact package or --artifact binary overrides crust.artifact for one build.
The commands below assume Node.js and Bun are installed; Deno builds also need deno on PATH, and Node.js binaries need a node on PATH that tsdown can build executables with. See tooling requirements and runtime versions.
Build
For a quick local binary build, compile only for your machine:
npm run build -- --target hostRuntime packages have no targets: run npm run build without --target.
Builds replace .crust/; add it to .gitignore. On Linux x64 with Bun binaries selected, the output looks like this:
.crust/
├── manifest.json # package paths and publish order
├── root/ # root package for npm distribution
│ ├── package.json
│ └── bin/my-cli.js # npm launcher; needs Node.js
└── linux-x64/
├── package.json
└── bin/my-cli-bun-linux-x64 # standalone executableRuntime packages omit the platform directories; root/bin/my-cli.js is the bundle itself. Generated manifests describe which metadata and files are shipped.
Choose release targets
# Build all supported targets, or the subset in crust.targets
npm run build
# Override the subset for this build (Bun example)
npm run build -- --target bun-linux-x64 --target bun-darwin-arm64Publish every platform you intend to support—not just host. Set crust.targets to keep a release subset in configuration. See supported targets, including musl targets and prerequisites for Alpine Linux.
Binary runtime version
Every binary embeds the version of the compiler that builds it: the first bun on PATH, else the Bun inside the crust executable; for Deno, deno on PATH; for Node.js, node on PATH. The build prints it as Compiler: and records it as embeddedRuntimeVersion in .crust/manifest.json, for cross-platform targets too.
When package.json declares engines.bun, engines.deno, or engines.node, that compiler must satisfy the semver range, or the build fails before replacing .crust/. A compiler whose --version does not exit within 30 seconds fails the build the same way, with cleanup targeting its POSIX process group or live Windows process tree. Descendants that leave the POSIX group or outlive their parent on Windows can escape cleanup. An exact version is a requirement, not a download request: Crust never installs or upgrades a compiler, so pin one with your version manager or CI. Runtime packages embed no runtime; their engines only describes what users need.
When the calling process runs under Deno, POSIX process-group cleanup requires unrestricted --allow-run. With an executable-scoped grant such as --allow-run=bun, timeout cleanup still terminates the direct child but reports the group-cleanup failure; descendants may survive.
Node.js binaries
Crust builds Node.js binaries with tsdown's executable support (Node.js single executable applications). @crustjs/crust ships tsdown and @tsdown/exe as optional dependencies, so no global tsdown is needed. Package managers skip them when optional dependencies are disabled or the Node.js running the install is outside tsdown's engines, instead of failing the install; other builds do not need them, and a Node.js binary build then reports the missing backend before replacing .crust/. It never falls back to a project's own tsdown of another version: only the versions @crustjs/crust pins are used. Reinstall @crustjs/crust with optional dependencies enabled under a Node.js that meets tsdown's engines, such as the one you build with. The node on PATH runs the build and is embedded, so it must satisfy both your engines.node and the installed tsdown's own requirements: its package engines and its executable builder's minimum. With the bundled tsdown 0.23 that means Node.js 26 or later. Crust reads both requirements from tsdown and reports which one fails, before replacing .crust/.
These are build requirements, not your users' requirements: engines.node can keep your own range (for example >=24) as long as the build machine's Node.js satisfies it. Binary packages do not repeat engines.node, since running the executable needs no Node.js; the npm launcher keeps its usual Node.js requirement. A root package that also publishes library exports keeps engines.node, because consumers import that code on their own Node.js.
Every command is its own single-entry executable. Bun first bundles it for Node.js exactly like a Node.js runtime package, so import.meta.main and require.main === module stay true only in the command's own entry (ESM or CommonJS) and env files embed the same PUBLIC_* values; tsdown then turns that bundle into the executable. Bun is therefore required even with --no-validate, checked before replacing .crust/. Application dependencies are bundled into it; a build fails if an import other than a Node.js built-in would be left for the runtime to resolve. Native addons, computed imports, crust.bunPlugins, and crust.external are not supported.
For each target, tsdown downloads the official Node.js archive of exactly that version once, verifies its checksum, unpacks it, and caches it. Crust does this for every target before replacing .crust/, so a failed download or a missing unpacking tool keeps the previous build. Archives are unpacked with the host's tar, except Windows targets on non-Windows hosts, which need unzip; GNU tar, as on most Linux hosts, also needs xz for Linux targets. On Windows, put the native ZIP-capable tar before Git Bash's GNU tar on PATH (for example, use PowerShell with the standard Windows path). Cached targets need none of these tools. macOS executables are only code-signed when built on macOS; sign cross-built ones yourself before running them on Apple silicon.
Run locally
Choose the entry you intend to distribute:
# npm's launcher route; requires Node.js:
npm run start -- --help
# Equivalent with the configuration above:
node .crust/root/bin/my-cli.js --help
# Direct executable for the Linux x64 Bun example above; no installed JS runtime:
./.crust/linux-x64/bin/my-cli-bun-linux-x64 --helpThe direct executable's name depends on your command and target; Windows executables end in .exe.
Run the staged entry before publishing. Binary launchers find their executables in the sibling platform directories; a runtime package's bundle runs directly, so start a Bun package with bun .crust/root/bin/my-cli.js, or a Deno package with deno run --allow-read=.crust/root .crust/root/bin/my-cli.js plus the permissions your CLI needs.
To test through npm link, link from .crust/root. Linking from the project root runs the source entry instead.
Deno runtime packages (experimental)
With "crust": { "runtime": "deno", "artifact": "package" }, crust build bundles each command with Deno's native deno bundle, which Deno itself marks experimental. The package embeds no runtime: users need Deno and run it through Deno's npm support, not through Node.js.
To build, you need:
deno2.5.0 or newer onPATH. Crust reads its version in the project directory and fails before replacing.crust/if it is older, because olderdeno bundleresolvespackage.jsondependencies from the registry instead of your installednode_modules. Crust never installs or upgrades Deno. It prints the bundler asCompiler:but records noembeddedRuntimeVersion, andengines.denostays a requirement for your users.- Your dependencies installed as Deno resolves them:
package.jsonwith itsnode_modules,deno.jsonimports, or both.deno bundleapplies your Deno configuration itself; for example,"nodeModulesDir": "none"resolves npm packages from Deno's cache and registry instead ofnode_modules. - Imports that also resolve under Bun: build preparation runs your entry under Bun (
bunonPATH, else the Bun inside thecrustexecutable), as it does for Deno binaries. Entry imports must resolve there too, so mapdeno.jsonimportsto the same names as thenode_modulespackages, or build with--no-validate, which also skips Extension artifacts. @crustjs/corefrom the same release as@crustjs/crust, or newer. The bundle includes your installed core, and older releases do not recognize Crust's Deno package marker: their packages need--allow-envand obey build-only snapshot variables.- Network access on a cold Deno cache:
deno bundledownloads its bundler once.
--minify, --env-file, crust.bunPlugins, crust.external, --target, and crust.targets are rejected. Load configuration at runtime instead, for example with deno run --env-file.
Each command becomes bin/<command>.js, with no shebang and no permission flags. The declared command retains its import.meta.main behavior, so it can guard its execute() call; imported dependencies are not main modules. After publishing, users run a command by package and bin name, and grant the permissions it needs:
# The bin named after the package can be omitted: deno run npm:my-cli
deno run --allow-read --allow-env=NO_COLOR,FORCE_COLOR,COLORTERM,TERM npm:my-cli@0.1.0/my-cli --help
# Install globally with the same grants
deno install -g --allow-read --allow-env=NO_COLOR,FORCE_COLOR,COLORTERM,TERM -n my-cli npm:my-cli@0.1.0/my-cli
my-cli --helpCrust never adds -A or any other grant: permissions belong to your users and depend on your CLI. Reading files shipped with crust.include or Extension artifacts through resolveArtifactDir() needs read access to the installed package, which is inside Deno's npm cache for deno run npm:; for deno install -g it is in that cache or, on newer Deno versions, in the install root. Terminal styling such as help() reads NO_COLOR, FORCE_COLOR, COLORTERM, and TERM, so it needs --allow-env for those names. Finished packages never read Crust's internal build variables, so they add no environment permission of their own.
The package is not a Node.js package: npm install -g installs a bin that Node.js cannot run, and npx is not supported. Building, packing, and running through deno run npm: and deno install -g from a local registry has been tested on Linux x64 with Deno 2.5.0, 2.6.0, 2.7.0, 2.8.0, and 2.9.6. Code that only works on another runtime, dependencies deno bundle cannot inline, and dynamic imports it cannot follow are not made portable.
Publish with crust publish
Use a package name you own and a new version, then build your release targets:
npm run build
npm run release -- --dry-run
# Publish order: ... -> root
npm login
npm run releaseThe dry run validates staged metadata and prints the plan without contacting the registry. Real publishing uses npm, uploads platform packages before the root, and stops on failure. Rerun to resume: existing name@version pairs are skipped, not overwritten or re-tagged.
For a scoped public package, add "publishConfig": { "access": "public" } to your project's package.json before building. Users install only the root package:
npm install -g my-cli
my-cli --helpFor binaries, the package manager selects the matching platform dependency. See the npm installation caveat for an affected npm version and a verified alternative.
Publishing from CI
For GitHub Actions trusted publishing, configure a trusted publisher on npm for the root and every platform package, then give the release job:
permissions:
contents: read
id-token: writeAfter installing dependencies and building the release targets, run npm run release; do not run npm login in CI.
npm runs inside each staged package, so it does not read the project's .npmrc. If you use a credentials file instead, select it explicitly:
NPM_CONFIG_USERCONFIG="$PWD/.npmrc" npm run releaseKeep credentials out of version control. Use --registry or --tag through the release script as needed; see publish options and registry precedence.
Custom distribution
For standalone binaries, distribute the contents of .crust/<platform>/bin/: the executable for each command and its adjacent data directories. You do not need the npm root launcher or npm manifests to run these files. Users need a compatible OS, architecture and system libraries, plus any external tools your own application calls—not a separate JavaScript runtime.
Release downloads
With crust.runtime set to "bun", build and archive a Linux x64/glibc release:
npm run build -- --artifact binary --target bun-linux-x64
tar -czf my-cli-linux-x64.tar.gz -C .crust/linux-x64/bin .
sha256sum my-cli-linux-x64.tar.gzUpload the archive and its checksum to your release host, such as GitHub Releases. Create a separate archive for each supported platform; include your license and any other required distribution files. Users verify the checksum, extract the matching archive, and run the executable directly:
mkdir my-cli
tar -xzf my-cli-linux-x64.tar.gz -C my-cli
./my-cli/my-cli-bun-linux-x64 --helpThe same approach works for Node.js and Deno binaries using their target names and generated filenames. Preserve executable permissions, keep shipped directories next to the executable, and apply any required platform signing before creating the archive.
Homebrew, Scoop, and other installers
These channels use the same release archives; they do not need Crust's npm resolver:
- Build and host an archive for each platform you support.
- Create a Homebrew formula or Scoop manifest that selects the matching archive and verifies its checksum.
- Install the executable and its adjacent data directories together, then expose the command using the installer's link or shim mechanism. Do not move the executable away from its data directories.
- Test the installed command from an unrelated working directory, including commands that read shipped assets.
The installer supplies platform selection instead of the npm launcher, so running the installed binary does not require Node.js. Crust does not generate formulas/manifests or publish to these channels; maintain them with your own release tooling. You can offer npm and direct-binary installation for the same build.
Runtime packages can also be archived, but must retain the contents/layout of .crust/root/ and still require their selected runtime. Copy or archive any output before the next build replaces .crust/.
Customize the build
Ship extra directories with crust.include
{
"crust": { "include": ["templates"] }
}Create templates/ in your project, then build. Read the shipped copy with resolveArtifactDir("templates") from @crustjs/core, rather than a path relative to the working directory. It works from the staged tree, an npm install, or a standalone executable. See artifact paths.
Extensions such as man(), skill(), and completion() generate their own directories during build preparation. That step runs entry-module top-level code, but not Command Actions. --no-validate skips preparation and those Extension artifacts; see Extension artifacts.
Keep dependencies external
Runtime packages bundle every dependency by default. crust.external keeps the named packages out of the bundle and also ships them: they become dependencies of the staged root package, with their ranges from your dependencies, so npm installs them beside the bundle.
{
"dependencies": { "better-sqlite3": "^12.0.0", "typescript": "7.0.2" },
"crust": {
"runtime": "node",
"artifact": "package",
"external": ["better-sqlite3", "typescript"]
}
}Use it for packages the CLI imports, runs, or reads files from at runtime:
- native addons such as
better-sqlite3, whose compiled binary cannot be bundled; - packages the CLI spawns or reads files from, such as resolving
typescript/package.jsonand running itstsc; these need not be imported at all; - very large dependencies you would rather install than bundle.
crust.external applies to Node and Bun runtime packages only; binaries and Deno reject it. Each entry must be a dependencies key with a publishable range, and @crustjs/* packages must stay bundled; see package.json configuration for the rules and staged package.json files for the output.
Validation executes each entry under Bun before bundling, with dependencies resolved from your project, so externals must be installed when you build. An entry that does not run a Crust app from @crustjs/core has no Command Snapshot and needs --no-validate.
Limits:
- A named ESM import of a CommonJS external, such as
import { open } from "some-cjs-package", may fail on Node.js. Use the default import (import pkg from "some-cjs-package") orrequire(). - A third-party package that depends on
@crustjs/coreshould stay bundled; as an external it would load its own copy of core.
Environment variables
Keep secrets in runtime variables; use PUBLIC_* only for values safe to embed. Env files apply to Bun and Node.js builds of either artifact. This entry reads one of each:
//#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
PUBLIC_API_ORIGIN=https://api.example.comFor Bun or Node.js builds (here Bun binaries behind the Node launcher):
npm run build -- --env-file .env.production
node .crust/root/bin/my-cli.js origin
# https://api.example.com
API_TOKEN=your-token node .crust/root/bin/my-cli.js
# DeployingOnly PUBLIC_* variables are embedded from env files; anyone with the output can read them. Deno
builds reject --env-file—supply their configuration at runtime instead.
Bun executables also load .env from their working directory at startup, but do not load that directory's bunfig.toml.
Add Bun bundler plugins
{
"crust": { "bunPlugins": ["@opentui/solid/bun-plugin"] }
}Plugins apply to Bun binaries and to Bun and Node.js runtime packages, not build preparation; Deno builds and Node.js binaries reject them. If entry imports need the same transform during preparation, register a runtime plugin in bunfig.toml preload too. See plugin configuration and the TUI setup.
Programmatic builds
import { build } from "@crustjs/crust";
const result = await build({
artifact: "binary", // overrides crust.artifact
onLog: (line) => console.log(line),
});
console.log(result.stageDir); // <cwd>/.crustRun with Bun, or with Node.js and bun on PATH; Node.js binaries also need a qualifying node on PATH. build() uses the same configuration as the CLI and returns artifact paths and Extension reports; it throws on failure. See the options and result types.