docs: implementation plan for mac setup

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mortdecai
2026-09-15 19:06:28 -04:00
parent f99f1e2f88
commit f672ef1ebd
+597
View File
@@ -0,0 +1,597 @@
# Mac Setup Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Turn the stock MacBook Pro into a quiet, bash/GNU-feeling, Ableton-first machine via one idempotent script run over SSH, with nightly rsync backup to tank.
**Architecture:** Repo `~/bin/mac` on steel141 is rsync'd to `~/mac` on the Mac by `scripts/run.sh`, which then runs `scripts/setup.sh` there as seth over `ssh -tt`, priming `sudo` from `$HOMELAB_PASSWORD` on stdin (verified 2026-09-15: a forced pty keys the sudo ticket on the tty, so `setup.sh` and Homebrew's internal `sudo` calls reuse it; without a pty the ticket is keyed on parent pid and children re-prompt). `setup.sh` is a list of sections that each check state, print `[skip]` or make the change, and back up any `defaults` domain before writing it. Dotfiles and kitty config are *included* from `~/mac/config/` rather than copied, so editing the repo edits the machine. `scripts/tank-side.sh` runs on pve173 to create the backup dataset, add it to sanoid, and install the Mac's rsync-only key.
**Tech Stack:** bash 5 (Homebrew), Homebrew + `Brewfile`, `defaults`, `pmset`, `scutil`, `networksetup`, launchd user agent, rsync 3.x (Homebrew on Mac; `rrsync` on pve173), sanoid.
**Spec:** `docs/plans/2026-09-15-mac-setup-design.md`
## Global Constraints
- Target: MacBookPro18,1, arm64, macOS 26.2 Tahoe. Refuse to run elsewhere.
- Hostname: `mac`. Mac LAN IP 192.168.0.94, user `seth`, uid 501. Alias `ssh mac` exists on steel141.
- Tank host pve173 = 192.168.0.173 (never .200). Backup dataset `tank/backups/mac`, sanoid template `tank_media`.
- SIP, Gatekeeper, FileVault stay on. Nothing under `/System/Applications` is removed. No Cmd/Ctrl swap.
- Every `defaults` domain is exported to `~/.mac-setup-backup/` before first write; `run.sh` pulls that dir back to `~/bin/mac/.backup/mac/` (gitignored).
- Every section is idempotent: second run prints only `[skip]` lines and exits 0.
- Homebrew prefix `/opt/homebrew`. GNU tools unprefixed via gnubin on PATH.
- Conventional commits, push after every commit (`gitea push`).
- Deviations from spec, decided while planning: (a) `displaysleep` on AC = 30 min not 0 — display sleep does not stop Live's audio, system sleep does; (b) no `launchctl disable` of Apple agents (incl. `photoanalysisd`, which idles with no Photos library) — they idle when their GUI feature is off and macOS re-enables them on update; the GUI toggles are in the manual checklist instead.
- Cask names `tailscale-app` and `font-jetbrains-mono` are from memory; if `brew bundle` rejects one, `brew search <name>` on the Mac and fix the Brewfile — don't skip the package.
---
### Task 1: Brewfile + config files
**Files:**
- Create: `Brewfile`
- Create: `config/bashrc`
- Create: `config/kitty.conf`
- Create: `config/ssh_homelab`
- Modify: `.gitignore` (add `Brewfile.lock.json`)
**Interfaces:**
- Produces: `Brewfile` consumed by `brew bundle --file=~/mac/Brewfile` in Task 2; `config/*` included by the marker blocks Task 2 writes (`~/.bash_profile` sources `~/mac/config/bashrc`; `~/.config/kitty/kitty.conf` includes `~/mac/config/kitty.conf`; `~/.ssh/config` includes `~/mac/config/ssh_homelab`).
- [ ] **Step 1: Write `Brewfile`**
```ruby
# Applied by scripts/setup.sh via `brew bundle`. Ableton Live is NOT here — licensed download.
brew "bash"
brew "coreutils"
brew "findutils"
brew "gnu-sed"
brew "grep"
brew "gawk"
brew "gnu-tar"
brew "iproute2mac" # gives `ip a`
brew "rsync" # macOS ships openrsync; backup.sh needs real rsync
brew "git"
brew "tmux"
brew "htop"
brew "ripgrep"
brew "fd"
brew "jq"
brew "wget"
brew "tree"
cask "kitty"
cask "rectangle"
cask "tailscale-app"
cask "font-jetbrains-mono"
```
- [ ] **Step 2: Write `config/bashrc`**
```bash
# ~/mac/config/bashrc — sourced by ~/.bash_profile (marker block written by setup.sh)
eval "$(/opt/homebrew/bin/brew shellenv)"
for d in coreutils findutils gnu-sed grep gawk gnu-tar; do
PATH="/opt/homebrew/opt/$d/libexec/gnubin:$PATH"
done
export PATH
export EDITOR=vim
export CLICOLOR=1
alias ls='ls --color=auto'
alias ll='ls -lah'
alias grep='grep --color=auto'
alias opus='claude --dangerously-skip-permissions'
HISTSIZE=50000; HISTFILESIZE=100000; shopt -s histappend
PS1='\[\e[1;33m\]\u@\h\[\e[0m\]:\[\e[1;34m\]\w\[\e[0m\]\$ '
```
- [ ] **Step 3: Write `config/kitty.conf`**
Copy `/home/claude/bin/kitty-web/config/kitty.conf` verbatim (76 lines: JetBrains Mono 16, #0a0a0a/#D35400 theme, powerline tabs), then append:
```
# --- macOS ---
macos_option_as_alt yes
hide_window_decorations titlebar-only
macos_quit_when_last_window_closed yes
```
Command: `cp /home/claude/bin/kitty-web/config/kitty.conf config/kitty.conf && printf '\n# --- macOS ---\nmacos_option_as_alt yes\nhide_window_decorations titlebar-only\nmacos_quit_when_last_window_closed yes\n' >> config/kitty.conf`
(`hide_window_decorations yes` from the Linux file is overridden by the later `titlebar-only` line — last write wins in kitty.)
- [ ] **Step 4: Write `config/ssh_homelab`**
```
# Included from ~/.ssh/config. Key ~/.ssh/id_ed25519 is generated by setup.sh;
# only pve173 is authorized so far (rrsync-restricted, for backup.sh).
Host pve173
HostName 192.168.0.173
User root
Host pve112
HostName 192.168.0.112
User root
Host pve197
HostName 192.168.0.197
User root
Host pve241
HostName 192.168.0.241
User root
Host steel141
HostName 192.168.0.141
User seth
Host bedroom
HostName 192.168.0.235
User seth
Host *
IdentityFile ~/.ssh/id_ed25519
ServerAliveInterval 30
```
- [ ] **Step 5: Ignore the Brewfile lock**
`echo 'Brewfile.lock.json' >> .gitignore`
- [ ] **Step 6: Syntax check + commit**
Run: `bash -n config/bashrc && echo OK`
Expected: `OK`
```bash
git add Brewfile config .gitignore
git commit -m "feat: Brewfile and included config files (bashrc, kitty, ssh homelab aliases)"
gitea push
```
---
### Task 2: `scripts/setup.sh` — helpers, preflight, brew, shell, hostname, dotfile includes
**Files:**
- Create: `scripts/setup.sh`
**Interfaces:**
- Consumes: `~/mac/Brewfile`, `~/mac/config/{bashrc,kitty.conf,ssh_homelab}` (Task 1).
- Produces: helper functions `log`, `setd`, `backup_domain`, `install_marker`, `CHANGED` used by Task 3 sections appended to this same file. `BK=~/.mac-setup-backup`, `TS` epoch. Generates `~/.ssh/id_ed25519` and prints the pubkey (consumed by Task 4 `tank-side.sh`).
- [ ] **Step 1: Write the file**
```bash
#!/bin/bash
# setup.sh — run ON the Mac as seth, via scripts/run.sh from steel141.
# Idempotent: every section prints [skip] when already in the desired state.
set -euo pipefail
REPO="$HOME/mac"
BK="$HOME/.mac-setup-backup"; TS=$(date +%s); mkdir -p "$BK"
CHANGED=0
log(){ printf '\033[1;33m[%s]\033[0m %s\n' "$1" "$2"; }
# ---------- preflight ----------
[[ $(uname -m) == arm64 && $(sw_vers -productVersion) == 26.* ]] || { echo "not the Mac this was written for"; exit 1; }
[[ ${HOSTNAME_WANT:-} ]] || { echo "HOSTNAME_WANT=<name> required"; exit 1; }
sudo -v # ticket already primed by run.sh over the pty; prompts if run by hand
( while true; do sudo -n true; sleep 50; done ) &
KEEPALIVE=$!; trap 'kill $KEEPALIVE 2>/dev/null' EXIT
# ---------- helpers ----------
backup_domain(){ # once per domain per run
local d=$1 f="$BK/${d//\//_}-$TS.plist"
[[ -e $f || -e $f.absent ]] && return 0
defaults export "$d" "$f" 2>/dev/null || : > "$f.absent"
}
setd(){ # setd <domain> <key> <bool|int|string> <value>
local d=$1 k=$2 t=$3 v=$4 cur want=$4
[[ $t == bool ]] && { [[ $v == true ]] && want=1 || want=0; }
cur=$(defaults read "$d" "$k" 2>/dev/null || echo __unset__)
if [[ $cur == "$want" ]]; then log skip "$d $k=$v"; return 0; fi
backup_domain "$d"; defaults write "$d" "$k" "-$t" "$v"; log set "$d $k=$v"; CHANGED=1
}
install_marker(){ # install_marker <file> <line> — ensure a line exists in file (create if absent)
local f=$1 line=$2
if [[ -f $f ]] && grep -qxF "$line" "$f"; then log skip "$f has include"; return 0; fi
[[ -f $f ]] && cp "$f" "$BK/$(basename "$f")-$TS"
mkdir -p "$(dirname "$f")"; printf '%s\n' "$line" >> "$f"; log set "$f += $line"; CHANGED=1
}
# ---------- brew ----------
if [[ -x /opt/homebrew/bin/brew ]]; then log skip "homebrew installed"; else
log set "installing homebrew (installs Xcode CLT headless first; 5-15 min)"
NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
CHANGED=1
fi
eval "$(/opt/homebrew/bin/brew shellenv)"
if brew bundle check --file="$REPO/Brewfile" >/dev/null 2>&1; then log skip "brew bundle satisfied"; else
log set "brew bundle"; brew bundle --file="$REPO/Brewfile"; CHANGED=1
fi
# ---------- shell ----------
grep -qx /opt/homebrew/bin/bash /etc/shells || { echo /opt/homebrew/bin/bash | sudo tee -a /etc/shells >/dev/null; log set "/etc/shells += homebrew bash"; }
if [[ $(dscl . -read "/Users/$USER" UserShell | awk '{print $2}') == /opt/homebrew/bin/bash ]]; then log skip "login shell bash5"; else
sudo chsh -s /opt/homebrew/bin/bash "$USER"; log set "login shell -> homebrew bash"; CHANGED=1
fi
install_marker "$HOME/.bash_profile" '[ -f ~/mac/config/bashrc ] && . ~/mac/config/bashrc # mac-setup'
install_marker "$HOME/.config/kitty/kitty.conf" "include $HOME/mac/config/kitty.conf"
# ---------- hostname ----------
for k in ComputerName LocalHostName HostName; do
if [[ $(scutil --get $k 2>/dev/null || true) == "$HOSTNAME_WANT" ]]; then log skip "$k=$HOSTNAME_WANT"; else
sudo scutil --set $k "$HOSTNAME_WANT"; log set "$k=$HOSTNAME_WANT"; CHANGED=1
fi
done
# ---------- ssh (homelab) ----------
mkdir -p "$HOME/.ssh"; chmod 700 "$HOME/.ssh"
[[ -f $HOME/.ssh/id_ed25519 ]] && log skip "ssh key exists" || { ssh-keygen -t ed25519 -N '' -C "seth@$HOSTNAME_WANT" -f "$HOME/.ssh/id_ed25519" >/dev/null; log set "generated ~/.ssh/id_ed25519"; CHANGED=1; }
if [[ -f $HOME/.ssh/config ]] && grep -q '^Include ~/mac/config/ssh_homelab' "$HOME/.ssh/config"; then log skip "ssh config include"; else
[[ -f $HOME/.ssh/config ]] && cp "$HOME/.ssh/config" "$BK/ssh_config-$TS"
{ echo 'Include ~/mac/config/ssh_homelab'; [[ -f $HOME/.ssh/config ]] && cat "$HOME/.ssh/config"; } > "$HOME/.ssh/config.new"
mv "$HOME/.ssh/config.new" "$HOME/.ssh/config"; chmod 600 "$HOME/.ssh/config"; log set "ssh config include"; CHANGED=1
fi
ssh-keygen -F 192.168.0.173 >/dev/null || { ssh-keyscan -t ed25519 192.168.0.173 >> "$HOME/.ssh/known_hosts" 2>/dev/null; log set "known_hosts += pve173"; }
# (Task 3 sections go here)
# ---------- done ----------
echo; log pubkey "$(cat "$HOME/.ssh/id_ed25519.pub")"
[[ $CHANGED == 1 ]] && log note "some changes (key repeat, scroll direction) apply fully after logout/login"
exit 0
```
`Include` must be the first line of `~/.ssh/config` (OpenSSH applies `Host *` blocks above it otherwise) — that's why the block rewrites the file with the include on top instead of appending.
- [ ] **Step 2: Syntax + lint**
Run: `bash -n scripts/setup.sh && shellcheck -s bash scripts/setup.sh; echo rc=$?`
Expected: no errors; warnings about `$USER`/`$HOME` are acceptable. `rc=0`.
- [ ] **Step 3: Commit**
```bash
chmod +x scripts/setup.sh
git add scripts/setup.sh
git commit -m "feat: setup.sh — preflight, homebrew, bash5 shell, hostname, dotfile includes"
gitea push
```
---
### Task 3: `scripts/setup.sh` — defaults (Linux feel, debloat, Dock), SparkFun, power, apply
**Files:**
- Modify: `scripts/setup.sh` — replace the line `# (Task 3 sections go here)` with the block below.
**Interfaces:**
- Consumes: `log`, `setd`, `backup_domain`, `CHANGED`, `BK`, `TS` from Task 2.
- Produces: nothing new for other tasks.
- [ ] **Step 1: Insert the sections**
```bash
# ---------- defaults: Linux feel ----------
G=NSGlobalDomain
setd $G KeyRepeat int 2
setd $G InitialKeyRepeat int 15
setd $G ApplePressAndHoldEnabled bool false # hold-key repeats instead of accent popup
setd $G com.apple.swipescrolldirection bool false # "natural" scrolling off
setd $G NSAutomaticWindowAnimationsEnabled bool false
setd $G AppleShowAllExtensions bool true
F=com.apple.finder
setd $F ShowPathbar bool true
setd $F ShowStatusBar bool true
setd $F _FXShowPosixPathInTitle bool true
setd $F FXPreferredViewStyle string Nlsv # list view
setd $F NewWindowTarget string PfHm # new windows open at ~
setd $F NewWindowTargetPath string "file://$HOME/"
setd com.apple.desktopservices DSDontWriteNetworkStores bool true
setd com.apple.desktopservices DSDontWriteUSBStores bool true
# ---------- defaults: debloat / Dock ----------
D=com.apple.dock
setd $D autohide bool true
setd $D autohide-delay int 0
setd $D show-recents bool false
setd $D tilesize int 48
for c in tl tr bl br; do setd $D wvous-$c-corner int 1; done # 1 = no action (stock br = Quick Note)
# Dock apps: only what exists. Finder + Trash are implicit.
dock_want=()
for app in /Applications/kitty.app "/Applications/Ableton Live 12"*.app "/System/Applications/System Settings.app"; do
[[ -d $app ]] && dock_want+=("file://${app// /%20}/")
done
dock_cur=$(defaults read $D persistent-apps 2>/dev/null | grep -oE '_CFURLString" = "[^"]+' | sed 's/.*= "//' | tr '\n' ' ' || true)
if [[ "$dock_cur" == "${dock_want[*]} " ]]; then log skip "dock apps"; else
backup_domain $D
defaults write $D persistent-apps -array
for u in "${dock_want[@]}"; do
defaults write $D persistent-apps -array-add "<dict><key>tile-data</key><dict><key>file-data</key><dict><key>_CFURLString</key><string>$u</string><key>_CFURLStringType</key><integer>15</integer></dict></dict><key>tile-type</key><string>file-tile</string></dict>"
done
log set "dock apps = ${dock_want[*]}"; CHANGED=1
fi
# ---------- stray network service ----------
if networksetup -listallnetworkservices | grep -qx 'SparkFun Pro Micro'; then
sudo networksetup -removenetworkservice 'SparkFun Pro Micro'; log set "removed SparkFun Pro Micro PPP service"; CHANGED=1
else log skip "no SparkFun service"; fi
# ---------- power (DAW) — AC profile only ----------
pm_cur=$(pmset -g custom | awk '/AC Power/{f=1;next} /Battery Power/{f=0} f && $1 ~ /^(sleep|displaysleep|disksleep|powernap)$/{printf "%s=%s ", $1, $2}')
if [[ $pm_cur == *"sleep=0 "* && $pm_cur == *"displaysleep=30 "* && $pm_cur == *"disksleep=0 "* && $pm_cur == *"powernap=0 "* ]]; then log skip "pmset AC profile"; else
pmset -g custom > "$BK/pmset-$TS.txt"
sudo pmset -c sleep 0 displaysleep 30 disksleep 0 powernap 0; log set "pmset -c sleep 0 displaysleep 30 disksleep 0 powernap 0"; CHANGED=1
fi
setd com.ableton.live NSAppSleepDisabled bool true # App Nap off for Live (domain exists before install; harmless)
# ---------- apply ----------
[[ $CHANGED == 1 ]] && { killall Dock Finder SystemUIServer 2>/dev/null || true; log set "restarted Dock/Finder"; }
```
- [ ] **Step 2: Syntax + lint**
Run: `bash -n scripts/setup.sh && shellcheck -s bash scripts/setup.sh; echo rc=$?`
Expected: `rc=0` (SC2086 on `$G/$F/$D` unquoted is fine — they hold no spaces; add `# shellcheck disable=SC2086` at the top of the defaults block if it's noisy).
- [ ] **Step 3: Dry-check the pmset parser locally against the inventory**
Run on steel141:
```bash
printf 'Battery Power:\n sleep 1\nAC Power:\n sleep 1\n displaysleep 10\n disksleep 10\n powernap 1\n' | awk '/AC Power/{f=1;next} /Battery Power/{f=0} f && $1 ~ /^(sleep|displaysleep|disksleep|powernap)$/{printf "%s=%s ", $1, $2}'
```
Expected: `sleep=1 displaysleep=10 disksleep=10 powernap=1 ` (so the mismatch branch fires on first run).
- [ ] **Step 4: Commit**
```bash
git add scripts/setup.sh
git commit -m "feat: setup.sh — Linux-feel defaults, Dock/debloat, SparkFun removal, DAW power profile"
gitea push
```
---
### Task 4: Backup — `scripts/backup.sh`, launchd agent, `scripts/tank-side.sh`
**Files:**
- Create: `scripts/backup.sh`
- Create: `config/xyz.sethpc.mac-backup.plist`
- Create: `scripts/tank-side.sh`
- Modify: `scripts/setup.sh` — insert a `# ---------- backup agent ----------` section just before `# ---------- apply ----------`.
**Interfaces:**
- Consumes: `install_marker`-style pattern; `/opt/homebrew/bin/rsync` (Task 1 Brewfile); Mac pubkey printed by Task 2.
- Produces: `tank-side.sh <pubkey-line>` run on pve173 by `run.sh` (Task 5).
- [ ] **Step 1: Write `scripts/backup.sh`**
```bash
#!/opt/homebrew/bin/bash
# Nightly by launchd (config/xyz.sethpc.mac-backup.plist). Mirrors the DAW-relevant
# dirs to tank. History comes from sanoid snapshots of tank/backups/mac on pve173,
# which is what makes `--delete` safe.
set -euo pipefail
src=()
for d in "$HOME/Music/Ableton" "$HOME/Documents"; do [[ -d $d ]] && src+=("$d"); done
[[ ${#src[@]} -gt 0 ]] || { echo "nothing to back up yet"; exit 0; }
# Remote path is relative to the rrsync root (/tank/backups/mac) set in root's authorized_keys on pve173.
exec /opt/homebrew/bin/rsync -a --delete -e 'ssh -o BatchMode=yes' "${src[@]}" root@192.168.0.173:/
```
- [ ] **Step 2: Write `config/xyz.sethpc.mac-backup.plist`**
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>Label</key><string>xyz.sethpc.mac-backup</string>
<key>ProgramArguments</key><array><string>/Users/seth/mac/scripts/backup.sh</string></array>
<key>StartCalendarInterval</key><dict><key>Hour</key><integer>3</integer><key>Minute</key><integer>30</integer></dict>
<key>StandardOutPath</key><string>/Users/seth/Library/Logs/mac-backup.log</string>
<key>StandardErrorPath</key><string>/Users/seth/Library/Logs/mac-backup.log</string>
</dict></plist>
```
- [ ] **Step 3: Write `scripts/tank-side.sh`** (runs on pve173 as root; arg = the Mac's pubkey line)
```bash
#!/bin/bash
# Run on pve173: ssh pve173 'bash -s' -- "<pubkey>" < scripts/tank-side.sh
set -euo pipefail
PUB=${1:?pubkey line required}
[[ $PUB == ssh-ed25519* ]] || { echo "not a pubkey: $PUB"; exit 1; }
DS=tank/backups/mac; TS=$(date +%s)
zfs list "$DS" >/dev/null 2>&1 && echo "[skip] dataset $DS" || { zfs create "$DS"; echo "[set] created $DS"; }
if grep -q "^\[$DS\]" /etc/sanoid/sanoid.conf; then echo "[skip] sanoid stanza"; else
mkdir -p /etc/sanoid/.backup; cp /etc/sanoid/sanoid.conf "/etc/sanoid/.backup/sanoid.conf-$TS"
printf '\n[%s]\n\tuse_template = tank_media\n' "$DS" >> /etc/sanoid/sanoid.conf; echo "[set] sanoid stanza"
fi
KEYLINE="restrict,command=\"/usr/bin/rrsync /$DS\" $PUB"
if grep -qF "$PUB" /root/.ssh/authorized_keys; then echo "[skip] key present"; else
cp /root/.ssh/authorized_keys "/root/.ssh/authorized_keys.bak-$TS"
echo "$KEYLINE" >> /root/.ssh/authorized_keys; echo "[set] rrsync-restricted key added"
fi
```
- [ ] **Step 4: Add the launchd section to `setup.sh`** (before `# ---------- apply ----------`)
```bash
# ---------- backup agent ----------
PL="$HOME/Library/LaunchAgents/xyz.sethpc.mac-backup.plist"
if [[ -f $PL ]] && cmp -s "$REPO/config/xyz.sethpc.mac-backup.plist" "$PL"; then log skip "backup launchd agent"; else
mkdir -p "$HOME/Library/LaunchAgents"; cp "$REPO/config/xyz.sethpc.mac-backup.plist" "$PL"
launchctl bootout "gui/$(id -u)/xyz.sethpc.mac-backup" 2>/dev/null || true
launchctl bootstrap "gui/$(id -u)" "$PL"; log set "backup launchd agent (03:30 nightly)"; CHANGED=1
fi
```
- [ ] **Step 5: Lint all three**
Run: `chmod +x scripts/backup.sh scripts/tank-side.sh && for f in scripts/*.sh; do bash -n $f && shellcheck -s bash $f; done; plutil -lint config/*.plist 2>/dev/null || xmllint --noout config/xyz.sethpc.mac-backup.plist; echo rc=$?`
Expected: `rc=0` (`plutil` is macOS-only; `xmllint` is the Linux fallback).
- [ ] **Step 6: Commit**
```bash
git add scripts/backup.sh scripts/tank-side.sh scripts/setup.sh config/xyz.sethpc.mac-backup.plist
git commit -m "feat: nightly rsync backup to tank with rrsync-restricted key and sanoid history"
gitea push
```
---
### Task 5: `scripts/run.sh` (steel141 side) + `docs/manual-checklist.md`
**Files:**
- Create: `scripts/run.sh`
- Create: `docs/manual-checklist.md`
- Modify: `CLAUDE.md` — Conventions: add the run command and checklist pointer.
**Interfaces:**
- Consumes: everything above. `run.sh` is the single entry point from steel141.
- [ ] **Step 1: Write `scripts/run.sh`**
```bash
#!/bin/bash
# From steel141: sync repo to the Mac, run setup there, pull backups back, then do the tank side.
# Usage: scripts/run.sh [--no-tank]
# sudo on the Mac is primed from $HOMELAB_PASSWORD over a forced pty (ssh -tt) so this works from a
# non-interactive session; with the var unset it falls back to an interactive prompt.
set -euo pipefail
cd "$(dirname "$0")/.."
rsync -a --delete --exclude .git --exclude .backup --exclude Brewfile.lock.json ./ mac:mac/
if [[ -n ${HOMELAB_PASSWORD:-} ]]; then
printf '%s\n' "$HOMELAB_PASSWORD" | ssh -tt mac 'sudo -S -v && HOSTNAME_WANT=mac ~/mac/scripts/setup.sh; exit' | tr -d '\r'
else
ssh -t mac 'HOSTNAME_WANT=mac ~/mac/scripts/setup.sh'
fi
mkdir -p .backup/mac && rsync -a mac:.mac-setup-backup/ .backup/mac/
[[ ${1:-} == --no-tank ]] && exit 0
PUB=$(ssh mac cat .ssh/id_ed25519.pub)
ssh pve173 'bash -s' -- "$PUB" < scripts/tank-side.sh
echo "tank side done; test: ssh mac ~/mac/scripts/backup.sh"
```
- [ ] **Step 2: Write `docs/manual-checklist.md`**
```markdown
# Manual checklist — GUI-only steps on macOS 26 Tahoe
Do these once, in order, after `scripts/run.sh` has completed. Tick and date.
## Apply the shell-level changes
- [ ] Log out / log in once (key repeat, press-and-hold, scroll direction only fully apply to a fresh session).
## Cloud + privacy
- [ ] System Settings > [your name] > iCloud > Drive (or "Saved to iCloud" > Drive): **Desktop & Documents Folders = OFF**. Keep account signed in.
- [ ] System Settings > General > AirDrop & Handoff: Handoff OFF, AirDrop = Contacts Only.
- [ ] System Settings > Screen Time: App & Website Activity OFF.
- [ ] System Settings > Spotlight: untick Siri Suggestions and any web/store result types; "Help Apple Improve Search" OFF.
- [ ] System Settings > Notifications: Allow notifications when mirroring/sharing OFF (keeps popups off a projector).
## DAW Focus
- [ ] System Settings > Focus > "+" > Custom > name **DAW**, icon of your choice. Allowed notifications: none. Turn on "Share across devices" OFF. Tick "Show in Control Center" is automatic — toggle it from the menu-bar moon before a session.
## Apps needing a first-launch grant
- [ ] Launch **Rectangle** once -> grant Accessibility when prompted. Set your snap keys.
- [ ] Launch **kitty** once. Gatekeeper prompt -> Open.
- [ ] Launch **Tailscale** -> log in to the tailnet. Confirm `tailscale status` in kitty and that `ssh mac` from bebop resolves via MagicDNS later.
- [ ] Gitea: `cat ~/.ssh/id_ed25519.pub` -> https://git.sethpc.xyz/user/settings/keys -> Add Key. Then `git clone git@git.sethpc.xyz:Seth/mac.git` works from the Mac if you ever want to edit there.
## Ableton
- [ ] ableton.com > account > download Live 12 (Suite/trial) -> install to /Applications -> authorize.
- [ ] Re-run `scripts/run.sh --no-tank` from steel141 so the Dock picks up Live (the script only adds it if installed).
- [ ] Live > Browser > Packs: download the Suite packs you want (they're part of the license — do not torrent them).
- [ ] Third-party packs: Finder > Go > Connect to Server > `smb://192.168.0.173/tank` (user seth, tick "Remember in Keychain") -> copy from `Downloads/Software/Milkie/` to `~/Music/Ableton/Samples/` (local disk — never run samples off SMB).
- [ ] Live > Settings > Audio: once an interface is attached, pick it, 48 kHz, buffer 128 (raise if crackle). Test with the DAW Focus on and the lid open on AC for 15 min: no sleep, no notification.
## Backup
- [ ] `ssh mac ~/mac/scripts/backup.sh` by hand once -> confirm `/tank/backups/mac/Ableton` appears on pve173 and `~/Library/Logs/mac-backup.log` exists.
```
- [ ] **Step 3: Update `CLAUDE.md` Conventions**
Append to the Conventions list:
```
- **Apply everything:** `scripts/run.sh` from steel141 (one sudo prompt at the lid). Second run must be all `[skip]`.
- GUI-only steps live in `docs/manual-checklist.md` — read when something "didn't apply" (it's probably on that list).
```
- [ ] **Step 4: Lint + commit**
Run: `chmod +x scripts/run.sh && bash -n scripts/run.sh && shellcheck -s bash scripts/run.sh; echo rc=$?`
Expected: `rc=0`
```bash
git add scripts/run.sh docs/manual-checklist.md CLAUDE.md
git commit -m "feat: run.sh entry point and Tahoe manual checklist"
gitea push
```
---
### Task 6: First run on the Mac
**Files:** none created; `.backup/mac/` populated (gitignored).
- [ ] **Step 1: Seth at the lid, run from steel141**
Run: `cd ~/bin/mac && scripts/run.sh --no-tank` (10-20 min; run it in the background and poll the output file — Homebrew's CLT install is the long part).
Expected: `Password:` echoed once (primed from env), Homebrew installs CLT + itself, `[set]` lines for every section, ends with `[pubkey] ssh-ed25519 ...`. Exit 0.
If Homebrew's CLT install fails headless: run `xcode-select --install` over `ssh mac`, click Install on the Mac's screen, re-run.
- [ ] **Step 2: Verify Linux feel over SSH** (SSH is non-login on macOS -> source the profile explicitly)
Run: `ssh mac 'source ~/.bash_profile; echo $BASH_VERSION; ip a | head -3; sed --version | head -1; ls --color=auto -d /Applications; hostname'`
Expected: `5.x`, an `ip` listing, `sed (GNU sed)`, coloured path, `mac`.
- [ ] **Step 3: Verify defaults + power**
Run: `ssh mac 'defaults read com.apple.dock persistent-apps | grep -c tile-type; defaults read com.apple.dock wvous-br-corner; pmset -g custom | sed -n "/AC Power/,/^$/p"; networksetup -listallnetworkservices | grep -c SparkFun'`
Expected: `2` (kitty + System Settings; `3` once Live is installed), `1`, AC block with `sleep 0 ... displaysleep 30 ... powernap 0`, `0`.
- [ ] **Step 4: Idempotency**
Run: `scripts/run.sh --no-tank 2>&1 | grep -c '\[set\]'`
Expected: `0`
- [ ] **Step 5: Record**
`git commit --allow-empty -m "chore: first setup.sh run on the Mac 2026-09-15 — verified idempotent"` then `gitea push`. Backups stay in `.backup/mac/` (gitignored).
---
### Task 7: Tank side + backup proof
- [ ] **Step 1: Run the tank side**
Run: `cd ~/bin/mac && PUB=$(ssh mac cat .ssh/id_ed25519.pub) && ssh pve173 'bash -s' -- "$PUB" < scripts/tank-side.sh`
Expected: `[set] created tank/backups/mac`, `[set] sanoid stanza`, `[set] rrsync-restricted key added`.
- [ ] **Step 2: Prove the restriction**
Run: `ssh mac 'ssh -o BatchMode=yes root@192.168.0.173 id'`
Expected: non-zero exit / rrsync error (an interactive command is refused). That's the point.
- [ ] **Step 3: Dry run then real run**
Run: `ssh mac 'mkdir -p ~/Documents/backup-probe && date > ~/Documents/backup-probe/ts && ~/mac/scripts/backup.sh'` then `ssh pve173 'ls -la /tank/backups/mac/Documents/backup-probe/ && zfs list -t snapshot -r tank/backups/mac | tail -2'`
Expected: `ts` present on tank; at least one `autosnap` snapshot within the hour (sanoid timer runs every minute, hourly policy).
- [ ] **Step 4: Clean the probe + confirm `--delete` propagates**
Run: `ssh mac 'rm -r ~/Documents/backup-probe && ~/mac/scripts/backup.sh' && ssh pve173 'ls /tank/backups/mac/Documents/ | grep -c backup-probe'`
Expected: `0`
- [ ] **Step 5: Record decision + commit**
Append to `DECISIONS.md` Implementation: `- 2026-09-15: Mac's key on pve173 is rrsync-restricted to /tank/backups/mac — a travelling laptop's key must not be root on the tank host.`
```bash
git add DECISIONS.md && git commit -m "docs: record rrsync restriction decision" && gitea push
```
---
### Task 8: Handoff
- [ ] **Step 1:** Update `CLAUDE.md` Current State: Phase -> `shipping (setup applied; manual checklist pending)`, remove "No changes made yet".
- [ ] **Step 2:** `/session-handoff` — capture: what ran, what's on the manual checklist for Seth, Live not yet installed, interface unknown, S4 MK1 macOS support unverified.
- [ ] **Step 3:** Commit + push.