feat: download and install extra DMG/PKG/ZIP installers during restore
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>
This commit is contained in:
@@ -25,6 +25,8 @@ source "$SSR_ROOT/lib/restore-state.sh"
|
|||||||
source "$SSR_ROOT/lib/icloud.sh"
|
source "$SSR_ROOT/lib/icloud.sh"
|
||||||
# shellcheck source=../lib/shell.sh
|
# shellcheck source=../lib/shell.sh
|
||||||
source "$SSR_ROOT/lib/shell.sh"
|
source "$SSR_ROOT/lib/shell.sh"
|
||||||
|
# shellcheck source=../lib/installers.sh
|
||||||
|
source "$SSR_ROOT/lib/installers.sh"
|
||||||
|
|
||||||
ssr::require_macos
|
ssr::require_macos
|
||||||
ssr::load_config
|
ssr::load_config
|
||||||
@@ -110,6 +112,16 @@ else
|
|||||||
ssr::rs::set "step_shell_deps" "skipped"
|
ssr::rs::set "step_shell_deps" "skipped"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Extra installers: DMG / PKG / ZIP not available via Homebrew or MAS.
|
||||||
|
# List file is backed up to <backup_dir>/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
|
ssr::rs::run macos_defaults "macOS defaults" ssr::macos::apply_defaults
|
||||||
|
|
||||||
if [[ "${SSR_SNAPSHOT_MACOS_PREFS:-true}" == "true" ]]; then
|
if [[ "${SSR_SNAPSHOT_MACOS_PREFS:-true}" == "true" ]]; then
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ source "$SSR_ROOT/lib/apps.sh"
|
|||||||
source "$SSR_ROOT/lib/macos-prefs.sh"
|
source "$SSR_ROOT/lib/macos-prefs.sh"
|
||||||
# shellcheck source=../lib/icloud.sh
|
# shellcheck source=../lib/icloud.sh
|
||||||
source "$SSR_ROOT/lib/icloud.sh"
|
source "$SSR_ROOT/lib/icloud.sh"
|
||||||
|
# shellcheck source=../lib/installers.sh
|
||||||
|
source "$SSR_ROOT/lib/installers.sh"
|
||||||
|
|
||||||
ssr::require_macos
|
ssr::require_macos
|
||||||
ssr::load_config
|
ssr::load_config
|
||||||
@@ -56,6 +58,9 @@ ssr::mackup::backup
|
|||||||
ssr::brew::dump "$backup_dir"
|
ssr::brew::dump "$backup_dir"
|
||||||
ssr::brew::cleanup
|
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 —
|
# 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.
|
# they would otherwise be missed on `ssr restore` and need manual reinstall.
|
||||||
if [[ "${SSR_SCAN_UNMANAGED_APPS:-true}" == "true" ]]; then
|
if [[ "${SSR_SCAN_UNMANAGED_APPS:-true}" == "true" ]]; then
|
||||||
|
|||||||
@@ -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 ]<url>
|
||||||
|
#
|
||||||
|
# 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
|
||||||
@@ -73,6 +73,13 @@ SSR_PREFS_CONFLICT_DEFAULT=accept
|
|||||||
# Set to "true" to skip all conflict prompts and auto-accept everywhere.
|
# Set to "true" to skip all conflict prompts and auto-accept everywhere.
|
||||||
# SSR_ASSUME_YES=false
|
# 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)
|
# Shell framework auto-install (runs after mackup restore)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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 <cloud>/BACKUP/<host>/extra-installers.txt during `ssr sync`.
|
||||||
|
# Read from there during `ssr restore`.
|
||||||
|
#
|
||||||
|
# File format (one entry per line):
|
||||||
|
# [optional-name ]<url>
|
||||||
|
# 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user