Modules/businessdev.ALbuild.Apps/Private/Resolve-BcAlToolVersion.ps1

function Resolve-BcAlToolVersion {
    <#
    .SYNOPSIS
        Selects the AL Tool (dotnet tool) version that can compile a given AL runtime.
 
    .DESCRIPTION
        The AL Tool package is versioned so that its MAJOR equals the AL runtime version it supports
        (17.x compiles runtime 17 = BC28; 18.x compiles runtime 18 = BC29). A compiler older than the
        app's declared runtime fails the compile outright with AL1043 ("The runtime version 'x' is not
        supported by the AL compiler"), so the compiler has to be chosen from the target runtime rather
        than left at "whatever latest stable happens to be".
 
        A BC major that has not shipped yet (NextMajor) only has PRERELEASE ('-beta') AL Tool versions
        on the feed, which is why 'Auto' falls back to a prerelease when the required major has no
        stable release: it makes NextMajor builds work today and silently returns to the stable
        compiler the moment that major goes GA - without a pipeline change.
 
        Versions are read from the NuGet flat-container index (a plain JSON GET, no NuGet client).
 
    .PARAMETER PackageId
        The dotnet tool package id. Default 'Microsoft.Dynamics.BusinessCentral.Development.Tools'.
 
    .PARAMETER RequiredMajor
        The AL runtime major that must be supported (= the AL Tool major). 0 = no constraint, which
        selects the newest version allowed by -Prerelease.
 
    .PARAMETER Prerelease
        Auto (default) - prefer a stable release for the required major, fall back to a prerelease only
        when that major has none. Always - take the newest version even if it is a prerelease.
        Never - stable only; returns nothing when the required major has no stable release.
 
    .PARAMETER IndexUrl
        Override the version index URL (defaults to the nuget.org flat-container index for -PackageId).
 
    .PARAMETER TimeoutSec
        HTTP timeout for the index query.
 
    .EXAMPLE
        Resolve-BcAlToolVersion -RequiredMajor 18
        # -> 18.0.39.10160-beta while BC29 is prerelease; the newest stable 18.x once it ships.
 
    .OUTPUTS
        PSCustomObject: Version, IsPrerelease, RequiredMajor, Available - or $null when nothing matches.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [string] $PackageId = 'Microsoft.Dynamics.BusinessCentral.Development.Tools',
        [int] $RequiredMajor = 0,
        [ValidateSet('Auto', 'Always', 'Never')] [string] $Prerelease = 'Auto',
        [string] $IndexUrl,
        [int] $TimeoutSec = 60
    )

    if (-not $IndexUrl) {
        # The flat-container ("package base address") index lists every published version, prerelease
        # included; the id must be lower-cased for that endpoint.
        $IndexUrl = "https://api.nuget.org/v3-flatcontainer/$($PackageId.ToLowerInvariant())/index.json"
    }

    # Windows PowerShell 5.1 still negotiates TLS 1.0 by default on some build agents, which nuget.org
    # refuses; opt into TLS 1.2 for this call (additive, so nothing already enabled is turned off).
    try {
        if ([Net.ServicePointManager]::SecurityProtocol -notmatch 'Tls12') {
            [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
        }
    }
    catch { Write-ALbuildLog -Level Warning "Could not enable TLS 1.2 for the AL Tool version query: $($_.Exception.Message)" }

    $raw = @()
    try {
        $response = Invoke-RestMethod -Uri $IndexUrl -Method Get -TimeoutSec $TimeoutSec
        if ($response -and ($response.PSObject.Properties.Name -contains 'versions')) { $raw = @($response.versions) }
    }
    catch {
        throw "Could not query the available AL Tool versions from '$IndexUrl': $($_.Exception.Message)"
    }
    if ($raw.Count -eq 0) { throw "The AL Tool version index '$IndexUrl' returned no versions." }

    # Split '<numeric>-<label>' (e.g. '18.0.39.10160-beta'); a version whose numeric part does not parse
    # is skipped rather than failing the whole selection.
    $parsed = foreach ($entry in $raw) {
        if (-not $entry) { continue }
        $text = [string] $entry
        $parts = $text -split '-', 2
        $numericText = $parts[0]
        $label = if ($parts.Count -gt 1) { $parts[1] } else { '' }
        $numeric = $null
        if (-not [version]::TryParse($numericText, [ref] $numeric)) { continue }
        [PSCustomObject]@{
            Version      = $text
            Numeric      = $numeric
            IsPrerelease = [bool] $label
        }
    }
    $parsed = @($parsed)
    if ($parsed.Count -eq 0) { throw "None of the $($raw.Count) AL Tool version(s) from '$IndexUrl' could be parsed." }

    $candidates = @($parsed)
    if ($RequiredMajor -gt 0) { $candidates = @($parsed | Where-Object { $_.Numeric.Major -eq $RequiredMajor }) }

    # Newest first; a stable release outranks a prerelease carrying the same numeric version.
    $ordered = @($candidates | Sort-Object -Property @{ Expression = 'Numeric'; Descending = $true }, @{ Expression = 'IsPrerelease'; Descending = $false })
    $stable = @($ordered | Where-Object { -not $_.IsPrerelease })

    $selected = switch ($Prerelease) {
        'Never' { if ($stable.Count -gt 0) { $stable[0] } else { $null } }
        'Always' { if ($ordered.Count -gt 0) { $ordered[0] } else { $null } }
        default {
            if ($stable.Count -gt 0) { $stable[0] }
            elseif ($ordered.Count -gt 0) { $ordered[0] }
            else { $null }
        }
    }

    if (-not $selected) { return $null }

    if ($selected.IsPrerelease) {
        Write-ALbuildLog -Level Warning ("AL Tool $($selected.Version) is a PRERELEASE" +
            $(if ($RequiredMajor -gt 0) { " - AL runtime $RequiredMajor has no stable compiler on the feed yet (expected while that BC major is in preview)" } else { '' }) + '.')
    }

    return [PSCustomObject]@{
        Version       = $selected.Version
        IsPrerelease  = $selected.IsPrerelease
        RequiredMajor = $RequiredMajor
        Available     = $candidates.Count
    }
}