Public/Remove-StaleDotNetRuntime.ps1
|
function Remove-StaleDotNetRuntime { <# .SYNOPSIS Removes superseded .NET shared framework (runtime) versions. .DESCRIPTION Inspects the installed .NET shared frameworks (Microsoft.NETCore.App, Microsoft.AspNetCore.App, Microsoft.WindowsDesktop.App, and any other framework found under the dotnet 'shared' folder) and removes every version that has been superseded by a newer patch within the same major.minor band. The newest version of each band is always kept. By default the removal is delegated to the .NET Uninstall Tool (dotnet-core-uninstall --all-lower-patches), which uninstalls each version through its original installer instead of deleting files. When the tool is missing it is installed with winget. The tool keeps versions that Visual Studio may require. The tool's filter options are mutually exclusive, so it cannot express every request. The original file-system removal is used instead when -Path or -Band is specified, when -KeepVersions is greater than 1, when the tool is unavailable, or when the tool fails. Prerelease versions are ranked below the matching stable release, so a stable 8.0.11 supersedes 8.0.11-preview.1. Removing a shared framework requires an elevated session. Supports -WhatIf and -Confirm; by default each removal must be confirmed. .PARAMETER Path One or more shared framework directories to evaluate (the folder that contains the version subdirectories). Defaults to every framework found under the 64-bit and 32-bit dotnet installations. Specifying this parameter forces the file-system removal. .PARAMETER Band Limits processing to the specified major.minor bands, for example '6.0' or '8.0'. By default all bands are evaluated. Specifying this parameter forces the file-system removal. .PARAMETER KeepVersions The number of most recent versions to keep in each major.minor band. Defaults to 1. A value greater than 1 forces the file-system removal. .PARAMETER IncludeSdk Also removes superseded .NET SDKs. Only the .NET Uninstall Tool can remove SDKs; the file-system fallback ignores them. .EXAMPLE Remove-StaleDotNetRuntime -WhatIf Shows which superseded shared framework versions would be removed. .EXAMPLE Remove-StaleDotNetRuntime -Confirm:$false Removes every superseded shared framework version without prompting. .EXAMPLE Remove-StaleDotNetRuntime -IncludeSdk -Confirm:$false Also removes the SDKs that have been superseded by a higher patch. .EXAMPLE Remove-StaleDotNetRuntime -Band '6.0', '8.0' -KeepVersions 2 Keeps the two newest patches of the 6.0 and 8.0 bands and removes the rest. .OUTPUTS System.IO.DirectoryInfo #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')] [OutputType([System.IO.DirectoryInfo])] param( [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)] [Alias('FullName')] [string[]]$Path, [ValidatePattern('^\d+\.\d+$')] [string[]]$Band, [ValidateRange(1, 1000)] [int]$KeepVersions = 1, [switch]$IncludeSdk ) begin { $versionRegex = [regex]::new('^(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)(?:-(?<pre>[0-9A-Za-z.-]+))?$') $removed = $false function Test-Elevated { $identity = [Security.Principal.WindowsIdentity]::GetCurrent() $principal = [Security.Principal.WindowsPrincipal]::new($identity) $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } function Get-DefaultSharedPath { $roots = @($env:ProgramFiles, ${env:ProgramFiles(x86)}) | Where-Object { $_ } | Select-Object -Unique | ForEach-Object { Join-Path $_ 'dotnet\shared' } foreach ($root in $roots) { if (Test-Path -LiteralPath $root -PathType Container) { (Get-ChildItem -LiteralPath $root -Directory).FullName } } } function Get-SharedFrameworkVersion { foreach ($frameworkPath in (Get-DefaultSharedPath)) { Get-ChildItem -LiteralPath $frameworkPath -Directory -ErrorAction SilentlyContinue } } function Get-UninstallToolPath { $command = Get-Command 'dotnet-core-uninstall' -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 if ($command) { return $command.Source } foreach ($root in @(${env:ProgramFiles(x86)}, $env:ProgramFiles)) { if (-not $root) { continue } $candidate = Join-Path $root 'dotnet-core-uninstall\dotnet-core-uninstall.exe' if (Test-Path -LiteralPath $candidate -PathType Leaf) { return $candidate } } } function Install-UninstallTool { if (-not (Get-Command 'winget' -CommandType Application -ErrorAction SilentlyContinue)) { Write-Verbose 'winget was not found; the .NET Uninstall Tool cannot be installed automatically.' return } if (-not $PSCmdlet.ShouldProcess('Microsoft.DotNet.UninstallTool', 'Install the .NET Uninstall Tool with winget')) { return } Write-Verbose 'Installing the .NET Uninstall Tool with winget.' & winget install --id Microsoft.DotNet.UninstallTool --source winget --accept-package-agreements --accept-source-agreements 2>&1 | ForEach-Object { Write-Verbose "$_" } if ($LASTEXITCODE -ne 0) { Write-Verbose "winget exited with code $LASTEXITCODE." return } Get-UninstallToolPath } function Invoke-UninstallTool { param( [string]$ToolPath, [string]$Target ) if ($WhatIfPreference) { $arguments = @('dry-run', '--all-lower-patches', $Target) } elseif ($PSCmdlet.ShouldProcess("$Target superseded versions", 'Remove with the .NET Uninstall Tool')) { $arguments = @('remove', '--all-lower-patches', $Target, '--yes') } else { return } Write-Verbose "Running: `"$ToolPath`" $($arguments -join ' ')" & $ToolPath @arguments 2>&1 | ForEach-Object { Write-Verbose "$_" } if ($LASTEXITCODE -ne 0) { throw "'$ToolPath $($arguments -join ' ')' exited with code $LASTEXITCODE." } } if (-not $WhatIfPreference -and -not (Test-Elevated)) { Write-Warning 'Not running elevated; removing a .NET shared framework requires administrator rights and will likely fail with access denied.' } $resolvedPaths = [System.Collections.Generic.List[string]]::new() } process { foreach ($item in $Path) { if ($item) { $resolvedPaths.Add($item) } } } end { # The tool's filter options are exclusive, so it can only express the default request. if ($resolvedPaths.Count -eq 0 -and -not $Band -and $KeepVersions -eq 1) { $toolPath = Get-UninstallToolPath if (-not $toolPath) { $toolPath = Install-UninstallTool } if ($toolPath) { $targets = @('--runtime', '--aspnet-runtime', '--windows-desktop-runtime') if ($IncludeSdk) { $targets += '--sdk' } $before = @(Get-SharedFrameworkVersion) try { foreach ($target in $targets) { Invoke-UninstallTool -ToolPath $toolPath -Target $target } if (-not $WhatIfPreference) { $remaining = @(Get-SharedFrameworkVersion).FullName $before | Where-Object { $_.FullName -notin $remaining } } return } catch { Write-Warning "The .NET Uninstall Tool did not complete: $($_.Exception.Message) Falling back to removing the directories." } } else { Write-Verbose 'The .NET Uninstall Tool is not available; falling back to removing the directories.' } } if ($IncludeSdk) { Write-Warning 'Only the .NET Uninstall Tool can remove SDKs; the fallback removal skips them.' } if ($resolvedPaths.Count -eq 0) { $discovered = Get-DefaultSharedPath foreach ($item in $discovered) { $resolvedPaths.Add($item) } } if ($resolvedPaths.Count -eq 0) { Write-Warning 'No .NET shared framework directories were found.' return } $failures = [System.Collections.Generic.List[string]]::new() foreach ($frameworkPath in ($resolvedPaths | Select-Object -Unique)) { if (-not (Test-Path -LiteralPath $frameworkPath -PathType Container)) { Write-Verbose "Skipping missing framework directory: $frameworkPath" continue } $versions = Get-ChildItem -LiteralPath $frameworkPath -Directory | ForEach-Object { $match = $versionRegex.Match($_.Name) if (-not $match.Success) { Write-Verbose "Ignoring non-version directory: $($_.FullName)" return } [PSCustomObject]@{ Directory = $_ Band = '{0}.{1}' -f $match.Groups['major'].Value, $match.Groups['minor'].Value Version = [version]('{0}.{1}.{2}' -f $match.Groups['major'].Value, $match.Groups['minor'].Value, $match.Groups['patch'].Value) IsStable = -not $match.Groups['pre'].Success Prerelease = $match.Groups['pre'].Value } } if (-not $versions) { Write-Verbose "No installed versions found in: $frameworkPath" continue } foreach ($group in ($versions | Group-Object Band)) { if ($Band -and $group.Name -notin $Band) { continue } $ordered = $group.Group | Sort-Object Version, IsStable, Prerelease -Descending $keep = $ordered | Select-Object -First $KeepVersions $stale = $ordered | Select-Object -Skip $KeepVersions Write-Verbose "$frameworkPath [$($group.Name)]: keeping $(($keep.Directory.Name) -join ', ')" foreach ($candidate in $stale) { $target = $candidate.Directory.FullName if (-not $PSCmdlet.ShouldProcess($target, 'Remove .NET shared framework version')) { continue } try { Remove-Item -LiteralPath $target -Recurse -Force -ErrorAction Stop Write-Verbose "Deleted: $target" $removed = $true $candidate.Directory } catch { $failures.Add("${target}: $($_.Exception.Message)") } } } } if (-not $removed) { Write-Verbose 'No superseded .NET shared framework versions were removed.' } if ($failures.Count -gt 0) { $noun = if ($failures.Count -eq 1) { 'version' } else { 'versions' } throw "Failed to delete $($failures.Count) .NET shared framework ${noun}:`n$($failures -join "`n")" } } } |