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 }

    if ($Development) {
        # Development (dev-endpoint) publish - the app is published as a replaceable ModernDev extension
        # (re-publishable without a version bump), so we do NOT run the Publish/Sync/Install-NAVApp flow.
        # The POST is made from the HOST to the container's IP: the in-container loopback to the SSL dev
        # endpoint is rejected at the TLS layer, whereas host->container-IP works (BcContainerHelper does
        # the same with -useDevEndpoint).
        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' } }

        # Resolve the developer-services port + SSL from the container's own CustomSettings.config
        # (authoritative regardless of which tool created the container).
        $cfgRaw = Invoke-BcContainerCommand -ContainerName $Name -DockerExecutable $DockerExecutable -ScriptBlock {
            $c = Get-ChildItem -Path 'C:\Program Files\Microsoft Dynamics*\*\Service\CustomSettings.config' -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1
            if (-not $c) { '|' }
            else {
                [xml]$x = Get-Content -LiteralPath $c.FullName
                $g = { param($k) $n = $x.SelectSingleNode("//appSettings/add[@key='$k']"); if ($n) { $n.value } }
                "$(& $g 'DeveloperServicesPort')|$(& $g 'DeveloperServicesSSLEnabled')"
            }
        }
        $cfgLine = @("$cfgRaw" -split "`r?`n" | Where-Object { $_ -match '\|' } | Select-Object -Last 1)
        $parts = "$cfgLine".Trim() -split '\|', 2
        $port = if ($parts[0].Trim()) { $parts[0].Trim() } else { '7049' }
        $useSsl = ($parts.Count -gt 1) -and ($parts[1].Trim() -eq 'true')

        # The dev endpoint is reachable at the container's IP (host->container NAT); fall back to the name.
        $ipResult = Invoke-BcDocker -DockerExecutable $DockerExecutable -PassThru -Quiet -Arguments @('inspect', '-f', '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}', $Name)
        $ip = if ($ipResult.Success) { "$($ipResult.StdOut)".Trim() } else { '' }
        $hostForUrl = if ($ip) { $ip } else { $Name }
        $proto = if ($useSsl) { 'https' } else { 'http' }
        $devUrl = "$proto`://$hostForUrl`:$port/$ServerInstance/dev/apps?SchemaUpdateMode=$schemaUpdateMode"

        Write-ALbuildLog "Dev-publishing '$(Split-Path -Path $AppFile -Leaf)' to $devUrl ..."
        Publish-BcAppToDevEndpoint -Url $devUrl -AppFile $AppFile -Credential $Credential -IgnoreSslErrors:$useSsl
        Write-ALbuildLog -Level Success "Dev-published '$(Split-Path -Path $AppFile -Leaf)' to the development endpoint of '$Name'."
        return
    }

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

    $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) {
            # Read the tenant's view of this app ONCE: the just-published version's SyncState (for the
            # fail-fast below), whether it is already installed, and the version the tenant DATA is at
            # (ExtensionDataVersion) - which, not "is another version published", is what actually decides
            # install vs data-upgrade.
            $tenantInfo = @()
            try {
                $tenantInfo = @(Get-NAVAppInfo -ServerInstance $ServerInstance -Name $info.Name -Publisher $info.Publisher -Tenant $Tenant -TenantSpecificProperties |
                        Where-Object { "$($_.AppId)" -eq "$($info.AppId)" })
            }
            catch { }
            # Property access is StrictMode-safe: Get-NAVAppInfo -TenantSpecificProperties carries SyncState /
            # ExtensionDataVersion / IsInstalled, but a mock (or an odd BC version) may not, and StrictMode
            # throws on a missing property. Read via PSObject.Properties so an absent field is just $null.
            $prop = { param($o, $n) if ($o -and ($o.PSObject.Properties.Name -contains $n)) { $o.$n } else { $null } }
            $thisVer = @($tenantInfo | Where-Object { "$($_.Version)" -eq "$($info.Version)" }) | Select-Object -First 1
            $syncState = "$(& $prop $thisVer 'SyncState')"

            # §2 FAIL FAST: Sync-NAVApp above can report success while the NST leaves the app NotSynced (a
            # schema conflict silently blocked 'Add' mode). A plain install then fails with "not
            # synchronized". Stop here with an actionable code + remedy instead of proceeding to a doomed
            # install. Callers pass -SyncMode ForceSync (CLI: --if-exists force-sync) to push the change.
            if ($DoSync -and $syncState -and $syncState -notin @('Synced', 'Unknown')) {
                [System.Console]::Out.WriteLine("ALBUILD-ERROR:NOT_SYNCHRONIZED: '$($info.Name)' $($info.Version) is '$syncState' after sync (a schema change was not applied). Re-publish with -SyncMode ForceSync (CLI: --if-exists force-sync).")
                [System.Console]::Out.Flush(); exit 1
            }

            # The version the tenant's DATA is at (highest ExtensionDataVersion across versions).
            $tenantDataList = @($tenantInfo | ForEach-Object { "$(& $prop $_ 'ExtensionDataVersion')" } | Where-Object { $_ })
            $tenantData = if ($tenantDataList.Count -gt 0) { @($tenantDataList | Sort-Object { [version]$_ } -Descending)[0] } else { '' }
            # Any OTHER published version still present.
            $prior = @($tenantInfo | Where-Object { "$($_.Version)" -ne "$($info.Version)" })

            # Upgrade when a DIFFERENT version is published, OR the tenant holds data written by an OLDER
            # version (even if no old version is still published - the case a plain install cannot fix and
            # that leaves the app NotSynced). Equal tenant data version == install (§3a: a same-version
            # republish is never an upgrade, whatever the caller asked for).
            $doUpgrade = ($prior.Count -gt 0)
            if (-not $doUpgrade -and $tenantData -and ([version]$tenantData -lt [version]$info.Version)) { $doUpgrade = $true }

            if (-not $doUpgrade -and (& $prop $thisVer 'IsInstalled')) {
                # §3a: same version already installed (e.g. a force-sync republish of the current version) -
                # Install-NAVApp would error "already installed". It is a no-op; report and move on.
                [System.Console]::Out.WriteLine("$($info.Name) $($info.Version): already installed (tenant data '$tenantData') - nothing to install.")
            }
            elseif (-not $doUpgrade) {
                [System.Console]::Out.WriteLine("$($info.Name) $($info.Version): first-time install (tenant data '$tenantData').")
                $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'."
}