Error handling
Report failures, clean up resources, and handle cancellation.
Throw an actionable Error. At the CLI entry point, execute() writes its message to stderr and sets exit status 1:
import { Crust } from "@crustjs/core";
const app = new Crust("deploy").action(() => {
throw new Error("Could not connect to api.example.com. Check your network connection.");
});
await app.execute();
$ bun cli.ts
Error: Could not connect to api.example.com. Check your network connection.
# exit 1execute() resolves to the exit code rather than rethrowing the error. For tests and programmatic callers, use run() to inspect failures without rendering them.
- Don't catch an error just to print it: that hides the failure from Crust and can leave the exit status at 0.
- Don't call
process.exit(): it stops the process before cleanup can finish.
Let Crust validate
Declare required values and allowed choices instead of checking them in the Command Action:
export const declared = new Crust("greet")
.flags({
name: "name",
type: "string",
required: true,
choices: ["Ada", "Grace"],
})
.action(({ flags, stdout }) => stdout(`Hello, ${flags.name}`));$ greet
Error: Missing required flag "--name"
$ greet --name Linus
Error: Invalid value "Linus" for --name. Expected one of: Ada, GraceBoth exit with status 1 before the action runs. These declarations also drive help text and inferred types. See Values and validation for custom parsing and schemas.
Fail from anywhere
Throw from actions, awaited helpers, or Context setup. Crust still disposes constructed Contexts:
import { defineContext } from "@crustjs/core";
const database = defineContext("database").setup(({ stderr }) => ({
[Symbol.dispose]() {
stderr("Closed database");
},
}));
function deployRelease() {
throw new Error("Deployment service is unavailable. Try again later.");
}
export const deploy = new Crust("deploy").provide(database()).action(async ({ ctx }) => {
await ctx.database;
deployRelease();
});$ deploy
Error: Deployment service is unavailable. Try again later.
Closed database
# exit 1Context-managed resources don't need action-level try/finally. If disposal also fails, Crust adds Cleanup failed: … without hiding the original failure. See failure propagation for lifecycle details.
Customize the message
Use an Extension's onError hook to add context for a known error:
import { defineExtension, defineExtensionId } from "@crustjs/core";
class ConfigError extends Error {}
const configErrors = defineExtension(defineExtensionId("config-errors")).onError(
(error, { stderr }) => {
if (!(error instanceof ConfigError)) return;
stderr(`Error: ${error.message}`);
stderr("Hint: Run init to create the config file.");
return true;
},
);
const configured = new Crust("app").extend(configErrors).action(() => {
throw new ConfigError("Config file not found.");
});Error: Config file not found.
Hint: Run init to create the config file.- Return
trueafter rendering to stop later hooks and the default renderer. - Return nothing to pass the error to the next hook in
.extend()order, then the default renderer. - Hooks only run for
execute(). Rendering thisConfigErrorstill exits with status 1; it doesn't turn failure into success.
didYouMean() uses this hook for command suggestions. See onError for the full contract.
Cancellation
Ctrl+C in a Crust prompt raises an AbortError. By default, execute() prints no error and returns exit code 130:
import { mkdir, writeFile } from "node:fs/promises";
import { input } from "@crustjs/prompts";
async function createProject() {
const name = await input({ message: "Project name?" });
await mkdir(name);
await writeFile(`${name}/package.json`, "{}");
}
const prompted = new Crust("scaffold").action(createProject);Keep side effects after prompts so cancelling leaves nothing to undo. To cancel explicitly, throw new DOMException("Cancelled", "AbortError").
The first Ctrl+C during execute() aborts the action's signal. Pass it to cancellable APIs such as fetch(url, { signal }); work that ignores it keeps running. An AbortError still allows cleanup; failed cleanup changes the exit code to 1. See execute() for caller signals and repeated Ctrl+C.
Handle errors in code
run() resolves to a failed outcome instead of rendering the error or changing process.exitCode. Using the configured app above:
const outcome = await configured.run([]);
if (outcome.status === "failed" && outcome.error instanceof ConfigError) {
console.log(outcome.error.message); // Config file not found.
}The outcome preserves the original error unless disposal also fails; then a SuppressedError contains both failures.
Framework errors use CrustError. Check error instanceof CrustError, then .is(code)—not message text. Application errors don't need this wrapper.
To assert rendered messages and exit codes, use captureExecute() from @crustjs/testing.