From a4b5fe77cf4eb6b1d5de7257dbc2a35ae0b0be9c Mon Sep 17 00:00:00 2001 From: Bhadra Date: Thu, 8 Jan 2026 14:24:11 +0530 Subject: [PATCH] feat(windows): add Windows support Phase 1 - core infrastructure Add isolated Windows support in windows/ directory with zero changes to existing macOS code. Phase 1 includes: - install.ps1: Windows installer with PATH and shortcut support - mole.ps1: Main CLI entry point with menu system - lib/core/base.ps1: Core definitions, colors, icons, constants - lib/core/common.ps1: Common functions loader - lib/core/file_ops.ps1: Safe file operations with protection checks - lib/core/log.ps1: Logging functions with colors - lib/core/ui.ps1: Interactive UI components (menus, confirmations) - go.mod/go.sum: Go module for future TUI tools - README.md: Windows-specific documentation Closes #273 --- windows/README.md | 111 +++++++++ windows/go.mod | 34 +++ windows/go.sum | 73 ++++++ windows/install.ps1 | 420 ++++++++++++++++++++++++++++++++ windows/lib/core/base.ps1 | 394 ++++++++++++++++++++++++++++++ windows/lib/core/common.ps1 | 130 ++++++++++ windows/lib/core/file_ops.ps1 | 439 ++++++++++++++++++++++++++++++++++ windows/lib/core/log.ps1 | 283 ++++++++++++++++++++++ windows/lib/core/ui.ps1 | 407 +++++++++++++++++++++++++++++++ windows/mole.ps1 | 265 ++++++++++++++++++++ 10 files changed, 2556 insertions(+) create mode 100644 windows/README.md create mode 100644 windows/go.mod create mode 100644 windows/go.sum create mode 100644 windows/install.ps1 create mode 100644 windows/lib/core/base.ps1 create mode 100644 windows/lib/core/common.ps1 create mode 100644 windows/lib/core/file_ops.ps1 create mode 100644 windows/lib/core/log.ps1 create mode 100644 windows/lib/core/ui.ps1 create mode 100644 windows/mole.ps1 diff --git a/windows/README.md b/windows/README.md new file mode 100644 index 0000000..5b1108b --- /dev/null +++ b/windows/README.md @@ -0,0 +1,111 @@ +# 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) +- Optional: 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 +``` + +## Environment Variables + +| Variable | Description | +|----------|-------------| +| `MOLE_DRY_RUN=1` | Preview changes without making them | +| `MOLE_DEBUG=1` | Enable debug output | + +## Directory Structure + +``` +windows/ +├── mole.ps1 # Main CLI entry point +├── install.ps1 # Windows installer +├── go.mod # Go module definition +├── go.sum # Go dependencies +└── 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 +``` + +## Configuration + +Mole stores its configuration in: +- Config: `~\.config\mole\` +- Cache: `~\.cache\mole\` +- Whitelist: `~\.config\mole\whitelist.txt` + +## Development + +### Phase 1: Core Infrastructure (Current) +- [x] `install.ps1` - Windows installer +- [x] `mole.ps1` - Main CLI entry point +- [x] `lib/core/*` - Core utility libraries + +### Phase 2: Cleanup Features (Planned) +- [ ] `bin/clean.ps1` - Deep cleanup orchestrator +- [ ] `bin/uninstall.ps1` - App removal with leftover detection +- [ ] `bin/optimize.ps1` - Cache rebuild and service refresh +- [ ] `bin/purge.ps1` - Aggressive cleanup mode +- [ ] `lib/clean/*` - Cleanup modules + +### Phase 3: TUI Tools (Planned) +- [ ] `cmd/analyze/` - Disk usage analyzer (Go) +- [ ] `cmd/status/` - Real-time system monitor (Go) +- [ ] `bin/analyze.ps1` - Analyzer wrapper +- [ ] `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. diff --git a/windows/go.mod b/windows/go.mod new file mode 100644 index 0000000..8463fe9 --- /dev/null +++ b/windows/go.mod @@ -0,0 +1,34 @@ +module github.com/tw93/mole/windows + +go 1.24.0 + +require ( + github.com/charmbracelet/bubbletea v0.25.0 + github.com/charmbracelet/lipgloss v0.9.1 + github.com/shirou/gopsutil/v3 v3.24.5 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/mattn/go-isatty v0.0.18 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.15.2 // indirect + github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect + github.com/rivo/uniseg v0.4.4 // indirect + github.com/shoenig/go-m1cpu v0.1.7 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.20.0 // indirect + golang.org/x/term v0.6.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/windows/go.sum b/windows/go.sum new file mode 100644 index 0000000..1fce446 --- /dev/null +++ b/windows/go.sum @@ -0,0 +1,73 @@ +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= diff --git a/windows/install.ps1 b/windows/install.ps1 new file mode 100644 index 0000000..d6796d6 --- /dev/null +++ b/windows/install.ps1 @@ -0,0 +1,420 @@ +#!/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]$Help +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +# ============================================================================ +# Configuration +# ============================================================================ + +$script:VERSION = "1.0.0" +$script:SourceDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$script:ShortcutName = "Mole" + +# Colors +$script:Colors = @{ + Red = "`e[31m" + Green = "`e[32m" + Yellow = "`e[33m" + Blue = "`e[34m" + Cyan = "`e[36m" + Gray = "`e[90m" + NC = "`e[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-Warning { + param([string]$Message) + $c = $script:Colors + Write-Host " $($c.Yellow)WARN$($c.NC) $Message" +} + +function Write-Error { + 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)╔╦╗╔═╗╦ ╔═╗$($c.NC)" + Write-Host " $($c.Cyan)║║║║ ║║ ║╣ $($c.NC)" + Write-Host " $($c.Cyan)╩ ╩╚═╝╩═╝╚═╝$($c.NC)" + Write-Host " $($c.Gray)Windows System Maintenance$($c.NC)" + Write-Host "" +} + +function Show-Help { + 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 $($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)-Help$($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-Error "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-Error "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-Error "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-Error "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-Error "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-Error "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) { + Copy-Item -Path $src -Destination $dst -Recurse -Force + } + else { + Copy-Item -Path $src -Destination $dst -Force + } + Write-Success "Copied: $item" + } + catch { + Write-Error "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 + $batchContent = @" +@echo off +powershell.exe -ExecutionPolicy Bypass -NoLogo -File "%~dp0mole.ps1" %* +"@ + $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-Warning "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-Error "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-Warning "Failed to remove config: $_" + } + } + } + + Write-Host "" + Write-Success "Mole uninstalled successfully!" + Write-Host "" + return $true +} + +# ============================================================================ +# Main +# ============================================================================ + +function Main { + if ($Help) { + Show-Help + return + } + + Show-Banner + + if ($Uninstall) { + Uninstall-Mole + } + else { + Install-Mole + } +} + +# Run +try { + Main +} +catch { + Write-Host "" + Write-Error "Installation failed: $_" + Write-Host "" + exit 1 +} diff --git a/windows/lib/core/base.ps1 b/windows/lib/core/base.ps1 new file mode 100644 index 0000000..1712130 --- /dev/null +++ b/windows/lib/core/base.ps1 @@ -0,0 +1,394 @@ +# 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\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. diff --git a/windows/lib/core/common.ps1 b/windows/lib/core/common.ps1 new file mode 100644 index 0000000..95ea2cf --- /dev/null +++ b/windows/lib/core/common.ps1 @@ -0,0 +1,130 @@ +# 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-Warning "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. diff --git a/windows/lib/core/file_ops.ps1 b/windows/lib/core/file_ops.ps1 new file mode 100644 index 0000000..53eb416 --- /dev/null +++ b/windows/lib/core/file_ops.ps1 @@ -0,0 +1,439 @@ +# 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:DryRun = $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:DryRun) { + $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:DryRun) { + $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:DryRun) { + 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:DryRun) { + 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:DryRun) { + 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:DryRun = $Enabled +} + +function Test-DryRunMode { + <# + .SYNOPSIS + Check if dry-run mode is enabled + #> + return $script:DryRun +} + +# ============================================================================ +# Exports (functions are available via dot-sourcing) +# ============================================================================ +# Functions: Test-SafePath, Get-PathSize, Remove-SafeItem, etc. diff --git a/windows/lib/core/log.ps1 b/windows/lib/core/log.ps1 new file mode 100644 index 0000000..24a7768 --- /dev/null +++ b/windows/lib/core/log.ps1 @@ -0,0 +1,283 @@ +# 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-Warning { + <# + .SYNOPSIS + Write a warning message + #> + param([string]$Message) + Write-LogMessage -Message $Message -Level "WARN" -Color "Yellow" -Icon $script:Icons.Warning +} + +function Write-Error { + <# + .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. diff --git a/windows/lib/core/ui.ps1 b/windows/lib/core/ui.ps1 new file mode 100644 index 0000000..cf95093 --- /dev/null +++ b/windows/lib/core/ui.ps1 @@ -0,0 +1,407 @@ +# 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 + $key = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") + + 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") + + 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 ($key in $selected.Keys) { + if ($selected[$key]) { + $result += $Items[$key] + } + } + 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 + + $banner = @" + + ${purple}███╗ ███╗ ██████╗ ██╗ ███████╗${nc} + ${purple}████╗ ████║██╔═══██╗██║ ██╔════╝${nc} + ${purple}██╔████╔██║██║ ██║██║ █████╗ ${nc} + ${purple}██║╚██╔╝██║██║ ██║██║ ██╔══╝ ${nc} + ${purple}██║ ╚═╝ ██║╚██████╔╝███████╗███████╗${nc} + ${purple}╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚══════╝${nc} + ${cyan}Windows System Maintenance${nc} + +"@ + + Write-Host $banner +} + +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. diff --git a/windows/mole.ps1 b/windows/mole.ps1 new file mode 100644 index 0000000..00833bd --- /dev/null +++ b/windows/mole.ps1 @@ -0,0 +1,265 @@ +#!/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 -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-Command { + param( + [string]$CommandName, + [string[]]$Arguments + ) + + $scriptPath = Join-Path $script:MOLE_BIN "$CommandName.ps1" + + if (-not (Test-Path $scriptPath)) { + Write-Error "Unknown command: $CommandName" + Write-Host "" + Write-Host "Run 'mole -ShowHelp' for available commands" + return + } + + # Execute the command script with arguments + & $scriptPath @Arguments +} + +# ============================================================================ +# 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 version flag + if ($Version) { + Show-Version + return + } + + # Handle help flag + if ($ShowHelp -and -not $Command) { + Show-MainHelp + return + } + + # If command specified, route to it + if ($Command) { + $validCommands = @("clean", "uninstall", "analyze", "status", "optimize", "purge") + + if ($Command -in $validCommands) { + Invoke-Command -CommandName $Command -Arguments $CommandArgs + } + else { + Write-Error "Unknown command: $Command" + 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-Command -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-Error "An error occurred: $_" + Write-Host "" + exit 1 +} +finally { + Clear-TempFiles +}