---
title: "bk · the monorepo cli"
description: "the personal cli that runs my monorepo. workspace isolation, deterministic ports, per-branch neon databases, stable tunnel urls — composed from one typescript binary built on oclif."
canonical: "https://bokendell.com/projects/cli"
last-updated: 2026-08-25
---

# bk · the monorepo cli

the personal cli that runs my monorepo. workspace isolation, deterministic ports, per-branch neon databases, stable tunnel urls — composed from one typescript binary built on oclif.

## Metadata
- Canonical: https://bokendell.com/projects/cli
- Markdown: https://bokendell.com/projects/cli.md
- Lifecycle: shipping
- Tags: cli, typescript, developer-tools, neon, cloudflare
- Updated: 2026-04-20

## Source
```mdx
<CaseStudyHero
	title={meta.title}
	thesis={meta.thesis}
	metric={meta.heroMetric}
	statusRows={[
		{ label: "status", value: "internal · used daily" },
		{ label: "stack", value: "oclif · clack · neonctl · cloudflared" },
		{ label: "binary", value: "$ bk" },
	]}
	media={
		<div
			style={{
				width: "100%",
				height: "100%",
				display: "flex",
				alignItems: "center",
				justifyContent: "center",
				fontFamily: "var(--font-data)",
				fontSize: 12,
				color: "var(--dim)",
				letterSpacing: "0.04em",
			}}
		>
			[ 8s terminal capture · workspace create → dev → tunnel ready ]
		</div>
	}
/>

## 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

<TechStackLine
	groups={[
		{ label: "framework", items: ["oclif/core (command resolver, help generator)", "@clack/prompts (interactive)"] },
		{ label: "infra clients", items: ["@neondatabase/api-client", "cloudflared (binary)", "vercel cli (binary)"] },
		{ label: "data", items: ["hive api (workspace registry · /workspaces)", "local .workspace.json per worktree"] },
		{ label: "build", items: ["typescript", "tsc → bin/run.js", "biome", "vitest"] },
	]}
	whyOneLiner="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.

<Mermaid
	caption="port allocation across projects · workspace stride 10 · max 9 per project"
	chart={`flowchart LR
		formula["port = base + (ws × 10) + offset"]
		formula --> portfolio["portfolio · base 3200"]
		formula --> golf["golf · base 3100"]
		formula --> hive["hive · base 3300"]
		portfolio --> ws1p["ws1 → 3210-3219"]
		portfolio --> ws2p["ws2 → 3220-3229"]
		golf --> ws1g["ws1 → 3110-3119"]
		hive --> ws1h["ws1 → 3310-3319"]
		classDef hot fill:#ca653c26,stroke:#ca653c,color:#eae3e1;
		class formula hot
	`}
/>

<DecisionPair
	chosen={{
		name: "oclif + clack",
		bullets: [
			"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",
		],
	}}
	rejected={{
		name: "commander · hand-rolled · ink-based repl",
		bullets: [
			"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",
		],
	}}
	narrative="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."
/>

<HardProblem
	headline="per-worktree neon database branches with stable tunnel URLs"
	triedFirst="hand-managed neon branches and ad-hoc cloudflared tunnels per dev session."
	whyFailed="every `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."
>
	`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.
</HardProblem>

<TryIt caption="real 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).">
	<CLITerminal preTypeCommand="bk --help" prompt="$ " height={460} enableLiveMode initialMode="live" />
</TryIt>

## 03 — result

<MetricStrip
	primary={{ value: "72", label: "subcommands" }}
	secondary={[
		{ value: "19", label: "command groups" },
		{ value: "9", label: "max workspaces per project" },
		{ value: "10", label: "ports reserved per workspace", delta: "deterministic stride" },
	]}
	context="`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."
/>
```

_Generated from live portfolio data and MDX source. Canonical site: https://bokendell.com_
