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.
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.
- 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: 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.
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.
03 — result
`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.