## 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
@@ -26,16 +26,16 @@
## 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
```
@@ -52,7 +52,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
@@ -71,13 +71,11 @@ mo purge --paths # Configure project scan directories
## Tips
- **Terminal**: iTerm2 has known compatibility issues; we recommend Alacritty, kitty, WezTerm, Ghostty, or Warp.
-- **Safety**: Built with strict protections. See our [Security Audit](SECURITY_AUDIT.md). Preview changes with `mo clean --dry-run`.
-- **Whitelist**: Manage protected paths with `mo clean --whitelist`.
-- **Touch ID**: Enable Touch ID for sudo commands by running `mo touchid`.
-- **Shell Completion**: Enable tab completion by running `mo completion` (auto-detect and install).
-- **Navigation**: Supports standard arrow keys and Vim bindings (`h/j/k/l`).
-- **Debug**: View detailed logs by appending the `--debug` flag (e.g., `mo clean --debug`).
-- **Detailed Preview**: Combine `--dry-run --debug` for comprehensive operation details including risk levels, file paths, sizes, and expected outcomes. Check `~/.config/mole/mole_debug_session.log` for full details.
+- **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
@@ -142,7 +140,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
@@ -209,7 +207,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
@@ -222,7 +220,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.
@@ -251,36 +249,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.
-
-## Developers
-
-Mole's development can not be without these Hackers. They contributed a lot of capabilities for Mole. Also, welcome to follow them! ❤️
-
-
-
+
+
+Join thousands of users worldwide who trust Mole to keep their Macs clean and optimized.
+
+
+
## Support
-
-Sponsorship and Community
-
-
-
-
-- 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/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..a0dcb7a 100644
--- a/cmd/analyze/cache.go
+++ b/cmd/analyze/cache.go
@@ -49,7 +49,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 +58,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 +208,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)
@@ -258,7 +258,7 @@ 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)
diff --git a/cmd/analyze/format.go b/cmd/analyze/format.go
index 7686745..5ef48d6 100644
--- a/cmd/analyze/format.go
+++ b/cmd/analyze/format.go
@@ -93,15 +93,12 @@ func humanizeBytes(size int64) string {
return fmt.Sprintf("%.1f %cB", value, "KMGTPE"[exp])
}
-func coloredProgressBar(value, max int64, percent float64) string {
- if max <= 0 {
+func coloredProgressBar(value, maxValue int64, percent float64) string {
+ if maxValue <= 0 {
return colorGray + strings.Repeat("░", barWidth) + colorReset
}
- filled := int((value * int64(barWidth)) / max)
- if filled > barWidth {
- filled = barWidth
- }
+ filled := min(int((value*int64(barWidth))/maxValue), barWidth)
var barColor string
if percent >= 50 {
@@ -114,26 +111,27 @@ func coloredProgressBar(value, max int64, percent float64) string {
barColor = colorGreen
}
- bar := barColor
- for i := 0; i < barWidth; i++ {
+ var bar strings.Builder
+ bar.WriteString(barColor)
+ for i := range barWidth {
if i < filled {
if i < filled-1 {
- bar += "█"
+ bar.WriteString("█")
} else {
- remainder := (value * int64(barWidth)) % max
- if remainder > max/2 {
- bar += "█"
- } else if remainder > max/4 {
- bar += "▓"
+ remainder := (value * int64(barWidth)) % maxValue
+ if remainder > maxValue/2 {
+ bar.WriteString("█")
+ } else if remainder > maxValue/4 {
+ bar.WriteString("▓")
} else {
- bar += "▒"
+ bar.WriteString("▒")
}
}
} else {
- bar += colorGray + "░" + barColor
+ bar.WriteString(colorGray + "░" + barColor)
}
}
- return bar + colorReset
+ return bar.String() + colorReset
}
// runeWidth returns display width for wide characters and emoji.
@@ -181,10 +179,6 @@ func calculateNameWidth(termWidth int) int {
return available
}
-func trimName(name string) string {
- return trimNameWithWidth(name, 45) // Default width for backward compatibility
-}
-
func trimNameWithWidth(name string, maxWidth int) string {
const (
ellipsis = "..."
diff --git a/cmd/analyze/heap.go b/cmd/analyze/heap.go
index 08bf8a9..0b4a5a5 100644
--- a/cmd/analyze/heap.go
+++ b/cmd/analyze/heap.go
@@ -7,11 +7,11 @@ 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 interface{}) {
+func (h *entryHeap) Push(x any) {
*h = append(*h, x.(dirEntry))
}
-func (h *entryHeap) Pop() interface{} {
+func (h *entryHeap) Pop() any {
old := *h
n := len(old)
x := old[n-1]
@@ -26,11 +26,11 @@ 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 interface{}) {
+func (h *largeFileHeap) Push(x any) {
*h = append(*h, x.(fileEntry))
}
-func (h *largeFileHeap) Pop() interface{} {
+func (h *largeFileHeap) Pop() any {
old := *h
n := len(old)
x := old[n-1]
diff --git a/cmd/analyze/main.go b/cmd/analyze/main.go
index f81055b..e97500e 100644
--- a/cmd/analyze/main.go
+++ b/cmd/analyze/main.go
@@ -148,7 +148,7 @@ func main() {
go prefetchOverviewCache(prefetchCtx)
p := tea.NewProgram(newModel(abs, isOverview), tea.WithAltScreen())
- if err := p.Start(); err != nil {
+ if _, err := p.Run(); err != nil {
fmt.Fprintf(os.Stderr, "analyzer error: %v\n", err)
os.Exit(1)
}
@@ -359,7 +359,7 @@ func (m model) scanCmd(path string) tea.Cmd {
return scanResultMsg{result: result, err: nil}
}
- v, err, _ := scanGroup.Do(path, func() (interface{}, error) {
+ v, err, _ := scanGroup.Do(path, func() (any, error) {
return scanPathConcurrent(path, m.filesScanned, m.dirsScanned, m.bytesScanned, m.currentPath)
})
@@ -997,10 +997,7 @@ func (m *model) clampEntrySelection() {
m.selected = 0
}
viewport := calculateViewport(m.height, false)
- maxOffset := len(m.entries) - viewport
- if maxOffset < 0 {
- maxOffset = 0
- }
+ maxOffset := max(len(m.entries)-viewport, 0)
if m.offset > maxOffset {
m.offset = maxOffset
}
@@ -1025,10 +1022,7 @@ func (m *model) clampLargeSelection() {
m.largeSelected = 0
}
viewport := calculateViewport(m.height, true)
- maxOffset := len(m.largeFiles) - viewport
- if maxOffset < 0 {
- maxOffset = 0
- }
+ maxOffset := max(len(m.largeFiles)-viewport, 0)
if m.largeOffset > maxOffset {
m.largeOffset = maxOffset
}
diff --git a/cmd/analyze/scanner.go b/cmd/analyze/scanner.go
index 9654e59..b6ab09b 100644
--- a/cmd/analyze/scanner.go
+++ b/cmd/analyze/scanner.go
@@ -39,10 +39,7 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
heap.Init(largeFilesHeap)
// Worker pool sized for I/O-bound scanning.
- numWorkers := runtime.NumCPU() * cpuMultiplier
- if numWorkers < minWorkers {
- numWorkers = minWorkers
- }
+ numWorkers := max(runtime.NumCPU()*cpuMultiplier, minWorkers)
if numWorkers > maxWorkers {
numWorkers = maxWorkers
}
@@ -289,10 +286,7 @@ func calculateDirSizeFast(root string, filesScanned, dirsScanned, bytesScanned *
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
- concurrency := runtime.NumCPU() * 4
- if concurrency > 64 {
- concurrency = 64
- }
+ concurrency := min(runtime.NumCPU()*4, 64)
sem := make(chan struct{}, concurrency)
var walk func(string)
@@ -363,10 +357,9 @@ func findLargeFilesWithSpotlight(root string, minSize int64) []fileEntry {
return nil
}
- lines := strings.Split(strings.TrimSpace(string(output)), "\n")
var files []fileEntry
- for _, line := range lines {
+ for line := range strings.Lines(strings.TrimSpace(string(output))) {
if line == "" {
continue
}
@@ -413,8 +406,8 @@ func findLargeFilesWithSpotlight(root string, minSize int64) []fileEntry {
// isInFoldedDir checks if a path is inside a folded directory.
func isInFoldedDir(path string) bool {
- parts := strings.Split(path, string(os.PathSeparator))
- for _, part := range parts {
+ parts := strings.SplitSeq(path, string(os.PathSeparator))
+ for part := range parts {
if foldDirs[part] {
return true
}
@@ -432,10 +425,7 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, fil
var wg sync.WaitGroup
// Limit concurrent subdirectory scans.
- maxConcurrent := runtime.NumCPU() * 2
- if maxConcurrent > maxDirWorkers {
- maxConcurrent = maxDirWorkers
- }
+ maxConcurrent := min(runtime.NumCPU()*2, maxDirWorkers)
sem := make(chan struct{}, maxConcurrent)
for _, child := range children {
diff --git a/cmd/analyze/view.go b/cmd/analyze/view.go
index a434c71..6cdf400 100644
--- a/cmd/analyze/view.go
+++ b/cmd/analyze/view.go
@@ -100,14 +100,8 @@ func (m model) View() string {
fmt.Fprintln(&b, " No large files found (>=100MB)")
} else {
viewport := calculateViewport(m.height, true)
- start := m.largeOffset
- if start < 0 {
- start = 0
- }
- end := start + viewport
- if end > len(m.largeFiles) {
- end = len(m.largeFiles)
- }
+ start := max(m.largeOffset, 0)
+ end := min(start+viewport, len(m.largeFiles))
maxLargeSize := int64(1)
for _, file := range m.largeFiles {
if file.Size > maxLargeSize {
@@ -163,10 +157,7 @@ func (m model) View() string {
for idx, entry := range m.entries {
icon := "📁"
sizeVal := entry.Size
- barValue := sizeVal
- if barValue < 0 {
- barValue = 0
- }
+ barValue := max(sizeVal, 0)
var percent float64
if totalSize > 0 && sizeVal >= 0 {
percent = float64(sizeVal) / float64(totalSize) * 100
@@ -243,14 +234,8 @@ func (m model) View() string {
viewport := calculateViewport(m.height, false)
nameWidth := calculateNameWidth(m.width)
- start := m.offset
- if start < 0 {
- start = 0
- }
- end := start + viewport
- if end > len(m.entries) {
- end = len(m.entries)
- }
+ start := max(m.offset, 0)
+ end := min(start+viewport, len(m.entries))
for idx := start; idx < end; idx++ {
entry := m.entries[idx]
diff --git a/cmd/status/main.go b/cmd/status/main.go
index 4e152ee..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"
@@ -34,11 +37,53 @@ type model struct {
lastUpdated time.Time
collecting bool
animFrame int
+ 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: loadCatHidden(),
}
}
@@ -52,6 +97,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "q", "esc", "ctrl+c":
return m, tea.Quit
+ case "k":
+ // Toggle cat visibility and persist preference
+ m.catHidden = !m.catHidden
+ saveCatHidden(m.catHidden)
+ return m, nil
}
case tea.WindowSizeMsg:
m.width = msg.Width
@@ -89,7 +139,7 @@ func (m model) View() string {
return "Loading..."
}
- header := renderHeader(m.metrics, m.errMessage, m.animFrame, m.width)
+ 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)
@@ -104,10 +154,20 @@ func (m model) View() string {
}
rendered = append(rendered, renderCard(c, cardWidth, 0))
}
- return header + "\n" + lipgloss.JoinVertical(lipgloss.Left, rendered...)
+ 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
}
- return header + "\n" + renderTwoColumns(cards, m.width)
+ twoCol := renderTwoColumns(cards, m.width)
+ // Add extra newline if cat is hidden for better spacing
+ if m.catHidden {
+ return header + "\n\n" + twoCol
+ }
+ return header + "\n" + twoCol
}
func (m model) collectCmd() tea.Cmd {
@@ -127,16 +187,13 @@ func animTick() tea.Cmd {
func animTickWithSpeed(cpuUsage float64) tea.Cmd {
// Higher CPU = faster animation.
- interval := 300 - int(cpuUsage*2.5)
- if interval < 50 {
- interval = 50
- }
+ interval := max(300-int(cpuUsage*2.5), 50)
return tea.Tick(time.Duration(interval)*time.Millisecond, func(time.Time) tea.Msg { return animTickMsg{} })
}
func main() {
p := tea.NewProgram(newModel(), tea.WithAltScreen())
- if err := p.Start(); err != nil {
+ if _, err := p.Run(); err != nil {
fmt.Fprintf(os.Stderr, "system status error: %v\n", err)
os.Exit(1)
}
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_battery.go b/cmd/status/metrics_battery.go
index ecdb463..57f1f8b 100644
--- a/cmd/status/metrics_battery.go
+++ b/cmd/status/metrics_battery.go
@@ -68,11 +68,10 @@ func collectBatteries() (batts []BatteryStatus, err error) {
}
func parsePMSet(raw string, health string, cycles int, capacity int) []BatteryStatus {
- lines := strings.Split(raw, "\n")
var out []BatteryStatus
var timeLeft string
- for _, line := range lines {
+ for line := range strings.Lines(raw) {
// Time remaining.
if strings.Contains(line, "remaining") {
parts := strings.Fields(line)
@@ -128,8 +127,7 @@ func getCachedPowerData() (health string, cycles int, capacity int) {
return "", 0, 0
}
- lines := strings.Split(out, "\n")
- for _, line := range lines {
+ for line := range strings.Lines(out) {
lower := strings.ToLower(line)
if strings.Contains(lower, "cycle count") {
if _, after, found := strings.Cut(line, ":"); found {
@@ -183,8 +181,7 @@ func collectThermal() ThermalStatus {
// Fan info from cached system_profiler.
out := getSystemPowerOutput()
if out != "" {
- lines := strings.Split(out, "\n")
- for _, line := range lines {
+ 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 {
@@ -200,8 +197,7 @@ func collectThermal() ThermalStatus {
ctxPower, cancelPower := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancelPower()
if out, err := runCmd(ctxPower, "ioreg", "-rn", "AppleSmartBattery"); err == nil {
- lines := strings.Split(out, "\n")
- for _, line := range lines {
+ for line := range strings.Lines(out) {
line = strings.TrimSpace(line)
// Battery temperature ("Temperature" = 3055).
@@ -242,8 +238,9 @@ func collectThermal() ThermalStatus {
valStr, _, _ = strings.Cut(valStr, ",")
valStr, _, _ = strings.Cut(valStr, "}")
valStr = strings.TrimSpace(valStr)
- if powerMW, err := strconv.ParseFloat(valStr, 64); err == nil {
- thermal.BatteryPower = powerMW / 1000.0
+ // 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
}
}
}
diff --git a/cmd/status/metrics_bluetooth.go b/cmd/status/metrics_bluetooth.go
index f0c35a7..740c10c 100644
--- a/cmd/status/metrics_bluetooth.go
+++ b/cmd/status/metrics_bluetooth.go
@@ -68,13 +68,12 @@ func readBluetoothCTLDevices() ([]BluetoothDevice, error) {
}
func parseSPBluetooth(raw string) []BluetoothDevice {
- lines := strings.Split(raw, "\n")
var devices []BluetoothDevice
var currentName string
var connected bool
var battery string
- for _, line := range lines {
+ for line := range strings.Lines(raw) {
trim := strings.TrimSpace(line)
if len(trim) == 0 {
continue
@@ -112,10 +111,9 @@ func parseSPBluetooth(raw string) []BluetoothDevice {
}
func parseBluetoothctl(raw string) []BluetoothDevice {
- lines := strings.Split(raw, "\n")
var devices []BluetoothDevice
current := BluetoothDevice{}
- for _, line := range lines {
+ for line := range strings.Lines(raw) {
trim := strings.TrimSpace(line)
if strings.HasPrefix(trim, "Device ") {
if current.Name != "" {
@@ -123,8 +121,8 @@ func parseBluetoothctl(raw string) []BluetoothDevice {
}
current = BluetoothDevice{Name: strings.TrimPrefix(trim, "Device "), Connected: false}
}
- if strings.HasPrefix(trim, "Name:") {
- current.Name = strings.TrimSpace(strings.TrimPrefix(trim, "Name:"))
+ if after, ok := strings.CutPrefix(trim, "Name:"); ok {
+ current.Name = strings.TrimSpace(after)
}
if strings.HasPrefix(trim, "Connected:") {
current.Connected = strings.Contains(trim, "yes")
diff --git a/cmd/status/metrics_cpu.go b/cmd/status/metrics_cpu.go
index 59be505..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
@@ -119,7 +119,10 @@ func getCoreTopology() (pCores, eCores int) {
return 0, 0
}
- lines := strings.Split(strings.TrimSpace(out), "\n")
+ var lines []string
+ for line := range strings.Lines(strings.TrimSpace(out)) {
+ lines = append(lines, line)
+ }
if len(lines) < 4 {
return 0, 0
}
@@ -252,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_disk.go b/cmd/status/metrics_disk.go
index 3863aa0..9586fae 100644
--- a/cmd/status/metrics_disk.go
+++ b/cmd/status/metrics_disk.go
@@ -156,7 +156,7 @@ func isExternalDisk(device string) (bool, error) {
found bool
external bool
)
- for _, line := range strings.Split(out, "\n") {
+ for line := range strings.Lines(out) {
trim := strings.TrimSpace(line)
if strings.HasPrefix(trim, "Internal:") {
found = true
diff --git a/cmd/status/metrics_gpu.go b/cmd/status/metrics_gpu.go
index d4775f2..bb60235 100644
--- a/cmd/status/metrics_gpu.go
+++ b/cmd/status/metrics_gpu.go
@@ -61,9 +61,8 @@ func (c *Collector) collectGPU(now time.Time) ([]GPUStatus, error) {
return nil, err
}
- lines := strings.Split(strings.TrimSpace(out), "\n")
var gpus []GPUStatus
- for _, line := range lines {
+ for line := range strings.Lines(strings.TrimSpace(out)) {
fields := strings.Split(line, ",")
if len(fields) < 4 {
continue
diff --git a/cmd/status/metrics_hardware.go b/cmd/status/metrics_hardware.go
index 731d6a7..54ff7ba 100644
--- a/cmd/status/metrics_hardware.go
+++ b/cmd/status/metrics_hardware.go
@@ -28,8 +28,7 @@ func collectHardware(totalRAM uint64, disks []DiskStatus) HardwareInfo {
out, err := runCmd(ctx, "system_profiler", "SPHardwareDataType")
if err == nil {
- lines := strings.Split(out, "\n")
- for _, line := range lines {
+ for line := range strings.Lines(out) {
lower := strings.ToLower(strings.TrimSpace(line))
// Prefer "Model Name" over "Model Identifier".
if strings.Contains(lower, "model name:") {
@@ -85,10 +84,9 @@ func collectHardware(totalRAM uint64, disks []DiskStatus) HardwareInfo {
// parseRefreshRate extracts the highest refresh rate from system_profiler display output.
func parseRefreshRate(output string) string {
- lines := strings.Split(output, "\n")
maxHz := 0
- for _, line := range lines {
+ 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") {
@@ -100,8 +98,7 @@ func parseRefreshRate(output string) string {
}
continue
}
- if strings.HasSuffix(field, "hz") {
- numStr := strings.TrimSuffix(field, "hz")
+ if numStr, ok := strings.CutSuffix(field, "hz"); ok {
if numStr == "" && i > 0 {
numStr = fields[i-1]
}
@@ -133,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/metrics_memory.go b/cmd/status/metrics_memory.go
index 5851dd5..6cdd021 100644
--- a/cmd/status/metrics_memory.go
+++ b/cmd/status/metrics_memory.go
@@ -46,22 +46,22 @@ func getFileBackedMemory() uint64 {
// Parse page size from first line: "Mach Virtual Memory Statistics: (page size of 16384 bytes)"
var pageSize uint64 = 4096 // Default
- lines := strings.Split(out, "\n")
- if len(lines) > 0 {
- firstLine := lines[0]
- if strings.Contains(firstLine, "page size of") {
- if _, after, found := strings.Cut(firstLine, "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
+ 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."
- for _, line := range lines {
+ // Parse "File-backed pages: 388975."
if strings.Contains(line, "File-backed pages:") {
if _, after, found := strings.Cut(line, ":"); found {
numStr := strings.TrimSpace(after)
diff --git a/cmd/status/metrics_process.go b/cmd/status/metrics_process.go
index 5c497a4..b11f25c 100644
--- a/cmd/status/metrics_process.go
+++ b/cmd/status/metrics_process.go
@@ -21,15 +21,17 @@ func collectTopProcesses() []ProcessInfo {
return nil
}
- lines := strings.Split(strings.TrimSpace(out), "\n")
var procs []ProcessInfo
- for i, line := range lines {
+ 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
diff --git a/cmd/status/view.go b/cmd/status/view.go
index a5ef8ea..7ef56cd 100644
--- a/cmd/status/view.go
+++ b/cmd/status/view.go
@@ -32,7 +32,7 @@ const (
iconProcs = "❊"
)
-// Mole body frames.
+// Mole body frames (facing right).
var moleBody = [][]string{
{
` /\_/\`,
@@ -60,26 +60,60 @@ var moleBody = [][]string{
},
}
+// 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 {
- bodyIdx := animFrame % len(moleBody)
- body := moleBody[bodyIdx]
-
moleWidth := 15
- maxPos := termWidth - moleWidth
- if maxPos < 0 {
- maxPos = 0
- }
+ maxPos := max(termWidth-moleWidth, 0)
cycleLength := maxPos * 2
if cycleLength == 0 {
cycleLength = 1
}
pos := animFrame % cycleLength
- if pos > maxPos {
+ 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
@@ -96,7 +130,7 @@ type cardData struct {
lines []string
}
-func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int) string {
+func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int, catHidden bool) string {
title := titleStyle.Render("Mole Status")
scoreStyle := getScoreStyle(m.HealthScore)
@@ -134,24 +168,35 @@ func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int
headerLine := title + " " + scoreText + " " + strings.Join(infoParts, " · ")
- mole := getMoleFrame(animFrame, termWidth)
+ // 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 {
- 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)
}
}
@@ -197,10 +242,7 @@ func renderCPUCard(cpu CPUStatus) cardData {
}
sort.Slice(cores, func(i, j int) bool { return cores[i].val > cores[j].val })
- maxCores := 3
- if len(cores) < maxCores {
- maxCores = len(cores)
- }
+ 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))
@@ -219,28 +261,6 @@ func renderCPUCard(cpu CPUStatus) cardData {
return cardData{icon: iconCPU, title: "CPU", lines: lines}
}
-func renderGPUCard(gpus []GPUStatus) cardData {
- var lines []string
- if len(gpus) == 0 {
- lines = append(lines, subtleStyle.Render("No GPU detected"))
- } else {
- for _, g := range gpus {
- if g.Usage >= 0 {
- lines = append(lines, fmt.Sprintf("Total %s %5.1f%%", progressBar(g.Usage), g.Usage))
- }
- coreInfo := ""
- if g.CoreCount > 0 {
- coreInfo = fmt.Sprintf(" (%d cores)", g.CoreCount)
- }
- lines = append(lines, g.Name+coreInfo)
- if g.Usage < 0 {
- lines = append(lines, subtleStyle.Render("Run with sudo for usage metrics"))
- }
- }
- }
- return cardData{icon: iconGPU, title: "GPU", lines: lines}
-}
-
func renderMemoryCard(mem MemoryStatus) cardData {
// Check if swap is being used (or at least allocated).
hasSwap := mem.SwapTotal > 0 || mem.SwapUsed > 0
@@ -289,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))
@@ -356,10 +377,7 @@ func formatDiskLine(label string, d DiskStatus) string {
}
func ioBar(rate float64) string {
- filled := int(rate / 10.0)
- if filled > 5 {
- filled = 5
- }
+ filled := min(int(rate/10.0), 5)
if filled < 0 {
filled = 0
}
@@ -391,10 +409,7 @@ func renderProcessCard(procs []ProcessInfo) cardData {
}
func miniBar(percent float64) string {
- filled := int(percent / 20)
- if filled > 5 {
- filled = 5
- }
+ filled := min(int(percent/20), 5)
if filled < 0 {
filled = 0
}
@@ -437,10 +452,7 @@ func renderNetworkCard(netStats []NetworkStatus, proxy ProxyStatus) cardData {
}
func netBar(rate float64) string {
- filled := int(rate / 2.0)
- if filled > 5 {
- filled = 5
- }
+ filled := min(int(rate/2.0), 5)
if filled < 0 {
filled = 0
}
@@ -501,6 +513,7 @@ func renderBatteryCard(batts []BatteryStatus, thermal ThermalStatus) cardData {
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))
@@ -551,10 +564,7 @@ func renderSensorsCard(sensors []SensorReading) cardData {
func renderCard(data cardData, width int, height int) string {
titleText := data.icon + " " + data.title
- lineLen := width - lipgloss.Width(titleText) - 2
- if lineLen < 4 {
- lineLen = 4
- }
+ 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")
@@ -576,7 +586,7 @@ func progressBar(percent float64) string {
filled := int(percent / 100 * float64(total))
var builder strings.Builder
- for i := 0; i < total; i++ {
+ for i := range total {
if i < filled {
builder.WriteString("█")
} else {
@@ -597,7 +607,7 @@ func batteryProgressBar(percent float64) string {
filled := int(percent / 100 * float64(total))
var builder strings.Builder
- for i := 0; i < total; i++ {
+ for i := range total {
if i < filled {
builder.WriteString("█")
} else {
@@ -698,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/core/base.sh b/lib/core/base.sh
index ab39a66..5a455e9 100644
--- a/lib/core/base.sh
+++ b/lib/core/base.sh
@@ -70,6 +70,7 @@ declare -a DEFAULT_WHITELIST_PATTERNS=(
"$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
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/manage/update.sh b/lib/manage/update.sh
index 996a16e..b700f31 100644
--- a/lib/manage/update.sh
+++ b/lib/manage/update.sh
@@ -96,9 +96,8 @@ ask_for_updates() {
fi
echo ""
- echo -e "${YELLOW}Tip:${NC} Homebrew: brew upgrade / brew upgrade --cask"
- echo -e "${YELLOW}Tip:${NC} App Store: open App Store → Updates"
- echo -e "${YELLOW}Tip:${NC} macOS: System Settings → General → Software Update"
+ echo -e "${YELLOW}💡 Run ${GREEN}brew upgrade${YELLOW} to update${NC}"
+
return 1
}
diff --git a/lib/manage/whitelist.sh b/lib/manage/whitelist.sh
index 75f0762..e648e9e 100755
--- a/lib/manage/whitelist.sh
+++ b/lib/manage/whitelist.sh
@@ -85,7 +85,8 @@ Xcode archives (built app packages)|$HOME/Library/Developer/Xcode/Archives/*|ide
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 cache (IntelliJ, PyCharm, WebStorm)|$HOME/Library/Caches/JetBrains/*|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
diff --git a/mole b/mole
index 748a40e..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.19.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 'NR==1 && 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" {