public/Invoke-OctaUninstaller.ps1
|
function Get-OctaInstalledPrograms { <# .SYNOPSIS Reads the three registry locations Windows' own "Apps & features" page reads from - public, long-established Windows administration knowledge, not derived from any specific third-party tool. #> [CmdletBinding()] param() $paths = @( 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*', 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*', 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*' ) Get-ItemProperty -Path $paths -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName } | Select-Object DisplayName, Publisher, InstallDate, UninstallString, InstallLocation, PSPath | Sort-Object DisplayName -Unique } function Get-OctaUninstallMarkerPath { $folder = Join-Path $env:LOCALAPPDATA 'Octa' if (-not (Test-Path $folder)) { New-Item -ItemType Directory -Path $folder -Force | Out-Null } return Join-Path $folder 'last-uninstall.json' } function Invoke-OctaUninstaller { <# .SYNOPSIS Lists installed programs (FR-001), uninstalls one via its own registered uninstaller (FR-001), and finds/removes leftovers from the most recent uninstall (FR-002). Two separate, explicit confirmations - uninstalling, then cleaning leftovers - since they're different risks (running a program's own uninstaller vs. deleting files Octa found on its own). #> [CmdletBinding()] param( [string]$Remove, [switch]$CleanLeftovers, [switch]$Yes ) if (-not $Remove -and -not $CleanLeftovers) { $programs = @(Get-OctaInstalledPrograms) foreach ($p in $programs) { Write-Host ("{0,-50} {1}" -f $p.DisplayName, $p.Publisher) } return $programs } if ($Remove) { $program = Get-OctaInstalledPrograms | Where-Object { $_.DisplayName -eq $Remove } | Select-Object -First 1 if (-not $program) { return [pscustomobject]@{ Status = 'NotFound'; Message = "Program not found: $Remove" } } if (-not $program.UninstallString) { return [pscustomobject]@{ Status = 'Unsupported'; Message = "'$Remove' has no registered uninstaller." } } try { if ($program.UninstallString -match '^msiexec') { Start-Process -FilePath 'msiexec.exe' -ArgumentList ($program.UninstallString -replace '^msiexec(\.exe)?\s*', '') -Wait -ErrorAction Stop } else { Start-Process -FilePath 'cmd.exe' -ArgumentList "/c `"$($program.UninstallString)`"" -Wait -ErrorAction Stop } } catch { return [pscustomobject]@{ Status = 'Error'; Message = $_.Exception.Message } } $stillInstalled = Get-OctaInstalledPrograms | Where-Object { $_.DisplayName -eq $Remove } if ($stillInstalled) { return [pscustomobject]@{ Status = 'Failed'; Message = "'$Remove' still appears installed after running its uninstaller - it may require interactive confirmation, or the uninstall was cancelled." } } [pscustomobject]@{ DisplayName = $program.DisplayName Publisher = $program.Publisher InstallLocation = $program.InstallLocation } | ConvertTo-Json | Set-Content -Path (Get-OctaUninstallMarkerPath) -Encoding utf8 return [pscustomobject]@{ Status = 'Success'; Message = "Uninstalled $Remove" } } if ($CleanLeftovers) { $markerPath = Get-OctaUninstallMarkerPath if (-not (Test-Path $markerPath)) { return [pscustomobject]@{ Status = 'NothingToClean'; Message = 'No recent uninstall to clean up after. Run --remove first.' } } $marker = Get-Content -Path $markerPath -Raw | ConvertFrom-Json $leftovers = @() if ($marker.InstallLocation -and (Test-Path -LiteralPath $marker.InstallLocation)) { $leftovers += [pscustomobject]@{ Type = 'Folder'; Path = $marker.InstallLocation } } if ($marker.Publisher) { foreach ($base in @('HKCU:\Software', 'HKLM:\SOFTWARE')) { $candidate = Join-Path $base $marker.Publisher if (Test-Path -LiteralPath $candidate) { $leftovers += [pscustomobject]@{ Type = 'RegistryKey'; Path = $candidate } } } } # 009/Kudu parity: also check the exact Publisher/DisplayName of THIS specific # just-uninstalled program under the usual app-data locations - narrower than a generic # "scan everything and guess" pass (research.md found that unreliable against real data), # this only ever acts on a program Octa already knows for certain was just removed. $candidateNames = @($marker.Publisher, $marker.DisplayName) | Where-Object { $_ } | Select-Object -Unique foreach ($base in @($env:LOCALAPPDATA, $env:APPDATA, $env:PROGRAMDATA)) { foreach ($name in $candidateNames) { $candidate = Join-Path $base $name if ((Test-Path -LiteralPath $candidate) -and -not ($leftovers.Path -contains $candidate)) { $leftovers += [pscustomobject]@{ Type = 'Folder'; Path = $candidate } } } } if ($leftovers.Count -eq 0) { return [pscustomobject]@{ Status = 'NothingToClean'; Message = "No leftovers found for $($marker.DisplayName)." } } Write-Host "Leftovers found for $($marker.DisplayName):" foreach ($l in $leftovers) { Write-Host (" [{0}] {1}" -f $l.Type, $l.Path) } if (-not $Yes) { $response = Read-Host "Delete these leftovers? This is irreversible (y/N)" if ($response -notin @('y', 'Y', 's', 'S')) { return [pscustomobject]@{ Status = 'Cancelled' } } } foreach ($l in $leftovers) { Remove-Item -LiteralPath $l.Path -Recurse -Force -ErrorAction SilentlyContinue } Remove-Item -Path $markerPath -Force -ErrorAction SilentlyContinue return [pscustomobject]@{ Status = 'Success'; Removed = $leftovers } } } |