Files
System-Sync-Restore/lib/mackup.sh
T
Oliver Surke 3e54f0ca20 fix: pre-clean transient dirs + repeat cleanup before every mackup attempt
Root cause: Claude Code recreates ~/.claude/debug/latest (a dangling
symlink to the current debug session) almost immediately after it is
deleted. The old cleanup ran once before the retry loop, so any retry
after a 2s sleep would hit a freshly recreated symlink.

Fix: move _ssr_mackup_preclean() inside the retry loop so it runs
immediately before each mackup attempt, leaving no window for apps to
recreate problematic files.

Also remove known transient subdirectories by path before the generic
socket/dangling-link sweep:
  .claude/debug  .cursor/logs  .vscode/logs  .npm/_logs

Configurable via SSR_MACKUP_PRECLEAN_PATHS (space-separated, $HOME-relative).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 12:17:44 +02:00

384 lines
15 KiB
Bash

#!/usr/bin/env bash
# lib/mackup.sh — Mackup install + backup/restore.
# Note: upstream mackup is in low-maintenance mode; we keep it because it
# remains the simplest way to sync dotfiles for a wide range of apps. The
# user can swap engines via ~/.mackup.cfg without changing this script.
# ---------------------------------------------------------------------------
# Custom Mackup app definition auto-generator
# ---------------------------------------------------------------------------
# Curated dot-paths that matter for developer workflows but are not in
# Mackup's built-in app definitions. Add your own via SSR_MACKUP_EXTRA_PATHS
# (space-separated) in ssr.conf.
_SSR_MACKUP_CURATED=(
.claude # Claude AI CLI config, skills, agents
.cursor # Cursor IDE config + rules
.codex # OpenAI Codex config
.easymcp # EasyMCP config
.factory # Factory AI config
.config/claude
.config/cursor
.config/gh # GitHub CLI (if not already in mackup)
.config/op # 1Password CLI config
bin # personal ~/bin scripts
)
# Dot-dirs/files to never auto-include.
# Covers: caches, generated state, credentials, large build artefacts.
_SSR_MACKUP_SKIP=(
.Trash .localized .DS_Store .CFUserTextEncoding
.cache .npm .yarn .pnpm .bun
.gradle .m2 .ivy2 .sbt
.docker .vagrant .minikube .kube .helm
.local # runtime state, not config
.bash_history .zsh_history .zsh_sessions .sh_history
.lesshst .wget-hsts .python_history .node_repl_history
.ruby_history .irb_history .psql_history .mysql_history
.dbshell .viminfo .Xauthority
.Spotlight-V100 .fseventsd .DocumentRevisions-V100
# Security-sensitive — back up separately with encryption
.ssh .gnupg .aws .azure .gcloud .config/gcloud
)
# Derive a safe Mackup app-name and filename from a dot-path.
# Examples: .claude → ssr-claude .config/gh → ssr-config-gh
_ssr_mackup_cfg_name() {
local path="$1"
local name="${path#.}" # strip leading dot
name="${name//\//-}" # slashes → dashes
name="${name//_/-}" # underscores → dashes
name="$(printf '%s' "$name" | tr '[:upper:]' '[:lower:]')"
printf 'ssr-%s' "$name"
}
# Write a single ~/.mackup/<name>.cfg for one dot-path.
# Skips writing if the file already exists and its content matches
# (idempotent on repeated syncs).
_ssr_mackup_write_cfg() {
local cfg_dir="$1" item="$2"
local app_name; app_name="$(_ssr_mackup_cfg_name "$item")"
local cfg_file="$cfg_dir/${app_name}.cfg"
local desired
desired="$(printf '[application]\nname = %s\n\n[configuration_files]\n%s\n' \
"$app_name" "$item")"
if [[ -f "$cfg_file" ]] && [[ "$(cat "$cfg_file")" == "$desired" ]]; then
return 0 # already up-to-date
fi
printf '%s\n' "$desired" > "$cfg_file"
}
# Build a newline-separated list of all paths already declared in Mackup's
# built-in app definitions AND user-defined ~/.mackup/*.cfg files.
# Our own generated ssr-*.cfg files are excluded to avoid circular checks.
_ssr_mackup_declared_paths() {
local apps_dir=""
# Locate Mackup's built-in apps/ directory via Python.
if command -v python3 >/dev/null 2>&1; then
apps_dir="$(python3 -c \
'import os, mackup; print(os.path.join(os.path.dirname(mackup.__file__), "apps"))' \
2>/dev/null || true)"
fi
# Also include hand-written user definitions (not our generated ssr-*.cfg).
local user_cfg_dir="$HOME/.mackup"
{
[[ -d "$apps_dir" ]] \
&& grep -rh '' "$apps_dir"/*.cfg 2>/dev/null || true
[[ -d "$user_cfg_dir" ]] \
&& grep -rhl '' "$user_cfg_dir"/*.cfg 2>/dev/null \
| grep -v '/ssr-' \
| xargs grep -h '' 2>/dev/null || true
} | awk '
/^\[configuration_files\]/ { in_section=1; next }
/^\[/ { in_section=0 }
in_section && /^[^#[:space:]]/ { print $1 }
' | sort -u
}
# Generate one ~/.mackup/ssr-<name>.cfg per uncovered dot-dir/file.
# 1. Curated entries that exist in $HOME and are not yet covered.
# 2. Auto-discovered dot-dirs in $HOME not in the skip list and not covered.
#
# "Covered" means: already declared in a built-in or hand-written Mackup app
# definition OR already materialised in the Mackup backup directory.
#
# Removes the old combined ssr-custom.cfg if found (migration).
# Each file is picked up automatically by `mackup backup/restore`.
ssr::mackup::generate_custom_cfg() {
local mackup_dir="$1"
local cfg_dir="$HOME/.mackup"
mkdir -p "$cfg_dir"
# Remove legacy combined file if present.
[[ -f "$cfg_dir/ssr-custom.cfg" ]] && rm -f "$cfg_dir/ssr-custom.cfg"
# Build declared-paths lookup early so we can also purge stale ssr-*.cfg
# files that now duplicate a built-in definition (e.g. ssr-claude.cfg after
# Mackup added a built-in "claude-code" app).
local declared
declared="$(_ssr_mackup_declared_paths)"
local stale_cfg stale_path
for stale_cfg in "$cfg_dir"/ssr-*.cfg; do
[[ -f "$stale_cfg" ]] || continue
# Extract the single path from [configuration_files].
stale_path="$(awk '/^\[configuration_files\]/{f=1;next}/^\[/{f=0}f&&/^[^#[:space:]]/{print $1;exit}' "$stale_cfg")"
if printf '%s\n' "$declared" | grep -qxF "$stale_path"; then
ssr::log "Removing stale duplicate: ${stale_cfg##*/} ($stale_path now covered by built-in)"
rm -f "$stale_cfg"
fi
done
local skip_set=" ${_SSR_MACKUP_SKIP[*]} "
# Build declared-paths lookup (newline-separated, sorted).
local declared
declared="$(_ssr_mackup_declared_paths)"
# Return true when <path> is already declared in any existing app definition.
_is_declared() {
printf '%s\n' "$declared" | grep -qxF "$1"
}
# Return true when <path> is already present in the Mackup cloud backup.
_in_backup() { [[ -e "$mackup_dir/$1" ]]; }
local -a items=()
# 1. Curated + user-defined extras.
local item
for item in "${_SSR_MACKUP_CURATED[@]}" ${SSR_MACKUP_EXTRA_PATHS:-}; do
[[ -e "$HOME/$item" ]] || continue
_is_declared "$item" && continue
_in_backup "$item" && continue
items+=("$item")
done
# 2. Auto-discover uncovered dot-dirs.
local name
while IFS= read -r dotdir; do
name="${dotdir##*/}"
[[ "$skip_set" == *" $name "* ]] && continue
_is_declared "$name" && continue
_in_backup "$name" && continue
local dup=false
for item in "${items[@]:-}"; do [[ "$item" == "$name" ]] && dup=true && break; done
$dup && continue
items+=("$name")
done < <(find "$HOME" -maxdepth 1 -mindepth 1 -name '.*' -type d 2>/dev/null | sort)
if [[ ${#items[@]} -eq 0 ]]; then
ssr::log "No uncovered dot-dirs — ~/.mackup/ssr-*.cfg unchanged."
return 0
fi
local written=0 it
for it in "${items[@]}"; do
_ssr_mackup_write_cfg "$cfg_dir" "$it"
written=$((written + 1))
done
ssr::ok "~/.mackup/ssr-*.cfg: ${written} definition(s) written"
ssr::log "${items[*]}"
}
ssr::mackup::ensure_installed() {
if command -v mackup >/dev/null 2>&1; then
return 0
fi
ssr::log "Installing mackup via brew"
brew install mackup
}
# Writes ~/.mackup.cfg pointing the engine at the cloud directory.
# Idempotent: rewrites only if content differs.
ssr::mackup::configure() {
local mackup_dir="$1"
local cfg="$HOME/.mackup.cfg"
local desired
desired="$(printf '[storage]\nengine = file_system\npath = %s\ndirectory = mackup\n' "$mackup_dir")"
if [[ -f "$cfg" ]] && [[ "$(cat "$cfg")" == "$desired" ]]; then
return 0
fi
if [[ -L "$cfg" || -f "$cfg" ]]; then
cp -f "$cfg" "$cfg.bak.$(date +%s)" 2>/dev/null || true
rm -f "$cfg"
fi
printf '%s\n' "$desired" > "$cfg"
ssr::ok "Wrote $cfg"
}
# Force iCloud Drive to materialize placeholder (.icloud) files under the
# Mackup storage directory before mackup walks them. The most common ssr sync
# crash is `OSError [Errno 66] Directory not empty` raised by shutil.rmtree
# inside mackup when iCloud lazily re-creates dataless placeholders between
# listdir() and rmdir(). Pre-downloading sidesteps the race.
ssr::mackup::_materialize_icloud() {
local mackup_root="$1"
[[ -d "$mackup_root" ]] || return 0
case "$mackup_root" in
*Mobile\ Documents*) ;; # only worth doing for iCloud
*) return 0 ;;
esac
# `brctl download` was removed in macOS Sonoma (remaining brctl subcommands:
# diagnose/log/dump/status/accounts/quota/monitor). The current way to
# trigger per-file iCloud download from the CLI is `open -g <real-path>`,
# which asks the iCloud daemon to fetch the file in the background.
find "$mackup_root" -name '*.icloud' -type f 2>/dev/null | while IFS= read -r stub; do
local dir base real
dir="$(dirname "$stub")"
base="$(basename "$stub")"
real="${base#.}" # strip leading dot
real="${real%.icloud}" # strip .icloud suffix
open -g "$dir/$real" 2>/dev/null || true
done
# Brief pause so the iCloud daemon can start fetching before mackup reads.
sleep 1
# Belt-and-braces: prune any stubs that couldn't be resolved.
find "$mackup_root" -name '*.icloud' -type f -delete 2>/dev/null || true
}
# Try `mackup -f backup` with a small retry budget. iCloud-induced races are
# transient: a 2-second sleep is usually enough for the provider to settle.
# We deliberately do NOT abort `ssr sync` on persistent failure — the user
# still benefits from brew dump, cleanup, and the unmanaged-apps scan that
# run afterwards. The failure is surfaced loudly via ssr::warn and syslog.
ssr::mackup::backup() {
ssr::log "mackup backup"
# Derive mackup root from ~/.mackup.cfg (path + directory).
local cfg="$HOME/.mackup.cfg" mackup_root="" path dir
if [[ -f "$cfg" ]]; then
path="$(awk -F= '/^path/{gsub(/^[[:space:]]+|[[:space:]]+$/,"",$2); print $2}' "$cfg")"
dir="$( awk -F= '/^directory/{gsub(/^[[:space:]]+|[[:space:]]+$/,"",$2); print $2}' "$cfg")"
[[ -n "$path" && -n "$dir" ]] && mackup_root="$path/$dir"
fi
[[ -n "$mackup_root" ]] && ssr::mackup::_materialize_icloud "$mackup_root"
# ---------------------------------------------------------------------------
# Pre-backup cleanup — remove artefacts that shutil.copytree cannot handle.
# Run this immediately before each mackup attempt so that apps (e.g. Claude
# Code) that recreate debug symlinks very quickly don't sneak back in.
# ---------------------------------------------------------------------------
_ssr_mackup_preclean() {
# Known transient subdirectories that apps recreate constantly.
# These contain debug logs / sockets, never real config.
# Extend via SSR_MACKUP_PRECLEAN_PATHS (space-separated $HOME-relative).
local -a transient_dirs=(
.claude/debug # Claude Code debug session links
.cursor/logs # Cursor IDE logs
.vscode/logs # VS Code logs
.vscode-insiders/logs
.npm/_logs # npm debug logs
)
local extra dir
for extra in ${SSR_MACKUP_PRECLEAN_PATHS:-}; do
transient_dirs+=("$extra")
done
for dir in "${transient_dirs[@]}"; do
[[ -e "$HOME/$dir" ]] && rm -rf "$HOME/$dir" 2>/dev/null || true
done
# 1. Unix socket files (-type s) — [Errno 102] Operation not supported.
local n; n="$(find "$HOME" -type s 2>/dev/null | wc -l | tr -d ' ')"
if [[ "$n" -gt 0 ]]; then
ssr::log " pre-clean: removing $n socket file(s)"
find "$HOME" -type s -delete 2>/dev/null || true
fi
# 2. Dangling symlinks (-type l ! -e) — [Errno 2] No such file.
n="$(find "$HOME" -type l ! -e 2>/dev/null | wc -l | tr -d ' ')"
if [[ "$n" -gt 0 ]]; then
ssr::log " pre-clean: removing $n dangling symlink(s)"
find "$HOME" -type l ! -e -delete 2>/dev/null || true
fi
}
local attempt rc=0
for attempt in 1 2 3; do
_ssr_mackup_preclean # clean immediately before each attempt
if mackup -f backup; then
return 0
fi
rc=$?
ssr::warn "mackup backup failed (attempt $attempt/3, rc=$rc)"
if [[ $attempt -lt 3 ]]; then
[[ -n "$mackup_root" ]] && ssr::mackup::_materialize_icloud "$mackup_root"
sleep 2
fi
done
ssr::warn "mackup backup did not complete after 3 attempts (rc=$rc)."
ssr::warn "Sync will continue without it; rerun 'mackup -f backup' manually,"
ssr::warn "or switch ~/.mackup.cfg to a non-iCloud storage path if this persists."
ssr::syslog "mackup backup failed after retries (rc=$rc)"
return 0 # soft-fail so the rest of ssr sync still runs
}
ssr::mackup::restore() {
ssr::log "mackup restore"
# Pre-materialize iCloud placeholders so mackup doesn't have to wait for
# each file to download one-by-one during the restore walk.
local cfg="$HOME/.mackup.cfg" mackup_root="" path dir
if [[ -f "$cfg" ]]; then
path="$(awk -F= '/^path/{gsub(/^[[:space:]]+|[[:space:]]+$/,"",$2); print $2}' "$cfg")"
dir="$( awk -F= '/^directory/{gsub(/^[[:space:]]+|[[:space:]]+$/,"",$2); print $2}' "$cfg")"
[[ -n "$path" && -n "$dir" ]] && mackup_root="$path/$dir"
fi
if [[ -n "$mackup_root" ]]; then
ssr::log "Materializing iCloud files in $mackup_root (may take a moment)…"
ssr::mackup::_materialize_icloud "$mackup_root"
# Count remaining stubs; if there are many, give iCloud more time.
local stub_count
stub_count="$(find "$mackup_root" -name '*.icloud' -type f 2>/dev/null | wc -l | tr -d ' ')"
if [[ "$stub_count" -gt 0 ]]; then
ssr::warn "$stub_count placeholder file(s) still pending — waiting 5s for iCloud sync…"
sleep 5
fi
fi
local stub_total
stub_total="$(find "${mackup_root:-/dev/null}" -name '*.icloud' -type f 2>/dev/null | wc -l | tr -d ' ')"
if [[ "$stub_total" -gt 0 ]]; then
ssr::warn "$stub_total file(s) still need downloading from iCloud — restore may pause between files."
fi
ssr::log "Running mackup restore (long pauses are normal while iCloud fetches files)…"
# Background watchdog: prints elapsed time every 15s so the user knows the
# process is still alive during iCloud download pauses.
local start_ts rc=0 done_flag watchdog_pid
start_ts="$(date +%s)"
done_flag="$(mktemp)"
(
while [[ -f "$done_flag" ]]; do
sleep 15
[[ -f "$done_flag" ]] || break
printf ' … mackup restore still running (%ds elapsed — waiting for iCloud)\n' \
"$(( $(date +%s) - start_ts ))" >&2
done
) &
watchdog_pid=$!
# -f: answer "yes" to all prompts (consistent with backup behaviour).
mackup -f restore || rc=$?
rm -f "$done_flag"
kill "$watchdog_pid" 2>/dev/null || true
wait "$watchdog_pid" 2>/dev/null || true
local elapsed=$(( $(date +%s) - start_ts ))
if [[ $rc -ne 0 ]]; then
ssr::warn "mackup restore finished with errors (rc=$rc, ${elapsed}s elapsed)"
else
ssr::ok "mackup restore completed in ${elapsed}s"
fi
}