diff --git a/AGENTS.md b/AGENTS.md index 4004f0a..ffecf0e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,6 +108,135 @@ Edit `Brewfile` in the root directory. Use these formats: - Used for: Structured configuration data like SMB mounts - Parse this file in scripts when you need user-specific structured data +## Error Handling and Script Patterns + +This project follows specific patterns for error handling to ensure reliable installations while maintaining flexibility. + +### Philosophy + +- **Individual scripts fail fast**: Each `install.sh` should exit on error within its own scope (`set -e`) +- **Bootstrap continues on failure**: The main `bootstrap.sh` tracks failures but continues with other scripts +- **Logical isolation**: Each install script is treated as an independent task that won't break others + +### Shared Utility Functions + +All scripts should source the shared functions library: + +```bash +#!/usr/bin/env bash +set -e +source ~/.dotfiles/lib/functions.sh +``` + +Available functions from `lib/functions.sh`: + +- **`info "message"`** - Display informational message with blue indicator +- **`success "message"`** - Display success message with green indicator +- **`fail "message"`** - Display error message with red indicator and exit +- **`warn "message"`** - Display warning message with yellow indicator +- **`user "message"`** - Display user prompt message +- **`check_command tool_name`** - Verify a command exists, fail if not +- **`require_env VAR_NAME`** - Verify an environment variable is set, fail if not +- **`check_exists /path/to/file`** - Verify a file/directory exists, fail if not + +### Standard install.sh Pattern + +Every `install.sh` script should follow this pattern: + +```bash +#!/usr/bin/env bash +set -e # Exit on error within this script +source ~/.dotfiles/lib/functions.sh + +# 1. Validate prerequisites +check_command required_tool +require_env REQUIRED_VAR # If script needs environment variables + +# 2. Inform user what's happening +info "Installing something" + +# 3. Check if already installed/configured (idempotency) +if [ -f ~/.config/something ]; then + info "Already configured" + exit 0 +fi + +# 4. Perform operations +some_command +mkdir -p ~/.config +ln -s ~/.dotfiles/topic/config ~/.config/something + +# 5. Report success +success "Installation complete" +``` + +### Environment Variable Validation + +Scripts that require environment variables should validate them: + +- **In bootstrap.sh**: `validate_environment()` checks `USER_EMAIL` and `DEVICE_NAME` +- **In install scripts**: Use `require_env VAR_NAME` for script-specific requirements + +### Idempotency Guidelines + +Scripts should be safely re-runnable: + +```bash +# Check before creating +if [ ! -d "$DIR" ]; then + mkdir -p "$DIR" +fi + +# Check before symlinking +if [ ! -L "$LINK" ]; then + ln -s "$TARGET" "$LINK" +fi + +# Check if already installed +if command_output | grep -q "already installed"; then + info "Already installed" +else + install_command +fi +``` + +### Destructive Operations + +For potentially destructive operations (like replacing directories): + +1. **Check if target exists** +2. **Warn user** about what will happen +3. **Create backups** before removing +4. **Provide recovery information** + +Example from `maestral/install.sh`: + +```bash +if [ -e "$local_dir" ] && [ ! -L "$local_dir" ]; then + warn "$dir_name exists and will be replaced with a symlink" + info "Backing up existing $dir_name to ${dir_name}.backup" + mv "$local_dir" "${local_dir}.backup" +fi +``` + +### Bootstrap Error Tracking + +The `bootstrap.sh` script: + +- Uses `set +e` to continue on errors +- Tracks which scripts succeed and fail +- Reports a summary at the end +- Shows clear indication of what needs attention + +### When Writing New Scripts + +1. Always start with the standard pattern (shebang, set -e, source functions) +2. Validate prerequisites before making changes +3. Make operations idempotent +4. Use informative logging (info/success/warn) +5. Handle errors gracefully within the script's scope +6. Test that the script can be run multiple times safely + ## Best Practices for Agents ### When Modifying Files @@ -147,6 +276,51 @@ When making changes, always update documentation: - Keep scripts modular and focused on single responsibilities - Avoid hardcoding user-specific values (use `.env` or `local/config.json` instead) +## Code Formatting and Linting + +This project uses [Prettier](https://prettier.io/) with [prettier-plugin-sh](https://github.com/un-ts/prettier/tree/master/packages/sh) to maintain consistent code formatting across all files, including shell scripts. + +### Running Formatting and Linting + +- **Format all files**: `npm run format` +- **Check formatting**: `npm run lint` + +### When to Run Formatting + +**Always run the formatter before committing code.** This is a required expectation for all changes to the repository. + +1. **Before commits**: Run `npm run format` to automatically fix formatting issues +2. **During development**: Run `npm run lint` to check if your changes meet formatting standards +3. **After making changes**: Verify all files pass linting with `npm run lint` + +### What Gets Formatted + +Prettier with the shell plugin handles formatting for: + +- Shell scripts (`.sh`, `.bash`, `.zsh` files) +- JavaScript and JSON files (`package.json`, configuration files) +- Markdown files (`.md`) +- YAML files (`.yml`, `.yaml`) +- And other common file types + +### Configuration + +- **`.prettierrc`**: Project formatting rules (uses tabs, includes shell plugin) +- **`.editorconfig`**: Editor-level formatting hints (tabs for most files, spaces for YAML) +- **`.node-version`**: Specifies Node.js version (24) for consistent tooling + +### CI Integration + +The CI pipeline runs `npm run lint` on all pull requests and pushes to the main branch. Code that doesn't pass Prettier checks will fail CI and must be fixed before merging. + +### Best Practices + +1. **Install Node.js**: Ensure you have Node.js installed (version 24 or compatible) +2. **Install dependencies**: Run `npm install` in the repository root +3. **Format early and often**: Don't wait until the end to format your changes +4. **Trust the formatter**: Prettier is opinionated by design; let it handle formatting decisions +5. **Check before pushing**: Always run `npm run lint` before pushing commits + ## Privacy and Security - Never commit `.env` or `local/config.json` files diff --git a/bootstrap.sh b/bootstrap.sh index df738c4..ca93e1e 100755 --- a/bootstrap.sh +++ b/bootstrap.sh @@ -1,24 +1,40 @@ #!/usr/bin/env bash # # bootstrap installs things. -set +e +set +e # Keep +e so one script failure doesn't stop others -info() { - printf "\r [ \033[00;34m..\033[0m ] $1\n" -} +# Source shared functions +source "$(dirname "$0")/lib/functions.sh" -user() { - printf "\r [ \033[0;33m??\033[0m ] $1\n" -} +# Validate environment before proceeding +validate_environment() { + info "Validating environment" -success() { - printf "\r\033[2K [ \033[00;32mOK\033[0m ] $1\n" -} + # Check if .env file exists + if [ ! -f ~/.dotfiles/.env ]; then + fail ".env file not found. Copy .env.template and configure it." + fi + + # Source .env if not already sourced + if [ -z "$DEVICE_NAME" ] || [ -z "$USER_EMAIL" ]; then + source ~/.dotfiles/.env + fi + + # Validate required environment variables + if [ -z "$USER_EMAIL" ]; then + fail "USER_EMAIL not set in .env" + fi + + if [ -z "$DEVICE_NAME" ]; then + fail "DEVICE_NAME not set in .env" + fi -fail() { - printf "\r\033[2K [\033[0;31mFAIL\033[0m] $1\n" - echo '' - exit + # Check if local/config.json exists + if [ ! -f ~/.dotfiles/local/config.json ]; then + fail "local/config.json not found. Copy local/config.json.example and configure it." + fi + + success "Environment validated" } setup_gitconfig() { @@ -68,17 +84,63 @@ setup_clone() { cd .dotfiles } +# Track installation results +declare -a failed_scripts=() +declare -a successful_scripts=() + +# Validate environment first +validate_environment + setup_gitconfig install_dotfiles # Run the pre-installers -find . -name preinstall.sh | while read installer; do sh -c "${installer}"; done +info "Running pre-install scripts" +find . -name preinstall.sh | while read installer; do + info "Running ${installer}" + if sh -c "${installer}"; then + success "Completed ${installer}" + else + warn "Failed ${installer}" + fi +done # Run Homebrew through the Brewfile info "› brew bundle" -brew bundle - -# find the installers and run them iteratively -find . -name install.sh | while read installer; do sh -c "${installer}"; done - -success 'All installed!' +if brew bundle; then + success "Homebrew packages installed" +else + warn "brew bundle had errors" +fi + +# Find and run the installers iteratively +info "Running install scripts" +while IFS= read -r installer; do + info "Running ${installer}" + if sh -c "${installer}"; then + successful_scripts+=("$installer") + success "Completed ${installer}" + else + failed_scripts+=("$installer") + warn "Failed ${installer}" + fi +done < <(find . -name install.sh) + +# Final report +echo "" +info "Installation Summary" +echo " Successful: ${#successful_scripts[@]}" +echo " Failed: ${#failed_scripts[@]}" + +if [ ${#failed_scripts[@]} -gt 0 ]; then + echo "" + warn "The following scripts failed:" + for script in "${failed_scripts[@]}"; do + echo " - $script" + done + echo "" + warn "Bootstrap completed with errors" +else + echo "" + success "All installed successfully!" +fi diff --git a/docker/install.sh b/docker/install.sh index 986d3dd..cbfb310 100755 --- a/docker/install.sh +++ b/docker/install.sh @@ -1,14 +1,25 @@ +#!/usr/bin/env bash +set -e +source ~/.dotfiles/lib/functions.sh + ### -# We want to use Docker without using Docker Desktop. This means using minikube too. +# We want to use Docker without using Docker Desktop. This means using minikube too. # These steps are described in more detail at: # https://dhwaneetbhatt.com/blog/run-docker-without-docker-desktop-on-macos ### -# Start minikube +check_command minikube + +info "Starting minikube" minikube start --mount --mount-string="${HOME}/Maestral/Code:/data/Code" -# Enable ingress + +info "Enabling ingress addon" minikube addons enable ingress -# Tell Docker CLI to talk to minikube's VM + +info "Setting up docker environment" eval $(minikube docker-env) -# Set up the custom host + +info "Adding docker.local to /etc/hosts" echo "$(minikube ip) docker.local" | sudo tee -a /etc/hosts > /dev/null + +success "Docker environment configured" diff --git a/gpg/install.sh b/gpg/install.sh index f496f41..afaaabc 100755 --- a/gpg/install.sh +++ b/gpg/install.sh @@ -1,4 +1,23 @@ -echo "setting up gpg" -mkdir ~/.gnupg -ln -s ~/.dotfiles/gpg/gpg-agent.conf ~/.gnupg/gpg-agent.conf +#!/usr/bin/env bash +set -e +source ~/.dotfiles/lib/functions.sh + +check_command gpgconf + +info "Setting up GPG" + +# Create .gnupg directory if it doesn't exist +if [ ! -d ~/.gnupg ]; then + mkdir ~/.gnupg + chmod 700 ~/.gnupg +fi + +# Create symlink if it doesn't exist +if [ ! -L ~/.gnupg/gpg-agent.conf ]; then + ln -s ~/.dotfiles/gpg/gpg-agent.conf ~/.gnupg/gpg-agent.conf +fi + +# Kill and restart gpg-agent gpgconf --kill gpg-agent + +success "GPG configured" diff --git a/java/install.sh b/java/install.sh index 0ac22a2..cca1b05 100755 --- a/java/install.sh +++ b/java/install.sh @@ -1,4 +1,27 @@ -echo "Setting up jenv" +#!/usr/bin/env bash +set -e +source ~/.dotfiles/lib/functions.sh + +check_command jenv + +info "Setting up jenv" + +# Check if any JDKs are installed +JDK_COUNT=$(find /Library/Java/JavaVirtualMachines -maxdepth 1 -name '*jdk*' -type d 2> /dev/null | wc -l | tr -d ' ') + +if [ "$JDK_COUNT" -eq 0 ]; then + warn "No JDKs found in /Library/Java/JavaVirtualMachines" + warn "Install a JDK before running this script" + exit 0 +fi + +info "Found $JDK_COUNT JDK(s), adding to jenv" + for d in /Library/Java/JavaVirtualMachines/*jdk*/; do - jenv add $d/Contents/Home/ + if [ -d "$d/Contents/Home" ]; then + info "Adding $(basename "$d")" + jenv add "$d/Contents/Home/" || warn "$(basename "$d") may already be added" + fi done + +success "jenv configured" diff --git a/lib/functions.sh b/lib/functions.sh new file mode 100644 index 0000000..297a24e --- /dev/null +++ b/lib/functions.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# +# Shared utility functions for dotfiles scripts +# +# Usage: source ~/.dotfiles/lib/functions.sh + +# Output functions with color formatting +info() { + printf "\r [ \033[00;34m..\033[0m ] $1\n" +} + +success() { + printf "\r\033[2K [ \033[00;32mOK\033[0m ] $1\n" +} + +fail() { + printf "\r\033[2K [\033[0;31mFAIL\033[0m] $1\n" >&2 + exit 1 +} + +warn() { + printf "\r\033[2K [\033[0;33mWARN\033[0m] $1\n" >&2 +} + +user() { + printf "\r [ \033[0;33m??\033[0m ] $1\n" +} + +# Check if a command exists +# Usage: check_command git +check_command() { + if ! command -v "$1" &> /dev/null; then + fail "$1 is required but not installed" + fi +} + +# Require an environment variable to be set +# Usage: require_env USER_EMAIL +require_env() { + if [ -z "${!1}" ]; then + fail "Environment variable $1 is required but not set" + fi +} + +# Check if a file or directory exists +# Usage: check_exists ~/.ssh/config +check_exists() { + if [ ! -e "$1" ]; then + fail "$1 does not exist" + fi +} diff --git a/macos/install.sh b/macos/install.sh index 76521fc..c8dfadc 100755 --- a/macos/install.sh +++ b/macos/install.sh @@ -1,9 +1,23 @@ +#!/usr/bin/env bash +set -e +source ~/.dotfiles/lib/functions.sh + +# Validate required environment variables +require_env DEVICE_NAME + +# Validate required commands are available +check_command mas +check_command duti +check_command envsubst +check_command node + # The Brewfile handles Homebrew-based app and library installs, but there may # still be updates and installables in the Mac App Store. There's a nifty # command line interface to it that we can use to just install everything, so # yeah, let's do that. -printf "\e[32mRunning MacOS setup\e[0m\n" -set -x +info "Running MacOS setup" + +info "Installing system updates" sudo softwareupdate -i -a # To look up the install ID go to the store and "copy link" @@ -174,9 +188,7 @@ sudo launchctl load ~/Library/LaunchAgents/dotfiles.macos.launch.plist # sudo chmod 644 /etc/auto_master # sudo chmod 644 /etc/auto_smb -{ set +x; } 2> /dev/null -printf "\e[32mMacOS settings updated.\e[0m\n" -printf "\e[33m(You may need to restart for all settings to take effect)\e[0m\n" +info "Setting default applications" ####################### ## Set default applications for various file types @@ -214,6 +226,10 @@ defaults write com.apple.WindowManager EnableTilingOptionAccelerator -bool false defaults write com.apple.WindowManager EnableTiledWindowMargins -bool false # Restart modified services -killall SystemUIServer -killall Finder -killall Dock +info "Restarting system services" +killall SystemUIServer 2> /dev/null || true +killall Finder 2> /dev/null || true +killall Dock 2> /dev/null || true + +success "MacOS settings updated" +warn "You may need to restart for all settings to take effect" diff --git a/maestral/install.sh b/maestral/install.sh index e392b30..cd5b306 100755 --- a/maestral/install.sh +++ b/maestral/install.sh @@ -1,27 +1,62 @@ -# Start Maestral +#!/usr/bin/env bash +set -e +source ~/.dotfiles/lib/functions.sh + +# Validate required commands +check_command maestral +check_command fileicon + +info "Starting Maestral" maestral start -# Set up Maestral -mkdir ~/Maestral -fileicon set ~/Maestral/ /System/Library/CoreServices/CoreTypes.bundle/Contents/Resources/LibraryFolderIcon.icns +# Create Maestral directory if it doesn't exist +if [ ! -d ~/Maestral ]; then + info "Creating Maestral directory" + mkdir ~/Maestral + fileicon set ~/Maestral/ /System/Library/CoreServices/CoreTypes.bundle/Contents/Resources/LibraryFolderIcon.icns +fi + +# Function to replace directory with symlink +replace_with_symlink() { + local dir_name=$1 + local icon_path=$2 + local local_dir=~/$dir_name + local maestral_dir=~/Maestral/$dir_name + + # Create the Maestral directory if it doesn't exist + mkdir -p "$maestral_dir" + + # If local directory exists and is not a symlink, handle it + if [ -e "$local_dir" ] && [ ! -L "$local_dir" ]; then + warn "$dir_name exists and will be replaced with a symlink to Maestral" + info "Backing up existing $dir_name to ${dir_name}.backup" + mv "$local_dir" "${local_dir}.backup" + fi + + # Create symlink if it doesn't exist + if [ ! -L "$local_dir" ]; then + info "Creating symlink: $local_dir -> $maestral_dir" + ln -s "$maestral_dir" "$local_dir" + fi + + # Set icon + fileicon set "$maestral_dir/" "$icon_path" + success "$dir_name configured" +} + +## We want to use Dropbox versions of a few key directories so they're the same across all devices -## - We want to use Dropbox versions of a few key directories so they're the same across all devices # Use sync'd Downloads -sudo rm -fr ~/Downloads -mkdir -p ~/Maestral/Downloads -ln -s ~/Maestral/Downloads ~/Downloads -fileicon set ~/Maestral/Downloads/ /System/Library/CoreServices/CoreTypes.bundle/Contents/Resources/DownloadsFolder.icns +replace_with_symlink "Downloads" "/System/Library/CoreServices/CoreTypes.bundle/Contents/Resources/DownloadsFolder.icns" # Use sync'd Documents -sudo rm -fr ~/Documents -mkdir -p ~/Maestral/Documents -ln -s ~/Maestral/Documents ~/Documents -fileicon set ~/Maestral/Documents/ /System/Library/CoreServices/CoreTypes.bundle/Contents/Resources/DocumentsFolderIcon.icns +replace_with_symlink "Documents" "/System/Library/CoreServices/CoreTypes.bundle/Contents/Resources/DocumentsFolderIcon.icns" # Use sync'd Code -mkdir -p ~/Maestral/Code -ln -s ~/Maestral/Code ~/Code -fileicon set ~/Maestral/Code/ /System/Library/CoreServices/CoreTypes.bundle/Contents/Resources/DeveloperFolderIcon.icns +replace_with_symlink "Code" "/System/Library/CoreServices/CoreTypes.bundle/Contents/Resources/DeveloperFolderIcon.icns" # Autostart on load +info "Enabling autostart" maestral autostart -Y + +success "Maestral configuration complete" diff --git a/postgresql/install.sh b/postgresql/install.sh index 643d582..f6f8260 100755 --- a/postgresql/install.sh +++ b/postgresql/install.sh @@ -1,5 +1,16 @@ -echo "Starting postgresql service" -brew services start postgresql +#!/usr/bin/env bash +set -e +source ~/.dotfiles/lib/functions.sh + +check_command brew + +info "Starting PostgreSQL service" + +if brew services start postgresql; then + success "PostgreSQL service started" +else + warn "PostgreSQL service may already be running" +fi # Since I always come here when psql is not working as expected # here are some useful extra commands: diff --git a/python/install.sh b/python/install.sh index 5388999..93ebd44 100755 --- a/python/install.sh +++ b/python/install.sh @@ -1,11 +1,32 @@ -echo "Install latest python" -mkdir "$(pyenv root)"/plugins -ln -s ~/.dotfiles/python/pyenv-install-latest "$(pyenv root)"/plugins/pyenv-install-latest +#!/usr/bin/env bash +set -e +source ~/.dotfiles/lib/functions.sh + +# Validate pyenv is installed +check_command pyenv + +info "Setting up pyenv plugin" +PYENV_PLUGINS_DIR="$(pyenv root)/plugins" +PLUGIN_NAME="pyenv-install-latest" + +# Create plugins directory if it doesn't exist +if [ ! -d "$PYENV_PLUGINS_DIR" ]; then + mkdir -p "$PYENV_PLUGINS_DIR" +fi + +# Create symlink if it doesn't exist +if [ ! -L "$PYENV_PLUGINS_DIR/$PLUGIN_NAME" ]; then + ln -s ~/.dotfiles/python/$PLUGIN_NAME "$PYENV_PLUGINS_DIR/$PLUGIN_NAME" +fi + +info "Installing latest python" pyenv install-latest -echo "Setting global python" +info "Setting global python" source ./hook.zsh pyenv global $(get_installed_python_version) -echo "Installing pipenv" +info "Installing pipenv" pip install -U pipenv + +success "Python environment configured" diff --git a/ruby/install.sh b/ruby/install.sh index 2fb9b6c..34348b6 100755 --- a/ruby/install.sh +++ b/ruby/install.sh @@ -1,3 +1,21 @@ -echo "Installing rbenv" -rbenv install 2.7.1 -rbenv global 2.7.1 +#!/usr/bin/env bash +set -e +source ~/.dotfiles/lib/functions.sh + +check_command rbenv + +RUBY_VERSION="2.7.1" + +info "Installing Ruby ${RUBY_VERSION}" + +# Check if the version is already installed +if rbenv versions | grep -q "$RUBY_VERSION"; then + info "Ruby ${RUBY_VERSION} already installed" +else + rbenv install "$RUBY_VERSION" +fi + +info "Setting global Ruby version to ${RUBY_VERSION}" +rbenv global "$RUBY_VERSION" + +success "Ruby environment configured" diff --git a/ssh/install.sh b/ssh/install.sh index 6766465..3e0934e 100755 --- a/ssh/install.sh +++ b/ssh/install.sh @@ -1,10 +1,28 @@ +#!/usr/bin/env bash +set -e +source ~/.dotfiles/lib/functions.sh + +# Validate required environment variables +require_env USER_EMAIL + SSH_KEY="$HOME/.ssh/id_ed25519" +# Ensure .ssh directory exists +if [ ! -d "$HOME/.ssh" ]; then + info "Creating .ssh directory" + mkdir -p "$HOME/.ssh" + chmod 700 "$HOME/.ssh" +fi + # Check if the key already exists if [ ! -f "$SSH_KEY" ]; then - echo "🛠 SSH key not found. Generating a new one..." - ssh-keygen -t ed25519 -C "$USER_EMAIL" -f "$SSH_KEY" -N "" - echo "✅ SSH key generated at $SSH_KEY" + info "SSH key not found. Generating a new one..." + if ssh-keygen -t ed25519 -C "$USER_EMAIL" -f "$SSH_KEY" -N ""; then + success "SSH key generated at $SSH_KEY" + else + fail "Failed to generate SSH key" + fi else - echo "🔑 SSH key already exists at $SSH_KEY" + info "SSH key already exists at $SSH_KEY" + success "SSH key configured" fi diff --git a/zsh/install.sh b/zsh/install.sh index 75d6f51..b62d404 100755 --- a/zsh/install.sh +++ b/zsh/install.sh @@ -1,12 +1,34 @@ -echo "Installing Oh My ZSH!" -sh -c "$(curl -fsSL https://raw.githubusercontent.com/robbyrussell/oh-my-zsh/master/tools/install.sh)" +#!/usr/bin/env bash +set -e +source ~/.dotfiles/lib/functions.sh -echo "Installing Powerline Fonts" -git clone https://github.com/powerline/fonts.git --depth=1 -cd fonts +# Check if Oh My ZSH is already installed +if [ ! -d "$HOME/.oh-my-zsh" ]; then + info "Installing Oh My ZSH" + sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended + success "Oh My ZSH installed" +else + info "Oh My ZSH already installed" +fi + +# Install Powerline Fonts +info "Installing Powerline Fonts" +TEMP_FONTS_DIR=$(mktemp -d) +git clone https://github.com/powerline/fonts.git --depth=1 "$TEMP_FONTS_DIR" +cd "$TEMP_FONTS_DIR" ./install.sh -cd .. -rm -rf fonts +cd - +rm -rf "$TEMP_FONTS_DIR" +success "Powerline Fonts installed" + +# Set zsh as default shell if it isn't already +CURRENT_SHELL=$(dscl . -read ~/ UserShell | sed 's/UserShell: //') +if [ "$CURRENT_SHELL" != "/bin/zsh" ]; then + info "Setting zsh as default shell" + chsh -s /bin/zsh + success "Default shell changed to zsh" +else + info "zsh is already the default shell" +fi -echo "Enabling zsh as the default shell" -chsh -s /bin/zsh +success "Zsh configuration complete"