diff --git a/CONTRIBUTORS.svg b/CONTRIBUTORS.svg index 804858f..27f52d9 100644 --- a/CONTRIBUTORS.svg +++ b/CONTRIBUTORS.svg @@ -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 - + diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md index 4aa089c..67c3971 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 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/clean/user.sh b/lib/clean/user.sh index 82d2b6d..a951685 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,66 +15,6 @@ 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() { local -a app_paths=( diff --git a/lib/core/app_protection.sh b/lib/core/app_protection.sh index b908a06..90bfdb9 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 @@ -872,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) @@ -923,44 +940,37 @@ 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/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/common.sh b/lib/core/common.sh index 6e847ab..a507aa2 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 @@ -172,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}" 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/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" 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/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 diff --git a/lib/ui/menu_paginated.sh b/lib/ui/menu_paginated.sh index 2dc5cd0..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 @@ -764,7 +762,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 @@ -789,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 @@ -815,6 +814,9 @@ paginated_multi_select() { fi else filter_query+="j" + rebuild_view + need_full_redraw=true + continue fi ;; "CHAR:k") @@ -829,17 +831,66 @@ paginated_multi_select() { fi else filter_query+="k" + rebuild_view + need_full_redraw=true + 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:}" + 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 +900,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 +918,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 +931,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 @@ -895,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 diff --git a/lib/uninstall/batch.sh b/lib/uninstall/batch.sh index 25193cf..3302aa2 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). @@ -101,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 @@ -109,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 @@ -129,17 +136,21 @@ 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 + 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" @@ -178,72 +189,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 @@ -276,42 +272,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 @@ -370,6 +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 -a failed_items=() local -a success_items=() local current_index=0 @@ -394,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 @@ -404,24 +400,16 @@ 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") + [[ -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 + else + # Fallback to manual removal if brew fails if [[ "$needs_sudo" == true ]]; then safe_sudo_remove "$app_path" || reason="remove failed" else @@ -446,7 +434,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 @@ -472,6 +466,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") @@ -577,6 +572,28 @@ batch_uninstall_applications() { print_summary_block "$title" "${summary_details[@]}" printf '\n' + # 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 "" + fi + 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..abb17a3 --- /dev/null +++ b/lib/uninstall/brew.sh @@ -0,0 +1,210 @@ +#!/bin/bash +# Mole - Homebrew Cask Uninstallation Support +# Detects Homebrew-managed casks via Caskroom linkage and uninstalls them via brew + +set -euo pipefail + +# Prevent multiple sourcing +if [[ -n "${MOLE_BREW_UNINSTALL_LOADED:-}" ]]; then + return 0 +fi +readonly MOLE_BREW_UNINSTALL_LOADED=1 + +# Resolve a path to its absolute real path (follows symlinks) +# Args: $1 - path to resolve +# Returns: Absolute resolved path, or empty string on failure +resolve_path() { + local p="$1" + [[ -e "$p" ]] || return 1 + + # macOS 12.3+ and Linux have realpath + if realpath "$p" 2> /dev/null; then + return 0 + fi + + # Fallback: use cd -P to resolve directory, then append basename + local dir base + dir=$(cd -P "$(dirname "$p")" 2> /dev/null && pwd) || return 1 + base=$(basename "$p") + echo "$dir/$base" +} + +# Check if Homebrew is installed and accessible +# Returns: 0 if brew is available, 1 otherwise +is_homebrew_available() { + command -v brew > /dev/null 2>&1 +} + +# Extract cask token from a Caskroom path +# Args: $1 - path (must be inside Caskroom) +# Prints: cask token to stdout +# Returns: 0 if valid token extracted, 1 otherwise +_extract_cask_token_from_path() { + local path="$1" + + # Check if path is inside Caskroom + case "$path" in + /opt/homebrew/Caskroom/* | /usr/local/Caskroom/*) ;; + *) return 1 ;; + esac + + # Extract token from path: /opt/homebrew/Caskroom///... + local token + token="${path#*/Caskroom/}" # Remove everything up to and including Caskroom/ + token="${token%%/*}" # Take only the first path component + + # Validate token looks like a valid cask name (lowercase alphanumeric with hyphens) + if [[ -n "$token" && "$token" =~ ^[a-z0-9][a-z0-9-]*$ ]]; then + echo "$token" + return 0 + fi + + return 1 +} + +# Stage 1: Deterministic detection via fully resolved path +# Fast, no false positives - follows all symlinks +_detect_cask_via_resolved_path() { + local app_path="$1" + local resolved + if resolved=$(resolve_path "$app_path") && [[ -n "$resolved" ]]; then + _extract_cask_token_from_path "$resolved" && return 0 + fi + return 1 +} + +# Stage 2: Search Caskroom by app bundle name using find +# Catches apps where the .app in /Applications doesn't link to Caskroom +# Only succeeds if exactly one cask matches (avoids wrong uninstall) +_detect_cask_via_caskroom_search() { + local app_bundle_name="$1" + [[ -z "$app_bundle_name" ]] && return 1 + + local -a tokens=() + local room match token + + for room in "/opt/homebrew/Caskroom" "/usr/local/Caskroom"; do + [[ -d "$room" ]] || continue + while IFS= read -r match; do + [[ -n "$match" ]] || continue + token=$(_extract_cask_token_from_path "$match" 2> /dev/null) || continue + [[ -n "$token" ]] && tokens+=("$token") + done < <(find "$room" -maxdepth 3 -name "$app_bundle_name" 2> /dev/null) + done + + # Need at least one token + ((${#tokens[@]} > 0)) || return 1 + + # Deduplicate and check count + local -a uniq + IFS=$'\n' read -r -d '' -a uniq < <(printf '%s\n' "${tokens[@]}" | sort -u && printf '\0') || true + + # Only succeed if exactly one unique token found and it's installed + if ((${#uniq[@]} == 1)) && [[ -n "${uniq[0]}" ]]; then + HOMEBREW_NO_ENV_HINTS=1 brew list --cask 2> /dev/null | grep -qxF "${uniq[0]}" || return 1 + echo "${uniq[0]}" + return 0 + fi + + return 1 +} + +# Stage 3: Check if app_path is a direct symlink to Caskroom +_detect_cask_via_symlink_check() { + local app_path="$1" + [[ -L "$app_path" ]] || return 1 + + local target + target=$(readlink "$app_path" 2> /dev/null) || return 1 + _extract_cask_token_from_path "$target" +} + +# Stage 4: Query brew list --cask and verify with brew info (slowest fallback) +_detect_cask_via_brew_list() { + local app_path="$1" + local app_bundle_name="$2" + local app_name_lower + app_name_lower=$(echo "${app_bundle_name%.app}" | LC_ALL=C tr '[:upper:]' '[:lower:]') + + local cask_name + cask_name=$(HOMEBREW_NO_ENV_HINTS=1 brew list --cask 2> /dev/null | grep -Fix "$app_name_lower") || return 1 + + # Verify this cask actually owns this app path + HOMEBREW_NO_ENV_HINTS=1 brew info --cask "$cask_name" 2> /dev/null | grep -qF "$app_path" || return 1 + echo "$cask_name" +} + +# Get Homebrew cask name for an app +# Uses multi-stage detection (fast to slow, deterministic to heuristic): +# 1. Resolve symlinks fully, check if path is in Caskroom (fast, deterministic) +# 2. Search Caskroom by app bundle name using find +# 3. Check if app is a direct symlink to Caskroom +# 4. Query brew list --cask and verify with brew info (slowest) +# +# Args: $1 - app_path +# Prints: cask token to stdout if brew-managed +# Returns: 0 if Homebrew-managed, 1 otherwise +get_brew_cask_name() { + local app_path="$1" + [[ -z "$app_path" || ! -e "$app_path" ]] && return 1 + is_homebrew_available || return 1 + + local app_bundle_name + app_bundle_name=$(basename "$app_path") + + # Try each detection method in order (fast to slow) + _detect_cask_via_resolved_path "$app_path" && return 0 + _detect_cask_via_caskroom_search "$app_bundle_name" && return 0 + _detect_cask_via_symlink_check "$app_path" && return 0 + _detect_cask_via_brew_list "$app_path" "$app_bundle_name" && return 0 + + return 1 +} + +# Uninstall a Homebrew cask and verify removal +# Args: $1 - cask_name, $2 - app_path (optional, for verification) +# Returns: 0 on success, 1 on failure +brew_uninstall_cask() { + local cask_name="$1" + local app_path="${2:-}" + + is_homebrew_available || return 1 + [[ -z "$cask_name" ]] && return 1 + + debug_log "Attempting brew uninstall --cask $cask_name" + + # 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 + + # 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 failed with exit code $?" + 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 + + # Success: uninstall worked and both are gone, or already uninstalled + if $cask_gone && $app_gone; then + debug_log "Successfully uninstalled cask '$cask_name'" + return 0 + fi + + debug_log "brew uninstall failed: cask_gone=$cask_gone app_gone=$app_gone" + return 1 +} 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 </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 03be535..d2675d8 100644 --- a/tests/clean_user_core.bats +++ b/tests/clean_user_core.bats @@ -103,22 +103,7 @@ EOF [ "$status" -eq 0 ] } -@test "clean_empty_library_items only cleans empty dirs" { - 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"* ]] - [[ "$output" != *"Empty Library files"* ]] -} @test "clean_browsers calls expected cache paths" { run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'