mirror of
https://github.com/tw93/Mole.git
synced 2026-02-04 13:16:47 +00:00
chore: remove windows support from dev branch (moved to dedicated windows branch)
This commit is contained in:
196
.github/workflows/windows.yml
vendored
196
.github/workflows/windows.yml
vendored
@@ -1,196 +0,0 @@
|
||||
name: Windows CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- 'windows/**'
|
||||
- '.github/workflows/windows.yml'
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- 'windows/**'
|
||||
- '.github/workflows/windows.yml'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build & Test
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
cache-dependency-path: windows/go.sum
|
||||
|
||||
- name: Build Go binaries
|
||||
working-directory: windows
|
||||
run: |
|
||||
go build -o bin/analyze.exe ./cmd/analyze/
|
||||
go build -o bin/status.exe ./cmd/status/
|
||||
|
||||
- name: Run Go tests
|
||||
working-directory: windows
|
||||
run: go test -v ./...
|
||||
|
||||
- name: Validate PowerShell syntax
|
||||
shell: pwsh
|
||||
run: |
|
||||
$scripts = Get-ChildItem -Path windows -Filter "*.ps1" -Recurse
|
||||
$errors = @()
|
||||
foreach ($script in $scripts) {
|
||||
$parseErrors = $null
|
||||
$null = [System.Management.Automation.Language.Parser]::ParseFile(
|
||||
$script.FullName,
|
||||
[ref]$null,
|
||||
[ref]$parseErrors
|
||||
)
|
||||
if ($parseErrors) {
|
||||
Write-Host "ERROR: $($script.FullName)" -ForegroundColor Red
|
||||
foreach ($err in $parseErrors) {
|
||||
Write-Host " $($err.Message)" -ForegroundColor Red
|
||||
}
|
||||
$errors += $script.FullName
|
||||
} else {
|
||||
Write-Host "OK: $($script.Name)" -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
if ($errors.Count -gt 0) {
|
||||
Write-Host "`n$($errors.Count) script(s) have syntax errors!" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
pester:
|
||||
name: Pester Tests
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Pester
|
||||
shell: pwsh
|
||||
run: |
|
||||
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
|
||||
Install-Module -Name Pester -MinimumVersion 5.0.0 -Force -SkipPublisherCheck
|
||||
|
||||
- name: Run Pester tests
|
||||
shell: pwsh
|
||||
run: |
|
||||
Import-Module Pester -MinimumVersion 5.0.0
|
||||
|
||||
$config = New-PesterConfiguration
|
||||
$config.Run.Path = "windows/tests"
|
||||
$config.Run.Exit = $true
|
||||
$config.Output.Verbosity = "Detailed"
|
||||
$config.TestResult.Enabled = $true
|
||||
$config.TestResult.OutputPath = "windows/test-results.xml"
|
||||
$config.TestResult.OutputFormat = "NUnitXml"
|
||||
|
||||
Invoke-Pester -Configuration $config
|
||||
|
||||
- name: Upload test results
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: pester-results
|
||||
path: windows/test-results.xml
|
||||
|
||||
compatibility:
|
||||
name: Windows Compatibility
|
||||
strategy:
|
||||
matrix:
|
||||
os: [windows-2022, windows-2019]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Test PowerShell 5.1
|
||||
shell: powershell
|
||||
run: |
|
||||
Write-Host "Testing on ${{ matrix.os }} with PowerShell $($PSVersionTable.PSVersion)"
|
||||
|
||||
# Test main entry point
|
||||
$result = & powershell -ExecutionPolicy Bypass -File "windows\mole.ps1" -ShowHelp 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "mole.ps1 -ShowHelp failed" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
Write-Host "✓ mole.ps1 works on ${{ matrix.os }}"
|
||||
|
||||
- name: Test command scripts
|
||||
shell: powershell
|
||||
run: |
|
||||
$commands = @("clean", "uninstall", "optimize", "purge", "analyze", "status")
|
||||
foreach ($cmd in $commands) {
|
||||
$scriptPath = "windows\bin\$cmd.ps1"
|
||||
if (Test-Path $scriptPath) {
|
||||
$result = & powershell -ExecutionPolicy Bypass -File $scriptPath -ShowHelp 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "✗ $cmd.ps1 failed" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
Write-Host "✓ $cmd.ps1 works"
|
||||
}
|
||||
}
|
||||
|
||||
security:
|
||||
name: Security Checks
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Check for unsafe patterns
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "Checking for unsafe removal patterns..."
|
||||
|
||||
$unsafePatterns = @(
|
||||
"Remove-Item.*-Recurse.*-Force.*\\\$env:SystemRoot",
|
||||
"Remove-Item.*-Recurse.*-Force.*C:\\Windows",
|
||||
"Remove-Item.*-Recurse.*-Force.*C:\\Program Files"
|
||||
)
|
||||
|
||||
$files = Get-ChildItem -Path windows -Filter "*.ps1" -Recurse
|
||||
$issues = @()
|
||||
|
||||
foreach ($file in $files) {
|
||||
$content = Get-Content $file.FullName -Raw
|
||||
foreach ($pattern in $unsafePatterns) {
|
||||
if ($content -match $pattern) {
|
||||
$issues += "$($file.Name): matches unsafe pattern"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($issues.Count -gt 0) {
|
||||
Write-Host "Unsafe patterns found:" -ForegroundColor Red
|
||||
$issues | ForEach-Object { Write-Host " $_" -ForegroundColor Red }
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "✓ No unsafe patterns found" -ForegroundColor Green
|
||||
|
||||
- name: Verify protection checks
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "Verifying protection logic..."
|
||||
|
||||
# Source file_ops to get Test-IsProtectedPath
|
||||
. windows\lib\core\base.ps1
|
||||
. windows\lib\core\file_ops.ps1
|
||||
|
||||
$protectedPaths = @(
|
||||
"C:\Windows",
|
||||
"C:\Windows\System32",
|
||||
"C:\Program Files",
|
||||
"C:\Program Files (x86)"
|
||||
)
|
||||
|
||||
foreach ($path in $protectedPaths) {
|
||||
if (-not (Test-ProtectedPath -Path $path)) {
|
||||
Write-Host "✗ $path should be protected!" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
Write-Host "✓ $path is protected" -ForegroundColor Green
|
||||
}
|
||||
16
windows/.gitignore
vendored
16
windows/.gitignore
vendored
@@ -1,16 +0,0 @@
|
||||
# Windows Mole - .gitignore
|
||||
|
||||
# Build artifacts
|
||||
bin/*.exe
|
||||
|
||||
# Go build cache
|
||||
.gocache/
|
||||
|
||||
# IDE files
|
||||
.idea/
|
||||
.vscode/
|
||||
*.code-workspace
|
||||
|
||||
# Test artifacts
|
||||
*.test
|
||||
coverage.out
|
||||
@@ -1,44 +0,0 @@
|
||||
# Mole Windows - Makefile
|
||||
# Build Go tools for Windows
|
||||
|
||||
.PHONY: all build clean analyze status
|
||||
|
||||
# Default target
|
||||
all: build
|
||||
|
||||
# Build both tools
|
||||
build: analyze status
|
||||
|
||||
# Build analyze tool
|
||||
analyze:
|
||||
@echo "Building analyze..."
|
||||
@go build -o bin/analyze.exe ./cmd/analyze/
|
||||
|
||||
# Build status tool
|
||||
status:
|
||||
@echo "Building status..."
|
||||
@go build -o bin/status.exe ./cmd/status/
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
@echo "Cleaning..."
|
||||
@rm -f bin/analyze.exe bin/status.exe
|
||||
|
||||
# Install (copy to PATH)
|
||||
install: build
|
||||
@echo "Installing to $(USERPROFILE)/bin..."
|
||||
@mkdir -p "$(USERPROFILE)/bin"
|
||||
@cp bin/analyze.exe "$(USERPROFILE)/bin/"
|
||||
@cp bin/status.exe "$(USERPROFILE)/bin/"
|
||||
|
||||
# Run tests
|
||||
test:
|
||||
@go test -v ./...
|
||||
|
||||
# Format code
|
||||
fmt:
|
||||
@go fmt ./...
|
||||
|
||||
# Vet code
|
||||
vet:
|
||||
@go vet ./...
|
||||
@@ -1,169 +0,0 @@
|
||||
# Mole for Windows
|
||||
|
||||
Windows support for [Mole](https://github.com/tw93/Mole) - A system maintenance toolkit.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Windows 10/11
|
||||
- PowerShell 5.1 or later (pre-installed on Windows 10/11)
|
||||
- Go 1.24+ (for building TUI tools)
|
||||
|
||||
## Installation
|
||||
|
||||
### Quick Install
|
||||
|
||||
```powershell
|
||||
# Clone the repository
|
||||
git clone https://github.com/tw93/Mole.git
|
||||
cd Mole/windows
|
||||
|
||||
# Run the installer
|
||||
.\install.ps1 -AddToPath
|
||||
```
|
||||
|
||||
### Manual Installation
|
||||
|
||||
```powershell
|
||||
# Install to custom location
|
||||
.\install.ps1 -InstallDir C:\Tools\Mole -AddToPath
|
||||
|
||||
# Create Start Menu shortcut
|
||||
.\install.ps1 -AddToPath -CreateShortcut
|
||||
```
|
||||
|
||||
### Uninstall
|
||||
|
||||
```powershell
|
||||
.\install.ps1 -Uninstall
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```powershell
|
||||
# Interactive menu
|
||||
mole
|
||||
|
||||
# Show help
|
||||
mole -ShowHelp
|
||||
|
||||
# Show version
|
||||
mole -Version
|
||||
|
||||
# Commands
|
||||
mole clean # Deep system cleanup
|
||||
mole clean -DryRun # Preview cleanup without deleting
|
||||
mole uninstall # Interactive app uninstaller
|
||||
mole optimize # System optimization
|
||||
mole purge # Clean developer artifacts
|
||||
mole analyze # Disk space analyzer
|
||||
mole status # System health monitor
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `clean` | Deep cleanup of temp files, caches, and logs |
|
||||
| `uninstall` | Interactive application uninstaller |
|
||||
| `optimize` | System optimization and health checks |
|
||||
| `purge` | Clean project build artifacts (node_modules, etc.) |
|
||||
| `analyze` | Interactive disk space analyzer (TUI) |
|
||||
| `status` | Real-time system health monitor (TUI) |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `MOLE_DRY_RUN=1` | Preview changes without making them |
|
||||
| `MOLE_DEBUG=1` | Enable debug output |
|
||||
| `MO_ANALYZE_PATH` | Starting path for analyze tool |
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
windows/
|
||||
├── mole.ps1 # Main CLI entry point
|
||||
├── install.ps1 # Windows installer
|
||||
├── Makefile # Build automation for Go tools
|
||||
├── go.mod # Go module definition
|
||||
├── go.sum # Go dependencies
|
||||
├── bin/
|
||||
│ ├── clean.ps1 # Deep cleanup orchestrator
|
||||
│ ├── uninstall.ps1 # Interactive app uninstaller
|
||||
│ ├── optimize.ps1 # System optimization
|
||||
│ ├── purge.ps1 # Project artifact cleanup
|
||||
│ ├── analyze.ps1 # Disk analyzer wrapper
|
||||
│ └── status.ps1 # Status monitor wrapper
|
||||
├── cmd/
|
||||
│ ├── analyze/ # Disk analyzer (Go TUI)
|
||||
│ │ └── main.go
|
||||
│ └── status/ # System status (Go TUI)
|
||||
│ └── main.go
|
||||
└── lib/
|
||||
├── core/
|
||||
│ ├── base.ps1 # Core definitions and utilities
|
||||
│ ├── common.ps1 # Common functions loader
|
||||
│ ├── file_ops.ps1 # Safe file operations
|
||||
│ ├── log.ps1 # Logging functions
|
||||
│ └── ui.ps1 # Interactive UI components
|
||||
└── clean/
|
||||
├── user.ps1 # User cleanup (temp, downloads, etc.)
|
||||
├── caches.ps1 # Browser and app caches
|
||||
├── dev.ps1 # Developer tool caches
|
||||
├── apps.ps1 # Application leftovers
|
||||
└── system.ps1 # System cleanup (requires admin)
|
||||
```
|
||||
|
||||
## Building TUI Tools
|
||||
|
||||
The analyze and status commands require Go to be installed:
|
||||
|
||||
```powershell
|
||||
cd windows
|
||||
|
||||
# Build both tools
|
||||
make build
|
||||
|
||||
# Or build individually
|
||||
go build -o bin/analyze.exe ./cmd/analyze/
|
||||
go build -o bin/status.exe ./cmd/status/
|
||||
|
||||
# The wrapper scripts will auto-build if Go is available
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Mole stores its configuration in:
|
||||
- Config: `~\.config\mole\`
|
||||
- Cache: `~\.cache\mole\`
|
||||
- Whitelist: `~\.config\mole\whitelist.txt`
|
||||
- Purge paths: `~\.config\mole\purge_paths.txt`
|
||||
|
||||
## Development Phases
|
||||
|
||||
### Phase 1: Core Infrastructure ✅
|
||||
- [x] `install.ps1` - Windows installer
|
||||
- [x] `mole.ps1` - Main CLI entry point
|
||||
- [x] `lib/core/*` - Core utility libraries
|
||||
|
||||
### Phase 2: Cleanup Features ✅
|
||||
- [x] `bin/clean.ps1` - Deep cleanup orchestrator
|
||||
- [x] `bin/uninstall.ps1` - App removal with leftover detection
|
||||
- [x] `bin/optimize.ps1` - System optimization
|
||||
- [x] `bin/purge.ps1` - Project artifact cleanup
|
||||
- [x] `lib/clean/*` - Cleanup modules
|
||||
|
||||
### Phase 3: TUI Tools ✅
|
||||
- [x] `cmd/analyze/` - Disk usage analyzer (Go)
|
||||
- [x] `cmd/status/` - Real-time system monitor (Go)
|
||||
- [x] `bin/analyze.ps1` - Analyzer wrapper
|
||||
- [x] `bin/status.ps1` - Status wrapper
|
||||
|
||||
### Phase 4: Testing & CI (Planned)
|
||||
- [ ] `tests/` - Pester tests
|
||||
- [ ] GitHub Actions workflows
|
||||
- [ ] `scripts/build.ps1` - Build automation
|
||||
|
||||
## License
|
||||
|
||||
Same license as the main Mole project.
|
||||
@@ -1,79 +0,0 @@
|
||||
# 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
|
||||
@@ -1,300 +0,0 @@
|
||||
# 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
|
||||
@@ -1,545 +0,0 @@
|
||||
# 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
|
||||
@@ -1,615 +0,0 @@
|
||||
# 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
|
||||
@@ -1,73 +0,0 @@
|
||||
# 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
|
||||
@@ -1,623 +0,0 @@
|
||||
# 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
|
||||
Binary file not shown.
@@ -1,780 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
// Scanning limits to prevent infinite scanning
|
||||
const (
|
||||
dirSizeTimeout = 500 * time.Millisecond // Max time to calculate a single directory size
|
||||
maxFilesPerDir = 10000 // Max files to scan per directory
|
||||
maxScanDepth = 10 // Max recursion depth (shallow scan)
|
||||
shallowScanDepth = 3 // Depth for quick size estimation
|
||||
)
|
||||
|
||||
// ANSI color codes
|
||||
const (
|
||||
colorReset = "\033[0m"
|
||||
colorBold = "\033[1m"
|
||||
colorDim = "\033[2m"
|
||||
colorPurple = "\033[35m"
|
||||
colorPurpleBold = "\033[1;35m"
|
||||
colorCyan = "\033[36m"
|
||||
colorCyanBold = "\033[1;36m"
|
||||
colorYellow = "\033[33m"
|
||||
colorGreen = "\033[32m"
|
||||
colorRed = "\033[31m"
|
||||
colorGray = "\033[90m"
|
||||
colorWhite = "\033[97m"
|
||||
)
|
||||
|
||||
// Icons
|
||||
const (
|
||||
iconFolder = "📁"
|
||||
iconFile = "📄"
|
||||
iconDisk = "💾"
|
||||
iconClean = "🧹"
|
||||
iconTrash = "🗑️"
|
||||
iconBack = "⬅️"
|
||||
iconSelected = "✓"
|
||||
iconArrow = "➤"
|
||||
)
|
||||
|
||||
// Cleanable directory patterns
|
||||
var cleanablePatterns = map[string]bool{
|
||||
"node_modules": true,
|
||||
"vendor": true,
|
||||
".venv": true,
|
||||
"venv": true,
|
||||
"__pycache__": true,
|
||||
".pytest_cache": true,
|
||||
"target": true,
|
||||
"build": true,
|
||||
"dist": true,
|
||||
".next": true,
|
||||
".nuxt": true,
|
||||
".turbo": true,
|
||||
".parcel-cache": true,
|
||||
"bin": true,
|
||||
"obj": true,
|
||||
".gradle": true,
|
||||
".idea": true,
|
||||
".vs": true,
|
||||
}
|
||||
|
||||
// Skip patterns for scanning
|
||||
var skipPatterns = map[string]bool{
|
||||
"$Recycle.Bin": true,
|
||||
"System Volume Information": true,
|
||||
"Windows": true,
|
||||
"Program Files": true,
|
||||
"Program Files (x86)": true,
|
||||
"ProgramData": true,
|
||||
"Recovery": true,
|
||||
"Config.Msi": true,
|
||||
}
|
||||
|
||||
// Protected paths that should NEVER be deleted
|
||||
var protectedPaths = []string{
|
||||
`C:\Windows`,
|
||||
`C:\Program Files`,
|
||||
`C:\Program Files (x86)`,
|
||||
`C:\ProgramData`,
|
||||
`C:\Users\Default`,
|
||||
`C:\Users\Public`,
|
||||
`C:\Recovery`,
|
||||
`C:\System Volume Information`,
|
||||
}
|
||||
|
||||
// isProtectedPath checks if a path is protected from deletion
|
||||
func isProtectedPath(path string) bool {
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return true // If we can't resolve the path, treat it as protected
|
||||
}
|
||||
absPath = strings.ToLower(absPath)
|
||||
|
||||
// Check against protected paths
|
||||
for _, protected := range protectedPaths {
|
||||
protectedLower := strings.ToLower(protected)
|
||||
if absPath == protectedLower || strings.HasPrefix(absPath, protectedLower+`\`) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check against skip patterns (system directories)
|
||||
baseName := strings.ToLower(filepath.Base(absPath))
|
||||
for pattern := range skipPatterns {
|
||||
if strings.ToLower(pattern) == baseName {
|
||||
// Only protect if it's at a root level (e.g., C:\Windows, not C:\Projects\Windows)
|
||||
parent := filepath.Dir(absPath)
|
||||
if len(parent) <= 3 { // e.g., "C:\"
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Protect Windows directory itself
|
||||
winDir := strings.ToLower(os.Getenv("WINDIR"))
|
||||
sysRoot := strings.ToLower(os.Getenv("SYSTEMROOT"))
|
||||
if winDir != "" && (absPath == winDir || strings.HasPrefix(absPath, winDir+`\`)) {
|
||||
return true
|
||||
}
|
||||
if sysRoot != "" && (absPath == sysRoot || strings.HasPrefix(absPath, sysRoot+`\`)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Entry types
|
||||
type dirEntry struct {
|
||||
Name string
|
||||
Path string
|
||||
Size int64
|
||||
IsDir bool
|
||||
LastAccess time.Time
|
||||
IsCleanable bool
|
||||
}
|
||||
|
||||
type fileEntry struct {
|
||||
Name string
|
||||
Path string
|
||||
Size int64
|
||||
}
|
||||
|
||||
type historyEntry struct {
|
||||
Path string
|
||||
Entries []dirEntry
|
||||
LargeFiles []fileEntry
|
||||
TotalSize int64
|
||||
Selected int
|
||||
}
|
||||
|
||||
// Model for Bubble Tea
|
||||
type model struct {
|
||||
path string
|
||||
entries []dirEntry
|
||||
largeFiles []fileEntry
|
||||
history []historyEntry
|
||||
selected int
|
||||
totalSize int64
|
||||
scanning bool
|
||||
showLargeFiles bool
|
||||
multiSelected map[string]bool
|
||||
deleteConfirm bool
|
||||
deleteTarget string
|
||||
scanProgress int64
|
||||
scanTotal int64
|
||||
width int
|
||||
height int
|
||||
err error
|
||||
cache map[string]historyEntry
|
||||
}
|
||||
|
||||
// Messages
|
||||
type scanCompleteMsg struct {
|
||||
entries []dirEntry
|
||||
largeFiles []fileEntry
|
||||
totalSize int64
|
||||
}
|
||||
|
||||
type scanProgressMsg struct {
|
||||
current int64
|
||||
total int64
|
||||
}
|
||||
|
||||
type scanErrorMsg struct {
|
||||
err error
|
||||
}
|
||||
|
||||
type deleteCompleteMsg struct {
|
||||
path string
|
||||
err error
|
||||
}
|
||||
|
||||
func newModel(startPath string) model {
|
||||
return model{
|
||||
path: startPath,
|
||||
entries: []dirEntry{},
|
||||
largeFiles: []fileEntry{},
|
||||
history: []historyEntry{},
|
||||
selected: 0,
|
||||
scanning: true,
|
||||
multiSelected: make(map[string]bool),
|
||||
cache: make(map[string]historyEntry),
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd {
|
||||
return m.scanPath(m.path)
|
||||
}
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
return m.handleKeyPress(msg)
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
return m, nil
|
||||
case scanCompleteMsg:
|
||||
m.entries = msg.entries
|
||||
m.largeFiles = msg.largeFiles
|
||||
m.totalSize = msg.totalSize
|
||||
m.scanning = false
|
||||
m.selected = 0
|
||||
// Cache result
|
||||
m.cache[m.path] = historyEntry{
|
||||
Path: m.path,
|
||||
Entries: msg.entries,
|
||||
LargeFiles: msg.largeFiles,
|
||||
TotalSize: msg.totalSize,
|
||||
}
|
||||
return m, nil
|
||||
case scanProgressMsg:
|
||||
m.scanProgress = msg.current
|
||||
m.scanTotal = msg.total
|
||||
return m, nil
|
||||
case scanErrorMsg:
|
||||
m.err = msg.err
|
||||
m.scanning = false
|
||||
return m, nil
|
||||
case deleteCompleteMsg:
|
||||
m.deleteConfirm = false
|
||||
m.deleteTarget = ""
|
||||
if msg.err != nil {
|
||||
m.err = msg.err
|
||||
} else {
|
||||
// Rescan after delete
|
||||
m.scanning = true
|
||||
delete(m.cache, m.path)
|
||||
return m, m.scanPath(m.path)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
// Handle delete confirmation
|
||||
if m.deleteConfirm {
|
||||
switch msg.String() {
|
||||
case "y", "Y":
|
||||
target := m.deleteTarget
|
||||
m.deleteConfirm = false
|
||||
return m, m.deletePath(target)
|
||||
case "n", "N", "esc":
|
||||
m.deleteConfirm = false
|
||||
m.deleteTarget = ""
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
switch msg.String() {
|
||||
case "q", "ctrl+c":
|
||||
return m, tea.Quit
|
||||
case "up", "k":
|
||||
if m.selected > 0 {
|
||||
m.selected--
|
||||
}
|
||||
case "down", "j":
|
||||
if m.selected < len(m.entries)-1 {
|
||||
m.selected++
|
||||
}
|
||||
case "enter", "right", "l":
|
||||
if !m.scanning && len(m.entries) > 0 {
|
||||
entry := m.entries[m.selected]
|
||||
if entry.IsDir {
|
||||
// Save current state to history
|
||||
m.history = append(m.history, historyEntry{
|
||||
Path: m.path,
|
||||
Entries: m.entries,
|
||||
LargeFiles: m.largeFiles,
|
||||
TotalSize: m.totalSize,
|
||||
Selected: m.selected,
|
||||
})
|
||||
m.path = entry.Path
|
||||
m.selected = 0
|
||||
m.multiSelected = make(map[string]bool)
|
||||
|
||||
// Check cache
|
||||
if cached, ok := m.cache[entry.Path]; ok {
|
||||
m.entries = cached.Entries
|
||||
m.largeFiles = cached.LargeFiles
|
||||
m.totalSize = cached.TotalSize
|
||||
return m, nil
|
||||
}
|
||||
|
||||
m.scanning = true
|
||||
return m, m.scanPath(entry.Path)
|
||||
}
|
||||
}
|
||||
case "left", "h", "backspace":
|
||||
if len(m.history) > 0 {
|
||||
last := m.history[len(m.history)-1]
|
||||
m.history = m.history[:len(m.history)-1]
|
||||
m.path = last.Path
|
||||
m.entries = last.Entries
|
||||
m.largeFiles = last.LargeFiles
|
||||
m.totalSize = last.TotalSize
|
||||
m.selected = last.Selected
|
||||
m.multiSelected = make(map[string]bool)
|
||||
m.scanning = false
|
||||
}
|
||||
case "space":
|
||||
if len(m.entries) > 0 {
|
||||
entry := m.entries[m.selected]
|
||||
if m.multiSelected[entry.Path] {
|
||||
delete(m.multiSelected, entry.Path)
|
||||
} else {
|
||||
m.multiSelected[entry.Path] = true
|
||||
}
|
||||
}
|
||||
case "d", "delete":
|
||||
if len(m.entries) > 0 {
|
||||
entry := m.entries[m.selected]
|
||||
m.deleteConfirm = true
|
||||
m.deleteTarget = entry.Path
|
||||
}
|
||||
case "D":
|
||||
// Delete all selected
|
||||
if len(m.multiSelected) > 0 {
|
||||
m.deleteConfirm = true
|
||||
m.deleteTarget = fmt.Sprintf("%d items", len(m.multiSelected))
|
||||
}
|
||||
case "f":
|
||||
m.showLargeFiles = !m.showLargeFiles
|
||||
case "r":
|
||||
// Refresh
|
||||
delete(m.cache, m.path)
|
||||
m.scanning = true
|
||||
return m, m.scanPath(m.path)
|
||||
case "o":
|
||||
// Open in Explorer
|
||||
if len(m.entries) > 0 {
|
||||
entry := m.entries[m.selected]
|
||||
openInExplorer(entry.Path)
|
||||
}
|
||||
case "g":
|
||||
m.selected = 0
|
||||
case "G":
|
||||
m.selected = len(m.entries) - 1
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m model) View() string {
|
||||
var b strings.Builder
|
||||
|
||||
// Header
|
||||
b.WriteString(fmt.Sprintf("%s%s Mole Disk Analyzer %s\n", colorPurpleBold, iconDisk, colorReset))
|
||||
b.WriteString(fmt.Sprintf("%s%s%s\n", colorGray, m.path, colorReset))
|
||||
b.WriteString("\n")
|
||||
|
||||
// Show delete confirmation
|
||||
if m.deleteConfirm {
|
||||
b.WriteString(fmt.Sprintf("%s%s Delete %s? (y/n)%s\n", colorRed, iconTrash, m.deleteTarget, colorReset))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Scanning indicator
|
||||
if m.scanning {
|
||||
b.WriteString(fmt.Sprintf("%s⠋ Scanning...%s\n", colorCyan, colorReset))
|
||||
if m.scanTotal > 0 {
|
||||
b.WriteString(fmt.Sprintf("%s %d / %d items%s\n", colorGray, m.scanProgress, m.scanTotal, colorReset))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Error display
|
||||
if m.err != nil {
|
||||
b.WriteString(fmt.Sprintf("%sError: %v%s\n", colorRed, m.err, colorReset))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// Total size
|
||||
b.WriteString(fmt.Sprintf(" Total: %s%s%s\n", colorYellow, formatBytes(m.totalSize), colorReset))
|
||||
b.WriteString("\n")
|
||||
|
||||
// Large files toggle
|
||||
if m.showLargeFiles && len(m.largeFiles) > 0 {
|
||||
b.WriteString(fmt.Sprintf("%s%s Large Files (>100MB):%s\n", colorCyanBold, iconFile, colorReset))
|
||||
for i, f := range m.largeFiles {
|
||||
if i >= 10 {
|
||||
b.WriteString(fmt.Sprintf(" %s... and %d more%s\n", colorGray, len(m.largeFiles)-10, colorReset))
|
||||
break
|
||||
}
|
||||
b.WriteString(fmt.Sprintf(" %s%s%s %s\n", colorYellow, formatBytes(f.Size), colorReset, truncatePath(f.Path, 60)))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// Directory entries
|
||||
visibleEntries := m.height - 12
|
||||
if visibleEntries < 5 {
|
||||
visibleEntries = 20
|
||||
}
|
||||
|
||||
start := 0
|
||||
if m.selected >= visibleEntries {
|
||||
start = m.selected - visibleEntries + 1
|
||||
}
|
||||
|
||||
for i := start; i < len(m.entries) && i < start+visibleEntries; i++ {
|
||||
entry := m.entries[i]
|
||||
prefix := " "
|
||||
|
||||
// Selection indicator
|
||||
if i == m.selected {
|
||||
prefix = fmt.Sprintf("%s%s%s ", colorCyan, iconArrow, colorReset)
|
||||
} else if m.multiSelected[entry.Path] {
|
||||
prefix = fmt.Sprintf("%s%s%s ", colorGreen, iconSelected, colorReset)
|
||||
}
|
||||
|
||||
// Icon
|
||||
icon := iconFile
|
||||
if entry.IsDir {
|
||||
icon = iconFolder
|
||||
}
|
||||
if entry.IsCleanable {
|
||||
icon = iconClean
|
||||
}
|
||||
|
||||
// Size and percentage
|
||||
pct := float64(0)
|
||||
if m.totalSize > 0 {
|
||||
pct = float64(entry.Size) / float64(m.totalSize) * 100
|
||||
}
|
||||
|
||||
// Bar
|
||||
barWidth := 20
|
||||
filled := int(pct / 100 * float64(barWidth))
|
||||
bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled)
|
||||
|
||||
// Color based on selection
|
||||
nameColor := colorReset
|
||||
if i == m.selected {
|
||||
nameColor = colorCyanBold
|
||||
}
|
||||
|
||||
b.WriteString(fmt.Sprintf("%s%s %s%8s%s %s%s%s %s%.1f%%%s %s\n",
|
||||
prefix,
|
||||
icon,
|
||||
colorYellow, formatBytes(entry.Size), colorReset,
|
||||
colorGray, bar, colorReset,
|
||||
colorDim, pct, colorReset,
|
||||
nameColor+entry.Name+colorReset,
|
||||
))
|
||||
}
|
||||
|
||||
// Footer with keybindings
|
||||
b.WriteString("\n")
|
||||
b.WriteString(fmt.Sprintf("%s↑↓%s navigate %s↵%s enter %s←%s back %sf%s files %sd%s delete %sr%s refresh %sq%s quit%s\n",
|
||||
colorCyan, colorReset,
|
||||
colorCyan, colorReset,
|
||||
colorCyan, colorReset,
|
||||
colorCyan, colorReset,
|
||||
colorCyan, colorReset,
|
||||
colorCyan, colorReset,
|
||||
colorCyan, colorReset,
|
||||
colorReset,
|
||||
))
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// scanPath scans a directory and returns entries
|
||||
func (m model) scanPath(path string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
entries, largeFiles, totalSize, err := scanDirectory(path)
|
||||
if err != nil {
|
||||
return scanErrorMsg{err: err}
|
||||
}
|
||||
return scanCompleteMsg{
|
||||
entries: entries,
|
||||
largeFiles: largeFiles,
|
||||
totalSize: totalSize,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// deletePath deletes a file or directory with protection checks
|
||||
func (m model) deletePath(path string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
// Safety check: never delete protected paths
|
||||
if isProtectedPath(path) {
|
||||
return deleteCompleteMsg{
|
||||
path: path,
|
||||
err: fmt.Errorf("cannot delete protected system path: %s", path),
|
||||
}
|
||||
}
|
||||
|
||||
err := os.RemoveAll(path)
|
||||
return deleteCompleteMsg{path: path, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
// scanDirectory scans a directory concurrently
|
||||
func scanDirectory(path string) ([]dirEntry, []fileEntry, int64, error) {
|
||||
entries, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
return nil, nil, 0, err
|
||||
}
|
||||
|
||||
var (
|
||||
dirEntries []dirEntry
|
||||
largeFiles []fileEntry
|
||||
totalSize int64
|
||||
mu sync.Mutex
|
||||
wg sync.WaitGroup
|
||||
)
|
||||
|
||||
numWorkers := runtime.NumCPU() * 2
|
||||
if numWorkers > 32 {
|
||||
numWorkers = 32
|
||||
}
|
||||
|
||||
sem := make(chan struct{}, numWorkers)
|
||||
var processedCount int64
|
||||
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
entryPath := filepath.Join(path, name)
|
||||
|
||||
// Skip system directories
|
||||
if skipPatterns[name] {
|
||||
continue
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
|
||||
go func(name, entryPath string, isDir bool) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
|
||||
var size int64
|
||||
var lastAccess time.Time
|
||||
var isCleanable bool
|
||||
|
||||
if isDir {
|
||||
size = calculateDirSize(entryPath)
|
||||
isCleanable = cleanablePatterns[name]
|
||||
} else {
|
||||
info, err := os.Stat(entryPath)
|
||||
if err == nil {
|
||||
size = info.Size()
|
||||
lastAccess = info.ModTime()
|
||||
}
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
dirEntries = append(dirEntries, dirEntry{
|
||||
Name: name,
|
||||
Path: entryPath,
|
||||
Size: size,
|
||||
IsDir: isDir,
|
||||
LastAccess: lastAccess,
|
||||
IsCleanable: isCleanable,
|
||||
})
|
||||
|
||||
totalSize += size
|
||||
|
||||
// Track large files
|
||||
if !isDir && size >= 100*1024*1024 {
|
||||
largeFiles = append(largeFiles, fileEntry{
|
||||
Name: name,
|
||||
Path: entryPath,
|
||||
Size: size,
|
||||
})
|
||||
}
|
||||
|
||||
atomic.AddInt64(&processedCount, 1)
|
||||
}(name, entryPath, entry.IsDir())
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Sort by size descending
|
||||
sort.Slice(dirEntries, func(i, j int) bool {
|
||||
return dirEntries[i].Size > dirEntries[j].Size
|
||||
})
|
||||
|
||||
sort.Slice(largeFiles, func(i, j int) bool {
|
||||
return largeFiles[i].Size > largeFiles[j].Size
|
||||
})
|
||||
|
||||
return dirEntries, largeFiles, totalSize, nil
|
||||
}
|
||||
|
||||
// calculateDirSize calculates the size of a directory with timeout and limits
|
||||
// Uses shallow scanning for speed - estimates based on first few levels
|
||||
func calculateDirSize(path string) int64 {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), dirSizeTimeout)
|
||||
defer cancel()
|
||||
|
||||
var size int64
|
||||
var fileCount int64
|
||||
|
||||
// Use a channel to signal completion
|
||||
done := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
defer close(done)
|
||||
quickScanDir(ctx, path, 0, &size, &fileCount)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
// Completed normally
|
||||
case <-ctx.Done():
|
||||
// Timeout - return partial size (already accumulated)
|
||||
}
|
||||
|
||||
return size
|
||||
}
|
||||
|
||||
// quickScanDir does a fast shallow scan for size estimation
|
||||
func quickScanDir(ctx context.Context, path string, depth int, size *int64, fileCount *int64) {
|
||||
// Check context cancellation
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
// Limit depth for speed
|
||||
if depth > shallowScanDepth {
|
||||
return
|
||||
}
|
||||
|
||||
// Limit total files scanned
|
||||
if atomic.LoadInt64(fileCount) > maxFilesPerDir {
|
||||
return
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
// Check cancellation
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
if atomic.LoadInt64(fileCount) > maxFilesPerDir {
|
||||
return
|
||||
}
|
||||
|
||||
entryPath := filepath.Join(path, entry.Name())
|
||||
|
||||
if entry.IsDir() {
|
||||
name := entry.Name()
|
||||
// Skip hidden and system directories
|
||||
if skipPatterns[name] || (strings.HasPrefix(name, ".") && len(name) > 1) {
|
||||
continue
|
||||
}
|
||||
quickScanDir(ctx, entryPath, depth+1, size, fileCount)
|
||||
} else {
|
||||
info, err := entry.Info()
|
||||
if err == nil {
|
||||
atomic.AddInt64(size, info.Size())
|
||||
atomic.AddInt64(fileCount, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// formatBytes formats bytes to human readable string
|
||||
func formatBytes(bytes int64) string {
|
||||
const unit = 1024
|
||||
if bytes < unit {
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n := bytes / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
|
||||
// truncatePath truncates a path to fit in maxLen
|
||||
func truncatePath(path string, maxLen int) string {
|
||||
if len(path) <= maxLen {
|
||||
return path
|
||||
}
|
||||
return "..." + path[len(path)-maxLen+3:]
|
||||
}
|
||||
|
||||
// openInExplorer opens a path in Windows Explorer
|
||||
func openInExplorer(path string) {
|
||||
// Use explorer.exe to open the path
|
||||
go func() {
|
||||
exec.Command("explorer.exe", "/select,", path).Run()
|
||||
}()
|
||||
}
|
||||
|
||||
func main() {
|
||||
var startPath string
|
||||
|
||||
flag.StringVar(&startPath, "path", "", "Path to analyze")
|
||||
flag.Parse()
|
||||
|
||||
// Check environment variable
|
||||
if startPath == "" {
|
||||
startPath = os.Getenv("MO_ANALYZE_PATH")
|
||||
}
|
||||
|
||||
// Use command line argument
|
||||
if startPath == "" && len(flag.Args()) > 0 {
|
||||
startPath = flag.Args()[0]
|
||||
}
|
||||
|
||||
// Default to user profile
|
||||
if startPath == "" {
|
||||
startPath = os.Getenv("USERPROFILE")
|
||||
}
|
||||
|
||||
// Resolve to absolute path
|
||||
absPath, err := filepath.Abs(startPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Check if path exists
|
||||
if _, err := os.Stat(absPath); os.IsNotExist(err) {
|
||||
fmt.Fprintf(os.Stderr, "Error: Path does not exist: %s\n", absPath)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
p := tea.NewProgram(newModel(absPath), tea.WithAltScreen())
|
||||
if _, err := p.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFormatBytes(t *testing.T) {
|
||||
tests := []struct {
|
||||
input int64
|
||||
expected string
|
||||
}{
|
||||
{0, "0 B"},
|
||||
{512, "512 B"},
|
||||
{1024, "1.0 KB"},
|
||||
{1536, "1.5 KB"},
|
||||
{1048576, "1.0 MB"},
|
||||
{1073741824, "1.0 GB"},
|
||||
{1099511627776, "1.0 TB"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
result := formatBytes(test.input)
|
||||
if result != test.expected {
|
||||
t.Errorf("formatBytes(%d) = %s, expected %s", test.input, result, test.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncatePath(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
maxLen int
|
||||
expected string
|
||||
}{
|
||||
{"C:\\short", 20, "C:\\short"},
|
||||
{"C:\\this\\is\\a\\very\\long\\path\\that\\should\\be\\truncated", 30, "...ong\\path\\that\\should\\be\\truncated"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
result := truncatePath(test.input, test.maxLen)
|
||||
if len(result) > test.maxLen && test.maxLen < len(test.input) {
|
||||
// For truncated paths, just verify length constraint
|
||||
if len(result) > test.maxLen+10 { // Allow some flexibility
|
||||
t.Errorf("truncatePath(%s, %d) length = %d, expected <= %d", test.input, test.maxLen, len(result), test.maxLen)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanablePatterns(t *testing.T) {
|
||||
expectedCleanable := []string{
|
||||
"node_modules",
|
||||
"vendor",
|
||||
".venv",
|
||||
"venv",
|
||||
"__pycache__",
|
||||
"target",
|
||||
"build",
|
||||
"dist",
|
||||
}
|
||||
|
||||
for _, pattern := range expectedCleanable {
|
||||
if !cleanablePatterns[pattern] {
|
||||
t.Errorf("Expected %s to be in cleanablePatterns", pattern)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkipPatterns(t *testing.T) {
|
||||
expectedSkip := []string{
|
||||
"$Recycle.Bin",
|
||||
"System Volume Information",
|
||||
"Windows",
|
||||
"Program Files",
|
||||
}
|
||||
|
||||
for _, pattern := range expectedSkip {
|
||||
if !skipPatterns[pattern] {
|
||||
t.Errorf("Expected %s to be in skipPatterns", pattern)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateDirSize(t *testing.T) {
|
||||
// Create a temp directory with known content
|
||||
tmpDir, err := os.MkdirTemp("", "mole_test_*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Create a test file with known size
|
||||
testFile := filepath.Join(tmpDir, "test.txt")
|
||||
content := []byte("Hello, World!") // 13 bytes
|
||||
if err := os.WriteFile(testFile, content, 0644); err != nil {
|
||||
t.Fatalf("Failed to write test file: %v", err)
|
||||
}
|
||||
|
||||
size := calculateDirSize(tmpDir)
|
||||
if size != int64(len(content)) {
|
||||
t.Errorf("calculateDirSize() = %d, expected %d", size, len(content))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewModel(t *testing.T) {
|
||||
model := newModel("C:\\")
|
||||
|
||||
if model.path != "C:\\" {
|
||||
t.Errorf("newModel path = %s, expected C:\\", model.path)
|
||||
}
|
||||
|
||||
if !model.scanning {
|
||||
t.Error("newModel should start in scanning state")
|
||||
}
|
||||
|
||||
if model.multiSelected == nil {
|
||||
t.Error("newModel multiSelected should be initialized")
|
||||
}
|
||||
|
||||
if model.cache == nil {
|
||||
t.Error("newModel cache should be initialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanDirectory(t *testing.T) {
|
||||
// Create a temp directory with known structure
|
||||
tmpDir, err := os.MkdirTemp("", "mole_scan_test_*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Create subdirectory
|
||||
subDir := filepath.Join(tmpDir, "subdir")
|
||||
if err := os.Mkdir(subDir, 0755); err != nil {
|
||||
t.Fatalf("Failed to create subdir: %v", err)
|
||||
}
|
||||
|
||||
// Create test files
|
||||
testFile1 := filepath.Join(tmpDir, "file1.txt")
|
||||
testFile2 := filepath.Join(subDir, "file2.txt")
|
||||
os.WriteFile(testFile1, []byte("content1"), 0644)
|
||||
os.WriteFile(testFile2, []byte("content2"), 0644)
|
||||
|
||||
entries, largeFiles, totalSize, err := scanDirectory(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("scanDirectory error: %v", err)
|
||||
}
|
||||
|
||||
if len(entries) != 2 { // subdir + file1.txt
|
||||
t.Errorf("Expected 2 entries, got %d", len(entries))
|
||||
}
|
||||
|
||||
if totalSize == 0 {
|
||||
t.Error("totalSize should be greater than 0")
|
||||
}
|
||||
|
||||
// No large files in this test
|
||||
_ = largeFiles
|
||||
}
|
||||
@@ -1,674 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/shirou/gopsutil/v3/cpu"
|
||||
"github.com/shirou/gopsutil/v3/disk"
|
||||
"github.com/shirou/gopsutil/v3/host"
|
||||
"github.com/shirou/gopsutil/v3/mem"
|
||||
"github.com/shirou/gopsutil/v3/net"
|
||||
"github.com/shirou/gopsutil/v3/process"
|
||||
)
|
||||
|
||||
// Styles
|
||||
var (
|
||||
titleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#C79FD7")).Bold(true)
|
||||
headerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#87CEEB")).Bold(true)
|
||||
labelStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#888888"))
|
||||
valueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF"))
|
||||
okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#A5D6A7"))
|
||||
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD75F"))
|
||||
dangerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5F5F")).Bold(true)
|
||||
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#666666"))
|
||||
cardStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("#444444")).Padding(0, 1)
|
||||
)
|
||||
|
||||
// Metrics snapshot
|
||||
type MetricsSnapshot struct {
|
||||
CollectedAt time.Time
|
||||
HealthScore int
|
||||
HealthMessage string
|
||||
|
||||
// Hardware
|
||||
Hostname string
|
||||
OS string
|
||||
Platform string
|
||||
Uptime time.Duration
|
||||
|
||||
// CPU
|
||||
CPUModel string
|
||||
CPUCores int
|
||||
CPUPercent float64
|
||||
CPUPerCore []float64
|
||||
|
||||
// Memory
|
||||
MemTotal uint64
|
||||
MemUsed uint64
|
||||
MemPercent float64
|
||||
SwapTotal uint64
|
||||
SwapUsed uint64
|
||||
SwapPercent float64
|
||||
|
||||
// Disk
|
||||
Disks []DiskInfo
|
||||
|
||||
// Network
|
||||
Networks []NetworkInfo
|
||||
|
||||
// Processes
|
||||
TopProcesses []ProcessInfo
|
||||
}
|
||||
|
||||
type DiskInfo struct {
|
||||
Device string
|
||||
Mountpoint string
|
||||
Total uint64
|
||||
Used uint64
|
||||
Free uint64
|
||||
UsedPercent float64
|
||||
Fstype string
|
||||
}
|
||||
|
||||
type NetworkInfo struct {
|
||||
Name string
|
||||
BytesSent uint64
|
||||
BytesRecv uint64
|
||||
PacketsSent uint64
|
||||
PacketsRecv uint64
|
||||
}
|
||||
|
||||
type ProcessInfo struct {
|
||||
PID int32
|
||||
Name string
|
||||
CPU float64
|
||||
Memory float32
|
||||
}
|
||||
|
||||
// Collector
|
||||
type Collector struct {
|
||||
prevNet map[string]net.IOCountersStat
|
||||
prevNetTime time.Time
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewCollector() *Collector {
|
||||
return &Collector{
|
||||
prevNet: make(map[string]net.IOCountersStat),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Collector) Collect() MetricsSnapshot {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var (
|
||||
snapshot MetricsSnapshot
|
||||
wg sync.WaitGroup
|
||||
mu sync.Mutex
|
||||
)
|
||||
|
||||
snapshot.CollectedAt = time.Now()
|
||||
|
||||
// Host info
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if info, err := host.InfoWithContext(ctx); err == nil {
|
||||
mu.Lock()
|
||||
snapshot.Hostname = info.Hostname
|
||||
snapshot.OS = info.OS
|
||||
snapshot.Platform = fmt.Sprintf("%s %s", info.Platform, info.PlatformVersion)
|
||||
snapshot.Uptime = time.Duration(info.Uptime) * time.Second
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
// CPU info
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if cpuInfo, err := cpu.InfoWithContext(ctx); err == nil && len(cpuInfo) > 0 {
|
||||
mu.Lock()
|
||||
snapshot.CPUModel = cpuInfo[0].ModelName
|
||||
snapshot.CPUCores = runtime.NumCPU()
|
||||
mu.Unlock()
|
||||
}
|
||||
if percent, err := cpu.PercentWithContext(ctx, 500*time.Millisecond, false); err == nil && len(percent) > 0 {
|
||||
mu.Lock()
|
||||
snapshot.CPUPercent = percent[0]
|
||||
mu.Unlock()
|
||||
}
|
||||
if perCore, err := cpu.PercentWithContext(ctx, 500*time.Millisecond, true); err == nil {
|
||||
mu.Lock()
|
||||
snapshot.CPUPerCore = perCore
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
// Memory
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if memInfo, err := mem.VirtualMemoryWithContext(ctx); err == nil {
|
||||
mu.Lock()
|
||||
snapshot.MemTotal = memInfo.Total
|
||||
snapshot.MemUsed = memInfo.Used
|
||||
snapshot.MemPercent = memInfo.UsedPercent
|
||||
mu.Unlock()
|
||||
}
|
||||
if swapInfo, err := mem.SwapMemoryWithContext(ctx); err == nil {
|
||||
mu.Lock()
|
||||
snapshot.SwapTotal = swapInfo.Total
|
||||
snapshot.SwapUsed = swapInfo.Used
|
||||
snapshot.SwapPercent = swapInfo.UsedPercent
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
// Disk
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if partitions, err := disk.PartitionsWithContext(ctx, false); err == nil {
|
||||
var disks []DiskInfo
|
||||
for _, p := range partitions {
|
||||
// Skip non-physical drives
|
||||
if !strings.HasPrefix(p.Device, "C:") &&
|
||||
!strings.HasPrefix(p.Device, "D:") &&
|
||||
!strings.HasPrefix(p.Device, "E:") &&
|
||||
!strings.HasPrefix(p.Device, "F:") {
|
||||
continue
|
||||
}
|
||||
if usage, err := disk.UsageWithContext(ctx, p.Mountpoint); err == nil {
|
||||
disks = append(disks, DiskInfo{
|
||||
Device: p.Device,
|
||||
Mountpoint: p.Mountpoint,
|
||||
Total: usage.Total,
|
||||
Used: usage.Used,
|
||||
Free: usage.Free,
|
||||
UsedPercent: usage.UsedPercent,
|
||||
Fstype: p.Fstype,
|
||||
})
|
||||
}
|
||||
}
|
||||
mu.Lock()
|
||||
snapshot.Disks = disks
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
// Network
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if netIO, err := net.IOCountersWithContext(ctx, true); err == nil {
|
||||
var networks []NetworkInfo
|
||||
for _, io := range netIO {
|
||||
// Skip loopback and inactive interfaces
|
||||
if io.Name == "Loopback Pseudo-Interface 1" || (io.BytesSent == 0 && io.BytesRecv == 0) {
|
||||
continue
|
||||
}
|
||||
networks = append(networks, NetworkInfo{
|
||||
Name: io.Name,
|
||||
BytesSent: io.BytesSent,
|
||||
BytesRecv: io.BytesRecv,
|
||||
PacketsSent: io.PacketsSent,
|
||||
PacketsRecv: io.PacketsRecv,
|
||||
})
|
||||
}
|
||||
mu.Lock()
|
||||
snapshot.Networks = networks
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
// Top Processes
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
procs, err := process.ProcessesWithContext(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var procInfos []ProcessInfo
|
||||
for _, p := range procs {
|
||||
name, err := p.NameWithContext(ctx)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
cpuPercent, _ := p.CPUPercentWithContext(ctx)
|
||||
memPercent, _ := p.MemoryPercentWithContext(ctx)
|
||||
|
||||
if cpuPercent > 0.1 || memPercent > 0.1 {
|
||||
procInfos = append(procInfos, ProcessInfo{
|
||||
PID: p.Pid,
|
||||
Name: name,
|
||||
CPU: cpuPercent,
|
||||
Memory: memPercent,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by CPU usage
|
||||
for i := 0; i < len(procInfos)-1; i++ {
|
||||
for j := i + 1; j < len(procInfos); j++ {
|
||||
if procInfos[j].CPU > procInfos[i].CPU {
|
||||
procInfos[i], procInfos[j] = procInfos[j], procInfos[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Take top 5
|
||||
if len(procInfos) > 5 {
|
||||
procInfos = procInfos[:5]
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
snapshot.TopProcesses = procInfos
|
||||
mu.Unlock()
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Calculate health score
|
||||
snapshot.HealthScore, snapshot.HealthMessage = calculateHealthScore(snapshot)
|
||||
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func calculateHealthScore(s MetricsSnapshot) (int, string) {
|
||||
score := 100
|
||||
var issues []string
|
||||
|
||||
// CPU penalty (30% weight)
|
||||
if s.CPUPercent > 90 {
|
||||
score -= 30
|
||||
issues = append(issues, "High CPU")
|
||||
} else if s.CPUPercent > 70 {
|
||||
score -= 15
|
||||
issues = append(issues, "Elevated CPU")
|
||||
}
|
||||
|
||||
// Memory penalty (25% weight)
|
||||
if s.MemPercent > 90 {
|
||||
score -= 25
|
||||
issues = append(issues, "High Memory")
|
||||
} else if s.MemPercent > 80 {
|
||||
score -= 12
|
||||
issues = append(issues, "Elevated Memory")
|
||||
}
|
||||
|
||||
// Disk penalty (20% weight)
|
||||
for _, d := range s.Disks {
|
||||
if d.UsedPercent > 95 {
|
||||
score -= 20
|
||||
issues = append(issues, fmt.Sprintf("Disk %s Critical", d.Device))
|
||||
break
|
||||
} else if d.UsedPercent > 85 {
|
||||
score -= 10
|
||||
issues = append(issues, fmt.Sprintf("Disk %s Low", d.Device))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Swap penalty (10% weight)
|
||||
if s.SwapPercent > 80 {
|
||||
score -= 10
|
||||
issues = append(issues, "High Swap")
|
||||
}
|
||||
|
||||
if score < 0 {
|
||||
score = 0
|
||||
}
|
||||
|
||||
msg := "Excellent"
|
||||
if len(issues) > 0 {
|
||||
msg = strings.Join(issues, ", ")
|
||||
} else if score >= 90 {
|
||||
msg = "Excellent"
|
||||
} else if score >= 70 {
|
||||
msg = "Good"
|
||||
} else if score >= 50 {
|
||||
msg = "Fair"
|
||||
} else {
|
||||
msg = "Poor"
|
||||
}
|
||||
|
||||
return score, msg
|
||||
}
|
||||
|
||||
// Model for Bubble Tea
|
||||
type model struct {
|
||||
collector *Collector
|
||||
metrics MetricsSnapshot
|
||||
animFrame int
|
||||
catHidden bool
|
||||
ready bool
|
||||
collecting bool
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
// Messages
|
||||
type tickMsg time.Time
|
||||
type metricsMsg MetricsSnapshot
|
||||
|
||||
func newModel() model {
|
||||
return model{
|
||||
collector: NewCollector(),
|
||||
animFrame: 0,
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd {
|
||||
return tea.Batch(
|
||||
m.collectMetrics(),
|
||||
tickCmd(),
|
||||
)
|
||||
}
|
||||
|
||||
func tickCmd() tea.Cmd {
|
||||
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
|
||||
return tickMsg(t)
|
||||
})
|
||||
}
|
||||
|
||||
func (m model) collectMetrics() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
return metricsMsg(m.collector.Collect())
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "q", "ctrl+c":
|
||||
return m, tea.Quit
|
||||
case "c":
|
||||
m.catHidden = !m.catHidden
|
||||
case "r":
|
||||
m.collecting = true
|
||||
return m, m.collectMetrics()
|
||||
}
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
case tickMsg:
|
||||
m.animFrame++
|
||||
if m.animFrame%2 == 0 && !m.collecting {
|
||||
return m, tea.Batch(
|
||||
m.collectMetrics(),
|
||||
tickCmd(),
|
||||
)
|
||||
}
|
||||
return m, tickCmd()
|
||||
case metricsMsg:
|
||||
m.metrics = MetricsSnapshot(msg)
|
||||
m.ready = true
|
||||
m.collecting = false
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m model) View() string {
|
||||
if !m.ready {
|
||||
return "\n Loading system metrics..."
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
// Header with mole animation
|
||||
moleFrame := getMoleFrame(m.animFrame, m.catHidden)
|
||||
|
||||
b.WriteString("\n")
|
||||
b.WriteString(titleStyle.Render(" 🐹 Mole System Status"))
|
||||
b.WriteString(" ")
|
||||
b.WriteString(moleFrame)
|
||||
b.WriteString("\n\n")
|
||||
|
||||
// Health score
|
||||
healthColor := okStyle
|
||||
if m.metrics.HealthScore < 50 {
|
||||
healthColor = dangerStyle
|
||||
} else if m.metrics.HealthScore < 70 {
|
||||
healthColor = warnStyle
|
||||
}
|
||||
b.WriteString(fmt.Sprintf(" Health: %s %s\n\n",
|
||||
healthColor.Render(fmt.Sprintf("%d%%", m.metrics.HealthScore)),
|
||||
dimStyle.Render(m.metrics.HealthMessage),
|
||||
))
|
||||
|
||||
// System info
|
||||
b.WriteString(headerStyle.Render(" 📍 System"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(fmt.Sprintf(" %s %s\n", labelStyle.Render("Host:"), valueStyle.Render(m.metrics.Hostname)))
|
||||
b.WriteString(fmt.Sprintf(" %s %s\n", labelStyle.Render("OS:"), valueStyle.Render(m.metrics.Platform)))
|
||||
b.WriteString(fmt.Sprintf(" %s %s\n", labelStyle.Render("Uptime:"), valueStyle.Render(formatDuration(m.metrics.Uptime))))
|
||||
b.WriteString("\n")
|
||||
|
||||
// CPU
|
||||
b.WriteString(headerStyle.Render(" ⚡ CPU"))
|
||||
b.WriteString("\n")
|
||||
cpuColor := getPercentColor(m.metrics.CPUPercent)
|
||||
b.WriteString(fmt.Sprintf(" %s %s\n", labelStyle.Render("Model:"), valueStyle.Render(truncateString(m.metrics.CPUModel, 50))))
|
||||
b.WriteString(fmt.Sprintf(" %s %s (%d cores)\n",
|
||||
labelStyle.Render("Usage:"),
|
||||
cpuColor.Render(fmt.Sprintf("%.1f%%", m.metrics.CPUPercent)),
|
||||
m.metrics.CPUCores,
|
||||
))
|
||||
b.WriteString(fmt.Sprintf(" %s\n", renderProgressBar(m.metrics.CPUPercent, 30)))
|
||||
b.WriteString("\n")
|
||||
|
||||
// Memory
|
||||
b.WriteString(headerStyle.Render(" 🧠 Memory"))
|
||||
b.WriteString("\n")
|
||||
memColor := getPercentColor(m.metrics.MemPercent)
|
||||
b.WriteString(fmt.Sprintf(" %s %s / %s %s\n",
|
||||
labelStyle.Render("RAM:"),
|
||||
memColor.Render(formatBytes(m.metrics.MemUsed)),
|
||||
valueStyle.Render(formatBytes(m.metrics.MemTotal)),
|
||||
memColor.Render(fmt.Sprintf("(%.1f%%)", m.metrics.MemPercent)),
|
||||
))
|
||||
b.WriteString(fmt.Sprintf(" %s\n", renderProgressBar(m.metrics.MemPercent, 30)))
|
||||
if m.metrics.SwapTotal > 0 {
|
||||
b.WriteString(fmt.Sprintf(" %s %s / %s\n",
|
||||
labelStyle.Render("Swap:"),
|
||||
valueStyle.Render(formatBytes(m.metrics.SwapUsed)),
|
||||
valueStyle.Render(formatBytes(m.metrics.SwapTotal)),
|
||||
))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
// Disk
|
||||
b.WriteString(headerStyle.Render(" 💾 Disks"))
|
||||
b.WriteString("\n")
|
||||
for _, d := range m.metrics.Disks {
|
||||
diskColor := getPercentColor(d.UsedPercent)
|
||||
b.WriteString(fmt.Sprintf(" %s %s / %s %s\n",
|
||||
labelStyle.Render(d.Device),
|
||||
diskColor.Render(formatBytes(d.Used)),
|
||||
valueStyle.Render(formatBytes(d.Total)),
|
||||
diskColor.Render(fmt.Sprintf("(%.1f%%)", d.UsedPercent)),
|
||||
))
|
||||
b.WriteString(fmt.Sprintf(" %s\n", renderProgressBar(d.UsedPercent, 30)))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
// Top Processes
|
||||
if len(m.metrics.TopProcesses) > 0 {
|
||||
b.WriteString(headerStyle.Render(" 📊 Top Processes"))
|
||||
b.WriteString("\n")
|
||||
for _, p := range m.metrics.TopProcesses {
|
||||
b.WriteString(fmt.Sprintf(" %s %s (CPU: %.1f%%, Mem: %.1f%%)\n",
|
||||
dimStyle.Render(fmt.Sprintf("[%d]", p.PID)),
|
||||
valueStyle.Render(truncateString(p.Name, 20)),
|
||||
p.CPU,
|
||||
p.Memory,
|
||||
))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// Network
|
||||
if len(m.metrics.Networks) > 0 {
|
||||
b.WriteString(headerStyle.Render(" 🌐 Network"))
|
||||
b.WriteString("\n")
|
||||
for i, n := range m.metrics.Networks {
|
||||
if i >= 3 {
|
||||
break
|
||||
}
|
||||
b.WriteString(fmt.Sprintf(" %s ↑%s ↓%s\n",
|
||||
labelStyle.Render(truncateString(n.Name, 20)+":"),
|
||||
valueStyle.Render(formatBytes(n.BytesSent)),
|
||||
valueStyle.Render(formatBytes(n.BytesRecv)),
|
||||
))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// Footer
|
||||
b.WriteString(dimStyle.Render(" [q] quit [r] refresh [c] toggle mole"))
|
||||
b.WriteString("\n")
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func getMoleFrame(frame int, hidden bool) string {
|
||||
if hidden {
|
||||
return ""
|
||||
}
|
||||
frames := []string{
|
||||
"🐹",
|
||||
"🐹.",
|
||||
"🐹..",
|
||||
"🐹...",
|
||||
}
|
||||
return frames[frame%len(frames)]
|
||||
}
|
||||
|
||||
func renderProgressBar(percent float64, width int) string {
|
||||
filled := int(percent / 100 * float64(width))
|
||||
if filled > width {
|
||||
filled = width
|
||||
}
|
||||
if filled < 0 {
|
||||
filled = 0
|
||||
}
|
||||
|
||||
color := okStyle
|
||||
if percent > 85 {
|
||||
color = dangerStyle
|
||||
} else if percent > 70 {
|
||||
color = warnStyle
|
||||
}
|
||||
|
||||
bar := strings.Repeat("█", filled) + strings.Repeat("░", width-filled)
|
||||
return color.Render(bar)
|
||||
}
|
||||
|
||||
func getPercentColor(percent float64) lipgloss.Style {
|
||||
if percent > 85 {
|
||||
return dangerStyle
|
||||
} else if percent > 70 {
|
||||
return warnStyle
|
||||
}
|
||||
return okStyle
|
||||
}
|
||||
|
||||
func formatBytes(bytes uint64) string {
|
||||
const unit = 1024
|
||||
if bytes < unit {
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
div, exp := uint64(unit), 0
|
||||
for n := bytes / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
|
||||
func formatDuration(d time.Duration) string {
|
||||
days := int(d.Hours() / 24)
|
||||
hours := int(d.Hours()) % 24
|
||||
minutes := int(d.Minutes()) % 60
|
||||
|
||||
if days > 0 {
|
||||
return fmt.Sprintf("%dd %dh %dm", days, hours, minutes)
|
||||
}
|
||||
if hours > 0 {
|
||||
return fmt.Sprintf("%dh %dm", hours, minutes)
|
||||
}
|
||||
return fmt.Sprintf("%dm", minutes)
|
||||
}
|
||||
|
||||
func truncateString(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen-3] + "..."
|
||||
}
|
||||
|
||||
// getWindowsVersion gets detailed Windows version using PowerShell
|
||||
func getWindowsVersion() string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "powershell", "-Command",
|
||||
"(Get-CimInstance Win32_OperatingSystem).Caption")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "Windows"
|
||||
}
|
||||
return strings.TrimSpace(string(output))
|
||||
}
|
||||
|
||||
// getBatteryInfo gets battery info on Windows (for laptops)
|
||||
func getBatteryInfo() (int, bool, bool) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "powershell", "-Command",
|
||||
"(Get-CimInstance Win32_Battery).EstimatedChargeRemaining")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return 0, false, false
|
||||
}
|
||||
|
||||
percent, err := strconv.Atoi(strings.TrimSpace(string(output)))
|
||||
if err != nil {
|
||||
return 0, false, false
|
||||
}
|
||||
|
||||
// Check if charging
|
||||
cmdStatus := exec.CommandContext(ctx, "powershell", "-Command",
|
||||
"(Get-CimInstance Win32_Battery).BatteryStatus")
|
||||
statusOutput, _ := cmdStatus.Output()
|
||||
status, _ := strconv.Atoi(strings.TrimSpace(string(statusOutput)))
|
||||
isCharging := status == 2 // 2 = AC Power
|
||||
|
||||
return percent, isCharging, true
|
||||
}
|
||||
|
||||
func main() {
|
||||
p := tea.NewProgram(newModel(), tea.WithAltScreen())
|
||||
if _, err := p.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFormatBytesUint64(t *testing.T) {
|
||||
tests := []struct {
|
||||
input uint64
|
||||
expected string
|
||||
}{
|
||||
{0, "0 B"},
|
||||
{512, "512 B"},
|
||||
{1024, "1.0 KB"},
|
||||
{1536, "1.5 KB"},
|
||||
{1048576, "1.0 MB"},
|
||||
{1073741824, "1.0 GB"},
|
||||
{1099511627776, "1.0 TB"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
result := formatBytes(test.input)
|
||||
if result != test.expected {
|
||||
t.Errorf("formatBytes(%d) = %s, expected %s", test.input, result, test.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatDuration(t *testing.T) {
|
||||
tests := []struct {
|
||||
input time.Duration
|
||||
expected string
|
||||
}{
|
||||
{5 * time.Minute, "5m"},
|
||||
{2 * time.Hour, "2h 0m"},
|
||||
{25 * time.Hour, "1d 1h 0m"},
|
||||
{49*time.Hour + 30*time.Minute, "2d 1h 30m"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
result := formatDuration(test.input)
|
||||
if result != test.expected {
|
||||
t.Errorf("formatDuration(%v) = %s, expected %s", test.input, result, test.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateString(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
maxLen int
|
||||
expected string
|
||||
}{
|
||||
{"short", 10, "short"},
|
||||
{"this is a long string", 10, "this is..."},
|
||||
{"exact", 5, "exact"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
result := truncateString(test.input, test.maxLen)
|
||||
if result != test.expected {
|
||||
t.Errorf("truncateString(%s, %d) = %s, expected %s", test.input, test.maxLen, result, test.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateHealthScore(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
snapshot MetricsSnapshot
|
||||
minScore int
|
||||
maxScore int
|
||||
}{
|
||||
{
|
||||
name: "Healthy system",
|
||||
snapshot: MetricsSnapshot{
|
||||
CPUPercent: 20,
|
||||
MemPercent: 40,
|
||||
SwapPercent: 10,
|
||||
Disks: []DiskInfo{
|
||||
{UsedPercent: 50},
|
||||
},
|
||||
},
|
||||
minScore: 90,
|
||||
maxScore: 100,
|
||||
},
|
||||
{
|
||||
name: "High CPU",
|
||||
snapshot: MetricsSnapshot{
|
||||
CPUPercent: 95,
|
||||
MemPercent: 40,
|
||||
SwapPercent: 10,
|
||||
Disks: []DiskInfo{
|
||||
{UsedPercent: 50},
|
||||
},
|
||||
},
|
||||
minScore: 50,
|
||||
maxScore: 75,
|
||||
},
|
||||
{
|
||||
name: "High Memory",
|
||||
snapshot: MetricsSnapshot{
|
||||
CPUPercent: 20,
|
||||
MemPercent: 95,
|
||||
SwapPercent: 10,
|
||||
Disks: []DiskInfo{
|
||||
{UsedPercent: 50},
|
||||
},
|
||||
},
|
||||
minScore: 60,
|
||||
maxScore: 80,
|
||||
},
|
||||
{
|
||||
name: "Critical Disk",
|
||||
snapshot: MetricsSnapshot{
|
||||
CPUPercent: 20,
|
||||
MemPercent: 40,
|
||||
SwapPercent: 10,
|
||||
Disks: []DiskInfo{
|
||||
{Device: "C:", UsedPercent: 98},
|
||||
},
|
||||
},
|
||||
minScore: 60,
|
||||
maxScore: 85,
|
||||
},
|
||||
{
|
||||
name: "Multiple issues",
|
||||
snapshot: MetricsSnapshot{
|
||||
CPUPercent: 95,
|
||||
MemPercent: 95,
|
||||
SwapPercent: 85,
|
||||
Disks: []DiskInfo{
|
||||
{Device: "C:", UsedPercent: 98},
|
||||
},
|
||||
},
|
||||
minScore: 0,
|
||||
maxScore: 30,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
score, msg := calculateHealthScore(test.snapshot)
|
||||
if score < test.minScore || score > test.maxScore {
|
||||
t.Errorf("calculateHealthScore() = %d (%s), expected between %d and %d",
|
||||
score, msg, test.minScore, test.maxScore)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCollector(t *testing.T) {
|
||||
collector := NewCollector()
|
||||
|
||||
if collector == nil {
|
||||
t.Fatal("NewCollector returned nil")
|
||||
}
|
||||
|
||||
if collector.prevNet == nil {
|
||||
t.Error("prevNet map should be initialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMoleFrame(t *testing.T) {
|
||||
// Test visible frames
|
||||
for i := 0; i < 8; i++ {
|
||||
frame := getMoleFrame(i, false)
|
||||
if frame == "" {
|
||||
t.Errorf("getMoleFrame(%d, false) returned empty string", i)
|
||||
}
|
||||
}
|
||||
|
||||
// Test hidden
|
||||
frame := getMoleFrame(0, true)
|
||||
if frame != "" {
|
||||
t.Errorf("getMoleFrame(0, true) = %s, expected empty string", frame)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderProgressBar(t *testing.T) {
|
||||
tests := []struct {
|
||||
percent float64
|
||||
width int
|
||||
}{
|
||||
{0, 20},
|
||||
{50, 20},
|
||||
{100, 20},
|
||||
{75, 30},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
result := renderProgressBar(test.percent, test.width)
|
||||
if result == "" {
|
||||
t.Errorf("renderProgressBar(%.0f, %d) returned empty string", test.percent, test.width)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPercentColor(t *testing.T) {
|
||||
// Just verify it doesn't panic
|
||||
_ = getPercentColor(50)
|
||||
_ = getPercentColor(75)
|
||||
_ = getPercentColor(90)
|
||||
}
|
||||
|
||||
func TestNewModel(t *testing.T) {
|
||||
model := newModel()
|
||||
|
||||
if model.collector == nil {
|
||||
t.Error("collector should be initialized")
|
||||
}
|
||||
|
||||
if model.ready {
|
||||
t.Error("ready should be false initially")
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
module github.com/tw93/mole/windows
|
||||
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/shirou/gopsutil/v3 v3.24.5
|
||||
github.com/yusufpapurcu/wmi v1.2.4
|
||||
golang.org/x/sys v0.36.0
|
||||
)
|
||||
@@ -1,73 +0,0 @@
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM=
|
||||
github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg=
|
||||
github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg=
|
||||
github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I=
|
||||
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY=
|
||||
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||
github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98=
|
||||
github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34=
|
||||
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
||||
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
|
||||
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
|
||||
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
|
||||
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
|
||||
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
|
||||
github.com/shoenig/go-m1cpu v0.1.7 h1:C76Yd0ObKR82W4vhfjZiCp0HxcSZ8Nqd84v+HZ0qyI0=
|
||||
github.com/shoenig/go-m1cpu v0.1.7/go.mod h1:KkDOw6m3ZJQAPHbrzkZki4hnx+pDRR1Lo+ldA56wD5w=
|
||||
github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk=
|
||||
github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
||||
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
||||
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw=
|
||||
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
|
||||
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -1,440 +0,0 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# Mole Windows Installer
|
||||
# Installs Mole Windows support to the system and adds to PATH
|
||||
|
||||
#Requires -Version 5.1
|
||||
param(
|
||||
[string]$InstallDir = "$env:LOCALAPPDATA\Mole",
|
||||
[switch]$AddToPath,
|
||||
[switch]$CreateShortcut,
|
||||
[switch]$Uninstall,
|
||||
[switch]$Force,
|
||||
[switch]$ShowHelp
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
# ============================================================================
|
||||
# Configuration
|
||||
# ============================================================================
|
||||
|
||||
$script:VERSION = "1.0.0"
|
||||
$script:SourceDir = if ($MyInvocation.MyCommand.Path) {
|
||||
Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
} else {
|
||||
$PSScriptRoot
|
||||
}
|
||||
$script:ShortcutName = "Mole"
|
||||
|
||||
# Colors (using [char]27 for PowerShell 5.1 compatibility)
|
||||
$script:ESC = [char]27
|
||||
$script:Colors = @{
|
||||
Red = "$($script:ESC)[31m"
|
||||
Green = "$($script:ESC)[32m"
|
||||
Yellow = "$($script:ESC)[33m"
|
||||
Blue = "$($script:ESC)[34m"
|
||||
Cyan = "$($script:ESC)[36m"
|
||||
Gray = "$($script:ESC)[90m"
|
||||
NC = "$($script:ESC)[0m"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Helpers
|
||||
# ============================================================================
|
||||
|
||||
function Write-Info {
|
||||
param([string]$Message)
|
||||
$c = $script:Colors
|
||||
Write-Host " $($c.Blue)INFO$($c.NC) $Message"
|
||||
}
|
||||
|
||||
function Write-Success {
|
||||
param([string]$Message)
|
||||
$c = $script:Colors
|
||||
Write-Host " $($c.Green)OK$($c.NC) $Message"
|
||||
}
|
||||
|
||||
function Write-MoleWarning {
|
||||
param([string]$Message)
|
||||
$c = $script:Colors
|
||||
Write-Host " $($c.Yellow)WARN$($c.NC) $Message"
|
||||
}
|
||||
|
||||
function Write-MoleError {
|
||||
param([string]$Message)
|
||||
$c = $script:Colors
|
||||
Write-Host " $($c.Red)ERROR$($c.NC) $Message"
|
||||
}
|
||||
|
||||
function Show-Banner {
|
||||
$c = $script:Colors
|
||||
Write-Host ""
|
||||
Write-Host " $($c.Cyan)MOLE$($c.NC)"
|
||||
Write-Host " $($c.Gray)Windows System Maintenance$($c.NC)"
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
function Show-InstallerHelp {
|
||||
Show-Banner
|
||||
|
||||
$c = $script:Colors
|
||||
Write-Host " $($c.Green)USAGE:$($c.NC)"
|
||||
Write-Host ""
|
||||
Write-Host " .\install.ps1 [options]"
|
||||
Write-Host ""
|
||||
Write-Host " $($c.Green)OPTIONS:$($c.NC)"
|
||||
Write-Host ""
|
||||
Write-Host " $($c.Cyan)-InstallDir <path>$($c.NC) Installation directory"
|
||||
Write-Host " Default: $env:LOCALAPPDATA\Mole"
|
||||
Write-Host ""
|
||||
Write-Host " $($c.Cyan)-AddToPath$($c.NC) Add Mole to user PATH"
|
||||
Write-Host ""
|
||||
Write-Host " $($c.Cyan)-CreateShortcut$($c.NC) Create Start Menu shortcut"
|
||||
Write-Host ""
|
||||
Write-Host " $($c.Cyan)-Uninstall$($c.NC) Remove Mole from system"
|
||||
Write-Host ""
|
||||
Write-Host " $($c.Cyan)-Force$($c.NC) Overwrite existing installation"
|
||||
Write-Host ""
|
||||
Write-Host " $($c.Cyan)-ShowHelp$($c.NC) Show this help message"
|
||||
Write-Host ""
|
||||
Write-Host " $($c.Green)EXAMPLES:$($c.NC)"
|
||||
Write-Host ""
|
||||
Write-Host " $($c.Gray)# Install with defaults$($c.NC)"
|
||||
Write-Host " .\install.ps1"
|
||||
Write-Host ""
|
||||
Write-Host " $($c.Gray)# Install and add to PATH$($c.NC)"
|
||||
Write-Host " .\install.ps1 -AddToPath"
|
||||
Write-Host ""
|
||||
Write-Host " $($c.Gray)# Custom install location$($c.NC)"
|
||||
Write-Host " .\install.ps1 -InstallDir C:\Tools\Mole -AddToPath"
|
||||
Write-Host ""
|
||||
Write-Host " $($c.Gray)# Full installation$($c.NC)"
|
||||
Write-Host " .\install.ps1 -AddToPath -CreateShortcut"
|
||||
Write-Host ""
|
||||
Write-Host " $($c.Gray)# Uninstall$($c.NC)"
|
||||
Write-Host " .\install.ps1 -Uninstall"
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
function Test-IsAdmin {
|
||||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
$principal = [Security.Principal.WindowsPrincipal]$identity
|
||||
return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
}
|
||||
|
||||
function Add-ToUserPath {
|
||||
param([string]$Directory)
|
||||
|
||||
$currentPath = [Environment]::GetEnvironmentVariable("PATH", "User")
|
||||
|
||||
if ($currentPath -split ";" | Where-Object { $_ -eq $Directory }) {
|
||||
Write-Info "Already in PATH: $Directory"
|
||||
return $true
|
||||
}
|
||||
|
||||
$newPath = if ($currentPath) { "$currentPath;$Directory" } else { $Directory }
|
||||
|
||||
try {
|
||||
[Environment]::SetEnvironmentVariable("PATH", $newPath, "User")
|
||||
Write-Success "Added to PATH: $Directory"
|
||||
|
||||
# Update current session
|
||||
$env:PATH = "$env:PATH;$Directory"
|
||||
return $true
|
||||
}
|
||||
catch {
|
||||
Write-MoleError "Failed to update PATH: $_"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Remove-FromUserPath {
|
||||
param([string]$Directory)
|
||||
|
||||
$currentPath = [Environment]::GetEnvironmentVariable("PATH", "User")
|
||||
|
||||
if (-not $currentPath) {
|
||||
return $true
|
||||
}
|
||||
|
||||
$paths = $currentPath -split ";" | Where-Object { $_ -ne $Directory -and $_ -ne "" }
|
||||
$newPath = $paths -join ";"
|
||||
|
||||
try {
|
||||
[Environment]::SetEnvironmentVariable("PATH", $newPath, "User")
|
||||
Write-Success "Removed from PATH: $Directory"
|
||||
return $true
|
||||
}
|
||||
catch {
|
||||
Write-MoleError "Failed to update PATH: $_"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function New-StartMenuShortcut {
|
||||
param(
|
||||
[string]$TargetPath,
|
||||
[string]$ShortcutName,
|
||||
[string]$Description
|
||||
)
|
||||
|
||||
$startMenuPath = [Environment]::GetFolderPath("StartMenu")
|
||||
$programsPath = Join-Path $startMenuPath "Programs"
|
||||
$shortcutPath = Join-Path $programsPath "$ShortcutName.lnk"
|
||||
|
||||
try {
|
||||
$shell = New-Object -ComObject WScript.Shell
|
||||
$shortcut = $shell.CreateShortcut($shortcutPath)
|
||||
$shortcut.TargetPath = "powershell.exe"
|
||||
$shortcut.Arguments = "-NoExit -ExecutionPolicy Bypass -File `"$TargetPath`""
|
||||
$shortcut.Description = $Description
|
||||
$shortcut.WorkingDirectory = Split-Path -Parent $TargetPath
|
||||
$shortcut.Save()
|
||||
|
||||
Write-Success "Created shortcut: $shortcutPath"
|
||||
return $true
|
||||
}
|
||||
catch {
|
||||
Write-MoleError "Failed to create shortcut: $_"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Remove-StartMenuShortcut {
|
||||
param([string]$ShortcutName)
|
||||
|
||||
$startMenuPath = [Environment]::GetFolderPath("StartMenu")
|
||||
$programsPath = Join-Path $startMenuPath "Programs"
|
||||
$shortcutPath = Join-Path $programsPath "$ShortcutName.lnk"
|
||||
|
||||
if (Test-Path $shortcutPath) {
|
||||
try {
|
||||
Remove-Item $shortcutPath -Force
|
||||
Write-Success "Removed shortcut: $shortcutPath"
|
||||
return $true
|
||||
}
|
||||
catch {
|
||||
Write-MoleError "Failed to remove shortcut: $_"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
return $true
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Install
|
||||
# ============================================================================
|
||||
|
||||
function Install-Mole {
|
||||
Write-Info "Installing Mole v$script:VERSION..."
|
||||
Write-Host ""
|
||||
|
||||
# Check if already installed
|
||||
if ((Test-Path $InstallDir) -and -not $Force) {
|
||||
Write-MoleError "Mole is already installed at: $InstallDir"
|
||||
Write-Host ""
|
||||
Write-Host " Use -Force to overwrite or -Uninstall to remove first"
|
||||
Write-Host ""
|
||||
return $false
|
||||
}
|
||||
|
||||
# Create install directory
|
||||
if (-not (Test-Path $InstallDir)) {
|
||||
try {
|
||||
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
|
||||
Write-Success "Created directory: $InstallDir"
|
||||
}
|
||||
catch {
|
||||
Write-MoleError "Failed to create directory: $_"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
# Copy files
|
||||
Write-Info "Copying files..."
|
||||
|
||||
$filesToCopy = @(
|
||||
"mole.ps1"
|
||||
"go.mod"
|
||||
"bin"
|
||||
"lib"
|
||||
"cmd"
|
||||
)
|
||||
|
||||
foreach ($item in $filesToCopy) {
|
||||
$src = Join-Path $script:SourceDir $item
|
||||
$dst = Join-Path $InstallDir $item
|
||||
|
||||
if (Test-Path $src) {
|
||||
try {
|
||||
if ((Get-Item $src).PSIsContainer) {
|
||||
# For directories, remove destination first if exists to avoid nesting
|
||||
if (Test-Path $dst) {
|
||||
Remove-Item -Path $dst -Recurse -Force
|
||||
}
|
||||
Copy-Item -Path $src -Destination $dst -Recurse -Force
|
||||
}
|
||||
else {
|
||||
Copy-Item -Path $src -Destination $dst -Force
|
||||
}
|
||||
Write-Success "Copied: $item"
|
||||
}
|
||||
catch {
|
||||
Write-MoleError "Failed to copy $item`: $_"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Create scripts and tests directories if they don't exist
|
||||
$extraDirs = @("scripts", "tests")
|
||||
foreach ($dir in $extraDirs) {
|
||||
$dirPath = Join-Path $InstallDir $dir
|
||||
if (-not (Test-Path $dirPath)) {
|
||||
New-Item -ItemType Directory -Path $dirPath -Force | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
# Create launcher batch file for easier access
|
||||
# Note: Store %~dp0 immediately to avoid issues with delayed expansion in the parse loop
|
||||
$batchContent = @"
|
||||
@echo off
|
||||
setlocal EnableDelayedExpansion
|
||||
|
||||
rem Store the script directory immediately before any shifting
|
||||
set "MOLE_DIR=%~dp0"
|
||||
|
||||
set "ARGS="
|
||||
:parse
|
||||
if "%~1"=="" goto run
|
||||
set "ARGS=!ARGS! '%~1'"
|
||||
shift
|
||||
goto parse
|
||||
:run
|
||||
powershell.exe -ExecutionPolicy Bypass -NoLogo -NoProfile -Command "& '%MOLE_DIR%mole.ps1' !ARGS!"
|
||||
"@
|
||||
$batchPath = Join-Path $InstallDir "mole.cmd"
|
||||
Set-Content -Path $batchPath -Value $batchContent -Encoding ASCII
|
||||
Write-Success "Created launcher: mole.cmd"
|
||||
|
||||
# Add to PATH if requested
|
||||
if ($AddToPath) {
|
||||
Write-Host ""
|
||||
Add-ToUserPath -Directory $InstallDir
|
||||
}
|
||||
|
||||
# Create shortcut if requested
|
||||
if ($CreateShortcut) {
|
||||
Write-Host ""
|
||||
$targetPath = Join-Path $InstallDir "mole.ps1"
|
||||
New-StartMenuShortcut -TargetPath $targetPath -ShortcutName $script:ShortcutName -Description "Windows System Maintenance Toolkit"
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Success "Mole installed successfully!"
|
||||
Write-Host ""
|
||||
Write-Host " Location: $InstallDir"
|
||||
Write-Host ""
|
||||
|
||||
if ($AddToPath) {
|
||||
Write-Host " Run 'mole' from any terminal to start"
|
||||
}
|
||||
else {
|
||||
Write-Host " Run the following to start:"
|
||||
Write-Host " & `"$InstallDir\mole.ps1`""
|
||||
Write-Host ""
|
||||
Write-Host " Or add to PATH with:"
|
||||
Write-Host " .\install.ps1 -AddToPath"
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
return $true
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Uninstall
|
||||
# ============================================================================
|
||||
|
||||
function Uninstall-Mole {
|
||||
Write-Info "Uninstalling Mole..."
|
||||
Write-Host ""
|
||||
|
||||
# Check for existing installation
|
||||
$configPath = Join-Path $env:LOCALAPPDATA "Mole"
|
||||
$installPath = if (Test-Path $InstallDir) { $InstallDir } elseif (Test-Path $configPath) { $configPath } else { $null }
|
||||
|
||||
if (-not $installPath) {
|
||||
Write-MoleWarning "Mole is not installed"
|
||||
return $true
|
||||
}
|
||||
|
||||
# Remove from PATH
|
||||
Remove-FromUserPath -Directory $installPath
|
||||
|
||||
# Remove shortcut
|
||||
Remove-StartMenuShortcut -ShortcutName $script:ShortcutName
|
||||
|
||||
# Remove installation directory
|
||||
try {
|
||||
Remove-Item -Path $installPath -Recurse -Force
|
||||
Write-Success "Removed directory: $installPath"
|
||||
}
|
||||
catch {
|
||||
Write-MoleError "Failed to remove directory: $_"
|
||||
return $false
|
||||
}
|
||||
|
||||
# Remove config directory if different from install
|
||||
$configDir = Join-Path $env:USERPROFILE ".config\mole"
|
||||
if (Test-Path $configDir) {
|
||||
Write-Info "Found config directory: $configDir"
|
||||
$response = Read-Host " Remove config files? (y/N)"
|
||||
if ($response -eq "y" -or $response -eq "Y") {
|
||||
try {
|
||||
Remove-Item -Path $configDir -Recurse -Force
|
||||
Write-Success "Removed config: $configDir"
|
||||
}
|
||||
catch {
|
||||
Write-MoleWarning "Failed to remove config: $_"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Success "Mole uninstalled successfully!"
|
||||
Write-Host ""
|
||||
return $true
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Main
|
||||
# ============================================================================
|
||||
|
||||
function Main {
|
||||
if ($ShowHelp) {
|
||||
Show-InstallerHelp
|
||||
return
|
||||
}
|
||||
|
||||
Show-Banner
|
||||
|
||||
if ($Uninstall) {
|
||||
Uninstall-Mole
|
||||
}
|
||||
else {
|
||||
Install-Mole
|
||||
}
|
||||
}
|
||||
|
||||
# Run
|
||||
try {
|
||||
Main
|
||||
}
|
||||
catch {
|
||||
Write-Host ""
|
||||
Write-Host " $($script:Colors.Red)ERROR$($script:Colors.NC) Installation failed: $_"
|
||||
Write-Host ""
|
||||
exit 1
|
||||
}
|
||||
@@ -1,442 +0,0 @@
|
||||
# Mole - Application-Specific Cleanup Module
|
||||
# Cleans leftover data from uninstalled apps and app-specific caches
|
||||
|
||||
#Requires -Version 5.1
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
# Prevent multiple sourcing
|
||||
if ((Get-Variable -Name 'MOLE_CLEAN_APPS_LOADED' -Scope Script -ErrorAction SilentlyContinue) -and $script:MOLE_CLEAN_APPS_LOADED) { return }
|
||||
$script:MOLE_CLEAN_APPS_LOADED = $true
|
||||
|
||||
# Import dependencies
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
. "$scriptDir\..\core\base.ps1"
|
||||
. "$scriptDir\..\core\log.ps1"
|
||||
. "$scriptDir\..\core\file_ops.ps1"
|
||||
|
||||
# ============================================================================
|
||||
# Orphaned App Data Detection
|
||||
# ============================================================================
|
||||
|
||||
function Get-InstalledPrograms {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Get list of installed programs from registry
|
||||
#>
|
||||
|
||||
$programs = @()
|
||||
|
||||
$registryPaths = @(
|
||||
"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*"
|
||||
"HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*"
|
||||
"HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*"
|
||||
)
|
||||
|
||||
foreach ($path in $registryPaths) {
|
||||
$items = Get-ItemProperty -Path $path -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.DisplayName } |
|
||||
Select-Object DisplayName, InstallLocation, Publisher
|
||||
if ($items) {
|
||||
$programs += $items
|
||||
}
|
||||
}
|
||||
|
||||
# Also check UWP apps
|
||||
try {
|
||||
$uwpApps = Get-AppxPackage -ErrorAction SilentlyContinue |
|
||||
Select-Object @{N='DisplayName';E={$_.Name}}, @{N='InstallLocation';E={$_.InstallLocation}}, Publisher
|
||||
if ($uwpApps) {
|
||||
$programs += $uwpApps
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Debug "Could not enumerate UWP apps: $_"
|
||||
}
|
||||
|
||||
return $programs
|
||||
}
|
||||
|
||||
function Find-OrphanedAppData {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Find app data folders for apps that are no longer installed
|
||||
#>
|
||||
param([int]$DaysOld = 60)
|
||||
|
||||
$installedPrograms = Get-InstalledPrograms
|
||||
$installedNames = $installedPrograms | ForEach-Object { $_.DisplayName.ToLower() }
|
||||
|
||||
$orphanedPaths = @()
|
||||
$cutoffDate = (Get-Date).AddDays(-$DaysOld)
|
||||
|
||||
# Check common app data locations
|
||||
$appDataPaths = @(
|
||||
@{ Path = $env:APPDATA; Type = "Roaming" }
|
||||
@{ Path = $env:LOCALAPPDATA; Type = "Local" }
|
||||
)
|
||||
|
||||
foreach ($location in $appDataPaths) {
|
||||
if (-not (Test-Path $location.Path)) { continue }
|
||||
|
||||
$folders = Get-ChildItem -Path $location.Path -Directory -ErrorAction SilentlyContinue
|
||||
|
||||
foreach ($folder in $folders) {
|
||||
# Skip system folders
|
||||
$skipFolders = @('Microsoft', 'Windows', 'Packages', 'Programs', 'Temp', 'Roaming')
|
||||
if ($folder.Name -in $skipFolders) { continue }
|
||||
|
||||
# Skip if recently modified
|
||||
if ($folder.LastWriteTime -gt $cutoffDate) { continue }
|
||||
|
||||
# Check if app is installed using stricter matching
|
||||
# Require exact match or that folder name is a clear prefix/suffix of app name
|
||||
$isInstalled = $false
|
||||
$folderLower = $folder.Name.ToLower()
|
||||
foreach ($name in $installedNames) {
|
||||
# Exact match
|
||||
if ($name -eq $folderLower) {
|
||||
$isInstalled = $true
|
||||
break
|
||||
}
|
||||
# Folder is prefix of app name (e.g., "chrome" matches "chrome browser")
|
||||
if ($name.StartsWith($folderLower) -and $folderLower.Length -ge 4) {
|
||||
$isInstalled = $true
|
||||
break
|
||||
}
|
||||
# App name is prefix of folder (e.g., "vscode" matches "vscode-data")
|
||||
if ($folderLower.StartsWith($name) -and $name.Length -ge 4) {
|
||||
$isInstalled = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $isInstalled) {
|
||||
$orphanedPaths += @{
|
||||
Path = $folder.FullName
|
||||
Name = $folder.Name
|
||||
Type = $location.Type
|
||||
Size = (Get-PathSize -Path $folder.FullName)
|
||||
LastModified = $folder.LastWriteTime
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $orphanedPaths
|
||||
}
|
||||
|
||||
function Clear-OrphanedAppData {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Clean orphaned application data
|
||||
#>
|
||||
param([int]$DaysOld = 60)
|
||||
|
||||
Start-Section "Orphaned app data"
|
||||
|
||||
$orphaned = Find-OrphanedAppData -DaysOld $DaysOld
|
||||
|
||||
if ($orphaned.Count -eq 0) {
|
||||
Write-Info "No orphaned app data found"
|
||||
Stop-Section
|
||||
return
|
||||
}
|
||||
|
||||
# Filter by size (only clean if > 10MB to avoid noise)
|
||||
$significantOrphans = $orphaned | Where-Object { $_.Size -gt 10MB }
|
||||
|
||||
if ($significantOrphans.Count -gt 0) {
|
||||
$totalSize = ($significantOrphans | Measure-Object -Property Size -Sum).Sum
|
||||
$sizeHuman = Format-ByteSize -Bytes $totalSize
|
||||
|
||||
Write-Info "Found $($significantOrphans.Count) orphaned folders ($sizeHuman)"
|
||||
|
||||
foreach ($orphan in $significantOrphans) {
|
||||
$orphanSize = Format-ByteSize -Bytes $orphan.Size
|
||||
Remove-SafeItem -Path $orphan.Path -Description "$($orphan.Name) ($orphanSize)" -Recurse
|
||||
}
|
||||
}
|
||||
|
||||
Stop-Section
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Specific Application Cleanup
|
||||
# ============================================================================
|
||||
|
||||
function Clear-OfficeCache {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Clean Microsoft Office caches and temp files
|
||||
#>
|
||||
|
||||
$officeCachePaths = @(
|
||||
# Office 365 / 2019 / 2021
|
||||
"$env:LOCALAPPDATA\Microsoft\Office\16.0\OfficeFileCache"
|
||||
"$env:LOCALAPPDATA\Microsoft\Office\16.0\Wef"
|
||||
"$env:LOCALAPPDATA\Microsoft\Outlook\RoamCache"
|
||||
"$env:LOCALAPPDATA\Microsoft\Outlook\Offline Address Books"
|
||||
# Older Office versions
|
||||
"$env:LOCALAPPDATA\Microsoft\Office\15.0\OfficeFileCache"
|
||||
# Office temp files
|
||||
"$env:APPDATA\Microsoft\Templates\*.tmp"
|
||||
"$env:APPDATA\Microsoft\Word\*.tmp"
|
||||
"$env:APPDATA\Microsoft\Excel\*.tmp"
|
||||
"$env:APPDATA\Microsoft\PowerPoint\*.tmp"
|
||||
)
|
||||
|
||||
foreach ($path in $officeCachePaths) {
|
||||
if ($path -like "*.tmp") {
|
||||
$parent = Split-Path -Parent $path
|
||||
if (Test-Path $parent) {
|
||||
$tmpFiles = Get-ChildItem -Path $parent -Filter "*.tmp" -File -ErrorAction SilentlyContinue
|
||||
if ($tmpFiles) {
|
||||
$paths = $tmpFiles | ForEach-Object { $_.FullName }
|
||||
Remove-SafeItems -Paths $paths -Description "Office temp files"
|
||||
}
|
||||
}
|
||||
}
|
||||
elseif (Test-Path $path) {
|
||||
Clear-DirectoryContents -Path $path -Description "Office $(Split-Path -Leaf $path)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Clear-OneDriveCache {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Clean OneDrive cache
|
||||
#>
|
||||
|
||||
$oneDriveCachePaths = @(
|
||||
"$env:LOCALAPPDATA\Microsoft\OneDrive\logs"
|
||||
"$env:LOCALAPPDATA\Microsoft\OneDrive\setup\logs"
|
||||
)
|
||||
|
||||
foreach ($path in $oneDriveCachePaths) {
|
||||
if (Test-Path $path) {
|
||||
Remove-OldFiles -Path $path -DaysOld 7 -Description "OneDrive logs"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Clear-DropboxCache {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Clean Dropbox cache
|
||||
#>
|
||||
|
||||
# Dropbox cache is typically in the Dropbox folder itself
|
||||
$dropboxInfoPath = "$env:LOCALAPPDATA\Dropbox\info.json"
|
||||
|
||||
if (Test-Path $dropboxInfoPath) {
|
||||
try {
|
||||
$dropboxInfo = Get-Content $dropboxInfoPath | ConvertFrom-Json
|
||||
$dropboxPath = $dropboxInfo.personal.path
|
||||
|
||||
if ($dropboxPath) {
|
||||
$dropboxCachePath = "$dropboxPath\.dropbox.cache"
|
||||
if (Test-Path $dropboxCachePath) {
|
||||
Clear-DirectoryContents -Path $dropboxCachePath -Description "Dropbox cache"
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Debug "Could not read Dropbox config: $_"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Clear-GoogleDriveCache {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Clean Google Drive cache
|
||||
#>
|
||||
|
||||
$googleDriveCachePaths = @(
|
||||
"$env:LOCALAPPDATA\Google\DriveFS\Logs"
|
||||
"$env:LOCALAPPDATA\Google\DriveFS\*.tmp"
|
||||
)
|
||||
|
||||
foreach ($path in $googleDriveCachePaths) {
|
||||
if ($path -like "*.tmp") {
|
||||
$parent = Split-Path -Parent $path
|
||||
if (Test-Path $parent) {
|
||||
$tmpFiles = Get-ChildItem -Path $parent -Filter "*.tmp" -ErrorAction SilentlyContinue
|
||||
if ($tmpFiles) {
|
||||
$paths = $tmpFiles | ForEach-Object { $_.FullName }
|
||||
Remove-SafeItems -Paths $paths -Description "Google Drive temp"
|
||||
}
|
||||
}
|
||||
}
|
||||
elseif (Test-Path $path) {
|
||||
Remove-OldFiles -Path $path -DaysOld 7 -Description "Google Drive logs"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Clear-AdobeData {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Clean Adobe application caches and temp files
|
||||
#>
|
||||
|
||||
$adobeCachePaths = @(
|
||||
"$env:APPDATA\Adobe\Common\Media Cache Files"
|
||||
"$env:APPDATA\Adobe\Common\Peak Files"
|
||||
"$env:APPDATA\Adobe\Common\Team Projects Cache"
|
||||
"$env:LOCALAPPDATA\Adobe\*\Cache"
|
||||
"$env:LOCALAPPDATA\Adobe\*\CameraRaw\Cache"
|
||||
"$env:LOCALAPPDATA\Temp\Adobe"
|
||||
)
|
||||
|
||||
foreach ($pattern in $adobeCachePaths) {
|
||||
$paths = Resolve-Path $pattern -ErrorAction SilentlyContinue
|
||||
foreach ($path in $paths) {
|
||||
if (Test-Path $path.Path) {
|
||||
Clear-DirectoryContents -Path $path.Path -Description "Adobe cache"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Clear-AutodeskData {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Clean Autodesk application caches
|
||||
#>
|
||||
|
||||
$autodeskCachePaths = @(
|
||||
"$env:LOCALAPPDATA\Autodesk\*\Cache"
|
||||
"$env:APPDATA\Autodesk\*\cache"
|
||||
)
|
||||
|
||||
foreach ($pattern in $autodeskCachePaths) {
|
||||
$paths = Resolve-Path $pattern -ErrorAction SilentlyContinue
|
||||
foreach ($path in $paths) {
|
||||
if (Test-Path $path.Path) {
|
||||
Clear-DirectoryContents -Path $path.Path -Description "Autodesk cache"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Gaming Platform Cleanup
|
||||
# ============================================================================
|
||||
|
||||
function Clear-GamingPlatformCaches {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Clean gaming platform caches (Steam, Epic, Origin, etc.)
|
||||
#>
|
||||
|
||||
# Steam
|
||||
$steamPaths = @(
|
||||
"${env:ProgramFiles(x86)}\Steam\appcache\httpcache"
|
||||
"${env:ProgramFiles(x86)}\Steam\appcache\librarycache"
|
||||
"${env:ProgramFiles(x86)}\Steam\logs"
|
||||
)
|
||||
foreach ($path in $steamPaths) {
|
||||
if (Test-Path $path) {
|
||||
Clear-DirectoryContents -Path $path -Description "Steam $(Split-Path -Leaf $path)"
|
||||
}
|
||||
}
|
||||
|
||||
# Epic Games Launcher
|
||||
$epicPaths = @(
|
||||
"$env:LOCALAPPDATA\EpicGamesLauncher\Saved\webcache"
|
||||
"$env:LOCALAPPDATA\EpicGamesLauncher\Saved\Logs"
|
||||
)
|
||||
foreach ($path in $epicPaths) {
|
||||
if (Test-Path $path) {
|
||||
Clear-DirectoryContents -Path $path -Description "Epic Games $(Split-Path -Leaf $path)"
|
||||
}
|
||||
}
|
||||
|
||||
# EA App (Origin replacement)
|
||||
$eaPaths = @(
|
||||
"$env:LOCALAPPDATA\Electronic Arts\EA Desktop\cache"
|
||||
"$env:APPDATA\Origin\*\cache"
|
||||
)
|
||||
foreach ($pattern in $eaPaths) {
|
||||
$paths = Resolve-Path $pattern -ErrorAction SilentlyContinue
|
||||
foreach ($path in $paths) {
|
||||
if (Test-Path $path.Path) {
|
||||
Clear-DirectoryContents -Path $path.Path -Description "EA/Origin cache"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# GOG Galaxy
|
||||
$gogPaths = @(
|
||||
"$env:LOCALAPPDATA\GOG.com\Galaxy\webcache"
|
||||
"$env:PROGRAMDATA\GOG.com\Galaxy\logs"
|
||||
)
|
||||
foreach ($path in $gogPaths) {
|
||||
if (Test-Path $path) {
|
||||
Clear-DirectoryContents -Path $path -Description "GOG Galaxy $(Split-Path -Leaf $path)"
|
||||
}
|
||||
}
|
||||
|
||||
# Ubisoft Connect
|
||||
$ubiPaths = @(
|
||||
"$env:LOCALAPPDATA\Ubisoft Game Launcher\cache"
|
||||
"$env:LOCALAPPDATA\Ubisoft Game Launcher\logs"
|
||||
)
|
||||
foreach ($path in $ubiPaths) {
|
||||
if (Test-Path $path) {
|
||||
Clear-DirectoryContents -Path $path -Description "Ubisoft $(Split-Path -Leaf $path)"
|
||||
}
|
||||
}
|
||||
|
||||
# Battle.net
|
||||
$battlenetPaths = @(
|
||||
"$env:APPDATA\Battle.net\Cache"
|
||||
"$env:APPDATA\Battle.net\Logs"
|
||||
)
|
||||
foreach ($path in $battlenetPaths) {
|
||||
if (Test-Path $path) {
|
||||
Clear-DirectoryContents -Path $path -Description "Battle.net $(Split-Path -Leaf $path)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Main Application Cleanup Function
|
||||
# ============================================================================
|
||||
|
||||
function Invoke-AppCleanup {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Run all application-specific cleanup tasks
|
||||
#>
|
||||
param([switch]$IncludeOrphaned)
|
||||
|
||||
Start-Section "Applications"
|
||||
|
||||
# Productivity apps
|
||||
Clear-OfficeCache
|
||||
Clear-OneDriveCache
|
||||
Clear-DropboxCache
|
||||
Clear-GoogleDriveCache
|
||||
|
||||
# Creative apps
|
||||
Clear-AdobeData
|
||||
Clear-AutodeskData
|
||||
|
||||
# Gaming platforms
|
||||
Clear-GamingPlatformCaches
|
||||
|
||||
Stop-Section
|
||||
|
||||
# Orphaned app data (separate section)
|
||||
if ($IncludeOrphaned) {
|
||||
Clear-OrphanedAppData -DaysOld 60
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Exports
|
||||
# ============================================================================
|
||||
# Functions: Get-InstalledPrograms, Find-OrphanedAppData, Clear-OfficeCache, etc.
|
||||
@@ -1,385 +0,0 @@
|
||||
# Mole - Cache Cleanup Module
|
||||
# Cleans Windows and application caches
|
||||
|
||||
#Requires -Version 5.1
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
# Prevent multiple sourcing
|
||||
if ((Get-Variable -Name 'MOLE_CLEAN_CACHES_LOADED' -Scope Script -ErrorAction SilentlyContinue) -and $script:MOLE_CLEAN_CACHES_LOADED) { return }
|
||||
$script:MOLE_CLEAN_CACHES_LOADED = $true
|
||||
|
||||
# Import dependencies
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
. "$scriptDir\..\core\base.ps1"
|
||||
. "$scriptDir\..\core\log.ps1"
|
||||
. "$scriptDir\..\core\file_ops.ps1"
|
||||
|
||||
# ============================================================================
|
||||
# Windows System Caches
|
||||
# ============================================================================
|
||||
|
||||
function Clear-WindowsUpdateCache {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Clean Windows Update cache (requires admin)
|
||||
#>
|
||||
|
||||
if (-not (Test-IsAdmin)) {
|
||||
Write-Debug "Skipping Windows Update cache - requires admin"
|
||||
return
|
||||
}
|
||||
|
||||
$wuPath = "$env:WINDIR\SoftwareDistribution\Download"
|
||||
|
||||
if (Test-Path $wuPath) {
|
||||
# Stop Windows Update service first
|
||||
if (Test-DryRunMode) {
|
||||
Write-DryRun "Windows Update cache"
|
||||
Set-SectionActivity
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
Stop-Service -Name wuauserv -Force -ErrorAction SilentlyContinue
|
||||
Clear-DirectoryContents -Path $wuPath -Description "Windows Update cache"
|
||||
Start-Service -Name wuauserv -ErrorAction SilentlyContinue
|
||||
}
|
||||
catch {
|
||||
Write-Debug "Could not clear Windows Update cache: $_"
|
||||
Start-Service -Name wuauserv -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Clear-DeliveryOptimizationCache {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Clean Windows Delivery Optimization cache (requires admin)
|
||||
#>
|
||||
|
||||
if (-not (Test-IsAdmin)) {
|
||||
Write-Debug "Skipping Delivery Optimization cache - requires admin"
|
||||
return
|
||||
}
|
||||
|
||||
$doPath = "$env:WINDIR\ServiceProfiles\NetworkService\AppData\Local\Microsoft\Windows\DeliveryOptimization"
|
||||
|
||||
if (Test-Path $doPath) {
|
||||
if (Test-DryRunMode) {
|
||||
Write-DryRun "Delivery Optimization cache"
|
||||
Set-SectionActivity
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
Stop-Service -Name DoSvc -Force -ErrorAction SilentlyContinue
|
||||
Clear-DirectoryContents -Path "$doPath\Cache" -Description "Delivery Optimization cache"
|
||||
Start-Service -Name DoSvc -ErrorAction SilentlyContinue
|
||||
}
|
||||
catch {
|
||||
Write-Debug "Could not clear Delivery Optimization cache: $_"
|
||||
Start-Service -Name DoSvc -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Clear-FontCache {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Clean Windows font cache (requires admin)
|
||||
#>
|
||||
|
||||
if (-not (Test-IsAdmin)) {
|
||||
return
|
||||
}
|
||||
|
||||
$fontCachePath = "$env:LOCALAPPDATA\Microsoft\Windows\Fonts\FontCache"
|
||||
|
||||
if (Test-Path $fontCachePath) {
|
||||
Remove-SafeItem -Path $fontCachePath -Description "Font cache"
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Browser Caches
|
||||
# ============================================================================
|
||||
|
||||
function Clear-BrowserCaches {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Clean browser cache directories
|
||||
#>
|
||||
|
||||
Start-Section "Browser caches"
|
||||
|
||||
# Chrome
|
||||
$chromeCachePaths = @(
|
||||
"$env:LOCALAPPDATA\Google\Chrome\User Data\Default\Cache"
|
||||
"$env:LOCALAPPDATA\Google\Chrome\User Data\Default\Code Cache"
|
||||
"$env:LOCALAPPDATA\Google\Chrome\User Data\Default\GPUCache"
|
||||
"$env:LOCALAPPDATA\Google\Chrome\User Data\Default\Service Worker\CacheStorage"
|
||||
"$env:LOCALAPPDATA\Google\Chrome\User Data\ShaderCache"
|
||||
"$env:LOCALAPPDATA\Google\Chrome\User Data\GrShaderCache"
|
||||
)
|
||||
|
||||
foreach ($path in $chromeCachePaths) {
|
||||
if (Test-Path $path) {
|
||||
Clear-DirectoryContents -Path $path -Description "Chrome $(Split-Path -Leaf $path)"
|
||||
}
|
||||
}
|
||||
|
||||
# Edge
|
||||
$edgeCachePaths = @(
|
||||
"$env:LOCALAPPDATA\Microsoft\Edge\User Data\Default\Cache"
|
||||
"$env:LOCALAPPDATA\Microsoft\Edge\User Data\Default\Code Cache"
|
||||
"$env:LOCALAPPDATA\Microsoft\Edge\User Data\Default\GPUCache"
|
||||
"$env:LOCALAPPDATA\Microsoft\Edge\User Data\Default\Service Worker\CacheStorage"
|
||||
"$env:LOCALAPPDATA\Microsoft\Edge\User Data\ShaderCache"
|
||||
)
|
||||
|
||||
foreach ($path in $edgeCachePaths) {
|
||||
if (Test-Path $path) {
|
||||
Clear-DirectoryContents -Path $path -Description "Edge $(Split-Path -Leaf $path)"
|
||||
}
|
||||
}
|
||||
|
||||
# Firefox
|
||||
$firefoxProfiles = "$env:APPDATA\Mozilla\Firefox\Profiles"
|
||||
if (Test-Path $firefoxProfiles) {
|
||||
$profiles = Get-ChildItem -Path $firefoxProfiles -Directory -ErrorAction SilentlyContinue
|
||||
foreach ($profile in $profiles) {
|
||||
$firefoxCachePaths = @(
|
||||
"$($profile.FullName)\cache2"
|
||||
"$($profile.FullName)\startupCache"
|
||||
"$($profile.FullName)\shader-cache"
|
||||
)
|
||||
foreach ($path in $firefoxCachePaths) {
|
||||
if (Test-Path $path) {
|
||||
Clear-DirectoryContents -Path $path -Description "Firefox cache"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Brave
|
||||
$braveCachePath = "$env:LOCALAPPDATA\BraveSoftware\Brave-Browser\User Data\Default\Cache"
|
||||
if (Test-Path $braveCachePath) {
|
||||
Clear-DirectoryContents -Path $braveCachePath -Description "Brave cache"
|
||||
}
|
||||
|
||||
# Opera
|
||||
$operaCachePath = "$env:APPDATA\Opera Software\Opera Stable\Cache"
|
||||
if (Test-Path $operaCachePath) {
|
||||
Clear-DirectoryContents -Path $operaCachePath -Description "Opera cache"
|
||||
}
|
||||
|
||||
Stop-Section
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Application Caches
|
||||
# ============================================================================
|
||||
|
||||
function Clear-AppCaches {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Clean common application caches
|
||||
#>
|
||||
|
||||
Start-Section "Application caches"
|
||||
|
||||
# Spotify
|
||||
$spotifyCachePaths = @(
|
||||
"$env:LOCALAPPDATA\Spotify\Data"
|
||||
"$env:LOCALAPPDATA\Spotify\Storage"
|
||||
)
|
||||
foreach ($path in $spotifyCachePaths) {
|
||||
if (Test-Path $path) {
|
||||
Clear-DirectoryContents -Path $path -Description "Spotify cache"
|
||||
}
|
||||
}
|
||||
|
||||
# Discord
|
||||
$discordCachePaths = @(
|
||||
"$env:APPDATA\discord\Cache"
|
||||
"$env:APPDATA\discord\Code Cache"
|
||||
"$env:APPDATA\discord\GPUCache"
|
||||
)
|
||||
foreach ($path in $discordCachePaths) {
|
||||
if (Test-Path $path) {
|
||||
Clear-DirectoryContents -Path $path -Description "Discord cache"
|
||||
}
|
||||
}
|
||||
|
||||
# Slack
|
||||
$slackCachePaths = @(
|
||||
"$env:APPDATA\Slack\Cache"
|
||||
"$env:APPDATA\Slack\Code Cache"
|
||||
"$env:APPDATA\Slack\GPUCache"
|
||||
"$env:APPDATA\Slack\Service Worker\CacheStorage"
|
||||
)
|
||||
foreach ($path in $slackCachePaths) {
|
||||
if (Test-Path $path) {
|
||||
Clear-DirectoryContents -Path $path -Description "Slack cache"
|
||||
}
|
||||
}
|
||||
|
||||
# Teams
|
||||
$teamsCachePaths = @(
|
||||
"$env:APPDATA\Microsoft\Teams\Cache"
|
||||
"$env:APPDATA\Microsoft\Teams\blob_storage"
|
||||
"$env:APPDATA\Microsoft\Teams\databases"
|
||||
"$env:APPDATA\Microsoft\Teams\GPUCache"
|
||||
"$env:APPDATA\Microsoft\Teams\IndexedDB"
|
||||
"$env:APPDATA\Microsoft\Teams\Local Storage"
|
||||
"$env:APPDATA\Microsoft\Teams\tmp"
|
||||
)
|
||||
foreach ($path in $teamsCachePaths) {
|
||||
if (Test-Path $path) {
|
||||
Clear-DirectoryContents -Path $path -Description "Teams cache"
|
||||
}
|
||||
}
|
||||
|
||||
# VS Code
|
||||
$vscodeCachePaths = @(
|
||||
"$env:APPDATA\Code\Cache"
|
||||
"$env:APPDATA\Code\CachedData"
|
||||
"$env:APPDATA\Code\CachedExtensions"
|
||||
"$env:APPDATA\Code\CachedExtensionVSIXs"
|
||||
"$env:APPDATA\Code\Code Cache"
|
||||
"$env:APPDATA\Code\GPUCache"
|
||||
)
|
||||
foreach ($path in $vscodeCachePaths) {
|
||||
if (Test-Path $path) {
|
||||
Clear-DirectoryContents -Path $path -Description "VS Code cache"
|
||||
}
|
||||
}
|
||||
|
||||
# Zoom
|
||||
$zoomCachePath = "$env:APPDATA\Zoom\data"
|
||||
if (Test-Path $zoomCachePath) {
|
||||
Clear-DirectoryContents -Path $zoomCachePath -Description "Zoom cache"
|
||||
}
|
||||
|
||||
# Adobe Creative Cloud
|
||||
$adobeCachePaths = @(
|
||||
"$env:LOCALAPPDATA\Adobe\*\Cache"
|
||||
"$env:APPDATA\Adobe\Common\Media Cache Files"
|
||||
"$env:APPDATA\Adobe\Common\Peak Files"
|
||||
)
|
||||
foreach ($pattern in $adobeCachePaths) {
|
||||
$paths = Resolve-Path $pattern -ErrorAction SilentlyContinue
|
||||
foreach ($path in $paths) {
|
||||
if (Test-Path $path) {
|
||||
Clear-DirectoryContents -Path $path.Path -Description "Adobe cache"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Steam (download cache, not games)
|
||||
$steamCachePath = "${env:ProgramFiles(x86)}\Steam\appcache"
|
||||
if (Test-Path $steamCachePath) {
|
||||
Clear-DirectoryContents -Path $steamCachePath -Description "Steam app cache"
|
||||
}
|
||||
|
||||
# Epic Games Launcher
|
||||
$epicCachePath = "$env:LOCALAPPDATA\EpicGamesLauncher\Saved\webcache"
|
||||
if (Test-Path $epicCachePath) {
|
||||
Clear-DirectoryContents -Path $epicCachePath -Description "Epic Games cache"
|
||||
}
|
||||
|
||||
Stop-Section
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Windows Store / UWP App Caches
|
||||
# ============================================================================
|
||||
|
||||
function Clear-StoreAppCaches {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Clean Windows Store and UWP app caches
|
||||
#>
|
||||
|
||||
# Microsoft Store cache
|
||||
$storeCache = "$env:LOCALAPPDATA\Microsoft\Windows\WCN"
|
||||
if (Test-Path $storeCache) {
|
||||
Clear-DirectoryContents -Path $storeCache -Description "Windows Store cache"
|
||||
}
|
||||
|
||||
# Store app temp files
|
||||
$storeTemp = "$env:LOCALAPPDATA\Packages\Microsoft.WindowsStore_*\LocalCache"
|
||||
$storePaths = Resolve-Path $storeTemp -ErrorAction SilentlyContinue
|
||||
foreach ($path in $storePaths) {
|
||||
if (Test-Path $path.Path) {
|
||||
Clear-DirectoryContents -Path $path.Path -Description "Store LocalCache"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# .NET / Runtime Caches
|
||||
# ============================================================================
|
||||
|
||||
function Clear-DotNetCaches {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Clean .NET runtime caches
|
||||
#>
|
||||
|
||||
# .NET temp files
|
||||
$dotnetTemp = "$env:LOCALAPPDATA\Temp\Microsoft.NET"
|
||||
if (Test-Path $dotnetTemp) {
|
||||
Clear-DirectoryContents -Path $dotnetTemp -Description ".NET temp files"
|
||||
}
|
||||
|
||||
# NGen cache (don't touch - managed by Windows)
|
||||
# Assembly cache (don't touch - managed by CLR)
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Main Cache Cleanup Function
|
||||
# ============================================================================
|
||||
|
||||
function Invoke-CacheCleanup {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Run all cache cleanup tasks
|
||||
#>
|
||||
param(
|
||||
[switch]$IncludeWindowsUpdate,
|
||||
[switch]$IncludeBrowsers,
|
||||
[switch]$IncludeApps
|
||||
)
|
||||
|
||||
Start-Section "System caches"
|
||||
|
||||
# Windows system caches (if admin)
|
||||
if (Test-IsAdmin) {
|
||||
if ($IncludeWindowsUpdate) {
|
||||
Clear-WindowsUpdateCache
|
||||
Clear-DeliveryOptimizationCache
|
||||
}
|
||||
Clear-FontCache
|
||||
}
|
||||
|
||||
Clear-StoreAppCaches
|
||||
Clear-DotNetCaches
|
||||
|
||||
Stop-Section
|
||||
|
||||
# Browser caches
|
||||
if ($IncludeBrowsers) {
|
||||
Clear-BrowserCaches
|
||||
}
|
||||
|
||||
# Application caches
|
||||
if ($IncludeApps) {
|
||||
Clear-AppCaches
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Exports
|
||||
# ============================================================================
|
||||
# Functions: Clear-WindowsUpdateCache, Clear-BrowserCaches, Clear-AppCaches, etc.
|
||||
@@ -1,537 +0,0 @@
|
||||
# Mole - Developer Tools Cleanup Module
|
||||
# Cleans development tool caches and build artifacts
|
||||
|
||||
#Requires -Version 5.1
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
# Prevent multiple sourcing
|
||||
if ((Get-Variable -Name 'MOLE_CLEAN_DEV_LOADED' -Scope Script -ErrorAction SilentlyContinue) -and $script:MOLE_CLEAN_DEV_LOADED) { return }
|
||||
$script:MOLE_CLEAN_DEV_LOADED = $true
|
||||
|
||||
# Import dependencies
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
. "$scriptDir\..\core\base.ps1"
|
||||
. "$scriptDir\..\core\log.ps1"
|
||||
. "$scriptDir\..\core\file_ops.ps1"
|
||||
|
||||
# ============================================================================
|
||||
# Node.js / JavaScript Ecosystem
|
||||
# ============================================================================
|
||||
|
||||
function Clear-NpmCache {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Clean npm, pnpm, yarn, and bun caches
|
||||
#>
|
||||
|
||||
# npm cache
|
||||
if (Get-Command npm -ErrorAction SilentlyContinue) {
|
||||
if (Test-DryRunMode) {
|
||||
Write-DryRun "npm cache"
|
||||
}
|
||||
else {
|
||||
try {
|
||||
$null = npm cache clean --force 2>&1
|
||||
Write-Success "npm cache"
|
||||
Set-SectionActivity
|
||||
}
|
||||
catch {
|
||||
Write-Debug "npm cache clean failed: $_"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# npm cache directory (fallback)
|
||||
$npmCachePath = "$env:APPDATA\npm-cache"
|
||||
if (Test-Path $npmCachePath) {
|
||||
Clear-DirectoryContents -Path $npmCachePath -Description "npm cache directory"
|
||||
}
|
||||
|
||||
# pnpm store
|
||||
$pnpmStorePath = "$env:LOCALAPPDATA\pnpm\store"
|
||||
if (Test-Path $pnpmStorePath) {
|
||||
if (Get-Command pnpm -ErrorAction SilentlyContinue) {
|
||||
if (Test-DryRunMode) {
|
||||
Write-DryRun "pnpm store"
|
||||
}
|
||||
else {
|
||||
try {
|
||||
$null = pnpm store prune 2>&1
|
||||
Write-Success "pnpm store pruned"
|
||||
Set-SectionActivity
|
||||
}
|
||||
catch {
|
||||
Write-Debug "pnpm store prune failed: $_"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Yarn cache
|
||||
$yarnCachePaths = @(
|
||||
"$env:LOCALAPPDATA\Yarn\Cache"
|
||||
"$env:USERPROFILE\.yarn\cache"
|
||||
)
|
||||
foreach ($path in $yarnCachePaths) {
|
||||
if (Test-Path $path) {
|
||||
Clear-DirectoryContents -Path $path -Description "Yarn cache"
|
||||
}
|
||||
}
|
||||
|
||||
# Bun cache
|
||||
$bunCachePath = "$env:USERPROFILE\.bun\install\cache"
|
||||
if (Test-Path $bunCachePath) {
|
||||
Clear-DirectoryContents -Path $bunCachePath -Description "Bun cache"
|
||||
}
|
||||
}
|
||||
|
||||
function Clear-NodeBuildCaches {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Clean Node.js build-related caches
|
||||
#>
|
||||
|
||||
# node-gyp
|
||||
$nodeGypPath = "$env:LOCALAPPDATA\node-gyp\Cache"
|
||||
if (Test-Path $nodeGypPath) {
|
||||
Clear-DirectoryContents -Path $nodeGypPath -Description "node-gyp cache"
|
||||
}
|
||||
|
||||
# Electron cache
|
||||
$electronCachePath = "$env:LOCALAPPDATA\electron\Cache"
|
||||
if (Test-Path $electronCachePath) {
|
||||
Clear-DirectoryContents -Path $electronCachePath -Description "Electron cache"
|
||||
}
|
||||
|
||||
# TypeScript cache
|
||||
$tsCachePath = "$env:LOCALAPPDATA\TypeScript"
|
||||
if (Test-Path $tsCachePath) {
|
||||
Clear-DirectoryContents -Path $tsCachePath -Description "TypeScript cache"
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Python Ecosystem
|
||||
# ============================================================================
|
||||
|
||||
function Clear-PythonCaches {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Clean Python and pip caches
|
||||
#>
|
||||
|
||||
# pip cache
|
||||
if (Get-Command pip -ErrorAction SilentlyContinue) {
|
||||
if (Test-DryRunMode) {
|
||||
Write-DryRun "pip cache"
|
||||
}
|
||||
else {
|
||||
try {
|
||||
$null = pip cache purge 2>&1
|
||||
Write-Success "pip cache"
|
||||
Set-SectionActivity
|
||||
}
|
||||
catch {
|
||||
Write-Debug "pip cache purge failed: $_"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# pip cache directory
|
||||
$pipCachePath = "$env:LOCALAPPDATA\pip\Cache"
|
||||
if (Test-Path $pipCachePath) {
|
||||
Clear-DirectoryContents -Path $pipCachePath -Description "pip cache directory"
|
||||
}
|
||||
|
||||
# Python bytecode caches (__pycache__)
|
||||
# Note: These are typically in project directories, cleaned by purge command
|
||||
|
||||
# pyenv cache
|
||||
$pyenvCachePath = "$env:USERPROFILE\.pyenv\cache"
|
||||
if (Test-Path $pyenvCachePath) {
|
||||
Clear-DirectoryContents -Path $pyenvCachePath -Description "pyenv cache"
|
||||
}
|
||||
|
||||
# Poetry cache
|
||||
$poetryCachePath = "$env:LOCALAPPDATA\pypoetry\Cache"
|
||||
if (Test-Path $poetryCachePath) {
|
||||
Clear-DirectoryContents -Path $poetryCachePath -Description "Poetry cache"
|
||||
}
|
||||
|
||||
# conda packages
|
||||
$condaCachePaths = @(
|
||||
"$env:USERPROFILE\.conda\pkgs"
|
||||
"$env:USERPROFILE\anaconda3\pkgs"
|
||||
"$env:USERPROFILE\miniconda3\pkgs"
|
||||
)
|
||||
foreach ($path in $condaCachePaths) {
|
||||
if (Test-Path $path) {
|
||||
# Only clean index and temp files, not actual packages
|
||||
$tempFiles = Get-ChildItem -Path $path -Filter "*.tmp" -ErrorAction SilentlyContinue
|
||||
if ($tempFiles) {
|
||||
$paths = $tempFiles | ForEach-Object { $_.FullName }
|
||||
Remove-SafeItems -Paths $paths -Description "Conda temp files"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Jupyter runtime
|
||||
$jupyterRuntimePath = "$env:APPDATA\jupyter\runtime"
|
||||
if (Test-Path $jupyterRuntimePath) {
|
||||
Clear-DirectoryContents -Path $jupyterRuntimePath -Description "Jupyter runtime"
|
||||
}
|
||||
|
||||
# pytest cache
|
||||
$pytestCachePath = "$env:USERPROFILE\.pytest_cache"
|
||||
if (Test-Path $pytestCachePath) {
|
||||
Remove-SafeItem -Path $pytestCachePath -Description "pytest cache" -Recurse
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# .NET / C# Ecosystem
|
||||
# ============================================================================
|
||||
|
||||
function Clear-DotNetDevCaches {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Clean .NET development caches
|
||||
#>
|
||||
|
||||
# NuGet cache
|
||||
$nugetCachePath = "$env:USERPROFILE\.nuget\packages"
|
||||
# Don't clean packages by default - they're needed for builds
|
||||
# Only clean http-cache and temp
|
||||
|
||||
$nugetHttpCache = "$env:LOCALAPPDATA\NuGet\v3-cache"
|
||||
if (Test-Path $nugetHttpCache) {
|
||||
Clear-DirectoryContents -Path $nugetHttpCache -Description "NuGet HTTP cache"
|
||||
}
|
||||
|
||||
$nugetTempPath = "$env:LOCALAPPDATA\NuGet\plugins-cache"
|
||||
if (Test-Path $nugetTempPath) {
|
||||
Clear-DirectoryContents -Path $nugetTempPath -Description "NuGet plugins cache"
|
||||
}
|
||||
|
||||
# MSBuild temp files
|
||||
$msbuildTemp = "$env:LOCALAPPDATA\Microsoft\MSBuild"
|
||||
if (Test-Path $msbuildTemp) {
|
||||
$tempDirs = Get-ChildItem -Path $msbuildTemp -Directory -Filter "*temp*" -ErrorAction SilentlyContinue
|
||||
foreach ($dir in $tempDirs) {
|
||||
Clear-DirectoryContents -Path $dir.FullName -Description "MSBuild temp"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Go Ecosystem
|
||||
# ============================================================================
|
||||
|
||||
function Clear-GoCaches {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Clean Go build and module caches
|
||||
#>
|
||||
|
||||
if (Get-Command go -ErrorAction SilentlyContinue) {
|
||||
if (Test-DryRunMode) {
|
||||
Write-DryRun "Go cache"
|
||||
}
|
||||
else {
|
||||
try {
|
||||
$null = go clean -cache 2>&1
|
||||
Write-Success "Go build cache"
|
||||
Set-SectionActivity
|
||||
}
|
||||
catch {
|
||||
Write-Debug "go clean -cache failed: $_"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Go module cache
|
||||
$goModCachePath = "$env:GOPATH\pkg\mod\cache"
|
||||
if (-not $env:GOPATH) {
|
||||
$goModCachePath = "$env:USERPROFILE\go\pkg\mod\cache"
|
||||
}
|
||||
if (Test-Path $goModCachePath) {
|
||||
Clear-DirectoryContents -Path $goModCachePath -Description "Go module cache"
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Rust Ecosystem
|
||||
# ============================================================================
|
||||
|
||||
function Clear-RustCaches {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Clean Rust/Cargo caches
|
||||
#>
|
||||
|
||||
# Cargo registry cache
|
||||
$cargoRegistryCache = "$env:USERPROFILE\.cargo\registry\cache"
|
||||
if (Test-Path $cargoRegistryCache) {
|
||||
Clear-DirectoryContents -Path $cargoRegistryCache -Description "Cargo registry cache"
|
||||
}
|
||||
|
||||
# Cargo git cache
|
||||
$cargoGitCache = "$env:USERPROFILE\.cargo\git\checkouts"
|
||||
if (Test-Path $cargoGitCache) {
|
||||
Clear-DirectoryContents -Path $cargoGitCache -Description "Cargo git cache"
|
||||
}
|
||||
|
||||
# Rustup downloads
|
||||
$rustupDownloads = "$env:USERPROFILE\.rustup\downloads"
|
||||
if (Test-Path $rustupDownloads) {
|
||||
Clear-DirectoryContents -Path $rustupDownloads -Description "Rustup downloads"
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Java / JVM Ecosystem
|
||||
# ============================================================================
|
||||
|
||||
function Clear-JvmCaches {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Clean JVM ecosystem caches (Gradle, Maven, etc.)
|
||||
#>
|
||||
|
||||
# Gradle caches
|
||||
$gradleCachePaths = @(
|
||||
"$env:USERPROFILE\.gradle\caches"
|
||||
"$env:USERPROFILE\.gradle\daemon"
|
||||
"$env:USERPROFILE\.gradle\wrapper\dists"
|
||||
)
|
||||
foreach ($path in $gradleCachePaths) {
|
||||
if (Test-Path $path) {
|
||||
# Only clean temp and old daemon logs
|
||||
$tempFiles = Get-ChildItem -Path $path -Recurse -Filter "*.lock" -ErrorAction SilentlyContinue
|
||||
if ($tempFiles) {
|
||||
$paths = $tempFiles | ForEach-Object { $_.FullName }
|
||||
Remove-SafeItems -Paths $paths -Description "Gradle lock files"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Maven repository (only clean temp files)
|
||||
$mavenRepoPath = "$env:USERPROFILE\.m2\repository"
|
||||
if (Test-Path $mavenRepoPath) {
|
||||
$tempFiles = Get-ChildItem -Path $mavenRepoPath -Recurse -Filter "*.lastUpdated" -ErrorAction SilentlyContinue
|
||||
if ($tempFiles) {
|
||||
$paths = $tempFiles | ForEach-Object { $_.FullName }
|
||||
Remove-SafeItems -Paths $paths -Description "Maven update markers"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Docker / Containers
|
||||
# ============================================================================
|
||||
|
||||
function Clear-DockerCaches {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Clean Docker build caches and unused data
|
||||
#>
|
||||
|
||||
if (-not (Get-Command docker -ErrorAction SilentlyContinue)) {
|
||||
return
|
||||
}
|
||||
|
||||
# Check if Docker daemon is running
|
||||
$dockerRunning = $false
|
||||
try {
|
||||
$null = docker info 2>&1
|
||||
$dockerRunning = $true
|
||||
}
|
||||
catch {
|
||||
Write-Debug "Docker daemon not running"
|
||||
}
|
||||
|
||||
if ($dockerRunning) {
|
||||
if (Test-DryRunMode) {
|
||||
Write-DryRun "Docker build cache"
|
||||
}
|
||||
else {
|
||||
try {
|
||||
$null = docker builder prune -af 2>&1
|
||||
Write-Success "Docker build cache"
|
||||
Set-SectionActivity
|
||||
}
|
||||
catch {
|
||||
Write-Debug "docker builder prune failed: $_"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Docker Desktop cache (Windows)
|
||||
$dockerDesktopCache = "$env:LOCALAPPDATA\Docker\wsl\data"
|
||||
# Note: Don't clean this - it's the WSL2 virtual disk
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Cloud CLI Tools
|
||||
# ============================================================================
|
||||
|
||||
function Clear-CloudCliCaches {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Clean cloud CLI tool caches (AWS, Azure, GCP)
|
||||
#>
|
||||
|
||||
# AWS CLI cache
|
||||
$awsCachePath = "$env:USERPROFILE\.aws\cli\cache"
|
||||
if (Test-Path $awsCachePath) {
|
||||
Clear-DirectoryContents -Path $awsCachePath -Description "AWS CLI cache"
|
||||
}
|
||||
|
||||
# Azure CLI logs
|
||||
$azureLogsPath = "$env:USERPROFILE\.azure\logs"
|
||||
if (Test-Path $azureLogsPath) {
|
||||
Clear-DirectoryContents -Path $azureLogsPath -Description "Azure CLI logs"
|
||||
}
|
||||
|
||||
# Google Cloud logs
|
||||
$gcloudLogsPath = "$env:APPDATA\gcloud\logs"
|
||||
if (Test-Path $gcloudLogsPath) {
|
||||
Clear-DirectoryContents -Path $gcloudLogsPath -Description "gcloud logs"
|
||||
}
|
||||
|
||||
# Kubernetes cache
|
||||
$kubeCachePath = "$env:USERPROFILE\.kube\cache"
|
||||
if (Test-Path $kubeCachePath) {
|
||||
Clear-DirectoryContents -Path $kubeCachePath -Description "Kubernetes cache"
|
||||
}
|
||||
|
||||
# Terraform plugin cache
|
||||
$terraformCachePath = "$env:APPDATA\terraform.d\plugin-cache"
|
||||
if (Test-Path $terraformCachePath) {
|
||||
Clear-DirectoryContents -Path $terraformCachePath -Description "Terraform plugin cache"
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# IDE Caches
|
||||
# ============================================================================
|
||||
|
||||
function Clear-IdeCaches {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Clean IDE caches (VS, VSCode, JetBrains, etc.)
|
||||
#>
|
||||
|
||||
# Visual Studio cache
|
||||
$vsCachePaths = @(
|
||||
"$env:LOCALAPPDATA\Microsoft\VisualStudio\*\ComponentModelCache"
|
||||
"$env:LOCALAPPDATA\Microsoft\VisualStudio\*\ImageCache"
|
||||
)
|
||||
foreach ($pattern in $vsCachePaths) {
|
||||
$paths = Resolve-Path $pattern -ErrorAction SilentlyContinue
|
||||
foreach ($path in $paths) {
|
||||
if (Test-Path $path.Path) {
|
||||
Clear-DirectoryContents -Path $path.Path -Description "Visual Studio cache"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# JetBrains IDEs caches
|
||||
$jetbrainsBasePaths = @(
|
||||
"$env:LOCALAPPDATA\JetBrains"
|
||||
"$env:APPDATA\JetBrains"
|
||||
)
|
||||
foreach ($basePath in $jetbrainsBasePaths) {
|
||||
if (Test-Path $basePath) {
|
||||
$ideFolders = Get-ChildItem -Path $basePath -Directory -ErrorAction SilentlyContinue
|
||||
foreach ($ideFolder in $ideFolders) {
|
||||
$cacheFolders = @("caches", "index", "tmp")
|
||||
foreach ($cacheFolder in $cacheFolders) {
|
||||
$cachePath = Join-Path $ideFolder.FullName $cacheFolder
|
||||
if (Test-Path $cachePath) {
|
||||
Clear-DirectoryContents -Path $cachePath -Description "$($ideFolder.Name) $cacheFolder"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Git Caches
|
||||
# ============================================================================
|
||||
|
||||
function Clear-GitCaches {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Clean Git temporary files and lock files
|
||||
#>
|
||||
|
||||
# Git config locks (stale)
|
||||
$gitConfigLock = "$env:USERPROFILE\.gitconfig.lock"
|
||||
if (Test-Path $gitConfigLock) {
|
||||
Remove-SafeItem -Path $gitConfigLock -Description "Git config lock"
|
||||
}
|
||||
|
||||
# GitHub CLI cache
|
||||
$ghCachePath = "$env:APPDATA\GitHub CLI"
|
||||
if (Test-Path $ghCachePath) {
|
||||
$cacheFiles = Get-ChildItem -Path $ghCachePath -Filter "*.json" -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-7) }
|
||||
if ($cacheFiles) {
|
||||
$paths = $cacheFiles | ForEach-Object { $_.FullName }
|
||||
Remove-SafeItems -Paths $paths -Description "GitHub CLI cache"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Main Developer Tools Cleanup Function
|
||||
# ============================================================================
|
||||
|
||||
function Invoke-DevToolsCleanup {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Run all developer tools cleanup tasks
|
||||
#>
|
||||
|
||||
Start-Section "Developer tools"
|
||||
|
||||
# JavaScript ecosystem
|
||||
Clear-NpmCache
|
||||
Clear-NodeBuildCaches
|
||||
|
||||
# Python ecosystem
|
||||
Clear-PythonCaches
|
||||
|
||||
# .NET ecosystem
|
||||
Clear-DotNetDevCaches
|
||||
|
||||
# Go ecosystem
|
||||
Clear-GoCaches
|
||||
|
||||
# Rust ecosystem
|
||||
Clear-RustCaches
|
||||
|
||||
# JVM ecosystem
|
||||
Clear-JvmCaches
|
||||
|
||||
# Containers
|
||||
Clear-DockerCaches
|
||||
|
||||
# Cloud CLI tools
|
||||
Clear-CloudCliCaches
|
||||
|
||||
# IDEs
|
||||
Clear-IdeCaches
|
||||
|
||||
# Git
|
||||
Clear-GitCaches
|
||||
|
||||
Stop-Section
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Exports
|
||||
# ============================================================================
|
||||
# Functions: Clear-NpmCache, Clear-PythonCaches, Clear-DockerCaches, etc.
|
||||
@@ -1,423 +0,0 @@
|
||||
# Mole - System Cleanup Module
|
||||
# Cleans Windows system files that require administrator access
|
||||
|
||||
#Requires -Version 5.1
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
# Prevent multiple sourcing
|
||||
if ((Get-Variable -Name 'MOLE_CLEAN_SYSTEM_LOADED' -Scope Script -ErrorAction SilentlyContinue) -and $script:MOLE_CLEAN_SYSTEM_LOADED) { return }
|
||||
$script:MOLE_CLEAN_SYSTEM_LOADED = $true
|
||||
|
||||
# Import dependencies
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
. "$scriptDir\..\core\base.ps1"
|
||||
. "$scriptDir\..\core\log.ps1"
|
||||
. "$scriptDir\..\core\file_ops.ps1"
|
||||
|
||||
# ============================================================================
|
||||
# System Temp Files
|
||||
# ============================================================================
|
||||
|
||||
function Clear-SystemTempFiles {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Clean system-level temporary files (requires admin)
|
||||
#>
|
||||
|
||||
if (-not (Test-IsAdmin)) {
|
||||
Write-Debug "Skipping system temp cleanup - requires admin"
|
||||
return
|
||||
}
|
||||
|
||||
# Windows Temp folder
|
||||
$winTemp = "$env:WINDIR\Temp"
|
||||
if (Test-Path $winTemp) {
|
||||
Remove-OldFiles -Path $winTemp -DaysOld 7 -Description "Windows temp files"
|
||||
}
|
||||
|
||||
# System temp (different from Windows temp)
|
||||
$systemTemp = "$env:SYSTEMROOT\Temp"
|
||||
if ((Test-Path $systemTemp) -and ($systemTemp -ne $winTemp)) {
|
||||
Remove-OldFiles -Path $systemTemp -DaysOld 7 -Description "System temp files"
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Windows Logs
|
||||
# ============================================================================
|
||||
|
||||
function Clear-WindowsLogs {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Clean Windows log files (requires admin)
|
||||
#>
|
||||
param([int]$DaysOld = 7)
|
||||
|
||||
if (-not (Test-IsAdmin)) {
|
||||
Write-Debug "Skipping Windows logs cleanup - requires admin"
|
||||
return
|
||||
}
|
||||
|
||||
# Windows Logs directory
|
||||
$logPaths = @(
|
||||
"$env:WINDIR\Logs\CBS"
|
||||
"$env:WINDIR\Logs\DISM"
|
||||
"$env:WINDIR\Logs\DPX"
|
||||
"$env:WINDIR\Logs\WindowsUpdate"
|
||||
"$env:WINDIR\Logs\SIH"
|
||||
"$env:WINDIR\Logs\waasmedia"
|
||||
"$env:WINDIR\Debug"
|
||||
"$env:WINDIR\Panther"
|
||||
"$env:PROGRAMDATA\Microsoft\Windows\WER\ReportQueue"
|
||||
"$env:PROGRAMDATA\Microsoft\Windows\WER\ReportArchive"
|
||||
)
|
||||
|
||||
foreach ($path in $logPaths) {
|
||||
if (Test-Path $path) {
|
||||
Remove-OldFiles -Path $path -DaysOld $DaysOld -Description "$(Split-Path -Leaf $path) logs"
|
||||
}
|
||||
}
|
||||
|
||||
# Setup logs (*.log files in Windows directory)
|
||||
$setupLogs = Get-ChildItem -Path "$env:WINDIR\*.log" -File -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-$DaysOld) }
|
||||
if ($setupLogs) {
|
||||
$paths = $setupLogs | ForEach-Object { $_.FullName }
|
||||
Remove-SafeItems -Paths $paths -Description "Windows setup logs"
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Windows Update Cleanup
|
||||
# ============================================================================
|
||||
|
||||
function Clear-WindowsUpdateFiles {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Clean Windows Update download cache (requires admin)
|
||||
#>
|
||||
|
||||
if (-not (Test-IsAdmin)) {
|
||||
Write-Debug "Skipping Windows Update cleanup - requires admin"
|
||||
return
|
||||
}
|
||||
|
||||
# Stop Windows Update service
|
||||
$wuService = Get-Service -Name wuauserv -ErrorAction SilentlyContinue
|
||||
$wasRunning = $wuService.Status -eq 'Running'
|
||||
|
||||
if ($wasRunning) {
|
||||
if (Test-DryRunMode) {
|
||||
Write-DryRun "Windows Update cache (service would be restarted)"
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
Stop-Service -Name wuauserv -Force -ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
Write-Debug "Could not stop Windows Update service: $_"
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
# Clean download cache
|
||||
$wuDownloadPath = "$env:WINDIR\SoftwareDistribution\Download"
|
||||
if (Test-Path $wuDownloadPath) {
|
||||
Clear-DirectoryContents -Path $wuDownloadPath -Description "Windows Update download cache"
|
||||
}
|
||||
|
||||
# Clean DataStore (old update history - be careful!)
|
||||
# Only clean temp files, not the actual database
|
||||
$wuDataStore = "$env:WINDIR\SoftwareDistribution\DataStore\Logs"
|
||||
if (Test-Path $wuDataStore) {
|
||||
Clear-DirectoryContents -Path $wuDataStore -Description "Windows Update logs"
|
||||
}
|
||||
}
|
||||
finally {
|
||||
# Always restart service if it was running, even if cleanup failed
|
||||
if ($wasRunning) {
|
||||
Start-Service -Name wuauserv -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Installer Cleanup
|
||||
# ============================================================================
|
||||
|
||||
function Clear-InstallerCache {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Clean Windows Installer cache (orphaned patches)
|
||||
#>
|
||||
|
||||
if (-not (Test-IsAdmin)) {
|
||||
return
|
||||
}
|
||||
|
||||
# Windows Installer patch cache
|
||||
# WARNING: Be very careful here - only clean truly orphaned files
|
||||
$installerPath = "$env:WINDIR\Installer"
|
||||
|
||||
# Only clean .tmp files and very old .msp files that are likely orphaned
|
||||
if (Test-Path $installerPath) {
|
||||
$tmpFiles = Get-ChildItem -Path $installerPath -Filter "*.tmp" -File -ErrorAction SilentlyContinue
|
||||
if ($tmpFiles) {
|
||||
$paths = $tmpFiles | ForEach-Object { $_.FullName }
|
||||
Remove-SafeItems -Paths $paths -Description "Installer temp files"
|
||||
}
|
||||
}
|
||||
|
||||
# Installer logs in temp
|
||||
$installerLogs = Get-ChildItem -Path $env:TEMP -Filter "MSI*.LOG" -File -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-30) }
|
||||
if ($installerLogs) {
|
||||
$paths = $installerLogs | ForEach-Object { $_.FullName }
|
||||
Remove-SafeItems -Paths $paths -Description "Old MSI logs"
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Component Store Cleanup
|
||||
# ============================================================================
|
||||
|
||||
function Invoke-ComponentStoreCleanup {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Run Windows Component Store cleanup (DISM)
|
||||
#>
|
||||
|
||||
if (-not (Test-IsAdmin)) {
|
||||
Write-Debug "Skipping component store cleanup - requires admin"
|
||||
return
|
||||
}
|
||||
|
||||
if (Test-DryRunMode) {
|
||||
Write-DryRun "Component Store cleanup (DISM)"
|
||||
Set-SectionActivity
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
Write-Info "Running Component Store cleanup (this may take a while)..."
|
||||
|
||||
# Run DISM cleanup
|
||||
$result = Start-Process -FilePath "dism.exe" `
|
||||
-ArgumentList "/Online", "/Cleanup-Image", "/StartComponentCleanup" `
|
||||
-Wait -PassThru -NoNewWindow -ErrorAction Stop
|
||||
|
||||
if ($result.ExitCode -eq 0) {
|
||||
Write-Success "Component Store cleanup"
|
||||
Set-SectionActivity
|
||||
}
|
||||
else {
|
||||
Write-Debug "DISM returned exit code: $($result.ExitCode)"
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Debug "Component Store cleanup failed: $_"
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Memory Dump Cleanup
|
||||
# ============================================================================
|
||||
|
||||
function Clear-MemoryDumps {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Clean Windows memory dumps
|
||||
#>
|
||||
|
||||
$dumpPaths = @(
|
||||
"$env:WINDIR\MEMORY.DMP"
|
||||
"$env:WINDIR\Minidump"
|
||||
"$env:LOCALAPPDATA\CrashDumps"
|
||||
)
|
||||
|
||||
foreach ($path in $dumpPaths) {
|
||||
if (Test-Path $path -PathType Leaf) {
|
||||
# Single file (MEMORY.DMP)
|
||||
Remove-SafeItem -Path $path -Description "Memory dump"
|
||||
}
|
||||
elseif (Test-Path $path -PathType Container) {
|
||||
# Directory (Minidump, CrashDumps)
|
||||
Clear-DirectoryContents -Path $path -Description "$(Split-Path -Leaf $path)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Font Cache
|
||||
# ============================================================================
|
||||
|
||||
function Clear-SystemFontCache {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Clear Windows font cache (requires admin and may need restart)
|
||||
#>
|
||||
|
||||
if (-not (Test-IsAdmin)) {
|
||||
return
|
||||
}
|
||||
|
||||
$fontCacheService = Get-Service -Name "FontCache" -ErrorAction SilentlyContinue
|
||||
|
||||
if ($fontCacheService) {
|
||||
if (Test-DryRunMode) {
|
||||
Write-DryRun "System font cache"
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
# Stop font cache service
|
||||
Stop-Service -Name "FontCache" -Force -ErrorAction SilentlyContinue
|
||||
|
||||
# Clear font cache files
|
||||
$fontCachePath = "$env:WINDIR\ServiceProfiles\LocalService\AppData\Local\FontCache"
|
||||
if (Test-Path $fontCachePath) {
|
||||
Clear-DirectoryContents -Path $fontCachePath -Description "System font cache"
|
||||
}
|
||||
|
||||
# Restart font cache service
|
||||
Start-Service -Name "FontCache" -ErrorAction SilentlyContinue
|
||||
}
|
||||
catch {
|
||||
Write-Debug "Font cache cleanup failed: $_"
|
||||
Start-Service -Name "FontCache" -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Disk Cleanup Tool Integration
|
||||
# ============================================================================
|
||||
|
||||
function Invoke-DiskCleanupTool {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Run Windows built-in Disk Cleanup tool with predefined settings
|
||||
#>
|
||||
param([switch]$Full)
|
||||
|
||||
if (-not (Test-IsAdmin)) {
|
||||
Write-Debug "Skipping Disk Cleanup tool - requires admin for full cleanup"
|
||||
}
|
||||
|
||||
if (Test-DryRunMode) {
|
||||
Write-DryRun "Windows Disk Cleanup tool"
|
||||
return
|
||||
}
|
||||
|
||||
# Set up registry keys for automated cleanup
|
||||
$cleanupKey = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\VolumeCaches"
|
||||
|
||||
$cleanupItems = @(
|
||||
"Active Setup Temp Folders"
|
||||
"Downloaded Program Files"
|
||||
"Internet Cache Files"
|
||||
"Old ChkDsk Files"
|
||||
"Recycle Bin"
|
||||
"Setup Log Files"
|
||||
"System error memory dump files"
|
||||
"System error minidump files"
|
||||
"Temporary Files"
|
||||
"Temporary Setup Files"
|
||||
"Thumbnail Cache"
|
||||
"Windows Error Reporting Archive Files"
|
||||
"Windows Error Reporting Queue Files"
|
||||
"Windows Error Reporting System Archive Files"
|
||||
"Windows Error Reporting System Queue Files"
|
||||
)
|
||||
|
||||
if ($Full -and (Test-IsAdmin)) {
|
||||
$cleanupItems += @(
|
||||
"Previous Installations"
|
||||
"Temporary Windows installation files"
|
||||
"Update Cleanup"
|
||||
"Windows Defender"
|
||||
"Windows Upgrade Log Files"
|
||||
)
|
||||
}
|
||||
|
||||
# Enable cleanup items in registry
|
||||
foreach ($item in $cleanupItems) {
|
||||
$itemPath = Join-Path $cleanupKey $item
|
||||
if (Test-Path $itemPath) {
|
||||
Set-ItemProperty -Path $itemPath -Name "StateFlags0100" -Value 2 -Type DWord -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
# Run disk cleanup
|
||||
$process = Start-Process -FilePath "cleanmgr.exe" `
|
||||
-ArgumentList "/sagerun:100" `
|
||||
-Wait -PassThru -NoNewWindow -ErrorAction Stop
|
||||
|
||||
if ($process.ExitCode -eq 0) {
|
||||
Write-Success "Windows Disk Cleanup"
|
||||
Set-SectionActivity
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Debug "Disk Cleanup failed: $_"
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Main System Cleanup Function
|
||||
# ============================================================================
|
||||
|
||||
function Invoke-SystemCleanup {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Run all system-level cleanup tasks (requires admin for full effect)
|
||||
#>
|
||||
param(
|
||||
[switch]$IncludeComponentStore,
|
||||
[switch]$IncludeDiskCleanup
|
||||
)
|
||||
|
||||
Start-Section "System cleanup"
|
||||
|
||||
if (-not (Test-IsAdmin)) {
|
||||
Write-MoleWarning "Running without admin - some cleanup tasks will be skipped"
|
||||
}
|
||||
|
||||
# System temp files
|
||||
Clear-SystemTempFiles
|
||||
|
||||
# Windows logs
|
||||
Clear-WindowsLogs -DaysOld 7
|
||||
|
||||
# Windows Update cache
|
||||
Clear-WindowsUpdateFiles
|
||||
|
||||
# Installer cache
|
||||
Clear-InstallerCache
|
||||
|
||||
# Memory dumps
|
||||
Clear-MemoryDumps
|
||||
|
||||
# Font cache
|
||||
Clear-SystemFontCache
|
||||
|
||||
# Optional: Component Store (can take a long time)
|
||||
if ($IncludeComponentStore) {
|
||||
Invoke-ComponentStoreCleanup
|
||||
}
|
||||
|
||||
# Optional: Windows Disk Cleanup tool
|
||||
if ($IncludeDiskCleanup) {
|
||||
Invoke-DiskCleanupTool -Full
|
||||
}
|
||||
|
||||
Stop-Section
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Exports
|
||||
# ============================================================================
|
||||
# Functions: Clear-SystemTempFiles, Clear-WindowsLogs, Invoke-SystemCleanup, etc.
|
||||
@@ -1,352 +0,0 @@
|
||||
# Mole - User Cleanup Module
|
||||
# Cleans user-level temporary files, caches, and downloads
|
||||
|
||||
#Requires -Version 5.1
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
# Prevent multiple sourcing
|
||||
if ((Get-Variable -Name 'MOLE_CLEAN_USER_LOADED' -Scope Script -ErrorAction SilentlyContinue) -and $script:MOLE_CLEAN_USER_LOADED) { return }
|
||||
$script:MOLE_CLEAN_USER_LOADED = $true
|
||||
|
||||
# Import dependencies
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
. "$scriptDir\..\core\base.ps1"
|
||||
. "$scriptDir\..\core\log.ps1"
|
||||
. "$scriptDir\..\core\file_ops.ps1"
|
||||
|
||||
# ============================================================================
|
||||
# Windows Temp Files Cleanup
|
||||
# ============================================================================
|
||||
|
||||
function Clear-UserTempFiles {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Clean user temporary files
|
||||
#>
|
||||
param([int]$DaysOld = 7)
|
||||
|
||||
Start-Section "User temp files"
|
||||
|
||||
# User temp directory
|
||||
$userTemp = $env:TEMP
|
||||
if (Test-Path $userTemp) {
|
||||
Remove-OldFiles -Path $userTemp -DaysOld $DaysOld -Description "User temp files"
|
||||
}
|
||||
|
||||
# Windows Temp (if accessible)
|
||||
$winTemp = "$env:WINDIR\Temp"
|
||||
if ((Test-Path $winTemp) -and (Test-IsAdmin)) {
|
||||
Remove-OldFiles -Path $winTemp -DaysOld $DaysOld -Description "Windows temp files"
|
||||
}
|
||||
|
||||
Stop-Section
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Downloads Folder Cleanup
|
||||
# ============================================================================
|
||||
|
||||
function Clear-OldDownloads {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Clean old files from Downloads folder (with user confirmation pattern)
|
||||
#>
|
||||
param([int]$DaysOld = 30)
|
||||
|
||||
$downloadsPath = [Environment]::GetFolderPath('UserProfile') + '\Downloads'
|
||||
|
||||
if (-not (Test-Path $downloadsPath)) {
|
||||
return
|
||||
}
|
||||
|
||||
# Find old installers and archives
|
||||
$patterns = @('*.exe', '*.msi', '*.zip', '*.7z', '*.rar', '*.tar.gz', '*.iso')
|
||||
$cutoffDate = (Get-Date).AddDays(-$DaysOld)
|
||||
|
||||
$oldFiles = @()
|
||||
foreach ($pattern in $patterns) {
|
||||
$files = Get-ChildItem -Path $downloadsPath -Filter $pattern -File -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.LastWriteTime -lt $cutoffDate }
|
||||
if ($files) {
|
||||
$oldFiles += $files
|
||||
}
|
||||
}
|
||||
|
||||
if ($oldFiles.Count -gt 0) {
|
||||
$paths = $oldFiles | ForEach-Object { $_.FullName }
|
||||
Remove-SafeItems -Paths $paths -Description "Old downloads (>${DaysOld}d)"
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Recycle Bin Cleanup
|
||||
# ============================================================================
|
||||
|
||||
function Clear-RecycleBin {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Empty the Recycle Bin
|
||||
#>
|
||||
|
||||
if (Test-DryRunMode) {
|
||||
Write-DryRun "Recycle Bin (would empty)"
|
||||
Set-SectionActivity
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
# Use Shell.Application COM object
|
||||
$shell = New-Object -ComObject Shell.Application
|
||||
$recycleBin = $shell.Namespace(0xA) # Recycle Bin
|
||||
$items = $recycleBin.Items()
|
||||
|
||||
if ($items.Count -gt 0) {
|
||||
# Calculate size
|
||||
$totalSize = 0
|
||||
foreach ($item in $items) {
|
||||
$totalSize += $item.Size
|
||||
}
|
||||
|
||||
# Clear using Clear-RecycleBin cmdlet (Windows 10+)
|
||||
Clear-RecycleBin -Force -ErrorAction SilentlyContinue
|
||||
|
||||
$sizeHuman = Format-ByteSize -Bytes $totalSize
|
||||
Write-Success "Recycle Bin $($script:Colors.Green)($sizeHuman)$($script:Colors.NC)"
|
||||
Set-SectionActivity
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Debug "Could not clear Recycle Bin: $_"
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Recent Files Cleanup
|
||||
# ============================================================================
|
||||
|
||||
function Clear-RecentFiles {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Clean old recent file shortcuts
|
||||
#>
|
||||
param([int]$DaysOld = 30)
|
||||
|
||||
$recentPath = "$env:APPDATA\Microsoft\Windows\Recent"
|
||||
|
||||
if (Test-Path $recentPath) {
|
||||
Remove-OldFiles -Path $recentPath -DaysOld $DaysOld -Filter "*.lnk" -Description "Old recent shortcuts"
|
||||
}
|
||||
|
||||
# AutomaticDestinations (jump lists)
|
||||
$autoDestPath = "$recentPath\AutomaticDestinations"
|
||||
if (Test-Path $autoDestPath) {
|
||||
Remove-OldFiles -Path $autoDestPath -DaysOld $DaysOld -Description "Old jump list entries"
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Thumbnail Cache Cleanup
|
||||
# ============================================================================
|
||||
|
||||
function Clear-ThumbnailCache {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Clean Windows thumbnail cache
|
||||
#>
|
||||
|
||||
$thumbCachePath = "$env:LOCALAPPDATA\Microsoft\Windows\Explorer"
|
||||
|
||||
if (-not (Test-Path $thumbCachePath)) {
|
||||
return
|
||||
}
|
||||
|
||||
# Thumbnail cache files (thumbcache_*.db)
|
||||
$thumbFiles = Get-ChildItem -Path $thumbCachePath -Filter "thumbcache_*.db" -File -ErrorAction SilentlyContinue
|
||||
|
||||
if ($thumbFiles) {
|
||||
$paths = $thumbFiles | ForEach-Object { $_.FullName }
|
||||
Remove-SafeItems -Paths $paths -Description "Thumbnail cache"
|
||||
}
|
||||
|
||||
# Icon cache
|
||||
$iconCache = "$env:LOCALAPPDATA\IconCache.db"
|
||||
if (Test-Path $iconCache) {
|
||||
Remove-SafeItem -Path $iconCache -Description "Icon cache"
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Windows Error Reports Cleanup
|
||||
# ============================================================================
|
||||
|
||||
function Clear-ErrorReports {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Clean Windows Error Reporting files
|
||||
#>
|
||||
param([int]$DaysOld = 7)
|
||||
|
||||
$werPaths = @(
|
||||
"$env:LOCALAPPDATA\Microsoft\Windows\WER"
|
||||
"$env:LOCALAPPDATA\CrashDumps"
|
||||
"$env:USERPROFILE\AppData\Local\Microsoft\Windows\WER"
|
||||
)
|
||||
|
||||
foreach ($path in $werPaths) {
|
||||
if (Test-Path $path) {
|
||||
$items = Get-ChildItem -Path $path -Recurse -Force -ErrorAction SilentlyContinue
|
||||
if ($items) {
|
||||
$paths = $items | ForEach-Object { $_.FullName }
|
||||
Remove-SafeItems -Paths $paths -Description "Error reports"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Memory dumps
|
||||
$dumpPaths = @(
|
||||
"$env:LOCALAPPDATA\CrashDumps"
|
||||
"$env:USERPROFILE\*.dmp"
|
||||
)
|
||||
|
||||
foreach ($path in $dumpPaths) {
|
||||
$dumps = Get-ChildItem -Path $path -Filter "*.dmp" -ErrorAction SilentlyContinue
|
||||
if ($dumps) {
|
||||
$paths = $dumps | ForEach-Object { $_.FullName }
|
||||
Remove-SafeItems -Paths $paths -Description "Memory dumps"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Windows Prefetch Cleanup (requires admin)
|
||||
# ============================================================================
|
||||
|
||||
function Clear-Prefetch {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Clean Windows Prefetch files (requires admin)
|
||||
#>
|
||||
param([int]$DaysOld = 14)
|
||||
|
||||
if (-not (Test-IsAdmin)) {
|
||||
Write-Debug "Skipping Prefetch cleanup - requires admin"
|
||||
return
|
||||
}
|
||||
|
||||
$prefetchPath = "$env:WINDIR\Prefetch"
|
||||
|
||||
if (Test-Path $prefetchPath) {
|
||||
Remove-OldFiles -Path $prefetchPath -DaysOld $DaysOld -Description "Prefetch files"
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Log Files Cleanup
|
||||
# ============================================================================
|
||||
|
||||
function Clear-UserLogs {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Clean old log files from common locations
|
||||
#>
|
||||
param([int]$DaysOld = 7)
|
||||
|
||||
$logLocations = @(
|
||||
"$env:LOCALAPPDATA\Temp\*.log"
|
||||
"$env:APPDATA\*.log"
|
||||
"$env:USERPROFILE\*.log"
|
||||
)
|
||||
|
||||
foreach ($location in $logLocations) {
|
||||
$parent = Split-Path -Parent $location
|
||||
$filter = Split-Path -Leaf $location
|
||||
|
||||
if (Test-Path $parent) {
|
||||
$logs = Get-ChildItem -Path $parent -Filter $filter -File -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-$DaysOld) }
|
||||
|
||||
if ($logs) {
|
||||
$paths = $logs | ForEach-Object { $_.FullName }
|
||||
Remove-SafeItems -Paths $paths -Description "Old log files"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Clipboard History Cleanup
|
||||
# ============================================================================
|
||||
|
||||
function Clear-ClipboardHistory {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Clear Windows clipboard history
|
||||
#>
|
||||
|
||||
if (Test-DryRunMode) {
|
||||
Write-DryRun "Clipboard history (would clear)"
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
# Load Windows Forms assembly for clipboard access
|
||||
Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue
|
||||
|
||||
# Clear current clipboard
|
||||
[System.Windows.Forms.Clipboard]::Clear()
|
||||
|
||||
# Clear clipboard history (Windows 10 1809+)
|
||||
$clipboardPath = "$env:LOCALAPPDATA\Microsoft\Windows\Clipboard"
|
||||
if (Test-Path $clipboardPath) {
|
||||
Clear-DirectoryContents -Path $clipboardPath -Description "Clipboard history"
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Debug "Could not clear clipboard: $_"
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Main User Cleanup Function
|
||||
# ============================================================================
|
||||
|
||||
function Invoke-UserCleanup {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Run all user-level cleanup tasks
|
||||
#>
|
||||
param(
|
||||
[int]$TempDaysOld = 7,
|
||||
[int]$DownloadsDaysOld = 30,
|
||||
[int]$LogDaysOld = 7,
|
||||
[switch]$IncludeDownloads,
|
||||
[switch]$IncludeRecycleBin
|
||||
)
|
||||
|
||||
Start-Section "User essentials"
|
||||
|
||||
# Always clean these
|
||||
Clear-UserTempFiles -DaysOld $TempDaysOld
|
||||
Clear-RecentFiles -DaysOld 30
|
||||
Clear-ThumbnailCache
|
||||
Clear-ErrorReports -DaysOld 7
|
||||
Clear-UserLogs -DaysOld $LogDaysOld
|
||||
Clear-Prefetch -DaysOld 14
|
||||
|
||||
# Optional: Downloads cleanup
|
||||
if ($IncludeDownloads) {
|
||||
Clear-OldDownloads -DaysOld $DownloadsDaysOld
|
||||
}
|
||||
|
||||
# Optional: Recycle Bin
|
||||
if ($IncludeRecycleBin) {
|
||||
Clear-RecycleBin
|
||||
}
|
||||
|
||||
Stop-Section
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Exports
|
||||
# ============================================================================
|
||||
# Functions: Clear-UserTempFiles, Clear-OldDownloads, Clear-RecycleBin, etc.
|
||||
@@ -1,396 +0,0 @@
|
||||
# Mole - Base Definitions and Utilities
|
||||
# Core definitions, constants, and basic utility functions used by all modules
|
||||
|
||||
#Requires -Version 5.1
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# Prevent multiple sourcing
|
||||
if ((Get-Variable -Name 'MOLE_BASE_LOADED' -Scope Script -ErrorAction SilentlyContinue) -and $script:MOLE_BASE_LOADED) { return }
|
||||
$script:MOLE_BASE_LOADED = $true
|
||||
|
||||
# ============================================================================
|
||||
# Color Definitions (ANSI escape codes for modern terminals)
|
||||
# ============================================================================
|
||||
$script:ESC = [char]27
|
||||
$script:Colors = @{
|
||||
Green = "$ESC[0;32m"
|
||||
Blue = "$ESC[0;34m"
|
||||
Cyan = "$ESC[0;36m"
|
||||
Yellow = "$ESC[0;33m"
|
||||
Purple = "$ESC[0;35m"
|
||||
PurpleBold = "$ESC[1;35m"
|
||||
Red = "$ESC[0;31m"
|
||||
Gray = "$ESC[0;90m"
|
||||
White = "$ESC[0;37m"
|
||||
NC = "$ESC[0m" # No Color / Reset
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Icon Definitions
|
||||
# ============================================================================
|
||||
$script:Icons = @{
|
||||
Confirm = [char]0x25CE # ◎
|
||||
Admin = [char]0x2699 # ⚙
|
||||
Success = [char]0x2713 # ✓
|
||||
Error = [char]0x263B # ☻
|
||||
Warning = [char]0x25CF # ●
|
||||
Empty = [char]0x25CB # ○
|
||||
Solid = [char]0x25CF # ●
|
||||
List = [char]0x2022 # •
|
||||
Arrow = [char]0x27A4 # ➤
|
||||
DryRun = [char]0x2192 # →
|
||||
NavUp = [char]0x2191 # ↑
|
||||
NavDown = [char]0x2193 # ↓
|
||||
Folder = [char]0x25A0 # ■ (folder substitute)
|
||||
File = [char]0x25A1 # □ (file substitute)
|
||||
Trash = [char]0x2718 # ✘ (trash substitute)
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Global Configuration Constants
|
||||
# ============================================================================
|
||||
$script:Config = @{
|
||||
TempFileAgeDays = 7 # Temp file retention (days)
|
||||
OrphanAgeDays = 60 # Orphaned data retention (days)
|
||||
MaxParallelJobs = 15 # Parallel job limit
|
||||
LogAgeDays = 7 # Log retention (days)
|
||||
CrashReportAgeDays = 7 # Crash report retention (days)
|
||||
MaxIterations = 100 # Max iterations for scans
|
||||
ConfigPath = "$env:USERPROFILE\.config\mole"
|
||||
CachePath = "$env:USERPROFILE\.cache\mole"
|
||||
WhitelistFile = "$env:USERPROFILE\.config\mole\whitelist.txt"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Default Whitelist Patterns (paths to never clean)
|
||||
# ============================================================================
|
||||
$script:DefaultWhitelistPatterns = @(
|
||||
"$env:LOCALAPPDATA\Microsoft\Windows\Explorer" # Windows Explorer cache
|
||||
"$env:LOCALAPPDATA\Microsoft\Windows\Fonts" # User fonts
|
||||
"$env:APPDATA\Microsoft\Windows\Recent" # Recent files (used by shell)
|
||||
"$env:LOCALAPPDATA\Packages\*" # UWP app data
|
||||
"$env:USERPROFILE\.vscode\extensions" # VS Code extensions
|
||||
"$env:USERPROFILE\.nuget" # NuGet packages
|
||||
"$env:USERPROFILE\.cargo" # Rust packages
|
||||
"$env:USERPROFILE\.rustup" # Rust toolchain
|
||||
"$env:USERPROFILE\.m2\repository" # Maven repository
|
||||
"$env:USERPROFILE\.gradle\caches\modules-2\files-*" # Gradle modules
|
||||
"$env:USERPROFILE\.ollama\models" # Ollama AI models
|
||||
"$env:LOCALAPPDATA\JetBrains" # JetBrains IDEs
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
# Protected System Paths (NEVER touch these)
|
||||
# ============================================================================
|
||||
$script:ProtectedPaths = @(
|
||||
"C:\Windows"
|
||||
"C:\Windows\System32"
|
||||
"C:\Windows\SysWOW64"
|
||||
"C:\Program Files"
|
||||
"C:\Program Files (x86)"
|
||||
"C:\Program Files\Windows Defender"
|
||||
"C:\Program Files (x86)\Windows Defender"
|
||||
"C:\ProgramData\Microsoft\Windows Defender"
|
||||
"$env:SYSTEMROOT"
|
||||
"$env:WINDIR"
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
# System Utilities
|
||||
# ============================================================================
|
||||
|
||||
function Test-IsAdmin {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Check if running with administrator privileges
|
||||
#>
|
||||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
|
||||
return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
}
|
||||
|
||||
function Get-FreeSpace {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Get free disk space on system drive
|
||||
.OUTPUTS
|
||||
Human-readable string (e.g., "100GB")
|
||||
#>
|
||||
param([string]$Drive = $env:SystemDrive)
|
||||
|
||||
$disk = Get-WmiObject Win32_LogicalDisk -Filter "DeviceID='$Drive'" -ErrorAction SilentlyContinue
|
||||
if ($disk) {
|
||||
return Format-ByteSize -Bytes $disk.FreeSpace
|
||||
}
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
function Get-WindowsVersion {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Get Windows version information
|
||||
#>
|
||||
$os = Get-WmiObject Win32_OperatingSystem
|
||||
return @{
|
||||
Name = $os.Caption
|
||||
Version = $os.Version
|
||||
Build = $os.BuildNumber
|
||||
Arch = $os.OSArchitecture
|
||||
}
|
||||
}
|
||||
|
||||
function Get-CPUCores {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Get number of CPU cores
|
||||
#>
|
||||
return (Get-WmiObject Win32_Processor).NumberOfLogicalProcessors
|
||||
}
|
||||
|
||||
function Get-OptimalParallelJobs {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Get optimal number of parallel jobs based on CPU cores
|
||||
#>
|
||||
param(
|
||||
[ValidateSet('scan', 'io', 'compute', 'default')]
|
||||
[string]$OperationType = 'default'
|
||||
)
|
||||
|
||||
$cores = Get-CPUCores
|
||||
switch ($OperationType) {
|
||||
'scan' { return [Math]::Min($cores * 2, 32) }
|
||||
'io' { return [Math]::Min($cores * 2, 32) }
|
||||
'compute' { return $cores }
|
||||
default { return [Math]::Min($cores + 2, 20) }
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Path Utilities
|
||||
# ============================================================================
|
||||
|
||||
function Test-ProtectedPath {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Check if a path is protected and should never be modified
|
||||
#>
|
||||
param([string]$Path)
|
||||
|
||||
$normalizedPath = [System.IO.Path]::GetFullPath($Path).TrimEnd('\')
|
||||
|
||||
foreach ($protected in $script:ProtectedPaths) {
|
||||
$normalizedProtected = [System.IO.Path]::GetFullPath($protected).TrimEnd('\')
|
||||
if ($normalizedPath -eq $normalizedProtected -or
|
||||
$normalizedPath.StartsWith("$normalizedProtected\", [StringComparison]::OrdinalIgnoreCase)) {
|
||||
return $true
|
||||
}
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
function Test-Whitelisted {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Check if path matches a whitelist pattern
|
||||
#>
|
||||
param([string]$Path)
|
||||
|
||||
# Check default patterns
|
||||
foreach ($pattern in $script:DefaultWhitelistPatterns) {
|
||||
$expandedPattern = [Environment]::ExpandEnvironmentVariables($pattern)
|
||||
if ($Path -like $expandedPattern) {
|
||||
return $true
|
||||
}
|
||||
}
|
||||
|
||||
# Check user whitelist file
|
||||
if (Test-Path $script:Config.WhitelistFile) {
|
||||
$userPatterns = Get-Content $script:Config.WhitelistFile -ErrorAction SilentlyContinue
|
||||
foreach ($pattern in $userPatterns) {
|
||||
$pattern = $pattern.Trim()
|
||||
if ($pattern -and -not $pattern.StartsWith('#')) {
|
||||
if ($Path -like $pattern) {
|
||||
return $true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $false
|
||||
}
|
||||
|
||||
function Resolve-SafePath {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Resolve and validate a path for safe operations
|
||||
#>
|
||||
param([string]$Path)
|
||||
|
||||
try {
|
||||
$resolved = [System.IO.Path]::GetFullPath($Path)
|
||||
return $resolved
|
||||
}
|
||||
catch {
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Formatting Utilities
|
||||
# ============================================================================
|
||||
|
||||
function Format-ByteSize {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Convert bytes to human-readable format
|
||||
#>
|
||||
param([long]$Bytes)
|
||||
|
||||
if ($Bytes -ge 1TB) {
|
||||
return "{0:N2}TB" -f ($Bytes / 1TB)
|
||||
}
|
||||
elseif ($Bytes -ge 1GB) {
|
||||
return "{0:N2}GB" -f ($Bytes / 1GB)
|
||||
}
|
||||
elseif ($Bytes -ge 1MB) {
|
||||
return "{0:N1}MB" -f ($Bytes / 1MB)
|
||||
}
|
||||
elseif ($Bytes -ge 1KB) {
|
||||
return "{0:N0}KB" -f ($Bytes / 1KB)
|
||||
}
|
||||
else {
|
||||
return "{0}B" -f $Bytes
|
||||
}
|
||||
}
|
||||
|
||||
function Format-Number {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Format a number with thousands separators
|
||||
#>
|
||||
param([long]$Number)
|
||||
return $Number.ToString("N0")
|
||||
}
|
||||
|
||||
function Format-TimeSpan {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Format a timespan to human-readable string
|
||||
#>
|
||||
param([TimeSpan]$Duration)
|
||||
|
||||
if ($Duration.TotalHours -ge 1) {
|
||||
return "{0:N1}h" -f $Duration.TotalHours
|
||||
}
|
||||
elseif ($Duration.TotalMinutes -ge 1) {
|
||||
return "{0:N0}m" -f $Duration.TotalMinutes
|
||||
}
|
||||
else {
|
||||
return "{0:N0}s" -f $Duration.TotalSeconds
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Environment Detection
|
||||
# ============================================================================
|
||||
|
||||
function Get-UserHome {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Get the current user's home directory
|
||||
#>
|
||||
return $env:USERPROFILE
|
||||
}
|
||||
|
||||
function Get-TempPath {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Get the system temp path
|
||||
#>
|
||||
return [System.IO.Path]::GetTempPath()
|
||||
}
|
||||
|
||||
function Get-ConfigPath {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Get Mole config directory, creating it if needed
|
||||
#>
|
||||
$path = $script:Config.ConfigPath
|
||||
if (-not (Test-Path $path)) {
|
||||
New-Item -ItemType Directory -Path $path -Force | Out-Null
|
||||
}
|
||||
return $path
|
||||
}
|
||||
|
||||
function Get-CachePath {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Get Mole cache directory, creating it if needed
|
||||
#>
|
||||
$path = $script:Config.CachePath
|
||||
if (-not (Test-Path $path)) {
|
||||
New-Item -ItemType Directory -Path $path -Force | Out-Null
|
||||
}
|
||||
return $path
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Temporary File Management
|
||||
# ============================================================================
|
||||
|
||||
$script:TempFiles = [System.Collections.ArrayList]::new()
|
||||
$script:TempDirs = [System.Collections.ArrayList]::new()
|
||||
|
||||
function New-TempFile {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Create a tracked temporary file
|
||||
#>
|
||||
param([string]$Prefix = "winmole")
|
||||
|
||||
$tempPath = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), "$Prefix-$([Guid]::NewGuid().ToString('N').Substring(0,8)).tmp")
|
||||
New-Item -ItemType File -Path $tempPath -Force | Out-Null
|
||||
[void]$script:TempFiles.Add($tempPath)
|
||||
return $tempPath
|
||||
}
|
||||
|
||||
function New-TempDirectory {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Create a tracked temporary directory
|
||||
#>
|
||||
param([string]$Prefix = "winmole")
|
||||
|
||||
$tempPath = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), "$Prefix-$([Guid]::NewGuid().ToString('N').Substring(0,8))")
|
||||
New-Item -ItemType Directory -Path $tempPath -Force | Out-Null
|
||||
[void]$script:TempDirs.Add($tempPath)
|
||||
return $tempPath
|
||||
}
|
||||
|
||||
function Clear-TempFiles {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Clean up all tracked temporary files and directories
|
||||
#>
|
||||
foreach ($file in $script:TempFiles) {
|
||||
if (Test-Path $file) {
|
||||
Remove-Item $file -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
$script:TempFiles.Clear()
|
||||
|
||||
foreach ($dir in $script:TempDirs) {
|
||||
if (Test-Path $dir) {
|
||||
Remove-Item $dir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
$script:TempDirs.Clear()
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Exports (functions and variables are available via dot-sourcing)
|
||||
# ============================================================================
|
||||
# Variables: Colors, Icons, Config, ProtectedPaths, DefaultWhitelistPatterns
|
||||
# Functions: Test-IsAdmin, Get-FreeSpace, Get-WindowsVersion, etc.
|
||||
@@ -1,130 +0,0 @@
|
||||
# Mole - Common Functions Library
|
||||
# Main entry point that loads all core modules
|
||||
|
||||
#Requires -Version 5.1
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# Prevent multiple sourcing
|
||||
if ((Get-Variable -Name 'MOLE_COMMON_LOADED' -Scope Script -ErrorAction SilentlyContinue) -and $script:MOLE_COMMON_LOADED) {
|
||||
return
|
||||
}
|
||||
$script:MOLE_COMMON_LOADED = $true
|
||||
|
||||
# Get the directory containing this script
|
||||
$script:MOLE_CORE_DIR = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$script:MOLE_LIB_DIR = Split-Path -Parent $script:MOLE_CORE_DIR
|
||||
$script:MOLE_ROOT_DIR = Split-Path -Parent $script:MOLE_LIB_DIR
|
||||
|
||||
# ============================================================================
|
||||
# Load Core Modules
|
||||
# ============================================================================
|
||||
|
||||
# Base definitions (colors, icons, constants)
|
||||
. "$script:MOLE_CORE_DIR\base.ps1"
|
||||
|
||||
# Logging functions
|
||||
. "$script:MOLE_CORE_DIR\log.ps1"
|
||||
|
||||
# Safe file operations
|
||||
. "$script:MOLE_CORE_DIR\file_ops.ps1"
|
||||
|
||||
# UI components
|
||||
. "$script:MOLE_CORE_DIR\ui.ps1"
|
||||
|
||||
# ============================================================================
|
||||
# Version Information
|
||||
# ============================================================================
|
||||
|
||||
$script:MOLE_VERSION = "1.0.0"
|
||||
$script:MOLE_BUILD_DATE = "2026-01-07"
|
||||
|
||||
function Get-MoleVersion {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Get Mole version information
|
||||
#>
|
||||
return @{
|
||||
Version = $script:MOLE_VERSION
|
||||
BuildDate = $script:MOLE_BUILD_DATE
|
||||
PowerShell = $PSVersionTable.PSVersion.ToString()
|
||||
Windows = (Get-WindowsVersion).Version
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Initialization
|
||||
# ============================================================================
|
||||
|
||||
function Initialize-Mole {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Initialize Mole environment
|
||||
#>
|
||||
|
||||
# Ensure config directory exists
|
||||
$configPath = Get-ConfigPath
|
||||
|
||||
# Ensure cache directory exists
|
||||
$cachePath = Get-CachePath
|
||||
|
||||
# Set up cleanup trap
|
||||
$null = Register-EngineEvent -SourceIdentifier PowerShell.Exiting -Action {
|
||||
Clear-TempFiles
|
||||
}
|
||||
|
||||
Write-Debug "Mole initialized"
|
||||
Write-Debug "Config: $configPath"
|
||||
Write-Debug "Cache: $cachePath"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Admin Elevation
|
||||
# ============================================================================
|
||||
|
||||
function Request-AdminPrivileges {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Request admin privileges if not already running as admin
|
||||
.DESCRIPTION
|
||||
Restarts the script with elevated privileges using UAC
|
||||
#>
|
||||
if (-not (Test-IsAdmin)) {
|
||||
Write-MoleWarning "Some operations require administrator privileges."
|
||||
|
||||
if (Read-Confirmation -Prompt "Restart with admin privileges?" -Default $true) {
|
||||
$scriptPath = $MyInvocation.PSCommandPath
|
||||
if ($scriptPath) {
|
||||
Start-Process powershell.exe -ArgumentList "-ExecutionPolicy Bypass -File `"$scriptPath`"" -Verb RunAs
|
||||
exit
|
||||
}
|
||||
}
|
||||
return $false
|
||||
}
|
||||
return $true
|
||||
}
|
||||
|
||||
function Invoke-AsAdmin {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Run a script block with admin privileges
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[scriptblock]$ScriptBlock
|
||||
)
|
||||
|
||||
if (Test-IsAdmin) {
|
||||
& $ScriptBlock
|
||||
}
|
||||
else {
|
||||
$command = $ScriptBlock.ToString()
|
||||
Start-Process powershell.exe -ArgumentList "-ExecutionPolicy Bypass -Command `"$command`"" -Verb RunAs -Wait
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Exports (functions are available via dot-sourcing)
|
||||
# ============================================================================
|
||||
# All functions from base.ps1, log.ps1, file_ops.ps1, and ui.ps1 are
|
||||
# automatically available when this file is dot-sourced.
|
||||
@@ -1,439 +0,0 @@
|
||||
# Mole - Safe File Operations Module
|
||||
# Provides safe file deletion and manipulation functions with protection checks
|
||||
|
||||
#Requires -Version 5.1
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
# Prevent multiple sourcing
|
||||
if ((Get-Variable -Name 'MOLE_FILEOPS_LOADED' -Scope Script -ErrorAction SilentlyContinue) -and $script:MOLE_FILEOPS_LOADED) { return }
|
||||
$script:MOLE_FILEOPS_LOADED = $true
|
||||
|
||||
# Import dependencies
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
. "$scriptDir\base.ps1"
|
||||
. "$scriptDir\log.ps1"
|
||||
|
||||
# ============================================================================
|
||||
# Global State
|
||||
# ============================================================================
|
||||
|
||||
$script:MoleDryRunMode = $env:MOLE_DRY_RUN -eq "1"
|
||||
$script:TotalSizeCleaned = 0
|
||||
$script:FilesCleaned = 0
|
||||
$script:TotalItems = 0
|
||||
|
||||
# ============================================================================
|
||||
# Safety Validation Functions
|
||||
# ============================================================================
|
||||
|
||||
function Test-SafePath {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Validate that a path is safe to operate on
|
||||
.DESCRIPTION
|
||||
Checks against protected paths and whitelist
|
||||
.OUTPUTS
|
||||
$true if safe, $false if protected
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Path
|
||||
)
|
||||
|
||||
# Must have a path
|
||||
if ([string]::IsNullOrWhiteSpace($Path)) {
|
||||
Write-Debug "Empty path rejected"
|
||||
return $false
|
||||
}
|
||||
|
||||
# Resolve to full path
|
||||
$fullPath = Resolve-SafePath -Path $Path
|
||||
if (-not $fullPath) {
|
||||
Write-Debug "Could not resolve path: $Path"
|
||||
return $false
|
||||
}
|
||||
|
||||
# Check protected paths
|
||||
if (Test-ProtectedPath -Path $fullPath) {
|
||||
Write-Debug "Protected path rejected: $fullPath"
|
||||
return $false
|
||||
}
|
||||
|
||||
# Check whitelist
|
||||
if (Test-Whitelisted -Path $fullPath) {
|
||||
Write-Debug "Whitelisted path rejected: $fullPath"
|
||||
return $false
|
||||
}
|
||||
|
||||
return $true
|
||||
}
|
||||
|
||||
function Get-PathSize {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Get the size of a file or directory in bytes
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Path
|
||||
)
|
||||
|
||||
if (-not (Test-Path $Path)) {
|
||||
return 0
|
||||
}
|
||||
|
||||
try {
|
||||
if (Test-Path $Path -PathType Container) {
|
||||
$size = (Get-ChildItem -Path $Path -Recurse -Force -ErrorAction SilentlyContinue |
|
||||
Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
|
||||
if ($null -eq $size) { return 0 }
|
||||
return [long]$size
|
||||
}
|
||||
else {
|
||||
return (Get-Item $Path -Force -ErrorAction SilentlyContinue).Length
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
function Get-PathSizeKB {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Get the size of a file or directory in kilobytes
|
||||
#>
|
||||
param([string]$Path)
|
||||
|
||||
$bytes = Get-PathSize -Path $Path
|
||||
return [Math]::Ceiling($bytes / 1024)
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Safe Removal Functions
|
||||
# ============================================================================
|
||||
|
||||
function Remove-SafeItem {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Safely remove a file or directory with all protection checks
|
||||
.DESCRIPTION
|
||||
This is the main safe deletion function. It:
|
||||
- Validates the path is not protected
|
||||
- Checks against whitelist
|
||||
- Supports dry-run mode
|
||||
- Tracks cleanup statistics
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Path,
|
||||
|
||||
[string]$Description = "",
|
||||
|
||||
[switch]$Force,
|
||||
|
||||
[switch]$Recurse
|
||||
)
|
||||
|
||||
# Validate path safety
|
||||
if (-not (Test-SafePath -Path $Path)) {
|
||||
Write-Debug "Skipping protected/whitelisted path: $Path"
|
||||
return $false
|
||||
}
|
||||
|
||||
# Check if path exists
|
||||
if (-not (Test-Path $Path)) {
|
||||
Write-Debug "Path does not exist: $Path"
|
||||
return $false
|
||||
}
|
||||
|
||||
# Get size before removal
|
||||
$size = Get-PathSize -Path $Path
|
||||
$sizeKB = [Math]::Ceiling($size / 1024)
|
||||
$sizeHuman = Format-ByteSize -Bytes $size
|
||||
|
||||
# Handle dry run
|
||||
if ($script:MoleDryRunMode) {
|
||||
$name = if ($Description) { $Description } else { Split-Path -Leaf $Path }
|
||||
Write-DryRun "$name $($script:Colors.Yellow)($sizeHuman dry)$($script:Colors.NC)"
|
||||
Set-SectionActivity
|
||||
return $true
|
||||
}
|
||||
|
||||
# Perform removal
|
||||
try {
|
||||
$isDirectory = Test-Path $Path -PathType Container
|
||||
|
||||
if ($isDirectory) {
|
||||
Remove-Item -Path $Path -Recurse -Force -ErrorAction Stop
|
||||
}
|
||||
else {
|
||||
Remove-Item -Path $Path -Force -ErrorAction Stop
|
||||
}
|
||||
|
||||
# Update statistics
|
||||
$script:TotalSizeCleaned += $sizeKB
|
||||
$script:FilesCleaned++
|
||||
$script:TotalItems++
|
||||
|
||||
# Log success
|
||||
$name = if ($Description) { $Description } else { Split-Path -Leaf $Path }
|
||||
Write-Success "$name $($script:Colors.Green)($sizeHuman)$($script:Colors.NC)"
|
||||
Set-SectionActivity
|
||||
|
||||
return $true
|
||||
}
|
||||
catch {
|
||||
Write-Debug "Failed to remove $Path : $_"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Remove-SafeItems {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Safely remove multiple items with a collective description
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string[]]$Paths,
|
||||
|
||||
[string]$Description = "Items"
|
||||
)
|
||||
|
||||
$totalSize = 0
|
||||
$removedCount = 0
|
||||
$failedCount = 0
|
||||
|
||||
foreach ($path in $Paths) {
|
||||
if (-not (Test-SafePath -Path $path)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (-not (Test-Path $path)) {
|
||||
continue
|
||||
}
|
||||
|
||||
$size = Get-PathSize -Path $path
|
||||
|
||||
if ($script:MoleDryRunMode) {
|
||||
$totalSize += $size
|
||||
$removedCount++
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
$isDirectory = Test-Path $path -PathType Container
|
||||
if ($isDirectory) {
|
||||
Remove-Item -Path $path -Recurse -Force -ErrorAction Stop
|
||||
}
|
||||
else {
|
||||
Remove-Item -Path $path -Force -ErrorAction Stop
|
||||
}
|
||||
$totalSize += $size
|
||||
$removedCount++
|
||||
}
|
||||
catch {
|
||||
$failedCount++
|
||||
Write-Debug "Failed to remove: $path - $_"
|
||||
}
|
||||
}
|
||||
|
||||
if ($removedCount -gt 0) {
|
||||
$sizeKB = [Math]::Ceiling($totalSize / 1024)
|
||||
$sizeHuman = Format-ByteSize -Bytes $totalSize
|
||||
|
||||
if ($script:MoleDryRunMode) {
|
||||
Write-DryRun "$Description $($script:Colors.Yellow)($removedCount items, $sizeHuman dry)$($script:Colors.NC)"
|
||||
}
|
||||
else {
|
||||
$script:TotalSizeCleaned += $sizeKB
|
||||
$script:FilesCleaned += $removedCount
|
||||
$script:TotalItems++
|
||||
Write-Success "$Description $($script:Colors.Green)($removedCount items, $sizeHuman)$($script:Colors.NC)"
|
||||
}
|
||||
Set-SectionActivity
|
||||
}
|
||||
|
||||
return @{
|
||||
Removed = $removedCount
|
||||
Failed = $failedCount
|
||||
Size = $totalSize
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Pattern-Based Cleanup Functions
|
||||
# ============================================================================
|
||||
|
||||
function Remove-OldFiles {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Remove files older than specified days
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Path,
|
||||
|
||||
[int]$DaysOld = 7,
|
||||
|
||||
[string]$Filter = "*",
|
||||
|
||||
[string]$Description = "Old files"
|
||||
)
|
||||
|
||||
if (-not (Test-Path $Path)) {
|
||||
return @{ Removed = 0; Size = 0 }
|
||||
}
|
||||
|
||||
$cutoffDate = (Get-Date).AddDays(-$DaysOld)
|
||||
|
||||
$oldFiles = Get-ChildItem -Path $Path -Filter $Filter -File -Force -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.LastWriteTime -lt $cutoffDate }
|
||||
|
||||
if ($oldFiles) {
|
||||
$paths = $oldFiles | ForEach-Object { $_.FullName }
|
||||
return Remove-SafeItems -Paths $paths -Description "$Description (>${DaysOld}d old)"
|
||||
}
|
||||
|
||||
return @{ Removed = 0; Size = 0 }
|
||||
}
|
||||
|
||||
function Remove-EmptyDirectories {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Remove empty directories recursively
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Path,
|
||||
|
||||
[string]$Description = "Empty directories"
|
||||
)
|
||||
|
||||
if (-not (Test-Path $Path)) {
|
||||
return @{ Removed = 0 }
|
||||
}
|
||||
|
||||
$removedCount = 0
|
||||
$maxIterations = 5
|
||||
|
||||
for ($i = 0; $i -lt $maxIterations; $i++) {
|
||||
$emptyDirs = Get-ChildItem -Path $Path -Directory -Recurse -Force -ErrorAction SilentlyContinue |
|
||||
Where-Object {
|
||||
(Get-ChildItem -Path $_.FullName -Force -ErrorAction SilentlyContinue | Measure-Object).Count -eq 0
|
||||
}
|
||||
|
||||
if (-not $emptyDirs -or $emptyDirs.Count -eq 0) {
|
||||
break
|
||||
}
|
||||
|
||||
foreach ($dir in $emptyDirs) {
|
||||
if (Test-SafePath -Path $dir.FullName) {
|
||||
if (-not $script:MoleDryRunMode) {
|
||||
try {
|
||||
Remove-Item -Path $dir.FullName -Force -ErrorAction Stop
|
||||
$removedCount++
|
||||
}
|
||||
catch {
|
||||
Write-Debug "Could not remove empty dir: $($dir.FullName)"
|
||||
}
|
||||
}
|
||||
else {
|
||||
$removedCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($removedCount -gt 0) {
|
||||
if ($script:MoleDryRunMode) {
|
||||
Write-DryRun "$Description $($script:Colors.Yellow)($removedCount dirs dry)$($script:Colors.NC)"
|
||||
}
|
||||
else {
|
||||
Write-Success "$Description $($script:Colors.Green)($removedCount dirs)$($script:Colors.NC)"
|
||||
}
|
||||
Set-SectionActivity
|
||||
}
|
||||
|
||||
return @{ Removed = $removedCount }
|
||||
}
|
||||
|
||||
function Clear-DirectoryContents {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Clear all contents of a directory but keep the directory itself
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Path,
|
||||
|
||||
[string]$Description = ""
|
||||
)
|
||||
|
||||
if (-not (Test-Path $Path)) {
|
||||
return @{ Removed = 0; Size = 0 }
|
||||
}
|
||||
|
||||
if (-not (Test-SafePath -Path $Path)) {
|
||||
return @{ Removed = 0; Size = 0 }
|
||||
}
|
||||
|
||||
$items = Get-ChildItem -Path $Path -Force -ErrorAction SilentlyContinue
|
||||
if ($items) {
|
||||
$paths = $items | ForEach-Object { $_.FullName }
|
||||
$desc = if ($Description) { $Description } else { Split-Path -Leaf $Path }
|
||||
return Remove-SafeItems -Paths $paths -Description $desc
|
||||
}
|
||||
|
||||
return @{ Removed = 0; Size = 0 }
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Statistics Functions
|
||||
# ============================================================================
|
||||
|
||||
function Get-CleanupStats {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Get current cleanup statistics
|
||||
#>
|
||||
return @{
|
||||
TotalSizeKB = $script:TotalSizeCleaned
|
||||
TotalSizeHuman = Format-ByteSize -Bytes ($script:TotalSizeCleaned * 1024)
|
||||
FilesCleaned = $script:FilesCleaned
|
||||
TotalItems = $script:TotalItems
|
||||
}
|
||||
}
|
||||
|
||||
function Reset-CleanupStats {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Reset cleanup statistics
|
||||
#>
|
||||
$script:TotalSizeCleaned = 0
|
||||
$script:FilesCleaned = 0
|
||||
$script:TotalItems = 0
|
||||
}
|
||||
|
||||
function Set-DryRunMode {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Enable or disable dry-run mode
|
||||
#>
|
||||
param([bool]$Enabled)
|
||||
$script:MoleDryRunMode = $Enabled
|
||||
}
|
||||
|
||||
function Test-DryRunMode {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Check if dry-run mode is enabled
|
||||
#>
|
||||
return $script:MoleDryRunMode
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Exports (functions are available via dot-sourcing)
|
||||
# ============================================================================
|
||||
# Functions: Test-SafePath, Get-PathSize, Remove-SafeItem, etc.
|
||||
@@ -1,285 +0,0 @@
|
||||
# Mole - Logging Module
|
||||
# Provides consistent logging functions with colors and icons
|
||||
|
||||
#Requires -Version 5.1
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
# Prevent multiple sourcing
|
||||
if ((Get-Variable -Name 'MOLE_LOG_LOADED' -Scope Script -ErrorAction SilentlyContinue) -and $script:MOLE_LOG_LOADED) { return }
|
||||
$script:MOLE_LOG_LOADED = $true
|
||||
|
||||
# Import base module
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
. "$scriptDir\base.ps1"
|
||||
|
||||
# ============================================================================
|
||||
# Log Configuration
|
||||
# ============================================================================
|
||||
|
||||
$script:LogConfig = @{
|
||||
DebugEnabled = $env:MOLE_DEBUG -eq "1"
|
||||
LogFile = $null
|
||||
Verbose = $false
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Core Logging Functions
|
||||
# ============================================================================
|
||||
|
||||
function Write-LogMessage {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Internal function to write formatted log message
|
||||
#>
|
||||
param(
|
||||
[string]$Message,
|
||||
[string]$Level,
|
||||
[string]$Color,
|
||||
[string]$Icon
|
||||
)
|
||||
|
||||
$timestamp = Get-Date -Format "HH:mm:ss"
|
||||
$colorCode = $script:Colors[$Color]
|
||||
$nc = $script:Colors.NC
|
||||
|
||||
$formattedIcon = if ($Icon) { "$Icon " } else { "" }
|
||||
$output = " ${colorCode}${formattedIcon}${nc}${Message}"
|
||||
|
||||
Write-Host $output
|
||||
|
||||
# Also write to log file if configured
|
||||
if ($script:LogConfig.LogFile) {
|
||||
"$timestamp [$Level] $Message" | Out-File -Append -FilePath $script:LogConfig.LogFile -Encoding UTF8
|
||||
}
|
||||
}
|
||||
|
||||
function Write-Info {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Write an informational message
|
||||
#>
|
||||
param([string]$Message)
|
||||
Write-LogMessage -Message $Message -Level "INFO" -Color "Cyan" -Icon $script:Icons.List
|
||||
}
|
||||
|
||||
function Write-Success {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Write a success message
|
||||
#>
|
||||
param([string]$Message)
|
||||
Write-LogMessage -Message $Message -Level "SUCCESS" -Color "Green" -Icon $script:Icons.Success
|
||||
}
|
||||
|
||||
|
||||
function Write-MoleWarning {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Write a warning message
|
||||
#>
|
||||
param([string]$Message)
|
||||
Write-LogMessage -Message $Message -Level "WARN" -Color "Yellow" -Icon $script:Icons.Warning
|
||||
}
|
||||
|
||||
function Write-MoleError {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Write an error message
|
||||
#>
|
||||
param([string]$Message)
|
||||
Write-LogMessage -Message $Message -Level "ERROR" -Color "Red" -Icon $script:Icons.Error
|
||||
}
|
||||
|
||||
|
||||
function Write-Debug {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Write a debug message (only if debug mode is enabled)
|
||||
#>
|
||||
param([string]$Message)
|
||||
|
||||
if ($script:LogConfig.DebugEnabled) {
|
||||
$gray = $script:Colors.Gray
|
||||
$nc = $script:Colors.NC
|
||||
Write-Host " ${gray}[DEBUG] $Message${nc}"
|
||||
}
|
||||
}
|
||||
|
||||
function Write-DryRun {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Write a dry-run message (action that would be taken)
|
||||
#>
|
||||
param([string]$Message)
|
||||
Write-LogMessage -Message $Message -Level "DRYRUN" -Color "Yellow" -Icon $script:Icons.DryRun
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Section Functions (for progress indication)
|
||||
# ============================================================================
|
||||
|
||||
$script:CurrentSection = @{
|
||||
Active = $false
|
||||
Activity = $false
|
||||
Name = ""
|
||||
}
|
||||
|
||||
function Start-Section {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Start a new section with a title
|
||||
#>
|
||||
param([string]$Title)
|
||||
|
||||
$script:CurrentSection.Active = $true
|
||||
$script:CurrentSection.Activity = $false
|
||||
$script:CurrentSection.Name = $Title
|
||||
|
||||
$purple = $script:Colors.PurpleBold
|
||||
$nc = $script:Colors.NC
|
||||
$arrow = $script:Icons.Arrow
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "${purple}${arrow} ${Title}${nc}"
|
||||
}
|
||||
|
||||
function Stop-Section {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
End the current section
|
||||
#>
|
||||
if ($script:CurrentSection.Active -and -not $script:CurrentSection.Activity) {
|
||||
Write-Success "Nothing to tidy"
|
||||
}
|
||||
$script:CurrentSection.Active = $false
|
||||
}
|
||||
|
||||
function Set-SectionActivity {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Mark that activity occurred in current section
|
||||
#>
|
||||
if ($script:CurrentSection.Active) {
|
||||
$script:CurrentSection.Activity = $true
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Progress Spinner
|
||||
# ============================================================================
|
||||
|
||||
$script:SpinnerFrames = @('|', '/', '-', '\')
|
||||
$script:SpinnerIndex = 0
|
||||
$script:SpinnerJob = $null
|
||||
|
||||
function Start-Spinner {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Start an inline spinner with message
|
||||
#>
|
||||
param([string]$Message = "Working...")
|
||||
|
||||
$script:SpinnerIndex = 0
|
||||
$gray = $script:Colors.Gray
|
||||
$nc = $script:Colors.NC
|
||||
|
||||
Write-Host -NoNewline " ${gray}$($script:SpinnerFrames[0]) $Message${nc}"
|
||||
}
|
||||
|
||||
function Update-Spinner {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Update the spinner animation
|
||||
#>
|
||||
param([string]$Message)
|
||||
|
||||
$script:SpinnerIndex = ($script:SpinnerIndex + 1) % $script:SpinnerFrames.Count
|
||||
$frame = $script:SpinnerFrames[$script:SpinnerIndex]
|
||||
$gray = $script:Colors.Gray
|
||||
$nc = $script:Colors.NC
|
||||
|
||||
# Move cursor to beginning of line and clear
|
||||
Write-Host -NoNewline "`r ${gray}$frame $Message${nc} "
|
||||
}
|
||||
|
||||
function Stop-Spinner {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Stop the spinner and clear the line
|
||||
#>
|
||||
Write-Host -NoNewline "`r `r"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Progress Bar
|
||||
# ============================================================================
|
||||
|
||||
function Write-Progress {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Write a progress bar
|
||||
#>
|
||||
param(
|
||||
[int]$Current,
|
||||
[int]$Total,
|
||||
[string]$Message = "",
|
||||
[int]$Width = 30
|
||||
)
|
||||
|
||||
$percent = if ($Total -gt 0) { [Math]::Round(($Current / $Total) * 100) } else { 0 }
|
||||
$filled = [Math]::Round(($Width * $Current) / [Math]::Max($Total, 1))
|
||||
$empty = $Width - $filled
|
||||
|
||||
$bar = ("[" + ("=" * $filled) + (" " * $empty) + "]")
|
||||
$cyan = $script:Colors.Cyan
|
||||
$nc = $script:Colors.NC
|
||||
|
||||
Write-Host -NoNewline "`r ${cyan}$bar${nc} ${percent}% $Message "
|
||||
}
|
||||
|
||||
function Complete-Progress {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Clear the progress bar line
|
||||
#>
|
||||
Write-Host -NoNewline "`r" + (" " * 80) + "`r"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Log File Management
|
||||
# ============================================================================
|
||||
|
||||
function Set-LogFile {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Set a log file for persistent logging
|
||||
#>
|
||||
param([string]$Path)
|
||||
|
||||
$script:LogConfig.LogFile = $Path
|
||||
$dir = Split-Path -Parent $Path
|
||||
if ($dir -and -not (Test-Path $dir)) {
|
||||
New-Item -ItemType Directory -Path $dir -Force | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
function Enable-DebugMode {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Enable debug logging
|
||||
#>
|
||||
$script:LogConfig.DebugEnabled = $true
|
||||
}
|
||||
|
||||
function Disable-DebugMode {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Disable debug logging
|
||||
#>
|
||||
$script:LogConfig.DebugEnabled = $false
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Exports (functions are available via dot-sourcing)
|
||||
# ============================================================================
|
||||
# Functions: Write-Info, Write-Success, Write-Warning, Write-Error, etc.
|
||||
@@ -1,449 +0,0 @@
|
||||
# Mole - UI Module
|
||||
# Provides interactive UI components (menus, confirmations, etc.)
|
||||
|
||||
#Requires -Version 5.1
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
# Prevent multiple sourcing
|
||||
if ((Get-Variable -Name 'MOLE_UI_LOADED' -Scope Script -ErrorAction SilentlyContinue) -and $script:MOLE_UI_LOADED) { return }
|
||||
$script:MOLE_UI_LOADED = $true
|
||||
|
||||
# Import dependencies
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
. "$scriptDir\base.ps1"
|
||||
. "$scriptDir\log.ps1"
|
||||
|
||||
# ============================================================================
|
||||
# Terminal Utilities
|
||||
# ============================================================================
|
||||
|
||||
function Get-TerminalSize {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Get terminal width and height
|
||||
#>
|
||||
try {
|
||||
return @{
|
||||
Width = $Host.UI.RawUI.WindowSize.Width
|
||||
Height = $Host.UI.RawUI.WindowSize.Height
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return @{ Width = 80; Height = 24 }
|
||||
}
|
||||
}
|
||||
|
||||
function Clear-Line {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Clear the current line
|
||||
#>
|
||||
$width = (Get-TerminalSize).Width
|
||||
Write-Host -NoNewline ("`r" + (" " * ($width - 1)) + "`r")
|
||||
}
|
||||
|
||||
function Move-CursorUp {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Move cursor up N lines
|
||||
#>
|
||||
param([int]$Lines = 1)
|
||||
Write-Host -NoNewline "$([char]27)[$Lines`A"
|
||||
}
|
||||
|
||||
function Move-CursorDown {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Move cursor down N lines
|
||||
#>
|
||||
param([int]$Lines = 1)
|
||||
Write-Host -NoNewline "$([char]27)[$Lines`B"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Confirmation Dialogs
|
||||
# ============================================================================
|
||||
|
||||
function Read-Confirmation {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Ask for yes/no confirmation
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Prompt,
|
||||
|
||||
[bool]$Default = $false
|
||||
)
|
||||
|
||||
$cyan = $script:Colors.Cyan
|
||||
$nc = $script:Colors.NC
|
||||
$hint = if ($Default) { "[Y/n]" } else { "[y/N]" }
|
||||
|
||||
Write-Host -NoNewline " ${cyan}$($script:Icons.Confirm)${nc} $Prompt $hint "
|
||||
|
||||
$response = Read-Host
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($response)) {
|
||||
return $Default
|
||||
}
|
||||
|
||||
return $response -match '^[Yy]'
|
||||
}
|
||||
|
||||
function Read-ConfirmationDestructive {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Ask for confirmation on destructive operations (requires typing 'yes')
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Prompt,
|
||||
|
||||
[string]$ConfirmText = "yes"
|
||||
)
|
||||
|
||||
$red = $script:Colors.Red
|
||||
$nc = $script:Colors.NC
|
||||
|
||||
Write-Host ""
|
||||
Write-Host " ${red}$($script:Icons.Warning) WARNING: $Prompt${nc}"
|
||||
Write-Host " Type '$ConfirmText' to confirm: " -NoNewline
|
||||
|
||||
$response = Read-Host
|
||||
return $response -eq $ConfirmText
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Menu Components
|
||||
# ============================================================================
|
||||
|
||||
function Show-Menu {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Display an interactive menu and return selected option
|
||||
.PARAMETER Title
|
||||
Menu title
|
||||
.PARAMETER Options
|
||||
Array of menu options (hashtables with Name and optionally Description, Action)
|
||||
.PARAMETER AllowBack
|
||||
Show back/exit option
|
||||
#>
|
||||
param(
|
||||
[string]$Title = "Menu",
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[array]$Options,
|
||||
|
||||
[switch]$AllowBack
|
||||
)
|
||||
|
||||
$selected = 0
|
||||
$maxIndex = $Options.Count - 1
|
||||
|
||||
# Add back option if allowed
|
||||
if ($AllowBack) {
|
||||
$Options = $Options + @{ Name = "Back"; Description = "Return to previous menu" }
|
||||
$maxIndex++
|
||||
}
|
||||
|
||||
$purple = $script:Colors.PurpleBold
|
||||
$cyan = $script:Colors.Cyan
|
||||
$gray = $script:Colors.Gray
|
||||
$nc = $script:Colors.NC
|
||||
|
||||
# Hide cursor
|
||||
Write-Host -NoNewline "$([char]27)[?25l"
|
||||
|
||||
try {
|
||||
while ($true) {
|
||||
# Clear screen and show menu
|
||||
Clear-Host
|
||||
|
||||
Write-Host ""
|
||||
Write-Host " ${purple}$($script:Icons.Arrow) $Title${nc}"
|
||||
Write-Host ""
|
||||
|
||||
for ($i = 0; $i -le $maxIndex; $i++) {
|
||||
$option = $Options[$i]
|
||||
$name = if ($option -is [hashtable]) { $option.Name } else { $option.ToString() }
|
||||
$desc = if ($option -is [hashtable] -and $option.Description) { " - $($option.Description)" } else { "" }
|
||||
|
||||
if ($i -eq $selected) {
|
||||
Write-Host " ${cyan}> $name${nc}${gray}$desc${nc}"
|
||||
}
|
||||
else {
|
||||
Write-Host " $name${gray}$desc${nc}"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host " ${gray}Use arrows or j/k to navigate, Enter to select, q to quit${nc}"
|
||||
|
||||
# Read key - handle both VirtualKeyCode and escape sequences
|
||||
$key = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
|
||||
|
||||
# Debug: uncomment to see key codes
|
||||
# Write-Host "VKey: $($key.VirtualKeyCode), Char: $([int]$key.Character)"
|
||||
|
||||
# Handle escape sequences for arrow keys (some terminals send these)
|
||||
$moved = $false
|
||||
if ($key.VirtualKeyCode -eq 0 -or $key.Character -eq [char]27) {
|
||||
# Escape sequence - read the next characters
|
||||
if ($Host.UI.RawUI.KeyAvailable) {
|
||||
$key2 = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
|
||||
if ($key2.Character -eq '[' -and $Host.UI.RawUI.KeyAvailable) {
|
||||
$key3 = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
|
||||
switch ($key3.Character) {
|
||||
'A' { # Up arrow escape sequence
|
||||
$selected = if ($selected -gt 0) { $selected - 1 } else { $maxIndex }
|
||||
$moved = $true
|
||||
}
|
||||
'B' { # Down arrow escape sequence
|
||||
$selected = if ($selected -lt $maxIndex) { $selected + 1 } else { 0 }
|
||||
$moved = $true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $moved) {
|
||||
switch ($key.VirtualKeyCode) {
|
||||
38 { # Up arrow
|
||||
$selected = if ($selected -gt 0) { $selected - 1 } else { $maxIndex }
|
||||
}
|
||||
40 { # Down arrow
|
||||
$selected = if ($selected -lt $maxIndex) { $selected + 1 } else { 0 }
|
||||
}
|
||||
13 { # Enter
|
||||
# Show cursor
|
||||
Write-Host -NoNewline "$([char]27)[?25h"
|
||||
|
||||
if ($AllowBack -and $selected -eq $maxIndex) {
|
||||
return $null # Back selected
|
||||
}
|
||||
return $Options[$selected]
|
||||
}
|
||||
default {
|
||||
switch ($key.Character) {
|
||||
'k' { $selected = if ($selected -gt 0) { $selected - 1 } else { $maxIndex } }
|
||||
'j' { $selected = if ($selected -lt $maxIndex) { $selected + 1 } else { 0 } }
|
||||
'q' {
|
||||
Write-Host -NoNewline "$([char]27)[?25h"
|
||||
return $null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
# Ensure cursor is shown
|
||||
Write-Host -NoNewline "$([char]27)[?25h"
|
||||
}
|
||||
}
|
||||
|
||||
function Show-SelectionList {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Display a multi-select list
|
||||
#>
|
||||
param(
|
||||
[string]$Title = "Select Items",
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[array]$Items,
|
||||
|
||||
[switch]$MultiSelect
|
||||
)
|
||||
|
||||
$cursor = 0
|
||||
$selected = @{}
|
||||
$maxIndex = $Items.Count - 1
|
||||
|
||||
$purple = $script:Colors.PurpleBold
|
||||
$cyan = $script:Colors.Cyan
|
||||
$green = $script:Colors.Green
|
||||
$gray = $script:Colors.Gray
|
||||
$nc = $script:Colors.NC
|
||||
|
||||
Write-Host -NoNewline "$([char]27)[?25l"
|
||||
|
||||
try {
|
||||
while ($true) {
|
||||
Clear-Host
|
||||
|
||||
Write-Host ""
|
||||
Write-Host " ${purple}$($script:Icons.Arrow) $Title${nc}"
|
||||
if ($MultiSelect) {
|
||||
Write-Host " ${gray}Space to toggle, Enter to confirm${nc}"
|
||||
}
|
||||
Write-Host ""
|
||||
|
||||
for ($i = 0; $i -le $maxIndex; $i++) {
|
||||
$item = $Items[$i]
|
||||
$name = if ($item -is [hashtable]) { $item.Name } else { $item.ToString() }
|
||||
$check = if ($selected[$i]) { "$($script:Icons.Success)" } else { "$($script:Icons.Empty)" }
|
||||
|
||||
if ($i -eq $cursor) {
|
||||
Write-Host " ${cyan}> ${check} $name${nc}"
|
||||
}
|
||||
else {
|
||||
$checkColor = if ($selected[$i]) { $green } else { $gray }
|
||||
Write-Host " ${checkColor}${check}${nc} $name"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host " ${gray}j/k or arrows to navigate, space to select, Enter to confirm, q to cancel${nc}"
|
||||
|
||||
$key = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
|
||||
|
||||
# Handle escape sequences for arrow keys (some terminals send these)
|
||||
$moved = $false
|
||||
if ($key.VirtualKeyCode -eq 0 -or $key.Character -eq [char]27) {
|
||||
# Escape sequence - read the next characters
|
||||
if ($Host.UI.RawUI.KeyAvailable) {
|
||||
$key2 = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
|
||||
if ($key2.Character -eq '[' -and $Host.UI.RawUI.KeyAvailable) {
|
||||
$key3 = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
|
||||
switch ($key3.Character) {
|
||||
'A' { # Up arrow escape sequence
|
||||
$cursor = if ($cursor -gt 0) { $cursor - 1 } else { $maxIndex }
|
||||
$moved = $true
|
||||
}
|
||||
'B' { # Down arrow escape sequence
|
||||
$cursor = if ($cursor -lt $maxIndex) { $cursor + 1 } else { 0 }
|
||||
$moved = $true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $moved) {
|
||||
switch ($key.VirtualKeyCode) {
|
||||
38 { $cursor = if ($cursor -gt 0) { $cursor - 1 } else { $maxIndex } }
|
||||
40 { $cursor = if ($cursor -lt $maxIndex) { $cursor + 1 } else { 0 } }
|
||||
32 { # Space
|
||||
if ($MultiSelect) {
|
||||
$selected[$cursor] = -not $selected[$cursor]
|
||||
}
|
||||
else {
|
||||
$selected = @{ $cursor = $true }
|
||||
}
|
||||
}
|
||||
13 { # Enter
|
||||
Write-Host -NoNewline "$([char]27)[?25h"
|
||||
$result = @()
|
||||
foreach ($selKey in $selected.Keys) {
|
||||
if ($selected[$selKey]) {
|
||||
$result += $Items[$selKey]
|
||||
}
|
||||
}
|
||||
return $result
|
||||
}
|
||||
default {
|
||||
switch ($key.Character) {
|
||||
'k' { $cursor = if ($cursor -gt 0) { $cursor - 1 } else { $maxIndex } }
|
||||
'j' { $cursor = if ($cursor -lt $maxIndex) { $cursor + 1 } else { 0 } }
|
||||
' ' {
|
||||
if ($MultiSelect) {
|
||||
$selected[$cursor] = -not $selected[$cursor]
|
||||
}
|
||||
else {
|
||||
$selected = @{ $cursor = $true }
|
||||
}
|
||||
}
|
||||
'q' {
|
||||
Write-Host -NoNewline "$([char]27)[?25h"
|
||||
return @()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Write-Host -NoNewline "$([char]27)[?25h"
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Banner / Header
|
||||
# ============================================================================
|
||||
|
||||
function Show-Banner {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Display the Mole ASCII banner
|
||||
#>
|
||||
$purple = $script:Colors.Purple
|
||||
$cyan = $script:Colors.Cyan
|
||||
$nc = $script:Colors.NC
|
||||
|
||||
Write-Host ""
|
||||
Write-Host " ${purple}MOLE${nc}"
|
||||
Write-Host " ${cyan}Windows System Maintenance${nc}"
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
function Show-Header {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Display a section header
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Title,
|
||||
|
||||
[string]$Subtitle = ""
|
||||
)
|
||||
|
||||
$purple = $script:Colors.PurpleBold
|
||||
$gray = $script:Colors.Gray
|
||||
$nc = $script:Colors.NC
|
||||
|
||||
Write-Host ""
|
||||
Write-Host " ${purple}$Title${nc}"
|
||||
if ($Subtitle) {
|
||||
Write-Host " ${gray}$Subtitle${nc}"
|
||||
}
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Summary Display
|
||||
# ============================================================================
|
||||
|
||||
function Show-Summary {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Display cleanup summary
|
||||
#>
|
||||
param(
|
||||
[long]$SizeBytes = 0,
|
||||
[int]$ItemCount = 0,
|
||||
[string]$Action = "Cleaned"
|
||||
)
|
||||
|
||||
$green = $script:Colors.Green
|
||||
$cyan = $script:Colors.Cyan
|
||||
$nc = $script:Colors.NC
|
||||
|
||||
$sizeHuman = Format-ByteSize -Bytes $SizeBytes
|
||||
|
||||
Write-Host ""
|
||||
Write-Host " $($green)━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━$($nc)"
|
||||
Write-Host " $($green)$($script:Icons.Success)$($nc) $($Action): $($cyan)$($sizeHuman)$($nc) across $($cyan)$($ItemCount)$($nc) items"
|
||||
Write-Host " $($green)━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━$($nc)"
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Exports (functions are available via dot-sourcing)
|
||||
# ============================================================================
|
||||
# Functions: Show-Menu, Show-Banner, Read-Confirmation, etc.
|
||||
320
windows/mole.ps1
320
windows/mole.ps1
@@ -1,320 +0,0 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# Mole - Windows System Maintenance Toolkit
|
||||
# Main CLI entry point
|
||||
|
||||
#Requires -Version 5.1
|
||||
param(
|
||||
[Parameter(Position = 0)]
|
||||
[string]$Command,
|
||||
|
||||
[Parameter(Position = 1, ValueFromRemainingArguments)]
|
||||
[string[]]$CommandArgs,
|
||||
|
||||
[switch]$Version,
|
||||
[switch]$ShowHelp
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
# Get script directory
|
||||
$script:MOLE_ROOT = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$script:MOLE_BIN = Join-Path $script:MOLE_ROOT "bin"
|
||||
$script:MOLE_LIB = Join-Path $script:MOLE_ROOT "lib"
|
||||
|
||||
# Import core
|
||||
. "$script:MOLE_LIB\core\common.ps1"
|
||||
|
||||
# ============================================================================
|
||||
# Version Info
|
||||
# ============================================================================
|
||||
|
||||
$script:MOLE_VER = "1.0.0"
|
||||
$script:MOLE_BUILD = "2026-01-07"
|
||||
|
||||
function Show-Version {
|
||||
$info = Get-MoleVersion
|
||||
Write-Host "Mole v$($info.Version)"
|
||||
Write-Host "Built: $($info.BuildDate)"
|
||||
Write-Host "PowerShell: $($info.PowerShell)"
|
||||
Write-Host "Windows: $($info.Windows)"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Help
|
||||
# ============================================================================
|
||||
|
||||
function Show-MainHelp {
|
||||
$cyan = $script:Colors.Cyan
|
||||
$gray = $script:Colors.Gray
|
||||
$green = $script:Colors.Green
|
||||
$nc = $script:Colors.NC
|
||||
|
||||
Show-Banner
|
||||
|
||||
Write-Host " ${cyan}Windows System Maintenance Toolkit${nc}"
|
||||
Write-Host " ${gray}Clean, optimize, and maintain your Windows system${nc}"
|
||||
Write-Host ""
|
||||
Write-Host " ${green}COMMANDS:${nc}"
|
||||
Write-Host ""
|
||||
Write-Host " ${cyan}clean${nc} Deep system cleanup (caches, temp, logs)"
|
||||
Write-Host " ${cyan}uninstall${nc} Smart application uninstaller"
|
||||
Write-Host " ${cyan}analyze${nc} Visual disk space analyzer"
|
||||
Write-Host " ${cyan}status${nc} Real-time system monitor"
|
||||
Write-Host " ${cyan}optimize${nc} System optimization tasks"
|
||||
Write-Host " ${cyan}purge${nc} Clean project build artifacts"
|
||||
Write-Host ""
|
||||
Write-Host " ${green}OPTIONS:${nc}"
|
||||
Write-Host ""
|
||||
Write-Host " ${cyan}-Version${nc} Show version information"
|
||||
Write-Host " ${cyan}-ShowHelp${nc} Show this help message"
|
||||
Write-Host ""
|
||||
Write-Host " ${green}EXAMPLES:${nc}"
|
||||
Write-Host ""
|
||||
Write-Host " ${gray}mole${nc} ${gray}# Interactive menu${nc}"
|
||||
Write-Host " ${gray}mole clean${nc} ${gray}# Deep cleanup${nc}"
|
||||
Write-Host " ${gray}mole clean -DryRun${nc} ${gray}# Preview cleanup${nc}"
|
||||
Write-Host " ${gray}mole uninstall${nc} ${gray}# Uninstall apps${nc}"
|
||||
Write-Host " ${gray}mole analyze${nc} ${gray}# Disk analyzer${nc}"
|
||||
Write-Host " ${gray}mole status${nc} ${gray}# System monitor${nc}"
|
||||
Write-Host " ${gray}mole optimize${nc} ${gray}# Optimize system${nc}"
|
||||
Write-Host " ${gray}mole purge${nc} ${gray}# Clean dev artifacts${nc}"
|
||||
Write-Host ""
|
||||
Write-Host " ${green}ENVIRONMENT:${nc}"
|
||||
Write-Host ""
|
||||
Write-Host " ${cyan}MOLE_DRY_RUN=1${nc} Preview without changes"
|
||||
Write-Host " ${cyan}MOLE_DEBUG=1${nc} Enable debug output"
|
||||
Write-Host ""
|
||||
Write-Host " ${gray}Run '${nc}mole <command> -ShowHelp${gray}' for command-specific help${nc}"
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Interactive Menu
|
||||
# ============================================================================
|
||||
|
||||
function Show-MainMenu {
|
||||
$options = @(
|
||||
@{
|
||||
Name = "Clean"
|
||||
Description = "Deep system cleanup"
|
||||
Command = "clean"
|
||||
Icon = $script:Icons.Trash
|
||||
}
|
||||
@{
|
||||
Name = "Uninstall"
|
||||
Description = "Remove applications"
|
||||
Command = "uninstall"
|
||||
Icon = $script:Icons.Folder
|
||||
}
|
||||
@{
|
||||
Name = "Analyze"
|
||||
Description = "Disk space analyzer"
|
||||
Command = "analyze"
|
||||
Icon = $script:Icons.File
|
||||
}
|
||||
@{
|
||||
Name = "Status"
|
||||
Description = "System monitor"
|
||||
Command = "status"
|
||||
Icon = $script:Icons.Solid
|
||||
}
|
||||
@{
|
||||
Name = "Optimize"
|
||||
Description = "System optimization"
|
||||
Command = "optimize"
|
||||
Icon = $script:Icons.Arrow
|
||||
}
|
||||
@{
|
||||
Name = "Purge"
|
||||
Description = "Clean dev artifacts"
|
||||
Command = "purge"
|
||||
Icon = $script:Icons.List
|
||||
}
|
||||
)
|
||||
|
||||
$selected = Show-Menu -Title "What would you like to do?" -Options $options -AllowBack
|
||||
|
||||
if ($null -eq $selected) {
|
||||
return $null
|
||||
}
|
||||
|
||||
return $selected.Command
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Command Router
|
||||
# ============================================================================
|
||||
|
||||
function Invoke-MoleCommand {
|
||||
param(
|
||||
[string]$CommandName,
|
||||
[string[]]$Arguments
|
||||
)
|
||||
|
||||
$scriptPath = Join-Path $script:MOLE_BIN "$CommandName.ps1"
|
||||
|
||||
if (-not (Test-Path $scriptPath)) {
|
||||
Write-MoleError "Unknown command: $CommandName"
|
||||
Write-Host ""
|
||||
Write-Host "Run 'mole -ShowHelp' for available commands"
|
||||
return
|
||||
}
|
||||
|
||||
# Execute the command script with arguments using splatting
|
||||
# This properly handles switch parameters passed as strings
|
||||
$argCount = if ($null -eq $Arguments) { 0 } else { @($Arguments).Count }
|
||||
if ($argCount -gt 0) {
|
||||
# Build a hashtable for splatting
|
||||
$splatParams = @{}
|
||||
$positionalArgs = @()
|
||||
|
||||
foreach ($arg in $Arguments) {
|
||||
# Remove surrounding quotes if present
|
||||
$cleanArg = $arg.Trim("'`"")
|
||||
|
||||
if ($cleanArg -match '^-(\w+)$') {
|
||||
# It's a switch parameter (e.g., -DryRun)
|
||||
$paramName = $Matches[1]
|
||||
$splatParams[$paramName] = $true
|
||||
}
|
||||
elseif ($cleanArg -match '^-(\w+)[=:](.+)$') {
|
||||
# It's a named parameter with value (e.g., -Name=Value or -Name:Value)
|
||||
$paramName = $Matches[1]
|
||||
$paramValue = $Matches[2].Trim("'`"")
|
||||
$splatParams[$paramName] = $paramValue
|
||||
}
|
||||
else {
|
||||
# Positional argument
|
||||
$positionalArgs += $cleanArg
|
||||
}
|
||||
}
|
||||
|
||||
# Execute with splatting
|
||||
if ($positionalArgs.Count -gt 0) {
|
||||
& $scriptPath @splatParams @positionalArgs
|
||||
}
|
||||
else {
|
||||
& $scriptPath @splatParams
|
||||
}
|
||||
}
|
||||
else {
|
||||
& $scriptPath
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# System Info Display
|
||||
# ============================================================================
|
||||
|
||||
function Show-SystemInfo {
|
||||
$cyan = $script:Colors.Cyan
|
||||
$gray = $script:Colors.Gray
|
||||
$green = $script:Colors.Green
|
||||
$nc = $script:Colors.NC
|
||||
|
||||
$winInfo = Get-WindowsVersion
|
||||
$freeSpace = Get-FreeSpace
|
||||
$isAdmin = if (Test-IsAdmin) { "${green}Yes${nc}" } else { "${gray}No${nc}" }
|
||||
|
||||
Write-Host ""
|
||||
Write-Host " ${gray}System:${nc} $($winInfo.Name)"
|
||||
Write-Host " ${gray}Free Space:${nc} $freeSpace on $($env:SystemDrive)"
|
||||
Write-Host " ${gray}Admin:${nc} $isAdmin"
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Main
|
||||
# ============================================================================
|
||||
|
||||
function Main {
|
||||
# Initialize
|
||||
Initialize-Mole
|
||||
|
||||
# Handle switches passed as strings (when called via batch file with quoted args)
|
||||
# e.g., mole '-ShowHelp' becomes $Command = "-ShowHelp" instead of $ShowHelp = $true
|
||||
$effectiveShowHelp = $ShowHelp
|
||||
$effectiveVersion = $Version
|
||||
$effectiveCommand = $Command
|
||||
|
||||
if ($Command -match '^-(.+)$') {
|
||||
$switchName = $Matches[1]
|
||||
switch ($switchName) {
|
||||
'ShowHelp' { $effectiveShowHelp = $true; $effectiveCommand = $null }
|
||||
'Help' { $effectiveShowHelp = $true; $effectiveCommand = $null }
|
||||
'h' { $effectiveShowHelp = $true; $effectiveCommand = $null }
|
||||
'Version' { $effectiveVersion = $true; $effectiveCommand = $null }
|
||||
'v' { $effectiveVersion = $true; $effectiveCommand = $null }
|
||||
}
|
||||
}
|
||||
|
||||
# Handle version flag
|
||||
if ($effectiveVersion) {
|
||||
Show-Version
|
||||
return
|
||||
}
|
||||
|
||||
# Handle help flag
|
||||
if ($effectiveShowHelp -and -not $effectiveCommand) {
|
||||
Show-MainHelp
|
||||
return
|
||||
}
|
||||
|
||||
# If command specified, route to it
|
||||
if ($effectiveCommand) {
|
||||
$validCommands = @("clean", "uninstall", "analyze", "status", "optimize", "purge")
|
||||
|
||||
if ($effectiveCommand -in $validCommands) {
|
||||
Invoke-MoleCommand -CommandName $effectiveCommand -Arguments $CommandArgs
|
||||
}
|
||||
else {
|
||||
Write-MoleError "Unknown command: $effectiveCommand"
|
||||
Write-Host ""
|
||||
Write-Host "Available commands: $($validCommands -join ', ')"
|
||||
Write-Host "Run 'mole -ShowHelp' for more information"
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
# Interactive mode
|
||||
Clear-Host
|
||||
Show-Banner
|
||||
Show-SystemInfo
|
||||
|
||||
while ($true) {
|
||||
$selectedCommand = Show-MainMenu
|
||||
|
||||
if ($null -eq $selectedCommand) {
|
||||
Clear-Host
|
||||
Write-Host ""
|
||||
Write-Host " Goodbye!"
|
||||
Write-Host ""
|
||||
break
|
||||
}
|
||||
|
||||
Clear-Host
|
||||
Invoke-MoleCommand -CommandName $selectedCommand -Arguments @()
|
||||
|
||||
Write-Host ""
|
||||
Write-Host " Press any key to continue..."
|
||||
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
|
||||
Clear-Host
|
||||
Show-Banner
|
||||
Show-SystemInfo
|
||||
}
|
||||
}
|
||||
|
||||
# Run
|
||||
try {
|
||||
Main
|
||||
}
|
||||
catch {
|
||||
Write-Host ""
|
||||
Write-MoleError "An error occurred: $_"
|
||||
Write-Host ""
|
||||
exit 1
|
||||
}
|
||||
finally {
|
||||
Clear-TempFiles
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
# Mole Windows - Build Script
|
||||
# Builds Go binaries and validates PowerShell scripts
|
||||
|
||||
#Requires -Version 5.1
|
||||
param(
|
||||
[switch]$Clean,
|
||||
[switch]$Release,
|
||||
[switch]$Validate,
|
||||
[switch]$ShowHelp
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# Get script directory
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$windowsDir = Split-Path -Parent $scriptDir
|
||||
$binDir = Join-Path $windowsDir "bin"
|
||||
|
||||
function Show-BuildHelp {
|
||||
Write-Host ""
|
||||
Write-Host "Mole Windows Build Script" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
Write-Host "Usage: .\build.ps1 [options]"
|
||||
Write-Host ""
|
||||
Write-Host "Options:"
|
||||
Write-Host " -Clean Clean build artifacts before building"
|
||||
Write-Host " -Release Build optimized release binaries"
|
||||
Write-Host " -Validate Validate PowerShell script syntax"
|
||||
Write-Host " -ShowHelp Show this help message"
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
if ($ShowHelp) {
|
||||
Show-BuildHelp
|
||||
return
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host " Mole Windows - Build" -ForegroundColor Cyan
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
# ============================================================================
|
||||
# Clean
|
||||
# ============================================================================
|
||||
|
||||
if ($Clean) {
|
||||
Write-Host "[Clean] Removing build artifacts..." -ForegroundColor Yellow
|
||||
|
||||
$artifacts = @(
|
||||
(Join-Path $binDir "analyze.exe"),
|
||||
(Join-Path $binDir "status.exe"),
|
||||
(Join-Path $windowsDir "coverage-go.out"),
|
||||
(Join-Path $windowsDir "coverage-pester.xml")
|
||||
)
|
||||
|
||||
foreach ($artifact in $artifacts) {
|
||||
if (Test-Path $artifact) {
|
||||
Remove-Item $artifact -Force
|
||||
Write-Host " Removed: $artifact" -ForegroundColor Gray
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Validate PowerShell Scripts
|
||||
# ============================================================================
|
||||
|
||||
if ($Validate) {
|
||||
Write-Host "[Validate] Checking PowerShell script syntax..." -ForegroundColor Yellow
|
||||
|
||||
$scripts = Get-ChildItem -Path $windowsDir -Filter "*.ps1" -Recurse
|
||||
$errors = @()
|
||||
|
||||
foreach ($script in $scripts) {
|
||||
try {
|
||||
$null = [System.Management.Automation.Language.Parser]::ParseFile(
|
||||
$script.FullName,
|
||||
[ref]$null,
|
||||
[ref]$null
|
||||
)
|
||||
Write-Host " OK: $($script.Name)" -ForegroundColor Green
|
||||
}
|
||||
catch {
|
||||
Write-Host " ERROR: $($script.Name)" -ForegroundColor Red
|
||||
$errors += $script.FullName
|
||||
}
|
||||
}
|
||||
|
||||
if ($errors.Count -gt 0) {
|
||||
Write-Host ""
|
||||
Write-Host " $($errors.Count) script(s) have syntax errors!" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Build Go Binaries
|
||||
# ============================================================================
|
||||
|
||||
Write-Host "[Build] Building Go binaries..." -ForegroundColor Yellow
|
||||
|
||||
# Check if Go is installed
|
||||
$goVersion = & go version 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host " Error: Go is not installed" -ForegroundColor Red
|
||||
Write-Host " Please install Go from https://golang.org/dl/" -ForegroundColor Gray
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host " $goVersion" -ForegroundColor Gray
|
||||
Write-Host ""
|
||||
|
||||
# Create bin directory if needed
|
||||
if (-not (Test-Path $binDir)) {
|
||||
New-Item -ItemType Directory -Path $binDir -Force | Out-Null
|
||||
}
|
||||
|
||||
Push-Location $windowsDir
|
||||
try {
|
||||
# Build flags
|
||||
$ldflags = ""
|
||||
if ($Release) {
|
||||
$ldflags = "-s -w" # Strip debug info for smaller binaries
|
||||
}
|
||||
|
||||
# Build analyze
|
||||
Write-Host " Building analyze.exe..." -ForegroundColor Gray
|
||||
if ($Release) {
|
||||
& go build -ldflags "$ldflags" -o "$binDir\analyze.exe" "./cmd/analyze/"
|
||||
}
|
||||
else {
|
||||
& go build -o "$binDir\analyze.exe" "./cmd/analyze/"
|
||||
}
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host " Failed to build analyze.exe" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
$analyzeSize = (Get-Item "$binDir\analyze.exe").Length / 1MB
|
||||
Write-Host " Built: analyze.exe ($([math]::Round($analyzeSize, 2)) MB)" -ForegroundColor Green
|
||||
|
||||
# Build status
|
||||
Write-Host " Building status.exe..." -ForegroundColor Gray
|
||||
if ($Release) {
|
||||
& go build -ldflags "$ldflags" -o "$binDir\status.exe" "./cmd/status/"
|
||||
}
|
||||
else {
|
||||
& go build -o "$binDir\status.exe" "./cmd/status/"
|
||||
}
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host " Failed to build status.exe" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
$statusSize = (Get-Item "$binDir\status.exe").Length / 1MB
|
||||
Write-Host " Built: status.exe ($([math]::Round($statusSize, 2)) MB)" -ForegroundColor Green
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host " Build complete!" -ForegroundColor Green
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
@@ -1,141 +0,0 @@
|
||||
# Mole Windows - Test Runner Script
|
||||
# Runs all tests (Pester for PowerShell, go test for Go)
|
||||
|
||||
#Requires -Version 5.1
|
||||
param(
|
||||
[switch]$Verbose,
|
||||
[switch]$NoPester,
|
||||
[switch]$NoGo,
|
||||
[switch]$Coverage
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$script:ExitCode = 0
|
||||
|
||||
# Get script directory
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$windowsDir = Split-Path -Parent $scriptDir
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host " Mole Windows - Test Suite" -ForegroundColor Cyan
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
# ============================================================================
|
||||
# Pester Tests
|
||||
# ============================================================================
|
||||
|
||||
if (-not $NoPester) {
|
||||
Write-Host "[Pester] Running PowerShell tests..." -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
|
||||
# Check if Pester is installed
|
||||
$pesterModule = Get-Module -ListAvailable -Name Pester | Where-Object { $_.Version -ge "5.0.0" }
|
||||
|
||||
if (-not $pesterModule) {
|
||||
Write-Host " Installing Pester 5.x..." -ForegroundColor Gray
|
||||
Install-Module -Name Pester -MinimumVersion 5.0.0 -Force -SkipPublisherCheck -Scope CurrentUser
|
||||
}
|
||||
|
||||
Import-Module Pester -MinimumVersion 5.0.0
|
||||
|
||||
$testsDir = Join-Path $windowsDir "tests"
|
||||
|
||||
$config = New-PesterConfiguration
|
||||
$config.Run.Path = $testsDir
|
||||
$config.Run.Exit = $false
|
||||
$config.Output.Verbosity = if ($Verbose) { "Detailed" } else { "Normal" }
|
||||
|
||||
if ($Coverage) {
|
||||
$config.CodeCoverage.Enabled = $true
|
||||
$config.CodeCoverage.Path = @(
|
||||
(Join-Path $windowsDir "lib\core\*.ps1"),
|
||||
(Join-Path $windowsDir "lib\clean\*.ps1"),
|
||||
(Join-Path $windowsDir "bin\*.ps1")
|
||||
)
|
||||
$config.CodeCoverage.OutputPath = Join-Path $windowsDir "coverage-pester.xml"
|
||||
}
|
||||
|
||||
try {
|
||||
$result = Invoke-Pester -Configuration $config
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "[Pester] Results:" -ForegroundColor Yellow
|
||||
Write-Host " Passed: $($result.PassedCount)" -ForegroundColor Green
|
||||
Write-Host " Failed: $($result.FailedCount)" -ForegroundColor $(if ($result.FailedCount -gt 0) { "Red" } else { "Green" })
|
||||
Write-Host " Skipped: $($result.SkippedCount)" -ForegroundColor Gray
|
||||
|
||||
if ($result.FailedCount -gt 0) {
|
||||
$script:ExitCode = 1
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Host " Error running Pester tests: $_" -ForegroundColor Red
|
||||
$script:ExitCode = 1
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Go Tests
|
||||
# ============================================================================
|
||||
|
||||
if (-not $NoGo) {
|
||||
Write-Host "[Go] Running Go tests..." -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
|
||||
# Check if Go is installed
|
||||
$goVersion = & go version 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host " Go is not installed, skipping Go tests" -ForegroundColor Gray
|
||||
}
|
||||
else {
|
||||
Write-Host " $goVersion" -ForegroundColor Gray
|
||||
Write-Host ""
|
||||
|
||||
Push-Location $windowsDir
|
||||
try {
|
||||
$goArgs = @("test")
|
||||
if ($Verbose) {
|
||||
$goArgs += "-v"
|
||||
}
|
||||
if ($Coverage) {
|
||||
$goArgs += "-coverprofile=coverage-go.out"
|
||||
}
|
||||
$goArgs += "./..."
|
||||
|
||||
& go @goArgs
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
$script:ExitCode = 1
|
||||
}
|
||||
else {
|
||||
Write-Host ""
|
||||
Write-Host "[Go] All tests passed" -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Summary
|
||||
# ============================================================================
|
||||
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
if ($script:ExitCode -eq 0) {
|
||||
Write-Host " All tests passed!" -ForegroundColor Green
|
||||
}
|
||||
else {
|
||||
Write-Host " Some tests failed!" -ForegroundColor Red
|
||||
}
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
exit $script:ExitCode
|
||||
@@ -1,199 +0,0 @@
|
||||
# Mole Windows - Cleanup Module Tests
|
||||
# Pester tests for lib/clean functionality
|
||||
|
||||
BeforeAll {
|
||||
# Get the windows directory path (tests are in windows/tests/)
|
||||
$script:WindowsDir = Split-Path -Parent $PSScriptRoot
|
||||
$script:LibDir = Join-Path $script:WindowsDir "lib"
|
||||
|
||||
# Import core modules first
|
||||
. "$script:LibDir\core\base.ps1"
|
||||
. "$script:LibDir\core\log.ps1"
|
||||
. "$script:LibDir\core\ui.ps1"
|
||||
. "$script:LibDir\core\file_ops.ps1"
|
||||
|
||||
# Import cleanup modules
|
||||
. "$script:LibDir\clean\user.ps1"
|
||||
. "$script:LibDir\clean\caches.ps1"
|
||||
. "$script:LibDir\clean\dev.ps1"
|
||||
. "$script:LibDir\clean\apps.ps1"
|
||||
. "$script:LibDir\clean\system.ps1"
|
||||
|
||||
# Enable dry-run mode for all tests
|
||||
$env:MOLE_DRY_RUN = "1"
|
||||
Set-DryRunMode -Enabled $true
|
||||
}
|
||||
|
||||
AfterAll {
|
||||
$env:MOLE_DRY_RUN = $null
|
||||
Set-DryRunMode -Enabled $false
|
||||
}
|
||||
|
||||
Describe "User Cleanup Module" {
|
||||
Context "Clear-UserTempFiles" {
|
||||
It "Should have Clear-UserTempFiles function" {
|
||||
Get-Command Clear-UserTempFiles -ErrorAction SilentlyContinue | Should -Not -BeNullOrEmpty
|
||||
}
|
||||
|
||||
It "Should run without error in dry-run mode" {
|
||||
{ Clear-UserTempFiles } | Should -Not -Throw
|
||||
}
|
||||
}
|
||||
|
||||
Context "Clear-OldDownloads" {
|
||||
It "Should have Clear-OldDownloads function" {
|
||||
Get-Command Clear-OldDownloads -ErrorAction SilentlyContinue | Should -Not -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
|
||||
Context "Clear-RecycleBin" {
|
||||
It "Should have Clear-RecycleBin function" {
|
||||
Get-Command Clear-RecycleBin -ErrorAction SilentlyContinue | Should -Not -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
|
||||
Context "Invoke-UserCleanup" {
|
||||
It "Should have main user cleanup function" {
|
||||
Get-Command Invoke-UserCleanup -ErrorAction SilentlyContinue | Should -Not -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Describe "Cache Cleanup Module" {
|
||||
Context "Browser Cache Functions" {
|
||||
It "Should have Clear-BrowserCaches function" {
|
||||
Get-Command Clear-BrowserCaches -ErrorAction SilentlyContinue | Should -Not -BeNullOrEmpty
|
||||
}
|
||||
|
||||
It "Should run browser cache cleanup without error" {
|
||||
{ Clear-BrowserCaches } | Should -Not -Throw
|
||||
}
|
||||
}
|
||||
|
||||
Context "Application Cache Functions" {
|
||||
It "Should have Clear-AppCaches function" {
|
||||
Get-Command Clear-AppCaches -ErrorAction SilentlyContinue | Should -Not -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
|
||||
Context "Windows Update Cache" {
|
||||
It "Should have Clear-WindowsUpdateCache function" {
|
||||
Get-Command Clear-WindowsUpdateCache -ErrorAction SilentlyContinue | Should -Not -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
|
||||
Context "Invoke-CacheCleanup" {
|
||||
It "Should have main cache cleanup function" {
|
||||
Get-Command Invoke-CacheCleanup -ErrorAction SilentlyContinue | Should -Not -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Describe "Developer Tools Cleanup Module" {
|
||||
Context "Node.js Cleanup" {
|
||||
It "Should have npm cache cleanup function" {
|
||||
Get-Command Clear-NpmCache -ErrorAction SilentlyContinue | Should -Not -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
|
||||
Context "Python Cleanup" {
|
||||
It "Should have Python cache cleanup function" {
|
||||
Get-Command Clear-PythonCaches -ErrorAction SilentlyContinue | Should -Not -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
|
||||
Context "Go Cleanup" {
|
||||
It "Should have Go cache cleanup function" {
|
||||
Get-Command Clear-GoCaches -ErrorAction SilentlyContinue | Should -Not -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
|
||||
Context "Rust Cleanup" {
|
||||
It "Should have Rust cache cleanup function" {
|
||||
Get-Command Clear-RustCaches -ErrorAction SilentlyContinue | Should -Not -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
|
||||
Context "Docker Cleanup" {
|
||||
It "Should have Docker cache cleanup function" {
|
||||
Get-Command Clear-DockerCaches -ErrorAction SilentlyContinue | Should -Not -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
|
||||
Context "Invoke-DevToolsCleanup" {
|
||||
It "Should have main dev tools cleanup function" {
|
||||
Get-Command Invoke-DevToolsCleanup -ErrorAction SilentlyContinue | Should -Not -BeNullOrEmpty
|
||||
}
|
||||
|
||||
It "Should run without error in dry-run mode" {
|
||||
{ Invoke-DevToolsCleanup } | Should -Not -Throw
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Describe "Apps Cleanup Module" {
|
||||
Context "Orphan Detection" {
|
||||
It "Should have Find-OrphanedAppData function" {
|
||||
Get-Command Find-OrphanedAppData -ErrorAction SilentlyContinue | Should -Not -BeNullOrEmpty
|
||||
}
|
||||
|
||||
It "Should have Clear-OrphanedAppData function" {
|
||||
Get-Command Clear-OrphanedAppData -ErrorAction SilentlyContinue | Should -Not -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
|
||||
Context "Specific App Cleanup" {
|
||||
It "Should have Clear-OfficeCache function" {
|
||||
Get-Command Clear-OfficeCache -ErrorAction SilentlyContinue | Should -Not -BeNullOrEmpty
|
||||
}
|
||||
|
||||
It "Should have Clear-AdobeData function" {
|
||||
Get-Command Clear-AdobeData -ErrorAction SilentlyContinue | Should -Not -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
|
||||
Context "Invoke-AppCleanup" {
|
||||
It "Should have main app cleanup function" {
|
||||
Get-Command Invoke-AppCleanup -ErrorAction SilentlyContinue | Should -Not -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Describe "System Cleanup Module" {
|
||||
Context "System Temp" {
|
||||
It "Should have Clear-SystemTempFiles function" {
|
||||
Get-Command Clear-SystemTempFiles -ErrorAction SilentlyContinue | Should -Not -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
|
||||
Context "Windows Logs" {
|
||||
It "Should have Clear-WindowsLogs function" {
|
||||
Get-Command Clear-WindowsLogs -ErrorAction SilentlyContinue | Should -Not -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
|
||||
Context "Windows Update Cleanup" {
|
||||
It "Should have Clear-WindowsUpdateFiles function" {
|
||||
Get-Command Clear-WindowsUpdateFiles -ErrorAction SilentlyContinue | Should -Not -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
|
||||
Context "Memory Dumps" {
|
||||
It "Should have Clear-MemoryDumps function" {
|
||||
Get-Command Clear-MemoryDumps -ErrorAction SilentlyContinue | Should -Not -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
|
||||
Context "Admin Requirements" {
|
||||
It "Should check for admin when needed" {
|
||||
# System cleanup should handle non-admin gracefully
|
||||
{ Clear-SystemTempFiles } | Should -Not -Throw
|
||||
}
|
||||
}
|
||||
|
||||
Context "Invoke-SystemCleanup" {
|
||||
It "Should have main system cleanup function" {
|
||||
Get-Command Invoke-SystemCleanup -ErrorAction SilentlyContinue | Should -Not -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
# Mole Windows - Command Tests
|
||||
# Pester tests for bin/ command scripts
|
||||
|
||||
BeforeAll {
|
||||
# Get the windows directory path (tests are in windows/tests/)
|
||||
$script:WindowsDir = Split-Path -Parent $PSScriptRoot
|
||||
$script:BinDir = Join-Path $script:WindowsDir "bin"
|
||||
}
|
||||
|
||||
Describe "Clean Command" {
|
||||
Context "Help Display" {
|
||||
It "Should show help without error" {
|
||||
$result = & powershell -ExecutionPolicy Bypass -File "$script:BinDir\clean.ps1" -ShowHelp 2>&1
|
||||
$result | Should -Not -BeNullOrEmpty
|
||||
$LASTEXITCODE | Should -Be 0
|
||||
}
|
||||
|
||||
It "Should mention dry-run in help" {
|
||||
$result = & powershell -ExecutionPolicy Bypass -File "$script:BinDir\clean.ps1" -ShowHelp 2>&1
|
||||
$result -join "`n" | Should -Match "DryRun"
|
||||
}
|
||||
}
|
||||
|
||||
Context "Dry Run Mode" {
|
||||
It "Should support -DryRun parameter" {
|
||||
# Just verify it starts without immediate error
|
||||
$job = Start-Job -ScriptBlock {
|
||||
param($binDir)
|
||||
& powershell -ExecutionPolicy Bypass -File "$binDir\clean.ps1" -DryRun 2>&1
|
||||
} -ArgumentList $script:BinDir
|
||||
|
||||
Start-Sleep -Seconds 3
|
||||
Stop-Job $job -ErrorAction SilentlyContinue
|
||||
Remove-Job $job -Force -ErrorAction SilentlyContinue
|
||||
|
||||
# If we got here without exception, test passes
|
||||
$true | Should -Be $true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Describe "Uninstall Command" {
|
||||
Context "Help Display" {
|
||||
It "Should show help without error" {
|
||||
$result = & powershell -ExecutionPolicy Bypass -File "$script:BinDir\uninstall.ps1" -ShowHelp 2>&1
|
||||
$result | Should -Not -BeNullOrEmpty
|
||||
$LASTEXITCODE | Should -Be 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Describe "Optimize Command" {
|
||||
Context "Help Display" {
|
||||
It "Should show help without error" {
|
||||
$result = & powershell -ExecutionPolicy Bypass -File "$script:BinDir\optimize.ps1" -ShowHelp 2>&1
|
||||
$result | Should -Not -BeNullOrEmpty
|
||||
$LASTEXITCODE | Should -Be 0
|
||||
}
|
||||
|
||||
It "Should mention optimization options in help" {
|
||||
$result = & powershell -ExecutionPolicy Bypass -File "$script:BinDir\optimize.ps1" -ShowHelp 2>&1
|
||||
$result -join "`n" | Should -Match "DryRun|Disk|DNS"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Describe "Purge Command" {
|
||||
Context "Help Display" {
|
||||
It "Should show help without error" {
|
||||
$result = & powershell -ExecutionPolicy Bypass -File "$script:BinDir\purge.ps1" -ShowHelp 2>&1
|
||||
$result | Should -Not -BeNullOrEmpty
|
||||
$LASTEXITCODE | Should -Be 0
|
||||
}
|
||||
|
||||
It "Should list artifact types in help" {
|
||||
$result = & powershell -ExecutionPolicy Bypass -File "$script:BinDir\purge.ps1" -ShowHelp 2>&1
|
||||
$result -join "`n" | Should -Match "node_modules|vendor|venv"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Describe "Analyze Command" {
|
||||
Context "Help Display" {
|
||||
It "Should show help without error" {
|
||||
$result = & powershell -ExecutionPolicy Bypass -File "$script:BinDir\analyze.ps1" -ShowHelp 2>&1
|
||||
$result | Should -Not -BeNullOrEmpty
|
||||
$LASTEXITCODE | Should -Be 0
|
||||
}
|
||||
|
||||
It "Should mention keybindings in help" {
|
||||
$result = & powershell -ExecutionPolicy Bypass -File "$script:BinDir\analyze.ps1" -ShowHelp 2>&1
|
||||
$result -join "`n" | Should -Match "Navigate|Enter|Quit"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Describe "Status Command" {
|
||||
Context "Help Display" {
|
||||
It "Should show help without error" {
|
||||
$result = & powershell -ExecutionPolicy Bypass -File "$script:BinDir\status.ps1" -ShowHelp 2>&1
|
||||
$result | Should -Not -BeNullOrEmpty
|
||||
$LASTEXITCODE | Should -Be 0
|
||||
}
|
||||
|
||||
It "Should mention system metrics in help" {
|
||||
$result = & powershell -ExecutionPolicy Bypass -File "$script:BinDir\status.ps1" -ShowHelp 2>&1
|
||||
$result -join "`n" | Should -Match "CPU|Memory|Disk|health"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Describe "Main Entry Point" {
|
||||
Context "mole.ps1" {
|
||||
BeforeAll {
|
||||
$script:MolePath = Join-Path $script:WindowsDir "mole.ps1"
|
||||
}
|
||||
|
||||
It "Should show help without error" {
|
||||
$result = & powershell -ExecutionPolicy Bypass -File $script:MolePath -ShowHelp 2>&1
|
||||
$result | Should -Not -BeNullOrEmpty
|
||||
}
|
||||
|
||||
It "Should show version without error" {
|
||||
$result = & powershell -ExecutionPolicy Bypass -File $script:MolePath -Version 2>&1
|
||||
$result | Should -Not -BeNullOrEmpty
|
||||
$result -join "`n" | Should -Match "Mole|v\d+\.\d+"
|
||||
}
|
||||
|
||||
It "Should list available commands in help" {
|
||||
$result = & powershell -ExecutionPolicy Bypass -File $script:MolePath -ShowHelp 2>&1
|
||||
$helpText = $result -join "`n"
|
||||
$helpText | Should -Match "clean"
|
||||
$helpText | Should -Match "uninstall"
|
||||
$helpText | Should -Match "optimize"
|
||||
$helpText | Should -Match "purge"
|
||||
$helpText | Should -Match "analyze"
|
||||
$helpText | Should -Match "status"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
# Mole Windows - Core Module Tests
|
||||
# Pester tests for lib/core functionality
|
||||
|
||||
BeforeAll {
|
||||
# Get the windows directory path (tests are in windows/tests/)
|
||||
$script:WindowsDir = Split-Path -Parent $PSScriptRoot
|
||||
$script:LibDir = Join-Path $script:WindowsDir "lib"
|
||||
|
||||
# Import core modules
|
||||
. "$script:LibDir\core\base.ps1"
|
||||
. "$script:LibDir\core\log.ps1"
|
||||
. "$script:LibDir\core\ui.ps1"
|
||||
. "$script:LibDir\core\file_ops.ps1"
|
||||
}
|
||||
|
||||
Describe "Base Module" {
|
||||
Context "Color Definitions" {
|
||||
It "Should define color codes" {
|
||||
$script:Colors | Should -Not -BeNullOrEmpty
|
||||
$script:Colors.Cyan | Should -Not -BeNullOrEmpty
|
||||
$script:Colors.Green | Should -Not -BeNullOrEmpty
|
||||
$script:Colors.Red | Should -Not -BeNullOrEmpty
|
||||
$script:Colors.NC | Should -Not -BeNullOrEmpty
|
||||
}
|
||||
|
||||
It "Should define icon set" {
|
||||
$script:Icons | Should -Not -BeNullOrEmpty
|
||||
$script:Icons.Success | Should -Not -BeNullOrEmpty
|
||||
$script:Icons.Error | Should -Not -BeNullOrEmpty
|
||||
$script:Icons.Warning | Should -Not -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
|
||||
Context "Test-IsAdmin" {
|
||||
It "Should return a boolean" {
|
||||
$result = Test-IsAdmin
|
||||
$result | Should -BeOfType [bool]
|
||||
}
|
||||
}
|
||||
|
||||
Context "Get-WindowsVersion" {
|
||||
It "Should return version info" {
|
||||
$result = Get-WindowsVersion
|
||||
$result | Should -Not -BeNullOrEmpty
|
||||
$result.Name | Should -Not -BeNullOrEmpty
|
||||
$result.Version | Should -Not -BeNullOrEmpty
|
||||
$result.Build | Should -Not -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
|
||||
Context "Get-FreeSpace" {
|
||||
It "Should return free space string" {
|
||||
$result = Get-FreeSpace
|
||||
$result | Should -Not -BeNullOrEmpty
|
||||
# Format is like "100.00GB" or "50.5MB" (no space between number and unit)
|
||||
$result | Should -Match "\d+(\.\d+)?(B|KB|MB|GB|TB)"
|
||||
}
|
||||
|
||||
It "Should accept drive parameter" {
|
||||
$result = Get-FreeSpace -Drive "C:"
|
||||
$result | Should -Not -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Describe "File Operations Module" {
|
||||
BeforeAll {
|
||||
# Create temp test directory
|
||||
$script:TestDir = Join-Path $env:TEMP "mole_test_$(Get-Random)"
|
||||
New-Item -ItemType Directory -Path $script:TestDir -Force | Out-Null
|
||||
}
|
||||
|
||||
AfterAll {
|
||||
# Cleanup test directory
|
||||
if (Test-Path $script:TestDir) {
|
||||
Remove-Item -Path $script:TestDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
Context "Format-ByteSize" {
|
||||
It "Should format bytes correctly" {
|
||||
# Actual format: no space, uses N0/N1/N2 formatting
|
||||
Format-ByteSize -Bytes 0 | Should -Be "0B"
|
||||
Format-ByteSize -Bytes 1024 | Should -Be "1KB"
|
||||
Format-ByteSize -Bytes 1048576 | Should -Be "1.0MB"
|
||||
Format-ByteSize -Bytes 1073741824 | Should -Be "1.00GB"
|
||||
}
|
||||
|
||||
It "Should handle large numbers" {
|
||||
Format-ByteSize -Bytes 1099511627776 | Should -Be "1.00TB"
|
||||
}
|
||||
}
|
||||
|
||||
Context "Get-PathSize" {
|
||||
BeforeEach {
|
||||
# Create test file
|
||||
$testFile = Join-Path $script:TestDir "testfile.txt"
|
||||
"Hello World" | Set-Content -Path $testFile
|
||||
}
|
||||
|
||||
It "Should return size for file" {
|
||||
$testFile = Join-Path $script:TestDir "testfile.txt"
|
||||
$result = Get-PathSize -Path $testFile
|
||||
$result | Should -BeGreaterThan 0
|
||||
}
|
||||
|
||||
It "Should return size for directory" {
|
||||
$result = Get-PathSize -Path $script:TestDir
|
||||
$result | Should -BeGreaterThan 0
|
||||
}
|
||||
|
||||
It "Should return 0 for non-existent path" {
|
||||
$result = Get-PathSize -Path "C:\NonExistent\Path\12345"
|
||||
$result | Should -Be 0
|
||||
}
|
||||
}
|
||||
|
||||
Context "Test-ProtectedPath" {
|
||||
It "Should protect Windows directory" {
|
||||
Test-ProtectedPath -Path "C:\Windows" | Should -Be $true
|
||||
Test-ProtectedPath -Path "C:\Windows\System32" | Should -Be $true
|
||||
}
|
||||
|
||||
It "Should protect Windows Defender paths" {
|
||||
Test-ProtectedPath -Path "C:\Program Files\Windows Defender" | Should -Be $true
|
||||
Test-ProtectedPath -Path "C:\ProgramData\Microsoft\Windows Defender" | Should -Be $true
|
||||
}
|
||||
|
||||
It "Should not protect temp directories" {
|
||||
Test-ProtectedPath -Path $env:TEMP | Should -Be $false
|
||||
}
|
||||
}
|
||||
|
||||
Context "Test-SafePath" {
|
||||
It "Should return false for protected paths" {
|
||||
Test-SafePath -Path "C:\Windows" | Should -Be $false
|
||||
Test-SafePath -Path "C:\Windows\System32" | Should -Be $false
|
||||
}
|
||||
|
||||
It "Should return true for safe paths" {
|
||||
Test-SafePath -Path $env:TEMP | Should -Be $true
|
||||
}
|
||||
|
||||
It "Should return false for empty paths" {
|
||||
# Test-SafePath has mandatory path parameter, so empty/null throws
|
||||
# But internally it should handle empty strings gracefully
|
||||
{ Test-SafePath -Path "" } | Should -Throw
|
||||
}
|
||||
}
|
||||
|
||||
Context "Remove-SafeItem" {
|
||||
BeforeEach {
|
||||
$script:TestFile = Join-Path $script:TestDir "safe_remove_test.txt"
|
||||
"Test content" | Set-Content -Path $script:TestFile
|
||||
}
|
||||
|
||||
It "Should remove file successfully" {
|
||||
$result = Remove-SafeItem -Path $script:TestFile
|
||||
$result | Should -Be $true
|
||||
Test-Path $script:TestFile | Should -Be $false
|
||||
}
|
||||
|
||||
It "Should respect DryRun mode" {
|
||||
$env:MOLE_DRY_RUN = "1"
|
||||
try {
|
||||
# Reset the module's DryRun state
|
||||
Set-DryRunMode -Enabled $true
|
||||
$result = Remove-SafeItem -Path $script:TestFile
|
||||
$result | Should -Be $true
|
||||
Test-Path $script:TestFile | Should -Be $true # File should still exist
|
||||
}
|
||||
finally {
|
||||
$env:MOLE_DRY_RUN = $null
|
||||
Set-DryRunMode -Enabled $false
|
||||
}
|
||||
}
|
||||
|
||||
It "Should not remove protected paths" {
|
||||
$result = Remove-SafeItem -Path "C:\Windows\System32"
|
||||
$result | Should -Be $false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Describe "Logging Module" {
|
||||
Context "Write-Log Functions" {
|
||||
It "Should have Write-Info function" {
|
||||
{ Write-Info "Test message" } | Should -Not -Throw
|
||||
}
|
||||
|
||||
It "Should have Write-Success function" {
|
||||
{ Write-Success "Test message" } | Should -Not -Throw
|
||||
}
|
||||
|
||||
It "Should have Write-MoleWarning function" {
|
||||
# Note: The actual function is Write-MoleWarning
|
||||
{ Write-MoleWarning "Test message" } | Should -Not -Throw
|
||||
}
|
||||
|
||||
It "Should have Write-MoleError function" {
|
||||
# Note: The actual function is Write-MoleError
|
||||
{ Write-MoleError "Test message" } | Should -Not -Throw
|
||||
}
|
||||
}
|
||||
|
||||
Context "Section Functions" {
|
||||
It "Should start and stop sections without error" {
|
||||
{ Start-Section -Title "Test Section" } | Should -Not -Throw
|
||||
{ Stop-Section } | Should -Not -Throw
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Describe "UI Module" {
|
||||
Context "Show-Banner" {
|
||||
It "Should display banner without error" {
|
||||
{ Show-Banner } | Should -Not -Throw
|
||||
}
|
||||
}
|
||||
|
||||
Context "Show-Header" {
|
||||
It "Should display header without error" {
|
||||
{ Show-Header -Title "Test Header" } | Should -Not -Throw
|
||||
}
|
||||
|
||||
It "Should accept subtitle parameter" {
|
||||
{ Show-Header -Title "Test" -Subtitle "Subtitle" } | Should -Not -Throw
|
||||
}
|
||||
}
|
||||
|
||||
Context "Show-Summary" {
|
||||
It "Should display summary without error" {
|
||||
{ Show-Summary -SizeBytes 1024 -ItemCount 5 } | Should -Not -Throw
|
||||
}
|
||||
}
|
||||
|
||||
Context "Read-Confirmation" {
|
||||
It "Should have Read-Confirmation function" {
|
||||
Get-Command Read-Confirmation -ErrorAction SilentlyContinue | Should -Not -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user