diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 0ddaeeb..6f7b0e0 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -36,7 +36,7 @@ jobs: run: brew install shfmt shellcheck golangci-lint - name: Set up Go - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v5 + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v5 with: go-version: '1.24.6' @@ -89,7 +89,7 @@ jobs: run: brew install shfmt shellcheck golangci-lint - name: Set up Go - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v5 + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v5 with: go-version: '1.24.6' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 71c974a..b1ac6db 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,7 +26,7 @@ jobs: uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4 - name: Set up Go - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v5 + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v5 with: go-version: "1.24.6" @@ -48,7 +48,7 @@ jobs: fi - name: Upload artifacts - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: ${{ matrix.artifact_name }} path: bin/*-darwin-* @@ -60,7 +60,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Download all artifacts - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 with: path: bin pattern: binaries-* diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 21a5efe..4151314 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,7 +17,7 @@ jobs: run: brew install bats-core shellcheck - name: Set up Go - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v5 + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v5 with: go-version: "1.24.6" diff --git a/.gitignore b/.gitignore index 8efd5c9..d245179 100644 --- a/.gitignore +++ b/.gitignore @@ -76,3 +76,6 @@ coverage.html session.json run_tests.ps1 +AGENTS.md +mole_guidelines.md +CLAUDE.md diff --git a/README.md b/README.md index d843f78..2c7ad76 100644 --- a/README.md +++ b/README.md @@ -61,13 +61,15 @@ mo remove # Remove Mole from system mo --help # Show help mo --version # Show installed version -mo clean --dry-run # Preview the cleanup plan -mo clean --whitelist # Manage protected caches -mo clean --dry-run --debug # Detailed preview with risk levels and file info +# Safe preview before applying changes +mo clean --dry-run +mo uninstall --dry-run +mo purge --dry-run -mo optimize --dry-run # Preview optimization actions -mo optimize --debug # Run with detailed operation logs +# --dry-run also works with: optimize, installer, remove, completion, touchid enable +mo clean --dry-run --debug # Preview + detailed logs mo optimize --whitelist # Manage protected optimization rules +mo clean --whitelist # Manage protected caches mo purge --paths # Configure project scan directories mo analyze /Volumes # Analyze external drives only ``` @@ -75,8 +77,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). -- 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`. +- Safety and logs: Deletions are permanent. Review with `--dry-run` first, and add `--debug` when needed. File operations are logged to `~/.config/mole/operations.log`. Disable with `MO_NO_OPLOG=1`. See [Security Audit](SECURITY_AUDIT.md). - Navigation: Mole supports arrow keys and Vim bindings `h/j/k/l`. ## Features in Detail diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md index 54fb445..fcf73d3 100644 --- a/SECURITY_AUDIT.md +++ b/SECURITY_AUDIT.md @@ -1,55 +1,13 @@ # Mole Security Reference -Version 1.27.0 | 2026-02-21 - -## Recent Fixes - -**Cleanup hardening audit, Feb 2026:** - -- `clean_deep_system()` now uses `safe_sudo_find_delete()` and `safe_sudo_remove()` for temp/log/diagnostic/report paths in `lib/clean/system.sh`. -- Removed direct `find ... -delete` from security-sensitive cleanup paths. Deletions now go through validated safe wrappers. -- `process_container_cache()` in `lib/clean/user.sh` now removes entries item-by-item with `safe_remove()`, so every delete is validated. -- `clean_application_support_logs()` now also performs item-by-item `safe_remove()` cleanup instead of direct bulk deletion. -- Group Containers cleanup now builds an explicit candidate list first, then filters protected/whitelisted items before deletion. -- `bin/clean.sh` dry-run export temp files rely on tracked temp lifecycle (`create_temp_file()` + trap cleanup) to avoid orphan temp artifacts. -- Added/updated regression coverage in `tests/clean_system_maintenance.bats`, `tests/clean_core.bats`, and `tests/clean_user_core.bats` for the new safe-deletion flow. -- Added conservative support-cache cleanup in `lib/clean/user.sh`: - - `~/Library/Application Support/CrashReporter` files older than 30 days - - `~/Library/Application Support/com.apple.idleassetsd` files older than 30 days - - `~/Library/Messages/StickerCache` and `~/Library/Messages/Caches/Previews/*` caches only -- Explicitly kept `~/Library/Messages/Attachments` and `~/Library/Metadata/CoreSpotlight` out of automatic cleanup to avoid user-data or indexing risk. -- Added low-risk cache coverage in `lib/clean/app_caches.sh`: - - `~/Library/Logs/CoreSimulator/*` - - Adobe media cache (`~/Library/Application Support/Adobe/Common/Media Cache Files/*`) - - Steam app/depot/shader/log caches and Minecraft/Lunar Client log/cache directories - - Legacy Microsoft Teams cache/log/temp directories under `~/Library/Application Support/Microsoft/Teams/*` - - `~/.cacher/logs/*` and `~/.kite/logs/*` -- Added conservative third-party system log cleanup in `lib/clean/system.sh`: - - `/Library/Logs/Adobe/*` and `/Library/Logs/CreativeCloud/*` older files only - - `/Library/Logs/adobegc.log` only when older than log retention -- Explicitly did not add high-risk cleanup defaults for: - - `/private/var/folders/*` broad deletion - - `~/Library/Application Support/MobileSync/Backup/*` - - Browser history/cookie databases (e.g., Arc History/Cookies/Web Data) - - Destructive container/image pruning commands by default - -**Uninstall audit, Jan 2026:** - -- `stop_launch_services()` now checks bundle_id is valid reverse-DNS before using it in find patterns. This stops glob injection. -- `find_app_files()` skips LaunchAgents named after common words like Music or Notes. -- Added comments explaining why `remove_file_list()` bypasses TOCTOU checks for symlinks. -- `brew_uninstall_cask()` treats exit code 124 as timeout failure, returns immediately. - -Other changes: - -- Symlink cleanup in `bin/clean.sh` goes through `safe_remove` now -- Orphaned helper cleanup in `lib/clean/apps.sh` switched to `safe_sudo_remove` -- ByHost pref cleanup checks bundle ID format first +Version 1.28.0 | 2026-02-27 ## Path Validation Every deletion goes through `lib/core/file_ops.sh`. The `validate_path_for_deletion()` function rejects empty paths, paths with `/../` in them, and anything containing control characters like newlines or null bytes. +Direct `find ... -delete` is not used for security-sensitive cleanup paths. Deletions go through validated safe wrappers like `safe_sudo_find_delete()`, `safe_sudo_remove()`, and `safe_remove()`. + **Blocked paths**, even with sudo: ```text @@ -85,10 +43,21 @@ App names need at least 3 characters. Otherwise "Go" would match "Google" and th Cache dirs like `~/.cargo/registry/cache` or `~/.gradle/caches` get cleaned. But `~/.cargo/bin`, `~/.mix/archives`, `~/.rustup` toolchains, `~/.stack/programs` stay untouched. +**Application Support and Caches:** + +- Cache entries are evaluated and removed safely on an item-by-item basis using `safe_remove()` (e.g., `process_container_cache`, `clean_application_support_logs`). +- Group Containers strictly filter against whitelists before deletion. +- Targets safe, age-gated resources natively (e.g., CrashReporter > 30 days, cached Steam/Simulator/Adobe/Teams log rot). +- Explicitly protects high-risk locations: `/private/var/folders/*` sweeping, iOS Backups (`MobileSync`), browser history/cookies, and destructive container/image pruning. + **LaunchAgent removal:** Only removed when uninstalling the app that owns them. All `com.apple.*` items are skipped. Services get stopped via `launchctl` first. Generic names like Music, Notes, Photos are excluded from the search. +`stop_launch_services()` checks bundle_id is valid reverse-DNS before using it in find patterns, stopping glob injection. `find_app_files()` skips LaunchAgents named after common words like Music or Notes. + +`unregister_app_bundle` explicitly drops uninstalled applications from LaunchServices via `lsregister -u`. `refresh_launch_services_after_uninstall` triggers asynchronous database compacting and rebuilds to ensure complete removal of stale app references without blocking workflows. + See `lib/core/app_protection.sh:find_app_files()`. ## Protected Categories @@ -99,6 +68,8 @@ VPN and proxy tools are skipped: Shadowsocks, V2Ray, Tailscale, Clash. AI tools are protected: Cursor, Claude, ChatGPT, Ollama, LM Studio. +`~/Library/Messages/Attachments` and `~/Library/Metadata/CoreSpotlight` are kept out of automatic cleanup to avoid user-data or indexing risk. + Time Machine backups running? Won't clean. Status unclear? Also won't clean. `com.apple.*` LaunchAgents/Daemons are never touched. @@ -120,6 +91,12 @@ Code at `cmd/analyze/*.go`. Network volume checks timeout after 5s (NFS/SMB/AFP can hang forever). mdfind searches get 10s. SQLite vacuum gets 20s, skipped if Mail/Safari/Messages is open. dyld cache rebuild gets 180s, skipped if done in the last 24h. +`brew_uninstall_cask()` treats exit code 124 as timeout failure, returns immediately. + +`app_support_item_size_bytes` calculation leverages direct `stat -f%z` checks and uses `du` only for directories, combined with strict timeout protections to avoid process hangs. + +Font cache rebuilding (`opt_font_cache_rebuild`) safely aborts if explicit browser processes (Safari, Chrome, Firefox, Arc, etc.) are detected, preventing GPU cache corruption and rendering bugs. + See `lib/core/timeout.sh:run_with_timeout()`. ## User Config @@ -144,6 +121,12 @@ Security-sensitive cleanup paths are covered by BATS regression tests, including - `tests/clean_user_core.bats` - `tests/clean_dev_caches.bats` - `tests/clean_system_maintenance.bats` +- `tests/purge.bats` +- `tests/core_safe_functions.bats` + +**System Memory Reports** computation uses bulk `find -exec stat` to avoid bash loop child-process limits on corrupted systems. +`bin/clean.sh` dry-run export temp files rely on tracked temp lifecycle (`create_temp_file()` + trap cleanup) to avoid orphan temp artifacts. +Background spinner logic interacts directly with `/dev/tty` and guarantees robust termination signals handling via trap mechanisms. Latest local verification for this release branch: @@ -151,6 +134,7 @@ Latest local verification for this release branch: - `bats tests/clean_user_core.bats` passed (13/13) - `bats tests/clean_dev_caches.bats` passed (8/8) - `bats tests/clean_system_maintenance.bats` passed (40/40) +- `bats tests/purge.bats tests/core_safe_functions.bats` passed (67/67) Run tests: diff --git a/bin/clean.sh b/bin/clean.sh index 914cb09..f36deb6 100755 --- a/bin/clean.sh +++ b/bin/clean.sh @@ -137,11 +137,6 @@ note_activity() { fi } -# shellcheck disable=SC2329 -has_cached_sudo() { - sudo -n true 2> /dev/null -} - CLEANUP_DONE=false # shellcheck disable=SC2329 cleanup() { @@ -373,7 +368,7 @@ safe_clean() { if should_protect_path "$path"; then skip=true - ((skipped_count++)) + skipped_count=$((skipped_count + 1)) log_operation "clean" "SKIPPED" "$path" "protected" fi @@ -381,7 +376,7 @@ safe_clean() { if is_path_whitelisted "$path"; then skip=true - ((skipped_count++)) + skipped_count=$((skipped_count + 1)) log_operation "clean" "SKIPPED" "$path" "whitelist" fi [[ "$skip" == "true" ]] && continue @@ -415,7 +410,7 @@ safe_clean() { fi if [[ $skipped_count -gt 0 ]]; then - ((whitelist_skipped_count += skipped_count)) + whitelist_skipped_count=$((whitelist_skipped_count + skipped_count)) fi if [[ ${#existing_paths[@]} -eq 0 ]]; then @@ -479,7 +474,7 @@ safe_clean() { echo "0 0" > "$temp_dir/result_${idx}" fi - ((idx++)) + idx=$((idx + 1)) if [[ $((idx % 20)) -eq 0 && "$show_spinner" == "true" && -t 1 ]]; then update_progress_if_needed "$idx" "${#existing_paths[@]}" last_progress_update 1 || true last_progress_update=$(get_epoch_seconds) @@ -508,12 +503,12 @@ safe_clean() { mv "$tmp_file" "$temp_dir/result_${idx}" 2> /dev/null || true ) & pids+=($!) - ((idx++)) + idx=$((idx + 1)) if ((${#pids[@]} >= MOLE_MAX_PARALLEL_JOBS)); then wait "${pids[0]}" 2> /dev/null || true pids=("${pids[@]:1}") - ((completed++)) + completed=$((completed + 1)) if [[ "$show_spinner" == "true" && -t 1 ]]; then update_progress_if_needed "$completed" "$total_paths" last_progress_update 2 || true @@ -525,7 +520,7 @@ safe_clean() { if [[ ${#pids[@]} -gt 0 ]]; then for pid in "${pids[@]}"; do wait "$pid" 2> /dev/null || true - ((completed++)) + completed=$((completed + 1)) if [[ "$show_spinner" == "true" && -t 1 ]]; then update_progress_if_needed "$completed" "$total_paths" last_progress_update 2 || true @@ -557,17 +552,17 @@ safe_clean() { if [[ $removed -eq 1 ]]; then if [[ "$size" -gt 0 ]]; then - ((total_size_kb += size)) + total_size_kb=$((total_size_kb + size)) fi - ((total_count += 1)) + total_count=$((total_count + 1)) removed_any=1 else if [[ -e "$path" && "$DRY_RUN" != "true" ]]; then - ((removal_failed_count++)) + removal_failed_count=$((removal_failed_count + 1)) fi fi fi - ((idx++)) + idx=$((idx + 1)) done fi @@ -595,16 +590,16 @@ safe_clean() { if [[ $removed -eq 1 ]]; then if [[ "$size_kb" -gt 0 ]]; then - ((total_size_kb += size_kb)) + total_size_kb=$((total_size_kb + size_kb)) fi - ((total_count += 1)) + total_count=$((total_count + 1)) removed_any=1 else if [[ -e "$path" && "$DRY_RUN" != "true" ]]; then - ((removal_failed_count++)) + removal_failed_count=$((removal_failed_count + 1)) fi fi - ((idx++)) + idx=$((idx + 1)) done fi fi @@ -626,7 +621,8 @@ safe_clean() { # Stop spinner before output stop_section_spinner - local size_human=$(bytes_to_human "$((total_size_kb * 1024))") + local size_human + size_human=$(bytes_to_human "$((total_size_kb * 1024))") local label="$description" if [[ ${#targets[@]} -gt 1 ]]; then @@ -636,7 +632,8 @@ safe_clean() { if [[ "$DRY_RUN" == "true" ]]; then echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} $label${NC}, ${YELLOW}$size_human dry${NC}" - local paths_temp=$(create_temp_file) + local paths_temp + paths_temp=$(create_temp_file) idx=0 if [[ ${#existing_paths[@]} -gt 0 ]]; then @@ -650,12 +647,12 @@ safe_clean() { fi [[ "$size" == "0" || -z "$size" ]] && { - ((idx++)) + idx=$((idx + 1)) continue } echo "$(dirname "$path")|$size|$path" >> "$paths_temp" - ((idx++)) + idx=$((idx + 1)) done fi @@ -683,7 +680,8 @@ safe_clean() { } } ' | while IFS='|' read -r display_path total_size child_count; do - local size_human=$(bytes_to_human "$((total_size * 1024))") + local size_human + size_human=$(bytes_to_human "$((total_size * 1024))") if [[ $child_count -gt 1 ]]; then echo "$display_path # $size_human, $child_count items" >> "$EXPORT_LIST_FILE" else @@ -694,9 +692,9 @@ safe_clean() { else echo -e " ${GREEN}${ICON_SUCCESS}${NC} $label${NC}, ${GREEN}$size_human${NC}" fi - ((files_cleaned += total_count)) - ((total_size_cleaned += total_size_kb)) - ((total_items++)) + files_cleaned=$((files_cleaned + total_count)) + total_size_cleaned=$((total_size_cleaned + total_size_kb)) + total_items=$((total_items + 1)) note_activity fi @@ -738,7 +736,7 @@ start_cleanup() { EOF # Preview system section when sudo is already cached (no password prompt). - if has_cached_sudo; then + if has_sudo_session; then SYSTEM_CLEAN=true echo -e "${GREEN}${ICON_SUCCESS}${NC} Admin access available, system preview included" echo "" @@ -751,7 +749,7 @@ EOF fi if [[ -t 0 ]]; then - if has_cached_sudo; then + if has_sudo_session; then SYSTEM_CLEAN=true echo -e "${GREEN}${ICON_SUCCESS}${NC} Admin access already available" echo "" @@ -791,7 +789,7 @@ EOF else echo "" echo "Running in non-interactive mode" - if has_cached_sudo; then + if has_sudo_session; then SYSTEM_CLEAN=true echo " ${ICON_LIST} System-level cleanup enabled, sudo session active" else @@ -872,9 +870,9 @@ perform_cleanup() { done if [[ "$is_predefined" == "true" ]]; then - ((predefined_count++)) + predefined_count=$((predefined_count + 1)) else - ((custom_count++)) + custom_count=$((custom_count + 1)) fi done @@ -1069,7 +1067,8 @@ perform_cleanup() { fi fi - local final_free_space=$(get_free_space) + local final_free_space + final_free_space=$(get_free_space) summary_details+=("Free space now: $final_free_space") fi else diff --git a/bin/completion.sh b/bin/completion.sh index 0a187e3..a575929 100755 --- a/bin/completion.sh +++ b/bin/completion.sh @@ -32,8 +32,32 @@ 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" } +if [[ $# -gt 0 ]]; then + normalized_args=() + for arg in "$@"; do + case "$arg" in + "--dry-run" | "-n") + export MOLE_DRY_RUN=1 + ;; + *) + 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 [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; 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,16 +97,21 @@ 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 - original_mode="" - original_mode="$(stat -f '%Mp%Lp' "$config_file" 2> /dev/null || true)" - temp_file="$(mktemp)" - grep -Ev "(^# Mole shell completion$|(mole|mo)[[:space:]]+completion)" "$config_file" > "$temp_file" || true - mv "$temp_file" "$config_file" - if [[ -n "$original_mode" ]]; then - chmod "$original_mode" "$config_file" 2> /dev/null || true + if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; 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)" + grep -Ev "(^# Mole shell completion$|(mole|mo)[[:space:]]+completion)" "$config_file" > "$temp_file" || true + mv "$temp_file" "$config_file" + if [[ -n "$original_mode" ]]; then + chmod "$original_mode" "$config_file" 2> /dev/null || true + fi + echo -e "${GREEN}${ICON_SUCCESS}${NC} Removed stale completion entries from $config_file" + echo "" fi - echo -e "${GREEN}${ICON_SUCCESS}${NC} Removed stale completion entries from $config_file" - echo "" fi log_error "mole not found in PATH, install Mole before enabling completion" exit 1 @@ -90,6 +119,12 @@ if [[ $# -eq 0 ]]; then # 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 [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; 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 +149,11 @@ if [[ $# -eq 0 ]]; then echo -e "${GRAY}Will add to ${config_file}:${NC}" echo " $completion_line" echo "" + if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; 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 +267,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 diff --git a/bin/installer.sh b/bin/installer.sh index 1b5645b..864404a 100755 --- a/bin/installer.sh +++ b/bin/installer.sh @@ -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}') - summary_details+=("Removed ${GREEN}$total_deleted${NC} installers, freed ${GREEN}${freed_mb}MB${NC}") - summary_details+=("Your Mac is cleaner now!") + 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=$? diff --git a/bin/purge.sh b/bin/purge.sh index 58e9f0f..1f6c664 100755 --- a/bin/purge.sh +++ b/bin/purge.sh @@ -50,7 +50,6 @@ start_purge() { if [[ -t 1 ]]; then printf '\033[2J\033[H' fi - printf '\n' # Initialize stats file in user cache directory local stats_dir="${XDG_CACHE_HOME:-$HOME/.cache}/mole" @@ -83,87 +82,89 @@ perform_purge() { wait "$monitor_pid" 2> /dev/null || true fi if [[ -t 1 ]]; then - printf '\r\033[K\n\033[K\033[A' + printf '\r\033[2K\n' > /dev/tty 2> /dev/null || true fi } - # Set up trap for cleanup - trap cleanup_monitor INT TERM + # Ensure Ctrl-C/TERM always stops spinner(s) and exits immediately. + handle_interrupt() { + cleanup_monitor + stop_inline_spinner 2> /dev/null || true + show_cursor 2> /dev/null || true + printf '\n' >&2 + exit 130 + } - # Show scanning with spinner on same line as title + # Set up trap for cleanup + abort + trap handle_interrupt INT TERM + + # Show scanning with spinner below the title line if [[ -t 1 ]]; then - # Print title first - printf '%s' "${PURPLE_BOLD}Purge Project Artifacts${NC} " + # Print title ONCE with newline; spinner occupies the line below + printf '%s\n' "${PURPLE_BOLD}Purge Project Artifacts${NC}" - # Start background monitor with ASCII spinner + # Capture terminal width in parent (most reliable before forking) + local _parent_cols=80 + local _stty_out + if _stty_out=$(stty size < /dev/tty 2> /dev/null); then + _parent_cols="${_stty_out##* }" # "rows cols" -> take cols + else + _parent_cols=$(tput cols 2> /dev/null || echo 80) + fi + [[ "$_parent_cols" =~ ^[0-9]+$ && $_parent_cols -gt 0 ]] || _parent_cols=80 + + # Start background monitor: writes directly to /dev/tty to avoid stdout state issues ( local spinner_chars="|/-\\" local spinner_idx=0 local last_path="" + # Use parent-captured width; never refresh inside the loop (avoids unreliable tput in bg) + local term_cols="$_parent_cols" + # Visible prefix "| Scanning " = 11 chars; reserve 25 total for safety margin + local max_path_len=$((term_cols - 25)) + ((max_path_len < 5)) && max_path_len=5 - # Set up trap to exit cleanly - trap 'exit 0' INT TERM + # Set up trap to exit cleanly (erase the spinner line via /dev/tty) + trap 'printf "\r\033[2K" >/dev/tty 2>/dev/null; exit 0' INT TERM - # Function to truncate path in the middle + # Truncate path to guaranteed fit truncate_path() { local path="$1" - local term_cols - term_cols=$(tput cols 2> /dev/null || echo 80) - # Reserve some space for the spinner and text (approx 20 chars) - local max_len=$((term_cols - 20)) - # Ensure a reasonable minimum width - if ((max_len < 40)); then - max_len=40 - fi - - if [[ ${#path} -le $max_len ]]; then + if [[ ${#path} -le $max_path_len ]]; then echo "$path" return fi - - # Calculate how much to show on each side - local side_len=$(((max_len - 3) / 2)) - local start="${path:0:$side_len}" - local end="${path: -$side_len}" - echo "${start}...${end}" + local side_len=$(((max_path_len - 3) / 2)) + echo "${path:0:$side_len}...${path: -$side_len}" } while [[ -f "$stats_dir/purge_scanning" ]]; do - local current_path=$(cat "$stats_dir/purge_scanning" 2> /dev/null || echo "") - local display_path="" + local current_path + current_path=$(cat "$stats_dir/purge_scanning" 2> /dev/null || echo "") if [[ -n "$current_path" ]]; then - display_path="${current_path/#$HOME/~}" + local display_path="${current_path/#$HOME/~}" display_path=$(truncate_path "$display_path") last_path="$display_path" - elif [[ -n "$last_path" ]]; then - display_path="$last_path" fi - # Get current spinner character local spin_char="${spinner_chars:$spinner_idx:1}" spinner_idx=$(((spinner_idx + 1) % ${#spinner_chars})) - # Show title on first line, spinner and scanning info on second line - if [[ -n "$display_path" ]]; then - # Line 1: Move to start, clear, print title - printf '\r\033[K%s\n' "${PURPLE_BOLD}Purge Project Artifacts${NC}" - # Line 2: Move to start, clear, print scanning info - printf '\r\033[K%s %sScanning %s' \ + # Write directly to /dev/tty: \033[2K clears entire current line, \r goes to start + if [[ -n "$last_path" ]]; then + printf '\r\033[2K%s %sScanning %s%s' \ "${BLUE}${spin_char}${NC}" \ - "${GRAY}" "$display_path" - # Move up THEN to start (important order!) - printf '\033[A\r' + "${GRAY}" "$last_path" "${NC}" > /dev/tty 2> /dev/null else - printf '\r\033[K%s\n' "${PURPLE_BOLD}Purge Project Artifacts${NC}" - printf '\r\033[K%s %sScanning...' \ + printf '\r\033[2K%s %sScanning...%s' \ "${BLUE}${spin_char}${NC}" \ - "${GRAY}" - printf '\033[A\r' + "${GRAY}" "${NC}" > /dev/tty 2> /dev/null fi sleep 0.05 done + printf '\r\033[2K' > /dev/tty 2> /dev/null exit 0 ) & monitor_pid=$! @@ -178,10 +179,6 @@ perform_purge() { trap - INT TERM cleanup_monitor - if [[ -t 1 ]]; then - echo -e "${PURPLE_BOLD}Purge Project Artifacts${NC}" - fi - # Exit codes: # 0 = success, show summary # 1 = user cancelled @@ -208,19 +205,24 @@ 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}') - summary_details+=("Space freed: ${GREEN}${freed_gb}GB${NC}") - summary_details+=("Free space now: $(get_free_space)") - - if [[ $total_items_cleaned -gt 0 ]]; then - summary_details+=("Items cleaned: $total_items_cleaned") + 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") else summary_details+=("No old project artifacts to clean.") - summary_details+=("Free space now: $(get_free_space)") + summary_details+=("Free space: $(get_free_space)") fi # Log session end @@ -238,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 "" @@ -267,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" @@ -276,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 diff --git a/bin/touchid.sh b/bin/touchid.sh index 8b377d1..76b5cc2 100755 --- a/bin/touchid.sh +++ b/bin/touchid.sh @@ -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 + + 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 - "--help" | "-h") - show_touchid_help - ;; enable) enable_touchid ;; diff --git a/bin/uninstall.sh b/bin/uninstall.sh index 5c2a436..b1b4f01 100755 --- a/bin/uninstall.sh +++ b/bin/uninstall.sh @@ -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 @@ -950,12 +957,22 @@ main() { rm -f "$apps_file" - echo -e "${GRAY}Press Enter to return to application list, any other key to exit...${NC}" + local prompt_timeout="${MOLE_UNINSTALL_RETURN_PROMPT_TIMEOUT_SEC:-3}" + if [[ ! "$prompt_timeout" =~ ^[0-9]+$ ]] || [[ "$prompt_timeout" -lt 1 ]]; then + prompt_timeout=3 + fi + + echo -e "${GRAY}Press Enter to return to the app list, press any other key or wait ${prompt_timeout}s to exit.${NC}" local key - IFS= read -r -s -n1 key || key="" + local read_ok=false + if IFS= read -r -s -n1 -t "$prompt_timeout" key; then + read_ok=true + else + key="" + fi drain_pending_input - if [[ -z "$key" ]]; then + if [[ "$read_ok" == "true" && -z "$key" ]]; then : else show_cursor diff --git a/cmd/analyze/format.go b/cmd/analyze/format.go index 5ef48d6..371539b 100644 --- a/cmd/analyze/format.go +++ b/cmd/analyze/format.go @@ -80,7 +80,7 @@ func humanizeBytes(size int64) string { if size < 0 { return "0 B" } - const unit = 1024 + const unit = 1000 if size < unit { return fmt.Sprintf("%d B", size) } @@ -90,7 +90,7 @@ func humanizeBytes(size int64) string { exp++ } value := float64(size) / float64(div) - return fmt.Sprintf("%.1f %cB", value, "KMGTPE"[exp]) + return fmt.Sprintf("%.1f %cB", value, "kMGTPE"[exp]) } func coloredProgressBar(value, maxValue int64, percent float64) string { diff --git a/cmd/analyze/format_test.go b/cmd/analyze/format_test.go index 7cfdf57..65a8333 100644 --- a/cmd/analyze/format_test.go +++ b/cmd/analyze/format_test.go @@ -63,15 +63,15 @@ func TestHumanizeBytes(t *testing.T) { {-100, "0 B"}, {0, "0 B"}, {512, "512 B"}, - {1023, "1023 B"}, - {1024, "1.0 KB"}, - {1536, "1.5 KB"}, - {10240, "10.0 KB"}, - {1048576, "1.0 MB"}, - {1572864, "1.5 MB"}, - {1073741824, "1.0 GB"}, - {1099511627776, "1.0 TB"}, - {1125899906842624, "1.0 PB"}, + {999, "999 B"}, + {1000, "1.0 kB"}, + {1500, "1.5 kB"}, + {10000, "10.0 kB"}, + {1000000, "1.0 MB"}, + {1500000, "1.5 MB"}, + {1000000000, "1.0 GB"}, + {1000000000000, "1.0 TB"}, + {1000000000000000, "1.0 PB"}, } for _, tt := range tests { diff --git a/cmd/status/main.go b/cmd/status/main.go index 3a5f349..77186dd 100644 --- a/cmd/status/main.go +++ b/cmd/status/main.go @@ -139,14 +139,20 @@ func (m model) View() string { return "Loading..." } - header, mole := renderHeader(m.metrics, m.errMessage, m.animFrame, m.width, m.catHidden) - cardWidth := 0 - if m.width > 80 { - cardWidth = max(24, m.width/2-4) + termWidth := m.width + if termWidth <= 0 { + termWidth = 80 } - cards := buildCards(m.metrics, cardWidth) - if m.width <= 80 { + header, mole := renderHeader(m.metrics, m.errMessage, m.animFrame, termWidth, m.catHidden) + + if termWidth <= 80 { + cardWidth := termWidth + if cardWidth > 2 { + cardWidth -= 2 + } + cards := buildCards(m.metrics, cardWidth) + var rendered []string for i, c := range cards { if i > 0 { @@ -164,7 +170,9 @@ func (m model) View() string { return lipgloss.JoinVertical(lipgloss.Left, content...) } - twoCol := renderTwoColumns(cards, m.width) + cardWidth := max(24, termWidth/2-4) + cards := buildCards(m.metrics, cardWidth) + twoCol := renderTwoColumns(cards, termWidth) // Combine header, mole, and cards with consistent spacing var content []string content = append(content, header) diff --git a/cmd/status/view.go b/cmd/status/view.go index c868bc1..514473f 100644 --- a/cmd/status/view.go +++ b/cmd/status/view.go @@ -131,6 +131,11 @@ type cardData struct { } func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int, catHidden bool) (string, string) { + if termWidth <= 0 { + termWidth = 80 + } + compactHeader := termWidth <= 80 + title := titleStyle.Render("Status") scoreStyle := getScoreStyle(m.HealthScore) @@ -162,14 +167,39 @@ func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int if m.Hardware.RefreshRate != "" { infoParts = append(infoParts, m.Hardware.RefreshRate) } - if m.Hardware.OSVersion != "" { - infoParts = append(infoParts, m.Hardware.OSVersion) + optionalInfoParts := []string{} + if !compactHeader && m.Hardware.OSVersion != "" { + optionalInfoParts = append(optionalInfoParts, m.Hardware.OSVersion) } - if m.Uptime != "" { - infoParts = append(infoParts, subtleStyle.Render("up "+m.Uptime)) + if !compactHeader && m.Uptime != "" { + optionalInfoParts = append(optionalInfoParts, subtleStyle.Render("up "+m.Uptime)) } - headerLine := title + " " + scoreText + " " + strings.Join(infoParts, " · ") + headLeft := title + " " + scoreText + headerLine := headLeft + if termWidth > 0 && lipgloss.Width(headerLine) > termWidth { + headerLine = wrapToWidth(headLeft, termWidth)[0] + } + if termWidth > 0 { + allParts := append(append([]string{}, infoParts...), optionalInfoParts...) + if len(allParts) > 0 { + combined := headLeft + " " + strings.Join(allParts, " · ") + if lipgloss.Width(combined) <= termWidth { + headerLine = combined + } else { + // When width is tight, drop lower-priority tail (OS and uptime) as a group. + fitParts := append([]string{}, infoParts...) + for len(fitParts) > 0 { + candidate := headLeft + " " + strings.Join(fitParts, " · ") + if lipgloss.Width(candidate) <= termWidth { + headerLine = candidate + break + } + fitParts = fitParts[:len(fitParts)-1] + } + } + } + } // Show cat unless hidden - render mole centered below header var mole string @@ -249,7 +279,7 @@ func renderCPUCard(cpu CPUStatus, thermal ThermalStatus) cardData { return cardData{icon: iconCPU, title: "CPU", lines: lines} } -func renderMemoryCard(mem MemoryStatus) cardData { +func renderMemoryCard(mem MemoryStatus, cardWidth int) cardData { // Check if swap is being used (or at least allocated). hasSwap := mem.SwapTotal > 0 || mem.SwapUsed > 0 @@ -270,8 +300,16 @@ func renderMemoryCard(mem MemoryStatus) cardData { if mem.SwapTotal > 0 { swapPercent = (float64(mem.SwapUsed) / float64(mem.SwapTotal)) * 100.0 } + swapLine := fmt.Sprintf("Swap %s %5.1f%%", progressBar(swapPercent), swapPercent) swapText := fmt.Sprintf("%s/%s", humanBytesCompact(mem.SwapUsed), humanBytesCompact(mem.SwapTotal)) - lines = append(lines, fmt.Sprintf("Swap %s %5.1f%% %s", progressBar(swapPercent), swapPercent, swapText)) + swapLineWithText := swapLine + " " + swapText + if cardWidth > 0 && lipgloss.Width(swapLineWithText) <= cardWidth { + lines = append(lines, swapLineWithText) + } else if cardWidth <= 0 { + lines = append(lines, swapLineWithText) + } else { + lines = append(lines, swapLine) + } lines = append(lines, fmt.Sprintf("Total %s / %s", humanBytes(mem.Used), humanBytes(mem.Total))) lines = append(lines, fmt.Sprintf("Avail %s", humanBytes(mem.Total-mem.Used))) // Simplified avail logic for consistency @@ -399,7 +437,7 @@ func renderProcessCard(procs []ProcessInfo) cardData { func buildCards(m MetricsSnapshot, width int) []cardData { cards := []cardData{ renderCPUCard(m.CPU, m.Thermal), - renderMemoryCard(m.Memory), + renderMemoryCard(m.Memory, width), renderDiskCard(m.Disks, m.DiskIO), renderBatteryCard(m.Batteries, m.Thermal), renderProcessCard(m.TopProcesses), @@ -596,18 +634,40 @@ func renderBatteryCard(batts []BatteryStatus, thermal ThermalStatus) cardData { } func renderCard(data cardData, width int, height int) string { - titleText := data.icon + " " + data.title - lineLen := max(width-lipgloss.Width(titleText)-2, 4) - header := titleStyle.Render(titleText) + " " + lineStyle.Render(strings.Repeat("╌", lineLen)) - content := header + "\n" + strings.Join(data.lines, "\n") + if width <= 0 { + width = colWidth + } + + titleText := data.icon + " " + data.title + lineLen := width - lipgloss.Width(titleText) - 2 + if lineLen < 0 { + lineLen = 0 + } + + header := titleStyle.Render(titleText) + if lineLen > 0 { + header += " " + lineStyle.Render(strings.Repeat("╌", lineLen)) + } + + lines := wrapToWidth(header, width) + for _, line := range data.lines { + lines = append(lines, wrapToWidth(line, width)...) + } - lines := strings.Split(content, "\n") for len(lines) < height { lines = append(lines, "") } return strings.Join(lines, "\n") } +func wrapToWidth(text string, width int) []string { + if width <= 0 { + return []string{text} + } + wrapped := lipgloss.NewStyle().MaxWidth(width).Render(text) + return strings.Split(wrapped, "\n") +} + func progressBar(percent float64) string { total := 16 if percent < 0 { diff --git a/cmd/status/view_test.go b/cmd/status/view_test.go index fbde8e9..beb99f3 100644 --- a/cmd/status/view_test.go +++ b/cmd/status/view_test.go @@ -3,6 +3,8 @@ package main import ( "strings" "testing" + + "github.com/charmbracelet/lipgloss" ) func TestFormatRate(t *testing.T) { @@ -934,6 +936,136 @@ func TestRenderHeaderErrorReturnsMoleOnce(t *testing.T) { } } +func TestRenderHeaderWrapsOnNarrowWidth(t *testing.T) { + m := MetricsSnapshot{ + HealthScore: 91, + Hardware: HardwareInfo{ + Model: "MacBook Pro", + CPUModel: "Apple M3 Max", + TotalRAM: "128GB", + DiskSize: "4TB", + RefreshRate: "120Hz", + OSVersion: "macOS 15.0", + }, + Uptime: "10d 3h", + } + + header, _ := renderHeader(m, "", 0, 38, true) + for _, line := range strings.Split(header, "\n") { + if lipgloss.Width(stripANSI(line)) > 38 { + t.Fatalf("renderHeader() line exceeds width: %q", line) + } + } +} + +func TestRenderHeaderHidesOSAndUptimeOnNarrowWidth(t *testing.T) { + m := MetricsSnapshot{ + HealthScore: 91, + Hardware: HardwareInfo{ + Model: "MacBook Pro", + CPUModel: "Apple M3 Max", + TotalRAM: "128GB", + DiskSize: "4TB", + RefreshRate: "120Hz", + OSVersion: "macOS 15.0", + }, + Uptime: "10d 3h", + } + + header, _ := renderHeader(m, "", 0, 80, true) + plain := stripANSI(header) + if strings.Contains(plain, "macOS 15.0") { + t.Fatalf("renderHeader() narrow width should hide os version, got %q", plain) + } + if strings.Contains(plain, "up 10d 3h") { + t.Fatalf("renderHeader() narrow width should hide uptime, got %q", plain) + } +} + +func TestRenderHeaderDropsLowPriorityInfoToStaySingleLine(t *testing.T) { + m := MetricsSnapshot{ + HealthScore: 90, + Hardware: HardwareInfo{ + Model: "MacBook Pro", + CPUModel: "Apple M2 Pro", + TotalRAM: "32.0 GB", + DiskSize: "460.4 GB", + RefreshRate: "60Hz", + OSVersion: "macOS 26.3", + }, + GPU: []GPUStatus{{CoreCount: 19}}, + Uptime: "9d 13h", + } + + header, _ := renderHeader(m, "", 0, 100, true) + plain := stripANSI(header) + if strings.Contains(plain, "\n") { + t.Fatalf("renderHeader() should stay single line when trimming low-priority fields, got %q", plain) + } + if strings.Contains(plain, "macOS 26.3") { + t.Fatalf("renderHeader() should drop os version when width is tight, got %q", plain) + } + if strings.Contains(plain, "up 9d 13h") { + t.Fatalf("renderHeader() should drop uptime when width is tight, got %q", plain) + } +} + +func TestRenderCardWrapsOnNarrowWidth(t *testing.T) { + card := cardData{ + icon: iconCPU, + title: "CPU", + lines: []string{ + "Total ████████████████ 100.0% @ 85.0°C", + "Load 12.34 / 8.90 / 7.65, 4P+4E", + }, + } + + rendered := renderCard(card, 26, 0) + for _, line := range strings.Split(rendered, "\n") { + if lipgloss.Width(stripANSI(line)) > 26 { + t.Fatalf("renderCard() line exceeds width: %q", line) + } + } +} + +func TestRenderMemoryCardHidesSwapSizeOnNarrowWidth(t *testing.T) { + card := renderMemoryCard(MemoryStatus{ + Used: 8 << 30, + Total: 16 << 30, + UsedPercent: 50.0, + SwapUsed: 482, + SwapTotal: 1000, + }, 38) + + if len(card.lines) < 3 { + t.Fatalf("renderMemoryCard() expected at least 3 lines, got %d", len(card.lines)) + } + + swapLine := stripANSI(card.lines[2]) + if strings.Contains(swapLine, "/") { + t.Fatalf("renderMemoryCard() narrow width should hide swap size, got %q", swapLine) + } +} + +func TestRenderMemoryCardShowsSwapSizeOnWideWidth(t *testing.T) { + card := renderMemoryCard(MemoryStatus{ + Used: 8 << 30, + Total: 16 << 30, + UsedPercent: 50.0, + SwapUsed: 482, + SwapTotal: 1000, + }, 60) + + if len(card.lines) < 3 { + t.Fatalf("renderMemoryCard() expected at least 3 lines, got %d", len(card.lines)) + } + + swapLine := stripANSI(card.lines[2]) + if !strings.Contains(swapLine, "/") { + t.Fatalf("renderMemoryCard() wide width should include swap size, got %q", swapLine) + } +} + func TestModelViewErrorRendersSingleMole(t *testing.T) { m := model{ width: 120, diff --git a/go.mod b/go.mod index 48a627c..153fbad 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 - github.com/shirou/gopsutil/v4 v4.26.1 + github.com/shirou/gopsutil/v4 v4.26.2 golang.org/x/sync v0.19.0 ) @@ -21,7 +21,7 @@ require ( github.com/clipperhouse/displaywidth v0.7.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.3.0 // indirect - github.com/ebitengine/purego v0.9.1 // indirect + github.com/ebitengine/purego v0.10.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect @@ -38,6 +38,6 @@ require ( github.com/tklauser/numcpus v0.11.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect - golang.org/x/sys v0.40.0 // indirect + golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.33.0 // indirect ) diff --git a/go.sum b/go.sum index baf6360..66dea7c 100644 --- a/go.sum +++ b/go.sum @@ -22,8 +22,8 @@ github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuh github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= -github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= @@ -53,8 +53,8 @@ github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/shirou/gopsutil/v4 v4.26.1 h1:TOkEyriIXk2HX9d4isZJtbjXbEjf5qyKPAzbzY0JWSo= -github.com/shirou/gopsutil/v4 v4.26.1/go.mod h1:medLI9/UNAb0dOI9Q3/7yWSqKkj00u+1tgY8nvv41pc= +github.com/shirou/gopsutil/v4 v4.26.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI= +github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= @@ -74,8 +74,8 @@ golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/install.sh b/install.sh index a75fe09..ceda468 100755 --- a/install.sh +++ b/install.sh @@ -266,6 +266,45 @@ get_installed_version() { fi } +resolve_install_channel() { + case "${MOLE_VERSION:-}" in + main | latest) + printf 'nightly\n' + return 0 + ;; + dev) + printf 'dev\n' + return 0 + ;; + esac + + if [[ "${MOLE_EDGE_INSTALL:-}" == "true" ]]; then + printf 'nightly\n' + return 0 + fi + + printf 'stable\n' +} + +write_install_channel_metadata() { + local channel="$1" + local metadata_file="$CONFIG_DIR/install_channel" + + local tmp_file + tmp_file=$(mktemp "${CONFIG_DIR}/install_channel.XXXXXX") || return 1 + { + printf 'CHANNEL=%s\n' "$channel" + } > "$tmp_file" || { + rm -f "$tmp_file" 2> /dev/null || true + return 1 + } + + mv -f "$tmp_file" "$metadata_file" || { + rm -f "$tmp_file" 2> /dev/null || true + return 1 + } +} + # CLI parsing (supports main/latest and version tokens). parse_args() { local -a args=("$@") @@ -506,7 +545,7 @@ download_binary() { if curl -fsSL --connect-timeout 10 --max-time 60 -o "$target_path" "$url"; then if [[ -t 1 ]]; then stop_line_spinner; fi chmod +x "$target_path" - xattr -cr "$target_path" 2> /dev/null || true + xattr -c "$target_path" 2> /dev/null || true log_success "Downloaded ${binary_name} binary" else if [[ -t 1 ]]; then stop_line_spinner; fi @@ -712,6 +751,12 @@ perform_install() { installed_version="$source_version" fi + local install_channel + install_channel="$(resolve_install_channel)" + if ! write_install_channel_metadata "$install_channel"; then + log_warning "Could not write install channel metadata" + fi + # Edge installs get a suffix to make the version explicit. if [[ "${MOLE_EDGE_INSTALL:-}" == "true" ]]; then installed_version="${installed_version}-edge" @@ -795,6 +840,12 @@ perform_update() { updated_version="$target_version" fi + local install_channel + install_channel="$(resolve_install_channel)" + if ! write_install_channel_metadata "$install_channel"; then + log_warning "Could not write install channel metadata" + fi + echo -e "${GREEN}${ICON_SUCCESS}${NC} Updated to latest version, $updated_version" } diff --git a/lib/clean/apps.sh b/lib/clean/apps.sh index 11d064c..b96f355 100644 --- a/lib/clean/apps.sh +++ b/lib/clean/apps.sh @@ -33,7 +33,7 @@ clean_ds_store_tree() { local size size=$(get_file_size "$ds_file") total_bytes=$((total_bytes + size)) - ((file_count++)) + file_count=$((file_count + 1)) if [[ "$DRY_RUN" != "true" ]]; then rm -f "$ds_file" 2> /dev/null || true fi @@ -53,9 +53,9 @@ clean_ds_store_tree() { echo -e " ${GREEN}${ICON_SUCCESS}${NC} $label${NC}, ${GREEN}$file_count files, $size_human${NC}" fi local size_kb=$(((total_bytes + 1023) / 1024)) - ((files_cleaned += file_count)) - ((total_size_cleaned += size_kb)) - ((total_items++)) + files_cleaned=$((files_cleaned + file_count)) + total_size_cleaned=$((total_size_cleaned + size_kb)) + total_items=$((total_items + 1)) note_activity fi } @@ -113,12 +113,12 @@ scan_installed_apps() { local bundle_id=$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "$plist_path" 2> /dev/null || echo "") if [[ -n "$bundle_id" ]]; then echo "$bundle_id" - ((count++)) + count=$((count + 1)) fi done ) > "$scan_tmp_dir/apps_${dir_idx}.txt" & pids+=($!) - ((dir_idx++)) + dir_idx=$((dir_idx + 1)) done # Collect running apps and LaunchAgents to avoid false orphan cleanup. ( @@ -300,7 +300,7 @@ clean_orphaned_app_data() { fi for match in "${matches[@]}"; do [[ -e "$match" ]] || continue - ((iteration_count++)) + iteration_count=$((iteration_count + 1)) if [[ $iteration_count -gt $MOLE_MAX_ORPHAN_ITERATIONS ]]; then break fi @@ -314,8 +314,8 @@ clean_orphaned_app_data() { continue fi if safe_clean "$match" "Orphaned $label: $bundle_id"; then - ((orphaned_count++)) - ((total_orphaned_kb += size_kb)) + orphaned_count=$((orphaned_count + 1)) + total_orphaned_kb=$((total_orphaned_kb + size_kb)) fi fi done @@ -326,7 +326,7 @@ clean_orphaned_app_data() { stop_section_spinner if [[ $orphaned_count -gt 0 ]]; then local orphaned_mb=$(echo "$total_orphaned_kb" | awk '{printf "%.1f", $1/1024}') - echo " ${GREEN}${ICON_SUCCESS}${NC} Cleaned $orphaned_count items, about ${orphaned_mb}MB" + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Cleaned $orphaned_count items, about ${orphaned_mb}MB" note_activity fi rm -f "$installed_bundles" @@ -430,8 +430,8 @@ clean_orphaned_system_services() { orphaned_files+=("$plist") local size_kb size_kb=$(sudo du -skP "$plist" 2> /dev/null | awk '{print $1}' || echo "0") - ((total_orphaned_kb += size_kb)) - ((orphaned_count++)) + total_orphaned_kb=$((total_orphaned_kb + size_kb)) + orphaned_count=$((orphaned_count + 1)) break fi done @@ -461,8 +461,8 @@ clean_orphaned_system_services() { orphaned_files+=("$plist") local size_kb size_kb=$(sudo du -skP "$plist" 2> /dev/null | awk '{print $1}' || echo "0") - ((total_orphaned_kb += size_kb)) - ((orphaned_count++)) + total_orphaned_kb=$((total_orphaned_kb + size_kb)) + orphaned_count=$((orphaned_count + 1)) break fi done @@ -491,8 +491,8 @@ clean_orphaned_system_services() { orphaned_files+=("$helper") local size_kb size_kb=$(sudo du -skP "$helper" 2> /dev/null | awk '{print $1}' || echo "0") - ((total_orphaned_kb += size_kb)) - ((orphaned_count++)) + total_orphaned_kb=$((total_orphaned_kb + size_kb)) + orphaned_count=$((orphaned_count + 1)) break fi done @@ -673,7 +673,7 @@ clean_orphaned_launch_agents() { if is_launch_item_orphaned "$plist"; then local size_kb=$(get_path_size_kb "$plist") orphaned_items+=("$bundle_id|$plist") - ((total_orphaned_kb += size_kb)) + total_orphaned_kb=$((total_orphaned_kb + size_kb)) fi done < <(find "$launch_agents_dir" -maxdepth 1 -name "*.plist" -print0 2> /dev/null) @@ -696,7 +696,7 @@ clean_orphaned_launch_agents() { IFS='|' read -r bundle_id plist_path <<< "$item" if [[ "$is_dry_run" == "true" ]]; then - ((dry_run_count++)) + dry_run_count=$((dry_run_count + 1)) log_operation "clean" "DRY_RUN" "$plist_path" "orphaned launch agent" continue fi @@ -706,7 +706,7 @@ clean_orphaned_launch_agents() { # Remove the plist file if safe_remove "$plist_path" false; then - ((removed_count++)) + removed_count=$((removed_count + 1)) log_operation "clean" "REMOVED" "$plist_path" "orphaned launch agent" else log_operation "clean" "FAILED" "$plist_path" "permission denied" diff --git a/lib/clean/brew.sh b/lib/clean/brew.sh index 89f930c..cf16da4 100644 --- a/lib/clean/brew.sh +++ b/lib/clean/brew.sh @@ -5,7 +5,12 @@ clean_homebrew() { command -v brew > /dev/null 2>&1 || return 0 if [[ "${DRY_RUN:-false}" == "true" ]]; then - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Homebrew · would cleanup and autoremove" + # Check if Homebrew cache is whitelisted + if is_path_whitelisted "$HOME/Library/Caches/Homebrew"; then + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Homebrew · skipped whitelist" + else + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Homebrew · would cleanup and autoremove" + fi return 0 fi # Skip if cleaned recently to avoid repeated heavy operations. diff --git a/lib/clean/caches.sh b/lib/clean/caches.sh index c0ecdb5..f7b4e1c 100644 --- a/lib/clean/caches.sh +++ b/lib/clean/caches.sh @@ -146,6 +146,8 @@ clean_project_caches() { done if [[ "$spinner_active" == "true" ]]; then stop_inline_spinner 2> /dev/null || true + # Extra clear to prevent spinner character remnants in terminal + [[ -t 1 ]] && printf "\r\033[2K" >&2 || true fi [[ "$has_dev_projects" == "false" ]] && return 0 fi @@ -208,7 +210,7 @@ clean_project_caches() { break fi sleep 0.1 - ((grace_period++)) + grace_period=$((grace_period + 1)) done if kill -0 "$pid" 2> /dev/null; then kill -KILL "$pid" 2> /dev/null || true diff --git a/lib/clean/dev.sh b/lib/clean/dev.sh index 582e977..940367d 100644 --- a/lib/clean/dev.sh +++ b/lib/clean/dev.sh @@ -7,7 +7,17 @@ clean_tool_cache() { local description="$1" shift if [[ "$DRY_RUN" != "true" ]]; then + local command_succeeded=false + if [[ -t 1 ]]; then + start_section_spinner "Cleaning $description..." + fi if "$@" > /dev/null 2>&1; then + command_succeeded=true + fi + if [[ -t 1 ]]; then + stop_section_spinner + fi + if [[ "$command_succeeded" == "true" ]]; then echo -e " ${GREEN}${ICON_SUCCESS}${NC} $description" fi else @@ -85,7 +95,8 @@ clean_dev_npm() { } # Python/pip ecosystem caches. clean_dev_python() { - if command -v pip3 > /dev/null 2>&1; then + # Check pip3 is functional (not just macOS stub that triggers CLT install dialog) + if command -v pip3 > /dev/null 2>&1 && pip3 --version > /dev/null 2>&1; then clean_tool_cache "pip cache" bash -c 'pip3 cache purge > /dev/null 2>&1 || true' note_activity fi @@ -249,11 +260,11 @@ clean_xcode_documentation_cache() { local entry for entry in "${sorted_entries[@]}"; do if [[ $idx -eq 0 ]]; then - ((idx++)) + idx=$((idx + 1)) continue fi stale_entries+=("$entry") - ((idx++)) + idx=$((idx + 1)) done if [[ ${#stale_entries[@]} -eq 0 ]]; then @@ -380,7 +391,7 @@ clean_xcode_simulator_runtime_volumes() { local unused_count=0 for candidate in "${sorted_candidates[@]}"; do local status="UNUSED" - if _sim_runtime_is_path_in_use "$candidate" "${mount_points[@]}"; then + if [[ ${#mount_points[@]} -gt 0 ]] && _sim_runtime_is_path_in_use "$candidate" "${mount_points[@]}"; then status="IN_USE" in_use_count=$((in_use_count + 1)) else @@ -791,12 +802,12 @@ clean_dev_jetbrains_toolbox() { local dir_path for dir_path in "${sorted_dirs[@]}"; do if [[ $idx -lt $keep_previous ]]; then - ((idx++)) + idx=$((idx + 1)) continue fi safe_clean "$dir_path" "JetBrains Toolbox old IDE version" note_activity - ((idx++)) + idx=$((idx + 1)) done done < <(command find "$product_dir" -mindepth 1 -maxdepth 1 -type d -name "ch-*" -print0 2> /dev/null) done diff --git a/lib/clean/hints.sh b/lib/clean/hints.sh index edabef8..f6538bf 100644 --- a/lib/clean/hints.sh +++ b/lib/clean/hints.sh @@ -3,9 +3,9 @@ set -euo pipefail -_MOLE_HINTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +mole_hints_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck disable=SC1090 -source "$_MOLE_HINTS_DIR/purge_shared.sh" +source "$mole_hints_dir/purge_shared.sh" # Quick reminder probe for project build artifacts handled by `mo purge`. # Designed to be very fast: shallow directory checks only, no deep find scans. @@ -58,7 +58,7 @@ hint_get_path_size_kb_with_timeout() { record_project_artifact_hint() { local path="$1" - ((PROJECT_ARTIFACT_HINT_COUNT++)) + PROJECT_ARTIFACT_HINT_COUNT=$((PROJECT_ARTIFACT_HINT_COUNT + 1)) if [[ ${#PROJECT_ARTIFACT_HINT_EXAMPLES[@]} -lt 2 ]]; then PROJECT_ARTIFACT_HINT_EXAMPLES+=("${path/#$HOME/~}") @@ -74,8 +74,8 @@ record_project_artifact_hint() { local size_kb="" if size_kb=$(hint_get_path_size_kb_with_timeout "$path" "$timeout_seconds"); then if [[ "$size_kb" =~ ^[0-9]+$ ]]; then - ((PROJECT_ARTIFACT_HINT_ESTIMATED_KB += size_kb)) - ((PROJECT_ARTIFACT_HINT_ESTIMATE_SAMPLES++)) + PROJECT_ARTIFACT_HINT_ESTIMATED_KB=$((PROJECT_ARTIFACT_HINT_ESTIMATED_KB + size_kb)) + PROJECT_ARTIFACT_HINT_ESTIMATE_SAMPLES=$((PROJECT_ARTIFACT_HINT_ESTIMATE_SAMPLES + 1)) else PROJECT_ARTIFACT_HINT_ESTIMATE_PARTIAL=true fi @@ -140,8 +140,8 @@ probe_project_artifact_hints() { local root_projects_scanned=0 if is_quick_purge_project_root "$root"; then - ((scanned_projects++)) - ((root_projects_scanned++)) + scanned_projects=$((scanned_projects + 1)) + root_projects_scanned=$((root_projects_scanned + 1)) if [[ $scanned_projects -gt $max_projects ]]; then PROJECT_ARTIFACT_HINT_TRUNCATED=true stop_scan=true @@ -175,8 +175,8 @@ probe_project_artifact_hints() { break fi - ((scanned_projects++)) - ((root_projects_scanned++)) + scanned_projects=$((scanned_projects + 1)) + root_projects_scanned=$((root_projects_scanned + 1)) if [[ $scanned_projects -gt $max_projects ]]; then PROJECT_ARTIFACT_HINT_TRUNCATED=true stop_scan=true @@ -206,7 +206,7 @@ probe_project_artifact_hints() { ;; esac - ((nested_count++)) + nested_count=$((nested_count + 1)) if [[ $nested_count -gt $max_nested_per_project ]]; then break fi diff --git a/lib/clean/project.sh b/lib/clean/project.sh index deeb5dd..d6158b4 100644 --- a/lib/clean/project.sh +++ b/lib/clean/project.sh @@ -569,16 +569,38 @@ select_purge_categories() { fi done local original_stty="" + local previous_exit_trap="" + local previous_int_trap="" + local previous_term_trap="" + local terminal_restored=false if [[ -t 0 ]] && command -v stty > /dev/null 2>&1; then original_stty=$(stty -g 2> /dev/null || echo "") fi + previous_exit_trap=$(trap -p EXIT || true) + previous_int_trap=$(trap -p INT || true) + previous_term_trap=$(trap -p TERM || true) # Terminal control functions restore_terminal() { + # Avoid trap churn when restore is called repeatedly via RETURN/EXIT paths. + if [[ "${terminal_restored:-false}" == "true" ]]; then + return + fi + terminal_restored=true + trap - EXIT INT TERM show_cursor if [[ -n "${original_stty:-}" ]]; then stty "${original_stty}" 2> /dev/null || stty sane 2> /dev/null || true fi + if [[ -n "$previous_exit_trap" ]]; then + eval "$previous_exit_trap" + fi + if [[ -n "$previous_int_trap" ]]; then + eval "$previous_int_trap" + fi + if [[ -n "$previous_term_trap" ]]; then + eval "$previous_term_trap" + fi } # shellcheck disable=SC2329 handle_interrupt() { @@ -618,7 +640,7 @@ select_purge_categories() { for ((i = 0; i < total_items; i++)); do if [[ ${selected[i]} == true ]]; then selected_size=$((selected_size + ${sizes[i]:-0})) - ((selected_count++)) + selected_count=$((selected_count + 1)) fi done @@ -633,7 +655,6 @@ select_purge_categories() { scroll_indicator=" ${GRAY}[${current_pos}/${total_items}]${NC}" fi - printf "%s\n" "$clear_line" printf "%s${PURPLE_BOLD}Select Categories to Clean${NC}%s${GRAY}, ${selected_size_human}, ${selected_count} selected${NC}\n" "$clear_line" "$scroll_indicator" printf "%s\n" "$clear_line" @@ -656,15 +677,42 @@ select_purge_categories() { fi done - # Fill empty slots to clear previous content - local items_shown=$visible_count - for ((i = items_shown; i < items_per_page; i++)); do - printf "%s\n" "$clear_line" - done - + # Keep one blank line between the list and footer tips. printf "%s\n" "$clear_line" - printf "%s${GRAY}${ICON_NAV_UP}${ICON_NAV_DOWN}/J/K | Space Select | Enter Confirm | A All | I Invert | Q Quit${NC}\n" "$clear_line" + # Adaptive footer hints — mirrors menu_paginated.sh pattern + local _term_w + _term_w=$(tput cols 2> /dev/null || echo 80) + [[ "$_term_w" =~ ^[0-9]+$ ]] || _term_w=80 + + local _sep=" ${GRAY}|${NC} " + local _nav="${GRAY}${ICON_NAV_UP}${ICON_NAV_DOWN}${NC}" + local _space="${GRAY}Space Select${NC}" + local _enter="${GRAY}Enter Confirm${NC}" + local _all="${GRAY}A All${NC}" + local _invert="${GRAY}I Invert${NC}" + local _quit="${GRAY}Q Quit${NC}" + + # Strip ANSI to measure real length + _ph_len() { printf "%s" "$1" | LC_ALL=C awk '{gsub(/\033\[[0-9;]*[A-Za-z]/,""); printf "%d", length}'; } + + # Level 0 (full): ↑↓ | Space Select | Enter Confirm | A All | I Invert | Q Quit + local _full="${_nav}${_sep}${_space}${_sep}${_enter}${_sep}${_all}${_sep}${_invert}${_sep}${_quit}" + if (($(_ph_len "$_full") <= _term_w)); then + printf "%s${_full}${NC}\n" "$clear_line" + else + # Level 1: ↑↓ | Enter Confirm | A All | I Invert | Q Quit + local _l1="${_nav}${_sep}${_enter}${_sep}${_all}${_sep}${_invert}${_sep}${_quit}" + if (($(_ph_len "$_l1") <= _term_w)); then + printf "%s${_l1}${NC}\n" "$clear_line" + else + # Level 2 (minimal): ↑↓ | Enter | Q Quit + printf "%s${_nav}${_sep}${_enter}${_sep}${_quit}${NC}\n" "$clear_line" + fi + fi + + # Clear stale content below the footer when list height shrinks. + printf '\033[J' } move_cursor_up() { if [[ $cursor_pos -gt 0 ]]; then @@ -680,9 +728,9 @@ select_purge_categories() { local visible_count=$((total_items - top_index)) [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then - ((cursor_pos++)) + cursor_pos=$((cursor_pos + 1)) elif [[ $((top_index + visible_count)) -lt $total_items ]]; then - ((top_index++)) + top_index=$((top_index + 1)) fi fi } @@ -767,6 +815,48 @@ select_purge_categories() { esac done } + +# Final confirmation before deleting selected purge artifacts. +confirm_purge_cleanup() { + local item_count="${1:-0}" + local total_size_kb="${2:-0}" + local unknown_count="${3:-0}" + + [[ "$item_count" =~ ^[0-9]+$ ]] || item_count=0 + [[ "$total_size_kb" =~ ^[0-9]+$ ]] || total_size_kb=0 + [[ "$unknown_count" =~ ^[0-9]+$ ]] || unknown_count=0 + + local item_text="artifact" + [[ $item_count -ne 1 ]] && item_text="artifacts" + + local size_display + size_display=$(bytes_to_human "$((total_size_kb * 1024))") + + local unknown_hint="" + if [[ $unknown_count -gt 0 ]]; then + local unknown_text="unknown size" + [[ $unknown_count -gt 1 ]] && unknown_text="unknown sizes" + unknown_hint=", ${unknown_count} ${unknown_text}" + fi + + echo -ne "${PURPLE}${ICON_ARROW}${NC} Remove ${item_count} ${item_text}, ${size_display}${unknown_hint} ${GREEN}Enter${NC} confirm, ${GRAY}ESC${NC} cancel: " + drain_pending_input + local key="" + IFS= read -r -s -n1 key || key="" + drain_pending_input + + case "$key" in + "" | $'\n' | $'\r' | y | Y) + echo "" + return 0 + ;; + *) + echo "" + return 1 + ;; + esac +} + # Main cleanup function - scans and prompts user to select artifacts to clean clean_project_artifacts() { local -a all_found_items=() @@ -825,8 +915,6 @@ clean_project_artifacts() { # Give monitor process time to exit and clear its output if [[ -t 1 ]]; then sleep 0.2 - # Clear the scanning line but preserve the title - printf '\n\033[K' fi # Collect all results @@ -1041,32 +1129,57 @@ clean_project_artifacts() { echo "$artifact_name" fi } - # Format display with alignment (like app_selector) + # Format display with alignment (mirrors app_selector.sh approach) + # Args: $1=project_path $2=artifact_type $3=size_str $4=terminal_width $5=max_path_width $6=artifact_col_width format_purge_display() { local project_path="$1" local artifact_type="$2" local size_str="$3" - # Terminal width for alignment - local terminal_width=$(tput cols 2> /dev/null || echo 80) - local fixed_width=32 # Reserve for size and artifact type (9 + 3 + 20) - local available_width=$((terminal_width - fixed_width)) - # Bounds: 30 chars min, but cap at 70% of terminal width to preserve aesthetics - local max_aesthetic_width=$((terminal_width * 70 / 100)) - [[ $available_width -gt $max_aesthetic_width ]] && available_width=$max_aesthetic_width - [[ $available_width -lt 30 ]] && available_width=30 + local terminal_width="${4:-$(tput cols 2> /dev/null || echo 80)}" + local max_path_width="${5:-}" + local artifact_col="${6:-12}" + local available_width + + if [[ -n "$max_path_width" ]]; then + available_width="$max_path_width" + else + # Standalone fallback: overhead = prefix(4)+space(1)+size(9)+sep(3)+artifact_col+recent(9) = artifact_col+26 + local fixed_width=$((artifact_col + 26)) + available_width=$((terminal_width - fixed_width)) + + local min_width=10 + if [[ $terminal_width -ge 120 ]]; then + min_width=48 + elif [[ $terminal_width -ge 100 ]]; then + min_width=38 + elif [[ $terminal_width -ge 80 ]]; then + min_width=25 + fi + + [[ $available_width -lt $min_width ]] && available_width=$min_width + [[ $available_width -gt 60 ]] && available_width=60 + fi + # Truncate project path if needed - local truncated_path=$(truncate_by_display_width "$project_path" "$available_width") - local current_width=$(get_display_width "$truncated_path") + local truncated_path + truncated_path=$(truncate_by_display_width "$project_path" "$available_width") + local current_width + current_width=$(get_display_width "$truncated_path") local char_count=${#truncated_path} local padding=$((available_width - current_width)) local printf_width=$((char_count + padding)) # Format: "project_path size | artifact_type" - printf "%-*s %9s | %-17s" "$printf_width" "$truncated_path" "$size_str" "$artifact_type" + printf "%-*s %9s | %-*s" "$printf_width" "$truncated_path" "$size_str" "$artifact_col" "$artifact_type" } # Build menu options - one line per artifact + # Pass 1: collect data into parallel arrays (needed for pre-scan of widths) + local -a raw_project_paths=() + local -a raw_artifact_types=() for item in "${safe_to_clean[@]}"; do - local project_path=$(get_project_path "$item") - local artifact_type=$(get_artifact_display_name "$item") + local project_path + project_path=$(get_project_path "$item") + local artifact_type + artifact_type=$(get_artifact_display_name "$item") local size_raw size_raw=$(get_dir_size_kb "$item") local size_kb=0 @@ -1095,13 +1208,66 @@ clean_project_artifacts() { break fi done - menu_options+=("$(format_purge_display "$project_path" "$artifact_type" "$size_human")") + raw_project_paths+=("$project_path") + raw_artifact_types+=("$artifact_type") item_paths+=("$item") item_sizes+=("$size_kb") item_size_unknown_flags+=("$size_unknown") item_recent_flags+=("$is_recent") done + # Pre-scan: find max path and artifact display widths (mirrors app_selector.sh approach) + local terminal_width + terminal_width=$(tput cols 2> /dev/null || echo 80) + [[ "$terminal_width" =~ ^[0-9]+$ ]] || terminal_width=80 + + local max_path_display_width=0 + local max_artifact_width=0 + for pp in "${raw_project_paths[@]+"${raw_project_paths[@]}"}"; do + local w + w=$(get_display_width "$pp") + [[ $w -gt $max_path_display_width ]] && max_path_display_width=$w + done + for at in "${raw_artifact_types[@]+"${raw_artifact_types[@]}"}"; do + [[ ${#at} -gt $max_artifact_width ]] && max_artifact_width=${#at} + done + + # Artifact column: cap at 17, floor at 6 (shortest typical names like "dist") + [[ $max_artifact_width -lt 6 ]] && max_artifact_width=6 + [[ $max_artifact_width -gt 17 ]] && max_artifact_width=17 + + # Exact overhead: prefix(4) + space(1) + size(9) + " | "(3) + artifact_col + " | Recent"(9) = artifact_col + 26 + local fixed_overhead=$((max_artifact_width + 26)) + local available_for_path=$((terminal_width - fixed_overhead)) + + local min_path_width=10 + if [[ $terminal_width -ge 120 ]]; then + min_path_width=48 + elif [[ $terminal_width -ge 100 ]]; then + min_path_width=38 + elif [[ $terminal_width -ge 80 ]]; then + min_path_width=25 + fi + + [[ $max_path_display_width -lt $min_path_width ]] && max_path_display_width=$min_path_width + [[ $available_for_path -lt $max_path_display_width ]] && max_path_display_width=$available_for_path + [[ $max_path_display_width -gt 60 ]] && max_path_display_width=60 + # Ensure path width is at least 5 on very narrow terminals + [[ $max_path_display_width -lt 5 ]] && max_path_display_width=5 + + # Pass 2: build menu_options using pre-computed widths + for ((idx = 0; idx < ${#raw_project_paths[@]}; idx++)); do + local size_kb_val="${item_sizes[idx]}" + local size_unknown_val="${item_size_unknown_flags[idx]}" + local size_human_val="" + if [[ "$size_unknown_val" == "true" ]]; then + size_human_val="unknown" + else + size_human_val=$(bytes_to_human "$((size_kb_val * 1024))") + fi + menu_options+=("$(format_purge_display "${raw_project_paths[idx]}" "${raw_artifact_types[idx]}" "$size_human_val" "$terminal_width" "$max_path_display_width" "$max_artifact_width")") + done + # Sort by size descending (largest first) - requested in issue #311 # Use external sort for better performance with many items if [[ ${#item_sizes[@]} -gt 0 ]]; then @@ -1147,11 +1313,11 @@ clean_project_artifacts() { # Set global vars for selector export PURGE_CATEGORY_SIZES=$( IFS=, - echo "${item_sizes[*]}" + echo "${item_sizes[*]-}" ) export PURGE_RECENT_CATEGORIES=$( IFS=, - echo "${item_recent_flags[*]}" + echo "${item_recent_flags[*]-}" ) # Interactive selection (only if terminal is available) PURGE_SELECTION_RESULT="" @@ -1176,11 +1342,32 @@ clean_project_artifacts() { unset PURGE_CATEGORY_SIZES PURGE_RECENT_CATEGORIES PURGE_SELECTION_RESULT return 0 fi + IFS=',' read -r -a selected_indices <<< "$PURGE_SELECTION_RESULT" + local selected_total_kb=0 + local selected_unknown_count=0 + for idx in "${selected_indices[@]}"; do + local selected_size_kb="${item_sizes[idx]:-0}" + [[ "$selected_size_kb" =~ ^[0-9]+$ ]] || selected_size_kb=0 + selected_total_kb=$((selected_total_kb + selected_size_kb)) + if [[ "${item_size_unknown_flags[idx]:-false}" == "true" ]]; then + selected_unknown_count=$((selected_unknown_count + 1)) + fi + done + + if [[ -t 0 ]]; then + if ! confirm_purge_cleanup "${#selected_indices[@]}" "$selected_total_kb" "$selected_unknown_count"; then + echo -e "${GRAY}Purge cancelled${NC}" + printf '\n' + unset PURGE_CATEGORY_SIZES PURGE_RECENT_CATEGORIES PURGE_SELECTION_RESULT + return 1 + fi + fi + # Clean selected items echo "" - IFS=',' read -r -a selected_indices <<< "$PURGE_SELECTION_RESULT" 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") @@ -1200,17 +1387,27 @@ clean_project_artifacts() { if [[ -t 1 ]]; then start_inline_spinner "Cleaning $project_path/$artifact_type..." fi + local removal_recorded=false if [[ -e "$item_path" ]]; then - safe_remove "$item_path" true - if [[ ! -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++)) + if safe_remove "$item_path" true; then + if [[ "$dry_run_mode" == "1" || ! -e "$item_path" ]]; then + local current_total + 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)) + removal_recorded=true + fi fi fi if [[ -t 1 ]]; then stop_inline_spinner - echo -e "${GREEN}${ICON_SUCCESS}${NC} $project_path, $artifact_type${NC}, ${GREEN}$size_human${NC}" + if [[ "$removal_recorded" == "true" ]]; then + 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 fi done # Update count diff --git a/lib/clean/system.sh b/lib/clean/system.sh index eea1883..817964e 100644 --- a/lib/clean/system.sh +++ b/lib/clean/system.sh @@ -5,6 +5,7 @@ set -euo pipefail clean_deep_system() { stop_section_spinner local cache_cleaned=0 + start_section_spinner "Cleaning system caches..." # Optimized: Single pass for /Library/Caches (3 patterns in 1 scan) if sudo test -d "/Library/Caches" 2> /dev/null; then while IFS= read -r -d '' file; do @@ -20,6 +21,7 @@ clean_deep_system() { \( -name "*.log" -mtime "+$MOLE_LOG_AGE_DAYS" \) \ \) -print0 2> /dev/null || true) fi + stop_section_spinner [[ $cache_cleaned -eq 1 ]] && log_success "System caches" start_section_spinner "Cleaning system temporary files..." local tmp_cleaned=0 @@ -84,7 +86,7 @@ clean_deep_system() { continue fi if safe_sudo_remove "$item"; then - ((updates_cleaned++)) + updates_cleaned=$((updates_cleaned + 1)) fi done < <(find /Library/Updates -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true) stop_section_spinner @@ -141,28 +143,33 @@ clean_deep_system() { debug_log "Cleaning macOS installer: $app_name, $size_human, ${age_days} days old" if safe_sudo_remove "$installer_app"; then log_success "$app_name, $size_human" - ((installer_cleaned++)) + installer_cleaned=$((installer_cleaned + 1)) fi fi done stop_section_spinner [[ $installer_cleaned -gt 0 ]] && debug_log "Cleaned $installer_cleaned macOS installer(s)" - start_section_spinner "Scanning system caches..." + start_section_spinner "Scanning browser code signature caches..." local code_sign_cleaned=0 while IFS= read -r -d '' cache_dir; do if safe_sudo_remove "$cache_dir"; then - ((code_sign_cleaned++)) + code_sign_cleaned=$((code_sign_cleaned + 1)) fi done < <(run_with_timeout 5 command find /private/var/folders -type d -name "*.code_sign_clone" -path "*/X/*" -print0 2> /dev/null || true) stop_section_spinner [[ $code_sign_cleaned -gt 0 ]] && log_success "Browser code signature caches, $code_sign_cleaned items" local diag_base="/private/var/db/diagnostics" + start_section_spinner "Cleaning system diagnostic logs..." safe_sudo_find_delete "$diag_base" "*" "$MOLE_LOG_AGE_DAYS" "f" || true safe_sudo_find_delete "$diag_base" "*.tracev3" "30" "f" || true safe_sudo_find_delete "/private/var/db/DiagnosticPipeline" "*" "$MOLE_LOG_AGE_DAYS" "f" || true + stop_section_spinner log_success "System diagnostic logs" + + start_section_spinner "Cleaning power logs..." safe_sudo_find_delete "/private/var/db/powerlog" "*" "$MOLE_LOG_AGE_DAYS" "f" || true + stop_section_spinner log_success "Power logs" start_section_spinner "Cleaning memory exception reports..." local mem_reports_dir="/private/var/db/reportmemoryexception/MemoryLimitViolations" @@ -171,15 +178,16 @@ clean_deep_system() { # Count and size old files before deletion local file_count=0 local total_size_kb=0 - while IFS= read -r -d '' file; do - ((file_count++)) - local file_size - file_size=$(sudo stat -f%z "$file" 2> /dev/null || echo "0") - ((total_size_kb += file_size / 1024)) - done < <(sudo find "$mem_reports_dir" -type f -mtime +30 -print0 2> /dev/null || true) + local total_bytes=0 + local stats_out + stats_out=$(sudo find "$mem_reports_dir" -type f -mtime +30 -exec stat -f "%z" {} + 2> /dev/null | awk '{c++; s+=$1} END {print c+0, s+0}' || true) + if [[ -n "$stats_out" ]]; then + read -r file_count total_bytes <<< "$stats_out" + total_size_kb=$((total_bytes / 1024)) + fi if [[ "$file_count" -gt 0 ]]; then - if [[ "${DRY_RUN:-false}" != "true" ]]; then + if [[ "${DRY_RUN:-}" != "true" ]]; then if safe_sudo_find_delete "$mem_reports_dir" "*" "30" "f"; then mem_cleaned=1 fi @@ -207,6 +215,11 @@ clean_time_machine_failed_backups() { echo -e " ${GREEN}${ICON_SUCCESS}${NC} No incomplete backups found" return 0 fi + # Fast pre-check: skip entirely if Time Machine is not configured (no tmutil needed) + if ! defaults read /Library/Preferences/com.apple.TimeMachine AutoBackup 2> /dev/null | grep -qE '^[01]$'; then + echo -e " ${GREEN}${ICON_SUCCESS}${NC} No incomplete backups found" + return 0 + fi start_section_spinner "Checking Time Machine configuration..." local spinner_active=true local tm_info @@ -287,7 +300,7 @@ clean_time_machine_failed_backups() { size_human=$(bytes_to_human "$((size_kb * 1024))") if [[ "$DRY_RUN" == "true" ]]; then echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Incomplete backup: $backup_name${NC}, ${YELLOW}$size_human dry${NC}" - ((tm_cleaned++)) + tm_cleaned=$((tm_cleaned + 1)) note_activity continue fi @@ -297,10 +310,10 @@ clean_time_machine_failed_backups() { fi if tmutil delete "$inprogress_file" 2> /dev/null; then echo -e " ${GREEN}${ICON_SUCCESS}${NC} Incomplete backup: $backup_name${NC}, ${GREEN}$size_human${NC}" - ((tm_cleaned++)) - ((files_cleaned++)) - ((total_size_cleaned += size_kb)) - ((total_items++)) + tm_cleaned=$((tm_cleaned + 1)) + files_cleaned=$((files_cleaned + 1)) + total_size_cleaned=$((total_size_cleaned + size_kb)) + total_items=$((total_items + 1)) note_activity else echo -e " ${YELLOW}!${NC} Could not delete: $backup_name · try manually with sudo" @@ -339,7 +352,7 @@ clean_time_machine_failed_backups() { size_human=$(bytes_to_human "$((size_kb * 1024))") if [[ "$DRY_RUN" == "true" ]]; then echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Incomplete APFS backup in $bundle_name: $backup_name${NC}, ${YELLOW}$size_human dry${NC}" - ((tm_cleaned++)) + tm_cleaned=$((tm_cleaned + 1)) note_activity continue fi @@ -348,10 +361,10 @@ clean_time_machine_failed_backups() { fi if tmutil delete "$inprogress_file" 2> /dev/null; then echo -e " ${GREEN}${ICON_SUCCESS}${NC} Incomplete APFS backup in $bundle_name: $backup_name${NC}, ${GREEN}$size_human${NC}" - ((tm_cleaned++)) - ((files_cleaned++)) - ((total_size_cleaned += size_kb)) - ((total_items++)) + tm_cleaned=$((tm_cleaned + 1)) + files_cleaned=$((files_cleaned + 1)) + total_size_cleaned=$((total_size_cleaned + size_kb)) + total_items=$((total_items + 1)) note_activity else echo -e " ${YELLOW}!${NC} Could not delete from bundle: $backup_name" @@ -388,6 +401,10 @@ clean_local_snapshots() { if ! command -v tmutil > /dev/null 2>&1; then return 0 fi + # Fast pre-check: skip entirely if Time Machine is not configured (no tmutil needed) + if ! defaults read /Library/Preferences/com.apple.TimeMachine AutoBackup 2> /dev/null | grep -qE '^[01]$'; then + return 0 + fi start_section_spinner "Checking Time Machine status..." local rc_running=0 diff --git a/lib/clean/user.sh b/lib/clean/user.sh index dac50d1..c111bd5 100644 --- a/lib/clean/user.sh +++ b/lib/clean/user.sh @@ -23,7 +23,7 @@ clean_user_essentials() { local cleaned_count=0 while IFS= read -r -d '' item; do if safe_remove "$item" true; then - ((cleaned_count++)) + cleaned_count=$((cleaned_count + 1)) fi done < <(command find "$HOME/.Trash" -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true) if [[ $cleaned_count -gt 0 ]]; then @@ -76,8 +76,13 @@ _clean_mail_downloads() { ) local count=0 local cleaned_kb=0 + local spinner_active=false for target_path in "${mail_dirs[@]}"; do if [[ -d "$target_path" ]]; then + if [[ "$spinner_active" == "false" && -t 1 ]]; then + start_section_spinner "Cleaning old Mail attachments..." + spinner_active=true + fi local dir_size_kb=0 dir_size_kb=$(get_path_size_kb "$target_path") if ! [[ "$dir_size_kb" =~ ^[0-9]+$ ]]; then @@ -95,13 +100,16 @@ _clean_mail_downloads() { local file_size_kb file_size_kb=$(get_path_size_kb "$file_path") if safe_remove "$file_path" true; then - ((count++)) - ((cleaned_kb += file_size_kb)) + count=$((count + 1)) + cleaned_kb=$((cleaned_kb + file_size_kb)) fi fi done < <(command find "$target_path" -type f -mtime +"$mail_age_days" -print0 2> /dev/null || true) fi done + if [[ "$spinner_active" == "true" ]]; then + stop_section_spinner + fi if [[ $count -gt 0 ]]; then local cleaned_mb cleaned_mb=$(echo "$cleaned_kb" | awk '{printf "%.1f", $1/1024}' || echo "0.0") @@ -163,7 +171,7 @@ clean_chrome_old_versions() { size_kb=$(get_path_size_kb "$dir" || echo 0) size_kb="${size_kb:-0}" total_size=$((total_size + size_kb)) - ((cleaned_count++)) + cleaned_count=$((cleaned_count + 1)) cleaned_any=true if [[ "$DRY_RUN" != "true" ]]; then if has_sudo_session; then @@ -183,9 +191,9 @@ clean_chrome_old_versions() { else echo -e " ${GREEN}${ICON_SUCCESS}${NC} Chrome old versions${NC}, ${GREEN}${cleaned_count} dirs, $size_human${NC}" fi - ((files_cleaned += cleaned_count)) - ((total_size_cleaned += total_size)) - ((total_items++)) + files_cleaned=$((files_cleaned + cleaned_count)) + total_size_cleaned=$((total_size_cleaned + total_size)) + total_items=$((total_items + 1)) note_activity fi } @@ -249,7 +257,7 @@ clean_edge_old_versions() { size_kb=$(get_path_size_kb "$dir" || echo 0) size_kb="${size_kb:-0}" total_size=$((total_size + size_kb)) - ((cleaned_count++)) + cleaned_count=$((cleaned_count + 1)) cleaned_any=true if [[ "$DRY_RUN" != "true" ]]; then if has_sudo_session; then @@ -269,9 +277,9 @@ clean_edge_old_versions() { else echo -e " ${GREEN}${ICON_SUCCESS}${NC} Edge old versions${NC}, ${GREEN}${cleaned_count} dirs, $size_human${NC}" fi - ((files_cleaned += cleaned_count)) - ((total_size_cleaned += total_size)) - ((total_items++)) + files_cleaned=$((files_cleaned + cleaned_count)) + total_size_cleaned=$((total_size_cleaned + total_size)) + total_items=$((total_items + 1)) note_activity fi } @@ -316,7 +324,7 @@ clean_edge_updater_old_versions() { size_kb=$(get_path_size_kb "$dir" || echo 0) size_kb="${size_kb:-0}" total_size=$((total_size + size_kb)) - ((cleaned_count++)) + cleaned_count=$((cleaned_count + 1)) cleaned_any=true if [[ "$DRY_RUN" != "true" ]]; then safe_remove "$dir" true > /dev/null 2>&1 || true @@ -331,9 +339,9 @@ clean_edge_updater_old_versions() { else echo -e " ${GREEN}${ICON_SUCCESS}${NC} Edge updater old versions${NC}, ${GREEN}${cleaned_count} dirs, $size_human${NC}" fi - ((files_cleaned += cleaned_count)) - ((total_size_cleaned += total_size)) - ((total_items++)) + files_cleaned=$((files_cleaned + cleaned_count)) + total_size_cleaned=$((total_size_cleaned + total_size)) + total_items=$((total_items + 1)) note_activity fi } @@ -387,6 +395,7 @@ scan_external_volumes() { done stop_section_spinner } + # Finder metadata (.DS_Store). clean_finder_metadata() { if [[ "$PROTECT_FINDER_METADATA" == "true" ]]; then @@ -411,14 +420,17 @@ clean_support_app_data() { safe_find_delete "$idle_assets_dir" "*" "$support_age_days" "f" || true fi + # Clean old aerial wallpaper videos (can be large, safe to remove). + safe_clean ~/Library/Application\ Support/com.apple.wallpaper/aerials/videos/* "Aerial wallpaper videos" + # Do not touch Messages attachments, only preview/sticker caches. if pgrep -x "Messages" > /dev/null 2>&1; then echo -e " ${GRAY}${ICON_WARNING}${NC} Messages is running · preview cache cleanup skipped" - return 0 + else + safe_clean ~/Library/Messages/StickerCache/* "Messages sticker cache" + safe_clean ~/Library/Messages/Caches/Previews/Attachments/* "Messages preview attachment cache" + safe_clean ~/Library/Messages/Caches/Previews/StickerCache/* "Messages preview sticker cache" fi - safe_clean ~/Library/Messages/StickerCache/* "Messages sticker cache" - safe_clean ~/Library/Messages/Caches/Previews/Attachments/* "Messages preview attachment cache" - safe_clean ~/Library/Messages/Caches/Previews/StickerCache/* "Messages preview sticker cache" } # App caches (merged: macOS system caches + Sandboxed apps). @@ -472,14 +484,15 @@ clean_app_caches() { else echo -e " ${GREEN}${ICON_SUCCESS}${NC} Sandboxed app caches${NC}, ${GREEN}$size_human${NC}" fi - ((files_cleaned += cleaned_count)) - ((total_size_cleaned += total_size)) - ((total_items++)) + files_cleaned=$((files_cleaned + cleaned_count)) + total_size_cleaned=$((total_size_cleaned + total_size)) + total_items=$((total_items + 1)) note_activity fi clean_group_container_caches } + # Process a single container cache directory. process_container_cache() { local container_dir="$1" @@ -500,9 +513,9 @@ process_container_cache() { if find "$cache_dir" -mindepth 1 -maxdepth 1 -print -quit 2> /dev/null | grep -q .; then local size size=$(get_path_size_kb "$cache_dir") - ((total_size += size)) + total_size=$((total_size + size)) found_any=true - ((cleaned_count++)) + cleaned_count=$((cleaned_count + 1)) if [[ "$DRY_RUN" != "true" ]]; then local item while IFS= read -r -d '' item; do @@ -600,7 +613,7 @@ clean_group_container_caches() { item_size=$(get_path_size_kb "$item" 2> /dev/null) || item_size=0 [[ "$item_size" =~ ^[0-9]+$ ]] || item_size=0 candidate_changed=true - ((candidate_size_kb += item_size)) + candidate_size_kb=$((candidate_size_kb + item_size)) done else for item in "${items_to_clean[@]}"; do @@ -609,14 +622,14 @@ clean_group_container_caches() { [[ "$item_size" =~ ^[0-9]+$ ]] || item_size=0 if safe_remove "$item" true 2> /dev/null; then candidate_changed=true - ((candidate_size_kb += item_size)) + candidate_size_kb=$((candidate_size_kb + item_size)) fi done fi if [[ "$candidate_changed" == "true" ]]; then - ((total_size += candidate_size_kb)) - ((cleaned_count++)) + total_size=$((total_size + candidate_size_kb)) + cleaned_count=$((cleaned_count + 1)) found_any=true fi done @@ -632,12 +645,13 @@ clean_group_container_caches() { else echo -e " ${GREEN}${ICON_SUCCESS}${NC} Group Containers logs/caches${NC}, ${GREEN}$size_human${NC}" fi - ((files_cleaned += cleaned_count)) - ((total_size_cleaned += total_size)) - ((total_items++)) + files_cleaned=$((files_cleaned + cleaned_count)) + total_size_cleaned=$((total_size_cleaned + total_size)) + total_items=$((total_items + 1)) note_activity fi } + # Browser caches (Safari/Chrome/Edge/Firefox). clean_browsers() { safe_clean ~/Library/Caches/com.apple.Safari/* "Safari cache" @@ -683,6 +697,7 @@ clean_browsers() { clean_edge_old_versions clean_edge_updater_old_versions } + # Cloud storage caches. clean_cloud_storage() { safe_clean ~/Library/Caches/com.dropbox.* "Dropbox cache" @@ -693,6 +708,7 @@ clean_cloud_storage() { safe_clean ~/Library/Caches/com.box.desktop "Box cache" safe_clean ~/Library/Caches/com.microsoft.OneDrive "OneDrive cache" } + # Office app caches. clean_office_applications() { safe_clean ~/Library/Caches/com.microsoft.Word "Microsoft Word cache" @@ -704,6 +720,7 @@ clean_office_applications() { safe_clean ~/Library/Caches/org.mozilla.thunderbird/* "Thunderbird cache" safe_clean ~/Library/Caches/com.apple.mail/* "Apple Mail cache" } + # Virtualization caches. clean_virtualization_tools() { stop_section_spinner @@ -712,6 +729,47 @@ clean_virtualization_tools() { safe_clean ~/VirtualBox\ VMs/.cache "VirtualBox cache" safe_clean ~/.vagrant.d/tmp/* "Vagrant temporary files" } + +# Estimate item size for Application Support cleanup. +# Files use stat; directories use du with timeout to avoid long blocking scans. +app_support_item_size_bytes() { + local item="$1" + local timeout_seconds="${2:-0.4}" + + if [[ -f "$item" && ! -L "$item" ]]; then + local file_bytes + file_bytes=$(stat -f%z "$item" 2> /dev/null || echo "0") + [[ "$file_bytes" =~ ^[0-9]+$ ]] || return 1 + printf '%s\n' "$file_bytes" + return 0 + fi + + if [[ -d "$item" && ! -L "$item" ]]; then + local du_tmp + du_tmp=$(mktemp) + local du_status=0 + if run_with_timeout "$timeout_seconds" du -skP "$item" > "$du_tmp" 2> /dev/null; then + du_status=0 + else + du_status=$? + fi + + if [[ $du_status -ne 0 ]]; then + rm -f "$du_tmp" + return 1 + fi + + local size_kb + size_kb=$(awk 'NR==1 {print $1; exit}' "$du_tmp") + rm -f "$du_tmp" + [[ "$size_kb" =~ ^[0-9]+$ ]] || return 1 + printf '%s\n' "$((size_kb * 1024))" + return 0 + fi + + return 1 +} + # Application Support logs/caches. clean_application_support_logs() { if [[ ! -d "$HOME/Library/Application Support" ]] || ! ls "$HOME/Library/Application Support" > /dev/null 2>&1; then @@ -721,14 +779,27 @@ clean_application_support_logs() { fi start_section_spinner "Scanning Application Support..." local total_size_bytes=0 + local total_size_partial=false local cleaned_count=0 local found_any=false + local size_timeout_seconds="${MOLE_APP_SUPPORT_ITEM_SIZE_TIMEOUT_SEC:-0.4}" + if [[ ! "$size_timeout_seconds" =~ ^[0-9]+([.][0-9]+)?$ ]]; then + size_timeout_seconds=0.4 + fi # Enable nullglob for safe globbing. local _ng_state _ng_state=$(shopt -p nullglob || true) shopt -s nullglob local app_count=0 local total_apps + # Temporarily disable pipefail here so that a partial find failure (e.g. TCC + # restrictions on macOS 26+) does not propagate through the pipeline and abort + # the whole scan via set -e. + local pipefail_was_set=false + if [[ -o pipefail ]]; then + pipefail_was_set=true + set +o pipefail + fi total_apps=$(command find "$HOME/Library/Application Support" -mindepth 1 -maxdepth 1 -type d 2> /dev/null | wc -l | tr -d ' ') [[ "$total_apps" =~ ^[0-9]+$ ]] || total_apps=0 local last_progress_update @@ -737,7 +808,7 @@ clean_application_support_logs() { [[ -d "$app_dir" ]] || continue local app_name app_name=$(basename "$app_dir") - ((app_count++)) + app_count=$((app_count + 1)) update_progress_if_needed "$app_count" "$total_apps" last_progress_update 1 || true local app_name_lower app_name_lower=$(echo "$app_name" | LC_ALL=C tr '[:upper:]' '[:lower:]') @@ -758,22 +829,34 @@ clean_application_support_logs() { if [[ -d "$candidate" ]]; then local item_found=false local candidate_size_bytes=0 + local candidate_size_partial=false local candidate_item_count=0 while IFS= read -r -d '' item; do [[ -e "$item" ]] || continue item_found=true - ((candidate_item_count++)) - if [[ -f "$item" && ! -L "$item" ]]; then - local bytes - bytes=$(stat -f%z "$item" 2> /dev/null || echo "0") - [[ "$bytes" =~ ^[0-9]+$ ]] && ((candidate_size_bytes += bytes)) || true + candidate_item_count=$((candidate_item_count + 1)) + if [[ ! -L "$item" && (-f "$item" || -d "$item") ]]; then + local item_size_bytes="" + if item_size_bytes=$(app_support_item_size_bytes "$item" "$size_timeout_seconds"); then + if [[ "$item_size_bytes" =~ ^[0-9]+$ ]]; then + candidate_size_bytes=$((candidate_size_bytes + item_size_bytes)) + else + candidate_size_partial=true + fi + else + candidate_size_partial=true + fi fi if ((candidate_item_count % 250 == 0)); then local current_time current_time=$(get_epoch_seconds) if [[ "$current_time" =~ ^[0-9]+$ ]] && ((current_time - last_progress_update >= 1)); then + local app_label="$app_name" + if [[ ${#app_label} -gt 24 ]]; then + app_label="${app_label:0:21}..." + fi stop_section_spinner - start_section_spinner "Scanning Application Support... $app_count/$total_apps ($app_name: $candidate_item_count items)" + start_section_spinner "Scanning Application Support... $app_count/$total_apps [$app_label, $candidate_item_count items]" last_progress_update=$current_time fi fi @@ -782,8 +865,9 @@ clean_application_support_logs() { fi done < <(command find "$candidate" -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true) if [[ "$item_found" == "true" ]]; then - ((total_size_bytes += candidate_size_bytes)) - ((cleaned_count++)) + total_size_bytes=$((total_size_bytes + candidate_size_bytes)) + [[ "$candidate_size_partial" == "true" ]] && total_size_partial=true + cleaned_count=$((cleaned_count + 1)) found_any=true fi fi @@ -800,22 +884,34 @@ clean_application_support_logs() { if [[ -d "$candidate" ]]; then local item_found=false local candidate_size_bytes=0 + local candidate_size_partial=false local candidate_item_count=0 while IFS= read -r -d '' item; do [[ -e "$item" ]] || continue item_found=true - ((candidate_item_count++)) - if [[ -f "$item" && ! -L "$item" ]]; then - local bytes - bytes=$(stat -f%z "$item" 2> /dev/null || echo "0") - [[ "$bytes" =~ ^[0-9]+$ ]] && ((candidate_size_bytes += bytes)) || true + candidate_item_count=$((candidate_item_count + 1)) + if [[ ! -L "$item" && (-f "$item" || -d "$item") ]]; then + local item_size_bytes="" + if item_size_bytes=$(app_support_item_size_bytes "$item" "$size_timeout_seconds"); then + if [[ "$item_size_bytes" =~ ^[0-9]+$ ]]; then + candidate_size_bytes=$((candidate_size_bytes + item_size_bytes)) + else + candidate_size_partial=true + fi + else + candidate_size_partial=true + fi fi if ((candidate_item_count % 250 == 0)); then local current_time current_time=$(get_epoch_seconds) if [[ "$current_time" =~ ^[0-9]+$ ]] && ((current_time - last_progress_update >= 1)); then + local container_label="$container" + if [[ ${#container_label} -gt 24 ]]; then + container_label="${container_label:0:21}..." + fi stop_section_spinner - start_section_spinner "Scanning Application Support... group container ($container: $candidate_item_count items)" + start_section_spinner "Scanning Application Support... group [$container_label, $candidate_item_count items]" last_progress_update=$current_time fi fi @@ -824,13 +920,18 @@ clean_application_support_logs() { fi done < <(command find "$candidate" -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true) if [[ "$item_found" == "true" ]]; then - ((total_size_bytes += candidate_size_bytes)) - ((cleaned_count++)) + total_size_bytes=$((total_size_bytes + candidate_size_bytes)) + [[ "$candidate_size_partial" == "true" ]] && total_size_partial=true + cleaned_count=$((cleaned_count + 1)) found_any=true fi fi done done + # Restore pipefail if it was previously set + if [[ "$pipefail_was_set" == "true" ]]; then + set -o pipefail + fi eval "$_ng_state" stop_section_spinner if [[ "$found_any" == "true" ]]; then @@ -838,13 +939,21 @@ clean_application_support_logs() { size_human=$(bytes_to_human "$total_size_bytes") local total_size_kb=$(((total_size_bytes + 1023) / 1024)) if [[ "$DRY_RUN" == "true" ]]; then - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Application Support logs/caches${NC}, ${YELLOW}$size_human dry${NC}" + if [[ "$total_size_partial" == "true" ]]; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Application Support logs/caches${NC}, ${YELLOW}at least $size_human dry${NC}" + else + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Application Support logs/caches${NC}, ${YELLOW}$size_human dry${NC}" + fi else - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Application Support logs/caches${NC}, ${GREEN}$size_human${NC}" + if [[ "$total_size_partial" == "true" ]]; then + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Application Support logs/caches${NC}, ${GREEN}at least $size_human${NC}" + else + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Application Support logs/caches${NC}, ${GREEN}$size_human${NC}" + fi fi - ((files_cleaned += cleaned_count)) - ((total_size_cleaned += total_size_kb)) - ((total_items++)) + files_cleaned=$((files_cleaned + cleaned_count)) + total_size_cleaned=$((total_size_cleaned + total_size_kb)) + total_items=$((total_items + 1)) note_activity fi } @@ -922,7 +1031,8 @@ check_large_file_candidates() { fi fi - if [[ "${SYSTEM_CLEAN:-false}" != "true" ]] && command -v tmutil > /dev/null 2>&1; then + if [[ "${SYSTEM_CLEAN:-false}" != "true" ]] && command -v tmutil > /dev/null 2>&1 && + defaults read /Library/Preferences/com.apple.TimeMachine AutoBackup 2> /dev/null | grep -qE '^[01]$'; then local snapshot_list snapshot_count snapshot_list=$(run_with_timeout 3 tmutil listlocalsnapshots / 2> /dev/null || true) if [[ -n "$snapshot_list" ]]; then diff --git a/lib/core/app_protection.sh b/lib/core/app_protection.sh index 1180152..144aac4 100755 --- a/lib/core/app_protection.sh +++ b/lib/core/app_protection.sh @@ -334,8 +334,8 @@ readonly DATA_PROTECTED_BUNDLES=( "*privateinternetaccess*" # Screensaver & Wallpaper - "*Aerial*" - "*aerial*" + "*Aerial.saver*" + "com.JohnCoates.Aerial*" "*Fliqlo*" "*fliqlo*" @@ -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 diff --git a/lib/core/base.sh b/lib/core/base.sh index 452f2b0..14dd48d 100644 --- a/lib/core/base.sh +++ b/lib/core/base.sh @@ -41,6 +41,28 @@ readonly ICON_DRY_RUN="→" readonly ICON_REVIEW="☞" readonly ICON_NAV_UP="↑" readonly ICON_NAV_DOWN="↓" +readonly ICON_INFO="ℹ" + +# ============================================================================ +# LaunchServices Utility +# ============================================================================ + +# Locate the lsregister binary (path varies across macOS versions). +get_lsregister_path() { + local -a candidates=( + "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister" + "/System/Library/CoreServices/Frameworks/LaunchServices.framework/Support/lsregister" + ) + local candidate="" + for candidate in "${candidates[@]}"; do + if [[ -x "$candidate" ]]; then + echo "$candidate" + return 0 + fi + done + echo "" + return 0 +} # ============================================================================ # Global Configuration Constants @@ -166,11 +188,6 @@ is_sip_enabled() { fi } -# Check if running in an interactive terminal -is_interactive() { - [[ -t 1 ]] -} - # Detect CPU architecture # Returns: "Apple Silicon" or "Intel" detect_architecture() { @@ -239,30 +256,6 @@ is_root_user() { [[ "$(id -u)" == "0" ]] } -get_user_home() { - local user="$1" - local home="" - - if [[ -z "$user" ]]; then - echo "" - return 0 - fi - - if command -v dscl > /dev/null 2>&1; then - home=$(dscl . -read "/Users/$user" NFSHomeDirectory 2> /dev/null | awk '{print $2}' | head -1 || true) - fi - - if [[ -z "$home" ]]; then - home=$(eval echo "~$user" 2> /dev/null || true) - fi - - if [[ "$home" == "~"* ]]; then - home="" - fi - - echo "$home" -} - get_invoking_user() { if [[ -n "${_MOLE_INVOKING_USER_CACHE:-}" ]]; then echo "$_MOLE_INVOKING_USER_CACHE" @@ -311,6 +304,30 @@ get_invoking_home() { echo "${HOME:-}" } +get_user_home() { + local user="$1" + local home="" + + if [[ -z "$user" ]]; then + echo "" + return 0 + fi + + if command -v dscl > /dev/null 2>&1; then + home=$(dscl . -read "/Users/$user" NFSHomeDirectory 2> /dev/null | awk '{print $2}' | head -1 || true) + fi + + if [[ -z "$home" ]]; then + home=$(eval echo "~$user" 2> /dev/null || true) + fi + + if [[ "$home" == "~"* ]]; then + home="" + fi + + echo "$home" +} + ensure_user_dir() { local raw_path="$1" if [[ -z "$raw_path" ]]; then @@ -428,35 +445,6 @@ ensure_user_file() { # Formatting Utilities # ============================================================================ -# Convert bytes to human-readable format (e.g., 1.5GB) -bytes_to_human() { - local bytes="$1" - [[ "$bytes" =~ ^[0-9]+$ ]] || { - echo "0B" - return 1 - } - - # GB: >= 1073741824 bytes - if ((bytes >= 1073741824)); then - printf "%d.%02dGB\n" $((bytes / 1073741824)) $(((bytes % 1073741824) * 100 / 1073741824)) - # MB: >= 1048576 bytes - elif ((bytes >= 1048576)); then - printf "%d.%01dMB\n" $((bytes / 1048576)) $(((bytes % 1048576) * 10 / 1048576)) - # KB: >= 1024 bytes (round up) - elif ((bytes >= 1024)); then - printf "%dKB\n" $(((bytes + 512) / 1024)) - else - printf "%dB\n" "$bytes" - fi -} - -# Convert kilobytes to human-readable format -# Args: $1 - size in KB -# Returns: formatted string -bytes_to_human_kb() { - bytes_to_human "$((${1:-0} * 1024))" -} - # Get brand-friendly localized name for an application get_brand_name() { local name="$1" @@ -513,6 +501,38 @@ get_brand_name() { fi } +# Convert bytes to human-readable format (e.g., 1.5GB) +# macOS (since Snow Leopard) uses Base-10 calculation (1 KB = 1000 bytes) +bytes_to_human() { + local bytes="$1" + [[ "$bytes" =~ ^[0-9]+$ ]] || { + echo "0B" + return 1 + } + + # GB: >= 1,000,000,000 bytes + if ((bytes >= 1000000000)); then + local scaled=$(((bytes * 100 + 500000000) / 1000000000)) + printf "%d.%02dGB\n" $((scaled / 100)) $((scaled % 100)) + # MB: >= 1,000,000 bytes + elif ((bytes >= 1000000)); then + local scaled=$(((bytes * 10 + 500000) / 1000000)) + printf "%d.%01dMB\n" $((scaled / 10)) $((scaled % 10)) + # KB: >= 1,000 bytes (round up to nearest KB instead of decimal) + elif ((bytes >= 1000)); then + printf "%dKB\n" $(((bytes + 500) / 1000)) + else + printf "%dB\n" "$bytes" + fi +} + +# Convert kilobytes to human-readable format +# Args: $1 - size in KB +# Returns: formatted string +bytes_to_human_kb() { + bytes_to_human "$((${1:-0} * 1024))" +} + # ============================================================================ # Temporary File Management # ============================================================================ @@ -704,91 +724,6 @@ update_progress_if_needed() { return 1 } -# ============================================================================ -# Spinner Stack Management (prevents nesting issues) -# ============================================================================ - -# Global spinner stack -declare -a MOLE_SPINNER_STACK=() - -# Push current spinner state onto stack -# Usage: push_spinner_state -push_spinner_state() { - local current_state="" - - # Save current spinner PID if running - if [[ -n "${MOLE_SPINNER_PID:-}" ]] && kill -0 "$MOLE_SPINNER_PID" 2> /dev/null; then - current_state="running:$MOLE_SPINNER_PID" - else - current_state="stopped" - fi - - MOLE_SPINNER_STACK+=("$current_state") - debug_log "Pushed spinner state: $current_state, stack depth: ${#MOLE_SPINNER_STACK[@]}" -} - -# Pop and restore spinner state from stack -# Usage: pop_spinner_state -pop_spinner_state() { - if [[ ${#MOLE_SPINNER_STACK[@]} -eq 0 ]]; then - debug_log "Warning: Attempted to pop from empty spinner stack" - return 1 - fi - - # Stack depth safety check - if [[ ${#MOLE_SPINNER_STACK[@]} -gt 10 ]]; then - debug_log "Warning: Spinner stack depth excessive, ${#MOLE_SPINNER_STACK[@]}, possible leak" - fi - - local last_idx=$((${#MOLE_SPINNER_STACK[@]} - 1)) - local state="${MOLE_SPINNER_STACK[$last_idx]}" - - # Remove from stack (Bash 3.2 compatible way) - # Instead of unset, rebuild array without last element - local -a new_stack=() - local i - for ((i = 0; i < last_idx; i++)); do - new_stack+=("${MOLE_SPINNER_STACK[$i]}") - done - MOLE_SPINNER_STACK=("${new_stack[@]}") - - debug_log "Popped spinner state: $state, remaining depth: ${#MOLE_SPINNER_STACK[@]}" - - # Restore state if needed - if [[ "$state" == running:* ]]; then - # Previous spinner was running - we don't restart it automatically - # This is intentional to avoid UI conflicts - : - fi - - return 0 -} - -# Safe spinner start with stack management -# Usage: safe_start_spinner -safe_start_spinner() { - local message="${1:-Working...}" - - # Push current state - push_spinner_state - - # Stop any existing spinner - stop_section_spinner 2> /dev/null || true - - # Start new spinner - start_section_spinner "$message" -} - -# Safe spinner stop with stack management -# Usage: safe_stop_spinner -safe_stop_spinner() { - # Stop current spinner - stop_section_spinner 2> /dev/null || true - - # Pop previous state - pop_spinner_state || true -} - # ============================================================================ # Terminal Compatibility Checks # ============================================================================ @@ -822,67 +757,3 @@ is_ansi_supported() { ;; esac } - -# Get terminal capability info -# Usage: get_terminal_info -get_terminal_info() { - local info="Terminal: ${TERM:-unknown}" - - if is_ansi_supported; then - info+=", ANSI supported" - - if command -v tput > /dev/null 2>&1; then - local cols=$(tput cols 2> /dev/null || echo "?") - local lines=$(tput lines 2> /dev/null || echo "?") - local colors=$(tput colors 2> /dev/null || echo "?") - info+=" ${cols}x${lines}, ${colors} colors" - fi - else - info+=", ANSI not supported" - fi - - echo "$info" -} - -# Validate terminal environment before running -# Usage: validate_terminal_environment -# Returns: 0 if OK, 1 with warning if issues detected -validate_terminal_environment() { - local warnings=0 - - # Check if TERM is set - if [[ -z "${TERM:-}" ]]; then - log_warning "TERM environment variable not set" - ((warnings++)) - fi - - # Check if running in a known problematic terminal - case "${TERM:-}" in - dumb) - log_warning "Running in 'dumb' terminal, limited functionality" - ((warnings++)) - ;; - unknown) - log_warning "Terminal type unknown, may have display issues" - ((warnings++)) - ;; - esac - - # Check terminal size if available - if command -v tput > /dev/null 2>&1; then - local cols=$(tput cols 2> /dev/null || echo "80") - if [[ "$cols" -lt 60 ]]; then - log_warning "Terminal width, $cols cols, is narrow, output may wrap" - ((warnings++)) - fi - fi - - # Report compatibility - if [[ $warnings -eq 0 ]]; then - debug_log "Terminal environment validated: $(get_terminal_info)" - return 0 - else - debug_log "Terminal compatibility warnings: $warnings" - return 1 - fi -} diff --git a/lib/core/common.sh b/lib/core/common.sh index d89b448..38f7640 100755 --- a/lib/core/common.sh +++ b/lib/core/common.sh @@ -163,7 +163,7 @@ remove_apps_from_dock() { local url url=$(/usr/libexec/PlistBuddy -c "Print :persistent-apps:$i:tile-data:file-data:_CFURLString" "$plist" 2> /dev/null || echo "") [[ -z "$url" ]] && { - ((i++)) + i=$((i + 1)) continue } @@ -175,7 +175,7 @@ remove_apps_from_dock() { continue fi fi - ((i++)) + i=$((i + 1)) done done diff --git a/lib/core/file_ops.sh b/lib/core/file_ops.sh index 7415f6a..5c41618 100644 --- a/lib/core/file_ops.sh +++ b/lib/core/file_ops.sh @@ -249,6 +249,11 @@ safe_remove() { local rm_exit=0 error_msg=$(rm -rf "$path" 2>&1) || rm_exit=$? # safe_remove + # Preserve interrupt semantics so callers can abort long-running deletions. + if [[ $rm_exit -ge 128 ]]; then + return "$rm_exit" + fi + if [[ $rm_exit -eq 0 ]]; then # Log successful removal log_operation "${MOLE_CURRENT_COMMAND:-clean}" "REMOVED" "$path" "$size_human" @@ -498,6 +503,19 @@ get_path_size_kb() { echo "0" return } + + # For .app bundles, prefer mdls logical size as it matches Finder + # (APFS clone/sparse files make 'du' severely underreport apps like Xcode) + if [[ "$path" == *.app || "$path" == *.app/ ]]; then + local mdls_size + mdls_size=$(mdls -name kMDItemLogicalSize -raw "$path" 2> /dev/null || true) + if [[ "$mdls_size" =~ ^[0-9]+$ && "$mdls_size" -gt 0 ]]; then + # Return in KB + echo "$((mdls_size / 1024))" + return + fi + fi + local size size=$(command du -skP "$path" 2> /dev/null | awk 'NR==1 {print $1; exit}' || true) @@ -518,7 +536,7 @@ calculate_total_size() { if [[ -n "$file" && -e "$file" ]]; then local size_kb size_kb=$(get_path_size_kb "$file") - ((total_kb += size_kb)) + total_kb=$((total_kb + size_kb)) fi done <<< "$files" diff --git a/lib/core/help.sh b/lib/core/help.sh index 13d2b17..2c0932e 100644 --- a/lib/core/help.sh +++ b/lib/core/help.sh @@ -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" } diff --git a/lib/core/log.sh b/lib/core/log.sh index 797b92f..95d92ce 100644 --- a/lib/core/log.sh +++ b/lib/core/log.sh @@ -363,7 +363,12 @@ print_summary_block() { fi done - local divider="======================================================================" + local _tw + _tw=$(tput cols 2> /dev/null || echo 70) + [[ "$_tw" =~ ^[0-9]+$ ]] || _tw=70 + [[ $_tw -gt 70 ]] && _tw=70 + local divider + divider=$(printf '%*s' "$_tw" '' | tr ' ' '=') # Print with dividers echo "" diff --git a/lib/core/sudo.sh b/lib/core/sudo.sh index d7de287..fe1fb16 100644 --- a/lib/core/sudo.sh +++ b/lib/core/sudo.sh @@ -76,7 +76,7 @@ _request_password() { if [[ -z "$password" ]]; then unset password - ((attempts++)) + attempts=$((attempts + 1)) if [[ $attempts -lt 3 ]]; then echo -e "${GRAY}${ICON_WARNING}${NC} Password cannot be empty" > "$tty_path" fi @@ -91,7 +91,7 @@ _request_password() { fi unset password - ((attempts++)) + attempts=$((attempts + 1)) if [[ $attempts -lt 3 ]]; then echo -e "${GRAY}${ICON_WARNING}${NC} Incorrect password, try again" > "$tty_path" fi @@ -166,7 +166,7 @@ request_sudo_access() { break fi sleep 0.1 - ((elapsed++)) + elapsed=$((elapsed + 1)) done # Touch ID failed/cancelled - clean up thoroughly before password input @@ -216,7 +216,7 @@ _start_sudo_keepalive() { local retry_count=0 while true; do if ! sudo -n -v 2> /dev/null; then - ((retry_count++)) + retry_count=$((retry_count + 1)) if [[ $retry_count -ge 3 ]]; then exit 1 fi diff --git a/lib/core/ui.sh b/lib/core/ui.sh index eb0c76c..421d29a 100755 --- a/lib/core/ui.sh +++ b/lib/core/ui.sh @@ -138,8 +138,8 @@ truncate_by_display_width() { fi truncated+="$char" - ((width += char_width)) - ((i++)) + width=$((width + char_width)) + i=$((i + 1)) done # Restore locale @@ -265,7 +265,7 @@ read_key() { drain_pending_input() { local drained=0 while IFS= read -r -s -n 1 -t 0.01 _ 2> /dev/null; do - ((drained++)) + drained=$((drained + 1)) [[ $drained -gt 100 ]] && break done } @@ -287,9 +287,40 @@ show_menu_option() { INLINE_SPINNER_PID="" INLINE_SPINNER_STOP_FILE="" +# Keep spinner message on one line and avoid wrapping/noisy output on narrow terminals. +format_spinner_message() { + local message="$1" + message="${message//$'\r'/ }" + message="${message//$'\n'/ }" + + local cols=80 + if command -v tput > /dev/null 2>&1; then + cols=$(tput cols 2> /dev/null || echo "80") + fi + [[ "$cols" =~ ^[0-9]+$ ]] || cols=80 + + # Reserve space for prefix + spinner char + spacing. + local available=$((cols - 8)) + if [[ $available -lt 20 ]]; then + available=20 + fi + + if [[ ${#message} -gt $available ]]; then + if [[ $available -gt 3 ]]; then + message="${message:0:$((available - 3))}..." + else + message="${message:0:$available}" + fi + fi + + printf "%s" "$message" +} + start_inline_spinner() { stop_inline_spinner 2> /dev/null || true local message="$1" + local display_message + display_message=$(format_spinner_message "$message") if [[ -t 1 ]]; then # Create unique stop flag file for this spinner instance @@ -309,8 +340,8 @@ start_inline_spinner() { while [[ ! -f "$stop_file" ]]; do local c="${chars:$((i % ${#chars})):1}" # Output to stderr to avoid interfering with stdout - printf "\r${MOLE_SPINNER_PREFIX:-}${BLUE}%s${NC} %s" "$c" "$message" >&2 || break - ((i++)) + printf "\r${MOLE_SPINNER_PREFIX:-}${BLUE}%s${NC} %s" "$c" "$display_message" >&2 || break + i=$((i + 1)) sleep 0.05 done @@ -321,7 +352,7 @@ start_inline_spinner() { INLINE_SPINNER_PID=$! disown "$INLINE_SPINNER_PID" 2> /dev/null || true else - echo -n " ${BLUE}|${NC} $message" >&2 || true + echo -n " ${BLUE}|${NC} $display_message" >&2 || true fi } @@ -336,7 +367,7 @@ stop_inline_spinner() { local wait_count=0 while kill -0 "$INLINE_SPINNER_PID" 2> /dev/null && [[ $wait_count -lt 5 ]]; do sleep 0.05 2> /dev/null || true - ((wait_count++)) + wait_count=$((wait_count + 1)) done # Only use SIGKILL as last resort if process is stuck @@ -356,20 +387,6 @@ stop_inline_spinner() { fi } -# Run command with a terminal spinner -with_spinner() { - local msg="$1" - shift || true - local timeout=180 - start_inline_spinner "$msg" - local exit_code=0 - if [[ -n "${MOLE_TIMEOUT_BIN:-}" ]]; then - "$MOLE_TIMEOUT_BIN" "$timeout" "$@" > /dev/null 2>&1 || exit_code=$? - else "$@" > /dev/null 2>&1 || exit_code=$?; fi - stop_inline_spinner "$msg" - return $exit_code -} - # Get spinner characters mo_spinner_chars() { local chars="|/-\\" diff --git a/lib/manage/autofix.sh b/lib/manage/autofix.sh index 4096c00..eb76fb4 100644 --- a/lib/manage/autofix.sh +++ b/lib/manage/autofix.sh @@ -138,7 +138,7 @@ perform_auto_fix() { echo -e "${BLUE}Enabling Firewall...${NC}" if sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setglobalstate on > /dev/null 2>&1; then echo -e "${GREEN}✓${NC} Firewall enabled" - ((fixed_count++)) + fixed_count=$((fixed_count + 1)) fixed_items+=("Firewall enabled") else echo -e "${RED}✗${NC} Failed to enable Firewall" @@ -154,7 +154,7 @@ perform_auto_fix() { auth sufficient pam_tid.so ' '$pam_file'" 2> /dev/null; then echo -e "${GREEN}✓${NC} Touch ID configured" - ((fixed_count++)) + fixed_count=$((fixed_count + 1)) fixed_items+=("Touch ID configured for sudo") else echo -e "${RED}✗${NC} Failed to configure Touch ID" @@ -167,7 +167,7 @@ auth sufficient pam_tid.so echo -e "${BLUE}Installing Rosetta 2...${NC}" if sudo softwareupdate --install-rosetta --agree-to-license 2>&1 | grep -qE "(Installing|Installed|already installed)"; then echo -e "${GREEN}✓${NC} Rosetta 2 installed" - ((fixed_count++)) + fixed_count=$((fixed_count + 1)) fixed_items+=("Rosetta 2 installed") else echo -e "${RED}✗${NC} Failed to install Rosetta 2" diff --git a/lib/manage/purge_paths.sh b/lib/manage/purge_paths.sh index 89f8517..aa34819 100644 --- a/lib/manage/purge_paths.sh +++ b/lib/manage/purge_paths.sh @@ -70,7 +70,7 @@ manage_purge_paths() { line="${line#"${line%%[![:space:]]*}"}" line="${line%"${line##*[![:space:]]}"}" [[ -z "$line" || "$line" =~ ^# ]] && continue - ((custom_count++)) + custom_count=$((custom_count + 1)) done < "$PURGE_PATHS_CONFIG" fi diff --git a/lib/manage/update.sh b/lib/manage/update.sh index bab206e..5ab9863 100644 --- a/lib/manage/update.sh +++ b/lib/manage/update.sh @@ -117,7 +117,7 @@ perform_updates() { if "$mole_bin" update 2>&1 | grep -qE "(Updated|latest version)"; then echo -e "${GREEN}${ICON_SUCCESS}${NC} Mole updated" reset_mole_cache - ((updated_count++)) + updated_count=$((updated_count + 1)) else echo -e "${RED}✗${NC} Mole update failed" fi diff --git a/lib/manage/whitelist.sh b/lib/manage/whitelist.sh index e322b6f..3424df7 100755 --- a/lib/manage/whitelist.sh +++ b/lib/manage/whitelist.sh @@ -302,7 +302,7 @@ ${GRAY}Edit: ${display_config}${NC}" cache_patterns+=("$pattern") menu_options+=("$display_name") - ((index++)) || true + index=$((index + 1)) done <<< "$items_source" # Identify custom patterns (not in predefined list) diff --git a/lib/optimize/maintenance.sh b/lib/optimize/maintenance.sh index 0cad6b3..a81c9cf 100644 --- a/lib/optimize/maintenance.sh +++ b/lib/optimize/maintenance.sh @@ -25,7 +25,7 @@ fix_broken_preferences() { plutil -lint "$plist_file" > /dev/null 2>&1 && continue safe_remove "$plist_file" true > /dev/null 2>&1 || true - ((broken_count++)) + broken_count=$((broken_count + 1)) done < <(command find "$prefs_dir" -maxdepth 1 -name "*.plist" -type f 2> /dev/null || true) # Check ByHost preferences. @@ -45,7 +45,7 @@ fix_broken_preferences() { plutil -lint "$plist_file" > /dev/null 2>&1 && continue safe_remove "$plist_file" true > /dev/null 2>&1 || true - ((broken_count++)) + broken_count=$((broken_count + 1)) done < <(command find "$byhost_dir" -name "*.plist" -type f 2> /dev/null || true) fi diff --git a/lib/optimize/tasks.sh b/lib/optimize/tasks.sh index 9d8d6bf..87f7758 100644 --- a/lib/optimize/tasks.sh +++ b/lib/optimize/tasks.sh @@ -14,7 +14,7 @@ opt_msg() { if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} $message" else - echo -e " ${GREEN}✓${NC} $message" + echo -e " ${GREEN}${ICON_SUCCESS}${NC} $message" fi } @@ -314,7 +314,7 @@ opt_sqlite_vacuum() { local file_size file_size=$(get_file_size "$db_file") if [[ "$file_size" -gt "$MOLE_SQLITE_MAX_SIZE" ]]; then - ((skipped++)) + skipped=$((skipped + 1)) continue fi @@ -327,7 +327,7 @@ opt_sqlite_vacuum() { freelist_count=$(echo "$page_info" | awk 'NR==2 {print $1}' 2> /dev/null || echo "") if [[ "$page_count" =~ ^[0-9]+$ && "$freelist_count" =~ ^[0-9]+$ && "$page_count" -gt 0 ]]; then if ((freelist_count * 100 < page_count * 5)); then - ((skipped++)) + skipped=$((skipped + 1)) continue fi fi @@ -341,7 +341,7 @@ opt_sqlite_vacuum() { set -e if [[ $integrity_status -ne 0 ]] || ! echo "$integrity_check" | grep -q "ok"; then - ((skipped++)) + skipped=$((skipped + 1)) continue fi fi @@ -354,14 +354,14 @@ opt_sqlite_vacuum() { set -e if [[ $exit_code -eq 0 ]]; then - ((vacuumed++)) + vacuumed=$((vacuumed + 1)) elif [[ $exit_code -eq 124 ]]; then - ((timed_out++)) + timed_out=$((timed_out + 1)) else - ((failed++)) + failed=$((failed + 1)) fi else - ((vacuumed++)) + vacuumed=$((vacuumed + 1)) fi done < <(compgen -G "$pattern" || true) done @@ -406,9 +406,10 @@ opt_launch_services_rebuild() { start_inline_spinner "" fi - local lsregister="/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister" + local lsregister + lsregister=$(get_lsregister_path) - if [[ -f "$lsregister" ]]; then + if [[ -n "$lsregister" ]]; then local success=0 if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then @@ -729,7 +730,7 @@ opt_spotlight_index_optimize() { test_end=$(get_epoch_seconds) test_duration=$((test_end - test_start)) if [[ $test_duration -gt 3 ]]; then - ((slow_count++)) + slow_count=$((slow_count + 1)) fi sleep 1 done @@ -741,7 +742,7 @@ opt_spotlight_index_optimize() { fi if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then - echo -e " ${BLUE}ℹ${NC} Spotlight search is slow, rebuilding index, may take 1-2 hours" + echo -e " ${BLUE}${ICON_INFO}${NC} Spotlight search is slow, rebuilding index, may take 1-2 hours" if sudo mdutil -E / > /dev/null 2>&1; then opt_msg "Spotlight index rebuild started" echo -e " ${GRAY}Indexing will continue in background${NC}" diff --git a/lib/ui/app_selector.sh b/lib/ui/app_selector.sh index 35610be..add9015 100755 --- a/lib/ui/app_selector.sh +++ b/lib/ui/app_selector.sh @@ -133,7 +133,7 @@ select_apps_for_uninstall() { sizekb_csv+=",${size_kb:-0}" fi names_arr+=("$display_name") - ((idx++)) + idx=$((idx + 1)) done # Use newline separator for names (safe for names with commas) local names_newline diff --git a/lib/ui/menu_paginated.sh b/lib/ui/menu_paginated.sh index 3ef9adc..c241fc1 100755 --- a/lib/ui/menu_paginated.sh +++ b/lib/ui/menu_paginated.sh @@ -155,7 +155,7 @@ paginated_multi_select() { # Only count if not already selected (handles duplicates) if [[ ${selected[idx]} != true ]]; then selected[idx]=true - ((selected_count++)) + selected_count=$((selected_count + 1)) fi fi done @@ -654,7 +654,7 @@ paginated_multi_select() { if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then local old_cursor=$cursor_pos - ((cursor_pos++)) + cursor_pos=$((cursor_pos + 1)) local new_cursor=$cursor_pos if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then @@ -674,7 +674,7 @@ paginated_multi_select() { prev_cursor_pos=$cursor_pos continue elif [[ $((top_index + visible_count)) -lt ${#view_indices[@]} ]]; then - ((top_index++)) + top_index=$((top_index + 1)) visible_count=$((${#view_indices[@]} - top_index)) [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page if [[ $cursor_pos -ge $visible_count ]]; then @@ -716,7 +716,7 @@ paginated_multi_select() { ((selected_count--)) else selected[real]=true - ((selected_count++)) + selected_count=$((selected_count + 1)) fi # Incremental update: only redraw header (for count) and current row @@ -757,9 +757,9 @@ paginated_multi_select() { local visible_count=$((${#view_indices[@]} - top_index)) [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then - ((cursor_pos++)) + cursor_pos=$((cursor_pos + 1)) elif [[ $((top_index + visible_count)) -lt ${#view_indices[@]} ]]; then - ((top_index++)) + top_index=$((top_index + 1)) fi need_full_redraw=true fi @@ -843,7 +843,7 @@ paginated_multi_select() { if [[ $idx -lt ${#view_indices[@]} ]]; then local real="${view_indices[idx]}" selected[real]=true - ((selected_count++)) + selected_count=$((selected_count + 1)) fi fi diff --git a/lib/ui/menu_simple.sh b/lib/ui/menu_simple.sh index f384024..0dd4607 100755 --- a/lib/ui/menu_simple.sh +++ b/lib/ui/menu_simple.sh @@ -159,7 +159,7 @@ paginated_multi_select() { # Count selections for header display local selected_count=0 for ((i = 0; i < total_items; i++)); do - [[ ${selected[i]} == true ]] && ((selected_count++)) + [[ ${selected[i]} == true ]] && selected_count=$((selected_count + 1)) done # Header @@ -247,9 +247,9 @@ paginated_multi_select() { [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then - ((cursor_pos++)) + cursor_pos=$((cursor_pos + 1)) elif [[ $((top_index + visible_count)) -lt $total_items ]]; then - ((top_index++)) + top_index=$((top_index + 1)) visible_count=$((total_items - top_index)) [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page if [[ $cursor_pos -ge $visible_count ]]; then diff --git a/lib/uninstall/batch.sh b/lib/uninstall/batch.sh index 138d815..cb4f79e 100755 --- a/lib/uninstall/batch.sh +++ b/lib/uninstall/batch.sh @@ -15,6 +15,10 @@ 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() { @@ -81,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) @@ -156,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 @@ -205,7 +219,12 @@ remove_file_list() { safe_remove_symlink "$file" "$use_sudo" && ((++count)) || true else if [[ "$use_sudo" == "true" ]]; then - safe_sudo_remove "$file" && ((++count)) || true + 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 @@ -321,7 +340,7 @@ batch_uninstall_applications() { local system_size_kb=$(calculate_total_size "$system_files" || echo "0") local diag_system_size_kb=$(calculate_total_size "$diag_system" || echo "0") local total_kb=$((app_size_kb + related_size_kb + system_size_kb + diag_system_size_kb)) - ((total_estimated_size += total_kb)) || true + total_estimated_size=$((total_estimated_size + total_kb)) # shellcheck disable=SC2128 if [[ -n "$system_files" || -n "$diag_system" ]]; then @@ -441,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 "" @@ -469,7 +488,7 @@ batch_uninstall_applications() { local -a success_items=() local current_index=0 for detail in "${app_details[@]}"; do - ((current_index++)) + current_index=$((current_index + 1)) IFS='|' read -r app_name app_path bundle_id total_kb encoded_files encoded_system_files has_sensitive_data needs_sudo is_brew_cask cask_name encoded_diag_system <<< "$detail" local related_files=$(decode_file_list "$encoded_files" "$app_name") local system_files=$(decode_file_list "$encoded_system_files" "$app_name") @@ -551,12 +570,18 @@ batch_uninstall_applications() { fi fi else - local ret=0 - safe_sudo_remove "$app_path" || ret=$? - if [[ $ret -ne 0 ]]; then - local diagnosis - diagnosis=$(diagnose_removal_failure "$ret" "$app_name") - IFS='|' read -r reason suggestion <<< "$diagnosis" + 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=$? + if [[ $ret -ne 0 ]]; then + local diagnosis + diagnosis=$(diagnose_removal_failure "$ret" "$app_name") + IFS='|' read -r reason suggestion <<< "$diagnosis" + fi fi fi else @@ -587,10 +612,14 @@ 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 defaults read "$bundle_id" &> /dev/null; then - defaults delete "$bundle_id" 2> /dev/null || true + 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). @@ -614,11 +643,11 @@ batch_uninstall_applications() { fi fi - ((total_size_freed += total_kb)) - ((success_count++)) - [[ "$used_brew_successfully" == "true" ]] && ((brew_apps_removed++)) - ((files_cleaned++)) - ((total_items++)) + total_size_freed=$((total_size_freed + total_kb)) + success_count=$((success_count + 1)) + [[ "$used_brew_successfully" == "true" ]] && brew_apps_removed=$((brew_apps_removed + 1)) + files_cleaned=$((files_cleaned + 1)) + total_items=$((total_items + 1)) success_items+=("$app_path") else if [[ -t 1 ]]; then @@ -632,7 +661,7 @@ batch_uninstall_applications() { fi fi - ((failed_count++)) + failed_count=$((failed_count + 1)) failed_items+=("$app_name:$reason:${suggestion:-}") fi done @@ -648,8 +677,15 @@ 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 - success_line+=", freed ${GREEN}${freed_display}${NC}" + 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. @@ -676,7 +712,7 @@ batch_uninstall_applications() { else current_line="$current_line, $display_item" fi - ((idx++)) + idx=$((idx + 1)) done if [[ -n "$current_line" ]]; then summary_details+=("$current_line") @@ -734,6 +770,9 @@ 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[@]}" @@ -741,30 +780,38 @@ batch_uninstall_applications() { # 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 + 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 - 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} + 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 [[ -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 "" + 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 - remove_apps_from_dock "${success_items[@]}" 2> /dev/null || true - refresh_launch_services_after_uninstall 2> /dev/null || true + 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 @@ -775,6 +822,6 @@ batch_uninstall_applications() { _restore_uninstall_traps unset -f _restore_uninstall_traps - ((total_size_cleaned += total_size_freed)) + total_size_cleaned=$((total_size_cleaned + total_size_freed)) unset failed_items } diff --git a/lib/uninstall/brew.sh b/lib/uninstall/brew.sh index 0e7ae90..87cc62c 100644 --- a/lib/uninstall/brew.sh +++ b/lib/uninstall/brew.sh @@ -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 diff --git a/mole b/mole index ccdd3e6..7d34c75 100755 --- a/mole +++ b/mole @@ -13,7 +13,7 @@ source "$SCRIPT_DIR/lib/core/commands.sh" trap cleanup_temp_files EXIT INT TERM # Version and update helpers -VERSION="1.27.0" +VERSION="1.28.1" MOLE_TAGLINE="Deep clean and optimize your Mac." is_touchid_configured() { @@ -38,14 +38,26 @@ get_latest_version_from_github() { } # Install detection (Homebrew vs manual). +# Uses variable capture + string matching to avoid SIGPIPE under pipefail. is_homebrew_install() { - local mole_path + local mole_path link_target brew_list="" has_brew=false mole_path=$(command -v mole 2> /dev/null) || return 1 - if [[ -L "$mole_path" ]] && readlink "$mole_path" | grep -q "Cellar/mole"; then - if command -v brew > /dev/null 2>&1; then - brew list --formula 2> /dev/null | grep -q "^mole$" && return 0 - else + # Cache brew list once if brew is available + if command -v brew > /dev/null 2>&1; then + has_brew=true + brew_list=$(brew list --formula 2> /dev/null) || true + fi + + # Helper to check if mole is in brew list + _mole_in_brew_list() { + [[ -n "$brew_list" ]] && [[ $'\n'"$brew_list"$'\n' == *$'\n'"mole"$'\n'* ]] + } + + if [[ -L "$mole_path" ]]; then + link_target=$(readlink "$mole_path" 2> /dev/null) || true + if [[ "$link_target" == *"Cellar/mole"* ]]; then + $has_brew && _mole_in_brew_list && return 0 return 1 fi fi @@ -54,8 +66,8 @@ is_homebrew_install() { case "$mole_path" in /opt/homebrew/bin/mole | /usr/local/bin/mole) if [[ -d /opt/homebrew/Cellar/mole ]] || [[ -d /usr/local/Cellar/mole ]]; then - if command -v brew > /dev/null 2>&1; then - brew list --formula 2> /dev/null | grep -q "^mole$" && return 0 + if $has_brew; then + _mole_in_brew_list && return 0 else return 0 # Cellar exists, probably Homebrew install fi @@ -64,17 +76,29 @@ is_homebrew_install() { esac fi - if command -v brew > /dev/null 2>&1; then + if $has_brew; then local brew_prefix brew_prefix=$(brew --prefix 2> /dev/null) if [[ -n "$brew_prefix" && "$mole_path" == "$brew_prefix/bin/mole" && -d "$brew_prefix/Cellar/mole" ]]; then - brew list --formula 2> /dev/null | grep -q "^mole$" && return 0 + _mole_in_brew_list && return 0 fi fi return 1 } +get_install_channel() { + local channel_file="$SCRIPT_DIR/install_channel" + local channel="stable" + if [[ -f "$channel_file" ]]; then + channel=$(sed -n 's/^CHANNEL=\(.*\)$/\1/p' "$channel_file" | head -1) + fi + case "$channel" in + nightly | dev | stable) printf '%s\n' "$channel" ;; + *) printf 'stable\n' ;; + esac +} + # Background update notice check_for_updates() { local msg_cache="$HOME/.cache/mole/update_message" @@ -193,7 +217,13 @@ show_version() { install_method="Homebrew" fi + local channel + channel=$(get_install_channel) + printf '\nMole version %s\n' "$VERSION" + if [[ "$channel" == "nightly" ]]; then + printf 'Channel: Nightly\n' + fi printf 'macOS: %s\n' "$os_ver" printf 'Architecture: %s\n' "$arch" printf 'Kernel: %s\n' "$kernel" @@ -222,10 +252,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" @@ -237,7 +273,13 @@ update_mole() { local force_update="${1:-false}" local nightly_update="${2:-false}" local update_interrupted=false - trap 'update_interrupted=true; echo ""; exit 130' INT TERM + local sudo_keepalive_pid="" + + # Cleanup function for sudo keepalive + _update_cleanup() { + [[ -n "$sudo_keepalive_pid" ]] && _stop_sudo_keepalive "$sudo_keepalive_pid" || true + } + trap '_update_cleanup; update_interrupted=true; echo ""; exit 130' INT TERM if is_homebrew_install; then if [[ "$nightly_update" == "true" ]]; then @@ -348,6 +390,8 @@ update_mole() { rm -f "$tmp_installer" exit 1 fi + # Start sudo keepalive to prevent cache expiration during install + sudo_keepalive_pid=$(_start_sudo_keepalive) fi if [[ -t 1 ]]; then @@ -399,6 +443,7 @@ update_mole() { else if [[ -t 1 ]]; then stop_inline_spinner; fi rm -f "$tmp_installer" + _update_cleanup log_error "Nightly update failed" echo "$install_output" | tail -10 >&2 # Show last 10 lines of error exit 1 @@ -409,6 +454,7 @@ update_mole() { else if [[ -t 1 ]]; then stop_inline_spinner; fi rm -f "$tmp_installer" + _update_cleanup log_error "Update failed" echo "$install_output" | tail -10 >&2 # Show last 10 lines of error exit 1 @@ -422,6 +468,7 @@ update_mole() { else if [[ -t 1 ]]; then stop_inline_spinner; fi rm -f "$tmp_installer" + _update_cleanup log_error "Update failed" echo "$install_output" | tail -10 >&2 # Show last 10 lines of error exit 1 @@ -431,10 +478,16 @@ update_mole() { rm -f "$tmp_installer" rm -f "$HOME/.cache/mole/update_message" + + # Cleanup and reset trap + _update_cleanup + trap - INT TERM } # 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 @@ -518,6 +571,31 @@ remove_mole() { exit 0 fi + # Dry-run mode: show preview and exit without confirmation + if [[ "$dry_run_mode" == "true" ]]; then + echo -e "${YELLOW}${ICON_DRY_RUN} DRY RUN MODE${NC}, no files will be removed" + echo "" + echo -e "${YELLOW}Remove Mole${NC}, would delete the following:" + 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 + echo -e "${YELLOW}Remove Mole${NC}, will delete the following:" if [[ "$is_homebrew" == "true" ]]; then echo " ${ICON_LIST} Mole via Homebrew" @@ -832,7 +910,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 diff --git a/tests/clean_core.bats b/tests/clean_core.bats index 8da50b2..836c15e 100644 --- a/tests/clean_core.bats +++ b/tests/clean_core.bats @@ -267,6 +267,9 @@ set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/system.sh" +defaults() { echo "1"; } + + clean_time_machine_failed_backups EOF @@ -310,6 +313,9 @@ set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/system.sh" +defaults() { echo "1"; } + + clean_time_machine_failed_backups EOF diff --git a/tests/clean_system_maintenance.bats b/tests/clean_system_maintenance.bats index e0d9438..fb79662 100644 --- a/tests/clean_system_maintenance.bats +++ b/tests/clean_system_maintenance.bats @@ -274,6 +274,9 @@ set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/system.sh" +defaults() { echo "1"; } + + tmutil() { if [[ "$1" == "destinationinfo" ]]; then echo "No destinations configured" @@ -297,6 +300,9 @@ set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/system.sh" +defaults() { echo "1"; } + + run_with_timeout() { printf '%s\n' \ "com.apple.TimeMachine.2023-10-25-120000" \ @@ -321,6 +327,9 @@ set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/system.sh" +defaults() { echo "1"; } + + run_with_timeout() { echo "Snapshots for disk /:"; } start_section_spinner(){ :; } stop_section_spinner(){ :; } diff --git a/tests/clean_user_core.bats b/tests/clean_user_core.bats index 7fae621..02405db 100644 --- a/tests/clean_user_core.bats +++ b/tests/clean_user_core.bats @@ -132,6 +132,38 @@ EOF [[ "$output" != *"App caches"* ]] || [[ "$output" == *"already clean"* ]] } +@test "clean_application_support_logs counts nested directory contents in dry-run size summary" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +safe_remove() { :; } +update_progress_if_needed() { return 1; } +should_protect_data() { return 1; } +is_critical_system_component() { return 1; } +files_cleaned=0 +total_size_cleaned=0 +total_items=0 + +mkdir -p "$HOME/Library/Application Support/TestApp/logs/nested" +dd if=/dev/zero of="$HOME/Library/Application Support/TestApp/logs/nested/data.bin" bs=1024 count=2 2> /dev/null + +clean_application_support_logs +echo "TOTAL_KB=$total_size_cleaned" +rm -rf "$HOME/Library/Application Support" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Application Support logs/caches"* ]] + local total_kb + total_kb=$(printf '%s\n' "$output" | sed -n 's/.*TOTAL_KB=\([0-9][0-9]*\).*/\1/p' | tail -1) + [[ -n "$total_kb" ]] + [[ "$total_kb" -ge 2 ]] +} + @test "clean_group_container_caches keeps protected caches and cleans non-protected caches" { run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc <<'EOF' set -euo pipefail diff --git a/tests/cli.bats b/tests/cli.bats index 5ed897b..83eb922 100644 --- a/tests/cli.bats +++ b/tests/cli.bats @@ -1,39 +1,40 @@ #!/usr/bin/env bats setup_file() { - PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" - export PROJECT_ROOT + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT - ORIGINAL_HOME="${HOME:-}" - export ORIGINAL_HOME + ORIGINAL_HOME="${HOME:-}" + export ORIGINAL_HOME - HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-cli-home.XXXXXX")" - export HOME + HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-cli-home.XXXXXX")" + export HOME - mkdir -p "$HOME" + mkdir -p "$HOME" } teardown_file() { - rm -rf "$HOME" - if [[ -n "${ORIGINAL_HOME:-}" ]]; then - export HOME="$ORIGINAL_HOME" - fi + rm -f "$PROJECT_ROOT/install_channel" + rm -rf "$HOME" + if [[ -n "${ORIGINAL_HOME:-}" ]]; then + export HOME="$ORIGINAL_HOME" + fi } create_fake_utils() { - local dir="$1" - mkdir -p "$dir" + local dir="$1" + mkdir -p "$dir" - cat > "$dir/sudo" <<'SCRIPT' + cat >"$dir/sudo" <<'SCRIPT' #!/usr/bin/env bash if [[ "$1" == "-n" || "$1" == "-v" ]]; then exit 0 fi exec "$@" SCRIPT - chmod +x "$dir/sudo" + chmod +x "$dir/sudo" - cat > "$dir/bioutil" <<'SCRIPT' + cat >"$dir/bioutil" <<'SCRIPT' #!/usr/bin/env bash if [[ "$1" == "-r" ]]; then echo "Touch ID: 1" @@ -41,138 +42,165 @@ if [[ "$1" == "-r" ]]; then fi exit 0 SCRIPT - chmod +x "$dir/bioutil" + chmod +x "$dir/bioutil" } setup() { - rm -rf "$HOME/.config" - mkdir -p "$HOME" + rm -rf "$HOME/.config" + mkdir -p "$HOME" + rm -f "$PROJECT_ROOT/install_channel" } @test "mole --help prints command overview" { - run env HOME="$HOME" "$PROJECT_ROOT/mole" --help - [ "$status" -eq 0 ] - [[ "$output" == *"mo clean"* ]] - [[ "$output" == *"mo analyze"* ]] + run env HOME="$HOME" "$PROJECT_ROOT/mole" --help + [ "$status" -eq 0 ] + [[ "$output" == *"mo clean"* ]] + [[ "$output" == *"mo analyze"* ]] } @test "mole --version reports script version" { - expected_version="$(grep '^VERSION=' "$PROJECT_ROOT/mole" | head -1 | sed 's/VERSION=\"\(.*\)\"/\1/')" - run env HOME="$HOME" "$PROJECT_ROOT/mole" --version - [ "$status" -eq 0 ] - [[ "$output" == *"$expected_version"* ]] + expected_version="$(grep '^VERSION=' "$PROJECT_ROOT/mole" | head -1 | sed 's/VERSION=\"\(.*\)\"/\1/')" + run env HOME="$HOME" "$PROJECT_ROOT/mole" --version + [ "$status" -eq 0 ] + [[ "$output" == *"$expected_version"* ]] +} + +@test "mole --version shows nightly channel metadata" { + expected_version="$(grep '^VERSION=' "$PROJECT_ROOT/mole" | head -1 | sed 's/VERSION=\"\(.*\)\"/\1/')" + cat > "$PROJECT_ROOT/install_channel" <<'EOF' +CHANNEL=nightly +EOF + + run env HOME="$HOME" "$PROJECT_ROOT/mole" --version + [ "$status" -eq 0 ] + [[ "$output" == *"Mole version $expected_version"* ]] + [[ "$output" == *"Channel: Nightly"* ]] } @test "mole unknown command returns error" { - run env HOME="$HOME" "$PROJECT_ROOT/mole" unknown-command - [ "$status" -ne 0 ] - [[ "$output" == *"Unknown command: unknown-command"* ]] + run env HOME="$HOME" "$PROJECT_ROOT/mole" unknown-command + [ "$status" -ne 0 ] + [[ "$output" == *"Unknown command: unknown-command"* ]] } @test "touchid status reports current configuration" { - run env HOME="$HOME" "$PROJECT_ROOT/mole" touchid status - [ "$status" -eq 0 ] - [[ "$output" == *"Touch ID"* ]] + run env HOME="$HOME" "$PROJECT_ROOT/mole" touchid status + [ "$status" -eq 0 ] + [[ "$output" == *"Touch ID"* ]] } @test "mo optimize command is recognized" { - run bash -c "grep -q '\"optimize\")' '$PROJECT_ROOT/mole'" - [ "$status" -eq 0 ] + run bash -c "grep -q '\"optimize\")' '$PROJECT_ROOT/mole'" + [ "$status" -eq 0 ] } @test "mo analyze binary is valid" { - if [[ -f "$PROJECT_ROOT/bin/analyze-go" ]]; then - [ -x "$PROJECT_ROOT/bin/analyze-go" ] - run file "$PROJECT_ROOT/bin/analyze-go" - [[ "$output" == *"Mach-O"* ]] || [[ "$output" == *"executable"* ]] - else - skip "analyze-go binary not built" - fi + if [[ -f "$PROJECT_ROOT/bin/analyze-go" ]]; then + [ -x "$PROJECT_ROOT/bin/analyze-go" ] + run file "$PROJECT_ROOT/bin/analyze-go" + [[ "$output" == *"Mach-O"* ]] || [[ "$output" == *"executable"* ]] + else + skip "analyze-go binary not built" + fi } @test "mo clean --debug creates debug log file" { - mkdir -p "$HOME/.config/mole" - run env HOME="$HOME" TERM="xterm-256color" MOLE_TEST_MODE=1 MO_DEBUG=1 "$PROJECT_ROOT/mole" clean --dry-run - [ "$status" -eq 0 ] - MOLE_OUTPUT="$output" + mkdir -p "$HOME/.config/mole" + run env HOME="$HOME" TERM="xterm-256color" MOLE_TEST_MODE=1 MO_DEBUG=1 "$PROJECT_ROOT/mole" clean --dry-run + [ "$status" -eq 0 ] + MOLE_OUTPUT="$output" - DEBUG_LOG="$HOME/.config/mole/mole_debug_session.log" - [ -f "$DEBUG_LOG" ] + DEBUG_LOG="$HOME/.config/mole/mole_debug_session.log" + [ -f "$DEBUG_LOG" ] - run grep "Mole Debug Session" "$DEBUG_LOG" - [ "$status" -eq 0 ] + run grep "Mole Debug Session" "$DEBUG_LOG" + [ "$status" -eq 0 ] - [[ "$MOLE_OUTPUT" =~ "Debug session log saved to" ]] + [[ "$MOLE_OUTPUT" =~ "Debug session log saved to" ]] } @test "mo clean without debug does not show debug log path" { - mkdir -p "$HOME/.config/mole" - run env HOME="$HOME" TERM="xterm-256color" MOLE_TEST_MODE=1 MO_DEBUG=0 "$PROJECT_ROOT/mole" clean --dry-run - [ "$status" -eq 0 ] + mkdir -p "$HOME/.config/mole" + run env HOME="$HOME" TERM="xterm-256color" MOLE_TEST_MODE=1 MO_DEBUG=0 "$PROJECT_ROOT/mole" clean --dry-run + [ "$status" -eq 0 ] - [[ "$output" != *"Debug session log saved to"* ]] + [[ "$output" != *"Debug session log saved to"* ]] } @test "mo clean --debug logs system info" { - mkdir -p "$HOME/.config/mole" - run env HOME="$HOME" TERM="xterm-256color" MOLE_TEST_MODE=1 MO_DEBUG=1 "$PROJECT_ROOT/mole" clean --dry-run - [ "$status" -eq 0 ] + mkdir -p "$HOME/.config/mole" + run env HOME="$HOME" TERM="xterm-256color" MOLE_TEST_MODE=1 MO_DEBUG=1 "$PROJECT_ROOT/mole" clean --dry-run + [ "$status" -eq 0 ] - DEBUG_LOG="$HOME/.config/mole/mole_debug_session.log" + DEBUG_LOG="$HOME/.config/mole/mole_debug_session.log" - run grep "User:" "$DEBUG_LOG" - [ "$status" -eq 0 ] + run grep "User:" "$DEBUG_LOG" + [ "$status" -eq 0 ] - run grep "Architecture:" "$DEBUG_LOG" - [ "$status" -eq 0 ] + run grep "Architecture:" "$DEBUG_LOG" + [ "$status" -eq 0 ] } @test "touchid status reflects pam file contents" { - pam_file="$HOME/pam_test" - cat > "$pam_file" <<'EOF' + pam_file="$HOME/pam_test" + cat >"$pam_file" <<'EOF' auth sufficient pam_opendirectory.so EOF - run env MOLE_PAM_SUDO_FILE="$pam_file" "$PROJECT_ROOT/bin/touchid.sh" status - [ "$status" -eq 0 ] - [[ "$output" == *"not configured"* ]] + run env MOLE_PAM_SUDO_FILE="$pam_file" "$PROJECT_ROOT/bin/touchid.sh" status + [ "$status" -eq 0 ] + [[ "$output" == *"not configured"* ]] - cat > "$pam_file" <<'EOF' + cat >"$pam_file" <<'EOF' auth sufficient pam_tid.so EOF - run env MOLE_PAM_SUDO_FILE="$pam_file" "$PROJECT_ROOT/bin/touchid.sh" status - [ "$status" -eq 0 ] - [[ "$output" == *"enabled"* ]] + run env MOLE_PAM_SUDO_FILE="$pam_file" "$PROJECT_ROOT/bin/touchid.sh" status + [ "$status" -eq 0 ] + [[ "$output" == *"enabled"* ]] } @test "enable_touchid inserts pam_tid line in pam file" { - pam_file="$HOME/pam_enable" - cat > "$pam_file" <<'EOF' + pam_file="$HOME/pam_enable" + cat >"$pam_file" <<'EOF' auth sufficient pam_opendirectory.so EOF - fake_bin="$HOME/fake-bin" - create_fake_utils "$fake_bin" + fake_bin="$HOME/fake-bin" + create_fake_utils "$fake_bin" - run env PATH="$fake_bin:$PATH" MOLE_PAM_SUDO_FILE="$pam_file" "$PROJECT_ROOT/bin/touchid.sh" enable - [ "$status" -eq 0 ] - grep -q "pam_tid.so" "$pam_file" - [[ -f "${pam_file}.mole-backup" ]] + run env PATH="$fake_bin:$PATH" MOLE_PAM_SUDO_FILE="$pam_file" "$PROJECT_ROOT/bin/touchid.sh" enable + [ "$status" -eq 0 ] + grep -q "pam_tid.so" "$pam_file" + [[ -f "${pam_file}.mole-backup" ]] } @test "disable_touchid removes pam_tid line" { - pam_file="$HOME/pam_disable" - cat > "$pam_file" <<'EOF' + pam_file="$HOME/pam_disable" + cat >"$pam_file" <<'EOF' auth sufficient pam_tid.so auth sufficient pam_opendirectory.so EOF - fake_bin="$HOME/fake-bin-disable" - create_fake_utils "$fake_bin" + fake_bin="$HOME/fake-bin-disable" + create_fake_utils "$fake_bin" - run env PATH="$fake_bin:$PATH" MOLE_PAM_SUDO_FILE="$pam_file" "$PROJECT_ROOT/bin/touchid.sh" disable - [ "$status" -eq 0 ] - run grep "pam_tid.so" "$pam_file" - [ "$status" -ne 0 ] + run env PATH="$fake_bin:$PATH" MOLE_PAM_SUDO_FILE="$pam_file" "$PROJECT_ROOT/bin/touchid.sh" disable + [ "$status" -eq 0 ] + 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 ] } diff --git a/tests/completion.bats b/tests/completion.bats index d586bcd..562a731 100755 --- a/tests/completion.bats +++ b/tests/completion.bats @@ -1,160 +1,165 @@ #!/usr/bin/env bats setup_file() { - PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" - export PROJECT_ROOT + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT - ORIGINAL_HOME="${HOME:-}" - export ORIGINAL_HOME + ORIGINAL_HOME="${HOME:-}" + export ORIGINAL_HOME - ORIGINAL_PATH="${PATH:-}" - export ORIGINAL_PATH + ORIGINAL_PATH="${PATH:-}" + export ORIGINAL_PATH - HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-completion-home.XXXXXX")" - export HOME + HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-completion-home.XXXXXX")" + export HOME - mkdir -p "$HOME" + mkdir -p "$HOME" - PATH="$PROJECT_ROOT:$PATH" - export PATH + PATH="$PROJECT_ROOT:$PATH" + export PATH } teardown_file() { - rm -rf "$HOME" - if [[ -n "${ORIGINAL_HOME:-}" ]]; then - export HOME="$ORIGINAL_HOME" - fi - if [[ -n "${ORIGINAL_PATH:-}" ]]; then - export PATH="$ORIGINAL_PATH" - fi + rm -rf "$HOME" + if [[ -n "${ORIGINAL_HOME:-}" ]]; then + export HOME="$ORIGINAL_HOME" + fi + if [[ -n "${ORIGINAL_PATH:-}" ]]; then + export PATH="$ORIGINAL_PATH" + fi } setup() { - rm -rf "$HOME/.config" - rm -rf "$HOME/.zshrc" "$HOME/.bashrc" "$HOME/.bash_profile" - mkdir -p "$HOME" + rm -rf "$HOME/.config" + rm -rf "$HOME/.zshrc" "$HOME/.bashrc" "$HOME/.bash_profile" + mkdir -p "$HOME" } @test "completion script exists and is executable" { - [ -f "$PROJECT_ROOT/bin/completion.sh" ] - [ -x "$PROJECT_ROOT/bin/completion.sh" ] + [ -f "$PROJECT_ROOT/bin/completion.sh" ] + [ -x "$PROJECT_ROOT/bin/completion.sh" ] } @test "completion script has valid bash syntax" { - run bash -n "$PROJECT_ROOT/bin/completion.sh" - [ "$status" -eq 0 ] + run bash -n "$PROJECT_ROOT/bin/completion.sh" + [ "$status" -eq 0 ] } @test "completion --help shows usage" { - run "$PROJECT_ROOT/bin/completion.sh" --help - [ "$status" -ne 0 ] - [[ "$output" == *"Usage: mole completion"* ]] - [[ "$output" == *"Auto-install"* ]] + run "$PROJECT_ROOT/bin/completion.sh" --help + [ "$status" -ne 0 ] + [[ "$output" == *"Usage: mole completion"* ]] + [[ "$output" == *"Auto-install"* ]] } @test "completion bash generates valid bash script" { - run "$PROJECT_ROOT/bin/completion.sh" bash - [ "$status" -eq 0 ] - [[ "$output" == *"_mole_completions"* ]] - [[ "$output" == *"complete -F _mole_completions mole mo"* ]] + run "$PROJECT_ROOT/bin/completion.sh" bash + [ "$status" -eq 0 ] + [[ "$output" == *"_mole_completions"* ]] + [[ "$output" == *"complete -F _mole_completions mole mo"* ]] } @test "completion bash script includes all commands" { - run "$PROJECT_ROOT/bin/completion.sh" bash - [ "$status" -eq 0 ] - [[ "$output" == *"optimize"* ]] - [[ "$output" == *"clean"* ]] - [[ "$output" == *"uninstall"* ]] - [[ "$output" == *"analyze"* ]] - [[ "$output" == *"status"* ]] - [[ "$output" == *"purge"* ]] - [[ "$output" == *"touchid"* ]] - [[ "$output" == *"completion"* ]] + run "$PROJECT_ROOT/bin/completion.sh" bash + [ "$status" -eq 0 ] + [[ "$output" == *"optimize"* ]] + [[ "$output" == *"clean"* ]] + [[ "$output" == *"uninstall"* ]] + [[ "$output" == *"analyze"* ]] + [[ "$output" == *"status"* ]] + [[ "$output" == *"purge"* ]] + [[ "$output" == *"touchid"* ]] + [[ "$output" == *"completion"* ]] } @test "completion bash script supports mo command" { - run "$PROJECT_ROOT/bin/completion.sh" bash - [ "$status" -eq 0 ] - [[ "$output" == *"complete -F _mole_completions mole mo"* ]] + run "$PROJECT_ROOT/bin/completion.sh" bash + [ "$status" -eq 0 ] + [[ "$output" == *"complete -F _mole_completions mole mo"* ]] } @test "completion bash can be loaded in bash" { - run bash -c "eval \"\$(\"$PROJECT_ROOT/bin/completion.sh\" bash)\" && complete -p mole" - [ "$status" -eq 0 ] - [[ "$output" == *"_mole_completions"* ]] + run bash -c "eval \"\$(\"$PROJECT_ROOT/bin/completion.sh\" bash)\" && complete -p mole" + [ "$status" -eq 0 ] + [[ "$output" == *"_mole_completions"* ]] } @test "completion zsh generates valid zsh script" { - run "$PROJECT_ROOT/bin/completion.sh" zsh - [ "$status" -eq 0 ] - [[ "$output" == *"#compdef mole mo"* ]] - [[ "$output" == *"_mole()"* ]] + run "$PROJECT_ROOT/bin/completion.sh" zsh + [ "$status" -eq 0 ] + [[ "$output" == *"#compdef mole mo"* ]] + [[ "$output" == *"_mole()"* ]] } @test "completion zsh includes command descriptions" { - run "$PROJECT_ROOT/bin/completion.sh" zsh - [ "$status" -eq 0 ] - [[ "$output" == *"optimize:Check and maintain system"* ]] - [[ "$output" == *"clean:Free up disk space"* ]] + run "$PROJECT_ROOT/bin/completion.sh" zsh + [ "$status" -eq 0 ] + [[ "$output" == *"optimize:Check and maintain system"* ]] + [[ "$output" == *"clean:Free up disk space"* ]] } @test "completion fish generates valid fish script" { - run "$PROJECT_ROOT/bin/completion.sh" fish - [ "$status" -eq 0 ] - [[ "$output" == *"complete -c mole"* ]] - [[ "$output" == *"complete -c mo"* ]] + run "$PROJECT_ROOT/bin/completion.sh" fish + [ "$status" -eq 0 ] + [[ "$output" == *"complete -c mole"* ]] + [[ "$output" == *"complete -c mo"* ]] } @test "completion fish includes both mole and mo commands" { - output="$("$PROJECT_ROOT/bin/completion.sh" fish)" - mole_count=$(echo "$output" | grep -c "complete -c mole") - mo_count=$(echo "$output" | grep -c "complete -c mo") + output="$("$PROJECT_ROOT/bin/completion.sh" fish)" + mole_count=$(echo "$output" | grep -c "complete -c mole") + mo_count=$(echo "$output" | grep -c "complete -c mo") - [ "$mole_count" -gt 0 ] - [ "$mo_count" -gt 0 ] + [ "$mole_count" -gt 0 ] + [ "$mo_count" -gt 0 ] } @test "completion auto-install detects zsh" { - # shellcheck disable=SC2030,SC2031 - export SHELL=/bin/zsh + # shellcheck disable=SC2030,SC2031 + export SHELL=/bin/zsh - # Simulate auto-install (no interaction) - run bash -c "echo 'y' | \"$PROJECT_ROOT/bin/completion.sh\"" + # Simulate auto-install (no interaction) + run bash -c "echo 'y' | \"$PROJECT_ROOT/bin/completion.sh\"" - if [[ "$output" == *"Already configured"* ]]; then - skip "Already configured from previous test" - fi + if [[ "$output" == *"Already configured"* ]]; then + skip "Already configured from previous test" + fi - [ -f "$HOME/.zshrc" ] || skip "Auto-install didn't create .zshrc" + [ -f "$HOME/.zshrc" ] || skip "Auto-install didn't create .zshrc" - run grep -E "mole[[:space:]]+completion" "$HOME/.zshrc" - [ "$status" -eq 0 ] + run grep -E "mole[[:space:]]+completion" "$HOME/.zshrc" + [ "$status" -eq 0 ] } @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" + mkdir -p "$HOME" + # shellcheck disable=SC2016 + echo 'eval "$(mole completion zsh)"' >"$HOME/.zshrc" - run "$PROJECT_ROOT/bin/completion.sh" - [ "$status" -eq 0 ] - [[ "$output" == *"updated"* ]] + 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 ] + run "$PROJECT_ROOT/bin/completion.sh" invalid-shell + [ "$status" -ne 0 ] } @test "completion subcommand supports bash/zsh/fish" { - run "$PROJECT_ROOT/bin/completion.sh" bash - [ "$status" -eq 0 ] + run "$PROJECT_ROOT/bin/completion.sh" bash + [ "$status" -eq 0 ] - run "$PROJECT_ROOT/bin/completion.sh" zsh - [ "$status" -eq 0 ] + run "$PROJECT_ROOT/bin/completion.sh" zsh + [ "$status" -eq 0 ] - run "$PROJECT_ROOT/bin/completion.sh" fish - [ "$status" -eq 0 ] + run "$PROJECT_ROOT/bin/completion.sh" fish + [ "$status" -eq 0 ] } diff --git a/tests/core_common.bats b/tests/core_common.bats index 10eee55..69d0a6f 100644 --- a/tests/core_common.bats +++ b/tests/core_common.bats @@ -102,9 +102,9 @@ setup() { HOME="$HOME" bash --noprofile --norc << 'EOF' source "$PROJECT_ROOT/lib/core/common.sh" bytes_to_human 512 -bytes_to_human 2048 -bytes_to_human $((5 * 1024 * 1024)) -bytes_to_human $((3 * 1024 * 1024 * 1024)) +bytes_to_human 2000 +bytes_to_human 5000000 +bytes_to_human 3000000000 EOF )" diff --git a/tests/core_performance.bats b/tests/core_performance.bats index 0582738..4bf4034 100644 --- a/tests/core_performance.bats +++ b/tests/core_performance.bats @@ -34,26 +34,26 @@ setup() { } @test "bytes_to_human produces correct output for GB range" { - result=$(bytes_to_human 1073741824) + result=$(bytes_to_human 1000000000) [ "$result" = "1.00GB" ] - result=$(bytes_to_human 5368709120) + result=$(bytes_to_human 5000000000) [ "$result" = "5.00GB" ] } @test "bytes_to_human produces correct output for MB range" { - result=$(bytes_to_human 1048576) + result=$(bytes_to_human 1000000) [ "$result" = "1.0MB" ] - result=$(bytes_to_human 104857600) + result=$(bytes_to_human 100000000) [ "$result" = "100.0MB" ] } @test "bytes_to_human produces correct output for KB range" { - result=$(bytes_to_human 1024) + result=$(bytes_to_human 1000) [ "$result" = "1KB" ] - result=$(bytes_to_human 10240) + result=$(bytes_to_human 10000) [ "$result" = "10KB" ] } diff --git a/tests/core_safe_functions.bats b/tests/core_safe_functions.bats index a720787..5805f04 100644 --- a/tests/core_safe_functions.bats +++ b/tests/core_safe_functions.bats @@ -110,6 +110,19 @@ teardown() { [ "$status" -eq 0 ] } +@test "safe_remove preserves interrupt exit codes" { + local test_file="$TEST_DIR/interrupt_file" + echo "test" > "$test_file" + + run bash -c " + source '$PROJECT_ROOT/lib/core/common.sh' + rm() { return 130; } + safe_remove '$test_file' true + " + [ "$status" -eq 130 ] + [ -f "$test_file" ] +} + @test "safe_remove in silent mode suppresses error output" { run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; safe_remove '/System/test' true 2>&1" [ "$status" -eq 1 ] diff --git a/tests/installer.bats b/tests/installer.bats index e26b876..1e26595 100644 --- a/tests/installer.bats +++ b/tests/installer.bats @@ -1,49 +1,56 @@ #!/usr/bin/env bats setup_file() { - PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" - export PROJECT_ROOT + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT - ORIGINAL_HOME="${HOME:-}" - export ORIGINAL_HOME + ORIGINAL_HOME="${HOME:-}" + export ORIGINAL_HOME - HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-installers-home.XXXXXX")" - export HOME + HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-installers-home.XXXXXX")" + export HOME - mkdir -p "$HOME" + mkdir -p "$HOME" } teardown_file() { - rm -rf "$HOME" - if [[ -n "${ORIGINAL_HOME:-}" ]]; then - export HOME="$ORIGINAL_HOME" - fi + rm -rf "$HOME" + if [[ -n "${ORIGINAL_HOME:-}" ]]; then + export HOME="$ORIGINAL_HOME" + fi } setup() { - export TERM="xterm-256color" - export MO_DEBUG=0 + export TERM="xterm-256color" + export MO_DEBUG=0 - # Create standard scan directories - mkdir -p "$HOME/Downloads" - mkdir -p "$HOME/Desktop" - mkdir -p "$HOME/Documents" - mkdir -p "$HOME/Public" - mkdir -p "$HOME/Library/Downloads" + # Create standard scan directories + mkdir -p "$HOME/Downloads" + mkdir -p "$HOME/Desktop" + mkdir -p "$HOME/Documents" + mkdir -p "$HOME/Public" + mkdir -p "$HOME/Library/Downloads" - # Clear previous test files - rm -rf "${HOME:?}/Downloads"/* - rm -rf "${HOME:?}/Desktop"/* - rm -rf "${HOME:?}/Documents"/* + # Clear previous test files + rm -rf "${HOME:?}/Downloads"/* + rm -rf "${HOME:?}/Desktop"/* + rm -rf "${HOME:?}/Documents"/* } # Test arguments @test "installer.sh rejects unknown options" { - run "$PROJECT_ROOT/bin/installer.sh" --unknown-option + run "$PROJECT_ROOT/bin/installer.sh" --unknown-option - [ "$status" -eq 1 ] - [[ "$output" == *"Unknown option"* ]] + [ "$status" -eq 1 ] + [[ "$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 @@ -53,187 +60,187 @@ setup() { # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @test "scan_installers_in_path (fallback find): finds .dmg files" { - touch "$HOME/Downloads/Chrome.dmg" + touch "$HOME/Downloads/Chrome.dmg" - run env PATH="/usr/bin:/bin" bash -euo pipefail -c " + run env PATH="/usr/bin:/bin" bash -euo pipefail -c " export MOLE_TEST_MODE=1 source \"\$1\" scan_installers_in_path \"\$2\" " bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" - [ "$status" -eq 0 ] - [[ "$output" == *"Chrome.dmg"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"Chrome.dmg"* ]] } @test "scan_installers_in_path (fallback find): finds multiple installer types" { - touch "$HOME/Downloads/App1.dmg" - touch "$HOME/Downloads/App2.pkg" - touch "$HOME/Downloads/App3.iso" - touch "$HOME/Downloads/App.mpkg" + touch "$HOME/Downloads/App1.dmg" + touch "$HOME/Downloads/App2.pkg" + touch "$HOME/Downloads/App3.iso" + touch "$HOME/Downloads/App.mpkg" - run env PATH="/usr/bin:/bin" bash -euo pipefail -c " + run env PATH="/usr/bin:/bin" bash -euo pipefail -c " export MOLE_TEST_MODE=1 source \"\$1\" scan_installers_in_path \"\$2\" " bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" - [ "$status" -eq 0 ] - [[ "$output" == *"App1.dmg"* ]] - [[ "$output" == *"App2.pkg"* ]] - [[ "$output" == *"App3.iso"* ]] - [[ "$output" == *"App.mpkg"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"App1.dmg"* ]] + [[ "$output" == *"App2.pkg"* ]] + [[ "$output" == *"App3.iso"* ]] + [[ "$output" == *"App.mpkg"* ]] } @test "scan_installers_in_path (fallback find): respects max depth" { - mkdir -p "$HOME/Downloads/level1/level2/level3" - touch "$HOME/Downloads/shallow.dmg" - touch "$HOME/Downloads/level1/mid.dmg" - touch "$HOME/Downloads/level1/level2/deep.dmg" - touch "$HOME/Downloads/level1/level2/level3/too-deep.dmg" + mkdir -p "$HOME/Downloads/level1/level2/level3" + touch "$HOME/Downloads/shallow.dmg" + touch "$HOME/Downloads/level1/mid.dmg" + touch "$HOME/Downloads/level1/level2/deep.dmg" + touch "$HOME/Downloads/level1/level2/level3/too-deep.dmg" - run env PATH="/usr/bin:/bin" bash -euo pipefail -c " + run env PATH="/usr/bin:/bin" bash -euo pipefail -c " export MOLE_TEST_MODE=1 source \"\$1\" scan_installers_in_path \"\$2\" " bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" - [ "$status" -eq 0 ] - # Default max depth is 2 - [[ "$output" == *"shallow.dmg"* ]] - [[ "$output" == *"mid.dmg"* ]] - [[ "$output" == *"deep.dmg"* ]] - [[ "$output" != *"too-deep.dmg"* ]] + [ "$status" -eq 0 ] + # Default max depth is 2 + [[ "$output" == *"shallow.dmg"* ]] + [[ "$output" == *"mid.dmg"* ]] + [[ "$output" == *"deep.dmg"* ]] + [[ "$output" != *"too-deep.dmg"* ]] } @test "scan_installers_in_path (fallback find): honors MOLE_INSTALLER_SCAN_MAX_DEPTH" { - mkdir -p "$HOME/Downloads/level1" - touch "$HOME/Downloads/top.dmg" - touch "$HOME/Downloads/level1/nested.dmg" + mkdir -p "$HOME/Downloads/level1" + touch "$HOME/Downloads/top.dmg" + touch "$HOME/Downloads/level1/nested.dmg" - run env PATH="/usr/bin:/bin" MOLE_INSTALLER_SCAN_MAX_DEPTH=1 bash -euo pipefail -c " + run env PATH="/usr/bin:/bin" MOLE_INSTALLER_SCAN_MAX_DEPTH=1 bash -euo pipefail -c " export MOLE_TEST_MODE=1 source \"\$1\" scan_installers_in_path \"\$2\" " bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" - [ "$status" -eq 0 ] - [[ "$output" == *"top.dmg"* ]] - [[ "$output" != *"nested.dmg"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"top.dmg"* ]] + [[ "$output" != *"nested.dmg"* ]] } @test "scan_installers_in_path (fallback find): handles non-existent directory" { - run env PATH="/usr/bin:/bin" bash -euo pipefail -c " + run env PATH="/usr/bin:/bin" bash -euo pipefail -c " export MOLE_TEST_MODE=1 source \"\$1\" scan_installers_in_path \"\$2\" " bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/NonExistent" - [ "$status" -eq 0 ] - [[ -z "$output" ]] + [ "$status" -eq 0 ] + [[ -z "$output" ]] } @test "scan_installers_in_path (fallback find): ignores non-installer files" { - touch "$HOME/Downloads/document.pdf" - touch "$HOME/Downloads/image.jpg" - touch "$HOME/Downloads/archive.tar.gz" - touch "$HOME/Downloads/Installer.dmg" + touch "$HOME/Downloads/document.pdf" + touch "$HOME/Downloads/image.jpg" + touch "$HOME/Downloads/archive.tar.gz" + touch "$HOME/Downloads/Installer.dmg" - run env PATH="/usr/bin:/bin" bash -euo pipefail -c " + run env PATH="/usr/bin:/bin" bash -euo pipefail -c " export MOLE_TEST_MODE=1 source \"\$1\" scan_installers_in_path \"\$2\" " bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" - [ "$status" -eq 0 ] - [[ "$output" != *"document.pdf"* ]] - [[ "$output" != *"image.jpg"* ]] - [[ "$output" != *"archive.tar.gz"* ]] - [[ "$output" == *"Installer.dmg"* ]] + [ "$status" -eq 0 ] + [[ "$output" != *"document.pdf"* ]] + [[ "$output" != *"image.jpg"* ]] + [[ "$output" != *"archive.tar.gz"* ]] + [[ "$output" == *"Installer.dmg"* ]] } @test "scan_all_installers: handles missing paths gracefully" { - # Don't create all scan directories, some may not exist - # Only create Downloads, delete others if they exist - rm -rf "$HOME/Desktop" - rm -rf "$HOME/Documents" - rm -rf "$HOME/Public" - rm -rf "$HOME/Public/Downloads" - rm -rf "$HOME/Library/Downloads" - mkdir -p "$HOME/Downloads" + # Don't create all scan directories, some may not exist + # Only create Downloads, delete others if they exist + rm -rf "$HOME/Desktop" + rm -rf "$HOME/Documents" + rm -rf "$HOME/Public" + rm -rf "$HOME/Public/Downloads" + rm -rf "$HOME/Library/Downloads" + mkdir -p "$HOME/Downloads" - # Add an installer to the one directory that exists - touch "$HOME/Downloads/test.dmg" + # Add an installer to the one directory that exists + touch "$HOME/Downloads/test.dmg" - run bash -euo pipefail -c ' + run bash -euo pipefail -c ' export MOLE_TEST_MODE=1 source "$1" scan_all_installers ' bash "$PROJECT_ROOT/bin/installer.sh" - # Should succeed even with missing paths - [ "$status" -eq 0 ] - # Should still find the installer in the existing directory - [[ "$output" == *"test.dmg"* ]] + # Should succeed even with missing paths + [ "$status" -eq 0 ] + # Should still find the installer in the existing directory + [[ "$output" == *"test.dmg"* ]] } # Test edge cases @test "scan_installers_in_path (fallback find): handles filenames with spaces" { - touch "$HOME/Downloads/My App Installer.dmg" + touch "$HOME/Downloads/My App Installer.dmg" - run env PATH="/usr/bin:/bin" bash -euo pipefail -c " + run env PATH="/usr/bin:/bin" bash -euo pipefail -c " export MOLE_TEST_MODE=1 source \"\$1\" scan_installers_in_path \"\$2\" " bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" - [ "$status" -eq 0 ] - [[ "$output" == *"My App Installer.dmg"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"My App Installer.dmg"* ]] } @test "scan_installers_in_path (fallback find): handles filenames with special characters" { - touch "$HOME/Downloads/App-v1.2.3_beta.pkg" + touch "$HOME/Downloads/App-v1.2.3_beta.pkg" - run env PATH="/usr/bin:/bin" bash -euo pipefail -c " + run env PATH="/usr/bin:/bin" bash -euo pipefail -c " export MOLE_TEST_MODE=1 source \"\$1\" scan_installers_in_path \"\$2\" " bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" - [ "$status" -eq 0 ] - [[ "$output" == *"App-v1.2.3_beta.pkg"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"App-v1.2.3_beta.pkg"* ]] } @test "scan_installers_in_path (fallback find): returns empty for directory with no installers" { - # Create some non-installer files - touch "$HOME/Downloads/document.pdf" - touch "$HOME/Downloads/image.png" + # Create some non-installer files + touch "$HOME/Downloads/document.pdf" + touch "$HOME/Downloads/image.png" - run env PATH="/usr/bin:/bin" bash -euo pipefail -c " + run env PATH="/usr/bin:/bin" bash -euo pipefail -c " export MOLE_TEST_MODE=1 source \"\$1\" scan_installers_in_path \"\$2\" " bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" - [ "$status" -eq 0 ] - [[ -z "$output" ]] + [ "$status" -eq 0 ] + [[ -z "$output" ]] } # Symlink handling tests @test "scan_installers_in_path (fallback find): skips symlinks to regular files" { - touch "$HOME/Downloads/real.dmg" - ln -s "$HOME/Downloads/real.dmg" "$HOME/Downloads/symlink.dmg" - ln -s /nonexistent "$HOME/Downloads/dangling.lnk" + touch "$HOME/Downloads/real.dmg" + ln -s "$HOME/Downloads/real.dmg" "$HOME/Downloads/symlink.dmg" + ln -s /nonexistent "$HOME/Downloads/dangling.lnk" - run env PATH="/usr/bin:/bin" bash -euo pipefail -c " + run env PATH="/usr/bin:/bin" bash -euo pipefail -c " export MOLE_TEST_MODE=1 source \"\$1\" scan_installers_in_path \"\$2\" " bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" - [ "$status" -eq 0 ] - [[ "$output" == *"real.dmg"* ]] - [[ "$output" != *"symlink.dmg"* ]] - [[ "$output" != *"dangling.lnk"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"real.dmg"* ]] + [[ "$output" != *"symlink.dmg"* ]] + [[ "$output" != *"dangling.lnk"* ]] } diff --git a/tests/purge.bats b/tests/purge.bats index c16e662..0137f7a 100644 --- a/tests/purge.bats +++ b/tests/purge.bats @@ -1,35 +1,35 @@ #!/usr/bin/env bats setup_file() { - PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" - export PROJECT_ROOT + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT - ORIGINAL_HOME="${HOME:-}" - export ORIGINAL_HOME + ORIGINAL_HOME="${HOME:-}" + export ORIGINAL_HOME - HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-purge-home.XXXXXX")" - export HOME + HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-purge-home.XXXXXX")" + export HOME - mkdir -p "$HOME" + mkdir -p "$HOME" } teardown_file() { - rm -rf "$HOME" - if [[ -n "${ORIGINAL_HOME:-}" ]]; then - export HOME="$ORIGINAL_HOME" - fi + rm -rf "$HOME" + if [[ -n "${ORIGINAL_HOME:-}" ]]; then + export HOME="$ORIGINAL_HOME" + fi } setup() { - mkdir -p "$HOME/www" - mkdir -p "$HOME/dev" - mkdir -p "$HOME/.cache/mole" + mkdir -p "$HOME/www" + mkdir -p "$HOME/dev" + mkdir -p "$HOME/.cache/mole" - rm -rf "${HOME:?}/www"/* "${HOME:?}/dev"/* + rm -rf "${HOME:?}/www"/* "${HOME:?}/dev"/* } @test "is_safe_project_artifact: rejects shallow paths (protection against accidents)" { - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_safe_project_artifact '$HOME/www/node_modules' '$HOME/www'; then echo 'UNSAFE' @@ -37,11 +37,11 @@ setup() { echo 'SAFE' fi ") - [[ "$result" == "SAFE" ]] + [[ "$result" == "SAFE" ]] } @test "is_safe_project_artifact: allows proper project artifacts" { - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_safe_project_artifact '$HOME/www/myproject/node_modules' '$HOME/www'; then echo 'ALLOWED' @@ -49,11 +49,11 @@ setup() { echo 'BLOCKED' fi ") - [[ "$result" == "ALLOWED" ]] + [[ "$result" == "ALLOWED" ]] } @test "is_safe_project_artifact: rejects non-absolute paths" { - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_safe_project_artifact 'relative/path/node_modules' '$HOME/www'; then echo 'UNSAFE' @@ -61,11 +61,11 @@ setup() { echo 'SAFE' fi ") - [[ "$result" == "SAFE" ]] + [[ "$result" == "SAFE" ]] } @test "is_safe_project_artifact: validates depth calculation" { - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_safe_project_artifact '$HOME/www/project/subdir/node_modules' '$HOME/www'; then echo 'ALLOWED' @@ -73,14 +73,14 @@ setup() { echo 'BLOCKED' fi ") - [[ "$result" == "ALLOWED" ]] + [[ "$result" == "ALLOWED" ]] } @test "is_safe_project_artifact: allows direct child when search path is project root" { - mkdir -p "$HOME/single-project/node_modules" - touch "$HOME/single-project/package.json" + mkdir -p "$HOME/single-project/node_modules" + touch "$HOME/single-project/package.json" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_safe_project_artifact '$HOME/single-project/node_modules' '$HOME/single-project'; then echo 'ALLOWED' @@ -89,15 +89,15 @@ setup() { fi ") - [[ "$result" == "ALLOWED" ]] + [[ "$result" == "ALLOWED" ]] } @test "is_safe_project_artifact: accepts physical path under symlinked search root" { - mkdir -p "$HOME/www/real/proj/node_modules" - touch "$HOME/www/real/proj/package.json" - ln -s "$HOME/www/real" "$HOME/www/link" + mkdir -p "$HOME/www/real/proj/node_modules" + touch "$HOME/www/real/proj/package.json" + ln -s "$HOME/www/real" "$HOME/www/link" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_safe_project_artifact '$HOME/www/real/proj/node_modules' '$HOME/www/link/proj'; then echo 'ALLOWED' @@ -106,43 +106,43 @@ setup() { fi ") - [[ "$result" == "ALLOWED" ]] + [[ "$result" == "ALLOWED" ]] } @test "filter_nested_artifacts: removes nested node_modules" { - mkdir -p "$HOME/www/project/node_modules/package/node_modules" + mkdir -p "$HOME/www/project/node_modules/package/node_modules" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' printf '%s\n' '$HOME/www/project/node_modules' '$HOME/www/project/node_modules/package/node_modules' | \ filter_nested_artifacts | wc -l | tr -d ' ' ") - [[ "$result" == "1" ]] + [[ "$result" == "1" ]] } @test "filter_nested_artifacts: keeps independent artifacts" { - mkdir -p "$HOME/www/project1/node_modules" - mkdir -p "$HOME/www/project2/target" + mkdir -p "$HOME/www/project1/node_modules" + mkdir -p "$HOME/www/project2/target" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' printf '%s\n' '$HOME/www/project1/node_modules' '$HOME/www/project2/target' | \ filter_nested_artifacts | wc -l | tr -d ' ' ") - [[ "$result" == "2" ]] + [[ "$result" == "2" ]] } @test "filter_nested_artifacts: removes Xcode build subdirectories (Mac projects)" { - # Simulate Mac Xcode project with nested .build directories: - # ~/www/testapp/build - # ~/www/testapp/build/Framework.build - # ~/www/testapp/build/Package.build - mkdir -p "$HOME/www/testapp/build/Framework.build" - mkdir -p "$HOME/www/testapp/build/Package.build" + # Simulate Mac Xcode project with nested .build directories: + # ~/www/testapp/build + # ~/www/testapp/build/Framework.build + # ~/www/testapp/build/Package.build + mkdir -p "$HOME/www/testapp/build/Framework.build" + mkdir -p "$HOME/www/testapp/build/Package.build" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' printf '%s\n' \ '$HOME/www/testapp/build' \ @@ -150,20 +150,20 @@ setup() { '$HOME/www/testapp/build/Package.build' | \ filter_nested_artifacts | wc -l | tr -d ' ' ") - - # Should only keep the top-level 'build' directory, filtering out nested .build dirs - [[ "$result" == "1" ]] + + # Should only keep the top-level 'build' directory, filtering out nested .build dirs + [[ "$result" == "1" ]] } # Vendor protection unit tests @test "is_rails_project_root: detects valid Rails project" { - mkdir -p "$HOME/www/test-rails/config" - mkdir -p "$HOME/www/test-rails/bin" - touch "$HOME/www/test-rails/config/application.rb" - touch "$HOME/www/test-rails/Gemfile" - touch "$HOME/www/test-rails/bin/rails" + mkdir -p "$HOME/www/test-rails/config" + mkdir -p "$HOME/www/test-rails/bin" + touch "$HOME/www/test-rails/config/application.rb" + touch "$HOME/www/test-rails/Gemfile" + touch "$HOME/www/test-rails/bin/rails" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_rails_project_root '$HOME/www/test-rails'; then echo 'YES' @@ -172,14 +172,14 @@ setup() { fi ") - [[ "$result" == "YES" ]] + [[ "$result" == "YES" ]] } @test "is_rails_project_root: rejects non-Rails directory" { - mkdir -p "$HOME/www/not-rails" - touch "$HOME/www/not-rails/package.json" + mkdir -p "$HOME/www/not-rails" + touch "$HOME/www/not-rails/package.json" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_rails_project_root '$HOME/www/not-rails'; then echo 'YES' @@ -188,14 +188,14 @@ setup() { fi ") - [[ "$result" == "NO" ]] + [[ "$result" == "NO" ]] } @test "is_go_project_root: detects valid Go project" { - mkdir -p "$HOME/www/test-go" - touch "$HOME/www/test-go/go.mod" + mkdir -p "$HOME/www/test-go" + touch "$HOME/www/test-go/go.mod" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_go_project_root '$HOME/www/test-go'; then echo 'YES' @@ -204,14 +204,14 @@ setup() { fi ") - [[ "$result" == "YES" ]] + [[ "$result" == "YES" ]] } @test "is_php_project_root: detects valid PHP Composer project" { - mkdir -p "$HOME/www/test-php" - touch "$HOME/www/test-php/composer.json" + mkdir -p "$HOME/www/test-php" + touch "$HOME/www/test-php/composer.json" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_php_project_root '$HOME/www/test-php'; then echo 'YES' @@ -220,17 +220,17 @@ setup() { fi ") - [[ "$result" == "YES" ]] + [[ "$result" == "YES" ]] } @test "is_protected_vendor_dir: protects Rails vendor" { - mkdir -p "$HOME/www/rails-app/vendor" - mkdir -p "$HOME/www/rails-app/config" - touch "$HOME/www/rails-app/config/application.rb" - touch "$HOME/www/rails-app/Gemfile" - touch "$HOME/www/rails-app/config/environment.rb" + mkdir -p "$HOME/www/rails-app/vendor" + mkdir -p "$HOME/www/rails-app/config" + touch "$HOME/www/rails-app/config/application.rb" + touch "$HOME/www/rails-app/Gemfile" + touch "$HOME/www/rails-app/config/environment.rb" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_protected_vendor_dir '$HOME/www/rails-app/vendor'; then echo 'PROTECTED' @@ -239,14 +239,14 @@ setup() { fi ") - [[ "$result" == "PROTECTED" ]] + [[ "$result" == "PROTECTED" ]] } @test "is_protected_vendor_dir: does not protect PHP vendor" { - mkdir -p "$HOME/www/php-app/vendor" - touch "$HOME/www/php-app/composer.json" + mkdir -p "$HOME/www/php-app/vendor" + touch "$HOME/www/php-app/composer.json" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_protected_vendor_dir '$HOME/www/php-app/vendor'; then echo 'PROTECTED' @@ -255,11 +255,11 @@ setup() { fi ") - [[ "$result" == "NOT_PROTECTED" ]] + [[ "$result" == "NOT_PROTECTED" ]] } @test "is_project_container detects project indicators" { - 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 source "$PROJECT_ROOT/lib/clean/project.sh" mkdir -p "$HOME/Workspace2/project" @@ -269,12 +269,12 @@ if is_project_container "$HOME/Workspace2" 2; then fi EOF - [ "$status" -eq 0 ] - [[ "$output" == *"yes"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"yes"* ]] } @test "discover_project_dirs includes detected containers" { - 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 source "$PROJECT_ROOT/lib/clean/project.sh" mkdir -p "$HOME/CustomProjects/app" @@ -282,37 +282,97 @@ touch "$HOME/CustomProjects/app/go.mod" discover_project_dirs | grep -q "$HOME/CustomProjects" EOF - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] } @test "save_discovered_paths writes config with tilde" { - 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 source "$PROJECT_ROOT/lib/clean/project.sh" save_discovered_paths "$HOME/Projects" grep -q "^~/" "$HOME/.config/mole/purge_paths" EOF - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] } @test "select_purge_categories returns failure on empty input" { - 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 source "$PROJECT_ROOT/lib/clean/project.sh" if select_purge_categories; then exit 1 fi +EOF + + [ "$status" -eq 0 ] +} + +@test "select_purge_categories restores caller EXIT/INT/TERM traps" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/clean/project.sh" +trap 'echo parent-exit' EXIT +trap 'echo parent-int' INT +trap 'echo parent-term' TERM + +before_exit=$(trap -p EXIT) +before_int=$(trap -p INT) +before_term=$(trap -p TERM) + +PURGE_CATEGORY_SIZES="1" +PURGE_RECENT_CATEGORIES="false" +select_purge_categories "demo" <<< $'\n' > /dev/null 2>&1 || true + +after_exit=$(trap -p EXIT) +after_int=$(trap -p INT) +after_term=$(trap -p TERM) + +if [[ "$before_exit" == "$after_exit" && "$before_int" == "$after_int" && "$before_term" == "$after_term" ]]; then + echo "PASS" +else + echo "FAIL" + echo "before_exit=$before_exit" + echo "after_exit=$after_exit" + echo "before_int=$before_int" + echo "after_int=$after_int" + echo "before_term=$before_term" + echo "after_term=$after_term" + exit 1 +fi +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"PASS"* ]] +} + +@test "confirm_purge_cleanup accepts Enter" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/clean/project.sh" +drain_pending_input() { :; } +confirm_purge_cleanup 2 1024 0 <<< '' EOF [ "$status" -eq 0 ] } -@test "is_protected_vendor_dir: protects Go vendor" { - mkdir -p "$HOME/www/go-app/vendor" - touch "$HOME/www/go-app/go.mod" +@test "confirm_purge_cleanup cancels on ESC" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/clean/project.sh" +drain_pending_input() { :; } +confirm_purge_cleanup 2 1024 0 <<< $'\033' +EOF - result=$(bash -c " + [ "$status" -eq 1 ] +} + +@test "is_protected_vendor_dir: protects Go vendor" { + mkdir -p "$HOME/www/go-app/vendor" + touch "$HOME/www/go-app/go.mod" + + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_protected_vendor_dir '$HOME/www/go-app/vendor'; then echo 'PROTECTED' @@ -321,13 +381,13 @@ EOF fi ") - [[ "$result" == "PROTECTED" ]] + [[ "$result" == "PROTECTED" ]] } @test "is_protected_vendor_dir: protects unknown vendor (conservative)" { - mkdir -p "$HOME/www/unknown-app/vendor" + mkdir -p "$HOME/www/unknown-app/vendor" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_protected_vendor_dir '$HOME/www/unknown-app/vendor'; then echo 'PROTECTED' @@ -336,14 +396,14 @@ EOF fi ") - [[ "$result" == "PROTECTED" ]] + [[ "$result" == "PROTECTED" ]] } @test "is_protected_purge_artifact: handles vendor directories correctly" { - mkdir -p "$HOME/www/php-app/vendor" - touch "$HOME/www/php-app/composer.json" + mkdir -p "$HOME/www/php-app/vendor" + touch "$HOME/www/php-app/composer.json" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_protected_purge_artifact '$HOME/www/php-app/vendor'; then echo 'PROTECTED' @@ -352,14 +412,14 @@ EOF fi ") - # PHP vendor should not be protected - [[ "$result" == "NOT_PROTECTED" ]] + # PHP vendor should not be protected + [[ "$result" == "NOT_PROTECTED" ]] } @test "is_protected_purge_artifact: returns false for non-vendor artifacts" { - mkdir -p "$HOME/www/app/node_modules" + mkdir -p "$HOME/www/app/node_modules" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_protected_purge_artifact '$HOME/www/app/node_modules'; then echo 'PROTECTED' @@ -368,23 +428,23 @@ EOF fi ") - # node_modules is not in the protected list - [[ "$result" == "NOT_PROTECTED" ]] + # node_modules is not in the protected list + [[ "$result" == "NOT_PROTECTED" ]] } # Integration tests @test "scan_purge_targets: skips Rails vendor directory" { - mkdir -p "$HOME/www/rails-app/vendor/javascript" - mkdir -p "$HOME/www/rails-app/config" - touch "$HOME/www/rails-app/config/application.rb" - touch "$HOME/www/rails-app/Gemfile" - mkdir -p "$HOME/www/rails-app/bin" - touch "$HOME/www/rails-app/bin/rails" + mkdir -p "$HOME/www/rails-app/vendor/javascript" + mkdir -p "$HOME/www/rails-app/config" + touch "$HOME/www/rails-app/config/application.rb" + touch "$HOME/www/rails-app/Gemfile" + mkdir -p "$HOME/www/rails-app/bin" + touch "$HOME/www/rails-app/bin/rails" - local scan_output - scan_output="$(mktemp)" + local scan_output + scan_output="$(mktemp)" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' scan_purge_targets '$HOME/www' '$scan_output' if grep -q '$HOME/www/rails-app/vendor' '$scan_output'; then @@ -394,19 +454,19 @@ EOF fi ") - rm -f "$scan_output" + rm -f "$scan_output" - [[ "$result" == "SKIPPED" ]] + [[ "$result" == "SKIPPED" ]] } @test "scan_purge_targets: cleans PHP Composer vendor directory" { - mkdir -p "$HOME/www/php-app/vendor" - touch "$HOME/www/php-app/composer.json" + mkdir -p "$HOME/www/php-app/vendor" + touch "$HOME/www/php-app/composer.json" - local scan_output - scan_output="$(mktemp)" + local scan_output + scan_output="$(mktemp)" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' scan_purge_targets '$HOME/www' '$scan_output' if grep -q '$HOME/www/php-app/vendor' '$scan_output'; then @@ -416,20 +476,20 @@ EOF fi ") - rm -f "$scan_output" + rm -f "$scan_output" - [[ "$result" == "FOUND" ]] + [[ "$result" == "FOUND" ]] } @test "scan_purge_targets: skips Go vendor directory" { - mkdir -p "$HOME/www/go-app/vendor" - touch "$HOME/www/go-app/go.mod" - touch "$HOME/www/go-app/go.sum" + mkdir -p "$HOME/www/go-app/vendor" + touch "$HOME/www/go-app/go.mod" + touch "$HOME/www/go-app/go.sum" - local scan_output - scan_output="$(mktemp)" + local scan_output + scan_output="$(mktemp)" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' scan_purge_targets '$HOME/www' '$scan_output' if grep -q '$HOME/www/go-app/vendor' '$scan_output'; then @@ -439,19 +499,19 @@ EOF fi ") - rm -f "$scan_output" + rm -f "$scan_output" - [[ "$result" == "SKIPPED" ]] + [[ "$result" == "SKIPPED" ]] } @test "scan_purge_targets: skips unknown vendor directory" { - # Create a vendor directory without any project file - mkdir -p "$HOME/www/unknown-app/vendor" + # Create a vendor directory without any project file + mkdir -p "$HOME/www/unknown-app/vendor" - local scan_output - scan_output="$(mktemp)" + local scan_output + scan_output="$(mktemp)" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' scan_purge_targets '$HOME/www' '$scan_output' if grep -q '$HOME/www/unknown-app/vendor' '$scan_output'; then @@ -461,20 +521,20 @@ EOF fi ") - rm -f "$scan_output" + rm -f "$scan_output" - # Unknown vendor should be protected (conservative approach) - [[ "$result" == "SKIPPED" ]] + # Unknown vendor should be protected (conservative approach) + [[ "$result" == "SKIPPED" ]] } @test "scan_purge_targets: finds direct-child artifacts in project root with find mode" { - mkdir -p "$HOME/single-project/node_modules" - touch "$HOME/single-project/package.json" + mkdir -p "$HOME/single-project/node_modules" + touch "$HOME/single-project/package.json" - local scan_output - scan_output="$(mktemp)" + local scan_output + scan_output="$(mktemp)" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' MO_USE_FIND=1 scan_purge_targets '$HOME/single-project' '$scan_output' if grep -q '$HOME/single-project/node_modules' '$scan_output'; then @@ -484,19 +544,19 @@ EOF fi ") - rm -f "$scan_output" + rm -f "$scan_output" - [[ "$result" == "FOUND" ]] + [[ "$result" == "FOUND" ]] } @test "scan_purge_targets: supports trailing slash search path in find mode" { - mkdir -p "$HOME/single-project/node_modules" - touch "$HOME/single-project/package.json" + mkdir -p "$HOME/single-project/node_modules" + touch "$HOME/single-project/package.json" - local scan_output - scan_output="$(mktemp)" + local scan_output + scan_output="$(mktemp)" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' MO_USE_FIND=1 scan_purge_targets '$HOME/single-project/' '$scan_output' if grep -q '$HOME/single-project/node_modules' '$scan_output'; then @@ -506,16 +566,16 @@ EOF fi ") - rm -f "$scan_output" + rm -f "$scan_output" - [[ "$result" == "FOUND" ]] + [[ "$result" == "FOUND" ]] } @test "is_recently_modified: detects recent projects" { - mkdir -p "$HOME/www/project/node_modules" - touch "$HOME/www/project/package.json" + mkdir -p "$HOME/www/project/node_modules" + touch "$HOME/www/project/package.json" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/core/common.sh' source '$PROJECT_ROOT/lib/clean/project.sh' if is_recently_modified '$HOME/www/project/node_modules'; then @@ -524,66 +584,66 @@ EOF echo 'OLD' fi ") - [[ "$result" == "RECENT" ]] + [[ "$result" == "RECENT" ]] } @test "is_recently_modified: marks old projects correctly" { - mkdir -p "$HOME/www/old-project/node_modules" - mkdir -p "$HOME/www/old-project" + mkdir -p "$HOME/www/old-project/node_modules" + mkdir -p "$HOME/www/old-project" - bash -c " + bash -c " source '$PROJECT_ROOT/lib/core/common.sh' source '$PROJECT_ROOT/lib/clean/project.sh' is_recently_modified '$HOME/www/old-project/node_modules' || true " - local exit_code=$? - [ "$exit_code" -eq 0 ] || [ "$exit_code" -eq 1 ] + local exit_code=$? + [ "$exit_code" -eq 0 ] || [ "$exit_code" -eq 1 ] } @test "purge targets are configured correctly" { - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' echo \"\${PURGE_TARGETS[@]}\" ") - [[ "$result" == *"node_modules"* ]] - [[ "$result" == *"target"* ]] + [[ "$result" == *"node_modules"* ]] + [[ "$result" == *"target"* ]] } @test "get_dir_size_kb: calculates directory size" { - mkdir -p "$HOME/www/test-project/node_modules" - dd if=/dev/zero of="$HOME/www/test-project/node_modules/file.bin" bs=1024 count=1024 2>/dev/null + mkdir -p "$HOME/www/test-project/node_modules" + dd if=/dev/zero of="$HOME/www/test-project/node_modules/file.bin" bs=1024 count=1024 2>/dev/null - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' get_dir_size_kb '$HOME/www/test-project/node_modules' ") - [[ "$result" -ge 1000 ]] && [[ "$result" -le 1100 ]] + [[ "$result" -ge 1000 ]] && [[ "$result" -le 1100 ]] } @test "get_dir_size_kb: handles non-existent paths gracefully" { - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' get_dir_size_kb '$HOME/www/non-existent' ") - [[ "$result" == "0" ]] + [[ "$result" == "0" ]] } @test "get_dir_size_kb: returns TIMEOUT when size calculation hangs" { - mkdir -p "$HOME/www/stuck-project/node_modules" + mkdir -p "$HOME/www/stuck-project/node_modules" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/core/common.sh' source '$PROJECT_ROOT/lib/clean/project.sh' run_with_timeout() { return 124; } get_dir_size_kb '$HOME/www/stuck-project/node_modules' ") - [[ "$result" == "TIMEOUT" ]] + [[ "$result" == "TIMEOUT" ]] } @test "clean_project_artifacts: restores caller INT/TERM traps" { - result=$(bash -c " + result=$(bash -c " set -euo pipefail export HOME='$HOME' source '$PROJECT_ROOT/lib/core/common.sh' @@ -609,92 +669,139 @@ EOF fi ") - [[ "$result" == *"PASS"* ]] + [[ "$result" == *"PASS"* ]] } @test "clean_project_artifacts: handles empty directory gracefully" { - run bash -c " + run bash -c " export HOME='$HOME' source '$PROJECT_ROOT/lib/core/common.sh' source '$PROJECT_ROOT/lib/clean/project.sh' clean_project_artifacts - " < /dev/null + " "$HOME/.cache/mole/purge_stats" + +mkdir -p "$HOME/www/test-project/node_modules" +echo "test data" > "$HOME/www/test-project/node_modules/file.js" +touch "$HOME/www/test-project/package.json" +touch -t 202001010101 "$HOME/www/test-project/node_modules" "$HOME/www/test-project/package.json" "$HOME/www/test-project" + +PURGE_SEARCH_PATHS=("$HOME/www") +safe_remove() { return 1; } + +export MOLE_DRY_RUN=1 +clean_project_artifacts + +stats_dir="${XDG_CACHE_HOME:-$HOME/.cache}/mole" +echo "COUNT=$(cat "$stats_dir/purge_count" 2> /dev/null || echo missing)" +echo "SIZE=$(cat "$stats_dir/purge_stats" 2> /dev/null || echo missing)" +[[ -d "$HOME/www/test-project/node_modules" ]] +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"COUNT=0"* ]] + [[ "$output" == *"SIZE=0"* ]] } @test "clean_project_artifacts: scans and finds artifacts" { - if ! command -v gtimeout >/dev/null 2>&1 && ! command -v timeout >/dev/null 2>&1; then - skip "gtimeout/timeout not available" - fi + if ! command -v gtimeout >/dev/null 2>&1 && ! command -v timeout >/dev/null 2>&1; then + skip "gtimeout/timeout not available" + fi - mkdir -p "$HOME/www/test-project/node_modules/package1" - echo "test data" > "$HOME/www/test-project/node_modules/package1/index.js" + mkdir -p "$HOME/www/test-project/node_modules/package1" + echo "test data" >"$HOME/www/test-project/node_modules/package1/index.js" - mkdir -p "$HOME/www/test-project" + mkdir -p "$HOME/www/test-project" - timeout_cmd="timeout" - command -v timeout >/dev/null 2>&1 || timeout_cmd="gtimeout" + timeout_cmd="timeout" + command -v timeout >/dev/null 2>&1 || timeout_cmd="gtimeout" - run bash -c " + run bash -c " export HOME='$HOME' $timeout_cmd 5 '$PROJECT_ROOT/bin/purge.sh' 2>&1 < /dev/null || true " - [[ "$output" =~ "Scanning" ]] || - [[ "$output" =~ "Purge complete" ]] || - [[ "$output" =~ "No old" ]] || - [[ "$output" =~ "Great" ]] + [[ "$output" =~ "Scanning" ]] || + [[ "$output" =~ "Purge complete" ]] || + [[ "$output" =~ "No old" ]] || + [[ "$output" =~ "Great" ]] } @test "mo purge: command exists and is executable" { - [ -x "$PROJECT_ROOT/mole" ] - [ -f "$PROJECT_ROOT/bin/purge.sh" ] + [ -x "$PROJECT_ROOT/mole" ] + [ -f "$PROJECT_ROOT/bin/purge.sh" ] } @test "mo purge: shows in help text" { - run env HOME="$HOME" "$PROJECT_ROOT/mole" --help - [ "$status" -eq 0 ] - [[ "$output" == *"mo purge"* ]] + run env HOME="$HOME" "$PROJECT_ROOT/mole" --help + [ "$status" -eq 0 ] + [[ "$output" == *"mo purge"* ]] } @test "mo purge: accepts --debug flag" { - if ! command -v gtimeout >/dev/null 2>&1 && ! command -v timeout >/dev/null 2>&1; then - skip "gtimeout/timeout not available" - fi + 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" + timeout_cmd="timeout" + command -v timeout >/dev/null 2>&1 || timeout_cmd="gtimeout" - run bash -c " + run bash -c " export HOME='$HOME' $timeout_cmd 2 '$PROJECT_ROOT/mole' purge --debug < /dev/null 2>&1 || true " - 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" { - if ! command -v gtimeout >/dev/null 2>&1 && ! command -v timeout >/dev/null 2>&1; then - skip "gtimeout/timeout not available" - fi + 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" + timeout_cmd="timeout" + command -v timeout >/dev/null 2>&1 || timeout_cmd="gtimeout" - bash -c " + bash -c " export HOME='$HOME' $timeout_cmd 2 '$PROJECT_ROOT/mole' purge < /dev/null 2>&1 || true " - [ -d "$HOME/.cache/mole" ] || [ -d "${XDG_CACHE_HOME:-$HOME/.cache}/mole" ] + [ -d "$HOME/.cache/mole" ] || [ -d "${XDG_CACHE_HOME:-$HOME/.cache}/mole" ] } # .NET bin directory detection tests @test "is_dotnet_bin_dir: finds .NET context in parent directory with Debug dir" { - mkdir -p "$HOME/www/dotnet-app/bin/Debug" - touch "$HOME/www/dotnet-app/MyProject.csproj" + mkdir -p "$HOME/www/dotnet-app/bin/Debug" + touch "$HOME/www/dotnet-app/MyProject.csproj" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_dotnet_bin_dir '$HOME/www/dotnet-app/bin'; then echo 'FOUND' @@ -703,14 +810,14 @@ EOF fi ") - [[ "$result" == "FOUND" ]] + [[ "$result" == "FOUND" ]] } @test "is_dotnet_bin_dir: requires .csproj AND Debug/Release" { - mkdir -p "$HOME/www/dotnet-app/bin" - touch "$HOME/www/dotnet-app/MyProject.csproj" + mkdir -p "$HOME/www/dotnet-app/bin" + touch "$HOME/www/dotnet-app/MyProject.csproj" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_dotnet_bin_dir '$HOME/www/dotnet-app/bin'; then echo 'FOUND' @@ -719,15 +826,15 @@ EOF fi ") - # Should not find it because Debug/Release directories don't exist - [[ "$result" == "NOT_FOUND" ]] + # Should not find it because Debug/Release directories don't exist + [[ "$result" == "NOT_FOUND" ]] } @test "is_dotnet_bin_dir: rejects non-bin directories" { - mkdir -p "$HOME/www/dotnet-app/obj" - touch "$HOME/www/dotnet-app/MyProject.csproj" + mkdir -p "$HOME/www/dotnet-app/obj" + touch "$HOME/www/dotnet-app/MyProject.csproj" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_dotnet_bin_dir '$HOME/www/dotnet-app/obj'; then echo 'FOUND' @@ -735,19 +842,18 @@ EOF echo 'NOT_FOUND' fi ") - [[ "$result" == "NOT_FOUND" ]] + [[ "$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" - touch "$HOME/www/dotnet-app/MyProject.csproj" + mkdir -p "$HOME/www/dotnet-app/bin/Debug" + touch "$HOME/www/dotnet-app/MyProject.csproj" - local scan_output - scan_output="$(mktemp)" + local scan_output + scan_output="$(mktemp)" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' scan_purge_targets '$HOME/www' '$scan_output' if grep -q '$HOME/www/dotnet-app/bin' '$scan_output'; then @@ -757,19 +863,19 @@ EOF fi ") - rm -f "$scan_output" + rm -f "$scan_output" - [[ "$result" == "FOUND" ]] + [[ "$result" == "FOUND" ]] } @test "scan_purge_targets: skips generic bin directories (non-.NET)" { - mkdir -p "$HOME/www/ruby-app/bin" - touch "$HOME/www/ruby-app/Gemfile" + mkdir -p "$HOME/www/ruby-app/bin" + touch "$HOME/www/ruby-app/Gemfile" - local scan_output - scan_output="$(mktemp)" + local scan_output + scan_output="$(mktemp)" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' scan_purge_targets '$HOME/www' '$scan_output' if grep -q '$HOME/www/ruby-app/bin' '$scan_output'; then @@ -779,6 +885,6 @@ EOF fi ") - rm -f "$scan_output" - [[ "$result" == "SKIPPED" ]] + rm -f "$scan_output" + [[ "$result" == "SKIPPED" ]] } diff --git a/tests/uninstall.bats b/tests/uninstall.bats index 3c98927..bd71faa 100644 --- a/tests/uninstall.bats +++ b/tests/uninstall.bats @@ -1,67 +1,67 @@ #!/usr/bin/env bats setup_file() { - PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" - export PROJECT_ROOT + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT - ORIGINAL_HOME="${BATS_TMPDIR:-}" # Use BATS_TMPDIR as original HOME if set by bats - if [[ -z "$ORIGINAL_HOME" ]]; then - ORIGINAL_HOME="${HOME:-}" - fi - export ORIGINAL_HOME + ORIGINAL_HOME="${BATS_TMPDIR:-}" # Use BATS_TMPDIR as original HOME if set by bats + if [[ -z "$ORIGINAL_HOME" ]]; then + ORIGINAL_HOME="${HOME:-}" + fi + export ORIGINAL_HOME - HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-uninstall-home.XXXXXX")" - export HOME + HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-uninstall-home.XXXXXX")" + export HOME } teardown_file() { - rm -rf "$HOME" - if [[ -n "${ORIGINAL_HOME:-}" ]]; then - export HOME="$ORIGINAL_HOME" - fi + rm -rf "$HOME" + if [[ -n "${ORIGINAL_HOME:-}" ]]; then + export HOME="$ORIGINAL_HOME" + fi } setup() { - export TERM="dumb" - rm -rf "${HOME:?}"/* - mkdir -p "$HOME" + export TERM="dumb" + rm -rf "${HOME:?}"/* + mkdir -p "$HOME" } create_app_artifacts() { - mkdir -p "$HOME/Applications/TestApp.app" - mkdir -p "$HOME/Library/Application Support/TestApp" - mkdir -p "$HOME/Library/Caches/TestApp" - mkdir -p "$HOME/Library/Containers/com.example.TestApp" - mkdir -p "$HOME/Library/Preferences" - touch "$HOME/Library/Preferences/com.example.TestApp.plist" - mkdir -p "$HOME/Library/Preferences/ByHost" - touch "$HOME/Library/Preferences/ByHost/com.example.TestApp.ABC123.plist" - mkdir -p "$HOME/Library/Saved Application State/com.example.TestApp.savedState" - mkdir -p "$HOME/Library/LaunchAgents" - touch "$HOME/Library/LaunchAgents/com.example.TestApp.plist" + mkdir -p "$HOME/Applications/TestApp.app" + mkdir -p "$HOME/Library/Application Support/TestApp" + mkdir -p "$HOME/Library/Caches/TestApp" + mkdir -p "$HOME/Library/Containers/com.example.TestApp" + mkdir -p "$HOME/Library/Preferences" + touch "$HOME/Library/Preferences/com.example.TestApp.plist" + mkdir -p "$HOME/Library/Preferences/ByHost" + touch "$HOME/Library/Preferences/ByHost/com.example.TestApp.ABC123.plist" + mkdir -p "$HOME/Library/Saved Application State/com.example.TestApp.savedState" + mkdir -p "$HOME/Library/LaunchAgents" + touch "$HOME/Library/LaunchAgents/com.example.TestApp.plist" } @test "find_app_files discovers user-level leftovers" { - create_app_artifacts + create_app_artifacts - result="$( - HOME="$HOME" bash --noprofile --norc << 'EOF' + result="$( + HOME="$HOME" bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" find_app_files "com.example.TestApp" "TestApp" EOF - )" + )" - [[ "$result" == *"Application Support/TestApp"* ]] - [[ "$result" == *"Caches/TestApp"* ]] - [[ "$result" == *"Preferences/com.example.TestApp.plist"* ]] - [[ "$result" == *"Saved Application State/com.example.TestApp.savedState"* ]] - [[ "$result" == *"Containers/com.example.TestApp"* ]] - [[ "$result" == *"LaunchAgents/com.example.TestApp.plist"* ]] + [[ "$result" == *"Application Support/TestApp"* ]] + [[ "$result" == *"Caches/TestApp"* ]] + [[ "$result" == *"Preferences/com.example.TestApp.plist"* ]] + [[ "$result" == *"Saved Application State/com.example.TestApp.savedState"* ]] + [[ "$result" == *"Containers/com.example.TestApp"* ]] + [[ "$result" == *"LaunchAgents/com.example.TestApp.plist"* ]] } @test "get_diagnostic_report_paths_for_app avoids executable prefix collisions" { - 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 source "$PROJECT_ROOT/lib/core/common.sh" @@ -92,16 +92,16 @@ result=$(get_diagnostic_report_paths_for_app "$app_dir" "Foo" "$diag_dir") [[ "$result" != *"Foobar_2026-01-01-120001_host.ips"* ]] || exit 1 EOF - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] } @test "calculate_total_size returns aggregate kilobytes" { - mkdir -p "$HOME/sized" - dd if=/dev/zero of="$HOME/sized/file1" bs=1024 count=1 > /dev/null 2>&1 - dd if=/dev/zero of="$HOME/sized/file2" bs=1024 count=2 > /dev/null 2>&1 + mkdir -p "$HOME/sized" + dd if=/dev/zero of="$HOME/sized/file1" bs=1024 count=1 >/dev/null 2>&1 + dd if=/dev/zero of="$HOME/sized/file2" bs=1024 count=2 >/dev/null 2>&1 - result="$( - HOME="$HOME" bash --noprofile --norc << 'EOF' + result="$( + HOME="$HOME" bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" files="$(printf '%s @@ -109,15 +109,15 @@ files="$(printf '%s ' "$HOME/sized/file1" "$HOME/sized/file2")" calculate_total_size "$files" EOF - )" + )" - [ "$result" -ge 3 ] + [ "$result" -ge 3 ] } @test "batch_uninstall_applications removes selected app data" { - create_app_artifacts + create_app_artifacts - 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 source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/uninstall/batch.sh" @@ -155,22 +155,22 @@ batch_uninstall_applications [[ ! -f "$HOME/Library/LaunchAgents/com.example.TestApp.plist" ]] || exit 1 EOF - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] } @test "batch_uninstall_applications preview shows full related file list" { - mkdir -p "$HOME/Applications/TestApp.app" - mkdir -p "$HOME/Library/Application Support/TestApp" - mkdir -p "$HOME/Library/Caches/TestApp" - mkdir -p "$HOME/Library/Logs/TestApp" - touch "$HOME/Library/Logs/TestApp/log1.log" - touch "$HOME/Library/Logs/TestApp/log2.log" - touch "$HOME/Library/Logs/TestApp/log3.log" - touch "$HOME/Library/Logs/TestApp/log4.log" - touch "$HOME/Library/Logs/TestApp/log5.log" - touch "$HOME/Library/Logs/TestApp/log6.log" + mkdir -p "$HOME/Applications/TestApp.app" + mkdir -p "$HOME/Library/Application Support/TestApp" + mkdir -p "$HOME/Library/Caches/TestApp" + mkdir -p "$HOME/Library/Logs/TestApp" + touch "$HOME/Library/Logs/TestApp/log1.log" + touch "$HOME/Library/Logs/TestApp/log2.log" + touch "$HOME/Library/Logs/TestApp/log3.log" + touch "$HOME/Library/Logs/TestApp/log4.log" + touch "$HOME/Library/Logs/TestApp/log5.log" + touch "$HOME/Library/Logs/TestApp/log6.log" - 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 source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/uninstall/batch.sh" @@ -210,28 +210,27 @@ total_size_cleaned=0 printf 'q' | batch_uninstall_applications EOF - [ "$status" -eq 0 ] - [[ "$output" == *"~/Library/Logs/TestApp/log6.log"* ]] - [[ "$output" != *"more files"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"~/Library/Logs/TestApp/log6.log"* ]] + [[ "$output" != *"more files"* ]] } @test "safe_remove can remove a simple directory" { - mkdir -p "$HOME/test_dir" - touch "$HOME/test_dir/file.txt" + mkdir -p "$HOME/test_dir" + touch "$HOME/test_dir/file.txt" - 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 source "$PROJECT_ROOT/lib/core/common.sh" safe_remove "$HOME/test_dir" [[ ! -d "$HOME/test_dir" ]] || exit 1 EOF - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] } - @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 source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/uninstall/batch.sh" @@ -242,11 +241,11 @@ result=$(decode_file_list "$valid_data" "TestApp") [[ -n "$result" ]] || exit 1 EOF - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] } @test "decode_file_list rejects invalid base64" { - 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 source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/uninstall/batch.sh" @@ -258,11 +257,11 @@ else fi EOF - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] } @test "decode_file_list handles empty input" { - 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 source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/uninstall/batch.sh" @@ -272,11 +271,11 @@ result=$(decode_file_list "$empty_data" "TestApp" 2>/dev/null) || true [[ -z "$result" ]] EOF - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] } @test "decode_file_list rejects non-absolute paths" { - 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 source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/uninstall/batch.sh" @@ -289,11 +288,11 @@ else fi EOF - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] } @test "decode_file_list handles both BSD and GNU base64 formats" { - 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 source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/uninstall/batch.sh" @@ -311,16 +310,16 @@ result=$(decode_file_list "$encoded_data" "TestApp") [[ -n "$result" ]] || exit 1 EOF - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] } @test "remove_mole deletes 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" + 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' + 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() { :; } @@ -355,9 +354,31 @@ export -f start_inline_spinner stop_inline_spinner rm sudo printf '\n' | "$PROJECT_ROOT/mole" remove EOF - [ "$status" -eq 0 ] - [ ! -f "$HOME/.local/bin/mole" ] - [ ! -f "$HOME/.local/bin/mo" ] - [ ! -d "$HOME/.config/mole" ] - [ ! -d "$HOME/.cache/mole" ] + [ "$status" -eq 0 ] + [ ! -f "$HOME/.local/bin/mole" ] + [ ! -f "$HOME/.local/bin/mo" ] + [ ! -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" ] }