From 634487b9d88c00c0697d579be13c215bb8fc7f29 Mon Sep 17 00:00:00 2001 From: "Oliver Surke (Gitea)" Date: Tue, 12 May 2026 09:39:48 +0200 Subject: [PATCH] feat: restore state/resume/report + single Spotlight index after mas installs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- bin/ssr-restore | 74 ++++++++++++++----- bin/ssr-status | 172 ++++++++++++++++++++++++++++++++++--------- lib/brew.sh | 16 ++-- lib/restore-state.sh | 144 ++++++++++++++++++++++++++++++++++++ 4 files changed, 348 insertions(+), 58 deletions(-) create mode 100644 lib/restore-state.sh diff --git a/bin/ssr-restore b/bin/ssr-restore index 233619d..917bc09 100755 --- a/bin/ssr-restore +++ b/bin/ssr-restore @@ -1,5 +1,9 @@ #!/usr/bin/env bash # 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 SSR_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -15,24 +19,42 @@ source "$SSR_ROOT/lib/mackup.sh" source "$SSR_ROOT/lib/macos.sh" # shellcheck source=../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::load_config usage() { cat </BACKUP/ - /Brewfile 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 if [[ -z "$_cloud_dir" || ! -d "$_cloud_dir" ]]; then ssr::warn "Cloud directory not yet accessible: ${_cloud_dir:-}" @@ -45,28 +67,44 @@ ssr::cloud::ensure_available backup_dir="$(ssr::cloud::backup_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" ssr::log "Using Brewfile: $brewfile" -ssr::macos::disable_gatekeeper -ssr::macos::install_rosetta +# Initialise state (archives previous state file if present). +ssr::rs::init "$brewfile" -ssr::brew::ensure_installed -ssr::brew::update -ssr::brew::restore "$brewfile" +# --- Step helpers ------------------------------------------------------------ -ssr::mackup::ensure_installed -ssr::mackup::configure "$mackup_dir" -ssr::mackup::restore +# Groups mackup install + configure + restore into a single resumable step. +_ssr_mackup_restore_step() { + ssr::mackup::ensure_installed + ssr::mackup::configure "$mackup_dir" + ssr::mackup::restore +} -ssr::macos::open_installed_casks -ssr::macos::apply_defaults +# --- Steps ------------------------------------------------------------------- + +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 - 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 +# 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" diff --git a/bin/ssr-status b/bin/ssr-status index 5abf09c..fdff8bc 100755 --- a/bin/ssr-status +++ b/bin/ssr-status @@ -1,5 +1,10 @@ #!/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 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" # shellcheck source=../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 -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" -echo " config: $cfg $([[ -f "$cfg" ]] && echo '(present)' || echo '(missing)')" -echo " provider: $provider" +# --------------------------------------------------------------------------- +# General status +# --------------------------------------------------------------------------- +_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 - echo " cloud dir: $cloud_dir $([[ -d "$cloud_dir" ]] && echo '(ok)' || echo '(unreachable)')" - echo " backup dir: $(ssr::cloud::backup_dir 2>/dev/null || echo 'n/a')" -fi + cfg="${SSR_CONFIG:-$HOME/.config/ssr/ssr.conf}" + provider="${SSR_CLOUD_PROVIDER:-icloud}" + state_dir="${SSR_STATE_DIR:-$HOME/.local/state/ssr}" -state_dir="${SSR_STATE_DIR:-$HOME/.local/state/ssr}" -if [[ -f "$state_dir/last-sync" ]]; then - echo " last sync: $(cat "$state_dir/last-sync")" -else - echo " last sync: never" -fi + printf 'ssr status\n' + printf ' repo: %s\n' "$SSR_ROOT" + printf ' config: %s %s\n' "$cfg" "$([[ -f "$cfg" ]] && echo '(present)' || echo '(missing)')" + printf ' provider: %s\n' "$provider" -# Surface unmanaged-app count and pref-snapshot summary from the last sync. -if backup_dir="$(ssr::cloud::backup_dir 2>/dev/null)"; then - if [[ -f "$backup_dir/unmanaged-apps.md" ]]; then - n="$(grep -cE '^\| \[' "$backup_dir/unmanaged-apps.md" 2>/dev/null || echo 0)" - echo " unmanaged: $n app(s) (see $backup_dir/unmanaged-apps.md)" + if cloud_dir="$(ssr::cloud::resolve_dir 2>/dev/null)"; then + printf ' cloud dir: %s %s\n' "$cloud_dir" \ + "$([[ -d "$cloud_dir" ]] && echo '(ok)' || echo '(unreachable)')" + printf ' backup dir: %s\n' "$(ssr::cloud::backup_dir 2>/dev/null || echo 'n/a')" 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}" -service_target="gui/$(id -u)/${label}" -if launchctl print "$service_target" >/dev/null 2>&1 \ - || launchctl list "$label" >/dev/null 2>&1; then - echo " schedule: active ($label)" -else - echo " schedule: inactive" -fi + if [[ -f "$state_dir/last-sync" ]]; then + printf ' last sync: %s\n' "$(cat "$state_dir/last-sync")" + else + printf ' last sync: never\n' + fi + + # Unmanaged apps + pref snapshot from last sync. + 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 diff --git a/lib/brew.sh b/lib/brew.sh index 227cfee..e30a2a4 100644 --- a/lib/brew.sh +++ b/lib/brew.sh @@ -66,12 +66,18 @@ ssr::brew::restore() { local brewfile="$1" [[ -f "$brewfile" ]] || ssr::die "Brewfile not found: $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 - # of ssr restore (mackup, defaults, prefs) still runs. The user can rerun - # `brew bundle install --file="$brewfile"` manually for failed entries. + + # Suppress per-app Spotlight indexing: mas would otherwise run mdimport + # after every single App Store installation, which adds minutes of latency + # spread across the run. We do one batch mdimport after bundle completes. 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 ssr::warn "brew bundle install finished with errors (rc=$rc) — some packages were not installed." ssr::warn "Rerun manually: brew bundle install --file=\"$brewfile\"" diff --git a/lib/restore-state.sh b/lib/restore-state.sh new file mode 100644 index 0000000..4bc811b --- /dev/null +++ b/lib/restore-state.sh @@ -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 [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 +}