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:
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 };
}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
}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);
});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 neededconst 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 neededThe 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.
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);
}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:
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:
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;
}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']);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.stdoutexecSyncorspawn- 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.