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'
}

# Classify a module base path into an install scope.
# Heuristic: anything under $HOME is CurrentUser, everything else AllUsers —
# holds for the standard PSModulePath layout on Windows, Linux and macOS.
function Get-PSMMScopeForPath {
    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$Path)
    if ($Path.StartsWith($HOME, [System.StringComparison]::OrdinalIgnoreCase)) { 'CurrentUser' } else { 'AllUsers' }
}

# 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('-'))"
}

# 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'
    )
    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 always installs the pinned version/range, update or not.
            Install-PSResource -Name $Name -Version $Version -Scope $Scope -Prerelease:$pre -TrustRepository -Reinstall:$Update -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
    }
}

# One entry's startup action, honouring the orthogonal Mode x Install matrix.
# Mode decides load-vs-not; Install decides disk/gallery policy.
function Invoke-PSMMEntryAction {
    [CmdletBinding()]
    param([Parameter(Mandatory)] $Entry)
    $name = $Entry.Name
    if ($Entry.Mode -eq 'Ignore') { return 'ignored' }
    $installed = [bool](Get-Module -ListAvailable -Name $name)
    $note = ''
    $pre = [bool]$Entry.AllowPrerelease
    switch ($Entry.Install) {
        'CheckOnly' { if (-not $installed) { return 'not installed (check-only)' }; $note = 'present' }
        'IfMissing' {
            if (-not $installed) { Install-PSMMModule -Name $name -Version $Entry.Version -Prerelease:$pre; $note = 'installed' }
            else { $note = 'present' }
        }
        'Latest' { Install-PSMMModule -Name $name -Update -Version $Entry.Version -Prerelease:$pre; $note = 'latest' }
    }
    if ($Entry.Mode -eq 'InstallOnly') { return "$note (not loaded)" }
    if (-not (Get-Module -Name $name)) {
        Import-PSMMModuleTimed -Entry $Entry
        return "$note + loaded"
    }
    return "$note + already loaded"
}

# 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
    }
}