diff options
Diffstat (limited to 'common/install.sh')
| -rwxr-xr-x | common/install.sh | 3715 |
1 files changed, 3715 insertions, 0 deletions
diff --git a/common/install.sh b/common/install.sh new file mode 100755 index 0000000..4a2b209 --- /dev/null +++ b/common/install.sh @@ -0,0 +1,3715 @@ +#!/usr/bin/env bash + +# Created By: srdusr +# Created On: Tue 06 Sep 2025 16:20:52 PM CAT +# Project: Dotfiles installation script + +# TODO: allow optional change user/password, also optional change root password, first check if they are the same (auto) + +# Dependencies: git, curl + +set -euo pipefail # Exit on error, undefined vars, pipe failures + +#====================================== +# Variables & Configuration +#====================================== + +# Color definitions +NOCOLOR='\033[0m' +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +WHITE='\033[0;37m' +BOLD='\033[1m' + +# Dotfiles configuration +DOTFILES_URL='https://github.com/srdusr/dotfiles.git' +DOTFILES_DIR="$HOME/.cfg" +LOG_FILE="$HOME/.local/share/dotfiles_install.log" +STATE_FILE="$HOME/.local/share/dotfiles_install_state" +BACKUP_DIR="$HOME/.dotfiles-backup-$(date +%Y%m%d-%H%M%S)" +PACKAGES_FILE="packages.yml" + +# Network connectivity check +CONNECTIVITY_CHECKED=false +INTERNET_AVAILABLE=false + +# Installation tracking +INSTALL_SUMMARY=() + +FAILED_ITEMS=() +SKIPPED_ITEMS=() +COMPLETED_STEPS=() + +# Script options +RESUME_MODE=false +UPDATE_MODE=false +VERBOSE_MODE=false +DRY_RUN=false +FORCE_MODE=false +ASK_MODE=false # New: ask for each step +INSTALL_MODE="ask" # ask, essentials, full, profile + +# Global variables for system detection +CFG_OS="" +DISTRO="" +PACKAGE_MANAGER="" +PACKAGE_UPDATE_CMD="" +PACKAGE_INSTALL_CMD="" +PRIVILEGE_TOOL="" +PRIVILEGE_CACHED=false + +# Essential tools needed by this script +ESSENTIAL_TOOLS=("git" "curl" "wget") +PACKAGE_TOOLS=("yq" "jq") + +# Config command tracking +CONFIG_COMMAND_AVAILABLE=false +CONFIG_COMMAND_FILE="" + +# Steps can be skipped by providing a comma-separated list in SKIP_STEPS +SKIP_STEPS="${SKIP_STEPS:-}" + +# Run control: run only a specific step, or start from a specific step +RUN_ONLY_STEP="${RUN_ONLY_STEP:-}" +RUN_FROM_STEP="${RUN_FROM_STEP:-}" +__RUN_FROM_STARTED=false + +# Interactive per-step prompt even without --ask (opt-in) +# Set INTERACTIVE_SKIP=true to be prompted for non-essential steps. +INTERACTIVE_SKIP="${INTERACTIVE_SKIP:-false}" + +# Steps considered essential (should rarely be skipped) +ESSENTIAL_STEPS=( + setup_environment + check_connectivity + detect_package_manager + install_dependencies +) + +is_step_skipped() { + local step="$1" + [[ ",${SKIP_STEPS}," == *",${step},"* ]] +} + +skip_step_if_requested() { + local step="$1" + if is_step_skipped "$step"; then + print_skip "Skipping step by request: $step" + mark_step_completed "$step" + return 1 + fi + return 0 +} + +should_run_step() { + local step="$1" + # If RUN_ONLY_STEP is set, only run that exact step + if [[ -n "$RUN_ONLY_STEP" && "$step" != "$RUN_ONLY_STEP" ]]; then + print_skip "Skipping step (RUN_ONLY_STEP=$RUN_ONLY_STEP): $step" + return 1 + fi + # If RUN_FROM_STEP is set, skip until we reach it, then run subsequent steps + if [[ -n "$RUN_FROM_STEP" && "$__RUN_FROM_STARTED" != true ]]; then + if [[ "$step" == "$RUN_FROM_STEP" ]]; then + __RUN_FROM_STARTED=true + else + print_skip "Skipping step until RUN_FROM_STEP=$RUN_FROM_STEP: $step" + return 1 + fi + fi + return 0 +} + +# Installation profiles +declare -A INSTALLATION_PROFILES=( + ["essentials"]="Essential packages only (git, curl, wget, vim, zsh)" + ["minimal"]="Minimal setup for basic development" + ["dev"]="Full development environment" + ["server"]="Server configuration" + ["full"]="Complete installation with all packages" +) + +# Installation steps configuration +declare -A INSTALLATION_STEPS=( + ["setup_environment"]="Setup installation environment" + ["check_connectivity"]="Check internet connectivity" + ["detect_package_manager"]="Detect or configure package manager" + ["install_dependencies"]="Install dependencies" + ["install_dotfiles"]="Install dotfiles repository" + ["setup_user_dirs"]="Setup user directories" + ["install_essentials"]="Install essential tools" + ["install_packages"]="Install system packages" + ["setup_shell"]="Setup shell environment" + ["setup_ssh"]="Setup SSH configuration" + ["configure_services"]="Configure system services" + ["setup_development_environment"]="Setup development environment" + ["apply_tweaks"]="Apply system tweaks" + ["deploy_config"]="Deploy config command and dotfiles" +) + +# Step order +STEP_ORDER=( + "setup_environment" + "check_connectivity" + "detect_package_manager" + "install_dependencies" + "install_dotfiles" + "deploy_config" + "setup_user_dirs" + "install_essentials" + "install_packages" + "setup_shell" + "setup_ssh" + "configure_services" + "setup_development_environment" + "apply_tweaks" +) + +#====================================== +# State Management Functions +#====================================== + +save_state() { + local current_step="$1" + local status="$2" + + mkdir -p "$(dirname "$STATE_FILE")" + + { + echo "LAST_STEP=$current_step" + echo "STEP_STATUS=$status" + echo "TIMESTAMP=$(date +%s)" + echo "RESUME_AVAILABLE=true" + echo "PRIVILEGE_CACHED=$PRIVILEGE_CACHED" + echo "INSTALL_MODE=$INSTALL_MODE" + echo "COMPLETED_STEPS=(${COMPLETED_STEPS[*]})" + echo "CFG_OS=$CFG_OS" + echo "DISTRO=${DISTRO:-}" + echo "PACKAGE_MANAGER=${PACKAGE_MANAGER:-}" + echo "PRIVILEGE_TOOL=${PRIVILEGE_TOOL:-}" + echo "CONNECTIVITY_CHECKED=$CONNECTIVITY_CHECKED" + echo "INTERNET_AVAILABLE=$INTERNET_AVAILABLE" + } > "$STATE_FILE" +} + +load_state() { + if [[ -f "$STATE_FILE" ]]; then + source "$STATE_FILE" + return 0 + else + return 1 + fi +} + +clear_state() { + [[ -f "$STATE_FILE" ]] && rm -f "$STATE_FILE" +} + +is_step_completed() { + local step="$1" + [[ " ${COMPLETED_STEPS[*]} " =~ " ${step} " ]] +} + +mark_step_completed() { + local step="$1" + if ! is_step_completed "$step"; then + COMPLETED_STEPS+=("$step") + fi + save_state "$step" "completed" +} + +mark_step_failed() { + local step="$1" + save_state "$step" "failed" +} + +#====================================== +# UI Functions +#====================================== + +print_color() { + local color="$1" + local message="$2" + echo -e "${color}${message}${NOCOLOR}" + + if [[ -n "${LOG_FILE:-}" && -f "$LOG_FILE" ]]; then + echo "$(date +'%Y-%m-%d %H:%M:%S') - $message" >> "$LOG_FILE" + fi +} + +print_header() { + local title="$1" + local border_char="=" + local border_length=60 + + echo + print_color "$CYAN" "$(printf '%*s' $border_length '' | tr ' ' "$border_char")" + print_color "$CYAN$BOLD" "$(printf '%*s' $(((border_length + ${#title}) / 2)) "$title")" + print_color "$CYAN" "$(printf '%*s' $border_length '' | tr ' ' "$border_char")" + echo +} + +print_section() { + local title="$1" + echo + print_color "$BLUE$BOLD" "▶ $title" + print_color "$BLUE" "$(printf '%*s' $((${#title} + 2)) '' | tr ' ' '-')" +} + +print_success() { + local message="$1" + print_color "$GREEN" "✓ $message" + INSTALL_SUMMARY+=("✓ $message") +} + +print_error() { + local message="$1" + print_color "$RED" "✗ $message" >&2 + FAILED_ITEMS+=("✗ $message") +} + +print_warning() { + local message="$1" + print_color "$YELLOW" "⚠ $message" +} + +print_info() { + local message="$1" + if [[ "$VERBOSE_MODE" == true ]] || [[ "${2:-}" == "always" ]]; then + print_color "$CYAN" "ℹ $message" + fi +} + +print_skip() { + local message="$1" + print_color "$YELLOW" "⏭ $message" + SKIPPED_ITEMS+=("⏭ $message") +} + +print_dry_run() { + local message="$1" + print_color "$CYAN" "[DRY RUN] $message" +} + +#====================================== +# Network Connectivity Functions +#====================================== + +check_internet_connectivity() { + if [[ "$CONNECTIVITY_CHECKED" == true ]]; then + return $([[ "$INTERNET_AVAILABLE" == true ]] && echo 0 || echo 1) + fi + + print_section "Checking Internet Connectivity" + + local test_urls=("8.8.8.8" "1.1.1.1" "google.com" "github.com") + + for url in "${test_urls[@]}"; do + if ping -c 1 -W 2 "$url" &>/dev/null || curl -s --connect-timeout 5 "https://$url" &>/dev/null; then + INTERNET_AVAILABLE=true + CONNECTIVITY_CHECKED=true + print_success "Internet connectivity confirmed" + return 0 + fi + done + + INTERNET_AVAILABLE=false + CONNECTIVITY_CHECKED=true + print_error "No internet connectivity detected" + + # Try to connect to WiFi or prompt user + attempt_network_connection + + return 1 +} + +attempt_network_connection() { + print_warning "Attempting to establish network connection..." + + # Try NetworkManager + if command_exists nmcli; then + print_info "Available WiFi networks:" + nmcli device wifi list 2>/dev/null || print_warning "Could not list WiFi networks" + + if prompt_user "Would you like to connect to a WiFi network?"; then + print_color "$YELLOW" "Enter WiFi network name (SSID): " + read -r wifi_ssid + if [[ -n "$wifi_ssid" ]]; then + print_color "$YELLOW" "Enter WiFi password: " + read -rs wifi_password + echo + + if execute_with_privilege "nmcli device wifi connect '$wifi_ssid' password '$wifi_password'"; then + print_success "Connected to WiFi network: $wifi_ssid" + # Re-check connectivity + CONNECTIVITY_CHECKED=false + check_internet_connectivity + return $? + else + print_error "Failed to connect to WiFi network" + fi + fi + fi + fi + + # Try other connection methods + if command_exists iwctl; then + print_info "You can also connect manually using iwctl" + fi + + return 1 +} + +#====================================== +# System Detection Functions +#====================================== + +detect_os() { + case "$(uname -s)" in + Linux) CFG_OS="linux" ;; + Darwin) CFG_OS="macos" ;; + MINGW*|MSYS*|CYGWIN*) CFG_OS="windows" ;; + *) CFG_OS="unknown" ;; + esac + + print_info "Detected OS: $CFG_OS" "always" +} + +detect_privilege_tools() { + if [[ "$(id -u)" -eq 0 ]]; then + PRIVILEGE_TOOL="" + print_info "Running as root, no privilege escalation needed" + return 0 + fi + + for tool in sudo doas pkexec; do + if command -v "$tool" &>/dev/null; then + PRIVILEGE_TOOL="$tool" + print_success "Using privilege escalation tool: $PRIVILEGE_TOOL" + return 0 + fi + done + + print_warning "No privilege escalation tool found (sudo, doas, pkexec)" + PRIVILEGE_TOOL="" + return 1 +} + +test_privilege_access() { + if [[ "$PRIVILEGE_CACHED" == true ]]; then + return 0 + fi + + if [[ -z "$PRIVILEGE_TOOL" ]]; then + return 0 # Running as root or no privilege needed + fi + + print_info "Testing privilege access..." + if "$PRIVILEGE_TOOL" -v &>/dev/null || echo "test" | "$PRIVILEGE_TOOL" -S true &>/dev/null; then + PRIVILEGE_CACHED=true + print_success "Privilege access confirmed" + return 0 + else + print_error "Failed to obtain privilege access" + return 1 + fi +} + +detect_package_manager() { + print_section "Detecting Package Manager" + save_state "detect_package_manager" "started" + + # First try to detect from OS release files + if [[ "$CFG_OS" == "linux" && -f /etc/os-release ]]; then + source /etc/os-release + case "$ID" in + arch|manjaro|endeavouros|artix) + DISTRO="$ID" + PACKAGE_MANAGER="pacman" + PACKAGE_UPDATE_CMD="pacman -Sy" + PACKAGE_INSTALL_CMD="pacman -S --noconfirm" + ;; + debian|ubuntu|mint|pop|elementary|zorin) + DISTRO="$ID" + PACKAGE_MANAGER="apt" + PACKAGE_UPDATE_CMD="apt-get update" + PACKAGE_INSTALL_CMD="apt-get install -y" + ;; + fedora|rhel|centos|rocky|almalinux) + DISTRO="$ID" + PACKAGE_MANAGER="dnf" + PACKAGE_UPDATE_CMD="dnf check-update" + PACKAGE_INSTALL_CMD="dnf install -y" + ;; + opensuse*|sles) + DISTRO="$ID" + PACKAGE_MANAGER="zypper" + PACKAGE_UPDATE_CMD="zypper refresh" + PACKAGE_INSTALL_CMD="zypper install -y" + ;; + gentoo) + DISTRO="$ID" + PACKAGE_MANAGER="portage" + PACKAGE_UPDATE_CMD="emerge --sync" + PACKAGE_INSTALL_CMD="emerge" + ;; + alpine) + DISTRO="$ID" + PACKAGE_MANAGER="apk" + PACKAGE_UPDATE_CMD="apk update" + PACKAGE_INSTALL_CMD="apk add" + ;; + void) + DISTRO="$ID" + PACKAGE_MANAGER="xbps" + PACKAGE_UPDATE_CMD="xbps-install -S" + PACKAGE_INSTALL_CMD="xbps-install -y" + ;; + nixos) + DISTRO="$ID" + PACKAGE_MANAGER="nix" + PACKAGE_UPDATE_CMD="nix-channel --update" + PACKAGE_INSTALL_CMD="nix-env -iA nixpkgs." + ;; + esac + elif [[ "$CFG_OS" == "macos" ]]; then + DISTRO="macos" + if command -v brew &>/dev/null; then + PACKAGE_MANAGER="brew" + PACKAGE_UPDATE_CMD="brew update" + PACKAGE_INSTALL_CMD="brew install" + else + PACKAGE_MANAGER="brew-install" + fi + fi + + # Fallback: detect by available commands + if [[ -z "$PACKAGE_MANAGER" ]]; then + local managers=( + "pacman:pacman:pacman -Sy:pacman -S --noconfirm" + "apt:apt:apt-get update:apt-get install -y" + "dnf:dnf:dnf check-update:dnf install -y" + "yum:yum:yum check-update:yum install -y" + "zypper:zypper:zypper refresh:zypper install -y" + "emerge:portage:emerge --sync:emerge" + "apk:apk:apk update:apk add" + "xbps-install:xbps:xbps-install -S:xbps-install -y" + "nix-env:nix:nix-channel --update:nix-env -iA nixpkgs." + "pkg:pkg:pkg update:pkg install -y" + "brew:brew:brew update:brew install" + ) + + for manager in "${managers[@]}"; do + local cmd="${manager%%:*}" + local name="${manager#*:}"; name="${name%%:*}" + local update_cmd="${manager#*:*:}"; update_cmd="${update_cmd%%:*}" + local install_cmd="${manager##*:}" + + if command -v "$cmd" &>/dev/null; then + PACKAGE_MANAGER="$name" + PACKAGE_UPDATE_CMD="$update_cmd" + PACKAGE_INSTALL_CMD="$install_cmd" + break + fi + done + fi + + if [[ -n "$PACKAGE_MANAGER" ]]; then + print_success "Detected package manager: $PACKAGE_MANAGER" + [[ -n "$DISTRO" ]] && print_info "Distribution: $DISTRO" + + # Try to override commands from packages.yml -> package_managers + # Find packages.yml in standard locations + local original_dir="$PWD" + cd "$HOME" 2>/dev/null || true + local packages_files=("$PACKAGES_FILE" "common/$PACKAGES_FILE" ".cfg/common/$PACKAGES_FILE") + local found_packages_file="" + for pf in "${packages_files[@]}"; do + if [[ -f "$pf" ]]; then + found_packages_file="$pf" + break + fi + done + cd "$original_dir" 2>/dev/null || true + + if command_exists yq && [[ -n "$found_packages_file" ]]; then + # Prefer distro block, fallback to manager block + # Initialize to avoid set -u (nounset) issues before assignment + local pm_update="" pm_install="" + if [[ -n "$DISTRO" ]]; then + pm_update=$(yq eval ".package_managers.${DISTRO}.update" "$found_packages_file" 2>/dev/null | grep -v "^null$" || echo "") + pm_install=$(yq eval ".package_managers.${DISTRO}.install" "$found_packages_file" 2>/dev/null | grep -v "^null$" || echo "") + fi + if [[ -z "$pm_update" || -z "$pm_install" ]]; then + pm_update=$(yq eval ".package_managers.${PACKAGE_MANAGER}.update" "$found_packages_file" 2>/dev/null | grep -v "^null$" || echo "") + pm_install=$(yq eval ".package_managers.${PACKAGE_MANAGER}.install" "$found_packages_file" 2>/dev/null | grep -v "^null$" || echo "") + fi + if [[ -n "$pm_update" && -n "$pm_install" ]]; then + PACKAGE_UPDATE_CMD="$pm_update" + PACKAGE_INSTALL_CMD="$pm_install" + print_info "Using package manager commands from packages.yml" + fi + fi + + # Export for compatibility with packages.yml custom commands that reference CFG_DISTRO + export CFG_DISTRO="$DISTRO" + + mark_step_completed "detect_package_manager" + return 0 + else + print_error "Could not detect package manager" + manual_package_manager_setup + return $? + fi +} + +manual_package_manager_setup() { + print_warning "No supported package manager detected automatically" + print_info "Please provide package manager commands manually:" + + while true; do + print_color "$YELLOW" "Enter package update command (e.g., 'apt-get update'): " + read -r PACKAGE_UPDATE_CMD + [[ -n "$PACKAGE_UPDATE_CMD" ]] && break + print_warning "Update command cannot be empty" + done + + while true; do + print_color "$YELLOW" "Enter package install command (e.g., 'apt-get install -y'): " + read -r PACKAGE_INSTALL_CMD + [[ -n "$PACKAGE_INSTALL_CMD" ]] && break + print_warning "Install command cannot be empty" + done + + PACKAGE_MANAGER="manual" + print_success "Manual package manager configuration set" + print_info "Update command: $PACKAGE_UPDATE_CMD" + print_info "Install command: $PACKAGE_INSTALL_CMD" + + mark_step_completed "detect_package_manager" + return 0 +} + +#====================================== +# Utility Functions +#====================================== + + +command_exists() { + command -v "$1" &>/dev/null +} + +execute_command() { + local cmd="$*" + + if [[ "$DRY_RUN" == true ]]; then + print_dry_run "$cmd" + return 0 + fi + + if [[ "$VERBOSE_MODE" == true ]]; then + print_info "Running: $cmd" + fi + + eval "$cmd" +} + +execute_with_privilege() { + local cmd="$*" + + if [[ "$DRY_RUN" == true ]]; then + if [[ -n "$PRIVILEGE_TOOL" ]]; then + print_dry_run "$PRIVILEGE_TOOL $cmd" + else + print_dry_run "$cmd" + fi + return 0 + fi + + if [[ -n "$PRIVILEGE_TOOL" ]]; then + if [[ "$PRIVILEGE_CACHED" != true ]]; then + test_privilege_access || return 1 + fi + eval "$PRIVILEGE_TOOL $cmd" + else + eval "$cmd" + fi +} + +prompt_user() { + local question="$1" + local default="${2:-Y}" + local response + + if [[ "$FORCE_MODE" == true ]]; then + print_info "Auto-answering '$question' with: $default" + [[ "$default" =~ ^[Yy] ]] && return 0 || return 1 + fi + + while true; do + if [[ "$default" == "Y" ]]; then + printf "%b%s%b" "$YELLOW" "$question [Y/n]: " "$NOCOLOR" + else + printf "%b%s%b" "$YELLOW" "$question [y/N]: " "$NOCOLOR" + fi + + read -r response + + if [[ -z "$response" ]]; then + response="$default" + fi + + case "${response^^}" in + Y|YES) echo; return 0 ;; + N|NO) echo; return 1 ;; + *) echo; print_warning "Please answer Y/yes or N/no" ;; + esac + done +} + +create_dir() { + local dir="$1" + local permissions="${2:-755}" + + if [[ "$DRY_RUN" == true ]]; then + print_dry_run "Create directory: $dir (mode: $permissions)" + return 0 + fi + + if [[ ! -d "$dir" ]]; then + mkdir -p "$dir" || { + print_error "Failed to create directory: $dir" + return 1 + } + chmod "$permissions" "$dir" + print_success "Created directory: $dir" + else + print_info "Directory already exists: $dir" + fi +} + +setup_logging() { + local log_dir + log_dir="$(dirname "$LOG_FILE")" + + if [[ ! -d "$log_dir" ]]; then + mkdir -p "$log_dir" || { + print_error "Failed to create log directory: $log_dir" + exit 1 + } + fi + + { + echo "=======================================" + echo "Dotfiles Installation Log" + echo "Date: $(date)" + echo "User: $USER" + echo "Host: ${HOSTNAME:-$(hostname)}" + echo "OS: $(uname -s)" + echo "Install Mode: $INSTALL_MODE" + echo "=======================================" + echo + } > "$LOG_FILE" + + print_info "Log file initialized: $LOG_FILE" "always" +} + +get_package_names() { + local package="$1" + local packages_file="${2:-}" + + # If packages.yml is available, check for distribution-specific mappings + if [[ -n "$packages_file" ]] && [[ -f "$packages_file" ]] && command_exists yq; then + local distro_packages="" + + # Try to get package name(s) for current distribution + case "$DISTRO" in + arch|manjaro|endeavouros|artix) + distro_packages=$(yq eval ".arch.$package" "$packages_file" 2>/dev/null | grep -v "^null$" || echo "") + ;; + debian|ubuntu|mint|pop|elementary|zorin) + distro_packages=$(yq eval ".debian.$package" "$packages_file" 2>/dev/null | grep -v "^null$" || echo "") + ;; + fedora|rhel|centos|rocky|almalinux) + distro_packages=$(yq eval ".rhel.$package" "$packages_file" 2>/dev/null | grep -v "^null$" || echo "") + ;; + opensuse*|sles) + distro_packages=$(yq eval ".opensuse.$package" "$packages_file" 2>/dev/null | grep -v "^null$" || echo "") + ;; + gentoo) + distro_packages=$(yq eval ".gentoo.$package" "$packages_file" 2>/dev/null | grep -v "^null$" || echo "") + ;; + alpine) + distro_packages=$(yq eval ".alpine.$package" "$packages_file" 2>/dev/null | grep -v "^null$" || echo "") + ;; + void) + distro_packages=$(yq eval ".void.$package" "$packages_file" 2>/dev/null | grep -v "^null$" || echo "") + ;; + macos) + # macOS uses array format, check if package exists in the list + if yq eval ".macos[]" "$packages_file" 2>/dev/null | grep -q "^$package$"; then + distro_packages="$package" + fi + ;; + esac + + # Return the distribution-specific package name(s) if found + if [[ -n "$distro_packages" ]]; then + echo "$distro_packages" + return 0 + fi + fi + + # Fallback to original package name + echo "$package" +} + +get_package_use_flags() { + local package="$1" + local packages_file="${2:-}" + + # Only relevant for Gentoo/Portage + if [[ "$PACKAGE_MANAGER" != "portage" ]]; then + echo "" + return 0 + fi + + if [[ -n "$packages_file" ]] && [[ -f "$packages_file" ]] && command_exists yq; then + local use_flags + use_flags=$(yq eval ".gentoo_use_flags.$package" "$packages_file" 2>/dev/null | grep -v "^null$" || echo "") + echo "$use_flags" + else + echo "" + fi +} + +#====================================== +# Dependency Installation Functions +#====================================== + +install_dependencies_if_missing() { + print_section "Checking for dependencies git, wget/curl" + save_state "install_dependencies" "started" + + local missing_deps=() + local failed_deps=() + + # Check for missing essential tools + for tool in "${ESSENTIAL_TOOLS[@]}"; do + if ! command_exists "$tool"; then + missing_deps+=("$tool") + fi + done + + # If everything is already present, skip with a clear message + if [[ ${#missing_deps[@]} -eq 0 ]]; then + print_skip "All required dependencies are already installed" + mark_step_completed "install_dependencies" + return 0 + fi + + # If no internet and dependencies are missing, try offline packages + if [[ "$INTERNET_AVAILABLE" != true ]] && [[ ${#missing_deps[@]} -gt 0 ]]; then + print_warning "No internet connection available" + print_info "Attempting to install dependencies from local packages..." + + # Try to install from local package cache + for tool in "${missing_deps[@]}"; do + if install_package_offline "$tool"; then + print_success "Installed $tool from local cache" + else + failed_deps+=("$tool") + fi + done + elif [[ ${#missing_deps[@]} -gt 0 ]]; then + # Online installation + print_info "Installing missing dependencies: ${missing_deps[*]}" + update_package_database + + for tool in "${missing_deps[@]}"; do + if install_single_package "$tool" "dependency"; then + print_success "Installed dependency: $tool" + else + failed_deps+=("$tool") + fi + done + fi + + if [[ ${#failed_deps[@]} -gt 0 ]]; then + print_error "Failed to install dependencies: ${failed_deps[*]}" + mark_step_failed "install_dependencies" + return 1 + else + print_success "Dependencies satisfied: ${missing_deps[*]}" + mark_step_completed "install_dependencies" + return 0 + fi +} + +install_package_offline() { + local package="$1" + + case "$PACKAGE_MANAGER" in + pacman) + # Check if package is in cache + if execute_with_privilege "pacman -U /var/cache/pacman/pkg/${package}-*.pkg.tar.*" 2>/dev/null; then + return 0 + fi + ;; + apt) + # Try from local cache + if execute_with_privilege "apt-get install --no-download '$package'" 2>/dev/null; then + return 0 + fi + ;; + esac + + return 1 +} + +#====================================== +# Package Management Functions +#====================================== + + +install_single_package() { + local package="$1" + local package_type="${2:-system}" + local packages_file="${3:-}" + + # Get the correct package name(s) for this distro - can be multiple packages + local pkg_names + pkg_names=$(get_package_names "$package" "$packages_file") + + # Get USE flags for Gentoo + local use_flags + use_flags=$(get_package_use_flags "$package" "$packages_file") + + print_info "Installing $package_type package: $package -> $pkg_names" + + # Handle multiple packages + local install_success=true + for pkg_name in $pkg_names; do + print_info "Installing: $pkg_name" + + case "$PACKAGE_MANAGER" in + pacman) + execute_with_privilege "$PACKAGE_INSTALL_CMD '$pkg_name'" || install_success=false + ;; + apt) + execute_with_privilege "$PACKAGE_INSTALL_CMD '$pkg_name'" || install_success=false + ;; + dnf|yum) + execute_with_privilege "$PACKAGE_INSTALL_CMD '$pkg_name'" || install_success=false + ;; + zypper) + execute_with_privilege "$PACKAGE_INSTALL_CMD '$pkg_name'" || install_success=false + ;; + portage) + local emerge_cmd="$PACKAGE_INSTALL_CMD" + if [[ -n "$use_flags" ]]; then + emerge_cmd="USE='$use_flags' $PACKAGE_INSTALL_CMD" + print_info "Using USE flags for $pkg_name: $use_flags" + fi + execute_with_privilege "$emerge_cmd '$pkg_name'" || install_success=false + ;; + apk) + execute_with_privilege "$PACKAGE_INSTALL_CMD '$pkg_name'" || install_success=false + ;; + xbps) + execute_with_privilege "$PACKAGE_INSTALL_CMD '$pkg_name'" || install_success=false + ;; + nix) + execute_command "$PACKAGE_INSTALL_CMD$pkg_name" || install_success=false + ;; + brew) + execute_command "$PACKAGE_INSTALL_CMD '$pkg_name'" || install_success=false + ;; + brew-install) + print_error "Homebrew not installed. Please install it first." + return 1 + ;; + manual) + execute_with_privilege "$PACKAGE_INSTALL_CMD '$pkg_name'" || install_success=false + ;; + *) + print_error "Package manager '$PACKAGE_MANAGER' not supported" + return 1 + ;; + esac + done + + return $([[ "$install_success" == true ]] && echo 0 || echo 1) +} + +update_package_database() { + print_info "Updating package database..." + + case "$PACKAGE_MANAGER" in + pacman) + execute_with_privilege "$PACKAGE_UPDATE_CMD" ;; + apt) + execute_with_privilege "$PACKAGE_UPDATE_CMD" ;; + dnf|yum) + execute_with_privilege "$PACKAGE_UPDATE_CMD" || true ;; + zypper) + execute_with_privilege "$PACKAGE_UPDATE_CMD" ;; + portage) + execute_with_privilege "$PACKAGE_UPDATE_CMD" ;; + apk) + execute_with_privilege "$PACKAGE_UPDATE_CMD" ;; + xbps) + execute_with_privilege "$PACKAGE_UPDATE_CMD" ;; + brew) + execute_command "$PACKAGE_UPDATE_CMD" ;; + manual) + execute_with_privilege "$PACKAGE_UPDATE_CMD" ;; + *) + print_info "Package database update not needed for $PACKAGE_MANAGER" ;; + esac +} + +install_homebrew() { + if command_exists brew; then + print_info "Homebrew already installed" + return 0 + fi + + print_info "Installing Homebrew..." + if execute_command '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'; then + print_success "Homebrew installed" + PACKAGE_MANAGER="brew" + + # Add to PATH for current session + if [[ -f "/opt/homebrew/bin/brew" ]]; then + eval "$(/opt/homebrew/bin/brew shellenv)" + elif [[ -f "/usr/local/bin/brew" ]]; then + eval "$(/usr/local/bin/brew shellenv)" + fi + return 0 + else + print_error "Failed to install Homebrew" + return 1 + fi +} + +install_yq() { + if command_exists yq; then + print_info "yq already installed" + return 0 + fi + + print_info "Installing yq..." + + local bin_dir="$HOME/.local/bin" + create_dir "$bin_dir" + + local yq_path="$bin_dir/yq" + local yq_url="" + + case "$(uname -m)" in + x86_64|amd64) + yq_url="https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64" ;; + aarch64|arm64) + yq_url="https://github.com/mikefarah/yq/releases/latest/download/yq_linux_arm64" ;; + armv7l) + yq_url="https://github.com/mikefarah/yq/releases/latest/download/yq_linux_arm" ;; + *) + print_error "Unsupported architecture: $(uname -m)" + return 1 ;; + esac + + if execute_command "curl -L '$yq_url' -o '$yq_path'"; then + execute_command "chmod +x '$yq_path'" + + # Add to PATH if not already there + if [[ ":$PATH:" != *":$bin_dir:"* ]]; then + export PATH="$bin_dir:$PATH" + fi + + print_success "yq installed successfully" + return 0 + else + print_error "Failed to install yq" + return 1 + fi +} + +parse_packages_from_yaml() { + local packages_file="$1" + local section="$2" + local packages=() + + if [[ ! -f "$packages_file" ]]; then + print_warning "Package file not found: $packages_file" + return 1 + fi + + if ! command_exists yq; then + print_error "yq not available for parsing packages.yml" + return 1 + fi + + # Try to parse packages from the specified section + if yq eval ".$section" "$packages_file" &>/dev/null; then + mapfile -t packages < <(yq eval ".$section[]" "$packages_file" 2>/dev/null | grep -v "^null$" || true) + fi + + # Output packages (one per line) + printf '%s\n' "${packages[@]}" +} + +get_profile_package_groups() { + local packages_file="$1" + local profile="$2" + local groups=() + + if [[ ! -f "$packages_file" ]]; then + print_warning "Package file not found: $packages_file" + return 1 + fi + + # Get package groups for the profile from the profiles section + if yq eval ".profiles.$profile.packages" "$packages_file" &>/dev/null; then + mapfile -t groups < <(yq eval ".profiles.$profile.packages[]" "$packages_file" 2>/dev/null | grep -v "^null$" || true) + fi + + # Fallback to old method if profiles section doesn't exist + if [[ ${#groups[@]} -eq 0 ]]; then + case "$profile" in + essentials) + groups=("common" "essentials") ;; + minimal) + groups=("common" "essentials" "minimal") ;; + dev) + groups=("common" "essentials" "minimal" "dev") ;; + server) + groups=("common" "essentials" "minimal" "server") ;; + full) + groups=("common" "essentials" "minimal" "dev" "server" "desktop" "wm" "media" "fonts") ;; + *) + print_error "Unknown profile: $profile" + return 1 + ;; + esac + fi + + printf '%s\n' "${groups[@]}" +} + +install_packages_from_yaml() { + local packages_file="$1" + local profile="${2:-essentials}" + local failed_packages=() + local installed_count=0 + + print_section "Installing Packages (Profile: $profile)" + + if [[ ! -f "$packages_file" ]]; then + print_warning "Package file not found: $packages_file, skipping package installation" + return 0 + fi + + # Get package groups to install based on profile + local groups + mapfile -t groups < <(get_profile_package_groups "$packages_file" "$profile") + + if [[ ${#groups[@]} -eq 0 ]]; then + print_error "No package groups found for profile: $profile" + return 1 + fi + + print_info "Installing package groups for $profile: ${groups[*]}" + + # Install packages from each group + for group in "${groups[@]}"; do + print_info "Installing packages from group: $group" + + local packages + mapfile -t packages < <(parse_packages_from_yaml "$packages_file" "$group") + + if [[ ${#packages[@]} -eq 0 ]]; then + print_info "No packages found in group: $group" + continue + fi + + print_info "Found ${#packages[@]} packages in group $group: ${packages[*]}" + + for package in "${packages[@]}"; do + [[ -z "$package" ]] && continue + + if install_single_package "$package" "$group" "$packages_file"; then + print_success "Installed: $package" + ((installed_count++)) + else + print_error "Failed to install: $package" + failed_packages+=("$package") + fi + done + done + + # Handle development environment setup + if yq eval ".profiles.$profile.enable_development" "$packages_file" 2>/dev/null | grep -q "true"; then + setup_development_environment "$packages_file" + fi + + print_info "Package installation summary:" + print_color "$GREEN" " Installed: $installed_count" + print_color "$RED" " Failed: ${#failed_packages[@]}" + + if [[ ${#failed_packages[@]} -gt 0 ]]; then + print_warning "Failed packages: ${failed_packages[*]}" + print_info "Failed packages will be listed in the final summary" + return 0 + else + print_success "All packages installed successfully" + return 0 + fi +} + +#====================================== +# Dotfiles Management System (Config Command) +#====================================== + +check_existing_config_command() { + print_info "Checking for existing config command..." + + # Known function files where config might already be defined + local function_files=( + "$HOME/.config/zsh/user/functions.zsh" + "$HOME/.config/zsh/.zshrc" + "$HOME/.zshrc" + "$HOME/.bashrc" + "$HOME/.profile" + ) + + # Check if config command is already available in current shell + if type config >/dev/null 2>&1; then + CONFIG_COMMAND_AVAILABLE=true + print_success "Config command already available in current shell" + return 0 + fi + + # Check files for existing config function definition + for f in "${function_files[@]}"; do + if [[ -f "$f" ]]; then + if grep -q '^\s*config\s*()' "$f" || grep -q '# Dotfiles Management System' "$f"; then + CONFIG_COMMAND_AVAILABLE=true + CONFIG_COMMAND_FILE="$f" + print_success "Config command found in: $f" + return 0 + fi + fi + done + + CONFIG_COMMAND_AVAILABLE=false + print_info "No existing config command found" + return 1 +} + +install_config_command() { + print_section "Installing Config Command" + + if check_existing_config_command; then + if [[ "$FORCE_MODE" == true ]]; then + print_info "Force mode: reinstalling config command" + else + return 0 + fi + fi + + # Determine current shell and profile file + local current_shell + current_shell=$(basename "$SHELL") + + local profile_file="" + case "$current_shell" in + bash) + if [[ -f "$HOME/.bashrc" ]]; then + profile_file="$HOME/.bashrc" + else + profile_file="$HOME/.bashrc" + touch "$profile_file" + fi + ;; + zsh) + if [[ -f "$HOME/.config/zsh/user/functions.zsh" ]]; then + profile_file="$HOME/.config/zsh/user/functions.zsh" + elif [[ -f "$HOME/.config/zsh/.zshrc" ]]; then + profile_file="$HOME/.config/zsh/.zshrc" + elif [[ -f "$HOME/.zshrc" ]]; then + profile_file="$HOME/.zshrc" + else + profile_file="$HOME/.zshrc" + touch "$profile_file" + fi + ;; + *) + if [[ -f "$HOME/.profile" ]]; then + profile_file="$HOME/.profile" + else + profile_file="$HOME/.profile" + touch "$profile_file" + fi + ;; + esac + + if [[ ! -w "$profile_file" ]]; then + print_error "Cannot write to profile file: $profile_file" + return 1 + fi + + # Check if config function already exists in the target file + if grep -q "# Dotfiles Management System" "$profile_file" 2>/dev/null; then + print_info "Config function already exists in $profile_file" + CONFIG_COMMAND_AVAILABLE=true + CONFIG_COMMAND_FILE="$profile_file" + return 0 + fi + + print_info "Adding config function to: $profile_file" + + # Add the config function + cat >> "$profile_file" << 'EOF' + +# Dotfiles Management System +if [[ -d "$HOME/.cfg" && -d "$HOME/.cfg/refs" ]]; then + # Core git wrapper with repository as work-tree + _config() { + git --git-dir="$HOME/.cfg" --work-tree="$HOME/.cfg" "$@" + } + + # Detect OS + case "$(uname -s)" in + Linux) CFG_OS="linux" ;; + Darwin) CFG_OS="macos" ;; + MINGW*|MSYS*|CYGWIN*) CFG_OS="windows" ;; + *) CFG_OS="other" ;; + esac + + # Map system path to repository path + _repo_path() { + local f="$1" + + # If it's an absolute path that's not in HOME, handle it specially + if [[ "$f" == /* && "$f" != "$HOME/"* ]]; then + echo "$CFG_OS/${f#/}" + return + fi + + # Check for paths that should go to the repository root + case "$f" in + common/*|linux/*|macos/*|windows/*|profile/*|README.md) + echo "$f" + return + ;; + "$HOME/"*) + f="${f#$HOME/}" + ;; + esac + + # Default: put under OS-specific home + echo "$CFG_OS/home/$f" + } + + _sys_path() { + local repo_path="$1" + local os_path_pattern="$CFG_OS/" + + # Handle OS-specific files that are not in the home subdirectory + if [[ "$repo_path" == "$os_path_pattern"* && "$repo_path" != */home/* ]]; then + echo "/${repo_path#$os_path_pattern}" + return + fi + + case "$repo_path" in + # Common configs → OS-specific config dirs + common/config/*) + case "$CFG_OS" in + linux) + local base="${XDG_CONFIG_HOME:-$HOME/.config}" + echo "$base/${repo_path#common/config/}" + ;; + macos) + echo "$HOME/Library/Application Support/${repo_path#common/config/}" + ;; + windows) + echo "$LOCALAPPDATA\\${repo_path#common/config/}" + ;; + *) + echo "$HOME/.config/${repo_path#common/config/}" + ;; + esac + ;; + + # Common assets → stay in repo + common/assets/*) + echo "$HOME/.cfg/$repo_path" + ;; + + # Other common files (dotfiles like .bashrc, .gitconfig, etc.) → $HOME + common/*) + echo "$HOME/${repo_path#common/}" + ;; + + # OS-specific home + */home/*) + echo "$HOME/${repo_path#*/home/}" + ;; + + # Profile configs and README → stay in repo + profile/*|README.md) + echo "$HOME/.cfg/$repo_path" + ;; + + # Default fallback + *) + echo "$HOME/.cfg/$repo_path" + ;; + + esac + } + + # Prompts for sudo if needed and runs the command + _sudo_prompt() { + if [[ $EUID -eq 0 ]]; then + "$@" + else + if command -v sudo >/dev/null; then + sudo "$@" + elif command -v doas >/dev/null; then + doas "$@" + elif command -v pkexec >/dev/null; then + pkexec "$@" + else + echo "Error: No privilege escalation tool found." + return 1 + fi + fi + } + + # Main config command + config() { + local cmd="$1"; shift + local target_dir="" + + # Parse optional --target flag for add + if [[ "$cmd" == "add" ]]; then + while [[ "$1" == --* ]]; do + case "$1" in + --target|-t) + target_dir="$2" + shift 2 + ;; + *) + echo "Unknown option: $1" + return 1 + ;; + esac + done + fi + + case "$cmd" in + add) + local file_path + for file_path in "$@"; do + local repo_path + if [[ -n "$target_dir" ]]; then + local rel_path + if [[ "$file_path" == /* ]]; then + rel_path="$(basename "$file_path")" + else + rel_path="$file_path" + fi + repo_path="$target_dir/$rel_path" + else + repo_path="$(_repo_path "$file_path")" + fi + + local full_repo_path="$HOME/.cfg/$repo_path" + mkdir -p "$(dirname "$full_repo_path")" + cp -a "$file_path" "$full_repo_path" + + git --git-dir="$HOME/.cfg" --work-tree="$HOME/.cfg" add "$repo_path" + + echo "Added: $file_path -> $repo_path" + done + ;; + rm) + local rm_opts="" + local file_path_list=() + + for arg in "$@"; do + if [[ "$arg" == "-"* ]]; then + rm_opts+=" $arg" + else + file_path_list+=("$arg") + fi + done + + for file_path in "${file_path_list[@]}"; do + local repo_path="$(_repo_path "$file_path")" + + if [[ "$rm_opts" == *"-r"* ]]; then + _config rm --cached -r "$repo_path" + else + _config rm --cached "$repo_path" + fi + + eval "rm $rm_opts \"$file_path\"" + echo "Removed: $file_path" + done + ;; + sync) + local direction="${1:-to-repo}"; shift + _config ls-files | while read -r repo_file; do + local sys_file="$(_sys_path "$repo_file")" + local full_repo_path="$HOME/.cfg/$repo_file" + if [[ "$direction" == "to-repo" ]]; then + if [[ -e "$sys_file" && -n "$(diff "$full_repo_path" "$sys_file" 2>/dev/null || echo "diff")" ]]; then + cp -a "$sys_file" "$full_repo_path" + echo "Synced to repo: $sys_file" + fi + elif [[ "$direction" == "from-repo" ]]; then + if [[ -e "$full_repo_path" && -n "$(diff "$full_repo_path" "$sys_file" 2>/dev/null || echo "diff")" ]]; then + local dest_dir="$(dirname "$sys_file")" + if [[ "$sys_file" == /* && "$sys_file" != "$HOME/"* ]]; then + _sudo_prompt mkdir -p "$dest_dir" + _sudo_prompt cp -a "$full_repo_path" "$sys_file" + else + mkdir -p "$dest_dir" + cp -a "$full_repo_path" "$sys_file" + fi + echo "Synced from repo: $sys_file" + fi + fi + done + ;; + status) + local auto_synced=() + while read -r repo_file; do + local sys_file="$(_sys_path "$repo_file")" + local full_repo_path="$HOME/.cfg/$repo_file" + if [[ -e "$sys_file" && -e "$full_repo_path" ]]; then + if ! diff -q "$full_repo_path" "$sys_file" >/dev/null 2>&1; then + cp -fa "$sys_file" "$full_repo_path" + auto_synced+=("$repo_file") + fi + fi + done < <(_config ls-files) + if [[ ${#auto_synced[@]} -gt 0 ]]; then + echo "=== Auto-synced Files ===" + for repo_file in "${auto_synced[@]}"; do + echo "synced: $(_sys_path "$repo_file") -> $repo_file" + done + echo + fi + _config status + echo + ;; + deploy) + _config ls-files | while read -r repo_file; do + local full_repo_path="$HOME/.cfg/$repo_file" + local sys_file="$(_sys_path "$repo_file")" + + if [[ -e "$full_repo_path" && -n "$sys_file" ]]; then + local dest_dir + dest_dir="$(dirname "$sys_file")" + + # Create destination if needed + if [[ "$sys_file" == /* && "$sys_file" != "$HOME/"* ]]; then + _sudo_prompt mkdir -p "$dest_dir" + _sudo_prompt cp -a "$full_repo_path" "$sys_file" + else + mkdir -p "$dest_dir" + cp -a "$full_repo_path" "$sys_file" + fi + + echo "Deployed: $repo_file -> $sys_file" + fi + done + ;; + checkout) + echo "Checking out dotfiles from .cfg..." + _config ls-files | while read -r repo_file; do + local full_repo_path="$HOME/.cfg/$repo_file" + local sys_file="$(_sys_path "$repo_file")" + + if [[ -e "$full_repo_path" && -n "$sys_file" ]]; then + local dest_dir + dest_dir="$(dirname "$sys_file")" + + # Create destination if it doesn't exist + if [[ "$sys_file" == /* && "$sys_file" != "$HOME/"* ]]; then + _sudo_prompt mkdir -p "$dest_dir" + _sudo_prompt cp -a "$full_repo_path" "$sys_file" + else + mkdir -p "$dest_dir" + cp -a "$full_repo_path" "$sys_file" + fi + + echo "Checked out: $repo_file -> $sys_file" + fi + done + ;; + backup) + local timestamp=$(date +%Y%m%d%H%M%S) + local backup_dir="$HOME/.dotfiles_backup/$timestamp" + echo "Backing up existing dotfiles to $backup_dir..." + + _config ls-files | while read -r repo_file; do + local sys_file="$(_sys_path "$repo_file")" + if [[ -e "$sys_file" ]]; then + local dest_dir_full="$backup_dir/$(dirname "$repo_file")" + mkdir -p "$dest_dir_full" + cp -a "$sys_file" "$backup_dir/$repo_file" + fi + done + echo "Backup complete. To restore, copy files from $backup_dir to their original locations." + ;; + *) + _config "$cmd" "$@" + ;; + esac + } +fi +EOF + + if [[ $? -eq 0 ]]; then + print_success "Config command added to: $profile_file" + CONFIG_COMMAND_AVAILABLE=true + CONFIG_COMMAND_FILE="$profile_file" + + # Source the file to make config command available immediately + # shellcheck disable=SC1090 + source "$profile_file" 2>/dev/null || print_warning "Failed to source $profile_file" + + return 0 + else + print_error "Failed to add config command to $profile_file" + return 1 + fi +} + +deploy_config() { + print_section "Deploying Configuration" + save_state "deploy_config" "started" + + # Ensure config command is available + if [[ "$CONFIG_COMMAND_AVAILABLE" != true ]]; then + install_config_command || { + print_error "Failed to install config command" + mark_step_failed "deploy_config" + return 1 + } + fi + + # Deploy dotfiles from repository to system + if [[ -d "$DOTFILES_DIR" ]]; then + print_info "Checking out dotfiles from repository..." + + # First, checkout files from the bare repository to restore directory structure + if [[ "$DRY_RUN" == true ]]; then + print_dry_run "config checkout" + else + # Source the config function if available + if type config >/dev/null 2>&1; then + print_info "Using config command to checkout files..." + if config checkout; then + print_success "Files checked out from repository" + else + print_warning "Some files may have failed to checkout, trying force checkout..." + config checkout -f || print_warning "Force checkout also had issues" + fi + else + # Fallback: use git directly + print_info "Using git directly to checkout files..." + # IMPORTANT: use $HOME/.cfg as work-tree, never the bare repo path + if git --git-dir="$DOTFILES_DIR" --work-tree="$HOME/.cfg" checkout HEAD -- . 2>/dev/null; then + print_success "Files checked out using git directly" + else + print_warning "Git checkout had issues, continuing anyway..." + fi + fi + fi + + # Backup existing files prior to deployment (prompt, allow skip) + if [[ "$DRY_RUN" == true ]]; then + print_dry_run "Backup existing dotfiles prior to deployment" + else + if [[ "$FORCE_MODE" == true ]]; then + # In force mode, perform backup without prompting + backup_existing_dotfiles || print_warning "Backup encountered issues (continuing)" + else + if prompt_user "Backup existing dotfiles before deployment?"; then + backup_existing_dotfiles || print_warning "Backup encountered issues (continuing)" + else + print_skip "User chose to skip backup before deployment" + fi + fi + fi + + print_info "Deploying dotfiles from repository to system locations..." + + # Verify config command is working + if ! verify_config_command; then + print_warning "Config command not working properly, using manual deployment" + manual_deploy_dotfiles + else + print_info "Config command available, deploying files..." + + if [[ "$DRY_RUN" == true ]]; then + print_dry_run "config deploy" + else + # Use the config function to deploy files + if config deploy; then + print_success "Dotfiles deployed successfully" + else + print_warning "Some files may have failed to deploy" + fi + fi + fi + + # Set appropriate permissions + set_dotfile_permissions + + else + print_warning "Dotfiles directory not found, skipping deployment" + fi + + mark_step_completed "deploy_config" +} + +verify_config_command() { + # Always verify the function is actually available in this shell + if type config >/dev/null 2>&1; then + CONFIG_COMMAND_AVAILABLE=true + print_success "Config command is available and working" + return 0 + fi + # Try sourcing the detected profile file if known + if [[ -n "$CONFIG_COMMAND_FILE" && -f "$CONFIG_COMMAND_FILE" ]]; then + # shellcheck disable=SC1090 + source "$CONFIG_COMMAND_FILE" 2>/dev/null || true + if type config >/dev/null 2>&1; then + CONFIG_COMMAND_AVAILABLE=true + print_success "Config command is available and working" + return 0 + fi + fi + print_warning "Config command not available" + return 1 +} + +# Manual deployment function (fallback when config command not available) +manual_deploy_dotfiles() { + print_info "Using manual deployment method..." + + if [[ ! -d "$DOTFILES_DIR" ]]; then + print_error "Dotfiles directory not found: $DOTFILES_DIR" + return 1 + fi + + # Source locations are always within the checked-out work-tree ($HOME/.cfg) + local os_dir="$HOME/.cfg/$CFG_OS" + local common_dir="$HOME/.cfg/common" + + deploy_file() { + local repo_file="$1" + local rel_path sys_file sys_dir base + + # Determine destination based on repo path + rel_path="${repo_file#$DOTFILES_DIR/}" + + # OS-specific files outside home + if [[ "$rel_path" == "$CFG_OS/"* && "$rel_path" != */home/* ]]; then + sys_file="/${rel_path#$CFG_OS/}" + else + case "$rel_path" in + common/config/*) + case "$CFG_OS" in + linux) + base="${XDG_CONFIG_HOME:-$HOME/.config}" + sys_file="$base/${rel_path#common/config/}" + ;; + macos) + sys_file="$HOME/Library/Application Support/${rel_path#common/config/}" + ;; + windows) + sys_file="$LOCALAPPDATA\\${rel_path#common/config/}" + ;; + *) + sys_file="$HOME/.config/${rel_path#common/config/}" + ;; + esac + ;; + common/assets/*) + # Assets are repo-internal; do not deploy to filesystem + return 0 + ;; + common/*) + sys_file="$HOME/${rel_path#common/}" + ;; + */home/*) + sys_file="$HOME/${rel_path#*/home/}" + ;; + profile/*|README.md) + sys_file="$HOME/.cfg/$rel_path" + ;; + *) + sys_file="$HOME/.cfg/$rel_path" + ;; + esac + fi + + sys_dir="$(dirname "$sys_file")" + mkdir -p "$sys_dir" + + # Avoid copying if source and destination resolve to the same file + local src_real dst_real + src_real=$(readlink -f -- "$repo_file" 2>/dev/null || echo "$repo_file") + dst_real=$(readlink -f -- "$sys_file" 2>/dev/null || echo "$sys_file") + if [[ -n "$dst_real" && "$src_real" == "$dst_real" ]]; then + print_skip "Skipping self-copy: $rel_path" + return 0 + fi + + # Copy with privilege if path is system (/etc, /usr, etc.) + if [[ "$sys_file" == /* ]]; then + # If we lack a privilege tool and are not root, skip with clear message + if [[ -z "$PRIVILEGE_TOOL" && "$EUID" -ne 0 ]]; then + print_skip "Skipping privileged deploy (no sudo/doas): $rel_path -> $sys_file" + else + execute_with_privilege "cp -a '$repo_file' '$sys_file'" \ + && print_info "Deployed (privileged): $rel_path" \ + || print_error "Failed to deploy (privileged): $rel_path" + fi + else + cp -a "$repo_file" "$sys_file" \ + && print_info "Deployed: $rel_path" \ + || print_error "Failed to deploy: $rel_path" + fi + } + + # Deploy all files in OS dir + if [[ -d "$os_dir" ]]; then + find "$os_dir" -type f | while read -r f; do + deploy_file "$f" + done + fi + + # Deploy all files in common dir + if [[ -d "$common_dir" ]]; then + find "$common_dir" -type f | while read -r f; do + deploy_file "$f" + done + fi +} + +# Set appropriate file permissions +set_dotfile_permissions() { + print_info "Setting appropriate file permissions..." + + # SSH directory permissions + if [[ -d "$HOME/.ssh" ]]; then + chmod 700 "$HOME/.ssh" + find "$HOME/.ssh" -name "id_*" -not -name "*.pub" -exec chmod 600 {} \; 2>/dev/null || true + find "$HOME/.ssh" -name "*.pub" -exec chmod 644 {} \; 2>/dev/null || true + find "$HOME/.ssh" -name "config" -exec chmod 600 {} \; 2>/dev/null || true + print_info "SSH permissions set" + fi + + # GPG directory permissions + if [[ -d "$HOME/.gnupg" ]]; then + chmod 700 "$HOME/.gnupg" + find "$HOME/.gnupg" -type f -exec chmod 600 {} \; 2>/dev/null || true + print_info "GPG permissions set" + fi + + # Make scripts executable + if [[ -d "$HOME/.local/bin" ]]; then + find "$HOME/.local/bin" -type f -exec chmod +x {} \; 2>/dev/null || true + print_info "Script permissions set" + fi + + if [[ -d "$HOME/.scripts" ]]; then + find "$HOME/.scripts" -type f -name "*.sh" -exec chmod +x {} \; 2>/dev/null || true + print_info "Shell script permissions set" + fi +} + +#====================================== +# Installation Step Functions +#====================================== + +setup_environment() { + print_section "Setting Up Environment" + save_state "setup_environment" "started" + + detect_os + detect_privilege_tools + detect_package_manager || { + print_error "Cannot proceed without a supported package manager" + mark_step_failed "setup_environment" + return 1 + } + + if [[ -n "$PRIVILEGE_TOOL" ]]; then + test_privilege_access || { + print_error "Cannot obtain necessary privileges" + mark_step_failed "setup_environment" + return 1 + } + fi + + mark_step_completed "setup_environment" +} + +check_connectivity() { + print_section "Checking Connectivity" + save_state "check_connectivity" "started" + + if check_internet_connectivity; then + mark_step_completed "check_connectivity" + return 0 + else + print_warning "Limited internet connectivity - some features may be unavailable" + mark_step_completed "check_connectivity" + return 0 # Don't fail completely + fi +} + +install_dependencies() { + print_section "Installing Dependencies" + save_state "install_dependencies" "started" + + if install_dependencies_if_missing; then + mark_step_completed "install_dependencies" + return 0 + else + mark_step_failed "install_dependencies" + return 1 + fi +} + +install_dotfiles() { + print_section "Installing Dotfiles" + save_state "install_dotfiles" "started" + + local update=false + + # Check internet connectivity for git operations + if [[ "$INTERNET_AVAILABLE" != true ]]; then + print_warning "No internet connectivity - skipping dotfiles installation" + mark_step_completed "install_dotfiles" + return 0 + fi + + if [[ -d "$DOTFILES_DIR" ]]; then + if [[ "$UPDATE_MODE" == true ]] || prompt_user "Dotfiles repository already exists. Update it?"; then + print_info "Updating existing dotfiles..." + # Detect ahead/behind before pulling to avoid unexpected fast-forwards + execute_command "git --git-dir='$DOTFILES_DIR' fetch origin main" || true + local ahead behind ab_line + ahead=0; behind=0 + ab_line=$(git --git-dir="$DOTFILES_DIR" rev-list --left-right --count HEAD...origin/main 2>/dev/null || true) + # Expected format: "<ahead>\t<behind>"; parse safely + if [[ "$ab_line" =~ ^([0-9]+)[[:space:]]+([0-9]+)$ ]]; then + ahead="${BASH_REMATCH[1]}" + behind="${BASH_REMATCH[2]}" + fi + if [[ ${ahead:-0} -gt 0 && ${behind:-0} -eq 0 ]]; then + print_warning "Your local dotfiles are ahead of origin/main by $ahead commit(s)." + while true; do + echo + print_color "$YELLOW" "Choose an action for local-ahead state:" + echo " [k] Keep local (skip pull)" + echo " [p] Push local commits" + echo " [c] Commit new changes and push" + echo " [s] Stash uncommitted changes (if any) and pull" + echo " [a] Abort" + printf "%b%s%b" "$YELLOW" "Enter choice [k/p/c/s/a]: " "$NOCOLOR" + read -r choice + case "${choice,,}" in + k) + print_warning "Keeping local commits; skipping pull" + break + ;; + p) + if execute_command "git --git-dir='$DOTFILES_DIR' --work-tree='$HOME/.cfg' push origin HEAD:main"; then + print_success "Pushed local commits" + else + print_error "Push failed" + fi + break + ;; + c) + print_info "Committing changes before push..." + printf "%b%s%b" "$YELLOW" "Commit message (default: 'WIP local changes via installer'): " "$NOCOLOR" + read -r commit_msg + [[ -z "$commit_msg" ]] && commit_msg="WIP local changes via installer" + if execute_command "git --git-dir='$DOTFILES_DIR' --work-tree='$HOME/.cfg' add -A" \ + && execute_command "git --git-dir='$DOTFILES_DIR' --work-tree='$HOME/.cfg' commit -m \"$commit_msg\"" \ + && execute_command "git --git-dir='$DOTFILES_DIR' --work-tree='$HOME/.cfg' push origin HEAD:main"; then + print_success "Committed and pushed" + else + print_error "Commit/push failed" + fi + break + ;; + s) + print_info "Stashing local (including untracked) before pull..." + if execute_command "git --git-dir='$DOTFILES_DIR' --work-tree='$HOME/.cfg' stash push -u -m 'installer-stash'"; then + print_success "Stashed local changes" + else + print_error "Stash failed" + fi + break + ;; + a) + print_error "Aborted by user" + mark_step_failed "install_dotfiles" + return 1 + ;; + *) + print_warning "Invalid choice. Please enter k/p/c/s/a." + ;; + esac + done + fi + # If remote is ahead (fast-forward), ask the user before pulling + if [[ ${behind:-0} -gt 0 && ${ahead:-0} -eq 0 ]]; then + print_warning "Origin/main is ahead by $behind commit(s)." + if ! prompt_user "Fast-forward to origin/main now?"; then + print_skip "User chose not to fast-forward; skipping pull" + # Skip pull entirely + goto_after_pull=true + fi + fi + if [[ "${goto_after_pull:-false}" == true ]] || execute_command "git --git-dir='$DOTFILES_DIR' --work-tree='$HOME/.cfg' pull origin main"; then + update=true + print_success "Dotfiles updated successfully" + else + print_error "Failed to pull updates" + # Interactive resolution for local changes + while true; do + echo + print_color "$YELLOW" "Local changes detected. Choose an action:" + echo " [c] Commit local changes" + echo " [s] Stash local changes" + echo " [k] Keep local changes (skip pulling)" + echo " [a] Abort" + printf "%b%s%b" "$YELLOW" "Enter choice [c/s/k/a]: " "$NOCOLOR" + read -r choice + case "${choice,,}" in + c) + print_info "Committing local changes..." + printf "%b%s%b" "$YELLOW" "Commit message (default: 'WIP local changes via installer'): " "$NOCOLOR" + read -r commit_msg + [[ -z "$commit_msg" ]] && commit_msg="WIP local changes via installer" + if execute_command "git --git-dir='$DOTFILES_DIR' --work-tree='$HOME/.cfg' add -A" \ + && execute_command "git --git-dir='$DOTFILES_DIR' --work-tree='$HOME/.cfg' commit -m \"$commit_msg\""; then + print_success "Committed local changes" + print_info "Retrying pull..." + if execute_command "git --git-dir='$DOTFILES_DIR' --work-tree='$HOME/.cfg' pull origin main"; then + update=true; print_success "Dotfiles updated successfully"; break + else + print_error "Pull failed again after commit. You may resolve manually or choose another option." + fi + else + print_error "Commit failed. Try another option." + fi + ;; + s) + print_info "Stashing local changes..." + if execute_command "git --git-dir='$DOTFILES_DIR' --work-tree='$HOME/.cfg' stash push -u -m 'installer-stash'"; then + print_success "Stashed local changes" + print_info "Retrying pull..." + if execute_command "git --git-dir='$DOTFILES_DIR' --work-tree='$HOME/.cfg' pull origin main"; then + update=true; print_success "Dotfiles updated successfully"; break + else + print_error "Pull failed again after stash. You may resolve manually or choose another option." + fi + else + print_error "Stash failed. Try another option." + fi + ;; + k) + print_warning "Keeping local changes and skipping pull" + break + ;; + a) + print_error "Aborted by user" + mark_step_failed "install_dotfiles" + return 1 + ;; + *) + print_warning "Invalid choice. Please enter c/s/k/a." + ;; + esac + done + fi + else + print_skip "Skipping dotfiles update" + mark_step_completed "install_dotfiles" + return 0 + fi + else + print_info "Cloning dotfiles repository..." + if execute_command "git clone --bare '$DOTFILES_URL' '$DOTFILES_DIR'"; then + print_success "Dotfiles repository cloned" + else + print_error "Failed to clone dotfiles repository" + mark_step_failed "install_dotfiles" + return 1 + fi + fi + + # Configure the repository + execute_command "git --git-dir='$DOTFILES_DIR' --work-tree='$HOME/.cfg' config status.showUntrackedFiles no" + + mark_step_completed "install_dotfiles" + print_success "Dotfiles installed successfully" +} + +setup_user_dirs() { + print_section "Setting Up User Directories" + save_state "setup_user_dirs" "started" + + local directories=('.cache' '.config' '.local/bin' '.local/share' '.scripts') + + for dir in "${directories[@]}"; do + create_dir "$HOME/$dir" + done + + # Set up XDG directories (ensure existence; no deletions) + if command_exists xdg-user-dirs-update; then + # Suppress tool output to avoid misleading terms like "removed"; we only ensure presence. + execute_command "xdg-user-dirs-update >/dev/null 2>&1 || true" + print_success "Ensured XDG user directories exist" + fi + + mark_step_completed "setup_user_dirs" +} + +install_essentials() { + print_section "Installing Essential Tools" + save_state "install_essentials" "started" + + # Fast-path: determine if any package tools are actually missing + local missing_tools=() + for tool in "${PACKAGE_TOOLS[@]}"; do + if [[ "$tool" == "yq" ]]; then + if command_exists yq || [[ -x "$HOME/.local/bin/yq" ]]; then + continue + fi + elif [[ "$tool" == "jq" ]]; then + if command_exists jq || is_package_installed jq; then + continue + fi + fi + if ! command_exists "$tool"; then + missing_tools+=("$tool") + fi + done + + if [[ ${#missing_tools[@]} -eq 0 ]]; then + print_skip "All essential tools are already installed" + mark_step_completed "install_essentials" + return 0 + fi + + # Install package processing tools first + for tool in "${PACKAGE_TOOLS[@]}"; do + if [[ "$tool" == "yq" ]]; then + if command_exists yq || [[ -x "$HOME/.local/bin/yq" ]]; then + print_info "Package tool already available: yq" + continue + fi + elif [[ "$tool" == "jq" ]]; then + if command_exists jq || is_package_installed jq; then + print_info "Package tool already available: jq" + continue + fi + fi + + if ! command_exists "$tool"; then + case "$tool" in + yq) + if install_yq; then + print_success "Installed package tool: $tool" + else + print_error "Failed to install package tool: $tool" + mark_step_failed "install_essentials" + return 1 + fi + ;; + jq) + if command_exists jq || is_package_installed jq; then + print_info "Package tool already available: jq" + elif install_single_package "jq" "essential"; then + print_success "Installed package tool: $tool" + else + print_error "Failed to install package tool: $tool" + mark_step_failed "install_essentials" + return 1 + fi + ;; + esac + else + print_info "Package tool already available: $tool" + fi + done + + mark_step_completed "install_essentials" +} + +install_packages() { + print_section "Installing Packages" + save_state "install_packages" "started" + + # Skip if essentials-only mode + if [[ "$INSTALL_MODE" == "essentials" ]]; then + print_skip "Package installation (essentials-only mode)" + mark_step_completed "install_packages" + return 0 + fi + + # Skip if no internet and packages require download + if [[ "$INTERNET_AVAILABLE" != true ]]; then + print_warning "No internet connectivity - skipping package installation" + mark_step_completed "install_packages" + return 0 + fi + + # Change to home directory to find packages.yml + local original_dir="$PWD" + cd "$HOME" 2>/dev/null || true + + # Look for packages.yml in common locations + local packages_files=("$PACKAGES_FILE" "common/$PACKAGES_FILE" ".cfg/common/$PACKAGES_FILE") + local found_packages_file="" + + for pf in "${packages_files[@]}"; do + if [[ -f "$pf" ]]; then + found_packages_file="$pf" + break + fi + done + + if [[ -n "$found_packages_file" ]]; then + # Handle custom installs first + handle_custom_installs "$found_packages_file" + + # Install packages + if install_packages_from_yaml "$found_packages_file" "$INSTALL_MODE"; then + mark_step_completed "install_packages" + else + print_warning "Some packages failed to install, but continuing..." + mark_step_completed "install_packages" + fi + else + print_warning "packages.yml not found, attempting to download from GitHub..." + + # Derive raw URL from DOTFILES_URL + # Supports formats like: + # https://github.com/<owner>/<repo>.git + # git@github.com:<owner>/<repo>.git + # https://github.com/<owner>/<repo> + local owner repo branch + branch="main" + case "$DOTFILES_URL" in + git@github.com:*) + owner="${DOTFILES_URL#git@github.com:}" + owner="${owner%.git}" + repo="${owner#*/}" + owner="${owner%%/*}" + ;; + https://github.com/*) + owner="${DOTFILES_URL#https://github.com/}" + owner="${owner%.git}" + repo="${owner#*/}" + owner="${owner%%/*}" + ;; + *) + owner="" + repo="" + ;; + esac + + local packages_url="" + if [[ -n "$owner" && -n "$repo" ]]; then + packages_url="https://raw.githubusercontent.com/$owner/$repo/$branch/common/packages.yml" + fi + local temp_packages="/tmp/packages.yml" + + if command_exists curl && [[ -n "$packages_url" ]]; then + if curl -fsSL "$packages_url" -o "$temp_packages" 2>/dev/null; then + # Create common directory if it doesn't exist + mkdir -p "$HOME/.cfg/common" 2>/dev/null || mkdir -p "$HOME/common" 2>/dev/null + + # Move to appropriate location + if [[ -d "$HOME/.cfg/common" ]]; then + mv "$temp_packages" "$HOME/.cfg/common/packages.yml" + found_packages_file="$HOME/.cfg/common/packages.yml" + elif [[ -d "$HOME/common" ]]; then + mv "$temp_packages" "$HOME/common/packages.yml" + found_packages_file="$HOME/common/packages.yml" + else + mv "$temp_packages" "$HOME/packages.yml" + found_packages_file="$HOME/packages.yml" + fi + + print_success "Downloaded packages.yml from GitHub" + + # Now install packages with the downloaded file + handle_custom_installs "$found_packages_file" + if install_packages_from_yaml "$found_packages_file" "$INSTALL_MODE"; then + mark_step_completed "install_packages" + else + print_warning "Some packages failed to install, but continuing..." + mark_step_completed "install_packages" + fi + else + print_warning "Failed to download packages.yml, skipping package installation" + mark_step_completed "install_packages" + fi + else + print_warning "curl not available and packages.yml not found, skipping package installation" + mark_step_completed "install_packages" + fi + fi + + cd "$original_dir" 2>/dev/null || true +} + +setup_shell() { + print_section "Setting Up Shell Environment" + save_state "setup_shell" "started" + + # Ensure config command is available before changing shells + if [[ "$CONFIG_COMMAND_AVAILABLE" != true ]]; then + print_warning "Config command not available, installing it first..." + install_config_command || { + print_error "Failed to install config command before shell setup" + mark_step_failed "setup_shell" + return 1 + } + fi + + if command_exists zsh; then + local zsh_path + zsh_path="$(command -v zsh)" + + if [[ "$FORCE_MODE" == true ]]; then + print_info "FORCE mode: changing default shell to Zsh without prompting" + if execute_with_privilege "chsh -s '$zsh_path' '$USER'"; then + print_success "Default shell changed to Zsh" + print_warning "Please log out and log back in to apply changes" + else + print_error "Failed to change default shell" + fi + elif [[ "$ASK_MODE" == true ]]; then + if prompt_user "Change default shell to Zsh?" "N"; then + if execute_with_privilege "chsh -s '$zsh_path' '$USER'"; then + print_success "Default shell changed to Zsh" + print_warning "Please log out and log back in to apply changes" + else + print_error "Failed to change default shell" + fi + else + print_skip "Default shell change (user chose No)" + fi + else + print_info "Skipping shell change (non-interactive mode). Use --ask to be prompted or --force to auto-change." + fi + else + print_warning "Zsh not installed, skipping shell setup" + fi + + # Zsh plugins are managed via packages.yml custom_installs (zsh_plugins) + # No direct plugin installation here to avoid duplication. + + mark_step_completed "setup_shell" +} + +## install_zsh_plugins deprecated; handled via packages.yml + +setup_ssh() { + print_section "Setting Up SSH" + save_state "setup_ssh" "started" + + local ssh_dir="$HOME/.ssh" + + if [[ ! -f "$ssh_dir/id_rsa" && ! -f "$ssh_dir/id_ed25519" ]]; then + if [[ "$FORCE_MODE" == true ]] || prompt_user "Generate SSH key pair?"; then + create_dir "$ssh_dir" 700 + + local email="${USER}@${HOSTNAME:-$(hostname)}" + local key_file="$ssh_dir/id_ed25519" + + if execute_command "ssh-keygen -t ed25519 -f '$key_file' -N '' -C '$email'"; then + print_success "SSH key pair generated (Ed25519)" + execute_command "chmod 600 '$key_file'" + execute_command "chmod 644 '$key_file.pub'" + + if [[ "$DRY_RUN" != true ]] && [[ -f "$key_file.pub" ]]; then + print_info "Your public key:" + print_color "$GREEN" "$(cat "$key_file.pub")" + print_info "Copy this key to your Git hosting service" + fi + else + print_error "Failed to generate SSH key" + mark_step_failed "setup_ssh" + return 1 + fi + fi + else + print_info "SSH key already exists" + fi + + mark_step_completed "setup_ssh" +} + +# Helper function to detect the init system +detect_init_system() { + if [ -d /run/systemd/system ]; then + echo "systemd" + elif command -v rc-service &>/dev/null; then + echo "openrc" + elif [ -d /etc/sv ]; then + echo "runit" + elif command -v service &>/dev/null; then + echo "sysvinit" + else + echo "unknown" + fi +} + +# Helper function to manage a service (enable/start) +manage_service() { + local action="$1" + local service="$2" + local init_system="$3" + # use numeric success code: 0=success, 1=failure + local success=1 + + case "$init_system" in + systemd) + # Resolve common generic service names to distro-specific systemd unit names + local svc_candidates=() + local lower_service + lower_service="${service,,}" + case "$lower_service" in + networkmanager) + svc_candidates+=("NetworkManager" "NetworkManager.service" "network-manager") + ;; + sshd) + # Debian uses 'ssh' service, others commonly use 'sshd' + svc_candidates+=("sshd" "ssh" "sshd.service" "ssh.service") + ;; + *) + svc_candidates+=("$service") + ;; + esac + + local tried=false + local rc=1 + for svc in "${svc_candidates[@]}"; do + tried=true + if [ "$action" == "enable" ]; then + # Prefer enabling and starting in one go when possible + if ! execute_command "$PRIVILEGE_TOOL systemctl enable --now '$svc'"; then + execute_command "$PRIVILEGE_TOOL systemctl enable '$svc'" + fi + rc=$? + elif [ "$action" == "start" ]; then + execute_command "$PRIVILEGE_TOOL systemctl start '$svc'" + rc=$? + else + rc=1 + fi + if [[ $rc -eq 0 ]]; then + success=0 + break + fi + print_warning "Failed to $action service candidate: $svc" + done + # If we didn't have a special mapping, fall back to original name once + if [[ "$tried" == false ]]; then + if [ "$action" == "enable" ]; then + execute_command "$PRIVILEGE_TOOL systemctl enable '$service'" + rc=$? + elif [ "$action" == "start" ]; then + execute_command "$PRIVILEGE_TOOL systemctl start '$service'" + rc=$? + fi + [[ $rc -eq 0 ]] && success=0 + fi + ;; + openrc) + if [ "$action" == "enable" ]; then + execute_command "$PRIVILEGE_TOOL rc-update add '$service' default" + success=$? + elif [ "$action" == "start" ]; then + execute_command "$PRIVILEGE_TOOL rc-service '$service' start" + success=$? + fi + ;; + runit) + if [ "$action" == "enable" ]; then + # Runit services are enabled by creating a symlink in the run level directory + execute_command "$PRIVILEGE_TOOL ln -sf /etc/sv/'$service' /var/service/" + success=$? + elif [ "$action" == "start" ]; then + # The 'start' action is usually implied by the symlink, but you can + # manually start it if needed + execute_command "$PRIVILEGE_TOOL sv start '$service'" + success=$? + fi + ;; + sysvinit|unknown) + # Use the generic 'service' command + if [ "$action" == "start" ]; then + execute_command "$PRIVILEGE_TOOL service '$service' start" + success=$? + fi + # Enabling is system-dependent for sysvinit/unknown; we'll check for chkconfig + if [ "$action" == "enable" ]; then + if command -v chkconfig &>/dev/null; then + execute_command "$PRIVILEGE_TOOL chkconfig '$service' on" + success=$? + else + success=0 + fi + fi + ;; + *) + print_error "Unknown init system: $init_system. Cannot $action service '$service'." + return 1 + ;; + esac + + return $success +} + +#====================================== +# Service Management Functions +#====================================== + +configure_services_from_yaml() { + local packages_file="$1" + local profile="$2" + + print_section "Configuring System Services" + save_state "configure_services" "started" + + if [[ "$CFG_OS" != "linux" ]]; then + print_skip "Service configuration (not supported on $CFG_OS)" + mark_step_completed "configure_services" + return 0 + fi + + if [[ ! -f "$packages_file" ]]; then + print_warning "Package file not found, skipping service configuration" + mark_step_completed "configure_services" + return 0 + fi + + # Detect the init system + local INIT_SYSTEM=$(detect_init_system) + print_info "Detected Init System: $INIT_SYSTEM" + + # Get services to enable for all profiles + local services_all + mapfile -t services_all < <(yq eval ".services.enable.all[]" "$packages_file" 2>/dev/null | grep -v "^null$" || true) + + # Get services to enable for specific profile + local services_profile + mapfile -t services_profile < <(yq eval ".services.enable.$profile[]" "$packages_file" 2>/dev/null | grep -v "^null$" || true) + + # Get services to disable for specific profile + local services_disable + mapfile -t services_disable < <(yq eval ".services.disable.$profile[]" "$packages_file" 2>/dev/null | grep -v "^null$" || true) + + # Enable services + for service in "${services_all[@]}" "${services_profile[@]}"; do + [[ -z "$service" ]] && continue + if [[ "$FORCE_MODE" == true ]] || prompt_user "Enable $service service?"; then + if manage_service "enable" "$service" "$INIT_SYSTEM"; then + manage_service "start" "$service" "$INIT_SYSTEM" + print_success "Enabled and started $service" + else + print_error "Failed to enable $service" + fi + fi + done + + # Disable services + for service in "${services_disable[@]}"; do + [[ -z "$service" ]] && continue + if [[ "$FORCE_MODE" == true ]] || prompt_user "Disable $service service?"; then + if manage_service "stop" "$service" "$INIT_SYSTEM"; then + manage_service "disable" "$service" "$INIT_SYSTEM" + print_success "Stopped and disabled $service" + else + print_error "Failed to disable $service" + fi + fi + done + + mark_step_completed "configure_services" +} + +configure_services() { + # Change to home directory to find packages.yml + local original_dir="$PWD" + cd "$HOME" 2>/dev/null || true + + local packages_files=("$PACKAGES_FILE" "common/$PACKAGES_FILE" ".cfg/common/$PACKAGES_FILE") + local found_packages_file="" + + for pf in "${packages_files[@]}"; do + if [[ -f "$pf" ]]; then + found_packages_file="$pf" + break + fi + done + + if [[ -n "$found_packages_file" ]]; then + configure_services_from_yaml "$found_packages_file" "$INSTALL_MODE" + else + # Fallback to original configure_services logic + print_section "Configuring System Services" + save_state "configure_services" "started" + + if [[ "$CFG_OS" != "linux" ]]; then + print_skip "Service configuration (not supported on $CFG_OS)" + mark_step_completed "configure_services" + return 0 + fi + + # Original service configuration logic here... + mark_step_completed "configure_services" + fi + + cd "$original_dir" 2>/dev/null || true +} + +setup_tmux_plugins() { + if [[ "$INTERNET_AVAILABLE" != true ]]; then + print_warning "No internet connectivity - skipping Tmux plugins installation" + return 0 + fi + + local tpm_dir="$HOME/.config/tmux/plugins/tpm" + local plugins_dir="$HOME/.config/tmux/plugins" + + if [[ ! -f "$HOME/.tmux.conf" && ! -f "$HOME/.config/tmux/tmux.conf" ]]; then + print_info "Tmux config not found, skipping plugin setup" + return 0 + fi + + print_info "Setting up Tmux plugins..." + create_dir "$plugins_dir" + + if [[ ! -d "$tpm_dir" || ! "$(ls -A "$tpm_dir" 2>/dev/null)" ]]; then + print_info "Installing Tmux Plugin Manager (TPM)..." + if execute_command "git clone https://github.com/tmux-plugins/tpm '$tpm_dir'"; then + print_success "TPM installed successfully" + print_info "Run 'tmux' and press 'prefix + I' to install plugins" + else + print_error "Failed to install TPM" + fi + else + print_info "TPM already installed" + fi +} + +#====================================== +# Development Environment Setup +#====================================== + + +setup_development_environment() { + # Accept optional packages_file argument. If missing, try to locate a default. + local packages_file="${1:-}" + if [[ -z "$packages_file" ]]; then + local candidates=("$HOME/$PACKAGES_FILE" "$HOME/common/$PACKAGES_FILE" "$HOME/.cfg/common/$PACKAGES_FILE") + for pf in "${candidates[@]}"; do + if [[ -f "$pf" ]]; then + packages_file="$pf" + break + fi + done + fi + + print_info "Setting up development environment" + + if [[ -z "$packages_file" || ! -f "$packages_file" ]]; then + print_warning "Package file not found, skipping development setup" + return 0 + fi + + # Apply git configuration + local git_configs + if command_exists yq; then + mapfile -t git_configs < <(yq eval ".development.git_config[]" "$packages_file" 2>/dev/null | grep -v "^null$" || true) + else + git_configs=() + fi + + if [[ ${#git_configs[@]} -gt 0 ]] && command_exists git; then + print_info "Applying git configuration" + for config in "${git_configs[@]}"; do + [[ -z "$config" ]] && continue + print_info "Running: $config" + execute_command "$config" + done + fi +} + +# Backup existing files that will be affected by deployment +backup_existing_dotfiles() { + local backup_root="$BACKUP_DIR/pre-deploy" + local os_dir="$DOTFILES_DIR/$CFG_OS" + local common_dir="$DOTFILES_DIR/common" + + print_info "Creating backup at: $backup_root" + mkdir -p "$backup_root" 2>/dev/null || true + + # Helper to compute destination path similar to manual_deploy_dotfiles + _compute_dest_path() { + local repo_file="$1" + local rel_path sys_file base + rel_path="${repo_file#$DOTFILES_DIR/}" + + if [[ "$rel_path" == "$CFG_OS/"* && "$rel_path" != */home/* ]]; then + sys_file="/${rel_path#$CFG_OS/}" + else + case "$rel_path" in + common/config/*) + case "$CFG_OS" in + linux) + base="${XDG_CONFIG_HOME:-$HOME/.config}" + sys_file="$base/${rel_path#common/config/}" + ;; + macos) + sys_file="$HOME/Library/Application Support/${rel_path#common/config/}" + ;; + windows) + sys_file="$LOCALAPPDATA\\${rel_path#common/config/}" + ;; + *) + sys_file="$HOME/.config/${rel_path#common/config/}" + ;; + esac + ;; + common/assets/*) + sys_file="$HOME/.cfg/$rel_path" + ;; + common/*) + sys_file="$HOME/${rel_path#common/}" + ;; + */home/*) + sys_file="$HOME/${rel_path#*/home/}" + ;; + profile/*|README.md) + sys_file="$HOME/.cfg/$rel_path" + ;; + *) + sys_file="$HOME/.cfg/$rel_path" + ;; + esac + fi + + echo "$sys_file" + } + + _backup_one() { + local repo_file="$1" + local dest + dest=$(_compute_dest_path "$repo_file") + [[ -z "$dest" ]] && return 0 + + if [[ -e "$dest" ]]; then + local rel_path="${repo_file#$DOTFILES_DIR/}" + local backup_path="$backup_root/$rel_path" + local backup_dir + backup_dir="$(dirname "$backup_path")" + mkdir -p "$backup_dir" 2>/dev/null || true + + if [[ "$dest" == /* ]]; then + execute_with_privilege "cp -a '$dest' '$backup_path'" \ + && print_info "Backed up (privileged): $rel_path" \ + || print_warning "Failed to backup (privileged): $rel_path" + else + cp -a "$dest" "$backup_path" \ + && print_info "Backed up: $rel_path" \ + || print_warning "Failed to backup: $rel_path" + fi + fi + } + + # Backup files from OS dir + if [[ -d "$os_dir" ]]; then + find "$os_dir" -type f | while read -r f; do + _backup_one "$f" + done + fi + + # Backup files from common dir + if [[ -d "$common_dir" ]]; then + find "$common_dir" -type f | while read -r f; do + _backup_one "$f" + done + fi + + print_success "Backup completed at: $backup_root" +} + +install_rust_development() { + local packages_file="$1" + + if ! command_exists rustc; then + install_rust + fi + + if command_exists cargo; then + print_info "Installing Rust components" + local components + mapfile -t components < <(yq eval ".development.rust.components[]" "$packages_file" 2>/dev/null | grep -v "^null$" || true) + + for component in "${components[@]}"; do + [[ -z "$component" ]] && continue + execute_command "rustup component add $component" + done + fi +} + +install_nodejs_development() { + local packages_file="$1" + + if ! command_exists node; then + install_nvm + install_node + fi + + if command_exists npm; then + print_info "Installing global Node.js packages" + local packages + mapfile -t packages < <(yq eval ".development.nodejs.global_packages[]" "$packages_file" 2>/dev/null | grep -v "^null$" || true) + + for package in "${packages[@]}"; do + [[ -z "$package" ]] && continue + execute_command "npm install -g $package" + done + fi +} + +install_python_development() { + local packages_file="$1" + + if command_exists pip || command_exists pip3; then + print_info "Installing global Python packages" + local packages + mapfile -t packages < <(yq eval ".development.python.global_packages[]" "$packages_file" 2>/dev/null | grep -v "^null$" || true) + + local pip_cmd="pip3" + command_exists pip3 || pip_cmd="pip" + + for package in "${packages[@]}"; do + [[ -z "$package" ]] && continue + execute_command "$pip_cmd install --user $package" + done + fi +} + +get_git_email_guess() { + local email_guess="" + + # Try to get email from existing git config + if command_exists git; then + email_guess=$(git config --global user.email 2>/dev/null || echo "") + if [[ -n "$email_guess" ]]; then + echo "$email_guess" + return 0 + fi + fi + + # Try to extract from common email-related environment variables + for var in EMAIL MAIL USER_EMAIL GIT_AUTHOR_EMAIL GIT_COMMITTER_EMAIL; do + if [[ -n "${!var:-}" ]]; then + echo "${!var}" + return 0 + fi + done + + # Check for email in /etc/passwd gecos field + if [[ -f /etc/passwd ]]; then + local gecos + gecos=$(getent passwd "$USER" 2>/dev/null | cut -d: -f5 | cut -d, -f1) + if [[ "$gecos" == *@* ]]; then + echo "$gecos" + return 0 + fi + fi + + # Try to guess based on common patterns + local domain="" + + # Check if we can determine domain from hostname + if command_exists hostname; then + local fqdn + fqdn=$(hostname -f 2>/dev/null || hostname 2>/dev/null || echo "") + if [[ "$fqdn" == *.* ]]; then + domain="${fqdn#*.}" + fi + fi + + # Fallback domain guessing + if [[ -z "$domain" ]]; then + if [[ -f /etc/mailname ]]; then + domain=$(cat /etc/mailname 2>/dev/null || echo "") + elif [[ -f /etc/hostname ]]; then + local hostname_file + hostname_file=$(cat /etc/hostname 2>/dev/null || echo "") + if [[ "$hostname_file" == *.* ]]; then + domain="${hostname_file#*.}" + fi + fi + fi + + # Final fallback + if [[ -z "$domain" ]]; then + domain="localhost" + fi + + echo "${USER}@${domain}" +} + +configure_git() { + local git_name="${USER}" + local git_email + git_email=$(get_git_email_guess) + + if [[ "$FORCE_MODE" != true ]]; then + print_color "$YELLOW" "Enter your Git username [$git_name]: " + read -r input_name + [[ -n "$input_name" ]] && git_name="$input_name" + + print_color "$YELLOW" "Enter your Git email [$git_email]: " + read -r input_email + [[ -n "$input_email" ]] && git_email="$input_email" + fi + + execute_command "git config --global user.name '$git_name'" + execute_command "git config --global user.email '$git_email'" + execute_command "git config --global init.defaultBranch main" + execute_command "git config --global pull.rebase false" + print_success "Git configured with name: $git_name, email: $git_email" +} + +install_development_tools() { + if [[ "$INTERNET_AVAILABLE" != true ]]; then + print_warning "No internet connectivity - skipping development tools installation" + return 0 + fi + + print_info "Installing development tools..." + + # Install Rust if not present + if ! command_exists rustc; then + install_rust + fi + + # Install Node.js via NVM if not present + if ! command_exists node; then + install_nvm + install_node + fi + + # Install Yarn if Node.js is available + if command_exists npm && ! command_exists yarn; then + install_yarn + fi +} + +install_rust() { + print_info "Installing Rust via rustup..." + + if command_exists rustup; then + print_info "Rust already installed" + return 0 + fi + + local cargo_home="${XDG_DATA_HOME:-$HOME/.local/share}/cargo" + local rustup_home="${XDG_DATA_HOME:-$HOME/.local/share}/rustup" + + create_dir "$(dirname "$cargo_home")" + + if execute_command "CARGO_HOME='$cargo_home' RUSTUP_HOME='$rustup_home' curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y"; then + print_success "Rust installed successfully" + + # Add to PATH for current session + if [[ -f "$cargo_home/env" ]]; then + source "$cargo_home/env" + fi + + return 0 + else + print_error "Failed to install Rust" + return 1 + fi +} + +install_nvm() { + local nvm_dir="$HOME/.config/nvm" + + if [[ -d "$nvm_dir" && -f "$nvm_dir/nvm.sh" ]]; then + print_info "NVM already installed" + return 0 + fi + + print_info "Installing Node Version Manager (NVM)..." + create_dir "$nvm_dir" + + if execute_command "curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/master/install.sh | NVM_DIR='$nvm_dir' bash"; then + export NVM_DIR="$nvm_dir" + if [[ -s "$NVM_DIR/nvm.sh" ]]; then + source "$NVM_DIR/nvm.sh" + print_success "NVM installed successfully" + return 0 + else + print_error "NVM installation failed - script not found" + return 1 + fi + else + print_error "Failed to install NVM" + return 1 + fi +} + +install_node() { + if command_exists node; then + print_info "Node.js already installed" + return 0 + fi + + print_info "Installing Node.js..." + + # Source NVM if available + local nvm_dir="$HOME/.config/nvm" + if [[ -s "$nvm_dir/nvm.sh" ]]; then + export NVM_DIR="$nvm_dir" + source "$NVM_DIR/nvm.sh" + fi + + if command_exists nvm; then + if execute_command "nvm install --lts" && execute_command "nvm use --lts" && execute_command "nvm alias default lts/*"; then + print_success "Node.js installed successfully" + return 0 + else + print_error "Failed to install Node.js via NVM" + return 1 + fi + else + print_error "NVM not available for Node.js installation" + return 1 + fi +} + +install_yarn() { + print_info "Installing Yarn..." + + if execute_command "curl -o- -L https://yarnpkg.com/install.sh | bash"; then + print_success "Yarn installed successfully" + + # Add to PATH for current session + local yarn_bin="$HOME/.yarn/bin" + if [[ -d "$yarn_bin" && ":$PATH:" != *":$yarn_bin:"* ]]; then + export PATH="$yarn_bin:$PATH" + fi + + return 0 + else + print_error "Failed to install Yarn" + return 1 + fi +} + +#====================================== +# System Tweaks Functions +#====================================== + +apply_system_tweaks() { + local packages_file="$1" + + print_section "Applying System Tweaks" + + if [[ ! -f "$packages_file" ]]; then + print_warning "Package file not found, skipping system tweaks" + return 0 + fi + + # Detect desktop environment and apply appropriate tweaks + local desktop_env="" + if [[ "$XDG_CURRENT_DESKTOP" == *"GNOME"* ]] || command_exists gnome-shell; then + desktop_env="gnome" + elif [[ "$XDG_CURRENT_DESKTOP" == *"KDE"* ]] || command_exists plasmashell; then + desktop_env="kde" + fi + + if [[ -n "$desktop_env" ]]; then + print_info "Applying $desktop_env tweaks" + + # Get tweak commands for the desktop environment + local tweaks + mapfile -t tweaks < <(yq eval ".system_tweaks.$desktop_env[]" "$packages_file" 2>/dev/null | grep -v "^null$" || true) + + for tweak in "${tweaks[@]}"; do + [[ -z "$tweak" ]] && continue + print_info "Applying tweak: $tweak" + if execute_command "$tweak"; then + print_success "Applied: $tweak" + else + print_warning "Failed to apply: $tweak" + fi + done + else + print_info "No supported desktop environment detected for tweaks" + fi + +} + +apply_tweaks() { + print_section "Applying System Tweaks" + save_state "apply_tweaks" "started" + + # Change to home directory to find packages.yml + local original_dir="$PWD" + cd "$HOME" 2>/dev/null || true + + local packages_files=("$PACKAGES_FILE" "common/$PACKAGES_FILE" ".cfg/common/$PACKAGES_FILE") + local found_packages_file="" + + for pf in "${packages_files[@]}"; do + if [[ -f "$pf" ]]; then + found_packages_file="$pf" + break + fi + done + + if [[ -n "$found_packages_file" ]]; then + apply_system_tweaks "$found_packages_file" + else + case "$CFG_OS" in + linux) + apply_linux_tweaks + ;; + macos) + apply_macos_tweaks + ;; + *) + print_info "No system tweaks defined for $CFG_OS" + ;; + esac + fi + + cd "$original_dir" 2>/dev/null || true + mark_step_completed "apply_tweaks" +} + +apply_linux_tweaks() { + # --- Locale tweak --- + if command -v localectl >/dev/null 2>&1; then + local current_locale + current_locale=$(localectl status | grep "System Locale" | cut -d= -f2 | cut -d, -f1) + if [[ "$current_locale" != "en_US.UTF-8" ]]; then + if prompt_user "Set system locale to en_US.UTF-8?"; then + if execute_with_privilege "localectl set-locale LANG=en_US.UTF-8"; then + print_success "Locale set to en_US.UTF-8" + else + print_error "Failed to set locale" + fi + fi + fi + fi + + # Desktop environment tweaks should be declared in packages.yml under system_tweaks. + print_info "Linux system tweaks applied (core). Desktop tweaks come from packages.yml." +} + +apply_macos_tweaks() { + print_info "macOS system tweaks applied (placeholder)" +} + +#====================================== +# Custom Installation Functions +#====================================== + +handle_custom_installs() { + local packages_file="$1" + + if [[ ! -f "$packages_file" ]] || ! command_exists yq; then + return 0 + fi + + print_info "Processing custom installations..." + + # Get custom install commands + local custom_installs + mapfile -t custom_installs < <(yq eval ".custom_installs | keys | .[]" "$packages_file" 2>/dev/null | grep -v "^null$" || true) + + for install_name in "${custom_installs[@]}"; do + [[ -z "$install_name" ]] && continue + + # Check condition + local condition + condition=$(yq eval ".custom_installs.$install_name.condition" "$packages_file" 2>/dev/null | grep -v "^null$" || echo "") + + if [[ -n "$condition" ]]; then + # Evaluate condition safely even under set -u (nounset) + local -i _had_nounset=0 + if set -o | grep -q "nounset\s*on"; then + _had_nounset=1 + set +u + fi + if ! eval "$condition" 2>/dev/null; then + if [[ $_had_nounset -eq 1 ]]; then set -u; fi + print_info "Skipping $install_name (condition not met)" + continue + fi + if [[ $_had_nounset -eq 1 ]]; then set -u; fi + fi + + # Get OS-specific command + local install_cmd="" + case "$CFG_OS" in + linux) + install_cmd=$(yq eval ".custom_installs.$install_name.linux" "$packages_file" 2>/dev/null | grep -v "^null$" || echo "") + ;; + macos) + install_cmd=$(yq eval ".custom_installs.$install_name.macos" "$packages_file" 2>/dev/null | grep -v "^null$" || echo "") + ;; + windows) + install_cmd=$(yq eval ".custom_installs.$install_name.windows" "$packages_file" 2>/dev/null | grep -v "^null$" || echo "") + ;; + esac + + # Fallback to generic command + if [[ -z "$install_cmd" ]]; then + install_cmd=$(yq eval ".custom_installs.$install_name.command" "$packages_file" 2>/dev/null | grep -v "^null$" || echo "") + fi + + if [[ -n "$install_cmd" ]]; then + print_info "Running custom install: $install_name" + if execute_command "$install_cmd"; then + print_success "Custom install completed: $install_name" + # If yq was installed into ~/.local/bin via custom install, ensure PATH includes it for current session + if [[ "$install_name" == "yq" && -x "$HOME/.local/bin/yq" ]]; then + if [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then + export PATH="$HOME/.local/bin:$PATH" + print_info "Added $HOME/.local/bin to PATH for current session" + fi + fi + else + print_error "Custom install failed: $install_name" + fi + else + print_warning "No install command found for $install_name on $CFG_OS" + fi + done +} + +#====================================== +# Installation Mode Selection +#====================================== + + +detect_installation_mode() { + if [[ "$INSTALL_MODE" != "ask" ]]; then + return 0 # Mode already set via command line + fi + + # Check if this is a re-run + if [[ -d "$DOTFILES_DIR" && ! "$UPDATE_MODE" == true ]]; then + print_section "Existing Installation Detected" + print_info "Dotfiles repository already exists at: $DOTFILES_DIR" + + if [[ "$FORCE_MODE" == true ]]; then + print_info "Force mode: proceeding with update" + UPDATE_MODE=true + INSTALL_MODE="essentials" # Default to essentials for updates + else + while true; do + print_color "$YELLOW" "What would you like to do?" + print_color "$CYAN" "1. Update existing dotfiles and system" + print_color "$CYAN" "2. Full reinstallation" + print_color "$CYAN" "3. Exit" + print_color "$YELLOW" "Select option [1-3]: " + read -r response + + case "$response" in + 1) + UPDATE_MODE=true + INSTALL_MODE="essentials" + print_success "Update mode selected" + break + ;; + 2) + print_warning "This will backup and reinstall everything" + if prompt_user "Continue with full reinstallation?"; then + # Backup existing installation + local backup_timestamp=$(date +%Y%m%d-%H%M%S) + local backup_location="$HOME/.dotfiles-backup-$backup_timestamp" + print_info "Backing up existing installation to: $backup_location" + cp -r "$DOTFILES_DIR" "$backup_location" 2>/dev/null || true + break + else + continue + fi + ;; + 3) + print_info "Installation cancelled by user" + exit 0 + ;; + *) + print_warning "Invalid selection. Please enter 1-3" + ;; + esac + done + fi + fi + + # If still asking, show installation mode selection + if [[ "$INSTALL_MODE" == "ask" ]]; then + select_installation_mode + fi +} + +select_installation_mode() { + print_header "Installation Mode Selection" + + print_color "$CYAN" "Available installation modes:" + echo + + local mode_number=1 + local mode_options=() + + for mode in essentials minimal dev server full; do + local description="${INSTALLATION_PROFILES[$mode]}" + print_color "$YELLOW" "$mode_number. $mode - $description" + mode_options+=("$mode") + ((mode_number++)) + done + + echo + print_color "$CYAN" "You can also specify a custom profile from the profiles/ directory" + echo + + while true; do + print_color "$YELLOW" "Select installation mode [1-5] or enter profile name [dev]: " + read -r response + + if [[ -z "$response" ]]; then + INSTALL_MODE="dev" + break + elif [[ "$response" =~ ^[1-5]$ ]]; then + INSTALL_MODE="${mode_options[$((response-1))]}" + break + elif [[ "$response" =~ ^[a-zA-Z][a-zA-Z0-9_-]*$ ]]; then + # Check if it's a valid profile + if [[ -f "profiles/$response.yml" ]] || [[ "${INSTALLATION_PROFILES[$response]:-}" ]]; then + INSTALL_MODE="$response" + break + else + print_warning "Profile '$response' not found" + fi + else + print_warning "Invalid selection. Please enter 1-5 or a profile name" + fi + done + + print_success "Selected installation mode: $INSTALL_MODE" + print_info "Description: ${INSTALLATION_PROFILES[$INSTALL_MODE]:-Custom profile}" +} + +#====================================== +# Ask Mode Implementation +#====================================== + +should_run_step() { + local step="$1" + local description="${INSTALLATION_STEPS[$step]}" + + # Respect explicit skip list + if is_step_skipped "$step"; then + return 1 + fi + + # Run-only and run-from controls + if [[ -n "$RUN_ONLY_STEP" && "$step" != "$RUN_ONLY_STEP" ]]; then + return 1 + fi + if [[ -n "$RUN_FROM_STEP" && "$__RUN_FROM_STARTED" != true ]]; then + if [[ "$step" == "$RUN_FROM_STEP" ]]; then + __RUN_FROM_STARTED=true + else + return 1 + fi + fi + + # Skip already completed steps unless forced + if is_step_completed "$step" && [[ "$FORCE_MODE" != true ]]; then + return 1 + fi + + # Ask mode prompt + if [[ "$ASK_MODE" == true ]]; then + prompt_user "Run step: $description?" && return 0 || return 1 + fi + + # Interactive skip even when not in ask mode (non-essential steps) + if [[ "$INTERACTIVE_SKIP" == true ]]; then + local is_essential=false + for es in "${ESSENTIAL_STEPS[@]}"; do [[ "$es" == "$step" ]] && is_essential=true && break; done + if [[ "$is_essential" != true ]]; then + if ! prompt_user "Proceed with: $description? (Choose No to skip)"; then + return 1 + fi + fi + fi + + return 0 +} + +#====================================== +# Command Line Argument Parsing +#====================================== + +show_help() { + cat << EOF +Dotfiles Installation Script + +USAGE: + $0 [OPTIONS] + +OPTIONS: + -h, --help Show this help message + -r, --resume Resume from last failed step + -u, --update Update existing dotfiles and packages + -v, --verbose Enable verbose output + -n, --dry-run Show what would be done without executing + -f, --force Force reinstallation and skip prompts + -a, --ask Ask before running each step + -m, --mode MODE Installation mode (essentials|minimal|dev|server|full|PROFILE) + +INSTALLATION MODES: + essentials Install only essential packages (git, curl, etc.) + minimal Minimal setup for basic development + dev Full development environment (default) + server Server configuration + full Complete installation with all packages + PROFILE Custom profile from profiles/ directory + +EXAMPLES: + $0 # Interactive installation (asks for mode) + $0 --mode essentials # Install essentials only + $0 --mode dev # Development environment + $0 --resume # Resume from last failed step + $0 --update --mode full # Update and install all packages + $0 --dry-run --mode dev # Preview development installation + $0 --ask --mode minimal # Ask before each step in minimal mode + +NOTES: + • Running without arguments on an existing installation will default to update mode + • Use --force to override existing installations + • Use --ask to have control over each installation step + • Configuration files are backed up before modification + +EOF +} + +parse_arguments() { + while [[ $# -gt 0 ]]; do + case $1 in + -h|--help) + show_help + exit 0 + ;; + -r|--resume) + RESUME_MODE=true + shift + ;; + -u|--update) + UPDATE_MODE=true + shift + ;; + -v|--verbose) + VERBOSE_MODE=true + shift + ;; + -n|--dry-run) + DRY_RUN=true + shift + ;; + -f|--force) + FORCE_MODE=true + shift + ;; + -a|--ask) + ASK_MODE=true + shift + ;; + -m|--mode) + INSTALL_MODE="$2" + shift 2 + ;; + *) + print_error "Unknown option: $1" + show_help + exit 1 + ;; + esac + done + + # Validate install mode + if [[ "$INSTALL_MODE" != "ask" ]]; then + if [[ ! "${INSTALLATION_PROFILES[$INSTALL_MODE]:-}" ]] && [[ ! -f "profiles/$INSTALL_MODE.yml" ]]; then + print_error "Invalid installation mode: $INSTALL_MODE" + print_info "Available modes: ${!INSTALLATION_PROFILES[*]}" + exit 1 + fi + fi +} + +#====================================== +# Summary Functions +#====================================== + +print_installation_summary() { + print_header "Installation Summary" + + local total_steps=${#STEP_ORDER[@]} + local completed_count=${#COMPLETED_STEPS[@]} + local failed_count=${#FAILED_ITEMS[@]} + + print_section "Progress Overview" + print_color "$CYAN" "Installation Mode: $INSTALL_MODE" + print_color "$CYAN" "Total Steps: $total_steps" + print_color "$GREEN" "Completed: $completed_count" + print_color "$RED" "Failed: $failed_count" + + if [[ ${#INSTALL_SUMMARY[@]} -gt 0 ]]; then + print_section "Successful Operations" + printf '%s\n' "${INSTALL_SUMMARY[@]}" + fi + + if [[ ${#FAILED_ITEMS[@]} -gt 0 ]]; then + print_section "Failed Operations" + printf '%s\n' "${FAILED_ITEMS[@]}" + print_warning "Check the log file: $LOG_FILE" + fi + + if [[ ${#SKIPPED_ITEMS[@]} -gt 0 ]]; then + print_section "Skipped Operations" + printf '%s\n' "${SKIPPED_ITEMS[@]}" + fi + + echo + print_color "$GREEN$BOLD" "Installation completed!" + print_info "Log file: $LOG_FILE" "always" +} + +#====================================== +# Main Installation Flow +#====================================== + +execute_step() { + local step_name="$1" + local step_desc="${INSTALLATION_STEPS[$step_name]}" + + if is_step_completed "$step_name" && [[ "$FORCE_MODE" != true ]]; then + print_success "$step_desc (already completed)" + return 0 + fi + + if "$step_name"; then + print_success "$step_desc completed" + mark_step_completed "$step_name" + return 0 + else + print_error "$step_desc failed" + mark_step_failed "$step_name" + return 1 + fi +} + +main() { + parse_arguments "$@" + setup_logging + + print_header "Dotfiles Installation" + + if [[ "$DRY_RUN" == true ]]; then + print_warning "DRY RUN MODE - No changes will be made" + echo + fi + + if [[ "$ASK_MODE" == true ]]; then + print_warning "ASK MODE - You will be prompted for each step" + echo + fi + + print_info "Starting installation for user: $USER" "always" + print_info "Log file: $LOG_FILE" "always" + + # Handle resume mode + if [[ "$RESUME_MODE" == true ]]; then + if load_state; then + print_info "Resuming from previous installation..." "always" + print_info "Last step: ${LAST_STEP:-unknown}" "always" + if [[ -n "${COMPLETED_STEPS:-}" ]]; then + eval "COMPLETED_STEPS=(${COMPLETED_STEPS:-})" + fi + else + print_warning "No previous installation state found" + print_info "Starting fresh installation..." + RESUME_MODE=false + fi + fi + + # Detect installation mode (handles re-runs and updates) + detect_installation_mode + + # Show installation plan + echo + print_color "$YELLOW$BOLD" "Installation Plan (Mode: $INSTALL_MODE):" + local step_number=1 + for step in "${STEP_ORDER[@]}"; do + local step_desc="${INSTALLATION_STEPS[$step]}" + if is_step_completed "$step" && [[ "$FORCE_MODE" != true ]]; then + print_color "$GREEN" "$step_number. $step_desc (✓ completed)" + else + print_color "$CYAN" "$step_number. $step_desc" + fi + step_number=$((step_number + 1)) + done + + echo + if [[ "$FORCE_MODE" != true ]] && [[ "$DRY_RUN" != true ]] && [[ "$ASK_MODE" != true ]]; then + if ! prompt_user "Continue with installation?"; then + print_info "Installation cancelled by user" + exit 0 + fi + fi + + # Execute installation steps + local failed_steps=() + local step_number=1 + local total_steps=${#STEP_ORDER[@]} + + for step in "${STEP_ORDER[@]}"; do + echo + print_color "$CYAN$BOLD" "[$step_number/$total_steps] ${INSTALLATION_STEPS[$step]}" + + # Check if we should run this step (ask mode) + if ! should_run_step "$step"; then + print_skip "${INSTALLATION_STEPS[$step]} (user choice)" + step_number=$((step_number + 1)) + continue + fi + + if execute_step "$step"; then + print_info "Step completed successfully: $step" + else + failed_steps+=("$step") + print_error "Step failed: $step" + + if [[ "$FORCE_MODE" != true ]] && [[ "$DRY_RUN" != true ]]; then + echo + if ! prompt_user "Step '$step' failed. Continue with remaining steps?" "Y"; then + print_info "Installation stopped by user" + break + fi + fi + fi + + step_number=$((step_number + 1)) + done + + # Post-installation + if [[ ${#failed_steps[@]} -eq 0 ]]; then + print_success "All installation steps completed successfully!" + clear_state + else + print_warning "${#failed_steps[@]} steps failed: ${failed_steps[*]}" + if [[ "${failed_steps[-1]:-}" != "" ]]; then + save_state "${failed_steps[-1]}" "failed" + fi + fi + + print_installation_summary + + # Final recommendations + if [[ "$DRY_RUN" != true ]]; then + echo + print_section "Post-Installation Recommendations" + print_color "$CYAN" "• Restart your terminal or run: source ~/.bashrc (or ~/.zshrc)" + print_color "$CYAN" "• Review your dotfiles configuration in: $DOTFILES_DIR" + print_color "$CYAN" "• Use the 'config' command to manage your dotfiles" + print_color "$CYAN" "• Test the config command: config status" + + if [[ ${#failed_steps[@]} -gt 0 ]]; then + print_color "$YELLOW" "• Run '$0 --resume' to retry failed steps" + print_color "$YELLOW" "• Check the log file for detailed error information: $LOG_FILE" + fi + + echo + print_color "$GREEN$BOLD" "Thank you for using the Dotfiles Installation Script!" + fi + + [[ ${#failed_steps[@]} -eq 0 ]] && exit 0 || exit 1 +} + +#====================================== +# Script Entry Point +#====================================== + +cleanup_on_exit() { + local exit_code=$? + + if [[ $exit_code -ne 0 ]] && [[ "$DRY_RUN" != true ]]; then + print_error "Installation interrupted (exit code: $exit_code)" + + if [[ -n "${current_step:-}" ]]; then + save_state "$current_step" "interrupted" + print_info "State saved. Run with --resume to continue" + fi + fi +} + +handle_interrupt() { + print_warning "Installation interrupted by user" + exit 130 +} + +trap cleanup_on_exit EXIT +trap handle_interrupt INT + +# Execute main if script is run directly +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + # Check basic requirements + for req in git curl; do + if ! command_exists "$req"; then + print_error "$req is required but not installed" + exit 1 + fi + done + + main "$@" +fi |
