#!/usr/bin/env bash # Configuration drift detection - compare actual system state to dotfiles # Follows the μέτρον principle: measure what is, against what should be set -euo pipefail DOTFILES_DIR="$HOME/dotfiles" DRIFT_REPORT_DIR="$HOME/.drift-reports" TIMESTAMP=$(date +%Y%m%d_%H%M%S) REPORT_FILE="$DRIFT_REPORT_DIR/drift-$TIMESTAMP.txt" # Colors GREEN='\033[0;32m' YELLOW='\033[1;33m' RED='\033[0;31m' BLUE='\033[0;34m' NC='\033[0m' echo -e "${BLUE}Configuration Drift Detection${NC}" echo "====================================" # Create report directory mkdir -p "$DRIFT_REPORT_DIR" # Initialize report cat > "$REPORT_FILE" << EOF Configuration Drift Report Generated: $(date) System: $(sw_vers -productVersion) EOF # Function to detect package drift detect_package_drift() { echo -e "${YELLOW}🔍 Analyzing package drift...${NC}" local brewfile="$DOTFILES_DIR/Brewfile" local lockfile="$DOTFILES_DIR/Brewfile.lock" local drift_found=false echo "=== PACKAGE DRIFT ANALYSIS ===" >> "$REPORT_FILE" if [[ ! -f "$brewfile" ]]; then echo "❌ Brewfile not found at $brewfile" | tee -a "$REPORT_FILE" return 1 fi # Check for packages in Brewfile but not installed echo "Packages defined but not installed:" >> "$REPORT_FILE" while IFS= read -r line; do if [[ "$line" =~ ^brew\ \"([^\"]+)\" ]]; then package="${BASH_REMATCH[1]}" if ! brew list --formula | grep -q "^$package$"; then echo " - $package (formula)" | tee -a "$REPORT_FILE" drift_found=true fi elif [[ "$line" =~ ^cask\ \"([^\"]+)\" ]]; then package="${BASH_REMATCH[1]}" if ! brew list --cask | grep -q "^$package$"; then echo " - $package (cask)" | tee -a "$REPORT_FILE" drift_found=true fi fi done < "$brewfile" # Check for installed packages not in Brewfile echo "" >> "$REPORT_FILE" echo "Packages installed but not in Brewfile:" >> "$REPORT_FILE" # Check formulae while IFS= read -r package; do if ! grep -q "brew \"$package\"" "$brewfile" 2>/dev/null; then echo " - $package (formula)" | tee -a "$REPORT_FILE" drift_found=true fi done < <(brew list --formula) # Check casks while IFS= read -r package; do if ! grep -q "cask \"$package\"" "$brewfile" 2>/dev/null; then echo " - $package (cask)" | tee -a "$REPORT_FILE" drift_found=true fi done < <(brew list --cask) # Version drift (if lockfile exists) if [[ -f "$lockfile" ]]; then echo "" >> "$REPORT_FILE" echo "Version drift from lockfile:" >> "$REPORT_FILE" # Compare current versions to locked versions while IFS= read -r line; do if [[ "$line" =~ ^([^[:space:]]+)[[:space:]]+(.+)$ ]]; then package="${BASH_REMATCH[1]}" locked_version="${BASH_REMATCH[2]}" # Get current version current_version=$(brew list --versions "$package" 2>/dev/null | head -1 | cut -d' ' -f2- || echo "not installed") if [[ "$current_version" != "$locked_version" && "$current_version" != "not installed" ]]; then echo " - $package: locked($locked_version) vs current($current_version)" | tee -a "$REPORT_FILE" drift_found=true fi fi done < <(grep -v '^#' "$lockfile" 2>/dev/null || true) fi if [[ "$drift_found" == "false" ]]; then echo -e "${GREEN}✅ No package drift detected${NC}" echo "No package drift detected" >> "$REPORT_FILE" else echo -e "${YELLOW}⚠️ Package drift detected - see report${NC}" fi echo "" >> "$REPORT_FILE" } # Function to detect configuration file drift detect_config_drift() { echo -e "${YELLOW}🔍 Analyzing configuration drift...${NC}" echo "=== CONFIGURATION FILE DRIFT ===" >> "$REPORT_FILE" local config_files=( ".zshrc:$HOME/.zshrc:$DOTFILES_DIR/shell/.zshrc" ".gitconfig:$HOME/.gitconfig:$DOTFILES_DIR/.gitconfig" ".vimrc:$HOME/.vimrc:$DOTFILES_DIR/.vimrc" ) local drift_found=false for config_spec in "${config_files[@]}"; do IFS=':' read -r name home_path dotfiles_path <<< "$config_spec" if [[ -f "$home_path" && -f "$dotfiles_path" ]]; then if ! diff -q "$home_path" "$dotfiles_path" >/dev/null 2>&1; then echo "Configuration drift detected: $name" | tee -a "$REPORT_FILE" echo " Home: $home_path" >> "$REPORT_FILE" echo " Dotfiles: $dotfiles_path" >> "$REPORT_FILE" echo " Run: diff \"$home_path\" \"$dotfiles_path\"" >> "$REPORT_FILE" echo "" >> "$REPORT_FILE" drift_found=true fi elif [[ -f "$home_path" && ! -f "$dotfiles_path" ]]; then echo "File exists in home but not in dotfiles: $name" | tee -a "$REPORT_FILE" drift_found=true elif [[ ! -f "$home_path" && -f "$dotfiles_path" ]]; then echo "File exists in dotfiles but not deployed: $name" | tee -a "$REPORT_FILE" drift_found=true fi done if [[ "$drift_found" == "false" ]]; then echo -e "${GREEN}✅ No configuration drift detected${NC}" echo "No configuration drift detected" >> "$REPORT_FILE" else echo -e "${YELLOW}⚠️ Configuration drift detected - see report${NC}" fi echo "" >> "$REPORT_FILE" } # Function to detect system settings drift detect_system_drift() { echo -e "${YELLOW}🔍 Analyzing system settings drift...${NC}" echo "=== SYSTEM SETTINGS DRIFT ===" >> "$REPORT_FILE" local defaults_script="$DOTFILES_DIR/scripts/set-macos-defaults.sh" if [[ ! -f "$defaults_script" ]]; then echo "No macOS defaults script found" >> "$REPORT_FILE" return 0 fi # Extract settings from defaults script and check current values local drift_found=false # Look for defaults write commands and check current values while IFS= read -r line; do if [[ "$line" =~ defaults\ write\ ([^[:space:]]+)\ ([^[:space:]]+)\ (.+) ]]; then domain="${BASH_REMATCH[1]}" key="${BASH_REMATCH[2]}" expected_value="${BASH_REMATCH[3]}" # Get current value current_value=$(defaults read "$domain" "$key" 2>/dev/null || echo "not set") # Simple comparison (could be enhanced for complex types) if [[ "$current_value" != "$expected_value" ]]; then echo "Setting drift: $domain $key" >> "$REPORT_FILE" echo " Expected: $expected_value" >> "$REPORT_FILE" echo " Current: $current_value" >> "$REPORT_FILE" echo "" >> "$REPORT_FILE" drift_found=true fi fi done < <(grep "defaults write" "$defaults_script" 2>/dev/null || true) if [[ "$drift_found" == "false" ]]; then echo -e "${GREEN}✅ No system settings drift detected${NC}" echo "No system settings drift detected" >> "$REPORT_FILE" else echo -e "${YELLOW}⚠️ System settings drift detected - see report${NC}" fi echo "" >> "$REPORT_FILE" } # Function to generate drift summary generate_summary() { echo -e "${BLUE}📊 Generating drift summary...${NC}" echo "=== DRIFT SUMMARY ===" >> "$REPORT_FILE" echo "Report generated: $(date)" >> "$REPORT_FILE" echo "Next recommended actions:" >> "$REPORT_FILE" if grep -q "drift detected" "$REPORT_FILE"; then echo "1. Review specific drift items above" >> "$REPORT_FILE" echo "2. Update dotfiles or system as appropriate" >> "$REPORT_FILE" echo "3. Run: ~/dotfiles/scripts/generate-lockfile.sh (for package versions)" >> "$REPORT_FILE" echo "4. Consider running: ~/dotfiles/engage (to re-sync configurations)" >> "$REPORT_FILE" else echo "✅ System is in sync with dotfiles configuration" >> "$REPORT_FILE" fi echo "" >> "$REPORT_FILE" echo "To fix drift automatically, consider:" >> "$REPORT_FILE" echo "- Package drift: brew bundle --file=$DOTFILES_DIR/Brewfile" >> "$REPORT_FILE" echo "- Config drift: re-run relevant sections of ~/dotfiles/engage" >> "$REPORT_FILE" echo "- System drift: ~/dotfiles/scripts/set-macos-defaults.sh" >> "$REPORT_FILE" } # Main execution main() { detect_package_drift detect_config_drift detect_system_drift generate_summary echo -e "${BLUE}📋 Full report saved to: $REPORT_FILE${NC}" # Show summary if grep -q "drift detected" "$REPORT_FILE"; then echo -e "${YELLOW}⚠️ Configuration drift detected${NC}" echo "Run: cat $REPORT_FILE | less" echo "Or: ~/dotfiles/scripts/detect-drift.sh --fix (future feature)" # Clean old reports (keep last 10) find "$DRIFT_REPORT_DIR" -name "drift-*.txt" | sort -r | tail -n +11 | xargs rm -f 2>/dev/null || true return 1 else echo -e "${GREEN}✅ System configuration is consistent with dotfiles${NC}" return 0 fi } # Command line options case "${1:-}" in --quiet|-q) main >/dev/null ;; --report|-r) if [[ -f "$REPORT_FILE" ]]; then cat "$REPORT_FILE" else main cat "$REPORT_FILE" fi ;; *) main ;; esac