Crust logoCrust

Lifecycle & Hooks

Understand the command execution lifecycle and preRun, run, postRun hooks.

Every command supports three lifecycle hooks: .preRun(), .run(), and .postRun(). These let you add initialization and cleanup logic around your main command handler.

Lifecycle Hooks

import { Crust } from "@crustjs/core";

const cmd = new Crust("deploy")
  .preRun(({ args, flags }) => {
    console.log("Validating configuration...");
  })
  .run(({ args, flags }) => {
    console.log("Deploying...");
  })
  .postRun(({ args, flags }) => {
    console.log("Cleaning up...");
  });

Execution Order

preRun  ->  run  ->  postRun
  1. .preRun() — Called before run(). Use for initialization, validation, or setup
  2. .run() — The main command handler
  3. .postRun() — Called after run(), even if run() throws. Use for cleanup or teardown

postRun runs in a finally block, so it executes even when run() throws an error. The error is still re-thrown after postRun completes.

Async Support

All hooks support async functions:

new Crust("migrate")
  .preRun(async () => {
    await checkDatabaseConnection();
  })
  .run(async () => {
    await runMigrations();
  })
  .postRun(async () => {
    await closeDatabaseConnection();
  })

Hook Context

Each hook receives the same CrustCommandContext object:

interface CrustCommandContext<A, F> {
  args: InferArgs<A>;       // Parsed positional arguments
  flags: InferFlags<F>;     // Parsed flags (inherited + local merged)
  rawArgs: string[];         // Arguments after `--`
  command: CommandNode;      // The resolved command node
}

All hooks see the same parsed input — there's no data transformation between hooks.

Full Execution Lifecycle

When you call .execute(), Crust runs through four phases:

1. Initialization

Plugins are collected from the command tree (root-first, depth-first) and each setup() hook runs sequentially. If any setup() throws, the lifecycle aborts immediately. After setup, the command tree is frozen and plugin warnings are surfaced.

2. Routing & Parsing

resolveCommand() walks argv against the subcommand tree, then parseArgs() parses and coerces args and flags for the matched command. Parsing errors (unknown flags, bad types) still pass through the middleware chain. Required-value validation is deferred to after middleware — this allows plugins like helpPlugin to intercept --help before missing-arg errors are surfaced.

3. Middleware & Hooks

The middleware chain uses an onion model — each plugin wraps the next, with command hooks at the center:

middleware A  →  middleware B  →  preRun → run → postRun
  • preRun() throws → run() is skipped, postRun() still runs
  • postRun() always runs, even on error. If both run and postRun throw, the original error is preserved

4. Error Handling

.execute() catches all errors — they never propagate to the caller. Errors are printed to stderr and process.exitCode is set to 1.

Practical Example

const deploy = new Crust("deploy")
  .meta({ description: "Deploy to production" })
  .flags({
    env: { type: "string", required: true, description: "Target environment" },
    dryRun: { type: "boolean", description: "Simulate without deploying", short: "d" },
  })
  .preRun(async ({ flags }) => {
    // Validate before running
    if (flags.env === "production" && flags.dryRun) {
      console.log("Dry run for production deployment");
    }
  })
  .run(async ({ flags }) => {
    if (flags.dryRun) {
      console.log(`[DRY RUN] Would deploy to ${flags.env}`);
      return;
    }
    console.log(`Deploying to ${flags.env}...`);
    // deployment logic
  })
  .postRun(async ({ flags }) => {
    // Always runs — send notification regardless of success/failure
    console.log(`Deployment to ${flags.env} finished`);
  });

On this page