feat: restore state/resume/report + single Spotlight index after mas installs

lib/brew.sh:
  Set MAS_NO_AUTO_INDEX=1 before brew bundle install to suppress the
  per-app mdimport call that mas otherwise runs after each App Store
  installation. Run one mdimport /Applications batch pass afterwards.

lib/restore-state.sh (new):
  Lightweight key=value state file (~/.local/state/ssr/restore-state).
  ssr::rs::init / ssr::rs::run / ssr::rs::set/get / ssr::rs::error /
  ssr::rs::report — tracks step outcomes and accumulated errors.

bin/ssr-restore:
  --resume flag: skip steps already marked ok in state file.
  Each step (brew_install, brew_restore, mackup_restore, macos_defaults,
  prefs_import) wrapped in ssr::rs::run; errors accumulate without abort.
  Formatted report printed at end of every run.

bin/ssr-status:
  ssr status restore — full restore step report + error log
  ssr status backup  — Brewfile lines, unmanaged apps, prefs snapshot
  ssr status         — general view now shows one-line last-restore
                       summary and hint to run 'ssr status restore'

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-12 09:39:48 +02:00
parent cf7f61e95d
commit 634487b9d8
4 changed files with 348 additions and 58 deletions
+56 -18
View File
@@ -1,5 +1,9 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# ssr-restore — restore brew packages, mackup data, macOS defaults. # ssr-restore — restore brew packages, mackup data, macOS defaults.
#
# Flags:
# --resume Skip steps already marked 'ok' in the previous run's state.
# -h|--help Show usage.
set -euo pipefail set -euo pipefail
SSR_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" SSR_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
@@ -15,24 +19,42 @@ source "$SSR_ROOT/lib/mackup.sh"
source "$SSR_ROOT/lib/macos.sh" source "$SSR_ROOT/lib/macos.sh"
# shellcheck source=../lib/macos-prefs.sh # shellcheck source=../lib/macos-prefs.sh
source "$SSR_ROOT/lib/macos-prefs.sh" source "$SSR_ROOT/lib/macos-prefs.sh"
# shellcheck source=../lib/restore-state.sh
source "$SSR_ROOT/lib/restore-state.sh"
ssr::require_macos ssr::require_macos
ssr::load_config ssr::load_config
usage() { usage() {
cat <<EOF cat <<EOF
Usage: ssr restore [BREWFILE] Usage: ssr restore [--resume] [BREWFILE]
Restores Homebrew packages, Mackup dotfiles and macOS settings.
--resume Skip steps already completed in a previous (aborted) run.
State is read from: \$SSR_STATE_DIR/restore-state
If BREWFILE is omitted, the latest cloud backup is used: If BREWFILE is omitted, the latest cloud backup is used:
<cloud>/BACKUP/<host> - <uuid>/Brewfile <cloud>/BACKUP/<host> - <uuid>/Brewfile
EOF EOF
} }
[[ "${1:-}" == "-h" || "${1:-}" == "--help" ]] && { usage; exit 0; } # --- Argument parsing --------------------------------------------------------
SSR_RESTORE_RESUME=false
BREWFILE_ARG=""
while [[ $# -gt 0 ]]; do
case "$1" in
--resume) SSR_RESTORE_RESUME=true; shift ;;
-h|--help) usage; exit 0 ;;
-*) ssr::die "Unknown flag: $1" ;;
*) BREWFILE_ARG="$1"; shift ;;
esac
done
export SSR_RESTORE_RESUME
ssr::log "ssr restore starting" # --- Cloud + Brewfile --------------------------------------------------------
ssr::log "ssr restore starting${SSR_RESTORE_RESUME:+ (--resume mode)}"
# Resolve cloud dir early so we can check availability before prompting.
_cloud_dir="$(ssr::cloud::resolve_dir 2>/dev/null)" || true _cloud_dir="$(ssr::cloud::resolve_dir 2>/dev/null)" || true
if [[ -z "$_cloud_dir" || ! -d "$_cloud_dir" ]]; then if [[ -z "$_cloud_dir" || ! -d "$_cloud_dir" ]]; then
ssr::warn "Cloud directory not yet accessible: ${_cloud_dir:-<unknown>}" ssr::warn "Cloud directory not yet accessible: ${_cloud_dir:-<unknown>}"
@@ -45,28 +67,44 @@ ssr::cloud::ensure_available
backup_dir="$(ssr::cloud::backup_dir)" backup_dir="$(ssr::cloud::backup_dir)"
mackup_dir="$(ssr::cloud::mackup_dir)" mackup_dir="$(ssr::cloud::mackup_dir)"
brewfile="${1:-$backup_dir/Brewfile}" brewfile="${BREWFILE_ARG:-$backup_dir/Brewfile}"
[[ -f "$brewfile" ]] || ssr::die "Brewfile not found: $brewfile" [[ -f "$brewfile" ]] || ssr::die "Brewfile not found: $brewfile"
ssr::log "Using Brewfile: $brewfile" ssr::log "Using Brewfile: $brewfile"
ssr::macos::disable_gatekeeper # Initialise state (archives previous state file if present).
ssr::macos::install_rosetta ssr::rs::init "$brewfile"
ssr::brew::ensure_installed # --- Step helpers ------------------------------------------------------------
ssr::brew::update
ssr::brew::restore "$brewfile"
ssr::mackup::ensure_installed # Groups mackup install + configure + restore into a single resumable step.
ssr::mackup::configure "$mackup_dir" _ssr_mackup_restore_step() {
ssr::mackup::restore ssr::mackup::ensure_installed
ssr::mackup::configure "$mackup_dir"
ssr::mackup::restore
}
ssr::macos::open_installed_casks # --- Steps -------------------------------------------------------------------
ssr::macos::apply_defaults
ssr::rs::run brew_install "Homebrew install" ssr::brew::ensure_installed
ssr::rs::run brew_restore "Brew bundle install" ssr::brew::restore "$brewfile"
ssr::rs::run mackup_restore "Mackup restore" _ssr_mackup_restore_step
ssr::rs::run macos_defaults "macOS defaults" ssr::macos::apply_defaults
# Import the macOS pref snapshot AFTER the declarative defaults so the
# snapshot is the last writer (wins for accepted domains).
if [[ "${SSR_SNAPSHOT_MACOS_PREFS:-true}" == "true" ]]; then if [[ "${SSR_SNAPSHOT_MACOS_PREFS:-true}" == "true" ]]; then
ssr::macos::prefs_import "$backup_dir" ssr::rs::run prefs_import "Prefs snapshot import" ssr::macos::prefs_import "$backup_dir"
else
ssr::rs::set "step_prefs_import" "skipped"
fi fi
# Optional: open installed cask apps for first-run config (not tracked as a
# resumable step — it's side-effect-only and always safe to re-run).
ssr::macos::open_installed_casks
# Mark overall completion time.
ssr::rs::set "finished" "$(ssr::ts)"
# --- Final report ------------------------------------------------------------
ssr::rs::report
ssr::ok "ssr restore completed" ssr::ok "ssr restore completed"
+137 -35
View File
@@ -1,5 +1,10 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# ssr-status — print current state: config, cloud target, last sync, schedule. # ssr-status — print current state, or a detailed restore/backup report.
#
# Usage:
# ssr status General status (cloud, last sync, schedule)
# ssr status restore Detailed report of the last restore run
# ssr status backup Summary of the last backup (sync) run
set -euo pipefail set -euo pipefail
SSR_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" SSR_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
@@ -7,46 +12,143 @@ SSR_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
source "$SSR_ROOT/lib/common.sh" source "$SSR_ROOT/lib/common.sh"
# shellcheck source=../lib/cloud.sh # shellcheck source=../lib/cloud.sh
source "$SSR_ROOT/lib/cloud.sh" source "$SSR_ROOT/lib/cloud.sh"
# shellcheck source=../lib/restore-state.sh
source "$SSR_ROOT/lib/restore-state.sh"
ssr::load_config ssr::load_config
cfg="${SSR_CONFIG:-$HOME/.config/ssr/ssr.conf}" # ---------------------------------------------------------------------------
provider="${SSR_CLOUD_PROVIDER:-icloud}" # Sub-command dispatch
# ---------------------------------------------------------------------------
case "${1:-}" in
restore)
ssr::rs::report
exit 0
;;
backup|sync)
_status_backup
exit 0
;;
""|general)
# fall through to general status below
;;
*)
printf 'Usage: ssr status [restore|backup]\n'
exit 1
;;
esac
echo "ssr status" # ---------------------------------------------------------------------------
echo " repo: $SSR_ROOT" # General status
echo " config: $cfg $([[ -f "$cfg" ]] && echo '(present)' || echo '(missing)')" # ---------------------------------------------------------------------------
echo " provider: $provider" _status_general() {
local cfg provider cloud_dir backup_dir state_dir label service_target
if cloud_dir="$(ssr::cloud::resolve_dir 2>/dev/null)"; then cfg="${SSR_CONFIG:-$HOME/.config/ssr/ssr.conf}"
echo " cloud dir: $cloud_dir $([[ -d "$cloud_dir" ]] && echo '(ok)' || echo '(unreachable)')" provider="${SSR_CLOUD_PROVIDER:-icloud}"
echo " backup dir: $(ssr::cloud::backup_dir 2>/dev/null || echo 'n/a')" state_dir="${SSR_STATE_DIR:-$HOME/.local/state/ssr}"
fi
state_dir="${SSR_STATE_DIR:-$HOME/.local/state/ssr}" printf 'ssr status\n'
if [[ -f "$state_dir/last-sync" ]]; then printf ' repo: %s\n' "$SSR_ROOT"
echo " last sync: $(cat "$state_dir/last-sync")" printf ' config: %s %s\n' "$cfg" "$([[ -f "$cfg" ]] && echo '(present)' || echo '(missing)')"
else printf ' provider: %s\n' "$provider"
echo " last sync: never"
fi
# Surface unmanaged-app count and pref-snapshot summary from the last sync. if cloud_dir="$(ssr::cloud::resolve_dir 2>/dev/null)"; then
if backup_dir="$(ssr::cloud::backup_dir 2>/dev/null)"; then printf ' cloud dir: %s %s\n' "$cloud_dir" \
if [[ -f "$backup_dir/unmanaged-apps.md" ]]; then "$([[ -d "$cloud_dir" ]] && echo '(ok)' || echo '(unreachable)')"
n="$(grep -cE '^\| \[' "$backup_dir/unmanaged-apps.md" 2>/dev/null || echo 0)" printf ' backup dir: %s\n' "$(ssr::cloud::backup_dir 2>/dev/null || echo 'n/a')"
echo " unmanaged: $n app(s) (see $backup_dir/unmanaged-apps.md)"
fi fi
if [[ -d "$backup_dir/macos-prefs" ]]; then
n="$(find "$backup_dir/macos-prefs" -name '*.plist' 2>/dev/null | wc -l | tr -d ' ')"
echo " prefs snap: $n domain(s) (see $backup_dir/macos-prefs/)"
fi
fi
label="${SSR_LAUNCHD_LABEL:-de.surke.ssr.sync}" if [[ -f "$state_dir/last-sync" ]]; then
service_target="gui/$(id -u)/${label}" printf ' last sync: %s\n' "$(cat "$state_dir/last-sync")"
if launchctl print "$service_target" >/dev/null 2>&1 \ else
|| launchctl list "$label" >/dev/null 2>&1; then printf ' last sync: never\n'
echo " schedule: active ($label)" fi
else
echo " schedule: inactive" # Unmanaged apps + pref snapshot from last sync.
fi if backup_dir="$(ssr::cloud::backup_dir 2>/dev/null)"; then
if [[ -f "$backup_dir/unmanaged-apps.md" ]]; then
local n
n="$(grep -cE '^\| \[' "$backup_dir/unmanaged-apps.md" 2>/dev/null || echo 0)"
printf ' unmanaged: %s app(s) → %s\n' "$n" "$backup_dir/unmanaged-apps.md"
fi
if [[ -d "$backup_dir/macos-prefs" ]]; then
local n
n="$(find "$backup_dir/macos-prefs" -name '*.plist' 2>/dev/null | wc -l | tr -d ' ')"
printf ' prefs snap: %s domain(s) → %s/macos-prefs/\n' "$n" "$backup_dir"
fi
fi
# Last restore summary (one line).
local rs_state; rs_state="$(_ssr_rs_state_file)"
if [[ -f "$rs_state" ]]; then
local rs_started rs_finished rs_warns
rs_started="$(grep '^started=' "$rs_state" | cut -d= -f2-)"
rs_finished="$(grep '^finished=' "$rs_state" | cut -d= -f2-)"
rs_warns="$(grep -cE '^step_[^=]+=(warn|fail)' "$rs_state" 2>/dev/null || echo 0)"
printf ' last restore: %s' "${rs_started:-(unknown)}"
[[ -n "$rs_finished" ]] && printf ' → %s' "$rs_finished"
[[ "$rs_warns" -gt 0 ]] && printf ' (%s warning(s))' "$rs_warns" \
|| printf ' (ok)'
printf '\n'
printf ' (run "ssr status restore" for full report)\n'
fi
# Schedule.
label="${SSR_LAUNCHD_LABEL:-de.surke.ssr.sync}"
service_target="gui/$(id -u)/${label}"
if launchctl print "$service_target" >/dev/null 2>&1 \
|| launchctl list "$label" >/dev/null 2>&1; then
printf ' schedule: active (%s)\n' "$label"
else
printf ' schedule: inactive\n'
fi
}
# ---------------------------------------------------------------------------
# Backup / sync report
# ---------------------------------------------------------------------------
_status_backup() {
local state_dir backup_dir
state_dir="${SSR_STATE_DIR:-$HOME/.local/state/ssr}"
printf '\n%s\n' "── Backup (Sync) Report ────────────────────────"
if [[ -f "$state_dir/last-sync" ]]; then
printf ' last sync : %s\n' "$(cat "$state_dir/last-sync")"
else
printf ' last sync : never\n'
fi
if backup_dir="$(ssr::cloud::backup_dir 2>/dev/null)" && [[ -d "$backup_dir" ]]; then
printf ' backup dir: %s\n' "$backup_dir"
if [[ -f "$backup_dir/Brewfile" ]]; then
local lines
lines="$(wc -l < "$backup_dir/Brewfile" | tr -d ' ')"
local mtime
mtime="$(date -r "$backup_dir/Brewfile" '+%Y-%m-%dT%H:%M:%S%z' 2>/dev/null || echo '?')"
printf ' Brewfile : %s lines, modified %s\n' "$lines" "$mtime"
else
printf ' Brewfile : not found\n'
fi
if [[ -f "$backup_dir/unmanaged-apps.md" ]]; then
local n
n="$(grep -cE '^\| \[' "$backup_dir/unmanaged-apps.md" 2>/dev/null || echo 0)"
printf ' unmanaged : %s app(s) → %s\n' "$n" "$backup_dir/unmanaged-apps.md"
fi
if [[ -d "$backup_dir/macos-prefs" ]]; then
local n
n="$(find "$backup_dir/macos-prefs" -name '*.plist' 2>/dev/null | wc -l | tr -d ' ')"
printf ' prefs snap: %s domain(s)\n' "$n"
fi
else
printf ' (backup directory not accessible)\n'
fi
printf '%s\n\n' "────────────────────────────────────────────────"
}
_status_general
+11 -5
View File
@@ -66,12 +66,18 @@ ssr::brew::restore() {
local brewfile="$1" local brewfile="$1"
[[ -f "$brewfile" ]] || ssr::die "Brewfile not found: $brewfile" [[ -f "$brewfile" ]] || ssr::die "Brewfile not found: $brewfile"
ssr::log "brew bundle install ← $brewfile" ssr::log "brew bundle install ← $brewfile"
# brew bundle exits non-zero when individual packages fail (network errors,
# missing tap, cask version mismatch, etc.). We soft-fail here so the rest # Suppress per-app Spotlight indexing: mas would otherwise run mdimport
# of ssr restore (mackup, defaults, prefs) still runs. The user can rerun # after every single App Store installation, which adds minutes of latency
# `brew bundle install --file="$brewfile"` manually for failed entries. # spread across the run. We do one batch mdimport after bundle completes.
local rc=0 local rc=0
brew bundle install --file="$brewfile" --verbose || rc=$? MAS_NO_AUTO_INDEX=1 brew bundle install --file="$brewfile" --verbose || rc=$?
# Single batch Spotlight index of /Applications after all installs.
ssr::log "Indexing /Applications in Spotlight (one-time batch after all installs)"
mdimport /Applications 2>/dev/null || true
[[ -d "$HOME/Applications" ]] && mdimport "$HOME/Applications" 2>/dev/null || true
if [[ $rc -ne 0 ]]; then if [[ $rc -ne 0 ]]; then
ssr::warn "brew bundle install finished with errors (rc=$rc) — some packages were not installed." ssr::warn "brew bundle install finished with errors (rc=$rc) — some packages were not installed."
ssr::warn "Rerun manually: brew bundle install --file=\"$brewfile\"" ssr::warn "Rerun manually: brew bundle install --file=\"$brewfile\""
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env bash
# lib/restore-state.sh — tracks restore step outcomes and accumulated errors.
#
# State file: $SSR_STATE_DIR/restore-state (key=value, one per line)
# Error log: $SSR_STATE_DIR/restore-errors.log
#
# Step values: ok | warn | fail | skipped | pending
_ssr_rs_state_file() {
printf '%s/restore-state' "${SSR_STATE_DIR:-$HOME/.local/state/ssr}"
}
_ssr_rs_error_file() {
printf '%s/restore-errors.log' "${SSR_STATE_DIR:-$HOME/.local/state/ssr}"
}
# Initialise (or reset) the state file for a new restore run.
# $1 = brewfile path used for this run.
ssr::rs::init() {
local brewfile="${1:-}"
local state_dir="${SSR_STATE_DIR:-$HOME/.local/state/ssr}"
ssr::ensure_dir "$state_dir"
# Archive previous state so `ssr status restore` can show it even after a
# new run starts.
local sf; sf="$(_ssr_rs_state_file)"
[[ -f "$sf" ]] && cp -f "$sf" "${sf}.prev" 2>/dev/null || true
# Truncate error log for the new run.
: > "$(_ssr_rs_error_file)"
# Write fresh state header.
printf 'started=%s\nbrewfile=%s\n' "$(ssr::ts)" "$brewfile" > "$sf"
}
# Set a key in the state file (upsert).
ssr::rs::set() {
local key="$1" val="$2"
local sf; sf="$(_ssr_rs_state_file)"
local tmp; tmp="$(mktemp)"
grep -v "^${key}=" "$sf" 2>/dev/null > "$tmp" || true
printf '%s=%s\n' "$key" "$val" >> "$tmp"
mv -f "$tmp" "$sf"
}
# Get a value from the state file (empty string if absent).
ssr::rs::get() {
local key="$1"
grep "^${key}=" "$(_ssr_rs_state_file)" 2>/dev/null | cut -d= -f2- || true
}
# Append a timestamped error line to the error log.
ssr::rs::error() {
printf '[%s] %s\n' "$(ssr::ts)" "$1" >> "$(_ssr_rs_error_file)"
}
# Run a named restore step.
# Usage: ssr::rs::run <step_key> <description> <function> [args…]
#
# If --resume is active and the step is already 'ok', it is skipped.
# Exit code of the function is captured; non-zero sets step to 'warn' (not
# fatal) and logs the error. The outer script keeps running.
ssr::rs::run() {
local key="$1" desc="$2"; shift 2
local fn="$1"; shift
# Resume: skip steps that already completed successfully.
if [[ "${SSR_RESTORE_RESUME:-false}" == "true" ]]; then
local prev; prev="$(ssr::rs::get "step_${key}")"
if [[ "$prev" == "ok" ]]; then
ssr::log "SKIP [$desc] (already completed in previous run)"
return 0
fi
fi
ssr::log "STEP [$desc]"
ssr::rs::set "step_${key}" "running"
local rc=0
"$fn" "$@" || rc=$?
if [[ $rc -eq 0 ]]; then
ssr::rs::set "step_${key}" "ok"
else
ssr::rs::set "step_${key}" "warn:rc=$rc"
ssr::rs::error "[$desc] exited rc=$rc"
ssr::warn "[$desc] finished with errors (rc=$rc) — continuing restore"
fi
}
# Print a formatted restore report to stdout.
ssr::rs::report() {
local sf; sf="$(_ssr_rs_state_file)"
local ef; ef="$(_ssr_rs_error_file)"
if [[ ! -f "$sf" ]]; then
printf 'No restore state found (%s).\n' "$sf"
return 0
fi
local started brewfile
started="$(grep '^started=' "$sf" | cut -d= -f2-)"
brewfile="$(grep '^brewfile=' "$sf" | cut -d= -f2-)"
printf '\n%s\n' "── Restore Report ──────────────────────────────"
printf ' started : %s\n' "${started:-unknown}"
printf ' brewfile : %s\n' "${brewfile:-(unknown)}"
printf '\n Steps:\n'
local -a step_keys=(brew_install brew_restore mackup_restore macos_defaults prefs_import)
local -A step_labels=(
[brew_install]="Homebrew install"
[brew_restore]="Brew bundle install"
[mackup_restore]="Mackup restore"
[macos_defaults]="macOS defaults"
[prefs_import]="Prefs snapshot import"
)
local key val icon
for key in "${step_keys[@]}"; do
val="$(grep "^step_${key}=" "$sf" 2>/dev/null | cut -d= -f2- || true)"
case "$val" in
ok) icon="✓" ;;
skipped) icon="" ;;
warn*) icon="⚠" ;;
fail*) icon="✗" ;;
running) icon="…" ;;
*) icon="?" ;;
esac
printf ' %s %-28s %s\n' "$icon" "${step_labels[$key]}" "${val:-(not run)}"
done
if [[ -s "$ef" ]]; then
printf '\n Errors:\n'
sed 's/^/ /' "$ef"
else
printf '\n No errors recorded.\n'
fi
printf '%s\n\n' "────────────────────────────────────────────────"
# Resume hint if any step failed.
if grep -qE '^step_[^=]+=(warn|fail)' "$sf" 2>/dev/null; then
printf ' To retry failed steps: ssr restore --resume\n\n'
fi
}