diff --git a/.config/dotconfs/docs/2026-08-06-roles-plan.md b/.config/dotconfs/docs/2026-08-06-roles-plan.md new file mode 100644 index 0000000..34702d3 --- /dev/null +++ b/.config/dotconfs/docs/2026-08-06-roles-plan.md @@ -0,0 +1,521 @@ +# Multi-role dotfiles Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Rebuild `templis/dotconfs` from the live configuration of three machines so that one repository serves a desktop, a server and a Mac, with the role resolved when the shell starts. + +**Architecture:** A bare git repository in `$HOME/.cfg` with the existing `config` alias. `.zshrc` becomes a stub that sources numbered fragments from `~/.config/zsh/rc.d/`, then exactly one role file chosen by `~/.config/dotconfs/role`. Every machine checks out every file; only the sourcing differs. Package sets are plain text lists per platform and role. + +**Tech Stack:** zsh, oh-my-zsh, powerlevel10k, git (bare repo), pacman/yay, Homebrew, flatpak. + +**Design:** `.config/dotconfs/docs/2026-08-06-roles-design.md` + +--- + +## Safety rules that apply to every task + +The working shell must never break. A broken `.zshrc` on shron means no +remote login on a production mail server. + +1. **Never edit the live `~/.zshrc` directly.** Build the new file next to it, + verify it, then swap. +2. **Verification is always the same command**, run against an isolated + `ZDOTDIR` so the current shell is untouched: + + ```bash + ZDOTDIR=/tmp/zsh-verify zsh -i -c 'echo LOADED_OK' 2>&1 | tail -5 + ``` + + Expected: `LOADED_OK` with no error lines above it. +3. **Keep a rescue shell open** on shron and the Mac while working on them. + If the new config fails, the open session can still repair it. +4. `.config-backup` is never deleted during the rollout. + +--- + +## Task 1: Preserve the 2021 state + +**Files:** +- Repo: `~/work/dotconfs` + +**Step 1: Create the archive branch** + +```bash +cd ~/work/dotconfs +git branch archive/2021-i3-sway cd6dd42 +git push origin archive/2021-i3-sway +``` + +**Step 2: Verify it is reachable** + +Run: `git ls-remote --heads origin archive/2021-i3-sway` +Expected: one line containing `refs/heads/archive/2021-i3-sway` + +**Step 3: Confirm the old files are still retrievable** + +Run: `git show archive/2021-i3-sway:.config/i3/config | head -3` +Expected: the 2021 i3 config header, not an error. + +No commit needed — branch creation is the change. + +--- + +## Task 2: The `.gitignore` + +**Files:** +- Create: `~/work/dotconfs/.gitignore` + +**Step 1: Write the file** + +```gitignore +# Secrets - never in the repository +mailsecrets.py +.netrc +.pgpass +*.key +*.pem +id_* +!id_*.pub + +# Machine-local - must differ per machine +.config/dotconfs/role +.config/zsh/rc.d/90-local.zsh + +# Runtime state +.zsh_history +.zcompdump* +.cache/ +.config-backup/ +``` + +**Step 2: Verify the ignore rules actually match** + +```bash +cd ~/work/dotconfs +git check-ignore -v .config/dotconfs/role mailsecrets.py .zsh_history +``` + +Expected: three lines, each naming `.gitignore` and the matching pattern. +A silent exit means a rule does not match — fix it before continuing. + +**Step 3: Verify public keys are still allowed** + +Run: `git check-ignore -v id_ed25519.pub; echo "exit=$?"` +Expected: `exit=1` (not ignored). If it exits 0 the negation is wrong. + +**Step 4: Commit** + +```bash +git add .gitignore +git commit -m "chore: ignore secrets, machine-local files and runtime state" +``` + +--- + +## Task 3: Split `.zshrc` into stub and fragments + +The live file is 240 lines: 16 exports, 14 functions, 9 aliases, 2 sources, +1 PATH line, the rest oh-my-zsh boilerplate and function bodies. + +**Files:** +- Create: `~/work/dotconfs/.zshrc` +- Create: `~/work/dotconfs/.config/zsh/rc.d/10-aliases.zsh` +- Create: `~/work/dotconfs/.config/zsh/rc.d/20-functions.zsh` +- Create: `~/work/dotconfs/.config/zsh/rc.d/30-path.zsh` +- Create: `~/work/dotconfs/.config/zsh/rc.d/50-desktop.zsh` +- Source: `~/.zshrc` (read only, do not modify yet) + +**Step 1: Classify every line of the live file** + +```bash +cp ~/.zshrc ~/work/dotconfs/.zshrc.source +``` + +Sorting rule — when in doubt, put it in base: + +| Goes to | What | +|---|---| +| `.zshrc` stub | oh-my-zsh bootstrap, `plugins=`, `source $ZSH/oh-my-zsh.sh`, the rc.d loop | +| `10-aliases.zsh` | all 9 `alias` lines | +| `20-functions.zsh` | all 14 functions **except** the docker ones | +| `30-path.zsh` | the `PATH` line, **without** `/home/templis/.cargo/bin` | +| `50-desktop.zsh` | p10k instant prompt (lines 4-5), `ZSH_THEME="powerlevel10k/powerlevel10k"`, `QT_QPA_PLATFORM`, `XDG_SESSION_TYPE` block, `poe`/`poe2` Steam paths, cargo in PATH, the docker functions | + +Hardcoded `/home/templis` paths become `$HOME` while moving. + +**Step 2: Write the stub** + +```sh +# ~/.zshrc - stub only. Everything real lives in ~/.config/zsh/rc.d/. +# The role decides which 50-*.zsh is sourced; see .config/dotconfs/role. + +# oh-my-zsh must be initialised before the fragments so they can rely on it. +export ZSH="$HOME/.oh-my-zsh" +plugins=(git fzf) +source "$ZSH/oh-my-zsh.sh" + +for f in "$HOME"/.config/zsh/rc.d/[0-4]*.zsh(N); do + source "$f" +done + +# Default to desktop so a machine without a role file still gets a usable +# shell. Servers must have the role file in place before first login. +ROLE=$(cat "$HOME/.config/dotconfs/role" 2>/dev/null || echo desktop) +[ -r "$HOME/.config/zsh/rc.d/50-$ROLE.zsh" ] && source "$HOME/.config/zsh/rc.d/50-$ROLE.zsh" + +# Last on purpose: machine-local overrides win without touching the repository. +[ -r "$HOME/.config/zsh/rc.d/90-local.zsh" ] && source "$HOME/.config/zsh/rc.d/90-local.zsh" +``` + +The `(N)` glob qualifier makes an empty `rc.d` a no-op instead of an error. + +**Step 3: Verify the new config loads in isolation** + +```bash +rm -rf /tmp/zsh-verify && mkdir -p /tmp/zsh-verify +cp ~/work/dotconfs/.zshrc /tmp/zsh-verify/.zshrc +mkdir -p /tmp/zsh-verify/.config/zsh +cp -r ~/work/dotconfs/.config/zsh/rc.d /tmp/zsh-verify/.config/zsh/ +echo desktop > /tmp/zsh-verify/role +HOME=/tmp/zsh-verify ZDOTDIR=/tmp/zsh-verify zsh -i -c 'echo LOADED_OK' 2>&1 | tail -10 +``` + +Expected: `LOADED_OK`, no `command not found`, no `parse error`. +If oh-my-zsh is missing under the fake HOME, symlink it in rather than +skipping this check. + +**Step 4: Verify nothing was lost** + +```bash +cat ~/work/dotconfs/.config/zsh/rc.d/*.zsh ~/work/dotconfs/.zshrc \ + | grep -cE '^\s*(alias|export|function|[a-zA-Z_][a-zA-Z0-9_-]*\s*\(\))' +``` + +Expected: at least 39 (16 exports + 14 functions + 9 aliases). A lower number +means lines were dropped in the split. + +**Step 5: Commit** + +```bash +rm ~/work/dotconfs/.zshrc.source +git add .zshrc .config/zsh/rc.d/ +git commit -m "feat: split zshrc into a stub and role-aware fragments" +``` + +--- + +## Task 4: Role-coloured prompt + +**Files:** +- Modify: `.config/zsh/rc.d/50-desktop.zsh` +- Create: `.config/zsh/rc.d/50-server.zsh` +- Create: `.config/zsh/rc.d/50-mobile.zsh` + +**Step 1: Add the colour to each role file** + +The segment stays identical everywhere; only the background changes. Append +to each file: + +```sh +# Role colour. Red on the server is a guard, not decoration: shron carries +# mail, web and cloud, and a glance at the prompt should be enough to know +# which machine a command is about to hit. +typeset -g POWERLEVEL9K_CONTEXT_BACKGROUND=blue # desktop +``` + +`50-server.zsh` uses `red`, `50-mobile.zsh` uses `green`. + +**Step 2: Verify each role produces a different value** + +```bash +for r in desktop server mobile; do + echo -n "$r: " + grep -h POWERLEVEL9K_CONTEXT_BACKGROUND ~/work/dotconfs/.config/zsh/rc.d/50-$r.zsh +done +``` + +Expected: three lines, three different colours. + +**Step 3: Verify the server role loads without powerlevel10k present** + +shron has oh-my-zsh but no p10k. Setting a P9K variable when p10k is absent +must not error: + +```bash +HOME=/tmp/zsh-verify ZDOTDIR=/tmp/zsh-verify \ + zsh -c 'typeset -g POWERLEVEL9K_CONTEXT_BACKGROUND=red; echo OK' +``` + +Expected: `OK`. If p10k turns out to be required, Task 8 installs it on shron +before the role file is deployed. + +**Step 4: Commit** + +```bash +git add .config/zsh/rc.d/50-*.zsh +git commit -m "feat: colour the prompt by role, red for the server" +``` + +--- + +## Task 5: Package lists and tooling + +**Files:** +- Create: `packages/arch-base.txt`, `packages/arch-desktop.txt`, `packages/arch-server.txt` +- Create: `packages/brew-mobile.txt`, `packages/brew-cask-mobile.txt`, `packages/flatpak-desktop.txt` +- Create: `.scripts/curate.sh`, `.scripts/packages.sh` + +**Step 1: Generate the candidate lists** + +`curate.sh` already exists in draft at +`/home/templis/.claude/jobs/e44cbeae/tmp/curate.sh`. Move it into `.scripts/`, +then produce the lists: + +```bash +.scripts/curate.sh > /tmp/candidates.txt +``` + +The 62 candidates split by hand into `arch-base.txt` (present on both Arch +machines and platform-neutral) and `arch-desktop.txt` (GUI, games, desktop +tooling). `arch-server.txt` comes from shron's own 106. + +Known gaps to fix by hand: `discord` and `nextcloud-client` are in autostart +but were missed because the process name and the binary name differ in case. + +**Step 2: Write `packages.sh`** + +```sh +#!/bin/sh +# Compare the installed set against the lists for this machine's role. +# +# Never removes anything. Individual packages on the server carry mail and web +# services, and a tool that tidies up on its own is a tool that eventually +# takes the mail server down. Extra packages are reported, not touched. +set -eu + +ROLE=$(cat "$HOME/.config/dotconfs/role" 2>/dev/null || echo desktop) +DIR="$(cd "$(dirname "$0")/../packages" && pwd)" + +case "$(uname -s)" in + Linux) WANT=$(cat "$DIR/arch-base.txt" "$DIR/arch-$ROLE.txt" 2>/dev/null | sort -u) + HAVE=$(pacman -Qqe | sort -u) ;; + Darwin) WANT=$(cat "$DIR/brew-$ROLE.txt" 2>/dev/null | sort -u) + HAVE=$(brew leaves | sort -u) ;; + *) echo "unsupported platform" >&2; exit 1 ;; +esac + +MISSING=$(comm -23 <(echo "$WANT") <(echo "$HAVE")) +EXTRA=$(comm -13 <(echo "$WANT") <(echo "$HAVE")) + +case "${1:-check}" in + check) + echo "missing (${$(echo "$MISSING" | grep -c .)}):"; echo "$MISSING" | sed 's/^/ /' + echo "extra (${$(echo "$EXTRA" | grep -c .)}):"; echo "$EXTRA" | sed 's/^/ /' + ;; + install) + [ -z "$MISSING" ] && { echo "nothing to install"; exit 0; } + case "$(uname -s)" in + Linux) yay -S --needed $MISSING ;; + Darwin) brew install $MISSING ;; + esac + ;; + *) echo "usage: packages.sh [check|install]" >&2; exit 1 ;; +esac +``` + +**Step 3: Verify `check` reports nothing missing on beastix** + +Run: `.scripts/packages.sh check` +Expected: `missing (0)`. The lists were derived from this machine, so anything +missing means a name was mistyped. + +**Step 4: Verify `install` is a no-op when nothing is missing** + +Run: `.scripts/packages.sh install` +Expected: `nothing to install`, no package manager invoked. + +**Step 5: Commit** + +```bash +chmod +x .scripts/packages.sh .scripts/curate.sh +git add packages/ .scripts/ +git commit -m "feat: package lists per role with a check-and-install script" +``` + +--- + +## Task 6: Activate on beastix + +This is the first task that touches the live machine. + +**Step 1: Create the bare repository** + +```bash +git init --bare "$HOME/.cfg" +alias config='/usr/bin/git --git-dir=$HOME/.cfg/ --work-tree=$HOME' +config config --local status.showUntrackedFiles no +config remote add origin ssh://git@git.opennerds.org:9922/templis/dotconfs.git +``` + +**Step 2: Write the role file before anything else** + +```bash +mkdir -p ~/.config/dotconfs +echo desktop > ~/.config/dotconfs/role +``` + +**Step 3: Back up the live zshrc** + +```bash +cp ~/.zshrc ~/.zshrc.pre-dotconfs +``` + +**Step 4: Fetch and check out, keeping conflicts** + +```bash +config fetch origin master +config checkout master 2>&1 | tee /tmp/checkout.log +``` + +If it refuses because of existing files, move each named file to +`~/.config-backup/` and retry. Do not use `-f`. + +**Step 5: Verify in a new shell, not the current one** + +```bash +zsh -i -c 'echo LOADED_OK; alias | wc -l; typeset -f | grep -c "^[a-z]"' +``` + +Expected: `LOADED_OK`, at least 9 aliases, at least 14 functions. + +**Step 6: Verify the role resolved** + +Run: `zsh -i -c 'echo $ROLE'` +Expected: `desktop` + +**Step 7: Commit and push** + +```bash +config add .zshrc .config/zsh/rc.d .gitignore packages .scripts +config commit -m "feat: adopt the role-aware layout on beastix" +config push -u origin master +``` + +--- + +## Task 7: Roll out to shron + +**Keep a second SSH session open throughout this task.** + +**Step 1: Open the rescue session** + +```bash +ssh shron +``` + +Leave it open and untouched. Do all work in a second connection. + +**Step 2: Install the bare repo and set the role first** + +```bash +ssh shron ' + mkdir -p ~/.config/dotconfs && echo server > ~/.config/dotconfs/role + git init --bare $HOME/.cfg + git --git-dir=$HOME/.cfg --work-tree=$HOME config --local status.showUntrackedFiles no + git --git-dir=$HOME/.cfg --work-tree=$HOME remote add origin ssh://git@git.opennerds.org:9922/templis/dotconfs.git +' +``` + +The role file comes first. Without it the default applies and a production +mail server comes up with a desktop prompt. + +**Step 3: Back up and check out** + +```bash +ssh shron ' + cp ~/.zshrc ~/.zshrc.pre-dotconfs + mkdir -p ~/.config-backup + git --git-dir=$HOME/.cfg --work-tree=$HOME fetch origin master + git --git-dir=$HOME/.cfg --work-tree=$HOME checkout master +' +``` + +**Step 4: Verify before trusting it** + +```bash +ssh shron 'zsh -i -c "echo LOADED_OK; echo role=\$ROLE"' +``` + +Expected: `LOADED_OK` and `role=server`. +If this fails, the rescue session restores `~/.zshrc.pre-dotconfs`. + +**Step 5: Review shron's 57 unique lines** + +Diff `~/.zshrc.pre-dotconfs` against the base fragments. Anything still needed +goes into `50-server.zsh`; the rest is dropped. Commit that from beastix, then +pull on shron. + +**Step 6: Verify a fresh login works** + +Open a genuinely new SSH connection — not `zsh -i -c` — and confirm the prompt +is red and the shell is usable. Only then close the rescue session. + +--- + +## Task 8: Roll out to the Mac + +Same sequence as Task 7 with `echo mobile > ~/.config/dotconfs/role`. + +Two differences: + +- Homebrew lives at `/opt/homebrew` (Apple Silicon). `30-path.zsh` must not + hardcode `/usr/local`. +- `.zprofile` does not exist on the Mac. Homebrew's `shellenv` belongs there, + not in `.zshrc`, or non-login shells lose the PATH — the same reason + `brew --version` appeared to be missing over SSH during the design phase. + +**Verification:** + +```bash +ssh thomas.kopp@192.168.100.142 'zsh -i -c "echo LOADED_OK; echo role=\$ROLE; which brew"' +``` + +Expected: `LOADED_OK`, `role=mobile`, `/opt/homebrew/bin/brew`. + +--- + +## Task 9: Update the README + +**Files:** +- Modify: `README.md` + +The current README describes the 2021 bare-repo technique and links a +`curl -Lks` installer. Two changes: + +1. Document the roles, the `rc.d` layout and `packages.sh`. +2. Drop `-k` from the installer command. It disables certificate verification + against a host that has a valid certificate, removing exactly the protection + that matters when piping a remote script into a shell. + +**Verify** the installer still resolves: + +```bash +curl -sL https://ls.shron.de/dotconf | head -3 +``` + +Expected: the `git clone --bare` line pointing at `git.opennerds.org`. + +**Commit:** + +```bash +git add README.md +git commit -m "docs: describe the role layout and stop skipping TLS verification" +``` + +--- + +## Out of scope + +- Migration to NixOS. +- macOS `defaults` (Finder, keyboard repeat). +- Removing packages on any machine.