Files
System-Sync-Restore/lib/mackup.sh
T
Oliver Surke be9b37010e fix: back up Docker Desktop config files only, not the whole ~/.docker dir
Docker Desktop checks that ~/.docker is a real directory and rejects it
if it is a symlink (Mackup's default when backing up a whole dir).

Solution: remove .docker from the skip list, add it to curated + selective.
Mackup will symlink only ~/.docker/config.json and ~/.docker/daemon.json —
the directory itself stays a real dir that Docker Desktop can manage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 15:03:24 +02:00

447 lines
18 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
.docker # Docker Desktop — selective files only (see _SSR_MACKUP_SELECTIVE)
.config/claude
.config/cursor
.config/gh # GitHub CLI (if not already in mackup)
.config/op # 1Password CLI config
bin # personal ~/bin scripts
)
# Selective sub-path overrides.
# When a directory contains volatile/problematic files (sockets, debug
# symlinks), list only the STABLE sub-paths here instead of the whole dir.
# Format: "dir=subpath1,subpath2,subpath3" (paths relative to the dir)
#
# Example: .claude has a debug/ dir with dangling symlinks → list only the
# config files we actually need. Extend via SSR_MACKUP_SELECTIVE_PATHS
# (same format, space-separated entries) in ssr.conf.
_SSR_MACKUP_SELECTIVE=(
".claude=settings.json,CLAUDE.md,skills,plugins,agents,commands"
".cursor=settings.json,keybindings.json,snippets,rules"
".codex=auth.json,config.toml"
".vscode=settings.json,keybindings.json,snippets"
".vscode-insiders=settings.json,keybindings.json,snippets"
".factory=settings.json,droids"
# Docker Desktop rejects ~/.docker as a symlink — back up individual files
# only so ~/.docker/ stays a real directory that Docker Desktop can manage.
".docker=config.json,daemon.json"
)
# 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 is handled via _SSR_MACKUP_SELECTIVE (individual files only).
# Docker Desktop rejects ~/.docker being a symlink, so we never back up
# the whole directory — only config.json / daemon.json as file symlinks.
.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"
}
# Resolve the list of [configuration_files] entries for an item.
# If a selective override exists for the item, expand sub-paths that are
# present on disk. Falls back to the item itself (whole directory).
#
# Outputs one path per line.
_ssr_mackup_resolve_paths() {
local item="$1"
local -a all_selective=("${_SSR_MACKUP_SELECTIVE[@]}")
# Merge user-defined selectives from ssr.conf.
local entry
for entry in ${SSR_MACKUP_SELECTIVE_PATHS:-}; do
all_selective+=("$entry")
done
for entry in "${all_selective[@]}"; do
local key="${entry%%=*}"
local val="${entry#*=}"
if [[ "$key" == "$item" ]]; then
# Split comma-separated sub-paths; only include those that exist.
local sub
local IFS=','
for sub in $val; do
IFS=' '
local full="$item/$sub"
# List the path even if it doesn't exist yet — it may be created later.
printf '%s\n' "$full"
done
return 0
fi
done
# No selective entry — back up the whole directory.
printf '%s\n' "$item"
}
# Write a single ~/.mackup/<name>.cfg for one dot-path.
# Uses selective sub-paths when a matching entry exists in _SSR_MACKUP_SELECTIVE.
# Skips writing if content is unchanged (idempotent).
_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"
# Build [configuration_files] block.
local paths_block
paths_block="$(_ssr_mackup_resolve_paths "$item")"
local desired
desired="$(printf '[application]\nname = %s\n\n[configuration_files]\n%s\n' \
"$app_name" "$paths_block")"
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
}