No description
  • Go 98.5%
  • Shell 1.1%
  • Makefile 0.4%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-07-26 00:31:31 +00:00
cmd/dotrig arch fixes 2026-04-30 07:51:50 +02:00
internal refactor(dropin): app-namespace fragments, derive ext from source 2026-07-26 01:16:27 +02:00
schemas refactor(dropin): app-namespace fragments, derive ext from source 2026-07-26 01:16:27 +02:00
testdata/repo refactor(dropin): app-namespace fragments, derive ext from source 2026-07-26 01:16:27 +02:00
.gitignore chore(make): add release-vm target; ignore test-dist/ 2026-05-17 10:28:08 +02:00
ARCHITECTURE.md docs: bootstrap HTTPS clone + system-git ops 2026-05-17 18:58:46 +02:00
CLAUDE.md docs: bootstrap HTTPS clone + system-git ops 2026-05-17 18:58:46 +02:00
go.mod chore(deps): update module golang.org/x/net from v0.54.0 to v0.56.0 [security] 2026-07-26 00:31:31 +00:00
go.sum chore(deps): update module golang.org/x/net from v0.54.0 to v0.56.0 [security] 2026-07-26 00:31:31 +00:00
IDEA.md update docs 2026-05-02 22:51:55 +02:00
install.sh refactor(dropin): app-namespace fragments, derive ext from source 2026-07-26 01:16:27 +02:00
LICENSE license: add MIT LICENSE + README link 2026-05-01 05:34:30 +02:00
Makefile chore(make): add release-vm target; ignore test-dist/ 2026-05-17 10:28:08 +02:00
README.md feat(loader): recursive index for apps, machines, profiles 2026-05-17 16:26:46 +02:00
renovate.json chore(deps): add renovate.json 2026-07-25 23:31:03 +00:00

dotrig

A single-binary Go CLI that reconciles a workstation against a declarative config repo — Kubernetes-style GitOps for your laptop.

Replaces a 162-role Ansible setup (see ../dotfiles-ansible) with a DAG-driven, concurrent, drift-aware engine. Current implementation is documented in ARCHITECTURE.md; the original design vision is in IDEA.md.

Heads up

Work in progress. Not ready for anyone but me — and maybe not ever. Names, flags, config shape, and on-disk state format will change without notice or migration path. There's no release, no stable API, no compatibility promise. I'm building this for my own machines; if it ends up useful to someone else, great, but that's not a goal yet. If you find this repo and it looks interesting, watch it — don't depend on it.

Status

Milestones 05 complete. dotrig apply deploys your dotfiles and runs real package managers — brew install, brew install --cask, brew tap, apt-get install (with modern signed-by= keyrings for third-party repos), mas install, plus op read for 1Password references. A fresh Mac running the install/bootstrap/clone/ apply sequence above converges from a bare OS to a fully provisioned shell.

M4 added the primitives migration needs: directory tree copy, mas provider, apt repos+keys, Script install (curl|sh escape hatch), download and unarchive strategies, post-config notify/handlers, --only/--skip filtering, and dotrig clone (in-process via go-git so no system git is required).

M5 added distribution: make release cross-compiles 4 binaries with sha256 checksums; install.sh drops the binary on PATH; dotrig bootstrap installs host deps via the existing pkgmgr providers; the sudo timestamp gets a keepalive whenever an apt run is queued.

--render-to <dir> redirects every ~-rooted destination into a sandbox so you can preview a profile against a real apply without touching your live $HOME — useful for migration testing. --configs-only skips installs for the template-rerender loop. --no-resolve-secrets swaps in deterministic placeholder values for every op://... ref so the sandbox renders work fully offline. --no-pull skips the git pull step at the top of apply.

What works today:

  • dotrig plan resolves a profile, walks depends_on transitively, builds a DAG, and prints it in topological levels.
  • dotrig apply walks the DAG concurrently, routes each node through the dispatcher, writes a real state.json, and prints an Ansible-style end-of-run summary. --dry-run swaps in a no-op applier and does the whole walk without touching disk.
  • Install nodes are batched per package manager (one install:brew node, packages aggregated from every contributing app). Cross-PM dependencies are tracked when an app's depends_on crosses PM boundaries.
  • Real Brew, Apt, Mas, and Script providers; pkgmgr.Default registers the subset that reports Available() on the host.
  • Provider preflight auto-installs everything providers themselves need before any DAG node runs: mas (a brew formula) lands first whenever any resolved app declares a mas: block, and every registered secret store contributes a BootstrapInstall block (e.g. the op CLI on macOS or the 1Password apt repo on Debian) that the engine dispatches to the host PM. Users never type brew install mas or apt install 1password-cli themselves. Brew itself is the one chicken-and-egg case — preflight refuses to run on macOS without it and points at dotrig bootstrap.
  • An app that declares blocks for several non-additive PMs picks one primary via pickPrimary (alphabetically-first registered); the others are dropped with a warning. Script is the only additive PM today and coexists with whichever primary was chosen.
  • The 1password store calls op read --no-newline (with --account= if the ref carries one); the env store reads os.Getenv.
  • State storage at ~/.local/state/dotrig/state.json: atomic tmp-file + rename, sha256-based drift detection, exclusive flock on the state file so two concurrent applies serialize cleanly.
  • SIGINT/SIGTERM cancel the run via context propagation. The engine releases the state lock and exits with code 130.
  • --force is wired through both the engine and RealApplier: the engine refuses to overwrite a hand-edited managed file unless --force is set; RealApplier backs the original up to <dst>.dotrig-backup-<unix-ts> before clobbering so you can always recover.
  • Per-app variables merge Application → Profile → Profile.Overrides[app] (last wins) and feed the template engine through Engine.Vars. The machine layer (vars and overrides) folds into the profile at load time, so the runtime chain is unchanged but reflects machine wins.
  • Drop-in fragments land at <dir>/<NN>-<app><suffix> where NN comes from the DAG's per-level node index.
  • Brew installs run brew tap, then brew install <pkgs>, then brew install --cask <casks> — taps before formulas before casks so tap-sourced formulas resolve. Each call surfaces brew's own stderr on failure.
  • Apt installs detect uid: as root, apt-get install -y directly; as a regular user, sudo -E apt-get install -y with stdin passthrough so the password prompt reaches your terminal. DEBIAN_FRONTEND=noninteractive is set so dpkg never blocks on a config dialog. apt-get update is your responsibility — run sudo apt update once before the first apply on a fresh box.
  • 1Password lookups go through {{ resolve }} — vars carry a {secret: {provider: 1password, ref: op://..., account?: ...}} map and dotrig calls op read --no-newline (with --account=<value> if account is set). dotrig assumes you've already signed in (desktop integration, biometrics, or op signin) and surfaces op's own auth-required error otherwise. Secret values are never logged.
  • dotrig apply warns about orphans — files dotrig once managed but the active profile no longer declares (e.g. after removing a ConfigItem from an app.yaml). dotrig prune walks state, lists orphans, and prompts before deleting each (--yes for non-interactive).
  • dotrig status reads state.json, re-hashes every tracked file, and prints one line per entry with an ok / modified / missing verdict. No engine, no config repo, no mutation. --changed skips clean rows, --app <name> filters to one app's contributions, --count emits a plain integer drift count (-1 never-applied, 0 clean, N drifted) for shell prompt segments.
  • dotrig apply --configs-only skips every install node — useful for the "I edited my zshrc template, just rerender please" loop and to keep CI off the host package manager.
  • Profiles can extends: one or more parents (scalar or list) and excludes: apps inherited from them. Multiple parents fold left-to-right (later wins, child overlays last); diamond inheritance resolves cleanly; cycles are rejected at load time.

What's deferred: package-manager proxy/aliasing, the TUI, per-node timeouts, native systemd / launchd primitives, macOS defaults, external/dynamic provider loading. Globals (a leading layer in the hierarchical merge) and drop-in numbering buckets (50- style tiers instead of dense 00-/01-/02- ordinals) are also out of scope today — the merge function and DagIndex plumbing both support them when needed.

Install

The canonical sequence on a fresh box is two commands:

# 1. Drop the dotrig binary on PATH (curl OR wget — script supports both).
curl -fsSL https://codeberg.org/dotrig/dotrig/raw/branch/main/install.sh | bash
# or:
wget -qO-  https://codeberg.org/dotrig/dotrig/raw/branch/main/install.sh | bash

# 2. Fresh-machine setup: prompts for repo URL + machine name, installs
#    host prereqs (Homebrew/apt deps + secret-store CLIs), clones the
#    config repo via go-git, writes ~/.config/dotrig/config.yaml.
dotrig bootstrap

# 3. Sign into 1Password (if used), then materialize the workstation.
dotrig apply

# Or fold steps 2 and 3 into one call with --apply (it will block on
# `op signin` and pick up where it would have stopped):
dotrig bootstrap --apply

install.sh only installs the binary — host bootstrap moved into the binary so the bash audit surface stays minimal and idempotency comes free from the existing pkgmgr providers.

What's actually interactive on a fresh box. Bootstrap walks away on its own except for these unavoidable touchpoints:

  • The two prompts at the very start (repo URL + machine name). Pass --repo and --machine to skip both.
  • Homebrew's first-install case: brew's own installer asks for sudo and may pop the macOS Xcode Command Line Tools dialog. Subsequent runs short-circuit since brew is already on PATH.
  • With --apply: an op signin block if 1Password is registered and not signed in. Bootstrap loops on a clear "run eval $(op signin) in another terminal, then press Enter" prompt. Type skip to bypass.
  • sudo for apt installs on Linux: prompted once, kept warm by the engine's sudo-keepalive for the rest of the run.

Re-running dotrig bootstrap on a host with an existing dotrig.yaml is safe: prompts are skipped (values pre-filled from disk) and the clone short-circuits if the path is populated.

Non-interactive use (CI / containers): pass --repo, --machine, and --non-interactive to fail instead of prompting. --no-brew and --no-secret-stores opt out of individual install stages; --apply chains the apply phase end-to-end.

For users who prefer separate commands, dotrig clone <url> <path> exists as the explicit-clone primitive bootstrap calls into.

Quick start (development)

You need Go 1.26 or newer:

go version   # should print go1.26.x

Clone, build, and run the bundled fixture:

git clone <this repo>
cd dotrig

# Compile every package in the module. './...' = "this dir + all subpackages".
go build ./...

# Run the test suite (every _test.go file under ./...).
go test ./...

# Try the CLI without building a binary first.
go run ./cmd/dotrig version
go run ./cmd/dotrig plan  --config testdata/repo/dotrig.yaml
go run ./cmd/dotrig apply --dry-run --config testdata/repo/dotrig.yaml

Expected plan output (against the fixture profile work, which has fzf, git, tmux, zsh; fzf depends on zsh):

os        darwin/arm64
pkgmgrs   brew, mas, script
plan      2 levels, 5 nodes

level 0
  install:brew   4 apps  fzf, git, tmux, zsh

level 1
  config:fzf[0]    /Users/you/.config/zsh/conf.d/00-fzf.zsh
  config:git[0]    /Users/you/.gitconfig
  config:tmux[0]   /Users/you/.config/tmux
  config:zsh[0]    /Users/you/.zshrc

Each config: row shows the destination path so you can verify a plan before letting apply touch disk. Output is colour-aware when stdout is a TTY.

apply --dry-run ends with an ansible-style summary line counting installs / configs / errors.

A Makefile wraps the most common dev tasks (make help lists them): make build, make test-race, make smoke, make cover, make fmt-check.

To install the binary into your $GOBIN (typically ~/go/bin):

go install ./cmd/dotrig
dotrig version

Repo layout

dotrig/
├── cmd/dotrig/                 main(), wires the cobra command tree
├── internal/                   all implementation lives here (see below)
├── testdata/repo/              fixture profile + apps used by tests and demos
├── ARCHITECTURE.md             current implementation (read this first)
├── IDEA.md                     original design vision
├── CLAUDE.md                   conventions for AI agents working in this repo
├── go.mod                      module path + Go version
└── go.sum                      checksums of dep versions

A note on internal/: in Go, any package under a directory named internal is importable only by packages inside the parent module. It's the language-level way to say "this is private API." Everything outside cmd/ is intentionally internal/ until something earns a stable public surface.

The internal packages, with their job in one line:

  • internal/cli — cobra commands (apply, plan, status, bootstrap, add, enable, disable, remove, update, fix, prune, clone, version); the only place flags are read.
  • internal/config — loads ~/.config/dotrig/config.yaml (the user-level config).
  • internal/domain — declarative types: Profile, Machine, Application, ConfigItem, InstallBlock.
  • internal/loader — reads profile/app/machine YAML, folds extends/excludes, expands depends_on transitively.
  • internal/engine — orchestrator: Plan builds the DAG, Reconcile builds + executes (preflight, sudo keepalive, dispatch, handlers, orphan warning, state save).
  • internal/dag — DAG primitives, cycle detection, topo levels, concurrent runner.
  • internal/templatetext/template wrapper with resolve/env/osFamily/arch/codename/default funcs, hierarchical var merge, and a secret cache.
  • internal/provider/pkgmgrPackageManager interface and Brew/Apt/Mas/Script implementations.
  • internal/provider/secretSecretStore interface and OnePassword/Env implementations.
  • internal/runRunner interface and OS impl: the single audited shell-out seam (everything except go-git routes through here).
  • internal/statestate.json read/write (atomic tmp-file + rename), sha256 drift detection, exclusive flock.
  • internal/osinfo — host OS detection (runtime.GOOS + /etc/os-release parser).
  • internal/fsApplier interface (Symlink, WriteRendered); DryRunApplier + RealApplier.
  • internal/pathx — path/mode helpers (ExpandHome, ResolveWithin, ParseFileMode) used at the engine boundary.
  • internal/log — thin wrapper around log/slog.
  • internal/vcs/git — in-process git via go-git/v5 (clone, pull, commit, push); no system git required.

The single-mutation rule: the only packages allowed to write to disk are internal/fs (config files), internal/state (state.json), and internal/provider/pkgmgr (when it invokes real package managers). Anything else that touches the filesystem is a bug.

Go workflow cheat-sheet

These are the commands you'll run constantly. The first time you encounter a flag here, this is what it does:

# Build every package. Doesn't produce binaries unless cmd/* is included.
go build ./...

# Run tests. -race instruments goroutine accesses; use it in CI and any time
# you touch dag/runner.go or anything concurrent. -count=1 disables Go's
# test result cache so tests actually re-run after no code change.
go test ./... -race
go test ./... -count=1

# Run a single package's tests, with verbose pass/fail per test:
go test ./internal/dag -v

# Run one specific test, by regex match on its name:
go test ./internal/dag -run TestCycleDetection

# Static analysis (suspicious-looking code: unused vars, shadowed errs, etc).
go vet ./...

# Format every .go file. 'gofmt -l .' lists files that need formatting,
# 'gofmt -w .' rewrites them in place. CI should run -l and fail if non-empty.
gofmt -l .
gofmt -w .

# Update golden test files (we use the convention from internal/template).
go test ./internal/template -update

# Tidy go.mod after adding/removing imports — drops unused deps, adds new ones.
go mod tidy

# Inspect deps:
go list -m all

If you're coming from another ecosystem: go build ./... is roughly cargo build --workspace / mvn compile; ./... is the recursive glob. Tests live next to source in *_test.go files in the same package.

Configuration shape

A user-level config tells dotrig where the config repo lives and which machine selector is active. The machine selector then resolves to a profile (and per-machine vars/overrides) inside the repo, so two similar hosts can share one machine file or each pick its own:

# ~/.config/dotrig/config.yaml
repo:
  url: git@github.com:matthias/dotrig-config.git   # for future git pull; ignored for now
  path: ~/.local/share/dotrig/config               # local checkout dotrig reads from
  branch: main
machine: laptop                                    # picks <repo>/machines/laptop.yaml
state_path: ~/.local/state/dotrig/state.json       # optional; defaults to XDG state dir

Relative repo.path is resolved against the directory of the config file itself — that's why the bundled fixture at testdata/repo/dotrig.yaml can say path: ..

The config repo (repo.path) holds the declarative content. Machines select a profile and layer host-local details; profiles compose other profiles; each application owns a directory so its manifest and source files travel together — one app, one touchpoint:

<config repo>/
├── machines/
│   ├── laptop.yaml
│   └── desktop.yaml
├── profiles/
│   ├── base.yaml
│   ├── work.yaml
│   └── personal.yaml
└── apps/
    ├── fzf/
    │   ├── app.yaml
    │   └── files/fzf.zsh
    ├── git/
    │   ├── app.yaml
    │   └── templates/gitconfig.tmpl
    └── zsh/
        ├── app.yaml
        └── files/zshrc

Inside each app dir:

  • app.yaml — the application manifest (install + configs).
  • files/ — verbatim sources for copy and symlink strategies.
  • templates/text/template sources for template strategy.

Src paths in app.yaml are resolved relative to the app's own directory, never the repo root. The files/ and templates/ split is organizational — strategy is what dispatches behavior.

Apps, machines, and profiles may all live at any depth under their top-level directory. The loader scans each subtree recursively and builds a name index: for apps/ the key is the directory basename holding app.yaml; for machines/ and profiles/ the key is the YAML file's basename minus .yaml. So apps/fzf/, apps/f/fzf/, and apps/cli/fzf/ all resolve to the bare name fzf; same for profiles/work.yaml vs profiles/team/work.yaml. References stay bare-name regardless of where the file lives (profile extends:, machine profile:, profile applications:). Two leaves indexing to the same basename are a config error and surface at load time with both paths quoted. Hidden directories (.git/, .jj/, …) are skipped at any depth.

dotrig add <name> prompts for an apps bucket (empty answer = top-level); pass --flat or --in <bucket> to skip the prompt, or --non-interactive to default to flat. Machines and profiles don't have a parallel scaffolder yet — create their files manually at whatever depth fits.

Profile (the what):

# profiles/work.yaml
name: work
description: Work laptop profile
vars:
  user_email: matthias@example.com
applications:
  - zsh
  - fzf
  - git
overrides:
  git:
    user_email: matthias@example.com

Profiles compose along two axes. extends: accepts a single parent or a list — multiple parents fold left-to-right (later parent wins on conflict, child overlays last). excludes: drops apps inherited from parents. Apps are merged with parent order preserved, vars layer with the child winning, and overrides merge per-app:

# profiles/personal.yaml
name: personal
extends: [base, mac]      # compose orthogonal trait sets
excludes: [discord]       # drop something a parent included
applications:
  - steam
overrides:
  git:
    user_email: matthias@personal.example

Cycles are rejected at load time. Diamond inheritance (a profile reached via two paths) resolves cleanly.

A machine selects which profile to apply and layers per-host details:

# machines/laptop.yaml
name: laptop
profile: personal
vars:
  hostname: matthias-mbp
overrides:
  git:
    user_email: matthias@personal.example
only: []                   # default --only set; CLI flag wins when present
skip: []

dotrig apply --machine desktop switches to a different machine for one run; otherwise the host-local config's machine: field selects the default.

Application (the how):

# apps/fzf/app.yaml
name: fzf
depends_on: [zsh]
install:
  brew:
    packages: [fzf]
  apt:
    packages: [fzf]
configs:
  - strategy: copy
    src: files/fzf.zsh        # resolves to apps/fzf/files/fzf.zsh
    drop_in:
      dir: ~/.config/zsh/conf.d
      suffix: .zsh

The drop_in block is the GitOps-friendly equivalent of Ansible's lineinfile: instead of editing a shared file, the engine drops a numbered fragment into a conf.d-style directory. The fragment number comes from the {{ .DagIndex }} template variable — the engine assigns it at toposort time so the fragment order is stable.

Conventions

The full list lives in CLAUDE.md. The headline rules:

  • stdlib first. Justify every third-party dep in the PR description. Today's allowlist: spf13/cobra (CLI), gopkg.in/yaml.v3 (decode), go-git/go-git/v5 (in-process git).
  • stdlib testing only. No testify, no go-cmp; table tests + golden files where warranted.
  • log/slog only for diagnostics; CLI user output is fine via cmd.Printf / fmt.Fprint*.
  • context.Context is the first arg on every function that does IO, exec, or could block.
  • No goroutines outside internal/dag.Run. That function is the single concurrency seam.
  • No shelling out outside internal/provider/ and internal/run/. Every shell-out routes through internal/run.Runner; the git client is in-process via go-git, not via os/exec.

Roadmap

Active: migrating apps from ../dotfiles-ansible/ into ../dotrig-config/ one app at a time. M4's primitive set plus the M4.5 workflow commands (add, enable, disable, remove, fix, prune, update) cover the day-to-day; new primitives are added only when an app surfaces a real need.

Future candidates, none blocking: package-manager proxy/aliasing, per-node timeouts, native systemd/launchd primitives, macOS defaults, and a TUI for visualizing DAG execution.

Past plans live under ~/.claude/plans/.

On AI assistance

I use AI as a research and coding tool. The boring stuff — boilerplate Go, looking up stdlib signatures, repetitive table tests, formatting tables, and documentation (this README included) — is AI-assisted. I hate writing docs and basically never had any until AI made it cheap enough to bother. Architecture choices, the stack, debugging when things break, anything operational, and every load-bearing decision in here are mine. If something works well, I picked it on purpose. If something is wrong, that's also on me.

I'm calling it out because it's the honest thing to do.

License

MIT — see LICENSE.