#!/usr/bin/env bash # Created By: srdusr # Created On: Tue 06 Sep 2025 16:20:52 PM CAT # Project: Dotfiles installation script # 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 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 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" # Do NOT source user shell files here to avoid early exits or side-effects. # We'll rely on fallbacks (git/manual deploy) if the function is not in the current shell. 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..." if git --git-dir="$DOTFILES_DIR" --work-tree="$DOTFILES_DIR" 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() { if type config >/dev/null 2>&1; then print_success "Config command is available and working" return 0 else print_warning "Config command not available" return 1 fi } # 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 local os_dir="$DOTFILES_DIR/$CFG_OS" local common_dir="$DOTFILES_DIR/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/*) 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 sys_dir="$(dirname "$sys_file")" mkdir -p "$sys_dir" # 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..." if 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" mark_step_failed "install_dotfiles" return 1 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//.git # git@github.com:/.git # https://github.com// 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" local success=false case "$init_system" in systemd) if [ "$action" == "enable" ]; then execute_command "$PRIVILEGE_TOOL systemctl enable '$service'" success=$? elif [ "$action" == "start" ]; then execute_command "$PRIVILEGE_TOOL systemctl start '$service'" success=$? 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 $((1 - 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. # This function keeps only essential, non-DE specific items. Use apply_system_tweaks # to apply YAML-driven commands. 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 if ! eval "$condition" 2>/dev/null; then print_info "Skipping $install_name (condition not met)" continue 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" 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