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:
@@ -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