Swarm

a branch should be a whole environment. one command gives it its own ports, its own database, its own env and a url i can open on my phone.

0commands across 36 topics · one typescript binary · internal, used daily
status
internal · used daily
stack
orpc · trpc-cli · clack · neon api · cloudflared · flyctl · tauri
releases
115 cli versions in the changelog

The problem

I work on three products in separate repos: Bagman, this site, and swarm itself. Each one runs several apps at once (an api, a web app, an admin, sometimes a mobile client), and I usually have three to five feature branches open across them. Every branch needs its own copy of everything. If two branches share a database, a migration on one breaks the other. If they share ports, the second dev server takes down the first. If the only url is localhost, I can't look at the work from anywhere but my desk.

Before swarm this was a pile of shell: git worktree add, neonctl branches create, pnpm dev, cloudflared tunnel run, and a note of which branch owned which port. Each step was easy. Forgetting one was not, and I forgot one about once a week.

A branch is a whole environment

swarm workspace create, run inside a worktree, does all of it in one command:

  • claims the next free workspace index for that project, and the index decides every port
  • branches the project's Neon database, with stage as the parent
  • writes the env the apps read, including the new database url
  • registers a public tunnel url for every app in the project

swarm workspace dev then starts the apps and the tunnel together. The ports come from a formula: the project's base port, plus the workspace index times a fixed stride, plus the app's own offset. Nothing is picked by hand, so two workspaces can't collide, and "which port is the api on" is arithmetic, not memory.

The parent branch is the part I'm most careful about. A Neon branch is a copy-on-write clone, so branching production would put live user data on every dev machine I own. Each project's config names stage as the parent, and the comment next to it says why. If the field were unset, Neon would fall back to the project default, which is production.

rendering diagram…
what one workspace create sets up

The tunnel is how I code from my phone. A cloud agent works on a branch, the workspace serves it at a stable url, and I open that url on the phone to check the result. For the mobile app, swarm cloud mobile does the same from a cloud VM: it brings up the database, boots the api, tunnels the api and Metro, and starts Expo pointed at both tunnels so the dev client on my phone can load it.

framework: orpc (one router per topic), trpc-cli (argv parsing, help), @clack/prompts · infra clients: neon api, cloudflared, flyctl · data: swarm-api (workspace registry), .env.workspace per worktree · build: typescript, tsdown, biome, vitest · gui: tauri desktop app

every command is a typed procedure with a zod input, so the same definition gives the terminal its flags and the desktop app its forms.

The cli that is also a gui

Every command is an oRPC procedure with a zod input and a little metadata: a description, examples, an icon. trpc-cli turns the input into flags and help text. The router loads one topic at a time, so swarm db … only imports the db commands.

The desktop app is a Tauri app, and it has no form code of its own for any command. It runs swarm internal schema, which walks the full router and prints every command with its flags, types, defaults and enum options. The desktop turns that into forms: value fields on top, switches for the booleans below. A command can also mark itself as a wizard. workspace create does, so the desktop shows an animated checklist built from the steps the cli emits while it runs, instead of a log. When I add a flag to a command, the form gets the field without a desktop change.

Chose

an orpc router + trpc-cli

  • one router per topic, loaded lazily
  • each command's zod input becomes its flags, validation and help
  • help is baked into a manifest at build time instead of walking the tree on every call
  • the desktop introspects the same router, so the gui is generated, not written
Over

oclif

  • what the first version of the cli was built on
  • commands are classes found by file layout, so the desktop would need its own copy of each command's shape
  • a typed router gives the same lookup without the file layout

oclif was a fine way to start, and its file-based command lookup was the draw. once i wanted a second client for the same commands, a typed router was the better fit: 36 topics and 121 commands today, all readable by the desktop.

Backups I have actually restored

swarm backups provisions production backups for any app that declares them. The dumps themselves run inside each app's own workers, nightly. Each dump is encrypted with age to two recipients, my key and a key that only the restore drill holds, and lands in an R2 bucket with lifecycle expiry and a retention lock. swarm backups status shows the newest daily and monthly dumps and flags a backup more than 26 hours old.

A backup nobody has restored is a guess, so there is a weekly restore drill. In Bagman it is scheduled for Sunday morning, after that night's backup has landed. It restores the newest dump into a dedicated drill branch, checks each table's row count against the dump's manifest, and compares the schema version on both sides: the count of applied migrations plus the newest one's hash. If either differs, the drill fails.

swarm backups restore can restore from an R2 dump, a Neon snapshot or a point in time, but only into a new branch. If the target branch already exists, it refuses. It prints the step that would promote the restored branch over production and never runs it. A restore is a bad moment to have a command that can overwrite the thing you're trying to save.

Chose

restore into a new branch, print the promote

  • every restore creates a branch that did not exist before
  • i can inspect the restored data next to production
  • promoting it is a step i run by hand, after reading it
Over

restore in place

  • one wrong flag overwrites the database you are trying to recover
  • nothing left to compare against if the dump turns out to be the wrong one

the cli can do almost everything, so the restore path is where i decided it should do less.

The architecture checks

swarm check arch is the other half of what swarm does for me. It runs dependency-cruiser for import boundaries plus 67 semantic rules I wrote, each one a pattern I kept correcting by hand in review: one component per file, types, constants and helpers in sibling files instead of inline, no emoji in source, no process.env outside the validated config, no repository calls from a router. Every repo that uses swarm runs it in its git hooks, on commit and again on push, scoped to the packages the branch changed. The long version, including how it keeps coding agents honest, is in custom architecture checks as guardrails for coding agents.

cloudflared made the wrong hostname and exited 0

The fix was to stop creating records. Each project now declares its own tunnel and one wildcard record that covers every workspace, so workspace create creates no dns at all for those projects. Each tunnel gets its own config file and its own connector, so two projects' tunnels can run side by side.

For the projects still on per-hostname records, swarm no longer trusts the exit code. It reads cloudflared's output, and if the hostname comes back with a zone appended, it reports that route as failed and says to log in again against the zone that owns the name.

What I tried first

one shared tunnel for every project, and a dns record per hostname, created with cloudflared tunnel route dns each time a workspace came up.

Why it failed

cloudflared's login cert is bound to one dns zone. when asked for a hostname in a different zone, it treats the name as a subdomain of its own zone, appends that zone, and exits 0. nothing failed until dns didn't resolve, much later. the per-hostname records also leaked: nothing ever deleted them.

the fly pruner kept an 11-day-old image live

Machine state is no longer part of the test. When an app's machines span more than one image, the newest created machine defines the keep-tag, and every machine on any other image is pruned, running or not.

The guard is the keep-tag itself: swarm only prunes once some machine on the newest image exists to take over, so an app is never left with no machines. That check is written out explicitly even though it holds today, so a later change to how the keep-tag is chosen can't quietly remove it.

What I tried first

before a deploy, prune stale machines, but skip any machine that is running, on the theory that a running machine must be the one serving the current version.

Why it failed

on one stage app it was the other way around. the machine on the old image was the one left running, and the current build sat stopped beside it. the app served an 11-day-old image for 11 days, and nothing looked wrong because the app was up the whole time.

Try it

the swarm command surface. live mode round-trips a few read commands to the public api; replay covers the rest. try `swarm --help` or `swarm workspace dev`.

live · provisioning commands stay replay

Where it is now

Swarm is internal. It isn't a product and I'm not trying to make it one. The changelog has 115 versions of the cli, from 0.0.2 to 4.11.1, and every repo I work in installs it from the registry and reads its own config to find its apps, its Infisical paths and its architecture rules. The workflow it was built for is still the one I use every day: create a workspace, run it, open the url wherever I am, and ship with swarm deploy fly when it's done.

⌘Kterminal⌘Lask