Modules/businessdev.ALbuild.Apps/Public/Install-BcContainerDependency.ps1

function Install-BcContainerDependency {
    <#
    .SYNOPSIS
        Publishes and installs an AL project's resolved dependency apps into a container, in
        dependency order.
 
    .DESCRIPTION
        The runtime counterpart to compile-time symbol resolution: an app can only be installed and
        tested once the apps it depends on are installed. This stages the .app packages found in
        -PackageFolder into the container's shared folder, then - inside the container - reads each
        package with Get-NAVAppInfo (which handles both regular and encrypted *runtime* packages,
        unlike a host-side ZIP reader), orders them so each app is published after the apps it depends
        on, and publishes/synchronises/installs each. Apps already installed (e.g. Microsoft
        first-party / the toolkit) are skipped; dependencies outside the set are assumed already
        present. Requires Windows + Docker.
 
    .PARAMETER Name
        Container name.
 
    .PARAMETER PackageFolder
        One or more folders of dependency .app files to install (searched recursively). Use this only
        when there is no resolver lock file: a developer's local .alpackages accumulates many
        historical versions/variants - prefer -PackageFile with the resolver-chosen set.
 
    .PARAMETER PackageFile
        Explicit dependency .app file paths to install (e.g. the files named in the resolver lock
        file). Preferred over -PackageFolder so only the resolved versions are installed. At least one
        of -PackageFolder/-PackageFile is required.
 
    .PARAMETER ServerInstance
        BC server instance inside the container. Default 'BC'.
 
    .PARAMETER SkipVerification
        Publish without signature verification. Default $true.
 
    .PARAMETER OperationTimeoutSeconds
        Maximum seconds to allow a single app's publish/sync/install before abandoning it and
        recording a failure. Prevents a hung operation (e.g. a deadlocked schema sync) from freezing
        the build. Default 600.
 
    .PARAMETER DockerExecutable
        The Docker executable to use (default 'docker').
 
    .OUTPUTS
        System.String lines describing what was installed/skipped.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseUsingScopeModifierInNewRunspaces', '',
        Justification = 'The Start-Job scriptblock declares a param() block bound positionally via -ArgumentList; $using: is intentionally not used.')]
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [Alias('ContainerName')] [string] $Name,
        [string[]] $PackageFolder,
        [string[]] $PackageFile,
        [string] $ServerInstance = 'BC',
        [bool] $SkipVerification = $true,
        [int] $OperationTimeoutSeconds = 600,
        [string] $DockerExecutable = 'docker'
    )

    if (-not $PackageFolder -and -not $PackageFile) { throw 'Specify -PackageFolder and/or -PackageFile.' }

    $found = New-Object System.Collections.Generic.List[object]
    foreach ($folder in $PackageFolder) {
        if ($folder -and (Test-Path -LiteralPath $folder)) {
            foreach ($f in (Get-ChildItem -LiteralPath $folder -Filter '*.app' -File -Recurse -ErrorAction SilentlyContinue)) { [void]$found.Add($f) }
        }
    }
    foreach ($file in $PackageFile) {
        if ($file -and (Test-Path -LiteralPath $file)) { [void]$found.Add((Get-Item -LiteralPath $file)) }
        elseif ($file) { Write-ALbuildLog -Level Warning "Dependency package file not found: $file" }
    }
    $files = @($found | Sort-Object -Property FullName -Unique)
    if ($files.Count -eq 0) {
        Write-ALbuildLog "No dependency packages found (folders: $($PackageFolder -join ', '); files: $((@($PackageFile) | Measure-Object).Count))."
        return
    }

    if (-not $PSCmdlet.ShouldProcess($Name, "Install $($files.Count) dependency app(s)")) { return }

    # Stage the packages into the container's shared folder (C:\run\my\deps) so the server can read
    # them - 'docker cp' is unsupported against a running hyperv container, and host-side parsing
    # cannot read encrypted runtime packages.
    $hostShare = Get-BcContainerHostShare -Name $Name
    $hostDeps = Join-Path $hostShare 'deps'
    if (Test-Path -LiteralPath $hostDeps) { Remove-Item -LiteralPath $hostDeps -Recurse -Force -ErrorAction SilentlyContinue }
    New-Item -ItemType Directory -Force -Path $hostDeps | Out-Null
    foreach ($file in $files) { Copy-Item -LiteralPath $file.FullName -Destination $hostDeps -Force }

    # Inside the container: read every package (Get-NAVAppInfo handles encrypted runtime apps), order
    # by dependency, then publish/sync/install the ones that are not already installed.
    $null = Invoke-BcContainerCommand -ContainerName $Name -DockerExecutable $DockerExecutable -StreamOutput -Variables @{
        DepsFolder              = 'C:\run\my\deps'
        ServerInstance          = $ServerInstance
        SkipVerification        = [bool]$SkipVerification
        OperationTimeoutSeconds = [int]$OperationTimeoutSeconds
        PollSeconds             = 10
    } -ScriptBlock {
        # Extract an app id from an app-info or dependency object (the property is 'AppId' on some
        # objects, 'Id' on others) and lowercase it. The 'if' must live in a scriptblock body (where
        # it is a valid expression in an assignment); written inline as '(if ...)' it would be parsed
        # as a command grouping and fail with "The term 'if' is not recognized".
        $appId = {
            param($o)
            $raw = if ($o.PSObject.Properties.Name -contains 'AppId') { $o.AppId } else { $o.Id }
            if ($raw) { "$raw".ToLowerInvariant() } else { '' }
        }

        # Does a .app contain an installable payload, or is it a symbols-only / compile-only package
        # (only the manifest + SymbolReference, which compiles but cannot be published)? A BC .app is a
        # NAVX header followed by a ZIP; a symbols package contains just those metadata files.
        $hasPayload = {
            param($path)
            try {
                Add-Type -AssemblyName System.IO.Compression -ErrorAction SilentlyContinue
                $bytes = [System.IO.File]::ReadAllBytes($path)
                $zs = -1
                for ($i = 0; ($i -lt 64) -and ($i -lt $bytes.Length - 4); $i++) {
                    if ($bytes[$i] -eq 0x50 -and $bytes[$i + 1] -eq 0x4B -and $bytes[$i + 2] -eq 0x03 -and $bytes[$i + 3] -eq 0x04) { $zs = $i; break }
                }
                if ($zs -lt 0) { return $true }   # unexpected format - do not block
                $ms = New-Object System.IO.MemoryStream (, ([byte[]]$bytes[$zs..($bytes.Length - 1)]))
                $zip = New-Object System.IO.Compression.ZipArchive($ms, [System.IO.Compression.ZipArchiveMode]::Read)
                $meta = @('[Content_Types].xml', 'DocComments.xml', 'NavxManifest.xml', 'SymbolReference.json')
                $payload = $false
                foreach ($e in $zip.Entries) { if ($meta -notcontains $e.FullName) { $payload = $true; break } }
                $zip.Dispose(); $ms.Dispose()
                return $payload
            }
            catch { return $true }   # on any error, do not block - let publish decide
        }

        $apps = foreach ($file in (Get-ChildItem -LiteralPath $DepsFolder -Filter '*.app' -File)) {
            try { $info = Get-NAVAppInfo -Path $file.FullName -ErrorAction Stop }
            catch { Write-Output "Skipped unreadable package '$($file.Name)': $($_.Exception.Message)"; continue }
            $depIds = @($info.Dependencies | ForEach-Object { & $appId $_ } | Where-Object { $_ })
            [PSCustomObject]@{
                File       = $file.FullName
                Id         = & $appId $info
                Name       = $info.Name
                Version    = $info.Version
                Publisher  = "$($info.Publisher)"
                DepIds     = $depIds
                HasPayload = (& $hasPayload $file.FullName)
            }
        }
        # Microsoft first-party apps (System/Base/Application, localization, test toolkit, ...) are
        # delivered by the container itself, so never publish them from .alpackages - those are the
        # compile-time symbol packages and may target a different platform build. ISV dependencies on
        # them are satisfied by the container, so dropping them here also keeps the dependency graph
        # correct (their ids simply aren't in-set).
        $platform = @($apps | Where-Object { $_.Publisher -eq 'Microsoft' })
        if ($platform.Count -gt 0) { Write-Output "Skipped $($platform.Count) Microsoft first-party package(s) provided by the container." }
        $apps = @($apps | Where-Object { $_.Id -and $_.Publisher -ne 'Microsoft' })

        # The same app can be present more than once - e.g. several versions committed under
        # .dependencies, or a local package that also resolved from a feed. Keep only the newest
        # version of each (by app id) so a stale copy is never installed alongside the current one.
        $byId = @{}
        foreach ($a in $apps) {
            $existing = $byId[$a.Id]
            if (-not $existing) { $byId[$a.Id] = $a; continue }
            $cur = try { [version]"$($a.Version)" } catch { [version]'0.0.0.0' }
            $prev = try { [version]"$($existing.Version)" } catch { [version]'0.0.0.0' }
            if ($cur -gt $prev) {
                Write-Output "Using newest '$($a.Name)' $($a.Version) (ignoring older $($existing.Version))."
                $byId[$a.Id] = $a
            }
            else {
                Write-Output "Using newest '$($existing.Name)' $($existing.Version) (ignoring older $($a.Version))."
            }
        }
        $apps = @($byId.Values)

        # Apps already installed in the tenant (skip those, and treat them as satisfied deps).
        $installed = @{}
        foreach ($a in @(Get-NAVAppInfo -ServerInstance $ServerInstance -Tenant 'default' -TenantSpecificProperties -ErrorAction SilentlyContinue)) {
            if ($a.IsInstalled) {
                $id = & $appId $a
                if ($id) { $installed[$id] = $true }
            }
        }

        # Topologically order: emit an app once its in-set dependencies have been emitted. Iterate the
        # fixed $apps array and track emitted apps in a hashtable (avoids List.Remove / @(list), which
        # bind unreliably in the container's Windows PowerShell).
        $inSet = @{}; foreach ($a in $apps) { $inSet[$a.Id] = $true }
        $done = @{}
        $ordered = New-Object System.Collections.Generic.List[object]
        while ($ordered.Count -lt $apps.Count) {
            $progressed = $false
            foreach ($a in $apps) {
                if ($done.ContainsKey($a.Id)) { continue }
                $unmet = @($a.DepIds | Where-Object { $inSet.ContainsKey($_) -and -not $done.ContainsKey($_) })
                if ($unmet.Count -eq 0) { [void]$ordered.Add($a); $done[$a.Id] = $true; $progressed = $true }
            }
            # A dependency cycle leaves apps unemitted; append them as-is so the publish still proceeds.
            if (-not $progressed) {
                foreach ($a in $apps) { if (-not $done.ContainsKey($a.Id)) { [void]$ordered.Add($a); $done[$a.Id] = $true } }
                break
            }
        }

        # Resolve the NAV management module so each timeout-bounded child job can import it (the same
        # module the container session already uses).
        $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 { '' }

        # Publish/sync/install each app, bounding every app in a timeout-limited child job so a hung
        # operation (e.g. a deadlocked schema sync) is abandoned and reported instead of freezing the
        # build. Do not abort on the first failure: record each, skip any app whose in-set dependency
        # failed (it cannot be satisfied), and report every failing app at the end.
        $failed = @{}    # id -> "Name Version - reason"
        $names = @{}     # id -> "Name Version" (for naming dependents)
        foreach ($a in $ordered) {
            $names[$a.Id] = "$($a.Name) $($a.Version)"
            if ($installed.ContainsKey($a.Id)) { Write-Output "Skipped (already installed) $($a.Name) $($a.Version)"; continue }

            # A symbols-only package compiles but has no installable payload - fail it clearly instead
            # of letting the server's recompile stall on the missing app.
            if (-not $a.HasPayload) {
                [System.Console]::Out.WriteLine("Installing '$($a.Name) $($a.Version)' SYMBOLS-ONLY"); [System.Console]::Out.Flush()
                $failed[$a.Id] = "$($a.Name) $($a.Version) - symbols-only package (compile-only): the .app has no application payload, so it cannot be installed. Provide the real, installable .app (the build/runtime artifact), not the symbol package."
                continue
            }

            $blockedBy = @($a.DepIds | Where-Object { $failed.ContainsKey($_) })
            if ($blockedBy.Count -gt 0) {
                $depNames = @($blockedBy | ForEach-Object { if ($names[$_]) { $names[$_] } else { $_ } }) -join ', '
                $failed[$a.Id] = "$($a.Name) $($a.Version) - dependency not installed: $depNames"
                Write-Output "FAILED $($a.Name) $($a.Version): dependency not installed ($depNames)"
                continue
            }

            # Default (additive) sync mode - fresh installs, and -ForceSync can hang on destructive
            # schema changes.
            # Suppress and CAPTURE warnings inside the job (a child job's warnings are written straight
            # to the host in Windows PowerShell, so the parent cannot redirect them); re-emit them as
            # 'ALBUILD-WARN:' data lines the parent prints after 'OK'.
            $job = Start-Job -ScriptBlock {
                param($module, $si, $path, $name, $ver, $skip)
                if ($module) { Import-Module $module -DisableNameChecking -ErrorAction Stop }
                $wv = @()
                Publish-NAVApp -ServerInstance $si -Path $path -SkipVerification:$skip -Scope Global -ErrorAction Stop -WarningVariable +wv -WarningAction SilentlyContinue
                Sync-NAVApp -ServerInstance $si -Name $name -Version $ver -ErrorAction Stop -WarningVariable +wv -WarningAction SilentlyContinue
                Install-NAVApp -ServerInstance $si -Name $name -Version $ver -ErrorAction Stop -WarningVariable +wv -WarningAction SilentlyContinue
                foreach ($x in $wv) { "ALBUILD-WARN:$x" }
            } -ArgumentList $navMgmtPath, $ServerInstance, $a.File, $a.Name, $a.Version, ([bool]$SkipVerification)

            # Live progress on one growing line: "Installing 'X 1.0' . . . OK" - a dot per poll,
            # flushed so it streams through the container/pipeline. Bounded by the timeout so a hung
            # operation cannot freeze the build.
            $stepStart = Get-Date
            [System.Console]::Out.Write("Installing '$($a.Name) $($a.Version)' "); [System.Console]::Out.Flush()
            $elapsed = 0
            $timedOut = $false
            while (-not (Wait-Job $job -Timeout $PollSeconds)) {
                $elapsed += $PollSeconds
                if ($elapsed -ge $OperationTimeoutSeconds) { $timedOut = $true; break }
                [System.Console]::Out.Write('. '); [System.Console]::Out.Flush()
            }

            if ($timedOut) {
                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 stall is usually a failed server-side operation (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 instead of a bare "timed out".
                $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() }
                $reason = if ($serverErr) { "timed out after $OperationTimeoutSeconds s. NAV server log: $serverErr" } else { "timed out after $OperationTimeoutSeconds s (operation hung; likely an incompatible schema/sync)" }
                $failed[$a.Id] = "$($a.Name) $($a.Version) - $reason"
                continue
            }

            try {
                # The job emits warnings as 'ALBUILD-WARN:' data lines; print 'OK' on the install line,
                # then each warning on its own line after it.
                $results = Receive-Job $job -ErrorAction Stop
                $jobWarn = @($results | Where-Object { "$_" -like 'ALBUILD-WARN:*' } | ForEach-Object { "$_" -replace '^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() }
            }
            catch {
                # Keep the COMPLETE error (only ANSI-stripped) for the live log so nothing is lost, and
                # derive a short categorised reason for the final summary. Keep the first AL error code
                # and the platform-version mismatch when present.
                $full = ("$($_.Exception.Message)" -replace '\x1b\[[0-9;]*m', '').TrimEnd()
                $raw = ($full -replace '\s+', ' ').Trim()
                $alErr = if ($raw -match '(error AL\d{3,}:[^|+~]{0,160})') { $matches[1].Trim() } else { '' }
                $plat = if ($raw -match 'Package:\s*([\d.]+),\s*Server:\s*([\d.]+)') { "built for BC platform $($matches[1]), container is $($matches[2])" } else { '' }
                $reason = ''
                if ($raw -match 'compilation failed') {
                    $reason = "extension compilation failed on publish$(if ($plat) { " ($plat)" })$(if ($alErr) { "; first error: $alErr" })"
                }
                elseif ($raw -match 'different version than the current server') {
                    $reason = "the package was built for a different BC platform build than this container$(if ($plat) { " ($plat)" })"
                }
                elseif ($raw -match 'synchroniz') { $reason = 'schema synchronization failed' }
                else {
                    $reason = ($raw -replace '^(Publish|Sync|Install)-NAVApp\s*:\s*', '').Trim()
                    if ($reason.Length -gt 200) { $reason = $reason.Substring(0, 200) + ' ...' }
                }
                if (-not $reason) { $reason = 'publish/install failed' }
                [System.Console]::Out.WriteLine("FAILED - $reason"); [System.Console]::Out.Flush()
                # The complete error, so support can see everything (not just the categorised reason).
                [System.Console]::Out.WriteLine($full); [System.Console]::Out.Flush()
                $failed[$a.Id] = "$($a.Name) $($a.Version) - $reason"
            }
            finally { Remove-Job $job -Force -ErrorAction SilentlyContinue }
        }

        if ($failed.Count -gt 0) {
            # Emit a clean failure summary (no PowerShell error-record scaffolding) and exit non-zero.
            # The complete per-app errors were already shown live above.
            [System.Console]::Out.WriteLine("ALBUILD-ERROR:$($failed.Count) dependency app(s) could not be installed (the ISV may not yet ship a build compatible with this BC version):")
            foreach ($v in @($failed.Values | Sort-Object)) { [System.Console]::Out.WriteLine("ALBUILD-ERROR: - $v") }
            [System.Console]::Out.Flush()
            exit 1
        }
    }

    # Per-app progress already streamed live; log a concise host-side completion line.
    Write-ALbuildLog -Level Success "Dependencies installed in '$Name'."
}