894062adcd
Each discovered dot-dir now gets its own Mackup app definition: .claude → ~/.mackup/ssr-claude.cfg .cursor → ~/.mackup/ssr-cursor.cfg .config/gh → ~/.mackup/ssr-config-gh.cfg … This matches Mackup's own convention, keeps definitions independent, and makes it easy to add/remove individual apps. The old combined ssr-custom.cfg is removed automatically on the next sync run. Files are written idempotently (skipped when content is unchanged). Co-authored-by: Cursor <cursoragent@cursor.com>
294 lines
12 KiB
Bash
294 lines
12 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"
|
|
}
|
|
|
|
# Generate one ~/.mackup/ssr-<name>.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 <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"
|
|
|
|
# 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
|
|
}
|