#!/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. # # Mackup 0.10.2 command mapping (importantly different from older versions): # mackup backup → copy managed files from ~/ into storage (no symlinks) # mackup restore → copy from storage back into ~/ # mackup link* → symlink-based flow; unreliable on newer macOS — SSR does not use it # --------------------------------------------------------------------------- # Custom Mackup app definition auto-generator # --------------------------------------------------------------------------- # Curated dot-paths for developer workflows NOT covered by Mackup's built-in # app definitions. Add your own via SSR_MACKUP_EXTRA_PATHS in ssr.conf. # # Built-ins that ALREADY cover common tools (do NOT add them here): # claude-code.cfg → .claude/{plugins,settings,skills,agents,settings.json,CLAUDE.md} # codex.cfg → .codex/{config.toml,auth.json} # cursor.cfg → Library/Application Support/Cursor/User/... (not .cursor/) # docker.cfg → .docker/{config.json,daemon.json} # github-cli.cfg → .config/gh/config.yml (xdg_configuration_files) # vscode.cfg → Library/Application Support/Code/User/... (not .vscode/) # vscode-insiders.cfg → Library/Application Support/Code - Insiders/... _SSR_MACKUP_CURATED=( # .cursor/ rules and workspace-specific settings live here — the built-in # cursor.cfg only covers Library/Application Support/Cursor (different path). .cursor .easymcp # EasyMCP config (no built-in) .factory # Factory AI config (no built-in) .config/op # 1Password CLI config (no built-in) 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=( # .cursor/ — only the stable config sub-paths; exclude logs/, extensions/, # workspaceStorage/ etc. The built-in cursor.cfg covers Library/Application # Support paths; this covers the dot-dir used for rules and workspace config. ".cursor=settings.json,keybindings.json,snippets,rules" ".factory=settings.json,droids" # Add selective overrides for any SSR_MACKUP_EXTRA_PATHS entries here if # you need sub-path granularity instead of whole-directory backup. ) # Dot-dirs/files to never auto-include. # Covers: caches, generated state, credentials, large build artefacts. _SSR_MACKUP_SKIP=( # System / macOS noise .Trash .localized .DS_Store .CFUserTextEncoding .Spotlight-V100 .fseventsd .DocumentRevisions-V100 .local # runtime state, not config # Package manager caches (large, machine-specific) .cache .npm .yarn .pnpm .bun .gradle .m2 .ivy2 .sbt .nuget # Installed tool runtimes (not config — reinstall on restore instead) .oh-my-zsh # framework itself, not user dotfiles .pyenv # Python runtime installs .rbenv # Ruby runtime installs .nvm # Node.js runtime installs .sdkman # Java/JVM runtime installs .rustup # Rust toolchains .go # Go workspace .cargo # Rust package cache .deno # Deno runtime + cache .swiftpm # Swift package cache # Docker / VM infrastructure (large, or require special handling) .vagrant .minikube .kube .helm # Sync-service data directories — never back up a sync engine inside iCloud .dropbox # Dropbox data; backing up to iCloud would be circular .OneDrive # OneDrive data directory # History files (session-specific, 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 # 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 initial [configuration_files] entries for a NEW item. # Uses _SSR_MACKUP_SELECTIVE defaults; once a cfg file exists the user owns # it and this function is never called for that item again. # # Outputs one path per line. _ssr_mackup_resolve_paths() { local item="$1" local entry for entry in "${_SSR_MACKUP_SELECTIVE[@]}"; do local key="${entry%%=*}" local val="${entry#*=}" if [[ "$key" == "$item" ]]; then local sub local IFS=',' for sub in $val; do IFS=' ' printf '%s\n' "$item/$sub" done return 0 fi done # No selective default — back up the whole directory. printf '%s\n' "$item" } # Write a single ~/.mackup/.cfg for a NEW item. # Never called when the file already exists — existing files are user-owned. _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" # Never overwrite existing files — user may have customised them. [[ -f "$cfg_file" ]] && return 0 local paths_block paths_block="$(_ssr_mackup_resolve_paths "$item")" printf '[application]\nname = %s\n\n[configuration_files]\n%s\n' \ "$app_name" "$paths_block" > "$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() { # Locate Mackup's built-in applications/ directory. # Mackup bundles its own Python interpreter — use it so the import works # regardless of which system python3 is active. local apps_dir="" mackup_bin mackup_bin="$(command -v mackup 2>/dev/null)" if [[ -n "$mackup_bin" ]]; then local mackup_py mackup_py="$(head -1 "$mackup_bin" | sed 's/^#!//')" apps_dir="$("$mackup_py" -c \ 'import os, mackup; print(os.path.join(os.path.dirname(mackup.__file__), "applications"))' \ 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"/ 2>/dev/null || true [[ -d "$user_cfg_dir" ]] \ && grep -rhl '' "$user_cfg_dir"/ 2>/dev/null \ | grep -v '/ssr-' \ | xargs grep -h '' 2>/dev/null || true } | awk ' /^\[configuration_files\]/ { in_cfg=1; in_xdg=0; next } /^\[xdg_configuration_files\]/ { in_xdg=1; in_cfg=0; next } /^\[/ { in_cfg=0; in_xdg=0 } in_cfg && /^[^#[:space:]]/ { print $1 } in_xdg && /^[^#[:space:]]/ { print ".config/" $1 } ' | sort -u } # Generate one ~/.mackup/ssr-.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 is already declared, OR when any declared path # starts with "/" (i.e. individual files inside are declared, # meaning a built-in covers the directory via selective file entries). # Example: built-in byobu declares .byobu/profile → .byobu is covered. # # Uses `command grep` (bypasses colour aliases) and a bash glob for the # prefix check (avoids regex escaping issues with dots in paths). _is_declared() { local p="$1" decl command grep -qxF "$p" <<< "$declared" && return 0 while IFS= read -r decl; do [[ "$decl" == "$p/"* ]] && return 0 done <<< "$declared" return 1 } # Return true when 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 `, # 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 } # Run `mackup -f backup` — copies managed paths from ~/ into the configured # storage directory (no symlinks). Copy mode avoids macOS issues seen with # `mackup link` / `link install` on recent OS releases. # # Light preclean removes known transient dirs so junk is not copied to cloud. # # 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. ssr::mackup::backup() { ssr::log "mackup backup (copy to cloud storage)" # 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" # --------------------------------------------------------------------------- # Light preclean — remove transient directories so their ephemeral contents # (debug symlinks, log files) are not included in the cloud copy. # --------------------------------------------------------------------------- _ssr_mackup_preclean() { 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 d for extra in ${SSR_MACKUP_PRECLEAN_PATHS:-}; do transient_dirs+=("$extra") done for d in "${transient_dirs[@]}"; do [[ -e "$HOME/$d" ]] && rm -rf "${HOME:?}/$d" 2>/dev/null || true done } local attempt rc=0 for attempt in 1 2 3; do _ssr_mackup_preclean if mackup -f backup; then ssr::ok "mackup backup complete — configs copied to cloud storage" 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; run 'mackup -f backup' manually," ssr::warn "or check ~/.mackup.cfg 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 (copy from cloud storage to ~/)" # Pre-materialize iCloud placeholders so mackup's isfile/isdir checks # see real files rather than .icloud stubs (which read as non-existent). 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" 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 — restore may skip those paths." fi ssr::log "Running mackup restore (long pauses normal while iCloud fetches files)…" # Background watchdog: prints elapsed time every 15s. 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=$! # Copy managed files from storage into ~/. -f auto-confirms overwrites. 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 — configs copied to ~/" fi } # Undo a previous `mackup link` / `link install` (symlinks → real files in ~/). # SSR now uses copy-based backup/restore; keep this for machines that still # have symlink-based Mackup from older SSR or manual `mackup link` use. # Invoked by `ssr mackup unlink` (interactive confirm). ssr::mackup::link_uninstall() { ssr::mackup::ensure_installed ssr::log "mackup link uninstall (revert symlinks → local files)" mackup -f link uninstall }