Skip to content

feat: migrate from dotbot to chezmoi#20

Merged
vaintrub merged 2 commits into
masterfrom
chore/chezmoi-migration
May 16, 2026
Merged

feat: migrate from dotbot to chezmoi#20
vaintrub merged 2 commits into
masterfrom
chore/chezmoi-migration

Conversation

@vaintrub
Copy link
Copy Markdown
Owner

@vaintrub vaintrub commented May 12, 2026

Summary

Migrate dotfiles management from dotbot to chezmoi, then iterate to a canonical-chezmoi shape: single .chezmoidata/packages.yaml SoT for packages + AI plugins, canonical default sourceDir (~/.local/share/chezmoi/), pre-source-state hook for prereq install, opentofu, structured plugin install with caveman as a regular plugin (no bespoke installer).

46 commits on chore/chezmoi-migration against master.

Why

  • chezmoi is the canonical 2026 leader: 20k stars, 124k brew installs/yr (200× dotbot), 508 commits / 26 releases per year vs dotbot's 10×-fewer
  • modify_ pattern (jq merge for Claude settings.json, fromToml/mergeOverwrite for Codex config.toml) handles tool-mutated files cleanly — preserves rtk hooks + plugin enables across applies
  • .chezmoiexternal.toml.tmpl with SHA-pinned commit replaces git submodule for oh-my-tmux; same mechanism downloads MesloLGS NF fonts cross-platform
  • One-line bootstrap: sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply vaintrub (no install.sh, no --source flag, no override)
  • packages.yaml as single SoT drives both install scripts AND .chezmoitemplates/{claude-settings-base.json,codex-config-base.toml} plugin enable flags — no install/enable drift
  • canonical caveman install via claude plugin install caveman@caveman (per upstream INSTALL.md per-agent table). Plugin self-registers SessionStart + UserPromptSubmit hooks via plugin.json. Previous bespoke bash install.sh was double-wiring hooks into settings.json → duplicate firings. Fixed.
  • hooks.read-source-state.pre.command runs hooks/ensure-prereqs.sh BEFORE chezmoi reads source state — installs git/zsh/vim/tmux/curl via apt/dnf on Linux or Xcode CLT + Homebrew on Mac. True zero-touch bootstrap on supported distros (Debian/Ubuntu/Fedora, macOS).

Architecture

Pattern Used for Why
Plain dot_X zshrc, vimrc, p10k.zsh, CLAUDE.md, rules/*.md static content
modify_X.tmpl + jq ~/.claude/settings.json tool-mutated; preserve rtk hooks + plugin enables
modify_X + #chezmoi:modify-template ~/.codex/config.toml pure Go template; strips runtime sections; mergeOverwrite base
File-level symlink_X iTerm2 plist (skipped — direct PrefsCustomFolder), Codex save-to-dotfiles SKILL.md share single source across two consumers
.chezmoiexternal.toml.tmpl gpakosz/.tmux (SHA-pinned), 4 MesloLGS NF fonts (cross-platform paths) vendored deps, refresh-period guard
.chezmoidata/packages.yaml packages.{common.brews, darwin.casks, linux.{apt,dnf,npm_global}} + plugins.{claude,claude_marketplaces,codex,codex_marketplaces} single declarative SoT
.chezmoitemplates/{X-base} curated bases for modify_ scripts canonical chezmoi location, never applied to $HOME
.chezmoiscripts/{50-,60-,70-} numerical-prefix ordered install pipeline (packages → rtk → plugins) deterministic execution within run_onchange_after_ group
.chezmoiscripts/{darwin,linux}/ OS-specific run_once_after scripts (iTerm2 config, chsh, fc-cache) per-OS layout, alphabetic sort puts darwin/ + linux/ before root numerical-prefix
hooks/ensure-prereqs.sh bootstrap script invoked via [hooks].read-source-state.pre runs BEFORE source state read; installs minimum prereqs on fresh box

Key state

  • sourceDir: chezmoi default ~/.local/share/chezmoi/ (XDG_DATA_HOME compliant). No override. Optional ~/dotfiles symlink for muscle memory (not required).
  • Prompts in .chezmoi.toml.tmpl: stdinIsATTY + hasKey + generic placeholders pattern (mkasberg/chezmoi-official idiom). Safe for forkers, Codespaces force non-interactive.
  • Plugins: 4 Claude plugins (gopls-lsp, figma, frontend-design, caveman) + 2 Codex plugins (github, google-drive from openai-curated reserved marketplace) all declared in packages.yaml.
  • Skills: Claude uses plugin cache (~/.claude/plugins/cache/caveman/.../skills/); Codex has own ~/.codex/skills/ dir with file-level symlink to Claude's save-to-dotfiles skill. No cross-leak.

Verification (this machine, post-apply)

Check Result
chezmoi diff clean
chezmoi doctor no errors/warnings
~/.claude/CLAUDE.md real file with @RTK.md ref
~/.claude/settings.json jq-merged: base + rtk PreToolUse hook + enabledPlugins
~/.codex/config.toml clean, base + runtime sections stripped, idempotent
claude plugin list gopls-lsp + figma + frontend-design + caveman, single registration each
iTerm2 PrefsCustomFolder /Users/vaintrub/.local/share/chezmoi/iterm (canonical)
MesloLGS NF fonts at ~/Library/Fonts/
~/.codex/skills/save-to-dotfiles/SKILL.md file-level symlink to claude copy
~/.config/tmux/ gpakosz archive extracted via external (SHA af33f07)
46 commits ahead master

Out of scope (TODO in README)

  • dot_gitconfig.tmpl consuming the name/email prompts
  • dot_ssh/config.tmpl cross-platform SSH defaults
  • dot_kube/, dot_aws/ cloud configs
  • Claude statusline rtk + caveman health indicators
  • mise migration from fnm (unified version manager)
  • Per-machine class split (work-mac vs personal-mac vs Linux-VM vs throwaway-codespace)

vaintrub added a commit that referenced this pull request May 13, 2026
Review of PR #20 caught two real issues + one doc gap.

M1 — set -u vs set -eu inconsistency
install-packages was the lone holdout on `set -u` (no -e); every other
script uses `set -eu`. Original rationale was "tolerate brew bundle
failures" but every soft-failing command (brew bundle, apt-get install,
dnf install, npm install -g) is already wrapped with `||` — `set -e`
doesn't trip on those.

Aligned to `set -eu` on both Mac and Linux branches. One caveat: brew
bundle's exit status used to be captured via `brew_status=$?` after the
heredoc — that pattern doesn't work with `set -e` (failure aborts before
$? capture). Rewrote with `|| brew_status=$?` inline, which set -e
treats as part of the conditional. Verified pattern with a minimal test.

M2 — fnm install/default error visibility
`fnm install --lts && fnm default lts/latest` silently no-ops if install
fails (the && short-circuits before fnm default). With set -eu aligned,
just split into two consecutive statements: install fails → script aborts
with fnm's own error visible. Cleaner than `|| echo` chains.

M8 — install-rtk error message detail
"rtk not found on PATH after install-packages — check packages.yaml /
brew state" was too generic. Common cause is brew bundle aborting because
VSCode or Docker cask adoption needs sudo (non-interactive apply can't
supply). Expanded to four lines: explain cause + actionable fix (rerun
from TTY or `sudo -v` to cache credentials).

R1 — Linux npm prefix doc note
Reviewer concern: claude/codex npm globals might not be on PATH for
70-install-plugins on a fresh box. Investigated: with default npm prefix
(`/usr`) binaries land at `/usr/bin/{claude,codex}` — already on PATH
for any child sh. Risk only with non-standard prefix (`npm config set
prefix ~/.local`). No code change needed — added troubleshooting
paragraph to README Linux section.

Deferred (out of scope, per plan):
- L5 splitn _0/_1 keys (sprig-idiomatic, accept)
- L6 DRY headless detection (3 instances, marginal win)
- L7 .chezmoiremove.tmpl empty placeholder (twpayne pattern)
- R2 VSCode adoption sudo prompt (pre-existing limitation)
- R3 Caveman plugin restart (Claude prints "Restart" itself)
- R4 CI checks (substantial work, separate task)
@vaintrub vaintrub force-pushed the chore/chezmoi-migration branch from 2e96144 to 125c4f8 Compare May 15, 2026 01:56
Replaces the prior dotbot + ad-hoc shell-install setup with a single
chezmoi source tree at ~/.local/share/chezmoi (XDG default — no custom
sourceDir override). Identical cross-platform model for macOS + Linux
(Debian/Ubuntu/Fedora), bootstrapped via the canonical one-liner:

    sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply vaintrub

Highlights:

- 3-tier install profile, use-case-driven (`core` / `dev` / `workstation`).
  OS is detected at runtime under the hood; profile picks USE CASE, not
  platform:
    core         minimum to make dotfiles functional. Hetzner VPS,
                 DO droplet, jetson SSH first-touch, Codespace, recovery.
                 ~50 MB.
    dev          core + CLI dev toolchain (mise tools + Linux apt/dnf
                 extras + Mac brews — symmetric, modulo Docker engine
                 which is `workstation`-only on Mac via Docker Desktop).
                 Headless dev box.
    workstation  dev + GUI apps. Mac: casks (iterm2, docker-desktop,
                 vscode, ngrok, fonts). Linux: empty placeholder for
                 when a Linux desktop machine joins the fleet.
  Profile is ALWAYS prompted at `chezmoi init`; environment is shown
  as a hint in the prompt text (detected env + SSH + GUI flags); user
  picks explicitly. Default = `core` (safe fallback for CI / non-TTY).

- mise as primary cross-platform dev-tool installer. Single declarative
  `dot_config/mise/config.toml.tmpl` lists 25 aqua-backend tools
  (kubectl, helm, k9s, kustomize, stern, argocd, opentofu, awscli,
  rclone, jq, gh, fzf, fd, yq, delta, shellcheck, buf, golangci-lint,
  goreleaser, gotestsum, protoc-gen-go, pnpm, uv, cloudflared,
  websocat, gitleaks) + 4 language runtimes (node/go/python/rust).
  Profile-aware: core renders just fzf+zoxide; dev/workstation render
  the full set. ~600 LOC of per-tool bespoke installers (GitHub-release
  downloads, arch shims, apt-repo wiring) gone in favour of mise's
  baked-in registry. Mac brew + Linux apt/dnf handle only OS glue and
  tools not in aqua (htop/tree/wget/nmap/telnet/libpq/wireguard-tools).

- Thin-wrapper / fat-library split for install scripts. The
  `run_onchange_after_50-install-packages.sh.tmpl` wrapper renders
  chezmoi facts (profile, osid, every package list) into DOTFILES_*
  env vars and sources `lib/install-packages.sh` (pure POSIX shell,
  no template syntax). Library is bats-testable: 25 unit tests cover
  predicates (is_dev cascade, is_workstation, is_debian_family with
  idLike fallback, is_fedora_family), apt/dnf/brew install paths
  (root vs sudo, profile cascade, missing-sudo skip), dispatcher
  branching, and the INSTALL_PACKAGES_INVOKE guard.

- Network safety baseline at core tier on Linux: `ufw` (disabled by
  default; user opts in via `sudo ufw enable`) + `tcpdump`. Mac uses
  the App Firewall (System Settings); tcpdump ships at /usr/sbin/.

- gitleaks pre-push hook wired globally via
  `core.hooksPath = ~/.config/git/hooks` in `dot_gitconfig.tmpl`. Runs
  `gitleaks git --log-opts=<remote..local>` on outbound commits; fails
  open if gitleaks not on PATH (core-profile machines). Per-repo
  opt-out: `git config --local core.hooksPath ''`.

- Touch ID for sudo on Mac via /etc/pam.d/sudo_local (Sonoma+ Apple-
  blessed extension point that survives system updates). Pre-Sonoma
  fallback edits /etc/pam.d/sudo directly. Idempotent grep guards;
  TTY check skips on non-interactive applies.

- OSC 133 semantic prompt sequences emitted from zshrc (preexec/precmd
  hooks). Powers shell-integration features in iTerm2 (⌘↑/⌘↓ jump),
  VS Code (command decorations), Wezterm (ScrollToPrompt), Windows
  Terminal, Ghostty. Skipped when p10k Pro or iTerm2 shell-integration
  is already emitting the marks.

- alker0/chezmoi.vim plugin in dot_vimrc — auto-detects chezmoi source
  files (dot_*, *.tmpl, modify_*, .chezmoitemplates/*), strips
  prefixes to derive the real filetype, and registers composite
  `<lang>.chezmoitmpl` syntax for Go-template-aware highlight. Honors
  .chezmoiroot, intercepts /tmp/chezmoi-edit* hardlinks.

- Hooks-based prereq install via .chezmoi.toml.tmpl
  `[hooks.read-source-state.pre]` — apt/dnf installs git/zsh/vim/
  tmux/curl/ca-certificates if missing (cert-bundle file check, not
  binary check), then curl-pipes mise into ~/.local/bin. Idempotent,
  silent on clean systems (stdout suppressed), skips with a clear
  warning if non-interactive without passwordless sudo.

- modify_ scripts for Claude settings.json (jq additive merge) and
  Codex config.toml (chezmoi-native fromToml/toToml merge). Both
  preserve tool-mutated keys (rtk init hooks, plugin enable lists)
  across applies. Bases live in .chezmoitemplates/ and load via
  includeTemplate.

- Single source of truth for plugins in .chezmoidata/packages.yaml:
  .plugins.{claude, claude_marketplaces, codex, codex_marketplaces}
  drives both the install loop in 70-install-plugins and the enable
  flags inside .chezmoitemplates/{claude-settings-base.json,
  codex-config-base.toml}.

- Externals replace git submodules: gpakosz/.tmux pinned by commit
  SHA in .chezmoiexternal.toml.tmpl; MesloLGS NF + Monaspace Neon
  fonts pulled into ~/Library/Fonts / ~/.local/share/fonts
  (Monaspace version hoisted to a single template var; Monaspace zip
  extracted via archive-file external for 4 canonical weights).
  Headless-SSH boxes skip font install.

- iTerm2 prefs live at iterm/com.googlecode.iterm2.plist (chezmoi-
  ignored); iTerm2 reads + writes there directly via
  PrefsCustomFolder, set on first apply by a Mac run_once_after_*
  script. No symlink layer.

- Per-machine override file via `create_dot_zshrc_local` (chezmoi-
  native seed-once attribute; sourced LAST by dot_zshrc so local
  edits win over everything tracked).

- .osid composite key derived once in .chezmoi.toml.tmpl from OS +
  osRelease.id (darwin / linux-ubuntu / linux-debian / linux-fedora /
  ...). Replaces nested if-elif distro dispatch; library predicates
  `is_debian_family` / `is_fedora_family` (with idLike fallback for
  Pop!_OS / Mint / Raspbian) consume it.

- GitHub Actions CI: `unit` job (bats tests/unit/) → `apply-core`
  job (chezmoi apply in devcontainer-style ubuntu-latest + bats
  tests/files/). Triggers on push (master, chore/**, feat/**, fix/**,
  docs/**) + PRs. Symlinks $GITHUB_WORKSPACE → ~/.local/share/chezmoi
  pre-init so subsequent chezmoi invocations find source.

- Repo docs: README.md (user-facing install + profile guide) +
  AGENTS.md (auto-loaded by Claude/Codex when working in the repo,
  CLAUDE.md is a symlink). Per-area rules under dot_claude/rules/
  auto-load at session start (frontend rules conditional via
  `paths:` glob).

Migration cleanup: deleted .dotbot, .install.conf.yaml, install,
.gitmodules, tmux/oh-my-tmux submodule, claude/+codex/ flat dirs.
Renamed top-level dotfiles to chezmoi dot_ source-state names.
Legacy `profile = "mac"` from earlier core/dev/mac tiering silently
translates to `workstation` in the template — existing Mac users
keep their GUI install across the upgrade.
@vaintrub vaintrub force-pushed the chore/chezmoi-migration branch from b1cea83 to 057c640 Compare May 16, 2026 21:29
Open PR #20 + push to branches matching the push trigger globs caused
GitHub Actions to fire two identical runs on every commit — one labeled
with the PR title, one with the latest commit message — both targeting
the same SHA. Wasted runner minutes + cluttered run list.

Solo repo with no fork PRs expected: `push` event alone covers
master + every working branch (chore/**, feat/**, fix/**, docs/**),
and the PR view surfaces the latest push's CI status automatically.

Re-enable `pull_request:` if external contributors start opening PRs
from forks — push trigger doesn't fire on the fork's source branch
since those commits don't land in this repo until merge.
@vaintrub vaintrub merged commit 15ced27 into master May 16, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant