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',
        # Publish via the container's development endpoint (like VS Code / BcContainerHelper -useDevEndpoint):
        # the app is published as a replaceable development extension - re-publishable without a version bump,
        # which is what test environments need. Requires -Credential (a BC user for the dev endpoint).
        [switch] $Development,
        [pscredential] $Credential
    )

    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

    if ($Development) {
        # Development (dev-endpoint) publish. The dev endpoint publishes + syncs + installs in one call and
        # marks the app as a development extension, so we do NOT run the Publish/Sync/Install-NAVApp flow.
        if (-not $Credential) { throw 'A development (dev-endpoint) publish requires -Credential (the BC user to authenticate to the dev endpoint).' }
        # The dev endpoint's schema-update modes are synchronize|recreate|forcesync; map from -SyncMode.
        $schemaUpdateMode = switch ($SyncMode) { 'ForceSync' { 'forcesync' } 'Clean' { 'recreate' } default { 'synchronize' } }

        $devScript = {
            # Resolve the developer-services endpoint from the server's CustomSettings.config (authoritative,
            # regardless of who created the container), then POST the .app to it (like V1 / VS Code publish).
            $cfgFile = Get-ChildItem -Path 'C:\Program Files\Microsoft Dynamics*\*\Service\CustomSettings.config' -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1
            if (-not $cfgFile) { [System.Console]::Out.WriteLine("ALBUILD-ERROR:Could not locate CustomSettings.config in the container to resolve the dev endpoint."); exit 1 }
            [xml]$cfg = Get-Content -LiteralPath $cfgFile.FullName
            $getSetting = { param($key) $node = $cfg.SelectSingleNode("//appSettings/add[@key='$key']"); if ($node) { $node.value } else { $null } }
            $port = & $getSetting 'DeveloperServicesPort'; if (-not $port) { $port = '7049' }
            $ssl = (& $getSetting 'DeveloperServicesSSLEnabled') -eq 'true'
            $proto = if ($ssl) { 'https' } else { 'http' }
            $url = "$proto`://localhost:$port/$ServerInstance/dev/apps?SchemaUpdateMode=$SchemaMode"

            if ($ssl) { [System.Net.ServicePointManager]::ServerCertificateValidationCallback = { $true } }
            Add-Type -AssemblyName System.Net.Http
            $client = [System.Net.Http.HttpClient]::new()
            $pair = "$($DevUser):$($DevPassword)"
            $b64 = [System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes($pair))
            $client.DefaultRequestHeaders.Authorization = [System.Net.Http.Headers.AuthenticationHeaderValue]::new('Basic', $b64)
            $client.Timeout = [System.Threading.Timeout]::InfiniteTimeSpan
            $client.DefaultRequestHeaders.ExpectContinue = $false

            $appName = Split-Path -Path $Path -Leaf
            $content = [System.Net.Http.MultipartFormDataContent]::new()
            $fs = [System.IO.FileStream]::new($Path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read)
            try {
                $disp = [System.Net.Http.Headers.ContentDispositionHeaderValue]::new('form-data')
                $disp.Name = $appName; $disp.FileName = $appName; $disp.FileNameStar = $appName
                $sc = [System.Net.Http.StreamContent]::new($fs); $sc.Headers.ContentDisposition = $disp
                $content.Add($sc)
                [System.Console]::Out.WriteLine("Dev-publishing '$appName' to $url ..."); [System.Console]::Out.Flush()
                $resp = $client.PostAsync($url, $content).GetAwaiter().GetResult()
                if (-not $resp.IsSuccessStatusCode) {
                    $detail = ''
                    try { $detail = $resp.Content.ReadAsStringAsync().GetAwaiter().GetResult() } catch { $detail = '' }
                    try { $j = $detail | ConvertFrom-Json; if ($j.Message) { $detail = $j.Message } } catch { $null = $_ }
                    [System.Console]::Out.WriteLine("ALBUILD-ERROR:Dev publish of '$appName' failed: $([int]$resp.StatusCode) $($resp.ReasonPhrase). $detail"); [System.Console]::Out.Flush()
                    exit 1
                }
                [System.Console]::Out.WriteLine("Dev-published '$appName' successfully."); [System.Console]::Out.Flush()
            }
            finally { $fs.Close() }
        }

        $null = Invoke-BcContainerCommand -ContainerName $Name -ScriptBlock $devScript -DockerExecutable $DockerExecutable -StreamOutput -Variables @{
            Path = $containerPath; ServerInstance = $ServerInstance; SchemaMode = $schemaUpdateMode
            DevUser = $Credential.UserName; DevPassword = $Credential.GetNetworkCredential().Password
        }
        Write-ALbuildLog -Level Success "Dev-published '$(Split-Path -Path $AppFile -Leaf)' to the development endpoint of '$Name'."
        return
    }

    $script = {
        $info = Get-NAVAppInfo -Path $Path
        # Each timeout-bounded child job is a fresh runspace that does not inherit the imported NAV cmdlets,
        # so it re-imports the exact assembly the session already loaded (resolved centrally, per BC version
        # and PowerShell edition, in ConvertTo-BcEncodedCommand). Reusing that loaded path keeps the version
        # logic in one place and works whether the session runs under Windows PowerShell or pwsh.
        $navMgmtPath = (Get-Command Publish-NAVApp -ErrorAction SilentlyContinue).Module.Path

        # 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) {
            # Install vs upgrade: a plain Install-NAVApp over an already-installed DIFFERENT version fails
            # ("tenant ... already uses a different version ... with the same app ID"). So if any OTHER
            # version of this app is already present on the tenant, the freshly published one is an
            # upgrade: run the data upgrade, then unpublish the superseded version(s). Otherwise it is a
            # first-time install. Mirrors Publish-BcOnPremApp (the directly-installed-server path).
            $prior = @(Get-NAVAppInfo -ServerInstance $ServerInstance -Name $info.Name -Publisher $info.Publisher -Tenant $Tenant -TenantSpecificProperties |
                    Where-Object { "$($_.AppId)" -eq "$($info.AppId)" -and "$($_.Version)" -ne "$($info.Version)" })

            if ($prior.Count -eq 0) {
                [System.Console]::Out.WriteLine("$($info.Name) $($info.Version): no other version present - first-time install.")
                $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 }
            }
            else {
                [System.Console]::Out.WriteLine("$($info.Name) $($info.Version): upgrading from $(@($prior | ForEach-Object { $_.Version }) -join ', ').")
                $job = Start-Job -ScriptBlock {
                    param($module, $si, $name, $ver, $tenant)
                    if ($module) { Import-Module $module -DisableNameChecking -ErrorAction Stop }
                    $wv = @()
                    Start-NAVAppDataUpgrade -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 "Upgrading to $app"
                if (-not $r.Ok) { [System.Console]::Out.WriteLine("ALBUILD-ERROR:$($r.Error)"); [System.Console]::Out.Flush(); exit 1 }

                foreach ($old in $prior) {
                    $job = Start-Job -ScriptBlock {
                        param($module, $si, $name, $publisher, $ver)
                        if ($module) { Import-Module $module -DisableNameChecking -ErrorAction Stop }
                        Unpublish-NAVApp -ServerInstance $si -Name $name -Publisher $publisher -Version $ver -ErrorAction Stop
                    } -ArgumentList $navMgmtPath, $ServerInstance, $info.Name, $info.Publisher, $old.Version
                    $r = Wait-BcStepJob -Job $job -Label "Unpublishing superseded $($info.Name) $($old.Version)"
                    # Best-effort cleanup: the upgrade already succeeded, so a failed unpublish of an old
                    # version must NOT fail the release. Warn and carry on (do not exit).
                    if (-not $r.Ok) { [System.Console]::Out.WriteLine("WARNING: Could not unpublish superseded $($info.Name) $($old.Version): $($r.Error). Continuing."); [System.Console]::Out.Flush() }
                }
            }
        }

        [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'."
}