## Features
- **All-in-one toolkit**: Combines CleanMyMac, AppCleaner, DaisyDisk, and iStat Menus in a **single binary**
-- **Deep cleaning**: Removes caches, logs, and browser leftovers to **reclaim gigabytes of space**
+- **Deep cleaning**: Removes caches, logs, browser leftovers, and orphaned app data to **reclaim gigabytes of space**
- **Smart uninstaller**: Removes apps plus launch agents, preferences, and **hidden remnants**
- **Disk insights**: Visualizes usage, finds large files, **rebuilds caches**, and refreshes system services
- **Live monitoring**: Shows real-time CPU, GPU, memory, disk, and network stats
## Quick Start
-**Install via Homebrew:**
+**Install via Homebrew**
```bash
brew install mole
```
-**Or via script:**
+**Or via script**
```bash
# Optional args: -s latest for main branch code, -s 1.17.0 for specific version
curl -fsSL https://raw.githubusercontent.com/tw93/mole/main/install.sh | bash
```
-**Windows:** Mole is built for macOS. An experimental Windows version is available in the [windows branch](https://github.com/tw93/Mole/tree/windows) for early adopters.
+> Note: Mole is built for macOS. An experimental Windows version is available in the [windows branch](https://github.com/tw93/Mole/tree/windows) for early adopters.
-**Run:**
+**Run**
```bash
mo # Interactive menu
-mo clean # Deep cleanup
-mo uninstall # Remove apps + leftovers
+mo clean # Deep cleanup + already-uninstalled app leftovers
+mo uninstall # Remove installed apps + their leftovers
mo optimize # Refresh caches & services
-mo analyze # Visual disk explorer
+mo analyze # Visual disk explorer (or 'mo analyse')
mo status # Live system health dashboard
mo purge # Clean project build artifacts
mo installer # Find and remove installer files
@@ -60,13 +60,16 @@ mo update --nightly # Update to latest unreleased main build, script in
mo remove # Remove Mole from system
mo --help # Show help
mo --version # Show installed version
+```
+
+**Preview safely**
-# Safe preview before applying changes
+```bash
mo clean --dry-run
mo uninstall --dry-run
mo purge --dry-run
-# --dry-run also works with: optimize, installer, remove, completion, touchid enable
+# 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
@@ -74,10 +77,21 @@ mo purge --paths # Configure project scan directories
mo analyze /Volumes # Analyze external drives only
```
+## Security & Safety Design
+
+Mole is a local system maintenance tool, and some commands can perform destructive local operations.
+
+Mole uses safety-first defaults: path validation, protected-directory rules, conservative cleanup boundaries, and explicit confirmation for higher-risk actions. When risk or uncertainty is high, Mole skips, refuses, or requires stronger confirmation rather than broadening deletion scope.
+
+`mo analyze` is safer for ad hoc cleanup because it moves files to Trash through Finder instead of deleting them directly.
+
+Review [SECURITY.md](SECURITY.md) and [SECURITY_AUDIT.md](SECURITY_AUDIT.md) for reporting guidance, safety boundaries, and current limitations.
+
## Tips
- Video tutorial: Watch the [Mole tutorial video](https://www.youtube.com/watch?v=UEe9-w4CcQ0), thanks to PAPAYA 電腦教室.
-- 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).
+- Safety and logs: `clean`, `uninstall`, `purge`, `installer`, and `remove` are destructive. Review with `--dry-run` first, and add `--debug` when needed. File operations are logged to `~/Library/Logs/mole/operations.log`. Disable with `MO_NO_OPLOG=1`. Review [SECURITY.md](SECURITY.md) and [SECURITY_AUDIT.md](SECURITY_AUDIT.md).
+- App leftovers: use `mo clean` when the app is already uninstalled, and `mo uninstall` when the app is still installed.
- Navigation: Mole supports arrow keys and Vim bindings `h/j/k/l`.
## Features in Detail
@@ -122,6 +136,8 @@ Uninstalling: Photoshop 2024
- Logs, WebKit storage, Cookies
- Extensions, Plugins, Launch daemons
+Note: On macOS 15 and later, Local Network permission entries can outlive app removal. Mole warns when an uninstalled app declares Local Network usage, but it does not auto-reset `/Volumes/Data/Library/Preferences/com.apple.networkextension*.plist` because that reset is global and requires Recovery mode.
+
====================================================================
Space freed: 12.8GB
====================================================================
@@ -150,7 +166,7 @@ Use `mo optimize --whitelist` to exclude specific optimizations.
### Disk Space Analyzer
-By default, Mole skips external drives under `/Volumes` for faster startup. To inspect them, run `mo analyze /Volumes` or a specific mount path.
+> Note: By default, Mole skips external drives under `/Volumes` for faster startup. To inspect them, run `mo analyze /Volumes` or a specific mount path.
```bash
$ mo analyze
@@ -197,9 +213,51 @@ Health score is based on CPU, memory, disk, temperature, and I/O load, with colo
Shortcuts: In `mo status`, press `k` to toggle the cat and save the preference, and `q` to quit.
+When enabled, `mo status` shows a read-only alert banner for processes that stay above the configured CPU threshold for a sustained window. Use `--proc-cpu-threshold`, `--proc-cpu-window`, or `--proc-cpu-alerts=false` to tune or disable it.
+
+#### Machine-Readable Output
+
+Both `mo analyze` and `mo status` support a `--json` flag for scripting and automation.
+
+`mo status` also auto-detects when its output is piped (not a terminal) and switches to JSON automatically.
+
+```bash
+# Disk analysis as JSON
+$ mo analyze --json ~/Documents
+{
+ "path": "/Users/you/Documents",
+ "overview": false,
+ "entries": [
+ { "name": "Library", "path": "...", "size": 80939438080, "is_dir": true },
+ ...
+ ],
+ "large_files": [
+ { "name": "backup.zip", "path": "...", "size": 8796093022 }
+ ],
+ "total_size": 168393441280,
+ "total_files": 42187
+}
+
+# System status as JSON
+$ mo status --json
+{
+ "host": "MacBook-Pro",
+ "health_score": 92,
+ "cpu": { "usage": 45.2, "logical_cpu": 8, ... },
+ "memory": { "total": 25769803776, "used": 15049334784, "used_percent": 58.4 },
+ "disks": [ ... ],
+ "uptime": "3d 12h 45m",
+ ...
+}
+
+# Auto-detected JSON when piped
+$ mo status | jq '.health_score'
+92
+```
+
### Project Artifact Purge
-Clean old build artifacts such as `node_modules`, `target`, `build`, and `dist` to free up disk space.
+Clean old build artifacts such as `node_modules`, `target`, `.build`, `build`, and `dist` to free up disk space.
```bash
mo purge
@@ -216,10 +274,10 @@ Select Categories to Clean - 18.5GB (8 selected)
● backend-service 2.5GB | node_modules
```
-> We recommend installing `fd` on macOS.
+> Note: We recommend installing `fd` on macOS.
> `brew install fd`
-> **Use with caution:** This permanently deletes selected artifacts. Review carefully before confirming. Projects newer than 7 days are marked and unselected by default.
+> Safety: This permanently deletes selected artifacts. Review carefully before confirming. Projects newer than 7 days are marked and unselected by default.
Custom Scan Paths
@@ -291,15 +349,15 @@ Thanks to everyone who helped build Mole. Go follow them. ❤️
Real feedback from users who shared Mole on X.
-
+
## Support
-- If Mole helped you, star the repo or [share it](https://twitter.com/intent/tweet?url=https://github.com/tw93/Mole&text=Mole%20-%20Deep%20clean%20and%20optimize%20your%20Mac.) with friends.
-- Got ideas or bugs? Read the [Contributing Guide](CONTRIBUTING.md) and open an issue or PR.
-- Like Mole? Buy Tw93 a Coke to support the project. 🥤 Supporters are below.
+- If Mole helped you, [share it](https://twitter.com/intent/tweet?url=https://github.com/tw93/Mole&text=Mole%20-%20Deep%20clean%20and%20optimize%20your%20Mac.) with friends or give it a star.
+- Got ideas or bugs? Open an issue or PR, feel free to contribute your best AI model.
+- I have two cats, TangYuan and Coke. If you think Mole delights your life, you can feed them canned food 🥩.
-
+
## License
diff --git a/Resources/mole/SECURITY.md b/Resources/mole/SECURITY.md
new file mode 100644
index 0000000..7a38830
--- /dev/null
+++ b/Resources/mole/SECURITY.md
@@ -0,0 +1,76 @@
+# Security Policy
+
+Mole is a local system maintenance tool. It includes high-risk operations such as cleanup, uninstall, optimization, and artifact removal. We treat safety boundaries, deletion logic, and release integrity as security-sensitive areas.
+
+## Reporting a Vulnerability
+
+Please report suspected security issues privately.
+
+- Email: `hitw93@gmail.com`
+- Subject line: `Mole security report`
+
+Do not open a public GitHub issue for an unpatched vulnerability.
+
+If GitHub Security Advisories private reporting is enabled for the repository, you may use that channel instead of email.
+
+Include as much of the following as possible:
+
+- Mole version and install method
+- macOS version
+- Exact command or workflow involved
+- Reproduction steps or proof of concept
+- Whether the issue involves deletion boundaries, symlinks, sudo, path validation, or release/install integrity
+
+## Response Expectations
+
+- We aim to acknowledge new reports within 7 calendar days.
+- We aim to provide a status update within 30 days if a fix or mitigation is not yet available.
+- We will coordinate disclosure after a fix, mitigation, or clear user guidance is ready.
+
+Response times are best-effort for a maintainer-led open source project, but security reports are prioritized over normal bug reports.
+
+## Supported Versions
+
+Security fixes are only guaranteed for:
+
+- The latest published release
+- The current `main` branch
+
+Older releases may not receive security fixes. Users running high-risk commands should stay current.
+
+## What We Consider a Security Issue
+
+Examples of security-relevant issues include:
+
+- Path validation bypasses
+- Deletion outside intended cleanup boundaries
+- Unsafe handling of symlinks or path traversal
+- Unexpected privilege escalation or unsafe sudo behavior
+- Sensitive data removal that bypasses documented protections
+- Release, installation, update, or checksum integrity issues
+- Vulnerabilities in logic that can cause unintended destructive behavior
+
+## What Usually Does Not Qualify
+
+The following are usually normal bugs, feature requests, or documentation issues rather than security issues:
+
+- Cleanup misses that leave recoverable junk behind
+- False negatives where Mole refuses to clean something
+- Cosmetic UI problems
+- Requests for broader or more aggressive cleanup behavior
+- Compatibility issues without a plausible security impact
+
+If you are unsure whether something is security-relevant, report it privately first.
+
+## Security-Focused Areas in Mole
+
+The project pays particular attention to:
+
+- Destructive command boundaries
+- Path validation and protected-directory rules
+- Sudo and privilege boundaries
+- Symlink and path traversal handling
+- Sensitive data exclusions
+- Packaging, release artifacts, checksums, and update/install flows
+
+For the current technical design and known limitations, see [SECURITY_AUDIT.md](SECURITY_AUDIT.md).
diff --git a/Resources/mole/SECURITY_AUDIT.md b/Resources/mole/SECURITY_AUDIT.md
index 0a6e471..674f9cf 100644
--- a/Resources/mole/SECURITY_AUDIT.md
+++ b/Resources/mole/SECURITY_AUDIT.md
@@ -1,18 +1,60 @@
-# Mole Security Reference
+# Mole Security Audit
-Version 1.30.0 | 2026-03-08
+This document describes the security-relevant behavior of the current `main` branch. It is intended as a public description of Mole's safety boundaries, destructive-operation controls, release integrity signals, and known limitations.
-This document describes the security-relevant behavior of the current codebase on `main`.
+## Executive Summary
-## Path Validation
+Mole is a local system maintenance tool. Its main risk surface is not remote code execution; it is unintended local damage caused by cleanup, uninstall, optimize, purge, installer cleanup, or other destructive operations.
-All destructive file operations go through `lib/core/file_ops.sh`.
+The project is designed around safety-first defaults:
-- `validate_path_for_deletion()` rejects empty paths, relative paths, traversal segments such as `/../`, and control characters.
-- Security-sensitive cleanup paths do not use raw `find ... -delete`.
-- Removal flows use guarded helpers such as `safe_remove()`, `safe_sudo_remove()`, `safe_find_delete()`, and `safe_sudo_find_delete()`.
+- destructive paths are validated before deletion
+- critical system roots and sensitive user-data categories are protected
+- sudo use is bounded and additional restrictions apply when elevated deletion is required
+- symlink handling is conservative
+- preview, confirmation, timeout, and operation logging are used to make destructive behavior more visible and auditable
-Blocked paths remain protected even with sudo, including:
+Mole prioritizes bounded cleanup over aggressive cleanup. When uncertainty exists, the tool should refuse, skip, or require stronger confirmation instead of widening deletion scope.
+
+The project continues to strengthen:
+
+- release integrity and public security signals
+- targeted regression coverage for high-risk paths
+- clearer documentation for privilege boundaries and known limitations
+
+## Threat Surface
+
+The highest-risk areas in Mole are:
+
+- direct file and directory deletion
+- recursive cleanup across common user and system cache locations
+- uninstall flows that combine app removal with remnant cleanup
+- project artifact purge for large dependency/build directories
+- elevated cleanup paths that require sudo
+- release, install, and update trust signals for distributed artifacts
+
+`mo analyze` is intentionally lower-risk than cleanup flows:
+
+- it does not require sudo
+- it respects normal user permissions and SIP
+- delete actions require explicit confirmation
+- deletion routes through Finder Trash behavior rather than direct permanent removal
+
+## Destructive Operation Boundaries
+
+All destructive shell file operations are routed through guarded helpers in `lib/core/file_ops.sh`.
+
+Core controls include:
+
+- `validate_path_for_deletion()` rejects empty paths
+- relative paths are rejected
+- path traversal segments such as `..` as a path component are rejected
+- paths containing control characters are rejected
+- raw `find ... -delete` is avoided for security-sensitive cleanup logic
+- removal flows use guarded helpers such as `safe_remove()`, `safe_sudo_remove()`, `safe_find_delete()`, and `safe_sudo_find_delete()`
+- incomplete download cleanup skips files currently open (lsof check) and uses quoted glob patterns to prevent word-splitting on filenames that contain spaces
+
+Blocked paths remain protected even with sudo. Examples include:
```text
/
@@ -26,7 +68,7 @@ Blocked paths remain protected even with sudo, including:
/Library/Extensions
```
-Some subpaths under protected roots are explicitly allowlisted for bounded cache and log cleanup, for example:
+Some subpaths under otherwise protected roots are explicitly allowlisted for bounded cleanup where the project intentionally supports cache/log maintenance. Examples include:
- `/private/tmp`
- `/private/var/tmp`
@@ -37,164 +79,220 @@ Some subpaths under protected roots are explicitly allowlisted for bounded cache
- `/private/var/db/powerlog`
- `/private/var/db/reportmemoryexception`
-When running with sudo, symlinked targets are validated before deletion and system-target symlinks are refused.
-
-## Cleanup Rules
+This design keeps cleanup scoped to known-safe maintenance targets instead of broad root-level deletion patterns.
-### Orphan Detection
+## Path Protection Reference
-Orphaned app data is handled in `lib/clean/apps.sh`.
+### Protected Prefixes (Never Deleted)
-- Generic orphaned app data requires both:
- - the app is not found by installed-app scanning and fallback checks, and
- - the target has been inactive for at least 30 days.
-- Claude VM bundles use a stricter app-specific window:
- - `~/Library/Application Support/Claude/vm_bundles/claudevm.bundle` must appear orphaned, and
- - it must be inactive for at least 7 days before cleanup.
-- Sensitive categories such as keychains, password-manager data, and protected app families are excluded from generic orphan cleanup.
+```text
+/
+/System
+/bin
+/sbin
+/usr
+/etc
+/var
+/private
+/Library/Extensions
+```
-Installed-app detection is broader than a simple `/Applications` scan and includes:
+### Whitelist Exceptions (Allowlisted for Cleanup)
-- `/Applications`
-- `/System/Applications`
-- `~/Applications`
-- Homebrew Caskroom locations
-- Setapp application paths
+Some subpaths under protected roots are explicitly allowlisted:
-Spotlight fallback checks are bounded with short timeouts to avoid hangs.
+- `/private/tmp`
+- `/private/var/tmp`
+- `/private/var/log`
+- `/private/var/folders`
+- `/private/var/db/diagnostics`
+- `/private/var/db/DiagnosticPipeline`
+- `/private/var/db/powerlog`
+- `/private/var/db/reportmemoryexception`
-### Uninstall Matching
+### Protected Categories
-App uninstall behavior is implemented in `lib/uninstall/batch.sh` and related helpers.
+In addition to path blocking, these categories are protected:
-- LaunchAgent and LaunchDaemon lookups require a valid reverse-DNS bundle identifier.
-- Deletion candidates are decoded and validated as absolute paths before removal.
-- Homebrew casks are preferentially removed with `brew uninstall --cask --zap`.
-- LaunchServices unregister and rebuild steps are skipped safely if `lsregister` is unavailable.
+- Keychains, password managers, credentials
+- VPN/proxy tools (Shadowsocks, V2Ray, Clash, Tailscale)
+- AI tools (Cursor, Claude, ChatGPT, Ollama)
+- Browser history and cookies
+- Time Machine data (during active backup)
+- `com.apple.*` LaunchAgents/LaunchDaemons
+- user-owned `~/Library/LaunchAgents/*.plist` automation/configuration
+- iCloud-synced `Mobile Documents`
-### Developer and Project Cleanup
+## Implementation Details
-Project artifact cleanup in `lib/clean/project.sh` protects recently modified targets:
+All deletion routes pass through `lib/core/file_ops.sh`:
-- recently modified project artifacts are treated as recent for 7 days
-- protected vendor and build-output heuristics prevent broad accidental deletions
-- nested artifacts are filtered to avoid duplicate or parent-child over-deletion
+- `validate_path_for_deletion()` - Empty, relative, traversal checks
+- `should_protect_path()` - Prefix and pattern matching
+- `safe_remove()`, `safe_find_delete()`, `safe_sudo_remove()` - Guarded operations
-Developer-cache cleanup preserves toolchains and other high-value state. Examples intentionally left alone include:
+See [`journal/2026-03-11-safe-remove-design.md`](journal/2026-03-11-safe-remove-design.md) for design rationale.
-- `~/.cargo/bin`
-- `~/.rustup`
-- `~/.mix/archives`
-- `~/.stack/programs`
+## Protected Directories and Categories
-## Protected Categories
+Mole has explicit protected-path and protected-category logic in addition to root-path blocking.
Protected or conservatively handled categories include:
-- system components such as Control Center, System Settings, TCC, Spotlight, and `/Library/Updates`
-- password managers and keychain-related data
-- VPN / proxy tools such as Shadowsocks, V2Ray, Clash, and Tailscale
+- system components such as Control Center, System Settings, TCC, Spotlight, Finder, and Dock-related state
+- keychains, password-manager data, tokens, credentials, and similar sensitive material
+- VPN and proxy tools such as Shadowsocks, V2Ray, Clash, and Tailscale
- AI tools in generic protected-data logic, including Cursor, Claude, ChatGPT, and Ollama
- `~/Library/Messages/Attachments`
- browser history and cookies
- Time Machine data while backup state is active or ambiguous
- `com.apple.*` LaunchAgents and LaunchDaemons
+- user-owned `~/Library/LaunchAgents/*.plist` automation/configuration
+- iCloud-synced `Mobile Documents` data
-## Analyzer
+Project purge also uses conservative heuristics:
-`mo analyze` is intentionally lower-risk than cleanup flows:
+- purge targets must be inside configured project boundaries
+- direct-child artifact cleanup is only allowed in single-project mode
+- recently modified artifacts are treated as recent for 7 days
+- nested artifacts are filtered to avoid parent-child over-deletion
+- protected vendor/build-output heuristics block ambiguous directories
-- it does not require sudo
-- it respects normal user permissions and SIP
-- interactive deletion requires an extra confirmation sequence
-- deletions route through Trash/Finder behavior rather than direct permanent removal
+Developer cleanup also preserves high-value state. Examples intentionally left alone include:
-Code lives under `cmd/analyze/*.go`.
+- `~/.cargo/bin`
+- `~/.rustup`
+- `~/.mix/archives`
+- `~/.stack/programs`
-## Timeouts and Hang Resistance
+## Symlink and Path Traversal Handling
-`lib/core/timeout.sh` uses this fallback order:
+Symlink behavior is intentionally conservative.
-1. `gtimeout` / `timeout`
-2. a Perl helper with process-group cleanup
-3. a shell fallback
+- path validation checks symlink targets before deletion
+- symlinks pointing at protected system targets are rejected
+- `safe_sudo_remove()` refuses to sudo-delete symlinks
+- `safe_find_delete()` and `safe_sudo_find_delete()` refuse to scan symlinked base directories
+- installer discovery avoids treating symlinked installer files as deletion candidates
+- analyzer scanning skips following symlinks to unexpected targets
-Current notable timeouts in security-relevant paths:
+Path traversal handling is also explicit:
-- orphan/Spotlight `mdfind` checks: 2s
-- LaunchServices rebuild during uninstall: 10s / 15s bounded steps
-- Homebrew uninstall cask flow: 300s default, extended to 600s or 900s for large apps
-- Application Support sizing: direct file `stat`, bounded `du` for directories
+- non-absolute paths are rejected for destructive helpers
+- `..` is rejected when it appears as a path component
+- legitimate names containing `..` inside a single path element remain allowed to avoid false positives for real application data
+- `mo analyze` delete validates the raw user-supplied path before `filepath.Abs` resolves it, then validates the resolved absolute path a second time, closing a window where traversal segments could survive `Abs` normalization
-Additional safety behavior:
+## Privilege Escalation and Sudo Boundaries
-- `brew_uninstall_cask()` treats exit code `124` as timeout failure and returns failure immediately
-- font cache rebuild is skipped while browsers are running
-- project-cache discovery and scans use strict timeouts to avoid whole-home stalls
+Mole uses sudo for a subset of system-maintenance paths, but elevated behavior is still bounded by validation and protected-path rules.
-## User Configuration
+Key properties:
-Protected paths can be added to `~/.config/mole/whitelist`, one path per line.
+- sudo access is explicitly requested instead of assumed
+- non-interactive preview remains conservative when sudo is unavailable
+- protected roots remain blocked even when sudo is available
+- sudo deletion uses the same path validation gate as non-sudo deletion
+- sudo cleanup skips or reports denied operations instead of widening scope
+- authentication, SIP/MDM, and read-only filesystem failures are classified separately in file-operation results
+- sudo credential prompting passes through the system's native PAM prompt rather than a hardcoded string, ensuring correct behavior across locales and PAM configurations
-Example:
+When sudo is denied or unavailable, Mole prefers skipping privileged cleanup to forcing execution through unsafe fallback behavior.
-```bash
-/Users/me/important-cache
-~/Library/Application Support/MyApp
-```
+## Sensitive Data Exclusions
-Exact path protection is preferred over pattern-style broad deletion rules.
+Mole is not intended to aggressively delete high-value user data.
-Use `--dry-run` before destructive operations when validating new cleanup behavior.
+Examples of conservative handling include:
-## Testing
+- sensitive app families are excluded from generic orphan cleanup
+- orphaned app data waits for inactivity windows before cleanup
+- Claude VM orphan cleanup uses a separate stricter rule
+- uninstall file lists are decoded and revalidated before removal
+- reverse-DNS bundle ID validation is required before LaunchAgent and LaunchDaemon pattern matching
-There is no dedicated `tests/security.bats`. Security-relevant behavior is covered by targeted BATS suites, including:
+Installed-app detection is broader than a single `/Applications` scan and includes:
-- `tests/clean_core.bats`
-- `tests/clean_user_core.bats`
-- `tests/clean_dev_caches.bats`
-- `tests/clean_system_maintenance.bats`
-- `tests/clean_apps.bats`
-- `tests/purge.bats`
-- `tests/core_safe_functions.bats`
-- `tests/optimize.bats`
+- `/Applications`
+- `/System/Applications`
+- `~/Applications`
+- Homebrew Caskroom locations
+- Setapp application paths
-Local verification used for the current branch includes:
+This reduces the risk of incorrectly classifying active software as orphaned data.
-```bash
-bats tests/clean_core.bats tests/clean_user_core.bats tests/clean_dev_caches.bats tests/clean_system_maintenance.bats tests/purge.bats tests/core_safe_functions.bats tests/clean_apps.bats tests/optimize.bats
-bash -n lib/core/base.sh lib/clean/apps.sh tests/clean_apps.bats tests/optimize.bats
-```
+## Dry-Run, Confirmation, and Audit Logging
+
+Mole exposes multiple safety controls before and during destructive actions:
+
+- `--dry-run` previews are available for major destructive commands
+- dry-run output deduplicates targets by filesystem identity (device+inode), so aliased paths and symlinks do not appear as separate items
+- interactive high-risk flows require explicit confirmation before deletion
+- purge marks recent projects conservatively and leaves them unselected by default
+- purge configuration is written atomically (mktemp then rename) to prevent partial writes if the process is interrupted
+- analyzer delete uses Finder Trash rather than direct permanent removal
+- operation logs are written to `~/Library/Logs/mole/operations.log` unless disabled with `MO_NO_OPLOG=1`
+- timeouts bound external commands so stalled discovery or uninstall operations do not silently hang the entire flow
+
+Relevant timeout behavior includes:
+
+- orphan and Spotlight checks: 2s
+- LaunchServices rebuild during uninstall: bounded 10s and 15s steps
+- Homebrew uninstall cask flow: 300s by default, extended for large apps when needed
+- project scans and sizing operations: bounded to avoid whole-home stalls
+
+## Release Integrity and Continuous Security Signals
+
+Mole treats release trust as part of its security posture, not just a packaging detail.
-CI additionally runs shell and Go validation on push.
+Repository-level signals include:
-## Dependencies
+- weekly Dependabot updates for Go modules and GitHub Actions
+- pre-commit hook that mirrors GitHub CI checks locally (shell syntax, shfmt, shellcheck, Go vet)
+- CI checks for unsafe `rm -rf` usage patterns and core protection behavior
+- targeted tests for path validation, purge boundaries, symlink behavior, dry-run flows, and destructive helpers
+- CodeQL scanning for Go and GitHub Actions workflows, with workflow permission hardening
+- curated changelog-driven release notes with a dedicated `Safety-related changes` section
+- published SHA-256 checksums for release assets
+- GitHub artifact attestations for release assets
-Primary Go dependencies are pinned in `go.mod`, including:
+These controls do not eliminate all supply-chain risk, but they make release changes easier to review and verify.
-- `github.com/charmbracelet/bubbletea v1.3.10`
-- `github.com/charmbracelet/lipgloss v1.1.0`
-- `github.com/shirou/gopsutil/v4 v4.26.2`
-- `github.com/cespare/xxhash/v2 v2.3.0`
+## Testing Coverage
-System tooling relies mainly on Apple-provided binaries and standard macOS utilities such as:
+There is no single `tests/security.bats` file. Instead, security-relevant behavior is covered by focused suites, including:
-- `tmutil`
-- `diskutil`
-- `plutil`
-- `launchctl`
-- `osascript`
-- `find`
-- `stat`
+- `tests/core_safe_functions.bats`
+- `tests/clean_core.bats`
+- `tests/clean_user_core.bats`
+- `tests/clean_dev_caches.bats`
+- `tests/clean_system_maintenance.bats`
+- `tests/clean_apps.bats`
+- `tests/purge.bats`
+- `tests/installer.bats`
+- `tests/optimize.bats`
-Dependency vulnerability status should be checked separately from this document.
+Key coverage areas include:
-## Limitations
+- path validation rejects empty, relative, traversal, and system paths
+- symlinked directories are rejected for destructive scans
+- purge protects shallow or ambiguous paths and filters nested artifacts
+- dry-run flows preview actions without applying them and do not emit duplicate targets
+- confirmation flows exist for high-risk interactive operations
+- sudo credential prompting and session management (`tests/manage_sudo.bats`)
+- purge config path discovery and write behavior (`tests/purge_config_paths.bats`)
+- hint and cleanup-hint flows (`tests/clean_hints.bats`)
-- Cleanup is destructive. There is no undo.
-- Generic orphan data waits 30 days before automatic cleanup.
-- Claude VM orphan cleanup waits 7 days before automatic cleanup.
-- Time Machine safety windows are hour-based, not day-based, and remain more conservative.
+## Known Limitations and Future Work
+
+- Cleanup is destructive. Most cleanup and uninstall flows do not provide undo.
+- `mo analyze` delete is safer because it uses Trash, but other cleanup flows are permanent once confirmed.
+- Generic orphan data waits 30 days before cleanup; this is conservative but heuristic.
+- Claude VM orphan cleanup waits 7 days before cleanup; this is also heuristic.
+- Time Machine safety windows are hour-based and intentionally conservative.
- Localized app names may still be missed in some heuristic paths, though bundle IDs are preferred where available.
- Users who want immediate removal of app data should use explicit uninstall flows rather than waiting for orphan cleanup.
+- Release signing and provenance signals are improving, but downstream package-manager trust also depends on external distribution infrastructure.
+- Planned follow-up work includes stronger destructive-command threat modeling, more regression coverage for high-risk paths, and continued hardening of release integrity and disclosure workflow.
+
+For reporting procedures and supported versions, see [SECURITY.md](SECURITY.md).
diff --git a/Resources/mole/bin/analyze-go b/Resources/mole/bin/analyze-go
index ebb96ba..9bd968a 100755
Binary files a/Resources/mole/bin/analyze-go and b/Resources/mole/bin/analyze-go differ
diff --git a/Resources/mole/bin/check.sh b/Resources/mole/bin/check.sh
index 24e4594..2b56045 100755
--- a/Resources/mole/bin/check.sh
+++ b/Resources/mole/bin/check.sh
@@ -14,6 +14,7 @@ source "$SCRIPT_DIR/lib/manage/update.sh"
source "$SCRIPT_DIR/lib/manage/autofix.sh"
source "$SCRIPT_DIR/lib/check/all.sh"
+source "$SCRIPT_DIR/lib/check/dev_environment.sh"
cleanup_all() {
stop_inline_spinner 2> /dev/null || true
@@ -42,6 +43,7 @@ main() {
local health_file=$(mktemp_file)
local security_file=$(mktemp_file)
local config_file=$(mktemp_file)
+ local dev_file=$(mktemp_file)
# Run all checks in parallel with spinner
if [[ -t 1 ]]; then
@@ -58,6 +60,7 @@ main() {
check_system_health > "$health_file" 2>&1 &
check_all_security > "$security_file" 2>&1 &
check_all_config > "$config_file" 2>&1 &
+ check_all_dev_environment > "$dev_file" 2>&1 &
wait
}
@@ -66,22 +69,21 @@ main() {
printf '\n'
fi
- # Display results
- echo -e "${BLUE}${ICON_ARROW}${NC} System updates"
+ # Display results (headers are printed by the check_all_* functions)
cat "$updates_file"
printf '\n'
- echo -e "${BLUE}${ICON_ARROW}${NC} System health"
cat "$health_file"
printf '\n'
- echo -e "${BLUE}${ICON_ARROW}${NC} Security posture"
cat "$security_file"
printf '\n'
- echo -e "${BLUE}${ICON_ARROW}${NC} Configuration"
cat "$config_file"
+ printf '\n'
+ cat "$dev_file"
+
# Show suggestions
show_suggestions
diff --git a/Resources/mole/bin/clean.sh b/Resources/mole/bin/clean.sh
index 039fd33..370d2a2 100755
--- a/Resources/mole/bin/clean.sh
+++ b/Resources/mole/bin/clean.sh
@@ -24,14 +24,34 @@ source "$SCRIPT_DIR/../lib/clean/user.sh"
SYSTEM_CLEAN=false
DRY_RUN=false
PROTECT_FINDER_METADATA=false
+EXTERNAL_VOLUME_TARGET=""
IS_M_SERIES=$([[ "$(uname -m)" == "arm64" ]] && echo "true" || echo "false")
EXPORT_LIST_FILE="$HOME/.config/mole/clean-list.txt"
CURRENT_SECTION=""
readonly PROTECTED_SW_DOMAINS=(
+ # Web editors
"capcut.com"
"photopea.com"
"pixlr.com"
+ # Google Workspace (offline mode)
+ "docs.google.com"
+ "sheets.google.com"
+ "slides.google.com"
+ "drive.google.com"
+ "mail.google.com"
+ # Code platforms (offline/PWA)
+ "github.com"
+ "gitlab.com"
+ "codepen.io"
+ "codesandbox.io"
+ "replit.com"
+ "stackblitz.com"
+ # Collaboration tools (offline/PWA)
+ "notion.so"
+ "figma.com"
+ "linear.app"
+ "excalidraw.com"
)
declare -a WHITELIST_PATTERNS=()
@@ -53,7 +73,7 @@ if [[ -f "$HOME/.config/mole/whitelist" ]]; then
fi
if [[ "$line" != "$FINDER_METADATA_SENTINEL" ]]; then
- if [[ ! "$line" =~ ^[a-zA-Z0-9/_.@\ *-]+$ ]]; then
+ if [[ "$line" =~ [[:cntrl:]] ]]; then
WHITELIST_WARNINGS+=("Invalid path format: $line")
continue
fi
@@ -129,6 +149,7 @@ PROJECT_ARTIFACT_HINT_EXAMPLES=()
PROJECT_ARTIFACT_HINT_ESTIMATED_KB=0
PROJECT_ARTIFACT_HINT_ESTIMATE_SAMPLES=0
PROJECT_ARTIFACT_HINT_ESTIMATE_PARTIAL=false
+declare -a DRY_RUN_SEEN_IDENTITIES=()
# shellcheck disable=SC2329
note_activity() {
@@ -137,6 +158,20 @@ note_activity() {
fi
}
+# shellcheck disable=SC2329
+register_dry_run_cleanup_target() {
+ local path="$1"
+ local identity
+ identity=$(mole_path_identity "$path")
+
+ if [[ ${#DRY_RUN_SEEN_IDENTITIES[@]} -gt 0 ]] && mole_identity_in_list "$identity" "${DRY_RUN_SEEN_IDENTITIES[@]}"; then
+ return 1
+ fi
+
+ DRY_RUN_SEEN_IDENTITIES+=("$identity")
+ return 0
+}
+
CLEANUP_DONE=false
# shellcheck disable=SC2329
cleanup() {
@@ -187,6 +222,35 @@ end_section() {
# shellcheck disable=SC2329
normalize_paths_for_cleanup() {
local -a input_paths=("$@")
+
+ # Fast path for large batches: O(n log n) via sort|awk instead of O(n²) bash loops.
+ # Lex sort guarantees every parent path precedes its children, so a single-pass
+ # awk can filter child paths by tracking only the last kept path.
+ # Paths with embedded newlines cannot go through the newline-delimited pipeline;
+ # they are output directly with null-byte delimiters and skipped by the sort pass.
+ if [[ ${#input_paths[@]} -gt 50 ]]; then
+ local -a _fast_pipeline=()
+ local _fast_path
+ for _fast_path in "${input_paths[@]}"; do
+ if [[ "$_fast_path" == *$'\n'* ]]; then
+ printf '%s\0' "$_fast_path"
+ else
+ _fast_pipeline+=("$_fast_path")
+ fi
+ done
+ if [[ ${#_fast_pipeline[@]} -gt 0 ]]; then
+ printf '%s\n' "${_fast_pipeline[@]}" |
+ awk '{sub(/\/$/, ""); if ($0 != "") print}' |
+ LC_ALL=C sort -u |
+ awk 'BEGIN { last = "" } {
+ if (last != "" && substr($0, 1, length(last) + 1) == last "/") next
+ last = $0; print
+ }' |
+ while IFS= read -r _fast_path; do printf '%s\0' "$_fast_path"; done
+ fi
+ return
+ fi
+
local -a unique_paths=()
for path in "${input_paths[@]}"; do
@@ -204,9 +268,21 @@ normalize_paths_for_cleanup() {
[[ "$found" == "true" ]] || unique_paths+=("$normalized")
done
+ # Paths with embedded newlines cannot safely go through the newline-delimited
+ # sort pipeline. Collect them separately and append to result as-is.
+ local -a pipeline_paths=()
+ local -a passthrough_paths=()
+ for path in "${unique_paths[@]}"; do
+ if [[ "$path" == *$'\n'* ]]; then
+ passthrough_paths+=("$path")
+ else
+ pipeline_paths+=("$path")
+ fi
+ done
+
local sorted_paths
- if [[ ${#unique_paths[@]} -gt 0 ]]; then
- sorted_paths=$(printf '%s\n' "${unique_paths[@]}" | awk '{print length "|" $0}' | LC_ALL=C sort -n | cut -d'|' -f2-)
+ if [[ ${#pipeline_paths[@]} -gt 0 ]]; then
+ sorted_paths=$(printf '%s\n' "${pipeline_paths[@]}" | awk '{print length "|" $0}' | LC_ALL=C sort -n | cut -d'|' -f2-)
else
sorted_paths=""
fi
@@ -226,8 +302,13 @@ normalize_paths_for_cleanup() {
[[ "$is_child" == "true" ]] || result_paths+=("$path")
done <<< "$sorted_paths"
+ # Append passthrough paths (newline-containing; not deduplicated against others).
+ if [[ ${#passthrough_paths[@]} -gt 0 ]]; then
+ result_paths+=("${passthrough_paths[@]}")
+ fi
+
if [[ ${#result_paths[@]} -gt 0 ]]; then
- printf '%s\n' "${result_paths[@]}"
+ printf '%s\0' "${result_paths[@]}"
fi
}
@@ -380,7 +461,12 @@ safe_clean() {
log_operation "clean" "SKIPPED" "$path" "whitelist"
fi
[[ "$skip" == "true" ]] && continue
- [[ -e "$path" ]] && existing_paths+=("$path")
+ if [[ -e "$path" ]]; then
+ if [[ "$DRY_RUN" == "true" ]]; then
+ register_dry_run_cleanup_target "$path" || continue
+ fi
+ existing_paths+=("$path")
+ fi
done
if [[ "$show_scan_feedback" == "true" ]]; then
@@ -419,7 +505,7 @@ safe_clean() {
if [[ ${#existing_paths[@]} -gt 1 ]]; then
local -a normalized_paths=()
- while IFS= read -r path; do
+ while IFS= read -r -d '' path; do
[[ -n "$path" ]] && normalized_paths+=("$path")
done < <(normalize_paths_for_cleanup "${existing_paths[@]}")
@@ -690,7 +776,9 @@ safe_clean() {
done
fi
else
- echo -e " ${GREEN}${ICON_SUCCESS}${NC} $label${NC}, ${GREEN}$size_human${NC}"
+ local line_color
+ line_color=$(cleanup_result_color_kb "$total_size_kb")
+ echo -e " ${line_color}${ICON_SUCCESS}${NC} $label${NC}, ${line_color}$size_human${NC}"
fi
files_cleaned=$((files_cleaned + total_count))
total_size_cleaned=$((total_size_cleaned + total_size_kb))
@@ -705,11 +793,25 @@ start_cleanup() {
# Set current command for operation logging
export MOLE_CURRENT_COMMAND="clean"
log_operation_session_start "clean"
+ DRY_RUN_SEEN_IDENTITIES=()
if [[ -t 1 ]]; then
printf '\033[2J\033[H'
fi
printf '\n'
+ if [[ -n "$EXTERNAL_VOLUME_TARGET" ]]; then
+ echo -e "${PURPLE_BOLD}Clean External Volume${NC}"
+ echo -e "${GRAY}${EXTERNAL_VOLUME_TARGET}${NC}"
+ echo ""
+
+ if [[ "$DRY_RUN" == "true" ]]; then
+ echo -e "${YELLOW}Dry Run Mode${NC}, Preview only, no deletions"
+ echo ""
+ fi
+ SYSTEM_CLEAN=false
+ return 0
+ fi
+
echo -e "${PURPLE_BOLD}Clean Your Mac${NC}"
echo ""
@@ -802,9 +904,15 @@ EOF
}
perform_cleanup() {
+ if [[ -n "$EXTERNAL_VOLUME_TARGET" ]]; then
+ total_items=0
+ files_cleaned=0
+ total_size_cleaned=0
+ fi
+
# Test mode skips expensive scans and returns minimal output.
local test_mode_enabled=false
- if [[ "${MOLE_TEST_MODE:-0}" == "1" ]]; then
+ if [[ -z "$EXTERNAL_VOLUME_TARGET" && "${MOLE_TEST_MODE:-0}" == "1" ]]; then
test_mode_enabled=true
if [[ "$DRY_RUN" == "true" ]]; then
echo -e "${YELLOW}Dry Run Mode${NC}, Preview only, no deletions"
@@ -838,7 +946,7 @@ perform_cleanup() {
total_size_cleaned=0
fi
- if [[ "$test_mode_enabled" == "false" ]]; then
+ if [[ "$test_mode_enabled" == "false" && -z "$EXTERNAL_VOLUME_TARGET" ]]; then
echo -e "${BLUE}${ICON_ADMIN}${NC} $(detect_architecture) | Free space: $(get_free_space)"
fi
@@ -853,7 +961,9 @@ perform_cleanup() {
fi
# Pre-check TCC permissions to avoid mid-run prompts.
- check_tcc_permissions
+ if [[ -z "$EXTERNAL_VOLUME_TARGET" ]]; then
+ check_tcc_permissions
+ fi
if [[ ${#WHITELIST_PATTERNS[@]} -gt 0 ]]; then
local predefined_count=0
@@ -914,98 +1024,104 @@ perform_cleanup() {
# Allow per-section failures without aborting the full run.
set +e
- # ===== 1. System =====
- if [[ "$SYSTEM_CLEAN" == "true" ]]; then
- start_section "System"
- clean_deep_system
- clean_local_snapshots
+ if [[ -n "$EXTERNAL_VOLUME_TARGET" ]]; then
+ start_section "External volume"
+ clean_external_volume_target "$EXTERNAL_VOLUME_TARGET"
end_section
- fi
+ else
+ # ===== 1. System =====
+ if [[ "$SYSTEM_CLEAN" == "true" ]]; then
+ start_section "System"
+ clean_deep_system
+ clean_local_snapshots
+ end_section
+ fi
- if [[ ${#WHITELIST_WARNINGS[@]} -gt 0 ]]; then
- echo ""
- for warning in "${WHITELIST_WARNINGS[@]}"; do
- echo -e " ${GRAY}${ICON_WARNING}${NC} Whitelist: $warning"
- done
- fi
+ if [[ ${#WHITELIST_WARNINGS[@]} -gt 0 ]]; then
+ echo ""
+ for warning in "${WHITELIST_WARNINGS[@]}"; do
+ echo -e " ${GRAY}${ICON_WARNING}${NC} Whitelist: $warning"
+ done
+ fi
+
+ # ===== 2. User essentials =====
+ start_section "User essentials"
+ clean_user_essentials
+ clean_finder_metadata
+ end_section
- # ===== 2. User essentials =====
- start_section "User essentials"
- clean_user_essentials
- clean_finder_metadata
- scan_external_volumes
- end_section
-
- # ===== 3. App caches (merged sandboxed and standard app caches) =====
- start_section "App caches"
- clean_app_caches
- end_section
-
- # ===== 4. Browsers =====
- start_section "Browsers"
- clean_browsers
- end_section
-
- # ===== 5. Cloud & Office =====
- start_section "Cloud & Office"
- clean_cloud_storage
- clean_office_applications
- end_section
-
- # ===== 6. Developer tools (merged CLI and GUI tooling) =====
- start_section "Developer tools"
- clean_developer_tools
- end_section
-
- # ===== 7. Applications =====
- start_section "Applications"
- clean_user_gui_applications
- end_section
-
- # ===== 8. Virtualization =====
- start_section "Virtualization"
- clean_virtualization_tools
- end_section
-
- # ===== 9. Application Support =====
- start_section "Application Support"
- clean_application_support_logs
- end_section
-
- # ===== 10. Orphaned data =====
- start_section "Orphaned data"
- clean_orphaned_app_data
- clean_orphaned_system_services
- clean_orphaned_launch_agents
- end_section
-
- # ===== 11. Apple Silicon =====
- clean_apple_silicon_caches
-
- # ===== 12. Device backups =====
- start_section "Device backups"
- check_ios_device_backups
- end_section
-
- # ===== 13. Time Machine =====
- start_section "Time Machine"
- clean_time_machine_failed_backups
- end_section
-
- # ===== 14. Large files =====
- start_section "Large files"
- check_large_file_candidates
- end_section
-
- # ===== 15. System Data clues =====
- start_section "System Data clues"
- show_system_data_hint_notice
- end_section
-
- # ===== 16. Project artifacts =====
- start_section "Project artifacts"
- show_project_artifact_hint_notice
- end_section
+ # ===== 3. App caches (merged sandboxed and standard app caches) =====
+ start_section "App caches"
+ clean_app_caches
+ end_section
+
+ # ===== 4. Browsers =====
+ start_section "Browsers"
+ clean_browsers
+ end_section
+
+ # ===== 5. Cloud & Office =====
+ start_section "Cloud & Office"
+ clean_cloud_storage
+ clean_office_applications
+ end_section
+
+ # ===== 6. Developer tools (merged CLI and GUI tooling) =====
+ start_section "Developer tools"
+ clean_developer_tools
+ end_section
+
+ # ===== 7. Applications =====
+ start_section "Applications"
+ clean_user_gui_applications
+ end_section
+
+ # ===== 8. Virtualization =====
+ start_section "Virtualization"
+ clean_virtualization_tools
+ end_section
+
+ # ===== 9. Application Support =====
+ start_section "Application Support"
+ clean_application_support_logs
+ end_section
+
+ # ===== 10. App leftovers =====
+ start_section "App leftovers"
+ clean_orphaned_app_data
+ clean_orphaned_system_services
+ show_user_launch_agent_hint_notice
+ end_section
+
+ # ===== 11. Apple Silicon =====
+ clean_apple_silicon_caches
+
+ # ===== 12. Device backups & firmware =====
+ start_section "Device backups & firmware"
+ clean_cached_device_firmware
+ check_ios_device_backups
+ end_section
+
+ # ===== 13. Time Machine =====
+ start_section "Time Machine"
+ clean_time_machine_failed_backups
+ end_section
+
+ # ===== 14. Large files =====
+ start_section "Large files"
+ check_large_file_candidates
+ end_section
+
+ # ===== 15. System Data clues =====
+ start_section "System Data clues"
+ show_system_data_hint_notice
+ end_section
+
+ # ===== 16. Project artifacts =====
+ start_section "Project artifacts"
+ show_project_artifact_hint_notice
+ end_section
+ fi
# ===== Final summary =====
echo ""
@@ -1055,9 +1171,9 @@ perform_cleanup() {
summary_details+=("$summary_line")
- # Movie comparison only if >= 1GB (1048576 KB)
- if ((total_size_cleaned >= 1048576)); then
- local freed_gb=$((total_size_cleaned / 1048576))
+ # Movie comparison only if >= 1GB
+ if ((total_size_cleaned >= MOLE_ONE_GIB_KB)); then
+ local freed_gb=$((total_size_cleaned / MOLE_ONE_GIB_KB))
local movies=$((freed_gb * 10 / 45))
if [[ $movies -gt 0 ]]; then
@@ -1095,8 +1211,8 @@ perform_cleanup() {
}
main() {
- for arg in "$@"; do
- case "$arg" in
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
"--help" | "-h")
show_clean_help
exit 0
@@ -1108,12 +1224,21 @@ main() {
DRY_RUN=true
export MOLE_DRY_RUN=1
;;
+ "--external")
+ shift
+ if [[ $# -eq 0 ]]; then
+ echo "Missing path for --external" >&2
+ exit 1
+ fi
+ EXTERNAL_VOLUME_TARGET=$(validate_external_volume_target "$1") || exit 1
+ ;;
"--whitelist")
source "$SCRIPT_DIR/../lib/manage/whitelist.sh"
manage_whitelist "clean"
exit 0
;;
esac
+ shift
done
start_cleanup
diff --git a/Resources/mole/bin/completion.sh b/Resources/mole/bin/completion.sh
index a575929..94ddca8 100755
--- a/Resources/mole/bin/completion.sh
+++ b/Resources/mole/bin/completion.sh
@@ -23,13 +23,32 @@ emit_fish_completions() {
for entry in "${MOLE_COMMANDS[@]}"; do
local name="${entry%%:*}"
local desc="${entry#*:}"
- printf 'complete -c %s -n "__fish_mole_no_subcommand" -a %s -d "%s"\n' "$cmd" "$name" "$desc"
+ printf 'complete -f -c %s -n "__fish_mole_no_subcommand" -a %s -d "%s"\n' "$cmd" "$name" "$desc"
done
printf '\n'
- printf 'complete -c %s -n "not __fish_mole_no_subcommand" -a bash -d "generate bash completion" -n "__fish_see_subcommand_path completion"\n' "$cmd"
- printf 'complete -c %s -n "not __fish_mole_no_subcommand" -a zsh -d "generate zsh completion" -n "__fish_see_subcommand_path completion"\n' "$cmd"
- printf 'complete -c %s -n "not __fish_mole_no_subcommand" -a fish -d "generate fish completion" -n "__fish_see_subcommand_path completion"\n' "$cmd"
+ printf 'complete -f -c %s -n "not __fish_mole_no_subcommand" -a bash -d "generate bash completion" -n "__fish_see_subcommand_path completion"\n' "$cmd"
+ printf 'complete -f -c %s -n "not __fish_mole_no_subcommand" -a zsh -d "generate zsh completion" -n "__fish_see_subcommand_path completion"\n' "$cmd"
+ printf 'complete -f -c %s -n "not __fish_mole_no_subcommand" -a fish -d "generate fish completion" -n "__fish_see_subcommand_path completion"\n' "$cmd"
+}
+
+remove_stale_completion_entries() {
+ local config_file="$1"
+ local success_message="$2"
+
+ if [[ ! -f "$config_file" ]] || ! grep -Eq "(^# Mole shell completion$|(mole|mo)[[:space:]]+completion)" "$config_file" 2> /dev/null; then
+ return 1
+ fi
+
+ local original_mode=""
+ local temp_file
+ 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"
+ [[ -n "$original_mode" ]] && chmod "$original_mode" "$config_file" 2> /dev/null || true
+ [[ -n "$success_message" ]] && echo -e "${GREEN}${ICON_SUCCESS}${NC} $success_message"
+ return 0
}
if [[ $# -gt 0 ]]; then
@@ -71,6 +90,75 @@ if [[ $# -eq 0 ]]; then
completion_name="mo"
fi
+ # Fish uses a separate install path: write to ~/.config/fish/completions/ so
+ # both `mole` and `mo` load completions independently on terminal startup.
+ if [[ "$current_shell" == "fish" ]]; then
+ fish_dir="${HOME}/.config/fish/completions"
+ mole_file="${fish_dir}/mole.fish"
+ mo_file="${fish_dir}/mo.fish"
+ config_fish="${HOME}/.config/fish/config.fish"
+
+ if [[ -z "$completion_name" ]]; then
+ # Clean up any stale config.fish entries even when mole is not in PATH
+ if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then
+ remove_stale_completion_entries "$config_fish" "Removed stale completion entries from config.fish" || true
+ fi
+ log_error "mole not found in PATH, install Mole before enabling completion"
+ exit 1
+ fi
+
+ if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
+ echo -e "${GRAY}${ICON_REVIEW} [DRY RUN] Would write Fish completions to:${NC}"
+ echo " $mole_file"
+ echo " $mo_file"
+ echo ""
+ echo -e "${GREEN}${ICON_SUCCESS}${NC} Dry run complete, no changes made"
+ exit 0
+ fi
+
+ # Remove stale config.fish source-based entries (previous install method)
+ if remove_stale_completion_entries "$config_fish" "Removed stale source-based entries from config.fish"; then
+ echo ""
+ fi
+
+ # Prompt only on first install; silently update if files exist
+ if [[ ! -f "$mole_file" ]]; then
+ echo ""
+ echo -e "${GRAY}Will write Fish completions to:${NC}"
+ echo " $mole_file"
+ echo " $mo_file"
+ echo ""
+ echo -ne "${PURPLE}${ICON_ARROW}${NC} Enable completion for ${GREEN}fish${NC}? ${GRAY}Enter confirm / Q cancel${NC}: "
+ IFS= read -r -s -n1 key || key=""
+ drain_pending_input
+ echo ""
+
+ case "$key" in
+ $'\e' | [Qq] | [Nn])
+ echo -e "${YELLOW}Cancelled${NC}"
+ exit 0
+ ;;
+ "" | $'\n' | $'\r' | [Yy]) ;;
+ *)
+ log_error "Invalid key"
+ exit 1
+ ;;
+ esac
+ fi
+
+ mkdir -p "$fish_dir"
+ "$completion_name" completion fish > "$mole_file"
+ # mo.fish sources mole.fish so Fish loads mo completions on `mo`
+ printf '# Mole completions for mo (alias) -- auto-generated, do not edit\n' > "$mo_file"
+ printf 'source %s\n' "$mole_file" >> "$mo_file"
+
+ if [[ -f "$mole_file" ]]; then
+ echo -e "${GREEN}${ICON_SUCCESS}${NC} Fish completions written to $fish_dir"
+ fi
+ echo ""
+ exit 0
+ fi
+
case "$current_shell" in
bash)
config_file="${HOME}/.bashrc"
@@ -83,11 +171,6 @@ if [[ $# -eq 0 ]]; then
# shellcheck disable=SC2016
completion_line='if output="$('"$completion_name"' completion zsh 2>/dev/null)"; then eval "$output"; fi'
;;
- fish)
- config_file="${HOME}/.config/fish/config.fish"
- # shellcheck disable=SC2016
- completion_line='set -l output ('"$completion_name"' completion fish 2>/dev/null); and echo "$output" | source'
- ;;
*)
log_error "Unsupported shell: $current_shell"
echo " mole completion "
diff --git a/Resources/mole/bin/installer.sh b/Resources/mole/bin/installer.sh
index 864404a..56d0af4 100755
--- a/Resources/mole/bin/installer.sh
+++ b/Resources/mole/bin/installer.sh
@@ -184,9 +184,19 @@ format_installer_display() {
truncated_name=$(truncate_by_display_width "$filename" "$available_width")
local current_width
current_width=$(get_display_width "$truncated_name")
- local char_count=${#truncated_name}
+
+ # Get byte count for printf width calculation
+ local old_lc="${LC_ALL:-}"
+ export LC_ALL=C
+ local byte_count=${#truncated_name}
+ if [[ -n "$old_lc" ]]; then
+ export LC_ALL="$old_lc"
+ else
+ unset LC_ALL
+ fi
+
local padding=$((available_width - current_width))
- local printf_width=$((char_count + padding))
+ local printf_width=$((byte_count + padding))
# Format: "filename size | source"
printf "%-*s %8s | %-10s" "$printf_width" "$truncated_name" "$size_str" "$source"
diff --git a/Resources/mole/bin/optimize.sh b/Resources/mole/bin/optimize.sh
index 1a24451..cbbf5f9 100755
--- a/Resources/mole/bin/optimize.sh
+++ b/Resources/mole/bin/optimize.sh
@@ -21,6 +21,7 @@ source "$SCRIPT_DIR/lib/optimize/maintenance.sh"
source "$SCRIPT_DIR/lib/optimize/tasks.sh"
source "$SCRIPT_DIR/lib/check/health_json.sh"
source "$SCRIPT_DIR/lib/check/all.sh"
+source "$SCRIPT_DIR/lib/check/dev_environment.sh"
source "$SCRIPT_DIR/lib/manage/whitelist.sh"
print_header() {
@@ -28,6 +29,60 @@ print_header() {
echo -e "${PURPLE_BOLD}Optimize and Check${NC}"
}
+# Bash-native JSON parsing helpers (no jq dependency).
+# Extract a simple numeric value from JSON by key.
+json_get_value() {
+ local json="$1"
+ local key="$2"
+ local value
+ value=$(echo "$json" | grep -o "\"${key}\"[[:space:]]*:[[:space:]]*[0-9.]*" | head -1 | sed 's/.*:[[:space:]]*//')
+ echo "${value:-0}"
+}
+
+# Validate JSON has expected structure (basic check).
+json_validate() {
+ local json="$1"
+ # Check for required keys
+ [[ "$json" == *'"memory_used_gb"'* ]] &&
+ [[ "$json" == *'"optimizations"'* ]] &&
+ [[ "$json" == *'{'* ]] && [[ "$json" == *'}'* ]]
+}
+
+# Parse optimization items from JSON array.
+# Outputs pipe-delimited records: action|name|description|safe
+# Single awk pass instead of per-item grep+sed to avoid subprocess overhead.
+parse_optimization_items() {
+ local json="$1"
+ awk '
+ function extract(line, key, pat, val, start, end) {
+ pat = "\"" key "\"[ \t]*:[ \t]*\""
+ if (match(line, pat)) {
+ start = RSTART + RLENGTH
+ val = substr(line, start)
+ # Find closing quote (skip escaped quotes)
+ end = 1
+ while (end <= length(val)) {
+ if (substr(val, end, 1) == "\"" && substr(val, end-1, 1) != "\\") break
+ end++
+ }
+ return substr(val, 1, end - 1)
+ }
+ return ""
+ }
+ /"optimizations".*\[/ { in_arr=1; next }
+ !in_arr { next }
+ /\]/ && !in_obj { exit }
+ /{/ { in_obj=1; action=""; name=""; desc=""; safe="" }
+ in_obj && /"action"/ { action = extract($0, "action") }
+ in_obj && /"name"/ { name = extract($0, "name") }
+ in_obj && /"description"/ { desc = extract($0, "description") }
+ in_obj && /"safe"/ {
+ val = $0; sub(/.*"safe"[[:space:]]*:[[:space:]]*/, "", val); sub(/[^a-z].*/, "", val); safe = val
+ }
+ /}/ { if (in_obj && action != "") print action "|" name "|" desc "|" safe; in_obj=0 }
+ ' <<< "$json"
+}
+
run_system_checks() {
# Skip checks in dry-run mode.
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
@@ -56,6 +111,8 @@ run_system_checks() {
check_all_config
echo ""
+ check_all_dev_environment
+
show_suggestions
if ask_for_updates; then
@@ -68,14 +125,13 @@ run_system_checks() {
show_optimization_summary() {
local safe_count="${OPTIMIZE_SAFE_COUNT:-0}"
- local confirm_count="${OPTIMIZE_CONFIRM_COUNT:-0}"
- if ((safe_count == 0 && confirm_count == 0)) && [[ -z "${AUTO_FIX_SUMMARY:-}" ]]; then
+ if ((safe_count == 0)) && [[ -z "${AUTO_FIX_SUMMARY:-}" ]]; then
return
fi
local summary_title
local -a summary_details=()
- local total_applied=$((safe_count + confirm_count))
+ local total_applied=$safe_count
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
summary_title="Dry Run Complete, No Changes Made"
@@ -139,12 +195,12 @@ show_optimization_summary() {
show_system_health() {
local health_json="$1"
- local mem_used=$(echo "$health_json" | jq -r '.memory_used_gb // 0' 2> /dev/null || echo "0")
- local mem_total=$(echo "$health_json" | jq -r '.memory_total_gb // 0' 2> /dev/null || echo "0")
- local disk_used=$(echo "$health_json" | jq -r '.disk_used_gb // 0' 2> /dev/null || echo "0")
- local disk_total=$(echo "$health_json" | jq -r '.disk_total_gb // 0' 2> /dev/null || echo "0")
- local disk_percent=$(echo "$health_json" | jq -r '.disk_used_percent // 0' 2> /dev/null || echo "0")
- local uptime=$(echo "$health_json" | jq -r '.uptime_days // 0' 2> /dev/null || echo "0")
+ local mem_used=$(json_get_value "$health_json" "memory_used_gb")
+ local mem_total=$(json_get_value "$health_json" "memory_total_gb")
+ local disk_used=$(json_get_value "$health_json" "disk_used_gb")
+ local disk_total=$(json_get_value "$health_json" "disk_total_gb")
+ local disk_percent=$(json_get_value "$health_json" "disk_used_percent")
+ local uptime=$(json_get_value "$health_json" "uptime_days")
mem_used=${mem_used:-0}
mem_total=${mem_total:-0}
@@ -157,11 +213,6 @@ show_system_health() {
"$mem_used" "$mem_total" "$disk_used" "$disk_total" "$uptime"
}
-parse_optimizations() {
- local health_json="$1"
- echo "$health_json" | jq -c '.optimizations[]' 2> /dev/null
-}
-
announce_action() {
local name="$1"
local desc="$2"
@@ -251,11 +302,8 @@ collect_security_fix_actions() {
SECURITY_FIXES+=("firewall|Enable macOS firewall")
fi
fi
- if [[ "${GATEKEEPER_DISABLED:-}" == "true" ]]; then
- if ! is_whitelisted "gatekeeper"; then
- SECURITY_FIXES+=("gatekeeper|Enable Gatekeeper, app download protection")
- fi
- fi
+ # Gatekeeper state is intentionally user-managed. Optimize may report it,
+ # but it must not change the user's "Anywhere" preference.
if touchid_supported && ! touchid_configured; then
if ! is_whitelisted "check_touchid"; then
SECURITY_FIXES+=("touchid|Enable Touch ID for sudo")
@@ -309,16 +357,6 @@ apply_firewall_fix() {
return 1
}
-apply_gatekeeper_fix() {
- if sudo spctl --master-enable 2> /dev/null; then
- echo -e " ${GREEN}${ICON_SUCCESS}${NC} Gatekeeper enabled"
- GATEKEEPER_DISABLED=false
- return 0
- fi
- echo -e " ${GRAY}${ICON_WARNING}${NC} Failed to enable Gatekeeper"
- return 1
-}
-
apply_touchid_fix() {
if "$SCRIPT_DIR/bin/touchid.sh" enable; then
return 0
@@ -339,9 +377,6 @@ perform_security_fixes() {
firewall)
apply_firewall_fix && ((applied++))
;;
- gatekeeper)
- apply_gatekeeper_fix && ((applied++))
- ;;
touchid)
apply_touchid_fix && ((applied++))
;;
@@ -406,12 +441,6 @@ main() {
echo -e "${YELLOW}${ICON_DRY_RUN} DRY RUN MODE${NC}, No files will be modified\n"
fi
- if ! command -v jq > /dev/null 2>&1; then
- echo -e "${YELLOW}${ICON_ERROR}${NC} Missing dependency: jq"
- echo -e "${GRAY}Install with: ${GREEN}brew install jq${NC}"
- exit 1
- fi
-
if ! command -v bc > /dev/null 2>&1; then
echo -e "${YELLOW}${ICON_ERROR}${NC} Missing dependency: bc"
echo -e "${GRAY}Install with: ${GREEN}brew install bc${NC}"
@@ -431,13 +460,13 @@ main() {
exit 1
fi
- if ! echo "$health_json" | jq empty 2> /dev/null; then
+ if ! json_validate "$health_json"; then
if [[ -t 1 ]]; then
stop_inline_spinner
fi
echo ""
log_error "Invalid system health data format"
- echo -e "${GRAY}${ICON_REVIEW}${NC} Check if jq, awk, sysctl, and df commands are available"
+ echo -e "${GRAY}${ICON_REVIEW}${NC} Check if awk, sysctl, and df commands are available"
exit 1
fi
@@ -459,28 +488,14 @@ main() {
fi
fi
- local -a safe_items=()
- local -a confirm_items=()
+ local -a items=()
local opts_file
opts_file=$(mktemp_file)
- parse_optimizations "$health_json" > "$opts_file"
-
- while IFS= read -r opt_json; do
- [[ -z "$opt_json" ]] && continue
-
- local name=$(echo "$opt_json" | jq -r '.name')
- local desc=$(echo "$opt_json" | jq -r '.description')
- local action=$(echo "$opt_json" | jq -r '.action')
- local path=$(echo "$opt_json" | jq -r '.path // ""')
- local safe=$(echo "$opt_json" | jq -r '.safe')
+ parse_optimization_items "$health_json" > "$opts_file"
- local item="${name}|${desc}|${action}|${path}"
-
- if [[ "$safe" == "true" ]]; then
- safe_items+=("$item")
- else
- confirm_items+=("$item")
- fi
+ while IFS='|' read -r action name desc safe; do
+ [[ -z "$action" ]] && continue
+ items+=("${name}|${desc}|${action}|")
done < "$opts_file"
echo ""
@@ -489,29 +504,18 @@ main() {
fi
export FIRST_ACTION=true
- if [[ ${#safe_items[@]} -gt 0 ]]; then
- for item in "${safe_items[@]}"; do
- IFS='|' read -r name desc action path <<< "$item"
- announce_action "$name" "$desc" "safe"
- execute_optimization "$action" "$path"
- done
- fi
-
- if [[ ${#confirm_items[@]} -gt 0 ]]; then
- for item in "${confirm_items[@]}"; do
- IFS='|' read -r name desc action path <<< "$item"
- announce_action "$name" "$desc" "confirm"
- execute_optimization "$action" "$path"
- done
- fi
+ for item in "${items[@]}"; do
+ IFS='|' read -r name desc action path <<< "$item"
+ announce_action "$name" "$desc" "safe"
+ execute_optimization "$action" "$path"
+ done
- local safe_count=${#safe_items[@]}
- local confirm_count=${#confirm_items[@]}
+ local safe_count=${#items[@]}
run_system_checks
export OPTIMIZE_SAFE_COUNT=$safe_count
- export OPTIMIZE_CONFIRM_COUNT=$confirm_count
+ export OPTIMIZE_CONFIRM_COUNT=0
show_optimization_summary
diff --git a/Resources/mole/bin/purge.sh b/Resources/mole/bin/purge.sh
index cd373bd..03a7dc9 100755
--- a/Resources/mole/bin/purge.sh
+++ b/Resources/mole/bin/purge.sh
@@ -40,6 +40,53 @@ note_activity() {
fi
}
+# Keep the most specific tail of a long purge path visible on the live scan line.
+compact_purge_scan_path() {
+ local path="$1"
+ local max_path_len="${2:-0}"
+
+ if ! [[ "$max_path_len" =~ ^[0-9]+$ ]] || [[ "$max_path_len" -lt 4 ]]; then
+ max_path_len=4
+ fi
+
+ if [[ ${#path} -le $max_path_len ]]; then
+ echo "$path"
+ return
+ fi
+
+ local suffix_len=$((max_path_len - 3))
+ local suffix="${path: -$suffix_len}"
+ local path_tail=""
+ local remainder="$path"
+
+ while [[ "$remainder" == */* ]]; do
+ local segment="/${remainder##*/}"
+ remainder="${remainder%/*}"
+
+ if [[ -z "$path_tail" ]]; then
+ if [[ ${#segment} -le $suffix_len ]]; then
+ path_tail="$segment"
+ else
+ break
+ fi
+ continue
+ fi
+
+ if [[ $((${#segment} + ${#path_tail})) -le $suffix_len ]]; then
+ path_tail="${segment}${path_tail}"
+ else
+ break
+ fi
+ done
+
+ if [[ -n "$path_tail" ]]; then
+ echo "...${path_tail}"
+ return
+ fi
+
+ echo "...$suffix"
+}
+
# Main purge function
start_purge() {
# Set current command for operation logging
@@ -127,24 +174,13 @@ perform_purge() {
# 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
- # Truncate path to guaranteed fit
- truncate_path() {
- local path="$1"
- if [[ ${#path} -le $max_path_len ]]; then
- echo "$path"
- return
- fi
- 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
current_path=$(cat "$stats_dir/purge_scanning" 2> /dev/null || echo "")
if [[ -n "$current_path" ]]; then
local display_path="${current_path/#$HOME/~}"
- display_path=$(truncate_path "$display_path")
+ display_path=$(compact_purge_scan_path "$display_path" "$max_path_len")
last_path="$display_path"
fi
@@ -291,4 +327,12 @@ main() {
show_cursor
}
+if [[ "${MOLE_SKIP_MAIN:-0}" == "1" ]]; then
+ if [[ "${BASH_SOURCE[0]}" != "$0" ]]; then
+ return 0
+ else
+ exit 0
+ fi
+fi
+
main "$@"
diff --git a/Resources/mole/bin/status-go b/Resources/mole/bin/status-go
index 290cabf..e3e369b 100755
Binary files a/Resources/mole/bin/status-go and b/Resources/mole/bin/status-go differ
diff --git a/Resources/mole/bin/touchid.sh b/Resources/mole/bin/touchid.sh
index 76b5cc2..9459e7f 100755
--- a/Resources/mole/bin/touchid.sh
+++ b/Resources/mole/bin/touchid.sh
@@ -292,7 +292,7 @@ show_menu() {
echo ""
case "$key" in
- $'\e') # ESC
+ $'\e' | q | Q) # ESC or Q
return 0
;;
"" | $'\n' | $'\r') # Enter
@@ -310,7 +310,7 @@ show_menu() {
drain_pending_input # Clean up any escape sequence remnants
case "$key" in
- $'\e') # ESC
+ $'\e' | q | Q) # ESC or Q
return 0
;;
"" | $'\n' | $'\r') # Enter
diff --git a/Resources/mole/bin/uninstall.sh b/Resources/mole/bin/uninstall.sh
index 5c96661..6f50544 100755
--- a/Resources/mole/bin/uninstall.sh
+++ b/Resources/mole/bin/uninstall.sh
@@ -135,6 +135,16 @@ uninstall_resolve_display_name() {
if [[ "$display_name" == /* ]]; then
display_name="$app_name"
fi
+
+ # Keep versioned bundle names when metadata collapses distinct installs.
+ if [[ -n "$display_name" && "$app_name" == "$display_name"* && "$app_name" != "$display_name" ]]; then
+ local suffix
+ suffix="${app_name#"$display_name"}"
+ if [[ "$suffix" == *[0-9]* ]]; then
+ display_name="$app_name"
+ fi
+ fi
+
display_name="${display_name%.app}"
display_name="${display_name//|/-}"
display_name="${display_name//[$'\t\r\n']/}"
@@ -176,6 +186,31 @@ uninstall_release_metadata_lock() {
[[ -d "$lock_dir" ]] && rmdir "$lock_dir" 2> /dev/null || true
}
+# Atomically replace the metadata cache file, healing stale root-owned copies.
+# stdin is closed so BSD mv/cp never blocks prompting on a non-writable target.
+uninstall_persist_cache_file() {
+ local src="$1"
+ local dst="$2"
+
+ [[ -s "$src" ]] || {
+ rm -f "$src" 2> /dev/null || true
+ return 0
+ }
+
+ # Heal stale file the user cannot write to (e.g. root-owned from a prior
+ # sudo run). The parent dir is user-owned, so rm succeeds regardless.
+ if [[ -e "$dst" && ! -w "$dst" ]]; then
+ rm -f "$dst" 2> /dev/null || true
+ fi
+
+ # shellcheck disable=SC2217 # BSD mv/cp read stdin when prompting; close it to avoid hang.
+ mv -f "$src" "$dst" < /dev/null 2> /dev/null || {
+ # shellcheck disable=SC2217
+ cp -f "$src" "$dst" < /dev/null 2> /dev/null || true
+ rm -f "$src" 2> /dev/null || true
+ }
+}
+
uninstall_collect_inline_metadata() {
local app_path="$1"
local app_mtime="${2:-0}"
@@ -322,10 +357,7 @@ start_uninstall_metadata_refresh() {
}
' "$updates_file" "$MOLE_UNINSTALL_META_CACHE_FILE" > "$merged_file"
- mv "$merged_file" "$MOLE_UNINSTALL_META_CACHE_FILE" 2> /dev/null || {
- cp "$merged_file" "$MOLE_UNINSTALL_META_CACHE_FILE" 2> /dev/null || true
- rm -f "$merged_file"
- }
+ uninstall_persist_cache_file "$merged_file" "$MOLE_UNINSTALL_META_CACHE_FILE"
uninstall_release_metadata_lock "$MOLE_UNINSTALL_META_CACHE_LOCK"
rm -f "$updates_file"
@@ -469,12 +501,11 @@ scan_applications() {
while IFS= read -r -d '' app_path; do
if [[ ! -e "$app_path" ]]; then continue; fi
- local app_name
- app_name=$(basename "$app_path" .app)
+ local app_name="${app_path##*/}"
+ app_name="${app_name%.app}"
# Skip nested apps inside another .app bundle.
- local parent_dir
- parent_dir=$(dirname "$app_path")
+ local parent_dir="${app_path%/*}"
if [[ "$parent_dir" == *".app" || "$parent_dir" == *".app/"* ]]; then
continue
fi
@@ -485,9 +516,10 @@ scan_applications() {
if [[ -n "$link_target" ]]; then
local resolved_target="$link_target"
if [[ "$link_target" != /* ]]; then
- local link_dir
- link_dir=$(dirname "$app_path")
- resolved_target=$(cd "$link_dir" 2> /dev/null && cd "$(dirname "$link_target")" 2> /dev/null && pwd)/$(basename "$link_target") 2> /dev/null || echo ""
+ local link_dir="${app_path%/*}"
+ local _link_parent="${link_target%/*}"
+ [[ "$_link_parent" == "$link_target" ]] && _link_parent="."
+ resolved_target=$(cd "$link_dir" 2> /dev/null && cd "$_link_parent" 2> /dev/null && pwd)/"${link_target##*/}" 2> /dev/null || echo ""
fi
case "$resolved_target" in
/System/* | /usr/bin/* | /usr/lib/* | /bin/* | /sbin/* | /private/etc/*)
@@ -547,6 +579,15 @@ scan_applications() {
return 0
fi
+ local plist="$app_path/Contents/Info.plist"
+ if [[ -f "$plist" ]]; then
+ local bg_only
+ bg_only=$(defaults read "$plist" LSBackgroundOnly 2> /dev/null || echo "")
+ if [[ "$bg_only" == "1" || "$bg_only" == "YES" || "$bg_only" == "true" ]]; then
+ return 0
+ fi
+ fi
+
local display_name="${cached_display_name:-}"
if [[ -z "$display_name" ]]; then
display_name=$(uninstall_resolve_display_name "$app_path" "$app_name")
@@ -636,6 +677,8 @@ scan_applications() {
local current_epoch
current_epoch=$(get_epoch_seconds)
local inline_metadata_count=0
+ local inline_metadata_effective_limit=$MOLE_UNINSTALL_INLINE_METADATA_LIMIT
+ [[ $cache_source_is_temp == true ]] && inline_metadata_effective_limit=99999
local metadata_total=0
metadata_total=$(wc -l < "$merged_file" 2> /dev/null || echo "0")
[[ "$metadata_total" =~ ^[0-9]+$ ]] || metadata_total=0
@@ -661,7 +704,7 @@ scan_applications() {
fi
local final_size_kb=0
- local final_size="N/A"
+ local final_size="--"
if [[ "$cached_size_kb" =~ ^[0-9]+$ && $cached_size_kb -gt 0 ]]; then
final_size_kb="$cached_size_kb"
final_size=$(bytes_to_human "$((cached_size_kb * 1024))")
@@ -696,7 +739,7 @@ scan_applications() {
fi
if [[ $needs_refresh == true ]]; then
- if [[ $inline_metadata_count -lt $MOLE_UNINSTALL_INLINE_METADATA_LIMIT ]]; then
+ if [[ $inline_metadata_count -lt $inline_metadata_effective_limit ]]; then
local inline_metadata inline_size_kb inline_epoch inline_updated_epoch
inline_metadata=$(uninstall_collect_inline_metadata "$app_path" "${app_mtime:-0}" "$current_epoch")
IFS='|' read -r inline_size_kb inline_epoch inline_updated_epoch <<< "$inline_metadata"
@@ -729,10 +772,7 @@ scan_applications() {
update_scan_status "Updating cache..." "0" "0"
if [[ -s "$cache_snapshot_file" ]]; then
if uninstall_acquire_metadata_lock "$MOLE_UNINSTALL_META_CACHE_LOCK"; then
- mv "$cache_snapshot_file" "$MOLE_UNINSTALL_META_CACHE_FILE" 2> /dev/null || {
- cp "$cache_snapshot_file" "$MOLE_UNINSTALL_META_CACHE_FILE" 2> /dev/null || true
- rm -f "$cache_snapshot_file"
- }
+ uninstall_persist_cache_file "$cache_snapshot_file" "$MOLE_UNINSTALL_META_CACHE_FILE"
uninstall_release_metadata_lock "$MOLE_UNINSTALL_META_CACHE_LOCK"
fi
fi
@@ -808,12 +848,226 @@ cleanup() {
trap cleanup EXIT INT TERM
+# Match app names from scan data against user-provided search terms.
+# Performs case-insensitive substring matching on app display names.
+# Returns matched entries from apps_data in selected_apps.
+match_apps_by_name() {
+ local -a search_terms=("$@")
+ selected_apps=()
+ local -a matched_indices=()
+
+ for search_term in "${search_terms[@]}"; do
+ local search_lower
+ search_lower=$(echo "$search_term" | tr '[:upper:]' '[:lower:]')
+ # Escape glob characters to prevent pattern injection
+ search_lower=${search_lower//\\/\\\\}
+ search_lower=${search_lower//\*/\\*}
+ search_lower=${search_lower//\?/\\?}
+ search_lower=${search_lower//\[/\\[}
+ local found=false
+ local idx=0
+ for app_data in "${apps_data[@]}"; do
+ IFS='|' read -r epoch app_path app_name bundle_id size last_used size_kb <<< "$app_data"
+ local name_lower
+ name_lower=$(echo "$app_name" | tr '[:upper:]' '[:lower:]')
+ # Also try matching against the .app directory base name
+ local dir_name
+ dir_name=$(basename "$app_path" .app)
+ local dir_lower
+ dir_lower=$(echo "$dir_name" | tr '[:upper:]' '[:lower:]')
+
+ if [[ "$name_lower" == "$search_lower" || "$dir_lower" == "$search_lower" ]]; then
+ # Exact match - prefer this
+ local already=false
+ local mi
+ for mi in "${matched_indices[@]+"${matched_indices[@]}"}"; do
+ [[ -z "$mi" ]] && continue
+ [[ "$mi" == "$idx" ]] && already=true && break
+ done
+ if [[ "$already" == "false" ]]; then
+ selected_apps+=("$app_data")
+ matched_indices+=("$idx")
+ fi
+ found=true
+ break
+ fi
+ idx=$((idx + 1))
+ done
+
+ # If no exact match, try substring match
+ if [[ "$found" == "false" ]]; then
+ idx=0
+ for app_data in "${apps_data[@]}"; do
+ IFS='|' read -r epoch app_path app_name bundle_id size last_used size_kb <<< "$app_data"
+ local name_lower
+ name_lower=$(echo "$app_name" | tr '[:upper:]' '[:lower:]')
+ local dir_name
+ dir_name=$(basename "$app_path" .app)
+ local dir_lower
+ dir_lower=$(echo "$dir_name" | tr '[:upper:]' '[:lower:]')
+
+ if [[ "$name_lower" == *"$search_lower"* || "$dir_lower" == *"$search_lower"* ]]; then
+ local already=false
+ local mi
+ for mi in "${matched_indices[@]+"${matched_indices[@]}"}"; do
+ [[ -z "$mi" ]] && continue
+ [[ "$mi" == "$idx" ]] && already=true && break
+ done
+ if [[ "$already" == "false" ]]; then
+ selected_apps+=("$app_data")
+ matched_indices+=("$idx")
+ fi
+ found=true
+ fi
+ idx=$((idx + 1))
+ done
+ fi
+
+ if [[ "$found" == "false" ]]; then
+ echo -e "${YELLOW}Warning:${NC} No application found matching '$search_term'"
+ fi
+ done
+}
+
+# Escape a value for embedding in a single-line JSON string. Only handles
+# the chars that would break a one-line value: backslash, quote, and C0
+# whitespace. Bundle IDs / display names never contain control bytes worth
+# preserving in this output.
+uninstall_list_json_escape() {
+ local s="$1"
+ s="${s//\\/\\\\}"
+ s="${s//\"/\\\"}"
+ s="${s//$'\t'/ }"
+ s="${s//$'\r'/ }"
+ s="${s//$'\n'/ }"
+ printf '%s' "$s"
+}
+
+# Read-only listing: surface each installed app's display name, bundle id,
+# the exact name `mo uninstall` accepts, and human-readable size. Reuses the
+# existing scanner so the output stays in lockstep with what the destructive
+# path sees.
+uninstall_list_apps() {
+ local apps_file=""
+ if ! apps_file=$(scan_applications); then
+ return 1
+ fi
+ if [[ ! -f "$apps_file" ]]; then
+ return 1
+ fi
+ if ! load_applications "$apps_file"; then
+ rm -f "$apps_file"
+ return 1
+ fi
+ rm -f "$apps_file"
+
+ # Auto-switch to JSON when stdout is piped, matching `mo status`.
+ local format="text"
+ if [[ ! -t 1 ]]; then
+ format="json"
+ fi
+
+ if [[ "$format" == "json" ]]; then
+ printf '['
+ local first=1
+ local app_data
+ for app_data in "${apps_data[@]+"${apps_data[@]}"}"; do
+ IFS='|' read -r _ app_path app_name bundle_id size _ _ <<< "$app_data"
+ local cask=""
+ if is_homebrew_available; then
+ cask=$(get_brew_cask_name "$app_path" 2> /dev/null || true)
+ fi
+ local uninstall_name="${cask:-$app_name}"
+ local source_label="App"
+ [[ -n "$cask" ]] && source_label="Homebrew"
+ local size_display
+ size_display=$(uninstall_normalize_size_display "$size")
+ if [[ $first -eq 1 ]]; then
+ first=0
+ printf '\n'
+ else
+ printf ',\n'
+ fi
+ printf ' {"name": "%s", "bundle_id": "%s", "source": "%s", "uninstall_name": "%s", "path": "%s", "size": "%s"}' \
+ "$(uninstall_list_json_escape "$app_name")" \
+ "$(uninstall_list_json_escape "$bundle_id")" \
+ "$source_label" \
+ "$(uninstall_list_json_escape "$uninstall_name")" \
+ "$(uninstall_list_json_escape "$app_path")" \
+ "$(uninstall_list_json_escape "$size_display")"
+ done
+ if [[ $first -eq 0 ]]; then
+ printf '\n'
+ fi
+ printf ']\n'
+ return 0
+ fi
+
+ local total=${#apps_data[@]}
+ if [[ $total -eq 0 ]]; then
+ echo "No applications found."
+ return 0
+ fi
+
+ printf '\n'
+ printf '%-36s %-30s %-30s %8s\n' 'NAME' 'BUNDLE ID' 'UNINSTALL NAME' 'SIZE'
+ printf -- '-%.0s' $(seq 1 108)
+ printf '\n'
+
+ local app_data
+ for app_data in "${apps_data[@]+"${apps_data[@]}"}"; do
+ IFS='|' read -r _ app_path app_name bundle_id size _ _ <<< "$app_data"
+ local cask=""
+ if is_homebrew_available; then
+ cask=$(get_brew_cask_name "$app_path" 2> /dev/null || true)
+ fi
+ local uninstall_name="${cask:-$app_name}"
+ local size_display
+ size_display=$(uninstall_normalize_size_display "$size")
+
+ # Truncate by display columns, then adjust printf width for CJK.
+ # printf counts bytes (LC_ALL=C), but CJK chars are 3 bytes yet only
+ # 2 display columns wide, so we pad with the extra bytes to land on
+ # the correct visual column.
+ local name_trunc name_display_w name_byte_count name_printf_w
+ name_trunc=$(truncate_by_display_width "$app_name" 34)
+ name_display_w=$(get_display_width "$name_trunc")
+
+ # Get byte count in C locale for printf
+ local old_lc="${LC_ALL:-}"
+ export LC_ALL=C
+ name_byte_count=${#name_trunc}
+ if [[ -n "$old_lc" ]]; then
+ export LC_ALL="$old_lc"
+ else
+ unset LC_ALL
+ fi
+
+ name_printf_w=$((36 + name_byte_count - name_display_w))
+
+ printf "%-*s %-30s %-30s %8s\n" \
+ "$name_printf_w" "$name_trunc" \
+ "${bundle_id:0:28}" \
+ "${uninstall_name:0:28}" \
+ "$size_display"
+ done
+
+ printf '\n%d application(s) | Remove with: mo uninstall \n\n' "$total"
+ return 0
+}
+
main() {
# Set current command for operation logging
export MOLE_CURRENT_COMMAND="uninstall"
log_operation_session_start "uninstall"
- # Global flags
+ # Default to Trash routing so an accidental uninstall is recoverable.
+ # The caller can opt back into rm -rf with --permanent. See #723.
+ export MOLE_DELETE_MODE="${MOLE_DELETE_MODE:-trash}"
+
+ # Parse flags and collect app name arguments
+ local -a app_name_args=()
+ local list_mode=0
for arg in "$@"; do
case "$arg" in
"--help" | "-h")
@@ -826,6 +1080,12 @@ main() {
"--dry-run" | "-n")
export MOLE_DRY_RUN=1
;;
+ "--permanent")
+ export MOLE_DELETE_MODE="permanent"
+ ;;
+ "--list")
+ list_mode=1
+ ;;
"--whitelist")
echo "Unknown uninstall option: $arg"
echo "Whitelist management is currently supported by: mo clean --whitelist / mo optimize --whitelist"
@@ -838,19 +1098,78 @@ main() {
exit 1
;;
*)
- echo "Unknown uninstall argument: $arg"
- echo "Use 'mo uninstall --help' for supported options."
- exit 1
+ app_name_args+=("$arg")
;;
esac
done
+ # --list short-circuits before any destructive code. Read-only path:
+ # scan, resolve uninstall names, print table or JSON, exit 0.
+ if [[ $list_mode -eq 1 ]]; then
+ uninstall_list_apps
+ return $?
+ fi
+
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
+ # Direct uninstall by app name
+ if [[ ${#app_name_args[@]} -gt 0 ]]; then
+ local apps_file=""
+ if ! apps_file=$(scan_applications); then
+ show_cursor
+ return 1
+ fi
+ if [[ ! -f "$apps_file" ]]; then
+ show_cursor
+ return 1
+ fi
+ if ! load_applications "$apps_file"; then
+ rm -f "$apps_file"
+ show_cursor
+ return 1
+ fi
+
+ match_apps_by_name "${app_name_args[@]}"
+ rm -f "$apps_file"
+
+ if [[ ${#selected_apps[@]} -eq 0 ]]; then
+ show_cursor
+ echo "No matching applications found."
+ return 1
+ fi
+
+ show_cursor
+ clear_screen
+ local selection_count=${#selected_apps[@]}
+ echo -e "${BLUE}${ICON_CONFIRM}${NC} Matched ${selection_count} app(s):"
+ local index=1
+ for selected_app in "${selected_apps[@]}"; do
+ IFS='|' read -r _ app_path app_name _ size last_used _ <<< "$selected_app"
+ local size_display
+ size_display=$(uninstall_normalize_size_display "$size")
+ local last_display
+ last_display=$(uninstall_normalize_last_used_display "$last_used")
+ printf "%d. %s %s | Last: %s\n" "$index" "$app_name" "$size_display" "$last_display"
+ ((index++))
+ done
+
+ printf '\n'
+ printf "Proceed with uninstallation? [y/N] "
+ local confirm
+ read -r confirm
+ if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then
+ echo "Aborted."
+ return 0
+ fi
+
+ batch_uninstall_applications
+ return 0
+ fi
+
local first_scan=true
while true; do
unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN
@@ -874,6 +1193,11 @@ main() {
return 1
fi
+ # Keystrokes typed during the scan/load phase must not leak into the
+ # selector. A queued Enter would confirm whichever app is highlighted
+ # first and drop the user straight into the destructive path. See #726.
+ drain_pending_input
+
set +e
select_apps_for_uninstall
local exit_code=$?
@@ -962,9 +1286,19 @@ main() {
IFS='|' read -r name_cell size_cell last_cell <<< "$row"
local name_display_width
name_display_width=$(get_display_width "$name_cell")
- local name_char_count=${#name_cell}
+
+ # Get byte count for printf width calculation
+ local old_lc="${LC_ALL:-}"
+ export LC_ALL=C
+ local name_byte_count=${#name_cell}
+ if [[ -n "$old_lc" ]]; then
+ export LC_ALL="$old_lc"
+ else
+ unset LC_ALL
+ fi
+
local padding_needed=$((max_name_display_width - name_display_width))
- local printf_name_width=$((name_char_count + padding_needed))
+ local printf_name_width=$((name_byte_count + padding_needed))
printf "%d. %-*s %*s | Last: %s\n" "$index" "$printf_name_width" "$name_cell" "$max_size_width" "$size_cell" "$last_cell"
((index++))
@@ -974,22 +1308,21 @@ main() {
rm -f "$apps_file"
- local prompt_timeout="${MOLE_UNINSTALL_RETURN_PROMPT_TIMEOUT_SEC:-3}"
- if [[ ! "$prompt_timeout" =~ ^[0-9]+$ ]] || [[ "$prompt_timeout" -lt 1 ]]; then
- prompt_timeout=3
- fi
-
- echo -e "${GRAY}Press Enter to return to the app list, press any other key or wait ${prompt_timeout}s to exit.${NC}"
- local key
- local read_ok=false
- if IFS= read -r -s -n1 -t "$prompt_timeout" key; then
- read_ok=true
- else
- key=""
- fi
+ local _countdown=5
+ local _key=""
+ local _pressed=false
+ while [[ $_countdown -gt 0 ]]; do
+ printf "\r${GRAY}Press Enter to return to the app list, press q to exit (%d)${NC} " "$_countdown"
+ if IFS= read -r -s -n1 -t 1 _key; then
+ _pressed=true
+ break
+ fi
+ ((_countdown--))
+ done
+ printf "\n"
drain_pending_input
- if [[ "$read_ok" == "true" && -z "$key" ]]; then
+ if [[ "$_pressed" == "true" && -z "$_key" ]]; then
:
else
show_cursor
diff --git a/Resources/mole/cmd/analyze/analyze_test.go b/Resources/mole/cmd/analyze/analyze_test.go
index 7a2fecd..28ceb8b 100644
--- a/Resources/mole/cmd/analyze/analyze_test.go
+++ b/Resources/mole/cmd/analyze/analyze_test.go
@@ -1,3 +1,5 @@
+//go:build darwin
+
package main
import (
@@ -5,10 +7,13 @@ import (
"fmt"
"os"
"path/filepath"
+ "strconv"
"strings"
"sync/atomic"
"testing"
"time"
+
+ tea "github.com/charmbracelet/bubbletea"
)
func resetOverviewSnapshotForTest() {
@@ -18,6 +23,30 @@ func resetOverviewSnapshotForTest() {
overviewSnapshotMu.Unlock()
}
+func runScanResultCmd(t *testing.T, cmd tea.Cmd) scanResultMsg {
+ t.Helper()
+
+ msg := cmd()
+ switch typed := msg.(type) {
+ case scanResultMsg:
+ return typed
+ case tea.BatchMsg:
+ for _, batchCmd := range typed {
+ if batchCmd == nil {
+ continue
+ }
+ if scanMsg, ok := batchCmd().(scanResultMsg); ok {
+ return scanMsg
+ }
+ }
+ t.Fatalf("expected tea.BatchMsg to contain a scanResultMsg, got %T", msg)
+ default:
+ t.Fatalf("expected scanResultMsg or tea.BatchMsg, got %T", msg)
+ }
+
+ return scanResultMsg{}
+}
+
func TestScanPathConcurrentBasic(t *testing.T) {
root := t.TempDir()
@@ -109,7 +138,7 @@ func TestPerformScanForJSONCountsTopLevelFiles(t *testing.T) {
t.Fatalf("write nested file: %v", err)
}
- result := performScanForJSON(root)
+ result := performScanForJSON(root, false)
if result.TotalFiles != 2 {
t.Fatalf("expected 2 files in JSON output, got %d", result.TotalFiles)
@@ -180,6 +209,97 @@ func TestOverviewStoreAndLoad(t *testing.T) {
}
}
+func TestUpdateKeyEscGoesBackFromDirectoryView(t *testing.T) {
+ m := model{
+ path: "/tmp/child",
+ history: []historyEntry{
+ {
+ Path: "/tmp",
+ Entries: []dirEntry{{Name: "child", Path: "/tmp/child", Size: 1, IsDir: true}},
+ TotalSize: 1,
+ Selected: 0,
+ EntryOffset: 0,
+ },
+ },
+ entries: []dirEntry{{Name: "file.txt", Path: "/tmp/child/file.txt", Size: 1}},
+ }
+
+ updated, cmd := m.updateKey(tea.KeyMsg{Type: tea.KeyEsc})
+ if cmd != nil {
+ t.Fatalf("expected no command when returning from cached history, got %v", cmd)
+ }
+
+ got, ok := updated.(model)
+ if !ok {
+ t.Fatalf("expected model, got %T", updated)
+ }
+ if got.path != "/tmp" {
+ t.Fatalf("expected path /tmp after Esc, got %s", got.path)
+ }
+ if got.status == "" {
+ t.Fatalf("expected status to be updated after Esc navigation")
+ }
+}
+
+func TestUpdateKeyCtrlCQuits(t *testing.T) {
+ m := model{}
+
+ _, cmd := m.updateKey(tea.KeyMsg{Type: tea.KeyCtrlC})
+ if cmd == nil {
+ t.Fatalf("expected quit command for Ctrl+C")
+ }
+ if _, ok := cmd().(tea.QuitMsg); !ok {
+ t.Fatalf("expected tea.QuitMsg from quit command")
+ }
+}
+
+func TestViewShowsEscBackAndCtrlCQuitHints(t *testing.T) {
+ m := model{
+ path: "/tmp/project",
+ history: []historyEntry{{Path: "/tmp"}},
+ entries: []dirEntry{{Name: "cache", Path: "/tmp/project/cache", Size: 1, IsDir: true}},
+ largeFiles: []fileEntry{{Name: "large.bin", Path: "/tmp/project/large.bin", Size: 1024}},
+ totalSize: 1024,
+ }
+
+ view := m.View()
+ if !strings.Contains(view, "Esc Back") {
+ t.Fatalf("expected Esc Back hint in view, got:\n%s", view)
+ }
+ if !strings.Contains(view, "Ctrl+C Quit") {
+ t.Fatalf("expected Ctrl+C Quit hint in view, got:\n%s", view)
+ }
+}
+
+func TestOverviewViewShowsFreeSpaceLabel(t *testing.T) {
+ m := model{
+ path: "/",
+ isOverview: true,
+ diskFree: 123_400_000,
+ entries: []dirEntry{{Name: "Home", Path: "/tmp/home", Size: 1, IsDir: true}},
+ }
+
+ view := m.View()
+ want := fmt.Sprintf("(%s free)", humanizeBytes(m.diskFree))
+ if !strings.Contains(view, want) {
+ t.Fatalf("expected free-space label %q in overview view, got:\n%s", want, view)
+ }
+}
+
+func TestOverviewViewOmitsFreeSpaceLabelWhenUnknown(t *testing.T) {
+ m := model{
+ path: "/",
+ isOverview: true,
+ diskFree: 0,
+ entries: []dirEntry{{Name: "Home", Path: "/tmp/home", Size: 1, IsDir: true}},
+ }
+
+ view := m.View()
+ if strings.Contains(view, "free)") {
+ t.Fatalf("expected overview view to omit free-space label when unavailable, got:\n%s", view)
+ }
+}
+
func TestCacheSaveLoadRoundTrip(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
@@ -218,6 +338,401 @@ func TestCacheSaveLoadRoundTrip(t *testing.T) {
}
}
+func TestScanPathConcurrentWarmsChildDirectoryCache(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+
+ root := filepath.Join(home, "root")
+ child := filepath.Join(root, "child")
+ if err := os.MkdirAll(child, 0o755); err != nil {
+ t.Fatalf("create child: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(root, "root.txt"), []byte("root-data"), 0o644); err != nil {
+ t.Fatalf("write root data: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(child, "data.bin"), []byte(strings.Repeat("x", 4096)), 0o644); err != nil {
+ t.Fatalf("write child data: %v", err)
+ }
+
+ var filesScanned, dirsScanned, bytesScanned int64
+ current := &atomic.Value{}
+ current.Store("")
+
+ if _, err := scanPathConcurrent(root, &filesScanned, &dirsScanned, &bytesScanned, current); err != nil {
+ t.Fatalf("scanPathConcurrent(root): %v", err)
+ }
+
+ cached, err := loadCacheFromDisk(child)
+ if err != nil {
+ t.Fatalf("expected warmed child cache, got error: %v", err)
+ }
+ if cached.TotalSize <= 0 {
+ t.Fatalf("expected positive cached child size, got %d", cached.TotalSize)
+ }
+ if len(cached.Entries) == 0 {
+ t.Fatalf("expected cached child entries to be populated")
+ }
+ if cached.TotalFiles != 1 {
+ t.Fatalf("expected warmed child cache to track local file count 1, got %d", cached.TotalFiles)
+ }
+ if !cached.NeedsRefresh {
+ t.Fatalf("expected warmed child cache to be marked for refresh")
+ }
+}
+
+func TestScanPathConcurrentUsesChildCacheLargeFiles(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+
+ root := filepath.Join(home, "root")
+ child := filepath.Join(root, "child")
+ if err := os.MkdirAll(child, 0o755); err != nil {
+ t.Fatalf("create child: %v", err)
+ }
+
+ largeFile := filepath.Join(child, "large.bin")
+ if err := os.WriteFile(largeFile, []byte(strings.Repeat("x", 2<<20)), 0o644); err != nil {
+ t.Fatalf("write large file: %v", err)
+ }
+
+ var childFiles, childDirs, childBytes int64
+ childCurrent := &atomic.Value{}
+ childCurrent.Store("")
+ childResult, err := scanPathConcurrent(child, &childFiles, &childDirs, &childBytes, childCurrent)
+ if err != nil {
+ t.Fatalf("scanPathConcurrent(child): %v", err)
+ }
+ if err := saveCacheToDisk(child, childResult); err != nil {
+ t.Fatalf("saveCacheToDisk(child): %v", err)
+ }
+
+ if err := os.Chmod(child, 0o000); err != nil {
+ t.Fatalf("chmod child unreadable: %v", err)
+ }
+ defer func() {
+ _ = os.Chmod(child, 0o755)
+ }()
+
+ var filesScanned, dirsScanned, bytesScanned int64
+ current := &atomic.Value{}
+ current.Store("")
+
+ result, err := scanPathConcurrent(root, &filesScanned, &dirsScanned, &bytesScanned, current)
+ if err != nil {
+ t.Fatalf("scanPathConcurrent(root): %v", err)
+ }
+
+ foundChild := false
+ for _, entry := range result.Entries {
+ if entry.Path == child {
+ foundChild = true
+ if entry.Size != childResult.TotalSize {
+ t.Fatalf("cached child size mismatch: want %d, got %d", childResult.TotalSize, entry.Size)
+ }
+ break
+ }
+ }
+ if !foundChild {
+ t.Fatalf("expected cached child directory in root entries")
+ }
+
+ foundLargeFile := false
+ for _, file := range result.LargeFiles {
+ if file.Path == largeFile {
+ foundLargeFile = true
+ break
+ }
+ }
+ if !foundLargeFile {
+ t.Fatalf("expected root large files to include cached child large file")
+ }
+}
+
+func TestScanPathConcurrentWarmsChildCachesWithoutRecursiveSpotlight(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+
+ root := filepath.Join(home, "root")
+ childOne := filepath.Join(root, "child-one")
+ childTwo := filepath.Join(root, "child-two")
+ for _, dir := range []string{childOne, childTwo} {
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatalf("create dir %s: %v", dir, err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "data.bin"), []byte(strings.Repeat("x", 4096)), 0o644); err != nil {
+ t.Fatalf("write data in %s: %v", dir, err)
+ }
+ }
+
+ logPath := filepath.Join(home, "mdfind.log")
+ stubDir := filepath.Join(home, "bin")
+ if err := os.MkdirAll(stubDir, 0o755); err != nil {
+ t.Fatalf("create stub dir: %v", err)
+ }
+ stubPath := filepath.Join(stubDir, "mdfind")
+ stubScript := fmt.Sprintf("#!/bin/sh\necho \"$*\" >> %s\nexit 0\n", strconv.Quote(logPath))
+ if err := os.WriteFile(stubPath, []byte(stubScript), 0o755); err != nil {
+ t.Fatalf("write mdfind stub: %v", err)
+ }
+ t.Setenv("PATH", stubDir+string(os.PathListSeparator)+os.Getenv("PATH"))
+
+ var filesScanned, dirsScanned, bytesScanned int64
+ current := &atomic.Value{}
+ current.Store("")
+
+ if _, err := scanPathConcurrent(root, &filesScanned, &dirsScanned, &bytesScanned, current); err != nil {
+ t.Fatalf("scanPathConcurrent(root): %v", err)
+ }
+
+ data, err := os.ReadFile(logPath)
+ if err != nil {
+ t.Fatalf("read mdfind log: %v", err)
+ }
+ lines := strings.Split(strings.TrimSpace(string(data)), "\n")
+ if len(lines) != 1 {
+ t.Fatalf("expected only root spotlight invocation, got %d lines: %q", len(lines), string(data))
+ }
+}
+
+func TestScanCmdTreatsWarmedCacheAsStale(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+
+ target := filepath.Join(home, "target")
+ if err := os.MkdirAll(target, 0o755); err != nil {
+ t.Fatalf("create target: %v", err)
+ }
+
+ result := scanResult{
+ Entries: []dirEntry{{Name: "child", Path: filepath.Join(target, "child"), Size: 1, IsDir: true}},
+ LargeFiles: []fileEntry{{Name: "big.bin", Path: filepath.Join(target, "big.bin"), Size: 2 << 20}},
+ TotalSize: 42,
+ TotalFiles: 1,
+ }
+ if err := saveCacheToDiskWithOptions(target, result, true); err != nil {
+ t.Fatalf("saveCacheToDiskWithOptions: %v", err)
+ }
+
+ m := newModel(target, false)
+ msg := m.scanCmd(target)()
+ scanMsg, ok := msg.(scanResultMsg)
+ if !ok {
+ t.Fatalf("expected scanResultMsg, got %T", msg)
+ }
+ if !scanMsg.stale {
+ t.Fatalf("expected warmed cache to trigger stale refresh path")
+ }
+ if scanMsg.result.TotalFiles != result.TotalFiles {
+ t.Fatalf("expected cached result to survive stale load, got %d", scanMsg.result.TotalFiles)
+ }
+}
+
+func TestEnterSelectedDirRefreshesStaleInMemoryCache(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+
+ parent := filepath.Join(home, "parent")
+ child := filepath.Join(parent, "child")
+ if err := os.MkdirAll(child, 0o755); err != nil {
+ t.Fatalf("create child: %v", err)
+ }
+
+ freshPath := filepath.Join(child, "fresh.bin")
+ if err := os.WriteFile(freshPath, []byte("fresh-data"), 0o644); err != nil {
+ t.Fatalf("write fresh file: %v", err)
+ }
+ freshInfo, err := os.Stat(freshPath)
+ if err != nil {
+ t.Fatalf("stat fresh file: %v", err)
+ }
+ freshSize := getActualFileSize(freshPath, freshInfo)
+
+ warmed := scanResult{
+ Entries: []dirEntry{{Name: "stale.bin", Path: filepath.Join(child, "stale.bin"), Size: 1}},
+ TotalSize: 1,
+ TotalFiles: 1,
+ }
+ if err := saveCacheToDiskWithOptions(child, warmed, true); err != nil {
+ t.Fatalf("saveCacheToDiskWithOptions: %v", err)
+ }
+
+ m := newModel(parent, false)
+ m.entries = []dirEntry{{Name: "child", Path: child, Size: 9, IsDir: true}}
+ m.cache[child] = historyEntry{
+ Path: child,
+ Entries: []dirEntry{{Name: "stale.bin", Path: filepath.Join(child, "stale.bin"), Size: 1}},
+ TotalSize: 1,
+ TotalFiles: 1,
+ NeedsRefresh: true,
+ }
+
+ updated, cmd := m.enterSelectedDir()
+ if cmd == nil {
+ t.Fatalf("expected stale in-memory child cache to trigger a refresh")
+ }
+
+ got := updated.(model)
+ if got.path != child {
+ t.Fatalf("expected path %s, got %s", child, got.path)
+ }
+ if !got.scanning {
+ t.Fatalf("expected directory to remain scanning while refreshing stale cache")
+ }
+ if got.totalSize != 1 {
+ t.Fatalf("expected stale cache contents to be shown immediately, got %d", got.totalSize)
+ }
+
+ scanMsg := runScanResultCmd(t, cmd)
+ if scanMsg.stale {
+ t.Fatalf("expected stale cached navigation to force a fresh scan")
+ }
+ if scanMsg.result.TotalSize != freshSize {
+ t.Fatalf("expected fresh rescan total size %d, got %d", freshSize, scanMsg.result.TotalSize)
+ }
+ if scanMsg.result.Entries[0].Name != "fresh.bin" {
+ t.Fatalf("expected rescan to surface live filesystem contents, got %+v", scanMsg.result.Entries)
+ }
+}
+
+func TestGoBackRefreshesHistoryEntryNeedingRefresh(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+
+ child := filepath.Join(home, "child")
+ if err := os.MkdirAll(child, 0o755); err != nil {
+ t.Fatalf("create child: %v", err)
+ }
+
+ freshPath := filepath.Join(child, "fresh.bin")
+ if err := os.WriteFile(freshPath, []byte("fresh-data-2"), 0o644); err != nil {
+ t.Fatalf("write fresh file: %v", err)
+ }
+ freshInfo, err := os.Stat(freshPath)
+ if err != nil {
+ t.Fatalf("stat fresh file: %v", err)
+ }
+ freshSize := getActualFileSize(freshPath, freshInfo)
+
+ warmed := scanResult{
+ Entries: []dirEntry{{Name: "stale.bin", Path: filepath.Join(child, "stale.bin"), Size: 2}},
+ TotalSize: 2,
+ TotalFiles: 1,
+ }
+ if err := saveCacheToDiskWithOptions(child, warmed, true); err != nil {
+ t.Fatalf("saveCacheToDiskWithOptions: %v", err)
+ }
+
+ m := newModel(filepath.Join(child, "grandchild"), false)
+ m.history = []historyEntry{{
+ Path: child,
+ Entries: []dirEntry{{Name: "stale.bin", Path: filepath.Join(child, "stale.bin"), Size: 2}},
+ TotalSize: 2,
+ TotalFiles: 1,
+ NeedsRefresh: true,
+ }}
+
+ updated, cmd := m.goBack()
+ if cmd == nil {
+ t.Fatalf("expected stale history entry to trigger a refresh")
+ }
+
+ got := updated.(model)
+ if got.path != child {
+ t.Fatalf("expected path %s after goBack, got %s", child, got.path)
+ }
+ if !got.scanning {
+ t.Fatalf("expected goBack to keep scanning while refreshing stale history entry")
+ }
+ if got.totalSize != 2 {
+ t.Fatalf("expected stale history snapshot to be restored immediately, got %d", got.totalSize)
+ }
+
+ scanMsg := runScanResultCmd(t, cmd)
+ if scanMsg.stale {
+ t.Fatalf("expected stale history navigation to force a fresh scan")
+ }
+ if scanMsg.result.TotalSize != freshSize {
+ t.Fatalf("expected fresh rescan total size %d, got %d", freshSize, scanMsg.result.TotalSize)
+ }
+ if scanMsg.result.Entries[0].Name != "fresh.bin" {
+ t.Fatalf("expected rescan to surface live filesystem contents, got %+v", scanMsg.result.Entries)
+ }
+}
+
+func TestScanPathConcurrentWarmsChildCacheWithLiveProgress(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+
+ root := filepath.Join(home, "root")
+ child := filepath.Join(root, "child")
+ if err := os.MkdirAll(child, 0o755); err != nil {
+ t.Fatalf("create child: %v", err)
+ }
+
+ const dirCount = 32
+ const filesPerDir = 256
+ for i := range dirCount {
+ dir := filepath.Join(child, fmt.Sprintf("dir-%02d", i))
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatalf("create nested dir %s: %v", dir, err)
+ }
+ for j := range filesPerDir {
+ file := filepath.Join(dir, fmt.Sprintf("file-%03d.bin", j))
+ if err := os.WriteFile(file, []byte("x"), 0o644); err != nil {
+ t.Fatalf("write %s: %v", file, err)
+ }
+ }
+ }
+
+ var filesScanned, dirsScanned, bytesScanned int64
+ current := &atomic.Value{}
+ current.Store("")
+
+ done := make(chan struct{})
+ errCh := make(chan error, 1)
+ go func() {
+ _, err := scanPathConcurrent(root, &filesScanned, &dirsScanned, &bytesScanned, current)
+ errCh <- err
+ close(done)
+ }()
+
+ deadline := time.Now().Add(5 * time.Second)
+ sawLiveProgress := false
+ for time.Now().Before(deadline) {
+ if atomic.LoadInt64(&filesScanned) > 0 {
+ select {
+ case <-done:
+ default:
+ sawLiveProgress = true
+ }
+ if sawLiveProgress {
+ break
+ }
+ }
+ select {
+ case <-done:
+ if !sawLiveProgress {
+ t.Fatalf("expected live progress before child warm scan completed, final files=%d", atomic.LoadInt64(&filesScanned))
+ }
+ default:
+ }
+ time.Sleep(2 * time.Millisecond)
+ }
+
+ if !sawLiveProgress {
+ t.Fatalf("expected filesScanned to advance before warm child scan finished")
+ }
+
+ select {
+ case err := <-errCh:
+ if err != nil {
+ t.Fatalf("scanPathConcurrent(root): %v", err)
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatalf("scan did not complete")
+ }
+}
+
func TestMeasureOverviewSize(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
diff --git a/Resources/mole/cmd/analyze/cache.go b/Resources/mole/cmd/analyze/cache.go
index 872a8d5..0de94ff 100644
--- a/Resources/mole/cmd/analyze/cache.go
+++ b/Resources/mole/cmd/analyze/cache.go
@@ -1,3 +1,5 @@
+//go:build darwin
+
package main
import (
@@ -36,13 +38,36 @@ func snapshotFromModel(m model) historyEntry {
EntryOffset: m.offset,
LargeSelected: m.largeSelected,
LargeOffset: m.largeOffset,
+ NeedsRefresh: m.viewNeedsRefresh,
IsOverview: m.isOverview,
}
}
-func cacheSnapshot(m model) historyEntry {
- entry := snapshotFromModel(m)
- entry.Dirty = false
+func filterNonEmptyEntries(entries []dirEntry) []dirEntry {
+ filtered := make([]dirEntry, 0, len(entries))
+ for _, entry := range entries {
+ if entry.Size > 0 {
+ filtered = append(filtered, entry)
+ }
+ }
+ return filtered
+}
+
+func historyEntryFromScanResult(path string, result scanResult, previous historyEntry, needsRefresh bool) historyEntry {
+ entry := historyEntry{
+ Path: path,
+ Entries: slices.Clone(result.Entries),
+ LargeFiles: slices.Clone(result.LargeFiles),
+ TotalSize: result.TotalSize,
+ TotalFiles: result.TotalFiles,
+ Selected: previous.Selected,
+ EntryOffset: previous.EntryOffset,
+ LargeSelected: previous.LargeSelected,
+ LargeOffset: previous.LargeOffset,
+ NeedsRefresh: needsRefresh,
+ Dirty: false,
+ IsOverview: previous.IsOverview,
+ }
return entry
}
@@ -253,6 +278,10 @@ func loadStaleCacheFromDisk(path string) (*cacheEntry, error) {
}
func saveCacheToDisk(path string, result scanResult) error {
+ return saveCacheToDiskWithOptions(path, result, false)
+}
+
+func saveCacheToDiskWithOptions(path string, result scanResult, needsRefresh bool) error {
cachePath, err := getCachePath(path)
if err != nil {
return err
@@ -264,12 +293,13 @@ func saveCacheToDisk(path string, result scanResult) error {
}
entry := cacheEntry{
- Entries: result.Entries,
- LargeFiles: result.LargeFiles,
- TotalSize: result.TotalSize,
- TotalFiles: result.TotalFiles,
- ModTime: info.ModTime(),
- ScanTime: time.Now(),
+ Entries: result.Entries,
+ LargeFiles: result.LargeFiles,
+ TotalSize: result.TotalSize,
+ TotalFiles: result.TotalFiles,
+ ModTime: info.ModTime(),
+ ScanTime: time.Now(),
+ NeedsRefresh: needsRefresh,
}
file, err := os.Create(cachePath)
diff --git a/Resources/mole/cmd/analyze/cleanable.go b/Resources/mole/cmd/analyze/cleanable.go
index 4c80879..5a7f8c5 100644
--- a/Resources/mole/cmd/analyze/cleanable.go
+++ b/Resources/mole/cmd/analyze/cleanable.go
@@ -1,3 +1,5 @@
+//go:build darwin
+
package main
import (
diff --git a/Resources/mole/cmd/analyze/constants.go b/Resources/mole/cmd/analyze/constants.go
index d400035..bdf79ab 100644
--- a/Resources/mole/cmd/analyze/constants.go
+++ b/Resources/mole/cmd/analyze/constants.go
@@ -1,3 +1,5 @@
+//go:build darwin
+
package main
import "time"
@@ -19,11 +21,15 @@ const (
cacheReuseWindow = 24 * time.Hour
staleCacheTTL = 3 * 24 * time.Hour
- // Worker pool limits.
- minWorkers = 16
- maxWorkers = 64
- cpuMultiplier = 4
- maxDirWorkers = 32
+ // Worker pool limits. Deliberately conservative: the App Library scan
+ // blocks many goroutines in syscalls on high-fan-out trees (Steam
+ // workshop/temp, browser caches), and each blocked goroutine holds an
+ // OS thread. Exceeding the per-user thread limit on macOS produces a
+ // fatal "runtime: failed to create new OS thread" with no recovery.
+ minWorkers = 4
+ maxWorkers = 16
+ cpuMultiplier = 1
+ maxDirWorkers = 8
openCommandTimeout = 10 * time.Second
)
diff --git a/Resources/mole/cmd/analyze/delete.go b/Resources/mole/cmd/analyze/delete.go
index 11feaee..00d07b4 100644
--- a/Resources/mole/cmd/analyze/delete.go
+++ b/Resources/mole/cmd/analyze/delete.go
@@ -1,3 +1,5 @@
+//go:build darwin
+
package main
import (
@@ -6,6 +8,7 @@ import (
"os"
"os/exec"
"path/filepath"
+ "slices"
"sort"
"strings"
"sync/atomic"
@@ -119,11 +122,21 @@ func trashPathWithProgress(root string, counter *int64) (int64, error) {
// moveToTrash uses macOS Finder to move a file/directory to Trash.
// This is the safest method as it uses the system's native trash mechanism.
func moveToTrash(path string) error {
+ // Validate raw input before Abs resolves ".." components away.
+ if err := validatePath(path); err != nil {
+ return err
+ }
+
absPath, err := filepath.Abs(path)
if err != nil {
return fmt.Errorf("failed to resolve path: %w", err)
}
+ // Validate resolved path as well (defense-in-depth).
+ if err := validatePath(absPath); err != nil {
+ return err
+ }
+
// Escape path for AppleScript (handle quotes and backslashes).
escapedPath := strings.ReplaceAll(absPath, "\\", "\\\\")
escapedPath = strings.ReplaceAll(escapedPath, "\"", "\\\"")
@@ -144,3 +157,22 @@ func moveToTrash(path string) error {
return nil
}
+
+// validatePath checks path safety for external commands.
+// Returns error if path is empty, relative, contains null bytes, or has traversal.
+func validatePath(path string) error {
+ if path == "" {
+ return fmt.Errorf("path is empty")
+ }
+ if !filepath.IsAbs(path) {
+ return fmt.Errorf("path must be absolute: %s", path)
+ }
+ if strings.Contains(path, "\x00") {
+ return fmt.Errorf("path contains null bytes")
+ }
+ // Check for path traversal attempts (.. components).
+ if slices.Contains(strings.Split(path, string(filepath.Separator)), "..") {
+ return fmt.Errorf("path contains traversal components: %s", path)
+ }
+ return nil
+}
diff --git a/Resources/mole/cmd/analyze/delete_test.go b/Resources/mole/cmd/analyze/delete_test.go
index 8d89ae1..f6b011f 100644
--- a/Resources/mole/cmd/analyze/delete_test.go
+++ b/Resources/mole/cmd/analyze/delete_test.go
@@ -1,8 +1,11 @@
+//go:build darwin
+
package main
import (
"os"
"path/filepath"
+ "strings"
"testing"
)
@@ -79,3 +82,95 @@ func TestMoveToTrashNonExistent(t *testing.T) {
t.Fatal("expected error for non-existent path")
}
}
+
+func TestMoveToTrashRejectsTraversal(t *testing.T) {
+ // Verify the full production path rejects ".." before filepath.Abs resolves it.
+ err := moveToTrash("/tmp/fakedir/../../../etc/passwd")
+ if err == nil {
+ t.Fatal("expected error for path with traversal components")
+ }
+ if !strings.Contains(err.Error(), "traversal") {
+ t.Fatalf("expected traversal error, got: %v", err)
+ }
+}
+
+func TestValidatePath(t *testing.T) {
+ tests := []struct {
+ name string
+ path string
+ wantErr bool
+ }{
+ // 基本合法路径
+ {"absolute path", "/Users/test/file.txt", false},
+ {"path with spaces", "/Users/test/My Documents/file.txt", false},
+ {"root", "/", false},
+
+ // 中文路径
+ {"chinese path", "/Users/test/中文文件夹/文件.txt", false},
+ {"chinese mixed", "/Users/test/Downloads/报告2024.pdf", false},
+
+ // Emoji 路径
+ {"emoji path", "/Users/test/📁文件夹/📝笔记.txt", false},
+ {"emoji only", "/Users/test/🎉/🎊.txt", false},
+
+ // 特殊字符路径 (之前被错误拒绝的)
+ {"dollar sign", "/Users/test/$HOME/workspace", false},
+ {"semicolon", "/Users/test/project;v2", false},
+ {"colon", "/Users/test/project:2024", false},
+ {"ampersand", "/Users/test/R&D/project", false},
+ {"at sign", "/Users/test/user@domain", false},
+ {"hash", "/Users/test/project#123", false},
+ {"percent", "/Users/test/100% complete", false},
+ {"exclamation", "/Users/test/important!.txt", false},
+ {"single quote", "/Users/test/user's files", false},
+ {"equals", "/Users/test/key=value", false},
+ {"plus", "/Users/test/file+v2", false},
+ {"brackets", "/Users/test/[2024] report", false},
+ {"parentheses", "/Users/test/project (copy)", false},
+ {"comma", "/Users/test/file, backup", false},
+
+ // 非法路径
+ {"empty", "", true},
+ {"relative", "relative/path", true},
+ {"relative dot", "./file.txt", true},
+ {"null byte", "/Users/test\x00/file", true},
+ {"path traversal", "/Users/test/../../../etc", true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := validatePath(tt.path)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("validatePath(%q) error = %v, wantErr %v", tt.path, err, tt.wantErr)
+ }
+ })
+ }
+}
+
+func TestValidatePathWithChineseAndSpecialChars(t *testing.T) {
+ // 专门测试之前会导致兼容性回退的路径
+ parent := t.TempDir()
+ testCases := []struct {
+ name string
+ path string
+ }{
+ {"chinese", "中文文件夹"},
+ {"emoji", "📁 文档"},
+ {"mixed", "报告-2024_v2 (终稿) [已审核]"},
+ {"special", "Project$2024; Q1: R&D"},
+ {"complex", "用户@公司 100% 完成!"},
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ fullPath := filepath.Join(parent, tc.path)
+ if err := os.MkdirAll(fullPath, 0o755); err != nil {
+ t.Fatalf("mkdir %q: %v", tc.path, err)
+ }
+
+ if err := validatePath(fullPath); err != nil {
+ t.Errorf("validatePath rejected valid path %q: %v", tc.path, err)
+ }
+ })
+ }
+}
diff --git a/Resources/mole/cmd/analyze/format.go b/Resources/mole/cmd/analyze/format.go
index 371539b..26d589e 100644
--- a/Resources/mole/cmd/analyze/format.go
+++ b/Resources/mole/cmd/analyze/format.go
@@ -1,3 +1,5 @@
+//go:build darwin
+
package main
import (
diff --git a/Resources/mole/cmd/analyze/format_test.go b/Resources/mole/cmd/analyze/format_test.go
index 65a8333..96241f3 100644
--- a/Resources/mole/cmd/analyze/format_test.go
+++ b/Resources/mole/cmd/analyze/format_test.go
@@ -1,3 +1,5 @@
+//go:build darwin
+
package main
import (
@@ -20,7 +22,7 @@ func TestRuneWidth(t *testing.T) {
{"CJK ideograph", '語', 2},
{"Full-width number", '1', 2},
{"ASCII space", ' ', 1},
- {"Tab", '\t', 1},
+ {"Tab", ' ', 1},
}
for _, tt := range tests {
diff --git a/Resources/mole/cmd/analyze/heap.go b/Resources/mole/cmd/analyze/heap.go
index 0b4a5a5..919ad1a 100644
--- a/Resources/mole/cmd/analyze/heap.go
+++ b/Resources/mole/cmd/analyze/heap.go
@@ -1,3 +1,5 @@
+//go:build darwin
+
package main
// entryHeap is a min-heap of dirEntry used to keep Top N largest entries.
diff --git a/Resources/mole/cmd/analyze/heap_test.go b/Resources/mole/cmd/analyze/heap_test.go
index 77408fc..12a1341 100644
--- a/Resources/mole/cmd/analyze/heap_test.go
+++ b/Resources/mole/cmd/analyze/heap_test.go
@@ -1,3 +1,5 @@
+//go:build darwin
+
package main
import (
diff --git a/Resources/mole/cmd/analyze/insights.go b/Resources/mole/cmd/analyze/insights.go
new file mode 100644
index 0000000..e38393f
--- /dev/null
+++ b/Resources/mole/cmd/analyze/insights.go
@@ -0,0 +1,177 @@
+//go:build darwin
+
+package main
+
+import (
+ "context"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// createInsightEntries returns the list of hidden-space insight entries
+// to show in the overview screen alongside the standard directory entries.
+func createInsightEntries() []dirEntry {
+ home := os.Getenv("HOME")
+ if home == "" {
+ return nil
+ }
+
+ var entries []dirEntry
+
+ // iOS Backups — ~/Library/Application Support/MobileSync/Backup
+ backupPath := filepath.Join(home, "Library", "Application Support", "MobileSync", "Backup")
+ if info, err := os.Stat(backupPath); err == nil && info.IsDir() {
+ entries = append(entries, dirEntry{
+ Name: "iOS Backups",
+ Path: backupPath,
+ IsDir: true,
+ Size: -1,
+ })
+ }
+
+ // Old Downloads — ~/Downloads (files older than 90 days)
+ downloadsPath := filepath.Join(home, "Downloads")
+ if info, err := os.Stat(downloadsPath); err == nil && info.IsDir() {
+ entries = append(entries, dirEntry{
+ Name: "Old Downloads (90d+)",
+ Path: downloadsPath,
+ IsDir: true,
+ Size: -1,
+ })
+ }
+
+ // Cleanable paths — things mo clean can remove or the user can safely delete.
+ // System Caches (~Library/Caches) is intentionally omitted here because the
+ // specific cache subdirectories below are already its children; listing both
+ // would double-count the same bytes.
+ cleanablePaths := []struct {
+ name string
+ path string
+ }{
+ // Universal (everyone has these)
+ {"System Logs", filepath.Join(home, "Library", "Logs")},
+ {"Homebrew Cache", filepath.Join(home, "Library", "Caches", "Homebrew")},
+
+ // Developer-specific (only shown if path exists)
+ {"Xcode DerivedData", filepath.Join(home, "Library", "Developer", "Xcode", "DerivedData")},
+ {"Xcode Simulators", filepath.Join(home, "Library", "Developer", "CoreSimulator", "Devices")},
+ {"Xcode Archives", filepath.Join(home, "Library", "Developer", "Xcode", "Archives")},
+ {"Spotify Cache", filepath.Join(home, "Library", "Application Support", "Spotify", "PersistentCache")},
+ {"JetBrains Cache", filepath.Join(home, "Library", "Caches", "JetBrains")},
+ {"Docker Data", filepath.Join(home, "Library", "Containers", "com.docker.docker", "Data")},
+ {"pip Cache", filepath.Join(home, "Library", "Caches", "pip")},
+ {"Gradle Cache", filepath.Join(home, ".gradle", "caches")},
+ {"CocoaPods Cache", filepath.Join(home, "Library", "Caches", "CocoaPods")},
+ }
+ for _, c := range cleanablePaths {
+ if info, err := os.Stat(c.path); err == nil && info.IsDir() {
+ entries = append(entries, dirEntry{
+ Name: c.name,
+ Path: c.path,
+ IsDir: true,
+ Size: -1,
+ })
+ }
+ }
+
+ return entries
+}
+
+// measureInsightSize measures the size of a path.
+// Old Downloads is treated specially: only files older than 90 days are counted.
+func measureInsightSize(path string) (int64, error) {
+ home := os.Getenv("HOME")
+
+ if home != "" && path == filepath.Join(home, "Downloads") {
+ return measureOldDownloads(path, 90)
+ }
+
+ return measureOverviewSize(path)
+}
+
+// measureOldDownloads calculates total size of files in a directory
+// that haven't been modified in the given number of days.
+func measureOldDownloads(dir string, daysOld int) (int64, error) {
+ cutoff := time.Now().AddDate(0, 0, -daysOld)
+ var total int64
+
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return 0, err
+ }
+
+ for _, entry := range entries {
+ // Skip hidden files.
+ if strings.HasPrefix(entry.Name(), ".") {
+ continue
+ }
+
+ info, err := entry.Info()
+ if err != nil {
+ continue
+ }
+
+ if info.ModTime().Before(cutoff) {
+ if entry.IsDir() {
+ // Use du for directories.
+ if size, err := getDirSizeFast(filepath.Join(dir, entry.Name())); err == nil {
+ total += size
+ }
+ } else {
+ total += info.Size()
+ }
+ }
+ }
+
+ return total, nil
+}
+
+// insightIcon returns an appropriate icon for an overview entry.
+func insightIcon(entry dirEntry) string {
+ switch entry.Name {
+ case "iOS Backups":
+ return "📱"
+ case "Old Downloads (90d+)":
+ return "📥"
+ case "Homebrew Cache", "pip Cache", "CocoaPods Cache", "Gradle Cache", "Spotify Cache", "JetBrains Cache":
+ return "💾"
+ case "System Logs":
+ return "📋"
+ case "Xcode DerivedData", "Xcode Archives":
+ return "🔨"
+ case "Xcode Simulators":
+ return "📲"
+ case "Docker Data":
+ return "🐳"
+ default:
+ return "📁"
+ }
+}
+
+// getDirSizeFast measures directory size using du.
+func getDirSizeFast(path string) (int64, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ cmd := exec.CommandContext(ctx, "du", "-sk", path)
+ output, err := cmd.Output()
+ if err != nil {
+ return 0, err
+ }
+
+ fields := strings.Fields(string(output))
+ if len(fields) == 0 {
+ return 0, nil
+ }
+
+ kb, err := strconv.ParseInt(fields[0], 10, 64)
+ if err != nil {
+ return 0, err
+ }
+
+ return kb * 1024, nil
+}
diff --git a/Resources/mole/cmd/analyze/insights_test.go b/Resources/mole/cmd/analyze/insights_test.go
new file mode 100644
index 0000000..bafaa5a
--- /dev/null
+++ b/Resources/mole/cmd/analyze/insights_test.go
@@ -0,0 +1,109 @@
+//go:build darwin
+
+package main
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func TestCreateInsightEntries(t *testing.T) {
+ entries := createInsightEntries()
+ // Should return at least some entries on a real Mac.
+ // iOS Backups may not exist, but Old Downloads and Mail Data likely do.
+ if len(entries) == 0 {
+ t.Log("No insight entries found (some paths may not exist on this machine)")
+ }
+
+ // Verify all entries have required fields.
+ for _, e := range entries {
+ if e.Name == "" {
+ t.Error("insight entry has empty Name")
+ }
+ if e.Path == "" {
+ t.Error("insight entry has empty Path")
+ }
+ if e.Size != -1 {
+ t.Errorf("insight entry %q should have Size=-1 (pending), got %d", e.Name, e.Size)
+ }
+ if !e.IsDir {
+ t.Errorf("insight entry %q should be a directory", e.Name)
+ }
+ }
+}
+
+func TestInsightIcon(t *testing.T) {
+ tests := []struct {
+ name string
+ want string
+ }{
+ {"iOS Backups", "📱"},
+ {"Old Downloads (90d+)", "📥"},
+ {"Homebrew Cache", "💾"},
+ {"System Logs", "📋"},
+ {"Xcode Simulators", "📲"},
+ {"Docker Data", "🐳"},
+ {"Home", "📁"},
+ {"Applications", "📁"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := insightIcon(dirEntry{Name: tt.name})
+ if got != tt.want {
+ t.Errorf("insightIcon(%q) = %q, want %q", tt.name, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestMeasureOldDownloads(t *testing.T) {
+ // Create a temp directory with old and new files.
+ dir := t.TempDir()
+
+ // Create an old file (set mtime to 100 days ago).
+ oldFile := filepath.Join(dir, "old.txt")
+ if err := os.WriteFile(oldFile, []byte("old content here"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ oldTime := time.Now().AddDate(0, 0, -100)
+ os.Chtimes(oldFile, oldTime, oldTime)
+
+ // Create a new file.
+ newFile := filepath.Join(dir, "new.txt")
+ if err := os.WriteFile(newFile, []byte("new content"), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ size, err := measureOldDownloads(dir, 90)
+ if err != nil {
+ t.Fatalf("measureOldDownloads: %v", err)
+ }
+
+ if size == 0 {
+ t.Error("expected non-zero size for old files")
+ }
+
+ // Size should be approximately the size of old.txt (16 bytes) but not new.txt.
+ if size > 1024 {
+ t.Errorf("size %d seems too large for a 16-byte file", size)
+ }
+}
+
+func TestMeasureInsightSizeFallsBackToOverview(t *testing.T) {
+ // For a non-Downloads path, measureInsightSize should use measureOverviewSize.
+ dir := t.TempDir()
+ testFile := filepath.Join(dir, "test.dat")
+ if err := os.WriteFile(testFile, make([]byte, 4096), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ size, err := measureInsightSize(dir)
+ if err != nil {
+ t.Fatalf("measureInsightSize: %v", err)
+ }
+ if size == 0 {
+ t.Error("expected non-zero size")
+ }
+}
diff --git a/Resources/mole/cmd/analyze/json.go b/Resources/mole/cmd/analyze/json.go
index 1c2ab44..46227f3 100644
--- a/Resources/mole/cmd/analyze/json.go
+++ b/Resources/mole/cmd/analyze/json.go
@@ -6,25 +6,38 @@ import (
"encoding/json"
"fmt"
"os"
+ "sort"
"sync/atomic"
+ "time"
)
type jsonOutput struct {
- Path string `json:"path"`
- Entries []jsonEntry `json:"entries"`
- TotalSize int64 `json:"total_size"`
- TotalFiles int64 `json:"total_files"`
+ Path string `json:"path"`
+ Overview bool `json:"overview"`
+ Entries []jsonEntry `json:"entries"`
+ LargeFiles []jsonFileEntry `json:"large_files,omitempty"`
+ TotalSize int64 `json:"total_size"`
+ TotalFiles int64 `json:"total_files,omitempty"`
}
type jsonEntry struct {
- Name string `json:"name"`
- Path string `json:"path"`
- Size int64 `json:"size"`
- IsDir bool `json:"is_dir"`
+ Name string `json:"name"`
+ Path string `json:"path"`
+ Size int64 `json:"size"`
+ IsDir bool `json:"is_dir"`
+ Insight bool `json:"insight,omitempty"`
+ Cleanable bool `json:"cleanable,omitempty"`
+ LastAccess string `json:"last_access,omitempty"`
+}
+
+type jsonFileEntry struct {
+ Name string `json:"name"`
+ Path string `json:"path"`
+ Size int64 `json:"size"`
}
func runJSONMode(path string, isOverview bool) {
- result := performScanForJSON(path)
+ result := performScanForJSON(path, isOverview)
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
@@ -34,48 +47,109 @@ func runJSONMode(path string, isOverview bool) {
}
}
-func performScanForJSON(path string) jsonOutput {
+func performScanForJSON(path string, isOverview bool) jsonOutput {
+ if isOverview {
+ return performOverviewScanForJSON(path)
+ }
+ return performDirectoryScanForJSON(path)
+}
+
+func performDirectoryScanForJSON(path string) jsonOutput {
var filesScanned, dirsScanned, bytesScanned int64
currentPath := &atomic.Value{}
currentPath.Store("")
- items, err := os.ReadDir(path)
+ result, err := scanPathConcurrentAllEntries(path, &filesScanned, &dirsScanned, &bytesScanned, currentPath)
if err != nil {
- fmt.Fprintf(os.Stderr, "failed to read directory: %v\n", err)
+ fmt.Fprintf(os.Stderr, "failed to scan directory: %v\n", err)
os.Exit(1)
}
- var entries []jsonEntry
- var totalSize int64
+ return jsonOutput{
+ Path: path,
+ Overview: false,
+ Entries: jsonEntriesFromDirEntries(result.Entries, false, nil),
+ LargeFiles: jsonFileEntriesFromFileEntries(result.LargeFiles),
+ TotalSize: result.TotalSize,
+ TotalFiles: result.TotalFiles,
+ }
+}
- for _, item := range items {
- fullPath := path + "/" + item.Name()
- var size int64
+func performOverviewScanForJSON(path string) jsonOutput {
+ overviewEntries := createOverviewEntries()
+ insightPaths := make(map[string]bool, len(createInsightEntries()))
+ for _, insight := range createInsightEntries() {
+ insightPaths[insight.Path] = true
+ }
- if item.IsDir() {
- size = calculateDirSizeFast(fullPath, &filesScanned, &dirsScanned, &bytesScanned, currentPath)
+ var totalSize int64
+ entries := make([]dirEntry, 0, len(overviewEntries))
+ for _, entry := range overviewEntries {
+ var (
+ size int64
+ err error
+ )
+
+ if cached, cacheErr := loadOverviewCachedSize(entry.Path); cacheErr == nil && cached > 0 {
+ size = cached
+ } else if insightPaths[entry.Path] {
+ size, err = measureInsightSize(entry.Path)
} else {
- info, err := item.Info()
- if err == nil {
- size = info.Size()
- atomic.AddInt64(&filesScanned, 1)
- atomic.AddInt64(&bytesScanned, size)
- }
+ size, err = measureOverviewSize(entry.Path)
+ }
+
+ if err == nil {
+ entry.Size = size
}
- totalSize += size
- entries = append(entries, jsonEntry{
- Name: item.Name(),
- Path: fullPath,
- Size: size,
- IsDir: item.IsDir(),
- })
+ // Match the TUI: omit scanned insight/tool entries that ended up empty.
+ if entry.Size == 0 {
+ continue
+ }
+ totalSize += entry.Size
+ entries = append(entries, entry)
}
+ sort.SliceStable(entries, func(i, j int) bool {
+ return entries[i].Size > entries[j].Size
+ })
+
return jsonOutput{
- Path: path,
- Entries: entries,
- TotalSize: totalSize,
- TotalFiles: atomic.LoadInt64(&filesScanned),
+ Path: path,
+ Overview: true,
+ Entries: jsonEntriesFromDirEntries(entries, true, insightPaths),
+ TotalSize: totalSize,
+ }
+}
+
+func jsonEntriesFromDirEntries(entries []dirEntry, isOverview bool, insightPaths map[string]bool) []jsonEntry {
+ output := make([]jsonEntry, 0, len(entries))
+ for _, entry := range entries {
+ item := jsonEntry{
+ Name: entry.Name,
+ Path: entry.Path,
+ Size: entry.Size,
+ IsDir: entry.IsDir,
+ Cleanable: entry.IsDir && isCleanableDir(entry.Path),
+ }
+
+ if isOverview {
+ item.Insight = insightPaths[entry.Path]
+ }
+
+ if !entry.LastAccess.IsZero() {
+ item.LastAccess = entry.LastAccess.UTC().Format(time.RFC3339)
+ }
+
+ output = append(output, item)
+ }
+ return output
+}
+
+func jsonFileEntriesFromFileEntries(files []fileEntry) []jsonFileEntry {
+ output := make([]jsonFileEntry, 0, len(files))
+ for _, f := range files {
+ output = append(output, jsonFileEntry(f))
}
+ return output
}
diff --git a/Resources/mole/cmd/analyze/json_test.go b/Resources/mole/cmd/analyze/json_test.go
new file mode 100644
index 0000000..53a3f0d
--- /dev/null
+++ b/Resources/mole/cmd/analyze/json_test.go
@@ -0,0 +1,101 @@
+//go:build darwin
+
+package main
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func TestPerformScanForJSONIncludesAllEntriesAndLargeFiles(t *testing.T) {
+ root := t.TempDir()
+
+ totalFiles := maxEntries + 6
+ for i := 0; i < totalFiles-1; i++ {
+ path := filepath.Join(root, fmt.Sprintf("small-%02d.txt", i))
+ if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
+ t.Fatalf("write small file %d: %v", i, err)
+ }
+ }
+
+ hugeFile := filepath.Join(root, "huge.bin")
+ if err := os.WriteFile(hugeFile, make([]byte, 2<<20), 0o644); err != nil {
+ t.Fatalf("write huge file: %v", err)
+ }
+
+ result := performScanForJSON(root, false)
+
+ if result.Overview {
+ t.Fatalf("expected non-overview JSON result")
+ }
+ if got := len(result.Entries); got != totalFiles {
+ t.Fatalf("expected %d entries, got %d", totalFiles, got)
+ }
+ if result.TotalFiles != int64(totalFiles) {
+ t.Fatalf("expected %d total files, got %d", totalFiles, result.TotalFiles)
+ }
+ if len(result.LargeFiles) == 0 {
+ t.Fatalf("expected large_files to include the large file")
+ }
+
+ foundHuge := false
+ for _, file := range result.LargeFiles {
+ if file.Name == "huge.bin" && file.Path == hugeFile {
+ foundHuge = true
+ break
+ }
+ }
+ if !foundHuge {
+ t.Fatalf("expected huge.bin in large_files, got %#v", result.LargeFiles)
+ }
+}
+
+func TestJSONEntriesFromDirEntriesIncludesMetadata(t *testing.T) {
+ oldAccess := time.Now().AddDate(0, 0, -120)
+
+ entries := jsonEntriesFromDirEntries([]dirEntry{
+ {
+ Name: "old.bin",
+ Path: "/tmp/old.bin",
+ Size: 42,
+ IsDir: false,
+ LastAccess: oldAccess,
+ },
+ {
+ Name: "node_modules",
+ Path: "/tmp/project/node_modules",
+ Size: 128,
+ IsDir: true,
+ },
+ }, false, nil)
+
+ if entries[0].LastAccess == "" {
+ t.Fatalf("expected last_access to be populated")
+ }
+ if entries[1].Cleanable != true {
+ t.Fatalf("expected node_modules entry to be marked cleanable")
+ }
+}
+
+func TestJSONEntriesFromDirEntriesMarksOverviewInsights(t *testing.T) {
+ entry := dirEntry{
+ Name: "Old Downloads (90d+)",
+ Path: "/tmp/test-home/Downloads",
+ Size: 256,
+ IsDir: true,
+ }
+
+ entries := jsonEntriesFromDirEntries([]dirEntry{entry}, true, map[string]bool{
+ entry.Path: true,
+ })
+
+ if len(entries) != 1 {
+ t.Fatalf("expected one entry, got %d", len(entries))
+ }
+ if !entries[0].Insight {
+ t.Fatalf("expected entry to be marked as insight")
+ }
+}
diff --git a/Resources/mole/cmd/analyze/main.go b/Resources/mole/cmd/analyze/main.go
index c8ed030..eb9d7b0 100644
--- a/Resources/mole/cmd/analyze/main.go
+++ b/Resources/mole/cmd/analyze/main.go
@@ -12,6 +12,7 @@ import (
"slices"
"sort"
"sync/atomic"
+ "syscall"
"time"
tea "github.com/charmbracelet/bubbletea"
@@ -43,12 +44,13 @@ type scanResult struct {
}
type cacheEntry struct {
- Entries []dirEntry
- LargeFiles []fileEntry
- TotalSize int64
- TotalFiles int64
- ModTime time.Time
- ScanTime time.Time
+ Entries []dirEntry
+ LargeFiles []fileEntry
+ TotalSize int64
+ TotalFiles int64
+ ModTime time.Time
+ ScanTime time.Time
+ NeedsRefresh bool
}
type historyEntry struct {
@@ -62,6 +64,7 @@ type historyEntry struct {
LargeSelected int
LargeOffset int
Dirty bool
+ NeedsRefresh bool
IsOverview bool
}
@@ -125,6 +128,8 @@ type model struct {
largeMultiSelected map[string]bool // Track multi-selected large files by path (safer than index)
totalFiles int64 // Total files found in current/last scan
lastTotalFiles int64 // Total files from previous scan (for progress bar)
+ diskFree int64 // Free disk space for the analyzed volume
+ viewNeedsRefresh bool
}
func (m model) inOverviewMode() bool {
@@ -181,11 +186,17 @@ func newModel(path string, isOverview bool) model {
currentPath.Store("")
var overviewFilesScanned, overviewDirsScanned, overviewBytesScanned int64
overviewCurrentPath := ""
+ var diskFreeBytes int64
+ var stat syscall.Statfs_t
+ if err := syscall.Statfs(path, &stat); err == nil {
+ diskFreeBytes = int64(stat.Bavail) * int64(stat.Bsize)
+ }
m := model{
path: path,
selected: 0,
status: "Preparing scan...",
+ diskFree: diskFreeBytes,
scanning: !isOverview,
filesScanned: &filesScanned,
dirsScanned: &dirsScanned,
@@ -246,6 +257,9 @@ func createOverviewEntries() []dirEntry {
dirEntry{Name: "System Library", Path: "/Library", IsDir: true, Size: -1},
)
+ // Hidden space insights — paths that silently accumulate disk usage.
+ entries = append(entries, createInsightEntries()...)
+
return entries
}
@@ -355,6 +369,9 @@ func (m model) scanCmd(path string) tea.Cmd {
TotalSize: cached.TotalSize,
TotalFiles: cached.TotalFiles,
}
+ if cached.NeedsRefresh {
+ return scanResultMsg{path: path, result: result, err: nil, stale: true}
+ }
return scanResultMsg{path: path, result: result, err: nil}
}
@@ -458,6 +475,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
case scanResultMsg:
if msg.path != "" && msg.path != m.path {
+ if msg.err == nil {
+ filteredEntries := filterNonEmptyEntries(msg.result.Entries)
+ result := msg.result
+ result.Entries = filteredEntries
+ m.cache[msg.path] = historyEntryFromScanResult(msg.path, result, m.cache[msg.path], msg.stale)
+ }
return m, nil
}
m.scanning = false
@@ -465,19 +488,17 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.status = fmt.Sprintf("Scan failed: %v", msg.err)
return m, nil
}
- filteredEntries := make([]dirEntry, 0, len(msg.result.Entries))
- for _, e := range msg.result.Entries {
- if e.Size > 0 {
- filteredEntries = append(filteredEntries, e)
- }
- }
+ filteredEntries := filterNonEmptyEntries(msg.result.Entries)
+ result := msg.result
+ result.Entries = filteredEntries
m.entries = filteredEntries
m.largeFiles = msg.result.LargeFiles
m.totalSize = msg.result.TotalSize
m.totalFiles = msg.result.TotalFiles
+ m.viewNeedsRefresh = msg.stale
m.clampEntrySelection()
m.clampLargeSelection()
- m.cache[m.path] = cacheSnapshot(m)
+ m.cache[m.path] = historyEntryFromScanResult(m.path, result, m.cache[m.path], msg.stale)
if m.totalSize > 0 {
if m.overviewSizeCache == nil {
m.overviewSizeCache = make(map[string]int64)
@@ -612,20 +633,22 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.deleteConfirm = false
m.deleteTarget = nil
return m, nil
+ case "ctrl+c":
+ return m, tea.Quit
default:
return m, nil
}
}
switch msg.String() {
- case "q", "ctrl+c", "Q":
+ case "q", "Q", "ctrl+c":
return m, tea.Quit
case "esc":
if m.showLargeFiles {
m.showLargeFiles = false
return m, nil
}
- return m, tea.Quit
+ return m.goBack()
case "up", "k", "K":
if m.showLargeFiles {
if m.largeSelected > 0 {
@@ -635,7 +658,11 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
}
} else if len(m.entries) > 0 && m.selected > 0 {
- m.selected--
+ next := m.selected - 1
+ for next > 0 && m.entries[next].Size == 0 {
+ next--
+ }
+ m.selected = next
if m.selected < m.offset {
m.offset = m.selected
}
@@ -650,7 +677,11 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
}
} else if len(m.entries) > 0 && m.selected < len(m.entries)-1 {
- m.selected++
+ next := m.selected + 1
+ for next < len(m.entries)-1 && m.entries[next].Size == 0 {
+ next++
+ }
+ m.selected = next
viewport := calculateViewport(m.height, false)
if m.selected >= m.offset+viewport {
m.offset = m.selected - viewport + 1
@@ -666,53 +697,7 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.showLargeFiles = false
return m, nil
}
- if len(m.history) == 0 {
- if !m.inOverviewMode() {
- return m, m.switchToOverviewMode()
- }
- return m, nil
- }
- last := m.history[len(m.history)-1]
- m.history = m.history[:len(m.history)-1]
- m.path = last.Path
- m.selected = last.Selected
- m.offset = last.EntryOffset
- m.largeSelected = last.LargeSelected
- m.largeOffset = last.LargeOffset
- m.isOverview = last.IsOverview
- if last.Dirty {
- // On overview return, refresh cached entries.
- if last.IsOverview {
- m.hydrateOverviewEntries()
- m.totalSize = sumKnownEntrySizes(m.entries)
- m.status = "Ready"
- m.scanning = false
- if nextPendingOverviewIndex(m.entries) >= 0 {
- m.overviewScanning = true
- return m, m.scheduleOverviewScans()
- }
- return m, nil
- }
- m.status = "Scanning..."
- m.scanning = true
- return m, tea.Batch(m.scanCmd(m.path), tickCmd())
- }
- m.entries = last.Entries
- m.largeFiles = last.LargeFiles
- m.totalSize = last.TotalSize
- m.clampEntrySelection()
- m.clampLargeSelection()
- if len(m.entries) == 0 {
- m.selected = 0
- } else if m.selected >= len(m.entries) {
- m.selected = len(m.entries) - 1
- }
- if m.selected < 0 {
- m.selected = 0
- }
- m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize))
- m.scanning = false
- return m, nil
+ return m.goBack()
case "r", "R":
m.multiSelected = make(map[string]bool)
m.largeMultiSelected = make(map[string]bool)
@@ -775,18 +760,14 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
for path := range m.largeMultiSelected {
go func(p string) {
- ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout)
- defer cancel()
- _ = exec.CommandContext(ctx, "open", p).Run()
+ _ = safeOpen(p, false)
}(path)
}
m.status = fmt.Sprintf("Opening %d items...", count)
} else {
selected := m.largeFiles[m.largeSelected]
go func(path string) {
- ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout)
- defer cancel()
- _ = exec.CommandContext(ctx, "open", path).Run()
+ _ = safeOpen(path, false)
}(selected.Path)
m.status = fmt.Sprintf("Opening %s...", selected.Name)
}
@@ -800,18 +781,14 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
for path := range m.multiSelected {
go func(p string) {
- ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout)
- defer cancel()
- _ = exec.CommandContext(ctx, "open", p).Run()
+ _ = safeOpen(p, false)
}(path)
}
m.status = fmt.Sprintf("Opening %d items...", count)
} else {
selected := m.entries[m.selected]
go func(path string) {
- ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout)
- defer cancel()
- _ = exec.CommandContext(ctx, "open", path).Run()
+ _ = safeOpen(path, false)
}(selected.Path)
m.status = fmt.Sprintf("Opening %s...", selected.Name)
}
@@ -829,18 +806,14 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
for path := range m.largeMultiSelected {
go func(p string) {
- ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout)
- defer cancel()
- _ = exec.CommandContext(ctx, "open", "-R", p).Run()
+ _ = safeOpen(p, true)
}(path)
}
m.status = fmt.Sprintf("Showing %d items in Finder...", count)
} else {
selected := m.largeFiles[m.largeSelected]
go func(path string) {
- ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout)
- defer cancel()
- _ = exec.CommandContext(ctx, "open", "-R", path).Run()
+ _ = safeOpen(path, true)
}(selected.Path)
m.status = fmt.Sprintf("Showing %s in Finder...", selected.Name)
}
@@ -854,22 +827,37 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
for path := range m.multiSelected {
go func(p string) {
- ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout)
- defer cancel()
- _ = exec.CommandContext(ctx, "open", "-R", p).Run()
+ _ = safeOpen(p, true)
}(path)
}
m.status = fmt.Sprintf("Showing %d items in Finder...", count)
} else {
selected := m.entries[m.selected]
go func(path string) {
- ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout)
- defer cancel()
- _ = exec.CommandContext(ctx, "open", "-R", path).Run()
+ _ = safeOpen(path, true)
}(selected.Path)
m.status = fmt.Sprintf("Showing %s in Finder...", selected.Name)
}
}
+ case "p", "P":
+ // Quick Look preview (single file only, no multi-select).
+ if m.showLargeFiles {
+ if len(m.largeFiles) > 0 {
+ selected := m.largeFiles[m.largeSelected]
+ go func(path string) {
+ _ = safePreview(path)
+ }(selected.Path)
+ m.status = fmt.Sprintf("Previewing %s...", selected.Name)
+ }
+ } else if len(m.entries) > 0 {
+ selected := m.entries[m.selected]
+ if !selected.IsDir {
+ go func(path string) {
+ _ = safePreview(path)
+ }(selected.Path)
+ m.status = fmt.Sprintf("Previewing %s...", selected.Name)
+ }
+ }
case " ":
// Toggle multi-select (paths as keys).
if m.showLargeFiles {
@@ -978,6 +966,73 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
+func (m model) goBack() (tea.Model, tea.Cmd) {
+ if len(m.history) == 0 {
+ if !m.inOverviewMode() {
+ return m, m.switchToOverviewMode()
+ }
+ return m, tea.Quit
+ }
+
+ last := m.history[len(m.history)-1]
+ m.history = m.history[:len(m.history)-1]
+ m.path = last.Path
+ m.selected = last.Selected
+ m.offset = last.EntryOffset
+ m.largeSelected = last.LargeSelected
+ m.largeOffset = last.LargeOffset
+ m.isOverview = last.IsOverview
+ if last.Dirty {
+ // On overview return, refresh cached entries.
+ if last.IsOverview {
+ m.hydrateOverviewEntries()
+ m.totalSize = sumKnownEntrySizes(m.entries)
+ m.status = "Ready"
+ m.scanning = false
+ if nextPendingOverviewIndex(m.entries) >= 0 {
+ m.overviewScanning = true
+ return m, m.scheduleOverviewScans()
+ }
+ return m, nil
+ }
+ m.status = "Scanning..."
+ m.scanning = true
+ return m, tea.Batch(m.scanCmd(m.path), tickCmd())
+ }
+ m.entries = last.Entries
+ m.largeFiles = last.LargeFiles
+ m.totalSize = last.TotalSize
+ m.totalFiles = last.TotalFiles
+ m.viewNeedsRefresh = last.NeedsRefresh
+ m.clampEntrySelection()
+ m.clampLargeSelection()
+ if len(m.entries) == 0 {
+ m.selected = 0
+ } else if m.selected >= len(m.entries) {
+ m.selected = len(m.entries) - 1
+ }
+ if m.selected < 0 {
+ m.selected = 0
+ }
+ if last.NeedsRefresh {
+ m.status = fmt.Sprintf("Loaded cached data for %s, refreshing...", displayPath(m.path))
+ m.scanning = true
+ if m.totalFiles > 0 {
+ m.lastTotalFiles = m.totalFiles
+ }
+ atomic.StoreInt64(m.filesScanned, 0)
+ atomic.StoreInt64(m.dirsScanned, 0)
+ atomic.StoreInt64(m.bytesScanned, 0)
+ if m.currentPath != nil {
+ m.currentPath.Store("")
+ }
+ return m, tea.Batch(m.scanFreshCmd(m.path), tickCmd())
+ }
+ m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize))
+ m.scanning = false
+ return m, nil
+}
+
func (m *model) switchToOverviewMode() tea.Cmd {
m.isOverview = true
m.path = "/"
@@ -1014,6 +1069,7 @@ func (m model) enterSelectedDir() (tea.Model, tea.Cmd) {
m.status = "Scanning..."
m.scanning = true
m.isOverview = false
+ m.viewNeedsRefresh = false
m.multiSelected = make(map[string]bool)
m.largeMultiSelected = make(map[string]bool)
@@ -1029,12 +1085,21 @@ func (m model) enterSelectedDir() (tea.Model, tea.Cmd) {
m.largeFiles = slices.Clone(cached.LargeFiles)
m.totalSize = cached.TotalSize
m.totalFiles = cached.TotalFiles
+ m.viewNeedsRefresh = cached.NeedsRefresh
m.selected = cached.Selected
m.offset = cached.EntryOffset
m.largeSelected = cached.LargeSelected
m.largeOffset = cached.LargeOffset
m.clampEntrySelection()
m.clampLargeSelection()
+ if cached.NeedsRefresh {
+ m.status = fmt.Sprintf("Loaded cached data for %s, refreshing...", displayPath(m.path))
+ m.scanning = true
+ if m.totalFiles > 0 {
+ m.lastTotalFiles = m.totalFiles
+ }
+ return m, tea.Batch(m.scanFreshCmd(m.path), tickCmd())
+ }
m.status = fmt.Sprintf("Cached view for %s", displayPath(m.path))
m.scanning = false
return m, nil
@@ -1163,7 +1228,7 @@ func (m *model) removePathFromView(path string) {
func scanOverviewPathCmd(path string, index int) tea.Cmd {
return func() tea.Msg {
- size, err := measureOverviewSize(path)
+ size, err := measureInsightSize(path)
return overviewSizeMsg{
Path: path,
Index: index,
@@ -1172,3 +1237,27 @@ func scanOverviewPathCmd(path string, index int) tea.Cmd {
}
}
}
+
+// safeOpen executes 'open' command with path validation.
+func safeOpen(path string, reveal bool) error {
+ if err := validatePath(path); err != nil {
+ return err
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout)
+ defer cancel()
+ args := []string{path}
+ if reveal {
+ args = []string{"-R", path}
+ }
+ return exec.CommandContext(ctx, "open", args...).Run()
+}
+
+// safePreview opens the file with the default macOS application.
+func safePreview(path string) error {
+ if err := validatePath(path); err != nil {
+ return err
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout)
+ defer cancel()
+ return exec.CommandContext(ctx, "open", path).Run()
+}
diff --git a/Resources/mole/cmd/analyze/main_stub.go b/Resources/mole/cmd/analyze/main_stub.go
new file mode 100644
index 0000000..89bd0e0
--- /dev/null
+++ b/Resources/mole/cmd/analyze/main_stub.go
@@ -0,0 +1,13 @@
+//go:build !darwin
+
+package main
+
+import (
+ "fmt"
+ "os"
+)
+
+func main() {
+ fmt.Fprintln(os.Stderr, "analyze is only supported on macOS")
+ os.Exit(1)
+}
diff --git a/Resources/mole/cmd/analyze/scanner.go b/Resources/mole/cmd/analyze/scanner.go
index f22387b..c43812d 100644
--- a/Resources/mole/cmd/analyze/scanner.go
+++ b/Resources/mole/cmd/analyze/scanner.go
@@ -1,3 +1,5 @@
+//go:build darwin
+
package main
import (
@@ -10,6 +12,7 @@ import (
"os/exec"
"path/filepath"
"runtime"
+ "sort"
"strconv"
"strings"
"sync"
@@ -59,6 +62,14 @@ func trySend[T any](ch chan<- T, item T, timeout time.Duration) bool {
}
func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *int64, currentPath *atomic.Value) (scanResult, error) {
+ return scanPathConcurrentWithOptions(root, filesScanned, dirsScanned, bytesScanned, currentPath, true, maxEntries)
+}
+
+func scanPathConcurrentAllEntries(root string, filesScanned, dirsScanned, bytesScanned *int64, currentPath *atomic.Value) (scanResult, error) {
+ return scanPathConcurrentWithOptions(root, filesScanned, dirsScanned, bytesScanned, currentPath, true, 0)
+}
+
+func scanPathConcurrentWithOptions(root string, filesScanned, dirsScanned, bytesScanned *int64, currentPath *atomic.Value, useSpotlight bool, entryLimit int) (scanResult, error) {
children, err := os.ReadDir(root)
if err != nil {
return scanResult{}, err
@@ -67,10 +78,16 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
var total int64
var localFilesScanned int64
var localBytesScanned int64
+ var subtreeFilesScanned atomic.Int64
+
+ collectAllEntries := entryLimit <= 0
+ var collectedEntries []dirEntry
- // Keep Top N heaps.
+ // Keep Top N heaps when a limit is requested.
entriesHeap := &entryHeap{}
- heap.Init(entriesHeap)
+ if !collectAllEntries {
+ heap.Init(entriesHeap)
+ }
largeFilesHeap := &largeFileHeap{}
heap.Init(largeFilesHeap)
@@ -95,7 +112,12 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
go func() {
defer collectorWg.Done()
for entry := range entryChan {
- if entriesHeap.Len() < maxEntries {
+ if collectAllEntries {
+ collectedEntries = append(collectedEntries, entry)
+ continue
+ }
+
+ if entriesHeap.Len() < entryLimit {
heap.Push(entriesHeap, entry)
} else if entry.Size > (*entriesHeap)[0].Size {
heap.Pop(entriesHeap)
@@ -171,21 +193,22 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
defer wg.Done()
defer func() { <-sem }()
- var size int64
+ result := scanResult{}
if cached, err := loadStoredOverviewSize(path); err == nil && cached > 0 {
- size = cached
- } else if cached, err := loadCacheFromDisk(path); err == nil {
- size = cached.TotalSize
+ result.TotalSize = cached
} else {
- size = calculateDirSizeConcurrent(path, largeFileChan, &largeFileMinSize, dirSem, duSem, duQueueSem, filesScanned, dirsScanned, bytesScanned, currentPath)
+ result = scanSubdirWithCache(path, largeFileChan, &largeFileMinSize, dirSem, duSem, duQueueSem, filesScanned, dirsScanned, bytesScanned, currentPath)
+ }
+ atomic.AddInt64(&total, result.TotalSize)
+ if result.TotalFiles > 0 {
+ subtreeFilesScanned.Add(result.TotalFiles)
}
- atomic.AddInt64(&total, size)
atomic.AddInt64(dirsScanned, 1)
trySend(entryChan, dirEntry{
Name: name,
Path: path,
- Size: size,
+ Size: result.TotalSize,
IsDir: true,
LastAccess: time.Time{},
}, 100*time.Millisecond)
@@ -229,14 +252,17 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
defer wg.Done()
defer func() { <-sem }()
- size := calculateDirSizeConcurrent(path, largeFileChan, &largeFileMinSize, dirSem, duSem, duQueueSem, filesScanned, dirsScanned, bytesScanned, currentPath)
- atomic.AddInt64(&total, size)
+ result := scanSubdirWithCache(path, largeFileChan, &largeFileMinSize, dirSem, duSem, duQueueSem, filesScanned, dirsScanned, bytesScanned, currentPath)
+ atomic.AddInt64(&total, result.TotalSize)
+ if result.TotalFiles > 0 {
+ subtreeFilesScanned.Add(result.TotalFiles)
+ }
atomic.AddInt64(dirsScanned, 1)
trySend(entryChan, dirEntry{
Name: name,
Path: path,
- Size: size,
+ Size: result.TotalSize,
IsDir: true,
LastAccess: time.Time{},
}, 100*time.Millisecond)
@@ -286,9 +312,17 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
collectorWg.Wait()
// Convert heaps to sorted slices (descending).
- entries := make([]dirEntry, entriesHeap.Len())
- for i := len(entries) - 1; i >= 0; i-- {
- entries[i] = heap.Pop(entriesHeap).(dirEntry)
+ var entries []dirEntry
+ if collectAllEntries {
+ entries = append(entries, collectedEntries...)
+ sort.SliceStable(entries, func(i, j int) bool {
+ return entries[i].Size > entries[j].Size
+ })
+ } else {
+ entries = make([]dirEntry, entriesHeap.Len())
+ for i := len(entries) - 1; i >= 0; i-- {
+ entries[i] = heap.Pop(entriesHeap).(dirEntry)
+ }
}
largeFiles := make([]fileEntry, largeFilesHeap.Len())
@@ -297,18 +331,63 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
}
// Use Spotlight for large files when it expands the list.
- if spotlightFiles := findLargeFilesWithSpotlight(root, spotlightMinFileSize); len(spotlightFiles) > len(largeFiles) {
- largeFiles = spotlightFiles
+ if useSpotlight {
+ if spotlightFiles := findLargeFilesWithSpotlight(root, spotlightMinFileSize); len(spotlightFiles) > len(largeFiles) {
+ largeFiles = spotlightFiles
+ }
}
return scanResult{
Entries: entries,
LargeFiles: largeFiles,
TotalSize: total,
- TotalFiles: atomic.LoadInt64(filesScanned),
+ TotalFiles: localFilesScanned + subtreeFilesScanned.Load(),
}, nil
}
+func publishLargeFiles(files []fileEntry, largeFileChan chan<- fileEntry) {
+ for _, file := range files {
+ trySend(largeFileChan, file, 100*time.Millisecond)
+ }
+}
+
+func loadCachedSubdirResult(path string, largeFileChan chan<- fileEntry) (scanResult, bool) {
+ cached, err := loadCacheFromDisk(path)
+ if err != nil {
+ return scanResult{}, false
+ }
+
+ result := scanResult{
+ Entries: cached.Entries,
+ LargeFiles: cached.LargeFiles,
+ TotalSize: cached.TotalSize,
+ TotalFiles: cached.TotalFiles,
+ }
+ publishLargeFiles(result.LargeFiles, largeFileChan)
+ return result, true
+}
+
+func scanSubdirWithCache(root string, largeFileChan chan<- fileEntry, largeFileMinSize *int64, dirSem, duSem, duQueueSem chan struct{}, filesScanned, dirsScanned, bytesScanned *int64, currentPath *atomic.Value) scanResult {
+ if cached, ok := loadCachedSubdirResult(root, largeFileChan); ok {
+ if cached.TotalFiles > 0 {
+ atomic.AddInt64(filesScanned, cached.TotalFiles)
+ }
+ if cached.TotalSize > 0 {
+ atomic.AddInt64(bytesScanned, cached.TotalSize)
+ }
+ return cached
+ }
+
+ result, err := scanPathConcurrentWithOptions(root, filesScanned, dirsScanned, bytesScanned, currentPath, false, maxEntries)
+ if err == nil {
+ publishLargeFiles(result.LargeFiles, largeFileChan)
+ _ = saveCacheToDiskWithOptions(root, result, true)
+ return result
+ }
+
+ return scanResult{TotalSize: calculateDirSizeConcurrent(root, largeFileChan, largeFileMinSize, dirSem, duSem, duQueueSem, filesScanned, dirsScanned, bytesScanned, currentPath)}
+}
+
func shouldFoldDirWithPath(name, path string) bool {
if foldDirs[name] {
return true
@@ -341,7 +420,7 @@ func calculateDirSizeFast(root string, filesScanned, dirsScanned, bytesScanned *
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
- concurrency := min(runtime.NumCPU()*4, 64)
+ concurrency := min(runtime.NumCPU()*cpuMultiplier, maxWorkers)
sem := make(chan struct{}, concurrency)
var walk func(string)
@@ -407,6 +486,16 @@ func calculateDirSizeFast(root string, filesScanned, dirsScanned, bytesScanned *
// Use Spotlight (mdfind) to quickly find large files.
func findLargeFilesWithSpotlight(root string, minSize int64) []fileEntry {
+ // Validate root path.
+ if err := validatePath(root); err != nil {
+ return nil
+ }
+
+ // Validate minSize is reasonable (non-negative and not excessively large).
+ if minSize < 0 || minSize > 1<<50 { // 1 PB max
+ return nil
+ }
+
query := fmt.Sprintf("kMDItemFSSize >= %d", minSize)
ctx, cancel := context.WithTimeout(context.Background(), mdlsTimeout)
@@ -610,12 +699,12 @@ func measureOverviewSize(path string) (int64, error) {
excludePath = filepath.Join(home, "Library")
}
- if duSize, err := getDirectorySizeFromDuWithExclude(path, excludePath); err == nil && duSize > 0 {
+ if duSize, err := getDirectorySizeFromDuWithExclude(path, excludePath); err == nil {
_ = storeOverviewSize(path, duSize)
return duSize, nil
}
- if logicalSize, err := getDirectoryLogicalSizeWithExclude(path, excludePath); err == nil && logicalSize > 0 {
+ if logicalSize, err := getDirectoryLogicalSizeWithExclude(path, excludePath); err == nil {
_ = storeOverviewSize(path, logicalSize)
return logicalSize, nil
}
@@ -633,6 +722,16 @@ func getDirectorySizeFromDu(path string) (int64, error) {
}
func getDirectorySizeFromDuWithExclude(path string, excludePath string) (int64, error) {
+ // Validate paths.
+ if err := validatePath(path); err != nil {
+ return 0, err
+ }
+ if excludePath != "" {
+ if err := validatePath(excludePath); err != nil {
+ return 0, err
+ }
+ }
+
runDuSize := func(target string) (int64, error) {
if _, err := os.Stat(target); err != nil {
return 0, err
diff --git a/Resources/mole/cmd/analyze/scanner_test.go b/Resources/mole/cmd/analyze/scanner_test.go
index 718d276..c5a7557 100644
--- a/Resources/mole/cmd/analyze/scanner_test.go
+++ b/Resources/mole/cmd/analyze/scanner_test.go
@@ -1,3 +1,5 @@
+//go:build darwin
+
package main
import (
diff --git a/Resources/mole/cmd/analyze/test_helpers_test.go b/Resources/mole/cmd/analyze/test_helpers_test.go
index 9490833..a84fe1a 100644
--- a/Resources/mole/cmd/analyze/test_helpers_test.go
+++ b/Resources/mole/cmd/analyze/test_helpers_test.go
@@ -1,3 +1,5 @@
+//go:build darwin
+
package main
import (
diff --git a/Resources/mole/cmd/analyze/view.go b/Resources/mole/cmd/analyze/view.go
index d696969..ddfaecf 100644
--- a/Resources/mole/cmd/analyze/view.go
+++ b/Resources/mole/cmd/analyze/view.go
@@ -14,7 +14,11 @@ func (m model) View() string {
fmt.Fprintln(&b)
if m.inOverviewMode() {
- fmt.Fprintf(&b, "%sAnalyze Disk%s\n", colorPurpleBold, colorReset)
+ freeLabel := ""
+ if m.diskFree > 0 {
+ freeLabel = fmt.Sprintf(" %s(%s free)%s", colorGray, humanizeBytes(m.diskFree), colorReset)
+ }
+ fmt.Fprintf(&b, "%sAnalyze Disk%s%s\n", colorPurpleBold, colorReset, freeLabel)
if m.overviewScanning {
allPending := true
for _, entry := range m.entries {
@@ -168,10 +172,16 @@ func (m model) View() string {
}
totalSize := m.totalSize
// Overview paths are short; fixed width keeps layout stable.
- nameWidth := 20
+ nameWidth := 22
+ displayNum := 0
for idx, entry := range m.entries {
- icon := "📁"
+ icon := insightIcon(entry)
sizeVal := entry.Size
+ // Hide entries that have been scanned and are empty (standard dirs
+ // are never 0 bytes; only insight dirs in unused tool paths are).
+ if sizeVal == 0 {
+ continue
+ }
barValue := max(sizeVal, 0)
var percent float64
if totalSize > 0 && sizeVal >= 0 {
@@ -214,7 +224,8 @@ func (m model) View() string {
percentColor = colorCyan
sizeColor = colorCyan
}
- displayIndex := idx + 1
+ displayNum++
+ displayIndex := displayNum
var hintLabel string
if entry.IsDir && isCleanableDir(entry.Path) {
@@ -327,31 +338,31 @@ func (m model) View() string {
fmt.Fprintln(&b)
if m.inOverviewMode() {
if len(m.history) > 0 {
- fmt.Fprintf(&b, "%s↑↓←→ | Enter | R Refresh | O Open | F File | ← Back | Q Quit%s\n", colorGray, colorReset)
+ fmt.Fprintf(&b, "%s↑↓←→ | Enter | R Refresh | O Open | P Preview | F File | Esc Back | Q/Ctrl+C Quit%s\n", colorGray, colorReset)
} else {
- fmt.Fprintf(&b, "%s↑↓→ | Enter | R Refresh | O Open | F File | Q Quit%s\n", colorGray, colorReset)
+ fmt.Fprintf(&b, "%s↑↓→ | Enter | R Refresh | O Open | P Preview | F File | Esc/Q Quit%s\n", colorGray, colorReset)
}
} else if m.showLargeFiles {
selectCount := len(m.largeMultiSelected)
if selectCount > 0 {
- fmt.Fprintf(&b, "%s↑↓← | Space Select | R Refresh | O Open | F File | ⌫ Del %d | ← Back | Q Quit%s\n", colorGray, selectCount, colorReset)
+ fmt.Fprintf(&b, "%s↑↓← | Space Select | R Refresh | O Open | P Preview | F File | ⌫ Del %d | Esc Back | Q/Ctrl+C Quit%s\n", colorGray, selectCount, colorReset)
} else {
- fmt.Fprintf(&b, "%s↑↓← | Space Select | R Refresh | O Open | F File | ⌫ Del | ← Back | Q Quit%s\n", colorGray, colorReset)
+ fmt.Fprintf(&b, "%s↑↓← | Space Select | R Refresh | O Open | P Preview | F File | ⌫ Del | Esc Back | Q/Ctrl+C Quit%s\n", colorGray, colorReset)
}
} else {
largeFileCount := len(m.largeFiles)
selectCount := len(m.multiSelected)
if selectCount > 0 {
if largeFileCount > 0 {
- fmt.Fprintf(&b, "%s↑↓←→ | Space Select | Enter | R Refresh | O Open | F File | ⌫ Del %d | T Top %d | Q Quit%s\n", colorGray, selectCount, largeFileCount, colorReset)
+ fmt.Fprintf(&b, "%s↑↓←→ | Space Select | Enter | R Refresh | O Open | P Preview | F File | ⌫ Del %d | T Top %d | Esc Back | Q/Ctrl+C Quit%s\n", colorGray, selectCount, largeFileCount, colorReset)
} else {
- fmt.Fprintf(&b, "%s↑↓←→ | Space Select | Enter | R Refresh | O Open | F File | ⌫ Del %d | Q Quit%s\n", colorGray, selectCount, colorReset)
+ fmt.Fprintf(&b, "%s↑↓←→ | Space Select | Enter | R Refresh | O Open | P Preview | F File | ⌫ Del %d | Esc Back | Q/Ctrl+C Quit%s\n", colorGray, selectCount, colorReset)
}
} else {
if largeFileCount > 0 {
- fmt.Fprintf(&b, "%s↑↓←→ | Space Select | Enter | R Refresh | O Open | F File | ⌫ Del | T Top %d | Q Quit%s\n", colorGray, largeFileCount, colorReset)
+ fmt.Fprintf(&b, "%s↑↓←→ | Space Select | Enter | R Refresh | O Open | P Preview | F File | ⌫ Del | T Top %d | Esc Back | Q/Ctrl+C Quit%s\n", colorGray, largeFileCount, colorReset)
} else {
- fmt.Fprintf(&b, "%s↑↓←→ | Space Select | Enter | R Refresh | O Open | F File | ⌫ Del | Q Quit%s\n", colorGray, colorReset)
+ fmt.Fprintf(&b, "%s↑↓←→ | Space Select | Enter | R Refresh | O Open | P Preview | F File | ⌫ Del | Esc Back | Q/Ctrl+C Quit%s\n", colorGray, colorReset)
}
}
}
diff --git a/Resources/mole/cmd/status/main.go b/Resources/mole/cmd/status/main.go
index a31a8ba..17de64b 100644
--- a/Resources/mole/cmd/status/main.go
+++ b/Resources/mole/cmd/status/main.go
@@ -21,7 +21,10 @@ var (
BuildTime = ""
// Command-line flags
- jsonOutput = flag.Bool("json", false, "output metrics as JSON instead of TUI")
+ jsonOutput = flag.Bool("json", false, "output metrics as JSON instead of TUI")
+ procCPUThreshold = flag.Float64("proc-cpu-threshold", 100, "alert when a process stays above this CPU percent")
+ procCPUWindow = flag.Duration("proc-cpu-window", 5*time.Minute, "continuous duration a process must exceed the CPU threshold")
+ procCPUAlerts = flag.Bool("proc-cpu-alerts", true, "enable persistent high-CPU process alerts")
)
func shouldUseJSONOutput(forceJSON bool, stdout *os.File) bool {
@@ -59,6 +62,21 @@ type model struct {
catHidden bool // true = hidden, false = visible
}
+// padViewToHeight ensures the rendered frame always overwrites the full
+// terminal region by padding with empty lines up to the current height.
+func padViewToHeight(view string, height int) string {
+ if height <= 0 {
+ return view
+ }
+
+ contentHeight := lipgloss.Height(view)
+ if contentHeight >= height {
+ return view
+ }
+
+ return view + strings.Repeat("\n", height-contentHeight)
+}
+
// getConfigPath returns the path to the status preferences file.
func getConfigPath() string {
home, err := os.UserHomeDir()
@@ -101,11 +119,29 @@ func saveCatHidden(hidden bool) {
func newModel() model {
return model{
- collector: NewCollector(),
+ collector: NewCollector(processWatchOptionsFromFlags()),
catHidden: loadCatHidden(),
}
}
+func processWatchOptionsFromFlags() ProcessWatchOptions {
+ return ProcessWatchOptions{
+ Enabled: *procCPUAlerts,
+ CPUThreshold: *procCPUThreshold,
+ Window: *procCPUWindow,
+ }
+}
+
+func validateFlags() error {
+ if *procCPUThreshold < 0 {
+ return fmt.Errorf("--proc-cpu-threshold must be >= 0")
+ }
+ if *procCPUWindow <= 0 {
+ return fmt.Errorf("--proc-cpu-window must be > 0")
+ }
+ return nil
+}
+
func (m model) Init() tea.Cmd {
return tea.Batch(tickAfter(0), animTick())
}
@@ -164,7 +200,9 @@ func (m model) View() string {
}
header, mole := renderHeader(m.metrics, m.errMessage, m.animFrame, termWidth, m.catHidden)
+ alertBar := renderProcessAlertBar(m.metrics.ProcessAlerts, termWidth)
+ var cardContent string
if termWidth <= 80 {
cardWidth := termWidth
if cardWidth > 2 {
@@ -179,27 +217,24 @@ func (m model) View() string {
}
rendered = append(rendered, renderCard(c, cardWidth, 0))
}
- // Combine header, mole, and cards with consistent spacing
- var content []string
- content = append(content, header)
- if mole != "" {
- content = append(content, mole)
- }
- content = append(content, lipgloss.JoinVertical(lipgloss.Left, rendered...))
- return lipgloss.JoinVertical(lipgloss.Left, content...)
+ cardContent = lipgloss.JoinVertical(lipgloss.Left, rendered...)
+ } else {
+ cardWidth := max(24, termWidth/2-4)
+ cards := buildCards(m.metrics, cardWidth)
+ cardContent = renderTwoColumns(cards, termWidth)
}
- 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)
+ parts := []string{header}
+ if alertBar != "" {
+ parts = append(parts, alertBar)
+ }
if mole != "" {
- content = append(content, mole)
+ parts = append(parts, mole)
}
- content = append(content, twoCol)
- return lipgloss.JoinVertical(lipgloss.Left, content...)
+ parts = append(parts, cardContent)
+ output := lipgloss.JoinVertical(lipgloss.Left, parts...)
+ return padViewToHeight(output, m.height)
}
func (m model) collectCmd() tea.Cmd {
@@ -225,7 +260,7 @@ func animTickWithSpeed(cpuUsage float64) tea.Cmd {
// runJSONMode collects metrics once and outputs as JSON.
func runJSONMode() {
- collector := NewCollector()
+ collector := NewCollector(processWatchOptionsFromFlags())
// First collection initializes network state (returns nil for network)
_, _ = collector.Collect()
@@ -259,6 +294,10 @@ func runTUIMode() {
func main() {
flag.Parse()
+ if err := validateFlags(); err != nil {
+ fmt.Fprintf(os.Stderr, "%v\n", err)
+ os.Exit(2)
+ }
if shouldUseJSONOutput(*jsonOutput, os.Stdout) {
runJSONMode()
@@ -266,3 +305,13 @@ func main() {
runTUIMode()
}
}
+
+func activeAlerts(alerts []ProcessAlert) []ProcessAlert {
+ var active []ProcessAlert
+ for _, alert := range alerts {
+ if alert.Status == "active" {
+ active = append(active, alert)
+ }
+ }
+ return active
+}
diff --git a/Resources/mole/cmd/status/main_test.go b/Resources/mole/cmd/status/main_test.go
index 76f31cb..028c103 100644
--- a/Resources/mole/cmd/status/main_test.go
+++ b/Resources/mole/cmd/status/main_test.go
@@ -3,6 +3,7 @@ package main
import (
"os"
"testing"
+ "time"
)
func TestShouldUseJSONOutput_ForceFlag(t *testing.T) {
@@ -42,3 +43,50 @@ func TestShouldUseJSONOutput_NonTTYFile(t *testing.T) {
t.Fatalf("expected file stdout to use JSON mode")
}
}
+
+func TestProcessWatchOptionsFromFlags(t *testing.T) {
+ oldThreshold := *procCPUThreshold
+ oldWindow := *procCPUWindow
+ oldAlerts := *procCPUAlerts
+ defer func() {
+ *procCPUThreshold = oldThreshold
+ *procCPUWindow = oldWindow
+ *procCPUAlerts = oldAlerts
+ }()
+
+ *procCPUThreshold = 125
+ *procCPUWindow = 2 * time.Minute
+ *procCPUAlerts = false
+
+ opts := processWatchOptionsFromFlags()
+ if opts.CPUThreshold != 125 {
+ t.Fatalf("CPUThreshold = %v, want 125", opts.CPUThreshold)
+ }
+ if opts.Window != 2*time.Minute {
+ t.Fatalf("Window = %v, want 2m", opts.Window)
+ }
+ if opts.Enabled {
+ t.Fatal("Enabled = true, want false")
+ }
+}
+
+func TestValidateFlags(t *testing.T) {
+ oldThreshold := *procCPUThreshold
+ oldWindow := *procCPUWindow
+ defer func() {
+ *procCPUThreshold = oldThreshold
+ *procCPUWindow = oldWindow
+ }()
+
+ *procCPUThreshold = -1
+ *procCPUWindow = 5 * time.Minute
+ if err := validateFlags(); err == nil {
+ t.Fatal("expected negative threshold to fail validation")
+ }
+
+ *procCPUThreshold = 100
+ *procCPUWindow = 0
+ if err := validateFlags(); err == nil {
+ t.Fatal("expected zero window to fail validation")
+ }
+}
diff --git a/Resources/mole/cmd/status/metrics.go b/Resources/mole/cmd/status/metrics.go
index 75ecd13..aaa8fd9 100644
--- a/Resources/mole/cmd/status/metrics.go
+++ b/Resources/mole/cmd/status/metrics.go
@@ -61,24 +61,29 @@ type MetricsSnapshot struct {
Host string `json:"host"`
Platform string `json:"platform"`
Uptime string `json:"uptime"`
+ UptimeSeconds uint64 `json:"uptime_seconds"`
Procs uint64 `json:"procs"`
Hardware HardwareInfo `json:"hardware"`
HealthScore int `json:"health_score"` // 0-100 system health score
HealthScoreMsg string `json:"health_score_msg"` // Brief explanation
- CPU CPUStatus `json:"cpu"`
- GPU []GPUStatus `json:"gpu"`
- Memory MemoryStatus `json:"memory"`
- Disks []DiskStatus `json:"disks"`
- DiskIO DiskIOStatus `json:"disk_io"`
- Network []NetworkStatus `json:"network"`
- NetworkHistory NetworkHistory `json:"network_history"`
- Proxy ProxyStatus `json:"proxy"`
- Batteries []BatteryStatus `json:"batteries"`
- Thermal ThermalStatus `json:"thermal"`
- Sensors []SensorReading `json:"sensors"`
- Bluetooth []BluetoothDevice `json:"bluetooth"`
- TopProcesses []ProcessInfo `json:"top_processes"`
+ CPU CPUStatus `json:"cpu"`
+ GPU []GPUStatus `json:"gpu"`
+ Memory MemoryStatus `json:"memory"`
+ Disks []DiskStatus `json:"disks"`
+ TrashSize uint64 `json:"trash_size"`
+ TrashApprox bool `json:"trash_approx"`
+ DiskIO DiskIOStatus `json:"disk_io"`
+ Network []NetworkStatus `json:"network"`
+ NetworkHistory NetworkHistory `json:"network_history"`
+ Proxy ProxyStatus `json:"proxy"`
+ Batteries []BatteryStatus `json:"batteries"`
+ Thermal ThermalStatus `json:"thermal"`
+ Sensors []SensorReading `json:"sensors"`
+ Bluetooth []BluetoothDevice `json:"bluetooth"`
+ TopProcesses []ProcessInfo `json:"top_processes"`
+ ProcessWatch ProcessWatchConfig `json:"process_watch"`
+ ProcessAlerts []ProcessAlert `json:"process_alerts"`
}
type HardwareInfo struct {
@@ -96,9 +101,12 @@ type DiskIOStatus struct {
}
type ProcessInfo struct {
- Name string `json:"name"`
- CPU float64 `json:"cpu"`
- Memory float64 `json:"memory"`
+ PID int `json:"pid"`
+ PPID int `json:"ppid"`
+ Name string `json:"name"`
+ Command string `json:"command"`
+ CPU float64 `json:"cpu"`
+ Memory float64 `json:"memory"`
}
type CPUStatus struct {
@@ -176,6 +184,7 @@ type BatteryStatus struct {
type ThermalStatus struct {
CPUTemp float64 `json:"cpu_temp"`
GPUTemp float64 `json:"gpu_temp"`
+ BatteryTemp float64 `json:"battery_temp"` // Battery temperature in Celsius when exposed by AppleSmartBattery
FanSpeed int `json:"fan_speed"`
FanCount int `json:"fan_count"`
SystemPower float64 `json:"system_power"` // System power consumption in Watts
@@ -215,13 +224,19 @@ type Collector struct {
cachedGPU []GPUStatus
prevDiskIO disk.IOCountersStat
lastDiskAt time.Time
+
+ watchMu sync.Mutex
+ processWatch ProcessWatchConfig
+ processWatcher *ProcessWatcher
}
-func NewCollector() *Collector {
+func NewCollector(options ProcessWatchOptions) *Collector {
return &Collector{
- prevNet: make(map[string]net.IOCountersStat),
- rxHistoryBuf: NewRingBuffer(NetworkHistorySize),
- txHistoryBuf: NewRingBuffer(NetworkHistorySize),
+ prevNet: make(map[string]net.IOCountersStat),
+ rxHistoryBuf: NewRingBuffer(NetworkHistorySize),
+ txHistoryBuf: NewRingBuffer(NetworkHistorySize),
+ processWatch: options.SnapshotConfig(),
+ processWatcher: NewProcessWatcher(options),
}
}
@@ -250,14 +265,12 @@ func (c *Collector) Collect() (MetricsSnapshot, error) {
sensorStats []SensorReading
gpuStats []GPUStatus
btStats []BluetoothDevice
- topProcs []ProcessInfo
+ allProcs []ProcessInfo
)
// Helper to launch concurrent collection.
collect := func(fn func() error) {
- wg.Add(1)
- go func() {
- defer wg.Done()
+ wg.Go(func() {
defer func() {
if r := recover(); r != nil {
errMu.Lock()
@@ -279,13 +292,16 @@ func (c *Collector) Collect() (MetricsSnapshot, error) {
}
errMu.Unlock()
}
- }()
+ })
}
// Launch independent collection tasks.
collect(func() (err error) { cpuStats, err = collectCPU(); return })
collect(func() (err error) { memStats, err = collectMemory(); return })
collect(func() (err error) { diskStats, err = collectDisks(); return })
+ var trashSize uint64
+ var trashApprox bool
+ collect(func() (err error) { trashSize, trashApprox = collectTrashSize(); return nil })
collect(func() (err error) { diskIO = c.collectDiskIO(now); return nil })
collect(func() (err error) { netStats, err = c.collectNetwork(now); return })
collect(func() (err error) { proxyStats = collectProxy(); return nil })
@@ -305,7 +321,7 @@ func (c *Collector) Collect() (MetricsSnapshot, error) {
}
return nil
})
- collect(func() (err error) { topProcs = collectTopProcesses(); return nil })
+ collect(func() (err error) { allProcs, err = collectProcesses(); return })
// Wait for all to complete.
wg.Wait()
@@ -319,13 +335,22 @@ func (c *Collector) Collect() (MetricsSnapshot, error) {
}
hwInfo := c.cachedHW
- score, scoreMsg := calculateHealthScore(cpuStats, memStats, diskStats, diskIO, thermalStats)
+ score, scoreMsg := calculateHealthScore(cpuStats, memStats, diskStats, diskIO, thermalStats, batteryStats, hostInfo.Uptime)
+ topProcs := topProcesses(allProcs, 5)
+
+ var processAlerts []ProcessAlert
+ c.watchMu.Lock()
+ if c.processWatcher != nil {
+ processAlerts = c.processWatcher.Update(now, allProcs)
+ }
+ c.watchMu.Unlock()
return MetricsSnapshot{
CollectedAt: now,
Host: hostInfo.Hostname,
Platform: fmt.Sprintf("%s %s", hostInfo.Platform, hostInfo.PlatformVersion),
Uptime: formatUptime(hostInfo.Uptime),
+ UptimeSeconds: hostInfo.Uptime,
Procs: hostInfo.Procs,
Hardware: hwInfo,
HealthScore: score,
@@ -334,22 +359,26 @@ func (c *Collector) Collect() (MetricsSnapshot, error) {
GPU: gpuStats,
Memory: memStats,
Disks: diskStats,
+ TrashSize: trashSize,
+ TrashApprox: trashApprox,
DiskIO: diskIO,
Network: netStats,
NetworkHistory: NetworkHistory{
RxHistory: c.rxHistoryBuf.Slice(),
TxHistory: c.txHistoryBuf.Slice(),
},
- Proxy: proxyStats,
- Batteries: batteryStats,
- Thermal: thermalStats,
- Sensors: sensorStats,
- Bluetooth: btStats,
- TopProcesses: topProcs,
+ Proxy: proxyStats,
+ Batteries: batteryStats,
+ Thermal: thermalStats,
+ Sensors: sensorStats,
+ Bluetooth: btStats,
+ TopProcesses: topProcs,
+ ProcessWatch: c.processWatch,
+ ProcessAlerts: processAlerts,
}, mergeErr
}
-func runCmd(ctx context.Context, name string, args ...string) (string, error) {
+var runCmd = func(ctx context.Context, name string, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, name, args...)
output, err := cmd.Output()
if err != nil {
@@ -358,7 +387,7 @@ func runCmd(ctx context.Context, name string, args ...string) (string, error) {
return string(output), nil
}
-func commandExists(name string) bool {
+var commandExists = func(name string) bool {
if name == "" {
return false
}
diff --git a/Resources/mole/cmd/status/metrics_battery.go b/Resources/mole/cmd/status/metrics_battery.go
index c28e319..1d1bbeb 100644
--- a/Resources/mole/cmd/status/metrics_battery.go
+++ b/Resources/mole/cmd/status/metrics_battery.go
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
+ "math"
"os"
"path/filepath"
"runtime"
@@ -195,86 +196,105 @@ func collectThermal() ThermalStatus {
ctxPower, cancelPower := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancelPower()
if out, err := runCmd(ctxPower, "ioreg", "-rn", "AppleSmartBattery"); err == nil {
- for line := range strings.Lines(out) {
- line = strings.TrimSpace(line)
+ powerThermal := parseAppleSmartBatteryThermal(out)
+ thermal.BatteryTemp = powerThermal.BatteryTemp
+ thermal.SystemPower = powerThermal.SystemPower
+ thermal.AdapterPower = powerThermal.AdapterPower
+ thermal.BatteryPower = powerThermal.BatteryPower
+ }
- // Battery temperature ("Temperature" = 3055).
- if _, after, found := strings.Cut(line, "\"Temperature\" = "); found {
- valStr := strings.TrimSpace(after)
- if tempRaw, err := strconv.Atoi(valStr); err == nil && tempRaw > 0 {
- thermal.CPUTemp = float64(tempRaw) / 100.0
- }
- }
+ // Do not synthesize CPU temperature from battery sensors or cpu_thermal_level.
+ // Those values are not CPU-package temperatures and produce false overheating data.
+ return thermal
+}
- // Adapter power (Watts) from current adapter.
- if strings.Contains(line, "\"AdapterDetails\" = {") && !strings.Contains(line, "AppleRaw") {
- if _, after, found := strings.Cut(line, "\"Watts\"="); found {
- valStr := strings.TrimSpace(after)
- valStr, _, _ = strings.Cut(valStr, ",")
- valStr, _, _ = strings.Cut(valStr, "}")
- valStr = strings.TrimSpace(valStr)
- if watts, err := strconv.ParseFloat(valStr, 64); err == nil && watts > 0 {
- thermal.AdapterPower = watts
- }
- }
+func parseAppleSmartBatteryThermal(out string) ThermalStatus {
+ var thermal ThermalStatus
+
+ for line := range strings.Lines(out) {
+ line = strings.TrimSpace(line)
+
+ // AppleSmartBattery reports battery temperature in centi-degrees Celsius.
+ if _, after, found := strings.Cut(line, "\"Temperature\" = "); found {
+ valStr := strings.TrimSpace(after)
+ if tempRaw, err := strconv.Atoi(valStr); err == nil && tempRaw > 0 {
+ thermal.BatteryTemp = float64(tempRaw) / 100.0
}
+ }
- // System power consumption (mW -> W).
- if _, after, found := strings.Cut(line, "\"SystemPowerIn\"="); found {
+ // Adapter power (Watts) from current adapter.
+ if strings.Contains(line, "\"AdapterDetails\" = {") && !strings.Contains(line, "AppleRaw") {
+ if _, after, found := strings.Cut(line, "\"Watts\"="); found {
valStr := strings.TrimSpace(after)
valStr, _, _ = strings.Cut(valStr, ",")
valStr, _, _ = strings.Cut(valStr, "}")
valStr = strings.TrimSpace(valStr)
- if powerMW, err := strconv.ParseFloat(valStr, 64); err == nil {
- // SystemPower should always be positive, reject invalid values
- if powerMW >= 0 && powerMW < 1000000 { // 0 to 1000W
- thermal.SystemPower = powerMW / 1000.0
- }
+ if watts, err := strconv.ParseFloat(valStr, 64); err == nil && watts > 0 {
+ thermal.AdapterPower = watts
}
}
+ }
- // Battery power (mW -> W, positive = discharging, negative = charging).
- if _, after, found := strings.Cut(line, "\"BatteryPower\"="); found {
- valStr := strings.TrimSpace(after)
- valStr, _, _ = strings.Cut(valStr, ",")
- valStr, _, _ = strings.Cut(valStr, "}")
- valStr = strings.TrimSpace(valStr)
+ // System power consumption (mW -> W).
+ if _, after, found := strings.Cut(line, "\"SystemPowerIn\"="); found {
+ valStr := strings.TrimSpace(after)
+ valStr, _, _ = strings.Cut(valStr, ",")
+ valStr, _, _ = strings.Cut(valStr, "}")
+ valStr = strings.TrimSpace(valStr)
+ if powerMW, err := strconv.ParseFloat(valStr, 64); err == nil {
+ // SystemPower should always be positive, reject invalid values
+ if powerMW >= 0 && powerMW < 1000000 { // 0 to 1000W
+ thermal.SystemPower = powerMW / 1000.0
+ }
+ }
+ }
- var powerMW float64
- var parsed bool
+ // Battery power (mW -> W, positive = discharging, negative = charging).
+ if _, after, found := strings.Cut(line, "\"BatteryPower\"="); found {
+ valStr := strings.TrimSpace(after)
+ valStr, _, _ = strings.Cut(valStr, ",")
+ valStr, _, _ = strings.Cut(valStr, "}")
+ valStr = strings.TrimSpace(valStr)
- // Strategy 1: Try parsing as a signed integer first.
- // This handles standard positive values and explicit negative strings like "-12345".
- if valInt, err := strconv.ParseInt(valStr, 10, 64); err == nil {
- powerMW = float64(valInt)
- parsed = true
- } else if valUint, err := strconv.ParseUint(valStr, 10, 64); err == nil {
- // Strategy 2: Try parsing as an unsigned integer (Two's Complement).
- // ioreg often returns negative values as huge uint64 numbers (e.g. 2^64 - 100).
- // Casting such a uint64 to int64 correctly restores the negative value.
- powerMW = float64(int64(valUint))
- parsed = true
- }
+ var powerMW float64
+ var parsed bool
- if parsed {
- // Validate reasonable battery power range: -200W to 200W
- if powerMW > -200000 && powerMW < 200000 {
- thermal.BatteryPower = powerMW / 1000.0
+ // Strategy 1: Try parsing as a signed integer first.
+ // This handles standard positive values and explicit negative strings like "-12345".
+ if valInt, err := strconv.ParseInt(valStr, 10, 64); err == nil {
+ powerMW = float64(valInt)
+ parsed = true
+ } else if valUint, err := strconv.ParseUint(valStr, 10, 64); err == nil {
+ // Strategy 2: Try parsing as an unsigned integer (Two's Complement).
+ // ioreg often returns negative values as huge uint64 numbers (e.g. 2^64 - 100).
+ // Explicitly handle two's complement rather than relying on an unchecked cast.
+ var signed int64
+ if valUint <= math.MaxInt64 {
+ // Fits in positive int64 range directly.
+ signed = int64(valUint)
+ } else {
+ // Interpret as negative two's complement value.
+ // For a uint64 v > MaxInt64, the corresponding negative int64 is:
+ // -(^v + 1) where ^ is bitwise NOT in 64 bits.
+ negMag := ^valUint + 1
+ // negMag now holds the magnitude of the negative value as uint64.
+ if negMag <= math.MaxInt64 {
+ signed = -int64(negMag)
+ } else {
+ // Magnitude too large to represent; skip this parsing strategy.
+ goto skipUintParse
}
}
+ powerMW = float64(signed)
+ parsed = true
+ skipUintParse:
}
- }
- }
- // Fallback: thermal level proxy.
- if thermal.CPUTemp == 0 {
- ctx2, cancel2 := context.WithTimeout(context.Background(), 500*time.Millisecond)
- defer cancel2()
- out2, err := runCmd(ctx2, "sysctl", "-n", "machdep.xcpm.cpu_thermal_level")
- if err == nil {
- level, _ := strconv.Atoi(strings.TrimSpace(out2))
- if level >= 0 {
- thermal.CPUTemp = 45 + float64(level)*0.5
+ if parsed {
+ // Validate reasonable battery power range: -200W to 200W
+ if powerMW > -200000 && powerMW < 200000 {
+ thermal.BatteryPower = powerMW / 1000.0
+ }
}
}
}
diff --git a/Resources/mole/cmd/status/metrics_battery_test.go b/Resources/mole/cmd/status/metrics_battery_test.go
new file mode 100644
index 0000000..e66aac6
--- /dev/null
+++ b/Resources/mole/cmd/status/metrics_battery_test.go
@@ -0,0 +1,45 @@
+package main
+
+import (
+ "math"
+ "testing"
+)
+
+func TestParseAppleSmartBatteryThermalKeepsBatteryTemperatureOutOfCPUTemp(t *testing.T) {
+ out := `
+ | | "Temperature" = 3055
+ | | "SystemPowerIn"=19967
+ | | "BatteryPower"=13654
+ | | "AdapterDetails" = {"Watts"=96}
+`
+
+ thermal := parseAppleSmartBatteryThermal(out)
+
+ if thermal.CPUTemp != 0 {
+ t.Fatalf("expected cpu temp to stay unset, got %v", thermal.CPUTemp)
+ }
+ if math.Abs(thermal.BatteryTemp-30.55) > 0.001 {
+ t.Fatalf("expected battery temp 30.55, got %v", thermal.BatteryTemp)
+ }
+ if math.Abs(thermal.SystemPower-19.967) > 0.001 {
+ t.Fatalf("expected system power 19.967W, got %v", thermal.SystemPower)
+ }
+ if thermal.AdapterPower != 96 {
+ t.Fatalf("expected adapter power 96W, got %v", thermal.AdapterPower)
+ }
+ if math.Abs(thermal.BatteryPower-13.654) > 0.001 {
+ t.Fatalf("expected battery power 13.654W, got %v", thermal.BatteryPower)
+ }
+}
+
+func TestParseAppleSmartBatteryThermalParsesTwosComplementBatteryPower(t *testing.T) {
+ out := `
+ | | "BatteryPower"=18446744073709539271
+`
+
+ thermal := parseAppleSmartBatteryThermal(out)
+
+ if math.Abs(thermal.BatteryPower-(-12.345)) > 0.001 {
+ t.Fatalf("expected battery power -12.345W, got %v", thermal.BatteryPower)
+ }
+}
diff --git a/Resources/mole/cmd/status/metrics_disk.go b/Resources/mole/cmd/status/metrics_disk.go
index da14f4d..31638ae 100644
--- a/Resources/mole/cmd/status/metrics_disk.go
+++ b/Resources/mole/cmd/status/metrics_disk.go
@@ -4,9 +4,14 @@ import (
"context"
"errors"
"fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
"runtime"
"sort"
+ "strconv"
"strings"
+ "sync"
"time"
"github.com/shirou/gopsutil/v4/disk"
@@ -22,6 +27,23 @@ var skipDiskMounts = map[string]bool{
"/dev": true,
}
+var skipDiskFSTypes = map[string]bool{
+ "afpfs": true,
+ "autofs": true,
+ "cifs": true,
+ "devfs": true,
+ "fuse": true,
+ "fuseblk": true,
+ "fusefs": true,
+ "macfuse": true,
+ "nfs": true,
+ "osxfuse": true,
+ "procfs": true,
+ "smbfs": true,
+ "tmpfs": true,
+ "webdav": true,
+}
+
func collectDisks() ([]DiskStatus, error) {
partitions, err := disk.Partitions(false)
if err != nil {
@@ -34,17 +56,7 @@ func collectDisks() ([]DiskStatus, error) {
seenVolume = make(map[string]bool)
)
for _, part := range partitions {
- if strings.HasPrefix(part.Device, "/dev/loop") {
- continue
- }
- if skipDiskMounts[part.Mountpoint] {
- continue
- }
- if strings.HasPrefix(part.Mountpoint, "/System/Volumes/") {
- continue
- }
- // Skip /private mounts.
- if strings.HasPrefix(part.Mountpoint, "/private/") {
+ if shouldSkipDiskPartition(part) {
continue
}
baseDevice := baseDeviceName(part.Device)
@@ -58,21 +70,31 @@ func collectDisks() ([]DiskStatus, error) {
if err != nil || usage.Total == 0 {
continue
}
+ total := usage.Total
+ if runtime.GOOS == "darwin" {
+ total = correctDiskTotalBytes(part.Mountpoint, total)
+ }
// Skip <1GB volumes.
- if usage.Total < 1<<30 {
+ if total < 1<<30 {
continue
}
// Use size-based dedupe key for shared pools.
- volKey := fmt.Sprintf("%s:%d", part.Fstype, usage.Total)
+ volKey := fmt.Sprintf("%s:%d", part.Fstype, total)
if seenVolume[volKey] {
continue
}
+ used := usage.Used
+ usedPercent := usage.UsedPercent
+ if runtime.GOOS == "darwin" && strings.ToLower(part.Fstype) == "apfs" {
+ used, usedPercent = correctAPFSDiskUsage(part.Mountpoint, total, usage.Used)
+ }
+
disks = append(disks, DiskStatus{
Mount: part.Mountpoint,
Device: part.Device,
- Used: usage.Used,
- Total: usage.Total,
- UsedPercent: usage.UsedPercent,
+ Used: used,
+ Total: total,
+ UsedPercent: usedPercent,
Fstype: part.Fstype,
})
seenDevice[baseDevice] = true
@@ -97,11 +119,45 @@ func collectDisks() ([]DiskStatus, error) {
return disks, nil
}
+func shouldSkipDiskPartition(part disk.PartitionStat) bool {
+ if strings.HasPrefix(part.Device, "/dev/loop") {
+ return true
+ }
+ if skipDiskMounts[part.Mountpoint] {
+ return true
+ }
+ if strings.HasPrefix(part.Mountpoint, "/System/Volumes/") {
+ return true
+ }
+ if strings.HasPrefix(part.Mountpoint, "/private/") {
+ return true
+ }
+
+ fstype := strings.ToLower(part.Fstype)
+ if skipDiskFSTypes[fstype] || strings.Contains(fstype, "fuse") {
+ return true
+ }
+
+ // On macOS, local disks should come from /dev. This filters sshfs/macFUSE-style
+ // mounts that can mirror the root volume and show up as duplicate internal disks.
+ if runtime.GOOS == "darwin" && part.Device != "" && !strings.HasPrefix(part.Device, "/dev/") {
+ return true
+ }
+
+ return false
+}
+
var (
// External disk cache.
lastDiskCacheAt time.Time
diskTypeCache = make(map[string]bool)
diskCacheTTL = 2 * time.Minute
+
+ // Finder startup disk usage cache (macOS APFS purgeable-aware).
+ finderDiskCacheMu sync.Mutex
+ finderDiskCachedAt time.Time
+ finderDiskFree uint64
+ finderDiskTotal uint64
)
func annotateDiskTypes(disks []DiskStatus) {
@@ -179,6 +235,165 @@ func isExternalDisk(device string) (bool, error) {
return external, nil
}
+// correctDiskTotalBytes uses diskutil's plist output when macOS reports a
+// meaningfully different disk size than gopsutil. This fixes external APFS
+// volumes that can show doubled capacities through statfs/gopsutil.
+func correctDiskTotalBytes(mountpoint string, rawTotal uint64) uint64 {
+ if rawTotal == 0 || !commandExists("diskutil") {
+ return rawTotal
+ }
+
+ diskutilTotal, err := getDiskutilTotalBytes(mountpoint)
+ if err != nil || diskutilTotal == 0 {
+ return rawTotal
+ }
+
+ if uint64AbsDiff(rawTotal, diskutilTotal) > 1<<30 {
+ return diskutilTotal
+ }
+
+ return rawTotal
+}
+
+func getDiskutilTotalBytes(mountpoint string) (uint64, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+
+ out, err := runCmd(ctx, "diskutil", "info", "-plist", mountpoint)
+ if err != nil {
+ return 0, err
+ }
+
+ // Prefer TotalSize, but keep older/plainer keys as fallbacks.
+ return extractPlistUint(out, "TotalSize", "DiskSize", "Size")
+}
+
+// correctAPFSDiskUsage returns Finder-accurate used bytes and percent for an
+// APFS volume, accounting for purgeable caches and APFS local snapshots that
+// statfs incorrectly counts as "used". Uses a three-tier fallback:
+// 1. Finder via osascript (startup disk only) — exact match with macOS Finder
+// 2. diskutil APFSContainerFree — corrects APFS snapshot space
+// 3. Raw gopsutil values — original statfs-based calculation
+func correctAPFSDiskUsage(mountpoint string, total, rawUsed uint64) (used uint64, usedPercent float64) {
+ // Tier 1: Finder via osascript (startup disk at "/" only).
+ if mountpoint == "/" && commandExists("osascript") {
+ if finderFree, finderTotal, err := getFinderStartupDiskFreeBytes(); err == nil &&
+ finderTotal > 0 && finderFree <= finderTotal {
+ used = finderTotal - finderFree
+ usedPercent = float64(used) / float64(finderTotal) * 100.0
+ return
+ }
+ }
+
+ // Tier 2: diskutil APFSContainerFree (corrects APFS local snapshots).
+ if commandExists("diskutil") {
+ if containerFree, err := getAPFSContainerFreeBytes(mountpoint); err == nil && containerFree <= total {
+ corrected := total - containerFree
+ // Only apply if it meaningfully differs (>1GB) from raw to avoid noise.
+ if rawUsed > corrected && rawUsed-corrected > 1<<30 {
+ used = corrected
+ usedPercent = float64(used) / float64(total) * 100.0
+ return
+ }
+ }
+ }
+
+ // Tier 3: fall back to raw gopsutil values.
+ return rawUsed, float64(rawUsed) / float64(total) * 100.0
+}
+
+// getAPFSContainerFreeBytes returns the APFS container free space (including
+// purgeable snapshot space) by parsing `diskutil info -plist`. This corrects
+// for APFS local snapshots which statfs counts as used.
+func getAPFSContainerFreeBytes(mountpoint string) (uint64, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+
+ out, err := runCmd(ctx, "diskutil", "info", "-plist", mountpoint)
+ if err != nil {
+ return 0, err
+ }
+
+ return extractPlistUint(out, "APFSContainerFree")
+}
+
+// getFinderStartupDiskFreeBytes queries Finder via osascript for the startup
+// disk free space. Finder's value includes purgeable caches and APFS snapshots,
+// matching the "X GB of Y GB used" display. Results are cached for 2 minutes.
+func getFinderStartupDiskFreeBytes() (free, total uint64, err error) {
+ finderDiskCacheMu.Lock()
+ defer finderDiskCacheMu.Unlock()
+
+ if !finderDiskCachedAt.IsZero() && time.Since(finderDiskCachedAt) < diskCacheTTL {
+ return finderDiskFree, finderDiskTotal, nil
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ // Single call returns both values as a comma-separated pair.
+ out, err := runCmd(ctx, "osascript", "-e",
+ `tell application "Finder" to return {free space of startup disk, capacity of startup disk}`)
+ if err != nil {
+ // Cache the failure timestamp so repeated calls within diskCacheTTL
+ // return immediately instead of each waiting the full 5s timeout.
+ finderDiskCachedAt = time.Now()
+ return 0, 0, err
+ }
+
+ // Output format: "3.2489E+11, 4.9438E+11" or "324892202048, 494384795648"
+ parts := strings.SplitN(strings.TrimSpace(out), ",", 2)
+ if len(parts) != 2 {
+ return 0, 0, fmt.Errorf("unexpected osascript output: %q", out)
+ }
+
+ freeF, err1 := strconv.ParseFloat(strings.TrimSpace(parts[0]), 64)
+ totalF, err2 := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
+ if err1 != nil || err2 != nil || freeF <= 0 || totalF <= 0 {
+ return 0, 0, fmt.Errorf("failed to parse osascript output: %q", out)
+ }
+
+ finderDiskFree = uint64(freeF)
+ finderDiskTotal = uint64(totalF)
+ finderDiskCachedAt = time.Now()
+ return finderDiskFree, finderDiskTotal, nil
+}
+
+func extractPlistUint(plist string, keys ...string) (uint64, error) {
+ for _, key := range keys {
+ marker := "" + key + ""
+ _, rest, found := strings.Cut(plist, marker)
+ if !found {
+ continue
+ }
+
+ _, rest, found = strings.Cut(rest, "")
+ if !found {
+ continue
+ }
+
+ value, _, found := strings.Cut(rest, "")
+ if !found {
+ continue
+ }
+
+ parsed, err := strconv.ParseUint(strings.TrimSpace(value), 10, 64)
+ if err != nil {
+ return 0, fmt.Errorf("failed to parse %s: %v", key, err)
+ }
+ return parsed, nil
+ }
+
+ return 0, fmt.Errorf("%s not found", strings.Join(keys, "/"))
+}
+
+func uint64AbsDiff(a, b uint64) uint64 {
+ if a > b {
+ return a - b
+ }
+ return b - a
+}
+
func (c *Collector) collectDiskIO(now time.Time) DiskIOStatus {
counters, err := disk.IOCounters()
if err != nil || len(counters) == 0 {
@@ -217,3 +432,34 @@ func (c *Collector) collectDiskIO(now time.Time) DiskIOStatus {
return DiskIOStatus{ReadRate: readRate, WriteRate: writeRate}
}
+
+// collectTrashSize returns the total size in bytes of ~/.Trash and whether
+// the result is approximate (true when the 2s timeout was reached).
+func collectTrashSize() (uint64, bool) {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return 0, false
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ var total uint64
+ trashPath := filepath.Join(home, ".Trash")
+ _ = filepath.WalkDir(trashPath, func(_ string, d fs.DirEntry, err error) error {
+ if ctx.Err() != nil {
+ return fs.SkipAll
+ }
+ if err != nil {
+ return nil
+ }
+ if d.Type()&fs.ModeSymlink != 0 {
+ return nil
+ }
+ if !d.IsDir() {
+ if info, err := d.Info(); err == nil {
+ total += uint64(info.Size())
+ }
+ }
+ return nil
+ })
+ return total, ctx.Err() != nil
+}
diff --git a/Resources/mole/cmd/status/metrics_disk_test.go b/Resources/mole/cmd/status/metrics_disk_test.go
new file mode 100644
index 0000000..24fed33
--- /dev/null
+++ b/Resources/mole/cmd/status/metrics_disk_test.go
@@ -0,0 +1,150 @@
+package main
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/shirou/gopsutil/v4/disk"
+)
+
+func TestShouldSkipDiskPartition(t *testing.T) {
+ tests := []struct {
+ name string
+ part disk.PartitionStat
+ want bool
+ }{
+ {
+ name: "keep local apfs root volume",
+ part: disk.PartitionStat{
+ Device: "/dev/disk3s1s1",
+ Mountpoint: "/",
+ Fstype: "apfs",
+ },
+ want: false,
+ },
+ {
+ name: "skip macfuse mirror mount",
+ part: disk.PartitionStat{
+ Device: "kaku-local:/",
+ Mountpoint: "/Users/tw93/Library/Caches/dev.kaku/sshfs/kaku-local",
+ Fstype: "macfuse",
+ },
+ want: true,
+ },
+ {
+ name: "skip smb share",
+ part: disk.PartitionStat{
+ Device: "//server/share",
+ Mountpoint: "/Volumes/share",
+ Fstype: "smbfs",
+ },
+ want: true,
+ },
+ {
+ name: "skip system volume",
+ part: disk.PartitionStat{
+ Device: "/dev/disk3s5",
+ Mountpoint: "/System/Volumes/Data",
+ Fstype: "apfs",
+ },
+ want: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := shouldSkipDiskPartition(tt.part); got != tt.want {
+ t.Fatalf("shouldSkipDiskPartition(%+v) = %v, want %v", tt.part, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestExtractPlistUint(t *testing.T) {
+ t.Run("prefers first matching key", func(t *testing.T) {
+ raw := `
+TotalSize1099511627776
+DiskSize2199023255552
+`
+
+ got, err := extractPlistUint(raw, "TotalSize", "DiskSize")
+ if err != nil {
+ t.Fatalf("extractPlistUint() error = %v", err)
+ }
+ if got != 1099511627776 {
+ t.Fatalf("extractPlistUint() = %d, want %d", got, uint64(1099511627776))
+ }
+ })
+
+ t.Run("falls back to later keys", func(t *testing.T) {
+ raw := `DiskSize1099511627776`
+
+ got, err := extractPlistUint(raw, "TotalSize", "DiskSize", "Size")
+ if err != nil {
+ t.Fatalf("extractPlistUint() error = %v", err)
+ }
+ if got != 1099511627776 {
+ t.Fatalf("extractPlistUint() = %d, want %d", got, uint64(1099511627776))
+ }
+ })
+
+ t.Run("returns error for malformed integer", func(t *testing.T) {
+ raw := `TotalSizeoops`
+
+ if _, err := extractPlistUint(raw, "TotalSize"); err == nil {
+ t.Fatalf("extractPlistUint() expected parse error")
+ }
+ })
+}
+
+func TestCorrectDiskTotalBytes(t *testing.T) {
+ origRunCmd := runCmd
+ origCommandExists := commandExists
+ t.Cleanup(func() {
+ runCmd = origRunCmd
+ commandExists = origCommandExists
+ })
+
+ commandExists = func(name string) bool {
+ return name == "diskutil"
+ }
+
+ t.Run("uses diskutil total when meaningfully different", func(t *testing.T) {
+ runCmd = func(ctx context.Context, name string, args ...string) (string, error) {
+ if name != "diskutil" {
+ return "", errors.New("unexpected command")
+ }
+ return `TotalSize1099511627776`, nil
+ }
+
+ got := correctDiskTotalBytes("/Volumes/Backup", 2199023255552)
+ if got != 1099511627776 {
+ t.Fatalf("correctDiskTotalBytes() = %d, want %d", got, uint64(1099511627776))
+ }
+ })
+
+ t.Run("keeps raw total for small differences", func(t *testing.T) {
+ runCmd = func(ctx context.Context, name string, args ...string) (string, error) {
+ return `TotalSize1000500000000`, nil
+ }
+
+ const rawTotal = 1000000000000
+ got := correctDiskTotalBytes("/Volumes/FastSSD", rawTotal)
+ if got != rawTotal {
+ t.Fatalf("correctDiskTotalBytes() = %d, want %d", got, uint64(rawTotal))
+ }
+ })
+
+ t.Run("keeps raw total when diskutil fails", func(t *testing.T) {
+ runCmd = func(ctx context.Context, name string, args ...string) (string, error) {
+ return "", errors.New("diskutil failed")
+ }
+
+ const rawTotal = 1099511627776
+ got := correctDiskTotalBytes("/Volumes/FastSSD", rawTotal)
+ if got != rawTotal {
+ t.Fatalf("correctDiskTotalBytes() = %d, want %d", got, uint64(rawTotal))
+ }
+ })
+}
diff --git a/Resources/mole/cmd/status/metrics_health.go b/Resources/mole/cmd/status/metrics_health.go
index 4bfd090..614d6d2 100644
--- a/Resources/mole/cmd/status/metrics_health.go
+++ b/Resources/mole/cmd/status/metrics_health.go
@@ -35,9 +35,21 @@ const (
// Disk IO (MB/s).
ioNormalThreshold = 50.0
ioHighThreshold = 150.0
+
+ // Battery.
+ batteryCycleWarn = 500
+ batteryCycleDanger = 900
+ batteryCapWarn = 90
+ batteryCapDanger = 80
+
+ // Uptime (seconds).
+ uptimeWarnDays = 7
+ uptimeDangerDays = 14
+ uptimeWarnSecs = uptimeWarnDays * 86400
+ uptimeDangerSecs = uptimeDangerDays * 86400
)
-func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, diskIO DiskIOStatus, thermal ThermalStatus) (int, string) {
+func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, diskIO DiskIOStatus, thermal ThermalStatus, batteries []BatteryStatus, uptimeSecs uint64) (int, string) {
score := 100.0
issues := []string{}
@@ -123,6 +135,27 @@ func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, d
}
score -= ioPenalty
+ // Battery health penalty (only when battery present).
+ if len(batteries) > 0 {
+ b := batteries[0]
+ _, sev := batteryHealthLabel(b.CycleCount, b.Capacity)
+ switch sev {
+ case "danger":
+ score -= 5
+ issues = append(issues, "Battery Service Soon")
+ case "warn":
+ score -= 2
+ }
+ }
+
+ // Uptime penalty (long uptime without restart).
+ if uptimeSecs > uptimeDangerSecs {
+ score -= 3
+ issues = append(issues, "Restart Recommended")
+ } else if uptimeSecs > uptimeWarnSecs {
+ score -= 1
+ }
+
// Clamp score.
if score < 0 {
score = 0
@@ -153,6 +186,29 @@ func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, d
return int(score), msg
}
+// batteryHealthLabel returns a human-readable health label and severity based on cycle count and capacity.
+// Severity is "ok", "warn", or "danger".
+func batteryHealthLabel(cycles int, capacity int) (string, string) {
+ if cycles > batteryCycleDanger || (capacity > 0 && capacity < batteryCapDanger) {
+ return "Service Soon", "danger"
+ }
+ if cycles > batteryCycleWarn || (capacity > 0 && capacity < batteryCapWarn) {
+ return "Fair", "warn"
+ }
+ return "Healthy", "ok"
+}
+
+// uptimeSeverity returns "ok", "warn", or "danger" based on uptime seconds.
+func uptimeSeverity(secs uint64) string {
+ if secs > uptimeDangerSecs {
+ return "danger"
+ }
+ if secs > uptimeWarnSecs {
+ return "warn"
+ }
+ return "ok"
+}
+
func formatUptime(secs uint64) string {
days := secs / 86400
hours := (secs % 86400) / 3600
diff --git a/Resources/mole/cmd/status/metrics_health_test.go b/Resources/mole/cmd/status/metrics_health_test.go
index b88df18..13ebc1b 100644
--- a/Resources/mole/cmd/status/metrics_health_test.go
+++ b/Resources/mole/cmd/status/metrics_health_test.go
@@ -12,6 +12,7 @@ func TestCalculateHealthScorePerfect(t *testing.T) {
[]DiskStatus{{UsedPercent: 30}},
DiskIOStatus{ReadRate: 5, WriteRate: 5},
ThermalStatus{CPUTemp: 40},
+ nil, 0,
)
if score != 100 {
@@ -29,6 +30,7 @@ func TestCalculateHealthScoreDetectsIssues(t *testing.T) {
[]DiskStatus{{UsedPercent: 95}},
DiskIOStatus{ReadRate: 120, WriteRate: 80},
ThermalStatus{CPUTemp: 90},
+ nil, 0,
)
if score >= 40 {
@@ -160,7 +162,7 @@ func TestCalculateHealthScoreEdgeCases(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- score, _ := calculateHealthScore(tt.cpu, tt.mem, tt.disks, tt.diskIO, tt.thermal)
+ score, _ := calculateHealthScore(tt.cpu, tt.mem, tt.disks, tt.diskIO, tt.thermal, nil, 0)
if score < tt.wantMin || score > tt.wantMax {
t.Errorf("calculateHealthScore() = %d, want range [%d, %d]", score, tt.wantMin, tt.wantMax)
}
@@ -168,6 +170,77 @@ func TestCalculateHealthScoreEdgeCases(t *testing.T) {
}
}
+func TestBatteryHealthLabel(t *testing.T) {
+ tests := []struct {
+ name string
+ cycles int
+ capacity int
+ label string
+ severity string
+ }{
+ {"new battery", 100, 98, "Healthy", "ok"},
+ {"moderate cycles", 600, 92, "Fair", "warn"},
+ {"high cycles", 950, 85, "Service Soon", "danger"},
+ {"low capacity", 200, 75, "Service Soon", "danger"},
+ {"warn capacity", 200, 88, "Fair", "warn"},
+ {"zero values", 0, 0, "Healthy", "ok"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ label, severity := batteryHealthLabel(tt.cycles, tt.capacity)
+ if label != tt.label {
+ t.Errorf("batteryHealthLabel(%d, %d) label = %q, want %q", tt.cycles, tt.capacity, label, tt.label)
+ }
+ if severity != tt.severity {
+ t.Errorf("batteryHealthLabel(%d, %d) severity = %q, want %q", tt.cycles, tt.capacity, severity, tt.severity)
+ }
+ })
+ }
+}
+
+func TestUptimeSeverity(t *testing.T) {
+ tests := []struct {
+ name string
+ secs uint64
+ want string
+ }{
+ {"fresh restart", 3600, "ok"},
+ {"6 days", 6 * 86400, "ok"},
+ {"8 days", 8 * 86400, "warn"},
+ {"15 days", 15 * 86400, "danger"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := uptimeSeverity(tt.secs)
+ if got != tt.want {
+ t.Errorf("uptimeSeverity(%d) = %q, want %q", tt.secs, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestHealthScoreBatteryPenalty(t *testing.T) {
+ base := func(batts []BatteryStatus, uptime uint64) int {
+ s, _ := calculateHealthScore(
+ CPUStatus{Usage: 10}, MemoryStatus{UsedPercent: 20},
+ []DiskStatus{{UsedPercent: 30}}, DiskIOStatus{ReadRate: 5, WriteRate: 5},
+ ThermalStatus{CPUTemp: 40}, batts, uptime,
+ )
+ return s
+ }
+
+ perfect := base(nil, 0)
+ withOldBattery := base([]BatteryStatus{{CycleCount: 950, Capacity: 75}}, 0)
+ withLongUptime := base(nil, 15*86400)
+
+ if withOldBattery >= perfect {
+ t.Errorf("old battery should reduce score: got %d vs perfect %d", withOldBattery, perfect)
+ }
+ if withLongUptime >= perfect {
+ t.Errorf("long uptime should reduce score: got %d vs perfect %d", withLongUptime, perfect)
+ }
+}
+
func TestFormatUptimeEdgeCases(t *testing.T) {
tests := []struct {
name string
diff --git a/Resources/mole/cmd/status/metrics_process.go b/Resources/mole/cmd/status/metrics_process.go
index b11f25c..9934bd1 100644
--- a/Resources/mole/cmd/status/metrics_process.go
+++ b/Resources/mole/cmd/status/metrics_process.go
@@ -2,52 +2,97 @@ package main
import (
"context"
+ "fmt"
"runtime"
+ "sort"
"strconv"
"strings"
"time"
)
-func collectTopProcesses() []ProcessInfo {
+func collectProcesses() ([]ProcessInfo, error) {
if runtime.GOOS != "darwin" {
- return nil
+ return nil, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
- // Use ps to get top processes by CPU.
- out, err := runCmd(ctx, "ps", "-Aceo", "pcpu,pmem,comm", "-r")
+ out, err := runCmd(ctx, "ps", "-Aceo", "pid=,ppid=,pcpu=,pmem=,comm=", "-r")
if err != nil {
- return nil
+ return nil, err
}
+ return parseProcessOutput(out), nil
+}
+func parseProcessOutput(raw string) []ProcessInfo {
var procs []ProcessInfo
- i := 0
- for line := range strings.Lines(strings.TrimSpace(out)) {
- if i == 0 {
- i++
+ for line := range strings.Lines(strings.TrimSpace(raw)) {
+ fields := strings.Fields(line)
+ if len(fields) < 5 {
continue
}
- if i > 5 {
- break
+
+ pid, err := strconv.Atoi(fields[0])
+ if err != nil || pid <= 0 {
+ continue
}
- i++
- fields := strings.Fields(line)
- if len(fields) < 3 {
+ ppid, _ := strconv.Atoi(fields[1])
+ cpuVal, err := strconv.ParseFloat(fields[2], 64)
+ if err != nil {
continue
}
- cpuVal, _ := strconv.ParseFloat(fields[0], 64)
- memVal, _ := strconv.ParseFloat(fields[1], 64)
- name := fields[len(fields)-1]
+ memVal, err := strconv.ParseFloat(fields[3], 64)
+ if err != nil {
+ continue
+ }
+
+ command := strings.Join(fields[4:], " ")
+ if command == "" {
+ continue
+ }
+ name := command
// Strip path from command name.
if idx := strings.LastIndex(name, "/"); idx >= 0 {
name = name[idx+1:]
}
procs = append(procs, ProcessInfo{
- Name: name,
- CPU: cpuVal,
- Memory: memVal,
+ PID: pid,
+ PPID: ppid,
+ Name: name,
+ Command: command,
+ CPU: cpuVal,
+ Memory: memVal,
})
}
return procs
}
+
+func topProcesses(processes []ProcessInfo, limit int) []ProcessInfo {
+ if limit <= 0 || len(processes) == 0 {
+ return nil
+ }
+
+ procs := make([]ProcessInfo, len(processes))
+ copy(procs, processes)
+ sort.Slice(procs, func(i, j int) bool {
+ if procs[i].CPU != procs[j].CPU {
+ return procs[i].CPU > procs[j].CPU
+ }
+ if procs[i].Memory != procs[j].Memory {
+ return procs[i].Memory > procs[j].Memory
+ }
+ return procs[i].PID < procs[j].PID
+ })
+
+ if len(procs) > limit {
+ procs = procs[:limit]
+ }
+ return procs
+}
+
+func formatProcessLabel(proc ProcessInfo) string {
+ if proc.Name != "" {
+ return fmt.Sprintf("%s (%d)", proc.Name, proc.PID)
+ }
+ return fmt.Sprintf("pid %d", proc.PID)
+}
diff --git a/Resources/mole/cmd/status/process_watch.go b/Resources/mole/cmd/status/process_watch.go
new file mode 100644
index 0000000..819b881
--- /dev/null
+++ b/Resources/mole/cmd/status/process_watch.go
@@ -0,0 +1,150 @@
+package main
+
+import (
+ "sort"
+ "time"
+)
+
+type ProcessWatchOptions struct {
+ Enabled bool
+ CPUThreshold float64
+ Window time.Duration
+}
+
+type ProcessWatchConfig struct {
+ Enabled bool `json:"enabled"`
+ CPUThreshold float64 `json:"cpu_threshold"`
+ Window string `json:"window"`
+}
+
+type ProcessAlert struct {
+ PID int `json:"pid"`
+ Name string `json:"name"`
+ Command string `json:"command,omitempty"`
+ CPU float64 `json:"cpu"`
+ Threshold float64 `json:"threshold"`
+ Window string `json:"window"`
+ TriggeredAt time.Time `json:"triggered_at"`
+ Status string `json:"status"`
+}
+
+type trackedProcess struct {
+ info ProcessInfo
+ firstAbove time.Time
+ triggeredAt time.Time
+ currentAbove bool
+}
+
+type processIdentity struct {
+ pid int
+ ppid int
+ command string
+}
+
+type ProcessWatcher struct {
+ options ProcessWatchOptions
+ tracks map[processIdentity]*trackedProcess
+}
+
+func NewProcessWatcher(options ProcessWatchOptions) *ProcessWatcher {
+ return &ProcessWatcher{
+ options: options,
+ tracks: make(map[processIdentity]*trackedProcess),
+ }
+}
+
+func (o ProcessWatchOptions) SnapshotConfig() ProcessWatchConfig {
+ return ProcessWatchConfig{
+ Enabled: o.Enabled,
+ CPUThreshold: o.CPUThreshold,
+ Window: o.Window.String(),
+ }
+}
+
+func (w *ProcessWatcher) Update(now time.Time, processes []ProcessInfo) []ProcessAlert {
+ if w == nil || !w.options.Enabled {
+ return nil
+ }
+
+ seen := make(map[processIdentity]bool, len(processes))
+ for _, proc := range processes {
+ if proc.PID <= 0 {
+ continue
+ }
+ key := processIdentity{
+ pid: proc.PID,
+ ppid: proc.PPID,
+ command: proc.Command,
+ }
+ seen[key] = true
+
+ track, ok := w.tracks[key]
+ if !ok {
+ track = &trackedProcess{}
+ w.tracks[key] = track
+ }
+
+ track.info = proc
+ track.currentAbove = proc.CPU >= w.options.CPUThreshold
+
+ if track.currentAbove {
+ if track.firstAbove.IsZero() {
+ track.firstAbove = now
+ }
+ if now.Sub(track.firstAbove) >= w.options.Window && track.triggeredAt.IsZero() {
+ track.triggeredAt = now
+ }
+ continue
+ }
+
+ track.firstAbove = time.Time{}
+ track.triggeredAt = time.Time{}
+ }
+
+ for pid := range w.tracks {
+ if !seen[pid] {
+ delete(w.tracks, pid)
+ }
+ }
+
+ return w.Snapshot()
+}
+
+func (w *ProcessWatcher) Snapshot() []ProcessAlert {
+ if w == nil || !w.options.Enabled {
+ return nil
+ }
+
+ alerts := make([]ProcessAlert, 0, len(w.tracks))
+ for _, track := range w.tracks {
+ if !track.currentAbove || track.triggeredAt.IsZero() {
+ continue
+ }
+
+ alerts = append(alerts, ProcessAlert{
+ PID: track.info.PID,
+ Name: track.info.Name,
+ Command: track.info.Command,
+ CPU: track.info.CPU,
+ Threshold: w.options.CPUThreshold,
+ Window: w.options.Window.String(),
+ TriggeredAt: track.triggeredAt,
+ Status: "active",
+ })
+ }
+
+ sort.Slice(alerts, func(i, j int) bool {
+ if alerts[i].Status != alerts[j].Status {
+ return alerts[i].Status == "active"
+ }
+ if !alerts[i].TriggeredAt.Equal(alerts[j].TriggeredAt) {
+ return alerts[i].TriggeredAt.Before(alerts[j].TriggeredAt)
+ }
+ if alerts[i].CPU != alerts[j].CPU {
+ return alerts[i].CPU > alerts[j].CPU
+ }
+ return alerts[i].PID < alerts[j].PID
+ })
+
+ return alerts
+}
diff --git a/Resources/mole/cmd/status/process_watch_test.go b/Resources/mole/cmd/status/process_watch_test.go
new file mode 100644
index 0000000..6868c6b
--- /dev/null
+++ b/Resources/mole/cmd/status/process_watch_test.go
@@ -0,0 +1,182 @@
+package main
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestParseProcessOutput(t *testing.T) {
+ raw := strings.Join([]string{
+ "123 1 145.2 10.1 /Applications/Visual Studio Code.app/Contents/MacOS/Electron",
+ "456 1 99.5 2.2 /System/Library/CoreServices/Finder.app/Contents/MacOS/Finder",
+ "bad line",
+ }, "\n")
+
+ procs := parseProcessOutput(raw)
+ if len(procs) != 2 {
+ t.Fatalf("parseProcessOutput() len = %d, want 2", len(procs))
+ }
+
+ if procs[0].PID != 123 || procs[0].PPID != 1 {
+ t.Fatalf("unexpected pid/ppid: %+v", procs[0])
+ }
+ if procs[0].Name != "Electron" {
+ t.Fatalf("unexpected process name %q", procs[0].Name)
+ }
+ if !strings.Contains(procs[0].Command, "Visual Studio Code.app") {
+ t.Fatalf("command path missing spaces: %q", procs[0].Command)
+ }
+}
+
+func TestTopProcessesSortsByCPU(t *testing.T) {
+ procs := []ProcessInfo{
+ {PID: 3, Name: "low", CPU: 20, Memory: 3},
+ {PID: 1, Name: "high", CPU: 120, Memory: 1},
+ {PID: 2, Name: "mid", CPU: 120, Memory: 8},
+ }
+
+ top := topProcesses(procs, 2)
+ if len(top) != 2 {
+ t.Fatalf("topProcesses() len = %d, want 2", len(top))
+ }
+ if top[0].PID != 2 || top[1].PID != 1 {
+ t.Fatalf("unexpected order: %+v", top)
+ }
+}
+
+func TestProcessWatcherTriggersAfterContinuousWindow(t *testing.T) {
+ base := time.Date(2026, 3, 19, 10, 0, 0, 0, time.UTC)
+ watcher := NewProcessWatcher(ProcessWatchOptions{
+ Enabled: true,
+ CPUThreshold: 100,
+ Window: 5 * time.Minute,
+ })
+
+ proc := []ProcessInfo{{PID: 42, Name: "stress", CPU: 140}}
+ if alerts := watcher.Update(base, proc); len(alerts) != 0 {
+ t.Fatalf("unexpected early alerts: %+v", alerts)
+ }
+ if alerts := watcher.Update(base.Add(4*time.Minute), proc); len(alerts) != 0 {
+ t.Fatalf("unexpected early alerts at 4m: %+v", alerts)
+ }
+ alerts := watcher.Update(base.Add(5*time.Minute), proc)
+ if len(alerts) != 1 {
+ t.Fatalf("expected 1 alert after full window, got %+v", alerts)
+ }
+ if alerts[0].Status != "active" {
+ t.Fatalf("unexpected alert status %q", alerts[0].Status)
+ }
+}
+
+func TestProcessWatcherResetsWhenUsageDrops(t *testing.T) {
+ base := time.Date(2026, 3, 19, 10, 0, 0, 0, time.UTC)
+ watcher := NewProcessWatcher(ProcessWatchOptions{
+ Enabled: true,
+ CPUThreshold: 100,
+ Window: 5 * time.Minute,
+ })
+
+ high := []ProcessInfo{{PID: 42, Name: "stress", CPU: 140}}
+ low := []ProcessInfo{{PID: 42, Name: "stress", CPU: 30}}
+
+ watcher.Update(base, high)
+ watcher.Update(base.Add(4*time.Minute), high)
+ if alerts := watcher.Update(base.Add(4*time.Minute+30*time.Second), low); len(alerts) != 0 {
+ t.Fatalf("expected reset after dip, got %+v", alerts)
+ }
+ if alerts := watcher.Update(base.Add(9*time.Minute), high); len(alerts) != 0 {
+ t.Fatalf("expected no alert after reset, got %+v", alerts)
+ }
+ if alerts := watcher.Update(base.Add(14*time.Minute), high); len(alerts) != 1 {
+ t.Fatalf("expected alert after second full window, got %+v", alerts)
+ }
+}
+
+func TestProcessWatcherResetsOnPIDReuse(t *testing.T) {
+ base := time.Date(2026, 3, 19, 10, 0, 0, 0, time.UTC)
+ watcher := NewProcessWatcher(ProcessWatchOptions{
+ Enabled: true,
+ CPUThreshold: 100,
+ Window: 2 * time.Minute,
+ })
+
+ firstProc := []ProcessInfo{{
+ PID: 42,
+ PPID: 1,
+ Name: "stress",
+ Command: "/usr/bin/stress",
+ CPU: 140,
+ }}
+ secondProc := []ProcessInfo{{
+ PID: 42,
+ PPID: 99,
+ Name: "node",
+ Command: "/usr/local/bin/node /tmp/server.js",
+ CPU: 135,
+ }}
+
+ watcher.Update(base, firstProc)
+ if alerts := watcher.Update(base.Add(2*time.Minute), firstProc); len(alerts) != 1 {
+ t.Fatalf("expected first process to alert after window, got %+v", alerts)
+ }
+
+ if alerts := watcher.Update(base.Add(3*time.Minute), secondProc); len(alerts) != 0 {
+ t.Fatalf("expected pid reuse to reset tracking, got %+v", alerts)
+ }
+ if alerts := watcher.Update(base.Add(5*time.Minute), secondProc); len(alerts) != 1 {
+ t.Fatalf("expected reused pid to alert only after its own window, got %+v", alerts)
+ }
+}
+
+func TestRenderProcessAlertBar(t *testing.T) {
+ alerts := []ProcessAlert{
+ {PID: 10, Name: "node", CPU: 150, Threshold: 100, Window: "5m0s", Status: "active"},
+ {PID: 11, Name: "java", CPU: 130, Threshold: 100, Window: "5m0s", Status: "active"},
+ }
+
+ bar := renderProcessAlertBar(alerts, 120)
+ if !strings.Contains(bar, "ALERT") {
+ t.Fatalf("missing alert prefix: %q", bar)
+ }
+ if !strings.Contains(bar, "node (10)") {
+ t.Fatalf("missing lead process label: %q", bar)
+ }
+ if !strings.Contains(bar, "+1 more") {
+ t.Fatalf("missing additional alert count: %q", bar)
+ }
+ if strings.Contains(bar, "terminate") || strings.Contains(bar, "ignore") {
+ t.Fatalf("unexpected action text in read-only alert bar: %q", bar)
+ }
+}
+
+func TestMetricsSnapshotJSONIncludesProcessWatch(t *testing.T) {
+ snapshot := MetricsSnapshot{
+ ProcessWatch: ProcessWatchConfig{
+ Enabled: true,
+ CPUThreshold: 100,
+ Window: "5m0s",
+ },
+ ProcessAlerts: []ProcessAlert{{
+ PID: 99,
+ Name: "node",
+ CPU: 140,
+ Threshold: 100,
+ Window: "5m0s",
+ Status: "active",
+ }},
+ }
+
+ data, err := json.Marshal(snapshot)
+ if err != nil {
+ t.Fatalf("json.Marshal() error = %v", err)
+ }
+ out := string(data)
+ if !strings.Contains(out, "\"process_watch\"") {
+ t.Fatalf("missing process_watch in json: %s", out)
+ }
+ if !strings.Contains(out, "\"process_alerts\"") {
+ t.Fatalf("missing process_alerts in json: %s", out)
+ }
+}
diff --git a/Resources/mole/cmd/status/view.go b/Resources/mole/cmd/status/view.go
index 217d53c..a2a8b0f 100644
--- a/Resources/mole/cmd/status/view.go
+++ b/Resources/mole/cmd/status/view.go
@@ -17,7 +17,12 @@ var (
okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#A5D6A7"))
lineStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#404040"))
- primaryStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#BD93F9"))
+ primaryStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#BD93F9"))
+ alertBarStyle = lipgloss.NewStyle().
+ Foreground(lipgloss.Color("#2B1200")).
+ Background(lipgloss.Color("#FFD75F")).
+ Bold(true).
+ Padding(0, 1)
)
const (
@@ -172,7 +177,16 @@ func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int
optionalInfoParts = append(optionalInfoParts, m.Hardware.OSVersion)
}
if !compactHeader && m.Uptime != "" {
- optionalInfoParts = append(optionalInfoParts, subtleStyle.Render("up "+m.Uptime))
+ uptimeText := "up " + m.Uptime
+ switch uptimeSeverity(m.UptimeSeconds) {
+ case "danger":
+ uptimeText = dangerStyle.Render(uptimeText + " ↻")
+ case "warn":
+ uptimeText = warnStyle.Render(uptimeText)
+ default:
+ uptimeText = subtleStyle.Render(uptimeText)
+ }
+ optionalInfoParts = append(optionalInfoParts, uptimeText)
}
headLeft := title + " " + scoreText
@@ -234,6 +248,35 @@ func getScoreStyle(score int) lipgloss.Style {
}
}
+func renderProcessAlertBar(alerts []ProcessAlert, width int) string {
+ active := activeAlerts(alerts)
+ if len(active) == 0 {
+ return ""
+ }
+
+ focus := active[0]
+
+ text := fmt.Sprintf(
+ "ALERT %s at %.1f%% for %s (threshold %.1f%%)",
+ formatProcessLabel(ProcessInfo{PID: focus.PID, Name: focus.Name}),
+ focus.CPU,
+ focus.Window,
+ focus.Threshold,
+ )
+ if len(active) > 1 {
+ text += fmt.Sprintf(" · +%d more", len(active)-1)
+ }
+
+ return renderBanner(alertBarStyle, text, width)
+}
+
+func renderBanner(style lipgloss.Style, text string, width int) string {
+ if width > 0 {
+ style = style.MaxWidth(width)
+ }
+ return style.Render(text)
+}
+
func renderCPUCard(cpu CPUStatus, thermal ThermalStatus) cardData {
var lines []string
@@ -346,7 +389,7 @@ func renderMemoryCard(mem MemoryStatus, cardWidth int) cardData {
return cardData{icon: iconMemory, title: "Memory", lines: lines}
}
-func renderDiskCard(disks []DiskStatus, io DiskIOStatus) cardData {
+func renderDiskCard(disks []DiskStatus, io DiskIOStatus, trashSize uint64, trashApprox bool) cardData {
var lines []string
if len(disks) == 0 {
lines = append(lines, subtleStyle.Render("Collecting..."))
@@ -365,7 +408,16 @@ func renderDiskCard(disks []DiskStatus, io DiskIOStatus) cardData {
addGroup("EXTR", external)
if len(lines) == 0 {
lines = append(lines, subtleStyle.Render("No disks detected"))
+ } else if len(disks) == 1 {
+ lines = append(lines, formatDiskMetaLine(disks[0]))
+ }
+ }
+ if trashSize > 0 {
+ prefix := ""
+ if trashApprox {
+ prefix = "~"
}
+ lines = append(lines, fmt.Sprintf("%-6s %s%s", "Trash", prefix, humanBytesShort(trashSize)))
}
readBar := ioBar(io.ReadRate)
writeBar := ioBar(io.WriteRate)
@@ -398,8 +450,19 @@ func formatDiskLine(label string, d DiskStatus) string {
}
bar := progressBar(d.UsedPercent)
used := humanBytesShort(d.Used)
- total := humanBytesShort(d.Total)
- return fmt.Sprintf("%-6s %s %5.1f%%, %s/%s", label, bar, d.UsedPercent, used, total)
+ free := uint64(0)
+ if d.Total > d.Used {
+ free = d.Total - d.Used
+ }
+ return fmt.Sprintf("%-6s %s %s used, %s free", label, bar, used, humanBytesShort(free))
+}
+
+func formatDiskMetaLine(d DiskStatus) string {
+ parts := []string{humanBytesShort(d.Total)}
+ if d.Fstype != "" {
+ parts = append(parts, strings.ToUpper(d.Fstype))
+ }
+ return fmt.Sprintf("Total %s", strings.Join(parts, " · "))
}
func ioBar(rate float64) string {
@@ -435,7 +498,7 @@ func buildCards(m MetricsSnapshot, width int) []cardData {
cards := []cardData{
renderCPUCard(m.CPU, m.Thermal),
renderMemoryCard(m.Memory, width),
- renderDiskCard(m.Disks, m.DiskIO),
+ renderDiskCard(m.Disks, m.DiskIO, m.TrashSize, m.TrashApprox),
renderBatteryCard(m.Batteries, m.Thermal),
renderProcessCard(m.TopProcesses),
renderNetworkCard(m.Network, m.NetworkHistory, m.Proxy, width),
@@ -594,15 +657,34 @@ func renderBatteryCard(batts []BatteryStatus, thermal ThermalStatus) cardData {
lines = append(lines, statusStyle.Render(statusText+statusIcon))
healthParts := []string{}
- if b.Health != "" {
+
+ // Battery health assessment label.
+ if b.CycleCount > 0 || b.Capacity > 0 {
+ label, severity := batteryHealthLabel(b.CycleCount, b.Capacity)
+ switch severity {
+ case "danger":
+ healthParts = append(healthParts, dangerStyle.Render(label))
+ case "warn":
+ healthParts = append(healthParts, warnStyle.Render(label))
+ default:
+ healthParts = append(healthParts, okStyle.Render(label))
+ }
+ } else if b.Health != "" {
healthParts = append(healthParts, b.Health)
}
+
if b.CycleCount > 0 {
- healthParts = append(healthParts, fmt.Sprintf("%d cycles", b.CycleCount))
+ cycleText := fmt.Sprintf("%d cycles", b.CycleCount)
+ if b.CycleCount > batteryCycleDanger {
+ cycleText = dangerStyle.Render(cycleText)
+ } else if b.CycleCount > batteryCycleWarn {
+ cycleText = warnStyle.Render(cycleText)
+ }
+ healthParts = append(healthParts, cycleText)
}
- if thermal.CPUTemp > 0 {
- tempText := colorizeTemp(thermal.CPUTemp) + "°C" // Reuse common color logic
+ if thermal.BatteryTemp > 0 {
+ tempText := "Battery " + colorizeTemp(thermal.BatteryTemp) + "°C"
healthParts = append(healthParts, tempText)
}
diff --git a/Resources/mole/cmd/status/view_test.go b/Resources/mole/cmd/status/view_test.go
index d49f72b..823eef0 100644
--- a/Resources/mole/cmd/status/view_test.go
+++ b/Resources/mole/cmd/status/view_test.go
@@ -749,29 +749,52 @@ func TestMiniBar(t *testing.T) {
func TestFormatDiskLine(t *testing.T) {
tests := []struct {
- name string
- label string
- disk DiskStatus
+ name string
+ label string
+ disk DiskStatus
+ wantUsed string
+ wantFree string
+ wantNoSubstr string
}{
{
- name: "empty label defaults to DISK",
- label: "",
- disk: DiskStatus{UsedPercent: 50.5, Used: 100 << 30, Total: 200 << 30},
+ name: "empty label defaults to DISK",
+ label: "",
+ disk: DiskStatus{UsedPercent: 50.5, Used: 100 << 30, Total: 200 << 30},
+ wantUsed: "100G used",
+ wantFree: "100G free",
+ wantNoSubstr: "%",
+ },
+ {
+ name: "internal disk",
+ label: "INTR",
+ disk: DiskStatus{UsedPercent: 67.2, Used: 336 << 30, Total: 500 << 30},
+ wantUsed: "336G used",
+ wantFree: "164G free",
+ wantNoSubstr: "%",
},
{
- name: "internal disk",
- label: "INTR",
- disk: DiskStatus{UsedPercent: 67.2, Used: 336 << 30, Total: 500 << 30},
+ name: "external disk",
+ label: "EXTR1",
+ disk: DiskStatus{UsedPercent: 85.0, Used: 850 << 30, Total: 1000 << 30},
+ wantUsed: "850G used",
+ wantFree: "150G free",
+ wantNoSubstr: "%",
},
{
- name: "external disk",
- label: "EXTR1",
- disk: DiskStatus{UsedPercent: 85.0, Used: 850 << 30, Total: 1000 << 30},
+ name: "low usage",
+ label: "INTR",
+ disk: DiskStatus{UsedPercent: 15.3, Used: 15 << 30, Total: 100 << 30},
+ wantUsed: "15G used",
+ wantFree: "85G free",
+ wantNoSubstr: "%",
},
{
- name: "low usage",
- label: "INTR",
- disk: DiskStatus{UsedPercent: 15.3, Used: 15 << 30, Total: 100 << 30},
+ name: "used exceeds total clamps free to zero",
+ label: "INTR",
+ disk: DiskStatus{UsedPercent: 110.0, Used: 110 << 30, Total: 100 << 30},
+ wantUsed: "110G used",
+ wantFree: "0 free",
+ wantNoSubstr: "%",
},
}
@@ -786,9 +809,85 @@ func TestFormatDiskLine(t *testing.T) {
if expectedLabel == "" {
expectedLabel = "DISK"
}
- if !contains(got, expectedLabel) {
+ if !strings.Contains(got, expectedLabel) {
t.Errorf("formatDiskLine(%q, ...) = %q, should contain label %q", tt.label, got, expectedLabel)
}
+ if !strings.Contains(got, tt.wantUsed) {
+ t.Errorf("formatDiskLine(%q, ...) = %q, should contain used value %q", tt.label, got, tt.wantUsed)
+ }
+ if !strings.Contains(got, tt.wantFree) {
+ t.Errorf("formatDiskLine(%q, ...) = %q, should contain free value %q", tt.label, got, tt.wantFree)
+ }
+ if tt.wantNoSubstr != "" && strings.Contains(got, tt.wantNoSubstr) {
+ t.Errorf("formatDiskLine(%q, ...) = %q, should not contain %q", tt.label, got, tt.wantNoSubstr)
+ }
+ })
+ }
+}
+
+func TestRenderDiskCardAddsMetaLineForSingleDisk(t *testing.T) {
+ card := renderDiskCard([]DiskStatus{{
+ UsedPercent: 28.4,
+ Used: 263 << 30,
+ Total: 926 << 30,
+ Fstype: "apfs",
+ }}, DiskIOStatus{ReadRate: 0, WriteRate: 0.1}, 0, false)
+
+ if len(card.lines) != 4 {
+ t.Fatalf("renderDiskCard() single disk expected 4 lines, got %d", len(card.lines))
+ }
+
+ meta := stripANSI(card.lines[1])
+ if meta != "Total 926G · APFS" {
+ t.Fatalf("renderDiskCard() single disk meta line = %q, want %q", meta, "Total 926G · APFS")
+ }
+}
+
+func TestRenderDiskCardDoesNotAddMetaLineForMultipleDisks(t *testing.T) {
+ card := renderDiskCard([]DiskStatus{
+ {UsedPercent: 28.4, Used: 263 << 30, Total: 926 << 30, Fstype: "apfs"},
+ {UsedPercent: 50.0, Used: 500 << 30, Total: 1000 << 30, Fstype: "apfs"},
+ }, DiskIOStatus{}, 0, false)
+
+ if len(card.lines) != 4 {
+ t.Fatalf("renderDiskCard() multiple disks expected 4 lines, got %d", len(card.lines))
+ }
+
+ for _, line := range card.lines {
+ if stripANSI(line) == "Total 926G · APFS" || stripANSI(line) == "Total 1000G · APFS" {
+ t.Fatalf("renderDiskCard() multiple disks should not add meta line, got %q", line)
+ }
+ }
+}
+
+func TestRenderDiskCardTrashLine(t *testing.T) {
+ disk := DiskStatus{UsedPercent: 50, Used: 500 << 30, Total: 1000 << 30, Fstype: "apfs"}
+ tests := []struct {
+ name string
+ trashSize uint64
+ approx bool
+ wantLine string
+ }{
+ {"no trash", 0, false, ""},
+ {"1.5 GB exact", 1536 << 20, false, "Trash 2G"},
+ {"approx 12 GB", 12 << 30, true, "Trash ~12G"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ card := renderDiskCard([]DiskStatus{disk}, DiskIOStatus{}, tt.trashSize, tt.approx)
+ found := ""
+ for _, line := range card.lines {
+ if s := stripANSI(line); len(s) > 5 && s[:5] == "Trash" {
+ found = s
+ break
+ }
+ }
+ if tt.wantLine == "" && found != "" {
+ t.Fatalf("expected no trash line, got %q", found)
+ }
+ if tt.wantLine != "" && found != tt.wantLine {
+ t.Fatalf("trash line = %q, want %q", found, tt.wantLine)
+ }
})
}
}
@@ -1066,6 +1165,37 @@ func TestRenderMemoryCardShowsSwapSizeOnWideWidth(t *testing.T) {
}
}
+func TestModelViewPadsToTerminalHeight(t *testing.T) {
+ tests := []struct {
+ name string
+ width int
+ height int
+ }{
+ {"narrow terminal", 60, 40},
+ {"wide terminal", 120, 40},
+ {"tall terminal", 120, 80},
+ {"short terminal", 120, 10},
+ {"zero height", 120, 0},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ m := model{
+ width: tt.width,
+ height: tt.height,
+ ready: true,
+ metrics: MetricsSnapshot{},
+ }
+
+ view := m.View()
+ got := lipgloss.Height(view)
+ if got < tt.height {
+ t.Errorf("View() height = %d, want >= %d (terminal height)", got, tt.height)
+ }
+ })
+ }
+}
+
func TestModelViewErrorRendersSingleMole(t *testing.T) {
m := model{
width: 120,
@@ -1102,16 +1232,3 @@ func stripANSI(s string) string {
}
return result.String()
}
-
-func contains(s, substr string) bool {
- return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && (s[:len(substr)] == substr || s[len(s)-len(substr):] == substr || containsMiddle(s, substr)))
-}
-
-func containsMiddle(s, substr string) bool {
- for i := 0; i <= len(s)-len(substr); i++ {
- if s[i:i+len(substr)] == substr {
- return true
- }
- }
- return false
-}
diff --git a/Resources/mole/go.mod b/Resources/mole/go.mod
index 153fbad..e2b2d82 100644
--- a/Resources/mole/go.mod
+++ b/Resources/mole/go.mod
@@ -1,15 +1,13 @@
module github.com/tw93/mole
-go 1.24.2
-
-toolchain go1.24.6
+go 1.25.0
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.2
- golang.org/x/sync v0.19.0
+ github.com/shirou/gopsutil/v4 v4.26.3
+ golang.org/x/sync v0.20.0
)
require (
diff --git a/Resources/mole/go.sum b/Resources/mole/go.sum
index 66dea7c..a5932db 100644
--- a/Resources/mole/go.sum
+++ b/Resources/mole/go.sum
@@ -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.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI=
-github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
+github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
+github.com/shirou/gopsutil/v4 v4.26.3/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=
@@ -67,8 +67,8 @@ github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
-golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
-golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
diff --git a/Resources/mole/install.sh b/Resources/mole/install.sh
index d5eeb1a..cb6f563 100755
--- a/Resources/mole/install.sh
+++ b/Resources/mole/install.sh
@@ -19,6 +19,7 @@ start_line_spinner() {
return
}
local chars="|/-\\"
+ # shellcheck disable=SC1003
[[ -z "$chars" ]] && chars='|/-\\'
local i=0
(while true; do
@@ -302,6 +303,7 @@ write_install_channel_metadata() {
local commit_hash="${2:-}"
local metadata_file="$CONFIG_DIR/install_channel"
+ mkdir -p "$CONFIG_DIR" 2> /dev/null || return 1
local tmp_file
tmp_file=$(mktemp "${CONFIG_DIR}/install_channel.XXXXXX") || return 1
{
@@ -587,6 +589,7 @@ install_files() {
if [[ "$source_dir_abs" != "$install_dir_abs" ]]; then
if needs_sudo; then
log_admin "Admin access required for /usr/local/bin"
+ sudo -v
fi
# Atomic update: copy to temporary name first, then move
diff --git a/Resources/mole/lib/check/all.sh b/Resources/mole/lib/check/all.sh
index 4a6960a..46b422a 100644
--- a/Resources/mole/lib/check/all.sh
+++ b/Resources/mole/lib/check/all.sh
@@ -13,6 +13,11 @@ list_login_items() {
return
fi
+ # Skip AppleScript during tests to avoid permission dialogs
+ if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then
+ return
+ fi
+
local raw_items
raw_items=$(osascript -e 'tell application "System Events" to get the name of every login item' 2> /dev/null || echo "")
[[ -z "$raw_items" || "$raw_items" == "missing value" ]] && return
@@ -34,7 +39,8 @@ check_touchid_sudo() {
if command -v is_whitelisted > /dev/null && is_whitelisted "check_touchid"; then return; fi
# Check if Touch ID is configured for sudo
local pam_file="/etc/pam.d/sudo"
- if [[ -f "$pam_file" ]] && grep -q "pam_tid.so" "$pam_file" 2> /dev/null; then
+ local pam_local="/etc/pam.d/sudo_local"
+ if grep -q "pam_tid.so" "$pam_file" "$pam_local" 2> /dev/null; then
echo -e " ${GREEN}✓${NC} Touch ID Biometric authentication enabled"
else
# Check if Touch ID is supported
@@ -136,8 +142,8 @@ check_firewall() {
return
fi
- # Fall back to macOS built-in firewall check
- local firewall_output=$(sudo /usr/libexec/ApplicationFirewall/socketfilterfw --getglobalstate 2> /dev/null || echo "")
+ # Fall back to macOS built-in firewall check (no sudo needed for read-only query)
+ local firewall_output=$(/usr/libexec/ApplicationFirewall/socketfilterfw --getglobalstate 2> /dev/null || echo "")
if [[ "$firewall_output" == *"State = 1"* ]] || [[ "$firewall_output" == *"State = 2"* ]]; then
echo -e " ${GREEN}✓${NC} Firewall Network protection enabled"
else
@@ -207,6 +213,7 @@ reset_brew_cache() {
reset_softwareupdate_cache() {
clear_cache_file "$CACHE_DIR/softwareupdate_list"
SOFTWARE_UPDATE_LIST=""
+ SOFTWARE_UPDATE_LIST_LOADED="false"
}
reset_mole_cache() {
@@ -228,19 +235,90 @@ is_cache_valid() {
# Cache software update list to avoid calling softwareupdate twice
SOFTWARE_UPDATE_LIST=""
+SOFTWARE_UPDATE_LIST_LOADED="false"
+
+software_update_has_entries() {
+ printf '%s\n' "$1" | grep -qE '^[[:space:]]*\* Label:'
+}
+
+is_macos_software_update_text() {
+ local text
+ text=$(printf '%s' "$1" | LC_ALL=C tr '[:upper:]' '[:lower:]')
+
+ case "$text" in
+ *macos* | *background\ security\ improvement* | *rapid\ security\ response* | *security\ response*)
+ return 0
+ ;;
+ esac
+
+ return 1
+}
+
+get_first_macos_software_update_summary() {
+ printf '%s\n' "$1" | awk '
+ /^\* Label:/ {
+ label=$0
+ sub(/^[[:space:]]*\* Label: */, "", label)
+ next
+ }
+ /^[[:space:]]*Title:/ {
+ title=$0
+ sub(/^[[:space:]]*Title: */, "", title)
+ sub(/, Version:.*/, "", title)
+ sub(/, Size:.*/, "", title)
+ combined=tolower(label " " title)
+ if (combined ~ /macos|background security improvement|rapid security response|security response/) {
+ print title
+ exit
+ }
+ }
+ '
+}
get_software_updates() {
local cache_file="$CACHE_DIR/softwareupdate_list"
+ if [[ "${SOFTWARE_UPDATE_LIST_LOADED:-false}" == "true" ]]; then
+ printf '%s\n' "$SOFTWARE_UPDATE_LIST"
+ return 0
+ fi
- # Optimized: Use defaults to check if updates are pending (much faster)
- local pending_updates
- pending_updates=$(defaults read /Library/Preferences/com.apple.SoftwareUpdate LastRecommendedUpdatesAvailable 2> /dev/null || echo "0")
+ if is_cache_valid "$cache_file"; then
+ SOFTWARE_UPDATE_LIST=$(cat "$cache_file" 2> /dev/null || true)
+ SOFTWARE_UPDATE_LIST_LOADED="true"
+ printf '%s\n' "$SOFTWARE_UPDATE_LIST"
+ return 0
+ fi
- if [[ "$pending_updates" -gt 0 ]]; then
- echo "Updates Available"
+ local spinner_started=false
+ if [[ -t 1 && -z "${SOFTWAREUPDATE_SPINNER_SHOWN:-}" ]]; then
+ MOLE_SPINNER_PREFIX=" " start_inline_spinner "Checking system updates..."
+ spinner_started=true
+ export SOFTWAREUPDATE_SPINNER_SHOWN=1
+ fi
+
+ local output=""
+ local sw_status=0
+ if output=$(run_with_timeout 10 softwareupdate -l --no-scan 2> /dev/null); then
+ SOFTWARE_UPDATE_LIST="$output"
+ ensure_user_file "$cache_file"
+ printf '%s' "$SOFTWARE_UPDATE_LIST" > "$cache_file" 2> /dev/null || true
else
- echo ""
+ sw_status=$?
+ SOFTWARE_UPDATE_LIST=""
+ if [[ -f "$cache_file" ]]; then
+ SOFTWARE_UPDATE_LIST=$(cat "$cache_file" 2> /dev/null || true)
+ fi
+ if [[ -n "${MO_DEBUG:-}" ]]; then
+ echo "[DEBUG] softwareupdate preload exit status: $sw_status" >&2
+ fi
+ fi
+
+ if [[ "$spinner_started" == "true" ]]; then
+ stop_inline_spinner
fi
+
+ SOFTWARE_UPDATE_LIST_LOADED="true"
+ printf '%s\n' "$SOFTWARE_UPDATE_LIST"
}
check_homebrew_updates() {
@@ -285,17 +363,26 @@ check_homebrew_updates() {
spinner_started=true
fi
- if formula_outdated=$(run_with_timeout 8 brew outdated --formula --quiet 2> /dev/null); then
- :
- else
- formula_status=$?
- fi
-
- if cask_outdated=$(run_with_timeout 8 brew outdated --cask --quiet 2> /dev/null); then
- :
- else
- cask_status=$?
- fi
+ local _brew_formula_tmp _brew_cask_tmp
+ _brew_formula_tmp=$(mktemp_file "brew_formula")
+ _brew_cask_tmp=$(mktemp_file "brew_cask")
+ (
+ run_with_timeout 8 brew outdated --formula --quiet > "$_brew_formula_tmp" 2> /dev/null
+ echo $? > "${_brew_formula_tmp}.status"
+ ) &
+ local _formula_pid=$!
+ (
+ run_with_timeout 8 brew outdated --cask --quiet > "$_brew_cask_tmp" 2> /dev/null
+ echo $? > "${_brew_cask_tmp}.status"
+ ) &
+ local _cask_pid=$!
+ wait "$_formula_pid" 2> /dev/null || true
+ wait "$_cask_pid" 2> /dev/null || true
+ formula_outdated=$(cat "$_brew_formula_tmp" 2> /dev/null || true)
+ cask_outdated=$(cat "$_brew_cask_tmp" 2> /dev/null || true)
+ formula_status=$(cat "${_brew_formula_tmp}.status" 2> /dev/null || echo "1")
+ cask_status=$(cat "${_brew_cask_tmp}.status" 2> /dev/null || echo "1")
+ rm -f "$_brew_formula_tmp" "$_brew_cask_tmp" "${_brew_formula_tmp}.status" "${_brew_cask_tmp}.status" 2> /dev/null || true
if [[ "$spinner_started" == "true" ]]; then
stop_inline_spinner
@@ -304,8 +391,12 @@ check_homebrew_updates() {
if [[ $formula_status -eq 0 || $cask_status -eq 0 ]]; then
formula_count=$(printf '%s\n' "$formula_outdated" | awk 'NF {count++} END {print count + 0}')
cask_count=$(printf '%s\n' "$cask_outdated" | awk 'NF {count++} END {print count + 0}')
- ensure_user_file "$cache_file"
- printf '%s %s\n' "$formula_count" "$cask_count" > "$cache_file" 2> /dev/null || true
+ # Only cache when both calls succeeded; partial results (one side failed)
+ # must not be written as zeros — next run should retry the failed side.
+ if [[ $formula_status -eq 0 && $cask_status -eq 0 ]]; then
+ ensure_user_file "$cache_file"
+ printf '%s %s\n' "$formula_count" "$cask_count" > "$cache_file" 2> /dev/null || true
+ fi
elif [[ $formula_status -eq 124 || $cask_status -eq 124 ]]; then
printf " ${GRAY}${ICON_WARNING}${NC} %-12s ${YELLOW}%s${NC}\n" "Homebrew" "Check timed out"
return
@@ -346,52 +437,30 @@ check_macos_update() {
# Check whitelist
if command -v is_whitelisted > /dev/null && is_whitelisted "check_macos_updates"; then return; fi
- # Fast check using system preferences
local updates_available="false"
- if [[ $(get_software_updates) == "Updates Available" ]]; then
- updates_available="true"
-
- # Verify with softwareupdate using --no-scan to avoid triggering a fresh scan
- # which can timeout. We prioritize avoiding false negatives (missing actual updates)
- # over false positives, so we only clear the update flag when softwareupdate
- # explicitly reports "No new software available"
- local sw_output=""
- local sw_status=0
- local spinner_started=false
- if [[ -t 1 ]]; then
- MOLE_SPINNER_PREFIX=" " start_inline_spinner "Checking macOS updates..."
- spinner_started=true
- fi
+ local macos_update_summary=""
+ local sw_output=""
+ sw_output=$(get_software_updates)
- local softwareupdate_timeout=10
- if sw_output=$(run_with_timeout "$softwareupdate_timeout" softwareupdate -l --no-scan 2> /dev/null); then
- :
- else
- sw_status=$?
- fi
-
- if [[ "$spinner_started" == "true" ]]; then
- stop_inline_spinner
- fi
-
- # Debug logging for troubleshooting
- if [[ -n "${MO_DEBUG:-}" ]]; then
- echo "[DEBUG] softwareupdate exit status: $sw_status, output lines: $(echo "$sw_output" | wc -l | tr -d ' ')" >&2
- fi
+ if [[ -n "${MO_DEBUG:-}" ]]; then
+ echo "[DEBUG] softwareupdate cached output lines: $(printf '%s\n' "$sw_output" | wc -l | tr -d ' ')" >&2
+ fi
- # Prefer avoiding false negatives: if the system indicates updates are pending,
- # only clear the flag when softwareupdate returns a list without any update entries.
- if [[ $sw_status -eq 0 && -n "$sw_output" ]]; then
- if ! echo "$sw_output" | grep -qE '^[[:space:]]*\*'; then
- updates_available="false"
- fi
+ if software_update_has_entries "$sw_output"; then
+ macos_update_summary=$(get_first_macos_software_update_summary "$sw_output")
+ if [[ -n "$macos_update_summary" ]] || is_macos_software_update_text "$sw_output"; then
+ updates_available="true"
fi
fi
export MACOS_UPDATE_AVAILABLE="$updates_available"
if [[ "$updates_available" == "true" ]]; then
- printf " ${GRAY}%s${NC} %-12s ${YELLOW}%s${NC}\n" "$ICON_WARNING" "macOS" "Update available"
+ if [[ -n "$macos_update_summary" ]]; then
+ printf " ${GRAY}%s${NC} %-12s ${YELLOW}%s${NC}\n" "$ICON_WARNING" "macOS" "$macos_update_summary"
+ else
+ printf " ${GRAY}%s${NC} %-12s ${YELLOW}%s${NC}\n" "$ICON_WARNING" "macOS" "Update available"
+ fi
else
printf " ${GREEN}✓${NC} %-12s %s\n" "macOS" "System up to date"
fi
@@ -490,7 +559,7 @@ get_appstore_update_labels() {
sub(/^[[:space:]]*\* Label: */, "", label)
sub(/,.*/, "", label)
lower=tolower(label)
- if (index(lower, "macos") == 0) {
+ if (lower !~ /macos|background security improvement|rapid security response|security response/) {
print label
}
}
@@ -504,7 +573,7 @@ get_macos_update_labels() {
sub(/^[[:space:]]*\* Label: */, "", label)
sub(/,.*/, "", label)
lower=tolower(label)
- if (index(lower, "macos") != 0) {
+ if (lower ~ /macos|background security improvement|rapid security response|security response/) {
print label
}
}
@@ -698,9 +767,100 @@ check_swap_usage() {
fi
}
+check_disk_smart() {
+ # Check whitelist
+ if command -v is_whitelisted > /dev/null && is_whitelisted "check_disk_smart"; then return; fi
+
+ if ! command -v diskutil > /dev/null 2>&1; then
+ return
+ fi
+
+ local boot_disk smart_status
+ boot_disk=$(diskutil info / 2> /dev/null | awk -F: '/Part of Whole/ {gsub(/^[ \t]+/, "", $2); print $2}')
+ [[ -z "$boot_disk" ]] && return
+ smart_status=$(diskutil info "$boot_disk" 2> /dev/null | awk -F: '/SMART Status/ {gsub(/^[ \t]+/, "", $2); print $2}')
+
+ if [[ -z "$smart_status" ]]; then
+ return
+ fi
+
+ if [[ "$smart_status" == "Verified" ]]; then
+ echo -e " ${GREEN}✓${NC} Disk Health SMART Verified"
+ elif [[ "$smart_status" == "Failing" ]]; then
+ echo -e " ${RED}✗${NC} Disk Health ${RED}SMART Failing — back up immediately${NC}"
+ else
+ echo -e " ${GRAY}${ICON_WARNING}${NC} Disk Health ${YELLOW}SMART: ${smart_status}${NC}"
+ fi
+}
+
+check_orphan_launch_agents() {
+ if command -v is_whitelisted > /dev/null && is_whitelisted "check_orphan_launch_agents"; then return; fi
+
+ local -a search_dirs orphans=()
+ IFS=: read -r -a search_dirs <<< "${MOLE_LAUNCH_AGENT_DIRS:-$HOME/Library/LaunchAgents:/Library/LaunchAgents}"
+
+ local plist label program
+ for dir in "${search_dirs[@]}"; do
+ [[ -d "$dir" ]] || continue
+ while IFS= read -r -d '' plist; do
+ label=$(basename "$plist" .plist)
+ [[ "$label" == com.apple.* ]] && continue
+ program=$(/usr/bin/plutil -extract Program raw -o - "$plist" 2> /dev/null ||
+ /usr/bin/plutil -extract ProgramArguments.0 raw -o - "$plist" 2> /dev/null)
+ [[ "$program" == /* && ! -e "$program" ]] && orphans+=("$label")
+ done < <(find "$dir" -maxdepth 1 -name "*.plist" -print0 2> /dev/null)
+ done
+
+ local count=${#orphans[@]}
+ if [[ $count -eq 0 ]]; then
+ echo -e " ${GREEN}✓${NC} Launch Agents None orphaned"
+ return
+ fi
+
+ local s=""
+ ((count > 1)) && s="s"
+ echo -e " ${GRAY}${ICON_WARNING}${NC} Launch Agents ${YELLOW}${count} orphan${s}${NC}"
+ local preview="${orphans[0]}"
+ ((count > 1)) && preview="${preview}, ${orphans[1]}"
+ ((count > 2)) && preview="${preview}, ${orphans[2]}"
+ ((count > 3)) && preview="${preview} +$((count - 3))"
+ echo -e " ${GRAY}${preview}${NC}"
+}
+
check_brew_health() {
# Check whitelist
if command -v is_whitelisted > /dev/null && is_whitelisted "check_brew_health"; then return; fi
+
+ if ! command -v brew > /dev/null 2>&1; then
+ return
+ fi
+
+ # Detect taps with no installed formulae or casks.
+ local -a stale_taps=()
+ local installed
+ installed=$(run_with_timeout 5 brew list --full-name 2> /dev/null || true)
+ local tap
+ while IFS= read -r tap; do
+ [[ -z "$tap" ]] && continue
+ # Skip the core taps — they are always needed.
+ [[ "$tap" == "homebrew/core" || "$tap" == "homebrew/cask" ]] && continue
+ if ! printf '%s\n' "$installed" | grep -q "^${tap}/"; then
+ stale_taps+=("$tap")
+ fi
+ done < <(run_with_timeout 5 brew tap 2> /dev/null)
+
+ local n=${#stale_taps[@]}
+ if [[ $n -eq 0 ]]; then
+ echo -e " ${GREEN}✓${NC} Brew Taps All taps in use"
+ else
+ local s=""
+ ((n > 1)) && s="s"
+ echo -e " ${GRAY}${ICON_WARNING}${NC} Brew Taps ${YELLOW}${n} unused tap${s}${NC}"
+ local preview="${stale_taps[0]}"
+ ((n > 1)) && preview="${preview}, ${stale_taps[1]}"
+ ((n > 2)) && preview="${preview} +$((n - 2))"
+ echo -e " ${GRAY}${preview}${NC}"
+ fi
}
check_system_health() {
@@ -709,6 +869,9 @@ check_system_health() {
check_memory_usage
check_swap_usage
check_login_items
+ check_disk_smart
+ check_orphan_launch_agents
+ check_brew_health
check_cache_size
# Time Machine check is optional; skip by default to avoid noise on systems without backups
}
diff --git a/Resources/mole/lib/check/dev_environment.sh b/Resources/mole/lib/check/dev_environment.sh
new file mode 100644
index 0000000..28f63b3
--- /dev/null
+++ b/Resources/mole/lib/check/dev_environment.sh
@@ -0,0 +1,142 @@
+#!/bin/bash
+# Dev Environment Checks Module
+# Surfaces developer-relevant system health information.
+
+# ============================================================================
+# Helper Functions
+# ============================================================================
+
+_extract_major_minor() {
+ printf '%s' "$1" | sed -E 's/^[^0-9]*//' | grep -oE '^[0-9]+\.[0-9]+'
+}
+
+# ============================================================================
+# Dev Environment Checks
+# ============================================================================
+
+check_launch_agents() {
+ # Check whitelist
+ if command -v is_whitelisted > /dev/null && is_whitelisted "check_launch_agents"; then return; fi
+
+ local agents_dir="$HOME/Library/LaunchAgents"
+
+ if [[ ! -d "$agents_dir" ]]; then
+ echo -e " ${GREEN}✓${NC} Launch Agents All healthy"
+ return
+ fi
+
+ local broken_count=0
+ local -a broken_labels=()
+
+ for plist in "$agents_dir"/*.plist; do
+ [[ -f "$plist" ]] || continue
+
+ local label
+ label=$(basename "$plist" .plist)
+
+ local binary=""
+ binary=$(/usr/libexec/PlistBuddy -c "Print :ProgramArguments:0" "$plist" 2> /dev/null || true)
+ if [[ -z "$binary" ]]; then
+ binary=$(/usr/libexec/PlistBuddy -c "Print :Program" "$plist" 2> /dev/null || true)
+ fi
+
+ if [[ -n "$binary" && ! -e "$binary" ]]; then
+ broken_count=$((broken_count + 1))
+ broken_labels+=("$label")
+ fi
+ done
+
+ if [[ $broken_count -eq 0 ]]; then
+ echo -e " ${GREEN}✓${NC} Launch Agents All healthy"
+ else
+ printf " ${GRAY}%s${NC} %-14s ${YELLOW}%s${NC}\n" "$ICON_WARNING" "Launch Agents" "${broken_count} broken"
+
+ local preview_limit=3
+ ((preview_limit > broken_count)) && preview_limit=$broken_count
+
+ local detail=""
+ for ((i = 0; i < preview_limit; i++)); do
+ if [[ $i -eq 0 ]]; then
+ detail="${broken_labels[$i]}"
+ else
+ detail="${detail}, ${broken_labels[$i]}"
+ fi
+ done
+
+ if ((broken_count > preview_limit)); then
+ local remaining=$((broken_count - preview_limit))
+ detail="${detail} +${remaining}"
+ fi
+
+ printf " ${GRAY}%s${NC}\n" "$detail"
+ fi
+}
+
+check_dev_tools() {
+ # Check whitelist
+ if command -v is_whitelisted > /dev/null && is_whitelisted "check_dev_tools"; then return; fi
+
+ local -a tools=(git node python3 brew go xcode-select)
+ local -a found=()
+
+ for tool in "${tools[@]}"; do
+ if command -v "$tool" > /dev/null 2>&1; then
+ found+=("$tool")
+ fi
+ done
+
+ if [[ ${#found[@]} -eq 0 ]]; then
+ echo -e " ${GREEN}✓${NC} Dev Tools None detected"
+ else
+ local found_list
+ found_list=$(printf '%s, ' "${found[@]}")
+ found_list="${found_list%, }"
+ echo -e " ${GREEN}✓${NC} Dev Tools ${#found[@]} found (${found_list})"
+ fi
+}
+
+check_version_mismatches() {
+ # Check whitelist
+ if command -v is_whitelisted > /dev/null && is_whitelisted "check_version_mismatches"; then return; fi
+
+ local -a conflicts=()
+
+ # Check psql client vs postgres server
+ if command -v psql > /dev/null 2>&1 && command -v postgres > /dev/null 2>&1; then
+ local psql_ver postgres_ver
+ psql_ver=$(_extract_major_minor "$(psql --version 2> /dev/null || true)")
+ postgres_ver=$(_extract_major_minor "$(postgres --version 2> /dev/null || true)")
+ if [[ -n "$psql_ver" && -n "$postgres_ver" && "$psql_ver" != "$postgres_ver" ]]; then
+ conflicts+=("psql ${psql_ver} vs server ${postgres_ver}")
+ fi
+ fi
+
+ # Check python3 vs pyenv
+ if command -v python3 > /dev/null 2>&1 && command -v pyenv > /dev/null 2>&1; then
+ local python_ver pyenv_ver
+ python_ver=$(_extract_major_minor "$(python3 --version 2> /dev/null || true)")
+ pyenv_ver=$(pyenv version 2> /dev/null | awk '{print $1}' || true)
+ if [[ -n "$pyenv_ver" && "$pyenv_ver" != "system" ]]; then
+ pyenv_ver=$(_extract_major_minor "$pyenv_ver")
+ if [[ -n "$python_ver" && -n "$pyenv_ver" && "$python_ver" != "$pyenv_ver" ]]; then
+ conflicts+=("python3 ${python_ver} vs pyenv ${pyenv_ver}")
+ fi
+ fi
+ fi
+
+ if [[ ${#conflicts[@]} -eq 0 ]]; then
+ echo -e " ${GREEN}✓${NC} Versions No conflicts"
+ else
+ local description
+ description=$(printf '%s; ' "${conflicts[@]}")
+ description="${description%; }"
+ printf " ${GRAY}%s${NC} %-14s ${YELLOW}%s${NC}\n" "$ICON_WARNING" "Versions" "$description"
+ fi
+}
+
+check_all_dev_environment() {
+ echo -e "${BLUE}${ICON_ARROW}${NC} Dev Environment"
+ check_launch_agents
+ check_dev_tools
+ check_version_mismatches
+}
diff --git a/Resources/mole/lib/check/health_json.sh b/Resources/mole/lib/check/health_json.sh
index cdda7fa..8b118e4 100644
--- a/Resources/mole/lib/check/health_json.sh
+++ b/Resources/mole/lib/check/health_json.sh
@@ -130,18 +130,29 @@ EOF
items+=('fix_broken_configs|Broken Config Repair|Fix corrupted preferences files|true')
items+=('network_optimization|Network Cache Refresh|Optimize DNS cache & restart mDNSResponder|true')
- # Advanced optimizations (high value, auto-run with safety checks)
+ # Advanced optimizations (auto-run, non-destructive or regenerated by macOS)
items+=('sqlite_vacuum|Database Optimization|Compress SQLite databases for Mail, Safari & Messages (skips if apps are running)|true')
items+=('launch_services_rebuild|LaunchServices Repair|Repair "Open with" menu & file associations|true')
items+=('font_cache_rebuild|Font Cache Rebuild|Rebuild font database to fix rendering issues (skips if browsers are running)|true')
items+=('dock_refresh|Dock Refresh|Fix broken icons and visual glitches in the Dock|true')
+ items+=('prevent_network_dsstore|Prevent Finder .DS_Store|Set a persistent Finder preference to stop writing .DS_Store on SMB/AFP/NFS and USB volumes|true')
- # System performance optimizations (new)
+ # System performance optimizations (auto-run, non-destructive)
items+=('memory_pressure_relief|Memory Optimization|Release inactive memory to improve system responsiveness|true')
items+=('network_stack_optimize|Network Stack Refresh|Flush routing table and ARP cache to resolve network issues|true')
items+=('disk_permissions_repair|Permission Repair|Fix user directory permission issues|true')
items+=('bluetooth_reset|Bluetooth Refresh|Restart Bluetooth module to fix connectivity (skips if in use)|true')
items+=('spotlight_index_optimize|Spotlight Optimization|Rebuild index if search is slow (smart detection)|true')
+ items+=('periodic_maintenance|Periodic Maintenance|Run macOS daily/weekly/monthly maintenance scripts if stale|true')
+ items+=('shared_file_list_repair|Shared File Lists|Repair corrupted Finder favorites and recent documents|true')
+ items+=('disk_verify|Disk Health|Verify filesystem integrity|true')
+ items+=('login_items_audit|Login Items|Audit login items for broken entries|true')
+
+ # System database cleanup (auto-run, low risk)
+ items+=('quarantine_cleanup|Quarantine Database Cleanup|Clear Gatekeeper download tracking history|true')
+ items+=('launch_agents_cleanup|Launch Agents Cleanup|Remove broken LaunchAgents whose binaries no longer exist|true')
+ items+=('notification_cleanup|Notifications|Clean old delivered notifications to reduce database bloat|true')
+ items+=('coreduet_cleanup|Usage Data|Clean old usage tracking data|true')
# Removed high-risk optimizations:
# - startup_items_cleanup: Risk of deleting legitimate app helpers
diff --git a/Resources/mole/lib/clean/app_caches.sh b/Resources/mole/lib/clean/app_caches.sh
index d99ebea..b3d364b 100644
--- a/Resources/mole/lib/clean/app_caches.sh
+++ b/Resources/mole/lib/clean/app_caches.sh
@@ -1,6 +1,61 @@
#!/bin/bash
# User GUI Applications Cleanup Module (desktop apps, media, utilities).
set -euo pipefail
+# Xcode DerivedData cleanup with project count and size reporting.
+# Fully regenerated on next build — safe to remove.
+clean_xcode_derived_data() {
+ local dd_dir="$HOME/Library/Developer/Xcode/DerivedData"
+
+ [[ -d "$dd_dir" ]] || return 0
+
+ # Skip while Xcode is running to avoid build failures.
+ if pgrep -x "Xcode" > /dev/null 2>&1; then
+ echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode is running, skipping DerivedData cleanup"
+ return 0
+ fi
+
+ # Count projects (each subdirectory is a project build).
+ local -a projects=()
+ while IFS= read -r -d '' dir; do
+ projects+=("$dir")
+ done < <(command find "$dd_dir" -mindepth 1 -maxdepth 1 -type d -print0 2> /dev/null || true)
+
+ local project_count=${#projects[@]}
+ [[ $project_count -eq 0 ]] && return 0
+
+ # Calculate total size.
+ local size_kb=0
+ size_kb=$(du -skP "$dd_dir" 2> /dev/null | awk '{print $1}') || size_kb=0
+ local size_human
+ size_human=$(bytes_to_human "$((size_kb * 1024))")
+
+ local project_label="projects"
+ [[ $project_count -eq 1 ]] && project_label="project"
+
+ if [[ "${DRY_RUN:-false}" == "true" ]]; then
+ echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Xcode DerivedData · ${project_count} ${project_label}, ${size_human}"
+ note_activity
+ return 0
+ fi
+
+ # Remove all project build dirs using safe_remove.
+ local removed=0
+ for dir in "${projects[@]}"; do
+ if safe_remove "$dir" "true"; then
+ removed=$((removed + 1))
+ fi
+ done
+
+ if [[ $removed -gt 0 ]]; then
+ local line_color
+ line_color=$(cleanup_result_color_kb "$size_kb" 2> /dev/null || echo "$GREEN")
+ echo -e " ${line_color}${ICON_SUCCESS}${NC} Xcode DerivedData · ${project_count} ${project_label}, ${line_color}${size_human}${NC}"
+ files_cleaned=$((${files_cleaned:-0} + removed))
+ total_size_cleaned=$((${total_size_cleaned:-0} + size_kb))
+ total_items=$((${total_items:-0} + removed))
+ note_activity
+ fi
+}
# Xcode and iOS tooling.
clean_xcode_tools() {
# Skip DerivedData/Archives while Xcode is running.
@@ -8,15 +63,66 @@ clean_xcode_tools() {
if pgrep -x "Xcode" > /dev/null 2>&1; then
xcode_running=true
fi
- safe_clean ~/Library/Developer/CoreSimulator/Caches/* "Simulator cache"
- safe_clean ~/Library/Developer/CoreSimulator/Devices/*/data/tmp/* "Simulator temp files"
+ # Skip Simulator caches/temp files while Simulator is running to avoid crashes.
+ local simulator_running=false
+ if pgrep -x "Simulator" > /dev/null 2>&1; then
+ simulator_running=true
+ fi
+ if [[ "$simulator_running" == "false" ]]; then
+ safe_clean ~/Library/Developer/CoreSimulator/Caches/* "Simulator cache"
+ safe_clean ~/Library/Developer/CoreSimulator/Devices/*/data/tmp/* "Simulator temp files"
+ safe_clean ~/Library/Logs/CoreSimulator/* "CoreSimulator logs"
+ # Remove unavailable simulator devices (not supported by the current Xcode SDK).
+ # run_with_timeout guards against xcrun blocking when only CLT is installed
+ # (can launch an invisible install dialog or wait on CoreSimulator XPC indefinitely).
+ if command -v xcrun > /dev/null 2>&1; then
+ local unavail_count
+ local unavailable_devices_output=""
+
+ # Tests may mock xcrun as a shell function. Timeout wrappers execute
+ # in a separate process and cannot reliably invoke exported functions.
+ # Prefer direct function invocation in that case.
+ if declare -F xcrun > /dev/null 2>&1; then
+ unavailable_devices_output=$(xcrun simctl list devices unavailable 2> /dev/null || true)
+ else
+ unavailable_devices_output=$(run_with_timeout 2 xcrun simctl list devices unavailable 2> /dev/null || true)
+ if [[ -z "$unavailable_devices_output" ]]; then
+ unavailable_devices_output=$(xcrun simctl list devices unavailable 2> /dev/null || true)
+ fi
+ fi
+ unavail_count=$(printf '%s\n' "$unavailable_devices_output" | command awk '/\([0-9A-F-]{36}\)/ { count++ } END { print count+0 }')
+ [[ "$unavail_count" =~ ^[0-9]+$ ]] || unavail_count=0
+ if [[ "$unavail_count" -gt 0 ]]; then
+ if [[ "${DRY_RUN:-false}" == "true" ]]; then
+ echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Unavailable simulators · would delete ${unavail_count} devices"
+ else
+ # Capture exit code so a timeout (124) or simctl error
+ # is reported instead of falsely echoing SUCCESS.
+ local _delete_rc=0
+ if declare -F xcrun > /dev/null 2>&1; then
+ xcrun simctl delete unavailable > /dev/null 2>&1 || _delete_rc=$?
+ else
+ run_with_timeout 5 xcrun simctl delete unavailable > /dev/null 2>&1 || _delete_rc=$?
+ fi
+ if [[ $_delete_rc -eq 0 ]]; then
+ echo -e " ${GREEN}${ICON_SUCCESS}${NC} Unavailable simulators · deleted ${unavail_count} devices"
+ else
+ echo -e " ${YELLOW}${ICON_WARNING}${NC} Unavailable simulators · simctl delete failed (exit=${_delete_rc})"
+ debug_log "xcrun simctl delete unavailable returned $_delete_rc"
+ fi
+ fi
+ note_activity
+ fi
+ fi
+ else
+ echo -e " ${GRAY}${ICON_WARNING}${NC} Simulator is running, skipping Simulator cache/temp/log cleanup"
+ fi
safe_clean ~/Library/Caches/com.apple.dt.Xcode/* "Xcode cache"
safe_clean ~/Library/Developer/Xcode/iOS\ Device\ Logs/* "iOS device logs"
safe_clean ~/Library/Developer/Xcode/watchOS\ Device\ Logs/* "watchOS device logs"
- safe_clean ~/Library/Logs/CoreSimulator/* "CoreSimulator logs"
safe_clean ~/Library/Developer/Xcode/Products/* "Xcode build products"
if [[ "$xcode_running" == "false" ]]; then
- safe_clean ~/Library/Developer/Xcode/DerivedData/* "Xcode derived data"
+ clean_xcode_derived_data
safe_clean ~/Library/Developer/Xcode/Archives/* "Xcode archives"
safe_clean ~/Library/Developer/Xcode/DocumentationCache/* "Xcode documentation cache"
safe_clean ~/Library/Developer/Xcode/DocumentationIndex/* "Xcode documentation index"
@@ -31,6 +137,8 @@ clean_code_editors() {
safe_clean ~/Library/Application\ Support/Code/CachedExtensions/* "VS Code extension cache"
safe_clean ~/Library/Application\ Support/Code/CachedData/* "VS Code data cache"
safe_clean ~/Library/Caches/com.sublimetext.*/* "Sublime Text cache"
+ safe_clean ~/Library/Caches/Zed/* "Zed cache"
+ safe_clean ~/Library/Logs/Zed/* "Zed logs"
}
# Communication apps.
clean_communication_apps() {
@@ -40,6 +148,7 @@ clean_communication_apps() {
safe_clean ~/Library/Caches/us.zoom.xos/* "Zoom cache"
safe_clean ~/Library/Caches/com.tencent.xinWeChat/* "WeChat cache"
safe_clean ~/Library/Caches/ru.keepcoder.Telegram/* "Telegram cache"
+
safe_clean ~/Library/Caches/com.microsoft.teams2/* "Microsoft Teams cache"
safe_clean ~/Library/Caches/net.whatsapp.WhatsApp/* "WhatsApp cache"
safe_clean ~/Library/Caches/com.skype.skype/* "Skype cache"
@@ -65,6 +174,13 @@ clean_ai_apps() {
safe_clean ~/Library/Caches/com.openai.chat/* "ChatGPT cache"
safe_clean ~/Library/Caches/com.anthropic.claudefordesktop/* "Claude desktop cache"
safe_clean ~/Library/Logs/Claude/* "Claude logs"
+ safe_clean ~/Library/Logs/com.openai.codex/* "Codex CLI logs"
+ # Codex (OpenAI, Electron)
+ safe_clean ~/Library/Application\ Support/Codex/Cache/* "Codex cache"
+ safe_clean ~/Library/Application\ Support/Codex/Code\ Cache/* "Codex code cache"
+ safe_clean ~/Library/Application\ Support/Codex/GPUCache/* "Codex GPU cache"
+ safe_clean ~/Library/Application\ Support/Codex/DawnGraphiteCache/* "Codex Dawn cache"
+ safe_clean ~/Library/Application\ Support/Codex/DawnWebGPUCache/* "Codex WebGPU cache"
}
# Design and creative tools.
clean_design_tools() {
@@ -74,13 +190,13 @@ clean_design_tools() {
safe_clean ~/Library/Caches/com.adobe.*/* "Adobe app caches"
safe_clean ~/Library/Caches/com.figma.Desktop/* "Figma cache"
safe_clean ~/Library/Application\ Support/Adobe/Common/Media\ Cache\ Files/* "Adobe media cache files"
- # Raycast cache is protected (clipboard history, images).
}
# Video editing tools.
clean_video_tools() {
safe_clean ~/Library/Caches/net.telestream.screenflow10/* "ScreenFlow cache"
safe_clean ~/Library/Caches/com.apple.FinalCut/* "Final Cut Pro cache"
safe_clean ~/Library/Caches/com.blackmagic-design.DaVinciResolve/* "DaVinci Resolve cache"
+ safe_clean ~/Movies/CacheClip/* "DaVinci Resolve CacheClip"
safe_clean ~/Library/Caches/com.adobe.PremierePro.*/* "Premiere Pro cache"
}
# 3D and CAD tools.
@@ -99,22 +215,23 @@ clean_productivity_apps() {
safe_clean ~/Library/Caches/com.filo.client/* "Filo cache"
safe_clean ~/Library/Caches/com.flomoapp.mac/* "Flomo cache"
safe_clean ~/Library/Application\ Support/Quark/Cache/videoCache/* "Quark video cache"
+ safe_clean ~/Library/Containers/com.ranchero.NetNewsWire-Evergreen/Data/Library/Caches/* "NetNewsWire cache"
+ safe_clean ~/Library/Containers/com.ideasoncanvas.mindnode/Data/Library/Caches/* "MindNode cache"
+ safe_clean ~/.cache/kaku/* "Kaku cache"
}
# Music/media players (protect Spotify offline music).
clean_media_players() {
local spotify_cache="$HOME/Library/Caches/com.spotify.client"
local spotify_data="$HOME/Library/Application Support/Spotify"
local has_offline_music=false
- # Heuristics: offline DB or large cache.
- if [[ -f "$spotify_data/PersistentCache/Storage/offline.bnk" ]] ||
+ # offline.bnk exists even with no offline downloads; only treat it as evidence
+ # when it has real content (>1 KB). Encrypted track blobs (*.file) are reliable.
+ local bnk_file="$spotify_data/PersistentCache/Storage/offline.bnk"
+ local bnk_size=0
+ [[ -f "$bnk_file" ]] && bnk_size=$(stat -f%z "$bnk_file" 2> /dev/null || echo 0)
+ if [[ $bnk_size -gt 1024 ]] ||
[[ -d "$spotify_data/PersistentCache/Storage" && -n "$(find "$spotify_data/PersistentCache/Storage" -type f -name "*.file" 2> /dev/null | head -1)" ]]; then
has_offline_music=true
- elif [[ -d "$spotify_cache" ]]; then
- local cache_size_kb
- cache_size_kb=$(get_path_size_kb "$spotify_cache")
- if [[ $cache_size_kb -ge 512000 ]]; then
- has_offline_music=true
- fi
fi
if [[ "$has_offline_music" == "true" ]]; then
echo -e " ${GRAY}${ICON_WARNING}${NC} Spotify cache protected · offline music detected"
@@ -146,6 +263,8 @@ clean_video_players() {
safe_clean ~/Library/Caches/tv.danmaku.bili/* "Bilibili cache"
safe_clean ~/Library/Caches/com.douyu.*/* "Douyu cache"
safe_clean ~/Library/Caches/com.huya.*/* "Huya cache"
+ safe_clean ~/Library/Caches/smart.stremio*/* "Stremio cache"
+ safe_clean ~/Library/Application\ Support/stremio/stremio-server/stremio-cache/* "Stremio server cache"
}
# Download managers.
clean_download_managers() {
@@ -179,6 +298,11 @@ clean_gaming_platforms() {
safe_clean ~/.lunarclient/logs/* "Lunar Client logs"
safe_clean ~/.lunarclient/offline/*/logs/* "Lunar Client offline logs"
safe_clean ~/.lunarclient/offline/files/*/logs/* "Lunar Client offline file logs"
+ safe_clean ~/Library/Caches/net.pcsx2.PCSX2/* "PCSX2 cache"
+ safe_clean ~/Library/Application\ Support/PCSX2/cache/* "PCSX2 shader cache"
+ safe_clean ~/Library/Logs/PCSX2/* "PCSX2 logs"
+ safe_clean ~/Library/Caches/net.rpcs3.rpcs3/* "RPCS3 cache"
+ safe_clean ~/Library/Application\ Support/rpcs3/logs/* "RPCS3 logs"
}
# Translation/dictionary apps.
clean_translation_apps() {
@@ -210,11 +334,27 @@ clean_shell_utils() {
safe_clean ~/.wget-hsts "wget HSTS cache"
safe_clean ~/.cacher/logs/* "Cacher logs"
safe_clean ~/.kite/logs/* "Kite logs"
+ safe_clean ~/Library/Caches/dev.warp.Warp-Stable/* "Warp cache"
+ safe_clean ~/Library/Logs/warp.log "Warp log"
+ safe_clean ~/Library/Caches/SentryCrash/Warp/* "Warp Sentry crash reports"
+ safe_clean ~/Library/Caches/com.mitchellh.ghostty/* "Ghostty cache"
}
# Input methods and system utilities.
clean_system_utils() {
safe_clean ~/Library/Caches/com.runjuu.Input-Source-Pro/* "Input Source Pro cache"
safe_clean ~/Library/Caches/macos-wakatime.WakaTime/* "WakaTime cache"
+ # WeType input method (image and dict update cache, not engine or user dict)
+ safe_clean ~/Library/Application\ Support/WeType/com.onevcat.Kingfisher.ImageCache.WeType/* "WeType image cache"
+ safe_clean ~/Library/Application\ Support/WeType/DictUpdate/* "WeType dict update cache"
+ # mihomo-party proxy tool (Electron)
+ safe_clean ~/Library/Application\ Support/mihomo-party/Cache/* "mihomo-party cache"
+ safe_clean ~/Library/Application\ Support/mihomo-party/Code\ Cache/* "mihomo-party code cache"
+ safe_clean ~/Library/Application\ Support/mihomo-party/GPUCache/* "mihomo-party GPU cache"
+ safe_clean ~/Library/Application\ Support/mihomo-party/DawnGraphiteCache/* "mihomo-party Dawn cache"
+ safe_clean ~/Library/Application\ Support/mihomo-party/DawnWebGPUCache/* "mihomo-party WebGPU cache"
+ safe_clean ~/Library/Application\ Support/mihomo-party/logs/* "mihomo-party logs"
+ # Stash proxy tool
+ safe_clean ~/Library/Caches/ws.stash.app.mac/* "Stash cache"
}
# Note-taking apps.
clean_note_apps() {
@@ -229,6 +369,9 @@ clean_note_apps() {
clean_launcher_apps() {
safe_clean ~/Library/Caches/com.runningwithcrayons.Alfred/* "Alfred cache"
safe_clean ~/Library/Caches/cx.c3.theunarchiver/* "The Unarchiver cache"
+ # Raycast: only clean network and FS caches; Clipboard subfolder contains user's clipboard history.
+ safe_clean ~/Library/Caches/com.raycast.macos/urlcache/* "Raycast URL cache"
+ safe_clean ~/Library/Caches/com.raycast.macos/fsCachedData/* "Raycast FS cache"
}
# Remote desktop tools.
clean_remote_desktop() {
@@ -240,8 +383,6 @@ clean_remote_desktop() {
# Main entry for GUI app cleanup.
clean_user_gui_applications() {
stop_section_spinner
- clean_xcode_tools
- clean_code_editors
clean_communication_apps
clean_dingtalk
clean_ai_apps
diff --git a/Resources/mole/lib/clean/apps.sh b/Resources/mole/lib/clean/apps.sh
index 75402f3..1b68847 100644
--- a/Resources/mole/lib/clean/apps.sh
+++ b/Resources/mole/lib/clean/apps.sh
@@ -36,7 +36,7 @@ clean_ds_store_tree() {
total_bytes=$((total_bytes + size))
file_count=$((file_count + 1))
if [[ "$DRY_RUN" != "true" ]]; then
- rm -f "$ds_file" 2> /dev/null || true
+ safe_remove "$ds_file" true 2> /dev/null || true
fi
if [[ $file_count -ge $MOLE_MAX_DS_STORE_FILES ]]; then
break
@@ -48,12 +48,14 @@ clean_ds_store_tree() {
if [[ $file_count -gt 0 ]]; then
local size_human
size_human=$(bytes_to_human "$total_bytes")
+ local size_kb=$(((total_bytes + 1023) / 1024))
if [[ "$DRY_RUN" == "true" ]]; then
echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} $label${NC}, ${YELLOW}$file_count files, $size_human dry${NC}"
else
- echo -e " ${GREEN}${ICON_SUCCESS}${NC} $label${NC}, ${GREEN}$file_count files, $size_human${NC}"
+ local line_color
+ line_color=$(cleanup_result_color_kb "$size_kb")
+ echo -e " ${line_color}${ICON_SUCCESS}${NC} $label${NC}, ${line_color}$file_count files, $size_human${NC}"
fi
- local size_kb=$(((total_bytes + 1023) / 1024))
files_cleaned=$((files_cleaned + file_count))
total_size_cleaned=$((total_size_cleaned + size_kb))
total_items=$((total_items + 1))
@@ -112,7 +114,7 @@ scan_installed_apps() {
local plist_path="$app_path/Contents/Info.plist"
[[ ! -f "$plist_path" ]] && continue
local bundle_id=$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "$plist_path" 2> /dev/null || echo "")
- if [[ -n "$bundle_id" ]]; then
+ if [[ -n "$bundle_id" && "$bundle_id" != "missing value" ]]; then
echo "$bundle_id"
count=$((count + 1))
fi
@@ -123,8 +125,11 @@ scan_installed_apps() {
done
# Collect running apps and LaunchAgents to avoid false orphan cleanup.
(
- local running_apps=$(run_with_timeout 5 osascript -e 'tell application "System Events" to get bundle identifier of every application process' 2> /dev/null || echo "")
- echo "$running_apps" | tr ',' '\n' | sed -e 's/^ *//;s/ *$//' -e '/^$/d' > "$scan_tmp_dir/running.txt"
+ # Skip AppleScript during tests to avoid permission dialogs
+ if [[ "${MOLE_TEST_MODE:-0}" != "1" && "${MOLE_TEST_NO_AUTH:-0}" != "1" ]]; then
+ local running_apps=$(run_with_timeout 5 osascript -e 'tell application "System Events" to get bundle identifier of every application process' 2> /dev/null || echo "")
+ echo "$running_apps" | tr ',' '\n' | sed -e 's/^ *//;s/ *$//' -e '/^$/d' -e '/^missing value$/d' > "$scan_tmp_dir/running.txt"
+ fi
# Fallback: lsappinfo is more reliable than osascript
if command -v lsappinfo > /dev/null 2>&1; then
run_with_timeout 3 lsappinfo list 2> /dev/null | grep -o '"CFBundleIdentifier"="[^"]*"' | cut -d'"' -f4 >> "$scan_tmp_dir/running.txt" 2> /dev/null || true
@@ -218,7 +223,8 @@ is_bundle_orphaned() {
if [[ -n "$bundle_id" ]] && [[ "$bundle_id" =~ ^[a-zA-Z0-9._-]+$ ]] && [[ ${#bundle_id} -ge 5 ]]; then
# Initialize cache file if needed
if [[ -z "$ORPHAN_MDFIND_CACHE_FILE" ]]; then
- ORPHAN_MDFIND_CACHE_FILE=$(mktemp "${TMPDIR:-/tmp}/mole_mdfind_cache.XXXXXX")
+ ensure_mole_temp_root
+ ORPHAN_MDFIND_CACHE_FILE=$(mktemp "$MOLE_RESOLVED_TMPDIR/mole_mdfind_cache.XXXXXX")
register_temp_file "$ORPHAN_MDFIND_CACHE_FILE"
fi
@@ -232,7 +238,7 @@ is_bundle_orphaned() {
else
# Query mdfind with strict timeout (2 seconds max)
local app_exists
- app_exists=$(run_with_timeout 2 mdfind "kMDItemCFBundleIdentifier == '$bundle_id'" 2> /dev/null | head -1 || echo "")
+ app_exists=$(run_with_timeout 5 mdfind "kMDItemCFBundleIdentifier == '$bundle_id'" 2> /dev/null | head -1 || echo "")
if [[ -n "$app_exists" ]]; then
echo "FOUND:$bundle_id" >> "$ORPHAN_MDFIND_CACHE_FILE"
return 1
@@ -274,7 +280,8 @@ is_claude_vm_bundle_orphaned() {
fi
if [[ -z "$ORPHAN_MDFIND_CACHE_FILE" ]]; then
- ORPHAN_MDFIND_CACHE_FILE=$(mktemp "${TMPDIR:-/tmp}/mole_mdfind_cache.XXXXXX")
+ ensure_mole_temp_root
+ ORPHAN_MDFIND_CACHE_FILE=$(mktemp "$MOLE_RESOLVED_TMPDIR/mole_mdfind_cache.XXXXXX")
register_temp_file "$ORPHAN_MDFIND_CACHE_FILE"
fi
@@ -283,7 +290,7 @@ is_claude_vm_bundle_orphaned() {
fi
if ! grep -Fxq "NOTFOUND:$claude_bundle_id" "$ORPHAN_MDFIND_CACHE_FILE" 2> /dev/null; then
local app_exists
- app_exists=$(run_with_timeout 2 mdfind "kMDItemCFBundleIdentifier == '$claude_bundle_id'" 2> /dev/null | head -1 || echo "")
+ app_exists=$(run_with_timeout 5 mdfind "kMDItemCFBundleIdentifier == '$claude_bundle_id'" 2> /dev/null | head -1 || echo "")
if [[ -n "$app_exists" ]]; then
echo "FOUND:$claude_bundle_id" >> "$ORPHAN_MDFIND_CACHE_FILE"
return 1
@@ -311,19 +318,31 @@ clean_orphaned_app_data() {
local total_orphaned_kb=0
start_section_spinner "Scanning orphaned app resources..."
- local claude_vm_bundle="$HOME/Library/Application Support/Claude/vm_bundles/claudevm.bundle"
- if is_claude_vm_bundle_orphaned "$claude_vm_bundle" "$installed_bundles"; then
- local claude_vm_size_kb
- claude_vm_size_kb=$(get_path_size_kb "$claude_vm_bundle")
- if [[ -n "$claude_vm_size_kb" && "$claude_vm_size_kb" != "0" ]]; then
- if safe_clean "$claude_vm_bundle" "Orphaned Claude workspace VM"; then
- orphaned_count=$((orphaned_count + 1))
- total_orphaned_kb=$((total_orphaned_kb + claude_vm_size_kb))
+ # Dynamically discover Claude VM bundles (path may vary across versions).
+ local claude_support_dir="$HOME/Library/Application Support/Claude"
+ if [[ -d "$claude_support_dir" ]]; then
+ while IFS= read -r -d '' claude_vm_bundle; do
+ if is_claude_vm_bundle_orphaned "$claude_vm_bundle" "$installed_bundles"; then
+ if is_path_whitelisted "$claude_vm_bundle"; then
+ debug_log "Skipping whitelisted orphan: $claude_vm_bundle"
+ continue
+ fi
+ local claude_vm_size_kb
+ claude_vm_size_kb=$(get_path_size_kb "$claude_vm_bundle")
+ if [[ -n "$claude_vm_size_kb" && "$claude_vm_size_kb" != "0" ]]; then
+ if safe_clean "$claude_vm_bundle" "Orphaned Claude workspace VM"; then
+ orphaned_count=$((orphaned_count + 1))
+ total_orphaned_kb=$((total_orphaned_kb + claude_vm_size_kb))
+ fi
+ fi
fi
- fi
+ done < <(find "$claude_support_dir" -maxdepth 3 -name "*.bundle" -type d -print0 2> /dev/null || true)
fi
# CRITICAL: NEVER add LaunchAgents or LaunchDaemons (breaks login items/startup apps).
+ # CRITICAL: NEVER add Containers/ (managed by containermanagerd, stubs expected).
+ # CRITICAL: NEVER add Application Scripts/ (could break Shortcuts/Automator workflows).
+ # CRITICAL: NEVER add Group Containers/ (TeamID.BundleID names cause false-positive orphan checks).
local -a resource_types=(
"$HOME/Library/Caches|Caches|com.*:org.*:net.*:io.*"
"$HOME/Library/Logs|Logs|com.*:org.*:net.*:io.*"
@@ -331,6 +350,8 @@ clean_orphaned_app_data() {
"$HOME/Library/WebKit|WebKit|com.*:org.*:net.*:io.*"
"$HOME/Library/HTTPStorages|HTTP|com.*:org.*:net.*:io.*"
"$HOME/Library/Cookies|Cookies|*.binarycookies"
+ "$HOME/Library/Application Support|AppSupport|com.*:org.*:net.*:io.*"
+ "$HOME/Library/Preferences|Prefs|com.*:org.*:net.*:io.*"
)
for resource_type in "${resource_types[@]}"; do
IFS='|' read -r base_path label patterns <<< "$resource_type"
@@ -369,7 +390,12 @@ clean_orphaned_app_data() {
local bundle_id=$(basename "$match")
bundle_id="${bundle_id%.savedState}"
bundle_id="${bundle_id%.binarycookies}"
+ bundle_id="${bundle_id%.plist}"
if is_bundle_orphaned "$bundle_id" "$match" "$installed_bundles"; then
+ if is_path_whitelisted "$match"; then
+ debug_log "Skipping whitelisted orphan: $match"
+ continue
+ fi
local size_kb
size_kb=$(get_path_size_kb "$match")
if [[ -z "$size_kb" || "$size_kb" == "0" ]]; then
@@ -446,7 +472,8 @@ clean_orphaned_system_services() {
if [[ -n "$bundle_id" ]] && [[ "$bundle_id" =~ ^[a-zA-Z0-9._-]+$ ]] && [[ ${#bundle_id} -ge 5 ]]; then
if [[ -z "$mdfind_cache_file" ]]; then
- mdfind_cache_file=$(mktemp "${TMPDIR:-/tmp}/mole_mdfind_cache.XXXXXX")
+ ensure_mole_temp_root
+ mdfind_cache_file=$(mktemp "$MOLE_RESOLVED_TMPDIR/mole_mdfind_cache.XXXXXX")
register_temp_file "$mdfind_cache_file"
fi
@@ -455,7 +482,7 @@ clean_orphaned_system_services() {
fi
if ! grep -Fxq "NOTFOUND:$bundle_id" "$mdfind_cache_file" 2> /dev/null; then
local app_found
- app_found=$(run_with_timeout 2 mdfind "kMDItemCFBundleIdentifier == '$bundle_id'" 2> /dev/null | head -1 || echo "")
+ app_found=$(run_with_timeout 5 mdfind "kMDItemCFBundleIdentifier == '$bundle_id'" 2> /dev/null | head -1 || echo "")
if [[ -n "$app_found" ]]; then
echo "FOUND:$bundle_id" >> "$mdfind_cache_file"
return 0
@@ -541,6 +568,7 @@ clean_orphaned_system_services() {
# Skip Apple system files
[[ "$filename" == com.apple.* ]] && continue
+ local matched_known=false
for pattern_entry in "${known_orphan_patterns[@]}"; do
local file_pattern="${pattern_entry%%:*}"
local app_path="${pattern_entry#*:}"
@@ -548,21 +576,53 @@ clean_orphaned_system_services() {
# shellcheck disable=SC2053
if [[ "$filename" == $file_pattern ]] && [[ ! -d "$app_path" ]]; then
if _system_service_app_exists "$bundle_id" "$app_path"; then
- continue
+ matched_known=true
+ break
fi
orphaned_files+=("$helper")
local size_kb
size_kb=$(sudo du -skP "$helper" 2> /dev/null | awk '{print $1}' || echo "0")
total_orphaned_kb=$((total_orphaned_kb + size_kb))
orphaned_count=$((orphaned_count + 1))
+ matched_known=true
break
fi
done
+
+ # Generic detection: bundle-ID-style helpers not matched by hardcoded list.
+ # Privileged helpers are frequently registered via SMJobBless and ship
+ # *inside* the parent app bundle at Contents/Library/LaunchServices/,
+ # which Spotlight does not index. Use the shared resolver so we do not
+ # falsely flag Adobe / 1Password / Docker helpers as orphaned when their
+ # parent app is installed. See #733.
+ if [[ "$matched_known" == "false" ]] && [[ "$bundle_id" =~ ^(com|org|net|io)\. ]]; then
+ if ! bundle_has_installed_app "$bundle_id"; then
+ orphaned_files+=("$helper")
+ local size_kb
+ size_kb=$(sudo du -skP "$helper" 2> /dev/null | awk '{print $1}' || echo "0")
+ total_orphaned_kb=$((total_orphaned_kb + size_kb))
+ orphaned_count=$((orphaned_count + 1))
+ fi
+ fi
done < <(sudo find /Library/PrivilegedHelperTools -maxdepth 1 -type f -print0 2> /dev/null)
fi
stop_section_spinner
+ # Drop whitelisted entries before reporting/cleaning.
+ if [[ $orphaned_count -gt 0 && ${#WHITELIST_PATTERNS[@]} -gt 0 ]]; then
+ local -a kept_files=()
+ for orphan_file in "${orphaned_files[@]}"; do
+ if is_path_whitelisted "$orphan_file"; then
+ debug_log "Skipping whitelisted orphan service: $orphan_file"
+ continue
+ fi
+ kept_files+=("$orphan_file")
+ done
+ orphaned_count=${#kept_files[@]}
+ orphaned_files=("${kept_files[@]}")
+ fi
+
# Report and clean
if [[ $orphaned_count -gt 0 ]]; then
echo -e " ${GRAY}${ICON_WARNING}${NC} Found $orphaned_count orphaned system services"
@@ -571,7 +631,7 @@ clean_orphaned_system_services() {
local filename
filename=$(basename "$orphan_file")
- if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
+ if [[ "$DRY_RUN" == "true" ]]; then
debug_log "[DRY RUN] Would remove orphaned service: $orphan_file"
else
# Unload if it's a LaunchDaemon/LaunchAgent
@@ -590,202 +650,20 @@ clean_orphaned_system_services() {
else
orphaned_kb_display="${total_orphaned_kb}KB"
fi
- echo -e " ${GREEN}${ICON_SUCCESS}${NC} Cleaned $orphaned_count orphaned services, about $orphaned_kb_display"
- note_activity
+ if [[ "${DRY_RUN:-false}" != "true" ]]; then
+ echo -e " ${GREEN}${ICON_SUCCESS}${NC} Cleaned $orphaned_count orphaned services, about $orphaned_kb_display"
+ note_activity
+ fi
fi
}
# ============================================================================
-# Orphaned LaunchAgent/LaunchDaemon Cleanup (Generic Detection)
+# User LaunchAgents
# ============================================================================
-# Extract program path from plist (supports both ProgramArguments and Program)
-_extract_program_path() {
- local plist="$1"
- local program=""
-
- program=$(plutil -extract ProgramArguments.0 raw "$plist" 2> /dev/null)
- if [[ -z "$program" ]]; then
- program=$(plutil -extract Program raw "$plist" 2> /dev/null)
- fi
-
- echo "$program"
-}
-
-# Extract associated bundle identifier from plist
-_extract_associated_bundle() {
- local plist="$1"
- local associated=""
-
- # Try array format first
- associated=$(plutil -extract AssociatedBundleIdentifiers.0 raw "$plist" 2> /dev/null)
- if [[ -z "$associated" ]] || [[ "$associated" == "1" ]]; then
- # Try string format
- associated=$(plutil -extract AssociatedBundleIdentifiers raw "$plist" 2> /dev/null)
- # Filter out dict/array markers
- if [[ "$associated" == "{"* ]] || [[ "$associated" == "["* ]]; then
- associated=""
- fi
- fi
-
- echo "$associated"
-}
-
-# Check if a LaunchAgent/LaunchDaemon is orphaned using multi-layer verification
-# Returns 0 if orphaned, 1 if not orphaned
-is_launch_item_orphaned() {
- local plist="$1"
-
- # Layer 1: Check if program path exists
- local program=$(_extract_program_path "$plist")
-
- # No program path - skip (not a standard launch item)
- [[ -z "$program" ]] && return 1
-
- # Program exists -> not orphaned
- [[ -e "$program" ]] && return 1
-
- # Layer 2: Check AssociatedBundleIdentifiers
- local associated=$(_extract_associated_bundle "$plist")
- if [[ -n "$associated" ]]; then
- # Check if associated app exists via mdfind
- if run_with_timeout 2 mdfind "kMDItemCFBundleIdentifier == '$associated'" 2> /dev/null | head -1 | grep -q .; then
- return 1 # Associated app found -> not orphaned
- fi
-
- # Extract vendor name from bundle ID (com.vendor.app -> vendor)
- local vendor=$(echo "$associated" | cut -d'.' -f2)
- if [[ -n "$vendor" ]] && [[ ${#vendor} -ge 3 ]]; then
- # Check if any app from this vendor exists
- if find /Applications ~/Applications -maxdepth 2 -iname "*${vendor}*" -type d 2> /dev/null | grep -iq "\.app"; then
- return 1 # Vendor app exists -> not orphaned
- fi
- fi
- fi
-
- # Layer 3: Check Application Support directory activity
- if [[ "$program" =~ /Library/Application\ Support/([^/]+)/ ]]; then
- local app_support_name="${BASH_REMATCH[1]}"
-
- # Check both user and system Application Support
- for base in "$HOME/Library/Application Support" "/Library/Application Support"; do
- local support_path="$base/$app_support_name"
- if [[ -d "$support_path" ]]; then
- # Check if there are files modified in last 7 days (active usage)
- local recent_file=$(find "$support_path" -type f -mtime -7 2> /dev/null | head -1)
- if [[ -n "$recent_file" ]]; then
- return 1 # Active Application Support -> not orphaned
- fi
- fi
- done
- fi
-
- # Layer 4: Check if app name from program path exists
- if [[ "$program" =~ /Applications/([^/]+)\.app/ ]]; then
- local app_name="${BASH_REMATCH[1]}"
- # Look for apps with similar names (case-insensitive)
- if find /Applications ~/Applications -maxdepth 2 -iname "*${app_name}*" -type d 2> /dev/null | grep -iq "\.app"; then
- return 1 # Similar app exists -> not orphaned
- fi
- fi
-
- # Layer 5: PrivilegedHelper special handling
- if [[ "$program" =~ ^/Library/PrivilegedHelperTools/ ]]; then
- local filename=$(basename "$plist")
- local bundle_id="${filename%.plist}"
-
- # Extract app hint from bundle ID (com.vendor.app.helper -> vendor)
- local app_hint=$(echo "$bundle_id" | sed 's/com\.//; s/\..*helper.*//')
-
- if [[ -n "$app_hint" ]] && [[ ${#app_hint} -ge 3 ]]; then
- # Look for main app
- if find /Applications ~/Applications -maxdepth 2 -iname "*${app_hint}*" -type d 2> /dev/null | grep -iq "\.app"; then
- return 1 # Helper's main app exists -> not orphaned
- fi
- fi
- fi
-
- # All checks failed -> likely orphaned
- return 0
-}
-
-# Clean orphaned user-level LaunchAgents
-# Only processes ~/Library/LaunchAgents (safer than system-level)
+# User-level LaunchAgents are user-owned automation/configuration, not generic
+# cleanup targets. `mo clean` must not delete them automatically.
clean_orphaned_launch_agents() {
- local launch_agents_dir="$HOME/Library/LaunchAgents"
-
- [[ ! -d "$launch_agents_dir" ]] && return 0
-
- start_section_spinner "Scanning orphaned launch agents..."
-
- local -a orphaned_items=()
- local total_orphaned_kb=0
-
- # Scan user LaunchAgents
- while IFS= read -r -d '' plist; do
- local filename=$(basename "$plist")
-
- # Skip Apple's LaunchAgents
- [[ "$filename" == com.apple.* ]] && continue
-
- local bundle_id="${filename%.plist}"
-
- # Check if orphaned using multi-layer verification
- if is_launch_item_orphaned "$plist"; then
- local size_kb=$(get_path_size_kb "$plist")
- orphaned_items+=("$bundle_id|$plist")
- total_orphaned_kb=$((total_orphaned_kb + size_kb))
- fi
- done < <(find "$launch_agents_dir" -maxdepth 1 -name "*.plist" -print0 2> /dev/null)
-
- stop_section_spinner
-
- local orphaned_count=${#orphaned_items[@]}
-
- if [[ $orphaned_count -eq 0 ]]; then
- return 0
- fi
-
- # Clean the orphaned items automatically
- local removed_count=0
- local dry_run_count=0
- local is_dry_run=false
- if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
- is_dry_run=true
- fi
- for item in "${orphaned_items[@]}"; do
- IFS='|' read -r bundle_id plist_path <<< "$item"
-
- if [[ "$is_dry_run" == "true" ]]; then
- dry_run_count=$((dry_run_count + 1))
- log_operation "clean" "DRY_RUN" "$plist_path" "orphaned launch agent"
- continue
- fi
-
- # Try to unload first (if currently loaded)
- launchctl unload "$plist_path" 2> /dev/null || true
-
- # Remove the plist file
- if safe_remove "$plist_path" false; then
- removed_count=$((removed_count + 1))
- log_operation "clean" "REMOVED" "$plist_path" "orphaned launch agent"
- else
- log_operation "clean" "FAILED" "$plist_path" "permission denied"
- fi
- done
-
- if [[ "$is_dry_run" == "true" ]]; then
- if [[ $dry_run_count -gt 0 ]]; then
- local cleaned_mb=$(echo "$total_orphaned_kb" | awk '{printf "%.1f", $1/1024}')
- echo " ${YELLOW}${ICON_DRY_RUN}${NC} Would remove $dry_run_count orphaned launch agent(s), ${cleaned_mb}MB"
- note_activity
- fi
- else
- if [[ $removed_count -gt 0 ]]; then
- local cleaned_mb=$(echo "$total_orphaned_kb" | awk '{printf "%.1f", $1/1024}')
- echo " ${GREEN}${ICON_SUCCESS}${NC} Removed $removed_count orphaned launch agent(s), ${cleaned_mb}MB"
- note_activity
- fi
- fi
+ return 0
}
diff --git a/Resources/mole/lib/clean/brew.sh b/Resources/mole/lib/clean/brew.sh
index 202c45a..77b9c38 100644
--- a/Resources/mole/lib/clean/brew.sh
+++ b/Resources/mole/lib/clean/brew.sh
@@ -61,7 +61,7 @@ clean_homebrew() {
local autoremove_exit=0
if [[ "$skip_cleanup" == "false" ]]; then
brew_tmp_file=$(create_temp_file)
- run_with_timeout "$timeout_seconds" brew cleanup > "$brew_tmp_file" 2>&1 &
+ run_with_timeout "$timeout_seconds" brew cleanup --prune=30 > "$brew_tmp_file" 2>&1 &
brew_pid=$!
fi
autoremove_tmp_file=$(create_temp_file)
diff --git a/Resources/mole/lib/clean/caches.sh b/Resources/mole/lib/clean/caches.sh
index 72892ce..0c2ceb7 100644
--- a/Resources/mole/lib/clean/caches.sh
+++ b/Resources/mole/lib/clean/caches.sh
@@ -49,11 +49,17 @@ clean_service_worker_cache() {
[[ ! -d "$cache_path" ]] && return 0
local cleaned_size=0
local protected_count=0
+ # shellcheck disable=SC2016
while IFS= read -r cache_dir; do
[[ ! -d "$cache_dir" ]] && continue
# Extract a best-effort domain name from cache folder.
local domain=$(basename "$cache_dir" | grep -oE '[a-zA-Z0-9][-a-zA-Z0-9]*\.[a-zA-Z]{2,}' | head -1 || echo "")
- local size=$(run_with_timeout 5 get_path_size_kb "$cache_dir")
+ local size=0
+ local _du_out
+ if _du_out=$(run_with_timeout 5 du -skP "$cache_dir" 2> /dev/null); then
+ local _sz="${_du_out%%[^0-9]*}"
+ [[ "$_sz" =~ ^[0-9]+$ ]] && size="$_sz"
+ fi
local is_protected=false
for protected_domain in "${PROTECTED_SW_DOMAINS[@]}"; do
if [[ "$domain" == *"$protected_domain"* ]]; then
@@ -62,13 +68,21 @@ clean_service_worker_cache() {
break
fi
done
+ # Service Worker cache dirs are keyed by origin hash, so they never
+ # match PROTECTED_SW_DOMAINS even when the user added Chrome SW paths
+ # to their whitelist. Honor the whitelist explicitly — otherwise MV3
+ # extensions lose their registered workers mid-session. See #724.
+ if [[ "$is_protected" == "false" ]] && is_path_whitelisted "$cache_dir"; then
+ is_protected=true
+ protected_count=$((protected_count + 1))
+ fi
if [[ "$is_protected" == "false" ]]; then
if [[ "$DRY_RUN" != "true" ]]; then
safe_remove "$cache_dir" true || true
fi
cleaned_size=$((cleaned_size + size))
fi
- done < <(run_with_timeout 10 sh -c "find '$cache_path' -type d -depth 2 2> /dev/null || true")
+ done < <(run_with_timeout 10 sh -c 'find "$1" -type d -depth 2 2>/dev/null || true' _ "$cache_path")
if [[ $cleaned_size -gt 0 ]]; then
local spinner_was_running=false
if [[ -t 1 && -n "${INLINE_SPINNER_PID:-}" ]]; then
@@ -76,11 +90,13 @@ clean_service_worker_cache() {
spinner_was_running=true
fi
local cleaned_mb=$((cleaned_size / 1024))
+ local line_color
+ line_color=$(cleanup_result_color_kb "$cleaned_size")
if [[ "$DRY_RUN" != "true" ]]; then
if [[ $protected_count -gt 0 ]]; then
- echo -e " ${GREEN}${ICON_SUCCESS}${NC} $browser_name Service Worker, ${cleaned_mb}MB, ${protected_count} protected"
+ echo -e " ${line_color}${ICON_SUCCESS}${NC} $browser_name Service Worker${NC}, ${line_color}${cleaned_mb}MB${NC}, ${protected_count} protected"
else
- echo -e " ${GREEN}${ICON_SUCCESS}${NC} $browser_name Service Worker, ${cleaned_mb}MB"
+ echo -e " ${line_color}${ICON_SUCCESS}${NC} $browser_name Service Worker${NC}, ${line_color}${cleaned_mb}MB${NC}"
fi
else
echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} $browser_name Service Worker, would clean ${cleaned_mb}MB, ${protected_count} protected"
@@ -117,6 +133,8 @@ project_cache_has_indicators() {
# Discover candidate project roots without scanning the whole home directory.
discover_project_cache_roots() {
local -a roots=()
+ local -a unique_roots=()
+ local -a seen_identities=()
local root
for root in "${MOLE_PURGE_DEFAULT_SEARCH_PATHS[@]}"; do
@@ -127,12 +145,18 @@ discover_project_cache_roots() {
[[ -d "$root" ]] && roots+=("$root")
done < <(mole_purge_read_paths_config "$HOME/.config/mole/purge_paths")
+ local _indicator_tmp
+ _indicator_tmp=$(create_temp_file)
+ local -a _indicator_pids=()
+ local _max_jobs
+ _max_jobs=$(get_optimal_parallel_jobs scan)
+
local dir
local base
for dir in "$HOME"/*/; do
[[ -d "$dir" ]] || continue
dir="${dir%/}"
- base=$(basename "$dir")
+ base="${dir##*/}"
case "$base" in
.* | Library | Applications | Movies | Music | Pictures | Public)
@@ -140,14 +164,38 @@ discover_project_cache_roots() {
;;
esac
- if project_cache_has_indicators "$dir" 5; then
- roots+=("$dir")
+ (project_cache_has_indicators "$dir" 5 && echo "$dir" >> "$_indicator_tmp") &
+ _indicator_pids+=($!)
+
+ if [[ ${#_indicator_pids[@]} -ge $_max_jobs ]]; then
+ wait "${_indicator_pids[0]}" 2> /dev/null || true
+ _indicator_pids=("${_indicator_pids[@]:1}")
fi
done
+ for _pid in "${_indicator_pids[@]}"; do
+ wait "$_pid" 2> /dev/null || true
+ done
+
+ local _found_dir
+ while IFS= read -r _found_dir; do
+ [[ -n "$_found_dir" ]] && roots+=("$_found_dir")
+ done < "$_indicator_tmp"
+ rm -f "$_indicator_tmp"
[[ ${#roots[@]} -eq 0 ]] && return 0
- printf '%s\n' "${roots[@]}" | LC_ALL=C sort -u
+ for root in "${roots[@]}"; do
+ local identity
+ identity=$(mole_path_identity "$root")
+ if [[ ${#seen_identities[@]} -gt 0 ]] && mole_identity_in_list "$identity" "${seen_identities[@]}"; then
+ continue
+ fi
+
+ seen_identities+=("$identity")
+ unique_roots+=("$root")
+ done
+
+ [[ ${#unique_roots[@]} -gt 0 ]] && printf '%s\n' "${unique_roots[@]}"
}
# Scan a project root for supported build caches while pruning heavy subtrees.
@@ -159,7 +207,7 @@ scan_project_cache_root() {
local -a find_args=(
find -P "$root" -maxdepth 9 -mount
- "(" -name "Library" -o -name ".Trash" -o -name "node_modules" -o -name ".git" -o -name ".svn" -o -name ".hg" -o -name ".venv" -o -name "venv" -o -name ".pnpm-store" -o -name ".fvm" -o -name "DerivedData" -o -name "Pods" ")"
+ "(" -name "Library" -o -name ".Trash" -o -name "node_modules" -o -name ".git" -o -name ".svn" -o -name ".hg" -o -name ".venv" -o -name "venv" -o -name ".pnpm-store" -o -name ".fvm" -o -name "DerivedData" -o -name "Pods" -o -name "miniconda3" -o -name "anaconda3" -o -name "miniforge3" -o -name "mambaforge" -o -name "site-packages" ")"
-prune -o
-type d
"(" -name ".next" -o -name "__pycache__" -o -name ".dart_tool" ")"
@@ -167,7 +215,26 @@ scan_project_cache_root() {
)
local status=0
- run_with_timeout "$scan_timeout" "${find_args[@]}" >> "$output_file" 2> /dev/null || status=$?
+ local tmp_file
+ tmp_file=$(create_temp_file)
+ run_with_timeout "$scan_timeout" "${find_args[@]}" > "$tmp_file" 2> /dev/null || status=$?
+
+ if [[ -s "$tmp_file" ]]; then
+ while IFS= read -r match_path; do
+ [[ -z "$match_path" ]] && continue
+ # Skip __pycache__ dirs with no .pyc/.pyo files (empty or already cleaned)
+ if [[ "${match_path##*/}" == "__pycache__" ]]; then
+ local has_bytecode
+ has_bytecode=$(find "$match_path" -maxdepth 1 \( -name '*.pyc' -o -name '*.pyo' \) 2> /dev/null | head -1)
+ [[ -z "$has_bytecode" ]] && continue
+ fi
+ local project_root=""
+ project_root=$(project_cache_group_root "$root" "$match_path")
+ [[ -z "$project_root" ]] && project_root="$root"
+ printf '%s\t%s\n' "$project_root" "$match_path" >> "$output_file"
+ done < "$tmp_file"
+ fi
+ rm -f "$tmp_file"
if [[ $status -eq 124 ]]; then
debug_log "Project cache scan timed out: $root"
@@ -178,13 +245,203 @@ scan_project_cache_root() {
return 0
}
+project_cache_group_root() {
+ local scan_root="$1"
+ local cache_path="$2"
+ local candidate
+
+ candidate=$(dirname "$cache_path")
+ while [[ -n "$candidate" && "$candidate" != "/" ]]; do
+ if mole_purge_is_project_root "$candidate"; then
+ printf '%s\n' "$candidate"
+ return 0
+ fi
+ [[ "$candidate" == "$scan_root" ]] && break
+ candidate=$(dirname "$candidate")
+ done
+
+ printf '%s\n' "$scan_root"
+}
+
+clean_project_cache_target() {
+ if [[ $# -lt 2 ]]; then
+ return 0
+ fi
+
+ local description="${*: -1}"
+ local -a target_paths=("${@:1:$#-1}")
+
+ if declare -f safe_clean > /dev/null 2>&1; then
+ safe_clean "${target_paths[@]}" "$description" || true
+ return 0
+ fi
+
+ if [[ "${DRY_RUN:-false}" == "true" ]]; then
+ return 0
+ fi
+
+ local target_path=""
+ for target_path in "${target_paths[@]}"; do
+ [[ -e "$target_path" ]] || continue
+ safe_remove "$target_path" true || true
+ done
+}
+
+flush_python_group_if_needed() {
+ local group_root="$1"
+ local array_name="$2"
+
+ local group_count=0
+ eval 'group_count=${#'"$array_name"'[@]}'
+ [[ -z "$group_root" || "$group_count" -eq 0 ]] && return 0
+ eval 'local -a group_dirs=( "${'"$array_name"'[@]}" )'
+ # shellcheck disable=SC2154 # group_dirs assigned via eval above
+ clean_python_bytecode_cache_group "$group_root" "${group_dirs[@]}"
+}
+
+process_project_cache_matches() {
+ local matches_file="$1"
+ [[ -f "$matches_file" ]] || return 0
+
+ local current_python_root=""
+ local -a current_python_dirs=()
+ local record_root=""
+ local cache_dir=""
+ while IFS=$'\t' read -r record_root cache_dir; do
+ [[ -n "$record_root" && -n "$cache_dir" ]] || continue
+ case "${cache_dir##*/}" in
+ ".next")
+ flush_python_group_if_needed "$current_python_root" current_python_dirs
+ current_python_root=""
+ current_python_dirs=()
+ [[ -d "$cache_dir/cache" ]] && clean_project_cache_target "$cache_dir/cache"/* "Next.js build cache" || true
+ ;;
+ "__pycache__")
+ if [[ "$record_root" != "$current_python_root" && ${#current_python_dirs[@]} -gt 0 ]]; then
+ flush_python_group_if_needed "$current_python_root" current_python_dirs
+ current_python_dirs=()
+ fi
+ current_python_root="$record_root"
+ [[ -d "$cache_dir" ]] && current_python_dirs+=("$cache_dir")
+ ;;
+ ".dart_tool")
+ flush_python_group_if_needed "$current_python_root" current_python_dirs
+ current_python_root=""
+ current_python_dirs=()
+ if [[ -d "$cache_dir" ]]; then
+ clean_project_cache_target "$cache_dir" "Flutter build cache (.dart_tool)" || true
+ local build_dir="$(dirname "$cache_dir")/build"
+ if [[ -d "$build_dir" ]]; then
+ clean_project_cache_target "$build_dir" "Flutter build cache (build/)" || true
+ fi
+ fi
+ ;;
+ esac
+ done < <(LC_ALL=C sort -u "$matches_file" 2> /dev/null)
+
+ flush_python_group_if_needed "$current_python_root" current_python_dirs
+}
+
+clean_python_bytecode_cache_group() {
+ local project_root="$1"
+ shift
+
+ local -a cache_dirs=("$@")
+ [[ ${#cache_dirs[@]} -eq 0 ]] && return 0
+
+ local display_root
+ display_root=$(basename "$project_root")
+ local total_size_kb=0
+ local removed_count=0
+ local skipped_count=0
+ local -a dry_run_paths=()
+ local -a dry_run_sizes=()
+
+ local cache_dir
+ for cache_dir in "${cache_dirs[@]}"; do
+ [[ -d "$cache_dir" ]] || continue
+
+ if should_protect_path "$cache_dir"; then
+ skipped_count=$((skipped_count + 1))
+ whitelist_skipped_count=$((${whitelist_skipped_count:-0} + 1))
+ log_operation "clean" "SKIPPED" "$cache_dir" "protected"
+ continue
+ fi
+
+ if is_path_whitelisted "$cache_dir"; then
+ skipped_count=$((skipped_count + 1))
+ whitelist_skipped_count=$((${whitelist_skipped_count:-0} + 1))
+ log_operation "clean" "SKIPPED" "$cache_dir" "whitelist"
+ continue
+ fi
+
+ local size_kb
+ size_kb=$(get_path_size_kb "$cache_dir")
+ [[ "$size_kb" =~ ^[0-9]+$ ]] || size_kb=0
+
+ if [[ "$DRY_RUN" == "true" ]]; then
+ if declare -f register_dry_run_cleanup_target > /dev/null 2>&1; then
+ register_dry_run_cleanup_target "$cache_dir" || continue
+ fi
+ dry_run_paths+=("$cache_dir")
+ dry_run_sizes+=("$size_kb")
+ else
+ if ! safe_remove "$cache_dir" true; then
+ continue
+ fi
+ fi
+
+ total_size_kb=$((total_size_kb + size_kb))
+ removed_count=$((removed_count + 1))
+ done
+
+ if [[ $removed_count -eq 0 ]]; then
+ return 0
+ fi
+
+ local size_human
+ size_human=$(bytes_to_human "$((total_size_kb * 1024))")
+
+ if [[ "$DRY_RUN" == "true" ]]; then
+ if [[ -n "${EXPORT_LIST_FILE:-}" ]]; then
+ ensure_user_file "$EXPORT_LIST_FILE"
+ local i=0
+ for ((i = 0; i < ${#dry_run_paths[@]}; i++)); do
+ local path="${dry_run_paths[i]}"
+ local path_size_kb="${dry_run_sizes[i]:-0}"
+ local path_size_human
+ path_size_human=$(bytes_to_human "$((path_size_kb * 1024))")
+ echo "${path} # ${path_size_human}" >> "$EXPORT_LIST_FILE"
+ done
+ fi
+
+ if [[ $skipped_count -gt 0 ]]; then
+ echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Python bytecode cache · ${display_root}${NC}, ${YELLOW}${removed_count} dirs, ${size_human} dry, ${skipped_count} skipped${NC}"
+ else
+ echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Python bytecode cache · ${display_root}${NC}, ${YELLOW}${removed_count} dirs, ${size_human} dry${NC}"
+ fi
+ else
+ local line_color
+ line_color=$(cleanup_result_color_kb "$total_size_kb")
+ if [[ $skipped_count -gt 0 ]]; then
+ echo -e " ${line_color}${ICON_SUCCESS}${NC} Python bytecode cache · ${display_root}${NC}, ${line_color}${removed_count} dirs, ${size_human}${NC}, ${skipped_count} skipped"
+ else
+ echo -e " ${line_color}${ICON_SUCCESS}${NC} Python bytecode cache · ${display_root}${NC}, ${line_color}${removed_count} dirs, ${size_human}${NC}"
+ fi
+ fi
+
+ files_cleaned=$((${files_cleaned:-0} + removed_count))
+ total_size_cleaned=$((${total_size_cleaned:-0} + total_size_kb))
+ total_items=$((${total_items:-0} + 1))
+ if declare -f note_activity > /dev/null 2>&1; then
+ note_activity
+ fi
+}
+
# Next.js/Python/Flutter project caches scoped to discovered project roots.
clean_project_caches() {
stop_inline_spinner 2> /dev/null || true
- local matches_tmp_file
- matches_tmp_file=$(create_temp_file)
-
local -a scan_roots=()
local root
while IFS= read -r root; do
@@ -199,30 +456,24 @@ clean_project_caches() {
fi
for root in "${scan_roots[@]}"; do
- scan_project_cache_root "$root" "$matches_tmp_file"
+ local root_matches_file
+ root_matches_file=$(create_temp_file)
+ scan_project_cache_root "$root" "$root_matches_file"
+
+ if [[ -t 1 ]]; then
+ stop_inline_spinner
+ fi
+
+ process_project_cache_matches "$root_matches_file"
+ rm -f "$root_matches_file"
+
+ if [[ -t 1 ]]; then
+ MOLE_SPINNER_PREFIX=" "
+ start_inline_spinner "Searching project caches..."
+ fi
done
if [[ -t 1 ]]; then
stop_inline_spinner
fi
-
- while IFS= read -r cache_dir; do
- case "$(basename "$cache_dir")" in
- ".next")
- [[ -d "$cache_dir/cache" ]] && safe_clean "$cache_dir/cache"/* "Next.js build cache" || true
- ;;
- "__pycache__")
- [[ -d "$cache_dir" ]] && safe_clean "$cache_dir"/* "Python bytecode cache" || true
- ;;
- ".dart_tool")
- if [[ -d "$cache_dir" ]]; then
- safe_clean "$cache_dir" "Flutter build cache (.dart_tool)" || true
- local build_dir="$(dirname "$cache_dir")/build"
- if [[ -d "$build_dir" ]]; then
- safe_clean "$build_dir" "Flutter build cache (build/)" || true
- fi
- fi
- ;;
- esac
- done < <(LC_ALL=C sort -u "$matches_tmp_file" 2> /dev/null)
}
diff --git a/Resources/mole/lib/clean/dev.sh b/Resources/mole/lib/clean/dev.sh
index fb8eefd..f3d05ab 100644
--- a/Resources/mole/lib/clean/dev.sh
+++ b/Resources/mole/lib/clean/dev.sh
@@ -2,10 +2,25 @@
# Developer Tools Cleanup Module
set -euo pipefail
-# Tool cache helper (respects DRY_RUN).
+# Tool cache helper (respects DRY_RUN and whitelist).
+# Args:
+# $1 = description (display name)
+# $2 = cache path to check against whitelist (empty string to skip check)
+# $3+ = command to run
clean_tool_cache() {
local description="$1"
- shift
+ local cache_path="$2"
+ shift 2
+
+ if [[ -n "$cache_path" ]] && is_path_whitelisted "$cache_path"; then
+ if [[ "$DRY_RUN" == "true" ]]; then
+ echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} $description · would skip (whitelist)"
+ else
+ echo -e " ${GREEN}${ICON_SUCCESS}${NC} $description · skipped (whitelist)"
+ fi
+ return 0
+ fi
+
if [[ "$DRY_RUN" != "true" ]]; then
local command_succeeded=false
if [[ -t 1 ]]; then
@@ -31,8 +46,6 @@ clean_dev_npm() {
local npm_cache_path="$npm_default_cache"
if command -v npm > /dev/null 2>&1; then
- clean_tool_cache "npm cache" npm cache clean --force
-
start_section_spinner "Checking npm cache path..."
npm_cache_path=$(run_with_timeout 2 npm config get cache 2> /dev/null) || npm_cache_path=""
stop_section_spinner
@@ -41,6 +54,7 @@ clean_dev_npm() {
npm_cache_path="$npm_default_cache"
fi
+ clean_tool_cache "npm cache" "$npm_cache_path" npm cache clean --force
note_activity
fi
@@ -75,11 +89,17 @@ clean_dev_npm() {
local pnpm_default_store=~/Library/pnpm/store
# Check if pnpm is actually usable (not just Corepack shim)
if command -v pnpm > /dev/null 2>&1 && COREPACK_ENABLE_DOWNLOAD_PROMPT=0 pnpm --version > /dev/null 2>&1; then
- COREPACK_ENABLE_DOWNLOAD_PROMPT=0 clean_tool_cache "pnpm cache" pnpm store prune
local pnpm_store_path
start_section_spinner "Checking store path..."
pnpm_store_path=$(COREPACK_ENABLE_DOWNLOAD_PROMPT=0 run_with_timeout 2 pnpm store path 2> /dev/null) || pnpm_store_path=""
stop_section_spinner
+
+ local pnpm_cache_check="$pnpm_default_store"
+ if [[ -n "$pnpm_store_path" && "$pnpm_store_path" == /* ]]; then
+ pnpm_cache_check="$pnpm_store_path"
+ fi
+ COREPACK_ENABLE_DOWNLOAD_PROMPT=0 clean_tool_cache "pnpm cache" "$pnpm_cache_check" pnpm store prune
+
if [[ -n "$pnpm_store_path" && "$pnpm_store_path" != "$pnpm_default_store" ]]; then
safe_clean "$pnpm_default_store"/* "Orphaned pnpm store"
fi
@@ -87,17 +107,83 @@ clean_dev_npm() {
# pnpm not installed or not usable, just clean the default store directory
safe_clean "$pnpm_default_store"/* "pnpm store"
fi
+ local bun_default_cache="$HOME/.bun/install/cache"
+ local bun_cache_path="$bun_default_cache"
+ local bun_cache_cleaned=false
+ local bun_dry_run="${DRY_RUN:-false}"
+ if command -v bun > /dev/null 2>&1 && bun --version > /dev/null 2>&1; then
+ if [[ -t 1 ]]; then start_section_spinner "Checking bun cache path..."; fi
+ bun_cache_path=$(run_with_timeout 2 bun pm cache 2> /dev/null) || bun_cache_path=""
+ if [[ -t 1 ]]; then stop_section_spinner; fi
+
+ if [[ -z "$bun_cache_path" || "$bun_cache_path" != /* ]]; then
+ bun_cache_path="$bun_default_cache"
+ fi
+
+ local bun_protected=false
+ is_path_whitelisted "$bun_cache_path" && bun_protected=true
+
+ if [[ "$bun_protected" == "true" ]]; then
+ if [[ "$bun_dry_run" == "true" ]]; then
+ echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} bun cache · would skip (whitelist)"
+ else
+ echo -e " ${GREEN}${ICON_SUCCESS}${NC} bun cache · skipped (whitelist)"
+ fi
+ bun_cache_cleaned=true
+ elif [[ "$bun_dry_run" != "true" ]]; then
+ if [[ -t 1 ]]; then
+ start_section_spinner "Cleaning bun cache..."
+ fi
+ if run_with_timeout 10 bun pm cache rm > /dev/null 2>&1; then
+ bun_cache_cleaned=true
+ fi
+ if [[ -t 1 ]]; then
+ stop_section_spinner
+ fi
+ if [[ "$bun_cache_cleaned" == "true" ]]; then
+ echo -e " ${GREEN}${ICON_SUCCESS}${NC} bun cache"
+ fi
+ else
+ echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} bun cache · would clean"
+ bun_cache_cleaned=true
+ fi
+
+ local bun_cache_path_normalized="${bun_cache_path%/}"
+ local bun_default_cache_normalized="${bun_default_cache%/}"
+ if [[ -d "$bun_cache_path_normalized" ]]; then
+ bun_cache_path_normalized=$(cd "$bun_cache_path_normalized" 2> /dev/null && pwd -P) || bun_cache_path_normalized="${bun_cache_path%/}"
+ fi
+ if [[ -d "$bun_default_cache_normalized" ]]; then
+ bun_default_cache_normalized=$(cd "$bun_default_cache_normalized" 2> /dev/null && pwd -P) || bun_default_cache_normalized="${bun_default_cache%/}"
+ fi
+
+ if [[ "$bun_cache_path_normalized" != "$bun_default_cache_normalized" ]]; then
+ safe_clean "$bun_default_cache"/* "Orphaned bun cache"
+ fi
+
+ # If bun pm cache rm fails, fall back to filesystem cleanup to avoid no-op.
+ if [[ "$bun_cache_cleaned" != "true" ]]; then
+ safe_clean "$bun_cache_path"/* "Bun cache"
+ fi
+ else
+ safe_clean "$bun_default_cache"/* "Bun cache"
+ fi
+
note_activity
safe_clean ~/.tnpm/_cacache/* "tnpm cache directory"
safe_clean ~/.tnpm/_logs/* "tnpm logs"
safe_clean ~/.yarn/cache/* "Yarn cache"
- safe_clean ~/.bun/install/cache/* "Bun cache"
}
# Python/pip ecosystem caches.
clean_dev_python() {
# 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'
+ local pip_cache_path
+ pip_cache_path=$(run_with_timeout 2 pip3 cache dir 2> /dev/null) || pip_cache_path=""
+ if [[ -z "$pip_cache_path" || "$pip_cache_path" != /* ]]; then
+ pip_cache_path="$HOME/Library/Caches/pip"
+ fi
+ clean_tool_cache "pip cache" "$pip_cache_path" bash -c 'pip3 cache purge > /dev/null 2>&1 || true'
note_activity
fi
safe_clean ~/.pyenv/cache/* "pyenv cache"
@@ -136,16 +222,54 @@ clean_dev_go() {
fi
if [[ "$build_protected" != "true" && "$mod_protected" != "true" ]]; then
- clean_tool_cache "Go cache" bash -c 'go clean -modcache > /dev/null 2>&1 || true; go clean -cache > /dev/null 2>&1 || true'
+ clean_tool_cache "Go cache" "" bash -c 'go clean -modcache > /dev/null 2>&1 || true; go clean -cache > /dev/null 2>&1 || true'
elif [[ "$build_protected" == "true" ]]; then
- clean_tool_cache "Go module cache" bash -c 'go clean -modcache > /dev/null 2>&1 || true'
+ clean_tool_cache "Go module cache" "" bash -c 'go clean -modcache > /dev/null 2>&1 || true'
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Go build cache · skipped (whitelist)"
else
- clean_tool_cache "Go build cache" bash -c 'go clean -cache > /dev/null 2>&1 || true'
+ clean_tool_cache "Go build cache" "" bash -c 'go clean -cache > /dev/null 2>&1 || true'
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Go module cache · skipped (whitelist)"
fi
note_activity
}
+
+get_mise_cache_path() {
+ if [[ -n "${MISE_CACHE_DIR:-}" && "${MISE_CACHE_DIR}" == /* ]]; then
+ echo "$MISE_CACHE_DIR"
+ return 0
+ fi
+
+ if command -v mise > /dev/null 2>&1; then
+ local mise_cache_path
+ mise_cache_path=$(run_with_timeout 2 mise cache path 2> /dev/null || echo "")
+ if [[ -n "$mise_cache_path" && "$mise_cache_path" == /* ]]; then
+ echo "$mise_cache_path"
+ return 0
+ fi
+ fi
+
+ echo "$HOME/Library/Caches/mise"
+}
+
+clean_dev_mise() {
+ local mise_cache_path
+ mise_cache_path=$(get_mise_cache_path)
+
+ if command -v mise > /dev/null 2>&1; then
+ if [[ "$DRY_RUN" != "true" ]]; then
+ clean_tool_cache "mise cache" "$mise_cache_path" bash -c 'mise cache clear > /dev/null 2>&1 || true'
+ note_activity
+ elif is_path_whitelisted "$mise_cache_path"; then
+ echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} mise cache · would skip (whitelist)"
+ note_activity
+ else
+ echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} mise cache · would clean"
+ note_activity
+ fi
+ fi
+
+ safe_clean "$mise_cache_path"/* "mise cache"
+}
# Rust/cargo caches.
clean_dev_rust() {
safe_clean ~/.cargo/registry/cache/* "Rust cargo cache"
@@ -190,22 +314,11 @@ check_rust_toolchains() {
# Docker caches (guarded by daemon check).
clean_dev_docker() {
if command -v docker > /dev/null 2>&1; then
- if [[ "$DRY_RUN" != "true" ]]; then
- start_section_spinner "Checking Docker daemon..."
- local docker_running=false
- if run_with_timeout 3 docker info > /dev/null 2>&1; then
- docker_running=true
- fi
- stop_section_spinner
- if [[ "$docker_running" == "true" ]]; then
- clean_tool_cache "Docker build cache" docker builder prune -af
- else
- debug_log "Docker daemon not running, skipping Docker cache cleanup"
- fi
- else
- note_activity
- echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Docker build cache · would clean"
- fi
+ note_activity
+ echo -e " ${GRAY}${ICON_WARNING}${NC} Docker unused data · skipped by default"
+ echo -e " ${GRAY}${ICON_REVIEW}${NC} ${GRAY}Review: docker system df${NC}"
+ echo -e " ${GRAY}${ICON_REVIEW}${NC} ${GRAY}Prune: docker system prune --filter until=720h${NC}"
+ debug_log "Docker daemon-managed cleanup skipped by default"
fi
safe_clean ~/.docker/buildx/cache/* "Docker BuildX cache"
}
@@ -213,7 +326,9 @@ clean_dev_docker() {
clean_dev_nix() {
if command -v nix-collect-garbage > /dev/null 2>&1; then
if [[ "$DRY_RUN" != "true" ]]; then
- clean_tool_cache "Nix garbage collection" nix-collect-garbage --delete-older-than 30d
+ clean_tool_cache "Nix garbage collection" "/nix/store" nix-collect-garbage --delete-older-than 30d
+ elif is_path_whitelisted "/nix/store"; then
+ echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Nix garbage collection · would skip (whitelist)"
else
echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Nix garbage collection · would clean"
fi
@@ -399,7 +514,9 @@ clean_xcode_device_support() {
done
if [[ $removed_count -gt 0 ]]; then
- echo -e " ${GREEN}${ICON_SUCCESS}${NC} ${display_name} · removed ${removed_count} old versions, ${stale_size_human}"
+ local line_color
+ line_color=$(cleanup_result_color_kb "$stale_size_kb")
+ echo -e " ${line_color}${ICON_SUCCESS}${NC} ${display_name} · removed ${removed_count} old versions, ${line_color}${stale_size_human}${NC}"
note_activity
fi
fi
@@ -615,10 +732,12 @@ clean_xcode_simulator_runtime_volumes() {
if [[ $removed_count -gt 0 ]]; then
local removed_human
removed_human=$(bytes_to_human "$((removed_size_kb * 1024))")
+ local line_color
+ line_color=$(cleanup_result_color_kb "$removed_size_kb")
if [[ $skipped_protected -gt 0 ]]; then
- echo -e " ${GREEN}${ICON_SUCCESS}${NC} Xcode runtime volumes · removed ${removed_count} (${removed_human}), skipped ${skipped_protected} protected"
+ echo -e " ${line_color}${ICON_SUCCESS}${NC} Xcode runtime volumes · removed ${removed_count} (${line_color}${removed_human}${NC}), skipped ${skipped_protected} protected"
else
- echo -e " ${GREEN}${ICON_SUCCESS}${NC} Xcode runtime volumes · removed ${removed_count} (${removed_human})"
+ echo -e " ${line_color}${ICON_SUCCESS}${NC} Xcode runtime volumes · removed ${removed_count} (${line_color}${removed_human}${NC})"
fi
note_activity
else
@@ -646,9 +765,19 @@ clean_dev_mobile() {
local -a unavailable_udids=()
local unavailable_udid=""
- # Check if simctl is accessible and working
+ # Check if simctl is accessible and working; timeout prevents hang when CLT-only.
local simctl_available=true
- if ! xcrun simctl list devices > /dev/null 2>&1; then
+ local simctl_probe_ok=false
+ if declare -F xcrun > /dev/null 2>&1; then
+ if xcrun simctl list devices > /dev/null 2>&1; then
+ simctl_probe_ok=true
+ fi
+ else
+ if run_with_timeout 2 xcrun simctl list devices > /dev/null 2>&1; then
+ simctl_probe_ok=true
+ fi
+ fi
+ if [[ "$simctl_probe_ok" != "true" ]]; then
debug_log "simctl not accessible or CoreSimulator service not running"
echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode unavailable simulators · simctl not available"
note_activity
@@ -704,10 +833,12 @@ clean_dev_mobile() {
removed_unavailable=0
fi
+ local line_color
+ line_color=$(cleanup_result_color_kb "$unavailable_size_kb")
if ((removed_unavailable > 0)); then
- echo -e " ${GREEN}${ICON_SUCCESS}${NC} Xcode unavailable simulators · removed ${removed_unavailable}, ${unavailable_size_human}"
+ echo -e " ${line_color}${ICON_SUCCESS}${NC} Xcode unavailable simulators · removed ${removed_unavailable}, ${line_color}${unavailable_size_human}${NC}"
else
- echo -e " ${GREEN}${ICON_SUCCESS}${NC} Xcode unavailable simulators · cleanup completed, ${unavailable_size_human}"
+ echo -e " ${line_color}${ICON_SUCCESS}${NC} Xcode unavailable simulators · cleanup completed, ${line_color}${unavailable_size_human}${NC}"
fi
else
stop_section_spinner
@@ -753,7 +884,9 @@ clean_dev_mobile() {
if ((manually_removed > 0)); then
if ((manual_failed == 0)); then
- echo -e " ${GREEN}${ICON_SUCCESS}${NC} Xcode unavailable simulators · removed ${manually_removed} (fallback), ${unavailable_size_human}"
+ local line_color
+ line_color=$(cleanup_result_color_kb "$unavailable_size_kb")
+ echo -e " ${line_color}${ICON_SUCCESS}${NC} Xcode unavailable simulators · removed ${manually_removed} (fallback), ${line_color}${unavailable_size_human}${NC}"
else
echo -e " ${YELLOW}${ICON_WARNING}${NC} Xcode unavailable simulators · partially cleaned ${manually_removed}/${#unavailable_udids[@]}, ${unavailable_size_human}"
fi
@@ -786,6 +919,7 @@ clean_dev_mobile() {
safe_clean ~/.android/cache/* "Android SDK cache"
safe_clean ~/Library/Developer/Xcode/UserData/IB\ Support/* "Xcode Interface Builder cache"
safe_clean ~/.cache/swift-package-manager/* "Swift package manager cache"
+ safe_clean ~/Library/Caches/org.swift.swiftpm/* "Swift package manager library cache"
# Expo/React Native caches (preserve state.json which contains auth tokens).
safe_clean ~/.expo/expo-go/* "Expo Go cache"
safe_clean ~/.expo/android-apk-cache/* "Expo Android APK cache"
@@ -914,10 +1048,76 @@ clean_dev_jetbrains_toolbox() {
_restore_whitelist
}
+
+# JetBrains IDE logs are safe to rebuild, unlike some cache subtrees that can
+# invalidate IDE indexes and trigger expensive reindexing.
+clean_dev_jetbrains_logs() {
+ safe_clean ~/Library/Logs/JetBrains/* "JetBrains IDE logs"
+}
+
+# AI coding agents (Claude Code, Cursor Agent, etc.) auto-update but never
+# remove previous versions, so ~/.local/share//versions accumulates
+# hundreds of MB per release. Keep the most recently modified N entries
+# (newest mtime == currently used) and remove the rest. Entries may be
+# binaries (Claude Code) or directories (Cursor Agent), so we enumerate
+# both with -type f and -type d.
+clean_dev_ai_agents() {
+ local keep_previous="${MOLE_AI_AGENTS_KEEP:-1}"
+ [[ "$keep_previous" =~ ^[0-9]+$ ]] || keep_previous=1
+
+ local -a agent_specs=(
+ "$HOME/.local/share/claude/versions|Claude Code old version"
+ "$HOME/.local/share/cursor-agent/versions|Cursor Agent old version"
+ )
+
+ local spec
+ for spec in "${agent_specs[@]}"; do
+ local versions_root="${spec%%|*}"
+ local label="${spec#*|}"
+ [[ -d "$versions_root" ]] || continue
+
+ local -a entries=()
+ while IFS= read -r -d '' entry; do
+ local name
+ name=$(basename "$entry")
+ [[ "$name" == .* ]] && continue
+ [[ ! "$name" =~ ^[0-9] ]] && continue
+ entries+=("$entry")
+ done < <(command find "$versions_root" -mindepth 1 -maxdepth 1 \( -type f -o -type d \) -print0 2> /dev/null)
+
+ [[ ${#entries[@]} -le "$keep_previous" ]] && continue
+
+ local -a sorted=()
+ while IFS= read -r line; do
+ sorted+=("${line#* }")
+ done < <(
+ local entry
+ for entry in "${entries[@]}"; do
+ local mtime
+ mtime=$(stat -f%m "$entry" 2> /dev/null || echo "0")
+ printf '%s %s\n' "$mtime" "$entry"
+ done | sort -rn
+ )
+
+ local idx=0
+ local target
+ for target in "${sorted[@]}"; do
+ if [[ $idx -lt $keep_previous ]]; then
+ idx=$((idx + 1))
+ continue
+ fi
+ safe_clean "$target" "$label"
+ note_activity
+ idx=$((idx + 1))
+ done
+ done
+}
+
# Other language tool caches.
clean_dev_other_langs() {
safe_clean ~/.bundle/cache/* "Ruby Bundler cache"
- safe_clean ~/.composer/cache/* "PHP Composer cache"
+ safe_clean ~/.composer/cache/* "PHP Composer cache (legacy)"
+ safe_clean ~/Library/Caches/composer/* "PHP Composer cache"
safe_clean ~/.nuget/packages/* "NuGet packages cache"
# safe_clean ~/.pub-cache/* "Dart Pub cache"
safe_clean ~/.cache/bazel/* "Bazel cache"
@@ -979,6 +1179,21 @@ clean_dev_misc() {
safe_clean ~/Library/Application\ Support/Claude/GPUCache/* "Claude GPU cache"
safe_clean ~/Library/Application\ Support/Claude/DawnGraphiteCache/* "Claude Dawn cache"
safe_clean ~/Library/Application\ Support/Claude/DawnWebGPUCache/* "Claude WebGPU cache"
+ safe_clean ~/Library/Application\ Support/Claude/sentry/* "Claude sentry cache"
+ safe_clean ~/Library/Application\ Support/Claude/pending-uploads/* "Claude pending uploads"
+ # Qoder (VS Code fork, Electron)
+ safe_clean ~/Library/Application\ Support/Qoder/Cache/* "Qoder cache"
+ safe_clean ~/Library/Application\ Support/Qoder/CachedData/* "Qoder cached data"
+ safe_clean ~/Library/Application\ Support/Qoder/CachedExtensionVSIXs/* "Qoder extension cache"
+ safe_clean ~/Library/Application\ Support/Qoder/Code\ Cache/* "Qoder code cache"
+ safe_clean ~/Library/Application\ Support/Qoder/GPUCache/* "Qoder GPU cache"
+ safe_clean ~/Library/Application\ Support/Qoder/DawnGraphiteCache/* "Qoder Dawn cache"
+ safe_clean ~/Library/Application\ Support/Qoder/DawnWebGPUCache/* "Qoder WebGPU cache"
+ safe_clean ~/Library/Application\ Support/Qoder/logs/* "Qoder logs"
+ # Prisma ORM engine binaries cache
+ safe_clean ~/.cache/prisma/* "Prisma cache"
+ # OpenCode AI tool cache
+ safe_clean ~/.cache/opencode/* "OpenCode cache"
}
# Shell and VCS leftovers.
clean_dev_shell() {
@@ -1024,7 +1239,19 @@ clean_dev_editors() {
safe_clean ~/Library/Application\ Support/Code/DawnWebGPUCache/* "VS Code WebGPU cache"
safe_clean ~/Library/Application\ Support/Code/GPUCache/* "VS Code GPU cache"
safe_clean ~/Library/Application\ Support/Code/CachedExtensionVSIXs/* "VS Code extension cache"
+ clean_service_worker_cache "VS Code" "$HOME/Library/Application Support/Code/Service Worker/CacheStorage"
+ safe_clean ~/Library/Application\ Support/Code/Service\ Worker/ScriptCache/* "VS Code Service Worker ScriptCache"
safe_clean ~/Library/Caches/Zed/* "Zed cache"
+ safe_clean ~/Library/Caches/copilot/* "GitHub Copilot cache"
+ safe_clean ~/.cache/vscode-ripgrep/* "VS Code ripgrep cache"
+ safe_clean ~/Library/Caches/Cursor/* "Cursor cache"
+ safe_clean ~/Library/Application\ Support/Cursor/CachedData/* "Cursor cached data"
+ safe_clean ~/Library/Application\ Support/Cursor/CachedExtensionVSIXs/* "Cursor extension cache"
+ safe_clean ~/Library/Application\ Support/Cursor/GPUCache/* "Cursor GPU cache"
+ safe_clean ~/Library/Application\ Support/Cursor/DawnGraphiteCache/* "Cursor Dawn cache"
+ safe_clean ~/Library/Application\ Support/Cursor/DawnWebGPUCache/* "Cursor WebGPU cache"
+ clean_service_worker_cache "Cursor" "$HOME/Library/Application Support/Cursor/Service Worker/CacheStorage"
+ safe_clean ~/Library/Application\ Support/Cursor/Service\ Worker/ScriptCache/* "Cursor Service Worker ScriptCache"
}
# Main developer tools cleanup sequence.
clean_developer_tools() {
@@ -1035,6 +1262,7 @@ clean_developer_tools() {
clean_dev_npm
clean_dev_python
clean_dev_go
+ clean_dev_mise
clean_dev_rust
check_rust_toolchains
clean_dev_docker
@@ -1046,6 +1274,8 @@ clean_developer_tools() {
clean_dev_mobile
clean_dev_jvm
clean_dev_jetbrains_toolbox
+ clean_dev_jetbrains_logs
+ clean_dev_ai_agents
clean_dev_other_langs
clean_dev_cicd
clean_dev_database
diff --git a/Resources/mole/lib/clean/hints.sh b/Resources/mole/lib/clean/hints.sh
index f6538bf..ba49116 100644
--- a/Resources/mole/lib/clean/hints.sh
+++ b/Resources/mole/lib/clean/hints.sh
@@ -54,6 +54,76 @@ hint_get_path_size_kb_with_timeout() {
printf '%s\n' "$size_kb"
}
+# shellcheck disable=SC2329
+hint_extract_launch_agent_program_path() {
+ local plist="$1"
+ local program=""
+
+ program=$(plutil -extract ProgramArguments.0 raw "$plist" 2> /dev/null || echo "")
+ if [[ -z "$program" ]]; then
+ program=$(plutil -extract Program raw "$plist" 2> /dev/null || echo "")
+ fi
+
+ printf '%s\n' "$program"
+}
+
+# shellcheck disable=SC2329
+hint_extract_launch_agent_associated_bundle() {
+ local plist="$1"
+ local associated=""
+
+ associated=$(plutil -extract AssociatedBundleIdentifiers.0 raw "$plist" 2> /dev/null || echo "")
+ if [[ -z "$associated" ]] || [[ "$associated" == "1" ]]; then
+ associated=$(plutil -extract AssociatedBundleIdentifiers raw "$plist" 2> /dev/null || echo "")
+ if [[ "$associated" == "{"* ]] || [[ "$associated" == "["* ]]; then
+ associated=""
+ fi
+ fi
+
+ printf '%s\n' "$associated"
+}
+
+# shellcheck disable=SC2329
+hint_is_app_scoped_launch_target() {
+ local program="$1"
+
+ case "$program" in
+ /Applications/Setapp/*.app/* | \
+ /Applications/*.app/* | \
+ "$HOME"/Applications/*.app/* | \
+ /Library/Input\ Methods/*.app/* | \
+ /Library/PrivilegedHelperTools/*)
+ return 0
+ ;;
+ esac
+
+ return 1
+}
+
+# shellcheck disable=SC2329
+hint_is_system_binary() {
+ local program="$1"
+
+ case "$program" in
+ /bin/* | /sbin/* | /usr/bin/* | /usr/sbin/* | /usr/libexec/*)
+ return 0
+ ;;
+ esac
+
+ return 1
+}
+
+# shellcheck disable=SC2329
+hint_launch_agent_bundle_exists() {
+ local bundle_id="$1"
+
+ [[ -z "$bundle_id" ]] && return 1
+
+ # Delegate to the shared resolver so Spotlight misses (e.g. KeePassXC
+ # installed via Homebrew) fall back to a direct /Applications scan. See #732.
+ bundle_has_installed_app "$bundle_id"
+}
+
# shellcheck disable=SC2329
record_project_artifact_hint() {
local path="$1"
@@ -351,3 +421,61 @@ show_project_artifact_hint_notice() {
fi
echo -e " ${GRAY}${ICON_REVIEW}${NC} Review: mo purge"
}
+
+# shellcheck disable=SC2329
+show_user_launch_agent_hint_notice() {
+ local launch_agents_dir="$HOME/Library/LaunchAgents"
+ [[ -d "$launch_agents_dir" ]] || return 0
+
+ local max_hits=3
+ local -a labels=()
+ local -a reasons=()
+ local -a targets=()
+ local plist
+
+ while IFS= read -r -d '' plist; do
+ local filename
+ filename=$(basename "$plist")
+ [[ "$filename" == com.apple.* ]] && continue
+
+ local reason=""
+ local target=""
+ local program=""
+ local associated=""
+
+ program=$(hint_extract_launch_agent_program_path "$plist")
+ if [[ -n "$program" ]] && hint_is_system_binary "$program"; then
+ continue
+ fi
+ if [[ -n "$program" ]] && hint_is_app_scoped_launch_target "$program" && [[ ! -e "$program" ]]; then
+ reason="Missing app/helper target"
+ target="${program/#$HOME/~}"
+ else
+ associated=$(hint_extract_launch_agent_associated_bundle "$plist")
+ if [[ -n "$associated" ]] && ! hint_launch_agent_bundle_exists "$associated"; then
+ reason="Associated app not found"
+ target="$associated"
+ fi
+ fi
+
+ if [[ -n "$reason" ]]; then
+ labels+=("$filename")
+ reasons+=("$reason")
+ targets+=("$target")
+ if [[ ${#labels[@]} -ge $max_hits ]]; then
+ break
+ fi
+ fi
+ done < <(find "$launch_agents_dir" -maxdepth 1 -name "*.plist" -print0 2> /dev/null)
+
+ [[ ${#labels[@]} -eq 0 ]] && return 0
+
+ note_activity
+
+ local i
+ for i in "${!labels[@]}"; do
+ echo -e " ${GREEN}${ICON_LIST}${NC} Potential stale login item: ${labels[$i]}"
+ echo -e " ${GRAY}${ICON_SUBLIST}${NC} ${reasons[$i]}: ${GRAY}${targets[$i]}${NC}"
+ done
+ echo -e " ${GRAY}${ICON_REVIEW}${NC} Review: open ~/Library/LaunchAgents and remove only items you recognize"
+}
diff --git a/Resources/mole/lib/clean/project.sh b/Resources/mole/lib/clean/project.sh
index c1a9ee7..70d5d38 100644
--- a/Resources/mole/lib/clean/project.sh
+++ b/Resources/mole/lib/clean/project.sh
@@ -26,6 +26,7 @@ readonly PURGE_CONFIG_FILE="$HOME/.config/mole/purge_paths"
# Resolved search paths.
PURGE_SEARCH_PATHS=()
+PURGE_CATEGORY_FULL_PATHS_ARRAY=()
# Project indicators for container detection.
# Monorepo indicators (higher priority)
@@ -74,7 +75,9 @@ discover_project_dirs() {
for path in "${DEFAULT_PURGE_SEARCH_PATHS[@]}"; do
if [[ -d "$path" ]]; then
- discovered+=("$path")
+ # Resolve to canonical casing to avoid duplicates on
+ # case-insensitive filesystems (macOS APFS).
+ discovered+=("$(mole_purge_resolve_path_case "$path")")
fi
done
@@ -83,9 +86,11 @@ discover_project_dirs() {
for dir in "$HOME"/*/; do
[[ ! -d "$dir" ]] && continue
dir="${dir%/}" # Remove trailing slash
+ # Resolve casing so that ~/code and ~/Code compare equal.
+ dir=$(mole_purge_resolve_path_case "$dir")
local already_found=false
- for existing in "${DEFAULT_PURGE_SEARCH_PATHS[@]}"; do
+ for existing in "${discovered[@]+"${discovered[@]}"}"; do
if [[ "$dir" == "$existing" ]]; then
already_found=true
break
@@ -98,27 +103,67 @@ discover_project_dirs() {
fi
done
- printf '%s\n' "${discovered[@]}" | sort -u
+ printf '%s\n' "${discovered[@]+"${discovered[@]}"}" | sort -u
}
-# Save discovered paths to config.
-save_discovered_paths() {
+# Prepare purge config directory/file ownership when possible.
+prepare_purge_config_path() {
+ ensure_user_dir "$(dirname "$PURGE_CONFIG_FILE")"
+ ensure_user_file "$PURGE_CONFIG_FILE"
+}
+
+# Write purge config content atomically when possible.
+write_purge_config() {
+ local header="$1"
+ shift
local -a paths=("$@")
- ensure_user_dir "$(dirname "$PURGE_CONFIG_FILE")"
+ prepare_purge_config_path
- cat > "$PURGE_CONFIG_FILE" << 'EOF'
-# Mole Purge Paths - Auto-discovered project directories
-# Edit this file to customize, or run: mo purge --paths
-# Add one path per line (supports ~ for home directory)
+ local tmp_file
+ tmp_file=$(mktemp_file "mole-purge-paths") || return 1
+
+ if ! cat > "$tmp_file" << EOF; then
+$header
EOF
+ rm -f "$tmp_file" 2> /dev/null || true
+ return 1
+ fi
- printf '\n' >> "$PURGE_CONFIG_FILE"
- for path in "${paths[@]}"; do
- # Convert $HOME to ~ for portability
- path="${path/#$HOME/~}"
- echo "$path" >> "$PURGE_CONFIG_FILE"
- done
+ # Guard empty-array expansion under `set -u` on bash 3.2 (first-run case
+ # from `mo purge --paths` passes only the header with no paths).
+ if [[ ${#paths[@]} -gt 0 ]]; then
+ for path in "${paths[@]}"; do
+ # Convert $HOME to ~ for portability
+ path="${path/#$HOME/~}"
+ if ! printf '%s\n' "$path" >> "$tmp_file"; then
+ rm -f "$tmp_file" 2> /dev/null || true
+ return 1
+ fi
+ done
+ fi
+
+ if ! mv "$tmp_file" "$PURGE_CONFIG_FILE" 2> /dev/null; then
+ rm -f "$tmp_file" 2> /dev/null || true
+ return 1
+ fi
+
+ return 0
+}
+
+warn_purge_config_write_failure() {
+ [[ -t 1 ]] || return 0
+ [[ -z "${_PURGE_DISCOVERY_SILENT:-}" ]] || return 0
+ echo -e "${YELLOW}${ICON_WARNING}${NC} Could not save purge paths to ${PURGE_CONFIG_FILE/#$HOME/~}, using discovered paths for this run" >&2
+}
+
+# Save discovered paths to config.
+save_discovered_paths() {
+ local -a paths=("$@")
+ write_purge_config "# Mole Purge Paths - Auto-discovered project directories
+# Edit this file to customize, or run: mo purge --paths
+# Add one path per line (supports ~ for home directory)
+" "${paths[@]}"
}
# Load purge paths from config or auto-discover
@@ -141,10 +186,12 @@ load_purge_config() {
if [[ ${#discovered[@]} -gt 0 ]]; then
PURGE_SEARCH_PATHS=("${discovered[@]}")
- save_discovered_paths "${discovered[@]}"
-
- if [[ -t 1 ]] && [[ -z "${_PURGE_DISCOVERY_SILENT:-}" ]]; then
- echo -e "${GRAY}Found ${#discovered[@]} project directories, saved to config${NC}" >&2
+ if save_discovered_paths "${discovered[@]}"; then
+ if [[ -t 1 ]] && [[ -z "${_PURGE_DISCOVERY_SILENT:-}" ]]; then
+ echo -e "${GRAY}Found ${#discovered[@]} project directories, saved to config${NC}" >&2
+ fi
+ else
+ warn_purge_config_write_failure
fi
else
PURGE_SEARCH_PATHS=("${DEFAULT_PURGE_SEARCH_PATHS[@]}")
@@ -155,6 +202,53 @@ load_purge_config() {
# Initialize paths on script load.
load_purge_config
+format_purge_target_path() {
+ local path="$1"
+ echo "${path/#$HOME/~}"
+}
+
+compact_purge_menu_path() {
+ local path="$1"
+ local max_width="${2:-0}"
+
+ if ! [[ "$max_width" =~ ^[0-9]+$ ]] || [[ "$max_width" -lt 4 ]]; then
+ max_width=4
+ fi
+
+ local path_width
+ path_width=$(get_display_width "$path")
+ if [[ $path_width -le $max_width ]]; then
+ echo "$path"
+ return
+ fi
+
+ local tail=""
+ local remainder="$path"
+ local prefix_width=3
+
+ while [[ "$remainder" == */* ]]; do
+ local segment="/${remainder##*/}"
+ remainder="${remainder%/*}"
+
+ local candidate="${segment}${tail}"
+ local candidate_width
+ candidate_width=$(get_display_width "$candidate")
+ if [[ $((candidate_width + prefix_width)) -le $max_width ]]; then
+ tail="$candidate"
+ else
+ break
+ fi
+ done
+
+ if [[ -n "$tail" ]]; then
+ echo "...${tail}"
+ return
+ fi
+
+ local suffix_len=$((max_width - 3))
+ echo "...${path: -$suffix_len}"
+}
+
# Args: $1 - directory path
# Determine whether a directory is a project root.
# This is used to safely allow cleaning direct-child artifacts when
@@ -199,7 +293,8 @@ is_safe_project_artifact() {
# Must not be a direct child of the search root.
local relative_path="${path#"$search_path"/}"
- local depth=$(echo "$relative_path" | LC_ALL=C tr -cd '/' | wc -c)
+ local _rel_stripped="${relative_path//\//}"
+ local depth=$((${#relative_path} - ${#_rel_stripped}))
if [[ $depth -lt 1 ]]; then
# Allow direct-child artifacts only when the search path is itself
# a project root (single-project mode).
@@ -343,7 +438,7 @@ scan_purge_targets() {
if [[ -n "$item" ]] && is_safe_project_artifact "$item" "$search_path"; then
echo "$item"
# Update scanning path to show current project directory
- local project_dir=$(dirname "$item")
+ local project_dir="${item%/*}"
echo "$project_dir" > "$stats_dir/purge_scanning" 2> /dev/null || true
fi
done < "$input_file" | filter_nested_artifacts | filter_protected_artifacts > "$output_file"
@@ -360,15 +455,11 @@ scan_purge_targets() {
debug_log "MO_USE_FIND=1: Forcing find instead of fd"
use_find=true
elif command -v fd > /dev/null 2>&1; then
- # Escape regex special characters in target names for fd patterns
- local escaped_targets=()
- for target in "${PURGE_TARGETS[@]}"; do
- escaped_targets+=("^$(printf '%s' "$target" | sed -e 's/[][(){}.^$*+?|\\]/\\&/g')\$")
- done
- local pattern="($(
- IFS='|'
- echo "${escaped_targets[*]}"
- ))"
+ # Escape regex special characters in target names for fd patterns (single sed pass)
+ local _escaped_lines
+ _escaped_lines=$(printf '%s\n' "${PURGE_TARGETS[@]}" | sed -e 's/[][(){}.^$*+?|\\]/\\&/g')
+ local pattern
+ pattern="($(printf '%s\n' "$_escaped_lines" | sed -e 's/^/^/' -e 's/$/$/' | paste -sd '|' -))"
local fd_args=(
"--absolute-path"
"--hidden"
@@ -389,8 +480,12 @@ scan_purge_targets() {
# Check if fd actually found anything - if empty, fallback to find
if [[ -s "$output_file.raw" ]]; then
debug_log "Using fd for scanning (found results)"
- use_find=false
process_scan_results "$output_file.raw"
+ if [[ -s "$output_file" ]]; then
+ use_find=false
+ else
+ debug_log "fd results became empty after filtering, falling back to find"
+ fi
else
debug_log "fd returned empty results, falling back to find"
rm -f "$output_file.raw"
@@ -460,14 +555,16 @@ filter_protected_artifacts() {
# Check if a path was modified recently (safety check).
is_recently_modified() {
local path="$1"
+ local current_time="${2:-}"
local age_days=$MIN_AGE_DAYS
if [[ ! -e "$path" ]]; then
return 1
fi
local mod_time
mod_time=$(get_file_mtime "$path")
- local current_time
- current_time=$(get_epoch_seconds)
+ if [[ -z "$current_time" || ! "$current_time" =~ ^[0-9]+$ ]]; then
+ current_time=$(get_epoch_seconds)
+ fi
local age_seconds=$((current_time - mod_time))
local age_in_days=$((age_seconds / 86400))
if [[ $age_in_days -lt $age_days ]]; then
@@ -542,7 +639,7 @@ select_purge_categories() {
term_height=24
fi
fi
- local reserved=6
+ local reserved=8
local available=$((term_height - reserved))
if [[ $available -lt 3 ]]; then
echo 3
@@ -659,6 +756,7 @@ select_purge_categories() {
printf "%s\n" "$clear_line"
IFS=',' read -r -a recent_flags <<< "${PURGE_RECENT_CATEGORIES:-}"
+ IFS=',' read -r -a age_labels <<< "${PURGE_AGE_LABELS:-}"
# Calculate visible range
local end_index=$((top_index + visible_count))
@@ -668,7 +766,8 @@ select_purge_categories() {
local checkbox="$ICON_EMPTY"
[[ ${selected[i]} == true ]] && checkbox="$ICON_SOLID"
local recent_marker=""
- [[ ${recent_flags[i]:-false} == "true" ]] && recent_marker=" ${GRAY}| Recent${NC}"
+ local _age="${age_labels[i]:-}"
+ [[ -n "$_age" ]] && recent_marker=" ${GRAY}| ${_age}${NC}"
local rel_pos=$((i - top_index))
if [[ $rel_pos -eq $cursor_pos ]]; then
printf "%s${CYAN}${ICON_ARROW} %s %s%s${NC}\n" "$clear_line" "$checkbox" "${categories[i]}" "$recent_marker"
@@ -680,6 +779,17 @@ select_purge_categories() {
# Keep one blank line between the list and footer tips.
printf "%s\n" "$clear_line"
+ local current_index=$((top_index + cursor_pos))
+ local current_full_path=""
+ local paths_len="${#PURGE_CATEGORY_FULL_PATHS_ARRAY[@]}"
+ if [[ "$paths_len" -gt 0 && "$current_index" -lt "$paths_len" ]]; then
+ current_full_path="${PURGE_CATEGORY_FULL_PATHS_ARRAY[current_index]}"
+ fi
+ if [[ -n "$current_full_path" ]]; then
+ printf "%s${GRAY}Full path:${NC} %s\n" "$clear_line" "$current_full_path"
+ printf "%s\n" "$clear_line"
+ fi
+
# Adaptive footer hints — mirrors menu_paginated.sh pattern
local _term_w
_term_w=$(tput cols 2> /dev/null || echo 80)
@@ -821,6 +931,7 @@ confirm_purge_cleanup() {
local item_count="${1:-0}"
local total_size_kb="${2:-0}"
local unknown_count="${3:-0}"
+ local -a selected_paths=("${@:4}")
[[ "$item_count" =~ ^[0-9]+$ ]] || item_count=0
[[ "$total_size_kb" =~ ^[0-9]+$ ]] || total_size_kb=0
@@ -839,6 +950,15 @@ confirm_purge_cleanup() {
unknown_hint=", ${unknown_count} ${unknown_text}"
fi
+ if [[ ${#selected_paths[@]} -gt 0 ]]; then
+ echo ""
+ echo -e "${GRAY}Selected paths:${NC}"
+ local selected_path=""
+ for selected_path in "${selected_paths[@]}"; do
+ echo " $selected_path"
+ done
+ 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=""
@@ -917,12 +1037,24 @@ clean_project_artifacts() {
sleep 0.2
fi
- # Collect all results
+ # Collect all results and deduplicate (overlapping search roots on
+ # case-insensitive filesystems can produce identical canonical paths).
+ # Uses a simple linear search instead of associative arrays for bash 3 compat.
for scan_output in "${scan_temps[@]+"${scan_temps[@]}"}"; do
if [[ -f "$scan_output" ]]; then
while IFS= read -r item; do
if [[ -n "$item" ]]; then
- all_found_items+=("$item")
+ local _already_seen=false
+ local _existing
+ for _existing in "${all_found_items[@]+"${all_found_items[@]}"}"; do
+ if [[ "$_existing" == "$item" ]]; then
+ _already_seen=true
+ break
+ fi
+ done
+ if [[ "$_already_seen" == "false" ]]; then
+ all_found_items+=("$item")
+ fi
fi
done < "$scan_output"
rm -f "$scan_output"
@@ -941,8 +1073,10 @@ clean_project_artifacts() {
return 2 # Special code: nothing to clean
fi
# Mark recently modified items (for default selection state)
+ local _now_epoch
+ _now_epoch=$(get_epoch_seconds)
for item in "${all_found_items[@]}"; do
- if is_recently_modified "$item"; then
+ if is_recently_modified "$item" "$_now_epoch"; then
recently_modified+=("$item")
fi
# Add all items to safe_to_clean, let user choose
@@ -952,11 +1086,38 @@ clean_project_artifacts() {
if [[ -t 1 ]]; then
start_inline_spinner "Calculating sizes..."
fi
+
+ # Pre-compute sizes in parallel with sliding-window throttle.
+ # Unbounded parallelism (all N at once) causes I/O contention on cold
+ # filesystem cache, making du timeout and display "unknown" sizes.
+ local -a _size_tmpfiles=()
+ local -a _size_pids=()
+ local _max_size_jobs
+ _max_size_jobs=$(get_optimal_parallel_jobs io)
+
+ for _sz_item in "${safe_to_clean[@]}"; do
+ local _stmp
+ _stmp=$(mktemp)
+ register_temp_file "$_stmp"
+ _size_tmpfiles+=("$_stmp")
+ (get_dir_size_kb "$_sz_item" > "$_stmp" 2> /dev/null) &
+ _size_pids+=($!)
+
+ if [[ ${#_size_pids[@]} -ge $_max_size_jobs ]]; then
+ wait "${_size_pids[0]}" 2> /dev/null || true
+ _size_pids=("${_size_pids[@]:1}")
+ fi
+ done
+ for _spid in "${_size_pids[@]+"${_size_pids[@]}"}"; do
+ wait "$_spid" 2> /dev/null || true
+ done
+
local -a menu_options=()
local -a item_paths=()
local -a item_sizes=()
local -a item_size_unknown_flags=()
local -a item_recent_flags=()
+ local -a item_age_labels=()
# Helper to get project name from path
# For ~/www/pake/src-tauri/target -> returns "pake"
# For ~/work/code/MyProject/node_modules -> returns "MyProject"
@@ -964,8 +1125,8 @@ clean_project_artifacts() {
get_project_name() {
local path="$1"
- local current_dir
- current_dir=$(dirname "$path")
+ local current_dir="${path%/*}"
+ [[ -z "$current_dir" ]] && current_dir="/"
local monorepo_root=""
local project_root=""
@@ -998,20 +1159,23 @@ clean_project_artifacts() {
# If we found project but still checking for monorepo above
# (only stop if we're beyond reasonable depth)
- local depth=$(echo "${current_dir#"$HOME"}" | LC_ALL=C tr -cd '/' | wc -c | tr -d ' ')
+ local _rel="${current_dir#"$HOME"}"
+ local _stripped="${_rel//\//}"
+ local depth=$((${#_rel} - ${#_stripped}))
if [[ -n "$project_root" && $depth -lt 2 ]]; then
break
fi
- current_dir=$(dirname "$current_dir")
+ local _parent="${current_dir%/*}"
+ current_dir="${_parent:-/}"
done
# Determine result: monorepo > project > fallback
local result=""
if [[ -n "$monorepo_root" ]]; then
- result=$(basename "$monorepo_root")
+ result="${monorepo_root##*/}"
elif [[ -n "$project_root" ]]; then
- result=$(basename "$project_root")
+ result="${project_root##*/}"
else
# Fallback: first directory under search root
local search_roots=()
@@ -1024,14 +1188,16 @@ clean_project_artifacts() {
root="${root%/}"
if [[ -n "$root" && "$path" == "$root/"* ]]; then
local relative_path="${path#"$root"/}"
- result=$(echo "$relative_path" | cut -d'/' -f1)
+ result="${relative_path%%/*}"
break
fi
done
# Final fallback: use grandparent directory
if [[ -z "$result" ]]; then
- result=$(dirname "$(dirname "$path")" | xargs basename)
+ local _gp="${path%/*}"
+ _gp="${_gp%/*}"
+ result="${_gp##*/}"
fi
fi
@@ -1045,8 +1211,8 @@ clean_project_artifacts() {
get_project_path() {
local path="$1"
- local current_dir
- current_dir=$(dirname "$path")
+ local current_dir="${path%/*}"
+ [[ -z "$current_dir" ]] && current_dir="/"
local monorepo_root=""
local project_root=""
@@ -1078,12 +1244,15 @@ clean_project_artifacts() {
fi
# If we found project but still checking for monorepo above
- local depth=$(echo "${current_dir#"$HOME"}" | LC_ALL=C tr -cd '/' | wc -c | tr -d ' ')
+ local _rel="${current_dir#"$HOME"}"
+ local _stripped="${_rel//\//}"
+ local depth=$((${#_rel} - ${#_stripped}))
if [[ -n "$project_root" && $depth -lt 2 ]]; then
break
fi
- current_dir=$(dirname "$current_dir")
+ local _parent="${current_dir%/*}"
+ current_dir="${_parent:-/}"
done
# Determine result: monorepo > project > fallback
@@ -1094,7 +1263,7 @@ clean_project_artifacts() {
result="$project_root"
else
# Fallback: use parent directory of artifact
- result=$(dirname "$path")
+ result="${path%/*}"
fi
# Convert to ~ format for cleaner display
@@ -1104,23 +1273,48 @@ clean_project_artifacts() {
# Helper to get artifact display name
# For duplicate artifact names within same project, include parent directory for context
+ # Uses pre-computed _cached_basenames and _cached_project_names arrays when available.
get_artifact_display_name() {
local path="$1"
- local artifact_name=$(basename "$path")
- local project_name=$(get_project_name "$path")
- local parent_name=$(basename "$(dirname "$path")")
+ local artifact_name="${path##*/}"
+ local parent_name="${path%/*}"
+ parent_name="${parent_name##*/}"
+
+ local project_name
+ if [[ -n "${_cached_project_names[*]+x}" ]]; then
+ # Fast path: use pre-computed cache
+ local _idx
+ project_name=""
+ for _idx in "${!safe_to_clean[@]}"; do
+ if [[ "${safe_to_clean[$_idx]}" == "$path" ]]; then
+ project_name="${_cached_project_names[$_idx]}"
+ break
+ fi
+ done
+ else
+ project_name=$(get_project_name "$path")
+ fi
# Check if there are other items with same artifact name AND same project
local has_duplicate=false
- for other_item in "${safe_to_clean[@]}"; do
- if [[ "$other_item" != "$path" && "$(basename "$other_item")" == "$artifact_name" ]]; then
- # Same artifact name, check if same project
- if [[ "$(get_project_name "$other_item")" == "$project_name" ]]; then
+ if [[ -n "${_cached_basenames[*]+x}" ]]; then
+ local _idx
+ for _idx in "${!safe_to_clean[@]}"; do
+ if [[ "${safe_to_clean[$_idx]}" != "$path" && "${_cached_basenames[$_idx]}" == "$artifact_name" && "${_cached_project_names[$_idx]}" == "$project_name" ]]; then
has_duplicate=true
break
fi
- fi
- done
+ done
+ else
+ for other_item in "${safe_to_clean[@]}"; do
+ if [[ "$other_item" != "$path" && "${other_item##*/}" == "$artifact_name" ]]; then
+ if [[ "$(get_project_name "$other_item")" == "$project_name" ]]; then
+ has_duplicate=true
+ break
+ fi
+ fi
+ done
+ fi
# If duplicate exists in same project and parent is not the project itself, show parent/artifact
if [[ "$has_duplicate" == "true" && "$parent_name" != "$project_name" && "$parent_name" != "." && "$parent_name" != "/" ]]; then
@@ -1157,31 +1351,56 @@ clean_project_artifacts() {
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
- truncated_path=$(truncate_by_display_width "$project_path" "$available_width")
+ truncated_path=$(compact_purge_menu_path "$project_path" "$available_width")
local current_width
current_width=$(get_display_width "$truncated_path")
- local char_count=${#truncated_path}
+
+ # Get byte count for printf width calculation
+ local old_lc="${LC_ALL:-}"
+ export LC_ALL=C
+ local byte_count=${#truncated_path}
+ if [[ -n "$old_lc" ]]; then
+ export LC_ALL="$old_lc"
+ else
+ unset LC_ALL
+ fi
+
local padding=$((available_width - current_width))
- local printf_width=$((char_count + padding))
+ local printf_width=$((byte_count + padding))
# Format: "project_path size | artifact_type"
printf "%-*s %9s | %-*s" "$printf_width" "$truncated_path" "$size_str" "$artifact_col" "$artifact_type"
}
+ # Pre-compute basenames and project names once so get_artifact_display_name()
+ # can avoid repeated filesystem traversals during the O(N^2) duplicate check.
+ local -a _cached_basenames=()
+ local -a _cached_project_names=()
+ local -a _cached_project_paths=()
+ local _pre_idx
+ for _pre_idx in "${!safe_to_clean[@]}"; do
+ _cached_basenames[_pre_idx]="${safe_to_clean[$_pre_idx]##*/}"
+ _cached_project_names[_pre_idx]=$(get_project_name "${safe_to_clean[$_pre_idx]}")
+ _cached_project_paths[_pre_idx]=$(get_project_path "${safe_to_clean[$_pre_idx]}")
+ done
+
# Build menu options - one line per artifact
- # Pass 1: collect data into parallel arrays (needed for pre-scan of widths)
+ # Pass 1: collect data into parallel arrays (needed for pre-scan of widths).
+ # Sizes are read from pre-computed results (parallel du calls launched above).
local -a raw_project_paths=()
local -a raw_artifact_types=()
+ local -a item_display_paths=()
+ local _sz_idx=0
for item in "${safe_to_clean[@]}"; do
- local project_path
- project_path=$(get_project_path "$item")
+ local project_path="${_cached_project_paths[$_sz_idx]}"
local artifact_type
artifact_type=$(get_artifact_display_name "$item")
local size_raw
- size_raw=$(get_dir_size_kb "$item")
+ size_raw=$(cat "${_size_tmpfiles[_sz_idx]}" 2> /dev/null || echo "0")
+ rm -f "${_size_tmpfiles[_sz_idx]}" 2> /dev/null || true
+ _sz_idx=$((_sz_idx + 1))
local size_kb=0
local size_human=""
local size_unknown=false
@@ -1211,9 +1430,24 @@ clean_project_artifacts() {
raw_project_paths+=("$project_path")
raw_artifact_types+=("$artifact_type")
item_paths+=("$item")
+ item_display_paths+=("$(format_purge_target_path "$item")")
item_sizes+=("$size_kb")
item_size_unknown_flags+=("$size_unknown")
item_recent_flags+=("$is_recent")
+ # Build human-readable age label (bash 3.2 compatible — no assoc arrays).
+ local _mod_time _age_secs _age_d
+ _mod_time=$(get_file_mtime "$item" 2> /dev/null || echo "0")
+ _age_secs=$((_now_epoch - _mod_time))
+ _age_d=$((_age_secs / 86400))
+ if [[ $_age_d -lt 1 ]]; then
+ item_age_labels+=("<1d")
+ elif [[ $_age_d -lt 30 ]]; then
+ item_age_labels+=("${_age_d}d")
+ elif [[ $_age_d -lt 365 ]]; then
+ item_age_labels+=("$((_age_d / 30))mo")
+ else
+ item_age_labels+=("$((_age_d / 365))y")
+ fi
done
# Pre-scan: find max path and artifact display widths (mirrors app_selector.sh approach)
@@ -1236,7 +1470,7 @@ clean_project_artifacts() {
[[ $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
+ # Exact overhead: prefix(4) + space(1) + size(9) + " | "(3) + artifact_col + " | 11mo"(7) = artifact_col + 24
local fixed_overhead=$((max_artifact_width + 26))
local available_for_path=$((terminal_width - fixed_overhead))
@@ -1251,7 +1485,6 @@ clean_project_artifacts() {
[[ $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
@@ -1291,6 +1524,8 @@ clean_project_artifacts() {
local -a sorted_item_sizes=()
local -a sorted_item_size_unknown_flags=()
local -a sorted_item_recent_flags=()
+ local -a sorted_item_display_paths=()
+ local -a sorted_item_age_labels=()
for idx in "${sorted_indices[@]}"; do
sorted_menu_options+=("${menu_options[idx]}")
@@ -1298,6 +1533,8 @@ clean_project_artifacts() {
sorted_item_sizes+=("${item_sizes[idx]}")
sorted_item_size_unknown_flags+=("${item_size_unknown_flags[idx]}")
sorted_item_recent_flags+=("${item_recent_flags[idx]}")
+ sorted_item_display_paths+=("${item_display_paths[idx]}")
+ sorted_item_age_labels+=("${item_age_labels[idx]}")
done
# Replace original arrays with sorted versions
@@ -1306,6 +1543,8 @@ clean_project_artifacts() {
item_sizes=("${sorted_item_sizes[@]}")
item_size_unknown_flags=("${sorted_item_size_unknown_flags[@]}")
item_recent_flags=("${sorted_item_recent_flags[@]}")
+ item_display_paths=("${sorted_item_display_paths[@]}")
+ item_age_labels=("${sorted_item_age_labels[@]}")
fi
if [[ -t 1 ]]; then
stop_inline_spinner
@@ -1327,11 +1566,17 @@ clean_project_artifacts() {
IFS=,
echo "${item_recent_flags[*]-}"
)
+ export PURGE_AGE_LABELS=$(
+ IFS=,
+ echo "${item_age_labels[*]-}"
+ )
# Interactive selection (only if terminal is available)
PURGE_SELECTION_RESULT=""
+ PURGE_CATEGORY_FULL_PATHS_ARRAY=("${item_display_paths[@]}")
if [[ -t 0 ]]; then
if ! select_purge_categories "${menu_options[@]}"; then
- unset PURGE_CATEGORY_SIZES PURGE_RECENT_CATEGORIES PURGE_SELECTION_RESULT
+ PURGE_CATEGORY_FULL_PATHS_ARRAY=()
+ unset PURGE_CATEGORY_SIZES PURGE_RECENT_CATEGORIES PURGE_AGE_LABELS PURGE_SELECTION_RESULT
return 1
fi
else
@@ -1347,12 +1592,14 @@ clean_project_artifacts() {
echo ""
echo -e "${GRAY}No items selected${NC}"
printf '\n'
- unset PURGE_CATEGORY_SIZES PURGE_RECENT_CATEGORIES PURGE_SELECTION_RESULT
+ PURGE_CATEGORY_FULL_PATHS_ARRAY=()
+ unset PURGE_CATEGORY_SIZES PURGE_RECENT_CATEGORIES PURGE_AGE_LABELS 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
+ local -a selected_display_paths=()
for idx in "${selected_indices[@]}"; do
local selected_size_kb="${item_sizes[idx]:-0}"
[[ "$selected_size_kb" =~ ^[0-9]+$ ]] || selected_size_kb=0
@@ -1360,16 +1607,19 @@ clean_project_artifacts() {
if [[ "${item_size_unknown_flags[idx]:-false}" == "true" ]]; then
selected_unknown_count=$((selected_unknown_count + 1))
fi
+ selected_display_paths+=("${item_display_paths[idx]}")
done
if [[ -t 0 ]]; then
- if ! confirm_purge_cleanup "${#selected_indices[@]}" "$selected_total_kb" "$selected_unknown_count"; then
+ if ! confirm_purge_cleanup "${#selected_indices[@]}" "$selected_total_kb" "$selected_unknown_count" "${selected_display_paths[@]}"; then
echo -e "${GRAY}Purge cancelled${NC}"
printf '\n'
- unset PURGE_CATEGORY_SIZES PURGE_RECENT_CATEGORIES PURGE_SELECTION_RESULT
+ PURGE_CATEGORY_FULL_PATHS_ARRAY=()
+ unset PURGE_CATEGORY_SIZES PURGE_RECENT_CATEGORIES PURGE_AGE_LABELS PURGE_SELECTION_RESULT
return 1
fi
fi
+ PURGE_CATEGORY_FULL_PATHS_ARRAY=()
# Clean selected items
echo ""
@@ -1378,8 +1628,8 @@ clean_project_artifacts() {
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")
- local project_path=$(get_project_path "$item_path")
+ local display_item_path
+ display_item_path=$(format_purge_target_path "$item_path")
local size_kb="${item_sizes[idx]}"
local size_unknown="${item_size_unknown_flags[idx]:-false}"
local size_human
@@ -1393,7 +1643,7 @@ clean_project_artifacts() {
continue
fi
if [[ -t 1 ]]; then
- start_inline_spinner "Cleaning $project_path/$artifact_type..."
+ start_inline_spinner "Cleaning $display_item_path..."
fi
local removal_recorded=false
if [[ -e "$item_path" ]]; then
@@ -1411,14 +1661,14 @@ clean_project_artifacts() {
stop_inline_spinner
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}"
+ echo -e "${GREEN}${ICON_SUCCESS}${NC} [DRY RUN] $display_item_path${NC}, ${GREEN}$size_human${NC}"
else
- echo -e "${GREEN}${ICON_SUCCESS}${NC} $project_path, $artifact_type${NC}, ${GREEN}$size_human${NC}"
+ echo -e "${GREEN}${ICON_SUCCESS}${NC} $display_item_path${NC}, ${GREEN}$size_human${NC}"
fi
fi
fi
done
# Update count
echo "$cleaned_count" > "$stats_dir/purge_count"
- unset PURGE_CATEGORY_SIZES PURGE_RECENT_CATEGORIES PURGE_SELECTION_RESULT
+ unset PURGE_CATEGORY_SIZES PURGE_RECENT_CATEGORIES PURGE_AGE_LABELS PURGE_SELECTION_RESULT
}
diff --git a/Resources/mole/lib/clean/purge_shared.sh b/Resources/mole/lib/clean/purge_shared.sh
index 91ad19f..2e96294 100644
--- a/Resources/mole/lib/clean/purge_shared.sh
+++ b/Resources/mole/lib/clean/purge_shared.sh
@@ -42,6 +42,7 @@ readonly MOLE_PURGE_TARGETS=(
"Pods" # CocoaPods
".cxx" # React Native Android NDK build cache
".expo" # Expo
+ ".build" # Swift Package Manager
)
readonly MOLE_PURGE_DEFAULT_SEARCH_PATHS=(
@@ -73,6 +74,7 @@ readonly MOLE_PURGE_PROJECT_INDICATORS=(
"Gemfile"
"composer.json"
"pubspec.yaml"
+ "Package.swift" # Swift Package Manager
"Makefile"
"build.zig"
"build.zig.zon"
@@ -122,6 +124,19 @@ mole_purge_quick_hint_target_names() {
done
}
+# Resolve a directory path to its canonical filesystem casing.
+# On case-insensitive macOS (APFS), ~/Code and ~/code point to the same
+# directory but with different display names. This function returns the
+# real (on-disk) path so that string comparisons work correctly for dedup.
+mole_purge_resolve_path_case() {
+ local path="$1"
+ if [[ -d "$path" ]]; then
+ (cd "$path" 2> /dev/null && pwd -P) || printf '%s\n' "$path"
+ else
+ printf '%s\n' "$path"
+ fi
+}
+
mole_purge_read_paths_config() {
local config_file="${1:-$HOME/.config/mole/purge_paths}"
[[ -f "$config_file" ]] || return 0
@@ -132,6 +147,7 @@ mole_purge_read_paths_config() {
line="${line%"${line##*[![:space:]]}"}"
[[ -z "$line" || "$line" =~ ^# ]] && continue
line="${line/#\~/$HOME}"
+ line=$(mole_purge_resolve_path_case "$line")
printf '%s\n' "$line"
done < "$config_file"
}
diff --git a/Resources/mole/lib/clean/system.sh b/Resources/mole/lib/clean/system.sh
index 817964e..a1183d1 100644
--- a/Resources/mole/lib/clean/system.sh
+++ b/Resources/mole/lib/clean/system.sh
@@ -116,8 +116,11 @@ clean_deep_system() {
fi
fi
# Clean macOS installer apps (e.g., "Install macOS Sequoia.app")
- # Only remove installers older than 14 days and not currently running
+ # Only remove installers older than 14 days, not currently running,
+ # and not matching the currently installed macOS version (recovery safety).
local installer_cleaned=0
+ local current_macos_version=""
+ current_macos_version=$(sw_vers -productVersion 2> /dev/null | cut -d. -f1 || true)
for installer_app in /Applications/Install\ macOS*.app; do
[[ -d "$installer_app" ]] || continue
local app_name
@@ -127,6 +130,19 @@ clean_deep_system() {
debug_log "Skipping $app_name: currently running"
continue
fi
+ # Skip if this installer matches the current macOS major version.
+ # Users may need it for recovery or reinstallation.
+ if [[ -n "$current_macos_version" ]]; then
+ local installer_plist="$installer_app/Contents/Info.plist"
+ if [[ -f "$installer_plist" ]]; then
+ local installer_version=""
+ installer_version=$(/usr/libexec/PlistBuddy -c "Print :DTPlatformVersion" "$installer_plist" 2> /dev/null | cut -d. -f1 || true)
+ if [[ -n "$installer_version" && "$installer_version" == *"$current_macos_version"* ]]; then
+ debug_log "Keeping $app_name: matches current macOS version ($current_macos_version)"
+ continue
+ fi
+ fi
+ fi
# Check age (same 14-day threshold as /macOS Install Data)
local mtime
mtime=$(get_file_mtime "$installer_app")
@@ -155,7 +171,7 @@ clean_deep_system() {
if safe_sudo_remove "$cache_dir"; then
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)
+ done < <(run_with_timeout 5 command find /private/var/folders -maxdepth 5 -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"
@@ -309,7 +325,9 @@ clean_time_machine_failed_backups() {
continue
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}"
+ local line_color
+ line_color=$(cleanup_result_color_kb "$size_kb")
+ echo -e " ${line_color}${ICON_SUCCESS}${NC} Incomplete backup: $backup_name${NC}, ${line_color}$size_human${NC}"
tm_cleaned=$((tm_cleaned + 1))
files_cleaned=$((files_cleaned + 1))
total_size_cleaned=$((total_size_cleaned + size_kb))
@@ -360,7 +378,9 @@ clean_time_machine_failed_backups() {
continue
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}"
+ local line_color
+ line_color=$(cleanup_result_color_kb "$size_kb")
+ echo -e " ${line_color}${ICON_SUCCESS}${NC} Incomplete APFS backup in $bundle_name: $backup_name${NC}, ${line_color}$size_human${NC}"
tm_cleaned=$((tm_cleaned + 1))
files_cleaned=$((files_cleaned + 1))
total_size_cleaned=$((total_size_cleaned + size_kb))
diff --git a/Resources/mole/lib/clean/user.sh b/Resources/mole/lib/clean/user.sh
index 60e2af9..8b0d2b1 100644
--- a/Resources/mole/lib/clean/user.sh
+++ b/Resources/mole/lib/clean/user.sh
@@ -11,21 +11,35 @@ clean_user_essentials() {
if ! is_path_whitelisted "$HOME/.Trash"; then
local trash_count
local trash_count_status=0
- trash_count=$(run_with_timeout 3 osascript -e 'tell application "Finder" to count items in trash' 2> /dev/null) || trash_count_status=$?
+ # Skip AppleScript during tests to avoid permission dialogs
+ if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then
+ trash_count=$(command find "$HOME/.Trash" -mindepth 1 -maxdepth 1 -print0 2> /dev/null |
+ tr -dc '\0' | wc -c | tr -d ' ' || echo "0")
+ else
+ trash_count=$(run_with_timeout 3 osascript -e 'tell application "Finder" to count items in trash' 2> /dev/null) || trash_count_status=$?
+ fi
if [[ $trash_count_status -eq 124 ]]; then
debug_log "Finder trash count timed out, using direct .Trash scan"
- trash_count=$(command find "$HOME/.Trash" -mindepth 1 -maxdepth 1 -exec printf '.' ';' 2> /dev/null |
- wc -c | awk '{print $1}' || echo "0")
+ trash_count=$(command find "$HOME/.Trash" -mindepth 1 -maxdepth 1 -print0 2> /dev/null |
+ tr -dc '\0' | wc -c | tr -d ' ' || echo "0")
fi
[[ "$trash_count" =~ ^[0-9]+$ ]] || trash_count="0"
if [[ "$DRY_RUN" == "true" ]]; then
[[ $trash_count -gt 0 ]] && echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Trash · would empty, $trash_count items" || echo -e " ${GREEN}${ICON_SUCCESS}${NC} Trash · already empty"
elif [[ $trash_count -gt 0 ]]; then
- if run_with_timeout 5 osascript -e 'tell application "Finder" to empty trash' > /dev/null 2>&1; then
- echo -e " ${GREEN}${ICON_SUCCESS}${NC} Trash · emptied, $trash_count items"
- note_activity
+ local emptied_via_finder=false
+ # Skip AppleScript during tests to avoid permission dialogs
+ if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then
+ debug_log "Skipping Finder AppleScript in test mode"
else
+ if run_with_timeout 5 osascript -e 'tell application "Finder" to empty trash' > /dev/null 2>&1; then
+ emptied_via_finder=true
+ echo -e " ${GREEN}${ICON_SUCCESS}${NC} Trash · emptied, $trash_count items"
+ note_activity
+ fi
+ fi
+ if [[ "$emptied_via_finder" != "true" ]]; then
debug_log "Finder trash empty failed or timed out, falling back to direct deletion"
local cleaned_count=0
while IFS= read -r -d '' item; do
@@ -71,6 +85,29 @@ _clean_recent_items() {
safe_clean ~/Library/Preferences/com.apple.recentitems.plist "Recent items preferences" || true
}
+# Internal: Clean incomplete browser downloads, skipping files currently open.
+_clean_incomplete_downloads() {
+ local -a patterns=(
+ "$HOME/Downloads/*.download"
+ "$HOME/Downloads/*.crdownload"
+ "$HOME/Downloads/*.part"
+ )
+ local labels=("Safari incomplete downloads" "Chrome incomplete downloads" "Partial incomplete downloads")
+ local i=0
+ for pattern in "${patterns[@]}"; do
+ local label="${labels[$i]}"
+ i=$((i + 1))
+ for f in $pattern; do
+ [[ -e "$f" ]] || continue
+ if lsof -F n -- "$f" > /dev/null 2>&1; then
+ echo -e " ${GRAY}${ICON_WARNING}${NC} Skipping active download: $(basename "$f")"
+ continue
+ fi
+ safe_clean "$f" "$label" || true
+ done
+ done
+}
+
# Internal: Clean old mail downloads.
_clean_mail_downloads() {
local mail_age_days=${MOLE_MAIL_AGE_DAYS:-}
@@ -120,7 +157,7 @@ _clean_mail_downloads() {
if [[ $count -gt 0 ]]; then
local cleaned_mb
cleaned_mb=$(echo "$cleaned_kb" | awk '{printf "%.1f", $1/1024}' || echo "0.0")
- echo " ${GREEN}${ICON_SUCCESS}${NC} Cleaned $count mail attachments, about ${cleaned_mb}MB"
+ echo -e " ${GREEN}${ICON_SUCCESS}${NC} Cleaned $count mail attachments older than ${mail_age_days}d, about ${cleaned_mb}MB"
note_activity
fi
}
@@ -156,6 +193,13 @@ clean_chrome_old_versions() {
current_version="${current_version##*/}"
[[ -n "$current_version" ]] || continue
+ # Verify the Current symlink target exists. If broken, skip to avoid
+ # accidentally deleting the active browser version.
+ if [[ ! -d "$versions_dir/$current_version" ]]; then
+ echo -e " ${GRAY}${ICON_WARNING}${NC} Chrome Current symlink is broken · skipping version cleanup"
+ continue
+ fi
+
local -a old_versions=()
local dir name
for dir in "$versions_dir"/*; do
@@ -196,7 +240,9 @@ clean_chrome_old_versions() {
if [[ "$DRY_RUN" == "true" ]]; then
echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Chrome old versions${NC}, ${YELLOW}${cleaned_count} dirs, $size_human dry${NC}"
else
- echo -e " ${GREEN}${ICON_SUCCESS}${NC} Chrome old versions${NC}, ${GREEN}${cleaned_count} dirs, $size_human${NC}"
+ local line_color
+ line_color=$(cleanup_result_color_kb "$total_size")
+ echo -e " ${line_color}${ICON_SUCCESS}${NC} Chrome old versions${NC}, ${line_color}${cleaned_count} dirs, $size_human${NC}"
fi
files_cleaned=$((files_cleaned + cleaned_count))
total_size_cleaned=$((total_size_cleaned + total_size))
@@ -242,6 +288,13 @@ clean_edge_old_versions() {
current_version="${current_version##*/}"
[[ -n "$current_version" ]] || continue
+ # Verify the Current symlink target exists. If broken, skip to avoid
+ # accidentally deleting the active browser version.
+ if [[ ! -d "$versions_dir/$current_version" ]]; then
+ echo -e " ${GRAY}${ICON_WARNING}${NC} Edge Current symlink is broken · skipping version cleanup"
+ continue
+ fi
+
local -a old_versions=()
local dir name
for dir in "$versions_dir"/*; do
@@ -282,7 +335,9 @@ clean_edge_old_versions() {
if [[ "$DRY_RUN" == "true" ]]; then
echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Edge old versions${NC}, ${YELLOW}${cleaned_count} dirs, $size_human dry${NC}"
else
- echo -e " ${GREEN}${ICON_SUCCESS}${NC} Edge old versions${NC}, ${GREEN}${cleaned_count} dirs, $size_human${NC}"
+ local line_color
+ line_color=$(cleanup_result_color_kb "$total_size")
+ echo -e " ${line_color}${ICON_SUCCESS}${NC} Edge old versions${NC}, ${line_color}${cleaned_count} dirs, $size_human${NC}"
fi
files_cleaned=$((files_cleaned + cleaned_count))
total_size_cleaned=$((total_size_cleaned + total_size))
@@ -344,7 +399,96 @@ clean_edge_updater_old_versions() {
if [[ "$DRY_RUN" == "true" ]]; then
echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Edge updater old versions${NC}, ${YELLOW}${cleaned_count} dirs, $size_human dry${NC}"
else
- echo -e " ${GREEN}${ICON_SUCCESS}${NC} Edge updater old versions${NC}, ${GREEN}${cleaned_count} dirs, $size_human${NC}"
+ local line_color
+ line_color=$(cleanup_result_color_kb "$total_size")
+ echo -e " ${line_color}${ICON_SUCCESS}${NC} Edge updater old versions${NC}, ${line_color}${cleaned_count} dirs, $size_human${NC}"
+ fi
+ files_cleaned=$((files_cleaned + cleaned_count))
+ total_size_cleaned=$((total_size_cleaned + total_size))
+ total_items=$((total_items + 1))
+ note_activity
+ fi
+}
+
+# Remove old Brave Browser versions while keeping Current.
+clean_brave_old_versions() {
+ local -a app_paths=(
+ "/Applications/Brave Browser.app"
+ "$HOME/Applications/Brave Browser.app"
+ )
+
+ # Match the exact Brave process name to avoid false positives
+ if pgrep -x "Brave Browser" > /dev/null 2>&1; then
+ echo -e " ${GRAY}${ICON_WARNING}${NC} Brave Browser running · old versions cleanup skipped"
+ return 0
+ fi
+
+ local cleaned_count=0
+ local total_size=0
+ local cleaned_any=false
+
+ for app_path in "${app_paths[@]}"; do
+ [[ -d "$app_path" ]] || continue
+
+ local versions_dir="$app_path/Contents/Frameworks/Brave Browser Framework.framework/Versions"
+ [[ -d "$versions_dir" ]] || continue
+
+ local current_link="$versions_dir/Current"
+ [[ -L "$current_link" ]] || continue
+
+ local current_version
+ current_version=$(readlink "$current_link" 2> /dev/null || true)
+ current_version="${current_version##*/}"
+ [[ -n "$current_version" ]] || continue
+
+ if [[ ! -d "$versions_dir/$current_version" ]]; then
+ echo -e " ${GRAY}${ICON_WARNING}${NC} Brave Browser Current symlink is broken · skipping version cleanup"
+ continue
+ fi
+
+ local -a old_versions=()
+ local dir name
+ for dir in "$versions_dir"/*; do
+ [[ -d "$dir" ]] || continue
+ name=$(basename "$dir")
+ [[ "$name" == "Current" ]] && continue
+ [[ "$name" == "$current_version" ]] && continue
+ if is_path_whitelisted "$dir"; then
+ continue
+ fi
+ old_versions+=("$dir")
+ done
+
+ if [[ ${#old_versions[@]} -eq 0 ]]; then
+ continue
+ fi
+
+ for dir in "${old_versions[@]}"; do
+ local size_kb
+ size_kb=$(get_path_size_kb "$dir" || echo 0)
+ size_kb="${size_kb:-0}"
+ total_size=$((total_size + size_kb))
+ cleaned_count=$((cleaned_count + 1))
+ cleaned_any=true
+ if [[ "$DRY_RUN" != "true" ]]; then
+ if has_sudo_session; then
+ safe_sudo_remove "$dir" > /dev/null 2>&1 || true
+ else
+ safe_remove "$dir" true > /dev/null 2>&1 || true
+ fi
+ fi
+ done
+ done
+
+ if [[ "$cleaned_any" == "true" ]]; then
+ local size_human
+ size_human=$(bytes_to_human "$((total_size * 1024))")
+ if [[ "$DRY_RUN" == "true" ]]; then
+ echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Brave old versions${NC}, ${YELLOW}${cleaned_count} dirs, $size_human dry${NC}"
+ else
+ local line_color
+ line_color=$(cleanup_result_color_kb "$total_size")
+ echo -e " ${line_color}${ICON_SUCCESS}${NC} Brave old versions${NC}, ${line_color}${cleaned_count} dirs, $size_human${NC}"
fi
files_cleaned=$((files_cleaned + cleaned_count))
total_size_cleaned=$((total_size_cleaned + total_size))
@@ -427,20 +571,72 @@ 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"
+ # Clean system-level idle/aerial screensaver videos (macOS re-downloads as needed).
+ local sys_idle_assets_dir="/Library/Application Support/com.apple.idleassetsd/Customer"
+ # Skip sudo operations during tests to avoid password prompts
+ if [[ "${MOLE_TEST_MODE:-0}" != "1" && "${MOLE_TEST_NO_AUTH:-0}" != "1" ]]; then
+ if sudo test -d "$sys_idle_assets_dir" 2> /dev/null; then
+ safe_sudo_find_delete "$sys_idle_assets_dir" "*" "$support_age_days" "f" || true
+ fi
+ fi
# Do not touch Messages attachments, only preview/sticker caches.
- if pgrep -x "Messages" > /dev/null 2>&1; then
- echo -e " ${GRAY}${ICON_WARNING}${NC} Messages is running · preview cache cleanup skipped"
- 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).
+cache_top_level_entry_count_capped() {
+ local dir="$1"
+ local cap="${2:-101}"
+ local count=0
+ local _nullglob_state
+ local _dotglob_state
+ _nullglob_state=$(shopt -p nullglob || true)
+ _dotglob_state=$(shopt -p dotglob || true)
+ shopt -s nullglob dotglob
+
+ local item
+ for item in "$dir"/*; do
+ [[ -e "$item" ]] || continue
+ count=$((count + 1))
+ if ((count >= cap)); then
+ break
+ fi
+ done
+
+ eval "$_nullglob_state"
+ eval "$_dotglob_state"
+
+ [[ "$count" =~ ^[0-9]+$ ]] || count=0
+ printf '%s\n' "$count"
+}
+
+directory_has_entries() {
+ local dir="$1"
+ [[ -d "$dir" ]] || return 1
+
+ local _nullglob_state
+ local _dotglob_state
+ _nullglob_state=$(shopt -p nullglob || true)
+ _dotglob_state=$(shopt -p dotglob || true)
+ shopt -s nullglob dotglob
+
+ local item
+ for item in "$dir"/*; do
+ if [[ -e "$item" ]]; then
+ eval "$_nullglob_state"
+ eval "$_dotglob_state"
+ return 0
+ fi
+ done
+
+ eval "$_nullglob_state"
+ eval "$_dotglob_state"
+ return 1
+}
+
clean_app_caches() {
start_section_spinner "Scanning app caches..."
@@ -453,9 +649,7 @@ clean_app_caches() {
safe_clean ~/Library/Caches/com.apple.QuickLook.thumbnailcache "QuickLook thumbnails" || true
safe_clean ~/Library/Caches/Quick\ Look/* "QuickLook cache" || true
safe_clean ~/Library/Caches/com.apple.iconservices* "Icon services cache" || true
- safe_clean ~/Downloads/*.download "Safari incomplete downloads" || true
- safe_clean ~/Downloads/*.crdownload "Chrome incomplete downloads" || true
- safe_clean ~/Downloads/*.part "Partial incomplete downloads" || true
+ _clean_incomplete_downloads
safe_clean ~/Library/Autosave\ Information/* "Autosave information" || true
safe_clean ~/Library/IdentityCaches/* "Identity caches" || true
safe_clean ~/Library/Suggestions/* "Siri suggestions cache" || true
@@ -469,14 +663,30 @@ clean_app_caches() {
# Sandboxed app caches
safe_clean ~/Library/Containers/com.apple.wallpaper.agent/Data/Library/Caches/* "Wallpaper agent cache"
safe_clean ~/Library/Containers/com.apple.mediaanalysisd/Data/Library/Caches/* "Media analysis cache"
+ safe_clean ~/Library/Containers/com.apple.mediaanalysisd/Data/tmp/* "Media analysis temp files"
safe_clean ~/Library/Containers/com.apple.AppStore/Data/Library/Caches/* "App Store cache"
safe_clean ~/Library/Containers/com.apple.configurator.xpc.InternetService/Data/tmp/* "Apple Configurator temp files"
+ safe_clean ~/Library/Containers/com.apple.wallpaper.extension.aerials/Data/tmp/* "Wallpaper aerials temp files"
+ safe_clean ~/Library/Containers/com.apple.geod/Data/tmp/* "Geod temp files"
+ safe_clean ~/Library/Containers/com.apple.stocks/Data/Library/Caches/* "Stocks cache"
+ safe_clean ~/Library/Application\ Support/com.apple.wallpaper/aerials/thumbnails/* "Wallpaper aerials thumbnails"
+ safe_clean ~/Library/Caches/com.apple.helpd/* "macOS Help system cache"
+ safe_clean ~/Library/Caches/GeoServices/* "Maps geo tile cache"
+ safe_clean ~/Library/Containers/com.apple.AvatarUI.AvatarPickerMemojiPicker/Data/Library/Caches/* "Memoji picker cache"
+ safe_clean ~/Library/Containers/com.apple.AMPArtworkAgent/Data/Library/Caches/* "Music album art cache"
+ safe_clean ~/Library/Containers/com.apple.CoreDevice.CoreDeviceService/Data/Library/Caches/* "CoreDevice service cache"
+ safe_clean ~/Library/Containers/com.apple.NeptuneOneExtension/Data/Library/Caches/* "Apple Intelligence extension cache"
+ safe_clean ~/Library/Containers/com.apple.AppleMediaServicesUI.UtilityExtension/Data/tmp/* "Apple Media Services temp files"
local containers_dir="$HOME/Library/Containers"
[[ ! -d "$containers_dir" ]] && return 0
start_section_spinner "Scanning sandboxed apps..."
local total_size=0
+ local total_size_partial=false
local cleaned_count=0
local found_any=false
+ local precise_size_limit="${MOLE_CONTAINER_CACHE_PRECISE_SIZE_LIMIT:-64}"
+ [[ "$precise_size_limit" =~ ^[0-9]+$ ]] || precise_size_limit=64
+ local precise_size_used=0
local _ng_state
_ng_state=$(shopt -p nullglob || true)
@@ -488,12 +698,24 @@ clean_app_caches() {
stop_section_spinner
if [[ "$found_any" == "true" ]]; then
- local size_human
- size_human=$(bytes_to_human "$((total_size * 1024))")
if [[ "$DRY_RUN" == "true" ]]; then
- echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Sandboxed app caches${NC}, ${YELLOW}$size_human dry${NC}"
+ if [[ "$total_size_partial" == "true" ]]; then
+ echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Sandboxed app caches${NC}, ${YELLOW}dry${NC}"
+ else
+ local size_human
+ size_human=$(bytes_to_human "$((total_size * 1024))")
+ echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Sandboxed app caches${NC}, ${YELLOW}$size_human dry${NC}"
+ fi
else
- echo -e " ${GREEN}${ICON_SUCCESS}${NC} Sandboxed app caches${NC}, ${GREEN}$size_human${NC}"
+ if [[ "$total_size_partial" == "true" ]]; then
+ echo -e " ${GREEN}${ICON_SUCCESS}${NC} Sandboxed app caches${NC}, ${GREEN}cleaned${NC}"
+ else
+ local size_human
+ size_human=$(bytes_to_human "$((total_size * 1024))")
+ local line_color
+ line_color=$(cleanup_result_color_kb "$total_size")
+ echo -e " ${line_color}${ICON_SUCCESS}${NC} Sandboxed app caches${NC}, ${line_color}$size_human${NC}"
+ fi
fi
files_cleaned=$((files_cleaned + cleaned_count))
total_size_cleaned=$((total_size_cleaned + total_size))
@@ -509,31 +731,46 @@ process_container_cache() {
local container_dir="$1"
[[ -d "$container_dir" ]] || return 0
[[ -L "$container_dir" ]] && return 0
- local bundle_id
- bundle_id=$(basename "$container_dir")
+ local bundle_id="${container_dir##*/}"
if is_critical_system_component "$bundle_id"; then
return 0
fi
- if should_protect_data "$bundle_id" || should_protect_data "$(echo "$bundle_id" | LC_ALL=C tr '[:upper:]' '[:lower:]')"; then
+ if should_protect_data "$bundle_id"; then
return 0
fi
local cache_dir="$container_dir/Data/Library/Caches"
[[ -d "$cache_dir" ]] || return 0
[[ -L "$cache_dir" ]] && return 0
- # Fast non-empty check.
- if find "$cache_dir" -mindepth 1 -maxdepth 1 -print -quit 2> /dev/null | grep -q .; then
+ local item_count
+ item_count=$(cache_top_level_entry_count_capped "$cache_dir" 101)
+ [[ "$item_count" =~ ^[0-9]+$ ]] || item_count=0
+ [[ "$item_count" -eq 0 ]] && return 0
+
+ if [[ "$item_count" -le 100 && "$precise_size_used" -lt "$precise_size_limit" ]]; then
local size
- size=$(get_path_size_kb "$cache_dir")
+ size=$(get_path_size_kb "$cache_dir" 2> /dev/null || echo "0")
+ [[ "$size" =~ ^[0-9]+$ ]] || size=0
total_size=$((total_size + size))
- found_any=true
- cleaned_count=$((cleaned_count + 1))
- if [[ "$DRY_RUN" != "true" ]]; then
- local item
- while IFS= read -r -d '' item; do
- [[ -e "$item" ]] || continue
- safe_remove "$item" true || true
- done < <(command find "$cache_dir" -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true)
- fi
+ precise_size_used=$((precise_size_used + 1))
+ else
+ total_size_partial=true
+ fi
+
+ found_any=true
+ cleaned_count=$((cleaned_count + 1))
+ if [[ "$DRY_RUN" != "true" ]]; then
+ local _nullglob_state
+ local _dotglob_state
+ _nullglob_state=$(shopt -p nullglob || true)
+ _dotglob_state=$(shopt -p dotglob || true)
+ shopt -s nullglob dotglob
+ local item
+ for item in "$cache_dir"/*; do
+ [[ -e "$item" ]] || continue
+ safe_remove "$item" true || true
+ done
+ eval "$_nullglob_state"
+ eval "$_dotglob_state"
fi
}
@@ -541,23 +778,25 @@ process_container_cache() {
clean_group_container_caches() {
local group_containers_dir="$HOME/Library/Group Containers"
[[ -d "$group_containers_dir" ]] || return 0
- if ! find "$group_containers_dir" -mindepth 1 -maxdepth 1 -print -quit 2> /dev/null | grep -q .; then
+ if ! directory_has_entries "$group_containers_dir"; then
return 0
fi
start_section_spinner "Scanning Group Containers..."
local total_size=0
+ local total_size_partial=false
local cleaned_count=0
local found_any=false
- # Collect all non-Apple container directories first
- local -a containers=()
local container_dir
+ local _nullglob_state
+ _nullglob_state=$(shopt -p nullglob || true)
+ shopt -s nullglob
+
for container_dir in "$group_containers_dir"/*; do
[[ -d "$container_dir" ]] || continue
[[ -L "$container_dir" ]] && continue
- local container_id
- container_id=$(basename "$container_dir")
+ local container_id="${container_dir##*/}"
# Skip Apple-owned shared containers entirely.
case "$container_id" in
@@ -565,13 +804,6 @@ clean_group_container_caches() {
continue
;;
esac
- containers+=("$container_dir")
- done
-
- # Process each container's candidate directories
- for container_dir in "${containers[@]}"; do
- local container_id
- container_id=$(basename "$container_dir")
local normalized_id="$container_id"
[[ "$normalized_id" == group.* ]] && normalized_id="${normalized_id#group.}"
@@ -601,42 +833,56 @@ clean_group_container_caches() {
continue
fi
- # Build non-protected candidate items for cleanup.
- local -a items_to_clean=()
local item
- while IFS= read -r -d '' item; do
- [[ -e "$item" ]] || continue
- [[ -L "$item" ]] && continue
- if should_protect_path "$item" 2> /dev/null || is_path_whitelisted "$item" 2> /dev/null; then
- continue
- else
- items_to_clean+=("$item")
- fi
- done < <(command find "$candidate" -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true)
-
- [[ ${#items_to_clean[@]} -gt 0 ]] || continue
+ local quick_count
+ quick_count=$(cache_top_level_entry_count_capped "$candidate" 101)
+ [[ "$quick_count" =~ ^[0-9]+$ ]] || quick_count=0
+ [[ "$quick_count" -eq 0 ]] && continue
local candidate_size_kb=0
local candidate_changed=false
- if [[ "$DRY_RUN" == "true" ]]; then
- for item in "${items_to_clean[@]}"; do
- local item_size
- item_size=$(get_path_size_kb "$item" 2> /dev/null) || item_size=0
- [[ "$item_size" =~ ^[0-9]+$ ]] || item_size=0
+ local _nullglob_state
+ local _dotglob_state
+ _nullglob_state=$(shopt -p nullglob || true)
+ _dotglob_state=$(shopt -p dotglob || true)
+ shopt -s nullglob dotglob
+
+ if [[ "$quick_count" -gt 100 ]]; then
+ total_size_partial=true
+ for item in "$candidate"/*; do
+ [[ -e "$item" ]] || continue
+ [[ -L "$item" ]] && continue
+ if should_protect_path "$item" 2> /dev/null || is_path_whitelisted "$item" 2> /dev/null; then
+ continue
+ fi
candidate_changed=true
- candidate_size_kb=$((candidate_size_kb + item_size))
+ if [[ "$DRY_RUN" != "true" ]]; then
+ safe_remove "$item" true 2> /dev/null || true
+ fi
done
else
- for item in "${items_to_clean[@]}"; do
+ for item in "$candidate"/*; do
+ [[ -e "$item" ]] || continue
+ [[ -L "$item" ]] && continue
+ if should_protect_path "$item" 2> /dev/null || is_path_whitelisted "$item" 2> /dev/null; then
+ continue
+ fi
local item_size
item_size=$(get_path_size_kb "$item" 2> /dev/null) || item_size=0
[[ "$item_size" =~ ^[0-9]+$ ]] || item_size=0
+ if [[ "$DRY_RUN" == "true" ]]; then
+ candidate_changed=true
+ candidate_size_kb=$((candidate_size_kb + item_size))
+ continue
+ fi
if safe_remove "$item" true 2> /dev/null; then
candidate_changed=true
candidate_size_kb=$((candidate_size_kb + item_size))
fi
done
fi
+ eval "$_nullglob_state"
+ eval "$_dotglob_state"
if [[ "$candidate_changed" == "true" ]]; then
total_size=$((total_size + candidate_size_kb))
@@ -645,6 +891,185 @@ clean_group_container_caches() {
fi
done
done
+ eval "$_nullglob_state"
+
+ stop_section_spinner
+
+ if [[ "$found_any" == "true" ]]; then
+ if [[ "$DRY_RUN" == "true" ]]; then
+ if [[ "$total_size_partial" == "true" ]]; then
+ echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Group Containers logs/caches${NC}, ${YELLOW}dry${NC}"
+ else
+ local size_human
+ size_human=$(bytes_to_human "$((total_size * 1024))")
+ echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Group Containers logs/caches${NC}, ${YELLOW}$size_human dry${NC}"
+ fi
+ else
+ if [[ "$total_size_partial" == "true" ]]; then
+ echo -e " ${GREEN}${ICON_SUCCESS}${NC} Group Containers logs/caches${NC}, ${GREEN}cleaned${NC}"
+ else
+ local size_human
+ size_human=$(bytes_to_human "$((total_size * 1024))")
+ local line_color
+ line_color=$(cleanup_result_color_kb "$total_size")
+ echo -e " ${line_color}${ICON_SUCCESS}${NC} Group Containers logs/caches${NC}, ${line_color}$size_human${NC}"
+ fi
+ fi
+ files_cleaned=$((files_cleaned + cleaned_count))
+ total_size_cleaned=$((total_size_cleaned + total_size))
+ total_items=$((total_items + 1))
+ note_activity
+ fi
+}
+
+resolve_existing_path() {
+ local path="$1"
+ [[ -e "$path" ]] || return 1
+
+ if command -v realpath > /dev/null 2>&1; then
+ realpath "$path" 2> /dev/null && return 0
+ fi
+
+ local dir base
+ dir=$(cd -P "$(dirname "$path")" 2> /dev/null && pwd) || return 1
+ base=$(basename "$path")
+ printf '%s/%s\n' "$dir" "$base"
+}
+
+external_volume_root() {
+ printf '%s\n' "${MOLE_EXTERNAL_VOLUMES_ROOT:-/Volumes}"
+}
+
+validate_external_volume_target() {
+ local target="$1"
+ local root
+ root=$(external_volume_root)
+ local resolved_root="$root"
+ if [[ -e "$root" ]]; then
+ resolved_root=$(resolve_existing_path "$root" 2> /dev/null || printf '%s\n' "$root")
+ fi
+ resolved_root="${resolved_root%/}"
+
+ if [[ -z "$target" ]]; then
+ echo "Missing external volume path" >&2
+ return 1
+ fi
+ if [[ "$target" != /* ]]; then
+ echo "External volume path must be absolute: $target" >&2
+ return 1
+ fi
+ if [[ "$target" == "$root" || "$target" == "$resolved_root" ]]; then
+ echo "Refusing to clean the volumes root directly: $resolved_root" >&2
+ return 1
+ fi
+ if [[ -L "$target" ]]; then
+ echo "Refusing to clean symlinked volume path: $target" >&2
+ return 1
+ fi
+
+ local resolved
+ resolved=$(resolve_existing_path "$target") || {
+ echo "External volume path does not exist: $target" >&2
+ return 1
+ }
+
+ if [[ "$resolved" != "$resolved_root/"* ]]; then
+ echo "External volume path must be under $resolved_root: $resolved" >&2
+ return 1
+ fi
+
+ local relative_path="${resolved#"$resolved_root"/}"
+ if [[ -z "$relative_path" || "$relative_path" == "$resolved" || "$relative_path" == */* ]]; then
+ echo "External cleanup only supports mounted paths directly under $resolved_root: $resolved" >&2
+ return 1
+ fi
+
+ local disk_info=""
+ disk_info=$(run_with_timeout 2 command diskutil info "$resolved" 2> /dev/null || echo "")
+ if [[ -n "$disk_info" ]]; then
+ if echo "$disk_info" | grep -Eq 'Internal:[[:space:]]+Yes'; then
+ echo "Refusing to clean an internal volume: $resolved" >&2
+ return 1
+ fi
+
+ local protocol=""
+ protocol=$(echo "$disk_info" | awk -F: '/Protocol:/ {gsub(/^[[:space:]]+/, "", $2); print $2; exit}')
+ case "$protocol" in
+ SMB | NFS | AFP | CIFS | WebDAV)
+ echo "Refusing to clean network volume protocol $protocol: $resolved" >&2
+ return 1
+ ;;
+ esac
+ fi
+
+ printf '%s\n' "$resolved"
+}
+
+clean_external_volume_target() {
+ local volume="$1"
+ [[ -d "$volume" ]] || return 1
+ [[ -L "$volume" ]] && return 1
+
+ local -a top_level_targets=(
+ "$volume/.TemporaryItems"
+ "$volume/.Trashes"
+ "$volume/.Spotlight-V100"
+ "$volume/.fseventsd"
+ )
+ local cleaned_count=0
+ local total_size=0
+ local found_any=false
+ local volume_name="${volume##*/}"
+
+ start_section_spinner "Scanning external volume..."
+
+ local target_path
+ for target_path in "${top_level_targets[@]}"; do
+ [[ -e "$target_path" ]] || continue
+ [[ -L "$target_path" ]] && continue
+ if should_protect_path "$target_path" 2> /dev/null || is_path_whitelisted "$target_path" 2> /dev/null; then
+ continue
+ fi
+
+ local size_kb
+ size_kb=$(get_path_size_kb "$target_path" 2> /dev/null || echo "0")
+ [[ "$size_kb" =~ ^[0-9]+$ ]] || size_kb=0
+
+ if [[ "$DRY_RUN" == "true" ]]; then
+ found_any=true
+ cleaned_count=$((cleaned_count + 1))
+ total_size=$((total_size + size_kb))
+ elif safe_remove "$target_path" true > /dev/null 2>&1; then
+ found_any=true
+ cleaned_count=$((cleaned_count + 1))
+ total_size=$((total_size + size_kb))
+ fi
+ done
+
+ if [[ "$PROTECT_FINDER_METADATA" != "true" ]]; then
+ clean_ds_store_tree "$volume" "${volume_name} volume, .DS_Store"
+ fi
+
+ while IFS= read -r -d '' metadata_file; do
+ [[ -e "$metadata_file" ]] || continue
+ if should_protect_path "$metadata_file" 2> /dev/null || is_path_whitelisted "$metadata_file" 2> /dev/null; then
+ continue
+ fi
+
+ local size_kb
+ size_kb=$(get_path_size_kb "$metadata_file" 2> /dev/null || echo "0")
+ [[ "$size_kb" =~ ^[0-9]+$ ]] || size_kb=0
+
+ if [[ "$DRY_RUN" == "true" ]]; then
+ found_any=true
+ cleaned_count=$((cleaned_count + 1))
+ total_size=$((total_size + size_kb))
+ elif safe_remove "$metadata_file" true > /dev/null 2>&1; then
+ found_any=true
+ cleaned_count=$((cleaned_count + 1))
+ total_size=$((total_size + size_kb))
+ fi
+ done < <(command find "$volume" -type f -name "._*" -print0 2> /dev/null || true)
stop_section_spinner
@@ -652,15 +1077,19 @@ clean_group_container_caches() {
local size_human
size_human=$(bytes_to_human "$((total_size * 1024))")
if [[ "$DRY_RUN" == "true" ]]; then
- echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Group Containers logs/caches${NC}, ${YELLOW}$size_human dry${NC}"
+ echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} External volume cleanup${NC}, ${YELLOW}${volume_name}, $size_human dry${NC}"
else
- echo -e " ${GREEN}${ICON_SUCCESS}${NC} Group Containers logs/caches${NC}, ${GREEN}$size_human${NC}"
+ local line_color
+ line_color=$(cleanup_result_color_kb "$total_size")
+ echo -e " ${line_color}${ICON_SUCCESS}${NC} External volume cleanup${NC}, ${line_color}${volume_name}, $size_human${NC}"
fi
files_cleaned=$((files_cleaned + cleaned_count))
total_size_cleaned=$((total_size_cleaned + total_size))
total_items=$((total_items + 1))
note_activity
fi
+
+ return 0
}
# Browser caches (Safari/Chrome/Edge/Firefox).
@@ -671,14 +1100,43 @@ clean_browsers() {
safe_clean ~/Library/Application\ Support/Google/Chrome/*/Application\ Cache/* "Chrome app cache"
safe_clean ~/Library/Application\ Support/Google/Chrome/*/GPUCache/* "Chrome GPU cache"
safe_clean ~/Library/Application\ Support/Google/Chrome/component_crx_cache/* "Chrome component CRX cache"
+ safe_clean ~/Library/Application\ Support/Google/Chrome/ShaderCache/* "Chrome shader cache"
+ safe_clean ~/Library/Application\ Support/Google/Chrome/GrShaderCache/* "Chrome GR shader cache"
+ safe_clean ~/Library/Application\ Support/Google/Chrome/GraphiteDawnCache/* "Chrome Dawn cache"
+ local _chrome_profile
+ for _chrome_profile in "$HOME/Library/Application Support/Google/Chrome"/*/; do
+ clean_service_worker_cache "Chrome" "$_chrome_profile/Service Worker/CacheStorage"
+ safe_clean "$_chrome_profile"/Service\ Worker/ScriptCache/* "Chrome Service Worker ScriptCache"
+ done
safe_clean ~/Library/Application\ Support/Google/GoogleUpdater/crx_cache/* "GoogleUpdater CRX cache"
safe_clean ~/Library/Application\ Support/Google/GoogleUpdater/*.old "GoogleUpdater old files"
safe_clean ~/Library/Caches/Chromium/* "Chromium cache"
safe_clean ~/.cache/puppeteer/* "Puppeteer browser cache"
safe_clean ~/Library/Caches/com.microsoft.edgemac/* "Edge cache"
+ # Arc Browser.
safe_clean ~/Library/Caches/company.thebrowser.Browser/* "Arc cache"
+ safe_clean ~/Library/Application\ Support/Arc/*/GPUCache/* "Arc GPU cache"
+ safe_clean ~/Library/Application\ Support/Arc/ShaderCache/* "Arc shader cache"
+ safe_clean ~/Library/Application\ Support/Arc/GrShaderCache/* "Arc GR shader cache"
+ safe_clean ~/Library/Application\ Support/Arc/GraphiteDawnCache/* "Arc Dawn cache"
+ local _arc_profile
+ for _arc_profile in "$HOME/Library/Application Support/Arc"/*/; do
+ clean_service_worker_cache "Arc" "$_arc_profile/Service Worker/CacheStorage"
+ safe_clean "$_arc_profile"/Service\ Worker/ScriptCache/* "Arc Service Worker ScriptCache"
+ done
safe_clean ~/Library/Caches/company.thebrowser.dia/* "Dia cache"
safe_clean ~/Library/Caches/BraveSoftware/Brave-Browser/* "Brave cache"
+ safe_clean ~/Library/Application\ Support/BraveSoftware/Brave-Browser/*/Application\ Cache/* "Brave app cache"
+ safe_clean ~/Library/Application\ Support/BraveSoftware/Brave-Browser/*/GPUCache/* "Brave GPU cache"
+ safe_clean ~/Library/Application\ Support/BraveSoftware/Brave-Browser/component_crx_cache/* "Brave component CRX cache"
+ safe_clean ~/Library/Application\ Support/BraveSoftware/Brave-Browser/ShaderCache/* "Brave shader cache"
+ safe_clean ~/Library/Application\ Support/BraveSoftware/Brave-Browser/GrShaderCache/* "Brave GR shader cache"
+ safe_clean ~/Library/Application\ Support/BraveSoftware/Brave-Browser/GraphiteDawnCache/* "Brave Dawn cache"
+ local _brave_profile
+ for _brave_profile in "$HOME/Library/Application Support/BraveSoftware/Brave-Browser"/*/; do
+ clean_service_worker_cache "Brave" "$_brave_profile/Service Worker/CacheStorage"
+ safe_clean "$_brave_profile"/Service\ Worker/ScriptCache/* "Brave Service Worker ScriptCache"
+ done
# Helium Browser.
safe_clean ~/Library/Caches/net.imput.helium/* "Helium cache"
safe_clean ~/Library/Application\ Support/net.imput.helium/*/GPUCache/* "Helium GPU cache"
@@ -704,7 +1162,17 @@ clean_browsers() {
safe_clean ~/Library/Caches/Firefox/* "Firefox cache"
fi
safe_clean ~/Library/Caches/com.operasoftware.Opera/* "Opera cache"
+ # Vivaldi Browser.
safe_clean ~/Library/Caches/com.vivaldi.Vivaldi/* "Vivaldi cache"
+ safe_clean ~/Library/Application\ Support/Vivaldi/*/GPUCache/* "Vivaldi GPU cache"
+ safe_clean ~/Library/Application\ Support/Vivaldi/ShaderCache/* "Vivaldi shader cache"
+ safe_clean ~/Library/Application\ Support/Vivaldi/GrShaderCache/* "Vivaldi GR shader cache"
+ safe_clean ~/Library/Application\ Support/Vivaldi/GraphiteDawnCache/* "Vivaldi Dawn cache"
+ local _vivaldi_profile
+ for _vivaldi_profile in "$HOME/Library/Application Support/Vivaldi"/*/; do
+ clean_service_worker_cache "Vivaldi" "$_vivaldi_profile/Service Worker/CacheStorage"
+ safe_clean "$_vivaldi_profile"/Service\ Worker/ScriptCache/* "Vivaldi Service Worker ScriptCache"
+ done
safe_clean ~/Library/Caches/Comet/* "Comet cache"
safe_clean ~/Library/Caches/com.kagi.kagimacOS/* "Orion cache"
safe_clean ~/Library/Caches/zen/* "Zen cache"
@@ -716,6 +1184,7 @@ clean_browsers() {
clean_chrome_old_versions
clean_edge_old_versions
clean_edge_updater_old_versions
+ clean_brave_old_versions
}
# Cloud storage caches.
@@ -732,7 +1201,13 @@ clean_cloud_storage() {
# Office app caches.
clean_office_applications() {
safe_clean ~/Library/Caches/com.microsoft.Word "Microsoft Word cache"
+ safe_clean ~/Library/Containers/com.microsoft.Word/Data/Library/Caches/* "Microsoft Word container cache"
+ safe_clean ~/Library/Containers/com.microsoft.Word/Data/tmp/* "Microsoft Word temp files"
+ safe_clean ~/Library/Containers/com.microsoft.Word/Data/Library/Logs/* "Microsoft Word container logs"
safe_clean ~/Library/Caches/com.microsoft.Excel "Microsoft Excel cache"
+ safe_clean ~/Library/Containers/com.microsoft.Excel/Data/Library/Caches/* "Microsoft Excel container cache"
+ safe_clean ~/Library/Containers/com.microsoft.Excel/Data/tmp/* "Microsoft Excel temp files"
+ safe_clean ~/Library/Containers/com.microsoft.Excel/Data/Library/Logs/* "Microsoft Excel container logs"
safe_clean ~/Library/Caches/com.microsoft.Powerpoint "Microsoft PowerPoint cache"
safe_clean ~/Library/Caches/com.microsoft.Outlook/* "Microsoft Outlook cache"
safe_clean ~/Library/Caches/com.apple.iWork.* "Apple iWork cache"
@@ -791,24 +1266,13 @@ app_support_item_size_bytes() {
return 1
fi
- local du_tmp
- du_tmp=$(mktemp)
- local du_status=0
+ local du_output
# Use stricter timeout for directories
- 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"
+ if ! du_output=$(run_with_timeout "$timeout_seconds" du -skP "$item" 2> /dev/null); then
return 1
fi
- local size_kb
- size_kb=$(awk 'NR==1 {print $1; exit}' "$du_tmp")
- rm -f "$du_tmp"
+ local size_kb="${du_output%%[^0-9]*}"
[[ "$size_kb" =~ ^[0-9]+$ ]] || return 1
printf '%s\n' "$((size_kb * 1024))"
return 0
@@ -853,17 +1317,22 @@ clean_application_support_logs() {
last_progress_update=$(get_epoch_seconds)
for app_dir in ~/Library/Application\ Support/*; do
[[ -d "$app_dir" ]] || continue
- local app_name
- app_name=$(basename "$app_dir")
+ local app_name="${app_dir##*/}"
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:]')
local is_protected=false
- if should_protect_data "$app_name"; then
+ if is_path_whitelisted "$app_dir" 2> /dev/null; then
+ is_protected=true
+ elif should_protect_path "$app_dir" 2> /dev/null; then
is_protected=true
- elif should_protect_data "$app_name_lower"; then
+ elif should_protect_data "$app_name"; then
is_protected=true
+ else
+ local app_name_lower
+ app_name_lower=$(echo "$app_name" | LC_ALL=C tr '[:upper:]' '[:lower:]')
+ if should_protect_data "$app_name_lower"; then
+ is_protected=true
+ fi
fi
if [[ "$is_protected" == "true" ]]; then
continue
@@ -874,6 +1343,9 @@ clean_application_support_logs() {
local -a start_candidates=("$app_dir/log" "$app_dir/logs" "$app_dir/activitylog" "$app_dir/Cache/Cache_Data" "$app_dir/Crashpad/completed")
for candidate in "${start_candidates[@]}"; do
if [[ -d "$candidate" ]]; then
+ if should_protect_path "$candidate" 2> /dev/null || is_path_whitelisted "$candidate" 2> /dev/null; then
+ continue
+ fi
# Quick count check - skip if too many items to avoid hanging
local quick_count
quick_count=$(app_support_entry_count_capped "$candidate" 1 101)
@@ -901,6 +1373,9 @@ clean_application_support_logs() {
local candidate_item_count=0
while IFS= read -r -d '' item; do
[[ -e "$item" ]] || continue
+ if should_protect_path "$item" 2> /dev/null || is_path_whitelisted "$item" 2> /dev/null; then
+ continue
+ fi
item_found=true
candidate_item_count=$((candidate_item_count + 1))
if [[ ! -L "$item" && (-f "$item" || -d "$item") ]]; then
@@ -1032,10 +1507,12 @@ clean_application_support_logs() {
echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Application Support logs/caches${NC}, ${YELLOW}$size_human dry${NC}"
fi
else
+ local line_color
+ line_color=$(cleanup_result_color_kb "$total_size_kb")
if [[ "$total_size_partial" == "true" ]]; then
- echo -e " ${GREEN}${ICON_SUCCESS}${NC} Application Support logs/caches${NC}, ${GREEN}at least $size_human${NC}"
+ echo -e " ${line_color}${ICON_SUCCESS}${NC} Application Support logs/caches${NC}, ${line_color}at least $size_human${NC}"
else
- echo -e " ${GREEN}${ICON_SUCCESS}${NC} Application Support logs/caches${NC}, ${GREEN}$size_human${NC}"
+ echo -e " ${line_color}${ICON_SUCCESS}${NC} Application Support logs/caches${NC}, ${line_color}$size_human${NC}"
fi
fi
files_cleaned=$((files_cleaned + cleaned_count))
@@ -1044,6 +1521,87 @@ clean_application_support_logs() {
note_activity
fi
}
+# Remove cached device firmware (.ipsw) from iTunes, Finder, and Apple Configurator 2.
+# These are installers for firmware already applied (or superseded) — macOS will
+# re-download them on demand. Typical size: 5-8GB per file. Never touches backups.
+clean_cached_device_firmware() {
+ local -a shallow_dirs=(
+ "$HOME/Library/iTunes/iPhone Software Updates"
+ "$HOME/Library/iTunes/iPad Software Updates"
+ "$HOME/Library/iTunes/iPod Software Updates"
+ )
+
+ # Apple Configurator 2 nests firmware under per-team-id group containers.
+ local -a configurator_dirs=()
+ local gc
+ for gc in "$HOME/Library/Group Containers"/*.group.com.apple.configurator; do
+ [[ -d "$gc" ]] || continue
+ configurator_dirs+=("$gc")
+ done
+
+ local cleaned_count=0
+ local total_size_kb=0
+ local cleaned_any=false
+
+ _process_ipsw_file() {
+ local ipsw="$1"
+ [[ -f "$ipsw" ]] || return 0
+ if is_path_whitelisted "$ipsw"; then
+ return 0
+ fi
+ local size_kb
+ size_kb=$(get_path_size_kb "$ipsw" || echo 0)
+ size_kb="${size_kb:-0}"
+ if [[ "$DRY_RUN" == "true" ]]; then
+ total_size_kb=$((total_size_kb + size_kb))
+ cleaned_count=$((cleaned_count + 1))
+ cleaned_any=true
+ return 0
+ fi
+
+ if safe_remove "$ipsw" true > /dev/null 2>&1; then
+ total_size_kb=$((total_size_kb + size_kb))
+ cleaned_count=$((cleaned_count + 1))
+ cleaned_any=true
+ fi
+ }
+
+ local dir ipsw
+ for dir in "${shallow_dirs[@]}"; do
+ [[ -d "$dir" ]] || continue
+ while IFS= read -r -d '' ipsw; do
+ _process_ipsw_file "$ipsw"
+ done < <(command find "$dir" -maxdepth 1 -type f -name "*.ipsw" -print0 2> /dev/null)
+ done
+
+ if [[ ${#configurator_dirs[@]} -gt 0 ]]; then
+ for dir in "${configurator_dirs[@]}"; do
+ [[ -d "$dir" ]] || continue
+ while IFS= read -r -d '' ipsw; do
+ _process_ipsw_file "$ipsw"
+ done < <(command find "$dir" -type f -name "*.ipsw" -print0 2> /dev/null)
+ done
+ fi
+
+ unset -f _process_ipsw_file
+
+ if [[ "$cleaned_any" == "true" ]]; then
+ local size_human
+ size_human=$(bytes_to_human "$((total_size_kb * 1024))")
+ if [[ "$DRY_RUN" == "true" ]]; then
+ echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Cached device firmware${NC}, ${YELLOW}${cleaned_count} files, $size_human dry${NC}"
+ else
+ local line_color
+ line_color=$(cleanup_result_color_kb "$total_size_kb")
+ echo -e " ${line_color}${ICON_SUCCESS}${NC} Cached device firmware${NC}, ${line_color}${cleaned_count} files, $size_human${NC}"
+ fi
+ files_cleaned=$((files_cleaned + cleaned_count))
+ total_size_cleaned=$((total_size_cleaned + total_size_kb))
+ total_items=$((total_items + 1))
+ note_activity
+ fi
+}
+
# iOS device backup info.
check_ios_device_backups() {
local backup_dir="$HOME/Library/Application Support/MobileSync/Backup"
diff --git a/Resources/mole/lib/core/app_protection.sh b/Resources/mole/lib/core/app_protection.sh
index 144aac4..c64276f 100755
--- a/Resources/mole/lib/core/app_protection.sh
+++ b/Resources/mole/lib/core/app_protection.sh
@@ -56,6 +56,11 @@ readonly SYSTEM_CRITICAL_BUNDLES_FAST=(
"GlobalPreferences"
".GlobalPreferences"
"org.pqrs.Karabiner*"
+ # CUPS printing subsystem ships with macOS; there is no parent .app to
+ # anchor it, so org.cups.* prefs always look "orphaned" to bundle-ID
+ # matching. Deleting them wipes the default printer and recent-printer
+ # list, which users see as lost saved printers. See #731.
+ "org.cups.*"
)
# Detailed list for uninstall protection
@@ -290,6 +295,8 @@ readonly DATA_PROTECTED_BUNDLES=(
"clash.*"
"Clash.*"
"clash_*"
+ "*clash-verge*"
+ "*Clash-Verge*"
"clashverge*"
"ClashVerge*"
"com.nssurge.surge-mac"
@@ -663,6 +670,12 @@ should_protect_data() {
com.apple.* | loginwindow | dock | systempreferences | finder | safari)
return 0
;;
+ # CUPS is an OS-provided subsystem with no user-facing app; without this
+ # guard `~/Library/Preferences/org.cups.PrintingPrefs.plist` (which holds
+ # the default printer and recent printers) looks orphaned. See #731.
+ org.cups.*)
+ return 0
+ ;;
backgroundtaskmanagement* | keychain* | security* | bluetooth* | wifi* | network* | tcc)
return 0
;;
@@ -694,7 +707,7 @@ should_protect_data() {
com.nssurge.* | com.v2ray.* | com.clash.* | ClashX* | Surge* | Shadowrocket* | Quantumult*)
return 0
;;
- clash-* | Clash-* | *-clash | *-Clash | clash.* | Clash.* | clash_* | clashverge* | ClashVerge*)
+ clash-* | Clash-* | *-clash | *-Clash | clash.* | Clash.* | clash_* | *clash-verge* | *Clash-Verge* | clashverge* | ClashVerge*)
return 0
;;
com.docker.* | com.getpostman.* | com.insomnia.*)
@@ -800,6 +813,10 @@ should_protect_path() {
*/Library/Preferences/com.apple.dock.plist | */Library/Preferences/com.apple.finder.plist)
return 0
;;
+ # Protect Mole's own runtime logs so cleanup cannot delete its active log targets.
+ */Library/Logs/mole | */Library/Logs/mole/ | */Library/Logs/mole/*)
+ return 0
+ ;;
# Bluetooth and WiFi configurations
*/ByHost/com.apple.bluetooth.* | */ByHost/com.apple.wifi.*)
return 0
@@ -808,6 +825,11 @@ should_protect_path() {
*/Library/Mobile\ Documents* | */Mobile\ Documents*)
return 0
;;
+ # CoreAudio and audio subsystem caches (issue #553)
+ # Cleaning these can cause audio output loss on Intel Macs
+ *com.apple.coreaudio* | *com.apple.audio.* | *coreaudiod*)
+ return 0
+ ;;
esac
# 6. Match full path against protected patterns
@@ -855,8 +877,20 @@ is_path_whitelisted() {
local target_path="$1"
[[ -z "$target_path" ]] && return 1
- # Normalize path (remove trailing slash)
+ # Normalize path (remove trailing slash, collapse consecutive slashes).
+ # Callers sometimes concat a glob expansion that already ends in `/`
+ # with a sub-path that begins with `/`, producing `.../Default//Service
+ # Worker/...`. Without collapsing, those never match a whitelist entry
+ # written with single separators. See #724.
+ #
+ # Note: on bash 3.2 (macOS default), `${var//\/\//\/}` leaves a literal
+ # backslash in the replacement. Indirect variables sidestep that.
+ local _slash_single="/"
+ local _slash_double="//"
local normalized_target="${target_path%/}"
+ while [[ "$normalized_target" == *"$_slash_double"* ]]; do
+ normalized_target="${normalized_target//$_slash_double/$_slash_single}"
+ done
# Empty whitelist means nothing is protected
[[ ${#WHITELIST_PATTERNS[@]} -eq 0 ]] && return 1
@@ -864,6 +898,9 @@ is_path_whitelisted() {
for pattern in "${WHITELIST_PATTERNS[@]}"; do
# Pattern is already expanded/normalized in bin/clean.sh
local check_pattern="${pattern%/}"
+ while [[ "$check_pattern" == *"$_slash_double"* ]]; do
+ check_pattern="${check_pattern//$_slash_double/$_slash_single}"
+ done
local has_glob="false"
case "$check_pattern" in
*\** | *\?* | *\[*)
@@ -943,6 +980,7 @@ find_app_files() {
"$HOME/Library/WebKit/$bundle_id"
"$HOME/Library/WebKit/com.apple.WebKit.WebContent/$bundle_id"
"$HOME/Library/HTTPStorages/$bundle_id"
+ "$HOME/Library/HTTPStorages/$bundle_id.binarycookies"
"$HOME/Library/Cookies/$bundle_id.binarycookies"
"$HOME/Library/LaunchAgents/$bundle_id.plist"
"$HOME/Library/Application Scripts/$bundle_id"
@@ -967,6 +1005,10 @@ find_app_files() {
"$HOME/.local/share/$app_name"
"$HOME/.$app_name"
"$HOME/.$app_name"rc
+ "$HOME/Library/SyncedPreferences/$bundle_id.plist"
+ "$HOME/Library/Address Book Plug-Ins/$app_name.bundle"
+ "$HOME/Library/Accessibility/$app_name.bundle"
+ "$HOME/Library/Mail/Bundles/$app_name.mailbundle"
)
# Add all naming variants to cover inconsistent app directory naming
@@ -1037,16 +1079,53 @@ find_app_files() {
# Handle Preferences and ByHost variants (only if bundle_id is valid)
if [[ -n "$bundle_id" && "$bundle_id" != "unknown" && ${#bundle_id} -gt 3 ]]; then
[[ -f ~/Library/Preferences/"$bundle_id".plist ]] && files_to_clean+=("$HOME/Library/Preferences/$bundle_id.plist")
+ [[ -d ~/Library/Preferences/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/Preferences/$bundle_id")
[[ -d ~/Library/Preferences/ByHost ]] && while IFS= read -r -d '' pref; do
files_to_clean+=("$pref")
done < <(command find ~/Library/Preferences/ByHost -maxdepth 1 \( -name "$bundle_id*.plist" \) -print0 2> /dev/null)
+ # NSURLSession download caches
+ local nsurlsession_dl="$HOME/Library/Caches/com.apple.nsurlsessiond/Downloads/$bundle_id"
+ [[ -d "$nsurlsession_dl" ]] && files_to_clean+=("$nsurlsession_dl")
+
# Group Containers (special handling)
if [[ -d ~/Library/Group\ Containers ]]; then
while IFS= read -r -d '' container; do
files_to_clean+=("$container")
done < <(command find ~/Library/Group\ Containers -maxdepth 1 \( -name "*$bundle_id*" \) -print0 2> /dev/null)
fi
+
+ # App extensions often use bundle-id-derived directories rather than the
+ # main bundle id exactly, for example share extensions or file providers.
+ local -a derived_bundle_roots=(
+ "$HOME/Library/Application Scripts"
+ "$HOME/Library/Containers"
+ "$HOME/Library/Application Support/FileProvider"
+ )
+ local derived_root=""
+ local derived_path=""
+ local existing_path=""
+ local already_added=false
+ for derived_root in "${derived_bundle_roots[@]}"; do
+ [[ -d "$derived_root" ]] || continue
+ while IFS= read -r -d '' derived_path; do
+ already_added=false
+ for existing_path in "${files_to_clean[@]}"; do
+ if [[ "$existing_path" == "$derived_path" ]]; then
+ already_added=true
+ break
+ fi
+ done
+ [[ "$already_added" == "true" ]] || files_to_clean+=("$derived_path")
+ done < <(command find "$derived_root" -maxdepth 1 -type d -name "*$bundle_id*" -print0 2> /dev/null)
+ done
+ fi
+
+ # Shared file lists (.sfl4 - recent documents etc.)
+ if [[ -n "$bundle_id" && "$bundle_id" != "unknown" ]] && [[ -d "$HOME/Library/Application Support/com.apple.sharedfilelist" ]]; then
+ while IFS= read -r -d '' sfl4_file; do
+ files_to_clean+=("$sfl4_file")
+ done < <(command find "$HOME/Library/Application Support/com.apple.sharedfilelist" -maxdepth 2 -name "${bundle_id}.sfl4" -print0 2> /dev/null)
fi
# Launch Agents by name (special handling)
@@ -1187,7 +1266,7 @@ get_diagnostic_report_paths_for_app() {
*) continue ;;
esac
case "$base" in
- *.ips | *.crash | *.spin) ;;
+ *.ips | *.crash | *.spin | *.diag) ;;
*) continue ;;
esac
printf '%s\n' "$f"
@@ -1233,6 +1312,10 @@ find_app_system_files() {
"/Library/Screen Savers/$app_name.saver"
"/Library/Caches/$bundle_id"
"/Library/Caches/$app_name"
+ "/Library/Extensions/$app_name.kext"
+ "/Library/StartupItems/$app_name"
+ "/Library/Logs/$app_name"
+ "/Library/Logs/$bundle_id"
)
# Add all naming variants for apps with spaces in name
@@ -1264,6 +1347,21 @@ find_app_system_files() {
system_files+=("$p")
done
+ # System LaunchAgents/LaunchDaemons often use bundle-id-derived helper
+ # labels (for example ".ProxyConfigHelper.plist"), so scan for
+ # validated reverse-DNS bundle-id prefixes before falling back to app name.
+ # The two -name patterns are anchored at the dot boundary so that, e.g.,
+ # bundle "com.foo" matches "com.foo.plist" and "com.foo.helper.plist" but
+ # NOT "com.foobar.plist" from an unrelated vendor.
+ if [[ -n "$bundle_id" && "$bundle_id" != "unknown" &&
+ "$bundle_id" =~ ^[a-zA-Z0-9][-a-zA-Z0-9]*(\.[a-zA-Z0-9][-a-zA-Z0-9]*)+$ ]]; then
+ for base in /Library/LaunchAgents /Library/LaunchDaemons; do
+ [[ -d "$base" ]] && while IFS= read -r -d '' plist; do
+ system_files+=("$plist")
+ done < <(command find "$base" -maxdepth 1 \( -name "${bundle_id}.plist" -o -name "${bundle_id}.*.plist" \) -print0 2> /dev/null)
+ done
+ fi
+
# System LaunchAgents/LaunchDaemons by name
if [[ ${#app_name} -gt 3 ]]; then
for base in /Library/LaunchAgents /Library/LaunchDaemons; do
diff --git a/Resources/mole/lib/core/base.sh b/Resources/mole/lib/core/base.sh
index 5479fa3..53519e4 100644
--- a/Resources/mole/lib/core/base.sh
+++ b/Resources/mole/lib/core/base.sh
@@ -78,6 +78,8 @@ readonly MOLE_SAVED_STATE_AGE_DAYS=30 # Saved state retention (days) - increa
readonly MOLE_TM_BACKUP_SAFE_HOURS=48 # TM backup safety window (hours)
readonly MOLE_MAX_DS_STORE_FILES=500 # Max .DS_Store files to clean per scan
readonly MOLE_MAX_ORPHAN_ITERATIONS=100 # Max iterations for orphaned app data scan
+readonly MOLE_ONE_GIB_KB=$((1024 * 1024))
+readonly MOLE_ONE_GB_BYTES=1000000000
# ============================================================================
# Whitelist Configuration
@@ -96,6 +98,7 @@ declare -a DEFAULT_WHITELIST_PATTERNS=(
"$HOME/Library/Caches/pypoetry/virtualenvs*"
"$HOME/Library/Caches/JetBrains*"
"$HOME/Library/Caches/com.jetbrains.toolbox*"
+ "$HOME/Library/Caches/tealdeer/tldr-pages"
"$HOME/Library/Application Support/JetBrains*"
"$HOME/Library/Caches/com.apple.finder"
"$HOME/Library/Mobile Documents*"
@@ -547,6 +550,11 @@ bytes_to_human_kb() {
bytes_to_human "$((${1:-0} * 1024))"
}
+# Pick a cleanup result color using the displayed decimal 1 GB threshold.
+cleanup_result_color_kb() {
+ printf '%s' "$GREEN"
+}
+
# ============================================================================
# Temporary File Management
# ============================================================================
@@ -555,10 +563,93 @@ bytes_to_human_kb() {
declare -a MOLE_TEMP_FILES=()
declare -a MOLE_TEMP_DIRS=()
+normalize_temp_root() {
+ local path="${1:-}"
+ [[ -z "$path" ]] && return 1
+
+ if [[ "$path" == "~"* ]]; then
+ path="${path/#\~/$HOME}"
+ fi
+
+ while [[ "$path" != "/" && "$path" == */ ]]; do
+ path="${path%/}"
+ done
+
+ [[ -n "$path" ]] || return 1
+ printf '%s\n' "$path"
+}
+
+probe_temp_root() {
+ local raw_path="$1"
+ local allow_create="${2:-false}"
+ local path
+ local probe=""
+
+ path=$(normalize_temp_root "$raw_path") || return 1
+
+ if [[ "$allow_create" == "true" ]]; then
+ ensure_user_dir "$path"
+ fi
+
+ [[ -d "$path" ]] || return 1
+
+ probe=$(mktemp "$path/mole.probe.XXXXXX" 2> /dev/null) || return 1
+ rm -f "$probe" 2> /dev/null || true
+
+ printf '%s\n' "$path"
+}
+
+ensure_mole_temp_root() {
+ if [[ -n "${MOLE_RESOLVED_TMPDIR:-}" ]]; then
+ return 0
+ fi
+
+ local resolved=""
+ local candidate="${TMPDIR:-}"
+ local invoking_home=""
+
+ if [[ -n "$candidate" ]]; then
+ resolved=$(probe_temp_root "$candidate" false || true)
+ fi
+
+ if [[ -z "$resolved" ]]; then
+ invoking_home=$(get_invoking_home)
+ if [[ -n "$invoking_home" ]]; then
+ resolved=$(probe_temp_root "$invoking_home/.cache/mole/tmp" true || true)
+ fi
+ fi
+
+ if [[ -z "$resolved" ]]; then
+ resolved=$(probe_temp_root "/tmp" false || true)
+ fi
+
+ [[ -n "$resolved" ]] || resolved="/tmp"
+ MOLE_RESOLVED_TMPDIR="$resolved"
+ export MOLE_RESOLVED_TMPDIR
+}
+
+get_mole_temp_root() {
+ ensure_mole_temp_root
+ printf '%s\n' "$MOLE_RESOLVED_TMPDIR"
+}
+
+prepare_mole_tmpdir() {
+ ensure_mole_temp_root
+ export TMPDIR="$MOLE_RESOLVED_TMPDIR"
+ printf '%s\n' "$MOLE_RESOLVED_TMPDIR"
+}
+
+mole_temp_path_template() {
+ local prefix="${1:-mole}"
+ ensure_mole_temp_root
+ printf '%s/%s.XXXXXX\n' "$MOLE_RESOLVED_TMPDIR" "$prefix"
+}
+
# Create tracked temporary file
create_temp_file() {
local temp
- temp=$(mktemp) || return 1
+ ensure_mole_temp_root
+ temp=$(mktemp "$MOLE_RESOLVED_TMPDIR/mole.XXXXXX") || return 1
register_temp_file "$temp"
echo "$temp"
}
@@ -566,7 +657,8 @@ create_temp_file() {
# Create tracked temporary directory
create_temp_dir() {
local temp
- temp=$(mktemp -d) || return 1
+ ensure_mole_temp_root
+ temp=$(mktemp -d "$MOLE_RESOLVED_TMPDIR/mole.XXXXXX") || return 1
register_temp_dir "$temp"
echo "$temp"
}
@@ -587,9 +679,8 @@ mktemp_file() {
local prefix="${1:-mole}"
local temp
local error_msg
- # Use TMPDIR if set, otherwise /tmp
# Add .XXXXXX suffix to work with both BSD and GNU mktemp
- if ! error_msg=$(mktemp "${TMPDIR:-/tmp}/${prefix}.XXXXXX" 2>&1); then
+ if ! error_msg=$(mktemp "$(mole_temp_path_template "$prefix")" 2>&1); then
echo "Error: Failed to create temporary file: $error_msg" >&2
return 1
fi
@@ -600,7 +691,9 @@ mktemp_file() {
# Cleanup all tracked temp files and directories
cleanup_temp_files() {
- stop_inline_spinner || true
+ if declare -F stop_inline_spinner > /dev/null 2>&1; then
+ stop_inline_spinner || true
+ fi
local file
if [[ ${#MOLE_TEMP_FILES[@]} -gt 0 ]]; then
for file in "${MOLE_TEMP_FILES[@]}"; do
diff --git a/Resources/mole/lib/core/bundle_resolver.sh b/Resources/mole/lib/core/bundle_resolver.sh
new file mode 100644
index 0000000..b4bad7e
--- /dev/null
+++ b/Resources/mole/lib/core/bundle_resolver.sh
@@ -0,0 +1,80 @@
+#!/bin/bash
+# Mole - Bundle ID resolution.
+# Resolves whether a bundle ID belongs to an installed application on this system.
+# Spotlight (mdfind) is unreliable: indexing can be off for /Applications, Homebrew
+# installs sometimes skip metadata importers, and Spotlight rarely indexes helpers
+# embedded inside .app bundles. This resolver falls back to a direct filesystem
+# scan that reads each app's Info.plist and checks SMJobBless-registered helpers.
+
+if [[ -n "${_MOLE_BUNDLE_RESOLVER_LOADED:-}" ]]; then
+ return 0
+fi
+readonly _MOLE_BUNDLE_RESOLVER_LOADED=1
+
+# Standard locations for installed apps on macOS. Overridable from tests.
+_MOLE_BUNDLE_RESOLVER_APP_ROOTS=(
+ "/Applications"
+ "/Applications/Setapp"
+ "/Applications/Utilities"
+ "$HOME/Applications"
+)
+
+# Return 0 if some installed app either has the given CFBundleIdentifier, or
+# registers a privileged helper with that ID via SMJobBless
+# (Contents/Library/LaunchServices/). Return 1 otherwise.
+#
+# Intended for orphan/stale detection: answering "is this launchagent or
+# privileged helper associated with an app that still exists on disk?"
+bundle_has_installed_app() {
+ local bundle_id="$1"
+ [[ -z "$bundle_id" ]] && return 1
+
+ # Reject obviously malformed IDs to avoid feeding junk into mdfind/find.
+ [[ "$bundle_id" =~ ^[a-zA-Z0-9._-]+$ ]] || return 1
+
+ # Fast path: Spotlight. Gated with a timeout because mdfind has been known
+ # to wedge on misconfigured indexes.
+ if command -v mdfind > /dev/null 2>&1; then
+ local hit
+ if declare -f run_with_timeout > /dev/null 2>&1; then
+ hit=$(run_with_timeout 2 mdfind "kMDItemCFBundleIdentifier == '$bundle_id'" 2> /dev/null | head -1)
+ else
+ hit=$(mdfind "kMDItemCFBundleIdentifier == '$bundle_id'" 2> /dev/null | head -1)
+ fi
+ [[ -n "$hit" ]] && return 0
+ fi
+
+ # Slow path: walk known app roots. Reads each Info.plist CFBundleIdentifier
+ # and checks for an SMJobBless helper registered under this bundle ID. This
+ # covers the two classes of false positive we saw:
+ # - App-owned launch agents whose bundle ID Spotlight failed to index
+ # (e.g. org.keepassxc.KeePassXC from Homebrew) -- issue #732
+ # - Privileged helpers embedded in a parent .app under
+ # Contents/Library/LaunchServices/ (e.g. the Adobe
+ # ARMDC helpers shipped inside Adobe Acrobat DC.app) -- issue #733
+ local parent_id=""
+ local suffix
+ for suffix in ".helper" ".daemon" ".agent" ".xpc"; do
+ if [[ "$bundle_id" == *"$suffix" ]]; then
+ parent_id="${bundle_id%"$suffix"}"
+ break
+ fi
+ done
+
+ local app_root app info app_bundle
+ for app_root in "${_MOLE_BUNDLE_RESOLVER_APP_ROOTS[@]}"; do
+ [[ -d "$app_root" ]] || continue
+ while IFS= read -r -d '' app; do
+ if [[ -e "$app/Contents/Library/LaunchServices/$bundle_id" ]]; then
+ return 0
+ fi
+ info="$app/Contents/Info.plist"
+ [[ -f "$info" ]] || continue
+ app_bundle=$(plutil -extract CFBundleIdentifier raw "$info" 2> /dev/null || echo "")
+ [[ "$app_bundle" == "$bundle_id" ]] && return 0
+ [[ -n "$parent_id" && "$app_bundle" == "$parent_id" ]] && return 0
+ done < <(find "$app_root" -maxdepth 1 -name "*.app" -print0 2> /dev/null)
+ done
+
+ return 1
+}
diff --git a/Resources/mole/lib/core/common.sh b/Resources/mole/lib/core/common.sh
index 38f7640..4afac04 100755
--- a/Resources/mole/lib/core/common.sh
+++ b/Resources/mole/lib/core/common.sh
@@ -14,6 +14,7 @@ _MOLE_CORE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Load core modules
source "$_MOLE_CORE_DIR/base.sh"
+prepare_mole_tmpdir > /dev/null
source "$_MOLE_CORE_DIR/log.sh"
source "$_MOLE_CORE_DIR/timeout.sh"
@@ -21,12 +22,52 @@ source "$_MOLE_CORE_DIR/file_ops.sh"
source "$_MOLE_CORE_DIR/help.sh"
source "$_MOLE_CORE_DIR/ui.sh"
source "$_MOLE_CORE_DIR/app_protection.sh"
+source "$_MOLE_CORE_DIR/bundle_resolver.sh"
# Load sudo management if available
if [[ -f "$_MOLE_CORE_DIR/sudo.sh" ]]; then
source "$_MOLE_CORE_DIR/sudo.sh"
fi
+# Normalize a path for comparisons while preserving root.
+mole_normalize_path() {
+ local path="$1"
+ local normalized="${path%/}"
+ [[ -n "$normalized" ]] && printf '%s\n' "$normalized" || printf '%s\n' "$path"
+}
+
+# Return a stable identity for an existing path. Prefer dev+inode so aliased
+# paths on case-insensitive filesystems or symlinks collapse to one identity.
+mole_path_identity() {
+ local path="$1"
+ local normalized
+ normalized=$(mole_normalize_path "$path")
+
+ if [[ -e "$normalized" || -L "$normalized" ]]; then
+ if command -v stat > /dev/null 2>&1; then
+ local fs_id=""
+ fs_id=$(stat -L -f '%d:%i' "$normalized" 2> /dev/null || stat -f '%d:%i' "$normalized" 2> /dev/null || true)
+ if [[ "$fs_id" =~ ^[0-9]+:[0-9]+$ ]]; then
+ printf 'inode:%s\n' "$fs_id"
+ return 0
+ fi
+ fi
+ fi
+
+ printf 'path:%s\n' "$normalized"
+}
+
+mole_identity_in_list() {
+ local needle="$1"
+ shift
+
+ local existing
+ for existing in "$@"; do
+ [[ "$existing" == "$needle" ]] && return 0
+ done
+ return 1
+}
+
# Update via Homebrew
update_via_homebrew() {
local current_version="$1"
diff --git a/Resources/mole/lib/core/file_ops.sh b/Resources/mole/lib/core/file_ops.sh
index 5c41618..4a5863f 100644
--- a/Resources/mole/lib/core/file_ops.sh
+++ b/Resources/mole/lib/core/file_ops.sh
@@ -92,7 +92,10 @@ validate_path_for_deletion() {
# Validate resolved target against protected paths
if [[ -n "$resolved_target" ]]; then
case "$resolved_target" in
- /System/* | /usr/bin/* | /usr/lib/* | /bin/* | /sbin/* | /private/etc/*)
+ / | /System | /System/* | /bin | /bin/* | /sbin | /sbin/* | \
+ /usr | /usr/bin | /usr/bin/* | /usr/lib | /usr/lib/* | \
+ /etc | /etc/* | /private/etc | /private/etc/* | \
+ /Library/Extensions | /Library/Extensions/*)
log_error "Symlink points to protected system path: $path -> $resolved_target"
return 1
;;
@@ -399,6 +402,193 @@ safe_sudo_remove() {
esac
}
+# ============================================================================
+# Unified deletion helper (Trash + permanent routing with forensic log)
+# ============================================================================
+
+# Route a deletion through either macOS Trash or permanent rm, while logging
+# every call for forensic review. Designed for destructive paths where undo
+# matters (e.g. uninstall). Not used by cache-clean paths.
+#
+# Usage: mole_delete [needs_sudo=false]
+#
+# Environment:
+# MOLE_DELETE_MODE "permanent" (default) or "trash"
+# MOLE_DRY_RUN=1 Log intent, do not delete
+# MOLE_TEST_TRASH_DIR Test-only override; Trash moves go here via `mv`
+# instead of Finder/trash CLI. Required for bats.
+# MOLE_DELETE_LOG Override the log file path (default:
+# ~/Library/Logs/mole/deletions.log)
+#
+# Returns 0 on success, 1 on failure. Always appends a tab-separated line to
+# the deletions log: \t\t\t\t.
+# size_kb is "unknown" when du could not measure the path (permission denied,
+# disappeared mid-call); never silently coerced to 0KB so post-hoc forensics
+# can tell measured-zero from measurement-failure.
+mole_delete() {
+ local path="$1"
+ local needs_sudo="${2:-false}"
+ local mode="${MOLE_DELETE_MODE:-permanent}"
+
+ [[ -z "$path" ]] && return 1
+
+ # Nothing to do if path does not exist (but a broken symlink still counts).
+ if [[ ! -e "$path" && ! -L "$path" ]]; then
+ return 0
+ fi
+
+ # Validation is delegated to the underlying safe_* helpers (which call
+ # validate_path_for_deletion). Trash routing only applies to paths the
+ # user could legitimately restore from, so we short-circuit invalid paths
+ # up front to avoid a no-op Trash move followed by a validation failure.
+ # The rejection itself is recorded in the forensic log so audit trails
+ # can distinguish refused-by-policy from never-attempted.
+ if [[ ! -L "$path" ]] && ! validate_path_for_deletion "$path"; then
+ _mole_delete_log "$mode" "0" "rejected" "$path"
+ return 1
+ fi
+
+ # Capture size before the delete so the log line is still useful when the
+ # path is gone afterwards. Use "unknown" (not 0) on failure so the log
+ # never lies about a multi-GB delete by recording it as 0KB.
+ local size_kb="unknown"
+ if [[ -e "$path" ]]; then
+ local raw_size=""
+ local du_rc=0
+ if [[ "$needs_sudo" == "true" ]]; then
+ raw_size=$(sudo du -skP "$path" 2> /dev/null | awk '{print $1; exit}')
+ du_rc=${PIPESTATUS[0]}
+ else
+ raw_size=$(get_path_size_kb "$path" 2> /dev/null) || du_rc=$?
+ fi
+ if [[ "$du_rc" -eq 0 && "$raw_size" =~ ^[0-9]+$ ]]; then
+ size_kb="$raw_size"
+ fi
+ fi
+
+ if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
+ debug_log "[DRY RUN] Would delete ($mode): $path"
+ _mole_delete_log "$mode" "$size_kb" "dry-run" "$path"
+ return 0
+ fi
+
+ # Trash mode: attempt Trash move first, fall through to permanent removal
+ # on failure so destructive operations never get silently skipped.
+ if [[ "$mode" == "trash" ]]; then
+ if _mole_move_to_trash "$path" "$needs_sudo"; then
+ _mole_delete_log "trash" "$size_kb" "ok" "$path"
+ log_operation "${MOLE_CURRENT_COMMAND:-uninstall}" "TRASHED" "$path" "${size_kb}KB"
+ return 0
+ fi
+ # User explicitly chose Trash for recoverability. Surface the fallback
+ # to permanent rm once per session so they know an "undo" isn't there.
+ if [[ -z "${_MOLE_TRASH_FALLBACK_WARNED:-}" ]]; then
+ _MOLE_TRASH_FALLBACK_WARNED=1
+ export _MOLE_TRASH_FALLBACK_WARNED
+ printf 'Warning: Trash unavailable, removing permanently. Subsequent files this session also bypass Trash.\n' >&2
+ fi
+ debug_log "Trash move failed, falling back to permanent delete: $path"
+ fi
+
+ # Permanent path. Delegate to the existing safe_* helpers so path
+ # validation, sudo handling, and existing log_operation calls remain
+ # unchanged for callers that have always gone through rm -rf.
+ local rc=0
+ if [[ -L "$path" ]]; then
+ safe_remove_symlink "$path" "$needs_sudo" || rc=$?
+ elif [[ "$needs_sudo" == "true" ]]; then
+ safe_sudo_remove "$path" || rc=$?
+ else
+ safe_remove "$path" "true" || rc=$?
+ fi
+
+ local status_label="ok"
+ [[ $rc -ne 0 ]] && status_label="error"
+ # Mark the trash-mode fallback so forensics can tell why rm was used.
+ if [[ "$mode" == "trash" && "$status_label" == "ok" ]]; then
+ status_label="trash-fallback-rm"
+ fi
+ _mole_delete_log "$mode" "$size_kb" "$status_label" "$path"
+ return "$rc"
+}
+
+# Move a path to the macOS Trash. Test harnesses set MOLE_TEST_TRASH_DIR to
+# redirect the move to a tmpdir, avoiding any Finder/osascript interaction.
+_mole_move_to_trash() {
+ local path="$1"
+ local needs_sudo="${2:-false}"
+
+ if [[ -n "${MOLE_TEST_TRASH_DIR:-}" ]]; then
+ mkdir -p "$MOLE_TEST_TRASH_DIR" 2> /dev/null || return 1
+ local dest="$MOLE_TEST_TRASH_DIR/$(basename "$path").$$.$(date +%s 2> /dev/null || echo 0)"
+ mv "$path" "$dest" 2> /dev/null
+ return $?
+ fi
+
+ # Blocked in test mode so uninstall tests never hit Finder/AppleScript.
+ if [[ "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then
+ return 1
+ fi
+
+ # Prefer the `trash` CLI (Homebrew formula) when available — it's faster
+ # and does not need Finder running. Fall back to AppleScript, which
+ # ships with macOS but prompts for auth on root-owned targets.
+ if command -v trash > /dev/null 2>&1; then
+ if [[ "$needs_sudo" == "true" ]]; then
+ sudo trash "$path" > /dev/null 2>&1 && return 0
+ else
+ trash "$path" > /dev/null 2>&1 && return 0
+ fi
+ fi
+
+ # AppleScript fallback. Pass the path via argv so special chars (quotes,
+ # backslashes) cannot break out of the quoted string.
+ osascript - "$path" > /dev/null 2>&1 << 'APPLESCRIPT'
+on run argv
+ set p to POSIX file (item 1 of argv)
+ tell application "Finder"
+ delete p
+ end tell
+end run
+APPLESCRIPT
+}
+
+_mole_delete_log() {
+ local mode="$1"
+ local size_kb="$2"
+ local status="$3"
+ local target="$4"
+
+ local log_file="${MOLE_DELETE_LOG:-$HOME/Library/Logs/mole/deletions.log}"
+ local log_dir
+ log_dir=$(dirname "$log_file")
+
+ # Surface log-write failures once per session. The deletions log is the
+ # only audit trail for Trash-routed removals; silently no-oping when the
+ # log dir is unwritable (root-owned from prior sudo, ENOSPC, read-only
+ # volume) defeats the design.
+ if ! mkdir -p "$log_dir" 2> /dev/null; then
+ _mole_warn_log_broken "create directory: $log_dir"
+ return 0
+ fi
+
+ local ts
+ ts=$(date '+%Y-%m-%dT%H:%M:%S%z' 2> /dev/null || echo "unknown")
+
+ if ! printf '%s\t%s\t%s\t%s\t%s\n' \
+ "$ts" "$mode" "$size_kb" "$status" "$target" \
+ >> "$log_file" 2> /dev/null; then
+ _mole_warn_log_broken "write to: $log_file"
+ fi
+}
+
+_mole_warn_log_broken() {
+ [[ -n "${_MOLE_DELETE_LOG_WARNED:-}" ]] && return 0
+ _MOLE_DELETE_LOG_WARNED=1
+ export _MOLE_DELETE_LOG_WARNED
+ printf 'Warning: deletions audit log unavailable (%s). Forensic trail incomplete this session.\n' "$1" >&2
+}
+
# ============================================================================
# Safe Find and Delete Operations
# ============================================================================
@@ -434,11 +624,17 @@ safe_find_delete() {
find_args+=("-mtime" "+$age_days")
fi
- # Iterate results to respect should_protect_path
+ # Iterate results to respect both system protection and user whitelist.
+ # Per-caller whitelist gates were missed in past releases (see #710, #724,
+ # #738, #744, #757); enforcing here makes the protection structural so
+ # new clean_* functions get whitelist enforcement for free.
while IFS= read -r -d '' match; do
if should_protect_path "$match"; then
continue
fi
+ if declare -f is_path_whitelisted > /dev/null && is_path_whitelisted "$match"; then
+ continue
+ fi
safe_remove "$match" true || true
done < <(command find "$base_dir" "${find_args[@]}" -print0 2> /dev/null || true)
@@ -481,11 +677,15 @@ safe_sudo_find_delete() {
find_args+=("-mtime" "+$age_days")
fi
- # Iterate results to respect should_protect_path
+ # Iterate results to respect both system protection and user whitelist.
+ # See safe_find_delete for rationale (#757).
while IFS= read -r -d '' match; do
if should_protect_path "$match"; then
continue
fi
+ if declare -f is_path_whitelisted > /dev/null && is_path_whitelisted "$match"; then
+ continue
+ fi
safe_sudo_remove "$match" || true
done < <(sudo find "$base_dir" "${find_args[@]}" -print0 2> /dev/null || true)
diff --git a/Resources/mole/lib/core/help.sh b/Resources/mole/lib/core/help.sh
index 6deb945..3c29277 100644
--- a/Resources/mole/lib/core/help.sh
+++ b/Resources/mole/lib/core/help.sh
@@ -3,10 +3,11 @@
show_clean_help() {
echo "Usage: mo clean [OPTIONS]"
echo ""
- echo "Clean up disk space by removing caches, logs, and temporary files."
+ echo "Clean up disk space by removing caches, logs, temporary files, and app leftovers from already-uninstalled apps."
echo ""
echo "Options:"
echo " --dry-run, -n Preview cleanup without making changes"
+ echo " --external PATH Clean OS metadata from a mounted external volume"
echo " --whitelist Manage protected paths"
echo " --debug Show detailed operation logs"
echo " -h, --help Show this help message"
@@ -53,13 +54,27 @@ show_touchid_help() {
}
show_uninstall_help() {
- echo "Usage: mo uninstall [OPTIONS]"
+ echo "Usage: mo uninstall [OPTIONS] [APP_NAME ...]"
echo ""
echo "Interactively remove applications and their leftover files."
+ echo "Optionally specify one or more app names to uninstall directly."
+ echo "For leftovers from apps that are already gone, use mo clean."
+ echo ""
+ echo "Examples:"
+ echo " mo uninstall Open interactive app selector"
+ echo " mo uninstall slack Uninstall Slack"
+ echo " mo uninstall slack zoom Uninstall Slack and Zoom"
+ echo " mo uninstall --dry-run slack Preview Slack uninstallation"
+ echo " mo uninstall --list Show installed apps and the names mo uninstall accepts"
echo ""
echo "Options:"
+ echo " --list List installed apps with the exact name mo uninstall accepts"
echo " --dry-run Preview app uninstallation without making changes"
+ echo " --permanent Bypass macOS Trash and rm -rf immediately"
echo " --whitelist Not supported for uninstall (use clean/optimize)"
echo " --debug Show detailed operation logs"
echo " -h, --help Show this help message"
+ echo ""
+ echo "By default, uninstalled files go to the macOS Trash so they can be"
+ echo "recovered. Use --permanent to skip the Trash step."
}
diff --git a/Resources/mole/lib/core/log.sh b/Resources/mole/lib/core/log.sh
index 95d92ce..3092257 100644
--- a/Resources/mole/lib/core/log.sh
+++ b/Resources/mole/lib/core/log.sh
@@ -21,9 +21,9 @@ fi
# Logging Configuration
# ============================================================================
-readonly LOG_FILE="${HOME}/.config/mole/mole.log"
-readonly DEBUG_LOG_FILE="${HOME}/.config/mole/mole_debug_session.log"
-readonly OPERATIONS_LOG_FILE="${HOME}/.config/mole/operations.log"
+readonly LOG_FILE="${HOME}/Library/Logs/mole/mole.log"
+readonly DEBUG_LOG_FILE="${HOME}/Library/Logs/mole/mole_debug_session.log"
+readonly OPERATIONS_LOG_FILE="${HOME}/Library/Logs/mole/operations.log"
readonly LOG_MAX_SIZE_DEFAULT=1048576 # 1MB
readonly OPLOG_MAX_SIZE_DEFAULT=5242880 # 5MB
@@ -37,6 +37,22 @@ fi
# Log Rotation
# ============================================================================
+append_log_line() {
+ local file_path="$1"
+ local line="${2:-}"
+
+ ensure_user_file "$file_path"
+ printf '%s\n' "$line" >> "$file_path" 2> /dev/null || true
+}
+
+append_log_lines() {
+ local file_path="$1"
+ shift
+
+ ensure_user_file "$file_path"
+ printf '%s\n' "$@" >> "$file_path" 2> /dev/null || true
+}
+
# Rotate log file if it exceeds maximum size
rotate_log_once() {
# Skip if already checked this session
@@ -81,9 +97,9 @@ log_info() {
echo -e "${BLUE}$1${NC}"
local timestamp
timestamp=$(get_timestamp)
- echo "[$timestamp] INFO: $1" >> "$LOG_FILE" 2> /dev/null || true
+ append_log_line "$LOG_FILE" "[$timestamp] INFO: $1"
if [[ "${MO_DEBUG:-}" == "1" ]]; then
- echo "[$timestamp] INFO: $1" >> "$DEBUG_LOG_FILE" 2> /dev/null || true
+ append_log_line "$DEBUG_LOG_FILE" "[$timestamp] INFO: $1"
fi
}
@@ -92,9 +108,9 @@ log_success() {
echo -e " ${GREEN}${ICON_SUCCESS}${NC} $1"
local timestamp
timestamp=$(get_timestamp)
- echo "[$timestamp] SUCCESS: $1" >> "$LOG_FILE" 2> /dev/null || true
+ append_log_line "$LOG_FILE" "[$timestamp] SUCCESS: $1"
if [[ "${MO_DEBUG:-}" == "1" ]]; then
- echo "[$timestamp] SUCCESS: $1" >> "$DEBUG_LOG_FILE" 2> /dev/null || true
+ append_log_line "$DEBUG_LOG_FILE" "[$timestamp] SUCCESS: $1"
fi
}
@@ -103,9 +119,9 @@ log_warning() {
echo -e "${YELLOW}$1${NC}"
local timestamp
timestamp=$(get_timestamp)
- echo "[$timestamp] WARNING: $1" >> "$LOG_FILE" 2> /dev/null || true
+ append_log_line "$LOG_FILE" "[$timestamp] WARNING: $1"
if [[ "${MO_DEBUG:-}" == "1" ]]; then
- echo "[$timestamp] WARNING: $1" >> "$DEBUG_LOG_FILE" 2> /dev/null || true
+ append_log_line "$DEBUG_LOG_FILE" "[$timestamp] WARNING: $1"
fi
}
@@ -114,9 +130,9 @@ log_error() {
echo -e "${YELLOW}${ICON_ERROR}${NC} $1" >&2
local timestamp
timestamp=$(get_timestamp)
- echo "[$timestamp] ERROR: $1" >> "$LOG_FILE" 2> /dev/null || true
+ append_log_line "$LOG_FILE" "[$timestamp] ERROR: $1"
if [[ "${MO_DEBUG:-}" == "1" ]]; then
- echo "[$timestamp] ERROR: $1" >> "$DEBUG_LOG_FILE" 2> /dev/null || true
+ append_log_line "$DEBUG_LOG_FILE" "[$timestamp] ERROR: $1"
fi
}
@@ -126,7 +142,7 @@ debug_log() {
echo -e "${GRAY}[DEBUG]${NC} $*" >&2
local timestamp
timestamp=$(get_timestamp)
- echo "[$timestamp] DEBUG: $*" >> "$DEBUG_LOG_FILE" 2> /dev/null || true
+ append_log_line "$DEBUG_LOG_FILE" "[$timestamp] DEBUG: $*"
fi
}
@@ -163,7 +179,7 @@ log_operation() {
local log_line="[$timestamp] [$command] $action $path"
[[ -n "$detail" ]] && log_line+=" ($detail)"
- echo "$log_line" >> "$OPERATIONS_LOG_FILE" 2> /dev/null || true
+ append_log_line "$OPERATIONS_LOG_FILE" "$log_line"
}
# Log session start marker
@@ -175,10 +191,10 @@ log_operation_session_start() {
local timestamp
timestamp=$(get_timestamp)
- {
- echo ""
- echo "# ========== $command session started at $timestamp =========="
- } >> "$OPERATIONS_LOG_FILE" 2> /dev/null || true
+ append_log_lines \
+ "$OPERATIONS_LOG_FILE" \
+ "" \
+ "# ========== $command session started at $timestamp =========="
}
# shellcheck disable=SC2329
@@ -198,9 +214,9 @@ log_operation_session_end() {
size_human="0B"
fi
- {
- echo "# ========== $command session ended at $timestamp, $items items, $size_human =========="
- } >> "$OPERATIONS_LOG_FILE" 2> /dev/null || true
+ append_log_line \
+ "$OPERATIONS_LOG_FILE" \
+ "# ========== $command session ended at $timestamp, $items items, $size_human =========="
}
# Enhanced debug logging for operations
@@ -214,11 +230,18 @@ debug_operation_start() {
[[ -n "$operation_desc" ]] && echo -e "${GRAY}[DEBUG] $operation_desc${NC}" >&2
# Also log to file
- {
- echo ""
- echo "=== $operation_name ==="
- [[ -n "$operation_desc" ]] && echo "Description: $operation_desc"
- } >> "$DEBUG_LOG_FILE" 2> /dev/null || true
+ if [[ -n "$operation_desc" ]]; then
+ append_log_lines \
+ "$DEBUG_LOG_FILE" \
+ "" \
+ "=== $operation_name ===" \
+ "Description: $operation_desc"
+ else
+ append_log_lines \
+ "$DEBUG_LOG_FILE" \
+ "" \
+ "=== $operation_name ==="
+ fi
fi
}
@@ -232,7 +255,7 @@ debug_operation_detail() {
echo -e "${GRAY}[DEBUG] $detail_type: $detail_value${NC}" >&2
# Also log to file
- echo "$detail_type: $detail_value" >> "$DEBUG_LOG_FILE" 2> /dev/null || true
+ append_log_line "$DEBUG_LOG_FILE" "$detail_type: $detail_value"
fi
}
@@ -252,7 +275,7 @@ debug_file_action() {
echo -e "${GRAY}[DEBUG] $action: $msg${NC}" >&2
# Also log to file
- echo "$action: $msg" >> "$DEBUG_LOG_FILE" 2> /dev/null || true
+ append_log_line "$DEBUG_LOG_FILE" "$action: $msg"
fi
}
@@ -303,8 +326,10 @@ log_system_info() {
fi
echo "Shell: ${SHELL:-unknown}, ${TERM:-unknown}"
- # Check sudo status non-interactively
- if sudo -n true 2> /dev/null; then
+ # Check sudo status non-interactively (skip in test mode)
+ if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then
+ echo "Sudo Access: Skipped (test mode)"
+ elif sudo -n true 2> /dev/null; then
echo "Sudo Access: Active"
else
echo "Sudo Access: Required"
diff --git a/Resources/mole/lib/core/sudo.sh b/Resources/mole/lib/core/sudo.sh
index 483497d..cb44042 100644
--- a/Resources/mole/lib/core/sudo.sh
+++ b/Resources/mole/lib/core/sudo.sh
@@ -44,58 +44,24 @@ is_clamshell_mode() {
_request_password() {
local tty_path="$1"
- local attempts=0
- local show_hint=true
- # Extra safety: ensure sudo cache is cleared before password input
sudo -k 2> /dev/null
- # Save original terminal settings and ensure they're restored on exit
local stty_orig
stty_orig=$(stty -g < "$tty_path" 2> /dev/null || echo "")
trap '[[ -n "${stty_orig:-}" ]] && stty "${stty_orig:-}" < "$tty_path" 2> /dev/null || true' RETURN
- while ((attempts < 3)); do
- local password=""
-
- # Show hint on first attempt about Touch ID appearing again
- if [[ $show_hint == true ]] && check_touchid_support; then
- echo -e "${GRAY}Note: Touch ID dialog may appear once more, just cancel it${NC}" > "$tty_path"
- show_hint=false
- fi
-
- printf "${PURPLE}${ICON_ARROW}${NC} Password: " > "$tty_path"
-
- # Disable terminal echo to hide password input (keep canonical mode for reliable input)
- stty -echo < "$tty_path" 2> /dev/null || true
- IFS= read -r password < "$tty_path" || password=""
- # Restore terminal echo immediately
- stty echo < "$tty_path" 2> /dev/null || true
-
- printf "\n" > "$tty_path"
-
- if [[ -z "$password" ]]; then
- unset password
- attempts=$((attempts + 1))
- if [[ $attempts -lt 3 ]]; then
- echo -e "${GRAY}${ICON_WARNING}${NC} Password cannot be empty" > "$tty_path"
- fi
- continue
- fi
+ if check_touchid_support; then
+ echo -e "${GRAY}Note: Touch ID dialog may appear once more, just cancel it${NC}" > "$tty_path"
+ fi
- # Verify password with sudo
- # NOTE: macOS PAM will trigger Touch ID before password auth - this is system behavior
- if printf '%s\n' "$password" | sudo -S -p "" -v > /dev/null 2>&1; then
- unset password
- return 0
- fi
+ echo -e "${PURPLE}${ICON_ARROW}${NC} Enter your credentials:" > "$tty_path"
- unset password
- attempts=$((attempts + 1))
- if [[ $attempts -lt 3 ]]; then
- echo -e "${GRAY}${ICON_WARNING}${NC} Incorrect password, try again" > "$tty_path"
- fi
- done
+ # shellcheck disable=SC2024,SC2094
+ # Intentionally route sudo's native prompt to the same TTY device it reads from.
+ if sudo -v < "$tty_path" > /dev/null 2> "$tty_path"; then
+ return 0
+ fi
return 1
}
@@ -108,6 +74,11 @@ request_sudo_access() {
return 0
fi
+ # Tests must never trigger real password or Touch ID prompts.
+ if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then
+ return 1
+ fi
+
# Detect if running in TTY environment
local tty_path="/dev/tty"
local is_gui_mode=false
@@ -149,10 +120,14 @@ request_sudo_access() {
# Check if in clamshell mode - if yes, skip Touch ID entirely
if is_clamshell_mode; then
+ local clear_lines=3
+ if check_touchid_support; then
+ clear_lines=4
+ fi
echo -e "${PURPLE}${ICON_ARROW}${NC} ${prompt_msg}"
if _request_password "$tty_path"; then
# Clear all prompt lines (use safe clearing method)
- safe_clear_lines 3 "$tty_path"
+ safe_clear_lines "$clear_lines" "$tty_path"
return 0
fi
return 1
@@ -299,6 +274,11 @@ ensure_sudo_session() {
return 0
fi
+ if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then
+ MOLE_SUDO_ESTABLISHED="false"
+ return 1
+ fi
+
# Stop old keepalive if exists
if [[ -n "$MOLE_SUDO_KEEPALIVE_PID" ]]; then
_stop_sudo_keepalive "$MOLE_SUDO_KEEPALIVE_PID"
diff --git a/Resources/mole/lib/core/timeout.sh b/Resources/mole/lib/core/timeout.sh
index edd7051..06bde93 100644
--- a/Resources/mole/lib/core/timeout.sh
+++ b/Resources/mole/lib/core/timeout.sh
@@ -55,6 +55,11 @@ if [[ -z "${MO_TIMEOUT_INITIALIZED:-}" ]]; then
echo "[TIMEOUT] Install coreutils for better reliability: brew install coreutils" >&2
fi
+ # Export so child processes inherit detected values and skip re-detection.
+ # Without this, children that inherit MO_TIMEOUT_INITIALIZED=1 skip the init
+ # block but have empty bin vars, forcing the slow shell fallback.
+ export MO_TIMEOUT_BIN
+ export MO_TIMEOUT_PERL_BIN
export MO_TIMEOUT_INITIALIZED=1
fi
@@ -181,7 +186,10 @@ run_with_timeout() {
"$@" &
local cmd_pid=$!
- # Start timeout killer in background
+ # Start timeout killer in background.
+ # Redirect all FDs to /dev/null so orphaned child processes (e.g. sleep $duration)
+ # do not inherit open file descriptors from the caller and block output pipes
+ # (notably bats output capture pipes that wait for all writers to close).
(
# Wait for timeout duration
sleep "$duration"
@@ -200,7 +208,7 @@ run_with_timeout() {
kill -KILL -"$cmd_pid" 2> /dev/null || kill -KILL "$cmd_pid" 2> /dev/null || true
fi
fi
- ) &
+ ) < /dev/null > /dev/null 2>&1 &
local killer_pid=$!
# Wait for command to complete
diff --git a/Resources/mole/lib/core/ui.sh b/Resources/mole/lib/core/ui.sh
index 421d29a..3f2a2f9 100755
--- a/Resources/mole/lib/core/ui.sh
+++ b/Resources/mole/lib/core/ui.sh
@@ -223,6 +223,7 @@ read_key() {
'q' | 'Q') echo "QUIT" ;;
'R') echo "RETRY" ;;
'm' | 'M') echo "MORE" ;;
+ 'v' | 'V') echo "VERSION" ;;
'u' | 'U') echo "UPDATE" ;;
't' | 'T') echo "TOUCHID" ;;
'j' | 'J') echo "DOWN" ;;
@@ -324,7 +325,8 @@ start_inline_spinner() {
if [[ -t 1 ]]; then
# Create unique stop flag file for this spinner instance
- INLINE_SPINNER_STOP_FILE="${TMPDIR:-/tmp}/mole_spinner_$$_$RANDOM.stop"
+ ensure_mole_temp_root
+ INLINE_SPINNER_STOP_FILE="$MOLE_RESOLVED_TMPDIR/mole_spinner_$$_$RANDOM.stop"
(
local stop_file="$INLINE_SPINNER_STOP_FILE"
@@ -342,7 +344,7 @@ start_inline_spinner() {
# Output to stderr to avoid interfering with stdout
printf "\r${MOLE_SPINNER_PREFIX:-}${BLUE}%s${NC} %s" "$c" "$display_message" >&2 || break
i=$((i + 1))
- sleep 0.05
+ /bin/sleep 0.05
done
# Clean up stop file before exiting
@@ -366,7 +368,7 @@ stop_inline_spinner() {
# Wait briefly for cooperative exit
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
+ /bin/sleep 0.05 2> /dev/null || true
wait_count=$((wait_count + 1))
done
diff --git a/Resources/mole/lib/manage/purge_paths.sh b/Resources/mole/lib/manage/purge_paths.sh
index aa34819..8ffb34b 100644
--- a/Resources/mole/lib/manage/purge_paths.sh
+++ b/Resources/mole/lib/manage/purge_paths.sh
@@ -12,15 +12,13 @@ if [[ -z "${PURGE_TARGETS:-}" ]]; then
source "$_MOLE_MANAGE_DIR/../clean/project.sh"
fi
-# Config file path (use :- to avoid re-declaration if already set)
-PURGE_PATHS_CONFIG="${PURGE_PATHS_CONFIG:-$HOME/.config/mole/purge_paths}"
+# Config file path (prefer the shared project constant when available)
+PURGE_PATHS_CONFIG="${PURGE_PATHS_CONFIG:-${PURGE_CONFIG_FILE:-$HOME/.config/mole/purge_paths}}"
# Ensure config file exists with helpful template
ensure_config_template() {
if [[ ! -f "$PURGE_PATHS_CONFIG" ]]; then
- ensure_user_dir "$(dirname "$PURGE_PATHS_CONFIG")"
- cat > "$PURGE_PATHS_CONFIG" << 'EOF'
-# Mole Purge Paths - Directories to scan for project artifacts
+ if ! write_purge_config "# Mole Purge Paths - Directories to scan for project artifacts
# Add one path per line (supports ~ for home directory)
# Delete all paths or this file to use defaults
#
@@ -28,7 +26,9 @@ ensure_config_template() {
# ~/Documents/MyProjects
# ~/Work/ClientA
# ~/Work/ClientB
-EOF
+"; then
+ echo -e "${YELLOW}${ICON_WARNING}${NC} Could not initialize ${PURGE_PATHS_CONFIG/#$HOME/~}" >&2
+ fi
fi
}
diff --git a/Resources/mole/lib/manage/whitelist.sh b/Resources/mole/lib/manage/whitelist.sh
index 41259ac..5ab7984 100755
--- a/Resources/mole/lib/manage/whitelist.sh
+++ b/Resources/mole/lib/manage/whitelist.sh
@@ -120,10 +120,12 @@ npm package cache|$HOME/.npm/_cacache/*|package_manager
pip Python package cache|$HOME/.cache/pip/*|package_manager
uv Python package cache|$HOME/.cache/uv/*|package_manager
R renv global cache (virtual environments)|$HOME/Library/Caches/org.R-project.R/R/renv/*|package_manager
+tealdeer tldr pages cache|$HOME/Library/Caches/tealdeer/tldr-pages|package_manager
Homebrew downloaded packages|$HOME/Library/Caches/Homebrew/*|package_manager
Yarn package manager cache|$HOME/.cache/yarn/*|package_manager
pnpm package store|$HOME/Library/pnpm/store/*|package_manager
-Composer PHP dependencies cache|$HOME/.composer/cache/*|package_manager
+Composer PHP dependencies cache (legacy)|$HOME/.composer/cache/*|package_manager
+Composer PHP dependencies cache|$HOME/Library/Caches/composer/*|package_manager
RubyGems cache|$HOME/.gem/cache/*|package_manager
Conda packages cache|$HOME/.conda/pkgs/*|package_manager
Anaconda packages cache|$HOME/anaconda3/pkgs/*|package_manager
@@ -140,12 +142,14 @@ Firefox browser cache|$HOME/Library/Caches/Firefox/*|browser_cache
Brave browser cache|$HOME/Library/Caches/BraveSoftware/Brave-Browser/*|browser_cache
Surge proxy cache|$HOME/Library/Caches/com.nssurge.surge-mac/*|network_tools
Surge configuration and data|$HOME/Library/Application Support/com.nssurge.surge-mac/*|network_tools
-Docker Desktop image cache|$HOME/Library/Containers/com.docker.docker/Data/*|container_cache
+Docker BuildX cache|$HOME/.docker/buildx/cache/*|container_cache
Podman container cache|$HOME/.local/share/containers/cache/*|container_cache
Font cache|$HOME/Library/Caches/com.apple.FontRegistry/*|system_cache
Spotlight metadata cache|$HOME/Library/Caches/com.apple.spotlight/*|system_cache
CloudKit cache|$HOME/Library/Caches/CloudKit/*|system_cache
Trash|$HOME/.Trash|system_cache
+iOS/iPadOS device firmware (.ipsw) from iTunes/Finder|$HOME/Library/iTunes/*Software Updates/*.ipsw|system_cache
+Apple Configurator 2 device firmware (.ipsw)|$HOME/Library/Group Containers/*.group.com.apple.configurator/**/*.ipsw|system_cache
EOF
# Add FINDER_METADATA with constant reference
echo "Finder metadata, .DS_Store|$FINDER_METADATA_SENTINEL|system_cache"
diff --git a/Resources/mole/lib/optimize/tasks.sh b/Resources/mole/lib/optimize/tasks.sh
index 0f69863..37fb789 100644
--- a/Resources/mole/lib/optimize/tasks.sh
+++ b/Resources/mole/lib/optimize/tasks.sh
@@ -252,6 +252,59 @@ opt_network_optimization() {
fi
}
+# Quarantine database cleanup (Gatekeeper download history).
+opt_quarantine_cleanup() {
+ if [[ "${MO_DEBUG:-}" == "1" ]]; then
+ debug_operation_start "Quarantine Database Cleanup" "Clear Gatekeeper download tracking history"
+ debug_operation_detail "Method" "DELETE + VACUUM on QuarantineEventsV2 SQLite database"
+ debug_operation_detail "Safety" "Only clears download tracking metadata, does not affect file quarantine flags"
+ debug_operation_detail "Expected outcome" "Reduced database size, cleared download tracking history"
+ debug_risk_level "LOW" "Database is automatically recreated by macOS"
+ fi
+
+ if ! command -v sqlite3 > /dev/null 2>&1; then
+ echo -e " ${GRAY}-${NC} Quarantine cleanup skipped, sqlite3 unavailable"
+ return 0
+ fi
+
+ local quarantine_db="$HOME/Library/Preferences/com.apple.LaunchServices.QuarantineEventsV2"
+
+ if [[ ! -f "$quarantine_db" ]]; then
+ opt_msg "Quarantine database already clean"
+ return 0
+ fi
+
+ if should_protect_path "$quarantine_db"; then
+ opt_msg "Quarantine database already clean"
+ return 0
+ fi
+
+ # Check if database has any entries worth cleaning.
+ local row_count
+ row_count=$(run_with_timeout 5 sqlite3 "$quarantine_db" "SELECT COUNT(*) FROM LSQuarantineEvent;" 2> /dev/null || echo "0")
+
+ if [[ ! "$row_count" =~ ^[0-9]+$ ]] || [[ "$row_count" -eq 0 ]]; then
+ opt_msg "Quarantine database already clean"
+ return 0
+ fi
+
+ if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then
+ local exit_code=0
+ set +e
+ run_with_timeout 10 sqlite3 "$quarantine_db" "DELETE FROM LSQuarantineEvent; VACUUM;" 2> /dev/null
+ exit_code=$?
+ set -e
+
+ if [[ $exit_code -eq 0 ]]; then
+ opt_msg "Quarantine history cleared ($row_count entries)"
+ else
+ echo -e " ${YELLOW}${ICON_WARNING}${NC} Failed to clean quarantine database"
+ fi
+ else
+ opt_msg "Quarantine history cleared ($row_count entries)"
+ fi
+}
+
# SQLite vacuum for Mail/Messages/Safari (safety checks applied).
opt_sqlite_vacuum() {
if [[ "${MO_DEBUG:-}" == "1" ]]; then
@@ -445,11 +498,27 @@ opt_launch_services_rebuild() {
}
# Font cache rebuild.
+browser_family_is_running() {
+ local browser_name="$1"
+
+ case "$browser_name" in
+ "Firefox")
+ pgrep -if "Firefox|org\\.mozilla\\.firefox|firefox .*contentproc|firefox .*plugin-container|firefox .*crashreporter" > /dev/null 2>&1
+ ;;
+ "Zen Browser")
+ pgrep -if "Zen Browser|org\\.mozilla\\.zen|Zen Browser Helper|zen .*contentproc" > /dev/null 2>&1
+ ;;
+ *)
+ pgrep -ix "$browser_name" > /dev/null 2>&1
+ ;;
+ esac
+}
+
opt_font_cache_rebuild() {
if [[ "${MO_DEBUG:-}" == "1" ]]; then
debug_operation_start "Font Cache Rebuild" "Clear and rebuild font cache"
debug_operation_detail "Method" "Run atsutil databases -remove"
- debug_operation_detail "Safety checks" "Skip when browsers are running to avoid cache rebuild conflicts"
+ debug_operation_detail "Safety checks" "Skip when browsers or browser helpers are running to avoid cache rebuild conflicts"
debug_operation_detail "Expected outcome" "Fixed font display issues, removed corrupted font cache"
debug_risk_level "LOW" "System automatically rebuilds font database"
fi
@@ -457,15 +526,13 @@ opt_font_cache_rebuild() {
local success=false
if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then
- # Some browsers (notably Firefox) can keep stale GPU/text caches in /var/folders if
- # system font databases are reset while browser/helper processes are still running.
+ # Some browsers can keep stale GPU/text caches in /var/folders if system font
+ # databases are reset while browser/helper processes are still running.
local -a running_browsers=()
- if pgrep -if "Firefox|org\\.mozilla\\.firefox|firefox-gpu-helper" > /dev/null 2>&1; then
- running_browsers+=("Firefox")
- fi
local browser_name
local -a browser_checks=(
+ "Firefox"
"Safari"
"Google Chrome"
"Chromium"
@@ -478,7 +545,7 @@ opt_font_cache_rebuild() {
"Helium"
)
for browser_name in "${browser_checks[@]}"; do
- if pgrep -ix "$browser_name" > /dev/null 2>&1; then
+ if browser_family_is_running "$browser_name"; then
running_browsers+=("$browser_name")
fi
done
@@ -487,8 +554,7 @@ opt_font_cache_rebuild() {
local running_list
running_list=$(printf "%s, " "${running_browsers[@]}")
running_list="${running_list%, }"
- echo -e " ${YELLOW}${ICON_WARNING}${NC} Skipped font cache rebuild because browsers are running: ${running_list}"
- echo -e " ${GRAY}${ICON_REVIEW}${NC} ${GRAY}Quit browsers completely, then rerun optimize if font issues persist${NC}"
+ echo -e " ${YELLOW}${ICON_WARNING}${NC} Font cache rebuild skipped · ${running_list} still running"
return 0
fi
@@ -641,6 +707,7 @@ opt_bluetooth_reset() {
fi
local spinner_started="false"
+ local disconnect_notice="Bluetooth devices may disconnect briefly during refresh"
if [[ -t 1 ]]; then
MOLE_SPINNER_PREFIX=" " start_inline_spinner "Checking Bluetooth..."
spinner_started="true"
@@ -688,13 +755,14 @@ opt_bluetooth_reset() {
fi
if sudo pkill -TERM bluetoothd > /dev/null 2>&1; then
+ if [[ "$spinner_started" == "true" ]]; then
+ stop_inline_spinner
+ fi
+ echo -e " ${GRAY}${ICON_WARNING}${NC} ${GRAY}${disconnect_notice}${NC}"
sleep 1
if pgrep -x bluetoothd > /dev/null 2>&1; then
sudo pkill -KILL bluetoothd > /dev/null 2>&1 || true
fi
- if [[ "$spinner_started" == "true" ]]; then
- stop_inline_spinner
- fi
opt_msg "Bluetooth module restarted"
opt_msg "Connectivity issues resolved"
else
@@ -707,6 +775,7 @@ opt_bluetooth_reset() {
if [[ "$spinner_started" == "true" ]]; then
stop_inline_spinner
fi
+ echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} ${disconnect_notice}"
opt_msg "Bluetooth module restarted"
opt_msg "Connectivity issues resolved"
fi
@@ -789,6 +858,346 @@ opt_dock_refresh() {
opt_msg "Dock refreshed"
}
+# Prevent .DS_Store on network and USB volumes.
+# Idempotent: writes two user defaults that stop Finder from creating
+# .DS_Store files on SMB/AFP/NFS shares and removable USB volumes.
+# Reversible with: defaults delete com.apple.desktopservices DSDontWrite{Network,USB}Stores
+opt_prevent_network_dsstore() {
+ local domain="com.apple.desktopservices"
+ local -a keys=("DSDontWriteNetworkStores" "DSDontWriteUSBStores")
+ local changed=0
+ local already=0
+
+ for key in "${keys[@]}"; do
+ local current
+ current=$(defaults read "$domain" "$key" 2> /dev/null || echo "")
+ if [[ "$current" == "1" ]]; then
+ already=$((already + 1))
+ continue
+ fi
+
+ if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
+ changed=$((changed + 1))
+ continue
+ fi
+
+ if defaults write "$domain" "$key" -bool true 2> /dev/null; then
+ changed=$((changed + 1))
+ fi
+ done
+
+ if [[ $changed -eq 0 && $already -gt 0 ]]; then
+ opt_msg ".DS_Store prevention already enabled on network & USB volumes"
+ return 0
+ fi
+
+ if [[ $changed -gt 0 ]]; then
+ opt_msg ".DS_Store prevention enabled on network & USB volumes"
+ fi
+}
+
+# Broken LaunchAgent cleanup.
+opt_launch_agents_cleanup() {
+ local agents_dir="$HOME/Library/LaunchAgents"
+
+ if [[ ! -d "$agents_dir" ]]; then
+ opt_msg "Launch Agents all healthy"
+ return 0
+ fi
+
+ local broken_count=0
+ local -a broken_plists=()
+
+ for plist in "$agents_dir"/*.plist; do
+ [[ -f "$plist" ]] || continue
+
+ local binary=""
+ binary=$(/usr/libexec/PlistBuddy -c "Print :ProgramArguments:0" "$plist" 2> /dev/null || true)
+ if [[ -z "$binary" ]]; then
+ binary=$(/usr/libexec/PlistBuddy -c "Print :Program" "$plist" 2> /dev/null || true)
+ fi
+
+ if [[ -n "$binary" && ! -e "$binary" ]]; then
+ broken_count=$((broken_count + 1))
+ broken_plists+=("$plist")
+ fi
+ done
+
+ if [[ $broken_count -eq 0 ]]; then
+ opt_msg "Launch Agents all healthy"
+ return 0
+ fi
+
+ for plist in "${broken_plists[@]}"; do
+ run_launchctl_unload "$plist"
+ safe_remove "$plist" true > /dev/null 2>&1 || true
+ done
+
+ opt_msg "Cleaned $broken_count broken Launch Agent(s)"
+}
+
+# macOS periodic maintenance scripts (daily/weekly/monthly).
+# Log path is configurable via MOLE_PERIODIC_LOG for testing; defaults to /var/log/daily.out.
+# A missing log file is treated as stale and triggers maintenance.
+opt_periodic_maintenance() {
+ local daily_log="${MOLE_PERIODIC_LOG:-/var/log/daily.out}"
+ local stale_days=7
+
+ if [[ -f "$daily_log" ]]; then
+ local last_mod now age_days
+ last_mod=$(stat -f %m "$daily_log" 2> /dev/null || echo "0")
+ now=$(get_epoch_seconds)
+ age_days=$(((now - last_mod) / 86400))
+
+ if [[ $age_days -lt $stale_days ]]; then
+ opt_msg "Periodic maintenance already current (${age_days}d ago)"
+ return 0
+ fi
+ fi
+
+ if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then
+ if ! sudo -n true 2> /dev/null; then
+ opt_msg "Periodic maintenance skipped (requires sudo)"
+ return 0
+ fi
+ # Capture stderr so --debug can surface the real failure reason
+ # (missing /etc/periodic scripts, SIP, broken launchd, etc.).
+ local periodic_output rc
+ if periodic_output=$(sudo periodic daily weekly monthly 2>&1); then
+ opt_msg "Periodic maintenance triggered"
+ else
+ rc=$?
+ echo -e " ${YELLOW}${ICON_WARNING}${NC} Failed to run periodic maintenance (exit=$rc)"
+ if [[ -n "$periodic_output" ]]; then
+ debug_log "periodic stderr: $periodic_output"
+ fi
+ fi
+ else
+ opt_msg "Periodic maintenance triggered"
+ fi
+}
+
+# Repair corrupted shared file list databases (Finder favorites, recent docs).
+opt_shared_file_list_repair() {
+ local sfl_dir="$HOME/Library/Application Support/com.apple.sharedfilelist"
+ if [[ ! -d "$sfl_dir" ]]; then
+ opt_msg "Shared file lists directory not found"
+ return 0
+ fi
+
+ local repaired=0
+ while IFS= read -r sfl_file; do
+ [[ -f "$sfl_file" ]] || continue
+ # Skip recent-documents list (user data, not a cache)
+ [[ "$sfl_file" == *"ApplicationRecentDocuments"* ]] && continue
+ if ! plutil -lint "$sfl_file" > /dev/null 2>&1; then
+ if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then
+ safe_remove "$sfl_file" true > /dev/null 2>&1 || true
+ fi
+ repaired=$((repaired + 1))
+ fi
+ done < <(command find "$sfl_dir" \( -name "*.sfl2" -o -name "*.sfl3" \) -type f 2> /dev/null || true)
+
+ if [[ $repaired -gt 0 ]]; then
+ opt_msg "Repaired $repaired corrupted shared file list(s)"
+ else
+ opt_msg "Shared file lists all healthy"
+ fi
+}
+
+# Clean old delivered notifications from NotificationCenter database.
+opt_notification_cleanup() {
+ local nc_db_dir
+ nc_db_dir="$(getconf DARWIN_USER_DIR 2> /dev/null || true)/com.apple.notificationcenter/db2"
+ local nc_db="$nc_db_dir/db"
+
+ if [[ ! -f "$nc_db" ]]; then
+ opt_msg "Notification Center database not found"
+ return 0
+ fi
+
+ local db_size
+ db_size=$(command du -sk "$nc_db" 2> /dev/null | awk '{print $1}')
+ db_size=${db_size:-0}
+
+ # Only clean if database exceeds 50MB (51200 KB)
+ if [[ $db_size -lt 51200 ]]; then
+ opt_msg "Notification Center database is healthy ($(bytes_to_human $((db_size * 1024))))"
+ return 0
+ fi
+
+ if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then
+ if command -v sqlite3 > /dev/null 2>&1; then
+ local sql_ok=0
+ sqlite3 "$nc_db" \
+ "DELETE FROM record WHERE delivered_date < strftime('%s','now','-30 days'); VACUUM;" \
+ 2> /dev/null || sql_ok=$?
+ if [[ $sql_ok -eq 0 ]]; then
+ killall NotificationCenter 2> /dev/null || true
+ opt_msg "Notification Center database cleaned (was $(bytes_to_human $((db_size * 1024))))"
+ else
+ echo -e " ${YELLOW}${ICON_WARNING}${NC} Notification Center cleanup skipped (database busy or locked)"
+ fi
+ else
+ echo -e " ${YELLOW}${ICON_WARNING}${NC} sqlite3 not available"
+ fi
+ else
+ opt_msg "Notification Center database cleaned (was $(bytes_to_human $((db_size * 1024))))"
+ fi
+}
+
+# Verify filesystem integrity via diskutil.
+# Disabled by default: diskutil verifyVolume triggers kernel-level I/O that
+# cannot be interrupted by SIGKILL when the volume has APFS inconsistencies,
+# causing the system to freeze. Set MOLE_ENABLE_DISK_VERIFY=1 to opt in.
+opt_disk_verify() {
+ if [[ "${MOLE_ENABLE_DISK_VERIFY:-0}" != "1" ]]; then
+ opt_msg "Disk verify skipped (set MOLE_ENABLE_DISK_VERIFY=1 to enable)"
+ return 0
+ fi
+
+ if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
+ opt_msg "Disk verify · skipped in dry-run"
+ return 0
+ fi
+
+ if [[ -t 1 ]]; then
+ MOLE_SPINNER_PREFIX=" " start_inline_spinner "Verifying disk filesystem..."
+ fi
+ local output
+ output=$(run_with_timeout 30 diskutil verifyVolume / 2>&1 || true)
+ if [[ -t 1 ]]; then
+ stop_inline_spinner
+ fi
+
+ if echo "$output" | grep -qi "appears to be OK\|volume appears to be ok"; then
+ opt_msg "Disk filesystem verified OK"
+ elif echo "$output" | grep -qi "error\|corrupt\|invalid"; then
+ echo -e " ${YELLOW}${ICON_WARNING}${NC} Disk issues detected · run: sudo diskutil repairVolume /"
+ else
+ opt_msg "Disk verify complete"
+ fi
+}
+
+# Clean Knowledge/CoreDuet usage tracking databases.
+opt_coreduet_cleanup() {
+ local knowledge_dir="$HOME/Library/Application Support/Knowledge"
+ local knowledge_db="$knowledge_dir/knowledgeC.db"
+
+ if [[ ! -f "$knowledge_db" ]]; then
+ opt_msg "Knowledge database not found"
+ return 0
+ fi
+
+ # Check combined size of WAL/SHM files + database
+ local wal_file="$knowledge_db-wal"
+ local shm_file="$knowledge_db-shm"
+ local total_size=0
+
+ for f in "$knowledge_db" "$wal_file" "$shm_file"; do
+ if [[ -f "$f" ]]; then
+ local fsize
+ fsize=$(command du -sk "$f" 2> /dev/null | awk '{print $1}')
+ total_size=$((total_size + ${fsize:-0}))
+ fi
+ done
+
+ # Skip if combined size < 100MB (102400 KB)
+ if [[ $total_size -lt 102400 ]]; then
+ opt_msg "Knowledge database is healthy ($(bytes_to_human $((total_size * 1024))))"
+ return 0
+ fi
+
+ if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then
+ # Remove WAL and SHM files safely (auto-regenerated by SQLite)
+ for f in "$wal_file" "$shm_file"; do
+ [[ -f "$f" ]] && safe_remove "$f" true > /dev/null 2>&1 || true
+ done
+ # Remove ZOBJECT entries older than 90 days (CoreTime is Mac epoch: seconds since 2001-01-01)
+ if command -v sqlite3 > /dev/null 2>&1; then
+ local sql_ok=0
+ sqlite3 "$knowledge_db" \
+ "DELETE FROM ZOBJECT WHERE ZCREATIONDATE < (strftime('%s','now','-90 days') - strftime('%s','2001-01-01')); VACUUM;" \
+ 2> /dev/null || sql_ok=$?
+ if [[ $sql_ok -eq 0 ]]; then
+ opt_msg "Knowledge database cleaned (was $(bytes_to_human $((total_size * 1024))))"
+ else
+ echo -e " ${YELLOW}${ICON_WARNING}${NC} Knowledge database cleanup skipped (database busy or locked)"
+ fi
+ else
+ echo -e " ${YELLOW}${ICON_WARNING}${NC} sqlite3 not available"
+ fi
+ else
+ opt_msg "Knowledge database cleaned (was $(bytes_to_human $((total_size * 1024))))"
+ fi
+}
+
+# Audit login items for broken entries referencing missing apps.
+# Check if a login item name corresponds to an installed app.
+# Login item names often differ from .app bundle names (e.g. "AliLangClient" -> "AliLang.app",
+# "Top Calendar" -> "TopCalendar.app"), so we try multiple matching strategies.
+_login_item_app_exists() {
+ local name="$1"
+ # 1. Exact match
+ if mdfind "kMDItemFSName == '${name}.app'" 2> /dev/null | grep -q .; then
+ return 0
+ fi
+ # 2. Try without spaces (e.g. "Top Calendar" -> "TopCalendar")
+ local nospace="${name// /}"
+ if [[ "$nospace" != "$name" ]] && mdfind "kMDItemFSName == '${nospace}.app'" 2> /dev/null | grep -q .; then
+ return 0
+ fi
+ # 3. Strip common helper suffixes (e.g. "AliLangClient" -> "AliLang")
+ local stripped
+ stripped=$(echo "$nospace" | sed -E 's/(Client|Helper|Agent|Launcher|Service)$//')
+ if [[ "$stripped" != "$nospace" ]] && mdfind "kMDItemFSName == '${stripped}.app'" 2> /dev/null | grep -q .; then
+ return 0
+ fi
+ return 1
+}
+
+opt_login_items_audit() {
+ if [[ "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then
+ opt_msg "Login items audit · skipped in test mode"
+ return 0
+ fi
+
+ local items_output
+ items_output=$(osascript -e 'tell application "System Events" to get the name of every login item' 2> /dev/null || true)
+
+ if [[ -z "$items_output" ]]; then
+ opt_msg "No login items found"
+ return 0
+ fi
+
+ local broken=0
+ local checked=0
+ # Split on ", " (comma-space) to preserve multi-word names like "Top Calendar" and "mihomo-party"
+ local old_ifs="$IFS"
+ IFS=',' read -ra items_list <<< "$items_output"
+ IFS="$old_ifs"
+ for item in "${items_list[@]}"; do
+ # Strip leading/trailing spaces from each token
+ item="${item# }"
+ item="${item% }"
+ [[ -z "$item" ]] && continue
+ # Skip items with single quotes to avoid breaking the mdfind query string
+ [[ "$item" == *"'"* ]] && continue
+ checked=$((checked + 1))
+ if _login_item_app_exists "$item"; then
+ continue
+ fi
+ echo -e " ${YELLOW}${ICON_WARNING}${NC} Broken login item: $item (app not found)"
+ broken=$((broken + 1))
+ done
+
+ if [[ $broken -eq 0 ]]; then
+ opt_msg "Login items all healthy ($checked checked)"
+ else
+ echo -e " ${YELLOW}${ICON_WARNING}${NC} $broken broken login item(s) · remove via System Settings > General > Login Items"
+ fi
+}
+
# Dispatch optimization by action name.
execute_optimization() {
local action="$1"
@@ -800,15 +1209,24 @@ execute_optimization() {
saved_state_cleanup) opt_saved_state_cleanup ;;
fix_broken_configs) opt_fix_broken_configs ;;
network_optimization) opt_network_optimization ;;
+ quarantine_cleanup) opt_quarantine_cleanup ;;
sqlite_vacuum) opt_sqlite_vacuum ;;
launch_services_rebuild) opt_launch_services_rebuild ;;
font_cache_rebuild) opt_font_cache_rebuild ;;
dock_refresh) opt_dock_refresh ;;
+ prevent_network_dsstore) opt_prevent_network_dsstore ;;
memory_pressure_relief) opt_memory_pressure_relief ;;
network_stack_optimize) opt_network_stack_optimize ;;
disk_permissions_repair) opt_disk_permissions_repair ;;
bluetooth_reset) opt_bluetooth_reset ;;
spotlight_index_optimize) opt_spotlight_index_optimize ;;
+ launch_agents_cleanup) opt_launch_agents_cleanup ;;
+ periodic_maintenance) opt_periodic_maintenance ;;
+ shared_file_list_repair) opt_shared_file_list_repair ;;
+ notification_cleanup) opt_notification_cleanup ;;
+ disk_verify) opt_disk_verify ;;
+ coreduet_cleanup) opt_coreduet_cleanup ;;
+ login_items_audit) opt_login_items_audit ;;
*)
echo -e "${YELLOW}${ICON_ERROR}${NC} Unknown action: $action"
return 1
diff --git a/Resources/mole/lib/ui/app_selector.sh b/Resources/mole/lib/ui/app_selector.sh
index add9015..3c9d070 100755
--- a/Resources/mole/lib/ui/app_selector.sh
+++ b/Resources/mole/lib/ui/app_selector.sh
@@ -17,8 +17,8 @@ format_app_display() {
fi
# Format size
- local size_str="N/A"
- [[ "$size" != "0" && "$size" != "" && "$size" != "Unknown" ]] && size_str="$size"
+ local size_str="--"
+ [[ "$size" != "0" && "$size" != "" && "$size" != "Unknown" && "$size" != "N/A" && "$size" != "--" ]] && size_str="$size"
# Calculate available width for app name based on terminal width
# Accept pre-calculated max_name_width (5th param) to avoid recalculation in loops
@@ -58,10 +58,19 @@ format_app_display() {
current_display_width=$(get_display_width "$truncated_name")
# Calculate padding needed
- # Formula: char_count + (available_width - display_width) = padding to add
- local char_count=${#truncated_name}
+ # printf counts bytes (in LC_ALL=C), not display width or char count.
+ # Get byte count for printf width calculation.
+ local old_lc="${LC_ALL:-}"
+ export LC_ALL=C
+ local byte_count=${#truncated_name}
+ if [[ -n "$old_lc" ]]; then
+ export LC_ALL="$old_lc"
+ else
+ unset LC_ALL
+ fi
+
local padding_needed=$((available_width - current_display_width))
- local printf_width=$((char_count + padding_needed))
+ local printf_width=$((byte_count + padding_needed))
# Use dynamic column width with corrected padding
printf "%-*s %9s | %s" "$printf_width" "$truncated_name" "$size_str" "$compact_last_used"
diff --git a/Resources/mole/lib/ui/menu_paginated.sh b/Resources/mole/lib/ui/menu_paginated.sh
index c241fc1..9212386 100755
--- a/Resources/mole/lib/ui/menu_paginated.sh
+++ b/Resources/mole/lib/ui/menu_paginated.sh
@@ -400,9 +400,9 @@ paginated_multi_select() {
draw_header() {
printf "\033[1;1H" >&2
if [[ -n "$filter_text" ]]; then
- printf "\r\033[2K${PURPLE_BOLD}%s${NC} ${YELLOW}/ Filter: ${filter_text}_${NC} ${GRAY}(%d/%d)${NC}\n" "${title}" "${#view_indices[@]}" "$total_items" >&2
+ printf "\r\033[2K${PURPLE_BOLD}%s${NC} ${YELLOW}/ Search: ${filter_text}_${NC} ${GRAY}(%d/%d)${NC}\n" "${title}" "${#view_indices[@]}" "$total_items" >&2
elif [[ -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then
- printf "\r\033[2K${PURPLE_BOLD}%s${NC} ${YELLOW}/ Filter: _ ${NC}${GRAY}(type to search)${NC}\n" "${title}" >&2
+ printf "\r\033[2K${PURPLE_BOLD}%s${NC} ${YELLOW}/ Search: _ ${NC}${GRAY}(type to search)${NC}\n" "${title}" >&2
else
printf "\r\033[2K${PURPLE_BOLD}%s${NC} ${GRAY}%d/%d selected${NC}\n" "${title}" "$selected_count" "$total_items" >&2
fi
@@ -511,7 +511,7 @@ paginated_multi_select() {
local sort_ctrl="${GRAY}S ${sort_status}${NC}"
local order_ctrl="${GRAY}O ${reverse_arrow}${NC}"
- local filter_ctrl="${GRAY}/ Filter${NC}"
+ local filter_ctrl="${GRAY}/ Search${NC}"
if [[ -n "$filter_text" ]]; then
local -a _segs_filter=("${GRAY}Backspace${NC}" "${GRAY}Ctrl+U Clear${NC}" "${GRAY}ESC Clear${NC}")
diff --git a/Resources/mole/lib/uninstall/batch.sh b/Resources/mole/lib/uninstall/batch.sh
index 8a22f9a..d93a4b2 100755
--- a/Resources/mole/lib/uninstall/batch.sh
+++ b/Resources/mole/lib/uninstall/batch.sh
@@ -11,14 +11,27 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
# Batch uninstall with a single confirmation.
-get_lsregister_path() {
- echo "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister"
-}
-
is_uninstall_dry_run() {
[[ "${MOLE_DRY_RUN:-0}" == "1" ]]
}
+app_declares_local_network_usage() {
+ local app_path="$1"
+ local info_plist="$app_path/Contents/Info.plist"
+
+ [[ -f "$info_plist" ]] || return 1
+
+ if plutil -extract NSLocalNetworkUsageDescription raw "$info_plist" > /dev/null 2>&1; then
+ return 0
+ fi
+
+ if plutil -extract NSBonjourServices xml1 -o - "$info_plist" > /dev/null 2>&1; then
+ return 0
+ fi
+
+ return 1
+}
+
# High-performance sensitive data detection (pure Bash, no subprocess)
# Faster than grep for batch operations, especially when processing many apps
has_sensitive_data() {
@@ -186,26 +199,29 @@ remove_login_item() {
# Remove from Login Items using index-based deletion (handles broken items)
if [[ -n "$clean_name" ]]; then
- # Escape double quotes and backslashes for AppleScript
- local escaped_name="${clean_name//\\/\\\\}"
- escaped_name="${escaped_name//\"/\\\"}"
-
- osascript <<- EOF > /dev/null 2>&1 || true
- tell application "System Events"
- try
- set itemCount to count of login items
- -- Delete in reverse order to avoid index shifting
- repeat with i from itemCount to 1 by -1
- try
- set itemName to name of login item i
- if itemName is "$escaped_name" then
- delete login item i
- end if
- end try
- end repeat
- end try
- end tell
- EOF
+ # Skip AppleScript during tests to avoid permission dialogs
+ if [[ "${MOLE_TEST_MODE:-0}" != "1" && "${MOLE_TEST_NO_AUTH:-0}" != "1" ]]; then
+ # Escape double quotes and backslashes for AppleScript
+ local escaped_name="${clean_name//\\/\\\\}"
+ escaped_name="${escaped_name//\"/\\\"}"
+
+ osascript <<- EOF > /dev/null 2>&1 || true
+ tell application "System Events"
+ try
+ set itemCount to count of login items
+ -- Delete in reverse order to avoid index shifting
+ repeat with i from itemCount to 1 by -1
+ try
+ set itemName to name of login item i
+ if itemName is "$escaped_name" then
+ delete login item i
+ end if
+ end try
+ end repeat
+ end try
+ end tell
+ EOF
+ fi
fi
}
@@ -223,19 +239,14 @@ remove_file_list() {
continue
fi
- if [[ -L "$file" ]]; then
- safe_remove_symlink "$file" "$use_sudo" && ((++count)) || true
+ if [[ "$use_sudo" == "true" ]] && is_uninstall_dry_run; then
+ debug_log "[DRY RUN] Would sudo remove: $file"
+ ((++count))
else
- if [[ "$use_sudo" == "true" ]]; then
- 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
+ # mole_delete routes through Trash when MOLE_DELETE_MODE=trash
+ # (uninstall default), falls back to the underlying safe_* helpers
+ # in permanent mode or when Trash is unavailable. See #723.
+ mole_delete "$file" "$use_sudo" && ((++count)) || true
fi
done <<< "$file_list"
@@ -282,6 +293,7 @@ batch_uninstall_applications() {
# Pre-scan: running apps, sudo needs, size.
local -a running_apps=()
local -a sudo_apps=()
+ local -a brew_cask_apps=()
local total_estimated_size=0
local -a app_details=()
@@ -319,6 +331,10 @@ batch_uninstall_applications() {
fi
fi
+ if [[ "$is_brew_cask" == "true" ]]; then
+ brew_cask_apps+=("$app_name")
+ fi
+
# Check if sudo is needed
local needs_sudo=false
local app_owner=$(get_file_owner "$app_path")
@@ -363,6 +379,11 @@ batch_uninstall_applications() {
has_sensitive_data="true"
fi
+ local has_local_network_usage="false"
+ if app_declares_local_network_usage "$app_path"; then
+ has_local_network_usage="true"
+ fi
+
# Store details for later use (base64 keeps lists on one line).
local encoded_files
encoded_files=$(printf '%s' "$related_files" | base64 | tr -d '\n' || echo "")
@@ -370,7 +391,7 @@ batch_uninstall_applications() {
encoded_system_files=$(printf '%s' "$system_files" | base64 | tr -d '\n' || echo "")
local encoded_diag_system
encoded_diag_system=$(printf '%s' "$diag_system" | base64 | tr -d '\n' || echo "")
- app_details+=("$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")
+ app_details+=("$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|$has_local_network_usage")
done
if [[ -t 1 ]]; then stop_inline_spinner; fi
@@ -380,10 +401,7 @@ batch_uninstall_applications() {
# Warn if brew cask apps are present.
local has_brew_cask=false
- for detail in "${app_details[@]}"; do
- IFS='|' read -r _ _ _ _ _ _ _ _ is_brew_cask_flag _ <<< "$detail"
- [[ "$is_brew_cask_flag" == "true" ]] && has_brew_cask=true
- done
+ [[ ${#brew_cask_apps[@]} -gt 0 ]] && has_brew_cask=true
if [[ "$has_brew_cask" == "true" ]]; then
echo -e "${GRAY}${ICON_WARNING} Homebrew apps will be fully cleaned, --zap removes configs and data${NC}"
@@ -392,7 +410,7 @@ batch_uninstall_applications() {
echo ""
for detail in "${app_details[@]}"; do
- IFS='|' read -r app_name app_path bundle_id total_kb encoded_files encoded_system_files has_sensitive_data needs_sudo_flag is_brew_cask cask_name encoded_diag_system <<< "$detail"
+ IFS='|' read -r app_name app_path bundle_id total_kb encoded_files encoded_system_files has_sensitive_data needs_sudo_flag is_brew_cask cask_name encoded_diag_system has_local_network_usage <<< "$detail"
local app_size_display=$(bytes_to_human "$((total_kb * 1024))")
local brew_tag=""
@@ -464,11 +482,19 @@ batch_uninstall_applications() {
# that user explicitly chose to uninstall. System-critical components remain protected.
export MOLE_UNINSTALL_MODE=1
- # Request sudo if needed for non-Homebrew removal operations.
- # Note: Homebrew resets sudo timestamp at process startup, so pre-auth would
- # cause duplicate password prompts in cask-only flows.
- if [[ ${#sudo_apps[@]} -gt 0 && "${MOLE_DRY_RUN:-0}" != "1" ]]; then
- if ! ensure_sudo_session "Admin required for system apps: ${sudo_apps[*]}"; then
+ # Establish sudo once before uninstalling apps that need admin access.
+ # Homebrew cask removal can prompt via sudo during uninstall hooks, which
+ # does not work reliably under Mole's timed non-interactive execution path.
+ if [[ "${MOLE_DRY_RUN:-0}" != "1" ]] &&
+ { [[ ${#sudo_apps[@]} -gt 0 ]] || [[ ${#brew_cask_apps[@]} -gt 0 ]]; }; then
+ local admin_prompt="Admin required to uninstall selected apps"
+ if [[ ${#sudo_apps[@]} -gt 0 && ${#brew_cask_apps[@]} -eq 0 ]]; then
+ admin_prompt="Admin required for system apps: ${sudo_apps[*]}"
+ elif [[ ${#brew_cask_apps[@]} -gt 0 && ${#sudo_apps[@]} -eq 0 ]]; then
+ admin_prompt="Admin required for Homebrew casks: ${brew_cask_apps[*]}"
+ fi
+
+ if ! ensure_sudo_session "$admin_prompt"; then
echo ""
log_error "Admin access denied"
_restore_uninstall_traps
@@ -481,10 +507,12 @@ batch_uninstall_applications() {
local brew_apps_removed=0 # Track successful brew uninstalls for silent autoremove
local -a failed_items=()
local -a success_items=()
+ local -a local_network_warning_apps=()
+ local -a system_extension_warning_apps=()
local current_index=0
for detail in "${app_details[@]}"; do
current_index=$((current_index + 1))
- IFS='|' read -r app_name app_path bundle_id total_kb encoded_files encoded_system_files has_sensitive_data needs_sudo is_brew_cask cask_name encoded_diag_system <<< "$detail"
+ IFS='|' read -r app_name app_path bundle_id total_kb encoded_files encoded_system_files has_sensitive_data needs_sudo is_brew_cask cask_name encoded_diag_system has_local_network_usage <<< "$detail"
local related_files=$(decode_file_list "$encoded_files" "$app_name")
local system_files=$(decode_file_list "$encoded_system_files" "$app_name")
local diag_system=$(decode_file_list "$encoded_diag_system" "$app_name")
@@ -520,6 +548,13 @@ batch_uninstall_applications() {
# Stop spinner before any removal attempt (avoids mixed output on errors)
[[ -t 1 ]] && stop_inline_spinner
+ # For large apps, print a waiting hint so the terminal does not appear frozen
+ if [[ -t 1 && $total_kb -gt 1048576 && -z "$reason" ]]; then
+ local _wait_size
+ _wait_size=$(bytes_to_human "$((total_kb * 1024))")
+ echo -e " ${GRAY}Removing ${app_name} (${_wait_size}), please wait...${NC}"
+ fi
+
local used_brew_successfully=false
if [[ -z "$reason" ]]; then
if [[ "$is_brew_cask" == "true" && -n "$cask_name" ]]; then
@@ -527,15 +562,29 @@ batch_uninstall_applications() {
if brew_uninstall_cask "$cask_name" "$app_path"; then
used_brew_successfully=true
else
- # Fallback to manual removal if brew fails
- if [[ "$needs_sudo" == true ]]; then
- if ! safe_sudo_remove "$app_path"; then
- reason="brew failed, manual removal failed"
+ # Only fall back to manual app removal when Homebrew no longer
+ # tracks the cask. Otherwise we would recreate the mismatch
+ # where brew still reports the app as installed after Mole
+ # removes the bundle manually.
+ local cask_state=2
+ if command -v is_brew_cask_installed > /dev/null 2>&1; then
+ if is_brew_cask_installed "$cask_name"; then
+ cask_state=0
+ else
+ cask_state=$?
fi
- else
- if ! safe_remove "$app_path" true; then
- reason="brew failed, manual removal failed"
+ fi
+
+ if [[ $cask_state -eq 1 ]]; then
+ if ! mole_delete "$app_path" "$needs_sudo"; then
+ reason="brew cleanup incomplete, manual removal failed"
fi
+ elif [[ $cask_state -eq 0 ]]; then
+ reason="brew uninstall failed, package still installed"
+ suggestion="Run brew uninstall --cask --zap $cask_name"
+ else
+ reason="brew uninstall failed, package state unknown"
+ suggestion="Run brew uninstall --cask --zap $cask_name"
fi
fi
elif [[ "$needs_sudo" == true ]]; then
@@ -554,24 +603,24 @@ batch_uninstall_applications() {
reason="protected system symlink, cannot remove"
;;
*)
- if ! safe_remove_symlink "$app_path" "true"; then
+ if ! mole_delete "$app_path" "true"; then
reason="failed to remove symlink"
fi
;;
esac
else
- if ! safe_remove_symlink "$app_path" "true"; then
+ if ! mole_delete "$app_path" "true"; then
reason="failed to remove symlink"
fi
fi
else
if is_uninstall_dry_run; then
- if ! safe_remove "$app_path" true; then
+ if ! mole_delete "$app_path" "false"; then
reason="dry-run path validation failed"
fi
else
local ret=0
- safe_sudo_remove "$app_path" || ret=$?
+ mole_delete "$app_path" "true" || ret=$?
if [[ $ret -ne 0 ]]; then
local diagnosis
diagnosis=$(diagnose_removal_failure "$ret" "$app_name")
@@ -580,7 +629,7 @@ batch_uninstall_applications() {
fi
fi
else
- if ! safe_remove "$app_path" true; then
+ if ! mole_delete "$app_path" "false"; then
if [[ ! -w "$(dirname "$app_path")" ]]; then
reason="parent directory not writable"
else
@@ -594,6 +643,24 @@ batch_uninstall_applications() {
if [[ -z "$reason" ]]; then
remove_file_list "$related_files" "false" > /dev/null
+ # Check for related files that still exist after removal (silent failures,
+ # e.g. container directories managed by macOS that resist rm -rf).
+ local leftover_kb=0
+ local -a leftover_paths=()
+ while IFS= read -r _lf; do
+ [[ -n "$_lf" && -e "$_lf" ]] || continue
+ # Skip macOS-managed container stubs: containermanagerd protects
+ # these directories via com.apple.provenance xattr; rm -rf always
+ # fails on them by design. User data is already gone at this point.
+ if [[ "$_lf" == */Library/Containers/* && -f "$_lf/.com.apple.containermanagerd.metadata.plist" ]]; then
+ continue
+ fi
+ leftover_paths+=("$_lf")
+ local _lfkb
+ _lfkb=$(get_path_size_kb "$_lf" || echo "0")
+ leftover_kb=$((leftover_kb + _lfkb))
+ done <<< "$related_files"
+
if [[ "$used_brew_successfully" == "true" ]]; then
remove_file_list "$diag_system" "true" > /dev/null
else
@@ -621,7 +688,7 @@ batch_uninstall_applications() {
if [[ -d "$HOME/Library/Preferences/ByHost" ]]; then
if [[ "$bundle_id" =~ ^[A-Za-z0-9._-]+$ ]]; then
while IFS= read -r -d '' plist_file; do
- safe_remove "$plist_file" true > /dev/null || true
+ mole_delete "$plist_file" "true" || true
done < <(command find "$HOME/Library/Preferences/ByHost" -maxdepth 1 -type f -name "${bundle_id}.*.plist" -print0 2> /dev/null || true)
else
debug_log "Skipping ByHost cleanup, invalid bundle id: $bundle_id"
@@ -638,12 +705,31 @@ batch_uninstall_applications() {
fi
fi
+ # Warn about files that could not be removed and exclude them from freed total.
+ if [[ ${#leftover_paths[@]} -gt 0 ]]; then
+ for _lpath in "${leftover_paths[@]}"; do
+ echo -e " ${YELLOW}${ICON_WARNING}${NC} Could not remove: ${_lpath/$HOME/~}"
+ done
+ total_kb=$((total_kb - leftover_kb))
+ ((total_kb < 0)) && total_kb=0
+ fi
+
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")
+ if [[ "$has_local_network_usage" == "true" ]]; then
+ local_network_warning_apps+=("$app_name")
+ fi
+
+ # Check for orphaned system extensions (camera, network, endpoint security, etc.)
+ if [[ -n "$bundle_id" && "$bundle_id" != "unknown" && "$bundle_id" =~ ^[A-Za-z0-9._-]+$ && -d /Library/SystemExtensions ]]; then
+ if command find /Library/SystemExtensions -maxdepth 3 -name "*.systemextension" -path "*${bundle_id}*" -print -quit 2> /dev/null | grep -q .; then
+ system_extension_warning_apps+=("$app_name")
+ fi
+ fi
else
if [[ -t 1 ]]; then
if [[ ${#app_details[@]} -gt 1 ]]; then
@@ -761,6 +847,31 @@ batch_uninstall_applications() {
summary_details+=("No applications were uninstalled.")
fi
+ if [[ ${#local_network_warning_apps[@]} -gt 0 ]]; then
+ local local_network_list=""
+ local idx
+ for ((idx = 0; idx < ${#local_network_warning_apps[@]}; idx++)); do
+ [[ $idx -gt 0 ]] && local_network_list+=", "
+ local_network_list+="${local_network_warning_apps[idx]}"
+ done
+
+ summary_details+=("${ICON_REVIEW} Local Network permissions on macOS 15+ can outlive app removal: ${YELLOW}${local_network_list}${NC}")
+ summary_details+=("${GRAY}${ICON_SUBLIST}${NC} Mole does not reset ${GRAY}/Volumes/Data/Library/Preferences/com.apple.networkextension*.plist${NC}")
+ summary_details+=("${GRAY}${ICON_SUBLIST}${NC} If stale or duplicate entries remain, clear them manually in Recovery mode because the reset is global${NC}")
+ fi
+
+ if [[ ${#system_extension_warning_apps[@]} -gt 0 ]]; then
+ local ext_list=""
+ local idx
+ for ((idx = 0; idx < ${#system_extension_warning_apps[@]}; idx++)); do
+ [[ $idx -gt 0 ]] && ext_list+=", "
+ ext_list+="${system_extension_warning_apps[idx]}"
+ done
+
+ summary_details+=("${ICON_REVIEW} System extensions may remain after removal: ${YELLOW}${ext_list}${NC}")
+ summary_details+=("${GRAY}${ICON_SUBLIST}${NC} Check ${GRAY}System Settings > General > Login Items & Extensions${NC} to remove leftover extensions")
+ fi
+
local title="Uninstall complete"
if [[ "$summary_status" == "warn" ]]; then
title="Uninstall incomplete"
diff --git a/Resources/mole/lib/uninstall/brew.sh b/Resources/mole/lib/uninstall/brew.sh
index 012ca53..5856a52 100644
--- a/Resources/mole/lib/uninstall/brew.sh
+++ b/Resources/mole/lib/uninstall/brew.sh
@@ -35,6 +35,21 @@ is_homebrew_available() {
command -v brew > /dev/null 2>&1
}
+# Check whether a cask is still recorded as installed in Homebrew.
+# Exit codes:
+# 0 - cask is installed
+# 1 - cask is not installed
+# 2 - install state could not be determined
+is_brew_cask_installed() {
+ local cask_name="$1"
+ [[ -n "$cask_name" ]] || return 2
+ is_homebrew_available || return 2
+
+ local cask_list
+ cask_list=$(HOMEBREW_NO_ENV_HINTS=1 brew list --cask 2> /dev/null) || return 2
+ grep -qxF "$cask_name" <<< "$cask_list"
+}
+
# Extract cask token from a Caskroom path
# Args: $1 - path (must be inside Caskroom)
# Prints: cask token to stdout
@@ -211,7 +226,12 @@ brew_uninstall_cask() {
# Verify removal (only if not timed out)
local cask_gone=true app_gone=true
- HOMEBREW_NO_ENV_HINTS=1 brew list --cask 2> /dev/null | grep -qxF "$cask_name" && cask_gone=false
+ if is_brew_cask_installed "$cask_name"; then
+ cask_gone=false
+ else
+ local cask_state=$?
+ [[ $cask_state -eq 1 ]] || cask_gone=false
+ fi
[[ -n "$app_path" && -e "$app_path" ]] && app_gone=false
# Success: uninstall worked and both are gone, or already uninstalled
diff --git a/Resources/mole/mole b/Resources/mole/mole
index 6f99026..9ddbd04 100755
--- a/Resources/mole/mole
+++ b/Resources/mole/mole
@@ -13,12 +13,11 @@ source "$SCRIPT_DIR/lib/core/commands.sh"
trap cleanup_temp_files EXIT INT TERM
# Version and update helpers
-VERSION="1.30.0"
+VERSION="1.35.0"
MOLE_TAGLINE="Deep clean and optimize your Mac."
is_touchid_configured() {
- local pam_sudo_file="/etc/pam.d/sudo"
- [[ -f "$pam_sudo_file" ]] && grep -q "pam_tid.so" "$pam_sudo_file" 2> /dev/null
+ grep -q "pam_tid.so" /etc/pam.d/sudo /etc/pam.d/sudo_local 2> /dev/null
}
get_latest_version() {
@@ -133,6 +132,14 @@ get_install_commit() {
fi
}
+get_latest_commit_from_github() {
+ local sha
+ sha=$(curl -fsSL --connect-timeout 2 --max-time 3 \
+ "https://api.github.com/repos/tw93/mole/commits/main" 2> /dev/null |
+ grep '"sha"[[:space:]]*:[[:space:]]*"[0-9a-f]\{40\}"' | head -1 | sed -E 's/.*"sha"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/')
+ echo "$sha"
+}
+
# Background update notice
check_for_updates() {
local msg_cache="$HOME/.cache/mole/update_message"
@@ -141,28 +148,44 @@ check_for_updates() {
(
(
- local latest
+ local channel
+ channel=$(get_install_channel)
- latest=$(get_latest_version_from_github)
- if [[ -z "$latest" ]]; then
- latest=$(get_latest_version)
- fi
+ if [[ "$channel" == "nightly" ]]; then
+ # Nightly: compare commit hashes instead of version numbers
+ local installed_commit latest_commit
+ installed_commit=$(get_install_commit)
+ latest_commit=$(get_latest_commit_from_github)
+
+ if [[ -n "$installed_commit" && -n "$latest_commit" && "${installed_commit:0:7}" != "${latest_commit:0:7}" ]]; then
+ printf "\nNew nightly commit %s available, run %smo update --nightly%s\n\n" "${latest_commit:0:7}" "$GREEN" "$NC" > "$msg_cache"
+ else
+ echo -n > "$msg_cache"
+ fi
+ else
+ local latest
- if [[ -n "$latest" && "$VERSION" != "$latest" && "$(printf '%s\n' "$VERSION" "$latest" | sort -V | head -1)" == "$VERSION" ]]; then
- if is_homebrew_install; then
- # For Homebrew, only notify if the brew tap has the new version available locally
- local brew_latest
- brew_latest=$(get_homebrew_latest_version || true)
- if [[ -n "$brew_latest" && "$brew_latest" != "$VERSION" && "$(printf '%s\n' "$VERSION" "$brew_latest" | sort -V | head -1)" == "$VERSION" ]]; then
- printf "\nUpdate %s available, run %smo update%s\n\n" "$brew_latest" "$GREEN" "$NC" > "$msg_cache"
+ latest=$(get_latest_version_from_github)
+ if [[ -z "$latest" ]]; then
+ latest=$(get_latest_version)
+ fi
+
+ if [[ -n "$latest" && "$VERSION" != "$latest" && "$(printf '%s\n' "$VERSION" "$latest" | sort -V | head -1)" == "$VERSION" ]]; then
+ if is_homebrew_install; then
+ # For Homebrew, only notify if the brew tap has the new version available locally
+ local brew_latest
+ brew_latest=$(get_homebrew_latest_version || true)
+ if [[ -n "$brew_latest" && "$brew_latest" != "$VERSION" && "$(printf '%s\n' "$VERSION" "$brew_latest" | sort -V | head -1)" == "$VERSION" ]]; then
+ printf "\nUpdate %s available, run %smo update%s\n\n" "$brew_latest" "$GREEN" "$NC" > "$msg_cache"
+ else
+ echo -n > "$msg_cache"
+ fi
else
- echo -n > "$msg_cache"
+ printf "\nUpdate %s available, run %smo update%s\n\n" "$latest" "$GREEN" "$NC" > "$msg_cache"
fi
else
- printf "\nUpdate %s available, run %smo update%s\n\n" "$latest" "$GREEN" "$NC" > "$msg_cache"
+ echo -n > "$msg_cache"
fi
- else
- echo -n > "$msg_cache"
fi
) > /dev/null 2>&1 < /dev/null &
)
@@ -494,6 +517,10 @@ update_mole() {
# Remove flow (Homebrew + manual + config/cache).
remove_mole() {
local dry_run_mode="${1:-false}"
+ local test_mode=false
+ if [[ "${MOLE_TEST_MODE:-0}" == "1" ]]; then
+ test_mode=true
+ fi
if [[ -t 1 ]]; then
start_inline_spinner "Detecting Mole installations..."
@@ -507,37 +534,47 @@ remove_mole() {
local -a manual_installs=()
local -a alias_installs=()
- if command -v brew > /dev/null 2>&1; then
- brew_cmd="brew"
- elif [[ -x "/opt/homebrew/bin/brew" ]]; then
- brew_cmd="/opt/homebrew/bin/brew"
- elif [[ -x "/usr/local/bin/brew" ]]; then
- brew_cmd="/usr/local/bin/brew"
- fi
+ if [[ "$test_mode" != "true" ]]; then
+ if command -v brew > /dev/null 2>&1; then
+ brew_cmd="brew"
+ elif [[ -x "/opt/homebrew/bin/brew" ]]; then
+ brew_cmd="/opt/homebrew/bin/brew"
+ elif [[ -x "/usr/local/bin/brew" ]]; then
+ brew_cmd="/usr/local/bin/brew"
+ fi
- if [[ -n "$brew_cmd" ]]; then
- if "$brew_cmd" list mole > /dev/null 2>&1; then
- brew_has_mole="true"
+ if [[ -n "$brew_cmd" ]]; then
+ if "$brew_cmd" list mole > /dev/null 2>&1; then
+ brew_has_mole="true"
+ fi
fi
- fi
- if [[ "$brew_has_mole" == "true" ]] || is_homebrew_install; then
- is_homebrew=true
+ if [[ "$brew_has_mole" == "true" ]] || is_homebrew_install; then
+ is_homebrew=true
+ fi
fi
local found_mole
- found_mole=$(command -v mole 2> /dev/null || true)
- if [[ -n "$found_mole" && -f "$found_mole" ]]; then
- if [[ ! -L "$found_mole" ]] || ! readlink "$found_mole" | grep -q "Cellar/mole"; then
- manual_installs+=("$found_mole")
+ found_mole=""
+ if [[ "$test_mode" != "true" ]]; then
+ found_mole=$(command -v mole 2> /dev/null || true)
+ if [[ -n "$found_mole" && -f "$found_mole" ]]; then
+ if [[ ! -L "$found_mole" ]] || ! readlink "$found_mole" | grep -q "Cellar/mole"; then
+ manual_installs+=("$found_mole")
+ fi
fi
fi
- local -a fallback_paths=(
- "/usr/local/bin/mole"
- "$HOME/.local/bin/mole"
- "/opt/local/bin/mole"
- )
+ local -a fallback_paths=()
+ if [[ "$test_mode" == "true" ]]; then
+ fallback_paths=("$HOME/.local/bin/mole")
+ else
+ fallback_paths=(
+ "/usr/local/bin/mole"
+ "$HOME/.local/bin/mole"
+ "/opt/local/bin/mole"
+ )
+ fi
for path in "${fallback_paths[@]}"; do
if [[ -f "$path" && "$path" != "$found_mole" ]]; then
@@ -548,18 +585,26 @@ remove_mole() {
done
local found_mo
- found_mo=$(command -v mo 2> /dev/null || true)
- if [[ -n "$found_mo" && -f "$found_mo" ]]; then
- if [[ ! -L "$found_mo" ]] || ! readlink "$found_mo" | grep -q "Cellar/mole"; then
- alias_installs+=("$found_mo")
+ found_mo=""
+ if [[ "$test_mode" != "true" ]]; then
+ found_mo=$(command -v mo 2> /dev/null || true)
+ if [[ -n "$found_mo" && -f "$found_mo" ]]; then
+ if [[ ! -L "$found_mo" ]] || ! readlink "$found_mo" | grep -q "Cellar/mole"; then
+ alias_installs+=("$found_mo")
+ fi
fi
fi
- local -a alias_fallback=(
- "/usr/local/bin/mo"
- "$HOME/.local/bin/mo"
- "/opt/local/bin/mo"
- )
+ local -a alias_fallback=()
+ if [[ "$test_mode" == "true" ]]; then
+ alias_fallback=("$HOME/.local/bin/mo")
+ else
+ alias_fallback=(
+ "/usr/local/bin/mo"
+ "$HOME/.local/bin/mo"
+ "/opt/local/bin/mo"
+ )
+ fi
for alias in "${alias_fallback[@]}"; do
if [[ -f "$alias" && "$alias" != "$found_mo" ]]; then
@@ -602,6 +647,7 @@ remove_mole() {
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}"
+ [[ -d "$HOME/Library/Logs/mole" ]] && echo -e " ${GRAY}${ICON_LIST} Would remove: $HOME/Library/Logs/mole${NC}"
printf '\n%s\n\n' "${GREEN}${ICON_SUCCESS}${NC} Dry run complete, no changes made"
exit 0
@@ -616,6 +662,7 @@ remove_mole() {
done
echo " ${ICON_LIST} ~/.config/mole"
echo " ${ICON_LIST} ~/.cache/mole"
+ echo " ${ICON_LIST} ~/Library/Logs/mole"
echo -ne "${PURPLE}${ICON_ARROW}${NC} Press ${GREEN}Enter${NC} to confirm, ${GRAY}ESC${NC} to cancel: "
IFS= read -r -s -n1 key || key=""
@@ -688,6 +735,9 @@ remove_mole() {
if [[ -d "$HOME/.config/mole" ]]; then
rm -rf "$HOME/.config/mole" 2> /dev/null || true
fi
+ if [[ -d "$HOME/Library/Logs/mole" ]]; then
+ rm -rf "$HOME/Library/Logs/mole" 2> /dev/null || true
+ fi
local final_message
if [[ "$has_error" == "true" ]]; then
@@ -737,7 +787,7 @@ show_main_menu() {
if [[ -t 0 ]]; then
printf '\r\033[2K\n'
- local controls="${GRAY}↑↓ | Enter | M More"
+ local controls="${GRAY}↑↓ | Enter | M More | V Version"
if ! is_touchid_configured; then
controls="${controls} | T TouchID"
elif [[ "${MAIN_MENU_SHOW_UPDATE:-false}" == "true" ]]; then
@@ -873,7 +923,7 @@ main() {
"uninstall")
exec "$SCRIPT_DIR/bin/uninstall.sh" "${args[@]:1}"
;;
- "analyze")
+ "analyze" | "analyse")
exec "$SCRIPT_DIR/bin/analyze.sh" "${args[@]:1}"
;;
"status")
diff --git a/Resources/mole/scripts/test.sh b/Resources/mole/scripts/test.sh
index 2cf6fab..fd64292 100755
--- a/Resources/mole/scripts/test.sh
+++ b/Resources/mole/scripts/test.sh
@@ -10,6 +10,9 @@ PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$PROJECT_ROOT"
+# Never allow the scripted test run to trigger real sudo or Touch ID prompts.
+export MOLE_TEST_NO_AUTH=1
+
# shellcheck source=lib/core/file_ops.sh
source "$PROJECT_ROOT/lib/core/file_ops.sh"
@@ -20,6 +23,20 @@ echo ""
FAILED=0
+enforce_timeout_dependency_in_ci() {
+ if [[ "${CI:-}" != "true" && "${GITHUB_ACTIONS:-}" != "true" ]]; then
+ return 0
+ fi
+
+ if command -v gtimeout > /dev/null 2>&1 || command -v timeout > /dev/null 2>&1; then
+ return 0
+ fi
+
+ printf "${RED}${ICON_ERROR} Missing timeout binary (gtimeout/timeout) in CI${NC}\n"
+ printf "${YELLOW}${ICON_WARNING} Install coreutils to provide gtimeout${NC}\n"
+ exit 1
+}
+
report_unit_result() {
if [[ $1 -eq 0 ]]; then
printf "${GREEN}${ICON_SUCCESS} Unit tests passed${NC}\n"
@@ -29,6 +46,8 @@ report_unit_result() {
fi
}
+enforce_timeout_dependency_in_ci
+
echo "1. Linting test scripts..."
if command -v shellcheck > /dev/null 2>&1; then
TEST_FILES=()
@@ -98,51 +117,90 @@ if command -v bats > /dev/null 2>&1 && [ -d "tests" ]; then
if [[ -t 1 && "${TERM:-}" != "dumb" ]]; then
use_color=true
fi
- if bats --help 2>&1 | grep -q -- "--formatter"; then
- formatter="${BATS_FORMATTER:-pretty}"
- if [[ "$formatter" == "tap" ]]; then
- if $use_color; then
- esc=$'\033'
- if bats --formatter tap "$@" |
- sed -e "s/^ok /${esc}[32mok ${esc}[0m /" \
- -e "s/^not ok /${esc}[31mnot ok ${esc}[0m /"; then
- report_unit_result 0
- else
- report_unit_result 1
- fi
- else
- if bats --formatter tap "$@"; then
- report_unit_result 0
- else
- report_unit_result 1
- fi
- fi
+
+ # Enable parallel execution across test files when GNU parallel is available.
+ # Cap at 6 jobs to balance speed vs. system load during CI.
+ bats_opts=()
+ if command -v parallel > /dev/null 2>&1 && bats --help 2>&1 | grep -q -- "--jobs"; then
+ _ncpu="$(sysctl -n hw.logicalcpu 2> /dev/null || nproc 2> /dev/null || echo 4)"
+ _jobs="$((_ncpu > 6 ? 6 : (_ncpu < 2 ? 2 : _ncpu)))"
+ # --no-parallelize-within-files ensures each test file's tests run
+ # sequentially (they share a $HOME set by setup_file and are not safe
+ # to run concurrently). Parallelism is only across files.
+ bats_opts+=("--jobs" "$_jobs" "--no-parallelize-within-files")
+ unset _ncpu _jobs
+ fi
+
+ # core_performance.bats has wall-clock timing assertions that are skewed by
+ # CPU contention from parallel test workers. When parallel mode is active,
+ # split it out to run sequentially after the parallel batch completes.
+ _perf_files=()
+ if [[ ${#bats_opts[@]} -gt 0 ]]; then
+ _all=("$@")
+ _rest=()
+ if [[ ${#_all[@]} -eq 1 && -d "${_all[0]}" ]]; then
+ while IFS= read -r _f; do
+ case "$_f" in
+ *core_performance.bats) _perf_files+=("$_f") ;;
+ *) _rest+=("$_f") ;;
+ esac
+ done < <(find "${_all[0]}" -type f -name '*.bats' | sort)
else
- # Pretty format for local development
- if bats --formatter "$formatter" "$@"; then
- report_unit_result 0
- else
- report_unit_result 1
- fi
+ for _f in "${_all[@]}"; do
+ case "$_f" in
+ *core_performance.bats) _perf_files+=("$_f") ;;
+ *) _rest+=("$_f") ;;
+ esac
+ done
fi
- else
- if $use_color; then
- esc=$'\033'
- if bats --tap "$@" |
- sed -e "s/^ok /${esc}[32mok ${esc}[0m /" \
- -e "s/^not ok /${esc}[31mnot ok ${esc}[0m /"; then
- report_unit_result 0
+ if [[ ${#_rest[@]} -gt 0 ]]; then
+ set -- "${_rest[@]}"
+ else
+ set --
+ fi
+ unset _all _rest _f
+ fi
+
+ # Accumulate pass/fail across all bats invocations.
+ _unit_rc=0
+
+ # Main run (parallel when bats_opts has --jobs, skipped if no files remain).
+ if [[ $# -gt 0 ]]; then
+ if bats --help 2>&1 | grep -q -- "--formatter"; then
+ formatter="${BATS_FORMATTER:-pretty}"
+ if [[ "$formatter" == "tap" ]]; then
+ if $use_color; then
+ esc=$'\033'
+ bats ${bats_opts[@]+"${bats_opts[@]}"} --formatter tap "$@" |
+ sed -e "s/^ok /${esc}[32mok ${esc}[0m /" \
+ -e "s/^not ok /${esc}[31mnot ok ${esc}[0m /" || _unit_rc=1
+ else
+ bats ${bats_opts[@]+"${bats_opts[@]}"} --formatter tap "$@" || _unit_rc=1
+ fi
else
- report_unit_result 1
+ # Pretty format for local development
+ bats ${bats_opts[@]+"${bats_opts[@]}"} --formatter "$formatter" "$@" || _unit_rc=1
fi
else
- if bats --tap "$@"; then
- report_unit_result 0
+ if $use_color; then
+ esc=$'\033'
+ bats ${bats_opts[@]+"${bats_opts[@]}"} --tap "$@" |
+ sed -e "s/^ok /${esc}[32mok ${esc}[0m /" \
+ -e "s/^not ok /${esc}[31mnot ok ${esc}[0m /" || _unit_rc=1
else
- report_unit_result 1
+ bats ${bats_opts[@]+"${bats_opts[@]}"} --tap "$@" || _unit_rc=1
fi
fi
fi
+
+ # Post-run: timing-sensitive perf tests run after parallel workers have
+ # finished so CPU contention does not skew wall-clock assertions.
+ for _pf in ${_perf_files[@]+"${_perf_files[@]}"}; do
+ bats "$_pf" || _unit_rc=1
+ done
+ unset _perf_files _pf
+
+ report_unit_result "$_unit_rc"
else
printf "${YELLOW}${ICON_WARNING} bats not installed or no tests found, skipping${NC}\n"
fi
@@ -185,37 +243,42 @@ fi
echo ""
echo "6. Testing installation..."
-# Skip if Homebrew mole is installed (install.sh will refuse to overwrite)
-install_test_home=""
-if command -v brew > /dev/null 2>&1 && brew list mole &> /dev/null; then
- printf "${GREEN}${ICON_SUCCESS} Installation test skipped, Homebrew${NC}\n"
+# Installation script is macOS-specific; skip this test on non-macOS platforms
+if [[ "$(uname -s)" != "Darwin" ]]; then
+ printf "${YELLOW}${ICON_WARNING} Installation test skipped (non-macOS)${NC}\n"
else
- install_test_home="$(mktemp -d /tmp/mole-test-home.XXXXXX 2> /dev/null || true)"
- if [[ -z "$install_test_home" ]]; then
- install_test_home="/tmp/mole-test-home"
- mkdir -p "$install_test_home"
+ # Skip if Homebrew mole is installed (install.sh will refuse to overwrite)
+ install_test_home=""
+ if command -v brew > /dev/null 2>&1 && brew list mole &> /dev/null; then
+ printf "${GREEN}${ICON_SUCCESS} Installation test skipped, Homebrew${NC}\n"
+ else
+ install_test_home="$(mktemp -d /tmp/mole-test-home.XXXXXX 2> /dev/null || true)"
+ if [[ -z "$install_test_home" ]]; then
+ install_test_home="/tmp/mole-test-home"
+ mkdir -p "$install_test_home"
+ fi
fi
-fi
-if [[ -z "$install_test_home" ]]; then
- :
-elif HOME="$install_test_home" \
- XDG_CONFIG_HOME="$install_test_home/.config" \
- XDG_CACHE_HOME="$install_test_home/.cache" \
- MO_NO_OPLOG=1 \
- ./install.sh --prefix /tmp/mole-test > /dev/null 2>&1; then
- if [ -f /tmp/mole-test/mole ]; then
- printf "${GREEN}${ICON_SUCCESS} Installation test passed${NC}\n"
+ if [[ -z "$install_test_home" ]]; then
+ :
+ elif HOME="$install_test_home" \
+ XDG_CONFIG_HOME="$install_test_home/.config" \
+ XDG_CACHE_HOME="$install_test_home/.cache" \
+ MO_NO_OPLOG=1 \
+ ./install.sh --prefix /tmp/mole-test > /dev/null 2>&1; then
+ if [[ -f "/tmp/mole-test/mole" ]]; then
+ printf "${GREEN}${ICON_SUCCESS} Installation test passed${NC}\n"
+ else
+ printf "${RED}${ICON_ERROR} Installation test failed${NC}\n"
+ ((FAILED++))
+ fi
else
printf "${RED}${ICON_ERROR} Installation test failed${NC}\n"
((FAILED++))
fi
-else
- printf "${RED}${ICON_ERROR} Installation test failed${NC}\n"
- ((FAILED++))
-fi
-MO_NO_OPLOG=1 safe_remove "/tmp/mole-test" true || true
-if [[ -n "$install_test_home" ]]; then
- MO_NO_OPLOG=1 safe_remove "$install_test_home" true || true
+ MO_NO_OPLOG=1 safe_remove "/tmp/mole-test" true || true
+ if [[ -n "$install_test_home" ]]; then
+ MO_NO_OPLOG=1 safe_remove "$install_test_home" true || true
+ fi
fi
echo ""
diff --git a/Resources/mole/scripts/update_homebrew_tap_formula.sh b/Resources/mole/scripts/update_homebrew_tap_formula.sh
new file mode 100755
index 0000000..7357b70
--- /dev/null
+++ b/Resources/mole/scripts/update_homebrew_tap_formula.sh
@@ -0,0 +1,120 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+usage() {
+ cat << 'EOF'
+Usage:
+ update_homebrew_tap_formula.sh \
+ --formula /path/to/Formula/mole.rb \
+ --tag V1.32.0 \
+ --source-sha \
+ --arm-sha \
+ --amd-sha
+EOF
+}
+
+formula_path=""
+tag=""
+source_sha=""
+arm_sha=""
+amd_sha=""
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --formula)
+ formula_path="${2:-}"
+ shift 2
+ ;;
+ --tag)
+ tag="${2:-}"
+ shift 2
+ ;;
+ --source-sha)
+ source_sha="${2:-}"
+ shift 2
+ ;;
+ --arm-sha)
+ arm_sha="${2:-}"
+ shift 2
+ ;;
+ --amd-sha)
+ amd_sha="${2:-}"
+ shift 2
+ ;;
+ -h | --help)
+ usage
+ exit 0
+ ;;
+ *)
+ echo "Unknown argument: $1" >&2
+ usage >&2
+ exit 1
+ ;;
+ esac
+done
+
+if [[ -z "$formula_path" || -z "$tag" || -z "$source_sha" || -z "$arm_sha" || -z "$amd_sha" ]]; then
+ usage >&2
+ exit 1
+fi
+
+if [[ ! -f "$formula_path" ]]; then
+ echo "Formula not found: $formula_path" >&2
+ exit 1
+fi
+
+replacement_counts="$(
+ TAG="$tag" \
+ SOURCE_SHA="$source_sha" \
+ ARM_SHA="$arm_sha" \
+ AMD_SHA="$amd_sha" \
+ perl -0ne '
+ my $text = $_;
+
+ my $source_replacements = (
+ $text =~ s{url "https://github.com/tw93/(?:Mole|mole)/archive/refs/tags/[^"]+\.tar\.gz"\n sha256 "[^"]+"}{
+ qq{url "https://github.com/tw93/Mole/archive/refs/tags/$ENV{TAG}.tar.gz"\n sha256 "$ENV{SOURCE_SHA}"}
+ }se
+ );
+
+ my $arm_replacements = (
+ $text =~ s{(on_arm do\s+url ")https://github.com/tw93/(?:Mole|mole)/releases/download/[^/]+/binaries-darwin-arm64\.tar\.gz("\s+sha256 ")[^"]+(")}{
+ qq{$1https://github.com/tw93/Mole/releases/download/$ENV{TAG}/binaries-darwin-arm64.tar.gz$2$ENV{ARM_SHA}$3}
+ }se
+ );
+
+ my $amd_replacements = (
+ $text =~ s{(on_intel do\s+url ")https://github.com/tw93/(?:Mole|mole)/releases/download/[^/]+/binaries-darwin-amd64\.tar\.gz("\s+sha256 ")[^"]+(")}{
+ qq{$1https://github.com/tw93/Mole/releases/download/$ENV{TAG}/binaries-darwin-amd64.tar.gz$2$ENV{AMD_SHA}$3}
+ }se
+ );
+
+ print "$source_replacements $arm_replacements $amd_replacements\n";
+ ' "$formula_path"
+)"
+
+read -r source_replacements arm_replacements amd_replacements <<< "$replacement_counts"
+
+if [[ "$source_replacements" != "1" || "$arm_replacements" != "1" || "$amd_replacements" != "1" ]]; then
+ echo "Failed to update formula: expected 1 replacement for source/arm/amd, got ${source_replacements}/${arm_replacements}/${amd_replacements}" >&2
+ exit 1
+fi
+
+TAG="$tag" \
+ SOURCE_SHA="$source_sha" \
+ ARM_SHA="$arm_sha" \
+ AMD_SHA="$amd_sha" \
+ perl -0pi -e '
+ s{url "https://github.com/tw93/(?:Mole|mole)/archive/refs/tags/[^"]+\.tar\.gz"\n sha256 "[^"]+"}{
+ qq{url "https://github.com/tw93/Mole/archive/refs/tags/$ENV{TAG}.tar.gz"\n sha256 "$ENV{SOURCE_SHA}"}
+ }se;
+
+ s{(on_arm do\s+url ")https://github.com/tw93/(?:Mole|mole)/releases/download/[^/]+/binaries-darwin-arm64\.tar\.gz("\s+sha256 ")[^"]+(")}{
+ qq{$1https://github.com/tw93/Mole/releases/download/$ENV{TAG}/binaries-darwin-arm64.tar.gz$2$ENV{ARM_SHA}$3}
+ }se;
+
+ s{(on_intel do\s+url ")https://github.com/tw93/(?:Mole|mole)/releases/download/[^/]+/binaries-darwin-amd64\.tar\.gz("\s+sha256 ")[^"]+(")}{
+ qq{$1https://github.com/tw93/Mole/releases/download/$ENV{TAG}/binaries-darwin-amd64.tar.gz$2$ENV{AMD_SHA}$3}
+ }se;
+' "$formula_path"
diff --git a/Resources/mole/tests/brew_uninstall.bats b/Resources/mole/tests/brew_uninstall.bats
index d3aab74..5cc3b7c 100644
--- a/Resources/mole/tests/brew_uninstall.bats
+++ b/Resources/mole/tests/brew_uninstall.bats
@@ -9,6 +9,10 @@ setup_file() {
HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-brew-uninstall-home.XXXXXX")"
export HOME
+
+ # Prevent AppleScript permission dialogs during tests
+ MOLE_TEST_MODE=1
+ export MOLE_TEST_MODE
}
teardown_file() {
@@ -97,6 +101,10 @@ remove_apps_from_dock() { :; }
force_kill_app() { return 0; }
run_with_timeout() { shift; "$@"; }
export -f run_with_timeout
+ensure_sudo_session() {
+ echo "ENSURE_SUDO:$*" >> "$HOME/brew_calls.log"
+ return 0
+}
# Mock brew to track calls
brew() {
@@ -117,13 +125,14 @@ total_size_cleaned=0
# Simulate 'Enter' for confirmation
printf '\n' | batch_uninstall_applications > /dev/null 2>&1
+grep -q "ENSURE_SUDO:Admin required for Homebrew casks: BrewApp" "$HOME/brew_calls.log"
grep -q "uninstall --cask --zap brew-app-cask" "$HOME/brew_calls.log"
EOF
[ "$status" -eq 0 ]
}
-@test "batch_uninstall_applications does not pre-auth sudo for brew-only casks" {
+@test "batch_uninstall_applications pre-auths sudo for brew-only casks" {
local app_bundle="$HOME/Applications/BrewPreAuth.app"
mkdir -p "$app_bundle"
@@ -145,8 +154,8 @@ run_with_timeout() { shift; "$@"; }
export -f run_with_timeout
ensure_sudo_session() {
- echo "UNEXPECTED_ENSURE_SUDO:$*" >> "$HOME/order.log"
- return 1
+ echo "ENSURE_SUDO:$*" >> "$HOME/order.log"
+ return 0
}
brew() {
@@ -165,8 +174,9 @@ total_size_cleaned=0
printf '\n' | batch_uninstall_applications > /dev/null 2>&1
+grep -q "ENSURE_SUDO:Admin required for Homebrew casks: BrewPreAuth" "$HOME/order.log"
grep -q "BREW_CALL:uninstall --cask --zap brew-preauth-cask" "$HOME/order.log"
-! grep -q "UNEXPECTED_ENSURE_SUDO:" "$HOME/order.log"
+[[ "$(sed -n '1p' "$HOME/order.log")" == "ENSURE_SUDO:Admin required for Homebrew casks: BrewPreAuth" ]]
EOF
[ "$status" -eq 0 ]
@@ -192,6 +202,7 @@ print_summary_block() { :; }
force_kill_app() { return 0; }
remove_apps_from_dock() { :; }
refresh_launch_services_after_uninstall() { echo "LS_REFRESH"; }
+ensure_sudo_session() { return 0; }
get_brew_cask_name() { echo "brew-timeout-cask"; return 0; }
brew_uninstall_cask() { return 0; }
@@ -224,37 +235,165 @@ EOF
[[ "$output" != *"Checking brew dependencies"* ]]
}
-@test "brew_uninstall_cask does not trigger extra sudo pre-auth" {
+@test "batch_uninstall_applications keeps brew-managed app intact when brew uninstall fails" {
+ local app_bundle="$HOME/Applications/BrewBroken.app"
+ mkdir -p "$app_bundle"
+
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/brew.sh"
+source "$PROJECT_ROOT/lib/uninstall/batch.sh"
+
+start_inline_spinner() { :; }
+stop_inline_spinner() { :; }
+get_file_owner() { whoami; }
+get_path_size_kb() { echo "100"; }
+bytes_to_human() { echo "$1"; }
+drain_pending_input() { :; }
+print_summary_block() { :; }
+force_kill_app() { return 0; }
+remove_apps_from_dock() { :; }
+stop_launch_services() { :; }
+unregister_app_bundle() { :; }
+remove_login_item() { :; }
+find_app_files() { return 0; }
+find_app_system_files() { return 0; }
+get_diagnostic_report_paths_for_app() { return 0; }
+calculate_total_size() { echo "0"; }
+has_sensitive_data() { return 1; }
+decode_file_list() { return 0; }
+remove_file_list() { :; }
+run_with_timeout() { shift; "$@"; }
+ensure_sudo_session() { return 0; }
+
+safe_remove() {
+ echo "SAFE_REMOVE:$1" >> "$HOME/remove.log"
+ rm -rf "$1"
+}
+
+safe_sudo_remove() {
+ echo "SAFE_SUDO_REMOVE:$1" >> "$HOME/remove.log"
+ rm -rf "$1"
+}
+
+get_brew_cask_name() { echo "brew-broken-cask"; return 0; }
+brew_uninstall_cask() { return 1; }
+is_brew_cask_installed() { return 0; }
+
+selected_apps=("0|$HOME/Applications/BrewBroken.app|BrewBroken|com.example.brewbroken|0|Never")
+files_cleaned=0
+total_items=0
+total_size_cleaned=0
+
+printf '\n' | batch_uninstall_applications > /dev/null 2>&1 || true
+
+[[ -d "$HOME/Applications/BrewBroken.app" ]]
+[[ ! -f "$HOME/remove.log" ]]
+EOF
+
+ [ "$status" -eq 0 ]
+}
+
+@test "batch_uninstall_applications finishes cleanup after brew removes cask record" {
+ local app_bundle="$HOME/Applications/BrewCleanup.app"
+ mkdir -p "$app_bundle"
+
+ 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"
+
+start_inline_spinner() { :; }
+stop_inline_spinner() { :; }
+get_file_owner() { whoami; }
+get_path_size_kb() { echo "100"; }
+bytes_to_human() { echo "$1"; }
+drain_pending_input() { :; }
+print_summary_block() { :; }
+force_kill_app() { return 0; }
+remove_apps_from_dock() { :; }
+stop_launch_services() { :; }
+unregister_app_bundle() { :; }
+remove_login_item() { :; }
+find_app_files() { return 0; }
+find_app_system_files() { return 0; }
+get_diagnostic_report_paths_for_app() { return 0; }
+calculate_total_size() { echo "0"; }
+has_sensitive_data() { return 1; }
+decode_file_list() { return 0; }
+remove_file_list() { :; }
+run_with_timeout() { shift; "$@"; }
+ensure_sudo_session() { return 0; }
+
+safe_remove() {
+ echo "SAFE_REMOVE:$1" >> "$HOME/remove.log"
+ rm -rf "$1"
+}
+
+safe_sudo_remove() {
+ echo "SAFE_SUDO_REMOVE:$1" >> "$HOME/remove.log"
+ rm -rf "$1"
+}
+
+get_brew_cask_name() { echo "brew-cleanup-cask"; return 0; }
+brew_uninstall_cask() { return 1; }
+is_brew_cask_installed() { return 1; }
+
+selected_apps=("0|$HOME/Applications/BrewCleanup.app|BrewCleanup|com.example.brewcleanup|0|Never")
+files_cleaned=0
+total_items=0
+total_size_cleaned=0
-debug_log() { :; }
-get_path_size_kb() { echo "0"; }
-run_with_timeout() { local _timeout="$1"; shift; "$@"; }
+printf '\n' | batch_uninstall_applications > /dev/null 2>&1
+
+[[ ! -d "$HOME/Applications/BrewCleanup.app" ]]
+grep -q "SAFE_REMOVE:$HOME/Applications/BrewCleanup.app" "$HOME/remove.log"
+EOF
-sudo() {
- echo "UNEXPECTED_SUDO_CALL:$*"
- return 1
+ [ "$status" -eq 0 ]
}
+@test "batch_uninstall_applications skips brew sudo pre-auth in dry-run mode" {
+ 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"
+
brew() {
- if [[ "${1:-}" == "uninstall" ]]; then
+ echo "BREW_CALL:$*" >> "$HOME/dry_run.log"
return 0
- fi
- if [[ "${1:-}" == "list" && "${2:-}" == "--cask" ]]; then
- return 0
- fi
- return 0
}
-export -f sudo brew
+export -f brew
+
+start_inline_spinner() { :; }
+stop_inline_spinner() { :; }
+get_file_owner() { whoami; }
+get_path_size_kb() { echo "100"; }
+bytes_to_human() { echo "$1"; }
+drain_pending_input() { :; }
+print_summary_block() { :; }
+remove_apps_from_dock() { :; }
+force_kill_app() { return 0; }
+ensure_sudo_session() {
+ echo "UNEXPECTED_ENSURE_SUDO:$*" >> "$HOME/dry_run.log"
+ return 1
+}
+run_with_timeout() { shift; "$@"; }
+export -f run_with_timeout
+
+get_brew_cask_name() { echo "brew-dry-run-cask"; return 0; }
+
+export MOLE_DRY_RUN=1
+selected_apps=("0|$HOME/Applications/BrewDryRun.app|BrewDryRun|com.example.brewdryrun|0|Never")
+mkdir -p "$HOME/Applications/BrewDryRun.app"
+files_cleaned=0
+total_items=0
+total_size_cleaned=0
+
+printf '\n' | batch_uninstall_applications > /dev/null 2>&1
-brew_uninstall_cask "mock-cask"
-echo "DONE"
+! grep -q "UNEXPECTED_ENSURE_SUDO:" "$HOME/dry_run.log" 2> /dev/null
EOF
[ "$status" -eq 0 ]
- [[ "$output" == *"DONE"* ]]
- [[ "$output" != *"UNEXPECTED_SUDO_CALL:"* ]]
}
diff --git a/Resources/mole/tests/bundle_resolver.bats b/Resources/mole/tests/bundle_resolver.bats
new file mode 100644
index 0000000..8eeb694
--- /dev/null
+++ b/Resources/mole/tests/bundle_resolver.bats
@@ -0,0 +1,142 @@
+#!/usr/bin/env bats
+
+# Tests for lib/core/bundle_resolver.sh. Validates the filesystem-fallback path:
+# we cannot rely on Spotlight indexing a fake /Applications under a tmpdir,
+# so each test forces the Spotlight path to miss (no binary or empty result)
+# and asserts the filesystem scan finds the app.
+
+setup_file() {
+ PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)"
+ export PROJECT_ROOT
+}
+
+setup() {
+ FAKE_HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-bundle-home.XXXXXX")"
+ export FAKE_HOME
+ mkdir -p "$FAKE_HOME/Applications"
+
+ # Stage a fake /Applications tree inside the tmp area. bundle_has_installed_app
+ # hardcodes the real /Applications roots, so we patch _MOLE_BUNDLE_RESOLVER_APP_ROOTS
+ # from the test harness itself.
+ FAKE_APPS="$FAKE_HOME/FakeApplications"
+ export FAKE_APPS
+ mkdir -p "$FAKE_APPS"
+}
+
+teardown() {
+ rm -rf "$FAKE_HOME"
+}
+
+# Shared prelude: source base + resolver, disable mdfind, point resolver at FAKE_APPS.
+prelude() {
+ cat <<'EOF'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/base.sh"
+source "$PROJECT_ROOT/lib/core/timeout.sh"
+source "$PROJECT_ROOT/lib/core/bundle_resolver.sh"
+
+# Force Spotlight miss so we test only the filesystem fallback.
+mdfind() { return 0; }
+export -f mdfind
+
+# Override the hardcoded app roots for the test.
+_MOLE_BUNDLE_RESOLVER_APP_ROOTS=("$FAKE_APPS")
+EOF
+}
+
+make_app() {
+ local app_dir="$1"
+ local bundle_id="$2"
+ mkdir -p "$app_dir/Contents"
+ cat > "$app_dir/Contents/Info.plist" <
+
+
+
+ CFBundleIdentifier
+ $bundle_id
+
+
+EOF
+}
+
+@test "bundle_has_installed_app finds an app by CFBundleIdentifier (Spotlight miss)" {
+ make_app "$FAKE_APPS/KeePassXC.app" "org.keepassxc.KeePassXC"
+
+ run env FAKE_APPS="$FAKE_APPS" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc < "$app/Contents/Library/LaunchServices/com.adobe.ARMDC.SMJobBlessHelper"
+
+ run env FAKE_APPS="$FAKE_APPS" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <Program%s' "$2" > "$1"; }
+mk "$LA/com.ghost.helper.plist" "/Applications/Ghost.app/Contents/MacOS/helper"
+mk "$LA/com.real.tool.plist" "/bin/sh"
+mk "$LA/com.apple.fake.plist" "/nonexistent/x"
+check_orphan_launch_agents
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"1 orphan"* ]]
+ [[ "$output" == *"com.ghost.helper"* ]]
+ [[ "$output" != *"com.real.tool"* ]]
+ [[ "$output" != *"com.apple.fake"* ]]
+}
+
+@test "check_orphan_launch_agents reports None when clean" {
+ 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/check/all.sh"
+LA="$HOME/Library/LaunchAgents"
+rm -rf "$LA" && mkdir -p "$LA"
+export MOLE_LAUNCH_AGENT_DIRS="$LA"
+check_orphan_launch_agents
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"None orphaned"* ]]
+}
+
+@test "check_orphan_launch_agents respects whitelist" {
+ 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/check/all.sh"
+is_whitelisted() { [[ "$1" == "check_orphan_launch_agents" ]]; }
+export -f is_whitelisted
+LA="$HOME/Library/LaunchAgents"
+rm -rf "$LA" && mkdir -p "$LA"
+export MOLE_LAUNCH_AGENT_DIRS="$LA"
+printf 'Program/nonexistent/x' > "$LA/com.ghost.plist"
+check_orphan_launch_agents
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ -z "$output" ]]
+}
diff --git a/Resources/mole/tests/clean_app_caches.bats b/Resources/mole/tests/clean_app_caches.bats
index 067664f..d211fd8 100644
--- a/Resources/mole/tests/clean_app_caches.bats
+++ b/Resources/mole/tests/clean_app_caches.bats
@@ -10,6 +10,10 @@ setup_file() {
HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-app-caches.XXXXXX")"
export HOME
+ # Prevent AppleScript permission dialogs during tests
+ MOLE_TEST_MODE=1
+ export MOLE_TEST_MODE
+
mkdir -p "$HOME"
}
@@ -54,20 +58,36 @@ EOF
[[ "$output" == *"Xcode documentation index"* ]]
}
-@test "clean_media_players protects spotify offline cache" {
+@test "clean_media_players protects spotify offline cache when bnk has content" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh"
source "$PROJECT_ROOT/lib/clean/app_caches.sh"
mkdir -p "$HOME/Library/Application Support/Spotify/PersistentCache/Storage"
-touch "$HOME/Library/Application Support/Spotify/PersistentCache/Storage/offline.bnk"
+dd if=/dev/zero of="$HOME/Library/Application Support/Spotify/PersistentCache/Storage/offline.bnk" bs=1024 count=2 2>/dev/null
safe_clean() { echo "CLEAN:$2"; }
clean_media_players
EOF
[ "$status" -eq 0 ]
+ [[ "$output" != *"CLEAN:Spotify cache"* ]]
[[ "$output" == *"Spotify cache protected"* ]]
- [[ "$output" != *"CLEAN: Spotify cache"* ]]
+}
+
+@test "clean_media_players cleans spotify cache when bnk is empty" {
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+source "$PROJECT_ROOT/lib/clean/app_caches.sh"
+mkdir -p "$HOME/Library/Application Support/Spotify/PersistentCache/Storage"
+> "$HOME/Library/Application Support/Spotify/PersistentCache/Storage/offline.bnk"
+safe_clean() { echo "CLEAN:$2"; }
+clean_media_players
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" != *"Spotify cache protected"* ]]
+ [[ "$output" == *"CLEAN:Spotify cache"* ]]
}
@test "clean_user_gui_applications calls all sections" {
@@ -80,13 +100,17 @@ safe_clean() { :; }
clean_xcode_tools() { echo "xcode"; }
clean_code_editors() { echo "editors"; }
clean_communication_apps() { echo "comm"; }
+clean_dingtalk() { echo "dingtalk"; }
+clean_ai_apps() { echo "ai"; }
clean_user_gui_applications
EOF
[ "$status" -eq 0 ]
- [[ "$output" == *"xcode"* ]]
- [[ "$output" == *"editors"* ]]
+ [[ "$output" != *"xcode"* ]]
+ [[ "$output" != *"editors"* ]]
[[ "$output" == *"comm"* ]]
+ [[ "$output" == *"dingtalk"* ]]
+ [[ "$output" == *"ai"* ]]
}
@test "clean_ai_apps calls expected caches" {
@@ -208,3 +232,131 @@ EOF
[[ "$output" == *"Minecraft logs"* ]]
[[ "$output" == *"Lunar Client logs"* ]]
}
+
+@test "clean_code_editors includes Zed caches" {
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+source "$PROJECT_ROOT/lib/clean/app_caches.sh"
+safe_clean() { echo "$2"; }
+clean_code_editors
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"Zed cache"* ]]
+ [[ "$output" == *"Zed logs"* ]]
+}
+
+@test "clean_shell_utils includes Warp and Ghostty caches" {
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+source "$PROJECT_ROOT/lib/clean/app_caches.sh"
+safe_clean() { echo "$2"; }
+clean_shell_utils
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"Warp cache"* ]]
+ [[ "$output" == *"Warp log"* ]]
+ [[ "$output" == *"Warp Sentry crash reports"* ]]
+ [[ "$output" == *"Ghostty cache"* ]]
+}
+
+@test "clean_xcode_tools handles zero unavailable simulators without syntax error" {
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+source "$PROJECT_ROOT/lib/clean/app_caches.sh"
+pgrep() { return 1; }
+safe_clean() { echo "$2"; }
+xcrun() {
+ if [[ "$*" == "simctl list devices unavailable" ]]; then
+ echo "== Devices =="
+ echo "-- iOS 17.0 --"
+ return 0
+ fi
+ return 0
+}
+export -f xcrun
+clean_xcode_tools
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" != *"syntax error"* ]]
+ [[ "$output" != *"Unavailable simulators"* ]]
+}
+
+@test "clean_xcode_tools reports unavailable simulators when present" {
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true /bin/bash --noprofile --norc << 'EOF'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+source "$PROJECT_ROOT/lib/clean/app_caches.sh"
+pgrep() { return 1; }
+safe_clean() { echo "$2"; }
+xcrun() {
+ if [[ "$*" == "simctl list devices unavailable" ]]; then
+ echo "== Devices =="
+ echo "-- Unavailable --"
+ echo " iPhone 12 (ABCDEF01-2345-6789-ABCD-EF0123456789) (Shutdown) (unavailable)"
+ echo " iPhone 13 (12345678-90AB-CDEF-1234-567890ABCDEF) (Shutdown) (unavailable)"
+ return 0
+ fi
+ return 0
+}
+export -f xcrun
+clean_xcode_tools
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" != *"syntax error"* ]]
+ [[ "$output" == *"would delete 2 devices"* ]]
+}
+
+# Previously the cleanup path used '|| true' followed by an unconditional
+# green SUCCESS echo, so a simctl timeout (124) or any failure was reported
+# as "deleted N devices". Capture exit code and branch on it.
+@test "clean_xcode_tools reports failure when simctl delete returns non-zero" {
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+source "$PROJECT_ROOT/lib/clean/app_caches.sh"
+pgrep() { return 1; }
+safe_clean() { echo "$2"; }
+xcrun() {
+ case "$*" in
+ "simctl list devices unavailable")
+ echo "== Devices =="
+ echo "-- Unavailable --"
+ echo " iPhone 12 (ABCDEF01-2345-6789-ABCD-EF0123456789) (Shutdown) (unavailable)"
+ return 0
+ ;;
+ "simctl delete unavailable")
+ return 124 # simulate run_with_timeout firing
+ ;;
+ esac
+ return 0
+}
+export -f xcrun
+clean_xcode_tools
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" != *"deleted 1 devices"* ]]
+ [[ "$output" == *"simctl delete failed"* ]]
+ [[ "$output" == *"exit=124"* ]]
+}
+
+@test "clean_video_players includes Stremio caches" {
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+source "$PROJECT_ROOT/lib/clean/app_caches.sh"
+safe_clean() { echo "$2"; }
+clean_video_players
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"Stremio cache"* ]]
+ [[ "$output" == *"Stremio server cache"* ]]
+}
diff --git a/Resources/mole/tests/clean_apps.bats b/Resources/mole/tests/clean_apps.bats
index 0fafe10..8fa9689 100644
--- a/Resources/mole/tests/clean_apps.bats
+++ b/Resources/mole/tests/clean_apps.bats
@@ -10,6 +10,10 @@ setup_file() {
HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-apps-module.XXXXXX")"
export HOME
+ # Prevent AppleScript permission dialogs during tests
+ MOLE_TEST_MODE=1
+ export MOLE_TEST_MODE
+
mkdir -p "$HOME"
}
@@ -28,8 +32,31 @@ source "$PROJECT_ROOT/lib/clean/apps.sh"
start_inline_spinner() { :; }
stop_section_spinner() { :; }
note_activity() { :; }
-get_file_size() { echo 10; }
-bytes_to_human() { echo "0B"; }
+get_file_size() { echo $((2 * 1024 * 1024 * 1024)); }
+bytes_to_human() { echo "2.15GB"; }
+files_cleaned=0
+total_size_cleaned=0
+total_items=0
+mkdir -p "$HOME/test_ds"
+touch "$HOME/test_ds/.DS_Store"
+clean_ds_store_tree "$HOME/test_ds" "DS test"
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"DS test"* ]]
+ [[ "$output" == *$'\033[0;33m→\033[0m'* ]]
+}
+
+@test "clean_ds_store_tree uses green for successful cleanups" {
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc <<'EOF'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+source "$PROJECT_ROOT/lib/clean/apps.sh"
+start_inline_spinner() { :; }
+stop_section_spinner() { :; }
+note_activity() { :; }
+get_file_size() { echo 512; }
+bytes_to_human() { echo "512B"; }
files_cleaned=0
total_size_cleaned=0
total_items=0
@@ -40,6 +67,7 @@ EOF
[ "$status" -eq 0 ]
[[ "$output" == *"DS test"* ]]
+ [[ "$output" == *$'\033[0;32m✓\033[0m'* ]]
}
@test "scan_installed_apps uses cache when fresh" {
@@ -59,6 +87,48 @@ EOF
[[ "$output" == *"com.example.App"* ]]
}
+@test "scan_installed_apps filters missing value from osascript output" {
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=1 bash --noprofile --norc <<'EOF'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+source "$PROJECT_ROOT/lib/clean/apps.sh"
+
+# Create a fake .app with a plist that has no CFBundleIdentifier
+mkdir -p "$HOME/Applications/FakeApp.app/Contents"
+cat > "$HOME/Applications/FakeApp.app/Contents/Info.plist" <<'PLIST'
+
+
+
+
+ CFBundleName
+ FakeApp
+
+
+PLIST
+
+# Create a valid .app alongside it
+mkdir -p "$HOME/Applications/GoodApp.app/Contents"
+cat > "$HOME/Applications/GoodApp.app/Contents/Info.plist" <<'PLIST'
+
+
+
+
+ CFBundleIdentifier
+ com.example.GoodApp
+
+
+PLIST
+
+debug_log() { :; }
+scan_installed_apps "$HOME/installed.txt"
+cat "$HOME/installed.txt"
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"com.example.GoodApp"* ]]
+ [[ "$output" != *"missing value"* ]]
+}
+
@test "is_bundle_orphaned returns true for old uninstalled bundle" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" ORPHAN_AGE_THRESHOLD=30 bash --noprofile --norc <<'EOF'
set -euo pipefail
@@ -340,6 +410,75 @@ EOF
}
+@test "clean_orphaned_app_data honors WHITELIST_PATTERNS for Claude VM bundle" {
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+source "$PROJECT_ROOT/lib/clean/apps.sh"
+
+scan_installed_apps() { : > "$1"; }
+mdfind() { return 0; }
+pgrep() { return 1; }
+run_with_timeout() { shift; "$@"; }
+get_file_mtime() { echo 0; }
+get_path_size_kb() { echo 4; }
+safe_clean() { echo "UNEXPECTED_CLEAN:$2"; rm -rf "$1"; }
+start_section_spinner() { :; }
+stop_section_spinner() { :; }
+
+mkdir -p "$HOME/Library/Caches"
+mkdir -p "$HOME/Library/Application Support/Claude/vm_bundles/claudevm.bundle"
+echo "vm data" > "$HOME/Library/Application Support/Claude/vm_bundles/claudevm.bundle/rootfs.img"
+
+WHITELIST_PATTERNS=("$HOME/Library/Application Support/Claude/vm_bundles/claudevm.bundle")
+
+clean_orphaned_app_data
+
+if [[ -d "$HOME/Library/Application Support/Claude/vm_bundles/claudevm.bundle" ]]; then
+ echo "PASS: Claude VM preserved by whitelist"
+fi
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" != *"UNEXPECTED_CLEAN"* ]]
+ [[ "$output" == *"PASS: Claude VM preserved by whitelist"* ]]
+}
+
+@test "clean_orphaned_app_data honors WHITELIST_PATTERNS for orphaned caches" {
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+source "$PROJECT_ROOT/lib/clean/apps.sh"
+
+scan_installed_apps() { : > "$1"; }
+is_bundle_orphaned() { return 0; }
+is_claude_vm_bundle_orphaned() { return 1; }
+mdfind() { return 0; }
+pgrep() { return 1; }
+run_with_timeout() { shift; "$@"; }
+get_file_mtime() { echo 0; }
+get_path_size_kb() { echo 4; }
+safe_clean() { echo "UNEXPECTED_CLEAN:$2"; rm -rf "$1"; }
+start_section_spinner() { :; }
+stop_section_spinner() { :; }
+
+mkdir -p "$HOME/Library/Caches/com.devtool.localbuild"
+echo "c" > "$HOME/Library/Caches/com.devtool.localbuild/data"
+
+WHITELIST_PATTERNS=("$HOME/Library/Caches/com.devtool.localbuild")
+
+clean_orphaned_app_data
+
+if [[ -d "$HOME/Library/Caches/com.devtool.localbuild" ]]; then
+ echo "PASS: whitelisted orphan cache preserved"
+fi
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" != *"UNEXPECTED_CLEAN"* ]]
+ [[ "$output" == *"PASS: whitelisted orphan cache preserved"* ]]
+}
+
@test "is_critical_system_component matches known system services" {
run bash --noprofile --norc <<'EOF'
set -euo pipefail
@@ -367,7 +506,7 @@ EOF
}
@test "clean_orphaned_system_services respects dry-run" {
- run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 bash --noprofile --norc <<'EOF'
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true MOLE_DRY_RUN=1 bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh"
source "$PROJECT_ROOT/lib/clean/apps.sh"
@@ -412,142 +551,31 @@ EOF
[[ "$output" != *"launchctl-called"* ]]
}
-@test "is_launch_item_orphaned detects orphan when program missing" {
+@test "clean_orphaned_launch_agents preserves user launch agents" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh"
source "$PROJECT_ROOT/lib/clean/apps.sh"
-tmp_dir="$(mktemp -d)"
-tmp_plist="$tmp_dir/com.test.orphan.plist"
-
-cat > "$tmp_plist" << 'PLIST'
-
-
-
-
- Label
- com.test.orphan
- ProgramArguments
-
- /nonexistent/app/program
-
-
-
-PLIST
-
-run_with_timeout() { shift; "$@"; }
-
-if is_launch_item_orphaned "$tmp_plist"; then
- echo "orphan"
-fi
-
-rm -rf "$tmp_dir"
-EOF
-
- [ "$status" -eq 0 ]
- [[ "$output" == *"orphan"* ]]
-}
-
-@test "is_launch_item_orphaned protects when program exists" {
- run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
-set -euo pipefail
-source "$PROJECT_ROOT/lib/core/common.sh"
-source "$PROJECT_ROOT/lib/clean/apps.sh"
-
-tmp_dir="$(mktemp -d)"
-tmp_plist="$tmp_dir/com.test.active.plist"
-tmp_program="$tmp_dir/program"
-touch "$tmp_program"
-
-cat > "$tmp_plist" << PLIST
-
-
-
-
- Label
- com.test.active
- ProgramArguments
-
- $tmp_program
-
-
-
-PLIST
-
-run_with_timeout() { shift; "$@"; }
-
-if is_launch_item_orphaned "$tmp_plist"; then
- echo "orphan"
-else
- echo "not-orphan"
-fi
-
-rm -rf "$tmp_dir"
-EOF
-
- [ "$status" -eq 0 ]
- [[ "$output" == *"not-orphan"* ]]
-}
-
-@test "is_launch_item_orphaned protects when app support active" {
- run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
-set -euo pipefail
-source "$PROJECT_ROOT/lib/core/common.sh"
-source "$PROJECT_ROOT/lib/clean/apps.sh"
-
-tmp_dir="$(mktemp -d)"
-tmp_plist="$tmp_dir/com.test.appsupport.plist"
-
-mkdir -p "$HOME/Library/Application Support/TestApp"
-touch "$HOME/Library/Application Support/TestApp/recent.txt"
-
-cat > "$tmp_plist" << 'PLIST'
+mkdir -p "$HOME/Library/LaunchAgents"
+cat > "$HOME/Library/LaunchAgents/com.example.custom-task.plist" <<'PLIST'
Label
- com.test.appsupport
- ProgramArguments
-
- $HOME/Library/Application Support/TestApp/Current/app
-
+ com.example.custom-task
PLIST
-run_with_timeout() { shift; "$@"; }
-
-if is_launch_item_orphaned "$tmp_plist"; then
- echo "orphan"
-else
- echo "not-orphan"
-fi
-
-rm -rf "$tmp_dir"
-rm -rf "$HOME/Library/Application Support/TestApp"
-EOF
-
- [ "$status" -eq 0 ]
- [[ "$output" == *"not-orphan"* ]]
-}
-
-@test "clean_orphaned_launch_agents skips when no orphans" {
- run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
-set -euo pipefail
-source "$PROJECT_ROOT/lib/core/common.sh"
-source "$PROJECT_ROOT/lib/clean/apps.sh"
-
-mkdir -p "$HOME/Library/LaunchAgents"
-
start_section_spinner() { :; }
stop_section_spinner() { :; }
note_activity() { :; }
-get_path_size_kb() { echo "1"; }
-run_with_timeout() { shift; "$@"; }
clean_orphaned_launch_agents
+
+[[ -f "$HOME/Library/LaunchAgents/com.example.custom-task.plist" ]]
EOF
[ "$status" -eq 0 ]
diff --git a/Resources/mole/tests/clean_browser_versions.bats b/Resources/mole/tests/clean_browser_versions.bats
index b90350a..0a2a3ed 100644
--- a/Resources/mole/tests/clean_browser_versions.bats
+++ b/Resources/mole/tests/clean_browser_versions.bats
@@ -10,6 +10,10 @@ setup_file() {
HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-browser-cleanup.XXXXXX")"
export HOME
+ # Prevent AppleScript permission dialogs during tests
+ MOLE_TEST_MODE=1
+ export MOLE_TEST_MODE
+
mkdir -p "$HOME"
}
diff --git a/Resources/mole/tests/clean_cached_device_firmware.bats b/Resources/mole/tests/clean_cached_device_firmware.bats
new file mode 100644
index 0000000..0167fc5
--- /dev/null
+++ b/Resources/mole/tests/clean_cached_device_firmware.bats
@@ -0,0 +1,254 @@
+#!/usr/bin/env bats
+
+setup_file() {
+ PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)"
+ export PROJECT_ROOT
+
+ ORIGINAL_HOME="${HOME:-}"
+ export ORIGINAL_HOME
+
+ HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-device-firmware.XXXXXX")"
+ export HOME
+
+ MOLE_TEST_MODE=1
+ export MOLE_TEST_MODE
+
+ mkdir -p "$HOME"
+}
+
+teardown_file() {
+ rm -rf "$HOME"
+ if [[ -n "${ORIGINAL_HOME:-}" ]]; then
+ export HOME="$ORIGINAL_HOME"
+ fi
+}
+
+setup() {
+ rm -rf "$HOME/Library"
+}
+
+@test "clean_cached_device_firmware is a no-op when no .ipsw files exist" {
+ 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"
+
+files_cleaned=0
+total_size_cleaned=0
+total_items=0
+
+clean_cached_device_firmware
+echo "Items: $total_items"
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"Items: 0"* ]]
+ [[ "$output" != *"Cached device firmware"* ]]
+}
+
+@test "clean_cached_device_firmware reports .ipsw files in dry-run from iTunes dirs" {
+ 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"
+
+IPHONE_DIR="$HOME/Library/iTunes/iPhone Software Updates"
+IPAD_DIR="$HOME/Library/iTunes/iPad Software Updates"
+mkdir -p "$IPHONE_DIR" "$IPAD_DIR"
+touch "$IPHONE_DIR/iPhone17,1_18.0_22A000_Restore.ipsw"
+touch "$IPHONE_DIR/iPhone15,2_17.5_21F000_Restore.ipsw"
+touch "$IPAD_DIR/iPad14,1_18.0_22A000_Restore.ipsw"
+
+is_path_whitelisted() { return 1; }
+get_path_size_kb() { echo "5242880"; } # 5GB
+bytes_to_human() { echo "5.0G"; }
+note_activity() { :; }
+export -f is_path_whitelisted get_path_size_kb bytes_to_human note_activity
+
+files_cleaned=0
+total_size_cleaned=0
+total_items=0
+
+clean_cached_device_firmware
+echo "Files: $files_cleaned Items: $total_items"
+
+# Verify files still exist (dry-run must not delete)
+[[ -f "$IPHONE_DIR/iPhone17,1_18.0_22A000_Restore.ipsw" ]] || exit 11
+[[ -f "$IPAD_DIR/iPad14,1_18.0_22A000_Restore.ipsw" ]] || exit 12
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"Cached device firmware"* ]]
+ [[ "$output" == *"3 files"* ]]
+ [[ "$output" == *"dry"* ]]
+ [[ "$output" == *"Files: 3 Items: 1"* ]]
+}
+
+@test "clean_cached_device_firmware finds .ipsw in Apple Configurator 2 nested cache" {
+ 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"
+
+CONFIG_DIR="$HOME/Library/Group Containers/K36BKF7T3D.group.com.apple.configurator/Library/Caches/Firmware/iPhone"
+mkdir -p "$CONFIG_DIR"
+touch "$CONFIG_DIR/nested_firmware.ipsw"
+
+is_path_whitelisted() { return 1; }
+get_path_size_kb() { echo "6291456"; }
+bytes_to_human() { echo "6G"; }
+note_activity() { :; }
+export -f is_path_whitelisted get_path_size_kb bytes_to_human note_activity
+
+files_cleaned=0
+total_size_cleaned=0
+total_items=0
+
+clean_cached_device_firmware
+echo "Files: $files_cleaned"
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"Cached device firmware"* ]]
+ [[ "$output" == *"Files: 1"* ]]
+}
+
+@test "clean_cached_device_firmware removes .ipsw files when not dry-run" {
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false bash --noprofile --norc <<'EOF'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+source "$PROJECT_ROOT/lib/clean/user.sh"
+
+IPHONE_DIR="$HOME/Library/iTunes/iPhone Software Updates"
+mkdir -p "$IPHONE_DIR"
+IPSW="$IPHONE_DIR/test_firmware.ipsw"
+touch "$IPSW"
+
+is_path_whitelisted() { return 1; }
+get_path_size_kb() { echo "1024"; }
+bytes_to_human() { echo "1M"; }
+note_activity() { :; }
+safe_remove() { rm -f "$1"; return 0; }
+export -f is_path_whitelisted get_path_size_kb bytes_to_human note_activity safe_remove
+
+files_cleaned=0
+total_size_cleaned=0
+total_items=0
+
+clean_cached_device_firmware
+
+if [[ -f "$IPSW" ]]; then
+ echo "FAIL: ipsw still present"
+ exit 10
+fi
+echo "DELETED"
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"Cached device firmware"* ]]
+ [[ "$output" == *"DELETED"* ]]
+}
+
+@test "clean_cached_device_firmware dry-run leaves real filesystem untouched (no safe_remove mock)" {
+ 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"
+
+IPHONE_DIR="$HOME/Library/iTunes/iPhone Software Updates"
+mkdir -p "$IPHONE_DIR"
+IPSW="$IPHONE_DIR/preserve.ipsw"
+touch "$IPSW"
+
+is_path_whitelisted() { return 1; }
+get_path_size_kb() { echo "1024"; }
+bytes_to_human() { echo "1M"; }
+note_activity() { :; }
+export -f is_path_whitelisted get_path_size_kb bytes_to_human note_activity
+
+files_cleaned=0
+total_size_cleaned=0
+total_items=0
+
+# Do NOT mock safe_remove — real function must honor DRY_RUN
+clean_cached_device_firmware
+
+if [[ ! -f "$IPSW" ]]; then
+ echo "FAIL: dry-run deleted the file"
+ exit 20
+fi
+echo "PRESERVED"
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"Cached device firmware"* ]]
+ [[ "$output" == *"dry"* ]]
+ [[ "$output" == *"PRESERVED"* ]]
+}
+
+@test "clean_cached_device_firmware respects whitelist" {
+ 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"
+
+IPHONE_DIR="$HOME/Library/iTunes/iPhone Software Updates"
+mkdir -p "$IPHONE_DIR"
+touch "$IPHONE_DIR/keep.ipsw"
+
+is_path_whitelisted() { return 0; }
+get_path_size_kb() { echo "5242880"; }
+bytes_to_human() { echo "5G"; }
+note_activity() { :; }
+export -f is_path_whitelisted get_path_size_kb bytes_to_human note_activity
+
+files_cleaned=0
+total_size_cleaned=0
+total_items=0
+
+clean_cached_device_firmware
+echo "Files: $files_cleaned"
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"Files: 0"* ]]
+ [[ "$output" != *"Cached device firmware"* ]]
+}
+
+@test "clean_cached_device_firmware does not report success when deletion fails" {
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false bash --noprofile --norc <<'EOF'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+source "$PROJECT_ROOT/lib/clean/user.sh"
+
+IPHONE_DIR="$HOME/Library/iTunes/iPhone Software Updates"
+mkdir -p "$IPHONE_DIR"
+IPSW="$IPHONE_DIR/fail_firmware.ipsw"
+touch "$IPSW"
+
+is_path_whitelisted() { return 1; }
+get_path_size_kb() { echo "1024"; }
+bytes_to_human() { echo "1M"; }
+note_activity() { :; }
+safe_remove() { return 1; }
+export -f is_path_whitelisted get_path_size_kb bytes_to_human note_activity safe_remove
+
+files_cleaned=0
+total_size_cleaned=0
+total_items=0
+
+clean_cached_device_firmware
+echo "Files: $files_cleaned Items: $total_items Size: $total_size_cleaned"
+
+if [[ ! -f "$IPSW" ]]; then
+ echo "FAIL: file deleted"
+ exit 30
+fi
+echo "PRESENT"
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"Files: 0 Items: 0 Size: 0"* ]]
+ [[ "$output" == *"PRESENT"* ]]
+ [[ "$output" != *"Cached device firmware"* ]]
+}
diff --git a/Resources/mole/tests/clean_core.bats b/Resources/mole/tests/clean_core.bats
index 836c15e..ab05999 100644
--- a/Resources/mole/tests/clean_core.bats
+++ b/Resources/mole/tests/clean_core.bats
@@ -10,6 +10,10 @@ setup_file() {
HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-clean-home.XXXXXX")"
export HOME
+ # Prevent AppleScript permission dialogs during tests
+ MOLE_TEST_MODE=1
+ export MOLE_TEST_MODE
+
mkdir -p "$HOME"
}
@@ -87,6 +91,26 @@ run_clean_dry_run() {
[[ "$output" == *"full preview"* ]]
}
+@test "mo clean --dry-run survives an unwritable TMPDIR" {
+ local blocked_tmp="$HOME/blocked-tmp"
+ mkdir -p "$blocked_tmp"
+ chmod 500 "$blocked_tmp"
+
+ set_mock_sudo_uncached
+ local test_path="$PATH"
+ if [[ -n "${TEST_MOCK_BIN:-}" ]]; then
+ test_path="$TEST_MOCK_BIN:$PATH"
+ fi
+
+ run env HOME="$HOME" TMPDIR="$blocked_tmp" MOLE_TEST_MODE=1 PATH="$test_path" \
+ "$PROJECT_ROOT/mole" clean --dry-run
+
+ [ "$status" -eq 0 ]
+ [[ "$output" != *"mktemp:"* ]]
+ [[ "$output" != *"Failed to create temporary file"* ]]
+ [ -d "$HOME/.cache/mole/tmp" ]
+}
+
@test "mo clean --dry-run reports user cache without deleting it" {
mkdir -p "$HOME/Library/Caches/TestApp"
echo "cache data" > "$HOME/Library/Caches/TestApp/cache.tmp"
@@ -98,6 +122,41 @@ run_clean_dry_run() {
[ -f "$HOME/Library/Caches/TestApp/cache.tmp" ]
}
+@test "mo clean --dry-run reports stale login item without deleting it" {
+ mkdir -p "$HOME/Library/LaunchAgents"
+ cat > "$HOME/Library/LaunchAgents/com.example.stale.plist" <<'PLIST'
+
+
+
+
+ Label
+ com.example.stale
+ ProgramArguments
+
+ /Applications/Missing.app/Contents/MacOS/Missing
+
+
+
+PLIST
+
+ run env HOME="$HOME" MOLE_TEST_MODE=1 "$PROJECT_ROOT/mole" clean --dry-run
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"Potential stale login item: com.example.stale.plist"* ]]
+ [ -f "$HOME/Library/LaunchAgents/com.example.stale.plist" ]
+}
+
+@test "mo clean --dry-run does not export duplicate targets across sections" {
+ mkdir -p "$HOME/Library/Application Support/Code/CachedData"
+ echo "cache" > "$HOME/Library/Application Support/Code/CachedData/data.bin"
+
+ run env HOME="$HOME" MOLE_TEST_MODE=0 "$PROJECT_ROOT/mole" clean --dry-run
+ [ "$status" -eq 0 ]
+
+ run grep -c "Application Support/Code/CachedData" "$HOME/.config/mole/clean-list.txt"
+ [ "$status" -eq 0 ]
+ [ "$output" -eq 1 ]
+}
+
@test "mo clean honors whitelist entries" {
mkdir -p "$HOME/Library/Caches/WhitelistedApp"
echo "keep me" > "$HOME/Library/Caches/WhitelistedApp/data.tmp"
@@ -322,4 +381,3 @@ EOF
[ "$status" -eq 0 ]
[[ "$output" == *"Time Machine backup in progress, skipping cleanup"* ]]
}
-
diff --git a/Resources/mole/tests/clean_dev_caches.bats b/Resources/mole/tests/clean_dev_caches.bats
index 9a2137e..64365ff 100644
--- a/Resources/mole/tests/clean_dev_caches.bats
+++ b/Resources/mole/tests/clean_dev_caches.bats
@@ -159,25 +159,266 @@ EOF
[[ "$output" != *"(custom path)"* ]]
}
-@test "clean_dev_docker skips when daemon not running" {
- run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MO_DEBUG=1 DRY_RUN=false bash --noprofile --norc <<'EOF'
+@test "clean_dev_npm cleans default bun cache when bun is unavailable" {
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh"
source "$PROJECT_ROOT/lib/clean/dev.sh"
start_section_spinner() { :; }
stop_section_spinner() { :; }
-run_with_timeout() { return 1; }
-clean_tool_cache() { echo "$1"; }
+clean_tool_cache() { echo "$1|$*"; }
+safe_clean() { echo "$2|$1"; }
+note_activity() { :; }
+run_with_timeout() { shift; "$@"; }
+npm() { return 0; }
+bun() { return 1; }
+export -f npm bun
+clean_dev_npm
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"Bun cache|$HOME/.bun/install/cache/*"* ]]
+ [[ "$output" != *"bun cache|bun cache bun pm cache rm"* ]]
+ [[ "$output" != *"Orphaned bun cache"* ]]
+}
+
+@test "clean_dev_npm uses bun cache command for default bun cache path" {
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+source "$PROJECT_ROOT/lib/clean/dev.sh"
+start_section_spinner() { :; }
+stop_section_spinner() { :; }
+clean_tool_cache() { :; }
+safe_clean() { echo "$2|$1"; }
+note_activity() { :; }
+run_with_timeout() { shift; "$@"; }
+npm() { return 0; }
+bun() {
+ if [[ "$1" == "--version" ]]; then
+ echo "1.2.0"
+ return 0
+ fi
+ if [[ "$1" == "pm" && "$2" == "cache" && "${3:-}" == "rm" ]]; then
+ return 0
+ fi
+ if [[ "$1" == "pm" && "$2" == "cache" ]]; then
+ echo "$HOME/.bun/install/cache"
+ return 0
+ fi
+ return 0
+}
+export -f npm bun
+clean_dev_npm
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"bun cache"* ]]
+ [[ "$output" != *"Bun cache|$HOME/.bun/install/cache/*"* ]]
+ [[ "$output" != *"Orphaned bun cache"* ]]
+}
+
+@test "clean_dev_npm cleans orphaned default bun cache when custom path is configured" {
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+source "$PROJECT_ROOT/lib/clean/dev.sh"
+start_section_spinner() { :; }
+stop_section_spinner() { :; }
+clean_tool_cache() { :; }
+safe_clean() { echo "$2|$1"; }
+note_activity() { :; }
+run_with_timeout() { shift; "$@"; }
+npm() { return 0; }
+bun() {
+ if [[ "$1" == "--version" ]]; then
+ echo "1.2.0"
+ return 0
+ fi
+ if [[ "$1" == "pm" && "$2" == "cache" && "${3:-}" == "rm" ]]; then
+ return 0
+ fi
+ if [[ "$1" == "pm" && "$2" == "cache" ]]; then
+ echo "/tmp/mole-bun-cache"
+ return 0
+ fi
+ return 0
+}
+export -f npm bun
+clean_dev_npm
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"bun cache"* ]]
+ [[ "$output" == *"Orphaned bun cache|$HOME/.bun/install/cache/*"* ]]
+}
+
+@test "clean_dev_npm treats default bun cache path with trailing slash as same path" {
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+source "$PROJECT_ROOT/lib/clean/dev.sh"
+start_section_spinner() { :; }
+stop_section_spinner() { :; }
+clean_tool_cache() { :; }
+safe_clean() { echo "$2|$1"; }
+note_activity() { :; }
+run_with_timeout() { shift; "$@"; }
+npm() { return 0; }
+bun() {
+ if [[ "$1" == "--version" ]]; then
+ echo "1.2.0"
+ return 0
+ fi
+ if [[ "$1" == "pm" && "$2" == "cache" && "${3:-}" == "rm" ]]; then
+ return 0
+ fi
+ if [[ "$1" == "pm" && "$2" == "cache" ]]; then
+ echo "$HOME/.bun/install/cache/"
+ return 0
+ fi
+ return 0
+}
+export -f npm bun
+clean_dev_npm
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"bun cache"* ]]
+ [[ "$output" != *"Orphaned bun cache"* ]]
+}
+
+@test "clean_dev_npm falls back to filesystem cleanup when bun cache command fails" {
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+source "$PROJECT_ROOT/lib/clean/dev.sh"
+start_section_spinner() { :; }
+stop_section_spinner() { :; }
+clean_tool_cache() { :; }
+safe_clean() { echo "$2|$1"; }
+note_activity() { :; }
+run_with_timeout() { shift; "$@"; }
+npm() { return 0; }
+bun() {
+ if [[ "$1" == "--version" ]]; then
+ echo "1.2.0"
+ return 0
+ fi
+ if [[ "$1" == "pm" && "$2" == "cache" && "${3:-}" == "rm" ]]; then
+ return 1
+ fi
+ if [[ "$1" == "pm" && "$2" == "cache" ]]; then
+ echo "/tmp/mole-bun-cache"
+ return 0
+ fi
+ return 0
+}
+export -f npm bun
+clean_dev_npm
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"Bun cache|/tmp/mole-bun-cache/*"* ]]
+ [[ "$output" == *"Orphaned bun cache|$HOME/.bun/install/cache/*"* ]]
+}
+
+@test "clean_dev_docker skips daemon-managed cleanup by default" {
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false bash --noprofile --norc <<'EOF'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+source "$PROJECT_ROOT/lib/clean/dev.sh"
+clean_tool_cache() { echo "$1|$*"; }
safe_clean() { echo "$2"; }
-debug_log() { echo "$*"; }
-docker() { return 1; }
+note_activity() { :; }
+debug_log() { :; }
+docker() { echo "docker called"; return 0; }
+export -f docker
+clean_dev_docker
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"Docker unused data · skipped by default"* ]]
+ [[ "$output" == *"Review: docker system df"* ]]
+ [[ "$output" == *"Prune: docker system prune"* ]]
+ [[ "$output" == *"Docker BuildX cache"* ]]
+ [[ "$output" != *"docker called"* ]]
+}
+
+@test "clean_dev_docker keeps BuildX cache cleanup" {
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false bash --noprofile --norc <<'EOF'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+source "$PROJECT_ROOT/lib/clean/dev.sh"
+clean_tool_cache() { echo "$1|$*"; }
+safe_clean() { echo "$2|$1"; }
+note_activity() { :; }
+debug_log() { :; }
+docker() { return 0; }
export -f docker
clean_dev_docker
EOF
[ "$status" -eq 0 ]
- [[ "$output" == *"Docker daemon not running"* ]]
- [[ "$output" != *"Docker build cache"* ]]
+ [[ "$output" == *"Docker BuildX cache|$HOME/.docker/buildx/cache/*"* ]]
+}
+
+@test "clean_dev_docker no longer depends on whitelist to avoid prune" {
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false bash --noprofile --norc <<'EOF'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+source "$PROJECT_ROOT/lib/clean/dev.sh"
+clean_tool_cache() { echo "$1|$*"; }
+safe_clean() { :; }
+note_activity() { :; }
+debug_log() { :; }
+is_path_whitelisted() {
+ [[ "$1" == "$HOME/.docker" ]] && return 0
+ return 1
+}
+export -f is_path_whitelisted
+docker() { echo "docker called"; return 0; }
+export -f docker
+clean_dev_docker
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"Docker unused data · skipped by default"* ]]
+ [[ "$output" != *"whitelisted"* ]]
+ [[ "$output" != *"mo clean --whitelist"* ]]
+ [[ "$output" != *"docker called"* ]]
+ [[ "$output" == *"Prune: docker system prune"* ]]
+}
+
+@test "clean_dev_mise respects MISE_CACHE_DIR and only targets cache" {
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MISE_CACHE_DIR="/tmp/mise-cache" bash --noprofile --norc <<'EOF'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+source "$PROJECT_ROOT/lib/clean/dev.sh"
+safe_clean() { echo "$2|$1"; }
+clean_tool_cache() { :; }
+note_activity() { :; }
+run_with_timeout() { shift; "$@"; }
+clean_dev_mise
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"mise cache|/tmp/mise-cache/*"* ]]
+ [[ "$output" != *".local/share/mise"* ]]
+}
+
+@test "clean_dev_other_langs cleans configured composer cache paths" {
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" COMPOSER_HOME="$HOME/.config/composer-home" bash --noprofile --norc <<'EOF'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+source "$PROJECT_ROOT/lib/clean/dev.sh"
+safe_clean() { echo "$2|$1"; }
+clean_dev_other_langs
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"PHP Composer cache (legacy)|"* ]]
+ [[ "$output" == *"PHP Composer cache|"* ]]
}
@test "clean_developer_tools runs key stages" {
@@ -191,6 +432,7 @@ clean_homebrew() { echo "brew"; }
clean_project_caches() { :; }
clean_dev_python() { :; }
clean_dev_go() { :; }
+clean_dev_mise() { echo "mise"; }
clean_dev_rust() { :; }
check_rust_toolchains() { :; }
check_android_ndk() { :; }
@@ -222,6 +464,7 @@ EOF
[ "$status" -eq 0 ]
[[ "$output" == *"npm"* ]]
+ [[ "$output" == *"mise"* ]]
[[ "$output" == *"brew"* ]]
}
diff --git a/Resources/mole/tests/clean_hints.bats b/Resources/mole/tests/clean_hints.bats
index 04ab24f..634ba39 100644
--- a/Resources/mole/tests/clean_hints.bats
+++ b/Resources/mole/tests/clean_hints.bats
@@ -23,6 +23,10 @@ setup() {
mkdir -p "$HOME/.config/mole"
}
+teardown() {
+ rm -rf "$HOME/Library/LaunchAgents"
+}
+
@test "probe_project_artifact_hints reuses purge targets and excludes noisy names" {
local root="$HOME/hints-root"
mkdir -p "$root/proj/node_modules" "$root/proj/vendor" "$root/proj/bin"
@@ -97,3 +101,67 @@ EOT3
[[ "$output" == *"~/Library/Developer/Xcode/DerivedData"* ]]
[[ "$output" == *"Review: mo analyze, Device backups, docker system df"* ]]
}
+
+@test "show_user_launch_agent_hint_notice reports missing app-backed target" {
+ mkdir -p "$HOME/Library/LaunchAgents"
+ cat > "$HOME/Library/LaunchAgents/com.example.stale.plist" <<'PLIST'
+
+
+
+
+ Label
+ com.example.stale
+ ProgramArguments
+
+ /Applications/Missing.app/Contents/MacOS/Missing
+
+
+
+PLIST
+
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOT4'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+source "$PROJECT_ROOT/lib/clean/hints.sh"
+note_activity() { :; }
+show_user_launch_agent_hint_notice
+EOT4
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"Potential stale login item: com.example.stale.plist"* ]]
+ [[ "$output" == *"Missing app/helper target"* ]]
+ [[ "$output" == *"Review: open ~/Library/LaunchAgents"* ]]
+}
+
+@test "show_user_launch_agent_hint_notice skips custom shell wrappers" {
+ mkdir -p "$HOME/Library/LaunchAgents"
+ cat > "$HOME/Library/LaunchAgents/com.example.custom.plist" <<'PLIST'
+
+
+
+
+ Label
+ com.example.custom
+ ProgramArguments
+
+ /bin/bash
+ -c
+ $HOME/bin/custom-task
+
+
+
+PLIST
+
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOT5'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+source "$PROJECT_ROOT/lib/clean/hints.sh"
+note_activity() { :; }
+run_with_timeout() { shift; "$@"; }
+show_user_launch_agent_hint_notice
+EOT5
+
+ [ "$status" -eq 0 ]
+ [[ "$output" != *"Potential stale login item:"* ]]
+ [[ "$output" != *"Review: open ~/Library/LaunchAgents"* ]]
+}
diff --git a/Resources/mole/tests/clean_misc.bats b/Resources/mole/tests/clean_misc.bats
index 31282bb..2d0617d 100644
--- a/Resources/mole/tests/clean_misc.bats
+++ b/Resources/mole/tests/clean_misc.bats
@@ -10,6 +10,10 @@ setup_file() {
HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-clean-extras.XXXXXX")"
export HOME
+ # Prevent AppleScript permission dialogs during tests
+ MOLE_TEST_MODE=1
+ export MOLE_TEST_MODE
+
mkdir -p "$HOME"
}
diff --git a/Resources/mole/tests/clean_system_caches.bats b/Resources/mole/tests/clean_system_caches.bats
index 0275c70..3d1f091 100644
--- a/Resources/mole/tests/clean_system_caches.bats
+++ b/Resources/mole/tests/clean_system_caches.bats
@@ -72,10 +72,10 @@ setup() {
mkdir -p "$test_cache"
run bash -c "
- run_with_timeout() { shift; \"\$@\"; }
- export -f run_with_timeout
source '$PROJECT_ROOT/lib/core/common.sh'
source '$PROJECT_ROOT/lib/clean/caches.sh'
+ run_with_timeout() { shift; \"\$@\"; }
+ export -f run_with_timeout
clean_service_worker_cache 'TestBrowser' '$test_cache'
"
[ "$status" -eq 0 ]
@@ -89,6 +89,10 @@ setup() {
mkdir -p "$test_cache/def456_https_example.com_0"
run bash -c "
+ export DRY_RUN=true
+ export PROTECTED_SW_DOMAINS=(capcut.com photopea.com)
+ source '$PROJECT_ROOT/lib/core/common.sh'
+ source '$PROJECT_ROOT/lib/clean/caches.sh'
run_with_timeout() {
local timeout=\"\$1\"
shift
@@ -105,15 +109,92 @@ setup() {
\"\$@\"
}
export -f run_with_timeout
- export DRY_RUN=true
- export PROTECTED_SW_DOMAINS=(capcut.com photopea.com)
+ clean_service_worker_cache 'TestBrowser' '$test_cache'
+ "
+ [ "$status" -eq 0 ]
+
+ [[ -d "$test_cache/abc123_https_capcut.com_0" ]]
+
+ rm -rf "$test_cache"
+}
+
+# Regression for #724: MV3 extension SW caches are keyed by origin hash,
+# so the PROTECTED_SW_DOMAINS domain-match never fires for them. The
+# whitelist is the only escape hatch users have — respect it here.
+@test "clean_service_worker_cache honors is_path_whitelisted (#724)" {
+ local test_cache="$HOME/test_sw_cache_wl"
+ mkdir -p "$test_cache/abc123hash_extension"
+ mkdir -p "$test_cache/def456hash_other"
+
+ run bash -c "
+ export DRY_RUN=false
+ export PROTECTED_SW_DOMAINS=(nomatch.invalid)
source '$PROJECT_ROOT/lib/core/common.sh'
source '$PROJECT_ROOT/lib/clean/caches.sh'
+ WHITELIST_PATTERNS=('$test_cache/abc123hash_extension')
+ safe_remove() { echo \"REMOVE:\$1\"; return 0; }
+ export -f safe_remove
+ note_activity() { :; }
+ export -f note_activity
+ run_with_timeout() {
+ local timeout=\"\$1\"
+ shift
+ if [[ \"\$1\" == \"sh\" ]]; then
+ printf '%s\n' '$test_cache/abc123hash_extension' '$test_cache/def456hash_other'
+ return 0
+ fi
+ if [[ \"\$1\" == \"du\" ]]; then
+ printf '2048\t%s\n' \"\$3\"
+ return 0
+ fi
+ \"\$@\"
+ }
+ export -f run_with_timeout
clean_service_worker_cache 'TestBrowser' '$test_cache'
"
+
[ "$status" -eq 0 ]
+ # Whitelisted dir must never be passed to safe_remove
+ [[ "$output" != *"REMOVE:$test_cache/abc123hash_extension"* ]]
+ # Non-whitelisted dir must be removed
+ [[ "$output" == *"REMOVE:$test_cache/def456hash_other"* ]]
+ # UI reports the protection count
+ [[ "$output" == *"1 protected"* ]]
- [[ -d "$test_cache/abc123_https_capcut.com_0" ]]
+ rm -rf "$test_cache"
+}
+
+@test "clean_service_worker_cache colors cleaned size with success color" {
+ local test_cache="$HOME/test_sw_cache_colored"
+ mkdir -p "$test_cache/abc123_https_example.com_0"
+
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc < "$HOME/.config/mole/purge_paths"
+
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+source "$PROJECT_ROOT/lib/clean/caches.sh"
+roots=$(discover_project_cache_roots)
+printf '%s\n' "$roots"
+printf 'COUNT=%s\n' "$(printf '%s\n' "$roots" | sed '/^$/d' | wc -l | tr -d ' ')"
+EOF
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"COUNT=1"* ]]
+}
+
@test "clean_project_caches skips stalled root scans" {
mkdir -p "$HOME/.config/mole"
mkdir -p "$HOME/SlowProjects/app"
@@ -268,6 +445,34 @@ EOF
rm -rf "$HOME/.config/mole" "$HOME/SlowProjects" "$fake_bin"
}
+@test "scan_project_cache_root prunes conda and site-packages" {
+ mkdir -p "$HOME/Projects/miniconda3/lib/python3.11/site-packages/pkg1/__pycache__"
+ mkdir -p "$HOME/Projects/miniconda3/lib/python3.11/site-packages/pkg2/__pycache__"
+ mkdir -p "$HOME/Projects/app/__pycache__"
+ touch "$HOME/Projects/miniconda3/lib/python3.11/site-packages/pkg1/__pycache__/mod.pyc"
+ touch "$HOME/Projects/miniconda3/lib/python3.11/site-packages/pkg2/__pycache__/mod.pyc"
+ touch "$HOME/Projects/app/pyproject.toml"
+ touch "$HOME/Projects/app/__pycache__/mod.pyc"
+
+ local output_file
+ output_file=$(mktemp)
+
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc < "$HOME/.cache/mole/softwareupdate_list" <<'OUT'
+Software Update Tool
-defaults() { echo "0"; }
+Software Update found the following new or updated software:
+* Label: macOS 99
+ Title: macOS 99, Version: 99.1, Size: 1024KiB, Recommended: YES, Action: restart,
+OUT
run_with_timeout() {
echo "SHOULD_NOT_CALL_SOFTWAREUPDATE"
- return 0
+ return 124
}
check_macos_update
@@ -660,18 +679,63 @@ echo "MACOS_UPDATE_AVAILABLE=$MACOS_UPDATE_AVAILABLE"
EOF
[ "$status" -eq 0 ]
- [[ "$output" == *"System up to date"* ]]
- [[ "$output" == *"MACOS_UPDATE_AVAILABLE=false"* ]]
+ [[ "$output" == *"macOS 99, Version: 99.1"* ]]
+ [[ "$output" == *"MACOS_UPDATE_AVAILABLE=true"* ]]
[[ "$output" != *"SHOULD_NOT_CALL_SOFTWAREUPDATE"* ]]
}
-@test "check_macos_update outputs debug info when MO_DEBUG set" {
+@test "reset_softwareupdate_cache clears in-memory softwareupdate state" {
run bash --noprofile --norc << 'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh"
source "$PROJECT_ROOT/lib/check/all.sh"
-defaults() { echo "1"; }
+calls_file="$HOME/softwareupdate_calls"
+printf '0\n' > "$calls_file"
+first_file="$HOME/first_updates.txt"
+second_file="$HOME/second_updates.txt"
+rm -f "$HOME/.cache/mole/softwareupdate_list"
+SOFTWARE_UPDATE_LIST=""
+SOFTWARE_UPDATE_LIST_LOADED="false"
+run_with_timeout() {
+ local timeout="${1:-}"
+ shift
+ if [[ "${1:-}" == "softwareupdate" && "${2:-}" == "-l" && "${3:-}" == "--no-scan" ]]; then
+ local calls
+ calls=$(cat "$calls_file")
+ calls=$((calls + 1))
+ printf '%s\n' "$calls" > "$calls_file"
+ cat < "$first_file"
+reset_softwareupdate_cache
+get_software_updates > "$second_file"
+printf 'CALLS=%s\n' "$(cat "$calls_file")"
+printf 'FIRST=%s\n' "$(cat "$first_file")"
+printf 'SECOND=%s\n' "$(cat "$second_file")"
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"CALLS=2"* ]]
+ [[ "$output" == *"FIRST=Software Update Tool"* ]]
+ [[ "$output" == *"SECOND=Software Update Tool"* ]]
+ [[ "$output" == *"macOS 2"* ]]
+}
+
+@test "check_macos_update outputs debug info when MO_DEBUG set" {
+ run bash --noprofile --norc << 'EOF'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+source "$PROJECT_ROOT/lib/check/all.sh"
export MO_DEBUG=1
@@ -692,7 +756,7 @@ check_macos_update 2>&1
EOF
[ "$status" -eq 0 ]
- [[ "$output" == *"[DEBUG] softwareupdate exit status:"* ]]
+ [[ "$output" == *"[DEBUG] softwareupdate cached output lines:"* ]]
}
@test "run_with_timeout succeeds without GNU timeout" {
@@ -1456,6 +1520,7 @@ opt_bluetooth_reset
EOF
[ "$status" -eq 0 ]
+ [[ "$output" == *"Bluetooth devices may disconnect briefly during refresh"* ]]
[[ "$output" == *"Bluetooth module restarted"* ]]
}
diff --git a/Resources/mole/tests/clean_user_core.bats b/Resources/mole/tests/clean_user_core.bats
index acf3b54..c0273d7 100644
--- a/Resources/mole/tests/clean_user_core.bats
+++ b/Resources/mole/tests/clean_user_core.bats
@@ -10,6 +10,10 @@ setup_file() {
HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-user-core.XXXXXX")"
export HOME
+ # Prevent AppleScript permission dialogs during tests
+ MOLE_TEST_MODE=1
+ export MOLE_TEST_MODE
+
mkdir -p "$HOME"
}
@@ -76,6 +80,41 @@ EOF
[[ "$output" == *"Trash · emptied, 2 items"* ]]
}
+@test "clean_user_essentials keeps Mole runtime logs while cleaning other user logs" {
+ mkdir -p "$HOME/Library/Logs/mole"
+ mkdir -p "$HOME/Library/Logs/OtherApp"
+ touch "$HOME/Library/Logs/mole/operations.log"
+ touch "$HOME/Library/Logs/OtherApp/old.log"
+
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+source "$PROJECT_ROOT/lib/clean/user.sh"
+DRY_RUN=false
+start_section_spinner() { :; }
+stop_section_spinner() { :; }
+note_activity() { :; }
+is_path_whitelisted() { return 1; }
+safe_clean() {
+ local path=""
+ for path in "${@:1:$#-1}"; do
+ if should_protect_path "$path"; then
+ continue
+ fi
+ /bin/rm -rf "$path"
+ done
+}
+
+clean_user_essentials
+
+[[ -d "$HOME/Library/Logs/mole" ]]
+[[ -f "$HOME/Library/Logs/mole/operations.log" ]]
+[[ ! -e "$HOME/Library/Logs/OtherApp/old.log" ]]
+EOF
+
+ [ "$status" -eq 0 ]
+}
+
@test "clean_app_caches includes macOS system caches" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail
@@ -114,7 +153,7 @@ EOF
[[ "$output" == *"SPIN_START:Scanning app caches..."* ]]
}
-@test "clean_support_app_data targets crash, wallpaper, and messages preview caches only" {
+@test "clean_support_app_data targets crash, idle assets, and messages preview caches only" {
local support_home="$HOME/support-cache-home-1"
run env HOME="$support_home" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail
@@ -137,13 +176,14 @@ EOF
[ "$status" -eq 0 ]
[[ "$output" == *"FIND:$support_home/Library/Application Support/CrashReporter:30:f"* ]]
[[ "$output" == *"FIND:$support_home/Library/Application Support/com.apple.idleassetsd:30:f"* ]]
+ [[ "$output" != *"Aerial wallpaper videos"* ]]
[[ "$output" == *"Messages sticker cache"* ]]
[[ "$output" == *"Messages preview attachment cache"* ]]
[[ "$output" == *"Messages preview sticker cache"* ]]
[[ "$output" != *"Messages attachments"* ]]
}
-@test "clean_support_app_data skips messages preview caches while Messages is running" {
+@test "clean_support_app_data always cleans messages preview caches" {
local support_home="$HOME/support-cache-home-2"
run env HOME="$support_home" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail
@@ -158,10 +198,9 @@ clean_support_app_data
EOF
[ "$status" -eq 0 ]
- [[ "$output" == *"Messages is running"* ]]
- [[ "$output" != *"Messages sticker cache"* ]]
- [[ "$output" != *"Messages preview attachment cache"* ]]
- [[ "$output" != *"Messages preview sticker cache"* ]]
+ [[ "$output" == *"Messages sticker cache"* ]]
+ [[ "$output" == *"Messages preview attachment cache"* ]]
+ [[ "$output" == *"Messages preview sticker cache"* ]]
}
@test "clean_app_caches skips protected containers" {
@@ -188,6 +227,39 @@ EOF
[[ "$output" != *"App caches"* ]] || [[ "$output" == *"already clean"* ]]
}
+@test "clean_app_caches skips expensive size scans for large sandboxed caches" {
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true /bin/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() { :; }
+bytes_to_human() { echo "0B"; }
+note_activity() { :; }
+safe_clean() { :; }
+should_protect_data() { return 1; }
+is_critical_system_component() { return 1; }
+get_path_size_kb() {
+ echo "SHOULD_NOT_SIZE_SCAN"
+ return 0
+}
+files_cleaned=0
+total_size_cleaned=0
+total_items=0
+
+mkdir -p "$HOME/Library/Containers/com.example.large/Data/Library/Caches"
+for i in $(seq 1 101); do
+ touch "$HOME/Library/Containers/com.example.large/Data/Library/Caches/file-$i.tmp"
+done
+
+clean_app_caches
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"Sandboxed app caches"* ]]
+ [[ "$output" != *"SHOULD_NOT_SIZE_SCAN"* ]]
+}
+
@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
@@ -255,6 +327,37 @@ EOF
[[ "$output" != *"REMOVE:"* ]]
}
+@test "clean_application_support_logs skips whitelisted application support directories" {
+ local support_home="$HOME/support-appsupport-whitelist"
+ run env HOME="$support_home" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
+set -euo pipefail
+mkdir -p "$HOME"
+source "$PROJECT_ROOT/lib/core/common.sh"
+source "$PROJECT_ROOT/lib/clean/user.sh"
+start_section_spinner() { :; }
+stop_section_spinner() { :; }
+note_activity() { :; }
+safe_remove() { echo "REMOVE:$1"; }
+update_progress_if_needed() { return 1; }
+should_protect_data() { return 1; }
+is_critical_system_component() { return 1; }
+WHITELIST_PATTERNS=("$HOME/Library/Application Support/io.github.clash-verge-rev.clash-verge-rev")
+files_cleaned=0
+total_size_cleaned=0
+total_items=0
+
+mkdir -p "$HOME/Library/Application Support/io.github.clash-verge-rev.clash-verge-rev/logs"
+touch "$HOME/Library/Application Support/io.github.clash-verge-rev.clash-verge-rev/logs/runtime.log"
+
+clean_application_support_logs
+test -f "$HOME/Library/Application Support/io.github.clash-verge-rev.clash-verge-rev/logs/runtime.log"
+rm -rf "$HOME/Library/Application Support"
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" != *"REMOVE:"* ]]
+}
+
@test "app_support_entry_count_capped stops at cap without failing under pipefail" {
local support_home="$HOME/support-appsupport-cap"
run env HOME="$support_home" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
@@ -415,6 +518,36 @@ EOF
[[ "$output" != *"Group Containers logs/caches"* ]]
}
+@test "clean_group_container_caches skips per-item size scans for large candidates" {
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true /bin/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() { :; }
+bytes_to_human() { echo "0B"; }
+note_activity() { :; }
+get_path_size_kb() {
+ echo "SHOULD_NOT_SIZE_SCAN"
+ return 0
+}
+files_cleaned=0
+total_size_cleaned=0
+total_items=0
+
+mkdir -p "$HOME/Library/Group Containers/group.com.example.large/Library/Caches"
+for i in $(seq 1 101); do
+ touch "$HOME/Library/Group Containers/group.com.example.large/Library/Caches/file-$i.tmp"
+done
+
+clean_group_container_caches
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"Group Containers logs/caches"* ]]
+ [[ "$output" != *"SHOULD_NOT_SIZE_SCAN"* ]]
+}
+
@test "clean_finder_metadata respects protection flag" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PROTECT_FINDER_METADATA=true /bin/bash --noprofile --norc <<'EOF'
set -euo pipefail
@@ -447,6 +580,7 @@ set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh"
source "$PROJECT_ROOT/lib/clean/user.sh"
safe_clean() { echo "$2"; }
+clean_service_worker_cache() { :; }
note_activity() { :; }
files_cleaned=0
total_size_cleaned=0
@@ -460,6 +594,29 @@ EOF
[[ "$output" == *"Puppeteer browser cache"* ]]
}
+@test "clean_browsers cleans Brave Service Worker caches" {
+ mkdir -p "$HOME/Library/Application Support/BraveSoftware/Brave-Browser/Default/Service Worker/ScriptCache"
+
+ 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"
+safe_clean() { echo "$2"; }
+clean_service_worker_cache() { echo "Brave SW $1"; }
+note_activity() { :; }
+files_cleaned=0
+total_size_cleaned=0
+total_items=0
+clean_browsers
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"Brave SW Brave"* ]]
+ [[ "$output" == *"Brave Service Worker ScriptCache"* ]]
+
+ rm -rf "$HOME/Library"
+}
+
@test "clean_application_support_logs skips when no access" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail
@@ -511,3 +668,74 @@ EOF
[[ "$output" == *"FOUND: .hidden_dir"* ]]
[[ "$output" == *"FOUND: regular_file.txt"* ]]
}
+
+@test "validate_external_volume_target canonicalizes root before comparing target" {
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+source "$PROJECT_ROOT/lib/clean/user.sh"
+
+mock_bin="$HOME/bin"
+mkdir -p "$mock_bin"
+cat > "$mock_bin/diskutil" <<'MOCK'
+#!/bin/bash
+exit 0
+MOCK
+chmod +x "$mock_bin/diskutil"
+export PATH="$mock_bin:$PATH"
+
+real_root="$(mktemp -d "$HOME/ext-real.XXXXXX")"
+link_root="$HOME/ext-link"
+ln -s "$real_root" "$link_root"
+mkdir -p "$link_root/USB"
+export MOLE_EXTERNAL_VOLUMES_ROOT="$link_root"
+
+resolved=$(validate_external_volume_target "$link_root/USB")
+echo "RESOLVED=$resolved"
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"RESOLVED="*"/USB"* ]]
+ [[ "$output" != *"must be under"* ]]
+}
+
+@test "clean_app_caches caps precise sandbox size scans when many containers exist" {
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true MOLE_CONTAINER_CACHE_PRECISE_SIZE_LIMIT=2 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() { :; }
+safe_clean() { :; }
+clean_support_app_data() { :; }
+clean_group_container_caches() { :; }
+bytes_to_human() { echo "0B"; }
+note_activity() { :; }
+should_protect_data() { return 1; }
+is_critical_system_component() { return 1; }
+files_cleaned=0
+total_size_cleaned=0
+total_items=0
+
+count_file="$HOME/size-count"
+get_path_size_kb() {
+ local count
+ count=$(cat "$count_file" 2> /dev/null || echo "0")
+ count=$((count + 1))
+ echo "$count" > "$count_file"
+ echo "1"
+}
+
+for i in $(seq 1 5); do
+ mkdir -p "$HOME/Library/Containers/com.example.$i/Data/Library/Caches"
+ touch "$HOME/Library/Containers/com.example.$i/Data/Library/Caches/file-$i.tmp"
+done
+
+clean_app_caches
+echo "SIZE_CALLS=$(cat "$count_file")"
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"Sandboxed app caches"* ]]
+ [[ "$output" == *"SIZE_CALLS=2"* ]]
+}
diff --git a/Resources/mole/tests/clean_xcode_derived_data.bats b/Resources/mole/tests/clean_xcode_derived_data.bats
new file mode 100644
index 0000000..3f790ef
--- /dev/null
+++ b/Resources/mole/tests/clean_xcode_derived_data.bats
@@ -0,0 +1,151 @@
+#!/usr/bin/env bats
+
+setup_file() {
+ PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)"
+ export PROJECT_ROOT
+
+ ORIGINAL_HOME="${HOME:-}"
+ export ORIGINAL_HOME
+
+ HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-xcode-dd.XXXXXX")"
+ export HOME
+
+ mkdir -p "$HOME"
+}
+
+teardown_file() {
+ rm -rf "$HOME"
+ if [[ -n "${ORIGINAL_HOME:-}" ]]; then
+ export HOME="$ORIGINAL_HOME"
+ fi
+}
+
+@test "clean_xcode_derived_data reports project count and size" {
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+source "$PROJECT_ROOT/lib/clean/app_caches.sh"
+
+start_section_spinner() { :; }
+stop_section_spinner() { :; }
+note_activity() { :; }
+is_path_whitelisted() { return 1; }
+cleanup_result_color_kb() { echo "\033[0;32m"; }
+bytes_to_human() { echo "36 KB"; }
+DRY_RUN=false
+files_cleaned=0
+total_size_cleaned=0
+total_items=0
+
+pgrep() { return 1; }
+export -f pgrep
+
+dd_dir="$HOME/Library/Developer/Xcode/DerivedData"
+mkdir -p "$dd_dir/ProjectAlpha-abcdef123"
+mkdir -p "$dd_dir/ProjectBeta-ghijkl456"
+mkdir -p "$dd_dir/ProjectGamma-mnopqr789"
+echo "build output" > "$dd_dir/ProjectAlpha-abcdef123/build.o"
+echo "build output" > "$dd_dir/ProjectBeta-ghijkl456/build.o"
+echo "build output" > "$dd_dir/ProjectGamma-mnopqr789/build.o"
+
+clean_xcode_derived_data
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"3 projects"* ]]
+ [[ "$output" == *"Xcode DerivedData"* ]]
+}
+
+@test "clean_xcode_derived_data skips when Xcode is running" {
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+source "$PROJECT_ROOT/lib/clean/app_caches.sh"
+
+start_section_spinner() { :; }
+stop_section_spinner() { :; }
+note_activity() { :; }
+is_path_whitelisted() { return 1; }
+DRY_RUN=false
+
+pgrep() { return 0; }
+export -f pgrep
+
+dd_dir="$HOME/Library/Developer/Xcode/DerivedData"
+mkdir -p "$dd_dir/SomeProject-abc123"
+echo "data" > "$dd_dir/SomeProject-abc123/build.o"
+
+clean_xcode_derived_data
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"Xcode is running"* ]]
+}
+
+@test "clean_xcode_derived_data handles empty DerivedData" {
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+source "$PROJECT_ROOT/lib/clean/app_caches.sh"
+
+start_section_spinner() { :; }
+stop_section_spinner() { :; }
+note_activity() { :; }
+is_path_whitelisted() { return 1; }
+DRY_RUN=false
+pgrep() { return 1; }
+export -f pgrep
+
+mkdir -p "$HOME/Library/Developer/Xcode/DerivedData"
+
+clean_xcode_derived_data
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" != *"projects"* ]]
+}
+
+@test "clean_xcode_derived_data handles missing DerivedData dir" {
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+source "$PROJECT_ROOT/lib/clean/app_caches.sh"
+
+start_section_spinner() { :; }
+stop_section_spinner() { :; }
+note_activity() { :; }
+is_path_whitelisted() { return 1; }
+DRY_RUN=false
+pgrep() { return 1; }
+export -f pgrep
+
+clean_xcode_derived_data
+EOF
+
+ [ "$status" -eq 0 ]
+}
+
+@test "clean_xcode_derived_data dry run shows would-clean message" {
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+source "$PROJECT_ROOT/lib/clean/app_caches.sh"
+
+start_section_spinner() { :; }
+stop_section_spinner() { :; }
+note_activity() { :; }
+is_path_whitelisted() { return 1; }
+DRY_RUN=true
+pgrep() { return 1; }
+export -f pgrep
+
+dd_dir="$HOME/Library/Developer/Xcode/DerivedData"
+mkdir -p "$dd_dir/MyApp-abc123"
+echo "data" > "$dd_dir/MyApp-abc123/build.o"
+
+clean_xcode_derived_data
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"1 project"* ]]
+}
diff --git a/Resources/mole/tests/cli.bats b/Resources/mole/tests/cli.bats
index 44882fd..f783600 100644
--- a/Resources/mole/tests/cli.bats
+++ b/Resources/mole/tests/cli.bats
@@ -7,10 +7,31 @@ setup_file() {
ORIGINAL_HOME="${HOME:-}"
export ORIGINAL_HOME
+ # Capture real GOCACHE before HOME is replaced with a temp dir.
+ # Without this, go build would use $HOME/Library/Caches/go-build inside the
+ # temp dir (empty), causing a full cold rebuild on every test run (~6s).
+ ORIGINAL_GOCACHE="$(go env GOCACHE 2>/dev/null || true)"
+ export ORIGINAL_GOCACHE
+
HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-cli-home.XXXXXX")"
export HOME
mkdir -p "$HOME"
+
+ # Build Go binaries from current source for JSON tests.
+ # Point GOPATH/GOMODCACHE/GOCACHE at the real home so go build can reuse
+ # the module and build caches rather than doing a cold rebuild every run.
+ if command -v go > /dev/null 2>&1; then
+ ANALYZE_BIN="$(mktemp "${TMPDIR:-/tmp}/analyze-go.XXXXXX")"
+ STATUS_BIN="$(mktemp "${TMPDIR:-/tmp}/status-go.XXXXXX")"
+ GOPATH="${ORIGINAL_HOME}/go" GOMODCACHE="${ORIGINAL_HOME}/go/pkg/mod" \
+ GOCACHE="${ORIGINAL_GOCACHE}" \
+ go build -o "$ANALYZE_BIN" "$PROJECT_ROOT/cmd/analyze" 2>/dev/null
+ GOPATH="${ORIGINAL_HOME}/go" GOMODCACHE="${ORIGINAL_HOME}/go/pkg/mod" \
+ GOCACHE="${ORIGINAL_GOCACHE}" \
+ go build -o "$STATUS_BIN" "$PROJECT_ROOT/cmd/status" 2>/dev/null
+ export ANALYZE_BIN STATUS_BIN
+ fi
}
teardown_file() {
@@ -19,6 +40,7 @@ teardown_file() {
if [[ -n "${ORIGINAL_HOME:-}" ]]; then
export HOME="$ORIGINAL_HOME"
fi
+ rm -f "${ANALYZE_BIN:-}" "${STATUS_BIN:-}"
}
create_fake_utils() {
@@ -184,7 +206,7 @@ EOF
[ "$status" -eq 0 ]
MOLE_OUTPUT="$output"
- DEBUG_LOG="$HOME/.config/mole/mole_debug_session.log"
+ DEBUG_LOG="$HOME/Library/Logs/mole/mole_debug_session.log"
[ -f "$DEBUG_LOG" ]
run grep "Mole Debug Session" "$DEBUG_LOG"
@@ -206,7 +228,7 @@ EOF
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/Library/Logs/mole/mole_debug_session.log"
run grep "User:" "$DEBUG_LOG"
[ "$status" -eq 0 ]
@@ -215,6 +237,41 @@ EOF
[ "$status" -eq 0 ]
}
+@test "mo clean --help includes external volume option" {
+ run env HOME="$HOME" "$PROJECT_ROOT/mole" clean --help
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"--external PATH"* ]]
+ [[ "$output" == *"already-uninstalled apps"* ]]
+}
+
+@test "mo uninstall --help directs leftover-only cleanup to clean" {
+ run env HOME="$HOME" "$PROJECT_ROOT/mole" uninstall --help
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"already gone, use mo clean"* ]]
+}
+
+@test "mo clean --external accepts canonicalized custom root" {
+ real_root="$(mktemp -d "$HOME/ext-real.XXXXXX")"
+ link_root="$HOME/ext-link"
+ ln -s "$real_root" "$link_root"
+ mkdir -p "$link_root/USB/.Trashes"
+ touch "$link_root/USB/.Trashes/cache.tmp"
+
+ mock_bin="$HOME/mock-bin"
+ mkdir -p "$mock_bin"
+ cat > "$mock_bin/diskutil" <<'EOF'
+#!/usr/bin/env bash
+exit 0
+EOF
+ chmod +x "$mock_bin/diskutil"
+
+ run env HOME="$HOME" PATH="$mock_bin:$PATH" MOLE_EXTERNAL_VOLUMES_ROOT="$link_root" \
+ MOLE_TEST_NO_AUTH=1 "$PROJECT_ROOT/mole" clean --external "$link_root/USB" --dry-run
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"Clean External Volume"* ]]
+ [[ "$output" == *"External volume cleanup"* ]]
+}
+
@test "touchid status reflects pam file contents" {
pam_file="$HOME/pam_test"
cat >"$pam_file" <<'EOF'
@@ -278,3 +335,152 @@ EOF
run grep "pam_tid.so" "$pam_file"
[ "$status" -ne 0 ]
}
+
+# --- JSON output mode tests ---
+
+@test "mo analyze --json outputs valid JSON with expected fields" {
+ if [[ ! -x "${ANALYZE_BIN:-}" ]]; then
+ skip "analyze binary not available (go not installed?)"
+ fi
+
+ run "$ANALYZE_BIN" --json /tmp
+ [ "$status" -eq 0 ]
+
+ # Validate it is parseable JSON
+ echo "$output" | python3 -c "import sys, json; json.load(sys.stdin)"
+
+ # Check required top-level keys
+ echo "$output" | python3 -c "
+import sys, json
+data = json.load(sys.stdin)
+assert 'path' in data, 'missing path'
+assert 'overview' in data, 'missing overview'
+assert 'entries' in data, 'missing entries'
+assert 'total_size' in data, 'missing total_size'
+assert 'total_files' in data, 'missing total_files'
+assert isinstance(data['entries'], list), 'entries is not a list'
+"
+}
+
+@test "mo analyze --json entries contain required fields" {
+ if [[ ! -x "${ANALYZE_BIN:-}" ]]; then
+ skip "analyze binary not available (go not installed?)"
+ fi
+
+ run "$ANALYZE_BIN" --json /tmp
+ [ "$status" -eq 0 ]
+
+ echo "$output" | python3 -c "
+import sys, json
+data = json.load(sys.stdin)
+assert data['overview'] is False, 'explicit path should not be overview mode'
+for entry in data['entries']:
+ assert 'name' in entry, 'entry missing name'
+ assert 'path' in entry, 'entry missing path'
+ assert 'size' in entry, 'entry missing size'
+ assert 'is_dir' in entry, 'entry missing is_dir'
+"
+}
+
+@test "mo analyze --json path reflects target directory" {
+ if [[ ! -x "${ANALYZE_BIN:-}" ]]; then
+ skip "analyze binary not available (go not installed?)"
+ fi
+
+ run "$ANALYZE_BIN" --json /tmp
+ [ "$status" -eq 0 ]
+
+ echo "$output" | python3 -c "
+import sys, json
+data = json.load(sys.stdin)
+assert data['path'] == '/tmp' or data['path'] == '/private/tmp', \
+ f\"unexpected path: {data['path']}\"
+"
+}
+
+@test "mo analyze --json overview mode returns expected schema" {
+ if [[ ! -x "${ANALYZE_BIN:-}" ]]; then
+ skip "analyze binary not available (go not installed?)"
+ fi
+
+ run "$ANALYZE_BIN" --json
+ [ "$status" -eq 0 ]
+
+ echo "$output" | python3 -c "
+import sys, json
+data = json.load(sys.stdin)
+assert 'path' in data, 'missing path'
+assert 'overview' in data, 'missing overview'
+assert data['overview'] is True, 'overview scan should have overview: true'
+assert 'entries' in data, 'missing entries'
+assert 'total_size' in data, 'missing total_size'
+assert isinstance(data['entries'], list), 'entries is not a list'
+"
+}
+
+@test "mo status --json outputs valid JSON with expected fields" {
+ if [[ ! -x "${STATUS_BIN:-}" ]]; then
+ skip "status binary not available (go not installed?)"
+ fi
+
+ run "$STATUS_BIN" --json
+ [ "$status" -eq 0 ]
+
+ # Validate it is parseable JSON
+ echo "$output" | python3 -c "import sys, json; json.load(sys.stdin)"
+
+ # Check required top-level keys
+ echo "$output" | python3 -c "
+import sys, json
+data = json.load(sys.stdin)
+for key in ['cpu', 'memory', 'disks', 'health_score', 'host', 'uptime']:
+ assert key in data, f'missing key: {key}'
+"
+}
+
+@test "mo status --json cpu section has expected structure" {
+ if [[ ! -x "${STATUS_BIN:-}" ]]; then
+ skip "status binary not available (go not installed?)"
+ fi
+
+ run "$STATUS_BIN" --json
+ [ "$status" -eq 0 ]
+
+ echo "$output" | python3 -c "
+import sys, json
+data = json.load(sys.stdin)
+cpu = data['cpu']
+assert 'usage' in cpu, 'cpu missing usage'
+assert 'logical_cpu' in cpu, 'cpu missing logical_cpu'
+assert isinstance(cpu['usage'], (int, float)), 'cpu usage is not a number'
+"
+}
+
+@test "mo status --json memory section has expected structure" {
+ if [[ ! -x "${STATUS_BIN:-}" ]]; then
+ skip "status binary not available (go not installed?)"
+ fi
+
+ run "$STATUS_BIN" --json
+ [ "$status" -eq 0 ]
+
+ echo "$output" | python3 -c "
+import sys, json
+data = json.load(sys.stdin)
+mem = data['memory']
+assert 'total' in mem, 'memory missing total'
+assert 'used' in mem, 'memory missing used'
+assert 'used_percent' in mem, 'memory missing used_percent'
+assert mem['total'] > 0, 'memory total should be positive'
+"
+}
+
+@test "mo status --json piped to stdout auto-detects JSON mode" {
+ if [[ ! -x "${STATUS_BIN:-}" ]]; then
+ skip "status binary not available (go not installed?)"
+ fi
+
+ # When piped (not a tty), status should auto-detect and output JSON
+ output=$("$STATUS_BIN" 2>/dev/null)
+ echo "$output" | python3 -c "import sys, json; json.load(sys.stdin)"
+}
diff --git a/Resources/mole/tests/completion.bats b/Resources/mole/tests/completion.bats
index 562a731..24645fe 100755
--- a/Resources/mole/tests/completion.bats
+++ b/Resources/mole/tests/completion.bats
@@ -101,14 +101,14 @@ setup() {
@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"* ]]
+ [[ "$output" == *"complete -f -c mole"* ]]
+ [[ "$output" == *"complete -f -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")
+ mole_count=$(echo "$output" | grep -c "complete -f -c mole")
+ mo_count=$(echo "$output" | grep -c "complete -f -c mo")
[ "$mole_count" -gt 0 ]
[ "$mo_count" -gt 0 ]
diff --git a/Resources/mole/tests/core_common.bats b/Resources/mole/tests/core_common.bats
index 69d0a6f..a7a279c 100644
--- a/Resources/mole/tests/core_common.bats
+++ b/Resources/mole/tests/core_common.bats
@@ -44,13 +44,31 @@ setup() {
[[ -n "$result" ]]
}
+@test "cleanup_result_color_kb always returns green" {
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+
+small_kb=1
+large_kb=$(((MOLE_ONE_GB_BYTES * 2) / 1024))
+
+if [[ "$(cleanup_result_color_kb "$small_kb")" == "$GREEN" ]] &&
+ [[ "$(cleanup_result_color_kb "$large_kb")" == "$GREEN" ]]; then
+ echo "ok"
+fi
+EOF
+
+ [ "$status" -eq 0 ]
+ [ "$output" = "ok" ]
+}
+
@test "log_info prints message and appends to log file" {
local message="Informational message from test"
local stdout_output
stdout_output="$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; log_info '$message'")"
[[ "$stdout_output" == *"$message"* ]]
- local log_file="$HOME/.config/mole/mole.log"
+ local log_file="$HOME/Library/Logs/mole/mole.log"
[[ -f "$log_file" ]]
grep -q "INFO: $message" "$log_file"
}
@@ -64,13 +82,35 @@ setup() {
[[ -s "$stderr_file" ]]
grep -q "$message" "$stderr_file"
- local log_file="$HOME/.config/mole/mole.log"
+ local log_file="$HOME/Library/Logs/mole/mole.log"
[[ -f "$log_file" ]]
grep -q "ERROR: $message" "$log_file"
}
+@test "log_operation recreates operations log if the log directory disappears mid-session" {
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+rm -rf "$HOME/Library/Logs/mole"
+log_operation "clean" "REMOVED" "/tmp/example" "1KB"
+EOF
+ [ "$status" -eq 0 ]
+
+ local oplog="$HOME/Library/Logs/mole/operations.log"
+ [[ -f "$oplog" ]]
+ grep -Fq "[clean] REMOVED /tmp/example (1KB)" "$oplog"
+}
+
+@test "should_protect_path protects Mole runtime logs" {
+ result="$(
+ HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc -c \
+ 'source "$PROJECT_ROOT/lib/core/common.sh"; should_protect_path "$HOME/Library/Logs/mole/operations.log" && echo protected || echo not-protected'
+ )"
+ [ "$result" = "protected" ]
+}
+
@test "rotate_log_once only checks log size once per session" {
- local log_file="$HOME/.config/mole/mole.log"
+ local log_file="$HOME/Library/Logs/mole/mole.log"
mkdir -p "$(dirname "$log_file")"
dd if=/dev/zero of="$log_file" bs=1024 count=1100 2> /dev/null
@@ -142,10 +182,24 @@ EOF
result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_data 'com.clash.app' && echo 'protected' || echo 'not-protected'")
[ "$result" = "protected" ]
+ result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_data 'io.github.clash-verge-rev.clash-verge-rev' && echo 'protected' || echo 'not-protected'")
+ [ "$result" = "protected" ]
+
result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_data 'com.example.RegularApp' && echo 'protected' || echo 'not-protected'")
[ "$result" = "not-protected" ]
}
+# Regression: CUPS prefs have a bundle-ID-style name but no parent .app,
+# so the orphan sweep deleted them and users lost their default printer
+# and recent-printer list. See #731.
+@test "should_protect_data protects CUPS printing prefs (#731)" {
+ result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_data 'org.cups.PrintingPrefs' && echo 'protected' || echo 'not-protected'")
+ [ "$result" = "protected" ]
+
+ result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_data 'org.cups.printers' && echo 'protected' || echo 'not-protected'")
+ [ "$result" = "protected" ]
+}
+
@test "input methods are protected during cleanup but allowed for uninstall" {
result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_data 'com.tencent.inputmethod.QQInput' && echo 'protected' || echo 'not-protected'")
[ "$result" = "protected" ]
@@ -201,10 +255,30 @@ sleep 0.1
stop_inline_spinner
echo "done"
EOF
-)
+ )
[[ "$result" == *"done"* ]]
}
+@test "start_inline_spinner ignores PATH-provided sleep in TTY mode" {
+ local fake_bin="$HOME/fake-bin"
+ local marker="$HOME/fake-sleep.marker"
+
+ mkdir -p "$fake_bin"
+ cat > "$fake_bin/sleep" <> "$marker"
+exec /bin/sleep "\$@"
+EOF
+ chmod +x "$fake_bin/sleep"
+
+ PATH="$fake_bin:$PATH" PROJECT_ROOT="$PROJECT_ROOT" HOME="$HOME" \
+ /usr/bin/script -q /dev/null /bin/bash --noprofile --norc -c \
+ "source \"\$PROJECT_ROOT/lib/core/common.sh\"; start_inline_spinner \"Testing...\"; /bin/sleep 0.15; stop_inline_spinner" \
+ > /dev/null 2>&1
+
+ [ ! -f "$marker" ]
+}
+
@test "read_key maps j/k/h/l to navigation" {
run bash -c "export MOLE_BASE_LOADED=1; source '$PROJECT_ROOT/lib/core/ui.sh'; echo -n 'j' | read_key"
[ "$output" = "DOWN" ]
@@ -231,3 +305,34 @@ EOF
run bash -c "export MOLE_BASE_LOADED=1; export MOLE_READ_KEY_FORCE_CHAR=1; source '$PROJECT_ROOT/lib/core/ui.sh'; echo -n 'j' | read_key"
[ "$output" = "CHAR:j" ]
}
+
+@test "ensure_sudo_session returns 1 and sets MOLE_SUDO_ESTABLISHED=false in test mode" {
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_NO_AUTH=1 bash --noprofile --norc <<'SCRIPT'
+source "$PROJECT_ROOT/lib/core/base.sh"
+source "$PROJECT_ROOT/lib/core/sudo.sh"
+MOLE_SUDO_ESTABLISHED=""
+ensure_sudo_session "Test prompt" && rc=0 || rc=$?
+echo "EXIT=$rc"
+echo "FLAG=$MOLE_SUDO_ESTABLISHED"
+SCRIPT
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"EXIT=1"* ]]
+ [[ "$output" == *"FLAG=false"* ]]
+}
+
+@test "ensure_sudo_session short-circuits to 0 when session already established" {
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'SCRIPT'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/base.sh"
+source "$PROJECT_ROOT/lib/core/sudo.sh"
+has_sudo_session() { return 0; }
+export -f has_sudo_session
+MOLE_SUDO_ESTABLISHED="true"
+ensure_sudo_session "Test prompt"
+echo "EXIT=$?"
+SCRIPT
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"EXIT=0"* ]]
+}
diff --git a/Resources/mole/tests/core_performance.bats b/Resources/mole/tests/core_performance.bats
index 4bf4034..36361ab 100644
--- a/Resources/mole/tests/core_performance.bats
+++ b/Resources/mole/tests/core_performance.bats
@@ -108,7 +108,7 @@ setup() {
@test "get_invoking_user executes quickly" {
local start end elapsed
- local limit_ms="${MOLE_PERF_GET_INVOKING_USER_LIMIT_MS:-500}"
+ local limit_ms="${MOLE_PERF_GET_INVOKING_USER_LIMIT_MS:-2000}"
start=$(date +%s%N)
for i in {1..100}; do
@@ -132,6 +132,7 @@ setup() {
@test "create_temp_file and cleanup_temp_files work efficiently" {
local start end elapsed
+ local limit_ms="${MOLE_PERF_CREATE_TEMP_FILE_LIMIT_MS:-3000}"
declare -a MOLE_TEMP_DIRS=()
@@ -143,7 +144,7 @@ setup() {
elapsed=$(( (end - start) / 1000000 ))
- [ "$elapsed" -lt 1000 ]
+ [ "$elapsed" -lt "$limit_ms" ]
[ "${#MOLE_TEMP_FILES[@]}" -eq 50 ]
@@ -152,7 +153,7 @@ setup() {
end=$(date +%s%N)
elapsed=$(( (end - start) / 1000000 ))
- [ "$elapsed" -lt 2000 ]
+ [ "$elapsed" -lt "$limit_ms" ]
[ "${#MOLE_TEMP_FILES[@]}" -eq 0 ]
}
@@ -233,5 +234,6 @@ setup() {
elapsed=$(( (end - start) / 1000000 ))
- [ "$elapsed" -lt 2000 ]
+ local limit_ms="${MOLE_PERF_SECTION_LIMIT_MS:-2000}"
+ [ "$elapsed" -lt "$limit_ms" ]
}
diff --git a/Resources/mole/tests/core_safe_functions.bats b/Resources/mole/tests/core_safe_functions.bats
index 5805f04..a2ea495 100644
--- a/Resources/mole/tests/core_safe_functions.bats
+++ b/Resources/mole/tests/core_safe_functions.bats
@@ -66,6 +66,9 @@ teardown() {
}
@test "validate_path_for_deletion rejects system directories" {
+ run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/'"
+ [ "$status" -eq 1 ]
+
run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/System'"
[ "$status" -eq 1 ]
@@ -86,6 +89,15 @@ teardown() {
[ "$status" -eq 1 ]
}
+@test "validate_path_for_deletion rejects symlink to protected system path" {
+ local link_path="$TEST_DIR/system-link"
+ ln -s "/System" "$link_path"
+
+ run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '$link_path' 2>&1"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"protected system path"* ]]
+}
+
@test "safe_remove successfully removes file" {
local test_file="$TEST_DIR/test_file.txt"
echo "test" > "$test_file"
@@ -134,6 +146,22 @@ teardown() {
[ "$status" -eq 1 ]
}
+@test "safe_sudo_remove refuses symlink paths" {
+ local target_dir="$TEST_DIR/real"
+ local link_dir="$TEST_DIR/link"
+ mkdir -p "$target_dir"
+ ln -s "$target_dir" "$link_dir"
+
+ run bash -c "
+ source '$PROJECT_ROOT/lib/core/common.sh'
+ sudo() { return 0; }
+ export -f sudo
+ safe_sudo_remove '$link_dir' 2>&1
+ "
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"Refusing to sudo remove symlink"* ]]
+}
+
@test "safe_find_delete rejects symlinked directory" {
local real_dir="$TEST_DIR/real"
local link_dir="$TEST_DIR/link"
diff --git a/Resources/mole/tests/dev_environment.bats b/Resources/mole/tests/dev_environment.bats
new file mode 100644
index 0000000..c86766c
--- /dev/null
+++ b/Resources/mole/tests/dev_environment.bats
@@ -0,0 +1,229 @@
+#!/usr/bin/env bats
+
+setup_file() {
+ PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)"
+ export PROJECT_ROOT
+
+ ORIGINAL_HOME="${HOME:-}"
+ export ORIGINAL_HOME
+
+ HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-devenv.XXXXXX")"
+ export HOME
+
+ mkdir -p "$HOME"
+}
+
+teardown_file() {
+ rm -rf "$HOME"
+ if [[ -n "${ORIGINAL_HOME:-}" ]]; then
+ export HOME="$ORIGINAL_HOME"
+ fi
+}
+
+# ============================================================================
+# Launch Agents tests
+# ============================================================================
+
+@test "check_launch_agents reports healthy when no broken agents" {
+ 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/check/dev_environment.sh"
+mkdir -p "$HOME/Library/LaunchAgents"
+cat > "$HOME/Library/LaunchAgents/com.test.valid.plist" << 'INNER_PLIST'
+
+
+
+
+ Label
+ com.test.valid
+ ProgramArguments
+
+ /bin/sh
+
+
+
+INNER_PLIST
+check_launch_agents
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"All healthy"* ]]
+}
+
+@test "check_launch_agents detects broken agent" {
+ 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/check/dev_environment.sh"
+mkdir -p "$HOME/Library/LaunchAgents"
+cat > "$HOME/Library/LaunchAgents/com.test.broken.plist" << 'INNER_PLIST'
+
+
+
+
+ Label
+ com.test.broken
+ ProgramArguments
+
+ /nonexistent/path/to/binary
+
+
+
+INNER_PLIST
+check_launch_agents
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"1 broken"* ]]
+ [[ "$output" == *"com.test.broken"* ]]
+}
+
+@test "check_launch_agents healthy when directory missing" {
+ 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/check/dev_environment.sh"
+rm -rf "$HOME/Library/LaunchAgents"
+check_launch_agents
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"All healthy"* ]]
+}
+
+# ============================================================================
+# Dev Tools tests
+# ============================================================================
+
+@test "check_dev_tools reports found tools" {
+ 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/check/dev_environment.sh"
+command() {
+ if [[ "$1" == "-v" ]]; then
+ case "$2" in
+ docker|go) return 1 ;;
+ *) builtin command "$@" ;;
+ esac
+ else
+ builtin command "$@"
+ fi
+}
+export -f command
+check_dev_tools
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"Dev Tools"* ]]
+ [[ "$output" == *"found"* ]]
+ [[ "$output" != *"docker"* ]]
+ [[ "$output" != *"not found"* ]]
+}
+
+# ============================================================================
+# Version Mismatches tests
+# ============================================================================
+
+@test "check_version_mismatches detects psql mismatch" {
+ 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/check/dev_environment.sh"
+psql() { echo "psql (PostgreSQL) 16.2"; }
+postgres() { echo "postgres (PostgreSQL) 14.1"; }
+export -f psql postgres
+command() {
+ if [[ "$1" == "-v" ]]; then
+ case "$2" in
+ psql|postgres) return 0 ;;
+ pyenv) return 1 ;;
+ *) builtin command "$@" ;;
+ esac
+ else
+ builtin command "$@"
+ fi
+}
+export -f command
+check_version_mismatches
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"psql 16.2 vs server 14.1"* ]]
+}
+
+@test "check_version_mismatches reports no conflicts when versions match" {
+ 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/check/dev_environment.sh"
+psql() { echo "psql (PostgreSQL) 16.2"; }
+postgres() { echo "postgres (PostgreSQL) 16.2"; }
+export -f psql postgres
+command() {
+ if [[ "$1" == "-v" ]]; then
+ case "$2" in
+ psql|postgres) return 0 ;;
+ pyenv) return 1 ;;
+ *) builtin command "$@" ;;
+ esac
+ else
+ builtin command "$@"
+ fi
+}
+export -f command
+check_version_mismatches
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"No conflicts"* ]]
+}
+
+@test "_extract_major_minor handles version strings" {
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/check/dev_environment.sh"
+result1=$(_extract_major_minor "v18.17.1")
+result2=$(_extract_major_minor "PostgreSQL 16.2")
+result3=$(_extract_major_minor "Python 3.12.1")
+echo "r1:$result1"
+echo "r2:$result2"
+echo "r3:$result3"
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"r1:18.17"* ]]
+ [[ "$output" == *"r2:16.2"* ]]
+ [[ "$output" == *"r3:3.12"* ]]
+}
+
+# ============================================================================
+# Aggregator test
+# ============================================================================
+
+@test "check_all_dev_environment runs all three checks" {
+ 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/check/dev_environment.sh"
+mkdir -p "$HOME/Library/LaunchAgents"
+command() {
+ if [[ "$1" == "-v" ]]; then
+ case "$2" in
+ psql|postgres|pyenv) return 1 ;;
+ *) builtin command "$@" ;;
+ esac
+ else
+ builtin command "$@"
+ fi
+}
+export -f command
+check_all_dev_environment
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"Launch Agents"* || "$output" == *"All healthy"* ]]
+ [[ "$output" == *"Dev Tools"* ]]
+ [[ "$output" == *"Versions"* || "$output" == *"No conflicts"* ]]
+}
diff --git a/Resources/mole/tests/dev_extended.bats b/Resources/mole/tests/dev_extended.bats
index ec7515e..939eae8 100644
--- a/Resources/mole/tests/dev_extended.bats
+++ b/Resources/mole/tests/dev_extended.bats
@@ -99,6 +99,7 @@ set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh"
source "$PROJECT_ROOT/lib/clean/dev.sh"
safe_clean() { echo "$2"; }
+clean_service_worker_cache() { :; }
clean_dev_editors
EOF
@@ -272,6 +273,115 @@ EOF
[[ "$output" != *"/241.2"* ]]
}
+@test "clean_dev_ai_agents keeps newest version and removes older ones by mtime" {
+ local claude_root="$HOME/.local/share/claude/versions"
+ local cursor_root="$HOME/.local/share/cursor-agent/versions"
+ mkdir -p "$claude_root" "$cursor_root"
+ touch -t 202604170829 "$claude_root/2.1.112"
+ touch -t 202604180902 "$claude_root/2.1.113"
+ touch -t 202604181002 "$claude_root/2.1.114"
+ mkdir -p "$cursor_root/2026.04.08-old" "$cursor_root/2026.04.15-new"
+ touch -t 202604080000 "$cursor_root/2026.04.08-old"
+ touch -t 202604150000 "$cursor_root/2026.04.15-new"
+
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+source "$PROJECT_ROOT/lib/clean/dev.sh"
+note_activity() { :; }
+safe_clean() { echo "$1|$2"; }
+clean_dev_ai_agents
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"/2.1.112|Claude Code old version"* ]]
+ [[ "$output" == *"/2.1.113|Claude Code old version"* ]]
+ [[ "$output" != *"/2.1.114|"* ]]
+ [[ "$output" == *"/2026.04.08-old|Cursor Agent old version"* ]]
+ [[ "$output" != *"/2026.04.15-new|"* ]]
+}
+
+@test "clean_dev_ai_agents respects MOLE_AI_AGENTS_KEEP and skips missing roots" {
+ local claude_root="$HOME/.local/share/claude/versions"
+ mkdir -p "$claude_root"
+ touch -t 202604170000 "$claude_root/2.1.100"
+ touch -t 202604180000 "$claude_root/2.1.101"
+ touch -t 202604190000 "$claude_root/2.1.102"
+
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+source "$PROJECT_ROOT/lib/clean/dev.sh"
+note_activity() { :; }
+safe_clean() { echo "$1"; }
+MOLE_AI_AGENTS_KEEP=2 clean_dev_ai_agents
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"/2.1.100"* ]]
+ [[ "$output" != *"/2.1.101"* ]]
+ [[ "$output" != *"/2.1.102"* ]]
+}
+
+@test "clean_dev_jetbrains_logs only targets JetBrains logs" {
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+source "$PROJECT_ROOT/lib/clean/dev.sh"
+safe_clean() { printf '%s|%s\n' "$1" "$2"; }
+clean_dev_jetbrains_logs
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"$HOME/Library/Logs/JetBrains/*|JetBrains IDE logs"* ]]
+ [[ "$output" != *"Library/Caches/JetBrains"* ]]
+}
+
+@test "clean_developer_tools includes JetBrains logs but not JetBrains cache sweep" {
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+source "$PROJECT_ROOT/lib/clean/dev.sh"
+stop_section_spinner() { :; }
+note_activity() { :; }
+safe_clean() { printf '%s|%s\n' "$1" "$2"; }
+clean_tool_cache() { :; }
+check_rust_toolchains() { :; }
+clean_dev_npm() { :; }
+clean_dev_python() { :; }
+clean_dev_go() { :; }
+clean_dev_mise() { :; }
+clean_dev_rust() { :; }
+clean_dev_docker() { :; }
+clean_dev_cloud() { :; }
+clean_dev_nix() { :; }
+clean_dev_shell() { :; }
+clean_dev_frontend() { :; }
+clean_project_caches() { :; }
+clean_dev_mobile() { :; }
+clean_dev_jvm() { :; }
+clean_dev_jetbrains_toolbox() { :; }
+clean_dev_ai_agents() { :; }
+clean_dev_other_langs() { :; }
+clean_dev_cicd() { :; }
+clean_dev_database() { :; }
+clean_dev_api_tools() { :; }
+clean_dev_network() { :; }
+clean_dev_misc() { :; }
+clean_dev_elixir() { :; }
+clean_dev_haskell() { :; }
+clean_dev_ocaml() { :; }
+clean_xcode_tools() { :; }
+clean_code_editors() { :; }
+clean_homebrew() { :; }
+clean_developer_tools
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"$HOME/Library/Logs/JetBrains/*|JetBrains IDE logs"* ]]
+ [[ "$output" != *"Library/Caches/JetBrains"* ]]
+}
+
@test "clean_xcode_simulator_runtime_volumes shows scan progress and skips sizing in-use volumes" {
local volumes_root="$HOME/sim-volumes"
local cryptex_root="$HOME/sim-cryptex"
diff --git a/Resources/mole/tests/file_ops_mole_delete.bats b/Resources/mole/tests/file_ops_mole_delete.bats
new file mode 100644
index 0000000..97f0b2d
--- /dev/null
+++ b/Resources/mole/tests/file_ops_mole_delete.bats
@@ -0,0 +1,212 @@
+#!/usr/bin/env bats
+
+# Tests for mole_delete in lib/core/file_ops.sh.
+# Exercises permanent mode (default), trash mode (via MOLE_TEST_TRASH_DIR
+# so Finder is never invoked), dry-run, and the deletions log.
+
+setup_file() {
+ PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)"
+ export PROJECT_ROOT
+}
+
+setup() {
+ SANDBOX="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-mole-delete.XXXXXX")"
+ export SANDBOX
+ export MOLE_DELETE_LOG="$SANDBOX/deletions.log"
+ export MOLE_TEST_TRASH_DIR="$SANDBOX/Trash"
+ export MOLE_TEST_NO_AUTH=1
+ unset MOLE_DELETE_MODE
+ unset MOLE_DRY_RUN
+}
+
+teardown() {
+ rm -rf "$SANDBOX"
+}
+
+prelude() {
+ cat < "$victim/keep.txt"
+
+ run bash --noprofile --norc < /dev/null || true)" ]]
+}
+
+@test "mole_delete trash mode moves the target instead of rm -rf" {
+ local victim="$SANDBOX/victim_trash"
+ mkdir -p "$victim"
+ printf 'payload' > "$victim/data.txt"
+
+ run bash --noprofile --norc < /dev/null || true)" ]]
+}
+
+@test "mole_delete writes a tab-separated log line per call" {
+ local victim="$SANDBOX/logged"
+ : > "$victim"
+
+ run bash --noprofile --norc < "$victim"
+
+ run bash --noprofile --norc < "$victim"
+
+ # Pointing MOLE_TEST_TRASH_DIR at a non-writable parent forces the stub
+ # trash move to fail, exercising the fallback path.
+ local blocked="$SANDBOX/blocked/Trash"
+ mkdir -p "$(dirname "$blocked")"
+ chmod 0555 "$(dirname "$blocked")"
+
+ run bash --noprofile --norc < "$victim"
+
+ run bash --noprofile --norc < "$victim"
+ local broken_log_dir="$SANDBOX/no_write/logs"
+ mkdir -p "$(dirname "$broken_log_dir")"
+ chmod 0555 "$(dirname "$broken_log_dir")"
+
+ run bash --noprofile --norc < "$SANDBOX/second_victim"
+mole_delete "$SANDBOX/second_victim"
+EOF
+
+ chmod 0755 "$(dirname "$broken_log_dir")"
+
+ [ "$status" -eq 0 ]
+ # Warning visible exactly once.
+ local warn_count
+ warn_count=$(printf '%s\n' "$output" | grep -c "deletions audit log unavailable" || true)
+ [ "$warn_count" = "1" ]
+}
diff --git a/Resources/mole/tests/manage_sudo.bats b/Resources/mole/tests/manage_sudo.bats
index 32c77bb..7a73c94 100644
--- a/Resources/mole/tests/manage_sudo.bats
+++ b/Resources/mole/tests/manage_sudo.bats
@@ -70,3 +70,61 @@ setup() {
result=$(bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; source '$PROJECT_ROOT/lib/core/sudo.sh'; echo \$MOLE_SUDO_ESTABLISHED")
[[ "$result" == "false" ]] || [[ -z "$result" ]]
}
+
+@test "request_sudo_access clears four lines in clamshell mode when Touch ID hint is shown" {
+ run bash -c '
+ unset MOLE_TEST_MODE MOLE_TEST_NO_AUTH
+ source "'"$PROJECT_ROOT"'/lib/core/common.sh"
+ source "'"$PROJECT_ROOT"'/lib/core/sudo.sh"
+
+ tty_file="$(mktemp)"
+ chmod 600 "$tty_file"
+
+ sudo() {
+ case "$1" in
+ -n) return 1 ;;
+ -k) return 0 ;;
+ *) return 1 ;;
+ esac
+ }
+ tty() { printf "%s\n" "$tty_file"; }
+ is_clamshell_mode() { return 0; }
+ check_touchid_support() { return 0; }
+ _request_password() { return 0; }
+ safe_clear_lines() { printf "CLEAR:%s\n" "$1"; }
+
+ request_sudo_access "Admin access required"
+ '
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"CLEAR:4"* ]]
+}
+
+@test "request_sudo_access keeps three-line cleanup in clamshell mode without Touch ID" {
+ run bash -c '
+ unset MOLE_TEST_MODE MOLE_TEST_NO_AUTH
+ source "'"$PROJECT_ROOT"'/lib/core/common.sh"
+ source "'"$PROJECT_ROOT"'/lib/core/sudo.sh"
+
+ tty_file="$(mktemp)"
+ chmod 600 "$tty_file"
+
+ sudo() {
+ case "$1" in
+ -n) return 1 ;;
+ -k) return 0 ;;
+ *) return 1 ;;
+ esac
+ }
+ tty() { printf "%s\n" "$tty_file"; }
+ is_clamshell_mode() { return 0; }
+ check_touchid_support() { return 1; }
+ _request_password() { return 0; }
+ safe_clear_lines() { printf "CLEAR:%s\n" "$1"; }
+
+ request_sudo_access "Admin access required"
+ '
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"CLEAR:3"* ]]
+}
diff --git a/Resources/mole/tests/manage_whitelist.bats b/Resources/mole/tests/manage_whitelist.bats
index fdaa659..e808310 100644
--- a/Resources/mole/tests/manage_whitelist.bats
+++ b/Resources/mole/tests/manage_whitelist.bats
@@ -116,6 +116,25 @@ setup() {
[ "$status" -eq 1 ]
}
+@test "whitelist validation accepts special and non-ASCII characters (#749)" {
+ # Verify the [[:cntrl:]] guard accepts valid macOS path chars and rejects control chars.
+ run bash --noprofile --norc -c "
+ accept() { [[ ! \"\$1\" =~ [[:cntrl:]] ]] && echo ACCEPT || echo REJECT; }
+ accept '/Users/me/Library/Application Support/Foo & Bar'
+ accept '/Users/me/Library/Caches/com.example+beta'
+ accept '/Users/me/Library/Caches/com.example(Preview)'
+ accept '/Users/me/Library/Caches/บริษัท'
+ accept '/Users/me/Library/Caches/app,[test]'
+ [[ \$'line\nbreak' =~ [[:cntrl:]] ]] && echo REJECT_NEWLINE || echo FAIL
+ [[ \$'tab\there' =~ [[:cntrl:]] ]] && echo REJECT_TAB || echo FAIL
+ "
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"ACCEPT"* ]]
+ [[ "$output" != *"REJECT /Users"* ]]
+ [[ "$output" == *"REJECT_NEWLINE"* ]]
+ [[ "$output" == *"REJECT_TAB"* ]]
+}
+
@test "is_path_whitelisted protects parent directories of whitelisted nested paths" {
local status
if HOME="$HOME" bash --noprofile --norc -c "
@@ -130,3 +149,113 @@ setup() {
fi
[ "$status" -eq 0 ]
}
+
+@test "default whitelist protects tealdeer cache parent for tldr pages" {
+ local status
+ if HOME="$HOME" bash --noprofile --norc -c "
+ source '$PROJECT_ROOT/lib/manage/whitelist.sh'
+ rm -f \"\$HOME/.config/mole/whitelist\"
+ load_whitelist
+ WHITELIST_PATTERNS=(\"\${CURRENT_WHITELIST_PATTERNS[@]}\")
+ is_path_whitelisted \"\$HOME/Library/Caches/tealdeer\"
+ "; then
+ status=0
+ else
+ status=$?
+ fi
+ [ "$status" -eq 0 ]
+}
+
+# Regression for #724: when a caller concats a glob expansion that ends
+# in `/` with a sub-path that starts with `/`, the result contains `//`.
+# Without slash collapsing, the comparison with a single-slash whitelist
+# entry always fails and Chrome MV3 service workers get wiped.
+@test "is_path_whitelisted matches entries against paths containing double slashes (#724)" {
+ local status
+ if HOME="$HOME" bash --noprofile --norc -c "
+ source '$PROJECT_ROOT/lib/core/base.sh'
+ source '$PROJECT_ROOT/lib/core/app_protection.sh'
+ WHITELIST_PATTERNS=(\"\$HOME/Library/Application Support/Google/Chrome/Default/Service Worker/CacheStorage\")
+ is_path_whitelisted \"\$HOME/Library/Application Support/Google/Chrome/Default//Service Worker/CacheStorage\"
+ "; then
+ status=0
+ else
+ status=$?
+ fi
+ [ "$status" -eq 0 ]
+}
+
+# safe_find_delete must consult the user whitelist on every match. Per-caller
+# gates were missed in past releases (#710, #724, #738, #744); enforcing it
+# inside the iterator makes whitelist protection structural rather than
+# case-by-case. Regression for #757.
+@test "safe_find_delete respects user whitelist for matched paths (#757)" {
+ local target_dir="$HOME/safe_find_delete_target"
+ local protected_file="$target_dir/protected.mat"
+ local removable_file="$target_dir/removable.mat"
+ mkdir -p "$target_dir"
+ : > "$protected_file"
+ : > "$removable_file"
+ touch -t 202001010000 "$protected_file" "$removable_file"
+
+ HOME="$HOME" bash --noprofile --norc -c "
+ set -euo pipefail
+ source '$PROJECT_ROOT/lib/core/base.sh'
+ source '$PROJECT_ROOT/lib/core/app_protection.sh'
+ source '$PROJECT_ROOT/lib/core/file_ops.sh'
+ WHITELIST_PATTERNS=(\"$target_dir/protected.mat\")
+ safe_find_delete \"$target_dir\" '*' 1 f
+ " > /dev/null
+
+ [[ -f "$protected_file" ]] || {
+ printf 'protected file was unexpectedly removed\n' >&2
+ return 1
+ }
+ [[ ! -f "$removable_file" ]] || {
+ printf 'removable file was unexpectedly kept\n' >&2
+ return 1
+ }
+}
+
+@test "safe_find_delete respects user whitelist glob patterns (#757)" {
+ local target_dir="$HOME/idleassetsd_target"
+ local protected_file="$target_dir/Customer/cbbim-w-prod.mat"
+ local removable_file="$target_dir/other/extra.dat"
+ mkdir -p "$target_dir/Customer" "$target_dir/other"
+ : > "$protected_file"
+ : > "$removable_file"
+ touch -t 202001010000 "$protected_file" "$removable_file"
+
+ HOME="$HOME" bash --noprofile --norc -c "
+ set -euo pipefail
+ source '$PROJECT_ROOT/lib/core/base.sh'
+ source '$PROJECT_ROOT/lib/core/app_protection.sh'
+ source '$PROJECT_ROOT/lib/core/file_ops.sh'
+ WHITELIST_PATTERNS=(\"$target_dir/Customer/*\")
+ safe_find_delete \"$target_dir\" '*' 1 f
+ " > /dev/null
+
+ [[ -f "$protected_file" ]] || {
+ printf 'glob-whitelisted file was unexpectedly removed\n' >&2
+ return 1
+ }
+ [[ ! -f "$removable_file" ]] || {
+ printf 'non-whitelisted file was unexpectedly kept\n' >&2
+ return 1
+ }
+}
+
+@test "is_path_whitelisted collapses slashes in whitelist entries too (#724)" {
+ local status
+ if HOME="$HOME" bash --noprofile --norc -c "
+ source '$PROJECT_ROOT/lib/core/base.sh'
+ source '$PROJECT_ROOT/lib/core/app_protection.sh'
+ WHITELIST_PATTERNS=(\"\$HOME//Library//Caches//chrome-sw\")
+ is_path_whitelisted \"\$HOME/Library/Caches/chrome-sw\"
+ "; then
+ status=0
+ else
+ status=$?
+ fi
+ [ "$status" -eq 0 ]
+}
diff --git a/Resources/mole/tests/optimize.bats b/Resources/mole/tests/optimize.bats
index 00a7b11..eedf510 100644
--- a/Resources/mole/tests/optimize.bats
+++ b/Resources/mole/tests/optimize.bats
@@ -116,6 +116,65 @@ EOF
[[ "$output" == *"mDNSResponder restarted"* ]]
}
+@test "opt_quarantine_cleanup reports clean when no database" {
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 bash --noprofile --norc <<'EOF'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+source "$PROJECT_ROOT/lib/optimize/tasks.sh"
+opt_quarantine_cleanup
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"already clean"* ]]
+}
+
+@test "opt_quarantine_cleanup reports entries in dry-run" {
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 bash --noprofile --norc <<'EOF'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+source "$PROJECT_ROOT/lib/optimize/tasks.sh"
+# Stub whitelist check to always allow.
+should_protect_path() { return 1; }
+# Create a mock quarantine database with entries.
+mkdir -p "$HOME/Library/Preferences"
+local_db="$HOME/Library/Preferences/com.apple.LaunchServices.QuarantineEventsV2"
+sqlite3 "$local_db" "CREATE TABLE IF NOT EXISTS LSQuarantineEvent (id TEXT);"
+sqlite3 "$local_db" "INSERT INTO LSQuarantineEvent VALUES ('test1');"
+sqlite3 "$local_db" "INSERT INTO LSQuarantineEvent VALUES ('test2');"
+opt_quarantine_cleanup
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"Quarantine history cleared"* ]]
+ [[ "$output" == *"2 entries"* ]]
+}
+
+@test "opt_quarantine_cleanup skips when sqlite3 unavailable" {
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+source "$PROJECT_ROOT/lib/optimize/tasks.sh"
+export PATH="/nonexistent"
+opt_quarantine_cleanup
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"sqlite3 unavailable"* ]]
+}
+
+@test "execute_optimization dispatches quarantine_cleanup" {
+ 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/optimize/tasks.sh"
+opt_quarantine_cleanup() { echo "quarantine"; }
+execute_optimization quarantine_cleanup
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"quarantine"* ]]
+}
+
@test "opt_sqlite_vacuum reports sqlite3 unavailable" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF'
set -euo pipefail
@@ -141,6 +200,60 @@ EOF
[[ "$output" == *"Font cache cleared"* ]]
}
+@test "optimize does not auto-fix Gatekeeper anymore" {
+ run grep -n "spctl --master-enable\\|SECURITY_FIXES+=([\"']gatekeeper|" "$PROJECT_ROOT/bin/optimize.sh"
+
+ [ "$status" -eq 1 ]
+}
+
+@test "opt_font_cache_rebuild skips when Firefox helpers are running" {
+ 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/optimize/tasks.sh"
+pgrep() {
+ case "$*" in
+ *"Firefox|org\\.mozilla\\.firefox|firefox .*contentproc|firefox .*plugin-container|firefox .*crashreporter"*)
+ return 0
+ ;;
+ *)
+ return 1
+ ;;
+ esac
+}
+export -f pgrep
+opt_font_cache_rebuild
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"Font cache rebuild skipped · Firefox still running"* ]]
+}
+
+@test "browser_family_is_running does not treat generic renderer helpers as Zen Browser" {
+ 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/optimize/tasks.sh"
+pgrep() {
+ case "$*" in
+ *"renderer|gpu"*)
+ return 0
+ ;;
+ *)
+ return 1
+ ;;
+ esac
+}
+export -f pgrep
+if browser_family_is_running "Zen Browser"; then
+ echo "MATCHED"
+fi
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" != *"MATCHED"* ]]
+}
+
@test "opt_dock_refresh clears cache files" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 bash --noprofile --norc <<'EOF'
set -euo pipefail
@@ -157,6 +270,62 @@ EOF
[[ "$output" == *"Dock refreshed"* ]]
}
+@test "opt_prevent_network_dsstore dry-run reports enabled" {
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 bash --noprofile --norc <<'EOF'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+source "$PROJECT_ROOT/lib/optimize/tasks.sh"
+defaults() {
+ case "$1" in
+ read) return 1 ;;
+ write) return 0 ;;
+ esac
+}
+opt_prevent_network_dsstore
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *".DS_Store prevention enabled"* ]]
+}
+
+@test "opt_prevent_network_dsstore idempotent when already set" {
+ 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/optimize/tasks.sh"
+defaults() {
+ if [[ "$1" == "read" ]]; then
+ echo "1"
+ return 0
+ fi
+ return 0
+}
+opt_prevent_network_dsstore
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"already enabled"* ]]
+}
+
+@test "prevent_network_dsstore is optional in optimize health json" {
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/check/health_json.sh"
+json="$(generate_health_json | tr '\n' ' ')"
+
+if printf '%s\n' "$json" | grep -q '"action": "prevent_network_dsstore".*"safe": false'; then
+ echo "optional"
+fi
+if printf '%s\n' "$json" | grep -q 'persistent Finder preference'; then
+ echo "described"
+fi
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"optional"* ]]
+ [[ "$output" == *"described"* ]]
+}
+
@test "execute_optimization dispatches actions" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail
@@ -199,3 +368,225 @@ EOF
[[ "$output" == *"lsregister not found"* ]]
[[ "$output" == *"survived"* ]]
}
+
+@test "opt_launch_agents_cleanup reports healthy when no directory" {
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 bash --noprofile --norc <<'EOF'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+source "$PROJECT_ROOT/lib/optimize/tasks.sh"
+opt_launch_agents_cleanup
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"Launch Agents all healthy"* ]]
+}
+
+@test "opt_launch_agents_cleanup detects broken agents" {
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 bash --noprofile --norc <<'EOF'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+source "$PROJECT_ROOT/lib/optimize/tasks.sh"
+# Create mock LaunchAgents with a broken binary reference.
+mkdir -p "$HOME/Library/LaunchAgents"
+cat > "$HOME/Library/LaunchAgents/com.test.broken.plist" <<'PLIST'
+
+
+
+
+ Label
+ com.test.broken
+ ProgramArguments
+
+ /nonexistent/binary
+
+
+
+PLIST
+safe_remove() { return 0; }
+opt_launch_agents_cleanup
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"Cleaned 1 broken Launch Agent"* ]]
+}
+
+@test "opt_launch_agents_cleanup skips healthy agents" {
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 bash --noprofile --norc <<'EOF'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+source "$PROJECT_ROOT/lib/optimize/tasks.sh"
+# Clean up any leftover plists from previous tests.
+rm -f "$HOME/Library/LaunchAgents"/*.plist 2>/dev/null || true
+# Create mock LaunchAgent pointing to an existing binary.
+mkdir -p "$HOME/Library/LaunchAgents"
+cat > "$HOME/Library/LaunchAgents/com.test.healthy.plist" <
+
+
+
+ Label
+ com.test.healthy
+ ProgramArguments
+
+ /bin/bash
+
+
+
+PLIST
+opt_launch_agents_cleanup
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"Launch Agents all healthy"* ]]
+}
+
+@test "execute_optimization dispatches launch_agents_cleanup" {
+ 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/optimize/tasks.sh"
+opt_launch_agents_cleanup() { echo "launch_agents"; }
+execute_optimization launch_agents_cleanup
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"launch_agents"* ]]
+}
+
+@test "opt_periodic_maintenance reports current when log is fresh" {
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 bash --noprofile --norc <<'EOF'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+source "$PROJECT_ROOT/lib/optimize/tasks.sh"
+tmplog="$(mktemp /tmp/mole-test-daily.XXXXXX)"
+touch "$tmplog"
+MOLE_PERIODIC_LOG="$tmplog" opt_periodic_maintenance
+rm -f "$tmplog"
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"already current"* ]]
+}
+
+@test "opt_periodic_maintenance triggers in dry-run when log is stale" {
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 bash --noprofile --norc <<'EOF'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+source "$PROJECT_ROOT/lib/optimize/tasks.sh"
+tmplog="$(mktemp /tmp/mole-test-daily.XXXXXX)"
+touch -t "$(date -v-10d +%Y%m%d%H%M.%S)" "$tmplog"
+MOLE_PERIODIC_LOG="$tmplog" opt_periodic_maintenance
+rm -f "$tmplog"
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"Periodic maintenance triggered"* ]]
+}
+
+@test "opt_periodic_maintenance triggers in dry-run when log is missing" {
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 bash --noprofile --norc <<'EOF'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+source "$PROJECT_ROOT/lib/optimize/tasks.sh"
+MOLE_PERIODIC_LOG="/tmp/mole-test-nonexistent-daily.out" opt_periodic_maintenance
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"Periodic maintenance triggered"* ]]
+}
+
+@test "execute_optimization dispatches periodic_maintenance" {
+ 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/optimize/tasks.sh"
+opt_periodic_maintenance() { echo "periodic"; }
+execute_optimization periodic_maintenance
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"periodic"* ]]
+}
+
+@test "opt_notification_cleanup reports healthy when db is small" {
+ local tmp_dir nc_db_dir
+ tmp_dir=$(mktemp -d)
+ nc_db_dir="$tmp_dir/com.apple.notificationcenter/db2"
+ mkdir -p "$nc_db_dir"
+ # Create a 1KB placeholder (below 50MB threshold)
+ dd if=/dev/zero of="$nc_db_dir/db" bs=1024 count=1 2>/dev/null
+
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc </dev/null
+
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc </dev/null
+
+ run env HOME="$tmp_dir" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc </dev/null
+
+ run env HOME="$tmp_dir" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc </dev/null
+}
+
+@test "sort: PURGE_CATEGORY_FULL_PATHS_ARRAY[0] is the largest artifact after size-descending sort" {
+ # alpha = small (~5 KB), beta = large (~200 KB).
+ # Alphabetical discovery order puts alpha first; size order puts beta first.
+ # After the sort, PURGE_CATEGORY_FULL_PATHS_ARRAY[0] must be beta's path.
+ mkdir -p "$HOME/www/alpha/node_modules"
+ mkdir -p "$HOME/www/beta/node_modules"
+ echo '{}' > "$HOME/www/alpha/package.json"
+ echo '{}' > "$HOME/www/beta/package.json"
+ dd if=/dev/zero of="$HOME/www/alpha/node_modules/data" bs=1024 count=5 2>/dev/null
+ dd if=/dev/zero of="$HOME/www/beta/node_modules/data" bs=1024 count=200 2>/dev/null
+
+ local capture_file script_file
+ capture_file=$(mktemp "$HOME/sort_capture.XXXXXX")
+ script_file=$(mktemp "$HOME/sort_script.XXXXXX.sh")
+
+ cat > "$script_file" << SCRIPT
+set -euo pipefail
+source "$PROJECT_ROOT/lib/clean/project.sh"
+mkdir -p "$HOME/.cache/mole"
+export XDG_CACHE_HOME="$HOME/.cache"
+export TERM="dumb"
+PURGE_SEARCH_PATHS=("$HOME/www")
+
+# Override the interactive selector: dump the full-path array to the capture
+# file then cancel (return 1) so nothing is deleted.
+select_purge_categories() {
+ printf '%s\n' "\${PURGE_CATEGORY_FULL_PATHS_ARRAY[@]}" > "$capture_file"
+ PURGE_SELECTION_RESULT=""
+ return 1
+}
+
+clean_project_artifacts 2>/dev/null || true
+SCRIPT
+
+ _run_in_pty "$script_file"
+ rm -f "$script_file"
+
+ if [[ ! -s "$capture_file" ]]; then
+ rm -f "$capture_file"
+ fail "capture file is empty – select_purge_categories was never called (stdin was not a tty?)"
+ fi
+
+ local first_path
+ first_path=$(head -1 "$capture_file")
+ rm -f "$capture_file"
+
+ # With the bug item_display_paths is not sorted, so alpha (alphabetically
+ # first) appears at index 0 → [[ ... == *beta* ]] fails.
+ # After the fix beta (largest) is at index 0 → test passes.
+ [[ "$first_path" == *"beta"* ]]
+}
+
+@test "sort: PURGE_CATEGORY_FULL_PATHS_ARRAY and PURGE_CATEGORY_SIZES indices are consistent" {
+ mkdir -p "$HOME/www/alpha/node_modules"
+ mkdir -p "$HOME/www/beta/node_modules"
+ echo '{}' > "$HOME/www/alpha/package.json"
+ echo '{}' > "$HOME/www/beta/package.json"
+ dd if=/dev/zero of="$HOME/www/alpha/node_modules/data" bs=1024 count=5 2>/dev/null
+ dd if=/dev/zero of="$HOME/www/beta/node_modules/data" bs=1024 count=200 2>/dev/null
+
+ local capture_file script_file
+ capture_file=$(mktemp "$HOME/sort_capture.XXXXXX")
+ script_file=$(mktemp "$HOME/sort_script.XXXXXX.sh")
+
+ cat > "$script_file" << SCRIPT
+set -euo pipefail
+source "$PROJECT_ROOT/lib/clean/project.sh"
+mkdir -p "$HOME/.cache/mole"
+export XDG_CACHE_HOME="$HOME/.cache"
+export TERM="dumb"
+PURGE_SEARCH_PATHS=("$HOME/www")
+
+select_purge_categories() {
+ echo "SIZES=\${PURGE_CATEGORY_SIZES:-}" > "$capture_file"
+ local i=0
+ for p in "\${PURGE_CATEGORY_FULL_PATHS_ARRAY[@]}"; do
+ echo "PATH[\$i]=\$p" >> "$capture_file"
+ i=\$((i + 1))
+ done
+ PURGE_SELECTION_RESULT=""
+ return 1
+}
+
+clean_project_artifacts 2>/dev/null || true
+SCRIPT
+
+ _run_in_pty "$script_file"
+ rm -f "$script_file"
+
+ if [[ ! -s "$capture_file" ]]; then
+ rm -f "$capture_file"
+ fail "capture file is empty – select_purge_categories was never called (stdin was not a tty?)"
+ fi
+
+ local sizes_csv
+ sizes_csv=$(grep '^SIZES=' "$capture_file" | cut -d= -f2-)
+ IFS=',' read -r -a sizes <<< "$sizes_csv"
+
+ local path0 path1
+ path0=$(grep '^PATH\[0\]=' "$capture_file" | head -1 | cut -d= -f2-)
+ path1=$(grep '^PATH\[1\]=' "$capture_file" | head -1 | cut -d= -f2-)
+ rm -f "$capture_file"
+
+ # PURGE_CATEGORY_SIZES must be sorted descending (largest first).
+ [ "${sizes[0]}" -gt "${sizes[1]}" ]
+
+ # Index 0 → largest artifact → beta's path.
+ # With the bug path0 = alpha (discovery order) → [[ ... == *beta* ]] fails.
+ [[ "$path0" == *"beta"* ]]
+
+ # Index 1 → smaller artifact → alpha's path.
+ [[ "$path1" == *"alpha"* ]]
+}
diff --git a/Resources/mole/tests/purge_config_paths.bats b/Resources/mole/tests/purge_config_paths.bats
index 9fe106b..fc6c3cd 100644
--- a/Resources/mole/tests/purge_config_paths.bats
+++ b/Resources/mole/tests/purge_config_paths.bats
@@ -108,8 +108,52 @@ EOF
echo "# Just a comment" > "$config_file"
run env HOME="$HOME" bash -c "source '$PROJECT_ROOT/lib/clean/project.sh'; echo \"\${PURGE_SEARCH_PATHS[*]}\""
-
+
[ "$status" -eq 0 ]
-
+
[[ "$output" == *"$HOME/Projects"* ]]
}
+
+@test "load_purge_config deduplicates case variants on case-insensitive FS" {
+ # Create a real directory so resolve_path_case can cd into it
+ mkdir -p "$HOME/code"
+
+ local config_file="$HOME/.config/mole/purge_paths"
+ cat > "$config_file" << EOF
+$HOME/code
+$HOME/Code
+EOF
+
+ run env HOME="$HOME" bash -c "source '$PROJECT_ROOT/lib/clean/project.sh'; echo \"\${#PURGE_SEARCH_PATHS[@]}\""
+
+ [ "$status" -eq 0 ]
+
+ # On case-insensitive FS (macOS default) both resolve to the same path,
+ # so count should be 1. On case-sensitive FS, Code doesn't exist, so
+ # resolve_path_case returns it unchanged — count may be 2 which is correct
+ # since they really are different directories.
+ if [[ -d "$HOME/Code" && "$(cd "$HOME/Code" && pwd -P)" == "$(cd "$HOME/code" && pwd -P)" ]]; then
+ [ "$output" = "1" ]
+ fi
+}
+
+@test "discover_project_dirs deduplicates default Code vs actual code" {
+ # Simulate: $HOME/code exists (actual dir), $HOME/Code is in defaults
+ mkdir -p "$HOME/code/myproject"
+ touch "$HOME/code/myproject/package.json"
+
+ # No config file — triggers discovery
+ run env HOME="$HOME" bash -c "
+ source '$PROJECT_ROOT/lib/clean/project.sh'
+ discover_project_dirs
+ "
+
+ [ "$status" -eq 0 ]
+
+ # On case-insensitive FS, $HOME/code should appear only once
+ if [[ -d "$HOME/Code" && "$(cd "$HOME/Code" && pwd -P)" == "$(cd "$HOME/code" && pwd -P)" ]]; then
+ local count
+ count=$(echo "$output" | grep -c "$HOME/code" || true)
+ [ "$count" -le 1 ]
+ fi
+}
diff --git a/Resources/mole/tests/regression.bats b/Resources/mole/tests/regression.bats
index 3e10baa..5d0b973 100644
--- a/Resources/mole/tests/regression.bats
+++ b/Resources/mole/tests/regression.bats
@@ -187,3 +187,69 @@ EOF
")
[[ "$result" == "loaded" ]]
}
+
+@test "normalize_paths_for_cleanup handles large nested batches without hanging" {
+ local limit_ms="${MOLE_PERF_NORMALIZE_PATHS_LIMIT_MS:-4000}"
+
+ run env PROJECT_ROOT="$PROJECT_ROOT" LIMIT_MS="$limit_ms" bash --noprofile --norc <<'EOF'
+set -euo pipefail
+
+python - <<'PY'
+from pathlib import Path
+import os
+project_root = Path(os.environ["PROJECT_ROOT"])
+text = (project_root / "bin/clean.sh").read_text()
+start = text.index("normalize_paths_for_cleanup() {")
+depth = 0
+end = None
+for i in range(start, len(text)):
+ ch = text[i]
+ if ch == "{":
+ depth += 1
+ elif ch == "}":
+ depth -= 1
+ if depth == 0:
+ end = i + 1
+ break
+Path("/tmp/normalize_paths_for_cleanup.sh").write_text(text[start:end] + "\n")
+PY
+
+source /tmp/normalize_paths_for_cleanup.sh
+
+paths=(
+ "$HOME/Library/Containers/com.microsoft.Word/Data/Library/Caches"
+ "$HOME/Library/Containers/com.microsoft.Excel/Data/Library/Caches/"
+)
+for i in $(seq 1 6000); do
+ paths+=("$HOME/Library/Containers/com.microsoft.Word/Data/Library/Caches/item-$i")
+ paths+=("$HOME/Library/Containers/com.microsoft.Excel/Data/Library/Caches/item-$i")
+done
+
+start_ns=$(python - <<'PY'
+import time
+print(time.time_ns())
+PY
+)
+normalized=()
+while IFS= read -r -d '' line; do
+ normalized+=("$line")
+done < <(normalize_paths_for_cleanup "${paths[@]}")
+end_ns=$(python - <<'PY'
+import time
+print(time.time_ns())
+PY
+)
+elapsed_ms=$(( (end_ns - start_ns) / 1000000 ))
+
+printf 'COUNT=%s ELAPSED_MS=%s\n' "${#normalized[@]}" "$elapsed_ms"
+printf '%s\n' "${normalized[@]}"
+
+[[ ${#normalized[@]} -eq 2 ]]
+[[ "${normalized[0]}" == "$HOME/Library/Containers/com.microsoft.Excel/Data/Library/Caches" || "${normalized[1]}" == "$HOME/Library/Containers/com.microsoft.Excel/Data/Library/Caches" ]]
+[[ "${normalized[0]}" == "$HOME/Library/Containers/com.microsoft.Word/Data/Library/Caches" || "${normalized[1]}" == "$HOME/Library/Containers/com.microsoft.Word/Data/Library/Caches" ]]
+(( elapsed_ms < LIMIT_MS ))
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"COUNT=2"* ]]
+}
diff --git a/Resources/mole/tests/scripts.bats b/Resources/mole/tests/scripts.bats
index 8dc0edc..9e3de97 100644
--- a/Resources/mole/tests/scripts.bats
+++ b/Resources/mole/tests/scripts.bats
@@ -131,3 +131,72 @@ EOF
run bash -c "grep -q 'MOLE_VERSION=\"dev\"' '$PROJECT_ROOT/install.sh'"
[ "$status" -eq 0 ]
}
+
+@test "update_homebrew_tap_formula.sh updates all release artifacts" {
+ local formula_file="$HOME/mole.rb"
+ cat > "$formula_file" <<'EOF'
+class Mole < Formula
+ desc "Mole"
+ homepage "https://github.com/tw93/Mole"
+ url "https://github.com/tw93/Mole/archive/refs/tags/V1.32.0.tar.gz"
+ sha256 "old-source-sha"
+
+ on_arm do
+ url "https://github.com/tw93/Mole/releases/download/V1.32.0/binaries-darwin-arm64.tar.gz"
+ sha256 "old-arm-sha"
+ end
+
+ on_intel do
+ url "https://github.com/tw93/Mole/releases/download/V1.32.0/binaries-darwin-amd64.tar.gz"
+ sha256 "old-amd-sha"
+ end
+end
+EOF
+
+ run "$PROJECT_ROOT/scripts/update_homebrew_tap_formula.sh" \
+ --formula "$formula_file" \
+ --tag "V1.33.0" \
+ --source-sha "new-source-sha" \
+ --arm-sha "new-arm-sha" \
+ --amd-sha "new-amd-sha"
+ [ "$status" -eq 0 ]
+
+ run grep -q 'url "https://github.com/tw93/Mole/archive/refs/tags/V1.33.0.tar.gz"' "$formula_file"
+ [ "$status" -eq 0 ]
+ run grep -q 'sha256 "new-source-sha"' "$formula_file"
+ [ "$status" -eq 0 ]
+ run grep -q 'url "https://github.com/tw93/Mole/releases/download/V1.33.0/binaries-darwin-arm64.tar.gz"' "$formula_file"
+ [ "$status" -eq 0 ]
+ run grep -q 'sha256 "new-arm-sha"' "$formula_file"
+ [ "$status" -eq 0 ]
+ run grep -q 'url "https://github.com/tw93/Mole/releases/download/V1.33.0/binaries-darwin-amd64.tar.gz"' "$formula_file"
+ [ "$status" -eq 0 ]
+ run grep -q 'sha256 "new-amd-sha"' "$formula_file"
+ [ "$status" -eq 0 ]
+}
+
+@test "update_homebrew_tap_formula.sh fails when expected sections are missing" {
+ local formula_file="$HOME/mole-missing-intel.rb"
+ cat > "$formula_file" <<'EOF'
+class Mole < Formula
+ desc "Mole"
+ homepage "https://github.com/tw93/Mole"
+ url "https://github.com/tw93/Mole/archive/refs/tags/V1.32.0.tar.gz"
+ sha256 "old-source-sha"
+
+ on_arm do
+ url "https://github.com/tw93/Mole/releases/download/V1.32.0/binaries-darwin-arm64.tar.gz"
+ sha256 "old-arm-sha"
+ end
+end
+EOF
+
+ run "$PROJECT_ROOT/scripts/update_homebrew_tap_formula.sh" \
+ --formula "$formula_file" \
+ --tag "V1.33.0" \
+ --source-sha "new-source-sha" \
+ --arm-sha "new-arm-sha" \
+ --amd-sha "new-amd-sha"
+ [ "$status" -ne 0 ]
+ [[ "$output" == *"Failed to update formula"* ]]
+}
diff --git a/Resources/mole/tests/test_match_apps_helper.sh b/Resources/mole/tests/test_match_apps_helper.sh
new file mode 100644
index 0000000..85cc7e0
--- /dev/null
+++ b/Resources/mole/tests/test_match_apps_helper.sh
@@ -0,0 +1,27 @@
+#!/bin/bash
+# Test helper: load match_apps_by_name directly from bin/uninstall.sh for unit testing.
+# Requires apps_data and selected_apps arrays to be defined before sourcing.
+
+# Declared by caller before sourcing this file
+: "${apps_data?apps_data array must be set before sourcing this file}"
+
+_test_helper_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+_repo_root="$(cd "${_test_helper_dir}/.." && pwd)"
+_uninstall_script="${_repo_root}/bin/uninstall.sh"
+
+if [[ ! -f "${_uninstall_script}" ]]; then
+ echo "Error: unable to find ${_uninstall_script}" >&2
+ return 1
+fi
+
+# Suppress color codes in test output
+YELLOW=""
+NC=""
+
+eval "$(
+ sed -n '/^match_apps_by_name()[[:space:]]*{/,/^}$/p' "${_uninstall_script}"
+)"
+
+unset _test_helper_dir
+unset _repo_root
+unset _uninstall_script
diff --git a/Resources/mole/tests/uninstall.bats b/Resources/mole/tests/uninstall.bats
index e6bfe27..f0dfd2c 100644
--- a/Resources/mole/tests/uninstall.bats
+++ b/Resources/mole/tests/uninstall.bats
@@ -60,6 +60,62 @@ EOF
[[ "$result" == *"LaunchAgents/com.example.TestApp.plist"* ]]
}
+@test "find_app_system_files discovers bundle-id-prefixed LaunchDaemons" {
+ fakebin="$HOME/fakebin"
+ mkdir -p "$fakebin"
+
+ # The new dot-anchored alternation invokes find with two -name patterns:
+ # "${bundle_id}.plist" and "${bundle_id}.*.plist". Match on either form.
+ cat > "$fakebin/find" <<'SCRIPT'
+#!/bin/sh
+args="$*"
+
+case "$args" in
+ *"/Library/LaunchDaemons"*'-name com.west2online.ClashXPro.*.plist'*)
+ printf '%s\0' "/Library/LaunchDaemons/com.west2online.ClashXPro.ProxyConfigHelper.plist"
+ ;;
+esac
+SCRIPT
+ chmod +x "$fakebin/find"
+
+ run env HOME="$HOME" PATH="$fakebin:$PATH" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+
+result=$(find_app_system_files "com.west2online.ClashXPro" "ClashX Pro")
+[[ "$result" == *"/Library/LaunchDaemons/com.west2online.ClashXPro.ProxyConfigHelper.plist"* ]] || exit 1
+EOF
+
+ [ "$status" -eq 0 ]
+}
+
+# The previous "${bundle_id}*.plist" glob over-matched: bundle "com.foo"
+# would harvest "com.foobar.plist" and "com.foobaz.plist" from unrelated
+# vendors. The dot-anchored alternation only matches at the dot boundary.
+@test "find_app_system_files does not over-match sibling-vendor LaunchDaemons" {
+ # Use a real /Library/LaunchDaemons-like fixture by isolating PATH so the
+ # function falls back to the system find binary, then assert only the
+ # expected files are surfaced.
+ fakebase="$HOME/fakebase"
+ mkdir -p "$fakebase/Library/LaunchAgents" "$fakebase/Library/LaunchDaemons"
+ : > "$fakebase/Library/LaunchDaemons/com.foo.plist" # exact match - keep
+ : > "$fakebase/Library/LaunchDaemons/com.foo.helper.plist" # dotted - keep
+ : > "$fakebase/Library/LaunchDaemons/com.foobar.plist" # sibling - reject
+ : > "$fakebase/Library/LaunchDaemons/com.foobaz.helper.plist" # sibling - reject
+
+ # Verify the find pattern itself, since the production find is hard-coded
+ # to /Library/* paths. This mirrors what app_protection.sh emits.
+ run bash --noprofile --norc -c "
+ cd '$fakebase/Library/LaunchDaemons'
+ find . -maxdepth 1 \( -name 'com.foo.plist' -o -name 'com.foo.*.plist' \) | sort
+ "
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"com.foo.plist"* ]]
+ [[ "$output" == *"com.foo.helper.plist"* ]]
+ [[ "$output" != *"com.foobar.plist"* ]]
+ [[ "$output" != *"com.foobaz.helper.plist"* ]]
+}
+
@test "get_diagnostic_report_paths_for_app avoids executable prefix collisions" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail
@@ -81,14 +137,18 @@ cat > "$app_dir/Contents/Info.plist" << 'PLIST'
PLIST
touch "$diag_dir/Foo.crash"
+touch "$diag_dir/Foo.diag"
touch "$diag_dir/Foo_2026-01-01-120000_host.ips"
touch "$diag_dir/Foobar.crash"
+touch "$diag_dir/Foobar.diag"
touch "$diag_dir/Foobar_2026-01-01-120001_host.ips"
result=$(get_diagnostic_report_paths_for_app "$app_dir" "Foo" "$diag_dir")
[[ "$result" == *"Foo.crash"* ]] || exit 1
+[[ "$result" == *"Foo.diag"* ]] || exit 1
[[ "$result" == *"Foo_2026-01-01-120000_host.ips"* ]] || exit 1
[[ "$result" != *"Foobar.crash"* ]] || exit 1
+[[ "$result" != *"Foobar.diag"* ]] || exit 1
[[ "$result" != *"Foobar_2026-01-01-120001_host.ips"* ]] || exit 1
EOF
@@ -158,6 +218,98 @@ EOF
[ "$status" -eq 0 ]
}
+@test "batch_uninstall_applications warns when removed app declares Local Network usage" {
+ 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"
+
+request_sudo_access() { return 0; }
+start_inline_spinner() { :; }
+stop_inline_spinner() { :; }
+enter_alt_screen() { :; }
+leave_alt_screen() { :; }
+hide_cursor() { :; }
+show_cursor() { :; }
+remove_apps_from_dock() { :; }
+pgrep() { return 1; }
+pkill() { return 0; }
+sudo() { return 0; }
+
+app_bundle="$HOME/Applications/NetworkApp.app"
+mkdir -p "$app_bundle/Contents"
+cat > "$app_bundle/Contents/Info.plist" <<'PLIST'
+
+
+
+
+ CFBundleIdentifier
+ com.example.NetworkApp
+ NSLocalNetworkUsageDescription
+ Discover devices on the local network
+
+
+PLIST
+
+selected_apps=()
+selected_apps+=("0|$app_bundle|NetworkApp|com.example.NetworkApp|0|Never")
+files_cleaned=0
+total_items=0
+total_size_cleaned=0
+
+printf '\n' | batch_uninstall_applications
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"Local Network permissions"* ]]
+ [[ "$output" == *"NetworkApp"* ]]
+ [[ "$output" == *"Recovery mode"* ]]
+}
+
+@test "batch_uninstall_applications skips Local Network warning for regular apps" {
+ 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"
+
+request_sudo_access() { return 0; }
+start_inline_spinner() { :; }
+stop_inline_spinner() { :; }
+enter_alt_screen() { :; }
+leave_alt_screen() { :; }
+hide_cursor() { :; }
+show_cursor() { :; }
+remove_apps_from_dock() { :; }
+pgrep() { return 1; }
+pkill() { return 0; }
+sudo() { return 0; }
+
+app_bundle="$HOME/Applications/PlainApp.app"
+mkdir -p "$app_bundle/Contents"
+cat > "$app_bundle/Contents/Info.plist" <<'PLIST'
+
+
+
+
+ CFBundleIdentifier
+ com.example.PlainApp
+
+
+PLIST
+
+selected_apps=()
+selected_apps+=("0|$app_bundle|PlainApp|com.example.PlainApp|0|Never")
+files_cleaned=0
+total_items=0
+total_size_cleaned=0
+
+printf '\n' | batch_uninstall_applications
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" != *"Local Network permissions"* ]]
+}
+
@test "batch_uninstall_applications preview shows full related file list" {
mkdir -p "$HOME/Applications/TestApp.app"
mkdir -p "$HOME/Library/Application Support/TestApp"
@@ -215,6 +367,91 @@ EOF
[[ "$output" != *"more files"* ]]
}
+@test "uninstall_persist_cache_file heals non-writable destination" {
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
+set -euo pipefail
+
+# Source only the helper by evaluating its function definition.
+eval "$(sed -n '/^uninstall_persist_cache_file()/,/^}$/p' "$PROJECT_ROOT/bin/uninstall.sh")"
+
+src="$HOME/cache.src"
+dst="$HOME/cache.dst"
+printf 'fresh-data\n' > "$src"
+printf 'stale-data\n' > "$dst"
+chmod 0444 "$dst"
+[[ ! -w "$dst" ]] || { echo "precondition: dst should be read-only" >&2; exit 1; }
+
+uninstall_persist_cache_file "$src" "$dst"
+
+[[ ! -e "$src" ]] || { echo "src should be gone" >&2; exit 1; }
+[[ -f "$dst" ]] || { echo "dst missing" >&2; exit 1; }
+grep -q 'fresh-data' "$dst" || { echo "dst not updated"; exit 1; }
+EOF
+
+ [ "$status" -eq 0 ]
+}
+
+@test "uninstall_persist_cache_file does not hang when mv would prompt (stdin closed)" {
+ # Regression for #722: BSD mv without -f prompts on non-writable dst and
+ # blocks reading stdin. The helper must close stdin and use -f.
+ #
+ # The hang detector uses a marker file rather than a PID-based watchdog:
+ # PIDs get recycled quickly on CI and a stale `kill -9 $pid` can succeed
+ # against an unrelated process, producing a false HANG. The marker
+ # approach only cares about whether the helper itself completed.
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
+set -euo pipefail
+eval "$(sed -n '/^uninstall_persist_cache_file()/,/^}$/p' "$PROJECT_ROOT/bin/uninstall.sh")"
+
+src="$HOME/snap.src"
+dst="$HOME/snap.dst"
+done_marker="$HOME/snap.done"
+printf 'x\n' > "$src"
+printf 'y\n' > "$dst"
+chmod 0444 "$dst"
+
+(
+ printf 'n\nn\nn\n' | uninstall_persist_cache_file "$src" "$dst"
+ : > "$done_marker"
+) &
+bgpid=$!
+
+# Poll for completion marker for up to ~5s.
+for _ in $(seq 1 50); do
+ [[ -e "$done_marker" ]] && break
+ sleep 0.1
+done
+
+if [[ ! -e "$done_marker" ]]; then
+ kill -9 "$bgpid" 2>/dev/null || true
+ echo HANG
+fi
+wait "$bgpid" 2>/dev/null || true
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" != *"HANG"* ]]
+}
+
+@test "uninstall_persist_cache_file is a no-op when source is empty" {
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
+set -euo pipefail
+eval "$(sed -n '/^uninstall_persist_cache_file()/,/^}$/p' "$PROJECT_ROOT/bin/uninstall.sh")"
+
+src="$HOME/empty.src"
+dst="$HOME/keep.dst"
+: > "$src"
+printf 'untouched\n' > "$dst"
+
+uninstall_persist_cache_file "$src" "$dst"
+
+[[ ! -e "$src" ]] || exit 1
+grep -q 'untouched' "$dst" || exit 1
+EOF
+
+ [ "$status" -eq 0 ]
+}
+
@test "safe_remove can remove a simple directory" {
mkdir -p "$HOME/test_dir"
touch "$HOME/test_dir/file.txt"
@@ -260,6 +497,44 @@ EOF
[ "$status" -eq 0 ]
}
+@test "uninstall_resolve_display_name keeps versioned app names when metadata is generic" {
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+
+function run_with_timeout() {
+ shift
+ "$@"
+}
+
+function mdls() {
+ echo "Xcode"
+}
+
+function plutil() {
+ if [[ "$3" == *"Info.plist" ]]; then
+ echo "Xcode"
+ return 0
+ fi
+ return 1
+}
+
+MOLE_UNINSTALL_USER_LC_ALL=""
+MOLE_UNINSTALL_USER_LANG=""
+
+eval "$(sed -n '/^uninstall_resolve_display_name()/,/^}/p' "$PROJECT_ROOT/bin/uninstall.sh")"
+
+app_path="$HOME/Applications/Xcode 16.4.app"
+mkdir -p "$app_path/Contents"
+touch "$app_path/Contents/Info.plist"
+
+result=$(uninstall_resolve_display_name "$app_path" "Xcode 16.4.app")
+[[ "$result" == "Xcode 16.4" ]] || exit 1
+EOF
+
+ [ "$status" -eq 0 ]
+}
+
@test "decode_file_list handles empty input" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail
@@ -360,9 +635,9 @@ EOF
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/.config/mole" "$HOME/.cache/mole" "$HOME/Library/Logs/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" MOLE_TEST_MODE=1 bash --noprofile --norc <<'EOF'
set -euo pipefail
start_inline_spinner() { :; }
stop_inline_spinner() { :; }
@@ -402,15 +677,16 @@ EOF
[ ! -f "$HOME/.local/bin/mo" ]
[ ! -d "$HOME/.config/mole" ]
[ ! -d "$HOME/.cache/mole" ]
+ [ ! -d "$HOME/Library/Logs/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"
+ mkdir -p "$HOME/.config/mole" "$HOME/.cache/mole" "$HOME/Library/Logs/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" MOLE_TEST_MODE=1 bash --noprofile --norc <<'EOF'
set -euo pipefail
start_inline_spinner() { :; }
stop_inline_spinner() { :; }
@@ -424,4 +700,436 @@ EOF
[ -f "$HOME/.local/bin/mo" ]
[ -d "$HOME/.config/mole" ]
[ -d "$HOME/.cache/mole" ]
+ [ -d "$HOME/Library/Logs/mole" ]
+}
+
+@test "remove_mole test mode ignores PATH installs outside test HOME" {
+ mkdir -p "$HOME/.local/bin" "$HOME/.config/mole" "$HOME/.cache/mole" "$HOME/Library/Logs/mole"
+ touch "$HOME/.local/bin/mole"
+ touch "$HOME/.local/bin/mo"
+
+ fake_global_bin="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-remove-path.XXXXXX")"
+ touch "$fake_global_bin/mole"
+ touch "$fake_global_bin/mo"
+ cat > "$fake_global_bin/brew" <<'EOF'
+#!/bin/bash
+exit 0
+EOF
+ chmod +x "$fake_global_bin/brew"
+
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="$fake_global_bin:/usr/bin:/bin" MOLE_TEST_MODE=1 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
+
+ rm -rf "$fake_global_bin"
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"$HOME/.local/bin/mole"* ]]
+ [[ "$output" == *"$HOME/.local/bin/mo"* ]]
+ [[ "$output" != *"$fake_global_bin/mole"* ]]
+ [[ "$output" != *"$fake_global_bin/mo"* ]]
+ [[ "$output" != *"brew uninstall --force mole"* ]]
+}
+@test "match_apps_by_name finds exact match case-insensitively" {
+ run bash --noprofile --norc <<'EOF'
+set -euo pipefail
+selected_apps=()
+apps_data=(
+ "1000|$HOME/Applications/TestApp.app|TestApp|com.example.TestApp|1.2 GB|1000000|1258291"
+ "1001|$HOME/Applications/TestApp2.app|TestApp2|com.example.TestApp2|500 MB|1000001|512000"
+ "1002|$HOME/Applications/TestApp3.app|TestApp3|com.example.TestApp3|300 MB|1000002|307200"
+)
+source "$PROJECT_ROOT/tests/test_match_apps_helper.sh"
+match_apps_by_name "testapp"
+echo "count=${#selected_apps[@]}"
+echo "match=${selected_apps[0]}"
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"count=1"* ]]
+ [[ "$output" == *"TestApp"* ]]
+}
+
+@test "match_apps_by_name finds by directory name" {
+ run bash --noprofile --norc <<'EOF'
+set -euo pipefail
+selected_apps=()
+apps_data=(
+ "1002|$HOME/Applications/TestApp.app|Test Application|com.example.TestApp|300 MB|1000002|307200"
+)
+source "$PROJECT_ROOT/tests/test_match_apps_helper.sh"
+match_apps_by_name "TestApp"
+echo "count=${#selected_apps[@]}"
+echo "match=${selected_apps[0]}"
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"count=1"* ]]
+ [[ "$output" == *"Test Application"* ]]
+}
+
+@test "match_apps_by_name warns on no match" {
+ run bash --noprofile --norc <<'EOF'
+set -euo pipefail
+selected_apps=()
+apps_data=(
+ "1000|$HOME/Applications/TestApp.app|TestApp|com.example.TestApp|1.2 GB|1000000|1258291"
+)
+source "$PROJECT_ROOT/tests/test_match_apps_helper.sh"
+match_apps_by_name "nonexistent"
+echo "count=${#selected_apps[@]}"
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"Warning: No application found matching 'nonexistent'"* ]]
+ [[ "$output" == *"count=0"* ]]
+}
+
+@test "match_apps_by_name handles multiple app names" {
+ run bash --noprofile --norc <<'EOF'
+set -euo pipefail
+selected_apps=()
+apps_data=(
+ "1000|$HOME/Applications/TestApp.app|TestApp|com.example.TestApp|1.2 GB|1000000|1258291"
+ "1001|$HOME/Applications/TestApp2.app|TestApp2|com.example.TestApp2|500 MB|1000001|512000"
+ "1002|$HOME/Applications/TestApp3.app|TestApp3|com.example.TestApp3|300 MB|1000002|307200"
+)
+source "$PROJECT_ROOT/tests/test_match_apps_helper.sh"
+match_apps_by_name "testapp2" "testapp3"
+echo "count=${#selected_apps[@]}"
+for app in "${selected_apps[@]}"; do
+ IFS='|' read -r _ _ name _ _ _ _ <<< "$app"
+ echo "matched=$name"
+done
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"count=2"* ]]
+ [[ "$output" == *"matched=TestApp2"* ]]
+ [[ "$output" == *"matched=TestApp3"* ]]
+}
+
+@test "match_apps_by_name falls back to substring match" {
+ run bash --noprofile --norc <<'EOF'
+set -euo pipefail
+selected_apps=()
+apps_data=(
+ "1000|$HOME/Applications/TestApp.app|TestApp|com.example.TestApp|1.2 GB|1000000|1258291"
+ "1001|$HOME/Applications/SlackDesktop.app|Slack|com.tinyspeck.slackmacgap|200 MB|1000001|204800"
+)
+source "$PROJECT_ROOT/tests/test_match_apps_helper.sh"
+match_apps_by_name "test"
+echo "count=${#selected_apps[@]}"
+for app in "${selected_apps[@]}"; do
+ IFS='|' read -r _ _ name _ _ _ _ <<< "$app"
+ echo "matched=$name"
+done
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"count=1"* ]]
+ [[ "$output" == *"matched=TestApp"* ]]
+}
+
+@test "match_apps_by_name does not duplicate when same name given twice" {
+ run bash --noprofile --norc <<'EOF'
+set -euo pipefail
+selected_apps=()
+apps_data=(
+ "1000|$HOME/Applications/TestApp.app|TestApp|com.example.TestApp|1.2 GB|1000000|1258291"
+)
+source "$PROJECT_ROOT/tests/test_match_apps_helper.sh"
+match_apps_by_name "testapp" "testapp"
+echo "count=${#selected_apps[@]}"
+EOF
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"count=1"* ]]
+}
+
+@test "main clears pending input before app selection after scan (#726)" {
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'INNER'
+set -euo pipefail
+
+trace_file="$HOME/uninstall-trace.log"
+app_cache_file="$HOME/apps-cache.txt"
+touch "$app_cache_file"
+
+log_operation_session_start() { :; }
+show_uninstall_help() { :; }
+hide_cursor() { :; }
+show_cursor() { :; }
+clear_screen() { :; }
+scan_applications() { printf '%s\n' "$app_cache_file"; }
+load_applications() {
+ printf 'load\n' >> "$trace_file"
+ return 0
+}
+drain_pending_input() {
+ printf 'drain\n' >> "$trace_file"
+}
+select_apps_for_uninstall() {
+ printf 'select\n' >> "$trace_file"
+ return 1
+}
+
+eval "$(sed -n '/^main()/,/^main "\$@"/p' "$PROJECT_ROOT/bin/uninstall.sh" | sed '$d')"
+
+main
+
+expected=$(printf 'load\ndrain\nselect\n')
+actual=$(cat "$trace_file")
+[[ "$actual" == "$expected" ]] || {
+ printf 'unexpected trace:\n%s\n' "$actual" >&2
+ exit 1
+}
+INNER
+
+ [ "$status" -eq 0 ]
+}
+
+
+# ---------------------------------------------------------------------------
+# #723: Trash routing default and --permanent flag
+# ---------------------------------------------------------------------------
+
+@test "uninstall main sets MOLE_DELETE_MODE=trash by default" {
+ local apps_cache
+ apps_cache="$(mktemp "${BATS_TEST_TMPDIR:-$BATS_RUN_TMPDIR:-$HOME}/tmp-723-trash.XXXXXX")"
+
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_NO_AUTH=1 \
+ APPS_CACHE_FILE="$apps_cache" bash --noprofile --norc <<'INNER'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+
+log_operation_session_start() { :; }
+show_uninstall_help() { :; }
+hide_cursor() { :; }
+show_cursor() { :; }
+clear_screen() { :; }
+scan_applications() { printf '%s\n' "$APPS_CACHE_FILE"; }
+load_applications() { return 0; }
+drain_pending_input() { :; }
+select_apps_for_uninstall() {
+ printf 'delete_mode=%s\n' "${MOLE_DELETE_MODE:-unset}"
+ return 1
+}
+
+eval "$(sed -n '/^main()/,/^main "\$@"/p' "$PROJECT_ROOT/bin/uninstall.sh" | sed '$d')"
+main
+INNER
+
+ rm -f "$apps_cache"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"delete_mode=trash"* ]]
+}
+
+@test "uninstall main sets MOLE_DELETE_MODE=permanent with --permanent flag" {
+ local apps_cache
+ apps_cache="$(mktemp "${BATS_TEST_TMPDIR:-$BATS_RUN_TMPDIR:-$HOME}/tmp-723-perm.XXXXXX")"
+
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_NO_AUTH=1 \
+ APPS_CACHE_FILE="$apps_cache" bash --noprofile --norc <<'INNER'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+
+log_operation_session_start() { :; }
+show_uninstall_help() { :; }
+hide_cursor() { :; }
+show_cursor() { :; }
+clear_screen() { :; }
+scan_applications() { printf '%s\n' "$APPS_CACHE_FILE"; }
+load_applications() { return 0; }
+drain_pending_input() { :; }
+select_apps_for_uninstall() {
+ printf 'delete_mode=%s\n' "${MOLE_DELETE_MODE:-unset}"
+ return 1
+}
+
+eval "$(sed -n '/^main()/,/^main "\$@"/p' "$PROJECT_ROOT/bin/uninstall.sh" | sed '$d')"
+main --permanent
+INNER
+
+ rm -f "$apps_cache"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"delete_mode=permanent"* ]]
+}
+
+# ---------------------------------------------------------------------------
+# --list: read-only inventory of installable app names (PR #755 scope)
+# ---------------------------------------------------------------------------
+
+@test "uninstall --list prints table with NAME, BUNDLE ID, UNINSTALL NAME, SIZE" {
+ local apps_cache
+ apps_cache="$(mktemp "${BATS_TEST_TMPDIR:-$BATS_RUN_TMPDIR:-$HOME}/tmp-list-text.XXXXXX")"
+ # Format matches load_applications: epoch|app_path|app_name|bundle_id|size|last_used|size_kb
+ cat > "$apps_cache" <<'CACHE'
+1700000000|/Applications/Slack.app|Slack|com.tinyspeck.slackmacgap|180MB|Today|184320
+1700000000|/Applications/Zoom.app|Zoom|us.zoom.xos|140MB|Yesterday|143360
+CACHE
+
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_NO_AUTH=1 \
+ APPS_CACHE_FILE="$apps_cache" bash --noprofile --norc <<'INNER'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+
+log_operation_session_start() { :; }
+show_uninstall_help() { :; }
+hide_cursor() { :; }
+show_cursor() { :; }
+clear_screen() { :; }
+scan_applications() { printf '%s\n' "$APPS_CACHE_FILE"; }
+load_applications() {
+ apps_data=()
+ while IFS='|' read -r epoch app_path app_name bundle_id size last_used size_kb; do
+ apps_data+=("$epoch|$app_path|$app_name|$bundle_id|$size|$last_used|${size_kb:-0}")
+ done < "$1"
+}
+# Stub Homebrew so test stays hermetic and brew detection never fires.
+is_homebrew_available() { return 1; }
+get_brew_cask_name() { return 1; }
+# Stubbed because the production helper lives earlier in bin/uninstall.sh
+# and our sed slice only pulls list-related helpers + main().
+uninstall_normalize_size_display() { local s="${1:-}"; [[ -z "$s" || "$s" == "0" || "$s" == "Unknown" ]] && echo "N/A" || echo "$s"; }
+
+eval "$(sed -n '/^uninstall_list_json_escape()/,/^main "\$@"/p' "$PROJECT_ROOT/bin/uninstall.sh" | sed '$d')"
+# Force text mode by simulating a TTY for stdout via /dev/tty redirect not
+# available in bats; instead pipe through a wrapper that fakes -t 1. Simplest:
+# call the function directly so [[ -t 1 ]] uses bash's stdout (the bats pipe).
+# We accept the function emits JSON when piped; assert against JSON shape too.
+main --list
+INNER
+
+ rm -f "$apps_cache"
+ [ "$status" -eq 0 ]
+ # Bats pipes stdout, so output is JSON. Assert both apps and uninstall_name.
+ [[ "$output" == *'"name": "Slack"'* ]]
+ [[ "$output" == *'"name": "Zoom"'* ]]
+ [[ "$output" == *'"uninstall_name": "Slack"'* ]]
+ [[ "$output" == *'"bundle_id": "com.tinyspeck.slackmacgap"'* ]]
+ [[ "$output" == *'"source": "App"'* ]]
+}
+
+@test "uninstall --list emits JSON array when stdout is piped" {
+ local apps_cache
+ apps_cache="$(mktemp "${BATS_TEST_TMPDIR:-$BATS_RUN_TMPDIR:-$HOME}/tmp-list-json.XXXXXX")"
+ cat > "$apps_cache" <<'CACHE'
+1700000000|/Applications/Slack.app|Slack|com.tinyspeck.slackmacgap|180MB|Today|184320
+CACHE
+
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_NO_AUTH=1 \
+ APPS_CACHE_FILE="$apps_cache" bash --noprofile --norc <<'INNER'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+
+log_operation_session_start() { :; }
+show_uninstall_help() { :; }
+hide_cursor() { :; }
+show_cursor() { :; }
+clear_screen() { :; }
+scan_applications() { printf '%s\n' "$APPS_CACHE_FILE"; }
+load_applications() {
+ apps_data=()
+ while IFS='|' read -r epoch app_path app_name bundle_id size last_used size_kb; do
+ apps_data+=("$epoch|$app_path|$app_name|$bundle_id|$size|$last_used|${size_kb:-0}")
+ done < "$1"
+}
+is_homebrew_available() { return 1; }
+get_brew_cask_name() { return 1; }
+# Stubbed because the production helper lives earlier in bin/uninstall.sh
+# and our sed slice only pulls list-related helpers + main().
+uninstall_normalize_size_display() { local s="${1:-}"; [[ -z "$s" || "$s" == "0" || "$s" == "Unknown" ]] && echo "N/A" || echo "$s"; }
+
+eval "$(sed -n '/^uninstall_list_json_escape()/,/^main "\$@"/p' "$PROJECT_ROOT/bin/uninstall.sh" | sed '$d')"
+main --list
+INNER
+
+ rm -f "$apps_cache"
+ [ "$status" -eq 0 ]
+ # Output should start with '[' and end with ']' to be a valid JSON array.
+ [[ "${output:0:1}" == "[" ]]
+ [[ "${output: -1}" == "]" ]]
+ # Round-trip via python to confirm it parses as JSON.
+ if command -v python3 > /dev/null; then
+ echo "$output" | python3 -c 'import sys, json; d=json.load(sys.stdin); assert isinstance(d, list) and len(d)==1 and d[0]["name"]=="Slack"'
+ fi
+}
+
+@test "uninstall --list with empty scan returns empty JSON array" {
+ local apps_cache
+ apps_cache="$(mktemp "${BATS_TEST_TMPDIR:-$BATS_RUN_TMPDIR:-$HOME}/tmp-list-empty.XXXXXX")"
+ # Non-empty file so load_applications doesn't bail early on size check.
+ echo "" > "$apps_cache"
+
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_NO_AUTH=1 \
+ APPS_CACHE_FILE="$apps_cache" bash --noprofile --norc <<'INNER'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+
+log_operation_session_start() { :; }
+show_uninstall_help() { :; }
+hide_cursor() { :; }
+show_cursor() { :; }
+clear_screen() { :; }
+scan_applications() { printf '%s\n' "$APPS_CACHE_FILE"; }
+load_applications() {
+ apps_data=()
+ return 0
+}
+is_homebrew_available() { return 1; }
+get_brew_cask_name() { return 1; }
+# Stubbed because the production helper lives earlier in bin/uninstall.sh
+# and our sed slice only pulls list-related helpers + main().
+uninstall_normalize_size_display() { local s="${1:-}"; [[ -z "$s" || "$s" == "0" || "$s" == "Unknown" ]] && echo "N/A" || echo "$s"; }
+
+eval "$(sed -n '/^uninstall_list_json_escape()/,/^main "\$@"/p' "$PROJECT_ROOT/bin/uninstall.sh" | sed '$d')"
+main --list
+INNER
+
+ rm -f "$apps_cache"
+ [ "$status" -eq 0 ]
+ [[ "$output" == "[]" ]]
+}
+
+@test "uninstall --list flags brew-managed apps with cask uninstall_name" {
+ local apps_cache
+ apps_cache="$(mktemp "${BATS_TEST_TMPDIR:-$BATS_RUN_TMPDIR:-$HOME}/tmp-list-brew.XXXXXX")"
+ cat > "$apps_cache" <<'CACHE'
+1700000000|/Applications/Visual Studio Code.app|Visual Studio Code|com.microsoft.VSCode|420MB|Today|430080
+CACHE
+
+ run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_NO_AUTH=1 \
+ APPS_CACHE_FILE="$apps_cache" bash --noprofile --norc <<'INNER'
+set -euo pipefail
+source "$PROJECT_ROOT/lib/core/common.sh"
+
+log_operation_session_start() { :; }
+show_uninstall_help() { :; }
+hide_cursor() { :; }
+show_cursor() { :; }
+clear_screen() { :; }
+scan_applications() { printf '%s\n' "$APPS_CACHE_FILE"; }
+load_applications() {
+ apps_data=()
+ while IFS='|' read -r epoch app_path app_name bundle_id size last_used size_kb; do
+ apps_data+=("$epoch|$app_path|$app_name|$bundle_id|$size|$last_used|${size_kb:-0}")
+ done < "$1"
+}
+# Force brew-managed result.
+is_homebrew_available() { return 0; }
+get_brew_cask_name() { printf '%s' "visual-studio-code"; return 0; }
+uninstall_normalize_size_display() { local s="${1:-}"; [[ -z "$s" || "$s" == "0" || "$s" == "Unknown" ]] && echo "N/A" || echo "$s"; }
+
+eval "$(sed -n '/^uninstall_list_json_escape()/,/^main "\$@"/p' "$PROJECT_ROOT/bin/uninstall.sh" | sed '$d')"
+main --list
+INNER
+
+ rm -f "$apps_cache"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *'"uninstall_name": "visual-studio-code"'* ]]
+ [[ "$output" == *'"source": "Homebrew"'* ]]
}
diff --git a/Resources/mole/tests/uninstall_naming_variants.bats b/Resources/mole/tests/uninstall_naming_variants.bats
index eee48f9..be084be 100644
--- a/Resources/mole/tests/uninstall_naming_variants.bats
+++ b/Resources/mole/tests/uninstall_naming_variants.bats
@@ -109,6 +109,24 @@ setup() {
[[ "$result" =~ .local/share/firefox ]]
}
+@test "find_app_files detects bundle-id-derived extension leftovers" {
+ mkdir -p "$HOME/Library/Application Support/FileProvider/com.tencent.xinWeChat.WeChatFileProviderExtension"
+ mkdir -p "$HOME/Library/Application Scripts/com.tencent.xinWeChat.WeChatMacShare"
+ mkdir -p "$HOME/Library/Application Scripts/5A4RE8SF68.com.tencent.xinWeChat"
+ mkdir -p "$HOME/Library/Containers/com.tencent.xinWeChat.WeChatFileProviderExtension"
+ mkdir -p "$HOME/Library/Group Containers/5A4RE8SF68.com.tencent.xinWeChat"
+ mkdir -p "$HOME/Library/Containers/com.tencent.otherapp.Helper"
+
+ result=$(find_app_files "com.tencent.xinWeChat" "WeChat")
+
+ [[ "$result" =~ Library/Application\ Support/FileProvider/com.tencent.xinWeChat.WeChatFileProviderExtension ]]
+ [[ "$result" =~ Library/Application\ Scripts/com.tencent.xinWeChat.WeChatMacShare ]]
+ [[ "$result" =~ Library/Application\ Scripts/5A4RE8SF68.com.tencent.xinWeChat ]]
+ [[ "$result" =~ Library/Containers/com.tencent.xinWeChat.WeChatFileProviderExtension ]]
+ [[ "$result" =~ Library/Group\ Containers/5A4RE8SF68.com.tencent.xinWeChat ]]
+ [[ ! "$result" =~ Library/Containers/com.tencent.otherapp.Helper ]]
+}
+
@test "find_app_files does not match empty app name" {
mkdir -p "$HOME/Library/Application Support/test"
diff --git a/Resources/mole/tests/update.bats b/Resources/mole/tests/update.bats
index 19954eb..3e8e45b 100644
--- a/Resources/mole/tests/update.bats
+++ b/Resources/mole/tests/update.bats
@@ -604,7 +604,7 @@ MOLE_TEST_MODE=1 MOLE_SKIP_MAIN=1 source "$PROJECT_ROOT/mole"
brew() {
if [[ "${1:-}" == "outdated" ]]; then
- echo "tw93/tap/mole (1.29.0) < 1.30.0"
+ echo "tw93/tap/mole (1.29.0) < 1.31.0"
return 0
fi
if [[ "${1:-}" == "info" ]]; then
@@ -619,7 +619,7 @@ get_homebrew_latest_version
EOF
[ "$status" -eq 0 ]
- [[ "$output" == "1.30.0" ]]
+ [[ "$output" == "1.31.0" ]]
}
@test "get_homebrew_latest_version parses brew info fallback with heading prefix" {
diff --git a/Resources/mole/tests/user_file_ops.bats b/Resources/mole/tests/user_file_ops.bats
index 086a125..fbad735 100644
--- a/Resources/mole/tests/user_file_ops.bats
+++ b/Resources/mole/tests/user_file_ops.bats
@@ -76,6 +76,61 @@ setup() {
[ -d "$result" ]
}
+@test "get_mole_temp_root uses writable TMPDIR when available" {
+ local writable_tmp="$HOME/custom-tmp"
+ mkdir -p "$writable_tmp"
+
+ result=$(env HOME="$HOME" TMPDIR="$writable_tmp" bash -c "source '$PROJECT_ROOT/lib/core/base.sh'; get_mole_temp_root")
+ [ "$result" = "$writable_tmp" ]
+}
+
+@test "get_mole_temp_root falls back to user cache when TMPDIR is not writable" {
+ local blocked_tmp="$HOME/blocked-tmp"
+ mkdir -p "$blocked_tmp"
+ chmod 500 "$blocked_tmp"
+
+ result=$(env HOME="$HOME" TMPDIR="$blocked_tmp" bash -c "source '$PROJECT_ROOT/lib/core/base.sh'; get_mole_temp_root")
+ [ "$result" = "$HOME/.cache/mole/tmp" ]
+ [ -d "$HOME/.cache/mole/tmp" ]
+}
+
+@test "get_mole_temp_root caches the first resolved directory" {
+ local first_tmp="$HOME/first-tmp"
+ local second_tmp="$HOME/second-tmp"
+ mkdir -p "$first_tmp" "$second_tmp"
+
+ result=$(env HOME="$HOME" TMPDIR="$first_tmp" bash -c "
+ source '$PROJECT_ROOT/lib/core/base.sh'
+ ensure_mole_temp_root
+ first=\$MOLE_RESOLVED_TMPDIR
+ export TMPDIR='$second_tmp'
+ ensure_mole_temp_root
+ second=\$MOLE_RESOLVED_TMPDIR
+ printf '%s|%s\n' \"\$first\" \"\$second\"
+ ")
+
+ [ "$result" = "$first_tmp|$first_tmp" ]
+}
+
+@test "get_mole_temp_root falls back to /tmp when TMPDIR and invoking home are unavailable" {
+ result=$(env HOME="$HOME" TMPDIR="/var/empty" bash -c "
+ source '$PROJECT_ROOT/lib/core/base.sh'
+ get_invoking_home() { echo '/var/empty'; }
+ get_mole_temp_root
+ ")
+
+ [ "$result" = "/tmp" ]
+}
+
+@test "common.sh exports resolved TMPDIR for runtime callers" {
+ local blocked_tmp="$HOME/common-blocked-tmp"
+ mkdir -p "$blocked_tmp"
+ chmod 500 "$blocked_tmp"
+
+ result=$(env HOME="$HOME" TMPDIR="$blocked_tmp" bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; printf '%s\n' \"\$TMPDIR\"")
+ [ "$result" = "$HOME/.cache/mole/tmp" ]
+}
+
@test "get_user_home returns home for valid user" {
current_user="${USER:-$(whoami)}"
result=$(bash -c "source '$PROJECT_ROOT/lib/core/base.sh'; get_user_home '$current_user'")