mirror of
https://github.com/tw93/Mole.git
synced 2026-03-22 18:30:08 +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:
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 533 KiB After Width: | Height: | Size: 541 KiB |
@@ -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 --debug # Run with detailed operation logs
|
||||
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 analyze /Volumes # Analyze external drives only
|
||||
```
|
||||
@@ -75,7 +81,7 @@ mo analyze /Volumes # Analyze external drives only
|
||||
## Tips
|
||||
|
||||
- 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`.
|
||||
- Navigation: Mole supports arrow keys and Vim bindings `h/j/k/l`.
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
current_shell="${SHELL##*/}"
|
||||
if [[ -z "$current_shell" ]]; then
|
||||
@@ -73,6 +98,10 @@ if [[ $# -eq 0 ]]; 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 [[ "$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="$(stat -f '%Mp%Lp' "$config_file" 2> /dev/null || true)"
|
||||
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 ""
|
||||
fi
|
||||
fi
|
||||
log_error "mole not found in PATH, install Mole before enabling completion"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 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 [[ "$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="$(stat -f '%Mp%Lp' "$config_file" 2> /dev/null || true)"
|
||||
temp_file="$(mktemp)"
|
||||
@@ -114,6 +150,11 @@ if [[ $# -eq 0 ]]; then
|
||||
echo -e "${GRAY}Will add to ${config_file}:${NC}"
|
||||
echo " $completion_line"
|
||||
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}: "
|
||||
IFS= read -r -s -n1 key || key=""
|
||||
drain_pending_input
|
||||
@@ -227,6 +268,7 @@ Setup shell tab completion for mole and mo commands.
|
||||
|
||||
Auto-install:
|
||||
mole completion # Auto-detect shell and install
|
||||
mole completion --dry-run # Preview config changes without writing files
|
||||
|
||||
Manual install:
|
||||
mole completion bash # Generate bash completion script
|
||||
|
||||
@@ -650,13 +650,22 @@ perform_installers() {
|
||||
show_summary() {
|
||||
local summary_heading="Installers cleaned"
|
||||
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
|
||||
local freed_mb
|
||||
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+=("Your Mac is cleaner now!")
|
||||
fi
|
||||
else
|
||||
summary_details+=("No installers were removed")
|
||||
fi
|
||||
@@ -675,6 +684,9 @@ main() {
|
||||
"--debug")
|
||||
export MO_DEBUG=1
|
||||
;;
|
||||
"--dry-run" | "-n")
|
||||
export MOLE_DRY_RUN=1
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $arg"
|
||||
exit 1
|
||||
@@ -682,6 +694,11 @@ main() {
|
||||
esac
|
||||
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
|
||||
perform_installers
|
||||
local exit_code=$?
|
||||
|
||||
15
bin/purge.sh
15
bin/purge.sh
@@ -205,11 +205,18 @@ perform_purge() {
|
||||
rm -f "$stats_dir/purge_count"
|
||||
fi
|
||||
|
||||
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
|
||||
summary_heading="Dry run complete - no changes made"
|
||||
fi
|
||||
|
||||
if [[ $total_size_cleaned -gt 0 ]]; then
|
||||
local freed_gb
|
||||
freed_gb=$(echo "$total_size_cleaned" | awk '{printf "%.2f", $1/1024/1024}')
|
||||
|
||||
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"
|
||||
summary_line+=" | Free: $(get_free_space)"
|
||||
summary_details+=("$summary_line")
|
||||
@@ -233,6 +240,7 @@ show_help() {
|
||||
echo ""
|
||||
echo -e "${YELLOW}Options:${NC}"
|
||||
echo " --paths Edit custom scan directories"
|
||||
echo " --dry-run Preview purge actions without making changes"
|
||||
echo " --debug Enable debug logging"
|
||||
echo " --help Show this help message"
|
||||
echo ""
|
||||
@@ -262,6 +270,9 @@ main() {
|
||||
"--debug")
|
||||
export MO_DEBUG=1
|
||||
;;
|
||||
"--dry-run" | "-n")
|
||||
export MOLE_DRY_RUN=1
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $arg"
|
||||
echo "Use 'mo purge --help' for usage information"
|
||||
@@ -271,6 +282,10 @@ main() {
|
||||
done
|
||||
|
||||
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
|
||||
perform_purge
|
||||
show_cursor
|
||||
|
||||
@@ -60,6 +60,10 @@ supports_touchid() {
|
||||
return 1
|
||||
}
|
||||
|
||||
touchid_dry_run_enabled() {
|
||||
[[ "${MOLE_DRY_RUN:-0}" == "1" ]]
|
||||
}
|
||||
|
||||
# Show current Touch ID status
|
||||
show_status() {
|
||||
if is_touchid_configured; then
|
||||
@@ -74,6 +78,16 @@ enable_touchid() {
|
||||
# Cleanup trap handled by global EXIT trap
|
||||
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
|
||||
if ! supports_touchid; then
|
||||
log_warning "This Mac may not support Touch ID"
|
||||
@@ -201,6 +215,16 @@ disable_touchid() {
|
||||
# Cleanup trap handled by global EXIT trap
|
||||
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
|
||||
echo -e "${YELLOW}Touch ID is not currently enabled${NC}"
|
||||
return 0
|
||||
@@ -303,12 +327,39 @@ show_menu() {
|
||||
|
||||
# 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")
|
||||
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_touchid
|
||||
;;
|
||||
|
||||
@@ -822,10 +822,17 @@ main() {
|
||||
"--debug")
|
||||
export MO_DEBUG=1
|
||||
;;
|
||||
"--dry-run" | "-n")
|
||||
export MOLE_DRY_RUN=1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
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
|
||||
while true; do
|
||||
|
||||
@@ -1367,6 +1367,7 @@ clean_project_artifacts() {
|
||||
echo ""
|
||||
local stats_dir="${XDG_CACHE_HOME:-$HOME/.cache}/mole"
|
||||
local cleaned_count=0
|
||||
local dry_run_mode="${MOLE_DRY_RUN:-0}"
|
||||
for idx in "${selected_indices[@]}"; do
|
||||
local item_path="${item_paths[idx]}"
|
||||
local artifact_type=$(basename "$item_path")
|
||||
@@ -1388,7 +1389,7 @@ clean_project_artifacts() {
|
||||
fi
|
||||
if [[ -e "$item_path" ]]; then
|
||||
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")
|
||||
echo "$((current_total + size_kb))" > "$stats_dir/purge_stats"
|
||||
cleaned_count=$((cleaned_count + 1))
|
||||
@@ -1396,8 +1397,12 @@ clean_project_artifacts() {
|
||||
fi
|
||||
if [[ -t 1 ]]; then
|
||||
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}"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
# Update count
|
||||
echo "$cleaned_count" > "$stats_dir/purge_count"
|
||||
|
||||
@@ -1419,6 +1419,11 @@ force_kill_app() {
|
||||
local app_name="$1"
|
||||
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
|
||||
local exec_name=""
|
||||
if [[ -n "$app_path" && -e "$app_path/Contents/Info.plist" ]]; then
|
||||
|
||||
@@ -18,6 +18,7 @@ show_installer_help() {
|
||||
echo "Find and remove installer files (.dmg, .pkg, .iso, .xip, .zip)."
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --dry-run Preview installer cleanup without making changes"
|
||||
echo " --debug Show detailed operation logs"
|
||||
echo " -h, --help Show this help message"
|
||||
}
|
||||
@@ -45,6 +46,7 @@ show_touchid_help() {
|
||||
echo " status Show current Touch ID status"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --dry-run Preview Touch ID changes without modifying sudo config"
|
||||
echo " -h, --help Show this help message"
|
||||
echo ""
|
||||
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 ""
|
||||
echo "Options:"
|
||||
echo " --dry-run Preview app uninstallation without making changes"
|
||||
echo " --debug Show detailed operation logs"
|
||||
echo " -h, --help Show this help message"
|
||||
}
|
||||
|
||||
@@ -11,6 +11,14 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
|
||||
# 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)
|
||||
# Faster than grep for batch operations, especially when processing many apps
|
||||
has_sensitive_data() {
|
||||
@@ -77,6 +85,11 @@ stop_launch_services() {
|
||||
local bundle_id="$1"
|
||||
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
|
||||
|
||||
# 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 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
|
||||
[[ -z "$app_name" && -z "$bundle_id" ]] && return 0
|
||||
|
||||
@@ -201,7 +219,12 @@ remove_file_list() {
|
||||
safe_remove_symlink "$file" "$use_sudo" && ((++count)) || true
|
||||
else
|
||||
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
|
||||
fi
|
||||
else
|
||||
safe_remove "$file" true && ((++count)) || true
|
||||
fi
|
||||
@@ -437,7 +460,7 @@ batch_uninstall_applications() {
|
||||
export MOLE_UNINSTALL_MODE=1
|
||||
|
||||
# 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 ! request_sudo_access "Admin required for system apps: ${sudo_apps[*]}"; then
|
||||
echo ""
|
||||
@@ -546,6 +569,11 @@ batch_uninstall_applications() {
|
||||
reason="failed to remove symlink"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
if is_uninstall_dry_run; then
|
||||
if ! safe_remove "$app_path" true; then
|
||||
reason="dry-run path validation failed"
|
||||
fi
|
||||
else
|
||||
local ret=0
|
||||
safe_sudo_remove "$app_path" || ret=$?
|
||||
@@ -555,6 +583,7 @@ batch_uninstall_applications() {
|
||||
IFS='|' read -r reason suggestion <<< "$diagnosis"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
else
|
||||
if ! safe_remove "$app_path" true; then
|
||||
if [[ ! -w "$(dirname "$app_path")" ]]; then
|
||||
@@ -583,11 +612,15 @@ batch_uninstall_applications() {
|
||||
remove_file_list "$system_all" "true" > /dev/null
|
||||
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 is_uninstall_dry_run; then
|
||||
debug_log "[DRY RUN] Would clear defaults domain: $bundle_id"
|
||||
else
|
||||
if defaults read "$bundle_id" &> /dev/null; then
|
||||
defaults delete "$bundle_id" 2> /dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# ByHost preferences (machine-specific).
|
||||
if [[ -d "$HOME/Library/Preferences/ByHost" ]]; then
|
||||
@@ -644,9 +677,16 @@ batch_uninstall_applications() {
|
||||
local success_text="app"
|
||||
[[ $success_count -gt 1 ]] && success_text="apps"
|
||||
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 is_uninstall_dry_run; then
|
||||
success_line+=", would free ${GREEN}${freed_display}${NC}"
|
||||
else
|
||||
success_line+=", freed ${GREEN}${freed_display}${NC}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Format app list with max 3 per line.
|
||||
if [[ ${#success_items[@]} -gt 0 ]]; then
|
||||
@@ -730,24 +770,48 @@ batch_uninstall_applications() {
|
||||
if [[ "$summary_status" == "warn" ]]; then
|
||||
title="Uninstall incomplete"
|
||||
fi
|
||||
if is_uninstall_dry_run; then
|
||||
title="Uninstall dry run complete"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
print_summary_block "$title" "${summary_details[@]}"
|
||||
printf '\n'
|
||||
|
||||
if [[ $success_count -gt 0 && ${#success_items[@]} -gt 0 ]]; then
|
||||
# Kick off LaunchServices rebuild in background immediately after summary.
|
||||
# The caller shows a 3s "Press Enter" prompt, giving the rebuild time to finish
|
||||
# before the user returns to the app list — fixes stale Spotlight entries (#490).
|
||||
(
|
||||
refresh_launch_services_after_uninstall 2> /dev/null || true
|
||||
remove_apps_from_dock "${success_items[@]}" 2> /dev/null || true
|
||||
) > /dev/null 2>&1 &
|
||||
# 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
|
||||
|
||||
# brew autoremove can be slow — run in background so the prompt returns quickly.
|
||||
if [[ $brew_apps_removed -gt 0 ]]; then
|
||||
(HOMEBREW_NO_ENV_HINTS=1 brew autoremove > /dev/null 2>&1 || true) &
|
||||
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
|
||||
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
|
||||
|
||||
_cleanup_sudo_keepalive
|
||||
|
||||
@@ -168,6 +168,11 @@ brew_uninstall_cask() {
|
||||
local cask_name="$1"
|
||||
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
|
||||
[[ -z "$cask_name" ]] && return 1
|
||||
|
||||
|
||||
46
mole
46
mole
@@ -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 --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 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 --nightly" "$NC" "Install latest unreleased main branch build"
|
||||
printf " %s%-28s%s %s\n" "$GREEN" "mo remove --dry-run" "$NC" "Preview Mole removal"
|
||||
echo
|
||||
printf "%s%s%s\n" "$BLUE" "OPTIONS" "$NC"
|
||||
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_mole() {
|
||||
local dry_run_mode="${1:-false}"
|
||||
|
||||
if [[ -t 1 ]]; then
|
||||
start_inline_spinner "Detecting Mole installations..."
|
||||
else
|
||||
@@ -571,6 +579,31 @@ remove_mole() {
|
||||
esac
|
||||
|
||||
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 [[ -z "$brew_cmd" ]]; then
|
||||
log_error "Homebrew command not found. Please ensure Homebrew is installed and in your PATH."
|
||||
@@ -859,7 +892,18 @@ main() {
|
||||
exit 0
|
||||
;;
|
||||
"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")
|
||||
show_help
|
||||
|
||||
@@ -176,3 +176,17 @@ EOF
|
||||
run grep "pam_tid.so" "$pam_file"
|
||||
[ "$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 ]
|
||||
}
|
||||
|
||||
@@ -132,17 +132,22 @@ setup() {
|
||||
}
|
||||
|
||||
@test "completion auto-install detects already installed" {
|
||||
# shellcheck disable=SC2031
|
||||
export SHELL=/bin/zsh
|
||||
mkdir -p "$HOME"
|
||||
# shellcheck disable=SC2016
|
||||
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 ]
|
||||
[[ "$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" {
|
||||
run "$PROJECT_ROOT/bin/completion.sh" invalid-shell
|
||||
[ "$status" -ne 0 ]
|
||||
|
||||
@@ -46,6 +46,13 @@ setup() {
|
||||
[[ "$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
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
@@ -733,6 +733,22 @@ EOF
|
||||
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" {
|
||||
if ! command -v gtimeout >/dev/null 2>&1 && ! command -v timeout >/dev/null 2>&1; then
|
||||
skip "gtimeout/timeout not available"
|
||||
@@ -798,7 +814,6 @@ EOF
|
||||
[[ "$result" == "NOT_FOUND" ]]
|
||||
}
|
||||
|
||||
|
||||
# Integration test for bin scanning
|
||||
@test "scan_purge_targets: includes .NET bin directories with Debug/Release" {
|
||||
mkdir -p "$HOME/www/dotnet-app/bin/Debug"
|
||||
|
||||
@@ -229,7 +229,6 @@ EOF
|
||||
[ "$status" -eq 0 ]
|
||||
}
|
||||
|
||||
|
||||
@test "decode_file_list validates base64 encoding" {
|
||||
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
|
||||
set -euo pipefail
|
||||
@@ -361,3 +360,25 @@ EOF
|
||||
[ ! -d "$HOME/.config/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" ]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user