Public/InstalledModule/Update-InstalledModule.ps1

# Module: Orbit.Tools
# 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-PsResource -Name MyModule | Where Repository -eq 'MyRepo' | Update-PsResource" as an alternative
    .PARAMETER Credential
      PsCredential. If provided, attaches the Credential on calls to Find-Module and Update-PsResource 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
      NOTE: 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/Orbit/tree/main/docs/Orbit.Tools/Update-InstalledModule.md
    .LINK
      https://github.com/DEberhardt/Orbit/tree/main/docs/Orbit.Tools/Limit-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 = '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()]
    [Alias('Prerelease')]
    [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-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') }
    $DebugPreference = if (-not $PSBoundParameters['Debug']) { $PSCmdlet.SessionState.PSVariable.GetValue('DebugPreference') } else { 'Continue' }
    $InformationPreference = if ( $PSBoundParameters['InformationAction']) { $PSCmdlet.SessionState.PSVariable.GetValue('InformationAction') } else { 'Continue' }

    #region Helper functions
    # 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"
      }
    }
    #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
    # Setting Exclusions
    Write-Verbose "Excluding Modules: $($Exclude -join ', ')"

    # Preparing Query
    $GetInstalledPSResource = @{ ErrorAction = 'Stop' }
    if ( $PSBoundParameters['Name'] ) {
      Write-Verbose -Message "Querying Modules with Name(s): $($Name -join ', ')"
      $GetInstalledPSResource.Name = @($Name)
    }
    else {
      Write-Verbose -Message 'Querying all Modules'
    }

    # Querying installed Modules
    $CurrentModules = Get-PSResource @GetInstalledPSResource | Where-Object Repository -EQ $Repository

    if ( $PSBoundParameters['Exclude'] ) {
      $CurrentModules = $CurrentModules | Where-Object Name -NotMatch "^$($Exclude -join '|')"
    }
    #endregion

    #region Confirm scope
    if ( -not $CurrentModules ) { throw 'No Modules have been queried, please investigate with Get-PSResource or Get-Module' }
    $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


    #region Preparing Splatting parameters
    $FindModuleParams = @{
      ErrorAction = 'Stop'
      Repository  = $Repository
    }
    $PSResource = @{
      ErrorAction   = 'Stop'
      AcceptLicense = $true
      Force         = $true
      Scope         = $Scope
    }
    switch ( $PSBoundParameters ) {
      'Credential' { $FindModuleParams.Credential = $PSResource.Credential = $Credential }
      'WhatIf' { $PSResource.WhatIf = $true }
    }
    if ( $ConfirmRequired -or $PSBoundParameters['Confirm'] ) { $PSResource.Confirm = $True }
    if ( $PSBoundParameters['AllowPreRelease'] ) {
      $FindModuleParams.AllowPreRelease = $true
      $PSResource.Prerelease = $true
    }
    #endregion
  }

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

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

      #region Update
      $LocalModule = Get-Module -Name $ModuleName -ListAvailable -Verbose:$false | Sort-Object Version -Descending | Select-Object -First 1
      if ( $LocalModule ) {
        $LocalModuleVersion = if ( $LocalModule.Count -gt 1 ) { $LocalModule[0].Version } else { $LocalModule.Version }
      }
      else {
        $LocalModuleVersion = $null
      }
      Write-Verbose -Message "$ModuleName - Online: $($OnlineModule.Version), Local: $($_.Version) $( if ($LocalModuleVersion) { "| $($LocalModuleVersion)"})"
      if ( $LocalModuleVersion -eq $OnlineModuleVersion ) {
        Write-Verbose -Message "$ModuleName - Module is up to date: ($LocalModuleVersion)"
      }
      elseif ( $OnlineModuleVersion -gt $ModuleVersion -and $OnlineModuleVersion -gt $LocalModuleVersion ) {
        # Removing Module if Loaded
        $Loaded = Get-Module -Name $ModuleName
        if ( $Loaded ) {
          Write-Verbose -Message "$ModuleName - Module loaded; Removing Module"
          Remove-Module -Name $ModuleName -Force -Verbose:$false -ErrorAction SilentlyContinue
        }

        #region Updating Module
        $UpdateInstalled = $false
        Write-Information "INFO: $ModuleName - Updating Module from $($LocalModuleVersion) to $($OnlineModuleVersion)"
        try {
          $PSResource.Name = $ModuleName
          if ($PSBoundParameters['Debug'] -or $DebugPreference -eq 'Continue') {
            " Function: $($MyInvocation.MyCommand.Name) - PSResource:", ($PSResource | Format-Table -AutoSize | Out-String).Trim() | Write-Debug
            " NOTE: Version ($OnlineModuleVersion) is added later as -Version or -RequiredVersion" | Write-Debug
          }
          $PSResource.Version = $OnlineModuleVersion
          try {
            Update-PSResource @PSResource
            $UpdateInstalled = $true
          }
          catch {
            # Overcoming halting errors when Module cannot be installed due to a signing issue.
            #NOTE Parameter Force is not available, or we could add both Skip parameters to the command before execution
            [string]$ExceptionMessage = $_.Exception.Message
            if ( $_.Exception.Message -match '-SkipPublisherCheck' ) {
              Write-Warning -Message "$(($ExceptionMessage -split 'ensure')[0]) - Re-running with -SkipPublisherCheck"
              try {
                Update-PSResource @PSResource -SkipPublisherCheck
                $UpdateInstalled = $true
              }
              catch {
                throw $_
              }
            }
            else {
              throw $_
            }
          }
        }
        catch {
          Write-Error "Update of '$ModuleName' with Update-PSResource failed: $_ "
          continue
        }
        #endregion

        # Importing module if it was loaded before
        if ( $Loaded -and $UpdateInstalled ) {
          Write-Verbose -Message "$ModuleName - Importing Module"
          try {
            $null = Import-Module -Name $ModuleName -Verbose:$false -ErrorAction Stop
            Write-Verbose -Message "$ModuleName - Module re-imported"
          }
          catch {
            try {
              $null = Import-Module -Name $ModuleName -Verbose:$false -Force -ErrorAction Stop
              Write-Verbose -Message "$ModuleName - Module re-imported forcefully"
            }
            catch {
              Write-Verbose -Message "$ModuleName - Importing Module failed, please restart session to importing Module" -Verbose
            }
          }
        }
        #endregion
      }
      elseif ($null -ne $OnlineModule) {
        Write-Verbose -Message "$ModuleName - Module is up to date: $($_.Version)"
      }
      #endregion

    }
  } #process

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

  } #end
} # Update-InstalledModule