Files
mac/docs/plans/2026-09-15-mac-setup-plan.md
Mortdecai f672ef1ebd docs: implementation plan for mac setup
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 19:06:28 -04:00

28 KiB

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

# 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
# ~/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

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

#!/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
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

# ---------- 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:

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
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

#!/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 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)
#!/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 ----------)
# ---------- 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
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

#!/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
# 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

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.

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.