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