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

function Install-BcAzureSignTool {
    <#
    .SYNOPSIS
        Ensures AzureSignTool is installed (as a .NET global tool) and on PATH.
 
    .DESCRIPTION
        Installs the 'AzureSignTool' dotnet global tool so the 'azuresigntool' command is available to
        Invoke-BcAppSigning on a build agent - reinstating the auto-install behaviour of ALbuild V1.
        Idempotent: when AzureSignTool 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 'azuresigntool' is immediately resolvable.
 
    .PARAMETER PackageId
        The dotnet tool package id. Default 'AzureSignTool'.
 
    .PARAMETER Version
        Optional specific version to pin; otherwise the latest is installed.
 
    .PARAMETER Force
        Reinstall/update even when AzureSignTool is already available.
 
    .PARAMETER Source
        NuGet package source to install 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-BcAzureSignTool
 
    .OUTPUTS
        PSCustomObject: Installed (bool), Path, Version.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([PSCustomObject])]
    param(
        [string] $PackageId = 'AzureSignTool',
        [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
            }
        }
    }
    $resolveTool = {
        $cmd = Get-Command -Name 'azuresigntool' -ErrorAction SilentlyContinue | Select-Object -First 1
        if ($cmd) { return $cmd }
        foreach ($dir in $toolDirs) {
            foreach ($exe in @('azuresigntool.exe', 'azuresigntool')) {
                $candidate = Join-Path $dir $exe
                if (Test-Path -LiteralPath $candidate) { return (Get-Command -Name $candidate -ErrorAction SilentlyContinue) }
            }
        }
        return $null
    }
    & $ensurePath

    $existing = & $resolveTool
    if ($existing -and -not $Force) {
        Write-ALbuildLog "AzureSignTool 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 AzureSignTool. Install the .NET SDK, or install '$PackageId' manually and pass -SignToolPath to Invoke-BcAppSigning."
    }

    if ($PSCmdlet.ShouldProcess($PackageId, 'Install AzureSignTool (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"); --ignore-failed-sources keeps a single failing
        # feed 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 AzureSignTool ('$PackageId') [dotnet exit $($result.ExitCode)]: $combined"
        }
        Write-ALbuildLog -Level Success "AzureSignTool '$PackageId' install step completed (dotnet exit $($result.ExitCode))."
    }

    & $ensurePath
    $tool = & $resolveTool
    if (-not $tool) {
        $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 ("AzureSignTool was installed but its 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 = $tool.Source; Version = $tool.Version }
}