bk · the monorepo cli

every dev workflow tool eventually becomes a personal cli. mine got serious enough that it deserves a writeup. one binary covers branch → workspace → neon db → tunnels → dev → ship.

0.0

01 — problem

i work on three apps in one monorepo (portfolio, golf, hive) across three to five concurrent feature branches. each branch needs its own database (so a migration on a feature branch can't break another branch's dev server), its own port range (so all the apps can run at once without collision), and its own public URL (so i can hit a workspace from my phone or share with a friend for review).

the un-ergonomic version was a wall of shell scripts: git worktree add, neonctl branches create, pnpm dev, cloudflared tunnel run, plus a hand-maintained spreadsheet of which workspace owned which port. when i forgot to rotate a port the next workspace took down all my running dev servers. when i forgot a neon branch a migration on a feature branch wiped my main dev data. these were the exact kind of papercuts that a cli exists to absorb.

02 — approach

framework:oclif/core (command resolver, help generator), @clack/prompts (interactive)·infra clients:@neondatabase/api-client, cloudflared (binary), vercel cli (binary)·data:hive api (workspace registry · /workspaces), local .workspace.json per worktree·build:typescript, tsc → bin/run.js, biome, vitest

oclif handles the boring parts (help, args, examples, plugin discovery); clack handles the interactive prompts; everything else i wrote because the abstractions weren't worth their weight.

the central idea is the workspace. one workspace = one branch = one neon db branch = one port range = one tunnel hostname per app. workspaces are numbered (1, 2, 3, …) and the index drives everything else through a deterministic formula:

port = project.base_port + (workspace_index × 10) + app.port_offset

with bases golf=3100, portfolio=3200, hive=3300 and ten ports per slot, every workspace owns a contiguous range that can't collide with any other workspace as long as the indices differ. nine workspaces per project before i run out of slots, which is more than i've ever needed.

rendering diagram…
port allocation across projects · workspace stride 10 · max 9 per project
oclif + clack
chosen
  • oclif: file-system-based command resolver (commands/workspace/dev.ts → bk workspace dev)
  • oclif: free help text + arg validation + examples generator
  • clack: prompts that match the warm aesthetic without the inquirer overhead
  • build is just tsc — no bundler, no plugin pipeline
commander · hand-rolled · ink-based repl
  • commander: less opinionated, but i'd have to write the help/examples plumbing
  • hand-rolled: appealing for ten commands, ridiculous for seventy-two
  • ink: a beautiful repl is a different product than a cli i type into bash

oclif's plugin model i don't use — but the file-system command resolver is what scales the cli from one binary to seventy-two subcommands without a routing config. that single feature is why oclif won.

per-worktree neon database branches with stable tunnel URLs

bk workspace create now does three things atomically: (1) git worktree add to a deterministic path, (2) call the neon api to create a branch named after the git branch, (3) register named cloudflared tunnels keyed to the workspace index. the URL pattern is stable: portfolio-app-ws1.dev.bokendell.com always points at workspace 1's portfolio-app, no matter how many times i restart the tunnel. the workspace registry lives in the hive api so the URL mapping survives across machines.

the unlock was making the neon branch and the tunnel hostname both deterministic functions of (project, workspace_index). once that was true, "which url is the staging api on?" became a calculation, not a memory.

what i tried firsthand-managed neon branches and ad-hoc cloudflared tunnels per dev session.
why it failedevery `cloudflared tunnel` run minted a new random URL. i couldn't share a workspace with a friend without re-pasting the URL. and i lost neon branches because i forgot which branch belonged to which worktree.
try itreal bk surface. live mode rounds-trips `projects`, `stats`, `who`, `last`, `version` to the public api — replay covers everything else. tab completes through subcommands. try `bk projects` (live) or `bk workspace dev` (replay).
live · provisioning commands stay replay

03 — result

72
subcommands
19
command groups
9
max workspaces per project
10
ports reserved per workspace
deterministic stride

`bk workspace create → bk workspace dev → tunnel ready` in under thirty seconds. one binary, no external coordination service, no shell-script-of-shell-scripts. the `.workspace.json` in each worktree is the only state outside the hive api.

⌘Kterminal