Previously ssr-custom cfg generation only checked whether the path was
already in the cloud backup directory. It did not consult Mackup's
~400 built-in app definitions, causing duplicates like ssr-claude.cfg
when Mackup already has a 'claude-code' entry covering .claude.
Changes:
- _ssr_mackup_declared_paths(): parses all built-in *.cfg files from
Mackup's Python package apps/ dir + hand-written ~/.mackup/*.cfg
(excluding our own ssr-*.cfg) into a sorted path set
- generate_custom_cfg now skips any path already in that set
- Stale ssr-*.cfg files that now duplicate a built-in are removed
automatically on the next sync run
Co-authored-by: Cursor <cursoragent@cursor.com>
shutil.copy fails on dangling symlinks with [Errno 2] No such file or
directory (e.g. ~/.claude/debug/latest → missing target).
Pre-backup cleanup now handles two cases:
- Unix sockets (-type s) → Errno 102
- Dangling links (-type l !-e) → Errno 2
Both are ephemeral runtime artefacts that cannot be meaningfully backed up.
Co-authored-by: Cursor <cursoragent@cursor.com>
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>
Mackup fails with shutil.Error [Errno 102] when it tries to copy Unix
socket files (e.g. iTerm2's iterm2-daemon-1.socket, sockets/secrets).
Sockets are ephemeral runtime artefacts — they cannot be meaningfully
backed up and are always recreated by the owning app on startup.
Purge all -type s files from $HOME before calling mackup backup.
Co-authored-by: Cursor <cursoragent@cursor.com>
Adds a simple config file (~/.config/ssr/extra-installers.txt) where
users can list installer URLs for apps not available on Homebrew or MAS
(e.g. EasyMCP Desktop, internal tools).
lib/installers.sh:
- Supports .dmg (mount → cp .app or run .pkg), .pkg, .zip
- Skips entries already present in /Applications
- Non-fatal: logs warnings and continues on failure
ssr sync: copies extra-installers.txt to <backup_dir> alongside Brewfile
ssr restore: new `extra_installers` resumable step runs after shell_deps
Format: optional-name <url> (# comments and blank lines ignored)
Example entry:
EasyMCP https://github.com/Adobe-AIFoundations/easymcp/.../EasyMCP-Desktop-3.0.3-arm64.dmg
Co-authored-by: Cursor <cursoragent@cursor.com>
After mackup restores dotfiles, ~/.zshrc may reference tools (Oh My Zsh,
nvm, pyenv, rustup, …) that haven't been installed yet, causing errors on
first shell login.
lib/shell.sh:
- Defines 13 built-in frameworks with detect + install commands
- Scans ~/.zshrc / ~/.bashrc etc. for references before installing
(avoids installing things the user doesn't actually use)
- SSR_SHELL_DEPS_EXTRA in ssr.conf for custom user additions
bin/ssr-restore:
- Adds `shell_deps` as a resumable step after mackup_restore
- Sources ~/.config/ssr/post-restore.sh if present (user hook)
config/post-restore.sh.example:
- Template with commented examples (OMZ plugins, npm globals, SSH keys)
Co-authored-by: Cursor <cursoragent@cursor.com>
Before each `ssr sync`, scan $HOME for dot-dirs/files not yet present
in the Mackup backup directory and write them into
~/.mackup/ssr-custom.cfg. This covers tools like .claude, .cursor,
.easymcp, .codex, .factory, and any future dot-dir without requiring
the user to write manual Mackup app definitions.
Curated list handles common developer tools; auto-discovery adds the
rest while skipping caches, credential dirs (.ssh, .gnupg, .aws,
.azure) and other volatile state.
Configurable via:
SSR_MACKUP_AUTODISCOVER=true (opt-out with false)
SSR_MACKUP_EXTRA_PATHS="..." (space-separated $HOME-relative paths)
Co-authored-by: Cursor <cursoragent@cursor.com>
lib/brew.sh:
Define _SSR_DEPRECATED_TAPS list (homebrew/core, /cask, /services,
/test-bot, /bundle, /cask-fonts, /cask-drivers, /cask-versions).
_ssr_brew_strip_deprecated_taps removes matching 'tap "…"' lines after
dump so they never cause "Tapping X has failed!" errors on restore.
lib/macos-prefs.sh:
Add com.apple.controlcenter, com.apple.systemuiserver and
com.apple.notificationcenterui to SSR_MACOS_PREF_DOMAINS_DEFAULT so
Control Centre module toggles, menu-bar extras order and Notification
Centre widgets are included in the pref snapshot on every sync.
Also add ControlCenter to the post-import service restart list.
lib/macos.sh:
Add ControlCenter to ssr::macos::restart_ui so the menu bar and
Control Centre panel pick up restored prefs immediately after mackup.
Kill cfprefsd first in all restart sequences to flush the pref cache.
Co-authored-by: Cursor <cursoragent@cursor.com>
Mackup restores com.apple.dock.plist and other UI preference files, but
the running Dock/Finder processes don't reload them automatically. Add
ssr::macos::restart_ui (killall cfprefsd Dock Finder SystemUIServer + 2s
settle) called immediately after the mackup restore step so the Dock
layout and Finder settings take effect without requiring a logout.
Co-authored-by: Cursor <cursoragent@cursor.com>
Homebrew cask installers call sudo internally for each package that needs
admin privileges. Without pre-authentication, macOS prompts for a password
on every such package across a long brew bundle run.
_ssr_sudo_keepalive_start:
Calls 'sudo -v' once (prompts user if not already cached), then spawns a
background loop that calls 'sudo -n true' every 60s to prevent the
default 5-minute credential cache from expiring mid-run.
_ssr_sudo_keepalive_stop:
Kills the keepalive loop after brew bundle completes.
If sudo is unavailable (non-admin user), a warning is shown and the run
continues — individual cask installers that need sudo will prompt as before.
Co-authored-by: Cursor <cursoragent@cursor.com>
lib/icloud.sh (new):
ssr::icloud::prefetch <dir> — spawn background open -g requests for
all .icloud stubs; returns immediately so other work can run in parallel.
ssr::icloud::wait <dir> [sec] — poll until stubs are gone or timeout
(default 180s). Logs progress every 5s.
ssr::icloud::keep_local <dir> — after sync, removes
com.apple.icloud.evictable xattr from every real file + updates
access-time (LRU delay), preventing iCloud from re-creating stubs
before the next run. Best-effort (no public pin API on macOS CLI).
bin/ssr-sync:
- prefetch Mackup folder immediately after cloud is available (before
brew update/upgrade) so downloads proceed in the background.
- ssr::icloud::wait called just before mackup backup.
- ssr::icloud::keep_local called after sync completes.
- SSR_ICLOUD_WAIT_TIMEOUT (default 180) and SSR_ICLOUD_KEEP_LOCAL
(default true) config toggles added.
bin/ssr-restore:
- prefetch Mackup folder right after cloud is validated, before the
brew bundle install step, maximising download time available.
config/ssr.conf.example:
Added SSR_ICLOUD_WAIT_TIMEOUT and SSR_ICLOUD_KEEP_LOCAL keys.
Co-authored-by: Cursor <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
- Pre-materialize iCloud stubs before restore (same as backup)
- Report stub count so user knows why it might be slow
- Background watchdog prints '… still running (Ns elapsed)' every 15s
so long iCloud download pauses don't look like a hang
- Report total elapsed time on completion
Co-authored-by: Cursor <cursoragent@cursor.com>
lib/brew.sh:
brew bundle exits non-zero when any single package fails (network error,
cask version mismatch, missing tap, etc.). Previously this killed the
entire ssr restore via set -euo pipefail before mackup / defaults / prefs
had a chance to run. Capture the exit code, emit a clear warning, and
continue. The user can rerun the manual command shown in the warning.
.gitignore:
Replace minimal gitignore with the comprehensive macOS + Cursor + VSCode
template generated by toptal gitignore.io. Preserve the original ssr-
specific custom rules (.local/, .config/, *.log, *.plist.local) in the
custom section at the bottom.
Co-authored-by: Cursor <cursoragent@cursor.com>
brctl download was removed in macOS Sonoma — the remaining subcommands are
diagnose/log/dump/status/accounts/quota/monitor. Replace the dead call with
a find loop that uses 'open -g <real-path>' to request per-file iCloud
download from the daemon, followed by a 1-second settle pause and the
existing .icloud stub deletion.
Also add -f to mackup restore for consistency with the backup call.
Co-authored-by: Cursor <cursoragent@cursor.com>
lib/brew.sh:
- Before running the Homebrew installer, check `sudo -n true`. If sudo
is not available, print a clear actionable error message with the
manual install command and exit instead of running NONINTERACTIVE=1
and crashing with "Need sudo access ... Administrator!" mid-way.
bin/ssr-restore:
- Drop the unconditional "Configure iCloud / cloud drive?" prompt.
Instead check whether the cloud directory is already mounted; if it
is, proceed silently (the common case on an already-configured Mac).
The prompt only appears when the directory is missing, i.e. iCloud
is not yet signed in or the drive is not mounted — with an
appropriate message explaining what to do.
Co-authored-by: Cursor <cursoragent@cursor.com>
Adds a round-trip mechanism for macOS system preferences that are not
covered by Homebrew or Mackup (Dock layout, Finder options, global
keyboard shortcuts, input sources, Spaces config, menu-bar clock,
NSGlobalDomain).
sync: `ssr sync` exports each configured domain with `defaults export`
and writes one plist per domain to <backup_dir>/macos-prefs/. Export is
soft-fail per domain so one bad domain doesn't abort the sync. Gated by
SSR_SNAPSHOT_MACOS_PREFS=true (default on).
restore: `ssr restore` replays the snapshot with `defaults import` after
the declarative config/macos-defaults.sh runs, so the snapshot is the
last writer and wins for accepted domains. Before importing, the restore
detects domains that both the snapshot and macos-defaults.sh touch and
prompts interactively:
Domain `com.apple.dock`:
Snapshot will OVERRIDE values set by macos-defaults.sh.
[A]ccept snapshot / [r]eject (keep declarative)? [A/r]:
Non-TTY (launchd, CI) falls back to SSR_PREFS_CONFLICT_DEFAULT (default
"accept"). SSR_ASSUME_YES=true skips all prompts. After import, Dock,
Finder, SystemUIServer and cfprefsd are restarted once.
New config keys: SSR_SNAPSHOT_MACOS_PREFS, SSR_MACOS_PREF_DOMAINS,
SSR_MACOS_PREF_DOMAINS_EXTRA, SSR_PREFS_CONFLICT_DEFAULT, SSR_ASSUME_YES.
ssr-status surfaces the snapshotted-domain count.
README documents the snapshot flow, default domain table, conflict UX,
and the five new config keys.
Co-authored-by: Cursor <cursoragent@cursor.com>
`ssr sync` now scans /Applications (and ~/Applications, configurable via
SSR_APP_DIRS) for top-level .app bundles that are neither Mac App Store
installs (Contents/_MASReceipt/receipt) nor Homebrew Cask installs, and
writes <backup_dir>/unmanaged-apps.md alongside the Brewfile. This closes
a long-standing gap in the restore story: such apps need manual reinstall
on a fresh machine, and were previously invisible to the sync workflow.
Cask detection runs fully offline against the local Caskroom and unions
four complementary strategies:
1. Caskroom walk for .app symlinks/bundles under
$(brew --caskroom)/<cask>/<version>/.
2. `app "Foo.app"` declarations parsed from .rb formula receipts.
3. `name "Foo"` field from .rb receipts (handles pkg-based casks like
Microsoft Edge, Teams, Zoom, Google Drive, etc., that never drop
a .app under the caskroom).
4. `"name":["Foo"]` field from modern .json receipts (multi-line aware).
For each unmanaged app the report includes version and bundle id from the
Info.plist plus a best-effort installer link, in preference order:
1. Sparkle SUFeedURL → derive the vendor homepage from the URL host.
2. Bundle-id prefix match against a curated vendor map (Adobe,
Microsoft, Docker, Cursor, Anthropic, OpenAI, JetBrains,
GitKraken, Signal, Zoom, Telegram, Slack, Synology, Jabra, ...).
3. DuckDuckGo search fallback (`<App name> macOS download`).
Toggles in ssr.conf.example: SSR_SCAN_UNMANAGED_APPS=true (default),
SSR_APP_DIRS=/Applications:~/Applications.
ssr-status gains a one-line summary referencing the report file.
Co-authored-by: Cursor <cursoragent@cursor.com>
- bin/ssr-schedule: use `launchctl bootstrap`/`bootout` (modern API) with
legacy `load -w` fallback. The previous `load -w`-only flow silently
no-op'd on macOS 10.15+ in some configurations, leaving `ssr status`
reporting `inactive` after a successful `ssr schedule on`.
- bin/ssr-status: query the service via `launchctl print`/`list <label>`
for consistent active/inactive detection.
- lib/mackup.sh: pre-materialize iCloud placeholders via `brctl download`
and retry `mackup -f backup` up to three times to dodge the recurring
`OSError [Errno 66] Directory not empty` race between mackup's rmtree
and iCloud's filesystem provider. Soft-fails so the rest of `ssr sync`
still runs even if mackup never settles; logs actionable hints.
Co-authored-by: Cursor <cursoragent@cursor.com>
macOS ships /bin/bash 3.2.57 which has no `${var,,}` parameter expansion.
Replace with a portable case statement matching y/Y. Behaviorally
identical (read -n 1 already constrains input to a single char, and the
original code only accepted 'y' after lowercasing, so 'y' and 'Y' are
the only accepting inputs).
Repo-wide audit for other bash 4+ features (case modification, mapfile,
nameref locals, ${var@op} transforms, coproc) returned clean.
Repro before fix:
$ ./install.sh
Enable daily scheduled sync (launchd)? [y/N]: y
lib/common.sh: line 52: ${reply,,}: bad substitution
Co-authored-by: Cursor <cursoragent@cursor.com>
In bash, strings cannot contain NUL bytes, so `$'\0'` expands to the empty
string. The pattern `*$'\0'*` collapsed to `**`, which matches every
non-empty value, so `validate_path` aborted on every real path with
"Path contains control chars".
- Drop the NUL check entirely (impossible to violate by construction).
- Use a single case statement for the newline and traversal checks; more
predictable than chained [[ ... && ... ]] glob comparisons on bash 3.2.
Verified: paths with spaces, dots in basenames (~/.config, ~/.local), and
the iCloud bundle directory all pass; empty paths, "..", and embedded
newlines still reject.
Repro before fix:
$ ./install.sh
[ERR] Path contains control chars
Co-authored-by: Cursor <cursoragent@cursor.com>