mirror of
https://github.com/tw93/Mole.git
synced 2026-02-04 13:16:47 +00:00
Merge branch 'dev' of github.com:tw93/Mole into dev
This commit is contained in:
23
.github/workflows/check.yml
vendored
23
.github/workflows/check.yml
vendored
@@ -27,20 +27,25 @@ jobs:
|
||||
~/Library/Caches/Homebrew
|
||||
/usr/local/Cellar/shfmt
|
||||
/usr/local/Cellar/shellcheck
|
||||
key: ${{ runner.os }}-brew-quality-${{ hashFiles('**/Brewfile') }}
|
||||
/usr/local/Cellar/golangci-lint
|
||||
key: ${{ runner.os }}-brew-quality-v2-${{ hashFiles('**/Brewfile') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-brew-quality-
|
||||
${{ runner.os }}-brew-quality-v2-
|
||||
|
||||
- name: Install tools
|
||||
run: brew install shfmt shellcheck
|
||||
run: brew install shfmt shellcheck golangci-lint
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v5
|
||||
with:
|
||||
go-version: '1.24.6'
|
||||
|
||||
- name: Install goimports
|
||||
run: go install golang.org/x/tools/cmd/goimports@latest
|
||||
|
||||
- name: Format all code
|
||||
run: |
|
||||
export PATH=$(go env GOPATH)/bin:$PATH
|
||||
./scripts/check.sh --format
|
||||
|
||||
- name: Commit formatting changes
|
||||
@@ -75,12 +80,18 @@ jobs:
|
||||
~/Library/Caches/Homebrew
|
||||
/usr/local/Cellar/shfmt
|
||||
/usr/local/Cellar/shellcheck
|
||||
key: ${{ runner.os }}-brew-quality-${{ hashFiles('**/Brewfile') }}
|
||||
/usr/local/Cellar/golangci-lint
|
||||
key: ${{ runner.os }}-brew-quality-v2-${{ hashFiles('**/Brewfile') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-brew-quality-
|
||||
${{ runner.os }}-brew-quality-v2-
|
||||
|
||||
- name: Install tools
|
||||
run: brew install shfmt shellcheck
|
||||
run: brew install shfmt shellcheck golangci-lint
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v5
|
||||
with:
|
||||
go-version: '1.24.6'
|
||||
|
||||
- name: Run check script
|
||||
run: ./scripts/check.sh --no-format
|
||||
|
||||
62
.github/workflows/update-contributors.yml
vendored
Normal file
62
.github/workflows/update-contributors.yml
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
name: Update Contributors
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 0 * * 0" # Every Sunday at midnight UTC
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
update-contributors:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate contributors SVG
|
||||
uses: tw93/contributors-list@master
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
svgPath: CONTRIBUTORS.svg
|
||||
svgWidth: 1000
|
||||
avatarSize: 72
|
||||
avatarMargin: 45
|
||||
userNameHeight: 20
|
||||
noFetch: false
|
||||
noCommit: true
|
||||
truncate: 0
|
||||
includeBots: false
|
||||
excludeUsers: "github-actions web-flow dependabot claude"
|
||||
itemTemplate: |
|
||||
<g transform="translate({{ x }}, {{ y }})">
|
||||
<defs>
|
||||
<clipPath id="cp-{{ login }}">
|
||||
<circle cx="36" cy="36" r="36" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<a xlink:href="{{{ url }}}" href="{{{ url }}}" class="contributor-link" target="_blank" rel="nofollow sponsored" title="{{{ name }}}">
|
||||
<image width="72" height="72" xlink:href="{{{ avatar }}}" href="{{{ avatar }}}" clip-path="url(#cp-{{ login }})" />
|
||||
<text x="36" y="86" text-anchor="middle" alignment-baseline="middle" font-size="10" fill="#666" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif">{{{ name }}}</text>
|
||||
</a>
|
||||
</g>
|
||||
|
||||
- name: Commit & Push
|
||||
uses: stefanzweifel/git-auto-commit-action@v7
|
||||
with:
|
||||
commit_message: "chore: update contributors [skip ci]"
|
||||
file_pattern: CONTRIBUTORS.svg
|
||||
commit_user_name: github-actions[bot]
|
||||
commit_user_email: 41898282+github-actions[bot]@users.noreply.github.com
|
||||
commit_author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
|
||||
push_options: '--force'
|
||||
43
.golangci.yml
Normal file
43
.golangci.yml
Normal file
@@ -0,0 +1,43 @@
|
||||
# golangci-lint configuration for Mole
|
||||
# https://golangci-lint.run/usage/configuration/
|
||||
|
||||
version: "2"
|
||||
|
||||
run:
|
||||
timeout: 5m
|
||||
# Only lint Go code in cmd directory
|
||||
modules-download-mode: readonly
|
||||
|
||||
linters:
|
||||
enable:
|
||||
# Default linters
|
||||
- govet
|
||||
- staticcheck
|
||||
- errcheck
|
||||
- ineffassign
|
||||
- unused
|
||||
|
||||
settings:
|
||||
govet:
|
||||
enable-all: true
|
||||
disable:
|
||||
- shadow
|
||||
- fieldalignment # struct field alignment optimization is noisy
|
||||
errcheck:
|
||||
exclude-functions:
|
||||
- (io.Closer).Close
|
||||
- (*os/exec.Cmd).Run
|
||||
- (*os/exec.Cmd).Start
|
||||
staticcheck:
|
||||
checks: ["all", "-QF1003", "-SA9003"]
|
||||
|
||||
exclusions:
|
||||
rules:
|
||||
# Ignore certain patterns in test files
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- errcheck
|
||||
# Ignore errors from os.Remove in cleanup code
|
||||
- text: "os.Remove"
|
||||
linters:
|
||||
- errcheck
|
||||
@@ -4,7 +4,10 @@
|
||||
|
||||
```bash
|
||||
# Install development tools
|
||||
brew install shfmt shellcheck bats-core
|
||||
brew install shfmt shellcheck bats-core golangci-lint
|
||||
|
||||
# Install goimports for better Go formatting
|
||||
go install golang.org/x/tools/cmd/goimports@latest
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
323
CONTRIBUTORS.svg
Normal file
323
CONTRIBUTORS.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 255 KiB |
51
README.md
51
README.md
@@ -13,12 +13,12 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://cdn.tw93.fun/img/mole.jpeg" alt="Mole - 95.50GB freed" width="800" />
|
||||
<img src="https://cdn.tw93.fun/img/mole.jpeg" alt="Mole - 95.50GB freed" width="1000" />
|
||||
</p>
|
||||
|
||||
## 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.
|
||||
|
||||
<details>
|
||||
<summary><strong>Custom Scan Paths</strong></summary>
|
||||
@@ -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.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -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=<name>` 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=<name>` 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
|
||||
|
||||
<p align="center">
|
||||
<img src="https://cdn.tw93.fun/pic/lovemole.jpeg" alt="Community feedback on Mole" width="800" />
|
||||
</p>
|
||||
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.
|
||||
<a href="https://github.com/tw93/Mole/graphs/contributors">
|
||||
<img src="./CONTRIBUTORS.svg?v=2" width="1000" />
|
||||
</a>
|
||||
|
||||
Join thousands of users worldwide who trust Mole to keep their Macs clean and optimized.
|
||||
|
||||
<img src="https://cdn.tw93.fun/pic/lovemole.jpeg" alt="Community feedback on Mole" width="1000" />
|
||||
|
||||
## Support
|
||||
|
||||
<a href="https://miaoyan.app/cats.html?name=Mole"><img src="https://miaoyan.app/assets/sponsors.svg" width="1000px" /></a>
|
||||
|
||||
- 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 <a href="https://miaoyan.app/cats.html?name=Mole" target="_blank">this link</a> to keep our mascots purring.
|
||||
- Love Mole? <a href="https://miaoyan.app/cats.html?name=Mole" target="_blank">Buy Tw93 an ice-cold Coke</a> to keep the project alive and kicking! 🥤
|
||||
|
||||
<details>
|
||||
<summary><strong>Friends who bought me Coke</strong></summary>
|
||||
<br/>
|
||||
<a href="https://miaoyan.app/cats.html?name=Mole"><img src="https://miaoyan.app/assets/sponsors.svg" width="1000" /></a>
|
||||
</details>
|
||||
|
||||
## License
|
||||
|
||||
MIT License - feel free to enjoy and participate in open source.
|
||||
MIT License — feel free to enjoy and participate in open source.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -308,9 +308,8 @@ safe_clean() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "${TRACK_SECTION:-0}" == "1" && "${SECTION_ACTIVITY:-0}" == "0" ]]; then
|
||||
# Always stop spinner before outputting results
|
||||
stop_section_spinner
|
||||
fi
|
||||
|
||||
local description
|
||||
local -a targets
|
||||
|
||||
@@ -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")
|
||||
|
||||
105
bin/touchid.sh
105
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,6 +203,30 @@ disable_touchid() {
|
||||
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
|
||||
@@ -149,6 +247,11 @@ disable_touchid() {
|
||||
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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -251,6 +251,7 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
|
||||
Entries: entries,
|
||||
LargeFiles: largeFiles,
|
||||
TotalSize: total,
|
||||
TotalFiles: atomic.LoadInt64(filesScanned),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -286,9 +286,8 @@ func commandExists(name string) bool {
|
||||
return false
|
||||
}
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
// Treat LookPath panics as "missing".
|
||||
}
|
||||
_ = recover()
|
||||
}()
|
||||
_, err := exec.LookPath(name)
|
||||
return err == nil
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,13 +256,11 @@ 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
|
||||
fi
|
||||
safe_remove "$match" true || true
|
||||
done < <(command find "$base_dir" "${find_args[@]}" -print0 2> /dev/null || true)
|
||||
|
||||
@@ -298,13 +298,11 @@ 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
|
||||
fi
|
||||
safe_sudo_remove "$match" || true
|
||||
done < <(sudo find "$base_dir" "${find_args[@]}" -print0 2> /dev/null || true)
|
||||
|
||||
|
||||
@@ -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 $?
|
||||
|
||||
4
mole
4
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'
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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" {
|
||||
|
||||
Reference in New Issue
Block a user