Crust
CLI to build and publish Crust apps as npm packages or standalone binaries
@crustjs/crust provides the crust CLI. crust build stages runtime packages or standalone binaries in .crust/, together with npm packaging metadata. crust publish uploads the npm packages; other distribution channels, such as release downloads or Homebrew, use the built binaries directly. The same build is available to scripts as build(). The Build and distribution guide walks through the workflow; this page is the reference.
Install
npm install -D @crustjs/crust- Installed through npm, the
crustcommand is a Node launcher for a standalone executable with Bun embedded. That launcher requires Node; a separate Bun installation is not needed. This is a build-tool installation requirement, not a requirement for users running your generated binaries directly. - Without
bunonPATH, arm64 hosts cannot build their own target. The error names the targets you can build; otherwise install Bun. @crustjs/crustoptionally depends ontsdownand@tsdown/exe, which build Node.js binaries with thenodeonPATH. Other builds never load them, so an install that skipped them, for example under a Node.js outside tsdown's engines, still builds everything else.- Deno binaries need
denoonPATH; Deno runtime packages (experimental) needdeno2.5.0 or newer there. - The
build()library is a plain JavaScript module, so it needsbunonPATHunless the script runs under Bun.@crustjs/coreis a peer dependency soBuildReportis the same type your Extensions see.
npm installation caveat
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. npm 11.19.1 is verified on Linux with Node 22.23.2. The minimum fixed npm version has not been established; this does not imply every npm 11 release is fixed.
Commands
crust build --artifact binary --target bun-linux-x64
# Runtime: bun (default)
# Artifact: binary
# Compiler: bun 1.4.2 (/usr/local/bin/bun)
# 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 the staged npm packages, not release archives, Homebrew formulas, or other installer metadata.
crust build options
| Option | Type | Default | Description |
|---|---|---|---|
--artifact | package, binary | none | Artifact kind: a runtime package or standalone binaries. Overrides crust.artifact; one of them is required |
-t, --target | string, repeatable | none | Canonical compiler target(s) for binaries, or host for this machine. Omit to stage crust.targets, or every target of the compiler. Rejected for runtime packages |
--env-file | string, repeatable | none | Explicit env file(s) used for build-time constants |
--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) |
Command names, entry files, runtime, default targets, Bun bundler plugins, extra directories, and external dependencies are not 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 publish| 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 |
crust publish reads .crust/manifest.json and fails with Run crust build before crust publish if there is none. It uses npm, which must be on PATH, because npm supports trusted publishing from CI.
- For each staged package, it checks
name@versionwithnpm viewand runsnpm publishonly if that version is missing. Existing versions are skipped and never re-tagged, so rerunning after a failure resumes.--dry-runstays offline. - Before anything publishes, it verifies the manifest, that package names are unique, and that every package directory resolves inside the staging root.
- When stdin is a TTY, npm inherits the terminal so browser or OTP authentication can complete.
- npm reads
publishConfig.accessfrom each stagedpackage.json, copied from your project. Scoped public packages need"publishConfig": { "access": "public" }. See Publishing from CI for credentials.
The version lookup replays the staged publishConfig registry settings to npm view using npm
10.6+ precedence, where CLI flags override publishConfig. npm 9 gives publishConfig priority,
so a conflicting --registry can make the lookup and the upload use different registries. Avoid
that combination or upgrade npm.
Programmatic API
build(options?) runs the crust build pipeline and resolves to what it staged. See Programmatic builds for a release-script example.
Prop
Type
Prop
Type
BuildArtifact kind | Path | Extra fields |
|---|---|---|
package-json | Root manifest first, then one per platform package | target on platform manifests |
launcher | root/bin/<command>.js for binaries | command |
executable | <platform>/bin/<command>-<target> | command, canonical target |
bundle | root/bin/<command>.js for runtime packages | command |
build()validatesbin, thecrustblock, and its options with the same errors the CLI prints, selects the compiler (checkingenginesfor binaries, and tsdown's requirements for Node.js binaries, whose targets' Node.js binaries are also downloaded and unpacked then), then wipes.crust/and stages every package.manifest.jsonis written last.- It throws on failure and prints nothing;
onLogreceives each progress line with its stream. BuildReportis@crustjs/core's type, re-exported.- Overlapping builds for the same real project directory reject before staging, including across processes and symlink aliases. An exclusive
.crust.lockdirectory beside.crust/is removed on success or failure. If a process is interrupted, remove the leftover lock only after confirming no build is running. Consume staged artifacts before rebuilding; the lock does not protect publishing or other readers.
Build output
crust build stages its output in .crust/: manifest.json, root/, one <platform>/ per selected target for binaries, and artifacts/ from Extension build hooks. The tree is ready for npm publishing, but npm is optional: distribute <platform>/bin/ with its adjacent data for direct-binary installation. The executable embeds its runtime; only the npm launcher needs Node.js. Runtime packages instead use root/ and require the user's selected runtime.
The build checks that package.json has non-empty name and version before wiping the previous stage, even with --no-validate. See Build for the tree and Custom distribution for release archives and installers.
manifest.json records the build's runtime and artifact. Binary builds also record embeddedRuntimeVersion, the version the selected compiler reported and embedded in every platform binary; runtime packages embed no runtime and omit it. See Binary runtime version.
Staged package.json files
Staged manifests are generated rather than copied:
| Project field | Staged behavior |
|---|---|
name, version | Required; platform names append the alias, such as @scope/my-cli-linux-x64 |
| npm metadata | description, license, author, homepage, bugs, repository, keywords, publishConfig, funding, and engines are copied¹ |
bin, files, type | Generated for the output; the root always uses "type": "module" |
dependencies, devDependencies | Omitted, except crust.external names, copied with their ranges; the compiler bundles everything else |
optionalDependencies | Generated for binary platform packages; absent for runtime packages |
exports, peerDependencies, peerDependenciesMeta | Preserved in the root only, subject to the rules below |
private, source scripts, $schema | Omitted |
¹ Node.js binaries omit engines.node, which constrained the Node.js that built and is embedded in the executables, from platform packages, and from the root unless it carries exports, whose importers need it. Other engines keys are copied.
README.md is copied into the root package and the license file into every package. Platform manifests carry os, cpu, and, on Linux, libc.
To ship a library alongside the CLI:
exportstargets must be./paths to staged files: acrust.includefile, an Extension artifact, or./package.json. Nested conditions andnullare supported; wildcards, fallback arrays, missing files, and.,..,node_modules, or empty segments are rejected.- Included files are copied, not transformed. A CommonJS source package needs an included nested
package.jsonsuch asdist/package.jsonwith{"type":"commonjs"}, or explicit.cjs/.d.ctsextensions. - Peer ranges are published as written:
workspace:,catalog:,file:,link:, andportal:ranges are rejected, as are local paths (./native,../native,/opt/native,~/native) and local tarballs (native.tgz).peerDependenciesMetaentries must name declared peers.
Supported targets
Targets apply to binaries. --target overrides crust.targets; without either, all targets of the compiler are built. host selects this machine's OS, architecture, and Linux C library and can be mixed with explicit targets. Runtime packages reject both --target and crust.targets.
| Bun target | Deno target | Node.js target | Platform alias |
|---|---|---|---|
bun-linux-x64, bun-linux-arm64 | x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu | linux-x64, linux-arm64 | linux-x64, linux-arm64 |
bun-linux-x64-musl, bun-linux-arm64-musl | none | none | linux-x64-musl, linux-arm64-musl |
bun-darwin-x64, bun-darwin-arm64 | x86_64-apple-darwin, aarch64-apple-darwin | darwin-x64, darwin-arm64 | darwin-x64, darwin-arm64 |
bun-windows-x64, bun-windows-arm64 | x86_64-pc-windows-msvc, aarch64-pc-windows-msvc | win-x64, win-arm64 | windows-x64, windows-arm64 |
Node.js targets use Node.js's own release names. Cross-platform builds download that target's official Node.js archive of the same version; see Node.js binaries for the unpacking tools and macOS signing.
Each platform package contains bin/<command>-<target> (.exe on Windows) per command. The launcher supports only the targets included in the build.
Linux glibc and musl
Plain Linux targets use glibc; Alpine needs Bun's -musl targets. The launcher detects the host C library and selects the matching binary. Deno and Node.js have no musl target.
Bun's musl executables still load libstdc++ and libgcc dynamically. A bare Alpine image needs
apk add libstdc++ libgcc; node:alpine already includes them.
Commands and entries (bin)
{
"bin": { "my-cli": "src/cli.ts", "my-cli-admin": "src/admin.ts" }
}Each bin key is an installed command name and each value is the TypeScript entry compiled for it. Every entry becomes bin/<command>.js in the root package and, for binaries, a <command>-<target> binary in every platform package. A string bin names one command after the unscoped package name; without bin, the build compiles src/cli.ts under that name.
- Entries must be relative paths to existing files inside the project. Containment is checked lexically; symlinks may point elsewhere. Two commands cannot share one entry (compared by real path).
- Command names follow the installed command name rule: letters, digits,
.,_, and-, starting with a letter or digit. They cannot differ only by case. - A validated build requires each entry's root command to be named after its key, such as
new Crust("my-cli-admin")for"my-cli-admin": "src/admin.ts".--no-validateskips this check. npm linkruns the source entry directly, so it needs the runtime's shebang (#!/usr/bin/env bun,#!/usr/bin/env node, or#!/usr/bin/env -S deno run -A).create-crusttemplates include it. Link.crust/rootto exercise the built distribution instead.
package.json configuration
{
"crust": {
"runtime": "bun",
"artifact": "binary",
"targets": ["bun-linux-x64", "bun-linux-arm64", "bun-darwin-arm64"],
"bunPlugins": ["@opentui/solid/bun-plugin", "./build/plugin.ts"],
"include": ["templates"]
}
}crust.artifact is required unless every build passes --artifact; the other keys are optional. Unknown keys under crust fail the build, so typos such as bunPlugin are not ignored.
| 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.artifact | package (a root-only runtime package for bun, node, or, experimentally, deno) or binary (standalone executables for bun, deno, or node). --artifact overrides it. |
crust.targets | Canonical compiler targets staged when a binary build runs without --target, host included. Without it every target of the compiler is staged. Rejected for runtime packages. |
crust.bunPlugins | Bun bundler plugin modules applied to Bun builds and Node runtime packages, in order. A bare name resolves from node_modules, a path from the project root; each must default-export the plugin. Rejected for Deno and Node binaries. |
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.external | Packages a Node or Bun runtime package leaves out of its bundles and ships as dependencies of the staged root package, with their dependencies ranges, in this order. See Keep dependencies external. Rejected for binaries and Deno. |
crust.include entries must be existing directories inside the project, cannot be named bin, cannot be inside .crust, and cannot reuse an Extension artifact directory name. Names are compared ignoring letter case (Bin, Assets vs assets) because they alias on case-insensitive file systems. Symlinks are copied dereferenced and must resolve inside the project.
Each crust.external entry must be listed once and be a key of dependencies; a package only in devDependencies, optionalDependencies, or peerDependencies is rejected, as are subpaths and wildcards. Its range must be publishable, by the same rules as peer ranges above. @crustjs/* packages, and npm aliases of them such as npm:@crustjs/utils@0.2.2, cannot be external: core detects a built package through a value the bundler inlines, and an external Crust package would load a second copy of core. An empty list does nothing. These checks run before .crust/ is wiped.
Editor completion
@crustjs/crust ships a JSON schema for this block. It extends the SchemaStore package.json schema, so completion for the standard fields keeps working. create-crust templates set it; crust build never copies $schema into staged packages.
{
"$schema": "./node_modules/@crustjs/crust/schema/package.json"
}Extension artifacts
.crust/artifacts/
├── man/<name>.1
├── skills/
└── completions/Registered Extensions such as man(), skill(), and completion() contribute build hooks. crust build runs those hooks while preparing each entry, writes the files they return, and copies each top-level directory into .crust/root/ and every platform package's bin/. A CLI reads them back with resolveArtifactDir(name).
- Each hook's returned paths are recorded per
binentry underbuildin.crust/manifest.json. Loose top-level files are recorded but not copied into packages. - With several
binentries, same-named directories merge, soman/greet.1andman/admin.1coexist. A path two entries both write fails the build naming both commands; collisions are compared case-insensitively. - Upgrade
@crustjs/core,@crustjs/crust, and build-hook Extensions together. Reports from an incompatible Core fail preparation with upgrade guidance. --no-validateskips all build hooks, so no artifact directories are staged and the manifest has nobuildfield.
Entry modules run during preparation and are not sandboxed; files they write elsewhere are not reported.