Part I: The Monolith That Worked
Let's be honest: the old workflow.ts was fine. It built the site. It ran the tests. It started servers. It committed and pushed. It had a menu, sub-menus for tests, a wizard for build flags, and a 3-step audit flow. It worked for months.
It was also 1270 lines in a single file with 6 mutable globals and zero unit tests.
The Globals
Here's what lived at the top of the file:
let devProc: ChildProcess | null = null;
let devUrl: string | null = null;
let staticProc: ChildProcess | null = null;
let staticUrl: string | null = null;
let waitingForCommitMsg: boolean = false;
let childRunning: boolean = false;let devProc: ChildProcess | null = null;
let devUrl: string | null = null;
let staticProc: ChildProcess | null = null;
let staticUrl: string | null = null;
let waitingForCommitMsg: boolean = false;
let childRunning: boolean = false;Six variables, all mutable, all module-scoped. devProc and staticProc held live process handles. devUrl and staticUrl tracked whether each server was running. waitingForCommitMsg told the readline handler to treat the next line as a git commit message instead of a menu command. childRunning prevented SIGINT from killing the whole tool when a child execSync was still running.
These weren't bugs. They were implicit state, encoded as booleans instead of formalized as states.
The Wizard Pattern
The build sub-menu was the clearest example. It used a boolean to track whether the user was "inside" the build wizard:
let waitingForBuildMenu: boolean = false;
const buildFlags: BuildFlags = {
toc: false, clean: false, images: false, statics: false,
mermaid: false, minify: true, pages: true,
};
function showBuildMenu(): void {
const on = '\x1b[32m[x]\x1b[0m';
const off = '\x1b[31m[ ]\x1b[0m';
console.log('\n\x1b[33m--- build options ---\x1b[0m');
console.log(` 1 ${buildFlags.toc ? on : off} Rebuild TOC`);
console.log(` 2 ${buildFlags.clean ? on : off} Clean output`);
// ... 5 more flags
process.stdout.write('\x1b[33mbuild> \x1b[0m');
}
function handleBuildMenu(input: string): void {
switch (input) {
case '1': buildFlags.toc = !buildFlags.toc; showBuildMenu(); break;
case '2': buildFlags.clean = !buildFlags.clean; showBuildMenu(); break;
// ... 5 more cases
case '': waitingForBuildMenu = false; buildStatic(); showMenu(); break;
case 'q': waitingForBuildMenu = false; showMenu(); break;
}
}let waitingForBuildMenu: boolean = false;
const buildFlags: BuildFlags = {
toc: false, clean: false, images: false, statics: false,
mermaid: false, minify: true, pages: true,
};
function showBuildMenu(): void {
const on = '\x1b[32m[x]\x1b[0m';
const off = '\x1b[31m[ ]\x1b[0m';
console.log('\n\x1b[33m--- build options ---\x1b[0m');
console.log(` 1 ${buildFlags.toc ? on : off} Rebuild TOC`);
console.log(` 2 ${buildFlags.clean ? on : off} Clean output`);
// ... 5 more flags
process.stdout.write('\x1b[33mbuild> \x1b[0m');
}
function handleBuildMenu(input: string): void {
switch (input) {
case '1': buildFlags.toc = !buildFlags.toc; showBuildMenu(); break;
case '2': buildFlags.clean = !buildFlags.clean; showBuildMenu(); break;
// ... 5 more cases
case '': waitingForBuildMenu = false; buildStatic(); showMenu(); break;
case 'q': waitingForBuildMenu = false; showMenu(); break;
}
}The main readline loop checked waitingForBuildMenu before processing input:
rl.on('line', (input) => {
if (waitingForBuildMenu) { handleBuildMenu(input.trim()); return; }
if (waitingForCommitMsg) { /* ... */ return; }
if (testState) { handleTestInput(input.trim()); return; }
if (auditState) { handleAuditInput(input.trim()); return; }
// ... main menu
});rl.on('line', (input) => {
if (waitingForBuildMenu) { handleBuildMenu(input.trim()); return; }
if (waitingForCommitMsg) { /* ... */ return; }
if (testState) { handleTestInput(input.trim()); return; }
if (auditState) { handleAuditInput(input.trim()); return; }
// ... main menu
});Four different state checks. Four different boolean/object guards. The readline handler had to know about every possible "mode" the tool could be in — and they were all checked sequentially.
The Test Wizard: Three Steps Deep
The test wizard was worse. It used a step counter to track which question the user was answering:
let testState: TestState | null = null;
function showTestStep1(): void {
testState = { step: 1 };
console.log('--- test: what? ---');
console.log(' 1 Unit tests + coverage');
console.log(' 2 E2E tests');
// ... 5 more options
}
function showTestStep2(): void {
testState!.step = 2;
console.log('--- test: target? ---');
console.log(' 1 DEV');
console.log(' 2 STATIC');
}let testState: TestState | null = null;
function showTestStep1(): void {
testState = { step: 1 };
console.log('--- test: what? ---');
console.log(' 1 Unit tests + coverage');
console.log(' 2 E2E tests');
// ... 5 more options
}
function showTestStep2(): void {
testState!.step = 2;
console.log('--- test: target? ---');
console.log(' 1 DEV');
console.log(' 2 STATIC');
}Step 1: pick a test suite. Step 2: pick a target. For a11y, there was even a step 1.5 (sub-menu for axe/contrast/ARIA). The state was encoded in testState.step — a number that could be 1, 2, or the string 'a11y-sub'. Three possible values, tracked in a field that TypeScript typed as number | 'a11y-sub'.
Why It Couldn't Be Tested
Every function in the file called console.log directly with ANSI escape codes:
console.log('\x1b[36m> Building toc.json...\x1b[0m');
execSync('npx tsx scripts/build-toc.ts', { stdio: 'inherit' });
console.log('\x1b[32m> Done.\x1b[0m');console.log('\x1b[36m> Building toc.json...\x1b[0m');
execSync('npx tsx scripts/build-toc.ts', { stdio: 'inherit' });
console.log('\x1b[32m> Done.\x1b[0m');No abstraction over output. No way to capture what was printed. No way to verify the menu rendered correctly without looking at a terminal.
Every build/test/audit command called execSync directly with stdio: 'inherit'. The child process owned stdout. There was no way to intercept, test, or mock the execution.
The SIGINT handler modified module-level state:
process.on('SIGINT', () => {
if (childRunning) return; // let child die
if (waitingForCommitMsg) {
waitingForCommitMsg = false;
console.log('\n> Commit cancelled.');
showMenu();
return;
}
stopAll();
process.exit(0);
});process.on('SIGINT', () => {
if (childRunning) return; // let child die
if (waitingForCommitMsg) {
waitingForCommitMsg = false;
console.log('\n> Commit cancelled.');
showMenu();
return;
}
stopAll();
process.exit(0);
});Every piece of behavior was tangled with I/O, process lifecycle, and global state. Testing any of it meant testing all of it.
The Trigger
The tool had one interaction mode: a flat menu with single-letter keys and nested wizard sub-menus. I wanted to add two more:
- Keys mode — all commands visible as 2-character shortcuts (vim-style)
- Command mode — free-form text input with tab completion
Adding a second mode to the existing structure meant duplicating the entire readline handler. Adding a third meant tripling it. The boolean guards (waitingForBuildMenu, testState, auditState) would multiply. The implicit state machine encoded in those booleans was already struggling with one mode — it couldn't handle three.
That's when I realized: the state was already there. The transitions were already there. The guards were already there. They were just spread across booleans and if chains instead of formalized as a state machine.
And we'd already formalized this pattern 28 times — in the frontend.
Next: Part II: "We Already Solved This 28 Times" — The state machine pattern hiding in plain sight in src/lib/.