Modules/businessdev.ALbuild.Containers/Public/Invoke-BcContainerCommand.ps1

function Invoke-BcContainerCommand {
    <#
    .SYNOPSIS
        Runs a PowerShell script block inside a running Business Central (Windows) container.
 
    .DESCRIPTION
        Executes the script block inside the container via 'docker exec ... powershell
        -EncodedCommand', returning the captured stdout. Simple variables can be passed with
        -Variables (marshalled as JSON across the process boundary). This is the single seam
        through which higher-level container cmdlets run Business Central management cmdlets.
 
    .PARAMETER ContainerName
        The target container.
 
    .PARAMETER ScriptBlock
        The PowerShell to run inside the container.
 
    .PARAMETER Variables
        Optional hashtable of simple values made available as variables inside the container.
 
    .PARAMETER PowerShellExe
        Pin the in-container PowerShell host instead of letting Get-BcContainerPowerShellExe choose it.
        The automatic choice follows the BC management cmdlets (Windows PowerShell through BC28, pwsh from
        BC29, where only the .NET 8 modules remain). Pin 'powershell' for a script that needs .NET
        Framework instead - the AL test runner does: it loads the .NET Framework client assemblies from
        C:\Test Assemblies and relies on ServicePointManager for the loopback certificate bypass, which is
        obsolete/ineffective under .NET. Such a script must not need the management cmdlets.
 
    .PARAMETER DockerExecutable
        The Docker executable to use (default 'docker').
 
    .EXAMPLE
        Invoke-BcContainerCommand -ContainerName bld -ScriptBlock { Get-NavServerInstance }
 
    .OUTPUTS
        System.String (the command's stdout).
    #>

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

        [Parameter(Mandatory, Position = 1)]
        [ValidateNotNull()]
        [scriptblock] $ScriptBlock,

        [hashtable] $Variables = @{},

        # Echo the in-container output live (so a long operation shows progress) instead of returning
        # it only after the command completes. The full output is still captured and returned.
        [switch] $StreamOutput,

        [ValidateSet('powershell', 'pwsh')]
        [string] $PowerShellExe,

        [string] $DockerExecutable = 'docker'
    )

    $scriptText = ConvertTo-BcEncodedCommand -ScriptBlock $ScriptBlock -Variables $Variables -AsText
    $encoded = [System.Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($scriptText))

    # Run under Windows PowerShell while the container ships the legacy .NET-Framework NAV management
    # DLLs (the proven path), or pwsh from BC29, which ships only the .NET 8 ones (see
    # Get-BcContainerPowerShellExe). A caller whose script needs a specific edition pins it instead.
    $psExe = if ($PSBoundParameters.ContainsKey('PowerShellExe') -and $PowerShellExe) { $PowerShellExe }
    else { Get-BcContainerPowerShellExe -Name $ContainerName -DockerExecutable $DockerExecutable }

    # 'docker exec ... <ps> -EncodedCommand <base64>' is a single command line, capped at ~32 KB on
    # Windows. Large in-container scripts (dependency install, publish, toolkit) exceed it, so stage
    # them as a .ps1 in the container's shared folder and run with -File instead. Both Windows PowerShell
    # and pwsh accept -EncodedCommand (UTF-16LE base64, immune to command-line re-parsing) and -File.
    $cmdFileHost = $null
    if ($encoded.Length -lt 24000) {
        $arguments = @('exec', $ContainerName, $psExe, '-NoLogo', '-NoProfile', '-EncodedCommand', $encoded)
    }
    else {
        # Resolve the container's ACTUAL C:\run\my bind mount (works for any container, not just an
        # ALbuild-created one in this job) so the staged script is visible inside the container.
        $hostShare = Resolve-BcContainerHostShare -Name $ContainerName -DockerExecutable $DockerExecutable
        if (-not (Test-Path -LiteralPath $hostShare)) { New-Item -ItemType Directory -Force -Path $hostShare | Out-Null }
        $leaf = ".albuild-cmd-$([guid]::NewGuid().ToString('N')).ps1"
        $cmdFileHost = Join-Path $hostShare $leaf
        # UTF-8 with BOM so the container's PowerShell reads non-ASCII correctly.
        [System.IO.File]::WriteAllText($cmdFileHost, $scriptText, [System.Text.UTF8Encoding]::new($true))
        $arguments = @('exec', $ContainerName, $psExe, '-NoLogo', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', "C:\run\my\$leaf")
    }

    $dockerArgs = @{
        DockerExecutable = $DockerExecutable
        PassThru         = $true
        Quiet            = $true
        Arguments        = $arguments
    }
    if ($StreamOutput) { $dockerArgs['StreamOutput'] = $true }
    try { $result = Invoke-BcDocker @dockerArgs }
    finally { if ($cmdFileHost) { Remove-Item -LiteralPath $cmdFileHost -Force -ErrorAction SilentlyContinue } }

    if (-not $result.Success) {
        # Prefer an explicit, already-clean failure summary the in-container script emits as
        # 'ALBUILD-ERROR:' lines and exits on - this avoids PowerShell's error-record scaffolding
        # (source line, '~~~~', 'At line:', '+ CategoryInfo'/'+ FullyQualifiedErrorId'), which is
        # fragmented unreliably by CLIXML serialisation. Fall back to the captured stderr/stdout,
        # decoded from CLIXML and reduced to its core message.
        $markerLines = @($result.StdOut -split "`r?`n" |
                Where-Object { $_ -match '^ALBUILD-ERROR:' } |
                ForEach-Object { ($_ -replace '^ALBUILD-ERROR:', '').TrimEnd() })
        if ($markerLines.Count -gt 0) {
            $detail = ($markerLines -join [Environment]::NewLine).Trim()
        }
        else {
            $raw = if ([string]::IsNullOrWhiteSpace($result.StdErr)) { $result.StdOut } else { $result.StdErr }
            $detail = Format-BcErrorMessage -Text (ConvertFrom-BcCliXml -Text $raw)
        }
        throw "Command in container '$ContainerName' failed (exit $($result.ExitCode)).$(if ([string]::IsNullOrWhiteSpace($detail)) { '' } else { [Environment]::NewLine + $detail.Trim() })"
    }

    return $result.StdOut
}