diff --git a/.gitignore b/.gitignore index d2e1178..84b6dc2 100644 --- a/.gitignore +++ b/.gitignore @@ -52,4 +52,3 @@ cmd/status/status /status mole-analyze # Note: bin/analyze-go and bin/status-go are released binaries and should be tracked -.mole_cleanup_stats diff --git a/README.md b/README.md index 824f79d..140355f 100644 --- a/README.md +++ b/README.md @@ -18,10 +18,10 @@ ## Features -- **All-in-one toolkit** combining the power of CleanMyMac, AppCleaner, DaisyDisk, Sensei, and iStat in one **trusted binary** +- **All-in-one toolkit** combining CleanMyMac, AppCleaner, DaisyDisk, Sensei, and iStat in one **trusted binary** - **Deep cleanup** scans and removes caches, logs, browser leftovers, and junk to **reclaim tens of gigabytes** - **Smart uninstall** completely removes apps including launch agents, preferences, caches, and **hidden leftovers** -- **Disk insight + optimization** visualizes usage, handles large files, **rebuilds caches**, cleans swap, and refreshes services +- **Disk insight + optimization** visualizes usage, handles large files, **rebuilds caches**, and refreshes services - **Live status** monitors CPU, GPU, memory, disk, network, battery, and proxy stats to **diagnose issues** ## Quick Start @@ -59,7 +59,6 @@ mo clean --dry-run # Preview cleanup plan mo clean --whitelist # Adjust protected caches mo uninstall --force-rescan # Rescan apps and refresh cache mo optimize --whitelist # Adjust protected optimization items - ``` ## Tips @@ -184,16 +183,24 @@ Health score based on CPU, memory, disk, temperature, and I/O load. Color-coded ### Project Artifact Purge -Remove build artifacts from old projects to reclaim disk space. Fast parallel scanning targets `node_modules`, `target`, `build`, `dist`, `.next`, `.gradle`, `venv`, and similar directories. +Clean old build artifacts (`node_modules`, `target`, `build`, `dist`, etc.) from your projects to free up disk space. ```bash -mo purge --dry-run # Preview cleanup (recommended) -mo purge # Clean old project artifacts +mo purge + +Select Categories to Clean - 18.5GB (8 selected) + +➤ ● my-react-app 3.2GB | node_modules + ● old-project 2.8GB | node_modules + ● rust-app 4.1GB | target + ● next-blog 1.9GB | node_modules + ○ current-work 856MB | node_modules | Recent + ● django-api 2.3GB | venv + ● vue-dashboard 1.7GB | node_modules + ● backend-service 2.5GB | node_modules ``` -**Safety:** Only scans common project directories, skips recently modified projects (7 days), and requires artifacts at least 2 levels deep to avoid system files. - -**Performance:** Uses macOS Spotlight index (mdfind) for lightning-fast scanning, with parallel search across multiple directories. +> **Use with caution:** This will permanently delete selected artifacts. Review carefully before confirming. Recent projects (< 7 days) are marked and unselected by default. ## Quick Launchers diff --git a/bin/clean.sh b/bin/clean.sh index 5d7d4a3..4aeb5ae 100755 --- a/bin/clean.sh +++ b/bin/clean.sh @@ -498,7 +498,6 @@ EOF # Check for cancel (ESC or Q) if [[ "$choice" == "QUIT" ]]; then - echo -e " ${GRAY}Cancelled${NC}" exit 0 fi diff --git a/bin/optimize.sh b/bin/optimize.sh index 0c43a42..6ec4a9a 100755 --- a/bin/optimize.sh +++ b/bin/optimize.sh @@ -445,14 +445,12 @@ main() { local key if ! key=$(read_key); then - echo -e " ${GRAY}Cancelled${NC}" exit 0 fi if [[ "$key" == "ENTER" ]]; then printf "\r\033[K" else - echo -e " ${GRAY}Cancelled${NC}" exit 0 fi diff --git a/bin/purge.sh b/bin/purge.sh index 569529d..d06c2d2 100755 --- a/bin/purge.sh +++ b/bin/purge.sh @@ -15,10 +15,6 @@ source "$SCRIPT_DIR/../lib/core/log.sh" source "$SCRIPT_DIR/../lib/clean/project.sh" # Configuration -DRY_RUN=false - -# Export list configuration -EXPORT_LIST_FILE="$HOME/.config/mole/purge-list.txt" CURRENT_SECTION="" # Section management @@ -48,75 +44,61 @@ start_purge() { fi printf '\n' echo -e "${PURPLE_BOLD}Purge Project Artifacts${NC}" - echo "" - if [[ "$DRY_RUN" == "true" ]]; then - echo -e "${GRAY}${ICON_SOLID}${NC} Dry run mode - previewing what would be cleaned" - echo "" - fi - - # Prepare export list - if [[ "$DRY_RUN" != "true" ]]; then - mkdir -p "$(dirname "$EXPORT_LIST_FILE")" - : > "$EXPORT_LIST_FILE" - fi - - # Initialize stats file - echo "0" > "$SCRIPT_DIR/../.mole_cleanup_stats" - echo "0" > "$SCRIPT_DIR/../.mole_cleanup_count" + # Initialize stats file in user cache directory + local stats_dir="${XDG_CACHE_HOME:-$HOME/.cache}/mole" + mkdir -p "$stats_dir" + echo "0" > "$stats_dir/purge_stats" + echo "0" > "$stats_dir/purge_count" } # Perform the purge perform_purge() { clean_project_artifacts + local exit_code=$? + + # Exit codes: + # 0 = success, show summary + # 1 = user cancelled + # 2 = nothing to clean + if [[ $exit_code -ne 0 ]]; then + return 0 + fi # Final summary (matching clean.sh format) echo "" - local summary_heading="" - if [[ "$DRY_RUN" == "true" ]]; then - summary_heading="Purge complete - dry run" - else - summary_heading="Purge complete" - fi - + local summary_heading="Purge complete" local -a summary_details=() local total_size_cleaned=0 local total_items_cleaned=0 - # Read stats - if [[ -f "$SCRIPT_DIR/../.mole_cleanup_stats" ]]; then - total_size_cleaned=$(cat "$SCRIPT_DIR/../.mole_cleanup_stats" 2> /dev/null || echo "0") - rm -f "$SCRIPT_DIR/../.mole_cleanup_stats" + # Read stats from user cache directory + local stats_dir="${XDG_CACHE_HOME:-$HOME/.cache}/mole" + + if [[ -f "$stats_dir/purge_stats" ]]; then + total_size_cleaned=$(cat "$stats_dir/purge_stats" 2> /dev/null || echo "0") + rm -f "$stats_dir/purge_stats" fi # Read count - if [[ -f "$SCRIPT_DIR/../.mole_cleanup_count" ]]; then - total_items_cleaned=$(cat "$SCRIPT_DIR/../.mole_cleanup_count" 2> /dev/null || echo "0") - rm -f "$SCRIPT_DIR/../.mole_cleanup_count" + if [[ -f "$stats_dir/purge_count" ]]; then + total_items_cleaned=$(cat "$stats_dir/purge_count" 2> /dev/null || echo "0") + rm -f "$stats_dir/purge_count" fi if [[ $total_size_cleaned -gt 0 ]]; then local freed_gb freed_gb=$(echo "$total_size_cleaned" | awk '{printf "%.2f", $1/1024/1024}') - if [[ "$DRY_RUN" == "true" ]]; then - summary_details+=("Potential space: ${GREEN}${freed_gb}GB${NC}") - else - summary_details+=("Space freed: ${GREEN}${freed_gb}GB${NC}") + 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 - - summary_details+=("Free space now: $(get_free_space)") + if [[ $total_items_cleaned -gt 0 ]]; then + summary_details+=("Items cleaned: $total_items_cleaned") fi else - if [[ "$DRY_RUN" == "true" ]]; then - summary_details+=("No old project artifacts found.") - else - summary_details+=("No old project artifacts to clean.") - fi + summary_details+=("No old project artifacts to clean.") summary_details+=("Free space now: $(get_free_space)") fi @@ -135,9 +117,6 @@ main() { "--debug") export MO_DEBUG=1 ;; - "--dry-run" | "-n") - DRY_RUN=true - ;; *) echo "Unknown option: $arg" echo "Use 'mo --help' for usage information" diff --git a/lib/clean/project.sh b/lib/clean/project.sh index e98559d..c4b665c 100644 --- a/lib/clean/project.sh +++ b/lib/clean/project.sh @@ -55,8 +55,8 @@ is_safe_project_artifact() { local depth=$(echo "$relative_path" | tr -cd '/' | wc -c) # Require at least 1 level deep (inside a project folder) - # e.g., ~/www/MyProject/node_modules is OK (depth >= 1) - # but ~/www/node_modules is NOT OK (depth = 0) + # e.g., ~/www/weekly/node_modules is OK (depth >= 1) + # but ~/www/node_modules is NOT OK (depth < 1) if [[ $depth -lt 1 ]]; then return 1 fi @@ -171,7 +171,8 @@ filter_nested_artifacts() { for target in "${PURGE_TARGETS[@]}"; do # Check if parent directory IS a target or IS INSIDE a target # e.g. .../node_modules/foo/node_modules -> parent has node_modules - if [[ "$parent_dir" == *"/$target"* || "$parent_dir" == *"/$target" ]]; then + # Use more strict matching to avoid false positives like "my_node_modules_backup" + if [[ "$parent_dir" == *"/$target/"* || "$parent_dir" == *"/$target" ]]; then is_nested=true break fi @@ -218,107 +219,169 @@ get_dir_size_kb() { fi } -# Simplified clean function for project artifacts -# Args: $1 - path, $2 - description -safe_clean() { - local path="$1" - local description="$2" +# Simple category selector (for purge only) +# Args: category names and metadata as arrays (passed via global vars) +# Returns: selected indices in PURGE_SELECTION_RESULT (comma-separated) +# Uses PURGE_RECENT_CATEGORIES to mark categories with recent items (default unselected) +select_purge_categories() { + local -a categories=("$@") + local total_items=${#categories[@]} - if [[ ! -e "$path" ]]; then - return 0 + if [[ $total_items -eq 0 ]]; then + return 1 fi - # Get size before deletion - local size_kb=$(get_dir_size_kb "$path") - - if [[ "$DRY_RUN" == "true" ]]; then - if [[ $size_kb -gt 0 ]]; then - local size_mb=$((size_kb / 1024)) - echo -e "${GRAY}Would remove:${NC} $description (~${size_mb}MB)" + # Initialize selection (all selected by default, except recent ones) + local -a selected=() + IFS=',' read -r -a recent_flags <<< "${PURGE_RECENT_CATEGORIES:-}" + for ((i = 0; i < total_items; i++)); do + # Default unselected if category has recent items + if [[ ${recent_flags[i]:-false} == "true" ]]; then + selected[i]=false + else + selected[i]=true fi - else - if [[ $size_kb -gt 0 ]]; then - local size_mb=$((size_kb / 1024)) + done - # Show cleaning status (transient) with spinner - if [[ -t 1 ]]; then - # Use standard spinner prefix or none as requested? - # User asked for "no indentation". MOLE_SPINNER_PREFIX controls indentation in ui.sh. - # But ui.sh often adds " |". - # Let's use start_inline_spinner which uses MOLE_SPINNER_PREFIX. - # We can temporarily clear prefix to avoid indentation if needed, - # but standard UI guidelines might suggest some alignment. - # The user specifically said "不要缩进". - local original_prefix="${MOLE_SPINNER_PREFIX:-}" - MOLE_SPINNER_PREFIX="" start_inline_spinner "Cleaning $description (~${size_mb}MB)..." - - rm -rf "$path" 2> /dev/null || true - - stop_inline_spinner - MOLE_SPINNER_PREFIX="$original_prefix" - else - rm -rf "$path" 2> /dev/null || true - fi - - if [[ ! -e "$path" ]]; then - echo -e "${GREEN}${ICON_SUCCESS}${NC} Removed $description (~${size_mb}MB)" - - # Update stats file - if [[ -f "$SCRIPT_DIR/../.mole_cleanup_stats" ]]; then - local current_total=$(cat "$SCRIPT_DIR/../.mole_cleanup_stats") - local new_total=$((current_total + size_kb)) - echo "$new_total" > "$SCRIPT_DIR/../.mole_cleanup_stats" - fi - - # Update count file - local count_file="$SCRIPT_DIR/../.mole_cleanup_count" - local current_count=0 - if [[ -f "$count_file" ]]; then - current_count=$(cat "$count_file") - fi - echo $((current_count + 1)) > "$count_file" - else - echo -e "${RED}${ICON_CROSS}${NC} Failed to remove $description" - fi - fi + local cursor_pos=0 + local original_stty="" + if [[ -t 0 ]] && command -v stty > /dev/null 2>&1; then + original_stty=$(stty -g 2> /dev/null || echo "") fi + + # Terminal control functions + restore_terminal() { + trap - EXIT INT TERM + show_cursor + if [[ -n "${original_stty:-}" ]]; then + stty "${original_stty}" 2> /dev/null || stty sane 2> /dev/null || true + fi + } + + handle_interrupt() { + restore_terminal + exit 130 + } + + draw_menu() { + printf "\033[H\033[2J" + # Calculate total size of selected items for header + local selected_size=0 + local selected_count=0 + IFS=',' read -r -a sizes <<< "${PURGE_CATEGORY_SIZES:-}" + for ((i = 0; i < total_items; i++)); do + if [[ ${selected[i]} == true ]]; then + selected_size=$((selected_size + ${sizes[i]:-0})) + ((selected_count++)) + fi + done + local selected_gb=$(echo "scale=1; $selected_size/1024/1024" | bc) + + printf '\n' + echo -e "${PURPLE_BOLD}Select Categories to Clean${NC} ${GRAY}- ${selected_gb}GB ($selected_count selected)${NC}" + echo "" + + IFS=',' read -r -a recent_flags <<< "${PURGE_RECENT_CATEGORIES:-}" + for ((i = 0; i < total_items; i++)); do + local checkbox="$ICON_EMPTY" + [[ ${selected[i]} == true ]] && checkbox="$ICON_SOLID" + + local recent_marker="" + [[ ${recent_flags[i]:-false} == "true" ]] && recent_marker=" ${GRAY}| Recent${NC}" + + if [[ $i -eq $cursor_pos ]]; then + printf "\r\033[2K${CYAN}${ICON_ARROW} %s %s%s${NC}\n" "$checkbox" "${categories[i]}" "$recent_marker" + else + printf "\r\033[2K %s %s%s\n" "$checkbox" "${categories[i]}" "$recent_marker" + fi + done + + echo "" + echo -e "${GRAY}↑↓ | Space Select | Enter Confirm | A All | I Invert | Q Quit${NC}" + } + + trap restore_terminal EXIT + trap handle_interrupt INT TERM + + # Preserve interrupt character for Ctrl-C + stty -echo -icanon intr ^C 2> /dev/null || true + hide_cursor + + # Main loop + while true; do + draw_menu + + # Read key + IFS= read -r -s -n1 key || key="" + + case "$key" in + $'\x1b') + # Arrow keys or ESC + # Read next 2 chars with timeout (bash 3.2 needs integer) + IFS= read -r -s -n1 -t 1 key2 || key2="" + if [[ "$key2" == "[" ]]; then + IFS= read -r -s -n1 -t 1 key3 || key3="" + case "$key3" in + A) # Up arrow + ((cursor_pos > 0)) && ((cursor_pos--)) + ;; + B) # Down arrow + ((cursor_pos < total_items - 1)) && ((cursor_pos++)) + ;; + esac + else + # ESC alone (no following chars) + restore_terminal + return 1 + fi + ;; + " ") # Space - toggle current item + if [[ ${selected[cursor_pos]} == true ]]; then + selected[cursor_pos]=false + else + selected[cursor_pos]=true + fi + ;; + "a"|"A") # Select all + for ((i = 0; i < total_items; i++)); do + selected[i]=true + done + ;; + "i"|"I") # Invert selection + for ((i = 0; i < total_items; i++)); do + if [[ ${selected[i]} == true ]]; then + selected[i]=false + else + selected[i]=true + fi + done + ;; + "q"|"Q"|$'\x03') # Quit or Ctrl-C + restore_terminal + return 1 + ;; + ""|$'\n'|$'\r') # Enter - confirm + # Build result + PURGE_SELECTION_RESULT="" + for ((i = 0; i < total_items; i++)); do + if [[ ${selected[i]} == true ]]; then + [[ -n "$PURGE_SELECTION_RESULT" ]] && PURGE_SELECTION_RESULT+="," + PURGE_SELECTION_RESULT+="$i" + fi + done + + restore_terminal + return 0 + ;; + esac + done } -# Main cleanup function -# Env: DRY_RUN +# Main cleanup function - scans and prompts user to select artifacts to clean clean_project_artifacts() { local -a all_found_items=() local -a safe_to_clean=() local -a recently_modified=() - local total_found_size=0 # in KB - - # Show warning and ask for confirmation (not in dry-run mode) - if [[ "$DRY_RUN" != "true" && -t 0 ]]; then - echo -e "${GRAY}${ICON_SOLID}${NC} Will remove old project build artifacts, use --dry-run to preview" - echo -ne "${PURPLE}${ICON_ARROW}${NC} Press ${GREEN}Enter${NC} to continue, ${GRAY}ESC${NC} to cancel: " - - # Read single key - IFS= read -r -s -n1 key || key="" - drain_pending_input - case "$key" in - $'\e') - echo "" - echo -e "${GRAY}Cancelled${NC}" - printf '\n' - exit 0 - ;; - "" | $'\n' | $'\r') - printf "\r\033[K" - # Continue with scan - ;; - *) - echo "" - echo -e "${GRAY}Cancelled${NC}" - printf '\n' - exit 0 - ;; - esac - fi # Set up cleanup on interrupt local scan_pids=() @@ -345,7 +408,7 @@ clean_project_artifacts() { # Start parallel scanning of all paths at once if [[ -t 1 ]]; then - start_inline_spinner "Scanning project directories (please wait)..." + start_inline_spinner "Scanning projects..." fi # Launch all scans in parallel @@ -387,50 +450,179 @@ clean_project_artifacts() { trap - INT TERM if [[ ${#all_found_items[@]} -eq 0 ]]; then - echo -e "${GREEN}${ICON_SUCCESS}${NC} No project artifacts found." - note_activity - return - fi - - # Filter items based on modification time - if [[ -t 1 ]]; then - start_inline_spinner "Analyzing artifacts..." + echo "" + echo -e "${GREEN}✓${NC} Great! No old project artifacts to clean" + printf '\n' + return 2 # Special code: nothing to clean fi + # Mark recently modified items (for default selection state) for item in "${all_found_items[@]}"; do if is_recently_modified "$item"; then recently_modified+=("$item") - else - safe_to_clean+=("$item") - local item_size=$(get_dir_size_kb "$item") - total_found_size=$((total_found_size + item_size)) fi + # Add all items to safe_to_clean, let user choose + safe_to_clean+=("$item") + done + + # Build menu options - one per artifact + if [[ -t 1 ]]; then + start_inline_spinner "Calculating sizes..." + fi + + local -a menu_options=() + local -a item_paths=() + local -a item_sizes=() + local -a item_recent_flags=() + + # Helper to get project name from path + # For ~/www/pake/src-tauri/target -> returns "pake" + # For ~/www/project/node_modules/xxx/node_modules -> returns "project" + get_project_name() { + local path="$1" + + # Find the project root by looking for direct child of search paths + local search_roots=("$HOME/www" "$HOME/dev" "$HOME/Projects") + + for root in "${search_roots[@]}"; do + if [[ "$path" == "$root/"* ]]; then + # Remove root prefix and get first directory component + local relative_path="${path#$root/}" + # Extract first directory name + echo "$relative_path" | cut -d'/' -f1 + return 0 + fi + done + + # Fallback: use grandparent directory + dirname "$(dirname "$path")" | xargs basename + } + + # Format display with alignment (like app_selector) + format_purge_display() { + local project_name="$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=28 # Reserve for type and size + local available_width=$((terminal_width - fixed_width)) + + # Bounds: 24-35 chars for project name + [[ $available_width -lt 24 ]] && available_width=24 + [[ $available_width -gt 35 ]] && available_width=35 + + # Truncate project name if needed + local truncated_name=$(truncate_by_display_width "$project_name" "$available_width") + local current_width=$(get_display_width "$truncated_name") + local char_count=${#truncated_name} + local padding=$((available_width - current_width)) + local printf_width=$((char_count + padding)) + + # Format: "project_name size | artifact_type" + printf "%-*s %9s | %-13s" "$printf_width" "$truncated_name" "$size_str" "$artifact_type" + } + + # Build menu options - one line per artifact + for item in "${safe_to_clean[@]}"; do + local project_name=$(get_project_name "$item") + local artifact_type=$(basename "$item") + local size_kb=$(get_dir_size_kb "$item") + local size_human=$(bytes_to_human "$((size_kb * 1024))") + + # Check if recent + local is_recent=false + for recent_item in "${recently_modified[@]}"; do + if [[ "$item" == "$recent_item" ]]; then + is_recent=true + break + fi + done + + menu_options+=("$(format_purge_display "$project_name" "$artifact_type" "$size_human")") + item_paths+=("$item") + item_sizes+=("$size_kb") + item_recent_flags+=("$is_recent") done if [[ -t 1 ]]; then stop_inline_spinner fi - echo -e "${BLUE}●${NC} Found ${#all_found_items[@]} artifacts (${#safe_to_clean[@]} older than $MIN_AGE_DAYS days)" + # Set global vars for selector + export PURGE_CATEGORY_SIZES=$(IFS=,; echo "${item_sizes[*]}") + export PURGE_RECENT_CATEGORIES=$(IFS=,; echo "${item_recent_flags[*]}") - if [[ ${#recently_modified[@]} -gt 0 ]]; then - echo -e "${YELLOW}${ICON_WARNING}${NC} Skipping ${#recently_modified[@]} recently modified items (active projects)" + # Interactive selection (only if terminal is available) + PURGE_SELECTION_RESULT="" + if [[ -t 0 ]]; then + if ! select_purge_categories "${menu_options[@]}"; then + unset PURGE_CATEGORY_SIZES PURGE_RECENT_CATEGORIES PURGE_SELECTION_RESULT + return 1 + fi + else + # Non-interactive: select all non-recent items + for ((i = 0; i < ${#menu_options[@]}; i++)); do + if [[ ${item_recent_flags[i]} != "true" ]]; then + [[ -n "$PURGE_SELECTION_RESULT" ]] && PURGE_SELECTION_RESULT+="," + PURGE_SELECTION_RESULT+="$i" + fi + done fi - if [[ ${#safe_to_clean[@]} -eq 0 ]]; then - echo -e "${GREEN}${ICON_SUCCESS}${NC} No old artifacts to clean." - note_activity - return + if [[ -z "$PURGE_SELECTION_RESULT" ]]; then + echo "" + echo -e "${GRAY}No items selected${NC}" + printf '\n' + unset PURGE_CATEGORY_SIZES PURGE_RECENT_CATEGORIES PURGE_SELECTION_RESULT + return 0 fi - # Show total size estimate - local total_size_mb=$((total_found_size / 1024)) - if [[ $total_size_mb -gt 0 ]]; then - echo -e "${GRAY}Estimated space to reclaim: ~${total_size_mb} MB${NC}" - fi + # Clean selected items + echo "" + IFS=',' read -r -a selected_indices <<< "$PURGE_SELECTION_RESULT" - # Clean safe items - for item in "${safe_to_clean[@]}"; do - safe_clean "$item" "$(basename "$(dirname "$item")")/$(basename "$item")" + local stats_dir="${XDG_CACHE_HOME:-$HOME/.cache}/mole" + local cleaned_count=0 + + for idx in "${selected_indices[@]}"; do + local item_path="${item_paths[idx]}" + local artifact_type=$(basename "$item_path") + local project_name=$(get_project_name "$item_path") + local size_kb="${item_sizes[idx]}" + local size_human=$(bytes_to_human "$((size_kb * 1024))") + + # Safety checks + if [[ -z "$item_path" || "$item_path" == "/" || "$item_path" == "$HOME" || "$item_path" != "$HOME/"* ]]; then + continue + fi + + # Show progress + if [[ -t 1 ]]; then + start_inline_spinner "Cleaning $project_name/$artifact_type..." + fi + + # Clean the item + if [[ -e "$item_path" ]]; then + rm -rf "$item_path" 2> /dev/null || true + + # Update stats + 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++)) + fi + fi + + if [[ -t 1 ]]; then + stop_inline_spinner + echo -e "${GREEN}✓${NC} $project_name - $artifact_type ${GREEN}($size_human)${NC}" + fi done + + # Update count + echo "$cleaned_count" > "$stats_dir/purge_count" + + unset PURGE_CATEGORY_SIZES PURGE_RECENT_CATEGORIES PURGE_SELECTION_RESULT } diff --git a/lib/manage/whitelist.sh b/lib/manage/whitelist.sh index fd8a432..bbec4bc 100755 --- a/lib/manage/whitelist.sh +++ b/lib/manage/whitelist.sh @@ -366,8 +366,6 @@ manage_whitelist_categories() { local exit_code=$? if [[ $exit_code -ne 0 ]]; then - echo "" - echo -e "${YELLOW}Cancelled${NC}" return 1 fi diff --git a/lib/ui/app_selector.sh b/lib/ui/app_selector.sh index 49f8638..89fe63a 100755 --- a/lib/ui/app_selector.sh +++ b/lib/ui/app_selector.sh @@ -118,7 +118,6 @@ select_apps_for_uninstall() { fi if [[ $exit_code -ne 0 ]]; then - echo "Cancelled" return 1 fi diff --git a/mole b/mole index 4f1fbbb..c9ae30a 100755 --- a/mole +++ b/mole @@ -231,6 +231,7 @@ show_help() { printf " %s%-28s%s %s\n" "$GREEN" "mo optimize" "$NC" "Check and maintain system" printf " %s%-28s%s %s\n" "$GREEN" "mo analyze" "$NC" "Explore disk usage" printf " %s%-28s%s %s\n" "$GREEN" "mo status" "$NC" "Monitor system health" + printf " %s%-28s%s %s\n" "$GREEN" "mo purge" "$NC" "Remove old project artifacts" printf " %s%-28s%s %s\n" "$GREEN" "mo touchid" "$NC" "Configure Touch ID for sudo" printf " %s%-28s%s %s\n" "$GREEN" "mo update" "$NC" "Update to latest version" printf " %s%-28s%s %s\n" "$GREEN" "mo remove" "$NC" "Remove Mole from system" @@ -242,10 +243,6 @@ show_help() { printf " %s%-28s%s %s\n" "$GREEN" "mo uninstall --force-rescan" "$NC" "Rescan apps and refresh cache" printf " %s%-28s%s %s\n" "$GREEN" "mo optimize --whitelist" "$NC" "Manage protected items" echo - printf "%s%s%s\n" "$BLUE" "ADVANCED" "$NC" - printf " %s%-28s%s %s\n" "$GREEN" "mo purge" "$NC" "Remove old project artifacts" - printf " %s%-28s%s %s\n" "$GREEN" "mo purge --dry-run" "$NC" "Preview project cleanup" - echo printf "%s%s%s\n" "$BLUE" "OPTIONS" "$NC" printf " %s%-28s%s %s\n" "$GREEN" "--debug" "$NC" "Show detailed operation logs" echo @@ -501,8 +498,6 @@ remove_mole() { drain_pending_input # Clean up any escape sequence remnants case "$key" in $'\e') - echo -e "${GRAY}Cancelled${NC}" - echo "" exit 0 ;; "" | $'\n' | $'\r') @@ -510,8 +505,6 @@ remove_mole() { # Continue with removal ;; *) - echo -e "${GRAY}Cancelled${NC}" - echo "" exit 0 ;; esac diff --git a/tests/purge.bats b/tests/purge.bats new file mode 100644 index 0000000..c78ebe7 --- /dev/null +++ b/tests/purge.bats @@ -0,0 +1,270 @@ +#!/usr/bin/env bats +# Tests for project artifact purge functionality +# bin/purge.sh and lib/clean/project.sh + +setup_file() { + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT + + ORIGINAL_HOME="${HOME:-}" + export ORIGINAL_HOME + + HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-purge-home.XXXXXX")" + export HOME + + mkdir -p "$HOME" +} + +teardown_file() { + rm -rf "$HOME" + if [[ -n "${ORIGINAL_HOME:-}" ]]; then + export HOME="$ORIGINAL_HOME" + fi +} + +setup() { + # Create test project directories + mkdir -p "$HOME/www" + mkdir -p "$HOME/dev" + mkdir -p "$HOME/.cache/mole" + + # Clean any previous test artifacts + rm -rf "$HOME/www"/* "$HOME/dev"/* +} + +# ================================================================= +# Safety Checks +# ================================================================= + +@test "is_safe_project_artifact: rejects shallow paths (protection against accidents)" { + # Should reject ~/www/node_modules (too shallow, depth < 1) + result=$(bash -c " + source '$PROJECT_ROOT/lib/clean/project.sh' + if is_safe_project_artifact '$HOME/www/node_modules' '$HOME/www'; then + echo 'UNSAFE' + else + echo 'SAFE' + fi + ") + [[ "$result" == "SAFE" ]] +} + +@test "is_safe_project_artifact: allows proper project artifacts" { + # Should allow ~/www/myproject/node_modules (depth >= 1) + 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' + else + echo 'BLOCKED' + fi + ") + [[ "$result" == "ALLOWED" ]] +} + +@test "is_safe_project_artifact: rejects non-absolute paths" { + # Should reject relative paths + result=$(bash -c " + source '$PROJECT_ROOT/lib/clean/project.sh' + if is_safe_project_artifact 'relative/path/node_modules' '$HOME/www'; then + echo 'UNSAFE' + else + echo 'SAFE' + fi + ") + [[ "$result" == "SAFE" ]] +} + +@test "is_safe_project_artifact: validates depth calculation" { + # ~/www/project/subdir/node_modules should be allowed (depth = 2) + 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' + else + echo 'BLOCKED' + fi + ") + [[ "$result" == "ALLOWED" ]] +} + +# ================================================================= +# Nested Artifact Filtering +# ================================================================= + +@test "filter_nested_artifacts: removes nested node_modules" { + # Create nested structure: + # ~/www/project/node_modules/package/node_modules + mkdir -p "$HOME/www/project/node_modules/package/node_modules" + + 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 ' ' + ") + + # Should only keep the parent node_modules (nested one filtered out) + [[ "$result" == "1" ]] +} + +@test "filter_nested_artifacts: keeps independent artifacts" { + mkdir -p "$HOME/www/project1/node_modules" + mkdir -p "$HOME/www/project2/target" + + 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 ' ' + ") + + # Should keep both (they're independent) + [[ "$result" == "2" ]] +} + +# ================================================================= +# Recently Modified Detection +# ================================================================= + +@test "is_recently_modified: detects recent projects" { + mkdir -p "$HOME/www/project/node_modules" + touch "$HOME/www/project/package.json" # Recently touched + + result=$(bash -c " + source '$PROJECT_ROOT/lib/clean/project.sh' + if is_recently_modified '$HOME/www/project/node_modules'; then + echo 'RECENT' + else + echo 'OLD' + fi + ") + [[ "$result" == "RECENT" ]] +} + +@test "is_recently_modified: marks old projects correctly" { + mkdir -p "$HOME/www/old-project/node_modules" + mkdir -p "$HOME/www/old-project" + + # Simulate old project (modified 30 days ago) + # Note: This is hard to test reliably without mocking 'find' + # Just verify the function can run without errors + bash -c " + source '$PROJECT_ROOT/lib/clean/project.sh' + is_recently_modified '$HOME/www/old-project/node_modules' || true + " + [ "$?" -eq 0 ] || [ "$?" -eq 1 ] # Allow both true/false, just check no crash +} + +# ================================================================= +# Artifact Detection +# ================================================================= + +@test "purge targets are configured correctly" { + # Verify PURGE_TARGETS array exists and contains expected values + result=$(bash -c " + source '$PROJECT_ROOT/lib/clean/project.sh' + echo \"\${PURGE_TARGETS[@]}\" + ") + [[ "$result" == *"node_modules"* ]] + [[ "$result" == *"target"* ]] +} + +# ================================================================= +# Size Calculation +# ================================================================= + +@test "get_dir_size_kb: calculates directory size" { + mkdir -p "$HOME/www/test-project/node_modules" + # Create a file with known size (~1MB) + dd if=/dev/zero of="$HOME/www/test-project/node_modules/file.bin" bs=1024 count=1024 2>/dev/null + + result=$(bash -c " + source '$PROJECT_ROOT/lib/clean/project.sh' + get_dir_size_kb '$HOME/www/test-project/node_modules' + ") + + # Should be around 1024 KB (allow some filesystem overhead) + [[ "$result" -ge 1000 ]] && [[ "$result" -le 1100 ]] +} + +@test "get_dir_size_kb: handles non-existent paths gracefully" { + result=$(bash -c " + source '$PROJECT_ROOT/lib/clean/project.sh' + get_dir_size_kb '$HOME/www/non-existent' + ") + [[ "$result" == "0" ]] +} + +# ================================================================= +# Integration Tests (Non-Interactive) +# ================================================================= + +@test "clean_project_artifacts: handles empty directory gracefully" { + # No projects, should exit cleanly + 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 + + # Should succeed (exit code 0 or 2 for nothing to clean) + [[ "$status" -eq 0 ]] || [[ "$status" -eq 2 ]] +} + +@test "clean_project_artifacts: scans and finds artifacts" { + # Create test project with node_modules (make it big enough to detect) + mkdir -p "$HOME/www/test-project/node_modules/package1" + echo "test data" > "$HOME/www/test-project/node_modules/package1/index.js" + + # Create parent directory timestamp old enough + mkdir -p "$HOME/www/test-project" + + # Run in non-interactive mode (with timeout to avoid hanging) + run bash -c " + export HOME='$HOME' + timeout 5 '$PROJECT_ROOT/bin/purge.sh' 2>&1 < /dev/null || true + " + + # Should either scan successfully or exit gracefully + # Check for expected outputs (scanning, completion, or nothing found) + [[ "$output" =~ "Scanning" ]] || + [[ "$output" =~ "Purge complete" ]] || + [[ "$output" =~ "No old" ]] || + [[ "$output" =~ "Great" ]] +} + +# ================================================================= +# Command Line Interface +# ================================================================= + +@test "mo purge: command exists and is executable" { + [ -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"* ]] +} + +@test "mo purge: accepts --debug flag" { + # Just verify it doesn't crash with --debug + run bash -c " + export HOME='$HOME' + timeout 2 '$PROJECT_ROOT/mole' purge --debug < /dev/null 2>&1 || true + " + # Should not crash (any exit code is OK, we just want to verify it runs) + true +} + +@test "mo purge: creates cache directory for stats" { + # Run purge (will exit quickly in non-interactive with no projects) + bash -c " + export HOME='$HOME' + timeout 2 '$PROJECT_ROOT/mole' purge < /dev/null 2>&1 || true + " + + # Cache directory should be created + [ -d "$HOME/.cache/mole" ] || [ -d "${XDG_CACHE_HOME:-$HOME/.cache}/mole" ] +}