Modules/businessdev.ALbuild.RuntimePackages/Private/Get-BcCompilerServiceTier.ps1

function Get-BcCompilerServiceTier {
    <#
    .SYNOPSIS
        Locates the Business Central service tier that carries Microsoft's AL compiler.
 
    .DESCRIPTION
        Every BC artifact ships the complete AL compiler (Microsoft.Dynamics.Nav.CodeAnalysis.dll) in
        its platform part, next to the runtime engine. Driving that compiler in-process is what lets a
        runtime package be built WITHOUT a container - see New-BcRuntimePackageFromSource.
 
        Resolution order, most specific first:
          1. -Path an explicit service tier folder.
          2. -PlatformPath the platform part of a Get-BcArtifact result (what the factory already has).
          3. the artifact caches on this machine, optionally filtered by -Version.
 
        Two properties of the result decide whether the compiler can actually be used here:
 
          TargetFramework read from the service's runtimeconfig.json. The BC major dictates it -
                           BC 29 targets net10, BC 28 and 26 target net8.
          Hostable whether THIS PowerShell can load it. A net10 assembly cannot be loaded by a
                           net8 process, so pwsh 7.4 cannot drive BC 29's compiler however much the
                           artifact is present. Reported rather than discovered as a cryptic load error.
 
    .PARAMETER Path
        An explicit service tier folder.
 
    .PARAMETER PlatformPath
        The PlatformPath of a Get-BcArtifact result.
 
    .PARAMETER Version
        Version or prefix to match (e.g. '28' or '28.4') when searching the caches.
 
    .OUTPUTS
        PSCustomObject with Directory, Version, TargetFramework, Hostable, Source.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [string] $Path,
        [string] $PlatformPath,
        [string] $Version
    )

    $compilerAssembly = 'Microsoft.Dynamics.Nav.CodeAnalysis.dll'

    # A service tier carries the compiler AND the engine. The Admin, Management and WebPublish
    # subfolders of an artifact carry the same assemblies and would otherwise pass - a machine then
    # reports twice as many "tiers" as it has, and loading from Admin fails later with nothing pointing
    # at the cause. Excluded by name, because the file check alone does not tell them apart.
    function Test-Tier([string] $candidate) {
        if ([string]::IsNullOrWhiteSpace($candidate)) { return $false }
        if (-not (Test-Path -LiteralPath $candidate)) { return $false }
        if ((Split-Path -Leaf $candidate) -in @('Admin', 'Management', 'WebPublish')) { return $false }
        foreach ($required in $compilerAssembly, 'Microsoft.Dynamics.Nav.Ncl.dll', 'Microsoft.Dynamics.Nav.Types.dll') {
            if (-not (Test-Path -LiteralPath (Join-Path $candidate $required))) { return $false }
        }
        return $true
    }

    # The install folder inside an artifact is NOT one fixed path: sandbox artifacts use
    # 'ServiceTier\PFiles64\...', on-premises artifacts use 'ServiceTier\program files\...'. Hardcoding
    # one of them silently finds nothing for the other - and the runtime factory builds against the
    # on-premises artifacts.
    function Find-TierIn([string] $platformRoot) {
        $result = [System.Collections.Generic.List[string]]::new()
        $serviceTier = Join-Path $platformRoot 'ServiceTier'
        if (-not (Test-Path -LiteralPath $serviceTier)) { return , $result }
        foreach ($programFiles in (Get-ChildItem -LiteralPath $serviceTier -Directory -ErrorAction SilentlyContinue)) {
            $base = Join-Path $programFiles.FullName 'Microsoft Dynamics NAV'
            if (-not (Test-Path -LiteralPath $base)) { continue }
            foreach ($major in (Get-ChildItem -LiteralPath $base -Directory -ErrorAction SilentlyContinue)) {
                $service = Join-Path $major.FullName 'Service'
                if (Test-Tier $service) { $result.Add($service) }
            }
        }
        return , $result
    }

    function New-TierInfo([string] $directory, [string] $source) {
        $bcVersion = $null
        $ncl = Join-Path $directory 'Microsoft.Dynamics.Nav.Ncl.dll'
        try { $bcVersion = [System.Reflection.AssemblyName]::GetAssemblyName($ncl).Version } catch { }

        # The service's own runtimeconfig names the framework it was built for. Guessing from the BC
        # major would be a rule that quietly goes wrong the next time Microsoft moves.
        $tfm = $null
        $runtimeConfig = Join-Path $directory 'Microsoft.Dynamics.Nav.Server.runtimeconfig.json'
        if (Test-Path -LiteralPath $runtimeConfig) {
            try { $tfm = (Get-Content -LiteralPath $runtimeConfig -Raw | ConvertFrom-Json).runtimeOptions.tfm } catch { }
        }

        $hostMajor = [System.Environment]::Version.Major
        $tierMajor = if ($tfm -and $tfm -match '^net(\d+)\.') { [int]$Matches[1] } else { $null }

        [PSCustomObject]@{
            Directory       = $directory
            Version         = $bcVersion
            TargetFramework = $tfm
            # A newer assembly cannot load in an older runtime; the other direction is fine.
            Hostable        = ($null -eq $tierMajor) -or ($tierMajor -le $hostMajor)
            HostFramework   = "net$hostMajor.0"
            Source          = $source
        }
    }

    if ($PSBoundParameters.ContainsKey('Path') -and $Path) {
        if (-not (Test-Tier $Path)) {
            throw "'$Path' is not a Business Central service tier: it must contain $compilerAssembly next to Microsoft.Dynamics.Nav.Ncl.dll and Microsoft.Dynamics.Nav.Types.dll."
        }
        return New-TierInfo $Path 'explicit'
    }

    $roots = [System.Collections.Generic.List[string]]::new()
    if ($PlatformPath) { $roots.Add($PlatformPath) }

    if ($roots.Count -eq 0) {
        if ($env:ALBUILD_BC_SERVICE_TIER -and (Test-Tier $env:ALBUILD_BC_SERVICE_TIER)) {
            return New-TierInfo $env:ALBUILD_BC_SERVICE_TIER 'ALBUILD_BC_SERVICE_TIER'
        }
        # The artifact caches only exist on Windows - a BC artifact is a Windows install tree. Scanning
        # for them elsewhere is not merely pointless, it THROWS: Join-Path with a drive-qualified parent
        # ('C:') raises DriveNotFoundException on Linux, which took the module's Linux test job down at
        # discovery time (build 27411). RuntimeInformation rather than $IsWindows, because $IsWindows
        # does not exist in Windows PowerShell 5.1 and reading it under StrictMode throws there.
        $onWindows = [System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform(
            [System.Runtime.InteropServices.OSPlatform]::Windows)
        $caches = @()
        if ($onWindows) {
            $systemDrive = if ($env:SystemDrive) { $env:SystemDrive } else { 'C:' }
            $caches = @(
                'C:\bcartifacts.cache',
                ($systemDrive + '\alb\artifacts'),
                (Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'ALbuild\artifacts'))
        }

        # The same caches ALbuild already fills. A machine that has ever built a BC app has one.
        foreach ($cache in $caches) {
            if (-not (Test-Path -LiteralPath $cache)) { continue }
            # <cache>\<type>\<version>\platform - bounded, not a recursive walk of a cache that is
            # tens of gigabytes.
            foreach ($platform in (Get-ChildItem -LiteralPath $cache -Directory -ErrorAction SilentlyContinue |
                    ForEach-Object { Get-ChildItem -LiteralPath $_.FullName -Directory -ErrorAction SilentlyContinue } |
                    ForEach-Object { Join-Path $_.FullName 'platform' } |
                    Where-Object { Test-Path -LiteralPath $_ })) {
                $roots.Add($platform)
            }
        }
    }

    $found = [System.Collections.Generic.List[object]]::new()
    foreach ($root in $roots) {
        # An explicitly passed platform path may already BE the service folder.
        if (Test-Tier $root) { $found.Add((New-TierInfo $root 'platform')); continue }
        foreach ($service in (Find-TierIn $root)) { $found.Add((New-TierInfo $service 'artifact')) }
    }

    if ($Version) {
        $found = [System.Collections.Generic.List[object]]@($found | Where-Object {
                $_.Version -and ("$($_.Version)" -eq $Version -or "$($_.Version)".StartsWith("$Version."))
            })
    }

    if ($found.Count -eq 0) { return $null }

    # Hostable first, then newest: a tier this process cannot load is never the better answer.
    return ($found | Sort-Object -Property @{Expression = { -not $_.Hostable } }, @{Expression = { $_.Version }; Descending = $true } |
        Select-Object -First 1)
}