Modules/businessdev.ALbuild.Containers/Private/ConvertFrom-BcCliXml.ps1

function ConvertFrom-BcCliXml {
    <#
    .SYNOPSIS
        Converts PowerShell CLIXML error-stream output into readable plain text.
    .DESCRIPTION
        Internal helper. When a child 'powershell' has its stderr captured through a pipe (as with
        'docker exec ... powershell'), it serialises its error/progress streams as CLIXML: a
        '#< CLIXML' marker followed by an <Objs> document in which the error text is XML-escaped
        (ANSI colour codes as '_x001B_[..m', line breaks as '_x000D__x000A_'). Dumped raw into an
        exception this is unreadable, so this reconstructs the message: it deserialises the document,
        keeps the error-stream strings (dropping progress records), and strips ANSI escape sequences.
        Anything that is not CLIXML is returned unchanged, so it is always safe to call.
    .PARAMETER Text
        The captured stream text, possibly containing a CLIXML document.
    .OUTPUTS
        System.String
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [AllowNull()] [AllowEmptyString()]
        [string] $Text
    )

    if ([string]::IsNullOrWhiteSpace($Text) -or ($Text -notmatch '#<\s*CLIXML')) { return $Text }

    # Isolate the <Objs>...</Objs> document, keeping any plain text that precedes the marker.
    $markerIndex = $Text.IndexOf('#<')
    $prefix = $Text.Substring(0, $markerIndex).Trim()
    $startIndex = $Text.IndexOf('<Objs', $markerIndex)
    $endTag = '</Objs>'
    $endIndex = if ($startIndex -ge 0) { $Text.IndexOf($endTag, $startIndex) } else { -1 }
    if ($startIndex -lt 0 -or $endIndex -lt 0) { return $Text }
    $xml = $Text.Substring($startIndex, $endIndex + $endTag.Length - $startIndex)

    try {
        $objects = [System.Management.Automation.PSSerializer]::Deserialize($xml)
    }
    catch {
        return $Text
    }

    # Error-stream fragments deserialise to strings (already containing their line breaks); progress
    # and other records come back as objects and are dropped.
    $message = (@($objects | Where-Object { $_ -is [string] }) -join '').Trim()
    $message = [regex]::Replace($message, "$([char]27)\[[0-9;]*[A-Za-z]", '')

    $combined = @($prefix, $message.Trim()) | Where-Object { $_ }
    return ($combined -join [Environment]::NewLine)
}