
Building a React-based CLI to Speed Up Development | Part 2: How
In Part 1, we explored the problem that PBX solves for our team at PressBox: locally managing 10+ services, databases, and workers without getting overwhelmed by terminal tabs. We talked about how features like session persistence, automatic discovery, and deterministic ports transformed our development workflow by centralizing common workflows and saving us loads of time.
The Architecture: Three Pillars
PBX is essentially a system orchestrator that happens to run on your laptop. The entire system rests on three core pillars that work together:
Process Orchestration: Start, stop, track, and recover services reliably
Smart Discovery: Find services automatically with flexible configuration overrides
Terminal UI: Real-time status updates for everything, built with React
Each piece solves a specific problem that every growing development team faces. Let’s dig into how we built each one.
Process Orchestration: The Foundation
The Lifecycle Challenge
Tracking processes sounds simple on the surface. Node’s child_process.spawn() gives you a PID. Great! But what happens when PBX crashes, or when someone force-quits the terminal? You’ve got zombie processes everywhere.
Our first attempt was naive: just spawn processes and hope for the best. That lasted about a day before we had dozens of orphaned services running on random ports, eating up CPU and memory. We needed something slightly more robust:
class ServerManager {
private services: Map<string, Service> = new Map();
async startService(id: string) {
const service = this.services.get(id);
if (!service) return;
// each service knows its own startup command
const child = spawn(service.command, service.args, {
cwd: service.directory,
detached: true,
});
// capture logs in a ring buffer (circular array that overwrites old data)
child.stdout.on("data", (data) => {
service.logs.push(data.toString());
// keep last 1000 lines per service
if (service.logs.length > 1000) {
service.logs.shift();
}
});
// track the PID so we can manage it later
await processService.addProcess(id, child.pid, port);
}
}That processService.addProcess() call is crucial. We write every PID to a JSON file:
// /tmp/.pbx/cache/pids.json
{
"pressbox": { "pid": 12345, "port": 8176, "startedAt": "2025-09-01T..." },
"editorial-db": { "pid": 12346, "port": 8094, "startedAt": "2025-09-01T..." }
}When PBX starts, it checks this file. Are those processes still running? If they are, it can adopt them. If PBX crashed last time, you get the option to clean up leftover processes or keep them running. Storing the process information in this simple persistence layer solved 95% of our zombie process problems.
The Shutdown Dance
Process cleanup on macOS turned out to be trickier than expected. SIGTERM doesn’t always work, especially with processes that spawn their own children. We implemented an escalating shutdown sequence:
async stopService(serviceId: string): Promise<void> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
// nuclear option after 10 seconds
processInfo.process.kill("SIGKILL");
reject(new Error(`Service ${serviceId} failed to stop gracefully`));
}, 10000);
processInfo.process.once("exit", () => {
clearTimeout(timeout);
resolve();
});
// try graceful shutdown first
processInfo.process.kill("SIGTERM");
});
}First, we try SIGTERM (please stop), wait 10 seconds, then SIGKILL (stop NOW). It’s not elegant, but it works. Zombie processes are real, and they’re annoying.
Session Persistence: Your Safety Net
One of the most valuable features is session persistence. Nothing is worse than accidentally hitting Ctrl-C and losing your entire development environment. Here’s how we prevent that disaster:
export const App = () => {
const [screen, setScreen] = useState('main');
const [services, setServices] = useState([]);
const { exit } = useApp();
// check for restorable session on mount
useEffect(() => {
checkForSession().then(session => {
if (session) {
setScreen('restore-prompt');
}
});
}, []);
// set up graceful shutdown
useEffect(() => {
process.on('SIGINT', async () => {
await saveSession();
await stopAllServices();
exit();
});
}, []);
return (
<Box flexDirection="column">
<Header />
<StatusBar services={services} />
{screen === 'main' && <MainMenu onSelect={setScreen} />}
{screen === 'services' && <ServerDashboard />}
{screen === 'databases' && <DatabaseDashboard />}
{screen === 'restore-prompt' && <RestorePrompt />}
<Footer />
</Box>
);
};When someone hits Ctrl-C, we intercept it, save the current state, and then exit cleanly. On startup, we check for that saved state and offer to restore it. As it turns out, this saves us a lot of time.
Smart Discovery with Flexible Configuration
Automatic Service Discovery
We wanted PBX to work immediately after cloning a repo, no configuration required. It walks your filesystem looking for services, understanding the nuances of our monorepo structure:
function discoverServices() {
const services = [];
// find all React Router apps
for (const dir of fs.readdirSync("apps")) {
const packageJson = readPackageJson(`apps/${dir}`);
if (packageJson) {
services.push({
type: "app",
name: dir,
path: `apps/${dir}`,
// smart command detection based on available scripts
command: packageJson.scripts.dev ? "pnpm dev" : "pnpm start",
// extract port from vite.config if present
port: extractPortFromConfig(`apps/${dir}/vite.config.ts`),
});
}
}
// find all Cloudflare Workers
for (const dir of fs.readdirSync("workers")) {
if (hasWranglerConfig(`workers/${dir}`)) {
services.push({
type: "worker",
name: dir,
path: `workers/${dir}`,
command: "pnpm wrangler dev",
});
}
}
// Containers use npm, not pnpm (they're outside the workspace)
for (const dir of fs.readdirSync("containers")) {
const packageJson = readPackageJson(`containers/${dir}`);
if (packageJson?.scripts?.build && packageJson?.scripts?.start) {
services.push({
type: "container",
name: dir,
path: `containers/${dir}`,
command: "npm run build && npm start",
});
}
}
return services;
}The discovery system understands subtle differences that are tailored to how we generally have things set up: containers use npm because they’re outside the pnpm workspace, Cloudflare Workers need wrangler, and apps typically use Vite and React Router. It uses these standards to automatically set up new apps, workers, or containers.
But sometimes you need to override defaults or add environment variables. That’s where the PBX config.json file provides escape hatches:
// pbx/config.json
{
"localServers": {
"services": {
"worker-curator": {
"command": "pnpm wrangler dev --port 8787",
"port": 8787,
"env": { "DEBUG": "true" }
}
}
}
}PBX merges these overrides with discovered services, so you get the best of both worlds: zero-config startup that just works, along with flexibility when you need it.
Deterministic Port Assignment: No More Conflicts
Port conflicts started to get us pretty quickly. You start your database, and it grabs port 8080. Start another service, port’s taken. Now you’re hunting for free ports, updating config files, and generally wasting time.
We solved this permanently with deterministic port assignment using MurmurHash3, a non-cryptographic hash algorithm designed for hash table distribution. It takes our service name and transforms it into a uniformly distributed number through a series of multiplication and bit-shifting operations. The beauty of this is its excellent avalanche effect: change one character in the service name, and you get a completely different port. This ensures our 300-port range (8080–8379) gets utilized evenly across all services.
Now editorial-db always gets port 8094. pressbox always gets 8176. No state files, no coordination, just math. The same service gets the same port every time, across every developer’s machine. It’s one of those simple solutions that eliminates an entire class of problems. It’s worth noting that this does not scale forever (the probability of conflicts increases as you add running services and databases), but it’s good enough for now.
Wrapping External Tools: The Reality of Development
PBX doesn’t try to replace existing tools completely — our focus was on making them easier to use. We wrap CLIs like turso and wrangler to provide better ergonomics for local development.
Taming Turso Database Management
Starting a local Turso database manually requires remembering specific flags and paths:
turso dev --port 8094 --db-file _local_db/turso/editorial-db.dbWith PBX, that’s automated. But more importantly, PBX handles the edge cases we discovered the hard way:
async stopDatabase(dbName: string) {
const port = getPort(dbName);
const pid = await isPortListening(port);
if (pid) {
await killProcess(pid);
// turso sometimes leaves orphans, so double-check
await sleep(1000);
if (await isPortListening(port)) {
// nuclear option: pkill
await execAsync(`pkill -f "turso dev --port ${port}"`);
}
}
}Some CLI tools don’t always clean up after themselves, but now PBX handles it gracefully.
Simplifying Cloudflare Worker Secrets
Cloudflare Workers need secrets. The only way to add them is through the wrangler CLI tool or on the Cloudflare dashboard, which can be tough depending on what directory you’re in and/or how many different workers need the secret. Setting them manually for 20+ workers is error-prone:
echo "secret-value" | pnpm wrangler secret put SECRET_NAME --env stagingPBX wraps this in a better interface, letting you deploy secrets to one or many workers at once:
async addSecretToMultipleServices(serviceIds, secretName, secretValue) {
for (const serviceId of serviceIds) {
const command = `echo "${secretValue}" | pnpm wrangler secret put ${secretName}`;
await execAsync(command, { cwd: service.path });
}
}Select workers from a checkbox list, enter the secret once, and deploy everywhere; no more copy-pasting commands and hoping you didn’t miss one.
Terminal UI: React Where You Least Expect It
Why React Actually Makes Sense Here
React components rendering in a terminal sounds weird, but managing services is fundamentally a state management problem: services are running or stopped, logs are streaming, and ports need tracking. Creating user interfaces and tracking state is exactly what React was designed to handle.
Traditional bash scripts give you static output:
# Traditional approach - fire and forget
echo "Starting database…"
turso dev --port 8080 &
echo "Database started on port 8080"We needed real-time status updates for 10+ services, interactive navigation, and a UI that any developer could extend. Ink gave us actual components:
// Ink - real components with state
const DatabaseStatus = ({ database }) => (
<Box>
<Text color={database.running ? "green" : "gray"}>
{database.running ? "●" : "○"} {database.name}
</Text>
{database.starting && <Spinner type="dots" />}
</Box>
);Now we have interfaces with arrow key navigation, color-coded statuses that update in real-time, and components our whole team already knows how to write. The terminal became just another render target.
Performance: Not Melting Your Terminal
Here’s something we learned the hard way: when you’re rendering 20+ status indicators that all update independently, React’s re-rendering can destroy your terminal performance. Every log line from every service triggered a full re-render. The terminal would lag, flicker, and eventually crash.
The solution was careful state management and ring buffers (circular arrays that overwrite old data) for everything:
// capture logs in a ring buffer - not unlimited arrays
child.stdout.on("data", (data) => {
service.logs.push(data.toString());
// only keep last 1000 lines per service
if (service.logs.length > 1000) {
service.logs.shift();
}
});For the aggregated log view that shows all services at once, we had to be even more careful:
const AggregatedLogs = ({ services }) => {
const [logs, setLogs] = useState([]);
useEffect(() => {
// subscribe to all service logs
const unsubscribes = services.map((service) =>
service.onLog((entry) => {
setLogs(
(prev) =>
[
...prev,
{
...entry,
service: service.name,
},
].slice(-10000), // keep last 10000 lines total
);
}),
);
return () => unsubscribes.forEach((fn) => fn());
}, [services]);
return <LogViewer logs={logs} showServiceName />;
};That .slice(-10000) prevents infinite memory growth. Without it, the UI would gradually slow down until it became unusable, but with it, PBX can run for days without issues.
Building an Actual Terminal UI
The dashboard needs to show everything at once while staying responsive. Here’s the core interaction model:
const ServerDashboard = () => {
const [services, setServices] = useState([]);
const [selected, setSelected] = useState(0);
useInput((input, key) => {
if (key.upArrow) setSelected(s => Math.max(0, s - 1));
if (key.downArrow) setSelected(s => Math.min(services.length - 1, s + 1));
if (input === ' ') toggleService(services[selected]); // space bar toggles
if (input === 'l') showLogs(services[selected]);
if (input === 'r') restartService(services[selected]);
});
return (
<Box flexDirection="column">
<Box marginBottom={1}>
<Text bold>Local Services ({running}/{total} running)</Text>
</Box>
{services.map((service, i) => (
<Box key={service.id}>
<Text color={i === selected ? 'blue' : 'white'}>
{i === selected ? '>' : ' '}
</Text>
<ServiceRow service={service} />
</Box>
))}
<Box marginTop={1}>
<Text dimColor>
↑↓ Navigate • Space: Start/Stop • L: Logs • R: Restart
</Text>
</Box>
</Box>
);
};Arrow keys navigate, space bar toggles services, and single letters trigger actions. The useInput hook from Ink handles all keyboard input. It feels like a native terminal application, but it’s just React components.
Some Lessons Learned
Building PBX taught us several hard lessons:
PID tracking from day one. We didn’t do this initially and spent hours hunting zombie processes during development. Always persist PIDs to disk immediately; you’ll need them when things go wrong.
Process cleanup is harder than starting processes. Getting services running is easy, but making sure they properly stop, especially on unexpected exit, requires careful signal handling and fallback strategies.
Memory management matters in terminals. Terminal UIs don’t have infinite memory. Use ring buffers, limit array sizes, or watch your CLI slowly grind to a halt as logs accumulate.
React.memo is critical for terminal performance. When you have 50+ components updating independently, careful memoization is the difference between smooth updates and a flickering mess.
The terminal is just another render target. Once you accept that, React patterns make perfect sense. Components, hooks, state management… it all translates directly.
The Bigger Picture: When to Build Your Own
PBX works for us because we have specific constraints: a consistent monorepo structure, a team that knows React and TypeScript, and somewhat complex local development needs… but the patterns we’ve discovered are universal.
If you’re drowning in terminal tabs, fighting port conflicts, or losing work to accidental Ctrl-C, these solutions will help. You don’t need to build a full PBX; even implementing just PID tracking or deterministic ports will improve your workflow.
The beauty of building on React is that extending PBX is just writing more components. We’ll continue to add features as needed. Things that we’re looking at adding next include a headless mode for CI environments (and for calling from other scripts/integration with coding AI assistants), service dependency graphs, and better resource tracking. Each new feature is just another component in the tree.
The terminal doesn’t have to be a constraint. With the right abstractions, it becomes a powerful platform for building the exact tools your team needs. If you’re interested in building tooling like PBX for yourself, try it out! You may save yourself and your team a lot of time.