Modules/businessdev.ALbuild.OnPrem/Private/Import-BcManagementShell.ps1

function Import-BcManagementShell {
    <#
    .SYNOPSIS
        Loads the Business Central administration shell (Publish-NAVApp / Sync-NAVApp /
        Install-NAVApp / Get-NAVAppInfo / ...) into the current session.
 
    .DESCRIPTION
        The on-prem cmdlets are provided by the BC server's management assemblies, which are NOT on
        PSModulePath and are therefore not auto-loaded. Without this, an on-prem publish fails with
        "The term 'Publish-NAVApp' is not recognized". Mirrors the AL.build V1 ReleaseOnPrem behaviour:
        if the shell is already present it is reused; otherwise the management assemblies are located
        under the BC install tree and imported (modern 'Microsoft.BusinessCentral.*' first, then the
        legacy 'Microsoft.Dynamics.Nav.*' names for older versions).
 
        BC29 removed the Windows PowerShell 5 compatibility assembly
        (...\Service\Management\Microsoft.Dynamics.Nav.Management.dll), leaving only the .NET 8 modules
        under ...\Service\Admin, which Windows PowerShell 5.1 cannot load - and Azure DevOps runs
        task.ps1 under 5.1. When that is the situation, this function falls back to installing proxy
        functions that forward each cmdlet to a PowerShell 7 child process (see
        Invoke-BcManagementProxyCommand), so the on-prem publish/sync/install/upgrade flow keeps
        working unchanged. That fallback needs PowerShell 7 on the agent; if it is missing we say so
        explicitly rather than failing later with an unrecognised-cmdlet error.
 
        Progress is logged in detail (every search root, every assembly imported and from where) so a
        failure on a customer agent is diagnosable from the pipeline log alone.
 
    .PARAMETER SentinelCommand
        The command whose presence proves the shell is loaded. Default 'Publish-NAVApp' (the cmdlet the
        on-prem publish actually needs).
 
    .PARAMETER SearchRoot
        Glob root(s) under which to search for the management assemblies.
 
    .OUTPUTS
        None. Throws a clear, actionable error listing the searched locations if the shell cannot be loaded.
    #>

    [CmdletBinding()]
    param(
        [string] $SentinelCommand = 'Publish-NAVApp',
        [string[]] $SearchRoot = @(
            'C:\Program Files\Microsoft Dynamics*\*\Service\',
            'C:\Program Files (x86)\Microsoft Dynamics*\*\Service\'
        )
    )

    # A resolved mode is sticky for the session: re-running the assembly hunt (and the pwsh smoke test)
    # on every one of the ~9 management calls a publish makes would be pure waste.
    if ((Test-Path 'variable:script:BcManagementMode') -and $script:BcManagementMode -eq 'Pwsh') {
        Write-ALbuildLog -Level Verbose "BC management shell resolved earlier to the PowerShell 7 delegation mode; reusing it."
        return
    }

    if (Get-Command -Name $SentinelCommand -ErrorAction SilentlyContinue) {
        Write-ALbuildLog "BC management shell already loaded ('$SentinelCommand' available); reusing it."
        $script:BcManagementMode = 'InProcess'
        return
    }

    Write-ALbuildLog "BC management shell not loaded ('$SentinelCommand' missing); locating the management assemblies..."

    # Modern (BC15+) assemblies first, then the legacy NAV names for older on-prem versions. Each set
    # provides the server (…Management) + app (…Apps.Management) cmdlets we call.
    $moduleSets = @(
        @('Microsoft.BusinessCentral.Management.dll', 'Microsoft.BusinessCentral.Apps.Management.dll'),
        @('Microsoft.Dynamics.Nav.Management.dll', 'Microsoft.Dynamics.Nav.Apps.Management.dll')
    )

    # A per-assembly import failure is EXPECTED and harmless: on a modern BC server under Windows
    # PowerShell 5.1 the modern Microsoft.BusinessCentral.* assemblies target .NET 8 and fail to load
    # ("System.Runtime, Version=8.0.0.0 ... not found"), then the legacy Microsoft.Dynamics.Nav.* set
    # loads fine. So the search is quiet (Verbose): attempts and failures are recorded, not warned, and
    # surfaced only in the throw if NOTHING yields the sentinel cmdlet. Only that final state is an error.
    $searched = [System.Collections.Generic.List[string]]::new()
    $loadErrors = [System.Collections.Generic.List[string]]::new()
    # Every modern (.NET 8) module file we located, whether or not it loaded here. On BC29 under Windows
    # PowerShell none of them load, and these are exactly the files the pwsh fallback must import.
    $modernFiles = [System.Collections.Generic.List[string]]::new()
    $importedAny = $false
    foreach ($set in $moduleSets) {
        $isModernSet = $set -contains 'Microsoft.BusinessCentral.Management.dll'
        foreach ($dll in $set) {
            foreach ($root in $SearchRoot) {
                # String-concatenate (roots end in a separator) rather than Join-Path: Join-Path
                # resolves the 'C:' qualifier and throws "drive 'C' does not exist" on non-Windows
                # agents (the module is loaded/tested on the Linux CI leg, though it only runs for real
                # on a Windows BC server).
                $searched.Add($root + $dll)
                $found = @()
                # NOTE: do NOT add -File here. With a wildcard '...\*\Service\' path + -Recurse, -File makes
                # Get-ChildItem return nothing (PowerShell quirk), so the assemblies are never found - which
                # is exactly why V1/the reference installer omit it. Filter out directories in the loop instead.
                try { $found = @(Get-ChildItem -Path $root -Filter $dll -Recurse -ErrorAction SilentlyContinue | Where-Object { -not $_.PSIsContainer }) }
                catch { $found = @() }   # a Windows-only root throws 'drive not found' off Windows; treat as no match
                foreach ($f in $found) {
                    Write-ALbuildLog -Level Verbose " trying '$($f.Name)' from '$($f.Directory.FullName)'."
                    if ($isModernSet) {
                        # Prefer the .psd1 manifest for the pwsh fallback: it declares RequiredAssemblies
                        # and the format files, which importing the bare .dll skips.
                        # Swap the extension by string, NOT Join-Path: as noted above, Join-Path resolves
                        # the 'C:' qualifier of these Windows paths and throws "drive 'C' does not exist"
                        # on a non-Windows agent (the module is loaded and unit-tested on the Linux CI leg,
                        # though it only runs for real on a Windows BC server). Test-Path is guarded for
                        # the same reason.
                        $manifest = $f.FullName -replace '\.dll$', '.psd1'
                        $hasManifest = $false
                        try { $hasManifest = Test-Path -LiteralPath $manifest -ErrorAction SilentlyContinue } catch { $hasManifest = $false }
                        $modernFiles.Add($(if ($hasManifest) { $manifest } else { $f.FullName }))
                    }
                    try {
                        Import-Module -Name $f.FullName -Force -Global -DisableNameChecking -ErrorAction Stop
                        $importedAny = $true
                    }
                    catch {
                        # Expected for the modern set on PS 5.1; recorded (not warned) in case the whole load fails.
                        $loadErrors.Add("$($f.FullName): $($_.Exception.Message)")
                        Write-ALbuildLog -Level Verbose " '$($f.Name)' did not load: $($_.Exception.Message)"
                    }
                }
            }
        }
        # Stop at the first assembly set that yields the cmdlet we need.
        if (Get-Command -Name $SentinelCommand -ErrorAction SilentlyContinue) { break }
    }

    if (Get-Command -Name $SentinelCommand -ErrorAction SilentlyContinue) {
        $script:BcManagementMode = 'InProcess'
        Write-ALbuildLog -Level Success "BC management shell loaded ('$SentinelCommand' now available)."
        return
    }

    # Nothing loaded in-process, but the server ships the modern .NET 8 modules: this is the BC29
    # situation (they need PowerShell 7 and we are almost certainly on Windows PowerShell 5.1, which is
    # what Azure DevOps runs task.ps1 under). Delegate every call to pwsh instead of failing. We do not
    # gate this on the edition: if we somehow already run on Core and the import still failed, a fresh
    # pwsh process is worth trying, and its real import error is a far better diagnostic than the
    # generic "could not load the management shell" below.
    if ($modernFiles.Count -gt 0) {
        $modules = @($modernFiles | Select-Object -Unique)
        Write-ALbuildLog -Level Information ("Only the .NET 8 management modules are present ({0}); they did not load in this session (PowerShell {1}, {2} edition) - this is a BC29+ server. Falling back to delegating management calls to PowerShell 7." -f (($modules | Split-Path -Leaf) -join ', '), $PSVersionTable.PSVersion, $PSVersionTable.PSEdition)

        $pwshPath = (Get-Command -Name 'pwsh' -CommandType Application -ErrorAction SilentlyContinue |
                Select-Object -First 1 -ExpandProperty Source)
        if (-not $pwshPath) {
            foreach ($candidate in @("$env:ProgramFiles\PowerShell\7\pwsh.exe", "${env:ProgramFiles(x86)}\PowerShell\7\pwsh.exe")) {
                if (Test-Path -LiteralPath $candidate) { $pwshPath = $candidate; break }
            }
        }
        if (-not $pwshPath) {
            throw @"
This Business Central server ships only the .NET 8 management modules (BC29 removed the Windows PowerShell 5 compatibility module Microsoft.Dynamics.Nav.Management.dll), and PowerShell 7 was not found on this agent - so '$SentinelCommand' cannot be reached at all.
Install PowerShell 7 on the agent (winget install --id Microsoft.PowerShell, or https://aka.ms/powershell), then re-run.
Modules found: $($modules -join '; ')
"@

        }

        # Smoke-test the delegation before declaring success, so a broken fallback fails here - with the
        # real import error - instead of on the first publish. SilentlyContinue so a *missing cmdlet*
        # falls through to the explicit message below; an import failure still throws (proxy exit 3).
        $probe = Invoke-BcManagementProxyCommand -Command 'Get-Command' -PwshPath $pwshPath -ModuleFile $modules `
            -Parameters @{ Name = $SentinelCommand; ErrorAction = 'SilentlyContinue' }
        if (-not $probe) {
            throw "PowerShell 7 ('$pwshPath') loaded the .NET 8 management modules but still does not expose '$SentinelCommand'. Modules: $($modules -join '; ')."
        }

        $script:BcManagementMode = 'Pwsh'
        $script:BcManagementModuleFile = $modules
        $script:BcManagementPwsh = $pwshPath
        Write-ALbuildLog -Level Success "BC management shell reachable via PowerShell 7 ('$pwshPath'); '$SentinelCommand' resolved there."
        return
    }

    # Neither an in-process import nor the pwsh fallback got us there.
    $hint = if ($importedAny) {
        "Assemblies were found but none exposed '$SentinelCommand' - the installed BC version may use different module names, or every candidate failed to load."
    }
    else {
        'No BC management assemblies were found under the search roots - the BC server (or its management tools) does not appear to be installed on this agent.'
    }
    $detail = "Could not load the Business Central management shell. $hint"
    $detail += [Environment]::NewLine + "Searched: $(($searched | Select-Object -Unique) -join '; ')"
    if ($loadErrors.Count -gt 0) { $detail += [Environment]::NewLine + "Load errors: " + ($loadErrors -join ' | ') }
    throw $detail
}