f672ef1ebd
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
598 lines
28 KiB
Markdown
598 lines
28 KiB
Markdown
# 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.
|