aboutsummaryrefslogtreecommitdiffstatshomepage
diff options
context:
space:
mode:
authorLibravatar sommerfeld <sommerfeld@sommerfeld.dev>2026-06-19 17:57:23 +0100
committerLibravatar sommerfeld <sommerfeld@sommerfeld.dev>2026-06-19 17:57:23 +0100
commitcf5a2f46f0167c8495d28de5b1364c8bc460b6d0 (patch)
tree9c46279f71aab9757abef8af927a75b198690bf3
parent259a3b989513f47f6cc5d36eaaf9e5fbef9b4d9a (diff)
downloaddotfiles-cf5a2f46f0167c8495d28de5b1364c8bc460b6d0.tar.gz
dotfiles-cf5a2f46f0167c8495d28de5b1364c8bc460b6d0.tar.bz2
dotfiles-cf5a2f46f0167c8495d28de5b1364c8bc460b6d0.zip
Migrate VM dotfiles to chezmoiHEADmaster
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.
-rw-r--r--.chezmoi.toml.tmpl6
-rw-r--r--.github/copilot-instructions.md12
-rw-r--r--README.md8
-rw-r--r--dot_config/containers/storage.conf.tmpl (renamed from dot_config/containers/storage.conf)15
-rw-r--r--dot_config/nvim/lua/plugins/ai.lua6
-rw-r--r--nix/README.md73
-rwxr-xr-xnix/bootstrap.sh39
-rw-r--r--nix/common.nix16
-rw-r--r--nix/host.nix4
-rw-r--r--nix/justfile89
-rw-r--r--nix/vm.nix139
-rw-r--r--private_dot_gnupg/gpg.conf2
-rwxr-xr-xrun_onchange_after_deploy-etc.sh.tmpl8
-rwxr-xr-xrun_onchange_after_deploy-firefox.sh.tmpl8
-rw-r--r--run_onchange_after_deploy-flatpak-overrides.sh.tmpl9
-rw-r--r--run_onchange_after_deploy-pteid-pkcs11.sh.tmpl73
-rw-r--r--run_onchange_after_deploy-tb-eer.sh.tmpl1
-rw-r--r--[-rwxr-xr-x]run_onchange_after_install-copilot-node.sh.tmpl (renamed from run_onchange_after_install-copilot-node.sh)8
18 files changed, 279 insertions, 237 deletions
diff --git a/.chezmoi.toml.tmpl b/.chezmoi.toml.tmpl
index f7b1156..3582b7e 100644
--- a/.chezmoi.toml.tmpl
+++ b/.chezmoi.toml.tmpl
@@ -1,3 +1,5 @@
+{{- $defaultMachineRole := default "host" (env "CHEZMOI_MACHINE_ROLE") -}}
+{{- $machineRole := promptStringOnce . "machineRole" "Machine role (host or vm)" $defaultMachineRole -}}
sourceDir = {{ .chezmoi.sourceDir | quote }}
[status]
@@ -7,7 +9,11 @@ sourceDir = {{ .chezmoi.sourceDir | quote }}
exclude = ["scripts"]
[data]
+ # Machine role used by templates and run hooks. Valid values: "host", "vm".
+ machineRole = {{ $machineRole | quote }}
+{{- if eq $machineRole "host" }}
# Block device holding the LUKS-encrypted root, without the /dev/ prefix
# (e.g. "nvme0n1p2", "sda2"). Resolved to a UUID at apply time via lsblk,
# used by etc/kernel/cmdline.tmpl.
luksRootPartition = {{ promptStringOnce . "luksRootPartition" "LUKS root partition (e.g. nvme0n1p2)" | quote }}
+{{- end }}
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index a07e728..df238ad 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -60,13 +60,11 @@ When editing shell config, all zsh configuration goes in `dot_config/zsh/` — d
`KEYBINDS.md` at the repository root documents every non-default keybind across neovim, zellij, zsh, ghostty, and sway. Whenever you add, remove, or change a keybind in any of these tools, you must update `KEYBINDS.md` to reflect the change in the same commit.
-## Nix VM symlink invariant
+## VM chezmoi role
-The remote-dev Ubuntu VM does NOT run chezmoi. It re-uses configs via Home-Manager symlinks declared in `nix/vm.nix` (the `xdg.configFile` and `home.file` blocks point at `~/.local/share/dotfiles/<source-path>`). Whenever you **add, remove, or rename** any file or directory under `dot_config/` (or any other chezmoi source path like `dot_claude/`, `private_dot_ssh/`), audit `nix/vm.nix` in the same commit and:
+The remote-dev Ubuntu VM also runs chezmoi. Home-Manager installs packages and VM session variables only; it must not deploy normal dotfiles. Machine-specific dotfile behavior belongs in chezmoi templates keyed by `machineRole`:
-- If the corresponding binary is provisioned by `nix/common.nix` and is relevant on the headless VM (i.e. not a GUI/wayland tool like sway/mako/waybar/fuzzel/mpv/zathura), **add a matching `xdg.configFile."<dest>".source = link "<source-path>";` entry**.
-- If a config is renamed or removed, update / drop the corresponding `link` entry — otherwise `home-manager switch` will fail or leave a dangling symlink.
-- Chezmoi attribute prefixes (`executable_`, `private_`, `dot_`) are stripped by chezmoi but NOT by `mkOutOfStoreSymlink`. Map each prefixed source filename to its stripped destination explicitly (see the git-hooks block in `nix/vm.nix` for the reference pattern).
-- Paths outside `~/.config/` (e.g. `~/.claude/skills/…`) use `home.file."<dest>".source = link "<source-path>";` instead of `xdg.configFile`.
+- `host`: user dotfiles plus host-only `/etc`, Firefox/LibreWolf, and Flatpak integration hooks.
+- `vm`: user dotfiles, skipping host-only `/etc` and Firefox/LibreWolf hooks.
-Treat the symlink block as part of the chezmoi source tree: a config that lands on the host via chezmoi but not on the VM via nix is the bug class this rule prevents.
+When adding, removing, or renaming user config under `dot_config/`, `dot_claude/`, `private_dot_ssh/`, `private_dot_gnupg/`, etc., use chezmoi naming conventions only. Do not add corresponding `xdg.configFile` or `home.file` mappings to `nix/vm.nix`.
diff --git a/README.md b/README.md
index 2a1e88e..84f2f6c 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,7 @@ My Arch Linux configuration, managed with [chezmoi](https://www.chezmoi.io/).
| Category | Choice |
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| OS & base | [Arch Linux](https://archlinux.org/) with pacman for official packages, [Nix](https://nixos.org/) / Home Manager for user-leaf tools, [sudo-rs](https://github.com/trifectatechfoundation/sudo-rs) for privilege escalation |
-| Dotfile manager | [chezmoi](https://www.chezmoi.io/) (dotfiles and `/etc` both deployed via `chezmoi apply`) |
+| Dotfile manager | [chezmoi](https://www.chezmoi.io/) (user dotfiles on host and VM; host-only `/etc` deployment via `chezmoi apply`) |
| Task runner | [just](https://just.systems/) — every maintenance action is a recipe (see below) |
| Shell | [zsh](https://www.zsh.org/), relocated to `$XDG_CONFIG_HOME/zsh`; plugins via [zinit](https://github.com/zdharma-continuum/zinit) |
| Terminal | [ghostty](https://ghostty.org/) |
@@ -79,8 +79,8 @@ Everything is driven by [just](https://just.systems/) recipes against four paral
| `dot_*`, `private_dot_*` | chezmoi | Dotfiles deployed to `$HOME`. Prefixes: `dot_` → `.`, `private_` → `0600`, `executable_` → `+x`. |
| `meta/*.txt` | `just pkg-apply`, `just pkg-status` | Plain-text package lists (one per line, `#` comments). Groups: `base`, `dev`, `wayland`, etc. |
| `systemd-units/{system,user}/*.txt` | `just unit-apply`, `just unit-status` | Units to enable, split by scope. `system/` files pair by name with `meta/` groups (`system/base.txt` ↔ `meta/base.txt`); `user/` files are standalone. Recipe group token: `<name>` / `system:<name>` / `user:<name>`. |
-| `etc/` | `run_onchange_after_deploy-etc.sh.tmpl` | System-level configs deployed to `/etc/` via a chezmoi onchange hook. |
-| `firefox/` | `run_onchange_after_deploy-firefox.sh.tmpl` | LibreWolf hardening: renders nixpkgs' Arkenfox `user.js` plus `firefox/user-overrides.js` into the Flatpak profile, and deploys `userChrome.css` (kept under the familiar `firefox/` name). |
+| `etc/` | `run_onchange_after_deploy-etc.sh.tmpl` | Host-only system-level configs deployed to `/etc/` via a chezmoi onchange hook. |
+| `firefox/` | `run_onchange_after_deploy-firefox.sh.tmpl` | Host-only LibreWolf hardening: renders nixpkgs' Arkenfox `user.js` plus `firefox/user-overrides.js` into the Flatpak profile, and deploys `userChrome.css` (kept under the familiar `firefox/` name). |
| (cartão de cidadão) | `run_onchange_after_deploy-pteid-pkcs11.sh.tmpl` | Bridges the `pt.gov.autenticacao` flatpak's PKCS#11 module into the NSS DB of every flatpak that needs cartão de cidadão (LibreWolf, Thunderbird, Okular, LibreOffice) — `--filesystem` + `--socket=pcsc` override + `modutil -add` per NSS DB (per-profile for Mozilla apps, shared `~/.pki/nssdb` for Okular/LibreOffice). No-op unless `pt.gov.autenticacao` is installed. |
| (Thunderbird native editor) | `run_onchange_after_deploy-tb-eer.sh.tmpl` | Bridges `external-editor-revived` (host pacman package) into the Thunderbird flatpak: deploys a `flatpak-spawn --host` wrapper into the sandbox's `~/.mozilla/native-messaging-hosts/` and rewrites the manifest `path` to point at it. No-op unless TB flatpak + EER host package are both installed. |
| (flatpak config sharing) | `run_onchange_after_deploy-flatpak-overrides.sh.tmpl` | Read-only `--filesystem=xdg-config/<app>:ro` overrides so the zathura and mpv flatpaks read our chezmoi-managed `~/.config/<app>/` instead of a separate in-sandbox copy. |
@@ -93,7 +93,7 @@ Run `just` or `just --list` for the full menu. Recipes follow a `DOMAIN-VERB` sc
| ------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- |
| Setup | `just init` | First-time setup: chezmoi init, git hooks, apply, base packages, curated units |
| Day-to-day | `just sync` | `apply` + `pkg-fix` + `unit-apply` (full reconcile) |
-| | `just apply` | `chezmoi apply` — atomically deploys dotfiles AND /etc |
+| | `just apply` | `chezmoi apply` — deploys dotfiles and host-only `/etc` |
| | `just re-add [PATH]` | Pull live changes back into the repo (dotfiles + /etc) |
| Add / forget | `just add PATH` | Dispatches to `dotfiles-add` (path) or `etc-add` (`/etc/...`) |
| | `just add GROUP NAME…` | Dispatches to `pkg-add` (bare names) or `unit-add` (ends in `.service`/`.timer`/…) |
diff --git a/dot_config/containers/storage.conf b/dot_config/containers/storage.conf.tmpl
index 3ba957e..62dd35c 100644
--- a/dot_config/containers/storage.conf
+++ b/dot_config/containers/storage.conf.tmpl
@@ -1,3 +1,17 @@
+{{- $machineRole := default "host" (index . "machineRole") -}}
+{{- if eq $machineRole "vm" -}}
+# Rootless podman storage configuration.
+#
+# The VM uses ext4, so use the kernel overlay driver. runroot/graphroot default
+# to $XDG_RUNTIME_DIR/containers and $XDG_DATA_HOME/containers/storage.
+
+[storage]
+driver = "overlay"
+
+[storage.options.overlay]
+# Kernel >=5.13 supports rootless overlay natively on the VM, so leave
+# mount_program unset and avoid fuse-overlayfs.
+{{- else -}}
# Rootless podman storage configuration.
#
# Uses the native kernel btrfs graph driver — much faster than fuse-overlayfs
@@ -15,3 +29,4 @@
[storage]
driver = "btrfs"
+{{- end }}
diff --git a/dot_config/nvim/lua/plugins/ai.lua b/dot_config/nvim/lua/plugins/ai.lua
index 6ebc6f5..81a8307 100644
--- a/dot_config/nvim/lua/plugins/ai.lua
+++ b/dot_config/nvim/lua/plugins/ai.lua
@@ -1,8 +1,8 @@
-- Prefer the chezmoi-pinned Node 24 (host has Arch's system node 26, which
-- breaks copilot-language-server — see
--- ~/.local/share/chezmoi/run_onchange_after_install-copilot-node.sh). Fall
--- back to `node` on PATH for hosts that don't run chezmoi (remote-dev VM
--- via Nix Home-Manager, where home.nix pins nodejs_24 in the profile).
+-- ~/.local/share/chezmoi/run_onchange_after_install-copilot-node.sh.tmpl).
+-- Fall back to `node` on PATH on the VM, where Nix pins nodejs_24 in the
+-- profile.
local pinned_node = vim.fs.joinpath(
vim.env.XDG_DATA_HOME or (vim.env.HOME .. "/.local/share"),
"copilot-node/bin/node"
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/<name>/` 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"
- '';
}
diff --git a/private_dot_gnupg/gpg.conf b/private_dot_gnupg/gpg.conf
index e6672bf..69ceb5a 100644
--- a/private_dot_gnupg/gpg.conf
+++ b/private_dot_gnupg/gpg.conf
@@ -8,4 +8,4 @@ keyserver-options auto-key-retrieve
keyid-format 0xlong
with-fingerprint
-default-key B79D F5F3 7D7F 9B0F 3902 38D5 3298 945F 717C 85F8
+default-key B79DF5F37D7F9B0F390238D53298945F717C85F8
diff --git a/run_onchange_after_deploy-etc.sh.tmpl b/run_onchange_after_deploy-etc.sh.tmpl
index 5f97c70..45e5c26 100755
--- a/run_onchange_after_deploy-etc.sh.tmpl
+++ b/run_onchange_after_deploy-etc.sh.tmpl
@@ -1,3 +1,5 @@
+{{- $machineRole := default "host" (index . "machineRole") -}}
+{{- if eq $machineRole "host" -}}
#!/usr/bin/env dash
# Deploy system-level configs from etc/ to /etc/.
# chezmoi re-runs this script whenever any file under etc/ changes.
@@ -64,3 +66,9 @@ target=$(readlink /usr/local/bin/sudo 2>/dev/null || true)
if [ "$target" = /usr/bin/sudo-rs ]; then
sudo rm -f /usr/local/bin/sudo
fi
+{{- else -}}
+#!/usr/bin/env dash
+# /etc deployment is host-only.
+set -eu
+exit 0
+{{- end }}
diff --git a/run_onchange_after_deploy-firefox.sh.tmpl b/run_onchange_after_deploy-firefox.sh.tmpl
index c9e49be..a9c3a1d 100755
--- a/run_onchange_after_deploy-firefox.sh.tmpl
+++ b/run_onchange_after_deploy-firefox.sh.tmpl
@@ -1,3 +1,5 @@
+{{- $machineRole := default "host" (index . "machineRole") -}}
+{{- if eq $machineRole "host" -}}
#!/usr/bin/env dash
# Deploy Firefox/LibreWolf hardening and custom CSS.
# chezmoi re-runs this script whenever any file under firefox/ or the nix
@@ -36,3 +38,9 @@ find firefox -type f | while IFS= read -r src; do
mkdir -p "$(dirname "$dest")"
cp --remove-destination "$src" "$dest"
done
+{{- else -}}
+#!/usr/bin/env dash
+# Firefox/LibreWolf hardening is host-only.
+set -eu
+exit 0
+{{- end }}
diff --git a/run_onchange_after_deploy-flatpak-overrides.sh.tmpl b/run_onchange_after_deploy-flatpak-overrides.sh.tmpl
index 44664cf..441c3ff 100644
--- a/run_onchange_after_deploy-flatpak-overrides.sh.tmpl
+++ b/run_onchange_after_deploy-flatpak-overrides.sh.tmpl
@@ -5,12 +5,13 @@
#
# script hash: {{ output "sh" "-c" (printf "sha256sum %q/run_onchange_after_deploy-flatpak-overrides.sh.tmpl 2>/dev/null || true" .chezmoi.sourceDir) }}
set -eu
+command -v flatpak >/dev/null 2>&1 || exit 0
apply() {
- app=$1
- shift
- flatpak info --user "$app" >/dev/null 2>&1 || return 0
- flatpak override --user "$@" "$app"
+ app=$1
+ shift
+ flatpak info --user "$app" >/dev/null 2>&1 || return 0
+ flatpak override --user "$@" "$app"
}
apply org.pwmt.zathura --filesystem=xdg-config/zathura:ro
diff --git a/run_onchange_after_deploy-pteid-pkcs11.sh.tmpl b/run_onchange_after_deploy-pteid-pkcs11.sh.tmpl
index 4f57757..504fc4d 100644
--- a/run_onchange_after_deploy-pteid-pkcs11.sh.tmpl
+++ b/run_onchange_after_deploy-pteid-pkcs11.sh.tmpl
@@ -8,6 +8,7 @@
#
# pteid entry hash: {{ output "sh" "-c" (printf "grep '^pt\\.gov\\.autenticacao' %q/meta/flatpak.txt 2>/dev/null || true" .chezmoi.sourceDir) | sha256sum }}
set -eu
+command -v flatpak >/dev/null 2>&1 || exit 0
PTEID_APP=pt.gov.autenticacao
MODULE_NAME=pteid-mw
@@ -26,36 +27,36 @@ SO_IN_SANDBOX="/run/host$SO"
SO_DIR_IN_SANDBOX="/run/host$SO_DIR"
if ! command -v modutil >/dev/null 2>&1 || ! command -v certutil >/dev/null 2>&1; then
- echo "pteid-pkcs11: modutil/certutil not found (install nss); skipping NSS registration." >&2
- exit 0
+ echo "pteid-pkcs11: modutil/certutil not found (install nss); skipping NSS registration." >&2
+ exit 0
fi
apply_override() {
- flatpak info --user "$1" >/dev/null 2>&1 || return 1
- flatpak override --user \
- --filesystem="$PTEID_LOC/files:ro" \
- --socket=pcsc \
- --env="LD_LIBRARY_PATH=$SO_DIR_IN_SANDBOX" \
- "$1"
+ flatpak info --user "$1" >/dev/null 2>&1 || return 1
+ flatpak override --user \
+ --filesystem="$PTEID_LOC/files:ro" \
+ --socket=pcsc \
+ --env="LD_LIBRARY_PATH=$SO_DIR_IN_SANDBOX" \
+ "$1"
}
register_in_profile() {
- prof="$1"
- proc_name="$2"
- [ -d "$prof" ] || return 0
- if [ ! -f "$prof/cert9.db" ]; then
- certutil -N -d "sql:$prof" --empty-password >/dev/null 2>&1 || return 0
- fi
- [ -f "$prof/cert9.db" ] || return 0
- if modutil -list -dbdir "sql:$prof" 2>/dev/null | grep -q "^[[:space:]]*Name:[[:space:]]*$MODULE_NAME$"; then
- return 0
- fi
- if pgrep -u "$(id -u)" -x "$proc_name" >/dev/null 2>&1; then
- echo "pteid-pkcs11: $proc_name is running; close it and re-run 'chezmoi apply' to register the PKCS#11 module." >&2
- return 0
- fi
- modutil -add "$MODULE_NAME" -libfile "$SO_IN_SANDBOX" -dbdir "sql:$prof" -force >/dev/null
- echo "pteid-pkcs11: registered $MODULE_NAME in ${prof#"$HOME/"}"
+ prof="$1"
+ proc_name="$2"
+ [ -d "$prof" ] || return 0
+ if [ ! -f "$prof/cert9.db" ]; then
+ certutil -N -d "sql:$prof" --empty-password >/dev/null 2>&1 || return 0
+ fi
+ [ -f "$prof/cert9.db" ] || return 0
+ if modutil -list -dbdir "sql:$prof" 2>/dev/null | grep -q "^[[:space:]]*Name:[[:space:]]*$MODULE_NAME$"; then
+ return 0
+ fi
+ if pgrep -u "$(id -u)" -x "$proc_name" >/dev/null 2>&1; then
+ echo "pteid-pkcs11: $proc_name is running; close it and re-run 'chezmoi apply' to register the PKCS#11 module." >&2
+ return 0
+ fi
+ modutil -add "$MODULE_NAME" -libfile "$SO_IN_SANDBOX" -dbdir "sql:$prof" -force >/dev/null
+ echo "pteid-pkcs11: registered $MODULE_NAME in ${prof#"$HOME/"}"
}
# Mozilla-family flatpaks: per-profile NSS DBs under ~/.var/app/<id>/<profile_subdir>/<profile>/
@@ -65,13 +66,13 @@ io.gitlab.librewolf-community .librewolf librewolf
org.mozilla.thunderbird .thunderbird thunderbird"
echo "$MOZILLA_APPS" | while IFS=' ' read -r app profile_subdir proc_name; do
- [ -n "$app" ] || continue
- apply_override "$app" || continue
- profiles_dir="$HOME/.var/app/$app/$profile_subdir"
- [ -d "$profiles_dir" ] || continue
- for prof in "$profiles_dir"/*/; do
- register_in_profile "$prof" "$proc_name"
- done
+ [ -n "$app" ] || continue
+ apply_override "$app" || continue
+ profiles_dir="$HOME/.var/app/$app/$profile_subdir"
+ [ -d "$profiles_dir" ] || continue
+ for prof in "$profiles_dir"/*/; do
+ register_in_profile "$prof" "$proc_name"
+ done
done
# Shared-NSS flatpaks (Poppler/LibreOffice): single ~/.pki/nssdb inside the sandbox.
@@ -81,9 +82,9 @@ org.kde.okular okular
org.libreoffice.LibreOffice soffice.bin"
echo "$SHARED_NSS_APPS" | while IFS=' ' read -r app proc_name; do
- [ -n "$app" ] || continue
- apply_override "$app" || continue
- prof="$HOME/.var/app/$app/.pki/nssdb"
- mkdir -p "$prof"
- register_in_profile "$prof/" "$proc_name"
+ [ -n "$app" ] || continue
+ apply_override "$app" || continue
+ prof="$HOME/.var/app/$app/.pki/nssdb"
+ mkdir -p "$prof"
+ register_in_profile "$prof/" "$proc_name"
done
diff --git a/run_onchange_after_deploy-tb-eer.sh.tmpl b/run_onchange_after_deploy-tb-eer.sh.tmpl
index c4dabff..add406b 100644
--- a/run_onchange_after_deploy-tb-eer.sh.tmpl
+++ b/run_onchange_after_deploy-tb-eer.sh.tmpl
@@ -10,6 +10,7 @@
#
# host manifest hash: {{ output "sh" "-c" "for p in $HOME/.nix-profile/lib/mozilla/native-messaging-hosts/external_editor_revived.json /usr/lib/mozilla/native-messaging-hosts/external_editor_revived.json /usr/lib/thunderbird/native-messaging-hosts/external_editor_revived.json; do [ -f \"$p\" ] && sha256sum \"$p\" && break; done; true" | sha256sum }}
set -eu
+command -v flatpak >/dev/null 2>&1 || exit 0
TB_APP=org.mozilla.thunderbird
MANIFEST_NAME=external_editor_revived.json
diff --git a/run_onchange_after_install-copilot-node.sh b/run_onchange_after_install-copilot-node.sh.tmpl
index 9f3e72e..03a2010 100755..100644
--- a/run_onchange_after_install-copilot-node.sh
+++ b/run_onchange_after_install-copilot-node.sh.tmpl
@@ -1,3 +1,5 @@
+{{- $machineRole := default "host" (index . "machineRole") -}}
+{{- if eq $machineRole "host" -}}
#!/usr/bin/env dash
# Install a Node.js 24 (LTS) runtime under ~/.local/share/copilot-node/ for the
# exclusive use of copilot.lua / copilot-lsp inside neovim. System-wide nodejs
@@ -42,3 +44,9 @@ rm -rf "$DEST"
mkdir -p "$(dirname "$DEST")"
mv "$tmp/node-v${NODE_VERSION}-linux-x64" "$DEST"
touch "$STAMP"
+{{- else -}}
+#!/usr/bin/env dash
+# VM gets Node 24 from the Nix Home-Manager profile.
+set -eu
+exit 0
+{{- end }}