mirror of
https://github.com/tw93/Mole.git
synced 2026-03-22 17:55:08 +00:00
Refine update/uninstall UX and stabilize brew flows
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
40
mole
40
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
|
||||
|
||||
@@ -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:"* ]]
|
||||
}
|
||||
|
||||
@@ -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" ]]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user