#!/usr/bin/env bash # ################################################################################ # # Of the theme that I have declared to you, I will now that ye make in harmony # together a Great Music. And since I have kindled you with the Flame # Imperishable, ye shall show forth your powers in adorning this theme, each # with his own thoughts and devices, if he will. But I will sit and hearken, # and be glad that through you great beauty has been wakened into song. # # — Eru Ilúvatar, The Silmarillion # ################################################################################ # # Bootstrap script for ~/.config # # A declarative system for bootstrapping macOS (and potentially Linux) with: # - Homebrew package management # - macOS defaults configuration # - Shell setup (fish) # - Window manager configuration (yabai + skhd) # - Development tools (git, ssh, gpg) # - Emacs configuration # ################################################################################ set -euo pipefail # # OS Detection # KERNEL_NAME=$(uname -s | tr '[:upper:]' '[:lower:]') KERNEL_RELEASE=$(uname -r | tr '[:upper:]' '[:lower:]') OS_NAME="unknown" OS_VERSION="unknown" case $KERNEL_NAME in darwin) OS_NAME=macos OS_VERSION=$(sw_vers -productVersion) ;; linux) case $KERNEL_RELEASE in *arch*|*coreos*) OS_NAME="arch" ;; esac ;; *) ;; esac # # User & Paths # if [ -z "${USER:-}" ]; then USER=$(whoami) fi export XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" export XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}" export XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}" SCRIPT_DIR="$(cd "$(dirname "$(readlink "${BASH_SOURCE[0]}")")" && pwd)" mkdir -p "$HOME/.local/bin" # # Logging Functions # RED='\033[0;31m' GREEN='\033[0;32m' BLUE='\033[0;34m' YELLOW='\033[1;33m' GRAY='\033[0;37m' RESET='\033[0m' function log() { echo -e "$*" } function error() { echo -e "${RED}✗ $*${RESET}" >&2 } function success() { echo -e "${GREEN}✓ $*${RESET}" } function info() { echo -e "${BLUE}→ $*${RESET}" } function warn() { echo -e "${YELLOW}⚠ $*${RESET}" } function task_start() { local task=$1 local description=${2:-$task} if [[ "$DRY_RUN" == "true" ]]; then log "${BLUE}[DRY RUN] $description${RESET}" else log "${BLUE}▶ $description${RESET}" fi } function task_complete() { local task=$1 local description=${2:-$task} if [[ "$DRY_RUN" != "true" ]]; then success "$description" fi } function task_skip() { local reason=$1 log "${GRAY}⊘ Skipped: $reason${RESET}" } function show_greeting() { cat << 'EOF' ╭────────────────────────────────────────────────╮ │ │ │ ⟡ The Ainulindalë Begins ⟡ │ │ │ │ "In the beginning Eru, the One, who in │ │ the Elvish tongue is named Ilúvatar, │ │ made the Ainur of his thought..." │ │ │ ╰────────────────────────────────────────────────╯ ✦ ✦ ✦ ✦ ⟡ ⟡ ✦ ⟡ Theme of ⟡ ✦ ⟡ ⟡ ✦ ✦ ✦ ✦ Shaping your environment with harmony and purpose... EOF } function show_farewell() { cat << 'EOF' ✦ ✦ ✦ ⟡ ⟡ "And great beauty has been wakened into song." ⟡ ⟡ ✦ ✦ ✦ EOF } # # Helper Functions # function fail() { error "$1" exit 1 } function check_command() { local cmd=$1 local error_msg=${2:-"$cmd is not installed"} if ! command -v "$cmd" &> /dev/null; then if [[ "${DRY_RUN:-false}" == "true" ]]; then warn "$error_msg (skipping check in dry-run mode)" return 1 elif [[ "${FORCE:-false}" == "true" ]]; then warn "$error_msg (continuing due to --force)" return 1 else fail "$error_msg" fi fi return 0 } function is_macos() { [[ "$OS_NAME" == "macos" ]] } function is_linux() { [[ "$KERNEL_NAME" == "linux" ]] } function check_homebrew() { command -v brew &> /dev/null } # Create a symlink from source to target # Usage: create_symlink source target [no_backup] # If no_backup is "true", existing files are removed without backup function create_symlink() { local source=$1 local target=$2 local no_backup=${3:-false} # Check if source exists if [[ ! -e "$source" ]]; then info "Skipping $target (source not found: $source)" return 0 fi # Create target directory if needed local target_dir target_dir=$(dirname "$target") if [[ ! -d "$target_dir" ]]; then info "Creating directory: $target_dir" if [[ "$DRY_RUN" != "true" ]]; then mkdir -p "$target_dir" fi fi # Check if it's already the correct symlink if [[ -L "$target" ]] && [[ "$(readlink "$target")" == "$source" ]]; then info "Symlink already exists: $target → $source" return 0 fi # Handle existing target if [[ -e "$target" || -L "$target" ]]; then if [[ "$no_backup" == "true" ]]; then info "Removing existing $target" if [[ "$DRY_RUN" != "true" ]]; then rm -f "$target" fi else local backup backup="${target}.backup.$(date +%Y%m%d_%H%M%S)" warn "Backing up existing $target to $backup" if [[ "$DRY_RUN" != "true" ]]; then mv "$target" "$backup" fi fi fi # Create symlink info "Creating symlink: $target → $source" if [[ "$DRY_RUN" != "true" ]]; then if ln -sf "$source" "$target"; then success "Created symlink: $target" else warn "Failed to create symlink: $target" return 1 fi fi } # # Lock File Management # LOCK_FILE="$XDG_CACHE_HOME/eru/eru.lock" function acquire_lock() { if [ -f "$LOCK_FILE" ]; then fail "Eru is already running. If this is an error, delete $LOCK_FILE" fi mkdir -p "$(dirname "$LOCK_FILE")" touch "$LOCK_FILE" } function release_lock() { rm -f "$LOCK_FILE" } trap release_lock INT TERM EXIT # # Task: Homebrew # function task_homebrew() { task_start "homebrew" "Setting up Homebrew" if ! is_macos; then task_skip "Not on macOS" return 0 fi if check_homebrew; then if [[ "$ACTION" == "upgrade" ]]; then info "Updating Homebrew..." if [[ "$DRY_RUN" != "true" ]]; then if ! brew update; then fail "Failed to update Homebrew" fi fi else info "Homebrew already installed at $(command -v brew)" fi else info "Installing Homebrew..." if [[ "$DRY_RUN" != "true" ]]; then if ! /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"; then fail "Failed to install Homebrew" fi # Add to PATH for this session if [[ -f "/opt/homebrew/bin/brew" ]]; then eval "$(/opt/homebrew/bin/brew shellenv)" fi fi fi task_complete "homebrew" "Homebrew ready" } # # Task: Packages # function task_packages() { local action_label="Installing" [[ "$ACTION" == "upgrade" ]] && action_label="Upgrading" task_start "packages" "$action_label packages" if ! is_macos; then task_skip "Not on macOS (Homebrew only)" return 0 fi if ! check_homebrew; then if [[ "${DRY_RUN:-false}" == "true" ]]; then warn "Homebrew not installed (skipping check in dry-run mode)" else fail "Homebrew not installed. Run: $0 install homebrew" fi fi local brewfiles=() # Common Brewfile if [[ -f "$SCRIPT_DIR/brew/Brewfile" ]]; then brewfiles+=("$SCRIPT_DIR/brew/Brewfile") fi # User-specific Brewfile if [[ -f "$SCRIPT_DIR/brew/$USER.Brewfile" ]]; then brewfiles+=("$SCRIPT_DIR/brew/$USER.Brewfile") fi # Hostname-specific Brewfile local hostname hostname=$(hostname -s) if [[ -f "$SCRIPT_DIR/brew/$hostname.Brewfile" ]]; then brewfiles+=("$SCRIPT_DIR/brew/$hostname.Brewfile") fi if [[ ${#brewfiles[@]} -eq 0 ]]; then warn "No Brewfiles found in $SCRIPT_DIR/brew/" return 0 fi # Upgrade all installed packages when running upgrade action if [[ "$ACTION" == "upgrade" ]]; then info "Upgrading all packages..." if [[ "$DRY_RUN" != "true" ]]; then brew upgrade fi fi for brewfile in "${brewfiles[@]}"; do info "Processing $(basename "$brewfile")..." if [[ "$DRY_RUN" != "true" ]]; then local bundle_args=("--file=$brewfile") [[ -n "${FORCE:-}" ]] && bundle_args+=("--no-upgrade") [[ "${FORMULA_ONLY:-false}" == "true" ]] && bundle_args+=("--formula") if ! brew bundle "${bundle_args[@]}"; then fail "Failed to install packages from $(basename "$brewfile")" fi fi done # claude-code if command -v claude &> /dev/null; then info "Claude is already installed" else info "Installing Claude" if [[ "$DRY_RUN" != "true" ]]; then curl -fsSL https://claude.ai/install.sh | bash fi fi # npm global packages if command -v npm &> /dev/null; then info "Installing global npm packages..." if [[ "$DRY_RUN" != "true" ]]; then npm i -g @zed-industries/claude-code-acp npm i -g @openai/codex npm i -g agent-browser agent-browser install fi else info "npm not available (install node via brew if needed)" fi # go packages if command -v go &> /dev/null; then info "Installing Go packages..." if [[ "$DRY_RUN" != "true" ]]; then GOBIN="$HOME/.local/bin" go install github.com/d12frosted/gitpulse@latest fi else info "go not available (install go via brew if needed)" fi # bun if command -v bun &> /dev/null; then info "bun is already installed" else info "Installing bun..." if [[ "$DRY_RUN" != "true" ]]; then curl -fsSL https://bun.sh/install | bash fi fi local complete_label="installed" [[ "$ACTION" == "upgrade" ]] && complete_label="upgraded" task_complete "packages" "Packages $complete_label" } # # Task: macOS Defaults # function task_macos() { task_start "macos" "Configuring macOS defaults" if ! is_macos; then task_skip "Not on macOS" return 0 fi local defaults_script="$XDG_CONFIG_HOME/macos/defaults.sh" if [[ ! -f "$defaults_script" ]]; then warn "No defaults script found at $defaults_script" return 0 fi info "Running macOS defaults configuration..." if [[ "$DRY_RUN" != "true" ]]; then if ! bash "$defaults_script"; then fail "Failed to apply macOS defaults" fi fi task_complete "macos" "macOS defaults configured" } # # Task: Shell Setup # function task_shell() { task_start "shell" "Setting up fish shell" check_command fish "fish not installed. Run: $0 install packages" local fish_path fish_path=$(command -v fish) # Check if fish is the default shell if [[ "$SHELL" != "$fish_path" ]]; then info "Setting fish as default shell..." # Ensure fish is in /etc/shells if ! grep -q "$fish_path" /etc/shells; then if [[ "$DRY_RUN" != "true" ]]; then if ! echo "$fish_path" | sudo tee -a /etc/shells > /dev/null; then fail "Failed to add fish to /etc/shells" fi fi fi # Change default shell if [[ "$DRY_RUN" != "true" ]]; then if ! sudo chsh -s "$fish_path" "$USER"; then fail "Failed to change default shell to fish" fi success "Default shell changed to fish (restart terminal to take effect)" fi else info "Fish is already the default shell" fi # sync and apply tinty tinty sync tinty apply base16-chinoiserie task_complete "shell" "Shell configured" } # # Task: Window Manager (yabai + skhd) # function task_wm() { task_start "wm" "Configuring window manager" if ! is_macos; then task_skip "Not on macOS" return 0 fi check_command yabai "yabai not installed. Run: $0 install packages" check_command skhd "skhd not installed. Run: $0 install packages" # Configure yabai sudoers info "Configuring yabai sudoers..." local yabai_path yabai_path=$(command -v yabai) local yabai_hash yabai_hash=$(shasum -a 256 "$yabai_path" | cut -d " " -f 1) if [[ "$DRY_RUN" != "true" ]]; then if ! echo "$(whoami) ALL=(root) NOPASSWD: sha256:$yabai_hash $yabai_path --load-sa" | \ sudo tee /private/etc/sudoers.d/yabai > /dev/null; then fail "Failed to configure yabai sudoers" fi info "Restarting yabai..." yabai --stop-service || true if ! yabai --start-service; then fail "Failed to start yabai service" fi fi # Configure skhd info "Configuring skhd..." local plist_path="$HOME/Library/LaunchAgents/com.koekeishiya.skhd.plist" if [[ -f "$plist_path" ]] && grep -q 'SHELL' "$plist_path"; then info "skhd already configured" if [[ "$DRY_RUN" != "true" ]]; then if ! skhd --restart-service; then fail "Failed to restart skhd service" fi fi else info "Patching skhd plist..." if [[ "$DRY_RUN" != "true" ]]; then skhd --stop-service || true skhd --uninstall-service || true if ! skhd --install-service; then fail "Failed to install skhd service" fi if ! /usr/libexec/PlistBuddy -c 'add :EnvironmentVariables:SHELL string /bin/sh' "$plist_path"; then fail "Failed to patch skhd plist" fi if ! skhd --start-service; then fail "Failed to start skhd service" fi fi fi info "Patching skhd PATH" if [[ "$DRY_RUN" != "true" ]]; then skhd --stop-service || true if ! /usr/libexec/PlistBuddy -c "add :EnvironmentVariables:PATH string $PATH" "$plist_path" 2>/dev/null; then if ! /usr/libexec/PlistBuddy -c "Set :EnvironmentVariables:PATH $PATH" "$plist_path"; then fail "Failed to patch skhd plist" fi fi if ! skhd --start-service; then fail "Failed to start skhd service" fi fi task_complete "wm" "Window manager configured" } # # Task: Development Tools # function task_devtools() { task_start "devtools" "Setting up development tools" # SSH key generation if [[ ! -f "$HOME/.ssh/id_ed25519" ]]; then info "Generating SSH key..." if [[ "$DRY_RUN" != "true" ]]; then mkdir -p "$HOME/.ssh" if ! ssh-keygen -t ed25519 -C "${USER}@$(hostname)" -f "$HOME/.ssh/id_ed25519" -N ""; then fail "Failed to generate SSH key" fi success "SSH key generated at $HOME/.ssh/id_ed25519" warn "Add your SSH public key to GitHub/GitLab:" cat "$HOME/.ssh/id_ed25519.pub" fi else info "SSH key already exists" fi # GPG permissions fix if [[ -d "$HOME/.gnupg" ]]; then info "Fixing GPG permissions..." if [[ "$DRY_RUN" != "true" ]]; then if ! chown -R "$(whoami)" "$HOME/.gnupg/"; then warn "Failed to change ownership of GPG directory" fi if ! find "$HOME/.gnupg" -type f -exec chmod 600 {} \;; then warn "Failed to fix GPG file permissions" fi if ! find "$HOME/.gnupg" -type d -exec chmod 700 {} \;; then warn "Failed to fix GPG directory permissions" fi fi fi task_complete "devtools" "Development tools configured" } # # Task: Symlinks # function task_symlinks() { task_start "symlinks" "Creating symlinks" # GnuPG configuration symlinks # GnuPG doesn't support XDG_CONFIG_HOME, so we symlink from ~/.config/gnupg to ~/.gnupg if [[ -d "$XDG_CONFIG_HOME/gnupg" ]]; then info "Setting up GnuPG symlinks..." # Symlink all files from ~/.config/gnupg to ~/.gnupg # Skip .example files and README.md - only symlink actual configs for config_file in "$XDG_CONFIG_HOME/gnupg"/*; do if [[ -f "$config_file" ]]; then local filename filename=$(basename "$config_file") # Skip example files and READMEs if [[ "$filename" == *.example ]] || [[ "$filename" == "README.md" ]]; then continue fi create_symlink "$config_file" "$HOME/.gnupg/$filename" fi done # Fix GnuPG permissions after creating symlinks if [[ -d "$HOME/.gnupg" ]] && [[ "$DRY_RUN" != "true" ]]; then info "Fixing GnuPG permissions..." if ! chown -R "$(whoami)" "$HOME/.gnupg/" 2>/dev/null; then warn "Failed to change ownership of GPG directory (may be normal)" fi if ! find "$HOME/.gnupg" -type f -exec chmod 600 {} \; 2>/dev/null; then warn "Failed to fix GPG file permissions" fi if ! find "$HOME/.gnupg" -type d -exec chmod 700 {} \; 2>/dev/null; then warn "Failed to fix GPG directory permissions" fi fi else info "No GnuPG config found at $XDG_CONFIG_HOME/gnupg" fi # SSH configuration symlinks # SSH can use ~/.ssh but we keep configs in ~/.config/ssh for consistency if [[ -d "$XDG_CONFIG_HOME/ssh" ]]; then info "Setting up SSH symlinks..." # Symlink SSH config files (skip .example files and READMEs) for ssh_file in "$XDG_CONFIG_HOME/ssh"/*; do if [[ -f "$ssh_file" ]]; then local filename filename=$(basename "$ssh_file") # Skip example files and READMEs if [[ "$filename" == *.example ]] || [[ "$filename" == "README.md" ]]; then continue fi create_symlink "$ssh_file" "$HOME/.ssh/$filename" fi done # Fix SSH file permissions (SSH requires strict permissions) if [[ "$DRY_RUN" != "true" ]]; then info "Fixing SSH permissions..." # Config file must be 600 (rw-------) if [[ -f "$XDG_CONFIG_HOME/ssh/config" ]]; then chmod 600 "$XDG_CONFIG_HOME/ssh/config" 2>/dev/null || true fi # Private keys must be 600 find "$XDG_CONFIG_HOME/ssh" -type f -name "id_*" ! -name "*.pub" -exec chmod 600 {} \; 2>/dev/null || true # Public keys can be 644 (rw-r--r--) find "$XDG_CONFIG_HOME/ssh" -type f -name "*.pub" -exec chmod 644 {} \; 2>/dev/null || true fi else info "No SSH config found at $XDG_CONFIG_HOME/ssh" fi # symlink eru.sh to eru create_symlink "$XDG_CONFIG_HOME/eru.sh" "$HOME/.local/bin/eru" # Claude Code settings (doesn't support XDG, so symlink from ~/.config/claude) if [[ -f "$XDG_CONFIG_HOME/claude/settings.json" ]]; then info "Setting up Claude Code symlinks..." mkdir -p "$HOME/.claude" create_symlink "$XDG_CONFIG_HOME/claude/settings.json" "$HOME/.claude/settings.json" fi task_complete "symlinks" "Symlinks created" } # # Task: Services (launchd) # function task_services() { task_start "services" "Setting up launchd services" if ! is_macos; then task_skip "Not on macOS" return 0 fi local launchd_dir="$XDG_CONFIG_HOME/launchd" local agents_dir="$HOME/Library/LaunchAgents" if [[ ! -d "$launchd_dir" ]]; then info "No launchd config directory at $launchd_dir" return 0 fi mkdir -p "$agents_dir" for plist in "$launchd_dir"/*.plist; do [[ -f "$plist" ]] || continue local plist_name plist_name=$(basename "$plist") local label="${plist_name%.plist}" local target="$agents_dir/$plist_name" info "Processing $plist_name..." # Create log directory if StandardOutPath/StandardErrorPath reference it local log_dir log_dir=$(grep -A1 'StandardOutPath\|StandardErrorPath' "$plist" 2>/dev/null | grep '' | sed 's|.*\(.*\).*|\1|' | xargs dirname 2>/dev/null | head -1) if [[ -n "$log_dir" ]] && [[ ! -d "$log_dir" ]]; then info "Creating log directory: $log_dir" if [[ "$DRY_RUN" != "true" ]]; then mkdir -p "$log_dir" fi fi # Unload existing service before modifying symlink if [[ "$DRY_RUN" != "true" ]]; then launchctl unload "$target" 2>/dev/null || true fi # Create symlink (no backup for managed service plists) create_symlink "$plist" "$target" true # Load service if [[ "$DRY_RUN" != "true" ]]; then if launchctl load "$target"; then success "Loaded $label" else warn "Failed to load $label" fi fi done task_complete "services" "Launchd services configured" } # # Task: Emacs # function task_emacs() { task_start "emacs" "Setting up Emacs" check_command emacs "Emacs not installed. Run: $0 install packages" local emacs_dir="$XDG_CONFIG_HOME/emacs" if [[ -f "$emacs_dir/setup.sh" ]]; then info "Running Emacs setup script..." if [[ "$DRY_RUN" != "true" ]]; then cd "$emacs_dir" # Pass subtasks if any were specified, otherwise run default (install) if [[ ${#EMACS_SUBTASKS[@]} -gt 0 ]]; then if ! bash setup.sh "${EMACS_SUBTASKS[@]}"; then fail "Failed to run Emacs setup script" fi else if ! bash setup.sh; then fail "Failed to run Emacs setup script" fi fi fi else warn "No Emacs setup script found at $emacs_dir/setup.sh" fi task_complete "emacs" "Emacs configured" } ################################################################################ # # Doctor Functions # ################################################################################ DOCTOR_ISSUES=0 function doctor_issue() { local message=$1 warn " ✗ $message" DOCTOR_ISSUES=$((DOCTOR_ISSUES + 1)) } function doctor_ok() { local message=$1 success " ✓ $message" } # # Doctor: Brew # function doctor_brew() { info "Checking Homebrew packages..." if ! check_homebrew; then doctor_issue "Homebrew is not installed" return 1 fi # Collect all packages from Brewfiles local brewfiles=() [[ -f "$SCRIPT_DIR/brew/Brewfile" ]] && brewfiles+=("$SCRIPT_DIR/brew/Brewfile") [[ -f "$SCRIPT_DIR/brew/$USER.Brewfile" ]] && brewfiles+=("$SCRIPT_DIR/brew/$USER.Brewfile") local hostname hostname=$(hostname -s) [[ -f "$SCRIPT_DIR/brew/$hostname.Brewfile" ]] && brewfiles+=("$SCRIPT_DIR/brew/$hostname.Brewfile") # Extract package names from Brewfiles (brew and cask) local brewfile_packages=() for brewfile in "${brewfiles[@]}"; do while IFS= read -r line; do # Match: brew "package" or brew "package", args: [...] if [[ $line =~ ^brew[[:space:]]+\"([^\"]+)\" ]]; then local pkg_name="${BASH_REMATCH[1]}" # Extract formula name from tap path (e.g., "tap/repo/formula@ver" -> "formula@ver") pkg_name="${pkg_name##*/}" brewfile_packages+=("$pkg_name") fi # Match: cask "package" if [[ $line =~ ^cask[[:space:]]+\"([^\"]+)\" ]]; then brewfile_packages+=("${BASH_REMATCH[1]}") fi done < "$brewfile" done # Get installed top-level packages (leaves = not dependencies) local installed_leaves installed_leaves=$(brew leaves) # Get installed casks local installed_casks installed_casks=$(brew list --cask 2>/dev/null) # Find orphan formulae (installed but not in Brewfile) local orphan_formulae=() while IFS= read -r pkg; do [[ -z "$pkg" ]] && continue # Extract formula name from tap path for comparison local pkg_name="${pkg##*/}" local found=false for bp in "${brewfile_packages[@]}"; do if [[ "$pkg_name" == "$bp" ]]; then found=true break fi done if [[ "$found" == "false" ]]; then orphan_formulae+=("$pkg") fi done <<< "$installed_leaves" # Find orphan casks local orphan_casks=() while IFS= read -r pkg; do [[ -z "$pkg" ]] && continue local found=false for bp in "${brewfile_packages[@]}"; do if [[ "$pkg" == "$bp" ]]; then found=true break fi done if [[ "$found" == "false" ]]; then orphan_casks+=("$pkg") fi done <<< "$installed_casks" # Report if [[ ${#orphan_formulae[@]} -eq 0 ]] && [[ ${#orphan_casks[@]} -eq 0 ]]; then doctor_ok "All installed packages are in Brewfiles" else if [[ ${#orphan_formulae[@]} -gt 0 ]]; then doctor_issue "Orphan formulae (not in Brewfile):" for pkg in "${orphan_formulae[@]}"; do log " brew \"$pkg\"" done fi if [[ ${#orphan_casks[@]} -gt 0 ]]; then doctor_issue "Orphan casks (not in Brewfile):" for pkg in "${orphan_casks[@]}"; do log " cask \"$pkg\"" done fi fi } # # Doctor: Shell # function doctor_shell() { info "Checking shell configuration..." local fish_path fish_path=$(command -v fish 2>/dev/null) if [[ -z "$fish_path" ]]; then doctor_issue "Fish shell is not installed" return 1 fi # Check if fish is in /etc/shells if grep -q "^$fish_path$" /etc/shells 2>/dev/null; then doctor_ok "Fish is in /etc/shells" else doctor_issue "Fish is not in /etc/shells" fi # Check if fish is the default shell local current_shell current_shell=$(dscl . -read ~/ UserShell 2>/dev/null | awk '{print $2}') if [[ "$current_shell" == "$fish_path" ]]; then doctor_ok "Fish is the default shell" else doctor_issue "Default shell is $current_shell (expected $fish_path)" fi } # # Doctor: Window Manager # function doctor_wm() { info "Checking window manager..." if ! is_macos; then info " Skipping (not on macOS)" return 0 fi # Check yabai if command -v yabai &>/dev/null; then if pgrep -x yabai &>/dev/null; then doctor_ok "yabai is running" else doctor_issue "yabai is installed but not running" fi else doctor_issue "yabai is not installed" fi # Check skhd if command -v skhd &>/dev/null; then if pgrep -x skhd &>/dev/null; then doctor_ok "skhd is running" else doctor_issue "skhd is installed but not running" fi else doctor_issue "skhd is not installed" fi } # # Doctor: Symlinks # function doctor_symlinks() { info "Checking symlinks..." # Check GnuPG symlinks if [[ -d "$XDG_CONFIG_HOME/gnupg" ]]; then for config_file in "$XDG_CONFIG_HOME/gnupg"/*; do if [[ -f "$config_file" ]]; then local filename filename=$(basename "$config_file") # Skip example files and READMEs [[ "$filename" == *.example ]] && continue [[ "$filename" == "README.md" ]] && continue local target="$HOME/.gnupg/$filename" if [[ -L "$target" ]] && [[ "$(readlink "$target")" == "$config_file" ]]; then doctor_ok "\$HOME/.gnupg/$filename → $config_file" else doctor_issue "\$HOME/.gnupg/$filename is not symlinked correctly" fi fi done fi # Check SSH config symlink local ssh_source="$XDG_CONFIG_HOME/ssh/config" local ssh_target="$HOME/.ssh/config" if [[ -f "$ssh_source" ]]; then if [[ -L "$ssh_target" ]] && [[ "$(readlink "$ssh_target")" == "$ssh_source" ]]; then doctor_ok "\$HOME/.ssh/config → $ssh_source" else doctor_issue "\$HOME/.ssh/config is not symlinked correctly" fi fi } # # Doctor: Services # function doctor_services() { info "Checking launchd services..." if ! is_macos; then info " Skipping (not on macOS)" return 0 fi local launchd_dir="$XDG_CONFIG_HOME/launchd" if [[ ! -d "$launchd_dir" ]]; then info " No launchd config directory" return 0 fi for plist in "$launchd_dir"/*.plist; do [[ -f "$plist" ]] || continue local plist_name plist_name=$(basename "$plist") local label="${plist_name%.plist}" local target="$HOME/Library/LaunchAgents/$plist_name" # Check symlink if [[ -L "$target" ]] && [[ "$(readlink "$target")" == "$plist" ]]; then doctor_ok "$plist_name symlinked" else doctor_issue "$plist_name not symlinked to LaunchAgents" fi # Check if loaded if launchctl list | grep -q "^[0-9-]*[[:space:]]*[0-9-]*[[:space:]]*$label$"; then doctor_ok "$label is loaded" else doctor_issue "$label is not loaded" fi done } # # Doctor: Emacs # function doctor_emacs() { info "Checking Emacs..." if ! command -v emacs &>/dev/null; then doctor_issue "Emacs is not installed" return 1 fi doctor_ok "Emacs is installed" local emacs_dir="$XDG_CONFIG_HOME/emacs" if [[ ! -d "$emacs_dir" ]]; then doctor_issue "Emacs config directory not found: $emacs_dir" return 1 fi # Check if eldev is available if ! command -v eldev &>/dev/null; then doctor_issue "eldev is not installed (needed for linting)" return 1 fi doctor_ok "eldev is installed" # Run eldev lint info "Running eldev lint..." pushd "$emacs_dir" > /dev/null || return 1 if eldev lint 2>&1; then doctor_ok "eldev lint passed" else doctor_issue "eldev lint found issues" fi popd > /dev/null || return 1 } # # Doctor: Main # function run_doctor() { local checks=("$@") # Default to all checks if none specified if [[ ${#checks[@]} -eq 0 ]]; then checks=(brew shell wm symlinks services emacs) fi DOCTOR_ISSUES=0 log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" info "Running doctor checks: ${checks[*]}" log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" log "" for check in "${checks[@]}"; do case "$check" in brew) doctor_brew ;; shell) doctor_shell ;; wm) doctor_wm ;; symlinks) doctor_symlinks ;; services) doctor_services ;; emacs) doctor_emacs ;; *) warn "Unknown doctor check: $check" ;; esac log "" done log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" if [[ $DOCTOR_ISSUES -eq 0 ]]; then success "All checks passed!" else warn "Found $DOCTOR_ISSUES issue(s)" fi log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" return $DOCTOR_ISSUES } # # Task Registry # # Check if a task name is valid function is_valid_task() { case "$1" in homebrew|packages|macos|shell|wm|devtools|symlinks|services|emacs) return 0 ;; *) return 1 ;; esac } # Get the function name for a task function get_task_function() { case "$1" in homebrew) echo "task_homebrew" ;; packages) echo "task_packages" ;; macos) echo "task_macos" ;; shell) echo "task_shell" ;; wm) echo "task_wm" ;; devtools) echo "task_devtools" ;; symlinks) echo "task_symlinks" ;; services) echo "task_services" ;; emacs) echo "task_emacs" ;; *) return 1 ;; esac } # Default task order for full installation DEFAULT_TASKS=(homebrew packages macos shell wm devtools symlinks services emacs) # # Main Execution # function show_usage() { cat << EOF Usage: $0 [tasks...] [options] Actions: install Run installation tasks upgrade Run upgrade tasks doctor Run health checks Tasks for install/upgrade (run all if none specified): homebrew Install/update Homebrew packages Install packages from Brewfiles macos Configure macOS defaults shell Set up fish shell wm Configure yabai + skhd devtools Set up git, ssh, gpg symlinks Create symlinks services Set up launchd services emacs Set up Emacs (supports emacs:subtask syntax) Checks for doctor (run all if none specified): brew Check for orphan packages not in Brewfiles shell Verify fish is the default shell wm Check yabai/skhd are running symlinks Verify symlinks are correct services Check launchd services are loaded emacs Run eldev lint Options: --dry-run Show what would be done without doing it --force Skip dependency checks -h, --help Show this help Examples: $0 install # Full installation $0 install packages wm # Just packages and window manager $0 upgrade packages # Upgrade packages only $0 install emacs:config # Run specific Emacs subtask $0 install --dry-run # See what would happen $0 doctor # Run all health checks $0 doctor brew # Check only brew packages EOF } function main() { # Parse action ACTION=${1:-} case "$ACTION" in install|upgrade) shift ;; doctor) shift # Collect doctor checks local doctor_checks=() while [[ $# -gt 0 ]]; do case "$1" in -h|--help) show_usage exit 0 ;; brew|shell|wm|symlinks|services|emacs) doctor_checks+=("$1") shift ;; *) error "Unknown doctor check: $1" show_usage exit 1 ;; esac done show_greeting run_doctor ${doctor_checks[@]+"${doctor_checks[@]}"} exit $? ;; -h|--help|help) show_usage exit 0 ;; "") error "No action specified" show_usage exit 1 ;; *) error "Unknown action: $ACTION" show_usage exit 1 ;; esac # Parse options and tasks DRY_RUN=false FORCE=false SELECTED_TASKS=() EMACS_SUBTASKS=() while [[ $# -gt 0 ]]; do case "$1" in --dry-run) DRY_RUN=true shift ;; --force) FORCE=true shift ;; -h|--help) show_usage exit 0 ;; emacs:*) SELECTED_TASKS+=(emacs) EMACS_SUBTASKS+=("${1#emacs:}") shift ;; *) if is_valid_task "$1"; then SELECTED_TASKS+=("$1") else error "Unknown task: $1" show_usage exit 1 fi shift ;; esac done # Use default tasks if none specified if [[ ${#SELECTED_TASKS[@]} -eq 0 ]]; then SELECTED_TASKS=("${DEFAULT_TASKS[@]}") fi # Show greeting show_greeting # Show configuration log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" info "Bootstrap Configuration" log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" log " Action: $ACTION" log " OS: $OS_NAME $OS_VERSION" log " User: $USER" log " Hostname: $(hostname -s)" log " Tasks: ${SELECTED_TASKS[*]}" if [[ ${#EMACS_SUBTASKS[@]} -gt 0 ]]; then log " Emacs: ${EMACS_SUBTASKS[*]}" fi if [[ "$DRY_RUN" == "true" ]]; then log " Mode: ${YELLOW}DRY RUN${RESET}" fi log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" log "" # Acquire lock if [[ "$DRY_RUN" != "true" ]]; then acquire_lock fi # Run tasks local failed_tasks=() local completed_tasks=() for task in "${SELECTED_TASKS[@]}"; do if ! "$(get_task_function "$task")"; then failed_tasks+=("$task") else completed_tasks+=("$task") fi log "" done # Summary log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" info "Summary" log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" if [[ ${#completed_tasks[@]} -gt 0 ]]; then success "Completed: ${completed_tasks[*]}" fi if [[ ${#failed_tasks[@]} -gt 0 ]]; then error "Failed: ${failed_tasks[*]}" exit 1 fi if [[ "$DRY_RUN" == "true" ]]; then log "" info "This was a dry run. Run without --dry-run to apply changes." fi show_farewell success "The theme is complete!" } main "$@"