diff --git a/bin/ssr-restore b/bin/ssr-restore index dd5c002..5c5b73e 100755 --- a/bin/ssr-restore +++ b/bin/ssr-restore @@ -25,6 +25,8 @@ source "$SSR_ROOT/lib/restore-state.sh" source "$SSR_ROOT/lib/icloud.sh" # shellcheck source=../lib/shell.sh source "$SSR_ROOT/lib/shell.sh" +# shellcheck source=../lib/installers.sh +source "$SSR_ROOT/lib/installers.sh" ssr::require_macos ssr::load_config @@ -110,6 +112,16 @@ else ssr::rs::set "step_shell_deps" "skipped" fi +# Extra installers: DMG / PKG / ZIP not available via Homebrew or MAS. +# List file is backed up to /extra-installers.txt by `ssr sync`. +_extra_installers_file="$backup_dir/extra-installers.txt" +if [[ -f "$_extra_installers_file" ]]; then + ssr::rs::run extra_installers "Extra installers" \ + ssr::installers::install_from_file "$_extra_installers_file" +else + ssr::rs::set "step_extra_installers" "skipped" +fi + ssr::rs::run macos_defaults "macOS defaults" ssr::macos::apply_defaults if [[ "${SSR_SNAPSHOT_MACOS_PREFS:-true}" == "true" ]]; then diff --git a/bin/ssr-sync b/bin/ssr-sync index 7b3f748..fa5ed4d 100755 --- a/bin/ssr-sync +++ b/bin/ssr-sync @@ -17,6 +17,8 @@ source "$SSR_ROOT/lib/apps.sh" source "$SSR_ROOT/lib/macos-prefs.sh" # shellcheck source=../lib/icloud.sh source "$SSR_ROOT/lib/icloud.sh" +# shellcheck source=../lib/installers.sh +source "$SSR_ROOT/lib/installers.sh" ssr::require_macos ssr::load_config @@ -56,6 +58,9 @@ ssr::mackup::backup ssr::brew::dump "$backup_dir" ssr::brew::cleanup +# Back up the extra-installers list alongside the Brewfile. +ssr::installers::sync_list "$backup_dir" + # Surface .app bundles that aren't covered by brew cask or the App Store — # they would otherwise be missed on `ssr restore` and need manual reinstall. if [[ "${SSR_SCAN_UNMANAGED_APPS:-true}" == "true" ]]; then diff --git a/config/extra-installers.txt.example b/config/extra-installers.txt.example new file mode 100644 index 0000000..2572489 --- /dev/null +++ b/config/extra-installers.txt.example @@ -0,0 +1,22 @@ +# extra-installers.txt — additional installers not available via Homebrew or MAS. +# +# Copy this file to ~/.config/ssr/extra-installers.txt and add your URLs. +# It is backed up to the cloud alongside your Brewfile on every `ssr sync`. +# +# Format (one entry per line): +# [optional-name ] +# +# Supported types: .dmg .pkg .zip +# Lines starting with # and blank lines are ignored. +# +# The installer is skipped if an app with a matching name is already +# present in /Applications or ~/Applications. +# +# ───────────────────────────────────────────────────────────────────────────── + +# EasyMCP Desktop (Adobe AI Foundations) +EasyMCP https://github.com/Adobe-AIFoundations/easymcp/releases/download/v3.0.3/EasyMCP-Desktop-3.0.3-arm64.dmg + +# Add your own entries below: +# SomeApp https://example.com/SomeApp-1.2.dmg +# AnotherTool https://builds.example.com/tool-latest.pkg diff --git a/config/ssr.conf.example b/config/ssr.conf.example index 42626ff..9d7cdaa 100644 --- a/config/ssr.conf.example +++ b/config/ssr.conf.example @@ -73,6 +73,13 @@ SSR_PREFS_CONFLICT_DEFAULT=accept # Set to "true" to skip all conflict prompts and auto-accept everywhere. # SSR_ASSUME_YES=false +# --------------------------------------------------------------------------- +# Extra installers — DMG / PKG / ZIP not available via Homebrew or MAS +# --------------------------------------------------------------------------- +# Create ~/.config/ssr/extra-installers.txt with one URL per line. +# See config/extra-installers.txt.example for the format. +# The file is automatically backed up to the cloud on every `ssr sync`. + # --------------------------------------------------------------------------- # Shell framework auto-install (runs after mackup restore) # --------------------------------------------------------------------------- diff --git a/lib/installers.sh b/lib/installers.sh new file mode 100644 index 0000000..dc77093 --- /dev/null +++ b/lib/installers.sh @@ -0,0 +1,225 @@ +#!/usr/bin/env bash +# lib/installers.sh — Download and install arbitrary DMG / PKG / ZIP installers. +# +# Config file: ~/.config/ssr/extra-installers.txt +# Backed up to /BACKUP//extra-installers.txt during `ssr sync`. +# Read from there during `ssr restore`. +# +# File format (one entry per line): +# [optional-name ] +# Lines starting with # and blank lines are ignored. +# +# Supported installer types: +# .dmg — mounted; .app inside → cp to /Applications +# .pkg inside → sudo installer +# .pkg — sudo installer -pkg +# .zip — unzipped; .app inside → cp to /Applications + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +# Download url to a temp file; echoes the temp path on success. +_ssr_inst_download() { + local url="$1" name="$2" + local ext="${url##*.}" + ext="${ext%%\?*}" # strip query string if present + local tmp + tmp="$(mktemp "/tmp/ssr_installer_XXXXXX.${ext}")" + ssr::log " ↓ $name ($url)" + if curl -fsSL --retry 3 --retry-delay 2 -o "$tmp" "$url"; then + printf '%s' "$tmp" + else + rm -f "$tmp" + return 1 + fi +} + +# Install a .dmg: mount → detect .app or .pkg → install → unmount. +_ssr_inst_dmg() { + local dmg="$1" name="$2" + local mountpoint + mountpoint="$(hdiutil attach -nobrowse -quiet -noverify "$dmg" \ + | awk '/Volumes/ {for(i=3;i<=NF;i++) printf $i" "; print ""}' \ + | sed 's/ *$//')" + if [[ -z "$mountpoint" ]]; then + ssr::warn " Could not mount DMG: $dmg" + return 1 + fi + ssr::log " Mounted: $mountpoint" + + local installed=false + + # 1. .app bundles at root of volume → copy to /Applications + local app + while IFS= read -r app; do + local app_name="${app##*/}" + ssr::log " Installing $app_name → /Applications" + cp -af "$app" "/Applications/$app_name" \ + && ssr::ok " $app_name installed" \ + && installed=true \ + || ssr::warn " Failed to copy $app_name (needs sudo?)" + done < <(find "$mountpoint" -maxdepth 2 -name '*.app' -type d 2>/dev/null) + + # 2. .pkg files at root of volume → run installer + if [[ "$installed" == "false" ]]; then + local pkg + while IFS= read -r pkg; do + ssr::log " Running pkg installer: ${pkg##*/}" + sudo installer -pkg "$pkg" -target / \ + && ssr::ok " ${pkg##*/} installed" \ + && installed=true \ + || ssr::warn " pkg install failed: ${pkg##*/}" + done < <(find "$mountpoint" -maxdepth 2 -name '*.pkg' 2>/dev/null) + fi + + hdiutil detach -quiet "$mountpoint" 2>/dev/null || true + + [[ "$installed" == "true" ]] +} + +# Install a .pkg directly. +_ssr_inst_pkg() { + local pkg="$1" + sudo installer -pkg "$pkg" -target / \ + && ssr::ok " ${pkg##*/} installed" \ + || { ssr::warn " pkg install failed: ${pkg##*/}"; return 1; } +} + +# Install a .zip: unzip → find .app → copy to /Applications. +_ssr_inst_zip() { + local zip="$1" + local tmpdir + tmpdir="$(mktemp -d /tmp/ssr_zip_XXXXXX)" + unzip -q "$zip" -d "$tmpdir" 2>/dev/null || true + + local installed=false app + while IFS= read -r app; do + local app_name="${app##*/}" + ssr::log " Installing $app_name → /Applications" + cp -af "$app" "/Applications/$app_name" \ + && ssr::ok " $app_name installed" \ + && installed=true \ + || ssr::warn " Failed to copy $app_name" + done < <(find "$tmpdir" -maxdepth 4 -name '*.app' -type d 2>/dev/null | head -20) + + rm -rf "$tmpdir" + [[ "$installed" == "true" ]] +} + +# Parse a single entry line into name + url. +# Echoes two lines: name, then url. +_ssr_inst_parse_line() { + local line="$1" + local name url + if [[ "$line" =~ ^[^[:space:]]+[[:space:]]+https?:// ]]; then + # "name URL" format + name="${line%% *}" + url="${line#* }" + else + # URL only — derive name from filename + url="$line" + name="${url##*/}" + name="${name%%\?*}" + # Strip version noise: EasyMCP-Desktop-3.0.3-arm64.dmg → EasyMCP Desktop + name="${name%-arm64.*}" + name="${name%-x64.*}" + name="${name%.dmg}"; name="${name%.pkg}"; name="${name%.zip}" + name="${name//-/ }" + fi + printf '%s\n%s\n' "$name" "$url" +} + +# Check if an app is already installed (looks in /Applications and ~/Applications). +_ssr_inst_already_installed() { + local name="$1" + # Normalise: strip version numbers and spaces for a loose match + local base + base="$(printf '%s' "$name" | sed 's/ [0-9].*//' | tr '[:upper:]' '[:lower:]')" + find /Applications "$HOME/Applications" -maxdepth 1 -name '*.app' 2>/dev/null \ + | while IFS= read -r app; do + local a="${app##*/}"; a="${a%.app}" + local al; al="$(printf '%s' "$a" | sed 's/ [0-9].*//' | tr '[:upper:]' '[:lower:]')" + [[ "$al" == *"$base"* || "$base" == *"$al"* ]] && { printf 'found'; return 0; } + done | grep -q 'found' +} + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +# Parse and install every URL in a given installer list file. +# Non-fatal: logs warnings on failure and continues with the next entry. +ssr::installers::install_from_file() { + local list_file="$1" + [[ -f "$list_file" ]] || { ssr::log "No extra-installers file: $list_file (skipping)"; return 0; } + + local count=0 ok=0 skip=0 fail=0 name url ext tmp + + while IFS= read -r raw; do + # Skip comments and blank lines. + [[ "$raw" =~ ^[[:space:]]*# ]] && continue + [[ -z "${raw// }" ]] && continue + + count=$((count + 1)) + + # Parse name + url. + name="$(printf '%s' "$raw" | { _ssr_inst_parse_line "$raw"; } | head -1)" + url="$( printf '%s' "$raw" | { _ssr_inst_parse_line "$raw"; } | tail -1)" + + # Check if already installed. + if _ssr_inst_already_installed "$name"; then + ssr::log " already installed: $name (skip)" + skip=$((skip + 1)) + continue + fi + + # Determine extension. + ext="${url##*.}"; ext="${ext%%\?*}"; ext="${ext,,}" + + # Download. + if ! tmp="$(_ssr_inst_download "$url" "$name")"; then + ssr::warn " Download failed: $url" + fail=$((fail + 1)) + continue + fi + + # Install based on type. + local result=0 + case "$ext" in + dmg) _ssr_inst_dmg "$tmp" "$name" || result=$? ;; + pkg) _ssr_inst_pkg "$tmp" || result=$? ;; + zip) _ssr_inst_zip "$tmp" || result=$? ;; + *) + ssr::warn " Unsupported installer type: .$ext ($url)" + result=1 + ;; + esac + + rm -f "$tmp" + + if [[ $result -eq 0 ]]; then + ok=$((ok + 1)) + else + fail=$((fail + 1)) + fi + + done < "$list_file" + + ssr::ok "Extra installers: $count total — $ok installed, $skip skipped (already present), $fail failed" + [[ $fail -eq 0 ]] # non-zero exit if any failed → tracked by ssr::rs::run +} + +# During `ssr sync`: copy the local list file to the backup directory so it +# travels to new machines and is versioned alongside the Brewfile. +ssr::installers::sync_list() { + local backup_dir="$1" + local src="${SSR_CONFIG_DIR:-$HOME/.config/ssr}/extra-installers.txt" + + if [[ -f "$src" ]]; then + cp "$src" "$backup_dir/extra-installers.txt" + ssr::ok "extra-installers.txt → $backup_dir/extra-installers.txt" + else + ssr::log "No extra-installers.txt found at $src (skipping)" + fi +}