diff --git a/lib/uninstall/batch.sh b/lib/uninstall/batch.sh index f69bc17..037f2c0 100755 --- a/lib/uninstall/batch.sh +++ b/lib/uninstall/batch.sh @@ -257,10 +257,8 @@ batch_uninstall_applications() { old_trap_term=$(trap -p TERM) _cleanup_sudo_keepalive() { - if [[ -n "${sudo_keepalive_pid:-}" ]]; then - kill "$sudo_keepalive_pid" 2> /dev/null || true - wait "$sudo_keepalive_pid" 2> /dev/null || true - sudo_keepalive_pid="" + if command -v stop_sudo_session >/dev/null 2>&1; then + stop_sudo_session fi } @@ -378,9 +376,7 @@ batch_uninstall_applications() { local size_display=$(bytes_to_human "$((total_estimated_size * 1024))") - echo "" - echo -e "${PURPLE_BOLD}Files to be removed:${NC}" - echo "" + echo -e "\n${PURPLE_BOLD}Files to be removed:${NC}" # Warn if brew cask apps are present. local has_brew_cask=false @@ -390,10 +386,11 @@ batch_uninstall_applications() { done if [[ "$has_brew_cask" == "true" ]]; then - echo -e "${GRAY}${ICON_WARNING}${NC} ${YELLOW}Homebrew apps will be fully cleaned (--zap: removes configs & data)${NC}" - echo "" + echo -e "${GRAY}${ICON_WARNING} Homebrew apps will be fully cleaned, --zap removes configs and data${NC}" fi + echo "" + for detail in "${app_details[@]}"; do IFS='|' read -r app_name app_path bundle_id total_kb encoded_files encoded_system_files has_sensitive_data needs_sudo_flag is_brew_cask cask_name encoded_diag_system <<< "$detail" local app_size_display=$(bytes_to_human "$((total_kb * 1024))") @@ -467,31 +464,21 @@ batch_uninstall_applications() { # that user explicitly chose to uninstall. System-critical components remain protected. export MOLE_UNINSTALL_MODE=1 - # Request sudo if needed. + # Request sudo if needed for non-Homebrew removal operations. + # Note: Homebrew resets sudo timestamp at process startup, so pre-auth would + # cause duplicate password prompts in cask-only flows. if [[ ${#sudo_apps[@]} -gt 0 && "${MOLE_DRY_RUN:-0}" != "1" ]]; then - if ! sudo -n true 2> /dev/null; then - if ! request_sudo_access "Admin required for system apps: ${sudo_apps[*]}"; then - echo "" - log_error "Admin access denied" - _restore_uninstall_traps - return 1 - fi + if ! ensure_sudo_session "Admin required for system apps: ${sudo_apps[*]}"; then + echo "" + log_error "Admin access denied" + _restore_uninstall_traps + return 1 fi - # Keep sudo alive during uninstall. - parent_pid=$$ - (while true; do - if ! kill -0 "$parent_pid" 2> /dev/null; then - exit 0 - fi - sudo -n true - sleep 60 - done 2> /dev/null) & - sudo_keepalive_pid=$! fi # 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 silent autoremove local -a failed_items=() local -a success_items=() local current_index=0 @@ -786,32 +773,13 @@ 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 - if is_uninstall_dry_run; then - log_info "[DRY RUN] Would run brew autoremove" - else - # Show spinner while checking for orphaned dependencies - if [[ -t 1 ]]; then - start_inline_spinner "Checking brew dependencies..." - fi - - local autoremove_output removed_count - # Add 30s timeout to prevent hanging on slow brew operations - # Use run_with_timeout for consistent cross-platform behavior (has shell fallback) - autoremove_output=$(run_with_timeout 30 bash -c '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 + # Run brew autoremove silently in background to avoid interrupting UX. + if [[ $brew_apps_removed -gt 0 && "${MOLE_DRY_RUN:-0}" != "1" ]]; then + ( + HOMEBREW_NO_ENV_HINTS=1 HOMEBREW_NO_AUTO_UPDATE=1 NONINTERACTIVE=1 \ + run_with_timeout 30 brew autoremove > /dev/null 2>&1 || true + ) & + disown $! 2> /dev/null || true fi # Clean up Dock entries for uninstalled apps. @@ -819,8 +787,11 @@ batch_uninstall_applications() { if is_uninstall_dry_run; then log_info "[DRY RUN] Would refresh LaunchServices and update Dock entries" else - remove_apps_from_dock "${success_items[@]}" 2> /dev/null || true - refresh_launch_services_after_uninstall 2> /dev/null || true + ( + remove_apps_from_dock "${success_items[@]}" > /dev/null 2>&1 || true + refresh_launch_services_after_uninstall > /dev/null 2>&1 || true + ) & + disown $! 2> /dev/null || true fi fi diff --git a/lib/uninstall/brew.sh b/lib/uninstall/brew.sh index 87cc62c..012ca53 100644 --- a/lib/uninstall/brew.sh +++ b/lib/uninstall/brew.sh @@ -178,13 +178,6 @@ brew_uninstall_cask() { debug_log "Attempting brew uninstall --cask --zap $cask_name" - # Ensure we have sudo access if needed, to prevent brew from hanging on password prompt - if [[ "${NONINTERACTIVE:-}" != "1" && -t 0 && -t 1 ]]; then - if ! sudo -n true 2> /dev/null; then - sudo -v - fi - fi - local uninstall_ok=false local brew_exit=0 diff --git a/mole b/mole index 1d8f842..70e9cd9 100755 --- a/mole +++ b/mole @@ -37,6 +37,31 @@ get_latest_version_from_github() { echo "$version" } +get_homebrew_latest_version() { + command -v brew > /dev/null 2>&1 || return 1 + + local line candidate="" + + # Prefer local tap outdated info to avoid notifying before formula is available. + line=$(HOMEBREW_NO_AUTO_UPDATE=1 brew outdated --formula --verbose mole 2> /dev/null | head -1 || true) + if [[ "$line" == *"< "* ]]; then + candidate="${line##*< }" + candidate="${candidate%% *}" + fi + + # Fallback for environments where outdated output is unavailable. + if [[ -z "$candidate" ]]; then + line=$(HOMEBREW_NO_AUTO_UPDATE=1 brew info mole 2> /dev/null | awk 'NR==1 { print; exit }' || true) + line="${line#==> }" + line="${line#*: }" + if [[ "$line" == stable* ]]; then + candidate=$(printf '%s\n' "$line" | awk '{print $2}') + fi + fi + + [[ -n "$candidate" ]] && printf '%s\n' "$candidate" +} + # Install detection (Homebrew vs manual). # Uses variable capture + string matching to avoid SIGPIPE under pipefail. @@ -127,9 +152,16 @@ check_for_updates() { if [[ -n "$latest" && "$VERSION" != "$latest" && "$(printf '%s\n' "$VERSION" "$latest" | sort -V | head -1)" == "$VERSION" ]]; then if is_homebrew_install; then - printf "\nUpdate %s available on GitHub (Homebrew sync may be pending)\nRun %sbrew upgrade mole%s or wait for the tap to sync\n\n" "$latest" "$GREEN" "$NC" > "$msg_cache" + # For Homebrew, only notify if the brew tap has the new version available locally + local brew_latest + brew_latest=$(get_homebrew_latest_version || true) + if [[ -n "$brew_latest" && "$brew_latest" != "$VERSION" && "$(printf '%s\n' "$VERSION" "$brew_latest" | sort -V | head -1)" == "$VERSION" ]]; then + printf "\nUpdate %s available, run %smo update%s\n\n" "$brew_latest" "$GREEN" "$NC" > "$msg_cache" + else + echo -n > "$msg_cache" + fi else - printf "\nUpdate available: %s → %s, run %smo update%s\n\n" "$VERSION" "$latest" "$GREEN" "$NC" > "$msg_cache" + printf "\nUpdate %s available, run %smo update%s\n\n" "$latest" "$GREEN" "$NC" > "$msg_cache" fi else echo -n > "$msg_cache" @@ -968,4 +1000,6 @@ main() { esac } -main "$@" +if [[ "${MOLE_SKIP_MAIN:-0}" != "1" ]]; then + main "$@" +fi diff --git a/tests/brew_uninstall.bats b/tests/brew_uninstall.bats index 84f3528..d3aab74 100644 --- a/tests/brew_uninstall.bats +++ b/tests/brew_uninstall.bats @@ -123,7 +123,56 @@ EOF [ "$status" -eq 0 ] } -@test "batch_uninstall_applications tolerates brew autoremove timeout" { +@test "batch_uninstall_applications does not pre-auth sudo for brew-only casks" { + local app_bundle="$HOME/Applications/BrewPreAuth.app" + mkdir -p "$app_bundle" + + 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/uninstall/batch.sh" + +start_inline_spinner() { :; } +stop_inline_spinner() { :; } +get_file_owner() { whoami; } +get_path_size_kb() { echo "100"; } +bytes_to_human() { echo "$1"; } +drain_pending_input() { :; } +print_summary_block() { :; } +remove_apps_from_dock() { :; } +force_kill_app() { return 0; } +run_with_timeout() { shift; "$@"; } +export -f run_with_timeout + +ensure_sudo_session() { + echo "UNEXPECTED_ENSURE_SUDO:$*" >> "$HOME/order.log" + return 1 +} + +brew() { + echo "BREW_CALL:$*" >> "$HOME/order.log" + return 0 +} +export -f brew + +get_brew_cask_name() { echo "brew-preauth-cask"; return 0; } +export -f get_brew_cask_name + +selected_apps=("0|$HOME/Applications/BrewPreAuth.app|BrewPreAuth|com.example.brewpreauth|0|Never") +files_cleaned=0 +total_items=0 +total_size_cleaned=0 + +printf '\n' | batch_uninstall_applications > /dev/null 2>&1 + +grep -q "BREW_CALL:uninstall --cask --zap brew-preauth-cask" "$HOME/order.log" +! grep -q "UNEXPECTED_ENSURE_SUDO:" "$HOME/order.log" +EOF + + [ "$status" -eq 0 ] +} + +@test "batch_uninstall_applications runs silent brew autoremove without UX noise" { local app_bundle="$HOME/Applications/BrewTimeout.app" mkdir -p "$app_bundle" @@ -151,9 +200,6 @@ run_with_timeout() { local duration="$1" shift echo "TIMEOUT_CALL:$duration:$*" >> "$HOME/timeout_calls.log" - if [[ "$duration" == "30" ]]; then - return 124 - fi "$@" } @@ -164,10 +210,51 @@ total_size_cleaned=0 printf '\n' | batch_uninstall_applications -cat "$HOME/timeout_calls.log" +sleep 0.2 + +if [[ -f "$HOME/timeout_calls.log" ]]; then + cat "$HOME/timeout_calls.log" +else + echo "NO_TIMEOUT_CALL" +fi EOF [ "$status" -eq 0 ] - [[ "$output" == *"TIMEOUT_CALL:30:bash -c HOMEBREW_NO_ENV_HINTS=1 brew autoremove 2>/dev/null"* ]] - [[ "$output" == *"LS_REFRESH"* ]] + [[ "$output" == *"TIMEOUT_CALL:30:brew autoremove"* ]] + [[ "$output" != *"Checking brew dependencies"* ]] +} + +@test "brew_uninstall_cask does not trigger extra sudo pre-auth" { + 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/uninstall/brew.sh" + +debug_log() { :; } +get_path_size_kb() { echo "0"; } +run_with_timeout() { local _timeout="$1"; shift; "$@"; } + +sudo() { + echo "UNEXPECTED_SUDO_CALL:$*" + return 1 +} + +brew() { + if [[ "${1:-}" == "uninstall" ]]; then + return 0 + fi + if [[ "${1:-}" == "list" && "${2:-}" == "--cask" ]]; then + return 0 + fi + return 0 +} +export -f sudo brew + +brew_uninstall_cask "mock-cask" +echo "DONE" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"DONE"* ]] + [[ "$output" != *"UNEXPECTED_SUDO_CALL:"* ]] } diff --git a/tests/update.bats b/tests/update.bats index 6473418..cb5c4a3 100644 --- a/tests/update.bats +++ b/tests/update.bats @@ -596,3 +596,52 @@ EOF [[ "$output" == *"Homebrew installs follow stable releases."* ]] [[ "$output" == *"mo update --nightly"* ]] } + +@test "get_homebrew_latest_version prefers brew outdated verbose target version" { + run bash --noprofile --norc <<'EOF' +set -euo pipefail +MOLE_SKIP_MAIN=1 source "$PROJECT_ROOT/mole" + +brew() { + if [[ "${1:-}" == "outdated" ]]; then + echo "tw93/tap/mole (1.29.0) < 1.30.0" + return 0 + fi + if [[ "${1:-}" == "info" ]]; then + echo "==> tw93/tap/mole: stable 9.9.9 (bottled)" + return 0 + fi + return 0 +} +export -f brew + +get_homebrew_latest_version +EOF + + [ "$status" -eq 0 ] + [[ "$output" == "1.30.0" ]] +} + +@test "get_homebrew_latest_version parses brew info fallback with heading prefix" { + run bash --noprofile --norc <<'EOF' +set -euo pipefail +MOLE_SKIP_MAIN=1 source "$PROJECT_ROOT/mole" + +brew() { + if [[ "${1:-}" == "outdated" ]]; then + return 0 + fi + if [[ "${1:-}" == "info" ]]; then + echo "==> tw93/tap/mole: stable 1.31.1 (bottled), HEAD" + return 0 + fi + return 0 +} +export -f brew + +get_homebrew_latest_version +EOF + + [ "$status" -eq 0 ] + [[ "$output" == "1.31.1" ]] +}