72516b864c
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>
226 lines
7.7 KiB
Bash
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
|
|
}
|