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-PSResourceProvenance {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Directory
    )

    $metadataPath = Join-Path $Directory 'PSGetModuleInfo.xml'
    if (-not (Test-Path -LiteralPath $metadataPath -PathType Leaf)) {
        return $null
    }

    try {
        $metadata = Import-Clixml -LiteralPath $metadataPath -ErrorAction Stop
        [PSCustomObject]@{
            Repository               = [string]$metadata.Repository
            RepositorySourceLocation = [string]$metadata.RepositorySourceLocation
        }
    }
    catch {
        Write-Verbose "Could not read PSResourceGet metadata from '$metadataPath': $_"
        return $null
    }
}

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 }

        # Save-PSResource's versioned layout already carries the authoritative
        # version in the directory name. Prefer it so dynamic manifests (which
        # Import-PowerShellDataFile cannot evaluate) remain discoverable.
        $version = $null
        try {
            $version = [version]$versionDirectory.Name
        }
        catch { }

        if ($null -eq $version) {
            try {
                $version = [version](Import-PowerShellDataFile -LiteralPath $manifestPath -ErrorAction Stop).ModuleVersion
            }
            catch {
                Write-Warning "Could not determine module version from '$versionDirectory' or '$manifestPath'; skipping this version."
                continue
            }
        }

        $provenance = Get-PSResourceProvenance -Directory $versionDirectory.FullName
        $candidates.Add([PSCustomObject]@{
            Name                     = $Name
            Version                  = $version
            Repository               = $provenance.Repository
            RepositorySourceLocation = $provenance.RepositorySourceLocation
            ManifestPath             = $manifestPath
        })
    }

    $directManifest = Join-Path $moduleRoot "$Name.psd1"
    if (Test-Path -LiteralPath $directManifest -PathType Leaf) {
        try {
            $version = [version](Import-PowerShellDataFile -LiteralPath $directManifest -ErrorAction Stop).ModuleVersion
            $provenance = Get-PSResourceProvenance -Directory $moduleRoot
            $candidates.Add([PSCustomObject]@{
                Name                     = $Name
                Version                  = $version
                Repository               = $provenance.Repository
                RepositorySourceLocation = $provenance.RepositorySourceLocation
                ManifestPath             = $directManifest
            })
        }
        catch {
            Write-Verbose "Could not read module version from '$directManifest': $_"
        }
    }

    $ordered = @($candidates |
        Sort-Object Version -Descending |
        Select-Object)
    if ($ordered.Count -eq 0) { return $null }

    $highest = $ordered[0]
    if (-not $highest.Repository -and -not $highest.RepositorySourceLocation) {
        $recorded = $ordered |
            Where-Object { $_.Repository -or $_.RepositorySourceLocation } |
            Select-Object -First 1
        if ($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.
 
        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 PSGallery for newer versions of every module found under the custom
        module path and saves updates in place.
    .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 {
                $latestRemote = @(Find-PSResource -Name $resourceName -Repository $selectedRepository -ErrorAction Stop) |
                    Sort-Object { [version]$_.Version } -Descending |
                    Select-Object -First 1
            }
            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 = [version]$latestRemote.Version.ToString()
            if ($installedVersion -ge $remoteVersion) {
                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.ToString() -Path $resolvedPath `
                    -Repository $selectedRepository -TrustRepository -IncludeXml -AcceptLicense `
                    -SkipDependencyCheck -ErrorAction Stop
            }
        }
    }
}