public/Invoke-psake.ps1

# spell-checker:ignore notr bitness
function Invoke-Psake {
    <#
    .SYNOPSIS
    Runs a psake build script.

    .DESCRIPTION
    This function runs a psake build script using a two-phase compile/run model.
    The compile phase loads the build file and validates the dependency graph.
    The run phase executes tasks in the resolved order.

    A pre-compiled [PsakeBuildPlan] from Get-PsakeBuildPlan can be piped in to
    skip the compile phase. When doing so, the build file is re-loaded for the
    execution phase to resolve properties, setup, and teardown blocks.

    .PARAMETER BuildFile
    The path to the psake build script to execute

    .PARAMETER TaskList
    A comma-separated list of task names to execute

    .PARAMETER Framework

    The version of the .NET framework you want to use during build. You can
    append x86 or x64 to force a specific framework. If not specified, x86 or
    x64 will be detected based on the bitness of the PowerShell process.
    Possible values: '4.0', '4.0x86', '4.0x64', '4.5', '4.5x86', '4.5x64',
    '4.5.1', '4.5.1x86', '4.5.1x64', '4.6', '4.6.1', '4.6.2', '4.7', '4.7.1',
    '4.7.2', '4.8', '4.8.1'

    .PARAMETER Docs
    Prints a list of tasks and their descriptions

    .PARAMETER Parameters
    A hashtable containing parameters to be passed into the current build
    script. These parameters will be processed before the 'Properties' function
    of the script is processed.

    .PARAMETER Properties
    A hashtable containing properties to be passed into the current build
    script. These properties will override matching properties that are found in
    the 'Properties' function of the script.

    .PARAMETER Initialization
    A script block that will be executed before the tasks are executed.

    .PARAMETER NoLogo
    Do not display the startup banner and copyright message.

    .PARAMETER DetailedDocs
    Prints a more descriptive list of tasks and their descriptions.

    .PARAMETER NoTimeReport
    Do not display the time report.

    .PARAMETER OutputFormat
    The output format. 'Default' for console output, 'JSON' for JSON to stdout,
    'GitHubActions' for GitHub Actions workflow annotations (::error::, ::warning::, ::debug::).

    .PARAMETER NoCache
    Bypass task caching. All tasks will execute regardless of cache state.

    .PARAMETER CompileOnly
    Return the build plan without executing any tasks. Delegates to
    Get-PsakeBuildPlan. Useful for tooling and testing.

    .PARAMETER Quiet
    Suppress all console output. The PsakeBuildResult is still returned.

    .PARAMETER BuildPlan
    A pre-compiled [PsakeBuildPlan] to execute, typically from Get-PsakeBuildPlan
    via the pipeline. Compile-phase parameters (BuildFile, TaskList, Framework,
    Docs, DetailedDocs, CompileOnly) are ignored when a plan is provided.
    Note: the build file is re-loaded during the execution phase.

    .EXAMPLE
    Invoke-psake

    Runs the 'default' task in the 'psakefile.ps1' build script

    .EXAMPLE
    Invoke-psake '.\build.ps1' Tests,Package

    Runs the 'Tests' and 'Package' tasks in the 'build.ps1' build script

    .EXAMPLE
    Invoke-psake -CompileOnly

    Returns the build plan without executing any tasks.

    .EXAMPLE
    Get-PsakeBuildPlan | Invoke-Psake

    Compiles the build plan then executes it. The build file is re-loaded
    during the execution phase.

    .EXAMPLE
    Invoke-psake -OutputFormat JSON

    Runs the build and outputs the result as JSON.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Position = 0, Mandatory = $false)]
        [string]$BuildFile,

        [Parameter(Position = 1, Mandatory = $false)]
        [string[]]$TaskList = @('default'),

        [Parameter(Position = 2, Mandatory = $false)]
        [string]$Framework,

        [Parameter(Position = 3, Mandatory = $false)]
        [switch]$Docs = $false,

        [Parameter(Position = 4, Mandatory = $false)]
        [hashtable]$Parameters = @{},

        [Parameter(Position = 5, Mandatory = $false)]
        [hashtable]$Properties = @{},

        [Parameter(Position = 6, Mandatory = $false)]
        [alias("init")]
        [scriptblock]$Initialization = {},

        [Parameter(Position = 7, Mandatory = $false)]
        [switch]$NoLogo,

        [Parameter(Position = 8, Mandatory = $false)]
        [switch]$DetailedDocs,

        [Parameter(Position = 9, Mandatory = $false)]
        [Alias("notr")]
        [switch]$NoTimeReport,

        [Parameter(Mandatory = $false)]
        [ValidateSet('Default', 'JSON', 'GitHubActions')]
        [string]$OutputFormat = 'Default',

        [Parameter(Mandatory = $false)]
        [switch]$NoCache,

        [Parameter(Mandatory = $false)]
        [switch]$CompileOnly,

        [Parameter(Mandatory = $false)]
        [switch]$Quiet,

        [Parameter(ValueFromPipeline)]
        [PsakeBuildPlan]$BuildPlan
    )

    begin {
        # Note: $psake var is instantiated by the psake.psm1

        #region Store Script Variables
        $script:Framework = $Framework
        $script:Docs = $Docs
        $script:DetailedDocs = $DetailedDocs
        $script:Properties = $Properties
        $script:Initialization = $Initialization
        $script:Parameters = $Parameters
        $script:NoTimeReport = $NoTimeReport
        #endregion Store Script Variables

        # Set output format for Write-BuildMessage
        $script:CurrentOutputFormat = if ($Quiet) { 'Quiet' } else { $OutputFormat }

        Write-Debug "Invoke-Psake: BuildFile='$BuildFile' TaskList='$($TaskList -join ', ')' OutputFormat='$OutputFormat' NoCache=$NoCache CompileOnly=$CompileOnly Quiet=$Quiet"
    }

    process {
        # === COMPILE-ONLY: delegate to Get-PsakeBuildPlan ===
        # This is the inversion point: Invoke-Psake calls Get-PsakeBuildPlan,
        # not the other way around. Early return avoids the try/finally below
        # so Restore-Environment is handled entirely by Get-PsakeBuildPlan.
        if (-not $BuildPlan -and $CompileOnly) {
            if (!$BuildFile) {
                $BuildFile = Get-DefaultBuildFile
            } elseif (
                !(Test-Path $BuildFile -PathType Leaf) -and
                ($null -ne (Get-DefaultBuildFile -UseDefaultIfNoneExist $false))
            ) {
                $TaskList = $BuildFile.Split(', ')
                $BuildFile = Get-DefaultBuildFile
            }

            $plan = Get-PsakeBuildPlan -BuildFile $BuildFile -TaskList $TaskList
            $psake.build_success = $plan.IsValid
            return $plan
        }

        $buildResult = $null
        $script:buildResultOut = $null

        try {
            if (-not $NoLogo -and -not $Quiet -and $OutputFormat -ne 'JSON') {
                Write-BuildMessage ((
                        ("psake version {0}" -f $psake.version),
                        "Copyright (c) 2010-2026 James Kovacs & Contributors"
                    ) -join $script:nl) "Heading"
            }

            $psake.error_message = $null

            if ($BuildPlan) {
                # === PIPELINE INPUT: Execute a pre-compiled plan ===
                # The build file is re-loaded here so that property blocks,
                # setup/teardown hooks, and includes are resolved in a fresh
                # execution context. Compile-phase parameters (BuildFile,
                # TaskList, Framework, Docs, DetailedDocs, CompileOnly) are
                # ignored because the plan is already compiled.
                Invoke-InBuildFileScope -BuildFile $BuildPlan.BuildFile -Module $MyInvocation.MyCommand.Module -ScriptBlock {
                    param($CurrentContext, $Module)

                    $invokeBuildPlanSplat = @{
                        Plan           = $BuildPlan
                        NoCache        = $NoCache
                        Module         = $Module
                        CurrentContext = $CurrentContext
                        Parameters     = $script:Parameters
                        Properties     = $script:Properties
                        Initialization = $script:Initialization
                    }
                    $buildResult = Invoke-BuildPlan @invokeBuildPlanSplat
                    $script:buildResultOut = $buildResult

                    if ($buildResult.Success) {
                        $successMsg = $msgs.psake_success -f $BuildPlan.BuildFile
                        if (-not $Quiet -and $OutputFormat -ne 'JSON') {
                            Write-BuildMessage ("$($script:nl)${successMsg}$($script:nl)") "Success"
                        }
                    }

                    if (-not $script:NoTimeReport -and -not $Quiet -and $OutputFormat -ne 'JSON') {
                        Write-TaskTimeSummary $buildResult.Duration
                    }
                }

                $buildResult = $script:buildResultOut
                $psake.build_success = $true
                if ($buildResult) {
                    $buildResult.Success = $true
                }

            } else {
                # === STANDARD PATH: resolve build file, compile, and run ===
                if (!$BuildFile) {
                    $BuildFile = Get-DefaultBuildFile
                } elseif (
                    !(Test-Path $BuildFile -PathType Leaf) -and
                    ($null -ne (Get-DefaultBuildFile -UseDefaultIfNoneExist $false))
                ) {
                    $TaskList = $BuildFile.Split(', ')
                    $BuildFile = Get-DefaultBuildFile
                }

                Invoke-InBuildFileScope -BuildFile $BuildFile -Module $MyInvocation.MyCommand.Module -ScriptBlock {
                    param($CurrentContext, $Module)

                    if ($script:Docs -or $script:DetailedDocs) {
                        if ($script:DetailedDocs) {
                            Write-Documentation -ShowDetailed:$true
                        } else {
                            Write-Documentation
                        }
                        return
                    }

                    # Compile the build plan
                    $effectiveTaskList = if ($TaskList -and $TaskList.Count -gt 0) {
                        $TaskList
                    } elseif ($CurrentContext.tasks.ContainsKey('default')) {
                        @('default')
                    } else {
                        @()
                    }

                    Write-Debug "Starting compile phase"
                    $plan = Compile-BuildPlan -BuildFile $BuildFile -TaskList $effectiveTaskList

                    if (-not $plan.IsValid) {
                        throw ($plan.ValidationErrors -join "`n")
                    }

                    Write-Debug "Compile phase complete, starting run phase"
                    # === RUN PHASE ===
                    $invokeBuildPlanSplat = @{
                        Plan           = $plan
                        NoCache        = $NoCache
                        Module         = $Module
                        CurrentContext = $CurrentContext
                        Parameters     = $script:Parameters
                        Properties     = $script:Properties
                        Initialization = $script:Initialization
                    }
                    $buildResult = Invoke-BuildPlan @invokeBuildPlanSplat

                    $script:buildResultOut = $buildResult

                    if ($buildResult.Success) {
                        $successMsg = $msgs.psake_success -f $BuildFile
                        if (-not $Quiet -and $OutputFormat -ne 'JSON') {
                            Write-BuildMessage ("$($script:nl)${successMsg}$($script:nl)") "Success"
                        }
                    }

                    if (-not $script:NoTimeReport -and -not $Quiet -and $OutputFormat -ne 'JSON') {
                        Write-TaskTimeSummary $buildResult.Duration
                    }
                }

                if ($Docs -or $DetailedDocs) {
                    $psake.build_success = $true
                    return
                }

                $buildResult = $script:buildResultOut
                $psake.build_success = $true

                if ($buildResult) {
                    $buildResult.Success = $true
                }
            }

        } catch {
            $psake.build_success = $false
            $psake.error_message = Format-ErrorMessage $_
            $psake.error_record = $_

            if ($buildResult) {
                Assert ($buildResult -is [PsakeBuildResult]) "Expected build result to be of type PsakeBuildResult. Is $($buildResult.GetType().FullName)"
                $buildResult.Success = $false
                $buildResult.ErrorMessage = $psake.error_message
                $buildResult.ErrorRecord = $_
            } else {
                $buildResult = [PsakeBuildResult]::new()
                $buildResult.Success = $false
                $buildResult.BuildFile = if ($BuildPlan) { $BuildPlan.BuildFile } else { $BuildFile }
                $buildResult.ErrorMessage = $psake.error_message
                $buildResult.CompletedAt = [datetime]::UtcNow
                $buildResult.ErrorRecord = $_
            }

            $inNestedScope = ($psake.Context.count -gt 1)
            if ( $inNestedScope ) {
                throw $_
            } else {
                if (!$psake.run_by_psake_build_tester) {
                    if (-not $Quiet -and $OutputFormat -ne 'JSON') {
                        Write-BuildMessage $psake.error_message "Error"
                    }
                }
            }
        } finally {
            Restore-Environment
        }

        # Output
        if ($OutputFormat -eq 'JSON' -and $buildResult) {
            $buildResult | ConvertTo-Json -Depth 3 -WarningAction Ignore
        } else {
            return $buildResult
        }
    }
}