Public/Get-ModulesWithUpdate.ps1
function Get-ModulesWithUpdate { <# .SYNOPSIS Get a list of installed PowerShell modules that have updates available in their source repository. .DESCRIPTION This function retrieves a list of installed PowerShell modules and checks for updates available in their source repository (e.g.: PowerShell Gallery or MAR). If a pre-release version is installed, it checks the repository for a newer pre-release version or an equivalent (or higher) stable version. Otherwise, it only checks for stable updates. .PARAMETER Name The module name or list of module names to check for updates. Wildcards and arrays are allowed. All modules ('*') are checked by default. .PARAMETER PassThru Display console output while returning module objects to the pipeline. When specified, the function will show available updates in the console and also return module objects for further processing. .EXAMPLE Get-ModulesWithUpdate This command retrieves all installed PowerShell modules and checks for updates in their source repository. .EXAMPLE Get-ModulesWithUpdate -PassThru This command checks all installed modules for updates. It returns PSModuleInfo objects to the pipeline and displays console output about available updates. .EXAMPLE Get-ModulesWithUpdate -Name 'Pester', 'PSScriptAnalyzer' -PassThru | Update-PSResource This command checks specific modules for updates, displays console output about available updates, and pipes the results to Update-PSResource. .NOTES This function uses Microsoft.PowerShell.PSResourceGet cmdlets for improved performance and functionality over the PowerShellGet module's cmdlets. The required module will be automatically installed if not present. Scope Priority: The function prioritizes CurrentUser scope modules over AllUsers scope modules, which matches PowerShell's own behavior for importing or updating modules. When a module is installed in both scopes, it checks for updates against the CurrentUser version since that's what PowerShell would load by default. Use -Verbose to see which version and scope is being used for each module. To Do: - Batch and "paginate" online checks to speed up. Find-PSResource can return multiple results in one request. - Add parameter for specifying specific repositories to check. #> [CmdletBinding()] [OutputType([PSCustomObject])] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Making it pretty.')] param( # List of modules to check for updates. This parameter accepts wildcards and checks all modules by default. [Parameter( Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName, HelpMessage = 'Enter a module name or an array of names. Wildcards are allowed.' )] [ValidateNotNullOrEmpty()] [SupportsWildcards()] [System.Collections.Generic.List[string]] $Name = @('*'), # Display console output while returning module objects to the pipeline. [Parameter()] [switch] $PassThru ) begin { # Check for required modules and attempt to install if missing. try { if (-not (Get-Command -Name 'Get-InstalledPSResource' -ErrorAction SilentlyContinue)) { Write-Information "The required module 'Microsoft.PowerShell.PSResourceGet' was not found. Attempting to install..." -InformationAction Continue try { Install-Module -Name 'Microsoft.PowerShell.PSResourceGet' -Scope CurrentUser -AllowClobber -Force -Verbose:$false Write-Information "Successfully installed 'Microsoft.PowerShell.PSResourceGet'." -InformationAction Continue Import-Module -Name 'Microsoft.PowerShell.PSResourceGet' -Force -Verbose:$false } catch { throw "Failed to install and import the required 'Microsoft.PowerShell.PSResourceGet' module. Please install it manually using: Install-Module -Name 'Microsoft.PowerShell.PSResourceGet'" } } else { # Import the module if it's not already imported. if (-not (Get-Module -Name 'Microsoft.PowerShell.PSResourceGet')) { Import-Module -Name 'Microsoft.PowerShell.PSResourceGet' -Force -Verbose:$false } } } catch { throw "Error checking for required module 'Microsoft.PowerShell.PSResourceGet': $_" } # Get the AllUsers scope path[s] from PSModulePath. (Re-test on non-Windows platforms.) Ignores blank entries. [string[]] $AllUsersModulePaths = @( [System.Environment]::GetEnvironmentVariable('PSModulePath', [System.EnvironmentVariableTarget]::Machine) -split [System.IO.Path]::PathSeparator | Where-Object { $_ } ) Write-Debug "AllUsers module paths: $($AllUsersModulePaths -join '; ')" function Test-IsAllUsersPath { <# .SYNOPSIS Check if a module installation path is in an AllUsers scope. .DESCRIPTION This function checks if the provided module installation path is located in an AllUsers scope by comparing it against the known AllUsers module paths. It returns true if the path starts with any of the AllUsers paths, indicating that the module is installed for all users on the system. .PARAMETER ModuleLocation The ModuleLocation (installation path) of the module to check. .OUTPUTS [bool] Returns true if the module is installed in an AllUsers scope, otherwise false. .EXAMPLE Test-IsAllUsersPath -ModuleLocation 'C:\Program Files\WindowsPowerShell\Modules\MyModule' This command checks if the specified module location is in an AllUsers scope and returns true or false. #> param ( # The ModuleLocation (installation path) of the module to check. [string] $ModuleLocation ) # Loop through all AllUsers paths and check if the module location starts with any of them (case-insensitive). foreach ($Path in $AllUsersModulePaths) { if ($ModuleLocation.StartsWith($Path, [System.StringComparison]::OrdinalIgnoreCase)) { return $true } } return $false } # end Test-IsAllUsersPath function function Select-ModuleVersion { <# .SYNOPSIS Select the appropriate module version from a group of installations of the same module. .DESCRIPTION This function takes a group of a module with multiple installations and selects the relevant module version. .PARAMETER ModuleGroup A group object containing multiple installations of the same module. .OUTPUTS [PSObject] Returns the selected module version object. .EXAMPLE $ModuleGroup = Get-InstalledPSResource -Name 'MyModule' | Group-Object -Property 'Name' $SelectedModule = Select-ModuleVersion -ModuleGroup $ModuleGroup This command selects the best version of 'MyModule' from the group of installations, prioritizing CurrentUser scope over AllUsers scope. #> param ( # A group object containing multiple installations of the same module. $ModuleGroup ) # For each module name, prioritize CurrentUser scope over AllUsers scope. $CurrentUserModules = @() $AllUsersModules = @() # Categorize each installed instance of the module by scope (installation location). foreach ($Module in $ModuleGroup.Group) { if (Test-IsAllUsersPath $Module.InstalledLocation) { $AllUsersModules += $Module } else { $CurrentUserModules += $Module } } if ($CurrentUserModules) { # If module exists in CurrentUser scope, use the highest version from CurrentUser. $SelectedModule = $CurrentUserModules | Sort-Object Version -Descending | Select-Object -First 1 if ($AllUsersModules) { $HighestAllUsers = $AllUsersModules | Sort-Object Version -Descending | Select-Object -First 1 "`n`tModule : $($ModuleGroup.Name)`n`tCurrentUser: $($SelectedModule.Version)`n`tAllUsers: $($HighestAllUsers.Version)" | Write-Debug } else { "`n`tModule : $($ModuleGroup.Name)`n`tCurrentUser: $($SelectedModule.Version)" | Write-Debug } return $SelectedModule } elseif ($AllUsersModules) { # If only in AllUsers scope, use the highest version from AllUsers. $SelectedModule = $AllUsersModules | Sort-Object Version -Descending | Select-Object -First 1 Write-Verbose " Module '$($ModuleGroup.Name)': Using AllUsers version $($SelectedModule.Version) (no CurrentUser installation found)." return $SelectedModule } else { # Fallback: If we can't determine scope, just use the highest version. $SelectedModule = $ModuleGroup.Group | Sort-Object Version -Descending | Select-Object -First 1 Write-Verbose " Module '$($ModuleGroup.Name)': Using highest version $($SelectedModule.Version) (scope undetermined)." return $SelectedModule } } # end SelectBestModuleVersion function function Test-IsModulePrerelease { <# .SYNOPSIS Check if a a prerelease version of a module is installed. .DESCRIPTION This function checks if an installed module version is a prerelease version. .PARAMETER Module The module object to check. .PARAMETER VersionString The version string of the module. .OUTPUTS [bool] Returns true if the module is a prerelease version; otherwise, false. .EXAMPLE $Module = Get-InstalledPSResource -Name 'MyModule' | Select-Object -First 1 $IsPrerelease = Test-IsModulePrerelease -Module $Module -VersionString $Module.Version.ToString() This command checks if the specified module is a prerelease version and returns true or false. #> param ($Module, $VersionString) # Return true if the IsPrerelease property is true or if the Prerelease property is not empty # or if the version string contains a prerelease identifier. if ($Module.PSObject.Properties['IsPrerelease']) { return $Module.IsPrerelease -or (-not [string]::IsNullOrWhiteSpace($Module.Prerelease)) -or ($VersionString -match 'alpha|beta|prerelease|preview|rc') #-or ($VersionString -match '-') } else { # Return true if the IsPrerelease property is true or if the Prerelease property is not empty. return $Module.IsPrerelease -or (-not [string]::IsNullOrWhiteSpace($Module.Prerelease)) } } # end IsModulePrerelease function # Initialize a list to hold modules with updates. [System.Collections.Generic.List[PSCustomObject]] $ModulesWithUpdates = @() } # end begin block process { #region Get installed modules try { # Optimize the search: if no wildcards are used, search for each module specifically $HasWildcards = $Name | Where-Object { $_ -match '[*?]' } if ($HasWildcards) { Write-Host "Searching for installed modules matching patterns: $($Name -join ', ')" -ForegroundColor Cyan # Use a wildcard search to get modules and determine if they are installed in an AllUsers or CurrentUser location. [System.Collections.Generic.List[PSObject]] $Modules = Get-InstalledPSResource -Name $Name -Verbose:$false | Where-Object { $_.Type -eq 'Module' } | Group-Object -Property 'Name' | ForEach-Object { Select-ModuleVersion $_ } | Sort-Object Name } else { # Check each individually for better performance when not using wildcards. Write-Host "Searching for specific installed modules: $($Name -join ', ')" -ForegroundColor Cyan $AllModules = @() foreach ($ModuleName in $Name) { Write-Debug "Looking for installed module: $ModuleName" $ModuleResults = Get-InstalledPSResource -Name $ModuleName -ErrorAction SilentlyContinue -Verbose:$false | Where-Object { $_.Type -eq 'Module' } if ($ModuleResults) { Write-Verbose "Found $($ModuleResults.Count) installation(s) of '$ModuleName'" $AllModules += $ModuleResults } else { Write-Verbose "'$ModuleName' was not found in installed modules." } } # Group by module name only and select the best version with scope priority [System.Collections.Generic.List[PSObject]] $Modules = $AllModules | Group-Object Name | ForEach-Object { Select-ModuleVersion $_ } | Sort-Object Name } # end if-else for wildcard check vs explicit name check } catch { throw $_ } # Stop if no modules were found. if (-not $Modules -or $Modules.Count -eq 0) { Write-Warning 'No matching modules were found.' return } else { Write-Host "Found $($Modules.Count) installed modules." -ForegroundColor Cyan } #endregion Get installed modules #region Check for updates Write-Host 'Checking the source repository for newer versions of the modules...' -ForegroundColor Cyan foreach ($Module in $Modules) { #region Get module information # Check installed [version] and prerelease status [boolean]. $InstalledVersion = $Module.Version $InstalledVersionString = $InstalledVersion.ToString() $IsPrerelease = Test-IsModulePrerelease $Module $InstalledVersionString # Determine which repository to check based on the module's data. $Repository = $null if ($Module.Repository -and $Module.Repository -ne 'Unknown') { $Repository = $Module.Repository } else { Write-Verbose "No repository information found for '$($Module.Name)', checking all registered repositories." } #endregion Get module information try { # Get the latest online version from the module's original source repository. $OnlineModule = $null try { if ($Repository) { Write-Verbose "Searching repository '$Repository' for module '$($Module.Name)' with Prerelease:$IsPrerelease" $OnlineModule = Find-PSResource -Name $Module.Name -Repository $Repository -Prerelease:$IsPrerelease } else { Write-Verbose "Searching all repositories for module '$($Module.Name)' with Prerelease:$IsPrerelease" $OnlineModule = Find-PSResource -Name $Module.Name -Prerelease:$IsPrerelease } } catch { # If the specific repository fails, try without specifying repository. Write-Verbose "Repository-specific search failed for module '$($Module.Name)', trying all repositories with Prerelease:$IsPrerelease" try { $OnlineModule = Find-PSResource -Name $Module.Name -Prerelease:$IsPrerelease } catch { Write-Verbose "All-repository search also failed for '$($Module.Name)'" } # If still no result, re-throw the original error to be handled by the outer catch. if (-not $OnlineModule) { throw } } # If no online module was found, skip to the next module. if (-not $OnlineModule) { Write-Warning "No online version found for module '$($Module.Name)' (Prerelease:$IsPrerelease)." continue } $OnlineVersion = $OnlineModule.Version Write-Debug "$($Module.Name) $InstalledVersion (Installed)" Write-Debug "$($Module.Name) $OnlineVersion (Online)`n" <# Normalize version objects for accurate comparison: PowerShell treats [version]"1.0.0" (Revision=-1) differently from [version]"1.0.0.0" (Revision=0). We need to normalize them by ensuring both have explicit 4-part notation. #> # Extract the base version without a prerelease suffix (handle both string and version object types). $OnlineVersionString = $OnlineVersion.ToString() $InstalledVersionString = $InstalledVersion.ToString() $OnlineVersionBase = if ($OnlineVersionString -match '^(\d+\.\d+\.\d+(?:\.\d+)?)-') { $matches[1] } else { # If it's already a version object without prerelease, just use its string representation. $OnlineVersionString } $InstalledVersionBase = if ($InstalledVersionString -match '^(\d+\.\d+\.\d+(?:\.\d+)?)-') { $matches[1] } else { # If it's already a version object without prerelease, just use its string representation. $InstalledVersionString } # .NET Version treats -1 as "unspecified", so we need to normalize with all 4 components explicitly defined. try { $OnlineVersionObj = [version]$OnlineVersionBase $InstalledVersionObj = [version]$InstalledVersionBase # Build normalized version strings with proper handling of unspecified components. $OnlineBuild = if ($OnlineVersionObj.Build -eq -1) { 0 } else { $OnlineVersionObj.Build } $OnlineRevision = if ($OnlineVersionObj.Revision -eq -1) { 0 } else { $OnlineVersionObj.Revision } $InstalledBuild = if ($InstalledVersionObj.Build -eq -1) { 0 } else { $InstalledVersionObj.Build } $InstalledRevision = if ($InstalledVersionObj.Revision -eq -1) { 0 } else { $InstalledVersionObj.Revision } # Create fully normalized 4-part version objects for accurate comparison. $OnlineVersionNormalized = [version]"$($OnlineVersionObj.Major).$($OnlineVersionObj.Minor).$OnlineBuild.$OnlineRevision" $InstalledVersionNormalized = [version]"$($InstalledVersionObj.Major).$($InstalledVersionObj.Minor).$InstalledBuild.$InstalledRevision" } catch { Write-Warning "Error parsing version for module '$($Module.Name)': Installed='$InstalledVersionString', Online='$OnlineVersionString'. Error: $($_.Exception.Message)" continue } # Check if the online module is a prerelease version. $OnlineIsPrerelease = Test-IsModulePrerelease $OnlineModule $OnlineVersion.ToString() Write-Debug "Normalized versions: Installed=$InstalledVersionNormalized, Online=$OnlineVersionNormalized" Write-Debug "Prerelease status: Installed=$IsPrerelease, Online=$OnlineIsPrerelease" # If a newer version is available, create a custom object with the PSPreworkout.ModuleInfo type. if ( $OnlineVersionNormalized -gt $InstalledVersionNormalized -or ( # Allow updates from prerelease to stable versions with same or higher base version. This allows upgrading from "1.0.0-beta" to "1.0.0" (stable release). ($OnlineVersionNormalized -ge $InstalledVersionNormalized) -and ($IsPrerelease -and -not $OnlineIsPrerelease) ) ) { # Create a custom object with PSPreworkout.ModuleInfo type $ModuleInfo = [PSCustomObject]@{ PSTypeName = 'PSPreworkout.ModuleInfo' Name = $Module.Name Version = $InstalledVersion Repository = $Module.Repository Description = $Module.Description Author = $Module.Author CompanyName = $Module.CompanyName Copyright = $Module.Copyright PublishedDate = $Module.PublishedDate InstalledDate = $Module.InstalledDate UpdateAvailable = $true OnlineVersion = $OnlineVersion IsInstalledPrerelease = $IsPrerelease IsOnlinePrerelease = $OnlineIsPrerelease } # Add the module to the list of modules with updates. $ModulesWithUpdates.Add($ModuleInfo) | Out-Null # Display module information when PassThru is specified. if ($PassThru) { $InstalledVersionDisplay = if ($IsPrerelease) { "$($InstalledVersion) (prerelease)" } else { $InstalledVersion } $OnlineVersionDisplay = if ($OnlineIsPrerelease) { "$($OnlineVersion) (prerelease)" } else { $OnlineVersion } Write-Host "$($Module.Name): $InstalledVersionDisplay → $OnlineVersionDisplay 🆕" -ForegroundColor Green } } } catch { # Handle different types of errors more specifically. $ErrorMessage = $_.Exception.Message if ($ErrorMessage -match 'could not be found') { if ($Repository) { Write-Warning "Module '$($Module.Name)' was not found in repository '$Repository'. It may have been installed manually or from a different source." } else { Write-Warning "Module '$($Module.Name)' was not found in any registered repositories. It may have been installed manually or from an unregistered source." } } elseif ($ErrorMessage -match 'null-valued expression') { Write-Warning "Module '$($Module.Name)' search returned null results. This may indicate a repository connectivity issue or the module may not be available in online repositories." } else { Write-Warning "Error checking for updates to module '$($Module.Name)': $ErrorMessage" } } } #endregion Check for updates if (-not $ModulesWithUpdates -or $ModulesWithUpdates.Count -eq 0) { Write-Host 'No module updates found in the online repository.' return } else { # Return the list of modules with updates to the host or the pipeline. Write-Host "Found $($ModulesWithUpdates.Count) modules with updates available." -ForegroundColor Yellow $ModulesWithUpdates } } # end process block } |