mirror of
https://github.com/tw93/Mole.git
synced 2026-02-04 15:39:42 +00:00
Merge pull request #314 from JackPhallen/fix/harden-brew-uninstall
fix(uninstall): Harden brew uninstall
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
@@ -133,15 +136,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"
|
||||
@@ -180,72 +183,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
|
||||
|
||||
@@ -278,42 +266,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
|
||||
|
||||
@@ -372,6 +357,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
|
||||
@@ -406,24 +392,14 @@ 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
|
||||
# Stop spinner before brew output
|
||||
if [[ -t 1 ]]; then
|
||||
stop_inline_spinner
|
||||
fi
|
||||
|
||||
# Use brew uninstall --cask - show output directly
|
||||
local brew_failed=false
|
||||
if ! run_with_timeout 120 brew uninstall --cask "$cask_name" 2>&1; then
|
||||
brew_failed=true
|
||||
log_warning "brew uninstall failed for $app_name, falling back to manual cleanup"
|
||||
fi
|
||||
|
||||
if [[ "$brew_failed" == "true" ]]; then
|
||||
# Fallback to manual cleanup
|
||||
[[ -z "$related_files" ]] && related_files=$(find_app_files "$bundle_id" "$app_name")
|
||||
[[ -z "$system_files" ]] && system_files=$(find_app_system_files "$bundle_id" "$app_name")
|
||||
# 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"
|
||||
else
|
||||
@@ -448,7 +424,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
|
||||
@@ -474,6 +456,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")
|
||||
@@ -579,6 +562,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=()
|
||||
|
||||
281
lib/uninstall/brew.sh
Normal file
281
lib/uninstall/brew.sh
Normal file
@@ -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/<token>/<version>/...
|
||||
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
|
||||
}
|
||||
@@ -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"
|
||||
@@ -117,7 +117,7 @@ EOF
|
||||
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"Empty Library folders"* ]]
|
||||
[[ "$output" != *"Empty Library files"* ]]
|
||||
[[ "$output" == *"Empty Library files"* ]]
|
||||
}
|
||||
|
||||
@test "clean_browsers calls expected cache paths" {
|
||||
|
||||
Reference in New Issue
Block a user