modules/shared/CliTimeout.ps1

#Requires -Version 7.0
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

<#
.SYNOPSIS
    CLI timeout helper for wrapper scripts.
.DESCRIPTION
    Provides Invoke-WithTimeout for wrapping external CLI invocations with a
    hard timeout. When the command resolves to a real executable (Application),
    uses System.Diagnostics.Process for reliable timeout/kill semantics.
    When the command is a PowerShell function (e.g. test mock), falls back to
    the call operator & so that Pester mocks work transparently.

    Returns [PSCustomObject]@{ ExitCode; Output; Stdout; Stderr }.
    On timeout: ExitCode = -1, Output = "Timed out after N seconds".
#>


# Always define the smart version that handles both real executables and test mocks.
# This overrides any previously-loaded Process-only version from Installer.ps1
# which cannot invoke PowerShell function mocks used by Pester tests.
function Invoke-WithTimeout {
        [CmdletBinding()]
        param (
            [Parameter(Mandatory)][string]   $Command,
            [Parameter(Mandatory)][string[]] $Arguments,
            [int] $TimeoutSec = 300
        )

        $sanitize = if (Get-Command Remove-Credentials -ErrorAction SilentlyContinue) {
            ${function:Remove-Credentials}
        } else {
            { param([string]$Text) $Text }
        }

        $cmdInfo = Get-Command $Command -ErrorAction SilentlyContinue
        $cmdType = if ($cmdInfo -and $cmdInfo.PSObject.Properties['CommandType']) { $cmdInfo.CommandType } else { $null }

        # Real executable — use System.Diagnostics.Process with hard timeout
        if ($cmdType -eq 'Application') {
            $psi = [System.Diagnostics.ProcessStartInfo]::new()
            $psi.FileName  = $cmdInfo.Source
            foreach ($a in $Arguments) { $psi.ArgumentList.Add($a) }
            $psi.RedirectStandardOutput = $true
            $psi.RedirectStandardError  = $true
            $psi.UseShellExecute = $false

            $proc = [System.Diagnostics.Process]::new()
            $proc.StartInfo = $psi
            $null = $proc.Start()

            $stdoutTask = $proc.StandardOutput.ReadToEndAsync()
            $stderrTask = $proc.StandardError.ReadToEndAsync()

            if (-not $proc.WaitForExit($TimeoutSec * 1000)) {
                try { $proc.Kill($true) } catch {
                    Write-Verbose ("Invoke-WithTimeout: Kill after timeout failed: {0}" -f $_.Exception.Message)
                }
                return [PSCustomObject]@{
                    ExitCode = -1
                    Output   = "Timed out after $TimeoutSec seconds"
                    Stdout   = ''
                    Stderr   = "Timed out after $TimeoutSec seconds"
                }
            }

            $stdout  = $stdoutTask.Result
            $stderr  = $stderrTask.Result
            $combined = (& $sanitize (($stdout + "`n" + $stderr).Trim()))
            return [PSCustomObject]@{
                ExitCode = $proc.ExitCode
                Output   = $combined
                Stdout   = (& $sanitize $stdout)
                Stderr   = (& $sanitize $stderr)
            }
        }

        # PowerShell function/alias/cmdlet (test mocks) — call operator fallback.
        # Sanitize the captured output to mirror the real-executable branch, so
        # tests and callers see consistent credential-scrubbed text regardless
        # of whether $Command resolves to a real binary or a Pester mock.
        $output = & $Command @Arguments 2>&1 | Out-String
        $lastExit = if (Test-Path variable:LASTEXITCODE) { $LASTEXITCODE } else { 0 }
        $sanitizedOutput = (& $sanitize $output.Trim())
        return [PSCustomObject]@{
            ExitCode = $lastExit
            Output   = $sanitizedOutput
            Stdout   = $sanitizedOutput
            Stderr   = ''
        }
    }