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:
+58
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user