refactor(mackup): transition to copy-based backup and restore

- Updated `ssr sync` and `ssr restore` commands to use `mackup backup` and `mackup restore`, replacing the previous symlink-based approach.
- Added guidance for users with legacy symlink setups to run `ssr mackup unlink` for recovery.
- Revised documentation in README, completions, and configuration examples to reflect the new copy-based methodology for improved reliability on newer macOS versions.
- Enhanced comments in the codebase to clarify the changes and their implications for users.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-13 09:17:25 +02:00
parent 51fb589ef2
commit c31338dba9
7 changed files with 47 additions and 91 deletions
+3 -2
View File
@@ -2,8 +2,9 @@
Reproducible macOS setup in one command. `ssr` snapshots your Homebrew bundle and application configs to a cloud drive on a schedule, and can rebuild a fresh Mac from that snapshot. Reproducible macOS setup in one command. `ssr` snapshots your Homebrew bundle and application configs to a cloud drive on a schedule, and can rebuild a fresh Mac from that snapshot.
- **Backup** (`ssr sync`): dumps `Brewfile` and runs `mackup link install` to the configured cloud directory (symlink-based). - **Backup** (`ssr sync`): dumps `Brewfile` and runs `mackup backup` to the configured cloud directory (copy-based; no symlinks).
- **Restore** (`ssr restore`): installs Homebrew, replays the `Brewfile`, runs `mackup link`, applies macOS defaults. - **Restore** (`ssr restore`): installs Homebrew, replays the `Brewfile`, runs `mackup restore`, applies macOS defaults.
- **Legacy Mackup symlinks**: if a previous install used `mackup link`, run `ssr mackup unlink` once before relying on copy-based sync.
- **Scheduled sync**: native macOS `launchd` agent (daily by default). - **Scheduled sync**: native macOS `launchd` agent (daily by default).
- **Cloud storage**: iCloud Drive, Dropbox, Google Drive, OneDrive, or any custom path — selectable via config. - **Cloud storage**: iCloud Drive, Dropbox, Google Drive, OneDrive, or any custom path — selectable via config.
- **No secrets**: all state lives in your cloud drive; the repo only contains code and config templates. - **No secrets**: all state lives in your cloud drive; the repo only contains code and config templates.
+2 -2
View File
@@ -27,8 +27,8 @@ Usage:
Commands: Commands:
install First-time install: links binary, seeds config, schedules sync. install First-time install: links binary, seeds config, schedules sync.
sync Run a backup: brew bundle dump + mackup link install → cloud. sync Run a backup: brew bundle dump + mackup backup → cloud.
restore [Brewfile] Restore a machine: brew bundle + mackup link + macOS defaults. restore [Brewfile] Restore a machine: brew bundle + mackup restore + macOS defaults.
update [--dry-run] Pull latest ssr code, refresh launchd if needed. update [--dry-run] Pull latest ssr code, refresh launchd if needed.
schedule on|off Enable/disable the scheduled launchd sync job. schedule on|off Enable/disable the scheduled launchd sync job.
config show|edit Print or edit the active config file. config show|edit Print or edit the active config file.
+9 -9
View File
@@ -5,8 +5,8 @@
# scan Per-item prompts for new ~/.mackup/ssr-*.cfg files. # scan Per-item prompts for new ~/.mackup/ssr-*.cfg files.
# list List existing ~/.mackup/ssr-*.cfg files with the path they cover. # list List existing ~/.mackup/ssr-*.cfg files with the path they cover.
# clean Remove all generated ssr-*.cfg files (prompts first). # clean Remove all generated ssr-*.cfg files (prompts first).
# unlink Run `mackup link uninstall` — copy configs back from Mackup # unlink Run `mackup link uninstall` — only for undoing symlink-based Mackup
# storage and remove symlinks (fixes broken iTerm / shell paths). # (legacy SSR or manual `mackup link`); copies from storage and removes symlinks.
set -euo pipefail set -euo pipefail
SSR_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" SSR_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
@@ -31,14 +31,14 @@ Subcommands:
scan Suggest new ~/.mackup/ssr-*.cfg files (confirm each item). scan Suggest new ~/.mackup/ssr-*.cfg files (confirm each item).
list List existing generated files. list List existing generated files.
clean Remove all generated ssr-*.cfg files. clean Remove all generated ssr-*.cfg files.
unlink Revert Mackup symlinks (copy from cloud → real files in ~). unlink Undo symlink-based Mackup only (`mackup link uninstall`).
Run 'ssr mackup scan' when new dot-dirs appear; commit ~/.mackup/ssr-*.cfg Run 'ssr mackup scan' when new dot-dirs appear; commit ~/.mackup/ssr-*.cfg
with your dotfiles if you use them. with your dotfiles if you use them.
If Terminal/iTerm or other apps break after sync, run: If you previously used symlink-based Mackup and apps break, run:
ssr mackup unlink ssr mackup unlink
That runs Mackup's official undo for symlink-based installs. Current SSR uses copy-based `mackup backup` / `restore` instead.
EOF EOF
} }
@@ -259,11 +259,11 @@ cmd_unlink() {
This runs: mackup -f link uninstall This runs: mackup -f link uninstall
Mackup copies each managed path from your Mackup storage folder back into Mackup copies each managed path from your Mackup storage folder back into
$HOME and removes the symlinks. Use when settings broke after `ssr sync` / $HOME and removes the symlinks. Use only if you still have symlink-based
`mackup link install` (e.g. iTerm2, shell init files pointing at iCloud). Mackup from an older SSR or from manual `mackup link` / `link install`.
After unlink, apps use normal local files again. Run `ssr sync` only when After unlink, apps use normal local files again. Ongoing `ssr sync` uses
you are ready to re-apply symlink-based backup. `mackup backup` (copy to cloud), not symlinks.
EOF EOF
printf '\n' printf '\n'
ssr::confirm "Run mackup link uninstall now?" 120 || { printf 'Aborted.\n'; return 0; } ssr::confirm "Run mackup link uninstall now?" 120 || { printf 'Aborted.\n'; return 0; }
+1 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# ssr-sync — back up Homebrew + Mackup state to the configured cloud dir. # ssr-sync — back up Homebrew + Mackup state to the configured cloud dir (copy-based mackup backup).
set -euo pipefail set -euo pipefail
SSR_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" SSR_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+1 -1
View File
@@ -65,7 +65,7 @@ _ssr() {
'scan[Suggest and confirm ssr-*.cfg files]' \ 'scan[Suggest and confirm ssr-*.cfg files]' \
'list[List generated ssr-*.cfg files]' \ 'list[List generated ssr-*.cfg files]' \
'clean[Remove all generated ssr-*.cfg files]' \ 'clean[Remove all generated ssr-*.cfg files]' \
'unlink[mackup link uninstall — revert symlinks]' 'unlink[undo symlink Mackup — legacy recovery]'
;; ;;
esac esac
;; ;;
+4 -5
View File
@@ -16,14 +16,13 @@ SSR_CLOUD_PROVIDER=icloud
SSR_BACKUP_SUBDIR=BACKUP SSR_BACKUP_SUBDIR=BACKUP
SSR_MACKUP_SUBDIR=Mackup SSR_MACKUP_SUBDIR=Mackup
# Optional iTerm2 race workaround: pre-symlink DynamicProfiles → Mackup cloud # SSR uses `mackup backup` / `mackup restore` (copy, not symlinks) for reliability
# before `mackup link install`. Default is off — enabling can break iTerm if # on newer macOS. If you still have Mackup symlinks from an older setup, run:
# the cloud copy is incomplete. Prefer `ssr mackup unlink` to recover. # ssr mackup unlink
# SSR_MACKUP_PRESYMLINK=1
# Subdirs (relative to $HOME) to delete before each mackup backup attempt. # Subdirs (relative to $HOME) to delete before each mackup backup attempt.
# Handles transient dirs with dangling symlinks or sockets (debug sessions, # Handles transient dirs with dangling symlinks or sockets (debug sessions,
# log dirs) that shutil.copytree cannot copy. # log dirs) that should not be copied to cloud.
# Built-in defaults: .claude/debug .cursor/logs .vscode/logs .npm/_logs # Built-in defaults: .claude/debug .cursor/logs .vscode/logs .npm/_logs
# SSR_MACKUP_PRECLEAN_PATHS=".my-app/tmp .other-app/run" # SSR_MACKUP_PRECLEAN_PATHS=".my-app/tmp .other-app/run"
+27 -71
View File
@@ -5,10 +5,9 @@
# user can swap engines via ~/.mackup.cfg without changing this script. # user can swap engines via ~/.mackup.cfg without changing this script.
# #
# Mackup 0.10.2 command mapping (importantly different from older versions): # Mackup 0.10.2 command mapping (importantly different from older versions):
# mackup backup → copy only; NO symlinks created in ~/ # mackup backup → copy managed files from ~/ into storage (no symlinks)
# mackup restore → copy only; NO symlinks created in ~/ # mackup restore → copy from storage back into ~/
# mackup link install → copy to cloud + create symlinks in ~/ ← SSR uses this # mackup link* → symlink-based flow; unreliable on newer macOS — SSR does not use it
# mackup link → create symlinks in ~/ (cloud files must exist) ← SSR restore
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Custom Mackup app definition auto-generator # Custom Mackup app definition auto-generator
@@ -338,21 +337,16 @@ ssr::mackup::_materialize_icloud() {
find "$mackup_root" -name '*.icloud' -type f -delete 2>/dev/null || true find "$mackup_root" -name '*.icloud' -type f -delete 2>/dev/null || true
} }
# Run `mackup -f link install` — this is the correct "backup with symlinks" # Run `mackup -f backup` — copies managed paths from ~/ into the configured
# command in Mackup 0.10.2. Unlike `mackup backup` (which only copies), # storage directory (no symlinks). Copy mode avoids macOS issues seen with
# `link install` moves each managed file to the cloud folder and replaces it # `mackup link` / `link install` on recent OS releases.
# with a symlink. On subsequent runs it detects already-linked files and
# skips them efficiently.
# #
# Sockets and dangling symlinks are naturally skipped by `link install` because # Light preclean removes known transient dirs so junk is not copied to cloud.
# Python's os.path.isfile/isdir returns False for those types. We still run a
# light preclean for known transient directories so their contents don't appear
# as stale files in the cloud copy.
# #
# We do NOT abort `ssr sync` on persistent failure — brew dump, cleanup, and # We do NOT abort `ssr sync` on persistent failure — brew dump, cleanup, and
# the unmanaged-apps scan still run. Failure is surfaced via ssr::warn. # the unmanaged-apps scan still run. Failure is surfaced via ssr::warn.
ssr::mackup::backup() { ssr::mackup::backup() {
ssr::log "mackup link install (backup + symlink)" ssr::log "mackup backup (copy to cloud storage)"
# Derive mackup root from ~/.mackup.cfg (path + directory). # Derive mackup root from ~/.mackup.cfg (path + directory).
local cfg="$HOME/.mackup.cfg" mackup_root="" path dir local cfg="$HOME/.mackup.cfg" mackup_root="" path dir
@@ -363,47 +357,11 @@ ssr::mackup::backup() {
fi fi
[[ -n "$mackup_root" ]] && ssr::mackup::_materialize_icloud "$mackup_root" [[ -n "$mackup_root" ]] && ssr::mackup::_materialize_icloud "$mackup_root"
# ---------------------------------------------------------------------------
# Optional pre-symlink (OFF by default): iTerm2 can recreate
# DynamicProfiles/ so fast that `mackup link install` loses the race.
# Forcing a symlink here can break iTerm if the cloud copy is wrong or
# empty — only enable after you trust the Mackup cloud tree.
#
# export SSR_MACKUP_PRESYMLINK=1 # in ssr.conf or the shell
#
# Format: home-relative path (same as in the Mackup app definition).
# ---------------------------------------------------------------------------
local -a _presymlink=()
case "${SSR_MACKUP_PRESYMLINK:-}" in
1|true|yes|on)
_presymlink=(.config/iterm2/AppSupport/DynamicProfiles)
;;
esac
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Light preclean — remove transient directories so their ephemeral contents # Light preclean — remove transient directories so their ephemeral contents
# (debug symlinks, log files) are not included in the cloud copy. # (debug symlinks, log files) are not included in the cloud copy.
# `link install` already skips sockets and dangling symlinks via its own
# os.path.isfile/isdir guard, so we only need to handle known directories.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_ssr_mackup_preclean() { _ssr_mackup_preclean() {
local mr="$1" # mackup root
# 1. Pre-symlink race-condition paths.
local rel home_p cloud_p
for rel in "${_presymlink[@]}"; do
home_p="$HOME/$rel"
cloud_p="$mr/$rel"
[[ -d "$cloud_p" ]] || continue # no cloud copy yet — skip
if [[ -L "$home_p" ]] && [[ "$(readlink "$home_p")" == "$cloud_p" ]]; then
continue # already correct symlink
fi
ssr::log " pre-symlink: $rel → cloud (prevents iTerm2-style race)"
rm -rf "$home_p" 2>/dev/null || true
ln -s "$cloud_p" "$home_p" 2>/dev/null || true
done
# 2. Transient directories whose contents should never go into cloud.
local -a transient_dirs=( local -a transient_dirs=(
.claude/debug # Claude Code debug session links .claude/debug # Claude Code debug session links
.cursor/logs # Cursor IDE logs .cursor/logs # Cursor IDE logs
@@ -422,28 +380,28 @@ ssr::mackup::backup() {
local attempt rc=0 local attempt rc=0
for attempt in 1 2 3; do for attempt in 1 2 3; do
_ssr_mackup_preclean "$mackup_root" _ssr_mackup_preclean
if mackup -f link install; then if mackup -f backup; then
ssr::ok "mackup link install complete — symlinks created in ~/" ssr::ok "mackup backup complete — configs copied to cloud storage"
return 0 return 0
fi fi
rc=$? rc=$?
ssr::warn "mackup link install failed (attempt $attempt/3, rc=$rc)" ssr::warn "mackup backup failed (attempt $attempt/3, rc=$rc)"
if [[ $attempt -lt 3 ]]; then if [[ $attempt -lt 3 ]]; then
[[ -n "$mackup_root" ]] && ssr::mackup::_materialize_icloud "$mackup_root" [[ -n "$mackup_root" ]] && ssr::mackup::_materialize_icloud "$mackup_root"
sleep 2 sleep 2
fi fi
done done
ssr::warn "mackup link install did not complete after 3 attempts (rc=$rc)." ssr::warn "mackup backup did not complete after 3 attempts (rc=$rc)."
ssr::warn "Sync will continue without it; run 'mackup -f link install' manually," ssr::warn "Sync will continue without it; run 'mackup -f backup' manually,"
ssr::warn "or check ~/.mackup.cfg if this persists." ssr::warn "or check ~/.mackup.cfg if this persists."
ssr::syslog "mackup link install failed after retries (rc=$rc)" ssr::syslog "mackup backup failed after retries (rc=$rc)"
return 0 # soft-fail so the rest of ssr sync still runs return 0 # soft-fail so the rest of ssr sync still runs
} }
ssr::mackup::restore() { ssr::mackup::restore() {
ssr::log "mackup link (restore via symlinks)" ssr::log "mackup restore (copy from cloud storage to ~/)"
# Pre-materialize iCloud placeholders so mackup's isfile/isdir checks # Pre-materialize iCloud placeholders so mackup's isfile/isdir checks
# see real files rather than .icloud stubs (which read as non-existent). # see real files rather than .icloud stubs (which read as non-existent).
@@ -467,9 +425,9 @@ ssr::mackup::restore() {
local stub_total local stub_total
stub_total="$(find "${mackup_root:-/dev/null}" -name '*.icloud' -type f 2>/dev/null | wc -l | tr -d ' ')" stub_total="$(find "${mackup_root:-/dev/null}" -name '*.icloud' -type f 2>/dev/null | wc -l | tr -d ' ')"
if [[ "$stub_total" -gt 0 ]]; then if [[ "$stub_total" -gt 0 ]]; then
ssr::warn "$stub_total file(s) still need downloading — symlinks may be skipped for those." ssr::warn "$stub_total file(s) still need downloading — restore may skip those paths."
fi fi
ssr::log "Running mackup link (long pauses normal while iCloud fetches files)…" ssr::log "Running mackup restore (long pauses normal while iCloud fetches files)…"
# Background watchdog: prints elapsed time every 15s. # Background watchdog: prints elapsed time every 15s.
local start_ts rc=0 done_flag watchdog_pid local start_ts rc=0 done_flag watchdog_pid
@@ -479,16 +437,14 @@ ssr::mackup::restore() {
while [[ -f "$done_flag" ]]; do while [[ -f "$done_flag" ]]; do
sleep 15 sleep 15
[[ -f "$done_flag" ]] || break [[ -f "$done_flag" ]] || break
printf ' … mackup link still running (%ds elapsed — waiting for iCloud)\n' \ printf ' … mackup restore still running (%ds elapsed — waiting for iCloud)\n' \
"$(( $(date +%s) - start_ts ))" >&2 "$(( $(date +%s) - start_ts ))" >&2
done done
) & ) &
watchdog_pid=$! watchdog_pid=$!
# `mackup link` creates symlinks ~/file → cloud/file for all managed apps. # Copy managed files from storage into ~/. -f auto-confirms overwrites.
# Unlike `mackup restore` (which copies), this keeps files in iCloud and mackup -f restore || rc=$?
# ensures ongoing sync. -f auto-confirms replacement of existing home files.
mackup -f link || rc=$?
rm -f "$done_flag" rm -f "$done_flag"
kill "$watchdog_pid" 2>/dev/null || true kill "$watchdog_pid" 2>/dev/null || true
@@ -496,16 +452,16 @@ ssr::mackup::restore() {
local elapsed=$(( $(date +%s) - start_ts )) local elapsed=$(( $(date +%s) - start_ts ))
if [[ $rc -ne 0 ]]; then if [[ $rc -ne 0 ]]; then
ssr::warn "mackup link finished with errors (rc=$rc, ${elapsed}s elapsed)" ssr::warn "mackup restore finished with errors (rc=$rc, ${elapsed}s elapsed)"
else else
ssr::ok "mackup link completed in ${elapsed}s — symlinks created in ~/" ssr::ok "mackup restore completed in ${elapsed}s — configs copied to ~/"
fi fi
} }
# Reverse `mackup link` / `link install`: copy each managed path from the # Undo a previous `mackup link` / `link install` (symlinks → real files in ~/).
# Mackup storage tree back into $HOME and remove the symlinks. Use when apps # SSR now uses copy-based backup/restore; keep this for machines that still
# (e.g. iTerm2) misbehave after symlink-based sync or iCloud produced a bad # have symlink-based Mackup from older SSR or manual `mackup link` use.
# tree. Invoked by `ssr mackup unlink` (interactive confirm). # Invoked by `ssr mackup unlink` (interactive confirm).
ssr::mackup::link_uninstall() { ssr::mackup::link_uninstall() {
ssr::mackup::ensure_installed ssr::mackup::ensure_installed
ssr::log "mackup link uninstall (revert symlinks → local files)" ssr::log "mackup link uninstall (revert symlinks → local files)"