Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

Part V: Commander.js as a Thin Shell

The CLI framework is the thinnest layer in the stack. Commander.js handles argument parsing and --help generation. Everything else — state, logic, output, testing — lives in the machines, pure functions, and ConsoleIO.

Why Commander.js?

It was already in node_modules — a transitive dependency of @axe-core/cli and @mermaid-js/mermaid-cli. One line to make it explicit:

npm install -D commander@12

What it gives us for free:

  • --help auto-generated at every level (top, group, subcommand)
  • Subcommand tree (work serve dev, work test e2e --target=dev)
  • Option parsing with types and defaults
  • Version flag

What it doesn't do: manage state, handle I/O, run commands. That's our code.

The Composition Root

program.ts is 40 lines. It creates the dependencies and wires everything:

import { Command } from 'commander';
import { createConsoleIO } from './core/console';
import { loadConfig } from './core/config';
import { registerServeCommands } from './commands/serve';
import { registerBuildCommands } from './commands/build';
import { registerTestCommands } from './commands/test';
// ... 8 more imports

export function createProgram(): Command {
  const io = createConsoleIO();
  const config = loadConfig();

  const program = new Command()
    .name('work')
    .description('serard.dev development workflow tool')
    .version('1.0.0');

  registerServeCommands(program, io, config);
  registerBuildCommands(program, io, config);
  registerTestCommands(program, io, config);
  registerAuditCommands(program, io, config);
  registerGitCommands(program, io);
  registerCaptureCommand(program, io, config);
  registerReviewCommand(program, io);
  registerOpenCommand(program, io);
  registerConfigCommand(program, io);
  registerStatusCommand(program, io);
  registerAssetsCommand(program, io);

  return program;
}

ConsoleIO and Config are created once and passed to every command. This is the only place in the codebase where real dependencies are instantiated. Everything else receives them.

Pure Functions in Commands

Each command file has two parts: pure logic (testable) and Commander wiring (thin).

resolveFlags() — Build Options

export function resolveFlags(opts: BuildStaticOptions): string[] {
  const flags: string[] = [];
  if (opts.noClean)   flags.push('--no-clean');
  if (opts.noImages)  flags.push('--no-images');
  if (opts.noStatics) flags.push('--no-statics');
  if (opts.noMermaid) flags.push('--no-mermaid');
  if (opts.noMinify)  flags.push('--no-minify');
  if (opts.noPages)   flags.push('--no-pages');
  return flags;
}

Five lines of logic. Zero dependencies. Testing is trivial:

expect(resolveFlags({})).toEqual([]);
expect(resolveFlags({ noMermaid: true })).toEqual(['--no-mermaid']);

resolveTestTarget() — Config Merge

export function resolveTestTarget(opts: TestOptions, config: WorkConfig): string {
  return opts.target ?? config.test.target;
}

One line. CLI flag overrides config default. This encodes the resolution order: CLI flag > .workrc.json > DEFAULTS.

escapeMessage() — Git Safety

export function escapeMessage(msg: string): string {
  return msg.replace(/"/g, '\\"');
}

Pure. Testable. Handles the edge case of quotes in commit messages.

.workrc.json — Persistent Configuration

All 12 configurable defaults live in one file:

{
  "tui": { "mode": "panels" },
  "serve": { "devPort": 4001, "staticPort": 4000, "startTimeout": 10000 },
  "test": { "target": "static", "workers": 4, "retries": 2, "timeout": 15000 },
  "audit": { "concurrency": 2, "lighthouseTimeout": 60000, "passThreshold": 90 },
  "capture": { "themes": "all", "accents": "green" },
  "visual": { "diffThreshold": 0.2 }
}

All values are optional. Missing keys fall back to hardcoded DEFAULTS. The loadConfig function deep-merges:

export function loadConfig(fsIO: ConfigFS = realFS): WorkConfig {
  if (!fsIO.exists(CONFIG_PATH)) return { ...DEFAULTS };
  try {
    const raw = JSON.parse(fsIO.readFile(CONFIG_PATH));
    return deepMerge(DEFAULTS, raw);
  } catch {
    return { ...DEFAULTS };  // invalid JSON → defaults
  }
}

saveConfig writes only changed values (sparse, no default bloat). So a .workrc.json with just { "tui": { "mode": "keys" } } works fine — everything else comes from DEFAULTS.

The work config command makes this accessible at runtime:

work config show                    # print merged config
work config set test.workers 8      # change a value
work config reset                   # back to defaults
work config path                    # print file path

The Entry Point

workflow.ts is 30 lines:

import { createProgram } from './cli/program';
import { launchInteractive } from './cli/tui/menu';

const args = process.argv.slice(2);

if (args.length === 0 && (process.stdout.isTTY ?? false)) {
  launchInteractive();          // no args + TTY → interactive TUI
} else if (args.length === 0) {
  createProgram().outputHelp(); // no args + pipe → help text
} else {
  createProgram().parseAsync(process.argv);  // CLI mode
}

Three paths. TTY detection determines which one. Commander handles the parsing. The machines handle the state. ConsoleIO handles the output. The entry point does nothing.

The --help Payoff

The old tool had a hand-written help block — 60 lines of console.log with manually aligned columns. Any new command meant updating the help text by hand.

Now:

$ work --help
Usage: work [options] [command]

Commands:
  serve              Manage development servers
  build              Build pipeline
  test               Run test suites
  audit              Run quality audits
  git                Version control operations
  ...

$ work test --help
Commands:
  unit               Unit tests + coverage (Vitest)
  e2e [options]      E2E tests (Playwright)
  visual [options]   Visual regression tests
  ...

$ work build static --help
Options:
  --no-toc           skip TOC rebuild
  --no-clean         keep output dir
  --no-images        skip content image copy
  ...

Three levels deep. Auto-generated. Always in sync with the actual options.


Next: Part VI: Three Modes, One Machine — Panels, Keys, and Command mode all driven by a single TUI machine.