Modules/businessdev.ALbuild.OnPrem/Private/Invoke-BcManagementProxyCommand.ps1

function Invoke-BcManagementProxyCommand {
    <#
    .SYNOPSIS
        Runs one Business Central management cmdlet in a PowerShell 7 child process and returns its
        output to the calling (Windows PowerShell 5.1) session.
 
    .DESCRIPTION
        From BC29 the management cmdlets exist only as .NET 8 assemblies
        (Microsoft.BusinessCentral.Management / .Apps.Management under ...\Service\Admin). Windows
        PowerShell 5.1 cannot load a .NET 8 assembly, and BC29 removed the Windows PowerShell 5
        compatibility module Microsoft.Dynamics.Nav.Management.dll that used to bridge that gap.
        Microsoft ships no replacement bridge: their own NavAdminTool.ps1 just prints "Couldn't load
        <module>" under 5.1.
 
        Azure DevOps runs task.ps1 under Windows PowerShell 5.1, so on-prem releases against a BC29
        server must delegate. This helper is that delegation: it serialises the bound parameters,
        launches 'pwsh' with a generated script that imports the modern modules and splats the
        parameters into the real cmdlet, and deserialises the result back. Import-BcManagementShell
        installs thin proxy functions (Publish-NAVApp, Sync-NAVApp, ...) that call in here, so the
        public on-prem functions are unchanged and keep working in-process wherever the cmdlets load
        natively (BC <= 28 under 5.1, any version under pwsh).
 
        Objects come back as deserialised PSObjects. Property access is unaffected, and the callers
        already coerce identity values (e.g. [guid]"$($info.AppId)") because the legacy compatibility
        module produced the same deserialised shapes.
 
    .PARAMETER Command
        The management cmdlet to invoke, e.g. 'Publish-NAVApp'.
 
    .PARAMETER Parameters
        The parameters to splat into it. Common parameters other than ErrorAction are dropped;
        ErrorAction is forwarded so the cmdlet keeps its usual behaviour in the child.
 
    .PARAMETER ModuleFile
        Full paths of the management modules (.psd1/.dll) the child must import before invoking.
 
    .PARAMETER PwshPath
        Full path to pwsh.exe.
 
    .OUTPUTS
        Whatever the cmdlet returned (deserialised), or nothing.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Command,
        [hashtable] $Parameters = @{},
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string[]] $ModuleFile,
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $PwshPath
    )

    # Common parameters would either be meaningless in the child (-ErrorVariable writes into a variable
    # that dies with the process) or break splatting (-Verbose as a [switch] is fine, but the *Variable /
    # *Buffer ones are not). ErrorAction is the one we must keep: the on-prem publish relies on
    # '-ErrorAction SilentlyContinue' returning nothing instead of throwing when the version already exists.
    $dropped = @(
        'Verbose', 'Debug', 'WarningAction', 'InformationAction', 'ErrorVariable', 'WarningVariable',
        'InformationVariable', 'OutVariable', 'OutBuffer', 'PipelineVariable', 'ProgressAction',
        'WhatIf', 'Confirm'
    )
    $effective = @{}
    foreach ($key in $Parameters.Keys) {
        if ($dropped -contains $key) { continue }
        $effective[$key] = $Parameters[$key]
    }

    $callerErrorAction = "$($Parameters['ErrorAction'])"
    $tolerated = @('SilentlyContinue', 'Ignore') -contains $callerErrorAction

    $stem = Join-Path ([System.IO.Path]::GetTempPath()) "albuild-mgmt-$([guid]::NewGuid().ToString('N'))"
    $paramFile = "$stem.params.xml"
    $outFile = "$stem.out.xml"
    $scriptFile = "$stem.ps1"
    $stdOutFile = "$stem.stdout.txt"
    $stdErrFile = "$stem.stderr.txt"

    try {
        # CLIXML rather than JSON: it round-trips the parameter types the cmdlets expect (Guid, Version,
        # bool, string[]) without us hand-rolling conversions.
        Export-Clixml -InputObject $effective -LiteralPath $paramFile -Depth 5 | Out-Null

        # Everything interpolated into the child script lands inside a single-quoted PowerShell literal,
        # so double any apostrophe (a temp path under a profile like 'O'Brien' would otherwise break it).
        $q = { param([string] $Text) $Text -replace "'", "''" }
        $imports = ($ModuleFile | ForEach-Object { "Import-Module -Name '$(& $q $_)' -DisableNameChecking -ErrorAction Stop" }) -join [Environment]::NewLine
        $childScript = @"
`$ErrorActionPreference = 'Stop'
try {
$imports
}
catch {
    [Console]::Error.WriteLine('ALBUILD-PROXY-IMPORT: ' + `$_.Exception.Message)
    exit 3
}
`$__p = Import-Clixml -LiteralPath '$(& $q $paramFile)'
try {
    `$__r = & '$(& $q $Command)' @__p
    if (`$null -ne `$__r) { `$__r | Export-Clixml -LiteralPath '$(& $q $outFile)' -Depth 5 }
    exit 0
}
catch {
    [Console]::Error.WriteLine('ALBUILD-PROXY-ERROR: ' + `$_.Exception.Message)
    exit 2
}
"@

        # UTF-8 with BOM so pwsh reads non-ASCII (app names, publisher) correctly.
        [System.IO.File]::WriteAllText($scriptFile, $childScript, [System.Text.UTF8Encoding]::new($true))

        Write-ALbuildLog -Level Verbose "Delegating '$Command' to PowerShell 7 ('$PwshPath')."

        $process = Start-Process -FilePath $PwshPath -ArgumentList @(
            '-NoLogo', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $scriptFile
        ) -Wait -PassThru -NoNewWindow -RedirectStandardOutput $stdOutFile -RedirectStandardError $stdErrFile

        $stdOut = if (Test-Path -LiteralPath $stdOutFile) { (Get-Content -LiteralPath $stdOutFile -Raw) } else { '' }
        $stdErr = if (Test-Path -LiteralPath $stdErrFile) { (Get-Content -LiteralPath $stdErrFile -Raw) } else { '' }

        # The cmdlets log progress on stdout (Sync-NAVApp and friends are chatty). Surface it so an
        # on-prem release stays diagnosable from the pipeline log alone.
        if (-not [string]::IsNullOrWhiteSpace($stdOut)) {
            foreach ($line in ($stdOut -split "`r?`n" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })) {
                Write-ALbuildLog -Level Information " [$Command] $($line.TrimEnd())"
            }
        }

        if ($process.ExitCode -eq 0) {
            if (Test-Path -LiteralPath $outFile) { return Import-Clixml -LiteralPath $outFile }
            return
        }

        $detail = Format-BcErrorMessage -Text (($stdErr -replace 'ALBUILD-PROXY-(IMPORT|ERROR): ', '').Trim())
        if ([string]::IsNullOrWhiteSpace($detail)) { $detail = "pwsh exited with code $($process.ExitCode)." }

        # Exit 3 = the modules themselves would not load: infrastructure, never silenced. Exit 2 = the
        # cmdlet failed, so honour the caller's -ErrorAction (SilentlyContinue must return nothing).
        if ($process.ExitCode -eq 2 -and $tolerated) {
            Write-ALbuildLog -Level Verbose "'$Command' failed but -ErrorAction $callerErrorAction was requested: $detail"
            return
        }
        throw "$Command failed in the PowerShell 7 management session. $detail"
    }
    finally {
        Remove-Item -LiteralPath $paramFile, $outFile, $scriptFile, $stdOutFile, $stdErrFile -Force -ErrorAction SilentlyContinue
    }
}