feat: auto-detect and install missing shell frameworks after restore

After mackup restores dotfiles, ~/.zshrc may reference tools (Oh My Zsh,
nvm, pyenv, rustup, …) that haven't been installed yet, causing errors on
first shell login.

lib/shell.sh:
  - Defines 13 built-in frameworks with detect + install commands
  - Scans ~/.zshrc / ~/.bashrc etc. for references before installing
    (avoids installing things the user doesn't actually use)
  - SSR_SHELL_DEPS_EXTRA in ssr.conf for custom user additions

bin/ssr-restore:
  - Adds `shell_deps` as a resumable step after mackup_restore
  - Sources ~/.config/ssr/post-restore.sh if present (user hook)

config/post-restore.sh.example:
  - Template with commented examples (OMZ plugins, npm globals, SSH keys)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-12 10:19:32 +02:00
parent 649449c628
commit 371dea1851
4 changed files with 297 additions and 0 deletions
+197
View File
@@ -0,0 +1,197 @@
#!/usr/bin/env bash
# lib/shell.sh — Detect and install shell frameworks / version managers that
# are referenced in the user's dotfiles but are not yet present on disk.
#
# Why this exists:
# Mackup restores dotfiles (e.g. ~/.zshrc) but not the *tools* those files
# source. If ~/.zshrc contains `source ~/.oh-my-zsh/oh-my-zsh.sh` and
# Oh My Zsh is not installed, every new shell session prints an error.
#
# Architecture:
# - _SSR_SHELL_DEPS defines built-in known frameworks (name|check|install).
# - SSR_SHELL_DEPS_EXTRA in ssr.conf allows the user to add custom entries
# using the same pipe-delimited format.
# - ssr::shell::detect_and_install iterates all entries, runs the check,
# and invokes the installer when the check fails.
# ---------------------------------------------------------------------------
# Built-in dependency definitions.
# Format: "name|check_expression|install_command"
# check_expression — any bash expression; success (exit 0) = already OK.
# install_command — run as the current user (no sudo).
# ---------------------------------------------------------------------------
_SSR_SHELL_DEPS=(
# Oh My Zsh — most common zsh framework
"oh-my-zsh\
|[[ -d \"\$HOME/.oh-my-zsh\" ]]\
|RUNZSH=no CHSH=no sh -c \"\$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)\""
# Prezto — lightweight zsh framework
"prezto\
|[[ -d \"\$HOME/.zprezto\" ]]\
|git clone --recursive https://github.com/sorin-ionescu/prezto.git \"\${ZDOTDIR:-\$HOME}/.zprezto\""
# Zinit — zsh plugin manager
"zinit\
|[[ -d \"\${ZINIT_HOME:-\$HOME/.local/share/zinit/zinit.git}\" ]]\
|bash -c \"\$(curl --fail --show-error --silent --location https://raw.githubusercontent.com/zdharma-continuum/zinit/HEAD/scripts/install.sh)\""
# Antigen — zsh plugin manager
"antigen\
|[[ -f \"\$HOME/.antigen.zsh\" ]] || [[ -f \"\$HOME/.config/antigen/antigen.zsh\" ]]\
|mkdir -p \"\$HOME/.config/antigen\" && curl -sSL git.io/antigen -o \"\$HOME/.config/antigen/antigen.zsh\""
# nvm — Node Version Manager
"nvm\
|[[ -d \"\${NVM_DIR:-\$HOME/.nvm}\" ]]\
|PROFILE=/dev/null bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/HEAD/install.sh)\""
# pyenv — Python version manager
"pyenv\
|command -v pyenv >/dev/null 2>&1 || [[ -d \"\$HOME/.pyenv\" ]]\
|brew install pyenv"
# rbenv — Ruby version manager
"rbenv\
|command -v rbenv >/dev/null 2>&1 || [[ -d \"\$HOME/.rbenv\" ]]\
|brew install rbenv"
# rustup — Rust toolchain installer
"rustup\
|command -v rustup >/dev/null 2>&1 || [[ -d \"\$HOME/.rustup\" ]]\
|curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --no-modify-path"
# SDKMAN — JVM version manager (Java, Kotlin, Groovy, …)
"sdkman\
|[[ -d \"\$HOME/.sdkman\" ]]\
|curl -s https://get.sdkman.io | bash"
# Starship prompt
"starship\
|command -v starship >/dev/null 2>&1\
|brew install starship"
# Powerlevel10k — when installed standalone (not via OMZ/zinit)
"powerlevel10k\
|[[ -d \"\${ZSH_CUSTOM:-\$HOME/.oh-my-zsh/custom}/themes/powerlevel10k\" ]] \
|| [[ -d \"\$HOME/.local/share/powerlevel10k\" ]]\
|git clone --depth=1 https://github.com/romkatv/powerlevel10k.git \"\${ZSH_CUSTOM:-\$HOME/.oh-my-zsh/custom}/themes/powerlevel10k\""
# asdf — universal version manager
"asdf\
|command -v asdf >/dev/null 2>&1 || [[ -d \"\$HOME/.asdf\" ]]\
|brew install asdf"
# mise (formerly rtx) — fast version manager
"mise\
|command -v mise >/dev/null 2>&1\
|brew install mise"
)
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
# Return a list of RC files that are likely to reference external frameworks.
_ssr_shell_rc_files() {
local files=(
"$HOME/.zshrc"
"$HOME/.zprofile"
"$HOME/.bashrc"
"$HOME/.bash_profile"
"$HOME/.profile"
)
local f out=()
for f in "${files[@]}"; do
[[ -f "$f" ]] && out+=("$f")
done
printf '%s\n' "${out[@]}"
}
# Given a dependency name, return true if that name is referenced in any RC file.
# This lets us skip installers for things the user's dotfiles don't actually use.
_ssr_shell_dep_referenced() {
local name="$1"
local pattern
case "$name" in
oh-my-zsh) pattern="oh-my-zsh" ;;
prezto) pattern="zprezto" ;;
zinit) pattern="zinit" ;;
antigen) pattern="antigen" ;;
nvm) pattern="NVM_DIR\|nvm.sh" ;;
pyenv) pattern="pyenv" ;;
rbenv) pattern="rbenv" ;;
rustup) pattern="rustup\|cargo" ;;
sdkman) pattern="sdkman\|SDKMAN_DIR" ;;
starship) pattern="starship" ;;
powerlevel10k) pattern="powerlevel10k\|p10k" ;;
asdf) pattern="asdf" ;;
mise) pattern="mise" ;;
*) return 0 ;; # unknown — always consider referenced
esac
local rcfile
while IFS= read -r rcfile; do
grep -qE "$pattern" "$rcfile" 2>/dev/null && return 0
done < <(_ssr_shell_rc_files)
return 1
}
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
# Detect missing shell frameworks and install them.
# Iterates _SSR_SHELL_DEPS + SSR_SHELL_DEPS_EXTRA (from ssr.conf).
#
# Each entry: "name|check_expression|install_command"
#
# Skips any entry whose name is NOT referenced in the user's RC files
# (avoids installing things that are irrelevant to this machine).
ssr::shell::detect_and_install() {
local -a all_deps=("${_SSR_SHELL_DEPS[@]}")
# Append user-defined extras (SSR_SHELL_DEPS_EXTRA set in ssr.conf).
if [[ -n "${SSR_SHELL_DEPS_EXTRA:-}" ]]; then
local entry
for entry in "${SSR_SHELL_DEPS_EXTRA[@]}"; do
all_deps+=("$entry")
done
fi
local name check_expr install_cmd
local installed=0 skipped=0
for dep in "${all_deps[@]}"; do
# Parse pipe-delimited fields.
name="$( printf '%s' "$dep" | cut -d'|' -f1)"
check_expr="$( printf '%s' "$dep" | cut -d'|' -f2)"
install_cmd="$( printf '%s' "$dep" | cut -d'|' -f3)"
# Skip if not referenced in any RC file (reduces noise on clean systems).
if ! _ssr_shell_dep_referenced "$name"; then
skipped=$((skipped + 1))
continue
fi
# Run the check expression; skip if already satisfied.
if eval "$check_expr" 2>/dev/null; then
ssr::log "shell dep OK: $name"
continue
fi
ssr::warn "Shell dep missing: $name — installing…"
if eval "$install_cmd" 2>&1 | { while IFS= read -r line; do ssr::log " [$name] $line"; done; }; then
ssr::ok "Installed: $name"
installed=$((installed + 1))
else
ssr::warn "Install failed: $name (non-fatal — continue manually)"
fi
done
if [[ $installed -gt 0 ]]; then
ssr::ok "Shell framework setup: $installed installed, $skipped not referenced (skipped)"
else
ssr::log "Shell framework setup: all referenced deps already present"
fi
}