From cf5a2f46f0167c8495d28de5b1364c8bc460b6d0 Mon Sep 17 00:00:00 2001 From: sommerfeld Date: Fri, 19 Jun 2026 17:57:23 +0100 Subject: Migrate VM dotfiles to chezmoi Move VM dotfile deployment out of Home Manager and into chezmoi with a machineRole guard. Add VM recipes for applying chezmoi state and restarting the Nix GnuPG agent. Make host-only hooks no-op on the VM and render container storage per role. --- nix/README.md | 73 +++++++++++++++++------------ nix/bootstrap.sh | 39 +++++++++++++++- nix/common.nix | 16 +++---- nix/host.nix | 4 -- nix/justfile | 89 +++++++++++++++++++++++++++++++++-- nix/vm.nix | 139 ++----------------------------------------------------- 6 files changed, 178 insertions(+), 182 deletions(-) (limited to 'nix') diff --git a/nix/README.md b/nix/README.md index 26699d0..123bf60 100644 --- a/nix/README.md +++ b/nix/README.md @@ -24,14 +24,12 @@ GitHub on first launch. 1. Installs Nix (Determinate Systems multi-user installer). 2. Clones this repo to `~/.local/share/dotfiles`. -3. Runs `home-manager switch --flake .../nix#vm`, which: - - Installs the CLI tool subset (see `common.nix` + `vm.nix`). - - Symlinks `~/.config/{nvim,zellij,zsh,direnv,ghostty,git}` and - `~/.ssh/config` at the cloned working tree via - `mkOutOfStoreSymlink`, so `git pull` is enough to pick up config - edits — no rebuild needed for config-only changes. - - Sets `ZDOTDIR=$HOME/.config/zsh` so the shared zshrc/zprofile load. -4. Appends the nix-store zsh to `/etc/shells` and `chsh`'s to it. +3. Runs `home-manager switch --flake .../nix#vm`, which installs the + shared CLI tool subset (see `common.nix` + `vm.nix`). +4. Writes a VM-role chezmoi config and runs `chezmoi apply`, deploying + the same user dotfiles model as the host while skipping host-only + `/etc` and Firefox hooks. +5. Appends the nix-store zsh to `/etc/shells` and `chsh`'s to it. ## Updating after a dotfiles change @@ -44,15 +42,25 @@ just update # pull + home-manager switch (handles everything) Or piece-by-piece if you know which one you need: ```sh -just pull # config-only changes (nvim/zellij/zsh/git/ssh): no rebuild needed +just pull # fetch the latest checkout only just switch # rebuild home-manager from the current checkout +just apply # apply VM-role chezmoi dotfiles from the current checkout ``` -> `just update` runs `pull` then `switch`. The home-manager invocation -> uses `--impure --flake '.#vm' -b backup`; the single-quotes around the -> flake ref matter because our zsh enables `extendedglob`, which would -> otherwise interpret `.#vm` as a glob pattern. On the host, swap -> `#vm` → `#host`. +> `just update` runs `pull`, `switch`, and `apply`. The home-manager +> invocation uses `--impure --flake '.#vm' -b backup`; the single-quotes +> around the flake ref matter because our zsh enables `extendedglob`, +> which would otherwise interpret `.#vm` as a glob pattern. On the host, +> swap `#vm` → `#host`. + +Existing VMs that predate the chezmoi migration should run once: + +```sh +just migrate-chezmoi +``` + +This removes only safe old Home-Manager-managed symlinks before applying +chezmoi. ## Adding a tool @@ -97,14 +105,16 @@ One-time setup on the VM: ```sh rm -f ~/.ssh/agent.sock ~/.config/git/allowed_signers +just fix-gpg-agent +gpg-connect-agent 'getinfo version' /bye gpg --import /path/to/work-private-key.asc gpg --edit-key 3298945F717C85F8 trust quit gpg --list-secret-keys --with-keygrip 3298945F717C85F8 ``` -The VM profile symlinks the repo-owned `gpg.conf`, `gpg-agent.conf`, -and `sshcontrol` into `~/.gnupg`. The tracked git config already uses -normal OpenPGP signing, so no +Chezmoi deploys the repo-owned `gpg.conf`, `gpg-agent.conf`, and +`sshcontrol` into `~/.gnupg`. The tracked git config already uses normal +OpenPGP signing, so no `~/.config/git/config.local` override is needed for SSH-format signing. If `~/.config/git/config.local` only contains the old SSH-format signing override, remove it too. @@ -112,6 +122,8 @@ signing override, remove it too. Verify on the VM: ```sh +gpg-connect-agent 'getinfo version' /bye +echo 'Hello world!' | gpg -s --armor - ssh-add -L git commit --allow-empty -m test git log --show-signature -1 @@ -121,9 +133,9 @@ git log --show-signature -1 - **GPG / pass**: HM installs `gnupg` and `pass` but does _not_ import any private key. On the VM, import the work key manually; repo-owned - `gpg.conf`, `gpg-agent.conf`, and `sshcontrol` are symlinked by - `vm.nix`. On the host, smartcard access via `pcscd` is configured in `host.nix` - (`~/.gnupg/scdaemon.conf`). + `gpg.conf`, `gpg-agent.conf`, and `sshcontrol` are deployed by + chezmoi. On the host, smartcard access via `pcscd` is configured in + `host.nix` (`~/.gnupg/scdaemon.conf`). - **Disk usage**: Nix store + nvim plugins consumes ~3-5 GB. Check partition size first on the VM. - **Network for first nvim launch**: `vim.pack.add` fetches plugins @@ -165,19 +177,22 @@ podman run --rm docker.io/library/alpine echo hi ``` The VM home-manager profile installs `podman`, `crun`, `conmon`, -`netavark`, `aardvark-dns`, `slirp4netns`, and `passt`, and writes -sensible `~/.config/containers/{registries,storage,policy}.conf` files. +`netavark`, `aardvark-dns`, `slirp4netns`, and `passt`. Chezmoi deploys +role-aware `~/.config/containers/{registries,storage,policy}.conf` +files. ## How it's wired -`common.nix` uses `config.lib.file.mkOutOfStoreSymlink` so the symlinks -point at the **live working tree** at `~/.local/share/dotfiles/...`, -not at copies in `/nix/store`. This means: +Home-Manager installs packages only. Chezmoi owns dotfiles on both the +host and VM, keyed by `machineRole` in the chezmoi config: + +- `host`: normal user dotfiles plus host-only `/etc`, Firefox, and + Flatpak integration hooks. +- `vm`: normal user dotfiles, skipping host-only `/etc` and Firefox + hooks. -- Editing `dot_config/nvim/init.lua` in the cloned repo takes effect - on the next `nvim` launch with no rebuild. -- `home-manager switch` only needs to re-run when adding/removing a - package or changing what's symlinked. +`home-manager switch` only needs to re-run when the Nix profile changes. +Config-only edits are picked up by `chezmoi apply`. The zsh plugins (`zsh-syntax-highlighting`, etc.) live in `$HOME/.nix-profile/share/`. The shared `dot_zshrc` prefers the diff --git a/nix/bootstrap.sh b/nix/bootstrap.sh index 86f82ca..58ab190 100755 --- a/nix/bootstrap.sh +++ b/nix/bootstrap.sh @@ -8,7 +8,8 @@ # 1. Install Nix (Determinate Systems installer, multi-user). # 2. Clone (or fast-forward) the dotfiles repo to ~/.local/share/dotfiles. # 3. Run `home-manager switch --flake .../nix#vm`. -# 4. Add Nix-store zsh to /etc/shells and chsh the user. +# 4. Initialize VM-role chezmoi config and apply dotfiles. +# 5. Add Nix-store zsh to /etc/shells and chsh the user. # # Environment overrides: # DOTFILES_REPO Git URL (default: https://github.com/ruifm/dotfiles) @@ -64,7 +65,41 @@ nix --extra-experimental-features 'nix-command flakes' \ run home-manager/master -- \ switch --impure --flake "$DIR/nix#vm" -b backup -# ── 4. chsh to nix-store zsh ───────────────────────────────────────────────── +# ── 4. Chezmoi dotfiles ────────────────────────────────────────────────────── +log "Writing VM chezmoi config and applying dotfiles…" +CHEZMOI="$HOME/.nix-profile/bin/chezmoi" +if [ ! -x "$CHEZMOI" ]; then + CHEZMOI=$(command -v chezmoi) +fi +CHEZMOI_MACHINE_ROLE=vm "$CHEZMOI" init -S "$DIR" --promptDefaults +CONFIG="${XDG_CONFIG_HOME:-$HOME/.config}/chezmoi/chezmoi.toml" +if ! grep -Eq '^[[:space:]]*machineRole[[:space:]]*=[[:space:]]*"vm"[[:space:]]*$' "$CONFIG"; then + err "$CONFIG does not set machineRole = \"vm\"" + exit 1 +fi +"$CHEZMOI" apply -S "$DIR" -v + +log "Restarting GnuPG through the Nix profile…" +GPGCONF="$HOME/.nix-profile/bin/gpgconf" +GPG_CONNECT_AGENT="$HOME/.nix-profile/bin/gpg-connect-agent" +if command -v systemctl >/dev/null 2>&1; then + systemctl --user stop \ + gpg-agent.service \ + gpg-agent.socket \ + gpg-agent-ssh.socket \ + gpg-agent-extra.socket \ + gpg-agent-browser.socket >/dev/null 2>&1 || true + systemctl --user mask \ + gpg-agent.socket \ + gpg-agent-ssh.socket \ + gpg-agent-extra.socket \ + gpg-agent-browser.socket >/dev/null 2>&1 || true +fi +"$GPGCONF" --kill all >/dev/null 2>&1 || true +"$GPGCONF" --launch gpg-agent +"$GPG_CONNECT_AGENT" 'getinfo version' /bye + +# ── 5. chsh to nix-store zsh ───────────────────────────────────────────────── NIX_ZSH="$HOME/.nix-profile/bin/zsh" if [ -x "$NIX_ZSH" ]; then if ! grep -qxF "$NIX_ZSH" /etc/shells 2>/dev/null; then diff --git a/nix/common.nix b/nix/common.nix index f9a7042..7290395 100644 --- a/nix/common.nix +++ b/nix/common.nix @@ -1,12 +1,10 @@ { config, pkgs, lib, dotfilesRoot, ... }: -# Shared Home-Manager module: ONLY package installation. Config-file -# deployment is *not* handled here — on the Arch host, chezmoi owns -# every dotfile under $HOME; on the remote-dev VM, `vm.nix` carries -# its own `xdg.configFile`/`home.activation` block since chezmoi isn't -# installed there. Keeping this module deployment-agnostic prevents -# home-manager from conflicting with chezmoi on the host (which would -# otherwise materialize as `.backup` files on every `nix-switch`). +# Shared Home-Manager module: ONLY package installation. Dotfile deployment is +# owned by chezmoi on both the Arch host and the remote-dev VM. Keeping this +# module deployment-agnostic prevents home-manager from conflicting with +# chezmoi-owned files (which would otherwise materialize as `.backup` files on +# every `nix-switch`). # # Policy: this profile carries leaf CLI tools, editor/AI-agent runtimes # (node, uv), and build *orchestrators* (cmake, ninja, ccache, sccache). @@ -47,6 +45,7 @@ choose zoxide just + chezmoi # Viewers bat @@ -160,8 +159,7 @@ # The nix `podman` is wrapped to find these helpers via /nix/store # paths, so we don't need a containers.conf for `helper_binaries_dir`. # Per-user containers config (registries/storage/policy) lives under - # chezmoi at `private_dot_config/containers/` and is symlinked on the - # VM by `vm.nix`'s xdg.configFile block. + # chezmoi at `dot_config/containers/`. podman crun # OCI runtime (lighter than runc; default for rootless) conmon # container monitor process diff --git a/nix/host.nix b/nix/host.nix index de68230..5a3d8a9 100644 --- a/nix/host.nix +++ b/nix/host.nix @@ -185,10 +185,6 @@ in # firefox/user-overrides.js into the Flatpak profile. arkenfox-userjs-profile - # Dotfile manager. bootstrap.sh uses the pacman `just` only long enough - # to run nix-switch; after that, this nix-profile copy is on PATH. - chezmoi - # ── OCR ────────────────────────────────────────────────────────────────── # Override merges eng + por language data into a single derivation, # replacing three pacman packages (tesseract, tesseract-data-eng, diff --git a/nix/justfile b/nix/justfile index 19e4a9b..3368193 100644 --- a/nix/justfile +++ b/nix/justfile @@ -4,10 +4,10 @@ default: @just --list -# Pull latest dotfiles and rebuild Home-Manager profile -update: pull switch +# Pull latest dotfiles, rebuild Home-Manager profile, and apply dotfiles +update: pull switch apply -# Pull latest dotfiles only (config-only changes, no nix rebuild) +# Pull latest dotfiles only pull: git -C {{ justfile_directory() }}/.. pull --ff-only @@ -15,6 +15,89 @@ pull: switch: home-manager switch --impure --flake '{{ justfile_directory() }}#vm' -b backup +# Apply VM dotfiles with chezmoi +apply: _ensure-vm-chezmoi-config + #!/usr/bin/env sh + set -eu + src=$(cd "{{ justfile_directory() }}/.." && pwd -P) + chezmoi apply -S "$src" -v + +_ensure-vm-chezmoi-config: + #!/usr/bin/env sh + set -eu + src=$(cd "{{ justfile_directory() }}/.." && pwd -P) + CHEZMOI_MACHINE_ROLE=vm chezmoi init -S "$src" --promptDefaults + config="${XDG_CONFIG_HOME:-$HOME/.config}/chezmoi/chezmoi.toml" + if ! grep -Eq '^[[:space:]]*machineRole[[:space:]]*=[[:space:]]*"vm"[[:space:]]*$' "$config"; then + echo "error: $config does not set machineRole = \"vm\"" >&2 + exit 1 + fi + +# Restart GnuPG through the Nix profile, avoiding Ubuntu's older user agent +fix-gpg-agent: + #!/usr/bin/env sh + set -eu + gpgconf_bin="$HOME/.nix-profile/bin/gpgconf" + gpg_connect_agent_bin="$HOME/.nix-profile/bin/gpg-connect-agent" + [ -x "$gpgconf_bin" ] || gpgconf_bin=$(command -v gpgconf) + [ -x "$gpg_connect_agent_bin" ] || gpg_connect_agent_bin=$(command -v gpg-connect-agent) + if command -v systemctl >/dev/null 2>&1; then + systemctl --user stop \ + gpg-agent.service \ + gpg-agent.socket \ + gpg-agent-ssh.socket \ + gpg-agent-extra.socket \ + gpg-agent-browser.socket >/dev/null 2>&1 || true + systemctl --user mask \ + gpg-agent.socket \ + gpg-agent-ssh.socket \ + gpg-agent-extra.socket \ + gpg-agent-browser.socket >/dev/null 2>&1 || true + fi + "$gpgconf_bin" --kill all >/dev/null 2>&1 || true + "$gpgconf_bin" --launch gpg-agent + "$gpg_connect_agent_bin" 'getinfo version' /bye + +# One-time migration from the old VM Home-Manager symlink deployment to chezmoi +migrate-chezmoi: pull switch fix-gpg-agent _cleanup-home-manager-dotfiles apply + +_cleanup-home-manager-dotfiles: _ensure-vm-chezmoi-config + #!/usr/bin/env bash + set -euo pipefail + src=$(cd "{{ justfile_directory() }}/.." && pwd -P) + + remove_old_symlink() { + path=$1 + [ -L "$path" ] || return 0 + raw=$(readlink "$path") + resolved=$(readlink -f "$path" 2>/dev/null || true) + case "$raw" in + "$src"/*|/nix/store/*) rm -f "$path"; return 0 ;; + esac + case "$resolved" in + "$src"/*|/nix/store/*) rm -f "$path"; return 0 ;; + esac + printf 'refusing to remove unexpected symlink: %s -> %s\n' "$path" "$raw" >&2 + exit 1 + } + + while IFS= read -r path; do + remove_old_symlink "$path" + done < <(chezmoi managed -S "$src" --include=files,symlinks --path-style=absolute) + + # The old VM profile materialized ~/.ssh/config as a real 0600 file because + # OpenSSH rejects group-writable symlink targets. Chezmoi now owns it; only + # remove the old file when it still exactly matches the repo source. + ssh_config="$HOME/.ssh/config" + if [ -f "$ssh_config" ] && [ ! -L "$ssh_config" ]; then + if cmp -s "$ssh_config" "$src/private_dot_ssh/config"; then + rm -f "$ssh_config" + else + printf 'refusing to overwrite modified %s; merge it before migrating\n' "$ssh_config" >&2 + exit 1 + fi + fi + # Garbage-collect old home-manager generations and nix store gc: home-manager expire-generations '-7 days' diff --git a/nix/vm.nix b/nix/vm.nix index 4465732..39bf52d 100644 --- a/nix/vm.nix +++ b/nix/vm.nix @@ -1,14 +1,8 @@ -{ config, pkgs, lib, dotfilesRoot, ... }: +{ ... }: -# VM-only Home-Manager profile (Ubuntu 22.04 remote-dev box). Adds the -# rootless podman stack, the editor/zellij/zsh/git config symlinks -# back into the cloned dotfiles tree, and a minimal ~/.zshenv shim — -# all of which the Arch host gets from chezmoi instead. - -let - dotfiles = "${builtins.getEnv "HOME"}/.local/share/dotfiles"; - link = path: config.lib.file.mkOutOfStoreSymlink "${dotfiles}/${path}"; -in +# VM-only Home-Manager profile (Ubuntu remote-dev box). This installs the +# shared tool profile and VM session variables only; dotfile deployment is +# owned by chezmoi, matching the Arch host. { imports = [ ./common.nix ]; @@ -26,129 +20,4 @@ in # No extra packages — the rootless podman stack now lives in # `common.nix` so the host and VM share the same nix-pinned versions. home.packages = [ ]; - - # ── Shared config symlinks ────────────────────────────────────────────────── - # Live symlinks back into the cloned working tree so `git pull` is enough - # to update configs — no `home-manager switch` required after every edit. - # On the Arch host the same files are deployed by chezmoi; this block - # exists because the VM doesn't run chezmoi. - # - # INVARIANT: every program that is both (a) installed by `nix/common.nix` - # and (b) has a config tree under `dot_config//` MUST appear here. - # Otherwise the VM silently uses the tool's defaults while the host runs - # the tracked config — drift that's hard to spot. See - # `.github/copilot-instructions.md` (§ Nix VM symlink invariant). - xdg.configFile = { - # Editor + multiplexer + terminal - "nvim".source = link "dot_config/nvim"; - "zellij".source = link "dot_config/zellij"; - "ghostty".source = link "dot_config/ghostty"; # for terminfo refs only - - # Shells - "zsh/.zshrc".source = link "dot_config/zsh/dot_zshrc"; - "zsh/.zprofile".source = link "dot_config/zsh/dot_zprofile"; - "direnv/direnvrc".source = link "dot_config/direnv/direnvrc"; - - # Git - "git/config".source = link "dot_config/git/config"; - "git/attributes".source = link "dot_config/git/attributes"; - "git/ignore".source = link "dot_config/git/ignore"; - # Git hooks: source filenames carry the chezmoi `executable_` attribute - # prefix which only chezmoi strips. In nix-managed setups we use raw - # symlinks, so map each hook to its stripped name explicitly. The - # executable bit comes from the working-tree file mode (git resolves - # the symlink). - "git/hooks/pre-push".source = link "dot_config/git/hooks/executable_pre-push"; - "git/hooks/pre-commit".source = link "dot_config/git/hooks/executable_pre-commit"; - "git/hooks/commit-msg".source = link "dot_config/git/hooks/executable_commit-msg"; - "git/hooks/post-commit".source = link "dot_config/git/hooks/executable_post-commit"; - "git/hooks/_dispatch.sh".source = link "dot_config/git/hooks/_dispatch.sh"; - - # Leaf CLI tools whose binary lives in nix/common.nix - "bat/config".source = link "dot_config/bat/config"; - "lsd/config.yaml".source = link "dot_config/lsd/config.yaml"; - "yazi".source = link "dot_config/yazi"; - "ripgrep/ripgreprc".source = link "dot_config/ripgrep/ripgreprc"; - "fd/ignore".source = link "dot_config/fd/ignore"; - "wget/wgetrc".source = link "dot_config/wget/wgetrc"; - "npm/npmrc".source = link "dot_config/npm/npmrc"; - "ipython/profile_default/ipython_config.py".source = - link "dot_config/ipython/profile_default/ipython_config.py"; - - # Debug / build tooling - "gdb/gdbinit".source = link "dot_config/gdb/gdbinit"; - "gdb/gdbearlyinit".source = link "dot_config/gdb/gdbearlyinit"; - "clangd/config.yaml".source = link "dot_config/clangd/config.yaml"; - "ccache/ccache.conf".source = link "dot_config/ccache/ccache.conf"; - - # Code review (binary from common.nix) - "tuicr/config.toml".source = link "dot_config/tuicr/config.toml"; - - # Rootless podman config — registries.conf + policy.json are - # chezmoi-owned (shared with the host); storage.conf stays inline - # below because the VM needs the overlay driver (ext4 host) while - # the Arch host uses btrfs. - "containers/registries.conf".source = link "dot_config/containers/registries.conf"; - "containers/policy.json".source = link "dot_config/containers/policy.json"; - }; - - # VM-only: overlay driver. (Host's btrfs storage.conf is chezmoi-owned - # at dot_config/containers/storage.conf.) - xdg.configFile."containers/storage.conf".text = '' - [storage] - # runroot/graphroot default to $XDG_RUNTIME_DIR/containers and - # $XDG_DATA_HOME/containers/storage respectively for rootless — leave unset. - driver = "overlay" - - [storage.options.overlay] - # Kernel >=5.13 supports rootless overlay natively (VM is on 5.15), - # so mount_program is left unset → uses the kernel driver directly - # instead of fuse-overlayfs. - ''; - - # Claude-code looks under ~/.claude (NOT XDG). Skills live there. - # Symlink the whole tuicr skill directory so SKILL.md and the wrapper - # script (chezmoi `executable_` prefix preserved → see the dispatch - # comment in SKILL.md) are picked up together. - home.file.".claude/skills/tuicr/SKILL.md".source = - link "dot_claude/skills/tuicr/SKILL.md"; - home.file.".claude/skills/tuicr/tuicr-wrapper.sh".source = - link "dot_claude/skills/tuicr/executable_tuicr-wrapper.sh"; - - # GnuPG config is repo-owned like on the host. Private key material stays in - # ~/.gnupg/private-keys-v1.d and is never tracked. - home.file.".gnupg/gpg.conf".source = link "private_dot_gnupg/gpg.conf"; - home.file.".gnupg/gpg-agent.conf".source = - link "private_dot_gnupg/gpg-agent.conf"; - home.file.".gnupg/sshcontrol".source = - link "private_dot_gnupg/sshcontrol"; - - # ~/.ssh/config from the dotfiles tree (read-only); keys + known_hosts - # stay machine-local. We can't symlink via home.file because - # mkOutOfStoreSymlink exposes the working-tree perms (0664 under a - # default umask 002) and OpenSSH refuses any group-writable ssh_config. - # Materialize a real 0600 file via activation instead. - home.activation.sshConfig = lib.hm.dag.entryAfter [ "writeBoundary" ] '' - run install -D -m 600 \ - "${dotfiles}/private_dot_ssh/config" "$HOME/.ssh/config" - ''; - - # GnuPG cares about the homedir mode; the linked config files themselves - # contain no secrets and are repo-owned. - home.activation.gnupgDirectory = lib.hm.dag.entryAfter [ "writeBoundary" ] '' - run install -d -m 700 "$HOME/.gnupg" - run chmod 700 "$HOME/.gnupg" - ''; - - # ZDOTDIR redirect so login shells find ~/.config/zsh/.zprofile etc. - # Also source HM's session-vars — HM normally drops these into - # ~/.profile, but zsh login shells don't read .profile, and we don't - # use programs.zsh.enable. - home.file.".zshenv".text = '' - if [ -r "$HOME/.nix-profile/etc/profile.d/hm-session-vars.sh" ]; then - . "$HOME/.nix-profile/etc/profile.d/hm-session-vars.sh" - fi - export ZDOTDIR="$HOME/.config/zsh" - [[ -r "$ZDOTDIR/.zshenv" ]] && source "$ZDOTDIR/.zshenv" - ''; } -- cgit v1.3.1