devboost is a Go program built as a Terraform-inspired typed-resource
engine: every module describes desired state as a list of typed
engine.Resource values, and one shared function (engine.ComputeDiff)
computes what's out of sync. plan and apply both call that same
function — plan prints the result and stops, apply prints and then
executes it — so there is no separate hand-written plan narration to
drift out of sync with what apply actually does.
This replaced an earlier bash implementation (still visible in git
history) where every module hand-wrote a plan function and an apply
function separately, describing the same change twice in two different
places. That duplication was a real, hit-in-production bug class — see
the CHANGELOG for the double-sourcing bug this surfaced.
ComputeDiff closes that bug class structurally: there is only one place
"what needs to change" is computed, for both commands.
devboost/
cmd/devboost/ # CLI entry point (main.go)
engine/ # Core engine — no knowledge of specific modules
resource.go # Resource, ResourceKind, PendingOp, ComputeDiff, DiffAndExecute
plan.go # Plan(): diff, print, stop
apply.go # Apply(): diff-and-execute one resource at a time, in dependency order
doctor.go # Doctor(): diffs the combined graph once, groups results by module
diagnostic.go # Diagnostic/DiagnosticFunc: read-only findings with nothing to converge
kinds/ # Resource kind implementations — the "providers"
directory.go # DirExists
gitclone.go # GitClone
file.go # File (full-content, backed up before overwrite)
gitconfig.go # GitConfig (shells out to `git config`)
blockinfile.go # BlockInFile / RemoveBlock (managed block between markers)
lineinfile.go # LineInFile (the dedup modules' comment-out mechanism)
package.go # Package (per-OS package manager abstraction)
commandguarded.go # CommandGuarded (the one deliberate escape hatch)
backup.go # Shared backup/snapshot/archive helpers
os.go # OS detection
modules/ # Module ports — the actual opinionated defaults
registry.go # Module registry: All, AllResources
znap.go, starship.go, tmux.go, mise.go, pkg.go, git.go, corepack.go,
direnv.go, services.go, security.go, zsh*.go, *_dedup.go, uninstall.go,
migrateohmyzsh.go, clean.go
config/ # ~/.devboost.yaml reader (config.Config)
// A resource kind knows how to diff itself against live system state.
type ResourceKind interface {
Diff() (*PendingOp, error)
}
// What a module declares: an ID, a kind, and optional dependencies.
type Resource struct {
ID string
Kind ResourceKind
DependsOn []string
}
// A pending change — never authored directly, always computed by Diff().
type PendingOp struct {
ResourceID string
Description string
Execute func() error
}ComputeDiff topologically sorts resources by DependsOn and calls
Diff() on each once — used by plan and doctor, where nothing has
converged yet so a single batch pass is safe. DiffAndExecute interleaves
diff-then-immediately-execute per resource in dependency order — required
by apply, because a dependent resource must see the real post-execution
state of what it depends on, not a stale batch diff.
A module is just a plain Go function returning a resource list — no interface to implement, no registration boilerplate beyond adding one line to the registry:
// engine/modules/foo.go
package modules
func Foo(cfg *config.Config) []engine.Resource {
if cfg.Get("foo.enable", "true") != "true" {
return nil
}
return []engine.Resource{
{ID: "foo_config", Kind: kinds.File{Path: cfg.Get("foo.path", "~/.foorc"), Content: renderFooConfig(cfg)}},
}
}// engine/modules/registry.go
var All = []Module{
// ...
{Name: "foo", Resources: func(cfg *config.Config, os kinds.OS) []engine.Resource { return Foo(cfg) }},
}That's it — plan, apply, and doctor all pick it up automatically
through AllResources/Doctor, with zero separate plan/apply logic to
keep in sync.
Module resource declarations are plain Go struct literals — not a DSL,
not YAML. This was a deliberate choice made once Go was already settled
on as the implementation language (the user-facing ~/.devboost.yaml
config file is unrelated and stays YAML — see config/config.go). The
reasoning: most of devboost's code will be read and modified by coding
agents more than by hand, so "not fluent in Go" carries little weight —
what matters is readability and correctness, and Go struct literals get
full compiler and type checking that a YAML-based DSL would need to
reinvent. See git history for the fuller discussion (Docker Compose was
considered as a counter-example favoring YAML, but ultimately devboost's
"a module should be trivial to add, optimized on boilerplate" goal was
better served by plain typed Go).
Each kind in engine/kinds/ is a small, reusable, parametrized primitive
— the equivalent of a Terraform provider resource type. Shelling out
inside a kind's Diff/apply is fine, and often correct, when the target
tool's own CLI is the best available diff/apply primitive (GitConfig
shells to git config, Package shells to brew/apt/dnf/pacman) —
never as a shortcut to skip writing a real diff.
CommandGuarded is the one deliberate escape hatch, for state that
genuinely doesn't fit any typed kind (see engine/kinds/commandguarded.go
for the full reasoning). A module still only ever declares data — {ID, Params, Wants} — never imperative logic at the declaration site. An
unregistered ID fails loudly (not a silent no-op): adding a new use
requires writing a real Go implementation in kinds, registered via
RegisterCommand, the same amount of real work as adding a proper kind.
This is intentional friction — it must never be the easy path when a
typed kind is achievable instead.
VendorInstall and GitHubReleaseInstall handle tools with no package
on a given platform at all (confirmed by directly querying real
apt/dnf, not assumed: lazygit, mise, atuin, starship, dust, and procs
aren't packaged on stock Debian/Ubuntu or Fedora, varying by distro).
VendorInstall fetches and runs a tool's own official non-interactive
install script; GitHubReleaseInstall resolves and extracts a project's
latest GitHub release binary when no install script exists at all. Both
install into kinds.ManagedBinDir (~/.local/bin), forced explicitly
via each installer's own destination env var — several default
elsewhere otherwise (atuin: ~/.atuin/bin; starship/dust:
/usr/local/bin) — and both are checked for presence via
kinds.BinaryAvailable, which — unlike a bare exec.LookPath — checks
ManagedBinDir directly, not just the current process's PATH (which
never includes it; only a future shell does, via the zsh module's own
rendered PATH export). Any kind or module invoking one of these tools
directly must resolve its real path via kinds.ResolveBinary for the
same reason — a literal exec.Command("mise", ...) from inside
devboost's own process silently fails "not found" even moments after
mise was genuinely installed in the same apply run, confirmed via a
real Ubuntu container test.
Checks like BinaryAvailable/ResolveBinary are deliberately
evaluated fresh every call (inside a kind's Diff/Satisfied/
Converge), never decided once when a module's resource list is built
— a module-construction-time-only check can never see a tool a
different resource installs earlier in the same apply run. This was
a real bug (Mise()/Services() both gated themselves off entirely at
construction time), not just a style preference: if a check like this
ever becomes expensive enough to need memoizing, that's an explicit
opt-in at the call site, not something baked into the calling
convention by default.
Resources declare explicit DependsOn when one resource's correctness
depends on another having already run — e.g. security's managed block
depends on zsh's full-file render, because writing the block first and
then having zsh's File resource overwrite the whole file would silently
destroy it. engine.ComputeDiff/DiffAndExecute topologically sort on
this, with cycle and unknown-dependency detection. This replaced the bash
version's implicit, hand-maintained module registration order.
doctor diffs every module's combined resource graph in one pass (not
per-module in isolation — an earlier version did that and broke the
moment a cross-module DependsOn was added, see the module's tests for
the regression coverage), then groups the results back by owning module
for display. This keeps output readable as dedup checks and diagnostics
accumulate — ten tools with several checks each still reads as ten
grouped sections, not a flat list of every individual check.
Every module that picks a specific tool documents why directly above
its constructor function — adoption/reputation research, first-party
guidance where it conflicts with common practice, and an honest
confidence level (well-documented consensus vs. taste vs. now-questionable).
See .agents/skills/devboost-module-author/SKILL.md for the process, and
any module file (e.g. engine/modules/git.go, engine/modules/corepack.go)
for real examples — including examples of a default that research
revealed was worth actually fixing, not just caveat-ing.
- Create
engine/modules/foo.gowith aFoo(cfg *config.Config) []engine.Resourcefunction. - Research the tool choice (see the module-author skill) and write the rationale as a doc comment above
Foo. - Add a test file
foo_test.gocovering the enable-gate and default-resource-shape. - Register it in
engine/modules/registry.go'sAllslice. - Run
go build ./... && go test ./.... - Add any new config keys to
.devboost.yaml.example.
- Idempotency: structural, not per-module discipline —
ComputeDiffreturning no pending ops is "nothing to do." - Non-destructive: never overwrite user files directly — managed blocks/includes, backups before every mutating write.
- Config-driven: sensible defaults in code, overrides in
~/.devboost.yaml. - Explicit dependencies:
DependsOn, not registration order, decides execution order. - One diff function:
planandapplycan never narrate a different change than what actually happens.
install.sh (repo root) is a small, pure-POSIX-shell dispatcher modeled
on rustup's rustup-init.sh: detect OS/arch (including the Rosetta 2
edge case on Apple Silicon), download the matching prebuilt binary from
the latest release,
exec it. No application logic lives there — everything real is in the Go
binary. Cross-compilation is currently local-only (GOOS/GOARCH builds
run by hand for each release, no GitHub Actions release pipeline yet).
- Automate cross-platform release builds (currently cut by hand — see Bootstrap Distribution above).
- In-tool config-schema-version warning (bash version had this; not yet ported — see CHANGELOG.md).
- Async/prefetched
apt-get updateonce the CLI orchestrates multiple resources concurrently. - Periodic adversarial re-review of each module's tool choice against current ecosystem state (see the module-author skill).