docs: implementation plan for the multi-role layout

Nine tasks, each with the verification that has to pass before the next one
starts. The ordering is dictated by risk rather than convenience: the archive
branch first so the 2021 state is never at stake, beastix before the two remote
machines because it is the source, and the Mac last because it is the only
platform whose PATH problem cannot be reproduced locally.

Every task verifies against an isolated ZDOTDIR before the live .zshrc is
touched, and the two remote rollouts require a second SSH session to stay open.
A broken shell config on shron means no remote login on a production mail
server, so the role file is written before the checkout, not after - otherwise
the default applies and the server comes up on the desktop profile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
thomas.kopp
2026-08-06 23:46:41 +02:00
co-authored by Claude Opus 5
parent 94b6ba3c33
commit b354c356ae
@@ -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.