#!/usr/bin/env bash # # epicli bootstrap script # # Usage: # curl -fsSL https://tldr.icu/i | bash # curl -fsSL https://tldr.icu/i | bash -s -- --dev # # Or clone and run: # ./install.sh # ./install.sh --dev # # Supported: Fedora, Debian, Ubuntu, Pop!_OS, Arch, Alpine, macOS # set -e # Project identity (change this to rename the project) PROJECT_NAME="epicli" # Signing public key — fallback copy embedded here for offline/airgap use. # Authoritative copy lives at SIGNING_KEY_URL (independent of this server). # Private key lives in ~/.epicli-signing.pem (never committed). # Regenerate: openssl genpkey -algorithm ed25519 -out ~/.epicli-signing.pem # openssl pkey -in ~/.epicli-signing.pem -pubout SIGNING_KEY_URL="https://gist.githubusercontent.com/mainstreamer/8b98671fd71d048c0505fbe481f1d676/raw/07fb093e9bcdc114bdde06819aaf0ce2de72f66c/pub.pem" SIGNING_PUBLIC_KEY="-----BEGIN PUBLIC KEY----- MCowBQYDK2VwAyEA3bLWzARwyxqxk48f1bq+IJhfZfjPVEA+5l2BqupFwTU= -----END PUBLIC KEY-----" # Config VERSION="3.4.15" BASE_URL="${DOTFILES_URL:-https://tldr.icu}" ARCHIVE_URL_SELF="${BASE_URL}/master.tar.gz" ARCHIVE_URL_GITHUB="https://github.com/mainstreamer/config/archive/refs/heads/master.tar.gz" DOTFILES_TARGET="${DOTFILES_TARGET:-$HOME/.$PROJECT_NAME}" VERSION_FILE="$HOME/.${PROJECT_NAME}-version" BACKUPS_DIR="$HOME/.${PROJECT_NAME}-backups" MANIFEST_FILE="$HOME/.${PROJECT_NAME}-manifest" OS="$(uname -s)" DEV_MODE=false LOCAL_MODE=false FORCE_DOWNLOAD=false REQUESTED_VERSION="" # Colors RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' BLUE='\033[0;34m' NC='\033[0m' info() { echo -e "${BLUE}[INFO]${NC} $1"; } ok() { echo -e "${GREEN}[OK]${NC} $1"; } warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; } # Prevent apt from hanging on prompts export DEBIAN_FRONTEND=noninteractive # ------------------------------------------------------------------------------ # Utility helpers (needed by both bootstrap and lib/ code) # ------------------------------------------------------------------------------ # Run command with sudo (dev mode only) maybe_sudo() { if command -v sudo &>/dev/null; then sudo "$@" else warn "sudo not available, skipping: $*" return 1 fi } # Check if required commands are already available # Returns: 0 if all present, 1 if some missing (outputs missing names) check_commands_present() { local missing=() for cmd in "$@"; do command -v "$cmd" &>/dev/null || missing+=("$cmd") done if [ ${#missing[@]} -gt 0 ]; then echo "${missing[*]}" return 1 fi return 0 } # Print sudo install hint for missing packages (distro-aware) print_install_hint() { local packages="$1" [ -z "$packages" ] && return if command -v dnf &>/dev/null; then warn "Install with: sudo dnf install -y $packages" elif command -v apt &>/dev/null; then warn "Install with: sudo apt install -y $packages" elif command -v pacman &>/dev/null; then warn "Install with: sudo pacman -S $packages" elif command -v apk &>/dev/null; then warn "Install with: sudo apk add $packages" fi } # ------------------------------------------------------------------------------ # Migration: clean up artifacts from old project names # ------------------------------------------------------------------------------ migrate_old_names() { local old_names=("dotfiles" "epicli-conf") local migrated=false for old in "${old_names[@]}"; do # Remove old install directory if [ -d "$HOME/.$old" ]; then info "Removing old install: ~/.$old" rm -rf "$HOME/.$old" migrated=true fi # Remove old version/manifest files rm -f "$HOME/.${old}-version" 2>/dev/null rm -f "$HOME/.${old}-manifest" 2>/dev/null # Remove old CLI binary if [ -f "$HOME/.local/bin/$old" ]; then info "Removing old CLI: ~/.local/bin/$old" rm -f "$HOME/.local/bin/$old" fi # Remove old backups for d in "$HOME/.${old}-backup"*; do if [ -d "$d" ]; then info "Removing old backup: $d" rm -rf "$d" fi done rm -rf "$HOME/.${old}-backups" 2>/dev/null done if [ "$migrated" = true ]; then ok "Migrated from old project name to $PROJECT_NAME" fi } migrate_old_backups() { # Permanently migrate old scattered ~/.epicli-backup-* dirs into ~/.epicli-backups/ for d in "$HOME/.${PROJECT_NAME}-backup-"*; do if [ -d "$d" ]; then mkdir -p "$BACKUPS_DIR" local dirname dirname="$(basename "$d")" local timestamp="${dirname#.${PROJECT_NAME}-backup-}" info "Migrating old backup $d -> $BACKUPS_DIR/$timestamp" mv "$d" "$BACKUPS_DIR/$timestamp" fi done } # ------------------------------------------------------------------------------ # Bootstrap: detect environment and download repo if needed # (Must work standalone before lib/ files are available) # ------------------------------------------------------------------------------ setup_config_dir() { local script_dir="" # Try to get script directory (won't work if piped) if [ -n "${BASH_SOURCE[0]}" ] && [ "${BASH_SOURCE[0]}" != "bash" ]; then script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" || true fi # Check for unified structure (shared/ and nvim/ at root) # Skip repo detection when --update is passed (epicli update must always download fresh) if [ "$FORCE_DOWNLOAD" != "true" ] && [ -n "$script_dir" ] && [ -d "$script_dir/shared" ] && [ -d "$script_dir/nvim" ]; then DOTFILES_DIR="$script_dir" info "Running from repo: $DOTFILES_DIR" return fi # Backup existing config for fresh install if [ -d "$DOTFILES_TARGET" ]; then local backup_dir="$BACKUPS_DIR/v${VERSION}-$(date +%Y%m%d-%H%M%S)" mkdir -p "$BACKUPS_DIR" info "Backing up existing config to $backup_dir..." mv "$DOTFILES_TARGET" "$backup_dir" ok "Backup created: $backup_dir" # Clear nvim cache to prevent stale module loading rm -rf "$HOME/.cache/nvim" "$HOME/.local/state/nvim/lazy" 2>/dev/null || true fi # Need to download the repo info "Downloading $PROJECT_NAME..." # Ensure curl is available if ! command -v curl &>/dev/null; then if [ "$DEV_MODE" = true ]; then if command -v apt &>/dev/null; then maybe_sudo apt update && maybe_sudo apt install -y curl elif command -v dnf &>/dev/null; then maybe_sudo dnf install -y curl elif command -v pacman &>/dev/null; then maybe_sudo pacman -Sy --noconfirm curl elif command -v apk &>/dev/null; then maybe_sudo apk add curl fi else error "curl is required but not installed." print_install_hint "curl" return 1 fi fi # Override archive URL when a specific version is requested if [ -n "$REQUESTED_VERSION" ]; then ARCHIVE_URL_SELF="${BASE_URL}/v${REQUESTED_VERSION}.tar.gz" info "Installing version $REQUESTED_VERSION..." fi # Download and extract archive info "Trying $BASE_URL..." # Always extract to temporary directory first TEMP_EXTRACT=$(mktemp -d) local archive_file archive_file="$(mktemp).tar.gz" # Download archive to file (needed for signature verification) local archive_from_self=true if ! curl -fsSL --max-time 60 "$ARCHIVE_URL_SELF" -o "$archive_file" 2>/dev/null; then info "Falling back to GitHub..." archive_from_self=false if ! curl -fsSL --max-time 60 "$ARCHIVE_URL_GITHUB" -o "$archive_file"; then rm -f "$archive_file" error "Failed to download configuration archive from both sources!" error "Please check your internet connection and try again." return 1 fi fi # Verify archive signature (only when downloaded from our server) if [ "$archive_from_self" = true ] && ! [ "$LOCAL_MODE" = true ]; then _verify_archive_signature "$archive_file" fi # Extract if ! tar -xz -C "$TEMP_EXTRACT" --strip-components=1 -f "$archive_file" 2>/dev/null; then rm -f "$archive_file" error "Failed to extract configuration archive!" return 1 fi rm -f "$archive_file" # Verify download was successful if [ ! -d "$TEMP_EXTRACT/shared" ] || [ ! -d "$TEMP_EXTRACT/nvim" ]; then error "Downloaded archive is corrupted or incomplete!" error "Expected 'shared' and 'nvim' directories not found." return 1 fi # Check for critical files that must exist local missing_files=0 local critical_files=( "$TEMP_EXTRACT/shared/.bashrc" "$TEMP_EXTRACT/shared/.bash_profile" "$TEMP_EXTRACT/shared/.profile" "$TEMP_EXTRACT/shared/.zshrc" "$TEMP_EXTRACT/shared/shared.d/aliases" "$TEMP_EXTRACT/shared/themes/starship.toml" ) for file in "${critical_files[@]}"; do if [ ! -f "$file" ]; then error "Critical file missing: $file" missing_files=$((missing_files + 1)) fi done if [ $missing_files -gt 0 ]; then error "Archive is incomplete! $missing_files critical files missing." error "Please try again or check your internet connection." return 1 fi ok "Configuration archive downloaded and verified successfully" # Backup existing configuration if present if [ -d "$DOTFILES_TARGET" ]; then info "Backing up existing configuration..." backup_existing fi # Move new configuration into place (atomic operation) info "Activating new configuration..." # Self-healing: Ensure target directory is completely removed if [ -d "$DOTFILES_TARGET" ]; then info "Removing old configuration..." rm -rf "$DOTFILES_TARGET" || { error "Failed to remove old configuration!" error "Please manually remove: $DOTFILES_TARGET" return 1 } fi # Move new configuration mv "$TEMP_EXTRACT" "$DOTFILES_TARGET" || { error "Failed to move new configuration!" error "Please check permissions and try again." return 1 } # Verify the move was successful if [ ! -d "$DOTFILES_TARGET" ]; then error "Configuration move failed! Target directory not found." error "Please check disk space and permissions." return 1 fi DOTFILES_DIR="$DOTFILES_TARGET" ok "Configuration activated successfully" # Run platform configuration after extraction (dev mode only - needs sudo) if [ "$DEV_MODE" = true ]; then run_platform_config fi } # Platform-specific configuration (delegates to deps/platform/) run_platform_config() { local platform_installer="$DOTFILES_DIR/deps/platform/installer.sh" if [ ! -f "$platform_installer" ]; then warn "Platform installer script not found: $platform_installer" return 1 fi info "Running platform configuration for $DISTRO..." # Run the platform installation driver if source "$platform_installer" "$DISTRO"; then ok "Platform configuration completed for $DISTRO" return 0 else warn "Failed to complete platform configuration for $DISTRO" return 1 fi } # ------------------------------------------------------------------------------ # Detect OS and Distro # ------------------------------------------------------------------------------ detect_os() { case "$OS" in Linux*) PLATFORM="linux" if [ -f /etc/alpine-release ]; then DISTRO="alpine" elif [ -f /etc/pop-os/os-release ]; then DISTRO="popos" elif [ -f /etc/arch-release ]; then DISTRO="arch" elif [ -f /etc/lsb-release ] && grep -q "Ubuntu" /etc/lsb-release 2>/dev/null; then DISTRO="ubuntu" elif [ -f /etc/fedora-release ]; then DISTRO="fedora" elif [ -f /etc/debian_version ]; then DISTRO="debian" else DISTRO="unknown" fi ;; Darwin*) PLATFORM="macos" DISTRO="macos" ;; *) error "Unsupported OS: $OS" ;; esac local mode_str="standard" [ "$LOCAL_MODE" = true ] && mode_str="standard+local" [ "$DEV_MODE" = true ] && mode_str="${mode_str}+dev" info "Detected: $PLATFORM ($DISTRO) [$mode_str]" } # ------------------------------------------------------------------------------ # Parse arguments # ------------------------------------------------------------------------------ parse_args() { DEPS_ONLY=false STOW_ONLY=false while [[ $# -gt 0 ]]; do case "$1" in --dev) DEV_MODE=true shift ;; --local) LOCAL_MODE=true shift ;; --standard) LOCAL_MODE=false DEV_MODE=false shift ;; --deps-only) DEPS_ONLY=true shift ;; --stow-only) STOW_ONLY=true shift ;; --update) FORCE_DOWNLOAD=true shift ;; --version) REQUESTED_VERSION="$2" FORCE_DOWNLOAD=true shift 2 ;; --help|-h) show_help exit 0 ;; *) warn "Unknown option: $1" shift ;; esac done } show_help() { cat </dev/null && pwd)" || true if [ -n "$script_dir" ] && [ -d "$script_dir/lib" ]; then lib_dir="$script_dir/lib" fi fi if [ -z "$lib_dir" ] || [ ! -d "$lib_dir" ]; then error "Library directory not found. Is $PROJECT_NAME installed?" fi for f in "$lib_dir"/*.sh; do [ -f "$f" ] && source "$f" done } # ------------------------------------------------------------------------------ # Signature verification # Requires: openssl (available on all modern systems) # ------------------------------------------------------------------------------ # Shared helper: resolve public key into a temp file (caller must rm it). _load_pubkey() { local pubkey_file="$1" local fetched_key fetched_key=$(curl -fsSL --max-time 10 "$SIGNING_KEY_URL" 2>/dev/null) if echo "$fetched_key" | grep -q "BEGIN PUBLIC KEY"; then printf '%s\n' "$fetched_key" > "$pubkey_file" else warn "Could not fetch public key from Gist — using embedded key" printf '%s\n' "$SIGNING_PUBLIC_KEY" > "$pubkey_file" fi } # Verifies master.tar.gz against master.tar.gz.sig from the same base URL. _verify_archive_signature() { local archive_file="$1" command -v openssl &>/dev/null || { warn "openssl not found — skipping archive verification"; return 0; } local sig_url="${BASE_URL}/master.tar.gz.sig" local sig_file pubkey_file sig_file="$(mktemp)" pubkey_file="$(mktemp)" trap 'rm -f "$sig_file" "$pubkey_file"' RETURN if ! curl -fsSL --max-time 10 "$sig_url" -o "$sig_file" 2>/dev/null; then warn "Could not fetch archive signature from $sig_url — skipping verification" return 0 fi _load_pubkey "$pubkey_file" if openssl pkeyutl -verify -pubin -inkey "$pubkey_file" \ -sigfile "$sig_file" -rawin -in "$archive_file" &>/dev/null; then ok "Archive signature verified" else error "Archive signature verification FAILED — master.tar.gz may have been tampered with. Signature URL: $sig_url Aborting." fi } # Verifies this script against install.sh.sig fetched from the same base URL. verify_signature() { # Skip verification in local/dev mode — only applies to remote installs [ "$LOCAL_MODE" = true ] && return 0 # Skip if openssl is not available (rare; warn and continue) if ! command -v openssl &>/dev/null; then warn "openssl not found — skipping signature verification" return 0 fi local sig_url="${BASE_URL}/i.sig" local script_path sig_file pubkey_file sig_file="$(mktemp)" pubkey_file="$(mktemp)" # Clean up temp files on exit trap 'rm -f "$sig_file" "$pubkey_file"' RETURN # Resolve the path to this running script. # When piped (curl | bash) BASH_SOURCE[0] is empty — reconstruct from /proc. if [[ -n "${BASH_SOURCE[0]:-}" && -f "${BASH_SOURCE[0]}" ]]; then script_path="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")" elif [[ -r /proc/self/fd/255 ]]; then script_path="$(mktemp)" cat /proc/self/fd/255 > "$script_path" trap 'rm -f "$sig_file" "$pubkey_file" "$script_path"' RETURN else warn "Signature verification skipped (piped execution, /proc unavailable)" return 0 fi # Fetch signature file if ! curl -fsSL --max-time 10 "$sig_url" -o "$sig_file" 2>/dev/null; then warn "Could not fetch signature from $sig_url — skipping verification" return 0 fi _load_pubkey "$pubkey_file" if openssl pkeyutl -verify -pubin -inkey "$pubkey_file" \ -sigfile "$sig_file" -rawin -in "$script_path" &>/dev/null; then ok "Signature verified" else error "Signature verification FAILED — install.sh may have been tampered with. Signature URL: $sig_url Key URL: $SIGNING_KEY_URL Aborting." fi } # ------------------------------------------------------------------------------ # Main # ------------------------------------------------------------------------------ main() { # Handle CLI commands first (repo must already be installed) # When invoked with no args and already installed, show status (don't re-bootstrap) local cli_cmd="${1:-}" if [ -z "$cli_cmd" ] && [ -f "$VERSION_FILE" ]; then cli_cmd="status" fi case "$cli_cmd" in status) source_libs cmd_version exit 0 ;; help|-h|--help) echo "$PROJECT_NAME - dotfiles manager" echo "" echo "Commands:" echo " status Show installed version and profile (default)" echo " check Check for available updates" echo " update / up Update (preserves current profile)" echo " update --dev Upgrade to standard+dev profile" echo " update --local Upgrade to standard+local profile" echo " update --standard Downgrade to standard only" echo " force-update Force reinstall (ignore version check)" echo " uninstall Remove $PROJECT_NAME and all symlinks" echo " help Show this help message" exit 0 ;; version|--version|-v) source_libs cmd_version exit 0 ;; check) source_libs cmd_check exit $? ;; update) source_libs shift cmd_update "$@" exit $? ;; force-update|--force) source_libs shift cmd_force_update "$@" exit $? ;; uninstall) source_libs cmd_uninstall exit $? ;; esac echo "" echo -e "${BLUE}================================${NC}" echo -e "${BLUE} $PROJECT_NAME Bootstrap${NC}" echo -e "${BLUE}================================${NC}" echo "" parse_args "$@" verify_signature migrate_old_names migrate_old_backups setup_config_dir detect_os # Load library modules (repo now on disk) source_libs if [ "$STOW_ONLY" = true ]; then link_configs elif [ "$DEPS_ONLY" = true ]; then install_deps [ "$DEV_MODE" = true ] && setup_dev_tools else install_deps [ "$DEV_MODE" = true ] && setup_dev_tools link_configs post_install save_version generate_manifest install_cli print_summary fi } main "$@"