Lifecycle

How a CLI invocation flows through maltty, from process.argv to process exit.

Phases

Every invocation passes through five phases: parse, validate, bootstrap, execute, and exit.

1. Parse and validate

Yargs parses process.argv, matches a registered command, strips internal keys, and validates args against the command's Zod schema. Validation failures exit with code 1.

2. Bootstrap

The config client discovers and validates the config file. createContext() assembles the context with args, config, meta, format, store, colors, log, prompts, and spinner.

3. Execute

The runner executes the middleware onion (described below), then exits with code 0 on success or the error's exit code on failure.

Middleware Onion

Middleware follows a nested onion model. Root middleware wraps command middleware, which wraps the handler:

root middleware[0] before
  root middleware[1] before
    command middleware[0] before
      command middleware[1] before
        handler()
      command middleware[1] after
    command middleware[0] after
  root middleware[1] after
root middleware[0] after

Each middleware calls next() to pass control inward. Code before next() runs on the way in; code after next() runs on the way out.

Root middleware

Declared on cli(). Runs for every command. Use it for cross-cutting concerns.

import { cli, middleware } from 'maltty'

const timing = middleware(async (ctx, next) => {
  const start = Date.now()
  await next()
  ctx.log.info(`Done in ${Date.now() - start}ms`)
})

cli({
  name: 'my-app',
  version: '1.0.0',
  middleware: [timing],
  commands: { deploy },
})

Command middleware

Declared on command(). Runs only when that command is matched. Use it for command-specific setup.

import { command, middleware } from 'maltty'

const requireAuth = middleware(async (ctx, next) => {
  const token = process.env['API_TOKEN']
  if (!token) {
    ctx.fail('Missing API_TOKEN')
  }
  ctx.store.set('token', token)
  await next()
})

export default command({
  description: 'Deploy the application',
  middleware: [requireAuth],
  async handler(ctx) {
    const token = ctx.store.get('token')
    ctx.log.raw(`Deploying with token ${token}`)
  },
})

Short-circuiting

A middleware that does not call next() prevents all inner layers from running. The outer middleware's post-next() code still executes.

Data flow

Middleware communicates with handlers and other middleware via ctx.store:

const loadUser = middleware(async (ctx, next) => {
  ctx.store.set('user', await fetchUser())
  await next()
})

Error Handling

OriginBehavior
Handler throwsPost-next() code skipped, error propagates to CLI boundary
Middleware throwsOuter post-next() code skipped, error propagates to CLI boundary
ctx.fail(message)Throws ContextError with a specific exit code (default 1)
ctx.fail(message, { code, exitCode })Same as above with a machine-readable code and custom exitCode
Arg validation failsExits before middleware runs

The CLI boundary catches all errors, logs the message, and calls process.exit with the appropriate code. Handlers never call process.exit directly.

See Context for the full ctx.fail() API and all other context properties.

References