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:
2026-05-12 10:33:56 +02:00
parent 371dea1851
commit 72516b864c
5 changed files with 271 additions and 0 deletions
+12
View File
@@ -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 <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
if [[ "${SSR_SNAPSHOT_MACOS_PREFS:-true}" == "true" ]]; then
+5
View File
@@ -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
+22
View File
@@ -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
+7
View File
@@ -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)
# ---------------------------------------------------------------------------
+225
View File
@@ -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
}