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

function Install-BcAlTool {
    <#
    .SYNOPSIS
        Ensures the cross-platform AL Tool CLI is installed (as a .NET global tool) and on PATH.
 
    .DESCRIPTION
        Installs 'Microsoft.Dynamics.BusinessCentral.Development.Tools' as a dotnet global tool so the
        'al' command (which wraps the AL compiler, alc) is available to Invoke-BcCompiler's AlTool
        engine on a build agent. Idempotent: when the AL Tool is already on PATH it does nothing
        (unless -Force). Requires the .NET SDK ('dotnet'). After installing it adds the global-tools
        folder (~/.dotnet/tools) to PATH for the current session so the freshly installed 'al' is
        immediately resolvable.
 
    .PARAMETER PackageId
        The dotnet tool package id. Default 'Microsoft.Dynamics.BusinessCentral.Development.Tools'.
 
    .PARAMETER Version
        Optional specific version to pin; otherwise the latest is installed.
 
    .PARAMETER Force
        Reinstall/update even when the AL Tool is already available.
 
    .PARAMETER Source
        NuGet package source to install the AL Tool from, passed as --add-source. Defaults to
        nuget.org so the install works even when the agent has no NuGet sources configured (a fresh
        agent often has none, which fails with "No NuGet sources are defined or enabled"). Set to ''
        to rely solely on the agent's nuget.config, or to an internal feed URL for offline agents.
 
    .PARAMETER DotNetExecutable
        The .NET CLI executable. Default 'dotnet'.
 
    .EXAMPLE
        Install-BcAlTool
 
    .OUTPUTS
        PSCustomObject: Installed (bool), Path, Version.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([PSCustomObject])]
    param(
        [string] $PackageId = 'Microsoft.Dynamics.BusinessCentral.Development.Tools',
        [string] $Version,
        [switch] $Force,
        [string] $Source = 'https://api.nuget.org/v3/index.json',
        [string] $DotNetExecutable = 'dotnet'
    )

    # dotnet installs --global tools under <home>\.dotnet\tools. That folder is not always on PATH on
    # a fresh agent right after install, and some agents relocate the CLI home off the user profile
    # (DOTNET_CLI_HOME), so the tool can land outside %USERPROFILE%\.dotnet\tools. Consider every
    # candidate home and, as a backstop, locate the executable directly on disk.
    $toolDirs = @(
        @($env:DOTNET_CLI_HOME, $env:USERPROFILE, $HOME) |
            Where-Object { $_ } |
            ForEach-Object { Join-Path (Join-Path $_ '.dotnet') 'tools' } |
            Select-Object -Unique
    )
    $ensurePath = {
        foreach ($dir in $toolDirs) {
            if ((Test-Path -LiteralPath $dir) -and (($env:PATH -split [System.IO.Path]::PathSeparator) -notcontains $dir)) {
                $env:PATH = $dir + [System.IO.Path]::PathSeparator + $env:PATH
            }
        }
    }
    # Resolve the AL Tool command from PATH, falling back to a direct scan of the global-tools dirs.
    # The direct scan covers a freshly installed tool whose folder PATH lookup has not picked up yet,
    # or one installed under a non-default home.
    $resolveAlTool = {
        $cmd = @('al', 'altool', 'alc') | ForEach-Object { Get-Command -Name $_ -ErrorAction SilentlyContinue } | Select-Object -First 1
        if ($cmd) { return $cmd }
        foreach ($dir in $toolDirs) {
            foreach ($exe in @('al.exe', 'altool.exe', 'alc.exe', 'al', 'altool', 'alc')) {
                $candidate = Join-Path $dir $exe
                if (Test-Path -LiteralPath $candidate) { return (Get-Command -Name $candidate -ErrorAction SilentlyContinue) }
            }
        }
        return $null
    }
    & $ensurePath

    $existing = & $resolveAlTool
    if ($existing -and -not $Force) {
        Write-ALbuildLog "AL Tool already available: $($existing.Source)."
        return [PSCustomObject]@{ Installed = $false; Path = $existing.Source; Version = $existing.Version }
    }

    $dotnet = Get-Command -Name $DotNetExecutable -ErrorAction SilentlyContinue | Select-Object -First 1
    if (-not $dotnet) {
        throw "The .NET SDK ('$DotNetExecutable') is required to install the AL Tool. Install the .NET SDK, or pass -CompilerPath to Invoke-BcCompiler / install '$PackageId' manually."
    }

    if ($PSCmdlet.ShouldProcess($PackageId, 'Install AL Tool (dotnet global tool)')) {
        $verb = if ($Force) { 'update' } else { 'install' }
        # --add-source guarantees a usable feed even when the agent has no NuGet sources configured
        # ("No NuGet sources are defined or enabled") or when its only source is unreachable.
        # --ignore-failed-sources additionally keeps a single failing feed (e.g. an auth-required
        # Azure Artifacts source) from aborting the restore.
        $installArgs = @('tool', $verb, '--global', $PackageId, '--ignore-failed-sources')
        if ($Source) { $installArgs += @('--add-source', $Source) }
        if ($Version) { $installArgs += @('--version', $Version) }
        # SuccessExitCodes 0,1 keeps Invoke-ALbuildProcess from throwing on the benign exit 1 that
        # `dotnet tool install` returns for "already installed"; we still inspect the result below so a
        # genuine exit-1 failure is surfaced instead of being reported as a successful install.
        $result = Invoke-ALbuildProcess -FilePath $dotnet.Source -Arguments $installArgs -PassThru -SuccessExitCodes @(0, 1)
        $combined = "$($result.StdOut)`n$($result.StdErr)".Trim()
        if ($combined) { Write-ALbuildLog "dotnet $($installArgs -join ' '):`n$combined" }
        $benign = $combined -match 'already installed|is up to date'
        if ($result.ExitCode -ne 0 -and -not $benign) {
            throw "Failed to install the AL Tool ('$PackageId') [dotnet exit $($result.ExitCode)]: $combined"
        }
        Write-ALbuildLog -Level Success "AL Tool '$PackageId' install step completed (dotnet exit $($result.ExitCode))."
    }

    & $ensurePath
    $al = & $resolveAlTool
    if (-not $al) {
        # Surface where we looked and what dotnet thinks is installed, so a path/relocation problem on
        # the agent is diagnosable from the failed build log instead of guesswork.
        $listing = '(unavailable)'
        try { $listing = (Invoke-ALbuildProcess -FilePath $dotnet.Source -Arguments @('tool', 'list', '--global') -PassThru).StdOut } catch { $listing = "(could not list global tools: $($_.Exception.Message))" }
        throw ("The AL Tool was installed but its 'al' command was not found. Searched PATH and: $($toolDirs -join ', '). " +
            "DOTNET_CLI_HOME='$($env:DOTNET_CLI_HOME)', USERPROFILE='$($env:USERPROFILE)'.`nInstalled global tools:`n$($listing.Trim())")
    }
    return [PSCustomObject]@{ Installed = $true; Path = $al.Source; Version = $al.Version }
}