PSResourceHelpers.ps1
|
function Expand-PSResourcePattern { [CmdletBinding()] param( [string[]]$Pattern ) foreach ($entry in $Pattern) { foreach ($part in ($entry -split ',')) { $trimmed = $part.Trim() if ($trimmed) { $trimmed } } } } function Test-PSResourcePattern { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Value, [string[]]$Pattern ) if (-not $Pattern -or $Pattern.Count -eq 0) { return $true } foreach ($item in $Pattern) { if ($Value -like $item) { return $true } } return $false } function Get-PSResourceMetadata { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Directory ) $metadataPath = Join-Path $Directory 'PSGetModuleInfo.xml' if (-not (Test-Path -LiteralPath $metadataPath -PathType Leaf)) { return $null } try { Import-Clixml -LiteralPath $metadataPath -ErrorAction Stop } catch { Write-Verbose "Could not read PSResourceGet metadata from '$metadataPath': $_" return $null } } function ConvertTo-PSResourceVersion { [CmdletBinding()] param( [PSObject]$Resource, [string]$FallbackVersion ) $prerelease = [string]$Resource.Prerelease if (-not $prerelease) { $prerelease = [string]$Resource.AdditionalMetadata.Prerelease } $isPrerelease = $prerelease -or [string]$Resource.IsPrerelease -eq 'true' -or [string]$Resource.AdditionalMetadata.IsPrerelease -eq 'true' foreach ($text in @( [string]$Resource.AdditionalMetadata.NormalizedVersion [string]$Resource.NormalizedVersion [string]$Resource.Version $FallbackVersion )) { if (-not $text) { continue } # PSResourceInfo splits Version and Prerelease; serialized metadata may # instead carry the complete version in Version or NormalizedVersion. if ($prerelease -and ($text -split '\+', 2)[0] -notmatch '-') { $parts = $text -split '\+', 2 $text = "$($parts[0])-$prerelease" if ($parts.Count -gt 1) { $text += "+$($parts[1])" } } if ($text -notmatch '^(?<Core>[0-9]+(?:\.[0-9]+){1,3})(?:-(?<Label>[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$') { Write-Verbose "Could not parse PSResource version '$text'." continue } $label = $Matches.Label $core = $null if (-not [version]::TryParse($Matches.Core, [ref]$core)) { Write-Verbose "Could not parse PSResource numeric version '$text'." continue } if ($isPrerelease -and -not $label) { Write-Verbose "PSResource version '$text' is marked prerelease but has no prerelease label." continue } [PSCustomObject]@{ Version = $text NumericVersion = [version]::new($core.Major, $core.Minor, [Math]::Max(0, $core.Build), [Math]::Max(0, $core.Revision)) Prerelease = [string]$label IsPrerelease = [bool]$label } return } } function Compare-PSResourceVersion { [CmdletBinding()] param( [Parameter(Mandatory)] [PSObject]$Left, [Parameter(Mandatory)] [PSObject]$Right ) $comparison = $Left.NumericVersion.CompareTo($Right.NumericVersion) if ($comparison -ne 0) { return $comparison } if (-not $Left.IsPrerelease) { if ($Right.IsPrerelease) { return 1 } return 0 } if (-not $Right.IsPrerelease) { return -1 } $leftLabels = $Left.Prerelease.Split('.') $rightLabels = $Right.Prerelease.Split('.') for ($index = 0; $index -lt [Math]::Min($leftLabels.Count, $rightLabels.Count); $index++) { $leftLabel = $leftLabels[$index] $rightLabel = $rightLabels[$index] $leftNumeric = $leftLabel -match '^[0-9]+$' $rightNumeric = $rightLabel -match '^[0-9]+$' if ($leftNumeric -and $rightNumeric) { # Numeric identifiers are unbounded in SemVer, not Int32 values. $comparison = ([System.Numerics.BigInteger]::Parse($leftLabel)).CompareTo( [System.Numerics.BigInteger]::Parse($rightLabel)) } elseif ($leftNumeric) { return -1 } elseif ($rightNumeric) { return 1 } else { $comparison = [StringComparer]::OrdinalIgnoreCase.Compare($leftLabel, $rightLabel) } if ($comparison -ne 0) { return $comparison } } return $leftLabels.Count.CompareTo($rightLabels.Count) } function Get-InstalledPSResourceInfoInPath { <# .SYNOPSIS Gets the highest installed version and repository provenance for a module. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Path, [Parameter(Mandatory)] [string]$Name ) $moduleRoot = Join-Path $Path $Name if (-not (Test-Path -LiteralPath $moduleRoot -PathType Container)) { return $null } $candidates = [System.Collections.Generic.List[PSObject]]::new() foreach ($versionDirectory in Get-ChildItem -LiteralPath $moduleRoot -Directory -ErrorAction SilentlyContinue) { $manifestPath = Join-Path $versionDirectory.FullName "$Name.psd1" if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { continue } $metadata = Get-PSResourceMetadata -Directory $versionDirectory.FullName # The directory carries only the numeric version for prerelease saves. # Keep it as the fallback so dynamic manifests remain discoverable. $version = ConvertTo-PSResourceVersion -Resource $metadata -FallbackVersion $versionDirectory.Name if ($null -eq $version) { try { $manifest = Import-PowerShellDataFile -LiteralPath $manifestPath -ErrorAction Stop $version = ConvertTo-PSResourceVersion -Resource ([PSCustomObject]@{ Version = $manifest.ModuleVersion Prerelease = $manifest.PrivateData.PSData.Prerelease IsPrerelease = ([string]$metadata.IsPrerelease -eq 'true' -or [string]$metadata.AdditionalMetadata.IsPrerelease -eq 'true') }) } catch { Write-Verbose "Could not read module version from '$manifestPath': $_" } } if ($null -eq $version) { Write-Warning "Could not determine module version from '$versionDirectory' or '$manifestPath'; skipping this version." continue } $candidates.Add([PSCustomObject]@{ Name = $Name Version = $version.Version NumericVersion = $version.NumericVersion Prerelease = $version.Prerelease IsPrerelease = $version.IsPrerelease Repository = $metadata.Repository RepositorySourceLocation = $metadata.RepositorySourceLocation ManifestPath = $manifestPath }) } $directManifest = Join-Path $moduleRoot "$Name.psd1" if (Test-Path -LiteralPath $directManifest -PathType Leaf) { $metadata = Get-PSResourceMetadata -Directory $moduleRoot $version = ConvertTo-PSResourceVersion -Resource $metadata if ($null -eq $version) { try { $manifest = Import-PowerShellDataFile -LiteralPath $directManifest -ErrorAction Stop $version = ConvertTo-PSResourceVersion -Resource ([PSCustomObject]@{ Version = $manifest.ModuleVersion Prerelease = $manifest.PrivateData.PSData.Prerelease IsPrerelease = ([string]$metadata.IsPrerelease -eq 'true' -or [string]$metadata.AdditionalMetadata.IsPrerelease -eq 'true') }) } catch { Write-Verbose "Could not read module version from '$directManifest': $_" } } if ($null -ne $version) { $candidates.Add([PSCustomObject]@{ Name = $Name Version = $version.Version NumericVersion = $version.NumericVersion Prerelease = $version.Prerelease IsPrerelease = $version.IsPrerelease Repository = $metadata.Repository RepositorySourceLocation = $metadata.RepositorySourceLocation ManifestPath = $directManifest }) } else { Write-Warning "Could not determine module version from '$directManifest'; skipping this version." } } $highest = $null $recorded = $null foreach ($candidate in $candidates) { if ($null -eq $highest -or (Compare-PSResourceVersion $candidate $highest) -gt 0) { $highest = $candidate } if ($candidate.Repository -or $candidate.RepositorySourceLocation) { if ($null -eq $recorded -or (Compare-PSResourceVersion $candidate $recorded) -gt 0) { $recorded = $candidate } } } if ($null -eq $highest) { return $null } if (-not $highest.Repository -and -not $highest.RepositorySourceLocation -and $recorded) { $highest.Repository = $recorded.Repository $highest.RepositorySourceLocation = $recorded.RepositorySourceLocation } return $highest } function Get-InstalledPSResourceInPath { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Path, [string[]]$Name, [string[]]$Exclude ) if (-not (Test-Path -LiteralPath $Path -PathType Container)) { return } $namePatterns = @(Expand-PSResourcePattern $Name) $excludePatterns = @(Expand-PSResourcePattern $Exclude) Get-ChildItem -LiteralPath $Path -Directory -ErrorAction SilentlyContinue | ForEach-Object { $resourceName = $_.Name if (-not (Test-PSResourcePattern -Value $resourceName -Pattern $namePatterns)) { return } if ($excludePatterns.Count -gt 0 -and (Test-PSResourcePattern -Value $resourceName -Pattern $excludePatterns)) { return } Get-InstalledPSResourceInfoInPath -Path $Path -Name $resourceName } | Where-Object { $null -ne $_ } | Sort-Object Name } function Resolve-PSResourceRepository { [CmdletBinding()] param( [Parameter(Mandatory)] [PSObject]$Resource, [string]$Repository = 'PSGallery', [switch]$Override ) if ($Override) { return $Repository } if ($Resource.Repository) { return [string]$Resource.Repository } if ($Resource.RepositorySourceLocation) { try { $source = ([string]$Resource.RepositorySourceLocation).TrimEnd('/') $match = Get-PSResourceRepository -ErrorAction Stop | Where-Object { "$($_.Uri)".TrimEnd('/') -eq $source } | Select-Object -First 1 if ($match) { return [string]$match.Name } } catch { Write-Verbose "Could not resolve repository source '$($Resource.RepositorySourceLocation)': $_" } Write-Warning "Could not resolve recorded repository source '$($Resource.RepositorySourceLocation)' for '$($Resource.Name)'; skipping instead of falling back to '$Repository'." return $null } return $Repository } function Update-InstalledPSResource { <# .SYNOPSIS Update PowerShell resources deployed to a custom module path via Save-PSResource. .DESCRIPTION Enumerates modules discovered under the supplied path — as deployed by Save-PSResource — determines each module's highest installed version from the on-disk layout (<Path>/<Name>/<Version>/<Name>.psd1 for versioned layouts, or <Path>/<Name>/<Name>.psd1 for direct layouts), compares it to the latest version available in the given repository, and saves a newer version alongside the existing ones using Save-PSResource with the same root path. No Install-PSResource tracking or PSModulePath manipulation is required; this approach works correctly for modules deployed with Save-PSResource to any arbitrary directory. By default, each module is queried and updated from the repository recorded in the highest installed version's PSGetModuleInfo.xml. If only a recorded repository source URI is available, the registered repository with that URI is used. Modules without provenance fall back to PSGallery. An explicitly supplied -Repository overrides recorded provenance for every selected module. Prerelease versions are recovered from PSGetModuleInfo.xml, including NormalizedVersion and prerelease metadata. Only modules whose highest installed version is a prerelease include prereleases in repository queries. Prerelease labels are compared semantically (for example, beta.10 is newer than beta.2); a stable release is newer than a prerelease with the same numeric version. Stable installations stay on stable releases. Older versions can supply missing repository provenance, but not prerelease state. Use -Name and -Exclude wildcard filters to select managed modules and skip local/product-owned modules without repository lookup warnings. No-op when the path does not exist, when no selected modules are found, or when all selected modules are current. A repository lookup failure for one selected module is reported as a warning; other modules continue. .PARAMETER Path The module root directory to scan for installed PowerShell resources. This should be the same root directory that was passed to Save-PSResource when the modules were deployed. .PARAMETER Repository Explicit repository override. When supplied, all selected modules are queried and updated from this repository. When omitted, recorded metadata is honored and modules without provenance fall back to PSGallery. .PARAMETER Name Optional wildcard pattern(s) selecting module names to update. Accepts arrays and comma-separated values. .PARAMETER Exclude Optional wildcard pattern(s) excluding module names before repository lookup. Accepts arrays and comma-separated values. .EXAMPLE Update-InstalledPSResource -Path (Join-Path $HOME 'PowerShellModules') Checks each module's recorded repository (or PSGallery when absent) for newer versions and saves updates in place, retaining prerelease tracking for modules whose highest installed version is a prerelease. .EXAMPLE Update-InstalledPSResource -Path $env:PSModulePath.Split([IO.Path]::PathSeparator)[0] -WhatIf Shows which resources would be updated without making any changes. .EXAMPLE Update-InstalledPSResource -Path D:\PowerShell\Modules -Name 'Shmuelie.*' -Exclude '*.Local' Updates matching modules from their recorded repositories while skipping local modules before any repository lookup. .EXAMPLE Update-InstalledPSResource -Path D:\PowerShell\Modules -Repository PSGallery Explicitly overrides recorded provenance and queries every selected module from PSGallery. #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory, Position = 0)] [ValidateNotNullOrEmpty()] [string]$Path, [Parameter()] [ValidateNotNullOrEmpty()] [string]$Repository = 'PSGallery', [string[]]$Name, [string[]]$Exclude ) begin { $repositoryOverride = $PSBoundParameters.ContainsKey('Repository') } process { if (-not (Test-Path -LiteralPath $Path -PathType Container)) { Write-Verbose "Module path '$Path' does not exist. Nothing to update." return } $resolvedPath = (Resolve-Path -LiteralPath $Path).ProviderPath $resources = @(Get-InstalledPSResourceInPath -Path $resolvedPath -Name $Name -Exclude $Exclude) foreach ($resource in $resources) { $resourceName = $resource.Name $installedVersion = $resource.Version $selectedRepository = Resolve-PSResourceRepository ` -Resource $resource ` -Repository $Repository ` -Override:$repositoryOverride if (-not $selectedRepository) { continue } $latestRemote = $null try { $findParameters = @{ Name = $resourceName Repository = $selectedRepository ErrorAction = 'Stop' } if ($resource.IsPrerelease) { $findParameters.Prerelease = $true } foreach ($remote in Find-PSResource @findParameters) { $remoteVersion = ConvertTo-PSResourceVersion -Resource $remote if ($null -eq $remoteVersion) { Write-Warning "Could not determine version of '$resourceName' in repository '$selectedRepository'; skipping this result." continue } if (-not $resource.IsPrerelease -and $remoteVersion.IsPrerelease) { continue } if ($null -eq $latestRemote -or (Compare-PSResourceVersion $remoteVersion $latestRemote) -gt 0) { $latestRemote = $remoteVersion } } } catch { Write-Warning "Could not look up '$resourceName' in repository '$selectedRepository': $_" continue } if ($null -eq $latestRemote) { Write-Verbose "'$resourceName' was not found in repository '$selectedRepository'; skipping." continue } $remoteVersion = $latestRemote.Version if ((Compare-PSResourceVersion $resource $latestRemote) -ge 0) { Write-Verbose "'$resourceName' $installedVersion is already current (latest: $remoteVersion)." continue } if ($PSCmdlet.ShouldProcess($resourceName, "Update from $installedVersion to $remoteVersion from '$selectedRepository' in '$resolvedPath'")) { Save-PSResource -Name $resourceName -Version $remoteVersion -Path $resolvedPath ` -Repository $selectedRepository -TrustRepository -IncludeXml -AcceptLicense ` -SkipDependencyCheck -ErrorAction Stop } } } } |