Skip to content

Convert machine setup to declarative mise bootstrap - #21

Merged
ericboehs merged 21 commits into
masterfrom
mise-bootstrap
Aug 17, 2026
Merged

Convert machine setup to declarative mise bootstrap#21
ericboehs merged 21 commits into
masterfrom
mise-bootstrap

Conversation

@ericboehs

Copy link
Copy Markdown
Owner

Replaces the imperative bootstrap.sh (329 lines) and defaults.sh (79 lines)
with a declarative mise.toml. Setup is now mise bootstrap — convergent,
dry-runnable, and diffable.

What moved where

Old New
brew install / apt-get install loops [bootstrap.packages] (brew:, brew-cask:, apt: — the prefix is the platform filter)
ln -s loop over a hardcoded file list [dotfiles], 25 entries
git clone of gcd [bootstrap.repos]
defaults write × 37 [bootstrap.macos.*] curated + raw [bootstrap.macos.defaults.<domain>]
Nerd font zip download brew-cask:font-inconsolata-nerd-font
Linux shims, TPM, nvim/treesitter [tasks.bootstrap] + three bootstrap:* subtasks

Bugs this surfaced and fixes

  • .tmux.conf binds 9 helper scripts but bootstrap.sh symlinked only 4 —
    dead bindings on a fresh install. The TOML links ~/.tmux as a whole
    directory, which is what the live machine actually has.
  • bootstrap.sh linked .ssh/config, but the repo has no .ssh/ — it would
    prompt over a real ~/.ssh/config or leave a broken symlink. Dropped.
  • ~/.config/lsd/colors.yaml was a broken symlink to a file nothing creates.
    The new config repairs it.

Ordering notes

TPM is a task, not a [bootstrap.repos] entry: repos run at step 7 and
dotfiles at step 8, so cloning into ~/.tmux/plugins/ would materialize a
real ~/.tmux and block the whole-directory symlink. Submodules init from a
pre-dotfiles hook for the same reason.

Two defaults keys live in hooks rather than [bootstrap.macos.defaults]
because template expressions do not render in defaults values (verified
against a junk domain): com.apple.screencapture location needs $HOME, and
NSGlobalDomain com.apple.mouse.tapBehavior needs -currentHost.

Parity verification

  • macOS defaults: extracted domain key pairs from defaults.sh and from
    mise bootstrap macos defaults status, diffed with comm. 37 keys covered;
    the only two in the old script but not the TOML are the two moved to hooks.
    Nothing extra.
  • Dotfiles: mise bootstrap dotfiles status → 25 entries, 23 applied,
    2 differs (one real file byte-identical to the repo copy, one broken
    symlink) — both resolved by --force-dotfiles.
  • Dry run: confirmed mise bootstrap -n prints the bootstrap task without
    executing it.

CI

.github/workflows/bootstrap.yml drives the real thing on Ubuntu instead of
asserting symlinks by hand: mise bootstrap -y --force-dotfiles --skip tools,task, then mise bootstrap dotfiles status --missing (exit 1 on
drift), a CLI-tool presence check, and a re-run no-op check. Hardened per the
gh-actions-security checklist — SHA-pinned checkout, permissions: {}
default-deny, persist-credentials: false.

Also

iTerm2 references swapped to Ghostty (bin/code-editor, two
terminal-notifier -sender bundle IDs, a .tmux.conf comment), and the dead
minpac submodule plus four stale .vim* ignore rules removed.

Not done here

mise bootstrap has not been run for real on the live machine yet — only
-n and the per-step status commands. The old scripts are already deleted, so
this branch is the only path once merged.

Port bootstrap.sh and defaults.sh to mise's declarative bootstrap:
packages, repos, dotfiles, macOS defaults and hooks in mise.toml, with
the four genuinely imperative steps left as `bootstrap:*` tasks.

Verified against this machine with `mise bootstrap -n`: all 37 macOS
defaults keys from defaults.sh are covered (the two that aren't
expressible — the -currentHost tap behavior and the $HOME-relative
screenshot location — moved to hooks), and 23 of 25 dotfile entries
already report `applied`.

Two fixes fall out of the port:
- ~/.tmux is declared as one directory link, matching what this machine
  actually has, so .tmux.conf's bindings for agenda.sh, btop.sh,
  goto-window.sh, move-window.sh and session-jump.sh resolve on a fresh
  install; bootstrap.sh linked only four of the nine helpers.
- The dead ~/.ssh/config entry is dropped — the repo has no .ssh/, so
  bootstrap.sh would either prompt over a real config or leave a broken
  symlink.

mise.toml is un-ignored here because the global gitignore excludes
per-project mise configs; this repo's copy is the setup itself.

bootstrap.sh and defaults.sh stay until this is proven on a fresh
machine.
mise.toml now covers everything both scripts did, so delete them and
point the README and CI at `mise bootstrap`.

CI drives the real documented path — install mise from mise.run, then
`mise bootstrap --force-dotfiles --skip tools,task` — and asserts
convergence with `mise bootstrap dotfiles status --missing` instead of
hand-listing every expected symlink. [tools] and the bootstrap task are
skipped for runtime (bootstrap:nvim compiles every treesitter parser);
bootstrap:linux-shims still runs on its own since it creates the fd/bat
shims the tool check asserts on. Submodules are no longer checked out by
actions/checkout so the pre-dotfiles hook is exercised instead.

Also harden the workflow while it's open: default-deny permissions with
per-job grants, actions/checkout pinned to a SHA and bumped to v7.0.1,
and persist-credentials disabled. zizmor reports no findings.

shellcheck now covers .tmux/*.sh, which is what's left of the repo's
shell scripts; .claude/scripts/statusline.sh has pre-existing warnings
and is left for its own change.

Drop the iTerm2 Catppuccin import task and the iTerm2 pointer in
.tmux.conf — Ghostty is the terminal now.
code-editor opened iTerm, and claude-man/monitor_tmux_pane passed
-sender com.googlecode.iterm2 to terminal-notifier so click-through
focused an app that is no longer installed.
.gitmodules declared .vim/pack/minpac/opt/minpac but .vim/ has no
gitlink in the index (and no longer exists in the repo), so the entry
was inert config. The four .vim* .gitignore lines guarded paths that
are likewise gone.
Homebrew runs on Linux, so the brew: prefix only filters in one
direction — the Ubuntu CI job installed the entire macOS list alongside
the apt one and then died building terminal-notifier from source, which
needs xcodebuild. Every package entry now carries an explicit os filter.

The filter needs mise 2026.8.4, so declare min_version; without it an
older mise reports only 'invalid type: map, expected a string'.
Tasks run in a non-login shell, so .zprofile's path_helper line never
fires and /opt/homebrew/bin is absent. On a fresh Mac that meant the
bootstrap task could not see the tools bootstrap had just installed:
bootstrap:nvim hit its 'command -v nvim || exit 0' guard and skipped
every plugin and treesitter parser without printing anything, and
bootstrap:tpm ran install_plugins without tmux and failed the run.

Both guards now say why they skipped instead of exiting silently, and
a missing tmux no longer fails the whole bootstrap.
.zshrc ended with `[[ -f ~/.zshrc.local ]] && source ~/.zshrc.local`.
When that file is absent the test returns 1, and since it was the last
statement in .zshrc the shell reached its first prompt with $? = 1, so
powerlevel10k drew the OK caret in the ERROR color. Any machine without
a .zshrc.local opened every new terminal with a red prompt.

Verified in a clean VM: the caret emits SGR 31 without the file and 32
with it; the `if` form returns 0 either way.
bootstrap:nvim synced plugins and treesitter parsers but never loaded
mason, so its ensure_installed tools were still downloaded on the first
real nvim launch — the thing this task exists to avoid. Load mason
headless and block until its install queue drains.

lazygit was missing outright: LazyVim binds <leader>gg to it and flags
it in checkhealth. macOS only; it is not in the Ubuntu LTS apt repos.
`[env] _.path` prepends, so the previous fix put /opt/homebrew/bin ahead of
mise's own installs and let brew's python/ruby/node shadow the [tools]
versions inside this repo. Move the append into the two tasks that actually
need brew binaries.
Two silent half-failures, both of which left work for the first real launch —
the thing this task exists to prevent:

- treesitter: ~300 parsers is enough back-to-back GitHub downloads to draw
  429s, and install() resolves regardless, so one pass exited 0 with 167 of
  ~300 installed. Retry until a pass adds nothing, clearing the stale extract
  dirs that made retries die on ENOTEMPTY.
- mason: the wait sampled "nothing is installing" once, which is true early on
  while downloads are still queued, so nvim exited mid-install. Require 15
  consecutive idle seconds instead.

Also print the resulting counts, since exit 0 clearly wasn't evidence of much.
The retry loop capped at 4 passes and broke out mid-climb (292 -> 309 parsers
on the last pass), reporting nothing about having stopped early. Loop until a
pass adds nothing, and say so out loud if 8 passes isn't enough.

Mason failures were invisible too: erb-formatter and erb-lint are gems and die
with "gem failed with exit code 1" on a machine whose ruby didn't install, but
that only reaches mason.log. Print the names.
LazyVim's terraform extra hardcodes terraform_fmt and terraform_validate, so
opening a .tf file threw "Error running terraform: ENOENT" — the binary was
never installed.

It lands in [tools] rather than [bootstrap.packages] because homebrew-core
dropped the formula over the BUSL relicense, and mise's brew backend rejects
the hashicorp tap outright ("the tap must publish API metadata ... mise will
not proxy to the brew CLI"), erroring out the entire packages step rather than
skipping the one entry.
This reverts the [tools] entry from c9386fc.

~/.config/mise/config.toml is symlinked into this repo, so the entry went live
on every machine using these dotfiles the moment it was committed — no
bootstrap required. With GitHub's releases API down, mise cannot resolve
terraform@latest and warns on every shell init, and that console output trips
Powerlevel10k's instant-prompt guard.

Re-add once the API is healthy, pinned rather than "latest" and installed at
the same time, so an unresolvable entry can never sit in the live config.
# Conflicts:
#	.tmux.conf
The final hook said on-demand parser installs were expected. That was true
before bootstrap:nvim installed all of them; now the opposite holds, and the
old note talks you out of noticing the exact symptom that means the warm
silently half-finished.
The LazyVim terraform extra shells out to the binary on every .tf buffer and
errored with ENOENT without it. Pinned rather than "latest": this file is
symlinked into ~/.config, so a version mise cannot resolve warns on every shell
init, and that console output trips Powerlevel10k's instant-prompt guard — which
is exactly what a GitHub releases-API outage produced last week.
mise ships a rubygems plugin that shells out to `mise reshim` after installing
any gem with executables, and `mise exec` does not put mise itself on PATH. On a
machine that installed mise the way the README says — curl mise.run, which lands
in ~/.local/bin — that hook died with ENOENT, gem exited 1, and mason reported
erb-formatter and erb_lint as failed even though the gem files installed fine.

It never reproduced on my Mac because mise came from brew there, and the task
already appended /opt/homebrew/bin. A pristine VM run following the documented
install path failed both packages every time.

Verified on that VM: 8 mason packages before, 10 after, no failure warning.
.zsh/path.zsh puts ~/bin at the front of $PATH and the README documents a dozen
scripts that live there, but nothing ever created the directory. On this Mac it
has been a hand-made symlink since June, so the gap was invisible; on a pristine
VM every one of those scripts was missing and 'command -v claude-man' came back
empty.

Verified both ways: mise reports the existing hand-made link as already applied
here, and creates it from scratch on the VM, after which claude-man and vm both
resolve.
mise bootstraps itself so it can't be a [bootstrap.packages] entry — whichever
copy you install by hand wins, and nothing in the repo enforces it. Homebrew's
build disables self-update and lagged upstream by two releases, but the reason
it matters is that it lands in /opt/homebrew/bin and so hid the mason gem bug
fixed in ec943ed: on a mise.run machine the reshim hook needs ~/.local/bin on
PATH, and a brew install accidentally satisfied it.
@ericboehs
ericboehs merged commit 1380889 into master Aug 17, 2026
2 checks passed
@ericboehs
ericboehs deleted the mise-bootstrap branch August 18, 2026 14:25
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