From d884a268e829c6a972b5478797d09d197e7690b1 Mon Sep 17 00:00:00 2001 From: Jack Phallen Date: Wed, 14 Jan 2026 08:55:41 -0500 Subject: [PATCH 01/21] 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 02/21] 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" { From f8cb96d32832baccf7ba8380d707b876b989c53e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Thu, 15 Jan 2026 13:26:06 +0800 Subject: [PATCH 03/21] fix: resolve password input issue with special characters Remove -icanon mode from stty settings to fix password authentication failures when passwords contain special characters like '.' or '@'. The non-canonical mode (-icanon min 1 time 0) caused character loss in Terminal.app. Using only -echo keeps canonical mode which provides more reliable character handling across all terminal emulators. --- lib/core/sudo.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/core/sudo.sh b/lib/core/sudo.sh index 57b80b4..31011c6 100644 --- a/lib/core/sudo.sh +++ b/lib/core/sudo.sh @@ -66,11 +66,11 @@ _request_password() { printf "${PURPLE}${ICON_ARROW}${NC} Password: " > "$tty_path" - # Disable terminal echo to hide password input - stty -echo -icanon min 1 time 0 < "$tty_path" 2> /dev/null || true + # Disable terminal echo to hide password input (keep canonical mode for reliable input) + stty -echo < "$tty_path" 2> /dev/null || true IFS= read -r password < "$tty_path" || password="" # Restore terminal echo immediately - stty echo icanon < "$tty_path" 2> /dev/null || true + stty echo < "$tty_path" 2> /dev/null || true printf "\n" > "$tty_path" From dbf036fdaa1149a6278252166c9e1f198c9fe7cb Mon Sep 17 00:00:00 2001 From: Tw93 Date: Thu, 15 Jan 2026 14:05:42 +0800 Subject: [PATCH 04/21] refactor: simplify brew.sh with native macOS tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - resolve_path: use realpath (macOS 12.3+) instead of python3/perl - deduplicate tokens with sort -u instead of manual loop - reuse _extract_cask_token_from_path in symlink check - simplify brew_uninstall_cask boolean logic Reduces 88 lines (281 → 193) --- lib/uninstall/brew.sh | 166 ++++++++++-------------------------------- 1 file changed, 39 insertions(+), 127 deletions(-) diff --git a/lib/uninstall/brew.sh b/lib/uninstall/brew.sh index 7ffc168..d526e05 100644 --- a/lib/uninstall/brew.sh +++ b/lib/uninstall/brew.sh @@ -15,49 +15,18 @@ readonly MOLE_BREW_UNINSTALL_LOADED=1 # Returns: Absolute resolved path, or empty string on failure resolve_path() { local p="$1" + [[ -e "$p" ]] || return 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" + # macOS 12.3+ and Linux have realpath + if realpath "$p" 2>/dev/null; then return 0 fi - # Not a symlink, return as-is if it exists - if [[ -e "$p" ]]; then - echo "$p" - return 0 - fi - return 1 + # 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 @@ -112,81 +81,54 @@ _detect_cask_via_caskroom_search() { [[ -z "$app_bundle_name" ]] && return 1 local -a tokens=() - local -a uniq=() - local room match token t u seen + 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" ]] || continue - tokens+=("$token") + [[ -n "$token" ]] && 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 + # 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)); then - local candidate="${uniq[0]}" - HOMEBREW_NO_ENV_HINTS=1 brew list --cask 2>/dev/null | grep -qxF "$candidate" || return 1 - echo "$candidate" + 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 (simpler readlink check) -# Redundant with stage 1 in most cases, but kept as fallback +# 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 - - 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 + _extract_cask_token_from_path "$target" } -# Stage 4: Query brew list --cask and verify with brew info -# Slowest but catches edge cases where app was moved/renamed +# 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 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 "") + cask_name=$(HOMEBREW_NO_ENV_HINTS=1 brew list --cask 2>/dev/null | grep -Fix "$app_name_lower") || return 1 - 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 + # 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 @@ -228,54 +170,24 @@ brew_uninstall_cask() { 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" + # Run uninstall with timeout (suppress hints/auto-update) + local uninstall_ok=false + if HOMEBREW_NO_ENV_HINTS=1 HOMEBREW_NO_AUTO_UPDATE=1 NONINTERACTIVE=1 \ + run_with_timeout 120 brew uninstall --cask "$cask_name" 2>&1; then + uninstall_ok=true 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 + # Verify removal + 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 - 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 + # 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 - 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 + + debug_log "brew uninstall failed: cask_gone=$cask_gone app_gone=$app_gone" + return 1 } From 7b14a3abd8b592517da804a755265edf3be6f5e0 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Thu, 15 Jan 2026 14:31:36 +0800 Subject: [PATCH 05/21] feat(uninstall): enhance brew UX and auto-cleanup dependencies - Auto-run 'brew autoremove' after uninstalling casks - Fix spinner interference during brew operations - Add safety check for cask token detection --- lib/uninstall/batch.sh | 14 +++++++++++--- lib/uninstall/brew.sh | 3 +++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/lib/uninstall/batch.sh b/lib/uninstall/batch.sh index 55555ad..bca31ea 100755 --- a/lib/uninstall/batch.sh +++ b/lib/uninstall/batch.sh @@ -395,6 +395,8 @@ batch_uninstall_applications() { local used_brew_successfully=false if [[ -z "$reason" ]]; then if [[ "$is_brew_cask" == "true" && -n "$cask_name" ]]; then + # Stop spinner before brew output + [[ -t 1 ]] && stop_inline_spinner # Use brew_uninstall_cask helper (handles env vars, timeout, verification) if brew_uninstall_cask "$cask_name" "$app_path"; then used_brew_successfully=true @@ -562,10 +564,16 @@ batch_uninstall_applications() { print_summary_block "$title" "${summary_details[@]}" printf '\n' - # Suggest brew autoremove if Homebrew casks were successfully uninstalled + # Auto-run brew autoremove if Homebrew casks were uninstalled if [[ $brew_apps_removed -gt 0 ]]; then - echo -e " ${GRAY}Tip: Run ${NC}brew autoremove${GRAY} to clean up orphaned dependencies${NC}" - echo "" + local autoremove_output removed_count + autoremove_output=$(HOMEBREW_NO_ENV_HINTS=1 brew autoremove 2>/dev/null) || true + removed_count=$(printf '%s\n' "$autoremove_output" | grep -c "^Uninstalling" || true) + removed_count=${removed_count:-0} + if [[ $removed_count -gt 0 ]]; then + echo -e "${GREEN}${ICON_SUCCESS}${NC} Cleaned $removed_count orphaned brew dependencies" + echo "" + fi fi # Clean up Dock entries for uninstalled apps. diff --git a/lib/uninstall/brew.sh b/lib/uninstall/brew.sh index d526e05..1cecdbf 100644 --- a/lib/uninstall/brew.sh +++ b/lib/uninstall/brew.sh @@ -92,6 +92,9 @@ _detect_cask_via_caskroom_search() { 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 From 30547c9c4c6a36b0b2c217c2343d757b358b7af8 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Thu, 15 Jan 2026 15:13:51 +0800 Subject: [PATCH 06/21] refactor(uninstall): enhance login item removal and brew UI - Escape quotes/backslashes in app names for AppleScript safety - Silence osascript stdout to prevent output noise - Capture brew uninstall output to avoid spinner corruption - Log brew errors to debug_log for troubleshooting --- lib/uninstall/batch.sh | 8 ++++++-- lib/uninstall/brew.sh | 7 +++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/lib/uninstall/batch.sh b/lib/uninstall/batch.sh index 7e914ac..cf98629 100755 --- a/lib/uninstall/batch.sh +++ b/lib/uninstall/batch.sh @@ -104,7 +104,11 @@ remove_login_item() { # Remove from Login Items using index-based deletion (handles broken items) if [[ -n "$clean_name" ]]; then - osascript <<- EOF 2> /dev/null || true + # Escape double quotes and backslashes for AppleScript + local escaped_name="${clean_name//\\/\\\\}" + escaped_name="${escaped_name//\"/\\\"}" + + osascript <<- EOF > /dev/null 2>&1 || true tell application "System Events" try set itemCount to count of login items @@ -112,7 +116,7 @@ remove_login_item() { repeat with i from itemCount to 1 by -1 try set itemName to name of login item i - if itemName is "$clean_name" then + if itemName is "$escaped_name" then delete login item i end if end try diff --git a/lib/uninstall/brew.sh b/lib/uninstall/brew.sh index 1cecdbf..a749e6a 100644 --- a/lib/uninstall/brew.sh +++ b/lib/uninstall/brew.sh @@ -175,9 +175,12 @@ brew_uninstall_cask() { # Run uninstall with timeout (suppress hints/auto-update) local uninstall_ok=false - if HOMEBREW_NO_ENV_HINTS=1 HOMEBREW_NO_AUTO_UPDATE=1 NONINTERACTIVE=1 \ - run_with_timeout 120 brew uninstall --cask "$cask_name" 2>&1; then + local output + if output=$(HOMEBREW_NO_ENV_HINTS=1 HOMEBREW_NO_AUTO_UPDATE=1 NONINTERACTIVE=1 \ + run_with_timeout 120 brew uninstall --cask "$cask_name" 2>&1); then uninstall_ok=true + else + debug_log "brew uninstall output: $output" fi # Verify removal From 7dc854cf30d2a2f2d8a38ac979ade42cba75f9c2 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Thu, 15 Jan 2026 21:01:11 +0800 Subject: [PATCH 07/21] fix(uninstall): enhance receipt file processing safety and prevent system file deletion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CRITICAL SECURITY FIX Enhanced the receipt file parsing in uninstall operations to prevent accidental deletion of critical system files while maintaining deep cleanup capabilities. Changes: - Tightened whitelist in find_app_receipt_files() to exclude /Users/*, /usr/*, and /opt/* broad patterns - Added explicit blacklist for /private/* with safe exceptions for logs, temp files, and diagnostic data - Integrated should_protect_path() check for additional protection - Added file deduplication with sort -u to prevent duplicate deletions - Removed dry-run feature from batch uninstall (unused entry point) Path Protection: ✅ Blocked: /etc/passwd, /var/db/*, /private/etc/*, all system binaries ✅ Allowed: /Applications/*, specific /Library/* subdirs, safe /private/* paths ✅ Additional: Keychain files, system preferences via should_protect_path() This fixes a critical security issue where parsing .bom receipt files could result in deletion of system files like /etc/passwd and /var/db/*, leading to system corruption and data loss. Affects: V1.12.14 and later versions Testing: Validated against critical system paths, all blocked correctly --- lib/core/app_protection.sh | 70 ++++++++++++++++++++++---------------- lib/core/file_ops.sh | 38 ++++++++++++++++++++- lib/uninstall/batch.sh | 4 +++ 3 files changed, 82 insertions(+), 30 deletions(-) diff --git a/lib/core/app_protection.sh b/lib/core/app_protection.sh index f68c57a..4770bad 100755 --- a/lib/core/app_protection.sh +++ b/lib/core/app_protection.sh @@ -877,12 +877,24 @@ find_app_system_files() { done < <(command find /private/var/db/receipts -maxdepth 1 \( -name "*$bundle_id*" \) -print0 2> /dev/null) fi + local receipt_files="" + receipt_files=$(find_app_receipt_files "$bundle_id") + + local combined_files="" if [[ ${#system_files[@]} -gt 0 ]]; then - printf '%s\n' "${system_files[@]}" + combined_files=$(printf '%s\n' "${system_files[@]}") fi - # Find files from receipts (Deep Scan) - find_app_receipt_files "$bundle_id" + if [[ -n "$receipt_files" ]]; then + if [[ -n "$combined_files" ]]; then + combined_files+=$'\n' + fi + combined_files+="$receipt_files" + fi + + if [[ -n "$combined_files" ]]; then + printf '%s\n' "$combined_files" | sort -u + fi } # Locate files using installation receipts (BOM) @@ -928,44 +940,44 @@ find_app_receipt_files() { # ------------------------------------------------------------------------ local is_safe=false - # Whitelisted prefixes + # Whitelisted prefixes (exclude /Users, /usr, /opt) case "$clean_path" in /Applications/*) is_safe=true ;; - /Users/*) is_safe=true ;; - /usr/local/*) is_safe=true ;; - /opt/*) is_safe=true ;; - /Library/*) - # Filter sub-paths in /Library to avoid system damage - # Allow safely: Application Support, Caches, Logs, Preferences - case "$clean_path" in - /Library/Application\ Support/*) is_safe=true ;; - /Library/Caches/*) is_safe=true ;; - /Library/Logs/*) is_safe=true ;; - /Library/Preferences/*) is_safe=true ;; - /Library/PrivilegedHelperTools/*) is_safe=true ;; - /Library/LaunchAgents/*) is_safe=true ;; - /Library/LaunchDaemons/*) is_safe=true ;; - /Library/Internet\ Plug-Ins/*) is_safe=true ;; - /Library/Audio/Plug-Ins/*) is_safe=true ;; - /Library/Extensions/*) is_safe=false ;; # Default unsafe - *) is_safe=false ;; - esac - ;; + /Library/Application\ Support/*) is_safe=true ;; + /Library/Caches/*) is_safe=true ;; + /Library/Logs/*) is_safe=true ;; + /Library/Preferences/*) is_safe=true ;; + /Library/LaunchAgents/*) is_safe=true ;; + /Library/LaunchDaemons/*) is_safe=true ;; + /Library/PrivilegedHelperTools/*) is_safe=true ;; + /Library/Internet\ Plug-Ins/*) is_safe=true ;; + /Library/Audio/Plug-Ins/*) is_safe=true ;; + /Library/Frameworks/*) is_safe=true ;; + /Library/Input\ Methods/*) is_safe=true ;; + /Library/QuickLook/*) is_safe=true ;; + /Library/PreferencePanes/*) is_safe=true ;; + /Library/Screen\ Savers/*) is_safe=true ;; + /Library/Extensions/*) is_safe=false ;; + *) is_safe=false ;; esac # Hard blocks case "$clean_path" in - /System/* | /usr/bin/* | /usr/lib/* | /bin/* | /sbin/*) is_safe=false ;; + /System/* | /usr/bin/* | /usr/lib/* | /bin/* | /sbin/* | /private/*) is_safe=false ;; esac if [[ "$is_safe" == "true" && -e "$clean_path" ]]; then - # If lsbom lists /Applications, skip to avoid system damage. - # Extra check: path must be deep enough? - # If path is just "/Applications", skip. - if [[ "$clean_path" == "/Applications" || "$clean_path" == "/Library" || "$clean_path" == "/usr/local" ]]; then + # Skip top-level directories + if [[ "$clean_path" == "/Applications" || "$clean_path" == "/Library" ]]; then continue fi + if declare -f should_protect_path > /dev/null 2>&1; then + if should_protect_path "$clean_path"; then + continue + fi + fi + receipt_files+=("$clean_path") fi diff --git a/lib/core/file_ops.sh b/lib/core/file_ops.sh index 4fb03a7..9fdd65f 100644 --- a/lib/core/file_ops.sh +++ b/lib/core/file_ops.sh @@ -66,14 +66,50 @@ validate_path_for_deletion() { ;; esac + # Allow known safe paths under /private + case "$path" in + /private/tmp | /private/tmp/* | \ + /private/var/tmp | /private/var/tmp/* | \ + /private/var/log | /private/var/log/* | \ + /private/var/folders | /private/var/folders/* | \ + /private/var/db/diagnostics | /private/var/db/diagnostics/* | \ + /private/var/db/DiagnosticPipeline | /private/var/db/DiagnosticPipeline/* | \ + /private/var/db/powerlog | /private/var/db/powerlog/* | \ + /private/var/db/reportmemoryexception | /private/var/db/reportmemoryexception/*) + return 0 + ;; + esac + # Check path isn't critical system directory case "$path" in - / | /bin | /sbin | /usr | /usr/bin | /usr/sbin | /etc | /var | /System | /System/* | /Library/Extensions) + / | /bin | /bin/* | /sbin | /sbin/* | /usr | /usr/bin | /usr/bin/* | /usr/sbin | /usr/sbin/* | /usr/lib | /usr/lib/* | /System | /System/* | /Library/Extensions) log_error "Path validation failed: critical system directory: $path" return 1 ;; + /private) + log_error "Path validation failed: critical system directory: $path" + return 1 + ;; + /etc | /etc/* | /private/etc | /private/etc/*) + log_error "Path validation failed: /etc contains critical system files: $path" + return 1 + ;; + /var | /var/db | /var/db/* | /private/var | /private/var/db | /private/var/db/*) + log_error "Path validation failed: /var/db contains system databases: $path" + return 1 + ;; esac + # Check if path is protected (keychains, system settings, etc) + if declare -f should_protect_path > /dev/null 2>&1; then + if should_protect_path "$path"; then + if [[ "${MO_DEBUG:-0}" == "1" ]]; then + log_warning "Path validation: protected path skipped: $path" + fi + return 1 + fi + fi + return 0 } diff --git a/lib/uninstall/batch.sh b/lib/uninstall/batch.sh index cf98629..8ee9b69 100755 --- a/lib/uninstall/batch.sh +++ b/lib/uninstall/batch.sh @@ -136,6 +136,10 @@ remove_file_list() { while IFS= read -r file; do [[ -n "$file" && -e "$file" ]] || continue + if ! validate_path_for_deletion "$file"; then + continue + fi + if [[ -L "$file" ]]; then if [[ "$use_sudo" == "true" ]]; then sudo rm "$file" 2> /dev/null && ((++count)) || true From 2cecb881a987957c579c95dd673ca67d4f4f0b7d Mon Sep 17 00:00:00 2001 From: Tw93 Date: Thu, 15 Jan 2026 21:02:13 +0800 Subject: [PATCH 08/21] docs: update SECURITY_AUDIT for receipt processing safety - Document /private path exceptions for safe cleanup - Add receipt file filtering details - Auto-format shell scripts (shellcheck) --- SECURITY_AUDIT.md | 8 +++++++- lib/core/app_protection.sh | 2 +- lib/uninstall/batch.sh | 4 ++-- lib/uninstall/brew.sh | 18 +++++++++--------- 4 files changed, 19 insertions(+), 13 deletions(-) diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md index 4aa089c..7bec259 100644 --- a/SECURITY_AUDIT.md +++ b/SECURITY_AUDIT.md @@ -93,9 +93,14 @@ Even with `sudo`, these paths are **unconditionally blocked**: /bin, /sbin, /usr # Core binaries /etc, /var # System configuration /Library/Extensions # Kernel extensions +/private # System-private directories ``` -**Exception:** `/System/Library/Caches/com.apple.coresymbolicationd/data` (safe, rebuildable cache). +**Exceptions:** + +- `/System/Library/Caches/com.apple.coresymbolicationd/data` (safe, rebuildable cache) +- `/private/tmp`, `/private/var/tmp`, `/private/var/log`, `/private/var/folders` +- `/private/var/db/diagnostics`, `/private/var/db/DiagnosticPipeline`, `/private/var/db/powerlog`, `/private/var/db/reportmemoryexception` **Code:** `lib/core/file_ops.sh:60-78` @@ -161,6 +166,7 @@ For user-selected app removal: - **Safety Limit:** 3-char minimum (prevents "Go" matching "Google") - **Disabled:** Fuzzy matching and wildcard expansion for short names. - **User Confirmation:** Required before deletion. +- **Receipt Scans:** BOM-derived files are limited to safe system prefixes and filtered by `should_protect_path()`. **Code:** `lib/clean/apps.sh:uninstall_app()` diff --git a/lib/core/app_protection.sh b/lib/core/app_protection.sh index 4770bad..98b753a 100755 --- a/lib/core/app_protection.sh +++ b/lib/core/app_protection.sh @@ -13,7 +13,7 @@ _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 +if ! declare -p WHITELIST_PATTERNS &> /dev/null; then declare -a WHITELIST_PATTERNS=() fi diff --git a/lib/uninstall/batch.sh b/lib/uninstall/batch.sh index 8ee9b69..843af7f 100755 --- a/lib/uninstall/batch.sh +++ b/lib/uninstall/batch.sh @@ -363,7 +363,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 brew_apps_removed=0 # Track successful brew uninstalls for autoremove tip local -a failed_items=() local -a success_items=() local current_index=0 @@ -573,7 +573,7 @@ batch_uninstall_applications() { # Auto-run brew autoremove if Homebrew casks were uninstalled if [[ $brew_apps_removed -gt 0 ]]; then local autoremove_output removed_count - autoremove_output=$(HOMEBREW_NO_ENV_HINTS=1 brew autoremove 2>/dev/null) || true + autoremove_output=$(HOMEBREW_NO_ENV_HINTS=1 brew autoremove 2> /dev/null) || true removed_count=$(printf '%s\n' "$autoremove_output" | grep -c "^Uninstalling" || true) removed_count=${removed_count:-0} if [[ $removed_count -gt 0 ]]; then diff --git a/lib/uninstall/brew.sh b/lib/uninstall/brew.sh index a749e6a..3811079 100644 --- a/lib/uninstall/brew.sh +++ b/lib/uninstall/brew.sh @@ -18,13 +18,13 @@ resolve_path() { [[ -e "$p" ]] || return 1 # macOS 12.3+ and Linux have realpath - if realpath "$p" 2>/dev/null; then + 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 + dir=$(cd -P "$(dirname "$p")" 2> /dev/null && pwd) || return 1 base=$(basename "$p") echo "$dir/$base" } @@ -87,9 +87,9 @@ _detect_cask_via_caskroom_search() { [[ -d "$room" ]] || continue while IFS= read -r match; do [[ -n "$match" ]] || continue - token=$(_extract_cask_token_from_path "$match" 2>/dev/null) || 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 < <(find "$room" -maxdepth 3 -name "$app_bundle_name" 2> /dev/null) done # Need at least one token @@ -101,7 +101,7 @@ _detect_cask_via_caskroom_search() { # 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 + HOMEBREW_NO_ENV_HINTS=1 brew list --cask 2> /dev/null | grep -qxF "${uniq[0]}" || return 1 echo "${uniq[0]}" return 0 fi @@ -115,7 +115,7 @@ _detect_cask_via_symlink_check() { [[ -L "$app_path" ]] || return 1 local target - target=$(readlink "$app_path" 2>/dev/null) || return 1 + target=$(readlink "$app_path" 2> /dev/null) || return 1 _extract_cask_token_from_path "$target" } @@ -127,10 +127,10 @@ _detect_cask_via_brew_list() { 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 + 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 + HOMEBREW_NO_ENV_HINTS=1 brew info --cask "$cask_name" 2> /dev/null | grep -qF "$app_path" || return 1 echo "$cask_name" } @@ -185,7 +185,7 @@ brew_uninstall_cask() { # Verify removal 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 + 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 From c9b110b8829cda8c0fc7d0f0f836a5825162322d Mon Sep 17 00:00:00 2001 From: Tw93 Date: Thu, 15 Jan 2026 21:03:54 +0800 Subject: [PATCH 09/21] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=20pnpm=20store?= =?UTF-8?q?=20=E9=BB=98=E8=AE=A4=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 pnpm store 路径从错误的 ~/.pnpm-store 改为正确的 ~/Library/pnpm/store - macOS 上 pnpm 的默认 store 位置是 ~/Library/pnpm/store - Fixes #319 --- lib/manage/whitelist.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/manage/whitelist.sh b/lib/manage/whitelist.sh index e648e9e..23f5d91 100755 --- a/lib/manage/whitelist.sh +++ b/lib/manage/whitelist.sh @@ -122,7 +122,7 @@ uv Python package cache|$HOME/.cache/uv/*|package_manager R renv global cache (virtual environments)|$HOME/Library/Caches/org.R-project.R/R/renv/*|package_manager Homebrew downloaded packages|$HOME/Library/Caches/Homebrew/*|package_manager Yarn package manager cache|$HOME/.cache/yarn/*|package_manager -pnpm package store|$HOME/.pnpm-store/*|package_manager +pnpm package store|$HOME/Library/pnpm/store/*|package_manager Composer PHP dependencies cache|$HOME/.composer/cache/*|package_manager RubyGems cache|$HOME/.gem/cache/*|package_manager Conda packages cache|$HOME/.conda/pkgs/*|package_manager From c2eb73b213afeb38a4b7d0ee7e877342117d3984 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Thu, 15 Jan 2026 21:03:54 +0800 Subject: [PATCH 10/21] fix: correct pnpm store default path - Change pnpm store path from incorrect ~/.pnpm-store to correct ~/Library/pnpm/store - The default pnpm store location on macOS is ~/Library/pnpm/store - Fixes #319 --- lib/manage/whitelist.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/manage/whitelist.sh b/lib/manage/whitelist.sh index e648e9e..23f5d91 100755 --- a/lib/manage/whitelist.sh +++ b/lib/manage/whitelist.sh @@ -122,7 +122,7 @@ uv Python package cache|$HOME/.cache/uv/*|package_manager R renv global cache (virtual environments)|$HOME/Library/Caches/org.R-project.R/R/renv/*|package_manager Homebrew downloaded packages|$HOME/Library/Caches/Homebrew/*|package_manager Yarn package manager cache|$HOME/.cache/yarn/*|package_manager -pnpm package store|$HOME/.pnpm-store/*|package_manager +pnpm package store|$HOME/Library/pnpm/store/*|package_manager Composer PHP dependencies cache|$HOME/.composer/cache/*|package_manager RubyGems cache|$HOME/.gem/cache/*|package_manager Conda packages cache|$HOME/.conda/pkgs/*|package_manager From 377f777184d9c8618cda6caaeac053908ed213cd Mon Sep 17 00:00:00 2001 From: Tw93 Date: Thu, 15 Jan 2026 21:06:59 +0800 Subject: [PATCH 11/21] test: remove outdated empty files assertion from clean_empty_library_items test The clean_empty_library_items function intentionally skips empty file cleanup to avoid removing app sentinel files (as noted in lib/clean/user.sh:77). Updated the test to only verify empty folder cleanup, matching current behavior. --- tests/clean_user_core.bats | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/clean_user_core.bats b/tests/clean_user_core.bats index 89867f8..dd918d8 100644 --- a/tests/clean_user_core.bats +++ b/tests/clean_user_core.bats @@ -117,7 +117,6 @@ EOF [ "$status" -eq 0 ] [[ "$output" == *"Empty Library folders"* ]] - [[ "$output" == *"Empty Library files"* ]] } @test "clean_browsers calls expected cache paths" { From eb717e558ebf7310b392ef2b91a0ed70d7492b88 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Thu, 15 Jan 2026 21:11:34 +0800 Subject: [PATCH 12/21] test: source brew uninstall helpers --- tests/brew_uninstall.bats | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/brew_uninstall.bats b/tests/brew_uninstall.bats index ea1ffef..ecd880b 100644 --- a/tests/brew_uninstall.bats +++ b/tests/brew_uninstall.bats @@ -31,6 +31,7 @@ setup() { run bash < Date: Thu, 15 Jan 2026 21:24:38 +0800 Subject: [PATCH 13/21] fix: remove insecure empty folder cleanup logic to prevent critical data loss (#320) - Removes clean_empty_library_items functionality that incorrectly deleted critical paths (e.g., Postgres data, Steam locks) - Cleans up associated tests and unnecessary protection rules - Ensures empty folders are preserved by default for safety --- lib/clean/user.sh | 62 +-------------------------- tests/clean_apps.bats | 1 + tests/clean_core.bats | 87 -------------------------------------- tests/clean_user_core.bats | 14 ------ 4 files changed, 2 insertions(+), 162 deletions(-) diff --git a/lib/clean/user.sh b/lib/clean/user.sh index 82d2b6d..6826755 100644 --- a/lib/clean/user.sh +++ b/lib/clean/user.sh @@ -5,9 +5,7 @@ clean_user_essentials() { start_section_spinner "Scanning caches..." safe_clean ~/Library/Caches/* "User app cache" stop_section_spinner - start_section_spinner "Scanning empty items..." - clean_empty_library_items - stop_section_spinner + safe_clean ~/Library/Logs/* "User app logs" if is_path_whitelisted "$HOME/.Trash"; then note_activity @@ -17,65 +15,7 @@ clean_user_essentials() { fi } -clean_empty_library_items() { - if [[ ! -d "$HOME/Library" ]]; then - return 0 - fi - # 1. Clean top-level empty directories and files in Library - local -a empty_dirs=() - while IFS= read -r -d '' dir; do - [[ -d "$dir" ]] && empty_dirs+=("$dir") - done < <(find "$HOME/Library" -mindepth 1 -maxdepth 1 -type d -empty -print0 2> /dev/null) - - if [[ ${#empty_dirs[@]} -gt 0 ]]; then - safe_clean "${empty_dirs[@]}" "Empty Library folders" - fi - - # 2. Clean empty subdirectories in Application Support and other key locations - # Iteratively remove empty directories until no more are found - local -a key_locations=( - "$HOME/Library/Application Support" - "$HOME/Library/Caches" - ) - - for location in "${key_locations[@]}"; do - [[ -d "$location" ]] || continue - - # Limit passes to keep cleanup fast; 3 iterations handle most nested scenarios. - local max_iterations=3 - local iteration=0 - - while [[ $iteration -lt $max_iterations ]]; do - local -a nested_empty_dirs=() - # Find empty directories - while IFS= read -r -d '' dir; do - # Skip if whitelisted - if is_path_whitelisted "$dir"; then - continue - fi - # Skip protected system components - local dir_name=$(basename "$dir") - if is_critical_system_component "$dir_name"; then - continue - fi - [[ -d "$dir" ]] && nested_empty_dirs+=("$dir") - done < <(find "$location" -mindepth 1 -type d -empty -print0 2> /dev/null) - - # If no empty dirs found, we're done with this location - if [[ ${#nested_empty_dirs[@]} -eq 0 ]]; then - break - fi - - local location_name=$(basename "$location") - safe_clean "${nested_empty_dirs[@]}" "Empty $location_name subdirs" - - ((iteration++)) - done - done - - # Empty file cleanup is skipped to avoid removing app sentinel files. -} # Remove old Google Chrome versions while keeping Current. clean_chrome_old_versions() { diff --git a/tests/clean_apps.bats b/tests/clean_apps.bats index 9955117..7533315 100644 --- a/tests/clean_apps.bats +++ b/tests/clean_apps.bats @@ -114,3 +114,4 @@ EOF [ "$status" -eq 0 ] [[ "$output" == "ok" ]] } + diff --git a/tests/clean_core.bats b/tests/clean_core.bats index 9a0c41f..d526d7b 100644 --- a/tests/clean_core.bats +++ b/tests/clean_core.bats @@ -264,91 +264,4 @@ EOF [[ "$output" == *"Time Machine backup in progress, skipping cleanup"* ]] } -@test "clean_empty_library_items removes nested empty directories in Application Support" { - # Create nested empty directory structure - mkdir -p "$HOME/Library/Application Support/UninstalledApp1/SubDir/DeepDir" - mkdir -p "$HOME/Library/Application Support/UninstalledApp2/Cache" - mkdir -p "$HOME/Library/Application Support/ActiveApp/Data" - mkdir -p "$HOME/Library/Caches/EmptyCache/SubCache" - # Create a file in ActiveApp to make it non-empty - touch "$HOME/Library/Application Support/ActiveApp/Data/config.json" - - # Create top-level empty directory in Library - mkdir -p "$HOME/Library/EmptyTopLevel" - - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/clean/user.sh" - -# Mock dependencies -is_path_whitelisted() { return 1; } -is_critical_system_component() { return 1; } -bytes_to_human() { echo "$1"; } -note_activity() { :; } -safe_clean() { - # Actually remove the directories for testing - for path in "$@"; do - if [ "$path" != "${@: -1}" ]; then # Skip the description (last arg) - rm -rf "$path" 2>/dev/null || true - fi - done -} - -clean_empty_library_items -EOF - - [ "$status" -eq 0 ] - - # Empty nested dirs should be removed - [ ! -d "$HOME/Library/Application Support/UninstalledApp1" ] - [ ! -d "$HOME/Library/Application Support/UninstalledApp2" ] - [ ! -d "$HOME/Library/Caches/EmptyCache" ] - [ ! -d "$HOME/Library/EmptyTopLevel" ] - - # Non-empty directory should remain - [ -d "$HOME/Library/Application Support/ActiveApp" ] - [ -f "$HOME/Library/Application Support/ActiveApp/Data/config.json" ] -} - -@test "clean_empty_library_items respects whitelist for empty directories" { - mkdir -p "$HOME/Library/Application Support/ProtectedEmptyApp" - mkdir -p "$HOME/Library/Application Support/UnprotectedEmptyApp" - mkdir -p "$HOME/.config/mole" - - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/clean/user.sh" - -# Mock dependencies -is_critical_system_component() { return 1; } -bytes_to_human() { echo "$1"; } -note_activity() { :; } - -# Mock whitelist to protect ProtectedEmptyApp -is_path_whitelisted() { - [[ "$1" == *"ProtectedEmptyApp"* ]] -} - -safe_clean() { - # Actually remove the directories for testing - for path in "$@"; do - if [ "$path" != "${@: -1}" ]; then # Skip the description (last arg) - rm -rf "$path" 2>/dev/null || true - fi - done -} - -clean_empty_library_items -EOF - - [ "$status" -eq 0 ] - - # Whitelisted directory should remain even if empty - [ -d "$HOME/Library/Application Support/ProtectedEmptyApp" ] - - # Non-whitelisted directory should be removed - [ ! -d "$HOME/Library/Application Support/UnprotectedEmptyApp" ] -} diff --git a/tests/clean_user_core.bats b/tests/clean_user_core.bats index dd918d8..d2675d8 100644 --- a/tests/clean_user_core.bats +++ b/tests/clean_user_core.bats @@ -103,21 +103,7 @@ EOF [ "$status" -eq 0 ] } -@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" -source "$PROJECT_ROOT/lib/clean/user.sh" -safe_clean() { echo "$2"; } -WHITELIST_PATTERNS=() -mkdir -p "$HOME/Library/EmptyDir" -touch "$HOME/Library/empty.txt" -clean_empty_library_items -EOF - [ "$status" -eq 0 ] - [[ "$output" == *"Empty Library folders"* ]] -} @test "clean_browsers calls expected cache paths" { run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' From 06342de24f910f55cd9ae3326f9805cd3f9e9e79 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Fri, 16 Jan 2026 09:54:36 +0800 Subject: [PATCH 14/21] security: restrict BOM whitelist to prevent shared component deletion - Removes shared directories (Frameworks, Plugins, etc) from receipt scanning whitelist - Ensures that uninstalling an app won't accidentally delete shared system libraries - Updates SECURITY_AUDIT.md to reflect stricter receipt scanning policy --- SECURITY_AUDIT.md | 2 +- lib/core/app_protection.sh | 7 ------- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md index 7bec259..67c3971 100644 --- a/SECURITY_AUDIT.md +++ b/SECURITY_AUDIT.md @@ -166,7 +166,7 @@ For user-selected app removal: - **Safety Limit:** 3-char minimum (prevents "Go" matching "Google") - **Disabled:** Fuzzy matching and wildcard expansion for short names. - **User Confirmation:** Required before deletion. -- **Receipt Scans:** BOM-derived files are limited to safe system prefixes and filtered by `should_protect_path()`. +- **Receipt Scans:** BOM-derived files are restricted to app-specific prefixes (e.g., `/Applications`, `/Library/Application Support`). Shared directories like `/Library/Frameworks` are **excluded** to prevent collateral damage. **Code:** `lib/clean/apps.sh:uninstall_app()` diff --git a/lib/core/app_protection.sh b/lib/core/app_protection.sh index 98b753a..90bfdb9 100755 --- a/lib/core/app_protection.sh +++ b/lib/core/app_protection.sh @@ -950,13 +950,6 @@ find_app_receipt_files() { /Library/LaunchAgents/*) is_safe=true ;; /Library/LaunchDaemons/*) is_safe=true ;; /Library/PrivilegedHelperTools/*) is_safe=true ;; - /Library/Internet\ Plug-Ins/*) is_safe=true ;; - /Library/Audio/Plug-Ins/*) is_safe=true ;; - /Library/Frameworks/*) is_safe=true ;; - /Library/Input\ Methods/*) is_safe=true ;; - /Library/QuickLook/*) is_safe=true ;; - /Library/PreferencePanes/*) is_safe=true ;; - /Library/Screen\ Savers/*) is_safe=true ;; /Library/Extensions/*) is_safe=false ;; *) is_safe=false ;; esac From 444bc3a70aec8d3266c5ea61c638be228bcc8c79 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Fri, 16 Jan 2026 02:00:46 +0000 Subject: [PATCH 15/21] chore: auto format code --- lib/clean/user.sh | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/clean/user.sh b/lib/clean/user.sh index 6826755..a951685 100644 --- a/lib/clean/user.sh +++ b/lib/clean/user.sh @@ -15,8 +15,6 @@ clean_user_essentials() { fi } - - # Remove old Google Chrome versions while keeping Current. clean_chrome_old_versions() { local -a app_paths=( From 5a04bf145dd2536e10e3bd7edf2c9195e4b53aae Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 16 Jan 2026 02:10:32 +0000 Subject: [PATCH 16/21] chore: update contributors [skip ci] --- CONTRIBUTORS.svg | 55 +++++++++++++++++++++++++++++------------------- 1 file changed, 33 insertions(+), 22 deletions(-) diff --git a/CONTRIBUTORS.svg b/CONTRIBUTORS.svg index 231d88e..d00baf2 100644 --- a/CONTRIBUTORS.svg +++ b/CONTRIBUTORS.svg @@ -1,4 +1,4 @@ - + @@ -13,17 +13,6 @@ - - - - - - - - bhadraagada - - - @@ -34,6 +23,17 @@ JackPhallen + + + + + + + + + bhadraagada + + @@ -244,6 +244,17 @@ + + + + + + + + NanmiCoder + + + @@ -254,7 +265,7 @@ Schlauer-Hax - + @@ -265,7 +276,7 @@ anonymort - + @@ -276,7 +287,7 @@ khipu-luke - + @@ -287,7 +298,7 @@ LmanTW - + @@ -298,7 +309,7 @@ kwakubiney - + @@ -309,7 +320,7 @@ kowyo - + @@ -320,7 +331,7 @@ jalen0x - + @@ -331,7 +342,7 @@ Hensell - + @@ -342,7 +353,7 @@ Copper-Eye - + @@ -353,7 +364,7 @@ ClathW - + From d29a0f828bdfb72a0451e560f9f28589a4339b59 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Fri, 16 Jan 2026 10:19:38 +0800 Subject: [PATCH 17/21] fix(uninstall): fix Dock cleanup by using correct PlistBuddy path - Changed from `command -v PlistBuddy` to `[[ -x /usr/libexec/PlistBuddy ]]` - PlistBuddy is not in PATH, it's at /usr/libexec/PlistBuddy on macOS - Previous code would always return early, making Dock cleanup never work - Also improved fallback logic for already-deleted apps - Tested and verified Dock icons are now properly removed after uninstall --- lib/core/common.sh | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/lib/core/common.sh b/lib/core/common.sh index ad955fa..a507aa2 100755 --- a/lib/core/common.sh +++ b/lib/core/common.sh @@ -119,15 +119,28 @@ remove_apps_from_dock() { local plist="$HOME/Library/Preferences/com.apple.dock.plist" [[ -f "$plist" ]] || return 0 - command -v PlistBuddy > /dev/null 2>&1 || return 0 + # PlistBuddy is at /usr/libexec/PlistBuddy on macOS + [[ -x /usr/libexec/PlistBuddy ]] || return 0 local changed=false for target in "${targets[@]}"; do local app_path="$target" - # Normalize path for comparison - realpath might fail if app is already deleted + # Normalize path for comparison - use original path if app already deleted local full_path - full_path=$(cd "$(dirname "$app_path")" 2> /dev/null && pwd || echo "") - [[ -n "$full_path" ]] && full_path="$full_path/$(basename "$app_path")" + if full_path=$(cd "$(dirname "$app_path")" 2> /dev/null && pwd); then + full_path="$full_path/$(basename "$app_path")" + else + # App already deleted - use the original path as-is + # Remove ~/ prefix and expand to full path if needed + if [[ "$app_path" == ~/* ]]; then + full_path="$HOME/${app_path#~/}" + elif [[ "$app_path" != /* ]]; then + # Relative path - skip this entry + continue + else + full_path="$app_path" + fi + fi # URL-encode the path for matching against Dock URLs (spaces -> %20) local encoded_path="${full_path// /%20}" From 60ee0e1f9c44ed6112f51aea5d88d881a1381fd7 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Fri, 16 Jan 2026 10:26:46 +0800 Subject: [PATCH 18/21] feat(uninstall): add progress spinner for brew autoremove - Show 'Checking brew dependencies...' spinner while running brew autoremove - Prevents user confusion when brew cleanup takes time after uninstall - Improves UX by providing visual feedback during dependency cleanup --- lib/uninstall/batch.sh | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lib/uninstall/batch.sh b/lib/uninstall/batch.sh index 843af7f..c57a27f 100755 --- a/lib/uninstall/batch.sh +++ b/lib/uninstall/batch.sh @@ -572,10 +572,20 @@ batch_uninstall_applications() { # Auto-run brew autoremove if Homebrew casks were uninstalled if [[ $brew_apps_removed -gt 0 ]]; then + # Show spinner while checking for orphaned dependencies + if [[ -t 1 ]]; then + start_inline_spinner "Checking brew dependencies..." + fi + local autoremove_output removed_count autoremove_output=$(HOMEBREW_NO_ENV_HINTS=1 brew autoremove 2> /dev/null) || true removed_count=$(printf '%s\n' "$autoremove_output" | grep -c "^Uninstalling" || true) removed_count=${removed_count:-0} + + if [[ -t 1 ]]; then + stop_inline_spinner + fi + if [[ $removed_count -gt 0 ]]; then echo -e "${GREEN}${ICON_SUCCESS}${NC} Cleaned $removed_count orphaned brew dependencies" echo "" From ae955125601ec14ca1e1f608a04f5089678d1ccd Mon Sep 17 00:00:00 2001 From: Tw93 Date: Fri, 16 Jan 2026 10:38:27 +0800 Subject: [PATCH 19/21] fix(ui): restore real-time search filtering in paginated menu - Previous perf optimization (318c67f) broke real-time search by removing rebuild_view call - Now calls rebuild_view and triggers full redraw when typing/deleting - Uses 'continue' to skip drain_pending_input, preserving fast typed characters - Fixes issue where only first character was effective in search --- lib/ui/menu_paginated.sh | 39 +++++++++++++++++++++++++-------------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/lib/ui/menu_paginated.sh b/lib/ui/menu_paginated.sh index 2dc5cd0..6722947 100755 --- a/lib/ui/menu_paginated.sh +++ b/lib/ui/menu_paginated.sh @@ -764,7 +764,9 @@ paginated_multi_select() { if [[ "$filter_mode" == "true" ]]; then local ch="${key#CHAR:}" filter_query+="$ch" + rebuild_view need_full_redraw=true + continue elif [[ "$has_metadata" == "true" ]]; then # Cycle sort mode (only if metadata available) case "$sort_mode" in @@ -815,6 +817,9 @@ paginated_multi_select() { fi else filter_query+="j" + rebuild_view + need_full_redraw=true + continue fi ;; "CHAR:k") @@ -829,17 +834,26 @@ paginated_multi_select() { fi else filter_query+="k" + rebuild_view + need_full_redraw=true + continue fi ;; "CHAR:f" | "CHAR:F") if [[ "$filter_mode" == "true" ]]; then filter_query+="${key#CHAR:}" + rebuild_view + need_full_redraw=true + continue fi # F is currently unbound in normal mode to avoid conflict with Refresh (R) ;; "CHAR:r" | "CHAR:R") if [[ "$filter_mode" == "true" ]]; then filter_query+="${key#CHAR:}" + rebuild_view + need_full_redraw=true + continue else # Trigger Refresh signal (Unified with Analyze) cleanup @@ -849,6 +863,9 @@ paginated_multi_select() { "CHAR:o" | "CHAR:O") if [[ "$filter_mode" == "true" ]]; then filter_query+="${key#CHAR:}" + rebuild_view + need_full_redraw=true + continue elif [[ "$has_metadata" == "true" ]]; then # O toggles reverse order (Unified Sort Order) if [[ "$sort_reverse" == "true" ]]; then @@ -864,13 +881,10 @@ paginated_multi_select() { # Backspace filter if [[ "$filter_mode" == "true" && -n "$filter_query" ]]; then filter_query="${filter_query%?}" - # Fast footer-only update in filter mode (avoid full redraw) - local filter_status="${filter_query:-_}" - local footer_row=$((items_per_page + 4)) - printf "\033[%d;1H\033[2K" "$footer_row" >&2 - local sep=" ${GRAY}|${NC} " - printf "%s" "${GRAY}Search: ${filter_status}${NC}${sep}${GRAY}Delete${NC}${sep}${GRAY}Enter Confirm${NC}${sep}${GRAY}ESC Cancel${NC}" >&2 - printf "\033[%d;1H\033[2K" "$((footer_row + 1))" >&2 + # Rebuild view to apply filter in real-time + rebuild_view + # Trigger redraw and continue to avoid drain_pending_input + need_full_redraw=true continue fi ;; @@ -880,13 +894,10 @@ paginated_multi_select() { # avoid accidental leading spaces if [[ -n "$filter_query" || "$ch" != " " ]]; then filter_query+="$ch" - # Fast footer-only update in filter mode (avoid full redraw) - local filter_status="${filter_query:-_}" - local footer_row=$((items_per_page + 4)) - printf "\033[%d;1H\033[2K" "$footer_row" >&2 - local sep=" ${GRAY}|${NC} " - printf "%s" "${GRAY}Search: ${filter_status}${NC}${sep}${GRAY}Delete${NC}${sep}${GRAY}Enter Confirm${NC}${sep}${GRAY}ESC Cancel${NC}" >&2 - printf "\033[%d;1H\033[2K" "$((footer_row + 1))" >&2 + # Rebuild view to apply filter in real-time + rebuild_view + # Trigger redraw and continue to avoid drain_pending_input + need_full_redraw=true continue fi fi From 7294ef65a115a8d691ca466ef6721ed97530203e Mon Sep 17 00:00:00 2001 From: Tw93 Date: Fri, 16 Jan 2026 10:58:10 +0800 Subject: [PATCH 20/21] feat(ui): allow arrow keys and space during search filtering - Modified FORCE_CHAR mode in read_key() to recognize arrow keys and space - Users can now navigate and select items while typing in search mode - Improves UX by eliminating need to press Enter before selecting - ESC key still works to cancel search This restores V1.19.0 behavior where navigation worked during search --- lib/core/ui.sh | 38 ++++++++++++++++++++++++++- lib/ui/menu_paginated.sh | 55 ++++++++++++++++++++++++++++++---------- 2 files changed, 79 insertions(+), 14 deletions(-) diff --git a/lib/core/ui.sh b/lib/core/ui.sh index fe98143..c9feab4 100755 --- a/lib/core/ui.sh +++ b/lib/core/ui.sh @@ -170,7 +170,43 @@ read_key() { case "$key" in $'\n' | $'\r') echo "ENTER" ;; $'\x7f' | $'\x08') echo "DELETE" ;; - $'\x1b') echo "QUIT" ;; + $'\x1b') + # Check if this is an escape sequence (arrow keys) or ESC key + if IFS= read -r -s -n 1 -t 0.1 rest 2> /dev/null; then + if [[ "$rest" == "[" ]]; then + if IFS= read -r -s -n 1 -t 0.1 rest2 2> /dev/null; then + case "$rest2" in + "A") echo "UP" ;; + "B") echo "DOWN" ;; + "C") echo "RIGHT" ;; + "D") echo "LEFT" ;; + "3") + IFS= read -r -s -n 1 -t 0.1 rest3 2> /dev/null + [[ "$rest3" == "~" ]] && echo "DELETE" || echo "OTHER" + ;; + *) echo "OTHER" ;; + esac + else echo "QUIT"; fi + elif [[ "$rest" == "O" ]]; then + if IFS= read -r -s -n 1 -t 0.1 rest2 2> /dev/null; then + case "$rest2" in + "A") echo "UP" ;; + "B") echo "DOWN" ;; + "C") echo "RIGHT" ;; + "D") echo "LEFT" ;; + *) echo "OTHER" ;; + esac + else echo "OTHER"; fi + else + # Not an escape sequence, it's ESC key + echo "QUIT" + fi + else + # No following characters, it's ESC key + echo "QUIT" + fi + ;; + ' ') echo "SPACE" ;; # Allow space in filter mode for selection [[:print:]]) echo "CHAR:$key" ;; *) echo "OTHER" ;; esac diff --git a/lib/ui/menu_paginated.sh b/lib/ui/menu_paginated.sh index 6722947..26e8661 100755 --- a/lib/ui/menu_paginated.sh +++ b/lib/ui/menu_paginated.sh @@ -201,7 +201,6 @@ paginated_multi_select() { export MOLE_MENU_SORT_MODE="$sort_mode" export MOLE_MENU_SORT_REVERSE="$sort_reverse" restore_terminal - unset MOLE_READ_KEY_FORCE_CHAR } # Interrupt handler @@ -595,7 +594,6 @@ paginated_multi_select() { "QUIT") if [[ "$filter_mode" == "true" ]]; then filter_mode="false" - unset MOLE_READ_KEY_FORCE_CHAR filter_query="" applied_query="" top_index=0 @@ -791,7 +789,6 @@ paginated_multi_select() { else # Enter filter mode filter_mode="true" - export MOLE_READ_KEY_FORCE_CHAR=1 filter_query="" top_index=0 cursor_pos=0 @@ -839,6 +836,46 @@ paginated_multi_select() { continue fi ;; + "TOUCHID") + if [[ "$filter_mode" == "true" ]]; then + filter_query+="t" + rebuild_view + need_full_redraw=true + continue + fi + ;; + "RIGHT") + if [[ "$filter_mode" == "true" ]]; then + filter_query+="l" + rebuild_view + need_full_redraw=true + continue + fi + ;; + "LEFT") + if [[ "$filter_mode" == "true" ]]; then + filter_query+="h" + rebuild_view + need_full_redraw=true + continue + fi + ;; + "MORE") + if [[ "$filter_mode" == "true" ]]; then + filter_query+="m" + rebuild_view + need_full_redraw=true + continue + fi + ;; + "UPDATE") + if [[ "$filter_mode" == "true" ]]; then + filter_query+="u" + rebuild_view + need_full_redraw=true + continue + fi + ;; "CHAR:f" | "CHAR:F") if [[ "$filter_mode" == "true" ]]; then filter_query+="${key#CHAR:}" @@ -906,17 +943,9 @@ paginated_multi_select() { if [[ "$filter_mode" == "true" ]]; then applied_query="$filter_query" filter_mode="false" - unset MOLE_READ_KEY_FORCE_CHAR - top_index=0 - cursor_pos=0 - - searching="true" - draw_menu # paint "searching..." - drain_pending_input # drop any extra keypresses (e.g., double-Enter) + # Preserve cursor/top_index so navigation during search is respected rebuild_view - searching="false" - draw_menu - continue + # Fall through to confirmation logic fi # In normal mode: smart Enter behavior # 1. Check if any items are already selected From ffa46b03ee161a05ff7dfcd99976a00a89655d66 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Fri, 16 Jan 2026 12:54:21 +0800 Subject: [PATCH 21/21] fix(uninstall): resolve hang during brew uninstall by exposing output and ensuring sudo --- lib/uninstall/batch.sh | 2 ++ lib/uninstall/brew.sh | 19 +++++++++++++++---- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/lib/uninstall/batch.sh b/lib/uninstall/batch.sh index c57a27f..3302aa2 100755 --- a/lib/uninstall/batch.sh +++ b/lib/uninstall/batch.sh @@ -388,6 +388,8 @@ batch_uninstall_applications() { # Stop Launch Agents/Daemons before removal. local has_system_files="false" [[ -n "$system_files" ]] && has_system_files="true" + + stop_launch_services "$bundle_id" "$has_system_files" # Remove from Login Items diff --git a/lib/uninstall/brew.sh b/lib/uninstall/brew.sh index 3811079..abb17a3 100644 --- a/lib/uninstall/brew.sh +++ b/lib/uninstall/brew.sh @@ -174,13 +174,24 @@ brew_uninstall_cask() { debug_log "Attempting brew uninstall --cask $cask_name" # Run uninstall with timeout (suppress hints/auto-update) + debug_log "Attempting brew uninstall --cask $cask_name" + + # Ensure we have sudo access if needed, to prevent brew from hanging on password prompt + # Many brew casks need sudo to uninstall + if ! sudo -n true 2> /dev/null; then + # If we don't have sudo, try to get it (visibly) + sudo -v + fi + local uninstall_ok=false - local output - if output=$(HOMEBREW_NO_ENV_HINTS=1 HOMEBREW_NO_AUTO_UPDATE=1 NONINTERACTIVE=1 \ - run_with_timeout 120 brew uninstall --cask "$cask_name" 2>&1); then + + # Run directly without output capture to allow user interaction/visibility + # This avoids silence/hangs when brew asks for passwords or confirmation + if HOMEBREW_NO_ENV_HINTS=1 HOMEBREW_NO_AUTO_UPDATE=1 NONINTERACTIVE=1 \ + brew uninstall --cask "$cask_name"; then uninstall_ok=true else - debug_log "brew uninstall output: $output" + debug_log "brew uninstall failed with exit code $?" fi # Verify removal