Modules/businessdev.ALbuild.Apps/Public/Publish-BcContainerApp.ps1

function Publish-BcContainerApp {
    <#
    .SYNOPSIS
        Publishes an AL app (.app) to a Business Central container, optionally syncing/installing.
 
    .DESCRIPTION
        Copies the .app into the container and publishes it with the BC server management cmdlets,
        then optionally synchronises and installs it. Requires Windows + Docker.
 
    .PARAMETER Name
        Container name.
 
    .PARAMETER AppFile
        Path to the .app file on the host.
 
    .PARAMETER SkipVerification
        Publish without signature verification (for unsigned development apps).
 
    .PARAMETER Sync
        Synchronise the app after publishing.
 
    .PARAMETER Install
        Install the app after synchronising.
 
    .PARAMETER SyncMode
        Schema sync mode: Add (default), Clean, Development or ForceSync.
 
    .PARAMETER Scope
        Global (default) or Tenant.
 
    .PARAMETER ServerInstance
        BC server instance. Default 'BC'.
 
    .PARAMETER Tenant
        Tenant. Default 'default'.
 
    .PARAMETER DockerExecutable
        The Docker executable to use (default 'docker').
 
    .PARAMETER OperationTimeoutSeconds
        Maximum seconds to allow the publish/sync/install before abandoning it and throwing. Prevents
        a hung operation (e.g. a deadlocked schema sync) from freezing the build. Default 600.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseUsingScopeModifierInNewRunspaces', '',
        Justification = 'The Start-Job scriptblock declares a param() block bound positionally via -ArgumentList; $using: is intentionally not used.')]
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [Alias('ContainerName')] [string] $Name,
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $AppFile,
        [switch] $SkipVerification,
        [switch] $Sync,
        [switch] $Install,
        [ValidateSet('Add', 'Clean', 'Development', 'ForceSync')] [string] $SyncMode = 'Add',
        [ValidateSet('Global', 'Tenant')] [string] $Scope = 'Global',
        [string] $ServerInstance = 'BC',
        [string] $Tenant = 'default',
        [int] $OperationTimeoutSeconds = 600,
        [string] $DockerExecutable = 'docker'
    )

    if (-not (Test-Path -LiteralPath $AppFile)) { throw "App file not found: '$AppFile'." }
    if (-not $PSCmdlet.ShouldProcess($Name, "Publish $(Split-Path $AppFile -Leaf)")) { return }

    $containerPath = Copy-BcFileToContainer -Name $Name -Source $AppFile -DockerExecutable $DockerExecutable

    $script = {
        $info = Get-NAVAppInfo -Path $Path
        # Resolve the NAV management module so each timeout-bounded child job can import it.
        $navMgmt = @(Get-ChildItem 'C:\Program Files\Microsoft Dynamics NAV\*\Service\Management\Microsoft.Dynamics.Nav.Management.dll' -ErrorAction SilentlyContinue)
        if (-not $navMgmt) { $navMgmt = @(Get-ChildItem 'C:\Program Files\Microsoft Dynamics NAV' -Recurse -Filter 'Microsoft.Dynamics.Nav.Management.dll' -ErrorAction SilentlyContinue) }
        $navMgmtPath = if ($navMgmt) { $navMgmt[0].FullName } else { '' }

        # Show a single operation's job as a growing-dots line, then 'OK' / 'FAILED <complete error>'
        # / 'TIMED OUT', so EACH step is shown separately and it is obvious which one hangs. The
        # caller builds the job with explicit scalar arguments (a robust Start-Job pattern).
        function Wait-BcStepJob {
            param([System.Management.Automation.Job] $Job, [string] $Label)
            $stepStart = Get-Date
            [System.Console]::Out.Write("$Label "); [System.Console]::Out.Flush()
            $elapsed = 0
            while (-not (Wait-Job $Job -Timeout $PollSeconds)) {
                $elapsed += $PollSeconds
                if ($elapsed -ge $OperationTimeoutSeconds) {
                    Stop-Job $Job -ErrorAction SilentlyContinue
                    Remove-Job $Job -Force -ErrorAction SilentlyContinue
                    [System.Console]::Out.WriteLine("TIMED OUT after $OperationTimeoutSeconds s"); [System.Console]::Out.Flush()
                    # A stalled publish/sync is usually a *server-side* operation that failed (e.g. an
                    # extension recompile) whose real error only lands in the NAV server event log -
                    # the cmdlet just never returns. Surface that log entry so the reason is visible.
                    $serverErr = ''
                    try {
                        $evt = Get-WinEvent -FilterHashtable @{ LogName = 'Application'; ProviderName = "MicrosoftDynamicsNavServer`$$ServerInstance"; Level = 2; StartTime = $stepStart } -MaxEvents 1 -ErrorAction SilentlyContinue
                        if ($evt) {
                            $serverErr = if ($evt.Message -match '(?s)\bMessage\b\s*(.+)$') { $matches[1] } else { $evt.Message }
                            $serverErr = ($serverErr -replace '\x1b\[[0-9;]*m', '' -replace '\s+', ' ').Trim()
                        }
                    }
                    catch { $serverErr = '' }   # best-effort: never let log-reading mask the timeout
                    if ($serverErr) {
                        [System.Console]::Out.WriteLine("NAV server log: $serverErr"); [System.Console]::Out.Flush()
                        return [PSCustomObject]@{ Ok = $false; Error = "$Label timed out after $OperationTimeoutSeconds s. NAV server log: $serverErr" }
                    }
                    return [PSCustomObject]@{ Ok = $false; Error = "$Label timed out after $OperationTimeoutSeconds s (operation hung)" }
                }
                [System.Console]::Out.Write('. '); [System.Console]::Out.Flush()
            }
            try {
                # The job emits warnings as 'ALBUILD-WARN:' data lines (a child job's warnings are
                # written straight to the host, so they cannot be redirected from the parent). Print
                # 'OK' on the step line, then each warning on its own line; the rest is real output.
                $results = Receive-Job $Job -ErrorAction Stop
                $jobWarn = @($results | Where-Object { "$_" -like 'ALBUILD-WARN:*' } | ForEach-Object { "$_" -replace '^ALBUILD-WARN:', '' })
                $out = @($results | Where-Object { "$_" -notlike 'ALBUILD-WARN:*' })
                [System.Console]::Out.WriteLine('OK'); [System.Console]::Out.Flush()
                foreach ($w in $jobWarn) { [System.Console]::Out.WriteLine(" WARNING: $(($w -replace '\s+', ' ').Trim())"); [System.Console]::Out.Flush() }
                $txt = if ($out) { ($out | Out-String).Trim() } else { '' }
                if ($txt) { [System.Console]::Out.WriteLine($txt); [System.Console]::Out.Flush() }
                return [PSCustomObject]@{ Ok = $true; Error = '' }
            }
            catch {
                [System.Console]::Out.WriteLine('FAILED'); [System.Console]::Out.Flush()
                # Show the COMPLETE error (full compiler/server output), only ANSI-stripped.
                $full = ("$($_.Exception.Message)" -replace '\x1b\[[0-9;]*m', '').TrimEnd()
                [System.Console]::Out.WriteLine($full); [System.Console]::Out.Flush()
                $first = @(($full -split "`r?`n") | Where-Object { $_.Trim() })
                $first = if ($first.Count -gt 0) { $first[0].Trim() } else { 'failed' }
                return [PSCustomObject]@{ Ok = $false; Error = "$Label failed: $first" }
            }
            finally { Remove-Job $Job -Force -ErrorAction SilentlyContinue }
        }

        $app = "'$($info.Name) $($info.Version)'"

        $job = Start-Job -ScriptBlock {
            param($module, $si, $path, $scope, $skip)
            if ($module) { Import-Module $module -DisableNameChecking -ErrorAction Stop }
            $wv = @()
            Publish-NAVApp -ServerInstance $si -Path $path -Scope $scope -SkipVerification:$skip -ErrorAction Stop -WarningVariable +wv -WarningAction SilentlyContinue
            foreach ($x in $wv) { "ALBUILD-WARN:$x" }
        } -ArgumentList $navMgmtPath, $ServerInstance, $Path, $Scope, ([bool]$SkipVerification)
        $r = Wait-BcStepJob -Job $job -Label "Publishing $app"
        if (-not $r.Ok) { [System.Console]::Out.WriteLine("ALBUILD-ERROR:$($r.Error)"); [System.Console]::Out.Flush(); exit 1 }

        if ($DoSync) {
            $job = Start-Job -ScriptBlock {
                param($module, $si, $name, $ver, $mode, $tenant)
                if ($module) { Import-Module $module -DisableNameChecking -ErrorAction Stop }
                $wv = @()
                Sync-NAVApp -ServerInstance $si -Name $name -Version $ver -Mode $mode -Tenant $tenant -Force -ErrorAction Stop -WarningVariable +wv -WarningAction SilentlyContinue
                foreach ($x in $wv) { "ALBUILD-WARN:$x" }
            } -ArgumentList $navMgmtPath, $ServerInstance, $info.Name, $info.Version, $Mode, $Tenant
            $r = Wait-BcStepJob -Job $job -Label "Syncing $app"
            if (-not $r.Ok) { [System.Console]::Out.WriteLine("ALBUILD-ERROR:$($r.Error)"); [System.Console]::Out.Flush(); exit 1 }
        }

        if ($DoInstall) {
            $job = Start-Job -ScriptBlock {
                param($module, $si, $name, $ver, $tenant)
                if ($module) { Import-Module $module -DisableNameChecking -ErrorAction Stop }
                $wv = @()
                Install-NAVApp -ServerInstance $si -Name $name -Version $ver -Tenant $tenant -ErrorAction Stop -WarningVariable +wv -WarningAction SilentlyContinue
                foreach ($x in $wv) { "ALBUILD-WARN:$x" }
            } -ArgumentList $navMgmtPath, $ServerInstance, $info.Name, $info.Version, $Tenant
            $r = Wait-BcStepJob -Job $job -Label "Installing $app"
            if (-not $r.Ok) { [System.Console]::Out.WriteLine("ALBUILD-ERROR:$($r.Error)"); [System.Console]::Out.Flush(); exit 1 }
        }

        [System.Console]::Out.WriteLine("Published $($info.Name) $($info.Version)")
    }

    # Stream the in-container output so the progress line is visible live; the full output is still
    # returned. Log a concise host-side summary (the per-app line already streamed).
    $null = Invoke-BcContainerCommand -ContainerName $Name -ScriptBlock $script -DockerExecutable $DockerExecutable -StreamOutput -Variables @{
        Path = $containerPath; ServerInstance = $ServerInstance; Scope = $Scope
        SkipVerification = [bool]$SkipVerification; DoSync = [bool]$Sync; DoInstall = [bool]$Install; Mode = $SyncMode; Tenant = $Tenant
        OperationTimeoutSeconds = [int]$OperationTimeoutSeconds; PollSeconds = 10
    }
    Write-ALbuildLog -Level Success "Published & installed '$(Split-Path -Path $AppFile -Leaf)' into '$Name'."
}