Bootstrap/Import-SecondLine.ps1

<#
.SYNOPSIS
    Imports the SecondLine version a project approves, before anything else loads SecondLine.
 
.DESCRIPTION
    SecondLine-Bootstrap: 1
 
    A loaded module can't choose its own version, so this script chooses it first.
    It reads only data files and imports nothing until it knows the version:
 
      1. Finds the project standard: -Standard, then SECONDLINE_STANDARD, then the
         nearest secondline.standard.psd1 in -Path or one of its parents.
      2. Follows the file's Extends chain. Relative parents resolve from the file
         that declares them. A cycle, or a chain longer than 64 files, is an error.
         '@development' ends the chain here, because the packaged baseline lives in
         the package that hasn't been chosen yet. That baseline never pins versions.
      3. Gets ModuleVersions from each layer, lowest precedence first: machine
         settings, user settings, then the project chain from its root parent to the
         nearest file. A project value always wins. A user value is ignored where the
         project sets one, and Get-SLSetting shows that it was overridden.
      4. Imports exactly that version with -RequiredVersion. It never installs or
         updates anything. If the version isn't installed, it says how to install it.
 
    In CI (CI, GITHUB_ACTIONS or TF_BUILD set), or with SECONDLINE_NO_HOST_SETTINGS=1,
    the machine and user settings files are ignored, so the project files alone
    decide the version.
 
    Copy this file into a consuming repository (for example build/Import-SecondLine.ps1)
    and run it with the call operator. Test-SLPolicyReference reports when the copy
    is older than the bootstrap in the installed SecondLine.
 
.PARAMETER Path
    The folder where the standard search starts. Default: the current folder.
    Scripts should pass $PSScriptRoot.
 
.PARAMETER Standard
    An explicit standard file. This overrides SECONDLINE_STANDARD and the search.
 
.PARAMETER Name
    The module to import. Default: SecondLine.
 
.PARAMETER AllowUnpinned
    If no layer approves a version, import the highest installed version instead of failing.
 
.PARAMETER NoHostSettings
    Ignore the machine and user settings files.
 
.PARAMETER PassThru
    Return the selection: Name, Version, Source and the imported module.
 
.PARAMETER LibraryOnly
    Only define the functions. SecondLine itself uses this to share one implementation.
 
.EXAMPLE
    & "$PSScriptRoot\Import-SecondLine.ps1" -Path $PSScriptRoot
#>

[CmdletBinding()]
param(
    [string] $Path,
    [string] $Standard,
    [string] $Name = 'SecondLine',
    [switch] $AllowUnpinned,
    [switch] $NoHostSettings,
    [switch] $PassThru,
    [switch] $LibraryOnly
)

# The bootstrap format, compared by Test-SLPolicyReference with vendored copies.
$script:SLBootstrapVersion = 1

# Nearest secondline.standard.psd1 in Directory or one of its parents, else $null.
function Find-SLStandardPath {
    param([string] $Directory = (Get-Location).ProviderPath)
    $folder = [IO.DirectoryInfo] $Directory
    while ($null -ne $folder) {
        $candidate = Join-Path $folder.FullName 'secondline.standard.psd1'
        if (Test-Path -LiteralPath $candidate -PathType Leaf) { return $candidate }
        $folder = $folder.Parent
    }
    return $null
}

# The standard to use: explicit path, then SECONDLINE_STANDARD, then the nearest parent.
function Resolve-SLStandardLocation {
    param([string] $Path, [string] $Directory)
    if ($Path) { return $Path }
    if ($env:SECONDLINE_STANDARD) { return $env:SECONDLINE_STANDARD }
    if (-not $Directory) { $Directory = (Get-Location).ProviderPath }
    return (Find-SLStandardPath -Directory $Directory)
}

# Reads a standard and its Extends parents, nearest first, as objects with Path and Data.
# DevelopmentPath resolves '@development'. Without it, the chain stops there.
function Get-SLStandardChain {
    param([Parameter(Mandatory)] [string] $Path, [string] $DevelopmentPath)
    $chain = @()
    $stack = @()
    $current = $Path
    while ($current) {
        $resolved = (Resolve-Path -LiteralPath $current -ErrorAction Stop).ProviderPath
        if ($stack -contains $resolved) { throw "Circular SecondLine inheritance: $(@($stack + $resolved) -join ' -> ')" }
        if ($stack.Count -ge 64) { throw 'SecondLine inheritance exceeds 64 files.' }
        try { $data = Import-PowerShellDataFile -LiteralPath $resolved -ErrorAction Stop }
        catch { throw "Could not read $resolved as a PowerShell data file: $($_.Exception.Message)" }
        $stack += $resolved
        $chain += [pscustomobject]@{ Path = $resolved; Data = $data }
        $current = $null
        if (-not $data.ContainsKey('Extends')) { break }
        if ($data.Extends -isnot [string] -or -not $data.Extends.Trim()) {
            throw "SecondLine standard $resolved requires Extends to be a nonempty string."
        }
        $parent = $data.Extends
        if ($parent -eq '@development') {
            if (-not $DevelopmentPath) { break }
            $parent = $DevelopmentPath
        }
        elseif (-not [IO.Path]::IsPathRooted($parent)) {
            $parent = Join-Path (Split-Path $resolved -Parent) $parent
        }
        if (-not (Test-Path -LiteralPath $parent -PathType Leaf)) { throw "SecondLine parent standard not found: $parent (from $resolved)." }
        $current = $parent
    }
    return $chain
}

# True when running under a CI service, where personal settings must not apply.
function Test-SLCIEnvironment {
    return [bool] ($env:CI -eq 'true' -or $env:GITHUB_ACTIONS -eq 'true' -or $env:TF_BUILD)
}

# Paths of the machine and user settings files. SECONDLINE_MACHINE_SETTINGS and
# SECONDLINE_USER_SETTINGS override them, for tests and unusual layouts.
function Get-SLHostSettingsPath {
    $machine = $env:SECONDLINE_MACHINE_SETTINGS
    if (-not $machine) { $machine = Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) 'SecondLine\settings.psd1' }
    $user = $env:SECONDLINE_USER_SETTINGS
    if (-not $user) { $user = Join-Path ([Environment]::GetFolderPath('ApplicationData')) 'SecondLine\settings.psd1' }
    return [pscustomobject]@{ Machine = $machine; User = $user }
}

# Machine then user settings, as layers: Scope, Path, Data. An unreadable settings
# file is an error, not silently skipped. Returns nothing when host settings are off.
function Get-SLHostSettingsLayer {
    param([switch] $NoHostSettings)
    if ($NoHostSettings -or $env:SECONDLINE_NO_HOST_SETTINGS -eq '1' -or (Test-SLCIEnvironment)) { return }
    $paths = Get-SLHostSettingsPath
    foreach ($scope in 'Machine', 'User') {
        $file = $paths.$scope
        if (-not (Test-Path -LiteralPath $file -PathType Leaf)) { continue }
        try { $data = Import-PowerShellDataFile -LiteralPath $file -ErrorAction Stop }
        catch { throw "Could not read SecondLine $($scope.ToLowerInvariant()) settings $($file): $($_.Exception.Message)" }
        [pscustomobject]@{ Scope = $scope; Path = $file; Data = $data }
    }
}

# Folds one hashtable section across layers, lowest precedence first. Returns
# name -> Name, Value, Source, Overridden (lower-precedence values that differed).
function Merge-SLSettingLayer {
    param([Parameter(Mandatory)] [AllowEmptyCollection()] [object[]] $Layers, [Parameter(Mandatory)] [string] $Section)
    $effective = @{}
    foreach ($layer in $Layers) {
        if (-not $layer.Data.ContainsKey($Section)) { continue }
        $table = $layer.Data[$Section]
        if ($table -isnot [hashtable]) { throw "$Section in $($layer.Source) must be @{ Name = value }." }
        foreach ($key in $table.Keys) {
            $overridden = @()
            if ($effective.ContainsKey($key)) {
                $previous = $effective[$key]
                $overridden = @($previous.Overridden)
                if ("$($previous.Value)" -ne "$($table[$key])") {
                    $overridden += [pscustomobject]@{ Value = $previous.Value; Source = $previous.Source }
                }
            }
            $effective[$key] = [pscustomobject]@{ Name = "$key"; Value = $table[$key]; Source = $layer.Source; Overridden = $overridden }
        }
    }
    return $effective
}

# The project chain as layers, root parent first, so the nearest file has the highest precedence.
function Get-SLProjectSettingsLayer {
    param([string] $Standard, [string] $Directory, [string] $DevelopmentPath)
    $location = Resolve-SLStandardLocation -Path $Standard -Directory $Directory
    if (-not $location) { return }
    if (-not (Test-Path -LiteralPath $location -PathType Leaf)) { throw "SecondLine standard not found: $location." }
    $chain = @(Get-SLStandardChain -Path $location -DevelopmentPath $DevelopmentPath)
    for ($i = $chain.Count - 1; $i -ge 0; $i--) {
        [pscustomobject]@{ Scope = 'Project'; Path = $chain[$i].Path; Data = $chain[$i].Data; Source = "project $($chain[$i].Path)" }
    }
}

# The approved version of each module: machine < user < project chain (nearest wins).
# Every value must be a plain numeric version, because Import-Module -RequiredVersion needs one.
function Resolve-SLModuleVersionPolicy {
    param([string] $Standard, [string] $Directory, [switch] $NoHostSettings, [string] $DevelopmentPath)
    $layers = @()
    foreach ($h in @(Get-SLHostSettingsLayer -NoHostSettings:$NoHostSettings)) {
        $layers += [pscustomobject]@{ Scope = $h.Scope; Path = $h.Path; Data = $h.Data; Source = "$($h.Scope.ToLowerInvariant()) $($h.Path)" }
    }
    $layers += @(Get-SLProjectSettingsLayer -Standard $Standard -Directory $Directory -DevelopmentPath $DevelopmentPath)
    $policy = Merge-SLSettingLayer -Layers $layers -Section 'ModuleVersions'
    foreach ($entry in $policy.Values) {
        if ("$($entry.Value)" -notmatch '^\d+(\.\d+){1,3}$') {
            throw "ModuleVersions.$($entry.Name) = '$($entry.Value)' in $($entry.Source) is not a version like 0.4.0."
        }
    }
    return $policy
}

# Picks and imports the approved version. Never installs anything.
function Import-SLApprovedModule {
    param(
        [string] $Name = 'SecondLine',
        [string] $Standard,
        [string] $Directory,
        [switch] $AllowUnpinned,
        [switch] $NoHostSettings
    )
    $policy = Resolve-SLModuleVersionPolicy -Standard $Standard -Directory $Directory -NoHostSettings:$NoHostSettings
    $installed = @(Get-Module -ListAvailable -Name $Name | Sort-Object Version -Descending)
    $entry = $null
    if ($policy.ContainsKey($Name)) { $entry = $policy[$Name] }
    if ($entry) {
        $version = [version] "$($entry.Value)"
        $source = $entry.Source
    }
    elseif ($AllowUnpinned -and $installed.Count -gt 0) {
        $version = $installed[0].Version
        $source = 'highest installed (-AllowUnpinned)'
    }
    else {
        throw "No approved $Name version. Add ModuleVersions = @{ $Name = '<version>' } to the project's secondline.standard.psd1, or pass -AllowUnpinned."
    }

    $loaded = @(Get-Module -Name $Name)
    if ($loaded.Count -gt 0) {
        if (@($loaded | Where-Object { $_.Version -ne $version }).Count -eq 0) {
            return [pscustomobject]@{ Name = $Name; Version = $version; Source = $source; Module = $loaded[0]; AlreadyLoaded = $true }
        }
        throw ("$Name $(($loaded.Version | ForEach-Object { "$_" }) -join ', ') is already loaded in this session, but $source approves $version. " +
            "A loaded module can't be swapped reliably. Open a new PowerShell session and run the bootstrap before anything imports $Name.")
    }
    if (-not ($installed | Where-Object { $_.Version -eq $version })) {
        $have = 'none'
        if ($installed.Count -gt 0) { $have = ($installed.Version | ForEach-Object { "$_" }) -join ', ' }
        throw ("$Name $version is approved by $source but is not installed (installed: $have). Nothing was installed. " +
            "To install it, review and run: Install-PSResource $Name -Version $version (Windows PowerShell 5.1: Install-Module $Name -RequiredVersion $version -Scope CurrentUser)")
    }
    $module = Import-Module -Name $Name -RequiredVersion $version -Global -PassThru -ErrorAction Stop
    return [pscustomobject]@{ Name = $Name; Version = $version; Source = $source; Module = $module; AlreadyLoaded = $false }
}

if ($LibraryOnly) { return }

$ErrorActionPreference = 'Stop'
if (-not $Path) { $Path = (Get-Location).ProviderPath }
$selection = Import-SLApprovedModule -Name $Name -Standard $Standard -Directory $Path -AllowUnpinned:$AllowUnpinned -NoHostSettings:$NoHostSettings
Write-Verbose "$($selection.Name) $($selection.Version) selected by $($selection.Source)."
if ($PassThru) { $selection }