#!/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/.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" } # Generate one ~/.mackup/ssr-.cfg per uncovered dot-dir/file. # 1. Curated entries that exist in $HOME and are not yet in the Mackup backup. # 2. Auto-discovered dot-dirs in $HOME not in the skip list and not in backup. # # Removes the old combined ssr-custom.cfg if found (migration from old format). # 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" local skip_set=" ${_SSR_MACKUP_SKIP[*]} " _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 _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 _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 if _ssr_mackup_write_cfg "$cfg_dir" "$it"; then written=$((written + 1)) fi done ssr::ok "~/.mackup/ssr-*.cfg: ${#items[@]} app definitions written (${written} new/updated)" 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 `, # 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" # Unix socket files (type s) cannot be copied and are always recreated # by their owning app on startup. Remove them from $HOME before backup # to prevent shutil.Error: [Errno 102] Operation not supported on socket. local socket_count socket_count="$(find "$HOME" -type s 2>/dev/null | wc -l | tr -d ' ')" if [[ "$socket_count" -gt 0 ]]; then ssr::log "Removing $socket_count Unix socket file(s) before mackup backup (runtime artefacts)" find "$HOME" -type s -delete 2>/dev/null || true fi local attempt rc=0 for attempt in 1 2 3; do 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 }