1
0
mirror of https://github.com/tw93/Mole.git synced 2026-03-22 19:40:07 +00:00

Merge branch 'main' into dev

This commit is contained in:
tw93
2026-03-03 15:42:13 +08:00
59 changed files with 2303 additions and 1299 deletions

View File

@@ -36,7 +36,7 @@ jobs:
run: brew install shfmt shellcheck golangci-lint run: brew install shfmt shellcheck golangci-lint
- name: Set up Go - name: Set up Go
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v5 uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v5
with: with:
go-version: '1.24.6' go-version: '1.24.6'
@@ -89,7 +89,7 @@ jobs:
run: brew install shfmt shellcheck golangci-lint run: brew install shfmt shellcheck golangci-lint
- name: Set up Go - name: Set up Go
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v5 uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v5
with: with:
go-version: '1.24.6' go-version: '1.24.6'

View File

@@ -26,7 +26,7 @@ jobs:
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4
- name: Set up Go - name: Set up Go
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v5 uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v5
with: with:
go-version: "1.24.6" go-version: "1.24.6"
@@ -48,7 +48,7 @@ jobs:
fi fi
- name: Upload artifacts - name: Upload artifacts
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with: with:
name: ${{ matrix.artifact_name }} name: ${{ matrix.artifact_name }}
path: bin/*-darwin-* path: bin/*-darwin-*
@@ -60,7 +60,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Download all artifacts - name: Download all artifacts
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with: with:
path: bin path: bin
pattern: binaries-* pattern: binaries-*

View File

@@ -17,7 +17,7 @@ jobs:
run: brew install bats-core shellcheck run: brew install bats-core shellcheck
- name: Set up Go - name: Set up Go
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v5 uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v5
with: with:
go-version: "1.24.6" go-version: "1.24.6"

3
.gitignore vendored
View File

@@ -76,3 +76,6 @@ coverage.html
session.json session.json
run_tests.ps1 run_tests.ps1
AGENTS.md
mole_guidelines.md
CLAUDE.md

View File

@@ -61,13 +61,15 @@ mo remove # Remove Mole from system
mo --help # Show help mo --help # Show help
mo --version # Show installed version mo --version # Show installed version
mo clean --dry-run # Preview the cleanup plan # Safe preview before applying changes
mo clean --whitelist # Manage protected caches mo clean --dry-run
mo clean --dry-run --debug # Detailed preview with risk levels and file info mo uninstall --dry-run
mo purge --dry-run
mo optimize --dry-run # Preview optimization actions # --dry-run also works with: optimize, installer, remove, completion, touchid enable
mo optimize --debug # Run with detailed operation logs mo clean --dry-run --debug # Preview + detailed logs
mo optimize --whitelist # Manage protected optimization rules mo optimize --whitelist # Manage protected optimization rules
mo clean --whitelist # Manage protected caches
mo purge --paths # Configure project scan directories mo purge --paths # Configure project scan directories
mo analyze /Volumes # Analyze external drives only mo analyze /Volumes # Analyze external drives only
``` ```
@@ -75,8 +77,7 @@ mo analyze /Volumes # Analyze external drives only
## Tips ## Tips
- Video tutorial: Watch the [Mole tutorial video](https://www.youtube.com/watch?v=UEe9-w4CcQ0), thanks to PAPAYA 電腦教室. - Video tutorial: Watch the [Mole tutorial video](https://www.youtube.com/watch?v=UEe9-w4CcQ0), thanks to PAPAYA 電腦教室.
- Safety first: Deletions are permanent. Review carefully and preview with `mo clean --dry-run`. See [Security Audit](SECURITY_AUDIT.md). - Safety 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).
- Debug and logs: Use `--debug` for detailed logs. Combine with `--dry-run` for a full preview. File operations are logged to `~/.config/mole/operations.log`. Disable with `MO_NO_OPLOG=1`.
- Navigation: Mole supports arrow keys and Vim bindings `h/j/k/l`. - Navigation: Mole supports arrow keys and Vim bindings `h/j/k/l`.
## Features in Detail ## Features in Detail

View File

@@ -1,55 +1,13 @@
# Mole Security Reference # Mole Security Reference
Version 1.27.0 | 2026-02-21 Version 1.28.0 | 2026-02-27
## 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
## Path Validation ## 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. 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: **Blocked paths**, even with sudo:
```text ```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. 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:** **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. 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()`. See `lib/core/app_protection.sh:find_app_files()`.
## Protected Categories ## 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. 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. Time Machine backups running? Won't clean. Status unclear? Also won't clean.
`com.apple.*` LaunchAgents/Daemons are never touched. `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. 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()`. See `lib/core/timeout.sh:run_with_timeout()`.
## User Config ## User Config
@@ -144,6 +121,12 @@ Security-sensitive cleanup paths are covered by BATS regression tests, including
- `tests/clean_user_core.bats` - `tests/clean_user_core.bats`
- `tests/clean_dev_caches.bats` - `tests/clean_dev_caches.bats`
- `tests/clean_system_maintenance.bats` - `tests/clean_system_maintenance.bats`
- `tests/purge.bats`
- `tests/core_safe_functions.bats`
**System Memory Reports** computation uses bulk `find -exec stat` to avoid bash loop child-process limits on corrupted systems.
`bin/clean.sh` dry-run export temp files rely on tracked temp lifecycle (`create_temp_file()` + trap cleanup) to avoid orphan temp artifacts.
Background spinner logic interacts directly with `/dev/tty` and guarantees robust termination signals handling via trap mechanisms.
Latest local verification for this release branch: Latest local verification for this release branch:
@@ -151,6 +134,7 @@ Latest local verification for this release branch:
- `bats tests/clean_user_core.bats` passed (13/13) - `bats tests/clean_user_core.bats` passed (13/13)
- `bats tests/clean_dev_caches.bats` passed (8/8) - `bats tests/clean_dev_caches.bats` passed (8/8)
- `bats tests/clean_system_maintenance.bats` passed (40/40) - `bats tests/clean_system_maintenance.bats` passed (40/40)
- `bats tests/purge.bats tests/core_safe_functions.bats` passed (67/67)
Run tests: Run tests:

View File

@@ -137,11 +137,6 @@ note_activity() {
fi fi
} }
# shellcheck disable=SC2329
has_cached_sudo() {
sudo -n true 2> /dev/null
}
CLEANUP_DONE=false CLEANUP_DONE=false
# shellcheck disable=SC2329 # shellcheck disable=SC2329
cleanup() { cleanup() {
@@ -373,7 +368,7 @@ safe_clean() {
if should_protect_path "$path"; then if should_protect_path "$path"; then
skip=true skip=true
((skipped_count++)) skipped_count=$((skipped_count + 1))
log_operation "clean" "SKIPPED" "$path" "protected" log_operation "clean" "SKIPPED" "$path" "protected"
fi fi
@@ -381,7 +376,7 @@ safe_clean() {
if is_path_whitelisted "$path"; then if is_path_whitelisted "$path"; then
skip=true skip=true
((skipped_count++)) skipped_count=$((skipped_count + 1))
log_operation "clean" "SKIPPED" "$path" "whitelist" log_operation "clean" "SKIPPED" "$path" "whitelist"
fi fi
[[ "$skip" == "true" ]] && continue [[ "$skip" == "true" ]] && continue
@@ -415,7 +410,7 @@ safe_clean() {
fi fi
if [[ $skipped_count -gt 0 ]]; then if [[ $skipped_count -gt 0 ]]; then
((whitelist_skipped_count += skipped_count)) whitelist_skipped_count=$((whitelist_skipped_count + skipped_count))
fi fi
if [[ ${#existing_paths[@]} -eq 0 ]]; then if [[ ${#existing_paths[@]} -eq 0 ]]; then
@@ -479,7 +474,7 @@ safe_clean() {
echo "0 0" > "$temp_dir/result_${idx}" echo "0 0" > "$temp_dir/result_${idx}"
fi fi
((idx++)) idx=$((idx + 1))
if [[ $((idx % 20)) -eq 0 && "$show_spinner" == "true" && -t 1 ]]; then if [[ $((idx % 20)) -eq 0 && "$show_spinner" == "true" && -t 1 ]]; then
update_progress_if_needed "$idx" "${#existing_paths[@]}" last_progress_update 1 || true update_progress_if_needed "$idx" "${#existing_paths[@]}" last_progress_update 1 || true
last_progress_update=$(get_epoch_seconds) last_progress_update=$(get_epoch_seconds)
@@ -508,12 +503,12 @@ safe_clean() {
mv "$tmp_file" "$temp_dir/result_${idx}" 2> /dev/null || true mv "$tmp_file" "$temp_dir/result_${idx}" 2> /dev/null || true
) & ) &
pids+=($!) pids+=($!)
((idx++)) idx=$((idx + 1))
if ((${#pids[@]} >= MOLE_MAX_PARALLEL_JOBS)); then if ((${#pids[@]} >= MOLE_MAX_PARALLEL_JOBS)); then
wait "${pids[0]}" 2> /dev/null || true wait "${pids[0]}" 2> /dev/null || true
pids=("${pids[@]:1}") pids=("${pids[@]:1}")
((completed++)) completed=$((completed + 1))
if [[ "$show_spinner" == "true" && -t 1 ]]; then if [[ "$show_spinner" == "true" && -t 1 ]]; then
update_progress_if_needed "$completed" "$total_paths" last_progress_update 2 || true update_progress_if_needed "$completed" "$total_paths" last_progress_update 2 || true
@@ -525,7 +520,7 @@ safe_clean() {
if [[ ${#pids[@]} -gt 0 ]]; then if [[ ${#pids[@]} -gt 0 ]]; then
for pid in "${pids[@]}"; do for pid in "${pids[@]}"; do
wait "$pid" 2> /dev/null || true wait "$pid" 2> /dev/null || true
((completed++)) completed=$((completed + 1))
if [[ "$show_spinner" == "true" && -t 1 ]]; then if [[ "$show_spinner" == "true" && -t 1 ]]; then
update_progress_if_needed "$completed" "$total_paths" last_progress_update 2 || true update_progress_if_needed "$completed" "$total_paths" last_progress_update 2 || true
@@ -557,17 +552,17 @@ safe_clean() {
if [[ $removed -eq 1 ]]; then if [[ $removed -eq 1 ]]; then
if [[ "$size" -gt 0 ]]; then if [[ "$size" -gt 0 ]]; then
((total_size_kb += size)) total_size_kb=$((total_size_kb + size))
fi fi
((total_count += 1)) total_count=$((total_count + 1))
removed_any=1 removed_any=1
else else
if [[ -e "$path" && "$DRY_RUN" != "true" ]]; then if [[ -e "$path" && "$DRY_RUN" != "true" ]]; then
((removal_failed_count++)) removal_failed_count=$((removal_failed_count + 1))
fi fi
fi fi
fi fi
((idx++)) idx=$((idx + 1))
done done
fi fi
@@ -595,16 +590,16 @@ safe_clean() {
if [[ $removed -eq 1 ]]; then if [[ $removed -eq 1 ]]; then
if [[ "$size_kb" -gt 0 ]]; then if [[ "$size_kb" -gt 0 ]]; then
((total_size_kb += size_kb)) total_size_kb=$((total_size_kb + size_kb))
fi fi
((total_count += 1)) total_count=$((total_count + 1))
removed_any=1 removed_any=1
else else
if [[ -e "$path" && "$DRY_RUN" != "true" ]]; then if [[ -e "$path" && "$DRY_RUN" != "true" ]]; then
((removal_failed_count++)) removal_failed_count=$((removal_failed_count + 1))
fi fi
fi fi
((idx++)) idx=$((idx + 1))
done done
fi fi
fi fi
@@ -626,7 +621,8 @@ safe_clean() {
# Stop spinner before output # Stop spinner before output
stop_section_spinner 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" local label="$description"
if [[ ${#targets[@]} -gt 1 ]]; then if [[ ${#targets[@]} -gt 1 ]]; then
@@ -636,7 +632,8 @@ safe_clean() {
if [[ "$DRY_RUN" == "true" ]]; then if [[ "$DRY_RUN" == "true" ]]; then
echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} $label${NC}, ${YELLOW}$size_human dry${NC}" 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 idx=0
if [[ ${#existing_paths[@]} -gt 0 ]]; then if [[ ${#existing_paths[@]} -gt 0 ]]; then
@@ -650,12 +647,12 @@ safe_clean() {
fi fi
[[ "$size" == "0" || -z "$size" ]] && { [[ "$size" == "0" || -z "$size" ]] && {
((idx++)) idx=$((idx + 1))
continue continue
} }
echo "$(dirname "$path")|$size|$path" >> "$paths_temp" echo "$(dirname "$path")|$size|$path" >> "$paths_temp"
((idx++)) idx=$((idx + 1))
done done
fi fi
@@ -683,7 +680,8 @@ safe_clean() {
} }
} }
' | while IFS='|' read -r display_path total_size child_count; do ' | 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 if [[ $child_count -gt 1 ]]; then
echo "$display_path # $size_human, $child_count items" >> "$EXPORT_LIST_FILE" echo "$display_path # $size_human, $child_count items" >> "$EXPORT_LIST_FILE"
else else
@@ -694,9 +692,9 @@ safe_clean() {
else else
echo -e " ${GREEN}${ICON_SUCCESS}${NC} $label${NC}, ${GREEN}$size_human${NC}" echo -e " ${GREEN}${ICON_SUCCESS}${NC} $label${NC}, ${GREEN}$size_human${NC}"
fi fi
((files_cleaned += total_count)) files_cleaned=$((files_cleaned + total_count))
((total_size_cleaned += total_size_kb)) total_size_cleaned=$((total_size_cleaned + total_size_kb))
((total_items++)) total_items=$((total_items + 1))
note_activity note_activity
fi fi
@@ -738,7 +736,7 @@ start_cleanup() {
EOF EOF
# Preview system section when sudo is already cached (no password prompt). # Preview system section when sudo is already cached (no password prompt).
if has_cached_sudo; then if has_sudo_session; then
SYSTEM_CLEAN=true SYSTEM_CLEAN=true
echo -e "${GREEN}${ICON_SUCCESS}${NC} Admin access available, system preview included" echo -e "${GREEN}${ICON_SUCCESS}${NC} Admin access available, system preview included"
echo "" echo ""
@@ -751,7 +749,7 @@ EOF
fi fi
if [[ -t 0 ]]; then if [[ -t 0 ]]; then
if has_cached_sudo; then if has_sudo_session; then
SYSTEM_CLEAN=true SYSTEM_CLEAN=true
echo -e "${GREEN}${ICON_SUCCESS}${NC} Admin access already available" echo -e "${GREEN}${ICON_SUCCESS}${NC} Admin access already available"
echo "" echo ""
@@ -791,7 +789,7 @@ EOF
else else
echo "" echo ""
echo "Running in non-interactive mode" echo "Running in non-interactive mode"
if has_cached_sudo; then if has_sudo_session; then
SYSTEM_CLEAN=true SYSTEM_CLEAN=true
echo " ${ICON_LIST} System-level cleanup enabled, sudo session active" echo " ${ICON_LIST} System-level cleanup enabled, sudo session active"
else else
@@ -872,9 +870,9 @@ perform_cleanup() {
done done
if [[ "$is_predefined" == "true" ]]; then if [[ "$is_predefined" == "true" ]]; then
((predefined_count++)) predefined_count=$((predefined_count + 1))
else else
((custom_count++)) custom_count=$((custom_count + 1))
fi fi
done done
@@ -1069,7 +1067,8 @@ perform_cleanup() {
fi fi
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") summary_details+=("Free space now: $final_free_space")
fi fi
else else

View File

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

View File

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

View File

@@ -50,7 +50,6 @@ start_purge() {
if [[ -t 1 ]]; then if [[ -t 1 ]]; then
printf '\033[2J\033[H' printf '\033[2J\033[H'
fi fi
printf '\n'
# Initialize stats file in user cache directory # Initialize stats file in user cache directory
local stats_dir="${XDG_CACHE_HOME:-$HOME/.cache}/mole" local stats_dir="${XDG_CACHE_HOME:-$HOME/.cache}/mole"
@@ -83,87 +82,89 @@ perform_purge() {
wait "$monitor_pid" 2> /dev/null || true wait "$monitor_pid" 2> /dev/null || true
fi fi
if [[ -t 1 ]]; then 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 fi
} }
# Set up trap for cleanup # Ensure Ctrl-C/TERM always stops spinner(s) and exits immediately.
trap cleanup_monitor INT TERM 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 if [[ -t 1 ]]; then
# Print title first # Print title ONCE with newline; spinner occupies the line below
printf '%s' "${PURPLE_BOLD}Purge Project Artifacts${NC} " 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_chars="|/-\\"
local spinner_idx=0 local spinner_idx=0
local last_path="" 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 # Set up trap to exit cleanly (erase the spinner line via /dev/tty)
trap 'exit 0' INT TERM 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() { truncate_path() {
local path="$1" local path="$1"
local term_cols if [[ ${#path} -le $max_path_len ]]; then
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
echo "$path" echo "$path"
return return
fi fi
local side_len=$(((max_path_len - 3) / 2))
# Calculate how much to show on each side echo "${path:0:$side_len}...${path: -$side_len}"
local side_len=$(((max_len - 3) / 2))
local start="${path:0:$side_len}"
local end="${path: -$side_len}"
echo "${start}...${end}"
} }
while [[ -f "$stats_dir/purge_scanning" ]]; do while [[ -f "$stats_dir/purge_scanning" ]]; do
local current_path=$(cat "$stats_dir/purge_scanning" 2> /dev/null || echo "") local current_path
local display_path="" current_path=$(cat "$stats_dir/purge_scanning" 2> /dev/null || echo "")
if [[ -n "$current_path" ]]; then if [[ -n "$current_path" ]]; then
display_path="${current_path/#$HOME/~}" local display_path="${current_path/#$HOME/~}"
display_path=$(truncate_path "$display_path") display_path=$(truncate_path "$display_path")
last_path="$display_path" last_path="$display_path"
elif [[ -n "$last_path" ]]; then
display_path="$last_path"
fi fi
# Get current spinner character
local spin_char="${spinner_chars:$spinner_idx:1}" local spin_char="${spinner_chars:$spinner_idx:1}"
spinner_idx=$(((spinner_idx + 1) % ${#spinner_chars})) spinner_idx=$(((spinner_idx + 1) % ${#spinner_chars}))
# Show title on first line, spinner and scanning info on second line # Write directly to /dev/tty: \033[2K clears entire current line, \r goes to start
if [[ -n "$display_path" ]]; then if [[ -n "$last_path" ]]; then
# Line 1: Move to start, clear, print title printf '\r\033[2K%s %sScanning %s%s' \
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' \
"${BLUE}${spin_char}${NC}" \ "${BLUE}${spin_char}${NC}" \
"${GRAY}" "$display_path" "${GRAY}" "$last_path" "${NC}" > /dev/tty 2> /dev/null
# Move up THEN to start (important order!)
printf '\033[A\r'
else else
printf '\r\033[K%s\n' "${PURPLE_BOLD}Purge Project Artifacts${NC}" printf '\r\033[2K%s %sScanning...%s' \
printf '\r\033[K%s %sScanning...' \
"${BLUE}${spin_char}${NC}" \ "${BLUE}${spin_char}${NC}" \
"${GRAY}" "${GRAY}" "${NC}" > /dev/tty 2> /dev/null
printf '\033[A\r'
fi fi
sleep 0.05 sleep 0.05
done done
printf '\r\033[2K' > /dev/tty 2> /dev/null
exit 0 exit 0
) & ) &
monitor_pid=$! monitor_pid=$!
@@ -178,10 +179,6 @@ perform_purge() {
trap - INT TERM trap - INT TERM
cleanup_monitor cleanup_monitor
if [[ -t 1 ]]; then
echo -e "${PURPLE_BOLD}Purge Project Artifacts${NC}"
fi
# Exit codes: # Exit codes:
# 0 = success, show summary # 0 = success, show summary
# 1 = user cancelled # 1 = user cancelled
@@ -208,19 +205,24 @@ perform_purge() {
rm -f "$stats_dir/purge_count" rm -f "$stats_dir/purge_count"
fi fi
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
summary_heading="Dry run complete - no changes made"
fi
if [[ $total_size_cleaned -gt 0 ]]; then if [[ $total_size_cleaned -gt 0 ]]; then
local freed_gb local freed_gb
freed_gb=$(echo "$total_size_cleaned" | awk '{printf "%.2f", $1/1024/1024}') freed_gb=$(echo "$total_size_cleaned" | awk '{printf "%.2f", $1/1024/1024}')
summary_details+=("Space freed: ${GREEN}${freed_gb}GB${NC}") local summary_line="Space freed: ${GREEN}${freed_gb}GB${NC}"
summary_details+=("Free space now: $(get_free_space)") if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
summary_line="Would free: ${GREEN}${freed_gb}GB${NC}"
if [[ $total_items_cleaned -gt 0 ]]; then
summary_details+=("Items cleaned: $total_items_cleaned")
fi fi
[[ $total_items_cleaned -gt 0 ]] && summary_line+=" | Items: $total_items_cleaned"
summary_line+=" | Free: $(get_free_space)"
summary_details+=("$summary_line")
else else
summary_details+=("No old project artifacts to clean.") summary_details+=("No old project artifacts to clean.")
summary_details+=("Free space now: $(get_free_space)") summary_details+=("Free space: $(get_free_space)")
fi fi
# Log session end # Log session end
@@ -238,6 +240,7 @@ show_help() {
echo "" echo ""
echo -e "${YELLOW}Options:${NC}" echo -e "${YELLOW}Options:${NC}"
echo " --paths Edit custom scan directories" echo " --paths Edit custom scan directories"
echo " --dry-run Preview purge actions without making changes"
echo " --debug Enable debug logging" echo " --debug Enable debug logging"
echo " --help Show this help message" echo " --help Show this help message"
echo "" echo ""
@@ -267,6 +270,9 @@ main() {
"--debug") "--debug")
export MO_DEBUG=1 export MO_DEBUG=1
;; ;;
"--dry-run" | "-n")
export MOLE_DRY_RUN=1
;;
*) *)
echo "Unknown option: $arg" echo "Unknown option: $arg"
echo "Use 'mo purge --help' for usage information" echo "Use 'mo purge --help' for usage information"
@@ -276,6 +282,10 @@ main() {
done done
start_purge start_purge
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
echo -e "${YELLOW}${ICON_DRY_RUN} DRY RUN MODE${NC}, No project artifacts will be removed"
printf '\n'
fi
hide_cursor hide_cursor
perform_purge perform_purge
show_cursor show_cursor

View File

@@ -60,6 +60,10 @@ supports_touchid() {
return 1 return 1
} }
touchid_dry_run_enabled() {
[[ "${MOLE_DRY_RUN:-0}" == "1" ]]
}
# Show current Touch ID status # Show current Touch ID status
show_status() { show_status() {
if is_touchid_configured; then if is_touchid_configured; then
@@ -74,6 +78,16 @@ enable_touchid() {
# Cleanup trap handled by global EXIT trap # Cleanup trap handled by global EXIT trap
local temp_file="" local temp_file=""
if touchid_dry_run_enabled; then
if is_touchid_configured; then
echo -e "${GREEN}${ICON_SUCCESS} Touch ID is already enabled, no changes needed${NC}"
else
echo -e "${GREEN}${ICON_SUCCESS} [DRY RUN] Would enable Touch ID for sudo${NC}"
echo -e "${GRAY}${ICON_REVIEW} Target files: ${PAM_SUDO_FILE} and/or ${PAM_SUDO_LOCAL_FILE}${NC}"
fi
return 0
fi
# First check if system supports Touch ID # First check if system supports Touch ID
if ! supports_touchid; then if ! supports_touchid; then
log_warning "This Mac may not support Touch ID" log_warning "This Mac may not support Touch ID"
@@ -201,6 +215,16 @@ disable_touchid() {
# Cleanup trap handled by global EXIT trap # Cleanup trap handled by global EXIT trap
local temp_file="" local temp_file=""
if touchid_dry_run_enabled; then
if ! is_touchid_configured; then
echo -e "${YELLOW}Touch ID is not currently enabled${NC}"
else
echo -e "${GREEN}${ICON_SUCCESS} [DRY RUN] Would disable Touch ID for sudo${NC}"
echo -e "${GRAY}${ICON_REVIEW} Target files: ${PAM_SUDO_FILE} and/or ${PAM_SUDO_LOCAL_FILE}${NC}"
fi
return 0
fi
if ! is_touchid_configured; then if ! is_touchid_configured; then
echo -e "${YELLOW}Touch ID is not currently enabled${NC}" echo -e "${YELLOW}Touch ID is not currently enabled${NC}"
return 0 return 0
@@ -303,12 +327,39 @@ show_menu() {
# Main # Main
main() { main() {
local command="${1:-}" local command=""
local arg
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 case "$command" in
"--help" | "-h")
show_touchid_help
;;
enable) enable)
enable_touchid enable_touchid
;; ;;

View File

@@ -822,10 +822,17 @@ main() {
"--debug") "--debug")
export MO_DEBUG=1 export MO_DEBUG=1
;; ;;
"--dry-run" | "-n")
export MOLE_DRY_RUN=1
;;
esac esac
done done
hide_cursor hide_cursor
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
echo -e "${YELLOW}${ICON_DRY_RUN} DRY RUN MODE${NC}, No app files or settings will be modified"
printf '\n'
fi
local first_scan=true local first_scan=true
while true; do while true; do
@@ -950,12 +957,22 @@ main() {
rm -f "$apps_file" 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 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 drain_pending_input
if [[ -z "$key" ]]; then if [[ "$read_ok" == "true" && -z "$key" ]]; then
: :
else else
show_cursor show_cursor

View File

@@ -80,7 +80,7 @@ func humanizeBytes(size int64) string {
if size < 0 { if size < 0 {
return "0 B" return "0 B"
} }
const unit = 1024 const unit = 1000
if size < unit { if size < unit {
return fmt.Sprintf("%d B", size) return fmt.Sprintf("%d B", size)
} }
@@ -90,7 +90,7 @@ func humanizeBytes(size int64) string {
exp++ exp++
} }
value := float64(size) / float64(div) 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 { func coloredProgressBar(value, maxValue int64, percent float64) string {

View File

@@ -63,15 +63,15 @@ func TestHumanizeBytes(t *testing.T) {
{-100, "0 B"}, {-100, "0 B"},
{0, "0 B"}, {0, "0 B"},
{512, "512 B"}, {512, "512 B"},
{1023, "1023 B"}, {999, "999 B"},
{1024, "1.0 KB"}, {1000, "1.0 kB"},
{1536, "1.5 KB"}, {1500, "1.5 kB"},
{10240, "10.0 KB"}, {10000, "10.0 kB"},
{1048576, "1.0 MB"}, {1000000, "1.0 MB"},
{1572864, "1.5 MB"}, {1500000, "1.5 MB"},
{1073741824, "1.0 GB"}, {1000000000, "1.0 GB"},
{1099511627776, "1.0 TB"}, {1000000000000, "1.0 TB"},
{1125899906842624, "1.0 PB"}, {1000000000000000, "1.0 PB"},
} }
for _, tt := range tests { for _, tt := range tests {

View File

@@ -139,14 +139,20 @@ func (m model) View() string {
return "Loading..." return "Loading..."
} }
header, mole := renderHeader(m.metrics, m.errMessage, m.animFrame, m.width, m.catHidden) termWidth := m.width
cardWidth := 0 if termWidth <= 0 {
if m.width > 80 { termWidth = 80
cardWidth = max(24, m.width/2-4)
} }
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 var rendered []string
for i, c := range cards { for i, c := range cards {
if i > 0 { if i > 0 {
@@ -164,7 +170,9 @@ func (m model) View() string {
return lipgloss.JoinVertical(lipgloss.Left, content...) 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 // Combine header, mole, and cards with consistent spacing
var content []string var content []string
content = append(content, header) content = append(content, header)

View File

@@ -131,6 +131,11 @@ type cardData struct {
} }
func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int, catHidden bool) (string, string) { func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int, catHidden bool) (string, string) {
if termWidth <= 0 {
termWidth = 80
}
compactHeader := termWidth <= 80
title := titleStyle.Render("Status") title := titleStyle.Render("Status")
scoreStyle := getScoreStyle(m.HealthScore) scoreStyle := getScoreStyle(m.HealthScore)
@@ -162,14 +167,39 @@ func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int
if m.Hardware.RefreshRate != "" { if m.Hardware.RefreshRate != "" {
infoParts = append(infoParts, m.Hardware.RefreshRate) infoParts = append(infoParts, m.Hardware.RefreshRate)
} }
if m.Hardware.OSVersion != "" { optionalInfoParts := []string{}
infoParts = append(infoParts, m.Hardware.OSVersion) if !compactHeader && m.Hardware.OSVersion != "" {
optionalInfoParts = append(optionalInfoParts, m.Hardware.OSVersion)
} }
if m.Uptime != "" { if !compactHeader && m.Uptime != "" {
infoParts = append(infoParts, subtleStyle.Render("up "+m.Uptime)) optionalInfoParts = append(optionalInfoParts, subtleStyle.Render("up "+m.Uptime))
} }
headerLine := title + " " + scoreText + " " + strings.Join(infoParts, " · ") headLeft := title + " " + scoreText
headerLine := headLeft
if termWidth > 0 && lipgloss.Width(headerLine) > termWidth {
headerLine = wrapToWidth(headLeft, termWidth)[0]
}
if termWidth > 0 {
allParts := append(append([]string{}, infoParts...), optionalInfoParts...)
if len(allParts) > 0 {
combined := headLeft + " " + strings.Join(allParts, " · ")
if lipgloss.Width(combined) <= termWidth {
headerLine = combined
} else {
// When width is tight, drop lower-priority tail (OS and uptime) as a group.
fitParts := append([]string{}, infoParts...)
for len(fitParts) > 0 {
candidate := headLeft + " " + strings.Join(fitParts, " · ")
if lipgloss.Width(candidate) <= termWidth {
headerLine = candidate
break
}
fitParts = fitParts[:len(fitParts)-1]
}
}
}
}
// Show cat unless hidden - render mole centered below header // Show cat unless hidden - render mole centered below header
var mole string var mole string
@@ -249,7 +279,7 @@ func renderCPUCard(cpu CPUStatus, thermal ThermalStatus) cardData {
return cardData{icon: iconCPU, title: "CPU", lines: lines} 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). // Check if swap is being used (or at least allocated).
hasSwap := mem.SwapTotal > 0 || mem.SwapUsed > 0 hasSwap := mem.SwapTotal > 0 || mem.SwapUsed > 0
@@ -270,8 +300,16 @@ func renderMemoryCard(mem MemoryStatus) cardData {
if mem.SwapTotal > 0 { if mem.SwapTotal > 0 {
swapPercent = (float64(mem.SwapUsed) / float64(mem.SwapTotal)) * 100.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)) 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("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 lines = append(lines, fmt.Sprintf("Avail %s", humanBytes(mem.Total-mem.Used))) // Simplified avail logic for consistency
@@ -399,7 +437,7 @@ func renderProcessCard(procs []ProcessInfo) cardData {
func buildCards(m MetricsSnapshot, width int) []cardData { func buildCards(m MetricsSnapshot, width int) []cardData {
cards := []cardData{ cards := []cardData{
renderCPUCard(m.CPU, m.Thermal), renderCPUCard(m.CPU, m.Thermal),
renderMemoryCard(m.Memory), renderMemoryCard(m.Memory, width),
renderDiskCard(m.Disks, m.DiskIO), renderDiskCard(m.Disks, m.DiskIO),
renderBatteryCard(m.Batteries, m.Thermal), renderBatteryCard(m.Batteries, m.Thermal),
renderProcessCard(m.TopProcesses), renderProcessCard(m.TopProcesses),
@@ -596,18 +634,40 @@ func renderBatteryCard(batts []BatteryStatus, thermal ThermalStatus) cardData {
} }
func renderCard(data cardData, width int, height int) string { func renderCard(data cardData, width int, height int) string {
titleText := data.icon + " " + data.title if width <= 0 {
lineLen := max(width-lipgloss.Width(titleText)-2, 4) width = colWidth
header := titleStyle.Render(titleText) + " " + lineStyle.Render(strings.Repeat("╌", lineLen)) }
content := header + "\n" + strings.Join(data.lines, "\n")
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 { for len(lines) < height {
lines = append(lines, "") lines = append(lines, "")
} }
return strings.Join(lines, "\n") 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 { func progressBar(percent float64) string {
total := 16 total := 16
if percent < 0 { if percent < 0 {

View File

@@ -3,6 +3,8 @@ package main
import ( import (
"strings" "strings"
"testing" "testing"
"github.com/charmbracelet/lipgloss"
) )
func TestFormatRate(t *testing.T) { func TestFormatRate(t *testing.T) {
@@ -934,6 +936,136 @@ func TestRenderHeaderErrorReturnsMoleOnce(t *testing.T) {
} }
} }
func TestRenderHeaderWrapsOnNarrowWidth(t *testing.T) {
m := MetricsSnapshot{
HealthScore: 91,
Hardware: HardwareInfo{
Model: "MacBook Pro",
CPUModel: "Apple M3 Max",
TotalRAM: "128GB",
DiskSize: "4TB",
RefreshRate: "120Hz",
OSVersion: "macOS 15.0",
},
Uptime: "10d 3h",
}
header, _ := renderHeader(m, "", 0, 38, true)
for _, line := range strings.Split(header, "\n") {
if lipgloss.Width(stripANSI(line)) > 38 {
t.Fatalf("renderHeader() line exceeds width: %q", line)
}
}
}
func TestRenderHeaderHidesOSAndUptimeOnNarrowWidth(t *testing.T) {
m := MetricsSnapshot{
HealthScore: 91,
Hardware: HardwareInfo{
Model: "MacBook Pro",
CPUModel: "Apple M3 Max",
TotalRAM: "128GB",
DiskSize: "4TB",
RefreshRate: "120Hz",
OSVersion: "macOS 15.0",
},
Uptime: "10d 3h",
}
header, _ := renderHeader(m, "", 0, 80, true)
plain := stripANSI(header)
if strings.Contains(plain, "macOS 15.0") {
t.Fatalf("renderHeader() narrow width should hide os version, got %q", plain)
}
if strings.Contains(plain, "up 10d 3h") {
t.Fatalf("renderHeader() narrow width should hide uptime, got %q", plain)
}
}
func TestRenderHeaderDropsLowPriorityInfoToStaySingleLine(t *testing.T) {
m := MetricsSnapshot{
HealthScore: 90,
Hardware: HardwareInfo{
Model: "MacBook Pro",
CPUModel: "Apple M2 Pro",
TotalRAM: "32.0 GB",
DiskSize: "460.4 GB",
RefreshRate: "60Hz",
OSVersion: "macOS 26.3",
},
GPU: []GPUStatus{{CoreCount: 19}},
Uptime: "9d 13h",
}
header, _ := renderHeader(m, "", 0, 100, true)
plain := stripANSI(header)
if strings.Contains(plain, "\n") {
t.Fatalf("renderHeader() should stay single line when trimming low-priority fields, got %q", plain)
}
if strings.Contains(plain, "macOS 26.3") {
t.Fatalf("renderHeader() should drop os version when width is tight, got %q", plain)
}
if strings.Contains(plain, "up 9d 13h") {
t.Fatalf("renderHeader() should drop uptime when width is tight, got %q", plain)
}
}
func TestRenderCardWrapsOnNarrowWidth(t *testing.T) {
card := cardData{
icon: iconCPU,
title: "CPU",
lines: []string{
"Total ████████████████ 100.0% @ 85.0°C",
"Load 12.34 / 8.90 / 7.65, 4P+4E",
},
}
rendered := renderCard(card, 26, 0)
for _, line := range strings.Split(rendered, "\n") {
if lipgloss.Width(stripANSI(line)) > 26 {
t.Fatalf("renderCard() line exceeds width: %q", line)
}
}
}
func TestRenderMemoryCardHidesSwapSizeOnNarrowWidth(t *testing.T) {
card := renderMemoryCard(MemoryStatus{
Used: 8 << 30,
Total: 16 << 30,
UsedPercent: 50.0,
SwapUsed: 482,
SwapTotal: 1000,
}, 38)
if len(card.lines) < 3 {
t.Fatalf("renderMemoryCard() expected at least 3 lines, got %d", len(card.lines))
}
swapLine := stripANSI(card.lines[2])
if strings.Contains(swapLine, "/") {
t.Fatalf("renderMemoryCard() narrow width should hide swap size, got %q", swapLine)
}
}
func TestRenderMemoryCardShowsSwapSizeOnWideWidth(t *testing.T) {
card := renderMemoryCard(MemoryStatus{
Used: 8 << 30,
Total: 16 << 30,
UsedPercent: 50.0,
SwapUsed: 482,
SwapTotal: 1000,
}, 60)
if len(card.lines) < 3 {
t.Fatalf("renderMemoryCard() expected at least 3 lines, got %d", len(card.lines))
}
swapLine := stripANSI(card.lines[2])
if !strings.Contains(swapLine, "/") {
t.Fatalf("renderMemoryCard() wide width should include swap size, got %q", swapLine)
}
}
func TestModelViewErrorRendersSingleMole(t *testing.T) { func TestModelViewErrorRendersSingleMole(t *testing.T) {
m := model{ m := model{
width: 120, width: 120,

6
go.mod
View File

@@ -8,7 +8,7 @@ require (
github.com/cespare/xxhash/v2 v2.3.0 github.com/cespare/xxhash/v2 v2.3.0
github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0 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 golang.org/x/sync v0.19.0
) )
@@ -21,7 +21,7 @@ require (
github.com/clipperhouse/displaywidth v0.7.0 // indirect github.com/clipperhouse/displaywidth v0.7.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // 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/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-ole/go-ole v1.3.0 // indirect
github.com/lucasb-eyer/go-colorful 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/tklauser/numcpus v0.11.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yusufpapurcu/wmi v1.2.4 // 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 golang.org/x/text v0.33.0 // indirect
) )

12
go.sum
View File

@@ -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/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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= 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 h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 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= 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/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 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 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.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI=
github.com/shirou/gopsutil/v4 v4.26.1/go.mod h1:medLI9/UNAb0dOI9Q3/7yWSqKkj00u+1tgY8nvv41pc= 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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= 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.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.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.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.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= 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= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View File

@@ -266,6 +266,45 @@ get_installed_version() {
fi 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). # CLI parsing (supports main/latest and version tokens).
parse_args() { parse_args() {
local -a args=("$@") local -a args=("$@")
@@ -506,7 +545,7 @@ download_binary() {
if curl -fsSL --connect-timeout 10 --max-time 60 -o "$target_path" "$url"; then if curl -fsSL --connect-timeout 10 --max-time 60 -o "$target_path" "$url"; then
if [[ -t 1 ]]; then stop_line_spinner; fi if [[ -t 1 ]]; then stop_line_spinner; fi
chmod +x "$target_path" 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" log_success "Downloaded ${binary_name} binary"
else else
if [[ -t 1 ]]; then stop_line_spinner; fi if [[ -t 1 ]]; then stop_line_spinner; fi
@@ -712,6 +751,12 @@ perform_install() {
installed_version="$source_version" installed_version="$source_version"
fi 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. # Edge installs get a suffix to make the version explicit.
if [[ "${MOLE_EDGE_INSTALL:-}" == "true" ]]; then if [[ "${MOLE_EDGE_INSTALL:-}" == "true" ]]; then
installed_version="${installed_version}-edge" installed_version="${installed_version}-edge"
@@ -795,6 +840,12 @@ perform_update() {
updated_version="$target_version" updated_version="$target_version"
fi 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" echo -e "${GREEN}${ICON_SUCCESS}${NC} Updated to latest version, $updated_version"
} }

View File

@@ -33,7 +33,7 @@ clean_ds_store_tree() {
local size local size
size=$(get_file_size "$ds_file") size=$(get_file_size "$ds_file")
total_bytes=$((total_bytes + size)) total_bytes=$((total_bytes + size))
((file_count++)) file_count=$((file_count + 1))
if [[ "$DRY_RUN" != "true" ]]; then if [[ "$DRY_RUN" != "true" ]]; then
rm -f "$ds_file" 2> /dev/null || true rm -f "$ds_file" 2> /dev/null || true
fi fi
@@ -53,9 +53,9 @@ clean_ds_store_tree() {
echo -e " ${GREEN}${ICON_SUCCESS}${NC} $label${NC}, ${GREEN}$file_count files, $size_human${NC}" echo -e " ${GREEN}${ICON_SUCCESS}${NC} $label${NC}, ${GREEN}$file_count files, $size_human${NC}"
fi fi
local size_kb=$(((total_bytes + 1023) / 1024)) local size_kb=$(((total_bytes + 1023) / 1024))
((files_cleaned += file_count)) files_cleaned=$((files_cleaned + file_count))
((total_size_cleaned += size_kb)) total_size_cleaned=$((total_size_cleaned + size_kb))
((total_items++)) total_items=$((total_items + 1))
note_activity note_activity
fi fi
} }
@@ -113,12 +113,12 @@ scan_installed_apps() {
local bundle_id=$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "$plist_path" 2> /dev/null || echo "") local bundle_id=$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "$plist_path" 2> /dev/null || echo "")
if [[ -n "$bundle_id" ]]; then if [[ -n "$bundle_id" ]]; then
echo "$bundle_id" echo "$bundle_id"
((count++)) count=$((count + 1))
fi fi
done done
) > "$scan_tmp_dir/apps_${dir_idx}.txt" & ) > "$scan_tmp_dir/apps_${dir_idx}.txt" &
pids+=($!) pids+=($!)
((dir_idx++)) dir_idx=$((dir_idx + 1))
done done
# Collect running apps and LaunchAgents to avoid false orphan cleanup. # Collect running apps and LaunchAgents to avoid false orphan cleanup.
( (
@@ -300,7 +300,7 @@ clean_orphaned_app_data() {
fi fi
for match in "${matches[@]}"; do for match in "${matches[@]}"; do
[[ -e "$match" ]] || continue [[ -e "$match" ]] || continue
((iteration_count++)) iteration_count=$((iteration_count + 1))
if [[ $iteration_count -gt $MOLE_MAX_ORPHAN_ITERATIONS ]]; then if [[ $iteration_count -gt $MOLE_MAX_ORPHAN_ITERATIONS ]]; then
break break
fi fi
@@ -314,8 +314,8 @@ clean_orphaned_app_data() {
continue continue
fi fi
if safe_clean "$match" "Orphaned $label: $bundle_id"; then if safe_clean "$match" "Orphaned $label: $bundle_id"; then
((orphaned_count++)) orphaned_count=$((orphaned_count + 1))
((total_orphaned_kb += size_kb)) total_orphaned_kb=$((total_orphaned_kb + size_kb))
fi fi
fi fi
done done
@@ -326,7 +326,7 @@ clean_orphaned_app_data() {
stop_section_spinner stop_section_spinner
if [[ $orphaned_count -gt 0 ]]; then if [[ $orphaned_count -gt 0 ]]; then
local orphaned_mb=$(echo "$total_orphaned_kb" | awk '{printf "%.1f", $1/1024}') 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 note_activity
fi fi
rm -f "$installed_bundles" rm -f "$installed_bundles"
@@ -430,8 +430,8 @@ clean_orphaned_system_services() {
orphaned_files+=("$plist") orphaned_files+=("$plist")
local size_kb local size_kb
size_kb=$(sudo du -skP "$plist" 2> /dev/null | awk '{print $1}' || echo "0") size_kb=$(sudo du -skP "$plist" 2> /dev/null | awk '{print $1}' || echo "0")
((total_orphaned_kb += size_kb)) total_orphaned_kb=$((total_orphaned_kb + size_kb))
((orphaned_count++)) orphaned_count=$((orphaned_count + 1))
break break
fi fi
done done
@@ -461,8 +461,8 @@ clean_orphaned_system_services() {
orphaned_files+=("$plist") orphaned_files+=("$plist")
local size_kb local size_kb
size_kb=$(sudo du -skP "$plist" 2> /dev/null | awk '{print $1}' || echo "0") size_kb=$(sudo du -skP "$plist" 2> /dev/null | awk '{print $1}' || echo "0")
((total_orphaned_kb += size_kb)) total_orphaned_kb=$((total_orphaned_kb + size_kb))
((orphaned_count++)) orphaned_count=$((orphaned_count + 1))
break break
fi fi
done done
@@ -491,8 +491,8 @@ clean_orphaned_system_services() {
orphaned_files+=("$helper") orphaned_files+=("$helper")
local size_kb local size_kb
size_kb=$(sudo du -skP "$helper" 2> /dev/null | awk '{print $1}' || echo "0") size_kb=$(sudo du -skP "$helper" 2> /dev/null | awk '{print $1}' || echo "0")
((total_orphaned_kb += size_kb)) total_orphaned_kb=$((total_orphaned_kb + size_kb))
((orphaned_count++)) orphaned_count=$((orphaned_count + 1))
break break
fi fi
done done
@@ -673,7 +673,7 @@ clean_orphaned_launch_agents() {
if is_launch_item_orphaned "$plist"; then if is_launch_item_orphaned "$plist"; then
local size_kb=$(get_path_size_kb "$plist") local size_kb=$(get_path_size_kb "$plist")
orphaned_items+=("$bundle_id|$plist") orphaned_items+=("$bundle_id|$plist")
((total_orphaned_kb += size_kb)) total_orphaned_kb=$((total_orphaned_kb + size_kb))
fi fi
done < <(find "$launch_agents_dir" -maxdepth 1 -name "*.plist" -print0 2> /dev/null) 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" IFS='|' read -r bundle_id plist_path <<< "$item"
if [[ "$is_dry_run" == "true" ]]; then if [[ "$is_dry_run" == "true" ]]; then
((dry_run_count++)) dry_run_count=$((dry_run_count + 1))
log_operation "clean" "DRY_RUN" "$plist_path" "orphaned launch agent" log_operation "clean" "DRY_RUN" "$plist_path" "orphaned launch agent"
continue continue
fi fi
@@ -706,7 +706,7 @@ clean_orphaned_launch_agents() {
# Remove the plist file # Remove the plist file
if safe_remove "$plist_path" false; then if safe_remove "$plist_path" false; then
((removed_count++)) removed_count=$((removed_count + 1))
log_operation "clean" "REMOVED" "$plist_path" "orphaned launch agent" log_operation "clean" "REMOVED" "$plist_path" "orphaned launch agent"
else else
log_operation "clean" "FAILED" "$plist_path" "permission denied" log_operation "clean" "FAILED" "$plist_path" "permission denied"

View File

@@ -5,7 +5,12 @@
clean_homebrew() { clean_homebrew() {
command -v brew > /dev/null 2>&1 || return 0 command -v brew > /dev/null 2>&1 || return 0
if [[ "${DRY_RUN:-false}" == "true" ]]; then 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 return 0
fi fi
# Skip if cleaned recently to avoid repeated heavy operations. # Skip if cleaned recently to avoid repeated heavy operations.

View File

@@ -146,6 +146,8 @@ clean_project_caches() {
done done
if [[ "$spinner_active" == "true" ]]; then if [[ "$spinner_active" == "true" ]]; then
stop_inline_spinner 2> /dev/null || true 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 fi
[[ "$has_dev_projects" == "false" ]] && return 0 [[ "$has_dev_projects" == "false" ]] && return 0
fi fi
@@ -208,7 +210,7 @@ clean_project_caches() {
break break
fi fi
sleep 0.1 sleep 0.1
((grace_period++)) grace_period=$((grace_period + 1))
done done
if kill -0 "$pid" 2> /dev/null; then if kill -0 "$pid" 2> /dev/null; then
kill -KILL "$pid" 2> /dev/null || true kill -KILL "$pid" 2> /dev/null || true

View File

@@ -7,7 +7,17 @@ clean_tool_cache() {
local description="$1" local description="$1"
shift shift
if [[ "$DRY_RUN" != "true" ]]; then 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 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" echo -e " ${GREEN}${ICON_SUCCESS}${NC} $description"
fi fi
else else
@@ -85,7 +95,8 @@ clean_dev_npm() {
} }
# Python/pip ecosystem caches. # Python/pip ecosystem caches.
clean_dev_python() { 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' clean_tool_cache "pip cache" bash -c 'pip3 cache purge > /dev/null 2>&1 || true'
note_activity note_activity
fi fi
@@ -249,11 +260,11 @@ clean_xcode_documentation_cache() {
local entry local entry
for entry in "${sorted_entries[@]}"; do for entry in "${sorted_entries[@]}"; do
if [[ $idx -eq 0 ]]; then if [[ $idx -eq 0 ]]; then
((idx++)) idx=$((idx + 1))
continue continue
fi fi
stale_entries+=("$entry") stale_entries+=("$entry")
((idx++)) idx=$((idx + 1))
done done
if [[ ${#stale_entries[@]} -eq 0 ]]; then if [[ ${#stale_entries[@]} -eq 0 ]]; then
@@ -380,7 +391,7 @@ clean_xcode_simulator_runtime_volumes() {
local unused_count=0 local unused_count=0
for candidate in "${sorted_candidates[@]}"; do for candidate in "${sorted_candidates[@]}"; do
local status="UNUSED" 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" status="IN_USE"
in_use_count=$((in_use_count + 1)) in_use_count=$((in_use_count + 1))
else else
@@ -791,12 +802,12 @@ clean_dev_jetbrains_toolbox() {
local dir_path local dir_path
for dir_path in "${sorted_dirs[@]}"; do for dir_path in "${sorted_dirs[@]}"; do
if [[ $idx -lt $keep_previous ]]; then if [[ $idx -lt $keep_previous ]]; then
((idx++)) idx=$((idx + 1))
continue continue
fi fi
safe_clean "$dir_path" "JetBrains Toolbox old IDE version" safe_clean "$dir_path" "JetBrains Toolbox old IDE version"
note_activity note_activity
((idx++)) idx=$((idx + 1))
done done
done < <(command find "$product_dir" -mindepth 1 -maxdepth 1 -type d -name "ch-*" -print0 2> /dev/null) done < <(command find "$product_dir" -mindepth 1 -maxdepth 1 -type d -name "ch-*" -print0 2> /dev/null)
done done

View File

@@ -3,9 +3,9 @@
set -euo pipefail set -euo pipefail
_MOLE_HINTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" mole_hints_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck disable=SC1090 # 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`. # Quick reminder probe for project build artifacts handled by `mo purge`.
# Designed to be very fast: shallow directory checks only, no deep find scans. # Designed to be very fast: shallow directory checks only, no deep find scans.
@@ -58,7 +58,7 @@ hint_get_path_size_kb_with_timeout() {
record_project_artifact_hint() { record_project_artifact_hint() {
local path="$1" local path="$1"
((PROJECT_ARTIFACT_HINT_COUNT++)) PROJECT_ARTIFACT_HINT_COUNT=$((PROJECT_ARTIFACT_HINT_COUNT + 1))
if [[ ${#PROJECT_ARTIFACT_HINT_EXAMPLES[@]} -lt 2 ]]; then if [[ ${#PROJECT_ARTIFACT_HINT_EXAMPLES[@]} -lt 2 ]]; then
PROJECT_ARTIFACT_HINT_EXAMPLES+=("${path/#$HOME/~}") PROJECT_ARTIFACT_HINT_EXAMPLES+=("${path/#$HOME/~}")
@@ -74,8 +74,8 @@ record_project_artifact_hint() {
local size_kb="" local size_kb=""
if size_kb=$(hint_get_path_size_kb_with_timeout "$path" "$timeout_seconds"); then if size_kb=$(hint_get_path_size_kb_with_timeout "$path" "$timeout_seconds"); then
if [[ "$size_kb" =~ ^[0-9]+$ ]]; then if [[ "$size_kb" =~ ^[0-9]+$ ]]; then
((PROJECT_ARTIFACT_HINT_ESTIMATED_KB += size_kb)) PROJECT_ARTIFACT_HINT_ESTIMATED_KB=$((PROJECT_ARTIFACT_HINT_ESTIMATED_KB + size_kb))
((PROJECT_ARTIFACT_HINT_ESTIMATE_SAMPLES++)) PROJECT_ARTIFACT_HINT_ESTIMATE_SAMPLES=$((PROJECT_ARTIFACT_HINT_ESTIMATE_SAMPLES + 1))
else else
PROJECT_ARTIFACT_HINT_ESTIMATE_PARTIAL=true PROJECT_ARTIFACT_HINT_ESTIMATE_PARTIAL=true
fi fi
@@ -140,8 +140,8 @@ probe_project_artifact_hints() {
local root_projects_scanned=0 local root_projects_scanned=0
if is_quick_purge_project_root "$root"; then if is_quick_purge_project_root "$root"; then
((scanned_projects++)) scanned_projects=$((scanned_projects + 1))
((root_projects_scanned++)) root_projects_scanned=$((root_projects_scanned + 1))
if [[ $scanned_projects -gt $max_projects ]]; then if [[ $scanned_projects -gt $max_projects ]]; then
PROJECT_ARTIFACT_HINT_TRUNCATED=true PROJECT_ARTIFACT_HINT_TRUNCATED=true
stop_scan=true stop_scan=true
@@ -175,8 +175,8 @@ probe_project_artifact_hints() {
break break
fi fi
((scanned_projects++)) scanned_projects=$((scanned_projects + 1))
((root_projects_scanned++)) root_projects_scanned=$((root_projects_scanned + 1))
if [[ $scanned_projects -gt $max_projects ]]; then if [[ $scanned_projects -gt $max_projects ]]; then
PROJECT_ARTIFACT_HINT_TRUNCATED=true PROJECT_ARTIFACT_HINT_TRUNCATED=true
stop_scan=true stop_scan=true
@@ -206,7 +206,7 @@ probe_project_artifact_hints() {
;; ;;
esac esac
((nested_count++)) nested_count=$((nested_count + 1))
if [[ $nested_count -gt $max_nested_per_project ]]; then if [[ $nested_count -gt $max_nested_per_project ]]; then
break break
fi fi

View File

@@ -569,16 +569,38 @@ select_purge_categories() {
fi fi
done done
local original_stty="" 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 if [[ -t 0 ]] && command -v stty > /dev/null 2>&1; then
original_stty=$(stty -g 2> /dev/null || echo "") original_stty=$(stty -g 2> /dev/null || echo "")
fi 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 # Terminal control functions
restore_terminal() { 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 trap - EXIT INT TERM
show_cursor show_cursor
if [[ -n "${original_stty:-}" ]]; then if [[ -n "${original_stty:-}" ]]; then
stty "${original_stty}" 2> /dev/null || stty sane 2> /dev/null || true stty "${original_stty}" 2> /dev/null || stty sane 2> /dev/null || true
fi 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 # shellcheck disable=SC2329
handle_interrupt() { handle_interrupt() {
@@ -618,7 +640,7 @@ select_purge_categories() {
for ((i = 0; i < total_items; i++)); do for ((i = 0; i < total_items; i++)); do
if [[ ${selected[i]} == true ]]; then if [[ ${selected[i]} == true ]]; then
selected_size=$((selected_size + ${sizes[i]:-0})) selected_size=$((selected_size + ${sizes[i]:-0}))
((selected_count++)) selected_count=$((selected_count + 1))
fi fi
done done
@@ -633,7 +655,6 @@ select_purge_categories() {
scroll_indicator=" ${GRAY}[${current_pos}/${total_items}]${NC}" scroll_indicator=" ${GRAY}[${current_pos}/${total_items}]${NC}"
fi 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${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" printf "%s\n" "$clear_line"
@@ -656,15 +677,42 @@ select_purge_categories() {
fi fi
done done
# Fill empty slots to clear previous content # Keep one blank line between the list and footer tips.
local items_shown=$visible_count
for ((i = items_shown; i < items_per_page; i++)); do
printf "%s\n" "$clear_line"
done
printf "%s\n" "$clear_line" 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() { move_cursor_up() {
if [[ $cursor_pos -gt 0 ]]; then if [[ $cursor_pos -gt 0 ]]; then
@@ -680,9 +728,9 @@ select_purge_categories() {
local visible_count=$((total_items - top_index)) local visible_count=$((total_items - top_index))
[[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page
if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then
((cursor_pos++)) cursor_pos=$((cursor_pos + 1))
elif [[ $((top_index + visible_count)) -lt $total_items ]]; then elif [[ $((top_index + visible_count)) -lt $total_items ]]; then
((top_index++)) top_index=$((top_index + 1))
fi fi
fi fi
} }
@@ -767,6 +815,48 @@ select_purge_categories() {
esac esac
done 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 # Main cleanup function - scans and prompts user to select artifacts to clean
clean_project_artifacts() { clean_project_artifacts() {
local -a all_found_items=() local -a all_found_items=()
@@ -825,8 +915,6 @@ clean_project_artifacts() {
# Give monitor process time to exit and clear its output # Give monitor process time to exit and clear its output
if [[ -t 1 ]]; then if [[ -t 1 ]]; then
sleep 0.2 sleep 0.2
# Clear the scanning line but preserve the title
printf '\n\033[K'
fi fi
# Collect all results # Collect all results
@@ -1041,32 +1129,57 @@ clean_project_artifacts() {
echo "$artifact_name" echo "$artifact_name"
fi 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() { format_purge_display() {
local project_path="$1" local project_path="$1"
local artifact_type="$2" local artifact_type="$2"
local size_str="$3" local size_str="$3"
# Terminal width for alignment local terminal_width="${4:-$(tput cols 2> /dev/null || echo 80)}"
local terminal_width=$(tput cols 2> /dev/null || echo 80) local max_path_width="${5:-}"
local fixed_width=32 # Reserve for size and artifact type (9 + 3 + 20) local artifact_col="${6:-12}"
local available_width=$((terminal_width - fixed_width)) local available_width
# Bounds: 30 chars min, but cap at 70% of terminal width to preserve aesthetics
local max_aesthetic_width=$((terminal_width * 70 / 100)) if [[ -n "$max_path_width" ]]; then
[[ $available_width -gt $max_aesthetic_width ]] && available_width=$max_aesthetic_width available_width="$max_path_width"
[[ $available_width -lt 30 ]] && available_width=30 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 # Truncate project path if needed
local truncated_path=$(truncate_by_display_width "$project_path" "$available_width") local truncated_path
local current_width=$(get_display_width "$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 char_count=${#truncated_path}
local padding=$((available_width - current_width)) local padding=$((available_width - current_width))
local printf_width=$((char_count + padding)) local printf_width=$((char_count + padding))
# Format: "project_path size | artifact_type" # 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 # 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 for item in "${safe_to_clean[@]}"; do
local project_path=$(get_project_path "$item") local project_path
local artifact_type=$(get_artifact_display_name "$item") project_path=$(get_project_path "$item")
local artifact_type
artifact_type=$(get_artifact_display_name "$item")
local size_raw local size_raw
size_raw=$(get_dir_size_kb "$item") size_raw=$(get_dir_size_kb "$item")
local size_kb=0 local size_kb=0
@@ -1095,13 +1208,66 @@ clean_project_artifacts() {
break break
fi fi
done 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_paths+=("$item")
item_sizes+=("$size_kb") item_sizes+=("$size_kb")
item_size_unknown_flags+=("$size_unknown") item_size_unknown_flags+=("$size_unknown")
item_recent_flags+=("$is_recent") item_recent_flags+=("$is_recent")
done 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 # Sort by size descending (largest first) - requested in issue #311
# Use external sort for better performance with many items # Use external sort for better performance with many items
if [[ ${#item_sizes[@]} -gt 0 ]]; then if [[ ${#item_sizes[@]} -gt 0 ]]; then
@@ -1147,11 +1313,11 @@ clean_project_artifacts() {
# Set global vars for selector # Set global vars for selector
export PURGE_CATEGORY_SIZES=$( export PURGE_CATEGORY_SIZES=$(
IFS=, IFS=,
echo "${item_sizes[*]}" echo "${item_sizes[*]-}"
) )
export PURGE_RECENT_CATEGORIES=$( export PURGE_RECENT_CATEGORIES=$(
IFS=, IFS=,
echo "${item_recent_flags[*]}" echo "${item_recent_flags[*]-}"
) )
# Interactive selection (only if terminal is available) # Interactive selection (only if terminal is available)
PURGE_SELECTION_RESULT="" PURGE_SELECTION_RESULT=""
@@ -1176,11 +1342,32 @@ clean_project_artifacts() {
unset PURGE_CATEGORY_SIZES PURGE_RECENT_CATEGORIES PURGE_SELECTION_RESULT unset PURGE_CATEGORY_SIZES PURGE_RECENT_CATEGORIES PURGE_SELECTION_RESULT
return 0 return 0
fi fi
IFS=',' read -r -a selected_indices <<< "$PURGE_SELECTION_RESULT"
local selected_total_kb=0
local selected_unknown_count=0
for idx in "${selected_indices[@]}"; do
local selected_size_kb="${item_sizes[idx]:-0}"
[[ "$selected_size_kb" =~ ^[0-9]+$ ]] || selected_size_kb=0
selected_total_kb=$((selected_total_kb + selected_size_kb))
if [[ "${item_size_unknown_flags[idx]:-false}" == "true" ]]; then
selected_unknown_count=$((selected_unknown_count + 1))
fi
done
if [[ -t 0 ]]; then
if ! confirm_purge_cleanup "${#selected_indices[@]}" "$selected_total_kb" "$selected_unknown_count"; then
echo -e "${GRAY}Purge cancelled${NC}"
printf '\n'
unset PURGE_CATEGORY_SIZES PURGE_RECENT_CATEGORIES PURGE_SELECTION_RESULT
return 1
fi
fi
# Clean selected items # Clean selected items
echo "" echo ""
IFS=',' read -r -a selected_indices <<< "$PURGE_SELECTION_RESULT"
local stats_dir="${XDG_CACHE_HOME:-$HOME/.cache}/mole" local stats_dir="${XDG_CACHE_HOME:-$HOME/.cache}/mole"
local cleaned_count=0 local cleaned_count=0
local dry_run_mode="${MOLE_DRY_RUN:-0}"
for idx in "${selected_indices[@]}"; do for idx in "${selected_indices[@]}"; do
local item_path="${item_paths[idx]}" local item_path="${item_paths[idx]}"
local artifact_type=$(basename "$item_path") local artifact_type=$(basename "$item_path")
@@ -1200,17 +1387,27 @@ clean_project_artifacts() {
if [[ -t 1 ]]; then if [[ -t 1 ]]; then
start_inline_spinner "Cleaning $project_path/$artifact_type..." start_inline_spinner "Cleaning $project_path/$artifact_type..."
fi fi
local removal_recorded=false
if [[ -e "$item_path" ]]; then if [[ -e "$item_path" ]]; then
safe_remove "$item_path" true if safe_remove "$item_path" true; then
if [[ ! -e "$item_path" ]]; then if [[ "$dry_run_mode" == "1" || ! -e "$item_path" ]]; then
local current_total=$(cat "$stats_dir/purge_stats" 2> /dev/null || echo "0") local current_total
echo "$((current_total + size_kb))" > "$stats_dir/purge_stats" current_total=$(cat "$stats_dir/purge_stats" 2> /dev/null || echo "0")
((cleaned_count++)) echo "$((current_total + size_kb))" > "$stats_dir/purge_stats"
cleaned_count=$((cleaned_count + 1))
removal_recorded=true
fi
fi fi
fi fi
if [[ -t 1 ]]; then if [[ -t 1 ]]; then
stop_inline_spinner stop_inline_spinner
echo -e "${GREEN}${ICON_SUCCESS}${NC} $project_path, $artifact_type${NC}, ${GREEN}$size_human${NC}" if [[ "$removal_recorded" == "true" ]]; then
if [[ "$dry_run_mode" == "1" ]]; then
echo -e "${GREEN}${ICON_SUCCESS}${NC} [DRY RUN] $project_path, $artifact_type${NC}, ${GREEN}$size_human${NC}"
else
echo -e "${GREEN}${ICON_SUCCESS}${NC} $project_path, $artifact_type${NC}, ${GREEN}$size_human${NC}"
fi
fi
fi fi
done done
# Update count # Update count

View File

@@ -5,6 +5,7 @@ set -euo pipefail
clean_deep_system() { clean_deep_system() {
stop_section_spinner stop_section_spinner
local cache_cleaned=0 local cache_cleaned=0
start_section_spinner "Cleaning system caches..."
# Optimized: Single pass for /Library/Caches (3 patterns in 1 scan) # Optimized: Single pass for /Library/Caches (3 patterns in 1 scan)
if sudo test -d "/Library/Caches" 2> /dev/null; then if sudo test -d "/Library/Caches" 2> /dev/null; then
while IFS= read -r -d '' file; do while IFS= read -r -d '' file; do
@@ -20,6 +21,7 @@ clean_deep_system() {
\( -name "*.log" -mtime "+$MOLE_LOG_AGE_DAYS" \) \ \( -name "*.log" -mtime "+$MOLE_LOG_AGE_DAYS" \) \
\) -print0 2> /dev/null || true) \) -print0 2> /dev/null || true)
fi fi
stop_section_spinner
[[ $cache_cleaned -eq 1 ]] && log_success "System caches" [[ $cache_cleaned -eq 1 ]] && log_success "System caches"
start_section_spinner "Cleaning system temporary files..." start_section_spinner "Cleaning system temporary files..."
local tmp_cleaned=0 local tmp_cleaned=0
@@ -84,7 +86,7 @@ clean_deep_system() {
continue continue
fi fi
if safe_sudo_remove "$item"; then if safe_sudo_remove "$item"; then
((updates_cleaned++)) updates_cleaned=$((updates_cleaned + 1))
fi fi
done < <(find /Library/Updates -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true) done < <(find /Library/Updates -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true)
stop_section_spinner stop_section_spinner
@@ -141,28 +143,33 @@ clean_deep_system() {
debug_log "Cleaning macOS installer: $app_name, $size_human, ${age_days} days old" debug_log "Cleaning macOS installer: $app_name, $size_human, ${age_days} days old"
if safe_sudo_remove "$installer_app"; then if safe_sudo_remove "$installer_app"; then
log_success "$app_name, $size_human" log_success "$app_name, $size_human"
((installer_cleaned++)) installer_cleaned=$((installer_cleaned + 1))
fi fi
fi fi
done done
stop_section_spinner stop_section_spinner
[[ $installer_cleaned -gt 0 ]] && debug_log "Cleaned $installer_cleaned macOS installer(s)" [[ $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 local code_sign_cleaned=0
while IFS= read -r -d '' cache_dir; do while IFS= read -r -d '' cache_dir; do
if safe_sudo_remove "$cache_dir"; then if safe_sudo_remove "$cache_dir"; then
((code_sign_cleaned++)) code_sign_cleaned=$((code_sign_cleaned + 1))
fi fi
done < <(run_with_timeout 5 command find /private/var/folders -type d -name "*.code_sign_clone" -path "*/X/*" -print0 2> /dev/null || true) 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 stop_section_spinner
[[ $code_sign_cleaned -gt 0 ]] && log_success "Browser code signature caches, $code_sign_cleaned items" [[ $code_sign_cleaned -gt 0 ]] && log_success "Browser code signature caches, $code_sign_cleaned items"
local diag_base="/private/var/db/diagnostics" 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" "*" "$MOLE_LOG_AGE_DAYS" "f" || true
safe_sudo_find_delete "$diag_base" "*.tracev3" "30" "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 safe_sudo_find_delete "/private/var/db/DiagnosticPipeline" "*" "$MOLE_LOG_AGE_DAYS" "f" || true
stop_section_spinner
log_success "System diagnostic logs" 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 safe_sudo_find_delete "/private/var/db/powerlog" "*" "$MOLE_LOG_AGE_DAYS" "f" || true
stop_section_spinner
log_success "Power logs" log_success "Power logs"
start_section_spinner "Cleaning memory exception reports..." start_section_spinner "Cleaning memory exception reports..."
local mem_reports_dir="/private/var/db/reportmemoryexception/MemoryLimitViolations" local mem_reports_dir="/private/var/db/reportmemoryexception/MemoryLimitViolations"
@@ -171,15 +178,16 @@ clean_deep_system() {
# Count and size old files before deletion # Count and size old files before deletion
local file_count=0 local file_count=0
local total_size_kb=0 local total_size_kb=0
while IFS= read -r -d '' file; do local total_bytes=0
((file_count++)) local stats_out
local file_size 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)
file_size=$(sudo stat -f%z "$file" 2> /dev/null || echo "0") if [[ -n "$stats_out" ]]; then
((total_size_kb += file_size / 1024)) read -r file_count total_bytes <<< "$stats_out"
done < <(sudo find "$mem_reports_dir" -type f -mtime +30 -print0 2> /dev/null || true) total_size_kb=$((total_bytes / 1024))
fi
if [[ "$file_count" -gt 0 ]]; then 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 if safe_sudo_find_delete "$mem_reports_dir" "*" "30" "f"; then
mem_cleaned=1 mem_cleaned=1
fi fi
@@ -207,6 +215,11 @@ clean_time_machine_failed_backups() {
echo -e " ${GREEN}${ICON_SUCCESS}${NC} No incomplete backups found" echo -e " ${GREEN}${ICON_SUCCESS}${NC} No incomplete backups found"
return 0 return 0
fi 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..." start_section_spinner "Checking Time Machine configuration..."
local spinner_active=true local spinner_active=true
local tm_info local tm_info
@@ -287,7 +300,7 @@ clean_time_machine_failed_backups() {
size_human=$(bytes_to_human "$((size_kb * 1024))") size_human=$(bytes_to_human "$((size_kb * 1024))")
if [[ "$DRY_RUN" == "true" ]]; then if [[ "$DRY_RUN" == "true" ]]; then
echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Incomplete backup: $backup_name${NC}, ${YELLOW}$size_human dry${NC}" echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Incomplete backup: $backup_name${NC}, ${YELLOW}$size_human dry${NC}"
((tm_cleaned++)) tm_cleaned=$((tm_cleaned + 1))
note_activity note_activity
continue continue
fi fi
@@ -297,10 +310,10 @@ clean_time_machine_failed_backups() {
fi fi
if tmutil delete "$inprogress_file" 2> /dev/null; then if tmutil delete "$inprogress_file" 2> /dev/null; then
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Incomplete backup: $backup_name${NC}, ${GREEN}$size_human${NC}" echo -e " ${GREEN}${ICON_SUCCESS}${NC} Incomplete backup: $backup_name${NC}, ${GREEN}$size_human${NC}"
((tm_cleaned++)) tm_cleaned=$((tm_cleaned + 1))
((files_cleaned++)) files_cleaned=$((files_cleaned + 1))
((total_size_cleaned += size_kb)) total_size_cleaned=$((total_size_cleaned + size_kb))
((total_items++)) total_items=$((total_items + 1))
note_activity note_activity
else else
echo -e " ${YELLOW}!${NC} Could not delete: $backup_name · try manually with sudo" echo -e " ${YELLOW}!${NC} Could not delete: $backup_name · try manually with sudo"
@@ -339,7 +352,7 @@ clean_time_machine_failed_backups() {
size_human=$(bytes_to_human "$((size_kb * 1024))") size_human=$(bytes_to_human "$((size_kb * 1024))")
if [[ "$DRY_RUN" == "true" ]]; then 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}" echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Incomplete APFS backup in $bundle_name: $backup_name${NC}, ${YELLOW}$size_human dry${NC}"
((tm_cleaned++)) tm_cleaned=$((tm_cleaned + 1))
note_activity note_activity
continue continue
fi fi
@@ -348,10 +361,10 @@ clean_time_machine_failed_backups() {
fi fi
if tmutil delete "$inprogress_file" 2> /dev/null; then 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}" echo -e " ${GREEN}${ICON_SUCCESS}${NC} Incomplete APFS backup in $bundle_name: $backup_name${NC}, ${GREEN}$size_human${NC}"
((tm_cleaned++)) tm_cleaned=$((tm_cleaned + 1))
((files_cleaned++)) files_cleaned=$((files_cleaned + 1))
((total_size_cleaned += size_kb)) total_size_cleaned=$((total_size_cleaned + size_kb))
((total_items++)) total_items=$((total_items + 1))
note_activity note_activity
else else
echo -e " ${YELLOW}!${NC} Could not delete from bundle: $backup_name" echo -e " ${YELLOW}!${NC} Could not delete from bundle: $backup_name"
@@ -388,6 +401,10 @@ clean_local_snapshots() {
if ! command -v tmutil > /dev/null 2>&1; then if ! command -v tmutil > /dev/null 2>&1; then
return 0 return 0
fi 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..." start_section_spinner "Checking Time Machine status..."
local rc_running=0 local rc_running=0

View File

@@ -23,7 +23,7 @@ clean_user_essentials() {
local cleaned_count=0 local cleaned_count=0
while IFS= read -r -d '' item; do while IFS= read -r -d '' item; do
if safe_remove "$item" true; then if safe_remove "$item" true; then
((cleaned_count++)) cleaned_count=$((cleaned_count + 1))
fi fi
done < <(command find "$HOME/.Trash" -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true) done < <(command find "$HOME/.Trash" -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true)
if [[ $cleaned_count -gt 0 ]]; then if [[ $cleaned_count -gt 0 ]]; then
@@ -76,8 +76,13 @@ _clean_mail_downloads() {
) )
local count=0 local count=0
local cleaned_kb=0 local cleaned_kb=0
local spinner_active=false
for target_path in "${mail_dirs[@]}"; do for target_path in "${mail_dirs[@]}"; do
if [[ -d "$target_path" ]]; then 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 local dir_size_kb=0
dir_size_kb=$(get_path_size_kb "$target_path") dir_size_kb=$(get_path_size_kb "$target_path")
if ! [[ "$dir_size_kb" =~ ^[0-9]+$ ]]; then if ! [[ "$dir_size_kb" =~ ^[0-9]+$ ]]; then
@@ -95,13 +100,16 @@ _clean_mail_downloads() {
local file_size_kb local file_size_kb
file_size_kb=$(get_path_size_kb "$file_path") file_size_kb=$(get_path_size_kb "$file_path")
if safe_remove "$file_path" true; then if safe_remove "$file_path" true; then
((count++)) count=$((count + 1))
((cleaned_kb += file_size_kb)) cleaned_kb=$((cleaned_kb + file_size_kb))
fi fi
fi fi
done < <(command find "$target_path" -type f -mtime +"$mail_age_days" -print0 2> /dev/null || true) done < <(command find "$target_path" -type f -mtime +"$mail_age_days" -print0 2> /dev/null || true)
fi fi
done done
if [[ "$spinner_active" == "true" ]]; then
stop_section_spinner
fi
if [[ $count -gt 0 ]]; then if [[ $count -gt 0 ]]; then
local cleaned_mb local cleaned_mb
cleaned_mb=$(echo "$cleaned_kb" | awk '{printf "%.1f", $1/1024}' || echo "0.0") cleaned_mb=$(echo "$cleaned_kb" | awk '{printf "%.1f", $1/1024}' || echo "0.0")
@@ -163,7 +171,7 @@ clean_chrome_old_versions() {
size_kb=$(get_path_size_kb "$dir" || echo 0) size_kb=$(get_path_size_kb "$dir" || echo 0)
size_kb="${size_kb:-0}" size_kb="${size_kb:-0}"
total_size=$((total_size + size_kb)) total_size=$((total_size + size_kb))
((cleaned_count++)) cleaned_count=$((cleaned_count + 1))
cleaned_any=true cleaned_any=true
if [[ "$DRY_RUN" != "true" ]]; then if [[ "$DRY_RUN" != "true" ]]; then
if has_sudo_session; then if has_sudo_session; then
@@ -183,9 +191,9 @@ clean_chrome_old_versions() {
else else
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Chrome old versions${NC}, ${GREEN}${cleaned_count} dirs, $size_human${NC}" echo -e " ${GREEN}${ICON_SUCCESS}${NC} Chrome old versions${NC}, ${GREEN}${cleaned_count} dirs, $size_human${NC}"
fi fi
((files_cleaned += cleaned_count)) files_cleaned=$((files_cleaned + cleaned_count))
((total_size_cleaned += total_size)) total_size_cleaned=$((total_size_cleaned + total_size))
((total_items++)) total_items=$((total_items + 1))
note_activity note_activity
fi fi
} }
@@ -249,7 +257,7 @@ clean_edge_old_versions() {
size_kb=$(get_path_size_kb "$dir" || echo 0) size_kb=$(get_path_size_kb "$dir" || echo 0)
size_kb="${size_kb:-0}" size_kb="${size_kb:-0}"
total_size=$((total_size + size_kb)) total_size=$((total_size + size_kb))
((cleaned_count++)) cleaned_count=$((cleaned_count + 1))
cleaned_any=true cleaned_any=true
if [[ "$DRY_RUN" != "true" ]]; then if [[ "$DRY_RUN" != "true" ]]; then
if has_sudo_session; then if has_sudo_session; then
@@ -269,9 +277,9 @@ clean_edge_old_versions() {
else else
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Edge old versions${NC}, ${GREEN}${cleaned_count} dirs, $size_human${NC}" echo -e " ${GREEN}${ICON_SUCCESS}${NC} Edge old versions${NC}, ${GREEN}${cleaned_count} dirs, $size_human${NC}"
fi fi
((files_cleaned += cleaned_count)) files_cleaned=$((files_cleaned + cleaned_count))
((total_size_cleaned += total_size)) total_size_cleaned=$((total_size_cleaned + total_size))
((total_items++)) total_items=$((total_items + 1))
note_activity note_activity
fi fi
} }
@@ -316,7 +324,7 @@ clean_edge_updater_old_versions() {
size_kb=$(get_path_size_kb "$dir" || echo 0) size_kb=$(get_path_size_kb "$dir" || echo 0)
size_kb="${size_kb:-0}" size_kb="${size_kb:-0}"
total_size=$((total_size + size_kb)) total_size=$((total_size + size_kb))
((cleaned_count++)) cleaned_count=$((cleaned_count + 1))
cleaned_any=true cleaned_any=true
if [[ "$DRY_RUN" != "true" ]]; then if [[ "$DRY_RUN" != "true" ]]; then
safe_remove "$dir" true > /dev/null 2>&1 || true safe_remove "$dir" true > /dev/null 2>&1 || true
@@ -331,9 +339,9 @@ clean_edge_updater_old_versions() {
else else
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Edge updater old versions${NC}, ${GREEN}${cleaned_count} dirs, $size_human${NC}" echo -e " ${GREEN}${ICON_SUCCESS}${NC} Edge updater old versions${NC}, ${GREEN}${cleaned_count} dirs, $size_human${NC}"
fi fi
((files_cleaned += cleaned_count)) files_cleaned=$((files_cleaned + cleaned_count))
((total_size_cleaned += total_size)) total_size_cleaned=$((total_size_cleaned + total_size))
((total_items++)) total_items=$((total_items + 1))
note_activity note_activity
fi fi
} }
@@ -387,6 +395,7 @@ scan_external_volumes() {
done done
stop_section_spinner stop_section_spinner
} }
# Finder metadata (.DS_Store). # Finder metadata (.DS_Store).
clean_finder_metadata() { clean_finder_metadata() {
if [[ "$PROTECT_FINDER_METADATA" == "true" ]]; then if [[ "$PROTECT_FINDER_METADATA" == "true" ]]; then
@@ -411,14 +420,17 @@ clean_support_app_data() {
safe_find_delete "$idle_assets_dir" "*" "$support_age_days" "f" || true safe_find_delete "$idle_assets_dir" "*" "$support_age_days" "f" || true
fi fi
# Clean old aerial wallpaper videos (can be large, safe to remove).
safe_clean ~/Library/Application\ Support/com.apple.wallpaper/aerials/videos/* "Aerial wallpaper videos"
# Do not touch Messages attachments, only preview/sticker caches. # Do not touch Messages attachments, only preview/sticker caches.
if pgrep -x "Messages" > /dev/null 2>&1; then if pgrep -x "Messages" > /dev/null 2>&1; then
echo -e " ${GRAY}${ICON_WARNING}${NC} Messages is running · preview cache cleanup skipped" 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 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). # App caches (merged: macOS system caches + Sandboxed apps).
@@ -472,14 +484,15 @@ clean_app_caches() {
else else
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Sandboxed app caches${NC}, ${GREEN}$size_human${NC}" echo -e " ${GREEN}${ICON_SUCCESS}${NC} Sandboxed app caches${NC}, ${GREEN}$size_human${NC}"
fi fi
((files_cleaned += cleaned_count)) files_cleaned=$((files_cleaned + cleaned_count))
((total_size_cleaned += total_size)) total_size_cleaned=$((total_size_cleaned + total_size))
((total_items++)) total_items=$((total_items + 1))
note_activity note_activity
fi fi
clean_group_container_caches clean_group_container_caches
} }
# Process a single container cache directory. # Process a single container cache directory.
process_container_cache() { process_container_cache() {
local container_dir="$1" local container_dir="$1"
@@ -500,9 +513,9 @@ process_container_cache() {
if find "$cache_dir" -mindepth 1 -maxdepth 1 -print -quit 2> /dev/null | grep -q .; then if find "$cache_dir" -mindepth 1 -maxdepth 1 -print -quit 2> /dev/null | grep -q .; then
local size local size
size=$(get_path_size_kb "$cache_dir") size=$(get_path_size_kb "$cache_dir")
((total_size += size)) total_size=$((total_size + size))
found_any=true found_any=true
((cleaned_count++)) cleaned_count=$((cleaned_count + 1))
if [[ "$DRY_RUN" != "true" ]]; then if [[ "$DRY_RUN" != "true" ]]; then
local item local item
while IFS= read -r -d '' item; do while IFS= read -r -d '' item; do
@@ -600,7 +613,7 @@ clean_group_container_caches() {
item_size=$(get_path_size_kb "$item" 2> /dev/null) || item_size=0 item_size=$(get_path_size_kb "$item" 2> /dev/null) || item_size=0
[[ "$item_size" =~ ^[0-9]+$ ]] || item_size=0 [[ "$item_size" =~ ^[0-9]+$ ]] || item_size=0
candidate_changed=true candidate_changed=true
((candidate_size_kb += item_size)) candidate_size_kb=$((candidate_size_kb + item_size))
done done
else else
for item in "${items_to_clean[@]}"; do for item in "${items_to_clean[@]}"; do
@@ -609,14 +622,14 @@ clean_group_container_caches() {
[[ "$item_size" =~ ^[0-9]+$ ]] || item_size=0 [[ "$item_size" =~ ^[0-9]+$ ]] || item_size=0
if safe_remove "$item" true 2> /dev/null; then if safe_remove "$item" true 2> /dev/null; then
candidate_changed=true candidate_changed=true
((candidate_size_kb += item_size)) candidate_size_kb=$((candidate_size_kb + item_size))
fi fi
done done
fi fi
if [[ "$candidate_changed" == "true" ]]; then if [[ "$candidate_changed" == "true" ]]; then
((total_size += candidate_size_kb)) total_size=$((total_size + candidate_size_kb))
((cleaned_count++)) cleaned_count=$((cleaned_count + 1))
found_any=true found_any=true
fi fi
done done
@@ -632,12 +645,13 @@ clean_group_container_caches() {
else else
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Group Containers logs/caches${NC}, ${GREEN}$size_human${NC}" echo -e " ${GREEN}${ICON_SUCCESS}${NC} Group Containers logs/caches${NC}, ${GREEN}$size_human${NC}"
fi fi
((files_cleaned += cleaned_count)) files_cleaned=$((files_cleaned + cleaned_count))
((total_size_cleaned += total_size)) total_size_cleaned=$((total_size_cleaned + total_size))
((total_items++)) total_items=$((total_items + 1))
note_activity note_activity
fi fi
} }
# Browser caches (Safari/Chrome/Edge/Firefox). # Browser caches (Safari/Chrome/Edge/Firefox).
clean_browsers() { clean_browsers() {
safe_clean ~/Library/Caches/com.apple.Safari/* "Safari cache" safe_clean ~/Library/Caches/com.apple.Safari/* "Safari cache"
@@ -683,6 +697,7 @@ clean_browsers() {
clean_edge_old_versions clean_edge_old_versions
clean_edge_updater_old_versions clean_edge_updater_old_versions
} }
# Cloud storage caches. # Cloud storage caches.
clean_cloud_storage() { clean_cloud_storage() {
safe_clean ~/Library/Caches/com.dropbox.* "Dropbox cache" safe_clean ~/Library/Caches/com.dropbox.* "Dropbox cache"
@@ -693,6 +708,7 @@ clean_cloud_storage() {
safe_clean ~/Library/Caches/com.box.desktop "Box cache" safe_clean ~/Library/Caches/com.box.desktop "Box cache"
safe_clean ~/Library/Caches/com.microsoft.OneDrive "OneDrive cache" safe_clean ~/Library/Caches/com.microsoft.OneDrive "OneDrive cache"
} }
# Office app caches. # Office app caches.
clean_office_applications() { clean_office_applications() {
safe_clean ~/Library/Caches/com.microsoft.Word "Microsoft Word cache" safe_clean ~/Library/Caches/com.microsoft.Word "Microsoft Word cache"
@@ -704,6 +720,7 @@ clean_office_applications() {
safe_clean ~/Library/Caches/org.mozilla.thunderbird/* "Thunderbird cache" safe_clean ~/Library/Caches/org.mozilla.thunderbird/* "Thunderbird cache"
safe_clean ~/Library/Caches/com.apple.mail/* "Apple Mail cache" safe_clean ~/Library/Caches/com.apple.mail/* "Apple Mail cache"
} }
# Virtualization caches. # Virtualization caches.
clean_virtualization_tools() { clean_virtualization_tools() {
stop_section_spinner stop_section_spinner
@@ -712,6 +729,47 @@ clean_virtualization_tools() {
safe_clean ~/VirtualBox\ VMs/.cache "VirtualBox cache" safe_clean ~/VirtualBox\ VMs/.cache "VirtualBox cache"
safe_clean ~/.vagrant.d/tmp/* "Vagrant temporary files" 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. # Application Support logs/caches.
clean_application_support_logs() { clean_application_support_logs() {
if [[ ! -d "$HOME/Library/Application Support" ]] || ! ls "$HOME/Library/Application Support" > /dev/null 2>&1; then if [[ ! -d "$HOME/Library/Application Support" ]] || ! ls "$HOME/Library/Application Support" > /dev/null 2>&1; then
@@ -721,14 +779,27 @@ clean_application_support_logs() {
fi fi
start_section_spinner "Scanning Application Support..." start_section_spinner "Scanning Application Support..."
local total_size_bytes=0 local total_size_bytes=0
local total_size_partial=false
local cleaned_count=0 local cleaned_count=0
local found_any=false 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. # Enable nullglob for safe globbing.
local _ng_state local _ng_state
_ng_state=$(shopt -p nullglob || true) _ng_state=$(shopt -p nullglob || true)
shopt -s nullglob shopt -s nullglob
local app_count=0 local app_count=0
local total_apps local total_apps
# Temporarily disable pipefail here so that a partial find failure (e.g. TCC
# restrictions on macOS 26+) does not propagate through the pipeline and abort
# the whole scan via set -e.
local pipefail_was_set=false
if [[ -o pipefail ]]; then
pipefail_was_set=true
set +o pipefail
fi
total_apps=$(command find "$HOME/Library/Application Support" -mindepth 1 -maxdepth 1 -type d 2> /dev/null | wc -l | tr -d ' ') total_apps=$(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 [[ "$total_apps" =~ ^[0-9]+$ ]] || total_apps=0
local last_progress_update local last_progress_update
@@ -737,7 +808,7 @@ clean_application_support_logs() {
[[ -d "$app_dir" ]] || continue [[ -d "$app_dir" ]] || continue
local app_name local app_name
app_name=$(basename "$app_dir") app_name=$(basename "$app_dir")
((app_count++)) app_count=$((app_count + 1))
update_progress_if_needed "$app_count" "$total_apps" last_progress_update 1 || true update_progress_if_needed "$app_count" "$total_apps" last_progress_update 1 || true
local app_name_lower local app_name_lower
app_name_lower=$(echo "$app_name" | LC_ALL=C tr '[:upper:]' '[:lower:]') app_name_lower=$(echo "$app_name" | LC_ALL=C tr '[:upper:]' '[:lower:]')
@@ -758,22 +829,34 @@ clean_application_support_logs() {
if [[ -d "$candidate" ]]; then if [[ -d "$candidate" ]]; then
local item_found=false local item_found=false
local candidate_size_bytes=0 local candidate_size_bytes=0
local candidate_size_partial=false
local candidate_item_count=0 local candidate_item_count=0
while IFS= read -r -d '' item; do while IFS= read -r -d '' item; do
[[ -e "$item" ]] || continue [[ -e "$item" ]] || continue
item_found=true item_found=true
((candidate_item_count++)) candidate_item_count=$((candidate_item_count + 1))
if [[ -f "$item" && ! -L "$item" ]]; then if [[ ! -L "$item" && (-f "$item" || -d "$item") ]]; then
local bytes local item_size_bytes=""
bytes=$(stat -f%z "$item" 2> /dev/null || echo "0") if item_size_bytes=$(app_support_item_size_bytes "$item" "$size_timeout_seconds"); then
[[ "$bytes" =~ ^[0-9]+$ ]] && ((candidate_size_bytes += bytes)) || true if [[ "$item_size_bytes" =~ ^[0-9]+$ ]]; then
candidate_size_bytes=$((candidate_size_bytes + item_size_bytes))
else
candidate_size_partial=true
fi
else
candidate_size_partial=true
fi
fi fi
if ((candidate_item_count % 250 == 0)); then if ((candidate_item_count % 250 == 0)); then
local current_time local current_time
current_time=$(get_epoch_seconds) current_time=$(get_epoch_seconds)
if [[ "$current_time" =~ ^[0-9]+$ ]] && ((current_time - last_progress_update >= 1)); then 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 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 last_progress_update=$current_time
fi fi
fi fi
@@ -782,8 +865,9 @@ clean_application_support_logs() {
fi fi
done < <(command find "$candidate" -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true) done < <(command find "$candidate" -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true)
if [[ "$item_found" == "true" ]]; then if [[ "$item_found" == "true" ]]; then
((total_size_bytes += candidate_size_bytes)) total_size_bytes=$((total_size_bytes + candidate_size_bytes))
((cleaned_count++)) [[ "$candidate_size_partial" == "true" ]] && total_size_partial=true
cleaned_count=$((cleaned_count + 1))
found_any=true found_any=true
fi fi
fi fi
@@ -800,22 +884,34 @@ clean_application_support_logs() {
if [[ -d "$candidate" ]]; then if [[ -d "$candidate" ]]; then
local item_found=false local item_found=false
local candidate_size_bytes=0 local candidate_size_bytes=0
local candidate_size_partial=false
local candidate_item_count=0 local candidate_item_count=0
while IFS= read -r -d '' item; do while IFS= read -r -d '' item; do
[[ -e "$item" ]] || continue [[ -e "$item" ]] || continue
item_found=true item_found=true
((candidate_item_count++)) candidate_item_count=$((candidate_item_count + 1))
if [[ -f "$item" && ! -L "$item" ]]; then if [[ ! -L "$item" && (-f "$item" || -d "$item") ]]; then
local bytes local item_size_bytes=""
bytes=$(stat -f%z "$item" 2> /dev/null || echo "0") if item_size_bytes=$(app_support_item_size_bytes "$item" "$size_timeout_seconds"); then
[[ "$bytes" =~ ^[0-9]+$ ]] && ((candidate_size_bytes += bytes)) || true if [[ "$item_size_bytes" =~ ^[0-9]+$ ]]; then
candidate_size_bytes=$((candidate_size_bytes + item_size_bytes))
else
candidate_size_partial=true
fi
else
candidate_size_partial=true
fi
fi fi
if ((candidate_item_count % 250 == 0)); then if ((candidate_item_count % 250 == 0)); then
local current_time local current_time
current_time=$(get_epoch_seconds) current_time=$(get_epoch_seconds)
if [[ "$current_time" =~ ^[0-9]+$ ]] && ((current_time - last_progress_update >= 1)); then 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 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 last_progress_update=$current_time
fi fi
fi fi
@@ -824,13 +920,18 @@ clean_application_support_logs() {
fi fi
done < <(command find "$candidate" -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true) done < <(command find "$candidate" -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true)
if [[ "$item_found" == "true" ]]; then if [[ "$item_found" == "true" ]]; then
((total_size_bytes += candidate_size_bytes)) total_size_bytes=$((total_size_bytes + candidate_size_bytes))
((cleaned_count++)) [[ "$candidate_size_partial" == "true" ]] && total_size_partial=true
cleaned_count=$((cleaned_count + 1))
found_any=true found_any=true
fi fi
fi fi
done done
done done
# Restore pipefail if it was previously set
if [[ "$pipefail_was_set" == "true" ]]; then
set -o pipefail
fi
eval "$_ng_state" eval "$_ng_state"
stop_section_spinner stop_section_spinner
if [[ "$found_any" == "true" ]]; then if [[ "$found_any" == "true" ]]; then
@@ -838,13 +939,21 @@ clean_application_support_logs() {
size_human=$(bytes_to_human "$total_size_bytes") size_human=$(bytes_to_human "$total_size_bytes")
local total_size_kb=$(((total_size_bytes + 1023) / 1024)) local total_size_kb=$(((total_size_bytes + 1023) / 1024))
if [[ "$DRY_RUN" == "true" ]]; then 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 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 fi
((files_cleaned += cleaned_count)) files_cleaned=$((files_cleaned + cleaned_count))
((total_size_cleaned += total_size_kb)) total_size_cleaned=$((total_size_cleaned + total_size_kb))
((total_items++)) total_items=$((total_items + 1))
note_activity note_activity
fi fi
} }
@@ -922,7 +1031,8 @@ check_large_file_candidates() {
fi fi
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 local snapshot_list snapshot_count
snapshot_list=$(run_with_timeout 3 tmutil listlocalsnapshots / 2> /dev/null || true) snapshot_list=$(run_with_timeout 3 tmutil listlocalsnapshots / 2> /dev/null || true)
if [[ -n "$snapshot_list" ]]; then if [[ -n "$snapshot_list" ]]; then

View File

@@ -334,8 +334,8 @@ readonly DATA_PROTECTED_BUNDLES=(
"*privateinternetaccess*" "*privateinternetaccess*"
# Screensaver & Wallpaper # Screensaver & Wallpaper
"*Aerial*" "*Aerial.saver*"
"*aerial*" "com.JohnCoates.Aerial*"
"*Fliqlo*" "*Fliqlo*"
"*fliqlo*" "*fliqlo*"
@@ -1419,6 +1419,11 @@ force_kill_app() {
local app_name="$1" local app_name="$1"
local app_path="${2:-""}" local app_path="${2:-""}"
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
debug_log "[DRY RUN] Would terminate running app: $app_name"
return 0
fi
# Get the executable name from bundle if app_path is provided # Get the executable name from bundle if app_path is provided
local exec_name="" local exec_name=""
if [[ -n "$app_path" && -e "$app_path/Contents/Info.plist" ]]; then if [[ -n "$app_path" && -e "$app_path/Contents/Info.plist" ]]; then

View File

@@ -41,6 +41,28 @@ readonly ICON_DRY_RUN="→"
readonly ICON_REVIEW="☞" readonly ICON_REVIEW="☞"
readonly ICON_NAV_UP="↑" readonly ICON_NAV_UP="↑"
readonly ICON_NAV_DOWN="↓" 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 # Global Configuration Constants
@@ -166,11 +188,6 @@ is_sip_enabled() {
fi fi
} }
# Check if running in an interactive terminal
is_interactive() {
[[ -t 1 ]]
}
# Detect CPU architecture # Detect CPU architecture
# Returns: "Apple Silicon" or "Intel" # Returns: "Apple Silicon" or "Intel"
detect_architecture() { detect_architecture() {
@@ -239,30 +256,6 @@ is_root_user() {
[[ "$(id -u)" == "0" ]] [[ "$(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() { get_invoking_user() {
if [[ -n "${_MOLE_INVOKING_USER_CACHE:-}" ]]; then if [[ -n "${_MOLE_INVOKING_USER_CACHE:-}" ]]; then
echo "$_MOLE_INVOKING_USER_CACHE" echo "$_MOLE_INVOKING_USER_CACHE"
@@ -311,6 +304,30 @@ get_invoking_home() {
echo "${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() { ensure_user_dir() {
local raw_path="$1" local raw_path="$1"
if [[ -z "$raw_path" ]]; then if [[ -z "$raw_path" ]]; then
@@ -428,35 +445,6 @@ ensure_user_file() {
# Formatting Utilities # Formatting Utilities
# ============================================================================ # ============================================================================
# Convert bytes to human-readable format (e.g., 1.5GB)
bytes_to_human() {
local bytes="$1"
[[ "$bytes" =~ ^[0-9]+$ ]] || {
echo "0B"
return 1
}
# GB: >= 1073741824 bytes
if ((bytes >= 1073741824)); then
printf "%d.%02dGB\n" $((bytes / 1073741824)) $(((bytes % 1073741824) * 100 / 1073741824))
# MB: >= 1048576 bytes
elif ((bytes >= 1048576)); then
printf "%d.%01dMB\n" $((bytes / 1048576)) $(((bytes % 1048576) * 10 / 1048576))
# KB: >= 1024 bytes (round up)
elif ((bytes >= 1024)); then
printf "%dKB\n" $(((bytes + 512) / 1024))
else
printf "%dB\n" "$bytes"
fi
}
# Convert kilobytes to human-readable format
# Args: $1 - size in KB
# Returns: formatted string
bytes_to_human_kb() {
bytes_to_human "$((${1:-0} * 1024))"
}
# Get brand-friendly localized name for an application # Get brand-friendly localized name for an application
get_brand_name() { get_brand_name() {
local name="$1" local name="$1"
@@ -513,6 +501,38 @@ get_brand_name() {
fi 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 # Temporary File Management
# ============================================================================ # ============================================================================
@@ -704,91 +724,6 @@ update_progress_if_needed() {
return 1 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 <message>
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 # Terminal Compatibility Checks
# ============================================================================ # ============================================================================
@@ -822,67 +757,3 @@ is_ansi_supported() {
;; ;;
esac esac
} }
# Get terminal capability info
# Usage: get_terminal_info
get_terminal_info() {
local info="Terminal: ${TERM:-unknown}"
if is_ansi_supported; then
info+=", ANSI supported"
if command -v tput > /dev/null 2>&1; then
local cols=$(tput cols 2> /dev/null || echo "?")
local lines=$(tput lines 2> /dev/null || echo "?")
local colors=$(tput colors 2> /dev/null || echo "?")
info+=" ${cols}x${lines}, ${colors} colors"
fi
else
info+=", ANSI not supported"
fi
echo "$info"
}
# Validate terminal environment before running
# Usage: validate_terminal_environment
# Returns: 0 if OK, 1 with warning if issues detected
validate_terminal_environment() {
local warnings=0
# Check if TERM is set
if [[ -z "${TERM:-}" ]]; then
log_warning "TERM environment variable not set"
((warnings++))
fi
# Check if running in a known problematic terminal
case "${TERM:-}" in
dumb)
log_warning "Running in 'dumb' terminal, limited functionality"
((warnings++))
;;
unknown)
log_warning "Terminal type unknown, may have display issues"
((warnings++))
;;
esac
# Check terminal size if available
if command -v tput > /dev/null 2>&1; then
local cols=$(tput cols 2> /dev/null || echo "80")
if [[ "$cols" -lt 60 ]]; then
log_warning "Terminal width, $cols cols, is narrow, output may wrap"
((warnings++))
fi
fi
# Report compatibility
if [[ $warnings -eq 0 ]]; then
debug_log "Terminal environment validated: $(get_terminal_info)"
return 0
else
debug_log "Terminal compatibility warnings: $warnings"
return 1
fi
}

View File

@@ -163,7 +163,7 @@ remove_apps_from_dock() {
local url local url
url=$(/usr/libexec/PlistBuddy -c "Print :persistent-apps:$i:tile-data:file-data:_CFURLString" "$plist" 2> /dev/null || echo "") url=$(/usr/libexec/PlistBuddy -c "Print :persistent-apps:$i:tile-data:file-data:_CFURLString" "$plist" 2> /dev/null || echo "")
[[ -z "$url" ]] && { [[ -z "$url" ]] && {
((i++)) i=$((i + 1))
continue continue
} }
@@ -175,7 +175,7 @@ remove_apps_from_dock() {
continue continue
fi fi
fi fi
((i++)) i=$((i + 1))
done done
done done

View File

@@ -249,6 +249,11 @@ safe_remove() {
local rm_exit=0 local rm_exit=0
error_msg=$(rm -rf "$path" 2>&1) || rm_exit=$? # safe_remove 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 if [[ $rm_exit -eq 0 ]]; then
# Log successful removal # Log successful removal
log_operation "${MOLE_CURRENT_COMMAND:-clean}" "REMOVED" "$path" "$size_human" log_operation "${MOLE_CURRENT_COMMAND:-clean}" "REMOVED" "$path" "$size_human"
@@ -498,6 +503,19 @@ get_path_size_kb() {
echo "0" echo "0"
return 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 local size
size=$(command du -skP "$path" 2> /dev/null | awk 'NR==1 {print $1; exit}' || true) size=$(command du -skP "$path" 2> /dev/null | awk 'NR==1 {print $1; exit}' || true)
@@ -518,7 +536,7 @@ calculate_total_size() {
if [[ -n "$file" && -e "$file" ]]; then if [[ -n "$file" && -e "$file" ]]; then
local size_kb local size_kb
size_kb=$(get_path_size_kb "$file") size_kb=$(get_path_size_kb "$file")
((total_kb += size_kb)) total_kb=$((total_kb + size_kb))
fi fi
done <<< "$files" done <<< "$files"

View File

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

View File

@@ -363,7 +363,12 @@ print_summary_block() {
fi fi
done 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 # Print with dividers
echo "" echo ""

View File

@@ -76,7 +76,7 @@ _request_password() {
if [[ -z "$password" ]]; then if [[ -z "$password" ]]; then
unset password unset password
((attempts++)) attempts=$((attempts + 1))
if [[ $attempts -lt 3 ]]; then if [[ $attempts -lt 3 ]]; then
echo -e "${GRAY}${ICON_WARNING}${NC} Password cannot be empty" > "$tty_path" echo -e "${GRAY}${ICON_WARNING}${NC} Password cannot be empty" > "$tty_path"
fi fi
@@ -91,7 +91,7 @@ _request_password() {
fi fi
unset password unset password
((attempts++)) attempts=$((attempts + 1))
if [[ $attempts -lt 3 ]]; then if [[ $attempts -lt 3 ]]; then
echo -e "${GRAY}${ICON_WARNING}${NC} Incorrect password, try again" > "$tty_path" echo -e "${GRAY}${ICON_WARNING}${NC} Incorrect password, try again" > "$tty_path"
fi fi
@@ -166,7 +166,7 @@ request_sudo_access() {
break break
fi fi
sleep 0.1 sleep 0.1
((elapsed++)) elapsed=$((elapsed + 1))
done done
# Touch ID failed/cancelled - clean up thoroughly before password input # Touch ID failed/cancelled - clean up thoroughly before password input
@@ -216,7 +216,7 @@ _start_sudo_keepalive() {
local retry_count=0 local retry_count=0
while true; do while true; do
if ! sudo -n -v 2> /dev/null; then if ! sudo -n -v 2> /dev/null; then
((retry_count++)) retry_count=$((retry_count + 1))
if [[ $retry_count -ge 3 ]]; then if [[ $retry_count -ge 3 ]]; then
exit 1 exit 1
fi fi

View File

@@ -138,8 +138,8 @@ truncate_by_display_width() {
fi fi
truncated+="$char" truncated+="$char"
((width += char_width)) width=$((width + char_width))
((i++)) i=$((i + 1))
done done
# Restore locale # Restore locale
@@ -265,7 +265,7 @@ read_key() {
drain_pending_input() { drain_pending_input() {
local drained=0 local drained=0
while IFS= read -r -s -n 1 -t 0.01 _ 2> /dev/null; do while IFS= read -r -s -n 1 -t 0.01 _ 2> /dev/null; do
((drained++)) drained=$((drained + 1))
[[ $drained -gt 100 ]] && break [[ $drained -gt 100 ]] && break
done done
} }
@@ -287,9 +287,40 @@ show_menu_option() {
INLINE_SPINNER_PID="" INLINE_SPINNER_PID=""
INLINE_SPINNER_STOP_FILE="" 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() { start_inline_spinner() {
stop_inline_spinner 2> /dev/null || true stop_inline_spinner 2> /dev/null || true
local message="$1" local message="$1"
local display_message
display_message=$(format_spinner_message "$message")
if [[ -t 1 ]]; then if [[ -t 1 ]]; then
# Create unique stop flag file for this spinner instance # Create unique stop flag file for this spinner instance
@@ -309,8 +340,8 @@ start_inline_spinner() {
while [[ ! -f "$stop_file" ]]; do while [[ ! -f "$stop_file" ]]; do
local c="${chars:$((i % ${#chars})):1}" local c="${chars:$((i % ${#chars})):1}"
# Output to stderr to avoid interfering with stdout # 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++)) i=$((i + 1))
sleep 0.05 sleep 0.05
done done
@@ -321,7 +352,7 @@ start_inline_spinner() {
INLINE_SPINNER_PID=$! INLINE_SPINNER_PID=$!
disown "$INLINE_SPINNER_PID" 2> /dev/null || true disown "$INLINE_SPINNER_PID" 2> /dev/null || true
else else
echo -n " ${BLUE}|${NC} $message" >&2 || true echo -n " ${BLUE}|${NC} $display_message" >&2 || true
fi fi
} }
@@ -336,7 +367,7 @@ stop_inline_spinner() {
local wait_count=0 local wait_count=0
while kill -0 "$INLINE_SPINNER_PID" 2> /dev/null && [[ $wait_count -lt 5 ]]; do while kill -0 "$INLINE_SPINNER_PID" 2> /dev/null && [[ $wait_count -lt 5 ]]; do
sleep 0.05 2> /dev/null || true sleep 0.05 2> /dev/null || true
((wait_count++)) wait_count=$((wait_count + 1))
done done
# Only use SIGKILL as last resort if process is stuck # Only use SIGKILL as last resort if process is stuck
@@ -356,20 +387,6 @@ stop_inline_spinner() {
fi 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 # Get spinner characters
mo_spinner_chars() { mo_spinner_chars() {
local chars="|/-\\" local chars="|/-\\"

View File

@@ -138,7 +138,7 @@ perform_auto_fix() {
echo -e "${BLUE}Enabling Firewall...${NC}" echo -e "${BLUE}Enabling Firewall...${NC}"
if sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setglobalstate on > /dev/null 2>&1; then if sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setglobalstate on > /dev/null 2>&1; then
echo -e "${GREEN}${NC} Firewall enabled" echo -e "${GREEN}${NC} Firewall enabled"
((fixed_count++)) fixed_count=$((fixed_count + 1))
fixed_items+=("Firewall enabled") fixed_items+=("Firewall enabled")
else else
echo -e "${RED}${NC} Failed to enable Firewall" echo -e "${RED}${NC} Failed to enable Firewall"
@@ -154,7 +154,7 @@ perform_auto_fix() {
auth sufficient pam_tid.so auth sufficient pam_tid.so
' '$pam_file'" 2> /dev/null; then ' '$pam_file'" 2> /dev/null; then
echo -e "${GREEN}${NC} Touch ID configured" echo -e "${GREEN}${NC} Touch ID configured"
((fixed_count++)) fixed_count=$((fixed_count + 1))
fixed_items+=("Touch ID configured for sudo") fixed_items+=("Touch ID configured for sudo")
else else
echo -e "${RED}${NC} Failed to configure Touch ID" 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}" echo -e "${BLUE}Installing Rosetta 2...${NC}"
if sudo softwareupdate --install-rosetta --agree-to-license 2>&1 | grep -qE "(Installing|Installed|already installed)"; then if sudo softwareupdate --install-rosetta --agree-to-license 2>&1 | grep -qE "(Installing|Installed|already installed)"; then
echo -e "${GREEN}${NC} Rosetta 2 installed" echo -e "${GREEN}${NC} Rosetta 2 installed"
((fixed_count++)) fixed_count=$((fixed_count + 1))
fixed_items+=("Rosetta 2 installed") fixed_items+=("Rosetta 2 installed")
else else
echo -e "${RED}${NC} Failed to install Rosetta 2" echo -e "${RED}${NC} Failed to install Rosetta 2"

View File

@@ -70,7 +70,7 @@ manage_purge_paths() {
line="${line#"${line%%[![:space:]]*}"}" line="${line#"${line%%[![:space:]]*}"}"
line="${line%"${line##*[![:space:]]}"}" line="${line%"${line##*[![:space:]]}"}"
[[ -z "$line" || "$line" =~ ^# ]] && continue [[ -z "$line" || "$line" =~ ^# ]] && continue
((custom_count++)) custom_count=$((custom_count + 1))
done < "$PURGE_PATHS_CONFIG" done < "$PURGE_PATHS_CONFIG"
fi fi

View File

@@ -117,7 +117,7 @@ perform_updates() {
if "$mole_bin" update 2>&1 | grep -qE "(Updated|latest version)"; then if "$mole_bin" update 2>&1 | grep -qE "(Updated|latest version)"; then
echo -e "${GREEN}${ICON_SUCCESS}${NC} Mole updated" echo -e "${GREEN}${ICON_SUCCESS}${NC} Mole updated"
reset_mole_cache reset_mole_cache
((updated_count++)) updated_count=$((updated_count + 1))
else else
echo -e "${RED}${NC} Mole update failed" echo -e "${RED}${NC} Mole update failed"
fi fi

View File

@@ -302,7 +302,7 @@ ${GRAY}Edit: ${display_config}${NC}"
cache_patterns+=("$pattern") cache_patterns+=("$pattern")
menu_options+=("$display_name") menu_options+=("$display_name")
((index++)) || true index=$((index + 1))
done <<< "$items_source" done <<< "$items_source"
# Identify custom patterns (not in predefined list) # Identify custom patterns (not in predefined list)

View File

@@ -25,7 +25,7 @@ fix_broken_preferences() {
plutil -lint "$plist_file" > /dev/null 2>&1 && continue plutil -lint "$plist_file" > /dev/null 2>&1 && continue
safe_remove "$plist_file" true > /dev/null 2>&1 || true safe_remove "$plist_file" true > /dev/null 2>&1 || true
((broken_count++)) broken_count=$((broken_count + 1))
done < <(command find "$prefs_dir" -maxdepth 1 -name "*.plist" -type f 2> /dev/null || true) done < <(command find "$prefs_dir" -maxdepth 1 -name "*.plist" -type f 2> /dev/null || true)
# Check ByHost preferences. # Check ByHost preferences.
@@ -45,7 +45,7 @@ fix_broken_preferences() {
plutil -lint "$plist_file" > /dev/null 2>&1 && continue plutil -lint "$plist_file" > /dev/null 2>&1 && continue
safe_remove "$plist_file" true > /dev/null 2>&1 || true safe_remove "$plist_file" true > /dev/null 2>&1 || true
((broken_count++)) broken_count=$((broken_count + 1))
done < <(command find "$byhost_dir" -name "*.plist" -type f 2> /dev/null || true) done < <(command find "$byhost_dir" -name "*.plist" -type f 2> /dev/null || true)
fi fi

View File

@@ -14,7 +14,7 @@ opt_msg() {
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} $message" echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} $message"
else else
echo -e " ${GREEN}${NC} $message" echo -e " ${GREEN}${ICON_SUCCESS}${NC} $message"
fi fi
} }
@@ -314,7 +314,7 @@ opt_sqlite_vacuum() {
local file_size local file_size
file_size=$(get_file_size "$db_file") file_size=$(get_file_size "$db_file")
if [[ "$file_size" -gt "$MOLE_SQLITE_MAX_SIZE" ]]; then if [[ "$file_size" -gt "$MOLE_SQLITE_MAX_SIZE" ]]; then
((skipped++)) skipped=$((skipped + 1))
continue continue
fi fi
@@ -327,7 +327,7 @@ opt_sqlite_vacuum() {
freelist_count=$(echo "$page_info" | awk 'NR==2 {print $1}' 2> /dev/null || echo "") 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 [[ "$page_count" =~ ^[0-9]+$ && "$freelist_count" =~ ^[0-9]+$ && "$page_count" -gt 0 ]]; then
if ((freelist_count * 100 < page_count * 5)); then if ((freelist_count * 100 < page_count * 5)); then
((skipped++)) skipped=$((skipped + 1))
continue continue
fi fi
fi fi
@@ -341,7 +341,7 @@ opt_sqlite_vacuum() {
set -e set -e
if [[ $integrity_status -ne 0 ]] || ! echo "$integrity_check" | grep -q "ok"; then if [[ $integrity_status -ne 0 ]] || ! echo "$integrity_check" | grep -q "ok"; then
((skipped++)) skipped=$((skipped + 1))
continue continue
fi fi
fi fi
@@ -354,14 +354,14 @@ opt_sqlite_vacuum() {
set -e set -e
if [[ $exit_code -eq 0 ]]; then if [[ $exit_code -eq 0 ]]; then
((vacuumed++)) vacuumed=$((vacuumed + 1))
elif [[ $exit_code -eq 124 ]]; then elif [[ $exit_code -eq 124 ]]; then
((timed_out++)) timed_out=$((timed_out + 1))
else else
((failed++)) failed=$((failed + 1))
fi fi
else else
((vacuumed++)) vacuumed=$((vacuumed + 1))
fi fi
done < <(compgen -G "$pattern" || true) done < <(compgen -G "$pattern" || true)
done done
@@ -406,9 +406,10 @@ opt_launch_services_rebuild() {
start_inline_spinner "" start_inline_spinner ""
fi fi
local lsregister="/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister" local lsregister
lsregister=$(get_lsregister_path)
if [[ -f "$lsregister" ]]; then if [[ -n "$lsregister" ]]; then
local success=0 local success=0
if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then
@@ -729,7 +730,7 @@ opt_spotlight_index_optimize() {
test_end=$(get_epoch_seconds) test_end=$(get_epoch_seconds)
test_duration=$((test_end - test_start)) test_duration=$((test_end - test_start))
if [[ $test_duration -gt 3 ]]; then if [[ $test_duration -gt 3 ]]; then
((slow_count++)) slow_count=$((slow_count + 1))
fi fi
sleep 1 sleep 1
done done
@@ -741,7 +742,7 @@ opt_spotlight_index_optimize() {
fi fi
if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then 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 if sudo mdutil -E / > /dev/null 2>&1; then
opt_msg "Spotlight index rebuild started" opt_msg "Spotlight index rebuild started"
echo -e " ${GRAY}Indexing will continue in background${NC}" echo -e " ${GRAY}Indexing will continue in background${NC}"

View File

@@ -133,7 +133,7 @@ select_apps_for_uninstall() {
sizekb_csv+=",${size_kb:-0}" sizekb_csv+=",${size_kb:-0}"
fi fi
names_arr+=("$display_name") names_arr+=("$display_name")
((idx++)) idx=$((idx + 1))
done done
# Use newline separator for names (safe for names with commas) # Use newline separator for names (safe for names with commas)
local names_newline local names_newline

View File

@@ -155,7 +155,7 @@ paginated_multi_select() {
# Only count if not already selected (handles duplicates) # Only count if not already selected (handles duplicates)
if [[ ${selected[idx]} != true ]]; then if [[ ${selected[idx]} != true ]]; then
selected[idx]=true selected[idx]=true
((selected_count++)) selected_count=$((selected_count + 1))
fi fi
fi fi
done done
@@ -654,7 +654,7 @@ paginated_multi_select() {
if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then
local old_cursor=$cursor_pos local old_cursor=$cursor_pos
((cursor_pos++)) cursor_pos=$((cursor_pos + 1))
local new_cursor=$cursor_pos local new_cursor=$cursor_pos
if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then
@@ -674,7 +674,7 @@ paginated_multi_select() {
prev_cursor_pos=$cursor_pos prev_cursor_pos=$cursor_pos
continue continue
elif [[ $((top_index + visible_count)) -lt ${#view_indices[@]} ]]; then elif [[ $((top_index + visible_count)) -lt ${#view_indices[@]} ]]; then
((top_index++)) top_index=$((top_index + 1))
visible_count=$((${#view_indices[@]} - top_index)) visible_count=$((${#view_indices[@]} - top_index))
[[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page
if [[ $cursor_pos -ge $visible_count ]]; then if [[ $cursor_pos -ge $visible_count ]]; then
@@ -716,7 +716,7 @@ paginated_multi_select() {
((selected_count--)) ((selected_count--))
else else
selected[real]=true selected[real]=true
((selected_count++)) selected_count=$((selected_count + 1))
fi fi
# Incremental update: only redraw header (for count) and current row # Incremental update: only redraw header (for count) and current row
@@ -757,9 +757,9 @@ paginated_multi_select() {
local visible_count=$((${#view_indices[@]} - top_index)) local visible_count=$((${#view_indices[@]} - top_index))
[[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page
if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then
((cursor_pos++)) cursor_pos=$((cursor_pos + 1))
elif [[ $((top_index + visible_count)) -lt ${#view_indices[@]} ]]; then elif [[ $((top_index + visible_count)) -lt ${#view_indices[@]} ]]; then
((top_index++)) top_index=$((top_index + 1))
fi fi
need_full_redraw=true need_full_redraw=true
fi fi
@@ -843,7 +843,7 @@ paginated_multi_select() {
if [[ $idx -lt ${#view_indices[@]} ]]; then if [[ $idx -lt ${#view_indices[@]} ]]; then
local real="${view_indices[idx]}" local real="${view_indices[idx]}"
selected[real]=true selected[real]=true
((selected_count++)) selected_count=$((selected_count + 1))
fi fi
fi fi

View File

@@ -159,7 +159,7 @@ paginated_multi_select() {
# Count selections for header display # Count selections for header display
local selected_count=0 local selected_count=0
for ((i = 0; i < total_items; i++)); do for ((i = 0; i < total_items; i++)); do
[[ ${selected[i]} == true ]] && ((selected_count++)) [[ ${selected[i]} == true ]] && selected_count=$((selected_count + 1))
done done
# Header # Header
@@ -247,9 +247,9 @@ paginated_multi_select() {
[[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page
if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then
((cursor_pos++)) cursor_pos=$((cursor_pos + 1))
elif [[ $((top_index + visible_count)) -lt $total_items ]]; then elif [[ $((top_index + visible_count)) -lt $total_items ]]; then
((top_index++)) top_index=$((top_index + 1))
visible_count=$((total_items - top_index)) visible_count=$((total_items - top_index))
[[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page
if [[ $cursor_pos -ge $visible_count ]]; then if [[ $cursor_pos -ge $visible_count ]]; then

View File

@@ -15,6 +15,10 @@ get_lsregister_path() {
echo "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister" echo "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister"
} }
is_uninstall_dry_run() {
[[ "${MOLE_DRY_RUN:-0}" == "1" ]]
}
# High-performance sensitive data detection (pure Bash, no subprocess) # High-performance sensitive data detection (pure Bash, no subprocess)
# Faster than grep for batch operations, especially when processing many apps # Faster than grep for batch operations, especially when processing many apps
has_sensitive_data() { has_sensitive_data() {
@@ -81,6 +85,11 @@ stop_launch_services() {
local bundle_id="$1" local bundle_id="$1"
local has_system_files="${2:-false}" local has_system_files="${2:-false}"
if is_uninstall_dry_run; then
debug_log "[DRY RUN] Would unload launch services for bundle: $bundle_id"
return 0
fi
[[ -z "$bundle_id" || "$bundle_id" == "unknown" ]] && return 0 [[ -z "$bundle_id" || "$bundle_id" == "unknown" ]] && return 0
# Validate bundle_id format: must be reverse-DNS style (e.g., com.example.app) # Validate bundle_id format: must be reverse-DNS style (e.g., com.example.app)
@@ -156,6 +165,11 @@ remove_login_item() {
local app_name="$1" local app_name="$1"
local bundle_id="$2" local bundle_id="$2"
if is_uninstall_dry_run; then
debug_log "[DRY RUN] Would remove login item: ${app_name:-$bundle_id}"
return 0
fi
# Skip if no identifiers provided # Skip if no identifiers provided
[[ -z "$app_name" && -z "$bundle_id" ]] && return 0 [[ -z "$app_name" && -z "$bundle_id" ]] && return 0
@@ -205,7 +219,12 @@ remove_file_list() {
safe_remove_symlink "$file" "$use_sudo" && ((++count)) || true safe_remove_symlink "$file" "$use_sudo" && ((++count)) || true
else else
if [[ "$use_sudo" == "true" ]]; then if [[ "$use_sudo" == "true" ]]; then
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 else
safe_remove "$file" true && ((++count)) || true safe_remove "$file" true && ((++count)) || true
fi fi
@@ -321,7 +340,7 @@ batch_uninstall_applications() {
local system_size_kb=$(calculate_total_size "$system_files" || echo "0") local system_size_kb=$(calculate_total_size "$system_files" || echo "0")
local diag_system_size_kb=$(calculate_total_size "$diag_system" || 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)) 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 # shellcheck disable=SC2128
if [[ -n "$system_files" || -n "$diag_system" ]]; then if [[ -n "$system_files" || -n "$diag_system" ]]; then
@@ -441,7 +460,7 @@ batch_uninstall_applications() {
export MOLE_UNINSTALL_MODE=1 export MOLE_UNINSTALL_MODE=1
# Request sudo if needed. # Request sudo if needed.
if [[ ${#sudo_apps[@]} -gt 0 ]]; then if [[ ${#sudo_apps[@]} -gt 0 && "${MOLE_DRY_RUN:-0}" != "1" ]]; then
if ! sudo -n true 2> /dev/null; then if ! sudo -n true 2> /dev/null; then
if ! request_sudo_access "Admin required for system apps: ${sudo_apps[*]}"; then if ! request_sudo_access "Admin required for system apps: ${sudo_apps[*]}"; then
echo "" echo ""
@@ -469,7 +488,7 @@ batch_uninstall_applications() {
local -a success_items=() local -a success_items=()
local current_index=0 local current_index=0
for detail in "${app_details[@]}"; do for detail in "${app_details[@]}"; do
((current_index++)) current_index=$((current_index + 1))
IFS='|' read -r app_name app_path bundle_id total_kb encoded_files encoded_system_files has_sensitive_data needs_sudo is_brew_cask cask_name encoded_diag_system <<< "$detail" 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 related_files=$(decode_file_list "$encoded_files" "$app_name")
local system_files=$(decode_file_list "$encoded_system_files" "$app_name") local system_files=$(decode_file_list "$encoded_system_files" "$app_name")
@@ -551,12 +570,18 @@ batch_uninstall_applications() {
fi fi
fi fi
else else
local ret=0 if is_uninstall_dry_run; then
safe_sudo_remove "$app_path" || ret=$? if ! safe_remove "$app_path" true; then
if [[ $ret -ne 0 ]]; then reason="dry-run path validation failed"
local diagnosis fi
diagnosis=$(diagnose_removal_failure "$ret" "$app_name") else
IFS='|' read -r reason suggestion <<< "$diagnosis" 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
fi fi
else else
@@ -587,10 +612,14 @@ batch_uninstall_applications() {
remove_file_list "$system_all" "true" > /dev/null remove_file_list "$system_all" "true" > /dev/null
fi fi
# Clean up macOS defaults (preference domains). # Defaults writes are side effects that should never run in dry-run mode.
if [[ -n "$bundle_id" && "$bundle_id" != "unknown" ]]; then if [[ -n "$bundle_id" && "$bundle_id" != "unknown" ]]; then
if defaults read "$bundle_id" &> /dev/null; then if is_uninstall_dry_run; then
defaults delete "$bundle_id" 2> /dev/null || true 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 fi
# ByHost preferences (machine-specific). # ByHost preferences (machine-specific).
@@ -614,11 +643,11 @@ batch_uninstall_applications() {
fi fi
fi fi
((total_size_freed += total_kb)) total_size_freed=$((total_size_freed + total_kb))
((success_count++)) success_count=$((success_count + 1))
[[ "$used_brew_successfully" == "true" ]] && ((brew_apps_removed++)) [[ "$used_brew_successfully" == "true" ]] && brew_apps_removed=$((brew_apps_removed + 1))
((files_cleaned++)) files_cleaned=$((files_cleaned + 1))
((total_items++)) total_items=$((total_items + 1))
success_items+=("$app_path") success_items+=("$app_path")
else else
if [[ -t 1 ]]; then if [[ -t 1 ]]; then
@@ -632,7 +661,7 @@ batch_uninstall_applications() {
fi fi
fi fi
((failed_count++)) failed_count=$((failed_count + 1))
failed_items+=("$app_name:$reason:${suggestion:-}") failed_items+=("$app_name:$reason:${suggestion:-}")
fi fi
done done
@@ -648,8 +677,15 @@ batch_uninstall_applications() {
local success_text="app" local success_text="app"
[[ $success_count -gt 1 ]] && success_text="apps" [[ $success_count -gt 1 ]] && success_text="apps"
local success_line="Removed ${success_count} ${success_text}" local success_line="Removed ${success_count} ${success_text}"
if is_uninstall_dry_run; then
success_line="Would remove ${success_count} ${success_text}"
fi
if [[ -n "$freed_display" ]]; then if [[ -n "$freed_display" ]]; then
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 fi
# Format app list with max 3 per line. # Format app list with max 3 per line.
@@ -676,7 +712,7 @@ batch_uninstall_applications() {
else else
current_line="$current_line, $display_item" current_line="$current_line, $display_item"
fi fi
((idx++)) idx=$((idx + 1))
done done
if [[ -n "$current_line" ]]; then if [[ -n "$current_line" ]]; then
summary_details+=("$current_line") summary_details+=("$current_line")
@@ -734,6 +770,9 @@ batch_uninstall_applications() {
if [[ "$summary_status" == "warn" ]]; then if [[ "$summary_status" == "warn" ]]; then
title="Uninstall incomplete" title="Uninstall incomplete"
fi fi
if is_uninstall_dry_run; then
title="Uninstall dry run complete"
fi
echo "" echo ""
print_summary_block "$title" "${summary_details[@]}" print_summary_block "$title" "${summary_details[@]}"
@@ -741,30 +780,38 @@ batch_uninstall_applications() {
# Auto-run brew autoremove if Homebrew casks were uninstalled # Auto-run brew autoremove if Homebrew casks were uninstalled
if [[ $brew_apps_removed -gt 0 ]]; then if [[ $brew_apps_removed -gt 0 ]]; then
# Show spinner while checking for orphaned dependencies if is_uninstall_dry_run; then
if [[ -t 1 ]]; then log_info "[DRY RUN] Would run brew autoremove"
start_inline_spinner "Checking brew dependencies..." else
fi # Show spinner while checking for orphaned dependencies
if [[ -t 1 ]]; then
start_inline_spinner "Checking brew dependencies..."
fi
local autoremove_output removed_count local autoremove_output removed_count
autoremove_output=$(HOMEBREW_NO_ENV_HINTS=1 brew autoremove 2> /dev/null) || true 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=$(printf '%s\n' "$autoremove_output" | grep -c "^Uninstalling" || true)
removed_count=${removed_count:-0} removed_count=${removed_count:-0}
if [[ -t 1 ]]; then if [[ -t 1 ]]; then
stop_inline_spinner stop_inline_spinner
fi fi
if [[ $removed_count -gt 0 ]]; then if [[ $removed_count -gt 0 ]]; then
echo -e "${GREEN}${ICON_SUCCESS}${NC} Cleaned $removed_count orphaned brew dependencies" echo -e "${GREEN}${ICON_SUCCESS}${NC} Cleaned $removed_count orphaned brew dependencies"
echo "" echo ""
fi
fi fi
fi fi
# Clean up Dock entries for uninstalled apps. # Clean up Dock entries for uninstalled apps.
if [[ $success_count -gt 0 && ${#success_items[@]} -gt 0 ]]; then if [[ $success_count -gt 0 && ${#success_items[@]} -gt 0 ]]; then
remove_apps_from_dock "${success_items[@]}" 2> /dev/null || true if is_uninstall_dry_run; then
refresh_launch_services_after_uninstall 2> /dev/null || true log_info "[DRY RUN] Would refresh LaunchServices and update Dock entries"
else
remove_apps_from_dock "${success_items[@]}" 2> /dev/null || true
refresh_launch_services_after_uninstall 2> /dev/null || true
fi
fi fi
_cleanup_sudo_keepalive _cleanup_sudo_keepalive
@@ -775,6 +822,6 @@ batch_uninstall_applications() {
_restore_uninstall_traps _restore_uninstall_traps
unset -f _restore_uninstall_traps unset -f _restore_uninstall_traps
((total_size_cleaned += total_size_freed)) total_size_cleaned=$((total_size_cleaned + total_size_freed))
unset failed_items unset failed_items
} }

View File

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

113
mole
View File

@@ -13,7 +13,7 @@ source "$SCRIPT_DIR/lib/core/commands.sh"
trap cleanup_temp_files EXIT INT TERM trap cleanup_temp_files EXIT INT TERM
# Version and update helpers # Version and update helpers
VERSION="1.27.0" VERSION="1.28.1"
MOLE_TAGLINE="Deep clean and optimize your Mac." MOLE_TAGLINE="Deep clean and optimize your Mac."
is_touchid_configured() { is_touchid_configured() {
@@ -38,14 +38,26 @@ get_latest_version_from_github() {
} }
# Install detection (Homebrew vs manual). # Install detection (Homebrew vs manual).
# Uses variable capture + string matching to avoid SIGPIPE under pipefail.
is_homebrew_install() { 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 mole_path=$(command -v mole 2> /dev/null) || return 1
if [[ -L "$mole_path" ]] && readlink "$mole_path" | grep -q "Cellar/mole"; then # Cache brew list once if brew is available
if command -v brew > /dev/null 2>&1; then if command -v brew > /dev/null 2>&1; then
brew list --formula 2> /dev/null | grep -q "^mole$" && return 0 has_brew=true
else 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 return 1
fi fi
fi fi
@@ -54,8 +66,8 @@ is_homebrew_install() {
case "$mole_path" in case "$mole_path" in
/opt/homebrew/bin/mole | /usr/local/bin/mole) /opt/homebrew/bin/mole | /usr/local/bin/mole)
if [[ -d /opt/homebrew/Cellar/mole ]] || [[ -d /usr/local/Cellar/mole ]]; then if [[ -d /opt/homebrew/Cellar/mole ]] || [[ -d /usr/local/Cellar/mole ]]; then
if command -v brew > /dev/null 2>&1; then if $has_brew; then
brew list --formula 2> /dev/null | grep -q "^mole$" && return 0 _mole_in_brew_list && return 0
else else
return 0 # Cellar exists, probably Homebrew install return 0 # Cellar exists, probably Homebrew install
fi fi
@@ -64,17 +76,29 @@ is_homebrew_install() {
esac esac
fi fi
if command -v brew > /dev/null 2>&1; then if $has_brew; then
local brew_prefix local brew_prefix
brew_prefix=$(brew --prefix 2> /dev/null) brew_prefix=$(brew --prefix 2> /dev/null)
if [[ -n "$brew_prefix" && "$mole_path" == "$brew_prefix/bin/mole" && -d "$brew_prefix/Cellar/mole" ]]; then 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
fi fi
return 1 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 # Background update notice
check_for_updates() { check_for_updates() {
local msg_cache="$HOME/.cache/mole/update_message" local msg_cache="$HOME/.cache/mole/update_message"
@@ -193,7 +217,13 @@ show_version() {
install_method="Homebrew" install_method="Homebrew"
fi fi
local channel
channel=$(get_install_channel)
printf '\nMole version %s\n' "$VERSION" printf '\nMole version %s\n' "$VERSION"
if [[ "$channel" == "nightly" ]]; then
printf 'Channel: Nightly\n'
fi
printf 'macOS: %s\n' "$os_ver" printf 'macOS: %s\n' "$os_ver"
printf 'Architecture: %s\n' "$arch" printf 'Architecture: %s\n' "$arch"
printf 'Kernel: %s\n' "$kernel" printf 'Kernel: %s\n' "$kernel"
@@ -222,10 +252,16 @@ show_help() {
printf " %s%-28s%s %s\n" "$GREEN" "mo optimize --dry-run" "$NC" "Preview optimization" printf " %s%-28s%s %s\n" "$GREEN" "mo optimize --dry-run" "$NC" "Preview optimization"
printf " %s%-28s%s %s\n" "$GREEN" "mo optimize --whitelist" "$NC" "Manage protected items" printf " %s%-28s%s %s\n" "$GREEN" "mo optimize --whitelist" "$NC" "Manage protected items"
printf " %s%-28s%s %s\n" "$GREEN" "mo uninstall --dry-run" "$NC" "Preview app uninstall"
printf " %s%-28s%s %s\n" "$GREEN" "mo purge --dry-run" "$NC" "Preview project purge"
printf " %s%-28s%s %s\n" "$GREEN" "mo installer --dry-run" "$NC" "Preview installer cleanup"
printf " %s%-28s%s %s\n" "$GREEN" "mo touchid enable --dry-run" "$NC" "Preview Touch ID setup"
printf " %s%-28s%s %s\n" "$GREEN" "mo completion --dry-run" "$NC" "Preview shell completion edits"
printf " %s%-28s%s %s\n" "$GREEN" "mo purge --paths" "$NC" "Configure scan directories" printf " %s%-28s%s %s\n" "$GREEN" "mo purge --paths" "$NC" "Configure scan directories"
printf " %s%-28s%s %s\n" "$GREEN" "mo analyze /Volumes" "$NC" "Analyze external drives only" printf " %s%-28s%s %s\n" "$GREEN" "mo analyze /Volumes" "$NC" "Analyze external drives only"
printf " %s%-28s%s %s\n" "$GREEN" "mo update --force" "$NC" "Force reinstall latest stable version" printf " %s%-28s%s %s\n" "$GREEN" "mo update --force" "$NC" "Force reinstall latest stable version"
printf " %s%-28s%s %s\n" "$GREEN" "mo update --nightly" "$NC" "Install latest unreleased main branch build" printf " %s%-28s%s %s\n" "$GREEN" "mo update --nightly" "$NC" "Install latest unreleased main branch build"
printf " %s%-28s%s %s\n" "$GREEN" "mo remove --dry-run" "$NC" "Preview Mole removal"
echo echo
printf "%s%s%s\n" "$BLUE" "OPTIONS" "$NC" printf "%s%s%s\n" "$BLUE" "OPTIONS" "$NC"
printf " %s%-28s%s %s\n" "$GREEN" "--debug" "$NC" "Show detailed operation logs" printf " %s%-28s%s %s\n" "$GREEN" "--debug" "$NC" "Show detailed operation logs"
@@ -237,7 +273,13 @@ update_mole() {
local force_update="${1:-false}" local force_update="${1:-false}"
local nightly_update="${2:-false}" local nightly_update="${2:-false}"
local update_interrupted=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 is_homebrew_install; then
if [[ "$nightly_update" == "true" ]]; then if [[ "$nightly_update" == "true" ]]; then
@@ -348,6 +390,8 @@ update_mole() {
rm -f "$tmp_installer" rm -f "$tmp_installer"
exit 1 exit 1
fi fi
# Start sudo keepalive to prevent cache expiration during install
sudo_keepalive_pid=$(_start_sudo_keepalive)
fi fi
if [[ -t 1 ]]; then if [[ -t 1 ]]; then
@@ -399,6 +443,7 @@ update_mole() {
else else
if [[ -t 1 ]]; then stop_inline_spinner; fi if [[ -t 1 ]]; then stop_inline_spinner; fi
rm -f "$tmp_installer" rm -f "$tmp_installer"
_update_cleanup
log_error "Nightly update failed" log_error "Nightly update failed"
echo "$install_output" | tail -10 >&2 # Show last 10 lines of error echo "$install_output" | tail -10 >&2 # Show last 10 lines of error
exit 1 exit 1
@@ -409,6 +454,7 @@ update_mole() {
else else
if [[ -t 1 ]]; then stop_inline_spinner; fi if [[ -t 1 ]]; then stop_inline_spinner; fi
rm -f "$tmp_installer" rm -f "$tmp_installer"
_update_cleanup
log_error "Update failed" log_error "Update failed"
echo "$install_output" | tail -10 >&2 # Show last 10 lines of error echo "$install_output" | tail -10 >&2 # Show last 10 lines of error
exit 1 exit 1
@@ -422,6 +468,7 @@ update_mole() {
else else
if [[ -t 1 ]]; then stop_inline_spinner; fi if [[ -t 1 ]]; then stop_inline_spinner; fi
rm -f "$tmp_installer" rm -f "$tmp_installer"
_update_cleanup
log_error "Update failed" log_error "Update failed"
echo "$install_output" | tail -10 >&2 # Show last 10 lines of error echo "$install_output" | tail -10 >&2 # Show last 10 lines of error
exit 1 exit 1
@@ -431,10 +478,16 @@ update_mole() {
rm -f "$tmp_installer" rm -f "$tmp_installer"
rm -f "$HOME/.cache/mole/update_message" rm -f "$HOME/.cache/mole/update_message"
# Cleanup and reset trap
_update_cleanup
trap - INT TERM
} }
# Remove flow (Homebrew + manual + config/cache). # Remove flow (Homebrew + manual + config/cache).
remove_mole() { remove_mole() {
local dry_run_mode="${1:-false}"
if [[ -t 1 ]]; then if [[ -t 1 ]]; then
start_inline_spinner "Detecting Mole installations..." start_inline_spinner "Detecting Mole installations..."
else else
@@ -518,6 +571,31 @@ remove_mole() {
exit 0 exit 0
fi 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:" echo -e "${YELLOW}Remove Mole${NC}, will delete the following:"
if [[ "$is_homebrew" == "true" ]]; then if [[ "$is_homebrew" == "true" ]]; then
echo " ${ICON_LIST} Mole via Homebrew" echo " ${ICON_LIST} Mole via Homebrew"
@@ -832,7 +910,18 @@ main() {
exit 0 exit 0
;; ;;
"remove") "remove")
remove_mole local dry_run_remove=false
for arg in "${args[@]:1}"; do
case "$arg" in
"--dry-run" | "-n") dry_run_remove=true ;;
*)
echo "Unknown remove option: $arg"
echo "Use 'mole remove [--dry-run]' for supported options."
exit 1
;;
esac
done
remove_mole "$dry_run_remove"
;; ;;
"help" | "--help" | "-h") "help" | "--help" | "-h")
show_help show_help

View File

@@ -267,6 +267,9 @@ set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/core/common.sh"
source "$PROJECT_ROOT/lib/clean/system.sh" source "$PROJECT_ROOT/lib/clean/system.sh"
defaults() { echo "1"; }
clean_time_machine_failed_backups clean_time_machine_failed_backups
EOF EOF
@@ -310,6 +313,9 @@ set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/core/common.sh"
source "$PROJECT_ROOT/lib/clean/system.sh" source "$PROJECT_ROOT/lib/clean/system.sh"
defaults() { echo "1"; }
clean_time_machine_failed_backups clean_time_machine_failed_backups
EOF EOF

View File

@@ -274,6 +274,9 @@ set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/core/common.sh"
source "$PROJECT_ROOT/lib/clean/system.sh" source "$PROJECT_ROOT/lib/clean/system.sh"
defaults() { echo "1"; }
tmutil() { tmutil() {
if [[ "$1" == "destinationinfo" ]]; then if [[ "$1" == "destinationinfo" ]]; then
echo "No destinations configured" echo "No destinations configured"
@@ -297,6 +300,9 @@ set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/core/common.sh"
source "$PROJECT_ROOT/lib/clean/system.sh" source "$PROJECT_ROOT/lib/clean/system.sh"
defaults() { echo "1"; }
run_with_timeout() { run_with_timeout() {
printf '%s\n' \ printf '%s\n' \
"com.apple.TimeMachine.2023-10-25-120000" \ "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/core/common.sh"
source "$PROJECT_ROOT/lib/clean/system.sh" source "$PROJECT_ROOT/lib/clean/system.sh"
defaults() { echo "1"; }
run_with_timeout() { echo "Snapshots for disk /:"; } run_with_timeout() { echo "Snapshots for disk /:"; }
start_section_spinner(){ :; } start_section_spinner(){ :; }
stop_section_spinner(){ :; } stop_section_spinner(){ :; }

View File

@@ -132,6 +132,38 @@ EOF
[[ "$output" != *"App caches"* ]] || [[ "$output" == *"already clean"* ]] [[ "$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" { @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' run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc <<'EOF'
set -euo pipefail set -euo pipefail

View File

@@ -1,39 +1,40 @@
#!/usr/bin/env bats #!/usr/bin/env bats
setup_file() { setup_file() {
PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)"
export PROJECT_ROOT export PROJECT_ROOT
ORIGINAL_HOME="${HOME:-}" ORIGINAL_HOME="${HOME:-}"
export ORIGINAL_HOME export ORIGINAL_HOME
HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-cli-home.XXXXXX")" HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-cli-home.XXXXXX")"
export HOME export HOME
mkdir -p "$HOME" mkdir -p "$HOME"
} }
teardown_file() { teardown_file() {
rm -rf "$HOME" rm -f "$PROJECT_ROOT/install_channel"
if [[ -n "${ORIGINAL_HOME:-}" ]]; then rm -rf "$HOME"
export HOME="$ORIGINAL_HOME" if [[ -n "${ORIGINAL_HOME:-}" ]]; then
fi export HOME="$ORIGINAL_HOME"
fi
} }
create_fake_utils() { create_fake_utils() {
local dir="$1" local dir="$1"
mkdir -p "$dir" mkdir -p "$dir"
cat > "$dir/sudo" <<'SCRIPT' cat >"$dir/sudo" <<'SCRIPT'
#!/usr/bin/env bash #!/usr/bin/env bash
if [[ "$1" == "-n" || "$1" == "-v" ]]; then if [[ "$1" == "-n" || "$1" == "-v" ]]; then
exit 0 exit 0
fi fi
exec "$@" exec "$@"
SCRIPT SCRIPT
chmod +x "$dir/sudo" chmod +x "$dir/sudo"
cat > "$dir/bioutil" <<'SCRIPT' cat >"$dir/bioutil" <<'SCRIPT'
#!/usr/bin/env bash #!/usr/bin/env bash
if [[ "$1" == "-r" ]]; then if [[ "$1" == "-r" ]]; then
echo "Touch ID: 1" echo "Touch ID: 1"
@@ -41,138 +42,165 @@ if [[ "$1" == "-r" ]]; then
fi fi
exit 0 exit 0
SCRIPT SCRIPT
chmod +x "$dir/bioutil" chmod +x "$dir/bioutil"
} }
setup() { setup() {
rm -rf "$HOME/.config" rm -rf "$HOME/.config"
mkdir -p "$HOME" mkdir -p "$HOME"
rm -f "$PROJECT_ROOT/install_channel"
} }
@test "mole --help prints command overview" { @test "mole --help prints command overview" {
run env HOME="$HOME" "$PROJECT_ROOT/mole" --help run env HOME="$HOME" "$PROJECT_ROOT/mole" --help
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
[[ "$output" == *"mo clean"* ]] [[ "$output" == *"mo clean"* ]]
[[ "$output" == *"mo analyze"* ]] [[ "$output" == *"mo analyze"* ]]
} }
@test "mole --version reports script version" { @test "mole --version reports script version" {
expected_version="$(grep '^VERSION=' "$PROJECT_ROOT/mole" | head -1 | sed 's/VERSION=\"\(.*\)\"/\1/')" expected_version="$(grep '^VERSION=' "$PROJECT_ROOT/mole" | head -1 | sed 's/VERSION=\"\(.*\)\"/\1/')"
run env HOME="$HOME" "$PROJECT_ROOT/mole" --version run env HOME="$HOME" "$PROJECT_ROOT/mole" --version
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
[[ "$output" == *"$expected_version"* ]] [[ "$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" { @test "mole unknown command returns error" {
run env HOME="$HOME" "$PROJECT_ROOT/mole" unknown-command run env HOME="$HOME" "$PROJECT_ROOT/mole" unknown-command
[ "$status" -ne 0 ] [ "$status" -ne 0 ]
[[ "$output" == *"Unknown command: unknown-command"* ]] [[ "$output" == *"Unknown command: unknown-command"* ]]
} }
@test "touchid status reports current configuration" { @test "touchid status reports current configuration" {
run env HOME="$HOME" "$PROJECT_ROOT/mole" touchid status run env HOME="$HOME" "$PROJECT_ROOT/mole" touchid status
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
[[ "$output" == *"Touch ID"* ]] [[ "$output" == *"Touch ID"* ]]
} }
@test "mo optimize command is recognized" { @test "mo optimize command is recognized" {
run bash -c "grep -q '\"optimize\")' '$PROJECT_ROOT/mole'" run bash -c "grep -q '\"optimize\")' '$PROJECT_ROOT/mole'"
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
} }
@test "mo analyze binary is valid" { @test "mo analyze binary is valid" {
if [[ -f "$PROJECT_ROOT/bin/analyze-go" ]]; then if [[ -f "$PROJECT_ROOT/bin/analyze-go" ]]; then
[ -x "$PROJECT_ROOT/bin/analyze-go" ] [ -x "$PROJECT_ROOT/bin/analyze-go" ]
run file "$PROJECT_ROOT/bin/analyze-go" run file "$PROJECT_ROOT/bin/analyze-go"
[[ "$output" == *"Mach-O"* ]] || [[ "$output" == *"executable"* ]] [[ "$output" == *"Mach-O"* ]] || [[ "$output" == *"executable"* ]]
else else
skip "analyze-go binary not built" skip "analyze-go binary not built"
fi fi
} }
@test "mo clean --debug creates debug log file" { @test "mo clean --debug creates debug log file" {
mkdir -p "$HOME/.config/mole" 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 run env HOME="$HOME" TERM="xterm-256color" MOLE_TEST_MODE=1 MO_DEBUG=1 "$PROJECT_ROOT/mole" clean --dry-run
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
MOLE_OUTPUT="$output" MOLE_OUTPUT="$output"
DEBUG_LOG="$HOME/.config/mole/mole_debug_session.log" DEBUG_LOG="$HOME/.config/mole/mole_debug_session.log"
[ -f "$DEBUG_LOG" ] [ -f "$DEBUG_LOG" ]
run grep "Mole Debug Session" "$DEBUG_LOG" run grep "Mole Debug Session" "$DEBUG_LOG"
[ "$status" -eq 0 ] [ "$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" { @test "mo clean without debug does not show debug log path" {
mkdir -p "$HOME/.config/mole" 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 run env HOME="$HOME" TERM="xterm-256color" MOLE_TEST_MODE=1 MO_DEBUG=0 "$PROJECT_ROOT/mole" clean --dry-run
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
[[ "$output" != *"Debug session log saved to"* ]] [[ "$output" != *"Debug session log saved to"* ]]
} }
@test "mo clean --debug logs system info" { @test "mo clean --debug logs system info" {
mkdir -p "$HOME/.config/mole" 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 run env HOME="$HOME" TERM="xterm-256color" MOLE_TEST_MODE=1 MO_DEBUG=1 "$PROJECT_ROOT/mole" clean --dry-run
[ "$status" -eq 0 ] [ "$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" run grep "User:" "$DEBUG_LOG"
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
run grep "Architecture:" "$DEBUG_LOG" run grep "Architecture:" "$DEBUG_LOG"
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
} }
@test "touchid status reflects pam file contents" { @test "touchid status reflects pam file contents" {
pam_file="$HOME/pam_test" pam_file="$HOME/pam_test"
cat > "$pam_file" <<'EOF' cat >"$pam_file" <<'EOF'
auth sufficient pam_opendirectory.so auth sufficient pam_opendirectory.so
EOF EOF
run env MOLE_PAM_SUDO_FILE="$pam_file" "$PROJECT_ROOT/bin/touchid.sh" status run env MOLE_PAM_SUDO_FILE="$pam_file" "$PROJECT_ROOT/bin/touchid.sh" status
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
[[ "$output" == *"not configured"* ]] [[ "$output" == *"not configured"* ]]
cat > "$pam_file" <<'EOF' cat >"$pam_file" <<'EOF'
auth sufficient pam_tid.so auth sufficient pam_tid.so
EOF EOF
run env MOLE_PAM_SUDO_FILE="$pam_file" "$PROJECT_ROOT/bin/touchid.sh" status run env MOLE_PAM_SUDO_FILE="$pam_file" "$PROJECT_ROOT/bin/touchid.sh" status
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
[[ "$output" == *"enabled"* ]] [[ "$output" == *"enabled"* ]]
} }
@test "enable_touchid inserts pam_tid line in pam file" { @test "enable_touchid inserts pam_tid line in pam file" {
pam_file="$HOME/pam_enable" pam_file="$HOME/pam_enable"
cat > "$pam_file" <<'EOF' cat >"$pam_file" <<'EOF'
auth sufficient pam_opendirectory.so auth sufficient pam_opendirectory.so
EOF EOF
fake_bin="$HOME/fake-bin" fake_bin="$HOME/fake-bin"
create_fake_utils "$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 run env PATH="$fake_bin:$PATH" MOLE_PAM_SUDO_FILE="$pam_file" "$PROJECT_ROOT/bin/touchid.sh" enable
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
grep -q "pam_tid.so" "$pam_file" grep -q "pam_tid.so" "$pam_file"
[[ -f "${pam_file}.mole-backup" ]] [[ -f "${pam_file}.mole-backup" ]]
} }
@test "disable_touchid removes pam_tid line" { @test "disable_touchid removes pam_tid line" {
pam_file="$HOME/pam_disable" pam_file="$HOME/pam_disable"
cat > "$pam_file" <<'EOF' cat >"$pam_file" <<'EOF'
auth sufficient pam_tid.so auth sufficient pam_tid.so
auth sufficient pam_opendirectory.so auth sufficient pam_opendirectory.so
EOF EOF
fake_bin="$HOME/fake-bin-disable" fake_bin="$HOME/fake-bin-disable"
create_fake_utils "$fake_bin" create_fake_utils "$fake_bin"
run env PATH="$fake_bin:$PATH" MOLE_PAM_SUDO_FILE="$pam_file" "$PROJECT_ROOT/bin/touchid.sh" disable run env PATH="$fake_bin:$PATH" MOLE_PAM_SUDO_FILE="$pam_file" "$PROJECT_ROOT/bin/touchid.sh" disable
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
run grep "pam_tid.so" "$pam_file" run grep "pam_tid.so" "$pam_file"
[ "$status" -ne 0 ] [ "$status" -ne 0 ]
}
@test "touchid enable --dry-run does not modify pam file" {
pam_file="$HOME/pam_enable_dry_run"
cat >"$pam_file" <<'EOF'
auth sufficient pam_opendirectory.so
EOF
run env MOLE_PAM_SUDO_FILE="$pam_file" "$PROJECT_ROOT/bin/touchid.sh" enable --dry-run
[ "$status" -eq 0 ]
[[ "$output" == *"DRY RUN MODE"* ]]
run grep "pam_tid.so" "$pam_file"
[ "$status" -ne 0 ]
} }

View File

@@ -1,160 +1,165 @@
#!/usr/bin/env bats #!/usr/bin/env bats
setup_file() { setup_file() {
PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)"
export PROJECT_ROOT export PROJECT_ROOT
ORIGINAL_HOME="${HOME:-}" ORIGINAL_HOME="${HOME:-}"
export ORIGINAL_HOME export ORIGINAL_HOME
ORIGINAL_PATH="${PATH:-}" ORIGINAL_PATH="${PATH:-}"
export ORIGINAL_PATH export ORIGINAL_PATH
HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-completion-home.XXXXXX")" HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-completion-home.XXXXXX")"
export HOME export HOME
mkdir -p "$HOME" mkdir -p "$HOME"
PATH="$PROJECT_ROOT:$PATH" PATH="$PROJECT_ROOT:$PATH"
export PATH export PATH
} }
teardown_file() { teardown_file() {
rm -rf "$HOME" rm -rf "$HOME"
if [[ -n "${ORIGINAL_HOME:-}" ]]; then if [[ -n "${ORIGINAL_HOME:-}" ]]; then
export HOME="$ORIGINAL_HOME" export HOME="$ORIGINAL_HOME"
fi fi
if [[ -n "${ORIGINAL_PATH:-}" ]]; then if [[ -n "${ORIGINAL_PATH:-}" ]]; then
export PATH="$ORIGINAL_PATH" export PATH="$ORIGINAL_PATH"
fi fi
} }
setup() { setup() {
rm -rf "$HOME/.config" rm -rf "$HOME/.config"
rm -rf "$HOME/.zshrc" "$HOME/.bashrc" "$HOME/.bash_profile" rm -rf "$HOME/.zshrc" "$HOME/.bashrc" "$HOME/.bash_profile"
mkdir -p "$HOME" mkdir -p "$HOME"
} }
@test "completion script exists and is executable" { @test "completion script exists and is executable" {
[ -f "$PROJECT_ROOT/bin/completion.sh" ] [ -f "$PROJECT_ROOT/bin/completion.sh" ]
[ -x "$PROJECT_ROOT/bin/completion.sh" ] [ -x "$PROJECT_ROOT/bin/completion.sh" ]
} }
@test "completion script has valid bash syntax" { @test "completion script has valid bash syntax" {
run bash -n "$PROJECT_ROOT/bin/completion.sh" run bash -n "$PROJECT_ROOT/bin/completion.sh"
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
} }
@test "completion --help shows usage" { @test "completion --help shows usage" {
run "$PROJECT_ROOT/bin/completion.sh" --help run "$PROJECT_ROOT/bin/completion.sh" --help
[ "$status" -ne 0 ] [ "$status" -ne 0 ]
[[ "$output" == *"Usage: mole completion"* ]] [[ "$output" == *"Usage: mole completion"* ]]
[[ "$output" == *"Auto-install"* ]] [[ "$output" == *"Auto-install"* ]]
} }
@test "completion bash generates valid bash script" { @test "completion bash generates valid bash script" {
run "$PROJECT_ROOT/bin/completion.sh" bash run "$PROJECT_ROOT/bin/completion.sh" bash
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
[[ "$output" == *"_mole_completions"* ]] [[ "$output" == *"_mole_completions"* ]]
[[ "$output" == *"complete -F _mole_completions mole mo"* ]] [[ "$output" == *"complete -F _mole_completions mole mo"* ]]
} }
@test "completion bash script includes all commands" { @test "completion bash script includes all commands" {
run "$PROJECT_ROOT/bin/completion.sh" bash run "$PROJECT_ROOT/bin/completion.sh" bash
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
[[ "$output" == *"optimize"* ]] [[ "$output" == *"optimize"* ]]
[[ "$output" == *"clean"* ]] [[ "$output" == *"clean"* ]]
[[ "$output" == *"uninstall"* ]] [[ "$output" == *"uninstall"* ]]
[[ "$output" == *"analyze"* ]] [[ "$output" == *"analyze"* ]]
[[ "$output" == *"status"* ]] [[ "$output" == *"status"* ]]
[[ "$output" == *"purge"* ]] [[ "$output" == *"purge"* ]]
[[ "$output" == *"touchid"* ]] [[ "$output" == *"touchid"* ]]
[[ "$output" == *"completion"* ]] [[ "$output" == *"completion"* ]]
} }
@test "completion bash script supports mo command" { @test "completion bash script supports mo command" {
run "$PROJECT_ROOT/bin/completion.sh" bash run "$PROJECT_ROOT/bin/completion.sh" bash
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
[[ "$output" == *"complete -F _mole_completions mole mo"* ]] [[ "$output" == *"complete -F _mole_completions mole mo"* ]]
} }
@test "completion bash can be loaded in bash" { @test "completion bash can be loaded in bash" {
run bash -c "eval \"\$(\"$PROJECT_ROOT/bin/completion.sh\" bash)\" && complete -p mole" run bash -c "eval \"\$(\"$PROJECT_ROOT/bin/completion.sh\" bash)\" && complete -p mole"
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
[[ "$output" == *"_mole_completions"* ]] [[ "$output" == *"_mole_completions"* ]]
} }
@test "completion zsh generates valid zsh script" { @test "completion zsh generates valid zsh script" {
run "$PROJECT_ROOT/bin/completion.sh" zsh run "$PROJECT_ROOT/bin/completion.sh" zsh
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
[[ "$output" == *"#compdef mole mo"* ]] [[ "$output" == *"#compdef mole mo"* ]]
[[ "$output" == *"_mole()"* ]] [[ "$output" == *"_mole()"* ]]
} }
@test "completion zsh includes command descriptions" { @test "completion zsh includes command descriptions" {
run "$PROJECT_ROOT/bin/completion.sh" zsh run "$PROJECT_ROOT/bin/completion.sh" zsh
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
[[ "$output" == *"optimize:Check and maintain system"* ]] [[ "$output" == *"optimize:Check and maintain system"* ]]
[[ "$output" == *"clean:Free up disk space"* ]] [[ "$output" == *"clean:Free up disk space"* ]]
} }
@test "completion fish generates valid fish script" { @test "completion fish generates valid fish script" {
run "$PROJECT_ROOT/bin/completion.sh" fish run "$PROJECT_ROOT/bin/completion.sh" fish
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
[[ "$output" == *"complete -c mole"* ]] [[ "$output" == *"complete -c mole"* ]]
[[ "$output" == *"complete -c mo"* ]] [[ "$output" == *"complete -c mo"* ]]
} }
@test "completion fish includes both mole and mo commands" { @test "completion fish includes both mole and mo commands" {
output="$("$PROJECT_ROOT/bin/completion.sh" fish)" output="$("$PROJECT_ROOT/bin/completion.sh" fish)"
mole_count=$(echo "$output" | grep -c "complete -c mole") mole_count=$(echo "$output" | grep -c "complete -c mole")
mo_count=$(echo "$output" | grep -c "complete -c mo") mo_count=$(echo "$output" | grep -c "complete -c mo")
[ "$mole_count" -gt 0 ] [ "$mole_count" -gt 0 ]
[ "$mo_count" -gt 0 ] [ "$mo_count" -gt 0 ]
} }
@test "completion auto-install detects zsh" { @test "completion auto-install detects zsh" {
# shellcheck disable=SC2030,SC2031 # shellcheck disable=SC2030,SC2031
export SHELL=/bin/zsh export SHELL=/bin/zsh
# Simulate auto-install (no interaction) # Simulate auto-install (no interaction)
run bash -c "echo 'y' | \"$PROJECT_ROOT/bin/completion.sh\"" run bash -c "echo 'y' | \"$PROJECT_ROOT/bin/completion.sh\""
if [[ "$output" == *"Already configured"* ]]; then if [[ "$output" == *"Already configured"* ]]; then
skip "Already configured from previous test" skip "Already configured from previous test"
fi 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" run grep -E "mole[[:space:]]+completion" "$HOME/.zshrc"
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
} }
@test "completion auto-install detects already installed" { @test "completion auto-install detects already installed" {
# shellcheck disable=SC2031 mkdir -p "$HOME"
export SHELL=/bin/zsh # shellcheck disable=SC2016
mkdir -p "$HOME" echo 'eval "$(mole completion zsh)"' >"$HOME/.zshrc"
# shellcheck disable=SC2016
echo 'eval "$(mole completion zsh)"' > "$HOME/.zshrc"
run "$PROJECT_ROOT/bin/completion.sh" run env SHELL=/bin/zsh "$PROJECT_ROOT/bin/completion.sh"
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
[[ "$output" == *"updated"* ]] [[ "$output" == *"updated"* ]]
}
@test "completion --dry-run previews changes without writing config" {
run env SHELL=/bin/zsh "$PROJECT_ROOT/bin/completion.sh" --dry-run
[ "$status" -eq 0 ]
[[ "$output" == *"DRY RUN MODE"* ]]
[ ! -f "$HOME/.zshrc" ]
} }
@test "completion script handles invalid shell argument" { @test "completion script handles invalid shell argument" {
run "$PROJECT_ROOT/bin/completion.sh" invalid-shell run "$PROJECT_ROOT/bin/completion.sh" invalid-shell
[ "$status" -ne 0 ] [ "$status" -ne 0 ]
} }
@test "completion subcommand supports bash/zsh/fish" { @test "completion subcommand supports bash/zsh/fish" {
run "$PROJECT_ROOT/bin/completion.sh" bash run "$PROJECT_ROOT/bin/completion.sh" bash
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
run "$PROJECT_ROOT/bin/completion.sh" zsh run "$PROJECT_ROOT/bin/completion.sh" zsh
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
run "$PROJECT_ROOT/bin/completion.sh" fish run "$PROJECT_ROOT/bin/completion.sh" fish
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
} }

View File

@@ -102,9 +102,9 @@ setup() {
HOME="$HOME" bash --noprofile --norc << 'EOF' HOME="$HOME" bash --noprofile --norc << 'EOF'
source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/core/common.sh"
bytes_to_human 512 bytes_to_human 512
bytes_to_human 2048 bytes_to_human 2000
bytes_to_human $((5 * 1024 * 1024)) bytes_to_human 5000000
bytes_to_human $((3 * 1024 * 1024 * 1024)) bytes_to_human 3000000000
EOF EOF
)" )"

View File

@@ -34,26 +34,26 @@ setup() {
} }
@test "bytes_to_human produces correct output for GB range" { @test "bytes_to_human produces correct output for GB range" {
result=$(bytes_to_human 1073741824) result=$(bytes_to_human 1000000000)
[ "$result" = "1.00GB" ] [ "$result" = "1.00GB" ]
result=$(bytes_to_human 5368709120) result=$(bytes_to_human 5000000000)
[ "$result" = "5.00GB" ] [ "$result" = "5.00GB" ]
} }
@test "bytes_to_human produces correct output for MB range" { @test "bytes_to_human produces correct output for MB range" {
result=$(bytes_to_human 1048576) result=$(bytes_to_human 1000000)
[ "$result" = "1.0MB" ] [ "$result" = "1.0MB" ]
result=$(bytes_to_human 104857600) result=$(bytes_to_human 100000000)
[ "$result" = "100.0MB" ] [ "$result" = "100.0MB" ]
} }
@test "bytes_to_human produces correct output for KB range" { @test "bytes_to_human produces correct output for KB range" {
result=$(bytes_to_human 1024) result=$(bytes_to_human 1000)
[ "$result" = "1KB" ] [ "$result" = "1KB" ]
result=$(bytes_to_human 10240) result=$(bytes_to_human 10000)
[ "$result" = "10KB" ] [ "$result" = "10KB" ]
} }

View File

@@ -110,6 +110,19 @@ teardown() {
[ "$status" -eq 0 ] [ "$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" { @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" run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; safe_remove '/System/test' true 2>&1"
[ "$status" -eq 1 ] [ "$status" -eq 1 ]

View File

@@ -1,49 +1,56 @@
#!/usr/bin/env bats #!/usr/bin/env bats
setup_file() { setup_file() {
PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)"
export PROJECT_ROOT export PROJECT_ROOT
ORIGINAL_HOME="${HOME:-}" ORIGINAL_HOME="${HOME:-}"
export ORIGINAL_HOME export ORIGINAL_HOME
HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-installers-home.XXXXXX")" HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-installers-home.XXXXXX")"
export HOME export HOME
mkdir -p "$HOME" mkdir -p "$HOME"
} }
teardown_file() { teardown_file() {
rm -rf "$HOME" rm -rf "$HOME"
if [[ -n "${ORIGINAL_HOME:-}" ]]; then if [[ -n "${ORIGINAL_HOME:-}" ]]; then
export HOME="$ORIGINAL_HOME" export HOME="$ORIGINAL_HOME"
fi fi
} }
setup() { setup() {
export TERM="xterm-256color" export TERM="xterm-256color"
export MO_DEBUG=0 export MO_DEBUG=0
# Create standard scan directories # Create standard scan directories
mkdir -p "$HOME/Downloads" mkdir -p "$HOME/Downloads"
mkdir -p "$HOME/Desktop" mkdir -p "$HOME/Desktop"
mkdir -p "$HOME/Documents" mkdir -p "$HOME/Documents"
mkdir -p "$HOME/Public" mkdir -p "$HOME/Public"
mkdir -p "$HOME/Library/Downloads" mkdir -p "$HOME/Library/Downloads"
# Clear previous test files # Clear previous test files
rm -rf "${HOME:?}/Downloads"/* rm -rf "${HOME:?}/Downloads"/*
rm -rf "${HOME:?}/Desktop"/* rm -rf "${HOME:?}/Desktop"/*
rm -rf "${HOME:?}/Documents"/* rm -rf "${HOME:?}/Documents"/*
} }
# Test arguments # Test arguments
@test "installer.sh rejects unknown options" { @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 ] [ "$status" -eq 1 ]
[[ "$output" == *"Unknown option"* ]] [[ "$output" == *"Unknown option"* ]]
}
@test "installer.sh accepts --dry-run option" {
run env HOME="$HOME" TERM="xterm-256color" "$PROJECT_ROOT/bin/installer.sh" --dry-run
[[ "$status" -eq 0 || "$status" -eq 2 ]]
[[ "$output" == *"DRY RUN MODE"* ]]
} }
# Test scan_installers_in_path function directly # Test scan_installers_in_path function directly
@@ -53,187 +60,187 @@ setup() {
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@test "scan_installers_in_path (fallback find): finds .dmg files" { @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 export MOLE_TEST_MODE=1
source \"\$1\" source \"\$1\"
scan_installers_in_path \"\$2\" scan_installers_in_path \"\$2\"
" bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" " bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
[[ "$output" == *"Chrome.dmg"* ]] [[ "$output" == *"Chrome.dmg"* ]]
} }
@test "scan_installers_in_path (fallback find): finds multiple installer types" { @test "scan_installers_in_path (fallback find): finds multiple installer types" {
touch "$HOME/Downloads/App1.dmg" touch "$HOME/Downloads/App1.dmg"
touch "$HOME/Downloads/App2.pkg" touch "$HOME/Downloads/App2.pkg"
touch "$HOME/Downloads/App3.iso" touch "$HOME/Downloads/App3.iso"
touch "$HOME/Downloads/App.mpkg" 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 export MOLE_TEST_MODE=1
source \"\$1\" source \"\$1\"
scan_installers_in_path \"\$2\" scan_installers_in_path \"\$2\"
" bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" " bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
[[ "$output" == *"App1.dmg"* ]] [[ "$output" == *"App1.dmg"* ]]
[[ "$output" == *"App2.pkg"* ]] [[ "$output" == *"App2.pkg"* ]]
[[ "$output" == *"App3.iso"* ]] [[ "$output" == *"App3.iso"* ]]
[[ "$output" == *"App.mpkg"* ]] [[ "$output" == *"App.mpkg"* ]]
} }
@test "scan_installers_in_path (fallback find): respects max depth" { @test "scan_installers_in_path (fallback find): respects max depth" {
mkdir -p "$HOME/Downloads/level1/level2/level3" mkdir -p "$HOME/Downloads/level1/level2/level3"
touch "$HOME/Downloads/shallow.dmg" touch "$HOME/Downloads/shallow.dmg"
touch "$HOME/Downloads/level1/mid.dmg" touch "$HOME/Downloads/level1/mid.dmg"
touch "$HOME/Downloads/level1/level2/deep.dmg" touch "$HOME/Downloads/level1/level2/deep.dmg"
touch "$HOME/Downloads/level1/level2/level3/too-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 export MOLE_TEST_MODE=1
source \"\$1\" source \"\$1\"
scan_installers_in_path \"\$2\" scan_installers_in_path \"\$2\"
" bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" " bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
# Default max depth is 2 # Default max depth is 2
[[ "$output" == *"shallow.dmg"* ]] [[ "$output" == *"shallow.dmg"* ]]
[[ "$output" == *"mid.dmg"* ]] [[ "$output" == *"mid.dmg"* ]]
[[ "$output" == *"deep.dmg"* ]] [[ "$output" == *"deep.dmg"* ]]
[[ "$output" != *"too-deep.dmg"* ]] [[ "$output" != *"too-deep.dmg"* ]]
} }
@test "scan_installers_in_path (fallback find): honors MOLE_INSTALLER_SCAN_MAX_DEPTH" { @test "scan_installers_in_path (fallback find): honors MOLE_INSTALLER_SCAN_MAX_DEPTH" {
mkdir -p "$HOME/Downloads/level1" mkdir -p "$HOME/Downloads/level1"
touch "$HOME/Downloads/top.dmg" touch "$HOME/Downloads/top.dmg"
touch "$HOME/Downloads/level1/nested.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 export MOLE_TEST_MODE=1
source \"\$1\" source \"\$1\"
scan_installers_in_path \"\$2\" scan_installers_in_path \"\$2\"
" bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" " bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
[[ "$output" == *"top.dmg"* ]] [[ "$output" == *"top.dmg"* ]]
[[ "$output" != *"nested.dmg"* ]] [[ "$output" != *"nested.dmg"* ]]
} }
@test "scan_installers_in_path (fallback find): handles non-existent directory" { @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 export MOLE_TEST_MODE=1
source \"\$1\" source \"\$1\"
scan_installers_in_path \"\$2\" scan_installers_in_path \"\$2\"
" bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/NonExistent" " bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/NonExistent"
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
[[ -z "$output" ]] [[ -z "$output" ]]
} }
@test "scan_installers_in_path (fallback find): ignores non-installer files" { @test "scan_installers_in_path (fallback find): ignores non-installer files" {
touch "$HOME/Downloads/document.pdf" touch "$HOME/Downloads/document.pdf"
touch "$HOME/Downloads/image.jpg" touch "$HOME/Downloads/image.jpg"
touch "$HOME/Downloads/archive.tar.gz" touch "$HOME/Downloads/archive.tar.gz"
touch "$HOME/Downloads/Installer.dmg" 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 export MOLE_TEST_MODE=1
source \"\$1\" source \"\$1\"
scan_installers_in_path \"\$2\" scan_installers_in_path \"\$2\"
" bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" " bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
[[ "$output" != *"document.pdf"* ]] [[ "$output" != *"document.pdf"* ]]
[[ "$output" != *"image.jpg"* ]] [[ "$output" != *"image.jpg"* ]]
[[ "$output" != *"archive.tar.gz"* ]] [[ "$output" != *"archive.tar.gz"* ]]
[[ "$output" == *"Installer.dmg"* ]] [[ "$output" == *"Installer.dmg"* ]]
} }
@test "scan_all_installers: handles missing paths gracefully" { @test "scan_all_installers: handles missing paths gracefully" {
# Don't create all scan directories, some may not exist # Don't create all scan directories, some may not exist
# Only create Downloads, delete others if they exist # Only create Downloads, delete others if they exist
rm -rf "$HOME/Desktop" rm -rf "$HOME/Desktop"
rm -rf "$HOME/Documents" rm -rf "$HOME/Documents"
rm -rf "$HOME/Public" rm -rf "$HOME/Public"
rm -rf "$HOME/Public/Downloads" rm -rf "$HOME/Public/Downloads"
rm -rf "$HOME/Library/Downloads" rm -rf "$HOME/Library/Downloads"
mkdir -p "$HOME/Downloads" mkdir -p "$HOME/Downloads"
# Add an installer to the one directory that exists # Add an installer to the one directory that exists
touch "$HOME/Downloads/test.dmg" touch "$HOME/Downloads/test.dmg"
run bash -euo pipefail -c ' run bash -euo pipefail -c '
export MOLE_TEST_MODE=1 export MOLE_TEST_MODE=1
source "$1" source "$1"
scan_all_installers scan_all_installers
' bash "$PROJECT_ROOT/bin/installer.sh" ' bash "$PROJECT_ROOT/bin/installer.sh"
# Should succeed even with missing paths # Should succeed even with missing paths
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
# Should still find the installer in the existing directory # Should still find the installer in the existing directory
[[ "$output" == *"test.dmg"* ]] [[ "$output" == *"test.dmg"* ]]
} }
# Test edge cases # Test edge cases
@test "scan_installers_in_path (fallback find): handles filenames with spaces" { @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 export MOLE_TEST_MODE=1
source \"\$1\" source \"\$1\"
scan_installers_in_path \"\$2\" scan_installers_in_path \"\$2\"
" bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" " bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
[[ "$output" == *"My App Installer.dmg"* ]] [[ "$output" == *"My App Installer.dmg"* ]]
} }
@test "scan_installers_in_path (fallback find): handles filenames with special characters" { @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 export MOLE_TEST_MODE=1
source \"\$1\" source \"\$1\"
scan_installers_in_path \"\$2\" scan_installers_in_path \"\$2\"
" bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" " bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
[[ "$output" == *"App-v1.2.3_beta.pkg"* ]] [[ "$output" == *"App-v1.2.3_beta.pkg"* ]]
} }
@test "scan_installers_in_path (fallback find): returns empty for directory with no installers" { @test "scan_installers_in_path (fallback find): returns empty for directory with no installers" {
# Create some non-installer files # Create some non-installer files
touch "$HOME/Downloads/document.pdf" touch "$HOME/Downloads/document.pdf"
touch "$HOME/Downloads/image.png" 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 export MOLE_TEST_MODE=1
source \"\$1\" source \"\$1\"
scan_installers_in_path \"\$2\" scan_installers_in_path \"\$2\"
" bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" " bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
[[ -z "$output" ]] [[ -z "$output" ]]
} }
# Symlink handling tests # Symlink handling tests
@test "scan_installers_in_path (fallback find): skips symlinks to regular files" { @test "scan_installers_in_path (fallback find): skips symlinks to regular files" {
touch "$HOME/Downloads/real.dmg" touch "$HOME/Downloads/real.dmg"
ln -s "$HOME/Downloads/real.dmg" "$HOME/Downloads/symlink.dmg" ln -s "$HOME/Downloads/real.dmg" "$HOME/Downloads/symlink.dmg"
ln -s /nonexistent "$HOME/Downloads/dangling.lnk" 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 export MOLE_TEST_MODE=1
source \"\$1\" source \"\$1\"
scan_installers_in_path \"\$2\" scan_installers_in_path \"\$2\"
" bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" " bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
[[ "$output" == *"real.dmg"* ]] [[ "$output" == *"real.dmg"* ]]
[[ "$output" != *"symlink.dmg"* ]] [[ "$output" != *"symlink.dmg"* ]]
[[ "$output" != *"dangling.lnk"* ]] [[ "$output" != *"dangling.lnk"* ]]
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,67 +1,67 @@
#!/usr/bin/env bats #!/usr/bin/env bats
setup_file() { setup_file() {
PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)"
export PROJECT_ROOT export PROJECT_ROOT
ORIGINAL_HOME="${BATS_TMPDIR:-}" # Use BATS_TMPDIR as original HOME if set by bats ORIGINAL_HOME="${BATS_TMPDIR:-}" # Use BATS_TMPDIR as original HOME if set by bats
if [[ -z "$ORIGINAL_HOME" ]]; then if [[ -z "$ORIGINAL_HOME" ]]; then
ORIGINAL_HOME="${HOME:-}" ORIGINAL_HOME="${HOME:-}"
fi fi
export ORIGINAL_HOME export ORIGINAL_HOME
HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-uninstall-home.XXXXXX")" HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-uninstall-home.XXXXXX")"
export HOME export HOME
} }
teardown_file() { teardown_file() {
rm -rf "$HOME" rm -rf "$HOME"
if [[ -n "${ORIGINAL_HOME:-}" ]]; then if [[ -n "${ORIGINAL_HOME:-}" ]]; then
export HOME="$ORIGINAL_HOME" export HOME="$ORIGINAL_HOME"
fi fi
} }
setup() { setup() {
export TERM="dumb" export TERM="dumb"
rm -rf "${HOME:?}"/* rm -rf "${HOME:?}"/*
mkdir -p "$HOME" mkdir -p "$HOME"
} }
create_app_artifacts() { create_app_artifacts() {
mkdir -p "$HOME/Applications/TestApp.app" mkdir -p "$HOME/Applications/TestApp.app"
mkdir -p "$HOME/Library/Application Support/TestApp" mkdir -p "$HOME/Library/Application Support/TestApp"
mkdir -p "$HOME/Library/Caches/TestApp" mkdir -p "$HOME/Library/Caches/TestApp"
mkdir -p "$HOME/Library/Containers/com.example.TestApp" mkdir -p "$HOME/Library/Containers/com.example.TestApp"
mkdir -p "$HOME/Library/Preferences" mkdir -p "$HOME/Library/Preferences"
touch "$HOME/Library/Preferences/com.example.TestApp.plist" touch "$HOME/Library/Preferences/com.example.TestApp.plist"
mkdir -p "$HOME/Library/Preferences/ByHost" mkdir -p "$HOME/Library/Preferences/ByHost"
touch "$HOME/Library/Preferences/ByHost/com.example.TestApp.ABC123.plist" 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/Saved Application State/com.example.TestApp.savedState"
mkdir -p "$HOME/Library/LaunchAgents" mkdir -p "$HOME/Library/LaunchAgents"
touch "$HOME/Library/LaunchAgents/com.example.TestApp.plist" touch "$HOME/Library/LaunchAgents/com.example.TestApp.plist"
} }
@test "find_app_files discovers user-level leftovers" { @test "find_app_files discovers user-level leftovers" {
create_app_artifacts create_app_artifacts
result="$( result="$(
HOME="$HOME" bash --noprofile --norc << 'EOF' HOME="$HOME" bash --noprofile --norc <<'EOF'
set -euo pipefail set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/core/common.sh"
find_app_files "com.example.TestApp" "TestApp" find_app_files "com.example.TestApp" "TestApp"
EOF EOF
)" )"
[[ "$result" == *"Application Support/TestApp"* ]] [[ "$result" == *"Application Support/TestApp"* ]]
[[ "$result" == *"Caches/TestApp"* ]] [[ "$result" == *"Caches/TestApp"* ]]
[[ "$result" == *"Preferences/com.example.TestApp.plist"* ]] [[ "$result" == *"Preferences/com.example.TestApp.plist"* ]]
[[ "$result" == *"Saved Application State/com.example.TestApp.savedState"* ]] [[ "$result" == *"Saved Application State/com.example.TestApp.savedState"* ]]
[[ "$result" == *"Containers/com.example.TestApp"* ]] [[ "$result" == *"Containers/com.example.TestApp"* ]]
[[ "$result" == *"LaunchAgents/com.example.TestApp.plist"* ]] [[ "$result" == *"LaunchAgents/com.example.TestApp.plist"* ]]
} }
@test "get_diagnostic_report_paths_for_app avoids executable prefix collisions" { @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 set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh" 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 [[ "$result" != *"Foobar_2026-01-01-120001_host.ips"* ]] || exit 1
EOF EOF
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
} }
@test "calculate_total_size returns aggregate kilobytes" { @test "calculate_total_size returns aggregate kilobytes" {
mkdir -p "$HOME/sized" 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/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 dd if=/dev/zero of="$HOME/sized/file2" bs=1024 count=2 >/dev/null 2>&1
result="$( result="$(
HOME="$HOME" bash --noprofile --norc << 'EOF' HOME="$HOME" bash --noprofile --norc <<'EOF'
set -euo pipefail set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/core/common.sh"
files="$(printf '%s files="$(printf '%s
@@ -109,15 +109,15 @@ files="$(printf '%s
' "$HOME/sized/file1" "$HOME/sized/file2")" ' "$HOME/sized/file1" "$HOME/sized/file2")"
calculate_total_size "$files" calculate_total_size "$files"
EOF EOF
)" )"
[ "$result" -ge 3 ] [ "$result" -ge 3 ]
} }
@test "batch_uninstall_applications removes selected app data" { @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 set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/core/common.sh"
source "$PROJECT_ROOT/lib/uninstall/batch.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 [[ ! -f "$HOME/Library/LaunchAgents/com.example.TestApp.plist" ]] || exit 1
EOF EOF
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
} }
@test "batch_uninstall_applications preview shows full related file list" { @test "batch_uninstall_applications preview shows full related file list" {
mkdir -p "$HOME/Applications/TestApp.app" mkdir -p "$HOME/Applications/TestApp.app"
mkdir -p "$HOME/Library/Application Support/TestApp" mkdir -p "$HOME/Library/Application Support/TestApp"
mkdir -p "$HOME/Library/Caches/TestApp" mkdir -p "$HOME/Library/Caches/TestApp"
mkdir -p "$HOME/Library/Logs/TestApp" mkdir -p "$HOME/Library/Logs/TestApp"
touch "$HOME/Library/Logs/TestApp/log1.log" touch "$HOME/Library/Logs/TestApp/log1.log"
touch "$HOME/Library/Logs/TestApp/log2.log" touch "$HOME/Library/Logs/TestApp/log2.log"
touch "$HOME/Library/Logs/TestApp/log3.log" touch "$HOME/Library/Logs/TestApp/log3.log"
touch "$HOME/Library/Logs/TestApp/log4.log" touch "$HOME/Library/Logs/TestApp/log4.log"
touch "$HOME/Library/Logs/TestApp/log5.log" touch "$HOME/Library/Logs/TestApp/log5.log"
touch "$HOME/Library/Logs/TestApp/log6.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 set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/core/common.sh"
source "$PROJECT_ROOT/lib/uninstall/batch.sh" source "$PROJECT_ROOT/lib/uninstall/batch.sh"
@@ -210,28 +210,27 @@ total_size_cleaned=0
printf 'q' | batch_uninstall_applications printf 'q' | batch_uninstall_applications
EOF EOF
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
[[ "$output" == *"~/Library/Logs/TestApp/log6.log"* ]] [[ "$output" == *"~/Library/Logs/TestApp/log6.log"* ]]
[[ "$output" != *"more files"* ]] [[ "$output" != *"more files"* ]]
} }
@test "safe_remove can remove a simple directory" { @test "safe_remove can remove a simple directory" {
mkdir -p "$HOME/test_dir" mkdir -p "$HOME/test_dir"
touch "$HOME/test_dir/file.txt" 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 set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/core/common.sh"
safe_remove "$HOME/test_dir" safe_remove "$HOME/test_dir"
[[ ! -d "$HOME/test_dir" ]] || exit 1 [[ ! -d "$HOME/test_dir" ]] || exit 1
EOF EOF
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
} }
@test "decode_file_list validates base64 encoding" { @test "decode_file_list validates base64 encoding" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/core/common.sh"
source "$PROJECT_ROOT/lib/uninstall/batch.sh" source "$PROJECT_ROOT/lib/uninstall/batch.sh"
@@ -242,11 +241,11 @@ result=$(decode_file_list "$valid_data" "TestApp")
[[ -n "$result" ]] || exit 1 [[ -n "$result" ]] || exit 1
EOF EOF
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
} }
@test "decode_file_list rejects invalid base64" { @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 set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/core/common.sh"
source "$PROJECT_ROOT/lib/uninstall/batch.sh" source "$PROJECT_ROOT/lib/uninstall/batch.sh"
@@ -258,11 +257,11 @@ else
fi fi
EOF EOF
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
} }
@test "decode_file_list handles empty input" { @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 set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/core/common.sh"
source "$PROJECT_ROOT/lib/uninstall/batch.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" ]] [[ -z "$result" ]]
EOF EOF
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
} }
@test "decode_file_list rejects non-absolute paths" { @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 set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/core/common.sh"
source "$PROJECT_ROOT/lib/uninstall/batch.sh" source "$PROJECT_ROOT/lib/uninstall/batch.sh"
@@ -289,11 +288,11 @@ else
fi fi
EOF EOF
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
} }
@test "decode_file_list handles both BSD and GNU base64 formats" { @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 set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/core/common.sh"
source "$PROJECT_ROOT/lib/uninstall/batch.sh" source "$PROJECT_ROOT/lib/uninstall/batch.sh"
@@ -311,16 +310,16 @@ result=$(decode_file_list "$encoded_data" "TestApp")
[[ -n "$result" ]] || exit 1 [[ -n "$result" ]] || exit 1
EOF EOF
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
} }
@test "remove_mole deletes manual binaries and caches" { @test "remove_mole deletes manual binaries and caches" {
mkdir -p "$HOME/.local/bin" mkdir -p "$HOME/.local/bin"
touch "$HOME/.local/bin/mole" touch "$HOME/.local/bin/mole"
touch "$HOME/.local/bin/mo" touch "$HOME/.local/bin/mo"
mkdir -p "$HOME/.config/mole" "$HOME/.cache/mole" 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 set -euo pipefail
start_inline_spinner() { :; } start_inline_spinner() { :; }
stop_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 printf '\n' | "$PROJECT_ROOT/mole" remove
EOF EOF
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
[ ! -f "$HOME/.local/bin/mole" ] [ ! -f "$HOME/.local/bin/mole" ]
[ ! -f "$HOME/.local/bin/mo" ] [ ! -f "$HOME/.local/bin/mo" ]
[ ! -d "$HOME/.config/mole" ] [ ! -d "$HOME/.config/mole" ]
[ ! -d "$HOME/.cache/mole" ] [ ! -d "$HOME/.cache/mole" ]
}
@test "remove_mole dry-run keeps manual binaries and caches" {
mkdir -p "$HOME/.local/bin"
touch "$HOME/.local/bin/mole"
touch "$HOME/.local/bin/mo"
mkdir -p "$HOME/.config/mole" "$HOME/.cache/mole"
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="/usr/bin:/bin" bash --noprofile --norc <<'EOF'
set -euo pipefail
start_inline_spinner() { :; }
stop_inline_spinner() { :; }
export -f start_inline_spinner stop_inline_spinner
printf '\n' | "$PROJECT_ROOT/mole" remove --dry-run
EOF
[ "$status" -eq 0 ]
[[ "$output" == *"DRY RUN MODE"* ]]
[ -f "$HOME/.local/bin/mole" ]
[ -f "$HOME/.local/bin/mo" ]
[ -d "$HOME/.config/mole" ]
[ -d "$HOME/.cache/mole" ]
} }