#!/bin/bash # Dotfiles Setup Script # Description: Install dotfiles using GNU Stow with backup and selective installation set -e # Configuration SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" BACKUP_DIR="$HOME/.dotfiles-backup-$(date +%Y%m%d-%H%M%S)" AVAILABLE_PACKAGES=(bash zsh git ghostty starship tmux lazygit zed opencode) # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # Functions log_info() { echo -e "${BLUE}[INFO]${NC} $1" } log_success() { echo -e "${GREEN}[SUCCESS]${NC} $1" } log_warning() { echo -e "${YELLOW}[WARNING]${NC} $1" } log_error() { echo -e "${RED}[ERROR]${NC} $1" } show_help() { cat << EOF Dotfiles Setup Script USAGE: $0 [OPTIONS] OPTIONS: -h, --help Show this help message -d, --dry-run Show what would be done without making changes -v, --verbose Enable verbose output -q, --quiet Suppress non-essential output --only=PACKAGES Install only specified packages (comma-separated) Available: ${AVAILABLE_PACKAGES[*]} --backup-dir=DIR Custom backup directory (default: ~/.dotfiles-backup-TIMESTAMP) EXAMPLES: $0 # Install all configurations $0 --only=git,zsh # Install only git and zsh configurations $0 --dry-run # Preview what would be installed $0 --verbose # Install with detailed output AVAILABLE PACKAGES: EOF for package in "${AVAILABLE_PACKAGES[@]}"; do echo " - $package" done } check_dependencies() { log_info "Checking dependencies..." if ! command -v stow &> /dev/null; then log_error "GNU Stow is not installed" log_info "Installing GNU Stow..." if command -v apt-get &> /dev/null; then sudo apt-get update && sudo apt-get install -y stow else log_error "Cannot install stow automatically. Please install it manually." exit 1 fi fi log_success "All dependencies satisfied" } install_toolchain() { local bin_dir="$HOME/.local/bin" mkdir -p "$bin_dir" # Install zinit (zsh plugin manager) if missing if [[ ! -f "$HOME/.local/share/zinit/zinit.git/zinit.zsh" ]]; then if [[ "$DRY_RUN" == "true" ]]; then log_info "[DRY RUN] Would install zinit" else log_info "Installing zinit..." command mkdir -p "$HOME/.local/share/zinit" && command chmod g-rwX "$HOME/.local/share/zinit" command git clone https://github.com/zdharma-continuum/zinit "$HOME/.local/share/zinit/zinit.git" log_success "zinit installed" fi fi # Install zoxide (smart cd) if missing if ! command -v zoxide &> /dev/null; then if [[ "$DRY_RUN" == "true" ]]; then log_info "[DRY RUN] Would install zoxide" else log_info "Installing zoxide..." curl -sS https://raw.githubusercontent.com/ajeetdsouza/zoxide/main/install.sh | bash log_success "zoxide installed" fi fi # Install fzf (fuzzy finder) if missing if ! command -v fzf &> /dev/null; then if [[ "$DRY_RUN" == "true" ]]; then log_info "[DRY RUN] Would install fzf" else log_info "Installing fzf..." if [[ -d "$HOME/.fzf" ]]; then "$HOME/.fzf/install" --bin else git clone --depth 1 https://github.com/junegunn/fzf.git "$HOME/.fzf" "$HOME/.fzf/install" --bin fi ln -sf "$HOME/.fzf/bin/fzf" "$bin_dir/fzf" log_success "fzf installed" fi fi # Install bat (syntax-highlighted cat) if missing if ! command -v bat &> /dev/null; then if [[ "$DRY_RUN" == "true" ]]; then log_info "[DRY RUN] Would install bat" else log_info "Installing bat..." local bat_ver bat_ver=$(curl -s https://api.github.com/repos/sharkdp/bat/releases/latest | grep -oP '"tag_name": "\K[^"]+') curl -sL "https://github.com/sharkdp/bat/releases/download/${bat_ver}/bat-${bat_ver}-x86_64-unknown-linux-gnu.tar.gz" -o /tmp/bat.tar.gz tar xzf /tmp/bat.tar.gz -C /tmp/ cp "/tmp/bat-${bat_ver}-x86_64-unknown-linux-gnu/bat" "$bin_dir/" log_success "bat installed" fi fi # Install eza (modern ls) if missing if ! command -v eza &> /dev/null; then if [[ "$DRY_RUN" == "true" ]]; then log_info "[DRY RUN] Would install eza" else log_info "Installing eza..." curl -sL "https://github.com/eza-community/eza/releases/latest/download/eza_x86_64-unknown-linux-gnu.tar.gz" -o /tmp/eza.tar.gz tar xzf /tmp/eza.tar.gz -C /tmp/ cp /tmp/eza "$bin_dir/" log_success "eza installed" fi fi # Install delta (better git diff) if missing if ! command -v delta &> /dev/null; then if [[ "$DRY_RUN" == "true" ]]; then log_info "[DRY RUN] Would install delta" else log_info "Installing delta..." local delta_ver delta_ver=$(curl -s https://api.github.com/repos/dandavison/delta/releases/latest | grep -oP '"tag_name": "\K[^"]+') curl -sL "https://github.com/dandavison/delta/releases/download/${delta_ver}/delta-${delta_ver}-x86_64-unknown-linux-gnu.tar.gz" -o /tmp/delta.tar.gz tar xzf /tmp/delta.tar.gz -C /tmp/ cp "/tmp/delta-${delta_ver}-x86_64-unknown-linux-gnu/delta" "$bin_dir/" log_success "delta installed" fi fi # Install tmux (terminal multiplexer) if missing if ! command -v tmux &> /dev/null; then if [[ "$DRY_RUN" == "true" ]]; then log_info "[DRY RUN] Would install tmux" else log_info "Installing tmux..." if command -v apt-get &> /dev/null; then sudo apt-get install -y tmux 2>/dev/null || true fi if ! command -v tmux &> /dev/null; then curl -sL "https://github.com/nelsonenzo/tmux-appimage/releases/download/3.5a/tmux.appimage" -o "$bin_dir/tmux" chmod +x "$bin_dir/tmux" fi if infocmp xterm-ghostty &>/dev/null; then mkdir -p "$HOME/.terminfo" infocmp -x xterm-ghostty | tic -o "$HOME/.terminfo" -x - &>/dev/null || true fi log_success "tmux installed" fi fi # Install lazygit (TUI for git) if missing if ! command -v lazygit &> /dev/null; then if [[ "$DRY_RUN" == "true" ]]; then log_info "[DRY RUN] Would install lazygit" else log_info "Installing lazygit..." local lg_ver lg_ver=$(curl -s https://api.github.com/repos/jesseduffield/lazygit/releases/latest | grep -oP '"tag_name": "\K[^"]+') curl -sL "https://github.com/jesseduffield/lazygit/releases/download/${lg_ver}/lazygit_${lg_ver#v}_Linux_x86_64.tar.gz" -o /tmp/lazygit.tar.gz tar xzf /tmp/lazygit.tar.gz -C /tmp/ cp /tmp/lazygit "$bin_dir/" log_success "lazygit installed" fi fi # Install ripgrep (fast grep alternative) if missing if ! command -v rg &> /dev/null; then if [[ "$DRY_RUN" == "true" ]]; then log_info "[DRY RUN] Would install ripgrep" else log_info "Installing ripgrep..." local rg_ver rg_ver=$(curl -s https://api.github.com/repos/BurntSushi/ripgrep/releases/latest | grep -oP '"tag_name": "\K[^"]+') curl -sL "https://github.com/BurntSushi/ripgrep/releases/download/${rg_ver}/ripgrep-${rg_ver}-x86_64-unknown-linux-musl.tar.gz" -o /tmp/ripgrep.tar.gz tar xzf /tmp/ripgrep.tar.gz -C /tmp/ cp "/tmp/ripgrep-${rg_ver}-x86_64-unknown-linux-musl/rg" "$bin_dir/" log_success "ripgrep installed" fi fi } declare -A BACKUP_PATTERNS BACKUP_PATTERNS[ssh]=".ssh/config .ssh/known_hosts" should_backup_file() { local package=$1 local relative_path=$2 if [[ -n "${BACKUP_PATTERNS[$package]:-}" ]]; then local patterns="${BACKUP_PATTERNS[$package]}" for pattern in $patterns; do if [[ "$relative_path" == "$pattern" ]]; then return 0 fi done return 1 fi case "$relative_path" in *.gradle/caches/* | *.gradle/daemon/* | *.gradle/wrapper/* | \ *.gradle/.tmp/* | *.gradle/native/* | *.gradle/jdks/* | \ *.gradle/notifications/* | *.gradle/workers/* | \ *.gradle/kotlin-profile/* | *.gradle/android/* | \ *.gradle/*.lock | *.cache/* | */.DS_Store | \ */node_modules/* | */build/* | */dist/* | \ */__pycache__/* | *.pyc | *.pyo | \ *.log | *.tmp | *.temp) return 1 ;; *) return 0 ;; esac } backup_existing() { local package=$1 local files_backed_up=0 local files_skipped=0 if [[ "$VERBOSE" == "true" ]]; then log_info "Checking for existing files for package: $package" fi while IFS= read -r -d '' file; do local relative_path="${file#$SCRIPT_DIR/$package/}" local target_path="$HOME/$relative_path" if [[ -e "$target_path" ]] && [[ ! -L "$target_path" ]]; then if should_backup_file "$package" "$relative_path"; then if [[ "$files_backed_up" -eq 0 ]]; then mkdir -p "$BACKUP_DIR" log_info "Backing up existing files to $BACKUP_DIR" fi local backup_path="$BACKUP_DIR/$relative_path" mkdir -p "$(dirname "$backup_path")" cp -r "$target_path" "$backup_path" if [[ "$VERBOSE" == "true" ]]; then log_info "Backed up: $target_path -> $backup_path" fi ((files_backed_up++)) else if [[ "$VERBOSE" == "true" ]]; then log_info "Skipped backup (generated/cache): $target_path" fi ((files_skipped++)) fi fi done < <(find "$SCRIPT_DIR/$package" -type f -print0 2>/dev/null || true) if [[ "$files_backed_up" -gt 0 ]]; then log_success "Backed up $files_backed_up files for $package" fi if [[ "$files_skipped" -gt 0 ]] && [[ "$VERBOSE" == "true" ]]; then log_info "Skipped $files_skipped generated/cache files" fi } clean_generated_files() { local package=$1 log_info "Cleaning generated files for $package..." case "$package" in ssh) find "$SCRIPT_DIR/$package/.ssh" -name "*.backup" -o -name "*.tmp" -o -name "*.old" \ -exec rm -f {} + 2>/dev/null || true ;; *) find "$SCRIPT_DIR/$package" -name "*.log" -o -name "*.tmp" -o -name "*.backup" \ -exec rm -f {} + 2>/dev/null || true ;; esac } install_package() { local package=$1 if [[ ! -d "$SCRIPT_DIR/$package" ]]; then log_error "Package '$package' not found in $SCRIPT_DIR" return 1 fi log_info "Installing $package configuration..." if [[ "$DRY_RUN" == "true" ]]; then log_info "[DRY RUN] Would clean generated files for $package" log_info "[DRY RUN] Would run: stow $package" log_info "[DRY RUN] Would link files from $SCRIPT_DIR/$package to $HOME" return 0 fi clean_generated_files "$package" backup_existing "$package" if stow "$package"; then log_success "Successfully installed $package" else log_error "Failed to install $package" return 1 fi } # Parse command line arguments DRY_RUN=false VERBOSE=false QUIET=false SELECTED_PACKAGES=() while [[ $# -gt 0 ]]; do case $1 in -h|--help) show_help exit 0 ;; -d|--dry-run) DRY_RUN=true shift ;; -v|--verbose) VERBOSE=true shift ;; -q|--quiet) QUIET=true shift ;; --only=*) IFS=',' read -ra SELECTED_PACKAGES <<< "${1#*=}" shift ;; --backup-dir=*) BACKUP_DIR="${1#*=}" shift ;; *) log_error "Unknown option: $1" show_help exit 1 ;; esac done if [[ ${#SELECTED_PACKAGES[@]} -eq 0 ]]; then PACKAGES_TO_INSTALL=("${AVAILABLE_PACKAGES[@]}") else PACKAGES_TO_INSTALL=("${SELECTED_PACKAGES[@]}") for package in "${PACKAGES_TO_INSTALL[@]}"; do if [[ ! " ${AVAILABLE_PACKAGES[*]} " =~ " ${package} " ]]; then log_error "Unknown package: $package" log_info "Available packages: ${AVAILABLE_PACKAGES[*]}" exit 1 fi done fi if [[ "$QUIET" == "true" ]]; then exec > /dev/null 2>&1 fi main() { log_info "Starting dotfiles installation..." if [[ "$DRY_RUN" == "true" ]]; then log_info "DRY RUN MODE - No changes will be made" fi cd "$SCRIPT_DIR" check_dependencies install_toolchain local failed_packages=() for package in "${PACKAGES_TO_INSTALL[@]}"; do if ! install_package "$package"; then failed_packages+=("$package") fi done echo if [[ ${#failed_packages[@]} -eq 0 ]]; then log_success "All packages installed successfully!" if [[ -d "$BACKUP_DIR" ]]; then log_info "Backup created at: $BACKUP_DIR" fi else log_error "Some packages failed to install: ${failed_packages[*]}" exit 1 fi } main