## Features
-- **Unified toolkit**: Consolidated features of CleanMyMac, AppCleaner, DaisyDisk, and iStat into a **single binary**
+- **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
@@ -44,16 +44,16 @@ Built with PowerShell and Go for native Windows performance. Run `mole` after in
## Quick Start
-**Install by Brew, recommended:**
+**Install via Homebrew — recommended:**
```bash
brew install mole
```
-**or by Script, for older macOS or latest code:**
+**Or via script:**
```bash
-# Add '-s latest' for newest, '-s dev' for development, or '-s 1.17.0' for a version.
+# 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
```
@@ -70,7 +70,7 @@ mo purge # Clean project build artifacts
mo installer # Find and remove installer files
mo touchid # Configure Touch ID for sudo
-mo completion # Setup shell tab completion
+mo completion # Set up shell tab completion
mo update # Update Mole
mo remove # Remove Mole from system
mo --help # Show help
@@ -92,6 +92,7 @@ mo purge --paths # Configure project scan directories
- **Safety**: Built with strict protections. See [Security Audit](SECURITY_AUDIT.md). Preview changes with `mo clean --dry-run`.
- **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.
## Features in Detail
@@ -157,7 +158,7 @@ System: 5/32 GB RAM | 333/460 GB Disk (72%) | Uptime 6d
System optimization completed
====================================================================
-Use `mo optimize --whitelist` to protect specific optimization items from being run.
+Use `mo optimize --whitelist` to exclude specific optimizations.
```
### Disk Space Analyzer
@@ -203,7 +204,7 @@ Up ▮▯▯▯▯ 0.8 MB/s Chrome ▮▮▮▯▯ 2
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. Press `k` to hide/show cat, `q` to quit.
+Health score based on CPU, memory, disk, temperature, and I/O load. Color-coded by range.
### Project Artifact Purge
@@ -224,7 +225,7 @@ Select Categories to Clean - 18.5GB (8 selected)
● backend-service 2.5GB | node_modules
```
-> **Use with caution:** This will permanently delete selected artifacts. Review carefully before confirming. Recent projects (< 7 days) are marked and unselected by default.
+> **Use with caution:** This will permanently delete selected artifacts. Review carefully before confirming. Recent projects — less than 7 days old — are marked and unselected by default.
Custom Scan Paths
@@ -237,7 +238,7 @@ Run `mo purge --paths` to configure which directories to scan, or edit `~/.confi
~/Work/ClientB
```
-When custom paths are configured, only those directories are scanned. Otherwise, defaults to `~/Projects`, `~/GitHub`, `~/dev`, etc.
+When custom paths are configured, only those directories are scanned. Otherwise, it defaults to `~/Projects`, `~/GitHub`, `~/dev`, etc.
@@ -266,24 +267,34 @@ Launch Mole commands instantly from Raycast or Alfred:
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 you can set `MO_LAUNCHER_APP=` to override. For Raycast, if this is your first script directory, add it in Raycast Extensions (Add Script Directory) and then run "Reload Script Directories" to load the new commands.
+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! ❤️
-Users from around the world are loving Mole! Join the community and share your experience.
+
+
+
+
+Join thousands of users worldwide who trust Mole to keep their Macs clean and optimized.
+
+
## Support
-
-
-- If Mole saved you space, consider starring the repo or sharing it with friends who need a cleaner Mac.
+- 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 cats? Treat Tangyuan and Cola to canned food via this link to keep our mascots purring.
+- Love Mole? Buy Tw93 an ice-cold Coke to keep the project alive and kicking! 🥤
+
+
+Friends who bought me Coke
+
+
+
## License
-MIT License - feel free to enjoy and participate in open source.
+MIT License — feel free to enjoy and participate in open source.
diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md
index 30bbc60..2fc4a61 100644
--- a/SECURITY_AUDIT.md
+++ b/SECURITY_AUDIT.md
@@ -4,7 +4,7 @@
**Security Audit & Compliance Report**
-Version 1.17.0 | December 31, 2025
+Version 1.19.0 | January 5, 2026
---
@@ -33,7 +33,7 @@ Version 1.17.0 | December 31, 2025
|-----------|---------|
| Audit Date | December 31, 2025 |
| Audit Conclusion | **PASSED** |
-| Mole Version | V1.17.0 |
+| Mole Version | V1.19.0 |
| Audited Branch | `main` (HEAD) |
| Scope | Shell scripts, Go binaries, Configuration |
| Methodology | Static analysis, Threat modeling, Code review |
@@ -47,6 +47,7 @@ Version 1.17.0 | December 31, 2025
- Comprehensive protection for VPN, AI tools, and system components
- Atomic operations with crash recovery mechanisms
- Full user control with dry-run and whitelist capabilities
+- Installer cleanup safely scans common locations with user confirmation
---
@@ -290,7 +291,7 @@ bats tests/security.bats # Run specific suite
| Standard | Implementation |
|----------|----------------|
| OWASP Secure Coding | Input validation, least privilege, defense-in-depth |
-| CWE-22 (Path Traversal) | Absolute path enforcement, `../` rejection |
+| CWE-22 (Path Traversal) | Enhanced detection: rejects `/../` components while allowing `..` in directory names (Firefox compatibility) |
| CWE-78 (Command Injection) | Control character filtering |
| CWE-59 (Link Following) | Symlink detection before privileged operations |
| Apple File System Guidelines | Respects SIP, Read-Only Volumes, TCC |
diff --git a/bin/clean.sh b/bin/clean.sh
index 1586f4d..7beac0d 100755
--- a/bin/clean.sh
+++ b/bin/clean.sh
@@ -308,9 +308,8 @@ safe_clean() {
return 0
fi
- if [[ "${TRACK_SECTION:-0}" == "1" && "${SECTION_ACTIVITY:-0}" == "0" ]]; then
- stop_section_spinner
- fi
+ # Always stop spinner before outputting results
+ stop_section_spinner
local description
local -a targets
diff --git a/bin/installer.sh b/bin/installer.sh
index 73a8bd8..8eed412 100755
--- a/bin/installer.sh
+++ b/bin/installer.sh
@@ -205,12 +205,16 @@ collect_installers() {
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
@@ -245,9 +249,20 @@ collect_installers() {
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 "$(basename "$file")" "$size_human" "$source")
+ display=$(format_installer_display "$display_name" "$size_human" "$source")
# Store installer data in parallel arrays
INSTALLER_PATHS+=("$file")
diff --git a/bin/touchid.sh b/bin/touchid.sh
index c1a626a..1f45914 100755
--- a/bin/touchid.sh
+++ b/bin/touchid.sh
@@ -14,10 +14,17 @@ LIB_DIR="$(cd "$SCRIPT_DIR/../lib" && pwd)"
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
@@ -74,7 +81,74 @@ enable_touchid() {
echo ""
fi
- # Check if already configured
+ # 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
@@ -129,26 +203,55 @@ disable_touchid() {
return 0
fi
- # 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"
+ # 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
- # Remove pam_tid.so line
- temp_file=$(mktemp)
- grep -v "pam_tid.so" "$PAM_SUDO_FILE" > "$temp_file"
+ # 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
- 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
+ # 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
diff --git a/bin/uninstall.sh b/bin/uninstall.sh
index f16e017..389e222 100755
--- a/bin/uninstall.sh
+++ b/bin/uninstall.sh
@@ -59,6 +59,24 @@ scan_applications() {
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)
@@ -228,7 +246,6 @@ scan_applications() {
local progress_file="${temp_file}.progress"
echo "0" > "$progress_file"
- local spinner_pid=""
(
# shellcheck disable=SC2329 # Function invoked indirectly via trap
cleanup_spinner() { exit 0; }
diff --git a/cmd/analyze/cache.go b/cmd/analyze/cache.go
index 8b70bf2..93fb391 100644
--- a/cmd/analyze/cache.go
+++ b/cmd/analyze/cache.go
@@ -30,6 +30,7 @@ func snapshotFromModel(m model) historyEntry {
Entries: cloneDirEntries(m.entries),
LargeFiles: cloneFileEntries(m.largeFiles),
TotalSize: m.totalSize,
+ TotalFiles: m.totalFiles,
Selected: m.selected,
EntryOffset: m.offset,
LargeSelected: m.largeSelected,
@@ -49,7 +50,7 @@ func cloneDirEntries(entries []dirEntry) []dirEntry {
return nil
}
copied := make([]dirEntry, len(entries))
- copy(copied, entries)
+ copy(copied, entries) //nolint:all
return copied
}
@@ -58,7 +59,7 @@ func cloneFileEntries(files []fileEntry) []fileEntry {
return nil
}
copied := make([]fileEntry, len(files))
- copy(copied, files)
+ copy(copied, files) //nolint:all
return copied
}
@@ -208,7 +209,7 @@ func loadCacheFromDisk(path string) (*cacheEntry, error) {
if err != nil {
return nil, err
}
- defer file.Close()
+ defer file.Close() //nolint:errcheck
var entry cacheEntry
decoder := gob.NewDecoder(file)
@@ -250,6 +251,7 @@ func saveCacheToDisk(path string, result scanResult) error {
Entries: result.Entries,
LargeFiles: result.LargeFiles,
TotalSize: result.TotalSize,
+ TotalFiles: result.TotalFiles,
ModTime: info.ModTime(),
ScanTime: time.Now(),
}
@@ -258,12 +260,35 @@ func saveCacheToDisk(path string, result scanResult) error {
if err != nil {
return err
}
- defer file.Close()
+ 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 {
diff --git a/cmd/analyze/main.go b/cmd/analyze/main.go
index e97500e..bba4647 100644
--- a/cmd/analyze/main.go
+++ b/cmd/analyze/main.go
@@ -35,12 +35,14 @@ 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
}
@@ -50,6 +52,7 @@ type historyEntry struct {
Entries []dirEntry
LargeFiles []fileEntry
TotalSize int64
+ TotalFiles int64
Selected int
EntryOffset int
LargeSelected int
@@ -114,6 +117,8 @@ type model struct {
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)
}
func (m model) inOverviewMode() bool {
@@ -195,6 +200,13 @@ func newModel(path string, isOverview bool) model {
}
}
+ // 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
}
@@ -355,6 +367,7 @@ func (m model) scanCmd(path string) tea.Cmd {
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}
}
@@ -441,6 +454,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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()
@@ -685,6 +699,9 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
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)
@@ -968,6 +985,7 @@ func (m model) enterSelectedDir() (tea.Model, tea.Cmd) {
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
@@ -978,6 +996,10 @@ func (m model) enterSelectedDir() (tea.Model, tea.Cmd) {
m.scanning = false
return m, nil
}
+ m.lastTotalFiles = 0
+ if total, err := peekCacheTotalFiles(m.path); err == nil && total > 0 {
+ m.lastTotalFiles = total
+ }
return m, tea.Batch(m.scanCmd(m.path), tickCmd())
}
m.status = fmt.Sprintf("File: %s (%s)", selected.Name, humanizeBytes(selected.Size))
diff --git a/cmd/analyze/scanner.go b/cmd/analyze/scanner.go
index b6ab09b..0d7ddca 100644
--- a/cmd/analyze/scanner.go
+++ b/cmd/analyze/scanner.go
@@ -251,6 +251,7 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
Entries: entries,
LargeFiles: largeFiles,
TotalSize: total,
+ TotalFiles: atomic.LoadInt64(filesScanned),
}, nil
}
diff --git a/cmd/analyze/view.go b/cmd/analyze/view.go
index 6cdf400..67ae52b 100644
--- a/cmd/analyze/view.go
+++ b/cmd/analyze/view.go
@@ -75,10 +75,25 @@ func (m model) View() string {
if m.scanning {
filesScanned, dirsScanned, bytesScanned := m.getScanProgress()
- fmt.Fprintf(&b, "%s%s%s%s Scanning: %s%s files%s, %s%s dirs%s, %s%s%s\n",
+ 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)
diff --git a/cmd/status/main.go b/cmd/status/main.go
index cc5ba0e..b8f7aeb 100644
--- a/cmd/status/main.go
+++ b/cmd/status/main.go
@@ -1,8 +1,11 @@
+// Package main provides the mo status command for real-time system monitoring.
package main
import (
"fmt"
"os"
+ "path/filepath"
+ "strings"
"time"
tea "github.com/charmbracelet/bubbletea"
@@ -37,10 +40,50 @@ type model struct {
catHidden bool // true = hidden, false = visible
}
+// 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)
+}
+
func newModel() model {
return model{
collector: NewCollector(),
- catHidden: false,
+ catHidden: loadCatHidden(),
}
}
@@ -55,8 +98,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "q", "esc", "ctrl+c":
return m, tea.Quit
case "k":
- // Toggle cat visibility
+ // Toggle cat visibility and persist preference
m.catHidden = !m.catHidden
+ saveCatHidden(m.catHidden)
return m, nil
}
case tea.WindowSizeMsg:
diff --git a/cmd/status/metrics.go b/cmd/status/metrics.go
index bb5df62..2b24bba 100644
--- a/cmd/status/metrics.go
+++ b/cmd/status/metrics.go
@@ -286,9 +286,8 @@ func commandExists(name string) bool {
return false
}
defer func() {
- if r := recover(); r != nil {
- // Treat LookPath panics as "missing".
- }
+ // Treat LookPath panics as "missing".
+ _ = recover()
}()
_, err := exec.LookPath(name)
return err == nil
diff --git a/cmd/status/metrics_cpu.go b/cmd/status/metrics_cpu.go
index f892bef..29c7690 100644
--- a/cmd/status/metrics_cpu.go
+++ b/cmd/status/metrics_cpu.go
@@ -32,7 +32,7 @@ func collectCPU() (CPUStatus, error) {
}
// Two-call pattern for more reliable CPU usage.
- cpu.Percent(0, true)
+ warmUpCPU()
time.Sleep(cpuSampleInterval)
percents, err := cpu.Percent(0, true)
var totalPercent float64
@@ -255,3 +255,7 @@ func fallbackCPUUtilization(logical int) (float64, []float64, error) {
}
return avg, perCore, nil
}
+
+func warmUpCPU() {
+ cpu.Percent(0, true) //nolint:errcheck
+}
diff --git a/cmd/status/metrics_hardware.go b/cmd/status/metrics_hardware.go
index 8117e57..54ff7ba 100644
--- a/cmd/status/metrics_hardware.go
+++ b/cmd/status/metrics_hardware.go
@@ -130,6 +130,8 @@ func parseInt(s string) int {
return 0
}
var num int
- fmt.Sscanf(cleaned, "%d", &num)
+ 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
index 3a556f2..0e34828 100644
--- a/cmd/status/metrics_health.go
+++ b/cmd/status/metrics_health.go
@@ -70,10 +70,12 @@ func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, d
}
// Memory pressure penalty.
- if mem.Pressure == "warn" {
+ // Memory pressure penalty.
+ switch mem.Pressure {
+ case "warn":
score -= memPressureWarnPenalty
issues = append(issues, "Memory Pressure")
- } else if mem.Pressure == "critical" {
+ case "critical":
score -= memPressureCritPenalty
issues = append(issues, "Critical Memory")
}
@@ -131,16 +133,17 @@ func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, d
}
// Build message.
- msg := "Excellent"
- if score >= 90 {
+ var msg string
+ switch {
+ case score >= 90:
msg = "Excellent"
- } else if score >= 75 {
+ case score >= 75:
msg = "Good"
- } else if score >= 60 {
+ case score >= 60:
msg = "Fair"
- } else if score >= 40 {
+ case score >= 40:
msg = "Poor"
- } else {
+ default:
msg = "Critical"
}
diff --git a/cmd/status/view.go b/cmd/status/view.go
index 2691452..7ef56cd 100644
--- a/cmd/status/view.go
+++ b/cmd/status/view.go
@@ -187,15 +187,16 @@ func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int
}
func getScoreStyle(score int) lipgloss.Style {
- if score >= 90 {
+ switch {
+ case score >= 90:
return lipgloss.NewStyle().Foreground(lipgloss.Color("#87FF87")).Bold(true)
- } else if score >= 75 {
+ case score >= 75:
return lipgloss.NewStyle().Foreground(lipgloss.Color("#87D787")).Bold(true)
- } else if score >= 60 {
+ case score >= 60:
return lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD75F")).Bold(true)
- } else if score >= 40 {
+ case score >= 40:
return lipgloss.NewStyle().Foreground(lipgloss.Color("#FFAF5F")).Bold(true)
- } else {
+ default:
return lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Bold(true)
}
}
@@ -308,9 +309,10 @@ func renderMemoryCard(mem MemoryStatus) cardData {
if mem.Pressure != "" {
pressureStyle := okStyle
pressureText := "Status " + mem.Pressure
- if mem.Pressure == "warn" {
+ switch mem.Pressure {
+ case "warn":
pressureStyle = warnStyle
- } else if mem.Pressure == "critical" {
+ case "critical":
pressureStyle = dangerStyle
}
lines = append(lines, pressureStyle.Render(pressureText))
@@ -706,11 +708,11 @@ func humanBytesCompact(v uint64) string {
}
}
-func shorten(s string, max int) string {
- if len(s) <= max {
+func shorten(s string, maxLen int) string {
+ if len(s) <= maxLen {
return s
}
- return s[:max-1] + "…"
+ return s[:maxLen-1] + "…"
}
func renderTwoColumns(cards []cardData, width int) string {
diff --git a/install.sh b/install.sh
index b2408f3..13cbf8f 100755
--- a/install.sh
+++ b/install.sh
@@ -100,7 +100,7 @@ resolve_source_dir() {
local tmp
tmp="$(mktemp -d)"
- trap "stop_line_spinner 2>/dev/null; rm -rf '$tmp'" EXIT
+ trap 'stop_line_spinner 2>/dev/null; rm -rf "$tmp"' EXIT
local branch="${MOLE_VERSION:-}"
if [[ -z "$branch" ]]; then
diff --git a/lib/clean/app_caches.sh b/lib/clean/app_caches.sh
index 3418aa3..c4b62c6 100644
--- a/lib/clean/app_caches.sh
+++ b/lib/clean/app_caches.sh
@@ -88,6 +88,7 @@ clean_productivity_apps() {
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() {
diff --git a/lib/core/app_protection.sh b/lib/core/app_protection.sh
index 2c617a4..5822c6a 100755
--- a/lib/core/app_protection.sh
+++ b/lib/core/app_protection.sh
@@ -85,8 +85,9 @@ readonly DATA_PROTECTED_BUNDLES=(
"com.lastpass.*" # LastPass
"com.dashlane.*" # Dashlane
"com.bitwarden.*" # Bitwarden
- "com.keepassx.*" # KeePassXC
+ "com.keepassx.*" # KeePassXC (Legacy)
"org.keepassx.*" # KeePassX
+ "org.keepassxc.*" # KeePassXC
"com.authy.*" # Authy
"com.yubico.*" # YubiKey Manager
diff --git a/lib/core/file_ops.sh b/lib/core/file_ops.sh
index c7b5bf3..4fb03a7 100644
--- a/lib/core/file_ops.sh
+++ b/lib/core/file_ops.sh
@@ -46,7 +46,9 @@ validate_path_for_deletion() {
fi
# Check for path traversal attempts
- if [[ "$path" =~ \.\. ]]; then
+ # 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
@@ -254,12 +256,10 @@ safe_find_delete() {
find_args+=("-mtime" "+$age_days")
fi
- # Iterate results to respect should_protect_path when available
+ # Iterate results to respect should_protect_path
while IFS= read -r -d '' match; do
- if command -v should_protect_path > /dev/null 2>&1; then
- if should_protect_path "$match"; then
- continue
- fi
+ 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)
@@ -298,12 +298,10 @@ safe_sudo_find_delete() {
find_args+=("-mtime" "+$age_days")
fi
- # Iterate results to respect should_protect_path when available
+ # Iterate results to respect should_protect_path
while IFS= read -r -d '' match; do
- if command -v should_protect_path > /dev/null 2>&1; then
- if should_protect_path "$match"; then
- continue
- fi
+ 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)
diff --git a/lib/core/sudo.sh b/lib/core/sudo.sh
index 9527d14..57b80b4 100644
--- a/lib/core/sudo.sh
+++ b/lib/core/sudo.sh
@@ -9,6 +9,13 @@ set -euo pipefail
# ============================================================================
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 $?
diff --git a/mole b/mole
index a40c37c..398bf72 100755
--- a/mole
+++ b/mole
@@ -13,7 +13,7 @@ source "$SCRIPT_DIR/lib/core/commands.sh"
trap cleanup_temp_files EXIT INT TERM
# Version and update helpers
-VERSION="1.18.0"
+VERSION="1.20.0"
MOLE_TAGLINE="Deep clean and optimize your Mac."
is_touchid_configured() {
@@ -345,7 +345,7 @@ update_mole() {
if ! printf '%s\n' "$output" | grep -Eq "Updated to latest version|Already on latest version"; then
local new_version
- new_version=$("$mole_path" --version 2> /dev/null | awk 'NF {print $NF}' || echo "")
+ 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'
diff --git a/scripts/check.sh b/scripts/check.sh
index d4fbc50..4249a82 100755
--- a/scripts/check.sh
+++ b/scripts/check.sh
@@ -74,8 +74,12 @@ if [[ "$MODE" == "format" ]]; then
exit 1
fi
- if command -v go > /dev/null 2>&1; then
- echo -e "${YELLOW}Formatting Go code...${NC}"
+ 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
@@ -95,14 +99,42 @@ if [[ "$MODE" != "check" ]]; then
echo -e "${YELLOW}${ICON_WARNING} shfmt not installed, skipping${NC}\n"
fi
- if command -v go > /dev/null 2>&1; then
- echo -e "${YELLOW}2. Formatting Go code...${NC}"
+ 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 ShellCheck...${NC}"
+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"
@@ -114,7 +146,7 @@ else
echo -e "${YELLOW}${ICON_WARNING} shellcheck not installed, skipping${NC}\n"
fi
-echo -e "${YELLOW}4. Running syntax check...${NC}"
+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
@@ -133,7 +165,7 @@ find lib -name "*.sh" | while read -r script; do
done
echo -e "${GREEN}${ICON_SUCCESS} Syntax check passed${NC}\n"
-echo -e "${YELLOW}5. Checking optimizations...${NC}"
+echo -e "${YELLOW}6. Checking optimizations...${NC}"
OPTIMIZATION_SCORE=0
TOTAL_CHECKS=0
diff --git a/tests/clean_misc.bats b/tests/clean_misc.bats
index 1e7054e..c5e0d3c 100644
--- a/tests/clean_misc.bats
+++ b/tests/clean_misc.bats
@@ -92,9 +92,14 @@ EOF
@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
diff --git a/tests/core_safe_functions.bats b/tests/core_safe_functions.bats
index c6bcba6..a720787 100644
--- a/tests/core_safe_functions.bats
+++ b/tests/core_safe_functions.bats
@@ -43,6 +43,26 @@ teardown() {
@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" {