Modules/businessdev.ALbuild.Core/Public/Invoke-ALbuildProcess.ps1

function Invoke-ALbuildProcess {
    <#
    .SYNOPSIS
        Runs an external process, capturing stdout/stderr/exit code, with optional retry.
 
    .DESCRIPTION
        A reliable wrapper around System.Diagnostics.Process that:
          * captures stdout and stderr without dead-locking (asynchronous reads);
          * treats a configurable set of exit codes as success;
          * optionally retries on failure with a fixed back-off (for transient I/O);
          * throws a terminating, descriptive error on final failure unless -PassThru is used.
        Argument handling is dual-target: ArgumentList is used on PowerShell 7+, with a quoted
        fallback for Windows PowerShell 5.1.
 
    .PARAMETER FilePath
        The executable to run.
 
    .PARAMETER Arguments
        Arguments passed to the executable (array form; no manual quoting required).
 
    .PARAMETER WorkingDirectory
        Working directory for the process.
 
    .PARAMETER SuccessExitCodes
        Exit codes considered successful. Default: 0.
 
    .PARAMETER RetryCount
        Number of additional attempts on failure. Default: 0 (no retry).
 
    .PARAMETER RetryDelaySeconds
        Delay between attempts. Default: 5.
 
    .PARAMETER PassThru
        Return the result object even on failure instead of throwing.
 
    .PARAMETER StreamOutput
        Echo the process's stdout to the host line-by-line as it is produced (still captured in the
        returned StdOut), so long-running children show live progress instead of appearing all at
        once when they exit. Stderr is captured but not echoed live.
 
    .EXAMPLE
        Invoke-ALbuildProcess -FilePath 'docker' -Arguments @('version','--format','{{.Server.Version}}')
 
    .OUTPUTS
        PSCustomObject with ExitCode, StdOut, StdErr, Success, Attempts.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)]
        [ValidateNotNullOrEmpty()]
        [string] $FilePath,

        [Parameter(Position = 1)]
        [string[]] $Arguments = @(),

        [string] $WorkingDirectory,

        [int[]] $SuccessExitCodes = @(0),

        [ValidateRange(0, [int]::MaxValue)]
        [int] $RetryCount = 0,

        [ValidateRange(0, [int]::MaxValue)]
        [int] $RetryDelaySeconds = 5,

        [switch] $PassThru,

        [switch] $StreamOutput
    )

    $supportsArgumentList = $PSVersionTable.PSVersion.Major -ge 6

    $attempt = 0
    $result  = $null

    while ($attempt -le $RetryCount) {
        $attempt++

        $psi = [System.Diagnostics.ProcessStartInfo]::new()
        $psi.FileName               = $FilePath
        $psi.RedirectStandardOutput = $true
        $psi.RedirectStandardError  = $true
        $psi.UseShellExecute        = $false
        $psi.CreateNoWindow         = $true
        if ($WorkingDirectory) { $psi.WorkingDirectory = $WorkingDirectory }

        if ($supportsArgumentList) {
            foreach ($arg in $Arguments) { $psi.ArgumentList.Add([string]$arg) }
        }
        else {
            $psi.Arguments = ($Arguments | ForEach-Object {
                    $a = [string]$_
                    if ($a -match '\s|"') { '"' + ($a -replace '"', '\"') + '"' } else { $a }
                }) -join ' '
        }

        $process = [System.Diagnostics.Process]::new()
        $process.StartInfo = $psi

        try {
            if (-not $process.Start()) {
                throw "Failed to start process '$FilePath'."
            }

            if ($StreamOutput) {
                # Read stdout in chunks (not whole lines) and echo it raw, so partial-line progress -
                # e.g. a heartbeat that appends '.' without a trailing newline - is shown live instead
                # of being withheld until the next newline. Flush each chunk so it reaches the console
                # immediately. Drain stderr async to avoid the full-buffer deadlock. The full stdout is
                # still captured for the caller.
                $stderrTask = $process.StandardError.ReadToEndAsync()
                $sb = [System.Text.StringBuilder]::new()
                $buffer = [char[]]::new(4096)
                while (($read = $process.StandardOutput.Read($buffer, 0, $buffer.Length)) -gt 0) {
                    $chunk = [string]::new($buffer, 0, $read)
                    [System.Console]::Out.Write($chunk)
                    [System.Console]::Out.Flush()
                    [void]$sb.Append($chunk)
                }
                $process.WaitForExit()
                $stdout = $sb.ToString()
                $stderr = $stderrTask.GetAwaiter().GetResult()
                $exit   = $process.ExitCode
            }
            else {
                # Asynchronous reads avoid the classic full-buffer deadlock.
                $stdoutTask = $process.StandardOutput.ReadToEndAsync()
                $stderrTask = $process.StandardError.ReadToEndAsync()
                $process.WaitForExit()

                $stdout = $stdoutTask.GetAwaiter().GetResult()
                $stderr = $stderrTask.GetAwaiter().GetResult()
                $exit   = $process.ExitCode
            }
        }
        catch {
            $stdout = ''
            $stderr = $_.Exception.Message
            $exit   = -1
        }
        finally {
            $process.Dispose()
        }

        $success = $SuccessExitCodes -contains $exit

        $result = [PSCustomObject]@{
            ExitCode = $exit
            StdOut   = $stdout
            StdErr   = $stderr
            Success  = $success
            Attempts = $attempt
        }

        if ($success) { return $result }

        if ($attempt -le $RetryCount) {
            Write-ALbuildLog -Level Warning ("Process '$FilePath' failed (exit $exit), attempt $attempt of $($RetryCount + 1); retrying in $RetryDelaySeconds s...")
            if ($RetryDelaySeconds -gt 0) { Start-Sleep -Seconds $RetryDelaySeconds }
        }
    }

    if ($PassThru) { return $result }

    $detail = if ([string]::IsNullOrWhiteSpace($result.StdErr)) { $result.StdOut } else { $result.StdErr }
    $suffix = if ([string]::IsNullOrWhiteSpace($detail)) { '' } else { [Environment]::NewLine + $detail.Trim() }
    throw "Process '$FilePath' failed with exit code $($result.ExitCode) after $($result.Attempts) attempt(s).$suffix"
}