BoomTube
v0.32.0

BoomTube — design spec

BoomTube is declarative dev-machine setup: a single self-contained binary (executable: boom), compiled from TypeScript on Bun, that converges a machine to a declared state — dotfiles, packages, and tools from one boomfile.toml, with drift detection — and a journal that preserves whatever it displaces. Named for Kirby's Boom Tube — an instant conduit between worlds — it opens a portal to your machine's ideal state, and to your code.

It began as a bash prototype (extracted from alxjrvs/dotFiles) and was rewritten to TypeScript; this document is the design of record for that engine.

This document describes the current design, not how it got here. When a release changes behavior a running machine depends on, the upgrade path is a migration note beside it:

(Absolute links, not repo-relative ones: this file is also rendered as a standalone docs page, where a relative path into docs/ would not resolve.)

The model (decided — don't relitigate)

A boom invocation does one of two things:

  1. Reconcile verbs over a config repo's boomfile.toml — the sync verb runs on the bare boom source command (and its explicit boom source sync spelling); the rest are their own top-level commands:

    • boom source / boom source sync — reconcile the machine to the boomfile, running the sync verb (--fix repairs drift by overwriting conflicts; --update also updates outdated brew formulae)
    • boom verify — check drift, exit 0 ok / 2 warn / 1 fail (--json for a report; --ci narrows to a non-interactive schema-check gate, 0/1, no machine walk)
    • boom uninstall These share one verb-parameterized loop (src/engine/reconcile.ts) over a resource-type registry — siblings, not separate scripts. source --resume continues an interrupted one. A conflicting (non-boom-owned) file at a link destination is skipped by default (boom never clobbers a file it doesn't own); source --fix opts into overwriting it — that's how drift is repaired, so there's no separate fix verb. sync is the one canonical reconcile name; bare boom source is its shorthand (the namespace's default command), not a separate alias.

    The sync verb (never verify/uninstall) also syncs the config repo's own git state against its remote first (src/engine/sync.ts): by default pull --rebase --autostashs, so any uncommitted local edits ride along and land back on top; source --commit commits local edits first instead of autostashing them, so they replay as a real commit on the rebase. boom source push commits local config-repo changes and opens a pull request for them (src/engine/commit.ts), sharing its commit logic with source --commit so the default message/behavior can't drift.

  2. Discovered subcommands — built-ins are the @stricli route map, in src/cli.ts order:

    verify, uninstall, source, upgrade, doctor, skill.

    That list is asserted equal to commandNames() by test/docs-hygiene.test.ts, so adding a route without naming it here (or naming one that no longer routes) fails CI. source is itself a nested route map. User commands resolve at runtime from <config>/commands/<name>.ts. The route map is the single registry, with no hardcoded dispatch anywhere: index.ts decides built-in-vs-discovered by asking the route map itself (getRoutingTargetForInput), and src/commands/catalog.ts derives command names + briefs from that same route map for boom skill — one source of truth, no parallel table to keep in sync. That is why removing a verb is a one-line edit here: the shell completions and man page it also fed were derived, and were deleted with their commands.

The journal, without an undo verb

Every mutation is still journaled, and an overwrite still displaces the original into backups/<run-id>/ at 0700 rather than destroying it. What is gone is boom rollback: nothing replays those records automatically any more. Two consumers keep the journal load-bearing — displace() is what makes source --fix non-destructive, and --resume reads the last uncommitted run to continue it rather than opening a second.

So a bad sync is recovered by hand, from a backup tree that is still written for exactly that purpose. That is the trade: the records cost what they always did, and reading them is now a person's job.

Config source is a git remote (repo-only)

boom source set takes a remote reference — owner/repo, github:owner/repo, a full git URL, optionally @ref — never an arbitrary local path. Boom clones it into a managed cache dir (configRepoCacheDir, under the state dir) and records the breadcrumb ({ path, remote: { url, ref? } }), then syncs immediately — the one-command fresh-machine bootstrap is curl install.sh | sh && boom source set owner/repo, no repo-relative bootstrap script needed. --no-sync records only (review first, or re-point at a different repo without reconciling).

Sync is a pre-reconcile step (src/engine/sync.ts), not a resource: verify fetches and reports drift without touching the working tree — "N commits behind origin", plus separate warnings for uncommitted local changes and committed-but-unpushed local commits, since a clean behind-count alone would otherwise read as "up to date" while either kind of local drift sits unreported; the sync verb pulls first and reports what moved, then reconcile proceeds against whatever's on disk either way — a failed pull (including a git rev-list failure while checking drift) is reported as a failure but never blocks reconciling from the last-known-good local clone.

The pull is git pull --rebase --autostash (git stashes any dirty tracked changes before rebasing and restores them after, including automatically on an aborted rebase); source --commit commits local edits first instead of autostashing them (src/engine/commit.ts, shared with boom source push).

A rebase conflict aborts cleanly (git rebase --abort, which also restores the autostash) and is reported as a failure, but reconcile still proceeds from the local state as it was before the rebase attempt.

A pinned @ref (tag/sha, detached HEAD) is reported as static rather than checked for drift. Auth is whatever git/SSH already works in the user's shell — no boom-side credential handling.

The config-repo git verbs live under one namespace: boom source status is the read-only "how does my clone stand against origin?" (behind / unpushed / dirty, exit 0 in sync / 2 on drift) — the same summary the verify path shows, over a shared repoDrift helper, but without also walking the whole machine; boom source push commits any local config-repo changes and gets them upstream (-m/--message sets the commit message).

push takes the safe route by default. On a GitHub clone sitting on its default branch it publishes HEAD as boom/<subject-slug>-<sha> and opens a pull request against that branch rather than pushing it directly — a direct push is refused outright by any repo that protects its default branch, and a repo that runs CI on its config wants that CI to have seen the change before it is live. --direct forces the historical plain push, and boom falls back to it on its own — saying why — when there is no PR to open: a non-GitHub origin, no gh on PATH, an unset origin/HEAD, or a clone already checked out on a feature branch. --merge additionally asks GitHub to land the PR once its required checks pass, which is a request rather than a merge: nothing unverified gets in.

The clone's working tree never moves in PR mode. That tree is the target of every dotfile symlink on the machine, so checking out the PR branch would swap the user's live config out from under them until it merged; boom pushes the ref (git push origin HEAD:refs/heads/…) and leaves HEAD on the default branch, one commit ahead of origin. The next sync rebases, recognizes its own patch in the squashed merge, and drops it. The branch name embeds HEAD's short sha, so it is derived from the commit rather than a clock — re-running after a failed gh call reuses the ref it already pushed instead of opening a second near-identical PR, and the push is never forced.

boom source reset is the other direction — fetches, then hard-resets to the upstream tip (or the pinned @ref for a detached clone) and clears untracked files, discarding local changes back to what a fresh re-clone would leave. Like linkRemoteConfigRepo, boom source reset refuses to discard commits no remote has (listing them) unless --force is passed — uncommitted changes alone don't need --force, only unpushed commits do. linkRemoteConfigRepo itself refuses to wipe a managed clone that has either uncommitted changes or commits not yet pushed (checked separately — git status --porcelain never reports ahead-of-upstream) — boom source push or boom source reset first, then re-link.

Config is typed TOML, not code

boomfile.toml is a TOML document validated against a schema (src/config/schema.ts, valibot). It is grouped into [[section]]s; within a section, resources run in a fixed phase order: link → copy → tmpl → secret → dir → pkg → osx_default → launchd → run → check → absent → hook. Resources:

A section may carry when = { os, host, profile } to gate by machine, where each value is a string or a list of strings (any-of within an axis, AND across axes); overlay files boomfile.<os|host|profile>.toml are merged onto the base. --profile (repeatable) activates named profiles; os/host auto-match (overridable via BOOM_OS/BOOM_HOST). An overlay merges its [vars] and [boom] over the base's last-wins per key as well as appending its sections, [[section]] is optional in an overlay only (a vars-only overlay is legal; a base boomfile.toml with no sections is still a hard load error, so an empty or half-written one can never read as "declare nothing" and have every managed file reaped), and because a shallow last-wins merge on an ARRAY key is a replace rather than an append, an overlay declaring one drops the base's entries entirely.

A top-level [vars] table (a name→string map) supplies the values tmpl resources interpolate.

Duplicate file destinations resolve last-wins across [modules…, base, overlays…] — and only among the sections that apply to this run. link, copy, tmpl and (on macOS) launchd are keyed on their expanded dst alone (a module link and a base copy to the same path are one conflict, not two declarations), the loser is dropped at compose time rather than run and then fought over, and each override is reported as a CONFIG note. A secret — and a launchd off macOS — is keyed per kind instead, so it still beats a duplicate of itself but never overrides a kind of a different name. Both gates exist for the same reason: only a kind that takes ownership of the destination may take it away from another, because a winner that declares nothing leaves the file declared by nobody — and orphan reaping deletes exactly that. A winner hidden behind when would do the same, which is why gating is resolved before keying. Keying happens before the repo is walked, so it cannot see glob expansion: two glob src entries are never keyed against each other and can still collide on a concrete dst at run time, which the manifest write collapses last-wins as a second line of defense.

[boom] — machine-global self-wiring

A single top-level [boom] table folds boom-invoking-boom behaviors into the reconcile boom already runs, so a consumer stops hand-rolling run/plist boilerplate for them. Every field is opt-in; an absent (or all-off) table changes nothing. The behaviors are work items run through the same guarded loop as section resources (runWorkItems, src/engine/settings.ts) — so skill writes are journaled and recoverable — verb-aware (sync installs/refreshes, verify reports drift, uninstall tears the timers down):

Escalation, and why there is no askpass key

A tool boom spawns can escalate on its own — Homebrew runs sudo for any cask carrying a launchctl/pkgutil stanza, which boom source --update reaches whenever an outdated cask is declared (greedy or not). boom lets it ask you. sudo writes its prompt to /dev/tty, which no amount of silenced stdout suppresses, so the only thing that ever hid it was boom's own spinner redrawing that line 11×/second — an escalating step therefore runs under a persistent label instead of an animation, and the prompt survives. That needs no configuration.

A prompt you can see is still worth nothing if it doesn't say what wants the password, so a step that can escalate names its asker two ways. SUDO_PROMPT relabels the prompt itself ([boom] brew bundle needs administrator rights — password for jarvis:), which sudo honors from the invoking environment and Homebrew forwards untouched. And because sudoers' escapes (%p, %u, %H) have nothing for the command, the specific culprit comes from the tool's own output: boom pipes the step's stdout and relays only Homebrew's ==> headlines as live lines, so ▸ Upgrading cask tuple sits directly above the prompt while the byte counts stay hidden under the band. Piping also costs the tool its tty, which conveniently drops its colors and progress bars; the prompt is unaffected, since /dev/tty is not stdout.

There was a vault-backed key here for the unattended case (a launchd timer, CI), and a matching boom askpass command. The command is gone; the key is retired. The command printed a resolved secret to stdout, which is a second way to read a vault value under a program name a machine's own controls are unlikely to have denied. Its own documentation argued the verb needed no fence because "anyone who can run boom askpass op://… can run op read op://… directly" — false on any machine that restricts the vault CLI, which is exactly the machine that most needs the guarantee. No configured user was found; the feature was carrying that exposure for nobody.

sudo_askpass is still accepted and ignored, so a boomfile carrying it keeps loading. That is deliberate, and it is a different call from copy.expand, which is declared v.never so the failure can name its replacement. That pattern fits when the migration is another config key — the error names it and you edit one line. Here the migration is an environment action, which no config edit expresses, so failing the whole boomfile would strand a machine over a key whose replacement isn't in the file at all. A mutating sync warns when the key is set; the key is deleted at 1.0.

If you need an unattended escalating sync, export SUDO_ASKPASS yourself — it is sudo's variable, not boom's, and boom still honors one it inherits: it skips the prompt label and the header relay (nothing is going to ask). Otherwise keep mutating syncs interactive.

Hooks = the resource-type extension contract

hooks/<name>.ts default-exports (or names) sync/verify/uninstall functions — plus an optional declare run on every verb — receiving a HookApi:

{ with, verb, dryRun, env,                       // inputs
  repo, vars, os, host, profiles, linkMode, verbose, update,   // the run's context
  ok, warn, fail, note, plan, skip,              // the same output tiers a core resource uses
  declare(entry), journalWrite(op, file) }       // the two capabilities

Loaded by runtime import() (works inside the compiled binary). This replaces the bash _NAME_<verb> hooks and is the public extension point. What a hook gets is what a built-in resource gets:

A hook is still arbitrary code, so the hook side-effect marker stays in the journal regardless: journaling part of a hook's work never makes all of it reversible.

What remains a core change is adding a new resource type: SectionSchema is a valibot strictObject and RESOURCES is a static table — both deliberate consequences of the typed-validated-TOML north star. Note also that HookApi is not importable by a hook module (hooks are untyped .ts loaded by import()), so nothing type-checks a hook against it.

Transaction + state

On-disk state lives in a single bun:sqlite database at ${XDG_STATE_HOME:-~/.local/state}/boom/state.db (src/engine/db.ts): the per-run transaction journal (intent/done rows + undo token, a committed flag) and the manifest of owned destinations. Each journal row commits atomically (WAL), so an interrupted run leaves whole rows — there's no torn-record to guard against on read. Every run that writes holds an exclusive lockfile under the state dir (src/lib/lock.ts) — sync and uninstall — so concurrent runs can't race on destinations or clobber each other's manifest; a stale lock from a crashed run (dead pid) is reclaimed. committed is set only when the run finished with zero failures, and only after the [boom] self-wiring and the end-of-run finalize phases, both of which can still fail — a failure in either leaves the run uncommitted, so --resume distinguishes a clean run from a half-applied one. Each destructive filesystem op journals its whole undo — intent, the displaced original, and the done row naming it — before the write, so no crash can orphan a backup nothing points at. source --resume continues the interrupted run in place (its id + backup tree) rather than opening a new one. Mutating runs also back up any displaced file under …/backups/<run-id>/. boom rollback replays a run's done rows in reverse (remove created links, restore backups, re-apply a macOS default's prior value) — like a Mother Box, it remembers everything and can put it back; --dry-run previews the replay. It never claims an undo it did not perform: a directory boom created is reversed with rmdir (one the user has since filled is left in place and reported, never recursively deleted), and a defaults restore that exits nonzero is a reported failure, not a green line. boom rollback --to <checkpoint> exits 2 with a warning when the journal was pruned past the checkpoint, so a partial rewind is never mistaken for a complete one. The manifest drives orphan reaping (verify warns; sync reaps), and a legacy TSV manifest is imported once on upgrade. Breadcrumbs (config, code) record the config repo (path + remote) and code dir.

boom.lock — version pinning

A boomfile declares what to install, not which version, so two machines syncing the same config a week apart can land on different packages. boom lock (src/engine/pinning.ts) closes that gap without changing the model: it resolves every declared package to the version actually installed and writes them to boom.lock in the config repo — a committed, reviewable artifact, not machine state (which is why it lives beside the boomfile and not under the state dir). boom lock --check compares the machine against that file and exits on the usual warning-tier ladder (0 clean / 2 drift / 1 failure), so it works as a CI gate. Distinct from src/lib/lock.ts, which is the run mutex above — same word, unrelated concept.

Stack

Concern Choice
CLI @stricli/core — the only framework that compiles cleanly under bun build --compile
Config TOML via Bun.TOML.parse (lib/toml.ts re-adds the line number Bun omits), validated by valibot
State bun:sqlite (state.db: owned-destinations manifest + transaction journal)
Shell / process Bun.$ / Bun.spawnSync; node:fs/promises for symlink/copy/mode
Output Bun.color palette + a tally Reporter (drives exit codes)
Quality gates Biome (lint + format), tsc --noEmit, bun test
Distribution bun build --compile matrix (macOS arm64/x64, Linux x64)

Layout

src/
  cli.ts · index.ts        @stricli app + entrypoint (one dispatch: route-map lookup →
                           discovered user cmd, else Stricli — no hardcoded cases)
  commands/                verify/uninstall + source (reconcile.ts; source runs the
                           sync verb — `--fix` overwrites conflicts — and namespaces
                           the set/status/diff/push/reset
                           route map — set is the bootstrap),
                           where, rollback, upgrade, doctor (--config folds in the
                           former validate), code, mcp (add
                           route), completions, man, skill
                           catalog.ts (names+briefs + nested subcommands derived from the
                           route map for completions + man + skill); flags.ts (shared parsers)
  engine/
    reconcile.ts           the one verb loop
    sync.ts                pre-reconcile config-repo fetch/pull(--rebase --autostash)-and-report
    commit.ts              commit local config-repo changes (shared by `boom source push` + source --commit)
    diff.ts                boom source diff (read-only: working-tree diff vs HEAD + untracked)
    status.ts              boom source status (read-only drift vs origin, shared reportRepoDrift)
    push.ts reset.ts       boom source push / boom source reset
    pr.ts                  the GitHub half of source push (slug, branch name, gh)
    overview.ts            boom status (read-only dashboard composing the existing readers)
    registry.ts            data-driven resource table (phase order) + finalize hooks
    resources/             link · copy · tmpl · secret · dir · pkg · osx · launchd · run · check · hook
    secrets/backends.ts    pluggable secret backends (op · env)
    db.ts journal.ts       bun:sqlite store: transaction journal
    state.ts               the owned-destinations manifest (layout lives in lib/paths.ts)
    skill.ts               renders the Claude SKILL.md (commands/skill.ts is the CLI wrapper)
    pinning.ts             boom lock / --check: resolved package versions in boom.lock
    rollback.ts code.ts discovery.ts
  config/  schema.ts load.ts compose.ts remote.ts profile.ts
  lib/     reporter.ts color.ts fs.ts paths.ts proc.ts git.ts release.ts version.ts
test/                       bun test (unit + sandboxed integration)
examples/dotfiles/          a runnable boomfile.toml example
.github/workflows/          ci.yml (check + cross-compile smoke), release.yml (tag → matrix → attach)

Distribution

install.sh downloads the matching binary from the GitHub release; Formula/boom.rb installs it via Homebrew (the repo doubles as the tap). release.yml cross-compiles the matrix on Linux, then signs the macOS binaries on a real macOS runner before assembling the release and computing checksums over the final binaries. Signing is ad-hoc by default (valid on Apple Silicon); add the MACOS_*/APPLE_* repo secrets to switch on Developer ID signing + notarization (see the header of release.yml). install.sh/boom upgrade only re-sign ad-hoc when a download fails verification, so a notarized binary is never clobbered.