Crust logoCrust

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 crust command 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 bun on PATH, arm64 hosts cannot build their own target. The error names the targets you can build; otherwise install Bun.
  • @crustjs/crust optionally depends on tsdown and @tsdown/exe, which build Node.js binaries with the node on PATH. 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 deno on PATH; Deno runtime packages (experimental) need deno 2.5.0 or newer there.
  • The build() library is a plain JavaScript module, so it needs bun on PATH unless the script runs under Bun. @crustjs/core is a peer dependency so BuildReport is 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

OptionTypeDefaultDescription
--artifactpackage, binarynoneArtifact kind: a runtime package or standalone binaries. Overrides crust.artifact; one of them is required
-t, --targetstring, repeatablenoneCanonical 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-filestring, repeatablenoneExplicit env file(s) used for build-time constants
--validate, --no-validatebooleantrueMaterialize command definitions before compiling; --no-validate also skips Extension build hooks
--minify, --no-minifybooleannoneMinify 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

Dry-run output (excerpt)
crust publish --dry-run
# Publish order: linux-x64 -> ... -> root
#   → linux-x64: npm publish
OptionTypeDefaultDescription
--tagstringnoneOverride the npm dist-tag passed to npm publish
--dry-runbooleanfalsePrint publish order and commands without publishing
--registrystringnoneOverride 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@version with npm view and runs npm publish only if that version is missing. Existing versions are skipped and never re-tagged, so rerunning after a failure resumes. --dry-run stays 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.access from each staged package.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 kindPathExtra fields
package-jsonRoot manifest first, then one per platform packagetarget on platform manifests
launcherroot/bin/<command>.js for binariescommand
executable<platform>/bin/<command>-<target>command, canonical target
bundleroot/bin/<command>.js for runtime packagescommand
  • build() validates bin, the crust block, and its options with the same errors the CLI prints, selects the compiler (checking engines for 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.json is written last.
  • It throws on failure and prints nothing; onLog receives each progress line with its stream.
  • BuildReport is @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.lock directory 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 fieldStaged behavior
name, versionRequired; platform names append the alias, such as @scope/my-cli-linux-x64
npm metadatadescription, license, author, homepage, bugs, repository, keywords, publishConfig, funding, and engines are copied¹
bin, files, typeGenerated for the output; the root always uses "type": "module"
dependencies, devDependenciesOmitted, except crust.external names, copied with their ranges; the compiler bundles everything else
optionalDependenciesGenerated for binary platform packages; absent for runtime packages
exports, peerDependencies, peerDependenciesMetaPreserved in the root only, subject to the rules below
private, source scripts, $schemaOmitted

¹ 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:

  • exports targets must be ./ paths to staged files: a crust.include file, an Extension artifact, or ./package.json. Nested conditions and null are 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.json such as dist/package.json with {"type":"commonjs"}, or explicit .cjs/.d.cts extensions.
  • Peer ranges are published as written: workspace:, catalog:, file:, link:, and portal: ranges are rejected, as are local paths (./native, ../native, /opt/native, ~/native) and local tarballs (native.tgz). peerDependenciesMeta entries 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 targetDeno targetNode.js targetPlatform alias
bun-linux-x64, bun-linux-arm64x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnulinux-x64, linux-arm64linux-x64, linux-arm64
bun-linux-x64-musl, bun-linux-arm64-muslnonenonelinux-x64-musl, linux-arm64-musl
bun-darwin-x64, bun-darwin-arm64x86_64-apple-darwin, aarch64-apple-darwindarwin-x64, darwin-arm64darwin-x64, darwin-arm64
bun-windows-x64, bun-windows-arm64x86_64-pc-windows-msvc, aarch64-pc-windows-msvcwin-x64, win-arm64windows-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)

package.json
{
  "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-validate skips this check.
  • npm link runs 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-crust templates include it. Link .crust/root to exercise the built distribution instead.

package.json configuration

package.json
{
  "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.

KeyDescription
crust.runtimebun, deno, or node. Without it, deno.json/deno.jsonc selects Deno and @types/node without @types/bun selects Node; otherwise Bun.
crust.artifactpackage (a root-only runtime package for bun, node, or, experimentally, deno) or binary (standalone executables for bun, deno, or node). --artifact overrides it.
crust.targetsCanonical 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.bunPluginsBun 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.includeDirectories relative to the project root that crust build copies into .crust/root/ (listed in files) and into each platform package's bin/.
crust.externalPackages 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.

package.json
{
  "$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 bin entry under build in .crust/manifest.json. Loose top-level files are recorded but not copied into packages.
  • With several bin entries, same-named directories merge, so man/greet.1 and man/admin.1 coexist. 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-validate skips all build hooks, so no artifact directories are staged and the manifest has no build field.

Entry modules run during preparation and are not sandboxed; files they write elsewhere are not reported.

On this page