From ceac7957aa9cc63712073a3dfb66dd0ea62f80fe Mon Sep 17 00:00:00 2001 From: tw93 Date: Thu, 26 Feb 2026 11:47:28 +0800 Subject: [PATCH 01/69] fix(clean): correct Messages check logic and improve Application Support scanning - Fix Messages running check to not skip CrashReporter/idleassetsd cleanup - Add proper spacing between function definitions - Add app_support_item_size_bytes() for accurate size calculation - Improve progress reporting during Application Support scan --- lib/clean/user.sh | 106 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 92 insertions(+), 14 deletions(-) diff --git a/lib/clean/user.sh b/lib/clean/user.sh index dac50d1..438ccec 100644 --- a/lib/clean/user.sh +++ b/lib/clean/user.sh @@ -387,6 +387,7 @@ scan_external_volumes() { done stop_section_spinner } + # Finder metadata (.DS_Store). clean_finder_metadata() { if [[ "$PROTECT_FINDER_METADATA" == "true" ]]; then @@ -414,11 +415,11 @@ clean_support_app_data() { # 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). @@ -480,6 +481,7 @@ clean_app_caches() { clean_group_container_caches } + # Process a single container cache directory. process_container_cache() { local container_dir="$1" @@ -638,6 +640,7 @@ clean_group_container_caches() { note_activity fi } + # Browser caches (Safari/Chrome/Edge/Firefox). clean_browsers() { safe_clean ~/Library/Caches/com.apple.Safari/* "Safari cache" @@ -683,6 +686,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 +697,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 +709,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 +718,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,8 +768,13 @@ 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) @@ -758,15 +810,23 @@ 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 + 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 += 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 @@ -783,6 +843,7 @@ clean_application_support_logs() { done < <(command find "$candidate" -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true) if [[ "$item_found" == "true" ]]; then ((total_size_bytes += candidate_size_bytes)) + [[ "$candidate_size_partial" == "true" ]] && total_size_partial=true ((cleaned_count++)) found_any=true fi @@ -800,15 +861,23 @@ 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 + 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 += 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 @@ -825,6 +894,7 @@ clean_application_support_logs() { done < <(command find "$candidate" -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true) if [[ "$item_found" == "true" ]]; then ((total_size_bytes += candidate_size_bytes)) + [[ "$candidate_size_partial" == "true" ]] && total_size_partial=true ((cleaned_count++)) found_any=true fi @@ -838,9 +908,17 @@ 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)) From 3c3c976b5b81dd8b6c0d17cdb03894c09e505c93 Mon Sep 17 00:00:00 2001 From: tw93 Date: Thu, 26 Feb 2026 11:47:33 +0800 Subject: [PATCH 02/69] fix(optimize): use ICON constants and improve LaunchServices path detection - Replace Unicode characters with ICON_SUCCESS and ICON_INFO constants - Add fallback paths for lsregister tool (macOS 14+ compatibility) --- lib/optimize/tasks.sh | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/lib/optimize/tasks.sh b/lib/optimize/tasks.sh index 9d8d6bf..3afcbdf 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 } @@ -406,9 +406,20 @@ opt_launch_services_rebuild() { start_inline_spinner "" fi - local lsregister="/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister" + local lsregister="" + local -a lsregister_candidates=( + "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister" + "/System/Library/CoreServices/Frameworks/LaunchServices.framework/Support/lsregister" + ) + local lsregister_candidate="" + for lsregister_candidate in "${lsregister_candidates[@]}"; do + if [[ -x "$lsregister_candidate" ]]; then + lsregister="$lsregister_candidate" + break + fi + done - if [[ -f "$lsregister" ]]; then + if [[ -n "$lsregister" ]]; then local success=0 if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then @@ -741,7 +752,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}" From 3bd3e400b6fd34c3fcba1f52619bb450de0bf8d0 Mon Sep 17 00:00:00 2001 From: tw93 Date: Thu, 26 Feb 2026 11:47:41 +0800 Subject: [PATCH 03/69] style: improve code consistency and formatting - Rename _MOLE_HINTS_DIR to mole_hints_dir (naming convention) - Split local variable declaration and assignment - Add ICON_INFO constant to base.sh - Remove redundant has_cached_sudo function (use has_sudo_session) --- bin/clean.sh | 23 +++++++++++------------ lib/clean/hints.sh | 4 ++-- lib/core/base.sh | 1 + 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/bin/clean.sh b/bin/clean.sh index 914cb09..cdd1e65 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() { @@ -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 @@ -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 @@ -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 @@ -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/lib/clean/hints.sh b/lib/clean/hints.sh index edabef8..8f9f265 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. diff --git a/lib/core/base.sh b/lib/core/base.sh index 452f2b0..890e63a 100644 --- a/lib/core/base.sh +++ b/lib/core/base.sh @@ -41,6 +41,7 @@ readonly ICON_DRY_RUN="→" readonly ICON_REVIEW="☞" readonly ICON_NAV_UP="↑" readonly ICON_NAV_DOWN="↓" +readonly ICON_INFO="ℹ" # ============================================================================ # Global Configuration Constants From 980bbcd3c3fae2dd9ce4ab5fe41f8d3386ce04b2 Mon Sep 17 00:00:00 2001 From: tw93 Date: Thu, 26 Feb 2026 11:47:46 +0800 Subject: [PATCH 04/69] fix(uninstall): improve LaunchServices path detection for macOS 14+ - Add fallback paths for lsregister tool - Check executable permission before using path --- lib/uninstall/batch.sh | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/lib/uninstall/batch.sh b/lib/uninstall/batch.sh index 138d815..a7a298a 100755 --- a/lib/uninstall/batch.sh +++ b/lib/uninstall/batch.sh @@ -12,7 +12,19 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" # Batch uninstall with a single confirmation. get_lsregister_path() { - echo "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister" + 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 } # High-performance sensitive data detection (pure Bash, no subprocess) From 0b5ac830e5af8038f4a99ec5b6286ec2ce80f965 Mon Sep 17 00:00:00 2001 From: tw93 Date: Thu, 26 Feb 2026 11:47:51 +0800 Subject: [PATCH 05/69] test(clean): add test for Application Support size counting - Add test for nested directory contents in dry-run size summary --- tests/clean_user_core.bats | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) 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 From f394e7552c6318f5c7ca8e55b0d5fc3491ad2f30 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 26 Feb 2026 03:48:32 +0000 Subject: [PATCH 06/69] chore: update contributors [skip ci] --- CONTRIBUTORS.svg | 101 ++++++++++++++++++++++++++--------------------- 1 file changed, 56 insertions(+), 45 deletions(-) diff --git a/CONTRIBUTORS.svg b/CONTRIBUTORS.svg index 0d120c5..40eeff4 100644 --- a/CONTRIBUTORS.svg +++ b/CONTRIBUTORS.svg @@ -1,4 +1,4 @@ - + @@ -178,17 +178,6 @@ - - - - - - - - andmev - - - @@ -199,7 +188,7 @@ ndbroadbent - + @@ -210,7 +199,7 @@ ppauel - + @@ -221,7 +210,7 @@ shakeelmohamed - + @@ -232,7 +221,7 @@ Sizk - + @@ -243,7 +232,7 @@ Harsh-Kapoorr - + @@ -254,7 +243,7 @@ thijsvanhal - + @@ -265,7 +254,7 @@ TomP0 - + @@ -276,7 +265,7 @@ yuzeguitarist - + @@ -287,7 +276,7 @@ zeldrisho - + @@ -298,7 +287,7 @@ bikraj2 - + @@ -309,7 +298,7 @@ bunizao - + @@ -320,7 +309,7 @@ rans0 - + @@ -331,7 +320,7 @@ frozturk - + @@ -342,7 +331,7 @@ huyixi - + @@ -353,7 +342,7 @@ purofle - + @@ -364,7 +353,7 @@ yamamel - + @@ -375,7 +364,29 @@ NanmiCoder + + + + + + + + + imnotnoahhh + + + + + + + + + + andmev + + + @@ -386,7 +397,7 @@ uluumbch - + @@ -397,7 +408,7 @@ ClathW - + @@ -408,7 +419,7 @@ Copper-Eye - + @@ -419,7 +430,7 @@ DimitarNestorov - + @@ -430,7 +441,7 @@ gokulp01 - + @@ -441,7 +452,7 @@ Hensell - + @@ -452,7 +463,7 @@ jalen0x - + @@ -463,7 +474,7 @@ kowyo - + @@ -474,7 +485,7 @@ kwakubiney - + @@ -485,7 +496,7 @@ LmanTW - + @@ -496,7 +507,7 @@ injuxtice - + @@ -507,7 +518,7 @@ khipu-luke - + @@ -518,7 +529,7 @@ mariovtor - + @@ -529,7 +540,7 @@ anonymort - + @@ -540,7 +551,7 @@ Schlauer-Hax - + @@ -551,7 +562,7 @@ mickyyy68 - + @@ -562,7 +573,7 @@ EastSun5566 - + From 88b0fe6af3ef7c144ecebf96ed7b36a6684ff1b7 Mon Sep 17 00:00:00 2001 From: tw93 Date: Thu, 26 Feb 2026 11:53:39 +0800 Subject: [PATCH 07/69] refactor(base): extract get_lsregister_path as shared utility Move get_lsregister_path() to base.sh to avoid duplication across optimize and uninstall modules. Adds macOS 14+ compatibility with fallback paths. --- lib/core/base.sh | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/lib/core/base.sh b/lib/core/base.sh index 890e63a..d0efe1a 100644 --- a/lib/core/base.sh +++ b/lib/core/base.sh @@ -43,6 +43,27 @@ 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 # ============================================================================ From d0e1a200d6b936c64e0bd949aea03b8b851edee6 Mon Sep 17 00:00:00 2001 From: tw93 Date: Thu, 26 Feb 2026 11:53:44 +0800 Subject: [PATCH 08/69] refactor(optimize): use shared get_lsregister_path from base.sh Remove duplicate get_lsregister_path() implementation and use the shared utility from base.sh instead. --- lib/optimize/tasks.sh | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/lib/optimize/tasks.sh b/lib/optimize/tasks.sh index 3afcbdf..6260263 100644 --- a/lib/optimize/tasks.sh +++ b/lib/optimize/tasks.sh @@ -406,18 +406,8 @@ opt_launch_services_rebuild() { start_inline_spinner "" fi - local lsregister="" - local -a lsregister_candidates=( - "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister" - "/System/Library/CoreServices/Frameworks/LaunchServices.framework/Support/lsregister" - ) - local lsregister_candidate="" - for lsregister_candidate in "${lsregister_candidates[@]}"; do - if [[ -x "$lsregister_candidate" ]]; then - lsregister="$lsregister_candidate" - break - fi - done + local lsregister + lsregister=$(get_lsregister_path) if [[ -n "$lsregister" ]]; then local success=0 From 3e47cdb39edea6287e21c32dd1e469537985ab46 Mon Sep 17 00:00:00 2001 From: tw93 Date: Thu, 26 Feb 2026 11:53:49 +0800 Subject: [PATCH 09/69] refactor(uninstall): use shared get_lsregister_path from base.sh Remove duplicate get_lsregister_path() implementation and use the shared utility from base.sh instead. --- lib/uninstall/batch.sh | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/lib/uninstall/batch.sh b/lib/uninstall/batch.sh index a7a298a..fe7876f 100755 --- a/lib/uninstall/batch.sh +++ b/lib/uninstall/batch.sh @@ -11,22 +11,6 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" # Batch uninstall with a single confirmation. -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 -} - # High-performance sensitive data detection (pure Bash, no subprocess) # Faster than grep for batch operations, especially when processing many apps has_sensitive_data() { From 29ec8f7d43bf8c4f09ab9ebf96b57d1b02c331e9 Mon Sep 17 00:00:00 2001 From: tw93 Date: Thu, 26 Feb 2026 11:53:54 +0800 Subject: [PATCH 10/69] style(clean): unify DRY_RUN variable check style Use consistent "$DRY_RUN" check instead of "${DRY_RUN:-false}" to match project conventions. --- lib/clean/system.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/clean/system.sh b/lib/clean/system.sh index eea1883..dbe4a44 100644 --- a/lib/clean/system.sh +++ b/lib/clean/system.sh @@ -179,7 +179,7 @@ clean_deep_system() { done < <(sudo find "$mem_reports_dir" -type f -mtime +30 -print0 2> /dev/null || true) 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 From b3f023b5e609ede1a5890e4f83761a09a875409a Mon Sep 17 00:00:00 2001 From: Tw93 Date: Thu, 26 Feb 2026 03:54:50 +0000 Subject: [PATCH 11/69] chore: auto format code --- lib/clean/user.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/clean/user.sh b/lib/clean/user.sh index 438ccec..722eecd 100644 --- a/lib/clean/user.sh +++ b/lib/clean/user.sh @@ -816,7 +816,7 @@ clean_application_support_logs() { [[ -e "$item" ]] || continue item_found=true ((candidate_item_count++)) - if [[ ! -L "$item" && ( -f "$item" || -d "$item" ) ]]; then + 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 @@ -867,7 +867,7 @@ clean_application_support_logs() { [[ -e "$item" ]] || continue item_found=true ((candidate_item_count++)) - if [[ ! -L "$item" && ( -f "$item" || -d "$item" ) ]]; then + 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 From aa1a4368622b52f831a893bea7bc6a588e18f88c Mon Sep 17 00:00:00 2001 From: tw93 Date: Thu, 26 Feb 2026 16:36:06 +0800 Subject: [PATCH 12/69] fix(clean): improve loading feedback and spinner output --- lib/clean/dev.sh | 10 ++++++++++ lib/clean/system.sh | 9 ++++++++- lib/clean/user.sh | 20 ++++++++++++++++++-- lib/core/ui.sh | 35 +++++++++++++++++++++++++++++++++-- 4 files changed, 69 insertions(+), 5 deletions(-) diff --git a/lib/clean/dev.sh b/lib/clean/dev.sh index 8d02498..edb8157 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 diff --git a/lib/clean/system.sh b/lib/clean/system.sh index dbe4a44..5cc5e93 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 @@ -147,7 +149,7 @@ clean_deep_system() { 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 @@ -158,11 +160,16 @@ clean_deep_system() { [[ $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" diff --git a/lib/clean/user.sh b/lib/clean/user.sh index 722eecd..8a6f585 100644 --- a/lib/clean/user.sh +++ b/lib/clean/user.sh @@ -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 @@ -102,6 +107,9 @@ _clean_mail_downloads() { 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") @@ -832,8 +840,12 @@ clean_application_support_logs() { 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 @@ -883,8 +895,12 @@ clean_application_support_logs() { 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 diff --git a/lib/core/ui.sh b/lib/core/ui.sh index eb0c76c..2834091 100755 --- a/lib/core/ui.sh +++ b/lib/core/ui.sh @@ -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,7 +340,7 @@ 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 + printf "\r${MOLE_SPINNER_PREFIX:-}${BLUE}%s${NC} %s" "$c" "$display_message" >&2 || break ((i++)) 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 } From e17f35eb57e6045f821caf2fc7650c1f4d35c54b Mon Sep 17 00:00:00 2001 From: tw93 Date: Thu, 26 Feb 2026 16:43:09 +0800 Subject: [PATCH 13/69] fix(uninstall): show next-step prompt without post-cleanup delay --- lib/uninstall/batch.sh | 36 +++++++++++++++--------------------- 1 file changed, 15 insertions(+), 21 deletions(-) diff --git a/lib/uninstall/batch.sh b/lib/uninstall/batch.sh index fe7876f..597b9dc 100755 --- a/lib/uninstall/batch.sh +++ b/lib/uninstall/batch.sh @@ -735,32 +735,26 @@ batch_uninstall_applications() { print_summary_block "$title" "${summary_details[@]}" printf '\n' - # Auto-run brew autoremove if Homebrew casks were uninstalled - if [[ $brew_apps_removed -gt 0 ]]; then - # 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} + # Run non-critical post-cleanup tasks asynchronously so the next prompt appears immediately. + # These tasks are best-effort and should not block interactive flow. + if [[ $brew_apps_removed -gt 0 || ($success_count -gt 0 && ${#success_items[@]} -gt 0) ]]; then + local -a post_success_items=("${success_items[@]}") + local post_brew_apps_removed="$brew_apps_removed" if [[ -t 1 ]]; then - stop_inline_spinner + echo -e "${GRAY}${ICON_LIST}${NC} Finalizing uninstall cleanup in background..." fi - if [[ $removed_count -gt 0 ]]; then - echo -e "${GREEN}${ICON_SUCCESS}${NC} Cleaned $removed_count orphaned brew dependencies" - echo "" - fi - fi + ( + if [[ "$post_brew_apps_removed" -gt 0 ]]; then + HOMEBREW_NO_ENV_HINTS=1 brew autoremove > /dev/null 2>&1 || true + 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 [[ ${#post_success_items[@]} -gt 0 ]]; then + remove_apps_from_dock "${post_success_items[@]}" 2> /dev/null || true + refresh_launch_services_after_uninstall 2> /dev/null || true + fi + ) > /dev/null 2>&1 & fi _cleanup_sudo_keepalive From 837df390a59bb24a3a5aedf2c809649ddc85ce9d Mon Sep 17 00:00:00 2001 From: tw93 Date: Thu, 26 Feb 2026 19:42:34 +0800 Subject: [PATCH 14/69] fix(purge): rewrite spinner for /dev/tty reliability and fix cursor position - Capture terminal width in parent before forking; avoids unreliable tput calls inside the background subshell - Write spinner output directly to /dev/tty to prevent stdout state contention between parent and background processes - Use \033[2K (erase entire line) instead of \033[K (erase to EOL) - Add handle_interrupt() so Ctrl-C cleanly stops spinner, restores cursor, and exits 130 - cleanup_monitor now writes \r\033[2K\n so subsequent output starts on a clean line rather than on the cleared spinner line --- bin/purge.sh | 109 ++++++++++++++++++++++++--------------------------- 1 file changed, 52 insertions(+), 57 deletions(-) diff --git a/bin/purge.sh b/bin/purge.sh index 58e9f0f..ba8c746 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 @@ -212,15 +209,13 @@ perform_purge() { 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") - fi + local summary_line="Space freed: ${GREEN}${freed_gb}GB${NC}" + [[ $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 From d13c0927a6781ac107963ed9a90d038bf8d1d10c Mon Sep 17 00:00:00 2001 From: tw93 Date: Thu, 26 Feb 2026 19:42:42 +0800 Subject: [PATCH 15/69] feat(purge): add confirm dialog, two-pass column alignment, adaptive footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add confirm_purge_cleanup() to show item count + size and require explicit Enter/y confirmation before any deletion - Two-pass layout in clean_project_artifacts: pass 1 collects data, pre-scan finds max path and artifact widths, pass 2 formats with consistent column alignment across all rows - Adaptive footer hints in select_purge_categories degrade gracefully on narrow terminals (full → reduced → minimal) - Use printf '\033[J' to clear stale content when list height shrinks - Guard empty-array expansions with ${arr[*]-} for set -u safety - Add BATS tests for confirm_purge_cleanup (Enter confirm, ESC cancel) --- lib/clean/project.sh | 220 +++++++++++++++++++++++++++++++++++++------ tests/purge.bats | 22 +++++ 2 files changed, 214 insertions(+), 28 deletions(-) diff --git a/lib/clean/project.sh b/lib/clean/project.sh index deeb5dd..268f053 100644 --- a/lib/clean/project.sh +++ b/lib/clean/project.sh @@ -633,7 +633,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 +655,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 @@ -767,6 +793,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 +893,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 +1107,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 +1186,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 +1291,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,9 +1320,29 @@ 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++)) + 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 for idx in "${selected_indices[@]}"; do diff --git a/tests/purge.bats b/tests/purge.bats index c16e662..49337c7 100644 --- a/tests/purge.bats +++ b/tests/purge.bats @@ -308,6 +308,28 @@ EOF [ "$status" -eq 0 ] } +@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 "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 + + [ "$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" From 7a6da7b4193b0f88a6b0f2e9028ff9c26960428b Mon Sep 17 00:00:00 2001 From: tw93 Date: Thu, 26 Feb 2026 19:42:47 +0800 Subject: [PATCH 16/69] fix(uninstall): auto-exit return-to-list prompt after configurable timeout Replace blocking read with a timed read (default 3 s) so the prompt exits automatically instead of waiting forever. Timeout is configurable via MOLE_UNINSTALL_RETURN_PROMPT_TIMEOUT_SEC. Use a read_ok flag to distinguish timeout (exit) from Enter (return to list). --- bin/uninstall.sh | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/bin/uninstall.sh b/bin/uninstall.sh index 5c2a436..9d8960d 100755 --- a/bin/uninstall.sh +++ b/bin/uninstall.sh @@ -950,12 +950,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 From 6c0aa87389b75cb1dc03bdfb70e1bf71aaa0d6ca Mon Sep 17 00:00:00 2001 From: tw93 Date: Thu, 26 Feb 2026 19:42:52 +0800 Subject: [PATCH 17/69] fix(clean): guard DRY_RUN against unbound variable in system.sh Change "$DRY_RUN" to "${DRY_RUN:-}" so the check is safe under set -euo pipefail when DRY_RUN is not exported by the caller (e.g. unit tests that source lib/clean/system.sh directly without going through bin/clean.sh which initialises the variable). --- lib/clean/system.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/clean/system.sh b/lib/clean/system.sh index 5cc5e93..982ac32 100644 --- a/lib/clean/system.sh +++ b/lib/clean/system.sh @@ -186,7 +186,7 @@ clean_deep_system() { done < <(sudo find "$mem_reports_dir" -type f -mtime +30 -print0 2> /dev/null || true) if [[ "$file_count" -gt 0 ]]; then - if [[ "$DRY_RUN" != "true" ]]; then + if [[ "${DRY_RUN:-}" != "true" ]]; then if safe_sudo_find_delete "$mem_reports_dir" "*" "30" "f"; then mem_cleaned=1 fi From 2eb0f2b9b3d5cee9d0e66fb8b4102f32f20c1582 Mon Sep 17 00:00:00 2001 From: tw93 Date: Thu, 26 Feb 2026 19:42:58 +0800 Subject: [PATCH 18/69] fix(log): generate summary divider width from terminal width Replace hardcoded 70-char string with a dynamically generated divider capped at terminal width (max 70) so the separator fits narrow terminals instead of overflowing them. --- lib/core/log.sh | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 "" From 9056ce5b38ce2b5b082c4604f98cb448cb9e6869 Mon Sep 17 00:00:00 2001 From: tw93 Date: Fri, 27 Feb 2026 09:53:18 +0800 Subject: [PATCH 19/69] fix(status): adapt single-column width calculation --- cmd/status/main.go | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) 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) From 369d3b884050d69ff0b5302b10d5492aa23a6122 Mon Sep 17 00:00:00 2001 From: tw93 Date: Fri, 27 Feb 2026 09:53:21 +0800 Subject: [PATCH 20/69] fix(status): wrap header and card output on narrow terminals --- cmd/status/view.go | 51 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/cmd/status/view.go b/cmd/status/view.go index c868bc1..9f2a492 100644 --- a/cmd/status/view.go +++ b/cmd/status/view.go @@ -131,6 +131,10 @@ type cardData struct { } func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int, catHidden bool) (string, string) { + if termWidth <= 0 { + termWidth = 80 + } + title := titleStyle.Render("Status") scoreStyle := getScoreStyle(m.HealthScore) @@ -169,7 +173,20 @@ func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int infoParts = append(infoParts, subtleStyle.Render("up "+m.Uptime)) } - headerLine := title + " " + scoreText + " " + strings.Join(infoParts, " · ") + headLeft := title + " " + scoreText + baseLines := wrapToWidth(headLeft, termWidth) + headerLines := append([]string{}, baseLines...) + if len(infoParts) > 0 { + headRight := strings.Join(infoParts, " · ") + combined := headLeft + " " + headRight + if lipgloss.Width(combined) <= termWidth { + headerLines = wrapToWidth(combined, termWidth) + } else { + wrappedRight := wrapToWidth(headRight, termWidth) + headerLines = append(baseLines, wrappedRight...) + } + } + headerLine := lipgloss.JoinVertical(lipgloss.Left, headerLines...) // Show cat unless hidden - render mole centered below header var mole string @@ -596,18 +613,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 { From 84363779222aa577f85520300a45d1a51cad5784 Mon Sep 17 00:00:00 2001 From: tw93 Date: Fri, 27 Feb 2026 09:53:24 +0800 Subject: [PATCH 21/69] test(status): add narrow-width rendering coverage --- cmd/status/view_test.go | 42 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/cmd/status/view_test.go b/cmd/status/view_test.go index fbde8e9..829d770 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,46 @@ 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 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 TestModelViewErrorRendersSingleMole(t *testing.T) { m := model{ width: 120, From 0341ae66485fbe95539b2af89cd4b26b6700401f Mon Sep 17 00:00:00 2001 From: tw93 Date: Fri, 27 Feb 2026 09:53:27 +0800 Subject: [PATCH 22/69] perf(clean): speed up memory report size pre-scan --- lib/clean/system.sh | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/lib/clean/system.sh b/lib/clean/system.sh index 982ac32..b23081b 100644 --- a/lib/clean/system.sh +++ b/lib/clean/system.sh @@ -178,12 +178,13 @@ 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:-}" != "true" ]]; then From 15f698c60671190159b28334c35685032c9874e2 Mon Sep 17 00:00:00 2001 From: tw93 Date: Fri, 27 Feb 2026 10:02:06 +0800 Subject: [PATCH 23/69] fix(status): hide swap size text when card is narrow --- cmd/status/view.go | 14 +++++++++++--- cmd/status/view_test.go | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/cmd/status/view.go b/cmd/status/view.go index 9f2a492..402cc80 100644 --- a/cmd/status/view.go +++ b/cmd/status/view.go @@ -266,7 +266,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 @@ -287,8 +287,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 @@ -416,7 +424,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), diff --git a/cmd/status/view_test.go b/cmd/status/view_test.go index 829d770..4531786 100644 --- a/cmd/status/view_test.go +++ b/cmd/status/view_test.go @@ -976,6 +976,44 @@ func TestRenderCardWrapsOnNarrowWidth(t *testing.T) { } } +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, From f4118dc88336e89e0a7b484d51f2675c5fd13577 Mon Sep 17 00:00:00 2001 From: tw93 Date: Fri, 27 Feb 2026 10:02:47 +0800 Subject: [PATCH 24/69] fix(status): hide os and uptime in compact header --- cmd/status/view.go | 5 +++-- cmd/status/view_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/cmd/status/view.go b/cmd/status/view.go index 402cc80..a534028 100644 --- a/cmd/status/view.go +++ b/cmd/status/view.go @@ -134,6 +134,7 @@ func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int if termWidth <= 0 { termWidth = 80 } + compactHeader := termWidth <= 80 title := titleStyle.Render("Status") @@ -166,10 +167,10 @@ func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int if m.Hardware.RefreshRate != "" { infoParts = append(infoParts, m.Hardware.RefreshRate) } - if m.Hardware.OSVersion != "" { + if !compactHeader && m.Hardware.OSVersion != "" { infoParts = append(infoParts, m.Hardware.OSVersion) } - if m.Uptime != "" { + if !compactHeader && m.Uptime != "" { infoParts = append(infoParts, subtleStyle.Render("up "+m.Uptime)) } diff --git a/cmd/status/view_test.go b/cmd/status/view_test.go index 4531786..6dc06d4 100644 --- a/cmd/status/view_test.go +++ b/cmd/status/view_test.go @@ -958,6 +958,30 @@ func TestRenderHeaderWrapsOnNarrowWidth(t *testing.T) { } } +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 TestRenderCardWrapsOnNarrowWidth(t *testing.T) { card := cardData{ icon: iconCPU, From bbe3d3f284e88cbb57ec2d6526b3550f56b7e7f7 Mon Sep 17 00:00:00 2001 From: tw93 Date: Fri, 27 Feb 2026 10:07:44 +0800 Subject: [PATCH 25/69] fix(status): keep compact header single-line on tight widths --- cmd/status/view.go | 38 +++++++++++++++++++++++++------------- cmd/status/view_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 13 deletions(-) diff --git a/cmd/status/view.go b/cmd/status/view.go index a534028..514473f 100644 --- a/cmd/status/view.go +++ b/cmd/status/view.go @@ -167,27 +167,39 @@ func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int if m.Hardware.RefreshRate != "" { infoParts = append(infoParts, m.Hardware.RefreshRate) } + optionalInfoParts := []string{} if !compactHeader && m.Hardware.OSVersion != "" { - infoParts = append(infoParts, m.Hardware.OSVersion) + optionalInfoParts = append(optionalInfoParts, m.Hardware.OSVersion) } if !compactHeader && m.Uptime != "" { - infoParts = append(infoParts, subtleStyle.Render("up "+m.Uptime)) + optionalInfoParts = append(optionalInfoParts, subtleStyle.Render("up "+m.Uptime)) } headLeft := title + " " + scoreText - baseLines := wrapToWidth(headLeft, termWidth) - headerLines := append([]string{}, baseLines...) - if len(infoParts) > 0 { - headRight := strings.Join(infoParts, " · ") - combined := headLeft + " " + headRight - if lipgloss.Width(combined) <= termWidth { - headerLines = wrapToWidth(combined, termWidth) - } else { - wrappedRight := wrapToWidth(headRight, termWidth) - headerLines = append(baseLines, wrappedRight...) + 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] + } + } } } - headerLine := lipgloss.JoinVertical(lipgloss.Left, headerLines...) // Show cat unless hidden - render mole centered below header var mole string diff --git a/cmd/status/view_test.go b/cmd/status/view_test.go index 6dc06d4..beb99f3 100644 --- a/cmd/status/view_test.go +++ b/cmd/status/view_test.go @@ -982,6 +982,34 @@ func TestRenderHeaderHidesOSAndUptimeOnNarrowWidth(t *testing.T) { } } +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, From 194fe871e5f6ade50aa38ebb60fc00685acf0edf Mon Sep 17 00:00:00 2001 From: tw93 Date: Fri, 27 Feb 2026 10:07:49 +0800 Subject: [PATCH 26/69] chore(release): bump to 1.28.0 and refresh security audit --- SECURITY_AUDIT.md | 71 +++++++++++++++++------------------------------ mole | 2 +- 2 files changed, 27 insertions(+), 46 deletions(-) diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md index 54fb445..20fa513 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 @@ -145,6 +122,10 @@ Security-sensitive cleanup paths are covered by BATS regression tests, including - `tests/clean_dev_caches.bats` - `tests/clean_system_maintenance.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: - `bats tests/clean_core.bats` passed (12/12) diff --git a/mole b/mole index ccdd3e6..a30ae72 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.0" MOLE_TAGLINE="Deep clean and optimize your Mac." is_touchid_configured() { From a9433e4acd722a7206eb6031e2adf94f93a1c70a Mon Sep 17 00:00:00 2001 From: tw93 Date: Fri, 27 Feb 2026 11:18:53 +0800 Subject: [PATCH 27/69] fix: preserve interrupt semantics and restore purge traps --- SECURITY_AUDIT.md | 3 +++ lib/clean/project.sh | 22 ++++++++++++++++++++ lib/core/file_ops.sh | 5 +++++ tests/core_safe_functions.bats | 13 ++++++++++++ tests/purge.bats | 38 ++++++++++++++++++++++++++++++++++ 5 files changed, 81 insertions(+) diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md index 20fa513..fcf73d3 100644 --- a/SECURITY_AUDIT.md +++ b/SECURITY_AUDIT.md @@ -121,6 +121,8 @@ 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. @@ -132,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/lib/clean/project.sh b/lib/clean/project.sh index 268f053..2f460cf 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() { diff --git a/lib/core/file_ops.sh b/lib/core/file_ops.sh index 7415f6a..fc5cfee 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" 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/purge.bats b/tests/purge.bats index 49337c7..9e0ea96 100644 --- a/tests/purge.bats +++ b/tests/purge.bats @@ -308,6 +308,44 @@ 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 From 013549ad256dcf1b660425274bd86f8786583895 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Fri, 27 Feb 2026 22:52:38 +0800 Subject: [PATCH 28/69] fix: temporarily disable pipefail to prevent process substitution failures during cleanup operations --- lib/clean/user.sh | 10 ++++++++++ lib/core/file_ops.sh | 24 ++++++++++++++++++++++++ lib/optimize/maintenance.sh | 12 ++++++++++++ lib/optimize/tasks.sh | 12 ++++++++++++ 4 files changed, 58 insertions(+) diff --git a/lib/clean/user.sh b/lib/clean/user.sh index 8a6f585..52fd322 100644 --- a/lib/clean/user.sh +++ b/lib/clean/user.sh @@ -793,6 +793,12 @@ clean_application_support_logs() { [[ "$total_apps" =~ ^[0-9]+$ ]] || total_apps=0 local last_progress_update last_progress_update=$(get_epoch_seconds) + # Temporarily disable pipefail to prevent process substitution failures from interrupting the scan + local pipefail_was_set=false + if [[ -o pipefail ]]; then + pipefail_was_set=true + set +o pipefail + fi for app_dir in ~/Library/Application\ Support/*; do [[ -d "$app_dir" ]] || continue local app_name @@ -917,6 +923,10 @@ clean_application_support_logs() { 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 diff --git a/lib/core/file_ops.sh b/lib/core/file_ops.sh index fc5cfee..e99e628 100644 --- a/lib/core/file_ops.sh +++ b/lib/core/file_ops.sh @@ -434,6 +434,13 @@ safe_find_delete() { find_args+=("-mtime" "+$age_days") fi + # Temporarily disable pipefail to prevent process substitution failures from interrupting + local pipefail_was_set=false + if [[ -o pipefail ]]; then + pipefail_was_set=true + set +o pipefail + fi + # Iterate results to respect should_protect_path while IFS= read -r -d '' match; do if should_protect_path "$match"; then @@ -442,6 +449,11 @@ safe_find_delete() { safe_remove "$match" true || true done < <(command find "$base_dir" "${find_args[@]}" -print0 2> /dev/null || true) + # Restore pipefail if it was previously set + if [[ "$pipefail_was_set" == "true" ]]; then + set -o pipefail + fi + return 0 } @@ -481,6 +493,13 @@ safe_sudo_find_delete() { find_args+=("-mtime" "+$age_days") fi + # Temporarily disable pipefail to prevent process substitution failures from interrupting + local pipefail_was_set=false + if [[ -o pipefail ]]; then + pipefail_was_set=true + set +o pipefail + fi + # Iterate results to respect should_protect_path while IFS= read -r -d '' match; do if should_protect_path "$match"; then @@ -489,6 +508,11 @@ safe_sudo_find_delete() { safe_sudo_remove "$match" || true done < <(sudo find "$base_dir" "${find_args[@]}" -print0 2> /dev/null || true) + # Restore pipefail if it was previously set + if [[ "$pipefail_was_set" == "true" ]]; then + set -o pipefail + fi + return 0 } diff --git a/lib/optimize/maintenance.sh b/lib/optimize/maintenance.sh index 0cad6b3..4e5dc0c 100644 --- a/lib/optimize/maintenance.sh +++ b/lib/optimize/maintenance.sh @@ -11,6 +11,13 @@ fix_broken_preferences() { local broken_count=0 + # Temporarily disable pipefail to prevent process substitution failures from interrupting + local pipefail_was_set=false + if [[ -o pipefail ]]; then + pipefail_was_set=true + set +o pipefail + fi + while IFS= read -r plist_file; do [[ -f "$plist_file" ]] || continue @@ -49,5 +56,10 @@ fix_broken_preferences() { done < <(command find "$byhost_dir" -name "*.plist" -type f 2> /dev/null || true) fi + # Restore pipefail if it was previously set + if [[ "$pipefail_was_set" == "true" ]]; then + set -o pipefail + fi + echo "$broken_count" } diff --git a/lib/optimize/tasks.sh b/lib/optimize/tasks.sh index 6260263..42d27e6 100644 --- a/lib/optimize/tasks.sh +++ b/lib/optimize/tasks.sh @@ -191,12 +191,24 @@ opt_saved_state_cleanup() { local state_dir="$HOME/Library/Saved Application State" if [[ -d "$state_dir" ]]; then + # Temporarily disable pipefail to prevent process substitution failures from interrupting + local pipefail_was_set=false + if [[ -o pipefail ]]; then + pipefail_was_set=true + set +o pipefail + fi + while IFS= read -r -d '' state_path; do if should_protect_path "$state_path"; then continue fi safe_remove "$state_path" true > /dev/null 2>&1 || true done < <(command find "$state_dir" -type d -name "*.savedState" -mtime "+$MOLE_SAVED_STATE_AGE_DAYS" -print0 2> /dev/null) + + # Restore pipefail if it was previously set + if [[ "$pipefail_was_set" == "true" ]]; then + set -o pipefail + fi fi opt_msg "App saved states optimized" From a8065dfbaec19717e1e8590096c7727b4c1795d9 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Fri, 27 Feb 2026 23:20:04 +0800 Subject: [PATCH 29/69] fix(application_support_logs): prevent process substitution failures by temporarily disabling pipefail --- lib/clean/user.sh | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/lib/clean/user.sh b/lib/clean/user.sh index 52fd322..a0a61ac 100644 --- a/lib/clean/user.sh +++ b/lib/clean/user.sh @@ -789,21 +789,23 @@ clean_application_support_logs() { shopt -s nullglob local app_count=0 local total_apps - 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 - last_progress_update=$(get_epoch_seconds) - # Temporarily disable pipefail to prevent process substitution failures from interrupting the scan + # 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 + last_progress_update=$(get_epoch_seconds) for app_dir in ~/Library/Application\ Support/*; do [[ -d "$app_dir" ]] || continue local app_name app_name=$(basename "$app_dir") - ((app_count++)) + ((app_count++)) || true 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:]') @@ -829,12 +831,12 @@ clean_application_support_logs() { while IFS= read -r -d '' item; do [[ -e "$item" ]] || continue item_found=true - ((candidate_item_count++)) + ((candidate_item_count++)) || true 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 += item_size_bytes)) + ((candidate_size_bytes += item_size_bytes)) || true else candidate_size_partial=true fi @@ -860,9 +862,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)) + ((total_size_bytes += candidate_size_bytes)) || true [[ "$candidate_size_partial" == "true" ]] && total_size_partial=true - ((cleaned_count++)) + ((cleaned_count++)) || true found_any=true fi fi @@ -884,12 +886,12 @@ clean_application_support_logs() { while IFS= read -r -d '' item; do [[ -e "$item" ]] || continue item_found=true - ((candidate_item_count++)) + ((candidate_item_count++)) || true 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 += item_size_bytes)) + ((candidate_size_bytes += item_size_bytes)) || true else candidate_size_partial=true fi @@ -915,9 +917,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)) + ((total_size_bytes += candidate_size_bytes)) || true [[ "$candidate_size_partial" == "true" ]] && total_size_partial=true - ((cleaned_count++)) + ((cleaned_count++)) || true found_any=true fi fi From c129591cf49ce34d992c8d308025eab9e8055539 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Fri, 27 Feb 2026 23:44:10 +0800 Subject: [PATCH 30/69] fix: remove redundant pipefail blocks and fix arithmetic bug This commit addresses the issue #506 where mole would exit prematurely during application support scanning. Changes: 1. Remove 4 redundant pipefail disable/restore blocks: - safe_find_delete() in lib/core/file_ops.sh - safe_sudo_find_delete() in lib/core/file_ops.sh - fix_broken_preferences() in lib/optimize/maintenance.sh - opt_saved_state_cleanup() in lib/optimize/tasks.sh These were unnecessary because process substitution (< <(cmd)) is not affected by pipefail - only real pipelines (cmd1 | cmd2) are. 2. Fix real bug in fix_broken_preferences(): - Add || true to ((broken_count++)) on lines 35 and 55 - This prevents set -e from exiting when broken_count starts at 0 Fixes #506 --- lib/core/file_ops.sh | 24 ------------------------ lib/optimize/maintenance.sh | 16 ++-------------- lib/optimize/tasks.sh | 12 ------------ 3 files changed, 2 insertions(+), 50 deletions(-) diff --git a/lib/core/file_ops.sh b/lib/core/file_ops.sh index e99e628..fc5cfee 100644 --- a/lib/core/file_ops.sh +++ b/lib/core/file_ops.sh @@ -434,13 +434,6 @@ safe_find_delete() { find_args+=("-mtime" "+$age_days") fi - # Temporarily disable pipefail to prevent process substitution failures from interrupting - local pipefail_was_set=false - if [[ -o pipefail ]]; then - pipefail_was_set=true - set +o pipefail - fi - # Iterate results to respect should_protect_path while IFS= read -r -d '' match; do if should_protect_path "$match"; then @@ -449,11 +442,6 @@ safe_find_delete() { safe_remove "$match" true || true done < <(command find "$base_dir" "${find_args[@]}" -print0 2> /dev/null || true) - # Restore pipefail if it was previously set - if [[ "$pipefail_was_set" == "true" ]]; then - set -o pipefail - fi - return 0 } @@ -493,13 +481,6 @@ safe_sudo_find_delete() { find_args+=("-mtime" "+$age_days") fi - # Temporarily disable pipefail to prevent process substitution failures from interrupting - local pipefail_was_set=false - if [[ -o pipefail ]]; then - pipefail_was_set=true - set +o pipefail - fi - # Iterate results to respect should_protect_path while IFS= read -r -d '' match; do if should_protect_path "$match"; then @@ -508,11 +489,6 @@ safe_sudo_find_delete() { safe_sudo_remove "$match" || true done < <(sudo find "$base_dir" "${find_args[@]}" -print0 2> /dev/null || true) - # Restore pipefail if it was previously set - if [[ "$pipefail_was_set" == "true" ]]; then - set -o pipefail - fi - return 0 } diff --git a/lib/optimize/maintenance.sh b/lib/optimize/maintenance.sh index 4e5dc0c..c1f004c 100644 --- a/lib/optimize/maintenance.sh +++ b/lib/optimize/maintenance.sh @@ -11,13 +11,6 @@ fix_broken_preferences() { local broken_count=0 - # Temporarily disable pipefail to prevent process substitution failures from interrupting - local pipefail_was_set=false - if [[ -o pipefail ]]; then - pipefail_was_set=true - set +o pipefail - fi - while IFS= read -r plist_file; do [[ -f "$plist_file" ]] || continue @@ -32,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++)) || true done < <(command find "$prefs_dir" -maxdepth 1 -name "*.plist" -type f 2> /dev/null || true) # Check ByHost preferences. @@ -52,14 +45,9 @@ 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++)) || true done < <(command find "$byhost_dir" -name "*.plist" -type f 2> /dev/null || true) fi - # Restore pipefail if it was previously set - if [[ "$pipefail_was_set" == "true" ]]; then - set -o pipefail - fi - echo "$broken_count" } diff --git a/lib/optimize/tasks.sh b/lib/optimize/tasks.sh index 42d27e6..6260263 100644 --- a/lib/optimize/tasks.sh +++ b/lib/optimize/tasks.sh @@ -191,24 +191,12 @@ opt_saved_state_cleanup() { local state_dir="$HOME/Library/Saved Application State" if [[ -d "$state_dir" ]]; then - # Temporarily disable pipefail to prevent process substitution failures from interrupting - local pipefail_was_set=false - if [[ -o pipefail ]]; then - pipefail_was_set=true - set +o pipefail - fi - while IFS= read -r -d '' state_path; do if should_protect_path "$state_path"; then continue fi safe_remove "$state_path" true > /dev/null 2>&1 || true done < <(command find "$state_dir" -type d -name "*.savedState" -mtime "+$MOLE_SAVED_STATE_AGE_DAYS" -print0 2> /dev/null) - - # Restore pipefail if it was previously set - if [[ "$pipefail_was_set" == "true" ]]; then - set -o pipefail - fi fi opt_msg "App saved states optimized" From 922f5f1fc9ca78c8fec78e7fdacb49c1a923ca7a Mon Sep 17 00:00:00 2001 From: Tw93 Date: Fri, 27 Feb 2026 23:46:40 +0800 Subject: [PATCH 31/69] feat: add cleanup for aerial wallpaper videos Add support for cleaning old aerial wallpaper videos in: /Users/user/Library/Application Support/com.apple.wallpaper/aerials/videos These video files can consume significant disk space (up to 50GB+) and are safe to remove - macOS will re-download them on demand. Uses MOLE_SUPPORT_CACHE_AGE_DAYS (default 30 days) to avoid removing recently used wallpapers. Closes #508 --- lib/clean/user.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/clean/user.sh b/lib/clean/user.sh index a0a61ac..ef441a5 100644 --- a/lib/clean/user.sh +++ b/lib/clean/user.sh @@ -420,6 +420,12 @@ 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). + local wallpaper_videos_dir="$HOME/Library/Application Support/com.apple.wallpaper/aerials/videos" + if [[ -d "$wallpaper_videos_dir" && ! -L "$wallpaper_videos_dir" ]]; then + safe_find_delete "$wallpaper_videos_dir" "*" "$support_age_days" "f" || true + fi + # 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" From 5710679809641ce64561c8e0a8d0a6ec6344a823 Mon Sep 17 00:00:00 2001 From: tw93 Date: Sat, 28 Feb 2026 09:35:08 +0800 Subject: [PATCH 32/69] fix: skip Time Machine checks when not configured, close #510 Use a fast `defaults read` pre-check before spawning any tmutil process. On machines without Time Machine configured the check returns instantly, avoiding the spinner and the 2-3s tmutil timeout. Affected locations: - lib/clean/system.sh: clean_time_machine_failed_backups() - lib/clean/system.sh: clean_local_snapshots() - lib/clean/user.sh: local snapshot hint in system hints --- lib/clean/system.sh | 9 +++++++++ lib/clean/user.sh | 8 +++----- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/lib/clean/system.sh b/lib/clean/system.sh index b23081b..9a8d664 100644 --- a/lib/clean/system.sh +++ b/lib/clean/system.sh @@ -215,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 @@ -396,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 ef441a5..ae48055 100644 --- a/lib/clean/user.sh +++ b/lib/clean/user.sh @@ -421,10 +421,7 @@ clean_support_app_data() { fi # Clean old aerial wallpaper videos (can be large, safe to remove). - local wallpaper_videos_dir="$HOME/Library/Application Support/com.apple.wallpaper/aerials/videos" - if [[ -d "$wallpaper_videos_dir" && ! -L "$wallpaper_videos_dir" ]]; then - safe_find_delete "$wallpaper_videos_dir" "*" "$support_age_days" "f" || true - fi + 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 @@ -1034,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 From c7563351b96d147d1fb12e54a085073cb645436b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 28 Feb 2026 01:35:46 +0000 Subject: [PATCH 33/69] chore: update contributors [skip ci] --- CONTRIBUTORS.svg | 174 +++++++++++++++++++++++------------------------ 1 file changed, 87 insertions(+), 87 deletions(-) diff --git a/CONTRIBUTORS.svg b/CONTRIBUTORS.svg index 40eeff4..306756c 100644 --- a/CONTRIBUTORS.svg +++ b/CONTRIBUTORS.svg @@ -1,4 +1,4 @@ - + @@ -79,6 +79,17 @@ + + + + + + + + Sizk + + + @@ -89,7 +100,7 @@ rubnogueira - + @@ -100,7 +111,7 @@ biplavbarua - + @@ -111,7 +122,7 @@ bsisduck - + @@ -122,51 +133,7 @@ spider-yamet - - - - - - - - - jimmystridh - - - - - - - - - - fte-jjmartres - - - - - - - - - - - Else00 - - - - - - - - - - - carolyn-sun - - - @@ -177,18 +144,73 @@ Angelk90 + + + + + + + + + jimmystridh + + + + + + + + + + + fte-jjmartres + + + + + + + + + + + Else00 + + + + + + + + + + carolyn-sun + + + + + + + + + + + andmev + + + - + ndbroadbent - + @@ -199,7 +221,7 @@ ppauel - + @@ -210,18 +232,7 @@ shakeelmohamed - - - - - - - - - Sizk - - - + @@ -232,7 +243,7 @@ Harsh-Kapoorr - + @@ -243,7 +254,7 @@ thijsvanhal - + @@ -254,7 +265,7 @@ TomP0 - + @@ -265,7 +276,7 @@ yuzeguitarist - + @@ -276,7 +287,7 @@ zeldrisho - + @@ -287,7 +298,7 @@ bikraj2 - + @@ -298,7 +309,7 @@ bunizao - + @@ -309,7 +320,7 @@ rans0 - + @@ -320,7 +331,7 @@ frozturk - + @@ -331,7 +342,7 @@ huyixi - + @@ -342,7 +353,7 @@ purofle - + @@ -353,7 +364,7 @@ yamamel - + @@ -364,7 +375,7 @@ NanmiCoder - + @@ -375,17 +386,6 @@ imnotnoahhh - - - - - - - - - andmev - - From e8e4f91bf058a4a4dd4f3217c23779ce3b4cb1ac Mon Sep 17 00:00:00 2001 From: tw93 Date: Sat, 28 Feb 2026 09:49:16 +0800 Subject: [PATCH 34/69] fix: use xattr -c without -r flag for compatibility, close #509 --- install.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/install.sh b/install.sh index a75fe09..4854e9b 100755 --- a/install.sh +++ b/install.sh @@ -506,7 +506,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 From 310abb25102e42f3d9e24763bd08d0d37244261c Mon Sep 17 00:00:00 2001 From: tw93 Date: Sat, 28 Feb 2026 09:49:16 +0800 Subject: [PATCH 35/69] fix: clean aerial wallpaper videos directly without 30d limit and bypass app protection wildcard, close #508 --- lib/core/app_protection.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/core/app_protection.sh b/lib/core/app_protection.sh index 1180152..8b826f3 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*" From 68c9e932190e7726210ef3921a1d8d745fa58273 Mon Sep 17 00:00:00 2001 From: tw93 Date: Sat, 28 Feb 2026 09:49:21 +0800 Subject: [PATCH 36/69] docs: clarify update instructions for script vs homebrew testers --- AGENTS.md | 380 +++++++++++++++++++++++++++++++++ CLAUDE.md | 619 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 999 insertions(+) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d3e866d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,380 @@ +# AGENTS.md - Mole Project Knowledge Base + +## Project Identity + +**Mole** is a hybrid macOS cleanup tool combining: + +- **Bash 3.2** for system operations and orchestration (`lib/`, `bin/`) +- **Go 1.24+** for high-performance TUI components (`cmd/analyze`, `cmd/status`) +- **Safety-first philosophy**: User data loss is unacceptable + +**Mission**: Deep clean and optimize macOS while maintaining strict safety boundaries. + +--- + +## Architecture Overview + +```text +mole/ # Main CLI entrypoint (menu + routing) +├── mo # Lightweight wrapper, exec's mole +├── install.sh # Standalone installer/updater +├── bin/ # Command entry points (thin wrappers) +│ ├── clean.sh # Deep cleanup orchestrator +│ ├── uninstall.sh # App removal with leftover detection +│ ├── optimize.sh # Cache rebuild + service refresh +│ ├── purge.sh # Project artifact cleanup +│ ├── touchid.sh # Touch ID sudo enabler +│ ├── analyze.sh # Disk usage explorer wrapper +│ ├── status.sh # System health dashboard wrapper +│ ├── installer.sh # Core installation logic +│ └── completion.sh # Shell completion support +├── lib/ # Reusable shell logic (see lib/AGENTS.md) +│ ├── core/ # base.sh, log.sh, sudo.sh, ui.sh +│ ├── clean/ # Cleanup modules (user, apps, brew, system) +│ ├── optimize/ # Optimization modules +│ ├── check/ # Health check modules +│ ├── manage/ # Management utilities +│ ├── ui/ # UI components (balloons, spinners) +│ └── uninstall/ # Uninstallation logic +├── cmd/ # Go applications (see cmd/AGENTS.md) +│ ├── analyze/ # Disk analysis tool (Bubble Tea TUI) +│ └── status/ # Real-time monitoring (Bubble Tea TUI) +├── scripts/ # Build and test automation +│ ├── build.sh # Cross-platform Go builds +│ ├── test.sh # Main test runner (shell + go + BATS) +│ └── check.sh # Format + lint + optimization score +└── tests/ # BATS integration tests (see tests/AGENTS.md) +``` + +### Entry Point Flow + +1. **User invokes**: `mo clean` or `./mole clean` +2. **`mole` script**: Routes to `bin/clean.sh` via `exec` +3. **`bin/clean.sh`**: Sources `lib/clean/*.sh` modules +4. **Cleanup modules**: Call `safe_*` helpers from `lib/core/base.sh` +5. **Logging/UI**: Handled by `lib/core/log.sh` + `lib/core/ui.sh` + +--- + +## Safety Philosophy (CRITICAL) + +### NEVER Do These + +- Run `rm -rf` or any raw deletion commands +- Delete files without checking protection lists (`is_protected()`, `is_whitelisted()`) +- Modify system-critical paths (`/System`, `/Library/Apple`, `/usr/bin`) +- Remove `--prefix`/`--config` flags from `install.sh` +- Commit or push to remote without explicit user request +- Add `Co-Authored-By` (AI attribution) in commit messages +- Change the 1s ESC key timeout in `lib/core/ui.sh` +- Touch `com.apple.*` LaunchAgents/Daemons +- Clean during active Time Machine backups + +### ALWAYS Do These + +- Use `safe_*` helpers (`safe_rm`, `safe_find_delete` from `lib/core/base.sh`) +- Validate paths before operations (`validate_path`, `is_protected`) +- Test with `MO_DRY_RUN=1` before destructive operations +- Run `./scripts/check.sh` before committing shell changes +- Use `gh` CLI for ALL GitHub operations (issues, PRs, releases) +- Respect whitelist files (`~/.config/mole/whitelist`) +- Review and update `SECURITY_AUDIT.md` when modifying cleanup logic + +### Protection Mechanisms + +| Function | Location | Purpose | +|----------|----------|---------| +| `is_protected()` | `lib/core/base.sh` | System path protection | +| `is_whitelisted()` | `lib/core/base.sh` | User whitelist check | +| `safe_rm()` | `lib/core/base.sh` | Validated deletion | +| `safe_find_delete()` | `lib/core/base.sh` | Protected find+delete | +| `validate_path()` | Various | Path safety checks | + +--- + +## Code Style & Conventions + +### Shell Scripts (Bash 3.2) + +**Formatting**: Run `./scripts/check.sh --format` before committing. + +```bash +# shfmt flags: -i 4 -ci -sr -w +# 4-space indent, case body indent, space after redirect + +# CORRECT +command 2> /dev/null +echo "hello" > file.txt + +case "$var" in + pattern) + action + ;; +esac + +# WRONG +command 2>/dev/null # Missing space after > +``` + +**Naming Conventions**: + +- Variables: `lowercase_with_underscores` +- Functions: `verb_noun` (e.g., `clean_caches`, `get_size`) +- Constants: `UPPERCASE_WITH_UNDERSCORES` + +**Error Handling**: + +```bash +set -euo pipefail # Mandatory at top of files + +# Always quote variables +"$var" not $var + +# Use [[ instead of [ +if [[ -f "$file" ]]; then + ... +fi +``` + +**BSD/macOS Commands**: Use BSD-style flags, not GNU. + +```bash +# CORRECT (BSD) +stat -f%z "$file" + +# WRONG (GNU) +stat --format=%s "$file" +``` + +### Go Code + +**Formatting**: Standard `gofmt` or `goimports -local github.com/tw93/Mole` + +**Build Tags**: Use for macOS-specific code + +```go +//go:build darwin +``` + +**Error Handling**: Never ignore errors + +```go +if err != nil { + return fmt.Errorf("operation failed: %w", err) +} +``` + +### Comments + +- **Language**: English only +- **Focus**: Explain "why" not "what" +- **Safety**: Document protection boundaries explicitly + +--- + +## Build & Test + +### Build Commands + +```bash +make build # Current platform +make release-amd64 # macOS Intel +make release-arm64 # macOS Apple Silicon +make clean # Remove artifacts +``` + +### Test Commands + +```bash +./scripts/test.sh # Full suite (recommended) +bats tests/clean.bats # Specific BATS test +go test -v ./cmd/... # Go tests only +bash -n lib/clean/*.sh # Syntax check +shellcheck lib/**/*.sh # Lint shell scripts +``` + +### CI/CD Pipeline + +**Triggers**: Push/PR to `main`, `dev` branches + +**Key Checks**: + +- **Auto-formatting**: CI commits `shfmt` + `goimports` fixes back to branch +- **Safety audits**: Scans for raw `rm -rf`, validates protection lists +- **Secret scanning**: Blocks hardcoded credentials +- **Optimization score**: `scripts/check.sh` enforces performance standards + +**Environment Requirements**: + +- Go 1.24.6 (pinned) +- macOS 14/15 runners +- bats-core for shell integration tests +- Performance limits via `MOLE_PERF_*` env vars + +--- + +## GitHub Workflow + +### Always Use `gh` CLI + +```bash +# Issues +gh issue view 123 +gh issue list + +# Pull Requests +gh pr view +gh pr diff +gh pr checkout 123 + +# NEVER use raw git for GitHub operations +# ❌ git log --oneline origin/main..HEAD +# ✅ gh pr view +``` + +### Branch Strategy + +- **PRs target `dev`**, not `main` +- Release process: `dev` → `main` → tagged release → Homebrew update +- **Do not create new branches by default**. Stay on the current branch unless the user explicitly requests branch creation. + +### Commit Grouping Strategy + +- When the user asks for commits, **group commits by requirement**, not by command sequence. +- Keep each commit scoped to one logical change (feature/fix/docs/release), and avoid mixing unrelated files. +- If one request contains multiple requirements, submit them as separate commits in dependency order. +- Before committing, review staged files to ensure they belong to the same requirement. + +### Suggesting Latest Version for Testing + +When a bug fix is done but not yet released, you can suggest users to install the latest version, but **must distinguish between install methods**: + +1. **For Script Users (`curl | bash`)**: + Users can directly run `mo update --nightly` to pull the latest unreleased `main` branch. + +2. **For Homebrew Users (`brew`)**: + Homebrew users **cannot** use `mo update --nightly` (it will throw an error). Reinstalling via brew (`brew uninstall mole && brew install mole`) also will NOT fetch the unreleased fix, because Brew follows official tags. + If they want to test immediately, suggest they `brew uninstall mole`, then reinstall using the official curl script. + +--- + +## Logging & Debugging + +### Log Files + +| File | Purpose | Control | +|------|---------|---------| +| `~/.config/mole/mole.log` | General log (INFO/SUCCESS/WARNING/ERROR) | Always | +| `~/.config/mole/mole_debug_session.log` | Debug session log | `MO_DEBUG=1` | +| `~/.config/mole/operations.log` | All file deletions | Disable: `MO_NO_OPLOG=1` | + +### Environment Variables + +- `MO_DRY_RUN=1`: Preview without execution +- `MO_DEBUG=1`: Verbose debug output +- `MO_NO_OPLOG=1`: Disable operation logging + +### Operation Log Format + +```text +[2024-01-26 10:30:15] [clean] REMOVED /Users/xxx/Library/Caches/com.old.app (15.2MB) +[2024-01-26 10:30:15] [clean] SKIPPED /Users/xxx/Library/Caches/com.protected.app (whitelist) +``` + +- **Actions**: `REMOVED` | `SKIPPED` | `FAILED` | `REBUILT` +- **Commands**: `clean` | `uninstall` | `optimize` | `purge` +- Auto-rotates at 5MB + +--- + +## Project-Specific Patterns + +### Installer Self-Containment + +`install.sh` duplicates core UI/logging functions to remain standalone during bootstrap. This is intentional—it cannot depend on `lib/`. + +### Atomic Update Flow + +1. User runs `mo update` +2. Fetches latest `install.sh` from GitHub +3. Installer modifies `SCRIPT_DIR` in installed `mole` to point to `~/.config/mole` +4. Ensures CLI always uses latest synced modules + +### Dual Entry Points + +- **`mole`**: Main executable (menu + routing) +- **`mo`**: Lightweight alias (recommended for users) + +Both are functionally identical (`mo` calls `exec mole`). + +--- + +## Communication Style + +- **Address user as "汤帅" (Tang Shuai)** in all responses +- **Be concise and technical** +- **Explain safety implications upfront** +- **Provide file:line references** for code locations +- **Suggest validation steps** (dry-run, syntax check) +- **Avoid em dashes** in responses + +### Responding to Bug Reports (English) + +When replying to users after fixing a bug: + +```markdown +Thanks for your feedback! This issue has been fixed. + +**Root cause**: [Brief explanation of the bug cause] + +You can install the latest version to test: + +\`\`\`bash +curl -fsSL https://raw.githubusercontent.com/tw93/mole/main/install.sh | bash -s latest +\`\`\` + +I'll publish a new official release soon. +``` + +**Key elements**: + +- Start with "Thanks for your feedback!" +- Explain root cause concisely +- Provide the install command for testing +- Mention a new release is coming soon + +--- + +## Quick Reference + +### Decision Tree + +| Task | Location | Pattern | +|------|----------|---------| +| Add cleanup logic | `lib/clean/.sh` | Use `safe_*` helpers | +| Create command | `bin/.sh` | Thin wrapper, source `lib/` | +| Add core utility | `lib/core/.sh` | Reusable functions | +| Build performance tool | `cmd//` | Go with build tags | +| Write tests | `tests/.bats` | BATS + isolated `$HOME` | + +### Common Pitfalls + +1. **Over-engineering**: Keep it simple +2. **Assuming paths exist**: Always check first +3. **Ignoring protection logic**: Data loss is unacceptable +4. **Breaking installer flags**: Keep `--prefix`/`--config` in `install.sh` +5. **Silent failures**: Log errors with actionable messages + +--- + +## Resources + +- **Root documentation**: `README.md`, `CONTRIBUTING.md`, `SECURITY_AUDIT.md` +- **Code guidelines**: `CLAUDE.md` (AI assistant instructions) +- **Subdirectory guides**: `lib/AGENTS.md`, `cmd/AGENTS.md`, `tests/AGENTS.md` +- **Protection lists**: Check `is_protected()` implementations +- **User config**: `~/.config/mole/` + +--- + +**Remember**: When in doubt, err on the side of safety. It's better to clean less than to risk user data. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9b33cef --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,619 @@ +# CLAUDE.md - Development Guide for Mole + +This guide provides AI coding assistants with essential commands, patterns, and conventions for working in the Mole codebase. + +**Quick reference**: Build/test commands • Safety rules • Architecture map • Code style + +**For deeper context**: See `AGENTS.md` (architecture overview) and subdirectory guides: `lib/AGENTS.md`, `cmd/AGENTS.md`, `tests/AGENTS.md` + +--- + +## Safety Checklist + +Before any operation: + +- Use `safe_*` helpers (never raw `rm -rf` or `find -delete`) +- Check protection: `is_protected()`, `is_whitelisted()` +- Test first: `MO_DRY_RUN=1 ./mole clean` +- Validate syntax: `bash -n ` +- Run tests: `./scripts/test.sh` + +## NEVER Do These + +- Run `rm -rf` or any raw deletion commands +- Delete files without checking protection lists +- Modify system-critical paths (e.g., `/System`, `/Library/Apple`) +- Remove installer flags `--prefix`/`--config` from `install.sh` +- **Commit code changes or run `git commit` unless the user explicitly asks you to commit** +- **Push to remote repositories with `git push` or create commits automatically** +- **Create new git branches unless the user explicitly asks for a new branch** +- **Add `Co-Authored-By` lines in commit messages** - never include AI attribution in commits +- **Reply to GitHub issues or PRs on behalf of the user** - only prepare responses for user review +- **Comment on GitHub issues or create pull requests without explicit user request** +- Run destructive operations without dry-run validation +- Use raw `git` commands when `gh` CLI is available +- **Change the ESC key timeout in `lib/core/ui.sh`** - The 1s timeout in `read_key()` is intentional, do NOT reduce it + +## ALWAYS Do These + +- Use `safe_*` helper functions for deletions (`safe_rm`, `safe_find_delete`) +- Respect whitelist files (e.g., `~/.config/mole/whitelist`) +- Check protection logic before cleanup operations +- Test with dry-run modes first +- Validate syntax before suggesting changes: `bash -n ` +- **Prioritize `gh` CLI for ALL GitHub operations** - Always use `gh` to fetch and manipulate GitHub data (issues, PRs, releases, comments, etc.) instead of raw git commands or web scraping +- **ONLY analyze and provide solutions** - When user asks about GitHub issues, read the content, investigate code, provide diagnostic information and fixes, but NEVER commit or comment without explicit request +- **Wait for explicit permission** - Before any git commit, git push, or GitHub interaction, wait for user to explicitly request it +- **Stay on the current branch by default** - Only create or switch branches when the user explicitly requests it +- **Group commits by requirement** - Use one logical commit per requirement and do not mix unrelated file changes in the same commit +- Review and update `SECURITY_AUDIT.md` when modifying `clean` or `optimize` logic + +--- + +## Quick Reference + +### Build Commands + +```bash +# Build Go binaries for current platform +make build + +# Build release binaries (cross-platform) +make release-amd64 # macOS Intel +make release-arm64 # macOS Apple Silicon + +# Clean build artifacts +make clean +``` + +### Test Commands + +```bash +# Run full test suite (recommended before commits) +./scripts/test.sh + +# Run specific BATS test file +bats tests/clean.bats + +# Run specific test case by name +bats tests/clean.bats -f "should respect whitelist" + +# Run Go tests only +go test -v ./cmd/... + +# Run Go tests for specific package +go test -v ./cmd/analyze + +# Shell syntax check +bash -n lib/clean/user.sh +bash -n mole + +# Lint shell scripts +shellcheck --rcfile .shellcheckrc lib/**/*.sh bin/**/*.sh +``` + +### Development Commands + +```bash +# Test cleanup in dry-run mode +MO_DRY_RUN=1 ./mole clean + +# Enable debug logging +MO_DEBUG=1 ./mole clean + +# Disable operation logging +MO_NO_OPLOG=1 ./mole clean + +# Test Go tool directly +go run ./cmd/analyze + +# Test installation locally +./install.sh --prefix /usr/local/bin --config ~/.config/mole +``` + +### Log Files + +| File | Purpose | +|------|---------| +| `~/.config/mole/mole.log` | General log (INFO/SUCCESS/WARNING/ERROR) | +| `~/.config/mole/mole_debug_session.log` | Debug session log (MO_DEBUG=1) | +| `~/.config/mole/operations.log` | Operation log (all file deletions) | + +**Operation Log Format**: + +```text +[2024-01-26 10:30:15] [clean] REMOVED /Users/xxx/Library/Caches/com.old.app (15.2MB) +[2024-01-26 10:30:15] [clean] SKIPPED /Users/xxx/Library/Caches/com.protected.app (whitelist) +[2024-01-26 10:30:20] [uninstall] REMOVED /Applications/OldApp.app (150MB) +``` + +- **Actions**: `REMOVED` | `SKIPPED` | `FAILED` | `REBUILT` +- **Commands**: `clean` | `uninstall` | `optimize` | `purge` +- Disable with `MO_NO_OPLOG=1` +- Auto-rotates at 5MB + +--- + +## Architecture Quick Map + +```text +mole/ # Main CLI entrypoint (menu + routing) +├── mo # CLI alias wrapper +├── install.sh # Manual installer/updater (preserves --prefix/--config) +├── bin/ # Command entry points (thin wrappers) +│ ├── clean.sh # Deep cleanup orchestrator +│ ├── uninstall.sh # App removal with leftover detection +│ ├── optimize.sh # Cache rebuild + service refresh +│ ├── purge.sh # Aggressive cleanup mode +│ ├── touchid.sh # Touch ID sudo enabler +│ ├── analyze.sh # Disk usage explorer wrapper +│ ├── status.sh # System health dashboard wrapper +│ ├── installer.sh # Core installation logic +│ └── completion.sh # Shell completion support +├── lib/ # Reusable shell logic +│ ├── core/ # base.sh, log.sh, sudo.sh, ui.sh +│ ├── clean/ # Cleanup modules (user, apps, brew, system...) +│ ├── optimize/ # Optimization modules +│ ├── check/ # Health check modules +│ ├── manage/ # Management utilities +│ ├── ui/ # UI components (balloons, spinners) +│ └── uninstall/ # Uninstallation logic +├── cmd/ # Go applications +│ ├── analyze/ # Disk analysis tool +│ └── status/ # Real-time monitoring +├── scripts/ # Build and test automation +│ └── test.sh # Main test runner (shell + go + BATS) +└── tests/ # BATS integration tests +``` + +**Decision Tree**: + +- User cleanup logic → `lib/clean/.sh` +- Command entry → `bin/.sh` +- Core utils → `lib/core/.sh` +- Performance tool → `cmd//*.go` +- Tests → `tests/.bats` + +### Language Stack + +- **Shell (Bash 3.2)**: Core cleanup and system operations (`lib/`, `bin/`) +- **Go**: Performance-critical tools (`cmd/analyze/`, `cmd/status/`) +- **BATS**: Integration testing (`tests/`) + +--- + +## Code Style Guidelines + +### Shell Scripts + +- **Indentation**: 4 spaces (configured in .editorconfig) +- **Variables**: `lowercase_with_underscores` +- **Functions**: `verb_noun` format (e.g., `clean_caches`, `get_size`) +- **Constants**: `UPPERCASE_WITH_UNDERSCORES` +- **Quoting**: Always quote variables: `"$var"` not `$var` +- **Tests**: Use `[[` instead of `[` +- **Command substitution**: Use `$(command)` not backticks +- **Error handling**: Use `set -euo pipefail` at top of files + +### Shell Formatting (shfmt) + +**CRITICAL**: Always run `./scripts/check.sh --format` before committing shell script changes. + +The project uses `shfmt` with these flags: `shfmt -i 4 -ci -sr -w` + +| Flag | Meaning | Example | +|------|---------|---------| +| `-i 4` | 4-space indentation | Standard | +| `-ci` | Indent case bodies | `case` items indented | +| `-sr` | Space after redirect | `2> /dev/null` ✅ | + +**Correct Style**: + +```bash +# Redirects: KEEP the space after > or 2> +command 2> /dev/null +echo "hello" > file.txt + +# Case statements: body indented under pattern +case "$var" in + pattern) + action + ;; +esac +``` + +**Wrong Style** (DO NOT USE): + +```bash +# NO: missing space after redirect +command 2>/dev/null + +# NO: case body not indented +case "$var" in +pattern) +action +;; +esac +``` + +### Go Code + +- **Formatting**: Follow standard Go conventions (`gofmt`, `go vet`) +- **Package docs**: Add package-level documentation for exported functions +- **Error handling**: Never ignore errors, always handle them explicitly +- **Build tags**: Use `//go:build darwin` for macOS-specific code + +### Comments + +- **Language**: English only +- **Focus**: Explain "why" not "what" (code should be self-documenting) +- **Safety**: Document safety boundaries explicitly +- **Non-obvious logic**: Explain workarounds or complex patterns + +--- + +## Key Helper Functions + +### Safety Helpers (lib/core/base.sh) + +- `safe_rm `: Safe deletion with validation +- `safe_find_delete `: Protected find+delete +- `is_protected `: Check if path is system-protected +- `is_whitelisted `: Check user whitelist + +### Logging (lib/core/log.sh) + +- `log_info `: Informational messages +- `log_success `: Success notifications +- `log_warn `: Warnings +- `log_error `: Error messages +- `debug `: Debug output (requires MO_DEBUG=1) + +### UI Helpers (lib/core/ui.sh) + +- `confirm `: Yes/no confirmation +- `show_progress `: Progress display + +--- + +## Testing Strategy + +### Test Types + +1. **Syntax Validation**: `bash -n ` - catches basic errors +2. **Unit Tests**: BATS tests for individual functions +3. **Integration Tests**: Full command execution with BATS +4. **Dry-run Tests**: `MO_DRY_RUN=1` to validate without deletion +5. **Go Tests**: `go test -v ./cmd/...` + +### Test Environment Variables + +- `MO_DRY_RUN=1`: Preview changes without execution +- `MO_DEBUG=1`: Enable detailed debug logging +- `BATS_FORMATTER=pretty`: Use pretty output for BATS (default) +- `BATS_FORMATTER=tap`: Use TAP output for CI + +--- + +## Common Development Tasks + +### Adding New Cleanup Module + +1. Create `lib/clean/new_module.sh` +2. Implement cleanup logic using `safe_*` helpers +3. Source it in `bin/clean.sh` +4. Add protection checks for critical paths +5. Write BATS test in `tests/clean.bats` +6. Test with `MO_DRY_RUN=1` first + +### Modifying Go Tools + +1. Navigate to `cmd//` +2. Make changes to Go files +3. Test with `go run .` or `make build && ./bin/-go` +4. Run `go test -v` for unit tests +5. Check integration: `./mole ` + +### Debugging Issues + +1. Enable debug mode: `MO_DEBUG=1 ./mole clean` +2. Check logs for error messages +3. Verify sudo permissions: `sudo -n true` or `./mole touchid` +4. Test individual functions in isolation +5. Use `shellcheck` for shell script issues + +--- + +## Linting and Quality + +### Shell Script Linting + +- **Tool**: shellcheck with custom `.shellcheckrc` +- **Disabled rules**: SC2155, SC2034, SC2059, SC1091, SC2038 +- **Command**: `shellcheck --rcfile .shellcheckrc lib/**/*.sh bin/**/*.sh` + +### Go Code Quality + +- **Tools**: `go vet`, `go fmt`, `go test` +- **Command**: `go vet ./cmd/... && go test ./cmd/...` + +### CI/CD Pipeline + +- **Triggers**: Push/PR to main, dev branches +- **Platforms**: macOS 14, macOS 15 +- **Tools**: bats-core, shellcheck, Go 1.24.6 +- **Security checks**: Unsafe rm usage, app protection, secret scanning + +--- + +## File Organization Patterns + +### Shell Modules + +- Entry scripts in `bin/` should be thin wrappers +- Reusable logic goes in `lib/` +- Core utilities in `lib/core/` +- Feature-specific modules in `lib/clean/`, `lib/ui/`, etc. + +### Go Packages + +- Each tool in its own `cmd//` directory +- Main entry point in `main.go` +- Use standard Go project layout +- macOS-specific code guarded with build tags + +--- + +## GitHub Operations + +### Always Use gh CLI for GitHub Information + +**Golden Rule**: Whenever you need to fetch or manipulate GitHub data (issues, PRs, commits, releases, comments, etc.), **ALWAYS use `gh` CLI first**. It's more reliable, authenticated, and provides structured output compared to web scraping or raw git commands. +When responding to GitHub issues or PRs, fetch the content with `gh` before analysis and avoid web scraping. + +**Preferred Commands**: + +```bash +# Issues +gh issue view 123 # View issue details +gh issue list # List issues +gh issue comment 123 "message" # Comment on issue + +# Pull Requests +gh pr view # View current PR +gh pr diff # Show diff +gh pr list # List PRs +gh pr checkout 123 # Checkout PR branch +gh pr merge # Merge current PR + +# Repository operations +gh release create v1.0.0 # Create release +gh repo view # Repository info +gh api repos/owner/repo/issues # Raw API access +``` + +**NEVER use raw git commands for GitHub operations** when `gh` is available: + +- `git log --oneline origin/main..HEAD` → `gh pr view` +- `git remote get-url origin` → `gh repo view` +- Manual GitHub API curl commands → `gh api` + +### Suggesting Latest Version for Testing + +When a bug fix is done but not yet released, you can suggest users to install the latest version, but **must distinguish between install methods**: + +1. **For Script Users (`curl | bash`)**: + Users can directly run `mo update --nightly` to pull the latest unreleased `main` branch. + +2. **For Homebrew Users (`brew`)**: + Homebrew users **cannot** use `mo update --nightly` (it will throw an error). Reinstalling via brew (`brew uninstall mole && brew install mole`) also will NOT fetch the unreleased fix, because Brew follows official tags. + If they want to test immediately, suggest they `brew uninstall mole`, then reinstall using the official curl script. + +## Error Handling Patterns + +### Shell Scripts + +- Use `set -euo pipefail` for strict error handling +- Check command exit codes: `if command; then ...` +- Provide meaningful error messages with `log_error` +- Use cleanup traps for temporary resources + +### Go Code + +- Never ignore errors: `if err != nil { return err }` +- Use structured error messages +- Handle context cancellation appropriately +- Log errors with context information + +--- + +## Performance Considerations + +### Shell Optimization + +- Use built-in shell operations over external commands +- Prefer `find -delete` over `-exec rm` +- Minimize subprocess creation +- Use appropriate timeout mechanisms + +### Go Optimization + +- Use concurrency for I/O-bound operations +- Implement proper caching for expensive operations +- Profile memory usage in scanning operations +- Use efficient data structures for large datasets + +--- + +## Security Best Practices + +### Path Validation + +- Always validate user-provided paths +- Check against protection lists before operations +- Use absolute paths to prevent directory traversal +- Implement proper sandboxing for destructive operations + +### Permission Management + +- Request sudo only when necessary +- Use `sudo -n true` to check sudo availability +- Implement proper Touch ID integration +- Respect user whitelist configurations + +--- + +## Common Pitfalls to Avoid + +1. **Over-engineering**: Keep solutions simple. Don't add abstractions for one-time operations. +2. **Premature optimization**: Focus on correctness first, performance second. +3. **Assuming paths exist**: Always check before operating on files/directories. +4. **Ignoring protection logic**: User data loss is unacceptable. +5. **Breaking updates**: Keep `--prefix`/`--config` flags in `install.sh`. +6. **Platform assumptions**: Code must work on all supported macOS versions (10.13+). +7. **Silent failures**: Always log errors and provide actionable messages. + +--- + +## Communication Style + +- Address the user as "汤帅" (Tang Shuai) in every response +- Be concise and technical +- Explain safety implications upfront +- Show before/after for significant changes +- Provide file:line references for code locations +- Suggest testing steps for validation +- Avoid em dashes in responses + +### Responding to Bug Reports (English) + +When replying to users after fixing a bug, use this format: + +```markdown +Thanks for your feedback! This issue has been fixed. + +**Root cause**: [Brief explanation of what caused the bug] + +You can install the latest version to test: + +\`\`\`bash +curl -fsSL https://raw.githubusercontent.com/tw93/mole/main/install.sh | bash -s latest +\`\`\` + +I'll publish a new official release soon. +``` + +**Guidelines**: + +- Start with "Thanks for your feedback!" +- Explain root cause concisely +- Provide the install command for testing +- Mention a new release is coming + +--- + +## Resources + +### Quick Access + +- **Main script**: `mole` (menu + routing logic) +- **Protection lists**: Check `is_protected()` implementations in `lib/core/base.sh` +- **User config**: `~/.config/mole/` +- **Test directory**: `tests/` +- **Build scripts**: `scripts/` +- **Documentation**: `README.md`, `CONTRIBUTING.md`, `SECURITY_AUDIT.md` + +### Knowledge Base (Deeper Context) + +When you need detailed architecture understanding or module-specific patterns: + +- **`AGENTS.md`**: Project architecture, entry points, safety philosophy, project-specific patterns +- **`lib/AGENTS.md`**: Shell module library - safety helpers, cleanup modules, UI components, BSD/macOS patterns +- **`cmd/AGENTS.md`**: Go TUI tools - Bubble Tea architecture, concurrency patterns, caching strategies +- **`tests/AGENTS.md`**: BATS testing guide - isolation patterns, safety verification, APFS edge cases + +**Workflow**: Use CLAUDE.md for quick lookups → Consult AGENTS.md hierarchy for deep dives + +--- + +## Common Scenarios for Claude Code CLI + +### Scenario 1: Adding New Feature + +```bash +# 1. Read relevant documentation first +# For cleanup feature: check AGENTS.md → lib/AGENTS.md +# For Go tool feature: check AGENTS.md → cmd/AGENTS.md + +# 2. Locate the right module +# Decision tree in AGENTS.md line 297-305 + +# 3. Check existing patterns +grep -r "similar_function" lib/ + +# 4. Implement with safety checks +# Always use safe_* helpers (see lib/AGENTS.md lines 52-89) + +# 5. Test before committing +MO_DRY_RUN=1 ./mole clean +./scripts/test.sh +``` + +### Scenario 2: Debugging Issues + +```bash +# 1. Enable debug mode +MO_DEBUG=1 ./mole clean + +# 2. Check operation log +tail -f ~/.config/mole/operations.log + +# 3. Verify safety boundaries +# If deletion failed, check lib/core/base.sh:is_protected() + +# 4. Test individual function +# Source the module and test in isolation +source lib/clean/apps.sh +is_launch_item_orphaned "/path/to/plist" +``` + +### Scenario 3: Understanding Code Flow + +```bash +# 1. Start with entry point (AGENTS.md lines 48-54) +# User invokes: mo clean +# ↓ +# mole routes to: bin/clean.sh +# ↓ +# bin/clean.sh sources: lib/clean/*.sh +# ↓ +# Cleanup modules call: safe_* from lib/core/base.sh + +# 2. Check module-specific docs +# For shell modules: lib/AGENTS.md +# For Go tools: cmd/AGENTS.md +# For tests: tests/AGENTS.md +``` + +### Scenario 4: Code Review / PR Analysis + +```bash +# 1. Fetch PR with gh CLI +gh pr view 123 +gh pr diff + +# 2. Check safety compliance +# Scan for forbidden patterns (CLAUDE.md lines 19-32) +grep -n "rm -rf" changed_files.sh +grep -n "is_protected" changed_files.sh + +# 3. Verify test coverage +bats tests/related_test.bats + +# 4. Check formatting +./scripts/check.sh --format +``` + +--- + +**Remember**: When in doubt, err on the side of safety. It's better to clean less than to risk user data. From 61cf5271ebaf346e2d8e09b5e7254337371ddd29 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sat, 28 Feb 2026 01:50:15 +0000 Subject: [PATCH 37/69] chore: auto format code --- lib/clean/user.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/clean/user.sh b/lib/clean/user.sh index ae48055..0a66ade 100644 --- a/lib/clean/user.sh +++ b/lib/clean/user.sh @@ -1031,8 +1031,8 @@ check_large_file_candidates() { fi fi - 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 + 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 From 646ff72a96d1bc8a39b70f5551598e7f5ea14975 Mon Sep 17 00:00:00 2001 From: tw93 Date: Sat, 28 Feb 2026 09:53:07 +0800 Subject: [PATCH 38/69] fix(clean): resolve unbound variable 'mount_points[@]' error in empty array case (#511) --- lib/clean/dev.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/clean/dev.sh b/lib/clean/dev.sh index edb8157..83234e2 100644 --- a/lib/clean/dev.sh +++ b/lib/clean/dev.sh @@ -390,7 +390,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 From 1be71edc9df498eaf16f4b213daff180ad97ab32 Mon Sep 17 00:00:00 2001 From: tw93 Date: Sat, 28 Feb 2026 10:02:34 +0800 Subject: [PATCH 39/69] fix: use Base-10 sizes and mdls logical size to match macOS Finder - Switch bytes_to_human (shell) and humanizeBytes (Go) from Base-2 (1024) to Base-10 (1000) to match Apple's storage calculation standard since Snow Leopard - Add proper decimal rounding instead of truncation - Use mdls kMDItemLogicalSize for .app bundles to avoid APFS clone file undercounting by du Fixes #511 --- cmd/analyze/format.go | 4 ++-- cmd/analyze/format_test.go | 18 +++++++++--------- lib/core/base.sh | 21 ++++++++++++--------- lib/core/file_ops.sh | 13 +++++++++++++ 4 files changed, 36 insertions(+), 20 deletions(-) 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/lib/core/base.sh b/lib/core/base.sh index d0efe1a..e11c7f8 100644 --- a/lib/core/base.sh +++ b/lib/core/base.sh @@ -451,6 +451,7 @@ ensure_user_file() { # ============================================================================ # 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]+$ ]] || { @@ -458,15 +459,17 @@ bytes_to_human() { 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)) + # 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 diff --git a/lib/core/file_ops.sh b/lib/core/file_ops.sh index fc5cfee..4f90cea 100644 --- a/lib/core/file_ops.sh +++ b/lib/core/file_ops.sh @@ -503,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) From 7d70889ad4b910f03e8e79777190b98cda813f65 Mon Sep 17 00:00:00 2001 From: tw93 Date: Sat, 28 Feb 2026 11:03:16 +0800 Subject: [PATCH 40/69] chore: auto format code --- lib/clean/apps.sh | 36 +++++++++++------------ lib/clean/caches.sh | 2 +- lib/clean/dev.sh | 8 +++--- lib/clean/hints.sh | 16 +++++------ lib/clean/project.sh | 10 +++---- lib/clean/system.sh | 26 ++++++++--------- lib/clean/user.sh | 60 +++++++++++++++++++-------------------- lib/core/base.sh | 8 +++--- lib/core/common.sh | 4 +-- lib/core/file_ops.sh | 2 +- lib/core/sudo.sh | 8 +++--- lib/core/ui.sh | 10 +++---- lib/manage/autofix.sh | 6 ++-- lib/manage/purge_paths.sh | 2 +- lib/manage/update.sh | 2 +- lib/optimize/tasks.sh | 16 +++++------ lib/ui/app_selector.sh | 2 +- lib/ui/menu_paginated.sh | 14 ++++----- lib/ui/menu_simple.sh | 6 ++-- lib/uninstall/batch.sh | 18 ++++++------ test_rounding.sh | 17 +++++++++++ 21 files changed, 145 insertions(+), 128 deletions(-) create mode 100755 test_rounding.sh diff --git a/lib/clean/apps.sh b/lib/clean/apps.sh index 11d064c..1f67ab2 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++)) || true 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 += file_count)) || true + ((total_size_cleaned += size_kb)) || true + ((total_items++)) || true 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++)) || true fi done ) > "$scan_tmp_dir/apps_${dir_idx}.txt" & pids+=($!) - ((dir_idx++)) + ((dir_idx++)) || true 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++)) || true 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++)) || true + ((total_orphaned_kb += size_kb)) || true fi fi done @@ -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 += size_kb)) || true + ((orphaned_count++)) || true 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 += size_kb)) || true + ((orphaned_count++)) || true 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 += size_kb)) || true + ((orphaned_count++)) || true 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 += size_kb)) || true 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++)) || true 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++)) || true log_operation "clean" "REMOVED" "$plist_path" "orphaned launch agent" else log_operation "clean" "FAILED" "$plist_path" "permission denied" diff --git a/lib/clean/caches.sh b/lib/clean/caches.sh index c0ecdb5..8918789 100644 --- a/lib/clean/caches.sh +++ b/lib/clean/caches.sh @@ -208,7 +208,7 @@ clean_project_caches() { break fi sleep 0.1 - ((grace_period++)) + ((grace_period++)) || true 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 83234e2..770e424 100644 --- a/lib/clean/dev.sh +++ b/lib/clean/dev.sh @@ -259,11 +259,11 @@ clean_xcode_documentation_cache() { local entry for entry in "${sorted_entries[@]}"; do if [[ $idx -eq 0 ]]; then - ((idx++)) + ((idx++)) || true continue fi stale_entries+=("$entry") - ((idx++)) + ((idx++)) || true done if [[ ${#stale_entries[@]} -eq 0 ]]; then @@ -729,12 +729,12 @@ clean_dev_jetbrains_toolbox() { local dir_path for dir_path in "${sorted_dirs[@]}"; do if [[ $idx -lt $keep_previous ]]; then - ((idx++)) + ((idx++)) || true continue fi safe_clean "$dir_path" "JetBrains Toolbox old IDE version" note_activity - ((idx++)) + ((idx++)) || true 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 8f9f265..6b41bed 100644 --- a/lib/clean/hints.sh +++ b/lib/clean/hints.sh @@ -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++)) || true 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 += size_kb)) || true + ((PROJECT_ARTIFACT_HINT_ESTIMATE_SAMPLES++)) || true 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++)) || true + ((root_projects_scanned++)) || true 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++)) || true + ((root_projects_scanned++)) || true 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++)) || true if [[ $nested_count -gt $max_nested_per_project ]]; then break fi diff --git a/lib/clean/project.sh b/lib/clean/project.sh index 2f460cf..0539ccd 100644 --- a/lib/clean/project.sh +++ b/lib/clean/project.sh @@ -640,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++)) || true fi done @@ -728,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++)) || true elif [[ $((top_index + visible_count)) -lt $total_items ]]; then - ((top_index++)) + ((top_index++)) || true fi fi } @@ -1350,7 +1350,7 @@ clean_project_artifacts() { [[ "$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++)) || true fi done @@ -1391,7 +1391,7 @@ clean_project_artifacts() { 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++)) + ((cleaned_count++)) || true fi fi if [[ -t 1 ]]; then diff --git a/lib/clean/system.sh b/lib/clean/system.sh index 9a8d664..51c8862 100644 --- a/lib/clean/system.sh +++ b/lib/clean/system.sh @@ -86,7 +86,7 @@ clean_deep_system() { continue fi if safe_sudo_remove "$item"; then - ((updates_cleaned++)) + ((updates_cleaned++)) || true fi done < <(find /Library/Updates -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true) stop_section_spinner @@ -143,7 +143,7 @@ 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++)) || true fi fi done @@ -153,7 +153,7 @@ clean_deep_system() { 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++)) || true 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 @@ -300,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++)) || true note_activity continue fi @@ -310,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++)) || true + ((files_cleaned++)) || true + ((total_size_cleaned += size_kb)) || true + ((total_items++)) || true note_activity else echo -e " ${YELLOW}!${NC} Could not delete: $backup_name · try manually with sudo" @@ -352,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++)) || true note_activity continue fi @@ -361,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++)) || true + ((files_cleaned++)) || true + ((total_size_cleaned += size_kb)) || true + ((total_items++)) || true note_activity else echo -e " ${YELLOW}!${NC} Could not delete from bundle: $backup_name" diff --git a/lib/clean/user.sh b/lib/clean/user.sh index 0a66ade..d7865de 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++)) || true fi done < <(command find "$HOME/.Trash" -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true) if [[ $cleaned_count -gt 0 ]]; then @@ -100,8 +100,8 @@ _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++)) || true + ((cleaned_kb += file_size_kb)) || true fi fi done < <(command find "$target_path" -type f -mtime +"$mail_age_days" -print0 2> /dev/null || true) @@ -171,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++)) || true cleaned_any=true if [[ "$DRY_RUN" != "true" ]]; then if has_sudo_session; then @@ -191,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 += cleaned_count)) || true + ((total_size_cleaned += total_size)) || true + ((total_items++)) || true note_activity fi } @@ -257,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++)) || true cleaned_any=true if [[ "$DRY_RUN" != "true" ]]; then if has_sudo_session; then @@ -277,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 += cleaned_count)) || true + ((total_size_cleaned += total_size)) || true + ((total_items++)) || true note_activity fi } @@ -324,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++)) || true cleaned_any=true if [[ "$DRY_RUN" != "true" ]]; then safe_remove "$dir" true > /dev/null 2>&1 || true @@ -339,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 += cleaned_count)) || true + ((total_size_cleaned += total_size)) || true + ((total_items++)) || true note_activity fi } @@ -484,9 +484,9 @@ 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 += cleaned_count)) || true + ((total_size_cleaned += total_size)) || true + ((total_items++)) || true note_activity fi @@ -513,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 += size)) || true found_any=true - ((cleaned_count++)) + ((cleaned_count++)) || true if [[ "$DRY_RUN" != "true" ]]; then local item while IFS= read -r -d '' item; do @@ -613,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 += item_size)) || true done else for item in "${items_to_clean[@]}"; do @@ -622,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 += item_size)) || true fi done fi if [[ "$candidate_changed" == "true" ]]; then - ((total_size += candidate_size_kb)) - ((cleaned_count++)) + ((total_size += candidate_size_kb)) || true + ((cleaned_count++)) || true found_any=true fi done @@ -645,9 +645,9 @@ 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 += cleaned_count)) || true + ((total_size_cleaned += total_size)) || true + ((total_items++)) || true note_activity fi } @@ -951,9 +951,9 @@ clean_application_support_logs() { 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 += cleaned_count)) || true + ((total_size_cleaned += total_size_kb)) || true + ((total_items++)) || true note_activity fi } diff --git a/lib/core/base.sh b/lib/core/base.sh index e11c7f8..805f29a 100644 --- a/lib/core/base.sh +++ b/lib/core/base.sh @@ -878,18 +878,18 @@ validate_terminal_environment() { # Check if TERM is set if [[ -z "${TERM:-}" ]]; then log_warning "TERM environment variable not set" - ((warnings++)) + ((warnings++)) || true fi # Check if running in a known problematic terminal case "${TERM:-}" in dumb) log_warning "Running in 'dumb' terminal, limited functionality" - ((warnings++)) + ((warnings++)) || true ;; unknown) log_warning "Terminal type unknown, may have display issues" - ((warnings++)) + ((warnings++)) || true ;; esac @@ -898,7 +898,7 @@ validate_terminal_environment() { 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++)) + ((warnings++)) || true fi fi diff --git a/lib/core/common.sh b/lib/core/common.sh index d89b448..e39c2df 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++)) || true continue } @@ -175,7 +175,7 @@ remove_apps_from_dock() { continue fi fi - ((i++)) + ((i++)) || true done done diff --git a/lib/core/file_ops.sh b/lib/core/file_ops.sh index 4f90cea..dbe259b 100644 --- a/lib/core/file_ops.sh +++ b/lib/core/file_ops.sh @@ -536,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 += size_kb)) || true fi done <<< "$files" diff --git a/lib/core/sudo.sh b/lib/core/sudo.sh index d7de287..e27240f 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++)) || true 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++)) || true 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++)) || true 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++)) || true if [[ $retry_count -ge 3 ]]; then exit 1 fi diff --git a/lib/core/ui.sh b/lib/core/ui.sh index 2834091..c5ba761 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 += char_width)) || true + ((i++)) || true 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++)) || true [[ $drained -gt 100 ]] && break done } @@ -341,7 +341,7 @@ start_inline_spinner() { local c="${chars:$((i % ${#chars})):1}" # Output to stderr to avoid interfering with stdout printf "\r${MOLE_SPINNER_PREFIX:-}${BLUE}%s${NC} %s" "$c" "$display_message" >&2 || break - ((i++)) + ((i++)) || true sleep 0.05 done @@ -367,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++)) || true done # Only use SIGKILL as last resort if process is stuck diff --git a/lib/manage/autofix.sh b/lib/manage/autofix.sh index 4096c00..b3aea66 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++)) || true 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++)) || true 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++)) || true 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..473b229 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++)) || true done < "$PURGE_PATHS_CONFIG" fi diff --git a/lib/manage/update.sh b/lib/manage/update.sh index bab206e..8f417e8 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++)) || true else echo -e "${RED}✗${NC} Mole update failed" fi diff --git a/lib/optimize/tasks.sh b/lib/optimize/tasks.sh index 6260263..738cd87 100644 --- a/lib/optimize/tasks.sh +++ b/lib/optimize/tasks.sh @@ -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++)) || true 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++)) || true 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++)) || true continue fi fi @@ -354,14 +354,14 @@ opt_sqlite_vacuum() { set -e if [[ $exit_code -eq 0 ]]; then - ((vacuumed++)) + ((vacuumed++)) || true elif [[ $exit_code -eq 124 ]]; then - ((timed_out++)) + ((timed_out++)) || true else - ((failed++)) + ((failed++)) || true fi else - ((vacuumed++)) + ((vacuumed++)) || true fi done < <(compgen -G "$pattern" || true) done @@ -730,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++)) || true fi sleep 1 done diff --git a/lib/ui/app_selector.sh b/lib/ui/app_selector.sh index 35610be..a5e4fbc 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++)) || true 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..982a7e8 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++)) || true 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++)) || true 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++)) || true 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++)) || true 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++)) || true elif [[ $((top_index + visible_count)) -lt ${#view_indices[@]} ]]; then - ((top_index++)) + ((top_index++)) || true 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++)) || true fi fi diff --git a/lib/ui/menu_simple.sh b/lib/ui/menu_simple.sh index f384024..09f503a 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++)) || true 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++)) || true elif [[ $((top_index + visible_count)) -lt $total_items ]]; then - ((top_index++)) + ((top_index++)) || true 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 597b9dc..4cc185d 100755 --- a/lib/uninstall/batch.sh +++ b/lib/uninstall/batch.sh @@ -465,7 +465,7 @@ batch_uninstall_applications() { local -a success_items=() local current_index=0 for detail in "${app_details[@]}"; do - ((current_index++)) + ((current_index++)) || true 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") @@ -610,11 +610,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_kb)) || true + ((success_count++)) || true + [[ "$used_brew_successfully" == "true" ]] && ((brew_apps_removed++)) || true + ((files_cleaned++)) || true + ((total_items++)) || true success_items+=("$app_path") else if [[ -t 1 ]]; then @@ -628,7 +628,7 @@ batch_uninstall_applications() { fi fi - ((failed_count++)) + ((failed_count++)) || true failed_items+=("$app_name:$reason:${suggestion:-}") fi done @@ -672,7 +672,7 @@ batch_uninstall_applications() { else current_line="$current_line, $display_item" fi - ((idx++)) + ((idx++)) || true done if [[ -n "$current_line" ]]; then summary_details+=("$current_line") @@ -765,6 +765,6 @@ batch_uninstall_applications() { _restore_uninstall_traps unset -f _restore_uninstall_traps - ((total_size_cleaned += total_size_freed)) + ((total_size_cleaned += total_size_freed)) || true unset failed_items } diff --git a/test_rounding.sh b/test_rounding.sh new file mode 100755 index 0000000..a52d6ec --- /dev/null +++ b/test_rounding.sh @@ -0,0 +1,17 @@ +bytes_to_human_new() { + local bytes="$1" + if ((bytes >= 1000000000)); then + local scaled=$(( (bytes * 100 + 500000000) / 1000000000 )) + printf "%d.%02dGB\n" $((scaled / 100)) $((scaled % 100)) + elif ((bytes >= 1000000)); then + local scaled=$(( (bytes * 10 + 500000) / 1000000 )) + printf "%d.%01dMB\n" $((scaled / 10)) $((scaled % 10)) + elif ((bytes >= 1000)); then + printf "%dKB\n" $(((bytes + 500) / 1000)) + else + printf "%dB\n" "$bytes" + fi +} +bytes_to_human_new 12187977120 +bytes_to_human_new 12999000000 +bytes_to_human_new 36281810 From c19a0276b8752664bd33aef5596d87dc905f4cb8 Mon Sep 17 00:00:00 2001 From: tw93 Date: Sat, 28 Feb 2026 11:10:18 +0800 Subject: [PATCH 41/69] refactor: Update shell arithmetic increment syntax from `((var++)) || true` to `var=$((var + 1))` across various scripts. --- lib/clean/apps.sh | 36 ++++++++--------- lib/clean/caches.sh | 2 +- lib/clean/dev.sh | 8 ++-- lib/clean/hints.sh | 16 ++++---- lib/clean/project.sh | 10 ++--- lib/clean/system.sh | 26 ++++++------- lib/clean/user.sh | 78 ++++++++++++++++++------------------- lib/core/base.sh | 8 ++-- lib/core/common.sh | 4 +- lib/core/file_ops.sh | 2 +- lib/core/sudo.sh | 8 ++-- lib/core/ui.sh | 10 ++--- lib/manage/autofix.sh | 6 +-- lib/manage/purge_paths.sh | 2 +- lib/manage/update.sh | 2 +- lib/manage/whitelist.sh | 2 +- lib/optimize/maintenance.sh | 4 +- lib/optimize/tasks.sh | 16 ++++---- lib/ui/app_selector.sh | 2 +- lib/ui/menu_paginated.sh | 14 +++---- lib/ui/menu_simple.sh | 6 +-- lib/uninstall/batch.sh | 20 +++++----- 22 files changed, 141 insertions(+), 141 deletions(-) diff --git a/lib/clean/apps.sh b/lib/clean/apps.sh index 1f67ab2..ccdea20 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++)) || true + 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)) || true - ((total_size_cleaned += size_kb)) || true - ((total_items++)) || true + 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++)) || true + count=$((count + 1)) fi done ) > "$scan_tmp_dir/apps_${dir_idx}.txt" & pids+=($!) - ((dir_idx++)) || true + 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++)) || true + 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++)) || true - ((total_orphaned_kb += size_kb)) || true + orphaned_count=$((orphaned_count + 1)) + total_orphaned_kb=$((total_orphaned_kb + size_kb)) fi fi done @@ -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)) || true - ((orphaned_count++)) || true + 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)) || true - ((orphaned_count++)) || true + 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)) || true - ((orphaned_count++)) || true + 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)) || true + 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++)) || true + 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++)) || true + 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/caches.sh b/lib/clean/caches.sh index 8918789..f9361b4 100644 --- a/lib/clean/caches.sh +++ b/lib/clean/caches.sh @@ -208,7 +208,7 @@ clean_project_caches() { break fi sleep 0.1 - ((grace_period++)) || true + 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 770e424..c52a11b 100644 --- a/lib/clean/dev.sh +++ b/lib/clean/dev.sh @@ -259,11 +259,11 @@ clean_xcode_documentation_cache() { local entry for entry in "${sorted_entries[@]}"; do if [[ $idx -eq 0 ]]; then - ((idx++)) || true + idx=$((idx + 1)) continue fi stale_entries+=("$entry") - ((idx++)) || true + idx=$((idx + 1)) done if [[ ${#stale_entries[@]} -eq 0 ]]; then @@ -729,12 +729,12 @@ clean_dev_jetbrains_toolbox() { local dir_path for dir_path in "${sorted_dirs[@]}"; do if [[ $idx -lt $keep_previous ]]; then - ((idx++)) || true + idx=$((idx + 1)) continue fi safe_clean "$dir_path" "JetBrains Toolbox old IDE version" note_activity - ((idx++)) || true + 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 6b41bed..f6538bf 100644 --- a/lib/clean/hints.sh +++ b/lib/clean/hints.sh @@ -58,7 +58,7 @@ hint_get_path_size_kb_with_timeout() { record_project_artifact_hint() { local path="$1" - ((PROJECT_ARTIFACT_HINT_COUNT++)) || true + 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)) || true - ((PROJECT_ARTIFACT_HINT_ESTIMATE_SAMPLES++)) || true + 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++)) || true - ((root_projects_scanned++)) || true + 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++)) || true - ((root_projects_scanned++)) || true + 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++)) || true + 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 0539ccd..67e117b 100644 --- a/lib/clean/project.sh +++ b/lib/clean/project.sh @@ -640,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++)) || true + selected_count=$((selected_count + 1)) fi done @@ -728,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++)) || true + cursor_pos=$((cursor_pos + 1)) elif [[ $((top_index + visible_count)) -lt $total_items ]]; then - ((top_index++)) || true + top_index=$((top_index + 1)) fi fi } @@ -1350,7 +1350,7 @@ clean_project_artifacts() { [[ "$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++)) || true + selected_unknown_count=$((selected_unknown_count + 1)) fi done @@ -1391,7 +1391,7 @@ clean_project_artifacts() { 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++)) || true + cleaned_count=$((cleaned_count + 1)) fi fi if [[ -t 1 ]]; then diff --git a/lib/clean/system.sh b/lib/clean/system.sh index 51c8862..817964e 100644 --- a/lib/clean/system.sh +++ b/lib/clean/system.sh @@ -86,7 +86,7 @@ clean_deep_system() { continue fi if safe_sudo_remove "$item"; then - ((updates_cleaned++)) || true + updates_cleaned=$((updates_cleaned + 1)) fi done < <(find /Library/Updates -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true) stop_section_spinner @@ -143,7 +143,7 @@ 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++)) || true + installer_cleaned=$((installer_cleaned + 1)) fi fi done @@ -153,7 +153,7 @@ clean_deep_system() { local code_sign_cleaned=0 while IFS= read -r -d '' cache_dir; do if safe_sudo_remove "$cache_dir"; then - ((code_sign_cleaned++)) || true + 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 @@ -300,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++)) || true + tm_cleaned=$((tm_cleaned + 1)) note_activity continue fi @@ -310,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++)) || true - ((files_cleaned++)) || true - ((total_size_cleaned += size_kb)) || true - ((total_items++)) || true + 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" @@ -352,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++)) || true + tm_cleaned=$((tm_cleaned + 1)) note_activity continue fi @@ -361,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++)) || true - ((files_cleaned++)) || true - ((total_size_cleaned += size_kb)) || true - ((total_items++)) || true + 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" diff --git a/lib/clean/user.sh b/lib/clean/user.sh index d7865de..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++)) || true + 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 @@ -100,8 +100,8 @@ _clean_mail_downloads() { local file_size_kb file_size_kb=$(get_path_size_kb "$file_path") if safe_remove "$file_path" true; then - ((count++)) || true - ((cleaned_kb += file_size_kb)) || true + 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) @@ -171,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++)) || true + cleaned_count=$((cleaned_count + 1)) cleaned_any=true if [[ "$DRY_RUN" != "true" ]]; then if has_sudo_session; then @@ -191,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)) || true - ((total_size_cleaned += total_size)) || true - ((total_items++)) || true + files_cleaned=$((files_cleaned + cleaned_count)) + total_size_cleaned=$((total_size_cleaned + total_size)) + total_items=$((total_items + 1)) note_activity fi } @@ -257,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++)) || true + cleaned_count=$((cleaned_count + 1)) cleaned_any=true if [[ "$DRY_RUN" != "true" ]]; then if has_sudo_session; then @@ -277,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)) || true - ((total_size_cleaned += total_size)) || true - ((total_items++)) || true + files_cleaned=$((files_cleaned + cleaned_count)) + total_size_cleaned=$((total_size_cleaned + total_size)) + total_items=$((total_items + 1)) note_activity fi } @@ -324,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++)) || true + cleaned_count=$((cleaned_count + 1)) cleaned_any=true if [[ "$DRY_RUN" != "true" ]]; then safe_remove "$dir" true > /dev/null 2>&1 || true @@ -339,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)) || true - ((total_size_cleaned += total_size)) || true - ((total_items++)) || true + files_cleaned=$((files_cleaned + cleaned_count)) + total_size_cleaned=$((total_size_cleaned + total_size)) + total_items=$((total_items + 1)) note_activity fi } @@ -484,9 +484,9 @@ clean_app_caches() { else echo -e " ${GREEN}${ICON_SUCCESS}${NC} Sandboxed app caches${NC}, ${GREEN}$size_human${NC}" fi - ((files_cleaned += cleaned_count)) || true - ((total_size_cleaned += total_size)) || true - ((total_items++)) || true + files_cleaned=$((files_cleaned + cleaned_count)) + total_size_cleaned=$((total_size_cleaned + total_size)) + total_items=$((total_items + 1)) note_activity fi @@ -513,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)) || true + total_size=$((total_size + size)) found_any=true - ((cleaned_count++)) || true + cleaned_count=$((cleaned_count + 1)) if [[ "$DRY_RUN" != "true" ]]; then local item while IFS= read -r -d '' item; do @@ -613,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)) || true + candidate_size_kb=$((candidate_size_kb + item_size)) done else for item in "${items_to_clean[@]}"; do @@ -622,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)) || true + candidate_size_kb=$((candidate_size_kb + item_size)) fi done fi if [[ "$candidate_changed" == "true" ]]; then - ((total_size += candidate_size_kb)) || true - ((cleaned_count++)) || true + total_size=$((total_size + candidate_size_kb)) + cleaned_count=$((cleaned_count + 1)) found_any=true fi done @@ -645,9 +645,9 @@ 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)) || true - ((total_size_cleaned += total_size)) || true - ((total_items++)) || true + files_cleaned=$((files_cleaned + cleaned_count)) + total_size_cleaned=$((total_size_cleaned + total_size)) + total_items=$((total_items + 1)) note_activity fi } @@ -808,7 +808,7 @@ clean_application_support_logs() { [[ -d "$app_dir" ]] || continue local app_name app_name=$(basename "$app_dir") - ((app_count++)) || true + 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:]') @@ -834,12 +834,12 @@ clean_application_support_logs() { while IFS= read -r -d '' item; do [[ -e "$item" ]] || continue item_found=true - ((candidate_item_count++)) || 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 += item_size_bytes)) || true + candidate_size_bytes=$((candidate_size_bytes + item_size_bytes)) else candidate_size_partial=true fi @@ -865,9 +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)) || true + total_size_bytes=$((total_size_bytes + candidate_size_bytes)) [[ "$candidate_size_partial" == "true" ]] && total_size_partial=true - ((cleaned_count++)) || true + cleaned_count=$((cleaned_count + 1)) found_any=true fi fi @@ -889,12 +889,12 @@ clean_application_support_logs() { while IFS= read -r -d '' item; do [[ -e "$item" ]] || continue item_found=true - ((candidate_item_count++)) || 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 += item_size_bytes)) || true + candidate_size_bytes=$((candidate_size_bytes + item_size_bytes)) else candidate_size_partial=true fi @@ -920,9 +920,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)) || true + total_size_bytes=$((total_size_bytes + candidate_size_bytes)) [[ "$candidate_size_partial" == "true" ]] && total_size_partial=true - ((cleaned_count++)) || true + cleaned_count=$((cleaned_count + 1)) found_any=true fi fi @@ -951,9 +951,9 @@ clean_application_support_logs() { echo -e " ${GREEN}${ICON_SUCCESS}${NC} Application Support logs/caches${NC}, ${GREEN}$size_human${NC}" fi fi - ((files_cleaned += cleaned_count)) || true - ((total_size_cleaned += total_size_kb)) || true - ((total_items++)) || true + files_cleaned=$((files_cleaned + cleaned_count)) + total_size_cleaned=$((total_size_cleaned + total_size_kb)) + total_items=$((total_items + 1)) note_activity fi } diff --git a/lib/core/base.sh b/lib/core/base.sh index 805f29a..16e9860 100644 --- a/lib/core/base.sh +++ b/lib/core/base.sh @@ -878,18 +878,18 @@ validate_terminal_environment() { # Check if TERM is set if [[ -z "${TERM:-}" ]]; then log_warning "TERM environment variable not set" - ((warnings++)) || true + warnings=$((warnings + 1)) fi # Check if running in a known problematic terminal case "${TERM:-}" in dumb) log_warning "Running in 'dumb' terminal, limited functionality" - ((warnings++)) || true + warnings=$((warnings + 1)) ;; unknown) log_warning "Terminal type unknown, may have display issues" - ((warnings++)) || true + warnings=$((warnings + 1)) ;; esac @@ -898,7 +898,7 @@ validate_terminal_environment() { 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++)) || true + warnings=$((warnings + 1)) fi fi diff --git a/lib/core/common.sh b/lib/core/common.sh index e39c2df..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++)) || true + i=$((i + 1)) continue } @@ -175,7 +175,7 @@ remove_apps_from_dock() { continue fi fi - ((i++)) || true + i=$((i + 1)) done done diff --git a/lib/core/file_ops.sh b/lib/core/file_ops.sh index dbe259b..9821fd5 100644 --- a/lib/core/file_ops.sh +++ b/lib/core/file_ops.sh @@ -536,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)) || true + total_kb=$((total_kb + size_kb)) fi done <<< "$files" diff --git a/lib/core/sudo.sh b/lib/core/sudo.sh index e27240f..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++)) || true + 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++)) || true + 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++)) || true + 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++)) || true + 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 c5ba761..83d35eb 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)) || true - ((i++)) || true + 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++)) || true + drained=$((drained + 1)) [[ $drained -gt 100 ]] && break done } @@ -341,7 +341,7 @@ start_inline_spinner() { local c="${chars:$((i % ${#chars})):1}" # Output to stderr to avoid interfering with stdout printf "\r${MOLE_SPINNER_PREFIX:-}${BLUE}%s${NC} %s" "$c" "$display_message" >&2 || break - ((i++)) || true + i=$((i + 1)) sleep 0.05 done @@ -367,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++)) || true + wait_count=$((wait_count + 1)) done # Only use SIGKILL as last resort if process is stuck diff --git a/lib/manage/autofix.sh b/lib/manage/autofix.sh index b3aea66..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++)) || true + 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++)) || true + 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++)) || true + 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 473b229..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++)) || true + custom_count=$((custom_count + 1)) done < "$PURGE_PATHS_CONFIG" fi diff --git a/lib/manage/update.sh b/lib/manage/update.sh index 8f417e8..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++)) || true + 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 c1f004c..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++)) || true + 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++)) || true + 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 738cd87..87f7758 100644 --- a/lib/optimize/tasks.sh +++ b/lib/optimize/tasks.sh @@ -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++)) || true + 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++)) || true + 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++)) || true + skipped=$((skipped + 1)) continue fi fi @@ -354,14 +354,14 @@ opt_sqlite_vacuum() { set -e if [[ $exit_code -eq 0 ]]; then - ((vacuumed++)) || true + vacuumed=$((vacuumed + 1)) elif [[ $exit_code -eq 124 ]]; then - ((timed_out++)) || true + timed_out=$((timed_out + 1)) else - ((failed++)) || true + failed=$((failed + 1)) fi else - ((vacuumed++)) || true + vacuumed=$((vacuumed + 1)) fi done < <(compgen -G "$pattern" || true) done @@ -730,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++)) || true + slow_count=$((slow_count + 1)) fi sleep 1 done diff --git a/lib/ui/app_selector.sh b/lib/ui/app_selector.sh index a5e4fbc..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++)) || true + 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 982a7e8..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++)) || true + 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++)) || true + 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++)) || true + 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++)) || true + 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++)) || true + cursor_pos=$((cursor_pos + 1)) elif [[ $((top_index + visible_count)) -lt ${#view_indices[@]} ]]; then - ((top_index++)) || true + 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++)) || true + selected_count=$((selected_count + 1)) fi fi diff --git a/lib/ui/menu_simple.sh b/lib/ui/menu_simple.sh index 09f503a..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++)) || true + [[ ${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++)) || true + cursor_pos=$((cursor_pos + 1)) elif [[ $((top_index + visible_count)) -lt $total_items ]]; then - ((top_index++)) || true + 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 4cc185d..953b9ee 100755 --- a/lib/uninstall/batch.sh +++ b/lib/uninstall/batch.sh @@ -317,7 +317,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 @@ -465,7 +465,7 @@ batch_uninstall_applications() { local -a success_items=() local current_index=0 for detail in "${app_details[@]}"; do - ((current_index++)) || true + 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") @@ -610,11 +610,11 @@ batch_uninstall_applications() { fi fi - ((total_size_freed += total_kb)) || true - ((success_count++)) || true - [[ "$used_brew_successfully" == "true" ]] && ((brew_apps_removed++)) || true - ((files_cleaned++)) || true - ((total_items++)) || true + 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 @@ -628,7 +628,7 @@ batch_uninstall_applications() { fi fi - ((failed_count++)) || true + failed_count=$((failed_count + 1)) failed_items+=("$app_name:$reason:${suggestion:-}") fi done @@ -672,7 +672,7 @@ batch_uninstall_applications() { else current_line="$current_line, $display_item" fi - ((idx++)) || true + idx=$((idx + 1)) done if [[ -n "$current_line" ]]; then summary_details+=("$current_line") @@ -765,6 +765,6 @@ batch_uninstall_applications() { _restore_uninstall_traps unset -f _restore_uninstall_traps - ((total_size_cleaned += total_size_freed)) || true + total_size_cleaned=$((total_size_cleaned + total_size_freed)) unset failed_items } From 194e8ad29ab44d89af60ab3474b3f2457156dad0 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sat, 28 Feb 2026 03:11:16 +0000 Subject: [PATCH 42/69] chore: auto format code --- lib/core/base.sh | 4 ++-- lib/core/file_ops.sh | 2 +- test_rounding.sh | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/core/base.sh b/lib/core/base.sh index 16e9860..54d108e 100644 --- a/lib/core/base.sh +++ b/lib/core/base.sh @@ -461,11 +461,11 @@ bytes_to_human() { # GB: >= 1,000,000,000 bytes if ((bytes >= 1000000000)); then - local scaled=$(( (bytes * 100 + 500000000) / 1000000000 )) + 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 )) + 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 diff --git a/lib/core/file_ops.sh b/lib/core/file_ops.sh index 9821fd5..5c41618 100644 --- a/lib/core/file_ops.sh +++ b/lib/core/file_ops.sh @@ -503,7 +503,7 @@ 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 diff --git a/test_rounding.sh b/test_rounding.sh index a52d6ec..c7cb772 100755 --- a/test_rounding.sh +++ b/test_rounding.sh @@ -1,10 +1,10 @@ bytes_to_human_new() { local bytes="$1" if ((bytes >= 1000000000)); then - local scaled=$(( (bytes * 100 + 500000000) / 1000000000 )) + local scaled=$(((bytes * 100 + 500000000) / 1000000000)) printf "%d.%02dGB\n" $((scaled / 100)) $((scaled % 100)) elif ((bytes >= 1000000)); then - local scaled=$(( (bytes * 10 + 500000) / 1000000 )) + local scaled=$(((bytes * 10 + 500000) / 1000000)) printf "%d.%01dMB\n" $((scaled / 10)) $((scaled % 10)) elif ((bytes >= 1000)); then printf "%dKB\n" $(((bytes + 500) / 1000)) From 75dc9f01dcdf9ecc109b9a8abe099e13e45d3183 Mon Sep 17 00:00:00 2001 From: tw93 Date: Sat, 28 Feb 2026 11:22:35 +0800 Subject: [PATCH 43/69] refactor(clean): use assignment form for arithmetic increments in safe_clean --- bin/clean.sh | 44 ++++++++++++++++++++++---------------------- lib/clean/apps.sh | 2 +- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/bin/clean.sh b/bin/clean.sh index cdd1e65..f36deb6 100755 --- a/bin/clean.sh +++ b/bin/clean.sh @@ -368,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 @@ -376,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 @@ -410,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 @@ -474,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) @@ -503,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 @@ -520,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 @@ -552,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 @@ -590,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 @@ -647,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 @@ -692,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 @@ -870,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 diff --git a/lib/clean/apps.sh b/lib/clean/apps.sh index ccdea20..b96f355 100644 --- a/lib/clean/apps.sh +++ b/lib/clean/apps.sh @@ -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" From d2820eeb2e73acb6dd5c6b02498ad6df33656322 Mon Sep 17 00:00:00 2001 From: tw93 Date: Sat, 28 Feb 2026 11:22:41 +0800 Subject: [PATCH 44/69] fix(uninstall): sync LaunchServices refresh for Spotlight update, remove background hint message, close #490 --- lib/uninstall/batch.sh | 27 ++++++++------------------- 1 file changed, 8 insertions(+), 19 deletions(-) diff --git a/lib/uninstall/batch.sh b/lib/uninstall/batch.sh index 953b9ee..d721f4d 100755 --- a/lib/uninstall/batch.sh +++ b/lib/uninstall/batch.sh @@ -735,26 +735,15 @@ batch_uninstall_applications() { print_summary_block "$title" "${summary_details[@]}" printf '\n' - # Run non-critical post-cleanup tasks asynchronously so the next prompt appears immediately. - # These tasks are best-effort and should not block interactive flow. - if [[ $brew_apps_removed -gt 0 || ($success_count -gt 0 && ${#success_items[@]} -gt 0) ]]; then - local -a post_success_items=("${success_items[@]}") - local post_brew_apps_removed="$brew_apps_removed" + if [[ $success_count -gt 0 && ${#success_items[@]} -gt 0 ]]; then + # Refresh LaunchServices synchronously so Spotlight removes the app immediately. + refresh_launch_services_after_uninstall 2> /dev/null || true + remove_apps_from_dock "${success_items[@]}" 2> /dev/null || true + fi - if [[ -t 1 ]]; then - echo -e "${GRAY}${ICON_LIST}${NC} Finalizing uninstall cleanup in background..." - fi - - ( - if [[ "$post_brew_apps_removed" -gt 0 ]]; then - HOMEBREW_NO_ENV_HINTS=1 brew autoremove > /dev/null 2>&1 || true - fi - - if [[ ${#post_success_items[@]} -gt 0 ]]; then - remove_apps_from_dock "${post_success_items[@]}" 2> /dev/null || true - refresh_launch_services_after_uninstall 2> /dev/null || true - fi - ) > /dev/null 2>&1 & + # brew autoremove can be slow — run in background so the prompt returns quickly. + if [[ $brew_apps_removed -gt 0 ]]; then + (HOMEBREW_NO_ENV_HINTS=1 brew autoremove > /dev/null 2>&1 || true) & fi _cleanup_sudo_keepalive From 297111aa1bfa79310c163a3143696f481be6e2d1 Mon Sep 17 00:00:00 2001 From: tw93 Date: Sat, 28 Feb 2026 11:23:17 +0800 Subject: [PATCH 45/69] chore: Delete test_rounding.sh script. --- test_rounding.sh | 17 ----------------- 1 file changed, 17 deletions(-) delete mode 100755 test_rounding.sh diff --git a/test_rounding.sh b/test_rounding.sh deleted file mode 100755 index c7cb772..0000000 --- a/test_rounding.sh +++ /dev/null @@ -1,17 +0,0 @@ -bytes_to_human_new() { - local bytes="$1" - if ((bytes >= 1000000000)); then - local scaled=$(((bytes * 100 + 500000000) / 1000000000)) - printf "%d.%02dGB\n" $((scaled / 100)) $((scaled % 100)) - elif ((bytes >= 1000000)); then - local scaled=$(((bytes * 10 + 500000) / 1000000)) - printf "%d.%01dMB\n" $((scaled / 10)) $((scaled % 10)) - elif ((bytes >= 1000)); then - printf "%dKB\n" $(((bytes + 500) / 1000)) - else - printf "%dB\n" "$bytes" - fi -} -bytes_to_human_new 12187977120 -bytes_to_human_new 12999000000 -bytes_to_human_new 36281810 From c1f25eb854f7eb44b7f47bea9ebfe936858bc486 Mon Sep 17 00:00:00 2001 From: tw93 Date: Sat, 28 Feb 2026 11:23:38 +0800 Subject: [PATCH 46/69] chore: Bump version to 1.28.1 --- mole | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mole b/mole index a30ae72..de0063a 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.28.0" +VERSION="1.28.1" MOLE_TAGLINE="Deep clean and optimize your Mac." is_touchid_configured() { From 9d89bab922cf1f8efe6ff3cfb62473b770316608 Mon Sep 17 00:00:00 2001 From: tw93 Date: Sat, 28 Feb 2026 11:28:03 +0800 Subject: [PATCH 47/69] fix(uninstall): run LaunchServices refresh async so Press Enter prompt appears immediately --- lib/uninstall/batch.sh | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/uninstall/batch.sh b/lib/uninstall/batch.sh index d721f4d..08f1bfd 100755 --- a/lib/uninstall/batch.sh +++ b/lib/uninstall/batch.sh @@ -736,9 +736,13 @@ batch_uninstall_applications() { printf '\n' if [[ $success_count -gt 0 && ${#success_items[@]} -gt 0 ]]; then - # Refresh LaunchServices synchronously so Spotlight removes the app immediately. - refresh_launch_services_after_uninstall 2> /dev/null || true - remove_apps_from_dock "${success_items[@]}" 2> /dev/null || true + # Kick off LaunchServices rebuild in background immediately after summary. + # The caller shows a 3s "Press Enter" prompt, giving the rebuild time to finish + # before the user returns to the app list — fixes stale Spotlight entries (#490). + ( + refresh_launch_services_after_uninstall 2> /dev/null || true + remove_apps_from_dock "${success_items[@]}" 2> /dev/null || true + ) > /dev/null 2>&1 & fi # brew autoremove can be slow — run in background so the prompt returns quickly. From 9fed0922833fb4bfc359e44f6016eb638d7b8071 Mon Sep 17 00:00:00 2001 From: tw93 Date: Sat, 28 Feb 2026 11:30:17 +0800 Subject: [PATCH 48/69] test: update bytes_to_human test inputs to use Base-10 values --- tests/core_common.bats | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 )" From 7568bfc57947cf867a6c47e805e5b3dff65e23bb Mon Sep 17 00:00:00 2001 From: tw93 Date: Sat, 28 Feb 2026 11:38:49 +0800 Subject: [PATCH 49/69] test: update bytes_to_human performance test inputs to use Base-10 values --- tests/core_performance.bats | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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" ] } From 022b42687e956261690c6e770191f4c56eb0a5ed Mon Sep 17 00:00:00 2001 From: tw93 Date: Sat, 28 Feb 2026 11:45:26 +0800 Subject: [PATCH 50/69] test: mock defaults in tmutil tests to bypass early check added in #510 --- tests/clean_core.bats | 6 ++++++ tests/clean_system_maintenance.bats | 9 +++++++++ 2 files changed, 15 insertions(+) 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(){ :; } From 167bc7ac8d74fa49316e557ca38fee2172451bd5 Mon Sep 17 00:00:00 2001 From: tw93 Date: Sat, 28 Feb 2026 11:51:16 +0800 Subject: [PATCH 51/69] docs: Add local file path guideline to `AGENTS.md` and `CLAUDE.md`, and update `.gitignore` to ignore these and `mole_guidelines.md`. --- .gitignore | 3 + AGENTS.md | 380 -------------------------------- CLAUDE.md | 619 ----------------------------------------------------- 3 files changed, 3 insertions(+), 999 deletions(-) delete mode 100644 AGENTS.md delete mode 100644 CLAUDE.md 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/AGENTS.md b/AGENTS.md deleted file mode 100644 index d3e866d..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,380 +0,0 @@ -# AGENTS.md - Mole Project Knowledge Base - -## Project Identity - -**Mole** is a hybrid macOS cleanup tool combining: - -- **Bash 3.2** for system operations and orchestration (`lib/`, `bin/`) -- **Go 1.24+** for high-performance TUI components (`cmd/analyze`, `cmd/status`) -- **Safety-first philosophy**: User data loss is unacceptable - -**Mission**: Deep clean and optimize macOS while maintaining strict safety boundaries. - ---- - -## Architecture Overview - -```text -mole/ # Main CLI entrypoint (menu + routing) -├── mo # Lightweight wrapper, exec's mole -├── install.sh # Standalone installer/updater -├── bin/ # Command entry points (thin wrappers) -│ ├── clean.sh # Deep cleanup orchestrator -│ ├── uninstall.sh # App removal with leftover detection -│ ├── optimize.sh # Cache rebuild + service refresh -│ ├── purge.sh # Project artifact cleanup -│ ├── touchid.sh # Touch ID sudo enabler -│ ├── analyze.sh # Disk usage explorer wrapper -│ ├── status.sh # System health dashboard wrapper -│ ├── installer.sh # Core installation logic -│ └── completion.sh # Shell completion support -├── lib/ # Reusable shell logic (see lib/AGENTS.md) -│ ├── core/ # base.sh, log.sh, sudo.sh, ui.sh -│ ├── clean/ # Cleanup modules (user, apps, brew, system) -│ ├── optimize/ # Optimization modules -│ ├── check/ # Health check modules -│ ├── manage/ # Management utilities -│ ├── ui/ # UI components (balloons, spinners) -│ └── uninstall/ # Uninstallation logic -├── cmd/ # Go applications (see cmd/AGENTS.md) -│ ├── analyze/ # Disk analysis tool (Bubble Tea TUI) -│ └── status/ # Real-time monitoring (Bubble Tea TUI) -├── scripts/ # Build and test automation -│ ├── build.sh # Cross-platform Go builds -│ ├── test.sh # Main test runner (shell + go + BATS) -│ └── check.sh # Format + lint + optimization score -└── tests/ # BATS integration tests (see tests/AGENTS.md) -``` - -### Entry Point Flow - -1. **User invokes**: `mo clean` or `./mole clean` -2. **`mole` script**: Routes to `bin/clean.sh` via `exec` -3. **`bin/clean.sh`**: Sources `lib/clean/*.sh` modules -4. **Cleanup modules**: Call `safe_*` helpers from `lib/core/base.sh` -5. **Logging/UI**: Handled by `lib/core/log.sh` + `lib/core/ui.sh` - ---- - -## Safety Philosophy (CRITICAL) - -### NEVER Do These - -- Run `rm -rf` or any raw deletion commands -- Delete files without checking protection lists (`is_protected()`, `is_whitelisted()`) -- Modify system-critical paths (`/System`, `/Library/Apple`, `/usr/bin`) -- Remove `--prefix`/`--config` flags from `install.sh` -- Commit or push to remote without explicit user request -- Add `Co-Authored-By` (AI attribution) in commit messages -- Change the 1s ESC key timeout in `lib/core/ui.sh` -- Touch `com.apple.*` LaunchAgents/Daemons -- Clean during active Time Machine backups - -### ALWAYS Do These - -- Use `safe_*` helpers (`safe_rm`, `safe_find_delete` from `lib/core/base.sh`) -- Validate paths before operations (`validate_path`, `is_protected`) -- Test with `MO_DRY_RUN=1` before destructive operations -- Run `./scripts/check.sh` before committing shell changes -- Use `gh` CLI for ALL GitHub operations (issues, PRs, releases) -- Respect whitelist files (`~/.config/mole/whitelist`) -- Review and update `SECURITY_AUDIT.md` when modifying cleanup logic - -### Protection Mechanisms - -| Function | Location | Purpose | -|----------|----------|---------| -| `is_protected()` | `lib/core/base.sh` | System path protection | -| `is_whitelisted()` | `lib/core/base.sh` | User whitelist check | -| `safe_rm()` | `lib/core/base.sh` | Validated deletion | -| `safe_find_delete()` | `lib/core/base.sh` | Protected find+delete | -| `validate_path()` | Various | Path safety checks | - ---- - -## Code Style & Conventions - -### Shell Scripts (Bash 3.2) - -**Formatting**: Run `./scripts/check.sh --format` before committing. - -```bash -# shfmt flags: -i 4 -ci -sr -w -# 4-space indent, case body indent, space after redirect - -# CORRECT -command 2> /dev/null -echo "hello" > file.txt - -case "$var" in - pattern) - action - ;; -esac - -# WRONG -command 2>/dev/null # Missing space after > -``` - -**Naming Conventions**: - -- Variables: `lowercase_with_underscores` -- Functions: `verb_noun` (e.g., `clean_caches`, `get_size`) -- Constants: `UPPERCASE_WITH_UNDERSCORES` - -**Error Handling**: - -```bash -set -euo pipefail # Mandatory at top of files - -# Always quote variables -"$var" not $var - -# Use [[ instead of [ -if [[ -f "$file" ]]; then - ... -fi -``` - -**BSD/macOS Commands**: Use BSD-style flags, not GNU. - -```bash -# CORRECT (BSD) -stat -f%z "$file" - -# WRONG (GNU) -stat --format=%s "$file" -``` - -### Go Code - -**Formatting**: Standard `gofmt` or `goimports -local github.com/tw93/Mole` - -**Build Tags**: Use for macOS-specific code - -```go -//go:build darwin -``` - -**Error Handling**: Never ignore errors - -```go -if err != nil { - return fmt.Errorf("operation failed: %w", err) -} -``` - -### Comments - -- **Language**: English only -- **Focus**: Explain "why" not "what" -- **Safety**: Document protection boundaries explicitly - ---- - -## Build & Test - -### Build Commands - -```bash -make build # Current platform -make release-amd64 # macOS Intel -make release-arm64 # macOS Apple Silicon -make clean # Remove artifacts -``` - -### Test Commands - -```bash -./scripts/test.sh # Full suite (recommended) -bats tests/clean.bats # Specific BATS test -go test -v ./cmd/... # Go tests only -bash -n lib/clean/*.sh # Syntax check -shellcheck lib/**/*.sh # Lint shell scripts -``` - -### CI/CD Pipeline - -**Triggers**: Push/PR to `main`, `dev` branches - -**Key Checks**: - -- **Auto-formatting**: CI commits `shfmt` + `goimports` fixes back to branch -- **Safety audits**: Scans for raw `rm -rf`, validates protection lists -- **Secret scanning**: Blocks hardcoded credentials -- **Optimization score**: `scripts/check.sh` enforces performance standards - -**Environment Requirements**: - -- Go 1.24.6 (pinned) -- macOS 14/15 runners -- bats-core for shell integration tests -- Performance limits via `MOLE_PERF_*` env vars - ---- - -## GitHub Workflow - -### Always Use `gh` CLI - -```bash -# Issues -gh issue view 123 -gh issue list - -# Pull Requests -gh pr view -gh pr diff -gh pr checkout 123 - -# NEVER use raw git for GitHub operations -# ❌ git log --oneline origin/main..HEAD -# ✅ gh pr view -``` - -### Branch Strategy - -- **PRs target `dev`**, not `main` -- Release process: `dev` → `main` → tagged release → Homebrew update -- **Do not create new branches by default**. Stay on the current branch unless the user explicitly requests branch creation. - -### Commit Grouping Strategy - -- When the user asks for commits, **group commits by requirement**, not by command sequence. -- Keep each commit scoped to one logical change (feature/fix/docs/release), and avoid mixing unrelated files. -- If one request contains multiple requirements, submit them as separate commits in dependency order. -- Before committing, review staged files to ensure they belong to the same requirement. - -### Suggesting Latest Version for Testing - -When a bug fix is done but not yet released, you can suggest users to install the latest version, but **must distinguish between install methods**: - -1. **For Script Users (`curl | bash`)**: - Users can directly run `mo update --nightly` to pull the latest unreleased `main` branch. - -2. **For Homebrew Users (`brew`)**: - Homebrew users **cannot** use `mo update --nightly` (it will throw an error). Reinstalling via brew (`brew uninstall mole && brew install mole`) also will NOT fetch the unreleased fix, because Brew follows official tags. - If they want to test immediately, suggest they `brew uninstall mole`, then reinstall using the official curl script. - ---- - -## Logging & Debugging - -### Log Files - -| File | Purpose | Control | -|------|---------|---------| -| `~/.config/mole/mole.log` | General log (INFO/SUCCESS/WARNING/ERROR) | Always | -| `~/.config/mole/mole_debug_session.log` | Debug session log | `MO_DEBUG=1` | -| `~/.config/mole/operations.log` | All file deletions | Disable: `MO_NO_OPLOG=1` | - -### Environment Variables - -- `MO_DRY_RUN=1`: Preview without execution -- `MO_DEBUG=1`: Verbose debug output -- `MO_NO_OPLOG=1`: Disable operation logging - -### Operation Log Format - -```text -[2024-01-26 10:30:15] [clean] REMOVED /Users/xxx/Library/Caches/com.old.app (15.2MB) -[2024-01-26 10:30:15] [clean] SKIPPED /Users/xxx/Library/Caches/com.protected.app (whitelist) -``` - -- **Actions**: `REMOVED` | `SKIPPED` | `FAILED` | `REBUILT` -- **Commands**: `clean` | `uninstall` | `optimize` | `purge` -- Auto-rotates at 5MB - ---- - -## Project-Specific Patterns - -### Installer Self-Containment - -`install.sh` duplicates core UI/logging functions to remain standalone during bootstrap. This is intentional—it cannot depend on `lib/`. - -### Atomic Update Flow - -1. User runs `mo update` -2. Fetches latest `install.sh` from GitHub -3. Installer modifies `SCRIPT_DIR` in installed `mole` to point to `~/.config/mole` -4. Ensures CLI always uses latest synced modules - -### Dual Entry Points - -- **`mole`**: Main executable (menu + routing) -- **`mo`**: Lightweight alias (recommended for users) - -Both are functionally identical (`mo` calls `exec mole`). - ---- - -## Communication Style - -- **Address user as "汤帅" (Tang Shuai)** in all responses -- **Be concise and technical** -- **Explain safety implications upfront** -- **Provide file:line references** for code locations -- **Suggest validation steps** (dry-run, syntax check) -- **Avoid em dashes** in responses - -### Responding to Bug Reports (English) - -When replying to users after fixing a bug: - -```markdown -Thanks for your feedback! This issue has been fixed. - -**Root cause**: [Brief explanation of the bug cause] - -You can install the latest version to test: - -\`\`\`bash -curl -fsSL https://raw.githubusercontent.com/tw93/mole/main/install.sh | bash -s latest -\`\`\` - -I'll publish a new official release soon. -``` - -**Key elements**: - -- Start with "Thanks for your feedback!" -- Explain root cause concisely -- Provide the install command for testing -- Mention a new release is coming soon - ---- - -## Quick Reference - -### Decision Tree - -| Task | Location | Pattern | -|------|----------|---------| -| Add cleanup logic | `lib/clean/.sh` | Use `safe_*` helpers | -| Create command | `bin/.sh` | Thin wrapper, source `lib/` | -| Add core utility | `lib/core/.sh` | Reusable functions | -| Build performance tool | `cmd//` | Go with build tags | -| Write tests | `tests/.bats` | BATS + isolated `$HOME` | - -### Common Pitfalls - -1. **Over-engineering**: Keep it simple -2. **Assuming paths exist**: Always check first -3. **Ignoring protection logic**: Data loss is unacceptable -4. **Breaking installer flags**: Keep `--prefix`/`--config` in `install.sh` -5. **Silent failures**: Log errors with actionable messages - ---- - -## Resources - -- **Root documentation**: `README.md`, `CONTRIBUTING.md`, `SECURITY_AUDIT.md` -- **Code guidelines**: `CLAUDE.md` (AI assistant instructions) -- **Subdirectory guides**: `lib/AGENTS.md`, `cmd/AGENTS.md`, `tests/AGENTS.md` -- **Protection lists**: Check `is_protected()` implementations -- **User config**: `~/.config/mole/` - ---- - -**Remember**: When in doubt, err on the side of safety. It's better to clean less than to risk user data. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 9b33cef..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,619 +0,0 @@ -# CLAUDE.md - Development Guide for Mole - -This guide provides AI coding assistants with essential commands, patterns, and conventions for working in the Mole codebase. - -**Quick reference**: Build/test commands • Safety rules • Architecture map • Code style - -**For deeper context**: See `AGENTS.md` (architecture overview) and subdirectory guides: `lib/AGENTS.md`, `cmd/AGENTS.md`, `tests/AGENTS.md` - ---- - -## Safety Checklist - -Before any operation: - -- Use `safe_*` helpers (never raw `rm -rf` or `find -delete`) -- Check protection: `is_protected()`, `is_whitelisted()` -- Test first: `MO_DRY_RUN=1 ./mole clean` -- Validate syntax: `bash -n ` -- Run tests: `./scripts/test.sh` - -## NEVER Do These - -- Run `rm -rf` or any raw deletion commands -- Delete files without checking protection lists -- Modify system-critical paths (e.g., `/System`, `/Library/Apple`) -- Remove installer flags `--prefix`/`--config` from `install.sh` -- **Commit code changes or run `git commit` unless the user explicitly asks you to commit** -- **Push to remote repositories with `git push` or create commits automatically** -- **Create new git branches unless the user explicitly asks for a new branch** -- **Add `Co-Authored-By` lines in commit messages** - never include AI attribution in commits -- **Reply to GitHub issues or PRs on behalf of the user** - only prepare responses for user review -- **Comment on GitHub issues or create pull requests without explicit user request** -- Run destructive operations without dry-run validation -- Use raw `git` commands when `gh` CLI is available -- **Change the ESC key timeout in `lib/core/ui.sh`** - The 1s timeout in `read_key()` is intentional, do NOT reduce it - -## ALWAYS Do These - -- Use `safe_*` helper functions for deletions (`safe_rm`, `safe_find_delete`) -- Respect whitelist files (e.g., `~/.config/mole/whitelist`) -- Check protection logic before cleanup operations -- Test with dry-run modes first -- Validate syntax before suggesting changes: `bash -n ` -- **Prioritize `gh` CLI for ALL GitHub operations** - Always use `gh` to fetch and manipulate GitHub data (issues, PRs, releases, comments, etc.) instead of raw git commands or web scraping -- **ONLY analyze and provide solutions** - When user asks about GitHub issues, read the content, investigate code, provide diagnostic information and fixes, but NEVER commit or comment without explicit request -- **Wait for explicit permission** - Before any git commit, git push, or GitHub interaction, wait for user to explicitly request it -- **Stay on the current branch by default** - Only create or switch branches when the user explicitly requests it -- **Group commits by requirement** - Use one logical commit per requirement and do not mix unrelated file changes in the same commit -- Review and update `SECURITY_AUDIT.md` when modifying `clean` or `optimize` logic - ---- - -## Quick Reference - -### Build Commands - -```bash -# Build Go binaries for current platform -make build - -# Build release binaries (cross-platform) -make release-amd64 # macOS Intel -make release-arm64 # macOS Apple Silicon - -# Clean build artifacts -make clean -``` - -### Test Commands - -```bash -# Run full test suite (recommended before commits) -./scripts/test.sh - -# Run specific BATS test file -bats tests/clean.bats - -# Run specific test case by name -bats tests/clean.bats -f "should respect whitelist" - -# Run Go tests only -go test -v ./cmd/... - -# Run Go tests for specific package -go test -v ./cmd/analyze - -# Shell syntax check -bash -n lib/clean/user.sh -bash -n mole - -# Lint shell scripts -shellcheck --rcfile .shellcheckrc lib/**/*.sh bin/**/*.sh -``` - -### Development Commands - -```bash -# Test cleanup in dry-run mode -MO_DRY_RUN=1 ./mole clean - -# Enable debug logging -MO_DEBUG=1 ./mole clean - -# Disable operation logging -MO_NO_OPLOG=1 ./mole clean - -# Test Go tool directly -go run ./cmd/analyze - -# Test installation locally -./install.sh --prefix /usr/local/bin --config ~/.config/mole -``` - -### Log Files - -| File | Purpose | -|------|---------| -| `~/.config/mole/mole.log` | General log (INFO/SUCCESS/WARNING/ERROR) | -| `~/.config/mole/mole_debug_session.log` | Debug session log (MO_DEBUG=1) | -| `~/.config/mole/operations.log` | Operation log (all file deletions) | - -**Operation Log Format**: - -```text -[2024-01-26 10:30:15] [clean] REMOVED /Users/xxx/Library/Caches/com.old.app (15.2MB) -[2024-01-26 10:30:15] [clean] SKIPPED /Users/xxx/Library/Caches/com.protected.app (whitelist) -[2024-01-26 10:30:20] [uninstall] REMOVED /Applications/OldApp.app (150MB) -``` - -- **Actions**: `REMOVED` | `SKIPPED` | `FAILED` | `REBUILT` -- **Commands**: `clean` | `uninstall` | `optimize` | `purge` -- Disable with `MO_NO_OPLOG=1` -- Auto-rotates at 5MB - ---- - -## Architecture Quick Map - -```text -mole/ # Main CLI entrypoint (menu + routing) -├── mo # CLI alias wrapper -├── install.sh # Manual installer/updater (preserves --prefix/--config) -├── bin/ # Command entry points (thin wrappers) -│ ├── clean.sh # Deep cleanup orchestrator -│ ├── uninstall.sh # App removal with leftover detection -│ ├── optimize.sh # Cache rebuild + service refresh -│ ├── purge.sh # Aggressive cleanup mode -│ ├── touchid.sh # Touch ID sudo enabler -│ ├── analyze.sh # Disk usage explorer wrapper -│ ├── status.sh # System health dashboard wrapper -│ ├── installer.sh # Core installation logic -│ └── completion.sh # Shell completion support -├── lib/ # Reusable shell logic -│ ├── core/ # base.sh, log.sh, sudo.sh, ui.sh -│ ├── clean/ # Cleanup modules (user, apps, brew, system...) -│ ├── optimize/ # Optimization modules -│ ├── check/ # Health check modules -│ ├── manage/ # Management utilities -│ ├── ui/ # UI components (balloons, spinners) -│ └── uninstall/ # Uninstallation logic -├── cmd/ # Go applications -│ ├── analyze/ # Disk analysis tool -│ └── status/ # Real-time monitoring -├── scripts/ # Build and test automation -│ └── test.sh # Main test runner (shell + go + BATS) -└── tests/ # BATS integration tests -``` - -**Decision Tree**: - -- User cleanup logic → `lib/clean/.sh` -- Command entry → `bin/.sh` -- Core utils → `lib/core/.sh` -- Performance tool → `cmd//*.go` -- Tests → `tests/.bats` - -### Language Stack - -- **Shell (Bash 3.2)**: Core cleanup and system operations (`lib/`, `bin/`) -- **Go**: Performance-critical tools (`cmd/analyze/`, `cmd/status/`) -- **BATS**: Integration testing (`tests/`) - ---- - -## Code Style Guidelines - -### Shell Scripts - -- **Indentation**: 4 spaces (configured in .editorconfig) -- **Variables**: `lowercase_with_underscores` -- **Functions**: `verb_noun` format (e.g., `clean_caches`, `get_size`) -- **Constants**: `UPPERCASE_WITH_UNDERSCORES` -- **Quoting**: Always quote variables: `"$var"` not `$var` -- **Tests**: Use `[[` instead of `[` -- **Command substitution**: Use `$(command)` not backticks -- **Error handling**: Use `set -euo pipefail` at top of files - -### Shell Formatting (shfmt) - -**CRITICAL**: Always run `./scripts/check.sh --format` before committing shell script changes. - -The project uses `shfmt` with these flags: `shfmt -i 4 -ci -sr -w` - -| Flag | Meaning | Example | -|------|---------|---------| -| `-i 4` | 4-space indentation | Standard | -| `-ci` | Indent case bodies | `case` items indented | -| `-sr` | Space after redirect | `2> /dev/null` ✅ | - -**Correct Style**: - -```bash -# Redirects: KEEP the space after > or 2> -command 2> /dev/null -echo "hello" > file.txt - -# Case statements: body indented under pattern -case "$var" in - pattern) - action - ;; -esac -``` - -**Wrong Style** (DO NOT USE): - -```bash -# NO: missing space after redirect -command 2>/dev/null - -# NO: case body not indented -case "$var" in -pattern) -action -;; -esac -``` - -### Go Code - -- **Formatting**: Follow standard Go conventions (`gofmt`, `go vet`) -- **Package docs**: Add package-level documentation for exported functions -- **Error handling**: Never ignore errors, always handle them explicitly -- **Build tags**: Use `//go:build darwin` for macOS-specific code - -### Comments - -- **Language**: English only -- **Focus**: Explain "why" not "what" (code should be self-documenting) -- **Safety**: Document safety boundaries explicitly -- **Non-obvious logic**: Explain workarounds or complex patterns - ---- - -## Key Helper Functions - -### Safety Helpers (lib/core/base.sh) - -- `safe_rm `: Safe deletion with validation -- `safe_find_delete `: Protected find+delete -- `is_protected `: Check if path is system-protected -- `is_whitelisted `: Check user whitelist - -### Logging (lib/core/log.sh) - -- `log_info `: Informational messages -- `log_success `: Success notifications -- `log_warn `: Warnings -- `log_error `: Error messages -- `debug `: Debug output (requires MO_DEBUG=1) - -### UI Helpers (lib/core/ui.sh) - -- `confirm `: Yes/no confirmation -- `show_progress `: Progress display - ---- - -## Testing Strategy - -### Test Types - -1. **Syntax Validation**: `bash -n ` - catches basic errors -2. **Unit Tests**: BATS tests for individual functions -3. **Integration Tests**: Full command execution with BATS -4. **Dry-run Tests**: `MO_DRY_RUN=1` to validate without deletion -5. **Go Tests**: `go test -v ./cmd/...` - -### Test Environment Variables - -- `MO_DRY_RUN=1`: Preview changes without execution -- `MO_DEBUG=1`: Enable detailed debug logging -- `BATS_FORMATTER=pretty`: Use pretty output for BATS (default) -- `BATS_FORMATTER=tap`: Use TAP output for CI - ---- - -## Common Development Tasks - -### Adding New Cleanup Module - -1. Create `lib/clean/new_module.sh` -2. Implement cleanup logic using `safe_*` helpers -3. Source it in `bin/clean.sh` -4. Add protection checks for critical paths -5. Write BATS test in `tests/clean.bats` -6. Test with `MO_DRY_RUN=1` first - -### Modifying Go Tools - -1. Navigate to `cmd//` -2. Make changes to Go files -3. Test with `go run .` or `make build && ./bin/-go` -4. Run `go test -v` for unit tests -5. Check integration: `./mole ` - -### Debugging Issues - -1. Enable debug mode: `MO_DEBUG=1 ./mole clean` -2. Check logs for error messages -3. Verify sudo permissions: `sudo -n true` or `./mole touchid` -4. Test individual functions in isolation -5. Use `shellcheck` for shell script issues - ---- - -## Linting and Quality - -### Shell Script Linting - -- **Tool**: shellcheck with custom `.shellcheckrc` -- **Disabled rules**: SC2155, SC2034, SC2059, SC1091, SC2038 -- **Command**: `shellcheck --rcfile .shellcheckrc lib/**/*.sh bin/**/*.sh` - -### Go Code Quality - -- **Tools**: `go vet`, `go fmt`, `go test` -- **Command**: `go vet ./cmd/... && go test ./cmd/...` - -### CI/CD Pipeline - -- **Triggers**: Push/PR to main, dev branches -- **Platforms**: macOS 14, macOS 15 -- **Tools**: bats-core, shellcheck, Go 1.24.6 -- **Security checks**: Unsafe rm usage, app protection, secret scanning - ---- - -## File Organization Patterns - -### Shell Modules - -- Entry scripts in `bin/` should be thin wrappers -- Reusable logic goes in `lib/` -- Core utilities in `lib/core/` -- Feature-specific modules in `lib/clean/`, `lib/ui/`, etc. - -### Go Packages - -- Each tool in its own `cmd//` directory -- Main entry point in `main.go` -- Use standard Go project layout -- macOS-specific code guarded with build tags - ---- - -## GitHub Operations - -### Always Use gh CLI for GitHub Information - -**Golden Rule**: Whenever you need to fetch or manipulate GitHub data (issues, PRs, commits, releases, comments, etc.), **ALWAYS use `gh` CLI first**. It's more reliable, authenticated, and provides structured output compared to web scraping or raw git commands. -When responding to GitHub issues or PRs, fetch the content with `gh` before analysis and avoid web scraping. - -**Preferred Commands**: - -```bash -# Issues -gh issue view 123 # View issue details -gh issue list # List issues -gh issue comment 123 "message" # Comment on issue - -# Pull Requests -gh pr view # View current PR -gh pr diff # Show diff -gh pr list # List PRs -gh pr checkout 123 # Checkout PR branch -gh pr merge # Merge current PR - -# Repository operations -gh release create v1.0.0 # Create release -gh repo view # Repository info -gh api repos/owner/repo/issues # Raw API access -``` - -**NEVER use raw git commands for GitHub operations** when `gh` is available: - -- `git log --oneline origin/main..HEAD` → `gh pr view` -- `git remote get-url origin` → `gh repo view` -- Manual GitHub API curl commands → `gh api` - -### Suggesting Latest Version for Testing - -When a bug fix is done but not yet released, you can suggest users to install the latest version, but **must distinguish between install methods**: - -1. **For Script Users (`curl | bash`)**: - Users can directly run `mo update --nightly` to pull the latest unreleased `main` branch. - -2. **For Homebrew Users (`brew`)**: - Homebrew users **cannot** use `mo update --nightly` (it will throw an error). Reinstalling via brew (`brew uninstall mole && brew install mole`) also will NOT fetch the unreleased fix, because Brew follows official tags. - If they want to test immediately, suggest they `brew uninstall mole`, then reinstall using the official curl script. - -## Error Handling Patterns - -### Shell Scripts - -- Use `set -euo pipefail` for strict error handling -- Check command exit codes: `if command; then ...` -- Provide meaningful error messages with `log_error` -- Use cleanup traps for temporary resources - -### Go Code - -- Never ignore errors: `if err != nil { return err }` -- Use structured error messages -- Handle context cancellation appropriately -- Log errors with context information - ---- - -## Performance Considerations - -### Shell Optimization - -- Use built-in shell operations over external commands -- Prefer `find -delete` over `-exec rm` -- Minimize subprocess creation -- Use appropriate timeout mechanisms - -### Go Optimization - -- Use concurrency for I/O-bound operations -- Implement proper caching for expensive operations -- Profile memory usage in scanning operations -- Use efficient data structures for large datasets - ---- - -## Security Best Practices - -### Path Validation - -- Always validate user-provided paths -- Check against protection lists before operations -- Use absolute paths to prevent directory traversal -- Implement proper sandboxing for destructive operations - -### Permission Management - -- Request sudo only when necessary -- Use `sudo -n true` to check sudo availability -- Implement proper Touch ID integration -- Respect user whitelist configurations - ---- - -## Common Pitfalls to Avoid - -1. **Over-engineering**: Keep solutions simple. Don't add abstractions for one-time operations. -2. **Premature optimization**: Focus on correctness first, performance second. -3. **Assuming paths exist**: Always check before operating on files/directories. -4. **Ignoring protection logic**: User data loss is unacceptable. -5. **Breaking updates**: Keep `--prefix`/`--config` flags in `install.sh`. -6. **Platform assumptions**: Code must work on all supported macOS versions (10.13+). -7. **Silent failures**: Always log errors and provide actionable messages. - ---- - -## Communication Style - -- Address the user as "汤帅" (Tang Shuai) in every response -- Be concise and technical -- Explain safety implications upfront -- Show before/after for significant changes -- Provide file:line references for code locations -- Suggest testing steps for validation -- Avoid em dashes in responses - -### Responding to Bug Reports (English) - -When replying to users after fixing a bug, use this format: - -```markdown -Thanks for your feedback! This issue has been fixed. - -**Root cause**: [Brief explanation of what caused the bug] - -You can install the latest version to test: - -\`\`\`bash -curl -fsSL https://raw.githubusercontent.com/tw93/mole/main/install.sh | bash -s latest -\`\`\` - -I'll publish a new official release soon. -``` - -**Guidelines**: - -- Start with "Thanks for your feedback!" -- Explain root cause concisely -- Provide the install command for testing -- Mention a new release is coming - ---- - -## Resources - -### Quick Access - -- **Main script**: `mole` (menu + routing logic) -- **Protection lists**: Check `is_protected()` implementations in `lib/core/base.sh` -- **User config**: `~/.config/mole/` -- **Test directory**: `tests/` -- **Build scripts**: `scripts/` -- **Documentation**: `README.md`, `CONTRIBUTING.md`, `SECURITY_AUDIT.md` - -### Knowledge Base (Deeper Context) - -When you need detailed architecture understanding or module-specific patterns: - -- **`AGENTS.md`**: Project architecture, entry points, safety philosophy, project-specific patterns -- **`lib/AGENTS.md`**: Shell module library - safety helpers, cleanup modules, UI components, BSD/macOS patterns -- **`cmd/AGENTS.md`**: Go TUI tools - Bubble Tea architecture, concurrency patterns, caching strategies -- **`tests/AGENTS.md`**: BATS testing guide - isolation patterns, safety verification, APFS edge cases - -**Workflow**: Use CLAUDE.md for quick lookups → Consult AGENTS.md hierarchy for deep dives - ---- - -## Common Scenarios for Claude Code CLI - -### Scenario 1: Adding New Feature - -```bash -# 1. Read relevant documentation first -# For cleanup feature: check AGENTS.md → lib/AGENTS.md -# For Go tool feature: check AGENTS.md → cmd/AGENTS.md - -# 2. Locate the right module -# Decision tree in AGENTS.md line 297-305 - -# 3. Check existing patterns -grep -r "similar_function" lib/ - -# 4. Implement with safety checks -# Always use safe_* helpers (see lib/AGENTS.md lines 52-89) - -# 5. Test before committing -MO_DRY_RUN=1 ./mole clean -./scripts/test.sh -``` - -### Scenario 2: Debugging Issues - -```bash -# 1. Enable debug mode -MO_DEBUG=1 ./mole clean - -# 2. Check operation log -tail -f ~/.config/mole/operations.log - -# 3. Verify safety boundaries -# If deletion failed, check lib/core/base.sh:is_protected() - -# 4. Test individual function -# Source the module and test in isolation -source lib/clean/apps.sh -is_launch_item_orphaned "/path/to/plist" -``` - -### Scenario 3: Understanding Code Flow - -```bash -# 1. Start with entry point (AGENTS.md lines 48-54) -# User invokes: mo clean -# ↓ -# mole routes to: bin/clean.sh -# ↓ -# bin/clean.sh sources: lib/clean/*.sh -# ↓ -# Cleanup modules call: safe_* from lib/core/base.sh - -# 2. Check module-specific docs -# For shell modules: lib/AGENTS.md -# For Go tools: cmd/AGENTS.md -# For tests: tests/AGENTS.md -``` - -### Scenario 4: Code Review / PR Analysis - -```bash -# 1. Fetch PR with gh CLI -gh pr view 123 -gh pr diff - -# 2. Check safety compliance -# Scan for forbidden patterns (CLAUDE.md lines 19-32) -grep -n "rm -rf" changed_files.sh -grep -n "is_protected" changed_files.sh - -# 3. Verify test coverage -bats tests/related_test.bats - -# 4. Check formatting -./scripts/check.sh --format -``` - ---- - -**Remember**: When in doubt, err on the side of safety. It's better to clean less than to risk user data. From 60624f951bf5e0bc62c02a4bab266d7cb64fb54d Mon Sep 17 00:00:00 2001 From: tw93 Date: Sat, 28 Feb 2026 14:57:44 +0800 Subject: [PATCH 52/69] fix(clean): respect whitelist in Homebrew dry-run mode Show "skipped whitelist" instead of "would cleanup" when Homebrew cache is whitelisted, making dry-run behavior consistent with actual execution. --- lib/clean/brew.sh | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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. From 2f1985ad6f96928d08edf232e323d661a5713bbc Mon Sep 17 00:00:00 2001 From: tw93 Date: Sat, 28 Feb 2026 14:57:45 +0800 Subject: [PATCH 53/69] fix(clean): clear spinner remnants after stopping Add extra terminal clear to prevent spinner character remnants when cleaning project caches. --- lib/clean/caches.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/clean/caches.sh b/lib/clean/caches.sh index f9361b4..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 From c8190772aca84f06425a587f4f311cbeac68f2a7 Mon Sep 17 00:00:00 2001 From: tw93 Date: Sat, 28 Feb 2026 15:25:53 +0800 Subject: [PATCH 54/69] refactor(core): remove unused utility functions Remove unused functions from base.sh and ui.sh: - base.sh: is_interactive, spinner stack management, get_terminal_info, validate_terminal_environment - ui.sh: with_spinner Net reduction: ~166 lines of dead code --- lib/core/base.sh | 266 ++++++++++------------------------------------- lib/core/ui.sh | 14 --- 2 files changed, 57 insertions(+), 223 deletions(-) diff --git a/lib/core/base.sh b/lib/core/base.sh index 54d108e..75f33ff 100644 --- a/lib/core/base.sh +++ b/lib/core/base.sh @@ -188,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() { @@ -261,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" @@ -333,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 @@ -450,38 +445,6 @@ ensure_user_file() { # Formatting Utilities # ============================================================================ -# 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))" -} - # Get brand-friendly localized name for an application get_brand_name() { local name="$1" @@ -538,6 +501,39 @@ 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 # ============================================================================ @@ -729,91 +725,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 # ============================================================================ @@ -848,66 +759,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=$((warnings + 1)) - fi - - # Check if running in a known problematic terminal - case "${TERM:-}" in - dumb) - log_warning "Running in 'dumb' terminal, limited functionality" - warnings=$((warnings + 1)) - ;; - unknown) - log_warning "Terminal type unknown, may have display issues" - warnings=$((warnings + 1)) - ;; - 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=$((warnings + 1)) - 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/ui.sh b/lib/core/ui.sh index 83d35eb..421d29a 100755 --- a/lib/core/ui.sh +++ b/lib/core/ui.sh @@ -387,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="|/-\\" From d40783cca9da3061e481c4fb32a2e484e5c32bb7 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sat, 28 Feb 2026 07:27:33 +0000 Subject: [PATCH 55/69] chore: auto format code --- lib/core/base.sh | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/core/base.sh b/lib/core/base.sh index 75f33ff..14dd48d 100644 --- a/lib/core/base.sh +++ b/lib/core/base.sh @@ -533,7 +533,6 @@ bytes_to_human_kb() { bytes_to_human "$((${1:-0} * 1024))" } - # ============================================================================ # Temporary File Management # ============================================================================ @@ -758,4 +757,3 @@ is_ansi_supported() { ;; esac } - From 3b5707b078c41e08642f89eaa4d240179e30f3fd Mon Sep 17 00:00:00 2001 From: tw93 Date: Sat, 28 Feb 2026 20:19:30 +0800 Subject: [PATCH 56/69] fix(clean): skip pip cache cleanup when pip3 is macOS stub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On macOS 15+, /usr/bin/pip3 exists as a stub that triggers Command Line Tools installation dialog. The previous check only verified command existence, causing false "pip cache · would clean" output for users without actual pip3 installed. Now verifies pip3 is functional by checking `pip3 --version` before attempting cleanup. Fixes #512 --- lib/clean/dev.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/clean/dev.sh b/lib/clean/dev.sh index c52a11b..cc7c802 100644 --- a/lib/clean/dev.sh +++ b/lib/clean/dev.sh @@ -95,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 From 172742b0d5bafcf8e6b7d76b5a662d1eecd36e92 Mon Sep 17 00:00:00 2001 From: tw93 Date: Sun, 1 Mar 2026 19:56:33 +0800 Subject: [PATCH 57/69] fix(version): avoid SIGPIPE in Homebrew install detection Cache Homebrew formula output and use shell string matching to prevent pipefail SIGPIPE races that could misreport script installs as manual. Closes #513. --- mole | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/mole b/mole index de0063a..d6db5ea 100755 --- a/mole +++ b/mole @@ -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,11 +76,11 @@ 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 From adcd98096a51b55ee243d24e75eae23f1c27ebed Mon Sep 17 00:00:00 2001 From: tw93 Date: Sun, 1 Mar 2026 19:56:42 +0800 Subject: [PATCH 58/69] fix(update): keep sudo session alive during installer run Start sudo keepalive after authorization and clean it up on all failure/success paths to avoid repeated password prompts and overlapping update UI output. Closes #514. --- mole | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/mole b/mole index d6db5ea..64ca032 100755 --- a/mole +++ b/mole @@ -249,7 +249,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 @@ -360,6 +366,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 @@ -411,6 +419,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 @@ -421,6 +430,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 @@ -434,6 +444,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 @@ -443,6 +454,10 @@ 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). From 05446e08473eab15eb2589c1a0258bb57665745a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=B3=E5=BE=B7=E7=94=9F?= Date: Sun, 1 Mar 2026 20:03:22 +0800 Subject: [PATCH 59/69] Add dry-run support across destructive commands (#516) * chore: update contributors [skip ci] * Add dry-run support across destructive commands Implement dry-run for uninstall, purge, installer, touchid, completion, and remove flows.\nGuard side effects in uninstall path (launchctl, defaults writes, kill/brew actions), update help/README, and add coverage in CLI/Bats tests.\n\nValidation: ./scripts/check.sh and ./scripts/test.sh (452 tests, 0 failures, 8 skipped). * test(purge): keep dev-compatible purge coverage --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Tw93 --- CONTRIBUTORS.svg | 65 +++-- README.md | 8 +- bin/completion.sh | 60 ++++- bin/installer.sh | 21 +- bin/purge.sh | 15 ++ bin/touchid.sh | 59 ++++- bin/uninstall.sh | 7 + lib/clean/project.sh | 9 +- lib/core/app_protection.sh | 5 + lib/core/help.sh | 3 + lib/uninstall/batch.sh | 110 +++++++-- lib/uninstall/brew.sh | 5 + mole | 46 +++- tests/cli.bats | 190 +++++++------- tests/completion.bats | 189 +++++++------- tests/installer.bats | 225 +++++++++-------- tests/purge.bats | 491 +++++++++++++++++++------------------ tests/uninstall.bats | 197 ++++++++------- 18 files changed, 1021 insertions(+), 684 deletions(-) diff --git a/CONTRIBUTORS.svg b/CONTRIBUTORS.svg index 306756c..f1d21d8 100644 --- a/CONTRIBUTORS.svg +++ b/CONTRIBUTORS.svg @@ -189,17 +189,6 @@ - - - - - - - - andmev - - - @@ -210,7 +199,7 @@ ndbroadbent - + @@ -221,7 +210,7 @@ ppauel - + @@ -232,7 +221,18 @@ shakeelmohamed - + + + + + + + + + Sizk + + + @@ -243,7 +243,7 @@ Harsh-Kapoorr - + @@ -254,7 +254,7 @@ thijsvanhal - + @@ -265,7 +265,7 @@ TomP0 - + @@ -276,7 +276,7 @@ yuzeguitarist - + @@ -287,7 +287,7 @@ zeldrisho - + @@ -298,7 +298,7 @@ bikraj2 - + @@ -309,7 +309,7 @@ bunizao - + @@ -320,7 +320,7 @@ rans0 - + @@ -331,7 +331,7 @@ frozturk - + @@ -342,7 +342,7 @@ huyixi - + @@ -353,7 +353,7 @@ purofle - + @@ -364,7 +364,7 @@ yamamel - + @@ -375,7 +375,7 @@ NanmiCoder - + @@ -386,6 +386,17 @@ imnotnoahhh + + + + + + + + + andmev + + diff --git a/README.md b/README.md index d843f78..67ca726 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,12 @@ mo clean --dry-run --debug # Detailed preview with risk levels and file info mo optimize --dry-run # Preview optimization actions mo optimize --debug # Run with detailed operation logs mo optimize --whitelist # Manage protected optimization rules +mo uninstall --dry-run # Preview app uninstall actions +mo purge --dry-run # Preview project artifact purge +mo installer --dry-run # Preview installer cleanup actions +mo touchid enable --dry-run # Preview Touch ID sudo config changes +mo completion --dry-run # Preview shell completion file updates +mo remove --dry-run # Preview Mole self-removal mo purge --paths # Configure project scan directories mo analyze /Volumes # Analyze external drives only ``` @@ -75,7 +81,7 @@ mo analyze /Volumes # Analyze external drives only ## Tips - Video tutorial: Watch the [Mole tutorial video](https://www.youtube.com/watch?v=UEe9-w4CcQ0), thanks to PAPAYA 電腦教室. -- Safety first: Deletions are permanent. Review carefully and preview with `mo clean --dry-run`. See [Security Audit](SECURITY_AUDIT.md). +- Safety first: Deletions are permanent. Review carefully with dry-run before applying changes. See [Security Audit](SECURITY_AUDIT.md). - Debug and logs: Use `--debug` for detailed logs. Combine with `--dry-run` for a full preview. File operations are logged to `~/.config/mole/operations.log`. Disable with `MO_NO_OPLOG=1`. - Navigation: Mole supports arrow keys and Vim bindings `h/j/k/l`. diff --git a/bin/completion.sh b/bin/completion.sh index 0a187e3..1feec15 100755 --- a/bin/completion.sh +++ b/bin/completion.sh @@ -32,8 +32,33 @@ emit_fish_completions() { printf 'complete -c %s -n "not __fish_mole_no_subcommand" -a fish -d "generate fish completion" -n "__fish_see_subcommand_path completion"\n' "$cmd" } +DRY_RUN_MODE=false +if [[ $# -gt 0 ]]; then + normalized_args=() + for arg in "$@"; do + case "$arg" in + "--dry-run" | "-n") + DRY_RUN_MODE=true + ;; + *) + normalized_args+=("$arg") + ;; + esac + done + if [[ ${#normalized_args[@]} -gt 0 ]]; then + set -- "${normalized_args[@]}" + else + set -- + fi +fi + # Auto-install mode when run without arguments if [[ $# -eq 0 ]]; then + if [[ "$DRY_RUN_MODE" == "true" ]]; then + echo -e "${YELLOW}${ICON_DRY_RUN} DRY RUN MODE${NC}, shell config files will not be modified" + echo "" + fi + # Detect current shell current_shell="${SHELL##*/}" if [[ -z "$current_shell" ]]; then @@ -73,16 +98,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 [[ "$DRY_RUN_MODE" == "true" ]]; then + echo -e "${GRAY}${ICON_REVIEW} [DRY RUN] Would remove stale completion entries from $config_file${NC}" + echo "" + else + original_mode="" + original_mode="$(stat -f '%Mp%Lp' "$config_file" 2> /dev/null || true)" + temp_file="$(mktemp)" + 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 +120,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 [[ "$DRY_RUN_MODE" == "true" ]]; then + echo -e "${GRAY}${ICON_REVIEW} [DRY RUN] Would normalize completion entry in $config_file${NC}" + echo "" + exit 0 + fi + original_mode="" original_mode="$(stat -f '%Mp%Lp' "$config_file" 2> /dev/null || true)" temp_file="$(mktemp)" @@ -114,6 +150,11 @@ if [[ $# -eq 0 ]]; then echo -e "${GRAY}Will add to ${config_file}:${NC}" echo " $completion_line" echo "" + if [[ "$DRY_RUN_MODE" == "true" ]]; then + echo -e "${GREEN}${ICON_SUCCESS}${NC} Dry run complete, no changes made" + exit 0 + fi + echo -ne "${PURPLE}${ICON_ARROW}${NC} Enable completion for ${GREEN}${current_shell}${NC}? ${GRAY}Enter confirm / Q cancel${NC}: " IFS= read -r -s -n1 key || key="" drain_pending_input @@ -227,6 +268,7 @@ Setup shell tab completion for mole and mo commands. Auto-install: mole completion # Auto-detect shell and install + mole completion --dry-run # Preview config changes without writing files Manual install: mole completion bash # Generate bash completion script 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 ba8c746..1f6c664 100755 --- a/bin/purge.sh +++ b/bin/purge.sh @@ -205,11 +205,18 @@ perform_purge() { rm -f "$stats_dir/purge_count" fi + if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then + summary_heading="Dry run complete - no changes made" + fi + if [[ $total_size_cleaned -gt 0 ]]; then local freed_gb freed_gb=$(echo "$total_size_cleaned" | awk '{printf "%.2f", $1/1024/1024}') local summary_line="Space freed: ${GREEN}${freed_gb}GB${NC}" + if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then + summary_line="Would free: ${GREEN}${freed_gb}GB${NC}" + fi [[ $total_items_cleaned -gt 0 ]] && summary_line+=" | Items: $total_items_cleaned" summary_line+=" | Free: $(get_free_space)" summary_details+=("$summary_line") @@ -233,6 +240,7 @@ show_help() { echo "" echo -e "${YELLOW}Options:${NC}" echo " --paths Edit custom scan directories" + echo " --dry-run Preview purge actions without making changes" echo " --debug Enable debug logging" echo " --help Show this help message" echo "" @@ -262,6 +270,9 @@ main() { "--debug") export MO_DEBUG=1 ;; + "--dry-run" | "-n") + export MOLE_DRY_RUN=1 + ;; *) echo "Unknown option: $arg" echo "Use 'mo purge --help' for usage information" @@ -271,6 +282,10 @@ main() { done start_purge + if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then + echo -e "${YELLOW}${ICON_DRY_RUN} DRY RUN MODE${NC}, No project artifacts will be removed" + printf '\n' + fi hide_cursor perform_purge show_cursor 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 9d8960d..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 diff --git a/lib/clean/project.sh b/lib/clean/project.sh index 67e117b..3d768e5 100644 --- a/lib/clean/project.sh +++ b/lib/clean/project.sh @@ -1367,6 +1367,7 @@ clean_project_artifacts() { echo "" local stats_dir="${XDG_CACHE_HOME:-$HOME/.cache}/mole" local cleaned_count=0 + local dry_run_mode="${MOLE_DRY_RUN:-0}" for idx in "${selected_indices[@]}"; do local item_path="${item_paths[idx]}" local artifact_type=$(basename "$item_path") @@ -1388,7 +1389,7 @@ clean_project_artifacts() { fi if [[ -e "$item_path" ]]; then safe_remove "$item_path" true - if [[ ! -e "$item_path" ]]; then + if [[ "$dry_run_mode" == "1" || ! -e "$item_path" ]]; then local current_total=$(cat "$stats_dir/purge_stats" 2> /dev/null || echo "0") echo "$((current_total + size_kb))" > "$stats_dir/purge_stats" cleaned_count=$((cleaned_count + 1)) @@ -1396,7 +1397,11 @@ clean_project_artifacts() { fi if [[ -t 1 ]]; then stop_inline_spinner - echo -e "${GREEN}${ICON_SUCCESS}${NC} $project_path, $artifact_type${NC}, ${GREEN}$size_human${NC}" + if [[ "$dry_run_mode" == "1" ]]; then + echo -e "${GREEN}${ICON_SUCCESS}${NC} [DRY RUN] $project_path, $artifact_type${NC}, ${GREEN}$size_human${NC}" + else + echo -e "${GREEN}${ICON_SUCCESS}${NC} $project_path, $artifact_type${NC}, ${GREEN}$size_human${NC}" + fi fi done # Update count diff --git a/lib/core/app_protection.sh b/lib/core/app_protection.sh index 8b826f3..144aac4 100755 --- a/lib/core/app_protection.sh +++ b/lib/core/app_protection.sh @@ -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/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/uninstall/batch.sh b/lib/uninstall/batch.sh index 08f1bfd..cb4f79e 100755 --- a/lib/uninstall/batch.sh +++ b/lib/uninstall/batch.sh @@ -11,6 +11,14 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" # Batch uninstall with a single confirmation. +get_lsregister_path() { + echo "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister" +} + +is_uninstall_dry_run() { + [[ "${MOLE_DRY_RUN:-0}" == "1" ]] +} + # High-performance sensitive data detection (pure Bash, no subprocess) # Faster than grep for batch operations, especially when processing many apps has_sensitive_data() { @@ -77,6 +85,11 @@ stop_launch_services() { local bundle_id="$1" local has_system_files="${2:-false}" + if is_uninstall_dry_run; then + debug_log "[DRY RUN] Would unload launch services for bundle: $bundle_id" + return 0 + fi + [[ -z "$bundle_id" || "$bundle_id" == "unknown" ]] && return 0 # Validate bundle_id format: must be reverse-DNS style (e.g., com.example.app) @@ -152,6 +165,11 @@ remove_login_item() { local app_name="$1" local bundle_id="$2" + if is_uninstall_dry_run; then + debug_log "[DRY RUN] Would remove login item: ${app_name:-$bundle_id}" + return 0 + fi + # Skip if no identifiers provided [[ -z "$app_name" && -z "$bundle_id" ]] && return 0 @@ -201,7 +219,12 @@ remove_file_list() { safe_remove_symlink "$file" "$use_sudo" && ((++count)) || true else if [[ "$use_sudo" == "true" ]]; then - 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 @@ -437,7 +460,7 @@ batch_uninstall_applications() { export MOLE_UNINSTALL_MODE=1 # Request sudo if needed. - if [[ ${#sudo_apps[@]} -gt 0 ]]; then + if [[ ${#sudo_apps[@]} -gt 0 && "${MOLE_DRY_RUN:-0}" != "1" ]]; then if ! sudo -n true 2> /dev/null; then if ! request_sudo_access "Admin required for system apps: ${sudo_apps[*]}"; then echo "" @@ -547,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 @@ -583,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). @@ -644,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. @@ -730,24 +770,48 @@ batch_uninstall_applications() { if [[ "$summary_status" == "warn" ]]; then title="Uninstall incomplete" fi + if is_uninstall_dry_run; then + title="Uninstall dry run complete" + fi echo "" print_summary_block "$title" "${summary_details[@]}" printf '\n' - if [[ $success_count -gt 0 && ${#success_items[@]} -gt 0 ]]; then - # Kick off LaunchServices rebuild in background immediately after summary. - # The caller shows a 3s "Press Enter" prompt, giving the rebuild time to finish - # before the user returns to the app list — fixes stale Spotlight entries (#490). - ( - refresh_launch_services_after_uninstall 2> /dev/null || true - remove_apps_from_dock "${success_items[@]}" 2> /dev/null || true - ) > /dev/null 2>&1 & + # Auto-run brew autoremove if Homebrew casks were uninstalled + if [[ $brew_apps_removed -gt 0 ]]; then + if is_uninstall_dry_run; then + log_info "[DRY RUN] Would run brew autoremove" + else + # Show spinner while checking for orphaned dependencies + if [[ -t 1 ]]; then + start_inline_spinner "Checking brew dependencies..." + fi + + local autoremove_output removed_count + autoremove_output=$(HOMEBREW_NO_ENV_HINTS=1 brew autoremove 2> /dev/null) || true + removed_count=$(printf '%s\n' "$autoremove_output" | grep -c "^Uninstalling" || true) + removed_count=${removed_count:-0} + + if [[ -t 1 ]]; then + stop_inline_spinner + fi + + if [[ $removed_count -gt 0 ]]; then + echo -e "${GREEN}${ICON_SUCCESS}${NC} Cleaned $removed_count orphaned brew dependencies" + echo "" + fi + fi fi - # brew autoremove can be slow — run in background so the prompt returns quickly. - if [[ $brew_apps_removed -gt 0 ]]; then - (HOMEBREW_NO_ENV_HINTS=1 brew autoremove > /dev/null 2>&1 || true) & + # Clean up Dock entries for uninstalled apps. + if [[ $success_count -gt 0 && ${#success_items[@]} -gt 0 ]]; then + if is_uninstall_dry_run; then + log_info "[DRY RUN] Would refresh LaunchServices and update Dock entries" + else + remove_apps_from_dock "${success_items[@]}" 2> /dev/null || true + refresh_launch_services_after_uninstall 2> /dev/null || true + fi fi _cleanup_sudo_keepalive 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 64ca032..e503965 100755 --- a/mole +++ b/mole @@ -234,10 +234,16 @@ show_help() { printf " %s%-28s%s %s\n" "$GREEN" "mo optimize --dry-run" "$NC" "Preview optimization" printf " %s%-28s%s %s\n" "$GREEN" "mo optimize --whitelist" "$NC" "Manage protected items" + printf " %s%-28s%s %s\n" "$GREEN" "mo uninstall --dry-run" "$NC" "Preview app uninstall" + printf " %s%-28s%s %s\n" "$GREEN" "mo purge --dry-run" "$NC" "Preview project purge" + printf " %s%-28s%s %s\n" "$GREEN" "mo installer --dry-run" "$NC" "Preview installer cleanup" + printf " %s%-28s%s %s\n" "$GREEN" "mo touchid enable --dry-run" "$NC" "Preview Touch ID setup" + printf " %s%-28s%s %s\n" "$GREEN" "mo completion --dry-run" "$NC" "Preview shell completion edits" printf " %s%-28s%s %s\n" "$GREEN" "mo purge --paths" "$NC" "Configure scan directories" printf " %s%-28s%s %s\n" "$GREEN" "mo analyze /Volumes" "$NC" "Analyze external drives only" printf " %s%-28s%s %s\n" "$GREEN" "mo update --force" "$NC" "Force reinstall latest stable version" printf " %s%-28s%s %s\n" "$GREEN" "mo update --nightly" "$NC" "Install latest unreleased main branch build" + printf " %s%-28s%s %s\n" "$GREEN" "mo remove --dry-run" "$NC" "Preview Mole removal" echo printf "%s%s%s\n" "$BLUE" "OPTIONS" "$NC" printf " %s%-28s%s %s\n" "$GREEN" "--debug" "$NC" "Show detailed operation logs" @@ -462,6 +468,8 @@ update_mole() { # Remove flow (Homebrew + manual + config/cache). remove_mole() { + local dry_run_mode="${1:-false}" + if [[ -t 1 ]]; then start_inline_spinner "Detecting Mole installations..." else @@ -571,6 +579,31 @@ remove_mole() { esac local has_error=false + if [[ "$dry_run_mode" == "true" ]]; then + echo "" + echo -e "${YELLOW}${ICON_DRY_RUN} DRY RUN MODE${NC}, no files will be removed" + + if [[ "$is_homebrew" == "true" ]]; then + echo -e " ${GRAY}${ICON_LIST} Would run: brew uninstall --force mole${NC}" + fi + + if [[ ${manual_count:-0} -gt 0 ]]; then + for install in "${manual_installs[@]}"; do + [[ -f "$install" ]] && echo -e " ${GRAY}${ICON_LIST} Would remove: ${install}${NC}" + done + fi + if [[ ${alias_count:-0} -gt 0 ]]; then + for alias in "${alias_installs[@]}"; do + [[ -f "$alias" ]] && echo -e " ${GRAY}${ICON_LIST} Would remove: ${alias}${NC}" + done + fi + [[ -d "$HOME/.cache/mole" ]] && echo -e " ${GRAY}${ICON_LIST} Would remove: $HOME/.cache/mole${NC}" + [[ -d "$HOME/.config/mole" ]] && echo -e " ${GRAY}${ICON_LIST} Would remove: $HOME/.config/mole${NC}" + + printf '\n%s\n\n' "${GREEN}${ICON_SUCCESS}${NC} Dry run complete, no changes made" + exit 0 + fi + if [[ "$is_homebrew" == "true" ]]; then if [[ -z "$brew_cmd" ]]; then log_error "Homebrew command not found. Please ensure Homebrew is installed and in your PATH." @@ -859,7 +892,18 @@ main() { exit 0 ;; "remove") - remove_mole + local dry_run_remove=false + for arg in "${args[@]:1}"; do + case "$arg" in + "--dry-run" | "-n") dry_run_remove=true ;; + *) + echo "Unknown remove option: $arg" + echo "Use 'mole remove [--dry-run]' for supported options." + exit 1 + ;; + esac + done + remove_mole "$dry_run_remove" ;; "help" | "--help" | "-h") show_help diff --git a/tests/cli.bats b/tests/cli.bats index 5ed897b..92c92ec 100644 --- a/tests/cli.bats +++ b/tests/cli.bats @@ -1,39 +1,39 @@ #!/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 -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 +41,152 @@ 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" } @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 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/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 9e0ea96..63b0529 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,22 +282,22 @@ 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 @@ -305,7 +305,7 @@ if select_purge_categories; then fi EOF - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] } @test "select_purge_categories restores caller EXIT/INT/TERM traps" { @@ -369,10 +369,10 @@ EOF } @test "is_protected_vendor_dir: protects Go vendor" { - mkdir -p "$HOME/www/go-app/vendor" - touch "$HOME/www/go-app/go.mod" + mkdir -p "$HOME/www/go-app/vendor" + touch "$HOME/www/go-app/go.mod" - result=$(bash -c " + result=$(bash -c " source '$PROJECT_ROOT/lib/clean/project.sh' if is_protected_vendor_dir '$HOME/www/go-app/vendor'; then echo 'PROTECTED' @@ -381,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' @@ -396,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' @@ -412,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' @@ -428,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 @@ -454,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 @@ -476,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 @@ -499,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 @@ -521,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 @@ -544,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 @@ -566,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 @@ -584,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' @@ -669,92 +669,108 @@ 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 + " /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' @@ -763,14 +779,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' @@ -779,15 +795,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' @@ -795,19 +811,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 @@ -817,19 +832,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 @@ -839,6 +854,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" ] } From 241e6a7a348a7a3db13d0726d6a3b16130ccc2b8 Mon Sep 17 00:00:00 2001 From: tw93 Date: Sun, 1 Mar 2026 20:35:49 +0800 Subject: [PATCH 60/69] fix(purge): avoid counting failed dry-run removals --- lib/clean/project.sh | 24 +++++++++++++++--------- tests/purge.bats | 31 +++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/lib/clean/project.sh b/lib/clean/project.sh index 3d768e5..d6158b4 100644 --- a/lib/clean/project.sh +++ b/lib/clean/project.sh @@ -1387,20 +1387,26 @@ 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 [[ "$dry_run_mode" == "1" || ! -e "$item_path" ]]; then - local current_total=$(cat "$stats_dir/purge_stats" 2> /dev/null || echo "0") - echo "$((current_total + size_kb))" > "$stats_dir/purge_stats" - cleaned_count=$((cleaned_count + 1)) + 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 - 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}" + 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 diff --git a/tests/purge.bats b/tests/purge.bats index 63b0529..0137f7a 100644 --- a/tests/purge.bats +++ b/tests/purge.bats @@ -683,6 +683,37 @@ EOF [[ "$status" -eq 0 ]] || [[ "$status" -eq 2 ]] } +@test "clean_project_artifacts: dry-run does not count failed removals" { + 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/clean/project.sh" + +mkdir -p "$HOME/.cache/mole" +echo "0" > "$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" From de57a20828aca1428e04397cae105133e24ac1ae Mon Sep 17 00:00:00 2001 From: tw93 Date: Sun, 1 Mar 2026 21:04:41 +0800 Subject: [PATCH 61/69] refactor(dry-run): unify flag handling in completion and remove --- bin/completion.sh | 11 +++++------ mole | 50 +++++++++++++++++++++++------------------------ 2 files changed, 30 insertions(+), 31 deletions(-) diff --git a/bin/completion.sh b/bin/completion.sh index 1feec15..a575929 100755 --- a/bin/completion.sh +++ b/bin/completion.sh @@ -32,13 +32,12 @@ emit_fish_completions() { printf 'complete -c %s -n "not __fish_mole_no_subcommand" -a fish -d "generate fish completion" -n "__fish_see_subcommand_path completion"\n' "$cmd" } -DRY_RUN_MODE=false if [[ $# -gt 0 ]]; then normalized_args=() for arg in "$@"; do case "$arg" in "--dry-run" | "-n") - DRY_RUN_MODE=true + export MOLE_DRY_RUN=1 ;; *) normalized_args+=("$arg") @@ -54,7 +53,7 @@ fi # Auto-install mode when run without arguments if [[ $# -eq 0 ]]; then - if [[ "$DRY_RUN_MODE" == "true" ]]; 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 @@ -98,7 +97,7 @@ if [[ $# -eq 0 ]]; then if [[ -z "$completion_name" ]]; then if [[ -f "$config_file" ]] && grep -Eq "(^# Mole shell completion$|(mole|mo)[[:space:]]+completion)" "$config_file" 2> /dev/null; then - if [[ "$DRY_RUN_MODE" == "true" ]]; then + 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 @@ -120,7 +119,7 @@ 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 [[ "$DRY_RUN_MODE" == "true" ]]; 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 @@ -150,7 +149,7 @@ if [[ $# -eq 0 ]]; then echo -e "${GRAY}Will add to ${config_file}:${NC}" echo " $completion_line" echo "" - if [[ "$DRY_RUN_MODE" == "true" ]]; then + if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then echo -e "${GREEN}${ICON_SUCCESS}${NC} Dry run complete, no changes made" exit 0 fi diff --git a/mole b/mole index e503965..ae61f6c 100755 --- a/mole +++ b/mole @@ -553,6 +553,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" @@ -579,31 +604,6 @@ remove_mole() { esac local has_error=false - if [[ "$dry_run_mode" == "true" ]]; then - echo "" - echo -e "${YELLOW}${ICON_DRY_RUN} DRY RUN MODE${NC}, no files will be removed" - - if [[ "$is_homebrew" == "true" ]]; then - echo -e " ${GRAY}${ICON_LIST} Would run: brew uninstall --force mole${NC}" - fi - - if [[ ${manual_count:-0} -gt 0 ]]; then - for install in "${manual_installs[@]}"; do - [[ -f "$install" ]] && echo -e " ${GRAY}${ICON_LIST} Would remove: ${install}${NC}" - done - fi - if [[ ${alias_count:-0} -gt 0 ]]; then - for alias in "${alias_installs[@]}"; do - [[ -f "$alias" ]] && echo -e " ${GRAY}${ICON_LIST} Would remove: ${alias}${NC}" - done - fi - [[ -d "$HOME/.cache/mole" ]] && echo -e " ${GRAY}${ICON_LIST} Would remove: $HOME/.cache/mole${NC}" - [[ -d "$HOME/.config/mole" ]] && echo -e " ${GRAY}${ICON_LIST} Would remove: $HOME/.config/mole${NC}" - - printf '\n%s\n\n' "${GREEN}${ICON_SUCCESS}${NC} Dry run complete, no changes made" - exit 0 - fi - if [[ "$is_homebrew" == "true" ]]; then if [[ -z "$brew_cmd" ]]; then log_error "Homebrew command not found. Please ensure Homebrew is installed and in your PATH." From d4116fbd49b47ab735de354a5386a6288130b992 Mon Sep 17 00:00:00 2001 From: tw93 Date: Sun, 1 Mar 2026 21:04:48 +0800 Subject: [PATCH 62/69] docs(readme): reduce duplicate quick-start guidance --- README.md | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 67ca726..2c7ad76 100644 --- a/README.md +++ b/README.md @@ -61,19 +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 uninstall --dry-run # Preview app uninstall actions -mo purge --dry-run # Preview project artifact purge -mo installer --dry-run # Preview installer cleanup actions -mo touchid enable --dry-run # Preview Touch ID sudo config changes -mo completion --dry-run # Preview shell completion file updates -mo remove --dry-run # Preview Mole self-removal +mo clean --whitelist # Manage protected caches mo purge --paths # Configure project scan directories mo analyze /Volumes # Analyze external drives only ``` @@ -81,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 with dry-run before applying changes. See [Security Audit](SECURITY_AUDIT.md). -- Debug and logs: Use `--debug` for detailed logs. Combine with `--dry-run` for a full preview. File operations are logged to `~/.config/mole/operations.log`. Disable with `MO_NO_OPLOG=1`. +- 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 From 0a8f92cf835c40c73e4ba54c09850644f23e4a5f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 1 Mar 2026 13:06:02 +0000 Subject: [PATCH 63/69] chore: update contributors [skip ci] --- CONTRIBUTORS.svg | 64 ++++++++++++++++++++++++------------------------ 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/CONTRIBUTORS.svg b/CONTRIBUTORS.svg index f1d21d8..59e97c7 100644 --- a/CONTRIBUTORS.svg +++ b/CONTRIBUTORS.svg @@ -1,4 +1,4 @@ - + @@ -199,7 +199,7 @@ ndbroadbent - + @@ -210,7 +210,7 @@ ppauel - + @@ -221,17 +221,6 @@ shakeelmohamed - - - - - - - - - Sizk - - @@ -387,6 +376,17 @@ + + + + + + + + KoukeNeko + + + @@ -397,7 +397,7 @@ andmev - + @@ -408,7 +408,7 @@ uluumbch - + @@ -419,7 +419,7 @@ ClathW - + @@ -430,7 +430,7 @@ Copper-Eye - + @@ -441,7 +441,7 @@ DimitarNestorov - + @@ -452,7 +452,7 @@ gokulp01 - + @@ -463,7 +463,7 @@ Hensell - + @@ -474,7 +474,7 @@ jalen0x - + @@ -485,7 +485,7 @@ kowyo - + @@ -496,7 +496,7 @@ kwakubiney - + @@ -507,7 +507,7 @@ LmanTW - + @@ -518,7 +518,7 @@ injuxtice - + @@ -529,7 +529,7 @@ khipu-luke - + @@ -540,7 +540,7 @@ mariovtor - + @@ -551,7 +551,7 @@ anonymort - + @@ -562,7 +562,7 @@ Schlauer-Hax - + @@ -573,7 +573,7 @@ mickyyy68 - + @@ -584,7 +584,7 @@ EastSun5566 - + From 27a2cc592784e8d77a0b2c541374b7ebc92463a0 Mon Sep 17 00:00:00 2001 From: tw93 Date: Mon, 2 Mar 2026 10:54:44 +0800 Subject: [PATCH 64/69] feat: show nightly channel in version output Refs #517 --- install.sh | 51 ++++++++++++++++++++++++++++++++++++++++++++++++++ mole | 18 ++++++++++++++++++ tests/cli.bats | 14 ++++++++++++++ 3 files changed, 83 insertions(+) diff --git a/install.sh b/install.sh index 4854e9b..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=("$@") @@ -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/mole b/mole index ae61f6c..7d34c75 100755 --- a/mole +++ b/mole @@ -87,6 +87,18 @@ is_homebrew_install() { 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" @@ -205,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" diff --git a/tests/cli.bats b/tests/cli.bats index 92c92ec..83eb922 100644 --- a/tests/cli.bats +++ b/tests/cli.bats @@ -14,6 +14,7 @@ setup_file() { } teardown_file() { + rm -f "$PROJECT_ROOT/install_channel" rm -rf "$HOME" if [[ -n "${ORIGINAL_HOME:-}" ]]; then export HOME="$ORIGINAL_HOME" @@ -47,6 +48,7 @@ SCRIPT setup() { rm -rf "$HOME/.config" mkdir -p "$HOME" + rm -f "$PROJECT_ROOT/install_channel" } @test "mole --help prints command overview" { @@ -63,6 +65,18 @@ setup() { [[ "$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 ] From 046af7682c5276cf931d3e202124ec0129774a09 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 10:03:50 +0800 Subject: [PATCH 65/69] chore(deps): bump github.com/shirou/gopsutil/v4 from 4.26.1 to 4.26.2 (#527) Bumps [github.com/shirou/gopsutil/v4](https://github.com/shirou/gopsutil) from 4.26.1 to 4.26.2. - [Release notes](https://github.com/shirou/gopsutil/releases) - [Commits](https://github.com/shirou/gopsutil/compare/v4.26.1...v4.26.2) --- updated-dependencies: - dependency-name: github.com/shirou/gopsutil/v4 dependency-version: 4.26.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 6 +++--- go.sum | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) 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= From e992cd351b570b0b5714013d540ce56871baf2fd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 10:03:59 +0800 Subject: [PATCH 66/69] chore(deps): bump actions/download-artifact from 7.0.0 to 8.0.0 (#526) Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 7.0.0 to 8.0.0. - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/37930b1c2abaa49bbe596cd826c3c89aef350131...70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3) --- updated-dependencies: - dependency-name: actions/download-artifact dependency-version: 8.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 71c974a..bf30b82 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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-* From fd6d444e007160bc67a5b3aa38af599b23a53199 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 10:04:08 +0800 Subject: [PATCH 67/69] chore(deps): bump actions/upload-artifact from 6.0.0 to 7.0.0 (#525) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6.0.0 to 7.0.0. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/b7c566a772e6b6bfb58ed0dc250532a479d7789f...bbbca2ddaa5d8feaa63e36b76fdaad77386f024f) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bf30b82..7a36f0b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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-* From 0736892a5769a817e2223fe0ff83859ffd2484c8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 10:04:22 +0800 Subject: [PATCH 68/69] chore(deps): bump actions/setup-go from 6.2.0 to 6.3.0 (#524) Bumps [actions/setup-go](https://github.com/actions/setup-go) from 6.2.0 to 6.3.0. - [Release notes](https://github.com/actions/setup-go/releases) - [Commits](https://github.com/actions/setup-go/compare/7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5...4b73464bb391d4059bd26b0524d20df3927bd417) --- updated-dependencies: - dependency-name: actions/setup-go dependency-version: 6.3.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/check.yml | 4 ++-- .github/workflows/release.yml | 2 +- .github/workflows/test.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) 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 7a36f0b..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" 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" From 38601ec0d1d858b7280afd9b58710261ab338611 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 02:04:59 +0000 Subject: [PATCH 69/69] chore: update contributors [skip ci] --- CONTRIBUTORS.svg | 81 +++++++++++++++++++++--------------------------- 1 file changed, 35 insertions(+), 46 deletions(-) diff --git a/CONTRIBUTORS.svg b/CONTRIBUTORS.svg index 59e97c7..99c4a2c 100644 --- a/CONTRIBUTORS.svg +++ b/CONTRIBUTORS.svg @@ -1,4 +1,4 @@ - + @@ -189,6 +189,17 @@ + + + + + + + + andmev + + + @@ -199,7 +210,7 @@ ndbroadbent - + @@ -210,7 +221,7 @@ ppauel - + @@ -221,7 +232,7 @@ shakeelmohamed - + @@ -232,7 +243,7 @@ Harsh-Kapoorr - + @@ -243,7 +254,7 @@ thijsvanhal - + @@ -254,7 +265,7 @@ TomP0 - + @@ -265,17 +276,6 @@ yuzeguitarist - - - - - - - - - zeldrisho - - @@ -387,17 +387,6 @@ - - - - - - - - andmev - - - @@ -408,7 +397,7 @@ uluumbch - + @@ -419,7 +408,7 @@ ClathW - + @@ -430,7 +419,7 @@ Copper-Eye - + @@ -441,7 +430,7 @@ DimitarNestorov - + @@ -452,7 +441,7 @@ gokulp01 - + @@ -463,7 +452,7 @@ Hensell - + @@ -474,7 +463,7 @@ jalen0x - + @@ -485,7 +474,7 @@ kowyo - + @@ -496,7 +485,7 @@ kwakubiney - + @@ -507,7 +496,7 @@ LmanTW - + @@ -518,7 +507,7 @@ injuxtice - + @@ -529,7 +518,7 @@ khipu-luke - + @@ -540,7 +529,7 @@ mariovtor - + @@ -551,7 +540,7 @@ anonymort - + @@ -562,7 +551,7 @@ Schlauer-Hax - + @@ -573,7 +562,7 @@ mickyyy68 - + @@ -584,7 +573,7 @@ EastSun5566 - +