Files
System-Sync-Restore/README.md
T
Oliver Surke 51fb589ef2 fix(mackup): safer defaults + recovery after broken symlink sync
- Default-off SSR_MACKUP_PRESYMLINK for DynamicProfiles pre-symlink (was
  risking empty/wrong iCloud trees and breaking iTerm2).
- Remove incorrect skip entries: built-in cfgs use different paths than
  ~/.zsh, ~/.vscode*, ~/.iterm2 — skipping hid real overlap from scan only
  but the presymlink change was the main footgun.
- Add `ssr mackup unlink` → `mackup -f link uninstall` to copy configs back
  from Mackup storage and drop symlinks (official undo).
- Docs: ssr.conf.example, README sync/restore wording, completions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 20:09:23 +02:00

345 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 link install` to the configured cloud directory (symlink-based).
- **Restore** (`ssr restore`): installs Homebrew, replays the `Brewfile`, runs `mackup link`, 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
```bash
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:
```bash
export PATH="$HOME/.local/bin:$PATH"
```
---
## Usage
```text
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
```bash
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 feed**`SUFeedURL` 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`:
```bash
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
```bash
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:
```bash
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:
```bash
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`](https://github.com/scop/bash-completion) 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
```bash
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:
```bash
tail -f ~/.local/state/ssr/logs/sync.err.log
```
Show launchd-level details:
```bash
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`:
```bash
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
```bash
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](LICENSE).