Public/Support/Helper/Limit-InstalledModule.ps1

# Module: TeamsFunctions
# 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 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,AzureAD
 
      Uninstalls all versions except the most recent version for Modules ImportExcel and AzureAD
    .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/TeamsFunctions/tree/main/docs/Limit-InstalledModule.md
    .LINK
      https://github.com/DEberhardt/TeamsFunctions/tree/main/docs/Update-InstalledModule.md
    .LINK
      https://github.com/DEberhardt/TeamsFunctions/tree/main/docs/about_ModuleManagement.md
    .LINK
      https://github.com/DEberhardt/TeamsFunctions/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 for Modules known to cause instabilities')]
    [switch]$Force
  )

  begin {
    Show-FunctionStatus -Level Live
    Write-Verbose -Message "[BEGIN ] $($MyInvocation.MyCommand)"

    # Setting Preference Variables according to Upstream settings
    if (-not $PSBoundParameters.ContainsKey('Verbose')) { $VerbosePreference = $PSCmdlet.SessionState.PSVariable.GetValue('VerbosePreference') }
    if (-not $PSBoundParameters.ContainsKey('Debug')) { $DebugPreference = $PSCmdlet.SessionState.PSVariable.GetValue('DebugPreference') } else { $DebugPreference = 'Continue' }
    if ( $PSBoundParameters.ContainsKey('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 Defining Scope - Query & Filter
    # Preparing Query
    $GetInstalledModuleParams = @{
      ErrorAction = 'Stop'
    }
    if ( $PSBoundParameters.ContainsKey('Name') ) {
      Write-Verbose -Message "Querying Modules with Name(s): $($Name -join ', ')"
      $GetInstalledModuleParams.Name = @($Name)
    }
    else {
      Write-Verbose -Message 'Querying all Modules'
    }

    # Querying installed Modules
    $CurrentModules = Get-InstalledModule @GetInstalledModuleParams | Where-Object Repository -EQ $Repository
    # Filter Excludes
    $ProtectedModules = @('Az', 'Az.*', 'PSReadLine', 'PowerShellGet')
    if ( -not $Force ) {
      $ProtectedModules | ForEach-Object { if ( !($_ -in $Exclude) ) { $Exclude.Add($_) } }
      $CurrentModules = $CurrentModules | Where-Object Name -NotMatch "^$($ProtectedModules -join '|')"
    }
    if ( $PSBoundParameters.ContainsKey('Exclude') ) {
      $CurrentModules = $CurrentModules | Where-Object Name -NotMatch "^$($Exclude -join '|')"
    }
    #endregion

    #region Confirm scope
    $Count = $(if ($CurrentModules.GetType().BaseType.Name -eq 'Array') { $CurrentModules.Count } else { 1 })
    Write-Verbose -Message "Modules found to process: $Count"
    if ( $Force.IsPresent -or $($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 { }
        1 { $ConfirmRequired = $true }
        2 { break }
      }
    }
    #endregion

  }

  process {
    Write-Verbose -Message "[PROCESS] $($MyInvocation.MyCommand)"
    $CurrentModules | ForEach-Object {
      Write-Verbose "$($_.Name) - Parsing System for older versions of Module"

      if ( $ProtectedModules -contains $_.Name ) {
        Write-Warning -Message "$($_.Name) - Removing versions of this Module can lead to instability. Handle with care!"
        $ConfirmRequired = $true
      }

      # Preparing Splatting parameters
      $ModuleParams = @{
        ErrorAction = 'Stop'
      }
      if ( $PSBoundParameters.ContainsKey('WhatIf') ) { $ModuleParams.WhatIf = $True }
      if ( $ConfirmRequired -or $PSBoundParameters.ContainsKey('Confirm') ) { $ModuleParams.Confirm = $True }

      # Querying Versions to remove
      $VersionsToRemove = Get-InstalledModule -Name $_.Name -AllVersions | Where-Object Version -NE $_.Version
      if ( $VersionsToRemove ) {
        $RemoveCount = $(if ($VersionsToRemove.GetType().BaseType.Name -eq 'Array') { $VersionsToRemove.Count } else { 1 })
        Write-Verbose "$($_.Name) - $RemoveCount old Versions found, attempting uninstall"
        $VersionsToRemove | ForEach-Object {
          try {
            $Name = $_.Name
            $OldVersion = $_.Version
            Write-Verbose -Message "$($_.Name) - Unloading Module"
            Remove-Module -Name $Name -Force -ErrorAction Ignore -Verbose:$False
            Write-Verbose -Message "$($_.Name) - Uninstalling Version $($_.Version)"
            $_ | Uninstall-Module -Force @ModuleParams
            Write-Information "INFO: $($_.Name) - Module Version $($_.Version) removed"
          }
          catch {
            if ( $_.Exception.Message.Contains('Administrator rights are required to uninstall from that folder') ) {
              Write-Warning -Message "$Name - Cannot remove Module from the 'AllUsers' Scope. Please run this script as Administrator to do so"
            }
            else {
              Write-Error "$Name - Uninstalling module version $OldVersion failed: $_"
            }
          }
        }
        Write-Verbose -Message "$($_.Name) - Module limited to Version $($_.Version)"
      }
      else {
        Write-Verbose -Message "$($_.Name) - Module limited to Version $($_.Version) (No older Version(s) discovered)"
      }
    }
  } #process

  end {
    Write-Verbose -Message "[END ] $($MyInvocation.MyCommand)"

  } #end
} # Limit-InstalledModule