Initial ssr repository

Convert the legacy system_sync.sh and system_restore.sh into a modular
shareable repository:

- bin/ssr CLI dispatcher with install/sync/restore/schedule/config/status
- lib/ split: common, cloud, brew, mackup, macos
- Cloud provider abstraction (icloud, dropbox, googledrive, onedrive, custom)
- launchd template for scheduled daily sync (renderable via sed)
- ssr.conf.example with whitelisted KEY=VALUE config loader
- macos-defaults.sh extracted from inline block, overridable
- install.sh bootstrap symlinks CLI into ~/.local/bin
- README with usage, config table, migration map, security notes
- Original scripts archived under legacy/ for reference

Fixes vs originals: set -euo pipefail, quoted vars, find -print0,
spctl --global-disable (was --master-disable), MACOS=".macos" bug,
zero-width char before ln, Apple Silicon brew shellenv, config injection
hardening.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-11 15:05:17 +02:00
commit 539a8fc28f
23 changed files with 1171 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
# macOS noise
.DS_Store
._*
.Spotlight-V100
.Trashes
# User-local state (never commit)
.local/
.config/
*.log
# Editor / IDE
.idea/
.vscode/
.cursor/
# User-rendered launchd plist (template lives in launchd/)
*.plist.local
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 ssr contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+220
View File
@@ -0,0 +1,220 @@
# 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
```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 schedule on|off Enable/disable the daily launchd job
ssr config show|edit Show or edit the active config
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
└── Mackup/
└── … (managed by mackup)
```
### 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_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` |
---
## 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
```
---
## Project layout
```
.
├── install.sh # Thin wrapper around bin/ssr-install
├── bin/
│ ├── ssr # CLI dispatcher
│ ├── ssr-install # Local install + scheduling
│ ├── 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-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
│ └── macos.sh # macOS-specific actions (Rosetta, defaults, etc.)
├── config/
│ ├── ssr.conf.example # Reference user config
│ └── macos-defaults.sh # Default macOS preferences
├── 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).
+1
View File
@@ -0,0 +1 @@
1.0.0
Executable
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
# ssr — System Sync & Restore CLI dispatcher.
# Resolves the repository root, sources libs, and forwards to a subcommand.
set -euo pipefail
# Resolve script directory even when invoked via a symlink (e.g. /usr/local/bin/ssr).
SOURCE="${BASH_SOURCE[0]}"
while [[ -L "$SOURCE" ]]; do
DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
SOURCE="$(readlink "$SOURCE")"
[[ "$SOURCE" != /* ]] && SOURCE="$DIR/$SOURCE"
done
SSR_BIN_DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
SSR_ROOT="$(cd "$SSR_BIN_DIR/.." && pwd)"
export SSR_ROOT SSR_BIN_DIR
# shellcheck source=../lib/common.sh
source "$SSR_ROOT/lib/common.sh"
usage() {
cat <<EOF
ssr — System Sync & Restore for macOS
Usage:
ssr <command> [options]
Commands:
install First-time install: links binary, seeds config, schedules sync.
sync Run a backup: brew bundle dump + mackup backup → cloud.
restore [Brewfile] Restore a machine: brew bundle + mackup restore + macOS defaults.
schedule on|off Enable/disable the scheduled launchd sync job.
config show|edit Print or edit the active config file.
status Show last sync time, cloud target, and scheduled state.
version Print version.
Options:
-c, --config PATH Use a specific config file (default: ~/.config/ssr/ssr.conf).
-h, --help Show this help.
Environment:
SSR_CONFIG Same as --config.
Docs: see README.md in the repository root.
EOF
}
# Parse global flags.
while [[ $# -gt 0 ]]; do
case "$1" in
-c|--config) export SSR_CONFIG="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
--) shift; break ;;
-*) ssr::die "Unknown global flag: $1 (use -h for help)" ;;
*) break ;;
esac
done
[[ $# -gt 0 ]] || { usage; exit 1; }
cmd="$1"; shift
case "$cmd" in
install|sync|restore|schedule|config|status)
target="$SSR_BIN_DIR/ssr-$cmd"
[[ -x "$target" ]] || ssr::die "Subcommand not executable: $target"
exec "$target" "$@"
;;
version|-v|--version)
cat "$SSR_ROOT/VERSION" 2>/dev/null || echo "dev"
;;
help|-h|--help) usage ;;
*) ssr::err "Unknown command: $cmd"; usage; exit 1 ;;
esac
Executable
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# ssr-config — show or edit the active configuration file.
set -euo pipefail
SSR_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=../lib/common.sh
source "$SSR_ROOT/lib/common.sh"
cfg="${SSR_CONFIG:-$HOME/.config/ssr/ssr.conf}"
case "${1:-show}" in
show)
if [[ -f "$cfg" ]]; then
echo "# $cfg"
cat "$cfg"
else
ssr::warn "No config found at $cfg"
echo "Run: ssr install to seed one from the example."
exit 1
fi
;;
edit)
ssr::ensure_dir "$(dirname "$cfg")"
if [[ ! -f "$cfg" ]]; then
cp "$SSR_ROOT/config/ssr.conf.example" "$cfg"
chmod 600 "$cfg"
fi
"${EDITOR:-vi}" "$cfg"
;;
path)
echo "$cfg"
;;
*)
ssr::die "Usage: ssr config [show|edit|path]"
;;
esac
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# ssr-install — local install: symlink CLI, seed config, optionally schedule.
set -euo pipefail
SSR_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=../lib/common.sh
source "$SSR_ROOT/lib/common.sh"
ssr::require_macos
PREFIX="${SSR_PREFIX:-$HOME/.local/bin}"
CONFIG_DIR="${SSR_CONFIG_DIR:-$HOME/.config/ssr}"
CONFIG_FILE="$CONFIG_DIR/ssr.conf"
ssr::log "Installing ssr"
ssr::log " prefix: $PREFIX"
ssr::log " config: $CONFIG_FILE"
ssr::ensure_dir "$PREFIX"
ssr::ensure_dir "$CONFIG_DIR"
# Symlink the main CLI so `ssr` works from anywhere.
ln -sfn "$SSR_ROOT/bin/ssr" "$PREFIX/ssr"
ssr::ok "Linked $PREFIX/ssr → $SSR_ROOT/bin/ssr"
# Seed config from the example (do not overwrite an existing user config).
if [[ ! -f "$CONFIG_FILE" ]]; then
cp "$SSR_ROOT/config/ssr.conf.example" "$CONFIG_FILE"
chmod 600 "$CONFIG_FILE"
ssr::ok "Seeded $CONFIG_FILE (chmod 600)"
else
ssr::log "Config already exists, leaving untouched: $CONFIG_FILE"
fi
# Path hint if PREFIX is not on PATH.
case ":$PATH:" in
*":$PREFIX:"*) ;;
*) ssr::warn "$PREFIX is not on your PATH — add it to your shell rc:"
echo " export PATH=\"$PREFIX:\$PATH\"" ;;
esac
# Offer to enable the scheduled sync.
if ssr::confirm "Enable daily scheduled sync (launchd)?" 30; then
"$SSR_ROOT/bin/ssr-schedule" on
fi
ssr::ok "Install complete. Run: ssr status"
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# ssr-restore — restore brew packages, mackup data, macOS defaults.
set -euo pipefail
SSR_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=../lib/common.sh
source "$SSR_ROOT/lib/common.sh"
# shellcheck source=../lib/cloud.sh
source "$SSR_ROOT/lib/cloud.sh"
# shellcheck source=../lib/brew.sh
source "$SSR_ROOT/lib/brew.sh"
# shellcheck source=../lib/mackup.sh
source "$SSR_ROOT/lib/mackup.sh"
# shellcheck source=../lib/macos.sh
source "$SSR_ROOT/lib/macos.sh"
ssr::require_macos
ssr::load_config
usage() {
cat <<EOF
Usage: ssr restore [BREWFILE]
If BREWFILE is omitted, the latest cloud backup is used:
<cloud>/BACKUP/<host> - <uuid>/Brewfile
EOF
}
[[ "${1:-}" == "-h" || "${1:-}" == "--help" ]] && { usage; exit 0; }
ssr::log "ssr restore starting"
ssr::cloud::ensure_available
backup_dir="$(ssr::cloud::backup_dir)"
mackup_dir="$(ssr::cloud::mackup_dir)"
brewfile="${1:-$backup_dir/Brewfile}"
[[ -f "$brewfile" ]] || ssr::die "Brewfile not found: $brewfile"
ssr::log "Using Brewfile: $brewfile"
ssr::confirm "Configure iCloud / cloud drive and proceed with restore?" 60 \
|| ssr::die "Aborted by user"
ssr::macos::disable_gatekeeper
ssr::macos::install_rosetta
ssr::brew::ensure_installed
ssr::brew::update
ssr::brew::restore "$brewfile"
ssr::mackup::ensure_installed
ssr::mackup::configure "$mackup_dir"
ssr::mackup::restore
ssr::macos::open_installed_casks
ssr::macos::apply_defaults
ssr::ok "ssr restore completed"
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# ssr-schedule — manage the launchd scheduled sync job.
set -euo pipefail
SSR_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=../lib/common.sh
source "$SSR_ROOT/lib/common.sh"
ssr::require_macos
ssr::load_config
LABEL="${SSR_LAUNCHD_LABEL:-de.surke.ssr.sync}"
PLIST_SRC="$SSR_ROOT/launchd/${LABEL}.plist"
PLIST_DST="$HOME/Library/LaunchAgents/${LABEL}.plist"
LOG_DIR="${SSR_LOG_DIR:-$HOME/.local/state/ssr/logs}"
INTERVAL_HOUR="${SSR_SCHEDULE_HOUR:-9}"
INTERVAL_MIN="${SSR_SCHEDULE_MINUTE:-0}"
SSR_BIN="$SSR_ROOT/bin/ssr"
render_plist() {
ssr::ensure_dir "$LOG_DIR"
ssr::ensure_dir "$(dirname "$PLIST_DST")"
# Substitute placeholders from the template into the user's LaunchAgents dir.
sed \
-e "s|@@LABEL@@|${LABEL}|g" \
-e "s|@@SSR_BIN@@|${SSR_BIN}|g" \
-e "s|@@LOG_DIR@@|${LOG_DIR}|g" \
-e "s|@@HOUR@@|${INTERVAL_HOUR}|g" \
-e "s|@@MINUTE@@|${INTERVAL_MIN}|g" \
"$PLIST_SRC" > "$PLIST_DST"
chmod 600 "$PLIST_DST"
}
cmd_on() {
[[ -f "$PLIST_SRC" ]] || ssr::die "launchd template missing: $PLIST_SRC"
render_plist
# Reload to pick up changes if it was already loaded.
launchctl unload "$PLIST_DST" 2>/dev/null || true
launchctl load -w "$PLIST_DST"
ssr::ok "Scheduled sync enabled (${INTERVAL_HOUR}:$(printf '%02d' "$INTERVAL_MIN") daily) → $PLIST_DST"
}
cmd_off() {
if [[ -f "$PLIST_DST" ]]; then
launchctl unload "$PLIST_DST" 2>/dev/null || true
rm -f "$PLIST_DST"
ssr::ok "Scheduled sync disabled"
else
ssr::log "Scheduled sync was not installed"
fi
}
cmd_status() {
if [[ -f "$PLIST_DST" ]] && launchctl list | grep -q "$LABEL"; then
ssr::ok "Scheduled sync: active (label=$LABEL)"
else
ssr::log "Scheduled sync: inactive"
fi
}
case "${1:-status}" in
on|enable) cmd_on ;;
off|disable) cmd_off ;;
status) cmd_status ;;
*) ssr::die "Usage: ssr schedule [on|off|status]" ;;
esac
Executable
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# ssr-status — print current state: config, cloud target, last sync, schedule.
set -euo pipefail
SSR_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=../lib/common.sh
source "$SSR_ROOT/lib/common.sh"
# shellcheck source=../lib/cloud.sh
source "$SSR_ROOT/lib/cloud.sh"
ssr::load_config
cfg="${SSR_CONFIG:-$HOME/.config/ssr/ssr.conf}"
provider="${SSR_CLOUD_PROVIDER:-icloud}"
echo "ssr status"
echo " repo: $SSR_ROOT"
echo " config: $cfg $([[ -f "$cfg" ]] && echo '(present)' || echo '(missing)')"
echo " provider: $provider"
if cloud_dir="$(ssr::cloud::resolve_dir 2>/dev/null)"; then
echo " cloud dir: $cloud_dir $([[ -d "$cloud_dir" ]] && echo '(ok)' || echo '(unreachable)')"
echo " backup dir: $(ssr::cloud::backup_dir 2>/dev/null || echo 'n/a')"
fi
state_dir="${SSR_STATE_DIR:-$HOME/.local/state/ssr}"
if [[ -f "$state_dir/last-sync" ]]; then
echo " last sync: $(cat "$state_dir/last-sync")"
else
echo " last sync: never"
fi
label="${SSR_LAUNCHD_LABEL:-de.surke.ssr.sync}"
if launchctl list 2>/dev/null | grep -q "$label"; then
echo " schedule: active ($label)"
else
echo " schedule: inactive"
fi
Executable
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# ssr-sync — back up Homebrew + Mackup state to the configured cloud dir.
set -euo pipefail
SSR_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=../lib/common.sh
source "$SSR_ROOT/lib/common.sh"
# shellcheck source=../lib/cloud.sh
source "$SSR_ROOT/lib/cloud.sh"
# shellcheck source=../lib/brew.sh
source "$SSR_ROOT/lib/brew.sh"
# shellcheck source=../lib/mackup.sh
source "$SSR_ROOT/lib/mackup.sh"
ssr::require_macos
ssr::load_config
ssr::log "ssr sync starting"
ssr::syslog "sync starting"
ssr::cloud::ensure_available
backup_dir="$(ssr::cloud::backup_dir)"
mackup_dir="$(ssr::cloud::mackup_dir)"
ssr::ensure_dir "$backup_dir"
ssr::ensure_dir "$mackup_dir"
ssr::brew::ensure_installed
ssr::mackup::ensure_installed
ssr::mackup::configure "$mackup_dir"
ssr::brew::update
ssr::brew::upgrade
ssr::mackup::backup
ssr::brew::dump "$backup_dir"
ssr::brew::cleanup
# Record sync time so `ssr status` can report freshness.
state_dir="${SSR_STATE_DIR:-$HOME/.local/state/ssr}"
ssr::ensure_dir "$state_dir"
date '+%Y-%m-%dT%H:%M:%S%z' > "$state_dir/last-sync"
ssr::ok "ssr sync completed → $backup_dir"
ssr::syslog "sync completed"
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# macos-defaults.sh — opinionated macOS preferences applied during restore.
# Override by setting SSR_MACOS_DEFAULTS in your config to your own script.
# Each block is independent; failures are logged but do not abort the script.
set -uo pipefail
run() {
if ! "$@"; then
printf '[macos-defaults] failed: %s\n' "$*" >&2
fi
}
# Dock: position + appearance
run defaults write com.apple.dock pinning -string middle
run defaults write com.apple.dock mouse-over-hilte-stack -bool true
# Dock: recent applications stack
run defaults write com.apple.dock persistent-others -array-add \
'{ "tile-data" = { "list-type" = 1; }; "tile-type" = "recents-tile"; }'
# Login window message (requires sudo; skipped without)
if sudo -n true 2>/dev/null; then
run sudo defaults write /Library/Preferences/com.apple.loginwindow LoginwindowText "Welcome back..."
fi
# Time Machine on unsupported network volumes
run defaults write com.apple.systempreferences TMShowUnsupportedNetworkVolumes -bool true
# Mail: prefer plain text
run defaults write com.apple.mail PreferPlainText -bool true
# Finder: show hidden files + path bar
run defaults write com.apple.finder AppleShowAllFiles -bool true
run defaults write com.apple.finder ShowPathbar -bool true
# Screenshots: save to ~/Pictures/Screenshots as PNG
mkdir -p "$HOME/Pictures/Screenshots"
run defaults write com.apple.screencapture location -string "$HOME/Pictures/Screenshots"
run defaults write com.apple.screencapture type -string png
# Reload affected services
killall Dock 2>/dev/null || true
killall Finder 2>/dev/null || true
killall SystemUIServer 2>/dev/null || true
+49
View File
@@ -0,0 +1,49 @@
# ssr.conf — System Sync & Restore configuration.
# Format: simple KEY=VALUE pairs. Comments (# …) and blank lines allowed.
# This file is sourced by bash; only the keys below are recognized.
# Anything else will cause ssr to abort.
# ---------------------------------------------------------------------------
# Cloud storage
# ---------------------------------------------------------------------------
# Provider: icloud | dropbox | googledrive | onedrive | custom
SSR_CLOUD_PROVIDER=icloud
# Only used when SSR_CLOUD_PROVIDER=custom. Absolute path.
# SSR_CLOUD_PATH=/Volumes/MyNAS/macos-sync
# Subfolders created inside the cloud root.
SSR_BACKUP_SUBDIR=BACKUP
SSR_MACKUP_SUBDIR=Mackup
# ---------------------------------------------------------------------------
# Homebrew behavior during `ssr sync`
# ---------------------------------------------------------------------------
SSR_BREW_UPGRADE=true
SSR_BREW_CLEANUP=true
# ---------------------------------------------------------------------------
# macOS restore behavior
# ---------------------------------------------------------------------------
# Disabling Gatekeeper allows apps from anywhere. Off by default.
SSR_DISABLE_GATEKEEPER=false
# Open every installed cask once after restore (for first-run config).
SSR_OPEN_CASKS=false
# Path to the macOS defaults script applied at the end of `ssr restore`.
# Leave commented to use the repo default (config/macos-defaults.sh).
# SSR_MACOS_DEFAULTS=/path/to/your/macos-defaults.sh
# ---------------------------------------------------------------------------
# Scheduling
# ---------------------------------------------------------------------------
SSR_LAUNCHD_LABEL=de.surke.ssr.sync
SSR_SCHEDULE_HOUR=9
SSR_SCHEDULE_MINUTE=0
# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------
# SSR_STATE_DIR=$HOME/.local/state/ssr
# SSR_LOG_DIR=$HOME/.local/state/ssr/logs
Executable
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
# install.sh — top-level bootstrap.
# Delegates to bin/ssr-install so a fresh clone can run a single command:
# ./install.sh
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec "$DIR/bin/ssr-install" "$@"
+44
View File
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>@@LABEL@@</string>
<key>ProgramArguments</key>
<array>
<string>@@SSR_BIN@@</string>
<string>sync</string>
</array>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>@@HOUR@@</integer>
<key>Minute</key>
<integer>@@MINUTE@@</integer>
</dict>
<key>RunAtLoad</key>
<false/>
<key>StandardOutPath</key>
<string>@@LOG_DIR@@/sync.out.log</string>
<key>StandardErrorPath</key>
<string>@@LOG_DIR@@/sync.err.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
</dict>
<key>ProcessType</key>
<string>Background</string>
<key>LowPriorityIO</key>
<true/>
</dict>
</plist>
+9
View File
@@ -0,0 +1,9 @@
# legacy/
Original `system_sync.sh` / `system_restore.sh` kept for reference and
historical diffing. **Do not run these in production** — they have known
issues (no `set -euo pipefail`, broken `MACOS = ".macos"` assignment with
spaces, `` zero-width character before the `ln` call, deprecated
`spctl --master-disable`, hard-coded iCloud paths, no error handling).
Use the top-level `ssr` CLI instead. See [../README.md](../README.md).
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
# Setup script for setting up a new macos machine
echo "Starting setup"
declare -A nonPckgApps
nonPckgApps[PhraseExpress]="https://www.phraseexpress.com/de/mac/download/"
nonPckgApps[AusweisApp2]="https://www.ausweisapp.bund.de/download/windows-und-mac/"
#nonPckgApps[]=""
BREWFILE="$1"
LOGGER_BIN="/usr/bin/logger -ist '$0'"
# Check for Homebrew to be present, install if it's missing
if test ! $(which brew); then
echo "Installing homebrew..."
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install.sh)"
fi
BREW_BIN=$(which brew)
BREW_PREFX=$(brew --prefix)
CASK_DIR=$(brew --caskroom)
# Update homebrew recipes
$BREW_BIN update
$BREW_BIN install mackup
MACKUP_DIR="$HOME/Library/Mobile Documents/com~apple~CloudDocs/Mackup"
ln -s $MACKUP_DIR/.mackup.cfg $HOME/.mackup.cfg
MACKUP_BIN=$(which mackup)
read -t 60 -n 1 -p "configure iCloud Drive and confirm to continue with Brewfile '$BREWFILE'? [y/N]: " answer
if [ $? == 0 ]; then
echo
echo "allow Apps downloaded from anywhere…"
sudo spctl --master-disable
#open /System/Library/PreferencePanes/Security.prefPane
echo
echo "Make sure Rosetta is installed..."
sudo softwareupdate --install-rosetta
$BREW_BIN bundle --verbose --file="$BREWFILE" | $LOGGER_BIN
$MACKUP_BIN restore
#echo "this script is unable to install the following apps..."
#for key val in ${(kv)nonPckgApps}; do
# echo "$key"
# open "$val"
#done
read -n 1 -p "Hit return to continue: " answer
echo "these apps will need manual config and opened in the background..."
find "$CASK_DIR" -iname "*.app" | while read APP;
do
open "$APP";
done
read -n 1 -p "Hit return to continue: " answer
echo "Configuring OS... (may need sudo)"
MACOS = ".macos"
source "$MACOS"
# Pin The Dock To The Left(start), Bottom(middle), Right(end)
defaults write com.apple.dock pinning -string middle
# Set dock to a flat 2D version
defaults write com.apple.dock no-glass -boolean YES && killall Dock;
# Recent Applications Stack in Dock
defaults write com.apple.dock persistent-others -array-add '{ "tile-data" = { "list-type" = 1; }; "tile-type" = "recents-tile"; }' && killall Dock;
# put a small gradient behind an icon
defaults write com.apple.dock mouse-over-hilte-stack -boolean YES && killall Dock;
# add a message of your choice to the Login window
sudo defaults write /Library/Preferences/com.apple.loginwindow LoginwindowText "Welcome back..."
# Enable Time Machine On Unsupported Drives
defaults write com.apple.systempreferences TMShowUnsupportedNetworkVolumes 1
# Force Mail To Display In Plain Text
defaults write com.apple.mail PreferPlainText -bool TRUE
# widegts on the dashboard
defaults write com.apple.dashboard devmode YES
echo "Macbook setup completed!"
else
echo "Can't wait anymore!"
fi
+27
View File
@@ -0,0 +1,27 @@
#!/bin/bash
LOGGER_BIN="/usr/bin/logger -ist '$0'"
UUID=$(system_profiler SPHardwareDataType | awk '/UUID/ { print $3; }')
HOSTNAME=$(hostname -s)
BACKUPDIR="$HOME/Library/Mobile Documents/com~apple~CloudDocs/BACKUP/$HOSTNAME - $UUID"
# Check for Homebrew to be present, install if it's missing
if test ! $(which brew); then
echo "Installing homebrew..."
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install.sh)"
fi
BREW_BIN=$(which brew)
BREW_PREFX=$(brew --prefix)
CASK_DIR=$(brew --caskroom)
# Update homebrew recipes
$BREW_BIN update
MACKUP_DIR="$HOME/Library/Mobile Documents/com~apple~CloudDocs/Mackup"
MACKUP_BIN=$(which mackup)
$MACKUP_BIN -f backup
mkdir -vp "$BACKUPDIR"
pushd "$BACKUPDIR" >/dev/null
cp -vf Brewfile{,.bak}
$BREW_BIN bundle dump --describe --quiet --force | $LOGGER_BIN
popd >/dev/null
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# lib/brew.sh — Homebrew install + Brewfile dump/restore.
ssr::brew::ensure_installed() {
if command -v brew >/dev/null 2>&1; then
return 0
fi
ssr::log "Homebrew not found — installing (non-interactive)"
NONINTERACTIVE=1 /bin/bash -c \
"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# Ensure brew is on PATH for both Apple Silicon and Intel layouts.
if [[ -x /opt/homebrew/bin/brew ]]; then
eval "$(/opt/homebrew/bin/brew shellenv)"
elif [[ -x /usr/local/bin/brew ]]; then
eval "$(/usr/local/bin/brew shellenv)"
fi
command -v brew >/dev/null 2>&1 || ssr::die "brew still not on PATH after install"
}
ssr::brew::update() {
ssr::log "brew update"
brew update --quiet
}
ssr::brew::upgrade() {
[[ "${SSR_BREW_UPGRADE:-true}" == "true" ]] || return 0
ssr::log "brew upgrade"
brew upgrade --quiet || ssr::warn "brew upgrade reported errors (continuing)"
}
ssr::brew::cleanup() {
[[ "${SSR_BREW_CLEANUP:-true}" == "true" ]] || return 0
ssr::log "brew cleanup"
brew cleanup --quiet || true
}
# Dumps Brewfile to the cloud backup dir with a timestamped rotation copy.
ssr::brew::dump() {
local backup_dir="$1" brewfile="$1/Brewfile"
ssr::ensure_dir "$backup_dir"
if [[ -f "$brewfile" ]]; then
cp -f "$brewfile" "$brewfile.bak"
fi
ssr::log "brew bundle dump → $brewfile"
(
cd "$backup_dir"
brew bundle dump --describe --quiet --force --file=Brewfile
)
ssr::ok "Brewfile written ($(wc -l < "$brewfile" | tr -d ' ') lines)"
}
ssr::brew::restore() {
local brewfile="$1"
[[ -f "$brewfile" ]] || ssr::die "Brewfile not found: $brewfile"
ssr::log "brew bundle install ← $brewfile"
brew bundle install --file="$brewfile" --no-lock --verbose
}
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# lib/cloud.sh — cloud storage provider abstraction.
# Resolves SSR_CLOUD_DIR based on SSR_CLOUD_PROVIDER from config.
ssr::cloud::resolve_dir() {
local provider="${SSR_CLOUD_PROVIDER:-icloud}" custom="${SSR_CLOUD_PATH:-}"
case "$provider" in
icloud)
printf '%s\n' "$HOME/Library/Mobile Documents/com~apple~CloudDocs"
;;
dropbox)
if [[ -d "$HOME/Library/CloudStorage" ]]; then
find "$HOME/Library/CloudStorage" -maxdepth 1 -type d -name 'Dropbox*' -print -quit 2>/dev/null \
|| printf '%s\n' "$HOME/Dropbox"
else
printf '%s\n' "$HOME/Dropbox"
fi
;;
googledrive)
find "$HOME/Library/CloudStorage" -maxdepth 1 -type d -name 'GoogleDrive-*' -print -quit 2>/dev/null \
|| ssr::die "Google Drive folder not found under ~/Library/CloudStorage"
;;
onedrive)
find "$HOME/Library/CloudStorage" -maxdepth 1 -type d -name 'OneDrive-*' -print -quit 2>/dev/null \
|| ssr::die "OneDrive folder not found under ~/Library/CloudStorage"
;;
custom)
[[ -n "$custom" ]] || ssr::die "SSR_CLOUD_PROVIDER=custom requires SSR_CLOUD_PATH"
printf '%s\n' "$custom"
;;
*)
ssr::die "Unknown SSR_CLOUD_PROVIDER: $provider (use: icloud|dropbox|googledrive|onedrive|custom)"
;;
esac
}
# Returns the per-host backup directory under the cloud root.
ssr::cloud::backup_dir() {
local cloud host uuid
cloud="$(ssr::cloud::resolve_dir)"
host="$(hostname -s)"
uuid="$(/usr/sbin/system_profiler SPHardwareDataType 2>/dev/null \
| awk '/Hardware UUID/ { print $3 }')"
[[ -n "$uuid" ]] || uuid="no-uuid"
local subdir="${SSR_BACKUP_SUBDIR:-BACKUP}"
printf '%s/%s/%s - %s\n' "$cloud" "$subdir" "$host" "$uuid"
}
# Mackup data lives at the cloud root in a fixed folder. Mackup itself
# resolves its engine via ~/.mackup.cfg which the install routine seeds.
ssr::cloud::mackup_dir() {
local cloud
cloud="$(ssr::cloud::resolve_dir)"
printf '%s/%s\n' "$cloud" "${SSR_MACKUP_SUBDIR:-Mackup}"
}
ssr::cloud::ensure_available() {
local cloud
cloud="$(ssr::cloud::resolve_dir)"
[[ -d "$cloud" ]] || ssr::die "Cloud directory not available: $cloud"
ssr::ok "Cloud provider: ${SSR_CLOUD_PROVIDER:-icloud}$cloud"
}
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env bash
# lib/common.sh — shared helpers (logging, errors, validation).
# Sourced by all ssr commands. Never executed directly.
set -euo pipefail
# Resolve repo root from any sourced location.
SSR_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SSR_ROOT="$(cd "${SSR_LIB_DIR}/.." && pwd)"
export SSR_ROOT SSR_LIB_DIR
# Colors only when stdout is a TTY (no ANSI in launchd logs).
if [[ -t 1 ]]; then
SSR_C_RED=$'\033[31m'; SSR_C_GRN=$'\033[32m'
SSR_C_YLW=$'\033[33m'; SSR_C_BLU=$'\033[34m'
SSR_C_DIM=$'\033[2m'; SSR_C_RST=$'\033[0m'
else
SSR_C_RED=""; SSR_C_GRN=""; SSR_C_YLW=""; SSR_C_BLU=""; SSR_C_DIM=""; SSR_C_RST=""
fi
ssr::ts() { date '+%Y-%m-%dT%H:%M:%S%z'; }
ssr::log() { printf '%s %s[INFO]%s %s\n' "$(ssr::ts)" "$SSR_C_BLU" "$SSR_C_RST" "$*"; }
ssr::ok() { printf '%s %s[OK]%s %s\n' "$(ssr::ts)" "$SSR_C_GRN" "$SSR_C_RST" "$*"; }
ssr::warn() { printf '%s %s[WARN]%s %s\n' "$(ssr::ts)" "$SSR_C_YLW" "$SSR_C_RST" "$*" >&2; }
ssr::err() { printf '%s %s[ERR]%s %s\n' "$(ssr::ts)" "$SSR_C_RED" "$SSR_C_RST" "$*" >&2; }
ssr::die() { ssr::err "$@"; exit 1; }
# Mirror to syslog if available so launchd runs are findable via `log show`.
ssr::syslog() {
[[ -x /usr/bin/logger ]] || return 0
/usr/bin/logger -t "ssr" -- "$*" || true
}
ssr::require_macos() {
[[ "$(uname -s)" == "Darwin" ]] || ssr::die "ssr requires macOS (Darwin). Detected: $(uname -s)"
}
ssr::require_cmd() {
command -v "$1" >/dev/null 2>&1 || ssr::die "Required command missing: $1"
}
# Confirm prompt with optional timeout (returns 0 = yes).
ssr::confirm() {
local prompt="${1:-Continue?}" timeout="${2:-0}" reply
if [[ "$timeout" -gt 0 ]]; then
read -r -t "$timeout" -n 1 -p "${prompt} [y/N]: " reply || reply=""
else
read -r -n 1 -p "${prompt} [y/N]: " reply || reply=""
fi
echo
[[ "${reply,,}" == "y" ]]
}
# Validate a path is safe (no traversal, no shell metas). Used before any FS op.
ssr::validate_path() {
local p="$1"
[[ -n "$p" ]] || ssr::die "Empty path"
[[ "$p" != *$'\n'* && "$p" != *$'\0'* ]] || ssr::die "Path contains control chars"
case "$p" in
*..*) ssr::die "Path traversal rejected: $p" ;;
esac
}
ssr::ensure_dir() {
local d="$1"
ssr::validate_path "$d"
[[ -d "$d" ]] || mkdir -p "$d"
}
# Source config file if it exists. Validates that only known KEY=VALUE lines
# are present to avoid arbitrary code execution from a tampered config.
ssr::load_config() {
local cfg="${1:-${SSR_CONFIG:-$HOME/.config/ssr/ssr.conf}}"
export SSR_CONFIG="$cfg"
[[ -f "$cfg" ]] || return 0
if grep -Eq -v '^[[:space:]]*(#|$|[A-Z_][A-Z0-9_]*=)' "$cfg"; then
ssr::die "Config $cfg contains invalid lines (only KEY=VALUE allowed)"
fi
# shellcheck disable=SC1090
source "$cfg"
}
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# lib/mackup.sh — Mackup install + backup/restore.
# Note: upstream mackup is in low-maintenance mode; we keep it because it
# remains the simplest way to sync dotfiles for a wide range of apps. The
# user can swap engines via ~/.mackup.cfg without changing this script.
ssr::mackup::ensure_installed() {
if command -v mackup >/dev/null 2>&1; then
return 0
fi
ssr::log "Installing mackup via brew"
brew install mackup
}
# Writes ~/.mackup.cfg pointing the engine at the cloud directory.
# Idempotent: rewrites only if content differs.
ssr::mackup::configure() {
local mackup_dir="$1"
local cfg="$HOME/.mackup.cfg"
local desired
desired="$(printf '[storage]\nengine = file_system\npath = %s\ndirectory = mackup\n' "$mackup_dir")"
if [[ -f "$cfg" ]] && [[ "$(cat "$cfg")" == "$desired" ]]; then
return 0
fi
if [[ -L "$cfg" || -f "$cfg" ]]; then
cp -f "$cfg" "$cfg.bak.$(date +%s)" 2>/dev/null || true
rm -f "$cfg"
fi
printf '%s\n' "$desired" > "$cfg"
ssr::ok "Wrote $cfg"
}
ssr::mackup::backup() {
ssr::log "mackup backup"
mackup -f backup
}
ssr::mackup::restore() {
ssr::log "mackup restore"
mackup restore
}
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# lib/macos.sh — macOS system preferences.
# Sourced by `ssr restore`. Each setting is idempotent.
ssr::macos::apply_defaults() {
local script="${1:-${SSR_MACOS_DEFAULTS:-$SSR_ROOT/config/macos-defaults.sh}}"
if [[ ! -f "$script" ]]; then
ssr::warn "macOS defaults script missing: $script (skipping)"
return 0
fi
ssr::log "Applying macOS defaults from $script"
# shellcheck disable=SC1090
bash "$script"
ssr::ok "macOS defaults applied"
}
ssr::macos::install_rosetta() {
if /usr/bin/arch -x86_64 /usr/bin/true >/dev/null 2>&1; then
return 0
fi
if [[ "$(uname -m)" != "arm64" ]]; then
return 0
fi
ssr::log "Installing Rosetta 2"
sudo softwareupdate --install-rosetta --agree-to-license
}
# Allows non-App-Store apps to run. Requires sudo. Skipped unless explicitly
# enabled, because disabling Gatekeeper has security implications.
ssr::macos::disable_gatekeeper() {
[[ "${SSR_DISABLE_GATEKEEPER:-false}" == "true" ]] || return 0
ssr::warn "Disabling Gatekeeper (SSR_DISABLE_GATEKEEPER=true)"
sudo spctl --global-disable
}
ssr::macos::open_installed_casks() {
[[ "${SSR_OPEN_CASKS:-false}" == "true" ]] || return 0
local cask_dir
cask_dir="$(brew --caskroom 2>/dev/null)" || return 0
[[ -d "$cask_dir" ]] || return 0
ssr::log "Opening installed cask apps for first-run config"
find "$cask_dir" -maxdepth 3 -iname '*.app' -print0 \
| while IFS= read -r -d '' app; do
open "$app" || true
done
}