Public/Support/Helper/Update-InstalledModule.ps1

# Module: TeamsFunctions
# Function: ModuleManagement
# Author: Barbara Forbes, David Eberhardt
# Updated: 17-SEP 2022
# Status: Live




function Update-InstalledModule {
  <#
    .SYNOPSIS
      Updates and reloads modules from a Repository.
    .DESCRIPTION
      Updates all local modules that originated from a Repository (PowerShellGallery by default)
    .PARAMETER Name
      List of modules to update
    .PARAMETER Exclude
      List of modules to exclude from updating.
    .PARAMETER AllowPrerelease
      Updates to latest Version including PreReleases (if found) for every Module discovered
    .PARAMETER SkipMajorVersion
      Skip major version updates to account for breaking changes.
    .PARAMETER Scope
      String. CurrentUser or AllUsers. Default is CurrentUser.
    .PARAMETER Repository
      String. If not provided, targets the PowerShell gallery (PSGallery)
      EXPERIMENTAL. Untested behaviour may occur for custom repositories (Credential Parameter is not parsed, etc.)
      Please use "Get-InstalledModule | Where Repository -eq 'MyRepo' | Update-Module" as an alternative
    .PARAMETER Credential
      PsCredential. If provided, attaches the Credential on calls to Find-Module and Update-Module respectively.
      EXPERIMENTAL. Functionality is untested (Additional Parameters of *Module-CmdLets are not provided/parsed)
    .EXAMPLE
      Update-InstalledModule
 
      Updates all Modules to latest version found in Repository PowerShellGallery and installs them in the User Scope
    .EXAMPLE
      Update-InstalledModule [-Name] PSReadLine,PowerShellGet -SkipMajorVersion -Scope AllUsers
 
      Updates Modules PSReadLine and PowerShellGet to latest version found in Repository PowerShellGallery.
      Using Switch SkipMajorVersion will only update to the latest minor version currently installed of the module
      Scope AllUsers requires Administrative Rights. Script will terminate if not run as Administrator
    .EXAMPLE
      Update-InstalledModule [-Name] PSReadLine -AllowPrerelease
 
      Updates all Modules to latest version found in Repository PowerShellGallery including PreReleases
      Please note that, if the Name Parameter is not provided this will update ALL Modules to PreReleases found for each!
    .EXAMPLE
      Update-InstalledModule -Exclude Az
 
      Updates all Modules to latest version found in Repository PowerShellGallery, except Az
    .EXAMPLE
      Update-InstalledModule -Repository MyRepo
 
      Updates all Modules to latest version found in Repository MyRepo, except Az
    .EXAMPLE
      Update-InstalledModule -Repository MyRepo -Credential $MyPsCredential
 
      Authenticating against the Repository MyRepo with the provided Credential $MyCredential;
      Updates all Modules to latest version found in Repository MyRepo, except Az
    .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
      Update-InstalledModule: Updating modules with options
      Limit-InstalledModule: Removing old versions except the latest
 
      The parameters Repository and Credential are added to allow more flexibility with other repositories.
      They are currently EXPERIMENTAL and untested. Handle with Care!
 
      To avoid having to confirm a Trusted Source, the InstallationPolicy for the Repository can be set to Trusted with:
      Set-PSRepository -Name "PsGallery" -InstallationPolicy Trusted
      Set-PSRepository -Name "MyRepo" -InstallationPolicy Trusted
    .COMPONENT
      SupportingFunction
    .FUNCTIONALITY
      Updates all Modules installed for a given Repository providing some options to control
    .LINK
      https://github.com/DEberhardt/TeamsFunctions/tree/main/docs/Update-InstalledModule.md
    .LINK
      https://github.com/DEberhardt/TeamsFunctions/tree/main/docs/Limit-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 = 'Low')]
  [Alias('uim')]
  [OutputType([System.Void])]
  param (
    [Parameter(HelpMessage = 'Modules will be updated')]
    [System.Collections.Generic.List[object]]$Name = @(),

    [Parameter(HelpMessage = 'Excluded Modules will not be updated')]
    [System.Collections.Generic.List[object]]$Exclude = @(),

    [Parameter()]
    [switch]$AllowPrerelease,

    [parameter()]
    [switch]$SkipMajorVersion,

    [Parameter()]
    [ValidateSet('CurrentUser', 'AllUsers')]
    [String]$Scope = 'CurrentUser',

    [Parameter()]
    [String]$Repository = 'PSGallery',

    [Parameter()]
    [System.Management.Automation.PSCredential]$Credential = [System.Management.Automation.PSCredential]::Empty
  )

  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' }

    # Testing for Administrator
    if ( $Scope -eq 'AllUsers' ) {
      function Test-Administrator {
        $user = [Security.Principal.WindowsIdentity]::GetCurrent();
        (New-Object Security.Principal.WindowsPrincipal $user).IsInRole([Security.Principal.WindowsBuiltinRole]::Administrator)
      }
      # Check for Admin privileges
      If (! (Test-Administrator)) {
        throw "No Administrative privileges. To install in the 'AllUsers' Scope, please run this script as Administrator"
      }
    }

    # Setting Exclusions
    Write-Verbose "Excluding Modules: $($Exclude -join ', ')"

    # 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
    if ( $PSBoundParameters.ContainsKey('Exclude') ) {
      $CurrentModules = $CurrentModules | Where-Object Name -NotMatch "^$($Exclude -join '|')"
    }

    #region Confirm scope
    $Count = $(if ($CurrentModules.GetType().BaseType.Name -eq 'Array') { $CurrentModules.Count } else { 1 })
    Write-Verbose -Message "Modules found to process: $Count"
    if ( $Count -gt 10 ) {
      $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: $(if ( $Exclude ) { $Exclude -join ', ' } else { 'none' } ) | Proceed?"
      $result = $host.ui.PromptForChoice($title, $message, $options, 0)
      switch ($result) {
        0 { }
        1 { $ConfirmRequired = $true }
        2 { break }
      }
    }
    #endregion


    # Preparing Splatting parameters
    $FindModuleParams = @{
      ErrorAction = 'Stop'
      Repository  = $Repository
    }
    $UpdateModuleParams = @{
      ErrorAction   = 'Stop'
      AcceptLicense = $true
      Force         = $true
      Scope         = $Scope
    }
    if ( $PSBoundParameters.ContainsKey('AllowPreRelease') ) {
      $FindModuleParams.AllowPreRelease = $UpdateModuleParams.AllowPreRelease = $true
    }
    if ( $PSBoundParameters.ContainsKey('Credential') ) {
      $FindModuleParams.Credential = $UpdateModuleParams.Credential = $Credential
    }
  }

  process {
    Write-Verbose -Message "[PROCESS] $($MyInvocation.MyCommand)"
    $CurrentModules | ForEach-Object {
      [System.Version]$ModuleVersion = ($_.Version -split '-')[0]
      #region Finding latest Module Version
      try {
        Write-Verbose "$($_.Name) - Checking Repository '$Repository' for latest version"
        $OnlineModule = Find-Module -Name $_.Name @FindModuleParams
        # Finding most recent Minor Version if SkipMajorVersion is used.
        if ( $SkipMajorVersion.IsPresent -and $OnlineModule.Version.Split('.')[0] -gt $_.Version.Split('.')[0] ) {
          Write-Warning "$($_.Name) - Found new major version! Online: $($OnlineModule.Version), Local: $($_.Version)"

          $MaximumVersion = New-Object -TypeName System.Version -ArgumentList ($($_.Version.Split('.')[0]), 999, 999)
          Write-Verbose "$($_.Name) - Checking Repository '$Repository' for latest minor version ($MaximumVersion)" -Verbose
          $OnlineModule = Find-Module -Name $_.Name @FindModuleParams -MaximumVersion $MaximumVersion
        }
        $OnlineModuleVersion = [System.Version]$($OnlineModule.Version -split '-')[0]
      }
      catch {
        Write-Error "Module $($_.Name) not found in gallery $_"
        $OnlineModule = $null
      }
      #endregion

      #Region Update
      $LocalModule = Get-Module -Name $_.Name -ListAvailable -Verbose:$false | Sort-Object Version -Descending | Select-Object -First 1
      Write-Verbose -Message "$($_.Name) - Online: $($OnlineModule.Version), Local: $($_.Version) $( if ($LocalModule) { "| $($LocalModule.Version)"})"
      if ( $LocalModule.Version -gt $ModuleVersion ) {
        Write-Verbose -Message "$($_.Name) - Module is present in higher version: ($($LocalModule.Version))"
      }
      elseif ( $OnlineModuleVersion -gt $ModuleVersion -and $OnlineModuleVersion -gt $LocalModule.Version ) {
        # Removing Module if Loaded
        $Loaded = Get-Module -Name $_.Name
        if ( $Loaded ) {
          Write-Verbose -Message "$($_.Name) - Module loaded; Removing Module"
          Remove-Module -Name $_.Name -Force -Verbose:$false
        }

        # Updating Module
        try {
          Write-Information "INFO: $($_.Name) - Updating Module from $($_.Version) to $($OnlineModule.Version)"
          if ( $PSBoundParameters.ContainsKey('WhatIf') ) { $UpdateModuleParams.WhatIf = $True }
          if ( $ConfirmRequired -or $PSBoundParameters.ContainsKey('Confirm') ) { $UpdateModuleParams.Confirm = $True }
          #TEST This may work and actually fail if updated...
          #$UpdateModuleParams.RequiredVersion = $OnlineModuleVersion
          $UpdateModuleParams.RequiredVersion = $OnlineModule.Version
          Update-Module -Name $_.Name @UpdateModuleParams
        }
        catch {
          Write-Error "$($_.Name) failed: $_ "
          continue
        }

        # Importing module if it was loaded before
        if ( $Loaded ) {
          Write-Verbose -Message "$($_.Name) - Importing Module"
          Import-Module -Name $_.Name -Verbose:$false -ErrorAction SilentlyContinue
        }
      }
      elseif ($null -ne $OnlineModule) {
        Write-Verbose -Message "$($_.Name) - Module is up to date: $($_.Version)"
      }
      #endregion

    }
  } #process

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

  } #end
} # Update-InstalledModule