Root cause: Claude Code recreates ~/.claude/debug/latest (a dangling symlink to the current debug session) almost immediately after it is deleted. The old cleanup ran once before the retry loop, so any retry after a 2s sleep would hit a freshly recreated symlink. Fix: move _ssr_mackup_preclean() inside the retry loop so it runs immediately before each mackup attempt, leaving no window for apps to recreate problematic files. Also remove known transient subdirectories by path before the generic socket/dangling-link sweep: .claude/debug .cursor/logs .vscode/logs .npm/_logs Configurable via SSR_MACKUP_PRECLEAN_PATHS (space-separated, $HOME-relative). Co-authored-by: Cursor <cursoragent@cursor.com>
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): dumpsBrewfileand runsmackup backupto the configured cloud directory. - Restore (
ssr restore): installs Homebrew, replays theBrewfile, restoresmackupconfigs, applies macOS defaults. - Scheduled sync: native macOS
launchdagent (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:
- Symlink
bin/ssrinto~/.local/bin(override withSSR_PREFIX). - Seed
~/.config/ssr/ssr.conffromconfig/ssr.conf.example(chmod 600). - 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:
- Mac App Store — bundle contains
Contents/_MASReceipt/receipt - Homebrew Cask — bundle filename matches the
.appartifact 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:
- Sparkle feed —
SUFeedURLfrom the bundle'sInfo.plist→ linked vendor homepage. - Known vendor map — bundle-id prefix maps to the official download page (Adobe, Microsoft, Docker, Cursor, Anthropic, JetBrains, GitKraken, Signal, Zoom, etc.).
- 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.dockor 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 (0–23, 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/orbin/ssr*changed and the schedule is currently active. - Print added config keys when
config/ssr.conf.examplegained 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
# commentsandKEY=VALUElines are allowed. Foreign content aborts execution. chmod 600is applied tossr.confand the rendered launchd plist.SSR_DISABLE_GATEKEEPER=trueis 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.