3e54f0ca20
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>
384 lines
15 KiB
Bash
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
|
|
}
|