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 III: Four Machines Replace Six Globals

Six mutable globals became four state machines. Each machine owns its state, enforces its transitions, and delegates side effects through callbacks. Zero globals remain.

Machine 1: Runner — Command Execution Lifecycle

The old code had childRunning: boolean to track whether an execSync was in progress. The new code has a machine with four states:

Diagram

The Full Implementation (~95 lines)

export type RunnerState = 'idle' | 'running' | 'completed' | 'failed';

export interface RunnerCallbacks {
  onStateChange: (state: RunnerState, prev: RunnerState) => void;
  spawn: (cmd: string, env?: NodeJS.ProcessEnv) => void;
  writeOutput: (chunk: string) => void;
  updateStatusLine: (spinnerFrame: string, elapsed: string) => void;
  showResult: (ok: boolean, durationMs: number) => void;
  killProcess: () => void;
}

export function createRunnerMachine(callbacks: RunnerCallbacks) {
  let state: RunnerState = 'idle';
  let startTime = 0;
  let frame = 0;

  function transition(next: RunnerState): void {
    const prev = state;
    state = next;
    callbacks.onStateChange(state, prev);
  }

  function start(cmd: string, label: string, env?: NodeJS.ProcessEnv): void {
    if (state !== 'idle') return;     // guard
    startTime = Date.now();
    frame = 0;
    transition('running');
    callbacks.spawn(cmd, env);        // side effect via callback
  }

  function stdout(chunk: string): void {
    if (state !== 'running') return;  // guard
    callbacks.writeOutput(chunk);
  }

  function tick(): void {
    if (state !== 'running') return;  // guard
    frame = (frame + 1) % 10;
    callbacks.updateStatusLine(SPINNER_FRAMES[frame], elapsed());
  }

  function exit(code: number): void {
    if (state !== 'running') return;  // guard
    const duration = Date.now() - startTime;
    transition(code === 0 ? 'completed' : 'failed');
    callbacks.showResult(code === 0, duration);
  }

  function abort(): void {
    if (state !== 'running') return;
    callbacks.killProcess();
    transition('failed');
  }

  return { start, stdout, stderr: stdout, tick, exit, abort,
           getState: () => state, getLabel: () => label };
}

Guard Clause Walkthrough

What happens when stdout('data') arrives while the machine is in idle?

function stdout(chunk: string): void {
  if (state !== 'running') return;  // idle !== running → return
  callbacks.writeOutput(chunk);     // never reached
}

Nothing. The event is silently discarded. No error, no warning, no crash. This is intentional: it makes the machine safe to use in asynchronous environments where events can arrive out of order.

Before/After: The childRunning Pattern

Before (old workflow.ts):

let childRunning = false;

childRunning = true;
try { execSync('npx vitest run', { stdio: 'inherit' }); }
catch {}
childRunning = false;

process.on('SIGINT', () => {
  if (childRunning) return;  // don't kill us, let the child die
  process.exit(0);
});

After (machine + callback):

const machine = createRunnerMachine({
  spawn: (cmd) => { /* spawn child process */ },
  killProcess: () => { /* taskkill on Windows, kill on Unix */ },
  // ...
});

machine.start('npx vitest run', 'test unit');
// stdout/exit events arrive via process listeners
// machine tracks state — no boolean needed

The machine knows whether a command is running. The SIGINT handler asks the machine instead of checking a boolean.


Machine 2: Server — Dev/Static Lifecycle

The old code had devProc, devUrl, staticProc, staticUrl — four globals for two servers. The new code has one machine per server.

Diagram

Key design decision: restart = stop + start.

function start(port: number): void {
  if (state === 'running' || state === 'starting') {
    callbacks.killProcess();  // stop first
    url = null;
  }
  transition('starting');
  callbacks.spawnServe(dir, port);
}

Calling start() on a running server kills the old process and starts a new one. No separate restart() method needed. The machine handles the transition.


Machine 3: TUI — Interactive Mode State

The old code used four boolean/object checks in the readline handler. The new code has one machine with five phases:

Diagram

Key design decision: mode-agnostic. The TUI machine manages phases (idle, panel, running, postRun, input), not modes (panels, keys, command). The mode is stored in the state but the transitions don't depend on it. This means one machine drives all three interaction modes.


Machine 4: Wizard — Build Flag Toggles

The simplest machine. Pure flag toggling with a resolveFlags() pure function:

Diagram
export function resolveWizardFlags(flags: BuildFlags): string[] {
  const result: string[] = [];
  if (!flags.clean)   result.push('--no-clean');
  if (!flags.images)  result.push('--no-images');
  if (!flags.mermaid) result.push('--no-mermaid');
  // ...
  return result;
}

resolveWizardFlags is a pure function. It takes a flags object and returns an array of CLI flags. No I/O, no callbacks, no state. Testing it is a one-liner:

expect(resolveWizardFlags({ ...allTrue, mermaid: false })).toEqual(['--no-mermaid']);

The Key Insight

None of these four machines know about:

  • Commander.js
  • readline
  • ANSI escape codes
  • process.stdout
  • execSync or spawn
  • Windows vs Unix

They're pure logic. Events go in, state changes, callbacks fire. That's what makes them testable — and that's what Part IV is about.


Next: Part IV: 81 Tests, Zero Terminals — How we test a full CLI without spawning a single process.