Skip to content

Instantly share code, notes, and snippets.

@anschmieg
Last active January 13, 2026 08:04
Show Gist options
  • Select an option

  • Save anschmieg/59f8bca8527c7745aa3781f49c8bb313 to your computer and use it in GitHub Desktop.

Select an option

Save anschmieg/59f8bca8527c7745aa3781f49c8bb313 to your computer and use it in GitHub Desktop.
πŸš€ One-Command Dotfiles Deployment Script
#!/bin/bash
# πŸš€ One-Command Dotfiles Deployment Script
#
# Quick deployment: bash <(curl -sfL https://nothing.pink/zsh)
#
# (Note: 'curl | bash' works for automated/apply-only runs, but breaks interactive prompts)
# bash deploy.sh --apply-only
# Or set the environment: APPLY_ONLY=1 bash deploy.sh
#
# What this script does:
# 1. Installs chezmoi if needed
# 2. Helps you set up your age encryption key
# 3. Deploys your complete dotfiles setup
# 4. Runs all automated configuration scripts
# 5. Validates the installation (37 checks)
#
# Prerequisites: Your age encryption key from ~/.config/age/chezmoi.txt
set -euo pipefail
# --- Safety Check: Detect Pipe usage vs Inteactivity ---
if [[ ! -t 0 ]] && [[ -z "${APPLY_ONLY:-}" ]] && [[ -z "${CHEZMOI_AGE_KEY:-}" ]]; then
# If stdin is not a TTY (likely curl | bash), not in apply-only mode, and no key provided:
# We might need interactivity, which fails in pipe mode (borked input).
# Check if /dev/tty is available to warn the user.
if [[ -e /dev/tty ]]; then
echo "⚠️ Launched via pipe (curl | bash). Interactive prompts WILL FAIL."
echo "πŸ’‘ Please use this command instead:"
echo " bash <(curl -sfL https://gist.github.com/anschmieg/59f8bca8527c7745aa3781f49c8bb313/raw/deploy.sh)"
echo ""
echo " (Or provide inputs via environment variables to run non-interactively)"
echo " Sleeping 5s so you can abort..."
sleep 5
fi
fi
# --- Section status tracking ---
declare -A SECTION_STATUS
declare -A SECTION_MSGS
SECTION_ORDER=(
"dependencies"
"pkgx"
"github"
"age"
"pyenv"
"chezmoi"
"antidote"
"validation"
)
# --- Styled summary function ---
print_summary() {
local color_reset="\033[0m"
local color_green="\033[1;32m"
local color_red="\033[1;31m"
local color_yellow="\033[1;33m"
local color_blue="\033[1;34m"
local sep="━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
local overall_status="success"
local summary_msgs=""
local fail_msgs=""
local warn_msgs=""
echo -e "\n$sep"
echo -e "πŸ“ ${color_blue}DEPLOYMENT SUMMARY${color_reset}"
echo -e "$sep"
for section in "${SECTION_ORDER[@]}"; do
local status="${SECTION_STATUS[$section]:-skipped}"
local msg="${SECTION_MSGS[$section]:-}"
local icon=""
local color="$color_reset"
if [[ "$status" == "success" ]]; then
icon="βœ…"
color="$color_green"
elif [[ "$status" == "fail" ]]; then
icon="❌"
color="$color_red"
overall_status="fail"
fail_msgs+=" $icon ${section^}: $msg\n"
elif [[ "$status" == "warning" ]]; then
icon="⚠️"
color="$color_yellow"
[[ "$overall_status" != "fail" ]] && overall_status="warning"
warn_msgs+=" $icon ${section^}: $msg\n"
else
icon="βž–"
color="$color_blue"
fi
echo -e "$icon ${color}${section^}${color_reset}: $msg"
done
echo -e "$sep"
if [[ "$overall_status" == "success" ]]; then
echo -e "πŸŽ‰ ${color_green}DEPLOYMENT SUCCESSFUL!${color_reset} πŸŽ‰"
elif [[ "$overall_status" == "warning" ]]; then
echo -e "⚠️ ${color_yellow}DEPLOYMENT COMPLETED WITH WARNINGS${color_reset} ⚠️"
else
echo -e "❌ ${color_red}DEPLOYMENT FAILED!${color_reset} ❌"
fi
if [[ -n "$fail_msgs" ]]; then
echo -e "\n${color_red}Errors:${color_reset}\n$fail_msgs"
fi
if [[ -n "$warn_msgs" ]]; then
echo -e "\n${color_yellow}Warnings:${color_reset}\n$warn_msgs"
fi
echo -e "$sep\n"
[[ "$overall_status" == "success" ]] && exit 0 || exit 1
}
# --- Section status tracking ---
declare -A SECTION_STATUS
declare -A SECTION_MSGS
SECTION_ORDER=(
"dependencies"
"pkgx"
"github"
"age"
"pyenv"
"chezmoi"
"antidote"
"validation"
)
# Ensure ~/.local/bin is in PATH for this script and all child processes
export PATH="$HOME/.pkgx:$HOME/.local/bin:$PATH"
# Error handling function
handle_error() {
local exit_code=$?
echo "❌ Error occurred at line ${1:-"unknown"} with exit code $exit_code"
# Check if script is being sourced
if [[ "${BASH_SOURCE[0]}" != "${0}" ]]; then
return "$exit_code"
else
exit "$exit_code"
fi
}
# --- Legacy Protection Cleanup ---
# If upgrading from a version with immutable file protection, we need to clear flags once.
clear_legacy_protection() {
local files=(~/.gitconfig ~/.zshrc ~/.bashrc ~/.profile ~/.shellrc ~/.config/fish/config.fish)
local found_immutable=0
for f in "${files[@]}"; do
[ -f "$f" ] || continue
if command -v lsattr >/dev/null 2>&1; then
if lsattr "$f" 2>/dev/null | grep -q 'i'; then
found_immutable=1
break
fi
elif command -v ls >/dev/null 2>&1 && [[ "$OSTYPE" == "darwin"* ]]; then
if ls -lO "$f" 2>/dev/null | grep -q 'uchg'; then
found_immutable=1
break
fi
fi
done
if [ "$found_immutable" -eq 1 ]; then
echo "πŸ”“ Detecting legacy file protection. Clearing immutable flags..."
for f in "${files[@]}"; do
[ -f "$f" ] || continue
# Use sudo for attributes if possible, but don't fail if sudo isn't available
if command -v chattr >/dev/null 2>&1; then
sudo chattr -i "$f" 2>/dev/null || chattr -i "$f" 2>/dev/null || true
elif command -v chflags >/dev/null 2>&1; then
sudo chflags nouchg "$f" 2>/dev/null || chflags nouchg "$f" 2>/dev/null || true
fi
sudo chmod +w "$f" 2>/dev/null || chmod +w "$f" 2>/dev/null || true
done
echo "βœ… Legacy protection cleared."
fi
}
# Set up error trap with line number tracking
trap 'handle_error $LINENO' ERR
# Ensure script doesn't continue on errors in pipes
set -o pipefail
# --- Dependency Bootstrapping ---
REQUIRED_CMDS=(curl git zsh fish jq age python3)
MISSING_CMDS=()
# Detect OS and package manager
if command -v apt &>/dev/null; then
PKG_INSTALL="apt-get install -y"
APT_UPDATE_NEEDED=1
elif command -v dnf &>/dev/null; then
PKG_INSTALL="dnf install -y"
APT_UPDATE_NEEDED=0
elif command -v yum &>/dev/null; then
PKG_INSTALL="yum install -y"
APT_UPDATE_NEEDED=0
elif command -v apk &>/dev/null; then
PKG_INSTALL="apk add --no-cache"
APT_UPDATE_NEEDED=0
elif command -v brew &>/dev/null; then
PKG_INSTALL="brew install"
APT_UPDATE_NEEDED=0
else
PKG_INSTALL=""
APT_UPDATE_NEEDED=0
fi
# Function to install missing dependencies
install_missing_dependencies() {
local missing=("$@")
echo "πŸ” Missing dependencies: ${missing[*]}"
if [ -n "$PKG_INSTALL" ]; then
# Detect if we need sudo (not running as root and sudo is available)
USE_SUDO=""
if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
USE_SUDO="sudo"
fi
if [ "${APT_UPDATE_NEEDED:-0}" -eq 1 ]; then
echo "πŸ”„ Running apt-get update..."
$USE_SUDO apt-get update
APT_UPDATE_NEEDED=0
fi
for pkg in "${missing[@]}"; do
echo "πŸ“₯ Installing $pkg..."
if ! $USE_SUDO $PKG_INSTALL $pkg; then
echo "❌ Failed to install $pkg. Please install it manually."
exit 1
fi
done
else
echo "❌ No supported package manager found. Please install: ${missing[*]} manually."
exit 1
fi
}
# Check for missing commands (except pkgx)
for cmd in "${REQUIRED_CMDS[@]}"; do
if ! command -v "$cmd" &>/dev/null; then
MISSING_CMDS+=("$cmd")
fi
done
# Check and install missing dependencies
if [ "${#MISSING_CMDS[@]}" -gt 0 ]; then
if install_missing_dependencies "${MISSING_CMDS[@]}"; then
SECTION_STATUS[dependencies]="success"
SECTION_MSGS[dependencies]="All dependencies installed."
else
SECTION_STATUS[dependencies]="fail"
SECTION_MSGS[dependencies]="Failed to install dependencies."
fi
else
echo "βœ… All required dependencies are installed."
SECTION_STATUS[dependencies]="success"
SECTION_MSGS[dependencies]="All dependencies already present."
fi
# Ensure locales are generated on Linux to fix SSH warnings
if [ -f /etc/debian_version ] && command -v locale-gen >/dev/null; then
# Detect sudo
_SUDO=""
if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
_SUDO="sudo"
fi
if ! locale -a 2>/dev/null | grep -qi "en_US.utf8"; then
echo "🌍 Generating en_US.UTF-8 locale..."
$_SUDO locale-gen en_US.UTF-8 || true
$_SUDO update-locale LANG=en_US.UTF-8 || true
fi
fi
# Always install pkgx using the official installer if missing
if ! command -v pkgx &>/dev/null; then
echo "πŸ“₯ Installing pkgx using the official installer..."
if curl -fsSL https://pkgx.sh | bash; then
echo "βœ… pkgx installed successfully"
SECTION_STATUS[pkgx]="success"
SECTION_MSGS[pkgx]="pkgx installed successfully."
elif [ ! -x "$(command -v pkgx 2>/dev/null)" ]; then
# Fallback: find first writable dir in PATH
for dir in $(echo "$PATH" | tr ':' ' '); do
if [ -w "$dir" ]; then
echo "πŸ“₯ Retrying pkgx install to $dir ..."
mkdir -p "$dir"
if curl -fsSL https://pkgx.sh | bash -s -- -b "$dir"; then
export PATH="$dir:$PATH"
echo "βœ… pkgx installed successfully in $dir"
SECTION_STATUS[pkgx]="success"
SECTION_MSGS[pkgx]="pkgx installed successfully in $dir."
break
fi
fi
done
# If still not found
if ! command -v pkgx >/dev/null 2>&1; then
echo "❌ Failed to install pkgx! Please install it manually: curl -fsSL https://pkgx.sh | bash"
SECTION_STATUS[pkgx]="fail"
SECTION_MSGS[pkgx]="Failed to install pkgx."
print_summary
fi
fi
else
echo "βœ… pkgx is already installed."
SECTION_STATUS[pkgx]="success"
SECTION_MSGS[pkgx]="pkgx already installed."
fi
# --- Install essential tools via pkgx if missing ---
# --- Install essential tools via pkgx if missing ---
for tool in git zsh jq age python3; do
if ! command -v "$tool" >/dev/null 2>&1; then
echo "πŸ“¦ Installing $tool with pkgx..."
pkgx "$tool" --version >/dev/null 2>&1 || true
fi
done
# --- GitHub CLI Authentication Setup (before any git operations) ---
# Cleanup broken symlinks for critical files (e.g. .gitconfig) to prevent git config failures
if [ -L "$HOME/.gitconfig" ] && [ ! -e "$HOME/.gitconfig" ]; then
echo "🧹 Removing broken .gitconfig symlink..."
rm "$HOME/.gitconfig"
fi
# Ensure gh CLI is available everywhere (shim, not just alias)
if ! command -v gh >/dev/null 2>&1; then
if command -v pkgm >/dev/null 2>&1; then
pkgm shim gh || echo "⚠️ Failed to shim gh (pkgm)"
echo "ℹ️ Created persistent gh shim with pkgm."
elif command -v pkgx >/dev/null 2>&1; then
pkgx pkgm shim gh || echo "⚠️ Failed to shim gh (pkgx)"
echo "ℹ️ Created persistent gh shim with pkgx pkgm."
fi
fi
# Authenticate with GitHub CLI and set up git credential helper
if command -v gh >/dev/null 2>&1; then
# Clear legacy protection if we are about to touch git config
clear_legacy_protection
if [ -z "${APPLY_ONLY:-}" ] || [ "${APPLY_ONLY}" = "0" ]; then
if ! gh auth status --hostname github.com 2>&1 | grep -q 'Logged in to github.com'; then
echo "πŸ”‘ Launching GitHub CLI authentication flow..."
if ! gh auth login; then
echo "❌ GitHub authentication failed. You will be prompted for username/password on git operations."
echo "For best results, authenticate with GitHub CLI before running this script."
SECTION_STATUS[github]="warning"
SECTION_MSGS[github]="GitHub CLI authentication failed. You may be prompted for credentials."
fi
fi
fi
# Always try to setup git if gh is available, but skip error if already configured
if ! git config --global credential.helper | grep -q 'gh'; then
echo "πŸ”§ Configuring git to use gh as credential helper..."
if ! gh auth setup-git; then
echo "⚠️ Failed to set up git credential helper with gh. You may be prompted for credentials."
SECTION_STATUS[github]="warning"
SECTION_MSGS[github]="Failed to set up git credential helper with gh."
fi
fi
if [[ -z "${SECTION_STATUS[github]:-}" ]]; then
SECTION_STATUS[github]="success"
SECTION_MSGS[github]="GitHub CLI authenticated and git credential helper configured."
fi
else
echo "❌ GitHub CLI (gh) is still not available after shimming. Please ensure ~/.local/bin is in your PATH and try again."
SECTION_STATUS[github]="fail"
SECTION_MSGS[github]="GitHub CLI not available after shimming."
fi
# --- Deployment mode selection ---
DEPLOY_MODE=""
CUSTOM_DATA_ARGS=()
EXCLUDE_ARGS=()
APPLY_ONLY=""
# Parse --mode argument or prompt interactively
for arg in "$@"; do
case $arg in
--mode=*) DEPLOY_MODE="${arg#*=}" ;;
--mode) shift; DEPLOY_MODE="$1" ;;
--apply-only) APPLY_ONLY="1" ;;
--custom-*) # For custom mode, collect feature flags as --custom-foo=bar
CUSTOM_DATA_ARGS+=("${arg#--}")
;;
esac
done
if [ -z "$DEPLOY_MODE" ]; then
printf "\nChoose deployment mode:\n"
echo " 1) Full - Everything (personal + work, all plugins, all features)"
echo " 2) Server - No SSH keys, no heavy dependencies, but most config"
echo " 3) Custom - Choose each feature interactively"
if { [ -n "${APPLY_ONLY:-}" ] && [ "${APPLY_ONLY}" != "0" ]; } || { [ -n "${NONINTERACTIVE:-}" ] && [ "${NONINTERACTIVE}" != "0" ]; }; then
mode_choice=1
else
read -p "Enter choice [1-3, default 1]: " mode_choice
fi
case "$mode_choice" in
2) DEPLOY_MODE="server" ;;
3) DEPLOY_MODE="custom" ;;
*) DEPLOY_MODE="full" ;;
esac
fi
# Exclusions for modes are now handled via .chezmoiexclude.tmpl
if [ "$DEPLOY_MODE" = "server" ]; then
echo "πŸ”’ Server mode: SSH keys will be excluded via .chezmoiexclude.tmpl"
fi
printf "\nπŸ› οΈ Deployment mode: %s\n" "$DEPLOY_MODE"
if [ "$DEPLOY_MODE" = "custom" ]; then
# For each major feature, prompt user and build --data args for chezmoi
printf "\nCustom mode: Select which features to enable. (y/n, default y)\n"
read -p "Include personal SSH keys? [Y/n]: " ans; [[ "$ans" =~ ^[Nn]$ ]] && CUSTOM_DATA_ARGS+=("custom_include_personal_ssh=false") || CUSTOM_DATA_ARGS+=("custom_include_personal_ssh=true")
read -p "Include work SSH keys? [Y/n]: " ans; [[ "$ans" =~ ^[Nn]$ ]] && CUSTOM_DATA_ARGS+=("custom_include_work_ssh=false") || CUSTOM_DATA_ARGS+=("custom_include_work_ssh=true")
read -p "Include custom prompt (Powerlevel10k)? [Y/n]: " ans; [[ "$ans" =~ ^[Nn]$ ]] && CUSTOM_DATA_ARGS+=("custom_include_prompt=false") || CUSTOM_DATA_ARGS+=("custom_include_prompt=true")
read -p "Include all plugins? [Y/n]: " ans; [[ "$ans" =~ ^[Nn]$ ]] && CUSTOM_DATA_ARGS+=("custom_include_all_plugins=false") || CUSTOM_DATA_ARGS+=("custom_include_all_plugins=true")
read -p "Include basic plugins? [Y/n]: " ans; [[ "$ans" =~ ^[Nn]$ ]] && CUSTOM_DATA_ARGS+=("custom_include_basic_plugins=false") || CUSTOM_DATA_ARGS+=("custom_include_basic_plugins=true")
read -p "Include all languages/managers? [Y/n]: " ans; [[ "$ans" =~ ^[Nn]$ ]] && CUSTOM_DATA_ARGS+=("custom_include_all_languages=false") || CUSTOM_DATA_ARGS+=("custom_include_all_languages=true")
read -p "Include LLM/GPT wrapper? [Y/n]: " ans; [[ "$ans" =~ ^[Nn]$ ]] && CUSTOM_DATA_ARGS+=("custom_include_llm_wrapper=false") || CUSTOM_DATA_ARGS+=("custom_include_llm_wrapper=true")
read -p "Include all aliases? [Y/n]: " ans; [[ "$ans" =~ ^[Nn]$ ]] && CUSTOM_DATA_ARGS+=("custom_include_all_aliases=false") || CUSTOM_DATA_ARGS+=("custom_include_all_aliases=true")
fi
echo "οΏ½πŸš€ Starting automated dotfiles deployment..."
echo "πŸ“ Repository: https://github.com/anschmieg/dotfiles.git"
echo ""
# --- Ensure pkgx/pkgm has at least one package installed for validation ---
if command -v pkgx >/dev/null 2>&1; then
if ! pkgx pkgm list | grep -q fastfetch; then
echo "πŸ“¦ Installing fastfetch for pkgx/pkgm validation..."
pkgx pkgm install fastfetch || true
fi
fi
# Ensure ~/.local/bin is in PATH first
export PATH="$HOME/.local/bin:$PATH"
# --- Auto-configure git user from GitHub CLI if possible ---
if command -v gh &>/dev/null; then
GIT_EMAIL=$(gh api user/emails --jq 'map(select(.primary == true and .verified == true)) | .[0].email' 2>/dev/null || true)
GIT_NAME=$(gh api user --jq '.name' 2>/dev/null || true)
GIT_LOGIN=$(gh api user --jq '.login' 2>/dev/null || true)
GIT_ID=$(gh api user --jq '.id' 2>/dev/null || true)
# Always use GitHub username and noreply email format
if [ -n "$GIT_LOGIN" ] && [ -n "$GIT_ID" ]; then
GIT_NAME="$GIT_LOGIN"
GIT_EMAIL="${GIT_ID}+${GIT_LOGIN}@users.noreply.github.com"
echo "ℹ️ Using GitHub noreply email: $GIT_EMAIL"
fi
if ! git config --global user.email &>/dev/null && [ -n "$GIT_EMAIL" ]; then
git config --global user.email "$GIT_EMAIL"
echo "βœ… Set git user.email: $GIT_EMAIL"
fi
if ! git config --global user.name &>/dev/null && [ -n "$GIT_NAME" ]; then
git config --global user.name "$GIT_NAME"
echo "βœ… Set git user.name: $GIT_NAME"
fi
fi
# Install chezmoi if not present
if ! command -v chezmoi &>/dev/null; then
echo "πŸ“₯ Installing chezmoi..."
# Force installation to ~/.local/bin directory
mkdir -p ~/.local/bin
if curl -sfL https://get.chezmoi.io | sh -s -- -b ~/.local/bin; then
echo "βœ… chezmoi installation completed"
SECTION_STATUS[chezmoi]="success"
SECTION_MSGS[chezmoi]="chezmoi installed successfully."
else
echo "❌ Failed to install chezmoi!"
echo "πŸ’‘ Please install chezmoi manually:"
echo " curl -sfL https://get.chezmoi.io | sh -s -- -b ~/.local/bin"
echo " export PATH=\"\$HOME/.local/bin:\$PATH\""
SECTION_STATUS[chezmoi]="fail"
SECTION_MSGS[chezmoi]="Failed to install chezmoi."
print_summary
fi
else
SECTION_STATUS[chezmoi]="success"
SECTION_MSGS[chezmoi]="chezmoi already installed."
fi
# Verify chezmoi is available and set the command
CHEZMOI_CMD=""
if command -v chezmoi &>/dev/null; then
CHEZMOI_CMD="chezmoi"
echo "βœ… chezmoi found in PATH: $(command -v chezmoi)"
elif [[ -x "$HOME/.local/bin/chezmoi" ]]; then
CHEZMOI_CMD="$HOME/.local/bin/chezmoi"
echo "βœ… chezmoi found at ~/.local/bin/chezmoi"
elif [[ -x "./bin/chezmoi" ]]; then
CHEZMOI_CMD="./bin/chezmoi"
echo "βœ… chezmoi found at ./bin/chezmoi"
else
echo "❌ chezmoi not found! Checking installation directory..."
echo "πŸ“‚ Current directory: $(pwd)"
echo "πŸ“‚ Contents of ~/.local/bin/:"
ls -la "$HOME/.local/bin/" 2>/dev/null || echo " Directory ~/.local/bin/ does not exist"
echo "πŸ“‚ Contents of ./bin/:"
ls -la "./bin/" 2>/dev/null || echo " Directory ./bin/ does not exist"
echo ""
echo "πŸ’‘ Manual installation required:"
echo " curl -sfL https://get.chezmoi.io | sh -s -- -b ~/.local/bin"
echo " export PATH=\"\$HOME/.local/bin:\$PATH\""
exit 1
fi
# Parse --verbose or -v flag
VERBOSE=0
for arg in "$@"; do
if [ "$arg" = "--verbose" ] || [ "$arg" = "-v" ]; then
VERBOSE=1
break
fi
# Remove the flag from positional args
shift
done
# Helper to run commands quietly unless VERBOSE is set
run_quiet() {
if [ "$VERBOSE" -eq 1 ]; then
"$@"
else
"$@" >/dev/null 2>&1
fi
}
# Check for age key
if [[ ! -f ~/.config/age/chezmoi.txt ]]; then
echo "πŸ”‘ Age encryption key not found at ~/.config/age/chezmoi.txt"
echo ""
# Check for environment variable first
if [[ -n "${CHEZMOI_AGE_KEY:-}" ]]; then
echo "πŸ”‘ Found age key in CHEZMOI_AGE_KEY environment variable"
mkdir -p ~/.config/age
echo "$CHEZMOI_AGE_KEY" >~/.config/age/chezmoi.txt
chmod 600 ~/.config/age/chezmoi.txt
echo "βœ… Age key saved from environment variable!"
SECTION_STATUS[age]="success"
SECTION_MSGS[age]="Age key loaded from environment variable."
choice="none"
# Check if we're running interactively
elif [[ -t 0 ]] && { [ -z "${APPLY_ONLY:-}" ] || [ "${APPLY_ONLY}" = "0" ]; }; then
# Interactive mode - show menu
echo "Choose an option to provide your age key:"
echo " 1) Paste the key content (I'll create the file)"
echo " 2) I'll copy the file manually"
echo ""
read -p "Enter choice (1 or 2): " choice
else
# Non-interactive mode (piped script) - default to paste option
echo "πŸ€– Running in non-interactive mode. Starting age key paste mode..."
echo ""
echo "πŸ“‹ Please paste your age key content now and press Ctrl+D when finished:"
echo " (Age key format: AGE-SECRET-KEY-1...)"
echo ""
if { [ -n "${APPLY_ONLY:-}" ] && [ "${APPLY_ONLY}" != "0" ]; } || { [ -n "${NONINTERACTIVE:-}" ] && [ "${NONINTERACTIVE}" != "0" ]; }; then
# In non-interactive or apply-only mode we prefer not to wait for interactive input; if an age key isn't provided, skip encrypted files with a warning
if [[ -n "${CHEZMOI_AGE_KEY:-}" ]]; then
choice="none"
else
echo "⚠️ apply-only mode: age key not provided β€” encrypted files will be skipped with a warning."
SECTION_STATUS[age]="warning"
SECTION_MSGS[age]="Age key missing in apply-only mode; encrypted files will be skipped."
choice="none"
fi
else
choice="1"
fi
fi
case $choice in
none)
# Already handled by environment variable or skip
:
;;
1)
echo ""
echo "πŸ“‹ Please paste your age key content (from ~/.config/age/chezmoi.txt):"
echo " (Press Ctrl+D when finished)"
echo ""
# Create the directory
mkdir -p ~/.config/age
# Read the key content with timeout in non-interactive mode
if [[ ! -t 0 ]]; then
echo "⏳ Waiting for age key input... (timeout in 30 seconds)"
if ! key_content=$(timeout 30s cat); then
echo ""
echo "❌ No key input received or timeout reached."
SECTION_STATUS[age]="fail"
SECTION_MSGS[age]="No age key input received or timeout reached."
print_summary
fi
else
# Interactive mode
if ! key_content=$(cat); then
echo "❌ Failed to read key content"
SECTION_STATUS[age]="fail"
SECTION_MSGS[age]="Failed to read key content."
print_summary
fi
fi
if [[ -n "$key_content" ]]; then
echo "$key_content" >~/.config/age/chezmoi.txt
chmod 600 ~/.config/age/chezmoi.txt
echo "βœ… Age key saved successfully!"
SECTION_STATUS[age]="success"
SECTION_MSGS[age]="Age key pasted and saved."
else
echo "❌ No key content provided"
SECTION_STATUS[age]="fail"
SECTION_MSGS[age]="No key content provided."
print_summary
fi
;;
2)
echo ""
echo "πŸ“ Please manually copy your age key file:"
echo " Source: ~/.config/age/chezmoi.txt (from your existing machine)"
echo " Target: ~/.config/age/chezmoi.txt (on this machine)"
echo ""
echo "πŸ’‘ Run these commands:"
echo " mkdir -p ~/.config/age"
echo " # Copy chezmoi.txt from your existing machine to ~/.config/age/"
echo ""
read -p "Press Enter when you've copied the file..."
# Verify the file exists
if [[ ! -f ~/.config/age/chezmoi.txt ]]; then
echo "❌ Age key file still not found. Please copy it and run this script again."
SECTION_STATUS[age]="fail"
SECTION_MSGS[age]="Age key file not found after manual copy."
print_summary
fi
echo "βœ… Age key file found!"
SECTION_STATUS[age]="success"
SECTION_MSGS[age]="Age key file found after manual copy."
;;
*)
echo "❌ Invalid choice. Please run the script again and choose 1 or 2."
SECTION_STATUS[age]="fail"
SECTION_MSGS[age]="Invalid choice for age key input."
print_summary
;;
esac
else
SECTION_STATUS[age]="success"
SECTION_MSGS[age]="Age key already present."
fi
###############################################################
# Remove Homebrew pyenv/pyenv-virtualenv if present (avoid conflicts)
if command -v brew >/dev/null 2>&1; then
if brew list pyenv >/dev/null 2>&1; then
echo "🧹 Uninstalling Homebrew pyenv to avoid conflicts..."
brew uninstall --ignore-dependencies pyenv || true
fi
if brew list pyenv-virtualenv >/dev/null 2>&1; then
echo "🧹 Uninstalling Homebrew pyenv-virtualenv to avoid conflicts..."
brew uninstall --ignore-dependencies pyenv-virtualenv || true
fi
fi
# Parse custom flags into shell variables for use in logic
custom_include_all_languages="${custom_include_all_languages:-}"
if [ -z "$custom_include_all_languages" ]; then
# Try to get from environment if not set (for custom mode)
custom_include_all_languages="${CUSTOM_INCLUDE_ALL_LANGUAGES:-}"
fi
# Install and initialize pyenv only if in full mode, or in custom mode with all languages/managers enabled
if [ "$DEPLOY_MODE" = "full" ] || { [ "$DEPLOY_MODE" = "custom" ] && [ "${custom_include_all_languages}" = "true" ]; }; then
if [ -n "${APPLY_ONLY:-}" ] && [ "${APPLY_ONLY}" != "0" ]; then
echo "ℹ️ Skipping pyenv installation in apply-only mode"
SECTION_STATUS[pyenv]="skipped"
SECTION_MSGS[pyenv]="Skipped pyenv installation in apply-only mode."
else
# Install official pyenv (and pyenv-virtualenv) if not present
if [ ! -d "$HOME/.pyenv" ]; then
echo "πŸ“₯ Installing official pyenv and plugins..."
if curl https://pyenv.run | bash; then
SECTION_STATUS[pyenv]="success"
SECTION_MSGS[pyenv]="pyenv installed successfully."
else
SECTION_STATUS[pyenv]="fail"
SECTION_MSGS[pyenv]="Failed to install pyenv."
print_summary
fi
else
SECTION_STATUS[pyenv]="success"
SECTION_MSGS[pyenv]="pyenv already installed."
fi
# Official pyenv initialization (for this session)
export PYENV_ROOT="$HOME/.pyenv"
export PATH="$PYENV_ROOT/bin:$PATH"
if command -v pyenv >/dev/null 2>&1; then
eval "$(pyenv init --path)"
eval "$(pyenv virtualenv-init -)"
SECTION_STATUS[pyenv]="success"
SECTION_MSGS[pyenv]="pyenv initialized for this session."
fi
fi
else
SECTION_STATUS[pyenv]="skipped"
SECTION_MSGS[pyenv]="Skipped pyenv install and initialization (not enabled in this mode)."
echo "ℹ️ Skipping pyenv installation and initialization (not enabled in this mode)."
fi
echo "πŸ”„ Deploying dotfiles..."
echo " This will run all setup scripts automatically:"
echo " β€’ Install dependencies and tools"
echo " β€’ Configure package managers"
echo " β€’ Set up Zsh with plugins"
echo " β€’ Run validation checks"
echo ""
# Export deployment mode and custom flags as environment variables for chezmoi templating
export DEPLOY_MODE="$DEPLOY_MODE"
for arg in "${CUSTOM_DATA_ARGS[@]}"; do
key="$(echo "$arg" | cut -d= -f1)"
val="$(echo "$arg" | cut -d= -f2-)"
export "${key^^}"="$val"
done
# Robust chezmoi init/apply sequence
if $CHEZMOI_CMD init https://github.com/anschmieg/dotfiles.git; then
SECTION_STATUS[chezmoi]="success"
SECTION_MSGS[chezmoi]="chezmoi initialized with your dotfiles repo."
# Build an exclude list if we need to skip encrypted files when no key is present
if [[ -z "${CHEZMOI_AGE_KEY:-}" && ! -f ~/.config/age/chezmoi.txt ]]; then
echo "⚠️ No CHEZMOI_AGE_KEY provided; skipping encrypted files via --exclude encrypted"
EXCLUDE_ARGS+=(--exclude encrypted)
SECTION_STATUS[age]="warning"
SECTION_MSGS[age]="Skipped encrypted files; provide CHEZMOI_AGE_KEY to apply them."
fi
if $CHEZMOI_CMD apply --force "${EXCLUDE_ARGS[@]}"; then
SECTION_STATUS[validation]="success"
SECTION_MSGS[validation]="chezmoi apply and validation completed successfully."
else
SECTION_STATUS[validation]="fail"
SECTION_MSGS[validation]="Deployment failed during chezmoi apply! Check the output above for errors."
print_summary
fi
else
SECTION_STATUS[chezmoi]="fail"
SECTION_MSGS[chezmoi]="Deployment failed during chezmoi init! Check the output above for errors."
print_summary
fi
print_summary
# Attempt to change default shell to zsh if it isn't already
if command -v zsh >/dev/null; then
ZSH_PATH=$(command -v zsh)
# Simple check against $SHELL
if [ "$SHELL" != "$ZSH_PATH" ]; then
echo "🐚 Changing default shell to $ZSH_PATH..."
if chsh -s "$ZSH_PATH"; then
echo "βœ… Default shell changed to zsh."
else
echo "⚠️ Failed to change default shell. You may need to run: chsh -s $ZSH_PATH"
fi
fi
fi
echo ""
echo "🐚 Starting new shell session with your configuration..."
if [[ -n "${APPLY_ONLY:-}" ]] && [[ "${APPLY_ONLY}" = "1" ]]; then
echo "ℹ️ Apply-only mode: not launching an interactive shell"
else
exec zsh
fi
#!/bin/bash
# πŸš€ One-Command Dotfiles Deployment Script
#
# Quick deployment: curl -sfL https://nothing.pink/zsh | bash
# Or for interactivity: bash <(curl -sfL https://nothing.pink/zsh) < /dev/tty
#
# What this script does:
# 1. Installs chezmoi if needed
# 2. Helps you set up your age encryption key
# 3. Deploys your complete dotfiles setup
# 4. Runs all automated configuration scripts
# 5. Validates the installation (37 checks)
#
# Prerequisites: Your age encryption key from ~/.config/age/chezmoi.txt
set -euo pipefail
echo "πŸš€ Starting automated dotfiles deployment..."
echo "πŸ“ Repository: https://github.com/anschmieg/dotfiles.git"
echo ""
# Ensure ~/.local/bin is in PATH first
export PATH="$HOME/.local/bin:$PATH"
# --- Auto-configure git user from GitHub CLI if possible ---
if command -v gh &>/dev/null; then
GIT_EMAIL=$(gh api user/emails --jq 'map(select(.primary == true and .verified == true)) | .[0].email' 2>/dev/null || true)
GIT_NAME=$(gh api user --jq '.name' 2>/dev/null || true)
GIT_LOGIN=$(gh api user --jq '.login' 2>/dev/null || true)
GIT_ID=$(gh api user --jq '.id' 2>/dev/null || true)
# Fallback: use login if name is empty or null
if [ -z "$GIT_NAME" ] || [ "$GIT_NAME" = "null" ]; then
if [ -n "$GIT_LOGIN" ]; then
GIT_NAME="$GIT_LOGIN"
fi
fi
# Fallback: use noreply email if email is empty
if [ -z "$GIT_EMAIL" ] && [ -n "$GIT_ID" ] && [ -n "$GIT_LOGIN" ]; then
GIT_EMAIL="${GIT_ID}+${GIT_LOGIN}@users.noreply.github.com"
echo "ℹ️ Using inferred GitHub noreply email: $GIT_EMAIL"
fi
if ! git config --global user.email &>/dev/null && [ -n "$GIT_EMAIL" ]; then
git config --global user.email "$GIT_EMAIL"
echo "βœ… Set git user.email from GitHub CLI: $GIT_EMAIL"
fi
if ! git config --global user.name &>/dev/null && [ -n "$GIT_NAME" ]; then
git config --global user.name "$GIT_NAME"
echo "βœ… Set git user.name from GitHub CLI: $GIT_NAME"
fi
fi
# Install chezmoi if not present
if ! command -v chezmoi &>/dev/null; then
echo "πŸ“₯ Installing chezmoi..."
# Force installation to ~/.local/bin directory
mkdir -p ~/.local/bin
if curl -sfL https://get.chezmoi.io | sh -s -- -b ~/.local/bin; then
echo "βœ… chezmoi installation completed"
else
echo "❌ Failed to install chezmoi!"
echo "πŸ’‘ Please install chezmoi manually:"
echo " curl -sfL https://get.chezmoi.io | sh -s -- -b ~/.local/bin"
echo " export PATH=\"\$HOME/.local/bin:\$PATH\""
exit 1
fi
fi
# Verify chezmoi is available and set the command
CHEZMOI_CMD=""
if command -v chezmoi &>/dev/null; then
CHEZMOI_CMD="chezmoi"
echo "βœ… chezmoi found in PATH: $(command -v chezmoi)"
elif [[ -x "$HOME/.local/bin/chezmoi" ]]; then
CHEZMOI_CMD="$HOME/.local/bin/chezmoi"
echo "βœ… chezmoi found at ~/.local/bin/chezmoi"
elif [[ -x "./bin/chezmoi" ]]; then
CHEZMOI_CMD="./bin/chezmoi"
echo "βœ… chezmoi found at ./bin/chezmoi"
else
echo "❌ chezmoi not found! Checking installation directory..."
echo "πŸ“‚ Current directory: $(pwd)"
echo "πŸ“‚ Contents of ~/.local/bin/:"
ls -la "$HOME/.local/bin/" 2>/dev/null || echo " Directory ~/.local/bin/ does not exist"
echo "πŸ“‚ Contents of ./bin/:"
ls -la "./bin/" 2>/dev/null || echo " Directory ./bin/ does not exist"
echo ""
echo "πŸ’‘ Manual installation required:"
echo " curl -sfL https://get.chezmoi.io | sh -s -- -b ~/.local/bin"
echo " export PATH=\"\$HOME/.local/bin:\$PATH\""
exit 1
fi
# Parse --verbose or -v flag
VERBOSE=0
for arg in "$@"; do
if [ "$arg" = "--verbose" ] || [ "$arg" = "-v" ]; then
VERBOSE=1
break
fi
# Remove the flag from positional args
shift
done
# Helper to run commands quietly unless VERBOSE is set
run_quiet() {
if [ "$VERBOSE" -eq 1 ]; then
"$@"
else
"$@" >/dev/null 2>&1
fi
}
# Check for age key
if [[ ! -f ~/.config/age/chezmoi.txt ]]; then
echo "πŸ”‘ Age encryption key not found at ~/.config/age/chezmoi.txt"
echo ""
# Check for environment variable first
if [[ -n "${CHEZMOI_AGE_KEY:-}" ]]; then
echo "πŸ”‘ Found age key in CHEZMOI_AGE_KEY environment variable"
mkdir -p ~/.config/age
echo "$CHEZMOI_AGE_KEY" >~/.config/age/chezmoi.txt
chmod 600 ~/.config/age/chezmoi.txt
echo "βœ… Age key saved from environment variable!"
# Check if we're running interactively
elif [[ -t 0 ]]; then
# Interactive mode - show menu
echo "Choose an option to provide your age key:"
echo " 1) Paste the key content (I'll create the file)"
echo " 2) I'll copy the file manually"
echo ""
read -p "Enter choice (1 or 2): " choice
else
# Non-interactive mode (piped script) - default to paste option
echo "πŸ€– Running in non-interactive mode. Starting age key paste mode..."
echo ""
echo "πŸ“‹ Please paste your age key content now and press Ctrl+D when finished:"
echo " (Age key format: AGE-SECRET-KEY-1...)"
echo ""
choice="1"
fi
case $choice in
1)
echo ""
echo "πŸ“‹ Please paste your age key content (from ~/.config/age/chezmoi.txt):"
echo " (Press Ctrl+D when finished)"
echo ""
# Create the directory
mkdir -p ~/.config/age
# Read the key content with timeout in non-interactive mode
if [[ ! -t 0 ]]; then
echo "⏳ Waiting for age key input... (timeout in 30 seconds)"
if ! key_content=$(timeout 30s cat); then
echo ""
echo "❌ No key input received or timeout reached."
echo "πŸ’‘ Alternative: Run the script interactively instead:"
echo " 1. Download: curl -sL https://nothing.pink/zsh > deploy.sh"
echo " 2. Run: bash deploy.sh"
echo " 3. Or manually setup:"
echo " mkdir -p ~/.config/age"
echo " # Copy your age key to ~/.config/age/chezmoi.txt"
echo " chezmoi init --apply https://github.com/anschmieg/dotfiles.git"
exit 1
fi
else
# Interactive mode
if ! key_content=$(cat); then
echo "❌ Failed to read key content"
exit 1
fi
fi
if [[ -n "$key_content" ]]; then
echo "$key_content" >~/.config/age/chezmoi.txt
chmod 600 ~/.config/age/chezmoi.txt
echo "βœ… Age key saved successfully!"
else
echo "❌ No key content provided"
exit 1
fi
;;
2)
echo ""
echo "πŸ“ Please manually copy your age key file:"
echo " Source: ~/.config/age/chezmoi.txt (from your existing machine)"
echo " Target: ~/.config/age/chezmoi.txt (on this machine)"
echo ""
echo "πŸ’‘ Run these commands:"
echo " mkdir -p ~/.config/age"
echo " # Copy chezmoi.txt from your existing machine to ~/.config/age/"
echo ""
read -p "Press Enter when you've copied the file..."
# Verify the file exists
if [[ ! -f ~/.config/age/chezmoi.txt ]]; then
echo "❌ Age key file still not found. Please copy it and run this script again."
exit 1
fi
echo "βœ… Age key file found!"
;;
*)
echo "❌ Invalid choice. Please run the script again and choose 1 or 2."
exit 1
;;
esac
fi
# Deploy dotfiles
echo "πŸ”„ Deploying dotfiles..."
echo " This will run all setup scripts automatically:"
echo " β€’ Install dependencies and tools"
echo " β€’ Configure package managers"
echo " β€’ Set up Zsh with plugins"
echo " β€’ Run validation checks"
echo ""
if $CHEZMOI_CMD init --apply https://github.com/anschmieg/dotfiles.git; then
echo ""
echo "βœ… Deployment completed successfully!"
echo ""
echo "πŸ“‹ What was set up:"
echo " β€’ App package manager with auto-detection"
echo " β€’ Zsh with Antidote plugin manager"
echo " β€’ Git configuration"
echo " β€’ SSH configuration"
echo " β€’ Development tools and utilities"
echo ""
echo "πŸ” Validation results should appear above ☝️"
echo "πŸ’‘ If any validations failed, run: chezmoi cd && ./scripts/troubleshoot.sh"
else
echo "❌ Deployment failed! Check the output above for errors."
echo "πŸ†˜ For troubleshooting, visit: https://github.com/anschmieg/dotfiles"
exit 1
fi
echo ""
echo "🐚 Starting new shell session with your configuration..."
exec zsh
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment