1
0
mirror of https://github.com/tw93/Mole.git synced 2026-02-08 06:14:20 +00:00

chore: restructure windows branch (move windows/ content to root, remove macos files)

This commit is contained in:
Tw93
2026-01-10 13:23:29 +08:00
parent e84a457c2f
commit edf5ed09a9
140 changed files with 1472 additions and 34059 deletions

79
bin/analyze.ps1 Normal file
View File

@@ -0,0 +1,79 @@
# Mole - Analyze Command
# Disk space analyzer wrapper
#Requires -Version 5.1
param(
[Parameter(Position = 0)]
[string]$Path,
[switch]$ShowHelp
)
$ErrorActionPreference = "Stop"
# Script location
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$windowsDir = Split-Path -Parent $scriptDir
$binPath = Join-Path $windowsDir "bin\analyze.exe"
# Help
function Show-AnalyzeHelp {
$esc = [char]27
Write-Host ""
Write-Host "$esc[1;35mMole Analyze$esc[0m - Interactive disk space analyzer"
Write-Host ""
Write-Host "$esc[33mUsage:$esc[0m mole analyze [path]"
Write-Host ""
Write-Host "$esc[33mOptions:$esc[0m"
Write-Host " [path] Path to analyze (default: user profile)"
Write-Host " -ShowHelp Show this help message"
Write-Host ""
Write-Host "$esc[33mKeybindings:$esc[0m"
Write-Host " Up/Down Navigate entries"
Write-Host " Enter Enter directory"
Write-Host " Backspace Go back"
Write-Host " Space Multi-select"
Write-Host " d Delete selected"
Write-Host " f Toggle large files view"
Write-Host " o Open in Explorer"
Write-Host " r Refresh"
Write-Host " q Quit"
Write-Host ""
}
if ($ShowHelp) {
Show-AnalyzeHelp
return
}
# Check if binary exists
if (-not (Test-Path $binPath)) {
Write-Host "Building analyze tool..." -ForegroundColor Cyan
$cmdDir = Join-Path $windowsDir "cmd\analyze"
$binDir = Join-Path $windowsDir "bin"
if (-not (Test-Path $binDir)) {
New-Item -ItemType Directory -Path $binDir -Force | Out-Null
}
Push-Location $windowsDir
try {
$result = & go build -o "$binPath" "./cmd/analyze/" 2>&1
if ($LASTEXITCODE -ne 0) {
Write-Host "Failed to build analyze tool: $result" -ForegroundColor Red
Pop-Location
return
}
}
finally {
Pop-Location
}
}
# Set path environment variable if provided
if ($Path) {
$env:MO_ANALYZE_PATH = $Path
}
# Run the binary
& $binPath

View File

@@ -1,15 +0,0 @@
#!/bin/bash
# Mole - Analyze command.
# Runs the Go disk analyzer UI.
# Uses bundled analyze-go binary.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
GO_BIN="$SCRIPT_DIR/analyze-go"
if [[ -x "$GO_BIN" ]]; then
exec "$GO_BIN" "$@"
fi
echo "Bundled analyzer binary not found. Please reinstall Mole or run mo update to restore it." >&2
exit 1

View File

@@ -1,101 +0,0 @@
#!/bin/bash
set -euo pipefail
# Fix locale issues (similar to Issue #83)
export LC_ALL=C
export LANG=C
# Load common functions
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
source "$SCRIPT_DIR/lib/core/common.sh"
source "$SCRIPT_DIR/lib/core/sudo.sh"
source "$SCRIPT_DIR/lib/manage/update.sh"
source "$SCRIPT_DIR/lib/manage/autofix.sh"
source "$SCRIPT_DIR/lib/check/all.sh"
cleanup_all() {
stop_inline_spinner 2> /dev/null || true
stop_sudo_session
cleanup_temp_files
}
handle_interrupt() {
cleanup_all
exit 130
}
main() {
# Register unified cleanup handler
trap cleanup_all EXIT
trap handle_interrupt INT TERM
if [[ -t 1 ]]; then
clear
fi
printf '\n'
# Create temp files for parallel execution
local updates_file=$(mktemp_file)
local health_file=$(mktemp_file)
local security_file=$(mktemp_file)
local config_file=$(mktemp_file)
# Run all checks in parallel with spinner
if [[ -t 1 ]]; then
echo -ne "${PURPLE_BOLD}System Check${NC} "
start_inline_spinner "Running checks..."
else
echo -e "${PURPLE_BOLD}System Check${NC}"
echo ""
fi
# Parallel execution
{
check_all_updates > "$updates_file" 2>&1 &
check_system_health > "$health_file" 2>&1 &
check_all_security > "$security_file" 2>&1 &
check_all_config > "$config_file" 2>&1 &
wait
}
if [[ -t 1 ]]; then
stop_inline_spinner
printf '\n'
fi
# Display results
echo -e "${BLUE}${ICON_ARROW}${NC} System updates"
cat "$updates_file"
printf '\n'
echo -e "${BLUE}${ICON_ARROW}${NC} System health"
cat "$health_file"
printf '\n'
echo -e "${BLUE}${ICON_ARROW}${NC} Security posture"
cat "$security_file"
printf '\n'
echo -e "${BLUE}${ICON_ARROW}${NC} Configuration"
cat "$config_file"
# Show suggestions
show_suggestions
# Ask about auto-fix
if ask_for_auto_fix; then
perform_auto_fix
fi
# Ask about updates
if ask_for_updates; then
perform_updates
fi
printf '\n'
}
main "$@"

300
bin/clean.ps1 Normal file
View File

@@ -0,0 +1,300 @@
# Mole - Clean Command
# Deep cleanup for Windows with dry-run support and whitelist
#Requires -Version 5.1
[CmdletBinding()]
param(
[switch]$DryRun,
[switch]$System,
[switch]$DebugMode,
[switch]$Whitelist,
[switch]$ShowHelp
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
# Script location
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$libDir = Join-Path (Split-Path -Parent $scriptDir) "lib"
# Import core modules
. "$libDir\core\base.ps1"
. "$libDir\core\log.ps1"
. "$libDir\core\ui.ps1"
. "$libDir\core\file_ops.ps1"
# Import cleanup modules
. "$libDir\clean\user.ps1"
. "$libDir\clean\caches.ps1"
. "$libDir\clean\dev.ps1"
. "$libDir\clean\apps.ps1"
. "$libDir\clean\system.ps1"
# ============================================================================
# Configuration
# ============================================================================
$script:ExportListFile = "$env:USERPROFILE\.config\mole\clean-list.txt"
# ============================================================================
# Help
# ============================================================================
function Show-CleanHelp {
$esc = [char]27
Write-Host ""
Write-Host "$esc[1;35mMole Clean$esc[0m - Deep cleanup for Windows"
Write-Host ""
Write-Host "$esc[33mUsage:$esc[0m mole clean [options]"
Write-Host ""
Write-Host "$esc[33mOptions:$esc[0m"
Write-Host " -DryRun Preview changes without deleting (recommended first run)"
Write-Host " -System Include system-level cleanup (requires admin)"
Write-Host " -Whitelist Manage protected paths"
Write-Host " -DebugMode Enable debug logging"
Write-Host " -ShowHelp Show this help message"
Write-Host ""
Write-Host "$esc[33mExamples:$esc[0m"
Write-Host " mole clean -DryRun # Preview what would be cleaned"
Write-Host " mole clean # Run standard cleanup"
Write-Host " mole clean -System # Include system cleanup (as admin)"
Write-Host ""
}
# ============================================================================
# Whitelist Management
# ============================================================================
function Edit-Whitelist {
$whitelistPath = $script:Config.WhitelistFile
$whitelistDir = Split-Path -Parent $whitelistPath
# Ensure directory exists
if (-not (Test-Path $whitelistDir)) {
New-Item -ItemType Directory -Path $whitelistDir -Force | Out-Null
}
# Create default whitelist if doesn't exist
if (-not (Test-Path $whitelistPath)) {
$defaultContent = @"
# Mole Whitelist - Paths listed here will never be cleaned
# Use full paths or patterns with wildcards (*)
#
# Examples:
# C:\Users\YourName\Documents\ImportantProject
# C:\Users\*\AppData\Local\MyApp
# $env:LOCALAPPDATA\CriticalApp
#
# Add your protected paths below:
"@
Set-Content -Path $whitelistPath -Value $defaultContent
}
# Open in default editor
Write-Info "Opening whitelist file: $whitelistPath"
Start-Process notepad.exe -ArgumentList $whitelistPath -Wait
Write-Success "Whitelist saved"
}
# ============================================================================
# Cleanup Summary
# ============================================================================
function Show-CleanupSummary {
param(
[hashtable]$Stats,
[bool]$IsDryRun
)
$esc = [char]27
Write-Host ""
Write-Host "$esc[1;35m" -NoNewline
if ($IsDryRun) {
Write-Host "Dry run complete - no changes made" -NoNewline
}
else {
Write-Host "Cleanup complete" -NoNewline
}
Write-Host "$esc[0m"
Write-Host ""
if ($Stats.TotalSizeKB -gt 0) {
$sizeGB = [Math]::Round($Stats.TotalSizeKB / 1024 / 1024, 2)
if ($IsDryRun) {
Write-Host " Potential space: $esc[32m${sizeGB}GB$esc[0m"
Write-Host " Items found: $($Stats.FilesCleaned)"
Write-Host " Categories: $($Stats.TotalItems)"
Write-Host ""
Write-Host " Detailed list: $esc[90m$($script:ExportListFile)$esc[0m"
Write-Host " Run without -DryRun to apply cleanup"
}
else {
Write-Host " Space freed: $esc[32m${sizeGB}GB$esc[0m"
Write-Host " Items cleaned: $($Stats.FilesCleaned)"
Write-Host " Categories: $($Stats.TotalItems)"
Write-Host ""
Write-Host " Free space now: $(Get-FreeSpace)"
}
}
else {
if ($IsDryRun) {
Write-Host " No significant reclaimable space detected."
}
else {
Write-Host " System was already clean; no additional space freed."
}
Write-Host " Free space now: $(Get-FreeSpace)"
}
Write-Host ""
}
# ============================================================================
# Main Cleanup Flow
# ============================================================================
function Start-Cleanup {
param(
[bool]$IsDryRun,
[bool]$IncludeSystem
)
$esc = [char]27
# Clear screen
Clear-Host
Write-Host ""
Write-Host "$esc[1;35mClean Your Windows$esc[0m"
Write-Host ""
# Show mode
if ($IsDryRun) {
Write-Host "$esc[33mDry Run Mode$esc[0m - Preview only, no deletions"
Write-Host ""
# Prepare export file
$exportDir = Split-Path -Parent $script:ExportListFile
if (-not (Test-Path $exportDir)) {
New-Item -ItemType Directory -Path $exportDir -Force | Out-Null
}
$header = @"
# Mole Cleanup Preview - $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')
#
# How to protect files:
# 1. Copy any path below to $($script:Config.WhitelistFile)
# 2. Run: mole clean -Whitelist
#
"@
Set-Content -Path $script:ExportListFile -Value $header
}
else {
Write-Host "$esc[90m$($script:Icons.Solid) Use -DryRun to preview, -Whitelist to manage protected paths$esc[0m"
Write-Host ""
}
# System cleanup confirmation
if ($IncludeSystem -and -not $IsDryRun) {
if (-not (Test-IsAdmin)) {
Write-MoleWarning "System cleanup requires administrator privileges"
Write-Host " Run PowerShell as Administrator for full cleanup"
Write-Host ""
$IncludeSystem = $false
}
else {
Write-Host "$esc[32m$($script:Icons.Success)$esc[0m Running with Administrator privileges"
Write-Host ""
}
}
# Show system info
$winVer = Get-WindowsVersion
Write-Host "$esc[34m$($script:Icons.Admin)$esc[0m $($winVer.Name) | Free space: $(Get-FreeSpace)"
Write-Host ""
# Reset stats
Reset-CleanupStats
Set-DryRunMode -Enabled $IsDryRun
# Run cleanup modules
try {
# User essentials (temp, logs, etc.)
Invoke-UserCleanup -TempDaysOld 7 -LogDaysOld 7
# Browser caches
Clear-BrowserCaches
# Application caches
Clear-AppCaches
# Developer tools
Invoke-DevToolsCleanup
# Applications cleanup
Invoke-AppCleanup
# System cleanup (if requested and admin)
if ($IncludeSystem -and (Test-IsAdmin)) {
Invoke-SystemCleanup
}
}
catch {
Write-MoleError "Cleanup error: $_"
}
# Get final stats
$stats = Get-CleanupStats
# Show summary
Show-CleanupSummary -Stats $stats -IsDryRun $IsDryRun
}
# ============================================================================
# Main Entry Point
# ============================================================================
function Main {
# Enable debug if requested
if ($DebugMode) {
$env:MOLE_DEBUG = "1"
$DebugPreference = "Continue"
}
# Show help
if ($ShowHelp) {
Show-CleanHelp
return
}
# Manage whitelist
if ($Whitelist) {
Edit-Whitelist
return
}
# Set dry-run mode
if ($DryRun) {
$env:MOLE_DRY_RUN = "1"
}
else {
$env:MOLE_DRY_RUN = "0"
}
# Run cleanup
try {
Start-Cleanup -IsDryRun $DryRun -IncludeSystem $System
}
finally {
# Cleanup temp files
Clear-TempFiles
}
}
# Run main
Main

File diff suppressed because it is too large Load Diff

View File

@@ -1,251 +0,0 @@
#!/bin/bash
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
source "$ROOT_DIR/lib/core/common.sh"
source "$ROOT_DIR/lib/core/commands.sh"
command_names=()
for entry in "${MOLE_COMMANDS[@]}"; do
command_names+=("${entry%%:*}")
done
command_words="${command_names[*]}"
emit_zsh_subcommands() {
for entry in "${MOLE_COMMANDS[@]}"; do
printf " '%s:%s'\n" "${entry%%:*}" "${entry#*:}"
done
}
emit_fish_completions() {
local cmd="$1"
for entry in "${MOLE_COMMANDS[@]}"; do
local name="${entry%%:*}"
local desc="${entry#*:}"
printf 'complete -c %s -n "__fish_mole_no_subcommand" -a %s -d "%s"\n' "$cmd" "$name" "$desc"
done
printf '\n'
printf 'complete -c %s -n "not __fish_mole_no_subcommand" -a bash -d "generate bash completion" -n "__fish_see_subcommand_path completion"\n' "$cmd"
printf 'complete -c %s -n "not __fish_mole_no_subcommand" -a zsh -d "generate zsh completion" -n "__fish_see_subcommand_path completion"\n' "$cmd"
printf 'complete -c %s -n "not __fish_mole_no_subcommand" -a fish -d "generate fish completion" -n "__fish_see_subcommand_path completion"\n' "$cmd"
}
# Auto-install mode when run without arguments
if [[ $# -eq 0 ]]; then
# Detect current shell
current_shell="${SHELL##*/}"
if [[ -z "$current_shell" ]]; then
current_shell="$(ps -p "$PPID" -o comm= 2> /dev/null | awk '{print $1}')"
fi
completion_name=""
if command -v mole > /dev/null 2>&1; then
completion_name="mole"
elif command -v mo > /dev/null 2>&1; then
completion_name="mo"
fi
case "$current_shell" in
bash)
config_file="${HOME}/.bashrc"
[[ -f "${HOME}/.bash_profile" ]] && config_file="${HOME}/.bash_profile"
# shellcheck disable=SC2016
completion_line='if output="$('"$completion_name"' completion bash 2>/dev/null)"; then eval "$output"; fi'
;;
zsh)
config_file="${HOME}/.zshrc"
# shellcheck disable=SC2016
completion_line='if output="$('"$completion_name"' completion zsh 2>/dev/null)"; then eval "$output"; fi'
;;
fish)
config_file="${HOME}/.config/fish/config.fish"
# shellcheck disable=SC2016
completion_line='set -l output ('"$completion_name"' completion fish 2>/dev/null); and echo "$output" | source'
;;
*)
log_error "Unsupported shell: $current_shell"
echo " mole completion <bash|zsh|fish>"
exit 1
;;
esac
if [[ -z "$completion_name" ]]; then
if [[ -f "$config_file" ]] && grep -Eq "(^# Mole shell completion$|(mole|mo)[[:space:]]+completion)" "$config_file" 2> /dev/null; then
original_mode=""
original_mode="$(stat -f '%Mp%Lp' "$config_file" 2> /dev/null || true)"
temp_file="$(mktemp)"
grep -Ev "(^# Mole shell completion$|(mole|mo)[[:space:]]+completion)" "$config_file" > "$temp_file" || true
mv "$temp_file" "$config_file"
if [[ -n "$original_mode" ]]; then
chmod "$original_mode" "$config_file" 2> /dev/null || true
fi
echo -e "${GREEN}${ICON_SUCCESS}${NC} Removed stale completion entries from $config_file"
echo ""
fi
log_error "mole not found in PATH - install Mole before enabling completion"
exit 1
fi
# Check if already installed and normalize to latest line
if [[ -f "$config_file" ]] && grep -Eq "(mole|mo)[[:space:]]+completion" "$config_file" 2> /dev/null; then
original_mode=""
original_mode="$(stat -f '%Mp%Lp' "$config_file" 2> /dev/null || true)"
temp_file="$(mktemp)"
grep -Ev "(^# Mole shell completion$|(mole|mo)[[:space:]]+completion)" "$config_file" > "$temp_file" || true
mv "$temp_file" "$config_file"
if [[ -n "$original_mode" ]]; then
chmod "$original_mode" "$config_file" 2> /dev/null || true
fi
{
echo ""
echo "# Mole shell completion"
echo "$completion_line"
} >> "$config_file"
echo ""
echo -e "${GREEN}${ICON_SUCCESS}${NC} Shell completion updated in $config_file"
echo ""
exit 0
fi
# Prompt user for installation
echo ""
echo -e "${GRAY}Will add to ${config_file}:${NC}"
echo " $completion_line"
echo ""
echo -ne "${PURPLE}${ICON_ARROW}${NC} Enable completion for ${GREEN}${current_shell}${NC}? ${GRAY}Enter confirm / Q cancel${NC}: "
IFS= read -r -s -n1 key || key=""
drain_pending_input
echo ""
case "$key" in
$'\e' | [Qq] | [Nn])
echo -e "${YELLOW}Cancelled${NC}"
exit 0
;;
"" | $'\n' | $'\r' | [Yy]) ;;
*)
log_error "Invalid key"
exit 1
;;
esac
# Create config file if it doesn't exist
if [[ ! -f "$config_file" ]]; then
mkdir -p "$(dirname "$config_file")"
touch "$config_file"
fi
# Remove previous Mole completion lines to avoid duplicates
if [[ -f "$config_file" ]]; then
original_mode=""
original_mode="$(stat -f '%Mp%Lp' "$config_file" 2> /dev/null || true)"
temp_file="$(mktemp)"
grep -Ev "(^# Mole shell completion$|(mole|mo)[[:space:]]+completion)" "$config_file" > "$temp_file" || true
mv "$temp_file" "$config_file"
if [[ -n "$original_mode" ]]; then
chmod "$original_mode" "$config_file" 2> /dev/null || true
fi
fi
# Add completion line
{
echo ""
echo "# Mole shell completion"
echo "$completion_line"
} >> "$config_file"
echo -e "${GREEN}${ICON_SUCCESS}${NC} Completion added to $config_file"
echo ""
echo ""
echo -e "${GRAY}To activate now:${NC}"
echo -e " ${GREEN}source $config_file${NC}"
exit 0
fi
case "$1" in
bash)
cat << EOF
_mole_completions()
{
local cur_word prev_word
cur_word="\${COMP_WORDS[\$COMP_CWORD]}"
prev_word="\${COMP_WORDS[\$COMP_CWORD-1]}"
if [ "\$COMP_CWORD" -eq 1 ]; then
COMPREPLY=( \$(compgen -W "$command_words" -- "\$cur_word") )
else
case "\$prev_word" in
completion)
COMPREPLY=( \$(compgen -W "bash zsh fish" -- "\$cur_word") )
;;
*)
COMPREPLY=()
;;
esac
fi
}
complete -F _mole_completions mole mo
EOF
;;
zsh)
printf '#compdef mole mo\n\n'
printf '_mole() {\n'
printf ' local -a subcommands\n'
printf ' subcommands=(\n'
emit_zsh_subcommands
printf ' )\n'
printf " _describe 'subcommand' subcommands\n"
printf '}\n\n'
printf 'compdef _mole mole mo\n'
;;
fish)
printf '# Completions for mole\n'
emit_fish_completions mole
printf '\n# Completions for mo (alias)\n'
emit_fish_completions mo
printf '\nfunction __fish_mole_no_subcommand\n'
printf ' for i in (commandline -opc)\n'
# shellcheck disable=SC2016
printf ' if contains -- $i %s\n' "$command_words"
printf ' return 1\n'
printf ' end\n'
printf ' end\n'
printf ' return 0\n'
printf 'end\n\n'
printf 'function __fish_see_subcommand_path\n'
printf ' string match -q -- "completion" (commandline -opc)[1]\n'
printf 'end\n'
;;
*)
cat << 'EOF'
Usage: mole completion [bash|zsh|fish]
Setup shell tab completion for mole and mo commands.
Auto-install:
mole completion # Auto-detect shell and install
Manual install:
mole completion bash # Generate bash completion script
mole completion zsh # Generate zsh completion script
mole completion fish # Generate fish completion script
Examples:
# Auto-install (recommended)
mole completion
# Manual install - Bash
eval "$(mole completion bash)"
# Manual install - Zsh
eval "$(mole completion zsh)"
# Manual install - Fish
mole completion fish | source
EOF
exit 1
;;
esac

View File

@@ -1,704 +0,0 @@
#!/bin/bash
# Mole - Installer command
# Find and remove installer files - .dmg, .pkg, .mpkg, .iso, .xip, .zip
set -euo pipefail
# shellcheck disable=SC2154
# External variables set by menu_paginated.sh and environment
declare MOLE_SELECTION_RESULT
declare MOLE_INSTALLER_SCAN_MAX_DEPTH
export LC_ALL=C
export LANG=C
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../lib/core/common.sh"
source "$SCRIPT_DIR/../lib/ui/menu_paginated.sh"
cleanup() {
if [[ "${IN_ALT_SCREEN:-0}" == "1" ]]; then
leave_alt_screen
IN_ALT_SCREEN=0
fi
show_cursor
cleanup_temp_files
}
trap cleanup EXIT
trap 'trap - EXIT; cleanup; exit 130' INT TERM
# Scan configuration
readonly INSTALLER_SCAN_MAX_DEPTH_DEFAULT=2
readonly INSTALLER_SCAN_PATHS=(
"$HOME/Downloads"
"$HOME/Desktop"
"$HOME/Documents"
"$HOME/Public"
"$HOME/Library/Downloads"
"/Users/Shared"
"/Users/Shared/Downloads"
"$HOME/Library/Caches/Homebrew"
"$HOME/Library/Mobile Documents/com~apple~CloudDocs/Downloads"
"$HOME/Library/Containers/com.apple.mail/Data/Library/Mail Downloads"
"$HOME/Library/Application Support/Telegram Desktop"
"$HOME/Downloads/Telegram Desktop"
)
readonly MAX_ZIP_ENTRIES=50
ZIP_LIST_CMD=()
IN_ALT_SCREEN=0
if command -v zipinfo > /dev/null 2>&1; then
ZIP_LIST_CMD=(zipinfo -1)
elif command -v unzip > /dev/null 2>&1; then
ZIP_LIST_CMD=(unzip -Z -1)
fi
TERMINAL_WIDTH=0
# Check for installer payloads inside ZIP - check first N entries for installer patterns
is_installer_zip() {
local zip="$1"
local cap="$MAX_ZIP_ENTRIES"
[[ ${#ZIP_LIST_CMD[@]} -gt 0 ]] || return 1
if ! "${ZIP_LIST_CMD[@]}" "$zip" 2> /dev/null |
head -n "$cap" |
awk '
/\.(app|pkg|dmg|xip)(\/|$)/ { found=1; exit 0 }
END { exit found ? 0 : 1 }
'; then
return 1
fi
return 0
}
handle_candidate_file() {
local file="$1"
[[ -L "$file" ]] && return 0 # Skip symlinks explicitly
case "$file" in
*.dmg | *.pkg | *.mpkg | *.iso | *.xip)
echo "$file"
;;
*.zip)
[[ -r "$file" ]] || return 0
if is_installer_zip "$file" 2> /dev/null; then
echo "$file"
fi
;;
esac
}
scan_installers_in_path() {
local path="$1"
local max_depth="${MOLE_INSTALLER_SCAN_MAX_DEPTH:-$INSTALLER_SCAN_MAX_DEPTH_DEFAULT}"
[[ -d "$path" ]] || return 0
local file
if command -v fd > /dev/null 2>&1; then
while IFS= read -r file; do
handle_candidate_file "$file"
done < <(
fd --no-ignore --hidden --type f --max-depth "$max_depth" \
-e dmg -e pkg -e mpkg -e iso -e xip -e zip \
. "$path" 2> /dev/null || true
)
else
while IFS= read -r file; do
handle_candidate_file "$file"
done < <(
find "$path" -maxdepth "$max_depth" -type f \
\( -name '*.dmg' -o -name '*.pkg' -o -name '*.mpkg' \
-o -name '*.iso' -o -name '*.xip' -o -name '*.zip' \) \
2> /dev/null || true
)
fi
}
scan_all_installers() {
for path in "${INSTALLER_SCAN_PATHS[@]}"; do
scan_installers_in_path "$path"
done
}
# Initialize stats
declare -i total_deleted=0
declare -i total_size_freed_kb=0
# Global arrays for installer data
declare -a INSTALLER_PATHS=()
declare -a INSTALLER_SIZES=()
declare -a INSTALLER_SOURCES=()
declare -a DISPLAY_NAMES=()
# Get source directory display name - for example "Downloads" or "Desktop"
get_source_display() {
local file_path="$1"
local dir_path="${file_path%/*}"
# Match against known paths and return friendly names
case "$dir_path" in
"$HOME/Downloads"*) echo "Downloads" ;;
"$HOME/Desktop"*) echo "Desktop" ;;
"$HOME/Documents"*) echo "Documents" ;;
"$HOME/Public"*) echo "Public" ;;
"$HOME/Library/Downloads"*) echo "Library" ;;
"/Users/Shared"*) echo "Shared" ;;
"$HOME/Library/Caches/Homebrew"*) echo "Homebrew" ;;
"$HOME/Library/Mobile Documents/com~apple~CloudDocs/Downloads"*) echo "iCloud" ;;
"$HOME/Library/Containers/com.apple.mail"*) echo "Mail" ;;
*"Telegram Desktop"*) echo "Telegram" ;;
*) echo "${dir_path##*/}" ;;
esac
}
get_terminal_width() {
if [[ $TERMINAL_WIDTH -le 0 ]]; then
TERMINAL_WIDTH=$(tput cols 2> /dev/null || echo 80)
fi
echo "$TERMINAL_WIDTH"
}
# Format installer display with alignment - similar to purge command
format_installer_display() {
local filename="$1"
local size_str="$2"
local source="$3"
# Terminal width for alignment
local terminal_width
terminal_width=$(get_terminal_width)
local fixed_width=24 # Reserve for size and source
local available_width=$((terminal_width - fixed_width))
# Bounds check: 20-40 chars for filename
[[ $available_width -lt 20 ]] && available_width=20
[[ $available_width -gt 40 ]] && available_width=40
# Truncate filename if needed
local truncated_name
truncated_name=$(truncate_by_display_width "$filename" "$available_width")
local current_width
current_width=$(get_display_width "$truncated_name")
local char_count=${#truncated_name}
local padding=$((available_width - current_width))
local printf_width=$((char_count + padding))
# Format: "filename size | source"
printf "%-*s %8s | %-10s" "$printf_width" "$truncated_name" "$size_str" "$source"
}
# Collect all installers with their metadata
collect_installers() {
# Clear previous results
INSTALLER_PATHS=()
INSTALLER_SIZES=()
INSTALLER_SOURCES=()
DISPLAY_NAMES=()
# Start scanning with spinner
if [[ -t 1 ]]; then
start_inline_spinner "Scanning for installers..."
fi
# Start debug session
debug_operation_start "Collect Installers" "Scanning for redundant installer files"
# Scan all paths, deduplicate, and sort results
local -a all_files=()
while IFS= read -r file; do
[[ -z "$file" ]] && continue
all_files+=("$file")
debug_file_action "Found installer" "$file"
done < <(scan_all_installers | sort -u)
if [[ -t 1 ]]; then
stop_inline_spinner
fi
if [[ ${#all_files[@]} -eq 0 ]]; then
if [[ "${IN_ALT_SCREEN:-0}" != "1" ]]; then
echo -e "${GREEN}${ICON_SUCCESS}${NC} Great! No installer files to clean"
fi
return 1
fi
# Calculate sizes with spinner
if [[ -t 1 ]]; then
start_inline_spinner "Calculating sizes..."
fi
# Process each installer
for file in "${all_files[@]}"; do
# Calculate file size
local file_size=0
if [[ -f "$file" ]]; then
file_size=$(get_file_size "$file")
fi
# Get source directory
local source
source=$(get_source_display "$file")
# Format human readable size
local size_human
size_human=$(bytes_to_human "$file_size")
# Get display filename - strip Homebrew hash prefix if present
local display_name
display_name=$(basename "$file")
if [[ "$source" == "Homebrew" ]]; then
# Homebrew names often look like: sha256--name--version
# Strip the leading hash if it matches [0-9a-f]{64}--
if [[ "$display_name" =~ ^[0-9a-f]{64}--(.*) ]]; then
display_name="${BASH_REMATCH[1]}"
fi
fi
# Format display with alignment
local display
display=$(format_installer_display "$display_name" "$size_human" "$source")
# Store installer data in parallel arrays
INSTALLER_PATHS+=("$file")
INSTALLER_SIZES+=("$file_size")
INSTALLER_SOURCES+=("$source")
DISPLAY_NAMES+=("$display")
done
if [[ -t 1 ]]; then
stop_inline_spinner
fi
return 0
}
# Installer selector with Select All / Invert support
select_installers() {
local -a items=("$@")
local total_items=${#items[@]}
local clear_line=$'\r\033[2K'
if [[ $total_items -eq 0 ]]; then
return 1
fi
# Calculate items per page based on terminal height
_get_items_per_page() {
local term_height=24
if [[ -t 0 ]] || [[ -t 2 ]]; then
term_height=$(stty size < /dev/tty 2> /dev/null | awk '{print $1}')
fi
if [[ -z "$term_height" || $term_height -le 0 ]]; then
if command -v tput > /dev/null 2>&1; then
term_height=$(tput lines 2> /dev/null || echo "24")
else
term_height=24
fi
fi
local reserved=6
local available=$((term_height - reserved))
if [[ $available -lt 3 ]]; then
echo 3
elif [[ $available -gt 50 ]]; then
echo 50
else
echo "$available"
fi
}
local items_per_page=$(_get_items_per_page)
local cursor_pos=0
local top_index=0
# Initialize selection (all unselected by default)
local -a selected=()
for ((i = 0; i < total_items; i++)); do
selected[i]=false
done
local original_stty=""
if [[ -t 0 ]] && command -v stty > /dev/null 2>&1; then
original_stty=$(stty -g 2> /dev/null || echo "")
fi
restore_terminal() {
trap - EXIT INT TERM
if [[ "${IN_ALT_SCREEN:-0}" == "1" ]]; then
leave_alt_screen
IN_ALT_SCREEN=0
fi
show_cursor
if [[ -n "${original_stty:-}" ]]; then
stty "${original_stty}" 2> /dev/null || stty sane 2> /dev/null || true
fi
}
handle_interrupt() {
restore_terminal
exit 130
}
draw_menu() {
items_per_page=$(_get_items_per_page)
local max_top_index=0
if [[ $total_items -gt $items_per_page ]]; then
max_top_index=$((total_items - items_per_page))
fi
if [[ $top_index -gt $max_top_index ]]; then
top_index=$max_top_index
fi
if [[ $top_index -lt 0 ]]; then
top_index=0
fi
local visible_count=$((total_items - top_index))
[[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page
if [[ $cursor_pos -gt $((visible_count - 1)) ]]; then
cursor_pos=$((visible_count - 1))
fi
if [[ $cursor_pos -lt 0 ]]; then
cursor_pos=0
fi
printf "\033[H"
# Calculate selected size and count
local selected_size=0
local selected_count=0
for ((i = 0; i < total_items; i++)); do
if [[ ${selected[i]} == true ]]; then
selected_size=$((selected_size + ${INSTALLER_SIZES[i]:-0}))
((selected_count++))
fi
done
local selected_human
selected_human=$(bytes_to_human "$selected_size")
# Show position indicator if scrolling is needed
local scroll_indicator=""
if [[ $total_items -gt $items_per_page ]]; then
local current_pos=$((top_index + cursor_pos + 1))
scroll_indicator=" ${GRAY}[${current_pos}/${total_items}]${NC}"
fi
printf "${PURPLE_BOLD}Select Installers to Remove${NC}%s ${GRAY}- ${selected_human} ($selected_count selected)${NC}\n" "$scroll_indicator"
printf "%s\n" "$clear_line"
# Calculate visible range
local end_index=$((top_index + visible_count))
# Draw only visible items
for ((i = top_index; i < end_index; i++)); do
local checkbox="$ICON_EMPTY"
[[ ${selected[i]} == true ]] && checkbox="$ICON_SOLID"
local rel_pos=$((i - top_index))
if [[ $rel_pos -eq $cursor_pos ]]; then
printf "%s${CYAN}${ICON_ARROW} %s %s${NC}\n" "$clear_line" "$checkbox" "${items[i]}"
else
printf "%s %s %s\n" "$clear_line" "$checkbox" "${items[i]}"
fi
done
# Fill empty slots
local items_shown=$visible_count
for ((i = items_shown; i < items_per_page; i++)); do
printf "%s\n" "$clear_line"
done
printf "%s\n" "$clear_line"
printf "%s${GRAY}${ICON_NAV_UP}${ICON_NAV_DOWN} | Space Select | Enter Confirm | A All | I Invert | Q Quit${NC}\n" "$clear_line"
}
trap restore_terminal EXIT
trap handle_interrupt INT TERM
stty -echo -icanon intr ^C 2> /dev/null || true
hide_cursor
if [[ -t 1 ]]; then
printf "\033[2J\033[H" >&2
fi
# Main loop
while true; do
draw_menu
IFS= read -r -s -n1 key || key=""
case "$key" in
$'\x1b')
IFS= read -r -s -n1 -t 1 key2 || key2=""
if [[ "$key2" == "[" ]]; then
IFS= read -r -s -n1 -t 1 key3 || key3=""
case "$key3" in
A) # Up arrow
if [[ $cursor_pos -gt 0 ]]; then
((cursor_pos--))
elif [[ $top_index -gt 0 ]]; then
((top_index--))
fi
;;
B) # Down arrow
local absolute_index=$((top_index + cursor_pos))
local last_index=$((total_items - 1))
if [[ $absolute_index -lt $last_index ]]; then
local visible_count=$((total_items - top_index))
[[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page
if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then
((cursor_pos++))
elif [[ $((top_index + visible_count)) -lt $total_items ]]; then
((top_index++))
fi
fi
;;
esac
else
# ESC alone
restore_terminal
return 1
fi
;;
" ") # Space - toggle current item
local idx=$((top_index + cursor_pos))
if [[ ${selected[idx]} == true ]]; then
selected[idx]=false
else
selected[idx]=true
fi
;;
"a" | "A") # Select all
for ((i = 0; i < total_items; i++)); do
selected[i]=true
done
;;
"i" | "I") # Invert selection
for ((i = 0; i < total_items; i++)); do
if [[ ${selected[i]} == true ]]; then
selected[i]=false
else
selected[i]=true
fi
done
;;
"q" | "Q" | $'\x03') # Quit or Ctrl-C
restore_terminal
return 1
;;
"" | $'\n' | $'\r') # Enter - confirm
MOLE_SELECTION_RESULT=""
for ((i = 0; i < total_items; i++)); do
if [[ ${selected[i]} == true ]]; then
[[ -n "$MOLE_SELECTION_RESULT" ]] && MOLE_SELECTION_RESULT+=","
MOLE_SELECTION_RESULT+="$i"
fi
done
restore_terminal
return 0
;;
esac
done
}
# Show menu for user selection
show_installer_menu() {
if [[ ${#DISPLAY_NAMES[@]} -eq 0 ]]; then
return 1
fi
echo ""
MOLE_SELECTION_RESULT=""
if ! select_installers "${DISPLAY_NAMES[@]}"; then
return 1
fi
return 0
}
# Delete selected installers
delete_selected_installers() {
# Parse selection indices
local -a selected_indices=()
[[ -n "$MOLE_SELECTION_RESULT" ]] && IFS=',' read -ra selected_indices <<< "$MOLE_SELECTION_RESULT"
if [[ ${#selected_indices[@]} -eq 0 ]]; then
return 1
fi
# Calculate total size for confirmation
local confirm_size=0
for idx in "${selected_indices[@]}"; do
if [[ "$idx" =~ ^[0-9]+$ ]] && [[ $idx -lt ${#INSTALLER_SIZES[@]} ]]; then
confirm_size=$((confirm_size + ${INSTALLER_SIZES[$idx]:-0}))
fi
done
local confirm_human
confirm_human=$(bytes_to_human "$confirm_size")
# Show files to be deleted
echo -e "${PURPLE_BOLD}Files to be removed:${NC}"
for idx in "${selected_indices[@]}"; do
if [[ "$idx" =~ ^[0-9]+$ ]] && [[ $idx -lt ${#INSTALLER_PATHS[@]} ]]; then
local file_path="${INSTALLER_PATHS[$idx]}"
local file_size="${INSTALLER_SIZES[$idx]}"
local size_human
size_human=$(bytes_to_human "$file_size")
echo -e " ${GREEN}${ICON_SUCCESS}${NC} $(basename "$file_path") ${GRAY}(${size_human})${NC}"
fi
done
# Confirm deletion
echo ""
echo -ne "${PURPLE}${ICON_ARROW}${NC} Delete ${#selected_indices[@]} installer(s) (${confirm_human}) ${GREEN}Enter${NC} confirm, ${GRAY}ESC${NC} cancel: "
IFS= read -r -s -n1 confirm || confirm=""
case "$confirm" in
$'\e' | q | Q)
return 1
;;
"" | $'\n' | $'\r')
printf "\r\033[K" # Clear prompt line
echo "" # Single line break
;;
*)
return 1
;;
esac
# Delete each selected installer with spinner
total_deleted=0
total_size_freed_kb=0
if [[ -t 1 ]]; then
start_inline_spinner "Removing installers..."
fi
for idx in "${selected_indices[@]}"; do
if [[ ! "$idx" =~ ^[0-9]+$ ]] || [[ $idx -ge ${#INSTALLER_PATHS[@]} ]]; then
continue
fi
local file_path="${INSTALLER_PATHS[$idx]}"
local file_size="${INSTALLER_SIZES[$idx]}"
# Validate path before deletion
if ! validate_path_for_deletion "$file_path"; then
continue
fi
# Delete the file
if safe_remove "$file_path" true; then
total_size_freed_kb=$((total_size_freed_kb + ((file_size + 1023) / 1024)))
total_deleted=$((total_deleted + 1))
fi
done
if [[ -t 1 ]]; then
stop_inline_spinner
fi
return 0
}
# Perform the installers cleanup
perform_installers() {
# Enter alt screen for scanning and selection
if [[ -t 1 ]]; then
enter_alt_screen
IN_ALT_SCREEN=1
printf "\033[2J\033[H" >&2
fi
# Collect installers
if ! collect_installers; then
if [[ -t 1 ]]; then
leave_alt_screen
IN_ALT_SCREEN=0
fi
printf '\n'
echo -e "${GREEN}${ICON_SUCCESS}${NC} Great! No installer files to clean"
printf '\n'
return 2 # Nothing to clean
fi
# Show menu
if ! show_installer_menu; then
if [[ -t 1 ]]; then
leave_alt_screen
IN_ALT_SCREEN=0
fi
return 1 # User cancelled
fi
# Leave alt screen before deletion (so confirmation and results are on main screen)
if [[ -t 1 ]]; then
leave_alt_screen
IN_ALT_SCREEN=0
fi
# Delete selected
if ! delete_selected_installers; then
return 1
fi
return 0
}
show_summary() {
local summary_heading="Installers cleaned"
local -a summary_details=()
if [[ $total_deleted -gt 0 ]]; then
local freed_mb
freed_mb=$(echo "$total_size_freed_kb" | awk '{printf "%.2f", $1/1024}')
summary_details+=("Removed ${GREEN}$total_deleted${NC} installer(s), freed ${GREEN}${freed_mb}MB${NC}")
summary_details+=("Your Mac is cleaner now!")
else
summary_details+=("No installers were removed")
fi
print_summary_block "$summary_heading" "${summary_details[@]}"
printf '\n'
}
main() {
for arg in "$@"; do
case "$arg" in
"--debug")
export MO_DEBUG=1
;;
*)
echo "Unknown option: $arg"
exit 1
;;
esac
done
hide_cursor
perform_installers
local exit_code=$?
show_cursor
case $exit_code in
0)
show_summary
;;
1)
printf '\n'
;;
2)
# Already handled by collect_installers
;;
esac
return 0
}
# Only run main if not in test mode
if [[ "${MOLE_TEST_MODE:-0}" != "1" ]]; then
main "$@"
fi

545
bin/optimize.ps1 Normal file
View File

@@ -0,0 +1,545 @@
# Mole - Optimize Command
# System optimization and health checks for Windows
#Requires -Version 5.1
[CmdletBinding()]
param(
[switch]$DryRun,
[switch]$DebugMode,
[switch]$ShowHelp
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
# Script location
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$libDir = Join-Path (Split-Path -Parent $scriptDir) "lib"
# Import core modules
. "$libDir\core\base.ps1"
. "$libDir\core\log.ps1"
. "$libDir\core\ui.ps1"
. "$libDir\core\file_ops.ps1"
# ============================================================================
# Configuration
# ============================================================================
$script:OptimizationsApplied = 0
$script:IssuesFound = 0
$script:IssuesFixed = 0
# ============================================================================
# Help
# ============================================================================
function Show-OptimizeHelp {
$esc = [char]27
Write-Host ""
Write-Host "$esc[1;35mMole Optimize$esc[0m - System optimization and health checks"
Write-Host ""
Write-Host "$esc[33mUsage:$esc[0m mole optimize [options]"
Write-Host ""
Write-Host "$esc[33mOptions:$esc[0m"
Write-Host " -DryRun Preview optimizations without applying"
Write-Host " -DebugMode Enable debug logging"
Write-Host " -ShowHelp Show this help message"
Write-Host ""
Write-Host "$esc[33mOptimizations:$esc[0m"
Write-Host " - Disk defragmentation/TRIM (SSD optimization)"
Write-Host " - Windows Search index optimization"
Write-Host " - DNS cache flush"
Write-Host " - Network optimization"
Write-Host " - Startup program analysis"
Write-Host " - System file verification"
Write-Host ""
}
# ============================================================================
# System Health Information
# ============================================================================
function Get-SystemHealth {
<#
.SYNOPSIS
Collect system health metrics
#>
$health = @{}
# Memory info
$os = Get-WmiObject Win32_OperatingSystem
$health.MemoryTotalGB = [Math]::Round($os.TotalVisibleMemorySize / 1MB, 1)
$health.MemoryUsedGB = [Math]::Round(($os.TotalVisibleMemorySize - $os.FreePhysicalMemory) / 1MB, 1)
$health.MemoryUsedPercent = [Math]::Round((($os.TotalVisibleMemorySize - $os.FreePhysicalMemory) / $os.TotalVisibleMemorySize) * 100, 0)
# Disk info
$disk = Get-WmiObject Win32_LogicalDisk -Filter "DeviceID='$env:SystemDrive'"
$health.DiskTotalGB = [Math]::Round($disk.Size / 1GB, 0)
$health.DiskUsedGB = [Math]::Round(($disk.Size - $disk.FreeSpace) / 1GB, 0)
$health.DiskUsedPercent = [Math]::Round((($disk.Size - $disk.FreeSpace) / $disk.Size) * 100, 0)
# Uptime
$uptime = (Get-Date) - (Get-CimInstance Win32_OperatingSystem).LastBootUpTime
$health.UptimeDays = [Math]::Round($uptime.TotalDays, 1)
# CPU info
$cpu = Get-WmiObject Win32_Processor
$health.CPUName = $cpu.Name
$health.CPUCores = $cpu.NumberOfLogicalProcessors
return $health
}
function Show-SystemHealth {
param([hashtable]$Health)
$esc = [char]27
Write-Host "$esc[34m$($script:Icons.Admin)$esc[0m System " -NoNewline
Write-Host "$($Health.MemoryUsedGB)/$($Health.MemoryTotalGB)GB RAM | " -NoNewline
Write-Host "$($Health.DiskUsedGB)/$($Health.DiskTotalGB)GB Disk | " -NoNewline
Write-Host "Uptime $($Health.UptimeDays)d"
}
# ============================================================================
# Optimization Tasks
# ============================================================================
function Optimize-DiskDrive {
<#
.SYNOPSIS
Optimize disk (defrag for HDD, TRIM for SSD)
#>
$esc = [char]27
Write-Host ""
Write-Host "$esc[34m$($script:Icons.Arrow) Disk Optimization$esc[0m"
if (-not (Test-IsAdmin)) {
Write-Host " $esc[33m$($script:Icons.Warning)$esc[0m Requires administrator privileges"
return
}
if ($script:DryRun) {
Write-Host " $esc[33m$($script:Icons.DryRun)$esc[0m Would optimize $env:SystemDrive"
$script:OptimizationsApplied++
return
}
try {
# Check if SSD or HDD
$diskNumber = (Get-Partition -DriveLetter $env:SystemDrive[0]).DiskNumber
$mediaType = (Get-PhysicalDisk | Where-Object { $_.DeviceId -eq $diskNumber }).MediaType
if ($mediaType -eq "SSD") {
Write-Host " Running TRIM on SSD..."
$null = Optimize-Volume -DriveLetter $env:SystemDrive[0] -ReTrim -ErrorAction Stop
Write-Host " $esc[32m$($script:Icons.Success)$esc[0m SSD TRIM completed"
}
else {
Write-Host " Running defragmentation on HDD..."
$null = Optimize-Volume -DriveLetter $env:SystemDrive[0] -Defrag -ErrorAction Stop
Write-Host " $esc[32m$($script:Icons.Success)$esc[0m Defragmentation completed"
}
$script:OptimizationsApplied++
}
catch {
Write-Host " $esc[31m$($script:Icons.Error)$esc[0m Disk optimization failed: $_"
}
}
function Optimize-SearchIndex {
<#
.SYNOPSIS
Rebuild Windows Search index if needed
#>
$esc = [char]27
Write-Host ""
Write-Host "$esc[34m$($script:Icons.Arrow) Windows Search$esc[0m"
$searchService = Get-Service -Name WSearch -ErrorAction SilentlyContinue
if (-not $searchService) {
Write-Host " $esc[90mWindows Search service not found$esc[0m"
return
}
if ($searchService.Status -ne 'Running') {
Write-Host " $esc[33m$($script:Icons.Warning)$esc[0m Windows Search service is not running"
if ($script:DryRun) {
Write-Host " $esc[33m$($script:Icons.DryRun)$esc[0m Would start search service"
return
}
try {
Start-Service -Name WSearch -ErrorAction Stop
Write-Host " $esc[32m$($script:Icons.Success)$esc[0m Started Windows Search service"
$script:OptimizationsApplied++
}
catch {
Write-Host " $esc[31m$($script:Icons.Error)$esc[0m Could not start search service"
}
}
else {
Write-Host " $esc[32m$($script:Icons.Success)$esc[0m Search service running"
}
}
function Clear-DnsCache {
<#
.SYNOPSIS
Clear DNS resolver cache
#>
$esc = [char]27
Write-Host ""
Write-Host "$esc[34m$($script:Icons.Arrow) DNS Cache$esc[0m"
if ($script:DryRun) {
Write-Host " $esc[33m$($script:Icons.DryRun)$esc[0m Would flush DNS cache"
$script:OptimizationsApplied++
return
}
try {
Clear-DnsClientCache -ErrorAction Stop
Write-Host " $esc[32m$($script:Icons.Success)$esc[0m DNS cache flushed"
$script:OptimizationsApplied++
}
catch {
Write-Host " $esc[31m$($script:Icons.Error)$esc[0m Could not flush DNS cache: $_"
}
}
function Optimize-Network {
<#
.SYNOPSIS
Network stack optimization
#>
$esc = [char]27
Write-Host ""
Write-Host "$esc[34m$($script:Icons.Arrow) Network Optimization$esc[0m"
if (-not (Test-IsAdmin)) {
Write-Host " $esc[33m$($script:Icons.Warning)$esc[0m Requires administrator privileges"
return
}
if ($script:DryRun) {
Write-Host " $esc[33m$($script:Icons.DryRun)$esc[0m Would reset Winsock catalog"
Write-Host " $esc[33m$($script:Icons.DryRun)$esc[0m Would reset TCP/IP stack"
$script:OptimizationsApplied += 2
return
}
try {
# Reset Winsock
$null = netsh winsock reset 2>&1
Write-Host " $esc[32m$($script:Icons.Success)$esc[0m Winsock catalog reset"
$script:OptimizationsApplied++
}
catch {
Write-Host " $esc[31m$($script:Icons.Error)$esc[0m Winsock reset failed"
}
try {
# Flush ARP cache
$null = netsh interface ip delete arpcache 2>&1
Write-Host " $esc[32m$($script:Icons.Success)$esc[0m ARP cache cleared"
$script:OptimizationsApplied++
}
catch {
Write-Host " $esc[31m$($script:Icons.Error)$esc[0m ARP cache clear failed"
}
}
function Get-StartupPrograms {
<#
.SYNOPSIS
Analyze startup programs
#>
$esc = [char]27
Write-Host ""
Write-Host "$esc[34m$($script:Icons.Arrow) Startup Programs$esc[0m"
$startupPaths = @(
"HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run"
"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run"
"HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run"
)
$startupCount = 0
foreach ($path in $startupPaths) {
if (Test-Path $path) {
$items = Get-ItemProperty -Path $path -ErrorAction SilentlyContinue
$props = @($items.PSObject.Properties | Where-Object {
$_.Name -notin @('PSPath', 'PSParentPath', 'PSChildName', 'PSDrive', 'PSProvider')
})
$startupCount += $props.Count
}
}
# Also check startup folder
$startupFolder = "$env:APPDATA\Microsoft\Windows\Start Menu\Programs\Startup"
if (Test-Path $startupFolder) {
$startupFiles = @(Get-ChildItem -Path $startupFolder -File -ErrorAction SilentlyContinue)
$startupCount += $startupFiles.Count
}
if ($startupCount -gt 10) {
Write-Host " $esc[33m$($script:Icons.Warning)$esc[0m $startupCount startup programs (high)"
Write-Host " $esc[90mConsider disabling unnecessary startup items in Task Manager$esc[0m"
$script:IssuesFound++
}
elseif ($startupCount -gt 5) {
Write-Host " $esc[33m$($script:Icons.Warning)$esc[0m $startupCount startup programs (moderate)"
$script:IssuesFound++
}
else {
Write-Host " $esc[32m$($script:Icons.Success)$esc[0m $startupCount startup programs"
}
}
function Test-SystemFiles {
<#
.SYNOPSIS
Run System File Checker (SFC)
#>
$esc = [char]27
Write-Host ""
Write-Host "$esc[34m$($script:Icons.Arrow) System File Verification$esc[0m"
if (-not (Test-IsAdmin)) {
Write-Host " $esc[33m$($script:Icons.Warning)$esc[0m Requires administrator privileges"
return
}
if ($script:DryRun) {
Write-Host " $esc[33m$($script:Icons.DryRun)$esc[0m Would run System File Checker"
return
}
Write-Host " Running System File Checker (this may take several minutes)..."
try {
$sfcResult = Start-Process -FilePath "sfc.exe" -ArgumentList "/scannow" `
-Wait -PassThru -NoNewWindow -RedirectStandardOutput "$env:TEMP\sfc_output.txt" -ErrorAction Stop
$output = Get-Content "$env:TEMP\sfc_output.txt" -ErrorAction SilentlyContinue
Remove-Item "$env:TEMP\sfc_output.txt" -Force -ErrorAction SilentlyContinue
if ($output -match "did not find any integrity violations") {
Write-Host " $esc[32m$($script:Icons.Success)$esc[0m No integrity violations found"
}
elseif ($output -match "found corrupt files and successfully repaired") {
Write-Host " $esc[32m$($script:Icons.Success)$esc[0m Corrupt files were repaired"
$script:IssuesFixed++
}
elseif ($output -match "found corrupt files but was unable to fix") {
Write-Host " $esc[31m$($script:Icons.Error)$esc[0m Found corrupt files that could not be repaired"
Write-Host " $esc[90mRun 'DISM /Online /Cleanup-Image /RestoreHealth' then retry SFC$esc[0m"
$script:IssuesFound++
}
else {
Write-Host " $esc[32m$($script:Icons.Success)$esc[0m Scan completed"
}
}
catch {
Write-Host " $esc[31m$($script:Icons.Error)$esc[0m System File Checker failed: $_"
}
}
function Test-DiskHealth {
<#
.SYNOPSIS
Check disk health status
#>
$esc = [char]27
Write-Host ""
Write-Host "$esc[34m$($script:Icons.Arrow) Disk Health$esc[0m"
try {
$disks = Get-PhysicalDisk -ErrorAction Stop
foreach ($disk in $disks) {
$status = $disk.HealthStatus
$name = $disk.FriendlyName
if ($status -eq "Healthy") {
Write-Host " $esc[32m$($script:Icons.Success)$esc[0m $name - Healthy"
}
elseif ($status -eq "Warning") {
Write-Host " $esc[33m$($script:Icons.Warning)$esc[0m $name - Warning"
Write-Host " $esc[90mDisk may have issues, consider backing up data$esc[0m"
$script:IssuesFound++
}
else {
Write-Host " $esc[31m$($script:Icons.Error)$esc[0m $name - $status"
Write-Host " $esc[31mDisk has critical issues, back up data immediately!$esc[0m"
$script:IssuesFound++
}
}
}
catch {
Write-Host " $esc[90mCould not check disk health$esc[0m"
}
}
function Test-WindowsUpdate {
<#
.SYNOPSIS
Check Windows Update status
#>
$esc = [char]27
Write-Host ""
Write-Host "$esc[34m$($script:Icons.Arrow) Windows Update$esc[0m"
try {
$updateSession = New-Object -ComObject Microsoft.Update.Session
$updateSearcher = $updateSession.CreateUpdateSearcher()
Write-Host " Checking for updates..."
$searchResult = $updateSearcher.Search("IsInstalled=0")
$importantUpdates = $searchResult.Updates | Where-Object {
$_.MsrcSeverity -in @('Critical', 'Important')
}
if ($importantUpdates.Count -gt 0) {
Write-Host " $esc[33m$($script:Icons.Warning)$esc[0m $($importantUpdates.Count) important updates available"
Write-Host " $esc[90mRun Windows Update to install$esc[0m"
$script:IssuesFound++
}
elseif ($searchResult.Updates.Count -gt 0) {
Write-Host " $esc[90m$($script:Icons.List)$esc[0m $($searchResult.Updates.Count) optional updates available"
}
else {
Write-Host " $esc[32m$($script:Icons.Success)$esc[0m System is up to date"
}
}
catch {
Write-Host " $esc[90mCould not check Windows Update status$esc[0m"
}
}
# ============================================================================
# Summary
# ============================================================================
function Show-OptimizeSummary {
$esc = [char]27
Write-Host ""
Write-Host "$esc[1;35m" -NoNewline
if ($script:DryRun) {
Write-Host "Dry Run Complete - No Changes Made" -NoNewline
}
else {
Write-Host "Optimization Complete" -NoNewline
}
Write-Host "$esc[0m"
Write-Host ""
if ($script:DryRun) {
Write-Host " Would apply $esc[33m$($script:OptimizationsApplied)$esc[0m optimizations"
Write-Host " Run without -DryRun to apply changes"
}
else {
Write-Host " Optimizations applied: $esc[32m$($script:OptimizationsApplied)$esc[0m"
if ($script:IssuesFixed -gt 0) {
Write-Host " Issues fixed: $esc[32m$($script:IssuesFixed)$esc[0m"
}
if ($script:IssuesFound -gt 0) {
Write-Host " Issues found: $esc[33m$($script:IssuesFound)$esc[0m"
}
else {
Write-Host " System health: $esc[32mGood$esc[0m"
}
}
Write-Host ""
}
# ============================================================================
# Main Entry Point
# ============================================================================
function Main {
# Enable debug if requested
if ($DebugMode) {
$env:MOLE_DEBUG = "1"
$DebugPreference = "Continue"
}
# Show help
if ($ShowHelp) {
Show-OptimizeHelp
return
}
# Set dry-run mode
$script:DryRun = $DryRun
# Clear screen
Clear-Host
$esc = [char]27
Write-Host ""
Write-Host "$esc[1;35mOptimize and Check$esc[0m"
Write-Host ""
if ($script:DryRun) {
Write-Host "$esc[33m$($script:Icons.DryRun) DRY RUN MODE$esc[0m - No changes will be made"
Write-Host ""
}
# Show system health
$health = Get-SystemHealth
Show-SystemHealth -Health $health
# Run optimizations
Optimize-DiskDrive
Optimize-SearchIndex
Clear-DnsCache
Optimize-Network
# Run health checks
Get-StartupPrograms
Test-DiskHealth
Test-WindowsUpdate
# System file check is slow, ask first
if (-not $script:DryRun -and (Test-IsAdmin)) {
Write-Host ""
$runSfc = Read-Host "Run System File Checker? This may take several minutes (y/N)"
if ($runSfc -eq 'y' -or $runSfc -eq 'Y') {
Test-SystemFiles
}
}
# Summary
Show-OptimizeSummary
}
# Run main
Main

View File

@@ -1,509 +0,0 @@
#!/bin/bash
# Mole - Optimize command.
# Runs system maintenance checks and fixes.
# Supports dry-run where applicable.
set -euo pipefail
# Fix locale issues.
export LC_ALL=C
export LANG=C
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
source "$SCRIPT_DIR/lib/core/common.sh"
# Clean temp files on exit.
trap cleanup_temp_files EXIT INT TERM
source "$SCRIPT_DIR/lib/core/sudo.sh"
source "$SCRIPT_DIR/lib/manage/update.sh"
source "$SCRIPT_DIR/lib/manage/autofix.sh"
source "$SCRIPT_DIR/lib/optimize/maintenance.sh"
source "$SCRIPT_DIR/lib/optimize/tasks.sh"
source "$SCRIPT_DIR/lib/check/health_json.sh"
source "$SCRIPT_DIR/lib/check/all.sh"
source "$SCRIPT_DIR/lib/manage/whitelist.sh"
print_header() {
printf '\n'
echo -e "${PURPLE_BOLD}Optimize and Check${NC}"
}
run_system_checks() {
# Skip checks in dry-run mode.
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
return 0
fi
unset AUTO_FIX_SUMMARY AUTO_FIX_DETAILS
unset MOLE_SECURITY_FIXES_SHOWN
unset MOLE_SECURITY_FIXES_SKIPPED
echo ""
check_all_updates
echo ""
check_system_health
echo ""
check_all_security
if ask_for_security_fixes; then
perform_security_fixes
fi
if [[ "${MOLE_SECURITY_FIXES_SKIPPED:-}" != "true" ]]; then
echo ""
fi
check_all_config
echo ""
show_suggestions
if ask_for_updates; then
perform_updates
fi
if ask_for_auto_fix; then
perform_auto_fix
fi
}
show_optimization_summary() {
local safe_count="${OPTIMIZE_SAFE_COUNT:-0}"
local confirm_count="${OPTIMIZE_CONFIRM_COUNT:-0}"
if ((safe_count == 0 && confirm_count == 0)) && [[ -z "${AUTO_FIX_SUMMARY:-}" ]]; then
return
fi
local summary_title
local -a summary_details=()
local total_applied=$((safe_count + confirm_count))
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
summary_title="Dry Run Complete - No Changes Made"
summary_details+=("Would apply ${YELLOW}${total_applied:-0}${NC} optimizations")
summary_details+=("Run without ${YELLOW}--dry-run${NC} to apply these changes")
else
summary_title="Optimization and Check Complete"
# Build statistics summary
local -a stats=()
local cache_kb="${OPTIMIZE_CACHE_CLEANED_KB:-0}"
local db_count="${OPTIMIZE_DATABASES_COUNT:-0}"
local config_count="${OPTIMIZE_CONFIGS_REPAIRED:-0}"
if [[ "$cache_kb" =~ ^[0-9]+$ ]] && [[ "$cache_kb" -gt 0 ]]; then
local cache_human=$(bytes_to_human "$((cache_kb * 1024))")
stats+=("${cache_human} cache cleaned")
fi
if [[ "$db_count" =~ ^[0-9]+$ ]] && [[ "$db_count" -gt 0 ]]; then
stats+=("${db_count} databases optimized")
fi
if [[ "$config_count" =~ ^[0-9]+$ ]] && [[ "$config_count" -gt 0 ]]; then
stats+=("${config_count} configs repaired")
fi
# Build first summary line with most important stat only
local key_stat=""
if [[ "$cache_kb" =~ ^[0-9]+$ ]] && [[ "$cache_kb" -gt 0 ]]; then
local cache_human=$(bytes_to_human "$((cache_kb * 1024))")
key_stat="${cache_human} cache cleaned"
elif [[ "$db_count" =~ ^[0-9]+$ ]] && [[ "$db_count" -gt 0 ]]; then
key_stat="${db_count} databases optimized"
elif [[ "$config_count" =~ ^[0-9]+$ ]] && [[ "$config_count" -gt 0 ]]; then
key_stat="${config_count} configs repaired"
fi
if [[ -n "$key_stat" ]]; then
summary_details+=("Applied ${GREEN}${total_applied:-0}${NC} optimizations — ${key_stat}")
else
summary_details+=("Applied ${GREEN}${total_applied:-0}${NC} optimizations — all services tuned")
fi
local summary_line3=""
if [[ -n "${AUTO_FIX_SUMMARY:-}" ]]; then
summary_line3="${AUTO_FIX_SUMMARY}"
if [[ -n "${AUTO_FIX_DETAILS:-}" ]]; then
local detail_join
detail_join=$(echo "${AUTO_FIX_DETAILS}" | paste -sd ", " -)
[[ -n "$detail_join" ]] && summary_line3+="${detail_join}"
fi
summary_details+=("$summary_line3")
fi
summary_details+=("System fully optimized — faster, more secure and responsive")
fi
print_summary_block "$summary_title" "${summary_details[@]}"
}
show_system_health() {
local health_json="$1"
local mem_used=$(echo "$health_json" | jq -r '.memory_used_gb // 0' 2> /dev/null || echo "0")
local mem_total=$(echo "$health_json" | jq -r '.memory_total_gb // 0' 2> /dev/null || echo "0")
local disk_used=$(echo "$health_json" | jq -r '.disk_used_gb // 0' 2> /dev/null || echo "0")
local disk_total=$(echo "$health_json" | jq -r '.disk_total_gb // 0' 2> /dev/null || echo "0")
local disk_percent=$(echo "$health_json" | jq -r '.disk_used_percent // 0' 2> /dev/null || echo "0")
local uptime=$(echo "$health_json" | jq -r '.uptime_days // 0' 2> /dev/null || echo "0")
mem_used=${mem_used:-0}
mem_total=${mem_total:-0}
disk_used=${disk_used:-0}
disk_total=${disk_total:-0}
disk_percent=${disk_percent:-0}
uptime=${uptime:-0}
printf "${ICON_ADMIN} System %.0f/%.0f GB RAM | %.0f/%.0f GB Disk | Uptime %.0fd\n" \
"$mem_used" "$mem_total" "$disk_used" "$disk_total" "$uptime"
}
parse_optimizations() {
local health_json="$1"
echo "$health_json" | jq -c '.optimizations[]' 2> /dev/null
}
announce_action() {
local name="$1"
local desc="$2"
local kind="$3"
if [[ "${FIRST_ACTION:-true}" == "true" ]]; then
export FIRST_ACTION=false
else
echo ""
fi
echo -e "${BLUE}${ICON_ARROW} ${name}${NC}"
}
touchid_configured() {
local pam_file="/etc/pam.d/sudo"
[[ -f "$pam_file" ]] && grep -q "pam_tid.so" "$pam_file" 2> /dev/null
}
touchid_supported() {
if command -v bioutil > /dev/null 2>&1; then
if bioutil -r 2> /dev/null | grep -qi "Touch ID"; then
return 0
fi
fi
# Fallback: Apple Silicon Macs usually have Touch ID.
if [[ "$(uname -m)" == "arm64" ]]; then
return 0
fi
return 1
}
cleanup_path() {
local raw_path="$1"
local label="$2"
local expanded_path="${raw_path/#\~/$HOME}"
if [[ ! -e "$expanded_path" ]]; then
echo -e "${GREEN}${ICON_SUCCESS}${NC} $label"
return
fi
if should_protect_path "$expanded_path"; then
echo -e "${YELLOW}${ICON_WARNING}${NC} Protected $label"
return
fi
local size_kb
size_kb=$(get_path_size_kb "$expanded_path")
local size_display=""
if [[ "$size_kb" =~ ^[0-9]+$ && "$size_kb" -gt 0 ]]; then
size_display=$(bytes_to_human "$((size_kb * 1024))")
fi
local removed=false
if safe_remove "$expanded_path" true; then
removed=true
elif request_sudo_access "Removing $label requires admin access"; then
if safe_sudo_remove "$expanded_path"; then
removed=true
fi
fi
if [[ "$removed" == "true" ]]; then
if [[ -n "$size_display" ]]; then
echo -e "${GREEN}${ICON_SUCCESS}${NC} $label ${GREEN}(${size_display})${NC}"
else
echo -e "${GREEN}${ICON_SUCCESS}${NC} $label"
fi
else
echo -e "${YELLOW}${ICON_WARNING}${NC} Skipped $label ${GRAY}(grant Full Disk Access to your terminal and retry)${NC}"
fi
}
ensure_directory() {
local raw_path="$1"
local expanded_path="${raw_path/#\~/$HOME}"
ensure_user_dir "$expanded_path"
}
declare -a SECURITY_FIXES=()
collect_security_fix_actions() {
SECURITY_FIXES=()
if [[ "${FIREWALL_DISABLED:-}" == "true" ]]; then
if ! is_whitelisted "firewall"; then
SECURITY_FIXES+=("firewall|Enable macOS firewall")
fi
fi
if [[ "${GATEKEEPER_DISABLED:-}" == "true" ]]; then
if ! is_whitelisted "gatekeeper"; then
SECURITY_FIXES+=("gatekeeper|Enable Gatekeeper (App download protection)")
fi
fi
if touchid_supported && ! touchid_configured; then
if ! is_whitelisted "check_touchid"; then
SECURITY_FIXES+=("touchid|Enable Touch ID for sudo")
fi
fi
((${#SECURITY_FIXES[@]} > 0))
}
ask_for_security_fixes() {
if ! collect_security_fix_actions; then
return 1
fi
echo ""
echo -e "${BLUE}SECURITY FIXES${NC}"
for entry in "${SECURITY_FIXES[@]}"; do
IFS='|' read -r _ label <<< "$entry"
echo -e " ${ICON_LIST} $label"
done
echo ""
export MOLE_SECURITY_FIXES_SHOWN=true
echo -ne "${YELLOW}Apply now?${NC} ${GRAY}Enter confirm / Space cancel${NC}: "
local key
if ! key=$(read_key); then
export MOLE_SECURITY_FIXES_SKIPPED=true
echo -e "\n ${GRAY}${ICON_WARNING}${NC} Security fixes skipped"
echo ""
return 1
fi
if [[ "$key" == "ENTER" ]]; then
echo ""
return 0
else
export MOLE_SECURITY_FIXES_SKIPPED=true
echo -e "\n ${GRAY}${ICON_WARNING}${NC} Security fixes skipped"
echo ""
return 1
fi
}
apply_firewall_fix() {
if sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setglobalstate on > /dev/null 2>&1; then
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Firewall enabled"
FIREWALL_DISABLED=false
return 0
fi
echo -e " ${YELLOW}${ICON_WARNING}${NC} Failed to enable firewall (check permissions)"
return 1
}
apply_gatekeeper_fix() {
if sudo spctl --master-enable 2> /dev/null; then
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Gatekeeper enabled"
GATEKEEPER_DISABLED=false
return 0
fi
echo -e " ${YELLOW}${ICON_WARNING}${NC} Failed to enable Gatekeeper"
return 1
}
apply_touchid_fix() {
if "$SCRIPT_DIR/bin/touchid.sh" enable; then
return 0
fi
return 1
}
perform_security_fixes() {
if ! ensure_sudo_session "Security changes require admin access"; then
echo -e "${YELLOW}${ICON_WARNING}${NC} Skipped security fixes (sudo denied)"
return 1
fi
local applied=0
for entry in "${SECURITY_FIXES[@]}"; do
IFS='|' read -r action _ <<< "$entry"
case "$action" in
firewall)
apply_firewall_fix && ((applied++))
;;
gatekeeper)
apply_gatekeeper_fix && ((applied++))
;;
touchid)
apply_touchid_fix && ((applied++))
;;
esac
done
if ((applied > 0)); then
log_success "Security settings updated"
fi
SECURITY_FIXES=()
}
cleanup_all() {
stop_inline_spinner 2> /dev/null || true
stop_sudo_session
cleanup_temp_files
}
handle_interrupt() {
cleanup_all
exit 130
}
main() {
local health_json
for arg in "$@"; do
case "$arg" in
"--debug")
export MO_DEBUG=1
;;
"--dry-run")
export MOLE_DRY_RUN=1
;;
"--whitelist")
manage_whitelist "optimize"
exit 0
;;
esac
done
trap cleanup_all EXIT
trap handle_interrupt INT TERM
if [[ -t 1 ]]; then
clear
fi
print_header
# Dry-run indicator.
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
echo -e "${YELLOW}${ICON_DRY_RUN} DRY RUN MODE${NC} - No files will be modified\n"
fi
if ! command -v jq > /dev/null 2>&1; then
echo -e "${YELLOW}${ICON_ERROR}${NC} Missing dependency: jq"
echo -e "${GRAY}Install with: ${GREEN}brew install jq${NC}"
exit 1
fi
if ! command -v bc > /dev/null 2>&1; then
echo -e "${YELLOW}${ICON_ERROR}${NC} Missing dependency: bc"
echo -e "${GRAY}Install with: ${GREEN}brew install bc${NC}"
exit 1
fi
if [[ -t 1 ]]; then
start_inline_spinner "Collecting system info..."
fi
if ! health_json=$(generate_health_json 2> /dev/null); then
if [[ -t 1 ]]; then
stop_inline_spinner
fi
echo ""
log_error "Failed to collect system health data"
exit 1
fi
if ! echo "$health_json" | jq empty 2> /dev/null; then
if [[ -t 1 ]]; then
stop_inline_spinner
fi
echo ""
log_error "Invalid system health data format"
echo -e "${YELLOW}Tip:${NC} Check if jq, awk, sysctl, and df commands are available"
exit 1
fi
if [[ -t 1 ]]; then
stop_inline_spinner
fi
show_system_health "$health_json"
load_whitelist "optimize"
if [[ ${#CURRENT_WHITELIST_PATTERNS[@]} -gt 0 ]]; then
local count=${#CURRENT_WHITELIST_PATTERNS[@]}
if [[ $count -le 3 ]]; then
local patterns_list=$(
IFS=', '
echo "${CURRENT_WHITELIST_PATTERNS[*]}"
)
echo -e "${ICON_ADMIN} Active Whitelist: ${patterns_list}"
fi
fi
local -a safe_items=()
local -a confirm_items=()
local opts_file
opts_file=$(mktemp_file)
parse_optimizations "$health_json" > "$opts_file"
while IFS= read -r opt_json; do
[[ -z "$opt_json" ]] && continue
local name=$(echo "$opt_json" | jq -r '.name')
local desc=$(echo "$opt_json" | jq -r '.description')
local action=$(echo "$opt_json" | jq -r '.action')
local path=$(echo "$opt_json" | jq -r '.path // ""')
local safe=$(echo "$opt_json" | jq -r '.safe')
local item="${name}|${desc}|${action}|${path}"
if [[ "$safe" == "true" ]]; then
safe_items+=("$item")
else
confirm_items+=("$item")
fi
done < "$opts_file"
echo ""
if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then
ensure_sudo_session "System optimization requires admin access" || true
fi
export FIRST_ACTION=true
if [[ ${#safe_items[@]} -gt 0 ]]; then
for item in "${safe_items[@]}"; do
IFS='|' read -r name desc action path <<< "$item"
announce_action "$name" "$desc" "safe"
execute_optimization "$action" "$path"
done
fi
if [[ ${#confirm_items[@]} -gt 0 ]]; then
for item in "${confirm_items[@]}"; do
IFS='|' read -r name desc action path <<< "$item"
announce_action "$name" "$desc" "confirm"
execute_optimization "$action" "$path"
done
fi
local safe_count=${#safe_items[@]}
local confirm_count=${#confirm_items[@]}
run_system_checks
export OPTIMIZE_SAFE_COUNT=$safe_count
export OPTIMIZE_CONFIRM_COUNT=$confirm_count
show_optimization_summary
printf '\n'
}
main "$@"

615
bin/purge.ps1 Normal file
View File

@@ -0,0 +1,615 @@
# Mole - Purge Command
# Aggressive cleanup of project build artifacts
#Requires -Version 5.1
[CmdletBinding()]
param(
[switch]$DebugMode,
[switch]$Paths,
[switch]$ShowHelp
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
# Script location
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$libDir = Join-Path (Split-Path -Parent $scriptDir) "lib"
# Import core modules
. "$libDir\core\base.ps1"
. "$libDir\core\log.ps1"
. "$libDir\core\ui.ps1"
. "$libDir\core\file_ops.ps1"
# ============================================================================
# Configuration
# ============================================================================
$script:DefaultSearchPaths = @(
"$env:USERPROFILE\Documents"
"$env:USERPROFILE\Projects"
"$env:USERPROFILE\Code"
"$env:USERPROFILE\Development"
"$env:USERPROFILE\workspace"
"$env:USERPROFILE\github"
"$env:USERPROFILE\repos"
"$env:USERPROFILE\src"
"D:\Projects"
"D:\Code"
"D:\Development"
)
$script:ConfigFile = "$env:USERPROFILE\.config\mole\purge_paths.txt"
# Artifact patterns to clean
$script:ArtifactPatterns = @(
@{ Name = "node_modules"; Type = "Directory"; Language = "JavaScript/Node.js" }
@{ Name = "vendor"; Type = "Directory"; Language = "PHP/Go" }
@{ Name = ".venv"; Type = "Directory"; Language = "Python" }
@{ Name = "venv"; Type = "Directory"; Language = "Python" }
@{ Name = "__pycache__"; Type = "Directory"; Language = "Python" }
@{ Name = ".pytest_cache"; Type = "Directory"; Language = "Python" }
@{ Name = "target"; Type = "Directory"; Language = "Rust/Java" }
@{ Name = "build"; Type = "Directory"; Language = "General" }
@{ Name = "dist"; Type = "Directory"; Language = "General" }
@{ Name = ".next"; Type = "Directory"; Language = "Next.js" }
@{ Name = ".nuxt"; Type = "Directory"; Language = "Nuxt.js" }
@{ Name = ".turbo"; Type = "Directory"; Language = "Turborepo" }
@{ Name = ".parcel-cache"; Type = "Directory"; Language = "Parcel" }
@{ Name = "bin"; Type = "Directory"; Language = ".NET" }
@{ Name = "obj"; Type = "Directory"; Language = ".NET" }
@{ Name = ".gradle"; Type = "Directory"; Language = "Java/Gradle" }
@{ Name = ".idea"; Type = "Directory"; Language = "JetBrains IDE" }
@{ Name = "*.log"; Type = "File"; Language = "Logs" }
)
$script:TotalSizeCleaned = 0
$script:ItemsCleaned = 0
# ============================================================================
# Help
# ============================================================================
function Show-PurgeHelp {
$esc = [char]27
Write-Host ""
Write-Host "$esc[1;35mMole Purge$esc[0m - Clean project build artifacts"
Write-Host ""
Write-Host "$esc[33mUsage:$esc[0m mole purge [options]"
Write-Host ""
Write-Host "$esc[33mOptions:$esc[0m"
Write-Host " -Paths Edit custom scan directories"
Write-Host " -DebugMode Enable debug logging"
Write-Host " -ShowHelp Show this help message"
Write-Host ""
Write-Host "$esc[33mDefault Search Paths:$esc[0m"
foreach ($path in $script:DefaultSearchPaths) {
if (Test-Path $path) {
Write-Host " $esc[32m+$esc[0m $path"
}
else {
Write-Host " $esc[90m-$esc[0m $path (not found)"
}
}
Write-Host ""
Write-Host "$esc[33mArtifacts Cleaned:$esc[0m"
Write-Host " node_modules, vendor, venv, target, build, dist, __pycache__, etc."
Write-Host ""
}
# ============================================================================
# Path Management
# ============================================================================
function Get-SearchPaths {
<#
.SYNOPSIS
Get list of paths to scan for projects
#>
$paths = @()
# Load custom paths if available
if (Test-Path $script:ConfigFile) {
$customPaths = Get-Content $script:ConfigFile -ErrorAction SilentlyContinue |
Where-Object { $_ -and -not $_.StartsWith('#') } |
ForEach-Object { $_.Trim() }
foreach ($path in $customPaths) {
if (Test-Path $path) {
$paths += $path
}
}
}
# Add default paths if no custom paths or custom paths don't exist
if ($null -eq $paths -or @($paths).Count -eq 0) {
foreach ($path in $script:DefaultSearchPaths) {
if (Test-Path $path) {
$paths += $path
}
}
}
return $paths
}
function Edit-SearchPaths {
<#
.SYNOPSIS
Open search paths configuration for editing
#>
$configDir = Split-Path -Parent $script:ConfigFile
if (-not (Test-Path $configDir)) {
New-Item -ItemType Directory -Path $configDir -Force | Out-Null
}
if (-not (Test-Path $script:ConfigFile)) {
$defaultContent = @"
# Mole Purge - Custom Search Paths
# Add directories to scan for project artifacts (one per line)
# Lines starting with # are ignored
#
# Examples:
# D:\MyProjects
# E:\Work\Code
#
# Default paths (used if this file is empty):
# $env:USERPROFILE\Documents
# $env:USERPROFILE\Projects
# $env:USERPROFILE\Code
"@
Set-Content -Path $script:ConfigFile -Value $defaultContent
}
Write-Info "Opening paths configuration: $($script:ConfigFile)"
Start-Process notepad.exe -ArgumentList $script:ConfigFile -Wait
Write-Success "Configuration saved"
}
# ============================================================================
# Project Discovery
# ============================================================================
function Find-Projects {
<#
.SYNOPSIS
Find all development projects in search paths
#>
param([string[]]$SearchPaths)
$projects = @()
# Project markers
$projectMarkers = @(
"package.json" # Node.js
"composer.json" # PHP
"Cargo.toml" # Rust
"go.mod" # Go
"pom.xml" # Java/Maven
"build.gradle" # Java/Gradle
"requirements.txt" # Python
"pyproject.toml" # Python
"*.csproj" # .NET
"*.sln" # .NET Solution
)
$esc = [char]27
$pathCount = 0
$totalPaths = if ($null -eq $SearchPaths) { 0 } else { @($SearchPaths).Count }
if ($totalPaths -eq 0) {
return $projects
}
foreach ($searchPath in $SearchPaths) {
$pathCount++
Write-Progress -Activity "Scanning for projects" `
-Status "Searching: $searchPath" `
-PercentComplete (($pathCount / $totalPaths) * 100)
foreach ($marker in $projectMarkers) {
try {
$found = Get-ChildItem -Path $searchPath -Filter $marker -Recurse -Depth 4 -ErrorAction SilentlyContinue
foreach ($item in $found) {
$projectPath = Split-Path -Parent $item.FullName
# Skip if already found or if it's inside node_modules, etc.
$existingPaths = @($projects | ForEach-Object { $_.Path })
if ($existingPaths -contains $projectPath) { continue }
if ($projectPath -like "*\node_modules\*") { continue }
if ($projectPath -like "*\vendor\*") { continue }
if ($projectPath -like "*\.git\*") { continue }
# Find artifacts in this project
$artifacts = @(Find-ProjectArtifacts -ProjectPath $projectPath)
$artifactCount = if ($null -eq $artifacts) { 0 } else { $artifacts.Count }
if ($artifactCount -gt 0) {
$totalSize = ($artifacts | Measure-Object -Property SizeKB -Sum).Sum
if ($null -eq $totalSize) { $totalSize = 0 }
$projects += [PSCustomObject]@{
Path = $projectPath
Name = Split-Path -Leaf $projectPath
Marker = $marker
Artifacts = $artifacts
TotalSizeKB = $totalSize
TotalSizeHuman = Format-ByteSize -Bytes ($totalSize * 1024)
}
}
}
}
catch {
Write-Debug "Error scanning $searchPath for $marker : $_"
}
}
}
Write-Progress -Activity "Scanning for projects" -Completed
# Sort by size (largest first)
return $projects | Sort-Object -Property TotalSizeKB -Descending
}
function Find-ProjectArtifacts {
<#
.SYNOPSIS
Find cleanable artifacts in a project directory
#>
param([string]$ProjectPath)
$artifacts = @()
foreach ($pattern in $script:ArtifactPatterns) {
$items = Get-ChildItem -Path $ProjectPath -Filter $pattern.Name -Force -ErrorAction SilentlyContinue
foreach ($item in $items) {
if ($pattern.Type -eq "Directory" -and $item.PSIsContainer) {
$sizeKB = Get-PathSizeKB -Path $item.FullName
$artifacts += [PSCustomObject]@{
Path = $item.FullName
Name = $item.Name
Type = "Directory"
Language = $pattern.Language
SizeKB = $sizeKB
SizeHuman = Format-ByteSize -Bytes ($sizeKB * 1024)
}
}
elseif ($pattern.Type -eq "File" -and -not $item.PSIsContainer) {
$sizeKB = [Math]::Ceiling($item.Length / 1024)
$artifacts += [PSCustomObject]@{
Path = $item.FullName
Name = $item.Name
Type = "File"
Language = $pattern.Language
SizeKB = $sizeKB
SizeHuman = Format-ByteSize -Bytes ($sizeKB * 1024)
}
}
}
}
return $artifacts
}
# ============================================================================
# Project Selection UI
# ============================================================================
function Show-ProjectSelectionMenu {
<#
.SYNOPSIS
Interactive menu for selecting projects to clean
#>
param([array]$Projects)
$projectCount = if ($null -eq $Projects) { 0 } else { @($Projects).Count }
if ($projectCount -eq 0) {
Write-MoleWarning "No projects with cleanable artifacts found"
return @()
}
$esc = [char]27
$selectedIndices = @{}
$currentIndex = 0
$pageSize = 12
$pageStart = 0
try { [Console]::CursorVisible = $false } catch { }
try {
while ($true) {
Clear-Host
# Header
Write-Host ""
Write-Host "$esc[1;35mSelect Projects to Clean$esc[0m"
Write-Host ""
Write-Host "$esc[90mUse: $($script:Icons.NavUp)$($script:Icons.NavDown) navigate | Space select | A select all | Enter confirm | Q quit$esc[0m"
Write-Host ""
# Display projects
$pageEnd = [Math]::Min($pageStart + $pageSize, $projectCount)
for ($i = $pageStart; $i -lt $pageEnd; $i++) {
$project = $Projects[$i]
$isSelected = $selectedIndices.ContainsKey($i)
$isCurrent = ($i -eq $currentIndex)
$checkbox = if ($isSelected) { "$esc[32m[$($script:Icons.Success)]$esc[0m" } else { "[ ]" }
if ($isCurrent) {
Write-Host "$esc[7m" -NoNewline
}
$name = $project.Name
if ($name.Length -gt 30) {
$name = $name.Substring(0, 27) + "..."
}
$artifactCount = if ($null -eq $project.Artifacts) { 0 } else { @($project.Artifacts).Count }
Write-Host (" {0} {1,-32} {2,10} ({3} items)" -f $checkbox, $name, $project.TotalSizeHuman, $artifactCount) -NoNewline
if ($isCurrent) {
Write-Host "$esc[0m"
}
else {
Write-Host ""
}
}
# Footer
Write-Host ""
$selectedCount = $selectedIndices.Count
if ($selectedCount -gt 0) {
$totalSize = 0
foreach ($idx in $selectedIndices.Keys) {
$totalSize += $Projects[$idx].TotalSizeKB
}
$totalSizeHuman = Format-ByteSize -Bytes ($totalSize * 1024)
Write-Host "$esc[33mSelected:$esc[0m $selectedCount projects ($totalSizeHuman)"
}
# Page indicator
$totalPages = [Math]::Ceiling($projectCount / $pageSize)
$currentPage = [Math]::Floor($pageStart / $pageSize) + 1
Write-Host "$esc[90mPage $currentPage of $totalPages | Total: $projectCount projects$esc[0m"
# Handle input
$key = [Console]::ReadKey($true)
switch ($key.Key) {
'UpArrow' {
if ($currentIndex -gt 0) {
$currentIndex--
if ($currentIndex -lt $pageStart) {
$pageStart = [Math]::Max(0, $pageStart - $pageSize)
}
}
}
'DownArrow' {
if ($currentIndex -lt $projectCount - 1) {
$currentIndex++
if ($currentIndex -ge $pageStart + $pageSize) {
$pageStart += $pageSize
}
}
}
'PageUp' {
$pageStart = [Math]::Max(0, $pageStart - $pageSize)
$currentIndex = $pageStart
}
'PageDown' {
$pageStart = [Math]::Min($projectCount - $pageSize, $pageStart + $pageSize)
if ($pageStart -lt 0) { $pageStart = 0 }
$currentIndex = $pageStart
}
'Spacebar' {
if ($selectedIndices.ContainsKey($currentIndex)) {
$selectedIndices.Remove($currentIndex)
}
else {
$selectedIndices[$currentIndex] = $true
}
}
'A' {
# Select/deselect all
if ($selectedIndices.Count -eq $projectCount) {
$selectedIndices.Clear()
}
else {
for ($i = 0; $i -lt $projectCount; $i++) {
$selectedIndices[$i] = $true
}
}
}
'Enter' {
if ($selectedIndices.Count -gt 0) {
$selected = @()
foreach ($idx in $selectedIndices.Keys) {
$selected += $Projects[$idx]
}
return $selected
}
}
'Escape' { return @() }
'Q' { return @() }
}
}
}
finally {
try { [Console]::CursorVisible = $true } catch { }
}
}
# ============================================================================
# Cleanup
# ============================================================================
function Remove-ProjectArtifacts {
<#
.SYNOPSIS
Remove artifacts from selected projects
#>
param([array]$Projects)
$esc = [char]27
Write-Host ""
Write-Host "$esc[1;35mCleaning Project Artifacts$esc[0m"
Write-Host ""
foreach ($project in $Projects) {
Write-Host "$esc[34m$($script:Icons.Arrow)$esc[0m $($project.Name)"
foreach ($artifact in $project.Artifacts) {
if (Test-Path $artifact.Path) {
# Use safe removal with protection checks (returns boolean)
$success = Remove-SafeItem -Path $artifact.Path -Description $artifact.Name -Recurse
if ($success) {
Write-Host " $esc[32m$($script:Icons.Success)$esc[0m $($artifact.Name) ($($artifact.SizeHuman))"
$script:TotalSizeCleaned += $artifact.SizeKB
$script:ItemsCleaned++
}
else {
Write-Host " $esc[31m$($script:Icons.Error)$esc[0m $($artifact.Name) - removal failed"
}
}
}
}
}
# ============================================================================
# Summary
# ============================================================================
function Show-PurgeSummary {
$esc = [char]27
Write-Host ""
Write-Host "$esc[1;35mPurge Complete$esc[0m"
Write-Host ""
if ($script:TotalSizeCleaned -gt 0) {
$sizeGB = [Math]::Round($script:TotalSizeCleaned / 1024 / 1024, 2)
Write-Host " Space freed: $esc[32m${sizeGB}GB$esc[0m"
Write-Host " Items cleaned: $($script:ItemsCleaned)"
Write-Host " Free space now: $(Get-FreeSpace)"
}
else {
Write-Host " No artifacts to clean."
Write-Host " Free space now: $(Get-FreeSpace)"
}
Write-Host ""
}
# ============================================================================
# Main Entry Point
# ============================================================================
function Main {
# Enable debug if requested
if ($DebugMode) {
$env:MOLE_DEBUG = "1"
$DebugPreference = "Continue"
}
# Show help
if ($ShowHelp) {
Show-PurgeHelp
return
}
# Edit paths
if ($Paths) {
Edit-SearchPaths
return
}
# Clear screen
Clear-Host
$esc = [char]27
Write-Host ""
Write-Host "$esc[1;35mPurge Project Artifacts$esc[0m"
Write-Host ""
# Get search paths
$searchPaths = @(Get-SearchPaths)
if ($null -eq $searchPaths -or $searchPaths.Count -eq 0) {
Write-MoleWarning "No valid search paths found"
Write-Host "Run 'mole purge -Paths' to configure search directories"
return
}
Write-Info "Searching in $($searchPaths.Count) directories..."
# Find projects
$projects = @(Find-Projects -SearchPaths $searchPaths)
if ($null -eq $projects -or $projects.Count -eq 0) {
Write-Host ""
Write-Host "$esc[32m$($script:Icons.Success)$esc[0m No cleanable artifacts found"
Write-Host ""
return
}
$totalSize = ($projects | Measure-Object -Property TotalSizeKB -Sum).Sum
if ($null -eq $totalSize) { $totalSize = 0 }
$totalSizeHuman = Format-ByteSize -Bytes ($totalSize * 1024)
Write-Host ""
Write-Host "Found $esc[33m$($projects.Count)$esc[0m projects with $esc[33m$totalSizeHuman$esc[0m of artifacts"
Write-Host ""
# Project selection
$selected = @(Show-ProjectSelectionMenu -Projects $projects)
if ($null -eq $selected -or $selected.Count -eq 0) {
Write-Info "No projects selected"
return
}
# Confirm
Clear-Host
Write-Host ""
$selectedSize = ($selected | Measure-Object -Property TotalSizeKB -Sum).Sum
if ($null -eq $selectedSize) { $selectedSize = 0 }
$selectedSizeHuman = Format-ByteSize -Bytes ($selectedSize * 1024)
Write-Host "$esc[33mThe following will be cleaned ($selectedSizeHuman):$esc[0m"
Write-Host ""
foreach ($project in $selected) {
Write-Host " $($script:Icons.List) $($project.Name) ($($project.TotalSizeHuman))"
foreach ($artifact in $project.Artifacts) {
Write-Host " $esc[90m$($artifact.Name) - $($artifact.SizeHuman)$esc[0m"
}
}
Write-Host ""
$confirm = Read-Host "Continue? (y/N)"
if ($confirm -eq 'y' -or $confirm -eq 'Y') {
Remove-ProjectArtifacts -Projects $selected
Show-PurgeSummary
}
else {
Write-Info "Cancelled"
}
}
# Run main
Main

View File

@@ -1,166 +0,0 @@
#!/bin/bash
# Mole - Purge command.
# Cleans heavy project build artifacts.
# Interactive selection by project.
set -euo pipefail
# Fix locale issues (avoid Perl warnings on non-English systems)
export LC_ALL=C
export LANG=C
# Get script directory and source common functions
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../lib/core/common.sh"
# Set up cleanup trap for temporary files
trap cleanup_temp_files EXIT INT TERM
source "$SCRIPT_DIR/../lib/core/log.sh"
source "$SCRIPT_DIR/../lib/clean/project.sh"
# Configuration
CURRENT_SECTION=""
# Section management
start_section() {
local section_name="$1"
CURRENT_SECTION="$section_name"
printf '\n'
echo -e "${BLUE}━━━ ${section_name} ━━━${NC}"
}
end_section() {
CURRENT_SECTION=""
}
# Note activity for export list
note_activity() {
if [[ -n "$CURRENT_SECTION" ]]; then
printf '%s\n' "$CURRENT_SECTION" >> "$EXPORT_LIST_FILE"
fi
}
# Main purge function
start_purge() {
# Clear screen for better UX
if [[ -t 1 ]]; then
printf '\033[2J\033[H'
fi
printf '\n'
echo -e "${PURPLE_BOLD}Purge Project Artifacts${NC}"
# Initialize stats file in user cache directory
local stats_dir="${XDG_CACHE_HOME:-$HOME/.cache}/mole"
ensure_user_dir "$stats_dir"
ensure_user_file "$stats_dir/purge_stats"
ensure_user_file "$stats_dir/purge_count"
echo "0" > "$stats_dir/purge_stats"
echo "0" > "$stats_dir/purge_count"
}
# Perform the purge
perform_purge() {
clean_project_artifacts
local exit_code=$?
# Exit codes:
# 0 = success, show summary
# 1 = user cancelled
# 2 = nothing to clean
if [[ $exit_code -ne 0 ]]; then
return 0
fi
# Final summary (matching clean.sh format)
echo ""
local summary_heading="Purge complete"
local -a summary_details=()
local total_size_cleaned=0
local total_items_cleaned=0
# Read stats from user cache directory
local stats_dir="${XDG_CACHE_HOME:-$HOME/.cache}/mole"
if [[ -f "$stats_dir/purge_stats" ]]; then
total_size_cleaned=$(cat "$stats_dir/purge_stats" 2> /dev/null || echo "0")
rm -f "$stats_dir/purge_stats"
fi
# Read count
if [[ -f "$stats_dir/purge_count" ]]; then
total_items_cleaned=$(cat "$stats_dir/purge_count" 2> /dev/null || echo "0")
rm -f "$stats_dir/purge_count"
fi
if [[ $total_size_cleaned -gt 0 ]]; then
local freed_gb
freed_gb=$(echo "$total_size_cleaned" | awk '{printf "%.2f", $1/1024/1024}')
summary_details+=("Space freed: ${GREEN}${freed_gb}GB${NC}")
summary_details+=("Free space now: $(get_free_space)")
if [[ $total_items_cleaned -gt 0 ]]; then
summary_details+=("Items cleaned: $total_items_cleaned")
fi
else
summary_details+=("No old project artifacts to clean.")
summary_details+=("Free space now: $(get_free_space)")
fi
print_summary_block "$summary_heading" "${summary_details[@]}"
printf '\n'
}
# Show help message
show_help() {
echo -e "${PURPLE_BOLD}Mole Purge${NC} - Clean old project build artifacts"
echo ""
echo -e "${YELLOW}Usage:${NC} mo purge [options]"
echo ""
echo -e "${YELLOW}Options:${NC}"
echo " --paths Edit custom scan directories"
echo " --debug Enable debug logging"
echo " --help Show this help message"
echo ""
echo -e "${YELLOW}Default Paths:${NC}"
for path in "${DEFAULT_PURGE_SEARCH_PATHS[@]}"; do
echo " - $path"
done
}
# Main entry point
main() {
# Set up signal handling
trap 'show_cursor; exit 130' INT TERM
# Parse arguments
for arg in "$@"; do
case "$arg" in
"--paths")
source "$SCRIPT_DIR/../lib/manage/purge_paths.sh"
manage_purge_paths
exit 0
;;
"--help")
show_help
exit 0
;;
"--debug")
export MO_DEBUG=1
;;
*)
echo "Unknown option: $arg"
echo "Use 'mo purge --help' for usage information"
exit 1
;;
esac
done
start_purge
hide_cursor
perform_purge
show_cursor
}
main "$@"

73
bin/status.ps1 Normal file
View File

@@ -0,0 +1,73 @@
# Mole - Status Command
# System status monitor wrapper
#Requires -Version 5.1
param(
[switch]$ShowHelp
)
$ErrorActionPreference = "Stop"
# Script location
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$windowsDir = Split-Path -Parent $scriptDir
$binPath = Join-Path $windowsDir "bin\status.exe"
# Help
function Show-StatusHelp {
$esc = [char]27
Write-Host ""
Write-Host "$esc[1;35mMole Status$esc[0m - Real-time system health monitor"
Write-Host ""
Write-Host "$esc[33mUsage:$esc[0m mole status"
Write-Host ""
Write-Host "$esc[33mOptions:$esc[0m"
Write-Host " -ShowHelp Show this help message"
Write-Host ""
Write-Host "$esc[33mDisplays:$esc[0m"
Write-Host " - System health score (0-100)"
Write-Host " - CPU usage and model"
Write-Host " - Memory and swap usage"
Write-Host " - Disk space per drive"
Write-Host " - Top processes by CPU"
Write-Host " - Network interfaces"
Write-Host ""
Write-Host "$esc[33mKeybindings:$esc[0m"
Write-Host " c Toggle mole animation"
Write-Host " r Force refresh"
Write-Host " q Quit"
Write-Host ""
}
if ($ShowHelp) {
Show-StatusHelp
return
}
# Check if binary exists
if (-not (Test-Path $binPath)) {
Write-Host "Building status tool..." -ForegroundColor Cyan
$cmdDir = Join-Path $windowsDir "cmd\status"
$binDir = Join-Path $windowsDir "bin"
if (-not (Test-Path $binDir)) {
New-Item -ItemType Directory -Path $binDir -Force | Out-Null
}
Push-Location $windowsDir
try {
$result = & go build -o "$binPath" "./cmd/status/" 2>&1
if ($LASTEXITCODE -ne 0) {
Write-Host "Failed to build status tool: $result" -ForegroundColor Red
Pop-Location
return
}
}
finally {
Pop-Location
}
}
# Run the binary
& $binPath

View File

@@ -1,15 +0,0 @@
#!/bin/bash
# Mole - Status command.
# Runs the Go system status panel.
# Shows live system metrics.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
GO_BIN="$SCRIPT_DIR/status-go"
if [[ -x "$GO_BIN" ]]; then
exec "$GO_BIN" "$@"
fi
echo "Bundled status binary not found. Please reinstall Mole or run mo update to restore it." >&2
exit 1

View File

@@ -1,325 +0,0 @@
#!/bin/bash
# Mole - Touch ID command.
# Configures sudo with Touch ID.
# Guided toggle with safety checks.
set -euo pipefail
# Determine script location and source common functions
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LIB_DIR="$(cd "$SCRIPT_DIR/../lib" && pwd)"
# Source common functions
# shellcheck source=../lib/core/common.sh
source "$LIB_DIR/core/common.sh"
readonly PAM_SUDO_FILE="${MOLE_PAM_SUDO_FILE:-/etc/pam.d/sudo}"
readonly PAM_SUDO_LOCAL_FILE="${MOLE_PAM_SUDO_LOCAL_FILE:-/etc/pam.d/sudo_local}"
readonly PAM_TID_LINE="auth sufficient pam_tid.so"
# Check if Touch ID is already configured
is_touchid_configured() {
# Check sudo_local first
if [[ -f "$PAM_SUDO_LOCAL_FILE" ]]; then
grep -q "pam_tid.so" "$PAM_SUDO_LOCAL_FILE" 2> /dev/null && return 0
fi
# Fallback to standard sudo file
if [[ ! -f "$PAM_SUDO_FILE" ]]; then
return 1
fi
grep -q "pam_tid.so" "$PAM_SUDO_FILE" 2> /dev/null
}
# Check if system supports Touch ID
supports_touchid() {
# Check if bioutil exists and has Touch ID capability
if command -v bioutil &> /dev/null; then
bioutil -r 2> /dev/null | grep -q "Touch ID" && return 0
fi
# Fallback: check if running on Apple Silicon or modern Intel Mac
local arch
arch=$(uname -m)
if [[ "$arch" == "arm64" ]]; then
return 0
fi
# For Intel Macs, check if it's 2018 or later (approximation)
local model_year
model_year=$(system_profiler SPHardwareDataType 2> /dev/null | grep "Model Identifier" | grep -o "[0-9]\{4\}" | head -1)
if [[ -n "$model_year" ]] && [[ "$model_year" -ge 2018 ]]; then
return 0
fi
return 1
}
# Show current Touch ID status
show_status() {
if is_touchid_configured; then
echo -e "${GREEN}${ICON_SUCCESS}${NC} Touch ID is enabled for sudo"
else
echo -e "${YELLOW}${NC} Touch ID is not configured for sudo"
fi
}
# Enable Touch ID for sudo
enable_touchid() {
# Cleanup trap
local temp_file=""
trap '[[ -n "${temp_file:-}" ]] && rm -f "${temp_file:-}"' EXIT
# First check if system supports Touch ID
if ! supports_touchid; then
log_warning "This Mac may not support Touch ID"
read -rp "Continue anyway? [y/N] " confirm
if [[ ! "$confirm" =~ ^[Yy]$ ]]; then
echo -e "${YELLOW}Cancelled${NC}"
return 1
fi
echo ""
fi
# Check if we should use sudo_local (Sonoma+)
if grep -q "sudo_local" "$PAM_SUDO_FILE"; then
# Check if already correctly configured in sudo_local
if [[ -f "$PAM_SUDO_LOCAL_FILE" ]] && grep -q "pam_tid.so" "$PAM_SUDO_LOCAL_FILE"; then
# It is in sudo_local, but let's check if it's ALSO in sudo (incomplete migration)
if grep -q "pam_tid.so" "$PAM_SUDO_FILE"; then
# Clean up legacy config
temp_file=$(mktemp)
grep -v "pam_tid.so" "$PAM_SUDO_FILE" > "$temp_file"
if sudo mv "$temp_file" "$PAM_SUDO_FILE" 2> /dev/null; then
echo -e "${GREEN}${ICON_SUCCESS} Cleanup legacy configuration${NC}"
fi
fi
echo -e "${GREEN}${ICON_SUCCESS} Touch ID is already enabled${NC}"
return 0
fi
# Not configured in sudo_local yet.
# Check if configured in sudo (Legacy)
local is_legacy_configured=false
if grep -q "pam_tid.so" "$PAM_SUDO_FILE"; then
is_legacy_configured=true
fi
# Function to write to sudo_local
local write_success=false
if [[ ! -f "$PAM_SUDO_LOCAL_FILE" ]]; then
# Create the file
echo "# sudo_local: local customizations for sudo" | sudo tee "$PAM_SUDO_LOCAL_FILE" > /dev/null
echo "$PAM_TID_LINE" | sudo tee -a "$PAM_SUDO_LOCAL_FILE" > /dev/null
sudo chmod 444 "$PAM_SUDO_LOCAL_FILE"
sudo chown root:wheel "$PAM_SUDO_LOCAL_FILE"
write_success=true
else
# Append if not present
if ! grep -q "pam_tid.so" "$PAM_SUDO_LOCAL_FILE"; then
temp_file=$(mktemp)
cp "$PAM_SUDO_LOCAL_FILE" "$temp_file"
echo "$PAM_TID_LINE" >> "$temp_file"
sudo mv "$temp_file" "$PAM_SUDO_LOCAL_FILE"
sudo chmod 444 "$PAM_SUDO_LOCAL_FILE"
sudo chown root:wheel "$PAM_SUDO_LOCAL_FILE"
write_success=true
else
write_success=true # Already there (should be caught by first check, but safe fallback)
fi
fi
if $write_success; then
# If we migrated from legacy, clean it up now
if $is_legacy_configured; then
temp_file=$(mktemp)
grep -v "pam_tid.so" "$PAM_SUDO_FILE" > "$temp_file"
sudo mv "$temp_file" "$PAM_SUDO_FILE"
log_success "Touch ID migrated to sudo_local"
else
log_success "Touch ID enabled (via sudo_local) - try: sudo ls"
fi
return 0
else
log_error "Failed to write to sudo_local"
return 1
fi
fi
# Legacy method: Modify sudo file directly
# Check if already configured (Legacy)
if is_touchid_configured; then
echo -e "${GREEN}${ICON_SUCCESS} Touch ID is already enabled${NC}"
return 0
fi
# Create backup only if it doesn't exist to preserve original state
if [[ ! -f "${PAM_SUDO_FILE}.mole-backup" ]]; then
if ! sudo cp "$PAM_SUDO_FILE" "${PAM_SUDO_FILE}.mole-backup" 2> /dev/null; then
log_error "Failed to create backup"
return 1
fi
fi
# Create temp file
temp_file=$(mktemp)
# Insert pam_tid.so after the first comment block
awk '
BEGIN { inserted = 0 }
/^#/ { print; next }
!inserted && /^[^#]/ {
print "'"$PAM_TID_LINE"'"
inserted = 1
}
{ print }
' "$PAM_SUDO_FILE" > "$temp_file"
# Verify content change
if cmp -s "$PAM_SUDO_FILE" "$temp_file"; then
log_error "Failed to modify configuration"
return 1
fi
# Apply the changes
if sudo mv "$temp_file" "$PAM_SUDO_FILE" 2> /dev/null; then
log_success "Touch ID enabled - try: sudo ls"
return 0
else
log_error "Failed to enable Touch ID"
return 1
fi
}
# Disable Touch ID for sudo
disable_touchid() {
# Cleanup trap
local temp_file=""
trap '[[ -n "${temp_file:-}" ]] && rm -f "${temp_file:-}"' EXIT
if ! is_touchid_configured; then
echo -e "${YELLOW}Touch ID is not currently enabled${NC}"
return 0
fi
# Check sudo_local first
if [[ -f "$PAM_SUDO_LOCAL_FILE" ]] && grep -q "pam_tid.so" "$PAM_SUDO_LOCAL_FILE"; then
# Remove from sudo_local
temp_file=$(mktemp)
grep -v "pam_tid.so" "$PAM_SUDO_LOCAL_FILE" > "$temp_file"
if sudo mv "$temp_file" "$PAM_SUDO_LOCAL_FILE" 2> /dev/null; then
# Since we modified sudo_local, we should also check if it's in sudo file (legacy cleanup)
if grep -q "pam_tid.so" "$PAM_SUDO_FILE"; then
temp_file=$(mktemp)
grep -v "pam_tid.so" "$PAM_SUDO_FILE" > "$temp_file"
sudo mv "$temp_file" "$PAM_SUDO_FILE"
fi
echo -e "${GREEN}${ICON_SUCCESS} Touch ID disabled (removed from sudo_local)${NC}"
echo ""
return 0
else
log_error "Failed to disable Touch ID from sudo_local"
return 1
fi
fi
# Fallback to sudo file (legacy)
if grep -q "pam_tid.so" "$PAM_SUDO_FILE"; then
# Create backup only if it doesn't exist
if [[ ! -f "${PAM_SUDO_FILE}.mole-backup" ]]; then
if ! sudo cp "$PAM_SUDO_FILE" "${PAM_SUDO_FILE}.mole-backup" 2> /dev/null; then
log_error "Failed to create backup"
return 1
fi
fi
# Remove pam_tid.so line
temp_file=$(mktemp)
grep -v "pam_tid.so" "$PAM_SUDO_FILE" > "$temp_file"
if sudo mv "$temp_file" "$PAM_SUDO_FILE" 2> /dev/null; then
echo -e "${GREEN}${ICON_SUCCESS} Touch ID disabled${NC}"
echo ""
return 0
else
log_error "Failed to disable Touch ID"
return 1
fi
fi
# Should not reach here if is_touchid_configured was true
log_error "Could not find Touch ID configuration to disable"
return 1
}
# Interactive menu
show_menu() {
echo ""
show_status
if is_touchid_configured; then
echo -ne "${PURPLE}${NC} Press ${GREEN}Enter${NC} to disable, ${GRAY}Q${NC} to quit: "
IFS= read -r -s -n1 key || key=""
drain_pending_input # Clean up any escape sequence remnants
echo ""
case "$key" in
$'\e') # ESC
return 0
;;
"" | $'\n' | $'\r') # Enter
printf "\r\033[K" # Clear the prompt line
disable_touchid
;;
*)
echo ""
log_error "Invalid key"
;;
esac
else
echo -ne "${PURPLE}${NC} Press ${GREEN}Enter${NC} to enable, ${GRAY}Q${NC} to quit: "
IFS= read -r -s -n1 key || key=""
drain_pending_input # Clean up any escape sequence remnants
case "$key" in
$'\e') # ESC
return 0
;;
"" | $'\n' | $'\r') # Enter
printf "\r\033[K" # Clear the prompt line
enable_touchid
;;
*)
echo ""
log_error "Invalid key"
;;
esac
fi
}
# Main
main() {
local command="${1:-}"
case "$command" in
enable)
enable_touchid
;;
disable)
disable_touchid
;;
status)
show_status
;;
"")
show_menu
;;
*)
log_error "Unknown command: $command"
exit 1
;;
esac
}
main "$@"

623
bin/uninstall.ps1 Normal file
View File

@@ -0,0 +1,623 @@
# Mole - Uninstall Command
# Interactive application uninstaller for Windows
#Requires -Version 5.1
[CmdletBinding()]
param(
[switch]$DebugMode,
[switch]$Rescan,
[switch]$ShowHelp
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
# Script location
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$libDir = Join-Path (Split-Path -Parent $scriptDir) "lib"
# Import core modules
. "$libDir\core\base.ps1"
. "$libDir\core\log.ps1"
. "$libDir\core\ui.ps1"
. "$libDir\core\file_ops.ps1"
# ============================================================================
# Configuration
# ============================================================================
$script:CacheDir = "$env:USERPROFILE\.cache\mole"
$script:AppCacheFile = "$script:CacheDir\app_scan_cache.json"
$script:CacheTTLHours = 24
# ============================================================================
# Help
# ============================================================================
function Show-UninstallHelp {
$esc = [char]27
Write-Host ""
Write-Host "$esc[1;35mMole Uninstall$esc[0m - Interactive application uninstaller"
Write-Host ""
Write-Host "$esc[33mUsage:$esc[0m mole uninstall [options]"
Write-Host ""
Write-Host "$esc[33mOptions:$esc[0m"
Write-Host " -Rescan Force rescan of installed applications"
Write-Host " -DebugMode Enable debug logging"
Write-Host " -ShowHelp Show this help message"
Write-Host ""
Write-Host "$esc[33mFeatures:$esc[0m"
Write-Host " - Scans installed programs from registry and Windows Apps"
Write-Host " - Shows program size and last used date"
Write-Host " - Interactive selection with arrow keys"
Write-Host " - Cleans leftover files after uninstall"
Write-Host ""
}
# ============================================================================
# Protected Applications
# ============================================================================
$script:ProtectedApps = @(
"Microsoft Windows"
"Windows Feature Experience Pack"
"Microsoft Edge"
"Microsoft Edge WebView2"
"Windows Security"
"Microsoft Visual C++ *"
"Microsoft .NET *"
".NET Desktop Runtime*"
"Microsoft Update Health Tools"
"NVIDIA Graphics Driver*"
"AMD Software*"
"Intel*Driver*"
)
function Test-ProtectedApp {
param([string]$AppName)
foreach ($pattern in $script:ProtectedApps) {
if ($AppName -like $pattern) {
return $true
}
}
return $false
}
# ============================================================================
# Application Discovery
# ============================================================================
function Get-InstalledApplications {
<#
.SYNOPSIS
Scan and return all installed applications
#>
param([switch]$ForceRescan)
# Check cache
if (-not $ForceRescan -and (Test-Path $script:AppCacheFile)) {
$cacheInfo = Get-Item $script:AppCacheFile
$cacheAge = (Get-Date) - $cacheInfo.LastWriteTime
if ($cacheAge.TotalHours -lt $script:CacheTTLHours) {
Write-Debug "Loading from cache..."
try {
$cached = Get-Content $script:AppCacheFile | ConvertFrom-Json
return $cached
}
catch {
Write-Debug "Cache read failed, rescanning..."
}
}
}
Write-Info "Scanning installed applications..."
$apps = @()
# Registry paths for installed programs
$registryPaths = @(
"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*"
"HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*"
"HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*"
)
$count = 0
$total = $registryPaths.Count
foreach ($path in $registryPaths) {
$count++
Write-Progress -Activity "Scanning applications" -Status "Registry path $count of $total" -PercentComplete (($count / $total) * 50)
try {
$regItems = Get-ItemProperty -Path $path -ErrorAction SilentlyContinue
foreach ($item in $regItems) {
# Skip items without required properties
$displayName = $null
$uninstallString = $null
try { $displayName = $item.DisplayName } catch { }
try { $uninstallString = $item.UninstallString } catch { }
if ([string]::IsNullOrWhiteSpace($displayName) -or [string]::IsNullOrWhiteSpace($uninstallString)) {
continue
}
if (Test-ProtectedApp $displayName) {
continue
}
# Calculate size
$sizeKB = 0
try {
if ($item.EstimatedSize) {
$sizeKB = [long]$item.EstimatedSize
}
elseif ($item.InstallLocation -and (Test-Path $item.InstallLocation -ErrorAction SilentlyContinue)) {
$sizeKB = Get-PathSizeKB -Path $item.InstallLocation
}
}
catch { }
# Get install date
$installDate = $null
try {
if ($item.InstallDate) {
$installDate = [DateTime]::ParseExact($item.InstallDate, "yyyyMMdd", $null)
}
}
catch { }
# Get other properties safely
$publisher = $null
$version = $null
$installLocation = $null
try { $publisher = $item.Publisher } catch { }
try { $version = $item.DisplayVersion } catch { }
try { $installLocation = $item.InstallLocation } catch { }
$apps += [PSCustomObject]@{
Name = $displayName
Publisher = $publisher
Version = $version
SizeKB = $sizeKB
SizeHuman = Format-ByteSize -Bytes ($sizeKB * 1024)
InstallLocation = $installLocation
UninstallString = $uninstallString
InstallDate = $installDate
Source = "Registry"
}
}
}
catch {
Write-Debug "Error scanning registry path $path : $_"
}
}
# UWP / Store Apps
Write-Progress -Activity "Scanning applications" -Status "Scanning Windows Apps" -PercentComplete 75
try {
$uwpApps = Get-AppxPackage -ErrorAction SilentlyContinue |
Where-Object {
$_.IsFramework -eq $false -and
$_.SignatureKind -ne 'System' -and
-not (Test-ProtectedApp $_.Name)
}
foreach ($uwp in $uwpApps) {
# Get friendly name
$name = $uwp.Name
try {
$manifest = Get-AppxPackageManifest -Package $uwp.PackageFullName -ErrorAction SilentlyContinue
if ($manifest.Package.Properties.DisplayName -and
-not $manifest.Package.Properties.DisplayName.StartsWith("ms-resource:")) {
$name = $manifest.Package.Properties.DisplayName
}
}
catch { }
# Calculate size
$sizeKB = 0
if ($uwp.InstallLocation -and (Test-Path $uwp.InstallLocation)) {
$sizeKB = Get-PathSizeKB -Path $uwp.InstallLocation
}
$apps += [PSCustomObject]@{
Name = $name
Publisher = $uwp.Publisher
Version = $uwp.Version
SizeKB = $sizeKB
SizeHuman = Format-ByteSize -Bytes ($sizeKB * 1024)
InstallLocation = $uwp.InstallLocation
UninstallString = $null
PackageFullName = $uwp.PackageFullName
InstallDate = $null
Source = "WindowsStore"
}
}
}
catch {
Write-Debug "Could not enumerate UWP apps: $_"
}
Write-Progress -Activity "Scanning applications" -Completed
# Sort by size (largest first)
$apps = $apps | Sort-Object -Property SizeKB -Descending
# Cache results
if (-not (Test-Path $script:CacheDir)) {
New-Item -ItemType Directory -Path $script:CacheDir -Force | Out-Null
}
$apps | ConvertTo-Json -Depth 5 | Set-Content $script:AppCacheFile
return $apps
}
# ============================================================================
# Application Selection UI
# ============================================================================
function Show-AppSelectionMenu {
<#
.SYNOPSIS
Interactive menu for selecting applications to uninstall
#>
param([array]$Apps)
if ($Apps.Count -eq 0) {
Write-MoleWarning "No applications found to uninstall"
return @()
}
$esc = [char]27
$selectedIndices = @{}
$currentIndex = 0
$pageSize = 15
$pageStart = 0
$searchTerm = ""
$filteredApps = $Apps
# Hide cursor (may fail in non-interactive terminals)
try { [Console]::CursorVisible = $false } catch { }
try {
while ($true) {
Clear-Host
# Header
Write-Host ""
Write-Host "$esc[1;35mSelect Applications to Uninstall$esc[0m"
Write-Host ""
Write-Host "$esc[90mUse: $($script:Icons.NavUp)$($script:Icons.NavDown) navigate | Space select | Enter confirm | Q quit | / search$esc[0m"
Write-Host ""
# Search indicator
if ($searchTerm) {
Write-Host "$esc[33mSearch:$esc[0m $searchTerm ($($filteredApps.Count) matches)"
Write-Host ""
}
# Display apps
$pageEnd = [Math]::Min($pageStart + $pageSize, $filteredApps.Count)
for ($i = $pageStart; $i -lt $pageEnd; $i++) {
$app = $filteredApps[$i]
$isSelected = $selectedIndices.ContainsKey($app.Name)
$isCurrent = ($i -eq $currentIndex)
# Selection indicator
$checkbox = if ($isSelected) { "$esc[32m[$($script:Icons.Success)]$esc[0m" } else { "[ ]" }
# Highlight current
if ($isCurrent) {
Write-Host "$esc[7m" -NoNewline # Reverse video
}
# App info
$name = $app.Name
if ($name.Length -gt 40) {
$name = $name.Substring(0, 37) + "..."
}
$size = $app.SizeHuman
if (-not $size -or $size -eq "0B") {
$size = "N/A"
}
Write-Host (" {0} {1,-42} {2,10}" -f $checkbox, $name, $size) -NoNewline
if ($isCurrent) {
Write-Host "$esc[0m" # Reset
}
else {
Write-Host ""
}
}
# Footer
Write-Host ""
$selectedCount = $selectedIndices.Count
if ($selectedCount -gt 0) {
$totalSize = 0
foreach ($key in $selectedIndices.Keys) {
$app = $Apps | Where-Object { $_.Name -eq $key }
if ($app.SizeKB) {
$totalSize += $app.SizeKB
}
}
$totalSizeHuman = Format-ByteSize -Bytes ($totalSize * 1024)
Write-Host "$esc[33mSelected:$esc[0m $selectedCount apps ($totalSizeHuman)"
}
# Page indicator
$totalPages = [Math]::Ceiling($filteredApps.Count / $pageSize)
$currentPage = [Math]::Floor($pageStart / $pageSize) + 1
Write-Host "$esc[90mPage $currentPage of $totalPages$esc[0m"
# Handle input
$key = [Console]::ReadKey($true)
switch ($key.Key) {
'UpArrow' {
if ($currentIndex -gt 0) {
$currentIndex--
if ($currentIndex -lt $pageStart) {
$pageStart = [Math]::Max(0, $pageStart - $pageSize)
}
}
}
'DownArrow' {
if ($currentIndex -lt $filteredApps.Count - 1) {
$currentIndex++
if ($currentIndex -ge $pageStart + $pageSize) {
$pageStart += $pageSize
}
}
}
'PageUp' {
$pageStart = [Math]::Max(0, $pageStart - $pageSize)
$currentIndex = $pageStart
}
'PageDown' {
$pageStart = [Math]::Min($filteredApps.Count - $pageSize, $pageStart + $pageSize)
if ($pageStart -lt 0) { $pageStart = 0 }
$currentIndex = $pageStart
}
'Spacebar' {
$app = $filteredApps[$currentIndex]
if ($selectedIndices.ContainsKey($app.Name)) {
$selectedIndices.Remove($app.Name)
}
else {
$selectedIndices[$app.Name] = $true
}
}
'Enter' {
if ($selectedIndices.Count -gt 0) {
# Return selected apps
$selected = $Apps | Where-Object { $selectedIndices.ContainsKey($_.Name) }
return $selected
}
}
'Escape' {
return @()
}
'Q' {
return @()
}
'Oem2' { # Forward slash
# Search mode
Write-Host ""
Write-Host "Search: " -NoNewline
try { [Console]::CursorVisible = $true } catch { }
$searchTerm = Read-Host
try { [Console]::CursorVisible = $false } catch { }
if ($searchTerm) {
$filteredApps = $Apps | Where-Object { $_.Name -like "*$searchTerm*" }
}
else {
$filteredApps = $Apps
}
$currentIndex = 0
$pageStart = 0
}
'Backspace' {
if ($searchTerm) {
$searchTerm = ""
$filteredApps = $Apps
$currentIndex = 0
$pageStart = 0
}
}
}
}
}
finally {
try { [Console]::CursorVisible = $true } catch { }
}
}
# ============================================================================
# Uninstallation
# ============================================================================
function Uninstall-SelectedApps {
<#
.SYNOPSIS
Uninstall the selected applications
#>
param([array]$Apps)
$esc = [char]27
Write-Host ""
Write-Host "$esc[1;35mUninstalling Applications$esc[0m"
Write-Host ""
$successCount = 0
$failCount = 0
foreach ($app in $Apps) {
Write-Host "$esc[34m$($script:Icons.Arrow)$esc[0m Uninstalling: $($app.Name)" -NoNewline
try {
if ($app.Source -eq "WindowsStore") {
# UWP app
if ($app.PackageFullName) {
Remove-AppxPackage -Package $app.PackageFullName -ErrorAction Stop
Write-Host " $esc[32m$($script:Icons.Success)$esc[0m"
$successCount++
}
}
else {
# Registry app with uninstall string
$uninstallString = $app.UninstallString
# Handle different uninstall types
if ($uninstallString -like "MsiExec.exe*") {
# MSI uninstall
$productCode = [regex]::Match($uninstallString, '\{[0-9A-F-]+\}').Value
if ($productCode) {
$process = Start-Process -FilePath "msiexec.exe" `
-ArgumentList "/x", $productCode, "/qn", "/norestart" `
-Wait -PassThru -NoNewWindow
if ($process.ExitCode -eq 0 -or $process.ExitCode -eq 3010) {
Write-Host " $esc[32m$($script:Icons.Success)$esc[0m"
$successCount++
}
else {
Write-Host " $esc[33m(requires interaction)$esc[0m"
# Fallback to interactive uninstall
Start-Process -FilePath "msiexec.exe" -ArgumentList "/x", $productCode -Wait
$successCount++
}
}
}
else {
# Direct executable uninstall
# Try silent uninstall first
$silentArgs = @("/S", "/silent", "/quiet", "-s", "-silent", "-quiet", "/VERYSILENT")
$uninstalled = $false
foreach ($arg in $silentArgs) {
try {
$process = Start-Process -FilePath "cmd.exe" `
-ArgumentList "/c", "`"$uninstallString`"", $arg `
-Wait -PassThru -NoNewWindow -ErrorAction SilentlyContinue
if ($process.ExitCode -eq 0) {
Write-Host " $esc[32m$($script:Icons.Success)$esc[0m"
$successCount++
$uninstalled = $true
break
}
}
catch { }
}
if (-not $uninstalled) {
# Fallback to interactive - don't count as automatic success
Write-Host " $esc[33m(launching uninstaller - verify completion manually)$esc[0m"
Start-Process -FilePath "cmd.exe" -ArgumentList "/c", "`"$uninstallString`"" -Wait
# Note: Not incrementing $successCount since we can't verify if user completed or cancelled
}
}
}
# Clean leftover files
if ($app.InstallLocation -and (Test-Path $app.InstallLocation)) {
Write-Host " $esc[90mCleaning leftover files...$esc[0m"
Remove-SafeItem -Path $app.InstallLocation -Description "Leftover files" -Recurse
}
}
catch {
Write-Host " $esc[31m$($script:Icons.Error)$esc[0m"
Write-Debug "Uninstall failed: $_"
$failCount++
}
}
# Summary
Write-Host ""
Write-Host "$esc[1;35mUninstall Complete$esc[0m"
Write-Host " Successfully uninstalled: $esc[32m$successCount$esc[0m"
if ($failCount -gt 0) {
Write-Host " Failed: $esc[31m$failCount$esc[0m"
}
Write-Host ""
# Clear cache
if (Test-Path $script:AppCacheFile) {
Remove-Item $script:AppCacheFile -Force -ErrorAction SilentlyContinue
}
}
# ============================================================================
# Main Entry Point
# ============================================================================
function Main {
# Enable debug if requested
if ($DebugMode) {
$env:MOLE_DEBUG = "1"
$DebugPreference = "Continue"
}
# Show help
if ($ShowHelp) {
Show-UninstallHelp
return
}
# Clear screen
Clear-Host
# Get installed apps
$apps = Get-InstalledApplications -ForceRescan:$Rescan
if ($apps.Count -eq 0) {
Write-MoleWarning "No applications found"
return
}
Write-Info "Found $($apps.Count) applications"
# Show selection menu
$selected = Show-AppSelectionMenu -Apps $apps
if ($selected.Count -eq 0) {
Write-Info "No applications selected"
return
}
# Confirm uninstall
$esc = [char]27
Clear-Host
Write-Host ""
Write-Host "$esc[33mThe following applications will be uninstalled:$esc[0m"
Write-Host ""
foreach ($app in $selected) {
Write-Host " $($script:Icons.List) $($app.Name) ($($app.SizeHuman))"
}
Write-Host ""
$confirm = Read-Host "Continue? (y/N)"
if ($confirm -eq 'y' -or $confirm -eq 'Y') {
Uninstall-SelectedApps -Apps $selected
}
else {
Write-Info "Cancelled"
}
}
# Run main
Main

View File

@@ -1,586 +0,0 @@
#!/bin/bash
# Mole - Uninstall command.
# Interactive app uninstaller.
# Removes app files and leftovers.
set -euo pipefail
# Fix locale issues on non-English systems.
export LC_ALL=C
export LANG=C
# Load shared helpers.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../lib/core/common.sh"
# Clean temp files on exit.
trap cleanup_temp_files EXIT INT TERM
source "$SCRIPT_DIR/../lib/ui/menu_paginated.sh"
source "$SCRIPT_DIR/../lib/ui/app_selector.sh"
source "$SCRIPT_DIR/../lib/uninstall/batch.sh"
# State
selected_apps=()
declare -a apps_data=()
declare -a selection_state=()
total_items=0
files_cleaned=0
total_size_cleaned=0
# Scan applications and collect information.
scan_applications() {
# Cache app scan (24h TTL).
local cache_dir="$HOME/.cache/mole"
local cache_file="$cache_dir/app_scan_cache"
local cache_ttl=86400 # 24 hours
local force_rescan="${1:-false}"
ensure_user_dir "$cache_dir"
if [[ $force_rescan == false && -f "$cache_file" ]]; then
local cache_age=$(($(get_epoch_seconds) - $(get_file_mtime "$cache_file")))
[[ $cache_age -eq $(get_epoch_seconds) ]] && cache_age=86401 # Handle mtime read failure
if [[ $cache_age -lt $cache_ttl ]]; then
if [[ -t 2 ]]; then
echo -e "${GREEN}Loading from cache...${NC}" >&2
sleep 0.3 # Brief pause so user sees the message
fi
echo "$cache_file"
return 0
fi
fi
local inline_loading=false
if [[ -t 1 && -t 2 ]]; then
inline_loading=true
printf "\033[2J\033[H" >&2 # Clear screen for inline loading
fi
local temp_file
temp_file=$(create_temp_file)
# Local spinner_pid for cleanup
local spinner_pid=""
# Trap to handle Ctrl+C during scan
local scan_interrupted=false
# shellcheck disable=SC2329 # Function invoked indirectly via trap
trap_scan_cleanup() {
scan_interrupted=true
if [[ -n "$spinner_pid" ]]; then
kill -TERM "$spinner_pid" 2> /dev/null || true
wait "$spinner_pid" 2> /dev/null || true
fi
printf "\r\033[K" >&2
rm -f "$temp_file" "${temp_file}.sorted" "${temp_file}.progress" 2> /dev/null || true
exit 130
}
trap trap_scan_cleanup INT
local current_epoch
current_epoch=$(get_epoch_seconds)
# Pass 1: collect app paths and bundle IDs (no mdls).
local -a app_data_tuples=()
local -a app_dirs=(
"/Applications"
"$HOME/Applications"
"/Library/Input Methods"
"$HOME/Library/Input Methods"
)
local vol_app_dir
local nullglob_was_set=0
shopt -q nullglob && nullglob_was_set=1
shopt -s nullglob
for vol_app_dir in /Volumes/*/Applications; do
[[ -d "$vol_app_dir" && -r "$vol_app_dir" ]] || continue
if [[ -d "/Applications" && "$vol_app_dir" -ef "/Applications" ]]; then
continue
fi
if [[ -d "$HOME/Applications" && "$vol_app_dir" -ef "$HOME/Applications" ]]; then
continue
fi
app_dirs+=("$vol_app_dir")
done
if [[ $nullglob_was_set -eq 0 ]]; then
shopt -u nullglob
fi
for app_dir in "${app_dirs[@]}"; do
if [[ ! -d "$app_dir" ]]; then continue; fi
while IFS= read -r -d '' app_path; do
if [[ ! -e "$app_path" ]]; then continue; fi
local app_name
app_name=$(basename "$app_path" .app)
# Skip nested apps inside another .app bundle.
local parent_dir
parent_dir=$(dirname "$app_path")
if [[ "$parent_dir" == *".app" || "$parent_dir" == *".app/"* ]]; then
continue
fi
# Bundle ID from plist (fast path).
local bundle_id="unknown"
if [[ -f "$app_path/Contents/Info.plist" ]]; then
bundle_id=$(defaults read "$app_path/Contents/Info.plist" CFBundleIdentifier 2> /dev/null || echo "unknown")
fi
if should_protect_from_uninstall "$bundle_id"; then
continue
fi
# Store tuple for pass 2 (metadata + size).
app_data_tuples+=("${app_path}|${app_name}|${bundle_id}")
done < <(command find "$app_dir" -name "*.app" -maxdepth 3 -print0 2> /dev/null)
done
# Pass 2: metadata + size in parallel (mdls is slow).
local app_count=0
local total_apps=${#app_data_tuples[@]}
local max_parallel
max_parallel=$(get_optimal_parallel_jobs "io")
if [[ $max_parallel -lt 8 ]]; then
max_parallel=8 # At least 8 for good performance
elif [[ $max_parallel -gt 32 ]]; then
max_parallel=32 # Cap at 32 to avoid too many processes
fi
local pids=()
process_app_metadata() {
local app_data_tuple="$1"
local output_file="$2"
local current_epoch="$3"
IFS='|' read -r app_path app_name bundle_id <<< "$app_data_tuple"
# Display name priority: mdls display name → bundle display → bundle name → folder.
local display_name="$app_name"
if [[ -f "$app_path/Contents/Info.plist" ]]; then
local md_display_name
md_display_name=$(run_with_timeout 0.05 mdls -name kMDItemDisplayName -raw "$app_path" 2> /dev/null || echo "")
local bundle_display_name
bundle_display_name=$(plutil -extract CFBundleDisplayName raw "$app_path/Contents/Info.plist" 2> /dev/null)
local bundle_name
bundle_name=$(plutil -extract CFBundleName raw "$app_path/Contents/Info.plist" 2> /dev/null)
if [[ "$md_display_name" == /* ]]; then md_display_name=""; fi
md_display_name="${md_display_name//|/-}"
md_display_name="${md_display_name//[$'\t\r\n']/}"
bundle_display_name="${bundle_display_name//|/-}"
bundle_display_name="${bundle_display_name//[$'\t\r\n']/}"
bundle_name="${bundle_name//|/-}"
bundle_name="${bundle_name//[$'\t\r\n']/}"
if [[ -n "$md_display_name" && "$md_display_name" != "(null)" && "$md_display_name" != "$app_name" ]]; then
display_name="$md_display_name"
elif [[ -n "$bundle_display_name" && "$bundle_display_name" != "(null)" ]]; then
display_name="$bundle_display_name"
elif [[ -n "$bundle_name" && "$bundle_name" != "(null)" ]]; then
display_name="$bundle_name"
fi
fi
if [[ "$display_name" == /* ]]; then
display_name="$app_name"
fi
display_name="${display_name//|/-}"
display_name="${display_name//[$'\t\r\n']/}"
# App size (KB → human).
local app_size="N/A"
local app_size_kb="0"
if [[ -d "$app_path" ]]; then
app_size_kb=$(get_path_size_kb "$app_path")
app_size=$(bytes_to_human "$((app_size_kb * 1024))")
fi
# Last used: mdls (fast timeout) → mtime.
local last_used="Never"
local last_used_epoch=0
if [[ -d "$app_path" ]]; then
local metadata_date
metadata_date=$(run_with_timeout 0.1 mdls -name kMDItemLastUsedDate -raw "$app_path" 2> /dev/null || echo "")
if [[ "$metadata_date" != "(null)" && -n "$metadata_date" ]]; then
last_used_epoch=$(date -j -f "%Y-%m-%d %H:%M:%S %z" "$metadata_date" "+%s" 2> /dev/null || echo "0")
fi
if [[ "$last_used_epoch" -eq 0 ]]; then
last_used_epoch=$(get_file_mtime "$app_path")
fi
if [[ $last_used_epoch -gt 0 ]]; then
local days_ago=$(((current_epoch - last_used_epoch) / 86400))
if [[ $days_ago -eq 0 ]]; then
last_used="Today"
elif [[ $days_ago -eq 1 ]]; then
last_used="Yesterday"
elif [[ $days_ago -lt 7 ]]; then
last_used="${days_ago} days ago"
elif [[ $days_ago -lt 30 ]]; then
local weeks_ago=$((days_ago / 7))
[[ $weeks_ago -eq 1 ]] && last_used="1 week ago" || last_used="${weeks_ago} weeks ago"
elif [[ $days_ago -lt 365 ]]; then
local months_ago=$((days_ago / 30))
[[ $months_ago -eq 1 ]] && last_used="1 month ago" || last_used="${months_ago} months ago"
else
local years_ago=$((days_ago / 365))
[[ $years_ago -eq 1 ]] && last_used="1 year ago" || last_used="${years_ago} years ago"
fi
fi
fi
echo "${last_used_epoch}|${app_path}|${display_name}|${bundle_id}|${app_size}|${last_used}|${app_size_kb}" >> "$output_file"
}
export -f process_app_metadata
local progress_file="${temp_file}.progress"
echo "0" > "$progress_file"
(
# shellcheck disable=SC2329 # Function invoked indirectly via trap
cleanup_spinner() { exit 0; }
trap cleanup_spinner TERM INT EXIT
local spinner_chars="|/-\\"
local i=0
while true; do
local completed=$(cat "$progress_file" 2> /dev/null || echo 0)
local c="${spinner_chars:$((i % 4)):1}"
if [[ $inline_loading == true ]]; then
printf "\033[H\033[2K%s Scanning applications... %d/%d\n" "$c" "$completed" "$total_apps" >&2
else
printf "\r\033[K%s Scanning applications... %d/%d" "$c" "$completed" "$total_apps" >&2
fi
((i++))
sleep 0.1 2> /dev/null || sleep 1
done
) &
spinner_pid=$!
for app_data_tuple in "${app_data_tuples[@]}"; do
((app_count++))
process_app_metadata "$app_data_tuple" "$temp_file" "$current_epoch" &
pids+=($!)
echo "$app_count" > "$progress_file"
if ((${#pids[@]} >= max_parallel)); then
wait "${pids[0]}" 2> /dev/null
pids=("${pids[@]:1}")
fi
done
for pid in "${pids[@]}"; do
wait "$pid" 2> /dev/null
done
if [[ -n "$spinner_pid" ]]; then
kill -TERM "$spinner_pid" 2> /dev/null || true
wait "$spinner_pid" 2> /dev/null || true
fi
if [[ $inline_loading == true ]]; then
printf "\033[H\033[2K" >&2
else
echo -ne "\r\033[K" >&2
fi
rm -f "$progress_file"
if [[ ! -s "$temp_file" ]]; then
echo "No applications found to uninstall" >&2
rm -f "$temp_file"
return 1
fi
if [[ $total_apps -gt 50 ]]; then
if [[ $inline_loading == true ]]; then
printf "\033[H\033[2KProcessing %d applications...\n" "$total_apps" >&2
else
printf "\rProcessing %d applications... " "$total_apps" >&2
fi
fi
sort -t'|' -k1,1n "$temp_file" > "${temp_file}.sorted" || {
rm -f "$temp_file"
return 1
}
rm -f "$temp_file"
if [[ $total_apps -gt 50 ]]; then
if [[ $inline_loading == true ]]; then
printf "\033[H\033[2K" >&2
else
printf "\r\033[K" >&2
fi
fi
ensure_user_file "$cache_file"
cp "${temp_file}.sorted" "$cache_file" 2> /dev/null || true
if [[ -f "${temp_file}.sorted" ]]; then
echo "${temp_file}.sorted"
else
return 1
fi
}
load_applications() {
local apps_file="$1"
if [[ ! -f "$apps_file" || ! -s "$apps_file" ]]; then
log_warning "No applications found for uninstallation"
return 1
fi
apps_data=()
selection_state=()
while IFS='|' read -r epoch app_path app_name bundle_id size last_used size_kb; do
[[ ! -e "$app_path" ]] && continue
apps_data+=("$epoch|$app_path|$app_name|$bundle_id|$size|$last_used|${size_kb:-0}")
selection_state+=(false)
done < "$apps_file"
if [[ ${#apps_data[@]} -eq 0 ]]; then
log_warning "No applications available for uninstallation"
return 1
fi
return 0
}
# Cleanup: restore cursor and kill keepalive.
cleanup() {
if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then
leave_alt_screen
unset MOLE_ALT_SCREEN_ACTIVE
fi
if [[ -n "${sudo_keepalive_pid:-}" ]]; then
kill "$sudo_keepalive_pid" 2> /dev/null || true
wait "$sudo_keepalive_pid" 2> /dev/null || true
sudo_keepalive_pid=""
fi
show_cursor
exit "${1:-0}"
}
trap cleanup EXIT INT TERM
main() {
local force_rescan=false
# Global flags
for arg in "$@"; do
case "$arg" in
"--debug")
export MO_DEBUG=1
;;
esac
done
local use_inline_loading=false
if [[ -t 1 && -t 2 ]]; then
use_inline_loading=true
fi
hide_cursor
while true; do
local needs_scanning=true
local cache_file="$HOME/.cache/mole/app_scan_cache"
if [[ $force_rescan == false && -f "$cache_file" ]]; then
local cache_age=$(($(get_epoch_seconds) - $(get_file_mtime "$cache_file")))
[[ $cache_age -eq $(get_epoch_seconds) ]] && cache_age=86401
[[ $cache_age -lt 86400 ]] && needs_scanning=false
fi
if [[ $needs_scanning == true && $use_inline_loading == true ]]; then
if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" != "1" ]]; then
enter_alt_screen
export MOLE_ALT_SCREEN_ACTIVE=1
export MOLE_INLINE_LOADING=1
export MOLE_MANAGED_ALT_SCREEN=1
fi
printf "\033[2J\033[H" >&2
else
unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN MOLE_ALT_SCREEN_ACTIVE
if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then
leave_alt_screen
unset MOLE_ALT_SCREEN_ACTIVE
fi
fi
local apps_file=""
if ! apps_file=$(scan_applications "$force_rescan"); then
if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then
printf "\033[2J\033[H" >&2
leave_alt_screen
unset MOLE_ALT_SCREEN_ACTIVE
unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN
fi
return 1
fi
if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then
printf "\033[2J\033[H" >&2
fi
if [[ ! -f "$apps_file" ]]; then
if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then
leave_alt_screen
unset MOLE_ALT_SCREEN_ACTIVE
unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN
fi
return 1
fi
if ! load_applications "$apps_file"; then
if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then
leave_alt_screen
unset MOLE_ALT_SCREEN_ACTIVE
unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN
fi
rm -f "$apps_file"
return 1
fi
set +e
select_apps_for_uninstall
local exit_code=$?
set -e
if [[ $exit_code -ne 0 ]]; then
if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then
leave_alt_screen
unset MOLE_ALT_SCREEN_ACTIVE
unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN
fi
show_cursor
clear_screen
printf '\033[2J\033[H' >&2
rm -f "$apps_file"
if [[ $exit_code -eq 10 ]]; then
force_rescan=true
continue
fi
return 0
fi
if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then
leave_alt_screen
unset MOLE_ALT_SCREEN_ACTIVE
unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN
fi
show_cursor
clear_screen
printf '\033[2J\033[H' >&2
local selection_count=${#selected_apps[@]}
if [[ $selection_count -eq 0 ]]; then
echo "No apps selected"
rm -f "$apps_file"
continue
fi
echo -e "${BLUE}${ICON_CONFIRM}${NC} Selected ${selection_count} app(s):"
local -a summary_rows=()
local max_name_display_width=0
local max_size_width=0
local max_last_width=0
for selected_app in "${selected_apps[@]}"; do
IFS='|' read -r _ _ app_name _ size last_used _ <<< "$selected_app"
local name_width=$(get_display_width "$app_name")
[[ $name_width -gt $max_name_display_width ]] && max_name_display_width=$name_width
local size_display="$size"
[[ -z "$size_display" || "$size_display" == "0" || "$size_display" == "N/A" ]] && size_display="Unknown"
[[ ${#size_display} -gt $max_size_width ]] && max_size_width=${#size_display}
local last_display=$(format_last_used_summary "$last_used")
[[ ${#last_display} -gt $max_last_width ]] && max_last_width=${#last_display}
done
((max_size_width < 5)) && max_size_width=5
((max_last_width < 5)) && max_last_width=5
local term_width=$(tput cols 2> /dev/null || echo 100)
local available_for_name=$((term_width - 17 - max_size_width - max_last_width))
local min_name_width=24
if [[ $term_width -ge 120 ]]; then
min_name_width=50
elif [[ $term_width -ge 100 ]]; then
min_name_width=42
elif [[ $term_width -ge 80 ]]; then
min_name_width=30
fi
local name_trunc_limit=$max_name_display_width
[[ $name_trunc_limit -lt $min_name_width ]] && name_trunc_limit=$min_name_width
[[ $name_trunc_limit -gt $available_for_name ]] && name_trunc_limit=$available_for_name
[[ $name_trunc_limit -gt 60 ]] && name_trunc_limit=60
max_name_display_width=0
for selected_app in "${selected_apps[@]}"; do
IFS='|' read -r epoch app_path app_name bundle_id size last_used size_kb <<< "$selected_app"
local display_name
display_name=$(truncate_by_display_width "$app_name" "$name_trunc_limit")
local current_width
current_width=$(get_display_width "$display_name")
[[ $current_width -gt $max_name_display_width ]] && max_name_display_width=$current_width
local size_display="$size"
if [[ -z "$size_display" || "$size_display" == "0" || "$size_display" == "N/A" ]]; then
size_display="Unknown"
fi
local last_display
last_display=$(format_last_used_summary "$last_used")
summary_rows+=("$display_name|$size_display|$last_display")
done
((max_name_display_width < 16)) && max_name_display_width=16
local index=1
for row in "${summary_rows[@]}"; do
IFS='|' read -r name_cell size_cell last_cell <<< "$row"
local name_display_width
name_display_width=$(get_display_width "$name_cell")
local name_char_count=${#name_cell}
local padding_needed=$((max_name_display_width - name_display_width))
local printf_name_width=$((name_char_count + padding_needed))
printf "%d. %-*s %*s | Last: %s\n" "$index" "$printf_name_width" "$name_cell" "$max_size_width" "$size_cell" "$last_cell"
((index++))
done
batch_uninstall_applications
rm -f "$apps_file"
echo -e "${GRAY}Press Enter to return to application list, any other key to exit...${NC}"
local key
IFS= read -r -s -n1 key || key=""
drain_pending_input
if [[ -z "$key" ]]; then
:
else
show_cursor
return 0
fi
force_rescan=false
done
}
main "$@"

View File

@@ -1,666 +0,0 @@
#!/bin/bash
# Mole - Uninstall Module
# Interactive application uninstaller with keyboard navigation
#
# Usage:
# uninstall.sh # Launch interactive uninstaller
# uninstall.sh --force-rescan # Rescan apps and refresh cache
set -euo pipefail
# Fix locale issues (avoid Perl warnings on non-English systems)
export LC_ALL=C
export LANG=C
# Get script directory and source common functions
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../lib/core/common.sh"
source "$SCRIPT_DIR/../lib/ui/menu_paginated.sh"
source "$SCRIPT_DIR/../lib/ui/app_selector.sh"
source "$SCRIPT_DIR/../lib/uninstall/batch.sh"
# Note: Bundle preservation logic is now in lib/core/common.sh
# Initialize global variables
selected_apps=() # Global array for app selection
declare -a apps_data=()
declare -a selection_state=()
total_items=0
files_cleaned=0
total_size_cleaned=0
# Compact the "last used" descriptor for aligned summaries
format_last_used_summary() {
local value="$1"
case "$value" in
"" | "Unknown")
echo "Unknown"
return 0
;;
"Never" | "Recent" | "Today" | "Yesterday" | "This year" | "Old")
echo "$value"
return 0
;;
esac
if [[ $value =~ ^([0-9]+)[[:space:]]+days?\ ago$ ]]; then
echo "${BASH_REMATCH[1]}d ago"
return 0
fi
if [[ $value =~ ^([0-9]+)[[:space:]]+weeks?\ ago$ ]]; then
echo "${BASH_REMATCH[1]}w ago"
return 0
fi
if [[ $value =~ ^([0-9]+)[[:space:]]+months?\ ago$ ]]; then
echo "${BASH_REMATCH[1]}m ago"
return 0
fi
if [[ $value =~ ^([0-9]+)[[:space:]]+month\(s\)\ ago$ ]]; then
echo "${BASH_REMATCH[1]}m ago"
return 0
fi
if [[ $value =~ ^([0-9]+)[[:space:]]+years?\ ago$ ]]; then
echo "${BASH_REMATCH[1]}y ago"
return 0
fi
echo "$value"
}
# Scan applications and collect information
scan_applications() {
# Simplified cache: only check timestamp (24h TTL)
local cache_dir="$HOME/.cache/mole"
local cache_file="$cache_dir/app_scan_cache"
local cache_ttl=86400 # 24 hours
local force_rescan="${1:-false}"
ensure_user_dir "$cache_dir"
# Check if cache exists and is fresh
if [[ $force_rescan == false && -f "$cache_file" ]]; then
local cache_age=$(($(get_epoch_seconds) - $(get_file_mtime "$cache_file")))
[[ $cache_age -eq $(get_epoch_seconds) ]] && cache_age=86401 # Handle missing file
if [[ $cache_age -lt $cache_ttl ]]; then
# Cache hit - return immediately
# Show brief flash of cache usage if in interactive mode
if [[ -t 2 ]]; then
echo -e "${GREEN}Loading from cache...${NC}" >&2
# Small sleep to let user see it (optional, but good for "feeling" the speed vs glitch)
sleep 0.3
fi
echo "$cache_file"
return 0
fi
fi
# Cache miss - prepare for scanning
local inline_loading=false
if [[ -t 1 && -t 2 ]]; then
inline_loading=true
# Clear screen for inline loading
printf "\033[2J\033[H" >&2
fi
local temp_file
temp_file=$(create_temp_file)
# Pre-cache current epoch to avoid repeated calls
local current_epoch
current_epoch=$(get_epoch_seconds)
# First pass: quickly collect all valid app paths and bundle IDs (NO mdls calls)
local -a app_data_tuples=()
local -a app_dirs=(
"/Applications"
"$HOME/Applications"
)
local vol_app_dir
local nullglob_was_set=0
shopt -q nullglob && nullglob_was_set=1
shopt -s nullglob
for vol_app_dir in /Volumes/*/Applications; do
[[ -d "$vol_app_dir" && -r "$vol_app_dir" ]] || continue
if [[ -d "/Applications" && "$vol_app_dir" -ef "/Applications" ]]; then
continue
fi
if [[ -d "$HOME/Applications" && "$vol_app_dir" -ef "$HOME/Applications" ]]; then
continue
fi
app_dirs+=("$vol_app_dir")
done
if [[ $nullglob_was_set -eq 0 ]]; then
shopt -u nullglob
fi
for app_dir in "${app_dirs[@]}"; do
if [[ ! -d "$app_dir" ]]; then continue; fi
while IFS= read -r -d '' app_path; do
if [[ ! -e "$app_path" ]]; then continue; fi
local app_name
app_name=$(basename "$app_path" .app)
# Skip nested apps (e.g. inside Wrapper/ or Frameworks/ of another app)
# Check if parent path component ends in .app (e.g. /Foo.app/Bar.app or /Foo.app/Contents/Bar.app)
# This prevents false positives like /Old.apps/Target.app
local parent_dir
parent_dir=$(dirname "$app_path")
if [[ "$parent_dir" == *".app" || "$parent_dir" == *".app/"* ]]; then
continue
fi
# Get bundle ID only (fast, no mdls calls in first pass)
local bundle_id="unknown"
if [[ -f "$app_path/Contents/Info.plist" ]]; then
bundle_id=$(defaults read "$app_path/Contents/Info.plist" CFBundleIdentifier 2> /dev/null || echo "unknown")
fi
# Skip system critical apps (input methods, system components)
if should_protect_from_uninstall "$bundle_id"; then
continue
fi
# Store tuple: app_path|app_name|bundle_id (display_name will be resolved in parallel later)
app_data_tuples+=("${app_path}|${app_name}|${bundle_id}")
done < <(command find "$app_dir" -name "*.app" -maxdepth 3 -print0 2> /dev/null)
done
# Second pass: process each app with parallel size calculation
local app_count=0
local total_apps=${#app_data_tuples[@]}
# Bound parallelism - for metadata queries, can go higher since it's mostly waiting
local max_parallel
max_parallel=$(get_optimal_parallel_jobs "io")
if [[ $max_parallel -lt 8 ]]; then
max_parallel=8
elif [[ $max_parallel -gt 32 ]]; then
max_parallel=32
fi
local pids=()
# inline_loading variable already set above (line ~92)
# Process app metadata extraction function
process_app_metadata() {
local app_data_tuple="$1"
local output_file="$2"
local current_epoch="$3"
IFS='|' read -r app_path app_name bundle_id <<< "$app_data_tuple"
# Get localized display name (moved from first pass for better performance)
local display_name="$app_name"
if [[ -f "$app_path/Contents/Info.plist" ]]; then
# Try to get localized name from system metadata (best for i18n)
local md_display_name
md_display_name=$(run_with_timeout 0.05 mdls -name kMDItemDisplayName -raw "$app_path" 2> /dev/null || echo "")
# Get bundle names
local bundle_display_name
bundle_display_name=$(plutil -extract CFBundleDisplayName raw "$app_path/Contents/Info.plist" 2> /dev/null)
local bundle_name
bundle_name=$(plutil -extract CFBundleName raw "$app_path/Contents/Info.plist" 2> /dev/null)
# Priority order for name selection (prefer localized names):
# 1. System metadata display name (kMDItemDisplayName) - respects system language
# 2. CFBundleDisplayName - usually localized
# 3. CFBundleName - fallback
# 4. App folder name - last resort
if [[ -n "$md_display_name" && "$md_display_name" != "(null)" && "$md_display_name" != "$app_name" ]]; then
display_name="$md_display_name"
elif [[ -n "$bundle_display_name" && "$bundle_display_name" != "(null)" ]]; then
display_name="$bundle_display_name"
elif [[ -n "$bundle_name" && "$bundle_name" != "(null)" ]]; then
display_name="$bundle_name"
fi
fi
# Parallel size calculation
local app_size="N/A"
local app_size_kb="0"
if [[ -d "$app_path" ]]; then
# Get size in KB, then format for display
app_size_kb=$(get_path_size_kb "$app_path")
app_size=$(bytes_to_human "$((app_size_kb * 1024))")
fi
# Get last used date
local last_used="Never"
local last_used_epoch=0
if [[ -d "$app_path" ]]; then
# Try mdls first with short timeout (0.1s) for accuracy, fallback to mtime for speed
local metadata_date
metadata_date=$(run_with_timeout 0.1 mdls -name kMDItemLastUsedDate -raw "$app_path" 2> /dev/null || echo "")
if [[ "$metadata_date" != "(null)" && -n "$metadata_date" ]]; then
last_used_epoch=$(date -j -f "%Y-%m-%d %H:%M:%S %z" "$metadata_date" "+%s" 2> /dev/null || echo "0")
fi
# Fallback if mdls failed or returned nothing
if [[ "$last_used_epoch" -eq 0 ]]; then
last_used_epoch=$(get_file_mtime "$app_path")
fi
if [[ $last_used_epoch -gt 0 ]]; then
local days_ago=$(((current_epoch - last_used_epoch) / 86400))
if [[ $days_ago -eq 0 ]]; then
last_used="Today"
elif [[ $days_ago -eq 1 ]]; then
last_used="Yesterday"
elif [[ $days_ago -lt 7 ]]; then
last_used="${days_ago} days ago"
elif [[ $days_ago -lt 30 ]]; then
local weeks_ago=$((days_ago / 7))
[[ $weeks_ago -eq 1 ]] && last_used="1 week ago" || last_used="${weeks_ago} weeks ago"
elif [[ $days_ago -lt 365 ]]; then
local months_ago=$((days_ago / 30))
[[ $months_ago -eq 1 ]] && last_used="1 month ago" || last_used="${months_ago} months ago"
else
local years_ago=$((days_ago / 365))
[[ $years_ago -eq 1 ]] && last_used="1 year ago" || last_used="${years_ago} years ago"
fi
fi
fi
# Write to output file atomically
# Fields: epoch|app_path|display_name|bundle_id|size_human|last_used|size_kb
echo "${last_used_epoch}|${app_path}|${display_name}|${bundle_id}|${app_size}|${last_used}|${app_size_kb}" >> "$output_file"
}
export -f process_app_metadata
# Create a temporary file to track progress
local progress_file="${temp_file}.progress"
echo "0" > "$progress_file"
# Start a background spinner that reads progress from file
local spinner_pid=""
(
# shellcheck disable=SC2329 # Function invoked indirectly via trap
cleanup_spinner() { exit 0; }
trap cleanup_spinner TERM INT EXIT
local spinner_chars="|/-\\"
local i=0
while true; do
local completed=$(cat "$progress_file" 2> /dev/null || echo 0)
local c="${spinner_chars:$((i % 4)):1}"
if [[ $inline_loading == true ]]; then
printf "\033[H\033[2K%s Scanning applications... %d/%d\n" "$c" "$completed" "$total_apps" >&2
else
printf "\r\033[K%s Scanning applications... %d/%d" "$c" "$completed" "$total_apps" >&2
fi
((i++))
sleep 0.1 2> /dev/null || sleep 1
done
) &
spinner_pid=$!
# Process apps in parallel batches
for app_data_tuple in "${app_data_tuples[@]}"; do
((app_count++))
# Launch background process
process_app_metadata "$app_data_tuple" "$temp_file" "$current_epoch" &
pids+=($!)
# Update progress to show scanning progress (use app_count as it increments smoothly)
echo "$app_count" > "$progress_file"
# Wait if we've hit max parallel limit
if ((${#pids[@]} >= max_parallel)); then
wait "${pids[0]}" 2> /dev/null
pids=("${pids[@]:1}") # Remove first pid
fi
done
# Wait for remaining background processes
for pid in "${pids[@]}"; do
wait "$pid" 2> /dev/null
done
# Stop the spinner and clear the line
if [[ -n "$spinner_pid" ]]; then
kill -TERM "$spinner_pid" 2> /dev/null || true
wait "$spinner_pid" 2> /dev/null || true
fi
if [[ $inline_loading == true ]]; then
printf "\033[H\033[2K" >&2
else
echo -ne "\r\033[K" >&2
fi
rm -f "$progress_file"
# Check if we found any applications
if [[ ! -s "$temp_file" ]]; then
echo "No applications found to uninstall" >&2
rm -f "$temp_file"
return 1
fi
# Sort by last used (oldest first) and cache the result
# Show brief processing message for large app lists
if [[ $total_apps -gt 50 ]]; then
if [[ $inline_loading == true ]]; then
printf "\033[H\033[2KProcessing %d applications...\n" "$total_apps" >&2
else
printf "\rProcessing %d applications... " "$total_apps" >&2
fi
fi
sort -t'|' -k1,1n "$temp_file" > "${temp_file}.sorted" || {
rm -f "$temp_file"
return 1
}
rm -f "$temp_file"
# Clear processing message
if [[ $total_apps -gt 50 ]]; then
if [[ $inline_loading == true ]]; then
printf "\033[H\033[2K" >&2
else
printf "\r\033[K" >&2
fi
fi
# Save to cache (simplified - no metadata)
ensure_user_file "$cache_file"
cp "${temp_file}.sorted" "$cache_file" 2> /dev/null || true
# Return sorted file
if [[ -f "${temp_file}.sorted" ]]; then
echo "${temp_file}.sorted"
else
return 1
fi
}
load_applications() {
local apps_file="$1"
if [[ ! -f "$apps_file" || ! -s "$apps_file" ]]; then
log_warning "No applications found for uninstallation"
return 1
fi
# Clear arrays
apps_data=()
selection_state=()
# Read apps into array, skip non-existent apps
while IFS='|' read -r epoch app_path app_name bundle_id size last_used size_kb; do
# Skip if app path no longer exists
[[ ! -e "$app_path" ]] && continue
apps_data+=("$epoch|$app_path|$app_name|$bundle_id|$size|$last_used|${size_kb:-0}")
selection_state+=(false)
done < "$apps_file"
if [[ ${#apps_data[@]} -eq 0 ]]; then
log_warning "No applications available for uninstallation"
return 1
fi
return 0
}
# Cleanup function - restore cursor and clean up
cleanup() {
# Restore cursor using common function
if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then
leave_alt_screen
unset MOLE_ALT_SCREEN_ACTIVE
fi
if [[ -n "${sudo_keepalive_pid:-}" ]]; then
kill "$sudo_keepalive_pid" 2> /dev/null || true
wait "$sudo_keepalive_pid" 2> /dev/null || true
sudo_keepalive_pid=""
fi
show_cursor
exit "${1:-0}"
}
# Set trap for cleanup on exit
trap cleanup EXIT INT TERM
main() {
local force_rescan=false
for arg in "$@"; do
case "$arg" in
"--debug")
export MO_DEBUG=1
;;
"--force-rescan")
force_rescan=true
;;
esac
done
local use_inline_loading=false
if [[ -t 1 && -t 2 ]]; then
use_inline_loading=true
fi
# Hide cursor during operation
hide_cursor
# Main interaction loop
while true; do
# Simplified: always check if we need alt screen for scanning
# (scan_applications handles cache internally)
local needs_scanning=true
local cache_file="$HOME/.cache/mole/app_scan_cache"
if [[ $force_rescan == false && -f "$cache_file" ]]; then
local cache_age=$(($(get_epoch_seconds) - $(get_file_mtime "$cache_file")))
[[ $cache_age -eq $(get_epoch_seconds) ]] && cache_age=86401 # Handle missing file
[[ $cache_age -lt 86400 ]] && needs_scanning=false
fi
# Only enter alt screen if we need scanning (shows progress)
if [[ $needs_scanning == true && $use_inline_loading == true ]]; then
# Only enter if not already active
if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" != "1" ]]; then
enter_alt_screen
export MOLE_ALT_SCREEN_ACTIVE=1
export MOLE_INLINE_LOADING=1
export MOLE_MANAGED_ALT_SCREEN=1
fi
printf "\033[2J\033[H" >&2
else
# If we don't need scanning but have alt screen from previous iteration, keep it?
# Actually, scan_applications might output to stderr.
# Let's just unset the flags if we don't need scanning, but keep alt screen if it was active?
# No, select_apps_for_uninstall will handle its own screen management.
unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN MOLE_ALT_SCREEN_ACTIVE
if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then
leave_alt_screen
unset MOLE_ALT_SCREEN_ACTIVE
fi
fi
# Scan applications
local apps_file=""
if ! apps_file=$(scan_applications "$force_rescan"); then
if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then
printf "\033[2J\033[H" >&2
leave_alt_screen
unset MOLE_ALT_SCREEN_ACTIVE
unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN
fi
return 1
fi
if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then
printf "\033[2J\033[H" >&2
fi
if [[ ! -f "$apps_file" ]]; then
# Error message already shown by scan_applications
if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then
leave_alt_screen
unset MOLE_ALT_SCREEN_ACTIVE
unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN
fi
return 1
fi
# Load applications
if ! load_applications "$apps_file"; then
if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then
leave_alt_screen
unset MOLE_ALT_SCREEN_ACTIVE
unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN
fi
rm -f "$apps_file"
return 1
fi
# Interactive selection using paginated menu
set +e
select_apps_for_uninstall
local exit_code=$?
set -e
if [[ $exit_code -ne 0 ]]; then
if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then
leave_alt_screen
unset MOLE_ALT_SCREEN_ACTIVE
unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN
fi
show_cursor
clear_screen
printf '\033[2J\033[H' >&2 # Also clear stderr
rm -f "$apps_file"
# Handle Refresh (code 10)
if [[ $exit_code -eq 10 ]]; then
force_rescan=true
continue
fi
# User cancelled selection, exit the loop
return 0
fi
# Always clear on exit from selection, regardless of alt screen state
if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then
leave_alt_screen
unset MOLE_ALT_SCREEN_ACTIVE
unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN
fi
# Restore cursor and clear screen (output to both stdout and stderr for reliability)
show_cursor
clear_screen
printf '\033[2J\033[H' >&2 # Also clear stderr in case of mixed output
local selection_count=${#selected_apps[@]}
if [[ $selection_count -eq 0 ]]; then
echo "No apps selected"
rm -f "$apps_file"
# Loop back or exit? If select_apps_for_uninstall returns 0 but empty selection,
# it technically shouldn't happen based on that function's logic.
continue
fi
# Show selected apps with clean alignment
echo -e "${BLUE}${ICON_CONFIRM}${NC} Selected ${selection_count} app(s):"
local -a summary_rows=()
local max_name_width=0
local max_size_width=0
local max_last_width=0
# First pass: get actual max widths for all columns
for selected_app in "${selected_apps[@]}"; do
IFS='|' read -r _ _ app_name _ size last_used _ <<< "$selected_app"
[[ ${#app_name} -gt $max_name_width ]] && max_name_width=${#app_name}
local size_display="$size"
[[ -z "$size_display" || "$size_display" == "0" || "$size_display" == "N/A" ]] && size_display="Unknown"
[[ ${#size_display} -gt $max_size_width ]] && max_size_width=${#size_display}
local last_display=$(format_last_used_summary "$last_used")
[[ ${#last_display} -gt $max_last_width ]] && max_last_width=${#last_display}
done
((max_size_width < 5)) && max_size_width=5
((max_last_width < 5)) && max_last_width=5
# Calculate name width: use actual max, but constrain by terminal width
# Fixed elements: "99. " (4) + " " (2) + " | Last: " (11) = 17
local term_width=$(tput cols 2> /dev/null || echo 100)
local available_for_name=$((term_width - 17 - max_size_width - max_last_width))
# Dynamic minimum for better spacing on wide terminals
local min_name_width=24
if [[ $term_width -ge 120 ]]; then
min_name_width=50
elif [[ $term_width -ge 100 ]]; then
min_name_width=42
elif [[ $term_width -ge 80 ]]; then
min_name_width=30
fi
# Constrain name width: dynamic min, max min(actual_max, available, 60)
local name_trunc_limit=$max_name_width
[[ $name_trunc_limit -lt $min_name_width ]] && name_trunc_limit=$min_name_width
[[ $name_trunc_limit -gt $available_for_name ]] && name_trunc_limit=$available_for_name
[[ $name_trunc_limit -gt 60 ]] && name_trunc_limit=60
# Reset for second pass
max_name_width=0
for selected_app in "${selected_apps[@]}"; do
IFS='|' read -r epoch app_path app_name bundle_id size last_used size_kb <<< "$selected_app"
local display_name="$app_name"
if [[ ${#display_name} -gt $name_trunc_limit ]]; then
display_name="${display_name:0:$((name_trunc_limit - 3))}..."
fi
[[ ${#display_name} -gt $max_name_width ]] && max_name_width=${#display_name}
local size_display="$size"
if [[ -z "$size_display" || "$size_display" == "0" || "$size_display" == "N/A" ]]; then
size_display="Unknown"
fi
local last_display
last_display=$(format_last_used_summary "$last_used")
summary_rows+=("$display_name|$size_display|$last_display")
done
((max_name_width < 16)) && max_name_width=16
local index=1
for row in "${summary_rows[@]}"; do
IFS='|' read -r name_cell size_cell last_cell <<< "$row"
printf "%d. %-*s %*s | Last: %s\n" "$index" "$max_name_width" "$name_cell" "$max_size_width" "$size_cell" "$last_cell"
((index++))
done
# Execute batch uninstallation (handles confirmation)
batch_uninstall_applications
# Cleanup current apps file
rm -f "$apps_file"
# Pause before looping back
echo -e "${GRAY}Press Enter to return to application list, ESC to exit...${NC}"
local key
IFS= read -r -s -n1 key || key=""
drain_pending_input # Clean up any escape sequence remnants
case "$key" in
$'\e' | q | Q)
show_cursor
return 0
;;
*)
# Continue loop
;;
esac
# Reset force_rescan to false for subsequent loops,
# but relying on batch_uninstall's cache deletion for actual update
force_rescan=false
done
}
# Run main function