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'). .PARAMETER NoDiagnosticOutput Do not print the compiler's diagnostic lines (warnings/errors/info) in the logged output; the caller renders them from the returned Diagnostics instead (e.g. as DevOps annotations). Context lines are still logged. Without this switch the full compiler output is logged. .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', [switch] $NoDiagnosticOutput ) $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 # Analyzers are resolved to DLLs per engine below (their location differs: the host AL Tool ships # them in its install tree; the container in the AL Language extension), so they are appended after # this base argument list rather than passed here. $compilerArgs = Get-BcCompilerArguments -ProjectFolder $ProjectFolder -OutputFile $outputFile ` -PackageCachePath $PackageCachePath -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)." } # Resolve analyzer tokens/names to DLLs shipped with the AL Tool. Prefer its own package subtree # (the dotnet global tool keeps its files under .store/<packageId>) to avoid scanning unrelated # tools, falling back to the whole global-tools folder. Unresolved entries are skipped with a # warning rather than failing the compile. if ($Analyzer.Count -gt 0) { $toolsDir = Split-Path -Parent $compiler.Source $alStore = Join-Path $toolsDir '.store/microsoft.dynamics.businesscentral.development.tools' $analyzerRoot = if (Test-Path -LiteralPath $alStore) { $alStore } else { $toolsDir } $hostAnalyzers = Resolve-BcAnalyzer -Analyzer $Analyzer -SearchRoot $analyzerRoot -UnresolvedVariable 'unresolvedAnalyzers' if ($unresolvedAnalyzers) { Write-ALbuildLog -Level Warning "Analyzer(s) not found near the AL Tool and skipped: $($unresolvedAnalyzers -join ', ')." } if ($hostAnalyzers) { $compilerArgs = @($compilerArgs) + @($hostAnalyzers | ForEach-Object { "/analyzer:$_" }) Write-ALbuildLog "Analyzers: $(($hostAnalyzers | ForEach-Object { Split-Path $_ -Leaf }) -join ', ')." } } $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 # A ruleset is a host path; stage it into the shared mount and reference the container path. $__cRuleSet = '' if ($RuleSet -and (Test-Path -LiteralPath $RuleSet)) { $__hostRule = Join-Path $__hostWork 'ruleset' New-Item -ItemType Directory -Force -Path $__hostRule | Out-Null Copy-Item -LiteralPath $RuleSet -Destination $__hostRule -Force $__cRuleSet = "$__cWork\ruleset\$(Split-Path -Path $RuleSet -Leaf)" } # Analyzers are resolved INSIDE the container (their DLLs ship with the container's AL Language # extension, at container paths), so they are not baked into these args. $__cArgs = Get-BcCompilerArguments -ProjectFolder "$__cWork\project" -OutputFile "$__cWork\out\$outputFileName" ` -PackageCachePath @("$__cWork\symbols") -RuleSet $__cRuleSet ` -AssemblyProbingPath $AssemblyProbingPath -LogLevel $LogLevel -BuildBy $BuildBy -BuildUrl $BuildUrl ` -SourceRepositoryUrl $SourceRepositoryUrl -SourceCommit $SourceCommit # The first-party symbol-folder selection rule (Get-BcContainerSymbolFolder) runs inside the # container, which has no access to the module, so its source is passed in and re-defined there. # The analyzer resolver (Resolve-BcAnalyzer) is injected the same way to map analyzer tokens to # the container's DLLs. $__symFuncSource = "function Get-BcContainerSymbolFolder {`n$(${function:Get-BcContainerSymbolFolder})`n}" $__analyzerFuncSource = "function Resolve-BcAnalyzer {`n$(${function:Resolve-BcAnalyzer})`n}" $output = Invoke-BcContainerCommand -ContainerName $ContainerName -DockerExecutable $DockerExecutable -Variables @{ CompilerArgs = $__cArgs Compiler = $ContainerCompilerPath SymbolFuncSource = $__symFuncSource AnalyzerFuncSource = $__analyzerFuncSource AnalyzerTokens = $Analyzer } -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, the test toolkit, ...) ship as compiled .app files with the BC artifact, but their # in-container layout varies by artifact kind (sandbox: top-level Extensions/Applications.<country>; # on-prem: nested, unversioned <country>\Applications\<App>\Source). The selection rule lives in # Get-BcContainerSymbolFolder so it is unit-tested against fake trees; its source is injected here # (the container has no access to the module) and called against the real container paths. . ([ScriptBlock]::Create($SymbolFuncSource)) $__sel = Get-BcContainerSymbolFolder $__symDirs = @($__sel.SymbolFolders) $__diag = "Target major '$($__sel.VersionPrefix)'; symbol folders ($($__symDirs.Count)): $($__symDirs -join '; ')`r`n'Application' found in: $(if ($__sel.ApplicationFound) { $__sel.ApplicationFound -join '; ' } else { '<none>' }); widened: $(if ($__sel.Widened) { $__sel.Widened -join '; ' } else { '<n/a>' })`r`n" $__symArgs = @($__symDirs | ForEach-Object { "/packagecachepath:$_" }) # Resolve analyzer tokens/names to the container's AL Language extension DLLs (extracted # under $__alcDir when the vsix was unpacked, otherwise beside the supplied compiler). . ([ScriptBlock]::Create($AnalyzerFuncSource)) $__anzRoot = if ($__alcDir) { $__alcDir } else { Split-Path -Parent $__alcPath } $__anz = @(Resolve-BcAnalyzer -Analyzer $AnalyzerTokens -SearchRoot $__anzRoot) $__anzArgs = @($__anz | ForEach-Object { "/analyzer:$_" }) if ($__anz) { $__diag += "Analyzers: $(($__anz | ForEach-Object { Split-Path $_ -Leaf }) -join '; ')`r`n" } $__alcOut = $__diag + (& $__alcPath @CompilerArgs @__symArgs @__anzArgs 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) # Log the compiler output. With -NoDiagnosticOutput the caller renders the diagnostics itself (e.g. # the Compile AL App task, as DevOps annotations with clean paths), so drop the diagnostic lines here # to avoid printing each one twice; only the context lines (compilation started/ended, 'Compiled ...') # are kept. Without the switch (a direct call) the full output is logged as before. if ($result.StdOut) { $text = ([string] $result.StdOut).TrimEnd() if ($NoDiagnosticOutput) { $diagLine = [regex] '^(?:.+\(\d+,\d+\):\s*)?(warning|error|info)\s+[A-Za-z]{2}\d+:' $kept = @((([string] $result.StdOut) -split "`r?`n") | Where-Object { $_ -notmatch $diagLine }) $text = ($kept -join [Environment]::NewLine).TrimEnd() } if ($text) { Write-ALbuildLog $text } } if (-not $success) { Write-ALbuildLog -Level Error "Compilation of '$($appJson.name)' failed (exit $($result.ExitCode))." } else { Write-ALbuildLog -Level Success "Compiled '$outputFile'." } # Parse the compiler output into structured diagnostics (warnings/errors with file/line) so the # caller (e.g. the Compile AL App task) can surface them as DevOps logissues. $diagnostics = @(ConvertFrom-BcCompilerOutput -Output ([string] $result.StdOut)) return [PSCustomObject]@{ Success = $success OutputFile = if ($success) { $outputFile } else { $null } ExitCode = $result.ExitCode Output = $result.StdOut Diagnostics = $diagnostics } } |