Files
System-Sync-Restore/lib/installers.sh
T
Oliver Surke 72516b864c 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>
2026-05-12 10:33:56 +02:00

226 lines
7.7 KiB
Bash

#!/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
}