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:
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.
|
||||
Reference in New Issue
Block a user