Public/Update-NSPModule.ps1

function Update-NSPModule {
    <#
    .SYNOPSIS
        Updates Gallery modules and optionally removes older installed versions.
    .DESCRIPTION
        Accepts names or objects with a Name property, including Get-Module -ListAvailable
        and Get-InstalledModule output. Each distinct name is processed once per pipeline.
        Installs the latest stable PSGallery version unless an equal or newer version is
        already installed in the selected scope. Does not import or unload modules.
        RemoveOld deletes version folders only after verifying the retained installation.
        Loaded copies, flat layouts, links and nonstandard module locations are preserved.
        Removing old versions can affect scripts that require an exact older version.
        WhatIf queries the Gallery and reads local manifests, but changes nothing.
    .PARAMETER Name
        Exact module names, strings from the pipeline, or piped objects with a Name property.
        Wildcards are not accepted here; expand them with Get-Module first.
    .PARAMETER Scope
        CurrentUser (default) or AllUsers (requires elevation to install/remove machine copies).
    .PARAMETER RemoveOld
        Removes versions below the retained version in both standard edition folders.
        CurrentUser cleans the current user's folders only. AllUsers also cleans older
        copies in the current user's folders, never other users' profiles.
    .PARAMETER SkipPublisherCheck
        Passes SkipPublisherCheck to Install-Module when explicitly requested.
    .EXAMPLE
        Update-NSPModule -Name NSP.Console -RemoveOld -WhatIf
    .EXAMPLE
        Get-Module NSP.* -ListAvailable | Update-NSPModule -Scope AllUsers -RemoveOld
    .EXAMPLE
        'NSP.Console', 'NSP.Bootstrap' | Update-NSPModule
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
    param(
        [Parameter(Mandatory, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [ValidatePattern('^[A-Za-z0-9_][A-Za-z0-9_.-]*$')]
        [string[]]$Name,
        [ValidateSet('CurrentUser', 'AllUsers')][string]$Scope = 'CurrentUser',
        [switch]$RemoveOld,
        [switch]$SkipPublisherCheck
    )
    begin {
        $seen = @{}
        $userRoots = @(Get-NSPModuleUpdateRoot -Scope CurrentUser)
        $installRoots = $userRoots
        if ($Scope -eq 'AllUsers') {
            $installRoots = @(Get-NSPModuleUpdateRoot -Scope AllUsers)
        }
        $cleanupRoots = @($installRoots)
        if ($Scope -eq 'AllUsers') { $cleanupRoots += $userRoots }

        # Inspect only exact standard roots and version folders. Never traverse links.
        $readCopies = {
            param([string[]]$Roots, [string]$ModuleName)
            foreach ($root in $Roots) {
                $base = Join-Path $root $ModuleName
                if (-not (Test-Path -LiteralPath $base -PathType Container)) { continue }
                $linked = $false
                for ($ancestor = Get-Item -LiteralPath $base -Force -ErrorAction Stop;
                     $null -ne $ancestor; $ancestor = $ancestor.Parent) {
                    if ($ancestor.Attributes -band [IO.FileAttributes]::ReparsePoint) { $linked = $true; break }
                }
                if ($linked) { throw "Refusing to update linked module location: $base" }
                foreach ($directory in @(Get-ChildItem -LiteralPath $base -Directory -Force -ErrorAction Stop)) {
                    $version = $null
                    if (-not [version]::TryParse($directory.Name, [ref]$version)) { continue }
                    if ($directory.Attributes -band [IO.FileAttributes]::ReparsePoint) {
                        throw "Refusing to update linked version folder: $($directory.FullName)"
                    }
                    $manifest = Join-Path $directory.FullName "$ModuleName.psd1"
                    if (-not (Test-Path -LiteralPath $manifest -PathType Leaf)) { continue }
                    if ((Get-Item -LiteralPath $manifest -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) {
                        throw "Refusing to update linked manifest: $manifest"
                    }
                    $data = Import-PowerShellDataFile -LiteralPath $manifest -ErrorAction Stop
                    if ([version]$data.ModuleVersion -ne $version) { continue }
                    [pscustomobject]@{ Version = $version; Path = $directory.FullName }
                }
            }
        }
    }
    process {
        foreach ($moduleName in $Name) {
            if ($seen.ContainsKey($moduleName)) { continue }
            $seen[$moduleName] = $true
            try {
                # Matching Install-NSPModule: TLS 1.2 for the Gallery on Windows PowerShell.
                # Keep even this process-level setting untouched during WhatIf.
                if (-not $WhatIfPreference) {
                    [Net.ServicePointManager]::SecurityProtocol =
                        [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
                }
                $gallery = Find-Module -Name $moduleName -Repository PSGallery -ErrorAction Stop
                if (-not $gallery -or $gallery.Name -ne $moduleName) { throw "No exact PSGallery match for '$moduleName'." }
                $target = [version]$gallery.Version
                $installed = @(& $readCopies $installRoots $moduleName | Sort-Object Version -Descending)
                if ($installed.Count -and $installed[0].Version -ge $target) {
                    $target = $installed[0].Version
                } else {
                    if ($PSCmdlet.ShouldProcess($moduleName, "Install PSGallery version $target ($Scope)")) {
                        $parameters = @{
                            Name = $moduleName; RequiredVersion = $target.ToString(); Repository = 'PSGallery'
                            Scope = $Scope; Force = $true; ErrorAction = 'Stop'
                        }
                        if ($SkipPublisherCheck) { $parameters.SkipPublisherCheck = $true }
                        Install-Module @parameters
                        $installed = @(& $readCopies $installRoots $moduleName)
                        if (-not @($installed | Where-Object Version -eq $target).Count) {
                            throw "Cannot verify $moduleName $target in $Scope; no old versions removed."
                        }
                    } elseif (-not $WhatIfPreference) { continue }
                }
                if (-not $RemoveOld) { continue }
                # Do not strand an edition by deleting its only copy. Install-Module writes
                # to the calling edition's folder; preserve the other edition before cleanup.
                foreach ($root in $installRoots) {
                    $copies = @(& $readCopies @($root) $moduleName)
                    if (-not $copies.Count -or @($copies | Where-Object Version -ge $target).Count) { continue }
                    if ($target -ne [version]$gallery.Version) {
                        throw "Cannot replace older copies in $root with $target from PSGallery; no old versions removed."
                    }
                    if ($PSCmdlet.ShouldProcess($root, "Save $moduleName $target for this edition before cleanup")) {
                        Save-Module -Name $moduleName -RequiredVersion $target.ToString() -Repository PSGallery -Path $root -Force -ErrorAction Stop
                        if (-not @(& $readCopies @($root) $moduleName | Where-Object Version -ge $target).Count) {
                            throw "Cannot verify $moduleName $target in $root; no old versions removed."
                        }
                    } elseif (-not $WhatIfPreference) { continue }
                }
                $loadedPaths = @(Get-Module -Name $moduleName -All | ForEach-Object ModuleBase)
                foreach ($old in @(& $readCopies $cleanupRoots $moduleName | Where-Object Version -lt $target)) {
                    # A declined secondary save must also decline that folder's cleanup.
                    if (-not $WhatIfPreference) {
                        $root = Split-Path (Split-Path $old.Path -Parent) -Parent
                        if ($installRoots -contains $root -and -not @(& $readCopies @($root) $moduleName | Where-Object Version -ge $target).Count) { continue }
                    }
                    if ($loadedPaths -contains $old.Path) {
                        Write-Warning "Keeping loaded $moduleName $($old.Version): $($old.Path). Retry in a fresh session."
                        continue
                    }
                    if ($PSCmdlet.ShouldProcess($old.Path, "Remove $moduleName $($old.Version); retain $target")) {
                        Remove-Item -LiteralPath $old.Path -Recurse -Force -ErrorAction Stop
                    }
                }
            } catch {
                $PSCmdlet.WriteError($_)
            }
        }
    }
}