PwshSetupCore.ps1

# PwshSetupCore.ps1 0.0.3, built from src by Publish-SetupModule.ps1. Do not edit this file.

#region Core.psm1

<#
.SYNOPSIS
    Internal: the context a run shares across its parts, the data folders, the log file, markup escaping and durations.
#>


$script:SetupDataRoot = Join-Path -Path $env:ProgramData -ChildPath 'PwshSetup'

# StepPath is the steps folder being loaded, so Use-SetupTemplate finds the templates beside it.
# Task is the Invoke-AnsiTask control of the running step, so Write-SetupLog can print under it.
# RestartRequest is 'After' or 'Retry', set by Request-SetupRestart while a step runs.
$script:SetupContext = [pscustomobject]@{
    StepPath       = $null
    LogFile        = $null
    Task           = $null
    StepId         = $null
    RestartRequest = $null
}

# ── Context ──────────────────────────────────────────────────────────────────

function Get-SetupContext
{
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param()

    return $script:SetupContext
}

function Reset-SetupContext
{
    [CmdletBinding()]
    param()

    $script:SetupContext.StepPath = $null
    $script:SetupContext.LogFile = $null
    $script:SetupContext.Task = $null
    $script:SetupContext.StepId = $null
    $script:SetupContext.RestartRequest = $null
}

function Test-SetupInteractive
{
    [CmdletBinding()]
    [OutputType([bool])]
    param()

    try
    {
        return -not ([Console]::IsInputRedirected -or [Console]::IsOutputRedirected)
    }
    catch
    {
        return $false
    }
}

# ── Paths ────────────────────────────────────────────────────────────────────

function Get-SetupDataPath
{
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)] [ValidateSet('Logs', 'State')] [string] $Name
    )

    return (Join-Path -Path $script:SetupDataRoot -ChildPath $Name)
}

# The built module keeps the templates in Templates beside it; the source tree keeps them in templates beside src.
function Get-SetupTemplateRoot
{
    [CmdletBinding()]
    [OutputType([string])]
    param()

    $candidates = @(
        (Join-Path -Path $PSScriptRoot -ChildPath 'Templates')
        (Join-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -ChildPath 'templates')
    )
    foreach ($candidate in $candidates)
    {
        if (Test-Path -LiteralPath $candidate -PathType Container)
        {
            return $candidate
        }
    }

    throw ('No templates folder found beside {0}.' -f $PSScriptRoot)
}

function Get-SetupOwnTemplateRoot
{
    # Your own templates: the templates folder beside the steps folder, or nothing when there is none.
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [string] $StepPath = $script:SetupContext.StepPath
    )

    if (-not $StepPath)
    {
        return $null
    }
    $root = Join-Path -Path (Split-Path -Path $StepPath -Parent) -ChildPath 'templates'
    if (Test-Path -LiteralPath $root -PathType Container)
    {
        return $root
    }
    return $null
}

# ── Log ──────────────────────────────────────────────────────────────────────

function Write-SetupLogFile
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [AllowEmptyString()] [string] $Message,
        [ValidateSet('INFO', 'WARN', 'ERROR', 'OUTPUT')] [string] $Level = 'INFO'
    )

    $file = $script:SetupContext.LogFile
    if (-not $file)
    {
        return
    }

    $stamp = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss', [cultureinfo]::InvariantCulture)
    $step = '-'
    if ($script:SetupContext.StepId)
    {
        $step = $script:SetupContext.StepId
    }

    $lines = foreach ($line in ($Message.TrimEnd() -split '\r?\n'))
    {
        '{0} {1,-6} {2} {3}' -f $stamp, $Level, $step, $line
    }
    [System.IO.File]::AppendAllLines($file, [string[]]@($lines))
}

# ── Text ─────────────────────────────────────────────────────────────────────

function ConvertTo-SetupLiteral
{
    # Brackets doubled, so PwshAnsi markup prints the text as typed.
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)] [AllowEmptyString()] [AllowNull()] [string] $Text
    )

    return (([string]$Text -replace '\[', '[[') -replace '\]', ']]')
}

function Format-SetupDuration
{
    # 0.4s, 2.1s, 1m 05s, 1h 05m, the same on every culture.
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)] [TimeSpan] $Duration
    )

    $culture = [cultureinfo]::InvariantCulture
    if ($Duration.TotalSeconds -lt 60)
    {
        return [string]::Format($culture, '{0:0.0}s', $Duration.TotalSeconds)
    }
    if ($Duration.TotalMinutes -lt 60)
    {
        return [string]::Format($culture, '{0}m {1:00}s', [Math]::Floor($Duration.TotalMinutes), $Duration.Seconds)
    }
    return [string]::Format($culture, '{0}h {1:00}m', [Math]::Floor($Duration.TotalHours), $Duration.Minutes)
}

#endregion Core.psm1

#region Steps.psm1

<#
.SYNOPSIS
    Internal: turns a folder of step files into ordered step objects, runs a step's Test, and reads what a step installs.
.DESCRIPTION
    Every .ps1 in the folder is a step, ordered by the number its name starts with.
    Step files and templates each run in a module of their own, so the values they set at the top, or take as
    parameters, stay reachable from their Test and Action when those run later. Inside both, $ErrorActionPreference is
    Stop, and progress bars are off: they would draw over the run's own progress lines.
#>



# ── Loading ──────────────────────────────────────────────────────────────────

function Import-SetupStep
{
    [CmdletBinding()]
    [OutputType([pscustomobject[]])]
    param(
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Path
    )

    (Get-SetupContext).StepPath = $Path
    $files = Get-ChildItem -LiteralPath $Path -Filter '*.ps1' -File
    $steps = foreach ($file in $files)
    {
        Import-SetupStepFile -File $file
    }
    return @(@($steps) | Sort-Object -Property Order, Id)
}

function Import-SetupStepFile
{
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)] [System.IO.FileInfo] $File
    )

    try
    {
        $step = Import-SetupScript -Path $File.FullName
    }
    catch
    {
        throw ('Step file {0}: {1}' -f $File.Name, $_.Exception.Message)
    }

    $step.Id = $File.BaseName
    $step.Order = [int]::MaxValue
    if ($File.Name -match '^(?<order>\d+)')
    {
        $step.Order = [int]$Matches.order
    }
    $step.File = $File.FullName
    return $step
}

function Import-SetupScript
{
    # Runs a step file or a template in a module of its own and returns the one step it describes. A step that came
    # through Use-SetupTemplate keeps the template's module.
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)] [string] $Path,
        [hashtable] $Parameter = @{}
    )

    $loader = {
        param($SetupScriptPath, $SetupScriptParameter)

        $ErrorActionPreference = 'Stop'
        $ProgressPreference = 'SilentlyContinue'
        $script:SetupScriptOutput = @(. $SetupScriptPath @SetupScriptParameter)
    }
    $moduleName = 'PwshSetup.Step.{0}' -f [System.IO.Path]::GetFileNameWithoutExtension($Path)
    $module = New-Module -Name $moduleName -ScriptBlock $loader -ArgumentList $Path, $Parameter

    $output = @(& $module { $script:SetupScriptOutput })
    $found = @($output | Where-Object -FilterScript { $_ -is [psobject] -and $_.PSObject.TypeNames -contains 'PwshSetup.Step' })
    if ($found.Count -ne 1)
    {
        throw ('it returned {0} steps, and a step file returns exactly one New-SetupStep or Use-SetupTemplate.' -f $found.Count)
    }

    $step = $found[0]
    if (-not $step.Module)
    {
        $step.Module = $module
    }
    return $step
}

# ── Checks ───────────────────────────────────────────────────────────────────

function Test-SetupStepDone
{
    # The last thing the Test writes decides, by PowerShell truthiness, so a -match that finds nothing reads as not done.
    # A Test that throws counts as not done too, and Error says why: often it checks what an earlier step installs.
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)] [pscustomobject] $Step
    )

    if (-not $Step.Test)
    {
        return [pscustomobject]@{ Done = $false; Error = $null }
    }

    try
    {
        $output = @(& $Step.Test 3>$null 6>$null)
        $done = $output.Count -gt 0 -and [bool]$output[-1]
        return [pscustomobject]@{ Done = $done; Error = $null }
    }
    catch
    {
        return [pscustomobject]@{ Done = $false; Error = $_.Exception.Message }
    }
}

function Get-SetupStepPackage
{
    # The Chocolatey packages a step's Action names, read from its syntax tree without running it. A name in a variable
    # set at the top of the file, or passed to a template, is read from the step's module; one set inside the Action
    # cannot be known before it runs, and Unknown counts it.
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)] [pscustomobject] $Step
    )

    $isInstall = {
        param($Node)
        $Node -is [System.Management.Automation.Language.CommandAst] -and $Node.GetCommandName() -eq 'Install-ChocoPackage'
    }
    $names = [System.Collections.Generic.List[string]]::new()
    $unknown = 0
    foreach ($command in $Step.Action.Ast.FindAll($isInstall, $true))
    {
        $value = Get-SetupNameArgument -Command $command
        $name = $null
        if ($value -is [System.Management.Automation.Language.StringConstantExpressionAst])
        {
            $name = $value.Value
        }
        elseif ($value -is [System.Management.Automation.Language.VariableExpressionAst] -and $Step.Module)
        {
            $known = $Step.Module.SessionState.PSVariable.GetValue($value.VariablePath.UserPath)
            if ($known -is [string])
            {
                $name = $known
            }
        }

        if ($name)
        {
            $names.Add($name)
        }
        else
        {
            $unknown++
        }
    }

    return [pscustomobject]@{ Names = $names.ToArray(); Unknown = $unknown }
}

function Get-SetupNameArgument
{
    # The value given to -Name, or the first positional argument.
    [CmdletBinding()]
    [OutputType([System.Management.Automation.Language.Ast])]
    param(
        [Parameter(Mandatory)] [System.Management.Automation.Language.CommandAst] $Command
    )

    $elements = $Command.CommandElements
    for ($index = 1; $index -lt $elements.Count; $index++)
    {
        $element = $elements[$index]
        if ($element -isnot [System.Management.Automation.Language.CommandParameterAst])
        {
            return $element
        }
        if ($element.ParameterName -eq 'Name')
        {
            if ($element.Argument)
            {
                return $element.Argument
            }
            if ($index + 1 -lt $elements.Count)
            {
                return $elements[$index + 1]
            }
            return $null
        }
        if (-not $element.Argument -and $element.ParameterName -ne 'PassThru')
        {
            $index++
        }
    }
    return $null
}

#endregion Steps.psm1

#region State.psm1

<#
.SYNOPSIS
    Internal: what a run remembers across restarts.
.DESCRIPTION
    One JSON file per step folder under %ProgramData%\PwshSetup\State, and beside it the script parameters the resume
    task passes back. Written through a temp file, so a restart mid-write cannot leave half a file behind.
#>



# ── Files ────────────────────────────────────────────────────────────────────

function Get-SetupStateFile
{
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)] [string] $StepPath,
        [ValidateSet('State', 'Arguments')] [string] $Kind = 'State'
    )

    $bytes = [System.Text.Encoding]::UTF8.GetBytes($StepPath.ToLowerInvariant())
    $hash = [Convert]::ToHexString([System.Security.Cryptography.SHA256]::HashData($bytes)).Substring(0, 16).ToLowerInvariant()
    $extension = 'json'
    if ($Kind -eq 'Arguments')
    {
        $extension = 'args.clixml'
    }
    return (Join-Path -Path (Get-SetupDataPath -Name 'State') -ChildPath ('{0}.{1}' -f $hash, $extension))
}

function New-SetupFolder
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string] $Path
    )

    if (-not (Test-Path -LiteralPath $Path))
    {
        $null = New-Item -ItemType Directory -Path $Path -Force
    }
}

# ── State ────────────────────────────────────────────────────────────────────

function New-SetupState
{
    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)] [string] $StepPath,
        [AllowEmptyString()] [string] $ScriptPath = '',
        [Parameter(Mandatory)] [string] $LogFile,
        [Parameter(Mandatory)] [string[]] $Selected
    )

    return @{
        StepPath     = $StepPath
        ScriptPath   = $ScriptPath
        LogFile      = $LogFile
        StartedAt    = (Get-Date).ToUniversalTime().ToString('o')
        Selected     = @($Selected)
        Results      = @{}
        Retries      = @{}
        Restarts     = 0
        RestartingAt = $null
    }
}

function Read-SetupState
{
    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)] [string] $StepPath
    )

    $file = Get-SetupStateFile -StepPath $StepPath
    if (-not (Test-Path -LiteralPath $file))
    {
        return $null
    }

    try
    {
        $state = Get-Content -LiteralPath $file -Raw | ConvertFrom-Json -AsHashtable
    }
    catch
    {
        return $null
    }
    if (-not $state -or $state.StepPath -ne $StepPath)
    {
        return $null
    }

    $state.Selected = @($state.Selected)
    if ($null -eq $state.Results)
    {
        $state.Results = @{}
    }
    if ($null -eq $state.Retries)
    {
        $state.Retries = @{}
    }
    return $state
}

function Save-SetupState
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [hashtable] $State
    )

    $file = Get-SetupStateFile -StepPath $State.StepPath
    New-SetupFolder -Path (Split-Path -Path $file -Parent)

    $temp = '{0}.tmp' -f $file
    $State | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $temp -Encoding utf8NoBOM
    Move-Item -LiteralPath $temp -Destination $file -Force
}

function Remove-SetupState
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string] $StepPath
    )

    foreach ($kind in 'State', 'Arguments')
    {
        Remove-Item -LiteralPath (Get-SetupStateFile -StepPath $StepPath -Kind $kind) -Force -ErrorAction Ignore
    }
}

function Save-SetupArgument
{
    # Switches become booleans: a SwitchParameter does not come back from Import-Clixml as one.
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)] [string] $StepPath,
        [AllowNull()] [hashtable] $Arguments
    )

    $portable = @{}
    if ($Arguments)
    {
        foreach ($key in $Arguments.Keys)
        {
            $value = $Arguments[$key]
            if ($value -is [System.Management.Automation.SwitchParameter])
            {
                $value = $value.IsPresent
            }
            $portable[$key] = $value
        }
    }

    $file = Get-SetupStateFile -StepPath $StepPath -Kind 'Arguments'
    New-SetupFolder -Path (Split-Path -Path $file -Parent)
    $portable | Export-Clixml -LiteralPath $file
    return $file
}

function Set-SetupStepResult
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [hashtable] $State,
        [Parameter(Mandatory)] [string] $Id,
        [Parameter(Mandatory)] [ValidateSet('Done', 'Skipped', 'Failed', 'Retry')] [string] $Status,
        [TimeSpan] $Duration = [TimeSpan]::Zero,
        [AllowEmptyString()] [string] $Reason = ''
    )

    $State.Results[$Id] = @{
        Status  = $Status
        Seconds = [Math]::Round($Duration.TotalSeconds, 1)
        Reason  = $Reason
    }
}

# ── Restarts ─────────────────────────────────────────────────────────────────

function ConvertTo-SetupUtc
{
    # ConvertFrom-Json hands ISO dates back as DateTime; a string is parsed the same way.
    [CmdletBinding()]
    [OutputType([datetime])]
    param(
        [Parameter(Mandatory)] [object] $Value
    )

    if ($Value -is [datetime])
    {
        return $Value.ToUniversalTime()
    }
    $styles = [System.Globalization.DateTimeStyles]::RoundtripKind
    return [datetime]::Parse([string]$Value, [cultureinfo]::InvariantCulture, $styles).ToUniversalTime()
}

function Test-SetupRestartedSince
{
    # The run asked for a restart, and the machine has booted since.
    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory)] [hashtable] $State
    )

    if (-not $State.RestartingAt)
    {
        return $false
    }

    $restartingAt = ConvertTo-SetupUtc -Value $State.RestartingAt
    $bootedAt = (Get-CimInstance -ClassName 'Win32_OperatingSystem').LastBootUpTime.ToUniversalTime()
    return ($bootedAt -gt $restartingAt)
}

#endregion State.psm1

#region Restart.psm1

<#
.SYNOPSIS
    Internal: restarts that resume.
.DESCRIPTION
    Before restarting, the run registers a scheduled task that starts its script again at the next sign-in of the same
    user, elevated and on that user's desktop. The resumed run removes the task first thing.
#>



$script:ResumeTaskName = 'Resume'
$script:ResumeTaskPath = '\PwshSetup\'
$script:RestartCountdownSeconds = 10

function Register-SetupResume
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string] $ScriptPath,
        [Parameter(Mandatory)] [string] $ArgumentFile
    )

    $quote = { param($Text) "'" + ($Text -replace "'", "''") + "'" }
    $command = @(
        '$env:PWSHSETUP_RERUN = ''resume'''
        ('Set-Location -LiteralPath {0}' -f (& $quote (Split-Path -Path $ScriptPath -Parent)))
        ('$p = Import-Clixml -LiteralPath {0}' -f (& $quote $ArgumentFile))
        ('& {0} @p' -f (& $quote $ScriptPath))
        'exit $LASTEXITCODE'
    ) -join "`n"
    $encoded = [Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($command))

    $pwsh = (Get-Process -Id $PID).Path
    $user = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name

    $actionArguments = @{
        Execute  = $pwsh
        Argument = ('-NoProfile -ExecutionPolicy Bypass -EncodedCommand {0}' -f $encoded)
    }
    $settingsArguments = @{
        AllowStartIfOnBatteries    = $true
        DontStopIfGoingOnBatteries = $true
        ExecutionTimeLimit         = [TimeSpan]::Zero
        MultipleInstances          = 'IgnoreNew'
    }
    $taskArguments = @{
        TaskName    = $script:ResumeTaskName
        TaskPath    = $script:ResumeTaskPath
        Action      = New-ScheduledTaskAction @actionArguments
        Trigger     = New-ScheduledTaskTrigger -AtLogOn -User $user
        Principal   = New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Highest
        Settings    = New-ScheduledTaskSettingsSet @settingsArguments
        Force       = $true
        ErrorAction = 'Stop'
    }
    $null = Register-ScheduledTask @taskArguments

    Write-SetupLogFile -Message ('Registered the resume task for {0}.' -f $user)
}

function Unregister-SetupResume
{
    [CmdletBinding()]
    param()

    $task = Get-ScheduledTask -TaskName $script:ResumeTaskName -TaskPath $script:ResumeTaskPath -ErrorAction Ignore
    if ($task)
    {
        Unregister-ScheduledTask -TaskName $script:ResumeTaskName -TaskPath $script:ResumeTaskPath -Confirm:$false
    }
}

function Restart-SetupComputer
{
    # $true when the restart goes ahead, $false when Esc cancels it. With nobody at the keyboard the countdown runs out
    # and the restart goes ahead.
    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory)] [string] $StepName
    )

    $stepText = ConvertTo-SetupLiteral -Text $StepName
    $message = '[BrightYellow]{0} needs a restart.[/] [DarkGray]Setup carries on after the next sign-in.[/]' -f $stepText
    Format-AnsiText -Message $message | Out-AnsiHost

    if (Test-SetupInteractive)
    {
        $pauseArguments = @{
            Message        = 'Restarting. Any key restarts now, Esc cancels'
            TimeoutSeconds = $script:RestartCountdownSeconds
            ShowCountdown  = $true
            KeepMessage    = $true
            MessageColor   = 'BrightYellow'
        }
        $answer = Read-AnsiPause @pauseArguments
        if ($false -eq $answer)
        {
            Write-SetupLogFile -Message 'Restart cancelled.' -Level 'WARN'
            return $false
        }
    }

    Write-SetupLogFile -Message 'Restarting.'
    # shutdown.exe, not Restart-Computer: Restart-Computer goes through WMI, which can time out while the machine is
    # still starting its services. shutdown.exe asks the system directly and returns at once.
    & shutdown.exe /r /t 0 /f
    if ($LASTEXITCODE -ne 0)
    {
        throw ('shutdown.exe exited with code {0} while requesting restart.' -f $LASTEXITCODE)
    }
    return $true
}

#endregion Restart.psm1

#region Shell.psm1

<#
.SYNOPSIS
    Internal: the screens of a run, drawn with PwshAnsi. Header, key line and keys, the step list, the dry run, the
    summary, the failure.
#>



$script:ResultLabel = @{
    Done    = '[BrightGreen]Done[/]'
    Skipped = '[DarkGray]Already done[/]'
    Failed  = '[BrightRed]Failed[/]'
    Retry   = '[BrightYellow]After restart[/]'
    NotRun  = '[DarkGray]Not run[/]'
}
$script:ReasonLength = 60
$script:ResizeSettleMilliseconds = 150
$script:KeyPollMilliseconds = 40
$script:TableArguments = @{
    Border            = 'Rounded'
    BorderColor       = 'DarkGray'
    HeaderColor       = 'BrightCyan'
    ShowRowSeparators = $true
}

# ── Screen ───────────────────────────────────────────────────────────────────

function Clear-SetupScreen
{
    [CmdletBinding()]
    param()

    if (-not (Test-SetupInteractive))
    {
        return
    }
    Clear-Host
    [Console]::Write(('{0}[3J' -f [char]27))
}

function Write-SetupBlankLine
{
    [CmdletBinding()]
    param()

    [Console]::WriteLine()
}

function Write-SetupHeader
{
    # Label and value pairs, one to a row. Values may carry markup.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]] $Item
    )

    Format-AnsiRule -Title '[BrightWhite]PwshSetup[/]' -Alignment Left -LineColor DarkGray | Out-AnsiHost
    if ($Item.Count -gt 0)
    {
        $width = ($Item | ForEach-Object -Process { ([string]$_[0]).Length } | Measure-Object -Maximum).Maximum
        $rows = [System.Collections.Generic.List[object]]::new()
        foreach ($pair in $Item)
        {
            $rows.Add([string[]]@(('[DarkGray]{0}[/]' -f ([string]$pair[0]).PadRight($width)), [string]$pair[1]))
        }
        Format-AnsiGrid -Rows $rows.ToArray() -Padding 3 | Out-AnsiHost
    }
    Write-SetupBlankLine
}

function Write-SetupNote
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [AllowEmptyString()] [string] $Text,
        [string] $Color
    )

    $message = ConvertTo-SetupLiteral -Text $Text
    if ($Color)
    {
        $message = '[{0}]{1}[/]' -f $Color, $message
    }
    Format-AnsiText -Message $message | Out-AnsiHost
}

# ── Keys ─────────────────────────────────────────────────────────────────────

function Write-SetupKeyLine
{
    # Every entry is "<key> <label>", e.g. "F6 Run".
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string[]] $Key
    )

    $parts = foreach ($entry in $Key)
    {
        $split = $entry -split ' ', 2
        '[BrightCyan]{0}[/] {1}' -f $split[0], (ConvertTo-SetupLiteral -Text $split[1])
    }
    Format-AnsiText -Message ($parts -join ' [DarkGray]·[/] ') | Out-AnsiHost
}

function Read-SetupKey
{
    # Waits under a screen: a listed key or Esc acts at once, and a resize returns once the window keeps one size, so
    # the caller redraws. Action is Key, Back or Resize.
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)] [string[]] $Key
    )

    $Host.UI.RawUI.FlushInputBuffer()
    $width = [Console]::WindowWidth
    $height = [Console]::WindowHeight
    $resizedAt = $null

    while ($true)
    {
        if (-not [Console]::KeyAvailable)
        {
            if ([Console]::WindowWidth -ne $width -or [Console]::WindowHeight -ne $height)
            {
                $width = [Console]::WindowWidth
                $height = [Console]::WindowHeight
                $resizedAt = [datetime]::UtcNow
            }
            elseif ($resizedAt -and ([datetime]::UtcNow - $resizedAt).TotalMilliseconds -ge $script:ResizeSettleMilliseconds)
            {
                return [pscustomobject]@{ Action = 'Resize'; Key = '' }
            }
            Start-Sleep -Milliseconds $script:KeyPollMilliseconds
            continue
        }

        $name = [string][Console]::ReadKey($true).Key
        if ($name -eq 'Escape')
        {
            return [pscustomobject]@{ Action = 'Back'; Key = $name }
        }
        if ($Key -contains $name)
        {
            return [pscustomobject]@{ Action = 'Key'; Key = $name }
        }
    }
}

function Read-SetupConfirm
{
    # No is the default: Enter alone never starts anything.
    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory)] [string] $Prompt
    )

    $Host.UI.RawUI.FlushInputBuffer()
    return [bool](Read-AnsiConfirm -Prompt $Prompt -Default $false)
}

# ── Steps ────────────────────────────────────────────────────────────────────

function Format-SetupOrder
{
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)] [pscustomobject] $Step
    )

    if ($Step.Order -eq [int]::MaxValue)
    {
        return ''
    }
    return ('{0:000}' -f $Step.Order)
}

function Format-SetupStepAction
{
    # What a step does, read from its code: install the packages it names, or run; then restart when it says so.
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)] [pscustomobject] $Step
    )

    $packages = Get-SetupStepPackage -Step $Step
    $action = 'run'
    if ($packages.Names.Count -gt 0)
    {
        $action = 'install {0}' -f ($packages.Names -join ', ')
        if ($packages.Unknown -gt 0)
        {
            $action += ' and more'
        }
    }
    elseif ($packages.Unknown -gt 0)
    {
        $action = 'install packages'
    }

    $text = '[BrightCyan]{0}[/]' -f (ConvertTo-SetupLiteral -Text $action)
    if ($Step.RequiresRestart)
    {
        $text += ', [BrightYellow]then restart[/]'
    }
    return $text
}

function Write-SetupList
{
    # The steps folder as it stands, in run order, read without running any of it.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [object[]] $Steps
    )

    $rows = foreach ($step in $Steps)
    {
        $from = '[DarkGray]own[/]'
        if ($step.Template)
        {
            $from = ConvertTo-SetupLiteral -Text $step.Template
        }
        [pscustomobject]@{
            '#'  = Format-SetupOrder -Step $step
            Step = ConvertTo-SetupLiteral -Text $step.Name
            From = $from
            Does = Format-SetupStepAction -Step $step
        }
    }
    $tableArguments = $script:TableArguments
    Format-AnsiTable -Data @($rows) @tableArguments | Out-AnsiHost
}

# ── Dry run ──────────────────────────────────────────────────────────────────

function Get-SetupPlan
{
    # What each step's Test reports, behind one progress line. Tests only look, so this changes nothing.
    [CmdletBinding()]
    [OutputType([pscustomobject[]])]
    param(
        [Parameter(Mandatory)] [object[]] $Steps
    )

    $plan = [System.Collections.Generic.List[object]]::new()
    $check = {
        param($Task)

        for ($index = 0; $index -lt $Steps.Count; $index++)
        {
            $result = Test-SetupStepDone -Step $Steps[$index]
            $plan.Add([pscustomobject]@{ Step = $Steps[$index]; Done = $result.Done; Error = $result.Error })
            $Task.Update($index + 1, $Steps.Count)
        }
    }
    Invoke-AnsiTask -Name ('Checking {0} steps' -f $Steps.Count) -ScriptBlock $check -ProgressShow Percent
    return $plan.ToArray()
}

function Write-SetupPlan
{
    # The dry run: what each step would do, from its Test and its code, and a line that sums it up.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [object[]] $Plan
    )

    $rows = foreach ($row in $Plan)
    {
        $would = '[DarkGray]skip, already done[/]'
        if (-not $row.Done)
        {
            $would = Format-SetupStepAction -Step $row.Step
        }
        if ($row.Error)
        {
            $reason = $row.Error
            if ($reason.Length -gt $script:ReasonLength)
            {
                $reason = '{0}...' -f $reason.Substring(0, $script:ReasonLength)
            }
            $would += ' [DarkGray](check failed: {0})[/]' -f (ConvertTo-SetupLiteral -Text $reason)
        }
        [pscustomobject]@{
            '#'   = Format-SetupOrder -Step $row.Step
            Step  = ConvertTo-SetupLiteral -Text $row.Step.Name
            Would = $would
        }
    }
    $tableArguments = $script:TableArguments
    Format-AnsiTable -Data @($rows) @tableArguments | Out-AnsiHost

    $pending = @($Plan | Where-Object -FilterScript { -not $_.Done })
    $restarts = @($pending | Where-Object -FilterScript { $_.Step.RequiresRestart }).Count
    $summary = 'Dry run: {0} of {1} steps would run, and {2} would restart the machine. Nothing ran.' -f
    $pending.Count, $Plan.Count, $restarts
    Write-SetupNote -Text $summary -Color 'BrightCyan'
}

# ── Results ──────────────────────────────────────────────────────────────────

function Write-SetupSummary
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]] $Steps,
        [Parameter(Mandatory)] [hashtable] $State
    )

    if ($Steps.Count -eq 0)
    {
        return
    }

    $rows = foreach ($step in $Steps)
    {
        $result = $State.Results[$step.Id]
        $status = 'NotRun'
        $time = ''
        if ($result)
        {
            $status = $result.Status
            if ($result.Seconds -gt 0 -and $status -ne 'Skipped')
            {
                $time = Format-SetupDuration -Duration ([TimeSpan]::FromSeconds($result.Seconds))
            }
        }

        [pscustomobject]@{
            '#'    = Format-SetupOrder -Step $step
            Step   = ConvertTo-SetupLiteral -Text $step.Name
            Result = $script:ResultLabel[$status]
            Time   = $time
        }
    }
    Write-SetupBlankLine
    $tableArguments = $script:TableArguments
    Format-AnsiTable -Data @($rows) @tableArguments | Out-AnsiHost
}

function Write-SetupFailure
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [object] $ErrorRecord,
        [AllowEmptyString()] [AllowNull()] [string] $LogFile
    )

    Write-SetupBlankLine
    Format-AnsiRule -Title '[BrightRed]Failed[/]' -Alignment Left -LineColor DarkGray | Out-AnsiHost
    Format-AnsiException -Exception $ErrorRecord | Out-AnsiHost
    if ($LogFile)
    {
        Format-AnsiText -Message ('[DarkGray]Log[/] {0}' -f (ConvertTo-SetupLiteral -Text $LogFile)) | Out-AnsiHost
    }
}

function Wait-SetupClose
{
    # A window this run opened itself, elevated or resumed, stays open until a key is pressed, so its last screen
    # can be read. Unattended runs never wait for a key.
    [CmdletBinding()]
    param(
        [switch] $Unattended
    )

    if ($Unattended -or -not $env:PWSHSETUP_RERUN -or -not (Test-SetupInteractive))
    {
        return
    }
    Write-SetupBlankLine
    $null = Read-AnsiPause -Message 'Press any key to close'
}

#endregion Shell.psm1

#region New-SetupStep.psm1

<#
.SYNOPSIS
    Public: New-SetupStep, the object every step file returns.
#>


function New-SetupStep
{
    <#
    .SYNOPSIS
        Describes one setup step. A step file returns exactly one.
    .DESCRIPTION
        Test says whether the step is already done: $true skips it. Omit it and the step always runs.
        Action does the work. Throwing fails the step and stops the run.
        RequiresRestart restarts the machine once Action is done, and the run carries on with the next step after the
        next sign-in.
        The run order comes from the number the file name starts with.
    .PARAMETER Name
        What the screen and the log call the step.
    .PARAMETER Action
        The work.
    .PARAMETER Test
        $true when the work is already done.
    .PARAMETER RequiresRestart
        Restart once Action is done.
    .EXAMPLE
        New-SetupStep -Name 'Install Git' -Test { Test-SetupCommand -Name 'git' } -Action { Install-ChocoPackage -Name 'git' }
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Name,
        [Parameter(Mandatory)] [ValidateNotNull()] [scriptblock] $Action,
        [ValidateNotNull()] [scriptblock] $Test,
        [switch] $RequiresRestart
    )

    return [pscustomobject]@{
        PSTypeName      = 'PwshSetup.Step'
        Id              = $null
        Order           = [int]::MaxValue
        Name            = $Name
        Test            = $Test
        Action          = $Action
        RequiresRestart = [bool]$RequiresRestart
        File            = $null
        Template        = $null
        Module          = $null
    }
}

#endregion New-SetupStep.psm1

#region Write-SetupLog.psm1

<#
.SYNOPSIS
    Public: Write-SetupLog, a line in the run log that also shows under the running step.
#>



function Write-SetupLog
{
    <#
    .SYNOPSIS
        Writes a line to the run log, and shows it under the running step.
    .DESCRIPTION
        Inside a step the line goes to the log file and appears dimmed under the step on screen.
        Outside a run it only goes to the screen.
    .PARAMETER Message
        The line.
    .PARAMETER Level
        Info, Warning or Error. Warnings show yellow and errors red.
    .EXAMPLE
        Write-SetupLog -Message 'Hosts file updated.'
    .EXAMPLE
        Write-SetupLog -Message 'Nothing to copy.' -Level Warning
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipeline)] [AllowEmptyString()] [string] $Message,
        [ValidateSet('Info', 'Warning', 'Error')] [string] $Level = 'Info'
    )

    process
    {
        $fileLevel = 'INFO'
        $color = $null
        if ($Level -eq 'Warning')
        {
            $fileLevel = 'WARN'
            $color = 'BrightYellow'
        }
        elseif ($Level -eq 'Error')
        {
            $fileLevel = 'ERROR'
            $color = 'BrightRed'
        }
        Write-SetupLogFile -Message $Message -Level $fileLevel

        $text = ConvertTo-SetupLiteral -Text $Message
        if ($color)
        {
            $text = '[{0}]{1}[/]' -f $color, $text
        }

        $task = (Get-SetupContext).Task
        if ($task)
        {
            $task.Write($text)
            return
        }
        Format-AnsiText -Message $text | Out-AnsiHost
    }
}

#endregion Write-SetupLog.psm1

#region Request-SetupRestart.psm1

<#
.SYNOPSIS
    Public: Request-SetupRestart, a restart a step asks for while it runs.
#>



function Request-SetupRestart
{
    <#
    .SYNOPSIS
        Asks for a restart once the running step ends.
    .DESCRIPTION
        Without -Retry the step counts as done, and the run carries on with the next step after the restart.
        With -Retry the step could not do its work yet and runs again after the restart. A step that asks for a retry
        twice in a row fails.
        Outside a run it does nothing.
    .PARAMETER Retry
        Run this step again after the restart.
    .EXAMPLE
        if ($process.ExitCode -eq 3010) { Request-SetupRestart }
    .EXAMPLE
        Request-SetupRestart -Retry
    #>

    [CmdletBinding()]
    param(
        [switch] $Retry
    )

    $context = Get-SetupContext
    if ($Retry)
    {
        $context.RestartRequest = 'Retry'
        Write-SetupLogFile -Message 'Restart requested; this step runs again after it.'
        return
    }

    if ($context.RestartRequest -ne 'Retry')
    {
        $context.RestartRequest = 'After'
    }
    Write-SetupLogFile -Message 'Restart requested after this step.'
}

#endregion Request-SetupRestart.psm1

#region Test-SetupCommand.psm1

<#
.SYNOPSIS
    Public: Test-SetupCommand and Test-SetupProgram, the two checks most step Tests need.
#>


$script:UninstallKeys = @(
    'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*'
    'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
    'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*'
)

function Test-SetupCommand
{
    <#
    .SYNOPSIS
        $true when a command, such as git or dotnet, resolves in this session.
    .PARAMETER Name
        The command.
    .EXAMPLE
        Test-SetupCommand -Name 'git'
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Name
    )

    return [bool](Get-Command -Name $Name -ErrorAction Ignore)
}

function Test-SetupProgram
{
    <#
    .SYNOPSIS
        $true when Windows lists a program under Installed apps, by display name.
    .DESCRIPTION
        Reads the machine and current user uninstall entries, the list Settings shows.
        Wildcards are allowed, so 'Mozilla Firefox*' matches every edition and language.
    .PARAMETER Name
        The display name, wildcards allowed.
    .EXAMPLE
        Test-SetupProgram -Name 'Google Chrome'
    .EXAMPLE
        Test-SetupProgram -Name 'SQL Server Management Studio*'
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Name
    )

    foreach ($key in $script:UninstallKeys)
    {
        $match = Get-ItemProperty -Path $key -ErrorAction Ignore |
            Where-Object -FilterScript { $_.PSObject.Properties['DisplayName'] -and $_.DisplayName -like $Name } |
            Select-Object -First 1
        if ($match)
        {
            return $true
        }
    }
    return $false
}

#endregion Test-SetupCommand.psm1

#region Update-SetupEnvironment.psm1

<#
.SYNOPSIS
    Public: Update-SetupEnvironment, so a step sees what the step before it installed.
#>


function Update-SetupEnvironment
{
    <#
    .SYNOPSIS
        Reloads the machine and user environment variables into this session.
    .DESCRIPTION
        Installers write PATH and other variables to the registry, which a running session never rereads.
        This copies them in, machine first and user second, and joins both PATHs, so a command installed by one step
        resolves in the next. Install-ChocoPackage calls it after every install.
    .EXAMPLE
        Update-SetupEnvironment
    #>

    [CmdletBinding()]
    param()

    $machine = [Environment]::GetEnvironmentVariables('Machine')
    $user = [Environment]::GetEnvironmentVariables('User')

    foreach ($scope in $machine, $user)
    {
        foreach ($name in $scope.Keys)
        {
            if ($name -in 'Path', 'PSModulePath', 'USERNAME')
            {
                continue
            }
            [Environment]::SetEnvironmentVariable($name, [string]$scope[$name], 'Process')
        }
    }

    $paths = @([string]$machine['Path'], [string]$user['Path']) -split ';' | Where-Object -FilterScript { $_ }
    $env:Path = ($paths | Select-Object -Unique) -join ';'
}

#endregion Update-SetupEnvironment.psm1

#region Get-SetupTemplate.psm1

<#
.SYNOPSIS
    Public: Get-SetupTemplate, the templates a step file can name with Use-SetupTemplate.
#>



function Get-SetupTemplate
{
    <#
    .SYNOPSIS
        Lists the templates a step file can name with Use-SetupTemplate.
    .DESCRIPTION
        The templates published with the module, and with -StepPath your own from the templates folder beside that
        steps folder. One object per template: Id, the step's Name, its Parameters, its Source (Own or Module) and Path.
        Reading a template never runs it.
    .PARAMETER Id
        Template ids to return, wildcards allowed. All of them when omitted.
    .PARAMETER StepPath
        A steps folder, to list your own templates beside it too.
    .EXAMPLE
        Get-SetupTemplate
    .EXAMPLE
        Get-SetupTemplate -Id '*Sql*' -StepPath 'C:\Setup\steps'
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [ValidateNotNullOrEmpty()] [string[]] $Id = @('*'),
        [ValidateScript({ Test-Path -LiteralPath $_ -PathType Container })] [string] $StepPath
    )

    $sources = [System.Collections.Generic.List[object]]::new()
    if ($StepPath)
    {
        $ownRoot = Get-SetupOwnTemplateRoot -StepPath $StepPath
        if ($ownRoot)
        {
            $sources.Add([pscustomobject]@{ Source = 'Own'; Root = $ownRoot })
        }
    }
    $sources.Add([pscustomobject]@{ Source = 'Module'; Root = (Get-SetupTemplateRoot) })

    foreach ($source in $sources)
    {
        $files = Get-ChildItem -LiteralPath $source.Root -Filter '*.ps1' -File | Sort-Object -Property Name
        foreach ($file in $files)
        {
            $templateId = $file.BaseName
            if (@($Id | Where-Object -FilterScript { $templateId -like $_ }).Count -eq 0)
            {
                continue
            }

            $ast = [System.Management.Automation.Language.Parser]::ParseFile($file.FullName, [ref]$null, [ref]$null)
            [pscustomobject]@{
                PSTypeName = 'PwshSetup.Template'
                Id         = $templateId
                Name       = Get-SetupTemplateName -Ast $ast -Default $templateId
                Parameters = @(Get-SetupTemplateParameter -Ast $ast)
                Source     = $source.Source
                Path       = $file.FullName
            }
        }
    }
}

function Get-SetupTemplateParameter
{
    [CmdletBinding()]
    [OutputType([string[]])]
    param(
        [Parameter(Mandatory)] [System.Management.Automation.Language.ScriptBlockAst] $Ast
    )

    if (-not $Ast.ParamBlock)
    {
        return @()
    }
    return @($Ast.ParamBlock.Parameters | ForEach-Object -Process { $_.Name.VariablePath.UserPath })
}

function Get-SetupTemplateName
{
    # The step's name as a literal, read from the syntax tree: New-SetupStep -Name, or the Name key of the hashtable
    # splatted into it. The default when the name is built at run time.
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)] [System.Management.Automation.Language.ScriptBlockAst] $Ast,
        [Parameter(Mandatory)] [string] $Default
    )

    $isCommand = {
        param($Node)
        $Node -is [System.Management.Automation.Language.CommandAst] -and $Node.GetCommandName() -eq 'New-SetupStep'
    }
    $command = $Ast.Find($isCommand, $true)
    if ($command)
    {
        $elements = $command.CommandElements
        for ($index = 0; $index -lt $elements.Count - 1; $index++)
        {
            $element = $elements[$index]
            $isName = $element -is [System.Management.Automation.Language.CommandParameterAst] -and $element.ParameterName -eq 'Name'
            if ($isName -and $elements[$index + 1] -is [System.Management.Automation.Language.StringConstantExpressionAst])
            {
                return $elements[$index + 1].Value
            }
        }
    }

    $hashtables = $Ast.FindAll({ param($Node) $Node -is [System.Management.Automation.Language.HashtableAst] }, $true)
    foreach ($hashtable in $hashtables)
    {
        foreach ($pair in $hashtable.KeyValuePairs)
        {
            if ($pair.Item1 -isnot [System.Management.Automation.Language.StringConstantExpressionAst] -or $pair.Item1.Value -ne 'Name')
            {
                continue
            }
            $value = $pair.Item2
            if ($value -is [System.Management.Automation.Language.PipelineAst] -and $value.PipelineElements.Count -eq 1)
            {
                $expression = $value.PipelineElements[0].Expression
                if ($expression -is [System.Management.Automation.Language.StringConstantExpressionAst])
                {
                    return $expression.Value
                }
            }
        }
    }
    return $Default
}

#endregion Get-SetupTemplate.psm1

#region Use-SetupTemplate.psm1

<#
.SYNOPSIS
    Public: Use-SetupTemplate, a step file that is one line naming a template.
#>



function Use-SetupTemplate
{
    <#
    .SYNOPSIS
        Returns the step a template describes, for a step file to return as its own.
    .DESCRIPTION
        Looks for the template in the templates folder beside the steps folder first, where your own live, then among
        the templates published with the module. Your own template of the same name wins.
        The template runs in a module of its own, with -Parameter as its parameters.
    .PARAMETER Name
        The template, by file name without .ps1, such as Install-Git.
    .PARAMETER Parameter
        Values for the template's parameters, such as @{ Entries = '127.0.0.1 shop.local' }.
    .EXAMPLE
        Use-SetupTemplate -Name 'Install-Git'
    .EXAMPLE
        Use-SetupTemplate -Name 'Install-DotnetSdk' -Parameter @{ Version = 8 }
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)] [ValidatePattern('^[A-Za-z0-9-]+$')] [string] $Name,
        [hashtable] $Parameter = @{}
    )

    $path = Find-SetupTemplate -Name $Name
    try
    {
        $step = Import-SetupScript -Path $path -Parameter $Parameter
    }
    catch
    {
        throw ('Template {0}: {1}' -f $Name, $_.Exception.Message)
    }
    $step.Template = $Name
    return $step
}

function Find-SetupTemplate
{
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)] [string] $Name
    )

    $fileName = '{0}.ps1' -f $Name
    $roots = @(Get-SetupOwnTemplateRoot) + @(Get-SetupTemplateRoot) | Where-Object -FilterScript { $_ }
    foreach ($root in $roots)
    {
        $path = Join-Path -Path $root -ChildPath $fileName
        if (Test-Path -LiteralPath $path -PathType Leaf)
        {
            return $path
        }
    }
    throw ('No template named {0}. Get-SetupTemplate lists them; your own go in a templates folder beside the steps folder.' -f $Name)
}

#endregion Use-SetupTemplate.psm1

#region Install-ChocoPackage.psm1

<#
.SYNOPSIS
    Public: Install-ChocoPackage and Invoke-ChocoCommand, Chocolatey for steps.
.DESCRIPTION
    Success is decided by Chocolatey's exit code, never by searching its output.
    A package that asks for a restart gets one after its step, and a package Chocolatey refuses because a restart is
    pending runs again after that restart.
#>



# Chocolatey's own contract: 1641 and 3010 installed and want a restart, 350 and 1604 did nothing because one is pending.
$script:ChocoRestartExitCodes = @(1641, 3010)
$script:ChocoPendingExitCodes = @(350, 1604)
$script:ChocoTailLines = 15

function Invoke-ChocoCommand
{
    <#
    .SYNOPSIS
        Runs choco with the arguments given, and returns its exit code and output.
    .DESCRIPTION
        The output goes to the run log in full; the screen shows the command line only.
        Throws when Chocolatey is not installed.
    .PARAMETER ArgumentList
        The arguments, one per item.
    .EXAMPLE
        Invoke-ChocoCommand -ArgumentList 'feature', 'enable', '--name=exitOnRebootDetected'
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string[]] $ArgumentList
    )

    $choco = Get-Command -Name 'choco' -CommandType Application -ErrorAction Ignore | Select-Object -First 1
    if (-not $choco)
    {
        throw 'Chocolatey is not installed. Put the Chocolatey step before this one.'
    }

    Write-SetupLog -Message ('choco {0}' -f ($ArgumentList -join ' '))
    $output = & $choco.Source @ArgumentList 2>&1 | Out-String
    $exitCode = $LASTEXITCODE
    Write-SetupLogFile -Message $output -Level 'OUTPUT'

    return [pscustomobject]@{
        PSTypeName = 'PwshSetup.ChocoResult'
        ExitCode   = $exitCode
        Output     = $output.TrimEnd()
    }
}

function Install-ChocoPackage
{
    <#
    .SYNOPSIS
        Installs one Chocolatey package, and throws when it fails.
    .DESCRIPTION
        Exit 0 is done. 1641 and 3010 are done and ask for a restart after the step.
        350 and 1604 mean Chocolatey saw a pending restart and installed nothing: the step runs again after a restart.
        Anything else throws with the last lines of Chocolatey's output.
        The session environment is reloaded after an install, so the next step finds the new commands.
    .PARAMETER Name
        The package id.
    .PARAMETER Version
        A version to pin. The latest when omitted.
    .PARAMETER AdditionalArgument
        More choco arguments, one per item, such as '--params', '/NoDesktopShortcut'.
    .PARAMETER PassThru
        Return the result object.
    .EXAMPLE
        Install-ChocoPackage -Name 'git'
    .EXAMPLE
        Install-ChocoPackage -Name 'googlechrome' -AdditionalArgument '--ignore-checksums'
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Name,
        [ValidateNotNullOrEmpty()] [string] $Version,
        [string[]] $AdditionalArgument = @(),
        [switch] $PassThru
    )

    $arguments = @('install', $Name, '--yes', '--no-progress')
    if ($Version)
    {
        $arguments += @('--version', $Version)
    }
    $arguments += $AdditionalArgument

    $result = Invoke-ChocoCommand -ArgumentList $arguments
    if ($result.ExitCode -in $script:ChocoPendingExitCodes)
    {
        Write-SetupLog -Message ('Chocolatey sees a pending restart; {0} installs after it.' -f $Name) -Level Warning
        Request-SetupRestart -Retry
    }
    elseif ($result.ExitCode -in $script:ChocoRestartExitCodes)
    {
        Write-SetupLog -Message ('{0} installed, and asks for a restart.' -f $Name)
        Request-SetupRestart
        Update-SetupEnvironment
    }
    elseif ($result.ExitCode -eq 0)
    {
        Write-SetupLog -Message ('{0} installed.' -f $Name)
        Update-SetupEnvironment
    }
    else
    {
        $tail = ($result.Output -split '\r?\n' | Select-Object -Last $script:ChocoTailLines) -join [Environment]::NewLine
        throw ('choco install {0} failed with exit code {1}.{2}{3}' -f $Name, $result.ExitCode, [Environment]::NewLine, $tail)
    }

    if ($PassThru)
    {
        return $result
    }
}

#endregion Install-ChocoPackage.psm1

#region Invoke-SetupSql.psm1

<#
.SYNOPSIS
    Public: Invoke-SetupSql, Invoke-SetupSqlScalar and Test-SetupSqlDatabase, the SQL Server calls a step needs.
.DESCRIPTION
    Values travel as parameters, never concatenated into the statement.
#>


function New-SetupSqlCommand
{
    # Callers dispose the connection.
    [CmdletBinding()]
    [OutputType([System.Data.SqlClient.SqlCommand])]
    param(
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $ConnectionString,
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Sql,
        [hashtable] $Parameter = @{}
    )

    $connection = [System.Data.SqlClient.SqlConnection]::new($ConnectionString)
    $command = $connection.CreateCommand()
    $command.CommandText = $Sql
    foreach ($entry in $Parameter.GetEnumerator())
    {
        $null = $command.Parameters.AddWithValue($entry.Key, $entry.Value)
    }
    return $command
}

function Invoke-SetupSql
{
    <#
    .SYNOPSIS
        Runs a statement and returns the rows it affected.
    .PARAMETER ConnectionString
        Where to run it.
    .PARAMETER Sql
        The statement, with @name placeholders for values.
    .PARAMETER Parameter
        The values, keyed by placeholder.
    .EXAMPLE
        Invoke-SetupSql -ConnectionString $connectionString -Sql 'DELETE FROM Cache WHERE Region = @region' -Parameter @{ '@region' = 'eu' }
    #>

    [CmdletBinding()]
    [OutputType([int])]
    param(
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $ConnectionString,
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Sql,
        [hashtable] $Parameter = @{}
    )

    $command = New-SetupSqlCommand -ConnectionString $ConnectionString -Sql $Sql -Parameter $Parameter
    try
    {
        $command.Connection.Open()
        return $command.ExecuteNonQuery()
    }
    finally
    {
        $command.Connection.Dispose()
    }
}

function Invoke-SetupSqlScalar
{
    <#
    .SYNOPSIS
        Runs a query and returns the first column of its first row.
    .PARAMETER ConnectionString
        Where to run it.
    .PARAMETER Sql
        The query, with @name placeholders for values.
    .PARAMETER Parameter
        The values, keyed by placeholder.
    .EXAMPLE
        Invoke-SetupSqlScalar -ConnectionString $connectionString -Sql 'SELECT COUNT(*) FROM sys.tables'
    #>

    [CmdletBinding()]
    [OutputType([object])]
    param(
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $ConnectionString,
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Sql,
        [hashtable] $Parameter = @{}
    )

    $command = New-SetupSqlCommand -ConnectionString $ConnectionString -Sql $Sql -Parameter $Parameter
    try
    {
        $command.Connection.Open()
        return $command.ExecuteScalar()
    }
    finally
    {
        $command.Connection.Dispose()
    }
}

function Test-SetupSqlDatabase
{
    <#
    .SYNOPSIS
        $true when the database a connection string names exists on its server.
    .DESCRIPTION
        Connects to master on the same server, so it works before the database is created.
    .PARAMETER ConnectionString
        A connection string with Database or Initial Catalog.
    .EXAMPLE
        Test-SetupSqlDatabase -ConnectionString 'Server=.;Database=Shop;Integrated Security=True;TrustServerCertificate=True'
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $ConnectionString
    )

    $builder = [System.Data.SqlClient.SqlConnectionStringBuilder]::new($ConnectionString)
    $database = $builder.InitialCatalog
    if (-not $database)
    {
        throw 'The connection string names no Database or Initial Catalog.'
    }
    # The 'Initial Catalog' key, not the InitialCatalog setter, which throws "Keyword not supported" in this client.
    $builder['Initial Catalog'] = 'master'

    $scalarArguments = @{
        ConnectionString = $builder.ConnectionString
        Sql              = 'SELECT COUNT(*) FROM sys.databases WHERE name = @name'
        Parameter        = @{ '@name' = $database }
    }
    return ([int](Invoke-SetupSqlScalar @scalarArguments) -gt 0)
}

#endregion Invoke-SetupSql.psm1

#region Invoke-PwshSetup.psm1

<#
.SYNOPSIS
    Public: Invoke-PwshSetup, runs a folder of setup steps and carries on across restarts.
#>



$script:AppTitle = 'PwshSetup'

function Invoke-PwshSetup
{
    <#
    .SYNOPSIS
        Runs the steps in a folder, in order, and carries on across restarts on its own.
    .DESCRIPTION
        Lists the folder's steps. F6 runs them and F7 dry-runs them, each after a y/n confirm; Esc quits.
        A run checks each step right before it and skips what is done.
        A step that needs a restart gets one: the run registers itself to start again at the next sign-in, counts down
        ten seconds (Esc cancels), restarts, and carries on with the next step without asking.
        A step that fails stops the run; the next run asks to carry on from it.
        Needs an elevated session, except with -WhatIf. Call Assert-SetupAdmin first.
        Logs go to %ProgramData%\PwshSetup\Logs, one file per run.
    .PARAMETER Path
        The steps folder. Every .ps1 in it is a step, run in the order of the number its name starts with.
    .PARAMETER LogPath
        Folder for the log files.
    .PARAMETER Unattended
        Run without the list and the confirm.
    .PARAMETER SkipRestart
        Note a needed restart and carry on without one.
    .PARAMETER ScriptPath
        Script the resume task starts after a restart. Defaults to the calling script.
    .PARAMETER Arguments
        Parameters the resume task passes to that script. Defaults to the calling script's bound parameters.
    .PARAMETER PassThru
        Return the outcome: Status (Finished, Failed, Restarting, Cancelled, DryRun), LogFile and Duration.
    .EXAMPLE
        Invoke-PwshSetup -Path (Join-Path -Path $PSScriptRoot -ChildPath 'steps')
    .EXAMPLE
        Invoke-PwshSetup -Path 'C:\Setup\steps' -WhatIf
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Path,
        [ValidateNotNullOrEmpty()] [string] $LogPath = (Get-SetupDataPath -Name 'Logs'),
        [switch] $Unattended,
        [switch] $SkipRestart,
        [AllowEmptyString()] [string] $ScriptPath = $MyInvocation.PSCommandPath,
        [hashtable] $Arguments,
        [switch] $PassThru
    )

    if (-not (Test-Path -LiteralPath $Path -PathType Container))
    {
        throw ('Step folder not found: {0}' -f $Path)
    }
    $dryRun = [bool]$WhatIfPreference
    if (-not $dryRun -and -not (Test-SetupAdmin))
    {
        throw 'Invoke-PwshSetup needs an elevated session. Call Assert-SetupAdmin before it.'
    }
    if (-not $dryRun -and -not $Unattended -and -not (Test-SetupInteractive))
    {
        throw 'No console to show the list in. Pass -Unattended to run without it.'
    }

    if ($null -eq $Arguments)
    {
        $Arguments = @{}
        $caller = Get-PSCallStack | Select-Object -Skip 1 -First 1
        if ($caller)
        {
            foreach ($key in $caller.InvocationInfo.BoundParameters.Keys)
            {
                $Arguments[$key] = $caller.InvocationInfo.BoundParameters[$key]
            }
        }
    }

    [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
    $runArguments = @{
        StepPath    = (Resolve-Path -LiteralPath $Path).ProviderPath
        LogPath     = $LogPath
        Unattended  = [bool]$Unattended
        SkipRestart = [bool]$SkipRestart
        DryRun      = $dryRun
        ScriptPath  = $ScriptPath
        Arguments   = $Arguments
    }
    try
    {
        $outcome = Invoke-SetupRun @runArguments
    }
    finally
    {
        Reset-SetupContext
    }

    if ($PassThru)
    {
        return $outcome
    }
}

# ── Run ──────────────────────────────────────────────────────────────────────

function Invoke-SetupRun
{
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)] [string] $StepPath,
        [Parameter(Mandatory)] [string] $LogPath,
        [Parameter(Mandatory)] [bool] $Unattended,
        [Parameter(Mandatory)] [bool] $SkipRestart,
        [Parameter(Mandatory)] [bool] $DryRun,
        [AllowEmptyString()] [string] $ScriptPath,
        [hashtable] $Arguments
    )

    $rerun = @(([string]$env:PWSHSETUP_RERUN) -split ',' | Where-Object -FilterScript { $_ })
    if (-not $DryRun -and $rerun -contains 'resume')
    {
        Unregister-SetupResume
    }

    $header = @(
        , @('Steps', (ConvertTo-SetupLiteral -Text $StepPath))
        , @('Machine', ('{0} [DarkGray]{1}[/]' -f $env:COMPUTERNAME, (ConvertTo-SetupLiteral -Text $env:USERNAME)))
    )

    Clear-SetupScreen
    try
    {
        $steps = @(Import-SetupStep -Path $StepPath)
    }
    catch
    {
        Write-SetupHeader -Item $header
        Write-SetupFailure -ErrorRecord $_
        Wait-SetupClose -Unattended:$Unattended
        return (New-SetupOutcome -Status 'Failed')
    }
    if ($steps.Count -eq 0)
    {
        Write-SetupHeader -Item $header
        Write-SetupNote -Text 'No step files in the folder.' -Color 'BrightYellow'
        return (New-SetupOutcome -Status 'Finished')
    }

    if ($DryRun)
    {
        Write-SetupHeader -Item $header
        Write-SetupPlan -Plan (Get-SetupPlan -Steps $steps)
        return (New-SetupOutcome -Status 'DryRun')
    }

    # ── Resume, or the list ──────────────────────────────────────────────────

    $state = Read-SetupState -StepPath $StepPath
    $resumed = $false
    if ($state)
    {
        if ((Test-SetupRestartedSince -State $state) -or $Unattended -or $rerun -contains 'resume')
        {
            $resumed = $true
        }
        else
        {
            Write-SetupHeader -Item $header
            $startedAt = (ConvertTo-SetupUtc -Value $state.StartedAt).ToLocalTime()
            $startedText = $startedAt.ToString('yyyy-MM-dd HH:mm', [cultureinfo]::InvariantCulture)
            $prompt = 'Carry on with the unfinished run from {0}?' -f $startedText
            $answer = Read-AnsiConfirm -Prompt $prompt -Default $true
            if ($null -eq $answer)
            {
                return (New-SetupOutcome -Status 'Cancelled')
            }
            $resumed = [bool]$answer
            if (-not $resumed)
            {
                Remove-SetupState -StepPath $StepPath
                $state = $null
            }
        }
    }

    if (-not $resumed)
    {
        if (-not $Unattended -and -not (Wait-SetupGo -Steps $steps -Header $header))
        {
            Write-SetupNote -Text 'Nothing ran.' -Color 'DarkGray'
            return (New-SetupOutcome -Status 'Cancelled')
        }
        if (-not (Test-Path -LiteralPath $LogPath))
        {
            $null = New-Item -ItemType Directory -Path $LogPath -Force
        }
        $stateArguments = @{
            StepPath   = $StepPath
            ScriptPath = $ScriptPath
            LogFile    = Join-Path -Path $LogPath -ChildPath ('PwshSetup-{0}.log' -f (Get-Date -Format 'yyyy-MM-dd_HH-mm-ss'))
            Selected   = @($steps | ForEach-Object -Process { $_.Id })
        }
        $state = New-SetupState @stateArguments
    }

    Clear-SetupScreen
    $runKind = 'started'
    $runText = 'started'
    if ($resumed)
    {
        $runKind = 'resumed'
        $runText = 'resumed [DarkGray]{0} restart(s) so far[/]' -f $state.Restarts
    }
    $header += , @('Run', $runText)
    $header += , @('Log', (ConvertTo-SetupLiteral -Text $state.LogFile))
    Write-SetupHeader -Item $header

    $state.RestartingAt = $null
    Save-SetupState -State $state
    $argumentFile = Save-SetupArgument -StepPath $StepPath -Arguments $Arguments

    $context = Get-SetupContext
    $context.LogFile = $state.LogFile
    Write-SetupLogFile -Message ('Run {0} on {1} as {2}, steps in {3}.' -f $runKind, $env:COMPUTERNAME, $env:USERNAME, $StepPath)

    # ── Steps ────────────────────────────────────────────────────────────────

    $byId = @{}
    foreach ($step in $steps)
    {
        $byId[$step.Id] = $step
    }
    foreach ($id in @($state.Selected | Where-Object -FilterScript { -not $byId.ContainsKey($_) }))
    {
        Write-SetupLogFile -Message ('Step {0} has no file any more; left out.' -f $id) -Level 'WARN'
    }
    $runSteps = @($state.Selected | Where-Object -FilterScript { $byId.ContainsKey($_) } | ForEach-Object -Process { $byId[$_] })
    $remaining = @($runSteps | Where-Object -FilterScript {
            $result = $state.Results[$_.Id]
            -not $result -or $result.Status -notin 'Done', 'Skipped'
        })

    $run = [pscustomobject]@{
        Steps       = $remaining
        State       = $state
        SkipRestart = $SkipRestart
        RestartStep = $null
    }
    $stepScript = {
        param($Task)
        Invoke-SetupStepTask -Task $Task -Run $run
    }
    $tasks = foreach ($step in $remaining)
    {
        @{ Name = $step.Name; Script = $stepScript }
    }

    $failure = $null
    $null = Start-AnsiTitleAnimation -Text $script:AppTitle
    try
    {
        if (@($tasks).Count -gt 0)
        {
            Invoke-AnsiTask -Task @($tasks) -Show Both -ProgressShow Percent
        }
    }
    catch
    {
        $failure = $_
    }
    finally
    {
        Stop-AnsiTitleAnimation -Title $script:AppTitle
    }

    # ── Outcome ──────────────────────────────────────────────────────────────

    if ($failure)
    {
        Write-SetupLogFile -Message 'Run failed.' -Level 'ERROR'
        Write-SetupSummary -Steps $runSteps -State $state
        Write-SetupFailure -ErrorRecord $failure -LogFile $state.LogFile
        Write-SetupNote -Text 'Fix it and run setup again: it carries on from the failed step.' -Color 'DarkGray'
        Wait-SetupClose -Unattended:$Unattended
        return (New-SetupOutcome -Status 'Failed' -LogFile $state.LogFile -Duration (Get-SetupRunDuration -State $state))
    }

    if ($run.RestartStep)
    {
        Write-SetupBlankLine
        if (-not $state.ScriptPath)
        {
            Save-SetupState -State $state
            Write-SetupNote -Text 'Restart the machine, then run setup again to carry on.' -Color 'BrightYellow'
            return (New-SetupOutcome -Status 'Cancelled' -LogFile $state.LogFile -Duration (Get-SetupRunDuration -State $state))
        }

        $state.Restarts++
        $state.RestartingAt = (Get-Date).ToUniversalTime().ToString('o')
        Save-SetupState -State $state
        Register-SetupResume -ScriptPath $state.ScriptPath -ArgumentFile $argumentFile
        if (Restart-SetupComputer -StepName $run.RestartStep.Name)
        {
            return (New-SetupOutcome -Status 'Restarting' -LogFile $state.LogFile -Duration (Get-SetupRunDuration -State $state))
        }

        Unregister-SetupResume
        $state.Restarts--
        $state.RestartingAt = $null
        Save-SetupState -State $state
        Write-SetupNote -Text 'Restart cancelled. Restart the machine, then run setup again to carry on.' -Color 'BrightYellow'
        Wait-SetupClose -Unattended:$Unattended
        return (New-SetupOutcome -Status 'Cancelled' -LogFile $state.LogFile -Duration (Get-SetupRunDuration -State $state))
    }

    $duration = Get-SetupRunDuration -State $state
    $elapsed = Format-SetupDuration -Duration $duration
    Write-SetupLogFile -Message 'Run finished.'
    Write-SetupSummary -Steps $runSteps -State $state
    Remove-SetupState -StepPath $StepPath
    Write-SetupBlankLine
    Write-SetupNote -Text ('Setup finished in {0}, with {1} restart(s).' -f $elapsed, $state.Restarts) -Color 'BrightGreen'
    Format-AnsiText -Message ('[DarkGray]Log[/] {0}' -f (ConvertTo-SetupLiteral -Text $state.LogFile)) | Out-AnsiHost
    Wait-SetupClose -Unattended:$Unattended
    return (New-SetupOutcome -Status 'Finished' -LogFile $state.LogFile -Duration $duration)
}

function Wait-SetupGo
{
    # The list screen: F6 runs and F7 dry-runs, each after a y/n; Esc quits. $true once a run is confirmed.
    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory)] [object[]] $Steps,
        [Parameter(Mandatory)] [object[]] $Header
    )

    while ($true)
    {
        Clear-SetupScreen
        Write-SetupHeader -Item $Header
        Write-SetupKeyLine -Key @('F6 Run', 'F7 Dry run', 'Esc Quit')
        Write-SetupList -Steps $Steps

        $entered = Read-SetupKey -Key @('F6', 'F7')
        if ($entered.Action -eq 'Back')
        {
            return $false
        }
        if ($entered.Action -ne 'Key')
        {
            continue
        }

        Write-SetupBlankLine
        if ($entered.Key -eq 'F6')
        {
            if (Read-SetupConfirm -Prompt ('Run {0} steps?' -f $Steps.Count))
            {
                return $true
            }
            continue
        }

        if (Read-SetupConfirm -Prompt ('Dry run {0} steps?' -f $Steps.Count))
        {
            Write-SetupBlankLine
            Write-SetupPlan -Plan (Get-SetupPlan -Steps $Steps)
            Write-SetupBlankLine
            $null = Read-AnsiPause -Message 'Press any key to go back to the list'
        }
    }
}

function New-SetupOutcome
{
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)] [ValidateSet('Finished', 'Failed', 'Restarting', 'Cancelled', 'DryRun')] [string] $Status,
        [AllowEmptyString()] [string] $LogFile = '',
        [TimeSpan] $Duration = [TimeSpan]::Zero
    )

    return [pscustomobject]@{
        PSTypeName = 'PwshSetup.Outcome'
        Status     = $Status
        LogFile    = $LogFile
        Duration   = $Duration
    }
}

function Get-SetupRunDuration
{
    # From the run's first start, so the time spent across its restarts counts too.
    [CmdletBinding()]
    [OutputType([TimeSpan])]
    param(
        [Parameter(Mandatory)] [hashtable] $State
    )

    return ((Get-Date).ToUniversalTime() - (ConvertTo-SetupUtc -Value $State.StartedAt))
}

# ── Step ─────────────────────────────────────────────────────────────────────

function Invoke-SetupStepTask
{
    # One step inside Invoke-AnsiTask: checks it, since an earlier step may have done its work, runs it, records the
    # result, and stops the run when it needs a restart.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [object] $Task,
        [Parameter(Mandatory)] [pscustomobject] $Run
    )

    $step = $Run.Steps[$Task.Index]
    $state = $Run.State
    $context = Get-SetupContext
    $context.Task = $Task
    $context.StepId = $step.Id
    $context.RestartRequest = $null
    $watch = [System.Diagnostics.Stopwatch]::StartNew()
    Write-SetupLogFile -Message ('Started: {0}' -f $step.Name)

    try
    {
        $check = Test-SetupStepDone -Step $step
        if ($check.Error)
        {
            Write-SetupLogFile -Message ('Check failed, so the step runs: {0}' -f $check.Error) -Level 'WARN'
        }
        if ($check.Done)
        {
            Set-SetupStepResult -State $state -Id $step.Id -Status 'Skipped' -Duration $watch.Elapsed
            Write-SetupLogFile -Message 'Already done, skipped.'
            $Task.Skip('already done')
            return
        }

        Invoke-SetupStepAction -Step $step
        $request = $context.RestartRequest

        if ($request -eq 'Retry')
        {
            $tries = 1
            if ($state.Retries.ContainsKey($step.Id))
            {
                $tries = [int]$state.Retries[$step.Id] + 1
            }
            if ($tries -gt 1)
            {
                throw ('{0} still waits for a restart after one. Restart the machine and run setup again.' -f $step.Name)
            }
            $state.Retries[$step.Id] = $tries
            Set-SetupStepResult -State $state -Id $step.Id -Status 'Retry' -Duration $watch.Elapsed
            if ($Run.SkipRestart)
            {
                $Task.Skip('needs a restart first')
                return
            }
            $Run.RestartStep = $step
            $Task.Stop('restart, then again')
            return
        }

        Set-SetupStepResult -State $state -Id $step.Id -Status 'Done' -Duration $watch.Elapsed
        Write-SetupLogFile -Message ('Done in {0}.' -f (Format-SetupDuration -Duration $watch.Elapsed))
        if ($step.RequiresRestart -or $request -eq 'After')
        {
            if ($Run.SkipRestart)
            {
                $Task.Write('restart skipped')
                Write-SetupLogFile -Message 'Restart skipped.' -Level 'WARN'
                return
            }
            $Run.RestartStep = $step
            $Task.Stop('restart')
        }
    }
    catch
    {
        Set-SetupStepResult -State $state -Id $step.Id -Status 'Failed' -Duration $watch.Elapsed -Reason $_.Exception.Message
        Write-SetupLogFile -Message ($_ | Out-String) -Level 'ERROR'
        throw
    }
    finally
    {
        Save-SetupState -State $state
        $context.Task = $null
        $context.StepId = $null
    }
}

function Invoke-SetupStepAction
{
    # Output, errors and warnings of the Action go to the log, never between the progress lines.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [pscustomobject] $Step
    )

    & $Step.Action 2>&1 3>&1 | ForEach-Object -Process { Write-SetupStepOutput -Record $_ }
}

function Write-SetupStepOutput
{
    # Native commands write progress to stderr, so their error records are output, not errors.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [AllowNull()] [object] $Record
    )

    if ($null -eq $Record)
    {
        return
    }
    if ($Record -is [System.Management.Automation.WarningRecord])
    {
        Write-SetupLogFile -Message $Record.Message -Level 'WARN'
        return
    }
    if ($Record -is [System.Management.Automation.ErrorRecord])
    {
        $level = 'ERROR'
        if ($Record.FullyQualifiedErrorId -like 'NativeCommandError*')
        {
            $level = 'OUTPUT'
        }
        Write-SetupLogFile -Message $Record.ToString() -Level $level
        return
    }
    Write-SetupLogFile -Message ($Record | Out-String -Width 200) -Level 'OUTPUT'
}

#endregion Invoke-PwshSetup.psm1