#!/bin/bash # Mole - Homebrew Cask Uninstallation Support # Detects Homebrew-managed casks via Caskroom linkage and uninstalls them via brew set -euo pipefail # Prevent multiple sourcing if [[ -n "${MOLE_BREW_UNINSTALL_LOADED:-}" ]]; then return 0 fi readonly MOLE_BREW_UNINSTALL_LOADED=1 # Resolve a path to its absolute real path (follows symlinks) # Args: $1 - path to resolve # Returns: Absolute resolved path, or empty string on failure resolve_path() { local p="$1" [[ -e "$p" ]] || return 1 # macOS 12.3+ and Linux have realpath if realpath "$p" 2> /dev/null; then return 0 fi # Fallback: use cd -P to resolve directory, then append basename local dir base dir=$(cd -P "$(dirname "$p")" 2> /dev/null && pwd) || return 1 base=$(basename "$p") echo "$dir/$base" } # Check if Homebrew is installed and accessible # Returns: 0 if brew is available, 1 otherwise is_homebrew_available() { command -v brew > /dev/null 2>&1 } # Extract cask token from a Caskroom path # Args: $1 - path (must be inside Caskroom) # Prints: cask token to stdout # Returns: 0 if valid token extracted, 1 otherwise _extract_cask_token_from_path() { local path="$1" # Check if path is inside Caskroom case "$path" in /opt/homebrew/Caskroom/* | /usr/local/Caskroom/*) ;; *) return 1 ;; esac # Extract token from path: /opt/homebrew/Caskroom///... local token token="${path#*/Caskroom/}" # Remove everything up to and including Caskroom/ token="${token%%/*}" # Take only the first path component # Validate token looks like a valid cask name (lowercase alphanumeric with hyphens) if [[ -n "$token" && "$token" =~ ^[a-z0-9][a-z0-9-]*$ ]]; then echo "$token" return 0 fi return 1 } # Stage 1: Deterministic detection via fully resolved path # Fast, no false positives - follows all symlinks _detect_cask_via_resolved_path() { local app_path="$1" local resolved if resolved=$(resolve_path "$app_path") && [[ -n "$resolved" ]]; then _extract_cask_token_from_path "$resolved" && return 0 fi return 1 } # Stage 2: Search Caskroom by app bundle name using find # Catches apps where the .app in /Applications doesn't link to Caskroom # Only succeeds if exactly one cask matches (avoids wrong uninstall) _detect_cask_via_caskroom_search() { local app_bundle_name="$1" [[ -z "$app_bundle_name" ]] && return 1 local -a tokens=() local room match token for room in "/opt/homebrew/Caskroom" "/usr/local/Caskroom"; do [[ -d "$room" ]] || continue while IFS= read -r match; do [[ -n "$match" ]] || continue token=$(_extract_cask_token_from_path "$match" 2> /dev/null) || continue [[ -n "$token" ]] && tokens+=("$token") done < <(find "$room" -maxdepth 3 -name "$app_bundle_name" 2> /dev/null) done # Need at least one token ((${#tokens[@]} > 0)) || return 1 # Deduplicate and check count local -a uniq IFS=$'\n' read -r -d '' -a uniq < <(printf '%s\n' "${tokens[@]}" | sort -u && printf '\0') || true # Only succeed if exactly one unique token found and it's installed if ((${#uniq[@]} == 1)) && [[ -n "${uniq[0]}" ]]; then HOMEBREW_NO_ENV_HINTS=1 brew list --cask 2> /dev/null | grep -qxF "${uniq[0]}" || return 1 echo "${uniq[0]}" return 0 fi return 1 } # Stage 3: Check if app_path is a direct symlink to Caskroom _detect_cask_via_symlink_check() { local app_path="$1" [[ -L "$app_path" ]] || return 1 local target target=$(readlink "$app_path" 2> /dev/null) || return 1 _extract_cask_token_from_path "$target" } # Stage 4: Query brew list --cask and verify with brew info (slowest fallback) _detect_cask_via_brew_list() { local app_path="$1" local app_bundle_name="$2" local app_name_lower app_name_lower=$(echo "${app_bundle_name%.app}" | LC_ALL=C tr '[:upper:]' '[:lower:]') local cask_name cask_name=$(HOMEBREW_NO_ENV_HINTS=1 brew list --cask 2> /dev/null | grep -Fix "$app_name_lower") || return 1 # Verify this cask actually owns this app path HOMEBREW_NO_ENV_HINTS=1 brew info --cask "$cask_name" 2> /dev/null | grep -qF "$app_path" || return 1 echo "$cask_name" } # Get Homebrew cask name for an app # Uses multi-stage detection (fast to slow, deterministic to heuristic): # 1. Resolve symlinks fully, check if path is in Caskroom (fast, deterministic) # 2. Search Caskroom by app bundle name using find # 3. Check if app is a direct symlink to Caskroom # 4. Query brew list --cask and verify with brew info (slowest) # # Args: $1 - app_path # Prints: cask token to stdout if brew-managed # Returns: 0 if Homebrew-managed, 1 otherwise get_brew_cask_name() { local app_path="$1" [[ -z "$app_path" || ! -e "$app_path" ]] && return 1 is_homebrew_available || return 1 local app_bundle_name app_bundle_name=$(basename "$app_path") # Try each detection method in order (fast to slow) _detect_cask_via_resolved_path "$app_path" && return 0 _detect_cask_via_caskroom_search "$app_bundle_name" && return 0 _detect_cask_via_symlink_check "$app_path" && return 0 _detect_cask_via_brew_list "$app_path" "$app_bundle_name" && return 0 return 1 } # Uninstall a Homebrew cask and verify removal # Args: $1 - cask_name, $2 - app_path (optional, for verification) # Returns: 0 on success, 1 on failure brew_uninstall_cask() { local cask_name="$1" local app_path="${2:-}" is_homebrew_available || return 1 [[ -z "$cask_name" ]] && return 1 debug_log "Attempting brew uninstall --cask $cask_name" # Ensure we have sudo access if needed, to prevent brew from hanging on password prompt if [[ "${NONINTERACTIVE:-}" != "1" && -t 0 && -t 1 ]]; then if ! sudo -n true 2> /dev/null; then sudo -v fi fi local uninstall_ok=false local brew_exit=0 # Calculate timeout based on app size (large apps need more time) local timeout=300 # Default 5 minutes if [[ -n "$app_path" && -d "$app_path" ]]; then local size_gb=$(($(get_path_size_kb "$app_path") / 1048576)) if [[ $size_gb -gt 15 ]]; then timeout=900 # 15 minutes for very large apps (Xcode, Adobe, etc.) elif [[ $size_gb -gt 5 ]]; then timeout=600 # 10 minutes for large apps fi debug_log "App size: ${size_gb}GB, timeout: ${timeout}s" fi # Run with timeout to prevent hangs from problematic cask scripts local brew_exit=0 if HOMEBREW_NO_ENV_HINTS=1 HOMEBREW_NO_AUTO_UPDATE=1 NONINTERACTIVE=1 \ run_with_timeout "$timeout" brew uninstall --cask "$cask_name" 2>&1; then uninstall_ok=true else brew_exit=$? debug_log "brew uninstall timeout or failed with exit code: $brew_exit" # Exit code 124 indicates timeout from run_with_timeout # On timeout, fail immediately without verification to avoid inconsistent state if [[ $brew_exit -eq 124 ]]; then debug_log "brew uninstall timed out after ${timeout}s, returning failure" return 1 fi fi # Verify removal (only if not timed out) local cask_gone=true app_gone=true HOMEBREW_NO_ENV_HINTS=1 brew list --cask 2> /dev/null | grep -qxF "$cask_name" && cask_gone=false [[ -n "$app_path" && -e "$app_path" ]] && app_gone=false # Success: uninstall worked and both are gone, or already uninstalled if $cask_gone && $app_gone; then debug_log "Successfully uninstalled cask '$cask_name'" return 0 fi debug_log "brew uninstall failed: cask_gone=$cask_gone app_gone=$app_gone" return 1 }