resource/scripts/Install-MFBuildDependencies.ps1

<#
    .SYNOPSIS
        Install the modules a ModuleForge project needs to test and build, from its BuildDependencies.psd1.

    .DESCRIPTION
        Reads BuildDependencies.psd1 from the project root, in the format Install-PSResource -RequiredResourceFile
        reads, and installs the modules it lists. Prints the version of each module installed.

        Sources:
          - RequiredResourceFile the versions and repositories pinned in the file. The default
          - PSGallery the latest stable version of each module in the file, from PSGallery
          - PSGalleryPrerelease the latest version of each, prereleases included, from PSGallery

        Pass -Name to install only some of the file's modules.

        Pass -ForTests in a Pester workflow. It installs what the testDependencies setting in
        moduleForgeConfig.xml asks for:
          - None nothing, so tests use the modules the runner already has
          - PesterAndScriptAnalyzer Pester and PSScriptAnalyzer. The default, and used when the setting is missing
          - All Pester and PSScriptAnalyzer, plus everything else in the file except
                                     ModuleForge, which tests never need as they load from source. Mock anything
                                     else your code depends on where you can

        Pester and PSScriptAnalyzer are always installed for -ForTests unless the setting is None. If the file
        does not pin them, the latest comes from PSGallery with a warning.

        If the file is missing, or does not list a module named in -Name, that module is installed from
        PSGallery with a warning, so a project without the file still builds.

        Microsoft.PowerShell.PSResourceGet does all the installing, so it can't come from the file. PowerShell
        7.4 and newer include it. If it is missing or older than 1.0.0, it is installed with Install-Module.

        Does not import or depend on ModuleForge. Safe to run in a clean CI environment.

    .EXAMPLE
        .\scripts\Install-MFBuildDependencies.ps1

        Install every module in BuildDependencies.psd1, at the versions it pins.

    .EXAMPLE
        .\scripts\Install-MFBuildDependencies.ps1 -Name Pester

        Install only Pester, at the version the file pins.

    .EXAMPLE
        .\scripts\Install-MFBuildDependencies.ps1 -ForTests

        Install what the project's testDependencies setting asks for, before a Pester run.

    .EXAMPLE
        .\scripts\Install-MFBuildDependencies.ps1 -Source PSGalleryPrerelease -Name ModuleForge

        Install the newest ModuleForge from PSGallery, prereleases included, ignoring the version pinned in the file.

    .OUTPUTS
        [PSCustomObject] - Name and Version of each module installed

    .NOTES
        Author: Adrian Andersson
#>

[CmdletBinding()]
PARAM(
    #Where to install from. RequiredResourceFile uses the versions pinned in the file
    [Parameter()]
    [ValidateSet('RequiredResourceFile','PSGallery','PSGalleryPrerelease')]
    [string]$Source = 'RequiredResourceFile',
    #Only install these modules. Installs everything in the file by default
    [Parameter()]
    [string[]]$Name,
    #Install what the testDependencies setting in moduleForgeConfig.xml asks for, before a Pester run
    [Parameter()]
    [switch]$ForTests,
    #Root path of the project. Walks up from this script's directory to moduleForgeConfig.xml by default
    [Parameter()]
    [string]$ProjectPath,
    #Name of the dependency file in the project root
    [Parameter(DontShow)]
    [string]$DependencyFile = 'BuildDependencies.psd1'
)

#Modules every ModuleForge project needs, used when the file is missing
$essentialModules = 'ModuleForge','Pester','PSScriptAnalyzer'

if(!$ProjectPath)
{
    # Walk up from this script's directory to find moduleForgeConfig.xml
    $searchPath = $PSScriptRoot
    while($searchPath)
    {
        if(Get-ChildItem -LiteralPath $searchPath -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -ieq 'moduleForgeConfig.xml' })
        {
            $ProjectPath = $searchPath
            break
        }
        $parent = Split-Path $searchPath -Parent
        if($parent -eq $searchPath){ break }
        $searchPath = $parent
    }
    if(!$ProjectPath)
    {
        throw "Unable to locate 'moduleForgeConfig.xml' in '$PSScriptRoot' or any parent directory. Is this a ModuleForge project?"
    }
}
Write-Verbose "Project root: $ProjectPath"

function Get-MFModuleReport
{
    #One line per module: the version to import, and where it came from, for the log and PR comments
    PARAM([string]$ModuleName,[string]$From,[string]$Pin,[object]$Version,[string]$Prerelease)
    $description = switch($From)
    {
        'Pinned' { "pinned $Pin in $DependencyFile" }
        'NotPinned' { "latest from PSGallery, not pinned in $DependencyFile" }
        'NoFile' { "latest from PSGallery, no $DependencyFile" }
        'PSGallery' { 'latest from PSGallery' }
        'PSGalleryPrerelease' { 'latest from PSGallery, prereleases included' }
        'Runner' { "the runner's own copy, as testDependencies is None" }
    }
    [PSCustomObject]@{
        Name = $ModuleName
        #Import-Module -RequiredVersion takes the version without any prerelease label
        ModuleVersion = if($Version){ "$Version" }else{ $null }
        Version = if($Version){ "$Version$(if($Prerelease){"-$Prerelease"})" }else{ 'not found' }
        Source = $From
        Description = $description
    }
}

$testsAll = $false
if($ForTests)
{
    if($Name)
    {
        throw '-ForTests and -Name can not be used together. -ForTests takes its list from moduleForgeConfig.xml'
    }
    $testDependencies = $null
    $configFile = Get-ChildItem -LiteralPath $ProjectPath -File | Where-Object { $_.Name -ieq 'moduleForgeConfig.xml' } | Select-Object -First 1
    if($configFile)
    {
        try{
            $testDependencies = (Import-Clixml -LiteralPath $configFile.FullName -ErrorAction Stop).testDependencies
        }catch{
            Write-Warning "Unable to read moduleForgeConfig.xml, so using the default testDependencies"
        }
    }
    if(!$testDependencies)
    {
        $testDependencies = 'PesterAndScriptAnalyzer'
    }
    Write-Verbose "testDependencies: $testDependencies"
    switch($testDependencies)
    {
        'None' {
            Write-Information 'testDependencies is None, so nothing is installed. Tests use the modules the runner already has' -InformationAction Continue
            #Still report what the runner has, so the workflow imports a known version and the comment can say so
            foreach($toolName in 'Pester','PSScriptAnalyzer')
            {
                $found = Get-Module -ListAvailable -Name $toolName | Sort-Object -Property Version -Descending | Select-Object -First 1
                Get-MFModuleReport -ModuleName $toolName -From 'Runner' -Version $found.Version -Prerelease $found.PrivateData.PSData.Prerelease
            }
            return
        }
        'PesterAndScriptAnalyzer' { $Name = 'Pester','PSScriptAnalyzer' }
        'All' { $testsAll = $true }
        default { throw "Unknown testDependencies value '$testDependencies' in moduleForgeConfig.xml. Use None, PesterAndScriptAnalyzer or All" }
    }
}

#PSResourceGet installs everything else, so it is bootstrapped with the older PowerShellGet
$resourceGet = Get-Module -ListAvailable -Name Microsoft.PowerShell.PSResourceGet | Sort-Object -Property Version -Descending | Select-Object -First 1
if(!$resourceGet -or $resourceGet.Version -lt [version]'1.0.0')
{
    Write-Warning 'Microsoft.PowerShell.PSResourceGet 1.0.0 or newer not found. Installing it from PSGallery'
    Install-Module -Name Microsoft.PowerShell.PSResourceGet -Repository PSGallery -Force -SkipPublisherCheck -Scope CurrentUser
}
Import-Module Microsoft.PowerShell.PSResourceGet

$dependencyPath = Get-ChildItem -LiteralPath $ProjectPath -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -ieq $DependencyFile } | Select-Object -First 1
$dependencies = @{}
if($dependencyPath)
{
    #A data file, so loading it can't run code
    $dependencies = Import-PowerShellDataFile -LiteralPath $dependencyPath.FullName
}else{
    Write-Warning "No $DependencyFile found in $ProjectPath. Installing from PSGallery without version pinning"
}

$wanted = if($Name){ $Name }elseif($dependencies.Count){ @($dependencies.Keys) }else{ $essentialModules }
if($testsAll)
{
    #Tests can't run without Pester, so never leave it to the file to remember it. Tests load from source,
    #so they never need the ModuleForge that builds the module
    $wanted = @(@('Pester','PSScriptAnalyzer') + $wanted | Sort-Object -Unique | Where-Object { $_ -ne 'ModuleForge' })
}

#Match the file's module names case-insensitively, as PowerShell does
$fileNames = @{}
$dependencies.Keys.ForEach{ $fileNames[$_.ToLower()] = $_ }

$fromFile = @{}
$fromGallery = [System.Collections.Generic.List[string]]::new()
#Why each module comes from where it does, in the order asked for
$moduleSources = [ordered]@{}
foreach($moduleName in $wanted)
{
    $fileName = $fileNames[$moduleName.ToLower()]
    if($Source -eq 'RequiredResourceFile' -and $fileName)
    {
        $fromFile[$fileName] = $dependencies[$fileName]
        $moduleSources[$fileName] = 'Pinned'
    }
    else
    {
        if($Source -eq 'RequiredResourceFile')
        {
            Write-Warning "$moduleName is not pinned in $DependencyFile. Installing the latest from PSGallery"
        }
        $galleryName = if($fileName){ $fileName }else{ $moduleName }
        $fromGallery.Add($galleryName)
        $moduleSources[$galleryName] = if($Source -ne 'RequiredResourceFile'){ $Source }elseif($dependencyPath){ 'NotPinned' }else{ 'NoFile' }
    }
}

if($fromFile.Count)
{
    Write-Verbose "Installing from $($DependencyFile): $($fromFile.Keys -join ', ')"
    Install-PSResource -RequiredResource $fromFile -TrustRepository -AcceptLicense -Quiet -ErrorAction Stop
}
foreach($moduleName in $fromGallery)
{
    Write-Verbose "Installing $moduleName from PSGallery"
    $gallerySplat = @{
        Name = $moduleName
        Repository = 'PSGallery'
        TrustRepository = $true
        AcceptLicense = $true
        Quiet = $true
        ErrorAction = 'Stop'
    }
    if($Source -eq 'PSGalleryPrerelease')
    {
        $gallerySplat.Prerelease = $true
    }
    Install-PSResource @gallerySplat
}

#Report the version each module resolved to. For a pinned module that is the newest installed copy the pin
#allows, which may not be the newest installed copy, so workflows import it by exact version
foreach($moduleName in $moduleSources.Keys)
{
    $from = $moduleSources[$moduleName]
    $pin = if($from -eq 'Pinned'){ $fromFile[$moduleName].version }
    $installedSplat = @{ Name = $moduleName; ErrorAction = 'SilentlyContinue' }
    if($pin){ $installedSplat.Version = $pin }
    $installed = Get-InstalledPSResource @installedSplat | Sort-Object -Property Version -Descending | Select-Object -First 1
    Get-MFModuleReport -ModuleName $moduleName -From $from -Pin $pin -Version $installed.Version -Prerelease $installed.Prerelease
}