src/Engine/Install.ps1

# Install.ps1 — gallery operations: install, update, uninstall.
# Microsoft.PowerShell.PSResourceGet is the primary engine with a
# PowerShellGet fallback (older machines). Read-only lookups live in State.ps1.

# Which install engine is active? (surfaced in the UI; defuses the
# PSResourceGet-vs-PowerShellGet confusion)
function Get-PSMMInstallEngine {
    [CmdletBinding()] param()
    if (Get-Command Install-PSResource -ErrorAction SilentlyContinue) { 'PSResourceGet' } else { 'PowerShellGet' }
}

# Is this session elevated? Drives which scopes the UI offers (#28).
function Test-PSMMElevated {
    [CmdletBinding()] param()
    if ($IsWindows) {
        $id = [System.Security.Principal.WindowsIdentity]::GetCurrent()
        return ([System.Security.Principal.WindowsPrincipal]$id).IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)
    }
    [System.Environment]::UserName -eq 'root'
}

# The roots that count as CurrentUser.
#
# $HOME alone is NOT enough, and getting this wrong is expensive. On Windows
# the CurrentUser module location is derived from the **Documents known
# folder**, which can live on another drive entirely — a redirected or
# relocated Documents puts every one of your own modules outside $HOME. On
# such a machine a bare $HOME prefix test reports them all as AllUsers, so the
# grid marks them read-only and version cleanup refuses to touch them
# ("session is not elevated") — the feature silently stops working for exactly
# the modules it exists to clean up. Verified on a real machine where
# $HOME = C:\Users\<user> and Documents = E:\Users\<user>\Documents.
#
# Cached: this runs once per installed version during a full scan, and the
# Documents known folder cannot move inside a live session.
function Get-PSMMUserModuleRoot {
    [CmdletBinding()] param()
    if ($null -ne $script:PSMM_UserRoots) { return $script:PSMM_UserRoots }
    $roots = [System.Collections.Generic.List[string]]::new()
    if ($HOME) { $roots.Add("$HOME") }
    # Documents-derived default (Windows). Unix keeps its user modules under
    # ~/.local/share/powershell/Modules, which $HOME already covers.
    $docs = $null
    try { $docs = Get-PSMMUserDefaultModulePath } catch { }
    if ($docs) { $roots.Add("$docs") }
    $script:PSMM_UserRoots = @($roots | Where-Object { $_ } | Select-Object -Unique)
    $script:PSMM_UserRoots
}

# Classify a module base path into an install scope: the scope you would have
# to install to in order to replace it. Two values only — 'System' is NOT one
# of them, because "shipped with PowerShell" is not somewhere anything can be
# installed; see Test-PSMMPlatformModulePath for that question.
function Get-PSMMScopeForPath {
    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$Path)
    foreach ($root in (Get-PSMMUserModuleRoot)) {
        if ($Path.StartsWith($root, [System.StringComparison]::OrdinalIgnoreCase)) { return 'CurrentUser' }
    }
    'AllUsers'
}

# Is this module base one of the PLATFORM's own module directories?
# $PSHOME/Modules on every platform, plus Windows PowerShell's System32 store.
#
# These modules ship WITH PowerShell, so they are not ordinary machine-wide
# installs: you cannot replace pwsh's own Microsoft.PowerShell.* from the
# gallery. The unmanaged view still LISTS them - browsing their commands and
# help through psmm is a real use - but marks them `system` rather than
# `all`, and version cleanup refuses to remove one at any elevation (gh#27).
# Roots are read from $PSHOME / $env:SystemRoot at call time, never hard-coded.
#
# Deliberately NOT folded into Get-PSMMScopeForPath as a third scope value:
# that function feeds install-scope and elevation decisions, and "shipped with
# the platform" is not a scope anything can be installed to.
function Test-PSMMPlatformModulePath {
    [CmdletBinding()]
    param([Parameter(Mandatory)][AllowEmptyString()][string]$Path)
    if ([string]::IsNullOrWhiteSpace($Path)) { return $false }
    $roots = [System.Collections.Generic.List[string]]::new()
    if ($PSHOME) { $roots.Add((Join-Path $PSHOME 'Modules')) }
    if ($env:SystemRoot) {
        $roots.Add((Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\Modules'))
        $roots.Add((Join-Path $env:SystemRoot 'SysWOW64\WindowsPowerShell\v1.0\Modules'))
    }
    foreach ($r in $roots) {
        if ($Path.StartsWith($r, [System.StringComparison]::OrdinalIgnoreCase)) { return $true }
    }
    $false
}

# The prerelease label of one module copy ('' when it is a stable release).
# It lives in the manifest's PSData, never in the [version] - 0.1.0-beta8 and
# 0.1.0 share the same [version] 0.1.0 (gh#6).
function Get-PSMMPrereleaseLabel {
    [CmdletBinding()]
    param([AllowNull()] $ModuleInfo)
    if (-not $ModuleInfo) { return '' }
    $pre = $null
    try { $pre = $ModuleInfo.PrivateData.PSData.Prerelease } catch { }
    if ([string]::IsNullOrWhiteSpace("$pre")) { return '' }
    "$pre".TrimStart('-')
}

# "1.2.0-beta3" for display: the [version] plus the prerelease label when there
# is one. Every version psmm shows goes through here (gh#6).
function Get-PSMMVersionDisplay {
    [CmdletBinding()]
    param($Version, [string]$Prerelease)
    if ($null -eq $Version -or "$Version" -eq '') { return '' }
    if ([string]::IsNullOrWhiteSpace($Prerelease)) { return "$Version" }
    "$Version-$($Prerelease.TrimStart('-'))"
}

# "2.5" and "2.5.0" name the same release; [version] disagrees, because the
# segments you leave out are -1 rather than 0. Pad to four parts so matching a
# pin is never defeated by how many segments the user happened to type.
function Get-PSMMNormalVersion {
    [CmdletBinding()]
    param($Version)
    try {
        $v = [version]"$Version"
        '{0}.{1}.{2}.{3}' -f $v.Major, [Math]::Max(0, $v.Minor), [Math]::Max(0, $v.Build), [Math]::Max(0, $v.Revision)
    } catch { "$Version" }
}

# Is this EXACT version pin (base version + prerelease label) already on disk?
# A RANGE pin always answers $false — "newest inside the range" is a moving
# target, so an update against it is genuinely meaningful (gh#20, gh#23).
function Test-PSMMVersionInstalled {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Name,
        [Parameter(Mandatory)][AllowEmptyString()][string]$Version
    )
    if ($Version -notmatch '^(?<base>\d+(\.\d+){1,3})(-(?<label>[A-Za-z0-9][A-Za-z0-9.-]*))?$') { return $false }
    $want  = Get-PSMMNormalVersion -Version $Matches['base']
    $label = "$($Matches['label'])"
    foreach ($m in @(Get-Module -ListAvailable -Name $Name -ErrorAction SilentlyContinue)) {
        if ((Get-PSMMNormalVersion -Version "$($m.Version)") -ne $want) { continue }
        if ((Get-PSMMPrereleaseLabel -ModuleInfo $m) -eq $label) { return $true }
    }
    $false
}

# Is the newest installed copy of a module a prerelease?
function Test-PSMMInstalledPrerelease {
    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$Name)
    $newest = @(Get-Module -ListAvailable -Name $Name -ErrorAction SilentlyContinue |
        Sort-Object Version -Descending) | Select-Object -First 1
    if (-not $newest) { return $false }
    [bool](Get-PSMMPrereleaseLabel -ModuleInfo $newest)
}

# Install or update one module. Honours an optional version pin (exact or
# NuGet range), the entry's prerelease policy and the target scope. Throws on
# failure — callers decide how to report; a bulk operation must survive one
# module failing.
# -Prerelease is the entry's opt-in (gh#6). Independently of it, a module whose
# INSTALLED copy is already a prerelease keeps being updated along the
# prerelease track, because that is the only thing that can move it at all.
function Install-PSMMModule {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Name,
        [switch]$Update,
        [string]$Version,
        [switch]$Prerelease,
        [ValidateSet('CurrentUser', 'AllUsers')][string]$Scope = 'CurrentUser'
    )
    # gh#20, second line of defence. The policy function already degrades
    # Latest to IfMissing for an exact pin, so nothing psmm schedules asks for
    # this any more — but Install-PSMMModule is callable directly (the module
    # menu's u), and "update to the version you already have" must never mean
    # "download it again". The pin IS the answer; if it is on disk, we are done.
    if ($Update -and $Version -and (Test-PSMMVersionInstalled -Name $Name -Version $Version)) { return }

    if (Get-Command Install-PSResource -ErrorAction SilentlyContinue) {
        # a pin that names a prerelease implies -Prerelease, whatever the
        # entry's policy says: you cannot install 1.2.3-beta4 without it
        $pre = [bool]$Prerelease -or ($Version -match '^\d+(\.\d+){1,3}-')
        if ($Version) {
            # A pin installs the pinned version/range - and NEVER with
            # -Reinstall, which is gh#20 for range pins. Measured against a
            # local feed: with -Reinstall the module folder is deleted and
            # re-extracted even when the range is already satisfied, so
            # `Latest` + "[1.0,2.0)" re-downloaded on every single shell start.
            # Without it PSResourceGet skips an already-satisfied range
            # ("Resource ... is already installed") and still installs the
            # newest in range the moment one appears - which is exactly what
            # `Latest` within a range is supposed to mean.
            Install-PSResource -Name $Name -Version $Version -Scope $Scope -Prerelease:$pre -TrustRepository -ErrorAction Stop
        } elseif ($Update -and (Get-Module -ListAvailable -Name $Name) -and ($pre -or (Test-PSMMInstalledPrerelease -Name $Name))) {
            # Prerelease track: Update-PSResource is blind to a
            # prerelease-label-only bump (beta2 -> beta3 shares the base
            # version folder) - Install -Prerelease -Reinstall is the only
            # command that moves it (verified against PSResourceGet 1.2.0,
            # see src/Engine/SelfUpdate.ps1).
            Install-PSResource -Name $Name -Prerelease -Reinstall -Scope $Scope -TrustRepository -ErrorAction Stop
        } elseif ($Update -and (Get-Command Update-PSResource -ErrorAction SilentlyContinue) -and (Get-Module -ListAvailable -Name $Name)) {
            Update-PSResource -Name $Name -Scope $Scope -Prerelease:$pre -ErrorAction Stop
        } else {
            Install-PSResource -Name $Name -Scope $Scope -Prerelease:$pre -TrustRepository -ErrorAction Stop
        }
    } else {
        # PowerShellGet fallback: exact pins map to -RequiredVersion; NuGet
        # ranges are a PSResourceGet feature, so fall back to latest with a
        # warning rather than failing the whole operation.
        $exact = $Version -and $Version -match '^\d+(\.\d+){1,3}(-[A-Za-z0-9][A-Za-z0-9.-]*)?$'
        if ($Version -and -not $exact) {
            Write-Warning "psmm: version range '$Version' for '$Name' needs PSResourceGet - installing latest instead"
        }
        $params = @{ Name = $Name; Scope = $Scope; Force = $true; AllowClobber = $true; ErrorAction = 'Stop' }
        if ($exact) { $params.RequiredVersion = $Version }
        if ($Prerelease -or ($Version -match '^\d+(\.\d+){1,3}-') -or
            ($Update -and (Test-PSMMInstalledPrerelease -Name $Name))) { $params.AllowPrerelease = $true }
        Install-Module @params
    }
}

# Remove one specific installed version (duplicate-version cleanup).
function Uninstall-PSMMModuleVersion {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Name,
        [Parameter(Mandatory)][string]$Version
    )
    if (Get-Command Uninstall-PSResource -ErrorAction SilentlyContinue) {
        Uninstall-PSResource -Name $Name -Version "[$Version]" -ErrorAction Stop
    } else {
        Uninstall-Module -Name $Name -RequiredVersion $Version -Force -ErrorAction Stop
    }
}

# Import one entry's module, honouring an exact pin and recording how long the
# import took (ImportMs — surfaced in the startup report and the UI, because
# "which module makes my shell slow?" is the question everyone asks).
#
# -Global is MANDATORY here, not a nicety (gh#2). Every psmm import runs inside
# the psmm module, and about_Modules / Import-Module -Scope is explicit: called
# from within a module, Import-Module imports into THAT module's session state.
# Without -Global the module lands in psmm's private state - invisible to the
# user's prompt, `Get-Module` empty, its commands "not recognized" - while
# psmm's own `Get-Module` (which sees global + its own private state) happily
# reports it as loaded, for the rest of the session. Command auto-loading hides
# this for modules that export explicit names; modules whose manifest exports
# '*' (e.g. Microsoft.Online.SharePoint.PowerShell) cannot auto-load and break
# outright.
function Import-PSMMModuleTimed {
    [CmdletBinding()]
    param([Parameter(Mandatory)] $Entry)
    $sw = [System.Diagnostics.Stopwatch]::StartNew()
    try {
        if ($Entry.PinnedExact) {
            # -RequiredVersion is typed [System.Version] and throws on a
            # prerelease label, so a "1.2.3-beta4" pin imports by its BASE
            # version - which is the folder PowerShell actually installed it to
            # (a prerelease shares its base-version folder).
            $req = if ($Entry.PinnedBaseVersion) { $Entry.PinnedBaseVersion } else { $Entry.Version }
            Import-Module -Name $Entry.Name -RequiredVersion $req -Global -ErrorAction Stop
        } else {
            Import-Module -Name $Entry.Name -Global -ErrorAction Stop
        }
    } finally {
        $sw.Stop()
        $Entry.ImportMs = [int]$sw.Elapsed.TotalMilliseconds
    }
}