#!/bin/bash # ============================================================================= # utils.sh - Shared utilities for n8n-install scripts # ============================================================================= # Common functions and utilities used across all installation scripts. # # Provides: # - Path initialization (init_paths): Sets SCRIPT_DIR, PROJECT_ROOT, ENV_FILE # - Logging functions: log_info, log_success, log_warning, log_error # - .env manipulation: read_env_var, write_env_var, load_env # - Whiptail wrappers: wt_input, wt_yesno, require_whiptail # - Validation helpers: require_command, require_file, ensure_file_exists # - Profile management: is_profile_active, update_compose_profiles # - Doctor output helpers: print_ok, print_warning, print_error # - Directory preservation: backup_preserved_dirs, restore_preserved_dirs # # Usage: source "$(dirname "$0")/utils.sh" && init_paths # ============================================================================= #============================================================================= # CONSTANTS #============================================================================= DOMAIN_PLACEHOLDER="yourdomain.com" #============================================================================= # WHIPTAIL THEME (NEWT_COLORS) #============================================================================= # Solarized Dark theme with blue/cyan accents # Format: element=foreground,background # Colors: black, red, green, yellow, blue, magenta, cyan, white # Prefix with "bright" for bright variants (e.g., brightblue) export NEWT_COLORS=' root=white,black border=blue,black window=white,black shadow=black,black title=brightblue,black button=black,blue actbutton=black,cyan compactbutton=white,black checkbox=blue,black actcheckbox=black,cyan entry=cyan,black disentry=gray,black label=white,black listbox=white,black actlistbox=black,blue sellistbox=cyan,black actsellistbox=black,cyan textbox=white,black acttextbox=blue,black emptyscale=black,black fullscale=blue,black helpline=blue,black roottext=blue,black ' #============================================================================= # PATH INITIALIZATION #============================================================================= # Initialize standard paths - call at start of each script # WARNING: Must be called directly from script top-level, NOT from within functions. # BASH_SOURCE[1] refers to the script that sourced utils.sh. # Usage: source utils.sh && init_paths init_paths() { # BASH_SOURCE[1] = the script that called this function (not utils.sh itself) SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[1]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" ENV_FILE="$PROJECT_ROOT/.env" } #============================================================================= # LOGGING (Simplified) #============================================================================= # Internal logging function _log() { local level="$1" local message="$2" echo "" echo "[$level] $(date +%H:%M:%S): $message" } log_info() { _log "INFO" "$1" } log_success() { _log "OK" "$1" } log_warning() { _log "WARN" "$1" } log_error() { _log "ERROR" "$1" >&2 } # Display a header for major sections # Usage: log_header "Section Title" log_header() { local message="$1" local width=60 local padding=$(( (width - ${#message} - 2) / 2 )) local pad_left=$(printf '%*s' "$padding" '' | tr ' ' '=') local pad_right=$(printf '%*s' "$((width - ${#message} - 2 - padding))" '' | tr ' ' '=') echo "" echo "" echo -e "${BRIGHT_GREEN}${pad_left}${NC} ${BOLD}${WHITE}${message}${NC} ${BRIGHT_GREEN}${pad_right}${NC}" } # Display a sub-header for sections # Usage: log_subheader "Sub Section" log_subheader() { local message="$1" echo "" echo -e "${CYAN}--- ${message} ---${NC}" } # Display a divider line # Usage: log_divider log_divider() { echo "" echo -e "${DIM}${GREEN}$(printf '%.0s-' {1..60})${NC}" } # Display text in a box (for important messages) # Usage: log_box "Important message" log_box() { local message="$1" local len=${#message} local border=$(printf '%*s' "$((len + 4))" '' | tr ' ' '=') echo "" echo -e "${BRIGHT_GREEN}+${border}+${NC}" echo -e "${BRIGHT_GREEN}|${NC} ${BOLD}${WHITE}${message}${NC} ${BRIGHT_GREEN}|${NC}" echo -e "${BRIGHT_GREEN}+${border}+${NC}" } #============================================================================= # COLOR OUTPUT (for diagnostics and previews) #============================================================================= # Basic colors RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' CYAN='\033[0;36m' MAGENTA='\033[0;35m' WHITE='\033[1;37m' GRAY='\033[0;90m' NC='\033[0m' # No Color / Reset # Text styles BOLD='\033[1m' DIM='\033[2m' RESET='\033[0m' # Bright colors BRIGHT_RED='\033[1;31m' BRIGHT_GREEN='\033[1;32m' BRIGHT_YELLOW='\033[1;33m' BRIGHT_BLUE='\033[1;34m' BRIGHT_CYAN='\033[1;36m' # Background colors (for badges/labels) BG_RED='\033[41m' BG_GREEN='\033[42m' BG_YELLOW='\033[43m' print_ok() { echo "" echo -e " ${GREEN}[OK]${NC} $1" } print_error() { echo "" echo -e " ${RED}[ERROR]${NC} $1" } print_warning() { echo "" echo -e " ${YELLOW}[WARNING]${NC} $1" } print_info() { echo "" echo -e " ${BLUE}[INFO]${NC} $1" } #============================================================================= # PROGRESS INDICATORS #============================================================================= # Spinner animation frames SPINNER_FRAMES=('|' '/' '-' '\') SPINNER_PID="" # Start spinner with message # Usage: start_spinner "Loading..." start_spinner() { local message="$1" local i=0 # Don't start if not in terminal or already running [[ ! -t 1 ]] && return [[ -n "$SPINNER_PID" ]] && return ( while true; do printf "\r ${GREEN}${SPINNER_FRAMES[$i]}${NC} ${message} " i=$(( (i + 1) % 4 )) sleep 0.1 done ) & SPINNER_PID=$! disown } # Stop spinner and clear line # Usage: stop_spinner stop_spinner() { [[ -z "$SPINNER_PID" ]] && return kill "$SPINNER_PID" 2>/dev/null wait "$SPINNER_PID" 2>/dev/null SPINNER_PID="" # Clear the spinner line printf "\r%*s\r" 80 "" } # Show step progress (e.g., Step 3/7) # Usage: show_step 3 7 "Installing Docker" show_step() { local current=$1 local total=$2 local description="$3" echo "" echo -e "${BRIGHT_GREEN}[${current}/${total}]${NC} ${BOLD}${description}${NC}" echo -e "${DIM}$(printf '%.0s.' {1..50})${NC}" } # Show a simple progress bar # Usage: show_progress 50 100 "Downloading" show_progress() { local current=$1 local total=$2 local label="${3:-Progress}" local width=40 local percent=$(( current * 100 / total )) local filled=$(( current * width / total )) local empty=$(( width - filled )) local bar_filled=$(printf '%*s' "$filled" '' | tr ' ' '#') local bar_empty=$(printf '%*s' "$empty" '' | tr ' ' '-') printf "\r ${label}: ${GREEN}[${bar_filled}${GRAY}${bar_empty}${GREEN}]${NC} ${WHITE}%3d%%${NC}" "$percent" } # Complete progress bar with message # Usage: complete_progress "Download complete" complete_progress() { local message="${1:-Done}" printf "\r%*s\r" 80 "" echo -e " ${GREEN}[OK]${NC} ${message}" } #============================================================================= # ENVIRONMENT MANAGEMENT #============================================================================= # Load .env file safely # Usage: load_env [env_file_path] load_env() { local env_file="${1:-$ENV_FILE}" if [[ ! -f "$env_file" ]]; then log_error ".env file not found: $env_file" return 1 fi set -a source "$env_file" set +a } # Read a variable from .env file # Usage: value=$(read_env_var "VAR_NAME" [env_file]) read_env_var() { local var_name="$1" local env_file="${2:-$ENV_FILE}" if grep -q "^${var_name}=" "$env_file" 2>/dev/null; then grep "^${var_name}=" "$env_file" | cut -d'=' -f2- | sed 's/^"//' | sed 's/"$//' | sed "s/^'//" | sed "s/'$//" fi } # Write/update a variable in .env file (with automatic .bak cleanup) # Usage: write_env_var "VAR_NAME" "value" [env_file] write_env_var() { local var_name="$1" local var_value="$2" local env_file="${3:-$ENV_FILE}" if grep -q "^${var_name}=" "$env_file" 2>/dev/null; then sed -i.bak "\|^${var_name}=|d" "$env_file" rm -f "${env_file}.bak" fi echo "${var_name}=\"${var_value}\"" >> "$env_file" } # Check if a Docker Compose profile is active # IMPORTANT: Requires COMPOSE_PROFILES to be set before calling (via load_env or direct assignment) # Usage: is_profile_active "n8n" && echo "n8n is active" is_profile_active() { local profile="$1" [[ -n "$COMPOSE_PROFILES" && ",$COMPOSE_PROFILES," == *",$profile,"* ]] } # Get n8n workers compose file path if profile is active and file exists # Usage: path=$(get_n8n_workers_compose) && COMPOSE_FILES+=("-f" "$path") get_n8n_workers_compose() { local compose_file="$PROJECT_ROOT/docker-compose.n8n-workers.yml" if [ -f "$compose_file" ] && is_profile_active "n8n"; then echo "$compose_file" return 0 fi return 1 } # Get Supabase compose file path if profile is active and file exists # Usage: path=$(get_supabase_compose) && COMPOSE_FILES+=("-f" "$path") get_supabase_compose() { local compose_file="$PROJECT_ROOT/supabase/docker/docker-compose.yml" if [ -f "$compose_file" ] && is_profile_active "supabase"; then echo "$compose_file" return 0 fi return 1 } # Get Dify compose file path if profile is active and file exists # Usage: path=$(get_dify_compose) && COMPOSE_FILES+=("-f" "$path") get_dify_compose() { local compose_file="$PROJECT_ROOT/dify/docker/docker-compose.yaml" if [ -f "$compose_file" ] && is_profile_active "dify"; then echo "$compose_file" return 0 fi return 1 } # Build array of all active compose files (main + external services) # Appends docker-compose.override.yml last if it exists (user overrides, highest precedence) # IMPORTANT: Requires COMPOSE_PROFILES to be set before calling (via load_env) # Usage: build_compose_files_array; docker compose "${COMPOSE_FILES[@]}" up -d # Result is stored in global COMPOSE_FILES array build_compose_files_array() { COMPOSE_FILES=("-f" "$PROJECT_ROOT/docker-compose.yml") local path if path=$(get_n8n_workers_compose); then COMPOSE_FILES+=("-f" "$path") fi if path=$(get_supabase_compose); then COMPOSE_FILES+=("-f" "$path") fi if path=$(get_dify_compose); then COMPOSE_FILES+=("-f" "$path") fi # Include user overrides last (highest precedence) local override="$PROJECT_ROOT/docker-compose.override.yml" if [ -f "$override" ]; then COMPOSE_FILES+=("-f" "$override") fi } #============================================================================= # UTILITIES #============================================================================= # Require a command to be available # Usage: require_command "docker" "Install Docker: https://docs.docker.com/engine/install/" require_command() { local cmd="$1" local install_hint="${2:-Please install $cmd}" if ! command -v "$cmd" &> /dev/null; then log_error "'$cmd' not found. $install_hint" exit 1 fi } # Cleanup .bak files created by sed -i # Usage: cleanup_bak_files [directory] cleanup_bak_files() { local directory="${1:-$PROJECT_ROOT}" find "$directory" -maxdepth 1 -name "*.bak" -type f -delete 2>/dev/null || true } # Escape string for JSON output # Usage: escaped=$(json_escape "string with \"quotes\"") json_escape() { local str="$1" printf '%s' "$str" | sed 's/\\/\\\\/g; s/"/\\"/g; s/ /\\t/g' | tr -d '\n\r' } #============================================================================= # FILE UTILITIES #============================================================================= # Require a file to exist, exit with error if not found # Usage: require_file "/path/to/file" "Custom error message" require_file() { local file="$1" local error_msg="${2:-File not found: $file}" if [[ ! -f "$file" ]]; then log_error "$error_msg" exit 1 fi } # Ensure a file exists, create empty file if it doesn't # Usage: ensure_file_exists "/path/to/file" ensure_file_exists() { local file="$1" if [[ ! -f "$file" ]]; then touch "$file" fi } #============================================================================= # COMPOSE PROFILES MANAGEMENT #============================================================================= # Update COMPOSE_PROFILES in .env file # Usage: update_compose_profiles "n8n,monitoring,portainer" [env_file] update_compose_profiles() { local profiles="$1" local env_file="${2:-$ENV_FILE}" ensure_file_exists "$env_file" if grep -q "^COMPOSE_PROFILES=" "$env_file"; then sed -i.bak "\|^COMPOSE_PROFILES=|d" "$env_file" rm -f "${env_file}.bak" fi echo "COMPOSE_PROFILES=${profiles}" >> "$env_file" } #============================================================================= # DEBIAN_FRONTEND MANAGEMENT #============================================================================= ORIGINAL_DEBIAN_FRONTEND="" # Save current DEBIAN_FRONTEND and set to dialog for whiptail # Usage: save_debian_frontend save_debian_frontend() { ORIGINAL_DEBIAN_FRONTEND="$DEBIAN_FRONTEND" export DEBIAN_FRONTEND=dialog } # Restore original DEBIAN_FRONTEND value # Usage: restore_debian_frontend restore_debian_frontend() { if [[ -n "$ORIGINAL_DEBIAN_FRONTEND" ]]; then export DEBIAN_FRONTEND="$ORIGINAL_DEBIAN_FRONTEND" else unset DEBIAN_FRONTEND fi } #============================================================================= # SECRET GENERATION #============================================================================= # Generate random string with specified characters # Usage: gen_random 32 'A-Za-z0-9' gen_random() { local length="$1" local characters="$2" head /dev/urandom | tr -dc "$characters" | head -c "$length" } # Generate alphanumeric password # Usage: gen_password 32 gen_password() { gen_random "$1" 'A-Za-z0-9' } # Generate hex string # Usage: gen_hex 64 (returns 64 hex characters) gen_hex() { local length="$1" local bytes=$(( (length + 1) / 2 )) openssl rand -hex "$bytes" | head -c "$length" } # Generate base64 string # Usage: gen_base64 64 (returns 64 base64 characters) gen_base64() { local length="$1" local bytes=$(( (length * 3 + 3) / 4 )) openssl rand -base64 "$bytes" | head -c "$length" } # Generate bcrypt hash using Caddy # Usage: hash=$(generate_bcrypt_hash "plaintext_password") generate_bcrypt_hash() { local plaintext="$1" if [[ -n "$plaintext" ]]; then caddy hash-password --algorithm bcrypt --plaintext "$plaintext" 2>/dev/null fi } #============================================================================= # VALIDATION #============================================================================= # Validate that a value is a positive integer # Usage: validate_positive_integer "5" && echo "valid" validate_positive_integer() { local value="$1" [[ "$value" =~ ^0*[1-9][0-9]*$ ]] } #============================================================================= # WHIPTAIL HELPERS #============================================================================= # Ensure whiptail is available require_whiptail() { if ! command -v whiptail >/dev/null 2>&1; then log_error "'whiptail' is not installed. Install with: sudo apt-get install -y whiptail" exit 1 fi } # Get adaptive terminal size for whiptail dialogs # Usage: eval "$(wt_get_size)" # Sets: WT_HEIGHT, WT_WIDTH, WT_LIST_HEIGHT wt_get_size() { local term_lines term_cols term_lines=$(tput lines 2>/dev/null || echo 24) term_cols=$(tput cols 2>/dev/null || echo 80) # Calculate dimensions with margins local height=$((term_lines - 4)) local width=$((term_cols - 4)) # Apply min/max constraints [[ $height -lt 10 ]] && height=10 [[ $height -gt 40 ]] && height=40 [[ $width -lt 60 ]] && width=60 [[ $width -gt 120 ]] && width=120 # List height for checklists/menus (leave room for title, prompt, buttons) local list_height=$((height - 8)) [[ $list_height -lt 5 ]] && list_height=5 echo "WT_HEIGHT=$height WT_WIDTH=$width WT_LIST_HEIGHT=$list_height" } # Input box with adaptive sizing # Usage: result=$(wt_input "Title" "Prompt" "default") # Returns 0 on OK, 1 on Cancel wt_input() { local title="$1" local prompt="$2" local default_value="$3" eval "$(wt_get_size)" local result result=$(whiptail --title "$title" --inputbox "$prompt" "$WT_HEIGHT" "$WT_WIDTH" "$default_value" 3>&1 1>&2 2>&3) local status=$? [[ $status -ne 0 ]] && return 1 echo "$result" return 0 } # Password box with adaptive sizing # Usage: result=$(wt_password "Title" "Prompt") # Returns 0 on OK, 1 on Cancel wt_password() { local title="$1" local prompt="$2" eval "$(wt_get_size)" local result result=$(whiptail --title "$title" --passwordbox "$prompt" "$WT_HEIGHT" "$WT_WIDTH" 3>&1 1>&2 2>&3) local status=$? [[ $status -ne 0 ]] && return 1 echo "$result" return 0 } # Yes/No box with adaptive sizing # Usage: wt_yesno "Title" "Prompt" "default" (default: yes|no) # Returns 0 for Yes, 1 for No/Cancel wt_yesno() { local title="$1" local prompt="$2" local default_choice="$3" eval "$(wt_get_size)" local height=$((WT_HEIGHT < 12 ? WT_HEIGHT : 12)) if [ "$default_choice" = "yes" ]; then whiptail --title "$title" --yesno "$prompt" "$height" "$WT_WIDTH" else whiptail --title "$title" --defaultno --yesno "$prompt" "$height" "$WT_WIDTH" fi } # Message box with adaptive sizing # Usage: wt_msg "Title" "Message" wt_msg() { local title="$1" local message="$2" eval "$(wt_get_size)" local height=$((WT_HEIGHT < 12 ? WT_HEIGHT : 12)) whiptail --title "$title" --msgbox "$message" "$height" "$WT_WIDTH" } # Checklist (multiple selection) with adaptive sizing # Usage: result=$(wt_checklist "Title" "Prompt" "tag1" "desc1" "ON" "tag2" "desc2" "OFF" ...) # Returns: space-separated quoted tags, e.g., "tag1" "tag2" wt_checklist() { local title="$1" local prompt="$2" shift 2 eval "$(wt_get_size)" whiptail --title "$title" --checklist "$prompt" "$WT_HEIGHT" "$WT_WIDTH" "$WT_LIST_HEIGHT" "$@" 3>&1 1>&2 2>&3 } # Radiolist (single selection) with adaptive sizing # Usage: result=$(wt_radiolist "Title" "Prompt" "default_item" "tag1" "desc1" "ON" ...) # Returns: selected tag wt_radiolist() { local title="$1" local prompt="$2" local default_item="$3" shift 3 eval "$(wt_get_size)" whiptail --title "$title" --default-item "$default_item" --radiolist "$prompt" "$WT_HEIGHT" "$WT_WIDTH" "$WT_LIST_HEIGHT" "$@" 3>&1 1>&2 2>&3 } # Menu (item selection) with adaptive sizing # Usage: result=$(wt_menu "Title" "Prompt" "tag1" "desc1" "tag2" "desc2" ...) # Returns: selected tag wt_menu() { local title="$1" local prompt="$2" shift 2 eval "$(wt_get_size)" whiptail --title "$title" --menu "$prompt" "$WT_HEIGHT" "$WT_WIDTH" "$WT_LIST_HEIGHT" "$@" 3>&1 1>&2 2>&3 } # Safe parser for whiptail checklist results (replaces eval) # Usage: wt_parse_choices "$CHOICES" result_array # Parses quoted output like: "tag1" "tag2" "tag3" wt_parse_choices() { local choices="$1" local -n arr="$2" arr=() # Remove quotes and split by spaces local cleaned="${choices//\"/}" read -ra arr <<< "$cleaned" } #============================================================================= # LEGACY CONTAINER CLEANUP #============================================================================= # Remove legacy n8n worker containers from previous naming convention # Old format: localai-n8n-worker-N (N = 1-10) # New format: n8n-worker-N (managed by docker-compose.n8n-workers.yml) # Usage: cleanup_legacy_n8n_workers cleanup_legacy_n8n_workers() { local removed_count=0 local container_name log_info "Checking for legacy n8n worker containers..." for i in {1..10}; do container_name="localai-n8n-worker-$i" # Check if container exists (running or stopped) if docker ps -a --format '{{.Names}}' | grep -q "^${container_name}$"; then log_info "Removing legacy container: $container_name" docker stop "$container_name" 2>/dev/null || true docker rm -f "$container_name" 2>/dev/null || true removed_count=$((removed_count + 1)) fi done if [ $removed_count -gt 0 ]; then log_success "Removed $removed_count legacy n8n worker container(s)" else log_info "No legacy n8n worker containers found" fi } # Clean up legacy postgresus container after rename to databasus # This function removes the old "postgresus" container if it exists, # allowing the new "databasus" container to take its place. # Usage: cleanup_legacy_postgresus cleanup_legacy_postgresus() { local container_name="postgresus" # Check if container exists (running or stopped) if docker ps -a --format '{{.Names}}' | grep -q "^${container_name}$"; then log_info "Found legacy postgresus container, migrating to databasus..." docker stop "$container_name" 2>/dev/null || true docker rm -f "$container_name" 2>/dev/null || true log_success "Legacy postgresus container removed. Databasus will use existing data via volume alias." fi } #============================================================================= # USER DETECTION #============================================================================= # Get the real user who invoked the script (even when running with sudo) # Usage: real_user=$(get_real_user) # Returns: username of the real user, or "root" if cannot determine get_real_user() { # Try SUDO_USER first (set by sudo) if [[ -n "${SUDO_USER:-}" && "$SUDO_USER" != "root" ]]; then echo "$SUDO_USER" return 0 fi # Try logname (gets login name) local logname_user logname_user=$(logname 2>/dev/null) || true if [[ -n "$logname_user" && "$logname_user" != "root" ]]; then echo "$logname_user" return 0 fi # Try who am i (gets TTY user) local who_user who_user=$(who am i 2>/dev/null | awk '{print $1}') || true if [[ -n "$who_user" && "$who_user" != "root" ]]; then echo "$who_user" return 0 fi # Check if we're in a user's home directory local current_dir="$PWD" if [[ "$current_dir" =~ ^/home/([^/]+) ]]; then local home_user="${BASH_REMATCH[1]}" if id "$home_user" &>/dev/null; then echo "$home_user" return 0 fi fi # Fallback to current user whoami } # Get the home directory of the real user # Usage: real_home=$(get_real_user_home) get_real_user_home() { local real_user real_user=$(get_real_user) eval echo "~$real_user" } #============================================================================= # DIRECTORY PRESERVATION (for git updates) #============================================================================= # Directories containing user-customizable content that should survive git reset. # Used by update.sh to backup before git operations and restore after. PRESERVE_DIRS=("python-runner") # Backup preserved directories before git reset # Usage: backup_path=$(backup_preserved_dirs) || exit 1 # Returns: 0 on success (prints backup path to stdout), 1 on failure # NOTE: All logs go to stderr to keep stdout clean for the return value backup_preserved_dirs() { local backup_base="" local has_content=0 # Check if any directories need backup for dir in "${PRESERVE_DIRS[@]}"; do if [ -d "$PROJECT_ROOT/$dir" ] && [ -n "$(ls -A "$PROJECT_ROOT/$dir" 2>/dev/null)" ]; then has_content=1 break fi done # No content to backup if [ $has_content -eq 0 ]; then echo "" return 0 fi # Create secure temporary directory backup_base=$(mktemp -d /tmp/n8n-install-backup.XXXXXXXXXX) || { echo "[ERROR] Failed to create backup directory" >&2 return 1 } chmod 700 "$backup_base" # Backup each directory for dir in "${PRESERVE_DIRS[@]}"; do # Validate directory name (no path traversal) if [[ "$dir" =~ \.\.|^/ ]]; then echo "[ERROR] Invalid directory name in PRESERVE_DIRS: $dir" >&2 rm -rf "$backup_base" return 1 fi if [ -d "$PROJECT_ROOT/$dir" ] && [ -n "$(ls -A "$PROJECT_ROOT/$dir" 2>/dev/null)" ]; then echo "[INFO] Backing up $dir/ before git reset..." >&2 if ! cp -rp "$PROJECT_ROOT/$dir" "$backup_base/$dir"; then echo "[ERROR] Failed to backup $dir/. Aborting to prevent data loss." >&2 rm -rf "$backup_base" return 1 fi fi done echo "$backup_base" return 0 } # Restore preserved directories after git reset # Usage: restore_preserved_dirs # Returns: 0 on success, 1 on failure restore_preserved_dirs() { local backup_base="$1" # Nothing to restore if [ -z "$backup_base" ]; then return 0 fi if [ ! -d "$backup_base" ]; then log_warning "Backup directory not found: $backup_base" return 0 fi # Safety checks for PROJECT_ROOT if [ -z "$PROJECT_ROOT" ]; then log_error "PROJECT_ROOT is not set. Refusing to restore." return 1 fi if [ "$PROJECT_ROOT" = "/" ] || [ "$PROJECT_ROOT" = "/root" ] || [ "$PROJECT_ROOT" = "/home" ]; then log_error "PROJECT_ROOT is set to a dangerous path: $PROJECT_ROOT" return 1 fi for dir in "${PRESERVE_DIRS[@]}"; do # Validate directory name if [[ "$dir" =~ \.\.|^/ ]] || [ -z "$dir" ]; then log_error "Invalid directory name: $dir" continue fi if [ -d "$backup_base/$dir" ]; then log_info "Restoring $dir/ after git reset..." # Remove the git-restored version if [ -d "$PROJECT_ROOT/$dir" ]; then if ! rm -rf "$PROJECT_ROOT/$dir"; then log_error "Failed to remove $PROJECT_ROOT/$dir" return 1 fi fi # Restore from backup if ! mv "$backup_base/$dir" "$PROJECT_ROOT/$dir"; then log_error "Failed to restore $dir/ from backup" return 1 fi fi done # Cleanup backup directory rm -rf "$backup_base" return 0 }