Oliver Surke 894062adcd refactor: one ~/.mackup/ssr-<name>.cfg per dot-dir instead of combined file
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>
2026-05-12 10:50:42 +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%