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

    # Resolve the executable so .NET Process.Start (UseShellExecute = $false) can actually launch it.
    # Process.Start does not search PATHEXT, so a bare name whose real file is a .cmd/.bat launcher (on
    # Windows 'az' is 'az.cmd', 'npm' is 'npm.cmd', ...) fails with "file not found". Resolve a bare name
    # via Get-Command, and run a .cmd/.bat through cmd.exe. (On Linux these are bare binaries; this just
    # resolves to the full path, which is harmless.)
    $launchVia = $null
    $resolvedPath = $FilePath
    if (-not [System.IO.Path]::IsPathRooted($FilePath)) {
        $resolvedCmd = Get-Command -Name $FilePath -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
        if ($resolvedCmd -and $resolvedCmd.Source) { $resolvedPath = $resolvedCmd.Source }
    }
    if ($resolvedPath -match '\.(cmd|bat)$') {
        $launchVia = $resolvedPath
        $resolvedPath = if ($env:ComSpec) { $env:ComSpec } else { 'cmd.exe' }
    }

    $attempt = 0
    $result  = $null

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

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

        if ($launchVia) {
            # cmd.exe /c needs the classic double-quote wrap - cmd strips the first and last quote of the
            # line, so wrapping the whole '"prog" "arg" ...' in an outer pair leaves a valid command even
            # when the .cmd path or any argument contains spaces. Set the raw string (not ArgumentList).
            $inner = '"' + $launchVia + '"'
            foreach ($arg in $Arguments) { $inner += ' "' + ([string]$arg -replace '"', '""') + '"' }
            $psi.Arguments = '/c "' + $inner + '"'
        }
        elseif ($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"
}