Public/InstalledModule/Limit-InstalledModule.ps1
# Module: Orbit.Tools # Function: ModuleManagement # Author: Barbara Forbes, David Eberhardt # Updated: 17-SEP 2022 # Status: Live function Limit-InstalledModule { <# .SYNOPSIS Removes older versions of installed modules. .DESCRIPTION Removes older versions of the discovered modules keeping them tidy and making space on your disk. .PARAMETER Name List of modules to limit version to .PARAMETER Exclude List of modules to exclude from limiting. .PARAMETER Repository String. If not provided, targets the PowerShell gallery (PsGallery) EXPERIMENTAL. Untested behaviour may occur for custom repositories Please use "Get-InstalledModule | Where Repository -eq 'MyRepo' | Update-Module" as an alternative .PARAMETER SkipDependencyCheck Switch. Allows removal of Modules that have dependencies. This is useful for Modules like Microsoft.Graph and Az As the most recent version is retained, this should not cause issues. .PARAMETER Force Switch. Allows removal of Modules that were excluded because they are known to cause instabilities. This does not impact any Module specified with Exclude. Please see notes below. Handle with Care! .EXAMPLE Limit-InstalledModule Uninstalls all versions except the most recent version for ALL Modules found in Repository PsGallery This may not work for Modules in the AllUsers Scope if the Cmdlet is not run with Administrative rights. .EXAMPLE Limit-InstalledModule [-Name] ImportExcel,BuildHelpers Uninstalls all versions except the most recent version for Modules ImportExcel and BuildHelpers .EXAMPLE Limit-InstalledModule -Exclude MySpecialModule -Repository MyRepo Uninstalls all versions except the most recent version of all Modules found installed from the Repository 'MyRepo', except MySpecialModule and the modules known to cause instabilities. Please see notes below. Limitations may apply .EXAMPLE Limit-InstalledModule -Exclude MicrosoftTeams -Force Uninstalls all versions except the most recent version of all Modules found in PsGallery, except MicrosoftTeams This will also target Modules known to cause instabilities. Please see notes below. Handle with Care! .INPUTS System.String .OUTPUTS System.Void .NOTES Inspired by Barbara Forbes (@ba4bes,https://4bes.nl) 'Update-EveryModule', just separated out into two scripts. This is splitting Update-EveryModule into Limit-InstalledModule: Removing old versions except the latest Update-InstalledModule: Updating modules with options Sensitive Modules or ones known to cause instabilities have been excluded by default: Az, PsReadline, PowerShellGet Override with Force to also target these protected modules (This does not impact the use of the Exclude parameter). Repositories other than PsGallery could not be tested. .COMPONENT SupportingFunction .FUNCTIONALITY Removes old Module Versions retaining the most recent one installed .LINK https://github.com/DEberhardt/Orbit/tree/main/docs/Orbit.Tools/Limit-InstalledModule.md .LINK https://github.com/DEberhardt/Orbit/tree/main/docs/Orbit.Tools/Update-InstalledModule.md .LINK https://github.com/DEberhardt/Orbit/tree/main/docs/about/about_ModuleManagement.md .LINK https://github.com/DEberhardt/Orbit/tree/main/docs/ #> [cmdletbinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] [Alias('lim')] [OutputType([System.Void])] param ( [Parameter(HelpMessage = 'Modules will be updated')] [System.Collections.Generic.List[object]]$Name = @(), [Parameter(HelpMessage = 'Excluded Modules will not be removed')] [System.Collections.Generic.List[object]]$Exclude = @(), [Parameter()] [String]$Repository = 'PsGallery', [Parameter(HelpMessage = 'Overrides protection Modules with Dependencies')] [switch]$SkipDependencyCheck, [Parameter(HelpMessage = 'Overrides protection for Modules known to cause instabilities')] [switch]$Force ) begin { Show-OrbitFunctionStatus -Level Live Write-Verbose -Message "[BEGIN ] $($MyInvocation.MyCommand)" # Setting Preference Variables according to Upstream settings if (-not $PSBoundParameters['Verbose']) { $VerbosePreference = $PSCmdlet.SessionState.PSVariable.GetValue('VerbosePreference') } if (-not $PSBoundParameters['Debug']) { $DebugPreference = $PSCmdlet.SessionState.PSVariable.GetValue('DebugPreference') } else { $DebugPreference = 'Continue' } if ( $PSBoundParameters['InformationAction']) { $InformationPreference = $PSCmdlet.SessionState.PSVariable.GetValue('InformationAction') } else { $InformationPreference = 'Continue' } #region Helper functions # Testing for Administrator function Test-Administrator { $user = [Security.Principal.WindowsIdentity]::GetCurrent() (New-Object Security.Principal.WindowsPrincipal $user).IsInRole([Security.Principal.WindowsBuiltinRole]::Administrator) } function Test-ModuleScopeIsMachine ([object]$Module) { $AllUserScopeLocations = ([Environment]::GetEnvironmentVariable('PSModulePath', 'Machine')) -split ';' $AllUserScopeLocations | ForEach-Object { $AllTests = ($_ -in $($Module.InstalledLocation | Split-Path -Parent | Split-Path -Parent)) if ( $AllTests ) { $Module } } } #endregion #region Validating Requirements - Module and Repository try { $PSResourceGetModuleName = 'Microsoft.PowerShell.PSResourceGet' $PSResourceGetModule = Get-Module -ListAvailable -Name $PSResourceGetModuleName if ( -not $PSResourceGetModule ) { Write-Verbose -Message "This Cmdlet utilises the Module '$PSResourceGetModuleName'. To continue, please install this module" - try { $PSResourceGetModule = (Find-Module $PSResourceGetModuleName -ErrorAction Stop)[0] } catch { $PSResourceGetModule = (Find-Module $PSResourceGetModuleName -AllowPrerelease -ErrorAction Stop)[0] } $PSResourceGetModule | Install-Module -Confirm } $PSResourceGetModule | Import-Module -ErrorAction Stop } catch { if ( -not $_.ErrorRecord -match 'Assembly with same name is already loaded' ) { Write-Error "Module '$PSResourceGetModuleName' could not be loaded properly, please investigate" } } if ( $PSResourceGetModule.PrivateData.PSdata.Prerelease ) { Write-Warning -Message "This Cmdlet utilises the Module '$PSResourceGetModuleName' which currently is installed in a Prerelease version. Please report issues to 'https://github.com/PowerShell/PSResourceGet/issues'" } $PSResourceRepository = Get-PSResourceRepository -Name $Repository if ( -not $PSResourceRepository ) { if ( $Repository -eq 'PSGallery' ) { Register-PSResourceRepository -PSGallery -Trusted Set-PSResourceRepository PSGallery -ApiVersion v2 } else { $URI = Read-Host -Prompt "Repository not registered yet. Enter URI of Repository '$Repository' to register repository" Register-PSResourceRepository -Name $Repository -Trusted -Uri $URI } } elseif ( -not $PSResourceRepository.trusted ) { Write-Warning -Message "Repository $Repository - found but not trusted. To avoid having to confirm installing from this repository every single time, trusting this repository is recommended" Set-PSResourceRepository -Name $Repository -Trusted -Confirm Write-Verbose "PSResourceRepository '$Repository' registered, but not trusted. Trusted flag set - OK" } else { Write-Verbose "PSResourceRepository '$Repository' registered & trusted - OK" } #endregion #region Defining Scope - Query & Filter # Preparing Query $GetPSResource = @{ ErrorAction = 'Stop' } if ( $PSBoundParameters['Name'] ) { Write-Verbose -Message "Querying Modules with Name(s): $($Name -join ', ')" $GetPSResource.Name = @($Name) } else { Write-Verbose -Message 'Querying all Modules' } # Querying installed Modules $CurrentModules = Get-PSResource @GetPSResource | Where-Object Repository -EQ $Repository # Preparing Objects # [System.Collections.Generic.List[object]]$ModulesExcluded = @() [System.Collections.Generic.List[object]]$ModulesToProcess = @() # Filter Protected Modules [System.Collections.Generic.List[object]]$ProtectedModules = @('Az', 'PSReadLine', 'PowerShellGet', 'Microsoft.PowerShell.PSResourceGet') # Az.* are hardcoded below if ( -not $Force ) { # Adding Protected Modules to Exclusion scope if not already added #$ProtectedModules | ForEach-Object { if ( !($_ -in $Exclude) ) { $Exclude.Add($_) } } } # Processing Excludes if ($PSBoundParameters['Debug'] -or $DebugPreference -eq 'Continue') { "Function: $($MyInvocation.MyCommand.Name) - Exclude", ( $Exclude | Format-Table -AutoSize | Out-String).Trim() | Write-Debug } $Currentmodules | ForEach-Object { # Filtering first the ProtectedModules, then Excluded ones (adding both to ModulesExcluded) and adding all others to ModulesToProcess if ( -not $Force -and $($_.Name.StartsWith('Az.') -or $_.Name -match "^($($ProtectedModules -join '|'))$" ) ) { Write-Verbose -Message "Module '$($_.Name)' excluded from processing (Protected Module); Override with -Force" # $ModulesExcluded.Add($_) } elseif ( $_.Name -match "^($($Exclude -join '|'))$" ) { Write-Verbose -Message "Module '$($_.Name)' excluded from processing (manually excluded)" # $ModulesExcluded.Add($_) } else { Write-Verbose -Message "Module '$($_.Name)' considered for processing" $ModulesToProcess.Add($_) } } # Providing Feedback if ( -not $Force ) { Write-Warning -Message "The Modules $($ProtectedModules -join ', ') are excluded as they are protected against accidental deletion - To override please use -Force" } if ( $PSBoundParameters['Exclude'] ) { Write-Information "INFO: Modules $($Exclude -join ', ') are excluded from processing (provided through Exclude parameter)" } # #BODGE This works, but creates a warning for each module (flood)! # $ModulesExcluded | Select-Object Name -Unique | ForEach-Object { # It requires the manual inclusion of $_.StartsWith('Az.') as the match for 'Az.*' wouldn't work (includes AzureAdPreview) # if ( $_.Name.StartsWith('Az.') -or $_.Name -match "^($($ProtectedModules -join '|'))$" ) { # Write-Warning -Message "Module '$($_.Name)' excluded as it is a protected Module - To override please use -Force" # } # else { # Write-Information "INFO: Module '$($_.Name)' excluded from processing (provided through Exclude parameter)" # } # } # exiting if no scope detected if ( -not $ModulesToProcess ) { Write-Information 'INFO: No Modules scoped for removal, exiting' break } #endregion #region Confirm scope $Count = ($ModulesToProcess | Select-Object Name -Unique).Count Write-Verbose -Message "Modules found to process: $Count with $($ModulesToProcess.Count) Versions" if ( $($Exclude.Count -gt $ProtectedModules.Count + 1) -or $Count -gt 3 ) { $go = New-Object System.Management.Automation.Host.ChoiceDescription '&Yes', 'Continue' $goWithConfirm = New-Object System.Management.Automation.Host.ChoiceDescription '&Confirm', 'Continue with Confirm' $abort = New-Object System.Management.Automation.Host.ChoiceDescription '&No', 'Abort' $options = [System.Management.Automation.Host.ChoiceDescription[]]($go, $goWithConfirm, $abort) $title = 'Multiple Modules discovered!' $message = "Detected $Count Modules to process, excluding: $($Exclude -join ', ' ) | Proceed?" $result = $host.ui.PromptForChoice($title, $message, $options, 0) switch ($result) { 0 { $ConfirmRequired = $false } 1 { $ConfirmRequired = $true } 2 { break } } } #endregion } process { Write-Verbose -Message "[PROCESS] $($MyInvocation.MyCommand)" $ModulesToProcess | ForEach-Object { $LastVersion = ($ModulesToProcess | Where-Object Name -EQ $_.Name | Select-Object -First 1).Version if ( $_.Version -eq $LastVersion ) { Write-Verbose -Message "$($_.Name) - Version $($_.Version) is most recent version and will be retained" } else { # Module version is not the most recent version installed and is eligible for removal Write-Verbose -Message "$($_.Name) - Version $($_.Version) - Module is superseded by Version '$LastVersion' and will be uninstalled" if ($PSBoundParameters['Debug'] -or $DebugPreference -eq 'Continue') { "Function: $($MyInvocation.MyCommand.Name) - Module Version to Remove", ( $_ | Format-Table -AutoSize | Out-String).Trim() | Write-Debug } #region Preparing Splatting parameters $PSResource = @{ Name = $_.Name ErrorAction = 'Stop' Version = $_.Version SkipDependencyCheck = $SkipDependencyCheck #Required for individual Modules of MicrosoftGraph, Az, etc. } switch ( $PSBoundParameters ) { 'Credential' { $PSResource.Credential = $Credential } 'WhatIf' { $PSResource.WhatIf = $true } } if ( $ConfirmRequired -or $PSBoundParameters['Confirm'] ) { $PSResource.Confirm = $True } if ( $PSBoundParameters['AllowPreRelease'] ) {$PSResource.Prerelease = $true } #endregion # Execute try { Write-Verbose -Message "$($PSResource.Name) - Unloading Module" $PSResource.Name | Remove-Module -Force -ErrorAction Ignore -Verbose:$False Write-Verbose -Message "$($PSResource.Name) - Uninstalling Version $($PSResource.Version)" if ($PSBoundParameters['Debug'] -or $DebugPreference -eq 'Continue') { "Function: $($MyInvocation.MyCommand.Name) - PSResource", ( $PSResource | Format-Table -AutoSize | Out-String).Trim() | Write-Debug } if ( $Force -or $PSCmdlet.ShouldProcess("Module $($PSResource.Name), Version $($PSResource.Version)", 'Uninstall-PSResource') ) { Uninstall-PSResource @PSResource } } catch { if ( $_.Exception.Message.Contains('Administrator rights are required to uninstall from that folder') ) { Write-Warning -Message "$($PSResource.Name) - Uninstalling module version $($PSResource.Version) failed: Cannot uninstall Module from the 'AllUsers' Scope. Please run this script as Administrator to do so" } else { Write-Error "$($PSResource.Name) - Uninstalling module version $($PSResource.Version) failed: $_" } } } } } #process end { Write-Verbose -Message "[END ] $($MyInvocation.MyCommand)" } #end } # Limit-InstalledModule |