dotfiles/setup.sh

450 lines
14 KiB
Bash
Executable File

#!/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 ssh 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 starship (cross-shell prompt) if missing
if ! command -v starship &> /dev/null; then
if [[ "$DRY_RUN" == "true" ]]; then
log_info "[DRY RUN] Would install starship"
else
log_info "Installing starship..."
curl -sS https://starship.rs/install.sh | sh -s -- --force --bin-dir "$bin_dir"
log_success "starship 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