Crust
Build and distribute Crust applications for Bun, Deno, and Node.
@crustjs/crust provides the crust CLI. crust build writes the publishable npm packages for your CLI to .crust/, and crust publish uploads them.
Install
npm install -D @crustjs/crustThe npm package is itself built with crust build, so its bin is a Node launcher that runs a platform-specific standalone executable with Bun embedded. Node is required to start it; a separate Bun installation is not. Without bun on PATH, arm64 hosts cannot build their own target (Bun would copy the running crust executable onto itself); the error names the other targets, or install Bun.
Commands
crust build --target bun-linux-x64
# Runtime: bun (default)
# Staging 1 distribution target(s) in .../.crust...
# → linux-x64: .../.crust/linux-x64/bin/my-cli-bun-linux-x64
# ✓ Staged 2 npm package(s) successfully: (excerpt)crust build validates your CLI and stages it in .crust/. crust publish publishes what crust build staged.
crust build options
| Option | Type | Default | Description |
|---|---|---|---|
-t, --target | string, repeatable | none | Canonical compiler target(s), or host for this machine; repeatable. Omit to stage all Bun/Deno targets |
--env-file | string, repeatable | none | Explicit env file(s) used for build-time constants; repeatable |
--validate, --no-validate | boolean | true | Materialize command definitions before compiling; --no-validate also skips Extension build hooks |
--minify, --no-minify | boolean | none | Minify the output (default for bun and node; unsupported for deno) |
None of the command names, entry files, runtime, Bun bundler plugins, or extra directories are flags: they come from bin and the crust block in package.json.
crust publish options
crust publish --dry-run
# Publish order: linux-x64 -> ... -> root
# → linux-x64: npm publishPublishing reads .crust/manifest.json, verifies the staged directories and metadata, checks each staged name@version on the target registry with npm view, and runs npm publish in each staged directory whose version is missing, so npm must be on PATH. Versions that already exist are skipped and never re-tagged, which makes a rerun after a failed publish resume; the lookup is not a permission check (trusted publishing supplies no read credential), it targets the registry npm publish would under npm 10.6+ precedence (see Publishing from CI), and --dry-run stays offline. npm is used instead of bun publish because it supports trusted publishing from CI; bun publish does not. Without a prior crust build, it fails with Run crust build before crust publish.
When stdin is a TTY, npm inherits stdin, stdout, and stderr so its interactive browser/OTP authentication can complete. Non-interactive runs capture and forward npm output after each package finishes.
Before any publisher runs (including dry-run validation), manifest fields and staged package identities are checked, root and platform package names must be unique, and every listed package directory must resolve to a distinct directory inside the canonical staging root. A symlinked staging root is allowed; descendant directory or package.json links escaping it are rejected. This preflight is not protection against concurrent filesystem changes.
npm reads publishConfig.access from each staged package.json, which crust build copies from your project. Scoped public packages need "publishConfig": { "access": "public" }. See Publishing from CI for registry credentials.
| Option | Type | Default | Description |
|---|---|---|---|
--tag | string | none | Override the npm dist-tag passed to npm publish |
--dry-run | boolean | false | Print publish order and commands without publishing |
--registry | string | none | Override the registry passed to npm publish |
Build output
crust build stages the publishable npm tree in .crust/ (manifest.json, root/, one <platform>/ per target for Bun and Deno, and artifacts/ from Extension build hooks). It checks that package.json has non-empty string name and version fields before wiping the previous stage, even with --no-validate. See Build for the tree, the launcher, and naming rules.
Commands and entries (bin)
{
"bin": { "my-cli": "src/cli.ts", "my-cli-admin": "src/admin.ts" }
}The standard bin field declares what crust build builds: each key is an installed command name and each value is the TypeScript source entry compiled for it. Every entry becomes bin/<command>.js in the root package and, for Bun and Deno, a <command>-<target> binary in every platform package. A string bin is the entry of one command named after the unscoped package name, and without bin the build compiles src/cli.ts under that name.
Entries must be relative paths to existing files inside the project (checked lexically, so a symlink may point elsewhere), and two commands cannot share one entry: entries are compared by real path, so ./src/cli.ts, src/../src/cli.ts, and a symlink to src/cli.ts all count as the same file. Command names are limited to letters, digits, ., _, ~, and -, cannot start with . or -, and cannot differ only by case (Tool and tool would be one bin/ file on a case-insensitive filesystem), because they become file names and launcher text. These checks run before .crust/ is wiped, with or without --no-validate. Because the values are source files, npm link runs the source directly: the entry needs the runtime's shebang (#!/usr/bin/env bun, #!/usr/bin/env node, or #!/usr/bin/env -S deno run -A), which create-crust templates include; link .crust/root instead to exercise the built distribution.
During a validated build each entry's root command must be named after its key (new Crust("my-cli-admin") for "my-cli-admin": "src/admin.ts"), since help, man pages, completions, and skills all use that name; a mismatch fails the build with both names and the entry path. --no-validate skips this check along with the snapshots it needs.
package.json configuration
{
"crust": {
"runtime": "bun",
"bunPlugins": ["@opentui/solid/bun-plugin", "./build/plugin.ts"],
"include": ["templates"]
}
}Every key is optional. Any other key under crust is an error that lists the allowed keys, so typos such as bunPlugin fail the build instead of being ignored. The entry file is not configured here: it is the value of bin.
| Key | Description |
|---|---|
crust.runtime | bun, deno, or node. Without it, deno.json/deno.jsonc selects Deno and @types/node without @types/bun selects Node; otherwise Bun. |
crust.bunPlugins | Bun bundler plugin modules applied to Bun and Node builds, in order. A bare name resolves from node_modules, a path from the project root; each must default-export the plugin. Rejected for Deno. |
crust.include | Directories relative to the project root that crust build copies into .crust/root/ (listed in files) and into each platform package's bin/. |
crust.include entries must be existing directories inside the project (symlinks are copied dereferenced, so every symlink inside them must also resolve inside the project), cannot be named bin, cannot be inside .crust, and cannot repeat an Extension artifact directory name.
Editor completion
@crustjs/crust ships a JSON schema for this block at node_modules/@crustjs/crust/schema/package.json. It extends the standard SchemaStore package.json schema with the crust keys above, so pointing $schema at it keeps completion for name, scripts, and the rest while adding completion and validation for crust. create-crust templates set it; crust build never copies $schema into the staged packages.
{
"$schema": "./node_modules/@crustjs/crust/schema/package.json"
}Extension artifacts
.crust/artifacts/
├── man/<name>.1
├── skills/
└── completions/Registered Extensions own build artifacts. man(), skill(), and completion() return the files shown when registered; crust build writes them, copies each top-level directory into .crust/root/ and every platform package's bin/, and records each hook's returned paths per bin entry under build in .crust/manifest.json. These reports describe hook output, not all packaged files or arbitrary module side effects; entry execution is not sandboxed. Loose top-level artifact files are reported but not copied into packages. A CLI reads the directories back with resolveArtifactDir(name).
With several bin entries, each entry's hooks run in their own temporary directory and the results are merged into .crust/artifacts/. Identically spelled directories merge, so man/greet.1 and man/admin.1 coexist. Case-only directory aliases such as Assets/a and assets/b are rejected at every depth to keep the layout portable. A path two entries both write (or a file in one and a directory in another) fails the build naming both commands. These collisions are checked case-insensitively across entries, including file/ancestor conflicts. Unexpected symlinks and nonregular entries are rejected rather than copied.
Upgrade the application's @crustjs/core, @crustjs/crust, and build-hook Extensions together to the pure-return build API. Malformed or legacy subprocess reports (such as files: "unknown") fail preparation with compatible-Core guidance; there is no writer-hook compatibility shim. Report IDs must be nonblank and trimmed, and each reported path must be normalized, contained in the entry output directory, and name an existing regular file. This does not require all entry side-effect files to be reported or make entry execution a sandbox. An executed hook returning [] remains a valid, empty report. Failed preparation produces no completed manifest.
--no-validate skips snapshot preparation and all Extension build hooks, so no artifact directories are staged and the manifest has no build field.