diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index aa38925..716382a 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -21,7 +21,7 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} - name: Cache Homebrew - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v4 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v4 with: path: | ~/Library/Caches/Homebrew @@ -36,7 +36,7 @@ jobs: run: brew install shfmt shellcheck golangci-lint - name: Set up Go - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v5 + uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v5 with: go-version: '1.24.6' @@ -74,7 +74,7 @@ jobs: ref: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.head_ref) || github.ref }} - name: Cache Homebrew - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v4 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v4 with: path: | ~/Library/Caches/Homebrew @@ -89,7 +89,7 @@ jobs: run: brew install shfmt shellcheck golangci-lint - name: Set up Go - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v5 + uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v5 with: go-version: '1.24.6' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6569766..71c974a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,7 +26,7 @@ jobs: uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4 - name: Set up Go - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v5 + uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v5 with: go-version: "1.24.6" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index eb5a23c..21a5efe 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,7 +17,7 @@ jobs: run: brew install bats-core shellcheck - name: Set up Go - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v5 + uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v5 with: go-version: "1.24.6" @@ -79,7 +79,7 @@ jobs: echo "Checking for hardcoded secrets..." matches=$(grep -r "password\|secret\|api_key" --include="*.sh" . \ | grep -v "# \|test" \ - | grep -v -E "lib/core/sudo\.sh|lib/core/app_protection\.sh|lib/clean/user\.sh|lib/clean/brew\.sh|bin/optimize\.sh|lib/clean/apps\.sh" || true) + | grep -v -E "lib/core/sudo\.sh|lib/core/app_protection\.sh|lib/clean/user\.sh|lib/clean/brew\.sh|bin/optimize\.sh|lib/clean/apps\.sh|lib/uninstall/batch\.sh" || true) if [[ -n "$matches" ]]; then echo "$matches" echo "✗ Potential secrets found" diff --git a/.gitignore b/.gitignore index 2c2eb10..a384879 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,8 @@ tests/tmp-* CLAUDE.md GEMINI.md ANTIGRAVITY.md +WARP.md +AGENTS.md .cursorrules # Go build artifacts (development) diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index a601056..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,412 +0,0 @@ -# AGENTS.md - Development Guide for Mole - -This guide provides AI coding assistants with essential commands, patterns, and conventions for working in the Mole codebase. - -**Quick reference**: Build/test commands • Safety rules • Architecture map • Code style - ---- - -## Safety Checklist - -Before any operation: - -- Use `safe_*` helpers (never raw `rm -rf` or `find -delete`) -- Check protection: `is_protected()`, `is_whitelisted()` -- Test first: `MO_DRY_RUN=1 ./mole clean` -- Validate syntax: `bash -n ` -- Run tests: `./scripts/test.sh` - -## NEVER Do These - -- Run `rm -rf` or any raw deletion commands -- Delete files without checking protection lists -- Modify system-critical paths (e.g., `/System`, `/Library/Apple`) -- Remove installer flags `--prefix`/`--config` from `install.sh` -- **Commit code changes or run `git commit` unless the user explicitly asks you to commit** -- **Reply to GitHub issues or PRs on behalf of the user** - only prepare responses for user review -- Run destructive operations without dry-run validation -- Use raw `git` commands when `gh` CLI is available - -## ALWAYS Do These - -- Use `safe_*` helper functions for deletions (`safe_rm`, `safe_find_delete`) -- Respect whitelist files (e.g., `~/.config/mole/whitelist`) -- Check protection logic before cleanup operations -- Test with dry-run modes first -- Validate syntax before suggesting changes: `bash -n ` -- **Prioritize `gh` CLI for ALL GitHub operations** - Always use `gh` to fetch and manipulate GitHub data (issues, PRs, releases, comments, etc.) instead of raw git commands or web scraping -- **Document fixes in AGENTS.md instead of committing or replying** - Prepare commit messages and GitHub responses for user review, don't execute them -- Review and update `SECURITY_AUDIT.md` when modifying `clean` or `optimize` logic - ---- - -## Quick Reference - -### Build Commands - -```bash -# Build Go binaries for current platform -make build - -# Build release binaries (cross-platform) -make release-amd64 # macOS Intel -make release-arm64 # macOS Apple Silicon - -# Clean build artifacts -make clean -``` - -### Test Commands - -```bash -# Run full test suite (recommended before commits) -./scripts/test.sh - -# Run specific BATS test file -bats tests/clean.bats - -# Run specific test case by name -bats tests/clean.bats -f "should respect whitelist" - -# Run Go tests only -go test -v ./cmd/... - -# Run Go tests for specific package -go test -v ./cmd/analyze - -# Shell syntax check -bash -n lib/clean/user.sh -bash -n mole - -# Lint shell scripts -shellcheck --rcfile .shellcheckrc lib/**/*.sh bin/**/*.sh -``` - -### Development Commands - -```bash -# Test cleanup in dry-run mode -MO_DRY_RUN=1 ./mole clean - -# Enable debug logging -MO_DEBUG=1 ./mole clean - -# Test Go tool directly -go run ./cmd/analyze - -# Test installation locally -./install.sh --prefix /usr/local/bin --config ~/.config/mole -``` - ---- - -## Architecture Quick Map - -``` -mole/ # Main CLI entrypoint (menu + routing) -├── mo # CLI alias wrapper -├── install.sh # Manual installer/updater (preserves --prefix/--config) -├── bin/ # Command entry points (thin wrappers) -│ ├── clean.sh # Deep cleanup orchestrator -│ ├── uninstall.sh # App removal with leftover detection -│ ├── optimize.sh # Cache rebuild + service refresh -│ ├── purge.sh # Aggressive cleanup mode -│ ├── touchid.sh # Touch ID sudo enabler -│ ├── analyze.sh # Disk usage explorer wrapper -│ ├── status.sh # System health dashboard wrapper -│ ├── installer.sh # Core installation logic -│ └── completion.sh # Shell completion support -├── lib/ # Reusable shell logic -│ ├── core/ # base.sh, log.sh, sudo.sh, ui.sh -│ ├── clean/ # Cleanup modules (user, apps, brew, system...) -│ ├── optimize/ # Optimization modules -│ ├── check/ # Health check modules -│ ├── manage/ # Management utilities -│ ├── ui/ # UI components (balloons, spinners) -│ └── uninstall/ # Uninstallation logic -├── cmd/ # Go applications -│ ├── analyze/ # Disk analysis tool -│ └── status/ # Real-time monitoring -├── scripts/ # Build and test automation -│ └── test.sh # Main test runner (shell + go + BATS) -└── tests/ # BATS integration tests -``` - -**Decision Tree**: - -- User cleanup logic → `lib/clean/.sh` -- Command entry → `bin/.sh` -- Core utils → `lib/core/.sh` -- Performance tool → `cmd//*.go` -- Tests → `tests/.bats` - -### Language Stack - -- **Shell (Bash 3.2)**: Core cleanup and system operations (`lib/`, `bin/`) -- **Go**: Performance-critical tools (`cmd/analyze/`, `cmd/status/`) -- **BATS**: Integration testing (`tests/`) - ---- - -## Code Style Guidelines - -### Shell Scripts - -- **Indentation**: 4 spaces (configured in .editorconfig) -- **Variables**: `lowercase_with_underscores` -- **Functions**: `verb_noun` format (e.g., `clean_caches`, `get_size`) -- **Constants**: `UPPERCASE_WITH_UNDERSCORES` -- **Quoting**: Always quote variables: `"$var"` not `$var` -- **Tests**: Use `[[` instead of `[` -- **Command substitution**: Use `$(command)` not backticks -- **Error handling**: Use `set -euo pipefail` at top of files - -### Go Code - -- **Formatting**: Follow standard Go conventions (`gofmt`, `go vet`) -- **Package docs**: Add package-level documentation for exported functions -- **Error handling**: Never ignore errors, always handle them explicitly -- **Build tags**: Use `//go:build darwin` for macOS-specific code - -### Comments - -- **Language**: English only -- **Focus**: Explain "why" not "what" (code should be self-documenting) -- **Safety**: Document safety boundaries explicitly -- **Non-obvious logic**: Explain workarounds or complex patterns - ---- - -## Key Helper Functions - -### Safety Helpers (lib/core/base.sh) - -- `safe_rm `: Safe deletion with validation -- `safe_find_delete `: Protected find+delete -- `is_protected `: Check if path is system-protected -- `is_whitelisted `: Check user whitelist - -### Logging (lib/core/log.sh) - -- `log_info `: Informational messages -- `log_success `: Success notifications -- `log_warn `: Warnings -- `log_error `: Error messages -- `debug `: Debug output (requires MO_DEBUG=1) - -### UI Helpers (lib/core/ui.sh) - -- `confirm `: Yes/no confirmation -- `show_progress `: Progress display - ---- - -## Testing Strategy - -### Test Types - -1. **Syntax Validation**: `bash -n ` - catches basic errors -2. **Unit Tests**: BATS tests for individual functions -3. **Integration Tests**: Full command execution with BATS -4. **Dry-run Tests**: `MO_DRY_RUN=1` to validate without deletion -5. **Go Tests**: `go test -v ./cmd/...` - -### Test Environment Variables - -- `MO_DRY_RUN=1`: Preview changes without execution -- `MO_DEBUG=1`: Enable detailed debug logging -- `BATS_FORMATTER=pretty`: Use pretty output for BATS (default) -- `BATS_FORMATTER=tap`: Use TAP output for CI - ---- - -## Common Development Tasks - -### Adding New Cleanup Module - -1. Create `lib/clean/new_module.sh` -2. Implement cleanup logic using `safe_*` helpers -3. Source it in `bin/clean.sh` -4. Add protection checks for critical paths -5. Write BATS test in `tests/clean.bats` -6. Test with `MO_DRY_RUN=1` first - -### Modifying Go Tools - -1. Navigate to `cmd//` -2. Make changes to Go files -3. Test with `go run .` or `make build && ./bin/-go` -4. Run `go test -v` for unit tests -5. Check integration: `./mole ` - -### Debugging Issues - -1. Enable debug mode: `MO_DEBUG=1 ./mole clean` -2. Check logs for error messages -3. Verify sudo permissions: `sudo -n true` or `./mole touchid` -4. Test individual functions in isolation -5. Use `shellcheck` for shell script issues - ---- - -## Linting and Quality - -### Shell Script Linting - -- **Tool**: shellcheck with custom `.shellcheckrc` -- **Disabled rules**: SC2155, SC2034, SC2059, SC1091, SC2038 -- **Command**: `shellcheck --rcfile .shellcheckrc lib/**/*.sh bin/**/*.sh` - -### Go Code Quality - -- **Tools**: `go vet`, `go fmt`, `go test` -- **Command**: `go vet ./cmd/... && go test ./cmd/...` - -### CI/CD Pipeline - -- **Triggers**: Push/PR to main, dev branches -- **Platforms**: macOS 14, macOS 15 -- **Tools**: bats-core, shellcheck, Go 1.24.6 -- **Security checks**: Unsafe rm usage, app protection, secret scanning - ---- - -## File Organization Patterns - -### Shell Modules - -- Entry scripts in `bin/` should be thin wrappers -- Reusable logic goes in `lib/` -- Core utilities in `lib/core/` -- Feature-specific modules in `lib/clean/`, `lib/ui/`, etc. - -### Go Packages - -- Each tool in its own `cmd//` directory -- Main entry point in `main.go` -- Use standard Go project layout -- macOS-specific code guarded with build tags - ---- - -## GitHub Operations - -### ⚡ ALWAYS Use gh CLI for GitHub Information - -**Golden Rule**: Whenever you need to fetch or manipulate GitHub data (issues, PRs, commits, releases, comments, etc.), **ALWAYS use `gh` CLI first**. It's more reliable, authenticated, and provides structured output compared to web scraping or raw git commands. - -**Preferred Commands**: - -```bash -# Issues -gh issue view 123 # View issue details -gh issue list # List issues -gh issue comment 123 "message" # Comment on issue - -# Pull Requests -gh pr view # View current PR -gh pr diff # Show diff -gh pr list # List PRs -gh pr checkout 123 # Checkout PR branch -gh pr merge # Merge current PR - -# Repository operations -gh release create v1.0.0 # Create release -gh repo view # Repository info -gh api repos/owner/repo/issues # Raw API access -``` - -**NEVER use raw git commands for GitHub operations** when `gh` is available: - -- ❌ `git log --oneline origin/main..HEAD` → ✅ `gh pr view` -- ❌ `git remote get-url origin` → ✅ `gh repo view` -- ❌ Manual GitHub API curl commands → ✅ `gh api` - -## Error Handling Patterns - -### Shell Scripts - -- Use `set -euo pipefail` for strict error handling -- Check command exit codes: `if command; then ...` -- Provide meaningful error messages with `log_error` -- Use cleanup traps for temporary resources - -### Go Code - -- Never ignore errors: `if err != nil { return err }` -- Use structured error messages -- Handle context cancellation appropriately -- Log errors with context information - ---- - -## Performance Considerations - -### Shell Optimization - -- Use built-in shell operations over external commands -- Prefer `find -delete` over `-exec rm` -- Minimize subprocess creation -- Use appropriate timeout mechanisms - -### Go Optimization - -- Use concurrency for I/O-bound operations -- Implement proper caching for expensive operations -- Profile memory usage in scanning operations -- Use efficient data structures for large datasets - ---- - -## Security Best Practices - -### Path Validation - -- Always validate user-provided paths -- Check against protection lists before operations -- Use absolute paths to prevent directory traversal -- Implement proper sandboxing for destructive operations - -### Permission Management - -- Request sudo only when necessary -- Use `sudo -n true` to check sudo availability -- Implement proper Touch ID integration -- Respect user whitelist configurations - ---- - -## Common Pitfalls to Avoid - -1. **Over-engineering**: Keep solutions simple. Don't add abstractions for one-time operations. -2. **Premature optimization**: Focus on correctness first, performance second. -3. **Assuming paths exist**: Always check before operating on files/directories. -4. **Ignoring protection logic**: User data loss is unacceptable. -5. **Breaking updates**: Keep `--prefix`/`--config` flags in `install.sh`. -6. **Platform assumptions**: Code must work on all supported macOS versions (10.13+). -7. **Silent failures**: Always log errors and provide actionable messages. - ---- - -## Communication Style - -- Be concise and technical -- Explain safety implications upfront -- Show before/after for significant changes -- Provide file:line references for code locations -- Suggest testing steps for validation - ---- - -## Resources - -- Main script: `mole` (menu + routing logic) -- Protection lists: Check `is_protected()` implementations -- User config: `~/.config/mole/` -- Test directory: `tests/` -- Build scripts: `scripts/` -- Documentation: `README.md`, `CONTRIBUTING.md`, `SECURITY_AUDIT.md` - ---- - -**Remember**: When in doubt, err on the side of safety. It's better to clean less than to risk user data. diff --git a/README.md b/README.md index 1709e4d..6264759 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ brew install mole curl -fsSL https://raw.githubusercontent.com/tw93/mole/main/install.sh | bash ``` -**Windows:** Mole is designed for macOS, but we offer an experimental Windows version based on user demand. See the [windows branch](https://github.com/tw93/Mole/tree/windows) — for early adopters only. +**Windows:** Mole is designed for macOS, but we offer an experimental Windows version based on user demand. See the [windows branch](https://github.com/tw93/Mole/tree/windows), for early adopters only. **Run:** @@ -76,6 +76,7 @@ mo purge --paths # Configure project scan directories - **Safety**: Built with strict protections. See [Security Audit](SECURITY_AUDIT.md). Preview changes with `mo clean --dry-run`. - **Be Careful**: Although safe by design, file deletion is permanent. Please review operations carefully. - **Debug Mode**: Use `--debug` for detailed logs (e.g., `mo clean --debug`). Combine with `--dry-run` for comprehensive preview including risk levels and file details. +- **Operation Log**: File operations are logged to `~/.config/mole/operations.log` for troubleshooting. Disable with `MO_NO_OPLOG=1`. - **Navigation**: Supports arrow keys and Vim bindings (`h/j/k/l`). - **Status Shortcuts**: In `mo status`, press `k` to toggle cat visibility and save preference, `q` to quit. - **Configuration**: Run `mo touchid` for Touch ID sudo, `mo completion` for shell tab completion, `mo clean --whitelist` to manage protected paths. @@ -184,8 +185,8 @@ Read ▮▯▯▯▯ 2.1 MB/s Health Normal · 423 cycles Write ▮▮▮▯▯ 18.3 MB/s Temp 58°C · 1200 RPM ⇅ Network ▶ Processes -Down ▮▮▯▯▯ 3.2 MB/s Code ▮▮▮▮▯ 42.1% -Up ▮▯▯▯▯ 0.8 MB/s Chrome ▮▮▮▯▯ 28.3% +Down ▁▁█▂▁▁▁▁▁▁▁▁▇▆▅▂ 0.54 MB/s Code ▮▮▮▮▯ 42.1% +Up ▄▄▄▃▃▃▄▆▆▇█▁▁▁▁▁ 0.02 MB/s Chrome ▮▮▮▯▯ 28.3% Proxy HTTP · 192.168.1.100 Terminal ▮▯▯▯▯ 12.5% ``` @@ -210,7 +211,7 @@ Select Categories to Clean - 18.5GB (8 selected) ● backend-service 2.5GB | node_modules ``` -> **Use with caution:** This will permanently delete selected artifacts. Review carefully before confirming. Recent projects — less than 7 days old — are marked and unselected by default. +> **Use with caution:** This will permanently delete selected artifacts. Review carefully before confirming. Recent projects, less than 7 days old, are marked and unselected by default.
Custom Scan Paths @@ -258,21 +259,22 @@ Mole automatically detects your terminal, or set `MO_LAUNCHER_APP=` to ove ## Community Love -Mole wouldn't be possible without these amazing contributors. They've built countless features that make Mole what it is today. Go follow them! ❤️ +Big thanks to all contributors who helped build Mole. Go follow them! ❤️ -Join thousands of users worldwide who trust Mole to keep their Macs clean and optimized. +

+Real feedback from users who shared Mole on X. Community feedback on Mole ## Support -- If Mole saved you disk space, consider starring the repo or [sharing it](https://twitter.com/intent/tweet?url=https://github.com/tw93/Mole&text=Mole%20-%20Deep%20clean%20and%20optimize%20your%20Mac.) with friends. -- Have ideas or fixes? Check our [Contributing Guide](CONTRIBUTING.md), then open an issue or PR to help shape Mole's future. -- Love Mole? Buy Tw93 an ice-cold Coke to keep the project alive and kicking! 🥤 +- 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 found bugs? Check the [Contributing Guide](CONTRIBUTING.md) and open an issue or PR. +- Like Mole? Buy Tw93 a Coke to support the project! 🥤
Friends who bought me Coke @@ -282,4 +284,4 @@ Join thousands of users worldwide who trust Mole to keep their Macs clean and op ## License -MIT License — feel free to enjoy and participate in open source. +MIT License, feel free to enjoy and participate in open source. diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md index 67c3971..af3069d 100644 --- a/SECURITY_AUDIT.md +++ b/SECURITY_AUDIT.md @@ -1,356 +1,169 @@ -# Mole Security Audit Report +# Mole Security Reference -
+Version 1.23.2 | 2026-01-26 -**Status:** PASSED | **Risk Level:** LOW | **Version:** 1.21.0 (2026-01-15) +## Recent Fixes -
+**Uninstall audit, Jan 2026:** ---- +- `stop_launch_services()` now checks bundle_id is valid reverse-DNS before using it in find patterns. This stops glob injection. +- `find_app_files()` skips LaunchAgents named after common words like Music or Notes. +- Added comments explaining why `remove_file_list()` bypasses TOCTOU checks for symlinks. +- `brew_uninstall_cask()` treats exit code 124 as timeout failure, returns immediately. -## Audit Overview +Other changes: -| Attribute | Details | -|-----------|---------| -| Audit Date | January 15, 2026 | -| Audit Conclusion | **PASSED** | -| Mole Version | V1.21.0 | -| Audited Branch | `main` (HEAD) | -| Scope | Shell scripts, Go binaries, Configuration | -| Methodology | Static analysis, Threat modeling, Code review | -| Review Cycle | Every 6 months or after major feature additions | -| Next Review | June 2026 | +- Symlink cleanup in `bin/clean.sh` goes through `safe_remove` now +- Orphaned helper cleanup in `lib/clean/apps.sh` switched to `safe_sudo_remove` +- ByHost pref cleanup checks bundle ID format first -**Key Findings:** +## Path Validation -- Multi-layer validation effectively blocks risky system modifications. -- Conservative cleaning logic ensures safety (e.g., 60-day dormancy rule). -- Comprehensive protection for VPNs, AI tools, and core system components. -- Atomic operations prevent state corruption during crashes. -- Dry-run and whitelist features give users full control. -- Installer cleanup scans safely and requires user confirmation. +Every deletion goes through `lib/core/file_ops.sh`. The `validate_path_for_deletion()` function rejects empty paths, paths with `/../` in them, and anything containing control characters like newlines or null bytes. ---- +**Blocked paths**, even with sudo: -## Security Philosophy - -**Core Principle: "Do No Harm"** - -We built Mole on a **Zero Trust** architecture for filesystem operations. Every modification request is treated as dangerous until it passes strict validation. - -**Guiding Priorities:** - -1. **System Stability First** - We'd rather leave 1GB of junk than delete 1KB of your data. -2. **Conservative by Default** - High-risk operations always require explicit confirmation. -3. **Fail Safe** - When in doubt, we abort immediately. -4. **Transparency** - Every operation is logged and allows a preview via dry-run mode. - ---- - -## Threat Model - -### Attack Vectors & Mitigations - -| Threat | Risk Level | Mitigation | Status | -|--------|------------|------------|--------| -| Accidental System File Deletion | Critical | Multi-layer path validation, system directory blocklist | Mitigated | -| Path Traversal Attack | High | Absolute path enforcement, relative path rejection | Mitigated | -| Symlink Exploitation | High | Symlink detection in privileged mode | Mitigated | -| Command Injection | High | Control character filtering, strict validation | Mitigated | -| Empty Variable Deletion | High | Empty path validation, defensive checks | Mitigated | -| Race Conditions | Medium | Atomic operations, process isolation | Mitigated | -| Network Mount Hangs | Medium | Timeout protection, volume type detection | Mitigated | -| Privilege Escalation | Medium | Restricted sudo scope, user home validation | Mitigated | -| False Positive Deletion | Medium | 3-char minimum, fuzzy matching disabled | Mitigated | -| VPN Configuration Loss | Medium | Comprehensive VPN/proxy whitelist | Mitigated | - ---- - -## Defense Architecture - -### Multi-Layered Validation System - -All automated operations pass through hardened middleware (`lib/core/file_ops.sh`) with 4 layers of validation: - -#### Layer 1: Input Sanitization - -| Control | Protection Against | -|---------|---------------------| -| Absolute Path Enforcement | Path traversal attacks (`../etc`) | -| Control Character Filtering | Command injection (`\n`, `\r`, `\0`) | -| Empty Variable Protection | Accidental `rm -rf /` | -| Secure Temp Workspaces | Data leakage, race conditions | - -**Code:** `lib/core/file_ops.sh:validate_path_for_deletion()` - -#### Layer 2: System Path Protection ("Iron Dome") - -Even with `sudo`, these paths are **unconditionally blocked**: - -```bash -/ # Root filesystem -/System # macOS system files -/bin, /sbin, /usr # Core binaries -/etc, /var # System configuration -/Library/Extensions # Kernel extensions -/private # System-private directories +```text +/ # root +/System # macOS system +/bin, /sbin, /usr # binaries +/etc, /var # config +/Library/Extensions # kexts +/private # system private ``` -**Exceptions:** +Some system caches are OK to delete: -- `/System/Library/Caches/com.apple.coresymbolicationd/data` (safe, rebuildable cache) +- `/System/Library/Caches/com.apple.coresymbolicationd/data` - `/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` -**Code:** `lib/core/file_ops.sh:60-78` +See `lib/core/file_ops.sh:60-78`. -#### Layer 3: Symlink Detection +When running with sudo, `safe_sudo_recursive_delete()` also checks for symlinks. Refuses to follow symlinks pointing to system files. -For privileged operations, pre-flight checks prevent symlink-based attacks: +## Cleanup Rules -- Detects symlinks from cache folders pointing to system files. -- Refuses recursive deletion of symbolic links in sudo mode. -- Validates real path vs. symlink target. +**Orphan detection** at `lib/clean/apps.sh:orphan_detection()`: -**Code:** `lib/core/file_ops.sh:safe_sudo_recursive_delete()` +App data is only considered orphaned if the app itself is gone from all three locations: `/Applications`, `~/Applications`, `/System/Applications`. On top of that, the data must be untouched for at least 60 days. Adobe, Microsoft, and Google stuff is whitelisted regardless. -#### Layer 4: Permission Management +**Uninstall matching** at `lib/clean/apps.sh:uninstall_app()`: -When running with `sudo`: +App names need at least 3 characters. Otherwise "Go" would match "Google" and that's bad. Fuzzy matching is off. Receipt scans only look under `/Applications` and `/Library/Application Support`, not in shared places like `/Library/Frameworks`. -- Auto-corrects ownership back to user (`chown -R`). -- Restricts operations to the user's home directory. -- Enforces multiple validation checkpoints. +**Dev tools:** -### Interactive Analyzer (Go) +Cache dirs like `~/.cargo/registry/cache` or `~/.gradle/caches` get cleaned. But `~/.cargo/bin`, `~/.mix/archives`, `~/.rustup` toolchains, `~/.stack/programs` stay untouched. -The analyzer (`mo analyze`) uses a distinct security model: +**LaunchAgent removal:** -- Runs with standard user permissions only. -- Respects macOS System Integrity Protection (SIP). -- **Two-Key Confirmation:** Deletion requires ⌫ (Delete) to enter confirmation mode, then Enter to confirm. Prevents accidental double-press of the same key. -- **Trash Instead of Delete:** Files are moved to macOS Trash using Finder's native API, allowing easy recovery if needed. -- OS-level enforcement (cannot delete `/System` due to Read-Only Volume). +Only removed when uninstalling the app that owns them. All `com.apple.*` items are skipped. Services get stopped via `launchctl` first. Generic names like Music, Notes, Photos are excluded from the search. -**Code:** `cmd/analyze/*.go` +See `lib/core/app_protection.sh:find_app_files()`. ---- +## Protected Categories -## Safety Mechanisms +| Category | What's protected | +| -------- | ---------------- | +| System | Control Center, System Settings, TCC, `/Library/Updates`, Spotlight | +| VPN/Proxy | Shadowsocks, V2Ray, Tailscale, Clash | +| AI | Cursor, Claude, ChatGPT, Ollama, LM Studio | +| Time Machine | Checks if backup is running. If status unclear, skips cleanup. | +| Startup | `com.apple.*` LaunchAgents/Daemons always skipped | -### Conservative Cleaning Logic +See `lib/core/app_protection.sh:is_critical_system_component()`. -#### The "60-Day Rule" for Orphaned Data +## Analyzer -| Step | Verification | Criterion | -|------|--------------|-----------| -| 1. App Check | All installation locations | Must be missing from `/Applications`, `~/Applications`, `/System/Applications` | -| 2. Dormancy | Modification timestamps | Untouched for ≥60 days | -| 3. Vendor Whitelist | Cross-reference database | Adobe, Microsoft, and Google resources are protected | +`mo analyze` runs differently: -**Code:** `lib/clean/apps.sh:orphan_detection()` +- Standard user permissions, no sudo +- Respects SIP +- Two keys to delete: press ⌫ first, then Enter. Hard to delete by accident. +- Files go to Trash via Finder API, not rm -#### Developer Tool Ecosystems (Consolidated) +Code at `cmd/analyze/*.go`. -Support for 20+ languages (Rust, Go, Node, Python, JVM, Mobile, Elixir, Haskell, OCaml, etc.) with strict safety checks: +## Timeouts -- **Global Optimization:** The core `safe_clean` function now intelligently checks parent directories before attempting wildcard cleanups, eliminating overhead for missing tools across the entire system. -- **Safe Targets:** Only volatile caches are cleaned (e.g., `~/.cargo/registry/cache`, `~/.gradle/caches`). -- **Protected Paths:** Critical directories like `~/.cargo/bin`, `~/.mix/archives`, `~/.rustup` toolchains, and `~/.stack/programs` are explicitly **excluded**. +| Operation | Timeout | Why | +| --------- | ------- | --- | +| Network volume check | 5s | NFS/SMB/AFP can hang forever | +| App bundle search | 10s | mdfind sometimes stalls | +| SQLite vacuum | 20s | Skip if Mail/Safari/Messages is open | +| dyld cache rebuild | 180s | Skip if done in last 24h | -#### Active Uninstallation Heuristics +See `lib/core/base.sh:run_with_timeout()`. -For user-selected app removal: +## User Config -- **Sanitized Name Matching:** "Visual Studio Code" → `VisualStudioCode`, `.vscode` -- **Safety Limit:** 3-char minimum (prevents "Go" matching "Google") -- **Disabled:** Fuzzy matching and wildcard expansion for short names. -- **User Confirmation:** Required before deletion. -- **Receipt Scans:** BOM-derived files are restricted to app-specific prefixes (e.g., `/Applications`, `/Library/Application Support`). Shared directories like `/Library/Frameworks` are **excluded** to prevent collateral damage. - -**Code:** `lib/clean/apps.sh:uninstall_app()` - -#### System Protection Policies - -| Protected Category | Scope | Reason | -|--------------------|-------|--------| -| System Integrity Protection | `/Library/Updates`, `/System/*` | Respects macOS Read-Only Volume | -| Spotlight & System UI | `~/Library/Metadata/CoreSpotlight` | Prevents UI corruption | -| System Components | Control Center, System Settings, TCC | Centralized detection via `is_critical_system_component()` | -| Time Machine | Local snapshots, backups | Runtime activity detection (backup running, snapshots mounted), fails safe if status indeterminate | -| VPN & Proxy | Shadowsocks, V2Ray, Tailscale, Clash | Protects network configs | -| AI & LLM Tools | Cursor, Claude, ChatGPT, Ollama, LM Studio | Protects models, tokens, and sessions | -| Startup Items | `com.apple.*` LaunchAgents/Daemons | System items unconditionally skipped | - -**LaunchAgent/LaunchDaemon Cleanup During Uninstallation:** - -When users uninstall applications via `mo uninstall`, Mole automatically removes associated LaunchAgent and LaunchDaemon plists: - -- Scans `~/Library/LaunchAgents`, `~/Library/LaunchDaemons`, `/Library/LaunchAgents`, `/Library/LaunchDaemons` -- Matches both exact bundle ID (`com.example.app.plist`) and app name patterns (`*AppName*.plist`) -- Skips all `com.apple.*` system items via `should_protect_path()` validation -- Unloads services via `launchctl` before deletion (via `stop_launch_services()`) -- **Safer than orphan detection:** Only removes plists when the associated app is explicitly being uninstalled -- Prevents accumulation of orphaned startup items that persist after app removal - -**Code:** `lib/core/app_protection.sh:find_app_files()`, `lib/uninstall/batch.sh:stop_launch_services()` - -### Crash Safety & Atomic Operations - -| Operation | Safety Mechanism | Recovery Behavior | -|-----------|------------------|-------------------| -| Network Interface Reset | Atomic execution blocks | Wi-Fi/AirDrop restored to pre-operation state | -| Swap Clearing | Daemon restart | `dynamic_pager` handles recovery safely | -| Volume Scanning | Timeout + filesystem check | Auto-skip unresponsive NFS/SMB/AFP mounts | -| Homebrew Cache | Pre-flight size check | Skip if <50MB (avoids long delays) | -| Network Volume Check | `diskutil info` with timeout | Prevents hangs on slow/dead mounts | -| SQLite Vacuum | App-running check + 20s timeout | Skips if Mail/Safari/Messages active | -| dyld Cache Update | 24-hour freshness check + 180s timeout | Skips if recently updated | -| App Bundle Search | 10s timeout on mdfind | Fallback to standard paths | - -**Timeout Example:** +Put paths in `~/.config/mole/whitelist`, one per line: ```bash -run_with_timeout 5 diskutil info "$mount_point" || skip_volume +# exact matches only +/Users/me/important-cache +~/Library/Application Support/MyApp ``` -**Code:** `lib/core/base.sh:run_with_timeout()`, `lib/optimize/*.sh` +These paths are protected from all operations. ---- +Run `mo clean --dry-run` or `mo optimize --dry-run` to preview what would happen without actually doing it. -## User Controls +## Testing -### Dry-Run Mode +| Area | Coverage | +| ---- | -------- | +| File ops | 95% | +| Cleaning | 87% | +| Optimize | 82% | +| System | 90% | +| Security | 100% | -**Command:** `mo clean --dry-run` | `mo optimize --dry-run` - -**Behavior:** - -- Simulates the entire operation without modifying a single file. -- Lists every file/directory that **would** be deleted. -- Calculates total space that **would** be freed. -- **Zero risk** - no actual deletion commands are executed. - -### Custom Whitelists - -**File:** `~/.config/mole/whitelist` - -**Format:** +180+ test cases total, about 88% coverage. ```bash -# One path per line - exact matches only -/Users/username/important-cache -~/Library/Application Support/CriticalApp +bats tests/ # run all +bats tests/security.bats # security only ``` -- Paths are **unconditionally protected**. -- Applies to all operations (clean, optimize, uninstall). -- Supports absolute paths and `~` expansion. - -**Code:** `lib/core/file_ops.sh:is_whitelisted()` - -### Interactive Confirmations - -We mandate confirmation for: - -- Uninstalling system-scope applications. -- Removing large data directories (>1GB). -- Deleting items from shared vendor folders. - ---- - -## Testing & Compliance - -### Test Coverage - -Mole uses **BATS (Bash Automated Testing System)** for automated testing. - -| Test Category | Coverage | Key Tests | -|---------------|----------|-----------| -| Core File Operations | 95% | Path validation, symlink detection, permissions | -| Cleaning Logic | 87% | Orphan detection, 60-day rule, vendor whitelist | -| Optimization | 82% | Cache cleanup, timeouts | -| System Maintenance | 90% | Time Machine, network volumes, crash recovery | -| Security Controls | 100% | Path traversal, command injection, symlinks | - -**Total:** 180+ tests | **Overall Coverage:** ~88% - -**Test Execution:** - -```bash -bats tests/ # Run all tests -bats tests/security.bats # Run specific suite -``` - -### Standards Compliance - -| Standard | Implementation | -|----------|----------------| -| OWASP Secure Coding | Input validation, least privilege, defense-in-depth | -| CWE-22 (Path Traversal) | Enhanced detection: rejects `/../` components, safely handles `..` in directory names | -| CWE-78 (Command Injection) | Control character filtering | -| CWE-59 (Link Following) | Symlink detection before privileged operations | -| Apple File System Guidelines | Respects SIP, Read-Only Volumes, TCC | - -### Security Development Lifecycle - -- **Static Analysis:** `shellcheck` runs on all shell scripts. -- **Code Review:** All changes are manually reviewed by maintainers. -- **Dependency Scanning:** Minimal external dependencies, all carefully vetted. - -### Known Limitations - -| Limitation | Impact | Mitigation | -|------------|--------|------------| -| Requires `sudo` for system caches | Initial friction | Clear documentation explaining why | -| 60-day rule may delay cleanup | Some orphans remain longer | Manual `mo uninstall` is always available | -| No undo functionality | Deleted files are unrecoverable | Dry-run mode and warnings are clear | -| English-only name matching | May miss non-English apps | Fallback to Bundle ID matching | - -**Intentionally Out of Scope (Safety):** - -- Automatic deletion of user documents/media. -- Encryption key stores or password managers. -- System configuration files (`/etc/*`). -- Browser history or cookies. -- Git repository cleanup. - ---- +CI runs shellcheck and go vet on every push. ## Dependencies -### System Binaries +System binaries used, all SIP protected: -Mole relies on standard, SIP-protected macOS system binaries: +| Binary | For | +| ------ | --- | +| `plutil` | plist validation | +| `tmutil` | Time Machine | +| `dscacheutil` | cache rebuild | +| `diskutil` | volume info | -| Binary | Purpose | Fallback | -|--------|---------|----------| -| `plutil` | Validate `.plist` integrity | Skip invalid plists | -| `tmutil` | Time Machine interaction | Skip TM cleanup | -| `dscacheutil` | System cache rebuilding | Optional optimization | -| `diskutil` | Volume information | Skip network volumes | +Go libs in analyze-go: -### Go Dependencies (Interactive Tools) +| Lib | Version | License | +| --- | ------- | ------- | +| `bubbletea` | v0.23+ | MIT | +| `lipgloss` | v0.6+ | MIT | +| `gopsutil` | v3.22+ | BSD-3 | +| `xxhash` | v2.2+ | BSD-2 | -The compiled Go binary (`analyze-go`) includes: +Versions are pinned. No CVEs. Binaries built via GitHub Actions. -| Library | Version | Purpose | License | -|---------|---------|---------|---------| -| `bubbletea` | v0.23+ | TUI framework | MIT | -| `lipgloss` | v0.6+ | Terminal styling | MIT | -| `gopsutil` | v3.22+ | System metrics | BSD-3 | -| `xxhash` | v2.2+ | Fast hashing | BSD-2 | +## Limitations -**Supply Chain Security:** +| What | Impact | Workaround | +| ---- | ------ | ---------- | +| Needs sudo for system caches | Annoying first time | Docs explain why | +| 60-day wait for orphans | Some junk stays longer | Use `mo uninstall` manually | +| No undo | Gone is gone | Use dry-run first | +| English names only | Might miss localized apps | Falls back to bundle ID | -- All dependencies are pinned to specific versions. -- Regular security audits. -- No transitive dependencies with known CVEs. -- **Automated Releases**: Binaries are compiled and signed via GitHub Actions. -- **Source Only**: The repository contains no pre-compiled binaries. +**Won't touch:** ---- - -**Our Commitment:** This document certifies that Mole implements industry-standard defensive programming practices and strictly adheres to macOS security guidelines. We prioritize system stability and data integrity above all else. - -*For security concerns or vulnerability reports, please open an issue or contact the maintainers directly.* +- Your documents or media +- Password managers or keychains +- Files under `/etc` +- Browser history/cookies +- Git repos diff --git a/bin/clean.sh b/bin/clean.sh index 2de8f28..8f700c1 100755 --- a/bin/clean.sh +++ b/bin/clean.sh @@ -164,10 +164,6 @@ start_section() { echo "" echo -e "${PURPLE_BOLD}${ICON_ARROW} $1${NC}" - if [[ -t 1 ]]; then - MOLE_SPINNER_PREFIX=" " start_inline_spinner "Preparing..." - fi - if [[ "$DRY_RUN" == "true" ]]; then ensure_user_file "$EXPORT_LIST_FILE" echo "" >> "$EXPORT_LIST_FILE" @@ -308,9 +304,6 @@ safe_clean() { return 0 fi - # Always stop spinner before outputting results - stop_section_spinner - local description local -a targets @@ -361,6 +354,7 @@ safe_clean() { local show_scan_feedback=false if [[ ${#targets[@]} -gt 20 && -t 1 ]]; then show_scan_feedback=true + stop_section_spinner MOLE_SPINNER_PREFIX=" " start_inline_spinner "Scanning ${#targets[@]} items..." fi @@ -371,6 +365,7 @@ safe_clean() { if should_protect_path "$path"; then skip=true ((skipped_count++)) + log_operation "clean" "SKIPPED" "$path" "protected" fi [[ "$skip" == "true" ]] && continue @@ -378,6 +373,7 @@ safe_clean() { if is_path_whitelisted "$path"; then skip=true ((skipped_count++)) + log_operation "clean" "SKIPPED" "$path" "whitelist" fi [[ "$skip" == "true" ]] && continue [[ -e "$path" ]] && existing_paths+=("$path") @@ -387,7 +383,7 @@ safe_clean() { stop_section_spinner fi - debug_log "Cleaning: $description (${#existing_paths[@]} items)" + debug_log "Cleaning: $description, ${#existing_paths[@]} items" # Enhanced debug output with risk level and details if [[ "${MO_DEBUG:-}" == "1" && ${#existing_paths[@]} -gt 0 ]]; then @@ -437,6 +433,8 @@ safe_clean() { if [[ -t 1 ]]; then MOLE_SPINNER_PREFIX=" " start_inline_spinner "Scanning items..."; fi fi + local cleaning_spinner_started=false + # For larger batches, precompute sizes in parallel for better UX/stat accuracy. if [[ ${#existing_paths[@]} -gt 3 ]]; then local temp_dir @@ -528,6 +526,11 @@ safe_clean() { fi # Read results back in original order. + # Start spinner for cleaning phase + if [[ "$DRY_RUN" != "true" && ${#existing_paths[@]} -gt 0 && -t 1 ]]; then + MOLE_SPINNER_PREFIX=" " start_inline_spinner "Cleaning..." + cleaning_spinner_started=true + fi idx=0 if [[ ${#existing_paths[@]} -gt 0 ]]; then for path in "${existing_paths[@]}"; do @@ -536,12 +539,8 @@ safe_clean() { read -r size count < "$result_file" 2> /dev/null || true local removed=0 if [[ "$DRY_RUN" != "true" ]]; then - if [[ -L "$path" ]]; then - rm "$path" 2> /dev/null && removed=1 - else - if safe_remove "$path" true; then - removed=1 - fi + if safe_remove "$path" true; then + removed=1 fi else removed=1 @@ -564,6 +563,11 @@ safe_clean() { fi else + # Start spinner for cleaning phase (small batch) + if [[ "$DRY_RUN" != "true" && ${#existing_paths[@]} -gt 0 && -t 1 ]]; then + MOLE_SPINNER_PREFIX=" " start_inline_spinner "Cleaning..." + cleaning_spinner_started=true + fi local idx=0 if [[ ${#existing_paths[@]} -gt 0 ]]; then for path in "${existing_paths[@]}"; do @@ -573,12 +577,8 @@ safe_clean() { local removed=0 if [[ "$DRY_RUN" != "true" ]]; then - if [[ -L "$path" ]]; then - rm "$path" 2> /dev/null && removed=1 - else - if safe_remove "$path" true; then - removed=1 - fi + if safe_remove "$path" true; then + removed=1 fi else removed=1 @@ -600,7 +600,7 @@ safe_clean() { fi fi - if [[ "$show_spinner" == "true" ]]; then + if [[ "$show_spinner" == "true" || "$cleaning_spinner_started" == "true" ]]; then stop_section_spinner fi @@ -610,10 +610,13 @@ safe_clean() { debug_log "Permission denied while cleaning: $description" fi if [[ $removal_failed_count -gt 0 && "$DRY_RUN" != "true" ]]; then - debug_log "Skipped $removal_failed_count items (permission denied or in use) for: $description" + debug_log "Skipped $removal_failed_count items, permission denied or in use, for: $description" fi if [[ $removed_any -eq 1 ]]; then + # Stop spinner before output + stop_section_spinner + local size_human=$(bytes_to_human "$((total_size_kb * 1024))") local label="$description" @@ -622,7 +625,7 @@ safe_clean() { fi if [[ "$DRY_RUN" == "true" ]]; then - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} $label ${YELLOW}($size_human dry)${NC}" + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} $label${NC}, ${YELLOW}$size_human dry${NC}" local paths_temp=$(create_temp_file) @@ -673,7 +676,7 @@ safe_clean() { ' | while IFS='|' read -r display_path total_size child_count; do local size_human=$(bytes_to_human "$((total_size * 1024))") if [[ $child_count -gt 1 ]]; then - echo "$display_path # $size_human ($child_count items)" >> "$EXPORT_LIST_FILE" + echo "$display_path # $size_human, $child_count items" >> "$EXPORT_LIST_FILE" else echo "$display_path # $size_human" >> "$EXPORT_LIST_FILE" fi @@ -682,7 +685,7 @@ safe_clean() { rm -f "$paths_temp" fi else - echo -e " ${GREEN}${ICON_SUCCESS}${NC} $label ${GREEN}($size_human)${NC}" + echo -e " ${GREEN}${ICON_SUCCESS}${NC} $label${NC}, ${GREEN}$size_human${NC}" fi ((files_cleaned += total_count)) ((total_size_cleaned += total_size_kb)) @@ -694,6 +697,10 @@ safe_clean() { } start_cleanup() { + # Set current command for operation logging + export MOLE_CURRENT_COMMAND="clean" + log_operation_session_start "clean" + if [[ -t 1 ]]; then printf '\033[2J\033[H' fi @@ -702,11 +709,11 @@ start_cleanup() { echo "" if [[ "$DRY_RUN" != "true" && -t 0 ]]; then - echo -e "${GRAY}${ICON_SOLID} Use --dry-run to preview, --whitelist to manage protected paths${NC}" + echo -e "${GRAY}${ICON_WARNING} Use --dry-run to preview, --whitelist to manage protected paths${NC}" fi if [[ "$DRY_RUN" == "true" ]]; then - echo -e "${YELLOW}Dry Run Mode${NC} - Preview only, no deletions" + echo -e "${YELLOW}Dry Run Mode${NC}, Preview only, no deletions" echo "" SYSTEM_CLEAN=false @@ -727,42 +734,53 @@ EOF fi if [[ -t 0 ]]; then - echo -ne "${PURPLE}${ICON_ARROW}${NC} System caches need sudo — ${GREEN}Enter${NC} continue, ${GRAY}Space${NC} skip: " - - local choice - choice=$(read_key) - - # ESC/Q aborts, Space skips, Enter enables system cleanup. - if [[ "$choice" == "QUIT" ]]; then - echo -e " ${GRAY}Canceled${NC}" - exit 0 - fi - - if [[ "$choice" == "SPACE" ]]; then - echo -e " ${GRAY}Skipped${NC}" + if sudo -n true 2> /dev/null; then + SYSTEM_CLEAN=true + echo -e "${GREEN}${ICON_SUCCESS}${NC} Admin access already available" echo "" - SYSTEM_CLEAN=false - elif [[ "$choice" == "ENTER" ]]; then - printf "\r\033[K" # Clear the prompt line - if ensure_sudo_session "System cleanup requires admin access"; then - SYSTEM_CLEAN=true - echo -e "${GREEN}${ICON_SUCCESS}${NC} Admin access granted" + else + echo -ne "${PURPLE}${ICON_ARROW}${NC} System caches need sudo. ${GREEN}Enter${NC} continue, ${GRAY}Space${NC} skip: " + + local choice + choice=$(read_key) + + # ESC/Q aborts, Space skips, Enter enables system cleanup. + if [[ "$choice" == "QUIT" ]]; then + echo -e " ${GRAY}Canceled${NC}" + exit 0 + fi + + if [[ "$choice" == "SPACE" ]]; then + echo -e " ${GRAY}Skipped${NC}" echo "" + SYSTEM_CLEAN=false + elif [[ "$choice" == "ENTER" ]]; then + printf "\r\033[K" # Clear the prompt line + if ensure_sudo_session "System cleanup requires admin access"; then + SYSTEM_CLEAN=true + echo -e "${GREEN}${ICON_SUCCESS}${NC} Admin access granted" + echo "" + else + SYSTEM_CLEAN=false + echo "" + echo -e "${YELLOW}Authentication failed${NC}, continuing with user-level cleanup" + fi else SYSTEM_CLEAN=false + echo -e " ${GRAY}Skipped${NC}" echo "" - echo -e "${YELLOW}Authentication failed${NC}, continuing with user-level cleanup" fi - else - SYSTEM_CLEAN=false - echo -e " ${GRAY}Skipped${NC}" - echo "" fi else - SYSTEM_CLEAN=false echo "" echo "Running in non-interactive mode" - echo " ${ICON_LIST} System-level cleanup skipped (requires interaction)" + if sudo -n true 2> /dev/null; then + SYSTEM_CLEAN=true + echo " ${ICON_LIST} System-level cleanup enabled, sudo session active" + else + SYSTEM_CLEAN=false + echo " ${ICON_LIST} System-level cleanup skipped, requires sudo" + fi echo " ${ICON_LIST} User-level cleanup will proceed automatically" echo "" fi @@ -774,7 +792,7 @@ perform_cleanup() { if [[ "${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" + echo -e "${YELLOW}Dry Run Mode${NC}, Preview only, no deletions" echo "" fi echo -e "${GREEN}${ICON_LIST}${NC} User app cache" @@ -867,7 +885,7 @@ perform_cleanup() { fda_status=$? if [[ $fda_status -eq 1 ]]; then echo "" - echo -e "${YELLOW}${ICON_WARNING}${NC} ${GRAY}Tip: Grant Full Disk Access to your terminal in System Settings for best results${NC}" + echo -e "${GRAY}${ICON_WARNING}${NC} ${GRAY}Tip: Grant Full Disk Access to your terminal in System Settings for best results${NC}" fi fi @@ -892,7 +910,7 @@ perform_cleanup() { if [[ ${#WHITELIST_WARNINGS[@]} -gt 0 ]]; then echo "" for warning in "${WHITELIST_WARNINGS[@]}"; do - echo -e " ${YELLOW}${ICON_WARNING}${NC} Whitelist: $warning" + echo -e " ${GRAY}${ICON_WARNING}${NC} Whitelist: $warning" done fi @@ -955,6 +973,7 @@ perform_cleanup() { # ===== 12. Orphaned app data cleanup (60+ days inactive, skip protected vendors) ===== start_section "Uninstalled app data" clean_orphaned_app_data + clean_orphaned_system_services end_section # ===== 13. Apple Silicon optimizations ===== @@ -970,6 +989,11 @@ perform_cleanup() { clean_time_machine_failed_backups end_section + # ===== 16. Large files to review (report only) ===== + start_section "Large files to review" + check_large_file_candidates + end_section + # ===== Final summary ===== echo "" @@ -1032,7 +1056,7 @@ perform_cleanup() { else summary_status="info" if [[ "$DRY_RUN" == "true" ]]; then - summary_details+=("No significant reclaimable space detected (system already clean).") + summary_details+=("No significant reclaimable space detected, system already clean.") else summary_details+=("System was already clean; no additional space freed.") fi @@ -1043,6 +1067,9 @@ perform_cleanup() { set -e fi + # Log session end with summary + log_operation_session_end "clean" "$files_cleaned" "$total_size_cleaned" + print_summary_block "$summary_heading" "${summary_details[@]}" printf '\n' } @@ -1055,6 +1082,7 @@ main() { ;; "--dry-run" | "-n") DRY_RUN=true + export MOLE_DRY_RUN=1 ;; "--whitelist") source "$SCRIPT_DIR/../lib/manage/whitelist.sh" diff --git a/bin/completion.sh b/bin/completion.sh index b3d345c..0a187e3 100755 --- a/bin/completion.sh +++ b/bin/completion.sh @@ -84,7 +84,7 @@ if [[ $# -eq 0 ]]; then echo -e "${GREEN}${ICON_SUCCESS}${NC} Removed stale completion entries from $config_file" echo "" fi - log_error "mole not found in PATH - install Mole before enabling completion" + log_error "mole not found in PATH, install Mole before enabling completion" exit 1 fi diff --git a/bin/installer.sh b/bin/installer.sh index 8eed412..f088c19 100755 --- a/bin/installer.sh +++ b/bin/installer.sh @@ -387,7 +387,7 @@ select_installers() { scroll_indicator=" ${GRAY}[${current_pos}/${total_items}]${NC}" fi - printf "${PURPLE_BOLD}Select Installers to Remove${NC}%s ${GRAY}- ${selected_human} ($selected_count selected)${NC}\n" "$scroll_indicator" + printf "${PURPLE_BOLD}Select Installers to Remove${NC}%s ${GRAY}, ${selected_human}, ${selected_count} selected${NC}\n" "$scroll_indicator" printf "%s\n" "$clear_line" # Calculate visible range @@ -546,13 +546,13 @@ delete_selected_installers() { local file_size="${INSTALLER_SIZES[$idx]}" local size_human size_human=$(bytes_to_human "$file_size") - echo -e " ${GREEN}${ICON_SUCCESS}${NC} $(basename "$file_path") ${GRAY}(${size_human})${NC}" + echo -e " ${GREEN}${ICON_SUCCESS}${NC} $(basename "$file_path") ${GRAY}, ${size_human}${NC}" fi done # Confirm deletion echo "" - echo -ne "${PURPLE}${ICON_ARROW}${NC} Delete ${#selected_indices[@]} installer(s) (${confirm_human}) ${GREEN}Enter${NC} confirm, ${GRAY}ESC${NC} cancel: " + echo -ne "${PURPLE}${ICON_ARROW}${NC} Delete ${#selected_indices[@]} installers, ${confirm_human} ${GREEN}Enter${NC} confirm, ${GRAY}ESC${NC} cancel: " IFS= read -r -s -n1 confirm || confirm="" case "$confirm" in @@ -655,7 +655,7 @@ show_summary() { local freed_mb freed_mb=$(echo "$total_size_freed_kb" | awk '{printf "%.2f", $1/1024}') - summary_details+=("Removed ${GREEN}$total_deleted${NC} installer(s), freed ${GREEN}${freed_mb}MB${NC}") + summary_details+=("Removed ${GREEN}$total_deleted${NC} installers, freed ${GREEN}${freed_mb}MB${NC}") summary_details+=("Your Mac is cleaner now!") else summary_details+=("No installers were removed") diff --git a/bin/optimize.sh b/bin/optimize.sh index 07e7d81..6dbaaec 100755 --- a/bin/optimize.sh +++ b/bin/optimize.sh @@ -78,7 +78,7 @@ show_optimization_summary() { local total_applied=$((safe_count + confirm_count)) if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then - summary_title="Dry Run Complete - No Changes Made" + summary_title="Dry Run Complete, No Changes Made" summary_details+=("Would apply ${YELLOW}${total_applied:-0}${NC} optimizations") summary_details+=("Run without ${YELLOW}--dry-run${NC} to apply these changes") else @@ -115,9 +115,9 @@ show_optimization_summary() { fi if [[ -n "$key_stat" ]]; then - summary_details+=("Applied ${GREEN}${total_applied:-0}${NC} optimizations — ${key_stat}") + summary_details+=("Applied ${GREEN}${total_applied:-0}${NC} optimizations, ${key_stat}") else - summary_details+=("Applied ${GREEN}${total_applied:-0}${NC} optimizations — all services tuned") + summary_details+=("Applied ${GREEN}${total_applied:-0}${NC} optimizations, all services tuned") fi local summary_line3="" @@ -126,11 +126,11 @@ show_optimization_summary() { if [[ -n "${AUTO_FIX_DETAILS:-}" ]]; then local detail_join detail_join=$(echo "${AUTO_FIX_DETAILS}" | paste -sd ", " -) - [[ -n "$detail_join" ]] && summary_line3+=" — ${detail_join}" + [[ -n "$detail_join" ]] && summary_line3+=": ${detail_join}" fi summary_details+=("$summary_line3") fi - summary_details+=("System fully optimized — faster, more secure and responsive") + summary_details+=("System fully optimized") fi print_summary_block "$summary_title" "${summary_details[@]}" @@ -204,7 +204,7 @@ cleanup_path() { return fi if should_protect_path "$expanded_path"; then - echo -e "${YELLOW}${ICON_WARNING}${NC} Protected $label" + echo -e "${GRAY}${ICON_WARNING}${NC} Protected $label" return fi @@ -226,12 +226,12 @@ cleanup_path() { if [[ "$removed" == "true" ]]; then if [[ -n "$size_display" ]]; then - echo -e "${GREEN}${ICON_SUCCESS}${NC} $label ${GREEN}(${size_display})${NC}" + echo -e "${GREEN}${ICON_SUCCESS}${NC} $label${NC}, ${GREEN}${size_display}${NC}" else echo -e "${GREEN}${ICON_SUCCESS}${NC} $label" fi else - echo -e "${YELLOW}${ICON_WARNING}${NC} Skipped $label ${GRAY}(grant Full Disk Access to your terminal and retry)${NC}" + echo -e "${GRAY}${ICON_WARNING}${NC} Skipped $label${GRAY}, grant Full Disk Access to your terminal and retry${NC}" fi } @@ -252,7 +252,7 @@ collect_security_fix_actions() { fi if [[ "${GATEKEEPER_DISABLED:-}" == "true" ]]; then if ! is_whitelisted "gatekeeper"; then - SECURITY_FIXES+=("gatekeeper|Enable Gatekeeper (App download protection)") + SECURITY_FIXES+=("gatekeeper|Enable Gatekeeper, app download protection") fi fi if touchid_supported && ! touchid_configured; then @@ -304,7 +304,7 @@ apply_firewall_fix() { FIREWALL_DISABLED=false return 0 fi - echo -e " ${YELLOW}${ICON_WARNING}${NC} Failed to enable firewall (check permissions)" + echo -e " ${GRAY}${ICON_WARNING}${NC} Failed to enable firewall, check permissions" return 1 } @@ -314,7 +314,7 @@ apply_gatekeeper_fix() { GATEKEEPER_DISABLED=false return 0 fi - echo -e " ${YELLOW}${ICON_WARNING}${NC} Failed to enable Gatekeeper" + echo -e " ${GRAY}${ICON_WARNING}${NC} Failed to enable Gatekeeper" return 1 } @@ -327,7 +327,7 @@ apply_touchid_fix() { perform_security_fixes() { if ! ensure_sudo_session "Security changes require admin access"; then - echo -e "${YELLOW}${ICON_WARNING}${NC} Skipped security fixes (sudo denied)" + echo -e "${GRAY}${ICON_WARNING}${NC} Skipped security fixes, sudo denied" return 1 fi @@ -357,6 +357,8 @@ cleanup_all() { stop_inline_spinner 2> /dev/null || true stop_sudo_session cleanup_temp_files + # Log session end + log_operation_session_end "optimize" "${OPTIMIZE_SAFE_COUNT:-0}" "0" } handle_interrupt() { @@ -365,6 +367,9 @@ handle_interrupt() { } main() { + # Set current command for operation logging + export MOLE_CURRENT_COMMAND="optimize" + local health_json for arg in "$@"; do case "$arg" in @@ -381,6 +386,8 @@ main() { esac done + log_operation_session_start "optimize" + trap cleanup_all EXIT trap handle_interrupt INT TERM @@ -391,7 +398,7 @@ main() { # Dry-run indicator. if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then - echo -e "${YELLOW}${ICON_DRY_RUN} DRY RUN MODE${NC} - No files will be modified\n" + 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 diff --git a/bin/purge.sh b/bin/purge.sh index b169a77..ce67c57 100755 --- a/bin/purge.sh +++ b/bin/purge.sh @@ -42,6 +42,10 @@ note_activity() { # Main purge function start_purge() { + # Set current command for operation logging + export MOLE_CURRENT_COMMAND="purge" + log_operation_session_start "purge" + # Clear screen for better UX if [[ -t 1 ]]; then printf '\033[2J\033[H' @@ -214,13 +218,16 @@ perform_purge() { summary_details+=("Free space now: $(get_free_space)") fi + # Log session end + log_operation_session_end "purge" "${total_items_cleaned:-0}" "${total_size_cleaned:-0}" + print_summary_block "$summary_heading" "${summary_details[@]}" printf '\n' } # Show help message show_help() { - echo -e "${PURPLE_BOLD}Mole Purge${NC} - Clean old project build artifacts" + echo -e "${PURPLE_BOLD}Mole Purge${NC}, Clean old project build artifacts" echo "" echo -e "${YELLOW}Usage:${NC} mo purge [options]" echo "" @@ -231,7 +238,7 @@ show_help() { echo "" echo -e "${YELLOW}Default Paths:${NC}" for path in "${DEFAULT_PURGE_SEARCH_PATHS[@]}"; do - echo " - $path" + echo " * $path" done } diff --git a/bin/touchid.sh b/bin/touchid.sh index 4dce339..9f84dd0 100755 --- a/bin/touchid.sh +++ b/bin/touchid.sh @@ -141,7 +141,7 @@ enable_touchid() { sudo mv "$temp_file" "$PAM_SUDO_FILE" log_success "Touch ID migrated to sudo_local" else - log_success "Touch ID enabled (via sudo_local) - try: sudo ls" + log_success "Touch ID enabled, via sudo_local, try: sudo ls" fi return 0 else @@ -188,7 +188,7 @@ enable_touchid() { # Apply the changes if sudo mv "$temp_file" "$PAM_SUDO_FILE" 2> /dev/null; then - log_success "Touch ID enabled - try: sudo ls" + log_success "Touch ID enabled, try: sudo ls" return 0 else log_error "Failed to enable Touch ID" @@ -219,7 +219,7 @@ disable_touchid() { grep -v "pam_tid.so" "$PAM_SUDO_FILE" > "$temp_file" sudo mv "$temp_file" "$PAM_SUDO_FILE" fi - echo -e "${GREEN}${ICON_SUCCESS} Touch ID disabled (removed from sudo_local)${NC}" + echo -e "${GREEN}${ICON_SUCCESS} Touch ID disabled, removed from sudo_local${NC}" echo "" return 0 else diff --git a/bin/uninstall.sh b/bin/uninstall.sh index 389e222..cc415fc 100755 --- a/bin/uninstall.sh +++ b/bin/uninstall.sh @@ -137,6 +137,12 @@ scan_applications() { done < <(command find "$app_dir" -name "*.app" -maxdepth 3 -print0 2> /dev/null) done + if [[ ${#app_data_tuples[@]} -eq 0 ]]; then + rm -f "$temp_file" + printf "\r\033[K" >&2 + echo "No applications found to uninstall." >&2 + return 1 + fi # Pass 2: metadata + size in parallel (mdls is slow). local app_count=0 local total_apps=${#app_data_tuples[@]} @@ -368,6 +374,8 @@ cleanup() { wait "$sudo_keepalive_pid" 2> /dev/null || true sudo_keepalive_pid="" fi + # Log session end + log_operation_session_end "uninstall" "${files_cleaned:-0}" "${total_size_cleaned:-0}" show_cursor exit "${1:-0}" } @@ -375,6 +383,10 @@ cleanup() { trap cleanup EXIT INT TERM main() { + # Set current command for operation logging + export MOLE_CURRENT_COMMAND="uninstall" + log_operation_session_start "uninstall" + local force_rescan=false # Global flags for arg in "$@"; do @@ -490,7 +502,7 @@ main() { rm -f "$apps_file" continue fi - echo -e "${BLUE}${ICON_CONFIRM}${NC} Selected ${selection_count} app(s):" + echo -e "${BLUE}${ICON_CONFIRM}${NC} Selected ${selection_count} apps:" local -a summary_rows=() local max_name_display_width=0 local max_size_width=0 diff --git a/bin/uninstall_lib.sh b/bin/uninstall_lib.sh index 75e0ad0..26a94cc 100755 --- a/bin/uninstall_lib.sh +++ b/bin/uninstall_lib.sh @@ -565,7 +565,7 @@ main() { continue fi # Show selected apps with clean alignment - echo -e "${BLUE}${ICON_CONFIRM}${NC} Selected ${selection_count} app(s):" + echo -e "${BLUE}${ICON_CONFIRM}${NC} Selected ${selection_count} apps:" local -a summary_rows=() local max_name_width=0 local max_size_width=0 diff --git a/cmd/analyze/constants.go b/cmd/analyze/constants.go index 7106087..fd6591b 100644 --- a/cmd/analyze/constants.go +++ b/cmd/analyze/constants.go @@ -3,18 +3,19 @@ package main import "time" const ( - maxEntries = 30 - maxLargeFiles = 30 - barWidth = 24 - minLargeFileSize = 100 << 20 - defaultViewport = 12 - overviewCacheTTL = 7 * 24 * time.Hour - overviewCacheFile = "overview_sizes.json" - duTimeout = 30 * time.Second - mdlsTimeout = 5 * time.Second - maxConcurrentOverview = 8 - batchUpdateSize = 100 - cacheModTimeGrace = 30 * time.Minute + maxEntries = 30 + maxLargeFiles = 20 + barWidth = 24 + spotlightMinFileSize = 100 << 20 + largeFileWarmupMinSize = 1 << 20 + defaultViewport = 12 + overviewCacheTTL = 7 * 24 * time.Hour + overviewCacheFile = "overview_sizes.json" + duTimeout = 30 * time.Second + mdlsTimeout = 5 * time.Second + maxConcurrentOverview = 8 + batchUpdateSize = 100 + cacheModTimeGrace = 30 * time.Minute // Worker pool limits. minWorkers = 16 diff --git a/cmd/analyze/format_test.go b/cmd/analyze/format_test.go index f328212..7cfdf57 100644 --- a/cmd/analyze/format_test.go +++ b/cmd/analyze/format_test.go @@ -3,6 +3,7 @@ package main import ( "strings" "testing" + "time" ) func TestRuneWidth(t *testing.T) { @@ -307,3 +308,42 @@ func TestCalculateNameWidth(t *testing.T) { } } } + +func TestFormatUnusedTime(t *testing.T) { + now := time.Now().UTC() + tests := []struct { + name string + daysAgo int + want string + }{ + {"zero time", -1, ""}, // Special case: will use time.Time{} + {"recent file", 30, ""}, // < 90 days returns empty + {"just under threshold", 89, ""}, // Boundary: 89 days still empty + {"at 90 days", 90, ">3mo"}, // Boundary: exactly 90 days + {"4 months", 120, ">4mo"}, + {"6 months", 180, ">6mo"}, + {"11 months", 330, ">11mo"}, + {"just under 1 year", 364, ">12mo"}, + {"exactly 1 year", 365, ">1yr"}, + {"18 months", 548, ">1yr"}, // Between 1 and 2 years + {"just under 2 years", 729, ">1yr"}, + {"exactly 2 years", 730, ">2yr"}, + {"3 years", 1095, ">3yr"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var lastAccess time.Time + if tt.daysAgo >= 0 { + // Use a fixed UTC baseline to avoid DST-related flakiness. + lastAccess = now.Add(-time.Duration(tt.daysAgo) * 24 * time.Hour) + } + // If daysAgo < 0, lastAccess remains zero value + + got := formatUnusedTime(lastAccess) + if got != tt.want { + t.Errorf("formatUnusedTime(%d days ago) = %q, want %q", tt.daysAgo, got, tt.want) + } + }) + } +} diff --git a/cmd/analyze/main.go b/cmd/analyze/main.go index 59fe79a..e06bc41 100644 --- a/cmd/analyze/main.go +++ b/cmd/analyze/main.go @@ -332,9 +332,9 @@ func (m *model) scheduleOverviewScans() tea.Cmd { if len(pendingIndices) > 0 { firstEntry := m.entries[pendingIndices[0]] if len(pendingIndices) == 1 { - m.status = fmt.Sprintf("Scanning %s... (%d left)", firstEntry.Name, remaining) + m.status = fmt.Sprintf("Scanning %s..., %d left", firstEntry.Name, remaining) } else { - m.status = fmt.Sprintf("Scanning %d directories... (%d left)", len(pendingIndices), remaining) + m.status = fmt.Sprintf("Scanning %d directories..., %d left", len(pendingIndices), remaining) } } @@ -584,7 +584,7 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } switch msg.String() { - case "q", "ctrl+c": + case "q", "ctrl+c", "Q": return m, tea.Quit case "esc": if m.showLargeFiles { @@ -592,7 +592,7 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } return m, tea.Quit - case "up", "k": + case "up", "k", "K": if m.showLargeFiles { if m.largeSelected > 0 { m.largeSelected-- @@ -606,7 +606,7 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.offset = m.selected } } - case "down", "j": + case "down", "j", "J": if m.showLargeFiles { if m.largeSelected < len(m.largeFiles)-1 { m.largeSelected++ @@ -622,12 +622,12 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.offset = m.selected - viewport + 1 } } - case "enter", "right", "l": + case "enter", "right", "l", "L": if m.showLargeFiles { return m, nil } return m.enterSelectedDir() - case "b", "left", "h": + case "b", "left", "h", "B", "H": if m.showLargeFiles { m.showLargeFiles = false return m, nil @@ -679,7 +679,7 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) m.scanning = false return m, nil - case "r": + case "r", "R": m.multiSelected = make(map[string]bool) m.largeMultiSelected = make(map[string]bool) @@ -728,7 +728,7 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) } - case "o": + case "o", "O": // Open selected entries (multi-select aware). const maxBatchOpen = 20 if m.showLargeFiles { @@ -736,7 +736,7 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if len(m.largeMultiSelected) > 0 { count := len(m.largeMultiSelected) if count > maxBatchOpen { - m.status = fmt.Sprintf("Too many items to open (max %d, selected %d)", maxBatchOpen, count) + m.status = fmt.Sprintf("Too many items to open, max %d, selected %d", maxBatchOpen, count) return m, nil } for path := range m.largeMultiSelected { @@ -761,7 +761,7 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if len(m.multiSelected) > 0 { count := len(m.multiSelected) if count > maxBatchOpen { - m.status = fmt.Sprintf("Too many items to open (max %d, selected %d)", maxBatchOpen, count) + m.status = fmt.Sprintf("Too many items to open, max %d, selected %d", maxBatchOpen, count) return m, nil } for path := range m.multiSelected { @@ -790,7 +790,7 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if len(m.largeMultiSelected) > 0 { count := len(m.largeMultiSelected) if count > maxBatchReveal { - m.status = fmt.Sprintf("Too many items to reveal (max %d, selected %d)", maxBatchReveal, count) + m.status = fmt.Sprintf("Too many items to reveal, max %d, selected %d", maxBatchReveal, count) return m, nil } for path := range m.largeMultiSelected { @@ -815,7 +815,7 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if len(m.multiSelected) > 0 { count := len(m.multiSelected) if count > maxBatchReveal { - m.status = fmt.Sprintf("Too many items to reveal (max %d, selected %d)", maxBatchReveal, count) + m.status = fmt.Sprintf("Too many items to reveal, max %d, selected %d", maxBatchReveal, count) return m, nil } for path := range m.multiSelected { @@ -860,7 +860,7 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } } } - m.status = fmt.Sprintf("%d selected (%s)", count, humanizeBytes(totalSize)) + m.status = fmt.Sprintf("%d selected, %s", count, humanizeBytes(totalSize)) } else { m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) } @@ -886,7 +886,7 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } } } - m.status = fmt.Sprintf("%d selected (%s)", count, humanizeBytes(totalSize)) + m.status = fmt.Sprintf("%d selected, %s", count, humanizeBytes(totalSize)) } else { m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) } @@ -971,7 +971,9 @@ func (m model) enterSelectedDir() (tea.Model, tea.Cmd) { } selected := m.entries[m.selected] if selected.IsDir { - m.history = append(m.history, snapshotFromModel(m)) + if len(m.history) == 0 || m.history[len(m.history)-1].Path != m.path { + m.history = append(m.history, snapshotFromModel(m)) + } m.path = selected.Path m.selected = 0 m.offset = 0 @@ -1009,7 +1011,7 @@ func (m model) enterSelectedDir() (tea.Model, tea.Cmd) { } return m, tea.Batch(m.scanCmd(m.path), tickCmd()) } - m.status = fmt.Sprintf("File: %s (%s)", selected.Name, humanizeBytes(selected.Size)) + m.status = fmt.Sprintf("File: %s, %s", selected.Name, humanizeBytes(selected.Size)) return m, nil } diff --git a/cmd/analyze/scanner.go b/cmd/analyze/scanner.go index 2f0f797..5a0cde3 100644 --- a/cmd/analyze/scanner.go +++ b/cmd/analyze/scanner.go @@ -37,6 +37,7 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in largeFilesHeap := &largeFileHeap{} heap.Init(largeFilesHeap) + largeFileMinSize := int64(largeFileWarmupMinSize) // Worker pool sized for I/O-bound scanning. numWorkers := max(runtime.NumCPU()*cpuMultiplier, minWorkers) @@ -84,9 +85,13 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in for file := range largeFileChan { if largeFilesHeap.Len() < maxLargeFiles { heap.Push(largeFilesHeap, file) + if largeFilesHeap.Len() == maxLargeFiles { + atomic.StoreInt64(&largeFileMinSize, (*largeFilesHeap)[0].Size) + } } else if file.Size > (*largeFilesHeap)[0].Size { heap.Pop(largeFilesHeap) heap.Push(largeFilesHeap, file) + atomic.StoreInt64(&largeFileMinSize, (*largeFilesHeap)[0].Size) } } }() @@ -148,7 +153,7 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in } else if cached, err := loadCacheFromDisk(path); err == nil { size = cached.TotalSize } else { - size = calculateDirSizeConcurrent(path, largeFileChan, duSem, duQueueSem, filesScanned, dirsScanned, bytesScanned, currentPath) + size = calculateDirSizeConcurrent(path, largeFileChan, &largeFileMinSize, duSem, duQueueSem, filesScanned, dirsScanned, bytesScanned, currentPath) } atomic.AddInt64(&total, size) atomic.AddInt64(dirsScanned, 1) @@ -200,7 +205,7 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in defer wg.Done() defer func() { <-sem }() - size := calculateDirSizeConcurrent(path, largeFileChan, duSem, duQueueSem, filesScanned, dirsScanned, bytesScanned, currentPath) + size := calculateDirSizeConcurrent(path, largeFileChan, &largeFileMinSize, duSem, duQueueSem, filesScanned, dirsScanned, bytesScanned, currentPath) atomic.AddInt64(&total, size) atomic.AddInt64(dirsScanned, 1) @@ -233,8 +238,11 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in LastAccess: getLastAccessTimeFromInfo(info), } // Track large files only. - if !shouldSkipFileForLargeTracking(fullPath) && size >= minLargeFileSize { - largeFileChan <- fileEntry{Name: child.Name(), Path: fullPath, Size: size} + if !shouldSkipFileForLargeTracking(fullPath) { + minSize := atomic.LoadInt64(&largeFileMinSize) + if size >= minSize { + largeFileChan <- fileEntry{Name: child.Name(), Path: fullPath, Size: size} + } } } @@ -256,8 +264,8 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in largeFiles[i] = heap.Pop(largeFilesHeap).(fileEntry) } - // Use Spotlight for large files when available. - if spotlightFiles := findLargeFilesWithSpotlight(root, minLargeFileSize); len(spotlightFiles) > 0 { + // Use Spotlight for large files when it expands the list. + if spotlightFiles := findLargeFilesWithSpotlight(root, spotlightMinFileSize); len(spotlightFiles) > len(largeFiles) { largeFiles = spotlightFiles } @@ -430,7 +438,7 @@ func isInFoldedDir(path string) bool { return false } -func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, duSem, duQueueSem chan struct{}, filesScanned, dirsScanned, bytesScanned *int64, currentPath *atomic.Value) int64 { +func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, largeFileMinSize *int64, duSem, duQueueSem chan struct{}, filesScanned, dirsScanned, bytesScanned *int64, currentPath *atomic.Value) int64 { children, err := os.ReadDir(root) if err != nil { return 0 @@ -488,7 +496,7 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, duS defer wg.Done() defer func() { <-sem }() - size := calculateDirSizeConcurrent(path, largeFileChan, duSem, duQueueSem, filesScanned, dirsScanned, bytesScanned, currentPath) + size := calculateDirSizeConcurrent(path, largeFileChan, largeFileMinSize, duSem, duQueueSem, filesScanned, dirsScanned, bytesScanned, currentPath) atomic.AddInt64(&total, size) atomic.AddInt64(dirsScanned, 1) }(fullPath) @@ -505,8 +513,11 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, duS atomic.AddInt64(filesScanned, 1) atomic.AddInt64(bytesScanned, size) - if !shouldSkipFileForLargeTracking(fullPath) && size >= minLargeFileSize { - largeFileChan <- fileEntry{Name: child.Name(), Path: fullPath, Size: size} + if !shouldSkipFileForLargeTracking(fullPath) && largeFileMinSize != nil { + minSize := atomic.LoadInt64(largeFileMinSize) + if size >= minSize { + largeFileChan <- fileEntry{Name: child.Name(), Path: fullPath, Size: size} + } } // Update current path occasionally to prevent UI jitter. @@ -583,7 +594,7 @@ func getDirectorySizeFromDuWithExclude(path string, excludePath string) (int64, return 0, fmt.Errorf("du timeout after %v", duTimeout) } if stderr.Len() > 0 { - return 0, fmt.Errorf("du failed: %v (%s)", err, stderr.String()) + return 0, fmt.Errorf("du failed: %v, %s", err, stderr.String()) } return 0, fmt.Errorf("du failed: %v", err) } diff --git a/cmd/analyze/view.go b/cmd/analyze/view.go index b92678b..5f82820 100644 --- a/cmd/analyze/view.go +++ b/cmd/analyze/view.go @@ -86,7 +86,7 @@ func (m model) View() string { if m.scanning && percent >= 100 { percent = 99 } - progressPrefix = fmt.Sprintf(" %s(%.0f%%)%s", colorCyan, percent, colorReset) + progressPrefix = fmt.Sprintf(" %s%.0f%%%s", colorCyan, percent, colorReset) } fmt.Fprintf(&b, "%s%s%s%s Scanning%s: %s%s files%s, %s%s dirs%s, %s%s%s\n", @@ -112,7 +112,7 @@ func (m model) View() string { if m.showLargeFiles { if len(m.largeFiles) == 0 { - fmt.Fprintln(&b, " No large files found (>=100MB)") + fmt.Fprintln(&b, " No large files found") } else { viewport := calculateViewport(m.height, true) start := max(m.largeOffset, 0) @@ -342,7 +342,7 @@ func (m model) View() string { } 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 | F File | ⌫ Del %d | ← Back | Q 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) } @@ -351,13 +351,13 @@ func (m model) View() string { 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 | F File | ⌫ Del %d | T Top %d | Q 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 | F File | ⌫ Del %d | Q 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 | F File | ⌫ Del | T Top %d | Q 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) } @@ -390,12 +390,12 @@ func (m model) View() string { } if deleteCount > 1 { - fmt.Fprintf(&b, "%sDelete:%s %d items (%s) %sPress Enter to confirm | ESC cancel%s\n", + fmt.Fprintf(&b, "%sDelete:%s %d items, %s %sPress Enter to confirm | ESC cancel%s\n", colorRed, colorReset, deleteCount, humanizeBytes(totalDeleteSize), colorGray, colorReset) } else { - fmt.Fprintf(&b, "%sDelete:%s %s (%s) %sPress Enter to confirm | ESC cancel%s\n", + fmt.Fprintf(&b, "%sDelete:%s %s, %s %sPress Enter to confirm | ESC cancel%s\n", colorRed, colorReset, m.deleteTarget.Name, humanizeBytes(m.deleteTarget.Size), colorGray, colorReset) diff --git a/cmd/status/metrics.go b/cmd/status/metrics.go index 7617cd2..d573aa7 100644 --- a/cmd/status/metrics.go +++ b/cmd/status/metrics.go @@ -276,7 +276,8 @@ func (c *Collector) Collect() (MetricsSnapshot, error) { collect(func() (err error) { proxyStats = collectProxy(); return nil }) collect(func() (err error) { batteryStats, _ = collectBatteries(); return nil }) collect(func() (err error) { thermalStats = collectThermal(); return nil }) - collect(func() (err error) { sensorStats, _ = collectSensors(); return nil }) + // Sensors disabled - CPU temp already shown in CPU card + // collect(func() (err error) { sensorStats, _ = collectSensors(); return nil }) collect(func() (err error) { gpuStats, err = c.collectGPU(now); return }) collect(func() (err error) { // Bluetooth is slow; cache for 30s. diff --git a/cmd/status/metrics_battery.go b/cmd/status/metrics_battery.go index 4559188..c28e319 100644 --- a/cmd/status/metrics_battery.go +++ b/cmd/status/metrics_battery.go @@ -10,8 +10,6 @@ import ( "strconv" "strings" "time" - - "github.com/shirou/gopsutil/v4/sensors" ) var ( @@ -283,29 +281,3 @@ func collectThermal() ThermalStatus { return thermal } - -func collectSensors() ([]SensorReading, error) { - temps, err := sensors.SensorsTemperatures() - if err != nil { - return nil, err - } - var out []SensorReading - for _, t := range temps { - if t.Temperature <= 0 || t.Temperature > 150 { - continue - } - out = append(out, SensorReading{ - Label: prettifyLabel(t.SensorKey), - Value: t.Temperature, - Unit: "°C", - }) - } - return out, nil -} - -func prettifyLabel(key string) string { - key = strings.TrimSpace(key) - key = strings.TrimPrefix(key, "TC") - key = strings.ReplaceAll(key, "_", " ") - return key -} diff --git a/cmd/status/metrics_health.go b/cmd/status/metrics_health.go index 0e34828..4bfd090 100644 --- a/cmd/status/metrics_health.go +++ b/cmd/status/metrics_health.go @@ -69,7 +69,6 @@ func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, d issues = append(issues, "High Memory") } - // Memory pressure penalty. // Memory pressure penalty. switch mem.Pressure { case "warn": @@ -159,7 +158,8 @@ func formatUptime(secs uint64) string { hours := (secs % 86400) / 3600 mins := (secs % 3600) / 60 if days > 0 { - return fmt.Sprintf("%dd %dh %dm", days, hours, mins) + // Only show days and hours when uptime is over 1 day (skip minutes for brevity) + return fmt.Sprintf("%dd %dh", days, hours) } if hours > 0 { return fmt.Sprintf("%dh %dm", hours, mins) diff --git a/cmd/status/metrics_health_test.go b/cmd/status/metrics_health_test.go index 3bd2497..e9c9205 100644 --- a/cmd/status/metrics_health_test.go +++ b/cmd/status/metrics_health_test.go @@ -52,8 +52,8 @@ func TestFormatUptime(t *testing.T) { if got := formatUptime(3600 + 120); got != "1h 2m" { t.Fatalf("expected \"1h 2m\", got %s", got) } - if got := formatUptime(86400*2 + 3600*3 + 60*5); got != "2d 3h 5m" { - t.Fatalf("expected \"2d 3h 5m\", got %s", got) + if got := formatUptime(86400*2 + 3600*3 + 60*5); got != "2d 3h" { + t.Fatalf("expected \"2d 3h\", got %s", got) } } diff --git a/cmd/status/metrics_test.go b/cmd/status/metrics_test.go new file mode 100644 index 0000000..5515104 --- /dev/null +++ b/cmd/status/metrics_test.go @@ -0,0 +1,181 @@ +package main + +import ( + "slices" + "testing" +) + +func TestNewRingBuffer(t *testing.T) { + tests := []struct { + name string + capacity int + }{ + {"small buffer", 5}, + {"standard buffer", 120}, + {"single element", 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rb := NewRingBuffer(tt.capacity) + if rb == nil { + t.Fatal("NewRingBuffer returned nil") + } + if rb.cap != tt.capacity { + t.Errorf("NewRingBuffer(%d).cap = %d, want %d", tt.capacity, rb.cap, tt.capacity) + } + if rb.size != 0 { + t.Errorf("NewRingBuffer(%d).size = %d, want 0", tt.capacity, rb.size) + } + if rb.index != 0 { + t.Errorf("NewRingBuffer(%d).index = %d, want 0", tt.capacity, rb.index) + } + if len(rb.data) != tt.capacity { + t.Errorf("len(NewRingBuffer(%d).data) = %d, want %d", tt.capacity, len(rb.data), tt.capacity) + } + }) + } +} + +func TestRingBuffer_EmptyBuffer(t *testing.T) { + rb := NewRingBuffer(5) + got := rb.Slice() + + if got != nil { + t.Errorf("Slice() on empty buffer = %v, want nil", got) + } +} + +func TestRingBuffer_AddWithinCapacity(t *testing.T) { + rb := NewRingBuffer(5) + + // Add 3 elements (less than capacity) + rb.Add(1.0) + rb.Add(2.0) + rb.Add(3.0) + + if rb.size != 3 { + t.Errorf("size after 3 adds = %d, want 3", rb.size) + } + + got := rb.Slice() + want := []float64{1.0, 2.0, 3.0} + + if !slices.Equal(got, want) { + t.Errorf("Slice() = %v, want %v", got, want) + } +} + +func TestRingBuffer_ExactCapacity(t *testing.T) { + rb := NewRingBuffer(5) + + // Fill exactly to capacity + for i := 1; i <= 5; i++ { + rb.Add(float64(i)) + } + + if rb.size != 5 { + t.Errorf("size after filling to capacity = %d, want 5", rb.size) + } + + got := rb.Slice() + want := []float64{1.0, 2.0, 3.0, 4.0, 5.0} + + if !slices.Equal(got, want) { + t.Errorf("Slice() = %v, want %v", got, want) + } +} + +func TestRingBuffer_WrapAround(t *testing.T) { + rb := NewRingBuffer(5) + + // Add 7 elements to trigger wrap-around (2 past capacity) + // Internal state after: data=[6, 7, 3, 4, 5], index=2, size=5 + // Oldest element is at index 2 (value 3) + for i := 1; i <= 7; i++ { + rb.Add(float64(i)) + } + + if rb.size != 5 { + t.Errorf("size after wrap-around = %d, want 5", rb.size) + } + + // Verify index points to oldest element position + if rb.index != 2 { + t.Errorf("index after adding 7 elements to cap-5 buffer = %d, want 2", rb.index) + } + + got := rb.Slice() + // Should return chronological order: oldest (3) to newest (7) + want := []float64{3.0, 4.0, 5.0, 6.0, 7.0} + + if !slices.Equal(got, want) { + t.Errorf("Slice() = %v, want %v", got, want) + } +} + +func TestRingBuffer_MultipleWrapArounds(t *testing.T) { + rb := NewRingBuffer(3) + + // Add 10 elements (wraps multiple times) + for i := 1; i <= 10; i++ { + rb.Add(float64(i)) + } + + got := rb.Slice() + // Should have the last 3 values: 8, 9, 10 + want := []float64{8.0, 9.0, 10.0} + + if !slices.Equal(got, want) { + t.Errorf("Slice() after 10 adds to cap-3 buffer = %v, want %v", got, want) + } +} + +func TestRingBuffer_SingleElementBuffer(t *testing.T) { + rb := NewRingBuffer(1) + + rb.Add(5.0) + if got := rb.Slice(); !slices.Equal(got, []float64{5.0}) { + t.Errorf("Slice() = %v, want [5.0]", got) + } + + // Overwrite the single element + rb.Add(10.0) + if got := rb.Slice(); !slices.Equal(got, []float64{10.0}) { + t.Errorf("Slice() after overwrite = %v, want [10.0]", got) + } +} + +func TestRingBuffer_SliceReturnsNewSlice(t *testing.T) { + rb := NewRingBuffer(3) + rb.Add(1.0) + rb.Add(2.0) + + slice1 := rb.Slice() + slice2 := rb.Slice() + + // Modify slice1 and verify slice2 is unaffected + // This ensures Slice() returns a copy, not a reference to internal data + slice1[0] = 999.0 + + if slice2[0] == 999.0 { + t.Error("Slice() should return a new copy, not a reference to internal data") + } +} + +func TestRingBuffer_NegativeAndZeroValues(t *testing.T) { + rb := NewRingBuffer(4) + + // Test that negative and zero values are handled correctly + rb.Add(-5.0) + rb.Add(0.0) + rb.Add(0.0) + rb.Add(3.5) + + got := rb.Slice() + want := []float64{-5.0, 0.0, 0.0, 3.5} + + if !slices.Equal(got, want) { + t.Errorf("Slice() with negative/zero values = %v, want %v", got, want) + } +} diff --git a/cmd/status/view.go b/cmd/status/view.go index a8a723a..b66befd 100644 --- a/cmd/status/view.go +++ b/cmd/status/view.go @@ -131,7 +131,7 @@ type cardData struct { } func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int, catHidden bool) string { - title := titleStyle.Render("Mole Status") + title := titleStyle.Render("Status") scoreStyle := getScoreStyle(m.HealthScore) scoreText := subtleStyle.Render("Health ") + scoreStyle.Render(fmt.Sprintf("● %d", m.HealthScore)) @@ -145,7 +145,7 @@ func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int cpuInfo := m.Hardware.CPUModel // Append GPU core count when available. if len(m.GPU) > 0 && m.GPU[0].CoreCount > 0 { - cpuInfo += fmt.Sprintf(" (%dGPU)", m.GPU[0].CoreCount) + cpuInfo += fmt.Sprintf(", %dGPU", m.GPU[0].CoreCount) } infoParts = append(infoParts, cpuInfo) } @@ -165,6 +165,9 @@ func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int if m.Hardware.OSVersion != "" { infoParts = append(infoParts, m.Hardware.OSVersion) } + if m.Uptime != "" { + infoParts = append(infoParts, subtleStyle.Render("up "+m.Uptime)) + } headerLine := title + " " + scoreText + " " + strings.Join(infoParts, " · ") @@ -201,15 +204,6 @@ func getScoreStyle(score int) lipgloss.Style { } } -func hasSensorData(sensors []SensorReading) bool { - for _, s := range sensors { - if s.Note == "" && s.Value > 0 { - return true - } - } - return false -} - func renderCPUCard(cpu CPUStatus, thermal ThermalStatus) cardData { var lines []string @@ -224,7 +218,7 @@ func renderCPUCard(cpu CPUStatus, thermal ThermalStatus) cardData { lines = append(lines, fmt.Sprintf("Total %s %s", usageBar, headerText)) if cpu.PerCoreEstimated { - lines = append(lines, subtleStyle.Render("Per-core data unavailable (using averaged load)")) + lines = append(lines, subtleStyle.Render("Per-core data unavailable, using averaged load")) } else if len(cpu.PerCore) > 0 { type coreUsage struct { idx int @@ -245,10 +239,10 @@ func renderCPUCard(cpu CPUStatus, thermal ThermalStatus) cardData { // Load line at the end if cpu.PCoreCount > 0 && cpu.ECoreCount > 0 { - lines = append(lines, fmt.Sprintf("Load %.2f / %.2f / %.2f (%dP+%dE)", + lines = append(lines, fmt.Sprintf("Load %.2f / %.2f / %.2f, %dP+%dE", cpu.Load1, cpu.Load5, cpu.Load15, cpu.PCoreCount, cpu.ECoreCount)) } else { - lines = append(lines, fmt.Sprintf("Load %.2f / %.2f / %.2f (%d cores)", + lines = append(lines, fmt.Sprintf("Load %.2f / %.2f / %.2f, %d cores", cpu.Load1, cpu.Load5, cpu.Load15, cpu.LogicalCPU)) } @@ -276,7 +270,7 @@ func renderMemoryCard(mem MemoryStatus) cardData { if mem.SwapTotal > 0 { swapPercent = (float64(mem.SwapUsed) / float64(mem.SwapTotal)) * 100.0 } - swapText := fmt.Sprintf("(%s/%s)", humanBytesCompact(mem.SwapUsed), humanBytesCompact(mem.SwapTotal)) + swapText := fmt.Sprintf("%s/%s", humanBytesCompact(mem.SwapUsed), humanBytesCompact(mem.SwapTotal)) lines = append(lines, fmt.Sprintf("Swap %s %5.1f%% %s", progressBar(swapPercent), swapPercent, swapText)) lines = append(lines, fmt.Sprintf("Total %s / %s", humanBytes(mem.Used), humanBytes(mem.Total))) @@ -367,7 +361,7 @@ 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) + return fmt.Sprintf("%-6s %s %5.1f%%, %s/%s", label, bar, d.UsedPercent, used, total) } func ioBar(rate float64) string { @@ -411,9 +405,10 @@ func buildCards(m MetricsSnapshot, width int) []cardData { renderProcessCard(m.TopProcesses), renderNetworkCard(m.Network, m.NetworkHistory, m.Proxy, width), } - if hasSensorData(m.Sensors) { - cards = append(cards, renderSensorsCard(m.Sensors)) - } + // Sensors card disabled - redundant with CPU temp + // if hasSensorData(m.Sensors) { + // cards = append(cards, renderSensorsCard(m.Sensors)) + // } return cards } @@ -600,20 +595,6 @@ func renderBatteryCard(batts []BatteryStatus, thermal ThermalStatus) cardData { return cardData{icon: iconBattery, title: "Power", lines: lines} } -func renderSensorsCard(sensors []SensorReading) cardData { - var lines []string - for _, s := range sensors { - if s.Note != "" { - continue - } - lines = append(lines, fmt.Sprintf("%-12s %s", shorten(s.Label, 12), colorizeTemp(s.Value)+s.Unit)) - } - if len(lines) == 0 { - lines = append(lines, subtleStyle.Render("No sensors")) - } - return cardData{icon: iconSensors, title: "Sensors", lines: lines} -} - func renderCard(data cardData, width int, height int) string { titleText := data.icon + " " + data.title lineLen := max(width-lipgloss.Width(titleText)-2, 4) diff --git a/install.sh b/install.sh index b7d05be..6dc2cef 100755 --- a/install.sh +++ b/install.sh @@ -165,7 +165,7 @@ resolve_source_dir() { url="https://github.com/tw93/mole/archive/refs/tags/${branch}.tar.gz" fi - start_line_spinner "Fetching Mole source (${branch})..." + start_line_spinner "Fetching Mole source, ${branch}..." if command -v curl > /dev/null 2>&1; then if curl -fsSL --connect-timeout 10 --max-time 60 -o "$tmp/mole.tar.gz" "$url" 2> /dev/null; then if tar -xzf "$tmp/mole.tar.gz" -C "$tmp" 2> /dev/null; then @@ -509,7 +509,7 @@ download_binary() { log_success "Downloaded ${binary_name} binary" else if [[ -t 1 ]]; then stop_line_spinner; fi - log_warning "Could not download ${binary_name} binary (v${version}), trying local build" + log_warning "Could not download ${binary_name} binary, v${version}, trying local build" if build_binary_from_source "$binary_name" "$target_path"; then return 0 fi @@ -659,9 +659,9 @@ print_usage_summary() { local message="Mole ${action} successfully" if [[ "$action" == "updated" && -n "$previous_version" && -n "$new_version" && "$previous_version" != "$new_version" ]]; then - message+=" (${previous_version} -> ${new_version})" + message+=", ${previous_version} -> ${new_version}" elif [[ -n "$new_version" ]]; then - message+=" (version ${new_version})" + message+=", version ${new_version}" fi log_confirm "$message" @@ -763,7 +763,7 @@ perform_update() { fi if [[ "$installed_version" == "$target_version" ]]; then - echo -e "${GREEN}${ICON_SUCCESS}${NC} Already on latest version ($installed_version)" + echo -e "${GREEN}${ICON_SUCCESS}${NC} Already on latest version, $installed_version" exit 0 fi @@ -794,7 +794,7 @@ perform_update() { updated_version="$target_version" fi - echo -e "${GREEN}${ICON_SUCCESS}${NC} Updated to latest version ($updated_version)" + echo -e "${GREEN}${ICON_SUCCESS}${NC} Updated to latest version, $updated_version" } parse_args "$@" diff --git a/lib/check/all.sh b/lib/check/all.sh index c00c5f5..e2324e3 100644 --- a/lib/check/all.sh +++ b/lib/check/all.sh @@ -48,7 +48,7 @@ check_touchid_sudo() { fi if [[ "$is_supported" == "true" ]]; then - echo -e " ${YELLOW}${ICON_WARNING}${NC} Touch ID ${YELLOW}Not configured for sudo${NC}" + echo -e " ${GRAY}${ICON_WARNING}${NC} Touch ID ${YELLOW}Not configured for sudo${NC}" export TOUCHID_NOT_CONFIGURED=true fi fi @@ -57,13 +57,12 @@ check_touchid_sudo() { check_rosetta() { # Check whitelist if command -v is_whitelisted > /dev/null && is_whitelisted "check_rosetta"; then return; fi - # Check Rosetta 2 (for Apple Silicon Macs) + # Check Rosetta 2 (for Apple Silicon Macs) - informational only, not auto-fixed if [[ "$(uname -m)" == "arm64" ]]; then if [[ -f "/Library/Apple/usr/share/rosetta/rosetta" ]]; then echo -e " ${GREEN}✓${NC} Rosetta 2 Intel app translation ready" else - echo -e " ${YELLOW}${ICON_WARNING}${NC} Rosetta 2 ${YELLOW}Intel app support missing${NC}" - export ROSETTA_NOT_INSTALLED=true + echo -e " ${GRAY}${ICON_EMPTY}${NC} Rosetta 2 ${GRAY}Not installed${NC}" fi fi } @@ -79,7 +78,7 @@ check_git_config() { if [[ -n "$git_name" && -n "$git_email" ]]; then echo -e " ${GREEN}✓${NC} Git Global identity configured" else - echo -e " ${YELLOW}${ICON_WARNING}${NC} Git ${YELLOW}User identity not set${NC}" + echo -e " ${GRAY}${ICON_WARNING}${NC} Git ${YELLOW}User identity not set${NC}" fi fi } @@ -119,7 +118,7 @@ check_firewall() { if [[ "$firewall_output" == *"State = 1"* ]] || [[ "$firewall_output" == *"State = 2"* ]]; then echo -e " ${GREEN}✓${NC} Firewall Network protection enabled" else - echo -e " ${YELLOW}${ICON_WARNING}${NC} Firewall ${YELLOW}Network protection disabled${NC}" + echo -e " ${GRAY}${ICON_WARNING}${NC} Firewall ${YELLOW}Network protection disabled${NC}" export FIREWALL_DISABLED=true fi } @@ -134,7 +133,7 @@ check_gatekeeper() { echo -e " ${GREEN}✓${NC} Gatekeeper App download protection active" unset GATEKEEPER_DISABLED else - echo -e " ${YELLOW}${ICON_WARNING}${NC} Gatekeeper ${YELLOW}App security disabled${NC}" + echo -e " ${GRAY}${ICON_WARNING}${NC} Gatekeeper ${YELLOW}App security disabled${NC}" export GATEKEEPER_DISABLED=true fi fi @@ -149,7 +148,7 @@ check_sip() { if echo "$sip_status" | grep -q "enabled"; then echo -e " ${GREEN}✓${NC} SIP System integrity protected" else - echo -e " ${YELLOW}${ICON_WARNING}${NC} SIP ${YELLOW}System protection disabled${NC}" + echo -e " ${GRAY}${ICON_WARNING}${NC} SIP ${YELLOW}System protection disabled${NC}" fi fi } @@ -276,7 +275,7 @@ check_macos_update() { export MACOS_UPDATE_AVAILABLE="$updates_available" if [[ "$updates_available" == "true" ]]; then - echo -e " ${YELLOW}${ICON_WARNING}${NC} macOS ${YELLOW}Update available${NC}" + echo -e " ${GRAY}${ICON_WARNING}${NC} macOS ${YELLOW}Update available${NC}" else echo -e " ${GREEN}✓${NC} macOS System up to date" fi @@ -344,7 +343,7 @@ check_mole_update() { # Compare versions if [[ "$(printf '%s\n' "$current_version" "$latest_version" | sort -V | head -1)" == "$current_version" ]]; then export MOLE_UPDATE_AVAILABLE="true" - echo -e " ${YELLOW}${ICON_WARNING}${NC} Mole ${YELLOW}${latest_version} available${NC} (running ${current_version})" + echo -e " ${GRAY}${ICON_WARNING}${NC} Mole ${YELLOW}${latest_version} available${NC}, running ${current_version}" else echo -e " ${GREEN}✓${NC} Mole Latest version ${current_version}" fi @@ -406,9 +405,9 @@ check_disk_space() { export DISK_FREE_GB=$free_num if [[ $free_num -lt 20 ]]; then - echo -e " ${RED}✗${NC} Disk Space ${RED}${free_gb}GB free${NC} (Critical)" + echo -e " ${RED}✗${NC} Disk Space ${RED}${free_gb}GB free${NC}, Critical" elif [[ $free_num -lt 50 ]]; then - echo -e " ${YELLOW}${ICON_WARNING}${NC} Disk Space ${YELLOW}${free_gb}GB free${NC} (Low)" + echo -e " ${GRAY}${ICON_WARNING}${NC} Disk Space ${YELLOW}${free_gb}GB free${NC}, Low" else echo -e " ${GREEN}✓${NC} Disk Space ${free_gb}GB free" fi @@ -452,9 +451,9 @@ check_memory_usage() { ((used_percent < 0)) && used_percent=0 if [[ $used_percent -gt 90 ]]; then - echo -e " ${RED}✗${NC} Memory ${RED}${used_percent}% used${NC} (Critical)" + echo -e " ${RED}✗${NC} Memory ${RED}${used_percent}% used${NC}, Critical" elif [[ $used_percent -gt 80 ]]; then - echo -e " ${YELLOW}${ICON_WARNING}${NC} Memory ${YELLOW}${used_percent}% used${NC} (High)" + echo -e " ${GRAY}${ICON_WARNING}${NC} Memory ${YELLOW}${used_percent}% used${NC}, High" else echo -e " ${GREEN}✓${NC} Memory ${used_percent}% used" fi @@ -484,7 +483,7 @@ check_login_items() { fi if [[ $login_items_count -gt 15 ]]; then - echo -e " ${YELLOW}${ICON_WARNING}${NC} Login Items ${YELLOW}${login_items_count} apps${NC}" + echo -e " ${GRAY}${ICON_WARNING}${NC} Login Items ${YELLOW}${login_items_count} apps${NC}" elif [[ $login_items_count -gt 0 ]]; then echo -e " ${GREEN}✓${NC} Login Items ${login_items_count} apps" else @@ -548,9 +547,9 @@ check_cache_size() { local cache_size_int=$(echo "$cache_size_gb" | cut -d'.' -f1) if [[ $cache_size_int -gt 10 ]]; then - echo -e " ${YELLOW}${ICON_WARNING}${NC} Cache Size ${YELLOW}${cache_size_gb}GB${NC} cleanable" + echo -e " ${GRAY}${ICON_WARNING}${NC} Cache Size ${YELLOW}${cache_size_gb}GB${NC} cleanable" elif [[ $cache_size_int -gt 5 ]]; then - echo -e " ${YELLOW}${ICON_WARNING}${NC} Cache Size ${YELLOW}${cache_size_gb}GB${NC} cleanable" + echo -e " ${GRAY}${ICON_WARNING}${NC} Cache Size ${YELLOW}${cache_size_gb}GB${NC} cleanable" else echo -e " ${GREEN}✓${NC} Cache Size ${cache_size_gb}GB" fi @@ -568,7 +567,7 @@ check_swap_usage() { if [[ "$swap_used" == *"G"* ]]; then local swap_gb=${swap_num%.*} if [[ $swap_gb -gt 2 ]]; then - echo -e " ${YELLOW}${ICON_WARNING}${NC} Swap Usage ${YELLOW}${swap_used}${NC} (High)" + echo -e " ${GRAY}${ICON_WARNING}${NC} Swap Usage ${YELLOW}${swap_used}${NC}, High" else echo -e " ${GREEN}✓${NC} Swap Usage ${swap_used}" fi diff --git a/lib/clean/app_caches.sh b/lib/clean/app_caches.sh index c4b62c6..2402f09 100644 --- a/lib/clean/app_caches.sh +++ b/lib/clean/app_caches.sh @@ -18,7 +18,7 @@ clean_xcode_tools() { safe_clean ~/Library/Developer/Xcode/DerivedData/* "Xcode derived data" safe_clean ~/Library/Developer/Xcode/Archives/* "Xcode archives" else - echo -e " ${YELLOW}${ICON_WARNING}${NC} Xcode is running, skipping DerivedData and Archives cleanup" + echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode is running, skipping DerivedData and Archives cleanup" fi } # Code editors. @@ -107,7 +107,7 @@ clean_media_players() { fi fi if [[ "$has_offline_music" == "true" ]]; then - echo -e " ${YELLOW}${ICON_WARNING}${NC} Spotify cache protected · offline music detected" + echo -e " ${GRAY}${ICON_WARNING}${NC} Spotify cache protected · offline music detected" note_activity else safe_clean ~/Library/Caches/com.spotify.client/* "Spotify cache" diff --git a/lib/clean/apps.sh b/lib/clean/apps.sh index 8130482..9fffe40 100644 --- a/lib/clean/apps.sh +++ b/lib/clean/apps.sh @@ -46,9 +46,9 @@ clean_ds_store_tree() { local size_human size_human=$(bytes_to_human "$total_bytes") if [[ "$DRY_RUN" == "true" ]]; then - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} $label ${YELLOW}($file_count files, $size_human dry)${NC}" + 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 ${GREEN}($file_count files, $size_human)${NC}" + echo -e " ${GREEN}${ICON_SUCCESS}${NC} $label${NC}, ${GREEN}$file_count files, $size_human${NC}" fi local size_kb=$(((total_bytes + 1023) / 1024)) ((files_cleaned += file_count)) @@ -70,7 +70,7 @@ scan_installed_apps() { current_time=$(get_epoch_seconds) local age=$((current_time - cache_mtime)) if [[ $age -lt $cache_age_seconds ]]; then - debug_log "Using cached app list (age: ${age}s)" + debug_log "Using cached app list, age: ${age}s" if [[ -r "$cache_file" ]] && [[ -s "$cache_file" ]]; then if cat "$cache_file" > "$installed_bundles" 2> /dev/null; then return 0 @@ -82,7 +82,7 @@ scan_installed_apps() { fi fi fi - debug_log "Scanning installed applications (cache expired or missing)" + debug_log "Scanning installed applications, cache expired or missing" local -a app_dirs=( "/Applications" "/System/Applications" @@ -246,7 +246,7 @@ is_bundle_orphaned() { clean_orphaned_app_data() { if ! ls "$HOME/Library/Caches" > /dev/null 2>&1; then stop_section_spinner - echo -e " ${YELLOW}${ICON_WARNING}${NC} Skipped: No permission to access Library folders" + echo -e " ${GRAY}${ICON_WARNING}${NC} Skipped: No permission to access Library folders" return 0 fi start_section_spinner "Scanning installed apps..." @@ -310,8 +310,210 @@ clean_orphaned_app_data() { stop_section_spinner if [[ $orphaned_count -gt 0 ]]; then local orphaned_mb=$(echo "$total_orphaned_kb" | awk '{printf "%.1f", $1/1024}') - echo " ${GREEN}${ICON_SUCCESS}${NC} Cleaned $orphaned_count items (~${orphaned_mb}MB)" + echo " ${GREEN}${ICON_SUCCESS}${NC} Cleaned $orphaned_count items, about ${orphaned_mb}MB" note_activity fi rm -f "$installed_bundles" } + +# Clean orphaned system-level services (LaunchDaemons, LaunchAgents, PrivilegedHelperTools) +# These are left behind when apps are uninstalled but their system services remain +clean_orphaned_system_services() { + # Requires sudo + if ! sudo -n true 2> /dev/null; then + return 0 + fi + + start_section_spinner "Scanning orphaned system services..." + + local orphaned_count=0 + local total_orphaned_kb=0 + local -a orphaned_files=() + + # Known bundle ID patterns for common apps that leave system services behind + # Format: "file_pattern:app_check_command" + local -a known_orphan_patterns=( + # Sogou Input Method + "com.sogou.*:/Library/Input Methods/SogouInput.app" + # ClashX + "com.west2online.ClashX.*:/Applications/ClashX.app" + # ClashMac + "com.clashmac.*:/Applications/ClashMac.app" + # Nektony App Cleaner + "com.nektony.AC*:/Applications/App Cleaner & Uninstaller.app" + # i4tools (爱思助手) + "cn.i4tools.*:/Applications/i4Tools.app" + ) + + local mdfind_cache_file="" + _system_service_app_exists() { + local bundle_id="$1" + local app_path="$2" + + [[ -n "$app_path" && -d "$app_path" ]] && return 0 + + if [[ -n "$app_path" ]]; then + local app_name + app_name=$(basename "$app_path") + case "$app_path" in + /Applications/*) + [[ -d "$HOME/Applications/$app_name" ]] && return 0 + [[ -d "/Applications/Setapp/$app_name" ]] && return 0 + ;; + /Library/Input\ Methods/*) + [[ -d "$HOME/Library/Input Methods/$app_name" ]] && return 0 + ;; + esac + fi + + 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") + register_temp_file "$mdfind_cache_file" + fi + + if grep -Fxq "FOUND:$bundle_id" "$mdfind_cache_file" 2> /dev/null; then + return 0 + 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 "") + if [[ -n "$app_found" ]]; then + echo "FOUND:$bundle_id" >> "$mdfind_cache_file" + return 0 + fi + echo "NOTFOUND:$bundle_id" >> "$mdfind_cache_file" + fi + fi + + return 1 + } + + # Scan system LaunchDaemons + if [[ -d /Library/LaunchDaemons ]]; then + while IFS= read -r -d '' plist; do + local filename + filename=$(basename "$plist") + + # Skip Apple system files + [[ "$filename" == com.apple.* ]] && continue + + # Extract bundle ID from filename (remove .plist extension) + local bundle_id="${filename%.plist}" + + # Check against known orphan patterns + for pattern_entry in "${known_orphan_patterns[@]}"; do + local file_pattern="${pattern_entry%%:*}" + local app_path="${pattern_entry#*:}" + + # shellcheck disable=SC2053 + if [[ "$bundle_id" == $file_pattern ]] && [[ ! -d "$app_path" ]]; then + if _system_service_app_exists "$bundle_id" "$app_path"; then + continue + fi + orphaned_files+=("$plist") + local size_kb + size_kb=$(sudo du -sk "$plist" 2> /dev/null | awk '{print $1}' || echo "0") + ((total_orphaned_kb += size_kb)) + ((orphaned_count++)) + break + fi + done + done < <(sudo find /Library/LaunchDaemons -maxdepth 1 -name "*.plist" -print0 2> /dev/null) + fi + + # Scan system LaunchAgents + if [[ -d /Library/LaunchAgents ]]; then + while IFS= read -r -d '' plist; do + local filename + filename=$(basename "$plist") + + # Skip Apple system files + [[ "$filename" == com.apple.* ]] && continue + + local bundle_id="${filename%.plist}" + + for pattern_entry in "${known_orphan_patterns[@]}"; do + local file_pattern="${pattern_entry%%:*}" + local app_path="${pattern_entry#*:}" + + # shellcheck disable=SC2053 + if [[ "$bundle_id" == $file_pattern ]] && [[ ! -d "$app_path" ]]; then + if _system_service_app_exists "$bundle_id" "$app_path"; then + continue + fi + orphaned_files+=("$plist") + local size_kb + size_kb=$(sudo du -sk "$plist" 2> /dev/null | awk '{print $1}' || echo "0") + ((total_orphaned_kb += size_kb)) + ((orphaned_count++)) + break + fi + done + done < <(sudo find /Library/LaunchAgents -maxdepth 1 -name "*.plist" -print0 2> /dev/null) + fi + + # Scan PrivilegedHelperTools + if [[ -d /Library/PrivilegedHelperTools ]]; then + while IFS= read -r -d '' helper; do + local filename + filename=$(basename "$helper") + local bundle_id="$filename" + + # Skip Apple system files + [[ "$filename" == com.apple.* ]] && continue + + for pattern_entry in "${known_orphan_patterns[@]}"; do + local file_pattern="${pattern_entry%%:*}" + local app_path="${pattern_entry#*:}" + + # shellcheck disable=SC2053 + if [[ "$filename" == $file_pattern ]] && [[ ! -d "$app_path" ]]; then + if _system_service_app_exists "$bundle_id" "$app_path"; then + continue + fi + orphaned_files+=("$helper") + local size_kb + size_kb=$(sudo du -sk "$helper" 2> /dev/null | awk '{print $1}' || echo "0") + ((total_orphaned_kb += size_kb)) + ((orphaned_count++)) + break + fi + done + done < <(sudo find /Library/PrivilegedHelperTools -maxdepth 1 -type f -print0 2> /dev/null) + fi + + stop_section_spinner + + # Report and clean + if [[ $orphaned_count -gt 0 ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Found $orphaned_count orphaned system services" + + for orphan_file in "${orphaned_files[@]}"; do + local filename + filename=$(basename "$orphan_file") + + if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then + debug_log "[DRY RUN] Would remove orphaned service: $orphan_file" + else + # Unload if it's a LaunchDaemon/LaunchAgent + if [[ "$orphan_file" == *.plist ]]; then + sudo launchctl unload "$orphan_file" 2> /dev/null || true + fi + if safe_sudo_remove "$orphan_file"; then + debug_log "Removed orphaned service: $orphan_file" + fi + fi + done + + local orphaned_kb_display + if [[ $total_orphaned_kb -gt 1024 ]]; then + orphaned_kb_display=$(echo "$total_orphaned_kb" | awk '{printf "%.1fMB", $1/1024}') + 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 + fi + +} diff --git a/lib/clean/brew.sh b/lib/clean/brew.sh index b30fa7f..6ccf2d6 100644 --- a/lib/clean/brew.sh +++ b/lib/clean/brew.sh @@ -86,13 +86,13 @@ clean_homebrew() { freed_space=$(printf '%s\n' "$brew_output" | grep -o "[0-9.]*[KMGT]B freed" 2> /dev/null | tail -1 || true) if [[ $removed_count -gt 0 ]] || [[ -n "$freed_space" ]]; then if [[ -n "$freed_space" ]]; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Homebrew cleanup ${GREEN}($freed_space)${NC}" + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Homebrew cleanup${NC}, ${GREEN}$freed_space${NC}" else - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Homebrew cleanup (${removed_count} items)" + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Homebrew cleanup, ${removed_count} items" fi fi elif [[ $brew_exit -eq 124 ]]; then - echo -e " ${YELLOW}${ICON_WARNING}${NC} Homebrew cleanup timed out · run ${GRAY}brew cleanup${NC} manually" + echo -e " ${GRAY}${ICON_WARNING}${NC} Homebrew cleanup timed out · run ${GRAY}brew cleanup${NC} manually" fi # Process autoremove output - only show if packages were removed # Only surface autoremove output when packages were removed. @@ -102,10 +102,10 @@ clean_homebrew() { local removed_packages removed_packages=$(printf '%s\n' "$autoremove_output" | grep -c "^Uninstalling" 2> /dev/null || true) if [[ $removed_packages -gt 0 ]]; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Removed orphaned dependencies (${removed_packages} packages)" + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Removed orphaned dependencies, ${removed_packages} packages" fi elif [[ $autoremove_exit -eq 124 ]]; then - echo -e " ${YELLOW}${ICON_WARNING}${NC} Autoremove timed out · run ${GRAY}brew autoremove${NC} manually" + echo -e " ${GRAY}${ICON_WARNING}${NC} Autoremove timed out · run ${GRAY}brew autoremove${NC} manually" fi # Update cache timestamp on successful completion or when cleanup was intelligently skipped # This prevents repeated cache size checks within the 7-day window diff --git a/lib/clean/caches.sh b/lib/clean/caches.sh index 2fdfe87..b5ecdcf 100644 --- a/lib/clean/caches.sh +++ b/lib/clean/caches.sh @@ -22,7 +22,7 @@ check_tcc_permissions() { echo "" echo -e "${BLUE}First-time setup${NC}" echo -e "${GRAY}macOS will request permissions to access Library folders.${NC}" - echo -e "${GRAY}You may see ${GREEN}${#tcc_dirs[@]} permission dialogs${NC}${GRAY} - please approve them all.${NC}" + echo -e "${GRAY}You may see ${GREEN}${#tcc_dirs[@]} permission dialogs${NC}${GRAY}, please approve them all.${NC}" echo "" echo -ne "${PURPLE}${ICON_ARROW}${NC} Press ${GREEN}Enter${NC} to continue: " read -r @@ -75,12 +75,12 @@ clean_service_worker_cache() { local cleaned_mb=$((cleaned_size / 1024)) 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 " ${GREEN}${ICON_SUCCESS}${NC} $browser_name Service Worker, ${cleaned_mb}MB, ${protected_count} protected" else - echo -e " ${GREEN}${ICON_SUCCESS}${NC} $browser_name Service Worker (${cleaned_mb}MB)" + echo -e " ${GREEN}${ICON_SUCCESS}${NC} $browser_name Service Worker, ${cleaned_mb}MB" fi else - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} $browser_name Service Worker (would clean ${cleaned_mb}MB, ${protected_count} protected)" + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} $browser_name Service Worker, would clean ${cleaned_mb}MB, ${protected_count} protected" fi note_activity if [[ "$spinner_was_running" == "true" ]]; then diff --git a/lib/clean/dev.sh b/lib/clean/dev.sh index 5ddfabc..e3d7c44 100644 --- a/lib/clean/dev.sh +++ b/lib/clean/dev.sh @@ -45,7 +45,7 @@ clean_dev_npm() { # Python/pip ecosystem caches. clean_dev_python() { if command -v pip3 > /dev/null 2>&1; then - clean_tool_cache "pip cache" bash -c 'pip3 cache purge >/dev/null 2>&1 || true' + clean_tool_cache "pip cache" bash -c 'pip3 cache purge > /dev/null 2>&1 || true' note_activity fi safe_clean ~/.pyenv/cache/* "pyenv cache" @@ -65,7 +65,7 @@ clean_dev_python() { # Go build/module caches. clean_dev_go() { if command -v go > /dev/null 2>&1; 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' note_activity fi } @@ -77,12 +77,12 @@ clean_dev_rust() { } # Helper: Check for multiple versions in a directory. -# Args: $1=directory, $2=tool_name, $3+=additional_lines +# Args: $1=directory, $2=tool_name, $3=list_command, $4=remove_command check_multiple_versions() { local dir="$1" local tool_name="$2" - shift 2 - local -a additional_lines=("$@") + local list_cmd="${3:-}" + local remove_cmd="${4:-}" if [[ ! -d "$dir" ]]; then return 0 @@ -93,10 +93,11 @@ check_multiple_versions() { if [[ "$count" -gt 1 ]]; then note_activity - echo -e " Found ${GREEN}${count}${NC} ${tool_name}" - for line in "${additional_lines[@]}"; do - echo -e " $line" - done + local hint="" + if [[ -n "$list_cmd" ]]; then + hint=" · ${GRAY}${list_cmd}${NC}" + fi + echo -e " ${GRAY}${ICON_WARNING}${NC} ${tool_name}: ${count} found${hint}" fi } @@ -107,8 +108,7 @@ check_rust_toolchains() { check_multiple_versions \ "$HOME/.rustup/toolchains" \ "Rust toolchains" \ - "You can list them with: ${GRAY}rustup toolchain list${NC}" \ - "Remove unused with: ${GRAY}rustup toolchain uninstall ${NC}" + "rustup toolchain list" } # Docker caches (guarded by daemon check). clean_dev_docker() { @@ -170,8 +170,7 @@ check_android_ndk() { check_multiple_versions \ "$HOME/Library/Android/sdk/ndk" \ "Android NDK versions" \ - "Manage in: ${GRAY}Android Studio → SDK Manager${NC}" \ - "Or manually at: ${GRAY}\$HOME/Library/Android/sdk/ndk${NC}" + "Android Studio → SDK Manager" } clean_dev_mobile() { @@ -200,8 +199,8 @@ clean_dev_mobile() { # Simulator runtime caches. safe_clean ~/Library/Developer/CoreSimulator/Profiles/Runtimes/*/Contents/Resources/RuntimeRoot/System/Library/Caches/* "Simulator runtime cache" safe_clean ~/Library/Caches/Google/AndroidStudio*/* "Android Studio cache" - safe_clean ~/Library/Caches/CocoaPods/* "CocoaPods cache" - safe_clean ~/.cache/flutter/* "Flutter cache" + # safe_clean ~/Library/Caches/CocoaPods/* "CocoaPods cache" + # safe_clean ~/.cache/flutter/* "Flutter cache" safe_clean ~/.android/build-cache/* "Android build cache" safe_clean ~/.android/cache/* "Android SDK cache" safe_clean ~/Library/Developer/Xcode/UserData/IB\ Support/* "Xcode Interface Builder cache" @@ -214,12 +213,117 @@ clean_dev_jvm() { safe_clean ~/.sbt/* "SBT cache" safe_clean ~/.ivy2/cache/* "Ivy cache" } +# JetBrains Toolbox old IDE versions (keep current + recent backup). +clean_dev_jetbrains_toolbox() { + local toolbox_root="$HOME/Library/Application Support/JetBrains/Toolbox/apps" + [[ -d "$toolbox_root" ]] || return 0 + + local keep_previous="${MOLE_JETBRAINS_TOOLBOX_KEEP:-1}" + [[ "$keep_previous" =~ ^[0-9]+$ ]] || keep_previous=1 + + # Save and filter whitelist patterns for toolbox path + local whitelist_overridden="false" + local -a original_whitelist=() + if [[ ${#WHITELIST_PATTERNS[@]} -gt 0 ]]; then + original_whitelist=("${WHITELIST_PATTERNS[@]}") + local -a filtered_whitelist=() + local pattern + for pattern in "${WHITELIST_PATTERNS[@]}"; do + [[ "$toolbox_root" == "$pattern" || "$pattern" == "$toolbox_root"* ]] && continue + filtered_whitelist+=("$pattern") + done + WHITELIST_PATTERNS=("${filtered_whitelist[@]+${filtered_whitelist[@]}}") + whitelist_overridden="true" + fi + + # Helper to restore whitelist on exit + _restore_whitelist() { + [[ "$whitelist_overridden" == "true" ]] && WHITELIST_PATTERNS=("${original_whitelist[@]}") + return 0 + } + + local -a product_dirs=() + while IFS= read -r -d '' product_dir; do + product_dirs+=("$product_dir") + done < <(command find "$toolbox_root" -mindepth 1 -maxdepth 1 -type d -print0 2> /dev/null) + + if [[ ${#product_dirs[@]} -eq 0 ]]; then + _restore_whitelist + return 0 + fi + + local product_dir + for product_dir in "${product_dirs[@]}"; do + while IFS= read -r -d '' channel_dir; do + local current_link="" + local current_real="" + if [[ -L "$channel_dir/current" ]]; then + current_link=$(readlink "$channel_dir/current" 2> /dev/null || true) + if [[ -n "$current_link" ]]; then + if [[ "$current_link" == /* ]]; then + current_real="$current_link" + else + current_real="$channel_dir/$current_link" + fi + fi + elif [[ -d "$channel_dir/current" ]]; then + current_real="$channel_dir/current" + fi + + local -a version_dirs=() + while IFS= read -r -d '' version_dir; do + local name + name=$(basename "$version_dir") + + [[ "$name" == "current" ]] && continue + [[ "$name" == .* ]] && continue + [[ "$name" == "plugins" || "$name" == "plugins-lib" || "$name" == "plugins-libs" ]] && continue + [[ -n "$current_real" && "$version_dir" == "$current_real" ]] && continue + [[ ! "$name" =~ ^[0-9] ]] && continue + + version_dirs+=("$version_dir") + done < <(command find "$channel_dir" -mindepth 1 -maxdepth 1 -type d -print0 2> /dev/null) + + [[ ${#version_dirs[@]} -eq 0 ]] && continue + + local -a sorted_dirs=() + while IFS= read -r line; do + local dir_path="${line#* }" + sorted_dirs+=("$dir_path") + done < <( + for version_dir in "${version_dirs[@]}"; do + local mtime + mtime=$(stat -f%m "$version_dir" 2> /dev/null || echo "0") + printf '%s %s\n' "$mtime" "$version_dir" + done | sort -rn + ) + + if [[ ${#sorted_dirs[@]} -le "$keep_previous" ]]; then + continue + fi + + local idx=0 + local dir_path + for dir_path in "${sorted_dirs[@]}"; do + if [[ $idx -lt $keep_previous ]]; then + ((idx++)) + continue + fi + safe_clean "$dir_path" "JetBrains Toolbox old IDE version" + note_activity + ((idx++)) + done + done < <(command find "$product_dir" -mindepth 1 -maxdepth 1 -type d -name "ch-*" -print0 2> /dev/null) + done + + _restore_whitelist +} # Other language tool caches. clean_dev_other_langs() { safe_clean ~/.bundle/cache/* "Ruby Bundler cache" safe_clean ~/.composer/cache/* "PHP Composer cache" safe_clean ~/.nuget/packages/* "NuGet packages cache" - safe_clean ~/.pub-cache/* "Dart Pub cache" + # safe_clean ~/.pub-cache/* "Dart Pub cache" safe_clean ~/.cache/bazel/* "Bazel cache" safe_clean ~/.cache/zig/* "Zig cache" safe_clean ~/Library/Caches/deno/* "Deno cache" @@ -343,6 +447,7 @@ clean_developer_tools() { clean_project_caches clean_dev_mobile clean_dev_jvm + clean_dev_jetbrains_toolbox clean_dev_other_langs clean_dev_cicd clean_dev_database diff --git a/lib/clean/project.sh b/lib/clean/project.sh index a34e49c..e1ffa25 100644 --- a/lib/clean/project.sh +++ b/lib/clean/project.sh @@ -606,7 +606,7 @@ select_purge_categories() { fi printf "%s\n" "$clear_line" - printf "%s${PURPLE_BOLD}Select Categories to Clean${NC}%s ${GRAY}- ${selected_gb}GB ($selected_count selected)${NC}\n" "$clear_line" "$scroll_indicator" + printf "%s${PURPLE_BOLD}Select Categories to Clean${NC}%s ${GRAY}, ${selected_gb}GB, ${selected_count} selected${NC}\n" "$clear_line" "$scroll_indicator" printf "%s\n" "$clear_line" IFS=',' read -r -a recent_flags <<< "${PURGE_RECENT_CATEGORIES:-}" @@ -1135,7 +1135,7 @@ clean_project_artifacts() { fi if [[ -t 1 ]]; then stop_inline_spinner - echo -e "${GREEN}${ICON_SUCCESS}${NC} $project_path - $artifact_type ${GREEN}($size_human)${NC}" + echo -e "${GREEN}${ICON_SUCCESS}${NC} $project_path, $artifact_type${NC}, ${GREEN}$size_human${NC}" fi done # Update count diff --git a/lib/clean/system.sh b/lib/clean/system.sh index 27d6ab0..e3d282e 100644 --- a/lib/clean/system.sh +++ b/lib/clean/system.sh @@ -19,40 +19,38 @@ clean_deep_system() { safe_sudo_find_delete "/private/var/log" "*.gz" "$MOLE_LOG_AGE_DAYS" "f" || true log_success "System logs" if [[ -d "/Library/Updates" && ! -L "/Library/Updates" ]]; then - if ! is_sip_enabled; then - local updates_cleaned=0 - while IFS= read -r -d '' item; do - if [[ -z "$item" ]] || [[ ! "$item" =~ ^/Library/Updates/[^/]+$ ]]; then - debug_log "Skipping malformed path: $item" - continue - fi - local item_flags - item_flags=$($STAT_BSD -f%Sf "$item" 2> /dev/null || echo "") - if [[ "$item_flags" == *"restricted"* ]]; then - continue - fi - if safe_sudo_remove "$item"; then - ((updates_cleaned++)) - fi - done < <(find /Library/Updates -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true) - [[ $updates_cleaned -gt 0 ]] && log_success "System library updates" - fi + local updates_cleaned=0 + while IFS= read -r -d '' item; do + if [[ -z "$item" ]] || [[ ! "$item" =~ ^/Library/Updates/[^/]+$ ]]; then + debug_log "Skipping malformed path: $item" + continue + fi + local item_flags + item_flags=$($STAT_BSD -f%Sf "$item" 2> /dev/null || echo "") + if [[ "$item_flags" == *"restricted"* ]]; then + continue + fi + if safe_sudo_remove "$item"; then + ((updates_cleaned++)) + fi + done < <(find /Library/Updates -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true) + [[ $updates_cleaned -gt 0 ]] && log_success "System library updates" fi if [[ -d "/macOS Install Data" ]]; then local mtime=$(get_file_mtime "/macOS Install Data") local age_days=$((($(get_epoch_seconds) - mtime) / 86400)) - debug_log "Found macOS Install Data (age: ${age_days} days)" + debug_log "Found macOS Install Data, age ${age_days} days" if [[ $age_days -ge 30 ]]; then local size_kb=$(get_path_size_kb "/macOS Install Data") if [[ -n "$size_kb" && "$size_kb" -gt 0 ]]; then local size_human=$(bytes_to_human "$((size_kb * 1024))") - debug_log "Cleaning macOS Install Data: $size_human (${age_days} days old)" + debug_log "Cleaning macOS Install Data: $size_human, ${age_days} days old" if safe_sudo_remove "/macOS Install Data"; then - log_success "macOS Install Data ($size_human)" + log_success "macOS Install Data, $size_human" fi fi else - debug_log "Keeping macOS Install Data (only ${age_days} days old, needs 30+)" + debug_log "Keeping macOS Install Data, only ${age_days} days old, needs 30+" fi fi start_section_spinner "Scanning system caches..." @@ -72,27 +70,31 @@ clean_deep_system() { local current_time current_time=$(get_epoch_seconds) if [[ $((current_time - last_update_time)) -ge $update_interval ]]; then - start_section_spinner "Scanning system caches... ($found_count found)" + start_section_spinner "Scanning system caches... $found_count found" last_update_time=$current_time fi fi done < <(run_with_timeout 5 command find /private/var/folders -type d -name "*.code_sign_clone" -path "*/X/*" -print0 2> /dev/null || true) stop_section_spinner - [[ $code_sign_cleaned -gt 0 ]] && log_success "Browser code signature caches ($code_sign_cleaned items)" - safe_sudo_find_delete "/private/var/db/diagnostics/Special" "*" "$MOLE_LOG_AGE_DAYS" "f" || true - safe_sudo_find_delete "/private/var/db/diagnostics/Persist" "*" "$MOLE_LOG_AGE_DAYS" "f" || true - safe_sudo_find_delete "/private/var/db/DiagnosticPipeline" "*" "$MOLE_LOG_AGE_DAYS" "f" || true - log_success "System diagnostic logs" - safe_sudo_find_delete "/private/var/db/powerlog" "*" "$MOLE_LOG_AGE_DAYS" "f" || true - log_success "Power logs" - safe_sudo_find_delete "/private/var/db/reportmemoryexception/MemoryLimitViolations" "*" "30" "f" || true - log_success "Memory exception reports" - start_section_spinner "Cleaning diagnostic trace logs..." - local diag_logs_cleaned=0 - safe_sudo_find_delete "/private/var/db/diagnostics/Persist" "*.tracev3" "30" "f" && diag_logs_cleaned=1 || true - safe_sudo_find_delete "/private/var/db/diagnostics/Special" "*.tracev3" "30" "f" && diag_logs_cleaned=1 || true + [[ $code_sign_cleaned -gt 0 ]] && log_success "Browser code signature caches, $code_sign_cleaned items" + + start_section_spinner "Cleaning system diagnostic logs..." + local diag_cleaned=0 + safe_sudo_find_delete "/private/var/db/diagnostics/Special" "*" "$MOLE_LOG_AGE_DAYS" "f" && diag_cleaned=1 || true + safe_sudo_find_delete "/private/var/db/diagnostics/Persist" "*" "$MOLE_LOG_AGE_DAYS" "f" && diag_cleaned=1 || true + safe_sudo_find_delete "/private/var/db/DiagnosticPipeline" "*" "$MOLE_LOG_AGE_DAYS" "f" && diag_cleaned=1 || true + safe_sudo_find_delete "/private/var/db/powerlog" "*" "$MOLE_LOG_AGE_DAYS" "f" && diag_cleaned=1 || true + safe_sudo_find_delete "/private/var/db/reportmemoryexception/MemoryLimitViolations" "*" "30" "f" && diag_cleaned=1 || true stop_section_spinner - [[ $diag_logs_cleaned -eq 1 ]] && log_success "System diagnostic trace logs" + + [[ $diag_cleaned -eq 1 ]] && log_success "System diagnostic logs" + + start_section_spinner "Cleaning diagnostic trace logs..." + local trace_cleaned=0 + safe_sudo_find_delete "/private/var/db/diagnostics/Persist" "*.tracev3" "30" "f" && trace_cleaned=1 || true + safe_sudo_find_delete "/private/var/db/diagnostics/Special" "*.tracev3" "30" "f" && trace_cleaned=1 || true + stop_section_spinner + [[ $trace_cleaned -eq 1 ]] && log_success "System diagnostic trace logs" } # Incomplete Time Machine backups. clean_time_machine_failed_backups() { @@ -176,7 +178,7 @@ clean_time_machine_failed_backups() { local backup_name=$(basename "$inprogress_file") local size_human=$(bytes_to_human "$((size_kb * 1024))") if [[ "$DRY_RUN" == "true" ]]; then - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Incomplete backup: $backup_name ${YELLOW}($size_human dry)${NC}" + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Incomplete backup: $backup_name${NC}, ${YELLOW}$size_human dry${NC}" ((tm_cleaned++)) note_activity continue @@ -186,7 +188,7 @@ 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 ${GREEN}($size_human)${NC}" + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Incomplete backup: $backup_name${NC}, ${GREEN}$size_human${NC}" ((tm_cleaned++)) ((files_cleaned++)) ((total_size_cleaned += size_kb)) @@ -222,7 +224,7 @@ clean_time_machine_failed_backups() { local backup_name=$(basename "$inprogress_file") local size_human=$(bytes_to_human "$((size_kb * 1024))") if [[ "$DRY_RUN" == "true" ]]; then - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Incomplete APFS backup in $bundle_name: $backup_name ${YELLOW}($size_human dry)${NC}" + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Incomplete APFS backup in $bundle_name: $backup_name${NC}, ${YELLOW}$size_human dry${NC}" ((tm_cleaned++)) note_activity continue @@ -231,7 +233,7 @@ 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 ${GREEN}($size_human)${NC}" + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Incomplete APFS backup in $bundle_name: $backup_name${NC}, ${GREEN}$size_human${NC}" ((tm_cleaned++)) ((files_cleaned++)) ((total_size_cleaned += size_kb)) @@ -267,7 +269,7 @@ tm_is_running() { grep -qE '(^|[[:space:]])("Running"|Running)[[:space:]]*=[[:space:]]*1([[:space:]]*;|$)' <<< "$st" } -# Local APFS snapshots (keep the most recent). +# Local APFS snapshots (report only). clean_local_snapshots() { if ! command -v tmutil > /dev/null 2>&1; then return 0 @@ -277,93 +279,25 @@ clean_local_snapshots() { tm_is_running || rc_running=$? if [[ $rc_running -eq 2 ]]; then - echo -e " ${YELLOW}!${NC} Could not determine Time Machine status; skipping snapshot cleanup" + echo -e " ${YELLOW}!${NC} Could not determine Time Machine status; skipping snapshot check" return 0 fi if [[ $rc_running -eq 0 ]]; then - echo -e " ${YELLOW}!${NC} Time Machine is active; skipping snapshot cleanup" + echo -e " ${YELLOW}!${NC} Time Machine is active; skipping snapshot check" return 0 fi start_section_spinner "Checking local snapshots..." local snapshot_list - snapshot_list=$(tmutil listlocalsnapshots / 2> /dev/null) + snapshot_list=$(run_with_timeout 3 tmutil listlocalsnapshots / 2> /dev/null || true) stop_section_spinner [[ -z "$snapshot_list" ]] && return 0 - local cleaned_count=0 - local total_cleaned_size=0 # Estimation not possible without thin - local newest_ts=0 - local newest_name="" - local -a snapshots=() - while IFS= read -r line; do - if [[ "$line" =~ com\.apple\.TimeMachine\.([0-9]{4})-([0-9]{2})-([0-9]{2})-([0-9]{6}) ]]; then - local snap_name="${BASH_REMATCH[0]}" - snapshots+=("$snap_name") - local date_str="${BASH_REMATCH[1]}-${BASH_REMATCH[2]}-${BASH_REMATCH[3]} ${BASH_REMATCH[4]:0:2}:${BASH_REMATCH[4]:2:2}:${BASH_REMATCH[4]:4:2}" - local snap_ts=$(date -j -f "%Y-%m-%d %H:%M:%S" "$date_str" "+%s" 2> /dev/null || echo "0") - [[ "$snap_ts" == "0" ]] && continue - if [[ "$snap_ts" -gt "$newest_ts" ]]; then - newest_ts="$snap_ts" - newest_name="$snap_name" - fi - fi - done <<< "$snapshot_list" - [[ ${#snapshots[@]} -eq 0 ]] && return 0 - [[ -z "$newest_name" ]] && return 0 - - local deletable_count=$((${#snapshots[@]} - 1)) - [[ $deletable_count -le 0 ]] && return 0 - - if [[ "$DRY_RUN" != "true" ]]; then - if [[ ! -t 0 ]]; then - echo -e " ${YELLOW}!${NC} ${#snapshots[@]} local snapshot(s) found, skipping non-interactive mode" - echo -e " ${YELLOW}${ICON_WARNING}${NC} ${GRAY}Tip: Snapshots may cause Disk Utility to show different 'Available' values${NC}" - return 0 - fi - echo -e " ${YELLOW}!${NC} Time Machine local snapshots found" - echo -e " ${GRAY}macOS can recreate them if needed.${NC}" - echo -e " ${GRAY}The most recent snapshot will be kept.${NC}" - echo -ne " ${PURPLE}${ICON_ARROW}${NC} Remove all local snapshots except the most recent one? ${GREEN}Enter${NC} continue, ${GRAY}Space${NC} skip: " - local choice - if type read_key > /dev/null 2>&1; then - choice=$(read_key) - else - IFS= read -r -s -n 1 choice || choice="" - if [[ -z "$choice" || "$choice" == $'\n' || "$choice" == $'\r' ]]; then - choice="ENTER" - fi - fi - if [[ "$choice" == "ENTER" ]]; then - printf "\r\033[K" # Clear the prompt line - else - echo -e " ${GRAY}Skipped${NC}" - return 0 - fi - fi - - local snap_name - for snap_name in "${snapshots[@]}"; do - if [[ "$snap_name" =~ com\.apple\.TimeMachine\.([0-9]{4})-([0-9]{2})-([0-9]{2})-([0-9]{6}) ]]; then - if [[ "${BASH_REMATCH[0]}" != "$newest_name" ]]; then - if [[ "$DRY_RUN" == "true" ]]; then - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Local snapshot: $snap_name ${YELLOW}dry-run${NC}" - ((cleaned_count++)) - note_activity - else - if sudo tmutil deletelocalsnapshots "${BASH_REMATCH[1]}-${BASH_REMATCH[2]}-${BASH_REMATCH[3]}-${BASH_REMATCH[4]}" > /dev/null 2>&1; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Removed snapshot: $snap_name" - ((cleaned_count++)) - note_activity - else - echo -e " ${YELLOW}!${NC} Failed to remove: $snap_name" - fi - fi - fi - fi - done - if [[ $cleaned_count -gt 0 && "$DRY_RUN" != "true" ]]; then - log_success "Cleaned $cleaned_count local snapshots, kept latest" + local snapshot_count + snapshot_count=$(echo "$snapshot_list" | { grep -Eo 'com\.apple\.TimeMachine\.[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{6}' || true; } | wc -l | awk '{print $1}') + if [[ "$snapshot_count" =~ ^[0-9]+$ && "$snapshot_count" -gt 0 ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Time Machine local snapshots: ${GREEN}${snapshot_count}${NC}${GRAY}, Review: tmutil listlocalsnapshots /${NC}" + note_activity fi } diff --git a/lib/clean/user.sh b/lib/clean/user.sh index a951685..591bf19 100644 --- a/lib/clean/user.sh +++ b/lib/clean/user.sh @@ -7,11 +7,24 @@ clean_user_essentials() { stop_section_spinner safe_clean ~/Library/Logs/* "User app logs" - if is_path_whitelisted "$HOME/.Trash"; then - note_activity - echo -e " ${GREEN}${ICON_EMPTY}${NC} Trash · whitelist protected" - else - safe_clean ~/.Trash/* "Trash" + + if ! is_path_whitelisted "$HOME/.Trash"; then + local trash_count + trash_count=$(osascript -e 'tell application "Finder" to count items in trash' 2> /dev/null || echo "0") + [[ "$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 " ${GRAY}${ICON_EMPTY}${NC} Trash · already empty" + elif [[ $trash_count -gt 0 ]]; then + if 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 + else + safe_clean ~/.Trash/* "Trash" + fi + else + echo -e " ${GRAY}${ICON_EMPTY}${NC} Trash · already empty" + fi fi } @@ -24,7 +37,7 @@ clean_chrome_old_versions() { # Match the exact Chrome process name to avoid false positives if pgrep -x "Google Chrome" > /dev/null 2>&1; then - echo -e " ${YELLOW}${ICON_WARNING}${NC} Google Chrome running · old versions cleanup skipped" + echo -e " ${GRAY}${ICON_WARNING}${NC} Google Chrome running · old versions cleanup skipped" return 0 fi @@ -84,9 +97,9 @@ clean_chrome_old_versions() { local size_human size_human=$(bytes_to_human "$((total_size * 1024))") if [[ "$DRY_RUN" == "true" ]]; then - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Chrome old versions ${YELLOW}(${cleaned_count} dirs, $size_human dry)${NC}" + 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 ${GREEN}(${cleaned_count} dirs, $size_human)${NC}" + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Chrome old versions${NC}, ${GREEN}${cleaned_count} dirs, $size_human${NC}" fi ((files_cleaned += cleaned_count)) ((total_size_cleaned += total_size)) @@ -97,14 +110,20 @@ clean_chrome_old_versions() { # Remove old Microsoft Edge versions while keeping Current. clean_edge_old_versions() { - local -a app_paths=( - "/Applications/Microsoft Edge.app" - "$HOME/Applications/Microsoft Edge.app" - ) + # Allow override for testing + local -a app_paths + if [[ -n "${MOLE_EDGE_APP_PATHS:-}" ]]; then + IFS=':' read -ra app_paths <<< "$MOLE_EDGE_APP_PATHS" + else + app_paths=( + "/Applications/Microsoft Edge.app" + "$HOME/Applications/Microsoft Edge.app" + ) + fi # Match the exact Edge process name to avoid false positives (e.g., Microsoft Teams) if pgrep -x "Microsoft Edge" > /dev/null 2>&1; then - echo -e " ${YELLOW}${ICON_WARNING}${NC} Microsoft Edge running · old versions cleanup skipped" + echo -e " ${GRAY}${ICON_WARNING}${NC} Microsoft Edge running · old versions cleanup skipped" return 0 fi @@ -164,9 +183,9 @@ clean_edge_old_versions() { local size_human size_human=$(bytes_to_human "$((total_size * 1024))") if [[ "$DRY_RUN" == "true" ]]; then - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Edge old versions ${YELLOW}(${cleaned_count} dirs, $size_human dry)${NC}" + 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 ${GREEN}(${cleaned_count} dirs, $size_human)${NC}" + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Edge old versions${NC}, ${GREEN}${cleaned_count} dirs, $size_human${NC}" fi ((files_cleaned += cleaned_count)) ((total_size_cleaned += total_size)) @@ -181,7 +200,7 @@ clean_edge_updater_old_versions() { [[ -d "$updater_dir" ]] || return 0 if pgrep -x "Microsoft Edge" > /dev/null 2>&1; then - echo -e " ${YELLOW}${ICON_WARNING}${NC} Microsoft Edge running · updater cleanup skipped" + echo -e " ${GRAY}${ICON_WARNING}${NC} Microsoft Edge running · updater cleanup skipped" return 0 fi @@ -226,9 +245,9 @@ clean_edge_updater_old_versions() { local size_human size_human=$(bytes_to_human "$((total_size * 1024))") if [[ "$DRY_RUN" == "true" ]]; then - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Edge updater old versions ${YELLOW}(${cleaned_count} dirs, $size_human dry)${NC}" + 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 ${GREEN}(${cleaned_count} dirs, $size_human)${NC}" + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Edge updater old versions${NC}, ${GREEN}${cleaned_count} dirs, $size_human${NC}" fi ((files_cleaned += cleaned_count)) ((total_size_cleaned += total_size)) @@ -266,12 +285,12 @@ scan_external_volumes() { local network_count=${#network_volumes[@]} if [[ $volume_count -eq 0 ]]; then if [[ $network_count -gt 0 ]]; then - echo -e " ${GRAY}${ICON_LIST}${NC} External volumes (${network_count} network volume(s) skipped)" + echo -e " ${GRAY}${ICON_LIST}${NC} External volumes, ${network_count} network volumes skipped" note_activity fi return 0 fi - start_section_spinner "Scanning $volume_count external volume(s)..." + start_section_spinner "Scanning $volume_count external volumes..." for volume in "${candidate_volumes[@]}"; do [[ -d "$volume" && -r "$volume" ]] || continue local volume_trash="$volume/.Trashes" @@ -281,24 +300,20 @@ scan_external_volumes() { done < <(command find "$volume_trash" -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true) fi if [[ "$PROTECT_FINDER_METADATA" != "true" ]]; then - clean_ds_store_tree "$volume" "$(basename "$volume") volume (.DS_Store)" + clean_ds_store_tree "$volume" "$(basename "$volume") volume, .DS_Store" fi done stop_section_spinner } # Finder metadata (.DS_Store). clean_finder_metadata() { - stop_section_spinner if [[ "$PROTECT_FINDER_METADATA" == "true" ]]; then - note_activity - echo -e " ${GREEN}${ICON_EMPTY}${NC} Finder metadata · whitelist protected" return fi - clean_ds_store_tree "$HOME" "Home directory (.DS_Store)" + clean_ds_store_tree "$HOME" "Home directory, .DS_Store" } # macOS system caches and user-level leftovers. clean_macos_system_caches() { - stop_section_spinner # safe_clean already checks protected paths. safe_clean ~/Library/Saved\ Application\ State/* "Saved application states" || true safe_clean ~/Library/Caches/com.apple.photoanalysisd "Photo analysis cache" || true @@ -318,7 +333,6 @@ clean_macos_system_caches() { safe_clean ~/Library/Application\ Support/AddressBook/Sources/*/Photos.cache "Address Book photo cache" || true } clean_recent_items() { - stop_section_spinner local shared_dir="$HOME/Library/Application Support/com.apple.sharedfilelist" local -a recent_lists=( "$shared_dir/com.apple.LSSharedFileList.RecentApplications.sfl2" @@ -338,7 +352,6 @@ clean_recent_items() { safe_clean ~/Library/Preferences/com.apple.recentitems.plist "Recent items preferences" || true } clean_mail_downloads() { - stop_section_spinner local mail_age_days=${MOLE_MAIL_AGE_DAYS:-} if ! [[ "$mail_age_days" =~ ^[0-9]+$ ]]; then mail_age_days=30 @@ -376,7 +389,7 @@ clean_mail_downloads() { done if [[ $count -gt 0 ]]; then local cleaned_mb=$(echo "$cleaned_kb" | awk '{printf "%.1f", $1/1024}' || echo "0.0") - echo " ${GREEN}${ICON_SUCCESS}${NC} Cleaned $count mail attachments (~${cleaned_mb}MB)" + echo " ${GREEN}${ICON_SUCCESS}${NC} Cleaned $count mail attachments, about ${cleaned_mb}MB" note_activity fi } @@ -405,9 +418,9 @@ clean_sandboxed_app_caches() { if [[ "$found_any" == "true" ]]; then local size_human=$(bytes_to_human "$((total_size * 1024))") if [[ "$DRY_RUN" == "true" ]]; then - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Sandboxed app caches ${YELLOW}($size_human dry)${NC}" + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Sandboxed app caches${NC}, ${YELLOW}$size_human dry${NC}" else - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Sandboxed app caches ${GREEN}($size_human)${NC}" + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Sandboxed app caches${NC}, ${GREEN}$size_human${NC}" fi ((files_cleaned += cleaned_count)) ((total_size_cleaned += total_size)) @@ -449,13 +462,13 @@ process_container_cache() { } # Browser caches (Safari/Chrome/Edge/Firefox). clean_browsers() { - stop_section_spinner safe_clean ~/Library/Caches/com.apple.Safari/* "Safari cache" # Chrome/Chromium. safe_clean ~/Library/Caches/Google/Chrome/* "Chrome cache" 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/Caches/Chromium/* "Chromium cache" + safe_clean ~/.cache/puppeteer/* "Puppeteer browser cache" safe_clean ~/Library/Caches/com.microsoft.edgemac/* "Edge cache" safe_clean ~/Library/Caches/company.thebrowser.Browser/* "Arc cache" safe_clean ~/Library/Caches/company.thebrowser.dia/* "Dia cache" @@ -465,7 +478,7 @@ clean_browsers() { firefox_running=true fi if [[ "$firefox_running" == "true" ]]; then - echo -e " ${YELLOW}${ICON_WARNING}${NC} Firefox is running · cache cleanup skipped" + echo -e " ${GRAY}${ICON_WARNING}${NC} Firefox is running · cache cleanup skipped" else safe_clean ~/Library/Caches/Firefox/* "Firefox cache" fi @@ -475,7 +488,7 @@ clean_browsers() { safe_clean ~/Library/Caches/com.kagi.kagimacOS/* "Orion cache" safe_clean ~/Library/Caches/zen/* "Zen cache" if [[ "$firefox_running" == "true" ]]; then - echo -e " ${YELLOW}${ICON_WARNING}${NC} Firefox is running · profile cache cleanup skipped" + echo -e " ${GRAY}${ICON_WARNING}${NC} Firefox is running · profile cache cleanup skipped" else safe_clean ~/Library/Application\ Support/Firefox/Profiles/*/cache2/* "Firefox profile cache" fi @@ -485,7 +498,6 @@ clean_browsers() { } # Cloud storage caches. clean_cloud_storage() { - stop_section_spinner safe_clean ~/Library/Caches/com.dropbox.* "Dropbox cache" safe_clean ~/Library/Caches/com.getdropbox.dropbox "Dropbox cache" safe_clean ~/Library/Caches/com.google.GoogleDrive "Google Drive cache" @@ -496,7 +508,6 @@ clean_cloud_storage() { } # Office app caches. clean_office_applications() { - stop_section_spinner safe_clean ~/Library/Caches/com.microsoft.Word "Microsoft Word cache" safe_clean ~/Library/Caches/com.microsoft.Excel "Microsoft Excel cache" safe_clean ~/Library/Caches/com.microsoft.Powerpoint "Microsoft PowerPoint cache" @@ -516,10 +527,9 @@ clean_virtualization_tools() { } # Application Support logs/caches. clean_application_support_logs() { - stop_section_spinner if [[ ! -d "$HOME/Library/Application Support" ]] || ! ls "$HOME/Library/Application Support" > /dev/null 2>&1; then note_activity - echo -e " ${YELLOW}${ICON_WARNING}${NC} Skipped: No permission to access Application Support" + echo -e " ${GRAY}${ICON_WARNING}${NC} Skipped: No permission to access Application Support" return 0 fi start_section_spinner "Scanning Application Support..." @@ -593,9 +603,9 @@ clean_application_support_logs() { if [[ "$found_any" == "true" ]]; then local size_human=$(bytes_to_human "$((total_size * 1024))") if [[ "$DRY_RUN" == "true" ]]; then - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Application Support logs/caches ${YELLOW}($size_human dry)${NC}" + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Application Support logs/caches${NC}, ${YELLOW}$size_human dry${NC}" else - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Application Support logs/caches ${GREEN}($size_human)${NC}" + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Application Support logs/caches${NC}, ${GREEN}$size_human${NC}" fi ((files_cleaned += cleaned_count)) ((total_size_cleaned += total_size)) @@ -613,13 +623,107 @@ check_ios_device_backups() { local backup_human=$(command du -sh "$backup_dir" 2> /dev/null | awk '{print $1}') if [[ -n "$backup_human" ]]; then note_activity - echo -e " Found ${GREEN}${backup_human}${NC} iOS backups" - echo -e " You can delete them manually: ${backup_dir}" + echo -e " ${YELLOW}${ICON_WARNING}${NC} iOS backups: ${GREEN}${backup_human}${NC}${GRAY}, Path: $backup_dir${NC}" fi fi fi return 0 } + +# Large file candidates (report only, no deletion). +check_large_file_candidates() { + local threshold_kb=$((1024 * 1024)) # 1GB + local found_any=false + + local mail_dir="$HOME/Library/Mail" + if [[ -d "$mail_dir" ]]; then + local mail_kb + mail_kb=$(get_path_size_kb "$mail_dir") + if [[ "$mail_kb" -ge "$threshold_kb" ]]; then + local mail_human + mail_human=$(bytes_to_human "$((mail_kb * 1024))") + echo -e " ${YELLOW}${ICON_WARNING}${NC} Mail data: ${GREEN}${mail_human}${NC}${GRAY}, Path: $mail_dir${NC}" + found_any=true + fi + fi + + local mail_downloads="$HOME/Library/Mail Downloads" + if [[ -d "$mail_downloads" ]]; then + local downloads_kb + downloads_kb=$(get_path_size_kb "$mail_downloads") + if [[ "$downloads_kb" -ge "$threshold_kb" ]]; then + local downloads_human + downloads_human=$(bytes_to_human "$((downloads_kb * 1024))") + echo -e " ${YELLOW}${ICON_WARNING}${NC} Mail downloads: ${GREEN}${downloads_human}${NC}${GRAY}, Path: $mail_downloads${NC}" + found_any=true + fi + fi + + local installer_path + for installer_path in /Applications/Install\ macOS*.app; do + if [[ -e "$installer_path" ]]; then + local installer_kb + installer_kb=$(get_path_size_kb "$installer_path") + if [[ "$installer_kb" -gt 0 ]]; then + local installer_human + installer_human=$(bytes_to_human "$((installer_kb * 1024))") + echo -e " ${YELLOW}${ICON_WARNING}${NC} macOS installer: ${GREEN}${installer_human}${NC}${GRAY}, Path: $installer_path${NC}" + found_any=true + fi + fi + done + + local updates_dir="$HOME/Library/Updates" + if [[ -d "$updates_dir" ]]; then + local updates_kb + updates_kb=$(get_path_size_kb "$updates_dir") + if [[ "$updates_kb" -ge "$threshold_kb" ]]; then + local updates_human + updates_human=$(bytes_to_human "$((updates_kb * 1024))") + echo -e " ${YELLOW}${ICON_WARNING}${NC} macOS updates cache: ${GREEN}${updates_human}${NC}${GRAY}, Path: $updates_dir${NC}" + found_any=true + fi + fi + + if [[ "${SYSTEM_CLEAN:-false}" != "true" ]] && command -v tmutil > /dev/null 2>&1; then + local snapshot_list snapshot_count + snapshot_list=$(run_with_timeout 3 tmutil listlocalsnapshots / 2> /dev/null || true) + if [[ -n "$snapshot_list" ]]; then + snapshot_count=$(echo "$snapshot_list" | { grep -Eo 'com\.apple\.TimeMachine\.[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{6}' || true; } | wc -l | awk '{print $1}') + if [[ "$snapshot_count" =~ ^[0-9]+$ && "$snapshot_count" -gt 0 ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Time Machine local snapshots: ${GREEN}${snapshot_count}${NC}${GRAY}, Review: tmutil listlocalsnapshots /${NC}" + found_any=true + fi + fi + fi + + if command -v docker > /dev/null 2>&1; then + local docker_output + docker_output=$(run_with_timeout 3 docker system df --format '{{.Type}}\t{{.Size}}\t{{.Reclaimable}}' 2> /dev/null || true) + if [[ -n "$docker_output" ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Docker storage:" + while IFS=$'\t' read -r dtype dsize dreclaim; do + [[ -z "$dtype" ]] && continue + echo -e " ${GRAY}• $dtype: $dsize, Reclaimable: $dreclaim${NC}" + done <<< "$docker_output" + found_any=true + else + docker_output=$(run_with_timeout 3 docker system df 2> /dev/null || true) + if [[ -n "$docker_output" ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Docker storage:" + echo -e " ${GRAY}• Run: docker system df${NC}" + found_any=true + fi + fi + fi + + if [[ "$found_any" == "false" ]]; then + echo -e " ${GREEN}${ICON_SUCCESS}${NC} No large items detected in common locations" + fi + + note_activity + return 0 +} # Apple Silicon specific caches (IS_M_SERIES). clean_apple_silicon_caches() { if [[ "${IS_M_SERIES:-false}" != "true" ]]; then diff --git a/lib/core/app_protection.sh b/lib/core/app_protection.sh index 90bfdb9..446a478 100755 --- a/lib/core/app_protection.sh +++ b/lib/core/app_protection.sh @@ -493,6 +493,9 @@ should_protect_data() { # Check if a path is protected from deletion # Centralized logic to protect system settings, control center, and critical apps # +# In uninstall mode (MOLE_UNINSTALL_MODE=1), only system-critical components are protected. +# Data-protected apps (VPNs, dev tools, etc.) can be uninstalled when user explicitly chooses to. +# # Args: $1 - path to check # Returns: 0 if protected, 1 if safe to delete should_protect_path() { @@ -577,17 +580,31 @@ should_protect_path() { # 6. Match full path against protected patterns # This catches things like /Users/tw93/Library/Caches/Claude when pattern is *Claude* - for pattern in "${SYSTEM_CRITICAL_BUNDLES[@]}" "${DATA_PROTECTED_BUNDLES[@]}"; do - if bundle_matches_pattern "$path" "$pattern"; then - return 0 - fi - done + # In uninstall mode, only check system-critical bundles (user explicitly chose to uninstall) + if [[ "${MOLE_UNINSTALL_MODE:-0}" == "1" ]]; then + # Uninstall mode: only protect system-critical components + for pattern in "${SYSTEM_CRITICAL_BUNDLES[@]}"; do + if bundle_matches_pattern "$path" "$pattern"; then + return 0 + fi + done + else + # Normal mode (cleanup): protect both system-critical and data-protected bundles + for pattern in "${SYSTEM_CRITICAL_BUNDLES[@]}" "${DATA_PROTECTED_BUNDLES[@]}"; do + if bundle_matches_pattern "$path" "$pattern"; then + return 0 + fi + done + fi # 7. Check if the filename itself matches any protected patterns - local filename - filename=$(basename "$path") - if should_protect_data "$filename"; then - return 0 + # Skip in uninstall mode - user explicitly chose to remove this app + if [[ "${MOLE_UNINSTALL_MODE:-0}" != "1" ]]; then + local filename + filename=$(basename "$path") + if should_protect_data "$filename"; then + return 0 + fi fi return 1 @@ -643,6 +660,14 @@ is_path_whitelisted() { find_app_files() { local bundle_id="$1" local app_name="$2" + + # Early validation: require at least one valid identifier + # Skip scanning if both bundle_id and app_name are invalid + if [[ -z "$bundle_id" || "$bundle_id" == "unknown" ]] && + [[ -z "$app_name" || ${#app_name} -lt 2 ]]; then + return 0 # Silent return to avoid invalid scanning + fi + local -a files_to_clean=() # Normalize app name for matching @@ -665,7 +690,6 @@ find_app_files() { "$HOME/Library/HTTPStorages/$bundle_id" "$HOME/Library/Cookies/$bundle_id.binarycookies" "$HOME/Library/LaunchAgents/$bundle_id.plist" - "$HOME/Library/LaunchDaemons/$bundle_id.plist" "$HOME/Library/Application Scripts/$bundle_id" "$HOME/Library/Services/$app_name.workflow" "$HOME/Library/QuickLook/$app_name.qlgenerator" @@ -740,17 +764,26 @@ find_app_files() { fi fi - # Launch Agents and Daemons by name (special handling) - if [[ ${#app_name} -gt 3 ]]; then - if [[ -d ~/Library/LaunchAgents ]]; then + # Launch Agents by name (special handling) + # Note: LaunchDaemons are system-level and handled in find_app_system_files() + # Minimum 5-char threshold prevents false positives (e.g., "Time" matching system agents) + # Short-name apps (e.g., Zoom, Arc) are still cleaned via bundle_id matching above + # Security: Common words are excluded to prevent matching unrelated plist files + if [[ ${#app_name} -ge 5 ]] && [[ -d ~/Library/LaunchAgents ]]; then + # Skip common words that could match many unrelated LaunchAgents + # These are either generic terms or names that overlap with system/common utilities + local common_words="Music|Notes|Photos|Finder|Safari|Preview|Calendar|Contacts|Messages|Reminders|Clock|Weather|Stocks|Books|News|Podcasts|Voice|Files|Store|System|Helper|Agent|Daemon|Service|Update|Sync|Backup|Cloud|Manager|Monitor|Server|Client|Worker|Runner|Launcher|Driver|Plugin|Extension|Widget|Utility" + if [[ "$app_name" =~ ^($common_words)$ ]]; then + debug_log "Skipping LaunchAgent name search for common word: $app_name" + else while IFS= read -r -d '' plist; do + local plist_name=$(basename "$plist") + # Skip Apple's LaunchAgents + if [[ "$plist_name" =~ ^com\.apple\. ]]; then + continue + fi files_to_clean+=("$plist") - done < <(command find ~/Library/LaunchAgents -maxdepth 1 \( -name "*$app_name*.plist" \) -print0 2> /dev/null) - fi - if [[ -d ~/Library/LaunchDaemons ]]; then - while IFS= read -r -d '' plist; do - files_to_clean+=("$plist") - done < <(command find ~/Library/LaunchDaemons -maxdepth 1 \( -name "*$app_name*.plist" \) -print0 2> /dev/null) + done < <(command find ~/Library/LaunchAgents -maxdepth 1 -name "*$app_name*.plist" -print0 2> /dev/null) fi fi @@ -764,7 +797,7 @@ find_app_files() { # 2. Android Studio (Google) if [[ "$app_name" =~ Android.*Studio|android.*studio ]] || [[ "$bundle_id" =~ google.*android.*studio|jetbrains.*android ]]; then - for d in ~/AndroidStudioProjects ~/Library/Android ~/.android ~/.gradle; do + for d in ~/AndroidStudioProjects ~/Library/Android ~/.android; do [[ -d "$d" ]] && files_to_clean+=("$d") done [[ -d ~/Library/Application\ Support/Google ]] && while IFS= read -r -d '' d; do files_to_clean+=("$d"); done < <(command find ~/Library/Application\ Support/Google -maxdepth 1 -name "AndroidStudio*" -print0 2> /dev/null) @@ -904,6 +937,13 @@ find_app_receipt_files() { # Skip if no bundle ID [[ -z "$bundle_id" || "$bundle_id" == "unknown" ]] && return 0 + # Validate bundle_id format to prevent wildcard injection + # Only allow alphanumeric characters, dots, hyphens, and underscores + if [[ ! "$bundle_id" =~ ^[a-zA-Z0-9._-]+$ ]]; then + debug_log "Invalid bundle_id format: $bundle_id" + return 0 + fi + local -a receipt_files=() local -a bom_files=() @@ -935,6 +975,15 @@ find_app_receipt_files() { clean_path="/$clean_path" fi + # Path traversal protection: reject paths containing .. + if [[ "$clean_path" =~ \.\. ]]; then + debug_log "Rejected path traversal in BOM: $clean_path" + continue + fi + + # Normalize path (remove duplicate slashes) + clean_path=$(tr -s "/" <<< "$clean_path") + # ------------------------------------------------------------------------ # Safety check: restrict removal to trusted paths # ------------------------------------------------------------------------ diff --git a/lib/core/base.sh b/lib/core/base.sh index 99139ee..3c9e4c0 100644 --- a/lib/core/base.sh +++ b/lib/core/base.sh @@ -31,7 +31,7 @@ readonly ICON_CONFIRM="◎" readonly ICON_ADMIN="⚙" readonly ICON_SUCCESS="✓" readonly ICON_ERROR="☻" -readonly ICON_WARNING="●" +readonly ICON_WARNING="◎" readonly ICON_EMPTY="○" readonly ICON_SOLID="●" readonly ICON_LIST="•" @@ -687,7 +687,7 @@ update_progress_if_needed() { if [[ $((current_time - last_time)) -ge $interval ]]; then # Update the spinner with progress stop_section_spinner - start_section_spinner "Scanning items... ($completed/$total)" + start_section_spinner "Scanning items... $completed/$total" # Update the last_update_time variable eval "$last_update_var=$current_time" @@ -717,7 +717,7 @@ push_spinner_state() { fi MOLE_SPINNER_STACK+=("$current_state") - debug_log "Pushed spinner state: $current_state (stack depth: ${#MOLE_SPINNER_STACK[@]})" + debug_log "Pushed spinner state: $current_state, stack depth: ${#MOLE_SPINNER_STACK[@]}" } # Pop and restore spinner state from stack @@ -730,7 +730,7 @@ pop_spinner_state() { # Stack depth safety check if [[ ${#MOLE_SPINNER_STACK[@]} -gt 10 ]]; then - debug_log "Warning: Spinner stack depth excessive (${#MOLE_SPINNER_STACK[@]}), possible leak" + debug_log "Warning: Spinner stack depth excessive, ${#MOLE_SPINNER_STACK[@]}, possible leak" fi local last_idx=$((${#MOLE_SPINNER_STACK[@]} - 1)) @@ -745,7 +745,7 @@ pop_spinner_state() { done MOLE_SPINNER_STACK=("${new_stack[@]}") - debug_log "Popped spinner state: $state (remaining depth: ${#MOLE_SPINNER_STACK[@]})" + debug_log "Popped spinner state: $state, remaining depth: ${#MOLE_SPINNER_STACK[@]}" # Restore state if needed if [[ "$state" == running:* ]]; then @@ -822,7 +822,7 @@ get_terminal_info() { local info="Terminal: ${TERM:-unknown}" if is_ansi_supported; then - info+=" (ANSI supported)" + info+=", ANSI supported" if command -v tput > /dev/null 2>&1; then local cols=$(tput cols 2> /dev/null || echo "?") @@ -831,7 +831,7 @@ get_terminal_info() { info+=" ${cols}x${lines}, ${colors} colors" fi else - info+=" (ANSI not supported)" + info+=", ANSI not supported" fi echo "$info" @@ -852,11 +852,11 @@ validate_terminal_environment() { # Check if running in a known problematic terminal case "${TERM:-}" in dumb) - log_warning "Running in 'dumb' terminal - limited functionality" + log_warning "Running in 'dumb' terminal, limited functionality" ((warnings++)) ;; unknown) - log_warning "Terminal type unknown - may have display issues" + log_warning "Terminal type unknown, may have display issues" ((warnings++)) ;; esac @@ -865,7 +865,7 @@ validate_terminal_environment() { if command -v tput > /dev/null 2>&1; then local cols=$(tput cols 2> /dev/null || echo "80") if [[ "$cols" -lt 60 ]]; then - log_warning "Terminal width ($cols cols) is narrow - output may wrap" + log_warning "Terminal width, $cols cols, is narrow, output may wrap" ((warnings++)) fi fi diff --git a/lib/core/common.sh b/lib/core/common.sh index a507aa2..488f7f2 100755 --- a/lib/core/common.sh +++ b/lib/core/common.sh @@ -79,8 +79,9 @@ update_via_homebrew() { if echo "$upgrade_output" | grep -q "already installed"; then local installed_version installed_version=$(brew list --versions mole 2> /dev/null | awk '{print $2}') + [[ -z "$installed_version" ]] && installed_version=$(mo --version 2> /dev/null | awk '/Mole version/ {print $3; exit}') echo "" - echo -e "${GREEN}${ICON_SUCCESS}${NC} Already on latest version (${installed_version:-$current_version})" + echo -e "${GREEN}${ICON_SUCCESS}${NC} Already on latest version, ${installed_version:-$current_version}" echo "" elif echo "$upgrade_output" | grep -q "Error:"; then log_error "Homebrew upgrade failed" @@ -90,8 +91,9 @@ update_via_homebrew() { echo "$upgrade_output" | grep -Ev "^(==>|Updating Homebrew|Warning:)" || true local new_version new_version=$(brew list --versions mole 2> /dev/null | awk '{print $2}') + [[ -z "$new_version" ]] && new_version=$(mo --version 2> /dev/null | awk '/Mole version/ {print $3; exit}') echo "" - echo -e "${GREEN}${ICON_SUCCESS}${NC} Updated to latest version (${new_version:-$current_version})" + echo -e "${GREEN}${ICON_SUCCESS}${NC} Updated to latest version, ${new_version:-$current_version}" echo "" fi @@ -125,24 +127,29 @@ remove_apps_from_dock() { local changed=false for target in "${targets[@]}"; do local app_path="$target" - # Normalize path for comparison - use original path if app already deleted - local full_path - if full_path=$(cd "$(dirname "$app_path")" 2> /dev/null && pwd); then - full_path="$full_path/$(basename "$app_path")" - else - # App already deleted - use the original path as-is - # Remove ~/ prefix and expand to full path if needed - if [[ "$app_path" == ~/* ]]; then - full_path="$HOME/${app_path#~/}" - elif [[ "$app_path" != /* ]]; then - # Relative path - skip this entry - continue - else - full_path="$app_path" - fi + local full_path="" + + if [[ "$app_path" =~ [[:cntrl:]] ]]; then + debug_log "Skipping dock removal for path with control chars: $app_path" + continue fi - # URL-encode the path for matching against Dock URLs (spaces -> %20) + if [[ -e "$app_path" ]]; then + if full_path=$(cd "$(dirname "$app_path")" 2> /dev/null && pwd); then + full_path="$full_path/$(basename "$app_path")" + else + continue + fi + else + case "$app_path" in + ~/*) full_path="$HOME/${app_path#~/}" ;; + /*) full_path="$app_path" ;; + *) continue ;; + esac + fi + + [[ -z "$full_path" ]] && continue + local encoded_path="${full_path// /%20}" # Find the index of the app in persistent-apps diff --git a/lib/core/file_ops.sh b/lib/core/file_ops.sh index 9fdd65f..33a9126 100644 --- a/lib/core/file_ops.sh +++ b/lib/core/file_ops.sh @@ -39,6 +39,33 @@ validate_path_for_deletion() { return 1 fi + # Check symlink target if path is a symbolic link + if [[ -L "$path" ]]; then + local link_target + link_target=$(readlink "$path" 2> /dev/null) || { + log_error "Cannot read symlink: $path" + return 1 + } + + # Resolve relative symlinks to absolute paths for validation + local resolved_target="$link_target" + if [[ "$link_target" != /* ]]; then + local link_dir + link_dir=$(dirname "$path") + resolved_target=$(cd "$link_dir" 2> /dev/null && cd "$(dirname "$link_target")" 2> /dev/null && pwd)/$(basename "$link_target") || resolved_target="" + fi + + # Validate resolved target against protected paths + if [[ -n "$resolved_target" ]]; then + case "$resolved_target" in + /System/* | /usr/bin/* | /usr/lib/* | /bin/* | /sbin/* | /private/etc/*) + log_error "Symlink points to protected system path: $path -> $resolved_target" + return 1 + ;; + esac + fi + fi + # Check path is absolute if [[ "$path" != /* ]]; then log_error "Path validation failed: path must be absolute: $path" @@ -75,7 +102,8 @@ validate_path_for_deletion() { /private/var/db/diagnostics | /private/var/db/diagnostics/* | \ /private/var/db/DiagnosticPipeline | /private/var/db/DiagnosticPipeline/* | \ /private/var/db/powerlog | /private/var/db/powerlog/* | \ - /private/var/db/reportmemoryexception | /private/var/db/reportmemoryexception/*) + /private/var/db/reportmemoryexception | /private/var/db/reportmemoryexception/* | \ + /private/var/db/receipts/*.bom | /private/var/db/receipts/*.plist) return 0 ;; esac @@ -169,6 +197,18 @@ safe_remove() { debug_log "Removing: $path" + # Calculate size before deletion for logging + local size_kb=0 + local size_human="" + if oplog_enabled; then + if [[ -e "$path" ]]; then + size_kb=$(get_path_size_kb "$path" 2> /dev/null || echo "0") + if [[ "$size_kb" =~ ^[0-9]+$ ]] && [[ "$size_kb" -gt 0 ]]; then + size_human=$(bytes_to_human "$((size_kb * 1024))" 2> /dev/null || echo "${size_kb}KB") + fi + fi + fi + # Perform the deletion # Use || to capture the exit code so set -e won't abort on rm failures local error_msg @@ -176,6 +216,8 @@ safe_remove() { error_msg=$(rm -rf "$path" 2>&1) || rm_exit=$? # safe_remove if [[ $rm_exit -eq 0 ]]; then + # Log successful removal + log_operation "${MOLE_CURRENT_COMMAND:-clean}" "REMOVED" "$path" "$size_human" return 0 else # Check if it's a permission error @@ -183,9 +225,11 @@ safe_remove() { MOLE_PERMISSION_DENIED_COUNT=${MOLE_PERMISSION_DENIED_COUNT:-0} MOLE_PERMISSION_DENIED_COUNT=$((MOLE_PERMISSION_DENIED_COUNT + 1)) export MOLE_PERMISSION_DENIED_COUNT - debug_log "Permission denied: $path (may need Full Disk Access)" + debug_log "Permission denied: $path, may need Full Disk Access" + log_operation "${MOLE_CURRENT_COMMAND:-clean}" "FAILED" "$path" "permission denied" else [[ "$silent" != "true" ]] && log_error "Failed to remove: $path" + log_operation "${MOLE_CURRENT_COMMAND:-clean}" "FAILED" "$path" "error" fi return 1 fi @@ -239,20 +283,34 @@ safe_sudo_remove() { fi fi - debug_file_action "[DRY RUN] Would remove (sudo)" "$path" "$file_size" "$file_age" + debug_file_action "[DRY RUN] Would remove, sudo" "$path" "$file_size" "$file_age" else - debug_log "[DRY RUN] Would remove (sudo): $path" + debug_log "[DRY RUN] Would remove, sudo: $path" fi return 0 fi - debug_log "Removing (sudo): $path" + debug_log "Removing, sudo: $path" + + # Calculate size before deletion for logging + local size_kb=0 + local size_human="" + if oplog_enabled; then + if sudo test -e "$path" 2> /dev/null; then + size_kb=$(sudo du -sk "$path" 2> /dev/null | awk '{print $1}' || echo "0") + if [[ "$size_kb" =~ ^[0-9]+$ ]] && [[ "$size_kb" -gt 0 ]]; then + size_human=$(bytes_to_human "$((size_kb * 1024))" 2> /dev/null || echo "${size_kb}KB") + fi + fi + fi # Perform the deletion if sudo rm -rf "$path" 2> /dev/null; then # SAFE: safe_sudo_remove implementation + log_operation "${MOLE_CURRENT_COMMAND:-clean}" "REMOVED" "$path" "$size_human" return 0 else - log_error "Failed to remove (sudo): $path" + log_error "Failed to remove, sudo: $path" + log_operation "${MOLE_CURRENT_COMMAND:-clean}" "FAILED" "$path" "sudo error" return 1 fi } @@ -281,11 +339,11 @@ safe_find_delete() { # Validate type filter if [[ "$type_filter" != "f" && "$type_filter" != "d" ]]; then - log_error "Invalid type filter: $type_filter (must be 'f' or 'd')" + log_error "Invalid type filter: $type_filter, must be 'f' or 'd'" return 1 fi - debug_log "Finding in $base_dir: $pattern (age: ${age_days}d, type: $type_filter)" + debug_log "Finding in $base_dir: $pattern, age: ${age_days}d, type: $type_filter" local find_args=("-maxdepth" "5" "-name" "$pattern" "-type" "$type_filter") if [[ "$age_days" -gt 0 ]]; then @@ -312,7 +370,7 @@ safe_sudo_find_delete() { # Validate base directory (use sudo for permission-restricted dirs) if ! sudo test -d "$base_dir" 2> /dev/null; then - debug_log "Directory does not exist (skipping): $base_dir" + debug_log "Directory does not exist, skipping: $base_dir" return 0 fi @@ -323,11 +381,11 @@ safe_sudo_find_delete() { # Validate type filter if [[ "$type_filter" != "f" && "$type_filter" != "d" ]]; then - log_error "Invalid type filter: $type_filter (must be 'f' or 'd')" + log_error "Invalid type filter: $type_filter, must be 'f' or 'd'" return 1 fi - debug_log "Finding (sudo) in $base_dir: $pattern (age: ${age_days}d, type: $type_filter)" + debug_log "Finding, sudo, in $base_dir: $pattern, age: ${age_days}d, type: $type_filter" local find_args=("-maxdepth" "5" "-name" "$pattern" "-type" "$type_filter") if [[ "$age_days" -gt 0 ]]; then diff --git a/lib/core/log.sh b/lib/core/log.sh index d9dca13..cc933cd 100644 --- a/lib/core/log.sh +++ b/lib/core/log.sh @@ -23,10 +23,15 @@ fi readonly LOG_FILE="${HOME}/.config/mole/mole.log" readonly DEBUG_LOG_FILE="${HOME}/.config/mole/mole_debug_session.log" -readonly LOG_MAX_SIZE_DEFAULT=1048576 # 1MB +readonly OPERATIONS_LOG_FILE="${HOME}/.config/mole/operations.log" +readonly LOG_MAX_SIZE_DEFAULT=1048576 # 1MB +readonly OPLOG_MAX_SIZE_DEFAULT=5242880 # 5MB # Ensure log directory and file exist with correct ownership ensure_user_file "$LOG_FILE" +if [[ "${MO_NO_OPLOG:-}" != "1" ]]; then + ensure_user_file "$OPERATIONS_LOG_FILE" +fi # ============================================================================ # Log Rotation @@ -43,6 +48,15 @@ rotate_log_once() { mv "$LOG_FILE" "${LOG_FILE}.old" 2> /dev/null || true ensure_user_file "$LOG_FILE" fi + + # Rotate operations log (5MB limit) + if [[ "${MO_NO_OPLOG:-}" != "1" ]]; then + local oplog_max_size="$OPLOG_MAX_SIZE_DEFAULT" + if [[ -f "$OPERATIONS_LOG_FILE" ]] && [[ $(get_file_size "$OPERATIONS_LOG_FILE") -gt "$oplog_max_size" ]]; then + mv "$OPERATIONS_LOG_FILE" "${OPERATIONS_LOG_FILE}.old" 2> /dev/null || true + ensure_user_file "$OPERATIONS_LOG_FILE" + fi + fi } # ============================================================================ @@ -97,6 +111,80 @@ debug_log() { fi } +# ============================================================================ +# Operation Logging (Enabled by default) +# ============================================================================ +# Records all file operations for user troubleshooting +# Disable with MO_NO_OPLOG=1 + +oplog_enabled() { + [[ "${MO_NO_OPLOG:-}" != "1" ]] +} + +# Log an operation to the operations log file +# Usage: log_operation [detail] +# Example: log_operation "clean" "REMOVED" "/path/to/file" "15.2MB" +# Example: log_operation "clean" "SKIPPED" "/path/to/file" "whitelist" +# Example: log_operation "uninstall" "REMOVED" "/Applications/App.app" "150MB" +log_operation() { + # Allow disabling via environment variable + oplog_enabled || return 0 + + local command="${1:-unknown}" # clean/uninstall/optimize/purge + local action="${2:-UNKNOWN}" # REMOVED/SKIPPED/FAILED/REBUILT + local path="${3:-}" + local detail="${4:-}" + + # Skip if no path provided + [[ -z "$path" ]] && return 0 + + local timestamp + timestamp=$(date '+%Y-%m-%d %H:%M:%S') + + local log_line="[$timestamp] [$command] $action $path" + [[ -n "$detail" ]] && log_line+=" ($detail)" + + echo "$log_line" >> "$OPERATIONS_LOG_FILE" 2> /dev/null || true +} + +# Log session start marker +# Usage: log_operation_session_start +log_operation_session_start() { + oplog_enabled || return 0 + + local command="${1:-mole}" + local timestamp + timestamp=$(date '+%Y-%m-%d %H:%M:%S') + + { + echo "" + echo "# ========== $command session started at $timestamp ==========" + } >> "$OPERATIONS_LOG_FILE" 2> /dev/null || true +} + +# Log session end with summary +# Usage: log_operation_session_end +log_operation_session_end() { + oplog_enabled || return 0 + + local command="${1:-mole}" + local items="${2:-0}" + local size="${3:-0}" + local timestamp + timestamp=$(date '+%Y-%m-%d %H:%M:%S') + + local size_human="" + if [[ "$size" =~ ^[0-9]+$ ]] && [[ "$size" -gt 0 ]]; then + size_human=$(bytes_to_human "$((size * 1024))" 2> /dev/null || echo "${size}KB") + else + size_human="0B" + fi + + { + echo "# ========== $command session ended at $timestamp, $items items, $size_human ==========" + } >> "$OPERATIONS_LOG_FILE" 2> /dev/null || true +} + # Enhanced debug logging for operations debug_operation_start() { local operation_name="$1" @@ -138,10 +226,9 @@ debug_file_action() { local file_age="${4:-}" if [[ "${MO_DEBUG:-}" == "1" ]]; then - local msg=" - $file_path" - [[ -n "$file_size" ]] && msg+=" ($file_size" + local msg=" * $file_path" + [[ -n "$file_size" ]] && msg+=", $file_size" [[ -n "$file_age" ]] && msg+=", ${file_age} days old" - [[ -n "$file_size" ]] && msg+=")" # Output to stderr echo -e "${GRAY}[DEBUG] $action: $msg${NC}" >&2 @@ -165,10 +252,10 @@ debug_risk_level() { esac # Output to stderr with color - echo -e "${GRAY}[DEBUG] Risk Level: ${color}${risk_level}${GRAY} ($reason)${NC}" >&2 + echo -e "${GRAY}[DEBUG] Risk Level: ${color}${risk_level}${GRAY}, $reason${NC}" >&2 # Also log to file - echo "Risk Level: $risk_level ($reason)" >> "$DEBUG_LOG_FILE" 2> /dev/null || true + echo "Risk Level: $risk_level, $reason" >> "$DEBUG_LOG_FILE" 2> /dev/null || true fi } @@ -180,21 +267,23 @@ log_system_info() { # Reset debug log file for this new session ensure_user_file "$DEBUG_LOG_FILE" - : > "$DEBUG_LOG_FILE" + if ! : > "$DEBUG_LOG_FILE" 2> /dev/null; then + echo -e "${YELLOW}${ICON_WARNING}${NC} Debug log not writable: $DEBUG_LOG_FILE" >&2 + fi # Start block in debug log file { echo "----------------------------------------------------------------------" - echo "Mole Debug Session - $(date '+%Y-%m-%d %H:%M:%S')" + echo "Mole Debug Session, $(date '+%Y-%m-%d %H:%M:%S')" echo "----------------------------------------------------------------------" echo "User: $USER" echo "Hostname: $(hostname)" echo "Architecture: $(uname -m)" echo "Kernel: $(uname -r)" if command -v sw_vers > /dev/null; then - echo "macOS: $(sw_vers -productVersion) ($(sw_vers -buildVersion))" + echo "macOS: $(sw_vers -productVersion), $(sw_vers -buildVersion)" fi - echo "Shell: ${SHELL:-unknown} (${TERM:-unknown})" + echo "Shell: ${SHELL:-unknown}, ${TERM:-unknown}" # Check sudo status non-interactively if sudo -n true 2> /dev/null; then diff --git a/lib/core/sudo.sh b/lib/core/sudo.sh index 31011c6..d7de287 100644 --- a/lib/core/sudo.sh +++ b/lib/core/sudo.sh @@ -60,7 +60,7 @@ _request_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" + echo -e "${GRAY}Note: Touch ID dialog may appear once more, just cancel it${NC}" > "$tty_path" show_hint=false fi @@ -78,7 +78,7 @@ _request_password() { unset password ((attempts++)) if [[ $attempts -lt 3 ]]; then - echo -e "${YELLOW}${ICON_WARNING}${NC} Password cannot be empty" > "$tty_path" + echo -e "${GRAY}${ICON_WARNING}${NC} Password cannot be empty" > "$tty_path" fi continue fi @@ -93,7 +93,7 @@ _request_password() { unset password ((attempts++)) if [[ $attempts -lt 3 ]]; then - echo -e "${YELLOW}${ICON_WARNING}${NC} Incorrect password, try again" > "$tty_path" + echo -e "${GRAY}${ICON_WARNING}${NC} Incorrect password, try again" > "$tty_path" fi done @@ -143,7 +143,7 @@ request_sudo_access() { fi # Touch ID is available and not in clamshell mode - echo -e "${PURPLE}${ICON_ARROW}${NC} ${prompt_msg} ${GRAY}(Touch ID or password)${NC}" + echo -e "${PURPLE}${ICON_ARROW}${NC} ${prompt_msg} ${GRAY}, Touch ID or password${NC}" # Start sudo in background so we can monitor and control it sudo -v < /dev/null > /dev/null 2>&1 & diff --git a/lib/core/timeout.sh b/lib/core/timeout.sh index 5ff9576..ae9779a 100644 --- a/lib/core/timeout.sh +++ b/lib/core/timeout.sh @@ -100,7 +100,7 @@ run_with_timeout() { # ======================================================================== if [[ "${MO_DEBUG:-0}" == "1" ]]; then - echo "[TIMEOUT] Shell fallback (${duration}s): $*" >&2 + echo "[TIMEOUT] Shell fallback, ${duration}s: $*" >&2 fi # Start command in background diff --git a/lib/core/ui.sh b/lib/core/ui.sh index d27f231..ef44d92 100755 --- a/lib/core/ui.sh +++ b/lib/core/ui.sh @@ -220,7 +220,6 @@ read_key() { case "$key" in $'\n' | $'\r') echo "ENTER" ;; ' ') echo "SPACE" ;; - '/') echo "FILTER" ;; 'q' | 'Q') echo "QUIT" ;; 'R') echo "RETRY" ;; 'm' | 'M') echo "MORE" ;; @@ -308,7 +307,7 @@ start_inline_spinner() { # Output to stderr to avoid interfering with stdout printf "\r${MOLE_SPINNER_PREFIX:-}${BLUE}%s${NC} %s" "$c" "$message" >&2 || break ((i++)) - sleep 0.1 + sleep 0.05 done # Clean up stop file before exiting @@ -316,7 +315,7 @@ start_inline_spinner() { exit 0 ) & INLINE_SPINNER_PID=$! - disown 2> /dev/null || true + disown "$INLINE_SPINNER_PID" 2> /dev/null || true else echo -n " ${BLUE}|${NC} $message" >&2 || true fi diff --git a/lib/manage/autofix.sh b/lib/manage/autofix.sh index d603f02..4096c00 100644 --- a/lib/manage/autofix.sh +++ b/lib/manage/autofix.sh @@ -56,7 +56,7 @@ show_suggestions() { if [[ -n "${DISK_FREE_GB:-}" && "${DISK_FREE_GB:-0}" -lt 50 ]]; then if [[ -z "${CACHE_SIZE_GB:-}" ]] || (($(echo "${CACHE_SIZE_GB:-0} <= 5" | bc -l 2> /dev/null || echo 1))); then - manual_items+=("Low disk space (${DISK_FREE_GB}GB free)|Run: mo analyze to find large files") + manual_items+=("Low disk space, ${DISK_FREE_GB}GB free|Run: mo analyze to find large files") has_suggestions=true fi fi @@ -73,7 +73,7 @@ show_suggestions() { # Show auto-fix items if [[ ${#auto_fix_items[@]} -gt 0 ]]; then for item in "${auto_fix_items[@]}"; do - echo -e " ${YELLOW}${ICON_WARNING}${NC} ${item} ${GREEN}[auto]${NC}" + echo -e " ${GRAY}${ICON_WARNING}${NC} ${item} ${GREEN}[auto]${NC}" done fi @@ -82,7 +82,7 @@ show_suggestions() { for item in "${manual_items[@]}"; do local title="${item%%|*}" local hint="${item#*|}" - echo -e " ${YELLOW}${ICON_WARNING}${NC} ${title}" + echo -e " ${GRAY}${ICON_WARNING}${NC} ${title}" echo -e " ${GRAY}${hint}${NC}" done fi @@ -127,7 +127,7 @@ perform_auto_fix() { # Ensure sudo access if ! has_sudo_session; then if ! ensure_sudo_session "System fixes require admin access"; then - echo -e "${YELLOW}Skipping auto fixes (admin authentication required)${NC}" + echo -e "${YELLOW}Skipping auto fixes, admin authentication required${NC}" echo "" return 0 fi @@ -176,7 +176,7 @@ auth sufficient pam_tid.so fi if [[ $fixed_count -gt 0 ]]; then - AUTO_FIX_SUMMARY="Auto fixes applied: ${fixed_count} issue(s)" + AUTO_FIX_SUMMARY="Auto fixes applied: ${fixed_count} issues" if [[ ${#fixed_items[@]} -gt 0 ]]; then AUTO_FIX_DETAILS=$(printf '%s\n' "${fixed_items[@]}") else diff --git a/lib/manage/purge_paths.sh b/lib/manage/purge_paths.sh index bd163bd..0fddca2 100644 --- a/lib/manage/purge_paths.sh +++ b/lib/manage/purge_paths.sh @@ -58,7 +58,7 @@ manage_purge_paths() { if [[ -d "$path" ]]; then echo -e " ${GREEN}✓${NC} $display_path" else - echo -e " ${GRAY}○${NC} $display_path ${GRAY}(not found)${NC}" + echo -e " ${GRAY}○${NC} $display_path${GRAY}, not found${NC}" fi done fi @@ -76,7 +76,7 @@ manage_purge_paths() { echo "" if [[ $custom_count -gt 0 ]]; then - echo -e "${GRAY}Using custom config with $custom_count path(s)${NC}" + echo -e "${GRAY}Using custom config with $custom_count paths${NC}" else echo -e "${GRAY}Using ${#DEFAULT_PURGE_SEARCH_PATHS[@]} default paths${NC}" fi diff --git a/lib/manage/update.sh b/lib/manage/update.sh index b700f31..07cfb57 100644 --- a/lib/manage/update.sh +++ b/lib/manage/update.sh @@ -18,14 +18,14 @@ format_brew_update_label() { ((formulas > 0)) && details+=("${formulas} formula") ((casks > 0)) && details+=("${casks} cask") - local detail_str="(${total} updates)" + local detail_str=", ${total} updates" if ((${#details[@]} > 0)); then - detail_str="($( + detail_str=", $( IFS=', ' printf '%s' "${details[*]}" - ))" + )" fi - printf " • Homebrew %s" "$detail_str" + printf " • Homebrew%s" "$detail_str" } brew_has_outdated() { @@ -54,7 +54,7 @@ ask_for_updates() { if [[ -n "${APPSTORE_UPDATE_COUNT:-}" && "${APPSTORE_UPDATE_COUNT:-0}" -gt 0 ]]; then has_updates=true - update_list+=(" • App Store (${APPSTORE_UPDATE_COUNT} apps)") + update_list+=(" • App Store, ${APPSTORE_UPDATE_COUNT} apps") fi if [[ -n "${MACOS_UPDATE_AVAILABLE:-}" && "${MACOS_UPDATE_AVAILABLE}" == "true" ]]; then @@ -132,10 +132,10 @@ perform_updates() { echo -e "${GRAY}No updates to perform${NC}" return 0 elif [[ $updated_count -eq $total_count ]]; then - echo -e "${GREEN}All updates completed (${updated_count}/${total_count})${NC}" + echo -e "${GREEN}All updates completed, ${updated_count}/${total_count}${NC}" return 0 else - echo -e "${RED}Update failed (${updated_count}/${total_count})${NC}" + echo -e "${RED}Update failed, ${updated_count}/${total_count}${NC}" return 1 fi } diff --git a/lib/manage/whitelist.sh b/lib/manage/whitelist.sh index 23f5d91..e322b6f 100755 --- a/lib/manage/whitelist.sh +++ b/lib/manage/whitelist.sh @@ -148,7 +148,7 @@ CloudKit cache|$HOME/Library/Caches/CloudKit/*|system_cache Trash|$HOME/.Trash|system_cache EOF # Add FINDER_METADATA with constant reference - echo "Finder metadata (.DS_Store)|$FINDER_METADATA_SENTINEL|system_cache" + echo "Finder metadata, .DS_Store|$FINDER_METADATA_SENTINEL|system_cache" } # Get all optimize items with their patterns @@ -284,13 +284,13 @@ manage_whitelist_categories() { items_source=$(get_optimize_whitelist_items) active_config_file="$WHITELIST_CONFIG_OPTIMIZE" local display_config="${active_config_file/#$HOME/~}" - menu_title="Whitelist Manager – Select system checks to ignore + menu_title="Whitelist Manager, Select system checks to ignore ${GRAY}Edit: ${display_config}${NC}" else items_source=$(get_all_cache_items) active_config_file="$WHITELIST_CONFIG_CLEAN" local display_config="${active_config_file/#$HOME/~}" - menu_title="Whitelist Manager – Select caches to protect + menu_title="Whitelist Manager, Select caches to protect ${GRAY}Edit: ${display_config}${NC}" fi @@ -416,7 +416,7 @@ ${GRAY}Edit: ${display_config}${NC}" if [[ ${#custom_patterns[@]} -gt 0 ]]; then summary_lines+=("Protected ${#selected_patterns[@]} predefined + ${#custom_patterns[@]} custom patterns") else - summary_lines+=("Protected ${total_protected} cache(s)") + summary_lines+=("Protected ${total_protected} caches") fi local display_config="${active_config_file/#$HOME/~}" summary_lines+=("Config: ${GRAY}${display_config}${NC}") diff --git a/lib/optimize/tasks.sh b/lib/optimize/tasks.sh index 042e0f2..c213860 100644 --- a/lib/optimize/tasks.sh +++ b/lib/optimize/tasks.sh @@ -263,7 +263,7 @@ opt_sqlite_vacuum() { fi if ! command -v sqlite3 > /dev/null 2>&1; then - echo -e " ${GRAY}-${NC} Database optimization already optimal (sqlite3 unavailable)" + echo -e " ${GRAY}-${NC} Database optimization already optimal, sqlite3 unavailable" return 0 fi @@ -584,7 +584,7 @@ opt_disk_permissions_repair() { opt_msg "User directory permissions repaired" opt_msg "File access issues resolved" else - echo -e " ${YELLOW}!${NC} Failed to repair permissions (may not be needed)" + echo -e " ${YELLOW}!${NC} Failed to repair permissions, may not be needed" fi else opt_msg "User directory permissions repaired" @@ -705,7 +705,7 @@ opt_spotlight_index_optimize() { fi if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then - echo -e " ${BLUE}ℹ${NC} Spotlight search is slow, rebuilding index (may take 1-2 hours)" + echo -e " ${BLUE}ℹ${NC} Spotlight search is slow, rebuilding index, may take 1-2 hours" if sudo mdutil -E / > /dev/null 2>&1; then opt_msg "Spotlight index rebuild started" echo -e " ${GRAY}Indexing will continue in background${NC}" @@ -733,7 +733,7 @@ opt_dock_refresh() { if [[ -f "$db_file" ]]; then safe_remove "$db_file" true > /dev/null 2>&1 && refreshed=true fi - done < <(find "$dock_support" -name "*.db" -type f 2> /dev/null || true) + done < <(command find "$dock_support" -name "*.db" -type f 2> /dev/null || true) fi local dock_plist="$HOME/Library/Preferences/com.apple.dock.plist" diff --git a/lib/ui/menu_paginated.sh b/lib/ui/menu_paginated.sh index 26e8661..18c1503 100755 --- a/lib/ui/menu_paginated.sh +++ b/lib/ui/menu_paginated.sh @@ -87,13 +87,8 @@ paginated_multi_select() { local items_per_page=$(_pm_calculate_items_per_page) local cursor_pos=0 local top_index=0 - local filter_query="" - local filter_mode="false" # filter mode toggle local sort_mode="${MOLE_MENU_SORT_MODE:-${MOLE_MENU_SORT_DEFAULT:-date}}" # date|name|size local sort_reverse="${MOLE_MENU_SORT_REVERSE:-false}" - # Live query vs applied query - local applied_query="" - local searching="false" # Metadata (optional) # epochs[i] -> last_used_epoch (numeric) for item i @@ -124,36 +119,6 @@ paginated_multi_select() { view_indices[i]=$i done - # Escape for shell globbing without upsetting highlighters - _pm_escape_glob() { - local s="${1-}" out="" c - local i len=${#s} - for ((i = 0; i < len; i++)); do - c="${s:i:1}" - case "$c" in - $'\\' | '*' | '?' | '[' | ']') out+="\\$c" ;; - *) out+="$c" ;; - esac - done - printf '%s' "$out" - } - - # Case-insensitive fuzzy match (substring search) - _pm_match() { - local hay="$1" q="$2" - q="$(_pm_escape_glob "$q")" - local pat="*${q}*" - - shopt -s nocasematch - local ok=1 - # shellcheck disable=SC2254 # intentional glob match with a computed pattern - case "$hay" in - $pat) ok=0 ;; - esac - shopt -u nocasematch - return $ok - } - local -a selected=() local selected_count=0 # Cache selection count to avoid O(n) loops on every draw @@ -267,44 +232,13 @@ paginated_multi_select() { printf "%s%s\n" "$clear_line" "$line" >&2 } - # Rebuild the view_indices applying filter and sort + # Rebuild the view_indices applying sort rebuild_view() { - # Filter - local -a filtered=() - local effective_query="" - if [[ "$filter_mode" == "true" ]]; then - # Live editing: empty query -> show all items - effective_query="$filter_query" - if [[ -z "$effective_query" ]]; then - filtered=("${orig_indices[@]}") - else - local idx - for ((idx = 0; idx < total_items; idx++)); do - if _pm_match "${items[idx]}" "$effective_query"; then - filtered+=("$idx") - fi - done - fi - else - # Normal mode: use applied query; empty -> show all - effective_query="$applied_query" - if [[ -z "$effective_query" ]]; then - filtered=("${orig_indices[@]}") - else - local idx - for ((idx = 0; idx < total_items; idx++)); do - if _pm_match "${items[idx]}" "$effective_query"; then - filtered+=("$idx") - fi - done - fi - fi - # Sort (skip if no metadata) if [[ "$has_metadata" == "false" ]]; then - # No metadata: just use filtered list (already sorted by name naturally) - view_indices=("${filtered[@]}") - elif [[ ${#filtered[@]} -eq 0 ]]; then + # No metadata: just use original indices + view_indices=("${orig_indices[@]}") + elif [[ ${#orig_indices[@]} -eq 0 ]]; then view_indices=() else # Build sort key @@ -328,7 +262,7 @@ paginated_multi_select() { tmpfile=$(mktemp 2> /dev/null) || tmpfile="" if [[ -n "$tmpfile" ]]; then local k id - for id in "${filtered[@]}"; do + for id in "${orig_indices[@]}"; do case "$sort_mode" in date) k="${epochs[id]:-0}" ;; size) k="${sizekb[id]:-0}" ;; @@ -346,7 +280,7 @@ paginated_multi_select() { rm -f "$tmpfile" else # Fallback: no sorting - view_indices=("${filtered[@]}") + view_indices=("${orig_indices[@]}") fi fi @@ -404,34 +338,13 @@ paginated_multi_select() { # Visible slice local visible_total=${#view_indices[@]} if [[ $visible_total -eq 0 ]]; then - if [[ "$filter_mode" == "true" ]]; then - # While editing: do not show "No items available" - for ((i = 0; i < items_per_page; i++)); do - printf "${clear_line}\n" >&2 - done - printf "${clear_line}${GRAY}Type to filter | Delete | Enter Confirm | ESC Cancel${NC}\n" >&2 - printf "${clear_line}" >&2 - return - else - if [[ "$searching" == "true" ]]; then - printf "${clear_line}Searching…\n" >&2 - for ((i = 0; i < items_per_page; i++)); do - printf "${clear_line}\n" >&2 - done - printf "${clear_line}${GRAY}${ICON_NAV_UP}${ICON_NAV_DOWN} | Space | Enter | / Filter | Q Exit${NC}\n" >&2 - printf "${clear_line}" >&2 - return - else - # Post-search: truly empty list - printf "${clear_line}No items available\n" >&2 - for ((i = 0; i < items_per_page; i++)); do - printf "${clear_line}\n" >&2 - done - printf "${clear_line}${GRAY}${ICON_NAV_UP}${ICON_NAV_DOWN} | Space | Enter | / Filter | Q Exit${NC}\n" >&2 - printf "${clear_line}" >&2 - return - fi - fi + printf "${clear_line}No items available\n" >&2 + for ((i = 0; i < items_per_page; i++)); do + printf "${clear_line}\n" >&2 + done + printf "${clear_line}${GRAY}${ICON_NAV_UP}${ICON_NAV_DOWN} | Space | Enter | Q Exit${NC}\n" >&2 + printf "${clear_line}" >&2 + return fi local visible_count=$((visible_total - top_index)) @@ -465,7 +378,7 @@ paginated_multi_select() { printf "${clear_line}\n" >&2 - # Build sort and filter status + # Build sort status local sort_label="" case "$sort_mode" in date) sort_label="Date" ;; @@ -474,15 +387,6 @@ paginated_multi_select() { esac local sort_status="${sort_label}" - local filter_status="" - if [[ "$filter_mode" == "true" ]]; then - filter_status="${filter_query:-_}" - elif [[ -n "$applied_query" ]]; then - filter_status="${applied_query}" - else - filter_status="—" - fi - # Footer: single line with controls local sep=" ${GRAY}|${NC} " @@ -497,77 +401,54 @@ paginated_multi_select() { # Common menu items local nav="${GRAY}${ICON_NAV_UP}${ICON_NAV_DOWN}${NC}" local space_select="${GRAY}Space Select${NC}" - local space="${GRAY}Space${NC}" local enter="${GRAY}Enter${NC}" local exit="${GRAY}Q Exit${NC}" - if [[ "$filter_mode" == "true" ]]; then - # Filter mode: simple controls without sort - local -a _segs_filter=( - "${GRAY}Search: ${filter_status}${NC}" - "${GRAY}Delete${NC}" - "${GRAY}Enter Confirm${NC}" - "${GRAY}ESC Cancel${NC}" - ) - _print_wrapped_controls "$sep" "${_segs_filter[@]}" - else - # Normal mode - prepare dynamic items - local reverse_arrow="↑" - [[ "$sort_reverse" == "true" ]] && reverse_arrow="↓" + local reverse_arrow="↑" + [[ "$sort_reverse" == "true" ]] && reverse_arrow="↓" - local filter_text="/ Search" - [[ -n "$applied_query" ]] && filter_text="/ Clear" + local refresh="${GRAY}R Refresh${NC}" + local sort_ctrl="${GRAY}S ${sort_status}${NC}" + local order_ctrl="${GRAY}O ${reverse_arrow}${NC}" - local refresh="${GRAY}R Refresh${NC}" - local search="${GRAY}${filter_text}${NC}" - local sort_ctrl="${GRAY}S ${sort_status}${NC}" - local order_ctrl="${GRAY}O ${reverse_arrow}${NC}" + if [[ "$has_metadata" == "true" ]]; then + # With metadata: show sort controls + local term_width="${COLUMNS:-}" + [[ -z "$term_width" ]] && term_width=$(tput cols 2> /dev/null || echo 80) + [[ "$term_width" =~ ^[0-9]+$ ]] || term_width=80 - if [[ "$has_metadata" == "true" ]]; then - if [[ -n "$applied_query" ]]; then - # Filtering active: hide sort controls - local -a _segs_all=("$nav" "$space" "$enter" "$refresh" "$search" "$exit") - _print_wrapped_controls "$sep" "${_segs_all[@]}" - else - # Normal: show full controls with dynamic reduction - local term_width="${COLUMNS:-}" - [[ -z "$term_width" ]] && term_width=$(tput cols 2> /dev/null || echo 80) - [[ "$term_width" =~ ^[0-9]+$ ]] || term_width=80 + # Full controls + local -a _segs=("$nav" "$space_select" "$enter" "$refresh" "$sort_ctrl" "$order_ctrl" "$exit") - # Level 0: Full controls - local -a _segs=("$nav" "$space_select" "$enter" "$refresh" "$search" "$sort_ctrl" "$order_ctrl" "$exit") + # Calculate width + local total_len=0 seg_count=${#_segs[@]} + for i in "${!_segs[@]}"; do + total_len=$((total_len + $(_calc_len "${_segs[i]}"))) + [[ $i -lt $((seg_count - 1)) ]] && total_len=$((total_len + 3)) + done - # Calculate width - local total_len=0 seg_count=${#_segs[@]} - for i in "${!_segs[@]}"; do - total_len=$((total_len + $(_calc_len "${_segs[i]}"))) - [[ $i -lt $((seg_count - 1)) ]] && total_len=$((total_len + 3)) - done + # Level 1: Remove "Space Select" if too wide + if [[ $total_len -gt $term_width ]]; then + _segs=("$nav" "$enter" "$refresh" "$sort_ctrl" "$order_ctrl" "$exit") - # Level 1: Remove "Space Select" - if [[ $total_len -gt $term_width ]]; then - _segs=("$nav" "$enter" "$refresh" "$search" "$sort_ctrl" "$order_ctrl" "$exit") + total_len=0 + seg_count=${#_segs[@]} + for i in "${!_segs[@]}"; do + total_len=$((total_len + $(_calc_len "${_segs[i]}"))) + [[ $i -lt $((seg_count - 1)) ]] && total_len=$((total_len + 3)) + done - total_len=0 - seg_count=${#_segs[@]} - for i in "${!_segs[@]}"; do - total_len=$((total_len + $(_calc_len "${_segs[i]}"))) - [[ $i -lt $((seg_count - 1)) ]] && total_len=$((total_len + 3)) - done - - # Level 2: Remove "S ${sort_status}" - if [[ $total_len -gt $term_width ]]; then - _segs=("$nav" "$enter" "$refresh" "$search" "$order_ctrl" "$exit") - fi - fi - - _print_wrapped_controls "$sep" "${_segs[@]}" + # Level 2: Remove sort label if still too wide + if [[ $total_len -gt $term_width ]]; then + _segs=("$nav" "$enter" "$refresh" "$order_ctrl" "$exit") fi - else - # Without metadata: basic controls - local -a _segs_simple=("$nav" "$space_select" "$enter" "$refresh" "$search" "$exit") - _print_wrapped_controls "$sep" "${_segs_simple[@]}" fi + + _print_wrapped_controls "$sep" "${_segs[@]}" + else + # Without metadata: basic controls + local -a _segs_simple=("$nav" "$space_select" "$enter" "$refresh" "$exit") + _print_wrapped_controls "$sep" "${_segs_simple[@]}" fi printf "${clear_line}" >&2 } @@ -592,16 +473,6 @@ paginated_multi_select() { case "$key" in "QUIT") - if [[ "$filter_mode" == "true" ]]; then - filter_mode="false" - filter_query="" - applied_query="" - top_index=0 - cursor_pos=0 - rebuild_view - need_full_redraw=true - continue - fi cleanup return 1 ;; @@ -759,13 +630,7 @@ paginated_multi_select() { fi ;; "CHAR:s" | "CHAR:S") - if [[ "$filter_mode" == "true" ]]; then - local ch="${key#CHAR:}" - filter_query+="$ch" - rebuild_view - need_full_redraw=true - continue - elif [[ "$has_metadata" == "true" ]]; then + if [[ "$has_metadata" == "true" ]]; then # Cycle sort mode (only if metadata available) case "$sort_mode" in date) sort_mode="name" ;; @@ -776,135 +641,43 @@ paginated_multi_select() { need_full_redraw=true fi ;; - "FILTER") - # / key: toggle between filter and return - if [[ -n "$applied_query" ]]; then - # Already filtering, clear and return to full list - applied_query="" - filter_query="" - top_index=0 - cursor_pos=0 - rebuild_view - need_full_redraw=true - else - # Enter filter mode - filter_mode="true" - filter_query="" - top_index=0 - cursor_pos=0 - rebuild_view - need_full_redraw=true - fi - ;; "CHAR:j") - if [[ "$filter_mode" != "true" ]]; then - # Down navigation - if [[ ${#view_indices[@]} -gt 0 ]]; then - local absolute_index=$((top_index + cursor_pos)) - local last_index=$((${#view_indices[@]} - 1)) - if [[ $absolute_index -lt $last_index ]]; then - local visible_count=$((${#view_indices[@]} - top_index)) - [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page - if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then - ((cursor_pos++)) - elif [[ $((top_index + visible_count)) -lt ${#view_indices[@]} ]]; then - ((top_index++)) - fi + # Down navigation (vim style) + if [[ ${#view_indices[@]} -gt 0 ]]; then + local absolute_index=$((top_index + cursor_pos)) + local last_index=$((${#view_indices[@]} - 1)) + if [[ $absolute_index -lt $last_index ]]; then + local visible_count=$((${#view_indices[@]} - top_index)) + [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page + if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then + ((cursor_pos++)) + elif [[ $((top_index + visible_count)) -lt ${#view_indices[@]} ]]; then + ((top_index++)) fi + need_full_redraw=true fi - else - filter_query+="j" - rebuild_view - need_full_redraw=true - continue fi ;; "CHAR:k") - if [[ "$filter_mode" != "true" ]]; then - # Up navigation - if [[ ${#view_indices[@]} -gt 0 ]]; then - if [[ $cursor_pos -gt 0 ]]; then - ((cursor_pos--)) - elif [[ $top_index -gt 0 ]]; then - ((top_index--)) - fi + # Up navigation (vim style) + if [[ ${#view_indices[@]} -gt 0 ]]; then + if [[ $cursor_pos -gt 0 ]]; then + ((cursor_pos--)) + need_full_redraw=true + elif [[ $top_index -gt 0 ]]; then + ((top_index--)) + need_full_redraw=true fi - else - filter_query+="k" - rebuild_view - need_full_redraw=true - continue fi ;; - "TOUCHID") - if [[ "$filter_mode" == "true" ]]; then - filter_query+="t" - rebuild_view - need_full_redraw=true - continue - fi - ;; - "RIGHT") - if [[ "$filter_mode" == "true" ]]; then - filter_query+="l" - rebuild_view - need_full_redraw=true - continue - fi - ;; - "LEFT") - if [[ "$filter_mode" == "true" ]]; then - filter_query+="h" - rebuild_view - need_full_redraw=true - continue - fi - ;; - "MORE") - if [[ "$filter_mode" == "true" ]]; then - filter_query+="m" - rebuild_view - need_full_redraw=true - continue - fi - ;; - "UPDATE") - if [[ "$filter_mode" == "true" ]]; then - filter_query+="u" - rebuild_view - need_full_redraw=true - continue - fi - ;; - "CHAR:f" | "CHAR:F") - if [[ "$filter_mode" == "true" ]]; then - filter_query+="${key#CHAR:}" - rebuild_view - need_full_redraw=true - continue - fi - # F is currently unbound in normal mode to avoid conflict with Refresh (R) - ;; "CHAR:r" | "CHAR:R") - if [[ "$filter_mode" == "true" ]]; then - filter_query+="${key#CHAR:}" - rebuild_view - need_full_redraw=true - continue - else - # Trigger Refresh signal (Unified with Analyze) - cleanup - return 10 - fi + # Trigger Refresh signal + cleanup + return 10 ;; "CHAR:o" | "CHAR:O") - if [[ "$filter_mode" == "true" ]]; then - filter_query+="${key#CHAR:}" - rebuild_view - need_full_redraw=true - continue - elif [[ "$has_metadata" == "true" ]]; then - # O toggles reverse order (Unified Sort Order) + if [[ "$has_metadata" == "true" ]]; then + # O toggles reverse order if [[ "$sort_reverse" == "true" ]]; then sort_reverse="false" else @@ -914,40 +687,8 @@ paginated_multi_select() { need_full_redraw=true fi ;; - "DELETE") - # Backspace filter - if [[ "$filter_mode" == "true" && -n "$filter_query" ]]; then - filter_query="${filter_query%?}" - # Rebuild view to apply filter in real-time - rebuild_view - # Trigger redraw and continue to avoid drain_pending_input - need_full_redraw=true - continue - fi - ;; - CHAR:*) - if [[ "$filter_mode" == "true" ]]; then - local ch="${key#CHAR:}" - # avoid accidental leading spaces - if [[ -n "$filter_query" || "$ch" != " " ]]; then - filter_query+="$ch" - # Rebuild view to apply filter in real-time - rebuild_view - # Trigger redraw and continue to avoid drain_pending_input - need_full_redraw=true - continue - fi - fi - ;; "ENTER") - if [[ "$filter_mode" == "true" ]]; then - applied_query="$filter_query" - filter_mode="false" - # Preserve cursor/top_index so navigation during search is respected - rebuild_view - # Fall through to confirmation logic - fi - # In normal mode: smart Enter behavior + # Smart Enter behavior # 1. Check if any items are already selected local has_selection=false for ((i = 0; i < total_items; i++)); do diff --git a/lib/uninstall/batch.sh b/lib/uninstall/batch.sh index a34aa23..5f11973 100755 --- a/lib/uninstall/batch.sh +++ b/lib/uninstall/batch.sh @@ -11,24 +11,31 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" # Batch uninstall with a single confirmation. -# User data detection patterns (prompt user to backup if found). -readonly SENSITIVE_DATA_PATTERNS=( - "\.warp" # Warp terminal configs/themes - "/\.config/" # Standard Unix config directory - "/themes/" # Theme customizations - "/settings/" # Settings directories - "/Application Support/[^/]+/User Data" # Chrome/Electron user data - "/Preferences/[^/]+\.plist" # User preference files - "/Documents/" # User documents - "/\.ssh/" # SSH keys and configs (critical) - "/\.gnupg/" # GPG keys (critical) -) +# High-performance sensitive data detection (pure Bash, no subprocess) +# Faster than grep for batch operations, especially when processing many apps +has_sensitive_data() { + local files="$1" + [[ -z "$files" ]] && return 1 -# Join patterns into a single regex for grep. -SENSITIVE_DATA_REGEX=$( - IFS='|' - echo "${SENSITIVE_DATA_PATTERNS[*]}" -) + while IFS= read -r file; do + [[ -z "$file" ]] && continue + + # Use Bash native pattern matching (faster than spawning grep) + case "$file" in + */.warp* | */.config/* | */themes/* | */settings/* | */User\ Data/* | \ + */.ssh/* | */.gnupg/* | */Documents/* | */Preferences/*.plist | \ + */Desktop/* | */Downloads/* | */Movies/* | */Music/* | */Pictures/* | \ + */.password* | */.token* | */.auth* | */keychain* | \ + */Passwords/* | */Accounts/* | */Cookies/* | \ + */.aws/* | */.docker/config.json | */.kube/* | \ + */credentials/* | */secrets/*) + return 0 # Found sensitive data + ;; + esac + done <<< "$files" + + return 1 # Not found +} # Decode and validate base64 file list (safe for set -e). decode_file_list() { @@ -65,12 +72,20 @@ decode_file_list() { # Note: find_app_files() and calculate_total_size() are in lib/core/common.sh. # Stop Launch Agents/Daemons for an app. +# Security: bundle_id is validated to be reverse-DNS format before use in find patterns stop_launch_services() { local bundle_id="$1" local has_system_files="${2:-false}" [[ -z "$bundle_id" || "$bundle_id" == "unknown" ]] && return 0 + # Validate bundle_id format: must be reverse-DNS style (e.g., com.example.app) + # This prevents glob injection attacks if bundle_id contains special characters + if [[ ! "$bundle_id" =~ ^[a-zA-Z0-9][-a-zA-Z0-9]*(\.[a-zA-Z0-9][-a-zA-Z0-9]*)+$ ]]; then + debug_log "Invalid bundle_id format for LaunchAgent search: $bundle_id" + return 0 + fi + if [[ -d ~/Library/LaunchAgents ]]; then while IFS= read -r -d '' plist; do launchctl unload "$plist" 2> /dev/null || true @@ -128,6 +143,7 @@ remove_login_item() { } # Remove files (handles symlinks, optional sudo). +# Security: All paths pass validate_path_for_deletion() before any deletion. remove_file_list() { local file_list="$1" local use_sudo="${2:-false}" @@ -140,6 +156,12 @@ remove_file_list() { continue fi + # Symlinks are handled separately using rm (not safe_remove/safe_sudo_remove) + # because safe_sudo_remove() refuses symlinks entirely as a TOCTOU protection. + # This is safe because: + # 1. The path has already passed validate_path_for_deletion() above + # 2. rm on a symlink only removes the link itself, NOT the target + # 3. The symlink deletion is logged via operations.log if [[ -L "$file" ]]; then if [[ "$use_sudo" == "true" ]]; then sudo rm "$file" 2> /dev/null && ((++count)) || true @@ -168,39 +190,69 @@ batch_uninstall_applications() { return 0 fi + local old_trap_int old_trap_term + old_trap_int=$(trap -p INT) + old_trap_term=$(trap -p TERM) + + _restore_uninstall_traps() { + if [[ -n "$old_trap_int" ]]; then + eval "$old_trap_int" + else + trap - INT + fi + if [[ -n "$old_trap_term" ]]; then + eval "$old_trap_term" + else + trap - TERM + fi + } + + # Trap to clean up spinner and uninstall mode on interrupt + trap 'stop_inline_spinner 2>/dev/null; unset MOLE_UNINSTALL_MODE; echo ""; _restore_uninstall_traps; return 130' INT TERM + # Pre-scan: running apps, sudo needs, size. local -a running_apps=() local -a sudo_apps=() local total_estimated_size=0 local -a app_details=() + # Cache current user outside loop + local current_user=$(whoami) + if [[ -t 1 ]]; then start_inline_spinner "Scanning files..."; fi for selected_app in "${selected_apps[@]}"; do [[ -z "$selected_app" ]] && continue IFS='|' read -r _ app_path app_name bundle_id _ _ <<< "$selected_app" - # Check running app by bundle executable if available. + # Check running app by bundle executable if available local exec_name="" - if [[ -e "$app_path/Contents/Info.plist" ]]; then - exec_name=$(defaults read "$app_path/Contents/Info.plist" CFBundleExecutable 2> /dev/null || echo "") + local info_plist="$app_path/Contents/Info.plist" + if [[ -e "$info_plist" ]]; then + exec_name=$(defaults read "$info_plist" CFBundleExecutable 2> /dev/null || echo "") fi - local check_pattern="${exec_name:-$app_name}" - if pgrep -x "$check_pattern" > /dev/null 2>&1; then + if pgrep -qx "${exec_name:-$app_name}" 2> /dev/null; then running_apps+=("$app_name") fi - # Check if it's a Homebrew cask (deterministic: resolved path in Caskroom) - local cask_name="" - cask_name=$(get_brew_cask_name "$app_path" || echo "") - local is_brew_cask="false" - [[ -n "$cask_name" ]] && is_brew_cask="true" + local cask_name="" is_brew_cask="false" + local resolved_path=$(readlink "$app_path" 2> /dev/null || echo "") + if [[ "$resolved_path" == */Caskroom/* ]]; then + # Extract cask name using bash parameter expansion (faster than sed) + local tmp="${resolved_path#*/Caskroom/}" + cask_name="${tmp%%/*}" + [[ -n "$cask_name" ]] && is_brew_cask="true" + elif command -v get_brew_cask_name > /dev/null 2>&1; then + local detected_cask + detected_cask=$(get_brew_cask_name "$app_path" 2> /dev/null || true) + if [[ -n "$detected_cask" ]]; then + cask_name="$detected_cask" + is_brew_cask="true" + fi + fi - # Full file scanning for ALL apps (including Homebrew casks) - # brew uninstall --cask does NOT remove user data (caches, prefs, app support) - # Mole's value is cleaning those up, so we must scan for them + # Check if sudo is needed local needs_sudo=false local app_owner=$(get_file_owner "$app_path") - local current_user=$(whoami) if [[ ! -w "$(dirname "$app_path")" ]] || [[ "$app_owner" == "root" ]] || [[ -n "$app_owner" && "$app_owner" != "$current_user" ]]; then @@ -230,7 +282,7 @@ batch_uninstall_applications() { # Check for sensitive user data once. local has_sensitive_data="false" - if [[ -n "$related_files" ]] && echo "$related_files" | grep -qE "$SENSITIVE_DATA_REGEX"; then + if has_sensitive_data "$related_files"; then has_sensitive_data="true" fi @@ -260,7 +312,7 @@ batch_uninstall_applications() { done if [[ "$has_user_data" == "true" ]]; then - echo -e "${YELLOW}${ICON_WARNING}${NC} ${YELLOW}Note: Some apps contain user configurations/themes${NC}" + echo -e "${GRAY}${ICON_WARNING}${NC} ${YELLOW}Note: Some apps contain user configurations/themes${NC}" echo "" fi @@ -270,7 +322,7 @@ batch_uninstall_applications() { local brew_tag="" [[ "$is_brew_cask" == "true" ]] && brew_tag=" ${CYAN}[Brew]${NC}" - echo -e "${BLUE}${ICON_CONFIRM}${NC} ${app_name}${brew_tag} ${GRAY}(${app_size_display})${NC}" + echo -e "${BLUE}${ICON_CONFIRM}${NC} ${app_name}${brew_tag} ${GRAY}, ${app_size_display}${NC}" # Show detailed file list for ALL apps (brew casks leave user data behind) local related_files=$(decode_file_list "$encoded_files" "$app_name") @@ -295,7 +347,7 @@ batch_uninstall_applications() { while IFS= read -r file; do if [[ -n "$file" && -e "$file" ]]; then if [[ $sys_file_count -lt $max_files ]]; then - echo -e " ${BLUE}${ICON_SOLID}${NC} System: $file" + echo -e " ${BLUE}${ICON_WARNING}${NC} System: $file" fi ((sys_file_count++)) fi @@ -315,7 +367,7 @@ batch_uninstall_applications() { echo "" local removal_note="Remove ${app_total} ${app_text}" - [[ -n "$size_display" ]] && removal_note+=" (${size_display})" + [[ -n "$size_display" ]] && removal_note+=", ${size_display}" if [[ ${#running_apps[@]} -gt 0 ]]; then removal_note+=" ${YELLOW}[Running]${NC}" fi @@ -328,6 +380,7 @@ batch_uninstall_applications() { $'\e' | q | Q) echo "" echo "" + _restore_uninstall_traps return 0 ;; "" | $'\n' | $'\r' | y | Y) @@ -336,16 +389,22 @@ batch_uninstall_applications() { *) echo "" echo "" + _restore_uninstall_traps return 0 ;; esac + # Enable uninstall mode - allows deletion of data-protected apps (VPNs, dev tools, etc.) + # that user explicitly chose to uninstall. System-critical components remain protected. + export MOLE_UNINSTALL_MODE=1 + # Request sudo if needed. if [[ ${#sudo_apps[@]} -gt 0 ]]; then if ! sudo -n true 2> /dev/null; then if ! request_sudo_access "Admin required for system apps: ${sudo_apps[*]}"; then echo "" log_error "Admin access denied" + _restore_uninstall_traps return 1 fi fi @@ -399,34 +458,44 @@ batch_uninstall_applications() { fi # Remove the application only if not running. + # Stop spinner before any removal attempt (avoids mixed output on errors) + [[ -t 1 ]] && stop_inline_spinner + local used_brew_successfully=false if [[ -z "$reason" ]]; then if [[ "$is_brew_cask" == "true" && -n "$cask_name" ]]; then - # Stop spinner before brew output - [[ -t 1 ]] && stop_inline_spinner # Use brew_uninstall_cask helper (handles env vars, timeout, verification) 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 - safe_sudo_remove "$app_path" || reason="remove failed" + if ! safe_sudo_remove "$app_path"; then + reason="brew failed, manual removal failed" + fi else - safe_remove "$app_path" true || reason="remove failed" + if ! safe_remove "$app_path" true; then + reason="brew failed, manual removal failed" + fi fi fi elif [[ "$needs_sudo" == true ]]; then if ! safe_sudo_remove "$app_path"; then local app_owner=$(get_file_owner "$app_path") - local current_user=$(whoami) if [[ -n "$app_owner" && "$app_owner" != "$current_user" && "$app_owner" != "root" ]]; then - reason="owned by $app_owner" + reason="owned by $app_owner, try 'sudo chown $(whoami) \"$app_path\"'" else - reason="permission denied" + reason="permission denied, try 'mole touchid' for passwordless sudo" fi fi else - safe_remove "$app_path" true || reason="remove failed" + if ! safe_remove "$app_path" true; then + if [[ ! -w "$(dirname "$app_path")" ]]; then + reason="parent directory not writable" + else + reason="remove failed, check permissions" + fi + fi fi fi @@ -448,18 +517,23 @@ batch_uninstall_applications() { fi # ByHost preferences (machine-specific). - if [[ -d ~/Library/Preferences/ByHost ]]; then - find ~/Library/Preferences/ByHost -maxdepth 1 -name "${bundle_id}.*.plist" -delete 2> /dev/null || true + 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 + 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" + fi fi fi - # Stop spinner and show success + # Show success if [[ -t 1 ]]; then - stop_inline_spinner if [[ ${#app_details[@]} -gt 1 ]]; then - echo -e "\r\033[K${GREEN}✓${NC} [$current_index/${#app_details[@]}] ${app_name}" + echo -e "${GREEN}✓${NC} [$current_index/${#app_details[@]}] ${app_name}" else - echo -e "\r\033[K${GREEN}✓${NC} ${app_name}" + echo -e "${GREEN}✓${NC} ${app_name}" fi fi @@ -470,13 +544,12 @@ batch_uninstall_applications() { ((total_items++)) success_items+=("$app_name") else - # Stop spinner and show failure + # Show failure if [[ -t 1 ]]; then - stop_inline_spinner if [[ ${#app_details[@]} -gt 1 ]]; then - echo -e "\r\033[K${RED}✗${NC} [$current_index/${#app_details[@]}] ${app_name} ${GRAY}($reason)${NC}" + echo -e "${ICON_ERROR} [$current_index/${#app_details[@]}] ${app_name} ${GRAY}, $reason${NC}" else - echo -e "\r\033[K${RED}✗${NC} ${app_name} failed: $reason" + echo -e "${ICON_ERROR} ${app_name} failed: $reason" fi fi @@ -550,7 +623,7 @@ batch_uninstall_applications() { still*running*) reason_summary="is still running" ;; remove*failed*) reason_summary="could not be removed" ;; permission*denied*) reason_summary="permission denied" ;; - owned*by*) reason_summary="$first_reason (try with sudo)" ;; + owned*by*) reason_summary="$first_reason, try with sudo" ;; *) reason_summary="$first_reason" ;; esac fi @@ -617,12 +690,18 @@ batch_uninstall_applications() { sudo_keepalive_pid="" fi + # Disable uninstall mode + unset MOLE_UNINSTALL_MODE + # Invalidate cache if any apps were successfully uninstalled. if [[ $success_count -gt 0 ]]; then local cache_file="$HOME/.cache/mole/app_scan_cache" rm -f "$cache_file" 2> /dev/null || true fi + _restore_uninstall_traps + unset -f _restore_uninstall_traps + ((total_size_cleaned += total_size_freed)) unset failed_items } diff --git a/lib/uninstall/brew.sh b/lib/uninstall/brew.sh index 569a2dd..adba229 100644 --- a/lib/uninstall/brew.sh +++ b/lib/uninstall/brew.sh @@ -173,28 +173,45 @@ brew_uninstall_cask() { debug_log "Attempting brew uninstall --cask $cask_name" - # Run uninstall with timeout (suppress hints/auto-update) - debug_log "Attempting brew uninstall --cask $cask_name" - # Ensure we have sudo access if needed, to prevent brew from hanging on password prompt - # Many brew casks need sudo to uninstall - if ! sudo -n true 2> /dev/null; then - # If we don't have sudo, try to get it (visibly) - sudo -v + if [[ "${NONINTERACTIVE:-}" != "1" && -t 0 && -t 1 ]]; then + if ! sudo -n true 2> /dev/null; then + sudo -v + fi fi local uninstall_ok=false + local brew_exit=0 - # Run directly without output capture to allow user interaction/visibility - # This avoids silence/hangs when brew asks for passwords or confirmation - if HOMEBREW_NO_ENV_HINTS=1 HOMEBREW_NO_AUTO_UPDATE=1 NONINTERACTIVE=1 \ - brew uninstall --cask "$cask_name"; then - uninstall_ok=true - else - debug_log "brew uninstall failed with exit code $?" + # Calculate timeout based on app size (large apps need more time) + local timeout=300 # Default 5 minutes + if [[ -n "$app_path" && -d "$app_path" ]]; then + local size_gb=$(($(get_path_size_kb "$app_path") / 1048576)) + if [[ $size_gb -gt 15 ]]; then + timeout=900 # 15 minutes for very large apps (Xcode, Adobe, etc.) + elif [[ $size_gb -gt 5 ]]; then + timeout=600 # 10 minutes for large apps + fi + debug_log "App size: ${size_gb}GB, timeout: ${timeout}s" fi - # Verify removal + # Run with timeout to prevent hangs from problematic cask scripts + local brew_exit=0 + if HOMEBREW_NO_ENV_HINTS=1 HOMEBREW_NO_AUTO_UPDATE=1 NONINTERACTIVE=1 \ + run_with_timeout "$timeout" brew uninstall --cask "$cask_name" 2>&1; then + uninstall_ok=true + else + brew_exit=$? + debug_log "brew uninstall timeout or failed with exit code: $brew_exit" + # Exit code 124 indicates timeout from run_with_timeout + # On timeout, fail immediately without verification to avoid inconsistent state + if [[ $brew_exit -eq 124 ]]; then + debug_log "brew uninstall timed out after ${timeout}s, returning failure" + return 1 + fi + fi + + # 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 [[ -n "$app_path" && -e "$app_path" ]] && app_gone=false diff --git a/mole b/mole index 353bce9..8761cb1 100755 --- a/mole +++ b/mole @@ -13,7 +13,7 @@ source "$SCRIPT_DIR/lib/core/commands.sh" trap cleanup_temp_files EXIT INT TERM # Version and update helpers -VERSION="1.21.0" +VERSION="1.23.2" MOLE_TAGLINE="Deep clean and optimize your Mac." is_touchid_configured() { @@ -223,6 +223,7 @@ show_help() { printf " %s%-28s%s %s\n" "$GREEN" "mo optimize --dry-run" "$NC" "Preview optimization" printf " %s%-28s%s %s\n" "$GREEN" "mo optimize --whitelist" "$NC" "Manage protected items" printf " %s%-28s%s %s\n" "$GREEN" "mo purge --paths" "$NC" "Configure scan directories" + printf " %s%-28s%s %s\n" "$GREEN" "mo update --force" "$NC" "Force reinstall latest version" echo printf "%s%s%s\n" "$BLUE" "OPTIONS" "$NC" printf " %s%-28s%s %s\n" "$GREEN" "--debug" "$NC" "Show detailed operation logs" @@ -231,6 +232,7 @@ show_help() { # Update flow (Homebrew or installer). update_mole() { + local force_update="${1:-false}" local update_interrupted=false trap 'update_interrupted=true; echo ""; exit 130' INT TERM @@ -245,14 +247,14 @@ update_mole() { if [[ -z "$latest" ]]; then log_error "Unable to check for updates. Check network connection." - echo -e "${YELLOW}Tip:${NC} Check if you can access GitHub (https://github.com)" + echo -e "${YELLOW}Tip:${NC} Check if you can access GitHub, https://github.com" echo -e "${YELLOW}Tip:${NC} Try again with: ${GRAY}mo update${NC}" exit 1 fi - if [[ "$VERSION" == "$latest" ]]; then + if [[ "$VERSION" == "$latest" && "$force_update" != "true" ]]; then echo "" - echo -e "${GREEN}${ICON_SUCCESS}${NC} Already on latest version (${VERSION})" + echo -e "${GREEN}${ICON_SUCCESS}${NC} Already on latest version, ${VERSION}" echo "" exit 0 fi @@ -276,7 +278,7 @@ update_mole() { local curl_exit=$? if [[ -t 1 ]]; then stop_inline_spinner; fi rm -f "$tmp_installer" - log_error "Update failed (curl error: $curl_exit)" + log_error "Update failed, curl error: $curl_exit" case $curl_exit in 6) echo -e "${YELLOW}Tip:${NC} Could not resolve host. Check DNS or network connection." ;; @@ -292,7 +294,7 @@ update_mole() { download_error=$(wget --timeout=10 --tries=3 -qO "$tmp_installer" "$installer_url" 2>&1) || { if [[ -t 1 ]]; then stop_inline_spinner; fi rm -f "$tmp_installer" - log_error "Update failed (wget error)" + log_error "Update failed, wget error" echo -e "${YELLOW}Tip:${NC} Check network connection and try again." echo -e "${YELLOW}Tip:${NC} URL: $installer_url" exit 1 @@ -322,7 +324,7 @@ update_mole() { if [[ "$requires_sudo" == "true" ]]; then if ! request_sudo_access "Mole update requires admin access"; then - log_error "Update aborted (admin access denied)" + log_error "Update aborted, admin access denied" rm -f "$tmp_installer" exit 1 fi @@ -336,6 +338,7 @@ update_mole() { process_install_output() { local output="$1" + local fallback_version="$2" if [[ -t 1 ]]; then stop_inline_spinner; fi local filtered_output @@ -346,8 +349,17 @@ update_mole() { if ! printf '%s\n' "$output" | grep -Eq "Updated to latest version|Already on latest version"; then local new_version - new_version=$("$mole_path" --version 2> /dev/null | awk 'NR==1 && NF {print $NF}' || echo "") - printf '\n%s\n\n' "${GREEN}${ICON_SUCCESS}${NC} Updated to latest version (${new_version:-unknown})" + new_version=$(printf '%s\n' "$output" | sed -n 's/.*-> \([^[:space:]]\{1,\}\).*/\1/p' | head -1) + if [[ -z "$new_version" ]]; then + new_version=$(printf '%s\n' "$output" | sed -n 's/.*version[[:space:]]\{1,\}\([^[:space:]]\{1,\}\).*/\1/p' | head -1) + fi + if [[ -z "$new_version" ]]; then + new_version=$("$mole_path" --version 2> /dev/null | awk 'NR==1 && NF {print $NF}' || echo "") + fi + if [[ -z "$new_version" ]]; then + new_version="$fallback_version" + fi + printf '\n%s\n\n' "${GREEN}${ICON_SUCCESS}${NC} Updated to latest version, ${new_version:-unknown}" else printf '\n' fi @@ -359,11 +371,10 @@ update_mole() { if [[ ! -f "$config_dir/lib/core/common.sh" ]]; then config_dir="$HOME/.config/mole" fi - if install_output=$(MOLE_VERSION="$update_tag" "$tmp_installer" --prefix "$install_dir" --config "$config_dir" --update 2>&1); then - process_install_output "$install_output" - else + + if [[ "$force_update" == "true" ]]; then if install_output=$(MOLE_VERSION="$update_tag" "$tmp_installer" --prefix "$install_dir" --config "$config_dir" 2>&1); then - process_install_output "$install_output" + process_install_output "$install_output" "$latest" else if [[ -t 1 ]]; then stop_inline_spinner; fi rm -f "$tmp_installer" @@ -371,6 +382,20 @@ update_mole() { echo "$install_output" | tail -10 >&2 # Show last 10 lines of error exit 1 fi + else + if install_output=$(MOLE_VERSION="$update_tag" "$tmp_installer" --prefix "$install_dir" --config "$config_dir" --update 2>&1); then + process_install_output "$install_output" "$latest" + else + if install_output=$(MOLE_VERSION="$update_tag" "$tmp_installer" --prefix "$install_dir" --config "$config_dir" 2>&1); then + process_install_output "$install_output" "$latest" + else + if [[ -t 1 ]]; then stop_inline_spinner; fi + rm -f "$tmp_installer" + log_error "Update failed" + echo "$install_output" | tail -10 >&2 # Show last 10 lines of error + exit 1 + fi + fi fi rm -f "$tmp_installer" @@ -462,15 +487,15 @@ remove_mole() { exit 0 fi - echo -e "${YELLOW}Remove Mole${NC} - will delete the following:" + echo -e "${YELLOW}Remove Mole${NC}, will delete the following:" if [[ "$is_homebrew" == "true" ]]; then - echo " - Mole via Homebrew" + echo " * Mole via Homebrew" fi for install in ${manual_installs[@]+"${manual_installs[@]}"} ${alias_installs[@]+"${alias_installs[@]}"}; do - echo " - $install" + echo " * $install" done - echo " - ~/.config/mole" - echo " - ~/.cache/mole" + echo " * ~/.config/mole" + echo " * ~/.cache/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="" @@ -759,7 +784,14 @@ main() { exec "$SCRIPT_DIR/bin/completion.sh" "${args[@]:1}" ;; "update") - update_mole + local force_update=false + for arg in "${args[@]:1}"; do + case "$arg" in + --force | -f) force_update=true ;; + *) ;; + esac + done + update_mole "$force_update" exit 0 ;; "remove") diff --git a/scripts/check.sh b/scripts/check.sh index 4249a82..20e3dff 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -14,7 +14,7 @@ usage() { Usage: ./scripts/check.sh [--format|--no-format] Options: - --format Apply formatting fixes only (shfmt, gofmt) + --format Apply formatting fixes only, shfmt, gofmt --no-format Skip formatting and run checks only --help Show this help EOF @@ -55,7 +55,7 @@ readonly ICON_ERROR="☻" readonly ICON_WARNING="●" readonly ICON_LIST="•" -echo -e "${BLUE}=== Mole Check (${MODE}) ===${NC}\n" +echo -e "${BLUE}=== Mole Check, ${MODE} ===${NC}\n" SHELL_FILES=$(find . -type f \( -name "*.sh" -o -name "mole" \) \ -not -path "./.git/*" \ @@ -75,11 +75,11 @@ if [[ "$MODE" == "format" ]]; then fi if command -v goimports > /dev/null 2>&1; then - echo -e "${YELLOW}Formatting Go code (goimports)...${NC}" + echo -e "${YELLOW}Formatting Go code, goimports...${NC}" goimports -w -local github.com/tw93/Mole ./cmd echo -e "${GREEN}${ICON_SUCCESS} Go formatting complete${NC}\n" elif command -v go > /dev/null 2>&1; then - echo -e "${YELLOW}Formatting Go code (gofmt)...${NC}" + echo -e "${YELLOW}Formatting Go code, gofmt...${NC}" gofmt -w ./cmd echo -e "${GREEN}${ICON_SUCCESS} Go formatting complete${NC}\n" else @@ -100,11 +100,11 @@ if [[ "$MODE" != "check" ]]; then fi if command -v goimports > /dev/null 2>&1; then - echo -e "${YELLOW}2. Formatting Go code (goimports)...${NC}" + echo -e "${YELLOW}2. Formatting Go code, goimports...${NC}" goimports -w -local github.com/tw93/Mole ./cmd echo -e "${GREEN}${ICON_SUCCESS} Go formatting applied${NC}\n" elif command -v go > /dev/null 2>&1; then - echo -e "${YELLOW}2. Formatting Go code (gofmt)...${NC}" + echo -e "${YELLOW}2. Formatting Go code, gofmt...${NC}" gofmt -w ./cmd echo -e "${GREEN}${ICON_SUCCESS} Go formatting applied${NC}\n" fi @@ -148,18 +148,18 @@ fi echo -e "${YELLOW}5. Running syntax check...${NC}" if ! bash -n mole; then - echo -e "${RED}${ICON_ERROR} Syntax check failed (mole)${NC}\n" + echo -e "${RED}${ICON_ERROR} Syntax check failed, mole${NC}\n" exit 1 fi for script in bin/*.sh; do if ! bash -n "$script"; then - echo -e "${RED}${ICON_ERROR} Syntax check failed ($script)${NC}\n" + echo -e "${RED}${ICON_ERROR} Syntax check failed, $script${NC}\n" exit 1 fi done find lib -name "*.sh" | while read -r script; do if ! bash -n "$script"; then - echo -e "${RED}${ICON_ERROR} Syntax check failed ($script)${NC}\n" + echo -e "${RED}${ICON_ERROR} Syntax check failed, $script${NC}\n" exit 1 fi done diff --git a/scripts/setup-quick-launchers.sh b/scripts/setup-quick-launchers.sh index 11b8784..53b07f3 100755 --- a/scripts/setup-quick-launchers.sh +++ b/scripts/setup-quick-launchers.sh @@ -392,7 +392,7 @@ ${command} EOF - log_success "Workflow ready: ${name} (keyword: ${keyword})" + log_success "Workflow ready: ${name}, keyword: ${keyword}" done log_step "Open Alfred preferences → Workflows if you need to adjust keywords." @@ -413,11 +413,11 @@ main() { echo "" log_success "Done! Raycast and Alfred are ready with 5 commands:" - echo " • clean - Deep system cleanup" - echo " • uninstall - Remove applications" - echo " • optimize - System health & tuning" - echo " • analyze - Disk space explorer" - echo " • status - Live system monitor" + echo " • clean, Deep system cleanup" + echo " • uninstall, Remove applications" + echo " • optimize, System health & tuning" + echo " • analyze, Disk space explorer" + echo " • status, Live system monitor" echo "" } diff --git a/scripts/test.sh b/scripts/test.sh index 11bc688..d42905c 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -183,7 +183,7 @@ echo "" echo "6. Testing installation..." # Skip if Homebrew mole is installed (install.sh will refuse to overwrite) if brew list mole &> /dev/null; then - printf "${GREEN}${ICON_SUCCESS} Installation test skipped (Homebrew)${NC}\n" + printf "${GREEN}${ICON_SUCCESS} Installation test skipped, Homebrew${NC}\n" elif ./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" @@ -203,5 +203,5 @@ if [[ $FAILED -eq 0 ]]; then printf "${GREEN}${ICON_SUCCESS} All tests passed!${NC}\n" exit 0 fi -printf "${RED}${ICON_ERROR} $FAILED test(s) failed!${NC}\n" +printf "${RED}${ICON_ERROR} $FAILED tests failed!${NC}\n" exit 1 diff --git a/tests/clean_apps.bats b/tests/clean_apps.bats index 7533315..8de3f71 100644 --- a/tests/clean_apps.bats +++ b/tests/clean_apps.bats @@ -115,3 +115,48 @@ EOF [[ "$output" == "ok" ]] } +@test "clean_orphaned_system_services respects 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/clean/apps.sh" + +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +debug_log() { :; } + +tmp_dir="$(mktemp -d)" +tmp_plist="$tmp_dir/com.sogou.test.plist" +touch "$tmp_plist" + +sudo() { + if [[ "$1" == "-n" && "$2" == "true" ]]; then + return 0 + fi + if [[ "$1" == "find" ]]; then + printf '%s\0' "$tmp_plist" + return 0 + fi + if [[ "$1" == "du" ]]; then + echo "4 $tmp_plist" + return 0 + fi + if [[ "$1" == "launchctl" ]]; then + echo "launchctl-called" + return 0 + fi + if [[ "$1" == "rm" ]]; then + echo "rm-called" + return 0 + fi + command "$@" +} + +clean_orphaned_system_services +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"rm-called"* ]] + [[ "$output" != *"launchctl-called"* ]] +} diff --git a/tests/clean_browser_versions.bats b/tests/clean_browser_versions.bats index 41abf61..b90350a 100644 --- a/tests/clean_browser_versions.bats +++ b/tests/clean_browser_versions.bats @@ -248,27 +248,24 @@ EOF } @test "clean_edge_old_versions removes old versions but keeps current" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true bash --noprofile --norc <<'EOF' + # Create mock Edge directory structure + local EDGE_APP="$HOME/Applications/Microsoft Edge.app" + local VERSIONS_DIR="$EDGE_APP/Contents/Frameworks/Microsoft Edge Framework.framework/Versions" + mkdir -p "$VERSIONS_DIR"/{120.0.0.0,121.0.0.0,122.0.0.0} + ln -s "122.0.0.0" "$VERSIONS_DIR/Current" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true \ + MOLE_EDGE_APP_PATHS="$EDGE_APP" bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/user.sh" pgrep() { return 1; } -export -f pgrep - -# Create mock Edge directory structure -EDGE_APP="$HOME/Applications/Microsoft Edge.app" -VERSIONS_DIR="$EDGE_APP/Contents/Frameworks/Microsoft Edge Framework.framework/Versions" -mkdir -p "$VERSIONS_DIR"/{120.0.0.0,121.0.0.0,122.0.0.0} - -# Create Current symlink pointing to 122.0.0.0 -ln -s "122.0.0.0" "$VERSIONS_DIR/Current" - is_path_whitelisted() { return 1; } get_path_size_kb() { echo "10240"; } bytes_to_human() { echo "10M"; } note_activity() { :; } -export -f is_path_whitelisted get_path_size_kb bytes_to_human note_activity +export -f pgrep is_path_whitelisted get_path_size_kb bytes_to_human note_activity files_cleaned=0 total_size_cleaned=0 @@ -289,7 +286,14 @@ EOF # Use a fresh temp directory for this test TEST_HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-test8.XXXXXX")" - run env HOME="$TEST_HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + # Create Edge with only current version + local EDGE_APP="$TEST_HOME/Applications/Microsoft Edge.app" + local VERSIONS_DIR="$EDGE_APP/Contents/Frameworks/Microsoft Edge Framework.framework/Versions" + mkdir -p "$VERSIONS_DIR/122.0.0.0" + ln -s "122.0.0.0" "$VERSIONS_DIR/Current" + + run env HOME="$TEST_HOME" PROJECT_ROOT="$PROJECT_ROOT" \ + MOLE_EDGE_APP_PATHS="$EDGE_APP" bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/user.sh" @@ -301,17 +305,10 @@ bytes_to_human() { echo "10M"; } note_activity() { :; } export -f pgrep is_path_whitelisted get_path_size_kb bytes_to_human note_activity -# Initialize counters files_cleaned=0 total_size_cleaned=0 total_items=0 -# Create Edge with only current version -EDGE_APP="$HOME/Applications/Microsoft Edge.app" -VERSIONS_DIR="$EDGE_APP/Contents/Frameworks/Microsoft Edge Framework.framework/Versions" -mkdir -p "$VERSIONS_DIR/122.0.0.0" -ln -s "122.0.0.0" "$VERSIONS_DIR/Current" - clean_edge_old_versions EOF diff --git a/tests/clean_system_maintenance.bats b/tests/clean_system_maintenance.bats index 7be620b..97f2b5a 100644 --- a/tests/clean_system_maintenance.bats +++ b/tests/clean_system_maintenance.bats @@ -108,105 +108,47 @@ EOF [[ "$output" == *"No incomplete backups found"* ]] } -@test "clean_local_snapshots skips in non-interactive mode" { +@test "clean_local_snapshots reports snapshot count" { run bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/system.sh" -tmutil() { - if [[ "$1" == "listlocalsnapshots" ]]; then - printf '%s\n' \ - "com.apple.TimeMachine.2023-10-25-120000" \ - "com.apple.TimeMachine.2023-10-24-120000" - return 0 - fi - return 0 -} -start_section_spinner(){ :; } -stop_section_spinner(){ :; } -tm_is_running(){ return 1; } - -DRY_RUN="false" -clean_local_snapshots -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"skipping non-interactive mode"* ]] - [[ "$output" != *"Removed snapshot"* ]] -} - -@test "clean_local_snapshots keeps latest in dry-run" { - run bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/clean/system.sh" - -tmutil() { - if [[ "$1" == "listlocalsnapshots" ]]; then - printf '%s\n' \ - "com.apple.TimeMachine.2023-10-25-120000" \ - "com.apple.TimeMachine.2023-10-25-130000" \ - "com.apple.TimeMachine.2023-10-24-120000" - return 0 - fi - return 0 +run_with_timeout() { + printf '%s\n' \ + "com.apple.TimeMachine.2023-10-25-120000" \ + "com.apple.TimeMachine.2023-10-24-120000" } start_section_spinner(){ :; } stop_section_spinner(){ :; } note_activity(){ :; } tm_is_running(){ return 1; } -DRY_RUN="true" clean_local_snapshots EOF [ "$status" -eq 0 ] - [[ "$output" == *"Local snapshot: com.apple.TimeMachine.2023-10-25-120000"* ]] - [[ "$output" == *"Local snapshot: com.apple.TimeMachine.2023-10-24-120000"* ]] - [[ "$output" != *"Local snapshot: com.apple.TimeMachine.2023-10-25-130000"* ]] + [[ "$output" == *"Time Machine local snapshots:"* ]] + [[ "$output" == *"tmutil listlocalsnapshots /"* ]] } -@test "clean_local_snapshots uses read fallback when read_key missing" { - if ! command -v script > /dev/null 2>&1; then - skip "script not available" - fi - - local tmp_script="$BATS_TEST_TMPDIR/clean_local_snapshots_fallback.sh" - cat > "$tmp_script" <<'EOF' +@test "clean_local_snapshots is quiet when no snapshots" { + run bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/system.sh" -tmutil() { - if [[ "$1" == "listlocalsnapshots" ]]; then - printf '%s\n' \ - "com.apple.TimeMachine.2023-10-25-120000" \ - "com.apple.TimeMachine.2023-10-24-120000" - return 0 - fi - return 0 -} +run_with_timeout() { echo "Snapshots for disk /:"; } start_section_spinner(){ :; } stop_section_spinner(){ :; } note_activity(){ :; } tm_is_running(){ return 1; } -unset -f read_key - -CALL_LOG="$HOME/snapshot_calls.log" -> "$CALL_LOG" -sudo() { echo "sudo:$*" >> "$CALL_LOG"; return 0; } - -DRY_RUN="false" clean_local_snapshots -cat "$CALL_LOG" EOF - run bash --noprofile --norc -c "printf '\n' | script -q /dev/null bash \"$tmp_script\"" - [ "$status" -eq 0 ] - [[ "$output" == *"Skipped"* ]] + [[ "$output" != *"Time Machine local snapshots"* ]] } diff --git a/tests/clean_user_core.bats b/tests/clean_user_core.bats index d2675d8..f4d0515 100644 --- a/tests/clean_user_core.bats +++ b/tests/clean_user_core.bats @@ -34,8 +34,8 @@ clean_user_essentials EOF [ "$status" -eq 0 ] - [[ "$output" == *"Trash"* ]] - [[ "$output" == *"whitelist"* ]] + # Whitelist-protected items no longer show output (UX improvement in V1.22.0) + [[ "$output" != *"Trash"* ]] } @test "clean_macos_system_caches calls safe_clean for core paths" { @@ -88,8 +88,8 @@ clean_finder_metadata EOF [ "$status" -eq 0 ] - [[ "$output" == *"Finder metadata"* ]] - [[ "$output" == *"protected"* ]] + # Whitelist-protected items no longer show output (UX improvement in V1.22.0) + [[ "$output" == "" ]] } @test "check_ios_device_backups returns when no backup dir" { @@ -117,6 +117,7 @@ EOF [ "$status" -eq 0 ] [[ "$output" == *"Safari cache"* ]] [[ "$output" == *"Firefox cache"* ]] + [[ "$output" == *"Puppeteer browser cache"* ]] } @test "clean_application_support_logs skips when no access" { diff --git a/tests/core_performance.bats b/tests/core_performance.bats index dfc4037..949fbe6 100644 --- a/tests/core_performance.bats +++ b/tests/core_performance.bats @@ -108,6 +108,7 @@ setup() { @test "get_invoking_user executes quickly" { local start end elapsed + local limit_ms="${MOLE_PERF_GET_INVOKING_USER_LIMIT_MS:-500}" start=$(date +%s%N) for i in {1..100}; do @@ -117,7 +118,7 @@ setup() { elapsed=$(( (end - start) / 1000000 )) - [ "$elapsed" -lt 200 ] + [ "$elapsed" -lt "$limit_ms" ] } @test "get_darwin_major caches correctly" { diff --git a/tests/dev_extended.bats b/tests/dev_extended.bats index 4496ba1..f7a66ec 100644 --- a/tests/dev_extended.bats +++ b/tests/dev_extended.bats @@ -123,29 +123,84 @@ EOF } @test "check_android_ndk reports multiple NDK versions" { - run bash -c 'HOME=$(mktemp -d) && mkdir -p "$HOME/Library/Android/sdk/ndk"/{21.0.1,22.0.0,20.0.0} && source "$0" && note_activity() { :; } && NC="" && GREEN="" && GRAY="" && check_android_ndk' "$PROJECT_ROOT/lib/clean/dev.sh" + run bash -c 'HOME=$(mktemp -d) && mkdir -p "$HOME/Library/Android/sdk/ndk"/{21.0.1,22.0.0,20.0.0} && source "$0" && note_activity() { :; } && NC="" && GREEN="" && GRAY="" && YELLOW="" && ICON_WARNING="●" && check_android_ndk' "$PROJECT_ROOT/lib/clean/dev.sh" [ "$status" -eq 0 ] - [[ "$output" == *"Found 3 Android NDK versions"* ]] + [[ "$output" == *"Android NDK versions: 3 found"* ]] } @test "check_android_ndk silent when only one NDK" { - run bash -c 'HOME=$(mktemp -d) && mkdir -p "$HOME/Library/Android/sdk/ndk/22.0.0" && source "$0" && note_activity() { :; } && NC="" && GREEN="" && GRAY="" && check_android_ndk' "$PROJECT_ROOT/lib/clean/dev.sh" + run bash -c 'HOME=$(mktemp -d) && mkdir -p "$HOME/Library/Android/sdk/ndk/22.0.0" && source "$0" && note_activity() { :; } && NC="" && GREEN="" && GRAY="" && YELLOW="" && ICON_WARNING="●" && check_android_ndk' "$PROJECT_ROOT/lib/clean/dev.sh" [ "$status" -eq 0 ] - [[ "$output" != *"Found"*"NDK"* ]] + [[ "$output" != *"NDK versions"* ]] } @test "check_rust_toolchains reports multiple toolchains" { - run bash -c 'HOME=$(mktemp -d) && mkdir -p "$HOME/.rustup/toolchains"/{stable,nightly,1.75.0}-aarch64-apple-darwin && source "$0" && note_activity() { :; } && NC="" && GREEN="" && GRAY="" && rustup() { :; } && export -f rustup && check_rust_toolchains' "$PROJECT_ROOT/lib/clean/dev.sh" + run bash -c 'HOME=$(mktemp -d) && mkdir -p "$HOME/.rustup/toolchains"/{stable,nightly,1.75.0}-aarch64-apple-darwin && source "$0" && note_activity() { :; } && NC="" && GREEN="" && GRAY="" && YELLOW="" && ICON_WARNING="●" && rustup() { :; } && export -f rustup && check_rust_toolchains' "$PROJECT_ROOT/lib/clean/dev.sh" [ "$status" -eq 0 ] - [[ "$output" == *"Found 3 Rust toolchains"* ]] + [[ "$output" == *"Rust toolchains: 3 found"* ]] } @test "check_rust_toolchains silent when only one toolchain" { - run bash -c 'HOME=$(mktemp -d) && mkdir -p "$HOME/.rustup/toolchains/stable-aarch64-apple-darwin" && source "$0" && note_activity() { :; } && NC="" && GREEN="" && GRAY="" && rustup() { :; } && export -f rustup && check_rust_toolchains' "$PROJECT_ROOT/lib/clean/dev.sh" + run bash -c 'HOME=$(mktemp -d) && mkdir -p "$HOME/.rustup/toolchains/stable-aarch64-apple-darwin" && source "$0" && note_activity() { :; } && NC="" && GREEN="" && GRAY="" && YELLOW="" && ICON_WARNING="●" && rustup() { :; } && export -f rustup && check_rust_toolchains' "$PROJECT_ROOT/lib/clean/dev.sh" [ "$status" -eq 0 ] - [[ "$output" != *"Found"*"Rust"* ]] + [[ "$output" != *"Rust toolchains"* ]] +} + +@test "clean_dev_jetbrains_toolbox cleans old versions and bypasses toolbox whitelist" { + local toolbox_channel="$HOME/Library/Application Support/JetBrains/Toolbox/apps/IDEA/ch-0" + mkdir -p "$toolbox_channel/241.1" "$toolbox_channel/241.2" "$toolbox_channel/241.3" + ln -s "241.3" "$toolbox_channel/current" + touch -t 202401010000 "$toolbox_channel/241.1" + touch -t 202402010000 "$toolbox_channel/241.2" + touch -t 202403010000 "$toolbox_channel/241.3" + + 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" +toolbox_root="$HOME/Library/Application Support/JetBrains/Toolbox/apps" +WHITELIST_PATTERNS=("$toolbox_root"* "$HOME/Library/Application Support/JetBrains*") +note_activity() { :; } +safe_clean() { + local target="$1" + for pattern in "${WHITELIST_PATTERNS[@]+${WHITELIST_PATTERNS[@]}}"; do + if [[ "$pattern" == "$toolbox_root"* ]]; then + echo "WHITELIST_NOT_REMOVED" + exit 1 + fi + done + echo "$target" +} +MOLE_JETBRAINS_TOOLBOX_KEEP=1 +clean_dev_jetbrains_toolbox +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"/241.1"* ]] + [[ "$output" != *"/241.2"* ]] +} + +@test "clean_dev_jetbrains_toolbox keeps current directory and removes older versions" { + local toolbox_channel="$HOME/Library/Application Support/JetBrains/Toolbox/apps/IDEA/ch-0" + mkdir -p "$toolbox_channel/241.1" "$toolbox_channel/241.2" "$toolbox_channel/current" + touch -t 202401010000 "$toolbox_channel/241.1" + touch -t 202402010000 "$toolbox_channel/241.2" + + 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_JETBRAINS_TOOLBOX_KEEP=1 +clean_dev_jetbrains_toolbox +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"/241.1"* ]] + [[ "$output" != *"/241.2"* ]] } diff --git a/tests/manage_autofix.bats b/tests/manage_autofix.bats index b2f34ef..219d6d2 100644 --- a/tests/manage_autofix.bats +++ b/tests/manage_autofix.bats @@ -14,7 +14,6 @@ source "$PROJECT_ROOT/lib/manage/autofix.sh" export FIREWALL_DISABLED=true export FILEVAULT_DISABLED=true export TOUCHID_NOT_CONFIGURED=true -export ROSETTA_NOT_INSTALLED=true export CACHE_SIZE_GB=9 export BREW_HAS_WARNINGS=true export DISK_FREE_GB=25 @@ -27,7 +26,6 @@ EOF [[ "$output" == *"Enable Firewall for better security"* ]] [[ "$output" == *"Enable FileVault"* ]] [[ "$output" == *"Enable Touch ID for sudo"* ]] - [[ "$output" == *"Install Rosetta 2"* ]] [[ "$output" == *"Low disk space (25GB free)"* ]] [[ "$output" == *"AUTO_FLAG=true"* ]] } @@ -72,10 +70,6 @@ sudo() { case "$1" in defaults) return 0 ;; bash) return 0 ;; - softwareupdate) - echo "Installing Rosetta 2 stub output" - return 0 - ;; /usr/libexec/ApplicationFirewall/socketfilterfw) return 0 ;; *) return 0 ;; esac @@ -83,7 +77,6 @@ sudo() { export FIREWALL_DISABLED=true export TOUCHID_NOT_CONFIGURED=true -export ROSETTA_NOT_INSTALLED=true perform_auto_fix echo "SUMMARY=${AUTO_FIX_SUMMARY}" @@ -93,7 +86,6 @@ EOF [ "$status" -eq 0 ] [[ "$output" == *"Firewall enabled"* ]] [[ "$output" == *"Touch ID configured"* ]] - [[ "$output" == *"Rosetta 2 installed"* ]] - [[ "$output" == *"SUMMARY=Auto fixes applied: 3 issue(s)"* ]] + [[ "$output" == *"SUMMARY=Auto fixes applied: 2 issues"* ]] [[ "$output" == *"DETAILS"* ]] } diff --git a/tests/uninstall.bats b/tests/uninstall.bats index 59652d3..cd6b24e 100644 --- a/tests/uninstall.bats +++ b/tests/uninstall.bats @@ -39,8 +39,6 @@ create_app_artifacts() { mkdir -p "$HOME/Library/Saved Application State/com.example.TestApp.savedState" mkdir -p "$HOME/Library/LaunchAgents" touch "$HOME/Library/LaunchAgents/com.example.TestApp.plist" - mkdir -p "$HOME/Library/LaunchDaemons" - touch "$HOME/Library/LaunchDaemons/com.example.TestApp.plist" } @test "find_app_files discovers user-level leftovers" { @@ -60,7 +58,6 @@ EOF [[ "$result" == *"Saved Application State/com.example.TestApp.savedState"* ]] [[ "$result" == *"Containers/com.example.TestApp"* ]] [[ "$result" == *"LaunchAgents/com.example.TestApp.plist"* ]] - [[ "$result" == *"LaunchDaemons/com.example.TestApp.plist"* ]] } @test "calculate_total_size returns aggregate kilobytes" { @@ -121,7 +118,6 @@ batch_uninstall_applications [[ ! -d "$HOME/Library/Caches/TestApp" ]] || exit 1 [[ ! -f "$HOME/Library/Preferences/com.example.TestApp.plist" ]] || exit 1 [[ ! -f "$HOME/Library/LaunchAgents/com.example.TestApp.plist" ]] || exit 1 -[[ ! -f "$HOME/Library/LaunchDaemons/com.example.TestApp.plist" ]] || exit 1 EOF [ "$status" -eq 0 ] diff --git a/tests/update.bats b/tests/update.bats index a33d058..a02fd75 100644 --- a/tests/update.bats +++ b/tests/update.bats @@ -78,8 +78,8 @@ ask_for_updates EOF [ "$status" -eq 1 ] # ESC cancels - [[ "$output" == *"Homebrew (5 updates)"* ]] - [[ "$output" == *"App Store (1 apps)"* ]] + [[ "$output" == *"Homebrew, 3 formula, 2 cask"* ]] + [[ "$output" == *"App Store, 1 apps"* ]] [[ "$output" == *"macOS system"* ]] [[ "$output" == *"Mole"* ]] } @@ -233,3 +233,223 @@ EOF [ "$status" -eq 0 ] [[ "$output" == *"Already on latest version"* ]] } + +@test "process_install_output shows install.sh success message with version" { + run bash --noprofile --norc <<'EOF' +set -euo pipefail +GREEN='\033[0;32m' +ICON_SUCCESS='✓' +NC='\033[0m' + +process_install_output() { + local output="$1" + local fallback_version="$2" + + local filtered_output + filtered_output=$(printf '%s\n' "$output" | sed '/^$/d') + if [[ -n "$filtered_output" ]]; then + printf '%s\n' "$filtered_output" + fi + + if ! printf '%s\n' "$output" | grep -Eq "Updated to latest version|Already on latest version"; then + local new_version + new_version=$(printf '%s\n' "$output" | sed -n 's/.*-> \([^[:space:]]\{1,\}\).*/\1/p' | head -1) + if [[ -z "$new_version" ]]; then + new_version=$(printf '%s\n' "$output" | sed -n 's/.*version[[:space:]]\{1,\}\([^[:space:]]\{1,\}\).*/\1/p' | head -1) + fi + if [[ -z "$new_version" ]]; then + new_version=$(command -v mo > /dev/null 2>&1 && mo --version 2> /dev/null | awk 'NR==1 && NF {print $NF}' || echo "") + fi + if [[ -z "$new_version" ]]; then + new_version="$fallback_version" + fi + printf '\n%s\n' "${GREEN}${ICON_SUCCESS}${NC} Updated to latest version, ${new_version:-unknown}" + fi +} + +output="Installing Mole... +◎ Mole installed successfully, version 1.23.1" +process_install_output "$output" "1.23.0" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Updated to latest version, 1.23.1"* ]] + [[ "$output" != *"1.23.0"* ]] +} + +@test "process_install_output uses fallback version when install.sh has no success message" { + run bash --noprofile --norc <<'EOF' +set -euo pipefail +GREEN='\033[0;32m' +ICON_SUCCESS='✓' +NC='\033[0m' + +process_install_output() { + local output="$1" + local fallback_version="$2" + + local filtered_output + filtered_output=$(printf '%s\n' "$output" | sed '/^$/d') + if [[ -n "$filtered_output" ]]; then + printf '%s\n' "$filtered_output" + fi + + if ! printf '%s\n' "$output" | grep -Eq "Updated to latest version|Already on latest version"; then + local new_version + new_version=$(printf '%s\n' "$output" | sed -n 's/.*-> \([^[:space:]]\{1,\}\).*/\1/p' | head -1) + if [[ -z "$new_version" ]]; then + new_version=$(printf '%s\n' "$output" | sed -n 's/.*version[[:space:]]\{1,\}\([^[:space:]]\{1,\}\).*/\1/p' | head -1) + fi + if [[ -z "$new_version" ]]; then + new_version=$(command -v mo > /dev/null 2>&1 && mo --version 2> /dev/null | awk 'NR==1 && NF {print $NF}' || echo "") + fi + if [[ -z "$new_version" ]]; then + new_version="$fallback_version" + fi + printf '\n%s\n' "${GREEN}${ICON_SUCCESS}${NC} Updated to latest version, ${new_version:-unknown}" + fi +} + +output="Installing Mole... +Installation completed" +process_install_output "$output" "1.23.1" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Installation completed"* ]] + [[ "$output" == *"Updated to latest version, 1.23.1"* ]] +} + +@test "process_install_output handles empty output with fallback version" { + run bash --noprofile --norc <<'EOF' +set -euo pipefail +GREEN='\033[0;32m' +ICON_SUCCESS='✓' +NC='\033[0m' + +process_install_output() { + local output="$1" + local fallback_version="$2" + + local filtered_output + filtered_output=$(printf '%s\n' "$output" | sed '/^$/d') + if [[ -n "$filtered_output" ]]; then + printf '%s\n' "$filtered_output" + fi + + if ! printf '%s\n' "$output" | grep -Eq "Updated to latest version|Already on latest version"; then + local new_version + new_version=$(printf '%s\n' "$output" | sed -n 's/.*-> \([^[:space:]]\{1,\}\).*/\1/p' | head -1) + if [[ -z "$new_version" ]]; then + new_version=$(printf '%s\n' "$output" | sed -n 's/.*version[[:space:]]\{1,\}\([^[:space:]]\{1,\}\).*/\1/p' | head -1) + fi + if [[ -z "$new_version" ]]; then + new_version=$(command -v mo > /dev/null 2>&1 && mo --version 2> /dev/null | awk 'NR==1 && NF {print $NF}' || echo "") + fi + if [[ -z "$new_version" ]]; then + new_version="$fallback_version" + fi + printf '\n%s\n' "${GREEN}${ICON_SUCCESS}${NC} Updated to latest version, ${new_version:-unknown}" + fi +} + +output="" +process_install_output "$output" "1.23.1" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Updated to latest version, 1.23.1"* ]] +} + +@test "process_install_output does not extract wrong parentheses content" { + run bash --noprofile --norc <<'EOF' +set -euo pipefail +GREEN='\033[0;32m' +ICON_SUCCESS='✓' +NC='\033[0m' + +process_install_output() { + local output="$1" + local fallback_version="$2" + + local filtered_output + filtered_output=$(printf '%s\n' "$output" | sed '/^$/d') + if [[ -n "$filtered_output" ]]; then + printf '%s\n' "$filtered_output" + fi + + if ! printf '%s\n' "$output" | grep -Eq "Updated to latest version|Already on latest version"; then + local new_version + new_version=$(printf '%s\n' "$output" | sed -n 's/.*-> \([^[:space:]]\{1,\}\).*/\1/p' | head -1) + if [[ -z "$new_version" ]]; then + new_version=$(printf '%s\n' "$output" | sed -n 's/.*version[[:space:]]\{1,\}\([^[:space:]]\{1,\}\).*/\1/p' | head -1) + fi + if [[ -z "$new_version" ]]; then + new_version=$(command -v mo > /dev/null 2>&1 && mo --version 2> /dev/null | awk 'NR==1 && NF {print $NF}' || echo "") + fi + if [[ -z "$new_version" ]]; then + new_version="$fallback_version" + fi + printf '\n%s\n' "${GREEN}${ICON_SUCCESS}${NC} Updated to latest version, ${new_version:-unknown}" + fi +} + +output="Downloading (progress: 100%) +Done" +process_install_output "$output" "1.23.1" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Downloading (progress: 100%)"* ]] + [[ "$output" == *"Updated to latest version, 1.23.1"* ]] + [[ "$output" != *"progress: 100%"* ]] || [[ "$output" == *"Downloading (progress: 100%)"* ]] +} + +@test "update_mole with --force reinstalls even when on latest version" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" CURRENT_VERSION="$CURRENT_VERSION" PATH="$HOME/fake-bin:/usr/bin:/bin" TERM="dumb" bash --noprofile --norc << 'EOF' +set -euo pipefail +curl() { + local out="" + local url="" + while [[ $# -gt 0 ]]; do + case "$1" in + -o) + out="$2" + shift 2 + ;; + http*://*) + url="$1" + shift + ;; + *) + shift + ;; + esac + done + + if [[ -n "$out" ]]; then + cat > "$out" << 'INSTALLER' +#!/usr/bin/env bash +echo "Mole installed successfully, version $CURRENT_VERSION" +INSTALLER + return 0 + fi + + if [[ "$url" == *"api.github.com"* ]]; then + echo "{\"tag_name\":\"$CURRENT_VERSION\"}" + else + echo "VERSION=\"$CURRENT_VERSION\"" + fi +} +export -f curl + +brew() { exit 1; } +export -f brew + +"$PROJECT_ROOT/mole" update --force +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"Already on latest version"* ]] + [[ "$output" == *"Downloading"* ]] || [[ "$output" == *"Installing"* ]] || [[ "$output" == *"Updated"* ]] +}