Public/Start-SACCleanup.ps1
|
function Start-SACCleanup { <# .SYNOPSIS Surgical Autodesk Version Cleanup .DESCRIPTION Safely uninstalls specific older versions of Autodesk products without damaging global licensing, ODIS services, or newer installed versions. Designed for enterprise/MSP deployment to prune technical debt from CAD/BIM workstations. By default, it utilizes a dual-channel logging system: 1. Console Transcript: Captures standard output for immediate review. 2. Debug Log: Silently captures background IO exceptions to keep the console clean. 3. Attention Items: A targeted log of critical failures (AttentionItems.txt) created when uninstallation or registry eviction requires manual review. Supports robust temporary pathing, falling back to $env:TEMP if C:\temp is unavailable. .PARAMETER TargetProducts An array of string values representing the Autodesk products to target. Matches against the 'DisplayName' in the registry Add/Remove Programs list. Wildcards are handled implicitly by the script (e.g., "AutoCAD" targets "*AutoCAD*"). .PARAMETER TargetYears An array of integers representing the release years to target. .PARAMETER Silent Switch parameter. Bypasses all interactive confirmation prompts. Mandatory for deployment via RMM (e.g., N-Central) or background execution. .EXAMPLE # Scenario 1: The Default Run (Interactive) .\Autodesk-Cleanup.ps1 # Behavior: # Prompts for confirmation. Scans for the default array of products (AutoCAD, Revit, # Civil 3D, Inventor, etc.) matching years 2015 through 2023. .EXAMPLE # Scenario 2: RMM Silent Deployment (Default Targeting) .\Autodesk-Cleanup.ps1 -Silent # Behavior: # Bypasses the confirmation prompt. Ideal for an N-Central scheduled task # cleaning up legacy tech debt across a tenant. .EXAMPLE # Scenario 3: Surgical Single-Product Strike .\Autodesk-Cleanup.ps1 -TargetProducts "Revit" -TargetYears 2021 # Behavior: # Only searches for and removes "Revit 2021". Ignores AutoCAD, ignores Revit 2022. .EXAMPLE # Scenario 4: Aggressive AEC Legacy Sweep (Silent) .\Autodesk-Cleanup.ps1 -TargetProducts "AutoCAD", "Civil 3D", "Navisworks", "ReCap" -TargetYears 2015, 2016, 2017, 2018 -Silent # Behavior: # Silently targets a specific cluster of products for a defined range of older years. .EXAMPLE # Scenario 5: Targeted Manufacturing Suite Cleanup .\Autodesk-Cleanup.ps1 -TargetProducts "Inventor", "Vault Professional Client", "3ds Max" -TargetYears 2020, 2021 # Behavior: # Cleans up specific manufacturing and rendering tools for 2020 and 2021. #> [CmdletBinding()] param ( [string[]]$TargetProducts = @( "AutoCAD", "Revit", "Advance Steel", "Autodesk Material Library", "Civil 3D", "Inventor", "Navisworks Manage", "Navisworks Freedom", "ReCap", "3ds Max", "Maya", "Vault Professional Client", "Vault Basic Client" ), [int[]]$TargetYears = (2015..2023), [string[]]$AdditionalVendors = @(), [switch]$AnyVendor, [switch]$Silent ) $StopWatch = [System.Diagnostics.Stopwatch]::StartNew() $script:SACFailures = @() # Processes that are safe to kill if an older version is hanging (excluding shared system services/tray apps) $ProcessesToKill = @("acad*", "AcEventSync*", "AcQMod*", "revit*", "3dsmax*", "maya*", "inventor*", "roamer*", "navisworks*", "recap*", "dwgviewr*") # --- Logging Setup --- $ToDate = (Get-Date -Format 'yyyyMMdd_HHmmss') $BaseTemp = if (Test-Path "C:\temp") { "C:\temp" } else { $env:TEMP } $LogDir = Join-Path $BaseTemp "AutodeskCleanup_$($ToDate)" New-Item -ItemType Directory -Path $LogDir -Force -ErrorAction SilentlyContinue | Out-Null $TranscriptLog = "$($LogDir)\CleanupTranscript.log" $DebugLog = "$($LogDir)\CleanupDebug.log" Start-Transcript -Path $TranscriptLog -Append -Force | Out-Null # --- Helper Functions --- function Write-Msg { param ( [string]$Message, [ValidateSet("Info", "Success", "Warning", "Error")] [string]$Type = "Info" ) $TimeStamp = "[$(Get-Date -Format 'HH:mm:ss')]" $Colors = @{ "Info" = "Cyan" "Success" = "Green" "Warning" = "Yellow" "Error" = "Red" } Write-Host "$($TimeStamp) $($Message)" -ForegroundColor $Colors[$Type] } function Write-QuietLog { param ([string]$Message) $TimeStamp = "[$(Get-Date -Format 'HH:mm:ss')]" Add-Content -Path $DebugLog -Value "$($TimeStamp) [DEBUG] $($Message)" } function Test-Interactive { return [Environment]::UserInteractive -and -not $Silent -and ($host.Name -eq "ConsoleHost" -or $host.Name -match "ISE|VS Code") } function Invoke-SurgicalDirectoryCleanup { param ([string]$ProductName, [string]$Version) $SafePathsToSearch = @( "$($env:ProgramFiles)\Autodesk", "$(${env:ProgramFiles(x86)})\Autodesk", "$($env:ProgramData)\Autodesk", "$($env:PUBLIC)\Documents\Autodesk" ) $SearchPattern = "*$($ProductName)*$($Version)*" foreach ($basePath in $SafePathsToSearch) { if (Test-Path $basePath) { Get-ChildItem -Path $basePath -Filter $SearchPattern -Directory -ErrorAction SilentlyContinue | ForEach-Object { try { Remove-Item $_.FullName -Recurse -Force -ErrorAction Stop Write-Msg "Purged orphaned directory: $($_.FullName)" "Success" } catch { Write-QuietLog "Failed to remove directory $($_.FullName): $($_.Exception.Message)" $script:SACFailures += [PSCustomObject]@{ Component = "Directory Purge: $($_.FullName)"; Reason = $_.Exception.Message } } } } } } function Invoke-SACShortcutCleanup { param ([string]$ProductName, [string]$Version) $ShortcutLocations = @( "$($env:Public)\Desktop", "C:\Users\*\Desktop", "$($env:ProgramData)\Microsoft\Windows\Start Menu\Programs", "C:\Users\*\AppData\Roaming\Microsoft\Windows\Start Menu\Programs" ) $SearchPattern = "*$($ProductName)*$($Version)*.lnk" foreach ($loc in $ShortcutLocations) { # Resolve wildcards for user profiles $resolved = if ($loc -match '\*') { Resolve-Path $loc -ErrorAction SilentlyContinue } else { ,$loc } foreach ($path in $resolved) { if (Test-Path $path) { Get-ChildItem -Path $path -Filter $SearchPattern -Recurse -File -ErrorAction SilentlyContinue | ForEach-Object { try { Remove-Item $_.FullName -Force -ErrorAction Stop Write-Msg "Removed shortcut: $($_.Name)" "Success" } catch { Write-QuietLog "Failed to remove shortcut $($_.FullName): $($_.Exception.Message)" } } } } } } # --------------------------------------------------------------------------- # Tier Classification # --------------------------------------------------------------------------- # Tier 1 - Primary products: full uninstall, processed first # Tier 2 - Service packs / updates: skip uninstall if parent succeeded; evict only # Tier 3 - Add-ons / extensions: skip uninstall if parent succeeded; evict only # Tier 4 - Shared components: full uninstall, processed last # --------------------------------------------------------------------------- $Tier2Pattern = 'Service Pack|\bSP\d\b|Hotfix|Patch|Update \d|Security Update' $Tier3Pattern = 'Language Pack|Object Enabler|Add-[Ii]n|Plugin|Extension|' + 'Content Library|Content Core|Content Essential|Content Basic|' + 'Material Library Base|Material Library Low|Material Library Medium|Material Library High|' + 'Sample|Template|Documentation|DWG TrueView|' + 'Colorbooks|Color Books|Unit Schemas|MEP Content|MEP Metric|MEP Imperial|' + 'Revit Content|Batch Print|eTransmit|Worksharing Monitor|DB Link|Model Review|' + 'BIM Interoperability|Cloud Models|Issues Addon|Robot Structural Analysis Extension|' + 'OpenStudio CLI' $Tier4Pattern = 'Shared Component|Collaboration for Revit|Desktop Connector|Desktop App|Single Sign|Autodesk Access|Autodesk Identity|Genuine Service' function Get-SACTier { param ([string]$DisplayName) if ($DisplayName -match $Tier2Pattern) { return 2 } if ($DisplayName -match $Tier3Pattern) { return 3 } if ($DisplayName -match $Tier4Pattern) { return 4 } return 1 } # --------------------------------------------------------------------------- # Core uninstall engine: executes a single pre-classified entry # --------------------------------------------------------------------------- function Invoke-SACUninstallEntry { param ( [object]$App ) $ProductCode = $App.PSChildName $DisplayName = $App.DisplayName $UninstallString = $App.UninstallString $MsiLogFile = "$LogDir\$($DisplayName -replace '[\\/:*?"<>|]', '')_Uninstall.log" Write-Msg "Uninstalling: $DisplayName" "Warning" if ($ProductCode -match '^{.*}$') { if ($UninstallString -match '^MsiExec\.exe') { Write-Msg " [MSI] $DisplayName" "Info" $Process = Start-Process "msiexec.exe" -ArgumentList "/x $ProductCode /qn /norestart REBOOT=ReallySuppress MSIRESTARTMANAGERCONTROL=Disable /L*v `"$MsiLogFile`"" -PassThru -WindowStyle Hidden Invoke-SACWaitOnProcess -Process $Process -Label $DisplayName } else { Write-Msg " [Custom] $DisplayName" "Info" Invoke-SACCustomUninstall -App $App } } else { # Non-GUID entry - attempt custom uninstall path Invoke-SACCustomUninstall -App $App } # Evict the registry key. If it is already gone the uninstaller # cleaned up after itself, which is the ideal outcome - not a failure. if (Test-Path $App.PSPath) { try { Remove-Item $App.PSPath -Recurse -Force -ErrorAction Stop Write-Msg " Evicted registry key: $DisplayName" "Success" } catch { Write-QuietLog "Failed to evict registry key for $DisplayName ($($App.PSPath)): $($_.Exception.Message)" $script:SACFailures += [PSCustomObject]@{ Component = "Registry Eviction: $DisplayName"; Reason = $_.Exception.Message } } } else { Write-Msg " Registry key already removed (self-cleaned): $DisplayName" "Success" Write-QuietLog "Eviction skipped - key not found (uninstaller self-cleaned): $($App.PSPath)" } } function Invoke-SACWaitOnProcess { param ([System.Diagnostics.Process]$Process, [string]$Label) $LastCpu = $null $IdleSince = $null while (-not $Process.HasExited) { Start-Sleep -Seconds 10 try { $cpu = (Get-Process -Id $Process.Id -ErrorAction Stop).CPU if ($null -ne $LastCpu -and $cpu -eq $LastCpu) { if (-not $IdleSince) { $IdleSince = Get-Date } elseif (((Get-Date) - $IdleSince).TotalMinutes -ge 5) { Write-Msg " Idle timeout - terminating: $Label" "Warning" Stop-Process -Id $Process.Id -Force -ErrorAction SilentlyContinue break } } else { $IdleSince = $null; $LastCpu = $cpu } } catch { break } } Write-Msg " Exit code $($Process.ExitCode): $Label" "Info" # Safe/expected exit codes: # 0 = success # 3010 = success, reboot required # 1605 = product not installed (MSI) # 1614 = product not installed / uninstall already done # 1646 = product not registered for this machine (per-user install) # 7 = ODIS/setup: product not found (already removed by parent) $safeExitCodes = @(0, 3010, 1605, 1614, 1646, 7) if ($Process.ExitCode -notin $safeExitCodes) { $script:SACFailures += [PSCustomObject]@{ Component = "Uninstall: $Label"; Reason = "Exit Code $($Process.ExitCode)" } } else { Write-QuietLog " Safe exit code $($Process.ExitCode) for: $Label" } } function Invoke-SACCustomUninstall { param ([object]$App) $DisplayName = $App.DisplayName $UninstallString = $App.UninstallString if ([string]::IsNullOrWhiteSpace($UninstallString)) { Write-QuietLog "No UninstallString for $DisplayName. Skipping." return } $ExePath = '' $ArgPart = '' if ($UninstallString -match '^"([^"]+)"(.*)$') { $ExePath = $Matches[1]; $ArgPart = $Matches[2].Trim() } elseif ($UninstallString -match '^(.*\.exe)(.*)$') { $ExePath = $Matches[1].Trim(); $ArgPart = $Matches[2].Trim() } else { $ExePath = $UninstallString } if ([string]::IsNullOrWhiteSpace($ExePath)) { Write-QuietLog "Could not parse exe path for $DisplayName. Skipping." return } $FullArgs = "$ArgPart -q --silent /qn /quiet /norestart --mode unattended".Trim() try { $Process = Start-Process -FilePath $ExePath -ArgumentList $FullArgs -PassThru -WindowStyle Hidden -ErrorAction Stop Invoke-SACWaitOnProcess -Process $Process -Label $DisplayName } catch { $msg = $_.Exception.Message # If the installer exe itself is gone the product was already removed - not a failure if ($_.Exception -is [System.ComponentModel.Win32Exception] -and $_.Exception.NativeErrorCode -in @(2, 3)) { Write-Msg " Installer not found (already removed): $DisplayName" "Info" Write-QuietLog "Installer exe not found for $($DisplayName) - treating as already removed." } else { Write-QuietLog "Failed to launch uninstaller for $($DisplayName): $msg" Write-Msg " Launch failed for $DisplayName (see Debug Log)" "Error" $script:SACFailures += [PSCustomObject]@{ Component = "Uninstaller Launch: $DisplayName"; Reason = $msg } } } } # --------------------------------------------------------------------------- # Main function: scan, classify, sort by tier, smart-execute # --------------------------------------------------------------------------- function Invoke-UninstallAutodeskProduct { param ([string]$ProductName, [string]$Version) $PackageName = "*$ProductName*$Version*" $vendorPattern = '^$' if (-not $AnyVendor) { $vendors = @('Autodesk') if ($AdditionalVendors) { $vendors += $AdditionalVendors } $vendorPattern = ($vendors | ForEach-Object { [regex]::Escape($_) }) -join '|' } $AllKeys = Get-ItemProperty -Path @( 'HKLM:\Software\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*', 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*' ) -ErrorAction SilentlyContinue | Where-Object { if (-not ($_.DisplayName -like $PackageName)) { return $false } if ($AnyVendor) { return $true } return ($_.Publisher -match $vendorPattern -or $_.DisplayName -match $vendorPattern) } if (-not $AllKeys) { Write-QuietLog "No registry match for $ProductName $Version." return } # Kill running processes before we start foreach ($procName in $ProcessesToKill) { try { Get-Process -Name $procName -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction Stop } catch { Write-QuietLog "Could not stop process $procName`: $($_.Exception.Message)" } } # Classify and annotate each entry $Classified = $AllKeys | ForEach-Object { [PSCustomObject]@{ App = $_; Tier = (Get-SACTier -DisplayName $_.DisplayName) } } $Tier1 = @($Classified | Where-Object { $_.Tier -eq 1 }) $Tier2 = @($Classified | Where-Object { $_.Tier -eq 2 }) $Tier3 = @($Classified | Where-Object { $_.Tier -eq 3 }) $Tier4 = @($Classified | Where-Object { $_.Tier -eq 4 }) $total = $Classified.Count $skippable = $Tier2.Count + $Tier3.Count Write-Msg "Found $total component(s) for $($ProductName) $($Version): $($Tier1.Count) primary, $skippable update/addon(s), $($Tier4.Count) shared." "Info" # --- Tier 1: Primary products (full uninstall) --- $tier1Succeeded = New-Object 'System.Collections.Generic.HashSet[string]' foreach ($item in $Tier1) { Invoke-SACUninstallEntry -App $item.App # Track parent product names for Tier 2/3 skip logic $baseName = $item.App.DisplayName -replace $Tier2Pattern,'' -replace $Tier3Pattern,'' -replace $Tier4Pattern,'' -replace '\s+', ' ' $tier1Succeeded.Add($baseName.Trim()) | Out-Null } # --- Tier 2: Service packs / updates --- foreach ($item in $Tier2) { $dn = $item.App.DisplayName # Check if any T1 parent of this update was successfully processed $parentFound = $tier1Succeeded.Count -gt 0 if ($parentFound) { Write-Msg " [SKIP uninstall] Parent removed - evicting only: $dn" "Info" try { Remove-Item $item.App.PSPath -Recurse -Force -ErrorAction Stop Write-Msg " Evicted: $dn" "Success" } catch { Write-QuietLog "Failed to evict $($dn): $($_.Exception.Message)" $script:SACFailures += [PSCustomObject]@{ Component = "Evict SP/Update: $dn"; Reason = $_.Exception.Message; Severity = 'Warning' } } } else { Write-Msg " [FULL uninstall] No parent removed - running uninstaller: $dn" "Info" Invoke-SACUninstallEntry -App $item.App } } # --- Tier 3: Add-ons / extensions --- foreach ($item in $Tier3) { $dn = $item.App.DisplayName $parentFound = $tier1Succeeded.Count -gt 0 if ($parentFound) { Write-Msg " [SKIP uninstall] Parent removed - evicting only: $dn" "Info" try { Remove-Item $item.App.PSPath -Recurse -Force -ErrorAction Stop Write-Msg " Evicted: $dn" "Success" } catch { Write-QuietLog "Failed to evict $($dn): $($_.Exception.Message)" $script:SACFailures += [PSCustomObject]@{ Component = "Evict Addon: $dn"; Reason = $_.Exception.Message; Severity = 'Warning' } } } else { Write-Msg " [FULL uninstall] No parent removed - running uninstaller: $dn" "Info" Invoke-SACUninstallEntry -App $item.App } } # --- Tier 4: Shared components (always full uninstall, last) --- foreach ($item in $Tier4) { Invoke-SACUninstallEntry -App $item.App } # Run the surgical sweeps after all uninstallation attempts Invoke-SurgicalDirectoryCleanup -ProductName $ProductName -Version $Version Invoke-SACShortcutCleanup -ProductName $ProductName -Version $Version } # --- Execution Block --- Clear-Host Write-Msg "==========================================" "Info" Write-Msg " SURGICAL AUTODESK CLEANUP INITIALIZED" "Info" Write-Msg " Transcript: $($TranscriptLog)" "Info" Write-Msg " Debug Log: $($DebugLog)" "Info" Write-Msg "==========================================" "Info" if (Test-Interactive) { Write-Host "`nTarget Products: $($TargetProducts -join ', ')" -ForegroundColor Cyan Write-Host "Target Years: $($TargetYears -join ', ')`n" -ForegroundColor Cyan Write-Host "WARNING: This will forcefully remove the specified versions.`n" -ForegroundColor Yellow $Response = Read-Host "Type 'YES' to proceed" if ($Response -ne "YES") { Write-Msg "Execution aborted by user." "Warning" Stop-Transcript | Out-Null exit } } else { Write-Msg "Running in non-interactive/silent mode." "Info" } Write-Msg "Processing $($TargetProducts.Count) product(s) across $($TargetYears.Count) year(s)..." "Info" foreach ($year in ($TargetYears | Sort-Object)) { foreach ($product in $TargetProducts) { Invoke-UninstallAutodeskProduct -ProductName $product -Version $year.ToString() } } $StopWatch.Stop() $ElapsedTime = "{0:mm} min {0:ss} sec" -f $StopWatch.Elapsed Write-Msg "==========================================" "Info" Write-Msg " CLEANUP COMPLETED in $($ElapsedTime)" "Success" Write-Msg "==========================================" "Info" $criticals = @($script:SACFailures | Where-Object { $_.Severity -ne 'Warning' }) $warnings = @($script:SACFailures | Where-Object { $_.Severity -eq 'Warning' }) if ($criticals.Count -gt 0) { Write-Host "`n[!] FAILURES REQUIRING ATTENTION:" -ForegroundColor Red Write-Host " (Note: These items may have been forcibly evicted/removed despite errors)" -ForegroundColor Gray foreach ($fail in $criticals) { Write-Host " - $($fail.Component)" -ForegroundColor Yellow Write-Host " Reason: $($fail.Reason)" -ForegroundColor DarkGray } Write-Host "`nReview the Debug Log for diagnostics or resolve locks manually.`n" -ForegroundColor Red } if ($warnings.Count -gt 0) { Write-Host "`n[~] MINOR NOTICES ($($warnings.Count) item(s) - likely moot after parent removal):" -ForegroundColor DarkYellow foreach ($fail in $warnings) { Write-Host " - $($fail.Component)" -ForegroundColor DarkGray } Write-Host " See Debug Log for details. These are typically benign.`n" -ForegroundColor DarkGray } if ($criticals.Count -eq 0 -and $warnings.Count -eq 0) { Write-Host "`n[*] All operations completed successfully with no failures.`n" -ForegroundColor Green } elseif ($criticals.Count -eq 0) { Write-Host "`n[*] Primary operations succeeded. $($warnings.Count) minor notice(s) logged.`n" -ForegroundColor Green } # Persist outcome so the interactive menu can show a status badge on return $AttentionFile = Join-Path $LogDir "AttentionItems.txt" if ($criticals.Count -gt 0) { $content = @( "SURGICAL AUTODESK CLEANER - ITEMS REQUIRING ATTENTION", "Timestamp: $(Get-Date)", "Log Directory: $LogDir", "Note: Despite the errors below, these components may have been forcibly", "evicted or surgically removed from the system by the SAC engine.", "----------------------------------------------------------", "" ) foreach ($fail in $criticals) { $content += "[!] $($fail.Component)" $content += " Reason: $($fail.Reason)" $content += "" } $content | Out-File -FilePath $AttentionFile -Encoding utf8 } $script:SACLastRunStatus = [PSCustomObject]@{ Operation = 'Cleanup' Criticals = $criticals.Count Warnings = $warnings.Count Elapsed = $ElapsedTime LogDir = $LogDir AttentionItems = if ($criticals.Count -gt 0) { $AttentionFile } else { $null } } Stop-Transcript | Out-Null } |