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 tool so the 'al' command (which wraps the AL compiler, alc) is available to Invoke-BcCompiler's AlTool engine on a build agent. Requires the .NET SDK ('dotnet'). VERSION MATTERS. The AL Tool's MAJOR version is the AL runtime it can compile (17.x -> runtime 17 = BC28, 18.x -> runtime 18 = BC29); a compiler older than the app's declared runtime fails with AL1043 ("The runtime version 'x' is not supported by the AL compiler"). Pass -BcVersion (or -RuntimeVersion) and the required version is derived and resolved for you - including the PRERELEASE compiler that is the only one available while a BC major is still in preview (NextMajor). See Resolve-BcAlToolVersion. Given a required runtime, the tool is installed into a PER-VERSION folder under the ALbuild base cache (--tool-path) instead of the machine-wide global tool. Build agents run several stages (Current / NextMinor / NextMajor) against different BC majors, often concurrently, and a single global tool cannot serve them: one stage would silently re-point the compiler of another - and a shipping product would get built by a beta compiler. The per-version folder is reused across builds, so this costs one download per runtime major per agent. Idempotent: an AL Tool that already satisfies the requirement is left alone (unless -Force). Without any version requirement the historical behaviour is unchanged - a global install that no-ops whenever some 'al' is already resolvable - and after installing, the global-tools folder is added to PATH for the current session. .PARAMETER PackageId The dotnet tool package id. Default 'Microsoft.Dynamics.BusinessCentral.Development.Tools'. .PARAMETER Version Optional specific version to pin; otherwise the latest (matching the required runtime, when one is given) is installed. .PARAMETER BcVersion The Business Central version being compiled against (e.g. '29.0.53586.0' or '29'). The required AL runtime major is derived from it as (BC major - 11), matching Update-BcAppManifest. .PARAMETER RuntimeVersion The AL runtime to support (e.g. '18.0'), when it is known directly instead of via -BcVersion. Takes precedence over -BcVersion. .PARAMETER Prerelease How to treat prerelease AL Tool versions when resolving one for the required runtime: Auto (default) prefers a stable release and falls back to a prerelease only when the required major has none (the NextMajor case); Always takes the newest even if prerelease; Never refuses to use one. .PARAMETER ToolPath Install into this folder (dotnet tool --tool-path) instead of the global tool store. Set automatically to a per-version folder when a runtime requirement is given. .PARAMETER Global Force a machine-wide global install even when a runtime requirement is given. Affects every other build on the agent - prefer the default per-version isolation. .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 .EXAMPLE Install-BcAlTool -BcVersion '29.0.53586.0' # BC29 -> AL runtime 18 -> the newest 18.x AL Tool (a '-beta' while BC29 is in preview), # installed side by side under <base>\altool\<version>. .OUTPUTS PSCustomObject: Installed (bool), Path, Version, RequiredRuntimeMajor, Prerelease, ToolPath. #> [CmdletBinding(SupportsShouldProcess)] [OutputType([PSCustomObject])] param( [string] $PackageId = 'Microsoft.Dynamics.BusinessCentral.Development.Tools', [string] $Version, [string] $BcVersion, [string] $RuntimeVersion, [ValidateSet('Auto', 'Always', 'Never')] [string] $Prerelease = 'Auto', [string] $ToolPath, [switch] $Global, [switch] $Force, [string] $Source = 'https://api.nuget.org/v3/index.json', [string] $DotNetExecutable = 'dotnet' ) # --- Which AL runtime must the compiler support? ------------------------------------------------- # -RuntimeVersion wins; otherwise derive from the BC major exactly as Update-BcAppManifest does # (BC >= 12: runtime major = BC major - 11), so the compiler and the stamped manifest always agree. $requiredMajor = 0 if ($RuntimeVersion) { $runtimeParsed = $null if ([version]::TryParse((($RuntimeVersion -split '-', 2)[0]), [ref] $runtimeParsed)) { $requiredMajor = $runtimeParsed.Major } elseif ($RuntimeVersion -match '^\s*(\d+)') { $requiredMajor = [int] $Matches[1] } if ($requiredMajor -le 0) { throw "Could not read an AL runtime major from -RuntimeVersion '$RuntimeVersion'." } } elseif ($BcVersion) { $bcMajor = 0 if ($BcVersion -match '^\s*(\d+)') { $bcMajor = [int] $Matches[1] } if ($bcMajor -le 0) { throw "Could not read a Business Central major version from -BcVersion '$BcVersion'." } if ($bcMajor -ge 12) { $requiredMajor = $bcMajor - 11 } else { Write-ALbuildLog -Level Warning "BC $bcMajor predates the AL runtime numbering; not constraining the AL Tool version." } } if ($requiredMajor -gt 0) { Write-ALbuildLog "AL Tool requirement: runtime $requiredMajor.x (AL Tool $requiredMajor.x)$(if ($BcVersion) { " for BC $BcVersion" })." } # 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 # --- Pick the exact version to install ----------------------------------------------------------- # Only when a runtime is required: with no requirement the historical "latest, unpinned" install is # kept (and no network call is made to resolve a version). $targetVersion = $Version $targetIsPrerelease = $false if (-not $targetVersion -and $requiredMajor -gt 0) { $selection = Resolve-BcAlToolVersion -PackageId $PackageId -RequiredMajor $requiredMajor -Prerelease $Prerelease if (-not $selection) { throw ("No AL Tool version supporting AL runtime $requiredMajor" + $(if ($BcVersion) { " (BC $BcVersion)" }) + " is available on the feed with -Prerelease '$Prerelease'. " + "A BC major still in preview only publishes prerelease compilers - use -Prerelease Auto or Always, or pin one with -Version.") } $targetVersion = $selection.Version $targetIsPrerelease = $selection.IsPrerelease Write-ALbuildLog "Selected AL Tool $targetVersion ($(if ($targetIsPrerelease) { 'prerelease' } else { 'stable' })) out of $($selection.Available) version(s) for runtime $requiredMajor." } # --- Isolated (per-version) or machine-wide global? ---------------------------------------------- # Per-version isolation is the default once a runtime is required, so parallel stages targeting # different BC majors on one agent cannot overwrite each other's compiler. $isolated = $false if ($ToolPath) { $isolated = $true } elseif ($targetVersion -and $requiredMajor -gt 0 -and -not $Global) { $baseFolder = Get-ALbuildConfig -Name BaseFolder $ToolPath = Join-Path (Join-Path $baseFolder 'altool') $targetVersion $isolated = $true } if ($isolated) { # A per-version folder is self-identifying: if the executable is there, it IS the right version, # so a warm agent skips both the version query's install and the download entirely. $resolveInToolPath = { foreach ($exe in @('al.exe', 'altool.exe', 'al', 'altool')) { $candidate = Join-Path $ToolPath $exe if (Test-Path -LiteralPath $candidate) { return $candidate } } return $null } $cached = & $resolveInToolPath if ($cached -and -not $Force) { Write-ALbuildLog "AL Tool $targetVersion already present: $cached (cached)." return [PSCustomObject]@{ Installed = $false; Path = $cached; Version = $targetVersion; RequiredRuntimeMajor = $requiredMajor; Prerelease = $targetIsPrerelease; ToolPath = $ToolPath } } $dotnetExe = Get-Command -Name $DotNetExecutable -ErrorAction SilentlyContinue | Select-Object -First 1 if (-not $dotnetExe) { 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 $targetVersion", "Install AL Tool into '$ToolPath'")) { if (-not (Test-Path -LiteralPath $ToolPath)) { New-Item -ItemType Directory -Force -Path $ToolPath | Out-Null } $verb = if ($Force -and $cached) { 'update' } else { 'install' } $installArgs = @('tool', $verb, '--tool-path', $ToolPath, $PackageId, '--ignore-failed-sources') if ($Source) { $installArgs += @('--add-source', $Source) } if ($targetVersion) { $installArgs += @('--version', $targetVersion) } # An explicit prerelease --version installs without --prerelease; pass it only when the # version is left to dotnet (no resolved version) but prereleases are wanted. if (-not $targetVersion -and $Prerelease -ne 'Never') { $installArgs += '--prerelease' } Write-ALbuildLog "Installing AL Tool $targetVersion into '$ToolPath'..." $installResult = Invoke-ALbuildProcess -FilePath $dotnetExe.Source -Arguments $installArgs -PassThru -SuccessExitCodes @(0, 1) $installOutput = "$($installResult.StdOut)`n$($installResult.StdErr)".Trim() if ($installOutput) { Write-ALbuildLog "dotnet $($installArgs -join ' '):`n$installOutput" } $installBenign = $installOutput -match 'already installed|is up to date' if ($installResult.ExitCode -ne 0 -and -not $installBenign) { throw "Failed to install the AL Tool ('$PackageId' $targetVersion) into '$ToolPath' [dotnet exit $($installResult.ExitCode)]: $installOutput" } } $installedPath = & $resolveInToolPath if (-not $installedPath) { throw "The AL Tool ('$PackageId' $targetVersion) was installed into '$ToolPath' but no 'al' executable was found there." } Write-ALbuildLog -Level Success "AL Tool $targetVersion ready: $installedPath." return [PSCustomObject]@{ Installed = $true; Path = $installedPath; Version = $targetVersion; RequiredRuntimeMajor = $requiredMajor; Prerelease = $targetIsPrerelease; ToolPath = $ToolPath } } # Get-Command reports Version as a [version] for a real executable, but callers/hosts can surface a # plain string; reading .Major off a string throws under Set-StrictMode. Parse defensively and treat # an unreadable version as "unknown" (0) rather than failing the install. $majorOf = { param($command) if (-not $command) { return 0 } $raw = $command.Version if (-not $raw) { return 0 } if ($raw -is [version]) { return $raw.Major } $parsed = $null if ([version]::TryParse((([string] $raw) -split '-', 2)[0], [ref] $parsed)) { return $parsed.Major } if (([string] $raw) -match '^\s*(\d+)') { return [int] $Matches[1] } return 0 } $existing = & $resolveAlTool # An already-present AL Tool only satisfies the caller when it is new enough for the required # runtime. Without that check a long-lived self-hosted agent keeps compiling with the compiler it # happened to install first, and a NextMajor build fails AL1043 despite a newer compiler existing. $existingMajor = & $majorOf $existing $satisfies = $existing -and ($requiredMajor -le 0 -or $existingMajor -ge $requiredMajor) if ($existing -and -not $satisfies) { Write-ALbuildLog -Level Warning "AL Tool at '$($existing.Source)' is $($existing.Version) - too old for AL runtime $requiredMajor; updating it." } if ($satisfies -and -not $Force) { Write-ALbuildLog "AL Tool already available: $($existing.Source)." return [PSCustomObject]@{ Installed = $false; Path = $existing.Source; Version = $existing.Version; RequiredRuntimeMajor = $requiredMajor; Prerelease = $false; ToolPath = '' } } $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)')) { # 'update' also covers replacing an AL Tool that is present but too old for the required runtime # (install would just report "already installed" and leave the stale compiler in place). $verb = if ($Force -or $existing) { '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 ($targetVersion) { $installArgs += @('--version', $targetVersion) } elseif ($Prerelease -eq 'Always') { $installArgs += '--prerelease' } # 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())") } # A global install that still cannot serve the required runtime is a hard failure: compiling would # fail later with AL1043, far from the cause. $installedMajor = & $majorOf $al if ($requiredMajor -gt 0 -and $installedMajor -gt 0 -and $installedMajor -lt $requiredMajor) { throw ("The AL Tool at '$($al.Source)' is $($al.Version) after the install, which cannot compile AL runtime $requiredMajor" + $(if ($BcVersion) { " (BC $BcVersion)" }) + ". Expected $targetVersion. Check that the agent could reach the package feed.") } return [PSCustomObject]@{ Installed = $true; Path = $al.Source; Version = $al.Version; RequiredRuntimeMajor = $requiredMajor; Prerelease = $targetIsPrerelease; ToolPath = '' } } |