In Mackup 0.10.2, `mackup backup` only copies files to cloud — it never
creates symlinks. Symlinks require `mackup link install` (sync) or
`mackup link` (restore). This is why no symlinks appeared in ~/ after
running ssr sync.
Changes:
- ssr::mackup::backup() → now calls `mackup -f link install`
Moves managed files to cloud and creates symlinks in ~/
Naturally skips sockets and dangling symlinks via isfile/isdir guard,
so the pre-cleanup is now a light transient-dir purge only.
- ssr::mackup::restore() → now calls `mackup -f link`
Creates symlinks ~/file → cloud/file (files must already be in cloud).
Keeps files in iCloud for ongoing sync, unlike the copy-based restore.
- Added Mackup 0.10.2 command-mapping comment to file header.
Co-authored-by: Cursor <cursoragent@cursor.com>
ssr mackup scan now only suggests NEW files — it never overwrites an
existing ssr-*.cfg. Once created, the file belongs to the user who
edits it directly in ~/.mackup/ to customise which paths are backed up.
- _ssr_mackup_write_cfg: returns early if file already exists
- scan: drops UPD logic; only shows NEW suggestions
- _ssr_mackup_resolve_paths: removed SSR_MACKUP_SELECTIVE_PATHS support
(ssr.conf config variable removed — edit the cfg files instead)
- Scan output: hints to edit files directly after creation
Co-authored-by: Cursor <cursoragent@cursor.com>
Docker Desktop checks that ~/.docker is a real directory and rejects it
if it is a symlink (Mackup's default when backing up a whole dir).
Solution: remove .docker from the skip list, add it to curated + selective.
Mackup will symlink only ~/.docker/config.json and ~/.docker/daemon.json —
the directory itself stays a real dir that Docker Desktop can manage.
Co-authored-by: Cursor <cursoragent@cursor.com>
Mackup with engine=file_system already uses symlinks for all backed-up
apps (/Users/osurke/.docker → <cloud>/Mackup/mackup/.docker). The copy PHASE
fails when the source dir contains sockets or dangling symlinks.
For dirs like .claude that have a volatile debug/ subdir, the clean fix
is to list only the stable sub-paths in the Mackup cfg rather than the
whole directory — debug/ is never touched.
_SSR_MACKUP_SELECTIVE: built-in overrides for .claude, .cursor, .codex,
.vscode, .factory — lists only config files (settings.json, CLAUDE.md,
skills, plugins, agents, …), skipping logs/, debug/, etc.
_ssr_mackup_resolve_paths(): resolves the [configuration_files] entries
for a given item using the selective table, falling back to the whole dir.
ssr mackup scan now shows '6 sub-path(s) of .claude/' in the plan
instead of '.claude/' to make the selective behaviour visible.
Configurable via SSR_MACKUP_SELECTIVE_PATHS in ssr.conf.
Co-authored-by: Cursor <cursoragent@cursor.com>
Instead of silently writing ~/.mackup/ssr-*.cfg on every sync, generation
is now an explicit opt-in command: ssr mackup scan
ssr mackup scan — dry-run: shows NEW/UPD/DEL changes, prompts before writing
ssr mackup list — list existing generated files
ssr mackup clean — remove all generated files (with confirmation)
ssr sync no longer calls generate_custom_cfg automatically.
Completions updated for zsh, bash, fish.
Co-authored-by: Cursor <cursoragent@cursor.com>
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>
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>
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>
Expanded .gitignore to include additional macOS and Visual Studio Code entries, improving the exclusion of unnecessary files. Added rules for iCloud files, local history, and built extensions, while retaining essential configuration files. Cleaned up legacy entries and ensured custom rules are preserved.
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>
- bin/ssr-update: fast-forward `git pull` on the clone with safety guards
(dirty tree, detached HEAD, divergent history all abort).
- Re-renders & reloads the launchd plist when launchd/ or bin/ssr* changed
AND the schedule is currently active, so the cron picks up any plist
template updates automatically.
- Reports VERSION delta, commit log, and new config keys introduced in
config/ssr.conf.example so users can opt-in via `ssr config edit`.
- `--dry-run` shows would-be changes without pulling or touching launchd.
- Wired into bin/ssr dispatcher + README updated (Updating section, layout
entry, command help line).
Co-authored-by: Cursor <cursoragent@cursor.com>