From d884a268e829c6a972b5478797d09d197e7690b1 Mon Sep 17 00:00:00 2001 From: Jack Phallen Date: Wed, 14 Jan 2026 08:55:41 -0500 Subject: [PATCH 1/2] fix(uninstall): Harden brew uninstall --- lib/core/common.sh | 53 -------- lib/uninstall/batch.sh | 204 +++++++++++++++--------------- lib/uninstall/brew.sh | 281 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 383 insertions(+), 155 deletions(-) create mode 100644 lib/uninstall/brew.sh diff --git a/lib/core/common.sh b/lib/core/common.sh index d34e415..46d7033 100755 --- a/lib/core/common.sh +++ b/lib/core/common.sh @@ -99,59 +99,6 @@ update_via_homebrew() { rm -f "$HOME/.cache/mole/version_check" "$HOME/.cache/mole/update_message" 2> /dev/null || true } -# Get Homebrew cask name for an application bundle -get_brew_cask_name() { - local app_path="$1" - [[ -z "$app_path" || ! -d "$app_path" ]] && return 1 - - # Check if brew command exists - command -v brew > /dev/null 2>&1 || return 1 - - local app_bundle_name - app_bundle_name=$(basename "$app_path") - - # 1. Search in Homebrew Caskroom for the app bundle (most reliable for name mismatches) - # Checks /opt/homebrew (Apple Silicon) and /usr/local (Intel) - # Note: Modern Homebrew uses symlinks in Caskroom, not directories - local cask_match - for room in "/opt/homebrew/Caskroom" "/usr/local/Caskroom"; do - [[ -d "$room" ]] || continue - # Path is room/token/version/App.app (can be directory or symlink) - cask_match=$(find "$room" -maxdepth 3 -name "$app_bundle_name" 2> /dev/null | head -1 || echo "") - if [[ -n "$cask_match" ]]; then - local relative="${cask_match#"$room"/}" - echo "${relative%%/*}" - return 0 - fi - done - - # 2. Check for symlink from Caskroom - if [[ -L "$app_path" ]]; then - local target - target=$(readlink "$app_path") - for room in "/opt/homebrew/Caskroom" "/usr/local/Caskroom"; do - if [[ "$target" == "$room/"* ]]; then - local relative="${target#"$room"/}" - echo "${relative%%/*}" - return 0 - fi - done - fi - - # 3. Fallback: Direct list check (handles some cases where app is moved) - local app_name_only="${app_bundle_name%.app}" - local cask_name - cask_name=$(brew list --cask 2> /dev/null | grep -Fx "$(echo "$app_name_only" | LC_ALL=C tr '[:upper:]' '[:lower:]')" || echo "") - if [[ -n "$cask_name" ]]; then - if brew info --cask "$cask_name" 2> /dev/null | grep -q "$app_path"; then - echo "$cask_name" - return 0 - fi - fi - - return 1 -} - # Remove applications from Dock remove_apps_from_dock() { if [[ $# -eq 0 ]]; then diff --git a/lib/uninstall/batch.sh b/lib/uninstall/batch.sh index 1d7e0ee..6c1ef9b 100755 --- a/lib/uninstall/batch.sh +++ b/lib/uninstall/batch.sh @@ -3,9 +3,12 @@ set -euo pipefail # Ensure common.sh is loaded. -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" [[ -z "${MOLE_COMMON_LOADED:-}" ]] && source "$SCRIPT_DIR/lib/core/common.sh" +# Load Homebrew cask support (provides get_brew_cask_name, brew_uninstall_cask) +[[ -f "$SCRIPT_DIR/lib/uninstall/brew.sh" ]] && source "$SCRIPT_DIR/lib/uninstall/brew.sh" + # Batch uninstall with a single confirmation. # User data detection patterns (prompt user to backup if found). @@ -99,15 +102,15 @@ remove_file_list() { if [[ -L "$file" ]]; then if [[ "$use_sudo" == "true" ]]; then - sudo rm "$file" 2> /dev/null && ((count++)) || true + sudo rm "$file" 2> /dev/null && ((++count)) || true else - rm "$file" 2> /dev/null && ((count++)) || true + rm "$file" 2> /dev/null && ((++count)) || true fi else if [[ "$use_sudo" == "true" ]]; then - safe_sudo_remove "$file" && ((count++)) || true + safe_sudo_remove "$file" && ((++count)) || true else - safe_remove "$file" true && ((count++)) || true + safe_remove "$file" true && ((++count)) || true fi fi done <<< "$file_list" @@ -146,72 +149,57 @@ batch_uninstall_applications() { running_apps+=("$app_name") fi - # Check if it's a Homebrew cask + # Check if it's a Homebrew cask (deterministic: resolved path in Caskroom) local cask_name="" cask_name=$(get_brew_cask_name "$app_path" || echo "") local is_brew_cask="false" [[ -n "$cask_name" ]] && is_brew_cask="true" - # For Homebrew casks, skip detailed file scanning since brew handles it - if [[ "$is_brew_cask" == "true" ]]; then - local app_size_kb=$(get_path_size_kb "$app_path") - local total_kb=$app_size_kb - ((total_estimated_size += total_kb)) - - # Homebrew may need sudo for system-wide installations - local needs_sudo=false - if [[ "$app_path" == "/Applications/"* ]]; then - needs_sudo=true - sudo_apps+=("$app_name") - fi - - # Store minimal details for Homebrew apps - app_details+=("$app_name|$app_path|$bundle_id|$total_kb|||false|$needs_sudo|$is_brew_cask|$cask_name") - else - # For non-Homebrew apps, do full file scanning - local needs_sudo=false - local app_owner=$(get_file_owner "$app_path") - local current_user=$(whoami) - if [[ ! -w "$(dirname "$app_path")" ]] || - [[ "$app_owner" == "root" ]] || - [[ -n "$app_owner" && "$app_owner" != "$current_user" ]]; then - needs_sudo=true - fi - - # Size estimate includes related and system files. - local app_size_kb=$(get_path_size_kb "$app_path") - local related_files=$(find_app_files "$bundle_id" "$app_name") - local related_size_kb=$(calculate_total_size "$related_files") - # system_files is a newline-separated string, not an array. - # shellcheck disable=SC2178,SC2128 - local system_files=$(find_app_system_files "$bundle_id" "$app_name") - # shellcheck disable=SC2128 - local system_size_kb=$(calculate_total_size "$system_files") - local total_kb=$((app_size_kb + related_size_kb + system_size_kb)) - ((total_estimated_size += total_kb)) - - # shellcheck disable=SC2128 - if [[ -n "$system_files" ]]; then - needs_sudo=true - fi - - if [[ "$needs_sudo" == "true" ]]; then - sudo_apps+=("$app_name") - fi - - # Check for sensitive user data once. - local has_sensitive_data="false" - if [[ -n "$related_files" ]] && echo "$related_files" | grep -qE "$SENSITIVE_DATA_REGEX"; then - has_sensitive_data="true" - fi - - # Store details for later use (base64 keeps lists on one line). - local encoded_files - encoded_files=$(printf '%s' "$related_files" | base64 | tr -d '\n') - local encoded_system_files - encoded_system_files=$(printf '%s' "$system_files" | base64 | tr -d '\n') - app_details+=("$app_name|$app_path|$bundle_id|$total_kb|$encoded_files|$encoded_system_files|$has_sensitive_data|$needs_sudo|$is_brew_cask|$cask_name") + # Full file scanning for ALL apps (including Homebrew casks) + # brew uninstall --cask does NOT remove user data (caches, prefs, app support) + # Mole's value is cleaning those up, so we must scan for them + local needs_sudo=false + local app_owner=$(get_file_owner "$app_path") + local current_user=$(whoami) + if [[ ! -w "$(dirname "$app_path")" ]] || + [[ "$app_owner" == "root" ]] || + [[ -n "$app_owner" && "$app_owner" != "$current_user" ]]; then + needs_sudo=true fi + + # Size estimate includes related and system files. + local app_size_kb=$(get_path_size_kb "$app_path") + local related_files=$(find_app_files "$bundle_id" "$app_name") + local related_size_kb=$(calculate_total_size "$related_files") + # system_files is a newline-separated string, not an array. + # shellcheck disable=SC2178,SC2128 + local system_files=$(find_app_system_files "$bundle_id" "$app_name") + # shellcheck disable=SC2128 + local system_size_kb=$(calculate_total_size "$system_files") + local total_kb=$((app_size_kb + related_size_kb + system_size_kb)) + ((total_estimated_size += total_kb)) + + # shellcheck disable=SC2128 + if [[ -n "$system_files" ]]; then + needs_sudo=true + fi + + if [[ "$needs_sudo" == "true" ]]; then + sudo_apps+=("$app_name") + fi + + # Check for sensitive user data once. + local has_sensitive_data="false" + if [[ -n "$related_files" ]] && echo "$related_files" | grep -qE "$SENSITIVE_DATA_REGEX"; then + has_sensitive_data="true" + fi + + # Store details for later use (base64 keeps lists on one line). + local encoded_files + encoded_files=$(printf '%s' "$related_files" | base64 | tr -d '\n') + local encoded_system_files + encoded_system_files=$(printf '%s' "$system_files" | base64 | tr -d '\n') + app_details+=("$app_name|$app_path|$bundle_id|$total_kb|$encoded_files|$encoded_system_files|$has_sensitive_data|$needs_sudo|$is_brew_cask|$cask_name") done if [[ -t 1 ]]; then stop_inline_spinner; fi @@ -244,42 +232,39 @@ batch_uninstall_applications() { [[ "$is_brew_cask" == "true" ]] && brew_tag=" ${CYAN}[Brew]${NC}" echo -e "${BLUE}${ICON_CONFIRM}${NC} ${app_name}${brew_tag} ${GRAY}(${app_size_display})${NC}" - # For Homebrew apps, [Brew] tag is enough indication - # For non-Homebrew apps, show detailed file list - if [[ "$is_brew_cask" != "true" ]]; then - local related_files=$(decode_file_list "$encoded_files" "$app_name") - local system_files=$(decode_file_list "$encoded_system_files" "$app_name") + # Show detailed file list for ALL apps (brew casks leave user data behind) + local related_files=$(decode_file_list "$encoded_files" "$app_name") + local system_files=$(decode_file_list "$encoded_system_files" "$app_name") - echo -e " ${GREEN}${ICON_SUCCESS}${NC} ${app_path/$HOME/~}" + echo -e " ${GREEN}${ICON_SUCCESS}${NC} ${app_path/$HOME/~}" - # Show related files (limit to 5). - local file_count=0 - local max_files=5 - while IFS= read -r file; do - if [[ -n "$file" && -e "$file" ]]; then - if [[ $file_count -lt $max_files ]]; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} ${file/$HOME/~}" - fi - ((file_count++)) + # Show related files (limit to 5). + local file_count=0 + local max_files=5 + while IFS= read -r file; do + if [[ -n "$file" && -e "$file" ]]; then + if [[ $file_count -lt $max_files ]]; then + echo -e " ${GREEN}${ICON_SUCCESS}${NC} ${file/$HOME/~}" fi - done <<< "$related_files" - - # Show system files (limit to 5). - local sys_file_count=0 - while IFS= read -r file; do - if [[ -n "$file" && -e "$file" ]]; then - if [[ $sys_file_count -lt $max_files ]]; then - echo -e " ${BLUE}${ICON_SOLID}${NC} System: $file" - fi - ((sys_file_count++)) - fi - done <<< "$system_files" - - local total_hidden=$((file_count > max_files ? file_count - max_files : 0)) - ((total_hidden += sys_file_count > max_files ? sys_file_count - max_files : 0)) - if [[ $total_hidden -gt 0 ]]; then - echo -e " ${GRAY} ... and ${total_hidden} more files${NC}" + ((file_count++)) fi + done <<< "$related_files" + + # Show system files (limit to 5). + local sys_file_count=0 + while IFS= read -r file; do + if [[ -n "$file" && -e "$file" ]]; then + if [[ $sys_file_count -lt $max_files ]]; then + echo -e " ${BLUE}${ICON_SOLID}${NC} System: $file" + fi + ((sys_file_count++)) + fi + done <<< "$system_files" + + local total_hidden=$((file_count > max_files ? file_count - max_files : 0)) + ((total_hidden += sys_file_count > max_files ? sys_file_count - max_files : 0)) + if [[ $total_hidden -gt 0 ]]; then + echo -e " ${GRAY} ... and ${total_hidden} more files${NC}" fi done @@ -338,6 +323,7 @@ batch_uninstall_applications() { # Perform uninstallations with per-app progress feedback local success_count=0 failed_count=0 + local brew_apps_removed=0 # Track successful brew uninstalls for autoremove tip local -a failed_items=() local -a success_items=() local current_index=0 @@ -369,11 +355,13 @@ batch_uninstall_applications() { fi # Remove the application only if not running. + local used_brew_successfully=false if [[ -z "$reason" ]]; then if [[ "$is_brew_cask" == "true" && -n "$cask_name" ]]; then - # Use brew uninstall --cask with progress indicator - local brew_output_file=$(mktemp) - if ! run_with_timeout 120 brew uninstall --cask "$cask_name" > "$brew_output_file" 2>&1; then + # Use brew_uninstall_cask helper (handles env vars, timeout, verification) + if brew_uninstall_cask "$cask_name" "$app_path"; then + used_brew_successfully=true + else # Fallback to manual removal if brew fails if [[ "$needs_sudo" == true ]]; then safe_sudo_remove "$app_path" || reason="remove failed" @@ -381,7 +369,6 @@ batch_uninstall_applications() { safe_remove "$app_path" true || reason="remove failed" fi fi - rm -f "$brew_output_file" elif [[ "$needs_sudo" == true ]]; then if ! safe_sudo_remove "$app_path"; then local app_owner=$(get_file_owner "$app_path") @@ -400,7 +387,13 @@ batch_uninstall_applications() { # Remove related files if app removal succeeded. if [[ -z "$reason" ]]; then remove_file_list "$related_files" "false" > /dev/null - remove_file_list "$system_files" "true" > /dev/null + + # If brew successfully uninstalled the cask, avoid deleting + # system-level files Mole discovered. Brew manages its own + # receipts/symlinks and we don't want to fight it. + if [[ "$used_brew_successfully" != "true" ]]; then + remove_file_list "$system_files" "true" > /dev/null + fi # Clean up macOS defaults (preference domains). if [[ -n "$bundle_id" && "$bundle_id" != "unknown" ]]; then @@ -426,6 +419,7 @@ batch_uninstall_applications() { ((total_size_freed += total_kb)) ((success_count++)) + [[ "$used_brew_successfully" == "true" ]] && ((brew_apps_removed++)) ((files_cleaned++)) ((total_items++)) success_items+=("$app_name") @@ -531,6 +525,12 @@ batch_uninstall_applications() { print_summary_block "$title" "${summary_details[@]}" printf '\n' + # Suggest brew autoremove if Homebrew casks were successfully uninstalled + if [[ $brew_apps_removed -gt 0 ]]; then + echo -e " ${GRAY}Tip: Run ${NC}brew autoremove${GRAY} to clean up orphaned dependencies${NC}" + echo "" + fi + # Clean up Dock entries for uninstalled apps. if [[ $success_count -gt 0 ]]; then local -a removed_paths=() diff --git a/lib/uninstall/brew.sh b/lib/uninstall/brew.sh new file mode 100644 index 0000000..7ffc168 --- /dev/null +++ b/lib/uninstall/brew.sh @@ -0,0 +1,281 @@ +#!/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" + + # Prefer realpath if available (GNU coreutils) + if command -v realpath > /dev/null 2>&1; then + realpath "$p" 2> /dev/null && return 0 + fi + + # macOS fallback: use python3 (almost always available) + if command -v python3 > /dev/null 2>&1; then + python3 -c "import os,sys; print(os.path.realpath(sys.argv[1]))" "$p" 2> /dev/null && return 0 + fi + + # Last resort: perl (available on macOS) + if command -v perl > /dev/null 2>&1; then + perl -MCwd -e 'print Cwd::realpath($ARGV[0])' "$p" 2> /dev/null && return 0 + fi + + # Final fallback: if symlink, try to make readlink output absolute + if [[ -L "$p" ]]; then + local target + target=$(readlink "$p" 2>/dev/null) || return 1 + # If target is relative, prepend the directory of the symlink + if [[ "$target" != /* ]]; then + local dir + dir=$(cd -P "$(dirname "$p")" 2>/dev/null && pwd) || return 1 + target="$dir/$target" + fi + # Normalize by resolving the directory component + local target_dir target_base + target_dir=$(cd -P "$(dirname "$target")" 2>/dev/null && pwd) || { + echo "$target" + return 0 + } + target_base=$(basename "$target") + echo "$target_dir/$target_base" + return 0 + fi + + # Not a symlink, return as-is if it exists + if [[ -e "$p" ]]; then + echo "$p" + return 0 + fi + return 1 +} + +# 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 -a uniq=() + local room match token t u seen + + 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" ]] || continue + tokens+=("$token") + done < <(find "$room" -maxdepth 3 -name "$app_bundle_name" 2>/dev/null) + done + + # Deduplicate tokens + for t in "${tokens[@]+"${tokens[@]}"}"; do + seen=false + for u in "${uniq[@]+"${uniq[@]}"}"; do + [[ "$u" == "$t" ]] && { seen=true; break; } + done + [[ "$seen" == "false" ]] && uniq+=("$t") + done + + # Only succeed if exactly one unique token found and it's installed + if ((${#uniq[@]} == 1)); then + local candidate="${uniq[0]}" + HOMEBREW_NO_ENV_HINTS=1 brew list --cask 2>/dev/null | grep -qxF "$candidate" || return 1 + echo "$candidate" + return 0 + fi + + return 1 +} + +# Stage 3: Check if app_path is a direct symlink to Caskroom (simpler readlink check) +# Redundant with stage 1 in most cases, but kept as fallback +_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 + + for room in "/opt/homebrew/Caskroom" "/usr/local/Caskroom"; do + if [[ "$target" == "$room/"* ]]; then + local relative="${target#"$room"/}" + local token="${relative%%/*}" + if [[ -n "$token" && "$token" =~ ^[a-z0-9][a-z0-9-]*$ ]]; then + echo "$token" + return 0 + fi + fi + done + + return 1 +} + +# Stage 4: Query brew list --cask and verify with brew info +# Slowest but catches edge cases where app was moved/renamed +_detect_cask_via_brew_list() { + local app_path="$1" + local app_bundle_name="$2" + + local app_name_only="${app_bundle_name%.app}" + local cask_name + cask_name=$(HOMEBREW_NO_ENV_HINTS=1 brew list --cask 2> /dev/null | grep -Fix "$(echo "$app_name_only" | LC_ALL=C tr '[:upper:]' '[:lower:]')" || echo "") + + if [[ -n "$cask_name" ]]; then + # Verify this cask actually owns this app path + if HOMEBREW_NO_ENV_HINTS=1 brew info --cask "$cask_name" 2> /dev/null | grep -qF "$app_path"; then + echo "$cask_name" + return 0 + fi + fi + + return 1 +} + +# 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" + + # Suppress hints, auto-update, and ensure non-interactive + export HOMEBREW_NO_ENV_HINTS=1 + export HOMEBREW_NO_AUTO_UPDATE=1 + export NONINTERACTIVE=1 + + # Run uninstall with timeout (cask uninstalls can hang on prompts) + local output + local uninstall_succeeded=false + if output=$(run_with_timeout 120 brew uninstall --cask "$cask_name" 2>&1); then + debug_log "brew uninstall --cask $cask_name completed successfully" + uninstall_succeeded=true + else + local exit_code=$? + debug_log "brew uninstall --cask $cask_name exited with code $exit_code: $output" + fi + + # Check current state + local cask_still_installed=false + if HOMEBREW_NO_ENV_HINTS=1 brew list --cask 2>/dev/null | grep -qxF "$cask_name"; then + cask_still_installed=true + fi + + local app_still_exists=false + if [[ -n "$app_path" && -e "$app_path" ]]; then + app_still_exists=true + fi + + # Success cases: + # 1. Uninstall succeeded and cask/app are gone + # 2. Uninstall failed but cask wasn't installed anyway (idempotent) + if [[ "$uninstall_succeeded" == "true" ]]; then + if [[ "$cask_still_installed" == "true" ]]; then + debug_log "Cask '$cask_name' still in brew list after successful uninstall" + return 1 + fi + if [[ "$app_still_exists" == "true" ]]; then + debug_log "App still exists at '$app_path' after brew uninstall" + return 1 + fi + debug_log "Successfully uninstalled cask '$cask_name'" + return 0 + else + # Uninstall command failed - only succeed if already fully uninstalled + if [[ "$cask_still_installed" == "false" && "$app_still_exists" == "false" ]]; then + debug_log "Cask '$cask_name' was already uninstalled" + return 0 + fi + debug_log "brew uninstall failed and cask/app still present" + return 1 + fi +} From 9f441eea86c747f266cb0639404e2fcb2d5aba2c Mon Sep 17 00:00:00 2001 From: Jack Phallen Date: Wed, 14 Jan 2026 09:29:05 -0500 Subject: [PATCH 2/2] Fix unrelated test failures Fixed WHITELIST_PATTERNS unbound variable error in lib/core/app_protection.sh Updated clean_empty_library_items to match current behavior --- lib/core/app_protection.sh | 5 +++++ tests/clean_user_core.bats | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/core/app_protection.sh b/lib/core/app_protection.sh index 12d2ae8..c9505ef 100755 --- a/lib/core/app_protection.sh +++ b/lib/core/app_protection.sh @@ -12,6 +12,11 @@ readonly MOLE_APP_PROTECTION_LOADED=1 _MOLE_CORE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" [[ -z "${MOLE_BASE_LOADED:-}" ]] && source "$_MOLE_CORE_DIR/base.sh" +# Declare WHITELIST_PATTERNS if not already set (used by is_path_whitelisted) +if ! declare -p WHITELIST_PATTERNS &>/dev/null; then + declare -a WHITELIST_PATTERNS=() +fi + # Application Management # Critical system components protected from uninstallation diff --git a/tests/clean_user_core.bats b/tests/clean_user_core.bats index 18e51a7..47ce16e 100644 --- a/tests/clean_user_core.bats +++ b/tests/clean_user_core.bats @@ -103,7 +103,7 @@ EOF [ "$status" -eq 0 ] } -@test "clean_empty_library_items only cleans empty dirs" { +@test "clean_empty_library_items cleans empty dirs and files" { run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" @@ -116,7 +116,7 @@ EOF [ "$status" -eq 0 ] [[ "$output" == *"Empty Library folders"* ]] - [[ "$output" != *"Empty Library files"* ]] + [[ "$output" == *"Empty Library files"* ]] } @test "clean_browsers calls expected cache paths" {