Modules/businessdev.ALbuild.Apps/Public/Invoke-BcCompiler.ps1

function Invoke-BcCompiler {
    <#
    .SYNOPSIS
        Compiles an AL project into an .app package.
 
    .DESCRIPTION
        Compiles an AL project using either the cross-platform AL compiler (default) or a
        container's compiler:
          -Engine AlTool (default) invokes the AL compiler executable (alc) on the host. Symbols
                           are taken from the package cache (.alpackages), which Resolve-BcDependencies
                           populates; the AL compiler does not download dependencies itself.
          -Engine Container runs the compiler inside a Business Central container via the exec seam.
        The output file name follows the BC convention "<publisher>_<name>_<version>.app", derived
        from app.json.
 
    .PARAMETER ProjectFolder
        The AL project folder (contains app.json).
 
    .PARAMETER OutputFolder
        Where to write the compiled .app. Default: <ProjectFolder>/output.
 
    .PARAMETER Engine
        AlTool (default) or Container.
 
    .PARAMETER CompilerPath
        (AlTool) The AL compiler executable. Default 'alc'.
 
    .PARAMETER ContainerName
        (Container) The container to compile in.
 
    .PARAMETER ContainerCompilerPath
        (Container) The AL compiler path inside the container. Default 'alc.exe'.
 
    .PARAMETER PackageCachePath
        Symbol package folder(s). Default: <ProjectFolder>/.alpackages.
 
    .PARAMETER Analyzer
        Analyzer assembly paths (CodeCop/AppSourceCop/etc.).
 
    .PARAMETER RuleSet
        Ruleset file.
 
    .PARAMETER AssemblyProbingPath
        Additional .NET assembly probing paths.
 
    .PARAMETER LogLevel
        Compiler log level.
 
    .PARAMETER BuildBy
        Stamped into the compiled app's manifest as the tool that built it (alc /BuildBy).
        Default 'ALbuild'. Pass '' to omit.
 
    .PARAMETER BuildUrl
        URL of the build that produced the app, stamped into the manifest (alc /BuildUrl).
 
    .PARAMETER SourceRepositoryUrl
        Source repository URL stamped into the manifest (alc /SourceRepositoryUrl).
 
    .PARAMETER SourceCommit
        Source commit hash stamped into the manifest (alc /SourceCommit).
 
    .PARAMETER DockerExecutable
        (Container) The Docker executable to use (default 'docker').
 
    .EXAMPLE
        Invoke-BcCompiler -ProjectFolder ./app
 
    .OUTPUTS
        PSCustomObject with Success, OutputFile, ExitCode, Output.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $ProjectFolder,
        [string] $OutputFolder,
        [ValidateSet('AlTool', 'Container')] [string] $Engine = 'AlTool',
        [string] $CompilerPath = 'alc',
        [string] $ContainerName,
        [string] $ContainerCompilerPath = 'alc.exe',
        [string[]] $PackageCachePath = @(),
        [string[]] $Analyzer = @(),
        [string] $RuleSet,
        [string[]] $AssemblyProbingPath = @(),
        [ValidateSet('', 'Error', 'Warning', 'Verbose', 'Normal')] [string] $LogLevel = '',
        [string] $BuildBy = 'ALbuild',
        [string] $BuildUrl = '',
        [string] $SourceRepositoryUrl = '',
        [string] $SourceCommit = '',
        [string] $DockerExecutable = 'docker'
    )

    $appJsonPath = Join-Path $ProjectFolder 'app.json'
    if (-not (Test-Path -LiteralPath $appJsonPath)) {
        throw "No app.json found in project folder '$ProjectFolder'."
    }
    $appJson = Get-Content -LiteralPath $appJsonPath -Raw -Encoding UTF8 | ConvertFrom-Json

    if (-not $OutputFolder) { $OutputFolder = Join-Path $ProjectFolder 'output' }
    if (-not (Test-Path -LiteralPath $OutputFolder)) { New-Item -Path $OutputFolder -ItemType Directory -Force | Out-Null }

    if (-not $PackageCachePath -or $PackageCachePath.Count -eq 0) {
        $PackageCachePath = @(Join-Path $ProjectFolder '.alpackages')
    }

    # alc fails hard (AL1018) if any /packagecachepath points at a folder that does not exist. The
    # caller commonly includes the project's '.alpackages' unconditionally, but a dependency-free app
    # (or one whose dependencies were not resolved into that folder) never has it. Drop non-existent
    # entries so such an app still compiles against the remaining symbol folders (e.g. the BC
    # artifact's first-party symbols) instead of failing on the missing folder.
    $PackageCachePath = @($PackageCachePath | Where-Object { $_ -and (Test-Path -LiteralPath $_) })

    $outputFileName = "$($appJson.publisher)_$($appJson.name)_$($appJson.version).app"
    $outputFile = Join-Path $OutputFolder $outputFileName

    $compilerArgs = Get-BcCompilerArguments -ProjectFolder $ProjectFolder -OutputFile $outputFile `
        -PackageCachePath $PackageCachePath -Analyzer $Analyzer -RuleSet $RuleSet `
        -AssemblyProbingPath $AssemblyProbingPath -LogLevel $LogLevel `
        -BuildBy $BuildBy -BuildUrl $BuildUrl `
        -SourceRepositoryUrl $SourceRepositoryUrl -SourceCommit $SourceCommit

    Write-ALbuildLog "Compiling '$($appJson.name)' $($appJson.version) ($Engine engine)..."

    if ($Engine -eq 'AlTool') {
        # Locate the AL Tool CLI: the requested -CompilerPath ('alc'), then the 'al'/'altool' command
        # from the cross-platform AL Tool dotnet global tool
        # (dotnet tool install --global Microsoft.Dynamics.BusinessCentral.Development.Tools). The 'al'
        # tool wraps alc.exe behind a 'compile' subcommand; a raw alc takes the arguments directly.
        $compiler = Get-Command -Name $CompilerPath -ErrorAction SilentlyContinue | Select-Object -First 1
        # Fall back to the AL Tool only when relying on the default name; an explicit -CompilerPath that
        # is not found is an error (don't silently substitute a different compiler).
        if (-not $compiler -and -not $PSBoundParameters.ContainsKey('CompilerPath')) {
            $compiler = @('al', 'altool') | ForEach-Object { Get-Command -Name $_ -ErrorAction SilentlyContinue } | Select-Object -First 1
        }
        if (-not $compiler) {
            throw "The AL Tool was not found (tried '$CompilerPath' and the 'al' command). Install it with: dotnet tool install --global Microsoft.Dynamics.BusinessCentral.Development.Tools (or pass -CompilerPath)."
        }

        $compilerBase = [System.IO.Path]::GetFileNameWithoutExtension($compiler.Source)
        $invokeArgs = if ($compilerBase -in @('al', 'altool')) { @('compile') + $compilerArgs } else { $compilerArgs }
        $result = Invoke-ALbuildProcess -FilePath $compiler.Source -Arguments $invokeArgs -PassThru
    }
    else {
        if (-not $ContainerName) { throw "-ContainerName is required when -Engine is Container." }

        # Compile INSIDE the container. Two facts drive the design: the AL compiler is not on PATH there
        # (it ships as the AL Language extension, ALLanguage.vsix), and the container cannot see host paths.
        # So stage the project sources + resolved symbol packages into the container's shared C:\run\my mount,
        # compile against CONTAINER paths (adding the container's own Microsoft symbol packages), and copy the
        # produced .app back to the requested host location.
        $__share = Get-BcContainerHostShare -Name $ContainerName
        # Keep this leaf short: the staged symbol .app files (e.g. Microsoft first-party apps) have long
        # names and the share path is already deep, so a full GUID risks exceeding the Windows MAX_PATH.
        $__work = 'alc-' + [guid]::NewGuid().ToString('N').Substring(0, 8)
        $__hostWork = Join-Path $__share $__work
        $__hostProj = Join-Path $__hostWork 'project'
        $__hostSym = Join-Path $__hostWork 'symbols'
        $__hostOut = Join-Path $__hostWork 'out'
        foreach ($__d in @($__hostProj, $__hostSym, $__hostOut)) { New-Item -ItemType Directory -Force -Path $__d | Out-Null }
        try {
            # Stage the project sources (without its committed .alpackages / output) and the symbol packages.
            Get-ChildItem -LiteralPath $ProjectFolder -Force |
                Where-Object { $_.Name -notin @('.alpackages', 'output', '.output', '.git', '.snapshots') } |
                ForEach-Object { Copy-Item -LiteralPath $_.FullName -Destination $__hostProj -Recurse -Force }
            foreach ($__pcp in $PackageCachePath) {
                if (Test-Path -LiteralPath $__pcp) {
                    Get-ChildItem -LiteralPath $__pcp -Filter '*.app' -File -ErrorAction SilentlyContinue |
                        ForEach-Object { Copy-Item -LiteralPath $_.FullName -Destination $__hostSym -Force }
                }
            }

            # The container's own C:\dl artifact cache can be missing base first-party symbol packages (some
            # agents' caches lack the 'Application' umbrella app), so an in-container-only symbol search fails
            # AL1022. Resolve the COMPLETE symbols on the host from the container's OWN artifact and stage them
            # - the host Get-BcArtifact cache is always complete (the AL Tool compile relies on it). Centralised
            # here so it also covers the deprecated V1 'Compile' task, which passes no symbol paths of its own.
            try {
                $__envJson = & $DockerExecutable inspect $ContainerName --format '{{json .Config.Env}}' 2>$null | ConvertFrom-Json
                $__artUrl = @($__envJson | Where-Object { $_ -like 'artifactUrl=*' }) | Select-Object -First 1
                if ($__artUrl) {
                    $__art = Get-BcArtifact -ArtifactUrl ($__artUrl -replace '^artifactUrl=', '')
                    $__hostSymFolders = New-Object System.Collections.Generic.List[string]
                    $__ext = Join-Path $__art.ApplicationPath 'Extensions'
                    if (Test-Path -LiteralPath $__ext) { $__hostSymFolders.Add($__ext) }
                    Get-ChildItem -LiteralPath $__art.ApplicationPath -Directory -ErrorAction SilentlyContinue |
                        Where-Object { $_.Name -match '^Applications\..+' } | ForEach-Object { $__hostSymFolders.Add($_.FullName) }
                    if ($__art.PlatformPath) {
                        $__sa = Get-ChildItem -LiteralPath $__art.PlatformPath -Recurse -Filter 'System.app' -File -ErrorAction SilentlyContinue | Select-Object -First 1
                        if ($__sa) { $__hostSymFolders.Add($__sa.Directory.FullName) }
                    }
                    foreach ($__f in ($__hostSymFolders | Select-Object -Unique)) {
                        Get-ChildItem -LiteralPath $__f -Filter '*.app' -File -ErrorAction SilentlyContinue |
                            ForEach-Object { Copy-Item -LiteralPath $_.FullName -Destination $__hostSym -Force }
                    }
                }
            }
            catch { Write-Verbose "Could not stage host artifact symbols for the container compile: $($_.Exception.Message)" }

            $__cWork = 'C:\run\my\' + $__work
            $__cArgs = Get-BcCompilerArguments -ProjectFolder "$__cWork\project" -OutputFile "$__cWork\out\$outputFileName" `
                -PackageCachePath @("$__cWork\symbols") -Analyzer $Analyzer -RuleSet $RuleSet `
                -AssemblyProbingPath $AssemblyProbingPath -LogLevel $LogLevel -BuildBy $BuildBy -BuildUrl $BuildUrl `
                -SourceRepositoryUrl $SourceRepositoryUrl -SourceCommit $SourceCommit

            $output = Invoke-BcContainerCommand -ContainerName $ContainerName -DockerExecutable $DockerExecutable -Variables @{
                CompilerArgs = $__cArgs
                Compiler     = $ContainerCompilerPath
            } -ScriptBlock {
                # Prefer an explicit in-container compiler path when given and resolvable; otherwise obtain
                # alc.exe from the AL Language extension (a .vsix = zip) that ships in the container.
                $__alcPath = $null
                if ($Compiler -and $Compiler -ne 'alc.exe') {
                    $__c = Get-Command $Compiler -ErrorAction SilentlyContinue
                    if ($__c) { $__alcPath = $__c.Source } elseif (Test-Path -LiteralPath $Compiler) { $__alcPath = $Compiler }
                }
                $__alcDir = $null
                if (-not $__alcPath) {
                    $__vsix = @('C:\Run\ALLanguage.vsix', 'C:\Program Files\Microsoft Dynamics NAV\*\AL Development Environment\ALLanguage.vsix') |
                        ForEach-Object { Get-ChildItem -Path $_ -File -ErrorAction SilentlyContinue } | Select-Object -First 1
                    if (-not $__vsix) { throw 'AL Language extension (ALLanguage.vsix) not found in the container; cannot compile with the Container engine.' }
                    $__alcDir = Join-Path $env:TEMP ('alc-' + [guid]::NewGuid().ToString('N'))
                    Add-Type -AssemblyName System.IO.Compression.FileSystem
                    [System.IO.Compression.ZipFile]::ExtractToDirectory($__vsix.FullName, $__alcDir)
                    $__found = Get-ChildItem -Path $__alcDir -Recurse -Filter 'alc.exe' -File -ErrorAction SilentlyContinue | Select-Object -First 1
                    if (-not $__found) { Remove-Item -LiteralPath $__alcDir -Recurse -Force -ErrorAction SilentlyContinue; throw 'alc.exe was not found inside the AL Language extension.' }
                    $__alcPath = $__found.FullName
                }
                # First-party Microsoft symbols (Application / Base Application / System Application /
                # Business Foundation, ...) ship as compiled .app files with the BC artifact, but their exact
                # in-container location varies: the artifact mounted at C:\dl under <type>\<ver>\<country>\
                # Extensions or Applications.<country>, and/or the container's own C:\Applications*. Collect
                # every candidate folder that actually holds .app packages and hand them all to alc, rather
                # than bet on one layout. (Copying the ~100 long-named apps into the bind-mount share instead
                # would risk the Windows MAX_PATH limit, and C:\Applications alone is app SOURCE, not symbols.)
                #
                # Crucially, scope the C:\dl search to the container's OWN version. A shared artifact cache can
                # hold several BC versions at once (self-hosted agents reuse one C:\bcartifacts.cache), and
                # feeding this alc a newer version's .app files makes it reject ALL of them (AL1023 'not valid').
                # The container has exactly one platform installed, so its folder under 'Microsoft Dynamics NAV'
                # (e.g. '240' = v24) gives the target major to match C:\dl\<type>\<ver> against.
                $__navDirs = @(Get-ChildItem 'C:\Program Files\Microsoft Dynamics NAV' -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -match '^\d+$' })
                $__navName = ($__navDirs | Sort-Object { [int]$_.Name } -Descending | Select-Object -First 1).Name
                $__verPrefix = if ($__navName) { "$([int]$__navName.Substring(0, $__navName.Length - 1))." } else { '' }  # '240' -> '24.'
                $__cand = New-Object System.Collections.Generic.List[string]
                $__artRoots = @(Get-ChildItem 'C:\dl\*\*' -Directory -ErrorAction SilentlyContinue)   # C:\dl\<type>\<ver>
                if ($__verPrefix) { $__artRoots = @($__artRoots | Where-Object { $_.Name -like "$__verPrefix*" }) }
                foreach ($__ar in $__artRoots) {
                    Get-ChildItem $__ar.FullName -Recurse -Directory -ErrorAction SilentlyContinue |
                        Where-Object { $_.Name -eq 'Extensions' -or $_.Name -match '^Applications(\..+)?$' } |
                        ForEach-Object { $__cand.Add($_.FullName) }
                }
                # The container's own application folders (some images keep compiled first-party apps here).
                Get-ChildItem 'C:\' -Directory -ErrorAction SilentlyContinue |
                    Where-Object { $_.Name -match '^Applications(\..+)?$' } |
                    ForEach-Object { $__cand.Add($_.FullName) }
                # The platform 'System' symbol package ships as System.app under the AL Development Environment.
                $__sysApp = Get-ChildItem 'C:\Program Files\Microsoft Dynamics NAV' -Recurse -Filter 'System.app' -File -ErrorAction SilentlyContinue | Select-Object -First 1
                if (-not $__sysApp) { $__sysApp = Get-ChildItem 'C:\dl\*\*\platform\ModernDev' -Recurse -Filter 'System.app' -File -ErrorAction SilentlyContinue | Select-Object -First 1 }
                if ($__sysApp) { $__cand.Add($__sysApp.DirectoryName) }
                # Keep only folders that actually contain .app packages (drops app-source folders), de-duplicated.
                $__symDirs = @($__cand | Select-Object -Unique | Where-Object { $_ -and (Get-ChildItem -LiteralPath $_ -Filter '*.app' -File -ErrorAction SilentlyContinue | Select-Object -First 1) })
                $__appLoc = @($__symDirs | Where-Object { Get-ChildItem -LiteralPath $_ -Filter 'Microsoft_Application_*.app' -File -ErrorAction SilentlyContinue })
                # Some images/caches do not keep the base first-party symbol packages (Application / Base
                # Application / System Application) in the artifact's Extensions folder. If the scoped search
                # did not find 'Application', widen it across the likely container roots for those .app files
                # of the TARGET version (the version filter keeps a newer cached version's apps out, which this
                # alc would reject as AL1023), and add wherever they live.
                $__widened = @()
                if (-not $__appLoc -and $__verPrefix) {
                    $__vEsc = [regex]::Escape($__verPrefix)
                    foreach ($__pr in @('C:\Applications', 'C:\dl', 'C:\build', 'C:\run', 'C:\bcartifacts.cache', 'C:\Program Files\Microsoft Dynamics NAV', 'C:\ProgramData\Microsoft\Microsoft Dynamics NAV')) {
                        if (-not (Test-Path -LiteralPath $__pr)) { continue }
                        Get-ChildItem -LiteralPath $__pr -Recurse -File -Filter '*.app' -ErrorAction SilentlyContinue |
                            Where-Object { ($_.Name -like 'Microsoft_Application_*' -or $_.Name -like 'Microsoft_Base Application_*' -or $_.Name -like 'Microsoft_System Application_*') -and $_.Name -match "_$__vEsc" } |
                            ForEach-Object { $__widened += $_.DirectoryName }
                    }
                    $__widened = @($__widened | Select-Object -Unique)
                    foreach ($__w in $__widened) { if ($__symDirs -notcontains $__w) { $__symDirs += $__w } }
                    $__appLoc = @($__symDirs | Where-Object { Get-ChildItem -LiteralPath $_ -Filter 'Microsoft_Application_*.app' -File -ErrorAction SilentlyContinue })
                }
                $__diag = "Target major '$__verPrefix'; symbol folders ($($__symDirs.Count)): $($__symDirs -join '; ')`r`n'Application' found in: $(if ($__appLoc) { $__appLoc -join '; ' } else { '<none>' }); widened: $(if ($__widened) { $__widened -join '; ' } else { '<n/a>' })`r`n"
                $__symArgs = @($__symDirs | ForEach-Object { "/packagecachepath:$_" })
                $__alcOut = $__diag + (& $__alcPath @CompilerArgs @__symArgs 2>&1 | Out-String)
                $__code = $LASTEXITCODE
                if ($__alcDir) { Remove-Item -LiteralPath $__alcDir -Recurse -Force -ErrorAction SilentlyContinue }
                # Reset so a non-zero alc exit (a compile error we report ourselves) is not mistaken for a
                # failed container command.
                $global:LASTEXITCODE = 0
                [PSCustomObject]@{ ExitCode = $__code; Output = $__alcOut } | ConvertTo-Json -Compress -Depth 3
            }

            $__parsed = $null
            try { $__parsed = $output | ConvertFrom-Json }
            catch { Write-Verbose "Could not parse container compiler result: $($_.Exception.Message)" }
            $exit = if ($__parsed) { [int] $__parsed.ExitCode } else { 1 }
            $__alcMessages = if ($__parsed) { $__parsed.Output } else { $output }

            # Bring the produced .app back to the requested host output location.
            New-Item -ItemType Directory -Force -Path (Split-Path -Path $outputFile -Parent) | Out-Null
            $__produced = Join-Path $__hostOut $outputFileName
            if (Test-Path -LiteralPath $__produced) { Copy-Item -LiteralPath $__produced -Destination $outputFile -Force }
            $result = [PSCustomObject]@{ Success = ($exit -eq 0); ExitCode = $exit; StdOut = $__alcMessages; StdErr = '' }
        }
        finally {
            Remove-Item -LiteralPath $__hostWork -Recurse -Force -ErrorAction SilentlyContinue
        }
    }

    $success = $result.Success -and (Test-Path -LiteralPath $outputFile)
    if ($result.StdOut) { Write-ALbuildLog $result.StdOut.TrimEnd() }

    if (-not $success) {
        Write-ALbuildLog -Level Error "Compilation of '$($appJson.name)' failed (exit $($result.ExitCode))."
    }
    else {
        Write-ALbuildLog -Level Success "Compiled '$outputFile'."
    }

    return [PSCustomObject]@{
        Success    = $success
        OutputFile = if ($success) { $outputFile } else { $null }
        ExitCode   = $result.ExitCode
        Output     = $result.StdOut
    }
}