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.
- 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.
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.
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
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.
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
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.
one shared tunnel for every project, and a dns record per hostname, created with cloudflared tunnel route dns each time a workspace came up.
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.
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.
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.
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`.
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.