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 runner machine turns the childRunning boolean into four real states, so SIGINT and stdout can ask "is a child alive?" without touching a global.

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
One server machine per process replaces the four devProc/devUrl/staticProc/staticUrl globals - and collapses "restart" into a stop-then-start path.

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
The TUI machine tracks interaction phases rather than modes, so the same five-state diagram drives panels, key shortcuts and interactive commands alike.

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
The wizard machine is essentially one looping selecting state plus a terminal done - the real work lives in the pure resolveWizardFlags() helper next to it.
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.

⬇ Download