Private/Invoke-DFInstallPlan.ps1

#Requires -Version 7.0

function Invoke-DFInstallPlan {
    <#
    .SYNOPSIS
        Runs an install plan stage by stage and reports each tool's result.
    .DESCRIPTION
        For each stage, each manager batch:
          - its feeds are added if missing (listed first, added once);
          - the batch runs as one command when the manager supports batch,
            else one command per tool;
          - a batch the plan marked Elevate runs through its ElevateWith
            executable (the elevator role, e.g. gsudo);
          - when a batch fails, each of its tools is re-checked: one bad id
            doesn't fail the tools that did install.
        After the stage:
          - PATH is merged from the registry;
          - managers with installs.reactivate that installed something are
            re-activated (Register-DFTool -Name), so new runtimes reach PATH;
          - each tool is re-checked with no cached answer.
        A tool whose dependency failed or was skipped is skipped, naming it.
    .PARAMETER Plan
        From New-DFInstallPlan.
    .PARAMETER ToolDb
        Name -> tool record.
    .PARAMETER IsAvailable
        { param($record) } -> whether that tool is installed now.
    .PARAMETER ToolsPath
        Tools folder for re-activation. Default: the module's Tools/.
    .OUTPUTS
        PSCustomObject[]: Tool, Result (Installed, Failed, Skipped, NotFound), Detail.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject[]])]
    param(
        [Parameter(Mandatory)][pscustomobject]$Plan,
        [Parameter(Mandatory)][hashtable]$ToolDb,
        [Parameter(Mandatory)][scriptblock]$IsAvailable,
        [string]$ToolsPath
    )
    $pathArgs = if ($ToolsPath) { @{ ToolsPath = $ToolsPath } } else { @{} }
    $result = [ordered]@{}
    $set = { param($tool, $r, $d) $result[$tool] = [pscustomobject]@{ Tool = $tool; Result = $r; Detail = $d } }
    $badDep = {
        param($item)
        foreach ($d in $item.DependsOn) {
            if ($result.Contains($d) -and $result[$d].Result -in 'Failed', 'Skipped', 'NotFound') { return $d }
        }
    }
    $skipFor = {
        param($d)
        $why = @{ Failed = 'failed'; Skipped = 'was skipped'; NotFound = "isn't found yet" }[$result[$d].Result]
        "skipped: $d $why"
    }

    foreach ($stage in $Plan.Stages) {
        $reactivate = [System.Collections.Generic.List[string]]::new()
        foreach ($batch in $stage.Batches) {
            $m = $batch.Manager
            $blk = $batch.Block
            $ready = @(foreach ($it in $batch.Items) {
                $d = & $badDep $it
                if ($d) { & $set $it.Tool 'Skipped' (& $skipFor $d) } else { $it }
            })
            if (-not $ready) { continue }
            $elevate = [bool]$batch.Elevate
            if ($elevate -and -not $batch.ElevateWith) {
                foreach ($it in $ready) { & $set $it.Tool 'Skipped' "$($m.name) needs an elevated shell: rerun as administrator, or install an elevator such as gsudo" }
                continue
            }
            # Feeds first, each once, and only those the manager doesn't list yet.
            if ($blk.feeds) {
                $feeds = @($ready | Where-Object { $_.Ref.Feed } | ForEach-Object { $_.Ref.Feed } | Sort-Object name -Unique)
                if ($feeds) {
                    $listed = (Invoke-DFInstallCommand -Manager $m -Argv (Expand-DFInstallArgv -Template $blk.feeds.list -Values @{})).Output
                    $names = @($listed -split "`r?`n" | ForEach-Object { ($_.Trim() -split '\s+')[0] } | Where-Object { $_ })
                    foreach ($f in $feeds | Where-Object { $_.name -notin $names }) {
                        $r = Invoke-DFInstallCommand -Manager $m -Argv (Expand-DFInstallArgv -Template $blk.feeds.add -Values @{ name = $f.name; url = $f.url })
                        if ($r.ExitCode -ne 0) { Write-Warning "DotForge: could not add $($m.name) feed '$($f.name)' ($($f.url))." }
                    }
                }
            }
            $idOf = {
                param($it)
                if ($it.Ref.Feed -and $blk.feeds) { $blk.feeds.id.Replace('{feed}', $it.Ref.Feed.name).Replace('{id}', $it.Ref.Id) }
                else { $it.Ref.Id }
            }
            # One call for the whole batch when the manager supports it, else one per tool.
            # (A List, not `if { ,$ready }`: statement output would unroll the wrapper.)
            $groups = [System.Collections.Generic.List[object]]::new()
            if ($blk.batch) { $groups.Add($ready) } else { foreach ($it in $ready) { $groups.Add(@($it)) } }
            foreach ($group in $groups) {
                $ids = [string[]]@($group | ForEach-Object { & $idOf $_ })
                $r = if ($blk.function) {
                    $fnArgs = @{}
                    foreach ($p in $blk.args.PSObject.Properties) { $fnArgs[$p.Name] = if ($p.Value -eq '{id}') { $ids } else { $p.Value } }
                    Invoke-DFInstallCommand -Manager $m -Function $blk.function -Arguments $fnArgs
                } else {
                    Invoke-DFInstallCommand -Manager $m -Argv (Expand-DFInstallArgv -Template $blk.command -Values @{ ids = $ids }) -Elevate:$elevate -ElevateWith $batch.ElevateWith
                }
                foreach ($it in $group) {
                    $t = $ToolDb[$it.Tool]
                    # A failed batch may still have installed some of its tools.
                    $ok = $r.ExitCode -eq 0 -or ($group.Count -gt 1 -and (Test-DFToolAvailable -Executable $t.executable -Type $t.type -Force))
                    if ($ok) { & $set $it.Tool 'Installed' "via $($m.name)" }
                    else {
                        $tail = @("$($r.Output)".Trim() -split "`r?`n" | Select-Object -Last 3) -join ' / '
                        & $set $it.Tool 'Failed' "$($m.name) failed: $tail"
                    }
                }
                if ($r.ExitCode -eq 0 -and $blk.reactivate -and -not $reactivate.Contains($m.name)) { $reactivate.Add($m.name) }
            }
        }
        foreach ($p in $stage.Provided) {
            $d = & $badDep $p
            if ($d) { & $set $p.Tool 'Skipped' (& $skipFor $d) } else { & $set $p.Tool 'Installed' "comes with $($p.ProvidedBy)" }
        }

        Update-DFPathFromRegistry
        if ($reactivate.Count) { Register-DFTool -Name $reactivate.ToArray() @pathArgs 3>$null }
        foreach ($it in @($stage.Batches | ForEach-Object { $_.Items }) + @($stage.Provided)) {
            if (-not $it -or $result[$it.Tool].Result -ne 'Installed') { continue }
            $t = $ToolDb[$it.Tool]
            if (-not (Test-DFToolAvailable -Executable $t.executable -Type $t.type -Force)) {
                & $set $it.Tool 'NotFound' "installed, but '$($t.executable)' isn't found yet: open a new shell"
            }
        }
    }
    @($result.Values)
}