summaryrefslogtreecommitdiffstatshomepage
diff options
context:
space:
mode:
-rw-r--r--.chezmoi.toml.tmpl11
-rw-r--r--.chezmoiignore79
-rwxr-xr-x.githooks/post-commit2
-rw-r--r--.github/workflows/check.yml6
-rw-r--r--.gitignore2
-rw-r--r--KEYBINDS.md28
-rw-r--r--canonical/apparmor/dotfiles-nix10
-rw-r--r--canonical/firefox.js2
-rw-r--r--canonical/thunderbird.js4
-rw-r--r--dot_config/autostart/dotfiles-keybase.desktop5
-rw-r--r--dot_config/autostart/dotfiles-mattermost.desktop5
-rw-r--r--dot_config/autostart/dotfiles-nheko.desktop5
-rw-r--r--dot_config/autostart/dotfiles-thunderbird.desktop5
-rw-r--r--dot_config/containers/storage.conf.tmpl2
-rw-r--r--dot_config/dotfiles/role.tmpl1
-rw-r--r--dot_config/ghostty/config.tmpl (renamed from dot_config/ghostty/config)5
-rw-r--r--dot_config/git/config.tmpl (renamed from dot_config/git/config)10
-rw-r--r--dot_config/mimeapps.list.tmpl (renamed from dot_config/mimeapps.list)14
-rw-r--r--dot_config/nix/nix.conf2
-rw-r--r--dot_config/systemd/user/gpg-agent.service.d/canonical.conf.tmpl3
-rw-r--r--dot_config/systemd/user/podman.service.tmpl9
-rw-r--r--dot_config/systemd/user/podman.socket9
-rw-r--r--dot_config/zsh/dot_zprofile12
-rw-r--r--dot_config/zsh/dot_zshrc14
-rw-r--r--dot_local/bin/executable_dictate4
-rwxr-xr-xdot_local/bin/executable_linkhandler3
-rw-r--r--dot_local/bin/executable_ocr3
-rwxr-xr-xdot_local/bin/executable_record3
-rwxr-xr-xdot_local/bin/executable_wqr8
-rw-r--r--dot_local/lib/dotfiles/canonical_desktop.py201
-rw-r--r--dot_local/lib/dotfiles/record.py97
-rw-r--r--just-lib.sh17
-rw-r--r--justfile275
-rw-r--r--meta/canonical/apt.txt8
-rw-r--r--meta/canonical/extensions.txt3
-rw-r--r--meta/canonical/flatpak.txt4
-rw-r--r--meta/canonical/snap.txt8
-rw-r--r--nix/canonical.nix24
-rw-r--r--nix/flake.nix5
-rw-r--r--nix/host.nix21
-rw-r--r--nix/justfile9
-rw-r--r--nix/switch.sh33
-rw-r--r--nix/whisper-model.nix21
-rw-r--r--private_dot_gnupg/gpg-agent.conf3
-rw-r--r--private_dot_gnupg/gpg-agent.conf.tmpl7
-rw-r--r--private_dot_gnupg/gpg.conf.tmpl (renamed from private_dot_gnupg/gpg.conf)4
-rw-r--r--private_dot_ssh/config.tmpl (renamed from private_dot_ssh/config)8
-rw-r--r--run_after_canonical-desktop.sh.tmpl8
-rw-r--r--run_before_canonical-nvim-lock.sh.tmpl9
-rw-r--r--scripts/__init__.py0
-rw-r--r--scripts/canonical-system.sh18
-rw-r--r--scripts/canonical.py137
-rw-r--r--scripts/canonical_profiles.py74
-rw-r--r--scripts/maintenance-lib.sh38
-rw-r--r--scripts/nix-daemon-update.sh12
-rw-r--r--tests/test_canonical.py163
-rw-r--r--tests/test_canonical_desktop.py79
-rw-r--r--tests/test_canonical_profiles.py33
-rw-r--r--tests/test_canonical_record.py51
-rw-r--r--tests/test_recipes.py94
60 files changed, 1583 insertions, 147 deletions
diff --git a/.chezmoi.toml.tmpl b/.chezmoi.toml.tmpl
index 3582b7e..781cfbf 100644
--- a/.chezmoi.toml.tmpl
+++ b/.chezmoi.toml.tmpl
@@ -1,5 +1,8 @@
{{- $defaultMachineRole := default "host" (env "CHEZMOI_MACHINE_ROLE") -}}
-{{- $machineRole := promptStringOnce . "machineRole" "Machine role (host or vm)" $defaultMachineRole -}}
+{{- $machineRole := promptStringOnce . "machineRole" "Machine role (host, vm, canonical)" $defaultMachineRole -}}
+{{- if not (has $machineRole (list "host" "vm" "canonical")) -}}
+{{- fail "machineRole must be host, vm, or canonical" -}}
+{{- end -}}
sourceDir = {{ .chezmoi.sourceDir | quote }}
[status]
@@ -9,8 +12,12 @@ 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 "canonical" }}
+ workName = {{ promptStringOnce . "workName" "Work Git name" | quote }}
+ workEmail = {{ promptStringOnce . "workEmail" "Canonical email address" | quote }}
+ workSigningKey = {{ promptStringOnce . "workSigningKey" "Work GPG signing key fingerprint" | quote }}
+{{- end }}
{{- 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,
diff --git a/.chezmoiignore b/.chezmoiignore
index 01c4d9c..4c4ad4f 100644
--- a/.chezmoiignore
+++ b/.chezmoiignore
@@ -17,7 +17,84 @@ justfile
just-lib.sh
selene.toml
selene-globals.yml
+scripts/
+tests/
+docs/
+canonical/
+**/__pycache__/
+**/*.pyc
dot_config/nvim/nvim-pack-lock.json
-{{ if ne (default "host" (index . "machineRole")) "host" }}
+{{ if eq (default "host" (index . "machineRole")) "vm" }}
.local/share/applications/org.pwmt.zathura.desktop
{{ end }}
+{{ if eq (default "host" (index . "machineRole")) "canonical" }}
+*
+!.hushlogin
+!.zshenv
+!canonical-desktop.sh
+!canonical-nvim-lock.sh
+!.config
+.config/*
+{{ range list "bat" "cargo" "ccache" "clangd" "containers" "direnv" "dotfiles" "fd" "gdb" "ghostty" "ipython" "lsd" "nix" "npm" "opencode" "ripgrep" "sh" "tuicr" "wget" "yazi" "zellij" "zsh" "zathura" "autostart" }}
+!.config/{{ . }}/**
+{{ end }}
+!.config/nvim
+.config/nvim/*
+!.config/nvim/*.lua
+!.config/nvim/lua/**
+!.config/nvim/after/**
+!.config/git/config
+!.config/git
+.config/git/*
+!.config/git/attributes
+!.config/git/ignore
+!.config/mimeapps.list
+!.config/systemd/user/gpg-agent.service.d/canonical.conf
+!.config/systemd
+!.config/systemd/user
+.config/systemd/user/*
+!.config/systemd/user/gpg-agent.service.d/**
+!.config/systemd/user/podman.socket
+!.config/systemd/user/podman.service
+!.gnupg
+.gnupg/*
+!.gnupg/gpg.conf
+!.gnupg/gpg-agent.conf
+!.ssh/config
+!.ssh
+.ssh/*
+!.ssh/config
+!.codex/**
+!.claude/**
+!.copilot/**
+!.hermes/**
+!.omp/**
+!.local
+.local/*
+!.local/lib
+.local/lib/*
+!.local/lib/dotfiles
+.local/lib/dotfiles/*
+!.local/lib/dotfiles/*.py
+!.local/bin
+.local/bin/*
+!.local/share
+.local/share/*
+!.local/share/applications
+.local/share/applications/*
+!.local/share/applications/org.pwmt.zathura.desktop
+{{ range list "dictate" "ocr" "record" "rqr" "wqr" "linkhandler" }}
+!.local/bin/{{ . }}
+{{ end }}
+.config/nvim/nvim-pack-lock.json
+{{ else }}
+canonical-desktop.sh
+canonical-nvim-lock.sh
+.config/nix/nix.conf
+.config/autostart/dotfiles-*.desktop
+.config/systemd/user/gpg-agent.service.d/canonical.conf
+.config/systemd/user/podman.socket
+.config/systemd/user/podman.service
+.local/lib/dotfiles/
+.local/bin/canonical-desktop
+{{ end }}
diff --git a/.githooks/post-commit b/.githooks/post-commit
index d2d7eba..03ad8d4 100755
--- a/.githooks/post-commit
+++ b/.githooks/post-commit
@@ -1,2 +1,2 @@
#!/bin/sh
-chezmoi apply -S "$(git rev-parse --show-toplevel)"
+printf '%s\n' 'Dotfiles committed. Run just apply on the target machine to deploy.'
diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml
index 0a15f83..4d38a8c 100644
--- a/.github/workflows/check.yml
+++ b/.github/workflows/check.yml
@@ -21,3 +21,9 @@ jobs:
- name: just check
run: nix develop ./nix --command just check
+
+ - name: Role and package tests
+ run: nix develop ./nix --command just test
+
+ - name: Evaluate the corporate profile
+ run: nix eval --impure ./nix#homeConfigurations.canonical.activationPackage.drvPath
diff --git a/.gitignore b/.gitignore
index 490507e..ce223a1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,5 @@
.ruff_cache/
node_modules/
*.swp
+__pycache__/
+*.pyc
diff --git a/KEYBINDS.md b/KEYBINDS.md
index ced3cd1..9756d10 100644
--- a/KEYBINDS.md
+++ b/KEYBINDS.md
@@ -420,3 +420,31 @@ in `~/.XCompose`. Press and release Compose, then the sequence.
| `Compose - - -` | `—` (em dash) |
| `Compose - - .` | `–` (en dash) |
| `Compose . . .` | `…` |
+
+# Canonical GNOME Role
+
+These keys apply only to the `canonical` role with PaperWM. Company-locked
+settings remain unchanged.
+
+| Keys | Action |
+| ------------------- | ---------------------------------------- |
+| Super+Return | Ghostty |
+| Super+Shift+Return | Yazi in Ghostty |
+| Super+Shift+B | Firefox |
+| Super+T | Thunderbird, normal window |
+| Super+Shift+Q | Close window |
+| Super+F | Fullscreen |
+| Super+H/J/K/Right | Focus left/down/up/right |
+| Super+Shift+H/J/K/L | Move window left/down/up/right |
+| Super+I | Toggle dictation; copy text to clipboard |
+| Super+Shift+O | NormCap OCR |
+| Super+Shift+R | Toggle portal recording |
+| Super+P | Copyous history |
+| Caps Lock | Escape |
+| Right Ctrl | Compose |
+
+Use GNOME's screenshot UI and Emoji Copy's configured shortcut. PaperWM's
+other default shortcuts remain active. Sway modes and recovery keys do not
+apply to this role.
+Super+L remains the GNOME lock shortcut. Super+D opens the application launcher.
+Super+1 through Super+9 select workspaces 1 through 9; Super+0 selects workspace 10. Add Shift to move the current window to that workspace.
diff --git a/canonical/apparmor/dotfiles-nix b/canonical/apparmor/dotfiles-nix
new file mode 100644
index 0000000..b894210
--- /dev/null
+++ b/canonical/apparmor/dotfiles-nix
@@ -0,0 +1,10 @@
+abi <abi/4.0>,
+include <tunables/global>
+
+profile dotfiles-nix-bwrap /nix/store/*-bubblewrap-*/bin/bwrap flags=(unconfined) {
+ userns,
+}
+
+profile dotfiles-nix-podman /nix/store/*-podman-*/bin/{podman,.podman-wrapped} flags=(unconfined) {
+ userns,
+}
diff --git a/canonical/firefox.js b/canonical/firefox.js
new file mode 100644
index 0000000..aab1534
--- /dev/null
+++ b/canonical/firefox.js
@@ -0,0 +1,2 @@
+user_pref("browser.startup.page", 3);
+user_pref("extensions.formautofill.creditCards.enabled", false);
diff --git a/canonical/thunderbird.js b/canonical/thunderbird.js
new file mode 100644
index 0000000..c5310fc
--- /dev/null
+++ b/canonical/thunderbird.js
@@ -0,0 +1,4 @@
+user_pref("mail.openpgp.allow_external_gnupg", true);
+user_pref("mail.biff.show_alert", true);
+user_pref("mail.biff.use_system_alert", true);
+user_pref("mailnews.default_view_flags", 0);
diff --git a/dot_config/autostart/dotfiles-keybase.desktop b/dot_config/autostart/dotfiles-keybase.desktop
new file mode 100644
index 0000000..fbc6b9d
--- /dev/null
+++ b/dot_config/autostart/dotfiles-keybase.desktop
@@ -0,0 +1,5 @@
+[Desktop Entry]
+Type=Application
+Name=Keybase
+Exec=env KEYBASE_AUTOSTART=1 /snap/bin/keybase
+OnlyShowIn=GNOME;
diff --git a/dot_config/autostart/dotfiles-mattermost.desktop b/dot_config/autostart/dotfiles-mattermost.desktop
new file mode 100644
index 0000000..aa309dd
--- /dev/null
+++ b/dot_config/autostart/dotfiles-mattermost.desktop
@@ -0,0 +1,5 @@
+[Desktop Entry]
+Type=Application
+Name=Mattermost
+Exec=/snap/bin/mattermost-desktop --hidden
+OnlyShowIn=GNOME;
diff --git a/dot_config/autostart/dotfiles-nheko.desktop b/dot_config/autostart/dotfiles-nheko.desktop
new file mode 100644
index 0000000..d71f16e
--- /dev/null
+++ b/dot_config/autostart/dotfiles-nheko.desktop
@@ -0,0 +1,5 @@
+[Desktop Entry]
+Type=Application
+Name=Nheko Work
+Exec=/usr/bin/flatpak run im.nheko.Nheko --profile work
+OnlyShowIn=GNOME;
diff --git a/dot_config/autostart/dotfiles-thunderbird.desktop b/dot_config/autostart/dotfiles-thunderbird.desktop
new file mode 100644
index 0000000..ced3696
--- /dev/null
+++ b/dot_config/autostart/dotfiles-thunderbird.desktop
@@ -0,0 +1,5 @@
+[Desktop Entry]
+Type=Application
+Name=Thunderbird
+Exec=/snap/bin/thunderbird
+OnlyShowIn=GNOME;
diff --git a/dot_config/containers/storage.conf.tmpl b/dot_config/containers/storage.conf.tmpl
index c304b02..a148c5e 100644
--- a/dot_config/containers/storage.conf.tmpl
+++ b/dot_config/containers/storage.conf.tmpl
@@ -1,5 +1,5 @@
{{- $machineRole := default "host" (index . "machineRole") -}}
-{{- if eq $machineRole "vm" -}}
+{{- if ne $machineRole "host" -}}
# Rootless podman storage configuration: VM ext4.
[storage]
diff --git a/dot_config/dotfiles/role.tmpl b/dot_config/dotfiles/role.tmpl
new file mode 100644
index 0000000..f889c28
--- /dev/null
+++ b/dot_config/dotfiles/role.tmpl
@@ -0,0 +1 @@
+{{ default "host" (index . "machineRole") }}
diff --git a/dot_config/ghostty/config b/dot_config/ghostty/config.tmpl
index 8a1faf6..605c6d1 100644
--- a/dot_config/ghostty/config
+++ b/dot_config/ghostty/config.tmpl
@@ -13,7 +13,12 @@ clipboard-read = allow
bell-features = attention,title
# Window — no decorations (sway manages windows)
+{{ if eq (default "host" (index . "machineRole")) "canonical" }}
+window-decoration = auto
+command = {{ .chezmoi.homeDir }}/.nix-profile/bin/zsh -l
+{{ else }}
window-decoration = none
+{{ end }}
confirm-close-surface = false
resize-overlay = never
diff --git a/dot_config/git/config b/dot_config/git/config.tmpl
index 4237892..9987261 100644
--- a/dot_config/git/config
+++ b/dot_config/git/config.tmpl
@@ -1,7 +1,13 @@
[user]
+{{ if eq (default "host" (index . "machineRole")) "canonical" }}
+ email = {{ .workEmail | quote }}
+ name = {{ .workName | quote }}
+ signingkey = {{ .workSigningKey | quote }}
+{{ else }}
email = sommerfeld@sommerfeld.dev
name = sommerfeld
signingkey = 3298945F717C85F8
+{{ end }}
[push]
autoSetupRemote = true
followTags = true
@@ -9,8 +15,10 @@
[core]
whitespace = trailing-space,cr-at-eol
pager = delta
+{{ if ne (default "host" (index . "machineRole")) "canonical" }}
hooksPath = ~/.config/git/hooks
sshCommand = "ssh -i ~/.ssh/nym.pub"
+{{ end }}
[branch]
sort=-committerdate
[diff]
@@ -129,6 +137,7 @@
swd = switch -d
unstage = reset HEAD --
update=!git fetch && git merge --ff-only
+{{ if ne (default "host" (index . "machineRole")) "canonical" }}
[includeIf "gitdir:~/doxfiles/.git"]
path = config-personal
[includeIf "gitdir:~/dev/personal/"]
@@ -149,6 +158,7 @@
assume8bitEncoding = UTF-8
[credential "smtp://127.0.0.1:1016"]
helper = "!f() { test \"$1\" = get && printf 'password=%s\\n' \"$(pass show proton/bridge-smtp)\"; }; f"
+{{ end }}
[include]
; Machine-local overrides. Git silently skips this if the file is absent.
path = ~/.config/git/config.local
diff --git a/dot_config/mimeapps.list b/dot_config/mimeapps.list.tmpl
index ab2f7e2..f5b6d20 100644
--- a/dot_config/mimeapps.list
+++ b/dot_config/mimeapps.list.tmpl
@@ -1,3 +1,16 @@
+{{ if eq (default "host" (index . "machineRole")) "canonical" }}
+[Default Applications]
+application/pdf=org.pwmt.zathura.desktop
+x-scheme-handler/http=firefox_firefox.desktop
+x-scheme-handler/https=firefox_firefox.desktop
+text/html=firefox_firefox.desktop
+x-scheme-handler/mailto=thunderbird_thunderbird.desktop
+x-scheme-handler/webcal=thunderbird_thunderbird.desktop
+message/rfc822=thunderbird_thunderbird.desktop
+
+[Removed Associations]
+application/pdf=org.pwmt.zathura-pdf-poppler.desktop;
+{{ else }}
[Default Applications]
audio/x-vorbis+ogg=io.mpv.Mpv.desktop
audio/aac=io.mpv.Mpv.desktop
@@ -115,3 +128,4 @@ x-scheme-handler/webcals=org.mozilla.thunderbird.desktop;
[Removed Associations]
application/pdf=org.pwmt.zathura-pdf-poppler.desktop;
application/postscript=org.pwmt.zathura-ps.desktop;
+{{ end }}
diff --git a/dot_config/nix/nix.conf b/dot_config/nix/nix.conf
new file mode 100644
index 0000000..16e17dd
--- /dev/null
+++ b/dot_config/nix/nix.conf
@@ -0,0 +1,2 @@
+experimental-features = nix-command flakes
+!include nix.local.conf
diff --git a/dot_config/systemd/user/gpg-agent.service.d/canonical.conf.tmpl b/dot_config/systemd/user/gpg-agent.service.d/canonical.conf.tmpl
new file mode 100644
index 0000000..34557b4
--- /dev/null
+++ b/dot_config/systemd/user/gpg-agent.service.d/canonical.conf.tmpl
@@ -0,0 +1,3 @@
+[Service]
+ExecStart=
+ExecStart="{{ .chezmoi.homeDir }}/.nix-profile/bin/gpg-agent" --supervised
diff --git a/dot_config/systemd/user/podman.service.tmpl b/dot_config/systemd/user/podman.service.tmpl
new file mode 100644
index 0000000..f104bd9
--- /dev/null
+++ b/dot_config/systemd/user/podman.service.tmpl
@@ -0,0 +1,9 @@
+[Unit]
+Description=Rootless Podman API
+Requires=podman.socket
+After=podman.socket
+
+[Service]
+Type=exec
+ExecStart="{{ .chezmoi.homeDir }}/.nix-profile/bin/podman" system service
+KillMode=process
diff --git a/dot_config/systemd/user/podman.socket b/dot_config/systemd/user/podman.socket
new file mode 100644
index 0000000..9d17f3b
--- /dev/null
+++ b/dot_config/systemd/user/podman.socket
@@ -0,0 +1,9 @@
+[Unit]
+Description=Rootless Podman API socket
+
+[Socket]
+ListenStream=%t/podman/podman.sock
+SocketMode=0600
+
+[Install]
+WantedBy=sockets.target
diff --git a/dot_config/zsh/dot_zprofile b/dot_config/zsh/dot_zprofile
index a2f5c8b..ab6badb 100644
--- a/dot_config/zsh/dot_zprofile
+++ b/dot_config/zsh/dot_zprofile
@@ -4,6 +4,8 @@
# Guard against double-sourcing (e.g. nested login shells)
[[ -n $__ZPROFILE_SOURCED ]] && return
__ZPROFILE_SOURCED=1
+_dotfiles_role=host
+[[ -r "$HOME/.config/dotfiles/role" ]] && _dotfiles_role=$(<"$HOME/.config/dotfiles/role")
# ── PATH ──────────────────────────────────────────────────────────────────────
typeset -U path # deduplicate PATH entries
@@ -104,13 +106,15 @@ export _JAVA_OPTIONS="-Dawt.useSystemAAFontSettings=on -Dswing.aatext=true -Dswi
export _JAVA_AWT_WM_NONREPARENTING=1
# ── Miscellaneous ─────────────────────────────────────────────────────────────
-export QT_QPA_PLATFORMTHEME=qt6ct
-export NO_AT_BRIDGE=1 # suppress GTK accessibility bus warnings
+if [[ $_dotfiles_role == host ]]; then
+ export QT_QPA_PLATFORMTHEME=qt6ct
+ export NO_AT_BRIDGE=1
+fi
export DOCKER_HOST="unix://$XDG_RUNTIME_DIR/podman/podman.sock"
export INPUTRC="$XDG_CONFIG_HOME/sh/inputrc"
# ── Wayland ───────────────────────────────────────────────────────────────────
-export XDG_CURRENT_DESKTOP=sway
+[[ $_dotfiles_role == host ]] && export XDG_CURRENT_DESKTOP=sway
# Prefer Wayland for Qt; fall back to xcb for apps without qt{5,6}-wayland.
export QT_QPA_PLATFORM="wayland;xcb"
# SDL2 defaults to X11; force Wayland with X11 fallback. SDL3 ignores this.
@@ -142,7 +146,7 @@ case $(uname -n) in
esac
# ── Auto-start sway on VT1 ────────────────────────────────────────────────────
-if [[ -z $WAYLAND_DISPLAY && $XDG_VTNR == 1 ]]; then
+if [[ $_dotfiles_role == host && -z $WAYLAND_DISPLAY && $XDG_VTNR == 1 ]]; then
export XDG_SESSION_TYPE=wayland
exec sway
fi
diff --git a/dot_config/zsh/dot_zshrc b/dot_config/zsh/dot_zshrc
index 8371c15..dc92c3c 100644
--- a/dot_config/zsh/dot_zshrc
+++ b/dot_config/zsh/dot_zshrc
@@ -385,8 +385,10 @@ za() {
# (e.g. attached over SSH, then reattached locally, or vice versa).
reload-env() {
local pid
- pid=$(pgrep -u "$UID" -x sway | head -1) || {
- echo "reload-env: no sway process found for $USER" >&2
+ local compositor=sway
+ [[ $(<"$HOME/.config/dotfiles/role") == canonical ]] && compositor=gnome-shell
+ pid=$(pgrep -u "$UID" -x "$compositor" | head -1) || {
+ echo "reload-env: no $compositor process found for $USER" >&2
return 1
}
local kv
@@ -409,11 +411,9 @@ alias rj='just --justfile ~/.local/share/dotfiles/nix/justfile --working-directo
# Home-Manager (flake-based; standalone HM defaults to legacy ~/.config/home-manager)
hm() {
- local profile=host
- [ -r /etc/os-release ] && . /etc/os-release
- case "${ID:-}" in
- ubuntu|debian) profile=vm ;;
- esac
+ local profile
+ profile=$(chezmoi data | jq -er '.machineRole') || return
+ case $profile in host|vm|canonical) ;; *) return 1 ;; esac
local flake="$HOME/dotfiles/nix#${profile}"
[ -d "$HOME/.local/share/dotfiles/nix" ] && flake="$HOME/.local/share/dotfiles/nix#${profile}"
nix run home-manager/master -- "$@" --flake "$flake" --impure
diff --git a/dot_local/bin/executable_dictate b/dot_local/bin/executable_dictate
index 7ad80bb..3cd763e 100644
--- a/dot_local/bin/executable_dictate
+++ b/dot_local/bin/executable_dictate
@@ -77,7 +77,9 @@ stop_and_transcribe() {
fi
printf '%s' "$text" | wl-copy
- wtype -- "$text"
+ if [ "$(cat "$HOME/.config/dotfiles/role" 2>/dev/null)" != canonical ]; then
+ wtype -- "$text"
+ fi
notify-send -t 2500 "🎙️ Dictated" "$text"
}
diff --git a/dot_local/bin/executable_linkhandler b/dot_local/bin/executable_linkhandler
index e44dcc1..1ca006f 100755
--- a/dot_local/bin/executable_linkhandler
+++ b/dot_local/bin/executable_linkhandler
@@ -1,4 +1,7 @@
#!/usr/bin/env dash
+if [ "$(cat "$HOME/.config/dotfiles/role" 2>/dev/null)" = canonical ]; then
+ exec /usr/bin/xdg-open "$@"
+fi
resolve_url() {
if [ -f "$1" ]; then
diff --git a/dot_local/bin/executable_ocr b/dot_local/bin/executable_ocr
index aeadb51..d770911 100644
--- a/dot_local/bin/executable_ocr
+++ b/dot_local/bin/executable_ocr
@@ -21,6 +21,9 @@ if [ "${1:-}" ]; then
}
text="$(tesseract "$1" - -l "$lang" 2>/dev/null || true)"
else
+ if [ "$(cat "$HOME/.config/dotfiles/role" 2>/dev/null)" = canonical ]; then
+ exec flatpak run com.github.dynobo.normcap
+ fi
region="$(slurp 2>/dev/null)" || exit 0
text="$(grim -g "$region" - | tesseract - - -l "$lang" 2>/dev/null || true)"
fi
diff --git a/dot_local/bin/executable_record b/dot_local/bin/executable_record
index ac88771..2601f9d 100755
--- a/dot_local/bin/executable_record
+++ b/dot_local/bin/executable_record
@@ -1,4 +1,7 @@
#!/usr/bin/env dash
+if [ "$(cat "$HOME/.config/dotfiles/role" 2>/dev/null)" = canonical ]; then
+ exec /usr/bin/python3 "$HOME/.local/lib/dotfiles/record.py" "$@"
+fi
pid_file="/tmp/recordpid"
log_file="/tmp/record.log"
diff --git a/dot_local/bin/executable_wqr b/dot_local/bin/executable_wqr
index 5f9d36d..b4cc123 100755
--- a/dot_local/bin/executable_wqr
+++ b/dot_local/bin/executable_wqr
@@ -8,4 +8,10 @@ else
text="$1"
fi
-printf '%s' "$text" | qrencode -t PNG -o - | imv -
+if [ "$(cat "$HOME/.config/dotfiles/role" 2>/dev/null)" = canonical ]; then
+ image=$(mktemp "${XDG_RUNTIME_DIR:?}/dotfiles-qr-XXXXXX.png")
+ printf '%s' "$text" | qrencode -t PNG -o "$image"
+ /usr/bin/xdg-open "$image"
+else
+ printf '%s' "$text" | qrencode -t PNG -o - | imv -
+fi
diff --git a/dot_local/lib/dotfiles/canonical_desktop.py b/dot_local/lib/dotfiles/canonical_desktop.py
new file mode 100644
index 0000000..fc9e946
--- /dev/null
+++ b/dot_local/lib/dotfiles/canonical_desktop.py
@@ -0,0 +1,201 @@
+"""Apply owned GNOME keys and restore their previous values."""
+
+import importlib
+import json
+import os
+import shlex
+import sys
+from pathlib import Path
+
+HOME = Path.home()
+STATE = HOME / ".local/state/dotfiles/gnome-settings.json"
+EXTENSIONS = [
+ "paperwm@paperwm.github.com",
+ "copyous@boerdereinar.dev",
+ "emoji-copy@felipeftn",
+]
+
+
+def save_state(saved: dict) -> None:
+ STATE.parent.mkdir(parents=True, exist_ok=True)
+ temporary = STATE.with_suffix(".tmp")
+ temporary.write_text(json.dumps(saved, indent=2) + "\n")
+ temporary.chmod(0o600)
+ temporary.replace(STATE)
+
+
+def settings_object(schema: str, path: str | None = None):
+ gio = importlib.import_module("gi.repository.Gio")
+ source = gio.SettingsSchemaSource.get_default()
+ for directory in (HOME / ".local/share/gnome-shell/extensions").glob("*/schemas"):
+ if (directory / "gschemas.compiled").exists():
+ source = gio.SettingsSchemaSource.new_from_directory(
+ str(directory), source, False
+ )
+ definition = source.lookup(schema, True)
+ if definition is None:
+ print(
+ f"Missing schema: {schema}. Install extensions, log in again, then retry."
+ )
+ return None
+ return gio.Settings.new_full(definition, None, path)
+
+
+def write_key(
+ schema: str, key: str, value, saved: dict, path: str | None = None
+) -> None:
+ settings = settings_object(schema, path)
+ if settings is None:
+ return
+ if key not in settings.props.settings_schema.list_keys():
+ raise RuntimeError(f"Unknown setting: {schema} {key}")
+ if not settings.is_writable(key):
+ print(f"Locked by policy: {schema} {key}")
+ return
+ glib = importlib.import_module("gi.repository.GLib")
+ variant = glib.Variant(settings.get_value(key).get_type_string(), value)
+ name = json.dumps([schema, key, path])
+ if name not in saved:
+ previous = settings.get_user_value(key)
+ saved[name] = {
+ "before": previous.print_(True) if previous is not None else None
+ }
+ saved[name]["applied"] = variant.print_(True)
+ save_state(saved)
+ if not settings.set_value(key, variant):
+ raise RuntimeError(f"Cannot set {schema} {key}")
+
+
+def merge_key(schema: str, key: str, values: list[str], saved: dict) -> None:
+ settings = settings_object(schema)
+ if settings is not None:
+ write_key(
+ schema, key, list(dict.fromkeys([*settings.get_strv(key), *values])), saved
+ )
+
+
+def shortcuts(saved: dict) -> None:
+ shell = shlex.quote(str(HOME / ".nix-profile/bin/zsh"))
+ actions = {
+ "terminal": ("<Super>Return", "/snap/bin/ghostty"),
+ "files": ("<Super><Shift>Return", f"/snap/bin/ghostty -e {shell} -lc yazi"),
+ "browser": ("<Super><Shift>b", "/snap/bin/firefox"),
+ "mail": ("<Super>t", "/snap/bin/thunderbird"),
+ "dictate": ("<Super>i", f"{shell} -lc dictate"),
+ "ocr": ("<Super><Shift>o", f"{shell} -lc ocr"),
+ "record": ("<Super><Shift>r", f"{shell} -lc 'record toggle'"),
+ "clipboard": (
+ "<Super>p",
+ "gdbus call --session --dest org.gnome.Shell.Extensions.Copyous --object-path /org/gnome/Shell/Extensions/Copyous --method org.gnome.Shell.Extensions.Copyous.Show",
+ ),
+ }
+ schema = "org.gnome.settings-daemon.plugins.media-keys"
+ paths = []
+ for name, (binding, command) in actions.items():
+ path = f"/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/dotfiles-{name}/"
+ paths.append(path)
+ for key, value in {
+ "name": name,
+ "binding": binding,
+ "command": command,
+ }.items():
+ write_key(schema + ".custom-keybinding", key, value, saved, path)
+ merge_key(schema, "custom-keybindings", paths, saved)
+
+
+def apply_settings(saved: dict) -> None:
+ merge_key("org.gnome.shell", "enabled-extensions", EXTENSIONS, saved)
+ merge_key(
+ "org.gnome.desktop.input-sources",
+ "xkb-options",
+ ["caps:escape", "compose:rctrl"],
+ saved,
+ )
+ write_key(
+ "org.gnome.desktop.input-sources", "sources", [("xkb", "us+altgr-intl")], saved
+ )
+ write_key("org.gnome.desktop.wm.keybindings", "close", ["<Super><Shift>q"], saved)
+ write_key("org.gnome.desktop.wm.keybindings", "minimize", ["<Alt>F9"], saved)
+ write_key(
+ "org.gnome.shell.keybindings", "toggle-application-view", ["<Super>d"], saved
+ )
+ write_key(
+ "org.gnome.desktop.wm.keybindings", "toggle-fullscreen", ["<Super>f"], saved
+ )
+ paper = "org.gnome.shell.extensions.paperwm.keybindings"
+ for key in [
+ "new-window",
+ "take-window",
+ "toggle-maximize-width",
+ "slurp-in",
+ "barf-out-active",
+ "cycle-height",
+ ]:
+ write_key(paper, key, [], saved)
+ for direction, letter in zip(["left", "down", "up", "right"], "hjkl"):
+ binding = "<Super>Right" if direction == "right" else f"<Super>{letter}"
+ write_key(paper, f"switch-{direction}", [binding], saved)
+ write_key(paper, f"move-{direction}", [f"<Super><Shift>{letter}"], saved)
+ shortcuts(saved)
+ workspaces(saved)
+
+
+def workspaces(saved: dict) -> None:
+ write_key("org.gnome.mutter", "dynamic-workspaces", False, saved)
+ write_key("org.gnome.desktop.wm.preferences", "num-workspaces", 10, saved)
+ write_key("org.gnome.shell.extensions.dash-to-dock", "hot-keys", False, saved)
+ for number in range(1, 11):
+ key = str(number % 10)
+ for action, modifier in [("switch", ""), ("move", "<Shift>")]:
+ write_key(
+ "org.gnome.desktop.wm.keybindings",
+ f"{action}-to-workspace-{number}",
+ [f"<Super>{modifier}{key}"],
+ saved,
+ )
+ if number < 10:
+ write_key(
+ "org.gnome.shell.keybindings",
+ f"switch-to-application-{number}",
+ [],
+ saved,
+ )
+
+
+def restore(saved: dict) -> None:
+ glib = importlib.import_module("gi.repository.GLib")
+ for name, entry in list(saved.items()):
+ schema, key, path = json.loads(name)
+ settings = settings_object(schema, path)
+ if settings is None or not settings.is_writable(key):
+ continue
+ if settings.get_value(key).print_(True) != entry["applied"]:
+ print(f"Changed since deployment; retained: {schema} {key}")
+ continue
+ if entry["before"] is None:
+ settings.reset(key)
+ else:
+ settings.set_value(
+ key, glib.Variant.parse(None, entry["before"], None, None)
+ )
+ del saved[name]
+ save_state(saved)
+
+
+def main() -> None:
+ if (HOME / ".config/dotfiles/role").read_text().strip() != "canonical":
+ raise SystemExit("The desktop settings require the canonical role.")
+ if "GNOME" not in os.environ.get("XDG_CURRENT_DESKTOP", "").upper():
+ raise SystemExit("Run this command from the GNOME desktop session.")
+ saved = json.loads(STATE.read_text()) if STATE.exists() else {}
+ if sys.argv[1:] == ["settings"]:
+ apply_settings(saved)
+ elif sys.argv[1:] == ["restore"]:
+ restore(saved)
+ else:
+ raise SystemExit("Use settings or restore.")
+ importlib.import_module("gi.repository.Gio").Settings.sync()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/dot_local/lib/dotfiles/record.py b/dot_local/lib/dotfiles/record.py
new file mode 100644
index 0000000..d6b3cf9
--- /dev/null
+++ b/dot_local/lib/dotfiles/record.py
@@ -0,0 +1,97 @@
+"""Control one GNOME portal recording in a transient user service."""
+
+import fcntl
+import os
+import subprocess
+import sys
+from datetime import UTC, datetime
+from pathlib import Path
+
+APP = "com.dec05eba.gpu_screen_recorder"
+UNIT = "dotfiles-record.service"
+
+
+def active() -> bool:
+ return (
+ subprocess.run(
+ ["systemctl", "--user", "is-active", "--quiet", UNIT],
+ check=False,
+ ).returncode
+ == 0
+ )
+
+
+def start(runtime: Path) -> None:
+ if active():
+ return
+ (runtime / "control.sock").unlink(missing_ok=True)
+ videos = Path.home() / "vids"
+ videos.mkdir(exist_ok=True)
+ output = videos / (datetime.now(UTC).strftime("%Y-%m-%d_%H-%M-%S-%fZ") + ".mkv")
+ subprocess.run(
+ [
+ "systemd-run",
+ "--user",
+ "--collect",
+ "--unit=" + UNIT,
+ "--property=KillSignal=SIGINT",
+ "--property=TimeoutStopSec=30s",
+ "/usr/bin/flatpak",
+ "run",
+ "--filesystem=" + str(videos),
+ "--filesystem=" + str(runtime),
+ "--command=gpu-screen-recorder",
+ APP,
+ "-w",
+ "portal",
+ "-f",
+ "60",
+ "-o",
+ str(output),
+ "-ipc",
+ str(runtime / "control.sock"),
+ ],
+ check=True,
+ )
+
+
+def stop(runtime: Path) -> None:
+ if not active():
+ return
+ if (runtime / "control.sock").exists():
+ subprocess.run(
+ [
+ "/usr/bin/flatpak",
+ "run",
+ "--filesystem=" + str(runtime),
+ "--command=gsr-cli",
+ APP,
+ "-ipc",
+ str(runtime / "control.sock"),
+ "stop",
+ ],
+ check=True,
+ )
+ else:
+ subprocess.run(["systemctl", "--user", "stop", UNIT], check=True)
+
+
+def main() -> None:
+ action = sys.argv[1] if len(sys.argv) == 2 else "toggle"
+ if action not in {"start", "stop", "toggle", "status"}:
+ raise SystemExit("Use record start|stop|toggle|status")
+ if action == "status":
+ print("recording" if active() else "stopped")
+ return
+ runtime = Path(os.environ["XDG_RUNTIME_DIR"]) / "dotfiles-record"
+ runtime.mkdir(mode=0o700, exist_ok=True)
+ with (runtime / "lock").open("w") as lock:
+ fcntl.flock(lock, fcntl.LOCK_EX)
+ if action == "stop" or (action == "toggle" and active()):
+ stop(runtime)
+ else:
+ start(runtime)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/just-lib.sh b/just-lib.sh
index 1846c26..5caa79f 100644
--- a/just-lib.sh
+++ b/just-lib.sh
@@ -91,3 +91,20 @@ _undeclared_packages() {
done
fi
}
+_machine_role() {
+ role=$(chezmoi data -S "${DOTFILES_SOURCE:-.}" | jq -er '.machineRole') || return 1
+ case "$role" in
+ host | vm | canonical) printf '%s\n' "$role" ;;
+ *)
+ echo 'error: initialize chezmoi with host, vm, or canonical role first' >&2
+ return 1
+ ;;
+ esac
+}
+
+_require_host() {
+ [ "$(_machine_role)" = host ] || {
+ echo 'error: this recipe is for the Arch host only' >&2
+ return 1
+ }
+}
diff --git a/justfile b/justfile
index 68a9b83..6d2c2e9 100644
--- a/justfile
+++ b/justfile
@@ -7,7 +7,7 @@ default:
# ═══════════════════════════════════════════════════════════════════
# First-time machine setup: regenerate chezmoi config, install git hooks, deploy dotfiles, install base packages, switch Home-Manager, enable curated units
-init: _chezmoi-init _install-hooks apply (pkg-apply "base") nix-switch unit-apply
+init: _chezmoi-init _install-hooks (pkg-apply "base") nix-switch apply unit-apply _canonical-finish
# ═══════════════════════════════════════════════════════════════════
# Day-to-day
@@ -16,61 +16,81 @@ init: _chezmoi-init _install-hooks apply (pkg-apply "base") nix-switch unit-appl
# Reconcile everything: deploy dotfiles + /etc, top up packages, sync Home-Manager, enable curated units
sync: apply pkg-fix nix-switch unit-apply
-# Deploy dotfiles AND /etc atomically (chezmoi apply; /etc handled by onchange template)
+# Deploy home files and the role-specific hooks.
apply:
chezmoi apply -S . -v
-# Apply Home-Manager profile (host on Arch, vm on Ubuntu remote-dev). Falls
-
-# back to a no-op when nix isn't installed (pre-bootstrap state).
+# Build and activate the saved chezmoi role using the locked Home-Manager input.
nix-switch:
- #!/bin/sh
+ @bash "{{ justfile_directory() }}/nix/switch.sh"
+
+# Read-only corporate setup checks.
+canonical-check:
+ @python3 scripts/canonical.py check
+
+# Install corporate packages, profile, dotfiles, and desktop settings.
+canonical-setup: _require-canonical _install-hooks (pkg-apply "base") nix-switch apply canonical-system canonical-extensions canonical-desktop
+
+_require-canonical:
+ #!/usr/bin/env bash
set -eu
- if ! command -v nix >/dev/null 2>&1; then
- echo "nix not installed; skipping home-manager switch" >&2
- exit 0
- fi
- # home-manager's activation script references $USER unconditionally;
- # just runs recipes with a sanitized env that may drop it.
- export USER="${USER:-$(id -un)}"
- export HOME="${HOME:-$(getent passwd "$USER" | cut -d: -f6)}"
- profile=host
- [ -f /etc/os-release ] && . /etc/os-release || true
- case "${ID:-}" in
- ubuntu|debian) profile=vm ;;
- esac
- sh "{{ justfile_directory() }}/nix/with-github-auth.sh" \
- nix --extra-experimental-features 'nix-command flakes' \
- run home-manager/master -- \
- switch --impure --flake "{{ justfile_directory() }}/nix#${profile}" -b backup
- # Keep the login shell pointed at the Home-Manager-managed zsh.
- NIX_ZSH="$HOME/.nix-profile/bin/zsh"
- if [ -x "$NIX_ZSH" ]; then
- if ! grep -qxF "$NIX_ZSH" /etc/shells 2>/dev/null; then
- echo "$NIX_ZSH" | sudo tee -a /etc/shells >/dev/null
- fi
- current_shell="$(getent passwd "$USER" | cut -d: -f7)"
- if [ "$current_shell" != "$NIX_ZSH" ]; then
- sudo chsh -s "$NIX_ZSH" "$USER"
- fi
- fi
+ source just-lib.sh
+ [ "$(_machine_role)" = canonical ]
+
+# Install the two program-scoped AppArmor profiles and Thunderbird GPG access.
+canonical-system: _require-canonical
+ @bash scripts/canonical-system.sh
+
+canonical-extensions: _require-canonical
+ @python3 scripts/canonical.py extensions
+
+canonical-desktop: _require-canonical
+ @/usr/bin/python3 dot_local/lib/dotfiles/canonical_desktop.py settings
+
+# Set preferences after the first Firefox and Thunderbird launch.
+canonical-profiles: _require-canonical
+ @python3 -m scripts.canonical_profiles
+
+# Restore only desktop keys previously changed by this repo.
+canonical-desktop-restore: _require-canonical
+ @/usr/bin/python3 dot_local/lib/dotfiles/canonical_desktop.py restore
+
+# Upgrade an upstream multi-user Nix installation separately from package inputs.
+nix-daemon-update: _require-canonical
+ @bash scripts/nix-daemon-update.sh
+
+# Check package commands and chezmoi role boundaries without deployment.
+test:
+ @python3 -m unittest discover -s tests -v
# ═══════════════════════════════════════════════════════════════════
# Updates
# ═══════════════════════════════════════════════════════════════════
# Update everything: system packages, flatpaks, nix flake inputs
-update: pkg-update flatpak-update nix-update nvim-update
+update: pkg-update flatpak-update nix-update nvim-update _desktop-update
# Upgrade official Arch packages, after showing newly published Arch news.
pkg-update: arch-news-check _pacman-upgrade
_pacman-upgrade:
- @sudo pacman -Syu
+ #!/usr/bin/env bash
+ set -eu
+ source just-lib.sh
+ role=$(_machine_role) || exit 1
+ case "$role" in
+ host) sudo pacman -Syu ;;
+ canonical) python3 scripts/canonical.py update ;;
+ vm) echo "System packages are not managed for the VM." ;;
+ esac
# Show new Arch Linux news and ask whether to proceed, like paru's NewsOnUpgrade.
arch-news-check:
- @sh "{{ justfile_directory() }}/dot_local/bin/executable_arch-news-check"
+ #!/usr/bin/env bash
+ set -eu
+ source just-lib.sh
+ [ "$(_machine_role)" = host ] || exit 0
+ sh "{{ justfile_directory() }}/dot_local/bin/executable_arch-news-check"
# Mark the current Arch Linux news feed as seen without running an upgrade.
arch-news-read:
@@ -96,6 +116,12 @@ _nix-flake-update:
# Update all user-scope flatpaks (Flathub apps + URL bundles when their version changes)
flatpak-update:
#!/usr/bin/env dash
+ . "{{ justfile_directory() }}/just-lib.sh"
+ role=$(_machine_role) || exit 1
+ case "$role" in
+ canonical) exec python3 scripts/canonical.py flatpak-update ;;
+ vm) exit 0 ;;
+ esac
set -eu
flatpak update --user -y --noninteractive
[ -f meta/flatpak.txt ] || exit 0
@@ -192,25 +218,7 @@ _lockfiles-commit:
git commit -m "$msg" -- "${lockfiles[@]}"
# Re-add changes from live files back into the repo; pass a path to target one, or omit for all
-re-add *paths:
- #!/usr/bin/env bash
- set -eo pipefail
- args=({{ paths }})
- if [ ${#args[@]} -eq 0 ]; then
- just dotfiles-re-add
- just etc-re-add
- exit 0
- fi
- for raw in "${args[@]}"; do
- case "$raw" in
- /etc/*|etc/*) just etc-re-add "$raw" ;;
- */*) just dotfiles-re-add "$raw" ;;
- *)
- echo "error: re-add needs a path (got bare word: $raw)" >&2
- exit 1
- ;;
- esac
- done
+re-add *paths: (_maintenance-home "re-add" paths) (_etc-re-add "auto" paths) (_maintenance-apply-etc "auto" paths)
# Format code; pass a path to format a single file, or omit to format everything
fmt *target:
@@ -485,46 +493,21 @@ forget +args:
just pkg-forget "${args[@]}"
# Show dotfile + /etc diffs; pass a path to limit to a single file
-diff *paths:
- #!/usr/bin/env bash
- set -eo pipefail
- args=({{ paths }})
- if [ ${#args[@]} -eq 0 ]; then
- just dotfiles-diff
- just etc-diff
- exit 0
- fi
- for raw in "${args[@]}"; do
- case "$raw" in
- /etc/*|etc/*) just etc-diff "$raw" ;;
- */*) just dotfiles-diff "$raw" ;;
- *)
- echo "error: diff needs a path (got bare word: $raw)" >&2
- exit 1
- ;;
- esac
- done
+diff *paths: (_maintenance-home "diff" paths) (_etc-diff "auto" paths)
# 3-way merge dotfile or /etc conflicts; pass a path for one file, or omit to merge all
-merge *paths:
+merge *paths: (_maintenance-home "merge" paths) (_etc-merge "auto" paths)
+
+_maintenance-home action *paths:
#!/usr/bin/env bash
set -eo pipefail
- args=({{ paths }})
- if [ ${#args[@]} -eq 0 ]; then
- just dotfiles-merge
- just etc-merge
- exit 0
- fi
- for raw in "${args[@]}"; do
- case "$raw" in
- /etc/*|etc/*) just etc-merge "$raw" ;;
- */*) just dotfiles-merge "$raw" ;;
- *)
- echo "error: merge needs a path (got bare word: $raw)" >&2
- exit 1
- ;;
- esac
- done
+ source just-lib.sh
+ source scripts/maintenance-lib.sh
+ _maintenance_select auto home {{ paths }}
+ "$maintenance_run" || exit 0
+ action={{ action }}
+ if [ "$action" = merge ] && [ ${#args[@]} -eq 0 ]; then action=merge-all; fi
+ chezmoi "$action" -S . "${args[@]}"
# ═══════════════════════════════════════════════════════════════════
# Dotfiles domain (chezmoi-backed)
@@ -575,6 +558,8 @@ dotfiles-status:
# List curated systemd units with their enabled/active state
unit-list:
#!/usr/bin/env dash
+ . "{{ justfile_directory() }}/just-lib.sh"
+ [ "$(_machine_role)" = host ] || { echo "Use canonical-check for corporate status."; exit 0; }
_render() {
scope=$1 file=$2
sctl="systemctl"; [ "$scope" = user ] && sctl="systemctl --user"
@@ -604,6 +589,8 @@ unit-list:
# Enable all curated systemd units (idempotent, soft-fail per unit); walks system + user lists
unit-apply:
#!/usr/bin/env dash
+ . "{{ justfile_directory() }}/just-lib.sh"
+ [ "$(_machine_role)" = host ] || exit 0
if [ -f systemd-units/system.txt ]; then
sed -E 's/[[:space:]]*#.*$//; /^[[:space:]]*$/d' systemd-units/system.txt | while read -r u; do
sudo systemctl enable --now "$u" \
@@ -620,6 +607,8 @@ unit-apply:
# Show drift between curated units and actually-enabled systemd units (system + user)
unit-status:
#!/usr/bin/env dash
+ . "{{ justfile_directory() }}/just-lib.sh"
+ [ "$(_machine_role)" = host ] || { echo "Use canonical-check for corporate status."; exit 0; }
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
_drift() {
scope=$1 label=$2
@@ -658,6 +647,8 @@ unit-status:
# inferred by probing `systemctl [--user] cat <unit>` (system wins on tie).
unit-add +units:
#!/usr/bin/env dash
+ . "{{ justfile_directory() }}/just-lib.sh"
+ _require_host || exit 1
set -eu
_scope() {
u=$1
@@ -696,6 +687,8 @@ unit-add +units:
# inferred from which list currently contains the unit.
unit-forget +units:
#!/usr/bin/env dash
+ . "{{ justfile_directory() }}/just-lib.sh"
+ _require_host || exit 1
set -eu
for u in {{ units }}; do
scope=
@@ -728,6 +721,8 @@ unit-forget +units:
# Show /etc drift: repo-tracked files that differ from or are missing on the host
etc-status:
#!/usr/bin/env bash
+ . "{{ justfile_directory() }}/just-lib.sh"
+ [ "$(_machine_role)" = host ] || { echo "Use canonical-check for corporate status."; exit 0; }
set -eo pipefail
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
echo "=== /etc drift ==="
@@ -749,9 +744,15 @@ etc-status:
done < <(find etc -type f ! -name .ignore | sort)
# Diff repo-managed etc/<path> against live /etc/<path> (all managed files if no args)
-etc-diff *paths:
+etc-diff *paths: (_etc-diff "host" paths)
+
+_etc-diff scope *paths:
#!/usr/bin/env bash
+ . "{{ justfile_directory() }}/just-lib.sh"
set -eo pipefail
+ source scripts/maintenance-lib.sh
+ _maintenance_select '{{ scope }}' etc {{ paths }}
+ "$maintenance_run" || exit 0
diff_labels=0
if diff -u --label old --label new /dev/null /dev/null >/dev/null 2>&1; then
diff_labels=1
@@ -764,7 +765,6 @@ etc-diff *paths:
diff -u "$left" "$right"
fi
}
- args=({{ paths }})
if [ ${#args[@]} -eq 0 ]; then
mapfile -t args < <(find etc -type f ! -name .ignore | sort)
fi
@@ -808,6 +808,8 @@ etc-diff *paths:
# Diff live /etc/<path> against pristine pacman version (defaults to all repo-managed files)
etc-upstream-diff *paths:
#!/usr/bin/env bash
+ . "{{ justfile_directory() }}/just-lib.sh"
+ _require_host || exit 1
set -eo pipefail
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
diff_labels=0
@@ -880,10 +882,15 @@ etc-upstream-diff *paths:
done
# 3-way merge tracked /etc files against their live /etc counterparts (edit repo side)
-etc-merge *paths:
+etc-merge *paths: (_etc-merge "host" paths)
+
+_etc-merge scope *paths:
#!/usr/bin/env bash
+ . "{{ justfile_directory() }}/just-lib.sh"
set -eo pipefail
- args=({{ paths }})
+ source scripts/maintenance-lib.sh
+ _maintenance_select '{{ scope }}' etc {{ paths }}
+ "$maintenance_run" || exit 0
if [ ${#args[@]} -eq 0 ]; then
mapfile -t args < <(find etc -type f ! -name .ignore | sort)
fi
@@ -926,6 +933,8 @@ etc-merge *paths:
# Copy one or more /etc/<path> regular files into the repo's etc/ tree
etc-add +paths:
#!/usr/bin/env bash
+ . "{{ justfile_directory() }}/just-lib.sh"
+ _require_host || exit 1
set -eo pipefail
for path in {{ paths }}; do
case "$path" in
@@ -943,15 +952,19 @@ etc-add +paths:
echo "Run 'chezmoi apply' to sync (no-op content-wise, refreshes deploy hash)."
# Re-add changes from live /etc back into the repo (no args = all tracked files)
-etc-re-add *paths: (_etc-re-add paths) _apply-etc-re-add
+etc-re-add *paths: (_etc-re-add "host" paths) (_maintenance-apply-etc "host" paths)
-_etc-re-add *paths:
+_etc-re-add scope *paths:
#!/usr/bin/env bash
+ . "{{ justfile_directory() }}/just-lib.sh"
set -eo pipefail
+ source scripts/maintenance-lib.sh
+ _maintenance_select '{{ scope }}' etc {{ paths }}
+ "$maintenance_run" || exit 0
# Build target list: explicit paths, or every tracked repo file.
targets=()
- if [ -n "{{ paths }}" ]; then
- for raw in {{ paths }}; do
+ if [ ${#args[@]} -gt 0 ]; then
+ for raw in "${args[@]}"; do
case "$raw" in
*..*|*/./*|./*|../*) echo "error: unsafe path: $raw" >&2; exit 1 ;;
esac
@@ -999,6 +1012,8 @@ etc-forget +paths: (_etc-forget paths) _apply-etc-forget
_etc-forget +paths:
#!/usr/bin/env bash
+ . "{{ justfile_directory() }}/just-lib.sh"
+ _require_host || exit 1
set -eo pipefail
for raw in {{ paths }}; do
case "$raw" in
@@ -1022,6 +1037,8 @@ etc-reset +paths: (_etc-reset paths) _apply-etc-reset
_etc-reset +paths:
#!/usr/bin/env bash
+ . "{{ justfile_directory() }}/just-lib.sh"
+ _require_host || exit 1
set -eo pipefail
for raw in {{ paths }}; do
case "$raw" in
@@ -1058,7 +1075,13 @@ _etc-reset +paths:
# Stop tracking one or more /etc files: reset to pristine, deploy, then drop from repo
etc-untrack +paths: (_etc-reset paths) _apply-etc-reset (_etc-forget paths) _apply-etc-forget
-_apply-etc-re-add:
+_maintenance-apply-etc scope *paths:
+ #!/usr/bin/env bash
+ set -eo pipefail
+ source just-lib.sh
+ source scripts/maintenance-lib.sh
+ _maintenance_select '{{ scope }}' etc {{ paths }}
+ "$maintenance_run" || exit 0
chezmoi apply -S . -v
_apply-etc-reset:
@@ -1070,6 +1093,8 @@ _apply-etc-forget:
# Restore live /etc/<path> to pristine pacman contents (bypasses the repo)
etc-restore +paths:
#!/usr/bin/env bash
+ . "{{ justfile_directory() }}/just-lib.sh"
+ _require_host || exit 1
set -eo pipefail
for raw in {{ paths }}; do
case "$raw" in
@@ -1112,6 +1137,8 @@ etc-restore +paths:
pkg-status:
#!/usr/bin/env dash
. "{{ justfile_directory() }}/just-lib.sh"
+ [ "$(_machine_role)" = host ] || { echo "Use canonical-check for corporate status."; exit 0; }
+ . "{{ justfile_directory() }}/just-lib.sh"
flatpaks=$(flatpak list --user --app --columns=application 2>/dev/null || true)
echo "=== Package drift ==="
_active_pacman_packages | while read -r pkg; do
@@ -1130,11 +1157,15 @@ pkg-status:
undeclared:
#!/usr/bin/env dash
. "{{ justfile_directory() }}/just-lib.sh"
+ _require_host || exit 1
+ . "{{ justfile_directory() }}/just-lib.sh"
_undeclared_packages
# Show per-group install coverage; pass a group name for a per-package breakdown
pkg-list group="":
#!/usr/bin/env dash
+ . "{{ justfile_directory() }}/just-lib.sh"
+ [ "$(_machine_role)" = host ] || { echo "Use canonical-check for corporate status."; exit 0; }
is_installed() {
# $1: group name, $2: package/app id
if [ "$1" = "flatpak" ]; then
@@ -1194,6 +1225,12 @@ pkg-list group="":
# Install one or more package groups, or all groups if none given (e.g. just pkg-apply base intel)
pkg-apply *groups:
#!/usr/bin/env dash
+ . "{{ justfile_directory() }}/just-lib.sh"
+ role=$(_machine_role) || exit 1
+ case "$role" in
+ canonical) exec python3 scripts/canonical.py install ;;
+ vm) exit 0 ;;
+ esac
set -eu
. "{{ justfile_directory() }}/just-lib.sh"
# Keep declared packages marked explicit in the local pacman DB.
@@ -1228,6 +1265,12 @@ pkg-apply *groups:
pkg-fix:
#!/usr/bin/env dash
. "{{ justfile_directory() }}/just-lib.sh"
+ role=$(_machine_role) || exit 1
+ case "$role" in
+ canonical) exec python3 scripts/canonical.py install ;;
+ vm) exit 0 ;;
+ esac
+ . "{{ justfile_directory() }}/just-lib.sh"
flatpaks=$(flatpak list --user --app --columns=application 2>/dev/null || true)
for file in meta/*.txt; do
group=$(basename "$file" .txt)
@@ -1258,6 +1301,8 @@ pkg-fix:
# Append one or more packages to a group list and install them (e.g. just pkg-add base ripgrep fd)
pkg-add group +pkgs:
#!/usr/bin/env dash
+ . "{{ justfile_directory() }}/just-lib.sh"
+ _require_host || exit 1
set -eu
file="meta/{{ group }}.txt"
if [ ! -f "$file" ]; then
@@ -1283,6 +1328,8 @@ pkg-add group +pkgs:
# Remove one or more packages from a group list (does NOT uninstall; the package may belong to other groups)
pkg-forget group +pkgs:
#!/usr/bin/env dash
+ . "{{ justfile_directory() }}/just-lib.sh"
+ _require_host || exit 1
set -eu
file="meta/{{ group }}.txt"
if [ ! -f "$file" ]; then
@@ -1307,8 +1354,14 @@ _chezmoi-init:
chezmoi init -S .
_install-hooks:
- # Let the user-level git hooks dispatch project hooks.
- git config --local --unset core.hooksPath 2>/dev/null || true
+ #!/usr/bin/env bash
+ set -eu
+ source just-lib.sh
+ if [ "$(_machine_role)" = canonical ]; then
+ git config --local core.hooksPath .githooks
+ else
+ git config --local --unset core.hooksPath 2>/dev/null || true
+ fi
# Install all flatpaks declared in meta/flatpak.txt. Flathub IDs are batched
# into a single install call; URL bundles are downloaded and installed only
@@ -1326,3 +1379,19 @@ _active-packages:
#!/usr/bin/env dash
. "{{ justfile_directory() }}/just-lib.sh"
_active_pacman_packages
+
+_desktop-update:
+ #!/usr/bin/env bash
+ set -eu
+ source just-lib.sh
+ [ "$(_machine_role)" = canonical ] || exit 0
+ gext update --install $(sed '/^#/d; /^$/d' meta/canonical/extensions.txt)
+
+_canonical-finish:
+ #!/usr/bin/env bash
+ set -eu
+ source just-lib.sh
+ [ "$(_machine_role)" = canonical ] || exit 0
+ bash scripts/canonical-system.sh
+ python3 scripts/canonical.py extensions
+ /usr/bin/python3 dot_local/lib/dotfiles/canonical_desktop.py settings
diff --git a/meta/canonical/apt.txt b/meta/canonical/apt.txt
new file mode 100644
index 0000000..e8b5885
--- /dev/null
+++ b/meta/canonical/apt.txt
@@ -0,0 +1,8 @@
+git
+flatpak
+uidmap
+gir1.2-gda-5.0
+gir1.2-gsound-1.0
+zbar-tools
+pinentry-gnome3
+python3-gi
diff --git a/meta/canonical/extensions.txt b/meta/canonical/extensions.txt
new file mode 100644
index 0000000..e3450d2
--- /dev/null
+++ b/meta/canonical/extensions.txt
@@ -0,0 +1,3 @@
+paperwm@paperwm.github.com
+copyous@boerdereinar.dev
+emoji-copy@felipeftn
diff --git a/meta/canonical/flatpak.txt b/meta/canonical/flatpak.txt
new file mode 100644
index 0000000..6f0b46b
--- /dev/null
+++ b/meta/canonical/flatpak.txt
@@ -0,0 +1,4 @@
+org.pwmt.zathura
+com.github.dynobo.normcap
+com.dec05eba.gpu_screen_recorder
+im.nheko.Nheko
diff --git a/meta/canonical/snap.txt b/meta/canonical/snap.txt
new file mode 100644
index 0000000..b9f79a9
--- /dev/null
+++ b/meta/canonical/snap.txt
@@ -0,0 +1,8 @@
+firefox
+thunderbird
+ghostty
+mattermost-desktop
+zoom-client
+okular
+libreoffice
+keybase
diff --git a/nix/canonical.nix b/nix/canonical.nix
new file mode 100644
index 0000000..0f37427
--- /dev/null
+++ b/nix/canonical.nix
@@ -0,0 +1,24 @@
+{ pkgs, lib, ... }:
+{
+ imports = [ ./common.nix ];
+ home.username = builtins.getEnv "USER";
+ home.homeDirectory = builtins.getEnv "HOME";
+ home.sessionVariables.NVIM_TREESITTER_CC = "${pkgs.stdenv.cc}/bin/cc";
+ home.packages = with pkgs; [
+ external-editor-revived
+ gnome-extensions-cli
+ wl-clipboard
+ qrencode
+ libnotify
+ playerctl
+ pulseaudio
+ (tesseract.override {
+ enableLanguages = [
+ "eng"
+ "por"
+ ];
+ })
+ whisper-cpp
+ (import ./whisper-model.nix { inherit pkgs lib; })
+ ];
+}
diff --git a/nix/flake.nix b/nix/flake.nix
index d871ce7..b140bf0 100644
--- a/nix/flake.nix
+++ b/nix/flake.nix
@@ -260,6 +260,10 @@
devShells.${system}.default = pkgs.mkShellNoCC {
packages = with pkgs; [
basedpyright
+ chezmoi
+ git
+ jq
+ python3
just
nixfmt
prettier
@@ -275,6 +279,7 @@
homeConfigurations = {
vm = mkProfile ./vm.nix;
host = mkProfile ./host.nix;
+ canonical = mkProfile ./canonical.nix;
};
};
}
diff --git a/nix/host.nix b/nix/host.nix
index 588d635..b3d9d60 100644
--- a/nix/host.nix
+++ b/nix/host.nix
@@ -9,26 +9,7 @@
# Arch host Home-Manager package profile.
let
- whisper-cpp-model-base = pkgs.stdenvNoCC.mkDerivation rec {
- pname = "whisper-cpp-model-base";
- version = "1.0";
- src = pkgs.fetchurl {
- url = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin";
- hash = "sha256-YO1bw90U7qhWST0zQ0m0BXgt3K8AKNS130CINF+6Lv4=";
- };
- dontUnpack = true;
- installPhase = ''
- runHook preInstall
- install -Dm644 $src $out/share/whisper-cpp-models/ggml-base.bin
- runHook postInstall
- '';
- meta = with lib; {
- description = "Whisper.cpp ggml-base.bin model (142 MB, multilingual)";
- homepage = "https://huggingface.co/ggerganov/whisper.cpp";
- license = licenses.mit;
- platforms = platforms.all;
- };
- };
+ whisper-cpp-model-base = import ./whisper-model.nix { inherit pkgs lib; };
pass-secret-service-rust = pkgs.rustPlatform.buildRustPackage rec {
pname = "pass-secret-service";
version = "0.7.0";
diff --git a/nix/justfile b/nix/justfile
index 4ecd75b..7ea7b30 100644
--- a/nix/justfile
+++ b/nix/justfile
@@ -13,7 +13,7 @@ pull:
# Rebuild Home-Manager profile from the current checkout (no pull)
switch:
- home-manager switch --impure --flake '{{ justfile_directory() }}#vm' -b backup
+ bash '{{ justfile_directory() }}/switch.sh' vm
# Apply VM dotfiles with chezmoi
apply: _ensure-vm-chezmoi-config
@@ -37,6 +37,9 @@ _ensure-vm-chezmoi-config:
fix-gpg-agent:
#!/usr/bin/env sh
set -eu
+ . "{{ justfile_directory() }}/../just-lib.sh"
+ DOTFILES_SOURCE="{{ justfile_directory() }}/.."
+ [ "$(_machine_role)" = vm ]
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)
@@ -59,7 +62,7 @@ fix-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
+migrate-chezmoi: pull _ensure-vm-chezmoi-config switch fix-gpg-agent _cleanup-home-manager-dotfiles apply
_cleanup-home-manager-dotfiles: _ensure-vm-chezmoi-config
#!/usr/bin/env bash
@@ -90,7 +93,7 @@ _cleanup-home-manager-dotfiles: _ensure-vm-chezmoi-config
# 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
+ if cmp -s "$ssh_config" <(chezmoi execute-template -S "$src" --file "$src/private_dot_ssh/config.tmpl"); then
rm -f "$ssh_config"
else
printf 'refusing to overwrite modified %s; merge it before migrating\n' "$ssh_config" >&2
diff --git a/nix/switch.sh b/nix/switch.sh
new file mode 100644
index 0000000..ce63855
--- /dev/null
+++ b/nix/switch.sh
@@ -0,0 +1,33 @@
+#!/usr/bin/env bash
+set -euo pipefail
+root=$(cd "$(dirname "$0")/.." && pwd)
+cd "$root"
+source "$root/just-lib.sh"
+profile=$(_machine_role)
+if ! command -v nix >/dev/null 2>&1; then
+ [[ $profile != canonical ]] || {
+ echo 'Install upstream Nix first.' >&2
+ exit 1
+ }
+ echo 'Nix is not installed; skipping Home-Manager.' >&2
+ exit 0
+fi
+if [[ -n ${1:-} && $profile != "$1" ]]; then
+ echo "error: expected role $1, got $profile" >&2
+ exit 1
+fi
+export USER="${USER:-$(id -un)}"
+export HOME="${HOME:?HOME must be set}"
+generation=$(sh "$root/nix/with-github-auth.sh" \
+ nix --extra-experimental-features 'nix-command flakes' build --impure \
+ --no-link --print-out-paths "$root/nix#homeConfigurations.$profile.activationPackage")
+HOME_MANAGER_BACKUP_EXT=backup "$generation/activate"
+if [[ $profile != canonical ]]; then
+ shell="$HOME/.nix-profile/bin/zsh"
+ if ! grep -qxF "$shell" /etc/shells; then
+ printf '%s\n' "$shell" | sudo tee -a /etc/shells >/dev/null
+ fi
+ if [[ $(getent passwd "$USER" | cut -d: -f7) != "$shell" ]]; then
+ sudo chsh -s "$shell" "$USER"
+ fi
+fi
diff --git a/nix/whisper-model.nix b/nix/whisper-model.nix
new file mode 100644
index 0000000..3582bd3
--- /dev/null
+++ b/nix/whisper-model.nix
@@ -0,0 +1,21 @@
+{ pkgs, lib }:
+pkgs.stdenvNoCC.mkDerivation rec {
+ pname = "whisper-cpp-model-base";
+ version = "1.0";
+ src = pkgs.fetchurl {
+ url = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin";
+ hash = "sha256-YO1bw90U7qhWST0zQ0m0BXgt3K8AKNS130CINF+6Lv4=";
+ };
+ dontUnpack = true;
+ installPhase = ''
+ runHook preInstall
+ install -Dm644 $src $out/share/whisper-cpp-models/ggml-base.bin
+ runHook postInstall
+ '';
+ meta = with lib; {
+ description = "Whisper.cpp ggml-base.bin model (142 MB, multilingual)";
+ homepage = "https://huggingface.co/ggerganov/whisper.cpp";
+ license = licenses.mit;
+ platforms = platforms.all;
+ };
+}
diff --git a/private_dot_gnupg/gpg-agent.conf b/private_dot_gnupg/gpg-agent.conf
deleted file mode 100644
index 46879fd..0000000
--- a/private_dot_gnupg/gpg-agent.conf
+++ /dev/null
@@ -1,3 +0,0 @@
-enable-ssh-support
-pinentry-program /home/sommerfeld/.nix-profile/bin/pinentry-curses
-allow-loopback-pinentry
diff --git a/private_dot_gnupg/gpg-agent.conf.tmpl b/private_dot_gnupg/gpg-agent.conf.tmpl
new file mode 100644
index 0000000..ca074fb
--- /dev/null
+++ b/private_dot_gnupg/gpg-agent.conf.tmpl
@@ -0,0 +1,7 @@
+enable-ssh-support
+{{ if eq (default "host" (index . "machineRole")) "canonical" }}
+pinentry-program /usr/bin/pinentry-gnome3
+{{ else }}
+pinentry-program {{ .chezmoi.homeDir }}/.nix-profile/bin/pinentry-curses
+{{ end }}
+allow-loopback-pinentry
diff --git a/private_dot_gnupg/gpg.conf b/private_dot_gnupg/gpg.conf.tmpl
index 69ceb5a..ed5ceb5 100644
--- a/private_dot_gnupg/gpg.conf
+++ b/private_dot_gnupg/gpg.conf.tmpl
@@ -8,4 +8,8 @@ keyserver-options auto-key-retrieve
keyid-format 0xlong
with-fingerprint
+{{ if eq (default "host" (index . "machineRole")) "canonical" }}
+default-key {{ .workSigningKey }}
+{{ else }}
default-key B79DF5F37D7F9B0F390238D53298945F717C85F8
+{{ end }}
diff --git a/private_dot_ssh/config b/private_dot_ssh/config.tmpl
index 8df3687..b6dce43 100644
--- a/private_dot_ssh/config
+++ b/private_dot_ssh/config.tmpl
@@ -1,3 +1,10 @@
+{{ if eq (default "host" (index . "machineRole")) "canonical" }}
+Include config.local
+Host *
+ HashKnownHosts yes
+ ForwardAgent no
+ ServerAliveInterval 120
+{{ else }}
# Ensure KnownHosts are unreadable if leaked
HashKnownHosts yes
@@ -17,3 +24,4 @@ Match host * exec "gpg-connect-agent UPDATESTARTUPTTY /bye"
Match host *.wg,*.pulpo
IdentityFile ~/.ssh/nym.pub
+{{ end }}
diff --git a/run_after_canonical-desktop.sh.tmpl b/run_after_canonical-desktop.sh.tmpl
new file mode 100644
index 0000000..54e0a72
--- /dev/null
+++ b/run_after_canonical-desktop.sh.tmpl
@@ -0,0 +1,8 @@
+{{ if eq (default "host" (index . "machineRole")) "canonical" -}}
+#!/bin/sh
+set -eu
+case "${XDG_CURRENT_DESKTOP:-}" in
+ *GNOME*|*gnome*) /usr/bin/python3 "$HOME/.local/lib/dotfiles/canonical_desktop.py" settings ;;
+ *) echo 'Run just canonical-desktop from GNOME to apply desktop keys.' ;;
+esac
+{{ end -}}
diff --git a/run_before_canonical-nvim-lock.sh.tmpl b/run_before_canonical-nvim-lock.sh.tmpl
new file mode 100644
index 0000000..3aa3606
--- /dev/null
+++ b/run_before_canonical-nvim-lock.sh.tmpl
@@ -0,0 +1,9 @@
+{{ if eq (default "host" (index . "machineRole")) "canonical" -}}
+#!/bin/sh
+set -eu
+target="${XDG_CONFIG_HOME:-$HOME/.config}/nvim/nvim-pack-lock.json"
+if [ ! -e "$target" ] && [ ! -L "$target" ]; then
+ mkdir -p "$(dirname "$target")"
+ cp {{ printf "%s/dot_config/nvim/nvim-pack-lock.json" .chezmoi.sourceDir | quote }} "$target"
+fi
+{{ end -}}
diff --git a/scripts/__init__.py b/scripts/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/scripts/__init__.py
diff --git a/scripts/canonical-system.sh b/scripts/canonical-system.sh
new file mode 100644
index 0000000..e6f0825
--- /dev/null
+++ b/scripts/canonical-system.sh
@@ -0,0 +1,18 @@
+#!/usr/bin/env bash
+set -euo pipefail
+cd "$(dirname "$0")/.."
+source just-lib.sh
+[[ $(_machine_role) == canonical ]]
+[[ $(
+ # shellcheck disable=SC1091
+ . /etc/os-release
+ echo "$ID"
+) == ubuntu ]]
+# Parse before replacing the installed profile.
+sudo apparmor_parser --skip-kernel-load --skip-cache canonical/apparmor/dotfiles-nix
+sudo install -m 644 canonical/apparmor/dotfiles-nix /etc/apparmor.d/dotfiles-nix
+sudo apparmor_parser --replace /etc/apparmor.d/dotfiles-nix
+sudo snap connect thunderbird:gpg-keys
+systemctl --user daemon-reload
+systemctl --user enable --now gpg-agent.socket gpg-agent-ssh.socket podman.socket
+echo 'Existing GPG agent processes keep their executable until the next login.'
diff --git a/scripts/canonical.py b/scripts/canonical.py
new file mode 100644
index 0000000..2ff6310
--- /dev/null
+++ b/scripts/canonical.py
@@ -0,0 +1,137 @@
+"""Install declared corporate packages without removing existing packages."""
+
+import argparse
+import json
+import os
+import platform
+import subprocess
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def packages(source: str) -> list[str]:
+ return [
+ line.strip()
+ for line in (ROOT / "meta/canonical" / f"{source}.txt").read_text().splitlines()
+ if line.strip() and not line.startswith("#")
+ ]
+
+
+def snap_install_commands() -> list[list[str]]:
+ return [
+ ["sudo", "snap", "install", name, "--channel=stable"]
+ + (["--classic"] if name == "ghostty" else [])
+ for name in packages("snap")
+ ]
+
+
+def flatpak_install_commands() -> list[list[str]]:
+ return [
+ [
+ "flatpak",
+ "remote-add",
+ "--user",
+ "--if-not-exists",
+ "flathub",
+ "https://flathub.org/repo/flathub.flatpakrepo",
+ ],
+ [
+ "flatpak",
+ "install",
+ "--user",
+ "--assumeyes",
+ "flathub",
+ *packages("flatpak"),
+ ],
+ ]
+
+
+def update_commands() -> list[list[str]]:
+ # An untargeted refresh respects Snap holds. Explicit targets override them.
+ return [
+ ["sudo", "apt-get", "update"],
+ ["sudo", "apt-get", "upgrade"],
+ ["sudo", "snap", "refresh"],
+ ]
+
+
+def require_canonical() -> None:
+ data = json.loads(
+ subprocess.check_output(["chezmoi", "data", "-S", str(ROOT)], text=True)
+ )
+ if data.get("machineRole") != "canonical":
+ raise SystemExit("This command requires machineRole=canonical.")
+ if platform.freedesktop_os_release().get("ID") != "ubuntu":
+ raise SystemExit("This command requires Ubuntu.")
+
+
+def install() -> None:
+ subprocess.run(["sudo", "apt-get", "update"], check=True)
+ subprocess.run(["sudo", "apt-get", "install", *packages("apt")], check=True)
+ for command in snap_install_commands():
+ if subprocess.run(
+ ["snap", "list", command[3]],
+ check=False,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ ).returncode:
+ subprocess.run(command, check=True)
+ for command in flatpak_install_commands():
+ subprocess.run(command, check=True)
+
+
+def check() -> None:
+ commands = [
+ ["lsb_release", "-ds"],
+ *[
+ ["systemctl", "is-active", unit]
+ for unit in ["display-manager", "snapd", "apparmor", "nix-daemon"]
+ ],
+ ["landscape-config", "--actively-registered"],
+ ["snap", "connections", "thunderbird"],
+ ["snap", "list", *packages("snap")],
+ ["gnome-extensions", "list", "--enabled"],
+ ["flatpak", "list", "--user", "--app"],
+ *[["flatpak", "info", "--user", app] for app in packages("flatpak")],
+ ["getent", "passwd", str(os.getuid())],
+ ["getsubids", os.environ.get("USER", "")],
+ ["getsubids", "-g", os.environ.get("USER", "")],
+ ]
+ failed = False
+ for command in commands:
+ print("\n> " + " ".join(command), flush=True)
+ try:
+ failed |= subprocess.run(command, check=False).returncode != 0
+ except FileNotFoundError:
+ print(f"Missing: {command[0]}")
+ failed = True
+ if failed:
+ raise SystemExit(1)
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "action", choices=["install", "update", "flatpak-update", "extensions", "check"]
+ )
+ args = parser.parse_args()
+ require_canonical()
+ if args.action == "install":
+ install()
+ elif args.action == "check":
+ check()
+ else:
+ commands = update_commands()
+ if args.action == "flatpak-update":
+ commands = [
+ ["flatpak", "update", "--user", "--assumeyes", *packages("flatpak")]
+ ]
+ elif args.action == "extensions":
+ commands = [["gext", "install", *packages("extensions")]]
+ for command in commands:
+ subprocess.run(command, check=True)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/canonical_profiles.py b/scripts/canonical_profiles.py
new file mode 100644
index 0000000..4c12085
--- /dev/null
+++ b/scripts/canonical_profiles.py
@@ -0,0 +1,74 @@
+"""Add owned preferences to existing Snap browser profiles."""
+
+import configparser
+import json
+import shutil
+from pathlib import Path
+
+from scripts.canonical import ROOT, require_canonical
+
+START = "// dotfiles: begin"
+END = "// dotfiles: end"
+
+
+def preferences(existing: str, owned: str) -> str:
+ if START in existing:
+ before, remainder = existing.split(START, 1)
+ if END not in remainder:
+ raise ValueError("Incomplete dotfiles preference block")
+ _, after = remainder.split(END, 1)
+ existing = before.rstrip() + after
+ return existing.rstrip() + "\n" + START + "\n" + owned.rstrip() + "\n" + END + "\n"
+
+
+def profiles(root: Path) -> list[Path]:
+ ini = configparser.ConfigParser(interpolation=None)
+ ini.read(root / "profiles.ini")
+ result = []
+ for section in ini.sections():
+ if not section.startswith("Profile") or "Path" not in ini[section]:
+ continue
+ path = Path(ini[section]["Path"])
+ if ini[section].get("IsRelative", "1") == "1":
+ path = root / path
+ if path.resolve().is_relative_to(root.resolve()) and path.is_dir():
+ result.append(path)
+ return result
+
+
+def deploy(root: Path, source: Path) -> None:
+ found = profiles(root)
+ if not found:
+ print(f"No profile under {root}. Start the app once, close it, then retry.")
+ for profile in found:
+ target = profile / "user.js"
+ existing = target.read_text() if target.exists() else ""
+ updated = preferences(existing, source.read_text())
+ if updated == existing:
+ continue
+ if target.exists() and not target.with_suffix(".js.pre-dotfiles").exists():
+ shutil.copy2(target, target.with_suffix(".js.pre-dotfiles"))
+ target.write_text(updated)
+ print(f"Updated {target}")
+
+
+def main() -> None:
+ require_canonical()
+ home = Path.home()
+ deploy(home / "snap/firefox/common/.mozilla/firefox", ROOT / "canonical/firefox.js")
+ deploy(
+ home / "snap/thunderbird/common/.thunderbird", ROOT / "canonical/thunderbird.js"
+ )
+ source = (
+ home
+ / ".nix-profile/lib/mozilla/native-messaging-hosts/external_editor_revived.json"
+ )
+ manifest = json.loads(source.read_text())
+ target = home / ".mozilla/native-messaging-hosts/external_editor_revived.json"
+ target.parent.mkdir(parents=True, exist_ok=True)
+ manifest["path"] = str(home / ".nix-profile/bin/external-editor-revived")
+ target.write_text(json.dumps(manifest, indent=2) + "\n")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/maintenance-lib.sh b/scripts/maintenance-lib.sh
new file mode 100644
index 0000000..33e7e07
--- /dev/null
+++ b/scripts/maintenance-lib.sh
@@ -0,0 +1,38 @@
+#!/usr/bin/env bash
+
+# Set args and maintenance_run for one domain of a maintenance request.
+# shellcheck disable=SC2034
+_maintenance_select() {
+ local scope=$1 domain=$2 role raw target
+ shift 2
+ role=$(_machine_role) || return 1
+ args=()
+ maintenance_run=false
+ if [ "$scope" = host ]; then
+ _require_host || return 1
+ args=("$@")
+ maintenance_run=true
+ return
+ fi
+ for raw in "$@"; do
+ case "$raw" in
+ /etc/* | etc/*)
+ [ "$role" = host ] || {
+ echo 'error: /etc paths require the host role' >&2
+ return 1
+ }
+ target=etc
+ ;;
+ */*) target=home ;;
+ *)
+ echo "error: expected a file path: $raw" >&2
+ return 1
+ ;;
+ esac
+ [ "$target" != "$domain" ] || args+=("$raw")
+ done
+ if [ ${#args[@]} -gt 0 ] || { [ $# -eq 0 ] && { [ "$domain" = home ] || [ "$role" = host ]; }; }; then
+ maintenance_run=true
+ fi
+ return 0
+}
diff --git a/scripts/nix-daemon-update.sh b/scripts/nix-daemon-update.sh
new file mode 100644
index 0000000..2b2d12c
--- /dev/null
+++ b/scripts/nix-daemon-update.sh
@@ -0,0 +1,12 @@
+#!/usr/bin/env bash
+set -euo pipefail
+if [[ -e /nix/nix-installer || -e /etc/nix/nix.custom.conf ]] || dpkg-query -W nix-bin >/dev/null 2>&1; then
+ echo 'error: this recipe is only for the upstream multi-user installer' >&2
+ exit 1
+fi
+nix=/nix/var/nix/profiles/default/bin/nix
+[[ -x $nix ]]
+sudo "$nix" --extra-experimental-features nix-command upgrade-nix --profile /nix/var/nix/profiles/default
+sudo systemctl daemon-reload
+sudo systemctl restart nix-daemon.service
+"$nix" --version
diff --git a/tests/test_canonical.py b/tests/test_canonical.py
new file mode 100644
index 0000000..d1c2367
--- /dev/null
+++ b/tests/test_canonical.py
@@ -0,0 +1,163 @@
+import importlib.util
+import json
+import os
+import subprocess
+import tempfile
+import unittest
+from pathlib import Path
+from unittest.mock import patch
+
+ROOT = Path(__file__).resolve().parents[1]
+SPEC = importlib.util.spec_from_file_location(
+ "canonical", ROOT / "scripts/canonical.py"
+)
+assert SPEC and SPEC.loader
+canonical = importlib.util.module_from_spec(SPEC)
+SPEC.loader.exec_module(canonical)
+
+
+class PackageTests(unittest.TestCase):
+ def test_non_corporate_role_is_rejected_before_system_access(self):
+ with (
+ patch.object(
+ canonical.subprocess,
+ "check_output",
+ return_value='{"machineRole":"host"}',
+ ),
+ self.assertRaises(SystemExit),
+ ):
+ canonical.require_canonical()
+
+ def test_classic_permission_is_explicit(self):
+ commands = canonical.snap_install_commands()
+ self.assertIn(
+ ["sudo", "snap", "install", "ghostty", "--channel=stable", "--classic"],
+ commands,
+ )
+ self.assertEqual(sum("--classic" in command for command in commands), 1)
+
+ def test_flatpaks_are_user_scoped(self):
+ commands = canonical.flatpak_install_commands()
+ self.assertTrue(all("--user" in command for command in commands))
+ self.assertIn("im.nheko.Nheko", commands[-1])
+
+ def test_no_destructive_package_updates(self):
+ commands = canonical.update_commands()
+ self.assertIn(["sudo", "apt-get", "upgrade"], commands)
+ self.assertFalse(
+ any(
+ "autoremove" in command or "dist-upgrade" in command
+ for command in commands
+ )
+ )
+ self.assertFalse(any("--ignore-running" in command for command in commands))
+
+
+class RoleTests(unittest.TestCase):
+ def command(self, role: str, *args: str) -> list[str]:
+ return [
+ "chezmoi",
+ "--config",
+ "/dev/null",
+ "--config-format",
+ "toml",
+ "--override-data",
+ json.dumps(
+ {
+ "machineRole": role,
+ "workName": "Work User",
+ "workEmail": "work@canonical.com",
+ "workSigningKey": "A" * 40,
+ }
+ ),
+ "-S",
+ str(ROOT),
+ *args,
+ ]
+
+ def test_canonical_file_boundary(self):
+ files = subprocess.check_output(
+ self.command("canonical", "managed", "--include=files,scripts,symlinks"),
+ text=True,
+ ).splitlines()
+ for required in [
+ ".ssh/config",
+ ".gnupg/gpg.conf",
+ ".config/git/config",
+ ".config/ghostty/config",
+ ".local/bin/rqr",
+ ]:
+ self.assertIn(required, files)
+ for path in files:
+ self.assertFalse(
+ any(
+ part in path
+ for part in [
+ "sway",
+ "waybar",
+ "nym.pub",
+ "sshcontrol",
+ "pass-secret-service",
+ ".config/git/hooks",
+ "deploy-etc",
+ ]
+ )
+ )
+ self.assertNotIn("__pycache__", path)
+ self.assertNotEqual(path, ".config/nvim/nvim-pack-lock.json")
+ if path.endswith(".sh") and not path.startswith("."):
+ self.assertIn(path, ["canonical-desktop.sh", "canonical-nvim-lock.sh"])
+
+ def test_canonical_lockfile_is_seeded_without_overwriting(self):
+ rendered = subprocess.check_output(
+ self.command(
+ "canonical",
+ "execute-template",
+ "--file",
+ str(ROOT / "run_before_canonical-nvim-lock.sh.tmpl"),
+ ),
+ text=True,
+ )
+ with tempfile.TemporaryDirectory() as directory:
+ env = {
+ **os.environ,
+ "HOME": directory,
+ "XDG_CONFIG_HOME": directory + "/config",
+ }
+ target = Path(directory) / "config/nvim/nvim-pack-lock.json"
+ subprocess.run(["sh", "-c", rendered], env=env, check=True)
+ self.assertEqual(
+ target.read_bytes(),
+ (ROOT / "dot_config/nvim/nvim-pack-lock.json").read_bytes(),
+ )
+ target.write_text('{"local": true}\n')
+ subprocess.run(["sh", "-c", rendered], env=env, check=True)
+ self.assertEqual(target.read_text(), '{"local": true}\n')
+
+ def test_work_identity_is_rendered_without_personal_identity(self):
+ for source in [
+ "dot_config/git/config.tmpl",
+ "private_dot_ssh/config.tmpl",
+ "private_dot_gnupg/gpg.conf.tmpl",
+ ]:
+ rendered = subprocess.check_output(
+ self.command(
+ "canonical", "execute-template", "--file", str(ROOT / source)
+ ),
+ text=True,
+ )
+ self.assertNotIn("sommerfeld", rendered)
+ self.assertNotIn("nym.pub", rendered)
+ self.assertNotIn("proton/", rendered)
+
+ def test_host_and_vm_do_not_receive_corporate_autostart(self):
+ for role in ["host", "vm"]:
+ files = subprocess.check_output(
+ self.command(role, "managed", "--include=files,symlinks"), text=True
+ )
+ self.assertNotIn(".config/autostart/dotfiles-", files)
+ self.assertNotIn("gpg-agent.service.d/canonical.conf", files)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_canonical_desktop.py b/tests/test_canonical_desktop.py
new file mode 100644
index 0000000..cdbc812
--- /dev/null
+++ b/tests/test_canonical_desktop.py
@@ -0,0 +1,79 @@
+import importlib.util
+import tempfile
+import unittest
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+ROOT = Path(__file__).resolve().parents[1]
+SPEC = importlib.util.spec_from_file_location(
+ "desktop", ROOT / "dot_local/lib/dotfiles/canonical_desktop.py"
+)
+assert SPEC and SPEC.loader
+desktop = importlib.util.module_from_spec(SPEC)
+SPEC.loader.exec_module(desktop)
+
+
+class DesktopTests(unittest.TestCase):
+ def test_unknown_keys_fail_without_writing(self):
+ settings = MagicMock()
+ settings.props.settings_schema.list_keys.return_value = []
+ with (
+ patch.object(desktop, "settings_object", return_value=settings),
+ self.assertRaisesRegex(RuntimeError, "Unknown setting"),
+ ):
+ desktop.write_key("schema", "missing", "value", {})
+ settings.set_value.assert_not_called()
+
+ def test_launcher_uses_gnome_application_view_key(self):
+ with (
+ patch.object(desktop, "merge_key"),
+ patch.object(desktop, "shortcuts"),
+ patch.object(desktop, "workspaces"),
+ patch.object(desktop, "write_key") as write,
+ ):
+ desktop.apply_settings({})
+ write.assert_any_call(
+ "org.gnome.shell.keybindings", "toggle-application-view", ["<Super>d"], {}
+ )
+
+ def test_locked_keys_are_never_written(self):
+ settings = MagicMock()
+ settings.props.settings_schema.list_keys.return_value = ["key"]
+ settings.is_writable.return_value = False
+ with patch.object(desktop, "settings_object", return_value=settings):
+ desktop.write_key("schema", "key", "value", {})
+ settings.set_value.assert_not_called()
+
+ def test_first_snapshot_is_kept_on_repeated_apply(self):
+ settings = MagicMock()
+ settings.props.settings_schema.list_keys.return_value = ["key"]
+ settings.get_user_value.return_value.print_.return_value = "'original'"
+ glib = MagicMock()
+ glib.Variant.return_value.print_.return_value = "'managed'"
+ saved = {}
+ with (
+ tempfile.TemporaryDirectory() as directory,
+ patch.object(desktop, "STATE", Path(directory) / "state.json"),
+ patch.object(desktop, "settings_object", return_value=settings),
+ patch.object(desktop.importlib, "import_module", return_value=glib),
+ ):
+ desktop.write_key("schema", "key", "managed", saved)
+ settings.get_user_value.return_value.print_.return_value = "'changed'"
+ desktop.write_key("schema", "key", "managed", saved)
+ self.assertEqual(next(iter(saved.values()))["before"], "'original'")
+
+ def test_merge_keeps_unrelated_entries(self):
+ settings = MagicMock()
+ settings.get_strv.return_value = ["company", "personal"]
+ with (
+ patch.object(desktop, "settings_object", return_value=settings),
+ patch.object(desktop, "write_key") as write,
+ ):
+ desktop.merge_key("schema", "key", ["personal", "new"], {})
+ write.assert_called_once_with(
+ "schema", "key", ["company", "personal", "new"], {}
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_canonical_profiles.py b/tests/test_canonical_profiles.py
new file mode 100644
index 0000000..e066661
--- /dev/null
+++ b/tests/test_canonical_profiles.py
@@ -0,0 +1,33 @@
+import tempfile
+import unittest
+from pathlib import Path
+
+from scripts.canonical_profiles import preferences, profiles
+
+
+class ProfileTests(unittest.TestCase):
+ def test_preferences_preserve_user_lines_and_are_idempotent(self):
+ original = 'user_pref("local.setting", true);\n'
+ owned = 'user_pref("mail.biff.show_alert", true);\n'
+ result = preferences(original, owned)
+ self.assertIn(original, result)
+ self.assertEqual(preferences(result, owned), result)
+ self.assertNotIn("show_alert", preferences(result, ""))
+
+ def test_incomplete_marker_is_not_overwritten(self):
+ with self.assertRaises(ValueError):
+ preferences("// dotfiles: begin\nlocal data", "")
+
+ def test_profile_paths_stay_inside_the_snap_directory(self):
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ (root / "work@canonical.com").mkdir()
+ (root / "profiles.ini").write_text(
+ "[Profile0]\nPath=work@canonical.com\nIsRelative=1\n"
+ "[Profile1]\nPath=/etc\nIsRelative=0\n"
+ )
+ self.assertEqual(profiles(root), [root / "work@canonical.com"])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_canonical_record.py b/tests/test_canonical_record.py
new file mode 100644
index 0000000..cfc9faf
--- /dev/null
+++ b/tests/test_canonical_record.py
@@ -0,0 +1,51 @@
+import importlib.util
+import tempfile
+import unittest
+from pathlib import Path
+from unittest.mock import patch
+
+ROOT = Path(__file__).resolve().parents[1]
+SPEC = importlib.util.spec_from_file_location(
+ "record", ROOT / "dot_local/lib/dotfiles/record.py"
+)
+assert SPEC and SPEC.loader
+record = importlib.util.module_from_spec(SPEC)
+SPEC.loader.exec_module(record)
+
+
+class RecorderTests(unittest.TestCase):
+ def test_start_is_idempotent(self):
+ with (
+ patch.object(record, "active", return_value=True),
+ patch.object(record.subprocess, "run") as run,
+ ):
+ record.start(Path("/unused"))
+ run.assert_not_called()
+
+ def test_stop_before_portal_selection_stops_only_owned_service(self):
+ with (
+ tempfile.TemporaryDirectory() as directory,
+ patch.object(record, "active", return_value=True),
+ patch.object(record.subprocess, "run") as run,
+ ):
+ record.stop(Path(directory))
+ run.assert_called_once_with(
+ ["systemctl", "--user", "stop", "dotfiles-record.service"], check=True
+ )
+
+ def test_stop_uses_recorder_ipc_when_available(self):
+ with tempfile.TemporaryDirectory() as directory:
+ runtime = Path(directory)
+ (runtime / "control.sock").touch()
+ with (
+ patch.object(record, "active", return_value=True),
+ patch.object(record.subprocess, "run") as run,
+ ):
+ record.stop(runtime)
+ command = run.call_args.args[0]
+ self.assertIn("--command=gsr-cli", command)
+ self.assertEqual(command[-1], "stop")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_recipes.py b/tests/test_recipes.py
new file mode 100644
index 0000000..772833d
--- /dev/null
+++ b/tests/test_recipes.py
@@ -0,0 +1,94 @@
+import json
+import os
+import subprocess
+import tempfile
+import unittest
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+class RecipeTests(unittest.TestCase):
+ def invoke(self, role, *recipes):
+ with tempfile.TemporaryDirectory() as directory:
+ path = Path(directory)
+ commands = {
+ "chezmoi": 'if [ "$1" = data ]; then printf \'%s\\n\' "$ROLE_DATA"; else echo "chezmoi $*"; fi',
+ "sudo": 'echo "UNEXPECTED sudo"; exit 99',
+ "flatpak": 'echo "UNEXPECTED flatpak"; exit 99',
+ "pacman": 'echo "UNEXPECTED pacman"; exit 99',
+ }
+ for name, body in commands.items():
+ executable = path / name
+ executable.write_text("#!/bin/sh\n" + body + "\n")
+ executable.chmod(0o755)
+ return subprocess.run(
+ ["just", *recipes],
+ cwd=ROOT,
+ env={
+ **os.environ,
+ "PATH": f"{path}:{os.environ['PATH']}",
+ "ROLE_DATA": json.dumps({"machineRole": role}),
+ },
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ def test_invalid_role_stops_package_commands(self):
+ for recipe in ["pkg-apply", "pkg-fix", "flatpak-update"]:
+ with self.subTest(recipe=recipe):
+ result = self.invoke("invalid", recipe)
+ self.assertNotEqual(result.returncode, 0)
+ self.assertNotIn("UNEXPECTED", result.stdout)
+
+ def test_non_host_maintenance_only_uses_chezmoi(self):
+ for role in ["vm", "canonical"]:
+ for recipe in ["diff", "merge", "re-add"]:
+ with self.subTest(role=role, recipe=recipe):
+ result = self.invoke(role, recipe)
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertIn("chezmoi", result.stdout)
+ self.assertNotIn("UNEXPECTED", result.stdout)
+
+ def test_non_host_etc_paths_fail_before_home_changes(self):
+ for recipe in ["diff", "merge", "re-add"]:
+ result = self.invoke("canonical", recipe, ".config/zsh", "etc/hosts")
+ self.assertNotEqual(result.returncode, 0)
+ self.assertNotIn("chezmoi", result.stdout)
+
+ def test_vm_migration_initializes_role_before_switch(self):
+ result = subprocess.check_output(
+ ["just", "--justfile", "nix/justfile", "--dry-run", "migrate-chezmoi"],
+ cwd=ROOT,
+ stderr=subprocess.STDOUT,
+ text=True,
+ )
+ self.assertLess(result.index("chezmoi init"), result.index("switch.sh"))
+
+ def test_host_home_paths_do_not_select_etc(self):
+ for recipe in ["diff", "merge", "re-add"]:
+ result = self.invoke("host", recipe, ".config/zsh")
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertEqual(len(result.stdout.splitlines()), 1)
+ self.assertIn(".config/zsh", result.stdout)
+
+ def test_host_mixed_paths_are_split_by_domain(self):
+ for domain, expected in [("home", ".config/zsh"), ("etc", "etc/hosts")]:
+ result = subprocess.check_output(
+ [
+ "bash",
+ "-c",
+ (
+ "source scripts/maintenance-lib.sh; "
+ "_machine_role() { echo host; }; "
+ '_maintenance_select auto "$1" .config/zsh etc/hosts; '
+ 'printf "%s\\n" "$maintenance_run" "${args[@]}"'
+ ),
+ "test",
+ domain,
+ ],
+ cwd=ROOT,
+ text=True,
+ )
+ self.assertEqual(result.splitlines(), ["true", expected])