Public/Modules-Maintenance.ps1

# Nebula.Tools: Modules Maintenance =================================================================================================================

function Find-ModulesUpdates {
    <#
    .SYNOPSIS
        Checks installed modules for available updates using a chosen provider.
    .DESCRIPTION
        Uses one of:
          - PSResourceGet (v3): Get-InstalledPSResource + Find-PSResource
          - PowerShellGet (v2): Get-InstalledModule + Find-Module
        'Auto' uses PSResourceGet if available, otherwise falls back to PowerShellGet v2.
        Supports scope filtering.
    .PARAMETER Scope
        User | System | All | Unknown (filter results by install location).
    .PARAMETER Provider
        Auto (default) | PSResourceGet | PowerShellGet
    .PARAMETER IncludePrerelease
        Consider pre-release versions.
    .EXAMPLE
        Find-ModulesUpdates -Scope User -Provider PSResourceGet
    #>

    [CmdletBinding()]
    param(
        [ValidateSet('User','System','All','Unknown')]
        [string]$Scope = 'All',
        [ValidateSet('Auto','PSResourceGet','PowerShellGet')]
        [string]$Provider = 'Auto',
        [switch]$IncludePrerelease
    )

    function Get-ModuleScope([string]$installedLocation) {
        if ($installedLocation -imatch 'Program Files|ProgramData') { return 'System' }
        elseif ($installedLocation -imatch '\\Users\\')             { return 'User' }
        else                                                        { return 'Unknown' }
    }

    $hasPSRG = [bool](Get-Module -ListAvailable Microsoft.PowerShell.PSResourceGet)
    if ($Provider -eq 'Auto') { $Provider = if ($hasPSRG) { 'PSResourceGet' } else { 'PowerShellGet' } }

    $installed = @()
    if ($Provider -eq 'PSResourceGet') {
        Import-Module Microsoft.PowerShell.PSResourceGet -ErrorAction SilentlyContinue | Out-Null
        $installed = Get-InstalledPSResource -ErrorAction SilentlyContinue
    } else {
        $installed = Get-InstalledModule -ErrorAction SilentlyContinue
    }

    if (-not $installed) { return @() }

    $total = $installed.Count
    $idx = 0
    $updates = @()

    foreach ($mod in $installed) {
        $idx++
        Write-Progress -Activity "Checking for module updates..." `
            -Status "Checking $($mod.Name) ($idx of $total)" `
            -PercentComplete (($idx/$total)*100)

        try {
            if ($Provider -eq 'PSResourceGet') {
                $findParams = @{ Name = $mod.Name; ErrorAction = 'Stop' }
                if ($IncludePrerelease) { $findParams['Prerelease'] = $true }
                $latest = Find-PSResource @findParams
                if ($latest -and ([version]$latest.Version -gt [version]$mod.Version)) {
                    $scope = Get-ModuleScope $mod.InstalledLocation
                    if ($Scope -eq 'All' -or $Scope -eq $scope) {
                        $updates += [PSCustomObject]@{
                            Name             = $mod.Name
                            InstalledVersion = $mod.Version
                            LatestVersion    = $latest.Version
                            Scope            = $scope
                            # Provider = 'PSResourceGet'
                            # Repository = $latest.Repository
                        }
                    }
                }
            } else {
                $findParams = @{ Name = $mod.Name; ErrorAction = 'Stop' }
                if ($IncludePrerelease) { $findParams['AllowPrerelease'] = $true }
                $latest = Find-Module @findParams
                if ($latest -and ([version]$latest.Version -gt [version]$mod.Version)) {
                    $scope = Get-ModuleScope $mod.InstalledLocation
                    if ($Scope -eq 'All' -or $Scope -eq $scope) {
                        $updates += [PSCustomObject]@{
                            Name             = $mod.Name
                            InstalledVersion = $mod.Version
                            LatestVersion    = $latest.Version
                            Scope            = $scope
                            # Provider = 'PowerShellGet'
                            # Repository = $latest.Repository
                        }
                    }
                }
            }
        } catch {
            Write-Warning "Could not check updates for '$($mod.Name)': $($_.Exception.Message)"
        }
    }

    Write-Progress -Activity "Checking for module updates..." -Completed
    return $updates
}

function Remove-OldModuleVersions {
    <#
    .SYNOPSIS
        Remove older versions of a module, using PSResourceGet first, then PowerShellGet v2, then unmanaged folder delete.
    .DESCRIPTION
        Given a module name, this command:
          Enumerates all available versions on disk (Get-Module -ListAvailable).
          Determines which versions are tracked by PSResourceGet (v3) and/or PowerShellGet v2.
          Keeps the N most recent versions (default 1) and removes the rest.
           - Uses Uninstall-PSResource when the version is known to PSResourceGet.
           - Else uses Uninstall-Module when known to PowerShellGet v2.
           - Else removes the module folder (unmanaged copy) as a last resort.
        Supports -WhatIf/-Confirm via ShouldProcess.
    .PARAMETER Name
        Target module name (e.g., 'PSAppDeployToolkit').
    .PARAMETER Keep
        How many latest versions to keep. Default: 1.
    .PARAMETER Force
        Force removal where supported.
    .EXAMPLE
        Remove-OldModuleVersions -Name 'PSAppDeployToolkit'
    .EXAMPLE
        Remove-OldModuleVersions -Name 'MicrosoftPlaces' -Keep 2 -WhatIf
    .NOTES
        - Running in an elevated session may be required to remove versions under Program Files/ProgramData.
        - This function does not install anything; it only removes older versions.
    #>


    [CmdletBinding(SupportsShouldProcess = $true)]
    param(
        [Parameter(Mandatory, Position = 0)]
        [ValidateNotNullOrEmpty()]
        [string]$Name,
        [ValidateRange(1, 99)]
        [int]$Keep = 1,
        [switch]$Force
    )

    function Write-Section([string]$Text) { Write-Information "`n$Text" -InformationAction Continue }

    # Detect PSResourceGet availability
    $hasPSRG = [bool](Get-Module -ListAvailable Microsoft.PowerShell.PSResourceGet)
    if ($hasPSRG) {
        try { Import-Module Microsoft.PowerShell.PSResourceGet -ErrorAction Stop | Out-Null } catch { $hasPSRG = $false }
    }

    # Gather all available versions (on disk)
    $available = Get-Module -ListAvailable -Name $Name | Sort-Object Version
    if (-not $available) {
        Write-Warning "No modules named '$Name' were found on this machine."
        return
    }

    # Gather inventories v3 and v2
    $rgInstalled = @()
    if ($hasPSRG) {
        try { $rgInstalled = Get-InstalledPSResource -Name $Name -AllVersions -ErrorAction Stop } catch { $rgInstalled = @() }
    }
    $v2Installed = @()
    try { $v2Installed = Get-InstalledModule -Name $Name -AllVersions -ErrorAction Stop } catch { $v2Installed = @() }

    # Build version ledger
    $ledger =
        $available |
        Group-Object Version |
        ForEach-Object {
            $verText = $_.Name
            $ver     = [version]$verText
            $paths   = $_.Group | Select-Object -ExpandProperty ModuleBase -Unique

            $isV3 = $false
            if ($rgInstalled) {
                $isV3 = $null -ne ($rgInstalled | Where-Object { $_.Version -eq $verText })
            }
            $isV2 = $false
            if ($v2Installed) {
                $isV2 = $null -ne ($v2Installed | Where-Object { $_.Version -eq $verText })
            }

            [PSCustomObject]@{
                Version     = $ver
                VersionText = $verText
                Paths       = $paths
                TrackedV3   = $isV3
                TrackedV2   = $isV2
            }
        } | Sort-Object Version

    # Determine which versions to keep/remove
    $toKeep   = $ledger | Select-Object -Last $Keep
    $toRemove = $ledger | Select-Object -First ([math]::Max(0, $ledger.Count - $Keep))

    Write-Section "Module: $Name"
    Write-Information "Detected versions on disk:" -InformationAction Continue
    $ledger | ForEach-Object {
        $flag = if ($toKeep.Version -contains $_.Version) { "(keep)" } else { "" }
        $trk  = if ($_.TrackedV3 -and $_.TrackedV2) { "v3+v2" } elseif ($_.TrackedV3) { "v3" } elseif ($_.TrackedV2) { "v2" } else { "unmanaged" }
        Write-Information (" - {0} {1} [{2}]" -f $_.VersionText, $flag, $trk) -InformationAction Continue
        $_.Paths | ForEach-Object { Write-Information (" {0}" -f $_) -InformationAction Continue }
    }

    if (-not $toRemove) {
        Write-Information "`nNothing to remove. Keeping latest $Keep version(s)." -InformationAction Continue
        return
    }

    Write-Section ("Will remove {0} version(s): {1}" -f $toRemove.Count, (($toRemove.VersionText) -join ", "))

    # Removal loop (prefer v3 -> v2 -> unmanaged folder)
    $results = New-Object System.Collections.Generic.List[object]

    foreach ($entry in $toRemove) {
        foreach ($path in $entry.Paths) {
            $action = "Remove $Name $($entry.VersionText) at $path"
            if ($PSCmdlet.ShouldProcess($action)) {
                $removed = $false
                $msg = $null

                # Try PSResourceGet (v3)
                if ($hasPSRG -and $entry.TrackedV3 -and -not $removed) {
                    try {
                        Uninstall-PSResource -Name $Name -Version $entry.VersionText -Quiet -ErrorAction Stop
                        $results.Add([PSCustomObject]@{ Version=$entry.VersionText; Path=$path; Method='Uninstall-PSResource'; Status='Removed'; Message=$null })
                        $removed = $true
                    } catch { $msg = $_.Exception.Message }
                }

                # Try PowerShellGet v2
                if (-not $removed -and $entry.TrackedV2) {
                    try {
                        Uninstall-Module -Name $Name -RequiredVersion $entry.VersionText -Force:$Force -ErrorAction Stop
                        $results.Add([PSCustomObject]@{ Version=$entry.VersionText; Path=$path; Method='Uninstall-Module'; Status='Removed'; Message=$null })
                        $removed = $true
                    } catch { $msg = $_.Exception.Message }
                }

                # As a last resort, remove the folder
                if (-not $removed) {
                    try {
                        if (Test-Path -LiteralPath $path) {
                            Remove-Item -LiteralPath $path -Recurse -Force:$Force -ErrorAction Stop
                            $results.Add([PSCustomObject]@{ Version=$entry.VersionText; Path=$path; Method='Remove-Item'; Status='Removed'; Message=$null })
                            $removed = $true
                        } else {
                            $results.Add([PSCustomObject]@{ Version=$entry.VersionText; Path=$path; Method='Remove-Item'; Status='Skipped'; Message='Path not found' })
                        }
                    } catch {
                        $results.Add([PSCustomObject]@{ Version=$entry.VersionText; Path=$path; Method='Remove-Item'; Status='Failed'; Message=$_.Exception.Message })
                    }
                }

                if (-not $removed -and $msg) {
                    $results.Add([PSCustomObject]@{ Version=$entry.VersionText; Path=$path; Method='(v3/v2)'; Status='Failed'; Message=$msg })
                }
            }
        }
    }

    Write-Section "Removal summary"
    $results | Sort-Object Version, Path | Format-Table -AutoSize

    # Show what's left (both inventories + disk)
    Write-Section "Remaining (inventories + disk)"
    if ($hasPSRG) {
        $rgAfter = Get-InstalledPSResource -Name $Name -AllVersions -ErrorAction SilentlyContinue
        Write-Information "Get-InstalledPSResource:" -InformationAction Continue
        if ($rgAfter) { $rgAfter | Format-Table Name, Version, InstalledLocation -AutoSize } else { Write-Information "(none)" -InformationAction Continue }
    }

    $v2After = Get-InstalledModule -Name $Name -AllVersions -ErrorAction SilentlyContinue
    Write-Information "`nGet-InstalledModule:" -InformationAction Continue
    if ($v2After) { $v2After | Format-Table Name, Version, InstalledLocation -AutoSize } else { Write-Information "(none)" -InformationAction Continue }

    $diskAfter = Get-Module -ListAvailable -Name $Name | Sort-Object Version
    Write-Information "`nGet-Module -ListAvailable:" -InformationAction Continue
    if ($diskAfter) { $diskAfter | Format-Table Name, Version, ModuleBase -AutoSize } else { Write-Information "(none)" -InformationAction Continue }
}

function Update-Modules {
    <#
    .SYNOPSIS
        Updates modules to the latest version using a chosen provider, with preview and admin checks.
    .DESCRIPTION
        v3-first capable: can use PSResourceGet (Install-PSResource) or PowerShellGet v2 (Install-Module).
        Adds:
          - -Provider Auto|PSResourceGet|PowerShellGet (default Auto prefers v3 if available)
          - -Preview (dry-run)
          - Admin check: System-scope updates are skipped with a warning if not elevated
          - -CleanupOld removes older versions using the corresponding uninstall cmdlet
    .PARAMETER Scope
        User | System | All | Unknown.
    .PARAMETER Provider
        Auto (default) | PSResourceGet | PowerShellGet
    .PARAMETER Name
        Optional name filter (wildcards allowed).
    .PARAMETER CleanupOld
        Remove older versions after successful update.
    .PARAMETER IncludePrerelease
        Consider pre-release versions.
    .PARAMETER Preview
        Show the plan and make no changes.
    .EXAMPLE
        Update-Modules -Scope User -Provider PSResourceGet -Preview
    #>


    [CmdletBinding(SupportsShouldProcess = $true)]
    param(
        [ValidateSet('User', 'System', 'All', 'Unknown')]
        [string]$Scope = 'All',
        [ValidateSet('Auto', 'PSResourceGet', 'PowerShellGet')]
        [string]$Provider = 'Auto',
        [string[]]$Name,
        [switch]$CleanupOld,
        [switch]$IncludePrerelease,
        [switch]$Preview
    )

    function Get-ModuleScope([string]$installedLocation) {
        if ($installedLocation -imatch 'Program Files|ProgramData') { return 'System' }
        elseif ($installedLocation -imatch '\\Users\\') { return 'User' }
        else { return 'Unknown' }
    }
    function Get-TargetScopeString([string]$scopeToken) {
        switch ($scopeToken) {
            'User' { return 'CurrentUser' }
            'System' { return 'AllUsers' }
            default { return 'CurrentUser' }
        }
    }
    function Test-IsAdministrator {
        try {
            $id = [Security.Principal.WindowsIdentity]::GetCurrent()
            $pri = New-Object Security.Principal.WindowsPrincipal($id)
            return $pri.IsInRole([Security.Principal.WindowsBuiltinRole]::Administrator)
        }
        catch { return $false }
    }

    $hasPSRG = [bool](Get-Module -ListAvailable Microsoft.PowerShell.PSResourceGet)
    if ($Provider -eq 'Auto') { $Provider = if ($hasPSRG) { 'PSResourceGet' } else { 'PowerShellGet' } }
    if ($Provider -eq 'PSResourceGet') {
        Import-Module Microsoft.PowerShell.PSResourceGet -ErrorAction SilentlyContinue | Out-Null
    }

    # Get installed list per provider
    $installed = @()
    if ($Provider -eq 'PSResourceGet') {
        $installed = Get-InstalledPSResource -ErrorAction SilentlyContinue
    }
    else {
        $installed = Get-InstalledModule -ErrorAction SilentlyContinue
    }
    if ($Name) {
        $patterns = $Name
        $installed = $installed | Where-Object {
            $n = $_.Name
            foreach ($p in $patterns) { if ($n -like $p) { return $true } }
            return $false
        }
    }
    if (-not $installed) {
        Write-Information "No installed modules found for the given filter/provider." -InformationAction Continue
        return
    }

    # Build update plan
    $toUpdate = @()
    $total = $installed.Count; $i = 0
    foreach ($mod in $installed) {
        $i++
        Write-Progress -Activity "Scanning for module updates..." `
            -Status   "Checking $($mod.Name) ($i of $total)" `
            -PercentComplete (($i / $total) * 100)
        try {
            if ($Provider -eq 'PSResourceGet') {
                $findParams = @{ Name = $mod.Name; ErrorAction = 'Stop' }
                if ($IncludePrerelease) { $findParams['Prerelease'] = $true }
                $latest = Find-PSResource @findParams
                if ($latest -and ([version]$latest.Version -gt [version]$mod.Version)) {
                    $scope = Get-ModuleScope $mod.InstalledLocation
                    if ($Scope -eq 'All' -or $Scope -eq $scope) {
                        $toUpdate += [PSCustomObject]@{
                            Name             = $mod.Name
                            InstalledVersion = [version]$mod.Version
                            LatestVersion    = [version]$latest.Version
                            Repo             = $latest.Repository
                            Scope            = $scope
                            TargetScope      = Get-TargetScopeString $scope
                            Provider         = 'PSResourceGet'
                        }
                    }
                }
            }
            else {
                $findParams = @{ Name = $mod.Name; ErrorAction = 'Stop' }
                if ($IncludePrerelease) { $findParams['AllowPrerelease'] = $true }
                $latest = Find-Module @findParams
                if ($latest -and ([version]$latest.Version -gt [version]$mod.Version)) {
                    $scope = Get-ModuleScope $mod.InstalledLocation
                    if ($Scope -eq 'All' -or $Scope -eq $scope) {
                        $toUpdate += [PSCustomObject]@{
                            Name             = $mod.Name
                            InstalledVersion = [version]$mod.Version
                            LatestVersion    = [version]$latest.Version
                            Repo             = $latest.Repository
                            Scope            = $scope
                            TargetScope      = Get-TargetScopeString $scope
                            Provider         = 'PowerShellGet'
                        }
                    }
                }
            }
        }
        catch {
            Write-Warning "Could not query '$($mod.Name)': $($_.Exception.Message)"
        }
    }
    Write-Progress -Activity "Scanning for module updates..." -Completed

    if (-not $toUpdate) {
        Write-Information "All good: no updates found for selected Scope/Provider." -InformationAction Continue
        return
    }

    # Admin gating for System scope
    $isAdmin = Test-IsAdministrator
    $sysItems = $toUpdate | Where-Object { $_.Scope -eq 'System' }
    if ($sysItems -and -not $isAdmin) {
        Write-Warning "Not elevated: System-scope updates will be skipped. Re-run PowerShell as Administrator to include them."
        $toUpdate = $toUpdate | Where-Object { $_.Scope -ne 'System' }
        if (-not $toUpdate) { return }
    }

    # Preview
    $toUpdate | Sort-Object Name | Format-Table Name, InstalledVersion, LatestVersion, Scope, Provider, Repo -AutoSize
    if ($Preview) {
        Write-Information "`nPreview mode: no changes will be made." -InformationAction Continue
        return $toUpdate
    }

    # Execute updates with matching provider
    $j = 0
    foreach ($item in $toUpdate) {
        $j++
        $desc = "Update $($item.Name) $($item.InstalledVersion) -> $($item.LatestVersion) ($($item.TargetScope)) via $($item.Provider)"
        Write-Progress -Activity "Updating modules..." -Status "$desc ($j of $($toUpdate.Count))" -PercentComplete (($j / $toUpdate.Count) * 100)

        if ($PSCmdlet.ShouldProcess($desc)) {
            try {
                if ($item.Provider -eq 'PSResourceGet') {
                    Install-PSResource -Name $item.Name -Version $item.LatestVersion.ToString() `
                        -Scope $item.TargetScope -TrustRepository -Quiet -ErrorAction Stop
                }
                else {
                    try {
                        $repo = if ($item.Repo) { $item.Repo } else { 'PSGallery' }
                        $r = Get-PSRepository -Name $repo -ErrorAction Stop
                        if ($r.InstallationPolicy -ne 'Trusted') {
                            Set-PSRepository -Name $repo -InstallationPolicy Trusted -ErrorAction SilentlyContinue
                        }
                    }
                    catch {}
                    Install-Module -Name $item.Name -RequiredVersion $item.LatestVersion.ToString() `
                        -Scope $item.TargetScope -Force -AllowClobber -ErrorAction Stop
                }
                Write-Information "Updated $($item.Name) to $($item.LatestVersion) ($($item.TargetScope))." -InformationAction Continue

                if ($CleanupOld) {
                    # Remove older versions using the same provider, fallback if needed
                    try {
                        if ($item.Provider -eq 'PSResourceGet') {
                            $all = Get-InstalledPSResource -Name $item.Name -AllVersions -ErrorAction SilentlyContinue |
                            Sort-Object Version
                            $older = $all | Select-Object -SkipLast 1
                            foreach ($ov in $older) {
                                $rmDesc = "Remove older (v3) $($item.Name) $($ov.Version)"
                                if ($PSCmdlet.ShouldProcess($rmDesc)) {
                                    try {
                                        Uninstall-PSResource -Name $item.Name -Version $ov.Version -Quiet -ErrorAction Stop
                                        Write-Information "Removed $($item.Name) $($ov.Version) via Uninstall-PSResource." -InformationAction Continue
                                    }
                                    catch {
                                        # Fallback to v2 if v3 uninstall failed to find it
                                        Uninstall-Module -Name $item.Name -RequiredVersion $ov.Version -Force -ErrorAction SilentlyContinue
                                    }
                                }
                            }
                        }
                        else {
                            $all = Get-InstalledModule -Name $item.Name -AllVersions -ErrorAction SilentlyContinue |
                            Sort-Object Version
                            $older = $all | Select-Object -SkipLast 1
                            foreach ($ov in $older) {
                                $rmDesc = "Remove older (v2) $($item.Name) $($ov.Version)"
                                if ($PSCmdlet.ShouldProcess($rmDesc)) {
                                    try {
                                        Uninstall-Module -Name $ov.Name -RequiredVersion $ov.Version -Force -ErrorAction Stop
                                        Write-Information "Removed $($ov.Name) $($ov.Version) via Uninstall-Module." -InformationAction Continue
                                    }
                                    catch {
                                        # Fallback to v3 if needed
                                        Uninstall-PSResource -Name $item.Name -Version $ov.Version -Quiet -ErrorAction SilentlyContinue
                                    }
                                }
                            }
                        }
                    }
                    catch {
                        Write-Warning "CleanupOld failed for $($item.Name): $($_.Exception.Message)"
                    }
                }

            }
            catch {
                Write-Warning "Update failed for $($item.Name): $($_.Exception.Message)"
            }
        }
    }

    Write-Progress -Activity "Updating modules ..." -Completed
}