1
0
mirror of https://github.com/tw93/Mole.git synced 2026-03-24 15:45:07 +00:00

Add dry-run support across destructive commands (#516)

* chore: update contributors [skip ci]

* Add dry-run support across destructive commands

Implement dry-run for uninstall, purge, installer, touchid, completion, and remove flows.\nGuard side effects in uninstall path (launchctl, defaults writes, kill/brew actions), update help/README, and add coverage in CLI/Bats tests.\n\nValidation: ./scripts/check.sh and ./scripts/test.sh (452 tests, 0 failures, 8 skipped).

* test(purge): keep dev-compatible purge coverage

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Tw93 <hitw93@gmail.com>
This commit is contained in:
陳德生
2026-03-01 20:03:22 +08:00
committed by GitHub
parent adcd98096a
commit 05446e0847
18 changed files with 1021 additions and 684 deletions

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 533 KiB

After

Width:  |  Height:  |  Size: 541 KiB

View File

@@ -68,6 +68,12 @@ mo clean --dry-run --debug # Detailed preview with risk levels and file info
mo optimize --dry-run # Preview optimization actions mo optimize --dry-run # Preview optimization actions
mo optimize --debug # Run with detailed operation logs mo optimize --debug # Run with detailed operation logs
mo optimize --whitelist # Manage protected optimization rules mo optimize --whitelist # Manage protected optimization rules
mo uninstall --dry-run # Preview app uninstall actions
mo purge --dry-run # Preview project artifact purge
mo installer --dry-run # Preview installer cleanup actions
mo touchid enable --dry-run # Preview Touch ID sudo config changes
mo completion --dry-run # Preview shell completion file updates
mo remove --dry-run # Preview Mole self-removal
mo purge --paths # Configure project scan directories mo purge --paths # Configure project scan directories
mo analyze /Volumes # Analyze external drives only mo analyze /Volumes # Analyze external drives only
``` ```
@@ -75,7 +81,7 @@ mo analyze /Volumes # Analyze external drives only
## Tips ## Tips
- Video tutorial: Watch the [Mole tutorial video](https://www.youtube.com/watch?v=UEe9-w4CcQ0), thanks to PAPAYA 電腦教室. - Video tutorial: Watch the [Mole tutorial video](https://www.youtube.com/watch?v=UEe9-w4CcQ0), thanks to PAPAYA 電腦教室.
- Safety first: Deletions are permanent. Review carefully and preview with `mo clean --dry-run`. See [Security Audit](SECURITY_AUDIT.md). - Safety first: Deletions are permanent. Review carefully with dry-run before applying changes. See [Security Audit](SECURITY_AUDIT.md).
- Debug and logs: Use `--debug` for detailed logs. Combine with `--dry-run` for a full preview. File operations are logged to `~/.config/mole/operations.log`. Disable with `MO_NO_OPLOG=1`. - Debug and logs: Use `--debug` for detailed logs. Combine with `--dry-run` for a full preview. File operations are logged to `~/.config/mole/operations.log`. Disable with `MO_NO_OPLOG=1`.
- Navigation: Mole supports arrow keys and Vim bindings `h/j/k/l`. - Navigation: Mole supports arrow keys and Vim bindings `h/j/k/l`.

View File

@@ -32,8 +32,33 @@ emit_fish_completions() {
printf 'complete -c %s -n "not __fish_mole_no_subcommand" -a fish -d "generate fish completion" -n "__fish_see_subcommand_path completion"\n' "$cmd" printf 'complete -c %s -n "not __fish_mole_no_subcommand" -a fish -d "generate fish completion" -n "__fish_see_subcommand_path completion"\n' "$cmd"
} }
DRY_RUN_MODE=false
if [[ $# -gt 0 ]]; then
normalized_args=()
for arg in "$@"; do
case "$arg" in
"--dry-run" | "-n")
DRY_RUN_MODE=true
;;
*)
normalized_args+=("$arg")
;;
esac
done
if [[ ${#normalized_args[@]} -gt 0 ]]; then
set -- "${normalized_args[@]}"
else
set --
fi
fi
# Auto-install mode when run without arguments # Auto-install mode when run without arguments
if [[ $# -eq 0 ]]; then if [[ $# -eq 0 ]]; then
if [[ "$DRY_RUN_MODE" == "true" ]]; then
echo -e "${YELLOW}${ICON_DRY_RUN} DRY RUN MODE${NC}, shell config files will not be modified"
echo ""
fi
# Detect current shell # Detect current shell
current_shell="${SHELL##*/}" current_shell="${SHELL##*/}"
if [[ -z "$current_shell" ]]; then if [[ -z "$current_shell" ]]; then
@@ -73,6 +98,10 @@ if [[ $# -eq 0 ]]; then
if [[ -z "$completion_name" ]]; then if [[ -z "$completion_name" ]]; then
if [[ -f "$config_file" ]] && grep -Eq "(^# Mole shell completion$|(mole|mo)[[:space:]]+completion)" "$config_file" 2> /dev/null; then if [[ -f "$config_file" ]] && grep -Eq "(^# Mole shell completion$|(mole|mo)[[:space:]]+completion)" "$config_file" 2> /dev/null; then
if [[ "$DRY_RUN_MODE" == "true" ]]; then
echo -e "${GRAY}${ICON_REVIEW} [DRY RUN] Would remove stale completion entries from $config_file${NC}"
echo ""
else
original_mode="" original_mode=""
original_mode="$(stat -f '%Mp%Lp' "$config_file" 2> /dev/null || true)" original_mode="$(stat -f '%Mp%Lp' "$config_file" 2> /dev/null || true)"
temp_file="$(mktemp)" temp_file="$(mktemp)"
@@ -84,12 +113,19 @@ if [[ $# -eq 0 ]]; then
echo -e "${GREEN}${ICON_SUCCESS}${NC} Removed stale completion entries from $config_file" echo -e "${GREEN}${ICON_SUCCESS}${NC} Removed stale completion entries from $config_file"
echo "" echo ""
fi fi
fi
log_error "mole not found in PATH, install Mole before enabling completion" log_error "mole not found in PATH, install Mole before enabling completion"
exit 1 exit 1
fi fi
# Check if already installed and normalize to latest line # Check if already installed and normalize to latest line
if [[ -f "$config_file" ]] && grep -Eq "(mole|mo)[[:space:]]+completion" "$config_file" 2> /dev/null; then if [[ -f "$config_file" ]] && grep -Eq "(mole|mo)[[:space:]]+completion" "$config_file" 2> /dev/null; then
if [[ "$DRY_RUN_MODE" == "true" ]]; then
echo -e "${GRAY}${ICON_REVIEW} [DRY RUN] Would normalize completion entry in $config_file${NC}"
echo ""
exit 0
fi
original_mode="" original_mode=""
original_mode="$(stat -f '%Mp%Lp' "$config_file" 2> /dev/null || true)" original_mode="$(stat -f '%Mp%Lp' "$config_file" 2> /dev/null || true)"
temp_file="$(mktemp)" temp_file="$(mktemp)"
@@ -114,6 +150,11 @@ if [[ $# -eq 0 ]]; then
echo -e "${GRAY}Will add to ${config_file}:${NC}" echo -e "${GRAY}Will add to ${config_file}:${NC}"
echo " $completion_line" echo " $completion_line"
echo "" echo ""
if [[ "$DRY_RUN_MODE" == "true" ]]; then
echo -e "${GREEN}${ICON_SUCCESS}${NC} Dry run complete, no changes made"
exit 0
fi
echo -ne "${PURPLE}${ICON_ARROW}${NC} Enable completion for ${GREEN}${current_shell}${NC}? ${GRAY}Enter confirm / Q cancel${NC}: " echo -ne "${PURPLE}${ICON_ARROW}${NC} Enable completion for ${GREEN}${current_shell}${NC}? ${GRAY}Enter confirm / Q cancel${NC}: "
IFS= read -r -s -n1 key || key="" IFS= read -r -s -n1 key || key=""
drain_pending_input drain_pending_input
@@ -227,6 +268,7 @@ Setup shell tab completion for mole and mo commands.
Auto-install: Auto-install:
mole completion # Auto-detect shell and install mole completion # Auto-detect shell and install
mole completion --dry-run # Preview config changes without writing files
Manual install: Manual install:
mole completion bash # Generate bash completion script mole completion bash # Generate bash completion script

View File

@@ -650,13 +650,22 @@ perform_installers() {
show_summary() { show_summary() {
local summary_heading="Installers cleaned" local summary_heading="Installers cleaned"
local -a summary_details=() local -a summary_details=()
local dry_run_mode="${MOLE_DRY_RUN:-0}"
if [[ "$dry_run_mode" == "1" ]]; then
summary_heading="Dry run complete - no changes made"
fi
if [[ $total_deleted -gt 0 ]]; then if [[ $total_deleted -gt 0 ]]; then
local freed_mb local freed_mb
freed_mb=$(echo "$total_size_freed_kb" | awk '{printf "%.2f", $1/1024}') freed_mb=$(echo "$total_size_freed_kb" | awk '{printf "%.2f", $1/1024}')
if [[ "$dry_run_mode" == "1" ]]; then
summary_details+=("Would remove ${GREEN}$total_deleted${NC} installers, free ${GREEN}${freed_mb}MB${NC}")
else
summary_details+=("Removed ${GREEN}$total_deleted${NC} installers, freed ${GREEN}${freed_mb}MB${NC}") summary_details+=("Removed ${GREEN}$total_deleted${NC} installers, freed ${GREEN}${freed_mb}MB${NC}")
summary_details+=("Your Mac is cleaner now!") summary_details+=("Your Mac is cleaner now!")
fi
else else
summary_details+=("No installers were removed") summary_details+=("No installers were removed")
fi fi
@@ -675,6 +684,9 @@ main() {
"--debug") "--debug")
export MO_DEBUG=1 export MO_DEBUG=1
;; ;;
"--dry-run" | "-n")
export MOLE_DRY_RUN=1
;;
*) *)
echo "Unknown option: $arg" echo "Unknown option: $arg"
exit 1 exit 1
@@ -682,6 +694,11 @@ main() {
esac esac
done done
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
echo -e "${YELLOW}${ICON_DRY_RUN} DRY RUN MODE${NC}, No installer files will be removed"
printf '\n'
fi
hide_cursor hide_cursor
perform_installers perform_installers
local exit_code=$? local exit_code=$?

View File

@@ -205,11 +205,18 @@ perform_purge() {
rm -f "$stats_dir/purge_count" rm -f "$stats_dir/purge_count"
fi fi
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
summary_heading="Dry run complete - no changes made"
fi
if [[ $total_size_cleaned -gt 0 ]]; then if [[ $total_size_cleaned -gt 0 ]]; then
local freed_gb local freed_gb
freed_gb=$(echo "$total_size_cleaned" | awk '{printf "%.2f", $1/1024/1024}') freed_gb=$(echo "$total_size_cleaned" | awk '{printf "%.2f", $1/1024/1024}')
local summary_line="Space freed: ${GREEN}${freed_gb}GB${NC}" local summary_line="Space freed: ${GREEN}${freed_gb}GB${NC}"
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
summary_line="Would free: ${GREEN}${freed_gb}GB${NC}"
fi
[[ $total_items_cleaned -gt 0 ]] && summary_line+=" | Items: $total_items_cleaned" [[ $total_items_cleaned -gt 0 ]] && summary_line+=" | Items: $total_items_cleaned"
summary_line+=" | Free: $(get_free_space)" summary_line+=" | Free: $(get_free_space)"
summary_details+=("$summary_line") summary_details+=("$summary_line")
@@ -233,6 +240,7 @@ show_help() {
echo "" echo ""
echo -e "${YELLOW}Options:${NC}" echo -e "${YELLOW}Options:${NC}"
echo " --paths Edit custom scan directories" echo " --paths Edit custom scan directories"
echo " --dry-run Preview purge actions without making changes"
echo " --debug Enable debug logging" echo " --debug Enable debug logging"
echo " --help Show this help message" echo " --help Show this help message"
echo "" echo ""
@@ -262,6 +270,9 @@ main() {
"--debug") "--debug")
export MO_DEBUG=1 export MO_DEBUG=1
;; ;;
"--dry-run" | "-n")
export MOLE_DRY_RUN=1
;;
*) *)
echo "Unknown option: $arg" echo "Unknown option: $arg"
echo "Use 'mo purge --help' for usage information" echo "Use 'mo purge --help' for usage information"
@@ -271,6 +282,10 @@ main() {
done done
start_purge start_purge
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
echo -e "${YELLOW}${ICON_DRY_RUN} DRY RUN MODE${NC}, No project artifacts will be removed"
printf '\n'
fi
hide_cursor hide_cursor
perform_purge perform_purge
show_cursor show_cursor

View File

@@ -60,6 +60,10 @@ supports_touchid() {
return 1 return 1
} }
touchid_dry_run_enabled() {
[[ "${MOLE_DRY_RUN:-0}" == "1" ]]
}
# Show current Touch ID status # Show current Touch ID status
show_status() { show_status() {
if is_touchid_configured; then if is_touchid_configured; then
@@ -74,6 +78,16 @@ enable_touchid() {
# Cleanup trap handled by global EXIT trap # Cleanup trap handled by global EXIT trap
local temp_file="" local temp_file=""
if touchid_dry_run_enabled; then
if is_touchid_configured; then
echo -e "${GREEN}${ICON_SUCCESS} Touch ID is already enabled, no changes needed${NC}"
else
echo -e "${GREEN}${ICON_SUCCESS} [DRY RUN] Would enable Touch ID for sudo${NC}"
echo -e "${GRAY}${ICON_REVIEW} Target files: ${PAM_SUDO_FILE} and/or ${PAM_SUDO_LOCAL_FILE}${NC}"
fi
return 0
fi
# First check if system supports Touch ID # First check if system supports Touch ID
if ! supports_touchid; then if ! supports_touchid; then
log_warning "This Mac may not support Touch ID" log_warning "This Mac may not support Touch ID"
@@ -201,6 +215,16 @@ disable_touchid() {
# Cleanup trap handled by global EXIT trap # Cleanup trap handled by global EXIT trap
local temp_file="" local temp_file=""
if touchid_dry_run_enabled; then
if ! is_touchid_configured; then
echo -e "${YELLOW}Touch ID is not currently enabled${NC}"
else
echo -e "${GREEN}${ICON_SUCCESS} [DRY RUN] Would disable Touch ID for sudo${NC}"
echo -e "${GRAY}${ICON_REVIEW} Target files: ${PAM_SUDO_FILE} and/or ${PAM_SUDO_LOCAL_FILE}${NC}"
fi
return 0
fi
if ! is_touchid_configured; then if ! is_touchid_configured; then
echo -e "${YELLOW}Touch ID is not currently enabled${NC}" echo -e "${YELLOW}Touch ID is not currently enabled${NC}"
return 0 return 0
@@ -303,12 +327,39 @@ show_menu() {
# Main # Main
main() { main() {
local command="${1:-}" local command=""
local arg
case "$command" in for arg in "$@"; do
case "$arg" in
"--dry-run" | "-n")
export MOLE_DRY_RUN=1
;;
"--help" | "-h") "--help" | "-h")
show_touchid_help show_touchid_help
return 0
;; ;;
enable | disable | status)
if [[ -z "$command" ]]; then
command="$arg"
else
log_error "Only one touchid command is supported per run"
return 1
fi
;;
*)
log_error "Unknown command: $arg"
return 1
;;
esac
done
if touchid_dry_run_enabled; then
echo -e "${YELLOW}${ICON_DRY_RUN} DRY RUN MODE${NC}, No sudo authentication files will be modified"
echo ""
fi
case "$command" in
enable) enable)
enable_touchid enable_touchid
;; ;;

View File

@@ -822,10 +822,17 @@ main() {
"--debug") "--debug")
export MO_DEBUG=1 export MO_DEBUG=1
;; ;;
"--dry-run" | "-n")
export MOLE_DRY_RUN=1
;;
esac esac
done done
hide_cursor hide_cursor
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
echo -e "${YELLOW}${ICON_DRY_RUN} DRY RUN MODE${NC}, No app files or settings will be modified"
printf '\n'
fi
local first_scan=true local first_scan=true
while true; do while true; do

View File

@@ -1367,6 +1367,7 @@ clean_project_artifacts() {
echo "" echo ""
local stats_dir="${XDG_CACHE_HOME:-$HOME/.cache}/mole" local stats_dir="${XDG_CACHE_HOME:-$HOME/.cache}/mole"
local cleaned_count=0 local cleaned_count=0
local dry_run_mode="${MOLE_DRY_RUN:-0}"
for idx in "${selected_indices[@]}"; do for idx in "${selected_indices[@]}"; do
local item_path="${item_paths[idx]}" local item_path="${item_paths[idx]}"
local artifact_type=$(basename "$item_path") local artifact_type=$(basename "$item_path")
@@ -1388,7 +1389,7 @@ clean_project_artifacts() {
fi fi
if [[ -e "$item_path" ]]; then if [[ -e "$item_path" ]]; then
safe_remove "$item_path" true safe_remove "$item_path" true
if [[ ! -e "$item_path" ]]; then if [[ "$dry_run_mode" == "1" || ! -e "$item_path" ]]; then
local current_total=$(cat "$stats_dir/purge_stats" 2> /dev/null || echo "0") local current_total=$(cat "$stats_dir/purge_stats" 2> /dev/null || echo "0")
echo "$((current_total + size_kb))" > "$stats_dir/purge_stats" echo "$((current_total + size_kb))" > "$stats_dir/purge_stats"
cleaned_count=$((cleaned_count + 1)) cleaned_count=$((cleaned_count + 1))
@@ -1396,8 +1397,12 @@ clean_project_artifacts() {
fi fi
if [[ -t 1 ]]; then if [[ -t 1 ]]; then
stop_inline_spinner stop_inline_spinner
if [[ "$dry_run_mode" == "1" ]]; then
echo -e "${GREEN}${ICON_SUCCESS}${NC} [DRY RUN] $project_path, $artifact_type${NC}, ${GREEN}$size_human${NC}"
else
echo -e "${GREEN}${ICON_SUCCESS}${NC} $project_path, $artifact_type${NC}, ${GREEN}$size_human${NC}" echo -e "${GREEN}${ICON_SUCCESS}${NC} $project_path, $artifact_type${NC}, ${GREEN}$size_human${NC}"
fi fi
fi
done done
# Update count # Update count
echo "$cleaned_count" > "$stats_dir/purge_count" echo "$cleaned_count" > "$stats_dir/purge_count"

View File

@@ -1419,6 +1419,11 @@ force_kill_app() {
local app_name="$1" local app_name="$1"
local app_path="${2:-""}" local app_path="${2:-""}"
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
debug_log "[DRY RUN] Would terminate running app: $app_name"
return 0
fi
# Get the executable name from bundle if app_path is provided # Get the executable name from bundle if app_path is provided
local exec_name="" local exec_name=""
if [[ -n "$app_path" && -e "$app_path/Contents/Info.plist" ]]; then if [[ -n "$app_path" && -e "$app_path/Contents/Info.plist" ]]; then

View File

@@ -18,6 +18,7 @@ show_installer_help() {
echo "Find and remove installer files (.dmg, .pkg, .iso, .xip, .zip)." echo "Find and remove installer files (.dmg, .pkg, .iso, .xip, .zip)."
echo "" echo ""
echo "Options:" echo "Options:"
echo " --dry-run Preview installer cleanup without making changes"
echo " --debug Show detailed operation logs" echo " --debug Show detailed operation logs"
echo " -h, --help Show this help message" echo " -h, --help Show this help message"
} }
@@ -45,6 +46,7 @@ show_touchid_help() {
echo " status Show current Touch ID status" echo " status Show current Touch ID status"
echo "" echo ""
echo "Options:" echo "Options:"
echo " --dry-run Preview Touch ID changes without modifying sudo config"
echo " -h, --help Show this help message" echo " -h, --help Show this help message"
echo "" echo ""
echo "If no command is provided, an interactive menu is shown." echo "If no command is provided, an interactive menu is shown."
@@ -56,6 +58,7 @@ show_uninstall_help() {
echo "Interactively remove applications and their leftover files." echo "Interactively remove applications and their leftover files."
echo "" echo ""
echo "Options:" echo "Options:"
echo " --dry-run Preview app uninstallation without making changes"
echo " --debug Show detailed operation logs" echo " --debug Show detailed operation logs"
echo " -h, --help Show this help message" echo " -h, --help Show this help message"
} }

View File

@@ -11,6 +11,14 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
# Batch uninstall with a single confirmation. # Batch uninstall with a single confirmation.
get_lsregister_path() {
echo "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister"
}
is_uninstall_dry_run() {
[[ "${MOLE_DRY_RUN:-0}" == "1" ]]
}
# High-performance sensitive data detection (pure Bash, no subprocess) # High-performance sensitive data detection (pure Bash, no subprocess)
# Faster than grep for batch operations, especially when processing many apps # Faster than grep for batch operations, especially when processing many apps
has_sensitive_data() { has_sensitive_data() {
@@ -77,6 +85,11 @@ stop_launch_services() {
local bundle_id="$1" local bundle_id="$1"
local has_system_files="${2:-false}" local has_system_files="${2:-false}"
if is_uninstall_dry_run; then
debug_log "[DRY RUN] Would unload launch services for bundle: $bundle_id"
return 0
fi
[[ -z "$bundle_id" || "$bundle_id" == "unknown" ]] && return 0 [[ -z "$bundle_id" || "$bundle_id" == "unknown" ]] && return 0
# Validate bundle_id format: must be reverse-DNS style (e.g., com.example.app) # Validate bundle_id format: must be reverse-DNS style (e.g., com.example.app)
@@ -152,6 +165,11 @@ remove_login_item() {
local app_name="$1" local app_name="$1"
local bundle_id="$2" local bundle_id="$2"
if is_uninstall_dry_run; then
debug_log "[DRY RUN] Would remove login item: ${app_name:-$bundle_id}"
return 0
fi
# Skip if no identifiers provided # Skip if no identifiers provided
[[ -z "$app_name" && -z "$bundle_id" ]] && return 0 [[ -z "$app_name" && -z "$bundle_id" ]] && return 0
@@ -201,7 +219,12 @@ remove_file_list() {
safe_remove_symlink "$file" "$use_sudo" && ((++count)) || true safe_remove_symlink "$file" "$use_sudo" && ((++count)) || true
else else
if [[ "$use_sudo" == "true" ]]; then if [[ "$use_sudo" == "true" ]]; then
if is_uninstall_dry_run; then
debug_log "[DRY RUN] Would sudo remove: $file"
((++count))
else
safe_sudo_remove "$file" && ((++count)) || true safe_sudo_remove "$file" && ((++count)) || true
fi
else else
safe_remove "$file" true && ((++count)) || true safe_remove "$file" true && ((++count)) || true
fi fi
@@ -437,7 +460,7 @@ batch_uninstall_applications() {
export MOLE_UNINSTALL_MODE=1 export MOLE_UNINSTALL_MODE=1
# Request sudo if needed. # Request sudo if needed.
if [[ ${#sudo_apps[@]} -gt 0 ]]; then if [[ ${#sudo_apps[@]} -gt 0 && "${MOLE_DRY_RUN:-0}" != "1" ]]; then
if ! sudo -n true 2> /dev/null; then if ! sudo -n true 2> /dev/null; then
if ! request_sudo_access "Admin required for system apps: ${sudo_apps[*]}"; then if ! request_sudo_access "Admin required for system apps: ${sudo_apps[*]}"; then
echo "" echo ""
@@ -546,6 +569,11 @@ batch_uninstall_applications() {
reason="failed to remove symlink" reason="failed to remove symlink"
fi fi
fi fi
else
if is_uninstall_dry_run; then
if ! safe_remove "$app_path" true; then
reason="dry-run path validation failed"
fi
else else
local ret=0 local ret=0
safe_sudo_remove "$app_path" || ret=$? safe_sudo_remove "$app_path" || ret=$?
@@ -555,6 +583,7 @@ batch_uninstall_applications() {
IFS='|' read -r reason suggestion <<< "$diagnosis" IFS='|' read -r reason suggestion <<< "$diagnosis"
fi fi
fi fi
fi
else else
if ! safe_remove "$app_path" true; then if ! safe_remove "$app_path" true; then
if [[ ! -w "$(dirname "$app_path")" ]]; then if [[ ! -w "$(dirname "$app_path")" ]]; then
@@ -583,11 +612,15 @@ batch_uninstall_applications() {
remove_file_list "$system_all" "true" > /dev/null remove_file_list "$system_all" "true" > /dev/null
fi fi
# Clean up macOS defaults (preference domains). # Defaults writes are side effects that should never run in dry-run mode.
if [[ -n "$bundle_id" && "$bundle_id" != "unknown" ]]; then if [[ -n "$bundle_id" && "$bundle_id" != "unknown" ]]; then
if is_uninstall_dry_run; then
debug_log "[DRY RUN] Would clear defaults domain: $bundle_id"
else
if defaults read "$bundle_id" &> /dev/null; then if defaults read "$bundle_id" &> /dev/null; then
defaults delete "$bundle_id" 2> /dev/null || true defaults delete "$bundle_id" 2> /dev/null || true
fi fi
fi
# ByHost preferences (machine-specific). # ByHost preferences (machine-specific).
if [[ -d "$HOME/Library/Preferences/ByHost" ]]; then if [[ -d "$HOME/Library/Preferences/ByHost" ]]; then
@@ -644,9 +677,16 @@ batch_uninstall_applications() {
local success_text="app" local success_text="app"
[[ $success_count -gt 1 ]] && success_text="apps" [[ $success_count -gt 1 ]] && success_text="apps"
local success_line="Removed ${success_count} ${success_text}" local success_line="Removed ${success_count} ${success_text}"
if is_uninstall_dry_run; then
success_line="Would remove ${success_count} ${success_text}"
fi
if [[ -n "$freed_display" ]]; then if [[ -n "$freed_display" ]]; then
if is_uninstall_dry_run; then
success_line+=", would free ${GREEN}${freed_display}${NC}"
else
success_line+=", freed ${GREEN}${freed_display}${NC}" success_line+=", freed ${GREEN}${freed_display}${NC}"
fi fi
fi
# Format app list with max 3 per line. # Format app list with max 3 per line.
if [[ ${#success_items[@]} -gt 0 ]]; then if [[ ${#success_items[@]} -gt 0 ]]; then
@@ -730,24 +770,48 @@ batch_uninstall_applications() {
if [[ "$summary_status" == "warn" ]]; then if [[ "$summary_status" == "warn" ]]; then
title="Uninstall incomplete" title="Uninstall incomplete"
fi fi
if is_uninstall_dry_run; then
title="Uninstall dry run complete"
fi
echo "" echo ""
print_summary_block "$title" "${summary_details[@]}" print_summary_block "$title" "${summary_details[@]}"
printf '\n' printf '\n'
if [[ $success_count -gt 0 && ${#success_items[@]} -gt 0 ]]; then # Auto-run brew autoremove if Homebrew casks were uninstalled
# Kick off LaunchServices rebuild in background immediately after summary. if [[ $brew_apps_removed -gt 0 ]]; then
# The caller shows a 3s "Press Enter" prompt, giving the rebuild time to finish if is_uninstall_dry_run; then
# before the user returns to the app list — fixes stale Spotlight entries (#490). log_info "[DRY RUN] Would run brew autoremove"
( else
refresh_launch_services_after_uninstall 2> /dev/null || true # Show spinner while checking for orphaned dependencies
remove_apps_from_dock "${success_items[@]}" 2> /dev/null || true if [[ -t 1 ]]; then
) > /dev/null 2>&1 & start_inline_spinner "Checking brew dependencies..."
fi fi
# brew autoremove can be slow — run in background so the prompt returns quickly. local autoremove_output removed_count
if [[ $brew_apps_removed -gt 0 ]]; then autoremove_output=$(HOMEBREW_NO_ENV_HINTS=1 brew autoremove 2> /dev/null) || true
(HOMEBREW_NO_ENV_HINTS=1 brew autoremove > /dev/null 2>&1 || 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
fi
# Clean up Dock entries for uninstalled apps.
if [[ $success_count -gt 0 && ${#success_items[@]} -gt 0 ]]; then
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
fi
fi fi
_cleanup_sudo_keepalive _cleanup_sudo_keepalive

View File

@@ -168,6 +168,11 @@ brew_uninstall_cask() {
local cask_name="$1" local cask_name="$1"
local app_path="${2:-}" local app_path="${2:-}"
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
debug_log "[DRY RUN] Would brew uninstall --cask --zap $cask_name"
return 0
fi
is_homebrew_available || return 1 is_homebrew_available || return 1
[[ -z "$cask_name" ]] && return 1 [[ -z "$cask_name" ]] && return 1

46
mole
View File

@@ -234,10 +234,16 @@ show_help() {
printf " %s%-28s%s %s\n" "$GREEN" "mo optimize --dry-run" "$NC" "Preview optimization" printf " %s%-28s%s %s\n" "$GREEN" "mo optimize --dry-run" "$NC" "Preview optimization"
printf " %s%-28s%s %s\n" "$GREEN" "mo optimize --whitelist" "$NC" "Manage protected items" printf " %s%-28s%s %s\n" "$GREEN" "mo optimize --whitelist" "$NC" "Manage protected items"
printf " %s%-28s%s %s\n" "$GREEN" "mo uninstall --dry-run" "$NC" "Preview app uninstall"
printf " %s%-28s%s %s\n" "$GREEN" "mo purge --dry-run" "$NC" "Preview project purge"
printf " %s%-28s%s %s\n" "$GREEN" "mo installer --dry-run" "$NC" "Preview installer cleanup"
printf " %s%-28s%s %s\n" "$GREEN" "mo touchid enable --dry-run" "$NC" "Preview Touch ID setup"
printf " %s%-28s%s %s\n" "$GREEN" "mo completion --dry-run" "$NC" "Preview shell completion edits"
printf " %s%-28s%s %s\n" "$GREEN" "mo purge --paths" "$NC" "Configure scan directories" printf " %s%-28s%s %s\n" "$GREEN" "mo purge --paths" "$NC" "Configure scan directories"
printf " %s%-28s%s %s\n" "$GREEN" "mo analyze /Volumes" "$NC" "Analyze external drives only" printf " %s%-28s%s %s\n" "$GREEN" "mo analyze /Volumes" "$NC" "Analyze external drives only"
printf " %s%-28s%s %s\n" "$GREEN" "mo update --force" "$NC" "Force reinstall latest stable version" printf " %s%-28s%s %s\n" "$GREEN" "mo update --force" "$NC" "Force reinstall latest stable version"
printf " %s%-28s%s %s\n" "$GREEN" "mo update --nightly" "$NC" "Install latest unreleased main branch build" printf " %s%-28s%s %s\n" "$GREEN" "mo update --nightly" "$NC" "Install latest unreleased main branch build"
printf " %s%-28s%s %s\n" "$GREEN" "mo remove --dry-run" "$NC" "Preview Mole removal"
echo echo
printf "%s%s%s\n" "$BLUE" "OPTIONS" "$NC" printf "%s%s%s\n" "$BLUE" "OPTIONS" "$NC"
printf " %s%-28s%s %s\n" "$GREEN" "--debug" "$NC" "Show detailed operation logs" printf " %s%-28s%s %s\n" "$GREEN" "--debug" "$NC" "Show detailed operation logs"
@@ -462,6 +468,8 @@ update_mole() {
# Remove flow (Homebrew + manual + config/cache). # Remove flow (Homebrew + manual + config/cache).
remove_mole() { remove_mole() {
local dry_run_mode="${1:-false}"
if [[ -t 1 ]]; then if [[ -t 1 ]]; then
start_inline_spinner "Detecting Mole installations..." start_inline_spinner "Detecting Mole installations..."
else else
@@ -571,6 +579,31 @@ remove_mole() {
esac esac
local has_error=false local has_error=false
if [[ "$dry_run_mode" == "true" ]]; then
echo ""
echo -e "${YELLOW}${ICON_DRY_RUN} DRY RUN MODE${NC}, no files will be removed"
if [[ "$is_homebrew" == "true" ]]; then
echo -e " ${GRAY}${ICON_LIST} Would run: brew uninstall --force mole${NC}"
fi
if [[ ${manual_count:-0} -gt 0 ]]; then
for install in "${manual_installs[@]}"; do
[[ -f "$install" ]] && echo -e " ${GRAY}${ICON_LIST} Would remove: ${install}${NC}"
done
fi
if [[ ${alias_count:-0} -gt 0 ]]; then
for alias in "${alias_installs[@]}"; do
[[ -f "$alias" ]] && echo -e " ${GRAY}${ICON_LIST} Would remove: ${alias}${NC}"
done
fi
[[ -d "$HOME/.cache/mole" ]] && echo -e " ${GRAY}${ICON_LIST} Would remove: $HOME/.cache/mole${NC}"
[[ -d "$HOME/.config/mole" ]] && echo -e " ${GRAY}${ICON_LIST} Would remove: $HOME/.config/mole${NC}"
printf '\n%s\n\n' "${GREEN}${ICON_SUCCESS}${NC} Dry run complete, no changes made"
exit 0
fi
if [[ "$is_homebrew" == "true" ]]; then if [[ "$is_homebrew" == "true" ]]; then
if [[ -z "$brew_cmd" ]]; then if [[ -z "$brew_cmd" ]]; then
log_error "Homebrew command not found. Please ensure Homebrew is installed and in your PATH." log_error "Homebrew command not found. Please ensure Homebrew is installed and in your PATH."
@@ -859,7 +892,18 @@ main() {
exit 0 exit 0
;; ;;
"remove") "remove")
remove_mole local dry_run_remove=false
for arg in "${args[@]:1}"; do
case "$arg" in
"--dry-run" | "-n") dry_run_remove=true ;;
*)
echo "Unknown remove option: $arg"
echo "Use 'mole remove [--dry-run]' for supported options."
exit 1
;;
esac
done
remove_mole "$dry_run_remove"
;; ;;
"help" | "--help" | "-h") "help" | "--help" | "-h")
show_help show_help

View File

@@ -176,3 +176,17 @@ EOF
run grep "pam_tid.so" "$pam_file" run grep "pam_tid.so" "$pam_file"
[ "$status" -ne 0 ] [ "$status" -ne 0 ]
} }
@test "touchid enable --dry-run does not modify pam file" {
pam_file="$HOME/pam_enable_dry_run"
cat >"$pam_file" <<'EOF'
auth sufficient pam_opendirectory.so
EOF
run env MOLE_PAM_SUDO_FILE="$pam_file" "$PROJECT_ROOT/bin/touchid.sh" enable --dry-run
[ "$status" -eq 0 ]
[[ "$output" == *"DRY RUN MODE"* ]]
run grep "pam_tid.so" "$pam_file"
[ "$status" -ne 0 ]
}

View File

@@ -132,17 +132,22 @@ setup() {
} }
@test "completion auto-install detects already installed" { @test "completion auto-install detects already installed" {
# shellcheck disable=SC2031
export SHELL=/bin/zsh
mkdir -p "$HOME" mkdir -p "$HOME"
# shellcheck disable=SC2016 # shellcheck disable=SC2016
echo 'eval "$(mole completion zsh)"' >"$HOME/.zshrc" echo 'eval "$(mole completion zsh)"' >"$HOME/.zshrc"
run "$PROJECT_ROOT/bin/completion.sh" run env SHELL=/bin/zsh "$PROJECT_ROOT/bin/completion.sh"
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
[[ "$output" == *"updated"* ]] [[ "$output" == *"updated"* ]]
} }
@test "completion --dry-run previews changes without writing config" {
run env SHELL=/bin/zsh "$PROJECT_ROOT/bin/completion.sh" --dry-run
[ "$status" -eq 0 ]
[[ "$output" == *"DRY RUN MODE"* ]]
[ ! -f "$HOME/.zshrc" ]
}
@test "completion script handles invalid shell argument" { @test "completion script handles invalid shell argument" {
run "$PROJECT_ROOT/bin/completion.sh" invalid-shell run "$PROJECT_ROOT/bin/completion.sh" invalid-shell
[ "$status" -ne 0 ] [ "$status" -ne 0 ]

View File

@@ -46,6 +46,13 @@ setup() {
[[ "$output" == *"Unknown option"* ]] [[ "$output" == *"Unknown option"* ]]
} }
@test "installer.sh accepts --dry-run option" {
run env HOME="$HOME" TERM="xterm-256color" "$PROJECT_ROOT/bin/installer.sh" --dry-run
[[ "$status" -eq 0 || "$status" -eq 2 ]]
[[ "$output" == *"DRY RUN MODE"* ]]
}
# Test scan_installers_in_path function directly # Test scan_installers_in_path function directly
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

View File

@@ -733,6 +733,22 @@ EOF
true true
} }
@test "mo purge: accepts --dry-run flag" {
if ! command -v gtimeout >/dev/null 2>&1 && ! command -v timeout >/dev/null 2>&1; then
skip "gtimeout/timeout not available"
fi
timeout_cmd="timeout"
command -v timeout >/dev/null 2>&1 || timeout_cmd="gtimeout"
run bash -c "
export HOME='$HOME'
$timeout_cmd 2 '$PROJECT_ROOT/mole' purge --dry-run < /dev/null 2>&1 || true
"
[[ "$output" == *"DRY RUN MODE"* ]] || [[ "$output" == *"Dry run complete"* ]]
}
@test "mo purge: creates cache directory for stats" { @test "mo purge: creates cache directory for stats" {
if ! command -v gtimeout >/dev/null 2>&1 && ! command -v timeout >/dev/null 2>&1; then if ! command -v gtimeout >/dev/null 2>&1 && ! command -v timeout >/dev/null 2>&1; then
skip "gtimeout/timeout not available" skip "gtimeout/timeout not available"
@@ -798,7 +814,6 @@ EOF
[[ "$result" == "NOT_FOUND" ]] [[ "$result" == "NOT_FOUND" ]]
} }
# Integration test for bin scanning # Integration test for bin scanning
@test "scan_purge_targets: includes .NET bin directories with Debug/Release" { @test "scan_purge_targets: includes .NET bin directories with Debug/Release" {
mkdir -p "$HOME/www/dotnet-app/bin/Debug" mkdir -p "$HOME/www/dotnet-app/bin/Debug"

View File

@@ -229,7 +229,6 @@ EOF
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
} }
@test "decode_file_list validates base64 encoding" { @test "decode_file_list validates base64 encoding" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail set -euo pipefail
@@ -361,3 +360,25 @@ EOF
[ ! -d "$HOME/.config/mole" ] [ ! -d "$HOME/.config/mole" ]
[ ! -d "$HOME/.cache/mole" ] [ ! -d "$HOME/.cache/mole" ]
} }
@test "remove_mole dry-run keeps manual binaries and caches" {
mkdir -p "$HOME/.local/bin"
touch "$HOME/.local/bin/mole"
touch "$HOME/.local/bin/mo"
mkdir -p "$HOME/.config/mole" "$HOME/.cache/mole"
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="/usr/bin:/bin" bash --noprofile --norc <<'EOF'
set -euo pipefail
start_inline_spinner() { :; }
stop_inline_spinner() { :; }
export -f start_inline_spinner stop_inline_spinner
printf '\n' | "$PROJECT_ROOT/mole" remove --dry-run
EOF
[ "$status" -eq 0 ]
[[ "$output" == *"DRY RUN MODE"* ]]
[ -f "$HOME/.local/bin/mole" ]
[ -f "$HOME/.local/bin/mo" ]
[ -d "$HOME/.config/mole" ]
[ -d "$HOME/.cache/mole" ]
}