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 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] $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' }
        # --ignore-failed-sources so a single unreachable/auth-failing NuGet feed configured on the
        # agent (common with Azure Artifacts) does not abort the restore of a package available from
        # another source (e.g. nuget.org).
        $installArgs = @('tool', $verb, '--global', $PackageId, '--ignore-failed-sources')
        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 }
}