diff --git a/.gitignore b/.gitignore index 574c7c8..0a24ad3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,70 +1,16 @@ -# macOS -.DS_Store -.DS_Store? -._* -.Spotlight-V100 -.Trashes -ehthumbs.db -Thumbs.db +# Windows Mole - .gitignore -# Editor files -*~ -*.swp -*.swo -.idea/ -.vscode/*.code-workspace -.vscode/settings.json +# Build artifacts +bin/*.exe -# Logs -*.log -logs/ - -# Temporary files -tmp/ -temp/ -*.tmp -*.temp -*.dmg -tests/tmp-* - -# Cache -.cache/ -*.cache +# Go build cache .gocache/ -.gomod/ -# Backup files -*.bak -*.backup - -# System files -*.pid -*.lock - -# AI Assistant Instructions -.claude/ -.gemini/ -.kiro/ -CLAUDE.md -GEMINI.md -.cursorrules - -# Go build artifacts (development) -cmd/analyze/analyze -cmd/status/status -/status -/analyze -mole-analyze -# Go binaries -bin/analyze-go -bin/status-go -bin/analyze-darwin-* -bin/status-darwin-* +# IDE files +.idea/ +.vscode/ +*.code-workspace # Test artifacts -tests/tmp-*/ -tests/*.tmp -tests/*.log - -session.json -run_tests.ps1 +*.test +coverage.out diff --git a/Makefile b/Makefile index d52b4e0..b2904ba 100644 --- a/Makefile +++ b/Makefile @@ -1,40 +1,44 @@ -# Makefile for Mole +# Mole Windows - Makefile +# Build Go tools for Windows -.PHONY: all build clean release - -# Output directory -BIN_DIR := bin - -# Binaries -ANALYZE := analyze -STATUS := status - -# Source directories -ANALYZE_SRC := ./cmd/analyze -STATUS_SRC := ./cmd/status - -# Build flags -LDFLAGS := -s -w +.PHONY: all build clean analyze status +# Default target all: build -# Local build (current architecture) -build: - @echo "Building for local architecture..." - go build -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(ANALYZE)-go $(ANALYZE_SRC) - go build -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(STATUS)-go $(STATUS_SRC) +# Build both tools +build: analyze status -# Release build targets (run on native architectures for CGO support) -release-amd64: - @echo "Building release binaries (amd64)..." - GOOS=darwin GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(ANALYZE)-darwin-amd64 $(ANALYZE_SRC) - GOOS=darwin GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(STATUS)-darwin-amd64 $(STATUS_SRC) +# Build analyze tool +analyze: + @echo "Building analyze..." + @go build -o bin/analyze.exe ./cmd/analyze/ -release-arm64: - @echo "Building release binaries (arm64)..." - GOOS=darwin GOARCH=arm64 go build -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(ANALYZE)-darwin-arm64 $(ANALYZE_SRC) - GOOS=darwin GOARCH=arm64 go build -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(STATUS)-darwin-arm64 $(STATUS_SRC) +# Build status tool +status: + @echo "Building status..." + @go build -o bin/status.exe ./cmd/status/ +# Clean build artifacts clean: - @echo "Cleaning binaries..." - rm -f $(BIN_DIR)/$(ANALYZE)-* $(BIN_DIR)/$(STATUS)-* $(BIN_DIR)/$(ANALYZE)-go $(BIN_DIR)/$(STATUS)-go + @echo "Cleaning..." + @rm -f bin/analyze.exe bin/status.exe + +# Install (copy to PATH) +install: build + @echo "Installing to $(USERPROFILE)/bin..." + @mkdir -p "$(USERPROFILE)/bin" + @cp bin/analyze.exe "$(USERPROFILE)/bin/" + @cp bin/status.exe "$(USERPROFILE)/bin/" + +# Run tests +test: + @go test -v ./... + +# Format code +fmt: + @go fmt ./... + +# Vet code +vet: + @go vet ./... diff --git a/README.md b/README.md index 9e006d8..044fdcb 100644 --- a/README.md +++ b/README.md @@ -1,301 +1,169 @@ -
-

Mole

-

Deep clean and optimize your Mac.

-
+# Mole for Windows -

- Stars - Version - License - Commits - Twitter - Telegram -

+Windows support for [Mole](https://github.com/tw93/Mole) - A system maintenance toolkit. -

- Mole - 95.50GB freed -

+## Requirements -## Features +- Windows 10/11 +- PowerShell 5.1 or later (pre-installed on Windows 10/11) +- Go 1.24+ (for building TUI tools) -- **All-in-one toolkit**: CleanMyMac, AppCleaner, DaisyDisk, and iStat Menus combined into a **single binary** -- **Deep cleaning**: Scans and removes caches, logs, and browser leftovers to **reclaim gigabytes of space** -- **Smart uninstaller**: Thoroughly removes apps along with launch agents, preferences, and **hidden remnants** -- **Disk insights**: Visualizes usage, manages large files, **rebuilds caches**, and refreshes system services -- **Live monitoring**: Real-time stats for CPU, GPU, memory, disk, and network to **diagnose performance issues** +## Installation -## Platform Support +### Quick Install -Mole is designed for **macOS**. For Windows users, check out the `windows/` directory which provides a native Windows port with the same features: - -**Windows Installation:** ```powershell -irm https://raw.githubusercontent.com/tw93/mole/main/windows/install.ps1 | iex +# Clone the repository +git clone https://github.com/tw93/Mole.git +cd Mole/windows + +# Run the installer +.\install.ps1 -AddToPath ``` -**Windows Features:** -- Deep system cleanup (temp files, caches, logs, Windows Update cache) -- Smart app uninstaller with leftover detection -- System optimization and service refresh -- Developer artifact cleanup (node_modules, target, .venv, etc.) -- Disk analysis and real-time monitoring tools (TUI) +### Manual Installation -Built with PowerShell and Go for native Windows performance. Run `mole` after installation. +```powershell +# Install to custom location +.\install.ps1 -InstallDir C:\Tools\Mole -AddToPath -## Quick Start - -**Install via Homebrew — recommended:** - -```bash -brew install mole +# Create Start Menu shortcut +.\install.ps1 -AddToPath -CreateShortcut ``` -**Or via script:** +### Uninstall -```bash -# Optional args: -s latest for main branch code, -s 1.17.0 for specific version -curl -fsSL https://raw.githubusercontent.com/tw93/mole/main/install.sh | bash +```powershell +.\install.ps1 -Uninstall ``` -**Run:** +## Usage -```bash -mo # Interactive menu -mo clean # Deep cleanup -mo uninstall # Remove apps + leftovers -mo optimize # Refresh caches & services -mo analyze # Visual disk explorer -mo status # Live system health dashboard -mo purge # Clean project build artifacts -mo installer # Find and remove installer files +```powershell +# Interactive menu +mole -mo touchid # Configure Touch ID for sudo -mo completion # Set up shell tab completion -mo update # Update Mole -mo remove # Remove Mole from system -mo --help # Show help -mo --version # Show installed version +# Show help +mole -ShowHelp -mo clean --dry-run # Preview the cleanup plan -mo clean --whitelist # Manage protected caches -mo clean --dry-run --debug # Detailed preview with risk levels and file info +# Show version +mole -Version -mo optimize --dry-run # Preview optimization actions -mo optimize --debug # Run with detailed operation logs -mo optimize --whitelist # Manage protected optimization rules -mo purge --paths # Configure project scan directories +# Commands +mole clean # Deep system cleanup +mole clean -DryRun # Preview cleanup without deleting +mole uninstall # Interactive app uninstaller +mole optimize # System optimization +mole purge # Clean developer artifacts +mole analyze # Disk space analyzer +mole status # System health monitor ``` -## Tips +## Commands -- **Terminal**: iTerm2 has known compatibility issues; we recommend Alacritty, kitty, WezTerm, Ghostty, or Warp. -- **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. -- **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. +| Command | Description | +|---------|-------------| +| `clean` | Deep cleanup of temp files, caches, and logs | +| `uninstall` | Interactive application uninstaller | +| `optimize` | System optimization and health checks | +| `purge` | Clean project build artifacts (node_modules, etc.) | +| `analyze` | Interactive disk space analyzer (TUI) | +| `status` | Real-time system health monitor (TUI) | -## Features in Detail +## Environment Variables -### Deep System Cleanup +| Variable | Description | +|----------|-------------| +| `MOLE_DRY_RUN=1` | Preview changes without making them | +| `MOLE_DEBUG=1` | Enable debug output | +| `MO_ANALYZE_PATH` | Starting path for analyze tool | -```bash -$ mo clean +## Directory Structure -Scanning cache directories... - - ✓ User app cache 45.2GB - ✓ Browser cache (Chrome, Safari, Firefox) 10.5GB - ✓ Developer tools (Xcode, Node.js, npm) 23.3GB - ✓ System logs and temp files 3.8GB - ✓ App-specific cache (Spotify, Dropbox, Slack) 8.4GB - ✓ Trash 12.3GB - -==================================================================== -Space freed: 95.5GB | Free space now: 223.5GB -==================================================================== +``` +windows/ +├── mole.ps1 # Main CLI entry point +├── install.ps1 # Windows installer +├── Makefile # Build automation for Go tools +├── go.mod # Go module definition +├── go.sum # Go dependencies +├── bin/ +│ ├── clean.ps1 # Deep cleanup orchestrator +│ ├── uninstall.ps1 # Interactive app uninstaller +│ ├── optimize.ps1 # System optimization +│ ├── purge.ps1 # Project artifact cleanup +│ ├── analyze.ps1 # Disk analyzer wrapper +│ └── status.ps1 # Status monitor wrapper +├── cmd/ +│ ├── analyze/ # Disk analyzer (Go TUI) +│ │ └── main.go +│ └── status/ # System status (Go TUI) +│ └── main.go +└── lib/ + ├── core/ + │ ├── base.ps1 # Core definitions and utilities + │ ├── common.ps1 # Common functions loader + │ ├── file_ops.ps1 # Safe file operations + │ ├── log.ps1 # Logging functions + │ └── ui.ps1 # Interactive UI components + └── clean/ + ├── user.ps1 # User cleanup (temp, downloads, etc.) + ├── caches.ps1 # Browser and app caches + ├── dev.ps1 # Developer tool caches + ├── apps.ps1 # Application leftovers + └── system.ps1 # System cleanup (requires admin) ``` -### Smart App Uninstaller +## Building TUI Tools -```bash -$ mo uninstall +The analyze and status commands require Go to be installed: -Select Apps to Remove -═══════════════════════════ -▶ ☑ Photoshop 2024 (4.2G) | Old - ☐ IntelliJ IDEA (2.8G) | Recent - ☐ Premiere Pro (3.4G) | Recent +```powershell +cd windows -Uninstalling: Photoshop 2024 +# Build both tools +make build - ✓ Removed application - ✓ Cleaned 52 related files across 12 locations - - Application Support, Caches, Preferences - - Logs, WebKit storage, Cookies - - Extensions, Plugins, Launch daemons +# Or build individually +go build -o bin/analyze.exe ./cmd/analyze/ +go build -o bin/status.exe ./cmd/status/ -==================================================================== -Space freed: 12.8GB -==================================================================== +# The wrapper scripts will auto-build if Go is available ``` -### System Optimization +## Configuration -```bash -$ mo optimize +Mole stores its configuration in: +- Config: `~\.config\mole\` +- Cache: `~\.cache\mole\` +- Whitelist: `~\.config\mole\whitelist.txt` +- Purge paths: `~\.config\mole\purge_paths.txt` -System: 5/32 GB RAM | 333/460 GB Disk (72%) | Uptime 6d +## Development Phases - ✓ Rebuild system databases and clear caches - ✓ Reset network services - ✓ Refresh Finder and Dock - ✓ Clean diagnostic and crash logs - ✓ Remove swap files and restart dynamic pager - ✓ Rebuild launch services and spotlight index +### Phase 1: Core Infrastructure ✅ +- [x] `install.ps1` - Windows installer +- [x] `mole.ps1` - Main CLI entry point +- [x] `lib/core/*` - Core utility libraries -==================================================================== -System optimization completed -==================================================================== +### Phase 2: Cleanup Features ✅ +- [x] `bin/clean.ps1` - Deep cleanup orchestrator +- [x] `bin/uninstall.ps1` - App removal with leftover detection +- [x] `bin/optimize.ps1` - System optimization +- [x] `bin/purge.ps1` - Project artifact cleanup +- [x] `lib/clean/*` - Cleanup modules -Use `mo optimize --whitelist` to exclude specific optimizations. -``` +### Phase 3: TUI Tools ✅ +- [x] `cmd/analyze/` - Disk usage analyzer (Go) +- [x] `cmd/status/` - Real-time system monitor (Go) +- [x] `bin/analyze.ps1` - Analyzer wrapper +- [x] `bin/status.ps1` - Status wrapper -### Disk Space Analyzer - -```bash -$ mo analyze - -Analyze Disk ~/Documents | Total: 156.8GB - - ▶ 1. ███████████████████ 48.2% | 📁 Library 75.4GB >6mo - 2. ██████████░░░░░░░░░ 22.1% | 📁 Downloads 34.6GB - 3. ████░░░░░░░░░░░░░░░ 14.3% | 📁 Movies 22.4GB - 4. ███░░░░░░░░░░░░░░░░ 10.8% | 📁 Documents 16.9GB - 5. ██░░░░░░░░░░░░░░░░░ 5.2% | 📄 backup_2023.zip 8.2GB - - ↑↓←→ Navigate | O Open | F Show | ⌫ Delete | L Large files | Q Quit -``` - -### Live System Status - -Real-time dashboard with system health score, hardware info, and performance metrics. - -```bash -$ mo status - -Mole Status Health ● 92 MacBook Pro · M4 Pro · 32GB · macOS 14.5 - -⚙ CPU ▦ Memory -Total ████████████░░░░░░░ 45.2% Used ███████████░░░░░░░ 58.4% -Load 0.82 / 1.05 / 1.23 (8 cores) Total 14.2 / 24.0 GB -Core 1 ███████████████░░░░ 78.3% Free ████████░░░░░░░░░░ 41.6% -Core 2 ████████████░░░░░░░ 62.1% Avail 9.8 GB - -▤ Disk ⚡ Power -Used █████████████░░░░░░ 67.2% Level ██████████████████ 100% -Free 156.3 GB Status Charged -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% -Proxy HTTP · 192.168.1.100 Terminal ▮▯▯▯▯ 12.5% -``` - -Health score based on CPU, memory, disk, temperature, and I/O load. Color-coded by range. - -### Project Artifact Purge - -Clean old build artifacts (`node_modules`, `target`, `build`, `dist`, etc.) from your projects to free up disk space. - -```bash -mo purge - -Select Categories to Clean - 18.5GB (8 selected) - -➤ ● my-react-app 3.2GB | node_modules - ● old-project 2.8GB | node_modules - ● rust-app 4.1GB | target - ● next-blog 1.9GB | node_modules - ○ current-work 856MB | node_modules | Recent - ● django-api 2.3GB | venv - ● vue-dashboard 1.7GB | node_modules - ● backend-service 2.5GB | node_modules -``` - -> **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 - -Run `mo purge --paths` to configure which directories to scan, or edit `~/.config/mole/purge_paths` directly: - -```shell -~/Documents/MyProjects -~/Work/ClientA -~/Work/ClientB -``` - -When custom paths are configured, only those directories are scanned. Otherwise, it defaults to `~/Projects`, `~/GitHub`, `~/dev`, etc. - -
- -### Installer Cleanup - -Find and remove large installer files scattered across Downloads, Desktop, Homebrew caches, iCloud, and Mail. Each file is labeled by source to help you know where the space is hiding. - -```bash -mo installer - -Select Installers to Remove - 3.8GB (5 selected) - -➤ ● Photoshop_2024.dmg 1.2GB | Downloads - ● IntelliJ_IDEA.dmg 850.6MB | Downloads - ● Illustrator_Setup.pkg 920.4MB | Downloads - ● PyCharm_Pro.dmg 640.5MB | Homebrew - ● Acrobat_Reader.dmg 220.4MB | Downloads - ○ AppCode_Legacy.zip 410.6MB | Downloads -``` - -## Quick Launchers - -Launch Mole commands instantly from Raycast or Alfred: - -```bash -curl -fsSL https://raw.githubusercontent.com/tw93/Mole/main/scripts/setup-quick-launchers.sh | bash -``` - -Adds 5 commands: `clean`, `uninstall`, `optimize`, `analyze`, `status`. - -Mole automatically detects your terminal, or set `MO_LAUNCHER_APP=` to override. For Raycast users: if this is your first script directory, add it via Raycast Extensions → Add Script Directory, then run "Reload Script Directories". - -## 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! ❤️ - - - - - -Join thousands of users worldwide who trust Mole to keep their Macs clean and optimized. - -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! 🥤 - -
-Friends who bought me Coke -
- -
+### Phase 4: Testing & CI (Planned) +- [ ] `tests/` - Pester tests +- [ ] GitHub Actions workflows +- [ ] `scripts/build.ps1` - Build automation ## License -MIT License — feel free to enjoy and participate in open source. +Same license as the main Mole project. diff --git a/windows/bin/analyze.ps1 b/bin/analyze.ps1 similarity index 100% rename from windows/bin/analyze.ps1 rename to bin/analyze.ps1 diff --git a/bin/analyze.sh b/bin/analyze.sh deleted file mode 100755 index f699113..0000000 --- a/bin/analyze.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/bash -# Mole - Analyze command. -# Runs the Go disk analyzer UI. -# Uses bundled analyze-go binary. - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -GO_BIN="$SCRIPT_DIR/analyze-go" -if [[ -x "$GO_BIN" ]]; then - exec "$GO_BIN" "$@" -fi - -echo "Bundled analyzer binary not found. Please reinstall Mole or run mo update to restore it." >&2 -exit 1 diff --git a/bin/check.sh b/bin/check.sh deleted file mode 100755 index 24e4594..0000000 --- a/bin/check.sh +++ /dev/null @@ -1,101 +0,0 @@ -#!/bin/bash - -set -euo pipefail - -# Fix locale issues (similar to Issue #83) -export LC_ALL=C -export LANG=C - -# Load common functions -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -source "$SCRIPT_DIR/lib/core/common.sh" -source "$SCRIPT_DIR/lib/core/sudo.sh" -source "$SCRIPT_DIR/lib/manage/update.sh" -source "$SCRIPT_DIR/lib/manage/autofix.sh" - -source "$SCRIPT_DIR/lib/check/all.sh" - -cleanup_all() { - stop_inline_spinner 2> /dev/null || true - stop_sudo_session - cleanup_temp_files -} - -handle_interrupt() { - cleanup_all - exit 130 -} - -main() { - # Register unified cleanup handler - trap cleanup_all EXIT - trap handle_interrupt INT TERM - - if [[ -t 1 ]]; then - clear - fi - - printf '\n' - - # Create temp files for parallel execution - local updates_file=$(mktemp_file) - local health_file=$(mktemp_file) - local security_file=$(mktemp_file) - local config_file=$(mktemp_file) - - # Run all checks in parallel with spinner - if [[ -t 1 ]]; then - echo -ne "${PURPLE_BOLD}System Check${NC} " - start_inline_spinner "Running checks..." - else - echo -e "${PURPLE_BOLD}System Check${NC}" - echo "" - fi - - # Parallel execution - { - check_all_updates > "$updates_file" 2>&1 & - check_system_health > "$health_file" 2>&1 & - check_all_security > "$security_file" 2>&1 & - check_all_config > "$config_file" 2>&1 & - wait - } - - if [[ -t 1 ]]; then - stop_inline_spinner - printf '\n' - fi - - # Display results - echo -e "${BLUE}${ICON_ARROW}${NC} System updates" - cat "$updates_file" - - printf '\n' - echo -e "${BLUE}${ICON_ARROW}${NC} System health" - cat "$health_file" - - printf '\n' - echo -e "${BLUE}${ICON_ARROW}${NC} Security posture" - cat "$security_file" - - printf '\n' - echo -e "${BLUE}${ICON_ARROW}${NC} Configuration" - cat "$config_file" - - # Show suggestions - show_suggestions - - # Ask about auto-fix - if ask_for_auto_fix; then - perform_auto_fix - fi - - # Ask about updates - if ask_for_updates; then - perform_updates - fi - - printf '\n' -} - -main "$@" diff --git a/windows/bin/clean.ps1 b/bin/clean.ps1 similarity index 100% rename from windows/bin/clean.ps1 rename to bin/clean.ps1 diff --git a/bin/clean.sh b/bin/clean.sh deleted file mode 100755 index 7beac0d..0000000 --- a/bin/clean.sh +++ /dev/null @@ -1,1045 +0,0 @@ -#!/bin/bash -# Mole - Clean command. -# Runs cleanup modules with optional sudo. -# Supports dry-run and whitelist. - -set -euo pipefail - -export LC_ALL=C -export LANG=C - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/../lib/core/common.sh" - -source "$SCRIPT_DIR/../lib/core/sudo.sh" -source "$SCRIPT_DIR/../lib/clean/brew.sh" -source "$SCRIPT_DIR/../lib/clean/caches.sh" -source "$SCRIPT_DIR/../lib/clean/apps.sh" -source "$SCRIPT_DIR/../lib/clean/dev.sh" -source "$SCRIPT_DIR/../lib/clean/app_caches.sh" -source "$SCRIPT_DIR/../lib/clean/system.sh" -source "$SCRIPT_DIR/../lib/clean/user.sh" - -SYSTEM_CLEAN=false -DRY_RUN=false -PROTECT_FINDER_METADATA=false -IS_M_SERIES=$([[ "$(uname -m)" == "arm64" ]] && echo "true" || echo "false") - -EXPORT_LIST_FILE="$HOME/.config/mole/clean-list.txt" -CURRENT_SECTION="" -readonly PROTECTED_SW_DOMAINS=( - "capcut.com" - "photopea.com" - "pixlr.com" -) - -declare -a WHITELIST_PATTERNS=() -WHITELIST_WARNINGS=() -if [[ -f "$HOME/.config/mole/whitelist" ]]; then - while IFS= read -r line; do - # shellcheck disable=SC2295 - line="${line#"${line%%[![:space:]]*}"}" - # shellcheck disable=SC2295 - line="${line%"${line##*[![:space:]]}"}" - [[ -z "$line" || "$line" =~ ^# ]] && continue - - [[ "$line" == ~* ]] && line="${line/#~/$HOME}" - line="${line//\$HOME/$HOME}" - line="${line//\$\{HOME\}/$HOME}" - if [[ "$line" =~ \.\. ]]; then - WHITELIST_WARNINGS+=("Path traversal not allowed: $line") - continue - fi - - if [[ "$line" != "$FINDER_METADATA_SENTINEL" ]]; then - if [[ ! "$line" =~ ^[a-zA-Z0-9/_.@\ *-]+$ ]]; then - WHITELIST_WARNINGS+=("Invalid path format: $line") - continue - fi - - if [[ "$line" != /* ]]; then - WHITELIST_WARNINGS+=("Must be absolute path: $line") - continue - fi - fi - - if [[ "$line" =~ // ]]; then - WHITELIST_WARNINGS+=("Consecutive slashes: $line") - continue - fi - - case "$line" in - / | /System | /System/* | /bin | /bin/* | /sbin | /sbin/* | /usr/bin | /usr/bin/* | /usr/sbin | /usr/sbin/* | /etc | /etc/* | /var/db | /var/db/*) - WHITELIST_WARNINGS+=("Protected system path: $line") - continue - ;; - esac - - duplicate="false" - if [[ ${#WHITELIST_PATTERNS[@]} -gt 0 ]]; then - for existing in "${WHITELIST_PATTERNS[@]}"; do - if [[ "$line" == "$existing" ]]; then - duplicate="true" - break - fi - done - fi - [[ "$duplicate" == "true" ]] && continue - WHITELIST_PATTERNS+=("$line") - done < "$HOME/.config/mole/whitelist" -else - WHITELIST_PATTERNS=("${DEFAULT_WHITELIST_PATTERNS[@]}") -fi - -# Expand whitelist patterns once to avoid repeated tilde expansion in hot loops. -expand_whitelist_patterns() { - if [[ ${#WHITELIST_PATTERNS[@]} -gt 0 ]]; then - local -a EXPANDED_PATTERNS - EXPANDED_PATTERNS=() - for pattern in "${WHITELIST_PATTERNS[@]}"; do - local expanded="${pattern/#\~/$HOME}" - EXPANDED_PATTERNS+=("$expanded") - done - WHITELIST_PATTERNS=("${EXPANDED_PATTERNS[@]}") - fi -} -expand_whitelist_patterns - -if [[ ${#WHITELIST_PATTERNS[@]} -gt 0 ]]; then - for entry in "${WHITELIST_PATTERNS[@]}"; do - if [[ "$entry" == "$FINDER_METADATA_SENTINEL" ]]; then - PROTECT_FINDER_METADATA=true - break - fi - done -fi - -# Section tracking and summary counters. -total_items=0 -TRACK_SECTION=0 -SECTION_ACTIVITY=0 -files_cleaned=0 -total_size_cleaned=0 -whitelist_skipped_count=0 - -# shellcheck disable=SC2329 -note_activity() { - if [[ "${TRACK_SECTION:-0}" == "1" ]]; then - SECTION_ACTIVITY=1 - fi -} - -CLEANUP_DONE=false -# shellcheck disable=SC2329 -cleanup() { - local signal="${1:-EXIT}" - local exit_code="${2:-$?}" - - if [[ "$CLEANUP_DONE" == "true" ]]; then - return 0 - fi - CLEANUP_DONE=true - - stop_inline_spinner 2> /dev/null || true - - if [[ -t 1 ]]; then - printf "\r\033[K" >&2 || true - fi - - cleanup_temp_files - - stop_sudo_session - - show_cursor -} - -trap 'cleanup EXIT $?' EXIT -trap 'cleanup INT 130; exit 130' INT -trap 'cleanup TERM 143; exit 143' TERM - -start_section() { - TRACK_SECTION=1 - SECTION_ACTIVITY=0 - CURRENT_SECTION="$1" - 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" - echo "=== $1 ===" >> "$EXPORT_LIST_FILE" - fi -} - -end_section() { - stop_section_spinner - - if [[ "${TRACK_SECTION:-0}" == "1" && "${SECTION_ACTIVITY:-0}" == "0" ]]; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Nothing to clean" - fi - TRACK_SECTION=0 -} - -# shellcheck disable=SC2329 -normalize_paths_for_cleanup() { - local -a input_paths=("$@") - local -a unique_paths=() - - for path in "${input_paths[@]}"; do - local normalized="${path%/}" - [[ -z "$normalized" ]] && normalized="$path" - local found=false - if [[ ${#unique_paths[@]} -gt 0 ]]; then - for existing in "${unique_paths[@]}"; do - if [[ "$existing" == "$normalized" ]]; then - found=true - break - fi - done - fi - [[ "$found" == "true" ]] || unique_paths+=("$normalized") - done - - local sorted_paths - if [[ ${#unique_paths[@]} -gt 0 ]]; then - sorted_paths=$(printf '%s\n' "${unique_paths[@]}" | awk '{print length "|" $0}' | LC_ALL=C sort -n | cut -d'|' -f2-) - else - sorted_paths="" - fi - - local -a result_paths=() - while IFS= read -r path; do - [[ -z "$path" ]] && continue - local is_child=false - if [[ ${#result_paths[@]} -gt 0 ]]; then - for kept in "${result_paths[@]}"; do - if [[ "$path" == "$kept" || "$path" == "$kept"/* ]]; then - is_child=true - break - fi - done - fi - [[ "$is_child" == "true" ]] || result_paths+=("$path") - done <<< "$sorted_paths" - - if [[ ${#result_paths[@]} -gt 0 ]]; then - printf '%s\n' "${result_paths[@]}" - fi -} - -# shellcheck disable=SC2329 -get_cleanup_path_size_kb() { - local path="$1" - - if [[ -f "$path" && ! -L "$path" ]]; then - if command -v stat > /dev/null 2>&1; then - local bytes - bytes=$(stat -f%z "$path" 2> /dev/null || echo "0") - if [[ "$bytes" =~ ^[0-9]+$ && "$bytes" -gt 0 ]]; then - echo $(((bytes + 1023) / 1024)) - return 0 - fi - fi - fi - - if [[ -L "$path" ]]; then - if command -v stat > /dev/null 2>&1; then - local bytes - bytes=$(stat -f%z "$path" 2> /dev/null || echo "0") - if [[ "$bytes" =~ ^[0-9]+$ && "$bytes" -gt 0 ]]; then - echo $(((bytes + 1023) / 1024)) - else - echo 0 - fi - return 0 - fi - fi - - get_path_size_kb "$path" -} - -# Classification helper for cleanup risk levels -# shellcheck disable=SC2329 -classify_cleanup_risk() { - local description="$1" - local path="${2:-}" - - # HIGH RISK: System files, preference files, require sudo - if [[ "$description" =~ [Ss]ystem || "$description" =~ [Ss]udo || "$path" =~ ^/System || "$path" =~ ^/Library ]]; then - echo "HIGH|System files or requires admin access" - return - fi - - # HIGH RISK: Preference files that might affect app functionality - if [[ "$description" =~ [Pp]reference || "$path" =~ /Preferences/ ]]; then - echo "HIGH|Preference files may affect app settings" - return - fi - - # MEDIUM RISK: Installers, large files, app bundles - if [[ "$description" =~ [Ii]nstaller || "$description" =~ [Aa]pp.*[Bb]undle || "$description" =~ [Ll]arge ]]; then - echo "MEDIUM|Installer packages or app data" - return - fi - - # MEDIUM RISK: Old backups, downloads - if [[ "$description" =~ [Bb]ackup || "$description" =~ [Dd]ownload || "$description" =~ [Oo]rphan ]]; then - echo "MEDIUM|Backup or downloaded files" - return - fi - - # LOW RISK: Caches, logs, temporary files (automatically regenerated) - if [[ "$description" =~ [Cc]ache || "$description" =~ [Ll]og || "$description" =~ [Tt]emp || "$description" =~ [Tt]humbnail ]]; then - echo "LOW|Cache/log files, automatically regenerated" - return - fi - - # DEFAULT: MEDIUM - echo "MEDIUM|User data files" -} - -# shellcheck disable=SC2329 -safe_clean() { - if [[ $# -eq 0 ]]; then - return 0 - fi - - # Always stop spinner before outputting results - stop_section_spinner - - local description - local -a targets - - if [[ $# -eq 1 ]]; then - description="$1" - targets=("$1") - else - description="${*: -1}" - targets=("${@:1:$#-1}") - fi - - local removed_any=0 - local total_size_kb=0 - local total_count=0 - local skipped_count=0 - local removal_failed_count=0 - local permission_start=${MOLE_PERMISSION_DENIED_COUNT:-0} - - local show_scan_feedback=false - if [[ ${#targets[@]} -gt 20 && -t 1 ]]; then - show_scan_feedback=true - MOLE_SPINNER_PREFIX=" " start_inline_spinner "Scanning ${#targets[@]} items..." - fi - - local -a existing_paths=() - for path in "${targets[@]}"; do - local skip=false - - if should_protect_path "$path"; then - skip=true - ((skipped_count++)) - fi - - [[ "$skip" == "true" ]] && continue - - if is_path_whitelisted "$path"; then - skip=true - ((skipped_count++)) - fi - [[ "$skip" == "true" ]] && continue - [[ -e "$path" ]] && existing_paths+=("$path") - done - - if [[ "$show_scan_feedback" == "true" ]]; then - stop_section_spinner - fi - - debug_log "Cleaning: $description (${#existing_paths[@]} items)" - - # Enhanced debug output with risk level and details - if [[ "${MO_DEBUG:-}" == "1" && ${#existing_paths[@]} -gt 0 ]]; then - # Determine risk level for this cleanup operation - local risk_info - risk_info=$(classify_cleanup_risk "$description" "${existing_paths[0]}") - local risk_level="${risk_info%%|*}" - local risk_reason="${risk_info#*|}" - - debug_operation_start "$description" - debug_risk_level "$risk_level" "$risk_reason" - debug_operation_detail "Item count" "${#existing_paths[@]}" - - # Log sample of files (first 10) with details - if [[ ${#existing_paths[@]} -le 10 ]]; then - debug_operation_detail "Files to be removed" "All files listed below" - else - debug_operation_detail "Files to be removed" "Showing first 10 of ${#existing_paths[@]} files" - fi - fi - - if [[ $skipped_count -gt 0 ]]; then - ((whitelist_skipped_count += skipped_count)) - fi - - if [[ ${#existing_paths[@]} -eq 0 ]]; then - return 0 - fi - - if [[ ${#existing_paths[@]} -gt 1 ]]; then - local -a normalized_paths=() - while IFS= read -r path; do - [[ -n "$path" ]] && normalized_paths+=("$path") - done < <(normalize_paths_for_cleanup "${existing_paths[@]}") - - if [[ ${#normalized_paths[@]} -gt 0 ]]; then - existing_paths=("${normalized_paths[@]}") - else - existing_paths=() - fi - fi - - local show_spinner=false - if [[ ${#existing_paths[@]} -gt 10 ]]; then - show_spinner=true - local total_paths=${#existing_paths[@]} - if [[ -t 1 ]]; then MOLE_SPINNER_PREFIX=" " start_inline_spinner "Scanning items..."; fi - fi - - # For larger batches, precompute sizes in parallel for better UX/stat accuracy. - if [[ ${#existing_paths[@]} -gt 3 ]]; then - local temp_dir - temp_dir=$(create_temp_dir) - - local dir_count=0 - local sample_size=$((${#existing_paths[@]} > 20 ? 20 : ${#existing_paths[@]})) - local max_sample=$((${#existing_paths[@]} * 20 / 100)) - [[ $max_sample -gt $sample_size ]] && sample_size=$max_sample - - for ((i = 0; i < sample_size && i < ${#existing_paths[@]}; i++)); do - [[ -d "${existing_paths[i]}" ]] && ((dir_count++)) - done - - # Heuristic: mostly files -> sequential stat is faster than subshells. - if [[ $dir_count -lt 5 && ${#existing_paths[@]} -gt 20 ]]; then - if [[ -t 1 && "$show_spinner" == "false" ]]; then - MOLE_SPINNER_PREFIX=" " start_inline_spinner "Scanning items..." - show_spinner=true - fi - - local idx=0 - local last_progress_update - last_progress_update=$(get_epoch_seconds) - for path in "${existing_paths[@]}"; do - local size - size=$(get_cleanup_path_size_kb "$path") - [[ ! "$size" =~ ^[0-9]+$ ]] && size=0 - - if [[ "$size" -gt 0 ]]; then - echo "$size 1" > "$temp_dir/result_${idx}" - else - echo "0 0" > "$temp_dir/result_${idx}" - fi - - ((idx++)) - if [[ $((idx % 20)) -eq 0 && "$show_spinner" == "true" && -t 1 ]]; then - update_progress_if_needed "$idx" "${#existing_paths[@]}" last_progress_update 1 || true - last_progress_update=$(get_epoch_seconds) - fi - done - else - local -a pids=() - local idx=0 - local completed=0 - local last_progress_update - last_progress_update=$(get_epoch_seconds) - local total_paths=${#existing_paths[@]} - - if [[ ${#existing_paths[@]} -gt 0 ]]; then - for path in "${existing_paths[@]}"; do - ( - local size - size=$(get_cleanup_path_size_kb "$path") - [[ ! "$size" =~ ^[0-9]+$ ]] && size=0 - local tmp_file="$temp_dir/result_${idx}.$$" - if [[ "$size" -gt 0 ]]; then - echo "$size 1" > "$tmp_file" - else - echo "0 0" > "$tmp_file" - fi - mv "$tmp_file" "$temp_dir/result_${idx}" 2> /dev/null || true - ) & - pids+=($!) - ((idx++)) - - if ((${#pids[@]} >= MOLE_MAX_PARALLEL_JOBS)); then - wait "${pids[0]}" 2> /dev/null || true - pids=("${pids[@]:1}") - ((completed++)) - - if [[ "$show_spinner" == "true" && -t 1 ]]; then - update_progress_if_needed "$completed" "$total_paths" last_progress_update 2 || true - fi - fi - done - fi - - if [[ ${#pids[@]} -gt 0 ]]; then - for pid in "${pids[@]}"; do - wait "$pid" 2> /dev/null || true - ((completed++)) - - if [[ "$show_spinner" == "true" && -t 1 ]]; then - update_progress_if_needed "$completed" "$total_paths" last_progress_update 2 || true - fi - done - fi - fi - - # Read results back in original order. - idx=0 - if [[ ${#existing_paths[@]} -gt 0 ]]; then - for path in "${existing_paths[@]}"; do - local result_file="$temp_dir/result_${idx}" - if [[ -f "$result_file" ]]; then - 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 - fi - else - removed=1 - fi - - if [[ $removed -eq 1 ]]; then - if [[ "$size" -gt 0 ]]; then - ((total_size_kb += size)) - fi - ((total_count += 1)) - removed_any=1 - else - if [[ -e "$path" && "$DRY_RUN" != "true" ]]; then - ((removal_failed_count++)) - fi - fi - fi - ((idx++)) - done - fi - - else - local idx=0 - if [[ ${#existing_paths[@]} -gt 0 ]]; then - for path in "${existing_paths[@]}"; do - local size_kb - size_kb=$(get_cleanup_path_size_kb "$path") - [[ ! "$size_kb" =~ ^[0-9]+$ ]] && size_kb=0 - - 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 - fi - else - removed=1 - fi - - if [[ $removed -eq 1 ]]; then - if [[ "$size_kb" -gt 0 ]]; then - ((total_size_kb += size_kb)) - fi - ((total_count += 1)) - removed_any=1 - else - if [[ -e "$path" && "$DRY_RUN" != "true" ]]; then - ((removal_failed_count++)) - fi - fi - ((idx++)) - done - fi - fi - - if [[ "$show_spinner" == "true" ]]; then - stop_section_spinner - fi - - local permission_end=${MOLE_PERMISSION_DENIED_COUNT:-0} - # Track permission failures in debug output (avoid noisy user warnings). - if [[ $permission_end -gt $permission_start && $removed_any -eq 0 ]]; then - 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" - fi - - if [[ $removed_any -eq 1 ]]; then - local size_human=$(bytes_to_human "$((total_size_kb * 1024))") - - local label="$description" - if [[ ${#targets[@]} -gt 1 ]]; then - label+=" ${#targets[@]} items" - fi - - if [[ "$DRY_RUN" == "true" ]]; then - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} $label ${YELLOW}($size_human dry)${NC}" - - local paths_temp=$(create_temp_file) - - idx=0 - if [[ ${#existing_paths[@]} -gt 0 ]]; then - for path in "${existing_paths[@]}"; do - local size=0 - - if [[ -n "${temp_dir:-}" && -f "$temp_dir/result_${idx}" ]]; then - read -r size count < "$temp_dir/result_${idx}" 2> /dev/null || true - else - size=$(get_cleanup_path_size_kb "$path" 2> /dev/null || echo "0") - fi - - [[ "$size" == "0" || -z "$size" ]] && { - ((idx++)) - continue - } - - echo "$(dirname "$path")|$size|$path" >> "$paths_temp" - ((idx++)) - done - fi - - # Group dry-run paths by parent for a compact export list. - if [[ -f "$paths_temp" && -s "$paths_temp" ]]; then - sort -t'|' -k1,1 "$paths_temp" | awk -F'|' ' - { - parent = $1 - size = $2 - path = $3 - - parent_size[parent] += size - if (parent_count[parent] == 0) { - parent_first[parent] = path - } - parent_count[parent]++ - } - END { - for (parent in parent_size) { - if (parent_count[parent] > 1) { - printf "%s|%d|%d\n", parent, parent_size[parent], parent_count[parent] - } else { - printf "%s|%d|1\n", parent_first[parent], parent_size[parent] - } - } - } - ' | 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" - else - echo "$display_path # $size_human" >> "$EXPORT_LIST_FILE" - fi - done - - rm -f "$paths_temp" - fi - else - echo -e " ${GREEN}${ICON_SUCCESS}${NC} $label ${GREEN}($size_human)${NC}" - fi - ((files_cleaned += total_count)) - ((total_size_cleaned += total_size_kb)) - ((total_items++)) - note_activity - fi - - return 0 -} - -start_cleanup() { - if [[ -t 1 ]]; then - printf '\033[2J\033[H' - fi - printf '\n' - echo -e "${PURPLE_BOLD}Clean Your Mac${NC}" - echo "" - - if [[ "$DRY_RUN" != "true" && -t 0 ]]; then - echo -e "${GRAY}${ICON_SOLID} 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 "" - SYSTEM_CLEAN=false - - ensure_user_file "$EXPORT_LIST_FILE" - cat > "$EXPORT_LIST_FILE" << EOF -# Mole Cleanup Preview - $(date '+%Y-%m-%d %H:%M:%S') -# -# How to protect files: -# 1. Copy any path below to ~/.config/mole/whitelist -# 2. Run: mo clean --whitelist -# -# Example: -# /Users/*/Library/Caches/com.example.app -# - -EOF - return - 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}" - 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 "" - fi - else - SYSTEM_CLEAN=false - echo "" - echo "Running in non-interactive mode" - echo " ${ICON_LIST} System-level cleanup skipped (requires interaction)" - echo " ${ICON_LIST} User-level cleanup will proceed automatically" - echo "" - fi -} - -perform_cleanup() { - # Test mode skips expensive scans and returns minimal output. - local test_mode_enabled=false - 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 "" - fi - echo -e "${GREEN}${ICON_LIST}${NC} User app cache" - if [[ ${#WHITELIST_PATTERNS[@]} -gt 0 ]]; then - local -a expanded_defaults - expanded_defaults=() - for default in "${DEFAULT_WHITELIST_PATTERNS[@]}"; do - expanded_defaults+=("${default/#\~/$HOME}") - done - local has_custom=false - for pattern in "${WHITELIST_PATTERNS[@]}"; do - local is_default=false - local normalized_pattern="${pattern%/}" - for default in "${expanded_defaults[@]}"; do - local normalized_default="${default%/}" - [[ "$normalized_pattern" == "$normalized_default" ]] && is_default=true && break - done - [[ "$is_default" == "false" ]] && has_custom=true && break - done - [[ "$has_custom" == "true" ]] && echo -e "${GREEN}${ICON_SUCCESS}${NC} Protected items found" - fi - if [[ "$DRY_RUN" == "true" ]]; then - echo "" - echo "Potential space: 0.00GB" - fi - total_items=1 - files_cleaned=0 - total_size_cleaned=0 - fi - - if [[ "$test_mode_enabled" == "false" ]]; then - echo -e "${BLUE}${ICON_ADMIN}${NC} $(detect_architecture) | Free space: $(get_free_space)" - fi - - if [[ "$test_mode_enabled" == "true" ]]; then - local summary_heading="Test mode complete" - local -a summary_details - summary_details=() - summary_details+=("Test mode - no actual cleanup performed") - print_summary_block "$summary_heading" "${summary_details[@]}" - printf '\n' - return 0 - fi - - # Pre-check TCC permissions to avoid mid-run prompts. - check_tcc_permissions - - if [[ ${#WHITELIST_PATTERNS[@]} -gt 0 ]]; then - local predefined_count=0 - local custom_count=0 - - for pattern in "${WHITELIST_PATTERNS[@]}"; do - local is_predefined=false - for default in "${DEFAULT_WHITELIST_PATTERNS[@]}"; do - local expanded_default="${default/#\~/$HOME}" - if [[ "$pattern" == "$expanded_default" ]]; then - is_predefined=true - break - fi - done - - if [[ "$is_predefined" == "true" ]]; then - ((predefined_count++)) - else - ((custom_count++)) - fi - done - - if [[ $custom_count -gt 0 || $predefined_count -gt 0 ]]; then - local summary="" - [[ $predefined_count -gt 0 ]] && summary+="$predefined_count core" - [[ $custom_count -gt 0 && $predefined_count -gt 0 ]] && summary+=" + " - [[ $custom_count -gt 0 ]] && summary+="$custom_count custom" - summary+=" patterns active" - - echo -e "${BLUE}${ICON_SUCCESS}${NC} Whitelist: $summary" - - if [[ "$DRY_RUN" == "true" ]]; then - for pattern in "${WHITELIST_PATTERNS[@]}"; do - [[ "$pattern" == "$FINDER_METADATA_SENTINEL" ]] && continue - echo -e " ${GRAY}→ $pattern${NC}" - done - fi - fi - fi - - if [[ -t 1 && "$DRY_RUN" != "true" ]]; then - local fda_status=0 - has_full_disk_access - 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}" - fi - fi - - total_items=0 - files_cleaned=0 - total_size_cleaned=0 - - local had_errexit=0 - [[ $- == *e* ]] && had_errexit=1 - - # Allow per-section failures without aborting the full run. - set +e - - # ===== 1. Deep system cleanup (if admin) ===== - if [[ "$SYSTEM_CLEAN" == "true" ]]; then - start_section "Deep system" - clean_deep_system - clean_local_snapshots - end_section - fi - - if [[ ${#WHITELIST_WARNINGS[@]} -gt 0 ]]; then - echo "" - for warning in "${WHITELIST_WARNINGS[@]}"; do - echo -e " ${YELLOW}${ICON_WARNING}${NC} Whitelist: $warning" - done - fi - - start_section "User essentials" - clean_user_essentials - scan_external_volumes - end_section - - start_section "Finder metadata" - clean_finder_metadata - end_section - - # ===== 3. macOS system caches ===== - start_section "macOS system caches" - clean_macos_system_caches - clean_recent_items - clean_mail_downloads - end_section - - # ===== 4. Sandboxed app caches ===== - start_section "Sandboxed app caches" - clean_sandboxed_app_caches - end_section - - # ===== 5. Browsers ===== - start_section "Browsers" - clean_browsers - end_section - - # ===== 6. Cloud storage ===== - start_section "Cloud storage" - clean_cloud_storage - end_section - - # ===== 7. Office applications ===== - start_section "Office applications" - clean_office_applications - end_section - - # ===== 8. Developer tools ===== - start_section "Developer tools" - clean_developer_tools - end_section - - # ===== 9. Development applications ===== - start_section "Development applications" - clean_user_gui_applications - end_section - - # ===== 10. Virtualization tools ===== - start_section "Virtual machine tools" - clean_virtualization_tools - end_section - - # ===== 11. Application Support logs and caches cleanup ===== - start_section "Application Support" - clean_application_support_logs - end_section - - # ===== 12. Orphaned app data cleanup (60+ days inactive, skip protected vendors) ===== - start_section "Uninstalled app data" - clean_orphaned_app_data - end_section - - # ===== 13. Apple Silicon optimizations ===== - clean_apple_silicon_caches - - # ===== 14. iOS device backups ===== - start_section "iOS device backups" - check_ios_device_backups - end_section - - # ===== 15. Time Machine incomplete backups ===== - start_section "Time Machine incomplete backups" - clean_time_machine_failed_backups - end_section - - # ===== Final summary ===== - echo "" - - local summary_heading="" - local summary_status="success" - if [[ "$DRY_RUN" == "true" ]]; then - summary_heading="Dry run complete - no changes made" - else - summary_heading="Cleanup complete" - fi - - local -a summary_details=() - - if [[ $total_size_cleaned -gt 0 ]]; then - local freed_gb - freed_gb=$(echo "$total_size_cleaned" | awk '{printf "%.2f", $1/1024/1024}') - - if [[ "$DRY_RUN" == "true" ]]; then - local stats="Potential space: ${GREEN}${freed_gb}GB${NC}" - [[ $files_cleaned -gt 0 ]] && stats+=" | Items: $files_cleaned" - [[ $total_items -gt 0 ]] && stats+=" | Categories: $total_items" - summary_details+=("$stats") - - { - echo "" - echo "# ============================================" - echo "# Summary" - echo "# ============================================" - echo "# Potential cleanup: ${freed_gb}GB" - echo "# Items: $files_cleaned" - echo "# Categories: $total_items" - } >> "$EXPORT_LIST_FILE" - - summary_details+=("Detailed file list: ${GRAY}$EXPORT_LIST_FILE${NC}") - summary_details+=("Use ${GRAY}mo clean --whitelist${NC} to add protection rules") - else - local summary_line="Space freed: ${GREEN}${freed_gb}GB${NC}" - - if [[ $files_cleaned -gt 0 && $total_items -gt 0 ]]; then - summary_line+=" | Items cleaned: $files_cleaned | Categories: $total_items" - elif [[ $files_cleaned -gt 0 ]]; then - summary_line+=" | Items cleaned: $files_cleaned" - elif [[ $total_items -gt 0 ]]; then - summary_line+=" | Categories: $total_items" - fi - - summary_details+=("$summary_line") - - if [[ $(echo "$freed_gb" | awk '{print ($1 >= 1) ? 1 : 0}') -eq 1 ]]; then - local movies - movies=$(echo "$freed_gb" | awk '{printf "%.0f", $1/4.5}') - if [[ $movies -gt 0 ]]; then - summary_details+=("Equivalent to ~$movies 4K movies of storage.") - fi - fi - - local final_free_space=$(get_free_space) - summary_details+=("Free space now: $final_free_space") - fi - else - summary_status="info" - if [[ "$DRY_RUN" == "true" ]]; then - summary_details+=("No significant reclaimable space detected (system already clean).") - else - summary_details+=("System was already clean; no additional space freed.") - fi - summary_details+=("Free space now: $(get_free_space)") - fi - - if [[ $had_errexit -eq 1 ]]; then - set -e - fi - - print_summary_block "$summary_heading" "${summary_details[@]}" - printf '\n' -} - -main() { - for arg in "$@"; do - case "$arg" in - "--debug") - export MO_DEBUG=1 - ;; - "--dry-run" | "-n") - DRY_RUN=true - ;; - "--whitelist") - source "$SCRIPT_DIR/../lib/manage/whitelist.sh" - manage_whitelist "clean" - exit 0 - ;; - esac - done - - start_cleanup - hide_cursor - perform_cleanup - show_cursor - exit 0 -} - -main "$@" diff --git a/bin/completion.sh b/bin/completion.sh deleted file mode 100755 index b3d345c..0000000 --- a/bin/completion.sh +++ /dev/null @@ -1,251 +0,0 @@ -#!/bin/bash - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" - -source "$ROOT_DIR/lib/core/common.sh" -source "$ROOT_DIR/lib/core/commands.sh" - -command_names=() -for entry in "${MOLE_COMMANDS[@]}"; do - command_names+=("${entry%%:*}") -done -command_words="${command_names[*]}" - -emit_zsh_subcommands() { - for entry in "${MOLE_COMMANDS[@]}"; do - printf " '%s:%s'\n" "${entry%%:*}" "${entry#*:}" - done -} - -emit_fish_completions() { - local cmd="$1" - for entry in "${MOLE_COMMANDS[@]}"; do - local name="${entry%%:*}" - local desc="${entry#*:}" - printf 'complete -c %s -n "__fish_mole_no_subcommand" -a %s -d "%s"\n' "$cmd" "$name" "$desc" - done - - printf '\n' - printf 'complete -c %s -n "not __fish_mole_no_subcommand" -a bash -d "generate bash completion" -n "__fish_see_subcommand_path completion"\n' "$cmd" - printf 'complete -c %s -n "not __fish_mole_no_subcommand" -a zsh -d "generate zsh completion" -n "__fish_see_subcommand_path completion"\n' "$cmd" - printf 'complete -c %s -n "not __fish_mole_no_subcommand" -a fish -d "generate fish completion" -n "__fish_see_subcommand_path completion"\n' "$cmd" -} - -# Auto-install mode when run without arguments -if [[ $# -eq 0 ]]; then - # Detect current shell - current_shell="${SHELL##*/}" - if [[ -z "$current_shell" ]]; then - current_shell="$(ps -p "$PPID" -o comm= 2> /dev/null | awk '{print $1}')" - fi - - completion_name="" - if command -v mole > /dev/null 2>&1; then - completion_name="mole" - elif command -v mo > /dev/null 2>&1; then - completion_name="mo" - fi - - case "$current_shell" in - bash) - config_file="${HOME}/.bashrc" - [[ -f "${HOME}/.bash_profile" ]] && config_file="${HOME}/.bash_profile" - # shellcheck disable=SC2016 - completion_line='if output="$('"$completion_name"' completion bash 2>/dev/null)"; then eval "$output"; fi' - ;; - zsh) - config_file="${HOME}/.zshrc" - # shellcheck disable=SC2016 - completion_line='if output="$('"$completion_name"' completion zsh 2>/dev/null)"; then eval "$output"; fi' - ;; - fish) - config_file="${HOME}/.config/fish/config.fish" - # shellcheck disable=SC2016 - completion_line='set -l output ('"$completion_name"' completion fish 2>/dev/null); and echo "$output" | source' - ;; - *) - log_error "Unsupported shell: $current_shell" - echo " mole completion " - exit 1 - ;; - esac - - if [[ -z "$completion_name" ]]; then - if [[ -f "$config_file" ]] && grep -Eq "(^# Mole shell completion$|(mole|mo)[[:space:]]+completion)" "$config_file" 2> /dev/null; then - original_mode="" - original_mode="$(stat -f '%Mp%Lp' "$config_file" 2> /dev/null || true)" - temp_file="$(mktemp)" - grep -Ev "(^# Mole shell completion$|(mole|mo)[[:space:]]+completion)" "$config_file" > "$temp_file" || true - mv "$temp_file" "$config_file" - if [[ -n "$original_mode" ]]; then - chmod "$original_mode" "$config_file" 2> /dev/null || true - fi - echo -e "${GREEN}${ICON_SUCCESS}${NC} Removed stale completion entries from $config_file" - echo "" - fi - log_error "mole not found in PATH - install Mole before enabling completion" - exit 1 - fi - - # Check if already installed and normalize to latest line - if [[ -f "$config_file" ]] && grep -Eq "(mole|mo)[[:space:]]+completion" "$config_file" 2> /dev/null; then - original_mode="" - original_mode="$(stat -f '%Mp%Lp' "$config_file" 2> /dev/null || true)" - temp_file="$(mktemp)" - grep -Ev "(^# Mole shell completion$|(mole|mo)[[:space:]]+completion)" "$config_file" > "$temp_file" || true - mv "$temp_file" "$config_file" - if [[ -n "$original_mode" ]]; then - chmod "$original_mode" "$config_file" 2> /dev/null || true - fi - { - echo "" - echo "# Mole shell completion" - echo "$completion_line" - } >> "$config_file" - echo "" - echo -e "${GREEN}${ICON_SUCCESS}${NC} Shell completion updated in $config_file" - echo "" - exit 0 - fi - - # Prompt user for installation - echo "" - echo -e "${GRAY}Will add to ${config_file}:${NC}" - echo " $completion_line" - echo "" - echo -ne "${PURPLE}${ICON_ARROW}${NC} Enable completion for ${GREEN}${current_shell}${NC}? ${GRAY}Enter confirm / Q cancel${NC}: " - IFS= read -r -s -n1 key || key="" - drain_pending_input - echo "" - - case "$key" in - $'\e' | [Qq] | [Nn]) - echo -e "${YELLOW}Cancelled${NC}" - exit 0 - ;; - "" | $'\n' | $'\r' | [Yy]) ;; - *) - log_error "Invalid key" - exit 1 - ;; - esac - - # Create config file if it doesn't exist - if [[ ! -f "$config_file" ]]; then - mkdir -p "$(dirname "$config_file")" - touch "$config_file" - fi - - # Remove previous Mole completion lines to avoid duplicates - if [[ -f "$config_file" ]]; then - original_mode="" - original_mode="$(stat -f '%Mp%Lp' "$config_file" 2> /dev/null || true)" - temp_file="$(mktemp)" - grep -Ev "(^# Mole shell completion$|(mole|mo)[[:space:]]+completion)" "$config_file" > "$temp_file" || true - mv "$temp_file" "$config_file" - if [[ -n "$original_mode" ]]; then - chmod "$original_mode" "$config_file" 2> /dev/null || true - fi - fi - - # Add completion line - { - echo "" - echo "# Mole shell completion" - echo "$completion_line" - } >> "$config_file" - - echo -e "${GREEN}${ICON_SUCCESS}${NC} Completion added to $config_file" - echo "" - echo "" - echo -e "${GRAY}To activate now:${NC}" - echo -e " ${GREEN}source $config_file${NC}" - exit 0 -fi - -case "$1" in - bash) - cat << EOF -_mole_completions() -{ - local cur_word prev_word - cur_word="\${COMP_WORDS[\$COMP_CWORD]}" - prev_word="\${COMP_WORDS[\$COMP_CWORD-1]}" - - if [ "\$COMP_CWORD" -eq 1 ]; then - COMPREPLY=( \$(compgen -W "$command_words" -- "\$cur_word") ) - else - case "\$prev_word" in - completion) - COMPREPLY=( \$(compgen -W "bash zsh fish" -- "\$cur_word") ) - ;; - *) - COMPREPLY=() - ;; - esac - fi -} - -complete -F _mole_completions mole mo -EOF - ;; - zsh) - printf '#compdef mole mo\n\n' - printf '_mole() {\n' - printf ' local -a subcommands\n' - printf ' subcommands=(\n' - emit_zsh_subcommands - printf ' )\n' - printf " _describe 'subcommand' subcommands\n" - printf '}\n\n' - printf 'compdef _mole mole mo\n' - ;; - fish) - printf '# Completions for mole\n' - emit_fish_completions mole - printf '\n# Completions for mo (alias)\n' - emit_fish_completions mo - printf '\nfunction __fish_mole_no_subcommand\n' - printf ' for i in (commandline -opc)\n' - # shellcheck disable=SC2016 - printf ' if contains -- $i %s\n' "$command_words" - printf ' return 1\n' - printf ' end\n' - printf ' end\n' - printf ' return 0\n' - printf 'end\n\n' - printf 'function __fish_see_subcommand_path\n' - printf ' string match -q -- "completion" (commandline -opc)[1]\n' - printf 'end\n' - ;; - *) - cat << 'EOF' -Usage: mole completion [bash|zsh|fish] - -Setup shell tab completion for mole and mo commands. - -Auto-install: - mole completion # Auto-detect shell and install - -Manual install: - mole completion bash # Generate bash completion script - mole completion zsh # Generate zsh completion script - mole completion fish # Generate fish completion script - -Examples: - # Auto-install (recommended) - mole completion - - # Manual install - Bash - eval "$(mole completion bash)" - - # Manual install - Zsh - eval "$(mole completion zsh)" - - # Manual install - Fish - mole completion fish | source -EOF - exit 1 - ;; -esac diff --git a/bin/installer.sh b/bin/installer.sh deleted file mode 100755 index 8eed412..0000000 --- a/bin/installer.sh +++ /dev/null @@ -1,704 +0,0 @@ -#!/bin/bash -# Mole - Installer command -# Find and remove installer files - .dmg, .pkg, .mpkg, .iso, .xip, .zip - -set -euo pipefail - -# shellcheck disable=SC2154 -# External variables set by menu_paginated.sh and environment -declare MOLE_SELECTION_RESULT -declare MOLE_INSTALLER_SCAN_MAX_DEPTH - -export LC_ALL=C -export LANG=C - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/../lib/core/common.sh" -source "$SCRIPT_DIR/../lib/ui/menu_paginated.sh" - -cleanup() { - if [[ "${IN_ALT_SCREEN:-0}" == "1" ]]; then - leave_alt_screen - IN_ALT_SCREEN=0 - fi - show_cursor - cleanup_temp_files -} -trap cleanup EXIT -trap 'trap - EXIT; cleanup; exit 130' INT TERM - -# Scan configuration -readonly INSTALLER_SCAN_MAX_DEPTH_DEFAULT=2 -readonly INSTALLER_SCAN_PATHS=( - "$HOME/Downloads" - "$HOME/Desktop" - "$HOME/Documents" - "$HOME/Public" - "$HOME/Library/Downloads" - "/Users/Shared" - "/Users/Shared/Downloads" - "$HOME/Library/Caches/Homebrew" - "$HOME/Library/Mobile Documents/com~apple~CloudDocs/Downloads" - "$HOME/Library/Containers/com.apple.mail/Data/Library/Mail Downloads" - "$HOME/Library/Application Support/Telegram Desktop" - "$HOME/Downloads/Telegram Desktop" -) -readonly MAX_ZIP_ENTRIES=50 -ZIP_LIST_CMD=() -IN_ALT_SCREEN=0 - -if command -v zipinfo > /dev/null 2>&1; then - ZIP_LIST_CMD=(zipinfo -1) -elif command -v unzip > /dev/null 2>&1; then - ZIP_LIST_CMD=(unzip -Z -1) -fi - -TERMINAL_WIDTH=0 - -# Check for installer payloads inside ZIP - check first N entries for installer patterns -is_installer_zip() { - local zip="$1" - local cap="$MAX_ZIP_ENTRIES" - - [[ ${#ZIP_LIST_CMD[@]} -gt 0 ]] || return 1 - - if ! "${ZIP_LIST_CMD[@]}" "$zip" 2> /dev/null | - head -n "$cap" | - awk ' - /\.(app|pkg|dmg|xip)(\/|$)/ { found=1; exit 0 } - END { exit found ? 0 : 1 } - '; then - return 1 - fi - - return 0 -} - -handle_candidate_file() { - local file="$1" - - [[ -L "$file" ]] && return 0 # Skip symlinks explicitly - case "$file" in - *.dmg | *.pkg | *.mpkg | *.iso | *.xip) - echo "$file" - ;; - *.zip) - [[ -r "$file" ]] || return 0 - if is_installer_zip "$file" 2> /dev/null; then - echo "$file" - fi - ;; - esac -} - -scan_installers_in_path() { - local path="$1" - local max_depth="${MOLE_INSTALLER_SCAN_MAX_DEPTH:-$INSTALLER_SCAN_MAX_DEPTH_DEFAULT}" - - [[ -d "$path" ]] || return 0 - - local file - - if command -v fd > /dev/null 2>&1; then - while IFS= read -r file; do - handle_candidate_file "$file" - done < <( - fd --no-ignore --hidden --type f --max-depth "$max_depth" \ - -e dmg -e pkg -e mpkg -e iso -e xip -e zip \ - . "$path" 2> /dev/null || true - ) - else - while IFS= read -r file; do - handle_candidate_file "$file" - done < <( - find "$path" -maxdepth "$max_depth" -type f \ - \( -name '*.dmg' -o -name '*.pkg' -o -name '*.mpkg' \ - -o -name '*.iso' -o -name '*.xip' -o -name '*.zip' \) \ - 2> /dev/null || true - ) - fi -} - -scan_all_installers() { - for path in "${INSTALLER_SCAN_PATHS[@]}"; do - scan_installers_in_path "$path" - done -} - -# Initialize stats -declare -i total_deleted=0 -declare -i total_size_freed_kb=0 - -# Global arrays for installer data -declare -a INSTALLER_PATHS=() -declare -a INSTALLER_SIZES=() -declare -a INSTALLER_SOURCES=() -declare -a DISPLAY_NAMES=() - -# Get source directory display name - for example "Downloads" or "Desktop" -get_source_display() { - local file_path="$1" - local dir_path="${file_path%/*}" - - # Match against known paths and return friendly names - case "$dir_path" in - "$HOME/Downloads"*) echo "Downloads" ;; - "$HOME/Desktop"*) echo "Desktop" ;; - "$HOME/Documents"*) echo "Documents" ;; - "$HOME/Public"*) echo "Public" ;; - "$HOME/Library/Downloads"*) echo "Library" ;; - "/Users/Shared"*) echo "Shared" ;; - "$HOME/Library/Caches/Homebrew"*) echo "Homebrew" ;; - "$HOME/Library/Mobile Documents/com~apple~CloudDocs/Downloads"*) echo "iCloud" ;; - "$HOME/Library/Containers/com.apple.mail"*) echo "Mail" ;; - *"Telegram Desktop"*) echo "Telegram" ;; - *) echo "${dir_path##*/}" ;; - esac -} - -get_terminal_width() { - if [[ $TERMINAL_WIDTH -le 0 ]]; then - TERMINAL_WIDTH=$(tput cols 2> /dev/null || echo 80) - fi - echo "$TERMINAL_WIDTH" -} - -# Format installer display with alignment - similar to purge command -format_installer_display() { - local filename="$1" - local size_str="$2" - local source="$3" - - # Terminal width for alignment - local terminal_width - terminal_width=$(get_terminal_width) - local fixed_width=24 # Reserve for size and source - local available_width=$((terminal_width - fixed_width)) - - # Bounds check: 20-40 chars for filename - [[ $available_width -lt 20 ]] && available_width=20 - [[ $available_width -gt 40 ]] && available_width=40 - - # Truncate filename if needed - local truncated_name - truncated_name=$(truncate_by_display_width "$filename" "$available_width") - local current_width - current_width=$(get_display_width "$truncated_name") - local char_count=${#truncated_name} - local padding=$((available_width - current_width)) - local printf_width=$((char_count + padding)) - - # Format: "filename size | source" - printf "%-*s %8s | %-10s" "$printf_width" "$truncated_name" "$size_str" "$source" -} - -# Collect all installers with their metadata -collect_installers() { - # Clear previous results - INSTALLER_PATHS=() - INSTALLER_SIZES=() - INSTALLER_SOURCES=() - DISPLAY_NAMES=() - - # Start scanning with spinner - if [[ -t 1 ]]; then - start_inline_spinner "Scanning for installers..." - fi - - # Start debug session - debug_operation_start "Collect Installers" "Scanning for redundant installer files" - - # Scan all paths, deduplicate, and sort results - local -a all_files=() - - while IFS= read -r file; do - [[ -z "$file" ]] && continue - all_files+=("$file") - debug_file_action "Found installer" "$file" - done < <(scan_all_installers | sort -u) - - if [[ -t 1 ]]; then - stop_inline_spinner - fi - - if [[ ${#all_files[@]} -eq 0 ]]; then - if [[ "${IN_ALT_SCREEN:-0}" != "1" ]]; then - echo -e "${GREEN}${ICON_SUCCESS}${NC} Great! No installer files to clean" - fi - return 1 - fi - - # Calculate sizes with spinner - if [[ -t 1 ]]; then - start_inline_spinner "Calculating sizes..." - fi - - # Process each installer - for file in "${all_files[@]}"; do - # Calculate file size - local file_size=0 - if [[ -f "$file" ]]; then - file_size=$(get_file_size "$file") - fi - - # Get source directory - local source - source=$(get_source_display "$file") - - # Format human readable size - local size_human - size_human=$(bytes_to_human "$file_size") - - # Get display filename - strip Homebrew hash prefix if present - local display_name - display_name=$(basename "$file") - if [[ "$source" == "Homebrew" ]]; then - # Homebrew names often look like: sha256--name--version - # Strip the leading hash if it matches [0-9a-f]{64}-- - if [[ "$display_name" =~ ^[0-9a-f]{64}--(.*) ]]; then - display_name="${BASH_REMATCH[1]}" - fi - fi - - # Format display with alignment - local display - display=$(format_installer_display "$display_name" "$size_human" "$source") - - # Store installer data in parallel arrays - INSTALLER_PATHS+=("$file") - INSTALLER_SIZES+=("$file_size") - INSTALLER_SOURCES+=("$source") - DISPLAY_NAMES+=("$display") - done - - if [[ -t 1 ]]; then - stop_inline_spinner - fi - return 0 -} - -# Installer selector with Select All / Invert support -select_installers() { - local -a items=("$@") - local total_items=${#items[@]} - local clear_line=$'\r\033[2K' - - if [[ $total_items -eq 0 ]]; then - return 1 - fi - - # Calculate items per page based on terminal height - _get_items_per_page() { - local term_height=24 - if [[ -t 0 ]] || [[ -t 2 ]]; then - term_height=$(stty size < /dev/tty 2> /dev/null | awk '{print $1}') - fi - if [[ -z "$term_height" || $term_height -le 0 ]]; then - if command -v tput > /dev/null 2>&1; then - term_height=$(tput lines 2> /dev/null || echo "24") - else - term_height=24 - fi - fi - local reserved=6 - local available=$((term_height - reserved)) - if [[ $available -lt 3 ]]; then - echo 3 - elif [[ $available -gt 50 ]]; then - echo 50 - else - echo "$available" - fi - } - - local items_per_page=$(_get_items_per_page) - local cursor_pos=0 - local top_index=0 - - # Initialize selection (all unselected by default) - local -a selected=() - for ((i = 0; i < total_items; i++)); do - selected[i]=false - done - - local original_stty="" - if [[ -t 0 ]] && command -v stty > /dev/null 2>&1; then - original_stty=$(stty -g 2> /dev/null || echo "") - fi - - restore_terminal() { - trap - EXIT INT TERM - if [[ "${IN_ALT_SCREEN:-0}" == "1" ]]; then - leave_alt_screen - IN_ALT_SCREEN=0 - fi - show_cursor - if [[ -n "${original_stty:-}" ]]; then - stty "${original_stty}" 2> /dev/null || stty sane 2> /dev/null || true - fi - } - - handle_interrupt() { - restore_terminal - exit 130 - } - - draw_menu() { - items_per_page=$(_get_items_per_page) - - local max_top_index=0 - if [[ $total_items -gt $items_per_page ]]; then - max_top_index=$((total_items - items_per_page)) - fi - if [[ $top_index -gt $max_top_index ]]; then - top_index=$max_top_index - fi - if [[ $top_index -lt 0 ]]; then - top_index=0 - fi - - local visible_count=$((total_items - top_index)) - [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page - if [[ $cursor_pos -gt $((visible_count - 1)) ]]; then - cursor_pos=$((visible_count - 1)) - fi - if [[ $cursor_pos -lt 0 ]]; then - cursor_pos=0 - fi - - printf "\033[H" - - # Calculate selected size and count - local selected_size=0 - local selected_count=0 - for ((i = 0; i < total_items; i++)); do - if [[ ${selected[i]} == true ]]; then - selected_size=$((selected_size + ${INSTALLER_SIZES[i]:-0})) - ((selected_count++)) - fi - done - local selected_human - selected_human=$(bytes_to_human "$selected_size") - - # Show position indicator if scrolling is needed - local scroll_indicator="" - if [[ $total_items -gt $items_per_page ]]; then - local current_pos=$((top_index + cursor_pos + 1)) - 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 "%s\n" "$clear_line" - - # Calculate visible range - local end_index=$((top_index + visible_count)) - - # Draw only visible items - for ((i = top_index; i < end_index; i++)); do - local checkbox="$ICON_EMPTY" - [[ ${selected[i]} == true ]] && checkbox="$ICON_SOLID" - local rel_pos=$((i - top_index)) - if [[ $rel_pos -eq $cursor_pos ]]; then - printf "%s${CYAN}${ICON_ARROW} %s %s${NC}\n" "$clear_line" "$checkbox" "${items[i]}" - else - printf "%s %s %s\n" "$clear_line" "$checkbox" "${items[i]}" - fi - done - - # Fill empty slots - local items_shown=$visible_count - for ((i = items_shown; i < items_per_page; i++)); do - printf "%s\n" "$clear_line" - done - - printf "%s\n" "$clear_line" - printf "%s${GRAY}${ICON_NAV_UP}${ICON_NAV_DOWN} | Space Select | Enter Confirm | A All | I Invert | Q Quit${NC}\n" "$clear_line" - } - - trap restore_terminal EXIT - trap handle_interrupt INT TERM - stty -echo -icanon intr ^C 2> /dev/null || true - hide_cursor - if [[ -t 1 ]]; then - printf "\033[2J\033[H" >&2 - fi - - # Main loop - while true; do - draw_menu - - IFS= read -r -s -n1 key || key="" - case "$key" in - $'\x1b') - IFS= read -r -s -n1 -t 1 key2 || key2="" - if [[ "$key2" == "[" ]]; then - IFS= read -r -s -n1 -t 1 key3 || key3="" - case "$key3" in - A) # Up arrow - if [[ $cursor_pos -gt 0 ]]; then - ((cursor_pos--)) - elif [[ $top_index -gt 0 ]]; then - ((top_index--)) - fi - ;; - B) # Down arrow - local absolute_index=$((top_index + cursor_pos)) - local last_index=$((total_items - 1)) - if [[ $absolute_index -lt $last_index ]]; then - local visible_count=$((total_items - top_index)) - [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page - if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then - ((cursor_pos++)) - elif [[ $((top_index + visible_count)) -lt $total_items ]]; then - ((top_index++)) - fi - fi - ;; - esac - else - # ESC alone - restore_terminal - return 1 - fi - ;; - " ") # Space - toggle current item - local idx=$((top_index + cursor_pos)) - if [[ ${selected[idx]} == true ]]; then - selected[idx]=false - else - selected[idx]=true - fi - ;; - "a" | "A") # Select all - for ((i = 0; i < total_items; i++)); do - selected[i]=true - done - ;; - "i" | "I") # Invert selection - for ((i = 0; i < total_items; i++)); do - if [[ ${selected[i]} == true ]]; then - selected[i]=false - else - selected[i]=true - fi - done - ;; - "q" | "Q" | $'\x03') # Quit or Ctrl-C - restore_terminal - return 1 - ;; - "" | $'\n' | $'\r') # Enter - confirm - MOLE_SELECTION_RESULT="" - for ((i = 0; i < total_items; i++)); do - if [[ ${selected[i]} == true ]]; then - [[ -n "$MOLE_SELECTION_RESULT" ]] && MOLE_SELECTION_RESULT+="," - MOLE_SELECTION_RESULT+="$i" - fi - done - restore_terminal - return 0 - ;; - esac - done -} - -# Show menu for user selection -show_installer_menu() { - if [[ ${#DISPLAY_NAMES[@]} -eq 0 ]]; then - return 1 - fi - - echo "" - - MOLE_SELECTION_RESULT="" - if ! select_installers "${DISPLAY_NAMES[@]}"; then - return 1 - fi - - return 0 -} - -# Delete selected installers -delete_selected_installers() { - # Parse selection indices - local -a selected_indices=() - [[ -n "$MOLE_SELECTION_RESULT" ]] && IFS=',' read -ra selected_indices <<< "$MOLE_SELECTION_RESULT" - - if [[ ${#selected_indices[@]} -eq 0 ]]; then - return 1 - fi - - # Calculate total size for confirmation - local confirm_size=0 - for idx in "${selected_indices[@]}"; do - if [[ "$idx" =~ ^[0-9]+$ ]] && [[ $idx -lt ${#INSTALLER_SIZES[@]} ]]; then - confirm_size=$((confirm_size + ${INSTALLER_SIZES[$idx]:-0})) - fi - done - local confirm_human - confirm_human=$(bytes_to_human "$confirm_size") - - # Show files to be deleted - echo -e "${PURPLE_BOLD}Files to be removed:${NC}" - for idx in "${selected_indices[@]}"; do - if [[ "$idx" =~ ^[0-9]+$ ]] && [[ $idx -lt ${#INSTALLER_PATHS[@]} ]]; then - local file_path="${INSTALLER_PATHS[$idx]}" - 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}" - 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: " - - IFS= read -r -s -n1 confirm || confirm="" - case "$confirm" in - $'\e' | q | Q) - return 1 - ;; - "" | $'\n' | $'\r') - printf "\r\033[K" # Clear prompt line - echo "" # Single line break - ;; - *) - return 1 - ;; - esac - - # Delete each selected installer with spinner - total_deleted=0 - total_size_freed_kb=0 - - if [[ -t 1 ]]; then - start_inline_spinner "Removing installers..." - fi - - for idx in "${selected_indices[@]}"; do - if [[ ! "$idx" =~ ^[0-9]+$ ]] || [[ $idx -ge ${#INSTALLER_PATHS[@]} ]]; then - continue - fi - - local file_path="${INSTALLER_PATHS[$idx]}" - local file_size="${INSTALLER_SIZES[$idx]}" - - # Validate path before deletion - if ! validate_path_for_deletion "$file_path"; then - continue - fi - - # Delete the file - if safe_remove "$file_path" true; then - total_size_freed_kb=$((total_size_freed_kb + ((file_size + 1023) / 1024))) - total_deleted=$((total_deleted + 1)) - fi - done - - if [[ -t 1 ]]; then - stop_inline_spinner - fi - - return 0 -} - -# Perform the installers cleanup -perform_installers() { - # Enter alt screen for scanning and selection - if [[ -t 1 ]]; then - enter_alt_screen - IN_ALT_SCREEN=1 - printf "\033[2J\033[H" >&2 - fi - - # Collect installers - if ! collect_installers; then - if [[ -t 1 ]]; then - leave_alt_screen - IN_ALT_SCREEN=0 - fi - printf '\n' - echo -e "${GREEN}${ICON_SUCCESS}${NC} Great! No installer files to clean" - printf '\n' - return 2 # Nothing to clean - fi - - # Show menu - if ! show_installer_menu; then - if [[ -t 1 ]]; then - leave_alt_screen - IN_ALT_SCREEN=0 - fi - return 1 # User cancelled - fi - - # Leave alt screen before deletion (so confirmation and results are on main screen) - if [[ -t 1 ]]; then - leave_alt_screen - IN_ALT_SCREEN=0 - fi - - # Delete selected - if ! delete_selected_installers; then - return 1 - fi - - return 0 -} - -show_summary() { - local summary_heading="Installers cleaned" - local -a summary_details=() - - if [[ $total_deleted -gt 0 ]]; then - local freed_mb - freed_mb=$(echo "$total_size_freed_kb" | awk '{printf "%.2f", $1/1024}') - - summary_details+=("Removed ${GREEN}$total_deleted${NC} installer(s), freed ${GREEN}${freed_mb}MB${NC}") - summary_details+=("Your Mac is cleaner now!") - else - summary_details+=("No installers were removed") - fi - - print_summary_block "$summary_heading" "${summary_details[@]}" - printf '\n' -} - -main() { - for arg in "$@"; do - case "$arg" in - "--debug") - export MO_DEBUG=1 - ;; - *) - echo "Unknown option: $arg" - exit 1 - ;; - esac - done - - hide_cursor - perform_installers - local exit_code=$? - show_cursor - - case $exit_code in - 0) - show_summary - ;; - 1) - printf '\n' - ;; - 2) - # Already handled by collect_installers - ;; - esac - - return 0 -} - -# Only run main if not in test mode -if [[ "${MOLE_TEST_MODE:-0}" != "1" ]]; then - main "$@" -fi diff --git a/windows/bin/optimize.ps1 b/bin/optimize.ps1 similarity index 100% rename from windows/bin/optimize.ps1 rename to bin/optimize.ps1 diff --git a/bin/optimize.sh b/bin/optimize.sh deleted file mode 100755 index 07e7d81..0000000 --- a/bin/optimize.sh +++ /dev/null @@ -1,509 +0,0 @@ -#!/bin/bash -# Mole - Optimize command. -# Runs system maintenance checks and fixes. -# Supports dry-run where applicable. - -set -euo pipefail - -# Fix locale issues. -export LC_ALL=C -export LANG=C - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -source "$SCRIPT_DIR/lib/core/common.sh" - -# Clean temp files on exit. -trap cleanup_temp_files EXIT INT TERM -source "$SCRIPT_DIR/lib/core/sudo.sh" -source "$SCRIPT_DIR/lib/manage/update.sh" -source "$SCRIPT_DIR/lib/manage/autofix.sh" -source "$SCRIPT_DIR/lib/optimize/maintenance.sh" -source "$SCRIPT_DIR/lib/optimize/tasks.sh" -source "$SCRIPT_DIR/lib/check/health_json.sh" -source "$SCRIPT_DIR/lib/check/all.sh" -source "$SCRIPT_DIR/lib/manage/whitelist.sh" - -print_header() { - printf '\n' - echo -e "${PURPLE_BOLD}Optimize and Check${NC}" -} - -run_system_checks() { - # Skip checks in dry-run mode. - if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then - return 0 - fi - - unset AUTO_FIX_SUMMARY AUTO_FIX_DETAILS - unset MOLE_SECURITY_FIXES_SHOWN - unset MOLE_SECURITY_FIXES_SKIPPED - echo "" - - check_all_updates - echo "" - - check_system_health - echo "" - - check_all_security - if ask_for_security_fixes; then - perform_security_fixes - fi - if [[ "${MOLE_SECURITY_FIXES_SKIPPED:-}" != "true" ]]; then - echo "" - fi - - check_all_config - echo "" - - show_suggestions - - if ask_for_updates; then - perform_updates - fi - if ask_for_auto_fix; then - perform_auto_fix - fi -} - -show_optimization_summary() { - local safe_count="${OPTIMIZE_SAFE_COUNT:-0}" - local confirm_count="${OPTIMIZE_CONFIRM_COUNT:-0}" - if ((safe_count == 0 && confirm_count == 0)) && [[ -z "${AUTO_FIX_SUMMARY:-}" ]]; then - return - fi - - local summary_title - local -a summary_details=() - local total_applied=$((safe_count + confirm_count)) - - if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then - 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 - summary_title="Optimization and Check Complete" - - # Build statistics summary - local -a stats=() - local cache_kb="${OPTIMIZE_CACHE_CLEANED_KB:-0}" - local db_count="${OPTIMIZE_DATABASES_COUNT:-0}" - local config_count="${OPTIMIZE_CONFIGS_REPAIRED:-0}" - - if [[ "$cache_kb" =~ ^[0-9]+$ ]] && [[ "$cache_kb" -gt 0 ]]; then - local cache_human=$(bytes_to_human "$((cache_kb * 1024))") - stats+=("${cache_human} cache cleaned") - fi - - if [[ "$db_count" =~ ^[0-9]+$ ]] && [[ "$db_count" -gt 0 ]]; then - stats+=("${db_count} databases optimized") - fi - - if [[ "$config_count" =~ ^[0-9]+$ ]] && [[ "$config_count" -gt 0 ]]; then - stats+=("${config_count} configs repaired") - fi - - # Build first summary line with most important stat only - local key_stat="" - if [[ "$cache_kb" =~ ^[0-9]+$ ]] && [[ "$cache_kb" -gt 0 ]]; then - local cache_human=$(bytes_to_human "$((cache_kb * 1024))") - key_stat="${cache_human} cache cleaned" - elif [[ "$db_count" =~ ^[0-9]+$ ]] && [[ "$db_count" -gt 0 ]]; then - key_stat="${db_count} databases optimized" - elif [[ "$config_count" =~ ^[0-9]+$ ]] && [[ "$config_count" -gt 0 ]]; then - key_stat="${config_count} configs repaired" - fi - - if [[ -n "$key_stat" ]]; then - summary_details+=("Applied ${GREEN}${total_applied:-0}${NC} optimizations — ${key_stat}") - else - summary_details+=("Applied ${GREEN}${total_applied:-0}${NC} optimizations — all services tuned") - fi - - local summary_line3="" - if [[ -n "${AUTO_FIX_SUMMARY:-}" ]]; then - summary_line3="${AUTO_FIX_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}" - fi - summary_details+=("$summary_line3") - fi - summary_details+=("System fully optimized — faster, more secure and responsive") - fi - - print_summary_block "$summary_title" "${summary_details[@]}" -} - -show_system_health() { - local health_json="$1" - - local mem_used=$(echo "$health_json" | jq -r '.memory_used_gb // 0' 2> /dev/null || echo "0") - local mem_total=$(echo "$health_json" | jq -r '.memory_total_gb // 0' 2> /dev/null || echo "0") - local disk_used=$(echo "$health_json" | jq -r '.disk_used_gb // 0' 2> /dev/null || echo "0") - local disk_total=$(echo "$health_json" | jq -r '.disk_total_gb // 0' 2> /dev/null || echo "0") - local disk_percent=$(echo "$health_json" | jq -r '.disk_used_percent // 0' 2> /dev/null || echo "0") - local uptime=$(echo "$health_json" | jq -r '.uptime_days // 0' 2> /dev/null || echo "0") - - mem_used=${mem_used:-0} - mem_total=${mem_total:-0} - disk_used=${disk_used:-0} - disk_total=${disk_total:-0} - disk_percent=${disk_percent:-0} - uptime=${uptime:-0} - - printf "${ICON_ADMIN} System %.0f/%.0f GB RAM | %.0f/%.0f GB Disk | Uptime %.0fd\n" \ - "$mem_used" "$mem_total" "$disk_used" "$disk_total" "$uptime" -} - -parse_optimizations() { - local health_json="$1" - echo "$health_json" | jq -c '.optimizations[]' 2> /dev/null -} - -announce_action() { - local name="$1" - local desc="$2" - local kind="$3" - - if [[ "${FIRST_ACTION:-true}" == "true" ]]; then - export FIRST_ACTION=false - else - echo "" - fi - echo -e "${BLUE}${ICON_ARROW} ${name}${NC}" -} - -touchid_configured() { - local pam_file="/etc/pam.d/sudo" - [[ -f "$pam_file" ]] && grep -q "pam_tid.so" "$pam_file" 2> /dev/null -} - -touchid_supported() { - if command -v bioutil > /dev/null 2>&1; then - if bioutil -r 2> /dev/null | grep -qi "Touch ID"; then - return 0 - fi - fi - - # Fallback: Apple Silicon Macs usually have Touch ID. - if [[ "$(uname -m)" == "arm64" ]]; then - return 0 - fi - return 1 -} - -cleanup_path() { - local raw_path="$1" - local label="$2" - - local expanded_path="${raw_path/#\~/$HOME}" - if [[ ! -e "$expanded_path" ]]; then - echo -e "${GREEN}${ICON_SUCCESS}${NC} $label" - return - fi - if should_protect_path "$expanded_path"; then - echo -e "${YELLOW}${ICON_WARNING}${NC} Protected $label" - return - fi - - local size_kb - size_kb=$(get_path_size_kb "$expanded_path") - local size_display="" - if [[ "$size_kb" =~ ^[0-9]+$ && "$size_kb" -gt 0 ]]; then - size_display=$(bytes_to_human "$((size_kb * 1024))") - fi - - local removed=false - if safe_remove "$expanded_path" true; then - removed=true - elif request_sudo_access "Removing $label requires admin access"; then - if safe_sudo_remove "$expanded_path"; then - removed=true - fi - fi - - if [[ "$removed" == "true" ]]; then - if [[ -n "$size_display" ]]; then - echo -e "${GREEN}${ICON_SUCCESS}${NC} $label ${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}" - fi -} - -ensure_directory() { - local raw_path="$1" - local expanded_path="${raw_path/#\~/$HOME}" - ensure_user_dir "$expanded_path" -} - -declare -a SECURITY_FIXES=() - -collect_security_fix_actions() { - SECURITY_FIXES=() - if [[ "${FIREWALL_DISABLED:-}" == "true" ]]; then - if ! is_whitelisted "firewall"; then - SECURITY_FIXES+=("firewall|Enable macOS firewall") - fi - fi - if [[ "${GATEKEEPER_DISABLED:-}" == "true" ]]; then - if ! is_whitelisted "gatekeeper"; then - SECURITY_FIXES+=("gatekeeper|Enable Gatekeeper (App download protection)") - fi - fi - if touchid_supported && ! touchid_configured; then - if ! is_whitelisted "check_touchid"; then - SECURITY_FIXES+=("touchid|Enable Touch ID for sudo") - fi - fi - - ((${#SECURITY_FIXES[@]} > 0)) -} - -ask_for_security_fixes() { - if ! collect_security_fix_actions; then - return 1 - fi - - echo "" - echo -e "${BLUE}SECURITY FIXES${NC}" - for entry in "${SECURITY_FIXES[@]}"; do - IFS='|' read -r _ label <<< "$entry" - echo -e " ${ICON_LIST} $label" - done - echo "" - export MOLE_SECURITY_FIXES_SHOWN=true - echo -ne "${YELLOW}Apply now?${NC} ${GRAY}Enter confirm / Space cancel${NC}: " - - local key - if ! key=$(read_key); then - export MOLE_SECURITY_FIXES_SKIPPED=true - echo -e "\n ${GRAY}${ICON_WARNING}${NC} Security fixes skipped" - echo "" - return 1 - fi - - if [[ "$key" == "ENTER" ]]; then - echo "" - return 0 - else - export MOLE_SECURITY_FIXES_SKIPPED=true - echo -e "\n ${GRAY}${ICON_WARNING}${NC} Security fixes skipped" - echo "" - return 1 - fi -} - -apply_firewall_fix() { - if sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setglobalstate on > /dev/null 2>&1; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Firewall enabled" - FIREWALL_DISABLED=false - return 0 - fi - echo -e " ${YELLOW}${ICON_WARNING}${NC} Failed to enable firewall (check permissions)" - return 1 -} - -apply_gatekeeper_fix() { - if sudo spctl --master-enable 2> /dev/null; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Gatekeeper enabled" - GATEKEEPER_DISABLED=false - return 0 - fi - echo -e " ${YELLOW}${ICON_WARNING}${NC} Failed to enable Gatekeeper" - return 1 -} - -apply_touchid_fix() { - if "$SCRIPT_DIR/bin/touchid.sh" enable; then - return 0 - fi - return 1 -} - -perform_security_fixes() { - if ! ensure_sudo_session "Security changes require admin access"; then - echo -e "${YELLOW}${ICON_WARNING}${NC} Skipped security fixes (sudo denied)" - return 1 - fi - - local applied=0 - for entry in "${SECURITY_FIXES[@]}"; do - IFS='|' read -r action _ <<< "$entry" - case "$action" in - firewall) - apply_firewall_fix && ((applied++)) - ;; - gatekeeper) - apply_gatekeeper_fix && ((applied++)) - ;; - touchid) - apply_touchid_fix && ((applied++)) - ;; - esac - done - - if ((applied > 0)); then - log_success "Security settings updated" - fi - SECURITY_FIXES=() -} - -cleanup_all() { - stop_inline_spinner 2> /dev/null || true - stop_sudo_session - cleanup_temp_files -} - -handle_interrupt() { - cleanup_all - exit 130 -} - -main() { - local health_json - for arg in "$@"; do - case "$arg" in - "--debug") - export MO_DEBUG=1 - ;; - "--dry-run") - export MOLE_DRY_RUN=1 - ;; - "--whitelist") - manage_whitelist "optimize" - exit 0 - ;; - esac - done - - trap cleanup_all EXIT - trap handle_interrupt INT TERM - - if [[ -t 1 ]]; then - clear - fi - print_header - - # 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" - fi - - if ! command -v jq > /dev/null 2>&1; then - echo -e "${YELLOW}${ICON_ERROR}${NC} Missing dependency: jq" - echo -e "${GRAY}Install with: ${GREEN}brew install jq${NC}" - exit 1 - fi - - if ! command -v bc > /dev/null 2>&1; then - echo -e "${YELLOW}${ICON_ERROR}${NC} Missing dependency: bc" - echo -e "${GRAY}Install with: ${GREEN}brew install bc${NC}" - exit 1 - fi - - if [[ -t 1 ]]; then - start_inline_spinner "Collecting system info..." - fi - - if ! health_json=$(generate_health_json 2> /dev/null); then - if [[ -t 1 ]]; then - stop_inline_spinner - fi - echo "" - log_error "Failed to collect system health data" - exit 1 - fi - - if ! echo "$health_json" | jq empty 2> /dev/null; then - if [[ -t 1 ]]; then - stop_inline_spinner - fi - echo "" - log_error "Invalid system health data format" - echo -e "${YELLOW}Tip:${NC} Check if jq, awk, sysctl, and df commands are available" - exit 1 - fi - - if [[ -t 1 ]]; then - stop_inline_spinner - fi - - show_system_health "$health_json" - - load_whitelist "optimize" - if [[ ${#CURRENT_WHITELIST_PATTERNS[@]} -gt 0 ]]; then - local count=${#CURRENT_WHITELIST_PATTERNS[@]} - if [[ $count -le 3 ]]; then - local patterns_list=$( - IFS=', ' - echo "${CURRENT_WHITELIST_PATTERNS[*]}" - ) - echo -e "${ICON_ADMIN} Active Whitelist: ${patterns_list}" - fi - fi - - local -a safe_items=() - local -a confirm_items=() - local opts_file - opts_file=$(mktemp_file) - parse_optimizations "$health_json" > "$opts_file" - - while IFS= read -r opt_json; do - [[ -z "$opt_json" ]] && continue - - local name=$(echo "$opt_json" | jq -r '.name') - local desc=$(echo "$opt_json" | jq -r '.description') - local action=$(echo "$opt_json" | jq -r '.action') - local path=$(echo "$opt_json" | jq -r '.path // ""') - local safe=$(echo "$opt_json" | jq -r '.safe') - - local item="${name}|${desc}|${action}|${path}" - - if [[ "$safe" == "true" ]]; then - safe_items+=("$item") - else - confirm_items+=("$item") - fi - done < "$opts_file" - - echo "" - if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then - ensure_sudo_session "System optimization requires admin access" || true - fi - - export FIRST_ACTION=true - if [[ ${#safe_items[@]} -gt 0 ]]; then - for item in "${safe_items[@]}"; do - IFS='|' read -r name desc action path <<< "$item" - announce_action "$name" "$desc" "safe" - execute_optimization "$action" "$path" - done - fi - - if [[ ${#confirm_items[@]} -gt 0 ]]; then - for item in "${confirm_items[@]}"; do - IFS='|' read -r name desc action path <<< "$item" - announce_action "$name" "$desc" "confirm" - execute_optimization "$action" "$path" - done - fi - - local safe_count=${#safe_items[@]} - local confirm_count=${#confirm_items[@]} - - run_system_checks - - export OPTIMIZE_SAFE_COUNT=$safe_count - export OPTIMIZE_CONFIRM_COUNT=$confirm_count - - show_optimization_summary - - printf '\n' -} - -main "$@" diff --git a/windows/bin/purge.ps1 b/bin/purge.ps1 similarity index 100% rename from windows/bin/purge.ps1 rename to bin/purge.ps1 diff --git a/bin/purge.sh b/bin/purge.sh deleted file mode 100755 index 1574243..0000000 --- a/bin/purge.sh +++ /dev/null @@ -1,166 +0,0 @@ -#!/bin/bash -# Mole - Purge command. -# Cleans heavy project build artifacts. -# Interactive selection by project. - -set -euo pipefail - -# Fix locale issues (avoid Perl warnings on non-English systems) -export LC_ALL=C -export LANG=C - -# Get script directory and source common functions -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/../lib/core/common.sh" - -# Set up cleanup trap for temporary files -trap cleanup_temp_files EXIT INT TERM -source "$SCRIPT_DIR/../lib/core/log.sh" -source "$SCRIPT_DIR/../lib/clean/project.sh" - -# Configuration -CURRENT_SECTION="" - -# Section management -start_section() { - local section_name="$1" - CURRENT_SECTION="$section_name" - printf '\n' - echo -e "${BLUE}━━━ ${section_name} ━━━${NC}" -} - -end_section() { - CURRENT_SECTION="" -} - -# Note activity for export list -note_activity() { - if [[ -n "$CURRENT_SECTION" ]]; then - printf '%s\n' "$CURRENT_SECTION" >> "$EXPORT_LIST_FILE" - fi -} - -# Main purge function -start_purge() { - # Clear screen for better UX - if [[ -t 1 ]]; then - printf '\033[2J\033[H' - fi - printf '\n' - echo -e "${PURPLE_BOLD}Purge Project Artifacts${NC}" - - # Initialize stats file in user cache directory - local stats_dir="${XDG_CACHE_HOME:-$HOME/.cache}/mole" - ensure_user_dir "$stats_dir" - ensure_user_file "$stats_dir/purge_stats" - ensure_user_file "$stats_dir/purge_count" - echo "0" > "$stats_dir/purge_stats" - echo "0" > "$stats_dir/purge_count" -} - -# Perform the purge -perform_purge() { - clean_project_artifacts - local exit_code=$? - - # Exit codes: - # 0 = success, show summary - # 1 = user cancelled - # 2 = nothing to clean - if [[ $exit_code -ne 0 ]]; then - return 0 - fi - - # Final summary (matching clean.sh format) - echo "" - - local summary_heading="Purge complete" - local -a summary_details=() - local total_size_cleaned=0 - local total_items_cleaned=0 - - # Read stats from user cache directory - local stats_dir="${XDG_CACHE_HOME:-$HOME/.cache}/mole" - - if [[ -f "$stats_dir/purge_stats" ]]; then - total_size_cleaned=$(cat "$stats_dir/purge_stats" 2> /dev/null || echo "0") - rm -f "$stats_dir/purge_stats" - fi - - # Read count - if [[ -f "$stats_dir/purge_count" ]]; then - total_items_cleaned=$(cat "$stats_dir/purge_count" 2> /dev/null || echo "0") - rm -f "$stats_dir/purge_count" - fi - - if [[ $total_size_cleaned -gt 0 ]]; then - local freed_gb - freed_gb=$(echo "$total_size_cleaned" | awk '{printf "%.2f", $1/1024/1024}') - - summary_details+=("Space freed: ${GREEN}${freed_gb}GB${NC}") - summary_details+=("Free space now: $(get_free_space)") - - if [[ $total_items_cleaned -gt 0 ]]; then - summary_details+=("Items cleaned: $total_items_cleaned") - fi - else - summary_details+=("No old project artifacts to clean.") - summary_details+=("Free space now: $(get_free_space)") - fi - - 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 "" - echo -e "${YELLOW}Usage:${NC} mo purge [options]" - echo "" - echo -e "${YELLOW}Options:${NC}" - echo " --paths Edit custom scan directories" - echo " --debug Enable debug logging" - echo " --help Show this help message" - echo "" - echo -e "${YELLOW}Default Paths:${NC}" - for path in "${DEFAULT_PURGE_SEARCH_PATHS[@]}"; do - echo " - $path" - done -} - -# Main entry point -main() { - # Set up signal handling - trap 'show_cursor; exit 130' INT TERM - - # Parse arguments - for arg in "$@"; do - case "$arg" in - "--paths") - source "$SCRIPT_DIR/../lib/manage/purge_paths.sh" - manage_purge_paths - exit 0 - ;; - "--help") - show_help - exit 0 - ;; - "--debug") - export MO_DEBUG=1 - ;; - *) - echo "Unknown option: $arg" - echo "Use 'mo purge --help' for usage information" - exit 1 - ;; - esac - done - - start_purge - hide_cursor - perform_purge - show_cursor -} - -main "$@" diff --git a/windows/bin/status.ps1 b/bin/status.ps1 similarity index 100% rename from windows/bin/status.ps1 rename to bin/status.ps1 diff --git a/bin/status.sh b/bin/status.sh deleted file mode 100755 index afe6d13..0000000 --- a/bin/status.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/bash -# Mole - Status command. -# Runs the Go system status panel. -# Shows live system metrics. - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -GO_BIN="$SCRIPT_DIR/status-go" -if [[ -x "$GO_BIN" ]]; then - exec "$GO_BIN" "$@" -fi - -echo "Bundled status binary not found. Please reinstall Mole or run mo update to restore it." >&2 -exit 1 diff --git a/bin/touchid.sh b/bin/touchid.sh deleted file mode 100755 index 1f45914..0000000 --- a/bin/touchid.sh +++ /dev/null @@ -1,325 +0,0 @@ -#!/bin/bash -# Mole - Touch ID command. -# Configures sudo with Touch ID. -# Guided toggle with safety checks. - -set -euo pipefail - -# Determine script location and source common functions -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -LIB_DIR="$(cd "$SCRIPT_DIR/../lib" && pwd)" - -# Source common functions -# shellcheck source=../lib/core/common.sh -source "$LIB_DIR/core/common.sh" - -readonly PAM_SUDO_FILE="${MOLE_PAM_SUDO_FILE:-/etc/pam.d/sudo}" -readonly PAM_SUDO_LOCAL_FILE="${MOLE_PAM_SUDO_LOCAL_FILE:-/etc/pam.d/sudo_local}" -readonly PAM_TID_LINE="auth sufficient pam_tid.so" - -# Check if Touch ID is already configured -is_touchid_configured() { - # Check sudo_local first - if [[ -f "$PAM_SUDO_LOCAL_FILE" ]]; then - grep -q "pam_tid.so" "$PAM_SUDO_LOCAL_FILE" 2> /dev/null && return 0 - fi - - # Fallback to standard sudo file - if [[ ! -f "$PAM_SUDO_FILE" ]]; then - return 1 - fi - grep -q "pam_tid.so" "$PAM_SUDO_FILE" 2> /dev/null -} - -# Check if system supports Touch ID -supports_touchid() { - # Check if bioutil exists and has Touch ID capability - if command -v bioutil &> /dev/null; then - bioutil -r 2> /dev/null | grep -q "Touch ID" && return 0 - fi - - # Fallback: check if running on Apple Silicon or modern Intel Mac - local arch - arch=$(uname -m) - if [[ "$arch" == "arm64" ]]; then - return 0 - fi - - # For Intel Macs, check if it's 2018 or later (approximation) - local model_year - model_year=$(system_profiler SPHardwareDataType 2> /dev/null | grep "Model Identifier" | grep -o "[0-9]\{4\}" | head -1) - if [[ -n "$model_year" ]] && [[ "$model_year" -ge 2018 ]]; then - return 0 - fi - - return 1 -} - -# Show current Touch ID status -show_status() { - if is_touchid_configured; then - echo -e "${GREEN}${ICON_SUCCESS}${NC} Touch ID is enabled for sudo" - else - echo -e "${YELLOW}☻${NC} Touch ID is not configured for sudo" - fi -} - -# Enable Touch ID for sudo -enable_touchid() { - # Cleanup trap - local temp_file="" - trap '[[ -n "${temp_file:-}" ]] && rm -f "${temp_file:-}"' EXIT - - # First check if system supports Touch ID - if ! supports_touchid; then - log_warning "This Mac may not support Touch ID" - read -rp "Continue anyway? [y/N] " confirm - if [[ ! "$confirm" =~ ^[Yy]$ ]]; then - echo -e "${YELLOW}Cancelled${NC}" - return 1 - fi - echo "" - fi - - # Check if we should use sudo_local (Sonoma+) - if grep -q "sudo_local" "$PAM_SUDO_FILE"; then - # Check if already correctly configured in sudo_local - if [[ -f "$PAM_SUDO_LOCAL_FILE" ]] && grep -q "pam_tid.so" "$PAM_SUDO_LOCAL_FILE"; then - # It is in sudo_local, but let's check if it's ALSO in sudo (incomplete migration) - if grep -q "pam_tid.so" "$PAM_SUDO_FILE"; then - # Clean up legacy config - temp_file=$(mktemp) - grep -v "pam_tid.so" "$PAM_SUDO_FILE" > "$temp_file" - if sudo mv "$temp_file" "$PAM_SUDO_FILE" 2> /dev/null; then - echo -e "${GREEN}${ICON_SUCCESS} Cleanup legacy configuration${NC}" - fi - fi - echo -e "${GREEN}${ICON_SUCCESS} Touch ID is already enabled${NC}" - return 0 - fi - - # Not configured in sudo_local yet. - # Check if configured in sudo (Legacy) - local is_legacy_configured=false - if grep -q "pam_tid.so" "$PAM_SUDO_FILE"; then - is_legacy_configured=true - fi - - # Function to write to sudo_local - local write_success=false - if [[ ! -f "$PAM_SUDO_LOCAL_FILE" ]]; then - # Create the file - echo "# sudo_local: local customizations for sudo" | sudo tee "$PAM_SUDO_LOCAL_FILE" > /dev/null - echo "$PAM_TID_LINE" | sudo tee -a "$PAM_SUDO_LOCAL_FILE" > /dev/null - sudo chmod 444 "$PAM_SUDO_LOCAL_FILE" - sudo chown root:wheel "$PAM_SUDO_LOCAL_FILE" - write_success=true - else - # Append if not present - if ! grep -q "pam_tid.so" "$PAM_SUDO_LOCAL_FILE"; then - temp_file=$(mktemp) - cp "$PAM_SUDO_LOCAL_FILE" "$temp_file" - echo "$PAM_TID_LINE" >> "$temp_file" - sudo mv "$temp_file" "$PAM_SUDO_LOCAL_FILE" - sudo chmod 444 "$PAM_SUDO_LOCAL_FILE" - sudo chown root:wheel "$PAM_SUDO_LOCAL_FILE" - write_success=true - else - write_success=true # Already there (should be caught by first check, but safe fallback) - fi - fi - - if $write_success; then - # If we migrated from legacy, clean it up now - if $is_legacy_configured; then - temp_file=$(mktemp) - grep -v "pam_tid.so" "$PAM_SUDO_FILE" > "$temp_file" - 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" - fi - return 0 - else - log_error "Failed to write to sudo_local" - return 1 - fi - fi - - # Legacy method: Modify sudo file directly - - # Check if already configured (Legacy) - if is_touchid_configured; then - echo -e "${GREEN}${ICON_SUCCESS} Touch ID is already enabled${NC}" - return 0 - fi - - # Create backup only if it doesn't exist to preserve original state - if [[ ! -f "${PAM_SUDO_FILE}.mole-backup" ]]; then - if ! sudo cp "$PAM_SUDO_FILE" "${PAM_SUDO_FILE}.mole-backup" 2> /dev/null; then - log_error "Failed to create backup" - return 1 - fi - fi - - # Create temp file - temp_file=$(mktemp) - - # Insert pam_tid.so after the first comment block - awk ' - BEGIN { inserted = 0 } - /^#/ { print; next } - !inserted && /^[^#]/ { - print "'"$PAM_TID_LINE"'" - inserted = 1 - } - { print } - ' "$PAM_SUDO_FILE" > "$temp_file" - - # Verify content change - if cmp -s "$PAM_SUDO_FILE" "$temp_file"; then - log_error "Failed to modify configuration" - return 1 - fi - - # Apply the changes - if sudo mv "$temp_file" "$PAM_SUDO_FILE" 2> /dev/null; then - log_success "Touch ID enabled - try: sudo ls" - return 0 - else - log_error "Failed to enable Touch ID" - return 1 - fi -} - -# Disable Touch ID for sudo -disable_touchid() { - # Cleanup trap - local temp_file="" - trap '[[ -n "${temp_file:-}" ]] && rm -f "${temp_file:-}"' EXIT - - if ! is_touchid_configured; then - echo -e "${YELLOW}Touch ID is not currently enabled${NC}" - return 0 - fi - - # Check sudo_local first - if [[ -f "$PAM_SUDO_LOCAL_FILE" ]] && grep -q "pam_tid.so" "$PAM_SUDO_LOCAL_FILE"; then - # Remove from sudo_local - temp_file=$(mktemp) - grep -v "pam_tid.so" "$PAM_SUDO_LOCAL_FILE" > "$temp_file" - - if sudo mv "$temp_file" "$PAM_SUDO_LOCAL_FILE" 2> /dev/null; then - # Since we modified sudo_local, we should also check if it's in sudo file (legacy cleanup) - if grep -q "pam_tid.so" "$PAM_SUDO_FILE"; then - temp_file=$(mktemp) - 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 "" - return 0 - else - log_error "Failed to disable Touch ID from sudo_local" - return 1 - fi - fi - - # Fallback to sudo file (legacy) - if grep -q "pam_tid.so" "$PAM_SUDO_FILE"; then - # Create backup only if it doesn't exist - if [[ ! -f "${PAM_SUDO_FILE}.mole-backup" ]]; then - if ! sudo cp "$PAM_SUDO_FILE" "${PAM_SUDO_FILE}.mole-backup" 2> /dev/null; then - log_error "Failed to create backup" - return 1 - fi - fi - - # Remove pam_tid.so line - temp_file=$(mktemp) - grep -v "pam_tid.so" "$PAM_SUDO_FILE" > "$temp_file" - - if sudo mv "$temp_file" "$PAM_SUDO_FILE" 2> /dev/null; then - echo -e "${GREEN}${ICON_SUCCESS} Touch ID disabled${NC}" - echo "" - return 0 - else - log_error "Failed to disable Touch ID" - return 1 - fi - fi - - # Should not reach here if is_touchid_configured was true - log_error "Could not find Touch ID configuration to disable" - return 1 -} - -# Interactive menu -show_menu() { - echo "" - show_status - if is_touchid_configured; then - echo -ne "${PURPLE}☛${NC} Press ${GREEN}Enter${NC} to disable, ${GRAY}Q${NC} to quit: " - IFS= read -r -s -n1 key || key="" - drain_pending_input # Clean up any escape sequence remnants - echo "" - - case "$key" in - $'\e') # ESC - return 0 - ;; - "" | $'\n' | $'\r') # Enter - printf "\r\033[K" # Clear the prompt line - disable_touchid - ;; - *) - echo "" - log_error "Invalid key" - ;; - esac - else - echo -ne "${PURPLE}☛${NC} Press ${GREEN}Enter${NC} to enable, ${GRAY}Q${NC} to quit: " - IFS= read -r -s -n1 key || key="" - drain_pending_input # Clean up any escape sequence remnants - - case "$key" in - $'\e') # ESC - return 0 - ;; - "" | $'\n' | $'\r') # Enter - printf "\r\033[K" # Clear the prompt line - enable_touchid - ;; - *) - echo "" - log_error "Invalid key" - ;; - esac - fi -} - -# Main -main() { - local command="${1:-}" - - case "$command" in - enable) - enable_touchid - ;; - disable) - disable_touchid - ;; - status) - show_status - ;; - "") - show_menu - ;; - *) - log_error "Unknown command: $command" - exit 1 - ;; - esac -} - -main "$@" diff --git a/windows/bin/uninstall.ps1 b/bin/uninstall.ps1 similarity index 100% rename from windows/bin/uninstall.ps1 rename to bin/uninstall.ps1 diff --git a/bin/uninstall.sh b/bin/uninstall.sh deleted file mode 100755 index 389e222..0000000 --- a/bin/uninstall.sh +++ /dev/null @@ -1,586 +0,0 @@ -#!/bin/bash -# Mole - Uninstall command. -# Interactive app uninstaller. -# Removes app files and leftovers. - -set -euo pipefail - -# Fix locale issues on non-English systems. -export LC_ALL=C -export LANG=C - -# Load shared helpers. -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/../lib/core/common.sh" - -# Clean temp files on exit. -trap cleanup_temp_files EXIT INT TERM -source "$SCRIPT_DIR/../lib/ui/menu_paginated.sh" -source "$SCRIPT_DIR/../lib/ui/app_selector.sh" -source "$SCRIPT_DIR/../lib/uninstall/batch.sh" - -# State -selected_apps=() -declare -a apps_data=() -declare -a selection_state=() -total_items=0 -files_cleaned=0 -total_size_cleaned=0 - -# Scan applications and collect information. -scan_applications() { - # Cache app scan (24h TTL). - local cache_dir="$HOME/.cache/mole" - local cache_file="$cache_dir/app_scan_cache" - local cache_ttl=86400 # 24 hours - local force_rescan="${1:-false}" - - ensure_user_dir "$cache_dir" - - if [[ $force_rescan == false && -f "$cache_file" ]]; then - local cache_age=$(($(get_epoch_seconds) - $(get_file_mtime "$cache_file"))) - [[ $cache_age -eq $(get_epoch_seconds) ]] && cache_age=86401 # Handle mtime read failure - if [[ $cache_age -lt $cache_ttl ]]; then - if [[ -t 2 ]]; then - echo -e "${GREEN}Loading from cache...${NC}" >&2 - sleep 0.3 # Brief pause so user sees the message - fi - echo "$cache_file" - return 0 - fi - fi - - local inline_loading=false - if [[ -t 1 && -t 2 ]]; then - inline_loading=true - printf "\033[2J\033[H" >&2 # Clear screen for inline loading - fi - - local temp_file - temp_file=$(create_temp_file) - - # Local spinner_pid for cleanup - local spinner_pid="" - - # Trap to handle Ctrl+C during scan - local scan_interrupted=false - # shellcheck disable=SC2329 # Function invoked indirectly via trap - trap_scan_cleanup() { - scan_interrupted=true - if [[ -n "$spinner_pid" ]]; then - kill -TERM "$spinner_pid" 2> /dev/null || true - wait "$spinner_pid" 2> /dev/null || true - fi - printf "\r\033[K" >&2 - rm -f "$temp_file" "${temp_file}.sorted" "${temp_file}.progress" 2> /dev/null || true - exit 130 - } - trap trap_scan_cleanup INT - - local current_epoch - current_epoch=$(get_epoch_seconds) - - # Pass 1: collect app paths and bundle IDs (no mdls). - local -a app_data_tuples=() - local -a app_dirs=( - "/Applications" - "$HOME/Applications" - "/Library/Input Methods" - "$HOME/Library/Input Methods" - ) - local vol_app_dir - local nullglob_was_set=0 - shopt -q nullglob && nullglob_was_set=1 - shopt -s nullglob - for vol_app_dir in /Volumes/*/Applications; do - [[ -d "$vol_app_dir" && -r "$vol_app_dir" ]] || continue - if [[ -d "/Applications" && "$vol_app_dir" -ef "/Applications" ]]; then - continue - fi - if [[ -d "$HOME/Applications" && "$vol_app_dir" -ef "$HOME/Applications" ]]; then - continue - fi - app_dirs+=("$vol_app_dir") - done - if [[ $nullglob_was_set -eq 0 ]]; then - shopt -u nullglob - fi - - for app_dir in "${app_dirs[@]}"; do - if [[ ! -d "$app_dir" ]]; then continue; fi - - while IFS= read -r -d '' app_path; do - if [[ ! -e "$app_path" ]]; then continue; fi - - local app_name - app_name=$(basename "$app_path" .app) - - # Skip nested apps inside another .app bundle. - local parent_dir - parent_dir=$(dirname "$app_path") - if [[ "$parent_dir" == *".app" || "$parent_dir" == *".app/"* ]]; then - continue - fi - - # Bundle ID from plist (fast path). - local bundle_id="unknown" - if [[ -f "$app_path/Contents/Info.plist" ]]; then - bundle_id=$(defaults read "$app_path/Contents/Info.plist" CFBundleIdentifier 2> /dev/null || echo "unknown") - fi - - if should_protect_from_uninstall "$bundle_id"; then - continue - fi - - # Store tuple for pass 2 (metadata + size). - app_data_tuples+=("${app_path}|${app_name}|${bundle_id}") - done < <(command find "$app_dir" -name "*.app" -maxdepth 3 -print0 2> /dev/null) - done - - # Pass 2: metadata + size in parallel (mdls is slow). - local app_count=0 - local total_apps=${#app_data_tuples[@]} - local max_parallel - max_parallel=$(get_optimal_parallel_jobs "io") - if [[ $max_parallel -lt 8 ]]; then - max_parallel=8 # At least 8 for good performance - elif [[ $max_parallel -gt 32 ]]; then - max_parallel=32 # Cap at 32 to avoid too many processes - fi - local pids=() - - process_app_metadata() { - local app_data_tuple="$1" - local output_file="$2" - local current_epoch="$3" - - IFS='|' read -r app_path app_name bundle_id <<< "$app_data_tuple" - - # Display name priority: mdls display name → bundle display → bundle name → folder. - local display_name="$app_name" - if [[ -f "$app_path/Contents/Info.plist" ]]; then - local md_display_name - md_display_name=$(run_with_timeout 0.05 mdls -name kMDItemDisplayName -raw "$app_path" 2> /dev/null || echo "") - - local bundle_display_name - bundle_display_name=$(plutil -extract CFBundleDisplayName raw "$app_path/Contents/Info.plist" 2> /dev/null) - local bundle_name - bundle_name=$(plutil -extract CFBundleName raw "$app_path/Contents/Info.plist" 2> /dev/null) - - if [[ "$md_display_name" == /* ]]; then md_display_name=""; fi - md_display_name="${md_display_name//|/-}" - md_display_name="${md_display_name//[$'\t\r\n']/}" - - bundle_display_name="${bundle_display_name//|/-}" - bundle_display_name="${bundle_display_name//[$'\t\r\n']/}" - - bundle_name="${bundle_name//|/-}" - bundle_name="${bundle_name//[$'\t\r\n']/}" - - if [[ -n "$md_display_name" && "$md_display_name" != "(null)" && "$md_display_name" != "$app_name" ]]; then - display_name="$md_display_name" - elif [[ -n "$bundle_display_name" && "$bundle_display_name" != "(null)" ]]; then - display_name="$bundle_display_name" - elif [[ -n "$bundle_name" && "$bundle_name" != "(null)" ]]; then - display_name="$bundle_name" - fi - fi - - if [[ "$display_name" == /* ]]; then - display_name="$app_name" - fi - display_name="${display_name//|/-}" - display_name="${display_name//[$'\t\r\n']/}" - - # App size (KB → human). - local app_size="N/A" - local app_size_kb="0" - if [[ -d "$app_path" ]]; then - app_size_kb=$(get_path_size_kb "$app_path") - app_size=$(bytes_to_human "$((app_size_kb * 1024))") - fi - - # Last used: mdls (fast timeout) → mtime. - local last_used="Never" - local last_used_epoch=0 - - if [[ -d "$app_path" ]]; then - local metadata_date - metadata_date=$(run_with_timeout 0.1 mdls -name kMDItemLastUsedDate -raw "$app_path" 2> /dev/null || echo "") - - if [[ "$metadata_date" != "(null)" && -n "$metadata_date" ]]; then - last_used_epoch=$(date -j -f "%Y-%m-%d %H:%M:%S %z" "$metadata_date" "+%s" 2> /dev/null || echo "0") - fi - - if [[ "$last_used_epoch" -eq 0 ]]; then - last_used_epoch=$(get_file_mtime "$app_path") - fi - - if [[ $last_used_epoch -gt 0 ]]; then - local days_ago=$(((current_epoch - last_used_epoch) / 86400)) - - if [[ $days_ago -eq 0 ]]; then - last_used="Today" - elif [[ $days_ago -eq 1 ]]; then - last_used="Yesterday" - elif [[ $days_ago -lt 7 ]]; then - last_used="${days_ago} days ago" - elif [[ $days_ago -lt 30 ]]; then - local weeks_ago=$((days_ago / 7)) - [[ $weeks_ago -eq 1 ]] && last_used="1 week ago" || last_used="${weeks_ago} weeks ago" - elif [[ $days_ago -lt 365 ]]; then - local months_ago=$((days_ago / 30)) - [[ $months_ago -eq 1 ]] && last_used="1 month ago" || last_used="${months_ago} months ago" - else - local years_ago=$((days_ago / 365)) - [[ $years_ago -eq 1 ]] && last_used="1 year ago" || last_used="${years_ago} years ago" - fi - fi - fi - - echo "${last_used_epoch}|${app_path}|${display_name}|${bundle_id}|${app_size}|${last_used}|${app_size_kb}" >> "$output_file" - } - - export -f process_app_metadata - - local progress_file="${temp_file}.progress" - echo "0" > "$progress_file" - - ( - # shellcheck disable=SC2329 # Function invoked indirectly via trap - cleanup_spinner() { exit 0; } - trap cleanup_spinner TERM INT EXIT - local spinner_chars="|/-\\" - local i=0 - while true; do - local completed=$(cat "$progress_file" 2> /dev/null || echo 0) - local c="${spinner_chars:$((i % 4)):1}" - if [[ $inline_loading == true ]]; then - printf "\033[H\033[2K%s Scanning applications... %d/%d\n" "$c" "$completed" "$total_apps" >&2 - else - printf "\r\033[K%s Scanning applications... %d/%d" "$c" "$completed" "$total_apps" >&2 - fi - ((i++)) - sleep 0.1 2> /dev/null || sleep 1 - done - ) & - spinner_pid=$! - - for app_data_tuple in "${app_data_tuples[@]}"; do - ((app_count++)) - process_app_metadata "$app_data_tuple" "$temp_file" "$current_epoch" & - pids+=($!) - echo "$app_count" > "$progress_file" - - if ((${#pids[@]} >= max_parallel)); then - wait "${pids[0]}" 2> /dev/null - pids=("${pids[@]:1}") - fi - done - - for pid in "${pids[@]}"; do - wait "$pid" 2> /dev/null - done - - if [[ -n "$spinner_pid" ]]; then - kill -TERM "$spinner_pid" 2> /dev/null || true - wait "$spinner_pid" 2> /dev/null || true - fi - if [[ $inline_loading == true ]]; then - printf "\033[H\033[2K" >&2 - else - echo -ne "\r\033[K" >&2 - fi - rm -f "$progress_file" - - if [[ ! -s "$temp_file" ]]; then - echo "No applications found to uninstall" >&2 - rm -f "$temp_file" - return 1 - fi - - if [[ $total_apps -gt 50 ]]; then - if [[ $inline_loading == true ]]; then - printf "\033[H\033[2KProcessing %d applications...\n" "$total_apps" >&2 - else - printf "\rProcessing %d applications... " "$total_apps" >&2 - fi - fi - - sort -t'|' -k1,1n "$temp_file" > "${temp_file}.sorted" || { - rm -f "$temp_file" - return 1 - } - rm -f "$temp_file" - - if [[ $total_apps -gt 50 ]]; then - if [[ $inline_loading == true ]]; then - printf "\033[H\033[2K" >&2 - else - printf "\r\033[K" >&2 - fi - fi - - ensure_user_file "$cache_file" - cp "${temp_file}.sorted" "$cache_file" 2> /dev/null || true - - if [[ -f "${temp_file}.sorted" ]]; then - echo "${temp_file}.sorted" - else - return 1 - fi -} - -load_applications() { - local apps_file="$1" - - if [[ ! -f "$apps_file" || ! -s "$apps_file" ]]; then - log_warning "No applications found for uninstallation" - return 1 - fi - - apps_data=() - selection_state=() - - while IFS='|' read -r epoch app_path app_name bundle_id size last_used size_kb; do - [[ ! -e "$app_path" ]] && continue - - apps_data+=("$epoch|$app_path|$app_name|$bundle_id|$size|$last_used|${size_kb:-0}") - selection_state+=(false) - done < "$apps_file" - - if [[ ${#apps_data[@]} -eq 0 ]]; then - log_warning "No applications available for uninstallation" - return 1 - fi - - return 0 -} - -# Cleanup: restore cursor and kill keepalive. -cleanup() { - if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then - leave_alt_screen - unset MOLE_ALT_SCREEN_ACTIVE - fi - if [[ -n "${sudo_keepalive_pid:-}" ]]; then - kill "$sudo_keepalive_pid" 2> /dev/null || true - wait "$sudo_keepalive_pid" 2> /dev/null || true - sudo_keepalive_pid="" - fi - show_cursor - exit "${1:-0}" -} - -trap cleanup EXIT INT TERM - -main() { - local force_rescan=false - # Global flags - for arg in "$@"; do - case "$arg" in - "--debug") - export MO_DEBUG=1 - ;; - esac - done - - local use_inline_loading=false - if [[ -t 1 && -t 2 ]]; then - use_inline_loading=true - fi - - hide_cursor - - while true; do - local needs_scanning=true - local cache_file="$HOME/.cache/mole/app_scan_cache" - if [[ $force_rescan == false && -f "$cache_file" ]]; then - local cache_age=$(($(get_epoch_seconds) - $(get_file_mtime "$cache_file"))) - [[ $cache_age -eq $(get_epoch_seconds) ]] && cache_age=86401 - [[ $cache_age -lt 86400 ]] && needs_scanning=false - fi - - if [[ $needs_scanning == true && $use_inline_loading == true ]]; then - if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" != "1" ]]; then - enter_alt_screen - export MOLE_ALT_SCREEN_ACTIVE=1 - export MOLE_INLINE_LOADING=1 - export MOLE_MANAGED_ALT_SCREEN=1 - fi - printf "\033[2J\033[H" >&2 - else - unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN MOLE_ALT_SCREEN_ACTIVE - if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then - leave_alt_screen - unset MOLE_ALT_SCREEN_ACTIVE - fi - fi - - local apps_file="" - if ! apps_file=$(scan_applications "$force_rescan"); then - if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then - printf "\033[2J\033[H" >&2 - leave_alt_screen - unset MOLE_ALT_SCREEN_ACTIVE - unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN - fi - return 1 - fi - - if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then - printf "\033[2J\033[H" >&2 - fi - - if [[ ! -f "$apps_file" ]]; then - if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then - leave_alt_screen - unset MOLE_ALT_SCREEN_ACTIVE - unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN - fi - return 1 - fi - - if ! load_applications "$apps_file"; then - if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then - leave_alt_screen - unset MOLE_ALT_SCREEN_ACTIVE - unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN - fi - rm -f "$apps_file" - return 1 - fi - - set +e - select_apps_for_uninstall - local exit_code=$? - set -e - - if [[ $exit_code -ne 0 ]]; then - if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then - leave_alt_screen - unset MOLE_ALT_SCREEN_ACTIVE - unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN - fi - show_cursor - clear_screen - printf '\033[2J\033[H' >&2 - rm -f "$apps_file" - - if [[ $exit_code -eq 10 ]]; then - force_rescan=true - continue - fi - - return 0 - fi - - if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then - leave_alt_screen - unset MOLE_ALT_SCREEN_ACTIVE - unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN - fi - - show_cursor - clear_screen - printf '\033[2J\033[H' >&2 - local selection_count=${#selected_apps[@]} - if [[ $selection_count -eq 0 ]]; then - echo "No apps selected" - rm -f "$apps_file" - continue - fi - echo -e "${BLUE}${ICON_CONFIRM}${NC} Selected ${selection_count} app(s):" - local -a summary_rows=() - local max_name_display_width=0 - local max_size_width=0 - local max_last_width=0 - for selected_app in "${selected_apps[@]}"; do - IFS='|' read -r _ _ app_name _ size last_used _ <<< "$selected_app" - local name_width=$(get_display_width "$app_name") - [[ $name_width -gt $max_name_display_width ]] && max_name_display_width=$name_width - local size_display="$size" - [[ -z "$size_display" || "$size_display" == "0" || "$size_display" == "N/A" ]] && size_display="Unknown" - [[ ${#size_display} -gt $max_size_width ]] && max_size_width=${#size_display} - local last_display=$(format_last_used_summary "$last_used") - [[ ${#last_display} -gt $max_last_width ]] && max_last_width=${#last_display} - done - ((max_size_width < 5)) && max_size_width=5 - ((max_last_width < 5)) && max_last_width=5 - - local term_width=$(tput cols 2> /dev/null || echo 100) - local available_for_name=$((term_width - 17 - max_size_width - max_last_width)) - - local min_name_width=24 - if [[ $term_width -ge 120 ]]; then - min_name_width=50 - elif [[ $term_width -ge 100 ]]; then - min_name_width=42 - elif [[ $term_width -ge 80 ]]; then - min_name_width=30 - fi - - local name_trunc_limit=$max_name_display_width - [[ $name_trunc_limit -lt $min_name_width ]] && name_trunc_limit=$min_name_width - [[ $name_trunc_limit -gt $available_for_name ]] && name_trunc_limit=$available_for_name - [[ $name_trunc_limit -gt 60 ]] && name_trunc_limit=60 - - max_name_display_width=0 - - for selected_app in "${selected_apps[@]}"; do - IFS='|' read -r epoch app_path app_name bundle_id size last_used size_kb <<< "$selected_app" - - local display_name - display_name=$(truncate_by_display_width "$app_name" "$name_trunc_limit") - - local current_width - current_width=$(get_display_width "$display_name") - [[ $current_width -gt $max_name_display_width ]] && max_name_display_width=$current_width - - local size_display="$size" - if [[ -z "$size_display" || "$size_display" == "0" || "$size_display" == "N/A" ]]; then - size_display="Unknown" - fi - - local last_display - last_display=$(format_last_used_summary "$last_used") - - summary_rows+=("$display_name|$size_display|$last_display") - done - - ((max_name_display_width < 16)) && max_name_display_width=16 - - local index=1 - for row in "${summary_rows[@]}"; do - IFS='|' read -r name_cell size_cell last_cell <<< "$row" - local name_display_width - name_display_width=$(get_display_width "$name_cell") - local name_char_count=${#name_cell} - local padding_needed=$((max_name_display_width - name_display_width)) - local printf_name_width=$((name_char_count + padding_needed)) - - printf "%d. %-*s %*s | Last: %s\n" "$index" "$printf_name_width" "$name_cell" "$max_size_width" "$size_cell" "$last_cell" - ((index++)) - done - - batch_uninstall_applications - - rm -f "$apps_file" - - echo -e "${GRAY}Press Enter to return to application list, any other key to exit...${NC}" - local key - IFS= read -r -s -n1 key || key="" - drain_pending_input - - if [[ -z "$key" ]]; then - : - else - show_cursor - return 0 - fi - - force_rescan=false - done -} - -main "$@" diff --git a/bin/uninstall_lib.sh b/bin/uninstall_lib.sh deleted file mode 100755 index 75e0ad0..0000000 --- a/bin/uninstall_lib.sh +++ /dev/null @@ -1,666 +0,0 @@ -#!/bin/bash -# Mole - Uninstall Module -# Interactive application uninstaller with keyboard navigation -# -# Usage: -# uninstall.sh # Launch interactive uninstaller -# uninstall.sh --force-rescan # Rescan apps and refresh cache - -set -euo pipefail - -# Fix locale issues (avoid Perl warnings on non-English systems) -export LC_ALL=C -export LANG=C - -# Get script directory and source common functions -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/../lib/core/common.sh" -source "$SCRIPT_DIR/../lib/ui/menu_paginated.sh" -source "$SCRIPT_DIR/../lib/ui/app_selector.sh" -source "$SCRIPT_DIR/../lib/uninstall/batch.sh" - -# Note: Bundle preservation logic is now in lib/core/common.sh - -# Initialize global variables -selected_apps=() # Global array for app selection -declare -a apps_data=() -declare -a selection_state=() -total_items=0 -files_cleaned=0 -total_size_cleaned=0 - -# Compact the "last used" descriptor for aligned summaries -format_last_used_summary() { - local value="$1" - - case "$value" in - "" | "Unknown") - echo "Unknown" - return 0 - ;; - "Never" | "Recent" | "Today" | "Yesterday" | "This year" | "Old") - echo "$value" - return 0 - ;; - esac - - if [[ $value =~ ^([0-9]+)[[:space:]]+days?\ ago$ ]]; then - echo "${BASH_REMATCH[1]}d ago" - return 0 - fi - if [[ $value =~ ^([0-9]+)[[:space:]]+weeks?\ ago$ ]]; then - echo "${BASH_REMATCH[1]}w ago" - return 0 - fi - if [[ $value =~ ^([0-9]+)[[:space:]]+months?\ ago$ ]]; then - echo "${BASH_REMATCH[1]}m ago" - return 0 - fi - if [[ $value =~ ^([0-9]+)[[:space:]]+month\(s\)\ ago$ ]]; then - echo "${BASH_REMATCH[1]}m ago" - return 0 - fi - if [[ $value =~ ^([0-9]+)[[:space:]]+years?\ ago$ ]]; then - echo "${BASH_REMATCH[1]}y ago" - return 0 - fi - echo "$value" -} - -# Scan applications and collect information -scan_applications() { - # Simplified cache: only check timestamp (24h TTL) - local cache_dir="$HOME/.cache/mole" - local cache_file="$cache_dir/app_scan_cache" - local cache_ttl=86400 # 24 hours - local force_rescan="${1:-false}" - - ensure_user_dir "$cache_dir" - - # Check if cache exists and is fresh - if [[ $force_rescan == false && -f "$cache_file" ]]; then - local cache_age=$(($(get_epoch_seconds) - $(get_file_mtime "$cache_file"))) - [[ $cache_age -eq $(get_epoch_seconds) ]] && cache_age=86401 # Handle missing file - if [[ $cache_age -lt $cache_ttl ]]; then - # Cache hit - return immediately - # Show brief flash of cache usage if in interactive mode - if [[ -t 2 ]]; then - echo -e "${GREEN}Loading from cache...${NC}" >&2 - # Small sleep to let user see it (optional, but good for "feeling" the speed vs glitch) - sleep 0.3 - fi - echo "$cache_file" - return 0 - fi - fi - - # Cache miss - prepare for scanning - local inline_loading=false - if [[ -t 1 && -t 2 ]]; then - inline_loading=true - # Clear screen for inline loading - printf "\033[2J\033[H" >&2 - fi - - local temp_file - temp_file=$(create_temp_file) - - # Pre-cache current epoch to avoid repeated calls - local current_epoch - current_epoch=$(get_epoch_seconds) - - # First pass: quickly collect all valid app paths and bundle IDs (NO mdls calls) - local -a app_data_tuples=() - local -a app_dirs=( - "/Applications" - "$HOME/Applications" - ) - local vol_app_dir - local nullglob_was_set=0 - shopt -q nullglob && nullglob_was_set=1 - shopt -s nullglob - for vol_app_dir in /Volumes/*/Applications; do - [[ -d "$vol_app_dir" && -r "$vol_app_dir" ]] || continue - if [[ -d "/Applications" && "$vol_app_dir" -ef "/Applications" ]]; then - continue - fi - if [[ -d "$HOME/Applications" && "$vol_app_dir" -ef "$HOME/Applications" ]]; then - continue - fi - app_dirs+=("$vol_app_dir") - done - if [[ $nullglob_was_set -eq 0 ]]; then - shopt -u nullglob - fi - - for app_dir in "${app_dirs[@]}"; do - if [[ ! -d "$app_dir" ]]; then continue; fi - - while IFS= read -r -d '' app_path; do - if [[ ! -e "$app_path" ]]; then continue; fi - - local app_name - app_name=$(basename "$app_path" .app) - - # Skip nested apps (e.g. inside Wrapper/ or Frameworks/ of another app) - # Check if parent path component ends in .app (e.g. /Foo.app/Bar.app or /Foo.app/Contents/Bar.app) - # This prevents false positives like /Old.apps/Target.app - local parent_dir - parent_dir=$(dirname "$app_path") - if [[ "$parent_dir" == *".app" || "$parent_dir" == *".app/"* ]]; then - continue - fi - - # Get bundle ID only (fast, no mdls calls in first pass) - local bundle_id="unknown" - if [[ -f "$app_path/Contents/Info.plist" ]]; then - bundle_id=$(defaults read "$app_path/Contents/Info.plist" CFBundleIdentifier 2> /dev/null || echo "unknown") - fi - - # Skip system critical apps (input methods, system components) - if should_protect_from_uninstall "$bundle_id"; then - continue - fi - - # Store tuple: app_path|app_name|bundle_id (display_name will be resolved in parallel later) - app_data_tuples+=("${app_path}|${app_name}|${bundle_id}") - done < <(command find "$app_dir" -name "*.app" -maxdepth 3 -print0 2> /dev/null) - done - - # Second pass: process each app with parallel size calculation - local app_count=0 - local total_apps=${#app_data_tuples[@]} - # Bound parallelism - for metadata queries, can go higher since it's mostly waiting - local max_parallel - max_parallel=$(get_optimal_parallel_jobs "io") - if [[ $max_parallel -lt 8 ]]; then - max_parallel=8 - elif [[ $max_parallel -gt 32 ]]; then - max_parallel=32 - fi - local pids=() - # inline_loading variable already set above (line ~92) - - # Process app metadata extraction function - process_app_metadata() { - local app_data_tuple="$1" - local output_file="$2" - local current_epoch="$3" - - IFS='|' read -r app_path app_name bundle_id <<< "$app_data_tuple" - - # Get localized display name (moved from first pass for better performance) - local display_name="$app_name" - if [[ -f "$app_path/Contents/Info.plist" ]]; then - # Try to get localized name from system metadata (best for i18n) - local md_display_name - md_display_name=$(run_with_timeout 0.05 mdls -name kMDItemDisplayName -raw "$app_path" 2> /dev/null || echo "") - - # Get bundle names - local bundle_display_name - bundle_display_name=$(plutil -extract CFBundleDisplayName raw "$app_path/Contents/Info.plist" 2> /dev/null) - local bundle_name - bundle_name=$(plutil -extract CFBundleName raw "$app_path/Contents/Info.plist" 2> /dev/null) - - # Priority order for name selection (prefer localized names): - # 1. System metadata display name (kMDItemDisplayName) - respects system language - # 2. CFBundleDisplayName - usually localized - # 3. CFBundleName - fallback - # 4. App folder name - last resort - - if [[ -n "$md_display_name" && "$md_display_name" != "(null)" && "$md_display_name" != "$app_name" ]]; then - display_name="$md_display_name" - elif [[ -n "$bundle_display_name" && "$bundle_display_name" != "(null)" ]]; then - display_name="$bundle_display_name" - elif [[ -n "$bundle_name" && "$bundle_name" != "(null)" ]]; then - display_name="$bundle_name" - fi - fi - - # Parallel size calculation - local app_size="N/A" - local app_size_kb="0" - if [[ -d "$app_path" ]]; then - # Get size in KB, then format for display - app_size_kb=$(get_path_size_kb "$app_path") - app_size=$(bytes_to_human "$((app_size_kb * 1024))") - fi - - # Get last used date - local last_used="Never" - local last_used_epoch=0 - - if [[ -d "$app_path" ]]; then - # Try mdls first with short timeout (0.1s) for accuracy, fallback to mtime for speed - local metadata_date - metadata_date=$(run_with_timeout 0.1 mdls -name kMDItemLastUsedDate -raw "$app_path" 2> /dev/null || echo "") - - if [[ "$metadata_date" != "(null)" && -n "$metadata_date" ]]; then - last_used_epoch=$(date -j -f "%Y-%m-%d %H:%M:%S %z" "$metadata_date" "+%s" 2> /dev/null || echo "0") - fi - - # Fallback if mdls failed or returned nothing - if [[ "$last_used_epoch" -eq 0 ]]; then - last_used_epoch=$(get_file_mtime "$app_path") - fi - - if [[ $last_used_epoch -gt 0 ]]; then - local days_ago=$(((current_epoch - last_used_epoch) / 86400)) - - if [[ $days_ago -eq 0 ]]; then - last_used="Today" - elif [[ $days_ago -eq 1 ]]; then - last_used="Yesterday" - elif [[ $days_ago -lt 7 ]]; then - last_used="${days_ago} days ago" - elif [[ $days_ago -lt 30 ]]; then - local weeks_ago=$((days_ago / 7)) - [[ $weeks_ago -eq 1 ]] && last_used="1 week ago" || last_used="${weeks_ago} weeks ago" - elif [[ $days_ago -lt 365 ]]; then - local months_ago=$((days_ago / 30)) - [[ $months_ago -eq 1 ]] && last_used="1 month ago" || last_used="${months_ago} months ago" - else - local years_ago=$((days_ago / 365)) - [[ $years_ago -eq 1 ]] && last_used="1 year ago" || last_used="${years_ago} years ago" - fi - fi - fi - - # Write to output file atomically - # Fields: epoch|app_path|display_name|bundle_id|size_human|last_used|size_kb - echo "${last_used_epoch}|${app_path}|${display_name}|${bundle_id}|${app_size}|${last_used}|${app_size_kb}" >> "$output_file" - } - - export -f process_app_metadata - - # Create a temporary file to track progress - local progress_file="${temp_file}.progress" - echo "0" > "$progress_file" - - # Start a background spinner that reads progress from file - local spinner_pid="" - ( - # shellcheck disable=SC2329 # Function invoked indirectly via trap - cleanup_spinner() { exit 0; } - trap cleanup_spinner TERM INT EXIT - local spinner_chars="|/-\\" - local i=0 - while true; do - local completed=$(cat "$progress_file" 2> /dev/null || echo 0) - local c="${spinner_chars:$((i % 4)):1}" - if [[ $inline_loading == true ]]; then - printf "\033[H\033[2K%s Scanning applications... %d/%d\n" "$c" "$completed" "$total_apps" >&2 - else - printf "\r\033[K%s Scanning applications... %d/%d" "$c" "$completed" "$total_apps" >&2 - fi - ((i++)) - sleep 0.1 2> /dev/null || sleep 1 - done - ) & - spinner_pid=$! - - # Process apps in parallel batches - for app_data_tuple in "${app_data_tuples[@]}"; do - ((app_count++)) - - # Launch background process - process_app_metadata "$app_data_tuple" "$temp_file" "$current_epoch" & - pids+=($!) - - # Update progress to show scanning progress (use app_count as it increments smoothly) - echo "$app_count" > "$progress_file" - - # Wait if we've hit max parallel limit - if ((${#pids[@]} >= max_parallel)); then - wait "${pids[0]}" 2> /dev/null - pids=("${pids[@]:1}") # Remove first pid - fi - done - - # Wait for remaining background processes - for pid in "${pids[@]}"; do - wait "$pid" 2> /dev/null - done - - # Stop the spinner and clear the line - if [[ -n "$spinner_pid" ]]; then - kill -TERM "$spinner_pid" 2> /dev/null || true - wait "$spinner_pid" 2> /dev/null || true - fi - if [[ $inline_loading == true ]]; then - printf "\033[H\033[2K" >&2 - else - echo -ne "\r\033[K" >&2 - fi - rm -f "$progress_file" - - # Check if we found any applications - if [[ ! -s "$temp_file" ]]; then - echo "No applications found to uninstall" >&2 - rm -f "$temp_file" - return 1 - fi - - # Sort by last used (oldest first) and cache the result - # Show brief processing message for large app lists - if [[ $total_apps -gt 50 ]]; then - if [[ $inline_loading == true ]]; then - printf "\033[H\033[2KProcessing %d applications...\n" "$total_apps" >&2 - else - printf "\rProcessing %d applications... " "$total_apps" >&2 - fi - fi - - sort -t'|' -k1,1n "$temp_file" > "${temp_file}.sorted" || { - rm -f "$temp_file" - return 1 - } - rm -f "$temp_file" - - # Clear processing message - if [[ $total_apps -gt 50 ]]; then - if [[ $inline_loading == true ]]; then - printf "\033[H\033[2K" >&2 - else - printf "\r\033[K" >&2 - fi - fi - - # Save to cache (simplified - no metadata) - ensure_user_file "$cache_file" - cp "${temp_file}.sorted" "$cache_file" 2> /dev/null || true - - # Return sorted file - if [[ -f "${temp_file}.sorted" ]]; then - echo "${temp_file}.sorted" - else - return 1 - fi -} - -load_applications() { - local apps_file="$1" - - if [[ ! -f "$apps_file" || ! -s "$apps_file" ]]; then - log_warning "No applications found for uninstallation" - return 1 - fi - - # Clear arrays - apps_data=() - selection_state=() - - # Read apps into array, skip non-existent apps - while IFS='|' read -r epoch app_path app_name bundle_id size last_used size_kb; do - # Skip if app path no longer exists - [[ ! -e "$app_path" ]] && continue - - apps_data+=("$epoch|$app_path|$app_name|$bundle_id|$size|$last_used|${size_kb:-0}") - selection_state+=(false) - done < "$apps_file" - - if [[ ${#apps_data[@]} -eq 0 ]]; then - log_warning "No applications available for uninstallation" - return 1 - fi - - return 0 -} - -# Cleanup function - restore cursor and clean up -cleanup() { - # Restore cursor using common function - if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then - leave_alt_screen - unset MOLE_ALT_SCREEN_ACTIVE - fi - if [[ -n "${sudo_keepalive_pid:-}" ]]; then - kill "$sudo_keepalive_pid" 2> /dev/null || true - wait "$sudo_keepalive_pid" 2> /dev/null || true - sudo_keepalive_pid="" - fi - show_cursor - exit "${1:-0}" -} - -# Set trap for cleanup on exit -trap cleanup EXIT INT TERM - -main() { - local force_rescan=false - for arg in "$@"; do - case "$arg" in - "--debug") - export MO_DEBUG=1 - ;; - "--force-rescan") - force_rescan=true - ;; - esac - done - - local use_inline_loading=false - if [[ -t 1 && -t 2 ]]; then - use_inline_loading=true - fi - - # Hide cursor during operation - hide_cursor - - # Main interaction loop - while true; do - # Simplified: always check if we need alt screen for scanning - # (scan_applications handles cache internally) - local needs_scanning=true - local cache_file="$HOME/.cache/mole/app_scan_cache" - if [[ $force_rescan == false && -f "$cache_file" ]]; then - local cache_age=$(($(get_epoch_seconds) - $(get_file_mtime "$cache_file"))) - [[ $cache_age -eq $(get_epoch_seconds) ]] && cache_age=86401 # Handle missing file - [[ $cache_age -lt 86400 ]] && needs_scanning=false - fi - - # Only enter alt screen if we need scanning (shows progress) - if [[ $needs_scanning == true && $use_inline_loading == true ]]; then - # Only enter if not already active - if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" != "1" ]]; then - enter_alt_screen - export MOLE_ALT_SCREEN_ACTIVE=1 - export MOLE_INLINE_LOADING=1 - export MOLE_MANAGED_ALT_SCREEN=1 - fi - printf "\033[2J\033[H" >&2 - else - # If we don't need scanning but have alt screen from previous iteration, keep it? - # Actually, scan_applications might output to stderr. - # Let's just unset the flags if we don't need scanning, but keep alt screen if it was active? - # No, select_apps_for_uninstall will handle its own screen management. - unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN MOLE_ALT_SCREEN_ACTIVE - if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then - leave_alt_screen - unset MOLE_ALT_SCREEN_ACTIVE - fi - fi - - # Scan applications - local apps_file="" - if ! apps_file=$(scan_applications "$force_rescan"); then - if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then - printf "\033[2J\033[H" >&2 - leave_alt_screen - unset MOLE_ALT_SCREEN_ACTIVE - unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN - fi - return 1 - fi - - if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then - printf "\033[2J\033[H" >&2 - fi - - if [[ ! -f "$apps_file" ]]; then - # Error message already shown by scan_applications - if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then - leave_alt_screen - unset MOLE_ALT_SCREEN_ACTIVE - unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN - fi - return 1 - fi - - # Load applications - if ! load_applications "$apps_file"; then - if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then - leave_alt_screen - unset MOLE_ALT_SCREEN_ACTIVE - unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN - fi - rm -f "$apps_file" - return 1 - fi - - # Interactive selection using paginated menu - set +e - select_apps_for_uninstall - local exit_code=$? - set -e - - if [[ $exit_code -ne 0 ]]; then - if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then - leave_alt_screen - unset MOLE_ALT_SCREEN_ACTIVE - unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN - fi - show_cursor - clear_screen - printf '\033[2J\033[H' >&2 # Also clear stderr - rm -f "$apps_file" - - # Handle Refresh (code 10) - if [[ $exit_code -eq 10 ]]; then - force_rescan=true - continue - fi - - # User cancelled selection, exit the loop - return 0 - fi - - # Always clear on exit from selection, regardless of alt screen state - if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then - leave_alt_screen - unset MOLE_ALT_SCREEN_ACTIVE - unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN - fi - - # Restore cursor and clear screen (output to both stdout and stderr for reliability) - show_cursor - clear_screen - printf '\033[2J\033[H' >&2 # Also clear stderr in case of mixed output - local selection_count=${#selected_apps[@]} - if [[ $selection_count -eq 0 ]]; then - echo "No apps selected" - rm -f "$apps_file" - # Loop back or exit? If select_apps_for_uninstall returns 0 but empty selection, - # it technically shouldn't happen based on that function's logic. - continue - fi - # Show selected apps with clean alignment - echo -e "${BLUE}${ICON_CONFIRM}${NC} Selected ${selection_count} app(s):" - local -a summary_rows=() - local max_name_width=0 - local max_size_width=0 - local max_last_width=0 - # First pass: get actual max widths for all columns - for selected_app in "${selected_apps[@]}"; do - IFS='|' read -r _ _ app_name _ size last_used _ <<< "$selected_app" - [[ ${#app_name} -gt $max_name_width ]] && max_name_width=${#app_name} - local size_display="$size" - [[ -z "$size_display" || "$size_display" == "0" || "$size_display" == "N/A" ]] && size_display="Unknown" - [[ ${#size_display} -gt $max_size_width ]] && max_size_width=${#size_display} - local last_display=$(format_last_used_summary "$last_used") - [[ ${#last_display} -gt $max_last_width ]] && max_last_width=${#last_display} - done - ((max_size_width < 5)) && max_size_width=5 - ((max_last_width < 5)) && max_last_width=5 - - # Calculate name width: use actual max, but constrain by terminal width - # Fixed elements: "99. " (4) + " " (2) + " | Last: " (11) = 17 - local term_width=$(tput cols 2> /dev/null || echo 100) - local available_for_name=$((term_width - 17 - max_size_width - max_last_width)) - - # Dynamic minimum for better spacing on wide terminals - local min_name_width=24 - if [[ $term_width -ge 120 ]]; then - min_name_width=50 - elif [[ $term_width -ge 100 ]]; then - min_name_width=42 - elif [[ $term_width -ge 80 ]]; then - min_name_width=30 - fi - - # Constrain name width: dynamic min, max min(actual_max, available, 60) - local name_trunc_limit=$max_name_width - [[ $name_trunc_limit -lt $min_name_width ]] && name_trunc_limit=$min_name_width - [[ $name_trunc_limit -gt $available_for_name ]] && name_trunc_limit=$available_for_name - [[ $name_trunc_limit -gt 60 ]] && name_trunc_limit=60 - - # Reset for second pass - max_name_width=0 - - for selected_app in "${selected_apps[@]}"; do - IFS='|' read -r epoch app_path app_name bundle_id size last_used size_kb <<< "$selected_app" - - local display_name="$app_name" - if [[ ${#display_name} -gt $name_trunc_limit ]]; then - display_name="${display_name:0:$((name_trunc_limit - 3))}..." - fi - [[ ${#display_name} -gt $max_name_width ]] && max_name_width=${#display_name} - - local size_display="$size" - if [[ -z "$size_display" || "$size_display" == "0" || "$size_display" == "N/A" ]]; then - size_display="Unknown" - fi - - local last_display - last_display=$(format_last_used_summary "$last_used") - - summary_rows+=("$display_name|$size_display|$last_display") - done - - ((max_name_width < 16)) && max_name_width=16 - - local index=1 - for row in "${summary_rows[@]}"; do - IFS='|' read -r name_cell size_cell last_cell <<< "$row" - printf "%d. %-*s %*s | Last: %s\n" "$index" "$max_name_width" "$name_cell" "$max_size_width" "$size_cell" "$last_cell" - ((index++)) - done - - # Execute batch uninstallation (handles confirmation) - batch_uninstall_applications - - # Cleanup current apps file - rm -f "$apps_file" - - # Pause before looping back - echo -e "${GRAY}Press Enter to return to application list, ESC to exit...${NC}" - local key - IFS= read -r -s -n1 key || key="" - drain_pending_input # Clean up any escape sequence remnants - case "$key" in - $'\e' | q | Q) - show_cursor - return 0 - ;; - *) - # Continue loop - ;; - esac - - # Reset force_rescan to false for subsequent loops, - # but relying on batch_uninstall's cache deletion for actual update - force_rescan=false - done -} - -# Run main function diff --git a/windows/cmd/analyze/analyze.exe b/cmd/analyze/analyze.exe similarity index 100% rename from windows/cmd/analyze/analyze.exe rename to cmd/analyze/analyze.exe diff --git a/cmd/analyze/analyze_test.go b/cmd/analyze/analyze_test.go deleted file mode 100644 index 6ae8d2e..0000000 --- a/cmd/analyze/analyze_test.go +++ /dev/null @@ -1,360 +0,0 @@ -package main - -import ( - "encoding/gob" - "os" - "path/filepath" - "strings" - "sync/atomic" - "testing" - "time" -) - -func resetOverviewSnapshotForTest() { - overviewSnapshotMu.Lock() - overviewSnapshotCache = nil - overviewSnapshotLoaded = false - overviewSnapshotMu.Unlock() -} - -func TestScanPathConcurrentBasic(t *testing.T) { - root := t.TempDir() - - rootFile := filepath.Join(root, "root.txt") - if err := os.WriteFile(rootFile, []byte("root-data"), 0o644); err != nil { - t.Fatalf("write root file: %v", err) - } - - nested := filepath.Join(root, "nested") - if err := os.MkdirAll(nested, 0o755); err != nil { - t.Fatalf("create nested dir: %v", err) - } - - fileOne := filepath.Join(nested, "a.bin") - if err := os.WriteFile(fileOne, []byte("alpha"), 0o644); err != nil { - t.Fatalf("write file one: %v", err) - } - fileTwo := filepath.Join(nested, "b.bin") - if err := os.WriteFile(fileTwo, []byte(strings.Repeat("b", 32)), 0o644); err != nil { - t.Fatalf("write file two: %v", err) - } - - linkPath := filepath.Join(root, "link-to-a") - if err := os.Symlink(fileOne, linkPath); err != nil { - t.Fatalf("create symlink: %v", err) - } - - var filesScanned, dirsScanned, bytesScanned int64 - current := "" - - result, err := scanPathConcurrent(root, &filesScanned, &dirsScanned, &bytesScanned, ¤t) - if err != nil { - t.Fatalf("scanPathConcurrent returned error: %v", err) - } - - linkInfo, err := os.Lstat(linkPath) - if err != nil { - t.Fatalf("stat symlink: %v", err) - } - - expectedDirSize := int64(len("alpha") + len(strings.Repeat("b", 32))) - expectedRootFileSize := int64(len("root-data")) - expectedLinkSize := getActualFileSize(linkPath, linkInfo) - expectedTotal := expectedDirSize + expectedRootFileSize + expectedLinkSize - - if result.TotalSize != expectedTotal { - t.Fatalf("expected total size %d, got %d", expectedTotal, result.TotalSize) - } - - if got := atomic.LoadInt64(&filesScanned); got != 3 { - t.Fatalf("expected 3 files scanned, got %d", got) - } - if dirs := atomic.LoadInt64(&dirsScanned); dirs == 0 { - t.Fatalf("expected directory scan count to increase") - } - if bytes := atomic.LoadInt64(&bytesScanned); bytes == 0 { - t.Fatalf("expected byte counter to increase") - } - foundSymlink := false - for _, entry := range result.Entries { - if strings.HasSuffix(entry.Name, " →") { - foundSymlink = true - if entry.IsDir { - t.Fatalf("symlink entry should not be marked as directory") - } - } - } - if !foundSymlink { - t.Fatalf("expected symlink entry to be present in scan result") - } -} - -func TestDeletePathWithProgress(t *testing.T) { - // Skip in CI environments where Finder may not be available. - if os.Getenv("CI") != "" { - t.Skip("Skipping Finder-dependent test in CI") - } - - parent := t.TempDir() - target := filepath.Join(parent, "target") - if err := os.MkdirAll(target, 0o755); err != nil { - t.Fatalf("create target: %v", err) - } - - files := []string{ - filepath.Join(target, "one.txt"), - filepath.Join(target, "two.txt"), - } - for _, f := range files { - if err := os.WriteFile(f, []byte("content"), 0o644); err != nil { - t.Fatalf("write %s: %v", f, err) - } - } - - var counter int64 - count, err := trashPathWithProgress(target, &counter) - if err != nil { - t.Fatalf("trashPathWithProgress returned error: %v", err) - } - if count != int64(len(files)) { - t.Fatalf("expected %d files trashed, got %d", len(files), count) - } - if _, err := os.Stat(target); !os.IsNotExist(err) { - t.Fatalf("expected target to be moved to Trash, stat err=%v", err) - } -} - -func TestOverviewStoreAndLoad(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - resetOverviewSnapshotForTest() - t.Cleanup(resetOverviewSnapshotForTest) - - path := filepath.Join(home, "project") - want := int64(123456) - - if err := storeOverviewSize(path, want); err != nil { - t.Fatalf("storeOverviewSize: %v", err) - } - - got, err := loadStoredOverviewSize(path) - if err != nil { - t.Fatalf("loadStoredOverviewSize: %v", err) - } - if got != want { - t.Fatalf("snapshot mismatch: want %d, got %d", want, got) - } - - // Reload from disk and ensure value persists. - resetOverviewSnapshotForTest() - got, err = loadStoredOverviewSize(path) - if err != nil { - t.Fatalf("loadStoredOverviewSize after reset: %v", err) - } - if got != want { - t.Fatalf("snapshot mismatch after reset: want %d, got %d", want, got) - } -} - -func TestCacheSaveLoadRoundTrip(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - - target := filepath.Join(home, "cache-target") - if err := os.MkdirAll(target, 0o755); err != nil { - t.Fatalf("create target dir: %v", err) - } - - result := scanResult{ - Entries: []dirEntry{ - {Name: "alpha", Path: filepath.Join(target, "alpha"), Size: 10, IsDir: true}, - }, - LargeFiles: []fileEntry{ - {Name: "big.bin", Path: filepath.Join(target, "big.bin"), Size: 2048}, - }, - TotalSize: 42, - } - - if err := saveCacheToDisk(target, result); err != nil { - t.Fatalf("saveCacheToDisk: %v", err) - } - - cache, err := loadCacheFromDisk(target) - if err != nil { - t.Fatalf("loadCacheFromDisk: %v", err) - } - if cache.TotalSize != result.TotalSize { - t.Fatalf("total size mismatch: want %d, got %d", result.TotalSize, cache.TotalSize) - } - if len(cache.Entries) != len(result.Entries) { - t.Fatalf("entry count mismatch: want %d, got %d", len(result.Entries), len(cache.Entries)) - } - if len(cache.LargeFiles) != len(result.LargeFiles) { - t.Fatalf("large file count mismatch: want %d, got %d", len(result.LargeFiles), len(cache.LargeFiles)) - } -} - -func TestMeasureOverviewSize(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - resetOverviewSnapshotForTest() - t.Cleanup(resetOverviewSnapshotForTest) - - target := filepath.Join(home, "measure") - if err := os.MkdirAll(target, 0o755); err != nil { - t.Fatalf("create target: %v", err) - } - content := []byte(strings.Repeat("x", 2048)) - if err := os.WriteFile(filepath.Join(target, "data.bin"), content, 0o644); err != nil { - t.Fatalf("write file: %v", err) - } - - size, err := measureOverviewSize(target) - if err != nil { - t.Fatalf("measureOverviewSize: %v", err) - } - if size <= 0 { - t.Fatalf("expected positive size, got %d", size) - } - - // Ensure snapshot stored. - cached, err := loadStoredOverviewSize(target) - if err != nil { - t.Fatalf("loadStoredOverviewSize: %v", err) - } - if cached != size { - t.Fatalf("snapshot mismatch: want %d, got %d", size, cached) - } -} - -func TestIsCleanableDir(t *testing.T) { - if !isCleanableDir("/Users/test/project/node_modules") { - t.Fatalf("expected node_modules to be cleanable") - } - if isCleanableDir("/Users/test/Library/Caches/AppCache") { - t.Fatalf("Library caches should be handled by mo clean") - } - if isCleanableDir("") { - t.Fatalf("empty path should not be cleanable") - } -} - -func TestHasUsefulVolumeMounts(t *testing.T) { - root := t.TempDir() - if hasUsefulVolumeMounts(root) { - t.Fatalf("empty directory should not report useful mounts") - } - - hidden := filepath.Join(root, ".hidden") - if err := os.Mkdir(hidden, 0o755); err != nil { - t.Fatalf("create hidden dir: %v", err) - } - if hasUsefulVolumeMounts(root) { - t.Fatalf("hidden entries should not count as useful mounts") - } - - mount := filepath.Join(root, "ExternalDrive") - if err := os.Mkdir(mount, 0o755); err != nil { - t.Fatalf("create mount dir: %v", err) - } - if !hasUsefulVolumeMounts(root) { - t.Fatalf("expected useful mount when real directory exists") - } -} - -func TestLoadCacheExpiresWhenDirectoryChanges(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - - target := filepath.Join(home, "change-target") - if err := os.MkdirAll(target, 0o755); err != nil { - t.Fatalf("create target: %v", err) - } - - result := scanResult{TotalSize: 5} - if err := saveCacheToDisk(target, result); err != nil { - t.Fatalf("saveCacheToDisk: %v", err) - } - - // Advance mtime beyond grace period. - time.Sleep(time.Millisecond * 10) - if err := os.Chtimes(target, time.Now(), time.Now()); err != nil { - t.Fatalf("chtimes: %v", err) - } - - // Simulate older cache entry to exceed grace window. - cachePath, err := getCachePath(target) - if err != nil { - t.Fatalf("getCachePath: %v", err) - } - if _, err := os.Stat(cachePath); err != nil { - t.Fatalf("stat cache: %v", err) - } - oldTime := time.Now().Add(-cacheModTimeGrace - time.Minute) - if err := os.Chtimes(cachePath, oldTime, oldTime); err != nil { - t.Fatalf("chtimes cache: %v", err) - } - - file, err := os.Open(cachePath) - if err != nil { - t.Fatalf("open cache: %v", err) - } - var entry cacheEntry - if err := gob.NewDecoder(file).Decode(&entry); err != nil { - t.Fatalf("decode cache: %v", err) - } - _ = file.Close() - - entry.ScanTime = time.Now().Add(-8 * 24 * time.Hour) - - tmp := cachePath + ".tmp" - f, err := os.Create(tmp) - if err != nil { - t.Fatalf("create tmp cache: %v", err) - } - if err := gob.NewEncoder(f).Encode(&entry); err != nil { - t.Fatalf("encode tmp cache: %v", err) - } - _ = f.Close() - if err := os.Rename(tmp, cachePath); err != nil { - t.Fatalf("rename tmp cache: %v", err) - } - - if _, err := loadCacheFromDisk(target); err == nil { - t.Fatalf("expected cache load to fail after stale scan time") - } -} - -func TestScanPathPermissionError(t *testing.T) { - root := t.TempDir() - lockedDir := filepath.Join(root, "locked") - if err := os.Mkdir(lockedDir, 0o755); err != nil { - t.Fatalf("create locked dir: %v", err) - } - - // Create a file before locking. - if err := os.WriteFile(filepath.Join(lockedDir, "secret.txt"), []byte("shh"), 0o644); err != nil { - t.Fatalf("write secret: %v", err) - } - - // Remove permissions. - if err := os.Chmod(lockedDir, 0o000); err != nil { - t.Fatalf("chmod 000: %v", err) - } - defer func() { - // Restore permissions for cleanup. - _ = os.Chmod(lockedDir, 0o755) - }() - - var files, dirs, bytes int64 - current := "" - - // Scanning the locked dir itself should fail. - _, err := scanPathConcurrent(lockedDir, &files, &dirs, &bytes, ¤t) - if err == nil { - t.Fatalf("expected error scanning locked directory, got nil") - } - if !os.IsPermission(err) { - t.Logf("unexpected error type: %v", err) - } -} diff --git a/cmd/analyze/cache.go b/cmd/analyze/cache.go deleted file mode 100644 index 93fb391..0000000 --- a/cmd/analyze/cache.go +++ /dev/null @@ -1,346 +0,0 @@ -package main - -import ( - "context" - "encoding/gob" - "encoding/json" - "fmt" - "os" - "path/filepath" - "sync" - "time" - - "github.com/cespare/xxhash/v2" -) - -type overviewSizeSnapshot struct { - Size int64 `json:"size"` - Updated time.Time `json:"updated"` -} - -var ( - overviewSnapshotMu sync.Mutex - overviewSnapshotCache map[string]overviewSizeSnapshot - overviewSnapshotLoaded bool -) - -func snapshotFromModel(m model) historyEntry { - return historyEntry{ - Path: m.path, - Entries: cloneDirEntries(m.entries), - LargeFiles: cloneFileEntries(m.largeFiles), - TotalSize: m.totalSize, - TotalFiles: m.totalFiles, - Selected: m.selected, - EntryOffset: m.offset, - LargeSelected: m.largeSelected, - LargeOffset: m.largeOffset, - IsOverview: m.isOverview, - } -} - -func cacheSnapshot(m model) historyEntry { - entry := snapshotFromModel(m) - entry.Dirty = false - return entry -} - -func cloneDirEntries(entries []dirEntry) []dirEntry { - if len(entries) == 0 { - return nil - } - copied := make([]dirEntry, len(entries)) - copy(copied, entries) //nolint:all - return copied -} - -func cloneFileEntries(files []fileEntry) []fileEntry { - if len(files) == 0 { - return nil - } - copied := make([]fileEntry, len(files)) - copy(copied, files) //nolint:all - return copied -} - -func ensureOverviewSnapshotCacheLocked() error { - if overviewSnapshotLoaded { - return nil - } - storePath, err := getOverviewSizeStorePath() - if err != nil { - return err - } - data, err := os.ReadFile(storePath) - if err != nil { - if os.IsNotExist(err) { - overviewSnapshotCache = make(map[string]overviewSizeSnapshot) - overviewSnapshotLoaded = true - return nil - } - return err - } - if len(data) == 0 { - overviewSnapshotCache = make(map[string]overviewSizeSnapshot) - overviewSnapshotLoaded = true - return nil - } - var snapshots map[string]overviewSizeSnapshot - if err := json.Unmarshal(data, &snapshots); err != nil || snapshots == nil { - backupPath := storePath + ".corrupt" - _ = os.Rename(storePath, backupPath) - overviewSnapshotCache = make(map[string]overviewSizeSnapshot) - overviewSnapshotLoaded = true - return nil - } - overviewSnapshotCache = snapshots - overviewSnapshotLoaded = true - return nil -} - -func getOverviewSizeStorePath() (string, error) { - cacheDir, err := getCacheDir() - if err != nil { - return "", err - } - return filepath.Join(cacheDir, overviewCacheFile), nil -} - -func loadStoredOverviewSize(path string) (int64, error) { - if path == "" { - return 0, fmt.Errorf("empty path") - } - overviewSnapshotMu.Lock() - defer overviewSnapshotMu.Unlock() - if err := ensureOverviewSnapshotCacheLocked(); err != nil { - return 0, err - } - if overviewSnapshotCache == nil { - return 0, fmt.Errorf("snapshot cache unavailable") - } - if snapshot, ok := overviewSnapshotCache[path]; ok && snapshot.Size > 0 { - if time.Since(snapshot.Updated) < overviewCacheTTL { - return snapshot.Size, nil - } - return 0, fmt.Errorf("snapshot expired") - } - return 0, fmt.Errorf("snapshot not found") -} - -func storeOverviewSize(path string, size int64) error { - if path == "" || size <= 0 { - return fmt.Errorf("invalid overview size") - } - overviewSnapshotMu.Lock() - defer overviewSnapshotMu.Unlock() - if err := ensureOverviewSnapshotCacheLocked(); err != nil { - return err - } - if overviewSnapshotCache == nil { - overviewSnapshotCache = make(map[string]overviewSizeSnapshot) - } - overviewSnapshotCache[path] = overviewSizeSnapshot{ - Size: size, - Updated: time.Now(), - } - return persistOverviewSnapshotLocked() -} - -func persistOverviewSnapshotLocked() error { - storePath, err := getOverviewSizeStorePath() - if err != nil { - return err - } - tmpPath := storePath + ".tmp" - data, err := json.MarshalIndent(overviewSnapshotCache, "", " ") - if err != nil { - return err - } - if err := os.WriteFile(tmpPath, data, 0644); err != nil { - return err - } - return os.Rename(tmpPath, storePath) -} - -func loadOverviewCachedSize(path string) (int64, error) { - if path == "" { - return 0, fmt.Errorf("empty path") - } - if snapshot, err := loadStoredOverviewSize(path); err == nil { - return snapshot, nil - } - cacheEntry, err := loadCacheFromDisk(path) - if err != nil { - return 0, err - } - _ = storeOverviewSize(path, cacheEntry.TotalSize) - return cacheEntry.TotalSize, nil -} - -func getCacheDir() (string, error) { - home, err := os.UserHomeDir() - if err != nil { - return "", err - } - cacheDir := filepath.Join(home, ".cache", "mole") - if err := os.MkdirAll(cacheDir, 0755); err != nil { - return "", err - } - return cacheDir, nil -} - -func getCachePath(path string) (string, error) { - cacheDir, err := getCacheDir() - if err != nil { - return "", err - } - hash := xxhash.Sum64String(path) - filename := fmt.Sprintf("%x.cache", hash) - return filepath.Join(cacheDir, filename), nil -} - -func loadCacheFromDisk(path string) (*cacheEntry, error) { - cachePath, err := getCachePath(path) - if err != nil { - return nil, err - } - - file, err := os.Open(cachePath) - if err != nil { - return nil, err - } - defer file.Close() //nolint:errcheck - - var entry cacheEntry - decoder := gob.NewDecoder(file) - if err := decoder.Decode(&entry); err != nil { - return nil, err - } - - info, err := os.Stat(path) - if err != nil { - return nil, err - } - - if info.ModTime().After(entry.ModTime) { - // Allow grace window. - if cacheModTimeGrace <= 0 || info.ModTime().Sub(entry.ModTime) > cacheModTimeGrace { - return nil, fmt.Errorf("cache expired: directory modified") - } - } - - if time.Since(entry.ScanTime) > 7*24*time.Hour { - return nil, fmt.Errorf("cache expired: too old") - } - - return &entry, nil -} - -func saveCacheToDisk(path string, result scanResult) error { - cachePath, err := getCachePath(path) - if err != nil { - return err - } - - info, err := os.Stat(path) - if err != nil { - return err - } - - entry := cacheEntry{ - Entries: result.Entries, - LargeFiles: result.LargeFiles, - TotalSize: result.TotalSize, - TotalFiles: result.TotalFiles, - ModTime: info.ModTime(), - ScanTime: time.Now(), - } - - file, err := os.Create(cachePath) - if err != nil { - return err - } - defer file.Close() //nolint:errcheck - - encoder := gob.NewEncoder(file) - return encoder.Encode(entry) -} - -// peekCacheTotalFiles attempts to read the total file count from cache, -// ignoring expiration. Used for initial scan progress estimates. -func peekCacheTotalFiles(path string) (int64, error) { - cachePath, err := getCachePath(path) - if err != nil { - return 0, err - } - - file, err := os.Open(cachePath) - if err != nil { - return 0, err - } - defer file.Close() //nolint:errcheck - - var entry cacheEntry - decoder := gob.NewDecoder(file) - if err := decoder.Decode(&entry); err != nil { - return 0, err - } - - return entry.TotalFiles, nil -} - -func invalidateCache(path string) { - cachePath, err := getCachePath(path) - if err == nil { - _ = os.Remove(cachePath) - } - removeOverviewSnapshot(path) -} - -func removeOverviewSnapshot(path string) { - if path == "" { - return - } - overviewSnapshotMu.Lock() - defer overviewSnapshotMu.Unlock() - if err := ensureOverviewSnapshotCacheLocked(); err != nil { - return - } - if overviewSnapshotCache == nil { - return - } - if _, ok := overviewSnapshotCache[path]; ok { - delete(overviewSnapshotCache, path) - _ = persistOverviewSnapshotLocked() - } -} - -// prefetchOverviewCache warms overview cache in background. -func prefetchOverviewCache(ctx context.Context) { - entries := createOverviewEntries() - - var needScan []string - for _, entry := range entries { - if size, err := loadStoredOverviewSize(entry.Path); err == nil && size > 0 { - continue - } - needScan = append(needScan, entry.Path) - } - - if len(needScan) == 0 { - return - } - - for _, path := range needScan { - select { - case <-ctx.Done(): - return - default: - } - - size, err := measureOverviewSize(path) - if err == nil && size > 0 { - _ = storeOverviewSize(path, size) - } - } -} diff --git a/cmd/analyze/cleanable.go b/cmd/analyze/cleanable.go deleted file mode 100644 index 4c80879..0000000 --- a/cmd/analyze/cleanable.go +++ /dev/null @@ -1,107 +0,0 @@ -package main - -import ( - "path/filepath" - "strings" -) - -// isCleanableDir marks paths safe to delete manually (not handled by mo clean). -func isCleanableDir(path string) bool { - if path == "" { - return false - } - - // Exclude paths mo clean already handles. - if isHandledByMoClean(path) { - return false - } - - baseName := filepath.Base(path) - - // Project dependencies and build outputs are safe. - if projectDependencyDirs[baseName] { - return true - } - - return false -} - -// isHandledByMoClean checks if a path is cleaned by mo clean. -func isHandledByMoClean(path string) bool { - cleanPaths := []string{ - "/Library/Caches/", - "/Library/Logs/", - "/Library/Saved Application State/", - "/.Trash/", - "/Library/DiagnosticReports/", - } - - for _, p := range cleanPaths { - if strings.Contains(path, p) { - return true - } - } - - return false -} - -// Project dependency and build directories. -var projectDependencyDirs = map[string]bool{ - // JavaScript/Node. - "node_modules": true, - "bower_components": true, - ".yarn": true, - ".pnpm-store": true, - - // Python. - "venv": true, - ".venv": true, - "virtualenv": true, - "__pycache__": true, - ".pytest_cache": true, - ".mypy_cache": true, - ".ruff_cache": true, - ".tox": true, - ".eggs": true, - "htmlcov": true, - ".ipynb_checkpoints": true, - - // Ruby. - "vendor": true, - ".bundle": true, - - // Java/Kotlin/Scala. - ".gradle": true, - "out": true, - - // Build outputs. - "build": true, - "dist": true, - "target": true, - ".next": true, - ".nuxt": true, - ".output": true, - ".parcel-cache": true, - ".turbo": true, - ".vite": true, - ".nx": true, - "coverage": true, - ".coverage": true, - ".nyc_output": true, - - // Frontend framework outputs. - ".angular": true, - ".svelte-kit": true, - ".astro": true, - ".docusaurus": true, - - // Apple dev. - "DerivedData": true, - "Pods": true, - ".build": true, - "Carthage": true, - ".dart_tool": true, - - // Other tools. - ".terraform": true, -} diff --git a/cmd/analyze/constants.go b/cmd/analyze/constants.go deleted file mode 100644 index 7106087..0000000 --- a/cmd/analyze/constants.go +++ /dev/null @@ -1,248 +0,0 @@ -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 - - // Worker pool limits. - minWorkers = 16 - maxWorkers = 64 - cpuMultiplier = 4 - maxDirWorkers = 32 - openCommandTimeout = 10 * time.Second -) - -var foldDirs = map[string]bool{ - // VCS. - ".git": true, - ".svn": true, - ".hg": true, - - // JavaScript/Node. - "node_modules": true, - ".npm": true, - "_npx": true, - "_cacache": true, - "_logs": true, - "_locks": true, - "_quick": true, - "_libvips": true, - "_prebuilds": true, - "_update-notifier-last-checked": true, - ".yarn": true, - ".pnpm-store": true, - ".next": true, - ".nuxt": true, - "bower_components": true, - ".vite": true, - ".turbo": true, - ".parcel-cache": true, - ".nx": true, - ".rush": true, - "tnpm": true, - ".tnpm": true, - ".bun": true, - ".deno": true, - - // Python. - "__pycache__": true, - ".pytest_cache": true, - ".mypy_cache": true, - ".ruff_cache": true, - "venv": true, - ".venv": true, - "virtualenv": true, - ".tox": true, - "site-packages": true, - ".eggs": true, - "*.egg-info": true, - ".pyenv": true, - ".poetry": true, - ".pip": true, - ".pipx": true, - - // Ruby/Go/PHP (vendor), Java/Kotlin/Scala/Rust (target). - "vendor": true, - ".bundle": true, - "gems": true, - ".rbenv": true, - "target": true, - ".gradle": true, - ".m2": true, - ".ivy2": true, - "out": true, - "pkg": true, - "composer.phar": true, - ".composer": true, - ".cargo": true, - - // Build outputs. - "build": true, - "dist": true, - ".output": true, - "coverage": true, - ".coverage": true, - - // IDE. - ".idea": true, - ".vscode": true, - ".vs": true, - ".fleet": true, - - // Cache directories. - ".cache": true, - "__MACOSX": true, - ".DS_Store": true, - ".Trash": true, - "Caches": true, - ".Spotlight-V100": true, - ".fseventsd": true, - ".DocumentRevisions-V100": true, - ".TemporaryItems": true, - "$RECYCLE.BIN": true, - ".temp": true, - ".tmp": true, - "_temp": true, - "_tmp": true, - ".Homebrew": true, - ".rustup": true, - ".sdkman": true, - ".nvm": true, - - // macOS. - "Application Scripts": true, - "Saved Application State": true, - - // iCloud. - "Mobile Documents": true, - - // Containers. - ".docker": true, - ".containerd": true, - - // Mobile development. - "Pods": true, - "DerivedData": true, - ".build": true, - "xcuserdata": true, - "Carthage": true, - ".dart_tool": true, - - // Web frameworks. - ".angular": true, - ".svelte-kit": true, - ".astro": true, - ".solid": true, - - // Databases. - ".mysql": true, - ".postgres": true, - "mongodb": true, - - // Other. - ".terraform": true, - ".vagrant": true, - "tmp": true, - "temp": true, -} - -var skipSystemDirs = map[string]bool{ - "dev": true, - "tmp": true, - "private": true, - "cores": true, - "net": true, - "home": true, - "System": true, - "sbin": true, - "bin": true, - "etc": true, - "var": true, - "opt": false, - "usr": false, - "Volumes": true, - "Network": true, - ".vol": true, - ".Spotlight-V100": true, - ".fseventsd": true, - ".DocumentRevisions-V100": true, - ".TemporaryItems": true, - ".MobileBackups": true, -} - -var defaultSkipDirs = map[string]bool{ - "nfs": true, - "PHD": true, - "Permissions": true, -} - -var skipExtensions = map[string]bool{ - ".go": true, - ".js": true, - ".ts": true, - ".tsx": true, - ".jsx": true, - ".json": true, - ".md": true, - ".txt": true, - ".yml": true, - ".yaml": true, - ".xml": true, - ".html": true, - ".css": true, - ".scss": true, - ".sass": true, - ".less": true, - ".py": true, - ".rb": true, - ".java": true, - ".kt": true, - ".rs": true, - ".swift": true, - ".m": true, - ".mm": true, - ".c": true, - ".cpp": true, - ".h": true, - ".hpp": true, - ".cs": true, - ".sql": true, - ".db": true, - ".lock": true, - ".gradle": true, - ".mjs": true, - ".cjs": true, - ".coffee": true, - ".dart": true, - ".svelte": true, - ".vue": true, - ".nim": true, - ".hx": true, -} - -var spinnerFrames = []string{"|", "/", "-", "\\", "|", "/", "-", "\\"} - -const ( - colorPurple = "\033[0;35m" - colorPurpleBold = "\033[1;35m" - colorGray = "\033[0;90m" - colorRed = "\033[0;31m" - colorYellow = "\033[0;33m" - colorGreen = "\033[0;32m" - colorBlue = "\033[0;34m" - colorCyan = "\033[0;36m" - colorReset = "\033[0m" - colorBold = "\033[1m" -) diff --git a/cmd/analyze/delete.go b/cmd/analyze/delete.go deleted file mode 100644 index 11feaee..0000000 --- a/cmd/analyze/delete.go +++ /dev/null @@ -1,146 +0,0 @@ -package main - -import ( - "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "sort" - "strings" - "sync/atomic" - "time" - - tea "github.com/charmbracelet/bubbletea" -) - -const trashTimeout = 30 * time.Second - -func deletePathCmd(path string, counter *int64) tea.Cmd { - return func() tea.Msg { - count, err := trashPathWithProgress(path, counter) - return deleteProgressMsg{ - done: true, - err: err, - count: count, - path: path, - } - } -} - -// deleteMultiplePathsCmd moves paths to Trash and aggregates results. -func deleteMultiplePathsCmd(paths []string, counter *int64) tea.Cmd { - return func() tea.Msg { - var totalCount int64 - var errors []string - - // Process deeper paths first to avoid parent/child conflicts. - pathsToDelete := append([]string(nil), paths...) - sort.Slice(pathsToDelete, func(i, j int) bool { - return strings.Count(pathsToDelete[i], string(filepath.Separator)) > strings.Count(pathsToDelete[j], string(filepath.Separator)) - }) - - for _, path := range pathsToDelete { - count, err := trashPathWithProgress(path, counter) - totalCount += count - if err != nil { - if os.IsNotExist(err) { - continue - } - errors = append(errors, err.Error()) - } - } - - var resultErr error - if len(errors) > 0 { - resultErr = &multiDeleteError{errors: errors} - } - - return deleteProgressMsg{ - done: true, - err: resultErr, - count: totalCount, - path: "", - } - } -} - -// multiDeleteError holds multiple deletion errors. -type multiDeleteError struct { - errors []string -} - -func (e *multiDeleteError) Error() string { - if len(e.errors) == 1 { - return e.errors[0] - } - return strings.Join(e.errors[:min(3, len(e.errors))], "; ") -} - -// trashPathWithProgress moves a path to Trash using Finder. -// This allows users to recover accidentally deleted files. -func trashPathWithProgress(root string, counter *int64) (int64, error) { - // Verify path exists (use Lstat to handle broken symlinks). - info, err := os.Lstat(root) - if err != nil { - return 0, err - } - - // Count items for progress reporting. - var count int64 - if info.IsDir() { - _ = filepath.WalkDir(root, func(_ string, d os.DirEntry, err error) error { - if err != nil { - return nil - } - if !d.IsDir() { - count++ - if counter != nil { - atomic.StoreInt64(counter, count) - } - } - return nil - }) - } else { - count = 1 - if counter != nil { - atomic.StoreInt64(counter, 1) - } - } - - // Move to Trash using Finder AppleScript. - if err := moveToTrash(root); err != nil { - return 0, err - } - - return count, nil -} - -// moveToTrash uses macOS Finder to move a file/directory to Trash. -// This is the safest method as it uses the system's native trash mechanism. -func moveToTrash(path string) error { - absPath, err := filepath.Abs(path) - if err != nil { - return fmt.Errorf("failed to resolve path: %w", err) - } - - // Escape path for AppleScript (handle quotes and backslashes). - escapedPath := strings.ReplaceAll(absPath, "\\", "\\\\") - escapedPath = strings.ReplaceAll(escapedPath, "\"", "\\\"") - - script := fmt.Sprintf(`tell application "Finder" to delete POSIX file "%s"`, escapedPath) - - ctx, cancel := context.WithTimeout(context.Background(), trashTimeout) - defer cancel() - - cmd := exec.CommandContext(ctx, "osascript", "-e", script) - output, err := cmd.CombinedOutput() - if err != nil { - if ctx.Err() == context.DeadlineExceeded { - return fmt.Errorf("timeout moving to Trash") - } - return fmt.Errorf("failed to move to Trash: %s", strings.TrimSpace(string(output))) - } - - return nil -} diff --git a/cmd/analyze/delete_test.go b/cmd/analyze/delete_test.go deleted file mode 100644 index 9e48b22..0000000 --- a/cmd/analyze/delete_test.go +++ /dev/null @@ -1,87 +0,0 @@ -package main - -import ( - "os" - "path/filepath" - "testing" -) - -func TestTrashPathWithProgress(t *testing.T) { - // Skip in CI environments where Finder may not be available. - if os.Getenv("CI") != "" { - t.Skip("Skipping Finder-dependent test in CI") - } - - parent := t.TempDir() - target := filepath.Join(parent, "target") - if err := os.MkdirAll(target, 0o755); err != nil { - t.Fatalf("create target: %v", err) - } - - files := []string{ - filepath.Join(target, "one.txt"), - filepath.Join(target, "two.txt"), - } - for _, f := range files { - if err := os.WriteFile(f, []byte("content"), 0o644); err != nil { - t.Fatalf("write %s: %v", f, err) - } - } - - var counter int64 - count, err := trashPathWithProgress(target, &counter) - if err != nil { - t.Fatalf("trashPathWithProgress returned error: %v", err) - } - if count != int64(len(files)) { - t.Fatalf("expected %d files trashed, got %d", len(files), count) - } - if _, err := os.Stat(target); !os.IsNotExist(err) { - t.Fatalf("expected target to be moved to Trash, stat err=%v", err) - } -} - -func TestDeleteMultiplePathsCmdHandlesParentChild(t *testing.T) { - // Skip in CI environments where Finder may not be available. - if os.Getenv("CI") != "" { - t.Skip("Skipping Finder-dependent test in CI") - } - - base := t.TempDir() - parent := filepath.Join(base, "parent") - child := filepath.Join(parent, "child") - - // Structure: parent/fileA, parent/child/fileC. - if err := os.MkdirAll(child, 0o755); err != nil { - t.Fatalf("mkdir: %v", err) - } - if err := os.WriteFile(filepath.Join(parent, "fileA"), []byte("a"), 0o644); err != nil { - t.Fatalf("write fileA: %v", err) - } - if err := os.WriteFile(filepath.Join(child, "fileC"), []byte("c"), 0o644); err != nil { - t.Fatalf("write fileC: %v", err) - } - - var counter int64 - msg := deleteMultiplePathsCmd([]string{parent, child}, &counter)() - progress, ok := msg.(deleteProgressMsg) - if !ok { - t.Fatalf("expected deleteProgressMsg, got %T", msg) - } - if progress.err != nil { - t.Fatalf("unexpected error: %v", progress.err) - } - if progress.count != 2 { - t.Fatalf("expected 2 files trashed, got %d", progress.count) - } - if _, err := os.Stat(parent); !os.IsNotExist(err) { - t.Fatalf("expected parent to be moved to Trash, err=%v", err) - } -} - -func TestMoveToTrashNonExistent(t *testing.T) { - err := moveToTrash("/nonexistent/path/that/does/not/exist") - if err == nil { - t.Fatal("expected error for non-existent path") - } -} diff --git a/cmd/analyze/format.go b/cmd/analyze/format.go deleted file mode 100644 index 5ef48d6..0000000 --- a/cmd/analyze/format.go +++ /dev/null @@ -1,247 +0,0 @@ -package main - -import ( - "fmt" - "os" - "strings" - "time" -) - -func displayPath(path string) string { - home, err := os.UserHomeDir() - if err != nil || home == "" { - return path - } - if strings.HasPrefix(path, home) { - return strings.Replace(path, home, "~", 1) - } - return path -} - -// truncateMiddle trims the middle, keeping head and tail. -func truncateMiddle(s string, maxWidth int) string { - runes := []rune(s) - currentWidth := displayWidth(s) - - if currentWidth <= maxWidth { - return s - } - - if maxWidth < 10 { - width := 0 - for i, r := range runes { - width += runeWidth(r) - if width > maxWidth { - return string(runes[:i]) - } - } - return s - } - - targetHeadWidth := (maxWidth - 3) / 3 - targetTailWidth := maxWidth - 3 - targetHeadWidth - - headWidth := 0 - headIdx := 0 - for i, r := range runes { - w := runeWidth(r) - if headWidth+w > targetHeadWidth { - break - } - headWidth += w - headIdx = i + 1 - } - - tailWidth := 0 - tailIdx := len(runes) - for i := len(runes) - 1; i >= 0; i-- { - w := runeWidth(runes[i]) - if tailWidth+w > targetTailWidth { - break - } - tailWidth += w - tailIdx = i - } - - return string(runes[:headIdx]) + "..." + string(runes[tailIdx:]) -} - -func formatNumber(n int64) string { - if n < 1000 { - return fmt.Sprintf("%d", n) - } - if n < 1000000 { - return fmt.Sprintf("%.1fk", float64(n)/1000) - } - return fmt.Sprintf("%.1fM", float64(n)/1000000) -} - -func humanizeBytes(size int64) string { - if size < 0 { - return "0 B" - } - const unit = 1024 - if size < unit { - return fmt.Sprintf("%d B", size) - } - div, exp := int64(unit), 0 - for n := size / unit; n >= unit; n /= unit { - div *= unit - exp++ - } - value := float64(size) / float64(div) - return fmt.Sprintf("%.1f %cB", value, "KMGTPE"[exp]) -} - -func coloredProgressBar(value, maxValue int64, percent float64) string { - if maxValue <= 0 { - return colorGray + strings.Repeat("░", barWidth) + colorReset - } - - filled := min(int((value*int64(barWidth))/maxValue), barWidth) - - var barColor string - if percent >= 50 { - barColor = colorRed - } else if percent >= 20 { - barColor = colorYellow - } else if percent >= 5 { - barColor = colorBlue - } else { - barColor = colorGreen - } - - var bar strings.Builder - bar.WriteString(barColor) - for i := range barWidth { - if i < filled { - if i < filled-1 { - bar.WriteString("█") - } else { - remainder := (value * int64(barWidth)) % maxValue - if remainder > maxValue/2 { - bar.WriteString("█") - } else if remainder > maxValue/4 { - bar.WriteString("▓") - } else { - bar.WriteString("▒") - } - } - } else { - bar.WriteString(colorGray + "░" + barColor) - } - } - return bar.String() + colorReset -} - -// runeWidth returns display width for wide characters and emoji. -func runeWidth(r rune) int { - if r >= 0x4E00 && r <= 0x9FFF || // CJK Unified Ideographs - r >= 0x3400 && r <= 0x4DBF || // CJK Extension A - r >= 0x20000 && r <= 0x2A6DF || // CJK Extension B - r >= 0x2A700 && r <= 0x2B73F || // CJK Extension C - r >= 0x2B740 && r <= 0x2B81F || // CJK Extension D - r >= 0x2B820 && r <= 0x2CEAF || // CJK Extension E - r >= 0x3040 && r <= 0x30FF || // Hiragana and Katakana - r >= 0x31F0 && r <= 0x31FF || // Katakana Phonetic Extensions - r >= 0xAC00 && r <= 0xD7AF || // Hangul Syllables - r >= 0xFF00 && r <= 0xFFEF || // Fullwidth Forms - r >= 0x1F300 && r <= 0x1F6FF || // Miscellaneous Symbols and Pictographs (includes Transport) - r >= 0x1F900 && r <= 0x1F9FF || // Supplemental Symbols and Pictographs - r >= 0x2600 && r <= 0x26FF || // Miscellaneous Symbols - r >= 0x2700 && r <= 0x27BF || // Dingbats - r >= 0xFE10 && r <= 0xFE1F || // Vertical Forms - r >= 0x1F000 && r <= 0x1F02F { // Mahjong Tiles - return 2 - } - return 1 -} - -func displayWidth(s string) int { - width := 0 - for _, r := range s { - width += runeWidth(r) - } - return width -} - -// calculateNameWidth computes name column width from terminal width. -func calculateNameWidth(termWidth int) int { - const fixedWidth = 61 - available := termWidth - fixedWidth - - if available < 24 { - return 24 - } - if available > 60 { - return 60 - } - return available -} - -func trimNameWithWidth(name string, maxWidth int) string { - const ( - ellipsis = "..." - ellipsisWidth = 3 - ) - - runes := []rune(name) - widths := make([]int, len(runes)) - for i, r := range runes { - widths[i] = runeWidth(r) - } - - currentWidth := 0 - for i, w := range widths { - if currentWidth+w > maxWidth { - subWidth := currentWidth - j := i - for j > 0 && subWidth+ellipsisWidth > maxWidth { - j-- - subWidth -= widths[j] - } - if j == 0 { - return ellipsis - } - return string(runes[:j]) + ellipsis - } - currentWidth += w - } - - return name -} - -func padName(name string, targetWidth int) string { - currentWidth := displayWidth(name) - if currentWidth >= targetWidth { - return name - } - return name + strings.Repeat(" ", targetWidth-currentWidth) -} - -// formatUnusedTime formats time since last access. -func formatUnusedTime(lastAccess time.Time) string { - if lastAccess.IsZero() { - return "" - } - - duration := time.Since(lastAccess) - days := int(duration.Hours() / 24) - - if days < 90 { - return "" - } - - months := days / 30 - years := days / 365 - - if years >= 2 { - return fmt.Sprintf(">%dyr", years) - } else if years >= 1 { - return ">1yr" - } else if months >= 3 { - return fmt.Sprintf(">%dmo", months) - } - - return "" -} diff --git a/cmd/analyze/format_test.go b/cmd/analyze/format_test.go deleted file mode 100644 index f328212..0000000 --- a/cmd/analyze/format_test.go +++ /dev/null @@ -1,309 +0,0 @@ -package main - -import ( - "strings" - "testing" -) - -func TestRuneWidth(t *testing.T) { - tests := []struct { - name string - input rune - want int - }{ - {"ASCII letter", 'a', 1}, - {"ASCII digit", '5', 1}, - {"Chinese character", '中', 2}, - {"Japanese hiragana", 'あ', 2}, - {"Korean hangul", '한', 2}, - {"CJK ideograph", '語', 2}, - {"Full-width number", '1', 2}, - {"ASCII space", ' ', 1}, - {"Tab", '\t', 1}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := runeWidth(tt.input); got != tt.want { - t.Errorf("runeWidth(%q) = %d, want %d", tt.input, got, tt.want) - } - }) - } -} - -func TestDisplayWidth(t *testing.T) { - tests := []struct { - name string - input string - want int - }{ - {"Empty string", "", 0}, - {"ASCII only", "hello", 5}, - {"Chinese only", "你好", 4}, - {"Mixed ASCII and CJK", "hello世界", 9}, // 5 + 4 - {"Path with CJK", "/Users/张三/文件", 16}, // 7 (ASCII) + 4 (张三) + 4 (文件) + 1 (/) = 16 - {"Full-width chars", "123", 6}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := displayWidth(tt.input); got != tt.want { - t.Errorf("displayWidth(%q) = %d, want %d", tt.input, got, tt.want) - } - }) - } -} - -func TestHumanizeBytes(t *testing.T) { - tests := []struct { - input int64 - want string - }{ - {-100, "0 B"}, - {0, "0 B"}, - {512, "512 B"}, - {1023, "1023 B"}, - {1024, "1.0 KB"}, - {1536, "1.5 KB"}, - {10240, "10.0 KB"}, - {1048576, "1.0 MB"}, - {1572864, "1.5 MB"}, - {1073741824, "1.0 GB"}, - {1099511627776, "1.0 TB"}, - {1125899906842624, "1.0 PB"}, - } - - for _, tt := range tests { - got := humanizeBytes(tt.input) - if got != tt.want { - t.Errorf("humanizeBytes(%d) = %q, want %q", tt.input, got, tt.want) - } - } -} - -func TestFormatNumber(t *testing.T) { - tests := []struct { - input int64 - want string - }{ - {0, "0"}, - {500, "500"}, - {999, "999"}, - {1000, "1.0k"}, - {1500, "1.5k"}, - {999999, "1000.0k"}, - {1000000, "1.0M"}, - {1500000, "1.5M"}, - } - - for _, tt := range tests { - got := formatNumber(tt.input) - if got != tt.want { - t.Errorf("formatNumber(%d) = %q, want %q", tt.input, got, tt.want) - } - } -} - -func TestTruncateMiddle(t *testing.T) { - tests := []struct { - name string - input string - maxWidth int - check func(t *testing.T, result string) - }{ - { - name: "No truncation needed", - input: "short", - maxWidth: 10, - check: func(t *testing.T, result string) { - if result != "short" { - t.Errorf("Should not truncate short string, got %q", result) - } - }, - }, - { - name: "Truncate long ASCII", - input: "verylongfilename.txt", - maxWidth: 15, - check: func(t *testing.T, result string) { - if !strings.Contains(result, "...") { - t.Errorf("Truncated string should contain '...', got %q", result) - } - if displayWidth(result) > 15 { - t.Errorf("Truncated width %d exceeds max %d", displayWidth(result), 15) - } - }, - }, - { - name: "Truncate with CJK characters", - input: "非常长的中文文件名称.txt", - maxWidth: 20, - check: func(t *testing.T, result string) { - if !strings.Contains(result, "...") { - t.Errorf("Should truncate CJK string, got %q", result) - } - if displayWidth(result) > 20 { - t.Errorf("Truncated width %d exceeds max %d", displayWidth(result), 20) - } - }, - }, - { - name: "Very small width", - input: "longname", - maxWidth: 5, - check: func(t *testing.T, result string) { - if displayWidth(result) > 5 { - t.Errorf("Width %d exceeds max %d", displayWidth(result), 5) - } - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := truncateMiddle(tt.input, tt.maxWidth) - tt.check(t, result) - }) - } -} - -func TestDisplayPath(t *testing.T) { - tests := []struct { - name string - setup func() string - check func(t *testing.T, result string) - }{ - { - name: "Replace home directory", - setup: func() string { - home := t.TempDir() - t.Setenv("HOME", home) - return home + "/Documents/file.txt" - }, - check: func(t *testing.T, result string) { - if !strings.HasPrefix(result, "~/") { - t.Errorf("Expected path to start with ~/, got %q", result) - } - if !strings.HasSuffix(result, "Documents/file.txt") { - t.Errorf("Expected path to end with Documents/file.txt, got %q", result) - } - }, - }, - { - name: "Keep absolute path outside home", - setup: func() string { - t.Setenv("HOME", "/Users/test") - return "/var/log/system.log" - }, - check: func(t *testing.T, result string) { - if result != "/var/log/system.log" { - t.Errorf("Expected unchanged path, got %q", result) - } - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - path := tt.setup() - result := displayPath(path) - tt.check(t, result) - }) - } -} - -func TestPadName(t *testing.T) { - tests := []struct { - name string - input string - targetWidth int - wantWidth int - }{ - {"Pad ASCII", "test", 10, 10}, - {"No padding needed", "longname", 5, 8}, - {"Pad CJK", "中文", 10, 10}, - {"Mixed CJK and ASCII", "hello世", 15, 15}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := padName(tt.input, tt.targetWidth) - gotWidth := displayWidth(result) - if gotWidth < tt.wantWidth && displayWidth(tt.input) < tt.targetWidth { - t.Errorf("padName(%q, %d) width = %d, want >= %d", tt.input, tt.targetWidth, gotWidth, tt.wantWidth) - } - }) - } -} - -func TestTrimNameWithWidth(t *testing.T) { - tests := []struct { - name string - input string - maxWidth int - check func(t *testing.T, result string) - }{ - { - name: "Trim ASCII name", - input: "verylongfilename.txt", - maxWidth: 10, - check: func(t *testing.T, result string) { - if displayWidth(result) > 10 { - t.Errorf("Width exceeds max: %d > 10", displayWidth(result)) - } - if !strings.HasSuffix(result, "...") { - t.Errorf("Expected ellipsis, got %q", result) - } - }, - }, - { - name: "Trim CJK name", - input: "很长的文件名称.txt", - maxWidth: 12, - check: func(t *testing.T, result string) { - if displayWidth(result) > 12 { - t.Errorf("Width exceeds max: %d > 12", displayWidth(result)) - } - }, - }, - { - name: "No trimming needed", - input: "short.txt", - maxWidth: 20, - check: func(t *testing.T, result string) { - if result != "short.txt" { - t.Errorf("Should not trim, got %q", result) - } - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := trimNameWithWidth(tt.input, tt.maxWidth) - tt.check(t, result) - }) - } -} - -func TestCalculateNameWidth(t *testing.T) { - tests := []struct { - termWidth int - wantMin int - wantMax int - }{ - {80, 19, 60}, // 80 - 61 = 19 - {120, 59, 60}, // 120 - 61 = 59 - {200, 60, 60}, // Capped at 60 - {70, 24, 60}, // Below minimum, use 24 - {50, 24, 60}, // Very small, use minimum - } - - for _, tt := range tests { - got := calculateNameWidth(tt.termWidth) - if got < tt.wantMin || got > tt.wantMax { - t.Errorf("calculateNameWidth(%d) = %d, want between %d and %d", - tt.termWidth, got, tt.wantMin, tt.wantMax) - } - } -} diff --git a/cmd/analyze/heap.go b/cmd/analyze/heap.go deleted file mode 100644 index 0b4a5a5..0000000 --- a/cmd/analyze/heap.go +++ /dev/null @@ -1,39 +0,0 @@ -package main - -// entryHeap is a min-heap of dirEntry used to keep Top N largest entries. -type entryHeap []dirEntry - -func (h entryHeap) Len() int { return len(h) } -func (h entryHeap) Less(i, j int) bool { return h[i].Size < h[j].Size } -func (h entryHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] } - -func (h *entryHeap) Push(x any) { - *h = append(*h, x.(dirEntry)) -} - -func (h *entryHeap) Pop() any { - old := *h - n := len(old) - x := old[n-1] - *h = old[0 : n-1] - return x -} - -// largeFileHeap is a min-heap for fileEntry. -type largeFileHeap []fileEntry - -func (h largeFileHeap) Len() int { return len(h) } -func (h largeFileHeap) Less(i, j int) bool { return h[i].Size < h[j].Size } -func (h largeFileHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] } - -func (h *largeFileHeap) Push(x any) { - *h = append(*h, x.(fileEntry)) -} - -func (h *largeFileHeap) Pop() any { - old := *h - n := len(old) - x := old[n-1] - *h = old[0 : n-1] - return x -} diff --git a/cmd/analyze/main.go b/cmd/analyze/main.go index f34464a..60cc9bc 100644 --- a/cmd/analyze/main.go +++ b/cmd/analyze/main.go @@ -1,28 +1,155 @@ -//go:build darwin +//go:build windows package main import ( "context" + "flag" "fmt" - "io/fs" "os" "os/exec" "path/filepath" + "runtime" "sort" "strings" + "sync" "sync/atomic" "time" tea "github.com/charmbracelet/bubbletea" ) +// Scanning limits to prevent infinite scanning +const ( + dirSizeTimeout = 500 * time.Millisecond // Max time to calculate a single directory size + maxFilesPerDir = 10000 // Max files to scan per directory + maxScanDepth = 10 // Max recursion depth (shallow scan) + shallowScanDepth = 3 // Depth for quick size estimation +) + +// ANSI color codes +const ( + colorReset = "\033[0m" + colorBold = "\033[1m" + colorDim = "\033[2m" + colorPurple = "\033[35m" + colorPurpleBold = "\033[1;35m" + colorCyan = "\033[36m" + colorCyanBold = "\033[1;36m" + colorYellow = "\033[33m" + colorGreen = "\033[32m" + colorRed = "\033[31m" + colorGray = "\033[90m" + colorWhite = "\033[97m" +) + +// Icons +const ( + iconFolder = "📁" + iconFile = "📄" + iconDisk = "💾" + iconClean = "🧹" + iconTrash = "🗑️" + iconBack = "⬅️" + iconSelected = "✓" + iconArrow = "➤" +) + +// Cleanable directory patterns +var cleanablePatterns = map[string]bool{ + "node_modules": true, + "vendor": true, + ".venv": true, + "venv": true, + "__pycache__": true, + ".pytest_cache": true, + "target": true, + "build": true, + "dist": true, + ".next": true, + ".nuxt": true, + ".turbo": true, + ".parcel-cache": true, + "bin": true, + "obj": true, + ".gradle": true, + ".idea": true, + ".vs": true, +} + +// Skip patterns for scanning +var skipPatterns = map[string]bool{ + "$Recycle.Bin": true, + "System Volume Information": true, + "Windows": true, + "Program Files": true, + "Program Files (x86)": true, + "ProgramData": true, + "Recovery": true, + "Config.Msi": true, +} + +// Protected paths that should NEVER be deleted +var protectedPaths = []string{ + `C:\Windows`, + `C:\Program Files`, + `C:\Program Files (x86)`, + `C:\ProgramData`, + `C:\Users\Default`, + `C:\Users\Public`, + `C:\Recovery`, + `C:\System Volume Information`, +} + +// isProtectedPath checks if a path is protected from deletion +func isProtectedPath(path string) bool { + absPath, err := filepath.Abs(path) + if err != nil { + return true // If we can't resolve the path, treat it as protected + } + absPath = strings.ToLower(absPath) + + // Check against protected paths + for _, protected := range protectedPaths { + protectedLower := strings.ToLower(protected) + if absPath == protectedLower || strings.HasPrefix(absPath, protectedLower+`\`) { + return true + } + } + + // Check against skip patterns (system directories) + baseName := strings.ToLower(filepath.Base(absPath)) + for pattern := range skipPatterns { + if strings.ToLower(pattern) == baseName { + // Only protect if it's at a root level (e.g., C:\Windows, not C:\Projects\Windows) + parent := filepath.Dir(absPath) + if len(parent) <= 3 { // e.g., "C:\" + return true + } + } + } + + // Protect Windows directory itself + winDir := strings.ToLower(os.Getenv("WINDIR")) + sysRoot := strings.ToLower(os.Getenv("SYSTEMROOT")) + if winDir != "" && (absPath == winDir || strings.HasPrefix(absPath, winDir+`\`)) { + return true + } + if sysRoot != "" && (absPath == sysRoot || strings.HasPrefix(absPath, sysRoot+`\`)) { + return true + } + + return false +} + +// Entry types type dirEntry struct { - Name string - Path string - Size int64 - IsDir bool - LastAccess time.Time + Name string + Path string + Size int64 + IsDir bool + LastAccess time.Time + IsCleanable bool } type fileEntry struct { @@ -31,1101 +158,623 @@ type fileEntry struct { Size int64 } -type scanResult struct { - Entries []dirEntry - LargeFiles []fileEntry - TotalSize int64 - TotalFiles int64 -} - -type cacheEntry struct { - Entries []dirEntry - LargeFiles []fileEntry - TotalSize int64 - TotalFiles int64 - ModTime time.Time - ScanTime time.Time -} - type historyEntry struct { - Path string - Entries []dirEntry - LargeFiles []fileEntry - TotalSize int64 - TotalFiles int64 - Selected int - EntryOffset int - LargeSelected int - LargeOffset int - Dirty bool - IsOverview bool -} - -type scanResultMsg struct { - result scanResult - err error -} - -type overviewSizeMsg struct { - Path string - Index int - Size int64 - Err error -} - -type tickMsg time.Time - -type deleteProgressMsg struct { - done bool - err error - count int64 - path string + Path string + Entries []dirEntry + LargeFiles []fileEntry + TotalSize int64 + Selected int } +// Model for Bubble Tea type model struct { - path string - history []historyEntry - entries []dirEntry - largeFiles []fileEntry - selected int - offset int - status string - totalSize int64 - scanning bool - spinner int - filesScanned *int64 - dirsScanned *int64 - bytesScanned *int64 - currentPath *string - showLargeFiles bool - isOverview bool - deleteConfirm bool - deleteTarget *dirEntry - deleting bool - deleteCount *int64 - cache map[string]historyEntry - largeSelected int - largeOffset int - overviewSizeCache map[string]int64 - overviewFilesScanned *int64 - overviewDirsScanned *int64 - overviewBytesScanned *int64 - overviewCurrentPath *string - overviewScanning bool - overviewScanningSet map[string]bool // Track which paths are currently being scanned - width int // Terminal width - height int // Terminal height - multiSelected map[string]bool // Track multi-selected items by path (safer than index) - largeMultiSelected map[string]bool // Track multi-selected large files by path (safer than index) - totalFiles int64 // Total files found in current/last scan - lastTotalFiles int64 // Total files from previous scan (for progress bar) + path string + entries []dirEntry + largeFiles []fileEntry + history []historyEntry + selected int + totalSize int64 + scanning bool + showLargeFiles bool + multiSelected map[string]bool + deleteConfirm bool + deleteTarget string + scanProgress int64 + scanTotal int64 + width int + height int + err error + cache map[string]historyEntry } -func (m model) inOverviewMode() bool { - return m.isOverview && m.path == "/" +// Messages +type scanCompleteMsg struct { + entries []dirEntry + largeFiles []fileEntry + totalSize int64 } -func main() { - target := os.Getenv("MO_ANALYZE_PATH") - if target == "" && len(os.Args) > 1 { - target = os.Args[1] - } - - var abs string - var isOverview bool - - if target == "" { - isOverview = true - abs = "/" - } else { - var err error - abs, err = filepath.Abs(target) - if err != nil { - fmt.Fprintf(os.Stderr, "cannot resolve %q: %v\n", target, err) - os.Exit(1) - } - isOverview = false - } - - // Warm overview cache in background. - prefetchCtx, prefetchCancel := context.WithTimeout(context.Background(), 30*time.Second) - defer prefetchCancel() - go prefetchOverviewCache(prefetchCtx) - - p := tea.NewProgram(newModel(abs, isOverview), tea.WithAltScreen()) - if _, err := p.Run(); err != nil { - fmt.Fprintf(os.Stderr, "analyzer error: %v\n", err) - os.Exit(1) - } +type scanProgressMsg struct { + current int64 + total int64 } -func newModel(path string, isOverview bool) model { - var filesScanned, dirsScanned, bytesScanned int64 - currentPath := "" - var overviewFilesScanned, overviewDirsScanned, overviewBytesScanned int64 - overviewCurrentPath := "" - - m := model{ - path: path, - selected: 0, - status: "Preparing scan...", - scanning: !isOverview, - filesScanned: &filesScanned, - dirsScanned: &dirsScanned, - bytesScanned: &bytesScanned, - currentPath: ¤tPath, - showLargeFiles: false, - isOverview: isOverview, - cache: make(map[string]historyEntry), - overviewFilesScanned: &overviewFilesScanned, - overviewDirsScanned: &overviewDirsScanned, - overviewBytesScanned: &overviewBytesScanned, - overviewCurrentPath: &overviewCurrentPath, - overviewSizeCache: make(map[string]int64), - overviewScanningSet: make(map[string]bool), - multiSelected: make(map[string]bool), - largeMultiSelected: make(map[string]bool), - } - - if isOverview { - m.scanning = false - m.hydrateOverviewEntries() - m.selected = 0 - m.offset = 0 - if nextPendingOverviewIndex(m.entries) >= 0 { - m.overviewScanning = true - m.status = "Checking system folders..." - } else { - m.status = "Ready" - } - } - - // Try to peek last total files for progress bar, even if cache is stale - if !isOverview { - if total, err := peekCacheTotalFiles(path); err == nil && total > 0 { - m.lastTotalFiles = total - } - } - - return m +type scanErrorMsg struct { + err error } -func createOverviewEntries() []dirEntry { - home := os.Getenv("HOME") - entries := []dirEntry{} - - // Separate Home and ~/Library to avoid double counting. - if home != "" { - entries = append(entries, dirEntry{Name: "Home", Path: home, IsDir: true, Size: -1}) - - userLibrary := filepath.Join(home, "Library") - if _, err := os.Stat(userLibrary); err == nil { - entries = append(entries, dirEntry{Name: "App Library", Path: userLibrary, IsDir: true, Size: -1}) - } - } - - entries = append(entries, - dirEntry{Name: "Applications", Path: "/Applications", IsDir: true, Size: -1}, - dirEntry{Name: "System Library", Path: "/Library", IsDir: true, Size: -1}, - ) - - // Include Volumes only when real mounts exist. - if hasUsefulVolumeMounts("/Volumes") { - entries = append(entries, dirEntry{Name: "Volumes", Path: "/Volumes", IsDir: true, Size: -1}) - } - - return entries +type deleteCompleteMsg struct { + path string + err error } -func hasUsefulVolumeMounts(path string) bool { - entries, err := os.ReadDir(path) - if err != nil { - return false +func newModel(startPath string) model { + return model{ + path: startPath, + entries: []dirEntry{}, + largeFiles: []fileEntry{}, + history: []historyEntry{}, + selected: 0, + scanning: true, + multiSelected: make(map[string]bool), + cache: make(map[string]historyEntry), } - - for _, entry := range entries { - name := entry.Name() - if strings.HasPrefix(name, ".") { - continue - } - - info, err := os.Lstat(filepath.Join(path, name)) - if err != nil { - continue - } - if info.Mode()&fs.ModeSymlink != 0 { - continue // Ignore the synthetic MacintoshHD link - } - if info.IsDir() { - return true - } - } - return false -} - -func (m *model) hydrateOverviewEntries() { - m.entries = createOverviewEntries() - if m.overviewSizeCache == nil { - m.overviewSizeCache = make(map[string]int64) - } - for i := range m.entries { - if size, ok := m.overviewSizeCache[m.entries[i].Path]; ok { - m.entries[i].Size = size - continue - } - if size, err := loadOverviewCachedSize(m.entries[i].Path); err == nil { - m.entries[i].Size = size - m.overviewSizeCache[m.entries[i].Path] = size - } - } - m.totalSize = sumKnownEntrySizes(m.entries) -} - -func (m *model) sortOverviewEntriesBySize() { - // Stable sort by size. - sort.SliceStable(m.entries, func(i, j int) bool { - return m.entries[i].Size > m.entries[j].Size - }) -} - -func (m *model) scheduleOverviewScans() tea.Cmd { - if !m.inOverviewMode() { - return nil - } - - var pendingIndices []int - for i, entry := range m.entries { - if entry.Size < 0 && !m.overviewScanningSet[entry.Path] { - pendingIndices = append(pendingIndices, i) - if len(pendingIndices) >= maxConcurrentOverview { - break - } - } - } - - if len(pendingIndices) == 0 { - m.overviewScanning = false - if !hasPendingOverviewEntries(m.entries) { - m.sortOverviewEntriesBySize() - m.status = "Ready" - } - return nil - } - - var cmds []tea.Cmd - for _, idx := range pendingIndices { - entry := m.entries[idx] - m.overviewScanningSet[entry.Path] = true - cmd := scanOverviewPathCmd(entry.Path, idx) - cmds = append(cmds, cmd) - } - - m.overviewScanning = true - remaining := 0 - for _, e := range m.entries { - if e.Size < 0 { - remaining++ - } - } - if len(pendingIndices) > 0 { - firstEntry := m.entries[pendingIndices[0]] - if len(pendingIndices) == 1 { - m.status = fmt.Sprintf("Scanning %s... (%d left)", firstEntry.Name, remaining) - } else { - m.status = fmt.Sprintf("Scanning %d directories... (%d left)", len(pendingIndices), remaining) - } - } - - cmds = append(cmds, tickCmd()) - return tea.Batch(cmds...) -} - -func (m *model) getScanProgress() (files, dirs, bytes int64) { - if m.filesScanned != nil { - files = atomic.LoadInt64(m.filesScanned) - } - if m.dirsScanned != nil { - dirs = atomic.LoadInt64(m.dirsScanned) - } - if m.bytesScanned != nil { - bytes = atomic.LoadInt64(m.bytesScanned) - } - return } func (m model) Init() tea.Cmd { - if m.inOverviewMode() { - return m.scheduleOverviewScans() - } - return tea.Batch(m.scanCmd(m.path), tickCmd()) -} - -func (m model) scanCmd(path string) tea.Cmd { - return func() tea.Msg { - if cached, err := loadCacheFromDisk(path); err == nil { - result := scanResult{ - Entries: cached.Entries, - LargeFiles: cached.LargeFiles, - TotalSize: cached.TotalSize, - TotalFiles: 0, // Cache doesn't store file count currently, minor UI limitation - } - return scanResultMsg{result: result, err: nil} - } - - v, err, _ := scanGroup.Do(path, func() (any, error) { - return scanPathConcurrent(path, m.filesScanned, m.dirsScanned, m.bytesScanned, m.currentPath) - }) - - if err != nil { - return scanResultMsg{err: err} - } - - result := v.(scanResult) - - go func(p string, r scanResult) { - if err := saveCacheToDisk(p, r); err != nil { - _ = err // Cache save failure is not critical - } - }(path, result) - - return scanResultMsg{result: result, err: nil} - } -} - -func tickCmd() tea.Cmd { - return tea.Tick(time.Millisecond*80, func(t time.Time) tea.Msg { - return tickMsg(t) - }) + return m.scanPath(m.path) } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: - return m.updateKey(msg) + return m.handleKeyPress(msg) case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height return m, nil - case deleteProgressMsg: - if msg.done { - m.deleting = false - m.multiSelected = make(map[string]bool) - m.largeMultiSelected = make(map[string]bool) - if msg.err != nil { - m.status = fmt.Sprintf("Failed to delete: %v", msg.err) - } else { - if msg.path != "" { - m.removePathFromView(msg.path) - invalidateCache(msg.path) - } - invalidateCache(m.path) - m.status = fmt.Sprintf("Deleted %d items", msg.count) - for i := range m.history { - m.history[i].Dirty = true - } - for path := range m.cache { - entry := m.cache[path] - entry.Dirty = true - m.cache[path] = entry - } - m.scanning = true - atomic.StoreInt64(m.filesScanned, 0) - atomic.StoreInt64(m.dirsScanned, 0) - atomic.StoreInt64(m.bytesScanned, 0) - if m.currentPath != nil { - *m.currentPath = "" - } - return m, tea.Batch(m.scanCmd(m.path), tickCmd()) - } - } - return m, nil - case scanResultMsg: + case scanCompleteMsg: + m.entries = msg.entries + m.largeFiles = msg.largeFiles + m.totalSize = msg.totalSize m.scanning = false + m.selected = 0 + // Cache result + m.cache[m.path] = historyEntry{ + Path: m.path, + Entries: msg.entries, + LargeFiles: msg.largeFiles, + TotalSize: msg.totalSize, + } + return m, nil + case scanProgressMsg: + m.scanProgress = msg.current + m.scanTotal = msg.total + return m, nil + case scanErrorMsg: + m.err = msg.err + m.scanning = false + return m, nil + case deleteCompleteMsg: + m.deleteConfirm = false + m.deleteTarget = "" if msg.err != nil { - m.status = fmt.Sprintf("Scan failed: %v", msg.err) - return m, nil + m.err = msg.err + } else { + // Rescan after delete + m.scanning = true + delete(m.cache, m.path) + return m, m.scanPath(m.path) } - filteredEntries := make([]dirEntry, 0, len(msg.result.Entries)) - for _, e := range msg.result.Entries { - if e.Size > 0 { - filteredEntries = append(filteredEntries, e) - } - } - m.entries = filteredEntries - m.largeFiles = msg.result.LargeFiles - m.totalSize = msg.result.TotalSize - m.totalFiles = msg.result.TotalFiles - m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) - m.clampEntrySelection() - m.clampLargeSelection() - m.cache[m.path] = cacheSnapshot(m) - if m.totalSize > 0 { - if m.overviewSizeCache == nil { - m.overviewSizeCache = make(map[string]int64) - } - m.overviewSizeCache[m.path] = m.totalSize - go func(path string, size int64) { - _ = storeOverviewSize(path, size) - }(m.path, m.totalSize) - } - return m, nil - case overviewSizeMsg: - delete(m.overviewScanningSet, msg.Path) - - if msg.Err == nil { - if m.overviewSizeCache == nil { - m.overviewSizeCache = make(map[string]int64) - } - m.overviewSizeCache[msg.Path] = msg.Size - } - - if m.inOverviewMode() { - for i := range m.entries { - if m.entries[i].Path == msg.Path { - if msg.Err == nil { - m.entries[i].Size = msg.Size - } else { - m.entries[i].Size = 0 - } - break - } - } - m.totalSize = sumKnownEntrySizes(m.entries) - - if msg.Err != nil { - m.status = fmt.Sprintf("Unable to measure %s: %v", displayPath(msg.Path), msg.Err) - } - - cmd := m.scheduleOverviewScans() - return m, cmd - } - return m, nil - case tickMsg: - hasPending := false - if m.inOverviewMode() { - for _, entry := range m.entries { - if entry.Size < 0 { - hasPending = true - break - } - } - } - if m.scanning || m.deleting || (m.inOverviewMode() && (m.overviewScanning || hasPending)) { - m.spinner = (m.spinner + 1) % len(spinnerFrames) - if m.deleting && m.deleteCount != nil { - count := atomic.LoadInt64(m.deleteCount) - if count > 0 { - m.status = fmt.Sprintf("Moving to Trash... %s items", formatNumber(count)) - } - } - return m, tickCmd() - } - return m, nil - default: return m, nil } + return m, nil } -func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - // Delete confirm flow. +func (m model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // Handle delete confirmation if m.deleteConfirm { switch msg.String() { - case "enter": + case "y", "Y": + target := m.deleteTarget m.deleteConfirm = false - m.deleting = true - var deleteCount int64 - m.deleteCount = &deleteCount - - // Collect paths (safer than indices). - var pathsToDelete []string - if m.showLargeFiles { - if len(m.largeMultiSelected) > 0 { - for path := range m.largeMultiSelected { - pathsToDelete = append(pathsToDelete, path) - } - } else if m.deleteTarget != nil { - pathsToDelete = append(pathsToDelete, m.deleteTarget.Path) - } - } else { - if len(m.multiSelected) > 0 { - for path := range m.multiSelected { - pathsToDelete = append(pathsToDelete, path) - } - } else if m.deleteTarget != nil { - pathsToDelete = append(pathsToDelete, m.deleteTarget.Path) - } - } - - m.deleteTarget = nil - if len(pathsToDelete) == 0 { - m.deleting = false - m.status = "Nothing to delete" - return m, nil - } - - if len(pathsToDelete) == 1 { - targetPath := pathsToDelete[0] - m.status = fmt.Sprintf("Deleting %s...", filepath.Base(targetPath)) - return m, tea.Batch(deletePathCmd(targetPath, m.deleteCount), tickCmd()) - } - - m.status = fmt.Sprintf("Deleting %d items...", len(pathsToDelete)) - return m, tea.Batch(deleteMultiplePathsCmd(pathsToDelete, m.deleteCount), tickCmd()) - case "esc", "q": - m.status = "Cancelled" + return m, m.deletePath(target) + case "n", "N", "esc": m.deleteConfirm = false - m.deleteTarget = nil - return m, nil - default: + m.deleteTarget = "" return m, nil } + return m, nil } switch msg.String() { case "q", "ctrl+c": return m, tea.Quit - case "esc": - if m.showLargeFiles { - m.showLargeFiles = false - return m, nil - } - return m, tea.Quit case "up", "k": - if m.showLargeFiles { - if m.largeSelected > 0 { - m.largeSelected-- - if m.largeSelected < m.largeOffset { - m.largeOffset = m.largeSelected - } - } - } else if len(m.entries) > 0 && m.selected > 0 { + if m.selected > 0 { m.selected-- - if m.selected < m.offset { - m.offset = m.selected - } } case "down", "j": - if m.showLargeFiles { - if m.largeSelected < len(m.largeFiles)-1 { - m.largeSelected++ - viewport := calculateViewport(m.height, true) - if m.largeSelected >= m.largeOffset+viewport { - m.largeOffset = m.largeSelected - viewport + 1 - } - } - } else if len(m.entries) > 0 && m.selected < len(m.entries)-1 { + if m.selected < len(m.entries)-1 { m.selected++ - viewport := calculateViewport(m.height, false) - if m.selected >= m.offset+viewport { - m.offset = m.selected - viewport + 1 - } } case "enter", "right", "l": - if m.showLargeFiles { - return m, nil - } - return m.enterSelectedDir() - case "b", "left", "h": - if m.showLargeFiles { - m.showLargeFiles = false - return m, nil - } - if len(m.history) == 0 { - if !m.inOverviewMode() { - return m, m.switchToOverviewMode() - } - return m, nil - } - last := m.history[len(m.history)-1] - m.history = m.history[:len(m.history)-1] - m.path = last.Path - m.selected = last.Selected - m.offset = last.EntryOffset - m.largeSelected = last.LargeSelected - m.largeOffset = last.LargeOffset - m.isOverview = last.IsOverview - if last.Dirty { - // On overview return, refresh cached entries. - if last.IsOverview { - m.hydrateOverviewEntries() - m.totalSize = sumKnownEntrySizes(m.entries) - m.status = "Ready" - m.scanning = false - if nextPendingOverviewIndex(m.entries) >= 0 { - m.overviewScanning = true - return m, m.scheduleOverviewScans() - } - return m, nil - } - m.status = "Scanning..." - m.scanning = true - return m, tea.Batch(m.scanCmd(m.path), tickCmd()) - } - m.entries = last.Entries - m.largeFiles = last.LargeFiles - m.totalSize = last.TotalSize - m.clampEntrySelection() - m.clampLargeSelection() - if len(m.entries) == 0 { - m.selected = 0 - } else if m.selected >= len(m.entries) { - m.selected = len(m.entries) - 1 - } - if m.selected < 0 { - m.selected = 0 - } - m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) - m.scanning = false - return m, nil - case "r": - m.multiSelected = make(map[string]bool) - m.largeMultiSelected = make(map[string]bool) - - if m.inOverviewMode() { - m.overviewSizeCache = make(map[string]int64) - m.overviewScanningSet = make(map[string]bool) - m.hydrateOverviewEntries() // Reset sizes to pending - - for i := range m.entries { - m.entries[i].Size = -1 - } - m.totalSize = 0 - - m.status = "Refreshing..." - m.overviewScanning = true - return m, tea.Batch(m.scheduleOverviewScans(), tickCmd()) - } - - invalidateCache(m.path) - m.status = "Refreshing..." - m.scanning = true - if m.totalFiles > 0 { - m.lastTotalFiles = m.totalFiles - } - atomic.StoreInt64(m.filesScanned, 0) - atomic.StoreInt64(m.dirsScanned, 0) - atomic.StoreInt64(m.bytesScanned, 0) - if m.currentPath != nil { - *m.currentPath = "" - } - return m, tea.Batch(m.scanCmd(m.path), tickCmd()) - case "t", "T": - if !m.inOverviewMode() { - m.showLargeFiles = !m.showLargeFiles - if m.showLargeFiles { - m.largeSelected = 0 - m.largeOffset = 0 - m.largeMultiSelected = make(map[string]bool) - } else { + if !m.scanning && len(m.entries) > 0 { + entry := m.entries[m.selected] + if entry.IsDir { + // Save current state to history + m.history = append(m.history, historyEntry{ + Path: m.path, + Entries: m.entries, + LargeFiles: m.largeFiles, + TotalSize: m.totalSize, + Selected: m.selected, + }) + m.path = entry.Path + m.selected = 0 m.multiSelected = make(map[string]bool) - } - m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) - } - case "o": - // Open selected entries (multi-select aware). - const maxBatchOpen = 20 - if m.showLargeFiles { - if len(m.largeFiles) > 0 { - 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) - return m, nil - } - for path := range m.largeMultiSelected { - go func(p string) { - ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) - defer cancel() - _ = exec.CommandContext(ctx, "open", p).Run() - }(path) - } - m.status = fmt.Sprintf("Opening %d items...", count) - } else { - selected := m.largeFiles[m.largeSelected] - go func(path string) { - ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) - defer cancel() - _ = exec.CommandContext(ctx, "open", path).Run() - }(selected.Path) - m.status = fmt.Sprintf("Opening %s...", selected.Name) - } - } - } else if len(m.entries) > 0 { - 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) + + // Check cache + if cached, ok := m.cache[entry.Path]; ok { + m.entries = cached.Entries + m.largeFiles = cached.LargeFiles + m.totalSize = cached.TotalSize return m, nil } - for path := range m.multiSelected { - go func(p string) { - ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) - defer cancel() - _ = exec.CommandContext(ctx, "open", p).Run() - }(path) - } - m.status = fmt.Sprintf("Opening %d items...", count) - } else { - selected := m.entries[m.selected] - go func(path string) { - ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) - defer cancel() - _ = exec.CommandContext(ctx, "open", path).Run() - }(selected.Path) - m.status = fmt.Sprintf("Opening %s...", selected.Name) - } - } - case "f", "F": - // Reveal in Finder (multi-select aware). - const maxBatchReveal = 20 - if m.showLargeFiles { - if len(m.largeFiles) > 0 { - 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) - return m, nil - } - for path := range m.largeMultiSelected { - go func(p string) { - ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) - defer cancel() - _ = exec.CommandContext(ctx, "open", "-R", p).Run() - }(path) - } - m.status = fmt.Sprintf("Showing %d items in Finder...", count) - } else { - selected := m.largeFiles[m.largeSelected] - go func(path string) { - ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) - defer cancel() - _ = exec.CommandContext(ctx, "open", "-R", path).Run() - }(selected.Path) - m.status = fmt.Sprintf("Showing %s in Finder...", selected.Name) - } - } - } else if len(m.entries) > 0 { - 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) - return m, nil - } - for path := range m.multiSelected { - go func(p string) { - ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) - defer cancel() - _ = exec.CommandContext(ctx, "open", "-R", p).Run() - }(path) - } - m.status = fmt.Sprintf("Showing %d items in Finder...", count) - } else { - selected := m.entries[m.selected] - go func(path string) { - ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) - defer cancel() - _ = exec.CommandContext(ctx, "open", "-R", path).Run() - }(selected.Path) - m.status = fmt.Sprintf("Showing %s in Finder...", selected.Name) - } - } - case " ": - // Toggle multi-select (paths as keys). - if m.showLargeFiles { - if len(m.largeFiles) > 0 && m.largeSelected < len(m.largeFiles) { - if m.largeMultiSelected == nil { - m.largeMultiSelected = make(map[string]bool) - } - selectedPath := m.largeFiles[m.largeSelected].Path - if m.largeMultiSelected[selectedPath] { - delete(m.largeMultiSelected, selectedPath) - } else { - m.largeMultiSelected[selectedPath] = true - } - count := len(m.largeMultiSelected) - if count > 0 { - var totalSize int64 - for path := range m.largeMultiSelected { - for _, file := range m.largeFiles { - if file.Path == path { - totalSize += file.Size - break - } - } - } - m.status = fmt.Sprintf("%d selected (%s)", count, humanizeBytes(totalSize)) - } else { - m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) - } - } - } else if len(m.entries) > 0 && !m.inOverviewMode() && m.selected < len(m.entries) { - if m.multiSelected == nil { - m.multiSelected = make(map[string]bool) - } - selectedPath := m.entries[m.selected].Path - if m.multiSelected[selectedPath] { - delete(m.multiSelected, selectedPath) - } else { - m.multiSelected[selectedPath] = true - } - count := len(m.multiSelected) - if count > 0 { - var totalSize int64 - for path := range m.multiSelected { - for _, entry := range m.entries { - if entry.Path == path { - totalSize += entry.Size - break - } - } - } - m.status = fmt.Sprintf("%d selected (%s)", count, humanizeBytes(totalSize)) - } else { - m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) - } - } - case "delete", "backspace": - if m.showLargeFiles { - if len(m.largeFiles) > 0 { - if len(m.largeMultiSelected) > 0 { - m.deleteConfirm = true - for path := range m.largeMultiSelected { - for _, file := range m.largeFiles { - if file.Path == path { - m.deleteTarget = &dirEntry{ - Name: file.Name, - Path: file.Path, - Size: file.Size, - IsDir: false, - } - break - } - } - break // Only need first one for display - } - } else if m.largeSelected < len(m.largeFiles) { - selected := m.largeFiles[m.largeSelected] - m.deleteConfirm = true - m.deleteTarget = &dirEntry{ - Name: selected.Name, - Path: selected.Path, - Size: selected.Size, - IsDir: false, - } - } - } - } else if len(m.entries) > 0 && !m.inOverviewMode() { - if len(m.multiSelected) > 0 { - m.deleteConfirm = true - for path := range m.multiSelected { - // Resolve entry by path. - for i := range m.entries { - if m.entries[i].Path == path { - m.deleteTarget = &m.entries[i] - break - } - } - break // Only need first one for display - } - } else if m.selected < len(m.entries) { - selected := m.entries[m.selected] - m.deleteConfirm = true - m.deleteTarget = &selected - } - } - } - return m, nil -} -func (m *model) switchToOverviewMode() tea.Cmd { - m.isOverview = true - m.path = "/" - m.scanning = false - m.showLargeFiles = false - m.largeFiles = nil - m.largeSelected = 0 - m.largeOffset = 0 - m.deleteConfirm = false - m.deleteTarget = nil - m.selected = 0 - m.offset = 0 - m.hydrateOverviewEntries() - cmd := m.scheduleOverviewScans() - if cmd == nil { - m.status = "Ready" - return nil - } - return tea.Batch(cmd, tickCmd()) -} - -func (m model) enterSelectedDir() (tea.Model, tea.Cmd) { - if len(m.entries) == 0 { - return m, nil - } - selected := m.entries[m.selected] - if selected.IsDir { - m.history = append(m.history, snapshotFromModel(m)) - m.path = selected.Path - m.selected = 0 - m.offset = 0 - m.status = "Scanning..." - m.scanning = true - m.isOverview = false - m.multiSelected = make(map[string]bool) - m.largeMultiSelected = make(map[string]bool) - - atomic.StoreInt64(m.filesScanned, 0) - atomic.StoreInt64(m.dirsScanned, 0) - atomic.StoreInt64(m.bytesScanned, 0) - if m.currentPath != nil { - *m.currentPath = "" + m.scanning = true + return m, m.scanPath(entry.Path) + } } - - if cached, ok := m.cache[m.path]; ok && !cached.Dirty { - m.entries = cloneDirEntries(cached.Entries) - m.largeFiles = cloneFileEntries(cached.LargeFiles) - m.totalSize = cached.TotalSize - m.totalFiles = cached.TotalFiles - m.selected = cached.Selected - m.offset = cached.EntryOffset - m.largeSelected = cached.LargeSelected - m.largeOffset = cached.LargeOffset - m.clampEntrySelection() - m.clampLargeSelection() - m.status = fmt.Sprintf("Cached view for %s", displayPath(m.path)) + case "left", "h", "backspace": + if len(m.history) > 0 { + last := m.history[len(m.history)-1] + m.history = m.history[:len(m.history)-1] + m.path = last.Path + m.entries = last.Entries + m.largeFiles = last.LargeFiles + m.totalSize = last.TotalSize + m.selected = last.Selected + m.multiSelected = make(map[string]bool) m.scanning = false - return m, nil } - m.lastTotalFiles = 0 - if total, err := peekCacheTotalFiles(m.path); err == nil && total > 0 { - m.lastTotalFiles = total + case "space": + if len(m.entries) > 0 { + entry := m.entries[m.selected] + if m.multiSelected[entry.Path] { + delete(m.multiSelected, entry.Path) + } else { + m.multiSelected[entry.Path] = true + } } - return m, tea.Batch(m.scanCmd(m.path), tickCmd()) - } - m.status = fmt.Sprintf("File: %s (%s)", selected.Name, humanizeBytes(selected.Size)) - return m, nil -} - -func (m *model) clampEntrySelection() { - if len(m.entries) == 0 { + case "d", "delete": + if len(m.entries) > 0 { + entry := m.entries[m.selected] + m.deleteConfirm = true + m.deleteTarget = entry.Path + } + case "D": + // Delete all selected + if len(m.multiSelected) > 0 { + m.deleteConfirm = true + m.deleteTarget = fmt.Sprintf("%d items", len(m.multiSelected)) + } + case "f": + m.showLargeFiles = !m.showLargeFiles + case "r": + // Refresh + delete(m.cache, m.path) + m.scanning = true + return m, m.scanPath(m.path) + case "o": + // Open in Explorer + if len(m.entries) > 0 { + entry := m.entries[m.selected] + openInExplorer(entry.Path) + } + case "g": m.selected = 0 - m.offset = 0 - return - } - if m.selected >= len(m.entries) { + case "G": m.selected = len(m.entries) - 1 } - if m.selected < 0 { - m.selected = 0 - } - viewport := calculateViewport(m.height, false) - maxOffset := max(len(m.entries)-viewport, 0) - if m.offset > maxOffset { - m.offset = maxOffset - } - if m.selected < m.offset { - m.offset = m.selected - } - if m.selected >= m.offset+viewport { - m.offset = m.selected - viewport + 1 - } + return m, nil } -func (m *model) clampLargeSelection() { - if len(m.largeFiles) == 0 { - m.largeSelected = 0 - m.largeOffset = 0 - return - } - if m.largeSelected >= len(m.largeFiles) { - m.largeSelected = len(m.largeFiles) - 1 - } - if m.largeSelected < 0 { - m.largeSelected = 0 - } - viewport := calculateViewport(m.height, true) - maxOffset := max(len(m.largeFiles)-viewport, 0) - if m.largeOffset > maxOffset { - m.largeOffset = maxOffset - } - if m.largeSelected < m.largeOffset { - m.largeOffset = m.largeSelected - } - if m.largeSelected >= m.largeOffset+viewport { - m.largeOffset = m.largeSelected - viewport + 1 - } -} +func (m model) View() string { + var b strings.Builder -func sumKnownEntrySizes(entries []dirEntry) int64 { - var total int64 - for _, entry := range entries { - if entry.Size > 0 { - total += entry.Size + // Header + b.WriteString(fmt.Sprintf("%s%s Mole Disk Analyzer %s\n", colorPurpleBold, iconDisk, colorReset)) + b.WriteString(fmt.Sprintf("%s%s%s\n", colorGray, m.path, colorReset)) + b.WriteString("\n") + + // Show delete confirmation + if m.deleteConfirm { + b.WriteString(fmt.Sprintf("%s%s Delete %s? (y/n)%s\n", colorRed, iconTrash, m.deleteTarget, colorReset)) + return b.String() + } + + // Scanning indicator + if m.scanning { + b.WriteString(fmt.Sprintf("%s⠋ Scanning...%s\n", colorCyan, colorReset)) + if m.scanTotal > 0 { + b.WriteString(fmt.Sprintf("%s %d / %d items%s\n", colorGray, m.scanProgress, m.scanTotal, colorReset)) } - } - return total -} - -func nextPendingOverviewIndex(entries []dirEntry) int { - for i, entry := range entries { - if entry.Size < 0 { - return i - } - } - return -1 -} - -func hasPendingOverviewEntries(entries []dirEntry) bool { - for _, entry := range entries { - if entry.Size < 0 { - return true - } - } - return false -} - -func (m *model) removePathFromView(path string) { - if path == "" { - return + return b.String() } - var removedSize int64 - for i, entry := range m.entries { - if entry.Path == path { - if entry.Size > 0 { - removedSize = entry.Size + // Error display + if m.err != nil { + b.WriteString(fmt.Sprintf("%sError: %v%s\n", colorRed, m.err, colorReset)) + b.WriteString("\n") + } + + // Total size + b.WriteString(fmt.Sprintf(" Total: %s%s%s\n", colorYellow, formatBytes(m.totalSize), colorReset)) + b.WriteString("\n") + + // Large files toggle + if m.showLargeFiles && len(m.largeFiles) > 0 { + b.WriteString(fmt.Sprintf("%s%s Large Files (>100MB):%s\n", colorCyanBold, iconFile, colorReset)) + for i, f := range m.largeFiles { + if i >= 10 { + b.WriteString(fmt.Sprintf(" %s... and %d more%s\n", colorGray, len(m.largeFiles)-10, colorReset)) + break } - m.entries = append(m.entries[:i], m.entries[i+1:]...) - break + b.WriteString(fmt.Sprintf(" %s%s%s %s\n", colorYellow, formatBytes(f.Size), colorReset, truncatePath(f.Path, 60))) } + b.WriteString("\n") } - for i := 0; i < len(m.largeFiles); i++ { - if m.largeFiles[i].Path == path { - m.largeFiles = append(m.largeFiles[:i], m.largeFiles[i+1:]...) - break - } + // Directory entries + visibleEntries := m.height - 12 + if visibleEntries < 5 { + visibleEntries = 20 } - if removedSize > 0 { - if removedSize > m.totalSize { - m.totalSize = 0 - } else { - m.totalSize -= removedSize - } - m.clampEntrySelection() + start := 0 + if m.selected >= visibleEntries { + start = m.selected - visibleEntries + 1 } - m.clampLargeSelection() + + for i := start; i < len(m.entries) && i < start+visibleEntries; i++ { + entry := m.entries[i] + prefix := " " + + // Selection indicator + if i == m.selected { + prefix = fmt.Sprintf("%s%s%s ", colorCyan, iconArrow, colorReset) + } else if m.multiSelected[entry.Path] { + prefix = fmt.Sprintf("%s%s%s ", colorGreen, iconSelected, colorReset) + } + + // Icon + icon := iconFile + if entry.IsDir { + icon = iconFolder + } + if entry.IsCleanable { + icon = iconClean + } + + // Size and percentage + pct := float64(0) + if m.totalSize > 0 { + pct = float64(entry.Size) / float64(m.totalSize) * 100 + } + + // Bar + barWidth := 20 + filled := int(pct / 100 * float64(barWidth)) + bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled) + + // Color based on selection + nameColor := colorReset + if i == m.selected { + nameColor = colorCyanBold + } + + b.WriteString(fmt.Sprintf("%s%s %s%8s%s %s%s%s %s%.1f%%%s %s\n", + prefix, + icon, + colorYellow, formatBytes(entry.Size), colorReset, + colorGray, bar, colorReset, + colorDim, pct, colorReset, + nameColor+entry.Name+colorReset, + )) + } + + // Footer with keybindings + b.WriteString("\n") + b.WriteString(fmt.Sprintf("%s↑↓%s navigate %s↵%s enter %s←%s back %sf%s files %sd%s delete %sr%s refresh %sq%s quit%s\n", + colorCyan, colorReset, + colorCyan, colorReset, + colorCyan, colorReset, + colorCyan, colorReset, + colorCyan, colorReset, + colorCyan, colorReset, + colorCyan, colorReset, + colorReset, + )) + + return b.String() } -func scanOverviewPathCmd(path string, index int) tea.Cmd { +// scanPath scans a directory and returns entries +func (m model) scanPath(path string) tea.Cmd { return func() tea.Msg { - size, err := measureOverviewSize(path) - return overviewSizeMsg{ - Path: path, - Index: index, - Size: size, - Err: err, + entries, largeFiles, totalSize, err := scanDirectory(path) + if err != nil { + return scanErrorMsg{err: err} + } + return scanCompleteMsg{ + entries: entries, + largeFiles: largeFiles, + totalSize: totalSize, } } } + +// deletePath deletes a file or directory with protection checks +func (m model) deletePath(path string) tea.Cmd { + return func() tea.Msg { + // Safety check: never delete protected paths + if isProtectedPath(path) { + return deleteCompleteMsg{ + path: path, + err: fmt.Errorf("cannot delete protected system path: %s", path), + } + } + + err := os.RemoveAll(path) + return deleteCompleteMsg{path: path, err: err} + } +} + +// scanDirectory scans a directory concurrently +func scanDirectory(path string) ([]dirEntry, []fileEntry, int64, error) { + entries, err := os.ReadDir(path) + if err != nil { + return nil, nil, 0, err + } + + var ( + dirEntries []dirEntry + largeFiles []fileEntry + totalSize int64 + mu sync.Mutex + wg sync.WaitGroup + ) + + numWorkers := runtime.NumCPU() * 2 + if numWorkers > 32 { + numWorkers = 32 + } + + sem := make(chan struct{}, numWorkers) + var processedCount int64 + + for _, entry := range entries { + name := entry.Name() + entryPath := filepath.Join(path, name) + + // Skip system directories + if skipPatterns[name] { + continue + } + + wg.Add(1) + sem <- struct{}{} + + go func(name, entryPath string, isDir bool) { + defer wg.Done() + defer func() { <-sem }() + + var size int64 + var lastAccess time.Time + var isCleanable bool + + if isDir { + size = calculateDirSize(entryPath) + isCleanable = cleanablePatterns[name] + } else { + info, err := os.Stat(entryPath) + if err == nil { + size = info.Size() + lastAccess = info.ModTime() + } + } + + mu.Lock() + defer mu.Unlock() + + dirEntries = append(dirEntries, dirEntry{ + Name: name, + Path: entryPath, + Size: size, + IsDir: isDir, + LastAccess: lastAccess, + IsCleanable: isCleanable, + }) + + totalSize += size + + // Track large files + if !isDir && size >= 100*1024*1024 { + largeFiles = append(largeFiles, fileEntry{ + Name: name, + Path: entryPath, + Size: size, + }) + } + + atomic.AddInt64(&processedCount, 1) + }(name, entryPath, entry.IsDir()) + } + + wg.Wait() + + // Sort by size descending + sort.Slice(dirEntries, func(i, j int) bool { + return dirEntries[i].Size > dirEntries[j].Size + }) + + sort.Slice(largeFiles, func(i, j int) bool { + return largeFiles[i].Size > largeFiles[j].Size + }) + + return dirEntries, largeFiles, totalSize, nil +} + +// calculateDirSize calculates the size of a directory with timeout and limits +// Uses shallow scanning for speed - estimates based on first few levels +func calculateDirSize(path string) int64 { + ctx, cancel := context.WithTimeout(context.Background(), dirSizeTimeout) + defer cancel() + + var size int64 + var fileCount int64 + + // Use a channel to signal completion + done := make(chan struct{}) + + go func() { + defer close(done) + quickScanDir(ctx, path, 0, &size, &fileCount) + }() + + select { + case <-done: + // Completed normally + case <-ctx.Done(): + // Timeout - return partial size (already accumulated) + } + + return size +} + +// quickScanDir does a fast shallow scan for size estimation +func quickScanDir(ctx context.Context, path string, depth int, size *int64, fileCount *int64) { + // Check context cancellation + select { + case <-ctx.Done(): + return + default: + } + + // Limit depth for speed + if depth > shallowScanDepth { + return + } + + // Limit total files scanned + if atomic.LoadInt64(fileCount) > maxFilesPerDir { + return + } + + entries, err := os.ReadDir(path) + if err != nil { + return + } + + for _, entry := range entries { + // Check cancellation + select { + case <-ctx.Done(): + return + default: + } + + if atomic.LoadInt64(fileCount) > maxFilesPerDir { + return + } + + entryPath := filepath.Join(path, entry.Name()) + + if entry.IsDir() { + name := entry.Name() + // Skip hidden and system directories + if skipPatterns[name] || (strings.HasPrefix(name, ".") && len(name) > 1) { + continue + } + quickScanDir(ctx, entryPath, depth+1, size, fileCount) + } else { + info, err := entry.Info() + if err == nil { + atomic.AddInt64(size, info.Size()) + atomic.AddInt64(fileCount, 1) + } + } + } +} + +// formatBytes formats bytes to human readable string +func formatBytes(bytes int64) string { + const unit = 1024 + if bytes < unit { + return fmt.Sprintf("%d B", bytes) + } + div, exp := int64(unit), 0 + for n := bytes / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) +} + +// truncatePath truncates a path to fit in maxLen +func truncatePath(path string, maxLen int) string { + if len(path) <= maxLen { + return path + } + return "..." + path[len(path)-maxLen+3:] +} + +// openInExplorer opens a path in Windows Explorer +func openInExplorer(path string) { + // Use explorer.exe to open the path + go func() { + exec.Command("explorer.exe", "/select,", path).Run() + }() +} + +func main() { + var startPath string + + flag.StringVar(&startPath, "path", "", "Path to analyze") + flag.Parse() + + // Check environment variable + if startPath == "" { + startPath = os.Getenv("MO_ANALYZE_PATH") + } + + // Use command line argument + if startPath == "" && len(flag.Args()) > 0 { + startPath = flag.Args()[0] + } + + // Default to user profile + if startPath == "" { + startPath = os.Getenv("USERPROFILE") + } + + // Resolve to absolute path + absPath, err := filepath.Abs(startPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + // Check if path exists + if _, err := os.Stat(absPath); os.IsNotExist(err) { + fmt.Fprintf(os.Stderr, "Error: Path does not exist: %s\n", absPath) + os.Exit(1) + } + + p := tea.NewProgram(newModel(absPath), tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} diff --git a/windows/cmd/analyze/main_test.go b/cmd/analyze/main_test.go similarity index 100% rename from windows/cmd/analyze/main_test.go rename to cmd/analyze/main_test.go diff --git a/cmd/analyze/scanner.go b/cmd/analyze/scanner.go deleted file mode 100644 index 0d7ddca..0000000 --- a/cmd/analyze/scanner.go +++ /dev/null @@ -1,663 +0,0 @@ -package main - -import ( - "bytes" - "container/heap" - "context" - "fmt" - "io/fs" - "os" - "os/exec" - "path/filepath" - "runtime" - "sort" - "strconv" - "strings" - "sync" - "sync/atomic" - "syscall" - "time" - - "golang.org/x/sync/singleflight" -) - -var scanGroup singleflight.Group - -func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *int64, currentPath *string) (scanResult, error) { - children, err := os.ReadDir(root) - if err != nil { - return scanResult{}, err - } - - var total int64 - - // Keep Top N heaps. - entriesHeap := &entryHeap{} - heap.Init(entriesHeap) - - largeFilesHeap := &largeFileHeap{} - heap.Init(largeFilesHeap) - - // Worker pool sized for I/O-bound scanning. - numWorkers := max(runtime.NumCPU()*cpuMultiplier, minWorkers) - if numWorkers > maxWorkers { - numWorkers = maxWorkers - } - if numWorkers > len(children) { - numWorkers = len(children) - } - if numWorkers < 1 { - numWorkers = 1 - } - sem := make(chan struct{}, numWorkers) - var wg sync.WaitGroup - - // Collect results via channels. - entryChan := make(chan dirEntry, len(children)) - largeFileChan := make(chan fileEntry, maxLargeFiles*2) - - var collectorWg sync.WaitGroup - collectorWg.Add(2) - go func() { - defer collectorWg.Done() - for entry := range entryChan { - if entriesHeap.Len() < maxEntries { - heap.Push(entriesHeap, entry) - } else if entry.Size > (*entriesHeap)[0].Size { - heap.Pop(entriesHeap) - heap.Push(entriesHeap, entry) - } - } - }() - go func() { - defer collectorWg.Done() - for file := range largeFileChan { - if largeFilesHeap.Len() < maxLargeFiles { - heap.Push(largeFilesHeap, file) - } else if file.Size > (*largeFilesHeap)[0].Size { - heap.Pop(largeFilesHeap) - heap.Push(largeFilesHeap, file) - } - } - }() - - isRootDir := root == "/" - home := os.Getenv("HOME") - isHomeDir := home != "" && root == home - - for _, child := range children { - fullPath := filepath.Join(root, child.Name()) - - // Skip symlinks to avoid following unexpected targets. - if child.Type()&fs.ModeSymlink != 0 { - targetInfo, err := os.Stat(fullPath) - isDir := false - if err == nil && targetInfo.IsDir() { - isDir = true - } - - // Count link size only to avoid double-counting targets. - info, err := child.Info() - if err != nil { - continue - } - size := getActualFileSize(fullPath, info) - atomic.AddInt64(&total, size) - - entryChan <- dirEntry{ - Name: child.Name() + " →", - Path: fullPath, - Size: size, - IsDir: isDir, - LastAccess: getLastAccessTimeFromInfo(info), - } - continue - } - - if child.IsDir() { - if defaultSkipDirs[child.Name()] { - continue - } - - // Skip system dirs at root. - if isRootDir && skipSystemDirs[child.Name()] { - continue - } - - // ~/Library is scanned separately; reuse cache when possible. - if isHomeDir && child.Name() == "Library" { - wg.Add(1) - go func(name, path string) { - defer wg.Done() - sem <- struct{}{} - defer func() { <-sem }() - - var size int64 - if cached, err := loadStoredOverviewSize(path); err == nil && cached > 0 { - size = cached - } else if cached, err := loadCacheFromDisk(path); err == nil { - size = cached.TotalSize - } else { - size = calculateDirSizeConcurrent(path, largeFileChan, filesScanned, dirsScanned, bytesScanned, currentPath) - } - atomic.AddInt64(&total, size) - atomic.AddInt64(dirsScanned, 1) - - entryChan <- dirEntry{ - Name: name, - Path: path, - Size: size, - IsDir: true, - LastAccess: time.Time{}, - } - }(child.Name(), fullPath) - continue - } - - // Folded dirs: fast size without expanding. - if shouldFoldDirWithPath(child.Name(), fullPath) { - wg.Add(1) - go func(name, path string) { - defer wg.Done() - sem <- struct{}{} - defer func() { <-sem }() - - size, err := getDirectorySizeFromDu(path) - if err != nil || size <= 0 { - size = calculateDirSizeFast(path, filesScanned, dirsScanned, bytesScanned, currentPath) - } - atomic.AddInt64(&total, size) - atomic.AddInt64(dirsScanned, 1) - - entryChan <- dirEntry{ - Name: name, - Path: path, - Size: size, - IsDir: true, - LastAccess: time.Time{}, - } - }(child.Name(), fullPath) - continue - } - - wg.Add(1) - go func(name, path string) { - defer wg.Done() - sem <- struct{}{} - defer func() { <-sem }() - - size := calculateDirSizeConcurrent(path, largeFileChan, filesScanned, dirsScanned, bytesScanned, currentPath) - atomic.AddInt64(&total, size) - atomic.AddInt64(dirsScanned, 1) - - entryChan <- dirEntry{ - Name: name, - Path: path, - Size: size, - IsDir: true, - LastAccess: time.Time{}, - } - }(child.Name(), fullPath) - continue - } - - info, err := child.Info() - if err != nil { - continue - } - // Actual disk usage for sparse/cloud files. - size := getActualFileSize(fullPath, info) - atomic.AddInt64(&total, size) - atomic.AddInt64(filesScanned, 1) - atomic.AddInt64(bytesScanned, size) - - entryChan <- dirEntry{ - Name: child.Name(), - Path: fullPath, - Size: size, - IsDir: false, - LastAccess: getLastAccessTimeFromInfo(info), - } - // Track large files only. - if !shouldSkipFileForLargeTracking(fullPath) && size >= minLargeFileSize { - largeFileChan <- fileEntry{Name: child.Name(), Path: fullPath, Size: size} - } - } - - wg.Wait() - - // Close channels and wait for collectors. - close(entryChan) - close(largeFileChan) - collectorWg.Wait() - - // Convert heaps to sorted slices (descending). - entries := make([]dirEntry, entriesHeap.Len()) - for i := len(entries) - 1; i >= 0; i-- { - entries[i] = heap.Pop(entriesHeap).(dirEntry) - } - - largeFiles := make([]fileEntry, largeFilesHeap.Len()) - for i := len(largeFiles) - 1; i >= 0; i-- { - largeFiles[i] = heap.Pop(largeFilesHeap).(fileEntry) - } - - // Use Spotlight for large files when available. - if spotlightFiles := findLargeFilesWithSpotlight(root, minLargeFileSize); len(spotlightFiles) > 0 { - largeFiles = spotlightFiles - } - - return scanResult{ - Entries: entries, - LargeFiles: largeFiles, - TotalSize: total, - TotalFiles: atomic.LoadInt64(filesScanned), - }, nil -} - -func shouldFoldDirWithPath(name, path string) bool { - if foldDirs[name] { - return true - } - - // Handle npm cache structure. - if strings.Contains(path, "/.npm/") || strings.Contains(path, "/.tnpm/") { - parent := filepath.Base(filepath.Dir(path)) - if parent == ".npm" || parent == ".tnpm" || strings.HasPrefix(parent, "_") { - return true - } - if len(name) == 1 { - return true - } - } - - return false -} - -func shouldSkipFileForLargeTracking(path string) bool { - ext := strings.ToLower(filepath.Ext(path)) - return skipExtensions[ext] -} - -// calculateDirSizeFast performs concurrent dir sizing using os.ReadDir. -func calculateDirSizeFast(root string, filesScanned, dirsScanned, bytesScanned *int64, currentPath *string) int64 { - var total int64 - var wg sync.WaitGroup - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) - defer cancel() - - concurrency := min(runtime.NumCPU()*4, 64) - sem := make(chan struct{}, concurrency) - - var walk func(string) - walk = func(dirPath string) { - select { - case <-ctx.Done(): - return - default: - } - - if currentPath != nil && atomic.LoadInt64(filesScanned)%int64(batchUpdateSize) == 0 { - *currentPath = dirPath - } - - entries, err := os.ReadDir(dirPath) - if err != nil { - return - } - - var localBytes, localFiles int64 - - for _, entry := range entries { - if entry.IsDir() { - wg.Add(1) - subDir := filepath.Join(dirPath, entry.Name()) - go func(p string) { - defer wg.Done() - sem <- struct{}{} - defer func() { <-sem }() - walk(p) - }(subDir) - atomic.AddInt64(dirsScanned, 1) - } else { - info, err := entry.Info() - if err == nil { - size := getActualFileSize(filepath.Join(dirPath, entry.Name()), info) - localBytes += size - localFiles++ - } - } - } - - if localBytes > 0 { - atomic.AddInt64(&total, localBytes) - atomic.AddInt64(bytesScanned, localBytes) - } - if localFiles > 0 { - atomic.AddInt64(filesScanned, localFiles) - } - } - - walk(root) - wg.Wait() - - return total -} - -// Use Spotlight (mdfind) to quickly find large files. -func findLargeFilesWithSpotlight(root string, minSize int64) []fileEntry { - query := fmt.Sprintf("kMDItemFSSize >= %d", minSize) - - ctx, cancel := context.WithTimeout(context.Background(), mdlsTimeout) - defer cancel() - - cmd := exec.CommandContext(ctx, "mdfind", "-onlyin", root, query) - output, err := cmd.Output() - if err != nil { - return nil - } - - var files []fileEntry - - for line := range strings.Lines(strings.TrimSpace(string(output))) { - if line == "" { - continue - } - - // Filter code files first (cheap). - if shouldSkipFileForLargeTracking(line) { - continue - } - - // Filter folded directories (cheap string check). - if isInFoldedDir(line) { - continue - } - - info, err := os.Lstat(line) - if err != nil { - continue - } - - if info.IsDir() || info.Mode()&os.ModeSymlink != 0 { - continue - } - - // Actual disk usage for sparse/cloud files. - actualSize := getActualFileSize(line, info) - files = append(files, fileEntry{ - Name: filepath.Base(line), - Path: line, - Size: actualSize, - }) - } - - // Sort by size (descending). - sort.Slice(files, func(i, j int) bool { - return files[i].Size > files[j].Size - }) - - if len(files) > maxLargeFiles { - files = files[:maxLargeFiles] - } - - return files -} - -// isInFoldedDir checks if a path is inside a folded directory. -func isInFoldedDir(path string) bool { - parts := strings.SplitSeq(path, string(os.PathSeparator)) - for part := range parts { - if foldDirs[part] { - return true - } - } - return false -} - -func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, filesScanned, dirsScanned, bytesScanned *int64, currentPath *string) int64 { - children, err := os.ReadDir(root) - if err != nil { - return 0 - } - - var total int64 - var wg sync.WaitGroup - - // Limit concurrent subdirectory scans. - maxConcurrent := min(runtime.NumCPU()*2, maxDirWorkers) - sem := make(chan struct{}, maxConcurrent) - - for _, child := range children { - fullPath := filepath.Join(root, child.Name()) - - if child.Type()&fs.ModeSymlink != 0 { - info, err := child.Info() - if err != nil { - continue - } - size := getActualFileSize(fullPath, info) - total += size - atomic.AddInt64(filesScanned, 1) - atomic.AddInt64(bytesScanned, size) - continue - } - - if child.IsDir() { - if shouldFoldDirWithPath(child.Name(), fullPath) { - wg.Add(1) - go func(path string) { - defer wg.Done() - size, err := getDirectorySizeFromDu(path) - if err == nil && size > 0 { - atomic.AddInt64(&total, size) - atomic.AddInt64(bytesScanned, size) - atomic.AddInt64(dirsScanned, 1) - } - }(fullPath) - continue - } - - wg.Add(1) - go func(path string) { - defer wg.Done() - sem <- struct{}{} - defer func() { <-sem }() - - size := calculateDirSizeConcurrent(path, largeFileChan, filesScanned, dirsScanned, bytesScanned, currentPath) - atomic.AddInt64(&total, size) - atomic.AddInt64(dirsScanned, 1) - }(fullPath) - continue - } - - info, err := child.Info() - if err != nil { - continue - } - - size := getActualFileSize(fullPath, info) - total += size - atomic.AddInt64(filesScanned, 1) - atomic.AddInt64(bytesScanned, size) - - if !shouldSkipFileForLargeTracking(fullPath) && size >= minLargeFileSize { - largeFileChan <- fileEntry{Name: child.Name(), Path: fullPath, Size: size} - } - - // Update current path occasionally to prevent UI jitter. - if currentPath != nil && atomic.LoadInt64(filesScanned)%int64(batchUpdateSize) == 0 { - *currentPath = fullPath - } - } - - wg.Wait() - return total -} - -// measureOverviewSize calculates the size of a directory using multiple strategies. -// When scanning Home, it excludes ~/Library to avoid duplicate counting. -func measureOverviewSize(path string) (int64, error) { - if path == "" { - return 0, fmt.Errorf("empty path") - } - - path = filepath.Clean(path) - if !filepath.IsAbs(path) { - return 0, fmt.Errorf("path must be absolute: %s", path) - } - - if _, err := os.Stat(path); err != nil { - return 0, fmt.Errorf("cannot access path: %v", err) - } - - // Determine if we should exclude ~/Library (when scanning Home) - home := os.Getenv("HOME") - excludePath := "" - if home != "" && path == home { - excludePath = filepath.Join(home, "Library") - } - - if cached, err := loadStoredOverviewSize(path); err == nil && cached > 0 { - return cached, nil - } - - if duSize, err := getDirectorySizeFromDuWithExclude(path, excludePath); err == nil && duSize > 0 { - _ = storeOverviewSize(path, duSize) - return duSize, nil - } - - if logicalSize, err := getDirectoryLogicalSizeWithExclude(path, excludePath); err == nil && logicalSize > 0 { - _ = storeOverviewSize(path, logicalSize) - return logicalSize, nil - } - - if cached, err := loadCacheFromDisk(path); err == nil { - _ = storeOverviewSize(path, cached.TotalSize) - return cached.TotalSize, nil - } - - return 0, fmt.Errorf("unable to measure directory size with fast methods") -} - -func getDirectorySizeFromDu(path string) (int64, error) { - return getDirectorySizeFromDuWithExclude(path, "") -} - -func getDirectorySizeFromDuWithExclude(path string, excludePath string) (int64, error) { - runDuSize := func(target string) (int64, error) { - if _, err := os.Stat(target); err != nil { - return 0, err - } - - ctx, cancel := context.WithTimeout(context.Background(), duTimeout) - defer cancel() - - cmd := exec.CommandContext(ctx, "du", "-sk", target) - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - if ctx.Err() == context.DeadlineExceeded { - 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", err) - } - fields := strings.Fields(stdout.String()) - if len(fields) == 0 { - return 0, fmt.Errorf("du output empty") - } - kb, err := strconv.ParseInt(fields[0], 10, 64) - if err != nil { - return 0, fmt.Errorf("failed to parse du output: %v", err) - } - if kb <= 0 { - return 0, fmt.Errorf("du size invalid: %d", kb) - } - return kb * 1024, nil - } - - // When excluding a path (e.g., ~/Library), subtract only that exact directory instead of ignoring every "Library" - if excludePath != "" { - totalSize, err := runDuSize(path) - if err != nil { - return 0, err - } - excludeSize, err := runDuSize(excludePath) - if err != nil { - if !os.IsNotExist(err) { - return 0, err - } - excludeSize = 0 - } - if excludeSize > totalSize { - excludeSize = 0 - } - return totalSize - excludeSize, nil - } - - return runDuSize(path) -} - -func getDirectoryLogicalSizeWithExclude(path string, excludePath string) (int64, error) { - var total int64 - err := filepath.WalkDir(path, func(p string, d fs.DirEntry, err error) error { - if err != nil { - if os.IsPermission(err) { - return filepath.SkipDir - } - return nil - } - // Skip excluded path - if excludePath != "" && p == excludePath { - return filepath.SkipDir - } - if d.IsDir() { - return nil - } - info, err := d.Info() - if err != nil { - return nil - } - total += getActualFileSize(p, info) - return nil - }) - if err != nil && err != filepath.SkipDir { - return 0, err - } - return total, nil -} - -func getActualFileSize(_ string, info fs.FileInfo) int64 { - stat, ok := info.Sys().(*syscall.Stat_t) - if !ok { - return info.Size() - } - - actualSize := stat.Blocks * 512 - if actualSize < info.Size() { - return actualSize - } - return info.Size() -} - -func getLastAccessTime(path string) time.Time { - info, err := os.Stat(path) - if err != nil { - return time.Time{} - } - return getLastAccessTimeFromInfo(info) -} - -func getLastAccessTimeFromInfo(info fs.FileInfo) time.Time { - stat, ok := info.Sys().(*syscall.Stat_t) - if !ok { - return time.Time{} - } - return time.Unix(stat.Atimespec.Sec, stat.Atimespec.Nsec) -} diff --git a/cmd/analyze/scanner_test.go b/cmd/analyze/scanner_test.go deleted file mode 100644 index 718d276..0000000 --- a/cmd/analyze/scanner_test.go +++ /dev/null @@ -1,45 +0,0 @@ -package main - -import ( - "os" - "path/filepath" - "testing" -) - -func writeFileWithSize(t *testing.T, path string, size int) { - t.Helper() - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatalf("mkdir %s: %v", path, err) - } - content := make([]byte, size) - if err := os.WriteFile(path, content, 0o644); err != nil { - t.Fatalf("write %s: %v", path, err) - } -} - -func TestGetDirectoryLogicalSizeWithExclude(t *testing.T) { - base := t.TempDir() - homeFile := filepath.Join(base, "fileA") - libFile := filepath.Join(base, "Library", "fileB") - projectLibFile := filepath.Join(base, "Projects", "Library", "fileC") - - writeFileWithSize(t, homeFile, 100) - writeFileWithSize(t, libFile, 200) - writeFileWithSize(t, projectLibFile, 300) - - total, err := getDirectoryLogicalSizeWithExclude(base, "") - if err != nil { - t.Fatalf("getDirectoryLogicalSizeWithExclude (no exclude) error: %v", err) - } - if total != 600 { - t.Fatalf("expected total 600 bytes, got %d", total) - } - - excluding, err := getDirectoryLogicalSizeWithExclude(base, filepath.Join(base, "Library")) - if err != nil { - t.Fatalf("getDirectoryLogicalSizeWithExclude (exclude Library) error: %v", err) - } - if excluding != 400 { - t.Fatalf("expected 400 bytes when excluding top-level Library, got %d", excluding) - } -} diff --git a/cmd/analyze/view.go b/cmd/analyze/view.go deleted file mode 100644 index f2845a9..0000000 --- a/cmd/analyze/view.go +++ /dev/null @@ -1,428 +0,0 @@ -//go:build darwin - -package main - -import ( - "fmt" - "strings" - "sync/atomic" -) - -// View renders the TUI. -func (m model) View() string { - var b strings.Builder - fmt.Fprintln(&b) - - if m.inOverviewMode() { - fmt.Fprintf(&b, "%sAnalyze Disk%s\n", colorPurpleBold, colorReset) - if m.overviewScanning { - allPending := true - for _, entry := range m.entries { - if entry.Size >= 0 { - allPending = false - break - } - } - - if allPending { - fmt.Fprintf(&b, "%s%s%s%s Analyzing disk usage, please wait...%s\n", - colorCyan, colorBold, - spinnerFrames[m.spinner], - colorReset, colorReset) - return b.String() - } else { - fmt.Fprintf(&b, "%sSelect a location to explore:%s ", colorGray, colorReset) - fmt.Fprintf(&b, "%s%s%s%s Scanning...\n\n", colorCyan, colorBold, spinnerFrames[m.spinner], colorReset) - } - } else { - hasPending := false - for _, entry := range m.entries { - if entry.Size < 0 { - hasPending = true - break - } - } - if hasPending { - fmt.Fprintf(&b, "%sSelect a location to explore:%s ", colorGray, colorReset) - fmt.Fprintf(&b, "%s%s%s%s Scanning...\n\n", colorCyan, colorBold, spinnerFrames[m.spinner], colorReset) - } else { - fmt.Fprintf(&b, "%sSelect a location to explore:%s\n\n", colorGray, colorReset) - } - } - } else { - fmt.Fprintf(&b, "%sAnalyze Disk%s %s%s%s", colorPurpleBold, colorReset, colorGray, displayPath(m.path), colorReset) - if !m.scanning { - fmt.Fprintf(&b, " | Total: %s", humanizeBytes(m.totalSize)) - } - fmt.Fprintf(&b, "\n\n") - } - - if m.deleting { - count := int64(0) - if m.deleteCount != nil { - count = atomic.LoadInt64(m.deleteCount) - } - - fmt.Fprintf(&b, "%s%s%s%s Deleting: %s%s items%s removed, please wait...\n", - colorCyan, colorBold, - spinnerFrames[m.spinner], - colorReset, - colorYellow, formatNumber(count), colorReset) - - return b.String() - } - - if m.scanning { - filesScanned, dirsScanned, bytesScanned := m.getScanProgress() - - progressPrefix := "" - if m.lastTotalFiles > 0 { - percent := float64(filesScanned) / float64(m.lastTotalFiles) * 100 - // Cap at 100% generally - if percent > 100 { - percent = 100 - } - // While strictly scanning, cap at 99% to avoid "100% but still working" confusion - if m.scanning && percent >= 100 { - percent = 99 - } - 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", - colorCyan, colorBold, - spinnerFrames[m.spinner], - colorReset, - progressPrefix, - colorYellow, formatNumber(filesScanned), colorReset, - colorYellow, formatNumber(dirsScanned), colorReset, - colorGreen, humanizeBytes(bytesScanned), colorReset) - - if m.currentPath != nil { - currentPath := *m.currentPath - if currentPath != "" { - shortPath := displayPath(currentPath) - shortPath = truncateMiddle(shortPath, 50) - fmt.Fprintf(&b, "%s%s%s\n", colorGray, shortPath, colorReset) - } - } - - return b.String() - } - - if m.showLargeFiles { - if len(m.largeFiles) == 0 { - fmt.Fprintln(&b, " No large files found (>=100MB)") - } else { - viewport := calculateViewport(m.height, true) - start := max(m.largeOffset, 0) - end := min(start+viewport, len(m.largeFiles)) - maxLargeSize := int64(1) - for _, file := range m.largeFiles { - if file.Size > maxLargeSize { - maxLargeSize = file.Size - } - } - nameWidth := calculateNameWidth(m.width) - for idx := start; idx < end; idx++ { - file := m.largeFiles[idx] - shortPath := displayPath(file.Path) - shortPath = truncateMiddle(shortPath, nameWidth) - paddedPath := padName(shortPath, nameWidth) - entryPrefix := " " - nameColor := "" - sizeColor := colorGray - numColor := "" - - isMultiSelected := m.largeMultiSelected != nil && m.largeMultiSelected[file.Path] - selectIcon := "○" - if isMultiSelected { - selectIcon = fmt.Sprintf("%s●%s", colorGreen, colorReset) - nameColor = colorGreen - } - - if idx == m.largeSelected { - entryPrefix = fmt.Sprintf(" %s%s▶%s ", colorCyan, colorBold, colorReset) - if !isMultiSelected { - nameColor = colorCyan - } - sizeColor = colorCyan - numColor = colorCyan - } - size := humanizeBytes(file.Size) - bar := coloredProgressBar(file.Size, maxLargeSize, 0) - fmt.Fprintf(&b, "%s%s %s%2d.%s %s | 📄 %s%s%s %s%10s%s\n", - entryPrefix, selectIcon, numColor, idx+1, colorReset, bar, nameColor, paddedPath, colorReset, sizeColor, size, colorReset) - } - } - } else { - if len(m.entries) == 0 { - fmt.Fprintln(&b, " Empty directory") - } else { - if m.inOverviewMode() { - maxSize := int64(1) - for _, entry := range m.entries { - if entry.Size > maxSize { - maxSize = entry.Size - } - } - totalSize := m.totalSize - // Overview paths are short; fixed width keeps layout stable. - nameWidth := 20 - for idx, entry := range m.entries { - icon := "📁" - sizeVal := entry.Size - barValue := max(sizeVal, 0) - var percent float64 - if totalSize > 0 && sizeVal >= 0 { - percent = float64(sizeVal) / float64(totalSize) * 100 - } else { - percent = 0 - } - percentStr := fmt.Sprintf("%5.1f%%", percent) - if totalSize == 0 || sizeVal < 0 { - percentStr = " -- " - } - bar := coloredProgressBar(barValue, maxSize, percent) - sizeText := "pending.." - if sizeVal >= 0 { - sizeText = humanizeBytes(sizeVal) - } - sizeColor := colorGray - if sizeVal >= 0 && totalSize > 0 { - switch { - case percent >= 50: - sizeColor = colorRed - case percent >= 20: - sizeColor = colorYellow - case percent >= 5: - sizeColor = colorBlue - default: - sizeColor = colorGray - } - } - entryPrefix := " " - name := trimNameWithWidth(entry.Name, nameWidth) - paddedName := padName(name, nameWidth) - nameSegment := fmt.Sprintf("%s %s", icon, paddedName) - numColor := "" - percentColor := "" - if idx == m.selected { - entryPrefix = fmt.Sprintf(" %s%s▶%s ", colorCyan, colorBold, colorReset) - nameSegment = fmt.Sprintf("%s%s %s%s", colorCyan, icon, paddedName, colorReset) - numColor = colorCyan - percentColor = colorCyan - sizeColor = colorCyan - } - displayIndex := idx + 1 - - var hintLabel string - if entry.IsDir && isCleanableDir(entry.Path) { - hintLabel = fmt.Sprintf("%s🧹%s", colorYellow, colorReset) - } else { - lastAccess := entry.LastAccess - if lastAccess.IsZero() && entry.Path != "" { - lastAccess = getLastAccessTime(entry.Path) - } - if unusedTime := formatUnusedTime(lastAccess); unusedTime != "" { - hintLabel = fmt.Sprintf("%s%s%s", colorGray, unusedTime, colorReset) - } - } - - if hintLabel == "" { - fmt.Fprintf(&b, "%s%s%2d.%s %s %s%s%s | %s %s%10s%s\n", - entryPrefix, numColor, displayIndex, colorReset, bar, percentColor, percentStr, colorReset, - nameSegment, sizeColor, sizeText, colorReset) - } else { - fmt.Fprintf(&b, "%s%s%2d.%s %s %s%s%s | %s %s%10s%s %s\n", - entryPrefix, numColor, displayIndex, colorReset, bar, percentColor, percentStr, colorReset, - nameSegment, sizeColor, sizeText, colorReset, hintLabel) - } - } - } else { - maxSize := int64(1) - for _, entry := range m.entries { - if entry.Size > maxSize { - maxSize = entry.Size - } - } - - viewport := calculateViewport(m.height, false) - nameWidth := calculateNameWidth(m.width) - start := max(m.offset, 0) - end := min(start+viewport, len(m.entries)) - - for idx := start; idx < end; idx++ { - entry := m.entries[idx] - icon := "📄" - if entry.IsDir { - icon = "📁" - } - size := humanizeBytes(entry.Size) - name := trimNameWithWidth(entry.Name, nameWidth) - paddedName := padName(name, nameWidth) - - percent := float64(entry.Size) / float64(m.totalSize) * 100 - percentStr := fmt.Sprintf("%5.1f%%", percent) - - bar := coloredProgressBar(entry.Size, maxSize, percent) - - var sizeColor string - if percent >= 50 { - sizeColor = colorRed - } else if percent >= 20 { - sizeColor = colorYellow - } else if percent >= 5 { - sizeColor = colorBlue - } else { - sizeColor = colorGray - } - - isMultiSelected := m.multiSelected != nil && m.multiSelected[entry.Path] - selectIcon := "○" - nameColor := "" - if isMultiSelected { - selectIcon = fmt.Sprintf("%s●%s", colorGreen, colorReset) - nameColor = colorGreen - } - - entryPrefix := " " - nameSegment := fmt.Sprintf("%s %s", icon, paddedName) - if nameColor != "" { - nameSegment = fmt.Sprintf("%s%s %s%s", nameColor, icon, paddedName, colorReset) - } - numColor := "" - percentColor := "" - if idx == m.selected { - entryPrefix = fmt.Sprintf(" %s%s▶%s ", colorCyan, colorBold, colorReset) - if !isMultiSelected { - nameSegment = fmt.Sprintf("%s%s %s%s", colorCyan, icon, paddedName, colorReset) - } - numColor = colorCyan - percentColor = colorCyan - sizeColor = colorCyan - } - - displayIndex := idx + 1 - - var hintLabel string - if entry.IsDir && isCleanableDir(entry.Path) { - hintLabel = fmt.Sprintf("%s🧹%s", colorYellow, colorReset) - } else { - lastAccess := entry.LastAccess - if lastAccess.IsZero() && entry.Path != "" { - lastAccess = getLastAccessTime(entry.Path) - } - if unusedTime := formatUnusedTime(lastAccess); unusedTime != "" { - hintLabel = fmt.Sprintf("%s%s%s", colorGray, unusedTime, colorReset) - } - } - - if hintLabel == "" { - fmt.Fprintf(&b, "%s%s %s%2d.%s %s %s%s%s | %s %s%10s%s\n", - entryPrefix, selectIcon, numColor, displayIndex, colorReset, bar, percentColor, percentStr, colorReset, - nameSegment, sizeColor, size, colorReset) - } else { - fmt.Fprintf(&b, "%s%s %s%2d.%s %s %s%s%s | %s %s%10s%s %s\n", - entryPrefix, selectIcon, numColor, displayIndex, colorReset, bar, percentColor, percentStr, colorReset, - nameSegment, sizeColor, size, colorReset, hintLabel) - } - } - } - } - } - - fmt.Fprintln(&b) - if m.inOverviewMode() { - if len(m.history) > 0 { - fmt.Fprintf(&b, "%s↑↓←→ | Enter | R Refresh | O Open | F File | ← Back | Q Quit%s\n", colorGray, colorReset) - } else { - fmt.Fprintf(&b, "%s↑↓→ | Enter | R Refresh | O Open | F File | Q Quit%s\n", colorGray, colorReset) - } - } else if m.showLargeFiles { - selectCount := len(m.largeMultiSelected) - if selectCount > 0 { - fmt.Fprintf(&b, "%s↑↓← | Space Select | R Refresh | O Open | F File | ⌫ Del(%d) | ← Back | Q Quit%s\n", colorGray, selectCount, colorReset) - } else { - fmt.Fprintf(&b, "%s↑↓← | Space Select | R Refresh | O Open | F File | ⌫ Del | ← Back | Q Quit%s\n", colorGray, colorReset) - } - } else { - largeFileCount := len(m.largeFiles) - selectCount := len(m.multiSelected) - if selectCount > 0 { - if largeFileCount > 0 { - fmt.Fprintf(&b, "%s↑↓←→ | Space Select | Enter | R Refresh | O Open | F File | ⌫ Del(%d) | T Top(%d) | Q Quit%s\n", colorGray, selectCount, largeFileCount, colorReset) - } else { - 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) - } else { - fmt.Fprintf(&b, "%s↑↓←→ | Space Select | Enter | R Refresh | O Open | F File | ⌫ Del | Q Quit%s\n", colorGray, colorReset) - } - } - } - if m.deleteConfirm && m.deleteTarget != nil { - fmt.Fprintln(&b) - var deleteCount int - var totalDeleteSize int64 - if m.showLargeFiles && len(m.largeMultiSelected) > 0 { - deleteCount = len(m.largeMultiSelected) - for path := range m.largeMultiSelected { - for _, file := range m.largeFiles { - if file.Path == path { - totalDeleteSize += file.Size - break - } - } - } - } else if !m.showLargeFiles && len(m.multiSelected) > 0 { - deleteCount = len(m.multiSelected) - for path := range m.multiSelected { - for _, entry := range m.entries { - if entry.Path == path { - totalDeleteSize += entry.Size - break - } - } - } - } - - if deleteCount > 1 { - 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", - colorRed, colorReset, - m.deleteTarget.Name, humanizeBytes(m.deleteTarget.Size), - colorGray, colorReset) - } - } - return b.String() -} - -// calculateViewport returns visible rows for the current terminal height. -func calculateViewport(termHeight int, isLargeFiles bool) int { - if termHeight <= 0 { - return defaultViewport - } - - reserved := 6 // Header + footer - if isLargeFiles { - reserved = 5 - } - - available := termHeight - reserved - - if available < 1 { - return 1 - } - if available > 30 { - return 30 - } - - return available -} diff --git a/cmd/status/main.go b/cmd/status/main.go index b8f7aeb..39afa4f 100644 --- a/cmd/status/main.go +++ b/cmd/status/main.go @@ -1,200 +1,674 @@ -// Package main provides the mo status command for real-time system monitoring. +//go:build windows + package main import ( + "context" "fmt" "os" - "path/filepath" + "os/exec" + "runtime" + "strconv" "strings" + "sync" "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/shirou/gopsutil/v3/cpu" + "github.com/shirou/gopsutil/v3/disk" + "github.com/shirou/gopsutil/v3/host" + "github.com/shirou/gopsutil/v3/mem" + "github.com/shirou/gopsutil/v3/net" + "github.com/shirou/gopsutil/v3/process" ) -const refreshInterval = time.Second - +// Styles var ( - Version = "dev" - BuildTime = "" + titleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#C79FD7")).Bold(true) + headerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#87CEEB")).Bold(true) + labelStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) + valueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")) + okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#A5D6A7")) + warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD75F")) + dangerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5F5F")).Bold(true) + dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) + cardStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("#444444")).Padding(0, 1) ) -type tickMsg struct{} -type animTickMsg struct{} +// Metrics snapshot +type MetricsSnapshot struct { + CollectedAt time.Time + HealthScore int + HealthMessage string -type metricsMsg struct { - data MetricsSnapshot - err error + // Hardware + Hostname string + OS string + Platform string + Uptime time.Duration + + // CPU + CPUModel string + CPUCores int + CPUPercent float64 + CPUPerCore []float64 + + // Memory + MemTotal uint64 + MemUsed uint64 + MemPercent float64 + SwapTotal uint64 + SwapUsed uint64 + SwapPercent float64 + + // Disk + Disks []DiskInfo + + // Network + Networks []NetworkInfo + + // Processes + TopProcesses []ProcessInfo } +type DiskInfo struct { + Device string + Mountpoint string + Total uint64 + Used uint64 + Free uint64 + UsedPercent float64 + Fstype string +} + +type NetworkInfo struct { + Name string + BytesSent uint64 + BytesRecv uint64 + PacketsSent uint64 + PacketsRecv uint64 +} + +type ProcessInfo struct { + PID int32 + Name string + CPU float64 + Memory float32 +} + +// Collector +type Collector struct { + prevNet map[string]net.IOCountersStat + prevNetTime time.Time + mu sync.Mutex +} + +func NewCollector() *Collector { + return &Collector{ + prevNet: make(map[string]net.IOCountersStat), + } +} + +func (c *Collector) Collect() MetricsSnapshot { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var ( + snapshot MetricsSnapshot + wg sync.WaitGroup + mu sync.Mutex + ) + + snapshot.CollectedAt = time.Now() + + // Host info + wg.Add(1) + go func() { + defer wg.Done() + if info, err := host.InfoWithContext(ctx); err == nil { + mu.Lock() + snapshot.Hostname = info.Hostname + snapshot.OS = info.OS + snapshot.Platform = fmt.Sprintf("%s %s", info.Platform, info.PlatformVersion) + snapshot.Uptime = time.Duration(info.Uptime) * time.Second + mu.Unlock() + } + }() + + // CPU info + wg.Add(1) + go func() { + defer wg.Done() + if cpuInfo, err := cpu.InfoWithContext(ctx); err == nil && len(cpuInfo) > 0 { + mu.Lock() + snapshot.CPUModel = cpuInfo[0].ModelName + snapshot.CPUCores = runtime.NumCPU() + mu.Unlock() + } + if percent, err := cpu.PercentWithContext(ctx, 500*time.Millisecond, false); err == nil && len(percent) > 0 { + mu.Lock() + snapshot.CPUPercent = percent[0] + mu.Unlock() + } + if perCore, err := cpu.PercentWithContext(ctx, 500*time.Millisecond, true); err == nil { + mu.Lock() + snapshot.CPUPerCore = perCore + mu.Unlock() + } + }() + + // Memory + wg.Add(1) + go func() { + defer wg.Done() + if memInfo, err := mem.VirtualMemoryWithContext(ctx); err == nil { + mu.Lock() + snapshot.MemTotal = memInfo.Total + snapshot.MemUsed = memInfo.Used + snapshot.MemPercent = memInfo.UsedPercent + mu.Unlock() + } + if swapInfo, err := mem.SwapMemoryWithContext(ctx); err == nil { + mu.Lock() + snapshot.SwapTotal = swapInfo.Total + snapshot.SwapUsed = swapInfo.Used + snapshot.SwapPercent = swapInfo.UsedPercent + mu.Unlock() + } + }() + + // Disk + wg.Add(1) + go func() { + defer wg.Done() + if partitions, err := disk.PartitionsWithContext(ctx, false); err == nil { + var disks []DiskInfo + for _, p := range partitions { + // Skip non-physical drives + if !strings.HasPrefix(p.Device, "C:") && + !strings.HasPrefix(p.Device, "D:") && + !strings.HasPrefix(p.Device, "E:") && + !strings.HasPrefix(p.Device, "F:") { + continue + } + if usage, err := disk.UsageWithContext(ctx, p.Mountpoint); err == nil { + disks = append(disks, DiskInfo{ + Device: p.Device, + Mountpoint: p.Mountpoint, + Total: usage.Total, + Used: usage.Used, + Free: usage.Free, + UsedPercent: usage.UsedPercent, + Fstype: p.Fstype, + }) + } + } + mu.Lock() + snapshot.Disks = disks + mu.Unlock() + } + }() + + // Network + wg.Add(1) + go func() { + defer wg.Done() + if netIO, err := net.IOCountersWithContext(ctx, true); err == nil { + var networks []NetworkInfo + for _, io := range netIO { + // Skip loopback and inactive interfaces + if io.Name == "Loopback Pseudo-Interface 1" || (io.BytesSent == 0 && io.BytesRecv == 0) { + continue + } + networks = append(networks, NetworkInfo{ + Name: io.Name, + BytesSent: io.BytesSent, + BytesRecv: io.BytesRecv, + PacketsSent: io.PacketsSent, + PacketsRecv: io.PacketsRecv, + }) + } + mu.Lock() + snapshot.Networks = networks + mu.Unlock() + } + }() + + // Top Processes + wg.Add(1) + go func() { + defer wg.Done() + procs, err := process.ProcessesWithContext(ctx) + if err != nil { + return + } + + var procInfos []ProcessInfo + for _, p := range procs { + name, err := p.NameWithContext(ctx) + if err != nil { + continue + } + cpuPercent, _ := p.CPUPercentWithContext(ctx) + memPercent, _ := p.MemoryPercentWithContext(ctx) + + if cpuPercent > 0.1 || memPercent > 0.1 { + procInfos = append(procInfos, ProcessInfo{ + PID: p.Pid, + Name: name, + CPU: cpuPercent, + Memory: memPercent, + }) + } + } + + // Sort by CPU usage + for i := 0; i < len(procInfos)-1; i++ { + for j := i + 1; j < len(procInfos); j++ { + if procInfos[j].CPU > procInfos[i].CPU { + procInfos[i], procInfos[j] = procInfos[j], procInfos[i] + } + } + } + + // Take top 5 + if len(procInfos) > 5 { + procInfos = procInfos[:5] + } + + mu.Lock() + snapshot.TopProcesses = procInfos + mu.Unlock() + }() + + wg.Wait() + + // Calculate health score + snapshot.HealthScore, snapshot.HealthMessage = calculateHealthScore(snapshot) + + return snapshot +} + +func calculateHealthScore(s MetricsSnapshot) (int, string) { + score := 100 + var issues []string + + // CPU penalty (30% weight) + if s.CPUPercent > 90 { + score -= 30 + issues = append(issues, "High CPU") + } else if s.CPUPercent > 70 { + score -= 15 + issues = append(issues, "Elevated CPU") + } + + // Memory penalty (25% weight) + if s.MemPercent > 90 { + score -= 25 + issues = append(issues, "High Memory") + } else if s.MemPercent > 80 { + score -= 12 + issues = append(issues, "Elevated Memory") + } + + // Disk penalty (20% weight) + for _, d := range s.Disks { + if d.UsedPercent > 95 { + score -= 20 + issues = append(issues, fmt.Sprintf("Disk %s Critical", d.Device)) + break + } else if d.UsedPercent > 85 { + score -= 10 + issues = append(issues, fmt.Sprintf("Disk %s Low", d.Device)) + break + } + } + + // Swap penalty (10% weight) + if s.SwapPercent > 80 { + score -= 10 + issues = append(issues, "High Swap") + } + + if score < 0 { + score = 0 + } + + msg := "Excellent" + if len(issues) > 0 { + msg = strings.Join(issues, ", ") + } else if score >= 90 { + msg = "Excellent" + } else if score >= 70 { + msg = "Good" + } else if score >= 50 { + msg = "Fair" + } else { + msg = "Poor" + } + + return score, msg +} + +// Model for Bubble Tea type model struct { - collector *Collector - width int - height int - metrics MetricsSnapshot - errMessage string - ready bool - lastUpdated time.Time - collecting bool - animFrame int - catHidden bool // true = hidden, false = visible + collector *Collector + metrics MetricsSnapshot + animFrame int + catHidden bool + ready bool + collecting bool + width int + height int } -// getConfigPath returns the path to the status preferences file. -func getConfigPath() string { - home, err := os.UserHomeDir() - if err != nil { - return "" - } - return filepath.Join(home, ".config", "mole", "status_prefs") -} - -// loadCatHidden loads the cat hidden preference from config file. -func loadCatHidden() bool { - path := getConfigPath() - if path == "" { - return false - } - data, err := os.ReadFile(path) - if err != nil { - return false - } - return strings.TrimSpace(string(data)) == "cat_hidden=true" -} - -// saveCatHidden saves the cat hidden preference to config file. -func saveCatHidden(hidden bool) { - path := getConfigPath() - if path == "" { - return - } - // Ensure directory exists - dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0755); err != nil { - return - } - value := "cat_hidden=false" - if hidden { - value = "cat_hidden=true" - } - _ = os.WriteFile(path, []byte(value+"\n"), 0644) -} +// Messages +type tickMsg time.Time +type metricsMsg MetricsSnapshot func newModel() model { return model{ collector: NewCollector(), - catHidden: loadCatHidden(), + animFrame: 0, } } func (m model) Init() tea.Cmd { - return tea.Batch(tickAfter(0), animTick()) + return tea.Batch( + m.collectMetrics(), + tickCmd(), + ) +} + +func tickCmd() tea.Cmd { + return tea.Tick(time.Second, func(t time.Time) tea.Msg { + return tickMsg(t) + }) +} + +func (m model) collectMetrics() tea.Cmd { + return func() tea.Msg { + return metricsMsg(m.collector.Collect()) + } } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { - case "q", "esc", "ctrl+c": + case "q", "ctrl+c": return m, tea.Quit - case "k": - // Toggle cat visibility and persist preference + case "c": m.catHidden = !m.catHidden - saveCatHidden(m.catHidden) - return m, nil + case "r": + m.collecting = true + return m, m.collectMetrics() } case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height - return m, nil case tickMsg: - if m.collecting { - return m, nil - } - m.collecting = true - return m, m.collectCmd() - case metricsMsg: - if msg.err != nil { - m.errMessage = msg.err.Error() - } else { - m.errMessage = "" - } - m.metrics = msg.data - m.lastUpdated = msg.data.CollectedAt - m.collecting = false - // Mark ready after first successful data collection. - if !m.ready { - m.ready = true - } - return m, tickAfter(refreshInterval) - case animTickMsg: m.animFrame++ - return m, animTickWithSpeed(m.metrics.CPU.Usage) + if m.animFrame%2 == 0 && !m.collecting { + return m, tea.Batch( + m.collectMetrics(), + tickCmd(), + ) + } + return m, tickCmd() + case metricsMsg: + m.metrics = MetricsSnapshot(msg) + m.ready = true + m.collecting = false } return m, nil } func (m model) View() string { if !m.ready { - return "Loading..." + return "\n Loading system metrics..." } - header := renderHeader(m.metrics, m.errMessage, m.animFrame, m.width, m.catHidden) - cardWidth := 0 - if m.width > 80 { - cardWidth = maxInt(24, m.width/2-4) - } - cards := buildCards(m.metrics, cardWidth) + var b strings.Builder - if m.width <= 80 { - var rendered []string - for i, c := range cards { - if i > 0 { - rendered = append(rendered, "") + // Header with mole animation + moleFrame := getMoleFrame(m.animFrame, m.catHidden) + + b.WriteString("\n") + b.WriteString(titleStyle.Render(" 🐹 Mole System Status")) + b.WriteString(" ") + b.WriteString(moleFrame) + b.WriteString("\n\n") + + // Health score + healthColor := okStyle + if m.metrics.HealthScore < 50 { + healthColor = dangerStyle + } else if m.metrics.HealthScore < 70 { + healthColor = warnStyle + } + b.WriteString(fmt.Sprintf(" Health: %s %s\n\n", + healthColor.Render(fmt.Sprintf("%d%%", m.metrics.HealthScore)), + dimStyle.Render(m.metrics.HealthMessage), + )) + + // System info + b.WriteString(headerStyle.Render(" 📍 System")) + b.WriteString("\n") + b.WriteString(fmt.Sprintf(" %s %s\n", labelStyle.Render("Host:"), valueStyle.Render(m.metrics.Hostname))) + b.WriteString(fmt.Sprintf(" %s %s\n", labelStyle.Render("OS:"), valueStyle.Render(m.metrics.Platform))) + b.WriteString(fmt.Sprintf(" %s %s\n", labelStyle.Render("Uptime:"), valueStyle.Render(formatDuration(m.metrics.Uptime)))) + b.WriteString("\n") + + // CPU + b.WriteString(headerStyle.Render(" ⚡ CPU")) + b.WriteString("\n") + cpuColor := getPercentColor(m.metrics.CPUPercent) + b.WriteString(fmt.Sprintf(" %s %s\n", labelStyle.Render("Model:"), valueStyle.Render(truncateString(m.metrics.CPUModel, 50)))) + b.WriteString(fmt.Sprintf(" %s %s (%d cores)\n", + labelStyle.Render("Usage:"), + cpuColor.Render(fmt.Sprintf("%.1f%%", m.metrics.CPUPercent)), + m.metrics.CPUCores, + )) + b.WriteString(fmt.Sprintf(" %s\n", renderProgressBar(m.metrics.CPUPercent, 30))) + b.WriteString("\n") + + // Memory + b.WriteString(headerStyle.Render(" 🧠 Memory")) + b.WriteString("\n") + memColor := getPercentColor(m.metrics.MemPercent) + b.WriteString(fmt.Sprintf(" %s %s / %s %s\n", + labelStyle.Render("RAM:"), + memColor.Render(formatBytes(m.metrics.MemUsed)), + valueStyle.Render(formatBytes(m.metrics.MemTotal)), + memColor.Render(fmt.Sprintf("(%.1f%%)", m.metrics.MemPercent)), + )) + b.WriteString(fmt.Sprintf(" %s\n", renderProgressBar(m.metrics.MemPercent, 30))) + if m.metrics.SwapTotal > 0 { + b.WriteString(fmt.Sprintf(" %s %s / %s\n", + labelStyle.Render("Swap:"), + valueStyle.Render(formatBytes(m.metrics.SwapUsed)), + valueStyle.Render(formatBytes(m.metrics.SwapTotal)), + )) + } + b.WriteString("\n") + + // Disk + b.WriteString(headerStyle.Render(" 💾 Disks")) + b.WriteString("\n") + for _, d := range m.metrics.Disks { + diskColor := getPercentColor(d.UsedPercent) + b.WriteString(fmt.Sprintf(" %s %s / %s %s\n", + labelStyle.Render(d.Device), + diskColor.Render(formatBytes(d.Used)), + valueStyle.Render(formatBytes(d.Total)), + diskColor.Render(fmt.Sprintf("(%.1f%%)", d.UsedPercent)), + )) + b.WriteString(fmt.Sprintf(" %s\n", renderProgressBar(d.UsedPercent, 30))) + } + b.WriteString("\n") + + // Top Processes + if len(m.metrics.TopProcesses) > 0 { + b.WriteString(headerStyle.Render(" 📊 Top Processes")) + b.WriteString("\n") + for _, p := range m.metrics.TopProcesses { + b.WriteString(fmt.Sprintf(" %s %s (CPU: %.1f%%, Mem: %.1f%%)\n", + dimStyle.Render(fmt.Sprintf("[%d]", p.PID)), + valueStyle.Render(truncateString(p.Name, 20)), + p.CPU, + p.Memory, + )) + } + b.WriteString("\n") + } + + // Network + if len(m.metrics.Networks) > 0 { + b.WriteString(headerStyle.Render(" 🌐 Network")) + b.WriteString("\n") + for i, n := range m.metrics.Networks { + if i >= 3 { + break } - rendered = append(rendered, renderCard(c, cardWidth, 0)) + b.WriteString(fmt.Sprintf(" %s ↑%s ↓%s\n", + labelStyle.Render(truncateString(n.Name, 20)+":"), + valueStyle.Render(formatBytes(n.BytesSent)), + valueStyle.Render(formatBytes(n.BytesRecv)), + )) } - result := header + "\n" + lipgloss.JoinVertical(lipgloss.Left, rendered...) - // Add extra newline if cat is hidden for better spacing - if m.catHidden { - result = header + "\n\n" + lipgloss.JoinVertical(lipgloss.Left, rendered...) - } - return result + b.WriteString("\n") } - twoCol := renderTwoColumns(cards, m.width) - // Add extra newline if cat is hidden for better spacing - if m.catHidden { - return header + "\n\n" + twoCol + // Footer + b.WriteString(dimStyle.Render(" [q] quit [r] refresh [c] toggle mole")) + b.WriteString("\n") + + return b.String() +} + +func getMoleFrame(frame int, hidden bool) string { + if hidden { + return "" } - return header + "\n" + twoCol -} - -func (m model) collectCmd() tea.Cmd { - return func() tea.Msg { - data, err := m.collector.Collect() - return metricsMsg{data: data, err: err} + frames := []string{ + "🐹", + "🐹.", + "🐹..", + "🐹...", } + return frames[frame%len(frames)] } -func tickAfter(delay time.Duration) tea.Cmd { - return tea.Tick(delay, func(time.Time) tea.Msg { return tickMsg{} }) +func renderProgressBar(percent float64, width int) string { + filled := int(percent / 100 * float64(width)) + if filled > width { + filled = width + } + if filled < 0 { + filled = 0 + } + + color := okStyle + if percent > 85 { + color = dangerStyle + } else if percent > 70 { + color = warnStyle + } + + bar := strings.Repeat("█", filled) + strings.Repeat("░", width-filled) + return color.Render(bar) } -func animTick() tea.Cmd { - return tea.Tick(200*time.Millisecond, func(time.Time) tea.Msg { return animTickMsg{} }) +func getPercentColor(percent float64) lipgloss.Style { + if percent > 85 { + return dangerStyle + } else if percent > 70 { + return warnStyle + } + return okStyle } -func animTickWithSpeed(cpuUsage float64) tea.Cmd { - // Higher CPU = faster animation. - interval := max(300-int(cpuUsage*2.5), 50) - return tea.Tick(time.Duration(interval)*time.Millisecond, func(time.Time) tea.Msg { return animTickMsg{} }) +func formatBytes(bytes uint64) string { + const unit = 1024 + if bytes < unit { + return fmt.Sprintf("%d B", bytes) + } + div, exp := uint64(unit), 0 + for n := bytes / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) +} + +func formatDuration(d time.Duration) string { + days := int(d.Hours() / 24) + hours := int(d.Hours()) % 24 + minutes := int(d.Minutes()) % 60 + + if days > 0 { + return fmt.Sprintf("%dd %dh %dm", days, hours, minutes) + } + if hours > 0 { + return fmt.Sprintf("%dh %dm", hours, minutes) + } + return fmt.Sprintf("%dm", minutes) +} + +func truncateString(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen-3] + "..." +} + +// getWindowsVersion gets detailed Windows version using PowerShell +func getWindowsVersion() string { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "powershell", "-Command", + "(Get-CimInstance Win32_OperatingSystem).Caption") + output, err := cmd.Output() + if err != nil { + return "Windows" + } + return strings.TrimSpace(string(output)) +} + +// getBatteryInfo gets battery info on Windows (for laptops) +func getBatteryInfo() (int, bool, bool) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "powershell", "-Command", + "(Get-CimInstance Win32_Battery).EstimatedChargeRemaining") + output, err := cmd.Output() + if err != nil { + return 0, false, false + } + + percent, err := strconv.Atoi(strings.TrimSpace(string(output))) + if err != nil { + return 0, false, false + } + + // Check if charging + cmdStatus := exec.CommandContext(ctx, "powershell", "-Command", + "(Get-CimInstance Win32_Battery).BatteryStatus") + statusOutput, _ := cmdStatus.Output() + status, _ := strconv.Atoi(strings.TrimSpace(string(statusOutput))) + isCharging := status == 2 // 2 = AC Power + + return percent, isCharging, true } func main() { p := tea.NewProgram(newModel(), tea.WithAltScreen()) if _, err := p.Run(); err != nil { - fmt.Fprintf(os.Stderr, "system status error: %v\n", err) + fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } } diff --git a/windows/cmd/status/main_test.go b/cmd/status/main_test.go similarity index 100% rename from windows/cmd/status/main_test.go rename to cmd/status/main_test.go diff --git a/cmd/status/metrics.go b/cmd/status/metrics.go deleted file mode 100644 index 2b24bba..0000000 --- a/cmd/status/metrics.go +++ /dev/null @@ -1,294 +0,0 @@ -package main - -import ( - "context" - "fmt" - "os/exec" - "sync" - "time" - - "github.com/shirou/gopsutil/v3/disk" - "github.com/shirou/gopsutil/v3/host" - "github.com/shirou/gopsutil/v3/net" -) - -type MetricsSnapshot struct { - CollectedAt time.Time - Host string - Platform string - Uptime string - Procs uint64 - Hardware HardwareInfo - HealthScore int // 0-100 system health score - HealthScoreMsg string // Brief explanation - - CPU CPUStatus - GPU []GPUStatus - Memory MemoryStatus - Disks []DiskStatus - DiskIO DiskIOStatus - Network []NetworkStatus - Proxy ProxyStatus - Batteries []BatteryStatus - Thermal ThermalStatus - Sensors []SensorReading - Bluetooth []BluetoothDevice - TopProcesses []ProcessInfo -} - -type HardwareInfo struct { - Model string // MacBook Pro 14-inch, 2021 - CPUModel string // Apple M1 Pro / Intel Core i7 - TotalRAM string // 16GB - DiskSize string // 512GB - OSVersion string // macOS Sonoma 14.5 - RefreshRate string // 120Hz / 60Hz -} - -type DiskIOStatus struct { - ReadRate float64 // MB/s - WriteRate float64 // MB/s -} - -type ProcessInfo struct { - Name string - CPU float64 - Memory float64 -} - -type CPUStatus struct { - Usage float64 - PerCore []float64 - PerCoreEstimated bool - Load1 float64 - Load5 float64 - Load15 float64 - CoreCount int - LogicalCPU int - PCoreCount int // Performance cores (Apple Silicon) - ECoreCount int // Efficiency cores (Apple Silicon) -} - -type GPUStatus struct { - Name string - Usage float64 - MemoryUsed float64 - MemoryTotal float64 - CoreCount int - Note string -} - -type MemoryStatus struct { - Used uint64 - Total uint64 - UsedPercent float64 - SwapUsed uint64 - SwapTotal uint64 - Cached uint64 // File cache that can be freed if needed - Pressure string // macOS memory pressure: normal/warn/critical -} - -type DiskStatus struct { - Mount string - Device string - Used uint64 - Total uint64 - UsedPercent float64 - Fstype string - External bool -} - -type NetworkStatus struct { - Name string - RxRateMBs float64 - TxRateMBs float64 - IP string -} - -type ProxyStatus struct { - Enabled bool - Type string // HTTP, SOCKS, System - Host string -} - -type BatteryStatus struct { - Percent float64 - Status string - TimeLeft string - Health string - CycleCount int - Capacity int // Maximum capacity percentage (e.g., 85 means 85% of original) -} - -type ThermalStatus struct { - CPUTemp float64 - GPUTemp float64 - FanSpeed int - FanCount int - SystemPower float64 // System power consumption in Watts - AdapterPower float64 // AC adapter max power in Watts - BatteryPower float64 // Battery charge/discharge power in Watts (positive = discharging) -} - -type SensorReading struct { - Label string - Value float64 - Unit string - Note string -} - -type BluetoothDevice struct { - Name string - Connected bool - Battery string -} - -type Collector struct { - // Static cache. - cachedHW HardwareInfo - lastHWAt time.Time - hasStatic bool - - // Slow cache (30s-1m). - lastBTAt time.Time - lastBT []BluetoothDevice - - // Fast metrics (1s). - prevNet map[string]net.IOCountersStat - lastNetAt time.Time - lastGPUAt time.Time - cachedGPU []GPUStatus - prevDiskIO disk.IOCountersStat - lastDiskAt time.Time -} - -func NewCollector() *Collector { - return &Collector{ - prevNet: make(map[string]net.IOCountersStat), - } -} - -func (c *Collector) Collect() (MetricsSnapshot, error) { - now := time.Now() - - // Host info is cached by gopsutil; fetch once. - hostInfo, _ := host.Info() - - var ( - wg sync.WaitGroup - errMu sync.Mutex - mergeErr error - - cpuStats CPUStatus - memStats MemoryStatus - diskStats []DiskStatus - diskIO DiskIOStatus - netStats []NetworkStatus - proxyStats ProxyStatus - batteryStats []BatteryStatus - thermalStats ThermalStatus - sensorStats []SensorReading - gpuStats []GPUStatus - btStats []BluetoothDevice - topProcs []ProcessInfo - ) - - // Helper to launch concurrent collection. - collect := func(fn func() error) { - wg.Add(1) - go func() { - defer wg.Done() - if err := fn(); err != nil { - errMu.Lock() - if mergeErr == nil { - mergeErr = err - } else { - mergeErr = fmt.Errorf("%v; %w", mergeErr, err) - } - errMu.Unlock() - } - }() - } - - // Launch independent collection tasks. - collect(func() (err error) { cpuStats, err = collectCPU(); return }) - collect(func() (err error) { memStats, err = collectMemory(); return }) - collect(func() (err error) { diskStats, err = collectDisks(); return }) - collect(func() (err error) { diskIO = c.collectDiskIO(now); return nil }) - collect(func() (err error) { netStats, err = c.collectNetwork(now); return }) - collect(func() (err error) { proxyStats = collectProxy(); return nil }) - collect(func() (err error) { batteryStats, _ = collectBatteries(); return nil }) - collect(func() (err error) { thermalStats = collectThermal(); return nil }) - 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. - if now.Sub(c.lastBTAt) > 30*time.Second || len(c.lastBT) == 0 { - btStats = c.collectBluetooth(now) - c.lastBT = btStats - c.lastBTAt = now - } else { - btStats = c.lastBT - } - return nil - }) - collect(func() (err error) { topProcs = collectTopProcesses(); return nil }) - - // Wait for all to complete. - wg.Wait() - - // Dependent tasks (post-collect). - // Cache hardware info as it's expensive and rarely changes. - if !c.hasStatic || now.Sub(c.lastHWAt) > 10*time.Minute { - c.cachedHW = collectHardware(memStats.Total, diskStats) - c.lastHWAt = now - c.hasStatic = true - } - hwInfo := c.cachedHW - - score, scoreMsg := calculateHealthScore(cpuStats, memStats, diskStats, diskIO, thermalStats) - - return MetricsSnapshot{ - CollectedAt: now, - Host: hostInfo.Hostname, - Platform: fmt.Sprintf("%s %s", hostInfo.Platform, hostInfo.PlatformVersion), - Uptime: formatUptime(hostInfo.Uptime), - Procs: hostInfo.Procs, - Hardware: hwInfo, - HealthScore: score, - HealthScoreMsg: scoreMsg, - CPU: cpuStats, - GPU: gpuStats, - Memory: memStats, - Disks: diskStats, - DiskIO: diskIO, - Network: netStats, - Proxy: proxyStats, - Batteries: batteryStats, - Thermal: thermalStats, - Sensors: sensorStats, - Bluetooth: btStats, - TopProcesses: topProcs, - }, mergeErr -} - -func runCmd(ctx context.Context, name string, args ...string) (string, error) { - cmd := exec.CommandContext(ctx, name, args...) - output, err := cmd.Output() - if err != nil { - return "", err - } - return string(output), nil -} - -func commandExists(name string) bool { - if name == "" { - return false - } - defer func() { - // Treat LookPath panics as "missing". - _ = recover() - }() - _, err := exec.LookPath(name) - return err == nil -} diff --git a/cmd/status/metrics_battery.go b/cmd/status/metrics_battery.go deleted file mode 100644 index 57f1f8b..0000000 --- a/cmd/status/metrics_battery.go +++ /dev/null @@ -1,289 +0,0 @@ -package main - -import ( - "context" - "errors" - "fmt" - "os" - "path/filepath" - "runtime" - "strconv" - "strings" - "time" - - "github.com/shirou/gopsutil/v3/host" -) - -var ( - // Cache for heavy system_profiler output. - lastPowerAt time.Time - cachedPower string - powerCacheTTL = 30 * time.Second -) - -func collectBatteries() (batts []BatteryStatus, err error) { - defer func() { - if r := recover(); r != nil { - // Swallow panics to keep UI alive. - err = fmt.Errorf("battery collection failed: %v", r) - } - }() - - // macOS: pmset for real-time percentage/status. - if runtime.GOOS == "darwin" && commandExists("pmset") { - if out, err := runCmd(context.Background(), "pmset", "-g", "batt"); err == nil { - // Health/cycles/capacity from cached system_profiler. - health, cycles, capacity := getCachedPowerData() - if batts := parsePMSet(out, health, cycles, capacity); len(batts) > 0 { - return batts, nil - } - } - } - - // Linux: /sys/class/power_supply. - matches, _ := filepath.Glob("/sys/class/power_supply/BAT*/capacity") - for _, capFile := range matches { - statusFile := filepath.Join(filepath.Dir(capFile), "status") - capData, err := os.ReadFile(capFile) - if err != nil { - continue - } - statusData, _ := os.ReadFile(statusFile) - percentStr := strings.TrimSpace(string(capData)) - percent, _ := strconv.ParseFloat(percentStr, 64) - status := strings.TrimSpace(string(statusData)) - if status == "" { - status = "Unknown" - } - batts = append(batts, BatteryStatus{ - Percent: percent, - Status: status, - }) - } - if len(batts) > 0 { - return batts, nil - } - - return nil, errors.New("no battery data found") -} - -func parsePMSet(raw string, health string, cycles int, capacity int) []BatteryStatus { - var out []BatteryStatus - var timeLeft string - - for line := range strings.Lines(raw) { - // Time remaining. - if strings.Contains(line, "remaining") { - parts := strings.Fields(line) - for i, p := range parts { - if p == "remaining" && i > 0 { - timeLeft = parts[i-1] - } - } - } - - if !strings.Contains(line, "%") { - continue - } - fields := strings.Fields(line) - var ( - percent float64 - found bool - status = "Unknown" - ) - for i, f := range fields { - if strings.Contains(f, "%") { - value := strings.TrimSuffix(strings.TrimSuffix(f, ";"), "%") - if p, err := strconv.ParseFloat(value, 64); err == nil { - percent = p - found = true - if i+1 < len(fields) { - status = strings.TrimSuffix(fields[i+1], ";") - } - } - break - } - } - if !found { - continue - } - - out = append(out, BatteryStatus{ - Percent: percent, - Status: status, - TimeLeft: timeLeft, - Health: health, - CycleCount: cycles, - Capacity: capacity, - }) - } - return out -} - -// getCachedPowerData returns condition, cycles, and capacity from cached system_profiler. -func getCachedPowerData() (health string, cycles int, capacity int) { - out := getSystemPowerOutput() - if out == "" { - return "", 0, 0 - } - - for line := range strings.Lines(out) { - lower := strings.ToLower(line) - if strings.Contains(lower, "cycle count") { - if _, after, found := strings.Cut(line, ":"); found { - cycles, _ = strconv.Atoi(strings.TrimSpace(after)) - } - } - if strings.Contains(lower, "condition") { - if _, after, found := strings.Cut(line, ":"); found { - health = strings.TrimSpace(after) - } - } - if strings.Contains(lower, "maximum capacity") { - if _, after, found := strings.Cut(line, ":"); found { - capacityStr := strings.TrimSpace(after) - capacityStr = strings.TrimSuffix(capacityStr, "%") - capacity, _ = strconv.Atoi(strings.TrimSpace(capacityStr)) - } - } - } - return health, cycles, capacity -} - -func getSystemPowerOutput() string { - if runtime.GOOS != "darwin" { - return "" - } - - now := time.Now() - if cachedPower != "" && now.Sub(lastPowerAt) < powerCacheTTL { - return cachedPower - } - - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - - out, err := runCmd(ctx, "system_profiler", "SPPowerDataType") - if err == nil { - cachedPower = out - lastPowerAt = now - } - return cachedPower -} - -func collectThermal() ThermalStatus { - if runtime.GOOS != "darwin" { - return ThermalStatus{} - } - - var thermal ThermalStatus - - // Fan info from cached system_profiler. - out := getSystemPowerOutput() - if out != "" { - for line := range strings.Lines(out) { - lower := strings.ToLower(line) - if strings.Contains(lower, "fan") && strings.Contains(lower, "speed") { - if _, after, found := strings.Cut(line, ":"); found { - numStr := strings.TrimSpace(after) - numStr, _, _ = strings.Cut(numStr, " ") - thermal.FanSpeed, _ = strconv.Atoi(numStr) - } - } - } - } - - // Power metrics from ioreg (fast, real-time). - ctxPower, cancelPower := context.WithTimeout(context.Background(), 500*time.Millisecond) - defer cancelPower() - if out, err := runCmd(ctxPower, "ioreg", "-rn", "AppleSmartBattery"); err == nil { - for line := range strings.Lines(out) { - line = strings.TrimSpace(line) - - // Battery temperature ("Temperature" = 3055). - if _, after, found := strings.Cut(line, "\"Temperature\" = "); found { - valStr := strings.TrimSpace(after) - if tempRaw, err := strconv.Atoi(valStr); err == nil && tempRaw > 0 { - thermal.CPUTemp = float64(tempRaw) / 100.0 - } - } - - // Adapter power (Watts) from current adapter. - if strings.Contains(line, "\"AdapterDetails\" = {") && !strings.Contains(line, "AppleRaw") { - if _, after, found := strings.Cut(line, "\"Watts\"="); found { - valStr := strings.TrimSpace(after) - valStr, _, _ = strings.Cut(valStr, ",") - valStr, _, _ = strings.Cut(valStr, "}") - valStr = strings.TrimSpace(valStr) - if watts, err := strconv.ParseFloat(valStr, 64); err == nil && watts > 0 { - thermal.AdapterPower = watts - } - } - } - - // System power consumption (mW -> W). - if _, after, found := strings.Cut(line, "\"SystemPowerIn\"="); found { - valStr := strings.TrimSpace(after) - valStr, _, _ = strings.Cut(valStr, ",") - valStr, _, _ = strings.Cut(valStr, "}") - valStr = strings.TrimSpace(valStr) - if powerMW, err := strconv.ParseFloat(valStr, 64); err == nil && powerMW > 0 { - thermal.SystemPower = powerMW / 1000.0 - } - } - - // Battery power (mW -> W, positive = discharging). - if _, after, found := strings.Cut(line, "\"BatteryPower\"="); found { - valStr := strings.TrimSpace(after) - valStr, _, _ = strings.Cut(valStr, ",") - valStr, _, _ = strings.Cut(valStr, "}") - valStr = strings.TrimSpace(valStr) - // Parse as int64 first to handle negative values (charging) - if powerMW, err := strconv.ParseInt(valStr, 10, 64); err == nil { - thermal.BatteryPower = float64(powerMW) / 1000.0 - } - } - } - } - - // Fallback: thermal level proxy. - if thermal.CPUTemp == 0 { - ctx2, cancel2 := context.WithTimeout(context.Background(), 500*time.Millisecond) - defer cancel2() - out2, err := runCmd(ctx2, "sysctl", "-n", "machdep.xcpm.cpu_thermal_level") - if err == nil { - level, _ := strconv.Atoi(strings.TrimSpace(out2)) - if level >= 0 { - thermal.CPUTemp = 45 + float64(level)*0.5 - } - } - } - - return thermal -} - -func collectSensors() ([]SensorReading, error) { - temps, err := host.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_bluetooth.go b/cmd/status/metrics_bluetooth.go deleted file mode 100644 index 740c10c..0000000 --- a/cmd/status/metrics_bluetooth.go +++ /dev/null @@ -1,138 +0,0 @@ -package main - -import ( - "context" - "errors" - "runtime" - "strings" - "time" -) - -const ( - bluetoothCacheTTL = 30 * time.Second - bluetoothctlTimeout = 1500 * time.Millisecond -) - -func (c *Collector) collectBluetooth(now time.Time) []BluetoothDevice { - if len(c.lastBT) > 0 && !c.lastBTAt.IsZero() && now.Sub(c.lastBTAt) < bluetoothCacheTTL { - return c.lastBT - } - - if devs, err := readSystemProfilerBluetooth(); err == nil && len(devs) > 0 { - c.lastBTAt = now - c.lastBT = devs - return devs - } - - if devs, err := readBluetoothCTLDevices(); err == nil && len(devs) > 0 { - c.lastBTAt = now - c.lastBT = devs - return devs - } - - c.lastBTAt = now - if len(c.lastBT) == 0 { - c.lastBT = []BluetoothDevice{{Name: "No Bluetooth info", Connected: false}} - } - return c.lastBT -} - -func readSystemProfilerBluetooth() ([]BluetoothDevice, error) { - if runtime.GOOS != "darwin" || !commandExists("system_profiler") { - return nil, errors.New("system_profiler unavailable") - } - - ctx, cancel := context.WithTimeout(context.Background(), systemProfilerTimeout) - defer cancel() - - out, err := runCmd(ctx, "system_profiler", "SPBluetoothDataType") - if err != nil { - return nil, err - } - return parseSPBluetooth(out), nil -} - -func readBluetoothCTLDevices() ([]BluetoothDevice, error) { - if !commandExists("bluetoothctl") { - return nil, errors.New("bluetoothctl unavailable") - } - - ctx, cancel := context.WithTimeout(context.Background(), bluetoothctlTimeout) - defer cancel() - - out, err := runCmd(ctx, "bluetoothctl", "info") - if err != nil { - return nil, err - } - return parseBluetoothctl(out), nil -} - -func parseSPBluetooth(raw string) []BluetoothDevice { - var devices []BluetoothDevice - var currentName string - var connected bool - var battery string - - for line := range strings.Lines(raw) { - trim := strings.TrimSpace(line) - if len(trim) == 0 { - continue - } - if !strings.HasPrefix(line, " ") && strings.HasSuffix(trim, ":") { - // Reset at top-level sections. - currentName = "" - connected = false - battery = "" - continue - } - if strings.HasPrefix(line, " ") && strings.HasSuffix(trim, ":") { - if currentName != "" { - devices = append(devices, BluetoothDevice{Name: currentName, Connected: connected, Battery: battery}) - } - currentName = strings.TrimSuffix(trim, ":") - connected = false - battery = "" - continue - } - if strings.Contains(trim, "Connected:") { - connected = strings.Contains(trim, "Yes") - } - if strings.Contains(trim, "Battery Level:") { - battery = strings.TrimSpace(strings.TrimPrefix(trim, "Battery Level:")) - } - } - if currentName != "" { - devices = append(devices, BluetoothDevice{Name: currentName, Connected: connected, Battery: battery}) - } - if len(devices) == 0 { - return []BluetoothDevice{{Name: "No devices", Connected: false}} - } - return devices -} - -func parseBluetoothctl(raw string) []BluetoothDevice { - var devices []BluetoothDevice - current := BluetoothDevice{} - for line := range strings.Lines(raw) { - trim := strings.TrimSpace(line) - if strings.HasPrefix(trim, "Device ") { - if current.Name != "" { - devices = append(devices, current) - } - current = BluetoothDevice{Name: strings.TrimPrefix(trim, "Device "), Connected: false} - } - if after, ok := strings.CutPrefix(trim, "Name:"); ok { - current.Name = strings.TrimSpace(after) - } - if strings.HasPrefix(trim, "Connected:") { - current.Connected = strings.Contains(trim, "yes") - } - } - if current.Name != "" { - devices = append(devices, current) - } - if len(devices) == 0 { - return []BluetoothDevice{{Name: "No devices", Connected: false}} - } - return devices -} diff --git a/cmd/status/metrics_cpu.go b/cmd/status/metrics_cpu.go deleted file mode 100644 index 29c7690..0000000 --- a/cmd/status/metrics_cpu.go +++ /dev/null @@ -1,261 +0,0 @@ -package main - -import ( - "bufio" - "context" - "errors" - "runtime" - "strconv" - "strings" - "time" - - "github.com/shirou/gopsutil/v3/cpu" - "github.com/shirou/gopsutil/v3/load" -) - -const ( - cpuSampleInterval = 200 * time.Millisecond -) - -func collectCPU() (CPUStatus, error) { - counts, countsErr := cpu.Counts(false) - if countsErr != nil || counts == 0 { - counts = runtime.NumCPU() - } - - logical, logicalErr := cpu.Counts(true) - if logicalErr != nil || logical == 0 { - logical = runtime.NumCPU() - } - if logical <= 0 { - logical = 1 - } - - // Two-call pattern for more reliable CPU usage. - warmUpCPU() - time.Sleep(cpuSampleInterval) - percents, err := cpu.Percent(0, true) - var totalPercent float64 - perCoreEstimated := false - if err != nil || len(percents) == 0 { - fallbackUsage, fallbackPerCore, fallbackErr := fallbackCPUUtilization(logical) - if fallbackErr != nil { - if err != nil { - return CPUStatus{}, err - } - return CPUStatus{}, fallbackErr - } - totalPercent = fallbackUsage - percents = fallbackPerCore - perCoreEstimated = true - } else { - for _, v := range percents { - totalPercent += v - } - totalPercent /= float64(len(percents)) - } - - loadStats, loadErr := load.Avg() - var loadAvg load.AvgStat - if loadStats != nil { - loadAvg = *loadStats - } - if loadErr != nil || isZeroLoad(loadAvg) { - if fallback, err := fallbackLoadAvgFromUptime(); err == nil { - loadAvg = fallback - } - } - - // P/E core counts for Apple Silicon. - pCores, eCores := getCoreTopology() - - return CPUStatus{ - Usage: totalPercent, - PerCore: percents, - PerCoreEstimated: perCoreEstimated, - Load1: loadAvg.Load1, - Load5: loadAvg.Load5, - Load15: loadAvg.Load15, - CoreCount: counts, - LogicalCPU: logical, - PCoreCount: pCores, - ECoreCount: eCores, - }, nil -} - -func isZeroLoad(avg load.AvgStat) bool { - return avg.Load1 == 0 && avg.Load5 == 0 && avg.Load15 == 0 -} - -var ( - // Cache for core topology. - lastTopologyAt time.Time - cachedP, cachedE int - topologyTTL = 10 * time.Minute -) - -// getCoreTopology returns P/E core counts on Apple Silicon. -func getCoreTopology() (pCores, eCores int) { - if runtime.GOOS != "darwin" { - return 0, 0 - } - - now := time.Now() - if cachedP > 0 || cachedE > 0 { - if now.Sub(lastTopologyAt) < topologyTTL { - return cachedP, cachedE - } - } - - ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) - defer cancel() - - out, err := runCmd(ctx, "sysctl", "-n", - "hw.perflevel0.logicalcpu", - "hw.perflevel0.name", - "hw.perflevel1.logicalcpu", - "hw.perflevel1.name") - if err != nil { - return 0, 0 - } - - var lines []string - for line := range strings.Lines(strings.TrimSpace(out)) { - lines = append(lines, line) - } - if len(lines) < 4 { - return 0, 0 - } - - level0Count, _ := strconv.Atoi(strings.TrimSpace(lines[0])) - level0Name := strings.ToLower(strings.TrimSpace(lines[1])) - - level1Count, _ := strconv.Atoi(strings.TrimSpace(lines[2])) - level1Name := strings.ToLower(strings.TrimSpace(lines[3])) - - if strings.Contains(level0Name, "performance") { - pCores = level0Count - } else if strings.Contains(level0Name, "efficiency") { - eCores = level0Count - } - - if strings.Contains(level1Name, "performance") { - pCores = level1Count - } else if strings.Contains(level1Name, "efficiency") { - eCores = level1Count - } - - cachedP, cachedE = pCores, eCores - lastTopologyAt = now - return pCores, eCores -} - -func fallbackLoadAvgFromUptime() (load.AvgStat, error) { - if !commandExists("uptime") { - return load.AvgStat{}, errors.New("uptime command unavailable") - } - ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) - defer cancel() - - out, err := runCmd(ctx, "uptime") - if err != nil { - return load.AvgStat{}, err - } - - markers := []string{"load averages:", "load average:"} - idx := -1 - for _, marker := range markers { - if pos := strings.LastIndex(out, marker); pos != -1 { - idx = pos + len(marker) - break - } - } - if idx == -1 { - return load.AvgStat{}, errors.New("load averages not found in uptime output") - } - - segment := strings.TrimSpace(out[idx:]) - fields := strings.Fields(segment) - var values []float64 - for _, field := range fields { - field = strings.Trim(field, ",;") - if field == "" { - continue - } - val, err := strconv.ParseFloat(field, 64) - if err != nil { - continue - } - values = append(values, val) - if len(values) == 3 { - break - } - } - if len(values) < 3 { - return load.AvgStat{}, errors.New("could not parse load averages from uptime output") - } - - return load.AvgStat{ - Load1: values[0], - Load5: values[1], - Load15: values[2], - }, nil -} - -func fallbackCPUUtilization(logical int) (float64, []float64, error) { - if logical <= 0 { - logical = runtime.NumCPU() - } - if logical <= 0 { - logical = 1 - } - - ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) - defer cancel() - - out, err := runCmd(ctx, "ps", "-Aceo", "pcpu") - if err != nil { - return 0, nil, err - } - - scanner := bufio.NewScanner(strings.NewReader(out)) - total := 0.0 - lineIndex := 0 - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" { - continue - } - lineIndex++ - if lineIndex == 1 && (strings.Contains(strings.ToLower(line), "cpu") || strings.Contains(line, "%")) { - continue - } - - val, parseErr := strconv.ParseFloat(line, 64) - if parseErr != nil { - continue - } - total += val - } - if scanErr := scanner.Err(); scanErr != nil { - return 0, nil, scanErr - } - - maxTotal := float64(logical * 100) - if total < 0 { - total = 0 - } else if total > maxTotal { - total = maxTotal - } - - avg := total / float64(logical) - perCore := make([]float64, logical) - for i := range perCore { - perCore[i] = avg - } - return avg, perCore, nil -} - -func warmUpCPU() { - cpu.Percent(0, true) //nolint:errcheck -} diff --git a/cmd/status/metrics_disk.go b/cmd/status/metrics_disk.go deleted file mode 100644 index 9586fae..0000000 --- a/cmd/status/metrics_disk.go +++ /dev/null @@ -1,214 +0,0 @@ -package main - -import ( - "context" - "errors" - "fmt" - "runtime" - "sort" - "strings" - "time" - - "github.com/shirou/gopsutil/v3/disk" -) - -var skipDiskMounts = map[string]bool{ - "/System/Volumes/VM": true, - "/System/Volumes/Preboot": true, - "/System/Volumes/Update": true, - "/System/Volumes/xarts": true, - "/System/Volumes/Hardware": true, - "/System/Volumes/Data": true, - "/dev": true, -} - -func collectDisks() ([]DiskStatus, error) { - partitions, err := disk.Partitions(false) - if err != nil { - return nil, err - } - - var ( - disks []DiskStatus - seenDevice = make(map[string]bool) - seenVolume = make(map[string]bool) - ) - for _, part := range partitions { - if strings.HasPrefix(part.Device, "/dev/loop") { - continue - } - if skipDiskMounts[part.Mountpoint] { - continue - } - if strings.HasPrefix(part.Mountpoint, "/System/Volumes/") { - continue - } - // Skip /private mounts. - if strings.HasPrefix(part.Mountpoint, "/private/") { - continue - } - baseDevice := baseDeviceName(part.Device) - if baseDevice == "" { - baseDevice = part.Device - } - if seenDevice[baseDevice] { - continue - } - usage, err := disk.Usage(part.Mountpoint) - if err != nil || usage.Total == 0 { - continue - } - // Skip <1GB volumes. - if usage.Total < 1<<30 { - continue - } - // Use size-based dedupe key for shared pools. - volKey := fmt.Sprintf("%s:%d", part.Fstype, usage.Total) - if seenVolume[volKey] { - continue - } - disks = append(disks, DiskStatus{ - Mount: part.Mountpoint, - Device: part.Device, - Used: usage.Used, - Total: usage.Total, - UsedPercent: usage.UsedPercent, - Fstype: part.Fstype, - }) - seenDevice[baseDevice] = true - seenVolume[volKey] = true - } - - annotateDiskTypes(disks) - - sort.Slice(disks, func(i, j int) bool { - return disks[i].Total > disks[j].Total - }) - - if len(disks) > 3 { - disks = disks[:3] - } - - return disks, nil -} - -var ( - // External disk cache. - lastDiskCacheAt time.Time - diskTypeCache = make(map[string]bool) - diskCacheTTL = 2 * time.Minute -) - -func annotateDiskTypes(disks []DiskStatus) { - if len(disks) == 0 || runtime.GOOS != "darwin" || !commandExists("diskutil") { - return - } - - now := time.Now() - // Clear stale cache. - if now.Sub(lastDiskCacheAt) > diskCacheTTL { - diskTypeCache = make(map[string]bool) - lastDiskCacheAt = now - } - - for i := range disks { - base := baseDeviceName(disks[i].Device) - if base == "" { - base = disks[i].Device - } - - if val, ok := diskTypeCache[base]; ok { - disks[i].External = val - continue - } - - external, err := isExternalDisk(base) - if err != nil { - external = strings.HasPrefix(disks[i].Mount, "/Volumes/") - } - disks[i].External = external - diskTypeCache[base] = external - } -} - -func baseDeviceName(device string) string { - device = strings.TrimPrefix(device, "/dev/") - if !strings.HasPrefix(device, "disk") { - return device - } - for i := 4; i < len(device); i++ { - if device[i] == 's' { - return device[:i] - } - } - return device -} - -func isExternalDisk(device string) (bool, error) { - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - - out, err := runCmd(ctx, "diskutil", "info", device) - if err != nil { - return false, err - } - var ( - found bool - external bool - ) - for line := range strings.Lines(out) { - trim := strings.TrimSpace(line) - if strings.HasPrefix(trim, "Internal:") { - found = true - external = strings.Contains(trim, "No") - break - } - if strings.HasPrefix(trim, "Device Location:") { - found = true - external = strings.Contains(trim, "External") - } - } - if !found { - return false, errors.New("diskutil info missing Internal field") - } - return external, nil -} - -func (c *Collector) collectDiskIO(now time.Time) DiskIOStatus { - counters, err := disk.IOCounters() - if err != nil || len(counters) == 0 { - return DiskIOStatus{} - } - - var total disk.IOCountersStat - for _, v := range counters { - total.ReadBytes += v.ReadBytes - total.WriteBytes += v.WriteBytes - } - - if c.lastDiskAt.IsZero() { - c.prevDiskIO = total - c.lastDiskAt = now - return DiskIOStatus{} - } - - elapsed := now.Sub(c.lastDiskAt).Seconds() - if elapsed <= 0 { - elapsed = 1 - } - - readRate := float64(total.ReadBytes-c.prevDiskIO.ReadBytes) / 1024 / 1024 / elapsed - writeRate := float64(total.WriteBytes-c.prevDiskIO.WriteBytes) / 1024 / 1024 / elapsed - - c.prevDiskIO = total - c.lastDiskAt = now - - if readRate < 0 { - readRate = 0 - } - if writeRate < 0 { - writeRate = 0 - } - - return DiskIOStatus{ReadRate: readRate, WriteRate: writeRate} -} diff --git a/cmd/status/metrics_gpu.go b/cmd/status/metrics_gpu.go deleted file mode 100644 index bb60235..0000000 --- a/cmd/status/metrics_gpu.go +++ /dev/null @@ -1,184 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "errors" - "regexp" - "runtime" - "strconv" - "strings" - "time" -) - -const ( - systemProfilerTimeout = 4 * time.Second - macGPUInfoTTL = 10 * time.Minute - powermetricsTimeout = 2 * time.Second -) - -// Regex for GPU usage parsing. -var ( - gpuActiveResidencyRe = regexp.MustCompile(`GPU HW active residency:\s+([\d.]+)%`) - gpuIdleResidencyRe = regexp.MustCompile(`GPU idle residency:\s+([\d.]+)%`) -) - -func (c *Collector) collectGPU(now time.Time) ([]GPUStatus, error) { - if runtime.GOOS == "darwin" { - // Static GPU info (cached 10 min). - if len(c.cachedGPU) == 0 || c.lastGPUAt.IsZero() || now.Sub(c.lastGPUAt) >= macGPUInfoTTL { - if gpus, err := readMacGPUInfo(); err == nil && len(gpus) > 0 { - c.cachedGPU = gpus - c.lastGPUAt = now - } - } - - // Real-time GPU usage. - if len(c.cachedGPU) > 0 { - usage := getMacGPUUsage() - result := make([]GPUStatus, len(c.cachedGPU)) - copy(result, c.cachedGPU) - // Apply usage to first GPU (Apple Silicon). - if len(result) > 0 { - result[0].Usage = usage - } - return result, nil - } - } - - ctx, cancel := context.WithTimeout(context.Background(), 600*time.Millisecond) - defer cancel() - - if !commandExists("nvidia-smi") { - return []GPUStatus{{ - Name: "No GPU metrics available", - Note: "Install nvidia-smi or use platform-specific metrics", - }}, nil - } - - out, err := runCmd(ctx, "nvidia-smi", "--query-gpu=utilization.gpu,memory.used,memory.total,name", "--format=csv,noheader,nounits") - if err != nil { - return nil, err - } - - var gpus []GPUStatus - for line := range strings.Lines(strings.TrimSpace(out)) { - fields := strings.Split(line, ",") - if len(fields) < 4 { - continue - } - util, _ := strconv.ParseFloat(strings.TrimSpace(fields[0]), 64) - memUsed, _ := strconv.ParseFloat(strings.TrimSpace(fields[1]), 64) - memTotal, _ := strconv.ParseFloat(strings.TrimSpace(fields[2]), 64) - name := strings.TrimSpace(fields[3]) - - gpus = append(gpus, GPUStatus{ - Name: name, - Usage: util, - MemoryUsed: memUsed, - MemoryTotal: memTotal, - }) - } - - if len(gpus) == 0 { - return []GPUStatus{{ - Name: "GPU read failed", - Note: "Verify nvidia-smi availability", - }}, nil - } - - return gpus, nil -} - -func readMacGPUInfo() ([]GPUStatus, error) { - ctx, cancel := context.WithTimeout(context.Background(), systemProfilerTimeout) - defer cancel() - - if !commandExists("system_profiler") { - return nil, errors.New("system_profiler unavailable") - } - - out, err := runCmd(ctx, "system_profiler", "-json", "SPDisplaysDataType") - if err != nil { - return nil, err - } - - var data struct { - Displays []struct { - Name string `json:"_name"` - VRAM string `json:"spdisplays_vram"` - Vendor string `json:"spdisplays_vendor"` - Metal string `json:"spdisplays_metal"` - Cores string `json:"sppci_cores"` - } `json:"SPDisplaysDataType"` - } - if err := json.Unmarshal([]byte(out), &data); err != nil { - return nil, err - } - - var gpus []GPUStatus - for _, d := range data.Displays { - if d.Name == "" { - continue - } - noteParts := []string{} - if d.VRAM != "" { - noteParts = append(noteParts, "VRAM "+d.VRAM) - } - if d.Metal != "" { - noteParts = append(noteParts, d.Metal) - } - if d.Vendor != "" { - noteParts = append(noteParts, d.Vendor) - } - note := strings.Join(noteParts, " · ") - coreCount, _ := strconv.Atoi(d.Cores) - gpus = append(gpus, GPUStatus{ - Name: d.Name, - Usage: -1, // Will be updated with real-time data - CoreCount: coreCount, - Note: note, - }) - } - - if len(gpus) == 0 { - return []GPUStatus{{ - Name: "GPU info unavailable", - Note: "Unable to parse system_profiler output", - }}, nil - } - - return gpus, nil -} - -// getMacGPUUsage reads GPU active residency from powermetrics. -func getMacGPUUsage() float64 { - ctx, cancel := context.WithTimeout(context.Background(), powermetricsTimeout) - defer cancel() - - // powermetrics may require root. - out, err := runCmd(ctx, "powermetrics", "--samplers", "gpu_power", "-i", "500", "-n", "1") - if err != nil { - return -1 - } - - // Parse "GPU HW active residency: X.XX%". - matches := gpuActiveResidencyRe.FindStringSubmatch(out) - if len(matches) >= 2 { - usage, err := strconv.ParseFloat(matches[1], 64) - if err == nil { - return usage - } - } - - // Fallback: parse idle residency and derive active. - matchesIdle := gpuIdleResidencyRe.FindStringSubmatch(out) - if len(matchesIdle) >= 2 { - idle, err := strconv.ParseFloat(matchesIdle[1], 64) - if err == nil { - return 100.0 - idle - } - } - - return -1 -} diff --git a/cmd/status/metrics_hardware.go b/cmd/status/metrics_hardware.go deleted file mode 100644 index 54ff7ba..0000000 --- a/cmd/status/metrics_hardware.go +++ /dev/null @@ -1,137 +0,0 @@ -package main - -import ( - "context" - "fmt" - "runtime" - "strings" - "time" -) - -func collectHardware(totalRAM uint64, disks []DiskStatus) HardwareInfo { - if runtime.GOOS != "darwin" { - return HardwareInfo{ - Model: "Unknown", - CPUModel: runtime.GOARCH, - TotalRAM: humanBytes(totalRAM), - DiskSize: "Unknown", - OSVersion: runtime.GOOS, - RefreshRate: "", - } - } - - // Model and CPU from system_profiler. - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - - var model, cpuModel, osVersion, refreshRate string - - out, err := runCmd(ctx, "system_profiler", "SPHardwareDataType") - if err == nil { - for line := range strings.Lines(out) { - lower := strings.ToLower(strings.TrimSpace(line)) - // Prefer "Model Name" over "Model Identifier". - if strings.Contains(lower, "model name:") { - parts := strings.Split(line, ":") - if len(parts) == 2 { - model = strings.TrimSpace(parts[1]) - } - } - if strings.Contains(lower, "chip:") { - parts := strings.Split(line, ":") - if len(parts) == 2 { - cpuModel = strings.TrimSpace(parts[1]) - } - } - if strings.Contains(lower, "processor name:") && cpuModel == "" { - parts := strings.Split(line, ":") - if len(parts) == 2 { - cpuModel = strings.TrimSpace(parts[1]) - } - } - } - } - - ctx2, cancel2 := context.WithTimeout(context.Background(), 1*time.Second) - defer cancel2() - out2, err := runCmd(ctx2, "sw_vers", "-productVersion") - if err == nil { - osVersion = "macOS " + strings.TrimSpace(out2) - } - - // Get refresh rate from display info (use mini detail to keep it fast). - ctx3, cancel3 := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel3() - out3, err := runCmd(ctx3, "system_profiler", "-detailLevel", "mini", "SPDisplaysDataType") - if err == nil { - refreshRate = parseRefreshRate(out3) - } - - diskSize := "Unknown" - if len(disks) > 0 { - diskSize = humanBytes(disks[0].Total) - } - - return HardwareInfo{ - Model: model, - CPUModel: cpuModel, - TotalRAM: humanBytes(totalRAM), - DiskSize: diskSize, - OSVersion: osVersion, - RefreshRate: refreshRate, - } -} - -// parseRefreshRate extracts the highest refresh rate from system_profiler display output. -func parseRefreshRate(output string) string { - maxHz := 0 - - for line := range strings.Lines(output) { - lower := strings.ToLower(line) - // Look for patterns like "@ 60Hz", "@ 60.00Hz", or "Refresh Rate: 120 Hz". - if strings.Contains(lower, "hz") { - fields := strings.Fields(lower) - for i, field := range fields { - if field == "hz" && i > 0 { - if hz := parseInt(fields[i-1]); hz > maxHz && hz < 500 { - maxHz = hz - } - continue - } - if numStr, ok := strings.CutSuffix(field, "hz"); ok { - if numStr == "" && i > 0 { - numStr = fields[i-1] - } - if hz := parseInt(numStr); hz > maxHz && hz < 500 { - maxHz = hz - } - } - } - } - } - - if maxHz > 0 { - return fmt.Sprintf("%dHz", maxHz) - } - return "" -} - -// parseInt safely parses an integer from a string. -func parseInt(s string) int { - // Trim away non-numeric padding, keep digits and '.' for decimals. - cleaned := strings.TrimSpace(s) - cleaned = strings.TrimLeftFunc(cleaned, func(r rune) bool { - return (r < '0' || r > '9') && r != '.' - }) - cleaned = strings.TrimRightFunc(cleaned, func(r rune) bool { - return (r < '0' || r > '9') && r != '.' - }) - if cleaned == "" { - return 0 - } - var num int - if _, err := fmt.Sscanf(cleaned, "%d", &num); err != nil { - return 0 - } - return num -} diff --git a/cmd/status/metrics_health.go b/cmd/status/metrics_health.go deleted file mode 100644 index 0e34828..0000000 --- a/cmd/status/metrics_health.go +++ /dev/null @@ -1,168 +0,0 @@ -package main - -import ( - "fmt" - "strings" -) - -// Health score weights and thresholds. -const ( - // Weights. - healthCPUWeight = 30.0 - healthMemWeight = 25.0 - healthDiskWeight = 20.0 - healthThermalWeight = 15.0 - healthIOWeight = 10.0 - - // CPU. - cpuNormalThreshold = 30.0 - cpuHighThreshold = 70.0 - - // Memory. - memNormalThreshold = 50.0 - memHighThreshold = 80.0 - memPressureWarnPenalty = 5.0 - memPressureCritPenalty = 15.0 - - // Disk. - diskWarnThreshold = 70.0 - diskCritThreshold = 90.0 - - // Thermal. - thermalNormalThreshold = 60.0 - thermalHighThreshold = 85.0 - - // Disk IO (MB/s). - ioNormalThreshold = 50.0 - ioHighThreshold = 150.0 -) - -func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, diskIO DiskIOStatus, thermal ThermalStatus) (int, string) { - score := 100.0 - issues := []string{} - - // CPU penalty. - cpuPenalty := 0.0 - if cpu.Usage > cpuNormalThreshold { - if cpu.Usage > cpuHighThreshold { - cpuPenalty = healthCPUWeight * (cpu.Usage - cpuNormalThreshold) / cpuHighThreshold - } else { - cpuPenalty = (healthCPUWeight / 2) * (cpu.Usage - cpuNormalThreshold) / (cpuHighThreshold - cpuNormalThreshold) - } - } - score -= cpuPenalty - if cpu.Usage > cpuHighThreshold { - issues = append(issues, "High CPU") - } - - // Memory penalty. - memPenalty := 0.0 - if mem.UsedPercent > memNormalThreshold { - if mem.UsedPercent > memHighThreshold { - memPenalty = healthMemWeight * (mem.UsedPercent - memNormalThreshold) / memNormalThreshold - } else { - memPenalty = (healthMemWeight / 2) * (mem.UsedPercent - memNormalThreshold) / (memHighThreshold - memNormalThreshold) - } - } - score -= memPenalty - if mem.UsedPercent > memHighThreshold { - issues = append(issues, "High Memory") - } - - // Memory pressure penalty. - // Memory pressure penalty. - switch mem.Pressure { - case "warn": - score -= memPressureWarnPenalty - issues = append(issues, "Memory Pressure") - case "critical": - score -= memPressureCritPenalty - issues = append(issues, "Critical Memory") - } - - // Disk penalty. - diskPenalty := 0.0 - if len(disks) > 0 { - diskUsage := disks[0].UsedPercent - if diskUsage > diskWarnThreshold { - if diskUsage > diskCritThreshold { - diskPenalty = healthDiskWeight * (diskUsage - diskWarnThreshold) / (100 - diskWarnThreshold) - } else { - diskPenalty = (healthDiskWeight / 2) * (diskUsage - diskWarnThreshold) / (diskCritThreshold - diskWarnThreshold) - } - } - score -= diskPenalty - if diskUsage > diskCritThreshold { - issues = append(issues, "Disk Almost Full") - } - } - - // Thermal penalty. - thermalPenalty := 0.0 - if thermal.CPUTemp > 0 { - if thermal.CPUTemp > thermalNormalThreshold { - if thermal.CPUTemp > thermalHighThreshold { - thermalPenalty = healthThermalWeight - issues = append(issues, "Overheating") - } else { - thermalPenalty = healthThermalWeight * (thermal.CPUTemp - thermalNormalThreshold) / (thermalHighThreshold - thermalNormalThreshold) - } - } - score -= thermalPenalty - } - - // Disk IO penalty. - ioPenalty := 0.0 - totalIO := diskIO.ReadRate + diskIO.WriteRate - if totalIO > ioNormalThreshold { - if totalIO > ioHighThreshold { - ioPenalty = healthIOWeight - issues = append(issues, "Heavy Disk IO") - } else { - ioPenalty = healthIOWeight * (totalIO - ioNormalThreshold) / (ioHighThreshold - ioNormalThreshold) - } - } - score -= ioPenalty - - // Clamp score. - if score < 0 { - score = 0 - } - if score > 100 { - score = 100 - } - - // Build message. - var msg string - switch { - case score >= 90: - msg = "Excellent" - case score >= 75: - msg = "Good" - case score >= 60: - msg = "Fair" - case score >= 40: - msg = "Poor" - default: - msg = "Critical" - } - - if len(issues) > 0 { - msg = msg + ": " + strings.Join(issues, ", ") - } - - return int(score), msg -} - -func formatUptime(secs uint64) string { - days := secs / 86400 - hours := (secs % 86400) / 3600 - mins := (secs % 3600) / 60 - if days > 0 { - return fmt.Sprintf("%dd %dh %dm", days, hours, mins) - } - if hours > 0 { - return fmt.Sprintf("%dh %dm", hours, mins) - } - return fmt.Sprintf("%dm", mins) -} diff --git a/cmd/status/metrics_health_test.go b/cmd/status/metrics_health_test.go deleted file mode 100644 index b5b4f8b..0000000 --- a/cmd/status/metrics_health_test.go +++ /dev/null @@ -1,58 +0,0 @@ -package main - -import ( - "strings" - "testing" -) - -func TestCalculateHealthScorePerfect(t *testing.T) { - score, msg := calculateHealthScore( - CPUStatus{Usage: 10}, - MemoryStatus{UsedPercent: 20, Pressure: "normal"}, - []DiskStatus{{UsedPercent: 30}}, - DiskIOStatus{ReadRate: 5, WriteRate: 5}, - ThermalStatus{CPUTemp: 40}, - ) - - if score != 100 { - t.Fatalf("expected perfect score 100, got %d", score) - } - if msg != "Excellent" { - t.Fatalf("unexpected message %q", msg) - } -} - -func TestCalculateHealthScoreDetectsIssues(t *testing.T) { - score, msg := calculateHealthScore( - CPUStatus{Usage: 95}, - MemoryStatus{UsedPercent: 90, Pressure: "critical"}, - []DiskStatus{{UsedPercent: 95}}, - DiskIOStatus{ReadRate: 120, WriteRate: 80}, - ThermalStatus{CPUTemp: 90}, - ) - - if score >= 40 { - t.Fatalf("expected heavy penalties bringing score down, got %d", score) - } - if msg == "Excellent" { - t.Fatalf("expected message to include issues, got %q", msg) - } - if !strings.Contains(msg, "High CPU") { - t.Fatalf("message should mention CPU issue: %q", msg) - } - if !strings.Contains(msg, "Disk Almost Full") { - t.Fatalf("message should mention disk issue: %q", msg) - } -} - -func TestFormatUptime(t *testing.T) { - if got := formatUptime(65); got != "1m" { - t.Fatalf("expected 1m, got %s", got) - } - 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) - } -} diff --git a/cmd/status/metrics_memory.go b/cmd/status/metrics_memory.go deleted file mode 100644 index 6cdd021..0000000 --- a/cmd/status/metrics_memory.go +++ /dev/null @@ -1,99 +0,0 @@ -package main - -import ( - "context" - "runtime" - "strconv" - "strings" - "time" - - "github.com/shirou/gopsutil/v3/mem" -) - -func collectMemory() (MemoryStatus, error) { - vm, err := mem.VirtualMemory() - if err != nil { - return MemoryStatus{}, err - } - - swap, _ := mem.SwapMemory() - pressure := getMemoryPressure() - - // On macOS, vm.Cached is 0, so we calculate from file-backed pages. - cached := vm.Cached - if runtime.GOOS == "darwin" && cached == 0 { - cached = getFileBackedMemory() - } - - return MemoryStatus{ - Used: vm.Used, - Total: vm.Total, - UsedPercent: vm.UsedPercent, - SwapUsed: swap.Used, - SwapTotal: swap.Total, - Cached: cached, - Pressure: pressure, - }, nil -} - -func getFileBackedMemory() uint64 { - ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) - defer cancel() - out, err := runCmd(ctx, "vm_stat") - if err != nil { - return 0 - } - - // Parse page size from first line: "Mach Virtual Memory Statistics: (page size of 16384 bytes)" - var pageSize uint64 = 4096 // Default - firstLine := true - for line := range strings.Lines(out) { - if firstLine { - firstLine = false - if strings.Contains(line, "page size of") { - if _, after, found := strings.Cut(line, "page size of "); found { - if before, _, found := strings.Cut(after, " bytes"); found { - if size, err := strconv.ParseUint(strings.TrimSpace(before), 10, 64); err == nil { - pageSize = size - } - } - } - } - } - - // Parse "File-backed pages: 388975." - if strings.Contains(line, "File-backed pages:") { - if _, after, found := strings.Cut(line, ":"); found { - numStr := strings.TrimSpace(after) - numStr = strings.TrimSuffix(numStr, ".") - if pages, err := strconv.ParseUint(numStr, 10, 64); err == nil { - return pages * pageSize - } - } - } - } - return 0 -} - -func getMemoryPressure() string { - if runtime.GOOS != "darwin" { - return "" - } - ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) - defer cancel() - out, err := runCmd(ctx, "memory_pressure") - if err != nil { - return "" - } - lower := strings.ToLower(out) - if strings.Contains(lower, "critical") { - return "critical" - } - if strings.Contains(lower, "warn") { - return "warn" - } - if strings.Contains(lower, "normal") { - return "normal" - } - return "" -} diff --git a/cmd/status/metrics_network.go b/cmd/status/metrics_network.go deleted file mode 100644 index 00c94e6..0000000 --- a/cmd/status/metrics_network.go +++ /dev/null @@ -1,142 +0,0 @@ -package main - -import ( - "context" - "os" - "runtime" - "sort" - "strings" - "time" - - "github.com/shirou/gopsutil/v3/net" -) - -func (c *Collector) collectNetwork(now time.Time) ([]NetworkStatus, error) { - stats, err := net.IOCounters(true) - if err != nil { - return nil, err - } - - // Map interface IPs. - ifAddrs := getInterfaceIPs() - - if c.lastNetAt.IsZero() { - c.lastNetAt = now - for _, s := range stats { - c.prevNet[s.Name] = s - } - return nil, nil - } - - elapsed := now.Sub(c.lastNetAt).Seconds() - if elapsed <= 0 { - elapsed = 1 - } - - var result []NetworkStatus - for _, cur := range stats { - if isNoiseInterface(cur.Name) { - continue - } - prev, ok := c.prevNet[cur.Name] - if !ok { - continue - } - rx := float64(cur.BytesRecv-prev.BytesRecv) / 1024.0 / 1024.0 / elapsed - tx := float64(cur.BytesSent-prev.BytesSent) / 1024.0 / 1024.0 / elapsed - if rx < 0 { - rx = 0 - } - if tx < 0 { - tx = 0 - } - result = append(result, NetworkStatus{ - Name: cur.Name, - RxRateMBs: rx, - TxRateMBs: tx, - IP: ifAddrs[cur.Name], - }) - } - - c.lastNetAt = now - for _, s := range stats { - c.prevNet[s.Name] = s - } - - sort.Slice(result, func(i, j int) bool { - return result[i].RxRateMBs+result[i].TxRateMBs > result[j].RxRateMBs+result[j].TxRateMBs - }) - if len(result) > 3 { - result = result[:3] - } - - return result, nil -} - -func getInterfaceIPs() map[string]string { - result := make(map[string]string) - ifaces, err := net.Interfaces() - if err != nil { - return result - } - for _, iface := range ifaces { - for _, addr := range iface.Addrs { - // IPv4 only. - if strings.Contains(addr.Addr, ".") && !strings.HasPrefix(addr.Addr, "127.") { - ip := strings.Split(addr.Addr, "/")[0] - result[iface.Name] = ip - break - } - } - } - return result -} - -func isNoiseInterface(name string) bool { - lower := strings.ToLower(name) - noiseList := []string{"lo", "awdl", "utun", "llw", "bridge", "gif", "stf", "xhc", "anpi", "ap"} - for _, prefix := range noiseList { - if strings.HasPrefix(lower, prefix) { - return true - } - } - return false -} - -func collectProxy() ProxyStatus { - // Check environment variables first. - for _, env := range []string{"https_proxy", "HTTPS_PROXY", "http_proxy", "HTTP_PROXY"} { - if val := os.Getenv(env); val != "" { - proxyType := "HTTP" - if strings.HasPrefix(val, "socks") { - proxyType = "SOCKS" - } - // Extract host. - host := val - if strings.Contains(host, "://") { - host = strings.SplitN(host, "://", 2)[1] - } - if idx := strings.Index(host, "@"); idx >= 0 { - host = host[idx+1:] - } - return ProxyStatus{Enabled: true, Type: proxyType, Host: host} - } - } - - // macOS: check system proxy via scutil. - if runtime.GOOS == "darwin" { - ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) - defer cancel() - out, err := runCmd(ctx, "scutil", "--proxy") - if err == nil { - if strings.Contains(out, "HTTPEnable : 1") || strings.Contains(out, "HTTPSEnable : 1") { - return ProxyStatus{Enabled: true, Type: "System", Host: "System Proxy"} - } - if strings.Contains(out, "SOCKSEnable : 1") { - return ProxyStatus{Enabled: true, Type: "SOCKS", Host: "System Proxy"} - } - } - } - - return ProxyStatus{Enabled: false} -} diff --git a/cmd/status/metrics_process.go b/cmd/status/metrics_process.go deleted file mode 100644 index b11f25c..0000000 --- a/cmd/status/metrics_process.go +++ /dev/null @@ -1,53 +0,0 @@ -package main - -import ( - "context" - "runtime" - "strconv" - "strings" - "time" -) - -func collectTopProcesses() []ProcessInfo { - if runtime.GOOS != "darwin" { - return nil - } - ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) - defer cancel() - - // Use ps to get top processes by CPU. - out, err := runCmd(ctx, "ps", "-Aceo", "pcpu,pmem,comm", "-r") - if err != nil { - return nil - } - - var procs []ProcessInfo - i := 0 - for line := range strings.Lines(strings.TrimSpace(out)) { - if i == 0 { - i++ - continue - } - if i > 5 { - break - } - i++ - fields := strings.Fields(line) - if len(fields) < 3 { - continue - } - cpuVal, _ := strconv.ParseFloat(fields[0], 64) - memVal, _ := strconv.ParseFloat(fields[1], 64) - name := fields[len(fields)-1] - // Strip path from command name. - if idx := strings.LastIndex(name, "/"); idx >= 0 { - name = name[idx+1:] - } - procs = append(procs, ProcessInfo{ - Name: name, - CPU: cpuVal, - Memory: memVal, - }) - } - return procs -} diff --git a/cmd/status/view.go b/cmd/status/view.go deleted file mode 100644 index 7ef56cd..0000000 --- a/cmd/status/view.go +++ /dev/null @@ -1,758 +0,0 @@ -package main - -import ( - "fmt" - "sort" - "strconv" - "strings" - - "github.com/charmbracelet/lipgloss" -) - -var ( - titleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#C79FD7")).Bold(true) - subtleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#737373")) - warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD75F")) - dangerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5F5F")).Bold(true) - okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#A5D6A7")) - lineStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#404040")) - - primaryStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#BD93F9")) -) - -const ( - colWidth = 38 - iconCPU = "◉" - iconMemory = "◫" - iconGPU = "◧" - iconDisk = "▥" - iconNetwork = "⇅" - iconBattery = "◪" - iconSensors = "◈" - iconProcs = "❊" -) - -// Mole body frames (facing right). -var moleBody = [][]string{ - { - ` /\_/\`, - ` ___/ o o \`, - `/___ =-= /`, - `\____)-m-m)`, - }, - { - ` /\_/\`, - ` ___/ o o \`, - `/___ =-= /`, - `\____)mm__)`, - }, - { - ` /\_/\`, - ` ___/ · · \`, - `/___ =-= /`, - `\___)-m__m)`, - }, - { - ` /\_/\`, - ` ___/ o o \`, - `/___ =-= /`, - `\____)-mm-)`, - }, -} - -// Mirror mole body frames (facing left). -var moleBodyMirror = [][]string{ - { - ` /\_/\`, - ` / o o \___`, - ` \ =-= ___\`, - ` (m-m-(____/`, - }, - { - ` /\_/\`, - ` / o o \___`, - ` \ =-= ___\`, - ` (__mm(____/`, - }, - { - ` /\_/\`, - ` / · · \___`, - ` \ =-= ___\`, - ` (m__m-(___/`, - }, - { - ` /\_/\`, - ` / o o \___`, - ` \ =-= ___\`, - ` (-mm-(____/`, - }, -} - -// getMoleFrame renders the animated mole. -func getMoleFrame(animFrame int, termWidth int) string { - moleWidth := 15 - maxPos := max(termWidth-moleWidth, 0) - - cycleLength := maxPos * 2 - if cycleLength == 0 { - cycleLength = 1 - } - pos := animFrame % cycleLength - movingLeft := pos > maxPos - if movingLeft { - pos = cycleLength - pos - } - - // Use mirror frames when moving left - var frames [][]string - if movingLeft { - frames = moleBodyMirror - } else { - frames = moleBody - } - - bodyIdx := animFrame % len(frames) - body := frames[bodyIdx] - - padding := strings.Repeat(" ", pos) - var lines []string - - for _, line := range body { - lines = append(lines, padding+line) - } - - return strings.Join(lines, "\n") -} - -type cardData struct { - icon string - title string - lines []string -} - -func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int, catHidden bool) string { - title := titleStyle.Render("Mole Status") - - scoreStyle := getScoreStyle(m.HealthScore) - scoreText := subtleStyle.Render("Health ") + scoreStyle.Render(fmt.Sprintf("● %d", m.HealthScore)) - - // Hardware info for a single line. - infoParts := []string{} - if m.Hardware.Model != "" { - infoParts = append(infoParts, primaryStyle.Render(m.Hardware.Model)) - } - if m.Hardware.CPUModel != "" { - 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) - } - infoParts = append(infoParts, cpuInfo) - } - var specs []string - if m.Hardware.TotalRAM != "" { - specs = append(specs, m.Hardware.TotalRAM) - } - if m.Hardware.DiskSize != "" { - specs = append(specs, m.Hardware.DiskSize) - } - if len(specs) > 0 { - infoParts = append(infoParts, strings.Join(specs, "/")) - } - if m.Hardware.RefreshRate != "" { - infoParts = append(infoParts, m.Hardware.RefreshRate) - } - if m.Hardware.OSVersion != "" { - infoParts = append(infoParts, m.Hardware.OSVersion) - } - - headerLine := title + " " + scoreText + " " + strings.Join(infoParts, " · ") - - // Show cat unless hidden - var mole string - if !catHidden { - mole = getMoleFrame(animFrame, termWidth) - } - - if errMsg != "" { - if mole == "" { - return lipgloss.JoinVertical(lipgloss.Left, headerLine, "", dangerStyle.Render("ERROR: "+errMsg), "") - } - return lipgloss.JoinVertical(lipgloss.Left, headerLine, "", mole, dangerStyle.Render("ERROR: "+errMsg), "") - } - if mole == "" { - return headerLine - } - return headerLine + "\n" + mole -} - -func getScoreStyle(score int) lipgloss.Style { - switch { - case score >= 90: - return lipgloss.NewStyle().Foreground(lipgloss.Color("#87FF87")).Bold(true) - case score >= 75: - return lipgloss.NewStyle().Foreground(lipgloss.Color("#87D787")).Bold(true) - case score >= 60: - return lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD75F")).Bold(true) - case score >= 40: - return lipgloss.NewStyle().Foreground(lipgloss.Color("#FFAF5F")).Bold(true) - default: - return lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Bold(true) - } -} - -func buildCards(m MetricsSnapshot, _ int) []cardData { - cards := []cardData{ - renderCPUCard(m.CPU), - renderMemoryCard(m.Memory), - renderDiskCard(m.Disks, m.DiskIO), - renderBatteryCard(m.Batteries, m.Thermal), - renderProcessCard(m.TopProcesses), - renderNetworkCard(m.Network, m.Proxy), - } - if hasSensorData(m.Sensors) { - cards = append(cards, renderSensorsCard(m.Sensors)) - } - return cards -} - -func hasSensorData(sensors []SensorReading) bool { - for _, s := range sensors { - if s.Note == "" && s.Value > 0 { - return true - } - } - return false -} - -func renderCPUCard(cpu CPUStatus) cardData { - var lines []string - lines = append(lines, fmt.Sprintf("Total %s %5.1f%%", progressBar(cpu.Usage), cpu.Usage)) - - if cpu.PerCoreEstimated { - lines = append(lines, subtleStyle.Render("Per-core data unavailable (using averaged load)")) - } else if len(cpu.PerCore) > 0 { - type coreUsage struct { - idx int - val float64 - } - var cores []coreUsage - for i, v := range cpu.PerCore { - cores = append(cores, coreUsage{i, v}) - } - sort.Slice(cores, func(i, j int) bool { return cores[i].val > cores[j].val }) - - maxCores := min(len(cores), 3) - for i := 0; i < maxCores; i++ { - c := cores[i] - lines = append(lines, fmt.Sprintf("Core%-2d %s %5.1f%%", c.idx+1, progressBar(c.val), c.val)) - } - } - - // Load line at the end - if cpu.PCoreCount > 0 && cpu.ECoreCount > 0 { - 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)", - cpu.Load1, cpu.Load5, cpu.Load15, cpu.LogicalCPU)) - } - - return cardData{icon: iconCPU, title: "CPU", lines: lines} -} - -func renderMemoryCard(mem MemoryStatus) cardData { - // Check if swap is being used (or at least allocated). - hasSwap := mem.SwapTotal > 0 || mem.SwapUsed > 0 - - var lines []string - // Line 1: Used - lines = append(lines, fmt.Sprintf("Used %s %5.1f%%", progressBar(mem.UsedPercent), mem.UsedPercent)) - - // Line 2: Free - freePercent := 100 - mem.UsedPercent - lines = append(lines, fmt.Sprintf("Free %s %5.1f%%", progressBar(freePercent), freePercent)) - - if hasSwap { - // Layout with Swap: - // 3. Swap (progress bar + text) - // 4. Total - // 5. Avail - var swapPercent float64 - if mem.SwapTotal > 0 { - swapPercent = (float64(mem.SwapUsed) / float64(mem.SwapTotal)) * 100.0 - } - 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))) - lines = append(lines, fmt.Sprintf("Avail %s", humanBytes(mem.Total-mem.Used))) // Simplified avail logic for consistency - } else { - // Layout without Swap: - // 3. Total - // 4. Cached (if > 0) - // 5. Avail - lines = append(lines, fmt.Sprintf("Total %s / %s", humanBytes(mem.Used), humanBytes(mem.Total))) - - if mem.Cached > 0 { - lines = append(lines, fmt.Sprintf("Cached %s", humanBytes(mem.Cached))) - } - // Calculate available if not provided directly, or use Total-Used as proxy if needed, - // but typically available is more nuanced. Using what we have. - // Re-calculating available based on logic if needed, but mem.Total - mem.Used is often "Avail" - // in simple terms for this view or we could use the passed definition. - // Original code calculated: available := mem.Total - mem.Used - available := mem.Total - mem.Used - lines = append(lines, fmt.Sprintf("Avail %s", humanBytes(available))) - } - // Memory pressure status. - if mem.Pressure != "" { - pressureStyle := okStyle - pressureText := "Status " + mem.Pressure - switch mem.Pressure { - case "warn": - pressureStyle = warnStyle - case "critical": - pressureStyle = dangerStyle - } - lines = append(lines, pressureStyle.Render(pressureText)) - } - return cardData{icon: iconMemory, title: "Memory", lines: lines} -} - -func renderDiskCard(disks []DiskStatus, io DiskIOStatus) cardData { - var lines []string - if len(disks) == 0 { - lines = append(lines, subtleStyle.Render("Collecting...")) - } else { - internal, external := splitDisks(disks) - addGroup := func(prefix string, list []DiskStatus) { - if len(list) == 0 { - return - } - for i, d := range list { - label := diskLabel(prefix, i, len(list)) - lines = append(lines, formatDiskLine(label, d)) - } - } - addGroup("INTR", internal) - addGroup("EXTR", external) - if len(lines) == 0 { - lines = append(lines, subtleStyle.Render("No disks detected")) - } - } - readBar := ioBar(io.ReadRate) - writeBar := ioBar(io.WriteRate) - lines = append(lines, fmt.Sprintf("Read %s %.1f MB/s", readBar, io.ReadRate)) - lines = append(lines, fmt.Sprintf("Write %s %.1f MB/s", writeBar, io.WriteRate)) - return cardData{icon: iconDisk, title: "Disk", lines: lines} -} - -func splitDisks(disks []DiskStatus) (internal, external []DiskStatus) { - for _, d := range disks { - if d.External { - external = append(external, d) - } else { - internal = append(internal, d) - } - } - return internal, external -} - -func diskLabel(prefix string, index int, total int) string { - if total <= 1 { - return prefix - } - return fmt.Sprintf("%s%d", prefix, index+1) -} - -func formatDiskLine(label string, d DiskStatus) string { - if label == "" { - label = "DISK" - } - 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) -} - -func ioBar(rate float64) string { - filled := min(int(rate/10.0), 5) - if filled < 0 { - filled = 0 - } - bar := strings.Repeat("▮", filled) + strings.Repeat("▯", 5-filled) - if rate > 80 { - return dangerStyle.Render(bar) - } - if rate > 30 { - return warnStyle.Render(bar) - } - return okStyle.Render(bar) -} - -func renderProcessCard(procs []ProcessInfo) cardData { - var lines []string - maxProcs := 3 - for i, p := range procs { - if i >= maxProcs { - break - } - name := shorten(p.Name, 12) - cpuBar := miniBar(p.CPU) - lines = append(lines, fmt.Sprintf("%-12s %s %5.1f%%", name, cpuBar, p.CPU)) - } - if len(lines) == 0 { - lines = append(lines, subtleStyle.Render("No data")) - } - return cardData{icon: iconProcs, title: "Processes", lines: lines} -} - -func miniBar(percent float64) string { - filled := min(int(percent/20), 5) - if filled < 0 { - filled = 0 - } - return colorizePercent(percent, strings.Repeat("▮", filled)+strings.Repeat("▯", 5-filled)) -} - -func renderNetworkCard(netStats []NetworkStatus, proxy ProxyStatus) cardData { - var lines []string - var totalRx, totalTx float64 - var primaryIP string - - for _, n := range netStats { - totalRx += n.RxRateMBs - totalTx += n.TxRateMBs - if primaryIP == "" && n.IP != "" && n.Name == "en0" { - primaryIP = n.IP - } - } - - if len(netStats) == 0 { - lines = []string{subtleStyle.Render("Collecting...")} - } else { - rxBar := netBar(totalRx) - txBar := netBar(totalTx) - lines = append(lines, fmt.Sprintf("Down %s %s", rxBar, formatRate(totalRx))) - lines = append(lines, fmt.Sprintf("Up %s %s", txBar, formatRate(totalTx))) - // Show proxy and IP on one line. - var infoParts []string - if proxy.Enabled { - infoParts = append(infoParts, "Proxy "+proxy.Type) - } - if primaryIP != "" { - infoParts = append(infoParts, primaryIP) - } - if len(infoParts) > 0 { - lines = append(lines, strings.Join(infoParts, " · ")) - } - } - return cardData{icon: iconNetwork, title: "Network", lines: lines} -} - -func netBar(rate float64) string { - filled := min(int(rate/2.0), 5) - if filled < 0 { - filled = 0 - } - bar := strings.Repeat("▮", filled) + strings.Repeat("▯", 5-filled) - if rate > 8 { - return dangerStyle.Render(bar) - } - if rate > 3 { - return warnStyle.Render(bar) - } - return okStyle.Render(bar) -} - -func renderBatteryCard(batts []BatteryStatus, thermal ThermalStatus) cardData { - var lines []string - if len(batts) == 0 { - lines = append(lines, subtleStyle.Render("No battery")) - } else { - b := batts[0] - statusLower := strings.ToLower(b.Status) - percentText := fmt.Sprintf("%5.1f%%", b.Percent) - if b.Percent < 20 && statusLower != "charging" && statusLower != "charged" { - percentText = dangerStyle.Render(percentText) - } - lines = append(lines, fmt.Sprintf("Level %s %s", batteryProgressBar(b.Percent), percentText)) - - // Add capacity line if available. - if b.Capacity > 0 { - capacityText := fmt.Sprintf("%5d%%", b.Capacity) - if b.Capacity < 70 { - capacityText = dangerStyle.Render(capacityText) - } else if b.Capacity < 85 { - capacityText = warnStyle.Render(capacityText) - } - lines = append(lines, fmt.Sprintf("Health %s %s", batteryProgressBar(float64(b.Capacity)), capacityText)) - } - - statusIcon := "" - statusStyle := subtleStyle - if statusLower == "charging" || statusLower == "charged" { - statusIcon = " ⚡" - statusStyle = okStyle - } else if b.Percent < 20 { - statusStyle = dangerStyle - } - statusText := b.Status - if len(statusText) > 0 { - statusText = strings.ToUpper(statusText[:1]) + strings.ToLower(statusText[1:]) - } - if b.TimeLeft != "" { - statusText += " · " + b.TimeLeft - } - // Add power info. - if statusLower == "charging" || statusLower == "charged" { - if thermal.SystemPower > 0 { - statusText += fmt.Sprintf(" · %.0fW", thermal.SystemPower) - } else if thermal.AdapterPower > 0 { - statusText += fmt.Sprintf(" · %.0fW Adapter", thermal.AdapterPower) - } - } else if thermal.BatteryPower > 0 { - // Only show battery power when discharging (positive value) - statusText += fmt.Sprintf(" · %.0fW", thermal.BatteryPower) - } - lines = append(lines, statusStyle.Render(statusText+statusIcon)) - - healthParts := []string{} - if b.Health != "" { - healthParts = append(healthParts, b.Health) - } - if b.CycleCount > 0 { - healthParts = append(healthParts, fmt.Sprintf("%d cycles", b.CycleCount)) - } - - if thermal.CPUTemp > 0 { - tempText := fmt.Sprintf("%.0f°C", thermal.CPUTemp) - if thermal.CPUTemp > 80 { - tempText = dangerStyle.Render(tempText) - } else if thermal.CPUTemp > 60 { - tempText = warnStyle.Render(tempText) - } - healthParts = append(healthParts, tempText) - } - - if thermal.FanSpeed > 0 { - healthParts = append(healthParts, fmt.Sprintf("%d RPM", thermal.FanSpeed)) - } - - if len(healthParts) > 0 { - lines = append(lines, strings.Join(healthParts, " · ")) - } - } - - 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) - header := titleStyle.Render(titleText) + " " + lineStyle.Render(strings.Repeat("╌", lineLen)) - content := header + "\n" + strings.Join(data.lines, "\n") - - lines := strings.Split(content, "\n") - for len(lines) < height { - lines = append(lines, "") - } - return strings.Join(lines, "\n") -} - -func progressBar(percent float64) string { - total := 16 - if percent < 0 { - percent = 0 - } - if percent > 100 { - percent = 100 - } - filled := int(percent / 100 * float64(total)) - - var builder strings.Builder - for i := range total { - if i < filled { - builder.WriteString("█") - } else { - builder.WriteString("░") - } - } - return colorizePercent(percent, builder.String()) -} - -func batteryProgressBar(percent float64) string { - total := 16 - if percent < 0 { - percent = 0 - } - if percent > 100 { - percent = 100 - } - filled := int(percent / 100 * float64(total)) - - var builder strings.Builder - for i := range total { - if i < filled { - builder.WriteString("█") - } else { - builder.WriteString("░") - } - } - return colorizeBattery(percent, builder.String()) -} - -func colorizePercent(percent float64, s string) string { - switch { - case percent >= 85: - return dangerStyle.Render(s) - case percent >= 60: - return warnStyle.Render(s) - default: - return okStyle.Render(s) - } -} - -func colorizeBattery(percent float64, s string) string { - switch { - case percent < 20: - return dangerStyle.Render(s) - case percent < 50: - return warnStyle.Render(s) - default: - return okStyle.Render(s) - } -} - -func colorizeTemp(t float64) string { - switch { - case t >= 85: - return dangerStyle.Render(fmt.Sprintf("%.1f", t)) - case t >= 70: - return warnStyle.Render(fmt.Sprintf("%.1f", t)) - default: - return subtleStyle.Render(fmt.Sprintf("%.1f", t)) - } -} - -func formatRate(mb float64) string { - if mb < 0.01 { - return "0 MB/s" - } - if mb < 1 { - return fmt.Sprintf("%.2f MB/s", mb) - } - if mb < 10 { - return fmt.Sprintf("%.1f MB/s", mb) - } - return fmt.Sprintf("%.0f MB/s", mb) -} - -func humanBytes(v uint64) string { - switch { - case v > 1<<40: - return fmt.Sprintf("%.1f TB", float64(v)/(1<<40)) - case v > 1<<30: - return fmt.Sprintf("%.1f GB", float64(v)/(1<<30)) - case v > 1<<20: - return fmt.Sprintf("%.1f MB", float64(v)/(1<<20)) - case v > 1<<10: - return fmt.Sprintf("%.1f KB", float64(v)/(1<<10)) - default: - return strconv.FormatUint(v, 10) + " B" - } -} - -func humanBytesShort(v uint64) string { - switch { - case v >= 1<<40: - return fmt.Sprintf("%.0fT", float64(v)/(1<<40)) - case v >= 1<<30: - return fmt.Sprintf("%.0fG", float64(v)/(1<<30)) - case v >= 1<<20: - return fmt.Sprintf("%.0fM", float64(v)/(1<<20)) - case v >= 1<<10: - return fmt.Sprintf("%.0fK", float64(v)/(1<<10)) - default: - return strconv.FormatUint(v, 10) - } -} - -func humanBytesCompact(v uint64) string { - switch { - case v >= 1<<40: - return fmt.Sprintf("%.1fT", float64(v)/(1<<40)) - case v >= 1<<30: - return fmt.Sprintf("%.1fG", float64(v)/(1<<30)) - case v >= 1<<20: - return fmt.Sprintf("%.1fM", float64(v)/(1<<20)) - case v >= 1<<10: - return fmt.Sprintf("%.1fK", float64(v)/(1<<10)) - default: - return strconv.FormatUint(v, 10) - } -} - -func shorten(s string, maxLen int) string { - if len(s) <= maxLen { - return s - } - return s[:maxLen-1] + "…" -} - -func renderTwoColumns(cards []cardData, width int) string { - if len(cards) == 0 { - return "" - } - cw := colWidth - if width > 0 && width/2-2 > cw { - cw = width/2 - 2 - } - var rows []string - for i := 0; i < len(cards); i += 2 { - left := renderCard(cards[i], cw, 0) - right := "" - if i+1 < len(cards) { - right = renderCard(cards[i+1], cw, 0) - } - targetHeight := maxInt(lipgloss.Height(left), lipgloss.Height(right)) - left = renderCard(cards[i], cw, targetHeight) - if right != "" { - right = renderCard(cards[i+1], cw, targetHeight) - rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right)) - } else { - rows = append(rows, left) - } - } - - var spacedRows []string - for i, r := range rows { - if i > 0 { - spacedRows = append(spacedRows, "") - } - spacedRows = append(spacedRows, r) - } - return lipgloss.JoinVertical(lipgloss.Left, spacedRows...) -} - -func maxInt(a, b int) int { - if a > b { - return a - } - return b -} diff --git a/go.mod b/go.mod index 70db580..5cb9f41 100644 --- a/go.mod +++ b/go.mod @@ -1,38 +1,10 @@ -module github.com/tw93/mole +module github.com/tw93/mole/windows go 1.24.0 -toolchain go1.24.6 - require ( - github.com/cespare/xxhash/v2 v2.3.0 github.com/charmbracelet/bubbletea v1.3.10 - github.com/charmbracelet/lipgloss v1.1.0 github.com/shirou/gopsutil/v3 v3.24.5 - golang.org/x/sync v0.19.0 -) - -require ( - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/x/ansi v0.10.1 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect - github.com/charmbracelet/x/term v0.2.1 // indirect - github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect - github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/termenv v0.16.0 // indirect - github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect - github.com/rivo/uniseg v0.4.7 // indirect - github.com/shoenig/go-m1cpu v0.1.7 // indirect - github.com/tklauser/go-sysconf v0.3.12 // indirect - github.com/tklauser/numcpus v0.6.1 // indirect - github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sys v0.36.0 // indirect - golang.org/x/text v0.3.8 // indirect + github.com/yusufpapurcu/wmi v1.2.4 + golang.org/x/sys v0.36.0 ) diff --git a/go.sum b/go.sum index d66a188..1fce446 100644 --- a/go.sum +++ b/go.sum @@ -1,23 +1,13 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= -github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= -github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= -github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= -github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM= +github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg= +github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg= +github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= @@ -27,25 +17,29 @@ github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= +github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= -github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= +github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= github.com/shoenig/go-m1cpu v0.1.7 h1:C76Yd0ObKR82W4vhfjZiCp0HxcSZ8Nqd84v+HZ0qyI0= @@ -58,22 +52,20 @@ github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFA github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= -golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/windows/install.ps1 b/install.ps1 similarity index 100% rename from windows/install.ps1 rename to install.ps1 diff --git a/install.sh b/install.sh deleted file mode 100755 index 13cbf8f..0000000 --- a/install.sh +++ /dev/null @@ -1,767 +0,0 @@ -#!/bin/bash -# Mole - Installer for manual installs. -# Fetches source/binaries and installs to prefix. -# Supports update and edge installs. - -set -euo pipefail - -GREEN='\033[0;32m' -BLUE='\033[0;34m' -YELLOW='\033[1;33m' -RED='\033[0;31m' -NC='\033[0m' - -_SPINNER_PID="" -start_line_spinner() { - local msg="$1" - [[ ! -t 1 ]] && { - echo -e "${BLUE}|${NC} $msg" - return - } - local chars="|/-\\" - [[ -z "$chars" ]] && chars='|/-\\' - local i=0 - (while true; do - c="${chars:$((i % ${#chars})):1}" - printf "\r${BLUE}%s${NC} %s" "$c" "$msg" - ((i++)) - sleep 0.12 - done) & - _SPINNER_PID=$! -} -stop_line_spinner() { if [[ -n "$_SPINNER_PID" ]]; then - kill "$_SPINNER_PID" 2> /dev/null || true - wait "$_SPINNER_PID" 2> /dev/null || true - _SPINNER_PID="" - printf "\r\033[K" -fi; } - -VERBOSE=1 - -# Icons duplicated from lib/core/common.sh (install.sh runs standalone). -# Avoid readonly to prevent conflicts when sourcing common.sh later. -ICON_SUCCESS="✓" -ICON_ADMIN="●" -ICON_CONFIRM="◎" -ICON_ERROR="☻" - -log_info() { [[ ${VERBOSE} -eq 1 ]] && echo -e "${BLUE}$1${NC}"; } -log_success() { [[ ${VERBOSE} -eq 1 ]] && echo -e "${GREEN}${ICON_SUCCESS}${NC} $1"; } -log_warning() { [[ ${VERBOSE} -eq 1 ]] && echo -e "${YELLOW}$1${NC}"; } -log_error() { echo -e "${YELLOW}${ICON_ERROR}${NC} $1"; } -log_admin() { [[ ${VERBOSE} -eq 1 ]] && echo -e "${BLUE}${ICON_ADMIN}${NC} $1"; } -log_confirm() { [[ ${VERBOSE} -eq 1 ]] && echo -e "${BLUE}${ICON_CONFIRM}${NC} $1"; } - -# Install defaults -INSTALL_DIR="/usr/local/bin" -CONFIG_DIR="$HOME/.config/mole" -SOURCE_DIR="" - -ACTION="install" - -# Resolve source dir (local checkout, env override, or download). -needs_sudo() { - if [[ -e "$INSTALL_DIR" ]]; then - [[ ! -w "$INSTALL_DIR" ]] - return - fi - - local parent_dir - parent_dir="$(dirname "$INSTALL_DIR")" - [[ ! -w "$parent_dir" ]] -} - -maybe_sudo() { - if needs_sudo; then - sudo "$@" - else - "$@" - fi -} - -resolve_source_dir() { - if [[ -n "$SOURCE_DIR" && -d "$SOURCE_DIR" && -f "$SOURCE_DIR/mole" ]]; then - return 0 - fi - - if [[ -n "${BASH_SOURCE[0]:-}" && -f "${BASH_SOURCE[0]}" ]]; then - local script_dir - script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - if [[ -f "$script_dir/mole" ]]; then - SOURCE_DIR="$script_dir" - return 0 - fi - fi - - if [[ -n "${CLEAN_SOURCE_DIR:-}" && -d "$CLEAN_SOURCE_DIR" && -f "$CLEAN_SOURCE_DIR/mole" ]]; then - SOURCE_DIR="$CLEAN_SOURCE_DIR" - return 0 - fi - - local tmp - tmp="$(mktemp -d)" - trap 'stop_line_spinner 2>/dev/null; rm -rf "$tmp"' EXIT - - local branch="${MOLE_VERSION:-}" - if [[ -z "$branch" ]]; then - branch="$(get_latest_release_tag || true)" - fi - if [[ -z "$branch" ]]; then - branch="$(get_latest_release_tag_from_git || true)" - fi - if [[ -z "$branch" ]]; then - branch="main" - fi - if [[ "$branch" != "main" && "$branch" != "dev" ]]; then - branch="$(normalize_release_tag "$branch")" - fi - local url="https://github.com/tw93/mole/archive/refs/heads/main.tar.gz" - - if [[ "$branch" == "dev" ]]; then - url="https://github.com/tw93/mole/archive/refs/heads/dev.tar.gz" - elif [[ "$branch" != "main" ]]; then - url="https://github.com/tw93/mole/archive/refs/tags/${branch}.tar.gz" - fi - - start_line_spinner "Fetching Mole source (${branch})..." - if command -v curl > /dev/null 2>&1; then - if curl -fsSL -o "$tmp/mole.tar.gz" "$url" 2> /dev/null; then - if tar -xzf "$tmp/mole.tar.gz" -C "$tmp" 2> /dev/null; then - stop_line_spinner - - local extracted_dir - extracted_dir=$(find "$tmp" -mindepth 1 -maxdepth 1 -type d | head -n 1) - - if [[ -n "$extracted_dir" && -f "$extracted_dir/mole" ]]; then - SOURCE_DIR="$extracted_dir" - return 0 - fi - fi - else - stop_line_spinner - # Only exit early for version tags (not for main/dev branches) - if [[ "$branch" != "main" && "$branch" != "dev" ]]; then - log_error "Failed to fetch version ${branch}. Check if tag exists." - exit 1 - fi - fi - fi - stop_line_spinner - - start_line_spinner "Cloning Mole source..." - if command -v git > /dev/null 2>&1; then - local git_args=("--depth=1") - if [[ "$branch" != "main" ]]; then - git_args+=("--branch" "$branch") - fi - - if git clone "${git_args[@]}" https://github.com/tw93/mole.git "$tmp/mole" > /dev/null 2>&1; then - stop_line_spinner - SOURCE_DIR="$tmp/mole" - return 0 - fi - fi - stop_line_spinner - - log_error "Failed to fetch source files. Ensure curl or git is available." - exit 1 -} - -# Version helpers -get_source_version() { - local source_mole="$SOURCE_DIR/mole" - if [[ -f "$source_mole" ]]; then - sed -n 's/^VERSION="\(.*\)"$/\1/p' "$source_mole" | head -n1 - fi -} - -get_latest_release_tag() { - local tag - if ! command -v curl > /dev/null 2>&1; then - return 1 - fi - tag=$(curl -fsSL --connect-timeout 2 --max-time 3 \ - "https://api.github.com/repos/tw93/mole/releases/latest" 2> /dev/null | - sed -n 's/.*"tag_name":[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1) - if [[ -z "$tag" ]]; then - return 1 - fi - printf '%s\n' "$tag" -} - -get_latest_release_tag_from_git() { - if ! command -v git > /dev/null 2>&1; then - return 1 - fi - git ls-remote --tags --refs https://github.com/tw93/mole.git 2> /dev/null | - awk -F/ '{print $NF}' | - grep -E '^V[0-9]' | - sort -V | - tail -n 1 -} - -normalize_release_tag() { - local tag="$1" - while [[ "$tag" =~ ^[vV] ]]; do - tag="${tag#v}" - tag="${tag#V}" - done - if [[ -n "$tag" ]]; then - printf 'V%s\n' "$tag" - fi -} - -get_installed_version() { - local binary="$INSTALL_DIR/mole" - if [[ -x "$binary" ]]; then - local version - version=$("$binary" --version 2> /dev/null | awk '/Mole version/ {print $NF; exit}') - if [[ -n "$version" ]]; then - echo "$version" - else - sed -n 's/^VERSION="\(.*\)"$/\1/p' "$binary" | head -n1 - fi - fi -} - -# CLI parsing (supports main/latest and version tokens). -parse_args() { - local -a args=("$@") - local version_token="" - local i skip_next=false - for i in "${!args[@]}"; do - local token="${args[$i]}" - [[ -z "$token" ]] && continue - # Skip values for options that take arguments - if [[ "$skip_next" == "true" ]]; then - skip_next=false - continue - fi - if [[ "$token" == "--prefix" || "$token" == "--config" ]]; then - skip_next=true - continue - fi - if [[ "$token" == -* ]]; then - continue - fi - if [[ -n "$version_token" ]]; then - log_error "Unexpected argument: $token" - exit 1 - fi - case "$token" in - latest | main) - export MOLE_VERSION="main" - export MOLE_EDGE_INSTALL="true" - version_token="$token" - unset 'args[$i]' - ;; - dev) - export MOLE_VERSION="dev" - export MOLE_EDGE_INSTALL="true" - version_token="$token" - unset 'args[$i]' - ;; - [0-9]* | V[0-9]* | v[0-9]*) - export MOLE_VERSION="$token" - version_token="$token" - unset 'args[$i]' - ;; - *) - log_error "Unknown option: $token" - exit 1 - ;; - esac - done - if [[ ${#args[@]} -gt 0 ]]; then - set -- ${args[@]+"${args[@]}"} - else - set -- - fi - - while [[ $# -gt 0 ]]; do - case $1 in - --prefix) - if [[ -z "${2:-}" ]]; then - log_error "Missing value for --prefix" - exit 1 - fi - INSTALL_DIR="$2" - shift 2 - ;; - --config) - if [[ -z "${2:-}" ]]; then - log_error "Missing value for --config" - exit 1 - fi - CONFIG_DIR="$2" - shift 2 - ;; - --update) - ACTION="update" - shift 1 - ;; - --verbose | -v) - VERBOSE=1 - shift 1 - ;; - --help | -h) - log_error "Unknown option: $1" - exit 1 - ;; - *) - log_error "Unknown option: $1" - exit 1 - ;; - esac - done -} - -# Environment checks and directory setup -check_requirements() { - if [[ "$OSTYPE" != "darwin"* ]]; then - log_error "This tool is designed for macOS only" - exit 1 - fi - - if command -v brew > /dev/null 2>&1 && brew list mole > /dev/null 2>&1; then - local mole_path - mole_path=$(command -v mole 2> /dev/null || true) - local is_homebrew_binary=false - - if [[ -n "$mole_path" && -L "$mole_path" ]]; then - if readlink "$mole_path" | grep -q "Cellar/mole"; then - is_homebrew_binary=true - fi - fi - - if [[ "$is_homebrew_binary" == "true" ]]; then - if [[ "$ACTION" == "update" ]]; then - return 0 - fi - - echo -e "${YELLOW}Mole is installed via Homebrew${NC}" - echo "" - echo "Choose one:" - echo -e " 1. Update via Homebrew: ${GREEN}brew upgrade mole${NC}" - echo -e " 2. Switch to manual: ${GREEN}brew uninstall --force mole${NC} then re-run this" - echo "" - exit 1 - else - log_warning "Cleaning up stale Homebrew installation..." - brew uninstall --force mole > /dev/null 2>&1 || true - fi - fi - - if [[ ! -d "$(dirname "$INSTALL_DIR")" ]]; then - log_error "Parent directory $(dirname "$INSTALL_DIR") does not exist" - exit 1 - fi -} - -create_directories() { - if [[ ! -d "$INSTALL_DIR" ]]; then - maybe_sudo mkdir -p "$INSTALL_DIR" - fi - - if ! mkdir -p "$CONFIG_DIR" "$CONFIG_DIR/bin" "$CONFIG_DIR/lib"; then - log_error "Failed to create config directory: $CONFIG_DIR" - exit 1 - fi - -} - -# Binary install helpers -build_binary_from_source() { - local binary_name="$1" - local target_path="$2" - local cmd_dir="" - - case "$binary_name" in - analyze) - cmd_dir="cmd/analyze" - ;; - status) - cmd_dir="cmd/status" - ;; - *) - return 1 - ;; - esac - - if ! command -v go > /dev/null 2>&1; then - return 1 - fi - - if [[ ! -d "$SOURCE_DIR/$cmd_dir" ]]; then - return 1 - fi - - if [[ -t 1 ]]; then - start_line_spinner "Building ${binary_name} from source..." - else - echo "Building ${binary_name} from source..." - fi - - if (cd "$SOURCE_DIR" && go build -ldflags="-s -w" -o "$target_path" "./$cmd_dir" > /dev/null 2>&1); then - if [[ -t 1 ]]; then stop_line_spinner; fi - chmod +x "$target_path" - log_success "Built ${binary_name} from source" - return 0 - fi - - if [[ -t 1 ]]; then stop_line_spinner; fi - log_warning "Failed to build ${binary_name} from source" - return 1 -} - -download_binary() { - local binary_name="$1" - local target_path="$CONFIG_DIR/bin/${binary_name}-go" - local arch - arch=$(uname -m) - local arch_suffix="amd64" - if [[ "$arch" == "arm64" ]]; then - arch_suffix="arm64" - fi - - if [[ -f "$SOURCE_DIR/bin/${binary_name}-go" ]]; then - cp "$SOURCE_DIR/bin/${binary_name}-go" "$target_path" - chmod +x "$target_path" - log_success "Installed local ${binary_name} binary" - return 0 - elif [[ -f "$SOURCE_DIR/bin/${binary_name}-darwin-${arch_suffix}" ]]; then - cp "$SOURCE_DIR/bin/${binary_name}-darwin-${arch_suffix}" "$target_path" - chmod +x "$target_path" - log_success "Installed local ${binary_name} binary" - return 0 - fi - - if [[ "${MOLE_EDGE_INSTALL:-}" == "true" ]]; then - if build_binary_from_source "$binary_name" "$target_path"; then - return 0 - fi - fi - - local version - version=$(get_source_version) - if [[ -z "$version" ]]; then - log_warning "Could not determine version for ${binary_name}, trying local build" - if build_binary_from_source "$binary_name" "$target_path"; then - return 0 - fi - return 1 - fi - local url="https://github.com/tw93/mole/releases/download/V${version}/${binary_name}-darwin-${arch_suffix}" - - # Skip preflight network checks to avoid false negatives. - - if [[ -t 1 ]]; then - start_line_spinner "Downloading ${binary_name}..." - else - echo "Downloading ${binary_name}..." - fi - - if curl -fsSL --connect-timeout 10 --max-time 60 -o "$target_path" "$url"; then - if [[ -t 1 ]]; then stop_line_spinner; fi - chmod +x "$target_path" - 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" - if build_binary_from_source "$binary_name" "$target_path"; then - return 0 - fi - log_error "Failed to install ${binary_name} binary" - return 1 - fi -} - -# File installation (bin/lib/scripts + go helpers). -install_files() { - - resolve_source_dir - - local source_dir_abs - local install_dir_abs - local config_dir_abs - source_dir_abs="$(cd "$SOURCE_DIR" && pwd)" - install_dir_abs="$(cd "$INSTALL_DIR" && pwd)" - config_dir_abs="$(cd "$CONFIG_DIR" && pwd)" - - if [[ -f "$SOURCE_DIR/mole" ]]; then - if [[ "$source_dir_abs" != "$install_dir_abs" ]]; then - if needs_sudo; then - log_admin "Admin access required for /usr/local/bin" - fi - - # Atomic update: copy to temporary name first, then move - maybe_sudo cp "$SOURCE_DIR/mole" "$INSTALL_DIR/mole.new" - maybe_sudo chmod +x "$INSTALL_DIR/mole.new" - maybe_sudo mv -f "$INSTALL_DIR/mole.new" "$INSTALL_DIR/mole" - - log_success "Installed mole to $INSTALL_DIR" - fi - else - log_error "mole executable not found in ${SOURCE_DIR:-unknown}" - exit 1 - fi - - if [[ -f "$SOURCE_DIR/mo" ]]; then - if [[ "$source_dir_abs" == "$install_dir_abs" ]]; then - log_success "mo alias already present" - else - maybe_sudo cp "$SOURCE_DIR/mo" "$INSTALL_DIR/mo.new" - maybe_sudo chmod +x "$INSTALL_DIR/mo.new" - maybe_sudo mv -f "$INSTALL_DIR/mo.new" "$INSTALL_DIR/mo" - log_success "Installed mo alias" - fi - fi - - if [[ -d "$SOURCE_DIR/bin" ]]; then - local source_bin_abs="$(cd "$SOURCE_DIR/bin" && pwd)" - local config_bin_abs="$(cd "$CONFIG_DIR/bin" && pwd)" - if [[ "$source_bin_abs" == "$config_bin_abs" ]]; then - log_success "Modules already synced" - else - local -a bin_files=("$SOURCE_DIR/bin"/*) - if [[ ${#bin_files[@]} -gt 0 ]]; then - cp -r "${bin_files[@]}" "$CONFIG_DIR/bin/" - for file in "$CONFIG_DIR/bin/"*; do - [[ -e "$file" ]] && chmod +x "$file" - done - log_success "Installed modules" - fi - fi - fi - - if [[ -d "$SOURCE_DIR/lib" ]]; then - local source_lib_abs="$(cd "$SOURCE_DIR/lib" && pwd)" - local config_lib_abs="$(cd "$CONFIG_DIR/lib" && pwd)" - if [[ "$source_lib_abs" == "$config_lib_abs" ]]; then - log_success "Libraries already synced" - else - local -a lib_files=("$SOURCE_DIR/lib"/*) - if [[ ${#lib_files[@]} -gt 0 ]]; then - cp -r "${lib_files[@]}" "$CONFIG_DIR/lib/" - log_success "Installed libraries" - fi - fi - fi - - if [[ "$config_dir_abs" != "$source_dir_abs" ]]; then - for file in README.md LICENSE install.sh; do - if [[ -f "$SOURCE_DIR/$file" ]]; then - cp -f "$SOURCE_DIR/$file" "$CONFIG_DIR/" - fi - done - fi - - if [[ -f "$CONFIG_DIR/install.sh" ]]; then - chmod +x "$CONFIG_DIR/install.sh" - fi - - if [[ "$source_dir_abs" != "$install_dir_abs" ]]; then - maybe_sudo sed -i '' "s|SCRIPT_DIR=.*|SCRIPT_DIR=\"$CONFIG_DIR\"|" "$INSTALL_DIR/mole" - fi - - if ! download_binary "analyze"; then - exit 1 - fi - if ! download_binary "status"; then - exit 1 - fi -} - -# Verification and PATH hint -verify_installation() { - - if [[ -x "$INSTALL_DIR/mole" ]] && [[ -f "$CONFIG_DIR/lib/core/common.sh" ]]; then - - if "$INSTALL_DIR/mole" --help > /dev/null 2>&1; then - return 0 - else - log_warning "Mole command installed but may not be working properly" - fi - else - log_error "Installation verification failed" - exit 1 - fi -} - -setup_path() { - if [[ ":$PATH:" == *":$INSTALL_DIR:"* ]]; then - return - fi - - if [[ "$INSTALL_DIR" != "/usr/local/bin" ]]; then - log_warning "$INSTALL_DIR is not in your PATH" - echo "" - echo "To use mole from anywhere, add this line to your shell profile:" - echo "export PATH=\"$INSTALL_DIR:\$PATH\"" - echo "" - echo "For example, add it to ~/.zshrc or ~/.bash_profile" - fi -} - -print_usage_summary() { - local action="$1" - local new_version="$2" - local previous_version="${3:-}" - - if [[ ${VERBOSE} -ne 1 ]]; then - return - fi - - echo "" - - local message="Mole ${action} successfully" - - if [[ "$action" == "updated" && -n "$previous_version" && -n "$new_version" && "$previous_version" != "$new_version" ]]; then - message+=" (${previous_version} -> ${new_version})" - elif [[ -n "$new_version" ]]; then - message+=" (version ${new_version})" - fi - - log_confirm "$message" - - echo "" - echo "Usage:" - if [[ ":$PATH:" == *":$INSTALL_DIR:"* ]]; then - echo " mo # Interactive menu" - echo " mo clean # Deep cleanup" - echo " mo uninstall # Remove apps + leftovers" - echo " mo optimize # Check and maintain system" - echo " mo analyze # Explore disk usage" - echo " mo status # Monitor system health" - echo " mo touchid # Configure Touch ID for sudo" - echo " mo update # Update to latest version" - echo " mo --help # Show all commands" - else - echo " $INSTALL_DIR/mo # Interactive menu" - echo " $INSTALL_DIR/mo clean # Deep cleanup" - echo " $INSTALL_DIR/mo uninstall # Remove apps + leftovers" - echo " $INSTALL_DIR/mo optimize # Check and maintain system" - echo " $INSTALL_DIR/mo analyze # Explore disk usage" - echo " $INSTALL_DIR/mo status # Monitor system health" - echo " $INSTALL_DIR/mo touchid # Configure Touch ID for sudo" - echo " $INSTALL_DIR/mo update # Update to latest version" - echo " $INSTALL_DIR/mo --help # Show all commands" - fi - echo "" -} - -# Main install/update flows -perform_install() { - resolve_source_dir - local source_version - source_version="$(get_source_version || true)" - - check_requirements - create_directories - install_files - verify_installation - setup_path - - local installed_version - installed_version="$(get_installed_version || true)" - - if [[ -z "$installed_version" ]]; then - installed_version="$source_version" - fi - - # Edge installs get a suffix to make the version explicit. - if [[ "${MOLE_EDGE_INSTALL:-}" == "true" ]]; then - installed_version="${installed_version}-edge" - echo "" - local branch_name="${MOLE_VERSION:-main}" - log_warning "Edge version installed on ${branch_name} branch" - log_info "This is a testing version; use 'mo update' to switch to stable" - fi - - print_usage_summary "installed" "$installed_version" -} - -perform_update() { - check_requirements - - if command -v brew > /dev/null 2>&1 && brew list mole > /dev/null 2>&1; then - resolve_source_dir 2> /dev/null || true - local current_version - current_version=$(get_installed_version || echo "unknown") - if [[ -f "$SOURCE_DIR/lib/core/common.sh" ]]; then - # shellcheck disable=SC1090,SC1091 - source "$SOURCE_DIR/lib/core/common.sh" - update_via_homebrew "$current_version" - else - log_error "Cannot update Homebrew-managed Mole without full installation" - echo "" - echo "Please update via Homebrew:" - echo -e " ${GREEN}brew upgrade mole${NC}" - exit 1 - fi - exit 0 - fi - - local installed_version - installed_version="$(get_installed_version || true)" - - if [[ -z "$installed_version" ]]; then - log_warning "Mole is not currently installed in $INSTALL_DIR. Running fresh installation." - perform_install - return - fi - - resolve_source_dir - local target_version - target_version="$(get_source_version || true)" - - if [[ -z "$target_version" ]]; then - log_error "Unable to determine the latest Mole version." - exit 1 - fi - - if [[ "$installed_version" == "$target_version" ]]; then - echo -e "${GREEN}${ICON_SUCCESS}${NC} Already on latest version ($installed_version)" - exit 0 - fi - - local old_verbose=$VERBOSE - VERBOSE=0 - create_directories || { - VERBOSE=$old_verbose - log_error "Failed to create directories" - exit 1 - } - install_files || { - VERBOSE=$old_verbose - log_error "Failed to install files" - exit 1 - } - verify_installation || { - VERBOSE=$old_verbose - log_error "Failed to verify installation" - exit 1 - } - setup_path - VERBOSE=$old_verbose - - local updated_version - updated_version="$(get_installed_version || true)" - - if [[ -z "$updated_version" ]]; then - updated_version="$target_version" - fi - - echo -e "${GREEN}${ICON_SUCCESS}${NC} Updated to latest version ($updated_version)" -} - -parse_args "$@" - -case "$ACTION" in - update) - perform_update - ;; - *) - perform_install - ;; -esac diff --git a/lib/check/all.sh b/lib/check/all.sh deleted file mode 100644 index c00c5f5..0000000 --- a/lib/check/all.sh +++ /dev/null @@ -1,595 +0,0 @@ -#!/bin/bash -# System Checks Module -# Combines configuration, security, updates, and health checks - -set -euo pipefail - -# ============================================================================ -# Helper Functions -# ============================================================================ - -list_login_items() { - if ! command -v osascript > /dev/null 2>&1; then - return - fi - - local raw_items - raw_items=$(osascript -e 'tell application "System Events" to get the name of every login item' 2> /dev/null || echo "") - [[ -z "$raw_items" || "$raw_items" == "missing value" ]] && return - - IFS=',' read -ra login_items_array <<< "$raw_items" - for entry in "${login_items_array[@]}"; do - local trimmed - trimmed=$(echo "$entry" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//') - [[ -n "$trimmed" ]] && printf "%s\n" "$trimmed" - done -} - -# ============================================================================ -# Configuration Checks -# ============================================================================ - -check_touchid_sudo() { - # Check whitelist - if command -v is_whitelisted > /dev/null && is_whitelisted "check_touchid"; then return; fi - # Check if Touch ID is configured for sudo - local pam_file="/etc/pam.d/sudo" - if [[ -f "$pam_file" ]] && grep -q "pam_tid.so" "$pam_file" 2> /dev/null; then - echo -e " ${GREEN}✓${NC} Touch ID Biometric authentication enabled" - else - # Check if Touch ID is supported - local is_supported=false - if command -v bioutil > /dev/null 2>&1; then - if bioutil -r 2> /dev/null | grep -q "Touch ID"; then - is_supported=true - fi - elif [[ "$(uname -m)" == "arm64" ]]; then - is_supported=true - fi - - if [[ "$is_supported" == "true" ]]; then - echo -e " ${YELLOW}${ICON_WARNING}${NC} Touch ID ${YELLOW}Not configured for sudo${NC}" - export TOUCHID_NOT_CONFIGURED=true - fi - fi -} - -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) - 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 - fi - fi -} - -check_git_config() { - # Check whitelist - if command -v is_whitelisted > /dev/null && is_whitelisted "check_git_config"; then return; fi - # Check basic Git configuration - if command -v git > /dev/null 2>&1; then - local git_name=$(git config --global user.name 2> /dev/null || echo "") - local git_email=$(git config --global user.email 2> /dev/null || echo "") - - 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}" - fi - fi -} - -check_all_config() { - echo -e "${BLUE}${ICON_ARROW}${NC} System Configuration" - check_touchid_sudo - check_rosetta - check_git_config -} - -# ============================================================================ -# Security Checks -# ============================================================================ - -check_filevault() { - # Check whitelist - if command -v is_whitelisted > /dev/null && is_whitelisted "check_filevault"; then return; fi - # Check FileVault encryption status - if command -v fdesetup > /dev/null 2>&1; then - local fv_status=$(fdesetup status 2> /dev/null || echo "") - if echo "$fv_status" | grep -q "FileVault is On"; then - echo -e " ${GREEN}✓${NC} FileVault Disk encryption active" - else - echo -e " ${RED}✗${NC} FileVault ${RED}Disk encryption disabled${NC}" - export FILEVAULT_DISABLED=true - fi - fi -} - -check_firewall() { - # Check whitelist - if command -v is_whitelisted > /dev/null && is_whitelisted "firewall"; then return; fi - # Check firewall status using socketfilterfw (more reliable than defaults on modern macOS) - unset FIREWALL_DISABLED - local firewall_output=$(sudo /usr/libexec/ApplicationFirewall/socketfilterfw --getglobalstate 2> /dev/null || echo "") - if [[ "$firewall_output" == *"State = 1"* ]] || [[ "$firewall_output" == *"State = 2"* ]]; then - echo -e " ${GREEN}✓${NC} Firewall Network protection enabled" - else - echo -e " ${YELLOW}${ICON_WARNING}${NC} Firewall ${YELLOW}Network protection disabled${NC}" - export FIREWALL_DISABLED=true - fi -} - -check_gatekeeper() { - # Check whitelist - if command -v is_whitelisted > /dev/null && is_whitelisted "gatekeeper"; then return; fi - # Check Gatekeeper status - if command -v spctl > /dev/null 2>&1; then - local gk_status=$(spctl --status 2> /dev/null || echo "") - if echo "$gk_status" | grep -q "enabled"; then - 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}" - export GATEKEEPER_DISABLED=true - fi - fi -} - -check_sip() { - # Check whitelist - if command -v is_whitelisted > /dev/null && is_whitelisted "check_sip"; then return; fi - # Check System Integrity Protection - if command -v csrutil > /dev/null 2>&1; then - local sip_status=$(csrutil status 2> /dev/null || echo "") - 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}" - fi - fi -} - -check_all_security() { - echo -e "${BLUE}${ICON_ARROW}${NC} Security Status" - check_filevault - check_firewall - check_gatekeeper - check_sip -} - -# ============================================================================ -# Software Update Checks -# ============================================================================ - -# Cache configuration -CACHE_DIR="${HOME}/.cache/mole" -CACHE_TTL=600 # 10 minutes in seconds - -# Ensure cache directory exists -ensure_user_dir "$CACHE_DIR" - -clear_cache_file() { - local file="$1" - rm -f "$file" 2> /dev/null || true -} - -reset_brew_cache() { - clear_cache_file "$CACHE_DIR/brew_updates" -} - -reset_softwareupdate_cache() { - clear_cache_file "$CACHE_DIR/softwareupdate_list" - SOFTWARE_UPDATE_LIST="" -} - -reset_mole_cache() { - clear_cache_file "$CACHE_DIR/mole_version" -} - -# Check if cache is still valid -is_cache_valid() { - local cache_file="$1" - local ttl="${2:-$CACHE_TTL}" - - if [[ ! -f "$cache_file" ]]; then - return 1 - fi - - local cache_age=$(($(get_epoch_seconds) - $(get_file_mtime "$cache_file"))) - [[ $cache_age -lt $ttl ]] -} - -# Cache software update list to avoid calling softwareupdate twice -SOFTWARE_UPDATE_LIST="" - -get_software_updates() { - local cache_file="$CACHE_DIR/softwareupdate_list" - - # Optimized: Use defaults to check if updates are pending (much faster) - local pending_updates - pending_updates=$(defaults read /Library/Preferences/com.apple.SoftwareUpdate LastRecommendedUpdatesAvailable 2> /dev/null || echo "0") - - if [[ "$pending_updates" -gt 0 ]]; then - echo "Updates Available" - else - echo "" - fi -} - -check_appstore_updates() { - # Skipped for speed optimization - consolidated into check_macos_update - # We can't easily distinguish app store vs macos updates without the slow softwareupdate -l call - export APPSTORE_UPDATE_COUNT=0 -} - -check_macos_update() { - # Check whitelist - if command -v is_whitelisted > /dev/null && is_whitelisted "check_macos_updates"; then return; fi - - # Fast check using system preferences - local updates_available="false" - if [[ $(get_software_updates) == "Updates Available" ]]; then - updates_available="true" - - # Verify with softwareupdate using --no-scan to avoid triggering a fresh scan - # which can timeout. We prioritize avoiding false negatives (missing actual updates) - # over false positives, so we only clear the update flag when softwareupdate - # explicitly reports "No new software available" - local sw_output="" - local sw_status=0 - local spinner_started=false - if [[ -t 1 ]]; then - MOLE_SPINNER_PREFIX=" " start_inline_spinner "Checking macOS updates..." - spinner_started=true - fi - - local softwareupdate_timeout=10 - if sw_output=$(run_with_timeout "$softwareupdate_timeout" softwareupdate -l --no-scan 2> /dev/null); then - : - else - sw_status=$? - fi - - if [[ "$spinner_started" == "true" ]]; then - stop_inline_spinner - fi - - # Debug logging for troubleshooting - if [[ -n "${MO_DEBUG:-}" ]]; then - echo "[DEBUG] softwareupdate exit status: $sw_status, output lines: $(echo "$sw_output" | wc -l | tr -d ' ')" >&2 - fi - - # Prefer avoiding false negatives: if the system indicates updates are pending, - # only clear the flag when softwareupdate returns a list without any update entries. - if [[ $sw_status -eq 0 && -n "$sw_output" ]]; then - if ! echo "$sw_output" | grep -qE '^[[:space:]]*\*'; then - updates_available="false" - fi - fi - fi - - export MACOS_UPDATE_AVAILABLE="$updates_available" - - if [[ "$updates_available" == "true" ]]; then - echo -e " ${YELLOW}${ICON_WARNING}${NC} macOS ${YELLOW}Update available${NC}" - else - echo -e " ${GREEN}✓${NC} macOS System up to date" - fi -} - -check_mole_update() { - if command -v is_whitelisted > /dev/null && is_whitelisted "check_mole_update"; then return; fi - - # Check if Mole has updates - # Auto-detect version from mole main script - local current_version - if [[ -f "${SCRIPT_DIR:-/usr/local/bin}/mole" ]]; then - current_version=$(grep '^VERSION=' "${SCRIPT_DIR:-/usr/local/bin}/mole" 2> /dev/null | head -1 | sed 's/VERSION="\(.*\)"/\1/' || echo "unknown") - else - current_version="${VERSION:-unknown}" - fi - - local latest_version="" - local cache_file="$CACHE_DIR/mole_version" - - export MOLE_UPDATE_AVAILABLE="false" - - # Check cache first - if is_cache_valid "$cache_file"; then - latest_version=$(cat "$cache_file" 2> /dev/null || echo "") - else - # Show spinner while checking - if [[ -t 1 ]]; then - MOLE_SPINNER_PREFIX=" " start_inline_spinner "Checking Mole version..." - fi - - # Try to get latest version from GitHub - if command -v curl > /dev/null 2>&1; then - # Run in background to allow Ctrl+C to interrupt - local temp_version - temp_version=$(mktemp_file "mole_version_check") - curl -fsSL --connect-timeout 3 --max-time 5 https://api.github.com/repos/tw93/mole/releases/latest 2> /dev/null | grep '"tag_name"' | sed -E 's/.*"v?([^"]+)".*/\1/' > "$temp_version" & - local curl_pid=$! - - # Wait for curl to complete (allows Ctrl+C to interrupt) - if wait "$curl_pid" 2> /dev/null; then - latest_version=$(cat "$temp_version" 2> /dev/null || echo "") - # Save to cache - if [[ -n "$latest_version" ]]; then - ensure_user_file "$cache_file" - echo "$latest_version" > "$cache_file" 2> /dev/null || true - fi - fi - rm -f "$temp_version" 2> /dev/null || true - fi - - # Stop spinner - if [[ -t 1 ]]; then - stop_inline_spinner - fi - fi - - # Normalize version strings (remove leading 'v' or 'V') - current_version="${current_version#v}" - current_version="${current_version#V}" - latest_version="${latest_version#v}" - latest_version="${latest_version#V}" - - if [[ -n "$latest_version" && "$current_version" != "$latest_version" ]]; then - # 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})" - else - echo -e " ${GREEN}✓${NC} Mole Latest version ${current_version}" - fi - else - echo -e " ${GREEN}✓${NC} Mole Latest version ${current_version}" - fi -} - -check_all_updates() { - # Reset spinner flag for softwareupdate - unset SOFTWAREUPDATE_SPINNER_SHOWN - - # Preload software update data to avoid delays between subsequent checks - # Only redirect stdout, keep stderr for spinner display - get_software_updates > /dev/null - - echo -e "${BLUE}${ICON_ARROW}${NC} System Updates" - check_appstore_updates - check_macos_update - check_mole_update -} - -get_appstore_update_labels() { - get_software_updates | awk ' - /^\*/ { - label=$0 - sub(/^[[:space:]]*\* Label: */, "", label) - sub(/,.*/, "", label) - lower=tolower(label) - if (index(lower, "macos") == 0) { - print label - } - } - ' -} - -get_macos_update_labels() { - get_software_updates | awk ' - /^\*/ { - label=$0 - sub(/^[[:space:]]*\* Label: */, "", label) - sub(/,.*/, "", label) - lower=tolower(label) - if (index(lower, "macos") != 0) { - print label - } - } - ' -} - -# ============================================================================ -# System Health Checks -# ============================================================================ - -check_disk_space() { - local free_gb=$(command df -H / | awk 'NR==2 {print $4}' | sed 's/G//') - local free_num=$(echo "$free_gb" | tr -d 'G' | cut -d'.' -f1) - - 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)" - elif [[ $free_num -lt 50 ]]; then - echo -e " ${YELLOW}${ICON_WARNING}${NC} Disk Space ${YELLOW}${free_gb}GB free${NC} (Low)" - else - echo -e " ${GREEN}✓${NC} Disk Space ${free_gb}GB free" - fi -} - -check_memory_usage() { - local mem_total - mem_total=$(sysctl -n hw.memsize 2> /dev/null || echo "0") - if [[ -z "$mem_total" || "$mem_total" -le 0 ]]; then - echo -e " ${GRAY}-${NC} Memory Unable to determine" - return - fi - - local vm_output - vm_output=$(vm_stat 2> /dev/null || echo "") - - local page_size - page_size=$(echo "$vm_output" | awk '/page size of/ {print $8}') - [[ -z "$page_size" ]] && page_size=4096 - - local free_pages inactive_pages spec_pages - free_pages=$(echo "$vm_output" | awk '/Pages free/ {gsub(/\./,"",$3); print $3}') - inactive_pages=$(echo "$vm_output" | awk '/Pages inactive/ {gsub(/\./,"",$3); print $3}') - spec_pages=$(echo "$vm_output" | awk '/Pages speculative/ {gsub(/\./,"",$3); print $3}') - - free_pages=${free_pages:-0} - inactive_pages=${inactive_pages:-0} - spec_pages=${spec_pages:-0} - - # Estimate used percent: (total - free - inactive - speculative) / total - local total_pages=$((mem_total / page_size)) - local free_total=$((free_pages + inactive_pages + spec_pages)) - local used_pages=$((total_pages - free_total)) - if ((used_pages < 0)); then - used_pages=0 - fi - - local used_percent - used_percent=$(awk "BEGIN {printf \"%.0f\", ($used_pages / $total_pages) * 100}") - ((used_percent > 100)) && used_percent=100 - ((used_percent < 0)) && used_percent=0 - - if [[ $used_percent -gt 90 ]]; then - 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)" - else - echo -e " ${GREEN}✓${NC} Memory ${used_percent}% used" - fi -} - -check_login_items() { - # Check whitelist - if command -v is_whitelisted > /dev/null && is_whitelisted "check_login_items"; then return; fi - local login_items_count=0 - local -a login_items_list=() - - if [[ -t 0 ]]; then - # Show spinner while getting login items - if [[ -t 1 ]]; then - MOLE_SPINNER_PREFIX=" " start_inline_spinner "Checking login items..." - fi - - while IFS= read -r login_item; do - [[ -n "$login_item" ]] && login_items_list+=("$login_item") - done < <(list_login_items || true) - login_items_count=${#login_items_list[@]} - - # Stop spinner before output - if [[ -t 1 ]]; then - stop_inline_spinner - fi - fi - - if [[ $login_items_count -gt 15 ]]; then - echo -e " ${YELLOW}${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 - echo -e " ${GREEN}✓${NC} Login Items None" - return - fi - - # Show items in a single line (compact) - local preview_limit=3 - ((preview_limit > login_items_count)) && preview_limit=$login_items_count - - local items_display="" - for ((i = 0; i < preview_limit; i++)); do - if [[ $i -eq 0 ]]; then - items_display="${login_items_list[$i]}" - else - items_display="${items_display}, ${login_items_list[$i]}" - fi - done - - if ((login_items_count > preview_limit)); then - local remaining=$((login_items_count - preview_limit)) - items_display="${items_display} +${remaining}" - fi - - echo -e " ${GRAY}${items_display}${NC}" -} - -check_cache_size() { - local cache_size_kb=0 - - # Check common cache locations - local -a cache_paths=( - "$HOME/Library/Caches" - "$HOME/Library/Logs" - ) - - # Show spinner while calculating cache size - if [[ -t 1 ]]; then - MOLE_SPINNER_PREFIX=" " start_inline_spinner "Scanning cache..." - fi - - for cache_path in "${cache_paths[@]}"; do - if [[ -d "$cache_path" ]]; then - local size_output - size_output=$(get_path_size_kb "$cache_path") - [[ "$size_output" =~ ^[0-9]+$ ]] || size_output=0 - cache_size_kb=$((cache_size_kb + size_output)) - fi - done - - local cache_size_gb=$(echo "scale=1; $cache_size_kb / 1024 / 1024" | bc) - export CACHE_SIZE_GB=$cache_size_gb - - # Stop spinner before output - if [[ -t 1 ]]; then - stop_inline_spinner - fi - - # Convert to integer for comparison - 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" - elif [[ $cache_size_int -gt 5 ]]; then - echo -e " ${YELLOW}${ICON_WARNING}${NC} Cache Size ${YELLOW}${cache_size_gb}GB${NC} cleanable" - else - echo -e " ${GREEN}✓${NC} Cache Size ${cache_size_gb}GB" - fi -} - -check_swap_usage() { - # Check swap usage - if command -v sysctl > /dev/null 2>&1; then - local swap_info=$(sysctl vm.swapusage 2> /dev/null || echo "") - if [[ -n "$swap_info" ]]; then - local swap_used=$(echo "$swap_info" | grep -o "used = [0-9.]*[GM]" | awk 'NR==1{print $3}') - swap_used=${swap_used:-0M} - local swap_num="${swap_used//[GM]/}" - - 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)" - else - echo -e " ${GREEN}✓${NC} Swap Usage ${swap_used}" - fi - else - echo -e " ${GREEN}✓${NC} Swap Usage ${swap_used}" - fi - fi - fi -} - -check_brew_health() { - # Check whitelist - if command -v is_whitelisted > /dev/null && is_whitelisted "check_brew_health"; then return; fi -} - -check_system_health() { - echo -e "${BLUE}${ICON_ARROW}${NC} System Health" - check_disk_space - check_memory_usage - check_swap_usage - check_login_items - check_cache_size - # Time Machine check is optional; skip by default to avoid noise on systems without backups -} diff --git a/lib/check/health_json.sh b/lib/check/health_json.sh deleted file mode 100644 index adb20d6..0000000 --- a/lib/check/health_json.sh +++ /dev/null @@ -1,184 +0,0 @@ -#!/bin/bash -# System Health Check - JSON Generator -# Extracted from tasks.sh - -set -euo pipefail - -# Ensure dependencies are loaded (only if running standalone) -if [[ -z "${MOLE_FILE_OPS_LOADED:-}" ]]; then - SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" - source "$SCRIPT_DIR/lib/core/file_ops.sh" -fi - -# Get memory info in GB -get_memory_info() { - local total_bytes used_gb total_gb - - # Total memory - total_bytes=$(sysctl -n hw.memsize 2> /dev/null || echo "0") - total_gb=$(LC_ALL=C awk "BEGIN {printf \"%.2f\", $total_bytes / (1024*1024*1024)}" 2> /dev/null || echo "0") - [[ -z "$total_gb" || "$total_gb" == "" ]] && total_gb="0" - - # Used memory from vm_stat - local vm_output active wired compressed page_size - vm_output=$(vm_stat 2> /dev/null || echo "") - page_size=4096 - - active=$(echo "$vm_output" | LC_ALL=C awk '/Pages active:/ {print $NF}' | tr -d '.\n' 2> /dev/null) - wired=$(echo "$vm_output" | LC_ALL=C awk '/Pages wired down:/ {print $NF}' | tr -d '.\n' 2> /dev/null) - compressed=$(echo "$vm_output" | LC_ALL=C awk '/Pages occupied by compressor:/ {print $NF}' | tr -d '.\n' 2> /dev/null) - - active=${active:-0} - wired=${wired:-0} - compressed=${compressed:-0} - - local used_bytes=$(((active + wired + compressed) * page_size)) - used_gb=$(LC_ALL=C awk "BEGIN {printf \"%.2f\", $used_bytes / (1024*1024*1024)}" 2> /dev/null || echo "0") - [[ -z "$used_gb" || "$used_gb" == "" ]] && used_gb="0" - - echo "$used_gb $total_gb" -} - -# Get disk info -get_disk_info() { - local home="${HOME:-/}" - local df_output total_gb used_gb used_percent - - df_output=$(command df -k "$home" 2> /dev/null | tail -1) - - local total_kb used_kb - total_kb=$(echo "$df_output" | LC_ALL=C awk 'NR==1{print $2}' 2> /dev/null) - used_kb=$(echo "$df_output" | LC_ALL=C awk 'NR==1{print $3}' 2> /dev/null) - - total_kb=${total_kb:-0} - used_kb=${used_kb:-0} - [[ "$total_kb" == "0" ]] && total_kb=1 # Avoid division by zero - - total_gb=$(LC_ALL=C awk "BEGIN {printf \"%.2f\", $total_kb / (1024*1024)}" 2> /dev/null || echo "0") - used_gb=$(LC_ALL=C awk "BEGIN {printf \"%.2f\", $used_kb / (1024*1024)}" 2> /dev/null || echo "0") - used_percent=$(LC_ALL=C awk "BEGIN {printf \"%.1f\", ($used_kb / $total_kb) * 100}" 2> /dev/null || echo "0") - - [[ -z "$total_gb" || "$total_gb" == "" ]] && total_gb="0" - [[ -z "$used_gb" || "$used_gb" == "" ]] && used_gb="0" - [[ -z "$used_percent" || "$used_percent" == "" ]] && used_percent="0" - - echo "$used_gb $total_gb $used_percent" -} - -# Get uptime in days -get_uptime_days() { - local boot_output boot_time uptime_days - - boot_output=$(sysctl -n kern.boottime 2> /dev/null || echo "") - boot_time=$(echo "$boot_output" | awk -F 'sec = |, usec' '{print $2}' 2> /dev/null || echo "") - - if [[ -n "$boot_time" && "$boot_time" =~ ^[0-9]+$ ]]; then - local now - now=$(get_epoch_seconds) - local uptime_sec=$((now - boot_time)) - uptime_days=$(LC_ALL=C awk "BEGIN {printf \"%.1f\", $uptime_sec / 86400}" 2> /dev/null || echo "0") - else - uptime_days="0" - fi - - [[ -z "$uptime_days" || "$uptime_days" == "" ]] && uptime_days="0" - echo "$uptime_days" -} - -# JSON escape helper -json_escape() { - # Escape backslash, double quote, tab, and newline - local escaped - escaped=$(echo -n "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/ /\\t/g' | tr '\n' ' ') - echo -n "${escaped% }" -} - -# Generate JSON output -generate_health_json() { - # System info - read -r mem_used mem_total <<< "$(get_memory_info)" - read -r disk_used disk_total disk_percent <<< "$(get_disk_info)" - local uptime=$(get_uptime_days) - - # Ensure all values are valid numbers (fallback to 0) - mem_used=${mem_used:-0} - mem_total=${mem_total:-0} - disk_used=${disk_used:-0} - disk_total=${disk_total:-0} - disk_percent=${disk_percent:-0} - uptime=${uptime:-0} - - # Start JSON - cat << EOF -{ - "memory_used_gb": $mem_used, - "memory_total_gb": $mem_total, - "disk_used_gb": $disk_used, - "disk_total_gb": $disk_total, - "disk_used_percent": $disk_percent, - "uptime_days": $uptime, - "optimizations": [ -EOF - - # Collect all optimization items - local -a items=() - - # Core optimizations (safe and valuable) - items+=('system_maintenance|DNS & Spotlight Check|Refresh DNS cache & verify Spotlight status|true') - items+=('cache_refresh|Finder Cache Refresh|Refresh QuickLook thumbnails & icon services cache|true') - items+=('saved_state_cleanup|App State Cleanup|Remove old saved application states (30+ days)|true') - items+=('fix_broken_configs|Broken Config Repair|Fix corrupted preferences files|true') - items+=('network_optimization|Network Cache Refresh|Optimize DNS cache & restart mDNSResponder|true') - - # Advanced optimizations (high value, auto-run with safety checks) - items+=('sqlite_vacuum|Database Optimization|Compress SQLite databases for Mail, Safari & Messages (skips if apps are running)|true') - items+=('launch_services_rebuild|LaunchServices Repair|Repair "Open with" menu & file associations|true') - items+=('font_cache_rebuild|Font Cache Rebuild|Rebuild font database to fix rendering issues|true') - items+=('dock_refresh|Dock Refresh|Fix broken icons and visual glitches in the Dock|true') - - # System performance optimizations (new) - items+=('memory_pressure_relief|Memory Optimization|Release inactive memory to improve system responsiveness|true') - items+=('network_stack_optimize|Network Stack Refresh|Flush routing table and ARP cache to resolve network issues|true') - items+=('disk_permissions_repair|Permission Repair|Fix user directory permission issues|true') - items+=('bluetooth_reset|Bluetooth Refresh|Restart Bluetooth module to fix connectivity (skips if in use)|true') - items+=('spotlight_index_optimize|Spotlight Optimization|Rebuild index if search is slow (smart detection)|true') - - # Removed high-risk optimizations: - # - startup_items_cleanup: Risk of deleting legitimate app helpers - # - system_services_refresh: Risk of data loss when killing system services - # - dyld_cache_update: Low benefit, time-consuming, auto-managed by macOS - - # Output items as JSON - local first=true - for item in "${items[@]}"; do - IFS='|' read -r action name desc safe <<< "$item" - - # Escape strings - action=$(json_escape "$action") - name=$(json_escape "$name") - desc=$(json_escape "$desc") - - [[ "$first" == "true" ]] && first=false || echo "," - - cat << EOF - { - "category": "system", - "name": "$name", - "description": "$desc", - "action": "$action", - "safe": $safe - } -EOF - done - - # Close JSON - cat << 'EOF' - ] -} -EOF -} - -# Main execution (for testing) -if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then - generate_health_json -fi diff --git a/lib/clean/app_caches.sh b/lib/clean/app_caches.sh deleted file mode 100644 index c4b62c6..0000000 --- a/lib/clean/app_caches.sh +++ /dev/null @@ -1,235 +0,0 @@ -#!/bin/bash -# User GUI Applications Cleanup Module (desktop apps, media, utilities). -set -euo pipefail -# Xcode and iOS tooling. -clean_xcode_tools() { - # Skip DerivedData/Archives while Xcode is running. - local xcode_running=false - if pgrep -x "Xcode" > /dev/null 2>&1; then - xcode_running=true - fi - safe_clean ~/Library/Developer/CoreSimulator/Caches/* "Simulator cache" - safe_clean ~/Library/Developer/CoreSimulator/Devices/*/data/tmp/* "Simulator temp files" - safe_clean ~/Library/Caches/com.apple.dt.Xcode/* "Xcode cache" - safe_clean ~/Library/Developer/Xcode/iOS\ Device\ Logs/* "iOS device logs" - safe_clean ~/Library/Developer/Xcode/watchOS\ Device\ Logs/* "watchOS device logs" - safe_clean ~/Library/Developer/Xcode/Products/* "Xcode build products" - if [[ "$xcode_running" == "false" ]]; then - 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" - fi -} -# Code editors. -clean_code_editors() { - safe_clean ~/Library/Application\ Support/Code/logs/* "VS Code logs" - safe_clean ~/Library/Application\ Support/Code/Cache/* "VS Code cache" - safe_clean ~/Library/Application\ Support/Code/CachedExtensions/* "VS Code extension cache" - safe_clean ~/Library/Application\ Support/Code/CachedData/* "VS Code data cache" - safe_clean ~/Library/Caches/com.sublimetext.*/* "Sublime Text cache" -} -# Communication apps. -clean_communication_apps() { - safe_clean ~/Library/Application\ Support/discord/Cache/* "Discord cache" - safe_clean ~/Library/Application\ Support/legcord/Cache/* "Legcord cache" - safe_clean ~/Library/Application\ Support/Slack/Cache/* "Slack cache" - safe_clean ~/Library/Caches/us.zoom.xos/* "Zoom cache" - safe_clean ~/Library/Caches/com.tencent.xinWeChat/* "WeChat cache" - safe_clean ~/Library/Caches/ru.keepcoder.Telegram/* "Telegram cache" - safe_clean ~/Library/Caches/com.microsoft.teams2/* "Microsoft Teams cache" - safe_clean ~/Library/Caches/net.whatsapp.WhatsApp/* "WhatsApp cache" - safe_clean ~/Library/Caches/com.skype.skype/* "Skype cache" - safe_clean ~/Library/Caches/com.tencent.meeting/* "Tencent Meeting cache" - safe_clean ~/Library/Caches/com.tencent.WeWorkMac/* "WeCom cache" - safe_clean ~/Library/Caches/com.feishu.*/* "Feishu cache" -} -# DingTalk. -clean_dingtalk() { - safe_clean ~/Library/Caches/dd.work.exclusive4aliding/* "DingTalk iDingTalk cache" - safe_clean ~/Library/Caches/com.alibaba.AliLang.osx/* "AliLang security component" - safe_clean ~/Library/Application\ Support/iDingTalk/log/* "DingTalk logs" - safe_clean ~/Library/Application\ Support/iDingTalk/holmeslogs/* "DingTalk holmes logs" -} -# AI assistants. -clean_ai_apps() { - safe_clean ~/Library/Caches/com.openai.chat/* "ChatGPT cache" - safe_clean ~/Library/Caches/com.anthropic.claudefordesktop/* "Claude desktop cache" - safe_clean ~/Library/Logs/Claude/* "Claude logs" -} -# Design and creative tools. -clean_design_tools() { - safe_clean ~/Library/Caches/com.bohemiancoding.sketch3/* "Sketch cache" - safe_clean ~/Library/Application\ Support/com.bohemiancoding.sketch3/cache/* "Sketch app cache" - safe_clean ~/Library/Caches/Adobe/* "Adobe cache" - safe_clean ~/Library/Caches/com.adobe.*/* "Adobe app caches" - safe_clean ~/Library/Caches/com.figma.Desktop/* "Figma cache" - # Raycast cache is protected (clipboard history, images). -} -# Video editing tools. -clean_video_tools() { - safe_clean ~/Library/Caches/net.telestream.screenflow10/* "ScreenFlow cache" - safe_clean ~/Library/Caches/com.apple.FinalCut/* "Final Cut Pro cache" - safe_clean ~/Library/Caches/com.blackmagic-design.DaVinciResolve/* "DaVinci Resolve cache" - safe_clean ~/Library/Caches/com.adobe.PremierePro.*/* "Premiere Pro cache" -} -# 3D and CAD tools. -clean_3d_tools() { - safe_clean ~/Library/Caches/org.blenderfoundation.blender/* "Blender cache" - safe_clean ~/Library/Caches/com.maxon.cinema4d/* "Cinema 4D cache" - safe_clean ~/Library/Caches/com.autodesk.*/* "Autodesk cache" - safe_clean ~/Library/Caches/com.sketchup.*/* "SketchUp cache" -} -# Productivity apps. -clean_productivity_apps() { - safe_clean ~/Library/Caches/com.tw93.MiaoYan/* "MiaoYan cache" - safe_clean ~/Library/Caches/com.klee.desktop/* "Klee cache" - safe_clean ~/Library/Caches/klee_desktop/* "Klee desktop cache" - safe_clean ~/Library/Caches/com.orabrowser.app/* "Ora browser cache" - safe_clean ~/Library/Caches/com.filo.client/* "Filo cache" - safe_clean ~/Library/Caches/com.flomoapp.mac/* "Flomo cache" - safe_clean ~/Library/Application\ Support/Quark/Cache/videoCache/* "Quark video cache" -} -# Music/media players (protect Spotify offline music). -clean_media_players() { - local spotify_cache="$HOME/Library/Caches/com.spotify.client" - local spotify_data="$HOME/Library/Application Support/Spotify" - local has_offline_music=false - # Heuristics: offline DB or large cache. - if [[ -f "$spotify_data/PersistentCache/Storage/offline.bnk" ]] || - [[ -d "$spotify_data/PersistentCache/Storage" && -n "$(find "$spotify_data/PersistentCache/Storage" -type f -name "*.file" 2> /dev/null | head -1)" ]]; then - has_offline_music=true - elif [[ -d "$spotify_cache" ]]; then - local cache_size_kb - cache_size_kb=$(get_path_size_kb "$spotify_cache") - if [[ $cache_size_kb -ge 512000 ]]; then - has_offline_music=true - fi - fi - if [[ "$has_offline_music" == "true" ]]; then - echo -e " ${YELLOW}${ICON_WARNING}${NC} Spotify cache protected · offline music detected" - note_activity - else - safe_clean ~/Library/Caches/com.spotify.client/* "Spotify cache" - fi - safe_clean ~/Library/Caches/com.apple.Music "Apple Music cache" - safe_clean ~/Library/Caches/com.apple.podcasts "Apple Podcasts cache" - safe_clean ~/Library/Caches/com.apple.TV/* "Apple TV cache" - safe_clean ~/Library/Caches/tv.plex.player.desktop "Plex cache" - safe_clean ~/Library/Caches/com.netease.163music "NetEase Music cache" - safe_clean ~/Library/Caches/com.tencent.QQMusic/* "QQ Music cache" - safe_clean ~/Library/Caches/com.kugou.mac/* "Kugou Music cache" - safe_clean ~/Library/Caches/com.kuwo.mac/* "Kuwo Music cache" -} -# Video players. -clean_video_players() { - safe_clean ~/Library/Caches/com.colliderli.iina "IINA cache" - safe_clean ~/Library/Caches/org.videolan.vlc "VLC cache" - safe_clean ~/Library/Caches/io.mpv "MPV cache" - safe_clean ~/Library/Caches/com.iqiyi.player "iQIYI cache" - safe_clean ~/Library/Caches/com.tencent.tenvideo "Tencent Video cache" - safe_clean ~/Library/Caches/tv.danmaku.bili/* "Bilibili cache" - safe_clean ~/Library/Caches/com.douyu.*/* "Douyu cache" - safe_clean ~/Library/Caches/com.huya.*/* "Huya cache" -} -# Download managers. -clean_download_managers() { - safe_clean ~/Library/Caches/net.xmac.aria2gui "Aria2 cache" - safe_clean ~/Library/Caches/org.m0k.transmission "Transmission cache" - safe_clean ~/Library/Caches/com.qbittorrent.qBittorrent "qBittorrent cache" - safe_clean ~/Library/Caches/com.downie.Downie-* "Downie cache" - safe_clean ~/Library/Caches/com.folx.*/* "Folx cache" - safe_clean ~/Library/Caches/com.charlessoft.pacifist/* "Pacifist cache" -} -# Gaming platforms. -clean_gaming_platforms() { - safe_clean ~/Library/Caches/com.valvesoftware.steam/* "Steam cache" - safe_clean ~/Library/Application\ Support/Steam/htmlcache/* "Steam web cache" - safe_clean ~/Library/Caches/com.epicgames.EpicGamesLauncher/* "Epic Games cache" - safe_clean ~/Library/Caches/com.blizzard.Battle.net/* "Battle.net cache" - safe_clean ~/Library/Application\ Support/Battle.net/Cache/* "Battle.net app cache" - safe_clean ~/Library/Caches/com.ea.*/* "EA Origin cache" - safe_clean ~/Library/Caches/com.gog.galaxy/* "GOG Galaxy cache" - safe_clean ~/Library/Caches/com.riotgames.*/* "Riot Games cache" -} -# Translation/dictionary apps. -clean_translation_apps() { - safe_clean ~/Library/Caches/com.youdao.YoudaoDict "Youdao Dictionary cache" - safe_clean ~/Library/Caches/com.eudic.* "Eudict cache" - safe_clean ~/Library/Caches/com.bob-build.Bob "Bob Translation cache" -} -# Screenshot/recording tools. -clean_screenshot_tools() { - safe_clean ~/Library/Caches/com.cleanshot.* "CleanShot cache" - safe_clean ~/Library/Caches/com.reincubate.camo "Camo cache" - safe_clean ~/Library/Caches/com.xnipapp.xnip "Xnip cache" -} -# Email clients. -clean_email_clients() { - safe_clean ~/Library/Caches/com.readdle.smartemail-Mac "Spark cache" - safe_clean ~/Library/Caches/com.airmail.* "Airmail cache" -} -# Task management apps. -clean_task_apps() { - safe_clean ~/Library/Caches/com.todoist.mac.Todoist "Todoist cache" - safe_clean ~/Library/Caches/com.any.do.* "Any.do cache" -} -# Shell/terminal utilities. -clean_shell_utils() { - safe_clean ~/.zcompdump* "Zsh completion cache" - safe_clean ~/.lesshst "less history" - safe_clean ~/.viminfo.tmp "Vim temporary files" - safe_clean ~/.wget-hsts "wget HSTS cache" -} -# Input methods and system utilities. -clean_system_utils() { - safe_clean ~/Library/Caches/com.runjuu.Input-Source-Pro/* "Input Source Pro cache" - safe_clean ~/Library/Caches/macos-wakatime.WakaTime/* "WakaTime cache" -} -# Note-taking apps. -clean_note_apps() { - safe_clean ~/Library/Caches/notion.id/* "Notion cache" - safe_clean ~/Library/Caches/md.obsidian/* "Obsidian cache" - safe_clean ~/Library/Caches/com.logseq.*/* "Logseq cache" - safe_clean ~/Library/Caches/com.bear-writer.*/* "Bear cache" - safe_clean ~/Library/Caches/com.evernote.*/* "Evernote cache" - safe_clean ~/Library/Caches/com.yinxiang.*/* "Yinxiang Note cache" -} -# Launchers and automation tools. -clean_launcher_apps() { - safe_clean ~/Library/Caches/com.runningwithcrayons.Alfred/* "Alfred cache" - safe_clean ~/Library/Caches/cx.c3.theunarchiver/* "The Unarchiver cache" -} -# Remote desktop tools. -clean_remote_desktop() { - safe_clean ~/Library/Caches/com.teamviewer.*/* "TeamViewer cache" - safe_clean ~/Library/Caches/com.anydesk.*/* "AnyDesk cache" - safe_clean ~/Library/Caches/com.todesk.*/* "ToDesk cache" - safe_clean ~/Library/Caches/com.sunlogin.*/* "Sunlogin cache" -} -# Main entry for GUI app cleanup. -clean_user_gui_applications() { - stop_section_spinner - clean_xcode_tools - clean_code_editors - clean_communication_apps - clean_dingtalk - clean_ai_apps - clean_design_tools - clean_video_tools - clean_3d_tools - clean_productivity_apps - clean_media_players - clean_video_players - clean_download_managers - clean_gaming_platforms - clean_translation_apps - clean_screenshot_tools - clean_email_clients - clean_task_apps - clean_shell_utils - clean_system_utils - clean_note_apps - clean_launcher_apps - clean_remote_desktop -} diff --git a/windows/lib/clean/apps.ps1 b/lib/clean/apps.ps1 similarity index 100% rename from windows/lib/clean/apps.ps1 rename to lib/clean/apps.ps1 diff --git a/lib/clean/apps.sh b/lib/clean/apps.sh deleted file mode 100644 index 694238c..0000000 --- a/lib/clean/apps.sh +++ /dev/null @@ -1,313 +0,0 @@ -#!/bin/bash -# Application Data Cleanup Module -set -euo pipefail -# Args: $1=target_dir, $2=label -clean_ds_store_tree() { - local target="$1" - local label="$2" - [[ -d "$target" ]] || return 0 - local file_count=0 - local total_bytes=0 - local spinner_active="false" - if [[ -t 1 ]]; then - MOLE_SPINNER_PREFIX=" " - start_inline_spinner "Cleaning Finder metadata..." - spinner_active="true" - fi - local -a exclude_paths=( - -path "*/Library/Application Support/MobileSync" -prune -o - -path "*/Library/Developer" -prune -o - -path "*/.Trash" -prune -o - -path "*/node_modules" -prune -o - -path "*/.git" -prune -o - -path "*/Library/Caches" -prune -o - ) - local -a find_cmd=("command" "find" "$target") - if [[ "$target" == "$HOME" ]]; then - find_cmd+=("-maxdepth" "5") - fi - find_cmd+=("${exclude_paths[@]}" "-type" "f" "-name" ".DS_Store" "-print0") - while IFS= read -r -d '' ds_file; do - local size - size=$(get_file_size "$ds_file") - total_bytes=$((total_bytes + size)) - ((file_count++)) - if [[ "$DRY_RUN" != "true" ]]; then - rm -f "$ds_file" 2> /dev/null || true - fi - if [[ $file_count -ge $MOLE_MAX_DS_STORE_FILES ]]; then - break - fi - done < <("${find_cmd[@]}" 2> /dev/null || true) - if [[ "$spinner_active" == "true" ]]; then - stop_section_spinner - fi - if [[ $file_count -gt 0 ]]; then - 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}" - else - echo -e " ${GREEN}${ICON_SUCCESS}${NC} $label ${GREEN}($file_count files, $size_human)${NC}" - fi - local size_kb=$(((total_bytes + 1023) / 1024)) - ((files_cleaned += file_count)) - ((total_size_cleaned += size_kb)) - ((total_items++)) - note_activity - fi -} -# Orphaned app data (60+ days inactive). Env: ORPHAN_AGE_THRESHOLD, DRY_RUN -# Usage: scan_installed_apps "output_file" -scan_installed_apps() { - local installed_bundles="$1" - # Cache installed app scan briefly to speed repeated runs. - local cache_file="$HOME/.cache/mole/installed_apps_cache" - local cache_age_seconds=300 # 5 minutes - if [[ -f "$cache_file" ]]; then - local cache_mtime=$(get_file_mtime "$cache_file") - local current_time - 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)" - if [[ -r "$cache_file" ]] && [[ -s "$cache_file" ]]; then - if cat "$cache_file" > "$installed_bundles" 2> /dev/null; then - return 0 - else - debug_log "Warning: Failed to read cache, rebuilding" - fi - else - debug_log "Warning: Cache file empty or unreadable, rebuilding" - fi - fi - fi - debug_log "Scanning installed applications (cache expired or missing)" - local -a app_dirs=( - "/Applications" - "/System/Applications" - "$HOME/Applications" - # Homebrew Cask locations - "/opt/homebrew/Caskroom" - "/usr/local/Caskroom" - # Setapp applications - "$HOME/Library/Application Support/Setapp/Applications" - ) - # Temp dir avoids write contention across parallel scans. - local scan_tmp_dir=$(create_temp_dir) - local pids=() - local dir_idx=0 - for app_dir in "${app_dirs[@]}"; do - [[ -d "$app_dir" ]] || continue - ( - local -a app_paths=() - while IFS= read -r app_path; do - [[ -n "$app_path" ]] && app_paths+=("$app_path") - done < <(find "$app_dir" -name '*.app' -maxdepth 3 -type d 2> /dev/null) - local count=0 - for app_path in "${app_paths[@]:-}"; do - local plist_path="$app_path/Contents/Info.plist" - [[ ! -f "$plist_path" ]] && continue - local bundle_id=$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "$plist_path" 2> /dev/null || echo "") - if [[ -n "$bundle_id" ]]; then - echo "$bundle_id" - ((count++)) - fi - done - ) > "$scan_tmp_dir/apps_${dir_idx}.txt" & - pids+=($!) - ((dir_idx++)) - done - # Collect running apps and LaunchAgents to avoid false orphan cleanup. - ( - local running_apps=$(run_with_timeout 5 osascript -e 'tell application "System Events" to get bundle identifier of every application process' 2> /dev/null || echo "") - echo "$running_apps" | tr ',' '\n' | sed -e 's/^ *//;s/ *$//' -e '/^$/d' > "$scan_tmp_dir/running.txt" - # Fallback: lsappinfo is more reliable than osascript - if command -v lsappinfo > /dev/null 2>&1; then - run_with_timeout 3 lsappinfo list 2> /dev/null | grep -o '"CFBundleIdentifier"="[^"]*"' | cut -d'"' -f4 >> "$scan_tmp_dir/running.txt" 2> /dev/null || true - fi - ) & - pids+=($!) - ( - run_with_timeout 5 find ~/Library/LaunchAgents /Library/LaunchAgents \ - -name "*.plist" -type f 2> /dev/null | - xargs -I {} basename {} .plist > "$scan_tmp_dir/agents.txt" 2> /dev/null || true - ) & - pids+=($!) - debug_log "Waiting for ${#pids[@]} background processes: ${pids[*]}" - for pid in "${pids[@]}"; do - wait "$pid" 2> /dev/null || true - done - debug_log "All background processes completed" - cat "$scan_tmp_dir"/*.txt >> "$installed_bundles" 2> /dev/null || true - safe_remove "$scan_tmp_dir" true - sort -u "$installed_bundles" -o "$installed_bundles" - ensure_user_dir "$(dirname "$cache_file")" - cp "$installed_bundles" "$cache_file" 2> /dev/null || true - local app_count=$(wc -l < "$installed_bundles" 2> /dev/null | tr -d ' ') - debug_log "Scanned $app_count unique applications" -} -# Sensitive data patterns that should never be treated as orphaned -# These patterns protect security-critical application data -readonly ORPHAN_NEVER_DELETE_PATTERNS=( - "*1password*" "*1Password*" - "*keychain*" "*Keychain*" - "*bitwarden*" "*Bitwarden*" - "*lastpass*" "*LastPass*" - "*keepass*" "*KeePass*" - "*dashlane*" "*Dashlane*" - "*enpass*" "*Enpass*" - "*ssh*" "*gpg*" "*gnupg*" - "com.apple.keychain*" -) - -# Cache file for mdfind results (Bash 3.2 compatible, no associative arrays) -ORPHAN_MDFIND_CACHE_FILE="" - -# Usage: is_bundle_orphaned "bundle_id" "directory_path" "installed_bundles_file" -is_bundle_orphaned() { - local bundle_id="$1" - local directory_path="$2" - local installed_bundles="$3" - - # 1. Fast path: check protection list (in-memory, instant) - if should_protect_data "$bundle_id"; then - return 1 - fi - - # 2. Fast path: check sensitive data patterns (in-memory, instant) - local bundle_lower - bundle_lower=$(echo "$bundle_id" | LC_ALL=C tr '[:upper:]' '[:lower:]') - for pattern in "${ORPHAN_NEVER_DELETE_PATTERNS[@]}"; do - # shellcheck disable=SC2053 - if [[ "$bundle_lower" == $pattern ]]; then - return 1 - fi - done - - # 3. Fast path: check installed bundles file (file read, fast) - if grep -Fxq "$bundle_id" "$installed_bundles" 2> /dev/null; then - return 1 - fi - - # 4. Fast path: hardcoded system components - case "$bundle_id" in - loginwindow | dock | systempreferences | systemsettings | settings | controlcenter | finder | safari) - return 1 - ;; - esac - - # 5. Fast path: 60-day modification check (stat call, fast) - if [[ -e "$directory_path" ]]; then - local last_modified_epoch=$(get_file_mtime "$directory_path") - local current_epoch - current_epoch=$(get_epoch_seconds) - local days_since_modified=$(((current_epoch - last_modified_epoch) / 86400)) - if [[ $days_since_modified -lt ${ORPHAN_AGE_THRESHOLD:-60} ]]; then - return 1 - fi - fi - - # 6. Slow path: mdfind fallback with file-based caching (Bash 3.2 compatible) - # This catches apps installed in non-standard locations - if [[ -n "$bundle_id" ]] && [[ "$bundle_id" =~ ^[a-zA-Z0-9._-]+$ ]] && [[ ${#bundle_id} -ge 5 ]]; then - # Initialize cache file if needed - if [[ -z "$ORPHAN_MDFIND_CACHE_FILE" ]]; then - ORPHAN_MDFIND_CACHE_FILE=$(mktemp "${TMPDIR:-/tmp}/mole_mdfind_cache.XXXXXX") - register_temp_file "$ORPHAN_MDFIND_CACHE_FILE" - fi - - # Check cache first (grep is fast for small files) - if grep -Fxq "FOUND:$bundle_id" "$ORPHAN_MDFIND_CACHE_FILE" 2> /dev/null; then - return 1 - fi - if grep -Fxq "NOTFOUND:$bundle_id" "$ORPHAN_MDFIND_CACHE_FILE" 2> /dev/null; then - # Already checked, not found - continue to return 0 - : - else - # Query mdfind with strict timeout (2 seconds max) - local app_exists - app_exists=$(run_with_timeout 2 mdfind "kMDItemCFBundleIdentifier == '$bundle_id'" 2> /dev/null | head -1 || echo "") - if [[ -n "$app_exists" ]]; then - echo "FOUND:$bundle_id" >> "$ORPHAN_MDFIND_CACHE_FILE" - return 1 - else - echo "NOTFOUND:$bundle_id" >> "$ORPHAN_MDFIND_CACHE_FILE" - fi - fi - fi - - # All checks passed - this is an orphan - return 0 -} -# Orphaned app data sweep. -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" - return 0 - fi - start_section_spinner "Scanning installed apps..." - local installed_bundles=$(create_temp_file) - scan_installed_apps "$installed_bundles" - stop_section_spinner - local app_count=$(wc -l < "$installed_bundles" 2> /dev/null | tr -d ' ') - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Found $app_count active/installed apps" - local orphaned_count=0 - local total_orphaned_kb=0 - start_section_spinner "Scanning orphaned app resources..." - # CRITICAL: NEVER add LaunchAgents or LaunchDaemons (breaks login items/startup apps). - local -a resource_types=( - "$HOME/Library/Caches|Caches|com.*:org.*:net.*:io.*" - "$HOME/Library/Logs|Logs|com.*:org.*:net.*:io.*" - "$HOME/Library/Saved Application State|States|*.savedState" - "$HOME/Library/WebKit|WebKit|com.*:org.*:net.*:io.*" - "$HOME/Library/HTTPStorages|HTTP|com.*:org.*:net.*:io.*" - "$HOME/Library/Cookies|Cookies|*.binarycookies" - ) - orphaned_count=0 - for resource_type in "${resource_types[@]}"; do - IFS='|' read -r base_path label patterns <<< "$resource_type" - if [[ ! -d "$base_path" ]]; then - continue - fi - if ! ls "$base_path" > /dev/null 2>&1; then - continue - fi - local -a file_patterns=() - IFS=':' read -ra pattern_arr <<< "$patterns" - for pat in "${pattern_arr[@]}"; do - file_patterns+=("$base_path/$pat") - done - for item_path in "${file_patterns[@]}"; do - local iteration_count=0 - for match in $item_path; do - [[ -e "$match" ]] || continue - ((iteration_count++)) - if [[ $iteration_count -gt $MOLE_MAX_ORPHAN_ITERATIONS ]]; then - break - fi - local bundle_id=$(basename "$match") - bundle_id="${bundle_id%.savedState}" - bundle_id="${bundle_id%.binarycookies}" - if is_bundle_orphaned "$bundle_id" "$match" "$installed_bundles"; then - local size_kb - size_kb=$(get_path_size_kb "$match") - if [[ -z "$size_kb" || "$size_kb" == "0" ]]; then - continue - fi - safe_clean "$match" "Orphaned $label: $bundle_id" - ((orphaned_count++)) - ((total_orphaned_kb += size_kb)) - fi - done - done - done - 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)" - note_activity - fi - rm -f "$installed_bundles" -} diff --git a/lib/clean/brew.sh b/lib/clean/brew.sh deleted file mode 100644 index b30fa7f..0000000 --- a/lib/clean/brew.sh +++ /dev/null @@ -1,117 +0,0 @@ -#!/bin/bash -# Clean Homebrew caches and remove orphaned dependencies -# Env: DRY_RUN -# Skips if run within 7 days, runs cleanup/autoremove in parallel with 120s timeout -clean_homebrew() { - command -v brew > /dev/null 2>&1 || return 0 - if [[ "${DRY_RUN:-false}" == "true" ]]; then - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Homebrew · would cleanup and autoremove" - return 0 - fi - # Skip if cleaned recently to avoid repeated heavy operations. - local brew_cache_file="${HOME}/.cache/mole/brew_last_cleanup" - local cache_valid_days=7 - local should_skip=false - if [[ -f "$brew_cache_file" ]]; then - local last_cleanup - last_cleanup=$(cat "$brew_cache_file" 2> /dev/null || echo "0") - local current_time - current_time=$(get_epoch_seconds) - local time_diff=$((current_time - last_cleanup)) - local days_diff=$((time_diff / 86400)) - if [[ $days_diff -lt $cache_valid_days ]]; then - should_skip=true - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Homebrew · cleaned ${days_diff}d ago, skipped" - fi - fi - [[ "$should_skip" == "true" ]] && return 0 - # Skip cleanup if cache is small; still run autoremove. - local skip_cleanup=false - local brew_cache_size=0 - if [[ -d ~/Library/Caches/Homebrew ]]; then - brew_cache_size=$(run_with_timeout 3 du -sk ~/Library/Caches/Homebrew 2> /dev/null | awk '{print $1}') - local du_exit=$? - if [[ $du_exit -eq 0 && -n "$brew_cache_size" && "$brew_cache_size" -lt 51200 ]]; then - skip_cleanup=true - fi - fi - # Spinner reflects whether cleanup is skipped. - if [[ -t 1 ]]; then - if [[ "$skip_cleanup" == "true" ]]; then - MOLE_SPINNER_PREFIX=" " start_inline_spinner "Homebrew autoremove (cleanup skipped)..." - else - MOLE_SPINNER_PREFIX=" " start_inline_spinner "Homebrew cleanup and autoremove..." - fi - fi - # Run cleanup/autoremove in parallel with timeout guard per command. - local timeout_seconds=120 - local brew_tmp_file autoremove_tmp_file - local brew_pid autoremove_pid - local brew_exit=0 - local autoremove_exit=0 - if [[ "$skip_cleanup" == "false" ]]; then - brew_tmp_file=$(create_temp_file) - run_with_timeout "$timeout_seconds" brew cleanup > "$brew_tmp_file" 2>&1 & - brew_pid=$! - fi - autoremove_tmp_file=$(create_temp_file) - run_with_timeout "$timeout_seconds" brew autoremove > "$autoremove_tmp_file" 2>&1 & - autoremove_pid=$! - - if [[ -n "$brew_pid" ]]; then - wait "$brew_pid" 2> /dev/null || brew_exit=$? - fi - wait "$autoremove_pid" 2> /dev/null || autoremove_exit=$? - - local brew_success=false - if [[ "$skip_cleanup" == "false" && $brew_exit -eq 0 ]]; then - brew_success=true - fi - local autoremove_success=false - if [[ $autoremove_exit -eq 0 ]]; then - autoremove_success=true - fi - if [[ -t 1 ]]; then stop_inline_spinner; fi - # Process cleanup output and extract metrics - # Summarize cleanup results. - if [[ "$skip_cleanup" == "true" ]]; then - # Cleanup was skipped due to small cache size - local size_mb=$((brew_cache_size / 1024)) - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Homebrew cleanup · cache ${size_mb}MB, skipped" - elif [[ "$brew_success" == "true" && -f "$brew_tmp_file" ]]; then - local brew_output - brew_output=$(cat "$brew_tmp_file" 2> /dev/null || echo "") - local removed_count freed_space - removed_count=$(printf '%s\n' "$brew_output" | grep -c "Removing:" 2> /dev/null || true) - 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}" - else - 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" - fi - # Process autoremove output - only show if packages were removed - # Only surface autoremove output when packages were removed. - if [[ "$autoremove_success" == "true" && -f "$autoremove_tmp_file" ]]; then - local autoremove_output - autoremove_output=$(cat "$autoremove_tmp_file" 2> /dev/null || echo "") - 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)" - fi - elif [[ $autoremove_exit -eq 124 ]]; then - echo -e " ${YELLOW}${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 - # Update cache timestamp when any work succeeded or was intentionally skipped. - if [[ "$skip_cleanup" == "true" ]] || [[ "$brew_success" == "true" ]] || [[ "$autoremove_success" == "true" ]]; then - ensure_user_file "$brew_cache_file" - get_epoch_seconds > "$brew_cache_file" - fi -} diff --git a/windows/lib/clean/caches.ps1 b/lib/clean/caches.ps1 similarity index 100% rename from windows/lib/clean/caches.ps1 rename to lib/clean/caches.ps1 diff --git a/lib/clean/caches.sh b/lib/clean/caches.sh deleted file mode 100644 index 2fdfe87..0000000 --- a/lib/clean/caches.sh +++ /dev/null @@ -1,217 +0,0 @@ -#!/bin/bash -# Cache Cleanup Module -set -euo pipefail -# Preflight TCC prompts once to avoid mid-run interruptions. -check_tcc_permissions() { - [[ -t 1 ]] || return 0 - local permission_flag="$HOME/.cache/mole/permissions_granted" - [[ -f "$permission_flag" ]] && return 0 - local -a tcc_dirs=( - "$HOME/Library/Caches" - "$HOME/Library/Logs" - "$HOME/Library/Application Support" - "$HOME/Library/Containers" - "$HOME/.cache" - ) - # Quick permission probe (avoid deep scans). - local needs_permission_check=false - if ! ls "$HOME/Library/Caches" > /dev/null 2>&1; then - needs_permission_check=true - fi - if [[ "$needs_permission_check" == "true" ]]; then - 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 "" - echo -ne "${PURPLE}${ICON_ARROW}${NC} Press ${GREEN}Enter${NC} to continue: " - read -r - MOLE_SPINNER_PREFIX="" start_inline_spinner "Requesting permissions..." - # Touch each directory to trigger prompts without deep scanning. - for dir in "${tcc_dirs[@]}"; do - [[ -d "$dir" ]] && command find "$dir" -maxdepth 1 -type d > /dev/null 2>&1 - done - stop_inline_spinner - echo "" - fi - # Mark as granted to avoid repeat prompts. - ensure_user_file "$permission_flag" - return 0 -} -# Args: $1=browser_name, $2=cache_path -# Clean Service Worker cache while protecting critical web editors. -clean_service_worker_cache() { - local browser_name="$1" - local cache_path="$2" - [[ ! -d "$cache_path" ]] && return 0 - local cleaned_size=0 - local protected_count=0 - while IFS= read -r cache_dir; do - [[ ! -d "$cache_dir" ]] && continue - # Extract a best-effort domain name from cache folder. - local domain=$(basename "$cache_dir" | grep -oE '[a-zA-Z0-9][-a-zA-Z0-9]*\.[a-zA-Z]{2,}' | head -1 || echo "") - local size=$(run_with_timeout 5 get_path_size_kb "$cache_dir") - local is_protected=false - for protected_domain in "${PROTECTED_SW_DOMAINS[@]}"; do - if [[ "$domain" == *"$protected_domain"* ]]; then - is_protected=true - protected_count=$((protected_count + 1)) - break - fi - done - if [[ "$is_protected" == "false" ]]; then - if [[ "$DRY_RUN" != "true" ]]; then - safe_remove "$cache_dir" true || true - fi - cleaned_size=$((cleaned_size + size)) - fi - done < <(run_with_timeout 10 sh -c "find '$cache_path' -type d -depth 2 2> /dev/null || true") - if [[ $cleaned_size -gt 0 ]]; then - local spinner_was_running=false - if [[ -t 1 && -n "${INLINE_SPINNER_PID:-}" ]]; then - stop_inline_spinner - spinner_was_running=true - fi - 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)" - else - 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)" - fi - note_activity - if [[ "$spinner_was_running" == "true" ]]; then - MOLE_SPINNER_PREFIX=" " start_inline_spinner "Scanning browser Service Worker caches..." - fi - fi -} -# Next.js/Python project caches with tight scan bounds and timeouts. -clean_project_caches() { - stop_inline_spinner 2> /dev/null || true - # Fast pre-check before scanning the whole home dir. - local has_dev_projects=false - local -a common_dev_dirs=( - "$HOME/Code" - "$HOME/Projects" - "$HOME/workspace" - "$HOME/github" - "$HOME/dev" - "$HOME/work" - "$HOME/src" - "$HOME/repos" - "$HOME/Development" - "$HOME/www" - "$HOME/golang" - "$HOME/go" - "$HOME/rust" - "$HOME/python" - "$HOME/ruby" - "$HOME/java" - "$HOME/dotnet" - "$HOME/node" - ) - for dir in "${common_dev_dirs[@]}"; do - if [[ -d "$dir" ]]; then - has_dev_projects=true - break - fi - done - # Fallback: look for project markers near $HOME. - if [[ "$has_dev_projects" == "false" ]]; then - local -a project_markers=( - "node_modules" - ".git" - "target" - "go.mod" - "Cargo.toml" - "package.json" - "pom.xml" - "build.gradle" - ) - local spinner_active=false - if [[ -t 1 ]]; then - MOLE_SPINNER_PREFIX=" " - start_inline_spinner "Detecting dev projects..." - spinner_active=true - fi - for marker in "${project_markers[@]}"; do - if run_with_timeout 3 sh -c "find '$HOME' -maxdepth 2 -name '$marker' -not -path '*/Library/*' -not -path '*/.Trash/*' 2>/dev/null | head -1" | grep -q .; then - has_dev_projects=true - break - fi - done - if [[ "$spinner_active" == "true" ]]; then - stop_inline_spinner 2> /dev/null || true - fi - [[ "$has_dev_projects" == "false" ]] && return 0 - fi - if [[ -t 1 ]]; then - MOLE_SPINNER_PREFIX=" " - start_inline_spinner "Searching project caches..." - fi - local nextjs_tmp_file - nextjs_tmp_file=$(create_temp_file) - local pycache_tmp_file - pycache_tmp_file=$(create_temp_file) - local find_timeout=10 - # Parallel scans (Next.js and __pycache__). - ( - command find "$HOME" -P -mount -type d -name ".next" -maxdepth 3 \ - -not -path "*/Library/*" \ - -not -path "*/.Trash/*" \ - -not -path "*/node_modules/*" \ - -not -path "*/.*" \ - 2> /dev/null || true - ) > "$nextjs_tmp_file" 2>&1 & - local next_pid=$! - ( - command find "$HOME" -P -mount -type d -name "__pycache__" -maxdepth 3 \ - -not -path "*/Library/*" \ - -not -path "*/.Trash/*" \ - -not -path "*/node_modules/*" \ - -not -path "*/.*" \ - 2> /dev/null || true - ) > "$pycache_tmp_file" 2>&1 & - local py_pid=$! - local elapsed=0 - local check_interval=0.2 # Check every 200ms instead of 1s for smoother experience - while [[ $(echo "$elapsed < $find_timeout" | awk '{print ($1 < $2)}') -eq 1 ]]; do - if ! kill -0 $next_pid 2> /dev/null && ! kill -0 $py_pid 2> /dev/null; then - break - fi - sleep $check_interval - elapsed=$(echo "$elapsed + $check_interval" | awk '{print $1 + $2}') - done - # Kill stuck scans after timeout. - for pid in $next_pid $py_pid; do - if kill -0 "$pid" 2> /dev/null; then - kill -TERM "$pid" 2> /dev/null || true - local grace_period=0 - while [[ $grace_period -lt 20 ]]; do - if ! kill -0 "$pid" 2> /dev/null; then - break - fi - sleep 0.1 - ((grace_period++)) - done - if kill -0 "$pid" 2> /dev/null; then - kill -KILL "$pid" 2> /dev/null || true - fi - wait "$pid" 2> /dev/null || true - else - wait "$pid" 2> /dev/null || true - fi - done - if [[ -t 1 ]]; then - stop_inline_spinner - fi - while IFS= read -r next_dir; do - [[ -d "$next_dir/cache" ]] && safe_clean "$next_dir/cache"/* "Next.js build cache" || true - done < "$nextjs_tmp_file" - while IFS= read -r pycache; do - [[ -d "$pycache" ]] && safe_clean "$pycache"/* "Python bytecode cache" || true - done < "$pycache_tmp_file" -} diff --git a/windows/lib/clean/dev.ps1 b/lib/clean/dev.ps1 similarity index 100% rename from windows/lib/clean/dev.ps1 rename to lib/clean/dev.ps1 diff --git a/lib/clean/dev.sh b/lib/clean/dev.sh deleted file mode 100644 index bb519e7..0000000 --- a/lib/clean/dev.sh +++ /dev/null @@ -1,296 +0,0 @@ -#!/bin/bash -# Developer Tools Cleanup Module -set -euo pipefail -# Tool cache helper (respects DRY_RUN). -clean_tool_cache() { - local description="$1" - shift - if [[ "$DRY_RUN" != "true" ]]; then - if "$@" > /dev/null 2>&1; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} $description" - fi - else - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} $description · would clean" - fi - return 0 -} -# npm/pnpm/yarn/bun caches. -clean_dev_npm() { - if command -v npm > /dev/null 2>&1; then - clean_tool_cache "npm cache" npm cache clean --force - note_activity - fi - # Clean pnpm store cache - local pnpm_default_store=~/Library/pnpm/store - # Check if pnpm is actually usable (not just Corepack shim) - if command -v pnpm > /dev/null 2>&1 && COREPACK_ENABLE_DOWNLOAD_PROMPT=0 pnpm --version > /dev/null 2>&1; then - COREPACK_ENABLE_DOWNLOAD_PROMPT=0 clean_tool_cache "pnpm cache" pnpm store prune - local pnpm_store_path - start_section_spinner "Checking store path..." - pnpm_store_path=$(COREPACK_ENABLE_DOWNLOAD_PROMPT=0 run_with_timeout 2 pnpm store path 2> /dev/null) || pnpm_store_path="" - stop_section_spinner - if [[ -n "$pnpm_store_path" && "$pnpm_store_path" != "$pnpm_default_store" ]]; then - safe_clean "$pnpm_default_store"/* "Orphaned pnpm store" - fi - else - # pnpm not installed or not usable, just clean the default store directory - safe_clean "$pnpm_default_store"/* "pnpm store" - fi - note_activity - safe_clean ~/.tnpm/_cacache/* "tnpm cache directory" - safe_clean ~/.tnpm/_logs/* "tnpm logs" - safe_clean ~/.yarn/cache/* "Yarn cache" - safe_clean ~/.bun/install/cache/* "Bun cache" -} -# Python/pip ecosystem caches. -clean_dev_python() { - if command -v pip3 > /dev/null 2>&1; then - clean_tool_cache "pip cache" bash -c 'pip3 cache purge >/dev/null 2>&1 || true' - note_activity - fi - safe_clean ~/.pyenv/cache/* "pyenv cache" - safe_clean ~/.cache/poetry/* "Poetry cache" - safe_clean ~/.cache/uv/* "uv cache" - safe_clean ~/.cache/ruff/* "Ruff cache" - safe_clean ~/.cache/mypy/* "MyPy cache" - safe_clean ~/.pytest_cache/* "Pytest cache" - safe_clean ~/.jupyter/runtime/* "Jupyter runtime cache" - safe_clean ~/.cache/huggingface/* "Hugging Face cache" - safe_clean ~/.cache/torch/* "PyTorch cache" - safe_clean ~/.cache/tensorflow/* "TensorFlow cache" - safe_clean ~/.conda/pkgs/* "Conda packages cache" - safe_clean ~/anaconda3/pkgs/* "Anaconda packages cache" - safe_clean ~/.cache/wandb/* "Weights & Biases cache" -} -# 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' - note_activity - fi -} -# Rust/cargo caches. -clean_dev_rust() { - safe_clean ~/.cargo/registry/cache/* "Rust cargo cache" - safe_clean ~/.cargo/git/* "Cargo git cache" - safe_clean ~/.rustup/downloads/* "Rust downloads cache" -} -# Docker caches (guarded by daemon check). -clean_dev_docker() { - if command -v docker > /dev/null 2>&1; then - if [[ "$DRY_RUN" != "true" ]]; then - start_section_spinner "Checking Docker daemon..." - local docker_running=false - if run_with_timeout 3 docker info > /dev/null 2>&1; then - docker_running=true - fi - stop_section_spinner - if [[ "$docker_running" == "true" ]]; then - clean_tool_cache "Docker build cache" docker builder prune -af - else - debug_log "Docker daemon not running, skipping Docker cache cleanup" - fi - else - note_activity - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Docker build cache · would clean" - fi - fi - safe_clean ~/.docker/buildx/cache/* "Docker BuildX cache" -} -# Nix garbage collection. -clean_dev_nix() { - if command -v nix-collect-garbage > /dev/null 2>&1; then - if [[ "$DRY_RUN" != "true" ]]; then - clean_tool_cache "Nix garbage collection" nix-collect-garbage --delete-older-than 30d - else - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Nix garbage collection · would clean" - fi - note_activity - fi -} -# Cloud CLI caches. -clean_dev_cloud() { - safe_clean ~/.kube/cache/* "Kubernetes cache" - safe_clean ~/.local/share/containers/storage/tmp/* "Container storage temp" - safe_clean ~/.aws/cli/cache/* "AWS CLI cache" - safe_clean ~/.config/gcloud/logs/* "Google Cloud logs" - safe_clean ~/.azure/logs/* "Azure CLI logs" -} -# Frontend build caches. -clean_dev_frontend() { - safe_clean ~/.cache/typescript/* "TypeScript cache" - safe_clean ~/.cache/electron/* "Electron cache" - safe_clean ~/.cache/node-gyp/* "node-gyp cache" - safe_clean ~/.node-gyp/* "node-gyp build cache" - safe_clean ~/.turbo/cache/* "Turbo cache" - safe_clean ~/.vite/cache/* "Vite cache" - safe_clean ~/.cache/vite/* "Vite global cache" - safe_clean ~/.cache/webpack/* "Webpack cache" - safe_clean ~/.parcel-cache/* "Parcel cache" - safe_clean ~/.cache/eslint/* "ESLint cache" - safe_clean ~/.cache/prettier/* "Prettier cache" -} -# Mobile dev caches (can be large). -# Check for multiple Android NDK versions. -check_android_ndk() { - local ndk_dir="$HOME/Library/Android/sdk/ndk" - if [[ -d "$ndk_dir" ]]; then - local count - count=$(find "$ndk_dir" -mindepth 1 -maxdepth 1 -type d 2> /dev/null | wc -l | tr -d ' ') - if [[ "$count" -gt 1 ]]; then - note_activity - echo -e " Found ${GREEN}${count}${NC} Android NDK versions" - echo -e " You can delete unused versions manually: ${ndk_dir}" - fi - fi -} - -clean_dev_mobile() { - check_android_ndk - - if command -v xcrun > /dev/null 2>&1; then - debug_log "Checking for unavailable Xcode simulators" - if [[ "$DRY_RUN" == "true" ]]; then - clean_tool_cache "Xcode unavailable simulators" xcrun simctl delete unavailable - else - start_section_spinner "Checking unavailable simulators..." - if xcrun simctl delete unavailable > /dev/null 2>&1; then - stop_section_spinner - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Xcode unavailable simulators" - else - stop_section_spinner - fi - fi - note_activity - fi - # DeviceSupport caches/logs (preserve core support files). - safe_clean ~/Library/Developer/Xcode/iOS\ DeviceSupport/*/Symbols/System/Library/Caches/* "iOS device symbol cache" - safe_clean ~/Library/Developer/Xcode/iOS\ DeviceSupport/*.log "iOS device support logs" - safe_clean ~/Library/Developer/Xcode/watchOS\ DeviceSupport/*/Symbols/System/Library/Caches/* "watchOS device symbol cache" - safe_clean ~/Library/Developer/Xcode/tvOS\ DeviceSupport/*/Symbols/System/Library/Caches/* "tvOS device symbol cache" - # 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 ~/.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" - safe_clean ~/.cache/swift-package-manager/* "Swift package manager cache" -} -# JVM ecosystem caches. -clean_dev_jvm() { - safe_clean ~/.gradle/caches/* "Gradle caches" - safe_clean ~/.gradle/daemon/* "Gradle daemon logs" - safe_clean ~/.sbt/* "SBT cache" - safe_clean ~/.ivy2/cache/* "Ivy cache" -} -# 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 ~/.cache/bazel/* "Bazel cache" - safe_clean ~/.cache/zig/* "Zig cache" - safe_clean ~/Library/Caches/deno/* "Deno cache" -} -# CI/CD and DevOps caches. -clean_dev_cicd() { - safe_clean ~/.cache/terraform/* "Terraform cache" - safe_clean ~/.grafana/cache/* "Grafana cache" - safe_clean ~/.prometheus/data/wal/* "Prometheus WAL cache" - safe_clean ~/.jenkins/workspace/*/target/* "Jenkins workspace cache" - safe_clean ~/.cache/gitlab-runner/* "GitLab Runner cache" - safe_clean ~/.github/cache/* "GitHub Actions cache" - safe_clean ~/.circleci/cache/* "CircleCI cache" - safe_clean ~/.sonar/* "SonarQube cache" -} -# Database tool caches. -clean_dev_database() { - safe_clean ~/Library/Caches/com.sequel-ace.sequel-ace/* "Sequel Ace cache" - safe_clean ~/Library/Caches/com.eggerapps.Sequel-Pro/* "Sequel Pro cache" - safe_clean ~/Library/Caches/redis-desktop-manager/* "Redis Desktop Manager cache" - safe_clean ~/Library/Caches/com.navicat.* "Navicat cache" - safe_clean ~/Library/Caches/com.dbeaver.* "DBeaver cache" - safe_clean ~/Library/Caches/com.redis.RedisInsight "Redis Insight cache" -} -# API/debugging tool caches. -clean_dev_api_tools() { - safe_clean ~/Library/Caches/com.postmanlabs.mac/* "Postman cache" - safe_clean ~/Library/Caches/com.konghq.insomnia/* "Insomnia cache" - safe_clean ~/Library/Caches/com.tinyapp.TablePlus/* "TablePlus cache" - safe_clean ~/Library/Caches/com.getpaw.Paw/* "Paw API cache" - safe_clean ~/Library/Caches/com.charlesproxy.charles/* "Charles Proxy cache" - safe_clean ~/Library/Caches/com.proxyman.NSProxy/* "Proxyman cache" -} -# Misc dev tool caches. -clean_dev_misc() { - safe_clean ~/Library/Caches/com.unity3d.*/* "Unity cache" - safe_clean ~/Library/Caches/com.mongodb.compass/* "MongoDB Compass cache" - safe_clean ~/Library/Caches/com.figma.Desktop/* "Figma cache" - safe_clean ~/Library/Caches/com.github.GitHubDesktop/* "GitHub Desktop cache" - safe_clean ~/Library/Caches/SentryCrash/* "Sentry crash reports" - safe_clean ~/Library/Caches/KSCrash/* "KSCrash reports" - safe_clean ~/Library/Caches/com.crashlytics.data/* "Crashlytics data" -} -# Shell and VCS leftovers. -clean_dev_shell() { - safe_clean ~/.gitconfig.lock "Git config lock" - safe_clean ~/.gitconfig.bak* "Git config backup" - safe_clean ~/.oh-my-zsh/cache/* "Oh My Zsh cache" - safe_clean ~/.config/fish/fish_history.bak* "Fish shell backup" - safe_clean ~/.bash_history.bak* "Bash history backup" - safe_clean ~/.zsh_history.bak* "Zsh history backup" - safe_clean ~/.cache/pre-commit/* "pre-commit cache" -} -# Network tool caches. -clean_dev_network() { - safe_clean ~/.cache/curl/* "curl cache" - safe_clean ~/.cache/wget/* "wget cache" - safe_clean ~/Library/Caches/curl/* "macOS curl cache" - safe_clean ~/Library/Caches/wget/* "macOS wget cache" -} -# Orphaned SQLite temp files (-shm/-wal). Disabled due to low ROI. -clean_sqlite_temp_files() { - return 0 -} -# Main developer tools cleanup sequence. -clean_developer_tools() { - stop_section_spinner - clean_sqlite_temp_files - clean_dev_npm - clean_dev_python - clean_dev_go - clean_dev_rust - clean_dev_docker - clean_dev_cloud - clean_dev_nix - clean_dev_shell - clean_dev_frontend - clean_project_caches - clean_dev_mobile - clean_dev_jvm - clean_dev_other_langs - clean_dev_cicd - clean_dev_database - clean_dev_api_tools - clean_dev_network - clean_dev_misc - safe_clean ~/Library/Caches/Homebrew/* "Homebrew cache" - # Clean Homebrew locks without repeated sudo prompts. - local brew_lock_dirs=( - "/opt/homebrew/var/homebrew/locks" - "/usr/local/var/homebrew/locks" - ) - for lock_dir in "${brew_lock_dirs[@]}"; do - if [[ -d "$lock_dir" && -w "$lock_dir" ]]; then - safe_clean "$lock_dir"/* "Homebrew lock files" - elif [[ -d "$lock_dir" ]]; then - if find "$lock_dir" -mindepth 1 -maxdepth 1 -print -quit 2> /dev/null | grep -q .; then - debug_log "Skipping read-only Homebrew locks in $lock_dir" - fi - fi - done - clean_homebrew -} diff --git a/lib/clean/project.sh b/lib/clean/project.sh deleted file mode 100644 index 7a70ba9..0000000 --- a/lib/clean/project.sh +++ /dev/null @@ -1,925 +0,0 @@ -#!/bin/bash -# Project Purge Module (mo purge). -# Removes heavy project build artifacts and dependencies. -set -euo pipefail - -PROJECT_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -CORE_LIB_DIR="$(cd "$PROJECT_LIB_DIR/../core" && pwd)" -if ! command -v ensure_user_dir > /dev/null 2>&1; then - # shellcheck disable=SC1090 - source "$CORE_LIB_DIR/common.sh" -fi - -# Targets to look for (heavy build artifacts). -readonly PURGE_TARGETS=( - "node_modules" - "target" # Rust, Maven - "build" # Gradle, various - "dist" # JS builds - "venv" # Python - ".venv" # Python - ".pytest_cache" # Python (pytest) - ".mypy_cache" # Python (mypy) - ".tox" # Python (tox virtualenvs) - ".nox" # Python (nox virtualenvs) - ".ruff_cache" # Python (ruff) - ".gradle" # Gradle local - "__pycache__" # Python - ".next" # Next.js - ".nuxt" # Nuxt.js - ".output" # Nuxt.js - "vendor" # PHP Composer - "bin" # .NET build output (guarded; see is_protected_purge_artifact) - "obj" # C# / Unity - ".turbo" # Turborepo cache - ".parcel-cache" # Parcel bundler - ".dart_tool" # Flutter/Dart build cache - ".zig-cache" # Zig - "zig-out" # Zig - ".angular" # Angular - ".svelte-kit" # SvelteKit - ".astro" # Astro - "coverage" # Code coverage reports -) -# Minimum age in days before considering for cleanup. -readonly MIN_AGE_DAYS=7 -# Scan depth defaults (relative to search root). -readonly PURGE_MIN_DEPTH_DEFAULT=2 -readonly PURGE_MAX_DEPTH_DEFAULT=8 -# Search paths (default, can be overridden via config file). -readonly DEFAULT_PURGE_SEARCH_PATHS=( - "$HOME/www" - "$HOME/dev" - "$HOME/Projects" - "$HOME/GitHub" - "$HOME/Code" - "$HOME/Workspace" - "$HOME/Repos" - "$HOME/Development" -) - -# Config file for custom purge paths. -readonly PURGE_CONFIG_FILE="$HOME/.config/mole/purge_paths" - -# Resolved search paths. -PURGE_SEARCH_PATHS=() - -# Project indicators for container detection. -readonly PROJECT_INDICATORS=( - "package.json" - "Cargo.toml" - "go.mod" - "pyproject.toml" - "requirements.txt" - "pom.xml" - "build.gradle" - "Gemfile" - "composer.json" - "pubspec.yaml" - "Makefile" - "build.zig" - "build.zig.zon" - ".git" -) - -# Check if a directory contains projects (directly or in subdirectories). -is_project_container() { - local dir="$1" - local max_depth="${2:-2}" - - # Skip hidden/system directories. - local basename - basename=$(basename "$dir") - [[ "$basename" == .* ]] && return 1 - [[ "$basename" == "Library" ]] && return 1 - [[ "$basename" == "Applications" ]] && return 1 - [[ "$basename" == "Movies" ]] && return 1 - [[ "$basename" == "Music" ]] && return 1 - [[ "$basename" == "Pictures" ]] && return 1 - [[ "$basename" == "Public" ]] && return 1 - - # Single find expression for indicators. - local -a find_args=("$dir" "-maxdepth" "$max_depth" "(") - local first=true - for indicator in "${PROJECT_INDICATORS[@]}"; do - if [[ "$first" == "true" ]]; then - first=false - else - find_args+=("-o") - fi - find_args+=("-name" "$indicator") - done - find_args+=(")" "-print" "-quit") - - if find "${find_args[@]}" 2> /dev/null | grep -q .; then - return 0 - fi - - return 1 -} - -# Discover project directories in $HOME. -discover_project_dirs() { - local -a discovered=() - - for path in "${DEFAULT_PURGE_SEARCH_PATHS[@]}"; do - if [[ -d "$path" ]]; then - discovered+=("$path") - fi - done - - # Scan $HOME for other containers (depth 1). - local dir - for dir in "$HOME"/*/; do - [[ ! -d "$dir" ]] && continue - dir="${dir%/}" # Remove trailing slash - - local already_found=false - for existing in "${DEFAULT_PURGE_SEARCH_PATHS[@]}"; do - if [[ "$dir" == "$existing" ]]; then - already_found=true - break - fi - done - [[ "$already_found" == "true" ]] && continue - - if is_project_container "$dir" 2; then - discovered+=("$dir") - fi - done - - printf '%s\n' "${discovered[@]}" | sort -u -} - -# Save discovered paths to config. -save_discovered_paths() { - local -a paths=("$@") - - ensure_user_dir "$(dirname "$PURGE_CONFIG_FILE")" - - cat > "$PURGE_CONFIG_FILE" << 'EOF' -# Mole Purge Paths - Auto-discovered project directories -# Edit this file to customize, or run: mo purge --paths -# Add one path per line (supports ~ for home directory) -EOF - - printf '\n' >> "$PURGE_CONFIG_FILE" - for path in "${paths[@]}"; do - # Convert $HOME to ~ for portability - path="${path/#$HOME/~}" - echo "$path" >> "$PURGE_CONFIG_FILE" - done -} - -# Load purge paths from config or auto-discover -load_purge_config() { - PURGE_SEARCH_PATHS=() - - if [[ -f "$PURGE_CONFIG_FILE" ]]; then - while IFS= read -r line; do - line="${line#"${line%%[![:space:]]*}"}" - line="${line%"${line##*[![:space:]]}"}" - - [[ -z "$line" || "$line" =~ ^# ]] && continue - - line="${line/#\~/$HOME}" - - PURGE_SEARCH_PATHS+=("$line") - done < "$PURGE_CONFIG_FILE" - fi - - if [[ ${#PURGE_SEARCH_PATHS[@]} -eq 0 ]]; then - if [[ -t 1 ]] && [[ -z "${_PURGE_DISCOVERY_SILENT:-}" ]]; then - echo -e "${GRAY}First run: discovering project directories...${NC}" >&2 - fi - - local -a discovered=() - while IFS= read -r path; do - [[ -n "$path" ]] && discovered+=("$path") - done < <(discover_project_dirs) - - if [[ ${#discovered[@]} -gt 0 ]]; then - PURGE_SEARCH_PATHS=("${discovered[@]}") - save_discovered_paths "${discovered[@]}" - - if [[ -t 1 ]] && [[ -z "${_PURGE_DISCOVERY_SILENT:-}" ]]; then - echo -e "${GRAY}Found ${#discovered[@]} project directories, saved to config${NC}" >&2 - fi - else - PURGE_SEARCH_PATHS=("${DEFAULT_PURGE_SEARCH_PATHS[@]}") - fi - fi -} - -# Initialize paths on script load. -load_purge_config - -# Args: $1 - path to check -# Safe cleanup requires the path be inside a project directory. -is_safe_project_artifact() { - local path="$1" - local search_path="$2" - if [[ "$path" != /* ]]; then - return 1 - fi - # Must not be a direct child of the search root. - local relative_path="${path#"$search_path"/}" - local depth=$(echo "$relative_path" | LC_ALL=C tr -cd '/' | wc -c) - if [[ $depth -lt 1 ]]; then - return 1 - fi - return 0 -} - -# Detect if directory is a Rails project root -is_rails_project_root() { - local dir="$1" - [[ -f "$dir/config/application.rb" ]] || return 1 - [[ -f "$dir/Gemfile" ]] || return 1 - [[ -f "$dir/bin/rails" || -f "$dir/config/environment.rb" ]] -} - -# Detect if directory is a Go project root -is_go_project_root() { - local dir="$1" - [[ -f "$dir/go.mod" ]] -} - -# Detect if directory is a PHP Composer project root -is_php_project_root() { - local dir="$1" - [[ -f "$dir/composer.json" ]] -} - -# Decide whether a "bin" directory is a .NET directory -is_dotnet_bin_dir() { - local path="$1" - [[ "$(basename "$path")" == "bin" ]] || return 1 - - # Check if parent directory has a .csproj/.fsproj/.vbproj file - local parent_dir - parent_dir="$(dirname "$path")" - find "$parent_dir" -maxdepth 1 \( -name "*.csproj" -o -name "*.fsproj" -o -name "*.vbproj" \) 2> /dev/null | grep -q . || return 1 - - # Check if bin directory contains Debug/ or Release/ subdirectories - [[ -d "$path/Debug" || -d "$path/Release" ]] || return 1 - - return 0 -} - -# Check if a vendor directory should be protected from purge -# Expects path to be a vendor directory (basename == vendor) -# Strategy: Only clean PHP Composer vendor, protect all others -is_protected_vendor_dir() { - local path="$1" - local base - base=$(basename "$path") - [[ "$base" == "vendor" ]] || return 1 - local parent_dir - parent_dir=$(dirname "$path") - - # PHP Composer vendor can be safely regenerated with 'composer install' - # Do NOT protect it (return 1 = not protected = can be cleaned) - if is_php_project_root "$parent_dir"; then - return 1 - fi - - # Rails vendor (importmap dependencies) - should be protected - if is_rails_project_root "$parent_dir"; then - return 0 - fi - - # Go vendor (optional vendoring) - protect to avoid accidental deletion - if is_go_project_root "$parent_dir"; then - return 0 - fi - - # Unknown vendor type - protect by default (conservative approach) - return 0 -} - -# Check if an artifact should be protected from purge -is_protected_purge_artifact() { - local path="$1" - local base - base=$(basename "$path") - - case "$base" in - bin) - # Only allow purging bin/ when we can detect .NET context. - if is_dotnet_bin_dir "$path"; then - return 1 - fi - return 0 - ;; - vendor) - is_protected_vendor_dir "$path" - return $? - ;; - esac - - return 1 -} - -# Scan purge targets using fd (fast) or pruned find. -scan_purge_targets() { - local search_path="$1" - local output_file="$2" - local min_depth="$PURGE_MIN_DEPTH_DEFAULT" - local max_depth="$PURGE_MAX_DEPTH_DEFAULT" - if [[ ! "$min_depth" =~ ^[0-9]+$ ]]; then - min_depth="$PURGE_MIN_DEPTH_DEFAULT" - fi - if [[ ! "$max_depth" =~ ^[0-9]+$ ]]; then - max_depth="$PURGE_MAX_DEPTH_DEFAULT" - fi - if [[ "$max_depth" -lt "$min_depth" ]]; then - max_depth="$min_depth" - fi - if [[ ! -d "$search_path" ]]; then - return - fi - if command -v fd > /dev/null 2>&1; then - # Escape regex special characters in target names for fd patterns - local escaped_targets=() - for target in "${PURGE_TARGETS[@]}"; do - escaped_targets+=("$(printf '%s' "$target" | sed -e 's/[][(){}.^$*+?|\\]/\\&/g')") - done - local pattern="($( - IFS='|' - echo "${escaped_targets[*]}" - ))" - local fd_args=( - "--absolute-path" - "--hidden" - "--no-ignore" - "--type" "d" - "--min-depth" "$min_depth" - "--max-depth" "$max_depth" - "--threads" "4" - "--exclude" ".git" - "--exclude" "Library" - "--exclude" ".Trash" - "--exclude" "Applications" - ) - fd "${fd_args[@]}" "$pattern" "$search_path" 2> /dev/null | while IFS= read -r item; do - if is_safe_project_artifact "$item" "$search_path"; then - echo "$item" - fi - done | filter_nested_artifacts | filter_protected_artifacts > "$output_file" - else - # Pruned find avoids descending into heavy directories. - local prune_args=() - local prune_dirs=(".git" "Library" ".Trash" "Applications") - for dir in "${prune_dirs[@]}"; do - prune_args+=("-name" "$dir" "-prune" "-o") - done - for target in "${PURGE_TARGETS[@]}"; do - prune_args+=("-name" "$target" "-print" "-prune" "-o") - done - local find_expr=() - for dir in "${prune_dirs[@]}"; do - find_expr+=("-name" "$dir" "-prune" "-o") - done - local i=0 - for target in "${PURGE_TARGETS[@]}"; do - find_expr+=("-name" "$target" "-print" "-prune") - if [[ $i -lt $((${#PURGE_TARGETS[@]} - 1)) ]]; then - find_expr+=("-o") - fi - ((i++)) - done - command find "$search_path" -mindepth "$min_depth" -maxdepth "$max_depth" -type d \ - \( "${find_expr[@]}" \) 2> /dev/null | while IFS= read -r item; do - if is_safe_project_artifact "$item" "$search_path"; then - echo "$item" - fi - done | filter_nested_artifacts | filter_protected_artifacts > "$output_file" - fi -} -# Filter out nested artifacts (e.g. node_modules inside node_modules). -filter_nested_artifacts() { - while IFS= read -r item; do - local parent_dir=$(dirname "$item") - local is_nested=false - for target in "${PURGE_TARGETS[@]}"; do - if [[ "$parent_dir" == *"/$target/"* || "$parent_dir" == *"/$target" ]]; then - is_nested=true - break - fi - done - if [[ "$is_nested" == "false" ]]; then - echo "$item" - fi - done -} - -filter_protected_artifacts() { - while IFS= read -r item; do - if ! is_protected_purge_artifact "$item"; then - echo "$item" - fi - done -} -# Args: $1 - path -# Check if a path was modified recently (safety check). -is_recently_modified() { - local path="$1" - local age_days=$MIN_AGE_DAYS - if [[ ! -e "$path" ]]; then - return 1 - fi - local mod_time - mod_time=$(get_file_mtime "$path") - local current_time - current_time=$(get_epoch_seconds) - local age_seconds=$((current_time - mod_time)) - local age_in_days=$((age_seconds / 86400)) - if [[ $age_in_days -lt $age_days ]]; then - return 0 # Recently modified - else - return 1 # Old enough to clean - fi -} -# Args: $1 - path -# Get directory size in KB. -get_dir_size_kb() { - local path="$1" - if [[ -d "$path" ]]; then - du -sk "$path" 2> /dev/null | awk '{print $1}' || echo "0" - else - echo "0" - fi -} -# Purge category selector. -select_purge_categories() { - local -a categories=("$@") - local total_items=${#categories[@]} - local clear_line=$'\r\033[2K' - if [[ $total_items -eq 0 ]]; then - return 1 - fi - - # Calculate items per page based on terminal height. - _get_items_per_page() { - local term_height=24 - if [[ -t 0 ]] || [[ -t 2 ]]; then - term_height=$(stty size < /dev/tty 2> /dev/null | awk '{print $1}') - fi - if [[ -z "$term_height" || $term_height -le 0 ]]; then - if command -v tput > /dev/null 2>&1; then - term_height=$(tput lines 2> /dev/null || echo "24") - else - term_height=24 - fi - fi - local reserved=6 - local available=$((term_height - reserved)) - if [[ $available -lt 3 ]]; then - echo 3 - elif [[ $available -gt 50 ]]; then - echo 50 - else - echo "$available" - fi - } - - local items_per_page=$(_get_items_per_page) - local cursor_pos=0 - local top_index=0 - - # Initialize selection (all selected by default, except recent ones) - local -a selected=() - IFS=',' read -r -a recent_flags <<< "${PURGE_RECENT_CATEGORIES:-}" - for ((i = 0; i < total_items; i++)); do - # Default unselected if category has recent items - if [[ ${recent_flags[i]:-false} == "true" ]]; then - selected[i]=false - else - selected[i]=true - fi - done - local original_stty="" - if [[ -t 0 ]] && command -v stty > /dev/null 2>&1; then - original_stty=$(stty -g 2> /dev/null || echo "") - fi - # Terminal control functions - restore_terminal() { - trap - EXIT INT TERM - show_cursor - if [[ -n "${original_stty:-}" ]]; then - stty "${original_stty}" 2> /dev/null || stty sane 2> /dev/null || true - fi - } - # shellcheck disable=SC2329 - handle_interrupt() { - restore_terminal - exit 130 - } - draw_menu() { - # Recalculate items_per_page dynamically to handle window resize - items_per_page=$(_get_items_per_page) - - # Clamp pagination state to avoid cursor drifting out of view - local max_top_index=0 - if [[ $total_items -gt $items_per_page ]]; then - max_top_index=$((total_items - items_per_page)) - fi - if [[ $top_index -gt $max_top_index ]]; then - top_index=$max_top_index - fi - if [[ $top_index -lt 0 ]]; then - top_index=0 - fi - - local visible_count=$((total_items - top_index)) - [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page - if [[ $cursor_pos -gt $((visible_count - 1)) ]]; then - cursor_pos=$((visible_count - 1)) - fi - if [[ $cursor_pos -lt 0 ]]; then - cursor_pos=0 - fi - - printf "\033[H" - # Calculate total size of selected items for header - local selected_size=0 - local selected_count=0 - IFS=',' read -r -a sizes <<< "${PURGE_CATEGORY_SIZES:-}" - for ((i = 0; i < total_items; i++)); do - if [[ ${selected[i]} == true ]]; then - selected_size=$((selected_size + ${sizes[i]:-0})) - ((selected_count++)) - fi - done - local selected_gb - selected_gb=$(printf "%.1f" "$(echo "scale=2; $selected_size/1024/1024" | bc)") - - # Show position indicator if scrolling is needed - local scroll_indicator="" - if [[ $total_items -gt $items_per_page ]]; then - local current_pos=$((top_index + cursor_pos + 1)) - scroll_indicator=" ${GRAY}[${current_pos}/${total_items}]${NC}" - fi - - printf "%s\n" "$clear_line" - printf "%s${PURPLE_BOLD}Select Categories to Clean${NC}%s ${GRAY}- ${selected_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:-}" - - # Calculate visible range - local end_index=$((top_index + visible_count)) - - # Draw only visible items - for ((i = top_index; i < end_index; i++)); do - local checkbox="$ICON_EMPTY" - [[ ${selected[i]} == true ]] && checkbox="$ICON_SOLID" - local recent_marker="" - [[ ${recent_flags[i]:-false} == "true" ]] && recent_marker=" ${GRAY}| Recent${NC}" - local rel_pos=$((i - top_index)) - if [[ $rel_pos -eq $cursor_pos ]]; then - printf "%s${CYAN}${ICON_ARROW} %s %s%s${NC}\n" "$clear_line" "$checkbox" "${categories[i]}" "$recent_marker" - else - printf "%s %s %s%s\n" "$clear_line" "$checkbox" "${categories[i]}" "$recent_marker" - fi - done - - # Fill empty slots to clear previous content - local items_shown=$visible_count - for ((i = items_shown; i < items_per_page; i++)); do - printf "%s\n" "$clear_line" - done - - printf "%s\n" "$clear_line" - - printf "%s${GRAY}${ICON_NAV_UP}${ICON_NAV_DOWN} | Space Select | Enter Confirm | A All | I Invert | Q Quit${NC}\n" "$clear_line" - } - trap restore_terminal EXIT - trap handle_interrupt INT TERM - # Preserve interrupt character for Ctrl-C - stty -echo -icanon intr ^C 2> /dev/null || true - hide_cursor - if [[ -t 1 ]]; then - clear_screen - fi - # Main loop - while true; do - draw_menu - # Read key - IFS= read -r -s -n1 key || key="" - case "$key" in - $'\x1b') - # Arrow keys or ESC - # Read next 2 chars with timeout (bash 3.2 needs integer) - IFS= read -r -s -n1 -t 1 key2 || key2="" - if [[ "$key2" == "[" ]]; then - IFS= read -r -s -n1 -t 1 key3 || key3="" - case "$key3" in - A) # Up arrow - if [[ $cursor_pos -gt 0 ]]; then - ((cursor_pos--)) - elif [[ $top_index -gt 0 ]]; then - ((top_index--)) - fi - ;; - B) # Down arrow - local absolute_index=$((top_index + cursor_pos)) - local last_index=$((total_items - 1)) - if [[ $absolute_index -lt $last_index ]]; then - local visible_count=$((total_items - top_index)) - [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page - if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then - ((cursor_pos++)) - elif [[ $((top_index + visible_count)) -lt $total_items ]]; then - ((top_index++)) - fi - fi - ;; - esac - else - # ESC alone (no following chars) - restore_terminal - return 1 - fi - ;; - " ") # Space - toggle current item - local idx=$((top_index + cursor_pos)) - if [[ ${selected[idx]} == true ]]; then - selected[idx]=false - else - selected[idx]=true - fi - ;; - "a" | "A") # Select all - for ((i = 0; i < total_items; i++)); do - selected[i]=true - done - ;; - "i" | "I") # Invert selection - for ((i = 0; i < total_items; i++)); do - if [[ ${selected[i]} == true ]]; then - selected[i]=false - else - selected[i]=true - fi - done - ;; - "q" | "Q" | $'\x03') # Quit or Ctrl-C - restore_terminal - return 1 - ;; - "" | $'\n' | $'\r') # Enter - confirm - # Build result - PURGE_SELECTION_RESULT="" - for ((i = 0; i < total_items; i++)); do - if [[ ${selected[i]} == true ]]; then - [[ -n "$PURGE_SELECTION_RESULT" ]] && PURGE_SELECTION_RESULT+="," - PURGE_SELECTION_RESULT+="$i" - fi - done - restore_terminal - return 0 - ;; - esac - done -} -# Main cleanup function - scans and prompts user to select artifacts to clean -clean_project_artifacts() { - local -a all_found_items=() - local -a safe_to_clean=() - local -a recently_modified=() - # Set up cleanup on interrupt - # Note: Declared without 'local' so cleanup_scan trap can access them - scan_pids=() - scan_temps=() - # shellcheck disable=SC2329 - cleanup_scan() { - # Kill all background scans - for pid in "${scan_pids[@]+"${scan_pids[@]}"}"; do - kill "$pid" 2> /dev/null || true - done - # Clean up temp files - for temp in "${scan_temps[@]+"${scan_temps[@]}"}"; do - rm -f "$temp" 2> /dev/null || true - done - if [[ -t 1 ]]; then - stop_inline_spinner - fi - echo "" - exit 130 - } - trap cleanup_scan INT TERM - # Start parallel scanning of all paths at once - if [[ -t 1 ]]; then - start_inline_spinner "Scanning projects..." - fi - # Launch all scans in parallel - for path in "${PURGE_SEARCH_PATHS[@]}"; do - if [[ -d "$path" ]]; then - local scan_output - scan_output=$(mktemp) - scan_temps+=("$scan_output") - # Launch scan in background for true parallelism - scan_purge_targets "$path" "$scan_output" & - local scan_pid=$! - scan_pids+=("$scan_pid") - fi - done - # Wait for all scans to complete - for pid in "${scan_pids[@]+"${scan_pids[@]}"}"; do - wait "$pid" 2> /dev/null || true - done - if [[ -t 1 ]]; then - stop_inline_spinner - fi - # Collect all results - for scan_output in "${scan_temps[@]+"${scan_temps[@]}"}"; do - if [[ -f "$scan_output" ]]; then - while IFS= read -r item; do - if [[ -n "$item" ]]; then - all_found_items+=("$item") - fi - done < "$scan_output" - rm -f "$scan_output" - fi - done - # Clean up trap - trap - INT TERM - if [[ ${#all_found_items[@]} -eq 0 ]]; then - echo "" - echo -e "${GREEN}${ICON_SUCCESS}${NC} Great! No old project artifacts to clean" - printf '\n' - return 2 # Special code: nothing to clean - fi - # Mark recently modified items (for default selection state) - for item in "${all_found_items[@]}"; do - if is_recently_modified "$item"; then - recently_modified+=("$item") - fi - # Add all items to safe_to_clean, let user choose - safe_to_clean+=("$item") - done - # Build menu options - one per artifact - if [[ -t 1 ]]; then - start_inline_spinner "Calculating sizes..." - fi - local -a menu_options=() - local -a item_paths=() - local -a item_sizes=() - local -a item_recent_flags=() - # Helper to get project name from path - # For ~/www/pake/src-tauri/target -> returns "pake" - # For ~/work/code/MyProject/node_modules -> returns "MyProject" - # Strategy: Find the nearest ancestor directory containing a project indicator file - get_project_name() { - local path="$1" - local artifact_name - artifact_name=$(basename "$path") - - # Start from the parent of the artifact and walk up - local current_dir - current_dir=$(dirname "$path") - - while [[ "$current_dir" != "/" && "$current_dir" != "$HOME" && -n "$current_dir" ]]; do - # Check if current directory contains any project indicator - for indicator in "${PROJECT_INDICATORS[@]}"; do - if [[ -e "$current_dir/$indicator" ]]; then - # Found a project root, return its name - basename "$current_dir" - return 0 - fi - done - # Move up one level - current_dir=$(dirname "$current_dir") - done - - # Fallback: try the old logic (first directory under search root) - local search_roots=() - if [[ ${#PURGE_SEARCH_PATHS[@]} -gt 0 ]]; then - search_roots=("${PURGE_SEARCH_PATHS[@]}") - else - search_roots=("$HOME/www" "$HOME/dev" "$HOME/Projects") - fi - for root in "${search_roots[@]}"; do - root="${root%/}" - if [[ -n "$root" && "$path" == "$root/"* ]]; then - local relative_path="${path#"$root"/}" - echo "$relative_path" | cut -d'/' -f1 - return 0 - fi - done - - # Final fallback: use grandparent directory - dirname "$(dirname "$path")" | xargs basename - } - # Format display with alignment (like app_selector) - format_purge_display() { - local project_name="$1" - local artifact_type="$2" - local size_str="$3" - # Terminal width for alignment - local terminal_width=$(tput cols 2> /dev/null || echo 80) - local fixed_width=28 # Reserve for type and size - local available_width=$((terminal_width - fixed_width)) - # Bounds: 24-35 chars for project name - [[ $available_width -lt 24 ]] && available_width=24 - [[ $available_width -gt 35 ]] && available_width=35 - # Truncate project name if needed - local truncated_name=$(truncate_by_display_width "$project_name" "$available_width") - local current_width=$(get_display_width "$truncated_name") - local char_count=${#truncated_name} - local padding=$((available_width - current_width)) - local printf_width=$((char_count + padding)) - # Format: "project_name size | artifact_type" - printf "%-*s %9s | %-13s" "$printf_width" "$truncated_name" "$size_str" "$artifact_type" - } - # Build menu options - one line per artifact - for item in "${safe_to_clean[@]}"; do - local project_name=$(get_project_name "$item") - local artifact_type=$(basename "$item") - local size_kb=$(get_dir_size_kb "$item") - local size_human=$(bytes_to_human "$((size_kb * 1024))") - # Check if recent - local is_recent=false - for recent_item in "${recently_modified[@]+"${recently_modified[@]}"}"; do - if [[ "$item" == "$recent_item" ]]; then - is_recent=true - break - fi - done - menu_options+=("$(format_purge_display "$project_name" "$artifact_type" "$size_human")") - item_paths+=("$item") - item_sizes+=("$size_kb") - item_recent_flags+=("$is_recent") - done - if [[ -t 1 ]]; then - stop_inline_spinner - fi - # Set global vars for selector - export PURGE_CATEGORY_SIZES=$( - IFS=, - echo "${item_sizes[*]}" - ) - export PURGE_RECENT_CATEGORIES=$( - IFS=, - echo "${item_recent_flags[*]}" - ) - # Interactive selection (only if terminal is available) - PURGE_SELECTION_RESULT="" - if [[ -t 0 ]]; then - if ! select_purge_categories "${menu_options[@]}"; then - unset PURGE_CATEGORY_SIZES PURGE_RECENT_CATEGORIES PURGE_SELECTION_RESULT - return 1 - fi - else - # Non-interactive: select all non-recent items - for ((i = 0; i < ${#menu_options[@]}; i++)); do - if [[ ${item_recent_flags[i]} != "true" ]]; then - [[ -n "$PURGE_SELECTION_RESULT" ]] && PURGE_SELECTION_RESULT+="," - PURGE_SELECTION_RESULT+="$i" - fi - done - fi - if [[ -z "$PURGE_SELECTION_RESULT" ]]; then - echo "" - echo -e "${GRAY}No items selected${NC}" - printf '\n' - unset PURGE_CATEGORY_SIZES PURGE_RECENT_CATEGORIES PURGE_SELECTION_RESULT - return 0 - fi - # Clean selected items - echo "" - IFS=',' read -r -a selected_indices <<< "$PURGE_SELECTION_RESULT" - local stats_dir="${XDG_CACHE_HOME:-$HOME/.cache}/mole" - local cleaned_count=0 - for idx in "${selected_indices[@]}"; do - local item_path="${item_paths[idx]}" - local artifact_type=$(basename "$item_path") - local project_name=$(get_project_name "$item_path") - local size_kb="${item_sizes[idx]}" - local size_human=$(bytes_to_human "$((size_kb * 1024))") - # Safety checks - if [[ -z "$item_path" || "$item_path" == "/" || "$item_path" == "$HOME" || "$item_path" != "$HOME/"* ]]; then - continue - fi - if [[ -t 1 ]]; then - start_inline_spinner "Cleaning $project_name/$artifact_type..." - fi - if [[ -e "$item_path" ]]; then - safe_remove "$item_path" true - if [[ ! -e "$item_path" ]]; then - local current_total=$(cat "$stats_dir/purge_stats" 2> /dev/null || echo "0") - echo "$((current_total + size_kb))" > "$stats_dir/purge_stats" - ((cleaned_count++)) - fi - fi - if [[ -t 1 ]]; then - stop_inline_spinner - echo -e "${GREEN}${ICON_SUCCESS}${NC} $project_name - $artifact_type ${GREEN}($size_human)${NC}" - fi - done - # Update count - echo "$cleaned_count" > "$stats_dir/purge_count" - unset PURGE_CATEGORY_SIZES PURGE_RECENT_CATEGORIES PURGE_SELECTION_RESULT -} diff --git a/windows/lib/clean/system.ps1 b/lib/clean/system.ps1 similarity index 100% rename from windows/lib/clean/system.ps1 rename to lib/clean/system.ps1 diff --git a/lib/clean/system.sh b/lib/clean/system.sh deleted file mode 100644 index fe78075..0000000 --- a/lib/clean/system.sh +++ /dev/null @@ -1,339 +0,0 @@ -#!/bin/bash -# System-Level Cleanup Module (requires sudo). -set -euo pipefail -# System caches, logs, and temp files. -clean_deep_system() { - stop_section_spinner - local cache_cleaned=0 - safe_sudo_find_delete "/Library/Caches" "*.cache" "$MOLE_TEMP_FILE_AGE_DAYS" "f" && cache_cleaned=1 || true - safe_sudo_find_delete "/Library/Caches" "*.tmp" "$MOLE_TEMP_FILE_AGE_DAYS" "f" && cache_cleaned=1 || true - safe_sudo_find_delete "/Library/Caches" "*.log" "$MOLE_LOG_AGE_DAYS" "f" && cache_cleaned=1 || true - [[ $cache_cleaned -eq 1 ]] && log_success "System caches" - local tmp_cleaned=0 - safe_sudo_find_delete "/private/tmp" "*" "${MOLE_TEMP_FILE_AGE_DAYS}" "f" && tmp_cleaned=1 || true - safe_sudo_find_delete "/private/var/tmp" "*" "${MOLE_TEMP_FILE_AGE_DAYS}" "f" && tmp_cleaned=1 || true - [[ $tmp_cleaned -eq 1 ]] && log_success "System temp files" - safe_sudo_find_delete "/Library/Logs/DiagnosticReports" "*" "$MOLE_CRASH_REPORT_AGE_DAYS" "f" || true - log_success "System crash reports" - safe_sudo_find_delete "/private/var/log" "*.log" "$MOLE_LOG_AGE_DAYS" "f" || true - 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 - 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)" - 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)" - if safe_sudo_remove "/macOS Install Data"; then - log_success "macOS Install Data ($size_human)" - fi - fi - else - debug_log "Keeping macOS Install Data (only ${age_days} days old, needs 30+)" - fi - fi - start_section_spinner "Scanning system caches..." - local code_sign_cleaned=0 - local found_count=0 - local last_update_time - last_update_time=$(get_epoch_seconds) - local update_interval=2 - while IFS= read -r -d '' cache_dir; do - if safe_remove "$cache_dir" true; then - ((code_sign_cleaned++)) - fi - ((found_count++)) - - # Optimize: only check time every 50 files - if ((found_count % 50 == 0)); then - 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)" - 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 - stop_section_spinner - [[ $diag_logs_cleaned -eq 1 ]] && log_success "System diagnostic trace logs" -} -# Incomplete Time Machine backups. -clean_time_machine_failed_backups() { - local tm_cleaned=0 - if ! command -v tmutil > /dev/null 2>&1; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} No incomplete backups found" - return 0 - fi - start_section_spinner "Checking Time Machine configuration..." - local spinner_active=true - local tm_info - tm_info=$(run_with_timeout 2 tmutil destinationinfo 2>&1 || echo "failed") - if [[ "$tm_info" == *"No destinations configured"* || "$tm_info" == "failed" ]]; then - if [[ "$spinner_active" == "true" ]]; then - stop_section_spinner - fi - echo -e " ${GREEN}${ICON_SUCCESS}${NC} No incomplete backups found" - return 0 - fi - if [[ ! -d "/Volumes" ]]; then - if [[ "$spinner_active" == "true" ]]; then - stop_section_spinner - fi - echo -e " ${GREEN}${ICON_SUCCESS}${NC} No incomplete backups found" - return 0 - fi - if tmutil status 2> /dev/null | grep -q "Running = 1"; then - if [[ "$spinner_active" == "true" ]]; then - stop_section_spinner - fi - echo -e " ${YELLOW}!${NC} Time Machine backup in progress, skipping cleanup" - return 0 - fi - if [[ "$spinner_active" == "true" ]]; then - start_section_spinner "Checking backup volumes..." - fi - # Fast pre-scan for backup volumes to avoid slow tmutil checks. - local -a backup_volumes=() - for volume in /Volumes/*; do - [[ -d "$volume" ]] || continue - [[ "$volume" == "/Volumes/MacintoshHD" || "$volume" == "/" ]] && continue - [[ -L "$volume" ]] && continue - if [[ -d "$volume/Backups.backupdb" ]] || [[ -d "$volume/.MobileBackups" ]]; then - backup_volumes+=("$volume") - fi - done - if [[ ${#backup_volumes[@]} -eq 0 ]]; then - if [[ "$spinner_active" == "true" ]]; then - stop_section_spinner - fi - echo -e " ${GREEN}${ICON_SUCCESS}${NC} No incomplete backups found" - return 0 - fi - if [[ "$spinner_active" == "true" ]]; then - start_section_spinner "Scanning backup volumes..." - fi - for volume in "${backup_volumes[@]}"; do - local fs_type - fs_type=$(run_with_timeout 1 command df -T "$volume" 2> /dev/null | tail -1 | awk '{print $2}' || echo "unknown") - case "$fs_type" in - nfs | smbfs | afpfs | cifs | webdav | unknown) continue ;; - esac - local backupdb_dir="$volume/Backups.backupdb" - if [[ -d "$backupdb_dir" ]]; then - while IFS= read -r inprogress_file; do - [[ -d "$inprogress_file" ]] || continue - # Only delete old incomplete backups (safety window). - local file_mtime=$(get_file_mtime "$inprogress_file") - local current_time - current_time=$(get_epoch_seconds) - local hours_old=$(((current_time - file_mtime) / 3600)) - if [[ $hours_old -lt $MOLE_TM_BACKUP_SAFE_HOURS ]]; then - continue - fi - local size_kb=$(get_path_size_kb "$inprogress_file") - [[ "$size_kb" -le 0 ]] && continue - if [[ "$spinner_active" == "true" ]]; then - stop_section_spinner - spinner_active=false - fi - 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}" - ((tm_cleaned++)) - note_activity - continue - fi - if ! command -v tmutil > /dev/null 2>&1; then - echo -e " ${YELLOW}!${NC} tmutil not available, skipping: $backup_name" - 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}" - ((tm_cleaned++)) - ((files_cleaned++)) - ((total_size_cleaned += size_kb)) - ((total_items++)) - note_activity - else - echo -e " ${YELLOW}!${NC} Could not delete: $backup_name · try manually with sudo" - fi - done < <(run_with_timeout 15 find "$backupdb_dir" -maxdepth 3 -type d \( -name "*.inProgress" -o -name "*.inprogress" \) 2> /dev/null || true) - fi - # APFS bundles. - for bundle in "$volume"/*.backupbundle "$volume"/*.sparsebundle; do - [[ -e "$bundle" ]] || continue - [[ -d "$bundle" ]] || continue - local bundle_name=$(basename "$bundle") - local mounted_path=$(hdiutil info 2> /dev/null | grep -A 5 "image-path.*$bundle_name" | grep "/Volumes/" | awk '{print $1}' | head -1 || echo "") - if [[ -n "$mounted_path" && -d "$mounted_path" ]]; then - while IFS= read -r inprogress_file; do - [[ -d "$inprogress_file" ]] || continue - local file_mtime=$(get_file_mtime "$inprogress_file") - local current_time - current_time=$(get_epoch_seconds) - local hours_old=$(((current_time - file_mtime) / 3600)) - if [[ $hours_old -lt $MOLE_TM_BACKUP_SAFE_HOURS ]]; then - continue - fi - local size_kb=$(get_path_size_kb "$inprogress_file") - [[ "$size_kb" -le 0 ]] && continue - if [[ "$spinner_active" == "true" ]]; then - stop_section_spinner - spinner_active=false - fi - 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}" - ((tm_cleaned++)) - note_activity - continue - fi - if ! command -v tmutil > /dev/null 2>&1; then - 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}" - ((tm_cleaned++)) - ((files_cleaned++)) - ((total_size_cleaned += size_kb)) - ((total_items++)) - note_activity - else - echo -e " ${YELLOW}!${NC} Could not delete from bundle: $backup_name" - fi - done < <(run_with_timeout 15 find "$mounted_path" -maxdepth 3 -type d \( -name "*.inProgress" -o -name "*.inprogress" \) 2> /dev/null || true) - fi - done - done - if [[ "$spinner_active" == "true" ]]; then - stop_section_spinner - fi - if [[ $tm_cleaned -eq 0 ]]; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} No incomplete backups found" - fi -} -# Local APFS snapshots (keep the most recent). -clean_local_snapshots() { - if ! command -v tmutil > /dev/null 2>&1; then - return 0 - fi - start_section_spinner "Checking local snapshots..." - local snapshot_list - snapshot_list=$(tmutil listlocalsnapshots / 2> /dev/null) - 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" - fi -} diff --git a/windows/lib/clean/user.ps1 b/lib/clean/user.ps1 similarity index 100% rename from windows/lib/clean/user.ps1 rename to lib/clean/user.ps1 diff --git a/lib/clean/user.sh b/lib/clean/user.sh deleted file mode 100644 index 0308470..0000000 --- a/lib/clean/user.sh +++ /dev/null @@ -1,695 +0,0 @@ -#!/bin/bash -# User Data Cleanup Module -set -euo pipefail -clean_user_essentials() { - start_section_spinner "Scanning caches..." - safe_clean ~/Library/Caches/* "User app cache" - stop_section_spinner - start_section_spinner "Scanning empty items..." - clean_empty_library_items - 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" - fi -} - -clean_empty_library_items() { - if [[ ! -d "$HOME/Library" ]]; then - return 0 - fi - - # 1. Clean top-level empty directories in Library - local -a empty_dirs=() - while IFS= read -r -d '' dir; do - [[ -d "$dir" ]] && empty_dirs+=("$dir") - done < <(find "$HOME/Library" -mindepth 1 -maxdepth 1 -type d -empty -print0 2> /dev/null) - - if [[ ${#empty_dirs[@]} -gt 0 ]]; then - safe_clean "${empty_dirs[@]}" "Empty Library folders" - fi - - # 2. Clean empty subdirectories in Application Support and other key locations - # Iteratively remove empty directories until no more are found - local -a key_locations=( - "$HOME/Library/Application Support" - "$HOME/Library/Caches" - ) - - for location in "${key_locations[@]}"; do - [[ -d "$location" ]] || continue - - # Limit passes to keep cleanup fast; 3 iterations handle most nested scenarios. - local max_iterations=3 - local iteration=0 - - while [[ $iteration -lt $max_iterations ]]; do - local -a nested_empty_dirs=() - # Find empty directories - while IFS= read -r -d '' dir; do - # Skip if whitelisted - if is_path_whitelisted "$dir"; then - continue - fi - # Skip protected system components - local dir_name=$(basename "$dir") - if is_critical_system_component "$dir_name"; then - continue - fi - [[ -d "$dir" ]] && nested_empty_dirs+=("$dir") - done < <(find "$location" -mindepth 1 -type d -empty -print0 2> /dev/null) - - # If no empty dirs found, we're done with this location - if [[ ${#nested_empty_dirs[@]} -eq 0 ]]; then - break - fi - - local location_name=$(basename "$location") - safe_clean "${nested_empty_dirs[@]}" "Empty $location_name subdirs" - - ((iteration++)) - done - done - - # Empty file cleanup is skipped to avoid removing app sentinel files. -} - -# Remove old Google Chrome versions while keeping Current. -clean_chrome_old_versions() { - local -a app_paths=( - "/Applications/Google Chrome.app" - "$HOME/Applications/Google Chrome.app" - ) - - # Use -f to match Chrome Helper processes as well - if pgrep -f "Google Chrome" > /dev/null 2>&1; then - echo -e " ${YELLOW}${ICON_WARNING}${NC} Google Chrome running · old versions cleanup skipped" - return 0 - fi - - local cleaned_count=0 - local total_size=0 - local cleaned_any=false - - for app_path in "${app_paths[@]}"; do - [[ -d "$app_path" ]] || continue - - local versions_dir="$app_path/Contents/Frameworks/Google Chrome Framework.framework/Versions" - [[ -d "$versions_dir" ]] || continue - - local current_link="$versions_dir/Current" - [[ -L "$current_link" ]] || continue - - local current_version - current_version=$(readlink "$current_link" 2> /dev/null || true) - current_version="${current_version##*/}" - [[ -n "$current_version" ]] || continue - - local -a old_versions=() - local dir name - for dir in "$versions_dir"/*; do - [[ -d "$dir" ]] || continue - name=$(basename "$dir") - [[ "$name" == "Current" ]] && continue - [[ "$name" == "$current_version" ]] && continue - if is_path_whitelisted "$dir"; then - continue - fi - old_versions+=("$dir") - done - - if [[ ${#old_versions[@]} -eq 0 ]]; then - continue - fi - - for dir in "${old_versions[@]}"; do - local size_kb - size_kb=$(get_path_size_kb "$dir" || echo 0) - size_kb="${size_kb:-0}" - total_size=$((total_size + size_kb)) - ((cleaned_count++)) - cleaned_any=true - if [[ "$DRY_RUN" != "true" ]]; then - if has_sudo_session; then - safe_sudo_remove "$dir" > /dev/null 2>&1 || true - else - safe_remove "$dir" true > /dev/null 2>&1 || true - fi - fi - done - done - - if [[ "$cleaned_any" == "true" ]]; then - local size_human - size_human=$(bytes_to_human "$((total_size * 1024))") - if [[ "$DRY_RUN" == "true" ]]; then - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Chrome old versions ${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}" - fi - ((files_cleaned += cleaned_count)) - ((total_size_cleaned += total_size)) - ((total_items++)) - note_activity - fi -} - -# 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" - ) - - # Use -f to match Edge Helper processes as well - if pgrep -f "Microsoft Edge" > /dev/null 2>&1; then - echo -e " ${YELLOW}${ICON_WARNING}${NC} Microsoft Edge running · old versions cleanup skipped" - return 0 - fi - - local cleaned_count=0 - local total_size=0 - local cleaned_any=false - - for app_path in "${app_paths[@]}"; do - [[ -d "$app_path" ]] || continue - - local versions_dir="$app_path/Contents/Frameworks/Microsoft Edge Framework.framework/Versions" - [[ -d "$versions_dir" ]] || continue - - local current_link="$versions_dir/Current" - [[ -L "$current_link" ]] || continue - - local current_version - current_version=$(readlink "$current_link" 2> /dev/null || true) - current_version="${current_version##*/}" - [[ -n "$current_version" ]] || continue - - local -a old_versions=() - local dir name - for dir in "$versions_dir"/*; do - [[ -d "$dir" ]] || continue - name=$(basename "$dir") - [[ "$name" == "Current" ]] && continue - [[ "$name" == "$current_version" ]] && continue - if is_path_whitelisted "$dir"; then - continue - fi - old_versions+=("$dir") - done - - if [[ ${#old_versions[@]} -eq 0 ]]; then - continue - fi - - for dir in "${old_versions[@]}"; do - local size_kb - size_kb=$(get_path_size_kb "$dir" || echo 0) - size_kb="${size_kb:-0}" - total_size=$((total_size + size_kb)) - ((cleaned_count++)) - cleaned_any=true - if [[ "$DRY_RUN" != "true" ]]; then - if has_sudo_session; then - safe_sudo_remove "$dir" > /dev/null 2>&1 || true - else - safe_remove "$dir" true > /dev/null 2>&1 || true - fi - fi - done - done - - if [[ "$cleaned_any" == "true" ]]; then - local size_human - size_human=$(bytes_to_human "$((total_size * 1024))") - if [[ "$DRY_RUN" == "true" ]]; then - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Edge old versions ${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}" - fi - ((files_cleaned += cleaned_count)) - ((total_size_cleaned += total_size)) - ((total_items++)) - note_activity - fi -} - -# Remove old Microsoft EdgeUpdater versions while keeping latest. -clean_edge_updater_old_versions() { - local updater_dir="$HOME/Library/Application Support/Microsoft/EdgeUpdater/apps/msedge-stable" - [[ -d "$updater_dir" ]] || return 0 - - if pgrep -f "Microsoft Edge" > /dev/null 2>&1; then - echo -e " ${YELLOW}${ICON_WARNING}${NC} Microsoft Edge running · updater cleanup skipped" - return 0 - fi - - local -a version_dirs=() - local dir - for dir in "$updater_dir"/*; do - [[ -d "$dir" ]] || continue - version_dirs+=("$dir") - done - - if [[ ${#version_dirs[@]} -lt 2 ]]; then - return 0 - fi - - local latest_version - latest_version=$(printf '%s\n' "${version_dirs[@]##*/}" | sort -V | tail -n 1) - [[ -n "$latest_version" ]] || return 0 - - local cleaned_count=0 - local total_size=0 - local cleaned_any=false - - for dir in "${version_dirs[@]}"; do - local name - name=$(basename "$dir") - [[ "$name" == "$latest_version" ]] && continue - if is_path_whitelisted "$dir"; then - continue - fi - local size_kb - size_kb=$(get_path_size_kb "$dir" || echo 0) - size_kb="${size_kb:-0}" - total_size=$((total_size + size_kb)) - ((cleaned_count++)) - cleaned_any=true - if [[ "$DRY_RUN" != "true" ]]; then - safe_remove "$dir" true > /dev/null 2>&1 || true - fi - done - - if [[ "$cleaned_any" == "true" ]]; then - local size_human - size_human=$(bytes_to_human "$((total_size * 1024))") - if [[ "$DRY_RUN" == "true" ]]; then - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Edge updater old versions ${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}" - fi - ((files_cleaned += cleaned_count)) - ((total_size_cleaned += total_size)) - ((total_items++)) - note_activity - fi -} - -scan_external_volumes() { - [[ -d "/Volumes" ]] || return 0 - local -a candidate_volumes=() - local -a network_volumes=() - for volume in /Volumes/*; do - [[ -d "$volume" && -w "$volume" && ! -L "$volume" ]] || continue - [[ "$volume" == "/" || "$volume" == "/Volumes/Macintosh HD" ]] && continue - local protocol="" - protocol=$(run_with_timeout 1 command diskutil info "$volume" 2> /dev/null | grep -i "Protocol:" | awk '{print $2}' || echo "") - case "$protocol" in - SMB | NFS | AFP | CIFS | WebDAV) - network_volumes+=("$volume") - continue - ;; - esac - local fs_type="" - fs_type=$(run_with_timeout 1 command df -T "$volume" 2> /dev/null | tail -1 | awk '{print $2}' || echo "") - case "$fs_type" in - nfs | smbfs | afpfs | cifs | webdav) - network_volumes+=("$volume") - continue - ;; - esac - candidate_volumes+=("$volume") - done - local volume_count=${#candidate_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)" - note_activity - fi - return 0 - fi - start_section_spinner "Scanning $volume_count external volume(s)..." - for volume in "${candidate_volumes[@]}"; do - [[ -d "$volume" && -r "$volume" ]] || continue - local volume_trash="$volume/.Trashes" - if [[ -d "$volume_trash" && "$DRY_RUN" != "true" ]] && ! is_path_whitelisted "$volume_trash"; then - while IFS= read -r -d '' item; do - safe_remove "$item" true || true - 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)" - 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)" -} -# 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 - safe_clean ~/Library/Caches/com.apple.akd "Apple ID cache" || true - safe_clean ~/Library/Caches/com.apple.WebKit.Networking/* "WebKit network cache" || true - safe_clean ~/Library/DiagnosticReports/* "Diagnostic reports" || true - safe_clean ~/Library/Caches/com.apple.QuickLook.thumbnailcache "QuickLook thumbnails" || true - safe_clean ~/Library/Caches/Quick\ Look/* "QuickLook cache" || true - safe_clean ~/Library/Caches/com.apple.iconservices* "Icon services cache" || true - safe_clean ~/Downloads/*.download "Safari incomplete downloads" || true - safe_clean ~/Downloads/*.crdownload "Chrome incomplete downloads" || true - safe_clean ~/Downloads/*.part "Partial incomplete downloads" || true - safe_clean ~/Library/Autosave\ Information/* "Autosave information" || true - safe_clean ~/Library/IdentityCaches/* "Identity caches" || true - safe_clean ~/Library/Suggestions/* "Siri suggestions cache" || true - safe_clean ~/Library/Calendars/Calendar\ Cache "Calendar cache" || true - 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" - "$shared_dir/com.apple.LSSharedFileList.RecentDocuments.sfl2" - "$shared_dir/com.apple.LSSharedFileList.RecentServers.sfl2" - "$shared_dir/com.apple.LSSharedFileList.RecentHosts.sfl2" - "$shared_dir/com.apple.LSSharedFileList.RecentApplications.sfl" - "$shared_dir/com.apple.LSSharedFileList.RecentDocuments.sfl" - "$shared_dir/com.apple.LSSharedFileList.RecentServers.sfl" - "$shared_dir/com.apple.LSSharedFileList.RecentHosts.sfl" - ) - if [[ -d "$shared_dir" ]]; then - for sfl_file in "${recent_lists[@]}"; do - [[ -e "$sfl_file" ]] && safe_clean "$sfl_file" "Recent items list" || true - done - fi - 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 - fi - local -a mail_dirs=( - "$HOME/Library/Mail Downloads" - "$HOME/Library/Containers/com.apple.mail/Data/Library/Mail Downloads" - ) - local count=0 - local cleaned_kb=0 - for target_path in "${mail_dirs[@]}"; do - if [[ -d "$target_path" ]]; then - local dir_size_kb=0 - dir_size_kb=$(get_path_size_kb "$target_path") - if ! [[ "$dir_size_kb" =~ ^[0-9]+$ ]]; then - dir_size_kb=0 - fi - local min_kb="${MOLE_MAIL_DOWNLOADS_MIN_KB:-}" - if ! [[ "$min_kb" =~ ^[0-9]+$ ]]; then - min_kb=5120 - fi - if [[ "$dir_size_kb" -lt "$min_kb" ]]; then - continue - fi - while IFS= read -r -d '' file_path; do - if [[ -f "$file_path" ]]; then - local file_size_kb=$(get_path_size_kb "$file_path") - if safe_remove "$file_path" true; then - ((count++)) - ((cleaned_kb += file_size_kb)) - fi - fi - done < <(command find "$target_path" -type f -mtime +"$mail_age_days" -print0 2> /dev/null || true) - fi - done - if [[ $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)" - note_activity - fi -} -# Sandboxed app caches. -clean_sandboxed_app_caches() { - stop_section_spinner - safe_clean ~/Library/Containers/com.apple.wallpaper.agent/Data/Library/Caches/* "Wallpaper agent cache" - safe_clean ~/Library/Containers/com.apple.mediaanalysisd/Data/Library/Caches/* "Media analysis cache" - safe_clean ~/Library/Containers/com.apple.AppStore/Data/Library/Caches/* "App Store cache" - safe_clean ~/Library/Containers/com.apple.configurator.xpc.InternetService/Data/tmp/* "Apple Configurator temp files" - local containers_dir="$HOME/Library/Containers" - [[ ! -d "$containers_dir" ]] && return 0 - start_section_spinner "Scanning sandboxed apps..." - local total_size=0 - local cleaned_count=0 - local found_any=false - # Use nullglob to avoid literal globs. - local _ng_state - _ng_state=$(shopt -p nullglob || true) - shopt -s nullglob - for container_dir in "$containers_dir"/*; do - process_container_cache "$container_dir" - done - eval "$_ng_state" - stop_section_spinner - 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}" - else - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Sandboxed app caches ${GREEN}($size_human)${NC}" - fi - ((files_cleaned += cleaned_count)) - ((total_size_cleaned += total_size)) - ((total_items++)) - note_activity - fi -} -# Process a single container cache directory. -process_container_cache() { - local container_dir="$1" - [[ -d "$container_dir" ]] || return 0 - local bundle_id=$(basename "$container_dir") - if is_critical_system_component "$bundle_id"; then - return 0 - fi - if should_protect_data "$bundle_id" || should_protect_data "$(echo "$bundle_id" | LC_ALL=C tr '[:upper:]' '[:lower:]')"; then - return 0 - fi - local cache_dir="$container_dir/Data/Library/Caches" - [[ -d "$cache_dir" ]] || return 0 - # Fast non-empty check. - if find "$cache_dir" -mindepth 1 -maxdepth 1 -print -quit 2> /dev/null | grep -q .; then - local size=$(get_path_size_kb "$cache_dir") - ((total_size += size)) - found_any=true - ((cleaned_count++)) - if [[ "$DRY_RUN" != "true" ]]; then - # Clean contents safely with local nullglob. - local _ng_state - _ng_state=$(shopt -p nullglob || true) - shopt -s nullglob - for item in "$cache_dir"/*; do - [[ -e "$item" ]] || continue - safe_remove "$item" true || true - done - eval "$_ng_state" - fi - fi -} -# 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 ~/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" - safe_clean ~/Library/Caches/BraveSoftware/Brave-Browser/* "Brave cache" - local firefox_running=false - if pgrep -x "Firefox" > /dev/null 2>&1; then - firefox_running=true - fi - if [[ "$firefox_running" == "true" ]]; then - echo -e " ${YELLOW}${ICON_WARNING}${NC} Firefox is running · cache cleanup skipped" - else - safe_clean ~/Library/Caches/Firefox/* "Firefox cache" - fi - safe_clean ~/Library/Caches/com.operasoftware.Opera/* "Opera cache" - safe_clean ~/Library/Caches/com.vivaldi.Vivaldi/* "Vivaldi cache" - safe_clean ~/Library/Caches/Comet/* "Comet cache" - 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" - else - safe_clean ~/Library/Application\ Support/Firefox/Profiles/*/cache2/* "Firefox profile cache" - fi - clean_chrome_old_versions - clean_edge_old_versions - clean_edge_updater_old_versions -} -# 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" - safe_clean ~/Library/Caches/com.baidu.netdisk "Baidu Netdisk cache" - safe_clean ~/Library/Caches/com.alibaba.teambitiondisk "Alibaba Cloud cache" - safe_clean ~/Library/Caches/com.box.desktop "Box cache" - safe_clean ~/Library/Caches/com.microsoft.OneDrive "OneDrive cache" -} -# 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" - safe_clean ~/Library/Caches/com.microsoft.Outlook/* "Microsoft Outlook cache" - safe_clean ~/Library/Caches/com.apple.iWork.* "Apple iWork cache" - safe_clean ~/Library/Caches/com.kingsoft.wpsoffice.mac "WPS Office cache" - safe_clean ~/Library/Caches/org.mozilla.thunderbird/* "Thunderbird cache" - safe_clean ~/Library/Caches/com.apple.mail/* "Apple Mail cache" -} -# Virtualization caches. -clean_virtualization_tools() { - stop_section_spinner - safe_clean ~/Library/Caches/com.vmware.fusion "VMware Fusion cache" - safe_clean ~/Library/Caches/com.parallels.* "Parallels cache" - safe_clean ~/VirtualBox\ VMs/.cache "VirtualBox cache" - safe_clean ~/.vagrant.d/tmp/* "Vagrant temporary files" -} -# 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" - return 0 - fi - start_section_spinner "Scanning Application Support..." - local total_size=0 - local cleaned_count=0 - local found_any=false - # Enable nullglob for safe globbing. - local _ng_state - _ng_state=$(shopt -p nullglob || true) - shopt -s nullglob - for app_dir in ~/Library/Application\ Support/*; do - [[ -d "$app_dir" ]] || continue - local app_name=$(basename "$app_dir") - local app_name_lower=$(echo "$app_name" | LC_ALL=C tr '[:upper:]' '[:lower:]') - local is_protected=false - if should_protect_data "$app_name"; then - is_protected=true - elif should_protect_data "$app_name_lower"; then - is_protected=true - fi - if [[ "$is_protected" == "true" ]]; then - continue - fi - if is_critical_system_component "$app_name"; then - continue - fi - local -a start_candidates=("$app_dir/log" "$app_dir/logs" "$app_dir/activitylog" "$app_dir/Cache/Cache_Data" "$app_dir/Crashpad/completed") - for candidate in "${start_candidates[@]}"; do - if [[ -d "$candidate" ]]; then - if find "$candidate" -mindepth 1 -maxdepth 1 -print -quit 2> /dev/null | grep -q .; then - local size=$(get_path_size_kb "$candidate") - ((total_size += size)) - ((cleaned_count++)) - found_any=true - if [[ "$DRY_RUN" != "true" ]]; then - for item in "$candidate"/*; do - [[ -e "$item" ]] || continue - safe_remove "$item" true > /dev/null 2>&1 || true - done - fi - fi - fi - done - done - # Group Containers logs (explicit allowlist). - local known_group_containers=( - "group.com.apple.contentdelivery" - ) - for container in "${known_group_containers[@]}"; do - local container_path="$HOME/Library/Group Containers/$container" - local -a gc_candidates=("$container_path/Logs" "$container_path/Library/Logs") - for candidate in "${gc_candidates[@]}"; do - if [[ -d "$candidate" ]]; then - if find "$candidate" -mindepth 1 -maxdepth 1 -print -quit 2> /dev/null | grep -q .; then - local size=$(get_path_size_kb "$candidate") - ((total_size += size)) - ((cleaned_count++)) - found_any=true - if [[ "$DRY_RUN" != "true" ]]; then - for item in "$candidate"/*; do - [[ -e "$item" ]] || continue - safe_remove "$item" true > /dev/null 2>&1 || true - done - fi - fi - fi - done - done - eval "$_ng_state" - stop_section_spinner - 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}" - else - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Application Support logs/caches ${GREEN}($size_human)${NC}" - fi - ((files_cleaned += cleaned_count)) - ((total_size_cleaned += total_size)) - ((total_items++)) - note_activity - fi -} -# iOS device backup info. -check_ios_device_backups() { - local backup_dir="$HOME/Library/Application Support/MobileSync/Backup" - # Simplified check without find to avoid hanging. - if [[ -d "$backup_dir" ]]; then - local backup_kb=$(get_path_size_kb "$backup_dir") - if [[ -n "${backup_kb:-}" && "$backup_kb" -gt 102400 ]]; then - 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}" - fi - fi - fi - return 0 -} -# Apple Silicon specific caches (IS_M_SERIES). -clean_apple_silicon_caches() { - if [[ "${IS_M_SERIES:-false}" != "true" ]]; then - return 0 - fi - start_section "Apple Silicon updates" - safe_clean /Library/Apple/usr/share/rosetta/rosetta_update_bundle "Rosetta 2 cache" - safe_clean ~/Library/Caches/com.apple.rosetta.update "Rosetta 2 user cache" - safe_clean ~/Library/Caches/com.apple.amp.mediasevicesd "Apple Silicon media service cache" - end_section -} diff --git a/lib/core/app_protection.sh b/lib/core/app_protection.sh deleted file mode 100755 index 5822c6a..0000000 --- a/lib/core/app_protection.sh +++ /dev/null @@ -1,1018 +0,0 @@ -#!/bin/bash -# Mole - Application Protection -# System critical and data-protected application lists - -set -euo pipefail - -if [[ -n "${MOLE_APP_PROTECTION_LOADED:-}" ]]; then - return 0 -fi -readonly MOLE_APP_PROTECTION_LOADED=1 - -_MOLE_CORE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -[[ -z "${MOLE_BASE_LOADED:-}" ]] && source "$_MOLE_CORE_DIR/base.sh" - -# Application Management - -# Critical system components protected from uninstallation -readonly SYSTEM_CRITICAL_BUNDLES=( - "com.apple.*" # System essentials - "loginwindow" - "dock" - "systempreferences" - "finder" - "safari" - "com.apple.Settings*" - "com.apple.SystemSettings*" - "com.apple.controlcenter*" - "com.apple.backgroundtaskmanagement*" - "com.apple.loginitems*" - "com.apple.sharedfilelist*" - "com.apple.sfl*" - "backgroundtaskmanagementagent" - "keychain*" - "security*" - "bluetooth*" - "wifi*" - "network*" - "tcc" - "notification*" - "accessibility*" - "universalaccess*" - "HIToolbox*" - "textinput*" - "TextInput*" - "keyboard*" - "Keyboard*" - "inputsource*" - "InputSource*" - "keylayout*" - "KeyLayout*" - "GlobalPreferences" - ".GlobalPreferences" - "com.apple.inputmethod.*" - "org.pqrs.Karabiner*" - "com.apple.inputsource*" - "com.apple.TextInputMenuAgent" - "com.apple.TextInputSwitcher" -) - -# Applications with sensitive data; protected during cleanup but removable -readonly DATA_PROTECTED_BUNDLES=( - # Input Methods (protected during cleanup, uninstall allowed) - "com.tencent.inputmethod.QQInput" - "com.sogou.inputmethod.*" - "com.baidu.inputmethod.*" - "com.googlecode.rimeime.*" - "im.rime.*" - "*.inputmethod" - "*.InputMethod" - "*IME" - - # System Utilities & Cleanup Tools - "com.nektony.*" # App Cleaner & Uninstaller - "com.macpaw.*" # CleanMyMac, CleanMaster - "com.freemacsoft.AppCleaner" # AppCleaner - "com.omnigroup.omnidisksweeper" # OmniDiskSweeper - "com.daisydiskapp.*" # DaisyDisk - "com.tunabellysoftware.*" # Disk Utility apps - "com.grandperspectiv.*" # GrandPerspective - "com.binaryfruit.*" # FusionCast - - # Password Managers & Security - "com.1password.*" # 1Password - "com.agilebits.*" # 1Password legacy - "com.lastpass.*" # LastPass - "com.dashlane.*" # Dashlane - "com.bitwarden.*" # Bitwarden - "com.keepassx.*" # KeePassXC (Legacy) - "org.keepassx.*" # KeePassX - "org.keepassxc.*" # KeePassXC - "com.authy.*" # Authy - "com.yubico.*" # YubiKey Manager - - # Development Tools - IDEs & Editors - "com.jetbrains.*" # JetBrains IDEs (IntelliJ, DataGrip, etc.) - "JetBrains*" # JetBrains Application Support folders - "com.microsoft.VSCode" # Visual Studio Code - "com.visualstudio.code.*" # VS Code variants - "com.sublimetext.*" # Sublime Text - "com.sublimehq.*" # Sublime Merge - "com.microsoft.VSCodeInsiders" # VS Code Insiders - "com.apple.dt.Xcode" # Xcode (keep settings) - "com.coteditor.CotEditor" # CotEditor - "com.macromates.TextMate" # TextMate - "com.panic.Nova" # Nova - "abnerworks.Typora" # Typora (Markdown editor) - "com.uranusjr.macdown" # MacDown - - # AI & LLM Tools - "com.todesktop.*" # Cursor (often uses generic todesktop ID) - "Cursor" # Cursor App Support - "com.anthropic.claude*" # Claude - "Claude" # Claude App Support - "com.openai.chat*" # ChatGPT - "ChatGPT" # ChatGPT App Support - "com.ollama.ollama" # Ollama - "Ollama" # Ollama App Support - "com.lmstudio.lmstudio" # LM Studio - "LM Studio" # LM Studio App Support - "co.supertool.chatbox" # Chatbox - "page.jan.jan" # Jan - "com.huggingface.huggingchat" # HuggingChat - "Gemini" # Gemini - "com.perplexity.Perplexity" # Perplexity - "com.drawthings.DrawThings" # Draw Things - "com.divamgupta.diffusionbee" # DiffusionBee - "com.exafunction.windsurf" # Windsurf - "com.quora.poe.electron" # Poe - "chat.openai.com.*" # OpenAI web wrappers - - # Development Tools - Database Clients - "com.sequelpro.*" # Sequel Pro - "com.sequel-ace.*" # Sequel Ace - "com.tinyapp.*" # TablePlus - "com.dbeaver.*" # DBeaver - "com.navicat.*" # Navicat - "com.mongodb.compass" # MongoDB Compass - "com.redis.RedisInsight" # Redis Insight - "com.pgadmin.pgadmin4" # pgAdmin - "com.eggerapps.Sequel-Pro" # Sequel Pro legacy - "com.valentina-db.Valentina-Studio" # Valentina Studio - "com.dbvis.DbVisualizer" # DbVisualizer - - # Development Tools - API & Network - "com.postmanlabs.mac" # Postman - "com.konghq.insomnia" # Insomnia - "com.CharlesProxy.*" # Charles Proxy - "com.proxyman.*" # Proxyman - "com.getpaw.*" # Paw - "com.luckymarmot.Paw" # Paw legacy - "com.charlesproxy.charles" # Charles - "com.telerik.Fiddler" # Fiddler - "com.usebruno.app" # Bruno (API client) - - # Network Proxy & VPN Tools (pattern-based protection) - # Clash variants - "*clash*" # All Clash variants (ClashX, ClashX Pro, Clash Verge, etc) - "*Clash*" # Capitalized variants - "com.nssurge.surge-mac" # Surge - "*surge*" # Surge variants - "*Surge*" # Surge variants - "mihomo*" # Mihomo Party and variants - "*openvpn*" # OpenVPN Connect and variants - "*OpenVPN*" # OpenVPN capitalized variants - "net.openvpn.*" # OpenVPN bundle IDs - - # Proxy Clients (Shadowsocks, V2Ray, etc) - "*ShadowsocksX-NG*" # ShadowsocksX-NG - "com.qiuyuzhou.*" # ShadowsocksX-NG bundle - "*v2ray*" # V2Ray variants - "*V2Ray*" # V2Ray variants - "*v2box*" # V2Box - "*V2Box*" # V2Box - "*nekoray*" # Nekoray - "*sing-box*" # Sing-box - "*OneBox*" # OneBox - "*hiddify*" # Hiddify - "*Hiddify*" # Hiddify - "*loon*" # Loon - "*Loon*" # Loon - "*quantumult*" # Quantumult X - - # Mesh & Corporate VPNs - "*tailscale*" # Tailscale - "io.tailscale.*" # Tailscale bundle - "*zerotier*" # ZeroTier - "com.zerotier.*" # ZeroTier bundle - "*1dot1dot1dot1*" # Cloudflare WARP - "*cloudflare*warp*" # Cloudflare WARP - - # Commercial VPNs - "*nordvpn*" # NordVPN - "*expressvpn*" # ExpressVPN - "*protonvpn*" # ProtonVPN - "*surfshark*" # Surfshark - "*windscribe*" # Windscribe - "*mullvad*" # Mullvad - "*privateinternetaccess*" # PIA - - # Screensaver & Dynamic Wallpaper - "*Aerial*" # Aerial screensaver (all case variants) - "*aerial*" # Aerial lowercase - "*Fliqlo*" # Fliqlo screensaver (all case variants) - "*fliqlo*" # Fliqlo lowercase - - # Development Tools - Git & Version Control - "com.github.GitHubDesktop" # GitHub Desktop - "com.sublimemerge" # Sublime Merge - "com.torusknot.SourceTreeNotMAS" # SourceTree - "com.git-tower.Tower*" # Tower - "com.gitfox.GitFox" # GitFox - "com.github.Gitify" # Gitify - "com.fork.Fork" # Fork - "com.axosoft.gitkraken" # GitKraken - - # Development Tools - Terminal & Shell - "com.googlecode.iterm2" # iTerm2 - "net.kovidgoyal.kitty" # Kitty - "io.alacritty" # Alacritty - "com.github.wez.wezterm" # WezTerm - "com.hyper.Hyper" # Hyper - "com.mizage.divvy" # Divvy - "com.fig.Fig" # Fig (terminal assistant) - "dev.warp.Warp-Stable" # Warp - "com.termius-dmg" # Termius (SSH client) - - # Development Tools - Docker & Virtualization - "com.docker.docker" # Docker Desktop - "com.getutm.UTM" # UTM - "com.vmware.fusion" # VMware Fusion - "com.parallels.desktop.*" # Parallels Desktop - "org.virtualbox.app.VirtualBox" # VirtualBox - "com.vagrant.*" # Vagrant - "com.orbstack.OrbStack" # OrbStack - - # System Monitoring & Performance - "com.bjango.istatmenus*" # iStat Menus - "eu.exelban.Stats" # Stats - "com.monitorcontrol.*" # MonitorControl - "com.bresink.system-toolkit.*" # TinkerTool System - "com.mediaatelier.MenuMeters" # MenuMeters - "com.activity-indicator.app" # Activity Indicator - "net.cindori.sensei" # Sensei - - # Window Management & Productivity - "com.macitbetter.*" # BetterTouchTool, BetterSnapTool - "com.hegenberg.*" # BetterTouchTool legacy - "com.manytricks.*" # Moom, Witch, Name Mangler, Resolutionator - "com.divisiblebyzero.*" # Spectacle - "com.koingdev.*" # Koingg apps - "com.if.Amphetamine" # Amphetamine - "com.lwouis.alt-tab-macos" # AltTab - "net.matthewpalmer.Vanilla" # Vanilla - "com.lightheadsw.Caffeine" # Caffeine - "com.contextual.Contexts" # Contexts - "com.amethyst.Amethyst" # Amethyst - "com.knollsoft.Rectangle" # Rectangle - "com.knollsoft.Hookshot" # Hookshot - "com.surteesstudios.Bartender" # Bartender - "com.gaosun.eul" # eul (system monitor) - "com.pointum.hazeover" # HazeOver - - # Launcher & Automation - "com.runningwithcrayons.Alfred" # Alfred - "com.raycast.macos" # Raycast - "com.blacktree.Quicksilver" # Quicksilver - "com.stairways.keyboardmaestro.*" # Keyboard Maestro - "com.manytricks.Butler" # Butler - "com.happenapps.Quitter" # Quitter - "com.pilotmoon.scroll-reverser" # Scroll Reverser - "org.pqrs.Karabiner-Elements" # Karabiner-Elements - "com.apple.Automator" # Automator (system, but keep user workflows) - - # Note-Taking & Documentation - "com.bear-writer.*" # Bear - "com.typora.*" # Typora - "com.ulyssesapp.*" # Ulysses - "com.literatureandlatte.*" # Scrivener - "com.dayoneapp.*" # Day One - "notion.id" # Notion - "md.obsidian" # Obsidian - "com.logseq.logseq" # Logseq - "com.evernote.Evernote" # Evernote - "com.onenote.mac" # OneNote - "com.omnigroup.OmniOutliner*" # OmniOutliner - "net.shinyfrog.bear" # Bear legacy - "com.goodnotes.GoodNotes" # GoodNotes - "com.marginnote.MarginNote*" # MarginNote - "com.roamresearch.*" # Roam Research - "com.reflect.ReflectApp" # Reflect - "com.inkdrop.*" # Inkdrop - - # Design & Creative Tools - "com.adobe.*" # Adobe Creative Suite - "com.bohemiancoding.*" # Sketch - "com.figma.*" # Figma - "com.framerx.*" # Framer - "com.zeplin.*" # Zeplin - "com.invisionapp.*" # InVision - "com.principle.*" # Principle - "com.pixelmatorteam.*" # Pixelmator - "com.affinitydesigner.*" # Affinity Designer - "com.affinityphoto.*" # Affinity Photo - "com.affinitypublisher.*" # Affinity Publisher - "com.linearity.curve" # Linearity Curve - "com.canva.CanvaDesktop" # Canva - "com.maxon.cinema4d" # Cinema 4D - "com.autodesk.*" # Autodesk products - "com.sketchup.*" # SketchUp - - # Communication & Collaboration - "com.tencent.xinWeChat" # WeChat (Chinese users) - "com.tencent.qq" # QQ - "com.alibaba.DingTalkMac" # DingTalk - "com.alibaba.AliLang.osx" # AliLang (retain login/config data) - "com.alibaba.alilang3.osx.ShipIt" # AliLang updater component - "com.alibaba.AlilangMgr.QueryNetworkInfo" # AliLang network helper - "us.zoom.xos" # Zoom - "com.microsoft.teams*" # Microsoft Teams - "com.slack.Slack" # Slack - "com.hnc.Discord" # Discord - "app.legcord.Legcord" # Legcord - "org.telegram.desktop" # Telegram - "ru.keepcoder.Telegram" # Telegram legacy - "net.whatsapp.WhatsApp" # WhatsApp - "com.skype.skype" # Skype - "com.cisco.webexmeetings" # Webex - "com.ringcentral.RingCentral" # RingCentral - "com.readdle.smartemail-Mac" # Spark Email - "com.airmail.*" # Airmail - "com.postbox-inc.postbox" # Postbox - "com.tinyspeck.slackmacgap" # Slack legacy - - # Task Management & Productivity - "com.omnigroup.OmniFocus*" # OmniFocus - "com.culturedcode.*" # Things - "com.todoist.*" # Todoist - "com.any.do.*" # Any.do - "com.ticktick.*" # TickTick - "com.microsoft.to-do" # Microsoft To Do - "com.trello.trello" # Trello - "com.asana.nativeapp" # Asana - "com.clickup.*" # ClickUp - "com.monday.desktop" # Monday.com - "com.airtable.airtable" # Airtable - "com.notion.id" # Notion (also note-taking) - "com.linear.linear" # Linear - - # File Transfer & Sync - "com.panic.transmit*" # Transmit (FTP/SFTP) - "com.binarynights.ForkLift*" # ForkLift - "com.noodlesoft.Hazel" # Hazel - "com.cyberduck.Cyberduck" # Cyberduck - "io.filezilla.FileZilla" # FileZilla - "com.apple.Xcode.CloudDocuments" # Xcode Cloud Documents - "com.synology.*" # Synology apps - - # Cloud Storage & Backup (Issue #204) - "com.dropbox.*" # Dropbox - "com.getdropbox.*" # Dropbox legacy - "*dropbox*" # Dropbox helpers/updaters - "ws.agile.*" # 1Password sync helpers - "com.backblaze.*" # Backblaze - "*backblaze*" # Backblaze helpers - "com.box.desktop*" # Box - "*box.desktop*" # Box helpers - "com.microsoft.OneDrive*" # Microsoft OneDrive - "com.microsoft.SyncReporter" # OneDrive sync reporter - "*OneDrive*" # OneDrive helpers/updaters - "com.google.GoogleDrive" # Google Drive - "com.google.keystone*" # Google updaters (Drive, Chrome, etc.) - "*GoogleDrive*" # Google Drive helpers - "com.amazon.drive" # Amazon Drive - "com.apple.bird" # iCloud Drive daemon - "com.apple.CloudDocs*" # iCloud Documents - "com.displaylink.*" # DisplayLink - "com.fujitsu.pfu.ScanSnap*" # ScanSnap - "com.citrix.*" # Citrix Workspace - "org.xquartz.*" # XQuartz - "us.zoom.updater*" # Zoom updaters - "com.DigiDNA.iMazing*" # iMazing - "com.shirtpocket.*" # SuperDuper backup - "homebrew.mxcl.*" # Homebrew services - - # Screenshot & Recording - "com.cleanshot.*" # CleanShot X - "com.xnipapp.xnip" # Xnip - "com.reincubate.camo" # Camo - "com.tunabellysoftware.ScreenFloat" # ScreenFloat - "net.telestream.screenflow*" # ScreenFlow - "com.techsmith.snagit*" # Snagit - "com.techsmith.camtasia*" # Camtasia - "com.obsidianapp.screenrecorder" # Screen Recorder - "com.kap.Kap" # Kap - "com.getkap.*" # Kap legacy - "com.linebreak.CloudApp" # CloudApp - "com.droplr.droplr-mac" # Droplr - - # Media & Entertainment - "com.spotify.client" # Spotify - "com.apple.Music" # Apple Music - "com.apple.podcasts" # Apple Podcasts - "com.apple.BKAgentService" # Apple Books (Agent) - "com.apple.iBooksX" # Apple Books - "com.apple.iBooks" # Apple Books (Legacy) - "com.apple.FinalCutPro" # Final Cut Pro - "com.apple.Motion" # Motion - "com.apple.Compressor" # Compressor - "com.blackmagic-design.*" # DaVinci Resolve - "com.colliderli.iina" # IINA - "org.videolan.vlc" # VLC - "io.mpv" # MPV - "com.noodlesoft.Hazel" # Hazel (automation) - "tv.plex.player.desktop" # Plex - "com.netease.163music" # NetEase Music - - # License Management & App Stores - "com.paddle.Paddle*" # Paddle (license management) - "com.setapp.DesktopClient" # Setapp - "com.devmate.*" # DevMate (license framework) - "org.sparkle-project.Sparkle" # Sparkle (update framework) -) - -# Centralized check for critical system components (case-insensitive) -is_critical_system_component() { - local token="$1" - [[ -z "$token" ]] && return 1 - - local lower - lower=$(echo "$token" | LC_ALL=C tr '[:upper:]' '[:lower:]') - - case "$lower" in - *backgroundtaskmanagement* | *loginitems* | *systempreferences* | *systemsettings* | *settings* | *preferences* | *controlcenter* | *biometrickit* | *sfl* | *tcc*) - return 0 - ;; - *) - return 1 - ;; - esac -} - -# Legacy function - preserved for backward compatibility -# Use should_protect_from_uninstall() or should_protect_data() instead -readonly PRESERVED_BUNDLE_PATTERNS=("${SYSTEM_CRITICAL_BUNDLES[@]}" "${DATA_PROTECTED_BUNDLES[@]}") - -# Check if bundle ID matches pattern (glob support) -bundle_matches_pattern() { - local bundle_id="$1" - local pattern="$2" - - [[ -z "$pattern" ]] && return 1 - - # Use bash [[ ]] for glob pattern matching (works with variables in bash 3.2+) - # shellcheck disable=SC2053 # allow glob pattern matching - if [[ "$bundle_id" == $pattern ]]; then - return 0 - fi - return 1 -} - -# Check if application is a protected system component -should_protect_from_uninstall() { - local bundle_id="$1" - for pattern in "${SYSTEM_CRITICAL_BUNDLES[@]}"; do - if bundle_matches_pattern "$bundle_id" "$pattern"; then - return 0 - fi - done - return 1 -} - -# Check if application data should be protected during cleanup -should_protect_data() { - local bundle_id="$1" - # Protect both system critical and data protected bundles during cleanup - for pattern in "${SYSTEM_CRITICAL_BUNDLES[@]}" "${DATA_PROTECTED_BUNDLES[@]}"; do - if bundle_matches_pattern "$bundle_id" "$pattern"; then - return 0 - fi - done - return 1 -} - -# Check if a path is protected from deletion -# Centralized logic to protect system settings, control center, and critical apps -# -# Args: $1 - path to check -# Returns: 0 if protected, 1 if safe to delete -should_protect_path() { - local path="$1" - [[ -z "$path" ]] && return 1 - - local path_lower - path_lower=$(echo "$path" | LC_ALL=C tr '[:upper:]' '[:lower:]') - - # 1. Keyword-based matching for system components - # Protect System Settings, Preferences, Control Center, and related XPC services - # Also protect "Settings" (used in macOS Sequoia) and savedState files - if [[ "$path_lower" =~ systemsettings || "$path_lower" =~ systempreferences || "$path_lower" =~ controlcenter ]]; then - return 0 - fi - - # Additional check for com.apple.Settings (macOS Sequoia System Settings) - if [[ "$path_lower" =~ com\.apple\.settings ]]; then - return 0 - fi - - # Protect Notes cache (search index issues) - if [[ "$path_lower" =~ com\.apple\.notes ]]; then - return 0 - fi - - # 2. Protect caches critical for system UI rendering - # These caches are essential for modern macOS (Sonoma/Sequoia) system UI rendering - case "$path" in - # System Settings and Control Center caches (CRITICAL - prevents blank panel bug) - *com.apple.systempreferences.cache* | *com.apple.Settings.cache* | *com.apple.controlcenter.cache*) - return 0 - ;; - # Finder and Dock (system essential) - *com.apple.finder.cache* | *com.apple.dock.cache*) - return 0 - ;; - # System XPC services and sandboxed containers - */Library/Containers/com.apple.Settings* | */Library/Containers/com.apple.SystemSettings* | */Library/Containers/com.apple.controlcenter*) - return 0 - ;; - */Library/Group\ Containers/com.apple.systempreferences* | */Library/Group\ Containers/com.apple.Settings*) - return 0 - ;; - # Shared file lists for System Settings (macOS Sequoia) - Issue #136 - */com.apple.sharedfilelist/*com.apple.Settings* | */com.apple.sharedfilelist/*com.apple.SystemSettings* | */com.apple.sharedfilelist/*systempreferences*) - return 0 - ;; - esac - - # 3. Extract bundle ID from sandbox paths - # Matches: .../Library/Containers/bundle.id/... - # Matches: .../Library/Group Containers/group.id/... - if [[ "$path" =~ /Library/Containers/([^/]+) ]] || [[ "$path" =~ /Library/Group\ Containers/([^/]+) ]]; then - local bundle_id="${BASH_REMATCH[1]}" - if should_protect_data "$bundle_id"; then - return 0 - fi - fi - - # 4. Check for specific hardcoded critical patterns - case "$path" in - *com.apple.Settings* | *com.apple.SystemSettings* | *com.apple.controlcenter* | *com.apple.finder* | *com.apple.dock*) - return 0 - ;; - esac - - # 5. Protect critical preference files and user data - case "$path" in - */Library/Preferences/com.apple.dock.plist | */Library/Preferences/com.apple.finder.plist) - return 0 - ;; - # Bluetooth and WiFi configurations - */ByHost/com.apple.bluetooth.* | */ByHost/com.apple.wifi.*) - return 0 - ;; - # iCloud Drive - protect user's cloud synced data - */Library/Mobile\ Documents* | */Mobile\ Documents*) - return 0 - ;; - esac - - # 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 - - # 7. Check if the filename itself matches any protected patterns - local filename - filename=$(basename "$path") - if should_protect_data "$filename"; then - return 0 - fi - - return 1 -} - -# Check if a path is protected by whitelist patterns -# Args: $1 - path to check -# Returns: 0 if whitelisted, 1 if not -is_path_whitelisted() { - local target_path="$1" - [[ -z "$target_path" ]] && return 1 - - # Normalize path (remove trailing slash) - local normalized_target="${target_path%/}" - - # Empty whitelist means nothing is protected - [[ ${#WHITELIST_PATTERNS[@]} -eq 0 ]] && return 1 - - for pattern in "${WHITELIST_PATTERNS[@]}"; do - # Pattern is already expanded/normalized in bin/clean.sh - local check_pattern="${pattern%/}" - local has_glob="false" - case "$check_pattern" in - *\** | *\?* | *\[*) - has_glob="true" - ;; - esac - - # Check for exact match or glob pattern match - # shellcheck disable=SC2053 - if [[ "$normalized_target" == "$check_pattern" ]] || - [[ "$normalized_target" == $check_pattern ]]; then - return 0 - fi - - # Check if target is a parent directory of a whitelisted path - # e.g., if pattern is /path/to/dir/subdir and target is /path/to/dir, - # the target should be protected to preserve its whitelisted children - if [[ "$check_pattern" == "$normalized_target"/* ]]; then - return 0 - fi - - # Check if target is a child of a whitelisted directory path - if [[ "$has_glob" == "false" && "$normalized_target" == "$check_pattern"/* ]]; then - return 0 - fi - done - - return 1 -} - -# Locate files associated with an application -find_app_files() { - local bundle_id="$1" - local app_name="$2" - local -a files_to_clean=() - - # Normalize app name for matching - local nospace_name="${app_name// /}" - local underscore_name="${app_name// /_}" - - # Standard path patterns for user-level files - local -a user_patterns=( - "$HOME/Library/Application Support/$app_name" - "$HOME/Library/Application Support/$bundle_id" - "$HOME/Library/Caches/$bundle_id" - "$HOME/Library/Caches/$app_name" - "$HOME/Library/Logs/$app_name" - "$HOME/Library/Logs/$bundle_id" - "$HOME/Library/Application Support/CrashReporter/$app_name" - "$HOME/Library/Saved Application State/$bundle_id.savedState" - "$HOME/Library/Containers/$bundle_id" - "$HOME/Library/WebKit/$bundle_id" - "$HOME/Library/WebKit/com.apple.WebKit.WebContent/$bundle_id" - "$HOME/Library/HTTPStorages/$bundle_id" - "$HOME/Library/Cookies/$bundle_id.binarycookies" - "$HOME/Library/LaunchAgents/$bundle_id.plist" - "$HOME/Library/Application Scripts/$bundle_id" - "$HOME/Library/Services/$app_name.workflow" - "$HOME/Library/QuickLook/$app_name.qlgenerator" - "$HOME/Library/Internet Plug-Ins/$app_name.plugin" - "$HOME/Library/Audio/Plug-Ins/Components/$app_name.component" - "$HOME/Library/Audio/Plug-Ins/VST/$app_name.vst" - "$HOME/Library/Audio/Plug-Ins/VST3/$app_name.vst3" - "$HOME/Library/Audio/Plug-Ins/Digidesign/$app_name.dpm" - "$HOME/Library/PreferencePanes/$app_name.prefPane" - "$HOME/Library/Input Methods/$app_name.app" - "$HOME/Library/Input Methods/$bundle_id.app" - "$HOME/Library/Screen Savers/$app_name.saver" - "$HOME/Library/Frameworks/$app_name.framework" - "$HOME/Library/Autosave Information/$bundle_id" - "$HOME/Library/Contextual Menu Items/$app_name.plugin" - "$HOME/Library/Spotlight/$app_name.mdimporter" - "$HOME/Library/ColorPickers/$app_name.colorPicker" - "$HOME/Library/Workflows/$app_name.workflow" - "$HOME/.config/$app_name" - "$HOME/.local/share/$app_name" - "$HOME/.$app_name" - "$HOME/.$app_name"rc - ) - - # Add sanitized name variants if unique enough - if [[ ${#app_name} -gt 3 && "$app_name" =~ [[:space:]] ]]; then - user_patterns+=( - "$HOME/Library/Application Support/$nospace_name" - "$HOME/Library/Caches/$nospace_name" - "$HOME/Library/Logs/$nospace_name" - "$HOME/Library/Application Support/$underscore_name" - ) - fi - - # Process standard patterns - for p in "${user_patterns[@]}"; do - local expanded_path="${p/#\~/$HOME}" - # Skip if path doesn't exist - [[ ! -e "$expanded_path" ]] && continue - - # Safety check: Skip if path ends with a common directory name (indicates empty app_name/bundle_id) - # This prevents deletion of entire Library subdirectories when bundle_id is empty - case "$expanded_path" in - */Library/Application\ Support | */Library/Application\ Support/ | \ - */Library/Caches | */Library/Caches/ | \ - */Library/Logs | */Library/Logs/ | \ - */Library/Containers | */Library/Containers/ | \ - */Library/WebKit | */Library/WebKit/ | \ - */Library/HTTPStorages | */Library/HTTPStorages/ | \ - */Library/Application\ Scripts | */Library/Application\ Scripts/ | \ - */Library/Autosave\ Information | */Library/Autosave\ Information/ | \ - */Library/Group\ Containers | */Library/Group\ Containers/) - continue - ;; - esac - - files_to_clean+=("$expanded_path") - done - - # Handle Preferences and ByHost variants (only if bundle_id is valid) - if [[ -n "$bundle_id" && "$bundle_id" != "unknown" && ${#bundle_id} -gt 3 ]]; then - [[ -f ~/Library/Preferences/"$bundle_id".plist ]] && files_to_clean+=("$HOME/Library/Preferences/$bundle_id.plist") - [[ -d ~/Library/Preferences/ByHost ]] && while IFS= read -r -d '' pref; do - files_to_clean+=("$pref") - done < <(command find ~/Library/Preferences/ByHost -maxdepth 1 \( -name "$bundle_id*.plist" \) -print0 2> /dev/null) - - # Group Containers (special handling) - if [[ -d ~/Library/Group\ Containers ]]; then - while IFS= read -r -d '' container; do - files_to_clean+=("$container") - done < <(command find ~/Library/Group\ Containers -maxdepth 1 \( -name "*$bundle_id*" \) -print0 2> /dev/null) - fi - fi - - # Launch Agents by name (special handling) - if [[ ${#app_name} -gt 3 ]] && [[ -d ~/Library/LaunchAgents ]]; then - while IFS= read -r -d '' plist; do - files_to_clean+=("$plist") - done < <(command find ~/Library/LaunchAgents -maxdepth 1 \( -name "*$app_name*.plist" \) -print0 2> /dev/null) - fi - - # Handle specialized toolchains and development environments - # 1. DevEco-Studio (Huawei) - if [[ "$app_name" =~ DevEco|deveco ]] || [[ "$bundle_id" =~ huawei.*deveco ]]; then - for d in ~/DevEcoStudioProjects ~/DevEco-Studio ~/Library/Application\ Support/Huawei ~/Library/Caches/Huawei ~/Library/Logs/Huawei ~/Library/Huawei ~/Huawei ~/HarmonyOS ~/.huawei ~/.ohos; do - [[ -d "$d" ]] && files_to_clean+=("$d") - done - fi - - # 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 - [[ -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) - fi - - # 3. Xcode (Apple) - if [[ "$app_name" =~ Xcode|xcode ]] || [[ "$bundle_id" =~ apple.*xcode ]]; then - [[ -d ~/Library/Developer ]] && files_to_clean+=("$HOME/Library/Developer") - [[ -d ~/.Xcode ]] && files_to_clean+=("$HOME/.Xcode") - fi - - # 4. JetBrains (IDE settings) - if [[ "$bundle_id" =~ jetbrains ]] || [[ "$app_name" =~ IntelliJ|PyCharm|WebStorm|GoLand|RubyMine|PhpStorm|CLion|DataGrip|Rider ]]; then - for base in ~/Library/Application\ Support/JetBrains ~/Library/Caches/JetBrains ~/Library/Logs/JetBrains; do - [[ -d "$base" ]] && while IFS= read -r -d '' d; do files_to_clean+=("$d"); done < <(command find "$base" -maxdepth 1 -name "${app_name}*" -print0 2> /dev/null) - done - fi - - # 5. Unity / Unreal / Godot - [[ "$app_name" =~ Unity|unity ]] && [[ -d ~/Library/Unity ]] && files_to_clean+=("$HOME/Library/Unity") - [[ "$app_name" =~ Unreal|unreal ]] && [[ -d ~/Library/Application\ Support/Epic ]] && files_to_clean+=("$HOME/Library/Application Support/Epic") - [[ "$app_name" =~ Godot|godot ]] && [[ -d ~/Library/Application\ Support/Godot ]] && files_to_clean+=("$HOME/Library/Application Support/Godot") - - # 6. Tools - [[ "$bundle_id" =~ microsoft.*vscode ]] && [[ -d ~/.vscode ]] && files_to_clean+=("$HOME/.vscode") - [[ "$app_name" =~ Docker ]] && [[ -d ~/.docker ]] && files_to_clean+=("$HOME/.docker") - - # Output results - if [[ ${#files_to_clean[@]} -gt 0 ]]; then - printf '%s\n' "${files_to_clean[@]}" - fi - return 0 -} - -# Locate system-level application files -find_app_system_files() { - local bundle_id="$1" - local app_name="$2" - local -a system_files=() - - # Sanitized App Name (remove spaces) - local nospace_name="${app_name// /}" - - # Standard system path patterns - local -a system_patterns=( - "/Library/Application Support/$app_name" - "/Library/Application Support/$bundle_id" - "/Library/LaunchAgents/$bundle_id.plist" - "/Library/LaunchDaemons/$bundle_id.plist" - "/Library/Preferences/$bundle_id.plist" - "/Library/Receipts/$bundle_id.bom" - "/Library/Receipts/$bundle_id.plist" - "/Library/Frameworks/$app_name.framework" - "/Library/Internet Plug-Ins/$app_name.plugin" - "/Library/Input Methods/$app_name.app" - "/Library/Input Methods/$bundle_id.app" - "/Library/Audio/Plug-Ins/Components/$app_name.component" - "/Library/Audio/Plug-Ins/VST/$app_name.vst" - "/Library/Audio/Plug-Ins/VST3/$app_name.vst3" - "/Library/Audio/Plug-Ins/Digidesign/$app_name.dpm" - "/Library/QuickLook/$app_name.qlgenerator" - "/Library/PreferencePanes/$app_name.prefPane" - "/Library/Screen Savers/$app_name.saver" - "/Library/Caches/$bundle_id" - "/Library/Caches/$app_name" - ) - - if [[ ${#app_name} -gt 3 && "$app_name" =~ [[:space:]] ]]; then - system_patterns+=( - "/Library/Application Support/$nospace_name" - "/Library/Caches/$nospace_name" - "/Library/Logs/$nospace_name" - ) - fi - - # Process patterns - for p in "${system_patterns[@]}"; do - [[ ! -e "$p" ]] && continue - - # Safety check: Skip if path ends with a common directory name (indicates empty app_name/bundle_id) - case "$p" in - /Library/Application\ Support | /Library/Application\ Support/ | \ - /Library/Caches | /Library/Caches/ | \ - /Library/Logs | /Library/Logs/) - continue - ;; - esac - - system_files+=("$p") - done - - # System LaunchAgents/LaunchDaemons by name - if [[ ${#app_name} -gt 3 ]]; then - for base in /Library/LaunchAgents /Library/LaunchDaemons; do - [[ -d "$base" ]] && while IFS= read -r -d '' plist; do - system_files+=("$plist") - done < <(command find "$base" -maxdepth 1 \( -name "*$app_name*.plist" \) -print0 2> /dev/null) - done - fi - - # Privileged Helper Tools and Receipts (special handling) - # Only search with bundle_id if it's valid (not empty and not "unknown") - if [[ -n "$bundle_id" && "$bundle_id" != "unknown" && ${#bundle_id} -gt 3 ]]; then - [[ -d /Library/PrivilegedHelperTools ]] && while IFS= read -r -d '' helper; do - system_files+=("$helper") - done < <(command find /Library/PrivilegedHelperTools -maxdepth 1 \( -name "$bundle_id*" \) -print0 2> /dev/null) - - [[ -d /private/var/db/receipts ]] && while IFS= read -r -d '' receipt; do - system_files+=("$receipt") - done < <(command find /private/var/db/receipts -maxdepth 1 \( -name "*$bundle_id*" \) -print0 2> /dev/null) - fi - - if [[ ${#system_files[@]} -gt 0 ]]; then - printf '%s\n' "${system_files[@]}" - fi - - # Find files from receipts (Deep Scan) - find_app_receipt_files "$bundle_id" -} - -# Locate files using installation receipts (BOM) -find_app_receipt_files() { - local bundle_id="$1" - - # Skip if no bundle ID - [[ -z "$bundle_id" || "$bundle_id" == "unknown" ]] && return 0 - - local -a receipt_files=() - local -a bom_files=() - - # Find receipts matching the bundle ID - # Usually in /var/db/receipts/ - if [[ -d /private/var/db/receipts ]]; then - while IFS= read -r -d '' bom; do - bom_files+=("$bom") - done < <(find /private/var/db/receipts -maxdepth 1 -name "${bundle_id}*.bom" -print0 2> /dev/null) - fi - - # Process bom files if any found - if [[ ${#bom_files[@]} -gt 0 ]]; then - for bom_file in "${bom_files[@]}"; do - [[ ! -f "$bom_file" ]] && continue - - # Parse bom file - # lsbom -f: file paths only - # -s: suppress output (convert to text) - local bom_content - bom_content=$(lsbom -f -s "$bom_file" 2> /dev/null) - - while IFS= read -r file_path; do - # Standardize path (remove leading dot) - local clean_path="${file_path#.}" - - # Ensure absolute path - if [[ "$clean_path" != /* ]]; then - clean_path="/$clean_path" - fi - - # ------------------------------------------------------------------------ - # Safety check: restrict removal to trusted paths - # ------------------------------------------------------------------------ - local is_safe=false - - # Whitelisted prefixes - case "$clean_path" in - /Applications/*) is_safe=true ;; - /Users/*) is_safe=true ;; - /usr/local/*) is_safe=true ;; - /opt/*) is_safe=true ;; - /Library/*) - # Filter sub-paths in /Library to avoid system damage - # Allow safely: Application Support, Caches, Logs, Preferences - case "$clean_path" in - /Library/Application\ Support/*) is_safe=true ;; - /Library/Caches/*) is_safe=true ;; - /Library/Logs/*) is_safe=true ;; - /Library/Preferences/*) is_safe=true ;; - /Library/PrivilegedHelperTools/*) is_safe=true ;; - /Library/LaunchAgents/*) is_safe=true ;; - /Library/LaunchDaemons/*) is_safe=true ;; - /Library/Internet\ Plug-Ins/*) is_safe=true ;; - /Library/Audio/Plug-Ins/*) is_safe=true ;; - /Library/Extensions/*) is_safe=false ;; # Default unsafe - *) is_safe=false ;; - esac - ;; - esac - - # Hard blocks - case "$clean_path" in - /System/* | /usr/bin/* | /usr/lib/* | /bin/* | /sbin/*) is_safe=false ;; - esac - - if [[ "$is_safe" == "true" && -e "$clean_path" ]]; then - # If lsbom lists /Applications, skip to avoid system damage. - # Extra check: path must be deep enough? - # If path is just "/Applications", skip. - if [[ "$clean_path" == "/Applications" || "$clean_path" == "/Library" || "$clean_path" == "/usr/local" ]]; then - continue - fi - - receipt_files+=("$clean_path") - fi - - done <<< "$bom_content" - done - fi - if [[ ${#receipt_files[@]} -gt 0 ]]; then - printf '%s\n' "${receipt_files[@]}" - fi -} - -# Terminate a running application -force_kill_app() { - # Gracefully terminates or force-kills an application - local app_name="$1" - local app_path="${2:-""}" - - # Get the executable name from bundle if app_path is provided - local exec_name="" - if [[ -n "$app_path" && -e "$app_path/Contents/Info.plist" ]]; then - exec_name=$(defaults read "$app_path/Contents/Info.plist" CFBundleExecutable 2> /dev/null || echo "") - fi - - # Use executable name for precise matching, fallback to app name - local match_pattern="${exec_name:-$app_name}" - - # Check if process is running using exact match only - if ! pgrep -x "$match_pattern" > /dev/null 2>&1; then - return 0 - fi - - # Try graceful termination first - pkill -x "$match_pattern" 2> /dev/null || true - sleep 2 - - # Check again after graceful kill - if ! pgrep -x "$match_pattern" > /dev/null 2>&1; then - return 0 - fi - - # Force kill if still running - pkill -9 -x "$match_pattern" 2> /dev/null || true - sleep 2 - - # If still running and sudo is available, try with sudo - if pgrep -x "$match_pattern" > /dev/null 2>&1; then - if sudo -n true 2> /dev/null; then - sudo pkill -9 -x "$match_pattern" 2> /dev/null || true - sleep 2 - fi - fi - - # Final check with longer timeout for stubborn processes - local retries=3 - while [[ $retries -gt 0 ]]; do - if ! pgrep -x "$match_pattern" > /dev/null 2>&1; then - return 0 - fi - sleep 1 - ((retries--)) - done - - # Still running after all attempts - pgrep -x "$match_pattern" > /dev/null 2>&1 && return 1 || return 0 -} - -# Note: calculate_total_size() is defined in lib/core/file_ops.sh diff --git a/windows/lib/core/base.ps1 b/lib/core/base.ps1 similarity index 100% rename from windows/lib/core/base.ps1 rename to lib/core/base.ps1 diff --git a/lib/core/base.sh b/lib/core/base.sh deleted file mode 100644 index 5a455e9..0000000 --- a/lib/core/base.sh +++ /dev/null @@ -1,864 +0,0 @@ -#!/bin/bash -# Mole - Base Definitions and Utilities -# Core definitions, constants, and basic utility functions used by all modules - -set -euo pipefail - -# Prevent multiple sourcing -if [[ -n "${MOLE_BASE_LOADED:-}" ]]; then - return 0 -fi -readonly MOLE_BASE_LOADED=1 - -# ============================================================================ -# Color Definitions -# ============================================================================ -readonly ESC=$'\033' -readonly GREEN="${ESC}[0;32m" -readonly BLUE="${ESC}[0;34m" -readonly CYAN="${ESC}[0;36m" -readonly YELLOW="${ESC}[0;33m" -readonly PURPLE="${ESC}[0;35m" -readonly PURPLE_BOLD="${ESC}[1;35m" -readonly RED="${ESC}[0;31m" -readonly GRAY="${ESC}[0;90m" -readonly NC="${ESC}[0m" - -# ============================================================================ -# Icon Definitions -# ============================================================================ -readonly ICON_CONFIRM="◎" -readonly ICON_ADMIN="⚙" -readonly ICON_SUCCESS="✓" -readonly ICON_ERROR="☻" -readonly ICON_WARNING="●" -readonly ICON_EMPTY="○" -readonly ICON_SOLID="●" -readonly ICON_LIST="•" -readonly ICON_ARROW="➤" -readonly ICON_DRY_RUN="→" -readonly ICON_NAV_UP="↑" -readonly ICON_NAV_DOWN="↓" - -# ============================================================================ -# Global Configuration Constants -# ============================================================================ -readonly MOLE_TEMP_FILE_AGE_DAYS=7 # Temp file retention (days) -readonly MOLE_ORPHAN_AGE_DAYS=60 # Orphaned data retention (days) -readonly MOLE_MAX_PARALLEL_JOBS=15 # Parallel job limit -readonly MOLE_MAIL_DOWNLOADS_MIN_KB=5120 # Mail attachment size threshold -readonly MOLE_MAIL_AGE_DAYS=30 # Mail attachment retention (days) -readonly MOLE_LOG_AGE_DAYS=7 # Log retention (days) -readonly MOLE_CRASH_REPORT_AGE_DAYS=7 # Crash report retention (days) -readonly MOLE_SAVED_STATE_AGE_DAYS=30 # Saved state retention (days) - increased for safety -readonly MOLE_TM_BACKUP_SAFE_HOURS=48 # TM backup safety window (hours) -readonly MOLE_MAX_DS_STORE_FILES=500 # Max .DS_Store files to clean per scan -readonly MOLE_MAX_ORPHAN_ITERATIONS=100 # Max iterations for orphaned app data scan - -# ============================================================================ -# Whitelist Configuration -# ============================================================================ -readonly FINDER_METADATA_SENTINEL="FINDER_METADATA" -declare -a DEFAULT_WHITELIST_PATTERNS=( - "$HOME/Library/Caches/ms-playwright*" - "$HOME/.cache/huggingface*" - "$HOME/.m2/repository/*" - "$HOME/.ollama/models/*" - "$HOME/Library/Caches/com.nssurge.surge-mac/*" - "$HOME/Library/Application Support/com.nssurge.surge-mac/*" - "$HOME/Library/Caches/org.R-project.R/R/renv/*" - "$HOME/Library/Caches/pypoetry/virtualenvs*" - "$HOME/Library/Caches/JetBrains*" - "$HOME/Library/Caches/com.jetbrains.toolbox*" - "$HOME/Library/Application Support/JetBrains*" - "$HOME/Library/Caches/com.apple.finder" - "$HOME/Library/Mobile Documents*" - # System-critical caches that affect macOS functionality and stability - # CRITICAL: Removing these will cause system search and UI issues - "$HOME/Library/Caches/com.apple.FontRegistry*" - "$HOME/Library/Caches/com.apple.spotlight*" - "$HOME/Library/Caches/com.apple.Spotlight*" - "$HOME/Library/Caches/CloudKit*" - "$FINDER_METADATA_SENTINEL" -) - -declare -a DEFAULT_OPTIMIZE_WHITELIST_PATTERNS=( - "check_brew_health" - "check_touchid" - "check_git_config" -) - -# ============================================================================ -# BSD Stat Compatibility -# ============================================================================ -readonly STAT_BSD="/usr/bin/stat" - -# Get file size in bytes -get_file_size() { - local file="$1" - local result - result=$($STAT_BSD -f%z "$file" 2> /dev/null) - echo "${result:-0}" -} - -# Get file modification time in epoch seconds -get_file_mtime() { - local file="$1" - [[ -z "$file" ]] && { - echo "0" - return - } - local result - result=$($STAT_BSD -f%m "$file" 2> /dev/null || echo "") - if [[ "$result" =~ ^[0-9]+$ ]]; then - echo "$result" - else - echo "0" - fi -} - -# Determine date command once -if [[ -x /bin/date ]]; then - _DATE_CMD="/bin/date" -else - _DATE_CMD="date" -fi - -# Get current time in epoch seconds (defensive against locale/aliases) -get_epoch_seconds() { - local result - result=$($_DATE_CMD +%s 2> /dev/null || echo "") - if [[ "$result" =~ ^[0-9]+$ ]]; then - echo "$result" - else - echo "0" - fi -} - -# Get file owner username -get_file_owner() { - local file="$1" - $STAT_BSD -f%Su "$file" 2> /dev/null || echo "" -} - -# ============================================================================ -# System Utilities -# ============================================================================ - -# Check if System Integrity Protection is enabled -# Returns: 0 if SIP is enabled, 1 if disabled or cannot determine -is_sip_enabled() { - if ! command -v csrutil > /dev/null 2>&1; then - return 0 - fi - - local sip_status - sip_status=$(csrutil status 2> /dev/null || echo "") - - if echo "$sip_status" | grep -qi "enabled"; then - return 0 - else - return 1 - fi -} - -# Check if running in an interactive terminal -is_interactive() { - [[ -t 1 ]] -} - -# Detect CPU architecture -# Returns: "Apple Silicon" or "Intel" -detect_architecture() { - if [[ "$(uname -m)" == "arm64" ]]; then - echo "Apple Silicon" - else - echo "Intel" - fi -} - -# Get free disk space on root volume -# Returns: human-readable string (e.g., "100G") -get_free_space() { - local target="/" - if [[ -d "/System/Volumes/Data" ]]; then - target="/System/Volumes/Data" - fi - - df -h "$target" | awk 'NR==2 {print $4}' -} - -# Get Darwin kernel major version (e.g., 24 for 24.2.0) -# Returns 999 on failure to adopt conservative behavior (assume modern system) -get_darwin_major() { - local kernel - kernel=$(uname -r 2> /dev/null || true) - local major="${kernel%%.*}" - if [[ ! "$major" =~ ^[0-9]+$ ]]; then - # Return high number to skip potentially dangerous operations on unknown systems - major=999 - fi - echo "$major" -} - -# Check if Darwin kernel major version meets minimum -is_darwin_ge() { - local minimum="$1" - local major - major=$(get_darwin_major) - [[ "$major" -ge "$minimum" ]] -} - -# Get optimal parallel jobs for operation type (scan|io|compute|default) -get_optimal_parallel_jobs() { - local operation_type="${1:-default}" - local cpu_cores - cpu_cores=$(sysctl -n hw.ncpu 2> /dev/null || echo 4) - case "$operation_type" in - scan | io) - echo $((cpu_cores * 2)) - ;; - compute) - echo "$cpu_cores" - ;; - *) - echo $((cpu_cores + 2)) - ;; - esac -} - -# ============================================================================ -# User Context Utilities -# ============================================================================ - -is_root_user() { - [[ "$(id -u)" == "0" ]] -} - -get_user_home() { - local user="$1" - local home="" - - if [[ -z "$user" ]]; then - echo "" - return 0 - fi - - if command -v dscl > /dev/null 2>&1; then - home=$(dscl . -read "/Users/$user" NFSHomeDirectory 2> /dev/null | awk '{print $2}' | head -1 || true) - fi - - if [[ -z "$home" ]]; then - home=$(eval echo "~$user" 2> /dev/null || true) - fi - - if [[ "$home" == "~"* ]]; then - home="" - fi - - echo "$home" -} - -get_invoking_user() { - if [[ -n "${SUDO_USER:-}" && "${SUDO_USER:-}" != "root" ]]; then - echo "$SUDO_USER" - return 0 - fi - echo "${USER:-}" -} - -get_invoking_uid() { - if [[ -n "${SUDO_UID:-}" ]]; then - echo "$SUDO_UID" - return 0 - fi - - local uid - uid=$(id -u 2> /dev/null || true) - echo "$uid" -} - -get_invoking_gid() { - if [[ -n "${SUDO_GID:-}" ]]; then - echo "$SUDO_GID" - return 0 - fi - - local gid - gid=$(id -g 2> /dev/null || true) - echo "$gid" -} - -get_invoking_home() { - if [[ -n "${SUDO_USER:-}" && "${SUDO_USER:-}" != "root" ]]; then - get_user_home "$SUDO_USER" - return 0 - fi - - echo "${HOME:-}" -} - -ensure_user_dir() { - local raw_path="$1" - if [[ -z "$raw_path" ]]; then - return 0 - fi - - local target_path="$raw_path" - if [[ "$target_path" == "~"* ]]; then - target_path="${target_path/#\~/$HOME}" - fi - - mkdir -p "$target_path" 2> /dev/null || true - - if ! is_root_user; then - return 0 - fi - - local sudo_user="${SUDO_USER:-}" - if [[ -z "$sudo_user" || "$sudo_user" == "root" ]]; then - return 0 - fi - - local user_home - user_home=$(get_user_home "$sudo_user") - if [[ -z "$user_home" ]]; then - return 0 - fi - user_home="${user_home%/}" - - if [[ "$target_path" != "$user_home" && "$target_path" != "$user_home/"* ]]; then - return 0 - fi - - local owner_uid="${SUDO_UID:-}" - local owner_gid="${SUDO_GID:-}" - if [[ -z "$owner_uid" || -z "$owner_gid" ]]; then - owner_uid=$(id -u "$sudo_user" 2> /dev/null || true) - owner_gid=$(id -g "$sudo_user" 2> /dev/null || true) - fi - - if [[ -z "$owner_uid" || -z "$owner_gid" ]]; then - return 0 - fi - - local dir="$target_path" - while [[ -n "$dir" && "$dir" != "/" ]]; do - # Early stop: if ownership is already correct, no need to continue up the tree - if [[ -d "$dir" ]]; then - local current_uid - current_uid=$("$STAT_BSD" -f%u "$dir" 2> /dev/null || echo "") - if [[ "$current_uid" == "$owner_uid" ]]; then - break - fi - fi - - chown "$owner_uid:$owner_gid" "$dir" 2> /dev/null || true - - if [[ "$dir" == "$user_home" ]]; then - break - fi - dir=$(dirname "$dir") - if [[ "$dir" == "." ]]; then - break - fi - done -} - -ensure_user_file() { - local raw_path="$1" - if [[ -z "$raw_path" ]]; then - return 0 - fi - - local target_path="$raw_path" - if [[ "$target_path" == "~"* ]]; then - target_path="${target_path/#\~/$HOME}" - fi - - ensure_user_dir "$(dirname "$target_path")" - touch "$target_path" 2> /dev/null || true - - if ! is_root_user; then - return 0 - fi - - local sudo_user="${SUDO_USER:-}" - if [[ -z "$sudo_user" || "$sudo_user" == "root" ]]; then - return 0 - fi - - local user_home - user_home=$(get_user_home "$sudo_user") - if [[ -z "$user_home" ]]; then - return 0 - fi - user_home="${user_home%/}" - - if [[ "$target_path" != "$user_home" && "$target_path" != "$user_home/"* ]]; then - return 0 - fi - - local owner_uid="${SUDO_UID:-}" - local owner_gid="${SUDO_GID:-}" - if [[ -z "$owner_uid" || -z "$owner_gid" ]]; then - owner_uid=$(id -u "$sudo_user" 2> /dev/null || true) - owner_gid=$(id -g "$sudo_user" 2> /dev/null || true) - fi - - if [[ -n "$owner_uid" && -n "$owner_gid" ]]; then - chown "$owner_uid:$owner_gid" "$target_path" 2> /dev/null || true - fi -} - -# ============================================================================ -# Formatting Utilities -# ============================================================================ - -# Convert bytes to human-readable format (e.g., 1.5GB) -bytes_to_human() { - local bytes="$1" - [[ "$bytes" =~ ^[0-9]+$ ]] || { - echo "0B" - return 1 - } - - # GB: >= 1073741824 bytes - if ((bytes >= 1073741824)); then - printf "%d.%02dGB\n" $((bytes / 1073741824)) $(((bytes % 1073741824) * 100 / 1073741824)) - # MB: >= 1048576 bytes - elif ((bytes >= 1048576)); then - printf "%d.%01dMB\n" $((bytes / 1048576)) $(((bytes % 1048576) * 10 / 1048576)) - # KB: >= 1024 bytes (round up) - elif ((bytes >= 1024)); then - printf "%dKB\n" $(((bytes + 512) / 1024)) - else - printf "%dB\n" "$bytes" - fi -} - -# Convert kilobytes to human-readable format -# Args: $1 - size in KB -# Returns: formatted string -bytes_to_human_kb() { - bytes_to_human "$((${1:-0} * 1024))" -} - -# Get brand-friendly localized name for an application -get_brand_name() { - local name="$1" - - # Detect if system primary language is Chinese (Cached) - if [[ -z "${MOLE_IS_CHINESE_SYSTEM:-}" ]]; then - local sys_lang - sys_lang=$(defaults read -g AppleLanguages 2> /dev/null | grep -o 'zh-Hans\|zh-Hant\|zh' | head -1 || echo "") - if [[ -n "$sys_lang" ]]; then - export MOLE_IS_CHINESE_SYSTEM="true" - else - export MOLE_IS_CHINESE_SYSTEM="false" - fi - fi - - local is_chinese="${MOLE_IS_CHINESE_SYSTEM}" - - # Return localized names based on system language - if [[ "$is_chinese" == true ]]; then - # Chinese system - prefer Chinese names - case "$name" in - "qiyimac" | "iQiyi") echo "爱奇艺" ;; - "wechat" | "WeChat") echo "微信" ;; - "QQ") echo "QQ" ;; - "VooV Meeting") echo "腾讯会议" ;; - "dingtalk" | "DingTalk") echo "钉钉" ;; - "NeteaseMusic" | "NetEase Music") echo "网易云音乐" ;; - "BaiduNetdisk" | "Baidu NetDisk") echo "百度网盘" ;; - "alipay" | "Alipay") echo "支付宝" ;; - "taobao" | "Taobao") echo "淘宝" ;; - "futunn" | "Futu NiuNiu") echo "富途牛牛" ;; - "tencent lemon" | "Tencent Lemon Cleaner" | "Tencent Lemon") echo "腾讯柠檬清理" ;; - *) echo "$name" ;; - esac - else - # Non-Chinese system - use English names - case "$name" in - "qiyimac" | "爱奇艺") echo "iQiyi" ;; - "wechat" | "微信") echo "WeChat" ;; - "QQ") echo "QQ" ;; - "腾讯会议") echo "VooV Meeting" ;; - "dingtalk" | "钉钉") echo "DingTalk" ;; - "网易云音乐") echo "NetEase Music" ;; - "百度网盘") echo "Baidu NetDisk" ;; - "alipay" | "支付宝") echo "Alipay" ;; - "taobao" | "淘宝") echo "Taobao" ;; - "富途牛牛") echo "Futu NiuNiu" ;; - "腾讯柠檬清理" | "Tencent Lemon Cleaner") echo "Tencent Lemon" ;; - "keynote" | "Keynote") echo "Keynote" ;; - "pages" | "Pages") echo "Pages" ;; - "numbers" | "Numbers") echo "Numbers" ;; - *) echo "$name" ;; - esac - fi -} - -# ============================================================================ -# Temporary File Management -# ============================================================================ - -# Tracked temporary files and directories -declare -a MOLE_TEMP_FILES=() -declare -a MOLE_TEMP_DIRS=() - -# Create tracked temporary file -create_temp_file() { - local temp - temp=$(mktemp) || return 1 - MOLE_TEMP_FILES+=("$temp") - echo "$temp" -} - -# Create tracked temporary directory -create_temp_dir() { - local temp - temp=$(mktemp -d) || return 1 - MOLE_TEMP_DIRS+=("$temp") - echo "$temp" -} - -# Register existing file for cleanup -register_temp_file() { - MOLE_TEMP_FILES+=("$1") -} - -# Register existing directory for cleanup -register_temp_dir() { - MOLE_TEMP_DIRS+=("$1") -} - -# Create temp file with prefix (for analyze.sh compatibility) -# Compatible with both BSD mktemp (macOS default) and GNU mktemp (coreutils) -mktemp_file() { - local prefix="${1:-mole}" - # Use TMPDIR if set, otherwise /tmp - # Add .XXXXXX suffix to work with both BSD and GNU mktemp - mktemp "${TMPDIR:-/tmp}/${prefix}.XXXXXX" -} - -# Cleanup all tracked temp files and directories -cleanup_temp_files() { - stop_inline_spinner 2> /dev/null || true - local file - if [[ ${#MOLE_TEMP_FILES[@]} -gt 0 ]]; then - for file in "${MOLE_TEMP_FILES[@]}"; do - [[ -f "$file" ]] && rm -f "$file" 2> /dev/null || true - done - fi - - if [[ ${#MOLE_TEMP_DIRS[@]} -gt 0 ]]; then - for file in "${MOLE_TEMP_DIRS[@]}"; do - [[ -d "$file" ]] && rm -rf "$file" 2> /dev/null || true # SAFE: cleanup_temp_files - done - fi - - MOLE_TEMP_FILES=() - MOLE_TEMP_DIRS=() -} - -# ============================================================================ -# Section Tracking (for progress indication) -# ============================================================================ - -# Global section tracking variables -TRACK_SECTION=0 -SECTION_ACTIVITY=0 - -# Start a new section -# Args: $1 - section title -start_section() { - TRACK_SECTION=1 - SECTION_ACTIVITY=0 - echo "" - echo -e "${PURPLE_BOLD}${ICON_ARROW} $1${NC}" -} - -# End a section -# Shows "Nothing to tidy" if no activity was recorded -end_section() { - if [[ "${TRACK_SECTION:-0}" == "1" && "${SECTION_ACTIVITY:-0}" == "0" ]]; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Nothing to tidy" - fi - TRACK_SECTION=0 -} - -# Mark activity in current section -note_activity() { - if [[ "${TRACK_SECTION:-0}" == "1" ]]; then - SECTION_ACTIVITY=1 - fi -} - -# Start a section spinner with optional message -# Usage: start_section_spinner "message" -start_section_spinner() { - local message="${1:-Scanning...}" - stop_inline_spinner 2> /dev/null || true - if [[ -t 1 ]]; then - MOLE_SPINNER_PREFIX=" " start_inline_spinner "$message" - fi -} - -# Stop spinner and clear the line -# Usage: stop_section_spinner -stop_section_spinner() { - stop_inline_spinner 2> /dev/null || true - if [[ -t 1 ]]; then - echo -ne "\r\033[K" >&2 || true - fi -} - -# Safe terminal line clearing with terminal type detection -# Usage: safe_clear_lines [tty_device] -# Returns: 0 on success, 1 if terminal doesn't support ANSI -safe_clear_lines() { - local lines="${1:-1}" - local tty_device="${2:-/dev/tty}" - - # Use centralized ANSI support check (defined below) - # Note: This forward reference works because functions are parsed before execution - is_ansi_supported 2> /dev/null || return 1 - - # Clear lines one by one (more reliable than multi-line sequences) - local i - for ((i = 0; i < lines; i++)); do - printf "\033[1A\r\033[K" > "$tty_device" 2> /dev/null || return 1 - done - - return 0 -} - -# Safe single line clear with fallback -# Usage: safe_clear_line [tty_device] -safe_clear_line() { - local tty_device="${1:-/dev/tty}" - - # Use centralized ANSI support check - is_ansi_supported 2> /dev/null || return 1 - - printf "\r\033[K" > "$tty_device" 2> /dev/null || return 1 - return 0 -} - -# Update progress spinner if enough time has elapsed -# Usage: update_progress_if_needed [interval] -# Example: update_progress_if_needed "$completed" "$total" last_progress_update 2 -# Returns: 0 if updated, 1 if skipped -update_progress_if_needed() { - local completed="$1" - local total="$2" - local last_update_var="$3" # Name of variable holding last update time - local interval="${4:-2}" # Default: update every 2 seconds - - # Get current time - local current_time - current_time=$(get_epoch_seconds) - - # Get last update time from variable - local last_time - eval "last_time=\${$last_update_var:-0}" - [[ "$last_time" =~ ^[0-9]+$ ]] || last_time=0 - - # Check if enough time has elapsed - if [[ $((current_time - last_time)) -ge $interval ]]; then - # Update the spinner with progress - stop_section_spinner - start_section_spinner "Scanning items... ($completed/$total)" - - # Update the last_update_time variable - eval "$last_update_var=$current_time" - return 0 - fi - - return 1 -} - -# ============================================================================ -# Spinner Stack Management (prevents nesting issues) -# ============================================================================ - -# Global spinner stack -declare -a MOLE_SPINNER_STACK=() - -# Push current spinner state onto stack -# Usage: push_spinner_state -push_spinner_state() { - local current_state="" - - # Save current spinner PID if running - if [[ -n "${MOLE_SPINNER_PID:-}" ]] && kill -0 "$MOLE_SPINNER_PID" 2> /dev/null; then - current_state="running:$MOLE_SPINNER_PID" - else - current_state="stopped" - fi - - MOLE_SPINNER_STACK+=("$current_state") - debug_log "Pushed spinner state: $current_state (stack depth: ${#MOLE_SPINNER_STACK[@]})" -} - -# Pop and restore spinner state from stack -# Usage: pop_spinner_state -pop_spinner_state() { - if [[ ${#MOLE_SPINNER_STACK[@]} -eq 0 ]]; then - debug_log "Warning: Attempted to pop from empty spinner stack" - return 1 - fi - - # Stack depth safety check - if [[ ${#MOLE_SPINNER_STACK[@]} -gt 10 ]]; then - debug_log "Warning: Spinner stack depth excessive (${#MOLE_SPINNER_STACK[@]}), possible leak" - fi - - local last_idx=$((${#MOLE_SPINNER_STACK[@]} - 1)) - local state="${MOLE_SPINNER_STACK[$last_idx]}" - - # Remove from stack (Bash 3.2 compatible way) - # Instead of unset, rebuild array without last element - local -a new_stack=() - local i - for ((i = 0; i < last_idx; i++)); do - new_stack+=("${MOLE_SPINNER_STACK[$i]}") - done - MOLE_SPINNER_STACK=("${new_stack[@]}") - - debug_log "Popped spinner state: $state (remaining depth: ${#MOLE_SPINNER_STACK[@]})" - - # Restore state if needed - if [[ "$state" == running:* ]]; then - # Previous spinner was running - we don't restart it automatically - # This is intentional to avoid UI conflicts - : - fi - - return 0 -} - -# Safe spinner start with stack management -# Usage: safe_start_spinner -safe_start_spinner() { - local message="${1:-Working...}" - - # Push current state - push_spinner_state - - # Stop any existing spinner - stop_section_spinner 2> /dev/null || true - - # Start new spinner - start_section_spinner "$message" -} - -# Safe spinner stop with stack management -# Usage: safe_stop_spinner -safe_stop_spinner() { - # Stop current spinner - stop_section_spinner 2> /dev/null || true - - # Pop previous state - pop_spinner_state || true -} - -# ============================================================================ -# Terminal Compatibility Checks -# ============================================================================ - -# Check if terminal supports ANSI escape codes -# Usage: is_ansi_supported -# Returns: 0 if supported, 1 if not -is_ansi_supported() { - # Check if running in interactive terminal - [[ -t 1 ]] || return 1 - - # Check TERM variable - [[ -n "${TERM:-}" ]] || return 1 - - # Check for known ANSI-compatible terminals - case "$TERM" in - xterm* | vt100 | vt220 | screen* | tmux* | ansi | linux | rxvt* | konsole*) - return 0 - ;; - dumb | unknown) - return 1 - ;; - *) - # Check terminfo database if available - if command -v tput > /dev/null 2>&1; then - # Test if terminal supports colors (good proxy for ANSI support) - local colors=$(tput colors 2> /dev/null || echo "0") - [[ "$colors" -ge 8 ]] && return 0 - fi - return 1 - ;; - esac -} - -# Get terminal capability info -# Usage: get_terminal_info -get_terminal_info() { - local info="Terminal: ${TERM:-unknown}" - - if is_ansi_supported; then - info+=" (ANSI supported)" - - if command -v tput > /dev/null 2>&1; then - local cols=$(tput cols 2> /dev/null || echo "?") - local lines=$(tput lines 2> /dev/null || echo "?") - local colors=$(tput colors 2> /dev/null || echo "?") - info+=" ${cols}x${lines}, ${colors} colors" - fi - else - info+=" (ANSI not supported)" - fi - - echo "$info" -} - -# Validate terminal environment before running -# Usage: validate_terminal_environment -# Returns: 0 if OK, 1 with warning if issues detected -validate_terminal_environment() { - local warnings=0 - - # Check if TERM is set - if [[ -z "${TERM:-}" ]]; then - log_warning "TERM environment variable not set" - ((warnings++)) - fi - - # Check if running in a known problematic terminal - case "${TERM:-}" in - dumb) - log_warning "Running in 'dumb' terminal - limited functionality" - ((warnings++)) - ;; - unknown) - log_warning "Terminal type unknown - may have display issues" - ((warnings++)) - ;; - esac - - # Check terminal size if available - if command -v tput > /dev/null 2>&1; then - local cols=$(tput cols 2> /dev/null || echo "80") - if [[ "$cols" -lt 60 ]]; then - log_warning "Terminal width ($cols cols) is narrow - output may wrap" - ((warnings++)) - fi - fi - - # Report compatibility - if [[ $warnings -eq 0 ]]; then - debug_log "Terminal environment validated: $(get_terminal_info)" - return 0 - else - debug_log "Terminal compatibility warnings: $warnings" - return 1 - fi -} diff --git a/lib/core/commands.sh b/lib/core/commands.sh deleted file mode 100644 index 3d2559e..0000000 --- a/lib/core/commands.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/bash - -# Shared command list for help text and completions. -MOLE_COMMANDS=( - "clean:Free up disk space" - "uninstall:Remove apps completely" - "optimize:Check and maintain system" - "analyze:Explore disk usage" - "status:Monitor system health" - "purge:Remove old project artifacts" - "installer:Find and remove installer files" - "touchid:Configure Touch ID for sudo" - "completion:Setup shell tab completion" - "update:Update to latest version" - "remove:Remove Mole from system" - "help:Show help" - "version:Show version" -) diff --git a/windows/lib/core/common.ps1 b/lib/core/common.ps1 similarity index 100% rename from windows/lib/core/common.ps1 rename to lib/core/common.ps1 diff --git a/lib/core/common.sh b/lib/core/common.sh deleted file mode 100755 index 5437f17..0000000 --- a/lib/core/common.sh +++ /dev/null @@ -1,188 +0,0 @@ -#!/bin/bash -# Mole - Common Functions Library -# Main entry point that loads all core modules - -set -euo pipefail - -# Prevent multiple sourcing -if [[ -n "${MOLE_COMMON_LOADED:-}" ]]; then - return 0 -fi -readonly MOLE_COMMON_LOADED=1 - -_MOLE_CORE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -# Load core modules -source "$_MOLE_CORE_DIR/base.sh" -source "$_MOLE_CORE_DIR/log.sh" - -source "$_MOLE_CORE_DIR/timeout.sh" -source "$_MOLE_CORE_DIR/file_ops.sh" -source "$_MOLE_CORE_DIR/ui.sh" -source "$_MOLE_CORE_DIR/app_protection.sh" - -# Load sudo management if available -if [[ -f "$_MOLE_CORE_DIR/sudo.sh" ]]; then - source "$_MOLE_CORE_DIR/sudo.sh" -fi - -# Update via Homebrew -update_via_homebrew() { - local current_version="$1" - local temp_update temp_upgrade - temp_update=$(mktemp_file "brew_update") - temp_upgrade=$(mktemp_file "brew_upgrade") - - # Set up trap for interruption (Ctrl+C) with inline cleanup - trap 'stop_inline_spinner 2>/dev/null; rm -f "$temp_update" "$temp_upgrade" 2>/dev/null; echo ""; exit 130' INT TERM - - # Update Homebrew - if [[ -t 1 ]]; then - start_inline_spinner "Updating Homebrew..." - else - echo "Updating Homebrew..." - fi - - brew update > "$temp_update" 2>&1 & - local update_pid=$! - wait $update_pid 2> /dev/null || true # Continue even if brew update fails - - if [[ -t 1 ]]; then - stop_inline_spinner - fi - - # Upgrade Mole - if [[ -t 1 ]]; then - start_inline_spinner "Upgrading Mole..." - else - echo "Upgrading Mole..." - fi - - brew upgrade mole > "$temp_upgrade" 2>&1 & - local upgrade_pid=$! - wait $upgrade_pid 2> /dev/null || true # Continue even if brew upgrade fails - - local upgrade_output - upgrade_output=$(cat "$temp_upgrade") - - if [[ -t 1 ]]; then - stop_inline_spinner - fi - - # Clear trap - trap - INT TERM - - # Cleanup temp files - rm -f "$temp_update" "$temp_upgrade" - - if echo "$upgrade_output" | grep -q "already installed"; then - local installed_version - installed_version=$(brew list --versions mole 2> /dev/null | awk '{print $2}') - echo "" - 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" - echo "$upgrade_output" | grep "Error:" >&2 - return 1 - else - echo "$upgrade_output" | grep -Ev "^(==>|Updating Homebrew|Warning:)" || true - local new_version - new_version=$(brew list --versions mole 2> /dev/null | awk '{print $2}') - echo "" - echo -e "${GREEN}${ICON_SUCCESS}${NC} Updated to latest version (${new_version:-$current_version})" - echo "" - fi - - # Clear update cache (suppress errors if cache doesn't exist or is locked) - rm -f "$HOME/.cache/mole/version_check" "$HOME/.cache/mole/update_message" 2> /dev/null || true -} - -# Remove applications from Dock -remove_apps_from_dock() { - if [[ $# -eq 0 ]]; then - return 0 - fi - - local plist="$HOME/Library/Preferences/com.apple.dock.plist" - [[ -f "$plist" ]] || return 0 - - if ! command -v python3 > /dev/null 2>&1; then - return 0 - fi - - # Prune dock entries using Python helper - python3 - "$@" << 'PY' 2> /dev/null || return 0 -import os -import plistlib -import subprocess -import sys -import urllib.parse - -plist_path = os.path.expanduser('~/Library/Preferences/com.apple.dock.plist') -if not os.path.exists(plist_path): - sys.exit(0) - -def normalise(path): - if not path: - return '' - return os.path.normpath(os.path.realpath(path.rstrip('/'))) - -targets = {normalise(arg) for arg in sys.argv[1:] if arg} -targets = {t for t in targets if t} -if not targets: - sys.exit(0) - -with open(plist_path, 'rb') as fh: - try: - data = plistlib.load(fh) - except Exception: - sys.exit(0) - -apps = data.get('persistent-apps') -if not isinstance(apps, list): - sys.exit(0) - -changed = False -filtered = [] -for item in apps: - try: - url = item['tile-data']['file-data']['_CFURLString'] - except (KeyError, TypeError): - filtered.append(item) - continue - - if not isinstance(url, str): - filtered.append(item) - continue - - parsed = urllib.parse.urlparse(url) - path = urllib.parse.unquote(parsed.path or '') - if not path: - filtered.append(item) - continue - - candidate = normalise(path) - if any(candidate == t or candidate.startswith(t + os.sep) for t in targets): - changed = True - continue - - filtered.append(item) - -if not changed: - sys.exit(0) - -data['persistent-apps'] = filtered -with open(plist_path, 'wb') as fh: - try: - plistlib.dump(data, fh, fmt=plistlib.FMT_BINARY) - except Exception: - plistlib.dump(data, fh) - -# Restart Dock to apply changes -try: - subprocess.run(['killall', 'Dock'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False) -except Exception: - pass -PY -} diff --git a/windows/lib/core/file_ops.ps1 b/lib/core/file_ops.ps1 similarity index 100% rename from windows/lib/core/file_ops.ps1 rename to lib/core/file_ops.ps1 diff --git a/lib/core/file_ops.sh b/lib/core/file_ops.sh deleted file mode 100644 index 4fb03a7..0000000 --- a/lib/core/file_ops.sh +++ /dev/null @@ -1,351 +0,0 @@ -#!/bin/bash -# Mole - File Operations -# Safe file and directory manipulation with validation - -set -euo pipefail - -# Prevent multiple sourcing -if [[ -n "${MOLE_FILE_OPS_LOADED:-}" ]]; then - return 0 -fi -readonly MOLE_FILE_OPS_LOADED=1 - -# Ensure dependencies are loaded -_MOLE_CORE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -if [[ -z "${MOLE_BASE_LOADED:-}" ]]; then - # shellcheck source=lib/core/base.sh - source "$_MOLE_CORE_DIR/base.sh" -fi -if [[ -z "${MOLE_LOG_LOADED:-}" ]]; then - # shellcheck source=lib/core/log.sh - source "$_MOLE_CORE_DIR/log.sh" -fi -if [[ -z "${MOLE_TIMEOUT_LOADED:-}" ]]; then - # shellcheck source=lib/core/timeout.sh - source "$_MOLE_CORE_DIR/timeout.sh" -fi - -# ============================================================================ -# Path Validation -# ============================================================================ - -# Validate path for deletion (absolute, no traversal, not system dir) -validate_path_for_deletion() { - local path="$1" - - # Check path is not empty - if [[ -z "$path" ]]; then - log_error "Path validation failed: empty path" - return 1 - fi - - # Check path is absolute - if [[ "$path" != /* ]]; then - log_error "Path validation failed: path must be absolute: $path" - return 1 - fi - - # Check for path traversal attempts - # Only reject .. when it appears as a complete path component (/../ or /.. or ../) - # This allows legitimate directory names containing .. (e.g., Firefox's "name..files") - if [[ "$path" =~ (^|/)\.\.(\/|$) ]]; then - log_error "Path validation failed: path traversal not allowed: $path" - return 1 - fi - - # Check path doesn't contain dangerous characters - if [[ "$path" =~ [[:cntrl:]] ]] || [[ "$path" =~ $'\n' ]]; then - log_error "Path validation failed: contains control characters: $path" - return 1 - fi - - # Allow deletion of coresymbolicationd cache (safe system cache that can be rebuilt) - case "$path" in - /System/Library/Caches/com.apple.coresymbolicationd/data | /System/Library/Caches/com.apple.coresymbolicationd/data/*) - return 0 - ;; - esac - - # Check path isn't critical system directory - case "$path" in - / | /bin | /sbin | /usr | /usr/bin | /usr/sbin | /etc | /var | /System | /System/* | /Library/Extensions) - log_error "Path validation failed: critical system directory: $path" - return 1 - ;; - esac - - return 0 -} - -# ============================================================================ -# Safe Removal Operations -# ============================================================================ - -# Safe wrapper around rm -rf with validation -safe_remove() { - local path="$1" - local silent="${2:-false}" - - # Validate path - if ! validate_path_for_deletion "$path"; then - return 1 - fi - - # Check if path exists - if [[ ! -e "$path" ]]; then - return 0 - fi - - # Dry-run mode: log but don't delete - if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then - if [[ "${MO_DEBUG:-}" == "1" ]]; then - local file_type="file" - [[ -d "$path" ]] && file_type="directory" - [[ -L "$path" ]] && file_type="symlink" - - local file_size="" - local file_age="" - - if [[ -e "$path" ]]; then - local size_kb - size_kb=$(get_path_size_kb "$path" 2> /dev/null || echo "0") - if [[ "$size_kb" -gt 0 ]]; then - file_size=$(bytes_to_human "$((size_kb * 1024))") - fi - - if [[ -f "$path" || -d "$path" ]] && ! [[ -L "$path" ]]; then - local mod_time - mod_time=$(stat -f%m "$path" 2> /dev/null || echo "0") - local now - now=$(date +%s 2> /dev/null || echo "0") - if [[ "$mod_time" -gt 0 && "$now" -gt 0 ]]; then - file_age=$(((now - mod_time) / 86400)) - fi - fi - fi - - debug_file_action "[DRY RUN] Would remove" "$path" "$file_size" "$file_age" - else - debug_log "[DRY RUN] Would remove: $path" - fi - return 0 - fi - - debug_log "Removing: $path" - - # Perform the deletion - # Use || to capture the exit code so set -e won't abort on rm failures - local error_msg - local rm_exit=0 - error_msg=$(rm -rf "$path" 2>&1) || rm_exit=$? # safe_remove - - if [[ $rm_exit -eq 0 ]]; then - return 0 - else - # Check if it's a permission error - if [[ "$error_msg" == *"Permission denied"* ]] || [[ "$error_msg" == *"Operation not permitted"* ]]; then - 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)" - else - [[ "$silent" != "true" ]] && log_error "Failed to remove: $path" - fi - return 1 - fi -} - -# Safe sudo removal with symlink protection -safe_sudo_remove() { - local path="$1" - - # Validate path - if ! validate_path_for_deletion "$path"; then - log_error "Path validation failed for sudo remove: $path" - return 1 - fi - - # Check if path exists - if [[ ! -e "$path" ]]; then - return 0 - fi - - # Additional check: reject symlinks for sudo operations - if [[ -L "$path" ]]; then - log_error "Refusing to sudo remove symlink: $path" - return 1 - fi - - # Dry-run mode: log but don't delete - if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then - if [[ "${MO_DEBUG:-}" == "1" ]]; then - local file_type="file" - [[ -d "$path" ]] && file_type="directory" - - local file_size="" - local file_age="" - - if sudo test -e "$path" 2> /dev/null; then - local size_kb - size_kb=$(sudo du -sk "$path" 2> /dev/null | awk '{print $1}' || echo "0") - if [[ "$size_kb" -gt 0 ]]; then - file_size=$(bytes_to_human "$((size_kb * 1024))") - fi - - if sudo test -f "$path" 2> /dev/null || sudo test -d "$path" 2> /dev/null; then - local mod_time - mod_time=$(sudo stat -f%m "$path" 2> /dev/null || echo "0") - local now - now=$(date +%s 2> /dev/null || echo "0") - if [[ "$mod_time" -gt 0 && "$now" -gt 0 ]]; then - file_age=$(((now - mod_time) / 86400)) - fi - fi - fi - - debug_file_action "[DRY RUN] Would remove (sudo)" "$path" "$file_size" "$file_age" - else - debug_log "[DRY RUN] Would remove (sudo): $path" - fi - return 0 - fi - - debug_log "Removing (sudo): $path" - - # Perform the deletion - if sudo rm -rf "$path" 2> /dev/null; then # SAFE: safe_sudo_remove implementation - return 0 - else - log_error "Failed to remove (sudo): $path" - return 1 - fi -} - -# ============================================================================ -# Safe Find and Delete Operations -# ============================================================================ - -# Safe file discovery and deletion with depth and age limits -safe_find_delete() { - local base_dir="$1" - local pattern="$2" - local age_days="${3:-7}" - local type_filter="${4:-f}" - - # Validate base directory exists and is not a symlink - if [[ ! -d "$base_dir" ]]; then - log_error "Directory does not exist: $base_dir" - return 1 - fi - - if [[ -L "$base_dir" ]]; then - log_error "Refusing to search symlinked directory: $base_dir" - return 1 - fi - - # Validate type filter - if [[ "$type_filter" != "f" && "$type_filter" != "d" ]]; then - 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)" - - local find_args=("-maxdepth" "5" "-name" "$pattern" "-type" "$type_filter") - if [[ "$age_days" -gt 0 ]]; then - find_args+=("-mtime" "+$age_days") - fi - - # Iterate results to respect should_protect_path - while IFS= read -r -d '' match; do - if should_protect_path "$match"; then - continue - fi - safe_remove "$match" true || true - done < <(command find "$base_dir" "${find_args[@]}" -print0 2> /dev/null || true) - - return 0 -} - -# Safe sudo discovery and deletion -safe_sudo_find_delete() { - local base_dir="$1" - local pattern="$2" - local age_days="${3:-7}" - local type_filter="${4:-f}" - - # 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" - return 0 - fi - - if sudo test -L "$base_dir" 2> /dev/null; then - log_error "Refusing to search symlinked directory: $base_dir" - return 1 - fi - - # Validate type filter - if [[ "$type_filter" != "f" && "$type_filter" != "d" ]]; then - 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)" - - local find_args=("-maxdepth" "5" "-name" "$pattern" "-type" "$type_filter") - if [[ "$age_days" -gt 0 ]]; then - find_args+=("-mtime" "+$age_days") - fi - - # Iterate results to respect should_protect_path - while IFS= read -r -d '' match; do - if should_protect_path "$match"; then - continue - fi - safe_sudo_remove "$match" || true - done < <(sudo find "$base_dir" "${find_args[@]}" -print0 2> /dev/null || true) - - return 0 -} - -# ============================================================================ -# Size Calculation -# ============================================================================ - -# Get path size in KB (returns 0 if not found) -get_path_size_kb() { - local path="$1" - [[ -z "$path" || ! -e "$path" ]] && { - echo "0" - return - } - # Direct execution without timeout overhead - critical for performance in loops - # Use || echo 0 to ensure failure in du (e.g. permission error) doesn't exit script under set -e - # Pipefail would normally cause the pipeline to fail if du fails, but || handle catches it. - local size - size=$(command du -sk "$path" 2> /dev/null | awk 'NR==1 {print $1; exit}' || true) - - # Ensure size is a valid number (fix for non-numeric du output) - if [[ "$size" =~ ^[0-9]+$ ]]; then - echo "$size" - else - echo "0" - fi -} - -# Calculate total size for multiple paths -calculate_total_size() { - local files="$1" - local total_kb=0 - - while IFS= read -r file; do - if [[ -n "$file" && -e "$file" ]]; then - local size_kb - size_kb=$(get_path_size_kb "$file") - ((total_kb += size_kb)) - fi - done <<< "$files" - - echo "$total_kb" -} diff --git a/windows/lib/core/log.ps1 b/lib/core/log.ps1 similarity index 100% rename from windows/lib/core/log.ps1 rename to lib/core/log.ps1 diff --git a/lib/core/log.sh b/lib/core/log.sh deleted file mode 100644 index d9dca13..0000000 --- a/lib/core/log.sh +++ /dev/null @@ -1,291 +0,0 @@ -#!/bin/bash -# Mole - Logging System -# Centralized logging with rotation support - -set -euo pipefail - -# Prevent multiple sourcing -if [[ -n "${MOLE_LOG_LOADED:-}" ]]; then - return 0 -fi -readonly MOLE_LOG_LOADED=1 - -# Ensure base.sh is loaded for colors and icons -if [[ -z "${MOLE_BASE_LOADED:-}" ]]; then - _MOLE_CORE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - # shellcheck source=lib/core/base.sh - source "$_MOLE_CORE_DIR/base.sh" -fi - -# ============================================================================ -# Logging Configuration -# ============================================================================ - -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 - -# Ensure log directory and file exist with correct ownership -ensure_user_file "$LOG_FILE" - -# ============================================================================ -# Log Rotation -# ============================================================================ - -# Rotate log file if it exceeds maximum size -rotate_log_once() { - # Skip if already checked this session - [[ -n "${MOLE_LOG_ROTATED:-}" ]] && return 0 - export MOLE_LOG_ROTATED=1 - - local max_size="$LOG_MAX_SIZE_DEFAULT" - if [[ -f "$LOG_FILE" ]] && [[ $(get_file_size "$LOG_FILE") -gt "$max_size" ]]; then - mv "$LOG_FILE" "${LOG_FILE}.old" 2> /dev/null || true - ensure_user_file "$LOG_FILE" - fi -} - -# ============================================================================ -# Logging Functions -# ============================================================================ - -# Log informational message -log_info() { - echo -e "${BLUE}$1${NC}" - local timestamp=$(date '+%Y-%m-%d %H:%M:%S') - echo "[$timestamp] INFO: $1" >> "$LOG_FILE" 2> /dev/null || true - if [[ "${MO_DEBUG:-}" == "1" ]]; then - echo "[$timestamp] INFO: $1" >> "$DEBUG_LOG_FILE" 2> /dev/null || true - fi -} - -# Log success message -log_success() { - echo -e " ${GREEN}${ICON_SUCCESS}${NC} $1" - local timestamp=$(date '+%Y-%m-%d %H:%M:%S') - echo "[$timestamp] SUCCESS: $1" >> "$LOG_FILE" 2> /dev/null || true - if [[ "${MO_DEBUG:-}" == "1" ]]; then - echo "[$timestamp] SUCCESS: $1" >> "$DEBUG_LOG_FILE" 2> /dev/null || true - fi -} - -# Log warning message -log_warning() { - echo -e "${YELLOW}$1${NC}" - local timestamp=$(date '+%Y-%m-%d %H:%M:%S') - echo "[$timestamp] WARNING: $1" >> "$LOG_FILE" 2> /dev/null || true - if [[ "${MO_DEBUG:-}" == "1" ]]; then - echo "[$timestamp] WARNING: $1" >> "$DEBUG_LOG_FILE" 2> /dev/null || true - fi -} - -# Log error message -log_error() { - echo -e "${YELLOW}${ICON_ERROR}${NC} $1" >&2 - local timestamp=$(date '+%Y-%m-%d %H:%M:%S') - echo "[$timestamp] ERROR: $1" >> "$LOG_FILE" 2> /dev/null || true - if [[ "${MO_DEBUG:-}" == "1" ]]; then - echo "[$timestamp] ERROR: $1" >> "$DEBUG_LOG_FILE" 2> /dev/null || true - fi -} - -# Debug logging (active when MO_DEBUG=1) -debug_log() { - if [[ "${MO_DEBUG:-}" == "1" ]]; then - echo -e "${GRAY}[DEBUG]${NC} $*" >&2 - echo "[$(date '+%Y-%m-%d %H:%M:%S')] DEBUG: $*" >> "$DEBUG_LOG_FILE" 2> /dev/null || true - fi -} - -# Enhanced debug logging for operations -debug_operation_start() { - local operation_name="$1" - local operation_desc="${2:-}" - - if [[ "${MO_DEBUG:-}" == "1" ]]; then - # Output to stderr for immediate feedback - echo -e "${GRAY}[DEBUG] === $operation_name ===${NC}" >&2 - [[ -n "$operation_desc" ]] && echo -e "${GRAY}[DEBUG] $operation_desc${NC}" >&2 - - # Also log to file - { - echo "" - echo "=== $operation_name ===" - [[ -n "$operation_desc" ]] && echo "Description: $operation_desc" - } >> "$DEBUG_LOG_FILE" 2> /dev/null || true - fi -} - -# Log detailed operation information -debug_operation_detail() { - local detail_type="$1" # e.g., "Method", "Target", "Expected Outcome" - local detail_value="$2" - - if [[ "${MO_DEBUG:-}" == "1" ]]; then - # Output to stderr - echo -e "${GRAY}[DEBUG] $detail_type: $detail_value${NC}" >&2 - - # Also log to file - echo "$detail_type: $detail_value" >> "$DEBUG_LOG_FILE" 2> /dev/null || true - fi -} - -# Log individual file action with metadata -debug_file_action() { - local action="$1" # e.g., "Would remove", "Removing" - local file_path="$2" - local file_size="${3:-}" - local file_age="${4:-}" - - if [[ "${MO_DEBUG:-}" == "1" ]]; then - 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 - - # Also log to file - echo "$action: $msg" >> "$DEBUG_LOG_FILE" 2> /dev/null || true - fi -} - -# Log risk level for operations -debug_risk_level() { - local risk_level="$1" # LOW, MEDIUM, HIGH - local reason="$2" - - if [[ "${MO_DEBUG:-}" == "1" ]]; then - local color="$GRAY" - case "$risk_level" in - LOW) color="$GREEN" ;; - MEDIUM) color="$YELLOW" ;; - HIGH) color="$RED" ;; - esac - - # Output to stderr with color - 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 - fi -} - -# Log system information for debugging -log_system_info() { - # Only allow once per session - [[ -n "${MOLE_SYS_INFO_LOGGED:-}" ]] && return 0 - export MOLE_SYS_INFO_LOGGED=1 - - # Reset debug log file for this new session - ensure_user_file "$DEBUG_LOG_FILE" - : > "$DEBUG_LOG_FILE" - - # Start block in debug log file - { - echo "----------------------------------------------------------------------" - 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))" - fi - echo "Shell: ${SHELL:-unknown} (${TERM:-unknown})" - - # Check sudo status non-interactively - if sudo -n true 2> /dev/null; then - echo "Sudo Access: Active" - else - echo "Sudo Access: Required" - fi - echo "----------------------------------------------------------------------" - } >> "$DEBUG_LOG_FILE" 2> /dev/null || true - - # Notification to stderr - echo -e "${GRAY}[DEBUG] Debug logging enabled. Session log: $DEBUG_LOG_FILE${NC}" >&2 -} - -# ============================================================================ -# Command Execution Wrappers -# ============================================================================ - -# Run command silently (ignore errors) -run_silent() { - "$@" > /dev/null 2>&1 || true -} - -# Run command with error logging -run_logged() { - local cmd="$1" - # Log to main file, and also to debug file if enabled - if [[ "${MO_DEBUG:-}" == "1" ]]; then - if ! "$@" 2>&1 | tee -a "$LOG_FILE" | tee -a "$DEBUG_LOG_FILE" > /dev/null; then - log_warning "Command failed: $cmd" - return 1 - fi - else - if ! "$@" 2>&1 | tee -a "$LOG_FILE" > /dev/null; then - log_warning "Command failed: $cmd" - return 1 - fi - fi - return 0 -} - -# ============================================================================ -# Formatted Output -# ============================================================================ - -# Print formatted summary block -print_summary_block() { - local heading="" - local -a details=() - local saw_heading=false - - # Parse arguments - for arg in "$@"; do - if [[ "$saw_heading" == "false" ]]; then - saw_heading=true - heading="$arg" - else - details+=("$arg") - fi - done - - local divider="======================================================================" - - # Print with dividers - echo "" - echo "$divider" - if [[ -n "$heading" ]]; then - echo -e "${BLUE}${heading}${NC}" - fi - - # Print details - for detail in "${details[@]}"; do - [[ -z "$detail" ]] && continue - echo -e "${detail}" - done - echo "$divider" - - # If debug mode is on, remind user about the log file location - if [[ "${MO_DEBUG:-}" == "1" ]]; then - echo -e "${GRAY}Debug session log saved to:${NC} ${DEBUG_LOG_FILE}" - fi -} - -# ============================================================================ -# Initialize Logging -# ============================================================================ - -# Perform log rotation check on module load -rotate_log_once - -# If debug mode is enabled, log system info immediately -if [[ "${MO_DEBUG:-}" == "1" ]]; then - log_system_info -fi diff --git a/lib/core/sudo.sh b/lib/core/sudo.sh deleted file mode 100644 index 57b80b4..0000000 --- a/lib/core/sudo.sh +++ /dev/null @@ -1,319 +0,0 @@ -#!/bin/bash -# Sudo Session Manager -# Unified sudo authentication and keepalive management - -set -euo pipefail - -# ============================================================================ -# Touch ID and Clamshell Detection -# ============================================================================ - -check_touchid_support() { - # Check sudo_local first (Sonoma+) - if [[ -f /etc/pam.d/sudo_local ]]; then - grep -q "pam_tid.so" /etc/pam.d/sudo_local 2> /dev/null - return $? - fi - - # Fallback to checking sudo directly - if [[ -f /etc/pam.d/sudo ]]; then - grep -q "pam_tid.so" /etc/pam.d/sudo 2> /dev/null - return $? - fi - return 1 -} - -# Detect clamshell mode (lid closed) -is_clamshell_mode() { - # ioreg is missing (not macOS) -> treat as lid open - if ! command -v ioreg > /dev/null 2>&1; then - return 1 - fi - - # Check if lid is closed; ignore pipeline failures so set -e doesn't exit - local clamshell_state="" - clamshell_state=$( (ioreg -r -k AppleClamshellState -d 4 2> /dev/null | - grep "AppleClamshellState" | - head -1) || true) - - if [[ "$clamshell_state" =~ \"AppleClamshellState\"\ =\ Yes ]]; then - return 0 # Lid is closed - fi - return 1 # Lid is open -} - -_request_password() { - local tty_path="$1" - local attempts=0 - local show_hint=true - - # Extra safety: ensure sudo cache is cleared before password input - sudo -k 2> /dev/null - - # Save original terminal settings and ensure they're restored on exit - local stty_orig - stty_orig=$(stty -g < "$tty_path" 2> /dev/null || echo "") - trap '[[ -n "${stty_orig:-}" ]] && stty "${stty_orig:-}" < "$tty_path" 2> /dev/null || true' RETURN - - while ((attempts < 3)); do - local password="" - - # Show hint on first attempt about Touch ID appearing again - if [[ $show_hint == true ]] && check_touchid_support; then - echo -e "${GRAY}Note: Touch ID dialog may appear once more - just cancel it${NC}" > "$tty_path" - show_hint=false - fi - - printf "${PURPLE}${ICON_ARROW}${NC} Password: " > "$tty_path" - - # Disable terminal echo to hide password input - stty -echo -icanon min 1 time 0 < "$tty_path" 2> /dev/null || true - IFS= read -r password < "$tty_path" || password="" - # Restore terminal echo immediately - stty echo icanon < "$tty_path" 2> /dev/null || true - - printf "\n" > "$tty_path" - - if [[ -z "$password" ]]; then - unset password - ((attempts++)) - if [[ $attempts -lt 3 ]]; then - echo -e "${YELLOW}${ICON_WARNING}${NC} Password cannot be empty" > "$tty_path" - fi - continue - fi - - # Verify password with sudo - # NOTE: macOS PAM will trigger Touch ID before password auth - this is system behavior - if printf '%s\n' "$password" | sudo -S -p "" -v > /dev/null 2>&1; then - unset password - return 0 - fi - - unset password - ((attempts++)) - if [[ $attempts -lt 3 ]]; then - echo -e "${YELLOW}${ICON_WARNING}${NC} Incorrect password, try again" > "$tty_path" - fi - done - - return 1 -} - -request_sudo_access() { - local prompt_msg="${1:-Admin access required}" - - # Check if already have sudo access - if sudo -n true 2> /dev/null; then - return 0 - fi - - # Get TTY path - local tty_path="/dev/tty" - if [[ ! -r "$tty_path" || ! -w "$tty_path" ]]; then - tty_path=$(tty 2> /dev/null || echo "") - if [[ -z "$tty_path" || ! -r "$tty_path" || ! -w "$tty_path" ]]; then - log_error "No interactive terminal available" - return 1 - fi - fi - - sudo -k - - # Check if in clamshell mode - if yes, skip Touch ID entirely - if is_clamshell_mode; then - echo -e "${PURPLE}${ICON_ARROW}${NC} ${prompt_msg}" - if _request_password "$tty_path"; then - # Clear all prompt lines (use safe clearing method) - safe_clear_lines 3 "$tty_path" - return 0 - fi - return 1 - fi - - # Not in clamshell mode - try Touch ID if configured - if ! check_touchid_support; then - echo -e "${PURPLE}${ICON_ARROW}${NC} ${prompt_msg}" - if _request_password "$tty_path"; then - # Clear all prompt lines (use safe clearing method) - safe_clear_lines 3 "$tty_path" - return 0 - fi - return 1 - fi - - # Touch ID is available and not in clamshell mode - 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 & - local sudo_pid=$! - - # Wait for sudo to complete or timeout (5 seconds) - local elapsed=0 - local timeout=50 # 50 * 0.1s = 5 seconds - while ((elapsed < timeout)); do - if ! kill -0 "$sudo_pid" 2> /dev/null; then - # Process exited - wait "$sudo_pid" 2> /dev/null - local exit_code=$? - if [[ $exit_code -eq 0 ]] && sudo -n true 2> /dev/null; then - # Touch ID succeeded - clear the prompt line - safe_clear_lines 1 "$tty_path" - return 0 - fi - # Touch ID failed or cancelled - break - fi - sleep 0.1 - ((elapsed++)) - done - - # Touch ID failed/cancelled - clean up thoroughly before password input - - # Kill the sudo process if still running - if kill -0 "$sudo_pid" 2> /dev/null; then - kill -9 "$sudo_pid" 2> /dev/null - wait "$sudo_pid" 2> /dev/null || true - fi - - # Clear sudo state immediately - sudo -k 2> /dev/null - - # IMPORTANT: Wait longer for macOS to fully close Touch ID UI and SecurityAgent - # Without this delay, subsequent sudo calls may re-trigger Touch ID - sleep 1 - - # Clear any leftover prompts on the screen - safe_clear_line "$tty_path" - - # Now use our password input (this should not trigger Touch ID again) - if _request_password "$tty_path"; then - # Clear all prompt lines (use safe clearing method) - safe_clear_lines 3 "$tty_path" - return 0 - fi - return 1 -} - -# ============================================================================ -# Sudo Session Management -# ============================================================================ - -# Global state -MOLE_SUDO_KEEPALIVE_PID="" -MOLE_SUDO_ESTABLISHED="false" - -# Start sudo keepalive -_start_sudo_keepalive() { - # Start background keepalive process with all outputs redirected - # This is critical: command substitution waits for all file descriptors to close - ( - # Initial delay to let sudo cache stabilize after password entry - # This prevents immediately triggering Touch ID again - sleep 2 - - local retry_count=0 - while true; do - if ! sudo -n -v 2> /dev/null; then - ((retry_count++)) - if [[ $retry_count -ge 3 ]]; then - exit 1 - fi - sleep 5 - continue - fi - retry_count=0 - sleep 30 - kill -0 "$$" 2> /dev/null || exit - done - ) > /dev/null 2>&1 & - - local pid=$! - echo $pid -} - -# Stop sudo keepalive -_stop_sudo_keepalive() { - local pid="${1:-}" - if [[ -n "$pid" ]]; then - kill "$pid" 2> /dev/null || true - wait "$pid" 2> /dev/null || true - fi -} - -# Check if sudo session is active -has_sudo_session() { - sudo -n true 2> /dev/null -} - -# Request administrative access -request_sudo() { - local prompt_msg="${1:-Admin access required}" - - if has_sudo_session; then - return 0 - fi - - # Use the robust implementation from common.sh - if request_sudo_access "$prompt_msg"; then - return 0 - else - return 1 - fi -} - -# Maintain active sudo session with keepalive -ensure_sudo_session() { - local prompt="${1:-Admin access required}" - - # Check if already established - if has_sudo_session && [[ "$MOLE_SUDO_ESTABLISHED" == "true" ]]; then - return 0 - fi - - # Stop old keepalive if exists - if [[ -n "$MOLE_SUDO_KEEPALIVE_PID" ]]; then - _stop_sudo_keepalive "$MOLE_SUDO_KEEPALIVE_PID" - MOLE_SUDO_KEEPALIVE_PID="" - fi - - # Request sudo access - if ! request_sudo "$prompt"; then - MOLE_SUDO_ESTABLISHED="false" - return 1 - fi - - # Start keepalive - MOLE_SUDO_KEEPALIVE_PID=$(_start_sudo_keepalive) - - MOLE_SUDO_ESTABLISHED="true" - return 0 -} - -# Stop sudo session and cleanup -stop_sudo_session() { - if [[ -n "$MOLE_SUDO_KEEPALIVE_PID" ]]; then - _stop_sudo_keepalive "$MOLE_SUDO_KEEPALIVE_PID" - MOLE_SUDO_KEEPALIVE_PID="" - fi - MOLE_SUDO_ESTABLISHED="false" -} - -# Register cleanup on script exit -register_sudo_cleanup() { - trap stop_sudo_session EXIT INT TERM -} - -# Predict if operation requires administrative access -will_need_sudo() { - local -a operations=("$@") - for op in "${operations[@]}"; do - case "$op" in - system_update | appstore_update | macos_update | firewall | touchid | rosetta | system_fix) - return 0 - ;; - esac - done - return 1 -} diff --git a/lib/core/timeout.sh b/lib/core/timeout.sh deleted file mode 100644 index 5ff9576..0000000 --- a/lib/core/timeout.sh +++ /dev/null @@ -1,156 +0,0 @@ -#!/bin/bash -# Mole - Timeout Control -# Command execution with timeout support - -set -euo pipefail - -# Prevent multiple sourcing -if [[ -n "${MOLE_TIMEOUT_LOADED:-}" ]]; then - return 0 -fi -readonly MOLE_TIMEOUT_LOADED=1 - -# ============================================================================ -# Timeout Command Initialization -# ============================================================================ - -# Initialize timeout command (prefer gtimeout from coreutils, fallback to timeout) -# Sets MO_TIMEOUT_BIN to the available timeout command -# -# Recommendation: Install coreutils for reliable timeout support -# brew install coreutils -# -# The shell-based fallback has known limitations: -# - May not clean up all child processes -# - Has race conditions in edge cases -# - Less reliable than native timeout command -if [[ -z "${MO_TIMEOUT_INITIALIZED:-}" ]]; then - MO_TIMEOUT_BIN="" - for candidate in gtimeout timeout; do - if command -v "$candidate" > /dev/null 2>&1; then - MO_TIMEOUT_BIN="$candidate" - if [[ "${MO_DEBUG:-0}" == "1" ]]; then - echo "[TIMEOUT] Using command: $candidate" >&2 - fi - break - fi - done - - # Log warning if no timeout command available - if [[ -z "$MO_TIMEOUT_BIN" ]] && [[ "${MO_DEBUG:-0}" == "1" ]]; then - echo "[TIMEOUT] No timeout command found, using shell fallback" >&2 - echo "[TIMEOUT] Install coreutils for better reliability: brew install coreutils" >&2 - fi - - export MO_TIMEOUT_INITIALIZED=1 -fi - -# ============================================================================ -# Timeout Execution -# ============================================================================ - -# Run command with timeout -# Uses gtimeout/timeout if available, falls back to shell-based implementation -# -# Args: -# $1 - duration in seconds (0 or invalid = no timeout) -# $@ - command and arguments to execute -# -# Returns: -# Command exit code, or 124 if timed out (matches gtimeout behavior) -# -# Environment: -# MO_DEBUG - Set to 1 to enable debug logging to stderr -# -# Implementation notes: -# - Prefers gtimeout (coreutils) or timeout for reliability -# - Shell fallback uses SIGTERM → SIGKILL escalation -# - Attempts process group cleanup to handle child processes -# - Returns exit code 124 on timeout (standard timeout exit code) -# -# Known limitations of shell-based fallback: -# - Race condition: If command exits during signal delivery, the signal -# may target a reused PID (very rare, requires quick PID reuse) -# - Zombie processes: Brief zombies until wait completes -# - Nested children: SIGKILL may not reach all descendants -# - No process group: Cannot guarantee cleanup of detached children -# -# For mission-critical timeouts, install coreutils. -run_with_timeout() { - local duration="${1:-0}" - shift || true - - # No timeout if duration is invalid or zero - if [[ ! "$duration" =~ ^[0-9]+(\.[0-9]+)?$ ]] || [[ $(echo "$duration <= 0" | bc -l 2> /dev/null) -eq 1 ]]; then - "$@" - return $? - fi - - # Use timeout command if available (preferred path) - if [[ -n "${MO_TIMEOUT_BIN:-}" ]]; then - if [[ "${MO_DEBUG:-0}" == "1" ]]; then - echo "[TIMEOUT] Running with ${duration}s timeout: $*" >&2 - fi - "$MO_TIMEOUT_BIN" "$duration" "$@" - return $? - fi - - # ======================================================================== - # Shell-based fallback implementation - # ======================================================================== - - if [[ "${MO_DEBUG:-0}" == "1" ]]; then - echo "[TIMEOUT] Shell fallback (${duration}s): $*" >&2 - fi - - # Start command in background - "$@" & - local cmd_pid=$! - - # Start timeout killer in background - ( - # Wait for timeout duration - sleep "$duration" - - # Check if process still exists - if kill -0 "$cmd_pid" 2> /dev/null; then - # Try to kill process group first (negative PID), fallback to single process - # Process group kill is best effort - may not work if setsid was used - kill -TERM -"$cmd_pid" 2> /dev/null || kill -TERM "$cmd_pid" 2> /dev/null || true - - # Grace period for clean shutdown - sleep 2 - - # Escalate to SIGKILL if still alive - if kill -0 "$cmd_pid" 2> /dev/null; then - kill -KILL -"$cmd_pid" 2> /dev/null || kill -KILL "$cmd_pid" 2> /dev/null || true - fi - fi - ) & - local killer_pid=$! - - # Wait for command to complete - local exit_code=0 - set +e - wait "$cmd_pid" 2> /dev/null - exit_code=$? - set -e - - # Clean up killer process - if kill -0 "$killer_pid" 2> /dev/null; then - kill "$killer_pid" 2> /dev/null || true - wait "$killer_pid" 2> /dev/null || true - fi - - # Check if command was killed by timeout (exit codes 143=SIGTERM, 137=SIGKILL) - if [[ $exit_code -eq 143 || $exit_code -eq 137 ]]; then - # Command was killed by timeout - if [[ "${MO_DEBUG:-0}" == "1" ]]; then - echo "[TIMEOUT] Command timed out after ${duration}s" >&2 - fi - return 124 - fi - - # Command completed normally (or with its own error) - return "$exit_code" -} diff --git a/windows/lib/core/ui.ps1 b/lib/core/ui.ps1 similarity index 100% rename from windows/lib/core/ui.ps1 rename to lib/core/ui.ps1 diff --git a/lib/core/ui.sh b/lib/core/ui.sh deleted file mode 100755 index fe98143..0000000 --- a/lib/core/ui.sh +++ /dev/null @@ -1,434 +0,0 @@ -#!/bin/bash -# Mole - UI Components -# Terminal UI utilities: cursor control, keyboard input, spinners, menus - -set -euo pipefail - -if [[ -n "${MOLE_UI_LOADED:-}" ]]; then - return 0 -fi -readonly MOLE_UI_LOADED=1 - -_MOLE_CORE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -[[ -z "${MOLE_BASE_LOADED:-}" ]] && source "$_MOLE_CORE_DIR/base.sh" - -# Cursor control -clear_screen() { printf '\033[2J\033[H'; } -hide_cursor() { [[ -t 1 ]] && printf '\033[?25l' >&2 || true; } -show_cursor() { [[ -t 1 ]] && printf '\033[?25h' >&2 || true; } - -# Calculate display width (CJK characters count as 2) -get_display_width() { - local str="$1" - - # Optimized pure bash implementation without forks - local width - - # Save current locale - local old_lc="${LC_ALL:-}" - - # Get Char Count (UTF-8) - # We must export ensuring it applies to the expansion (though just assignment often works in newer bash, export is safer for all subshells/cmds) - export LC_ALL=en_US.UTF-8 - local char_count=${#str} - - # Get Byte Count (C) - export LC_ALL=C - local byte_count=${#str} - - # Restore Locale immediately - if [[ -n "$old_lc" ]]; then - export LC_ALL="$old_lc" - else - unset LC_ALL - fi - - if [[ $byte_count -eq $char_count ]]; then - echo "$char_count" - return - fi - - # CJK Heuristic: - # Most CJK chars are 3 bytes in UTF-8 and width 2. - # ASCII chars are 1 byte and width 1. - # Width ~= CharCount + (ByteCount - CharCount) / 2 - # "中" (1 char, 3 bytes) -> 1 + (2)/2 = 2. - # "A" (1 char, 1 byte) -> 1 + 0 = 1. - # This is an approximation but very fast and sufficient for App names. - # Integer arithmetic in bash automatically handles floor. - local extra_bytes=$((byte_count - char_count)) - local padding=$((extra_bytes / 2)) - width=$((char_count + padding)) - - # Adjust for zero-width joiners and emoji variation selectors (common in filenames/emojis) - # These characters add bytes but no visible width; subtract their count if present. - local zwj=$'\u200d' # zero-width joiner - local vs16=$'\ufe0f' # emoji variation selector - local zero_width=0 - - local without_zwj=${str//$zwj/} - zero_width=$((zero_width + (char_count - ${#without_zwj}))) - - local without_vs=${str//$vs16/} - zero_width=$((zero_width + (char_count - ${#without_vs}))) - - if ((zero_width > 0 && width > zero_width)); then - width=$((width - zero_width)) - fi - - echo "$width" -} - -# Truncate string by display width (handles CJK) -truncate_by_display_width() { - local str="$1" - local max_width="$2" - local current_width - current_width=$(get_display_width "$str") - - if [[ $current_width -le $max_width ]]; then - echo "$str" - return - fi - - # Fallback: Use pure bash character iteration - # Since we need to know the width of *each* character to truncate at the right spot, - # we cannot just use the total width formula on the whole string. - # However, iterating char-by-char and calling the optimized get_display_width function - # is now much faster because it doesn't fork 'wc'. - - # CRITICAL: Switch to UTF-8 for correct character iteration - local old_lc="${LC_ALL:-}" - export LC_ALL=en_US.UTF-8 - - local truncated="" - local width=0 - local i=0 - local char char_width - local strlen=${#str} # Re-calculate in UTF-8 - - # Optimization: If total width <= max_width, return original string (checked above) - - while [[ $i -lt $strlen ]]; do - char="${str:$i:1}" - - # Inlined width calculation for minimal overhead to avoid recursion overhead - # We are already in UTF-8, so ${#char} is char length (1). - # We need byte length for the heuristic. - # But switching locale inside loop is disastrous for perf. - # Logic: If char is ASCII (1 byte), width 1. - # If char is wide (3 bytes), width 2. - # How to detect byte size without switching locale? - # printf %s "$char" | wc -c ? Slow. - # Check against ASCII range? - # Fast ASCII check: if [[ "$char" < $'\x7f' ]]; then ... - - if [[ "$char" =~ [[:ascii:]] ]]; then - char_width=1 - else - # Assume wide for non-ascii in this context (simplified) - # Or use LC_ALL=C inside? No. - # Most non-ASCII in filenames are either CJK (width 2) or heavy symbols. - # Let's assume 2 for simplicity in this fast loop as we know we are usually dealing with CJK. - char_width=2 - fi - - if ((width + char_width + 3 > max_width)); then - break - fi - - truncated+="$char" - ((width += char_width)) - ((i++)) - done - - # Restore locale - if [[ -n "$old_lc" ]]; then - export LC_ALL="$old_lc" - else - unset LC_ALL - fi - - echo "${truncated}..." -} - -# Read single keyboard input -read_key() { - local key rest read_status - IFS= read -r -s -n 1 key - read_status=$? - [[ $read_status -ne 0 ]] && { - echo "QUIT" - return 0 - } - - if [[ "${MOLE_READ_KEY_FORCE_CHAR:-}" == "1" ]]; then - [[ -z "$key" ]] && { - echo "ENTER" - return 0 - } - case "$key" in - $'\n' | $'\r') echo "ENTER" ;; - $'\x7f' | $'\x08') echo "DELETE" ;; - $'\x1b') echo "QUIT" ;; - [[:print:]]) echo "CHAR:$key" ;; - *) echo "OTHER" ;; - esac - return 0 - fi - - [[ -z "$key" ]] && { - echo "ENTER" - return 0 - } - case "$key" in - $'\n' | $'\r') echo "ENTER" ;; - ' ') echo "SPACE" ;; - '/') echo "FILTER" ;; - 'q' | 'Q') echo "QUIT" ;; - 'R') echo "RETRY" ;; - 'm' | 'M') echo "MORE" ;; - 'u' | 'U') echo "UPDATE" ;; - 't' | 'T') echo "TOUCHID" ;; - 'j' | 'J') echo "DOWN" ;; - 'k' | 'K') echo "UP" ;; - 'h' | 'H') echo "LEFT" ;; - 'l' | 'L') echo "RIGHT" ;; - $'\x03') echo "QUIT" ;; - $'\x7f' | $'\x08') echo "DELETE" ;; - $'\x1b') - if IFS= read -r -s -n 1 -t 1 rest 2> /dev/null; then - if [[ "$rest" == "[" ]]; then - if IFS= read -r -s -n 1 -t 1 rest2 2> /dev/null; then - case "$rest2" in - "A") echo "UP" ;; "B") echo "DOWN" ;; - "C") echo "RIGHT" ;; "D") echo "LEFT" ;; - "3") - IFS= read -r -s -n 1 -t 1 rest3 2> /dev/null - [[ "$rest3" == "~" ]] && echo "DELETE" || echo "OTHER" - ;; - *) echo "OTHER" ;; - esac - else echo "QUIT"; fi - elif [[ "$rest" == "O" ]]; then - if IFS= read -r -s -n 1 -t 1 rest2 2> /dev/null; then - case "$rest2" in - "A") echo "UP" ;; "B") echo "DOWN" ;; - "C") echo "RIGHT" ;; "D") echo "LEFT" ;; - *) echo "OTHER" ;; - esac - else echo "OTHER"; fi - else echo "OTHER"; fi - else echo "QUIT"; fi - ;; - [[:print:]]) echo "CHAR:$key" ;; - *) echo "OTHER" ;; - esac -} - -drain_pending_input() { - local drained=0 - while IFS= read -r -s -n 1 -t 0.01 _ 2> /dev/null; do - ((drained++)) - [[ $drained -gt 100 ]] && break - done -} - -# Format menu option display -show_menu_option() { - local number="$1" - local text="$2" - local selected="$3" - - if [[ "$selected" == "true" ]]; then - echo -e "${CYAN}${ICON_ARROW} $number. $text${NC}" - else - echo " $number. $text" - fi -} - -# Background spinner implementation -INLINE_SPINNER_PID="" -INLINE_SPINNER_STOP_FILE="" - -start_inline_spinner() { - stop_inline_spinner 2> /dev/null || true - local message="$1" - - if [[ -t 1 ]]; then - # Create unique stop flag file for this spinner instance - INLINE_SPINNER_STOP_FILE="${TMPDIR:-/tmp}/mole_spinner_$$_$RANDOM.stop" - - ( - local stop_file="$INLINE_SPINNER_STOP_FILE" - local chars - chars="$(mo_spinner_chars)" - [[ -z "$chars" ]] && chars="|/-\\" - local i=0 - - # Cooperative exit: check for stop file instead of relying on signals - while [[ ! -f "$stop_file" ]]; do - local c="${chars:$((i % ${#chars})):1}" - # Output to stderr to avoid interfering with stdout - printf "\r${MOLE_SPINNER_PREFIX:-}${BLUE}%s${NC} %s" "$c" "$message" >&2 || break - ((i++)) - sleep 0.1 - done - - # Clean up stop file before exiting - rm -f "$stop_file" 2> /dev/null || true - exit 0 - ) & - INLINE_SPINNER_PID=$! - disown 2> /dev/null || true - else - echo -n " ${BLUE}|${NC} $message" >&2 || true - fi -} - -stop_inline_spinner() { - if [[ -n "$INLINE_SPINNER_PID" ]]; then - # Cooperative stop: create stop file to signal spinner to exit - if [[ -n "$INLINE_SPINNER_STOP_FILE" ]]; then - touch "$INLINE_SPINNER_STOP_FILE" 2> /dev/null || true - fi - - # Wait briefly for cooperative exit - local wait_count=0 - while kill -0 "$INLINE_SPINNER_PID" 2> /dev/null && [[ $wait_count -lt 5 ]]; do - sleep 0.05 2> /dev/null || true - ((wait_count++)) - done - - # Only use SIGKILL as last resort if process is stuck - if kill -0 "$INLINE_SPINNER_PID" 2> /dev/null; then - kill -KILL "$INLINE_SPINNER_PID" 2> /dev/null || true - fi - - wait "$INLINE_SPINNER_PID" 2> /dev/null || true - - # Cleanup - rm -f "$INLINE_SPINNER_STOP_FILE" 2> /dev/null || true - INLINE_SPINNER_PID="" - INLINE_SPINNER_STOP_FILE="" - - # Clear the line - use \033[2K to clear entire line, not just to end - [[ -t 1 ]] && printf "\r\033[2K" >&2 || true - fi -} - -# Run command with a terminal spinner -with_spinner() { - local msg="$1" - shift || true - local timeout=180 - start_inline_spinner "$msg" - local exit_code=0 - if [[ -n "${MOLE_TIMEOUT_BIN:-}" ]]; then - "$MOLE_TIMEOUT_BIN" "$timeout" "$@" > /dev/null 2>&1 || exit_code=$? - else "$@" > /dev/null 2>&1 || exit_code=$?; fi - stop_inline_spinner "$msg" - return $exit_code -} - -# Get spinner characters -mo_spinner_chars() { - local chars="|/-\\" - [[ -z "$chars" ]] && chars="|/-\\" - printf "%s" "$chars" -} - -# Format relative time for compact display (e.g., 3d ago) -format_last_used_summary() { - local value="$1" - - case "$value" in - "" | "Unknown") - echo "Unknown" - return 0 - ;; - "Never" | "Recent" | "Today" | "Yesterday" | "This year" | "Old") - echo "$value" - return 0 - ;; - esac - - if [[ $value =~ ^([0-9]+)[[:space:]]+days?\ ago$ ]]; then - echo "${BASH_REMATCH[1]}d ago" - return 0 - fi - if [[ $value =~ ^([0-9]+)[[:space:]]+weeks?\ ago$ ]]; then - echo "${BASH_REMATCH[1]}w ago" - return 0 - fi - if [[ $value =~ ^([0-9]+)[[:space:]]+months?\ ago$ ]]; then - echo "${BASH_REMATCH[1]}m ago" - return 0 - fi - if [[ $value =~ ^([0-9]+)[[:space:]]+month\(s\)\ ago$ ]]; then - echo "${BASH_REMATCH[1]}m ago" - return 0 - fi - if [[ $value =~ ^([0-9]+)[[:space:]]+years?\ ago$ ]]; then - echo "${BASH_REMATCH[1]}y ago" - return 0 - fi - echo "$value" -} - -# Check if terminal has Full Disk Access -# Returns 0 if FDA is granted, 1 if denied, 2 if unknown -has_full_disk_access() { - # Cache the result to avoid repeated checks - if [[ -n "${MOLE_HAS_FDA:-}" ]]; then - if [[ "$MOLE_HAS_FDA" == "1" ]]; then - return 0 - elif [[ "$MOLE_HAS_FDA" == "unknown" ]]; then - return 2 - else - return 1 - fi - fi - - # Test access to protected directories that require FDA - # Strategy: Try to access directories that are commonly protected - # If ANY of them are accessible, we likely have FDA - # If ALL fail, we definitely don't have FDA - local -a protected_dirs=( - "$HOME/Library/Safari/LocalStorage" - "$HOME/Library/Mail/V10" - "$HOME/Library/Messages/chat.db" - ) - - local accessible_count=0 - local tested_count=0 - - for test_path in "${protected_dirs[@]}"; do - # Only test when the protected path exists - if [[ -e "$test_path" ]]; then - tested_count=$((tested_count + 1)) - # Try to stat the ACTUAL protected path - this requires FDA - if stat "$test_path" > /dev/null 2>&1; then - accessible_count=$((accessible_count + 1)) - fi - fi - done - - # Three possible outcomes: - # 1. tested_count = 0: Can't determine (test paths don't exist) → unknown - # 2. tested_count > 0 && accessible_count > 0: Has FDA → yes - # 3. tested_count > 0 && accessible_count = 0: No FDA → no - if [[ $tested_count -eq 0 ]]; then - # Can't determine - test paths don't exist, treat as unknown - export MOLE_HAS_FDA="unknown" - return 2 - elif [[ $accessible_count -gt 0 ]]; then - # At least one path is accessible → has FDA - export MOLE_HAS_FDA=1 - return 0 - else - # Tested paths exist but not accessible → no FDA - export MOLE_HAS_FDA=0 - return 1 - fi -} diff --git a/lib/manage/autofix.sh b/lib/manage/autofix.sh deleted file mode 100644 index d603f02..0000000 --- a/lib/manage/autofix.sh +++ /dev/null @@ -1,191 +0,0 @@ -#!/bin/bash -# Auto-fix Manager -# Unified auto-fix suggestions and execution - -set -euo pipefail - -# Show system suggestions with auto-fix markers -show_suggestions() { - local has_suggestions=false - local can_auto_fix=false - local -a auto_fix_items=() - local -a manual_items=() - local skip_security_autofix=false - if [[ "${MOLE_SECURITY_FIXES_SHOWN:-}" == "true" ]]; then - skip_security_autofix=true - fi - - # Security suggestions - if [[ "$skip_security_autofix" == "false" && -n "${FIREWALL_DISABLED:-}" && "${FIREWALL_DISABLED}" == "true" ]]; then - auto_fix_items+=("Enable Firewall for better security") - has_suggestions=true - can_auto_fix=true - fi - - if [[ -n "${FILEVAULT_DISABLED:-}" && "${FILEVAULT_DISABLED}" == "true" ]]; then - manual_items+=("Enable FileVault|System Settings → Privacy & Security → FileVault") - has_suggestions=true - fi - - # Configuration suggestions - if [[ "$skip_security_autofix" == "false" && -n "${TOUCHID_NOT_CONFIGURED:-}" && "${TOUCHID_NOT_CONFIGURED}" == "true" ]]; then - auto_fix_items+=("Enable Touch ID for sudo") - has_suggestions=true - can_auto_fix=true - fi - - if [[ -n "${ROSETTA_NOT_INSTALLED:-}" && "${ROSETTA_NOT_INSTALLED}" == "true" ]]; then - auto_fix_items+=("Install Rosetta 2 for Intel app support") - has_suggestions=true - can_auto_fix=true - fi - - # Health suggestions - if [[ -n "${CACHE_SIZE_GB:-}" ]]; then - local cache_gb="${CACHE_SIZE_GB:-0}" - if (($(echo "$cache_gb > 5" | bc -l 2> /dev/null || echo 0))); then - manual_items+=("Free up ${cache_gb}GB by cleaning caches|Run: mo clean") - has_suggestions=true - fi - fi - - if [[ -n "${BREW_HAS_WARNINGS:-}" && "${BREW_HAS_WARNINGS}" == "true" ]]; then - manual_items+=("Fix Homebrew warnings|Run: brew doctor to see details") - has_suggestions=true - fi - - 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") - has_suggestions=true - fi - fi - - # Display suggestions - echo -e "${BLUE}${ICON_ARROW}${NC} Suggestions" - - if [[ "$has_suggestions" == "false" ]]; then - echo -e " ${GREEN}✓${NC} All looks good" - export HAS_AUTO_FIX_SUGGESTIONS="false" - return - fi - - # 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}" - done - fi - - # Show manual items - if [[ ${#manual_items[@]} -gt 0 ]]; then - for item in "${manual_items[@]}"; do - local title="${item%%|*}" - local hint="${item#*|}" - echo -e " ${YELLOW}${ICON_WARNING}${NC} ${title}" - echo -e " ${GRAY}${hint}${NC}" - done - fi - - # Export for use in auto-fix - export HAS_AUTO_FIX_SUGGESTIONS="$can_auto_fix" -} - -# Ask user if they want to auto-fix -# Returns: 0 if yes, 1 if no -ask_for_auto_fix() { - if [[ "${HAS_AUTO_FIX_SUGGESTIONS:-false}" != "true" ]]; then - return 1 - fi - - echo -ne "${PURPLE}${ICON_ARROW}${NC} Auto-fix issues now? ${GRAY}Enter confirm / Space cancel${NC}: " - - local key - if ! key=$(read_key); then - echo "no" - echo "" - return 1 - fi - - if [[ "$key" == "ENTER" ]]; then - echo "yes" - echo "" - return 0 - else - echo "no" - echo "" - return 1 - fi -} - -# Perform auto-fixes -# Returns: number of fixes applied -perform_auto_fix() { - local fixed_count=0 - local -a fixed_items=() - - # 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 "" - return 0 - fi - fi - - # Fix Firewall - if [[ -n "${FIREWALL_DISABLED:-}" && "${FIREWALL_DISABLED}" == "true" ]]; then - echo -e "${BLUE}Enabling Firewall...${NC}" - if sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setglobalstate on > /dev/null 2>&1; then - echo -e "${GREEN}✓${NC} Firewall enabled" - ((fixed_count++)) - fixed_items+=("Firewall enabled") - else - echo -e "${RED}✗${NC} Failed to enable Firewall" - fi - echo "" - fi - - # Fix Touch ID - if [[ -n "${TOUCHID_NOT_CONFIGURED:-}" && "${TOUCHID_NOT_CONFIGURED}" == "true" ]]; then - echo -e "${BLUE}${ICON_ARROW}${NC} Configuring Touch ID for sudo..." - local pam_file="/etc/pam.d/sudo" - if sudo bash -c "grep -q 'pam_tid.so' '$pam_file' 2>/dev/null || sed -i '' '2i\\ -auth sufficient pam_tid.so -' '$pam_file'" 2> /dev/null; then - echo -e "${GREEN}✓${NC} Touch ID configured" - ((fixed_count++)) - fixed_items+=("Touch ID configured for sudo") - else - echo -e "${RED}✗${NC} Failed to configure Touch ID" - fi - echo "" - fi - - # Install Rosetta 2 - if [[ -n "${ROSETTA_NOT_INSTALLED:-}" && "${ROSETTA_NOT_INSTALLED}" == "true" ]]; then - echo -e "${BLUE}Installing Rosetta 2...${NC}" - if sudo softwareupdate --install-rosetta --agree-to-license 2>&1 | grep -qE "(Installing|Installed|already installed)"; then - echo -e "${GREEN}✓${NC} Rosetta 2 installed" - ((fixed_count++)) - fixed_items+=("Rosetta 2 installed") - else - echo -e "${RED}✗${NC} Failed to install Rosetta 2" - fi - echo "" - fi - - if [[ $fixed_count -gt 0 ]]; then - AUTO_FIX_SUMMARY="Auto fixes applied: ${fixed_count} issue(s)" - if [[ ${#fixed_items[@]} -gt 0 ]]; then - AUTO_FIX_DETAILS=$(printf '%s\n' "${fixed_items[@]}") - else - AUTO_FIX_DETAILS="" - fi - else - AUTO_FIX_SUMMARY="Auto fixes skipped: No changes were required" - AUTO_FIX_DETAILS="" - fi - export AUTO_FIX_SUMMARY AUTO_FIX_DETAILS - return 0 -} diff --git a/lib/manage/purge_paths.sh b/lib/manage/purge_paths.sh deleted file mode 100644 index bd163bd..0000000 --- a/lib/manage/purge_paths.sh +++ /dev/null @@ -1,117 +0,0 @@ -#!/bin/bash -# Purge paths management functionality -# Opens config file for editing and shows current status - -set -euo pipefail - -# Get script directory and source dependencies -_MOLE_MANAGE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$_MOLE_MANAGE_DIR/../core/common.sh" -# Only source project.sh if not already loaded (has readonly vars) -if [[ -z "${PURGE_TARGETS:-}" ]]; then - source "$_MOLE_MANAGE_DIR/../clean/project.sh" -fi - -# Config file path (use :- to avoid re-declaration if already set) -PURGE_PATHS_CONFIG="${PURGE_PATHS_CONFIG:-$HOME/.config/mole/purge_paths}" - -# Ensure config file exists with helpful template -ensure_config_template() { - if [[ ! -f "$PURGE_PATHS_CONFIG" ]]; then - ensure_user_dir "$(dirname "$PURGE_PATHS_CONFIG")" - cat > "$PURGE_PATHS_CONFIG" << 'EOF' -# Mole Purge Paths - Directories to scan for project artifacts -# Add one path per line (supports ~ for home directory) -# Delete all paths or this file to use defaults -# -# Example: -# ~/Documents/MyProjects -# ~/Work/ClientA -# ~/Work/ClientB -EOF - fi -} - -# Main management function -manage_purge_paths() { - ensure_config_template - - local display_config="${PURGE_PATHS_CONFIG/#$HOME/~}" - - # Clear screen - if [[ -t 1 ]]; then - printf '\033[2J\033[H' - fi - - echo -e "${PURPLE_BOLD}Purge Paths Configuration${NC}" - echo "" - - # Show current status - echo -e "${YELLOW}Current Scan Paths:${NC}" - - # Reload config - load_purge_config - - if [[ ${#PURGE_SEARCH_PATHS[@]} -gt 0 ]]; then - for path in "${PURGE_SEARCH_PATHS[@]}"; do - local display_path="${path/#$HOME/~}" - if [[ -d "$path" ]]; then - echo -e " ${GREEN}✓${NC} $display_path" - else - echo -e " ${GRAY}○${NC} $display_path ${GRAY}(not found)${NC}" - fi - done - fi - - # Check if using custom config - local custom_count=0 - if [[ -f "$PURGE_PATHS_CONFIG" ]]; then - while IFS= read -r line; do - line="${line#"${line%%[![:space:]]*}"}" - line="${line%"${line##*[![:space:]]}"}" - [[ -z "$line" || "$line" =~ ^# ]] && continue - ((custom_count++)) - done < "$PURGE_PATHS_CONFIG" - fi - - echo "" - if [[ $custom_count -gt 0 ]]; then - echo -e "${GRAY}Using custom config with $custom_count path(s)${NC}" - else - echo -e "${GRAY}Using ${#DEFAULT_PURGE_SEARCH_PATHS[@]} default paths${NC}" - fi - - echo "" - echo -e "${YELLOW}Default Paths:${NC}" - for path in "${DEFAULT_PURGE_SEARCH_PATHS[@]}"; do - echo -e " ${GRAY}-${NC} ${path/#$HOME/~}" - done - - echo "" - echo -e "${YELLOW}Config File:${NC} $display_config" - echo "" - - # Open in editor - local editor="${EDITOR:-${VISUAL:-vim}}" - echo -e "Opening in ${CYAN}$editor${NC}..." - echo -e "${GRAY}Save and exit to apply changes. Leave empty to use defaults.${NC}" - echo "" - - # Wait for user to read - sleep 1 - - # Open editor - "$editor" "$PURGE_PATHS_CONFIG" - - # Reload and show updated status - load_purge_config - - echo "" - echo -e "${GREEN}${ICON_SUCCESS}${NC} Configuration updated" - echo -e "${GRAY}Run 'mo purge' to clean with new paths${NC}" - echo "" -} - -if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then - manage_purge_paths -fi diff --git a/lib/manage/update.sh b/lib/manage/update.sh deleted file mode 100644 index b700f31..0000000 --- a/lib/manage/update.sh +++ /dev/null @@ -1,141 +0,0 @@ -#!/bin/bash -# Update Manager -# Unified update execution for all update types - -set -euo pipefail - -# Format Homebrew update label for display -format_brew_update_label() { - local total="${BREW_OUTDATED_COUNT:-0}" - if [[ -z "$total" || "$total" -le 0 ]]; then - return - fi - - local -a details=() - local formulas="${BREW_FORMULA_OUTDATED_COUNT:-0}" - local casks="${BREW_CASK_OUTDATED_COUNT:-0}" - - ((formulas > 0)) && details+=("${formulas} formula") - ((casks > 0)) && details+=("${casks} cask") - - local detail_str="(${total} updates)" - if ((${#details[@]} > 0)); then - detail_str="($( - IFS=', ' - printf '%s' "${details[*]}" - ))" - fi - printf " • Homebrew %s" "$detail_str" -} - -brew_has_outdated() { - local kind="${1:-formula}" - command -v brew > /dev/null 2>&1 || return 1 - - if [[ "$kind" == "cask" ]]; then - brew outdated --cask --quiet 2> /dev/null | grep -q . - else - brew outdated --quiet 2> /dev/null | grep -q . - fi -} - -# Ask user if they want to update -# Returns: 0 if yes, 1 if no -ask_for_updates() { - local has_updates=false - local -a update_list=() - - local brew_entry - brew_entry=$(format_brew_update_label || true) - if [[ -n "$brew_entry" ]]; then - has_updates=true - update_list+=("$brew_entry") - fi - - if [[ -n "${APPSTORE_UPDATE_COUNT:-}" && "${APPSTORE_UPDATE_COUNT:-0}" -gt 0 ]]; then - has_updates=true - update_list+=(" • App Store (${APPSTORE_UPDATE_COUNT} apps)") - fi - - if [[ -n "${MACOS_UPDATE_AVAILABLE:-}" && "${MACOS_UPDATE_AVAILABLE}" == "true" ]]; then - has_updates=true - update_list+=(" • macOS system") - fi - - if [[ -n "${MOLE_UPDATE_AVAILABLE:-}" && "${MOLE_UPDATE_AVAILABLE}" == "true" ]]; then - has_updates=true - update_list+=(" • Mole") - fi - - if [[ "$has_updates" == "false" ]]; then - return 1 - fi - - echo -e "${BLUE}AVAILABLE UPDATES${NC}" - for item in "${update_list[@]}"; do - echo -e "$item" - done - echo "" - # If only Mole is relevant for automation, prompt just for Mole - if [[ "${MOLE_UPDATE_AVAILABLE:-}" == "true" ]]; then - echo "" - echo -ne "${YELLOW}Update Mole now?${NC} ${GRAY}Enter confirm / ESC cancel${NC}: " - - local key - if ! key=$(read_key); then - echo "skip" - echo "" - return 1 - fi - - if [[ "$key" == "ENTER" ]]; then - echo "yes" - echo "" - return 0 - fi - fi - - echo "" - echo -e "${YELLOW}💡 Run ${GREEN}brew upgrade${YELLOW} to update${NC}" - - return 1 -} - -# Perform all pending updates -# Returns: 0 if all succeeded, 1 if some failed -perform_updates() { - # Only handle Mole updates here; Homebrew/App Store/macOS are manual (tips shown in ask_for_updates) - local updated_count=0 - local total_count=0 - - if [[ -n "${MOLE_UPDATE_AVAILABLE:-}" && "${MOLE_UPDATE_AVAILABLE}" == "true" ]]; then - echo -e "${BLUE}Updating Mole...${NC}" - local mole_bin="${SCRIPT_DIR}/../../mole" - [[ ! -f "$mole_bin" ]] && mole_bin=$(command -v mole 2> /dev/null || echo "") - - if [[ -x "$mole_bin" ]]; then - if "$mole_bin" update 2>&1 | grep -qE "(Updated|latest version)"; then - echo -e "${GREEN}✓${NC} Mole updated" - reset_mole_cache - ((updated_count++)) - else - echo -e "${RED}✗${NC} Mole update failed" - fi - else - echo -e "${RED}✗${NC} Mole executable not found" - fi - echo "" - total_count=1 - fi - - if [[ $total_count -eq 0 ]]; then - 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}" - return 0 - else - 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 deleted file mode 100755 index e648e9e..0000000 --- a/lib/manage/whitelist.sh +++ /dev/null @@ -1,430 +0,0 @@ -#!/bin/bash -# Whitelist management functionality -# Shows actual files that would be deleted by dry-run - -set -euo pipefail - -# Get script directory and source dependencies -_MOLE_MANAGE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$_MOLE_MANAGE_DIR/../core/common.sh" -source "$_MOLE_MANAGE_DIR/../ui/menu_simple.sh" - -# Config file paths -readonly WHITELIST_CONFIG_CLEAN="$HOME/.config/mole/whitelist" -readonly WHITELIST_CONFIG_OPTIMIZE="$HOME/.config/mole/whitelist_optimize" -readonly WHITELIST_CONFIG_OPTIMIZE_LEGACY="$HOME/.config/mole/whitelist_checks" - -# Default whitelist patterns defined in lib/core/common.sh: -# - DEFAULT_WHITELIST_PATTERNS -# - FINDER_METADATA_SENTINEL - -# Save whitelist patterns to config (defaults to "clean" for legacy callers) -save_whitelist_patterns() { - local mode="clean" - if [[ $# -gt 0 ]]; then - case "$1" in - clean | optimize) - mode="$1" - shift - ;; - esac - fi - - local -a patterns - patterns=("$@") - - local config_file - local header_text - - if [[ "$mode" == "optimize" ]]; then - config_file="$WHITELIST_CONFIG_OPTIMIZE" - header_text="# Mole Optimization Whitelist - These checks will be skipped during optimization" - else - config_file="$WHITELIST_CONFIG_CLEAN" - header_text="# Mole Whitelist - Protected paths won't be deleted\n# Default protections: Playwright browsers, HuggingFace models, Maven repo, Ollama models, Surge Mac, R renv, Finder metadata\n# Add one pattern per line to keep items safe." - fi - - ensure_user_file "$config_file" - - echo -e "$header_text" > "$config_file" - - if [[ ${#patterns[@]} -gt 0 ]]; then - local -a unique_patterns=() - for pattern in "${patterns[@]}"; do - local duplicate="false" - if [[ ${#unique_patterns[@]} -gt 0 ]]; then - for existing in "${unique_patterns[@]}"; do - if patterns_equivalent "$pattern" "$existing"; then - duplicate="true" - break - fi - done - fi - [[ "$duplicate" == "true" ]] && continue - unique_patterns+=("$pattern") - done - - if [[ ${#unique_patterns[@]} -gt 0 ]]; then - printf '\n' >> "$config_file" - for pattern in "${unique_patterns[@]}"; do - echo "$pattern" >> "$config_file" - done - fi - fi -} - -# Get all cache items with their patterns -get_all_cache_items() { - # Format: "display_name|pattern|category" - cat << 'EOF' -Apple Mail cache|$HOME/Library/Caches/com.apple.mail/*|system_cache -Gradle build cache (Android Studio, Gradle projects)|$HOME/.gradle/caches/*|ide_cache -Gradle daemon processes cache|$HOME/.gradle/daemon/*|ide_cache -Xcode DerivedData (build outputs, indexes)|$HOME/Library/Developer/Xcode/DerivedData/*|ide_cache -Xcode archives (built app packages)|$HOME/Library/Developer/Xcode/Archives/*|ide_cache -Xcode internal cache files|$HOME/Library/Caches/com.apple.dt.Xcode/*|ide_cache -Xcode iOS device support symbols|$HOME/Library/Developer/Xcode/iOS DeviceSupport/*/Symbols/System/Library/Caches/*|ide_cache -Maven local repository (Java dependencies)|$HOME/.m2/repository/*|ide_cache -JetBrains IDEs data (IntelliJ, PyCharm, WebStorm, GoLand)|$HOME/Library/Application Support/JetBrains/*|ide_cache -JetBrains IDEs cache|$HOME/Library/Caches/JetBrains/*|ide_cache -Android Studio cache and indexes|$HOME/Library/Caches/Google/AndroidStudio*/*|ide_cache -Android build cache|$HOME/.android/build-cache/*|ide_cache -VS Code runtime cache|$HOME/Library/Application Support/Code/Cache/*|ide_cache -VS Code extension and update cache|$HOME/Library/Application Support/Code/CachedData/*|ide_cache -VS Code system cache (Cursor, VSCodium)|$HOME/Library/Caches/com.microsoft.VSCode/*|ide_cache -Cursor editor cache|$HOME/Library/Caches/com.todesktop.230313mzl4w4u92/*|ide_cache -Bazel build cache|$HOME/.cache/bazel/*|compiler_cache -Go build cache and module cache|$HOME/Library/Caches/go-build/*|compiler_cache -Go module cache|$HOME/go/pkg/mod/cache/*|compiler_cache -Rust Cargo registry cache|$HOME/.cargo/registry/cache/*|compiler_cache -Rust documentation cache|$HOME/.rustup/toolchains/*/share/doc/*|compiler_cache -Rustup toolchain downloads|$HOME/.rustup/downloads/*|compiler_cache -ccache compiler cache|$HOME/.ccache/*|compiler_cache -sccache distributed compiler cache|$HOME/.cache/sccache/*|compiler_cache -SBT Scala build cache|$HOME/.sbt/*|compiler_cache -Ivy dependency cache|$HOME/.ivy2/cache/*|compiler_cache -Turbo monorepo build cache|$HOME/.turbo/*|compiler_cache -Next.js build cache|$HOME/.next/*|compiler_cache -Vite build cache|$HOME/.vite/*|compiler_cache -Parcel bundler cache|$HOME/.parcel-cache/*|compiler_cache -pre-commit hooks cache|$HOME/.cache/pre-commit/*|compiler_cache -Ruff Python linter cache|$HOME/.cache/ruff/*|compiler_cache -MyPy type checker cache|$HOME/.cache/mypy/*|compiler_cache -Pytest test cache|$HOME/.pytest_cache/*|compiler_cache -Flutter SDK cache|$HOME/.cache/flutter/*|compiler_cache -Swift Package Manager cache|$HOME/.cache/swift-package-manager/*|compiler_cache -Zig compiler cache|$HOME/.cache/zig/*|compiler_cache -Deno cache|$HOME/Library/Caches/deno/*|compiler_cache -CocoaPods cache (iOS dependencies)|$HOME/Library/Caches/CocoaPods/*|package_manager -npm package cache|$HOME/.npm/_cacache/*|package_manager -pip Python package cache|$HOME/.cache/pip/*|package_manager -uv Python package cache|$HOME/.cache/uv/*|package_manager -R renv global cache (virtual environments)|$HOME/Library/Caches/org.R-project.R/R/renv/*|package_manager -Homebrew downloaded packages|$HOME/Library/Caches/Homebrew/*|package_manager -Yarn package manager cache|$HOME/.cache/yarn/*|package_manager -pnpm package store|$HOME/.pnpm-store/*|package_manager -Composer PHP dependencies cache|$HOME/.composer/cache/*|package_manager -RubyGems cache|$HOME/.gem/cache/*|package_manager -Conda packages cache|$HOME/.conda/pkgs/*|package_manager -Anaconda packages cache|$HOME/anaconda3/pkgs/*|package_manager -PyTorch model cache|$HOME/.cache/torch/*|ai_ml_cache -TensorFlow model and dataset cache|$HOME/.cache/tensorflow/*|ai_ml_cache -HuggingFace models and datasets|$HOME/.cache/huggingface/*|ai_ml_cache -Playwright browser binaries|$HOME/Library/Caches/ms-playwright*|ai_ml_cache -Selenium WebDriver binaries|$HOME/.cache/selenium/*|ai_ml_cache -Ollama local AI models|$HOME/.ollama/models/*|ai_ml_cache -Weights & Biases ML experiments cache|$HOME/.cache/wandb/*|ai_ml_cache -Safari web browser cache|$HOME/Library/Caches/com.apple.Safari/*|browser_cache -Chrome browser cache|$HOME/Library/Caches/Google/Chrome/*|browser_cache -Firefox browser cache|$HOME/Library/Caches/Firefox/*|browser_cache -Brave browser cache|$HOME/Library/Caches/BraveSoftware/Brave-Browser/*|browser_cache -Surge proxy cache|$HOME/Library/Caches/com.nssurge.surge-mac/*|network_tools -Surge configuration and data|$HOME/Library/Application Support/com.nssurge.surge-mac/*|network_tools -Docker Desktop image cache|$HOME/Library/Containers/com.docker.docker/Data/*|container_cache -Podman container cache|$HOME/.local/share/containers/cache/*|container_cache -Font cache|$HOME/Library/Caches/com.apple.FontRegistry/*|system_cache -Spotlight metadata cache|$HOME/Library/Caches/com.apple.spotlight/*|system_cache -CloudKit cache|$HOME/Library/Caches/CloudKit/*|system_cache -Trash|$HOME/.Trash|system_cache -EOF - # Add FINDER_METADATA with constant reference - echo "Finder metadata (.DS_Store)|$FINDER_METADATA_SENTINEL|system_cache" -} - -# Get all optimize items with their patterns -get_optimize_whitelist_items() { - # Format: "display_name|pattern|category" - cat << 'EOF' -macOS Firewall check|firewall|security_check -Gatekeeper check|gatekeeper|security_check -macOS system updates check|check_macos_updates|update_check -Mole updates check|check_mole_update|update_check -Homebrew health check (doctor)|check_brew_health|health_check -SIP status check|check_sip|security_check -FileVault status check|check_filevault|security_check -TouchID sudo check|check_touchid|config_check -Rosetta 2 check|check_rosetta|config_check -Git configuration check|check_git_config|config_check -Login items check|check_login_items|config_check -EOF -} - -patterns_equivalent() { - local first="${1/#~/$HOME}" - local second="${2/#~/$HOME}" - - # Only exact string match, no glob expansion - [[ "$first" == "$second" ]] && return 0 - return 1 -} - -load_whitelist() { - local mode="${1:-clean}" - local -a patterns=() - local config_file - local legacy_file="" - - if [[ "$mode" == "optimize" ]]; then - config_file="$WHITELIST_CONFIG_OPTIMIZE" - legacy_file="$WHITELIST_CONFIG_OPTIMIZE_LEGACY" - else - config_file="$WHITELIST_CONFIG_CLEAN" - fi - - local using_legacy="false" - if [[ ! -f "$config_file" && -n "$legacy_file" && -f "$legacy_file" ]]; then - config_file="$legacy_file" - using_legacy="true" - fi - - if [[ -f "$config_file" ]]; then - while IFS= read -r line; do - # shellcheck disable=SC2295 - line="${line#"${line%%[![:space:]]*}"}" - # shellcheck disable=SC2295 - line="${line%"${line##*[![:space:]]}"}" - [[ -z "$line" || "$line" =~ ^# ]] && continue - patterns+=("$line") - done < "$config_file" - else - if [[ "$mode" == "clean" ]]; then - patterns=("${DEFAULT_WHITELIST_PATTERNS[@]}") - elif [[ "$mode" == "optimize" ]]; then - patterns=("${DEFAULT_OPTIMIZE_WHITELIST_PATTERNS[@]}") - fi - fi - - if [[ ${#patterns[@]} -gt 0 ]]; then - local -a unique_patterns=() - for pattern in "${patterns[@]}"; do - local duplicate="false" - if [[ ${#unique_patterns[@]} -gt 0 ]]; then - for existing in "${unique_patterns[@]}"; do - if patterns_equivalent "$pattern" "$existing"; then - duplicate="true" - break - fi - done - fi - [[ "$duplicate" == "true" ]] && continue - unique_patterns+=("$pattern") - done - CURRENT_WHITELIST_PATTERNS=("${unique_patterns[@]}") - - # Migrate legacy optimize config to the new path automatically - if [[ "$mode" == "optimize" && "$using_legacy" == "true" && "$config_file" != "$WHITELIST_CONFIG_OPTIMIZE" ]]; then - save_whitelist_patterns "$mode" "${CURRENT_WHITELIST_PATTERNS[@]}" - fi - else - CURRENT_WHITELIST_PATTERNS=() - fi -} - -is_whitelisted() { - local pattern="$1" - local check_pattern="${pattern/#\~/$HOME}" - - if [[ ${#CURRENT_WHITELIST_PATTERNS[@]} -eq 0 ]]; then - return 1 - fi - - for existing in "${CURRENT_WHITELIST_PATTERNS[@]}"; do - local existing_expanded="${existing/#\~/$HOME}" - # Only use exact string match to prevent glob expansion security issues - if [[ "$check_pattern" == "$existing_expanded" ]]; then - return 0 - fi - done - return 1 -} - -manage_whitelist() { - local mode="${1:-clean}" - manage_whitelist_categories "$mode" -} - -manage_whitelist_categories() { - local mode="$1" - - # Load currently enabled patterns from both sources - load_whitelist "$mode" - - # Build cache items list - local -a cache_items=() - local -a cache_patterns=() - local -a menu_options=() - local index=0 - - # Choose source based on mode - local items_source - local menu_title - local active_config_file - - if [[ "$mode" == "optimize" ]]; then - 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 -${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 -${GRAY}Edit: ${display_config}${NC}" - fi - - while IFS='|' read -r display_name pattern _; do - # Expand $HOME in pattern - pattern="${pattern/\$HOME/$HOME}" - - cache_items+=("$display_name") - cache_patterns+=("$pattern") - menu_options+=("$display_name") - - ((index++)) || true - done <<< "$items_source" - - # Identify custom patterns (not in predefined list) - local -a custom_patterns=() - if [[ ${#CURRENT_WHITELIST_PATTERNS[@]} -gt 0 ]]; then - for current_pattern in "${CURRENT_WHITELIST_PATTERNS[@]}"; do - local is_predefined=false - for predefined_pattern in "${cache_patterns[@]}"; do - if patterns_equivalent "$current_pattern" "$predefined_pattern"; then - is_predefined=true - break - fi - done - if [[ "$is_predefined" == "false" ]]; then - custom_patterns+=("$current_pattern") - fi - done - fi - - # Prioritize already-selected items to appear first - local -a selected_cache_items=() - local -a selected_cache_patterns=() - local -a selected_menu_options=() - local -a remaining_cache_items=() - local -a remaining_cache_patterns=() - local -a remaining_menu_options=() - - for ((i = 0; i < ${#cache_patterns[@]}; i++)); do - if is_whitelisted "${cache_patterns[i]}"; then - selected_cache_items+=("${cache_items[i]}") - selected_cache_patterns+=("${cache_patterns[i]}") - selected_menu_options+=("${menu_options[i]}") - else - remaining_cache_items+=("${cache_items[i]}") - remaining_cache_patterns+=("${cache_patterns[i]}") - remaining_menu_options+=("${menu_options[i]}") - fi - done - - cache_items=() - cache_patterns=() - menu_options=() - if [[ ${#selected_cache_items[@]} -gt 0 ]]; then - cache_items=("${selected_cache_items[@]}") - cache_patterns=("${selected_cache_patterns[@]}") - menu_options=("${selected_menu_options[@]}") - fi - if [[ ${#remaining_cache_items[@]} -gt 0 ]]; then - cache_items+=("${remaining_cache_items[@]}") - cache_patterns+=("${remaining_cache_patterns[@]}") - menu_options+=("${remaining_menu_options[@]}") - fi - - if [[ ${#selected_cache_patterns[@]} -gt 0 ]]; then - local -a preselected_indices=() - for ((i = 0; i < ${#selected_cache_patterns[@]}; i++)); do - preselected_indices+=("$i") - done - local IFS=',' - export MOLE_PRESELECTED_INDICES="${preselected_indices[*]}" - else - unset MOLE_PRESELECTED_INDICES - fi - - MOLE_SELECTION_RESULT="" - paginated_multi_select "$menu_title" "${menu_options[@]}" - unset MOLE_PRESELECTED_INDICES - local exit_code=$? - - # Normal exit or cancel - if [[ $exit_code -ne 0 ]]; then - return 1 - fi - - # Convert selected indices to patterns - local -a selected_patterns=() - if [[ -n "$MOLE_SELECTION_RESULT" ]]; then - local -a selected_indices - IFS=',' read -ra selected_indices <<< "$MOLE_SELECTION_RESULT" - for idx in "${selected_indices[@]}"; do - if [[ $idx -ge 0 && $idx -lt ${#cache_patterns[@]} ]]; then - local pattern="${cache_patterns[$idx]}" - # Convert back to portable format with ~ - pattern="${pattern/#$HOME/~}" - selected_patterns+=("$pattern") - fi - done - fi - - # Merge custom patterns with selected patterns - local -a all_patterns=() - if [[ ${#selected_patterns[@]} -gt 0 ]]; then - all_patterns=("${selected_patterns[@]}") - fi - if [[ ${#custom_patterns[@]} -gt 0 ]]; then - for custom_pattern in "${custom_patterns[@]}"; do - all_patterns+=("$custom_pattern") - done - fi - - # Save to whitelist config (bash 3.2 + set -u safe) - if [[ ${#all_patterns[@]} -gt 0 ]]; then - save_whitelist_patterns "$mode" "${all_patterns[@]}" - else - save_whitelist_patterns "$mode" - fi - - local total_protected=$((${#selected_patterns[@]} + ${#custom_patterns[@]})) - local -a summary_lines=() - summary_lines+=("Whitelist Updated") - if [[ ${#custom_patterns[@]} -gt 0 ]]; then - summary_lines+=("Protected ${#selected_patterns[@]} predefined + ${#custom_patterns[@]} custom patterns") - else - summary_lines+=("Protected ${total_protected} cache(s)") - fi - local display_config="${active_config_file/#$HOME/~}" - summary_lines+=("Config: ${GRAY}${display_config}${NC}") - - print_summary_block "${summary_lines[@]}" - printf '\n' -} - -if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then - manage_whitelist -fi diff --git a/lib/optimize/maintenance.sh b/lib/optimize/maintenance.sh deleted file mode 100644 index 0cad6b3..0000000 --- a/lib/optimize/maintenance.sh +++ /dev/null @@ -1,53 +0,0 @@ -#!/bin/bash -# System Configuration Maintenance Module. -# Fix broken preferences and login items. - -set -euo pipefail - -# Remove corrupted preference files. -fix_broken_preferences() { - local prefs_dir="$HOME/Library/Preferences" - [[ -d "$prefs_dir" ]] || return 0 - - local broken_count=0 - - while IFS= read -r plist_file; do - [[ -f "$plist_file" ]] || continue - - local filename - filename=$(basename "$plist_file") - case "$filename" in - com.apple.* | .GlobalPreferences* | loginwindow.plist) - continue - ;; - esac - - plutil -lint "$plist_file" > /dev/null 2>&1 && continue - - safe_remove "$plist_file" true > /dev/null 2>&1 || true - ((broken_count++)) - done < <(command find "$prefs_dir" -maxdepth 1 -name "*.plist" -type f 2> /dev/null || true) - - # Check ByHost preferences. - local byhost_dir="$prefs_dir/ByHost" - if [[ -d "$byhost_dir" ]]; then - while IFS= read -r plist_file; do - [[ -f "$plist_file" ]] || continue - - local filename - filename=$(basename "$plist_file") - case "$filename" in - com.apple.* | .GlobalPreferences*) - continue - ;; - esac - - plutil -lint "$plist_file" > /dev/null 2>&1 && continue - - safe_remove "$plist_file" true > /dev/null 2>&1 || true - ((broken_count++)) - done < <(command find "$byhost_dir" -name "*.plist" -type f 2> /dev/null || true) - fi - - echo "$broken_count" -} diff --git a/lib/optimize/tasks.sh b/lib/optimize/tasks.sh deleted file mode 100644 index 042e0f2..0000000 --- a/lib/optimize/tasks.sh +++ /dev/null @@ -1,779 +0,0 @@ -#!/bin/bash -# Optimization Tasks - -set -euo pipefail - -# Config constants (override via env). -readonly MOLE_TM_THIN_TIMEOUT=180 -readonly MOLE_TM_THIN_VALUE=9999999999 -readonly MOLE_SQLITE_MAX_SIZE=104857600 # 100MB - -# Dry-run aware output. -opt_msg() { - local message="$1" - if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} $message" - else - echo -e " ${GREEN}✓${NC} $message" - fi -} - -run_launchctl_unload() { - local plist_file="$1" - local need_sudo="${2:-false}" - - if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then - return 0 - fi - - if [[ "$need_sudo" == "true" ]]; then - sudo launchctl unload "$plist_file" 2> /dev/null || true - else - launchctl unload "$plist_file" 2> /dev/null || true - fi -} - -needs_permissions_repair() { - local owner - owner=$(stat -f %Su "$HOME" 2> /dev/null || echo "") - if [[ -n "$owner" && "$owner" != "$USER" ]]; then - return 0 - fi - - local -a paths=( - "$HOME" - "$HOME/Library" - "$HOME/Library/Preferences" - ) - local path - for path in "${paths[@]}"; do - if [[ -e "$path" && ! -w "$path" ]]; then - return 0 - fi - done - - return 1 -} - -has_bluetooth_hid_connected() { - local bt_report - bt_report=$(system_profiler SPBluetoothDataType 2> /dev/null || echo "") - if ! echo "$bt_report" | grep -q "Connected: Yes"; then - return 1 - fi - - if echo "$bt_report" | grep -Eiq "Keyboard|Trackpad|Mouse|HID"; then - return 0 - fi - - return 1 -} - -is_ac_power() { - pmset -g batt 2> /dev/null | grep -q "AC Power" -} - -is_memory_pressure_high() { - if ! command -v memory_pressure > /dev/null 2>&1; then - return 1 - fi - - local mp_output - mp_output=$(memory_pressure -Q 2> /dev/null || echo "") - if echo "$mp_output" | grep -Eiq "warning|critical"; then - return 0 - fi - - return 1 -} - -flush_dns_cache() { - if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then - MOLE_DNS_FLUSHED=1 - return 0 - fi - - if sudo dscacheutil -flushcache 2> /dev/null && sudo killall -HUP mDNSResponder 2> /dev/null; then - MOLE_DNS_FLUSHED=1 - return 0 - fi - return 1 -} - -# Basic system maintenance. -opt_system_maintenance() { - if flush_dns_cache; then - opt_msg "DNS cache flushed" - fi - - local spotlight_status - spotlight_status=$(mdutil -s / 2> /dev/null || echo "") - if echo "$spotlight_status" | grep -qi "Indexing disabled"; then - echo -e " ${GRAY}${ICON_EMPTY}${NC} Spotlight indexing disabled" - else - opt_msg "Spotlight index verified" - fi -} - -# Refresh Finder caches (QuickLook/icon services). -opt_cache_refresh() { - local total_cache_size=0 - - if [[ "${MO_DEBUG:-}" == "1" ]]; then - debug_operation_start "Finder Cache Refresh" "Refresh QuickLook thumbnails and icon services" - debug_operation_detail "Method" "Remove cache files and rebuild via qlmanage" - debug_operation_detail "Expected outcome" "Faster Finder preview generation, fixed icon display issues" - debug_risk_level "LOW" "Caches are automatically rebuilt" - - local -a cache_targets=( - "$HOME/Library/Caches/com.apple.QuickLook.thumbnailcache" - "$HOME/Library/Caches/com.apple.iconservices.store" - "$HOME/Library/Caches/com.apple.iconservices" - ) - - debug_operation_detail "Files to be removed" "" - for target_path in "${cache_targets[@]}"; do - if [[ -e "$target_path" ]]; then - local size_kb - size_kb=$(get_path_size_kb "$target_path" 2> /dev/null || echo "0") - local size_human="unknown" - if [[ "$size_kb" -gt 0 ]]; then - size_human=$(bytes_to_human "$((size_kb * 1024))") - fi - debug_file_action " Will remove" "$target_path" "$size_human" "" - fi - done - fi - - if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then - qlmanage -r cache > /dev/null 2>&1 || true - qlmanage -r > /dev/null 2>&1 || true - fi - - local -a cache_targets=( - "$HOME/Library/Caches/com.apple.QuickLook.thumbnailcache" - "$HOME/Library/Caches/com.apple.iconservices.store" - "$HOME/Library/Caches/com.apple.iconservices" - ) - - for target_path in "${cache_targets[@]}"; do - if [[ -e "$target_path" ]]; then - if ! should_protect_path "$target_path"; then - local size_kb - size_kb=$(get_path_size_kb "$target_path" 2> /dev/null || echo "0") - if [[ "$size_kb" =~ ^[0-9]+$ ]]; then - total_cache_size=$((total_cache_size + size_kb)) - fi - safe_remove "$target_path" true > /dev/null 2>&1 - fi - fi - done - - export OPTIMIZE_CACHE_CLEANED_KB="${total_cache_size}" - opt_msg "QuickLook thumbnails refreshed" - opt_msg "Icon services cache rebuilt" -} - -# Removed: opt_maintenance_scripts - macOS handles log rotation automatically via launchd - -# Removed: opt_radio_refresh - Interrupts active user connections (WiFi, Bluetooth), degrading UX - -# Old saved states cleanup. -opt_saved_state_cleanup() { - if [[ "${MO_DEBUG:-}" == "1" ]]; then - debug_operation_start "App Saved State Cleanup" "Remove old application saved states" - debug_operation_detail "Method" "Find and remove .savedState folders older than $MOLE_SAVED_STATE_AGE_DAYS days" - debug_operation_detail "Location" "$HOME/Library/Saved Application State" - debug_operation_detail "Expected outcome" "Reduced disk usage, apps start with clean state" - debug_risk_level "LOW" "Old saved states, apps will create new ones" - fi - - local state_dir="$HOME/Library/Saved Application State" - - if [[ -d "$state_dir" ]]; then - while IFS= read -r -d '' state_path; do - if should_protect_path "$state_path"; then - continue - fi - safe_remove "$state_path" true > /dev/null 2>&1 - done < <(command find "$state_dir" -type d -name "*.savedState" -mtime "+$MOLE_SAVED_STATE_AGE_DAYS" -print0 2> /dev/null) - fi - - opt_msg "App saved states optimized" -} - -# Removed: opt_swap_cleanup - Direct virtual memory operations pose system crash risk - -# Removed: opt_startup_cache - Modern macOS has no such mechanism - -# Removed: opt_local_snapshots - Deletes user Time Machine recovery points, breaks backup continuity - -opt_fix_broken_configs() { - local spinner_started="false" - if [[ -t 1 ]]; then - MOLE_SPINNER_PREFIX=" " start_inline_spinner "Checking preferences..." - spinner_started="true" - fi - - local broken_prefs=$(fix_broken_preferences) - - if [[ "$spinner_started" == "true" ]]; then - stop_inline_spinner - fi - - export OPTIMIZE_CONFIGS_REPAIRED="${broken_prefs}" - if [[ $broken_prefs -gt 0 ]]; then - opt_msg "Repaired $broken_prefs corrupted preference files" - else - opt_msg "All preference files valid" - fi -} - -# DNS cache refresh. -opt_network_optimization() { - if [[ "${MO_DEBUG:-}" == "1" ]]; then - debug_operation_start "Network Optimization" "Refresh DNS cache and restart mDNSResponder" - debug_operation_detail "Method" "Flush DNS cache via dscacheutil and killall mDNSResponder" - debug_operation_detail "Expected outcome" "Faster DNS resolution, fixed network connectivity issues" - debug_risk_level "LOW" "DNS cache is automatically rebuilt" - fi - - if [[ "${MOLE_DNS_FLUSHED:-0}" == "1" ]]; then - opt_msg "DNS cache already refreshed" - opt_msg "mDNSResponder already restarted" - return 0 - fi - - if flush_dns_cache; then - opt_msg "DNS cache refreshed" - opt_msg "mDNSResponder restarted" - else - echo -e " ${YELLOW}!${NC} Failed to refresh DNS cache" - fi -} - -# SQLite vacuum for Mail/Messages/Safari (safety checks applied). -opt_sqlite_vacuum() { - if [[ "${MO_DEBUG:-}" == "1" ]]; then - debug_operation_start "Database Optimization" "Vacuum SQLite databases for Mail, Safari, and Messages" - debug_operation_detail "Method" "Run VACUUM command on databases after integrity check" - debug_operation_detail "Safety checks" "Skip if apps are running, verify integrity first, 20s timeout" - debug_operation_detail "Expected outcome" "Reduced database size, faster app performance" - debug_risk_level "LOW" "Only optimizes databases, does not delete data" - fi - - if ! command -v sqlite3 > /dev/null 2>&1; then - echo -e " ${GRAY}-${NC} Database optimization already optimal (sqlite3 unavailable)" - return 0 - fi - - local -a busy_apps=() - local -a check_apps=("Mail" "Safari" "Messages") - local app - for app in "${check_apps[@]}"; do - if pgrep -x "$app" > /dev/null 2>&1; then - busy_apps+=("$app") - fi - done - - if [[ ${#busy_apps[@]} -gt 0 ]]; then - echo -e " ${YELLOW}!${NC} Close these apps before database optimization: ${busy_apps[*]}" - return 0 - fi - - local spinner_started="false" - if [[ "${MOLE_DRY_RUN:-0}" != "1" && -t 1 ]]; then - MOLE_SPINNER_PREFIX=" " start_inline_spinner "Optimizing databases..." - spinner_started="true" - fi - - local -a db_paths=( - "$HOME/Library/Mail/V*/MailData/Envelope Index*" - "$HOME/Library/Messages/chat.db" - "$HOME/Library/Safari/History.db" - "$HOME/Library/Safari/TopSites.db" - ) - - local vacuumed=0 - local timed_out=0 - local failed=0 - local skipped=0 - - for pattern in "${db_paths[@]}"; do - while IFS= read -r db_file; do - [[ ! -f "$db_file" ]] && continue - [[ "$db_file" == *"-wal" || "$db_file" == *"-shm" ]] && continue - - should_protect_path "$db_file" && continue - - if ! file "$db_file" 2> /dev/null | grep -q "SQLite"; then - continue - fi - - # Skip large DBs (>100MB). - local file_size - file_size=$(get_file_size "$db_file") - if [[ "$file_size" -gt "$MOLE_SQLITE_MAX_SIZE" ]]; then - ((skipped++)) - continue - fi - - # Skip if freelist is tiny (already compact). - local page_info="" - page_info=$(run_with_timeout 5 sqlite3 "$db_file" "PRAGMA page_count; PRAGMA freelist_count;" 2> /dev/null || echo "") - local page_count="" - local freelist_count="" - page_count=$(echo "$page_info" | awk 'NR==1 {print $1}' 2> /dev/null || echo "") - freelist_count=$(echo "$page_info" | awk 'NR==2 {print $1}' 2> /dev/null || echo "") - if [[ "$page_count" =~ ^[0-9]+$ && "$freelist_count" =~ ^[0-9]+$ && "$page_count" -gt 0 ]]; then - if ((freelist_count * 100 < page_count * 5)); then - ((skipped++)) - continue - fi - fi - - # Verify integrity before VACUUM. - if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then - local integrity_check="" - set +e - integrity_check=$(run_with_timeout 10 sqlite3 "$db_file" "PRAGMA integrity_check;" 2> /dev/null) - local integrity_status=$? - set -e - - if [[ $integrity_status -ne 0 ]] || ! echo "$integrity_check" | grep -q "ok"; then - ((skipped++)) - continue - fi - fi - - local exit_code=0 - if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then - set +e - run_with_timeout 20 sqlite3 "$db_file" "VACUUM;" 2> /dev/null - exit_code=$? - set -e - - if [[ $exit_code -eq 0 ]]; then - ((vacuumed++)) - elif [[ $exit_code -eq 124 ]]; then - ((timed_out++)) - else - ((failed++)) - fi - else - ((vacuumed++)) - fi - done < <(compgen -G "$pattern" || true) - done - - if [[ "$spinner_started" == "true" ]]; then - stop_inline_spinner - fi - - export OPTIMIZE_DATABASES_COUNT="${vacuumed}" - if [[ $vacuumed -gt 0 ]]; then - opt_msg "Optimized $vacuumed databases for Mail, Safari, Messages" - elif [[ $timed_out -eq 0 && $failed -eq 0 ]]; then - opt_msg "All databases already optimized" - else - echo -e " ${YELLOW}!${NC} Database optimization incomplete" - fi - - if [[ $skipped -gt 0 ]]; then - opt_msg "Already optimal for $skipped databases" - fi - - if [[ $timed_out -gt 0 ]]; then - echo -e " ${YELLOW}!${NC} Timed out on $timed_out databases" - fi - - if [[ $failed -gt 0 ]]; then - echo -e " ${YELLOW}!${NC} Failed on $failed databases" - fi -} - -# LaunchServices rebuild ("Open with" issues). -opt_launch_services_rebuild() { - if [[ "${MO_DEBUG:-}" == "1" ]]; then - debug_operation_start "LaunchServices Rebuild" "Rebuild LaunchServices database" - debug_operation_detail "Method" "Run lsregister -r on system, user, and local domains" - debug_operation_detail "Purpose" "Fix \"Open with\" menu issues and file associations" - debug_operation_detail "Expected outcome" "Correct app associations, fixed duplicate entries" - debug_risk_level "LOW" "Database is automatically rebuilt" - fi - - if [[ -t 1 ]]; then - start_inline_spinner "" - fi - - local lsregister="/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister" - - if [[ -f "$lsregister" ]]; then - local success=0 - - if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then - set +e - "$lsregister" -r -domain local -domain user -domain system > /dev/null 2>&1 - success=$? - if [[ $success -ne 0 ]]; then - "$lsregister" -r -domain local -domain user > /dev/null 2>&1 - success=$? - fi - set -e - else - success=0 - fi - - if [[ -t 1 ]]; then - stop_inline_spinner - fi - - if [[ $success -eq 0 ]]; then - opt_msg "LaunchServices repaired" - opt_msg "File associations refreshed" - else - echo -e " ${YELLOW}!${NC} Failed to rebuild LaunchServices" - fi - else - if [[ -t 1 ]]; then - stop_inline_spinner - fi - echo -e " ${YELLOW}!${NC} lsregister not found" - fi -} - -# Font cache rebuild. -opt_font_cache_rebuild() { - if [[ "${MO_DEBUG:-}" == "1" ]]; then - debug_operation_start "Font Cache Rebuild" "Clear and rebuild font cache" - debug_operation_detail "Method" "Run atsutil databases -remove" - debug_operation_detail "Expected outcome" "Fixed font display issues, removed corrupted font cache" - debug_risk_level "LOW" "System automatically rebuilds font database" - fi - - local success=false - - if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then - if sudo atsutil databases -remove > /dev/null 2>&1; then - success=true - fi - else - success=true - fi - - if [[ "$success" == "true" ]]; then - opt_msg "Font cache cleared" - opt_msg "System will rebuild font database automatically" - else - echo -e " ${YELLOW}!${NC} Failed to clear font cache" - fi -} - -# Removed high-risk optimizations: -# - opt_startup_items_cleanup: Risk of deleting legitimate app helpers -# - opt_dyld_cache_update: Low benefit, time-consuming, auto-managed by macOS -# - opt_system_services_refresh: Risk of data loss when killing system services - -# Memory pressure relief. -opt_memory_pressure_relief() { - if [[ "${MO_DEBUG:-}" == "1" ]]; then - debug_operation_start "Memory Pressure Relief" "Release inactive memory if pressure is high" - debug_operation_detail "Method" "Run purge command to clear inactive memory" - debug_operation_detail "Condition" "Only runs if memory pressure is warning/critical" - debug_operation_detail "Expected outcome" "More available memory, improved responsiveness" - debug_risk_level "LOW" "Safe system command, does not affect active processes" - fi - - if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then - if ! is_memory_pressure_high; then - opt_msg "Memory pressure already optimal" - return 0 - fi - - if sudo purge > /dev/null 2>&1; then - opt_msg "Inactive memory released" - opt_msg "System responsiveness improved" - else - echo -e " ${YELLOW}!${NC} Failed to release memory pressure" - fi - else - opt_msg "Inactive memory released" - opt_msg "System responsiveness improved" - fi -} - -# Network stack reset (route + ARP). -opt_network_stack_optimize() { - local route_flushed="false" - local arp_flushed="false" - - if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then - local route_ok=true - local dns_ok=true - - if ! route -n get default > /dev/null 2>&1; then - route_ok=false - fi - if ! dscacheutil -q host -a name "example.com" > /dev/null 2>&1; then - dns_ok=false - fi - - if [[ "$route_ok" == "true" && "$dns_ok" == "true" ]]; then - opt_msg "Network stack already optimal" - return 0 - fi - - if sudo route -n flush > /dev/null 2>&1; then - route_flushed="true" - fi - - if sudo arp -a -d > /dev/null 2>&1; then - arp_flushed="true" - fi - else - route_flushed="true" - arp_flushed="true" - fi - - if [[ "$route_flushed" == "true" ]]; then - opt_msg "Network routing table refreshed" - fi - if [[ "$arp_flushed" == "true" ]]; then - opt_msg "ARP cache cleared" - else - if [[ "$route_flushed" == "true" ]]; then - return 0 - fi - echo -e " ${YELLOW}!${NC} Failed to optimize network stack" - fi -} - -# User directory permissions repair. -opt_disk_permissions_repair() { - if [[ "${MO_DEBUG:-}" == "1" ]]; then - debug_operation_start "Disk Permissions Repair" "Reset user directory permissions" - debug_operation_detail "Method" "Run diskutil resetUserPermissions on user home directory" - debug_operation_detail "Condition" "Only runs if permissions issues are detected" - debug_operation_detail "Expected outcome" "Fixed file access issues, correct ownership" - debug_risk_level "MEDIUM" "Requires sudo, modifies permissions" - fi - - local user_id - user_id=$(id -u) - - if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then - if ! needs_permissions_repair; then - opt_msg "User directory permissions already optimal" - return 0 - fi - - if [[ -t 1 ]]; then - start_inline_spinner "Repairing disk permissions..." - fi - - local success=false - if sudo diskutil resetUserPermissions / "$user_id" > /dev/null 2>&1; then - success=true - fi - - if [[ -t 1 ]]; then - stop_inline_spinner - fi - - if [[ "$success" == "true" ]]; then - 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)" - fi - else - opt_msg "User directory permissions repaired" - opt_msg "File access issues resolved" - fi -} - -# Bluetooth reset (skip if HID/audio active). -opt_bluetooth_reset() { - if [[ "${MO_DEBUG:-}" == "1" ]]; then - debug_operation_start "Bluetooth Reset" "Restart Bluetooth daemon" - debug_operation_detail "Method" "Kill bluetoothd daemon (auto-restarts)" - debug_operation_detail "Safety" "Skips if active Bluetooth keyboard/mouse/audio detected" - debug_operation_detail "Expected outcome" "Fixed Bluetooth connectivity issues" - debug_risk_level "LOW" "Daemon auto-restarts, connections auto-reconnect" - fi - - local spinner_started="false" - if [[ -t 1 ]]; then - MOLE_SPINNER_PREFIX=" " start_inline_spinner "Checking Bluetooth..." - spinner_started="true" - fi - - if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then - if has_bluetooth_hid_connected; then - if [[ "$spinner_started" == "true" ]]; then - stop_inline_spinner - fi - opt_msg "Bluetooth already optimal" - return 0 - fi - - local bt_audio_active=false - - local audio_info - audio_info=$(system_profiler SPAudioDataType 2> /dev/null || echo "") - - local default_output - default_output=$(echo "$audio_info" | awk '/Default Output Device: Yes/,/^$/' 2> /dev/null || echo "") - - if echo "$default_output" | grep -qi "Transport:.*Bluetooth"; then - bt_audio_active=true - fi - - if [[ "$bt_audio_active" == "false" ]]; then - if system_profiler SPBluetoothDataType 2> /dev/null | grep -q "Connected: Yes"; then - local -a media_apps=("Music" "Spotify" "VLC" "QuickTime Player" "TV" "Podcasts" "Safari" "Google Chrome" "Chrome" "Firefox" "Arc" "IINA" "mpv") - for app in "${media_apps[@]}"; do - if pgrep -x "$app" > /dev/null 2>&1; then - bt_audio_active=true - break - fi - done - fi - fi - - if [[ "$bt_audio_active" == "true" ]]; then - if [[ "$spinner_started" == "true" ]]; then - stop_inline_spinner - fi - opt_msg "Bluetooth already optimal" - return 0 - fi - - if sudo pkill -TERM bluetoothd > /dev/null 2>&1; then - sleep 1 - if pgrep -x bluetoothd > /dev/null 2>&1; then - sudo pkill -KILL bluetoothd > /dev/null 2>&1 || true - fi - if [[ "$spinner_started" == "true" ]]; then - stop_inline_spinner - fi - opt_msg "Bluetooth module restarted" - opt_msg "Connectivity issues resolved" - else - if [[ "$spinner_started" == "true" ]]; then - stop_inline_spinner - fi - opt_msg "Bluetooth already optimal" - fi - else - if [[ "$spinner_started" == "true" ]]; then - stop_inline_spinner - fi - opt_msg "Bluetooth module restarted" - opt_msg "Connectivity issues resolved" - fi -} - -# Spotlight index check/rebuild (only if slow). -opt_spotlight_index_optimize() { - local spotlight_status - spotlight_status=$(mdutil -s / 2> /dev/null || echo "") - - if echo "$spotlight_status" | grep -qi "Indexing disabled"; then - echo -e " ${GRAY}${ICON_EMPTY}${NC} Spotlight indexing is disabled" - return 0 - fi - - if echo "$spotlight_status" | grep -qi "Indexing enabled" && ! echo "$spotlight_status" | grep -qi "Indexing and searching disabled"; then - local slow_count=0 - local test_start test_end test_duration - for _ in 1 2; do - test_start=$(get_epoch_seconds) - mdfind "kMDItemFSName == 'Applications'" > /dev/null 2>&1 || true - test_end=$(get_epoch_seconds) - test_duration=$((test_end - test_start)) - if [[ $test_duration -gt 3 ]]; then - ((slow_count++)) - fi - sleep 1 - done - - if [[ $slow_count -ge 2 ]]; then - if ! is_ac_power; then - opt_msg "Spotlight index already optimal" - return 0 - fi - - if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then - 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}" - else - echo -e " ${YELLOW}!${NC} Failed to rebuild Spotlight index" - fi - else - opt_msg "Spotlight index rebuild started" - fi - else - opt_msg "Spotlight index already optimal" - fi - else - opt_msg "Spotlight index verified" - fi -} - -# Dock cache refresh. -opt_dock_refresh() { - local dock_support="$HOME/Library/Application Support/Dock" - local refreshed=false - - if [[ -d "$dock_support" ]]; then - while IFS= read -r db_file; do - 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) - fi - - local dock_plist="$HOME/Library/Preferences/com.apple.dock.plist" - if [[ -f "$dock_plist" ]]; then - touch "$dock_plist" 2> /dev/null || true - fi - - if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then - killall Dock 2> /dev/null || true - fi - - if [[ "$refreshed" == "true" ]]; then - opt_msg "Dock cache cleared" - fi - opt_msg "Dock refreshed" -} - -# Dispatch optimization by action name. -execute_optimization() { - local action="$1" - local path="${2:-}" - - case "$action" in - system_maintenance) opt_system_maintenance ;; - cache_refresh) opt_cache_refresh ;; - saved_state_cleanup) opt_saved_state_cleanup ;; - fix_broken_configs) opt_fix_broken_configs ;; - network_optimization) opt_network_optimization ;; - sqlite_vacuum) opt_sqlite_vacuum ;; - launch_services_rebuild) opt_launch_services_rebuild ;; - font_cache_rebuild) opt_font_cache_rebuild ;; - dock_refresh) opt_dock_refresh ;; - memory_pressure_relief) opt_memory_pressure_relief ;; - network_stack_optimize) opt_network_stack_optimize ;; - disk_permissions_repair) opt_disk_permissions_repair ;; - bluetooth_reset) opt_bluetooth_reset ;; - spotlight_index_optimize) opt_spotlight_index_optimize ;; - *) - echo -e "${YELLOW}${ICON_ERROR}${NC} Unknown action: $action" - return 1 - ;; - esac -} diff --git a/lib/ui/app_selector.sh b/lib/ui/app_selector.sh deleted file mode 100755 index 5c5238a..0000000 --- a/lib/ui/app_selector.sh +++ /dev/null @@ -1,192 +0,0 @@ -#!/bin/bash -# App selection functionality - -set -euo pipefail - -# Note: get_display_width() is now defined in lib/core/ui.sh - -# Format app info for display -format_app_display() { - local display_name="$1" size="$2" last_used="$3" - - # Use common function from ui.sh to format last used time - local compact_last_used - compact_last_used=$(format_last_used_summary "$last_used") - - # Format size - local size_str="Unknown" - [[ "$size" != "0" && "$size" != "" && "$size" != "Unknown" ]] && size_str="$size" - - # Calculate available width for app name based on terminal width - # Accept pre-calculated max_name_width (5th param) to avoid recalculation in loops - local terminal_width="${4:-$(tput cols 2> /dev/null || echo 80)}" - local max_name_width="${5:-}" - local available_width - - if [[ -n "$max_name_width" ]]; then - # Use pre-calculated width from caller - available_width=$max_name_width - else - # Fallback: calculate it (slower, but works for standalone calls) - # Fixed elements: " ○ " (4) + " " (1) + size (9) + " | " (3) + max_last (7) = 24 - local fixed_width=24 - available_width=$((terminal_width - fixed_width)) - - # Dynamic minimum for better spacing on wide terminals - local min_width=18 - if [[ $terminal_width -ge 120 ]]; then - min_width=48 - elif [[ $terminal_width -ge 100 ]]; then - min_width=38 - elif [[ $terminal_width -ge 80 ]]; then - min_width=25 - fi - - [[ $available_width -lt $min_width ]] && available_width=$min_width - [[ $available_width -gt 60 ]] && available_width=60 - fi - - # Truncate long names if needed (based on display width, not char count) - local truncated_name - truncated_name=$(truncate_by_display_width "$display_name" "$available_width") - - # Get actual display width after truncation - local current_display_width - current_display_width=$(get_display_width "$truncated_name") - - # Calculate padding needed - # Formula: char_count + (available_width - display_width) = padding to add - local char_count=${#truncated_name} - local padding_needed=$((available_width - current_display_width)) - local printf_width=$((char_count + padding_needed)) - - # Use dynamic column width with corrected padding - printf "%-*s %9s | %s" "$printf_width" "$truncated_name" "$size_str" "$compact_last_used" -} - -# Global variable to store selection result (bash 3.2 compatible) -MOLE_SELECTION_RESULT="" - -# Main app selection function -# shellcheck disable=SC2154 # apps_data is set by caller -select_apps_for_uninstall() { - if [[ ${#apps_data[@]} -eq 0 ]]; then - log_warning "No applications available for uninstallation" - return 1 - fi - - # Build menu options - # Show loading for large lists (formatting can be slow due to width calculations) - local app_count=${#apps_data[@]} - local terminal_width=$(tput cols 2> /dev/null || echo 80) - if [[ $app_count -gt 100 ]]; then - if [[ -t 2 ]]; then - printf "\rPreparing %d applications... " "$app_count" >&2 - fi - fi - - # Pre-scan to get actual max name width - local max_name_width=0 - for app_data in "${apps_data[@]}"; do - IFS='|' read -r _ _ display_name _ _ _ _ <<< "$app_data" - local name_width=$(get_display_width "$display_name") - [[ $name_width -gt $max_name_width ]] && max_name_width=$name_width - done - # Constrain based on terminal width: fixed=24, min varies by terminal width, max=60 - local fixed_width=24 - local available=$((terminal_width - fixed_width)) - - # Dynamic minimum: wider terminals get larger minimum for better spacing - local min_width=18 - if [[ $terminal_width -ge 120 ]]; then - min_width=48 # Wide terminals: very generous spacing - elif [[ $terminal_width -ge 100 ]]; then - min_width=38 # Medium-wide terminals: generous spacing - elif [[ $terminal_width -ge 80 ]]; then - min_width=25 # Standard terminals - fi - - [[ $max_name_width -lt $min_width ]] && max_name_width=$min_width - [[ $available -lt $max_name_width ]] && max_name_width=$available - [[ $max_name_width -gt 60 ]] && max_name_width=60 - - local -a menu_options=() - # Prepare metadata (comma-separated) for sorting/filtering inside the menu - local epochs_csv="" - local sizekb_csv="" - local idx=0 - for app_data in "${apps_data[@]}"; do - # Keep extended field 7 (size_kb) if present - IFS='|' read -r epoch _ display_name _ size last_used size_kb <<< "$app_data" - menu_options+=("$(format_app_display "$display_name" "$size" "$last_used" "$terminal_width" "$max_name_width")") - # Build csv lists (avoid trailing commas) - if [[ $idx -eq 0 ]]; then - epochs_csv="${epoch:-0}" - sizekb_csv="${size_kb:-0}" - else - epochs_csv+=",${epoch:-0}" - sizekb_csv+=",${size_kb:-0}" - fi - ((idx++)) - done - - # Clear loading message - if [[ $app_count -gt 100 ]]; then - if [[ -t 2 ]]; then - printf "\r\033[K" >&2 - fi - fi - - # Expose metadata for the paginated menu (optional inputs) - # - MOLE_MENU_META_EPOCHS: numeric last_used_epoch per item - # - MOLE_MENU_META_SIZEKB: numeric size in KB per item - # The menu will gracefully fallback if these are unset or malformed. - export MOLE_MENU_META_EPOCHS="$epochs_csv" - export MOLE_MENU_META_SIZEKB="$sizekb_csv" - # Optional: allow default sort override via env (date|name|size) - # export MOLE_MENU_SORT_DEFAULT="${MOLE_MENU_SORT_DEFAULT:-date}" - - # Use paginated menu - result will be stored in MOLE_SELECTION_RESULT - # Note: paginated_multi_select enters alternate screen and handles clearing - MOLE_SELECTION_RESULT="" - paginated_multi_select "Select Apps to Remove" "${menu_options[@]}" - local exit_code=$? - - # Clean env leakage for safety - unset MOLE_MENU_META_EPOCHS MOLE_MENU_META_SIZEKB - # leave MOLE_MENU_SORT_DEFAULT untouched if user set it globally - - # Refresh signal handling - if [[ $exit_code -eq 10 ]]; then - return 10 - fi - - if [[ $exit_code -ne 0 ]]; then - return 1 - fi - - if [[ -z "$MOLE_SELECTION_RESULT" ]]; then - echo "No apps selected" - return 1 - fi - - # Build selected apps array (global variable in bin/uninstall.sh) - selected_apps=() - - # Parse indices and build selected apps array - IFS=',' read -r -a indices_array <<< "$MOLE_SELECTION_RESULT" - - for idx in "${indices_array[@]}"; do - if [[ "$idx" =~ ^[0-9]+$ ]] && [[ $idx -ge 0 ]] && [[ $idx -lt ${#apps_data[@]} ]]; then - selected_apps+=("${apps_data[idx]}") - fi - done - - return 0 -} - -# Export function for external use -if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then - echo "This is a library file. Source it from other scripts." >&2 - exit 1 -fi diff --git a/lib/ui/menu_paginated.sh b/lib/ui/menu_paginated.sh deleted file mode 100755 index 9fd7400..0000000 --- a/lib/ui/menu_paginated.sh +++ /dev/null @@ -1,911 +0,0 @@ -#!/bin/bash -# Paginated menu with arrow key navigation - -set -euo pipefail - -# Terminal control functions -enter_alt_screen() { - if command -v tput > /dev/null 2>&1 && [[ -t 1 ]]; then - tput smcup 2> /dev/null || true - fi -} -leave_alt_screen() { - if command -v tput > /dev/null 2>&1 && [[ -t 1 ]]; then - tput rmcup 2> /dev/null || true - fi -} - -# Get terminal height with fallback -_pm_get_terminal_height() { - local height=0 - - # Try stty size first (most reliable, real-time) - # Use /dev/null | awk '{print $1}') - fi - - # Fallback to tput - if [[ -z "$height" || $height -le 0 ]]; then - if command -v tput > /dev/null 2>&1; then - height=$(tput lines 2> /dev/null || echo "24") - else - height=24 - fi - fi - - echo "$height" -} - -# Calculate dynamic items per page based on terminal height -_pm_calculate_items_per_page() { - local term_height=$(_pm_get_terminal_height) - # Reserved: header(1) + blank(1) + blank(1) + footer(1-2) = 4-5 rows - # Use 5 to be safe (leaves 1 row buffer when footer wraps to 2 lines) - local reserved=5 - local available=$((term_height - reserved)) - - # Ensure minimum and maximum bounds - if [[ $available -lt 1 ]]; then - echo 1 - elif [[ $available -gt 50 ]]; then - echo 50 - else - echo "$available" - fi -} - -# Parse CSV into newline list (Bash 3.2) -_pm_parse_csv_to_array() { - local csv="${1:-}" - if [[ -z "$csv" ]]; then - return 0 - fi - local IFS=',' - for _tok in $csv; do - printf "%s\n" "$_tok" - done -} - -# Main paginated multi-select menu function -paginated_multi_select() { - local title="$1" - shift - local -a items=("$@") - local external_alt_screen=false - if [[ "${MOLE_MANAGED_ALT_SCREEN:-}" == "1" || "${MOLE_MANAGED_ALT_SCREEN:-}" == "true" ]]; then - external_alt_screen=true - fi - - # Validation - if [[ ${#items[@]} -eq 0 ]]; then - echo "No items provided" >&2 - return 1 - fi - - local total_items=${#items[@]} - 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 - # sizekb[i] -> size in KB (numeric) for item i - local -a epochs=() - local -a sizekb=() - local has_metadata="false" - if [[ -n "${MOLE_MENU_META_EPOCHS:-}" ]]; then - while IFS= read -r v; do epochs+=("${v:-0}"); done < <(_pm_parse_csv_to_array "$MOLE_MENU_META_EPOCHS") - has_metadata="true" - fi - if [[ -n "${MOLE_MENU_META_SIZEKB:-}" ]]; then - while IFS= read -r v; do sizekb+=("${v:-0}"); done < <(_pm_parse_csv_to_array "$MOLE_MENU_META_SIZEKB") - has_metadata="true" - fi - - # If no metadata, force name sorting and disable sorting controls - if [[ "$has_metadata" == "false" && "$sort_mode" != "name" ]]; then - sort_mode="name" - fi - - # Index mappings - local -a orig_indices=() - local -a view_indices=() - local i - for ((i = 0; i < total_items; i++)); do - orig_indices[i]=$i - 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 - - # Initialize selection array - for ((i = 0; i < total_items; i++)); do - selected[i]=false - done - - if [[ -n "${MOLE_PRESELECTED_INDICES:-}" ]]; then - local cleaned_preselect="${MOLE_PRESELECTED_INDICES//[[:space:]]/}" - local -a initial_indices=() - IFS=',' read -ra initial_indices <<< "$cleaned_preselect" - for idx in "${initial_indices[@]}"; do - if [[ "$idx" =~ ^[0-9]+$ && $idx -ge 0 && $idx -lt $total_items ]]; then - # Only count if not already selected (handles duplicates) - if [[ ${selected[idx]} != true ]]; then - selected[idx]=true - ((selected_count++)) - fi - fi - done - fi - - # Preserve original TTY settings so we can restore them reliably - local original_stty="" - if [[ -t 0 ]] && command -v stty > /dev/null 2>&1; then - original_stty=$(stty -g 2> /dev/null || echo "") - fi - - restore_terminal() { - show_cursor - if [[ -n "${original_stty-}" ]]; then - stty "${original_stty}" 2> /dev/null || stty sane 2> /dev/null || stty echo icanon 2> /dev/null || true - else - stty sane 2> /dev/null || stty echo icanon 2> /dev/null || true - fi - if [[ "${external_alt_screen:-false}" == false ]]; then - leave_alt_screen - fi - } - - # Cleanup function - cleanup() { - trap - EXIT INT TERM - export MOLE_MENU_SORT_MODE="$sort_mode" - export MOLE_MENU_SORT_REVERSE="$sort_reverse" - restore_terminal - unset MOLE_READ_KEY_FORCE_CHAR - } - - # Interrupt handler - # shellcheck disable=SC2329 - handle_interrupt() { - cleanup - exit 130 # Standard exit code for Ctrl+C - } - - trap cleanup EXIT - trap handle_interrupt INT TERM - - # Setup terminal - preserve interrupt character - stty -echo -icanon intr ^C 2> /dev/null || true - if [[ $external_alt_screen == false ]]; then - enter_alt_screen - # Clear screen once on entry to alt screen - printf "\033[2J\033[H" >&2 - else - printf "\033[H" >&2 - fi - hide_cursor - - # Helper functions - # shellcheck disable=SC2329 - print_line() { printf "\r\033[2K%s\n" "$1" >&2; } - - # Print footer lines wrapping only at separators - _print_wrapped_controls() { - local sep="$1" - shift - local -a segs=("$@") - - local cols="${COLUMNS:-}" - [[ -z "$cols" ]] && cols=$(tput cols 2> /dev/null || echo 80) - [[ "$cols" =~ ^[0-9]+$ ]] || cols=80 - - _strip_ansi_len() { - local text="$1" - local stripped - stripped=$(printf "%s" "$text" | LC_ALL=C awk '{gsub(/\033\[[0-9;]*[A-Za-z]/,""); print}' || true) - [[ -z "$stripped" ]] && stripped="$text" - printf "%d" "${#stripped}" - } - - local line="" s candidate - local clear_line=$'\r\033[2K' - for s in "${segs[@]}"; do - if [[ -z "$line" ]]; then - candidate="$s" - else - candidate="$line${sep}${s}" - fi - local candidate_len - candidate_len=$(_strip_ansi_len "$candidate") - [[ -z "$candidate_len" ]] && candidate_len=0 - if ((candidate_len > cols)); then - printf "%s%s\n" "$clear_line" "$line" >&2 - line="$s" - else - line="$candidate" - fi - done - printf "%s%s\n" "$clear_line" "$line" >&2 - } - - # Rebuild the view_indices applying filter and 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 - view_indices=() - else - # Build sort key - local sort_key - if [[ "$sort_mode" == "date" ]]; then - # Date: ascending by default (oldest first) - sort_key="-k1,1n" - [[ "$sort_reverse" == "true" ]] && sort_key="-k1,1nr" - elif [[ "$sort_mode" == "size" ]]; then - # Size: descending by default (largest first) - sort_key="-k1,1nr" - [[ "$sort_reverse" == "true" ]] && sort_key="-k1,1n" - else - # Name: ascending by default (A to Z) - sort_key="-k1,1f" - [[ "$sort_reverse" == "true" ]] && sort_key="-k1,1fr" - fi - - # Create temporary file for sorting - local tmpfile - tmpfile=$(mktemp 2> /dev/null) || tmpfile="" - if [[ -n "$tmpfile" ]]; then - local k id - for id in "${filtered[@]}"; do - case "$sort_mode" in - date) k="${epochs[id]:-0}" ;; - size) k="${sizekb[id]:-0}" ;; - name | *) k="${items[id]}|${id}" ;; - esac - printf "%s\t%s\n" "$k" "$id" >> "$tmpfile" - done - - view_indices=() - while IFS=$'\t' read -r _key _id; do - [[ -z "$_id" ]] && continue - view_indices+=("$_id") - done < <(LC_ALL=C sort -t $'\t' $sort_key -- "$tmpfile" 2> /dev/null) - - rm -f "$tmpfile" - else - # Fallback: no sorting - view_indices=("${filtered[@]}") - fi - fi - - # Clamp cursor into visible range - local visible_count=${#view_indices[@]} - local max_top - if [[ $visible_count -gt $items_per_page ]]; then - max_top=$((visible_count - items_per_page)) - else - max_top=0 - fi - [[ $top_index -gt $max_top ]] && top_index=$max_top - local current_visible=$((visible_count - top_index)) - [[ $current_visible -gt $items_per_page ]] && current_visible=$items_per_page - if [[ $cursor_pos -ge $current_visible ]]; then - cursor_pos=$((current_visible > 0 ? current_visible - 1 : 0)) - fi - [[ $cursor_pos -lt 0 ]] && cursor_pos=0 - } - - # Initial view (default sort) - rebuild_view - - render_item() { - # $1: visible row index (0..items_per_page-1 in current window) - # $2: is_current flag - local vrow=$1 is_current=$2 - local idx=$((top_index + vrow)) - local real="${view_indices[idx]:--1}" - [[ $real -lt 0 ]] && return - local checkbox="$ICON_EMPTY" - [[ ${selected[real]} == true ]] && checkbox="$ICON_SOLID" - - if [[ $is_current == true ]]; then - printf "\r\033[2K${CYAN}${ICON_ARROW} %s %s${NC}\n" "$checkbox" "${items[real]}" >&2 - else - printf "\r\033[2K %s %s\n" "$checkbox" "${items[real]}" >&2 - fi - } - - # Draw the complete menu - draw_menu() { - # Recalculate items_per_page dynamically to handle window resize - items_per_page=$(_pm_calculate_items_per_page) - - printf "\033[H" >&2 - local clear_line="\r\033[2K" - - # Use cached selection count (maintained incrementally on toggle) - # No need to loop through all items anymore! - - # Header only - printf "${clear_line}${PURPLE_BOLD}%s${NC} ${GRAY}%d/%d selected${NC}\n" "${title}" "$selected_count" "$total_items" >&2 - - # 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 - fi - - local visible_count=$((visible_total - top_index)) - [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page - [[ $visible_count -le 0 ]] && visible_count=1 - if [[ $cursor_pos -ge $visible_count ]]; then - cursor_pos=$((visible_count - 1)) - [[ $cursor_pos -lt 0 ]] && cursor_pos=0 - fi - - printf "${clear_line}\n" >&2 - - # Items for current window - local start_idx=$top_index - local end_idx=$((top_index + items_per_page - 1)) - [[ $end_idx -ge $visible_total ]] && end_idx=$((visible_total - 1)) - - for ((i = start_idx; i <= end_idx; i++)); do - [[ $i -lt 0 ]] && continue - local is_current=false - [[ $((i - start_idx)) -eq $cursor_pos ]] && is_current=true - render_item $((i - start_idx)) $is_current - done - - # Fill empty slots to clear previous content - local items_shown=$((end_idx - start_idx + 1)) - [[ $items_shown -lt 0 ]] && items_shown=0 - for ((i = items_shown; i < items_per_page; i++)); do - printf "${clear_line}\n" >&2 - done - - printf "${clear_line}\n" >&2 - - # Build sort and filter status - local sort_label="" - case "$sort_mode" in - date) sort_label="Date" ;; - name) sort_label="Name" ;; - size) sort_label="Size" ;; - 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} " - - # Helper to calculate display length without ANSI codes - _calc_len() { - local text="$1" - local stripped - stripped=$(printf "%s" "$text" | LC_ALL=C awk '{gsub(/\033\[[0-9;]*[A-Za-z]/,""); print}') - printf "%d" "${#stripped}" - } - - # 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 filter_text="/ Search" - [[ -n "$applied_query" ]] && filter_text="/ Clear" - - 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 - 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 - - # 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 - - # 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 - - # 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[@]}" - fi - else - # Without metadata: basic controls - local -a _segs_simple=("$nav" "$space_select" "$enter" "$refresh" "$search" "$exit") - _print_wrapped_controls "$sep" "${_segs_simple[@]}" - fi - fi - printf "${clear_line}" >&2 - } - - # Track previous cursor position for incremental rendering - local prev_cursor_pos=$cursor_pos - local prev_top_index=$top_index - local need_full_redraw=true - - # Main interaction loop - while true; do - if [[ "$need_full_redraw" == "true" ]]; then - draw_menu - need_full_redraw=false - # Update tracking variables after full redraw - prev_cursor_pos=$cursor_pos - prev_top_index=$top_index - fi - - local key - key=$(read_key) - - case "$key" in - "QUIT") - if [[ "$filter_mode" == "true" ]]; then - filter_mode="false" - unset MOLE_READ_KEY_FORCE_CHAR - filter_query="" - applied_query="" - top_index=0 - cursor_pos=0 - rebuild_view - need_full_redraw=true - continue - fi - cleanup - return 1 - ;; - "UP") - if [[ ${#view_indices[@]} -eq 0 ]]; then - : - elif [[ $cursor_pos -gt 0 ]]; then - # Simple cursor move - only redraw affected rows - local old_cursor=$cursor_pos - ((cursor_pos--)) - local new_cursor=$cursor_pos - - # Calculate terminal row positions (+3: row 1=header, row 2=blank, row 3=first item) - local old_row=$((old_cursor + 3)) - local new_row=$((new_cursor + 3)) - - # Quick redraw: update only the two affected rows - printf "\033[%d;1H" "$old_row" >&2 - render_item "$old_cursor" false - printf "\033[%d;1H" "$new_row" >&2 - render_item "$new_cursor" true - - # CRITICAL: Move cursor to footer to avoid visual artifacts - printf "\033[%d;1H" "$((items_per_page + 4))" >&2 - - prev_cursor_pos=$cursor_pos - continue # Skip full redraw - elif [[ $top_index -gt 0 ]]; then - ((top_index--)) - prev_cursor_pos=$cursor_pos - prev_top_index=$top_index - need_full_redraw=true # Scrolling requires full redraw - fi - ;; - "DOWN") - if [[ ${#view_indices[@]} -eq 0 ]]; then - : - else - 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 - # Simple cursor move - only redraw affected rows - local old_cursor=$cursor_pos - ((cursor_pos++)) - local new_cursor=$cursor_pos - - # Calculate terminal row positions (+3: row 1=header, row 2=blank, row 3=first item) - local old_row=$((old_cursor + 3)) - local new_row=$((new_cursor + 3)) - - # Quick redraw: update only the two affected rows - printf "\033[%d;1H" "$old_row" >&2 - render_item "$old_cursor" false - printf "\033[%d;1H" "$new_row" >&2 - render_item "$new_cursor" true - - # CRITICAL: Move cursor to footer to avoid visual artifacts - printf "\033[%d;1H" "$((items_per_page + 4))" >&2 - - prev_cursor_pos=$cursor_pos - continue # Skip full redraw - elif [[ $((top_index + visible_count)) -lt ${#view_indices[@]} ]]; then - ((top_index++)) - visible_count=$((${#view_indices[@]} - top_index)) - [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page - if [[ $cursor_pos -ge $visible_count ]]; then - cursor_pos=$((visible_count - 1)) - fi - prev_cursor_pos=$cursor_pos - prev_top_index=$top_index - need_full_redraw=true # Scrolling requires full redraw - fi - fi - fi - ;; - "SPACE") - local idx=$((top_index + cursor_pos)) - if [[ $idx -lt ${#view_indices[@]} ]]; then - local real="${view_indices[idx]}" - if [[ ${selected[real]} == true ]]; then - selected[real]=false - ((selected_count--)) - else - selected[real]=true - ((selected_count++)) - fi - - # Incremental update: only redraw header (for count) and current row - # Header is at row 1 - printf "\033[1;1H\033[2K${PURPLE_BOLD}%s${NC} ${GRAY}%d/%d selected${NC}\n" "${title}" "$selected_count" "$total_items" >&2 - - # Redraw current item row (+3: row 1=header, row 2=blank, row 3=first item) - local item_row=$((cursor_pos + 3)) - printf "\033[%d;1H" "$item_row" >&2 - render_item "$cursor_pos" true - - # Move cursor to footer to avoid visual artifacts (items + header + 2 blanks) - printf "\033[%d;1H" "$((items_per_page + 4))" >&2 - - continue # Skip full redraw - fi - ;; - "RETRY") - # 'R' toggles reverse order (only if metadata available) - if [[ "$has_metadata" == "true" ]]; then - if [[ "$sort_reverse" == "true" ]]; then - sort_reverse="false" - else - sort_reverse="true" - fi - rebuild_view - need_full_redraw=true - fi - ;; - "CHAR:s" | "CHAR:S") - if [[ "$filter_mode" == "true" ]]; then - local ch="${key#CHAR:}" - filter_query+="$ch" - need_full_redraw=true - elif [[ "$has_metadata" == "true" ]]; then - # Cycle sort mode (only if metadata available) - case "$sort_mode" in - date) sort_mode="name" ;; - name) sort_mode="size" ;; - size) sort_mode="date" ;; - esac - rebuild_view - 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" - export MOLE_READ_KEY_FORCE_CHAR=1 - 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 - fi - fi - else - filter_query+="j" - 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 - fi - else - filter_query+="k" - fi - ;; - "CHAR:f" | "CHAR:F") - if [[ "$filter_mode" == "true" ]]; then - filter_query+="${key#CHAR:}" - 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:}" - else - # Trigger Refresh signal (Unified with Analyze) - cleanup - return 10 - fi - ;; - "CHAR:o" | "CHAR:O") - if [[ "$filter_mode" == "true" ]]; then - filter_query+="${key#CHAR:}" - elif [[ "$has_metadata" == "true" ]]; then - # O toggles reverse order (Unified Sort Order) - if [[ "$sort_reverse" == "true" ]]; then - sort_reverse="false" - else - sort_reverse="true" - fi - rebuild_view - need_full_redraw=true - fi - ;; - "DELETE") - # Backspace filter - if [[ "$filter_mode" == "true" && -n "$filter_query" ]]; then - filter_query="${filter_query%?}" - need_full_redraw=true - fi - ;; - CHAR:*) - if [[ "$filter_mode" == "true" ]]; then - local ch="${key#CHAR:}" - # avoid accidental leading spaces - if [[ -n "$filter_query" || "$ch" != " " ]]; then - filter_query+="$ch" - need_full_redraw=true - fi - fi - ;; - "ENTER") - if [[ "$filter_mode" == "true" ]]; then - applied_query="$filter_query" - filter_mode="false" - unset MOLE_READ_KEY_FORCE_CHAR - top_index=0 - cursor_pos=0 - - searching="true" - draw_menu # paint "searching..." - drain_pending_input # drop any extra keypresses (e.g., double-Enter) - rebuild_view - searching="false" - draw_menu - continue - fi - # In normal mode: smart Enter behavior - # 1. Check if any items are already selected - local has_selection=false - for ((i = 0; i < total_items; i++)); do - if [[ ${selected[i]} == true ]]; then - has_selection=true - break - fi - done - - # 2. If nothing selected, auto-select current item - if [[ $has_selection == false ]]; then - local idx=$((top_index + cursor_pos)) - if [[ $idx -lt ${#view_indices[@]} ]]; then - local real="${view_indices[idx]}" - selected[real]=true - ((selected_count++)) - fi - fi - - # 3. Confirm and exit with current selections - local -a selected_indices=() - for ((i = 0; i < total_items; i++)); do - if [[ ${selected[i]} == true ]]; then - selected_indices+=("$i") - fi - done - - local final_result="" - if [[ ${#selected_indices[@]} -gt 0 ]]; then - local IFS=',' - final_result="${selected_indices[*]}" - fi - - trap - EXIT INT TERM - MOLE_SELECTION_RESULT="$final_result" - export MOLE_MENU_SORT_MODE="$sort_mode" - export MOLE_MENU_SORT_REVERSE="$sort_reverse" - restore_terminal - return 0 - ;; - esac - - # Drain any accumulated input after processing (e.g., mouse wheel events) - # This prevents buffered events from causing jumps, without blocking keyboard input - drain_pending_input - done -} - -# Export function for external use -if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then - echo "This is a library file. Source it from other scripts." >&2 - exit 1 -fi diff --git a/lib/ui/menu_simple.sh b/lib/ui/menu_simple.sh deleted file mode 100755 index f384024..0000000 --- a/lib/ui/menu_simple.sh +++ /dev/null @@ -1,318 +0,0 @@ -#!/bin/bash -# Paginated menu with arrow key navigation - -set -euo pipefail - -# Terminal control functions -enter_alt_screen() { tput smcup 2> /dev/null || true; } -leave_alt_screen() { tput rmcup 2> /dev/null || true; } - -# Get terminal height with fallback -_ms_get_terminal_height() { - local height=0 - - # Try stty size first (most reliable, real-time) - # Use /dev/null | awk '{print $1}') - fi - - # Fallback to tput - if [[ -z "$height" || $height -le 0 ]]; then - if command -v tput > /dev/null 2>&1; then - height=$(tput lines 2> /dev/null || echo "24") - else - height=24 - fi - fi - - echo "$height" -} - -# Calculate dynamic items per page based on terminal height -_ms_calculate_items_per_page() { - local term_height=$(_ms_get_terminal_height) - # Layout: header(1) + spacing(1) + items + spacing(1) + footer(1) + clear(1) = 5 fixed lines - local reserved=6 # Increased to prevent header from being overwritten - local available=$((term_height - reserved)) - - # Ensure minimum and maximum bounds - if [[ $available -lt 1 ]]; then - echo 1 - elif [[ $available -gt 50 ]]; then - echo 50 - else - echo "$available" - fi -} - -# Main paginated multi-select menu function -paginated_multi_select() { - local title="$1" - shift - local -a items=("$@") - local external_alt_screen=false - if [[ "${MOLE_MANAGED_ALT_SCREEN:-}" == "1" || "${MOLE_MANAGED_ALT_SCREEN:-}" == "true" ]]; then - external_alt_screen=true - fi - - # Validation - if [[ ${#items[@]} -eq 0 ]]; then - echo "No items provided" >&2 - return 1 - fi - - local total_items=${#items[@]} - local items_per_page=$(_ms_calculate_items_per_page) - local cursor_pos=0 - local top_index=0 - local -a selected=() - - # Initialize selection array - for ((i = 0; i < total_items; i++)); do - selected[i]=false - done - - if [[ -n "${MOLE_PRESELECTED_INDICES:-}" ]]; then - local cleaned_preselect="${MOLE_PRESELECTED_INDICES//[[:space:]]/}" - local -a initial_indices=() - IFS=',' read -ra initial_indices <<< "$cleaned_preselect" - for idx in "${initial_indices[@]}"; do - if [[ "$idx" =~ ^[0-9]+$ && $idx -ge 0 && $idx -lt $total_items ]]; then - selected[idx]=true - fi - done - fi - - # Preserve original TTY settings so we can restore them reliably - local original_stty="" - if [[ -t 0 ]] && command -v stty > /dev/null 2>&1; then - original_stty=$(stty -g 2> /dev/null || echo "") - fi - - restore_terminal() { - show_cursor - if [[ -n "${original_stty-}" ]]; then - stty "${original_stty}" 2> /dev/null || stty sane 2> /dev/null || stty echo icanon 2> /dev/null || true - else - stty sane 2> /dev/null || stty echo icanon 2> /dev/null || true - fi - if [[ "${external_alt_screen:-false}" == false ]]; then - leave_alt_screen - fi - } - - # Cleanup function - cleanup() { - trap - EXIT INT TERM - restore_terminal - } - - # Interrupt handler - # shellcheck disable=SC2329 - handle_interrupt() { - cleanup - exit 130 # Standard exit code for Ctrl+C - } - - trap cleanup EXIT - trap handle_interrupt INT TERM - - # Setup terminal - preserve interrupt character - stty -echo -icanon intr ^C 2> /dev/null || true - if [[ $external_alt_screen == false ]]; then - enter_alt_screen - # Clear screen once on entry to alt screen - printf "\033[2J\033[H" >&2 - else - printf "\033[H" >&2 - fi - hide_cursor - - # Helper functions - # shellcheck disable=SC2329 - print_line() { printf "\r\033[2K%s\n" "$1" >&2; } - - render_item() { - local idx=$1 is_current=$2 - local checkbox="$ICON_EMPTY" - [[ ${selected[idx]} == true ]] && checkbox="$ICON_SOLID" - - if [[ $is_current == true ]]; then - printf "\r\033[2K${CYAN}${ICON_ARROW} %s %s${NC}\n" "$checkbox" "${items[idx]}" >&2 - else - printf "\r\033[2K %s %s\n" "$checkbox" "${items[idx]}" >&2 - fi - } - - # Draw the complete menu - draw_menu() { - # Recalculate items_per_page dynamically to handle window resize - items_per_page=$(_ms_calculate_items_per_page) - - # Move to home position without clearing (reduces flicker) - printf "\033[H" >&2 - - # Clear each line as we go instead of clearing entire screen - local clear_line="\r\033[2K" - - # Count selections for header display - local selected_count=0 - for ((i = 0; i < total_items; i++)); do - [[ ${selected[i]} == true ]] && ((selected_count++)) - done - - # Header - printf "${clear_line}${PURPLE_BOLD}%s${NC} ${GRAY}%d/%d selected${NC}\n" "${title}" "$selected_count" "$total_items" >&2 - - if [[ $total_items -eq 0 ]]; then - printf "${clear_line}${GRAY}No items available${NC}\n" >&2 - printf "${clear_line}\n" >&2 - printf "${clear_line}${GRAY}Q${NC} Quit\n" >&2 - printf "${clear_line}" >&2 - return - fi - - if [[ $top_index -gt $((total_items - 1)) ]]; then - if [[ $total_items -gt $items_per_page ]]; then - top_index=$((total_items - items_per_page)) - else - top_index=0 - fi - fi - - local visible_count=$((total_items - top_index)) - [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page - [[ $visible_count -le 0 ]] && visible_count=1 - if [[ $cursor_pos -ge $visible_count ]]; then - cursor_pos=$((visible_count - 1)) - [[ $cursor_pos -lt 0 ]] && cursor_pos=0 - fi - - printf "${clear_line}\n" >&2 - - # Items for current window - local start_idx=$top_index - local end_idx=$((top_index + items_per_page - 1)) - [[ $end_idx -ge $total_items ]] && end_idx=$((total_items - 1)) - - for ((i = start_idx; i <= end_idx; i++)); do - [[ $i -lt 0 ]] && continue - local is_current=false - [[ $((i - start_idx)) -eq $cursor_pos ]] && is_current=true - render_item $i $is_current - done - - # Fill empty slots to clear previous content - local items_shown=$((end_idx - start_idx + 1)) - [[ $items_shown -lt 0 ]] && items_shown=0 - for ((i = items_shown; i < items_per_page; i++)); do - printf "${clear_line}\n" >&2 - done - - # Clear any remaining lines at bottom - printf "${clear_line}\n" >&2 - printf "${clear_line}${GRAY}${ICON_NAV_UP}${ICON_NAV_DOWN} | Space | Enter | Q Exit${NC}\n" >&2 - - # Clear one more line to ensure no artifacts - printf "${clear_line}" >&2 - } - - # Main interaction loop - while true; do - draw_menu - local key=$(read_key) - - case "$key" in - "QUIT") - cleanup - return 1 - ;; - "UP") - if [[ $total_items -eq 0 ]]; then - : - elif [[ $cursor_pos -gt 0 ]]; then - ((cursor_pos--)) - elif [[ $top_index -gt 0 ]]; then - ((top_index--)) - fi - ;; - "DOWN") - if [[ $total_items -eq 0 ]]; then - : - else - local absolute_index=$((top_index + cursor_pos)) - if [[ $absolute_index -lt $((total_items - 1)) ]]; then - local visible_count=$((total_items - top_index)) - [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page - - if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then - ((cursor_pos++)) - elif [[ $((top_index + visible_count)) -lt $total_items ]]; then - ((top_index++)) - visible_count=$((total_items - top_index)) - [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page - if [[ $cursor_pos -ge $visible_count ]]; then - cursor_pos=$((visible_count - 1)) - fi - fi - fi - fi - ;; - "SPACE") - local idx=$((top_index + cursor_pos)) - if [[ $idx -lt $total_items ]]; then - if [[ ${selected[idx]} == true ]]; then - selected[idx]=false - else - selected[idx]=true - fi - fi - ;; - "ALL") - for ((i = 0; i < total_items; i++)); do - selected[i]=true - done - ;; - "NONE") - for ((i = 0; i < total_items; i++)); do - selected[i]=false - done - ;; - "ENTER") - # Store result in global variable instead of returning via stdout - local -a selected_indices=() - for ((i = 0; i < total_items; i++)); do - if [[ ${selected[i]} == true ]]; then - selected_indices+=("$i") - fi - done - - # Allow empty selection - don't auto-select cursor position - # This fixes the bug where unselecting all items would still select the last cursor position - local final_result="" - if [[ ${#selected_indices[@]} -gt 0 ]]; then - local IFS=',' - final_result="${selected_indices[*]}" - fi - - # Remove the trap to avoid cleanup on normal exit - trap - EXIT INT TERM - - # Store result in global variable - MOLE_SELECTION_RESULT="$final_result" - - # Manually cleanup terminal before returning - restore_terminal - - return 0 - ;; - esac - done -} - -# Export function for external use -if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then - echo "This is a library file. Source it from other scripts." >&2 - exit 1 -fi diff --git a/lib/uninstall/batch.sh b/lib/uninstall/batch.sh deleted file mode 100755 index 0d65df3..0000000 --- a/lib/uninstall/batch.sh +++ /dev/null @@ -1,492 +0,0 @@ -#!/bin/bash - -set -euo pipefail - -# Ensure common.sh is loaded. -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -[[ -z "${MOLE_COMMON_LOADED:-}" ]] && source "$SCRIPT_DIR/lib/core/common.sh" - -# 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) -) - -# Join patterns into a single regex for grep. -SENSITIVE_DATA_REGEX=$( - IFS='|' - echo "${SENSITIVE_DATA_PATTERNS[*]}" -) - -# Decode and validate base64 file list (safe for set -e). -decode_file_list() { - local encoded="$1" - local app_name="$2" - local decoded - - # macOS uses -D, GNU uses -d. Always return 0 for set -e safety. - if ! decoded=$(printf '%s' "$encoded" | base64 -D 2> /dev/null); then - if ! decoded=$(printf '%s' "$encoded" | base64 -d 2> /dev/null); then - log_error "Failed to decode file list for $app_name" >&2 - echo "" - return 0 # Return success with empty string - fi - fi - - if [[ "$decoded" =~ $'\0' ]]; then - log_warning "File list for $app_name contains null bytes, rejecting" >&2 - echo "" - return 0 # Return success with empty string - fi - - while IFS= read -r line; do - if [[ -n "$line" && ! "$line" =~ ^/ ]]; then - log_warning "Invalid path in file list for $app_name: $line" >&2 - echo "" - return 0 # Return success with empty string - fi - done <<< "$decoded" - - echo "$decoded" - return 0 -} -# Note: find_app_files() and calculate_total_size() are in lib/core/common.sh. - -# Stop Launch Agents/Daemons for an app. -stop_launch_services() { - local bundle_id="$1" - local has_system_files="${2:-false}" - - [[ -z "$bundle_id" || "$bundle_id" == "unknown" ]] && return 0 - - if [[ -d ~/Library/LaunchAgents ]]; then - while IFS= read -r -d '' plist; do - launchctl unload "$plist" 2> /dev/null || true - done < <(find ~/Library/LaunchAgents -maxdepth 1 -name "${bundle_id}*.plist" -print0 2> /dev/null) - fi - - if [[ "$has_system_files" == "true" ]]; then - if [[ -d /Library/LaunchAgents ]]; then - while IFS= read -r -d '' plist; do - sudo launchctl unload "$plist" 2> /dev/null || true - done < <(find /Library/LaunchAgents -maxdepth 1 -name "${bundle_id}*.plist" -print0 2> /dev/null) - fi - if [[ -d /Library/LaunchDaemons ]]; then - while IFS= read -r -d '' plist; do - sudo launchctl unload "$plist" 2> /dev/null || true - done < <(find /Library/LaunchDaemons -maxdepth 1 -name "${bundle_id}*.plist" -print0 2> /dev/null) - fi - fi -} - -# Remove files (handles symlinks, optional sudo). -remove_file_list() { - local file_list="$1" - local use_sudo="${2:-false}" - local count=0 - - while IFS= read -r file; do - [[ -n "$file" && -e "$file" ]] || continue - - if [[ -L "$file" ]]; then - if [[ "$use_sudo" == "true" ]]; then - sudo rm "$file" 2> /dev/null && ((count++)) || true - else - rm "$file" 2> /dev/null && ((count++)) || true - fi - else - if [[ "$use_sudo" == "true" ]]; then - safe_sudo_remove "$file" && ((count++)) || true - else - safe_remove "$file" true && ((count++)) || true - fi - fi - done <<< "$file_list" - - echo "$count" -} - -# Batch uninstall with single confirmation. -batch_uninstall_applications() { - local total_size_freed=0 - - # shellcheck disable=SC2154 - if [[ ${#selected_apps[@]} -eq 0 ]]; then - log_warning "No applications selected for uninstallation" - return 0 - fi - - # Pre-scan: running apps, sudo needs, size. - local -a running_apps=() - local -a sudo_apps=() - local total_estimated_size=0 - local -a app_details=() - - 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. - 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 "") - fi - local check_pattern="${exec_name:-$app_name}" - if pgrep -x "$check_pattern" > /dev/null 2>&1; then - running_apps+=("$app_name") - fi - - # Sudo needed if bundle owner/dir is not writable or system files exist. - 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 - needs_sudo=true - fi - - # Size estimate includes related and system files. - local app_size_kb=$(get_path_size_kb "$app_path") - local related_files=$(find_app_files "$bundle_id" "$app_name") - local related_size_kb=$(calculate_total_size "$related_files") - # system_files is a newline-separated string, not an array. - # shellcheck disable=SC2178,SC2128 - local system_files=$(find_app_system_files "$bundle_id" "$app_name") - # shellcheck disable=SC2128 - local system_size_kb=$(calculate_total_size "$system_files") - local total_kb=$((app_size_kb + related_size_kb + system_size_kb)) - ((total_estimated_size += total_kb)) - - # shellcheck disable=SC2128 - if [[ -n "$system_files" ]]; then - needs_sudo=true - fi - - if [[ "$needs_sudo" == "true" ]]; then - sudo_apps+=("$app_name") - fi - - # Check for sensitive user data once. - local has_sensitive_data="false" - if [[ -n "$related_files" ]] && echo "$related_files" | grep -qE "$SENSITIVE_DATA_REGEX"; then - has_sensitive_data="true" - fi - - # Store details for later use (base64 keeps lists on one line). - local encoded_files - encoded_files=$(printf '%s' "$related_files" | base64 | tr -d '\n') - local encoded_system_files - encoded_system_files=$(printf '%s' "$system_files" | base64 | tr -d '\n') - app_details+=("$app_name|$app_path|$bundle_id|$total_kb|$encoded_files|$encoded_system_files|$has_sensitive_data|$needs_sudo") - done - if [[ -t 1 ]]; then stop_inline_spinner; fi - - local size_display=$(bytes_to_human "$((total_estimated_size * 1024))") - - echo "" - echo -e "${PURPLE_BOLD}Files to be removed:${NC}" - echo "" - - # Warn if user data is detected. - local has_user_data=false - for detail in "${app_details[@]}"; do - IFS='|' read -r _ _ _ _ _ _ has_sensitive_data <<< "$detail" - if [[ "$has_sensitive_data" == "true" ]]; then - has_user_data=true - break - fi - done - - if [[ "$has_user_data" == "true" ]]; then - echo -e "${YELLOW}${ICON_WARNING}${NC} ${YELLOW}Note: Some apps contain user configurations/themes${NC}" - echo "" - fi - - for detail in "${app_details[@]}"; do - IFS='|' read -r app_name app_path bundle_id total_kb encoded_files encoded_system_files has_sensitive_data needs_sudo_flag <<< "$detail" - local related_files=$(decode_file_list "$encoded_files" "$app_name") - local system_files=$(decode_file_list "$encoded_system_files" "$app_name") - local app_size_display=$(bytes_to_human "$((total_kb * 1024))") - - echo -e "${BLUE}${ICON_CONFIRM}${NC} ${app_name} ${GRAY}(${app_size_display})${NC}" - echo -e " ${GREEN}${ICON_SUCCESS}${NC} ${app_path/$HOME/~}" - - # Show related files (limit to 5). - local file_count=0 - local max_files=5 - while IFS= read -r file; do - if [[ -n "$file" && -e "$file" ]]; then - if [[ $file_count -lt $max_files ]]; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} ${file/$HOME/~}" - fi - ((file_count++)) - fi - done <<< "$related_files" - - # Show system files (limit to 5). - local sys_file_count=0 - 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" - fi - ((sys_file_count++)) - fi - done <<< "$system_files" - - local total_hidden=$((file_count > max_files ? file_count - max_files : 0)) - ((total_hidden += sys_file_count > max_files ? sys_file_count - max_files : 0)) - if [[ $total_hidden -gt 0 ]]; then - echo -e " ${GRAY} ... and ${total_hidden} more files${NC}" - fi - done - - # Confirmation before requesting sudo. - local app_total=${#selected_apps[@]} - local app_text="app" - [[ $app_total -gt 1 ]] && app_text="apps" - - echo "" - local removal_note="Remove ${app_total} ${app_text}" - [[ -n "$size_display" ]] && removal_note+=" (${size_display})" - if [[ ${#running_apps[@]} -gt 0 ]]; then - removal_note+=" ${YELLOW}[Running]${NC}" - fi - echo -ne "${PURPLE}${ICON_ARROW}${NC} ${removal_note} ${GREEN}Enter${NC} confirm, ${GRAY}ESC${NC} cancel: " - - drain_pending_input # Clean up any pending input before confirmation - IFS= read -r -s -n1 key || key="" - drain_pending_input # Clean up any escape sequence remnants - case "$key" in - $'\e' | q | Q) - echo "" - echo "" - return 0 - ;; - "" | $'\n' | $'\r' | y | Y) - printf "\r\033[K" # Clear the prompt line - ;; - *) - echo "" - echo "" - return 0 - ;; - esac - - # 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" - return 1 - fi - fi - # Keep sudo alive during uninstall. - parent_pid=$$ - (while true; do - if ! kill -0 "$parent_pid" 2> /dev/null; then - exit 0 - fi - sudo -n true - sleep 60 - done 2> /dev/null) & - sudo_keepalive_pid=$! - fi - - if [[ -t 1 ]]; then start_inline_spinner "Uninstalling apps..."; fi - - # Perform uninstallations (silent mode, show results at end). - if [[ -t 1 ]]; then stop_inline_spinner; fi - local success_count=0 failed_count=0 - local -a failed_items=() - local -a success_items=() - for detail in "${app_details[@]}"; do - IFS='|' read -r app_name app_path bundle_id total_kb encoded_files encoded_system_files has_sensitive_data needs_sudo <<< "$detail" - local related_files=$(decode_file_list "$encoded_files" "$app_name") - local system_files=$(decode_file_list "$encoded_system_files" "$app_name") - local reason="" - - # Stop Launch Agents/Daemons before removal. - local has_system_files="false" - [[ -n "$system_files" ]] && has_system_files="true" - stop_launch_services "$bundle_id" "$has_system_files" - - if ! force_kill_app "$app_name" "$app_path"; then - reason="still running" - fi - - # Remove the application only if not running. - if [[ -z "$reason" ]]; then - if [[ "$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" - else - reason="permission denied" - fi - fi - else - safe_remove "$app_path" true || reason="remove failed" - fi - fi - - # Remove related files if app removal succeeded. - if [[ -z "$reason" ]]; then - remove_file_list "$related_files" "false" > /dev/null - remove_file_list "$system_files" "true" > /dev/null - - # Clean up macOS defaults (preference domains). - if [[ -n "$bundle_id" && "$bundle_id" != "unknown" ]]; then - if defaults read "$bundle_id" &> /dev/null; then - defaults delete "$bundle_id" 2> /dev/null || true - 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 - fi - fi - - ((total_size_freed += total_kb)) - ((success_count++)) - ((files_cleaned++)) - ((total_items++)) - success_items+=("$app_name") - else - ((failed_count++)) - failed_items+=("$app_name:$reason") - fi - done - - # Summary - local freed_display - freed_display=$(bytes_to_human "$((total_size_freed * 1024))") - - local summary_status="success" - local -a summary_details=() - - if [[ $success_count -gt 0 ]]; then - local success_list="${success_items[*]}" - local success_text="app" - [[ $success_count -gt 1 ]] && success_text="apps" - local success_line="Removed ${success_count} ${success_text}" - if [[ -n "$freed_display" ]]; then - success_line+=", freed ${GREEN}${freed_display}${NC}" - fi - - # Format app list with max 3 per line. - if [[ -n "$success_list" ]]; then - local idx=0 - local is_first_line=true - local current_line="" - - for app_name in "${success_items[@]}"; do - local display_item="${GREEN}${app_name}${NC}" - - if ((idx % 3 == 0)); then - if [[ -n "$current_line" ]]; then - summary_details+=("$current_line") - fi - if [[ "$is_first_line" == true ]]; then - current_line="${success_line}: $display_item" - is_first_line=false - else - current_line="$display_item" - fi - else - current_line="$current_line, $display_item" - fi - ((idx++)) - done - if [[ -n "$current_line" ]]; then - summary_details+=("$current_line") - fi - else - summary_details+=("$success_line") - fi - fi - - if [[ $failed_count -gt 0 ]]; then - summary_status="warn" - - local failed_names=() - for item in "${failed_items[@]}"; do - local name=${item%%:*} - failed_names+=("$name") - done - local failed_list="${failed_names[*]}" - - local reason_summary="could not be removed" - if [[ $failed_count -eq 1 ]]; then - local first_reason=${failed_items[0]#*:} - case "$first_reason" in - 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)" ;; - *) reason_summary="$first_reason" ;; - esac - fi - summary_details+=("Failed: ${RED}${failed_list}${NC} ${reason_summary}") - fi - - if [[ $success_count -eq 0 && $failed_count -eq 0 ]]; then - summary_status="info" - summary_details+=("No applications were uninstalled.") - fi - - local title="Uninstall complete" - if [[ "$summary_status" == "warn" ]]; then - title="Uninstall incomplete" - fi - - print_summary_block "$title" "${summary_details[@]}" - printf '\n' - - # Clean up Dock entries for uninstalled apps. - if [[ $success_count -gt 0 ]]; then - local -a removed_paths=() - for detail in "${app_details[@]}"; do - IFS='|' read -r app_name app_path _ _ _ _ <<< "$detail" - for success_name in "${success_items[@]}"; do - if [[ "$success_name" == "$app_name" ]]; then - removed_paths+=("$app_path") - break - fi - done - done - if [[ ${#removed_paths[@]} -gt 0 ]]; then - remove_apps_from_dock "${removed_paths[@]}" 2> /dev/null || true - fi - fi - - # Clean up sudo keepalive if it was started. - if [[ -n "${sudo_keepalive_pid:-}" ]]; then - kill "$sudo_keepalive_pid" 2> /dev/null || true - wait "$sudo_keepalive_pid" 2> /dev/null || true - sudo_keepalive_pid="" - fi - - # 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 - - ((total_size_cleaned += total_size_freed)) - unset failed_items -} diff --git a/mo b/mo deleted file mode 100755 index 890dab7..0000000 --- a/mo +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash -# Lightweight alias to run Mole via `mo` - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -exec "$SCRIPT_DIR/mole" "$@" diff --git a/mole b/mole deleted file mode 100755 index 398bf72..0000000 --- a/mole +++ /dev/null @@ -1,787 +0,0 @@ -#!/bin/bash -# Mole - Main CLI entrypoint. -# Routes subcommands and interactive menu. -# Handles update/remove flows. - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -source "$SCRIPT_DIR/lib/core/common.sh" -source "$SCRIPT_DIR/lib/core/commands.sh" - -trap cleanup_temp_files EXIT INT TERM - -# Version and update helpers -VERSION="1.20.0" -MOLE_TAGLINE="Deep clean and optimize your Mac." - -is_touchid_configured() { - local pam_sudo_file="/etc/pam.d/sudo" - [[ -f "$pam_sudo_file" ]] && grep -q "pam_tid.so" "$pam_sudo_file" 2> /dev/null -} - -get_latest_version() { - curl -fsSL --connect-timeout 2 --max-time 3 -H "Cache-Control: no-cache" \ - "https://raw.githubusercontent.com/tw93/mole/main/mole" 2> /dev/null | - grep '^VERSION=' | head -1 | sed 's/VERSION="\(.*\)"/\1/' -} - -get_latest_version_from_github() { - local version - version=$(curl -fsSL --connect-timeout 2 --max-time 3 \ - "https://api.github.com/repos/tw93/mole/releases/latest" 2> /dev/null | - grep '"tag_name"' | head -1 | sed -E 's/.*"([^"]+)".*/\1/') - version="${version#v}" - version="${version#V}" - echo "$version" -} - -# Install detection (Homebrew vs manual). -is_homebrew_install() { - local mole_path - mole_path=$(command -v mole 2> /dev/null) || return 1 - - if [[ -L "$mole_path" ]] && readlink "$mole_path" | grep -q "Cellar/mole"; then - if command -v brew > /dev/null 2>&1; then - brew list --formula 2> /dev/null | grep -q "^mole$" && return 0 - else - return 1 - fi - fi - - if [[ -f "$mole_path" ]]; then - case "$mole_path" in - /opt/homebrew/bin/mole | /usr/local/bin/mole) - if [[ -d /opt/homebrew/Cellar/mole ]] || [[ -d /usr/local/Cellar/mole ]]; then - if command -v brew > /dev/null 2>&1; then - brew list --formula 2> /dev/null | grep -q "^mole$" && return 0 - else - return 0 # Cellar exists, probably Homebrew install - fi - fi - ;; - esac - fi - - if command -v brew > /dev/null 2>&1; then - local brew_prefix - brew_prefix=$(brew --prefix 2> /dev/null) - if [[ -n "$brew_prefix" && "$mole_path" == "$brew_prefix/bin/mole" && -d "$brew_prefix/Cellar/mole" ]]; then - brew list --formula 2> /dev/null | grep -q "^mole$" && return 0 - fi - fi - - return 1 -} - -# Background update notice -check_for_updates() { - local msg_cache="$HOME/.cache/mole/update_message" - ensure_user_dir "$(dirname "$msg_cache")" - ensure_user_file "$msg_cache" - - ( - local latest - - latest=$(get_latest_version_from_github) - if [[ -z "$latest" ]]; then - latest=$(get_latest_version) - fi - - if [[ -n "$latest" && "$VERSION" != "$latest" && "$(printf '%s\n' "$VERSION" "$latest" | sort -V | head -1)" == "$VERSION" ]]; then - printf "\nUpdate available: %s → %s, run %smo update%s\n\n" "$VERSION" "$latest" "$GREEN" "$NC" > "$msg_cache" - else - echo -n > "$msg_cache" - fi - ) & - disown 2> /dev/null || true -} - -show_update_notification() { - local msg_cache="$HOME/.cache/mole/update_message" - if [[ -f "$msg_cache" && -s "$msg_cache" ]]; then - cat "$msg_cache" - echo - fi -} - -# UI helpers -show_brand_banner() { - cat << EOF -${GREEN} __ __ _ ${NC} -${GREEN}| \/ | ___ | | ___ ${NC} -${GREEN}| |\/| |/ _ \| |/ _ \\${NC} -${GREEN}| | | | (_) | | __/${NC} ${BLUE}https://github.com/tw93/mole${NC} -${GREEN}|_| |_|\___/|_|\___|${NC} ${GREEN}${MOLE_TAGLINE}${NC} - -EOF -} - -animate_mole_intro() { - if [[ ! -t 1 ]]; then - return - fi - - clear_screen - printf '\n' - hide_cursor - - local -a mole_lines=() - - while IFS= read -r line; do - mole_lines+=("$line") - done << 'EOF' - /\_/\ - ____/ o o \ - /~____ =o= / -(______)__m_m) - / \ - __/ /\ \__ - /__/ \__\_ -EOF - - local idx - local body_cutoff=4 - local body_color="${PURPLE}" - local ground_color="${GREEN}" - - for idx in "${!mole_lines[@]}"; do - if ((idx < body_cutoff)); then - printf "%s\n" "${body_color}${mole_lines[$idx]}${NC}" - else - printf "%s\n" "${ground_color}${mole_lines[$idx]}${NC}" - fi - sleep 0.1 - done - - printf '\n' - sleep 0.5 - - printf '\033[2J\033[H' - show_cursor -} - -show_version() { - local os_ver - if command -v sw_vers > /dev/null; then - os_ver=$(sw_vers -productVersion) - else - os_ver="Unknown" - fi - - local arch - arch=$(uname -m) - - local kernel - kernel=$(uname -r) - - local sip_status - if command -v csrutil > /dev/null; then - sip_status=$(csrutil status 2> /dev/null | grep -o "enabled\|disabled" || echo "Unknown") - sip_status="$(LC_ALL=C tr '[:lower:]' '[:upper:]' <<< "${sip_status:0:1}")${sip_status:1}" - else - sip_status="Unknown" - fi - - local disk_free - disk_free=$(df -h / 2> /dev/null | awk 'NR==2 {print $4}' || echo "Unknown") - - local install_method="Manual" - if is_homebrew_install; then - install_method="Homebrew" - fi - - printf '\nMole version %s\n' "$VERSION" - printf 'macOS: %s\n' "$os_ver" - printf 'Architecture: %s\n' "$arch" - printf 'Kernel: %s\n' "$kernel" - printf 'SIP: %s\n' "$sip_status" - printf 'Disk Free: %s\n' "$disk_free" - printf 'Install: %s\n' "$install_method" - printf 'Shell: %s\n\n' "${SHELL:-Unknown}" -} - -show_help() { - show_brand_banner - echo - printf "%s%s%s\n" "$BLUE" "COMMANDS" "$NC" - printf " %s%-28s%s %s\n" "$GREEN" "mo" "$NC" "Main menu" - for entry in "${MOLE_COMMANDS[@]}"; do - local name="${entry%%:*}" - local desc="${entry#*:}" - local display="mo $name" - [[ "$name" == "help" ]] && display="mo --help" - [[ "$name" == "version" ]] && display="mo --version" - printf " %s%-28s%s %s\n" "$GREEN" "$display" "$NC" "$desc" - done - echo - printf " %s%-28s%s %s\n" "$GREEN" "mo clean --dry-run" "$NC" "Preview cleanup" - printf " %s%-28s%s %s\n" "$GREEN" "mo clean --whitelist" "$NC" "Manage protected caches" - - 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" - echo - printf "%s%s%s\n" "$BLUE" "OPTIONS" "$NC" - printf " %s%-28s%s %s\n" "$GREEN" "--debug" "$NC" "Show detailed operation logs" - echo -} - -# Update flow (Homebrew or installer). -update_mole() { - local update_interrupted=false - trap 'update_interrupted=true; echo ""; exit 130' INT TERM - - if is_homebrew_install; then - update_via_homebrew "$VERSION" - exit 0 - fi - - local latest - latest=$(get_latest_version_from_github) - [[ -z "$latest" ]] && latest=$(get_latest_version) - - 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} Try again with: ${GRAY}mo update${NC}" - exit 1 - fi - - if [[ "$VERSION" == "$latest" ]]; then - echo "" - echo -e "${GREEN}${ICON_SUCCESS}${NC} Already on latest version (${VERSION})" - echo "" - exit 0 - fi - - if [[ -t 1 ]]; then - start_inline_spinner "Downloading latest version..." - else - echo "Downloading latest version..." - fi - - local installer_url="https://raw.githubusercontent.com/tw93/mole/main/install.sh" - local tmp_installer - tmp_installer="$(mktemp_file)" || { - log_error "Update failed" - exit 1 - } - - local download_error="" - if command -v curl > /dev/null 2>&1; then - download_error=$(curl -fsSL --connect-timeout 10 --max-time 60 "$installer_url" -o "$tmp_installer" 2>&1) || { - local curl_exit=$? - if [[ -t 1 ]]; then stop_inline_spinner; fi - rm -f "$tmp_installer" - 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." ;; - 7) echo -e "${YELLOW}Tip:${NC} Failed to connect. Check network or proxy settings." ;; - 22) echo -e "${YELLOW}Tip:${NC} HTTP 404 Not Found. The installer may have moved." ;; - 28) echo -e "${YELLOW}Tip:${NC} Connection timed out. Try again or check firewall." ;; - *) echo -e "${YELLOW}Tip:${NC} Check network connection and try again." ;; - esac - echo -e "${YELLOW}Tip:${NC} URL: $installer_url" - exit 1 - } - elif command -v wget > /dev/null 2>&1; then - 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)" - echo -e "${YELLOW}Tip:${NC} Check network connection and try again." - echo -e "${YELLOW}Tip:${NC} URL: $installer_url" - exit 1 - } - else - if [[ -t 1 ]]; then stop_inline_spinner; fi - rm -f "$tmp_installer" - log_error "curl or wget required" - echo -e "${YELLOW}Tip:${NC} Install curl with: ${GRAY}brew install curl${NC}" - exit 1 - fi - - if [[ -t 1 ]]; then stop_inline_spinner; fi - chmod +x "$tmp_installer" - - local mole_path - mole_path="$(command -v mole 2> /dev/null || echo "$0")" - local install_dir - install_dir="$(cd "$(dirname "$mole_path")" && pwd)" - - local requires_sudo="false" - if [[ ! -w "$install_dir" ]]; then - requires_sudo="true" - elif [[ -e "$install_dir/mole" && ! -w "$install_dir/mole" ]]; then - requires_sudo="true" - fi - - if [[ "$requires_sudo" == "true" ]]; then - if ! request_sudo_access "Mole update requires admin access"; then - log_error "Update aborted (admin access denied)" - rm -f "$tmp_installer" - exit 1 - fi - fi - - if [[ -t 1 ]]; then - start_inline_spinner "Installing update..." - else - echo "Installing update..." - fi - - process_install_output() { - local output="$1" - if [[ -t 1 ]]; then stop_inline_spinner; fi - - local filtered_output - filtered_output=$(printf '%s\n' "$output" | sed '/^$/d') - if [[ -n "$filtered_output" ]]; then - printf '\n%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=$("$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})" - else - printf '\n' - fi - } - - local install_output - local update_tag="V${latest#V}" - local config_dir="${MOLE_CONFIG_DIR:-$SCRIPT_DIR}" - 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 install_output=$(MOLE_VERSION="$update_tag" "$tmp_installer" --prefix "$install_dir" --config "$config_dir" 2>&1); then - process_install_output "$install_output" - 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 - - rm -f "$tmp_installer" - rm -f "$HOME/.cache/mole/update_message" -} - -# Remove flow (Homebrew + manual + config/cache). -remove_mole() { - if [[ -t 1 ]]; then - start_inline_spinner "Detecting Mole installations..." - else - echo "Detecting installations..." - fi - - local is_homebrew=false - local brew_cmd="" - local brew_has_mole="false" - local -a manual_installs=() - local -a alias_installs=() - - if command -v brew > /dev/null 2>&1; then - brew_cmd="brew" - elif [[ -x "/opt/homebrew/bin/brew" ]]; then - brew_cmd="/opt/homebrew/bin/brew" - elif [[ -x "/usr/local/bin/brew" ]]; then - brew_cmd="/usr/local/bin/brew" - fi - - if [[ -n "$brew_cmd" ]]; then - if "$brew_cmd" list --formula 2> /dev/null | grep -q "^mole$"; then - brew_has_mole="true" - fi - fi - - if [[ "$brew_has_mole" == "true" ]] || is_homebrew_install; then - is_homebrew=true - fi - - local found_mole - found_mole=$(command -v mole 2> /dev/null || true) - if [[ -n "$found_mole" && -f "$found_mole" ]]; then - if [[ ! -L "$found_mole" ]] || ! readlink "$found_mole" | grep -q "Cellar/mole"; then - manual_installs+=("$found_mole") - fi - fi - - local -a fallback_paths=( - "/usr/local/bin/mole" - "$HOME/.local/bin/mole" - "/opt/local/bin/mole" - ) - - for path in "${fallback_paths[@]}"; do - if [[ -f "$path" && "$path" != "$found_mole" ]]; then - if [[ ! -L "$path" ]] || ! readlink "$path" | grep -q "Cellar/mole"; then - manual_installs+=("$path") - fi - fi - done - - local found_mo - found_mo=$(command -v mo 2> /dev/null || true) - if [[ -n "$found_mo" && -f "$found_mo" ]]; then - alias_installs+=("$found_mo") - fi - - local -a alias_fallback=( - "/usr/local/bin/mo" - "$HOME/.local/bin/mo" - "/opt/local/bin/mo" - ) - - for alias in "${alias_fallback[@]}"; do - if [[ -f "$alias" && "$alias" != "$found_mo" ]]; then - alias_installs+=("$alias") - fi - done - - if [[ -t 1 ]]; then - stop_inline_spinner - fi - - printf '\n' - - local manual_count=${#manual_installs[@]} - local alias_count=${#alias_installs[@]} - if [[ "$is_homebrew" == "false" && ${manual_count:-0} -eq 0 && ${alias_count:-0} -eq 0 ]]; then - printf '%s\n\n' "${YELLOW}No Mole installation detected${NC}" - exit 0 - fi - - echo -e "${YELLOW}Remove Mole${NC} - will delete the following:" - if [[ "$is_homebrew" == "true" ]]; then - echo " - Mole via Homebrew" - fi - for install in ${manual_installs[@]+"${manual_installs[@]}"} ${alias_installs[@]+"${alias_installs[@]}"}; do - echo " - $install" - done - 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="" - drain_pending_input # Clean up any escape sequence remnants - case "$key" in - $'\e') - exit 0 - ;; - "" | $'\n' | $'\r') - printf "\r\033[K" # Clear the prompt line - ;; - *) - exit 0 - ;; - esac - - local has_error=false - if [[ "$is_homebrew" == "true" ]]; then - if [[ -z "$brew_cmd" ]]; then - log_error "Homebrew command not found. Please ensure Homebrew is installed and in your PATH." - log_warning "You may need to manually run: brew uninstall --force mole" - exit 1 - fi - - log_admin "Attempting to uninstall Mole via Homebrew..." - local brew_uninstall_output - if ! brew_uninstall_output=$("$brew_cmd" uninstall --force mole 2>&1); then - has_error=true - log_error "Homebrew uninstallation failed:" - printf "%s\n" "$brew_uninstall_output" | sed "s/^/${RED} | ${NC}/" >&2 - log_warning "Please manually run: ${YELLOW}brew uninstall --force mole${NC}" - echo "" # Add a blank line for readability - else - log_success "Mole uninstalled via Homebrew." - fi - fi - if [[ ${manual_count:-0} -gt 0 ]]; then - for install in "${manual_installs[@]}"; do - if [[ -f "$install" ]]; then - if [[ ! -w "$(dirname "$install")" ]]; then - if ! sudo rm -f "$install" 2> /dev/null; then - has_error=true - fi - else - if ! rm -f "$install" 2> /dev/null; then - has_error=true - fi - fi - fi - done - fi - if [[ ${alias_count:-0} -gt 0 ]]; then - for alias in "${alias_installs[@]}"; do - if [[ -f "$alias" ]]; then - if [[ ! -w "$(dirname "$alias")" ]]; then - if ! sudo rm -f "$alias" 2> /dev/null; then - has_error=true - fi - else - if ! rm -f "$alias" 2> /dev/null; then - has_error=true - fi - fi - fi - done - fi - if [[ -d "$HOME/.cache/mole" ]]; then - rm -rf "$HOME/.cache/mole" 2> /dev/null || true - fi - if [[ -d "$HOME/.config/mole" ]]; then - rm -rf "$HOME/.config/mole" 2> /dev/null || true - fi - - local final_message - if [[ "$has_error" == "true" ]]; then - final_message="${YELLOW}${ICON_ERROR} Mole uninstalled with some errors, thank you for using Mole!${NC}" - else - final_message="${GREEN}${ICON_SUCCESS} Mole uninstalled successfully, thank you for using Mole!${NC}" - fi - printf '\n%s\n\n' "$final_message" - - exit 0 -} - -# Menu UI -show_main_menu() { - local selected="${1:-1}" - local _full_draw="${2:-true}" # Kept for compatibility (unused) - local banner="${MAIN_MENU_BANNER:-}" - local update_message="${MAIN_MENU_UPDATE_MESSAGE:-}" - - if [[ -z "$banner" ]]; then - banner="$(show_brand_banner)" - MAIN_MENU_BANNER="$banner" - fi - - printf '\033[H' - - local line="" - printf '\r\033[2K\n' - - while IFS= read -r line || [[ -n "$line" ]]; do - printf '\r\033[2K%s\n' "$line" - done <<< "$banner" - - if [[ -n "$update_message" ]]; then - while IFS= read -r line || [[ -n "$line" ]]; do - printf '\r\033[2K%s\n' "$line" - done <<< "$update_message" - fi - - printf '\r\033[2K\n' - - printf '\r\033[2K%s\n' "$(show_menu_option 1 "Clean Free up disk space" "$([[ $selected -eq 1 ]] && echo true || echo false)")" - printf '\r\033[2K%s\n' "$(show_menu_option 2 "Uninstall Remove apps completely" "$([[ $selected -eq 2 ]] && echo true || echo false)")" - printf '\r\033[2K%s\n' "$(show_menu_option 3 "Optimize Check and maintain system" "$([[ $selected -eq 3 ]] && echo true || echo false)")" - printf '\r\033[2K%s\n' "$(show_menu_option 4 "Analyze Explore disk usage" "$([[ $selected -eq 4 ]] && echo true || echo false)")" - printf '\r\033[2K%s\n' "$(show_menu_option 5 "Status Monitor system health" "$([[ $selected -eq 5 ]] && echo true || echo false)")" - - if [[ -t 0 ]]; then - printf '\r\033[2K\n' - local controls="${GRAY}↑↓ | Enter | M More | " - if ! is_touchid_configured; then - controls="${controls}T TouchID" - else - controls="${controls}U Update" - fi - controls="${controls} | Q Quit${NC}" - printf '\r\033[2K%s\n' "$controls" - printf '\r\033[2K\n' - fi - - printf '\033[J' -} - -interactive_main_menu() { - if [[ -t 1 ]]; then - local tty_name - tty_name=$(tty 2> /dev/null || echo "") - if [[ -n "$tty_name" ]]; then - local flag_file - local cache_dir="$HOME/.cache/mole" - ensure_user_dir "$cache_dir" - flag_file="$cache_dir/intro_$(echo "$tty_name" | LC_ALL=C tr -c '[:alnum:]_' '_')" - if [[ ! -f "$flag_file" ]]; then - animate_mole_intro - ensure_user_file "$flag_file" - fi - fi - fi - local current_option=1 - local first_draw=true - local brand_banner="" - local msg_cache="$HOME/.cache/mole/update_message" - local update_message="" - - brand_banner="$(show_brand_banner)" - MAIN_MENU_BANNER="$brand_banner" - - if [[ -f "$msg_cache" && -s "$msg_cache" ]]; then - update_message="$(cat "$msg_cache" 2> /dev/null || echo "")" - fi - MAIN_MENU_UPDATE_MESSAGE="$update_message" - - cleanup_and_exit() { - show_cursor - exit 0 - } - - trap cleanup_and_exit INT - hide_cursor - - while true; do - show_main_menu $current_option "$first_draw" - if [[ "$first_draw" == "true" ]]; then - first_draw=false - fi - - local key - if ! key=$(read_key); then - continue - fi - - case "$key" in - "UP") ((current_option > 1)) && ((current_option--)) ;; - "DOWN") ((current_option < 5)) && ((current_option++)) ;; - "ENTER") - show_cursor - case $current_option in - 1) exec "$SCRIPT_DIR/bin/clean.sh" ;; - 2) exec "$SCRIPT_DIR/bin/uninstall.sh" ;; - 3) exec "$SCRIPT_DIR/bin/optimize.sh" ;; - 4) exec "$SCRIPT_DIR/bin/analyze.sh" ;; - 5) exec "$SCRIPT_DIR/bin/status.sh" ;; - esac - ;; - "CHAR:1") - show_cursor - exec "$SCRIPT_DIR/bin/clean.sh" - ;; - "CHAR:2") - show_cursor - exec "$SCRIPT_DIR/bin/uninstall.sh" - ;; - "CHAR:3") - show_cursor - exec "$SCRIPT_DIR/bin/optimize.sh" - ;; - "CHAR:4") - show_cursor - exec "$SCRIPT_DIR/bin/analyze.sh" - ;; - "CHAR:5") - show_cursor - exec "$SCRIPT_DIR/bin/status.sh" - ;; - "MORE") - show_cursor - clear - show_help - exit 0 - ;; - "VERSION") - show_cursor - clear - show_version - exit 0 - ;; - "TOUCHID") - show_cursor - exec "$SCRIPT_DIR/bin/touchid.sh" - ;; - "UPDATE") - show_cursor - clear - update_mole - exit 0 - ;; - "QUIT") cleanup_and_exit ;; - esac - - drain_pending_input - done -} - -# CLI dispatch -main() { - local -a args=() - for arg in "$@"; do - case "$arg" in - --debug) - export MO_DEBUG=1 - ;; - *) - args+=("$arg") - ;; - esac - done - - case "${args[0]:-""}" in - "optimize") - exec "$SCRIPT_DIR/bin/optimize.sh" "${args[@]:1}" - ;; - "clean") - exec "$SCRIPT_DIR/bin/clean.sh" "${args[@]:1}" - ;; - "uninstall") - exec "$SCRIPT_DIR/bin/uninstall.sh" "${args[@]:1}" - ;; - "analyze") - exec "$SCRIPT_DIR/bin/analyze.sh" "${args[@]:1}" - ;; - "status") - exec "$SCRIPT_DIR/bin/status.sh" "${args[@]:1}" - ;; - "purge") - exec "$SCRIPT_DIR/bin/purge.sh" "${args[@]:1}" - ;; - "installer") - exec "$SCRIPT_DIR/bin/installer.sh" "${args[@]:1}" - ;; - "touchid") - exec "$SCRIPT_DIR/bin/touchid.sh" "${args[@]:1}" - ;; - "completion") - exec "$SCRIPT_DIR/bin/completion.sh" "${args[@]:1}" - ;; - "update") - update_mole - exit 0 - ;; - "remove") - remove_mole - ;; - "help" | "--help" | "-h") - show_help - exit 0 - ;; - "version" | "--version" | "-V") - show_version - exit 0 - ;; - "") - check_for_updates - interactive_main_menu - ;; - *) - echo "Unknown command: ${args[0]}" - echo "Use 'mole --help' for usage information." - exit 1 - ;; - esac -} - -main "$@" diff --git a/windows/mole.ps1 b/mole.ps1 similarity index 100% rename from windows/mole.ps1 rename to mole.ps1 diff --git a/windows/scripts/build.ps1 b/scripts/build.ps1 similarity index 100% rename from windows/scripts/build.ps1 rename to scripts/build.ps1 diff --git a/scripts/check.sh b/scripts/check.sh deleted file mode 100755 index 4249a82..0000000 --- a/scripts/check.sh +++ /dev/null @@ -1,221 +0,0 @@ -#!/bin/bash -# Code quality checks for Mole. -# Auto-formats code, then runs lint and syntax checks. - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" - -MODE="all" - -usage() { - cat << 'EOF' -Usage: ./scripts/check.sh [--format|--no-format] - -Options: - --format Apply formatting fixes only (shfmt, gofmt) - --no-format Skip formatting and run checks only - --help Show this help -EOF -} - -while [[ $# -gt 0 ]]; do - case "$1" in - --format) - MODE="format" - shift - ;; - --no-format) - MODE="check" - shift - ;; - --help | -h) - usage - exit 0 - ;; - *) - echo "Unknown option: $1" - usage - exit 1 - ;; - esac -done - -cd "$PROJECT_ROOT" - -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' - -readonly ICON_SUCCESS="✓" -readonly ICON_ERROR="☻" -readonly ICON_WARNING="●" -readonly ICON_LIST="•" - -echo -e "${BLUE}=== Mole Check (${MODE}) ===${NC}\n" - -SHELL_FILES=$(find . -type f \( -name "*.sh" -o -name "mole" \) \ - -not -path "./.git/*" \ - -not -path "*/node_modules/*" \ - -not -path "*/tests/tmp-*/*" \ - -not -path "*/.*" \ - 2> /dev/null) - -if [[ "$MODE" == "format" ]]; then - echo -e "${YELLOW}Formatting shell scripts...${NC}" - if command -v shfmt > /dev/null 2>&1; then - echo "$SHELL_FILES" | xargs shfmt -i 4 -ci -sr -w - echo -e "${GREEN}${ICON_SUCCESS} Shell formatting complete${NC}\n" - else - echo -e "${RED}${ICON_ERROR} shfmt not installed${NC}" - exit 1 - fi - - if command -v goimports > /dev/null 2>&1; then - 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}" - gofmt -w ./cmd - echo -e "${GREEN}${ICON_SUCCESS} Go formatting complete${NC}\n" - else - echo -e "${YELLOW}${ICON_WARNING} go not installed, skipping gofmt${NC}\n" - fi - - echo -e "${GREEN}=== Format Completed ===${NC}" - exit 0 -fi - -if [[ "$MODE" != "check" ]]; then - echo -e "${YELLOW}1. Formatting shell scripts...${NC}" - if command -v shfmt > /dev/null 2>&1; then - echo "$SHELL_FILES" | xargs shfmt -i 4 -ci -sr -w - echo -e "${GREEN}${ICON_SUCCESS} Shell formatting applied${NC}\n" - else - echo -e "${YELLOW}${ICON_WARNING} shfmt not installed, skipping${NC}\n" - fi - - if command -v goimports > /dev/null 2>&1; then - 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}" - gofmt -w ./cmd - echo -e "${GREEN}${ICON_SUCCESS} Go formatting applied${NC}\n" - fi -fi - -echo -e "${YELLOW}3. Running Go linters...${NC}" -if command -v golangci-lint > /dev/null 2>&1; then - if ! golangci-lint config verify; then - echo -e "${RED}${ICON_ERROR} golangci-lint config invalid${NC}\n" - exit 1 - fi - if golangci-lint run ./cmd/...; then - echo -e "${GREEN}${ICON_SUCCESS} golangci-lint passed${NC}\n" - else - echo -e "${RED}${ICON_ERROR} golangci-lint failed${NC}\n" - exit 1 - fi -elif command -v go > /dev/null 2>&1; then - echo -e "${YELLOW}${ICON_WARNING} golangci-lint not installed, falling back to go vet${NC}" - if go vet ./cmd/...; then - echo -e "${GREEN}${ICON_SUCCESS} go vet passed${NC}\n" - else - echo -e "${RED}${ICON_ERROR} go vet failed${NC}\n" - exit 1 - fi -else - echo -e "${YELLOW}${ICON_WARNING} Go not installed, skipping Go checks${NC}\n" -fi - -echo -e "${YELLOW}4. Running ShellCheck...${NC}" -if command -v shellcheck > /dev/null 2>&1; then - if shellcheck mole bin/*.sh lib/*/*.sh scripts/*.sh; then - echo -e "${GREEN}${ICON_SUCCESS} ShellCheck passed${NC}\n" - else - echo -e "${RED}${ICON_ERROR} ShellCheck failed${NC}\n" - exit 1 - fi -else - echo -e "${YELLOW}${ICON_WARNING} shellcheck not installed, skipping${NC}\n" -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" - 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" - 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" - exit 1 - fi -done -echo -e "${GREEN}${ICON_SUCCESS} Syntax check passed${NC}\n" - -echo -e "${YELLOW}6. Checking optimizations...${NC}" -OPTIMIZATION_SCORE=0 -TOTAL_CHECKS=0 - -((TOTAL_CHECKS++)) -if grep -q "read -r -s -n 1 -t 1" lib/core/ui.sh; then - echo -e "${GREEN} ${ICON_SUCCESS} Keyboard timeout configured${NC}" - ((OPTIMIZATION_SCORE++)) -else - echo -e "${YELLOW} ${ICON_WARNING} Keyboard timeout may be misconfigured${NC}" -fi - -((TOTAL_CHECKS++)) -DRAIN_PASSES=$(grep -c "while IFS= read -r -s -n 1" lib/core/ui.sh 2> /dev/null || true) -DRAIN_PASSES=${DRAIN_PASSES:-0} -if [[ $DRAIN_PASSES -eq 1 ]]; then - echo -e "${GREEN} ${ICON_SUCCESS} drain_pending_input optimized${NC}" - ((OPTIMIZATION_SCORE++)) -else - echo -e "${YELLOW} ${ICON_WARNING} drain_pending_input has multiple passes${NC}" -fi - -((TOTAL_CHECKS++)) -if grep -q "rotate_log_once" lib/core/log.sh; then - echo -e "${GREEN} ${ICON_SUCCESS} Log rotation optimized${NC}" - ((OPTIMIZATION_SCORE++)) -else - echo -e "${YELLOW} ${ICON_WARNING} Log rotation not optimized${NC}" -fi - -((TOTAL_CHECKS++)) -if ! grep -q "cache_meta\|cache_dir_mtime" bin/uninstall.sh; then - echo -e "${GREEN} ${ICON_SUCCESS} Cache validation simplified${NC}" - ((OPTIMIZATION_SCORE++)) -else - echo -e "${YELLOW} ${ICON_WARNING} Cache still uses redundant metadata${NC}" -fi - -((TOTAL_CHECKS++)) -if grep -q "Consecutive slashes" bin/clean.sh; then - echo -e "${GREEN} ${ICON_SUCCESS} Path validation enhanced${NC}" - ((OPTIMIZATION_SCORE++)) -else - echo -e "${YELLOW} ${ICON_WARNING} Path validation not enhanced${NC}" -fi - -echo -e "${BLUE} Optimization score: $OPTIMIZATION_SCORE/$TOTAL_CHECKS${NC}\n" - -echo -e "${GREEN}=== Checks Completed ===${NC}" -if [[ $OPTIMIZATION_SCORE -eq $TOTAL_CHECKS ]]; then - echo -e "${GREEN}${ICON_SUCCESS} All optimizations applied${NC}" -else - echo -e "${YELLOW}${ICON_WARNING} Some optimizations missing${NC}" -fi diff --git a/scripts/setup-quick-launchers.sh b/scripts/setup-quick-launchers.sh deleted file mode 100755 index 11b8784..0000000 --- a/scripts/setup-quick-launchers.sh +++ /dev/null @@ -1,424 +0,0 @@ -#!/bin/bash -# Create Raycast script commands and Alfred keywords for Mole (clean + uninstall). - -set -euo pipefail - -BLUE='\033[0;34m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -RED='\033[0;31m' -NC='\033[0m' - -ICON_STEP="➜" -ICON_SUCCESS="✓" -ICON_WARN="!" -ICON_ERR="✗" - -log_step() { echo -e "${BLUE}${ICON_STEP}${NC} $1"; } -log_success() { echo -e "${GREEN}${ICON_SUCCESS}${NC} $1"; } -log_warn() { echo -e "${YELLOW}${ICON_WARN}${NC} $1"; } -log_error() { echo -e "${RED}${ICON_ERR}${NC} $1"; } -log_header() { echo -e "\n${BLUE}==== $1 ====${NC}\n"; } -is_interactive() { [[ -t 1 && -r /dev/tty ]]; } -prompt_enter() { - local prompt="$1" - if is_interactive; then - read -r -p "$prompt" < /dev/tty || true - else - echo "$prompt" - fi -} -detect_mo() { - if command -v mo > /dev/null 2>&1; then - command -v mo - elif command -v mole > /dev/null 2>&1; then - command -v mole - else - log_error "Mole not found. Install it first via Homebrew or ./install.sh." - exit 1 - fi -} - -write_raycast_script() { - local target="$1" - local title="$2" - local mo_bin="$3" - local subcommand="$4" - local raw_cmd="\"${mo_bin}\" ${subcommand}" - local cmd_escaped="${raw_cmd//\\/\\\\}" - cmd_escaped="${cmd_escaped//\"/\\\"}" - cat > "$target" << EOF -#!/bin/bash - -# Required parameters: -# @raycast.schemaVersion 1 -# @raycast.title ${title} -# @raycast.mode fullOutput -# @raycast.packageName Mole - -# Optional parameters: -# @raycast.icon 🐹 - -set -euo pipefail - -echo "🐹 Running ${title}..." -echo "" -CMD="${raw_cmd}" -CMD_ESCAPED="${cmd_escaped}" - -has_app() { - local name="\$1" - [[ -d "/Applications/\${name}.app" || -d "\$HOME/Applications/\${name}.app" ]] -} - -has_bin() { - command -v "\$1" >/dev/null 2>&1 -} - -launcher_available() { - local app="\$1" - case "\$app" in - Terminal) return 0 ;; - iTerm|iTerm2) has_app "iTerm" || has_app "iTerm2" ;; - Alacritty) has_app "Alacritty" ;; - Kitty) has_bin "kitty" || has_app "kitty" ;; - WezTerm) has_bin "wezterm" || has_app "WezTerm" ;; - Ghostty) has_bin "ghostty" || has_app "Ghostty" ;; - Hyper) has_app "Hyper" ;; - WindTerm) has_app "WindTerm" ;; - Warp) has_app "Warp" ;; - *) - return 1 ;; - esac -} - -detect_launcher_app() { - if [[ -n "\${MO_LAUNCHER_APP:-}" ]]; then - echo "\${MO_LAUNCHER_APP}" - return - fi - local candidates=(Warp Ghostty Alacritty Kitty WezTerm WindTerm Hyper iTerm2 iTerm Terminal) - local app - for app in "\${candidates[@]}"; do - if launcher_available "\$app"; then - echo "\$app" - return - fi - done - echo "Terminal" -} - -launch_with_app() { - local app="\$1" - case "\$app" in - Terminal) - if command -v osascript >/dev/null 2>&1; then - osascript <<'APPLESCRIPT' -set targetCommand to "${cmd_escaped}" -tell application "Terminal" - activate - do script targetCommand -end tell -APPLESCRIPT - return 0 - fi - ;; - iTerm|iTerm2) - if command -v osascript >/dev/null 2>&1; then - osascript <<'APPLESCRIPT' -set targetCommand to "${cmd_escaped}" -tell application "iTerm2" - activate - try - tell current window - tell current session - write text targetCommand - end tell - end tell - on error - create window with default profile - tell current window - tell current session - write text targetCommand - end tell - end tell - end try -end tell -APPLESCRIPT - return 0 - fi - ;; - Alacritty) - if launcher_available "Alacritty" && command -v open >/dev/null 2>&1; then - open -na "Alacritty" --args -e /bin/zsh -lc "${raw_cmd}" - return \$? - fi - ;; - Kitty) - if has_bin "kitty"; then - kitty --hold /bin/zsh -lc "${raw_cmd}" - return \$? - elif [[ -x "/Applications/kitty.app/Contents/MacOS/kitty" ]]; then - "/Applications/kitty.app/Contents/MacOS/kitty" --hold /bin/zsh -lc "${raw_cmd}" - return \$? - fi - ;; - WezTerm) - if has_bin "wezterm"; then - wezterm start -- /bin/zsh -lc "${raw_cmd}" - return \$? - elif [[ -x "/Applications/WezTerm.app/Contents/MacOS/wezterm" ]]; then - "/Applications/WezTerm.app/Contents/MacOS/wezterm" start -- /bin/zsh -lc "${raw_cmd}" - return \$? - fi - ;; - Ghostty) - if has_bin "ghostty"; then - ghostty --command "/bin/zsh" -- -lc "${raw_cmd}" - return \$? - elif [[ -x "/Applications/Ghostty.app/Contents/MacOS/ghostty" ]]; then - "/Applications/Ghostty.app/Contents/MacOS/ghostty" --command "/bin/zsh" -- -lc "${raw_cmd}" - return \$? - fi - ;; - Hyper) - if launcher_available "Hyper" && command -v open >/dev/null 2>&1; then - open -na "Hyper" --args /bin/zsh -lc "${raw_cmd}" - return \$? - fi - ;; - WindTerm) - if launcher_available "WindTerm" && command -v open >/dev/null 2>&1; then - open -na "WindTerm" --args /bin/zsh -lc "${raw_cmd}" - return \$? - fi - ;; - Warp) - if launcher_available "Warp" && command -v open >/dev/null 2>&1; then - open -na "Warp" --args /bin/zsh -lc "${raw_cmd}" - return \$? - fi - ;; - esac - return 1 -} - -if [[ -n "\${TERM:-}" && "\${TERM}" != "dumb" ]]; then - "${mo_bin}" ${subcommand} - exit \$? -fi - -TERM_APP="\$(detect_launcher_app)" - -if launch_with_app "\$TERM_APP"; then - exit 0 -fi - -if [[ "\$TERM_APP" != "Terminal" ]]; then - echo "Could not control \$TERM_APP, falling back to Terminal..." - if launch_with_app "Terminal"; then - exit 0 - fi -fi - -echo "TERM environment variable not set and no launcher succeeded." -echo "Run this manually:" -echo " ${raw_cmd}" -exit 1 -EOF - chmod +x "$target" -} - -create_raycast_commands() { - local mo_bin="$1" - local default_dir="$HOME/Library/Application Support/Raycast/script-commands" - local dir="$default_dir" - - log_step "Installing Raycast commands..." - mkdir -p "$dir" - write_raycast_script "$dir/mole-clean.sh" "clean" "$mo_bin" "clean" - write_raycast_script "$dir/mole-uninstall.sh" "uninstall" "$mo_bin" "uninstall" - write_raycast_script "$dir/mole-optimize.sh" "optimize" "$mo_bin" "optimize" - write_raycast_script "$dir/mole-analyze.sh" "analyze" "$mo_bin" "analyze" - write_raycast_script "$dir/mole-status.sh" "status" "$mo_bin" "status" - log_success "Scripts ready in: $dir" - - log_header "Raycast Configuration" - if command -v open > /dev/null 2>&1; then - if open "raycast://extensions/raycast/raycast-settings/extensions" > /dev/null 2>&1; then - log_step "Raycast settings opened." - else - log_warn "Could not auto-open Raycast." - fi - else - log_warn "open command not available; please open Raycast manually." - fi - - echo "If Raycast asks to add a Script Directory, use:" - echo " $dir" - - if is_interactive; then - log_header "Finalizing Setup" - prompt_enter "Press [Enter] to reload script directories in Raycast..." - if command -v open > /dev/null 2>&1 && open "raycast://extensions/raycast/raycast/reload-script-directories" > /dev/null 2>&1; then - log_step "Raycast script directories reloaded." - else - log_warn "Could not auto-reload Raycast script directories." - fi - - log_success "Raycast setup complete!" - else - log_warn "Non-interactive mode; skip Raycast reload. Please run 'Reload Script Directories' in Raycast." - fi -} - -uuid() { - if command -v uuidgen > /dev/null 2>&1; then - uuidgen - else - # Fallback pseudo UUID in format: 8-4-4-4-12 - local hex=$(openssl rand -hex 16) - echo "${hex:0:8}-${hex:8:4}-${hex:12:4}-${hex:16:4}-${hex:20:12}" - fi -} - -create_alfred_workflow() { - local mo_bin="$1" - local prefs_dir="${ALFRED_PREFS_DIR:-$HOME/Library/Application Support/Alfred/Alfred.alfredpreferences}" - local workflows_dir="$prefs_dir/workflows" - - if [[ ! -d "$workflows_dir" ]]; then - return - fi - - log_step "Installing Alfred workflows..." - local workflows=( - "fun.tw93.mole.clean|Mole clean|clean|Run Mole clean|\"${mo_bin}\" clean" - "fun.tw93.mole.uninstall|Mole uninstall|uninstall|Uninstall apps via Mole|\"${mo_bin}\" uninstall" - "fun.tw93.mole.optimize|Mole optimize|optimize|System health & optimization|\"${mo_bin}\" optimize" - "fun.tw93.mole.analyze|Mole analyze|analyze|Disk space analysis|\"${mo_bin}\" analyze" - "fun.tw93.mole.status|Mole status|status|Live system dashboard|\"${mo_bin}\" status" - ) - - for entry in "${workflows[@]}"; do - IFS="|" read -r bundle name keyword subtitle command <<< "$entry" - local workflow_uid="user.workflow.$(uuid | LC_ALL=C tr '[:upper:]' '[:lower:]')" - local input_uid - local action_uid - input_uid="$(uuid)" - action_uid="$(uuid)" - local dir="$workflows_dir/$workflow_uid" - mkdir -p "$dir" - - cat > "$dir/info.plist" << EOF - - - - - bundleid - ${bundle} - createdby - Mole - name - ${name} - objects - - - config - - argumenttype - 2 - keyword - ${keyword} - subtext - ${subtitle} - text - ${name} - withspace - - - type - alfred.workflow.input.keyword - uid - ${input_uid} - version - 1 - - - config - - concurrently - - escaping - 102 - script - #!/bin/bash -PATH="/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin" -${command} - - scriptargtype - 1 - scriptfile - - type - 0 - - type - alfred.workflow.action.script - uid - ${action_uid} - version - 2 - - - connections - - ${input_uid} - - - destinationuid - ${action_uid} - modifiers - 0 - modifiersubtext - - - - - uid - ${workflow_uid} - version - 1 - - -EOF - log_success "Workflow ready: ${name} (keyword: ${keyword})" - done - - log_step "Open Alfred preferences → Workflows if you need to adjust keywords." -} - -main() { - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo " Mole Quick Launchers" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - - local mo_bin - mo_bin="$(detect_mo)" - log_step "Detected Mole binary at: ${mo_bin}" - - create_raycast_commands "$mo_bin" - create_alfred_workflow "$mo_bin" - - 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 "" -} - -main "$@" diff --git a/windows/scripts/test.ps1 b/scripts/test.ps1 similarity index 100% rename from windows/scripts/test.ps1 rename to scripts/test.ps1 diff --git a/scripts/test.sh b/scripts/test.sh deleted file mode 100755 index 11bc688..0000000 --- a/scripts/test.sh +++ /dev/null @@ -1,207 +0,0 @@ -#!/bin/bash -# Test runner for Mole. -# Runs unit, Go, and integration tests. -# Exits non-zero on failures. - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" - -cd "$PROJECT_ROOT" - -# shellcheck source=lib/core/file_ops.sh -source "$PROJECT_ROOT/lib/core/file_ops.sh" - -echo "===============================" -echo "Mole Test Runner" -echo "===============================" -echo "" - -FAILED=0 - -report_unit_result() { - if [[ $1 -eq 0 ]]; then - printf "${GREEN}${ICON_SUCCESS} Unit tests passed${NC}\n" - else - printf "${RED}${ICON_ERROR} Unit tests failed${NC}\n" - ((FAILED++)) - fi -} - -echo "1. Linting test scripts..." -if command -v shellcheck > /dev/null 2>&1; then - TEST_FILES=() - while IFS= read -r file; do - TEST_FILES+=("$file") - done < <(find tests -type f \( -name '*.bats' -o -name '*.sh' \) | sort) - if [[ ${#TEST_FILES[@]} -gt 0 ]]; then - if shellcheck --rcfile "$PROJECT_ROOT/.shellcheckrc" "${TEST_FILES[@]}"; then - printf "${GREEN}${ICON_SUCCESS} Test script lint passed${NC}\n" - else - printf "${RED}${ICON_ERROR} Test script lint failed${NC}\n" - ((FAILED++)) - fi - else - printf "${YELLOW}${ICON_WARNING} No test scripts found, skipping${NC}\n" - fi -else - printf "${YELLOW}${ICON_WARNING} shellcheck not installed, skipping${NC}\n" -fi -echo "" - -echo "2. Running unit tests..." -if command -v bats > /dev/null 2>&1 && [ -d "tests" ]; then - if [[ -z "${TERM:-}" ]]; then - export TERM="xterm-256color" - fi - if [[ $# -eq 0 ]]; then - fd_available=0 - zip_available=0 - zip_list_available=0 - if command -v fd > /dev/null 2>&1; then - fd_available=1 - fi - if command -v zip > /dev/null 2>&1; then - zip_available=1 - fi - if command -v zipinfo > /dev/null 2>&1 || command -v unzip > /dev/null 2>&1; then - zip_list_available=1 - fi - - TEST_FILES=() - while IFS= read -r file; do - case "$file" in - tests/installer_fd.bats) - if [[ $fd_available -eq 1 ]]; then - TEST_FILES+=("$file") - fi - ;; - tests/installer_zip.bats) - if [[ $zip_available -eq 1 && $zip_list_available -eq 1 ]]; then - TEST_FILES+=("$file") - fi - ;; - *) - TEST_FILES+=("$file") - ;; - esac - done < <(find tests -type f -name '*.bats' | sort) - - if [[ ${#TEST_FILES[@]} -gt 0 ]]; then - set -- "${TEST_FILES[@]}" - else - set -- tests - fi - fi - use_color=false - if [[ -t 1 && "${TERM:-}" != "dumb" ]]; then - use_color=true - fi - if bats --help 2>&1 | grep -q -- "--formatter"; then - formatter="${BATS_FORMATTER:-pretty}" - if [[ "$formatter" == "tap" ]]; then - if $use_color; then - esc=$'\033' - if bats --formatter tap "$@" | - sed -e "s/^ok /${esc}[32mok ${esc}[0m /" \ - -e "s/^not ok /${esc}[31mnot ok ${esc}[0m /"; then - report_unit_result 0 - else - report_unit_result 1 - fi - else - if bats --formatter tap "$@"; then - report_unit_result 0 - else - report_unit_result 1 - fi - fi - else - # Pretty format for local development - if bats --formatter "$formatter" "$@"; then - report_unit_result 0 - else - report_unit_result 1 - fi - fi - else - if $use_color; then - esc=$'\033' - if bats --tap "$@" | - sed -e "s/^ok /${esc}[32mok ${esc}[0m /" \ - -e "s/^not ok /${esc}[31mnot ok ${esc}[0m /"; then - report_unit_result 0 - else - report_unit_result 1 - fi - else - if bats --tap "$@"; then - report_unit_result 0 - else - report_unit_result 1 - fi - fi - fi -else - printf "${YELLOW}${ICON_WARNING} bats not installed or no tests found, skipping${NC}\n" -fi -echo "" - -echo "3. Running Go tests..." -if command -v go > /dev/null 2>&1; then - if go build ./... > /dev/null 2>&1 && go vet ./cmd/... > /dev/null 2>&1 && go test ./cmd/... > /dev/null 2>&1; then - printf "${GREEN}${ICON_SUCCESS} Go tests passed${NC}\n" - else - printf "${RED}${ICON_ERROR} Go tests failed${NC}\n" - ((FAILED++)) - fi -else - printf "${YELLOW}${ICON_WARNING} Go not installed, skipping Go tests${NC}\n" -fi -echo "" - -echo "4. Testing module loading..." -if bash -c 'source lib/core/common.sh && echo "OK"' > /dev/null 2>&1; then - printf "${GREEN}${ICON_SUCCESS} Module loading passed${NC}\n" -else - printf "${RED}${ICON_ERROR} Module loading failed${NC}\n" - ((FAILED++)) -fi -echo "" - -echo "5. Running integration tests..." -# Quick syntax check for main scripts -if bash -n mole && bash -n bin/clean.sh && bash -n bin/optimize.sh; then - printf "${GREEN}${ICON_SUCCESS} Integration tests passed${NC}\n" -else - printf "${RED}${ICON_ERROR} Integration tests failed${NC}\n" - ((FAILED++)) -fi -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" -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" - else - printf "${RED}${ICON_ERROR} Installation test failed${NC}\n" - ((FAILED++)) - fi -else - printf "${RED}${ICON_ERROR} Installation test failed${NC}\n" - ((FAILED++)) -fi -safe_remove "/tmp/mole-test" true || true -echo "" - -echo "===============================" -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" -exit 1 diff --git a/windows/tests/Clean.Tests.ps1 b/tests/Clean.Tests.ps1 similarity index 100% rename from windows/tests/Clean.Tests.ps1 rename to tests/Clean.Tests.ps1 diff --git a/windows/tests/Commands.Tests.ps1 b/tests/Commands.Tests.ps1 similarity index 100% rename from windows/tests/Commands.Tests.ps1 rename to tests/Commands.Tests.ps1 diff --git a/windows/tests/Core.Tests.ps1 b/tests/Core.Tests.ps1 similarity index 100% rename from windows/tests/Core.Tests.ps1 rename to tests/Core.Tests.ps1 diff --git a/tests/clean_app_caches.bats b/tests/clean_app_caches.bats deleted file mode 100644 index 8be5da0..0000000 --- a/tests/clean_app_caches.bats +++ /dev/null @@ -1,164 +0,0 @@ -#!/usr/bin/env bats - -setup_file() { - PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" - export PROJECT_ROOT - - ORIGINAL_HOME="${HOME:-}" - export ORIGINAL_HOME - - HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-app-caches.XXXXXX")" - export HOME - - mkdir -p "$HOME" -} - -teardown_file() { - rm -rf "$HOME" - if [[ -n "${ORIGINAL_HOME:-}" ]]; then - export HOME="$ORIGINAL_HOME" - fi -} - -@test "clean_xcode_tools skips derived data when Xcode running" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/clean/app_caches.sh" -pgrep() { return 0; } -safe_clean() { echo "$2"; } -clean_xcode_tools -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Xcode is running"* ]] - [[ "$output" != *"derived data"* ]] - [[ "$output" != *"archives"* ]] -} - -@test "clean_media_players protects spotify offline cache" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/clean/app_caches.sh" -mkdir -p "$HOME/Library/Application Support/Spotify/PersistentCache/Storage" -touch "$HOME/Library/Application Support/Spotify/PersistentCache/Storage/offline.bnk" -safe_clean() { echo "CLEAN:$2"; } -clean_media_players -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Spotify cache protected"* ]] - [[ "$output" != *"CLEAN: Spotify cache"* ]] -} - -@test "clean_user_gui_applications calls all sections" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/clean/app_caches.sh" -stop_section_spinner() { :; } -safe_clean() { :; } -clean_xcode_tools() { echo "xcode"; } -clean_code_editors() { echo "editors"; } -clean_communication_apps() { echo "comm"; } -clean_user_gui_applications -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"xcode"* ]] - [[ "$output" == *"editors"* ]] - [[ "$output" == *"comm"* ]] -} - -@test "clean_ai_apps calls expected caches" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/clean/app_caches.sh" -safe_clean() { echo "$2"; } -clean_ai_apps -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"ChatGPT cache"* ]] - [[ "$output" == *"Claude desktop cache"* ]] -} - -@test "clean_design_tools calls expected caches" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/clean/app_caches.sh" -safe_clean() { echo "$2"; } -clean_design_tools -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Sketch cache"* ]] - [[ "$output" == *"Figma cache"* ]] -} - -@test "clean_dingtalk calls expected caches" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/clean/app_caches.sh" -safe_clean() { echo "$2"; } -clean_dingtalk -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"DingTalk iDingTalk cache"* ]] - [[ "$output" == *"DingTalk logs"* ]] -} - -@test "clean_download_managers calls expected caches" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/clean/app_caches.sh" -safe_clean() { echo "$2"; } -clean_download_managers -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Aria2 cache"* ]] - [[ "$output" == *"qBittorrent cache"* ]] -} - -@test "clean_productivity_apps calls expected caches" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/clean/app_caches.sh" -safe_clean() { echo "$2"; } -clean_productivity_apps -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"MiaoYan cache"* ]] - [[ "$output" == *"Flomo cache"* ]] -} - -@test "clean_screenshot_tools calls expected caches" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/clean/app_caches.sh" -safe_clean() { echo "$2"; } -clean_screenshot_tools -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"CleanShot cache"* ]] - [[ "$output" == *"Xnip cache"* ]] -} - -@test "clean_office_applications calls expected caches" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/clean/user.sh" -stop_section_spinner() { :; } -safe_clean() { echo "$2"; } -clean_office_applications -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Microsoft Word cache"* ]] - [[ "$output" == *"Apple iWork cache"* ]] -} diff --git a/tests/clean_apps.bats b/tests/clean_apps.bats deleted file mode 100644 index 9955117..0000000 --- a/tests/clean_apps.bats +++ /dev/null @@ -1,116 +0,0 @@ -#!/usr/bin/env bats - -setup_file() { - PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" - export PROJECT_ROOT - - ORIGINAL_HOME="${HOME:-}" - export ORIGINAL_HOME - - HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-apps-module.XXXXXX")" - export HOME - - mkdir -p "$HOME" -} - -teardown_file() { - rm -rf "$HOME" - if [[ -n "${ORIGINAL_HOME:-}" ]]; then - export HOME="$ORIGINAL_HOME" - fi -} - -@test "clean_ds_store_tree reports dry-run summary" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true /bin/bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/clean/apps.sh" -start_inline_spinner() { :; } -stop_section_spinner() { :; } -note_activity() { :; } -get_file_size() { echo 10; } -bytes_to_human() { echo "0B"; } -files_cleaned=0 -total_size_cleaned=0 -total_items=0 -mkdir -p "$HOME/test_ds" -touch "$HOME/test_ds/.DS_Store" -clean_ds_store_tree "$HOME/test_ds" "DS test" -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"DS test"* ]] -} - -@test "scan_installed_apps uses cache when fresh" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/clean/apps.sh" -mkdir -p "$HOME/.cache/mole" -echo "com.example.App" > "$HOME/.cache/mole/installed_apps_cache" -get_file_mtime() { date +%s; } -debug_log() { :; } -scan_installed_apps "$HOME/installed.txt" -cat "$HOME/installed.txt" -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"com.example.App"* ]] -} - -@test "is_bundle_orphaned returns true for old uninstalled bundle" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" ORPHAN_AGE_THRESHOLD=60 bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/clean/apps.sh" -should_protect_data() { return 1; } -get_file_mtime() { echo 0; } -if is_bundle_orphaned "com.example.Old" "$HOME/old" "$HOME/installed.txt"; then - echo "orphan" -fi -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"orphan"* ]] -} - -@test "clean_orphaned_app_data skips when no permission" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/clean/apps.sh" -ls() { return 1; } -stop_section_spinner() { :; } -clean_orphaned_app_data -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Skipped: No permission"* ]] -} - -@test "is_critical_system_component matches known system services" { - run bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/app_protection.sh" -is_critical_system_component "backgroundtaskmanagement" && echo "yes" -is_critical_system_component "SystemSettings" && echo "yes" -EOF - [ "$status" -eq 0 ] - [[ "${lines[0]}" == "yes" ]] - [[ "${lines[1]}" == "yes" ]] -} - -@test "is_critical_system_component ignores non-system names" { - run bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/app_protection.sh" -if is_critical_system_component "myapp"; then - echo "bad" -else - echo "ok" -fi -EOF - [ "$status" -eq 0 ] - [[ "$output" == "ok" ]] -} diff --git a/tests/clean_browser_versions.bats b/tests/clean_browser_versions.bats deleted file mode 100644 index 41abf61..0000000 --- a/tests/clean_browser_versions.bats +++ /dev/null @@ -1,322 +0,0 @@ -#!/usr/bin/env bats - -setup_file() { - PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" - export PROJECT_ROOT - - ORIGINAL_HOME="${HOME:-}" - export ORIGINAL_HOME - - HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-browser-cleanup.XXXXXX")" - export HOME - - mkdir -p "$HOME" -} - -teardown_file() { - rm -rf "$HOME" - if [[ -n "${ORIGINAL_HOME:-}" ]]; then - export HOME="$ORIGINAL_HOME" - fi -} - -@test "clean_chrome_old_versions skips when Chrome is running" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/clean/user.sh" - -# Mock pgrep to simulate Chrome running -pgrep() { return 0; } -export -f pgrep - -clean_chrome_old_versions -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Google Chrome running"* ]] - [[ "$output" == *"old versions cleanup skipped"* ]] -} - -@test "clean_chrome_old_versions removes old versions but keeps current" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/clean/user.sh" - -# Mock pgrep to simulate Chrome not running -pgrep() { return 1; } -export -f pgrep - -# Create mock Chrome directory structure -CHROME_APP="$HOME/Applications/Google Chrome.app" -VERSIONS_DIR="$CHROME_APP/Contents/Frameworks/Google Chrome Framework.framework/Versions" -mkdir -p "$VERSIONS_DIR"/{128.0.0.0,129.0.0.0,130.0.0.0} - -# Create Current symlink pointing to 130.0.0.0 -ln -s "130.0.0.0" "$VERSIONS_DIR/Current" - -# Mock functions -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 - -# Initialize counters -files_cleaned=0 -total_size_cleaned=0 -total_items=0 - -clean_chrome_old_versions - -# Verify output mentions old versions cleanup -echo "Cleaned: $files_cleaned items" -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Chrome old versions"* ]] - [[ "$output" == *"dry"* ]] - [[ "$output" == *"Cleaned: 2 items"* ]] -} - -@test "clean_chrome_old_versions respects whitelist" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/clean/user.sh" - -# Mock pgrep to simulate Chrome not running -pgrep() { return 1; } -export -f pgrep - -# Create mock Chrome directory structure -CHROME_APP="$HOME/Applications/Google Chrome.app" -VERSIONS_DIR="$CHROME_APP/Contents/Frameworks/Google Chrome Framework.framework/Versions" -mkdir -p "$VERSIONS_DIR"/{128.0.0.0,129.0.0.0,130.0.0.0} - -# Create Current symlink pointing to 130.0.0.0 -ln -s "130.0.0.0" "$VERSIONS_DIR/Current" - -# Mock is_path_whitelisted to protect version 128.0.0.0 -is_path_whitelisted() { - [[ "$1" == *"128.0.0.0"* ]] && return 0 - 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 - -# Initialize counters -files_cleaned=0 -total_size_cleaned=0 -total_items=0 - -clean_chrome_old_versions - -# Should only clean 129.0.0.0 (not 128.0.0.0 which is whitelisted) -echo "Cleaned: $files_cleaned items" -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Cleaned: 1 items"* ]] -} - -@test "clean_edge_updater_old_versions keeps latest version" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/clean/user.sh" - -pgrep() { return 1; } -export -f pgrep - -UPDATER_DIR="$HOME/Library/Application Support/Microsoft/EdgeUpdater/apps/msedge-stable" -mkdir -p "$UPDATER_DIR"/{117.0.2045.60,118.0.2088.46,119.0.2108.9} - -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 - -files_cleaned=0 -total_size_cleaned=0 -total_items=0 - -clean_edge_updater_old_versions - -echo "Cleaned: $files_cleaned items" -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Edge updater old versions"* ]] - [[ "$output" == *"dry"* ]] - [[ "$output" == *"Cleaned: 2 items"* ]] -} - -@test "clean_chrome_old_versions DRY_RUN mode does not delete files" { - # Create test directory - CHROME_APP="$HOME/Applications/Google Chrome.app" - VERSIONS_DIR="$CHROME_APP/Contents/Frameworks/Google Chrome Framework.framework/Versions" - mkdir -p "$VERSIONS_DIR"/{128.0.0.0,130.0.0.0} - - # Remove Current if it exists as a directory, then create symlink - rm -rf "$VERSIONS_DIR/Current" - ln -s "130.0.0.0" "$VERSIONS_DIR/Current" - - # Create a marker file in old version - touch "$VERSIONS_DIR/128.0.0.0/marker.txt" - - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/clean/user.sh" - -pgrep() { return 1; } -is_path_whitelisted() { return 1; } -get_path_size_kb() { echo "10240"; } -bytes_to_human() { echo "10M"; } -note_activity() { :; } -export -f pgrep is_path_whitelisted get_path_size_kb bytes_to_human note_activity - -files_cleaned=0 -total_size_cleaned=0 -total_items=0 - -clean_chrome_old_versions -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"dry"* ]] - # Verify marker file still exists (not deleted in dry run) - [ -f "$VERSIONS_DIR/128.0.0.0/marker.txt" ] -} - -@test "clean_chrome_old_versions handles missing Current symlink gracefully" { - # Use a fresh temp directory for this test - TEST_HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-test5.XXXXXX")" - - run env HOME="$TEST_HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/clean/user.sh" - -pgrep() { return 1; } -is_path_whitelisted() { return 1; } -get_path_size_kb() { echo "10240"; } -bytes_to_human() { echo "10M"; } -note_activity() { :; } -export -f pgrep is_path_whitelisted get_path_size_kb bytes_to_human note_activity - -# Initialize counters to prevent unbound variable errors -files_cleaned=0 -total_size_cleaned=0 -total_items=0 - -# Create Chrome app without Current symlink -CHROME_APP="$HOME/Applications/Google Chrome.app" -VERSIONS_DIR="$CHROME_APP/Contents/Frameworks/Google Chrome Framework.framework/Versions" -mkdir -p "$VERSIONS_DIR"/{128.0.0.0,129.0.0.0} -# No Current symlink created - -clean_chrome_old_versions -EOF - - rm -rf "$TEST_HOME" - [ "$status" -eq 0 ] - # Should exit gracefully with no output -} - -@test "clean_edge_old_versions skips when Edge is running" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/clean/user.sh" - -# Mock pgrep to simulate Edge running -pgrep() { return 0; } -export -f pgrep - -clean_edge_old_versions -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Microsoft Edge running"* ]] - [[ "$output" == *"old versions cleanup skipped"* ]] -} - -@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' -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 - -files_cleaned=0 -total_size_cleaned=0 -total_items=0 - -clean_edge_old_versions - -echo "Cleaned: $files_cleaned items" -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Edge old versions"* ]] - [[ "$output" == *"dry"* ]] - [[ "$output" == *"Cleaned: 2 items"* ]] -} - -@test "clean_edge_old_versions handles no old versions gracefully" { - # 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' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/clean/user.sh" - -pgrep() { return 1; } -is_path_whitelisted() { return 1; } -get_path_size_kb() { echo "10240"; } -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 - - rm -rf "$TEST_HOME" - [ "$status" -eq 0 ] - # Should exit gracefully with no cleanup output - [[ "$output" != *"Edge old versions"* ]] -} diff --git a/tests/clean_core.bats b/tests/clean_core.bats deleted file mode 100644 index 9a0c41f..0000000 --- a/tests/clean_core.bats +++ /dev/null @@ -1,354 +0,0 @@ -#!/usr/bin/env bats - -setup_file() { - PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" - export PROJECT_ROOT - - ORIGINAL_HOME="${HOME:-}" - export ORIGINAL_HOME - - HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-clean-home.XXXXXX")" - export HOME - - mkdir -p "$HOME" -} - -teardown_file() { - rm -rf "$HOME" - if [[ -n "${ORIGINAL_HOME:-}" ]]; then - export HOME="$ORIGINAL_HOME" - fi -} - -setup() { - export TERM="xterm-256color" - rm -rf "${HOME:?}"/* - rm -rf "$HOME/Library" "$HOME/.config" - mkdir -p "$HOME/Library/Caches" "$HOME/.config/mole" -} - -@test "mo clean --dry-run skips system cleanup in non-interactive mode" { - run env HOME="$HOME" MOLE_TEST_MODE=1 "$PROJECT_ROOT/mole" clean --dry-run - [ "$status" -eq 0 ] - [[ "$output" == *"Dry Run Mode"* ]] - [[ "$output" != *"Deep system-level cleanup"* ]] -} - -@test "mo clean --dry-run reports user cache without deleting it" { - mkdir -p "$HOME/Library/Caches/TestApp" - echo "cache data" > "$HOME/Library/Caches/TestApp/cache.tmp" - - run env HOME="$HOME" MOLE_TEST_MODE=1 "$PROJECT_ROOT/mole" clean --dry-run - [ "$status" -eq 0 ] - [[ "$output" == *"User app cache"* ]] - [[ "$output" == *"Potential space"* ]] - [ -f "$HOME/Library/Caches/TestApp/cache.tmp" ] -} - -@test "mo clean honors whitelist entries" { - mkdir -p "$HOME/Library/Caches/WhitelistedApp" - echo "keep me" > "$HOME/Library/Caches/WhitelistedApp/data.tmp" - - cat > "$HOME/.config/mole/whitelist" << EOF -$HOME/Library/Caches/WhitelistedApp* -EOF - - run env HOME="$HOME" MOLE_TEST_MODE=1 "$PROJECT_ROOT/mole" clean --dry-run - [ "$status" -eq 0 ] - [[ "$output" == *"Protected"* ]] - [ -f "$HOME/Library/Caches/WhitelistedApp/data.tmp" ] -} - -@test "mo clean honors whitelist entries with $HOME literal" { - mkdir -p "$HOME/Library/Caches/WhitelistedApp" - echo "keep me" > "$HOME/Library/Caches/WhitelistedApp/data.tmp" - - cat > "$HOME/.config/mole/whitelist" << 'EOF' -$HOME/Library/Caches/WhitelistedApp* -EOF - - run env HOME="$HOME" MOLE_TEST_MODE=1 "$PROJECT_ROOT/mole" clean --dry-run - [ "$status" -eq 0 ] - [[ "$output" == *"Protected"* ]] - [ -f "$HOME/Library/Caches/WhitelistedApp/data.tmp" ] -} - -@test "mo clean protects Maven repository by default" { - mkdir -p "$HOME/.m2/repository/org/example" - echo "dependency" > "$HOME/.m2/repository/org/example/lib.jar" - - run env HOME="$HOME" MOLE_TEST_MODE=1 "$PROJECT_ROOT/mole" clean --dry-run - [ "$status" -eq 0 ] - [ -f "$HOME/.m2/repository/org/example/lib.jar" ] - [[ "$output" != *"Maven repository cache"* ]] -} - -@test "FINDER_METADATA_SENTINEL in whitelist protects .DS_Store files" { - mkdir -p "$HOME/Documents" - touch "$HOME/Documents/.DS_Store" - - cat > "$HOME/.config/mole/whitelist" << EOF -FINDER_METADATA_SENTINEL -EOF - - # Test whitelist logic directly instead of running full clean - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/manage/whitelist.sh" -load_whitelist -if is_whitelisted "$HOME/Documents/.DS_Store"; then - echo "protected by whitelist" -fi -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"protected by whitelist"* ]] - [ -f "$HOME/Documents/.DS_Store" ] -} - -@test "clean_recent_items removes shared file lists" { - local shared_dir="$HOME/Library/Application Support/com.apple.sharedfilelist" - mkdir -p "$shared_dir" - touch "$shared_dir/com.apple.LSSharedFileList.RecentApplications.sfl2" - touch "$shared_dir/com.apple.LSSharedFileList.RecentDocuments.sfl2" - - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/clean/user.sh" -safe_clean() { - echo "safe_clean $1" -} -clean_recent_items -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Recent"* ]] -} - -@test "clean_recent_items handles missing shared directory" { - rm -rf "$HOME/Library/Application Support/com.apple.sharedfilelist" - - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/clean/user.sh" -safe_clean() { - echo "safe_clean $1" -} -clean_recent_items -EOF - - [ "$status" -eq 0 ] -} - -@test "clean_mail_downloads skips cleanup when size below threshold" { - mkdir -p "$HOME/Library/Mail Downloads" - echo "test" > "$HOME/Library/Mail Downloads/small.txt" - - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/clean/user.sh" -clean_mail_downloads -EOF - - [ "$status" -eq 0 ] - [ -f "$HOME/Library/Mail Downloads/small.txt" ] -} - -@test "clean_mail_downloads removes old attachments" { - mkdir -p "$HOME/Library/Mail Downloads" - touch "$HOME/Library/Mail Downloads/old.pdf" - touch -t 202301010000 "$HOME/Library/Mail Downloads/old.pdf" - - dd if=/dev/zero of="$HOME/Library/Mail Downloads/dummy.dat" bs=1024 count=6000 2>/dev/null - - [ -f "$HOME/Library/Mail Downloads/old.pdf" ] - - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/clean/user.sh" -clean_mail_downloads -EOF - - [ "$status" -eq 0 ] - [ ! -f "$HOME/Library/Mail Downloads/old.pdf" ] -} - -@test "clean_time_machine_failed_backups detects running backup correctly" { - if ! command -v tmutil > /dev/null 2>&1; then - skip "tmutil not available" - fi - - local mock_bin="$HOME/bin" - mkdir -p "$mock_bin" - - cat > "$mock_bin/tmutil" << 'MOCK_TMUTIL' -#!/bin/bash -if [[ "$1" == "status" ]]; then - cat << 'TMUTIL_OUTPUT' -Backup session status: -{ - ClientID = "com.apple.backupd"; - Running = 0; -} -TMUTIL_OUTPUT -elif [[ "$1" == "destinationinfo" ]]; then - cat << 'DEST_OUTPUT' -==================================================== -Name : TestBackup -Kind : Local -Mount Point : /Volumes/TestBackup -ID : 12345678-1234-1234-1234-123456789012 -==================================================== -DEST_OUTPUT -fi -MOCK_TMUTIL - chmod +x "$mock_bin/tmutil" - - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="$mock_bin:$PATH" bash --noprofile --norc << 'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/clean/system.sh" - -clean_time_machine_failed_backups -EOF - - [ "$status" -eq 0 ] - [[ "$output" != *"Time Machine backup in progress, skipping cleanup"* ]] -} - -@test "clean_time_machine_failed_backups skips when backup is actually running" { - if ! command -v tmutil > /dev/null 2>&1; then - skip "tmutil not available" - fi - - local mock_bin="$HOME/bin" - mkdir -p "$mock_bin" - - cat > "$mock_bin/tmutil" << 'MOCK_TMUTIL' -#!/bin/bash -if [[ "$1" == "status" ]]; then - cat << 'TMUTIL_OUTPUT' -Backup session status: -{ - ClientID = "com.apple.backupd"; - Running = 1; -} -TMUTIL_OUTPUT -elif [[ "$1" == "destinationinfo" ]]; then - cat << 'DEST_OUTPUT' -==================================================== -Name : TestBackup -Kind : Local -Mount Point : /Volumes/TestBackup -ID : 12345678-1234-1234-1234-123456789012 -==================================================== -DEST_OUTPUT -fi -MOCK_TMUTIL - chmod +x "$mock_bin/tmutil" - - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="$mock_bin:$PATH" bash --noprofile --norc << 'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/clean/system.sh" - -clean_time_machine_failed_backups -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Time Machine backup in progress, skipping cleanup"* ]] -} - -@test "clean_empty_library_items removes nested empty directories in Application Support" { - # Create nested empty directory structure - mkdir -p "$HOME/Library/Application Support/UninstalledApp1/SubDir/DeepDir" - mkdir -p "$HOME/Library/Application Support/UninstalledApp2/Cache" - mkdir -p "$HOME/Library/Application Support/ActiveApp/Data" - mkdir -p "$HOME/Library/Caches/EmptyCache/SubCache" - - # Create a file in ActiveApp to make it non-empty - touch "$HOME/Library/Application Support/ActiveApp/Data/config.json" - - # Create top-level empty directory in Library - mkdir -p "$HOME/Library/EmptyTopLevel" - - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/clean/user.sh" - -# Mock dependencies -is_path_whitelisted() { return 1; } -is_critical_system_component() { return 1; } -bytes_to_human() { echo "$1"; } -note_activity() { :; } -safe_clean() { - # Actually remove the directories for testing - for path in "$@"; do - if [ "$path" != "${@: -1}" ]; then # Skip the description (last arg) - rm -rf "$path" 2>/dev/null || true - fi - done -} - -clean_empty_library_items -EOF - - [ "$status" -eq 0 ] - - # Empty nested dirs should be removed - [ ! -d "$HOME/Library/Application Support/UninstalledApp1" ] - [ ! -d "$HOME/Library/Application Support/UninstalledApp2" ] - [ ! -d "$HOME/Library/Caches/EmptyCache" ] - [ ! -d "$HOME/Library/EmptyTopLevel" ] - - # Non-empty directory should remain - [ -d "$HOME/Library/Application Support/ActiveApp" ] - [ -f "$HOME/Library/Application Support/ActiveApp/Data/config.json" ] -} - -@test "clean_empty_library_items respects whitelist for empty directories" { - mkdir -p "$HOME/Library/Application Support/ProtectedEmptyApp" - mkdir -p "$HOME/Library/Application Support/UnprotectedEmptyApp" - mkdir -p "$HOME/.config/mole" - - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/clean/user.sh" - -# Mock dependencies -is_critical_system_component() { return 1; } -bytes_to_human() { echo "$1"; } -note_activity() { :; } - -# Mock whitelist to protect ProtectedEmptyApp -is_path_whitelisted() { - [[ "$1" == *"ProtectedEmptyApp"* ]] -} - -safe_clean() { - # Actually remove the directories for testing - for path in "$@"; do - if [ "$path" != "${@: -1}" ]; then # Skip the description (last arg) - rm -rf "$path" 2>/dev/null || true - fi - done -} - -clean_empty_library_items -EOF - - [ "$status" -eq 0 ] - - # Whitelisted directory should remain even if empty - [ -d "$HOME/Library/Application Support/ProtectedEmptyApp" ] - - # Non-whitelisted directory should be removed - [ ! -d "$HOME/Library/Application Support/UnprotectedEmptyApp" ] -} diff --git a/tests/clean_dev_caches.bats b/tests/clean_dev_caches.bats deleted file mode 100644 index 96377d0..0000000 --- a/tests/clean_dev_caches.bats +++ /dev/null @@ -1,107 +0,0 @@ -#!/usr/bin/env bats - -setup_file() { - PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" - export PROJECT_ROOT - - ORIGINAL_HOME="${HOME:-}" - export ORIGINAL_HOME - - HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-dev-caches.XXXXXX")" - export HOME - - mkdir -p "$HOME" -} - -teardown_file() { - rm -rf "$HOME" - if [[ -n "${ORIGINAL_HOME:-}" ]]; then - export HOME="$ORIGINAL_HOME" - fi -} - -@test "clean_dev_npm cleans orphaned pnpm store" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/clean/dev.sh" -start_section_spinner() { :; } -stop_section_spinner() { :; } -clean_tool_cache() { echo "$1"; } -safe_clean() { echo "$2"; } -note_activity() { :; } -run_with_timeout() { shift; "$@"; } -pnpm() { - if [[ "$1" == "store" && "$2" == "prune" ]]; then - return 0 - fi - if [[ "$1" == "store" && "$2" == "path" ]]; then - echo "/tmp/pnpm-store" - return 0 - fi - return 0 -} -npm() { return 0; } -export -f pnpm npm -clean_dev_npm -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Orphaned pnpm store"* ]] -} - -@test "clean_dev_docker skips when daemon not running" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MO_DEBUG=1 DRY_RUN=false bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/clean/dev.sh" -start_section_spinner() { :; } -stop_section_spinner() { :; } -run_with_timeout() { return 1; } -clean_tool_cache() { echo "$1"; } -safe_clean() { echo "$2"; } -debug_log() { echo "$*"; } -docker() { return 1; } -export -f docker -clean_dev_docker -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Docker daemon not running"* ]] - [[ "$output" != *"Docker build cache"* ]] -} - -@test "clean_developer_tools runs key stages" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/clean/dev.sh" -stop_section_spinner() { :; } -clean_sqlite_temp_files() { :; } -clean_dev_npm() { echo "npm"; } -clean_homebrew() { echo "brew"; } -clean_project_caches() { :; } -clean_dev_python() { :; } -clean_dev_go() { :; } -clean_dev_rust() { :; } -clean_dev_docker() { :; } -clean_dev_cloud() { :; } -clean_dev_nix() { :; } -clean_dev_shell() { :; } -clean_dev_frontend() { :; } -clean_dev_mobile() { :; } -clean_dev_jvm() { :; } -clean_dev_other_langs() { :; } -clean_dev_cicd() { :; } -clean_dev_database() { :; } -clean_dev_api_tools() { :; } -clean_dev_network() { :; } -clean_dev_misc() { :; } -safe_clean() { :; } -debug_log() { :; } -clean_developer_tools -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"npm"* ]] - [[ "$output" == *"brew"* ]] -} diff --git a/tests/clean_misc.bats b/tests/clean_misc.bats deleted file mode 100644 index c5e0d3c..0000000 --- a/tests/clean_misc.bats +++ /dev/null @@ -1,224 +0,0 @@ -#!/usr/bin/env bats - -setup_file() { - PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" - export PROJECT_ROOT - - ORIGINAL_HOME="${HOME:-}" - export ORIGINAL_HOME - - HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-clean-extras.XXXXXX")" - export HOME - - mkdir -p "$HOME" -} - -teardown_file() { - rm -rf "$HOME" - if [[ -n "${ORIGINAL_HOME:-}" ]]; then - export HOME="$ORIGINAL_HOME" - fi -} - -@test "clean_cloud_storage calls expected caches" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/clean/user.sh" -stop_section_spinner() { :; } -safe_clean() { echo "$2"; } -clean_cloud_storage -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Dropbox cache"* ]] - [[ "$output" == *"Google Drive cache"* ]] -} - -@test "clean_virtualization_tools hits cache paths" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/clean/user.sh" -stop_section_spinner() { :; } -safe_clean() { echo "$2"; } -clean_virtualization_tools -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"VMware Fusion cache"* ]] - [[ "$output" == *"Parallels cache"* ]] -} - -@test "clean_email_clients calls expected caches" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/clean/app_caches.sh" -safe_clean() { echo "$2"; } -clean_email_clients -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Spark cache"* ]] - [[ "$output" == *"Airmail cache"* ]] -} - -@test "clean_note_apps calls expected caches" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/clean/app_caches.sh" -safe_clean() { echo "$2"; } -clean_note_apps -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Notion cache"* ]] - [[ "$output" == *"Obsidian cache"* ]] -} - -@test "clean_task_apps calls expected caches" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/clean/app_caches.sh" -safe_clean() { echo "$2"; } -clean_task_apps -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Todoist cache"* ]] - [[ "$output" == *"Any.do cache"* ]] -} - -@test "scan_external_volumes skips when no volumes" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' -set -euo pipefail -export DRY_RUN="false" -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/clean/user.sh" -run_with_timeout() { return 1; } -# Mock missing dependencies and UI to ensure test passes regardless of volumes -clean_ds_store_tree() { :; } -start_section_spinner() { :; } -stop_section_spinner() { :; } -scan_external_volumes -EOF - - [ "$status" -eq 0 ] -} - -@test "clean_video_tools calls expected caches" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/clean/app_caches.sh" -safe_clean() { echo "$2"; } -clean_video_tools -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"ScreenFlow cache"* ]] - [[ "$output" == *"Final Cut Pro cache"* ]] -} - -@test "clean_video_players calls expected caches" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/clean/app_caches.sh" -safe_clean() { echo "$2"; } -clean_video_players -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"IINA cache"* ]] - [[ "$output" == *"VLC cache"* ]] -} - -@test "clean_3d_tools calls expected caches" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/clean/app_caches.sh" -safe_clean() { echo "$2"; } -clean_3d_tools -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Blender cache"* ]] - [[ "$output" == *"Cinema 4D cache"* ]] -} - -@test "clean_gaming_platforms calls expected caches" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/clean/app_caches.sh" -safe_clean() { echo "$2"; } -clean_gaming_platforms -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Steam cache"* ]] - [[ "$output" == *"Epic Games cache"* ]] -} - -@test "clean_translation_apps calls expected caches" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/clean/app_caches.sh" -safe_clean() { echo "$2"; } -clean_translation_apps -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Youdao Dictionary cache"* ]] - [[ "$output" == *"Eudict cache"* ]] -} - -@test "clean_launcher_apps calls expected caches" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/clean/app_caches.sh" -safe_clean() { echo "$2"; } -clean_launcher_apps -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Alfred cache"* ]] - [[ "$output" == *"The Unarchiver cache"* ]] -} - -@test "clean_remote_desktop calls expected caches" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/clean/app_caches.sh" -safe_clean() { echo "$2"; } -clean_remote_desktop -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"TeamViewer cache"* ]] - [[ "$output" == *"AnyDesk cache"* ]] -} - -@test "clean_system_utils calls expected caches" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/clean/app_caches.sh" -safe_clean() { echo "$2"; } -clean_system_utils -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Input Source Pro cache"* ]] - [[ "$output" == *"WakaTime cache"* ]] -} - -@test "clean_shell_utils calls expected caches" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/clean/app_caches.sh" -safe_clean() { echo "$2"; } -clean_shell_utils -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Zsh completion cache"* ]] - [[ "$output" == *"wget HSTS cache"* ]] -} diff --git a/tests/clean_system_caches.bats b/tests/clean_system_caches.bats deleted file mode 100644 index 295113d..0000000 --- a/tests/clean_system_caches.bats +++ /dev/null @@ -1,179 +0,0 @@ -#!/usr/bin/env bats - -setup_file() { - PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" - export PROJECT_ROOT - - ORIGINAL_HOME="${HOME:-}" - export ORIGINAL_HOME - - HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-clean-caches.XXXXXX")" - export HOME - - mkdir -p "$HOME" - mkdir -p "$HOME/.cache/mole" - mkdir -p "$HOME/Library/Caches" - mkdir -p "$HOME/Library/Logs" -} - -teardown_file() { - rm -rf "$HOME" - if [[ -n "${ORIGINAL_HOME:-}" ]]; then - export HOME="$ORIGINAL_HOME" - fi -} - -setup() { - source "$PROJECT_ROOT/lib/core/common.sh" - source "$PROJECT_ROOT/lib/clean/caches.sh" - - # Mock run_with_timeout to skip timeout overhead in tests - # shellcheck disable=SC2329 - run_with_timeout() { - shift # Remove timeout argument - "$@" - } - export -f run_with_timeout - - rm -f "$HOME/.cache/mole/permissions_granted" -} - -@test "check_tcc_permissions skips in non-interactive mode" { - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; source '$PROJECT_ROOT/lib/clean/caches.sh'; check_tcc_permissions" < /dev/null - [ "$status" -eq 0 ] - [[ ! -f "$HOME/.cache/mole/permissions_granted" ]] -} - -@test "check_tcc_permissions skips when permissions already granted" { - mkdir -p "$HOME/.cache/mole" - touch "$HOME/.cache/mole/permissions_granted" - - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; source '$PROJECT_ROOT/lib/clean/caches.sh'; [[ -t 1 ]] || true; check_tcc_permissions" - [ "$status" -eq 0 ] -} - -@test "check_tcc_permissions validates protected directories" { - - [[ -d "$HOME/Library/Caches" ]] - [[ -d "$HOME/Library/Logs" ]] - [[ -d "$HOME/.cache/mole" ]] - - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; source '$PROJECT_ROOT/lib/clean/caches.sh'; check_tcc_permissions < /dev/null" - [ "$status" -eq 0 ] -} - -@test "clean_service_worker_cache returns early when path doesn't exist" { - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; source '$PROJECT_ROOT/lib/clean/caches.sh'; clean_service_worker_cache 'TestBrowser' '/nonexistent/path'" - [ "$status" -eq 0 ] -} - -@test "clean_service_worker_cache handles empty cache directory" { - local test_cache="$HOME/test_sw_cache" - mkdir -p "$test_cache" - - run bash -c " - run_with_timeout() { shift; \"\$@\"; } - export -f run_with_timeout - source '$PROJECT_ROOT/lib/core/common.sh' - source '$PROJECT_ROOT/lib/clean/caches.sh' - clean_service_worker_cache 'TestBrowser' '$test_cache' - " - [ "$status" -eq 0 ] - - rm -rf "$test_cache" -} - -@test "clean_service_worker_cache protects specified domains" { - local test_cache="$HOME/test_sw_cache" - mkdir -p "$test_cache/abc123_https_capcut.com_0" - mkdir -p "$test_cache/def456_https_example.com_0" - - run bash -c " - run_with_timeout() { - local timeout=\"\$1\" - shift - if [[ \"\$1\" == \"get_path_size_kb\" ]]; then - echo 0 - return 0 - fi - if [[ \"\$1\" == \"sh\" ]]; then - printf '%s\n' \ - '$test_cache/abc123_https_capcut.com_0' \ - '$test_cache/def456_https_example.com_0' - return 0 - fi - \"\$@\" - } - export -f run_with_timeout - export DRY_RUN=true - export PROTECTED_SW_DOMAINS=(capcut.com photopea.com) - source '$PROJECT_ROOT/lib/core/common.sh' - source '$PROJECT_ROOT/lib/clean/caches.sh' - clean_service_worker_cache 'TestBrowser' '$test_cache' - " - [ "$status" -eq 0 ] - - [[ -d "$test_cache/abc123_https_capcut.com_0" ]] - - rm -rf "$test_cache" -} - -@test "clean_project_caches completes without errors" { - mkdir -p "$HOME/projects/test-app/.next/cache" - mkdir -p "$HOME/projects/python-app/__pycache__" - - touch "$HOME/projects/test-app/.next/cache/test.cache" - touch "$HOME/projects/python-app/__pycache__/module.pyc" - - run bash -c " - export DRY_RUN=true - source '$PROJECT_ROOT/lib/core/common.sh' - source '$PROJECT_ROOT/lib/clean/caches.sh' - clean_project_caches - " - [ "$status" -eq 0 ] - - rm -rf "$HOME/projects" -} - -@test "clean_project_caches handles timeout gracefully" { - if ! command -v gtimeout >/dev/null 2>&1 && ! command -v timeout >/dev/null 2>&1; then - skip "gtimeout/timeout not available" - fi - - mkdir -p "$HOME/test-project/.next" - - function find() { - sleep 2 # Simulate slow find - echo "$HOME/test-project/.next" - } - export -f find - - timeout_cmd="timeout" - command -v timeout >/dev/null 2>&1 || timeout_cmd="gtimeout" - - run $timeout_cmd 15 bash -c " - source '$PROJECT_ROOT/lib/core/common.sh' - source '$PROJECT_ROOT/lib/clean/caches.sh' - clean_project_caches - " - [ "$status" -eq 0 ] || [ "$status" -eq 124 ] - - rm -rf "$HOME/test-project" -} - -@test "clean_project_caches excludes Library and Trash directories" { - mkdir -p "$HOME/Library/.next/cache" - mkdir -p "$HOME/.Trash/.next/cache" - mkdir -p "$HOME/projects/.next/cache" - - run bash -c " - export DRY_RUN=true - source '$PROJECT_ROOT/lib/core/common.sh' - source '$PROJECT_ROOT/lib/clean/caches.sh' - clean_project_caches - " - [ "$status" -eq 0 ] - - rm -rf "$HOME/projects" -} diff --git a/tests/clean_system_maintenance.bats b/tests/clean_system_maintenance.bats deleted file mode 100644 index 6208864..0000000 --- a/tests/clean_system_maintenance.bats +++ /dev/null @@ -1,1110 +0,0 @@ -#!/usr/bin/env bats - -setup_file() { - PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" - export PROJECT_ROOT - - ORIGINAL_HOME="${HOME:-}" - export ORIGINAL_HOME - - HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-system-clean.XXXXXX")" - export HOME - - mkdir -p "$HOME" -} - -teardown_file() { - rm -rf "$HOME" - if [[ -n "${ORIGINAL_HOME:-}" ]]; then - export HOME="$ORIGINAL_HOME" - fi -} - -@test "clean_deep_system issues safe sudo deletions" { - run bash --noprofile --norc <<'EOF' -set -euo pipefail -CALL_LOG="$HOME/system_calls.log" -> "$CALL_LOG" -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/clean/system.sh" - -sudo() { return 0; } -safe_sudo_find_delete() { - echo "safe_sudo_find_delete:$1:$2" >> "$CALL_LOG" - return 0 -} -safe_sudo_remove() { - echo "safe_sudo_remove:$1" >> "$CALL_LOG" - return 0 -} -log_success() { :; } -start_section_spinner() { :; } -stop_section_spinner() { :; } -is_sip_enabled() { return 1; } -get_file_mtime() { echo 0; } -get_path_size_kb() { echo 0; } -find() { return 0; } -run_with_timeout() { shift; "$@"; } - -clean_deep_system -cat "$CALL_LOG" -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"/Library/Caches"* ]] - [[ "$output" == *"/private/tmp"* ]] - [[ "$output" == *"/private/var/log"* ]] -} - -@test "clean_deep_system skips /Library/Updates when SIP enabled" { - run bash --noprofile --norc <<'EOF' -set -euo pipefail -CALL_LOG="$HOME/system_calls_skip.log" -> "$CALL_LOG" -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/clean/system.sh" - -sudo() { return 0; } -safe_sudo_find_delete() { return 0; } -safe_sudo_remove() { - echo "REMOVE:$1" >> "$CALL_LOG" - return 0 -} -log_success() { :; } -start_section_spinner() { :; } -stop_section_spinner() { :; } -is_sip_enabled() { return 0; } # SIP enabled -> skip removal -find() { return 0; } -run_with_timeout() { shift; "$@"; } - -clean_deep_system -cat "$CALL_LOG" -EOF - - [ "$status" -eq 0 ] - [[ "$output" != *"/Library/Updates"* ]] -} - -@test "clean_time_machine_failed_backups exits when tmutil has no destinations" { - 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" == "destinationinfo" ]]; then - echo "No destinations configured" - return 0 - fi - return 0 -} -pgrep() { return 1; } -find() { return 0; } - -clean_time_machine_failed_backups -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"No incomplete backups found"* ]] -} - -@test "clean_local_snapshots skips in non-interactive mode" { - 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(){ :; } - -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 -} -start_section_spinner(){ :; } -stop_section_spinner(){ :; } -note_activity(){ :; } - -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"* ]] -} - -@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' -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(){ :; } -note_activity(){ :; } - -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"* ]] -} - - -@test "clean_homebrew skips when cleaned recently" { - run bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/clean/brew.sh" - -mkdir -p "$HOME/.cache/mole" -date +%s > "$HOME/.cache/mole/brew_last_cleanup" - -brew() { return 0; } - -clean_homebrew -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"cleaned"* ]] -} - -@test "clean_homebrew runs cleanup with timeout stubs" { - run bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/clean/brew.sh" - -mkdir -p "$HOME/.cache/mole" -rm -f "$HOME/.cache/mole/brew_last_cleanup" - - start_inline_spinner(){ :; } - stop_inline_spinner(){ :; } - note_activity(){ :; } - run_with_timeout() { - local duration="$1" - shift - if [[ "$1" == "du" ]]; then - echo "51201 $3" - return 0 - fi - "$@" - } - - brew() { - case "$1" in - cleanup) - echo "Removing: package" - return 0 - ;; - autoremove) - echo "Uninstalling pkg" - return 0 - ;; - *) - return 0 - ;; - esac -} - - clean_homebrew -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Homebrew cleanup"* ]] -} - -@test "check_appstore_updates is skipped for performance" { - run bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/check/all.sh" - -check_appstore_updates -echo "COUNT=$APPSTORE_UPDATE_COUNT" -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"COUNT=0"* ]] -} - -@test "check_macos_update avoids slow softwareupdate scans" { - run bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/check/all.sh" - -defaults() { echo "1"; } - -run_with_timeout() { - local timeout="${1:-}" - shift - if [[ "$timeout" != "10" ]]; then - echo "BAD_TIMEOUT:$timeout" - return 124 - fi - if [[ "${1:-}" == "softwareupdate" && "${2:-}" == "-l" && "${3:-}" == "--no-scan" ]]; then - cat <<'OUT' -Software Update Tool - -Software Update found the following new or updated software: -* Label: macOS 99 -OUT - return 0 - fi - return 124 -} - -start_inline_spinner(){ :; } -stop_inline_spinner(){ :; } - -check_macos_update -echo "MACOS_UPDATE_AVAILABLE=$MACOS_UPDATE_AVAILABLE" -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Update available"* ]] - [[ "$output" == *"MACOS_UPDATE_AVAILABLE=true"* ]] - [[ "$output" != *"BAD_TIMEOUT:"* ]] -} - -@test "check_macos_update clears update flag when softwareupdate reports no updates" { - run bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/check/all.sh" - -defaults() { echo "1"; } - -run_with_timeout() { - local timeout="${1:-}" - shift - if [[ "$timeout" != "10" ]]; then - echo "BAD_TIMEOUT:$timeout" - return 124 - fi - if [[ "${1:-}" == "softwareupdate" && "${2:-}" == "-l" && "${3:-}" == "--no-scan" ]]; then - cat <<'OUT' -Software Update Tool - -Finding available software -No new software available. -OUT - return 0 - fi - return 124 -} - -start_inline_spinner(){ :; } -stop_inline_spinner(){ :; } - -check_macos_update -echo "MACOS_UPDATE_AVAILABLE=$MACOS_UPDATE_AVAILABLE" -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"System up to date"* ]] - [[ "$output" == *"MACOS_UPDATE_AVAILABLE=false"* ]] - [[ "$output" != *"BAD_TIMEOUT:"* ]] -} - -@test "check_macos_update keeps update flag when softwareupdate times out" { - run bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/check/all.sh" - -defaults() { echo "1"; } - -run_with_timeout() { - local timeout="${1:-}" - shift - if [[ "$timeout" != "10" ]]; then - echo "BAD_TIMEOUT:$timeout" - return 124 - fi - if [[ "${1:-}" == "softwareupdate" && "${2:-}" == "-l" && "${3:-}" == "--no-scan" ]]; then - return 124 - fi - return 124 -} - -start_inline_spinner(){ :; } -stop_inline_spinner(){ :; } - -check_macos_update -echo "MACOS_UPDATE_AVAILABLE=$MACOS_UPDATE_AVAILABLE" -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Update available"* ]] - [[ "$output" == *"MACOS_UPDATE_AVAILABLE=true"* ]] - [[ "$output" != *"BAD_TIMEOUT:"* ]] -} - -@test "check_macos_update keeps update flag when softwareupdate returns empty output" { - run bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/check/all.sh" - -defaults() { echo "1"; } - -run_with_timeout() { - local timeout="${1:-}" - shift - if [[ "$timeout" != "10" ]]; then - echo "BAD_TIMEOUT:$timeout" - return 124 - fi - if [[ "${1:-}" == "softwareupdate" && "${2:-}" == "-l" && "${3:-}" == "--no-scan" ]]; then - return 0 - fi - return 124 -} - -start_inline_spinner(){ :; } -stop_inline_spinner(){ :; } - -check_macos_update -echo "MACOS_UPDATE_AVAILABLE=$MACOS_UPDATE_AVAILABLE" -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Update available"* ]] - [[ "$output" == *"MACOS_UPDATE_AVAILABLE=true"* ]] - [[ "$output" != *"BAD_TIMEOUT:"* ]] -} - -@test "check_macos_update skips softwareupdate when defaults shows no updates" { - run bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/check/all.sh" - -defaults() { echo "0"; } - -run_with_timeout() { - echo "SHOULD_NOT_CALL_SOFTWAREUPDATE" - return 0 -} - -check_macos_update -echo "MACOS_UPDATE_AVAILABLE=$MACOS_UPDATE_AVAILABLE" -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"System up to date"* ]] - [[ "$output" == *"MACOS_UPDATE_AVAILABLE=false"* ]] - [[ "$output" != *"SHOULD_NOT_CALL_SOFTWAREUPDATE"* ]] -} - -@test "check_macos_update outputs debug info when MO_DEBUG set" { - run bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/check/all.sh" - -defaults() { echo "1"; } - -export MO_DEBUG=1 - -run_with_timeout() { - local timeout="${1:-}" - shift - if [[ "${1:-}" == "softwareupdate" && "${2:-}" == "-l" && "${3:-}" == "--no-scan" ]]; then - echo "No new software available." - return 0 - fi - return 124 -} - -start_inline_spinner(){ :; } -stop_inline_spinner(){ :; } - -check_macos_update 2>&1 -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"[DEBUG] softwareupdate exit status:"* ]] -} - -@test "run_with_timeout succeeds without GNU timeout" { - run bash --noprofile --norc -c ' - set -euo pipefail - PATH="/usr/bin:/bin" - unset MO_TIMEOUT_INITIALIZED MO_TIMEOUT_BIN - source "'"$PROJECT_ROOT"'/lib/core/common.sh" - run_with_timeout 1 sleep 0.1 - ' - [ "$status" -eq 0 ] -} - -@test "run_with_timeout enforces timeout and returns 124" { - run bash --noprofile --norc -c ' - set -euo pipefail - PATH="/usr/bin:/bin" - unset MO_TIMEOUT_INITIALIZED MO_TIMEOUT_BIN - source "'"$PROJECT_ROOT"'/lib/core/common.sh" - run_with_timeout 1 sleep 5 - ' - [ "$status" -eq 124 ] -} - - -@test "opt_saved_state_cleanup removes old saved states" { - local state_dir="$HOME/Library/Saved Application State" - mkdir -p "$state_dir/com.example.app.savedState" - touch "$state_dir/com.example.app.savedState/data.plist" - - touch -t 202301010000 "$state_dir/com.example.app.savedState/data.plist" - - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/optimize/tasks.sh" -opt_saved_state_cleanup -EOF - - [ "$status" -eq 0 ] -} - -@test "opt_saved_state_cleanup handles missing state directory" { - rm -rf "$HOME/Library/Saved Application State" - - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/optimize/tasks.sh" -opt_saved_state_cleanup -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"App saved states optimized"* ]] -} - -@test "opt_cache_refresh cleans Quick Look cache" { - mkdir -p "$HOME/Library/Caches/com.apple.QuickLook.thumbnailcache" - touch "$HOME/Library/Caches/com.apple.QuickLook.thumbnailcache/test.db" - - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/optimize/tasks.sh" -qlmanage() { return 0; } -cleanup_path() { - local path="$1" - local label="${2:-}" - [[ -e "$path" ]] && rm -rf "$path" 2>/dev/null || true -} -export -f qlmanage cleanup_path -opt_cache_refresh -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"QuickLook thumbnails refreshed"* ]] -} - - -@test "get_path_size_kb returns zero for missing directory" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MO_DEBUG=0 bash --noprofile --norc << 'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -size=$(get_path_size_kb "/nonexistent/path") -echo "$size" -EOF - - [ "$status" -eq 0 ] - [ "$output" = "0" ] -} - -@test "get_path_size_kb calculates directory size" { - mkdir -p "$HOME/test_size" - dd if=/dev/zero of="$HOME/test_size/file.dat" bs=1024 count=10 2>/dev/null - - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MO_DEBUG=0 bash --noprofile --norc << 'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -size=$(get_path_size_kb "$HOME/test_size") -echo "$size" -EOF - - [ "$status" -eq 0 ] - [ "$output" -ge 10 ] -} - - -@test "opt_fix_broken_configs reports fixes" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/optimize/maintenance.sh" -source "$PROJECT_ROOT/lib/optimize/tasks.sh" - -fix_broken_preferences() { - echo 2 -} - -opt_fix_broken_configs -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Repaired 2 corrupted preference files"* ]] -} - - -@test "clean_deep_system cleans memory exception reports" { - run bash --noprofile --norc <<'EOF' -set -euo pipefail -CALL_LOG="$HOME/memory_exception_calls.log" -> "$CALL_LOG" -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/clean/system.sh" - -sudo() { return 0; } -safe_sudo_find_delete() { - echo "safe_sudo_find_delete:$1:$2:$3:$4" >> "$CALL_LOG" - return 0 -} -safe_sudo_remove() { return 0; } -log_success() { :; } -is_sip_enabled() { return 1; } -find() { return 0; } -run_with_timeout() { shift; "$@"; } - -clean_deep_system -cat "$CALL_LOG" -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"reportmemoryexception/MemoryLimitViolations"* ]] - [[ "$output" == *":30:"* ]] # 30-day retention -} - -@test "clean_deep_system cleans diagnostic trace logs" { - run bash --noprofile --norc <<'EOF' -set -euo pipefail -CALL_LOG="$HOME/diag_calls.log" -> "$CALL_LOG" -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/clean/system.sh" - -sudo() { return 0; } -safe_sudo_find_delete() { - echo "safe_sudo_find_delete:$1:$2" >> "$CALL_LOG" - return 0 -} -safe_sudo_remove() { return 0; } -log_success() { :; } -start_section_spinner() { :; } -stop_section_spinner() { :; } -is_sip_enabled() { return 1; } -find() { return 0; } -run_with_timeout() { shift; "$@"; } - -clean_deep_system -cat "$CALL_LOG" -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"diagnostics/Persist"* ]] - [[ "$output" == *"diagnostics/Special"* ]] - [[ "$output" == *"tracev3"* ]] -} - -@test "clean_deep_system validates symbolication cache size before cleaning" { - run bash --noprofile --norc <<'EOF' -set -euo pipefail - -symbolication_size_mb="2048" # 2GB - -if [[ -n "$symbolication_size_mb" && "$symbolication_size_mb" =~ ^[0-9]+$ ]]; then - if [[ $symbolication_size_mb -gt 1024 ]]; then - echo "WOULD_CLEAN=yes" - else - echo "WOULD_CLEAN=no" - fi -else - echo "WOULD_CLEAN=no" -fi -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"WOULD_CLEAN=yes"* ]] -} - -@test "clean_deep_system skips symbolication cache when small" { - run bash --noprofile --norc <<'EOF' -set -euo pipefail - -symbolication_size_mb="500" # 500MB < 1GB - -if [[ -n "$symbolication_size_mb" && "$symbolication_size_mb" =~ ^[0-9]+$ ]]; then - if [[ $symbolication_size_mb -gt 1024 ]]; then - echo "WOULD_CLEAN=yes" - else - echo "WOULD_CLEAN=no" - fi -else - echo "WOULD_CLEAN=no" -fi -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"WOULD_CLEAN=no"* ]] -} - -@test "clean_deep_system handles symbolication cache size check failure" { - run bash --noprofile --norc <<'EOF' -set -euo pipefail - -symbolication_size_mb="" # Empty - simulates failure - -if [[ -n "$symbolication_size_mb" && "$symbolication_size_mb" =~ ^[0-9]+$ ]]; then - if [[ $symbolication_size_mb -gt 1024 ]]; then - echo "WOULD_CLEAN=yes" - else - echo "WOULD_CLEAN=no" - fi -else - echo "WOULD_CLEAN=no" -fi -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"WOULD_CLEAN=no"* ]] -} - - - - - - - - -@test "opt_memory_pressure_relief skips when pressure is normal" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/optimize/tasks.sh" - -memory_pressure() { - echo "System-wide memory free percentage: 50%" - return 0 -} -export -f memory_pressure - -opt_memory_pressure_relief -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Memory pressure already optimal"* ]] -} - -@test "opt_memory_pressure_relief executes purge when pressure is high" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/optimize/tasks.sh" - -memory_pressure() { - echo "System-wide memory free percentage: warning" - return 0 -} -export -f memory_pressure - -sudo() { - if [[ "$1" == "purge" ]]; then - echo "purge:executed" - return 0 - fi - return 1 -} -export -f sudo - -opt_memory_pressure_relief -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Inactive memory released"* ]] - [[ "$output" == *"System responsiveness improved"* ]] -} - -@test "opt_network_stack_optimize skips when network is healthy" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/optimize/tasks.sh" - -route() { - return 0 -} -export -f route - -dscacheutil() { - echo "ip_address: 93.184.216.34" - return 0 -} -export -f dscacheutil - -opt_network_stack_optimize -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Network stack already optimal"* ]] -} - -@test "opt_network_stack_optimize flushes when network has issues" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/optimize/tasks.sh" - -route() { - if [[ "$2" == "get" ]]; then - return 1 - fi - if [[ "$1" == "-n" && "$2" == "flush" ]]; then - echo "route:flushed" - return 0 - fi - return 0 -} -export -f route - -sudo() { - if [[ "$1" == "route" || "$1" == "arp" ]]; then - shift - route "$@" || arp "$@" - return 0 - fi - return 1 -} -export -f sudo - -arp() { - echo "arp:cleared" - return 0 -} -export -f arp - -dscacheutil() { - return 1 -} -export -f dscacheutil - -opt_network_stack_optimize -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Network routing table refreshed"* ]] - [[ "$output" == *"ARP cache cleared"* ]] -} - -@test "opt_disk_permissions_repair skips when permissions are fine" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/optimize/tasks.sh" - -stat() { - if [[ "$2" == "%Su" ]]; then - echo "$USER" - return 0 - fi - command stat "$@" -} -export -f stat - -test() { - if [[ "$1" == "-e" || "$1" == "-w" ]]; then - return 0 - fi - command test "$@" -} -export -f test - -opt_disk_permissions_repair -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"User directory permissions already optimal"* ]] -} - -@test "opt_disk_permissions_repair calls diskutil when needed" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/optimize/tasks.sh" - -stat() { - if [[ "$2" == "%Su" ]]; then - echo "root" - return 0 - fi - command stat "$@" -} -export -f stat - -sudo() { - if [[ "$1" == "diskutil" && "$2" == "resetUserPermissions" ]]; then - echo "diskutil:resetUserPermissions" - return 0 - fi - return 1 -} -export -f sudo - -id() { - echo "501" -} -export -f id - -start_inline_spinner() { :; } -stop_inline_spinner() { :; } -export -f start_inline_spinner stop_inline_spinner - -opt_disk_permissions_repair -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"User directory permissions repaired"* ]] -} - -@test "opt_bluetooth_reset skips when HID device is connected" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/optimize/tasks.sh" - -system_profiler() { - cat << 'PROFILER_OUT' -Bluetooth: - Apple Magic Keyboard: - Connected: Yes - Type: Keyboard -PROFILER_OUT - return 0 -} -export -f system_profiler - -opt_bluetooth_reset -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Bluetooth already optimal"* ]] -} - -@test "opt_bluetooth_reset skips when media apps are running" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/optimize/tasks.sh" - -system_profiler() { - cat << 'PROFILER_OUT' -Bluetooth: - AirPods Pro: - Connected: Yes - Type: Headphones -PROFILER_OUT - return 0 -} -export -f system_profiler - -pgrep() { - if [[ "$2" == "Spotify" ]]; then - echo "12345" - return 0 - fi - return 1 -} -export -f pgrep - -opt_bluetooth_reset -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Bluetooth already optimal"* ]] -} - -@test "opt_bluetooth_reset skips when Bluetooth audio output is active" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/optimize/tasks.sh" - -system_profiler() { - if [[ "$1" == "SPAudioDataType" ]]; then - cat << 'AUDIO_OUT' -Audio: - Devices: - AirPods Pro: - Default Output Device: Yes - Manufacturer: Apple Inc. - Output Channels: 2 - Transport: Bluetooth - Output Source: AirPods Pro -AUDIO_OUT - return 0 - elif [[ "$1" == "SPBluetoothDataType" ]]; then - echo "Bluetooth:" - return 0 - fi - return 1 -} -export -f system_profiler - -awk() { - if [[ "${*}" == *"Default Output Device"* ]]; then - cat << 'AWK_OUT' - Default Output Device: Yes - Manufacturer: Apple Inc. - Output Channels: 2 - Transport: Bluetooth - Output Source: AirPods Pro -AWK_OUT - return 0 - fi - command awk "$@" -} -export -f awk - -opt_bluetooth_reset -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Bluetooth already optimal"* ]] -} - -@test "opt_bluetooth_reset restarts when safe" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/optimize/tasks.sh" - -system_profiler() { - cat << 'PROFILER_OUT' -Bluetooth: - AirPods: - Connected: Yes - Type: Audio -PROFILER_OUT - return 0 -} -export -f system_profiler - -pgrep() { - if [[ "$2" == "bluetoothd" ]]; then - return 1 # bluetoothd not running after TERM - fi - return 1 -} -export -f pgrep - -sudo() { - if [[ "$1" == "pkill" ]]; then - echo "pkill:bluetoothd:$2" - return 0 - fi - return 1 -} -export -f sudo - -sleep() { :; } -export -f sleep - -opt_bluetooth_reset -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Bluetooth module restarted"* ]] -} - -@test "opt_spotlight_index_optimize skips when search is fast" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/optimize/tasks.sh" - -mdutil() { - if [[ "$1" == "-s" ]]; then - echo "Indexing enabled." - return 0 - fi - return 0 -} -export -f mdutil - -mdfind() { - return 0 -} -export -f mdfind - -date() { - echo "1000" -} -export -f date - -opt_spotlight_index_optimize -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Spotlight index already optimal"* ]] -} diff --git a/tests/clean_user_core.bats b/tests/clean_user_core.bats deleted file mode 100644 index 18e51a7..0000000 --- a/tests/clean_user_core.bats +++ /dev/null @@ -1,160 +0,0 @@ -#!/usr/bin/env bats - -setup_file() { - PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" - export PROJECT_ROOT - - ORIGINAL_HOME="${HOME:-}" - export ORIGINAL_HOME - - HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-user-core.XXXXXX")" - export HOME - - mkdir -p "$HOME" -} - -teardown_file() { - rm -rf "$HOME" - if [[ -n "${ORIGINAL_HOME:-}" ]]; then - export HOME="$ORIGINAL_HOME" - fi -} - -@test "clean_user_essentials respects Trash whitelist" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/clean/user.sh" -start_section_spinner() { :; } -stop_section_spinner() { :; } -safe_clean() { echo "$2"; } -note_activity() { :; } -is_path_whitelisted() { [[ "$1" == "$HOME/.Trash" ]]; } -clean_user_essentials -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Trash"* ]] - [[ "$output" == *"whitelist"* ]] -} - -@test "clean_macos_system_caches calls safe_clean for core paths" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/clean/user.sh" -stop_section_spinner() { :; } -safe_clean() { echo "$2"; } -clean_macos_system_caches -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Saved application states"* ]] - [[ "$output" == *"QuickLook"* ]] -} - -@test "clean_sandboxed_app_caches skips protected containers" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true /bin/bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/clean/user.sh" -start_section_spinner() { :; } -stop_section_spinner() { :; } -bytes_to_human() { echo "0B"; } -note_activity() { :; } -safe_clean() { :; } -should_protect_data() { return 0; } -is_critical_system_component() { return 0; } -files_cleaned=0 -total_size_cleaned=0 -total_items=0 -mkdir -p "$HOME/Library/Containers/com.example.app/Data/Library/Caches" -process_container_cache "$HOME/Library/Containers/com.example.app" -clean_sandboxed_app_caches -EOF - - [ "$status" -eq 0 ] - [[ "$output" != *"Sandboxed app caches"* ]] -} - -@test "clean_finder_metadata respects protection flag" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PROTECT_FINDER_METADATA=true /bin/bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/clean/user.sh" -stop_section_spinner() { :; } -note_activity() { :; } -clean_finder_metadata -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Finder metadata"* ]] - [[ "$output" == *"protected"* ]] -} - -@test "check_ios_device_backups returns when no backup dir" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/clean/user.sh" -check_ios_device_backups -EOF - - [ "$status" -eq 0 ] -} - -@test "clean_empty_library_items only cleans empty dirs" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/clean/user.sh" -safe_clean() { echo "$2"; } -mkdir -p "$HOME/Library/EmptyDir" -touch "$HOME/Library/empty.txt" -clean_empty_library_items -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Empty Library folders"* ]] - [[ "$output" != *"Empty Library files"* ]] -} - -@test "clean_browsers calls expected cache paths" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/clean/user.sh" -safe_clean() { echo "$2"; } -clean_browsers -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Safari cache"* ]] - [[ "$output" == *"Firefox cache"* ]] -} - -@test "clean_application_support_logs skips when no access" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/clean/user.sh" -note_activity() { :; } -clean_application_support_logs -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Skipped: No permission"* ]] -} - -@test "clean_apple_silicon_caches exits when not M-series" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" IS_M_SERIES=false bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/clean/user.sh" -safe_clean() { echo "$2"; } -clean_apple_silicon_caches -EOF - - [ "$status" -eq 0 ] - [[ -z "$output" ]] -} diff --git a/tests/cli.bats b/tests/cli.bats deleted file mode 100644 index 5ed897b..0000000 --- a/tests/cli.bats +++ /dev/null @@ -1,178 +0,0 @@ -#!/usr/bin/env bats - -setup_file() { - PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" - export PROJECT_ROOT - - ORIGINAL_HOME="${HOME:-}" - export ORIGINAL_HOME - - HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-cli-home.XXXXXX")" - export HOME - - mkdir -p "$HOME" -} - -teardown_file() { - rm -rf "$HOME" - if [[ -n "${ORIGINAL_HOME:-}" ]]; then - export HOME="$ORIGINAL_HOME" - fi -} - -create_fake_utils() { - local dir="$1" - mkdir -p "$dir" - - cat > "$dir/sudo" <<'SCRIPT' -#!/usr/bin/env bash -if [[ "$1" == "-n" || "$1" == "-v" ]]; then - exit 0 -fi -exec "$@" -SCRIPT - chmod +x "$dir/sudo" - - cat > "$dir/bioutil" <<'SCRIPT' -#!/usr/bin/env bash -if [[ "$1" == "-r" ]]; then - echo "Touch ID: 1" - exit 0 -fi -exit 0 -SCRIPT - chmod +x "$dir/bioutil" -} - -setup() { - rm -rf "$HOME/.config" - mkdir -p "$HOME" -} - -@test "mole --help prints command overview" { - run env HOME="$HOME" "$PROJECT_ROOT/mole" --help - [ "$status" -eq 0 ] - [[ "$output" == *"mo clean"* ]] - [[ "$output" == *"mo analyze"* ]] -} - -@test "mole --version reports script version" { - expected_version="$(grep '^VERSION=' "$PROJECT_ROOT/mole" | head -1 | sed 's/VERSION=\"\(.*\)\"/\1/')" - run env HOME="$HOME" "$PROJECT_ROOT/mole" --version - [ "$status" -eq 0 ] - [[ "$output" == *"$expected_version"* ]] -} - -@test "mole unknown command returns error" { - run env HOME="$HOME" "$PROJECT_ROOT/mole" unknown-command - [ "$status" -ne 0 ] - [[ "$output" == *"Unknown command: unknown-command"* ]] -} - -@test "touchid status reports current configuration" { - run env HOME="$HOME" "$PROJECT_ROOT/mole" touchid status - [ "$status" -eq 0 ] - [[ "$output" == *"Touch ID"* ]] -} - -@test "mo optimize command is recognized" { - run bash -c "grep -q '\"optimize\")' '$PROJECT_ROOT/mole'" - [ "$status" -eq 0 ] -} - -@test "mo analyze binary is valid" { - if [[ -f "$PROJECT_ROOT/bin/analyze-go" ]]; then - [ -x "$PROJECT_ROOT/bin/analyze-go" ] - run file "$PROJECT_ROOT/bin/analyze-go" - [[ "$output" == *"Mach-O"* ]] || [[ "$output" == *"executable"* ]] - else - skip "analyze-go binary not built" - fi -} - -@test "mo clean --debug creates debug log file" { - mkdir -p "$HOME/.config/mole" - run env HOME="$HOME" TERM="xterm-256color" MOLE_TEST_MODE=1 MO_DEBUG=1 "$PROJECT_ROOT/mole" clean --dry-run - [ "$status" -eq 0 ] - MOLE_OUTPUT="$output" - - DEBUG_LOG="$HOME/.config/mole/mole_debug_session.log" - [ -f "$DEBUG_LOG" ] - - run grep "Mole Debug Session" "$DEBUG_LOG" - [ "$status" -eq 0 ] - - [[ "$MOLE_OUTPUT" =~ "Debug session log saved to" ]] -} - -@test "mo clean without debug does not show debug log path" { - mkdir -p "$HOME/.config/mole" - run env HOME="$HOME" TERM="xterm-256color" MOLE_TEST_MODE=1 MO_DEBUG=0 "$PROJECT_ROOT/mole" clean --dry-run - [ "$status" -eq 0 ] - - [[ "$output" != *"Debug session log saved to"* ]] -} - -@test "mo clean --debug logs system info" { - mkdir -p "$HOME/.config/mole" - run env HOME="$HOME" TERM="xterm-256color" MOLE_TEST_MODE=1 MO_DEBUG=1 "$PROJECT_ROOT/mole" clean --dry-run - [ "$status" -eq 0 ] - - DEBUG_LOG="$HOME/.config/mole/mole_debug_session.log" - - run grep "User:" "$DEBUG_LOG" - [ "$status" -eq 0 ] - - run grep "Architecture:" "$DEBUG_LOG" - [ "$status" -eq 0 ] -} - -@test "touchid status reflects pam file contents" { - pam_file="$HOME/pam_test" - cat > "$pam_file" <<'EOF' -auth sufficient pam_opendirectory.so -EOF - - run env MOLE_PAM_SUDO_FILE="$pam_file" "$PROJECT_ROOT/bin/touchid.sh" status - [ "$status" -eq 0 ] - [[ "$output" == *"not configured"* ]] - - cat > "$pam_file" <<'EOF' -auth sufficient pam_tid.so -EOF - - run env MOLE_PAM_SUDO_FILE="$pam_file" "$PROJECT_ROOT/bin/touchid.sh" status - [ "$status" -eq 0 ] - [[ "$output" == *"enabled"* ]] -} - -@test "enable_touchid inserts pam_tid line in pam file" { - pam_file="$HOME/pam_enable" - cat > "$pam_file" <<'EOF' -auth sufficient pam_opendirectory.so -EOF - - fake_bin="$HOME/fake-bin" - create_fake_utils "$fake_bin" - - run env PATH="$fake_bin:$PATH" MOLE_PAM_SUDO_FILE="$pam_file" "$PROJECT_ROOT/bin/touchid.sh" enable - [ "$status" -eq 0 ] - grep -q "pam_tid.so" "$pam_file" - [[ -f "${pam_file}.mole-backup" ]] -} - -@test "disable_touchid removes pam_tid line" { - pam_file="$HOME/pam_disable" - cat > "$pam_file" <<'EOF' -auth sufficient pam_tid.so -auth sufficient pam_opendirectory.so -EOF - - fake_bin="$HOME/fake-bin-disable" - create_fake_utils "$fake_bin" - - run env PATH="$fake_bin:$PATH" MOLE_PAM_SUDO_FILE="$pam_file" "$PROJECT_ROOT/bin/touchid.sh" disable - [ "$status" -eq 0 ] - run grep "pam_tid.so" "$pam_file" - [ "$status" -ne 0 ] -} diff --git a/tests/completion.bats b/tests/completion.bats deleted file mode 100755 index d586bcd..0000000 --- a/tests/completion.bats +++ /dev/null @@ -1,160 +0,0 @@ -#!/usr/bin/env bats - -setup_file() { - PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" - export PROJECT_ROOT - - ORIGINAL_HOME="${HOME:-}" - export ORIGINAL_HOME - - ORIGINAL_PATH="${PATH:-}" - export ORIGINAL_PATH - - HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-completion-home.XXXXXX")" - export HOME - - mkdir -p "$HOME" - - PATH="$PROJECT_ROOT:$PATH" - export PATH -} - -teardown_file() { - rm -rf "$HOME" - if [[ -n "${ORIGINAL_HOME:-}" ]]; then - export HOME="$ORIGINAL_HOME" - fi - if [[ -n "${ORIGINAL_PATH:-}" ]]; then - export PATH="$ORIGINAL_PATH" - fi -} - -setup() { - rm -rf "$HOME/.config" - rm -rf "$HOME/.zshrc" "$HOME/.bashrc" "$HOME/.bash_profile" - mkdir -p "$HOME" -} - -@test "completion script exists and is executable" { - [ -f "$PROJECT_ROOT/bin/completion.sh" ] - [ -x "$PROJECT_ROOT/bin/completion.sh" ] -} - -@test "completion script has valid bash syntax" { - run bash -n "$PROJECT_ROOT/bin/completion.sh" - [ "$status" -eq 0 ] -} - -@test "completion --help shows usage" { - run "$PROJECT_ROOT/bin/completion.sh" --help - [ "$status" -ne 0 ] - [[ "$output" == *"Usage: mole completion"* ]] - [[ "$output" == *"Auto-install"* ]] -} - -@test "completion bash generates valid bash script" { - run "$PROJECT_ROOT/bin/completion.sh" bash - [ "$status" -eq 0 ] - [[ "$output" == *"_mole_completions"* ]] - [[ "$output" == *"complete -F _mole_completions mole mo"* ]] -} - -@test "completion bash script includes all commands" { - run "$PROJECT_ROOT/bin/completion.sh" bash - [ "$status" -eq 0 ] - [[ "$output" == *"optimize"* ]] - [[ "$output" == *"clean"* ]] - [[ "$output" == *"uninstall"* ]] - [[ "$output" == *"analyze"* ]] - [[ "$output" == *"status"* ]] - [[ "$output" == *"purge"* ]] - [[ "$output" == *"touchid"* ]] - [[ "$output" == *"completion"* ]] -} - -@test "completion bash script supports mo command" { - run "$PROJECT_ROOT/bin/completion.sh" bash - [ "$status" -eq 0 ] - [[ "$output" == *"complete -F _mole_completions mole mo"* ]] -} - -@test "completion bash can be loaded in bash" { - run bash -c "eval \"\$(\"$PROJECT_ROOT/bin/completion.sh\" bash)\" && complete -p mole" - [ "$status" -eq 0 ] - [[ "$output" == *"_mole_completions"* ]] -} - -@test "completion zsh generates valid zsh script" { - run "$PROJECT_ROOT/bin/completion.sh" zsh - [ "$status" -eq 0 ] - [[ "$output" == *"#compdef mole mo"* ]] - [[ "$output" == *"_mole()"* ]] -} - -@test "completion zsh includes command descriptions" { - run "$PROJECT_ROOT/bin/completion.sh" zsh - [ "$status" -eq 0 ] - [[ "$output" == *"optimize:Check and maintain system"* ]] - [[ "$output" == *"clean:Free up disk space"* ]] -} - -@test "completion fish generates valid fish script" { - run "$PROJECT_ROOT/bin/completion.sh" fish - [ "$status" -eq 0 ] - [[ "$output" == *"complete -c mole"* ]] - [[ "$output" == *"complete -c mo"* ]] -} - -@test "completion fish includes both mole and mo commands" { - output="$("$PROJECT_ROOT/bin/completion.sh" fish)" - mole_count=$(echo "$output" | grep -c "complete -c mole") - mo_count=$(echo "$output" | grep -c "complete -c mo") - - [ "$mole_count" -gt 0 ] - [ "$mo_count" -gt 0 ] -} - -@test "completion auto-install detects zsh" { - # shellcheck disable=SC2030,SC2031 - export SHELL=/bin/zsh - - # Simulate auto-install (no interaction) - run bash -c "echo 'y' | \"$PROJECT_ROOT/bin/completion.sh\"" - - if [[ "$output" == *"Already configured"* ]]; then - skip "Already configured from previous test" - fi - - [ -f "$HOME/.zshrc" ] || skip "Auto-install didn't create .zshrc" - - run grep -E "mole[[:space:]]+completion" "$HOME/.zshrc" - [ "$status" -eq 0 ] -} - -@test "completion auto-install detects already installed" { - # shellcheck disable=SC2031 - export SHELL=/bin/zsh - mkdir -p "$HOME" - # shellcheck disable=SC2016 - echo 'eval "$(mole completion zsh)"' > "$HOME/.zshrc" - - run "$PROJECT_ROOT/bin/completion.sh" - [ "$status" -eq 0 ] - [[ "$output" == *"updated"* ]] -} - -@test "completion script handles invalid shell argument" { - run "$PROJECT_ROOT/bin/completion.sh" invalid-shell - [ "$status" -ne 0 ] -} - -@test "completion subcommand supports bash/zsh/fish" { - run "$PROJECT_ROOT/bin/completion.sh" bash - [ "$status" -eq 0 ] - - run "$PROJECT_ROOT/bin/completion.sh" zsh - [ "$status" -eq 0 ] - - run "$PROJECT_ROOT/bin/completion.sh" fish - [ "$status" -eq 0 ] -} diff --git a/tests/core_common.bats b/tests/core_common.bats deleted file mode 100644 index 5b2d88e..0000000 --- a/tests/core_common.bats +++ /dev/null @@ -1,207 +0,0 @@ -#!/usr/bin/env bats - -setup_file() { - PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" - export PROJECT_ROOT - - ORIGINAL_HOME="${HOME:-}" - export ORIGINAL_HOME - - HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-home.XXXXXX")" - export HOME - - mkdir -p "$HOME" -} - -teardown_file() { - rm -rf "$HOME" - if [[ -n "${ORIGINAL_HOME:-}" ]]; then - export HOME="$ORIGINAL_HOME" - fi -} - -setup() { - rm -rf "$HOME/.config" - mkdir -p "$HOME" -} - -@test "mo_spinner_chars returns default sequence" { - result="$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; mo_spinner_chars")" - [ "$result" = "|/-\\" ] -} - -@test "detect_architecture maps current CPU to friendly label" { - expected="Intel" - if [[ "$(uname -m)" == "arm64" ]]; then - expected="Apple Silicon" - fi - result="$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; detect_architecture")" - [ "$result" = "$expected" ] -} - -@test "get_free_space returns a non-empty value" { - result="$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; get_free_space")" - [[ -n "$result" ]] -} - -@test "log_info prints message and appends to log file" { - local message="Informational message from test" - local stdout_output - stdout_output="$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; log_info '$message'")" - [[ "$stdout_output" == *"$message"* ]] - - local log_file="$HOME/.config/mole/mole.log" - [[ -f "$log_file" ]] - grep -q "INFO: $message" "$log_file" -} - -@test "log_error writes to stderr and log file" { - local message="Something went wrong" - local stderr_file="$HOME/log_error_stderr.txt" - - HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; log_error '$message' 1>/dev/null 2>'$stderr_file'" - - [[ -s "$stderr_file" ]] - grep -q "$message" "$stderr_file" - - local log_file="$HOME/.config/mole/mole.log" - [[ -f "$log_file" ]] - grep -q "ERROR: $message" "$log_file" -} - -@test "rotate_log_once only checks log size once per session" { - local log_file="$HOME/.config/mole/mole.log" - mkdir -p "$(dirname "$log_file")" - dd if=/dev/zero of="$log_file" bs=1024 count=1100 2> /dev/null - - HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'" - [[ -f "${log_file}.old" ]] - - result=$(HOME="$HOME" MOLE_LOG_ROTATED=1 bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; echo \$MOLE_LOG_ROTATED") - [[ "$result" == "1" ]] -} - -@test "drain_pending_input clears stdin buffer" { - result=$( - (echo -e "test\ninput" | HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; drain_pending_input; echo done") & - pid=$! - sleep 2 - if kill -0 "$pid" 2> /dev/null; then - kill "$pid" 2> /dev/null || true - wait "$pid" 2> /dev/null || true - echo "timeout" - else - wait "$pid" 2> /dev/null || true - fi - ) - [[ "$result" == "done" ]] -} - -@test "bytes_to_human converts byte counts into readable units" { - output="$( - HOME="$HOME" bash --noprofile --norc << 'EOF' -source "$PROJECT_ROOT/lib/core/common.sh" -bytes_to_human 512 -bytes_to_human 2048 -bytes_to_human $((5 * 1024 * 1024)) -bytes_to_human $((3 * 1024 * 1024 * 1024)) -EOF - )" - - bytes_lines=() - while IFS= read -r line; do - bytes_lines+=("$line") - done <<< "$output" - - [ "${bytes_lines[0]}" = "512B" ] - [ "${bytes_lines[1]}" = "2KB" ] - [ "${bytes_lines[2]}" = "5.0MB" ] - [ "${bytes_lines[3]}" = "3.00GB" ] -} - -@test "create_temp_file and create_temp_dir are tracked and cleaned" { - HOME="$HOME" bash --noprofile --norc << 'EOF' -source "$PROJECT_ROOT/lib/core/common.sh" -create_temp_file > "$HOME/temp_file_path.txt" -create_temp_dir > "$HOME/temp_dir_path.txt" -cleanup_temp_files -EOF - - file_path="$(cat "$HOME/temp_file_path.txt")" - dir_path="$(cat "$HOME/temp_dir_path.txt")" - [ ! -e "$file_path" ] - [ ! -e "$dir_path" ] - rm -f "$HOME/temp_file_path.txt" "$HOME/temp_dir_path.txt" -} - - -@test "should_protect_data protects system and critical apps" { - result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_data 'com.apple.Safari' && echo 'protected' || echo 'not-protected'") - [ "$result" = "protected" ] - - result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_data 'com.clash.app' && echo 'protected' || echo 'not-protected'") - [ "$result" = "protected" ] - - result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_data 'com.example.RegularApp' && echo 'protected' || echo 'not-protected'") - [ "$result" = "not-protected" ] -} - -@test "input methods are protected during cleanup but allowed for uninstall" { - result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_data 'com.tencent.inputmethod.QQInput' && echo 'protected' || echo 'not-protected'") - [ "$result" = "protected" ] - - result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_data 'com.sogou.inputmethod.pinyin' && echo 'protected' || echo 'not-protected'") - [ "$result" = "protected" ] - - result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_from_uninstall 'com.tencent.inputmethod.QQInput' && echo 'protected' || echo 'not-protected'") - [ "$result" = "not-protected" ] - - result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_from_uninstall 'com.apple.inputmethod.SCIM' && echo 'protected' || echo 'not-protected'") - [ "$result" = "protected" ] -} - -@test "print_summary_block formats output correctly" { - result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; print_summary_block 'success' 'Test Summary' 'Detail 1' 'Detail 2'") - [[ "$result" == *"Test Summary"* ]] - [[ "$result" == *"Detail 1"* ]] - [[ "$result" == *"Detail 2"* ]] -} - -@test "start_inline_spinner and stop_inline_spinner work in non-TTY" { - result=$(HOME="$HOME" bash --noprofile --norc << 'EOF' -source "$PROJECT_ROOT/lib/core/common.sh" -MOLE_SPINNER_PREFIX=" " start_inline_spinner "Testing..." -sleep 0.1 -stop_inline_spinner -echo "done" -EOF -) - [[ "$result" == *"done"* ]] -} - -@test "read_key maps j/k/h/l to navigation" { - run bash -c "export MOLE_BASE_LOADED=1; source '$PROJECT_ROOT/lib/core/ui.sh'; echo -n 'j' | read_key" - [ "$output" = "DOWN" ] - - run bash -c "export MOLE_BASE_LOADED=1; source '$PROJECT_ROOT/lib/core/ui.sh'; echo -n 'k' | read_key" - [ "$output" = "UP" ] - - run bash -c "export MOLE_BASE_LOADED=1; source '$PROJECT_ROOT/lib/core/ui.sh'; echo -n 'h' | read_key" - [ "$output" = "LEFT" ] - - run bash -c "export MOLE_BASE_LOADED=1; source '$PROJECT_ROOT/lib/core/ui.sh'; echo -n 'l' | read_key" - [ "$output" = "RIGHT" ] -} - -@test "read_key maps uppercase J/K/H/L to navigation" { - run bash -c "export MOLE_BASE_LOADED=1; source '$PROJECT_ROOT/lib/core/ui.sh'; echo -n 'J' | read_key" - [ "$output" = "DOWN" ] - - run bash -c "export MOLE_BASE_LOADED=1; source '$PROJECT_ROOT/lib/core/ui.sh'; echo -n 'K' | read_key" - [ "$output" = "UP" ] -} - -@test "read_key respects MOLE_READ_KEY_FORCE_CHAR" { - run bash -c "export MOLE_BASE_LOADED=1; export MOLE_READ_KEY_FORCE_CHAR=1; source '$PROJECT_ROOT/lib/core/ui.sh'; echo -n 'j' | read_key" - [ "$output" = "CHAR:j" ] -} diff --git a/tests/core_performance.bats b/tests/core_performance.bats deleted file mode 100644 index dfc4037..0000000 --- a/tests/core_performance.bats +++ /dev/null @@ -1,236 +0,0 @@ -#!/usr/bin/env bats - -setup_file() { - PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" - export PROJECT_ROOT - - TEST_DATA_DIR="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-perf.XXXXXX")" - export TEST_DATA_DIR -} - -teardown_file() { - rm -rf "$TEST_DATA_DIR" -} - -setup() { - source "$PROJECT_ROOT/lib/core/base.sh" -} - -@test "bytes_to_human handles large values efficiently" { - local start end elapsed - local limit_ms="${MOLE_PERF_BYTES_TO_HUMAN_LIMIT_MS:-4000}" - - bytes_to_human 1073741824 > /dev/null - - start=$(date +%s%N) - for i in {1..1000}; do - bytes_to_human 1073741824 > /dev/null - done - end=$(date +%s%N) - - elapsed=$(( (end - start) / 1000000 )) - - [ "$elapsed" -lt "$limit_ms" ] -} - -@test "bytes_to_human produces correct output for GB range" { - result=$(bytes_to_human 1073741824) - [ "$result" = "1.00GB" ] - - result=$(bytes_to_human 5368709120) - [ "$result" = "5.00GB" ] -} - -@test "bytes_to_human produces correct output for MB range" { - result=$(bytes_to_human 1048576) - [ "$result" = "1.0MB" ] - - result=$(bytes_to_human 104857600) - [ "$result" = "100.0MB" ] -} - -@test "bytes_to_human produces correct output for KB range" { - result=$(bytes_to_human 1024) - [ "$result" = "1KB" ] - - result=$(bytes_to_human 10240) - [ "$result" = "10KB" ] -} - -@test "bytes_to_human handles edge cases" { - result=$(bytes_to_human 0) - [ "$result" = "0B" ] - - run bytes_to_human "invalid" - [ "$status" -eq 1 ] - [ "$output" = "0B" ] - - run bytes_to_human "-100" - [ "$status" -eq 1 ] - [ "$output" = "0B" ] -} - -@test "get_file_size is faster than multiple stat calls" { - local test_file="$TEST_DATA_DIR/size_test.txt" - dd if=/dev/zero of="$test_file" bs=1024 count=100 2> /dev/null - - local start end elapsed - local limit_ms="${MOLE_PERF_GET_FILE_SIZE_LIMIT_MS:-2000}" - start=$(date +%s%N) - for i in {1..100}; do - get_file_size "$test_file" > /dev/null - done - end=$(date +%s%N) - - elapsed=$(( (end - start) / 1000000 )) - - [ "$elapsed" -lt "$limit_ms" ] -} - -@test "get_file_mtime returns valid timestamp" { - local test_file="$TEST_DATA_DIR/mtime_test.txt" - touch "$test_file" - - result=$(get_file_mtime "$test_file") - - [[ "$result" =~ ^[0-9]{10,}$ ]] -} - -@test "get_file_owner returns current user for owned files" { - local test_file="$TEST_DATA_DIR/owner_test.txt" - touch "$test_file" - - result=$(get_file_owner "$test_file") - current_user=$(whoami) - - [ "$result" = "$current_user" ] -} - -@test "get_invoking_user executes quickly" { - local start end elapsed - - start=$(date +%s%N) - for i in {1..100}; do - get_invoking_user > /dev/null - done - end=$(date +%s%N) - - elapsed=$(( (end - start) / 1000000 )) - - [ "$elapsed" -lt 200 ] -} - -@test "get_darwin_major caches correctly" { - local first second - first=$(get_darwin_major) - second=$(get_darwin_major) - - [ "$first" = "$second" ] - [[ "$first" =~ ^[0-9]+$ ]] -} - -@test "create_temp_file and cleanup_temp_files work efficiently" { - local start end elapsed - - declare -a MOLE_TEMP_DIRS=() - - start=$(date +%s%N) - for i in {1..50}; do - create_temp_file > /dev/null - done - end=$(date +%s%N) - - elapsed=$(( (end - start) / 1000000 )) - - [ "$elapsed" -lt 1000 ] - - [ "${#MOLE_TEMP_FILES[@]}" -eq 50 ] - - start=$(date +%s%N) - cleanup_temp_files - end=$(date +%s%N) - - elapsed=$(( (end - start) / 1000000 )) - [ "$elapsed" -lt 2000 ] - - [ "${#MOLE_TEMP_FILES[@]}" -eq 0 ] -} - -@test "mktemp_file creates files with correct prefix" { - local temp_file - temp_file=$(mktemp_file "test_prefix") - - [[ "$temp_file" =~ test_prefix ]] - - [ -f "$temp_file" ] - - rm -f "$temp_file" -} - -@test "get_brand_name handles common apps efficiently" { - local start end elapsed - - get_brand_name "wechat" > /dev/null - - start=$(date +%s%N) - for i in {1..50}; do - get_brand_name "wechat" > /dev/null - get_brand_name "QQ" > /dev/null - get_brand_name "dingtalk" > /dev/null - done - end=$(date +%s%N) - - elapsed=$(( (end - start) / 1000000 )) - - [ "$elapsed" -lt 5000 ] -} - -@test "get_brand_name returns correct localized names" { - local result - result=$(get_brand_name "wechat") - - [[ "$result" == "WeChat" || "$result" == "微信" ]] -} - -@test "get_optimal_parallel_jobs returns sensible values" { - local result - - result=$(get_optimal_parallel_jobs) - [[ "$result" =~ ^[0-9]+$ ]] - [ "$result" -gt 0 ] - [ "$result" -le 128 ] - - local scan_jobs - scan_jobs=$(get_optimal_parallel_jobs "scan") - [ "$scan_jobs" -gt "$result" ] - - local compute_jobs - compute_jobs=$(get_optimal_parallel_jobs "compute") - [ "$compute_jobs" -le "$scan_jobs" ] -} - -@test "section tracking has minimal overhead" { - local start end elapsed - - if ! declare -f note_activity > /dev/null 2>&1; then - TRACK_SECTION=0 - SECTION_ACTIVITY=0 - note_activity() { - if [[ $TRACK_SECTION -eq 1 ]]; then - SECTION_ACTIVITY=1 - fi - } - fi - - note_activity - - start=$(date +%s%N) - for i in {1..1000}; do - note_activity - done - end=$(date +%s%N) - - elapsed=$(( (end - start) / 1000000 )) - - [ "$elapsed" -lt 2000 ] -} diff --git a/tests/core_safe_functions.bats b/tests/core_safe_functions.bats deleted file mode 100644 index a720787..0000000 --- a/tests/core_safe_functions.bats +++ /dev/null @@ -1,168 +0,0 @@ -#!/usr/bin/env bats - -setup_file() { - PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" - export PROJECT_ROOT - - ORIGINAL_HOME="${HOME:-}" - export ORIGINAL_HOME - - HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-safe-functions.XXXXXX")" - export HOME - - mkdir -p "$HOME" -} - -teardown_file() { - rm -rf "$HOME" - if [[ -n "${ORIGINAL_HOME:-}" ]]; then - export HOME="$ORIGINAL_HOME" - fi -} - -setup() { - source "$PROJECT_ROOT/lib/core/common.sh" - TEST_DIR="$HOME/test_safe_functions" - mkdir -p "$TEST_DIR" -} - -teardown() { - rm -rf "$TEST_DIR" -} - -@test "validate_path_for_deletion rejects empty path" { - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion ''" - [ "$status" -eq 1 ] -} - -@test "validate_path_for_deletion rejects relative path" { - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion 'relative/path'" - [ "$status" -eq 1 ] -} - -@test "validate_path_for_deletion rejects path traversal" { - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/tmp/../etc'" - [ "$status" -eq 1 ] - - # Test other path traversal patterns - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/var/log/../../etc'" - [ "$status" -eq 1 ] - - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '$TEST_DIR/..'" - [ "$status" -eq 1 ] -} - -@test "validate_path_for_deletion accepts Firefox-style ..files directories" { - # Firefox uses ..files suffix in IndexedDB directory names - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '$TEST_DIR/2753419432nreetyfallipx..files'" - [ "$status" -eq 0 ] - - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '$TEST_DIR/storage/default/https+++www.netflix.com/idb/name..files/data'" - [ "$status" -eq 0 ] - - # Directories with .. in the middle of names should be allowed - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '$TEST_DIR/test..backup/file.txt'" - [ "$status" -eq 0 ] -} - -@test "validate_path_for_deletion rejects system directories" { - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/System'" - [ "$status" -eq 1 ] - - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/usr/bin'" - [ "$status" -eq 1 ] - - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/etc'" - [ "$status" -eq 1 ] -} - -@test "validate_path_for_deletion accepts valid path" { - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '$TEST_DIR/valid'" - [ "$status" -eq 0 ] -} - -@test "safe_remove validates path before deletion" { - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; safe_remove '/System/test' 2>&1" - [ "$status" -eq 1 ] -} - -@test "safe_remove successfully removes file" { - local test_file="$TEST_DIR/test_file.txt" - echo "test" > "$test_file" - - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; safe_remove '$test_file' true" - [ "$status" -eq 0 ] - [ ! -f "$test_file" ] -} - -@test "safe_remove successfully removes directory" { - local test_subdir="$TEST_DIR/test_subdir" - mkdir -p "$test_subdir" - touch "$test_subdir/file.txt" - - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; safe_remove '$test_subdir' true" - [ "$status" -eq 0 ] - [ ! -d "$test_subdir" ] -} - -@test "safe_remove handles non-existent path gracefully" { - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; safe_remove '$TEST_DIR/nonexistent' true" - [ "$status" -eq 0 ] -} - -@test "safe_remove in silent mode suppresses error output" { - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; safe_remove '/System/test' true 2>&1" - [ "$status" -eq 1 ] -} - - -@test "safe_find_delete validates base directory" { - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; safe_find_delete '/nonexistent' '*.tmp' 7 'f' 2>&1" - [ "$status" -eq 1 ] -} - -@test "safe_find_delete rejects symlinked directory" { - local real_dir="$TEST_DIR/real" - local link_dir="$TEST_DIR/link" - mkdir -p "$real_dir" - ln -s "$real_dir" "$link_dir" - - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; safe_find_delete '$link_dir' '*.tmp' 7 'f' 2>&1" - [ "$status" -eq 1 ] - [[ "$output" == *"symlink"* ]] - - rm -rf "$link_dir" "$real_dir" -} - -@test "safe_find_delete validates type filter" { - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; safe_find_delete '$TEST_DIR' '*.tmp' 7 'x' 2>&1" - [ "$status" -eq 1 ] - [[ "$output" == *"Invalid type filter"* ]] -} - -@test "safe_find_delete deletes old files" { - local old_file="$TEST_DIR/old.tmp" - local new_file="$TEST_DIR/new.tmp" - - touch "$old_file" - touch "$new_file" - - touch -t "$(date -v-8d '+%Y%m%d%H%M.%S' 2>/dev/null || date -d '8 days ago' '+%Y%m%d%H%M.%S')" "$old_file" 2>/dev/null || true - - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; safe_find_delete '$TEST_DIR' '*.tmp' 7 'f'" - [ "$status" -eq 0 ] -} - -@test "MOLE_* constants are defined" { - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; echo \$MOLE_TEMP_FILE_AGE_DAYS" - [ "$status" -eq 0 ] - [ "$output" = "7" ] - - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; echo \$MOLE_MAX_PARALLEL_JOBS" - [ "$status" -eq 0 ] - [ "$output" = "15" ] - - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; echo \$MOLE_TM_BACKUP_SAFE_HOURS" - [ "$status" -eq 0 ] - [ "$output" = "48" ] -} diff --git a/tests/core_timeout.bats b/tests/core_timeout.bats deleted file mode 100644 index 95c1c84..0000000 --- a/tests/core_timeout.bats +++ /dev/null @@ -1,188 +0,0 @@ -#!/usr/bin/env bats - -setup() { - PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" - export PROJECT_ROOT - export MO_DEBUG=0 -} - -@test "run_with_timeout: command completes before timeout" { - result=$(bash -c " - set -euo pipefail - source '$PROJECT_ROOT/lib/core/timeout.sh' - run_with_timeout 5 echo 'success' - ") - [[ "$result" == "success" ]] -} - -@test "run_with_timeout: zero timeout runs command normally" { - result=$(bash -c " - set -euo pipefail - source '$PROJECT_ROOT/lib/core/timeout.sh' - run_with_timeout 0 echo 'no_timeout' - ") - [[ "$result" == "no_timeout" ]] -} - -@test "run_with_timeout: invalid timeout runs command normally" { - result=$(bash -c " - set -euo pipefail - source '$PROJECT_ROOT/lib/core/timeout.sh' - run_with_timeout invalid echo 'no_timeout' - ") - [[ "$result" == "no_timeout" ]] -} - -@test "run_with_timeout: negative timeout runs command normally" { - result=$(bash -c " - set -euo pipefail - source '$PROJECT_ROOT/lib/core/timeout.sh' - run_with_timeout -5 echo 'no_timeout' - ") - [[ "$result" == "no_timeout" ]] -} - -@test "run_with_timeout: preserves command exit code on success" { - bash -c " - set -euo pipefail - source '$PROJECT_ROOT/lib/core/timeout.sh' - run_with_timeout 5 true - " - exit_code=$? - [[ $exit_code -eq 0 ]] -} - -@test "run_with_timeout: preserves command exit code on failure" { - set +e - bash -c " - set +e - source '$PROJECT_ROOT/lib/core/timeout.sh' - run_with_timeout 5 false - exit \$? - " - exit_code=$? - set -e - [[ $exit_code -eq 1 ]] -} - -@test "run_with_timeout: returns 124 on timeout (if using gtimeout)" { - if ! command -v gtimeout >/dev/null 2>&1 && ! command -v timeout >/dev/null 2>&1; then - skip "gtimeout/timeout not available" - fi - - set +e - bash -c " - set +e - source '$PROJECT_ROOT/lib/core/timeout.sh' - run_with_timeout 1 sleep 10 - exit \$? - " - exit_code=$? - set -e - [[ $exit_code -eq 124 ]] -} - -@test "run_with_timeout: kills long-running command" { - start_time=$(date +%s) - set +e - bash -c " - set +e - source '$PROJECT_ROOT/lib/core/timeout.sh' - run_with_timeout 2 sleep 30 - " >/dev/null 2>&1 - set -e - end_time=$(date +%s) - duration=$((end_time - start_time)) - - [[ $duration -lt 10 ]] -} - -@test "run_with_timeout: handles fast-completing commands" { - start_time=$(date +%s) - bash -c " - set -euo pipefail - source '$PROJECT_ROOT/lib/core/timeout.sh' - run_with_timeout 10 echo 'fast' - " >/dev/null 2>&1 - end_time=$(date +%s) - duration=$((end_time - start_time)) - - [[ $duration -lt 3 ]] -} - -@test "run_with_timeout: works in pipefail mode" { - result=$(bash -c " - set -euo pipefail - source '$PROJECT_ROOT/lib/core/timeout.sh' - run_with_timeout 5 echo 'pipefail_test' - ") - [[ "$result" == "pipefail_test" ]] -} - -@test "run_with_timeout: doesn't cause unintended exits" { - result=$(bash -c " - set -euo pipefail - source '$PROJECT_ROOT/lib/core/timeout.sh' - run_with_timeout 5 true || true - echo 'survived' - ") - [[ "$result" == "survived" ]] -} - -@test "run_with_timeout: handles commands with arguments" { - result=$(bash -c " - set -euo pipefail - source '$PROJECT_ROOT/lib/core/timeout.sh' - run_with_timeout 5 echo 'arg1' 'arg2' 'arg3' - ") - [[ "$result" == "arg1 arg2 arg3" ]] -} - -@test "run_with_timeout: handles commands with spaces in arguments" { - result=$(bash -c " - set -euo pipefail - source '$PROJECT_ROOT/lib/core/timeout.sh' - run_with_timeout 5 echo 'hello world' - ") - [[ "$result" == "hello world" ]] -} - -@test "run_with_timeout: debug logging when MO_DEBUG=1" { - output=$(bash -c " - set -euo pipefail - export MO_DEBUG=1 - source '$PROJECT_ROOT/lib/core/timeout.sh' - run_with_timeout 5 echo 'test' 2>&1 - ") - [[ "$output" =~ TIMEOUT ]] -} - -@test "run_with_timeout: no debug logging when MO_DEBUG=0" { - output=$(bash -c " - set -euo pipefail - export MO_DEBUG=0 - unset MO_TIMEOUT_INITIALIZED - source '$PROJECT_ROOT/lib/core/timeout.sh' - run_with_timeout 5 echo 'test' - " 2>/dev/null) - [[ "$output" == "test" ]] -} - -@test "timeout.sh: prevents multiple sourcing" { - result=$(bash -c " - set -euo pipefail - source '$PROJECT_ROOT/lib/core/timeout.sh' - source '$PROJECT_ROOT/lib/core/timeout.sh' - echo 'loaded' - ") - [[ "$result" == "loaded" ]] -} - -@test "timeout.sh: sets MOLE_TIMEOUT_LOADED flag" { - result=$(bash -c " - set -euo pipefail - source '$PROJECT_ROOT/lib/core/timeout.sh' - echo \"\$MOLE_TIMEOUT_LOADED\" - ") - [[ "$result" == "1" ]] -} diff --git a/tests/installer.bats b/tests/installer.bats deleted file mode 100644 index e26b876..0000000 --- a/tests/installer.bats +++ /dev/null @@ -1,239 +0,0 @@ -#!/usr/bin/env bats - -setup_file() { - PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" - export PROJECT_ROOT - - ORIGINAL_HOME="${HOME:-}" - export ORIGINAL_HOME - - HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-installers-home.XXXXXX")" - export HOME - - mkdir -p "$HOME" -} - -teardown_file() { - rm -rf "$HOME" - if [[ -n "${ORIGINAL_HOME:-}" ]]; then - export HOME="$ORIGINAL_HOME" - fi -} - -setup() { - export TERM="xterm-256color" - export MO_DEBUG=0 - - # Create standard scan directories - mkdir -p "$HOME/Downloads" - mkdir -p "$HOME/Desktop" - mkdir -p "$HOME/Documents" - mkdir -p "$HOME/Public" - mkdir -p "$HOME/Library/Downloads" - - # Clear previous test files - rm -rf "${HOME:?}/Downloads"/* - rm -rf "${HOME:?}/Desktop"/* - rm -rf "${HOME:?}/Documents"/* -} - -# Test arguments - -@test "installer.sh rejects unknown options" { - run "$PROJECT_ROOT/bin/installer.sh" --unknown-option - - [ "$status" -eq 1 ] - [[ "$output" == *"Unknown option"* ]] -} - -# Test scan_installers_in_path function directly - -# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -# Tests using find (forced fallback by hiding fd) -# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -@test "scan_installers_in_path (fallback find): finds .dmg files" { - touch "$HOME/Downloads/Chrome.dmg" - - run env PATH="/usr/bin:/bin" bash -euo pipefail -c " - export MOLE_TEST_MODE=1 - source \"\$1\" - scan_installers_in_path \"\$2\" - " bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" - - [ "$status" -eq 0 ] - [[ "$output" == *"Chrome.dmg"* ]] -} - -@test "scan_installers_in_path (fallback find): finds multiple installer types" { - touch "$HOME/Downloads/App1.dmg" - touch "$HOME/Downloads/App2.pkg" - touch "$HOME/Downloads/App3.iso" - touch "$HOME/Downloads/App.mpkg" - - run env PATH="/usr/bin:/bin" bash -euo pipefail -c " - export MOLE_TEST_MODE=1 - source \"\$1\" - scan_installers_in_path \"\$2\" - " bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" - - [ "$status" -eq 0 ] - [[ "$output" == *"App1.dmg"* ]] - [[ "$output" == *"App2.pkg"* ]] - [[ "$output" == *"App3.iso"* ]] - [[ "$output" == *"App.mpkg"* ]] -} - -@test "scan_installers_in_path (fallback find): respects max depth" { - mkdir -p "$HOME/Downloads/level1/level2/level3" - touch "$HOME/Downloads/shallow.dmg" - touch "$HOME/Downloads/level1/mid.dmg" - touch "$HOME/Downloads/level1/level2/deep.dmg" - touch "$HOME/Downloads/level1/level2/level3/too-deep.dmg" - - run env PATH="/usr/bin:/bin" bash -euo pipefail -c " - export MOLE_TEST_MODE=1 - source \"\$1\" - scan_installers_in_path \"\$2\" - " bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" - - [ "$status" -eq 0 ] - # Default max depth is 2 - [[ "$output" == *"shallow.dmg"* ]] - [[ "$output" == *"mid.dmg"* ]] - [[ "$output" == *"deep.dmg"* ]] - [[ "$output" != *"too-deep.dmg"* ]] -} - -@test "scan_installers_in_path (fallback find): honors MOLE_INSTALLER_SCAN_MAX_DEPTH" { - mkdir -p "$HOME/Downloads/level1" - touch "$HOME/Downloads/top.dmg" - touch "$HOME/Downloads/level1/nested.dmg" - - run env PATH="/usr/bin:/bin" MOLE_INSTALLER_SCAN_MAX_DEPTH=1 bash -euo pipefail -c " - export MOLE_TEST_MODE=1 - source \"\$1\" - scan_installers_in_path \"\$2\" - " bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" - - [ "$status" -eq 0 ] - [[ "$output" == *"top.dmg"* ]] - [[ "$output" != *"nested.dmg"* ]] -} - -@test "scan_installers_in_path (fallback find): handles non-existent directory" { - run env PATH="/usr/bin:/bin" bash -euo pipefail -c " - export MOLE_TEST_MODE=1 - source \"\$1\" - scan_installers_in_path \"\$2\" - " bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/NonExistent" - - [ "$status" -eq 0 ] - [[ -z "$output" ]] -} - -@test "scan_installers_in_path (fallback find): ignores non-installer files" { - touch "$HOME/Downloads/document.pdf" - touch "$HOME/Downloads/image.jpg" - touch "$HOME/Downloads/archive.tar.gz" - touch "$HOME/Downloads/Installer.dmg" - - run env PATH="/usr/bin:/bin" bash -euo pipefail -c " - export MOLE_TEST_MODE=1 - source \"\$1\" - scan_installers_in_path \"\$2\" - " bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" - - [ "$status" -eq 0 ] - [[ "$output" != *"document.pdf"* ]] - [[ "$output" != *"image.jpg"* ]] - [[ "$output" != *"archive.tar.gz"* ]] - [[ "$output" == *"Installer.dmg"* ]] -} - -@test "scan_all_installers: handles missing paths gracefully" { - # Don't create all scan directories, some may not exist - # Only create Downloads, delete others if they exist - rm -rf "$HOME/Desktop" - rm -rf "$HOME/Documents" - rm -rf "$HOME/Public" - rm -rf "$HOME/Public/Downloads" - rm -rf "$HOME/Library/Downloads" - mkdir -p "$HOME/Downloads" - - # Add an installer to the one directory that exists - touch "$HOME/Downloads/test.dmg" - - run bash -euo pipefail -c ' - export MOLE_TEST_MODE=1 - source "$1" - scan_all_installers - ' bash "$PROJECT_ROOT/bin/installer.sh" - - # Should succeed even with missing paths - [ "$status" -eq 0 ] - # Should still find the installer in the existing directory - [[ "$output" == *"test.dmg"* ]] -} - -# Test edge cases - -@test "scan_installers_in_path (fallback find): handles filenames with spaces" { - touch "$HOME/Downloads/My App Installer.dmg" - - run env PATH="/usr/bin:/bin" bash -euo pipefail -c " - export MOLE_TEST_MODE=1 - source \"\$1\" - scan_installers_in_path \"\$2\" - " bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" - - [ "$status" -eq 0 ] - [[ "$output" == *"My App Installer.dmg"* ]] -} - -@test "scan_installers_in_path (fallback find): handles filenames with special characters" { - touch "$HOME/Downloads/App-v1.2.3_beta.pkg" - - run env PATH="/usr/bin:/bin" bash -euo pipefail -c " - export MOLE_TEST_MODE=1 - source \"\$1\" - scan_installers_in_path \"\$2\" - " bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" - - [ "$status" -eq 0 ] - [[ "$output" == *"App-v1.2.3_beta.pkg"* ]] -} - -@test "scan_installers_in_path (fallback find): returns empty for directory with no installers" { - # Create some non-installer files - touch "$HOME/Downloads/document.pdf" - touch "$HOME/Downloads/image.png" - - run env PATH="/usr/bin:/bin" bash -euo pipefail -c " - export MOLE_TEST_MODE=1 - source \"\$1\" - scan_installers_in_path \"\$2\" - " bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" - - [ "$status" -eq 0 ] - [[ -z "$output" ]] -} - -# Symlink handling tests - -@test "scan_installers_in_path (fallback find): skips symlinks to regular files" { - touch "$HOME/Downloads/real.dmg" - ln -s "$HOME/Downloads/real.dmg" "$HOME/Downloads/symlink.dmg" - ln -s /nonexistent "$HOME/Downloads/dangling.lnk" - - run env PATH="/usr/bin:/bin" bash -euo pipefail -c " - export MOLE_TEST_MODE=1 - source \"\$1\" - scan_installers_in_path \"\$2\" - " bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" - - [ "$status" -eq 0 ] - [[ "$output" == *"real.dmg"* ]] - [[ "$output" != *"symlink.dmg"* ]] - [[ "$output" != *"dangling.lnk"* ]] -} diff --git a/tests/installer_fd.bats b/tests/installer_fd.bats deleted file mode 100644 index 10d98ff..0000000 --- a/tests/installer_fd.bats +++ /dev/null @@ -1,245 +0,0 @@ -#!/usr/bin/env bats - -setup_file() { - PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" - export PROJECT_ROOT - - ORIGINAL_HOME="${HOME:-}" - export ORIGINAL_HOME - - HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-installers-home.XXXXXX")" - export HOME - - mkdir -p "$HOME" - - if command -v fd > /dev/null 2>&1; then - FD_AVAILABLE=1 - else - FD_AVAILABLE=0 - fi -} - -teardown_file() { - rm -rf "$HOME" - if [[ -n "${ORIGINAL_HOME:-}" ]]; then - export HOME="$ORIGINAL_HOME" - fi -} - -setup() { - export TERM="xterm-256color" - export MO_DEBUG=0 - - # Create standard scan directories - mkdir -p "$HOME/Downloads" - mkdir -p "$HOME/Desktop" - mkdir -p "$HOME/Documents" - mkdir -p "$HOME/Public" - mkdir -p "$HOME/Library/Downloads" - - # Clear previous test files - rm -rf "${HOME:?}/Downloads"/* - rm -rf "${HOME:?}/Desktop"/* - rm -rf "${HOME:?}/Documents"/* -} - -require_fd() { - [[ "${FD_AVAILABLE:-0}" -eq 1 ]] -} - -@test "scan_installers_in_path (fd): finds .dmg files" { - if ! require_fd; then - return 0 - fi - - touch "$HOME/Downloads/Chrome.dmg" - - run bash -euo pipefail -c ' - export MOLE_TEST_MODE=1 - source "$1" - scan_installers_in_path "$2" - ' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" - - [ "$status" -eq 0 ] - [[ "$output" == *"Chrome.dmg"* ]] -} - -@test "scan_installers_in_path (fd): finds multiple installer types" { - if ! require_fd; then - return 0 - fi - - touch "$HOME/Downloads/App1.dmg" - touch "$HOME/Downloads/App2.pkg" - touch "$HOME/Downloads/App3.iso" - touch "$HOME/Downloads/App.mpkg" - - run bash -euo pipefail -c ' - export MOLE_TEST_MODE=1 - source "$1" - scan_installers_in_path "$2" - ' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" - - [ "$status" -eq 0 ] - [[ "$output" == *"App1.dmg"* ]] - [[ "$output" == *"App2.pkg"* ]] - [[ "$output" == *"App3.iso"* ]] - [[ "$output" == *"App.mpkg"* ]] -} - -@test "scan_installers_in_path (fd): respects max depth" { - if ! require_fd; then - return 0 - fi - - mkdir -p "$HOME/Downloads/level1/level2/level3" - touch "$HOME/Downloads/shallow.dmg" - touch "$HOME/Downloads/level1/mid.dmg" - touch "$HOME/Downloads/level1/level2/deep.dmg" - touch "$HOME/Downloads/level1/level2/level3/too-deep.dmg" - - run bash -euo pipefail -c ' - export MOLE_TEST_MODE=1 - source "$1" - scan_installers_in_path "$2" - ' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" - - [ "$status" -eq 0 ] - # Default max depth is 2 - [[ "$output" == *"shallow.dmg"* ]] - [[ "$output" == *"mid.dmg"* ]] - [[ "$output" == *"deep.dmg"* ]] - [[ "$output" != *"too-deep.dmg"* ]] -} - -@test "scan_installers_in_path (fd): honors MOLE_INSTALLER_SCAN_MAX_DEPTH" { - if ! require_fd; then - return 0 - fi - - mkdir -p "$HOME/Downloads/level1" - touch "$HOME/Downloads/top.dmg" - touch "$HOME/Downloads/level1/nested.dmg" - - run env MOLE_INSTALLER_SCAN_MAX_DEPTH=1 bash -euo pipefail -c " - export MOLE_TEST_MODE=1 - source \"\$1\" - scan_installers_in_path \"\$2\" - " bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" - - [ "$status" -eq 0 ] - [[ "$output" == *"top.dmg"* ]] - [[ "$output" != *"nested.dmg"* ]] -} - -@test "scan_installers_in_path (fd): handles non-existent directory" { - if ! require_fd; then - return 0 - fi - - run bash -euo pipefail -c ' - export MOLE_TEST_MODE=1 - source "$1" - scan_installers_in_path "$2" - ' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/NonExistent" - - [ "$status" -eq 0 ] - [[ -z "$output" ]] -} - -@test "scan_installers_in_path (fd): ignores non-installer files" { - if ! require_fd; then - return 0 - fi - - touch "$HOME/Downloads/document.pdf" - touch "$HOME/Downloads/image.jpg" - touch "$HOME/Downloads/archive.tar.gz" - touch "$HOME/Downloads/Installer.dmg" - - run bash -euo pipefail -c ' - export MOLE_TEST_MODE=1 - source "$1" - scan_installers_in_path "$2" - ' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" - - [ "$status" -eq 0 ] - [[ "$output" != *"document.pdf"* ]] - [[ "$output" != *"image.jpg"* ]] - [[ "$output" != *"archive.tar.gz"* ]] - [[ "$output" == *"Installer.dmg"* ]] -} - -@test "scan_installers_in_path (fd): handles filenames with spaces" { - if ! require_fd; then - return 0 - fi - - touch "$HOME/Downloads/My App Installer.dmg" - - run bash -euo pipefail -c ' - export MOLE_TEST_MODE=1 - source "$1" - scan_installers_in_path "$2" - ' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" - - [ "$status" -eq 0 ] - [[ "$output" == *"My App Installer.dmg"* ]] -} - -@test "scan_installers_in_path (fd): handles filenames with special characters" { - if ! require_fd; then - return 0 - fi - - touch "$HOME/Downloads/App-v1.2.3_beta.pkg" - - run bash -euo pipefail -c ' - export MOLE_TEST_MODE=1 - source "$1" - scan_installers_in_path "$2" - ' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" - - [ "$status" -eq 0 ] - [[ "$output" == *"App-v1.2.3_beta.pkg"* ]] -} - -@test "scan_installers_in_path (fd): returns empty for directory with no installers" { - if ! require_fd; then - return 0 - fi - - # Create some non-installer files - touch "$HOME/Downloads/document.pdf" - touch "$HOME/Downloads/image.png" - - run bash -euo pipefail -c ' - export MOLE_TEST_MODE=1 - source "$1" - scan_installers_in_path "$2" - ' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" - - [ "$status" -eq 0 ] - [[ -z "$output" ]] -} - -@test "scan_installers_in_path (fd): skips symlinks to regular files" { - if ! require_fd; then - return 0 - fi - - touch "$HOME/Downloads/real.dmg" - ln -s "$HOME/Downloads/real.dmg" "$HOME/Downloads/symlink.dmg" - ln -s /nonexistent "$HOME/Downloads/dangling.lnk" - - run bash -euo pipefail -c ' - export MOLE_TEST_MODE=1 - source "$1" - scan_installers_in_path "$2" - ' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" - - [ "$status" -eq 0 ] - [[ "$output" == *"real.dmg"* ]] - [[ "$output" != *"symlink.dmg"* ]] - [[ "$output" != *"dangling.lnk"* ]] -} diff --git a/tests/installer_zip.bats b/tests/installer_zip.bats deleted file mode 100644 index 743df15..0000000 --- a/tests/installer_zip.bats +++ /dev/null @@ -1,377 +0,0 @@ -#!/usr/bin/env bats - -setup_file() { - PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" - export PROJECT_ROOT - - ORIGINAL_HOME="${HOME:-}" - export ORIGINAL_HOME - - HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-installers-home.XXXXXX")" - export HOME - - mkdir -p "$HOME" - - if command -v zip > /dev/null 2>&1; then - ZIP_AVAILABLE=1 - else - ZIP_AVAILABLE=0 - fi - if command -v zipinfo > /dev/null 2>&1 || command -v unzip > /dev/null 2>&1; then - ZIP_LIST_AVAILABLE=1 - else - ZIP_LIST_AVAILABLE=0 - fi - if command -v unzip > /dev/null 2>&1; then - UNZIP_AVAILABLE=1 - else - UNZIP_AVAILABLE=0 - fi -} - -teardown_file() { - rm -rf "$HOME" - if [[ -n "${ORIGINAL_HOME:-}" ]]; then - export HOME="$ORIGINAL_HOME" - fi -} - -setup() { - export TERM="xterm-256color" - export MO_DEBUG=0 - - # Create standard scan directories - mkdir -p "$HOME/Downloads" - mkdir -p "$HOME/Desktop" - mkdir -p "$HOME/Documents" - mkdir -p "$HOME/Public" - mkdir -p "$HOME/Library/Downloads" - - # Clear previous test files - rm -rf "${HOME:?}/Downloads"/* - rm -rf "${HOME:?}/Desktop"/* - rm -rf "${HOME:?}/Documents"/* -} - -zip_list_available() { - [[ "${ZIP_LIST_AVAILABLE:-0}" -eq 1 ]] -} - -require_zip_list() { - zip_list_available -} - -require_zip_support() { - [[ "${ZIP_AVAILABLE:-0}" -eq 1 && "${ZIP_LIST_AVAILABLE:-0}" -eq 1 ]] -} - -require_unzip_support() { - [[ "${ZIP_AVAILABLE:-0}" -eq 1 && "${UNZIP_AVAILABLE:-0}" -eq 1 ]] -} - -# Test ZIP installer detection - -@test "is_installer_zip: detects ZIP with installer content even with many entries" { - if ! require_zip_support; then - return 0 - fi - - # Create a ZIP with many files (more than old MAX_ZIP_ENTRIES=5) - # Include a .app file to have installer content - mkdir -p "$HOME/Downloads/large-app" - touch "$HOME/Downloads/large-app/MyApp.app" - for i in {1..9}; do - touch "$HOME/Downloads/large-app/file$i.txt" - done - (cd "$HOME/Downloads" && zip -q -r large-installer.zip large-app) - - run bash -euo pipefail -c ' - export MOLE_TEST_MODE=1 - source "$1" - if is_installer_zip "'"$HOME/Downloads/large-installer.zip"'"; then - echo "INSTALLER" - else - echo "NOT_INSTALLER" - fi - ' bash "$PROJECT_ROOT/bin/installer.sh" - - [ "$status" -eq 0 ] - [[ "$output" == "INSTALLER" ]] -} - -@test "is_installer_zip: detects ZIP with app content" { - if ! require_zip_support; then - return 0 - fi - - mkdir -p "$HOME/Downloads/app-content" - touch "$HOME/Downloads/app-content/MyApp.app" - (cd "$HOME/Downloads" && zip -q -r app.zip app-content) - - run bash -euo pipefail -c ' - export MOLE_TEST_MODE=1 - source "$1" - if is_installer_zip "'"$HOME/Downloads/app.zip"'"; then - echo "INSTALLER" - else - echo "NOT_INSTALLER" - fi - ' bash "$PROJECT_ROOT/bin/installer.sh" - - [ "$status" -eq 0 ] - [[ "$output" == "INSTALLER" ]] -} - -@test "is_installer_zip: rejects ZIP when installer pattern appears after MAX_ZIP_ENTRIES" { - if ! require_zip_support; then - return 0 - fi - - # Create a ZIP where .app appears after the 50th entry - mkdir -p "$HOME/Downloads/deep-content" - # Create 51 regular files first - for i in {1..51}; do - touch "$HOME/Downloads/deep-content/file$i.txt" - done - # Add .app file at the end (52nd entry) - touch "$HOME/Downloads/deep-content/MyApp.app" - (cd "$HOME/Downloads" && zip -q -r deep.zip deep-content) - - run bash -euo pipefail -c ' - export MOLE_TEST_MODE=1 - source "$1" - if is_installer_zip "'"$HOME/Downloads/deep.zip"'"; then - echo "INSTALLER" - else - echo "NOT_INSTALLER" - fi - ' bash "$PROJECT_ROOT/bin/installer.sh" - - [ "$status" -eq 0 ] - [[ "$output" == "NOT_INSTALLER" ]] -} - -@test "is_installer_zip: detects ZIP with real app bundle structure" { - if ! require_zip_support; then - return 0 - fi - - # Create a realistic .app bundle structure (directory, not just a file) - mkdir -p "$HOME/Downloads/RealApp.app/Contents/MacOS" - mkdir -p "$HOME/Downloads/RealApp.app/Contents/Resources" - echo "#!/bin/bash" > "$HOME/Downloads/RealApp.app/Contents/MacOS/RealApp" - chmod +x "$HOME/Downloads/RealApp.app/Contents/MacOS/RealApp" - cat > "$HOME/Downloads/RealApp.app/Contents/Info.plist" << 'EOF' - - - - - CFBundleExecutable - RealApp - - -EOF - (cd "$HOME/Downloads" && zip -q -r realapp.zip RealApp.app) - - run bash -euo pipefail -c ' - export MOLE_TEST_MODE=1 - source "$1" - if is_installer_zip "'"$HOME/Downloads/realapp.zip"'"; then - echo "INSTALLER" - else - echo "NOT_INSTALLER" - fi - ' bash "$PROJECT_ROOT/bin/installer.sh" - - [ "$status" -eq 0 ] - [[ "$output" == "INSTALLER" ]] -} - -@test "is_installer_zip: rejects ZIP with only regular files" { - if ! require_zip_support; then - return 0 - fi - - mkdir -p "$HOME/Downloads/data" - touch "$HOME/Downloads/data/file1.txt" - touch "$HOME/Downloads/data/file2.pdf" - (cd "$HOME/Downloads" && zip -q -r data.zip data) - - run bash -euo pipefail -c ' - export MOLE_TEST_MODE=1 - source "$1" - if is_installer_zip "'"$HOME/Downloads/data.zip"'"; then - echo "INSTALLER" - else - echo "NOT_INSTALLER" - fi - ' bash "$PROJECT_ROOT/bin/installer.sh" - - [ "$status" -eq 0 ] - [[ "$output" == "NOT_INSTALLER" ]] -} - -@test "is_installer_zip: returns NOT_INSTALLER when ZIP list command is unavailable" { - touch "$HOME/Downloads/empty.zip" - - run bash -euo pipefail -c ' - export MOLE_TEST_MODE=1 - source "$1" - ZIP_LIST_CMD=() - if is_installer_zip "$2"; then - echo "INSTALLER" - else - echo "NOT_INSTALLER" - fi - ' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads/empty.zip" - - [ "$status" -eq 0 ] - [[ "$output" == "NOT_INSTALLER" ]] -} - -@test "is_installer_zip: works with unzip list command" { - if ! require_unzip_support; then - return 0 - fi - - mkdir -p "$HOME/Downloads/app-content" - touch "$HOME/Downloads/app-content/MyApp.app" - (cd "$HOME/Downloads" && zip -q -r app.zip app-content) - - run bash -euo pipefail -c ' - export MOLE_TEST_MODE=1 - source "$1" - ZIP_LIST_CMD=(unzip -Z -1) - if is_installer_zip "$2"; then - echo "INSTALLER" - else - echo "NOT_INSTALLER" - fi - ' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads/app.zip" - - [ "$status" -eq 0 ] - [[ "$output" == "INSTALLER" ]] -} - -# Integration tests: ZIP scanning inside scan_all_installers - -@test "scan_all_installers: finds installer ZIP in Downloads" { - if ! require_zip_support; then - return 0 - fi - - # Create a valid installer ZIP (contains .app) - mkdir -p "$HOME/Downloads/app-content" - touch "$HOME/Downloads/app-content/MyApp.app" - (cd "$HOME/Downloads" && zip -q -r installer.zip app-content) - - run bash -euo pipefail -c ' - export MOLE_TEST_MODE=1 - source "$1" - scan_all_installers - ' bash "$PROJECT_ROOT/bin/installer.sh" - - [ "$status" -eq 0 ] - [[ "$output" == *"installer.zip"* ]] -} - -@test "scan_all_installers: ignores non-installer ZIP in Downloads" { - if ! require_zip_support; then - return 0 - fi - - # Create a non-installer ZIP (only regular files) - mkdir -p "$HOME/Downloads/data" - touch "$HOME/Downloads/data/file1.txt" - touch "$HOME/Downloads/data/file2.pdf" - (cd "$HOME/Downloads" && zip -q -r data.zip data) - - run bash -euo pipefail -c ' - export MOLE_TEST_MODE=1 - source "$1" - scan_all_installers - ' bash "$PROJECT_ROOT/bin/installer.sh" - - [ "$status" -eq 0 ] - [[ "$output" != *"data.zip"* ]] -} - -# Failure path tests for scan_installers_in_path - -@test "scan_installers_in_path: skips corrupt ZIP files" { - if ! require_zip_list; then - return 0 - fi - - # Create a corrupt ZIP file by just writing garbage data - echo "This is not a valid ZIP file" > "$HOME/Downloads/corrupt.zip" - - run bash -euo pipefail -c ' - export MOLE_TEST_MODE=1 - source "$1" - scan_installers_in_path "$2" - ' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" - - # Should succeed (return 0) and silently skip the corrupt ZIP - [ "$status" -eq 0 ] - # Output should be empty since corrupt.zip is not a valid installer - [[ -z "$output" ]] -} - -@test "scan_installers_in_path: handles permission-denied files" { - if ! require_zip_support; then - return 0 - fi - - # Create a valid installer ZIP - mkdir -p "$HOME/Downloads/app-content" - touch "$HOME/Downloads/app-content/MyApp.app" - (cd "$HOME/Downloads" && zip -q -r readable.zip app-content) - - # Create a readable installer ZIP alongside a permission-denied file - mkdir -p "$HOME/Downloads/restricted-app" - touch "$HOME/Downloads/restricted-app/App.app" - (cd "$HOME/Downloads" && zip -q -r restricted.zip restricted-app) - - # Remove read permissions from restricted.zip - chmod 000 "$HOME/Downloads/restricted.zip" - - run bash -euo pipefail -c ' - export MOLE_TEST_MODE=1 - source "$1" - scan_installers_in_path "$2" - ' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" - - # Should succeed and find the readable.zip but skip restricted.zip - [ "$status" -eq 0 ] - [[ "$output" == *"readable.zip"* ]] - [[ "$output" != *"restricted.zip"* ]] - - # Cleanup: restore permissions for teardown - chmod 644 "$HOME/Downloads/restricted.zip" -} - -@test "scan_installers_in_path: finds installer ZIP alongside corrupt ZIPs" { - if ! require_zip_support; then - return 0 - fi - - # Create a valid installer ZIP - mkdir -p "$HOME/Downloads/app-content" - touch "$HOME/Downloads/app-content/MyApp.app" - (cd "$HOME/Downloads" && zip -q -r valid-installer.zip app-content) - - # Create a corrupt ZIP - echo "garbage data" > "$HOME/Downloads/corrupt.zip" - - run bash -euo pipefail -c ' - export MOLE_TEST_MODE=1 - source "$1" - scan_installers_in_path "$2" - ' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads" - - # Should find the valid ZIP and silently skip the corrupt one - [ "$status" -eq 0 ] - [[ "$output" == *"valid-installer.zip"* ]] - [[ "$output" != *"corrupt.zip"* ]] -} diff --git a/tests/manage_autofix.bats b/tests/manage_autofix.bats deleted file mode 100644 index b2f34ef..0000000 --- a/tests/manage_autofix.bats +++ /dev/null @@ -1,99 +0,0 @@ -#!/usr/bin/env bats - -setup_file() { - PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" - export PROJECT_ROOT -} - -@test "show_suggestions lists auto and manual items and exports flag" { - run bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/base.sh" -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 - -show_suggestions -echo "AUTO_FLAG=${HAS_AUTO_FIX_SUGGESTIONS}" -EOF - - [ "$status" -eq 0 ] - [[ "$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"* ]] -} - -@test "ask_for_auto_fix accepts Enter" { - run bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/base.sh" -source "$PROJECT_ROOT/lib/manage/autofix.sh" -HAS_AUTO_FIX_SUGGESTIONS=true -read_key() { echo "ENTER"; return 0; } -ask_for_auto_fix -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"yes"* ]] -} - -@test "ask_for_auto_fix rejects other keys" { - run bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/base.sh" -source "$PROJECT_ROOT/lib/manage/autofix.sh" -HAS_AUTO_FIX_SUGGESTIONS=true -read_key() { echo "ESC"; return 0; } -ask_for_auto_fix -EOF - - [ "$status" -eq 1 ] - [[ "$output" == *"no"* ]] -} - -@test "perform_auto_fix applies available actions and records summary" { - run bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/base.sh" -source "$PROJECT_ROOT/lib/manage/autofix.sh" - -has_sudo_session() { return 0; } -ensure_sudo_session() { return 0; } -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 -} - -export FIREWALL_DISABLED=true -export TOUCHID_NOT_CONFIGURED=true -export ROSETTA_NOT_INSTALLED=true - -perform_auto_fix -echo "SUMMARY=${AUTO_FIX_SUMMARY}" -echo "DETAILS=${AUTO_FIX_DETAILS}" -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Firewall enabled"* ]] - [[ "$output" == *"Touch ID configured"* ]] - [[ "$output" == *"Rosetta 2 installed"* ]] - [[ "$output" == *"SUMMARY=Auto fixes applied: 3 issue(s)"* ]] - [[ "$output" == *"DETAILS"* ]] -} diff --git a/tests/manage_sudo.bats b/tests/manage_sudo.bats deleted file mode 100644 index 32c77bb..0000000 --- a/tests/manage_sudo.bats +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env bats - -setup_file() { - PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" - export PROJECT_ROOT -} - -setup() { - source "$PROJECT_ROOT/lib/core/common.sh" - source "$PROJECT_ROOT/lib/core/sudo.sh" -} - -@test "has_sudo_session returns 1 when no sudo session" { - # shellcheck disable=SC2329 - sudo() { return 1; } - export -f sudo - run has_sudo_session - [ "$status" -eq 0 ] || [ "$status" -eq 1 ] -} - -@test "sudo keepalive functions don't crash" { - - # shellcheck disable=SC2329 - function sudo() { - return 1 # Simulate no sudo available - } - export -f sudo - - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; source '$PROJECT_ROOT/lib/core/sudo.sh'; has_sudo_session" - [ "$status" -eq 1 ] # Expected: no sudo session -} - -@test "_start_sudo_keepalive returns a PID" { - function sudo() { - case "$1" in - -n) return 0 ;; # Simulate valid sudo session - -v) return 0 ;; # Refresh succeeds - *) return 1 ;; - esac - } - export -f sudo - - local pid - pid=$(bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; source '$PROJECT_ROOT/lib/core/sudo.sh'; _start_sudo_keepalive") - - [[ "$pid" =~ ^[0-9]+$ ]] - - kill "$pid" 2>/dev/null || true - wait "$pid" 2>/dev/null || true -} - -@test "_stop_sudo_keepalive handles invalid PID gracefully" { - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; source '$PROJECT_ROOT/lib/core/sudo.sh'; _stop_sudo_keepalive ''" - [ "$status" -eq 0 ] - - run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; source '$PROJECT_ROOT/lib/core/sudo.sh'; _stop_sudo_keepalive '99999'" - [ "$status" -eq 0 ] -} - - - -@test "stop_sudo_session cleans up keepalive process" { - export MOLE_SUDO_KEEPALIVE_PID="99999" - - run bash -c "export MOLE_SUDO_KEEPALIVE_PID=99999; source '$PROJECT_ROOT/lib/core/common.sh'; source '$PROJECT_ROOT/lib/core/sudo.sh'; stop_sudo_session" - [ "$status" -eq 0 ] -} - -@test "sudo manager initializes global state correctly" { - result=$(bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; source '$PROJECT_ROOT/lib/core/sudo.sh'; echo \$MOLE_SUDO_ESTABLISHED") - [[ "$result" == "false" ]] || [[ -z "$result" ]] -} diff --git a/tests/manage_whitelist.bats b/tests/manage_whitelist.bats deleted file mode 100644 index 234f4fb..0000000 --- a/tests/manage_whitelist.bats +++ /dev/null @@ -1,131 +0,0 @@ -#!/usr/bin/env bats - -setup_file() { - PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" - export PROJECT_ROOT - - ORIGINAL_HOME="${HOME:-}" - export ORIGINAL_HOME - - HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-whitelist-home.XXXXXX")" - export HOME - - mkdir -p "$HOME" -} - -teardown_file() { - rm -rf "$HOME" - if [[ -n "${ORIGINAL_HOME:-}" ]]; then - export HOME="$ORIGINAL_HOME" - fi -} - -setup() { - rm -rf "$HOME/.config" - mkdir -p "$HOME" - WHITELIST_PATH="$HOME/.config/mole/whitelist" -} - -@test "patterns_equivalent treats paths with tilde expansion as equal" { - local status - if HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/manage/whitelist.sh'; patterns_equivalent '~/.cache/test' \"\$HOME/.cache/test\""; then - status=0 - else - status=$? - fi - [ "$status" -eq 0 ] -} - -@test "patterns_equivalent distinguishes different paths" { - local status - if HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/manage/whitelist.sh'; patterns_equivalent '~/.cache/test' \"\$HOME/.cache/other\""; then - status=0 - else - status=$? - fi - [ "$status" -ne 0 ] -} - -@test "save_whitelist_patterns keeps unique entries and preserves header" { - HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/manage/whitelist.sh'; save_whitelist_patterns \"\$HOME/.cache/foo\" \"\$HOME/.cache/foo\" \"\$HOME/.cache/bar\"" - - [[ -f "$WHITELIST_PATH" ]] - - lines=() - while IFS= read -r line; do - lines+=("$line") - done < "$WHITELIST_PATH" - [ "${#lines[@]}" -ge 4 ] - occurrences=$(grep -c "$HOME/.cache/foo" "$WHITELIST_PATH") - [ "$occurrences" -eq 1 ] -} - -@test "load_whitelist falls back to defaults when config missing" { - rm -f "$WHITELIST_PATH" - HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/manage/whitelist.sh'; rm -f \"\$HOME/.config/mole/whitelist\"; load_whitelist; printf '%s\n' \"\${CURRENT_WHITELIST_PATTERNS[@]}\"" > "$HOME/current_whitelist.txt" - HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/manage/whitelist.sh'; printf '%s\n' \"\${DEFAULT_WHITELIST_PATTERNS[@]}\"" > "$HOME/default_whitelist.txt" - - current=() - while IFS= read -r line; do - current+=("$line") - done < "$HOME/current_whitelist.txt" - - defaults=() - while IFS= read -r line; do - defaults+=("$line") - done < "$HOME/default_whitelist.txt" - - [ "${#current[@]}" -eq "${#defaults[@]}" ] - [ "${current[0]}" = "${defaults[0]/\$HOME/$HOME}" ] -} - -@test "is_whitelisted matches saved patterns exactly" { - local status - if HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/manage/whitelist.sh'; save_whitelist_patterns \"\$HOME/.cache/unique-pattern\"; load_whitelist; is_whitelisted \"\$HOME/.cache/unique-pattern\""; then - status=0 - else - status=$? - fi - [ "$status" -eq 0 ] - - if HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/manage/whitelist.sh'; save_whitelist_patterns \"\$HOME/.cache/unique-pattern\"; load_whitelist; is_whitelisted \"\$HOME/.cache/other-pattern\""; then - status=0 - else - status=$? - fi - [ "$status" -ne 0 ] -} - -@test "mo clean --whitelist persists selections" { - whitelist_file="$HOME/.config/mole/whitelist" - mkdir -p "$(dirname "$whitelist_file")" - - run bash --noprofile --norc -c "cd '$PROJECT_ROOT'; printf \$'\\n' | HOME='$HOME' ./mo clean --whitelist" - [ "$status" -eq 0 ] - grep -q "\\.m2/repository" "$whitelist_file" - - run bash --noprofile --norc -c "cd '$PROJECT_ROOT'; printf \$' \\n' | HOME='$HOME' ./mo clean --whitelist" - [ "$status" -eq 0 ] - run grep -q "\\.m2/repository" "$whitelist_file" - [ "$status" -eq 1 ] - - run bash --noprofile --norc -c "cd '$PROJECT_ROOT'; printf \$'\\n' | HOME='$HOME' ./mo clean --whitelist" - [ "$status" -eq 0 ] - run grep -q "\\.m2/repository" "$whitelist_file" - [ "$status" -eq 1 ] -} - -@test "is_path_whitelisted protects parent directories of whitelisted nested paths" { - local status - if HOME="$HOME" bash --noprofile --norc -c " - source '$PROJECT_ROOT/lib/core/base.sh' - source '$PROJECT_ROOT/lib/core/app_protection.sh' - WHITELIST_PATTERNS=(\"\$HOME/Library/Caches/org.R-project.R/R/renv\") - is_path_whitelisted \"\$HOME/Library/Caches/org.R-project.R\" - "; then - status=0 - else - status=$? - fi - [ "$status" -eq 0 ] -} diff --git a/tests/optimize.bats b/tests/optimize.bats deleted file mode 100644 index a4ef886..0000000 --- a/tests/optimize.bats +++ /dev/null @@ -1,183 +0,0 @@ -#!/usr/bin/env bats - -setup_file() { - PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" - export PROJECT_ROOT - - ORIGINAL_HOME="${HOME:-}" - export ORIGINAL_HOME - - HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-optimize.XXXXXX")" - export HOME - - mkdir -p "$HOME" -} - -teardown_file() { - rm -rf "$HOME" - if [[ -n "${ORIGINAL_HOME:-}" ]]; then - export HOME="$ORIGINAL_HOME" - fi -} - -@test "needs_permissions_repair returns true when home not writable" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" USER="tester" bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/optimize/tasks.sh" -stat() { echo "root"; } -export -f stat -if needs_permissions_repair; then - echo "needs" -fi -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"needs"* ]] -} - -@test "has_bluetooth_hid_connected detects HID" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/optimize/tasks.sh" -system_profiler() { - cat << 'OUT' -Bluetooth: - Apple Magic Mouse: - Connected: Yes - Type: Mouse -OUT -} -export -f system_profiler -if has_bluetooth_hid_connected; then - echo "hid" -fi -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"hid"* ]] -} - -@test "is_ac_power detects AC power" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/optimize/tasks.sh" -pmset() { echo "AC Power"; } -export -f pmset -if is_ac_power; then - echo "ac" -fi -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"ac"* ]] -} - -@test "is_memory_pressure_high detects warning" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/optimize/tasks.sh" -memory_pressure() { echo "warning"; } -export -f memory_pressure -if is_memory_pressure_high; then - echo "high" -fi -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"high"* ]] -} - -@test "opt_system_maintenance reports DNS and Spotlight" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/optimize/tasks.sh" -flush_dns_cache() { return 0; } -mdutil() { echo "Indexing enabled."; } -opt_system_maintenance -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"DNS cache flushed"* ]] - [[ "$output" == *"Spotlight index verified"* ]] -} - -@test "opt_network_optimization refreshes DNS" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/optimize/tasks.sh" -flush_dns_cache() { return 0; } -opt_network_optimization -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"DNS cache refreshed"* ]] - [[ "$output" == *"mDNSResponder restarted"* ]] -} - -@test "opt_sqlite_vacuum reports sqlite3 unavailable" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/optimize/tasks.sh" -export PATH="/nonexistent" -opt_sqlite_vacuum -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"sqlite3 unavailable"* ]] -} - -@test "opt_font_cache_rebuild succeeds in dry-run" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/optimize/tasks.sh" -opt_font_cache_rebuild -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Font cache cleared"* ]] -} - -@test "opt_dock_refresh clears cache files" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/optimize/tasks.sh" -mkdir -p "$HOME/Library/Application Support/Dock" -touch "$HOME/Library/Application Support/Dock/test.db" -safe_remove() { return 0; } -opt_dock_refresh -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Dock cache cleared"* ]] - [[ "$output" == *"Dock refreshed"* ]] -} - -@test "execute_optimization dispatches actions" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/optimize/tasks.sh" -opt_dock_refresh() { echo "dock"; } -execute_optimization dock_refresh -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"dock"* ]] -} - -@test "execute_optimization rejects unknown action" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/optimize/tasks.sh" -execute_optimization unknown_action -EOF - - [ "$status" -eq 1 ] - [[ "$output" == *"Unknown action"* ]] -} diff --git a/tests/purge.bats b/tests/purge.bats deleted file mode 100644 index 5ccab1f..0000000 --- a/tests/purge.bats +++ /dev/null @@ -1,643 +0,0 @@ -#!/usr/bin/env bats - -setup_file() { - PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" - export PROJECT_ROOT - - ORIGINAL_HOME="${HOME:-}" - export ORIGINAL_HOME - - HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-purge-home.XXXXXX")" - export HOME - - mkdir -p "$HOME" -} - -teardown_file() { - rm -rf "$HOME" - if [[ -n "${ORIGINAL_HOME:-}" ]]; then - export HOME="$ORIGINAL_HOME" - fi -} - -setup() { - mkdir -p "$HOME/www" - mkdir -p "$HOME/dev" - mkdir -p "$HOME/.cache/mole" - - rm -rf "${HOME:?}/www"/* "${HOME:?}/dev"/* -} - -@test "is_safe_project_artifact: rejects shallow paths (protection against accidents)" { - result=$(bash -c " - source '$PROJECT_ROOT/lib/clean/project.sh' - if is_safe_project_artifact '$HOME/www/node_modules' '$HOME/www'; then - echo 'UNSAFE' - else - echo 'SAFE' - fi - ") - [[ "$result" == "SAFE" ]] -} - -@test "is_safe_project_artifact: allows proper project artifacts" { - result=$(bash -c " - source '$PROJECT_ROOT/lib/clean/project.sh' - if is_safe_project_artifact '$HOME/www/myproject/node_modules' '$HOME/www'; then - echo 'ALLOWED' - else - echo 'BLOCKED' - fi - ") - [[ "$result" == "ALLOWED" ]] -} - -@test "is_safe_project_artifact: rejects non-absolute paths" { - result=$(bash -c " - source '$PROJECT_ROOT/lib/clean/project.sh' - if is_safe_project_artifact 'relative/path/node_modules' '$HOME/www'; then - echo 'UNSAFE' - else - echo 'SAFE' - fi - ") - [[ "$result" == "SAFE" ]] -} - -@test "is_safe_project_artifact: validates depth calculation" { - result=$(bash -c " - source '$PROJECT_ROOT/lib/clean/project.sh' - if is_safe_project_artifact '$HOME/www/project/subdir/node_modules' '$HOME/www'; then - echo 'ALLOWED' - else - echo 'BLOCKED' - fi - ") - [[ "$result" == "ALLOWED" ]] -} - -@test "filter_nested_artifacts: removes nested node_modules" { - mkdir -p "$HOME/www/project/node_modules/package/node_modules" - - result=$(bash -c " - source '$PROJECT_ROOT/lib/clean/project.sh' - printf '%s\n' '$HOME/www/project/node_modules' '$HOME/www/project/node_modules/package/node_modules' | \ - filter_nested_artifacts | wc -l | tr -d ' ' - ") - - [[ "$result" == "1" ]] -} - -@test "filter_nested_artifacts: keeps independent artifacts" { - mkdir -p "$HOME/www/project1/node_modules" - mkdir -p "$HOME/www/project2/target" - - result=$(bash -c " - source '$PROJECT_ROOT/lib/clean/project.sh' - printf '%s\n' '$HOME/www/project1/node_modules' '$HOME/www/project2/target' | \ - filter_nested_artifacts | wc -l | tr -d ' ' - ") - - [[ "$result" == "2" ]] -} - -# Vendor protection unit tests -@test "is_rails_project_root: detects valid Rails project" { - mkdir -p "$HOME/www/test-rails/config" - mkdir -p "$HOME/www/test-rails/bin" - touch "$HOME/www/test-rails/config/application.rb" - touch "$HOME/www/test-rails/Gemfile" - touch "$HOME/www/test-rails/bin/rails" - - result=$(bash -c " - source '$PROJECT_ROOT/lib/clean/project.sh' - if is_rails_project_root '$HOME/www/test-rails'; then - echo 'YES' - else - echo 'NO' - fi - ") - - [[ "$result" == "YES" ]] -} - -@test "is_rails_project_root: rejects non-Rails directory" { - mkdir -p "$HOME/www/not-rails" - touch "$HOME/www/not-rails/package.json" - - result=$(bash -c " - source '$PROJECT_ROOT/lib/clean/project.sh' - if is_rails_project_root '$HOME/www/not-rails'; then - echo 'YES' - else - echo 'NO' - fi - ") - - [[ "$result" == "NO" ]] -} - -@test "is_go_project_root: detects valid Go project" { - mkdir -p "$HOME/www/test-go" - touch "$HOME/www/test-go/go.mod" - - result=$(bash -c " - source '$PROJECT_ROOT/lib/clean/project.sh' - if is_go_project_root '$HOME/www/test-go'; then - echo 'YES' - else - echo 'NO' - fi - ") - - [[ "$result" == "YES" ]] -} - -@test "is_php_project_root: detects valid PHP Composer project" { - mkdir -p "$HOME/www/test-php" - touch "$HOME/www/test-php/composer.json" - - result=$(bash -c " - source '$PROJECT_ROOT/lib/clean/project.sh' - if is_php_project_root '$HOME/www/test-php'; then - echo 'YES' - else - echo 'NO' - fi - ") - - [[ "$result" == "YES" ]] -} - -@test "is_protected_vendor_dir: protects Rails vendor" { - mkdir -p "$HOME/www/rails-app/vendor" - mkdir -p "$HOME/www/rails-app/config" - touch "$HOME/www/rails-app/config/application.rb" - touch "$HOME/www/rails-app/Gemfile" - touch "$HOME/www/rails-app/config/environment.rb" - - result=$(bash -c " - source '$PROJECT_ROOT/lib/clean/project.sh' - if is_protected_vendor_dir '$HOME/www/rails-app/vendor'; then - echo 'PROTECTED' - else - echo 'NOT_PROTECTED' - fi - ") - - [[ "$result" == "PROTECTED" ]] -} - -@test "is_protected_vendor_dir: does not protect PHP vendor" { - mkdir -p "$HOME/www/php-app/vendor" - touch "$HOME/www/php-app/composer.json" - - result=$(bash -c " - source '$PROJECT_ROOT/lib/clean/project.sh' - if is_protected_vendor_dir '$HOME/www/php-app/vendor'; then - echo 'PROTECTED' - else - echo 'NOT_PROTECTED' - fi - ") - - [[ "$result" == "NOT_PROTECTED" ]] -} - -@test "is_project_container detects project indicators" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/clean/project.sh" -mkdir -p "$HOME/Workspace2/project" -touch "$HOME/Workspace2/project/package.json" -if is_project_container "$HOME/Workspace2" 2; then - echo "yes" -fi -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"yes"* ]] -} - -@test "discover_project_dirs includes detected containers" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/clean/project.sh" -mkdir -p "$HOME/CustomProjects/app" -touch "$HOME/CustomProjects/app/go.mod" -discover_project_dirs | grep -q "$HOME/CustomProjects" -EOF - - [ "$status" -eq 0 ] -} - -@test "save_discovered_paths writes config with tilde" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/clean/project.sh" -save_discovered_paths "$HOME/Projects" -grep -q "^~/" "$HOME/.config/mole/purge_paths" -EOF - - [ "$status" -eq 0 ] -} - -@test "select_purge_categories returns failure on empty input" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/clean/project.sh" -if select_purge_categories; then - exit 1 -fi -EOF - - [ "$status" -eq 0 ] -} - -@test "is_protected_vendor_dir: protects Go vendor" { - mkdir -p "$HOME/www/go-app/vendor" - touch "$HOME/www/go-app/go.mod" - - result=$(bash -c " - source '$PROJECT_ROOT/lib/clean/project.sh' - if is_protected_vendor_dir '$HOME/www/go-app/vendor'; then - echo 'PROTECTED' - else - echo 'NOT_PROTECTED' - fi - ") - - [[ "$result" == "PROTECTED" ]] -} - -@test "is_protected_vendor_dir: protects unknown vendor (conservative)" { - mkdir -p "$HOME/www/unknown-app/vendor" - - result=$(bash -c " - source '$PROJECT_ROOT/lib/clean/project.sh' - if is_protected_vendor_dir '$HOME/www/unknown-app/vendor'; then - echo 'PROTECTED' - else - echo 'NOT_PROTECTED' - fi - ") - - [[ "$result" == "PROTECTED" ]] -} - -@test "is_protected_purge_artifact: handles vendor directories correctly" { - mkdir -p "$HOME/www/php-app/vendor" - touch "$HOME/www/php-app/composer.json" - - result=$(bash -c " - source '$PROJECT_ROOT/lib/clean/project.sh' - if is_protected_purge_artifact '$HOME/www/php-app/vendor'; then - echo 'PROTECTED' - else - echo 'NOT_PROTECTED' - fi - ") - - # PHP vendor should not be protected - [[ "$result" == "NOT_PROTECTED" ]] -} - -@test "is_protected_purge_artifact: returns false for non-vendor artifacts" { - mkdir -p "$HOME/www/app/node_modules" - - result=$(bash -c " - source '$PROJECT_ROOT/lib/clean/project.sh' - if is_protected_purge_artifact '$HOME/www/app/node_modules'; then - echo 'PROTECTED' - else - echo 'NOT_PROTECTED' - fi - ") - - # node_modules is not in the protected list - [[ "$result" == "NOT_PROTECTED" ]] -} - -# Integration tests -@test "scan_purge_targets: skips Rails vendor directory" { - mkdir -p "$HOME/www/rails-app/vendor/javascript" - mkdir -p "$HOME/www/rails-app/config" - touch "$HOME/www/rails-app/config/application.rb" - touch "$HOME/www/rails-app/Gemfile" - mkdir -p "$HOME/www/rails-app/bin" - touch "$HOME/www/rails-app/bin/rails" - - local scan_output - scan_output="$(mktemp)" - - result=$(bash -c " - source '$PROJECT_ROOT/lib/clean/project.sh' - scan_purge_targets '$HOME/www' '$scan_output' - if grep -q '$HOME/www/rails-app/vendor' '$scan_output'; then - echo 'FOUND' - else - echo 'SKIPPED' - fi - ") - - rm -f "$scan_output" - - [[ "$result" == "SKIPPED" ]] -} - -@test "scan_purge_targets: cleans PHP Composer vendor directory" { - mkdir -p "$HOME/www/php-app/vendor" - touch "$HOME/www/php-app/composer.json" - - local scan_output - scan_output="$(mktemp)" - - result=$(bash -c " - source '$PROJECT_ROOT/lib/clean/project.sh' - scan_purge_targets '$HOME/www' '$scan_output' - if grep -q '$HOME/www/php-app/vendor' '$scan_output'; then - echo 'FOUND' - else - echo 'MISSING' - fi - ") - - rm -f "$scan_output" - - [[ "$result" == "FOUND" ]] -} - -@test "scan_purge_targets: skips Go vendor directory" { - mkdir -p "$HOME/www/go-app/vendor" - touch "$HOME/www/go-app/go.mod" - touch "$HOME/www/go-app/go.sum" - - local scan_output - scan_output="$(mktemp)" - - result=$(bash -c " - source '$PROJECT_ROOT/lib/clean/project.sh' - scan_purge_targets '$HOME/www' '$scan_output' - if grep -q '$HOME/www/go-app/vendor' '$scan_output'; then - echo 'FOUND' - else - echo 'SKIPPED' - fi - ") - - rm -f "$scan_output" - - [[ "$result" == "SKIPPED" ]] -} - -@test "scan_purge_targets: skips unknown vendor directory" { - # Create a vendor directory without any project file - mkdir -p "$HOME/www/unknown-app/vendor" - - local scan_output - scan_output="$(mktemp)" - - result=$(bash -c " - source '$PROJECT_ROOT/lib/clean/project.sh' - scan_purge_targets '$HOME/www' '$scan_output' - if grep -q '$HOME/www/unknown-app/vendor' '$scan_output'; then - echo 'FOUND' - else - echo 'SKIPPED' - fi - ") - - rm -f "$scan_output" - - # Unknown vendor should be protected (conservative approach) - [[ "$result" == "SKIPPED" ]] -} - -@test "is_recently_modified: detects recent projects" { - mkdir -p "$HOME/www/project/node_modules" - touch "$HOME/www/project/package.json" - - result=$(bash -c " - source '$PROJECT_ROOT/lib/core/common.sh' - source '$PROJECT_ROOT/lib/clean/project.sh' - if is_recently_modified '$HOME/www/project/node_modules'; then - echo 'RECENT' - else - echo 'OLD' - fi - ") - [[ "$result" == "RECENT" ]] -} - -@test "is_recently_modified: marks old projects correctly" { - mkdir -p "$HOME/www/old-project/node_modules" - mkdir -p "$HOME/www/old-project" - - bash -c " - source '$PROJECT_ROOT/lib/core/common.sh' - source '$PROJECT_ROOT/lib/clean/project.sh' - is_recently_modified '$HOME/www/old-project/node_modules' || true - " - local exit_code=$? - [ "$exit_code" -eq 0 ] || [ "$exit_code" -eq 1 ] -} - -@test "purge targets are configured correctly" { - result=$(bash -c " - source '$PROJECT_ROOT/lib/clean/project.sh' - echo \"\${PURGE_TARGETS[@]}\" - ") - [[ "$result" == *"node_modules"* ]] - [[ "$result" == *"target"* ]] -} - -@test "get_dir_size_kb: calculates directory size" { - mkdir -p "$HOME/www/test-project/node_modules" - dd if=/dev/zero of="$HOME/www/test-project/node_modules/file.bin" bs=1024 count=1024 2>/dev/null - - result=$(bash -c " - source '$PROJECT_ROOT/lib/clean/project.sh' - get_dir_size_kb '$HOME/www/test-project/node_modules' - ") - - [[ "$result" -ge 1000 ]] && [[ "$result" -le 1100 ]] -} - -@test "get_dir_size_kb: handles non-existent paths gracefully" { - result=$(bash -c " - source '$PROJECT_ROOT/lib/clean/project.sh' - get_dir_size_kb '$HOME/www/non-existent' - ") - [[ "$result" == "0" ]] -} - -@test "clean_project_artifacts: handles empty directory gracefully" { - run bash -c " - export HOME='$HOME' - source '$PROJECT_ROOT/lib/core/common.sh' - source '$PROJECT_ROOT/lib/clean/project.sh' - clean_project_artifacts - " < /dev/null - - [[ "$status" -eq 0 ]] || [[ "$status" -eq 2 ]] -} - -@test "clean_project_artifacts: scans and finds artifacts" { - if ! command -v gtimeout >/dev/null 2>&1 && ! command -v timeout >/dev/null 2>&1; then - skip "gtimeout/timeout not available" - fi - - mkdir -p "$HOME/www/test-project/node_modules/package1" - echo "test data" > "$HOME/www/test-project/node_modules/package1/index.js" - - mkdir -p "$HOME/www/test-project" - - timeout_cmd="timeout" - command -v timeout >/dev/null 2>&1 || timeout_cmd="gtimeout" - - run bash -c " - export HOME='$HOME' - $timeout_cmd 5 '$PROJECT_ROOT/bin/purge.sh' 2>&1 < /dev/null || true - " - - [[ "$output" =~ "Scanning" ]] || - [[ "$output" =~ "Purge complete" ]] || - [[ "$output" =~ "No old" ]] || - [[ "$output" =~ "Great" ]] -} - -@test "mo purge: command exists and is executable" { - [ -x "$PROJECT_ROOT/mole" ] - [ -f "$PROJECT_ROOT/bin/purge.sh" ] -} - -@test "mo purge: shows in help text" { - run env HOME="$HOME" "$PROJECT_ROOT/mole" --help - [ "$status" -eq 0 ] - [[ "$output" == *"mo purge"* ]] -} - -@test "mo purge: accepts --debug flag" { - if ! command -v gtimeout >/dev/null 2>&1 && ! command -v timeout >/dev/null 2>&1; then - skip "gtimeout/timeout not available" - fi - - timeout_cmd="timeout" - command -v timeout >/dev/null 2>&1 || timeout_cmd="gtimeout" - - run bash -c " - export HOME='$HOME' - $timeout_cmd 2 '$PROJECT_ROOT/mole' purge --debug < /dev/null 2>&1 || true - " - true -} - -@test "mo purge: creates cache directory for stats" { - if ! command -v gtimeout >/dev/null 2>&1 && ! command -v timeout >/dev/null 2>&1; then - skip "gtimeout/timeout not available" - fi - - timeout_cmd="timeout" - command -v timeout >/dev/null 2>&1 || timeout_cmd="gtimeout" - - bash -c " - export HOME='$HOME' - $timeout_cmd 2 '$PROJECT_ROOT/mole' purge < /dev/null 2>&1 || true - " - - [ -d "$HOME/.cache/mole" ] || [ -d "${XDG_CACHE_HOME:-$HOME/.cache}/mole" ] -} - -# .NET bin directory detection tests -@test "is_dotnet_bin_dir: finds .NET context in parent directory with Debug dir" { - mkdir -p "$HOME/www/dotnet-app/bin/Debug" - touch "$HOME/www/dotnet-app/MyProject.csproj" - - result=$(bash -c " - source '$PROJECT_ROOT/lib/clean/project.sh' - if is_dotnet_bin_dir '$HOME/www/dotnet-app/bin'; then - echo 'FOUND' - else - echo 'NOT_FOUND' - fi - ") - - [[ "$result" == "FOUND" ]] -} - -@test "is_dotnet_bin_dir: requires .csproj AND Debug/Release" { - mkdir -p "$HOME/www/dotnet-app/bin" - touch "$HOME/www/dotnet-app/MyProject.csproj" - - result=$(bash -c " - source '$PROJECT_ROOT/lib/clean/project.sh' - if is_dotnet_bin_dir '$HOME/www/dotnet-app/bin'; then - echo 'FOUND' - else - echo 'NOT_FOUND' - fi - ") - - # Should not find it because Debug/Release directories don't exist - [[ "$result" == "NOT_FOUND" ]] -} - -@test "is_dotnet_bin_dir: rejects non-bin directories" { - mkdir -p "$HOME/www/dotnet-app/obj" - touch "$HOME/www/dotnet-app/MyProject.csproj" - - result=$(bash -c " - source '$PROJECT_ROOT/lib/clean/project.sh' - if is_dotnet_bin_dir '$HOME/www/dotnet-app/obj'; then - echo 'FOUND' - else - echo 'NOT_FOUND' - fi - ") - [[ "$result" == "NOT_FOUND" ]] -} - - -# Integration test for bin scanning -@test "scan_purge_targets: includes .NET bin directories with Debug/Release" { - mkdir -p "$HOME/www/dotnet-app/bin/Debug" - touch "$HOME/www/dotnet-app/MyProject.csproj" - - local scan_output - scan_output="$(mktemp)" - - result=$(bash -c " - source '$PROJECT_ROOT/lib/clean/project.sh' - scan_purge_targets '$HOME/www' '$scan_output' - if grep -q '$HOME/www/dotnet-app/bin' '$scan_output'; then - echo 'FOUND' - else - echo 'MISSING' - fi - ") - - rm -f "$scan_output" - - [[ "$result" == "FOUND" ]] -} - -@test "scan_purge_targets: skips generic bin directories (non-.NET)" { - mkdir -p "$HOME/www/ruby-app/bin" - touch "$HOME/www/ruby-app/Gemfile" - - local scan_output - scan_output="$(mktemp)" - - result=$(bash -c " - source '$PROJECT_ROOT/lib/clean/project.sh' - scan_purge_targets '$HOME/www' '$scan_output' - if grep -q '$HOME/www/ruby-app/bin' '$scan_output'; then - echo 'FOUND' - else - echo 'SKIPPED' - fi - ") - - rm -f "$scan_output" - [[ "$result" == "SKIPPED" ]] -} diff --git a/tests/purge_config_paths.bats b/tests/purge_config_paths.bats deleted file mode 100644 index 9fe106b..0000000 --- a/tests/purge_config_paths.bats +++ /dev/null @@ -1,115 +0,0 @@ -#!/usr/bin/env bats - -setup_file() { - PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" - export PROJECT_ROOT - - ORIGINAL_HOME="${HOME:-}" - export ORIGINAL_HOME - - HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-purge-config.XXXXXX")" - export HOME - - mkdir -p "$HOME" -} - -teardown_file() { - rm -rf "$HOME" - if [[ -n "${ORIGINAL_HOME:-}" ]]; then - export HOME="$ORIGINAL_HOME" - fi -} - -setup() { - rm -rf "$HOME/.config" - mkdir -p "$HOME/.config/mole" -} - -@test "load_purge_config loads default paths when config file is missing" { - run env HOME="$HOME" bash -c "source '$PROJECT_ROOT/lib/clean/project.sh'; echo \"\${PURGE_SEARCH_PATHS[*]}\"" - - [ "$status" -eq 0 ] - - [[ "$output" == *"$HOME/Projects"* ]] - [[ "$output" == *"$HOME/GitHub"* ]] - [[ "$output" == *"$HOME/dev"* ]] -} - -@test "load_purge_config loads custom paths from config file" { - local config_file="$HOME/.config/mole/purge_paths" - - cat > "$config_file" << EOF -$HOME/custom/projects -$HOME/work -EOF - - run env HOME="$HOME" bash -c "source '$PROJECT_ROOT/lib/clean/project.sh'; echo \"\${PURGE_SEARCH_PATHS[*]}\"" - - [ "$status" -eq 0 ] - - [[ "$output" == *"$HOME/custom/projects"* ]] - [[ "$output" == *"$HOME/work"* ]] - [[ "$output" != *"$HOME/GitHub"* ]] -} - -@test "load_purge_config expands tilde in paths" { - local config_file="$HOME/.config/mole/purge_paths" - - cat > "$config_file" << EOF -~/tilde/expanded -~/another/one -EOF - - run env HOME="$HOME" bash -c "source '$PROJECT_ROOT/lib/clean/project.sh'; echo \"\${PURGE_SEARCH_PATHS[*]}\"" - - [ "$status" -eq 0 ] - - [[ "$output" == *"$HOME/tilde/expanded"* ]] - [[ "$output" == *"$HOME/another/one"* ]] - [[ "$output" != *"~"* ]] -} - -@test "load_purge_config ignores comments and empty lines" { - local config_file="$HOME/.config/mole/purge_paths" - - cat > "$config_file" << EOF -$HOME/valid/path - - -$HOME/another/path -EOF - - run env HOME="$HOME" bash -c "source '$PROJECT_ROOT/lib/clean/project.sh'; echo \"\${#PURGE_SEARCH_PATHS[@]}\"; echo \"\${PURGE_SEARCH_PATHS[*]}\"" - - [ "$status" -eq 0 ] - - local lines - read -r -a lines <<< "$output" - local count="${lines[0]}" - - [ "$count" -eq 2 ] - [[ "$output" == *"$HOME/valid/path"* ]] - [[ "$output" == *"$HOME/another/path"* ]] -} - -@test "load_purge_config falls back to defaults if config file is empty" { - local config_file="$HOME/.config/mole/purge_paths" - touch "$config_file" - - run env HOME="$HOME" bash -c "source '$PROJECT_ROOT/lib/clean/project.sh'; echo \"\${PURGE_SEARCH_PATHS[*]}\"" - - [ "$status" -eq 0 ] - - [[ "$output" == *"$HOME/Projects"* ]] -} - -@test "load_purge_config falls back to defaults if config file has only comments" { - local config_file="$HOME/.config/mole/purge_paths" - echo "# Just a comment" > "$config_file" - - run env HOME="$HOME" bash -c "source '$PROJECT_ROOT/lib/clean/project.sh'; echo \"\${PURGE_SEARCH_PATHS[*]}\"" - - [ "$status" -eq 0 ] - - [[ "$output" == *"$HOME/Projects"* ]] -} diff --git a/tests/regression.bats b/tests/regression.bats deleted file mode 100644 index b61b7e7..0000000 --- a/tests/regression.bats +++ /dev/null @@ -1,158 +0,0 @@ -#!/usr/bin/env bats - -setup() { - PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" - export PROJECT_ROOT - export HOME="$BATS_TEST_TMPDIR/home" - mkdir -p "$HOME/.config/mole" -} - - -@test "find with non-existent directory doesn't cause script exit (pipefail bug)" { - result=$(bash -c ' - set -euo pipefail - find /non/existent/dir -name "*.cache" 2>/dev/null || true - echo "survived" - ') - [[ "$result" == "survived" ]] -} - -@test "browser directory check pattern is safe when directories don't exist" { - result=$(bash -c ' - set -euo pipefail - search_dirs=() - [[ -d "/non/existent/chrome" ]] && search_dirs+=("/non/existent/chrome") - [[ -d "/tmp" ]] && search_dirs+=("/tmp") - - if [[ ${#search_dirs[@]} -gt 0 ]]; then - find "${search_dirs[@]}" -maxdepth 1 -type f 2>/dev/null || true - fi - echo "survived" - ') - [[ "$result" == "survived" ]] -} - -@test "empty array doesn't cause unbound variable error" { - result=$(bash -c ' - set -euo pipefail - search_dirs=() - - if [[ ${#search_dirs[@]} -gt 0 ]]; then - echo "should not reach here" - fi - echo "survived" - ') - [[ "$result" == "survived" ]] -} - - -@test "version comparison works correctly" { - result=$(bash -c ' - v1="1.11.8" - v2="1.11.9" - if [[ "$(printf "%s\n" "$v1" "$v2" | sort -V | head -1)" == "$v1" && "$v1" != "$v2" ]]; then - echo "update_needed" - fi - ') - [[ "$result" == "update_needed" ]] -} - -@test "version comparison with same versions" { - result=$(bash -c ' - v1="1.11.8" - v2="1.11.8" - if [[ "$(printf "%s\n" "$v1" "$v2" | sort -V | head -1)" == "$v1" && "$v1" != "$v2" ]]; then - echo "update_needed" - else - echo "up_to_date" - fi - ') - [[ "$result" == "up_to_date" ]] -} - -@test "version prefix v/V is stripped correctly" { - result=$(bash -c ' - version="v1.11.9" - clean=${version#v} - clean=${clean#V} - echo "$clean" - ') - [[ "$result" == "1.11.9" ]] -} - -@test "network timeout prevents hanging (simulated)" { - if ! command -v gtimeout >/dev/null 2>&1 && ! command -v timeout >/dev/null 2>&1; then - skip "gtimeout/timeout not available" - fi - - timeout_cmd="timeout" - command -v timeout >/dev/null 2>&1 || timeout_cmd="gtimeout" - - # shellcheck disable=SC2016 - result=$($timeout_cmd 5 bash -c ' - result=$(curl -fsSL --connect-timeout 1 --max-time 2 "http://192.0.2.1:12345/test" 2>/dev/null || echo "failed") - if [[ "$result" == "failed" ]]; then - echo "timeout_works" - fi - ') - [[ "$result" == "timeout_works" ]] -} - -@test "empty version string is handled gracefully" { - result=$(bash -c ' - latest="" - if [[ -z "$latest" ]]; then - echo "handled" - fi - ') - [[ "$result" == "handled" ]] -} - - -@test "grep with no match doesn't cause exit in pipefail mode" { - result=$(bash -c ' - set -euo pipefail - echo "test" | grep "nonexistent" || true - echo "survived" - ') - [[ "$result" == "survived" ]] -} - -@test "command substitution failure is handled with || true" { - result=$(bash -c ' - set -euo pipefail - output=$(false) || true - echo "survived" - ') - [[ "$result" == "survived" ]] -} - -@test "arithmetic on zero doesn't cause exit" { - result=$(bash -c ' - set -euo pipefail - count=0 - ((count++)) || true - echo "$count" - ') - [[ "$result" == "1" ]] -} - - -@test "safe_remove pattern doesn't fail on non-existent path" { - result=$(bash -c " - set -euo pipefail - source '$PROJECT_ROOT/lib/core/common.sh' - safe_remove '$HOME/non/existent/path' true > /dev/null 2>&1 || true - echo 'survived' - ") - [[ "$result" == "survived" ]] -} - -@test "module loading doesn't fail" { - result=$(bash -c " - set -euo pipefail - source '$PROJECT_ROOT/lib/core/common.sh' - echo 'loaded' - ") - [[ "$result" == "loaded" ]] -} diff --git a/tests/scripts.bats b/tests/scripts.bats deleted file mode 100644 index 6cab9ea..0000000 --- a/tests/scripts.bats +++ /dev/null @@ -1,80 +0,0 @@ -#!/usr/bin/env bats - -setup_file() { - PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" - export PROJECT_ROOT - - ORIGINAL_HOME="${HOME:-}" - export ORIGINAL_HOME - - HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-scripts-home.XXXXXX")" - export HOME - - mkdir -p "$HOME" -} - -teardown_file() { - rm -rf "$HOME" - if [[ -n "${ORIGINAL_HOME:-}" ]]; then - export HOME="$ORIGINAL_HOME" - fi -} - -setup() { - export TERM="dumb" - rm -rf "${HOME:?}"/* - mkdir -p "$HOME" -} - -@test "check.sh --help shows usage information" { - run "$PROJECT_ROOT/scripts/check.sh" --help - [ "$status" -eq 0 ] - [[ "$output" == *"Usage"* ]] - [[ "$output" == *"--format"* ]] - [[ "$output" == *"--no-format"* ]] -} - -@test "check.sh script exists and is valid" { - [ -f "$PROJECT_ROOT/scripts/check.sh" ] - [ -x "$PROJECT_ROOT/scripts/check.sh" ] - - run bash -c "grep -q 'Mole Check' '$PROJECT_ROOT/scripts/check.sh'" - [ "$status" -eq 0 ] -} - -@test "test.sh script exists and is valid" { - [ -f "$PROJECT_ROOT/scripts/test.sh" ] - [ -x "$PROJECT_ROOT/scripts/test.sh" ] - - run bash -c "grep -q 'Mole Test Runner' '$PROJECT_ROOT/scripts/test.sh'" - [ "$status" -eq 0 ] -} - -@test "test.sh includes test lint step" { - run bash -c "grep -q 'Test script lint' '$PROJECT_ROOT/scripts/test.sh'" - [ "$status" -eq 0 ] -} - -@test "Makefile has build target for Go binaries" { - run bash -c "grep -q 'go build' '$PROJECT_ROOT/Makefile'" - [ "$status" -eq 0 ] -} - -@test "setup-quick-launchers.sh has detect_mo function" { - run bash -c "grep -q 'detect_mo()' '$PROJECT_ROOT/scripts/setup-quick-launchers.sh'" - [ "$status" -eq 0 ] -} - -@test "setup-quick-launchers.sh has Raycast script generation" { - run bash -c "grep -q 'create_raycast_commands' '$PROJECT_ROOT/scripts/setup-quick-launchers.sh'" - [ "$status" -eq 0 ] - run bash -c "grep -q 'write_raycast_script' '$PROJECT_ROOT/scripts/setup-quick-launchers.sh'" - [ "$status" -eq 0 ] -} - -@test "install.sh supports dev branch installs" { - run bash -c "grep -q 'refs/heads/dev.tar.gz' '$PROJECT_ROOT/install.sh'" - [ "$status" -eq 0 ] - run bash -c "grep -q 'MOLE_VERSION=\"dev\"' '$PROJECT_ROOT/install.sh'" - [ "$status" -eq 0 ] -} diff --git a/tests/uninstall.bats b/tests/uninstall.bats deleted file mode 100644 index 080e081..0000000 --- a/tests/uninstall.bats +++ /dev/null @@ -1,267 +0,0 @@ -#!/usr/bin/env bats - -setup_file() { - PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" - export PROJECT_ROOT - - ORIGINAL_HOME="${BATS_TMPDIR:-}" # Use BATS_TMPDIR as original HOME if set by bats - if [[ -z "$ORIGINAL_HOME" ]]; then - ORIGINAL_HOME="${HOME:-}" - fi - export ORIGINAL_HOME - - HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-uninstall-home.XXXXXX")" - export HOME -} - -teardown_file() { - rm -rf "$HOME" - if [[ -n "${ORIGINAL_HOME:-}" ]]; then - export HOME="$ORIGINAL_HOME" - fi -} - -setup() { - export TERM="dumb" - rm -rf "${HOME:?}"/* - mkdir -p "$HOME" -} - -create_app_artifacts() { - mkdir -p "$HOME/Applications/TestApp.app" - mkdir -p "$HOME/Library/Application Support/TestApp" - mkdir -p "$HOME/Library/Caches/TestApp" - mkdir -p "$HOME/Library/Containers/com.example.TestApp" - mkdir -p "$HOME/Library/Preferences" - touch "$HOME/Library/Preferences/com.example.TestApp.plist" - mkdir -p "$HOME/Library/Preferences/ByHost" - touch "$HOME/Library/Preferences/ByHost/com.example.TestApp.ABC123.plist" - mkdir -p "$HOME/Library/Saved Application State/com.example.TestApp.savedState" -} - -@test "find_app_files discovers user-level leftovers" { - create_app_artifacts - - result="$( - HOME="$HOME" bash --noprofile --norc << 'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -find_app_files "com.example.TestApp" "TestApp" -EOF - )" - - [[ "$result" == *"Application Support/TestApp"* ]] - [[ "$result" == *"Caches/TestApp"* ]] - [[ "$result" == *"Preferences/com.example.TestApp.plist"* ]] - [[ "$result" == *"Saved Application State/com.example.TestApp.savedState"* ]] - [[ "$result" == *"Containers/com.example.TestApp"* ]] -} - -@test "calculate_total_size returns aggregate kilobytes" { - mkdir -p "$HOME/sized" - dd if=/dev/zero of="$HOME/sized/file1" bs=1024 count=1 > /dev/null 2>&1 - dd if=/dev/zero of="$HOME/sized/file2" bs=1024 count=2 > /dev/null 2>&1 - - result="$( - HOME="$HOME" bash --noprofile --norc << 'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -files="$(printf '%s -%s -' "$HOME/sized/file1" "$HOME/sized/file2")" -calculate_total_size "$files" -EOF - )" - - [ "$result" -ge 3 ] -} - -@test "batch_uninstall_applications removes selected app data" { - create_app_artifacts - - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/uninstall/batch.sh" - -request_sudo_access() { return 0; } -start_inline_spinner() { :; } -stop_inline_spinner() { :; } -enter_alt_screen() { :; } -leave_alt_screen() { :; } -hide_cursor() { :; } -show_cursor() { :; } -remove_apps_from_dock() { :; } -pgrep() { return 1; } -pkill() { return 0; } -sudo() { return 0; } # Mock sudo command - -app_bundle="$HOME/Applications/TestApp.app" -mkdir -p "$app_bundle" # Ensure this is created in the temp HOME - -related="$(find_app_files "com.example.TestApp" "TestApp")" -encoded_related=$(printf '%s' "$related" | base64 | tr -d '\n') - -selected_apps=() -selected_apps+=("0|$app_bundle|TestApp|com.example.TestApp|0|Never") -files_cleaned=0 -total_items=0 -total_size_cleaned=0 - -batch_uninstall_applications - -[[ ! -d "$app_bundle" ]] || exit 1 -[[ ! -d "$HOME/Library/Application Support/TestApp" ]] || exit 1 -[[ ! -d "$HOME/Library/Caches/TestApp" ]] || exit 1 -[[ ! -f "$HOME/Library/Preferences/com.example.TestApp.plist" ]] || exit 1 -EOF - - [ "$status" -eq 0 ] -} - -@test "safe_remove can remove a simple directory" { - mkdir -p "$HOME/test_dir" - touch "$HOME/test_dir/file.txt" - - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" - -safe_remove "$HOME/test_dir" -[[ ! -d "$HOME/test_dir" ]] || exit 1 -EOF - [ "$status" -eq 0 ] -} - - -@test "decode_file_list validates base64 encoding" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/uninstall/batch.sh" - -valid_data=$(printf '/path/one -/path/two' | base64) -result=$(decode_file_list "$valid_data" "TestApp") -[[ -n "$result" ]] || exit 1 -EOF - - [ "$status" -eq 0 ] -} - -@test "decode_file_list rejects invalid base64" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/uninstall/batch.sh" - -if result=$(decode_file_list "not-valid-base64!!!" "TestApp" 2>/dev/null); then - [[ -z "$result" ]] -else - true -fi -EOF - - [ "$status" -eq 0 ] -} - -@test "decode_file_list handles empty input" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/uninstall/batch.sh" - -empty_data=$(printf '' | base64) -result=$(decode_file_list "$empty_data" "TestApp" 2>/dev/null) || true -[[ -z "$result" ]] -EOF - - [ "$status" -eq 0 ] -} - -@test "decode_file_list rejects non-absolute paths" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/uninstall/batch.sh" - -bad_data=$(printf 'relative/path' | base64) -if result=$(decode_file_list "$bad_data" "TestApp" 2>/dev/null); then - [[ -z "$result" ]] -else - true -fi -EOF - - [ "$status" -eq 0 ] -} - -@test "decode_file_list handles both BSD and GNU base64 formats" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/uninstall/batch.sh" - -test_paths="/path/to/file1 -/path/to/file2" - -encoded_data=$(printf '%s' "$test_paths" | base64 | tr -d '\n') - -result=$(decode_file_list "$encoded_data" "TestApp") - -[[ "$result" == *"/path/to/file1"* ]] || exit 1 -[[ "$result" == *"/path/to/file2"* ]] || exit 1 - -[[ -n "$result" ]] || exit 1 -EOF - - [ "$status" -eq 0 ] -} - -@test "remove_mole deletes manual binaries and caches" { - mkdir -p "$HOME/.local/bin" - touch "$HOME/.local/bin/mole" - touch "$HOME/.local/bin/mo" - mkdir -p "$HOME/.config/mole" "$HOME/.cache/mole" - - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="/usr/bin:/bin" bash --noprofile --norc << 'EOF' -set -euo pipefail -start_inline_spinner() { :; } -stop_inline_spinner() { :; } -rm() { - local -a flags=() - local -a paths=() - local arg - for arg in "$@"; do - if [[ "$arg" == -* ]]; then - flags+=("$arg") - else - paths+=("$arg") - fi - done - local path - for path in "${paths[@]}"; do - if [[ "$path" == "$HOME" || "$path" == "$HOME/"* ]]; then - /bin/rm "${flags[@]}" "$path" - fi - done - return 0 -} -sudo() { - if [[ "$1" == "rm" ]]; then - shift - rm "$@" - return 0 - fi - return 0 -} -export -f start_inline_spinner stop_inline_spinner rm sudo -printf '\n' | "$PROJECT_ROOT/mole" remove -EOF - - [ "$status" -eq 0 ] - [ ! -f "$HOME/.local/bin/mole" ] - [ ! -f "$HOME/.local/bin/mo" ] - [ ! -d "$HOME/.config/mole" ] - [ ! -d "$HOME/.cache/mole" ] -} diff --git a/tests/update.bats b/tests/update.bats deleted file mode 100644 index a33d058..0000000 --- a/tests/update.bats +++ /dev/null @@ -1,235 +0,0 @@ -#!/usr/bin/env bats - -setup_file() { - PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" - export PROJECT_ROOT - - CURRENT_VERSION="$(grep '^VERSION=' "$PROJECT_ROOT/mole" | head -1 | sed 's/VERSION=\"\\(.*\\)\"/\\1/')" - export CURRENT_VERSION - - ORIGINAL_HOME="${HOME:-}" - export ORIGINAL_HOME - - HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-update-manager.XXXXXX")" - export HOME - - mkdir -p "${HOME}/.cache/mole" -} - -teardown_file() { - rm -rf "$HOME" - if [[ -n "${ORIGINAL_HOME:-}" ]]; then - export HOME="$ORIGINAL_HOME" - fi -} - -setup() { - BREW_OUTDATED_COUNT=0 - BREW_FORMULA_OUTDATED_COUNT=0 - BREW_CASK_OUTDATED_COUNT=0 - APPSTORE_UPDATE_COUNT=0 - MACOS_UPDATE_AVAILABLE=false - MOLE_UPDATE_AVAILABLE=false - - export MOCK_BIN_DIR="$BATS_TMPDIR/mole-mocks-$$" - mkdir -p "$MOCK_BIN_DIR" - export PATH="$MOCK_BIN_DIR:$PATH" -} - -teardown() { - rm -rf "$MOCK_BIN_DIR" -} - -read_key() { - echo "ESC" - return 0 -} - -@test "ask_for_updates returns 1 when no updates available" { - run bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/manage/update.sh" -BREW_OUTDATED_COUNT=0 -APPSTORE_UPDATE_COUNT=0 -MACOS_UPDATE_AVAILABLE=false -MOLE_UPDATE_AVAILABLE=false -ask_for_updates -EOF - - [ "$status" -eq 1 ] -} - -@test "ask_for_updates shows updates and waits for input" { - run bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/manage/update.sh" -BREW_OUTDATED_COUNT=5 -BREW_FORMULA_OUTDATED_COUNT=3 -BREW_CASK_OUTDATED_COUNT=2 -APPSTORE_UPDATE_COUNT=1 -MACOS_UPDATE_AVAILABLE=true -MOLE_UPDATE_AVAILABLE=true - -read_key() { echo "ESC"; return 0; } - -ask_for_updates -EOF - - [ "$status" -eq 1 ] # ESC cancels - [[ "$output" == *"Homebrew (5 updates)"* ]] - [[ "$output" == *"App Store (1 apps)"* ]] - [[ "$output" == *"macOS system"* ]] - [[ "$output" == *"Mole"* ]] -} - -@test "ask_for_updates accepts Enter when updates exist" { - run bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/manage/update.sh" -BREW_OUTDATED_COUNT=2 -BREW_FORMULA_OUTDATED_COUNT=2 -MOLE_UPDATE_AVAILABLE=true -read_key() { echo "ENTER"; return 0; } -ask_for_updates -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"AVAILABLE UPDATES"* ]] - [[ "$output" == *"yes"* ]] -} - -@test "format_brew_update_label lists formula and cask counts" { - run bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/manage/update.sh" -BREW_OUTDATED_COUNT=5 -BREW_FORMULA_OUTDATED_COUNT=3 -BREW_CASK_OUTDATED_COUNT=2 -format_brew_update_label -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"3 formula"* ]] - [[ "$output" == *"2 cask"* ]] -} - -@test "perform_updates handles Homebrew success and Mole update" { - run bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/manage/update.sh" - -BREW_FORMULA_OUTDATED_COUNT=1 -BREW_CASK_OUTDATED_COUNT=0 -MOLE_UPDATE_AVAILABLE=true - -FAKE_DIR="$HOME/fake-script-dir" -mkdir -p "$FAKE_DIR/lib/manage" -cat > "$FAKE_DIR/mole" <<'SCRIPT' -#!/usr/bin/env bash -echo "Already on latest version" -SCRIPT -chmod +x "$FAKE_DIR/mole" -SCRIPT_DIR="$FAKE_DIR/lib/manage" - -brew_has_outdated() { return 0; } -start_inline_spinner() { :; } -stop_inline_spinner() { :; } -reset_brew_cache() { echo "BREW_CACHE_RESET"; } -reset_mole_cache() { echo "MOLE_CACHE_RESET"; } -has_sudo_session() { return 1; } -ensure_sudo_session() { echo "ensure_sudo_session_called"; return 1; } - -brew() { - if [[ "$1" == "upgrade" ]]; then - echo "Upgrading formula" - return 0 - fi - return 0 -} - -get_appstore_update_labels() { return 0; } -get_macos_update_labels() { return 0; } - -perform_updates -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Updating Mole"* ]] - [[ "$output" == *"Mole updated"* ]] - [[ "$output" == *"MOLE_CACHE_RESET"* ]] - [[ "$output" == *"All updates completed"* ]] -} - -@test "update_via_homebrew reports already on latest version" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' -set -euo pipefail -MOLE_TEST_BREW_UPDATE_OUTPUT="Updated 0 formulae" -MOLE_TEST_BREW_UPGRADE_OUTPUT="Warning: mole 1.7.9 already installed" -MOLE_TEST_BREW_LIST_OUTPUT="mole 1.7.9" -start_inline_spinner() { :; } -stop_inline_spinner() { :; } -brew() { - case "$1" in - update) echo "$MOLE_TEST_BREW_UPDATE_OUTPUT";; - upgrade) echo "$MOLE_TEST_BREW_UPGRADE_OUTPUT";; - list) if [[ "$2" == "--versions" ]]; then echo "$MOLE_TEST_BREW_LIST_OUTPUT"; fi ;; - esac -} -export -f brew start_inline_spinner stop_inline_spinner -source "$PROJECT_ROOT/lib/core/common.sh" -update_via_homebrew "1.7.9" -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Already on latest version"* ]] -} - -@test "update_mole skips download when already latest" { - 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 - echo "Installer executed" > "$out" - 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 -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Already on latest version"* ]] -} diff --git a/tests/user_file_ops.bats b/tests/user_file_ops.bats deleted file mode 100644 index 086a125..0000000 --- a/tests/user_file_ops.bats +++ /dev/null @@ -1,214 +0,0 @@ -#!/usr/bin/env bats - - -setup_file() { - PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" - export PROJECT_ROOT - - ORIGINAL_HOME="${HOME:-}" - export ORIGINAL_HOME - - HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-userfile.XXXXXX")" - export HOME - - mkdir -p "$HOME" -} - -teardown_file() { - rm -rf "$HOME" - if [[ -n "${ORIGINAL_HOME:-}" ]]; then - export HOME="$ORIGINAL_HOME" - fi -} - -setup() { - rm -rf "$HOME/.config" "$HOME/.cache" - mkdir -p "$HOME" -} - -@test "get_darwin_major returns numeric version on macOS" { - result=$(bash -c "source '$PROJECT_ROOT/lib/core/base.sh'; get_darwin_major") - [[ "$result" =~ ^[0-9]+$ ]] -} - -@test "get_darwin_major returns 999 on failure (mock uname failure)" { - result=$(bash -c " - uname() { return 1; } - export -f uname - source '$PROJECT_ROOT/lib/core/base.sh' - get_darwin_major - ") - [ "$result" = "999" ] -} - -@test "is_darwin_ge correctly compares versions" { - run bash -c "source '$PROJECT_ROOT/lib/core/base.sh'; is_darwin_ge 1" - [ "$status" -eq 0 ] - - result=$(bash -c "source '$PROJECT_ROOT/lib/core/base.sh'; is_darwin_ge 100 && echo 'yes' || echo 'no'") - [[ -n "$result" ]] -} - -@test "is_root_user detects non-root correctly" { - result=$(bash -c "source '$PROJECT_ROOT/lib/core/base.sh'; is_root_user && echo 'root' || echo 'not-root'") - [ "$result" = "not-root" ] -} - -@test "get_invoking_user returns current user when not sudo" { - result=$(bash -c "source '$PROJECT_ROOT/lib/core/base.sh'; get_invoking_user") - [ -n "$result" ] - [ "$result" = "${USER:-$(whoami)}" ] -} - -@test "get_invoking_uid returns numeric UID" { - result=$(bash -c "source '$PROJECT_ROOT/lib/core/base.sh'; get_invoking_uid") - [[ "$result" =~ ^[0-9]+$ ]] -} - -@test "get_invoking_gid returns numeric GID" { - result=$(bash -c "source '$PROJECT_ROOT/lib/core/base.sh'; get_invoking_gid") - [[ "$result" =~ ^[0-9]+$ ]] -} - -@test "get_invoking_home returns home directory" { - result=$(bash -c "source '$PROJECT_ROOT/lib/core/base.sh'; get_invoking_home") - [ -n "$result" ] - [ -d "$result" ] -} - -@test "get_user_home returns home for valid user" { - current_user="${USER:-$(whoami)}" - result=$(bash -c "source '$PROJECT_ROOT/lib/core/base.sh'; get_user_home '$current_user'") - [ -n "$result" ] - [ -d "$result" ] -} - -@test "get_user_home returns empty for invalid user" { - result=$(bash -c "source '$PROJECT_ROOT/lib/core/base.sh'; get_user_home 'nonexistent_user_12345'") - [ -z "$result" ] || [ "$result" = "~nonexistent_user_12345" ] -} - -@test "ensure_user_dir creates simple directory" { - test_dir="$HOME/.cache/test" - bash -c "source '$PROJECT_ROOT/lib/core/base.sh'; ensure_user_dir '$test_dir'" - [ -d "$test_dir" ] -} - -@test "ensure_user_dir creates nested directory" { - test_dir="$HOME/.config/mole/deep/nested/path" - bash -c "source '$PROJECT_ROOT/lib/core/base.sh'; ensure_user_dir '$test_dir'" - [ -d "$test_dir" ] -} - -@test "ensure_user_dir handles tilde expansion" { - bash -c "source '$PROJECT_ROOT/lib/core/base.sh'; ensure_user_dir '~/.cache/tilde-test'" - [ -d "$HOME/.cache/tilde-test" ] -} - -@test "ensure_user_dir is idempotent" { - test_dir="$HOME/.cache/idempotent" - bash -c "source '$PROJECT_ROOT/lib/core/base.sh'; ensure_user_dir '$test_dir'" - bash -c "source '$PROJECT_ROOT/lib/core/base.sh'; ensure_user_dir '$test_dir'" - [ -d "$test_dir" ] -} - -@test "ensure_user_dir handles empty path gracefully" { - run bash -c "source '$PROJECT_ROOT/lib/core/base.sh'; ensure_user_dir ''" - [ "$status" -eq 0 ] -} - -@test "ensure_user_dir preserves ownership for non-root users" { - test_dir="$HOME/.cache/ownership-test" - bash -c "source '$PROJECT_ROOT/lib/core/base.sh'; ensure_user_dir '$test_dir'" - - current_uid=$(id -u) - dir_uid=$(/usr/bin/stat -f%u "$test_dir") - [ "$dir_uid" = "$current_uid" ] -} - - -@test "ensure_user_file creates file and parent directories" { - test_file="$HOME/.config/mole/test.log" - bash -c "source '$PROJECT_ROOT/lib/core/base.sh'; ensure_user_file '$test_file'" - [ -f "$test_file" ] - [ -d "$(dirname "$test_file")" ] -} - -@test "ensure_user_file handles tilde expansion" { - bash -c "source '$PROJECT_ROOT/lib/core/base.sh'; ensure_user_file '~/.cache/tilde-file.txt'" - [ -f "$HOME/.cache/tilde-file.txt" ] -} - -@test "ensure_user_file is idempotent" { - test_file="$HOME/.cache/idempotent.txt" - bash -c "source '$PROJECT_ROOT/lib/core/base.sh'; ensure_user_file '$test_file'" - echo "content" > "$test_file" - bash -c "source '$PROJECT_ROOT/lib/core/base.sh'; ensure_user_file '$test_file'" - [ -f "$test_file" ] - [ "$(cat "$test_file")" = "content" ] -} - -@test "ensure_user_file handles empty path gracefully" { - run bash -c "source '$PROJECT_ROOT/lib/core/base.sh'; ensure_user_file ''" - [ "$status" -eq 0 ] -} - -@test "ensure_user_file creates deeply nested files" { - test_file="$HOME/.config/deep/very/nested/structure/file.log" - bash -c "source '$PROJECT_ROOT/lib/core/base.sh'; ensure_user_file '$test_file'" - [ -f "$test_file" ] -} - -@test "ensure_user_file preserves ownership for non-root users" { - test_file="$HOME/.cache/file-ownership-test.txt" - bash -c "source '$PROJECT_ROOT/lib/core/base.sh'; ensure_user_file '$test_file'" - - current_uid=$(id -u) - file_uid=$(/usr/bin/stat -f%u "$test_file") - [ "$file_uid" = "$current_uid" ] -} - -@test "ensure_user_dir early stop optimization works" { - test_dir="$HOME/.cache/perf/test/nested" - bash -c "source '$PROJECT_ROOT/lib/core/base.sh'; ensure_user_dir '$test_dir'" - - bash -c "source '$PROJECT_ROOT/lib/core/base.sh'; ensure_user_dir '$test_dir'" - [ -d "$test_dir" ] - - current_uid=$(id -u) - dir_uid=$(/usr/bin/stat -f%u "$test_dir") - [ "$dir_uid" = "$current_uid" ] -} - -@test "ensure_user_dir and ensure_user_file work together" { - cache_dir="$HOME/.cache/mole" - cache_file="$cache_dir/integration_test.log" - - bash -c "source '$PROJECT_ROOT/lib/core/base.sh'; ensure_user_dir '$cache_dir'" - bash -c "source '$PROJECT_ROOT/lib/core/base.sh'; ensure_user_file '$cache_file'" - - [ -d "$cache_dir" ] - [ -f "$cache_file" ] -} - -@test "multiple ensure_user_file calls in same directory" { - bash -c "source '$PROJECT_ROOT/lib/core/base.sh' - ensure_user_file '$HOME/.config/mole/file1.txt' - ensure_user_file '$HOME/.config/mole/file2.txt' - ensure_user_file '$HOME/.config/mole/file3.txt' - " - - [ -f "$HOME/.config/mole/file1.txt" ] - [ -f "$HOME/.config/mole/file2.txt" ] - [ -f "$HOME/.config/mole/file3.txt" ] -} - -@test "ensure functions handle concurrent calls safely" { - bash -c "source '$PROJECT_ROOT/lib/core/base.sh' - ensure_user_dir '$HOME/.cache/concurrent' & - ensure_user_dir '$HOME/.cache/concurrent' & - wait - " - - [ -d "$HOME/.cache/concurrent" ] -} diff --git a/windows/.gitignore b/windows/.gitignore deleted file mode 100644 index 0a24ad3..0000000 --- a/windows/.gitignore +++ /dev/null @@ -1,16 +0,0 @@ -# Windows Mole - .gitignore - -# Build artifacts -bin/*.exe - -# Go build cache -.gocache/ - -# IDE files -.idea/ -.vscode/ -*.code-workspace - -# Test artifacts -*.test -coverage.out diff --git a/windows/Makefile b/windows/Makefile deleted file mode 100644 index b2904ba..0000000 --- a/windows/Makefile +++ /dev/null @@ -1,44 +0,0 @@ -# Mole Windows - Makefile -# Build Go tools for Windows - -.PHONY: all build clean analyze status - -# Default target -all: build - -# Build both tools -build: analyze status - -# Build analyze tool -analyze: - @echo "Building analyze..." - @go build -o bin/analyze.exe ./cmd/analyze/ - -# Build status tool -status: - @echo "Building status..." - @go build -o bin/status.exe ./cmd/status/ - -# Clean build artifacts -clean: - @echo "Cleaning..." - @rm -f bin/analyze.exe bin/status.exe - -# Install (copy to PATH) -install: build - @echo "Installing to $(USERPROFILE)/bin..." - @mkdir -p "$(USERPROFILE)/bin" - @cp bin/analyze.exe "$(USERPROFILE)/bin/" - @cp bin/status.exe "$(USERPROFILE)/bin/" - -# Run tests -test: - @go test -v ./... - -# Format code -fmt: - @go fmt ./... - -# Vet code -vet: - @go vet ./... diff --git a/windows/README.md b/windows/README.md deleted file mode 100644 index 044fdcb..0000000 --- a/windows/README.md +++ /dev/null @@ -1,169 +0,0 @@ -# Mole for Windows - -Windows support for [Mole](https://github.com/tw93/Mole) - A system maintenance toolkit. - -## Requirements - -- Windows 10/11 -- PowerShell 5.1 or later (pre-installed on Windows 10/11) -- Go 1.24+ (for building TUI tools) - -## Installation - -### Quick Install - -```powershell -# Clone the repository -git clone https://github.com/tw93/Mole.git -cd Mole/windows - -# Run the installer -.\install.ps1 -AddToPath -``` - -### Manual Installation - -```powershell -# Install to custom location -.\install.ps1 -InstallDir C:\Tools\Mole -AddToPath - -# Create Start Menu shortcut -.\install.ps1 -AddToPath -CreateShortcut -``` - -### Uninstall - -```powershell -.\install.ps1 -Uninstall -``` - -## Usage - -```powershell -# Interactive menu -mole - -# Show help -mole -ShowHelp - -# Show version -mole -Version - -# Commands -mole clean # Deep system cleanup -mole clean -DryRun # Preview cleanup without deleting -mole uninstall # Interactive app uninstaller -mole optimize # System optimization -mole purge # Clean developer artifacts -mole analyze # Disk space analyzer -mole status # System health monitor -``` - -## Commands - -| Command | Description | -|---------|-------------| -| `clean` | Deep cleanup of temp files, caches, and logs | -| `uninstall` | Interactive application uninstaller | -| `optimize` | System optimization and health checks | -| `purge` | Clean project build artifacts (node_modules, etc.) | -| `analyze` | Interactive disk space analyzer (TUI) | -| `status` | Real-time system health monitor (TUI) | - -## Environment Variables - -| Variable | Description | -|----------|-------------| -| `MOLE_DRY_RUN=1` | Preview changes without making them | -| `MOLE_DEBUG=1` | Enable debug output | -| `MO_ANALYZE_PATH` | Starting path for analyze tool | - -## Directory Structure - -``` -windows/ -├── mole.ps1 # Main CLI entry point -├── install.ps1 # Windows installer -├── Makefile # Build automation for Go tools -├── go.mod # Go module definition -├── go.sum # Go dependencies -├── bin/ -│ ├── clean.ps1 # Deep cleanup orchestrator -│ ├── uninstall.ps1 # Interactive app uninstaller -│ ├── optimize.ps1 # System optimization -│ ├── purge.ps1 # Project artifact cleanup -│ ├── analyze.ps1 # Disk analyzer wrapper -│ └── status.ps1 # Status monitor wrapper -├── cmd/ -│ ├── analyze/ # Disk analyzer (Go TUI) -│ │ └── main.go -│ └── status/ # System status (Go TUI) -│ └── main.go -└── lib/ - ├── core/ - │ ├── base.ps1 # Core definitions and utilities - │ ├── common.ps1 # Common functions loader - │ ├── file_ops.ps1 # Safe file operations - │ ├── log.ps1 # Logging functions - │ └── ui.ps1 # Interactive UI components - └── clean/ - ├── user.ps1 # User cleanup (temp, downloads, etc.) - ├── caches.ps1 # Browser and app caches - ├── dev.ps1 # Developer tool caches - ├── apps.ps1 # Application leftovers - └── system.ps1 # System cleanup (requires admin) -``` - -## Building TUI Tools - -The analyze and status commands require Go to be installed: - -```powershell -cd windows - -# Build both tools -make build - -# Or build individually -go build -o bin/analyze.exe ./cmd/analyze/ -go build -o bin/status.exe ./cmd/status/ - -# The wrapper scripts will auto-build if Go is available -``` - -## Configuration - -Mole stores its configuration in: -- Config: `~\.config\mole\` -- Cache: `~\.cache\mole\` -- Whitelist: `~\.config\mole\whitelist.txt` -- Purge paths: `~\.config\mole\purge_paths.txt` - -## Development Phases - -### Phase 1: Core Infrastructure ✅ -- [x] `install.ps1` - Windows installer -- [x] `mole.ps1` - Main CLI entry point -- [x] `lib/core/*` - Core utility libraries - -### Phase 2: Cleanup Features ✅ -- [x] `bin/clean.ps1` - Deep cleanup orchestrator -- [x] `bin/uninstall.ps1` - App removal with leftover detection -- [x] `bin/optimize.ps1` - System optimization -- [x] `bin/purge.ps1` - Project artifact cleanup -- [x] `lib/clean/*` - Cleanup modules - -### Phase 3: TUI Tools ✅ -- [x] `cmd/analyze/` - Disk usage analyzer (Go) -- [x] `cmd/status/` - Real-time system monitor (Go) -- [x] `bin/analyze.ps1` - Analyzer wrapper -- [x] `bin/status.ps1` - Status wrapper - -### Phase 4: Testing & CI (Planned) -- [ ] `tests/` - Pester tests -- [ ] GitHub Actions workflows -- [ ] `scripts/build.ps1` - Build automation - -## License - -Same license as the main Mole project. diff --git a/windows/cmd/analyze/main.go b/windows/cmd/analyze/main.go deleted file mode 100644 index 60cc9bc..0000000 --- a/windows/cmd/analyze/main.go +++ /dev/null @@ -1,780 +0,0 @@ -//go:build windows - -package main - -import ( - "context" - "flag" - "fmt" - "os" - "os/exec" - "path/filepath" - "runtime" - "sort" - "strings" - "sync" - "sync/atomic" - "time" - - tea "github.com/charmbracelet/bubbletea" -) - -// Scanning limits to prevent infinite scanning -const ( - dirSizeTimeout = 500 * time.Millisecond // Max time to calculate a single directory size - maxFilesPerDir = 10000 // Max files to scan per directory - maxScanDepth = 10 // Max recursion depth (shallow scan) - shallowScanDepth = 3 // Depth for quick size estimation -) - -// ANSI color codes -const ( - colorReset = "\033[0m" - colorBold = "\033[1m" - colorDim = "\033[2m" - colorPurple = "\033[35m" - colorPurpleBold = "\033[1;35m" - colorCyan = "\033[36m" - colorCyanBold = "\033[1;36m" - colorYellow = "\033[33m" - colorGreen = "\033[32m" - colorRed = "\033[31m" - colorGray = "\033[90m" - colorWhite = "\033[97m" -) - -// Icons -const ( - iconFolder = "📁" - iconFile = "📄" - iconDisk = "💾" - iconClean = "🧹" - iconTrash = "🗑️" - iconBack = "⬅️" - iconSelected = "✓" - iconArrow = "➤" -) - -// Cleanable directory patterns -var cleanablePatterns = map[string]bool{ - "node_modules": true, - "vendor": true, - ".venv": true, - "venv": true, - "__pycache__": true, - ".pytest_cache": true, - "target": true, - "build": true, - "dist": true, - ".next": true, - ".nuxt": true, - ".turbo": true, - ".parcel-cache": true, - "bin": true, - "obj": true, - ".gradle": true, - ".idea": true, - ".vs": true, -} - -// Skip patterns for scanning -var skipPatterns = map[string]bool{ - "$Recycle.Bin": true, - "System Volume Information": true, - "Windows": true, - "Program Files": true, - "Program Files (x86)": true, - "ProgramData": true, - "Recovery": true, - "Config.Msi": true, -} - -// Protected paths that should NEVER be deleted -var protectedPaths = []string{ - `C:\Windows`, - `C:\Program Files`, - `C:\Program Files (x86)`, - `C:\ProgramData`, - `C:\Users\Default`, - `C:\Users\Public`, - `C:\Recovery`, - `C:\System Volume Information`, -} - -// isProtectedPath checks if a path is protected from deletion -func isProtectedPath(path string) bool { - absPath, err := filepath.Abs(path) - if err != nil { - return true // If we can't resolve the path, treat it as protected - } - absPath = strings.ToLower(absPath) - - // Check against protected paths - for _, protected := range protectedPaths { - protectedLower := strings.ToLower(protected) - if absPath == protectedLower || strings.HasPrefix(absPath, protectedLower+`\`) { - return true - } - } - - // Check against skip patterns (system directories) - baseName := strings.ToLower(filepath.Base(absPath)) - for pattern := range skipPatterns { - if strings.ToLower(pattern) == baseName { - // Only protect if it's at a root level (e.g., C:\Windows, not C:\Projects\Windows) - parent := filepath.Dir(absPath) - if len(parent) <= 3 { // e.g., "C:\" - return true - } - } - } - - // Protect Windows directory itself - winDir := strings.ToLower(os.Getenv("WINDIR")) - sysRoot := strings.ToLower(os.Getenv("SYSTEMROOT")) - if winDir != "" && (absPath == winDir || strings.HasPrefix(absPath, winDir+`\`)) { - return true - } - if sysRoot != "" && (absPath == sysRoot || strings.HasPrefix(absPath, sysRoot+`\`)) { - return true - } - - return false -} - -// Entry types -type dirEntry struct { - Name string - Path string - Size int64 - IsDir bool - LastAccess time.Time - IsCleanable bool -} - -type fileEntry struct { - Name string - Path string - Size int64 -} - -type historyEntry struct { - Path string - Entries []dirEntry - LargeFiles []fileEntry - TotalSize int64 - Selected int -} - -// Model for Bubble Tea -type model struct { - path string - entries []dirEntry - largeFiles []fileEntry - history []historyEntry - selected int - totalSize int64 - scanning bool - showLargeFiles bool - multiSelected map[string]bool - deleteConfirm bool - deleteTarget string - scanProgress int64 - scanTotal int64 - width int - height int - err error - cache map[string]historyEntry -} - -// Messages -type scanCompleteMsg struct { - entries []dirEntry - largeFiles []fileEntry - totalSize int64 -} - -type scanProgressMsg struct { - current int64 - total int64 -} - -type scanErrorMsg struct { - err error -} - -type deleteCompleteMsg struct { - path string - err error -} - -func newModel(startPath string) model { - return model{ - path: startPath, - entries: []dirEntry{}, - largeFiles: []fileEntry{}, - history: []historyEntry{}, - selected: 0, - scanning: true, - multiSelected: make(map[string]bool), - cache: make(map[string]historyEntry), - } -} - -func (m model) Init() tea.Cmd { - return m.scanPath(m.path) -} - -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - return m.handleKeyPress(msg) - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - return m, nil - case scanCompleteMsg: - m.entries = msg.entries - m.largeFiles = msg.largeFiles - m.totalSize = msg.totalSize - m.scanning = false - m.selected = 0 - // Cache result - m.cache[m.path] = historyEntry{ - Path: m.path, - Entries: msg.entries, - LargeFiles: msg.largeFiles, - TotalSize: msg.totalSize, - } - return m, nil - case scanProgressMsg: - m.scanProgress = msg.current - m.scanTotal = msg.total - return m, nil - case scanErrorMsg: - m.err = msg.err - m.scanning = false - return m, nil - case deleteCompleteMsg: - m.deleteConfirm = false - m.deleteTarget = "" - if msg.err != nil { - m.err = msg.err - } else { - // Rescan after delete - m.scanning = true - delete(m.cache, m.path) - return m, m.scanPath(m.path) - } - return m, nil - } - return m, nil -} - -func (m model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - // Handle delete confirmation - if m.deleteConfirm { - switch msg.String() { - case "y", "Y": - target := m.deleteTarget - m.deleteConfirm = false - return m, m.deletePath(target) - case "n", "N", "esc": - m.deleteConfirm = false - m.deleteTarget = "" - return m, nil - } - return m, nil - } - - switch msg.String() { - case "q", "ctrl+c": - return m, tea.Quit - case "up", "k": - if m.selected > 0 { - m.selected-- - } - case "down", "j": - if m.selected < len(m.entries)-1 { - m.selected++ - } - case "enter", "right", "l": - if !m.scanning && len(m.entries) > 0 { - entry := m.entries[m.selected] - if entry.IsDir { - // Save current state to history - m.history = append(m.history, historyEntry{ - Path: m.path, - Entries: m.entries, - LargeFiles: m.largeFiles, - TotalSize: m.totalSize, - Selected: m.selected, - }) - m.path = entry.Path - m.selected = 0 - m.multiSelected = make(map[string]bool) - - // Check cache - if cached, ok := m.cache[entry.Path]; ok { - m.entries = cached.Entries - m.largeFiles = cached.LargeFiles - m.totalSize = cached.TotalSize - return m, nil - } - - m.scanning = true - return m, m.scanPath(entry.Path) - } - } - case "left", "h", "backspace": - if len(m.history) > 0 { - last := m.history[len(m.history)-1] - m.history = m.history[:len(m.history)-1] - m.path = last.Path - m.entries = last.Entries - m.largeFiles = last.LargeFiles - m.totalSize = last.TotalSize - m.selected = last.Selected - m.multiSelected = make(map[string]bool) - m.scanning = false - } - case "space": - if len(m.entries) > 0 { - entry := m.entries[m.selected] - if m.multiSelected[entry.Path] { - delete(m.multiSelected, entry.Path) - } else { - m.multiSelected[entry.Path] = true - } - } - case "d", "delete": - if len(m.entries) > 0 { - entry := m.entries[m.selected] - m.deleteConfirm = true - m.deleteTarget = entry.Path - } - case "D": - // Delete all selected - if len(m.multiSelected) > 0 { - m.deleteConfirm = true - m.deleteTarget = fmt.Sprintf("%d items", len(m.multiSelected)) - } - case "f": - m.showLargeFiles = !m.showLargeFiles - case "r": - // Refresh - delete(m.cache, m.path) - m.scanning = true - return m, m.scanPath(m.path) - case "o": - // Open in Explorer - if len(m.entries) > 0 { - entry := m.entries[m.selected] - openInExplorer(entry.Path) - } - case "g": - m.selected = 0 - case "G": - m.selected = len(m.entries) - 1 - } - return m, nil -} - -func (m model) View() string { - var b strings.Builder - - // Header - b.WriteString(fmt.Sprintf("%s%s Mole Disk Analyzer %s\n", colorPurpleBold, iconDisk, colorReset)) - b.WriteString(fmt.Sprintf("%s%s%s\n", colorGray, m.path, colorReset)) - b.WriteString("\n") - - // Show delete confirmation - if m.deleteConfirm { - b.WriteString(fmt.Sprintf("%s%s Delete %s? (y/n)%s\n", colorRed, iconTrash, m.deleteTarget, colorReset)) - return b.String() - } - - // Scanning indicator - if m.scanning { - b.WriteString(fmt.Sprintf("%s⠋ Scanning...%s\n", colorCyan, colorReset)) - if m.scanTotal > 0 { - b.WriteString(fmt.Sprintf("%s %d / %d items%s\n", colorGray, m.scanProgress, m.scanTotal, colorReset)) - } - return b.String() - } - - // Error display - if m.err != nil { - b.WriteString(fmt.Sprintf("%sError: %v%s\n", colorRed, m.err, colorReset)) - b.WriteString("\n") - } - - // Total size - b.WriteString(fmt.Sprintf(" Total: %s%s%s\n", colorYellow, formatBytes(m.totalSize), colorReset)) - b.WriteString("\n") - - // Large files toggle - if m.showLargeFiles && len(m.largeFiles) > 0 { - b.WriteString(fmt.Sprintf("%s%s Large Files (>100MB):%s\n", colorCyanBold, iconFile, colorReset)) - for i, f := range m.largeFiles { - if i >= 10 { - b.WriteString(fmt.Sprintf(" %s... and %d more%s\n", colorGray, len(m.largeFiles)-10, colorReset)) - break - } - b.WriteString(fmt.Sprintf(" %s%s%s %s\n", colorYellow, formatBytes(f.Size), colorReset, truncatePath(f.Path, 60))) - } - b.WriteString("\n") - } - - // Directory entries - visibleEntries := m.height - 12 - if visibleEntries < 5 { - visibleEntries = 20 - } - - start := 0 - if m.selected >= visibleEntries { - start = m.selected - visibleEntries + 1 - } - - for i := start; i < len(m.entries) && i < start+visibleEntries; i++ { - entry := m.entries[i] - prefix := " " - - // Selection indicator - if i == m.selected { - prefix = fmt.Sprintf("%s%s%s ", colorCyan, iconArrow, colorReset) - } else if m.multiSelected[entry.Path] { - prefix = fmt.Sprintf("%s%s%s ", colorGreen, iconSelected, colorReset) - } - - // Icon - icon := iconFile - if entry.IsDir { - icon = iconFolder - } - if entry.IsCleanable { - icon = iconClean - } - - // Size and percentage - pct := float64(0) - if m.totalSize > 0 { - pct = float64(entry.Size) / float64(m.totalSize) * 100 - } - - // Bar - barWidth := 20 - filled := int(pct / 100 * float64(barWidth)) - bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled) - - // Color based on selection - nameColor := colorReset - if i == m.selected { - nameColor = colorCyanBold - } - - b.WriteString(fmt.Sprintf("%s%s %s%8s%s %s%s%s %s%.1f%%%s %s\n", - prefix, - icon, - colorYellow, formatBytes(entry.Size), colorReset, - colorGray, bar, colorReset, - colorDim, pct, colorReset, - nameColor+entry.Name+colorReset, - )) - } - - // Footer with keybindings - b.WriteString("\n") - b.WriteString(fmt.Sprintf("%s↑↓%s navigate %s↵%s enter %s←%s back %sf%s files %sd%s delete %sr%s refresh %sq%s quit%s\n", - colorCyan, colorReset, - colorCyan, colorReset, - colorCyan, colorReset, - colorCyan, colorReset, - colorCyan, colorReset, - colorCyan, colorReset, - colorCyan, colorReset, - colorReset, - )) - - return b.String() -} - -// scanPath scans a directory and returns entries -func (m model) scanPath(path string) tea.Cmd { - return func() tea.Msg { - entries, largeFiles, totalSize, err := scanDirectory(path) - if err != nil { - return scanErrorMsg{err: err} - } - return scanCompleteMsg{ - entries: entries, - largeFiles: largeFiles, - totalSize: totalSize, - } - } -} - -// deletePath deletes a file or directory with protection checks -func (m model) deletePath(path string) tea.Cmd { - return func() tea.Msg { - // Safety check: never delete protected paths - if isProtectedPath(path) { - return deleteCompleteMsg{ - path: path, - err: fmt.Errorf("cannot delete protected system path: %s", path), - } - } - - err := os.RemoveAll(path) - return deleteCompleteMsg{path: path, err: err} - } -} - -// scanDirectory scans a directory concurrently -func scanDirectory(path string) ([]dirEntry, []fileEntry, int64, error) { - entries, err := os.ReadDir(path) - if err != nil { - return nil, nil, 0, err - } - - var ( - dirEntries []dirEntry - largeFiles []fileEntry - totalSize int64 - mu sync.Mutex - wg sync.WaitGroup - ) - - numWorkers := runtime.NumCPU() * 2 - if numWorkers > 32 { - numWorkers = 32 - } - - sem := make(chan struct{}, numWorkers) - var processedCount int64 - - for _, entry := range entries { - name := entry.Name() - entryPath := filepath.Join(path, name) - - // Skip system directories - if skipPatterns[name] { - continue - } - - wg.Add(1) - sem <- struct{}{} - - go func(name, entryPath string, isDir bool) { - defer wg.Done() - defer func() { <-sem }() - - var size int64 - var lastAccess time.Time - var isCleanable bool - - if isDir { - size = calculateDirSize(entryPath) - isCleanable = cleanablePatterns[name] - } else { - info, err := os.Stat(entryPath) - if err == nil { - size = info.Size() - lastAccess = info.ModTime() - } - } - - mu.Lock() - defer mu.Unlock() - - dirEntries = append(dirEntries, dirEntry{ - Name: name, - Path: entryPath, - Size: size, - IsDir: isDir, - LastAccess: lastAccess, - IsCleanable: isCleanable, - }) - - totalSize += size - - // Track large files - if !isDir && size >= 100*1024*1024 { - largeFiles = append(largeFiles, fileEntry{ - Name: name, - Path: entryPath, - Size: size, - }) - } - - atomic.AddInt64(&processedCount, 1) - }(name, entryPath, entry.IsDir()) - } - - wg.Wait() - - // Sort by size descending - sort.Slice(dirEntries, func(i, j int) bool { - return dirEntries[i].Size > dirEntries[j].Size - }) - - sort.Slice(largeFiles, func(i, j int) bool { - return largeFiles[i].Size > largeFiles[j].Size - }) - - return dirEntries, largeFiles, totalSize, nil -} - -// calculateDirSize calculates the size of a directory with timeout and limits -// Uses shallow scanning for speed - estimates based on first few levels -func calculateDirSize(path string) int64 { - ctx, cancel := context.WithTimeout(context.Background(), dirSizeTimeout) - defer cancel() - - var size int64 - var fileCount int64 - - // Use a channel to signal completion - done := make(chan struct{}) - - go func() { - defer close(done) - quickScanDir(ctx, path, 0, &size, &fileCount) - }() - - select { - case <-done: - // Completed normally - case <-ctx.Done(): - // Timeout - return partial size (already accumulated) - } - - return size -} - -// quickScanDir does a fast shallow scan for size estimation -func quickScanDir(ctx context.Context, path string, depth int, size *int64, fileCount *int64) { - // Check context cancellation - select { - case <-ctx.Done(): - return - default: - } - - // Limit depth for speed - if depth > shallowScanDepth { - return - } - - // Limit total files scanned - if atomic.LoadInt64(fileCount) > maxFilesPerDir { - return - } - - entries, err := os.ReadDir(path) - if err != nil { - return - } - - for _, entry := range entries { - // Check cancellation - select { - case <-ctx.Done(): - return - default: - } - - if atomic.LoadInt64(fileCount) > maxFilesPerDir { - return - } - - entryPath := filepath.Join(path, entry.Name()) - - if entry.IsDir() { - name := entry.Name() - // Skip hidden and system directories - if skipPatterns[name] || (strings.HasPrefix(name, ".") && len(name) > 1) { - continue - } - quickScanDir(ctx, entryPath, depth+1, size, fileCount) - } else { - info, err := entry.Info() - if err == nil { - atomic.AddInt64(size, info.Size()) - atomic.AddInt64(fileCount, 1) - } - } - } -} - -// formatBytes formats bytes to human readable string -func formatBytes(bytes int64) string { - const unit = 1024 - if bytes < unit { - return fmt.Sprintf("%d B", bytes) - } - div, exp := int64(unit), 0 - for n := bytes / unit; n >= unit; n /= unit { - div *= unit - exp++ - } - return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) -} - -// truncatePath truncates a path to fit in maxLen -func truncatePath(path string, maxLen int) string { - if len(path) <= maxLen { - return path - } - return "..." + path[len(path)-maxLen+3:] -} - -// openInExplorer opens a path in Windows Explorer -func openInExplorer(path string) { - // Use explorer.exe to open the path - go func() { - exec.Command("explorer.exe", "/select,", path).Run() - }() -} - -func main() { - var startPath string - - flag.StringVar(&startPath, "path", "", "Path to analyze") - flag.Parse() - - // Check environment variable - if startPath == "" { - startPath = os.Getenv("MO_ANALYZE_PATH") - } - - // Use command line argument - if startPath == "" && len(flag.Args()) > 0 { - startPath = flag.Args()[0] - } - - // Default to user profile - if startPath == "" { - startPath = os.Getenv("USERPROFILE") - } - - // Resolve to absolute path - absPath, err := filepath.Abs(startPath) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - - // Check if path exists - if _, err := os.Stat(absPath); os.IsNotExist(err) { - fmt.Fprintf(os.Stderr, "Error: Path does not exist: %s\n", absPath) - os.Exit(1) - } - - p := tea.NewProgram(newModel(absPath), tea.WithAltScreen()) - if _, err := p.Run(); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } -} diff --git a/windows/cmd/status/main.go b/windows/cmd/status/main.go deleted file mode 100644 index 39afa4f..0000000 --- a/windows/cmd/status/main.go +++ /dev/null @@ -1,674 +0,0 @@ -//go:build windows - -package main - -import ( - "context" - "fmt" - "os" - "os/exec" - "runtime" - "strconv" - "strings" - "sync" - "time" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/shirou/gopsutil/v3/cpu" - "github.com/shirou/gopsutil/v3/disk" - "github.com/shirou/gopsutil/v3/host" - "github.com/shirou/gopsutil/v3/mem" - "github.com/shirou/gopsutil/v3/net" - "github.com/shirou/gopsutil/v3/process" -) - -// Styles -var ( - titleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#C79FD7")).Bold(true) - headerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#87CEEB")).Bold(true) - labelStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) - valueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")) - okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#A5D6A7")) - warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD75F")) - dangerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5F5F")).Bold(true) - dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) - cardStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("#444444")).Padding(0, 1) -) - -// Metrics snapshot -type MetricsSnapshot struct { - CollectedAt time.Time - HealthScore int - HealthMessage string - - // Hardware - Hostname string - OS string - Platform string - Uptime time.Duration - - // CPU - CPUModel string - CPUCores int - CPUPercent float64 - CPUPerCore []float64 - - // Memory - MemTotal uint64 - MemUsed uint64 - MemPercent float64 - SwapTotal uint64 - SwapUsed uint64 - SwapPercent float64 - - // Disk - Disks []DiskInfo - - // Network - Networks []NetworkInfo - - // Processes - TopProcesses []ProcessInfo -} - -type DiskInfo struct { - Device string - Mountpoint string - Total uint64 - Used uint64 - Free uint64 - UsedPercent float64 - Fstype string -} - -type NetworkInfo struct { - Name string - BytesSent uint64 - BytesRecv uint64 - PacketsSent uint64 - PacketsRecv uint64 -} - -type ProcessInfo struct { - PID int32 - Name string - CPU float64 - Memory float32 -} - -// Collector -type Collector struct { - prevNet map[string]net.IOCountersStat - prevNetTime time.Time - mu sync.Mutex -} - -func NewCollector() *Collector { - return &Collector{ - prevNet: make(map[string]net.IOCountersStat), - } -} - -func (c *Collector) Collect() MetricsSnapshot { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - var ( - snapshot MetricsSnapshot - wg sync.WaitGroup - mu sync.Mutex - ) - - snapshot.CollectedAt = time.Now() - - // Host info - wg.Add(1) - go func() { - defer wg.Done() - if info, err := host.InfoWithContext(ctx); err == nil { - mu.Lock() - snapshot.Hostname = info.Hostname - snapshot.OS = info.OS - snapshot.Platform = fmt.Sprintf("%s %s", info.Platform, info.PlatformVersion) - snapshot.Uptime = time.Duration(info.Uptime) * time.Second - mu.Unlock() - } - }() - - // CPU info - wg.Add(1) - go func() { - defer wg.Done() - if cpuInfo, err := cpu.InfoWithContext(ctx); err == nil && len(cpuInfo) > 0 { - mu.Lock() - snapshot.CPUModel = cpuInfo[0].ModelName - snapshot.CPUCores = runtime.NumCPU() - mu.Unlock() - } - if percent, err := cpu.PercentWithContext(ctx, 500*time.Millisecond, false); err == nil && len(percent) > 0 { - mu.Lock() - snapshot.CPUPercent = percent[0] - mu.Unlock() - } - if perCore, err := cpu.PercentWithContext(ctx, 500*time.Millisecond, true); err == nil { - mu.Lock() - snapshot.CPUPerCore = perCore - mu.Unlock() - } - }() - - // Memory - wg.Add(1) - go func() { - defer wg.Done() - if memInfo, err := mem.VirtualMemoryWithContext(ctx); err == nil { - mu.Lock() - snapshot.MemTotal = memInfo.Total - snapshot.MemUsed = memInfo.Used - snapshot.MemPercent = memInfo.UsedPercent - mu.Unlock() - } - if swapInfo, err := mem.SwapMemoryWithContext(ctx); err == nil { - mu.Lock() - snapshot.SwapTotal = swapInfo.Total - snapshot.SwapUsed = swapInfo.Used - snapshot.SwapPercent = swapInfo.UsedPercent - mu.Unlock() - } - }() - - // Disk - wg.Add(1) - go func() { - defer wg.Done() - if partitions, err := disk.PartitionsWithContext(ctx, false); err == nil { - var disks []DiskInfo - for _, p := range partitions { - // Skip non-physical drives - if !strings.HasPrefix(p.Device, "C:") && - !strings.HasPrefix(p.Device, "D:") && - !strings.HasPrefix(p.Device, "E:") && - !strings.HasPrefix(p.Device, "F:") { - continue - } - if usage, err := disk.UsageWithContext(ctx, p.Mountpoint); err == nil { - disks = append(disks, DiskInfo{ - Device: p.Device, - Mountpoint: p.Mountpoint, - Total: usage.Total, - Used: usage.Used, - Free: usage.Free, - UsedPercent: usage.UsedPercent, - Fstype: p.Fstype, - }) - } - } - mu.Lock() - snapshot.Disks = disks - mu.Unlock() - } - }() - - // Network - wg.Add(1) - go func() { - defer wg.Done() - if netIO, err := net.IOCountersWithContext(ctx, true); err == nil { - var networks []NetworkInfo - for _, io := range netIO { - // Skip loopback and inactive interfaces - if io.Name == "Loopback Pseudo-Interface 1" || (io.BytesSent == 0 && io.BytesRecv == 0) { - continue - } - networks = append(networks, NetworkInfo{ - Name: io.Name, - BytesSent: io.BytesSent, - BytesRecv: io.BytesRecv, - PacketsSent: io.PacketsSent, - PacketsRecv: io.PacketsRecv, - }) - } - mu.Lock() - snapshot.Networks = networks - mu.Unlock() - } - }() - - // Top Processes - wg.Add(1) - go func() { - defer wg.Done() - procs, err := process.ProcessesWithContext(ctx) - if err != nil { - return - } - - var procInfos []ProcessInfo - for _, p := range procs { - name, err := p.NameWithContext(ctx) - if err != nil { - continue - } - cpuPercent, _ := p.CPUPercentWithContext(ctx) - memPercent, _ := p.MemoryPercentWithContext(ctx) - - if cpuPercent > 0.1 || memPercent > 0.1 { - procInfos = append(procInfos, ProcessInfo{ - PID: p.Pid, - Name: name, - CPU: cpuPercent, - Memory: memPercent, - }) - } - } - - // Sort by CPU usage - for i := 0; i < len(procInfos)-1; i++ { - for j := i + 1; j < len(procInfos); j++ { - if procInfos[j].CPU > procInfos[i].CPU { - procInfos[i], procInfos[j] = procInfos[j], procInfos[i] - } - } - } - - // Take top 5 - if len(procInfos) > 5 { - procInfos = procInfos[:5] - } - - mu.Lock() - snapshot.TopProcesses = procInfos - mu.Unlock() - }() - - wg.Wait() - - // Calculate health score - snapshot.HealthScore, snapshot.HealthMessage = calculateHealthScore(snapshot) - - return snapshot -} - -func calculateHealthScore(s MetricsSnapshot) (int, string) { - score := 100 - var issues []string - - // CPU penalty (30% weight) - if s.CPUPercent > 90 { - score -= 30 - issues = append(issues, "High CPU") - } else if s.CPUPercent > 70 { - score -= 15 - issues = append(issues, "Elevated CPU") - } - - // Memory penalty (25% weight) - if s.MemPercent > 90 { - score -= 25 - issues = append(issues, "High Memory") - } else if s.MemPercent > 80 { - score -= 12 - issues = append(issues, "Elevated Memory") - } - - // Disk penalty (20% weight) - for _, d := range s.Disks { - if d.UsedPercent > 95 { - score -= 20 - issues = append(issues, fmt.Sprintf("Disk %s Critical", d.Device)) - break - } else if d.UsedPercent > 85 { - score -= 10 - issues = append(issues, fmt.Sprintf("Disk %s Low", d.Device)) - break - } - } - - // Swap penalty (10% weight) - if s.SwapPercent > 80 { - score -= 10 - issues = append(issues, "High Swap") - } - - if score < 0 { - score = 0 - } - - msg := "Excellent" - if len(issues) > 0 { - msg = strings.Join(issues, ", ") - } else if score >= 90 { - msg = "Excellent" - } else if score >= 70 { - msg = "Good" - } else if score >= 50 { - msg = "Fair" - } else { - msg = "Poor" - } - - return score, msg -} - -// Model for Bubble Tea -type model struct { - collector *Collector - metrics MetricsSnapshot - animFrame int - catHidden bool - ready bool - collecting bool - width int - height int -} - -// Messages -type tickMsg time.Time -type metricsMsg MetricsSnapshot - -func newModel() model { - return model{ - collector: NewCollector(), - animFrame: 0, - } -} - -func (m model) Init() tea.Cmd { - return tea.Batch( - m.collectMetrics(), - tickCmd(), - ) -} - -func tickCmd() tea.Cmd { - return tea.Tick(time.Second, func(t time.Time) tea.Msg { - return tickMsg(t) - }) -} - -func (m model) collectMetrics() tea.Cmd { - return func() tea.Msg { - return metricsMsg(m.collector.Collect()) - } -} - -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "q", "ctrl+c": - return m, tea.Quit - case "c": - m.catHidden = !m.catHidden - case "r": - m.collecting = true - return m, m.collectMetrics() - } - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - case tickMsg: - m.animFrame++ - if m.animFrame%2 == 0 && !m.collecting { - return m, tea.Batch( - m.collectMetrics(), - tickCmd(), - ) - } - return m, tickCmd() - case metricsMsg: - m.metrics = MetricsSnapshot(msg) - m.ready = true - m.collecting = false - } - return m, nil -} - -func (m model) View() string { - if !m.ready { - return "\n Loading system metrics..." - } - - var b strings.Builder - - // Header with mole animation - moleFrame := getMoleFrame(m.animFrame, m.catHidden) - - b.WriteString("\n") - b.WriteString(titleStyle.Render(" 🐹 Mole System Status")) - b.WriteString(" ") - b.WriteString(moleFrame) - b.WriteString("\n\n") - - // Health score - healthColor := okStyle - if m.metrics.HealthScore < 50 { - healthColor = dangerStyle - } else if m.metrics.HealthScore < 70 { - healthColor = warnStyle - } - b.WriteString(fmt.Sprintf(" Health: %s %s\n\n", - healthColor.Render(fmt.Sprintf("%d%%", m.metrics.HealthScore)), - dimStyle.Render(m.metrics.HealthMessage), - )) - - // System info - b.WriteString(headerStyle.Render(" 📍 System")) - b.WriteString("\n") - b.WriteString(fmt.Sprintf(" %s %s\n", labelStyle.Render("Host:"), valueStyle.Render(m.metrics.Hostname))) - b.WriteString(fmt.Sprintf(" %s %s\n", labelStyle.Render("OS:"), valueStyle.Render(m.metrics.Platform))) - b.WriteString(fmt.Sprintf(" %s %s\n", labelStyle.Render("Uptime:"), valueStyle.Render(formatDuration(m.metrics.Uptime)))) - b.WriteString("\n") - - // CPU - b.WriteString(headerStyle.Render(" ⚡ CPU")) - b.WriteString("\n") - cpuColor := getPercentColor(m.metrics.CPUPercent) - b.WriteString(fmt.Sprintf(" %s %s\n", labelStyle.Render("Model:"), valueStyle.Render(truncateString(m.metrics.CPUModel, 50)))) - b.WriteString(fmt.Sprintf(" %s %s (%d cores)\n", - labelStyle.Render("Usage:"), - cpuColor.Render(fmt.Sprintf("%.1f%%", m.metrics.CPUPercent)), - m.metrics.CPUCores, - )) - b.WriteString(fmt.Sprintf(" %s\n", renderProgressBar(m.metrics.CPUPercent, 30))) - b.WriteString("\n") - - // Memory - b.WriteString(headerStyle.Render(" 🧠 Memory")) - b.WriteString("\n") - memColor := getPercentColor(m.metrics.MemPercent) - b.WriteString(fmt.Sprintf(" %s %s / %s %s\n", - labelStyle.Render("RAM:"), - memColor.Render(formatBytes(m.metrics.MemUsed)), - valueStyle.Render(formatBytes(m.metrics.MemTotal)), - memColor.Render(fmt.Sprintf("(%.1f%%)", m.metrics.MemPercent)), - )) - b.WriteString(fmt.Sprintf(" %s\n", renderProgressBar(m.metrics.MemPercent, 30))) - if m.metrics.SwapTotal > 0 { - b.WriteString(fmt.Sprintf(" %s %s / %s\n", - labelStyle.Render("Swap:"), - valueStyle.Render(formatBytes(m.metrics.SwapUsed)), - valueStyle.Render(formatBytes(m.metrics.SwapTotal)), - )) - } - b.WriteString("\n") - - // Disk - b.WriteString(headerStyle.Render(" 💾 Disks")) - b.WriteString("\n") - for _, d := range m.metrics.Disks { - diskColor := getPercentColor(d.UsedPercent) - b.WriteString(fmt.Sprintf(" %s %s / %s %s\n", - labelStyle.Render(d.Device), - diskColor.Render(formatBytes(d.Used)), - valueStyle.Render(formatBytes(d.Total)), - diskColor.Render(fmt.Sprintf("(%.1f%%)", d.UsedPercent)), - )) - b.WriteString(fmt.Sprintf(" %s\n", renderProgressBar(d.UsedPercent, 30))) - } - b.WriteString("\n") - - // Top Processes - if len(m.metrics.TopProcesses) > 0 { - b.WriteString(headerStyle.Render(" 📊 Top Processes")) - b.WriteString("\n") - for _, p := range m.metrics.TopProcesses { - b.WriteString(fmt.Sprintf(" %s %s (CPU: %.1f%%, Mem: %.1f%%)\n", - dimStyle.Render(fmt.Sprintf("[%d]", p.PID)), - valueStyle.Render(truncateString(p.Name, 20)), - p.CPU, - p.Memory, - )) - } - b.WriteString("\n") - } - - // Network - if len(m.metrics.Networks) > 0 { - b.WriteString(headerStyle.Render(" 🌐 Network")) - b.WriteString("\n") - for i, n := range m.metrics.Networks { - if i >= 3 { - break - } - b.WriteString(fmt.Sprintf(" %s ↑%s ↓%s\n", - labelStyle.Render(truncateString(n.Name, 20)+":"), - valueStyle.Render(formatBytes(n.BytesSent)), - valueStyle.Render(formatBytes(n.BytesRecv)), - )) - } - b.WriteString("\n") - } - - // Footer - b.WriteString(dimStyle.Render(" [q] quit [r] refresh [c] toggle mole")) - b.WriteString("\n") - - return b.String() -} - -func getMoleFrame(frame int, hidden bool) string { - if hidden { - return "" - } - frames := []string{ - "🐹", - "🐹.", - "🐹..", - "🐹...", - } - return frames[frame%len(frames)] -} - -func renderProgressBar(percent float64, width int) string { - filled := int(percent / 100 * float64(width)) - if filled > width { - filled = width - } - if filled < 0 { - filled = 0 - } - - color := okStyle - if percent > 85 { - color = dangerStyle - } else if percent > 70 { - color = warnStyle - } - - bar := strings.Repeat("█", filled) + strings.Repeat("░", width-filled) - return color.Render(bar) -} - -func getPercentColor(percent float64) lipgloss.Style { - if percent > 85 { - return dangerStyle - } else if percent > 70 { - return warnStyle - } - return okStyle -} - -func formatBytes(bytes uint64) string { - const unit = 1024 - if bytes < unit { - return fmt.Sprintf("%d B", bytes) - } - div, exp := uint64(unit), 0 - for n := bytes / unit; n >= unit; n /= unit { - div *= unit - exp++ - } - return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) -} - -func formatDuration(d time.Duration) string { - days := int(d.Hours() / 24) - hours := int(d.Hours()) % 24 - minutes := int(d.Minutes()) % 60 - - if days > 0 { - return fmt.Sprintf("%dd %dh %dm", days, hours, minutes) - } - if hours > 0 { - return fmt.Sprintf("%dh %dm", hours, minutes) - } - return fmt.Sprintf("%dm", minutes) -} - -func truncateString(s string, maxLen int) string { - if len(s) <= maxLen { - return s - } - return s[:maxLen-3] + "..." -} - -// getWindowsVersion gets detailed Windows version using PowerShell -func getWindowsVersion() string { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - - cmd := exec.CommandContext(ctx, "powershell", "-Command", - "(Get-CimInstance Win32_OperatingSystem).Caption") - output, err := cmd.Output() - if err != nil { - return "Windows" - } - return strings.TrimSpace(string(output)) -} - -// getBatteryInfo gets battery info on Windows (for laptops) -func getBatteryInfo() (int, bool, bool) { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - - cmd := exec.CommandContext(ctx, "powershell", "-Command", - "(Get-CimInstance Win32_Battery).EstimatedChargeRemaining") - output, err := cmd.Output() - if err != nil { - return 0, false, false - } - - percent, err := strconv.Atoi(strings.TrimSpace(string(output))) - if err != nil { - return 0, false, false - } - - // Check if charging - cmdStatus := exec.CommandContext(ctx, "powershell", "-Command", - "(Get-CimInstance Win32_Battery).BatteryStatus") - statusOutput, _ := cmdStatus.Output() - status, _ := strconv.Atoi(strings.TrimSpace(string(statusOutput))) - isCharging := status == 2 // 2 = AC Power - - return percent, isCharging, true -} - -func main() { - p := tea.NewProgram(newModel(), tea.WithAltScreen()) - if _, err := p.Run(); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } -} diff --git a/windows/go.mod b/windows/go.mod deleted file mode 100644 index 5cb9f41..0000000 --- a/windows/go.mod +++ /dev/null @@ -1,10 +0,0 @@ -module github.com/tw93/mole/windows - -go 1.24.0 - -require ( - github.com/charmbracelet/bubbletea v1.3.10 - github.com/shirou/gopsutil/v3 v3.24.5 - github.com/yusufpapurcu/wmi v1.2.4 - golang.org/x/sys v0.36.0 -) diff --git a/windows/go.sum b/windows/go.sum deleted file mode 100644 index 1fce446..0000000 --- a/windows/go.sum +++ /dev/null @@ -1,73 +0,0 @@ -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM= -github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg= -github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg= -github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I= -github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= -github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= -github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= -github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= -github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= -github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= -github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= -github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= -github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= -github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= -github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= -github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= -github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= -github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= -github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= -github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= -github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= -github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= -github.com/shoenig/go-m1cpu v0.1.7 h1:C76Yd0ObKR82W4vhfjZiCp0HxcSZ8Nqd84v+HZ0qyI0= -github.com/shoenig/go-m1cpu v0.1.7/go.mod h1:KkDOw6m3ZJQAPHbrzkZki4hnx+pDRR1Lo+ldA56wD5w= -github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk= -github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= -github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= -github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= -github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= -github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= -github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw= -golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= -golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=