From 371dea1851c642c09f560bace961ead0d15174b9 Mon Sep 17 00:00:00 2001 From: "Oliver Surke (Gitea)" Date: Tue, 12 May 2026 10:19:32 +0200 Subject: [PATCH] feat: auto-detect and install missing shell frameworks after restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- bin/ssr-restore | 24 ++++ config/post-restore.sh.example | 60 ++++++++++ config/ssr.conf.example | 16 +++ lib/shell.sh | 197 +++++++++++++++++++++++++++++++++ 4 files changed, 297 insertions(+) create mode 100644 config/post-restore.sh.example create mode 100644 lib/shell.sh diff --git a/bin/ssr-restore b/bin/ssr-restore index 3b241b6..dd5c002 100755 --- a/bin/ssr-restore +++ b/bin/ssr-restore @@ -23,6 +23,8 @@ source "$SSR_ROOT/lib/macos-prefs.sh" source "$SSR_ROOT/lib/restore-state.sh" # shellcheck source=../lib/icloud.sh source "$SSR_ROOT/lib/icloud.sh" +# shellcheck source=../lib/shell.sh +source "$SSR_ROOT/lib/shell.sh" ssr::require_macos ssr::load_config @@ -100,6 +102,14 @@ ssr::rs::run mackup_restore "Mackup restore" _ssr_mackup_restore_step # by Mackup (com.apple.dock.plist etc.) without requiring a logout. ssr::macos::restart_ui +# After dotfiles are in place, install any shell frameworks / version +# managers they reference that aren't already present (e.g. Oh My Zsh). +if [[ "${SSR_SHELL_DEPS_ENABLED:-true}" == "true" ]]; then + ssr::rs::run shell_deps "Shell framework deps" ssr::shell::detect_and_install +else + ssr::rs::set "step_shell_deps" "skipped" +fi + ssr::rs::run macos_defaults "macOS defaults" ssr::macos::apply_defaults if [[ "${SSR_SNAPSHOT_MACOS_PREFS:-true}" == "true" ]]; then @@ -112,6 +122,20 @@ fi # resumable step — it's side-effect-only and always safe to re-run). ssr::macos::open_installed_casks +# --- User-defined post-restore hook -------------------------------------- +# Sources ~/.config/ssr/post-restore.sh when present. +# Copy config/post-restore.sh.example from the repo as a starting point. +_user_hook="${SSR_CONFIG_DIR:-$HOME/.config/ssr}/post-restore.sh" +if [[ -f "$_user_hook" ]]; then + ssr::log "Running post-restore hook: $_user_hook" + # shellcheck source=/dev/null + source "$_user_hook" \ + && ssr::ok "post-restore hook completed" \ + || ssr::warn "post-restore hook exited with errors (non-fatal)" +else + ssr::log "No post-restore hook found (optional: $_user_hook)" +fi + # Mark overall completion time. ssr::rs::set "finished" "$(ssr::ts)" diff --git a/config/post-restore.sh.example b/config/post-restore.sh.example new file mode 100644 index 0000000..a06152c --- /dev/null +++ b/config/post-restore.sh.example @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# config/post-restore.sh — custom post-restore hooks. +# +# Copy this file to ~/.config/ssr/post-restore.sh and add your own steps. +# It is sourced by `ssr restore` after all built-in steps complete. +# +# Available helpers (from lib/common.sh): +# ssr::log "message" — info line +# ssr::ok "message" — success line +# ssr::warn "message" — warning line (non-fatal) +# ssr::confirm "prompt" — interactive y/N prompt +# +# Available variables: +# SSR_ROOT — path to the ssr repository +# backup_dir — cloud backup directory for this host +# mackup_dir — cloud Mackup directory +# +# EXAMPLES +# ------------------------------------------------------------------------- + +# --- Custom shell dep (extends SSR_SHELL_DEPS_EXTRA in ssr.conf) --------- +# If you prefer the hook-file approach over ssr.conf arrays, add deps here: +# +# if [[ ! -d "$HOME/.my-custom-tool" ]]; then +# ssr::log "Installing my-custom-tool..." +# curl -sSL https://example.com/install.sh | bash +# fi + +# --- Oh My Zsh plugins not installed via Homebrew ------------------------ +# Plugins are usually git-cloned into ${ZSH_CUSTOM}/plugins. +# Example: zsh-syntax-highlighting +# +# ZSH_CUSTOM="${ZSH_CUSTOM:-$HOME/.oh-my-zsh/custom}" +# if [[ -d "$HOME/.oh-my-zsh" && ! -d "$ZSH_CUSTOM/plugins/zsh-syntax-highlighting" ]]; then +# git clone https://github.com/zsh-users/zsh-syntax-highlighting.git \ +# "$ZSH_CUSTOM/plugins/zsh-syntax-highlighting" +# fi + +# --- npm global packages -------------------------------------------------- +# npm globals are not tracked by Homebrew; restore them here. +# +# if command -v npm >/dev/null 2>&1; then +# npm install -g typescript prettier eslint +# fi + +# --- macOS app configuration not covered by Mackup ----------------------- +# Example: Dock position +# +# defaults write com.apple.dock orientation -string left +# killall Dock + +# --- SSH key import ------------------------------------------------------- +# Keys are intentionally excluded from Mackup (they are sensitive). +# Restore from an encrypted backup or password manager here: +# +# op item get "SSH Key (personal)" --fields "private key" > "$HOME/.ssh/id_ed25519" +# chmod 600 "$HOME/.ssh/id_ed25519" + +# --- GPG key import ------------------------------------------------------- +# gpg --import /path/to/exported.gpg diff --git a/config/ssr.conf.example b/config/ssr.conf.example index 3353d5b..42626ff 100644 --- a/config/ssr.conf.example +++ b/config/ssr.conf.example @@ -73,6 +73,22 @@ SSR_PREFS_CONFLICT_DEFAULT=accept # Set to "true" to skip all conflict prompts and auto-accept everywhere. # SSR_ASSUME_YES=false +# --------------------------------------------------------------------------- +# Shell framework auto-install (runs after mackup restore) +# --------------------------------------------------------------------------- +# Detect shell frameworks referenced in dotfiles that are not yet installed +# (e.g. oh-my-zsh, nvm, pyenv, rustup, starship, …) and install them. +# Set to "false" to skip this step. +# SSR_SHELL_DEPS_ENABLED=true + +# Additional custom deps, space-separated, pipe-delimited format: +# "name|check_expression|install_command" +# Example: +# SSR_SHELL_DEPS_EXTRA=( +# "my-tool|command -v my-tool|brew install my-tool" +# "my-fw|[[ -d ~/.my-fw ]]|curl -sSL https://example.com/install.sh | bash" +# ) + # --------------------------------------------------------------------------- # macOS restore behavior # --------------------------------------------------------------------------- diff --git a/lib/shell.sh b/lib/shell.sh new file mode 100644 index 0000000..5f616cf --- /dev/null +++ b/lib/shell.sh @@ -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 +}