Oliver Surke 1d18dd6a48 fix: prevent brew bundle hanging on large cask installs
HOMEBREW_NO_QUARANTINE=1 — skips the post-install xattr quarantine walk
  that Gatekeeper runs on every binary in the app bundle. For large apps
  like WhatsApp (237 MB, many nested binaries) this walk blocks for several
  minutes. Apps will show the standard one-time Gatekeeper dialog instead.

HOMEBREW_NO_INSTALL_CLEANUP=1 — skips per-package cache cleanup after each
  install. A single brew cleanup already runs in ssr-sync afterwards.

mdimport — now runs in background (&) so Spotlight indexing never blocks
  the restore flow.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 11:52:24 +02:00
2026-05-11 15:05:17 +02:00
2026-05-11 15:05:17 +02:00
2026-05-11 15:05:17 +02:00
2026-05-11 15:05:17 +02:00
2026-05-11 15:05:17 +02:00

ssr — System Sync & Restore for macOS

Reproducible macOS setup in one command. ssr snapshots your Homebrew bundle and application configs to a cloud drive on a schedule, and can rebuild a fresh Mac from that snapshot.

  • Backup (ssr sync): dumps Brewfile and runs mackup backup to the configured cloud directory.
  • Restore (ssr restore): installs Homebrew, replays the Brewfile, restores mackup configs, applies macOS defaults.
  • Scheduled sync: native macOS launchd agent (daily by default).
  • Cloud storage: iCloud Drive, Dropbox, Google Drive, OneDrive, or any custom path — selectable via config.
  • No secrets: all state lives in your cloud drive; the repo only contains code and config templates.

Successor to the original system_sync.sh / system_restore.sh. Same intent, modernized tooling, modular, idempotent, scheduled by default.


Requirements

  • macOS (Apple Silicon or Intel)
  • Bash 3.2+ (preinstalled)
  • Network access for the initial Homebrew install
  • A cloud drive folder you control (iCloud Drive is the default)

Install

git clone https://git.surke.de/osurke/system-sync-restore.git ~/.ssr
cd ~/.ssr
./install.sh

install.sh will:

  1. Symlink bin/ssr into ~/.local/bin (override with SSR_PREFIX).
  2. Seed ~/.config/ssr/ssr.conf from config/ssr.conf.example (chmod 600).
  3. Optionally enable the scheduled daily sync.

If ~/.local/bin is not on your PATH, add it to your shell rc:

export PATH="$HOME/.local/bin:$PATH"

Usage

ssr install              First-time install: link binary, seed config, schedule sync
ssr sync                 One-shot backup → cloud
ssr restore [Brewfile]   Restore the machine; Brewfile defaults to the cloud copy
ssr update [--dry-run]   Pull latest ssr code, refresh launchd if needed
ssr schedule on|off      Enable/disable the daily launchd job
ssr config show|edit     Show or edit the active config
ssr completions install  Install zsh/bash/fish tab-completion
ssr status               Show config, cloud target, last sync, schedule state
ssr version              Print version

First backup

ssr sync

Output structure in the cloud drive:

<cloud root>/
├── BACKUP/
│   └── <hostname> - <hardware-uuid>/
│       ├── Brewfile
│       ├── Brewfile.bak
│       ├── unmanaged-apps.md     # apps installed manually (not brew/MAS)
│       └── macos-prefs/          # pref-domain snapshots
│           ├── com.apple.dock.plist
│           ├── com.apple.finder.plist
│           └── …
└── Mackup/
    └── … (managed by mackup)

During each sync the unmanaged-apps scan compares every .app bundle in /Applications (and ~/Applications) against:

  1. Mac App Store — bundle contains Contents/_MASReceipt/receipt
  2. Homebrew Cask — bundle filename matches the .app artifact of any installed cask

Anything not matched is written to unmanaged-apps.md (Markdown table, one row per app, with version, bundle id and a best-effort installer link) and printed to the sync log as a warning. Disable with SSR_SCAN_UNMANAGED_APPS=false.

Link selection for each app uses, in order:

  1. Sparkle feedSUFeedURL from the bundle's Info.plist → linked vendor homepage.
  2. Known vendor map — bundle-id prefix maps to the official download page (Adobe, Microsoft, Docker, Cursor, Anthropic, JetBrains, GitKraken, Signal, Zoom, etc.).
  3. Web search — DuckDuckGo query for <App name> macOS download.

macOS preferences snapshot

ssr sync also snapshots the live state of a curated set of macOS preference domains, writing one plist per domain to <backup_dir>/macos-prefs/. During ssr restore these are replayed after config/macos-defaults.sh, so the snapshot is the last writer.

Default domains:

Domain What it covers
com.apple.dock Dock position, app layout, hot corners
com.apple.finder View options, sidebar, path bar
com.apple.symbolichotkeys Global keyboard shortcuts (Spotlight, Mission Control, Screenshots…)
com.apple.HIToolbox Input sources / keyboard layouts
com.apple.spaces Spaces / Mission Control layout
com.apple.menuextra.clock Menu-bar clock format
NSGlobalDomain Global UI prefs, key-repeat speed, sound, language

Conflict resolution during restore

When a snapshot domain overlaps with a domain that config/macos-defaults.sh also writes to, ssr restore warns and asks interactively:

[WARN] Pref-snapshot overlaps with declarative defaults for 2 domain(s):
       com.apple.dock, com.apple.finder

Domain `com.apple.dock`:
  Snapshot from last sync (2026-05-11T17:30:00+0200) will OVERRIDE values
  set by macos-defaults.sh.
  [A]ccept snapshot / [r]eject (keep declarative)? [A/r]:

Default = Accept (press Enter). Reply r to keep the declarative value for that domain. In non-interactive contexts (launchd, CI) the decision falls back to SSR_PREFS_CONFLICT_DEFAULT (default accept). Set SSR_ASSUME_YES=true to skip all prompts.

Extend the domain set in ~/.config/ssr/ssr.conf:

SSR_MACOS_PREF_DOMAINS_EXTRA="com.apple.universalaccess com.apple.Spotlight"

Mackup overlap: if you also configure mackup to sync com.apple.dock or similar domains, two mechanisms will compete. Pick one.

Restore on a new machine

git clone https://git.surke.de/osurke/system-sync-restore.git ~/.ssr
cd ~/.ssr && ./install.sh
ssr restore

By default ssr restore picks the Brewfile from <cloud>/BACKUP/<host> - <uuid>/Brewfile. To restore from a different machine's snapshot, pass an explicit path:

ssr restore "$HOME/Library/Mobile Documents/com~apple~CloudDocs/BACKUP/oldhost - ABCD-1234/Brewfile"

Configuration

Config lives in ~/.config/ssr/ssr.conf (override with --config or $SSR_CONFIG). The file is sourced by bash but only recognised KEY=VALUE lines are accepted; anything else causes ssr to abort.

Key Default Purpose
SSR_CLOUD_PROVIDER icloud icloud|dropbox|googledrive|onedrive|custom
SSR_CLOUD_PATH Absolute path; only used when provider is custom
SSR_BACKUP_SUBDIR BACKUP Subfolder for per-host backups
SSR_MACKUP_SUBDIR Mackup Subfolder used by mackup
SSR_BREW_UPGRADE true Run brew upgrade during sync
SSR_BREW_CLEANUP true Run brew cleanup after sync
SSR_SCAN_UNMANAGED_APPS true List .app bundles not from brew/MAS during sync
SSR_APP_DIRS /Applications:~/Applications Colon-separated dirs scanned for unmanaged apps
SSR_SNAPSHOT_MACOS_PREFS true Snapshot macOS pref domains during sync
SSR_MACOS_PREF_DOMAINS (see below) Full override of snapshotted domains (space-separated)
SSR_MACOS_PREF_DOMAINS_EXTRA Extra domains appended to the default set
SSR_PREFS_CONFLICT_DEFAULT accept Non-TTY conflict resolution: accept or reject
SSR_ASSUME_YES false Auto-accept all pref-snapshot conflicts on restore
SSR_DISABLE_GATEKEEPER false Run spctl --global-disable during restore (security trade-off)
SSR_OPEN_CASKS false Open every installed cask after restore for first-run config
SSR_MACOS_DEFAULTS repo's config/macos-defaults.sh Override path to your own defaults script
SSR_LAUNCHD_LABEL de.surke.ssr.sync launchd job label
SSR_SCHEDULE_HOUR 9 Daily run hour (023, local time)
SSR_SCHEDULE_MINUTE 0 Daily run minute
SSR_PREFIX $HOME/.local/bin Install prefix for the ssr symlink
SSR_STATE_DIR $HOME/.local/state/ssr Internal state directory
SSR_LOG_DIR $HOME/.local/state/ssr/logs launchd log directory

Cloud provider paths resolved automatically

Provider Resolved root
icloud ~/Library/Mobile Documents/com~apple~CloudDocs
dropbox ~/Library/CloudStorage/Dropbox* (fallback ~/Dropbox)
googledrive ~/Library/CloudStorage/GoogleDrive-*
onedrive ~/Library/CloudStorage/OneDrive-*
custom Value of SSR_CLOUD_PATH

Shell completions

ssr install offers to install completions automatically. To do it manually later:

ssr completions install                # detects $SHELL
ssr completions install --shell all    # zsh + bash + fish
ssr completions status                 # show what's wired up
ssr completions uninstall --shell all

Targets (user-scoped, no sudo):

Shell Path
zsh ~/.zsh/completions/_ssr
bash ~/.local/share/bash-completion/completions/ssr
fish ~/.config/fish/completions/ssr.fish

For zsh, ensure ~/.zsh/completions is on $fpath (the installer prints the snippet for ~/.zshrc if it isn't already). For bash, you need bash-completion@2 sourced from your shell rc — Homebrew install path is also printed. Fish needs no extra setup.

After installation, open a fresh shell or exec $SHELL and ssr <TAB> lists the available commands.


Scheduled sync

ssr schedule on     # write & load ~/Library/LaunchAgents/<label>.plist
ssr schedule off    # unload & remove
ssr schedule status # check

Logs land in $SSR_LOG_DIR/sync.out.log and sync.err.log. Tail them:

tail -f ~/.local/state/ssr/logs/sync.err.log

Show launchd-level details:

launchctl list | grep ssr
log show --predicate 'process == "ssr"' --last 1h

Updating

Updates happen on three independent layers:

Tool code (this repo). Self-update via ssr update:

ssr update            # git pull --ff-only on the clone, then refresh side-effects
ssr update --dry-run  # show what would change, don't pull

ssr update will:

  • Refuse if the working tree is dirty or HEAD is detached.
  • Fast-forward only — never rewrites history.
  • Re-render and reload the launchd plist if launchd/ or bin/ssr* changed and the schedule is currently active.
  • Print added config keys when config/ssr.conf.example gained new options.

Backup data (cloud drive). Refreshed automatically by the scheduled ssr sync. The previous Brewfile is preserved as Brewfile.bak; deeper history is provided by the cloud provider's built-in version retention (iCloud Drive, Dropbox, …). Trigger an on-demand sync with ssr sync.

Dependencies (Homebrew, mackup). ssr sync runs brew upgrade and brew cleanup when SSR_BREW_UPGRADE=true / SSR_BREW_CLEANUP=true (both default true). Set them to false in ssr.conf to keep snapshots without auto-upgrading installed packages.


Project layout

.
├── install.sh              # Thin wrapper around bin/ssr-install
├── bin/
│   ├── ssr                 # CLI dispatcher
│   ├── ssr-install         # Local install + scheduling
│   ├── ssr-update          # Self-update (git pull + launchd refresh)
│   ├── ssr-sync            # Backup to cloud
│   ├── ssr-restore         # Rebuild a Mac from the backup
│   ├── ssr-schedule        # launchd on/off/status
│   ├── ssr-config          # show/edit/path
│   ├── ssr-completions     # install zsh/bash/fish completions
│   └── ssr-status          # state summary
├── lib/
│   ├── common.sh           # Logging, validation, config loader
│   ├── cloud.sh            # Provider abstraction
│   ├── brew.sh             # Homebrew install + Brewfile ops
│   ├── mackup.sh           # Mackup install + backup/restore
│   ├── apps.sh             # Detect unmanaged .app bundles (not brew/MAS)
│   ├── macos.sh            # macOS-specific actions (Rosetta, defaults, etc.)
│   └── macos-prefs.sh      # Snapshot/restore macOS pref domains
├── config/
│   ├── ssr.conf.example    # Reference user config
│   └── macos-defaults.sh   # Default macOS preferences
├── completions/
│   ├── _ssr                # zsh
│   ├── ssr.bash            # bash
│   └── ssr.fish            # fish
├── launchd/
│   └── de.surke.ssr.sync.plist  # Template for the scheduled agent
├── VERSION
├── LICENSE
└── README.md

All dependencies are inside the repository; the only external requirements are macOS itself and curl (for the one-time Homebrew bootstrap).


Security

  • No secrets in this repo. All cloud data lives in the user's cloud drive.
  • The config file is sourced but validated: only # comments and KEY=VALUE lines are allowed. Foreign content aborts execution.
  • chmod 600 is applied to ssr.conf and the rendered launchd plist.
  • SSR_DISABLE_GATEKEEPER=true is opt-in only; off by default.
  • No remote pipes (curl … | bash) except Homebrew's own bootstrap during install.

Uninstall

ssr schedule off
rm -f ~/.local/bin/ssr
rm -rf ~/.config/ssr ~/.local/state/ssr

The cloud data is left untouched.


Migration from the legacy scripts

The old system_sync.sh / system_restore.sh map onto the new commands:

Legacy New
system_sync.sh ssr sync
system_restore.sh <Brewfile> ssr restore <Brewfile>
Manual cron / Lingon entry ssr schedule on
Hard-coded iCloud path SSR_CLOUD_PROVIDER in ssr.conf
Inline .macos block config/macos-defaults.sh (overridable)

License

MIT. See LICENSE.

S
Description
No description provided
Readme MIT 411 KiB
Languages
Shell 100%