Modules/businessdev.ALbuild.OnPrem/Public/Publish-BcPerTenantExtension.ps1

function Publish-BcPerTenantExtension {
    <#
    .SYNOPSIS
        Deploys a per-tenant extension to a Business Central environment via the automation API (licensed).
 
    .DESCRIPTION
        Uploads and schedules one or more per-tenant extensions using the Business Central automation
        API extensionUpload entity: create an upload record, PATCH the .app binary into it, then
        trigger the upload action. A bearer access token for the environment is required (acquire one
        with New-BcApiAuthContext, which supports S2S client-secret, certificate and refresh-token
        authentication).
 
        The target environment can be given either as a ready automation API base URL
        (-AutomationBaseUrl) or, more conveniently, as -TenantId + -Environment (the URL is then
        built for you). When -CompanyId is omitted the first company in the environment is used. One
        or more .app files (or a folder of them) can be published in a single call.
 
    .PARAMETER TenantId
        Azure AD tenant id of the environment. Used to build the automation API base URL.
 
    .PARAMETER Environment
        Business Central environment name (sandbox or production). Used to build the base URL.
 
    .PARAMETER AutomationBaseUrl
        The automation API base URL, e.g.
        https://api.businesscentral.dynamics.com/v2.0/{tenant}/{environment}/api/microsoft/automation/v2.0
        Supply this instead of -TenantId/-Environment to target a non-default service URL.
 
    .PARAMETER CompanyId
        The company id (GUID) in the environment. When omitted, the first company is resolved and used.
 
    .PARAMETER AppFile
        One or more .app files (or folders/wildcards) to publish. Runtime packages (*.runtime.app)
        are skipped. Defaults to the $(bcAppFile) build variable when called from the task.
 
    .PARAMETER AccessToken
        OAuth2 bearer token for the environment (e.g. from New-BcApiAuthContext).
 
    .PARAMETER SchemaSyncMode
        Schema sync mode: Add (default) or ForceSync.
 
    .EXAMPLE
        $ctx = New-BcApiAuthContext -TenantId $t -ClientId $c -ClientSecret $s
        Publish-BcPerTenantExtension -TenantId $t -Environment 'Production' -AccessToken $ctx.AccessToken -AppFile .\out
 
    .EXAMPLE
        Publish-BcPerTenantExtension -AutomationBaseUrl $url -CompanyId $id -AppFile .\out\My.app -AccessToken $token
    #>

    [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'Environment')]
    param(
        [Parameter(ParameterSetName = 'Environment', Mandatory)] [string] $TenantId,
        [Parameter(ParameterSetName = 'Environment', Mandatory)] [string] $Environment,
        [Parameter(ParameterSetName = 'Url', Mandatory)] [string] $AutomationBaseUrl,
        [string] $CompanyId,
        [Parameter(Mandatory)] [string[]] $AppFile,
        [Parameter(Mandatory)] [string] $AccessToken,
        [ValidateSet('Add', 'ForceSync')] [string] $SchemaSyncMode = 'Add'
    )

    Assert-ALbuildLicensed -Feature 'OnPrem'

    if ($PSCmdlet.ParameterSetName -eq 'Environment') {
        $AutomationBaseUrl = "https://api.businesscentral.dynamics.com/v2.0/$TenantId/$Environment/api/microsoft/automation/v2.0"
    }
    $base = $AutomationBaseUrl.TrimEnd('/')
    $headers = @{ Authorization = "Bearer $AccessToken" }

    # Resolve the .app files to publish (skip runtime packages). The wildcard/last-chance branch must
    # not use Get-ChildItem -File: -File is a FileSystem-provider *dynamic* parameter, so on a path
    # that does not resolve to the filesystem provider (e.g. a Windows 'C:\...' path on Linux, where
    # there is no C: drive) it fails to bind with "A parameter cannot be found that matches parameter
    # name 'File'" instead of simply matching nothing. Filter to files via PSIsContainer instead.
    $files = foreach ($p in $AppFile) {
        if (Test-Path -LiteralPath $p -PathType Container) { Get-ChildItem -LiteralPath $p -Filter '*.app' -Recurse -File }
        elseif (Test-Path -LiteralPath $p) { Get-Item -LiteralPath $p }
        else { Get-ChildItem -Path $p -ErrorAction SilentlyContinue | Where-Object { -not $_.PSIsContainer } }
    }
    $files = @($files | Where-Object { $_.Name -notlike '*.runtime.app' })
    if ($files.Count -eq 0) { throw "No .app file(s) found to publish (looked in: $($AppFile -join ', '))." }

    # Resolve the company when not supplied (first company in the environment, as the V1 task did).
    if (-not $CompanyId) {
        $companies = Invoke-RestMethod -Uri "$base/companies" -Headers $headers -Method Get -ErrorAction Stop
        $CompanyId = @($companies.value)[0].id
        if (-not $CompanyId) { throw "No company found at '$base'." }
    }

    $companySegment = "companies($CompanyId)/extensionUpload"

    foreach ($file in $files) {
        if (-not $PSCmdlet.ShouldProcess($AutomationBaseUrl, "Publish PTE $($file.Name)")) { continue }

        # 1. Create the upload record (with the requested schema sync mode).
        $createBody = @{ schemaSyncMode = $SchemaSyncMode } | ConvertTo-Json
        $upload = Invoke-RestMethod -Uri "$base/$companySegment" -Method Post -Headers ($headers + @{ 'Content-Type' = 'application/json' }) -Body $createBody -ErrorAction Stop
        $etag = $upload.'@odata.etag'
        $uploadId = $upload.systemId

        # 2. PATCH the binary content into the upload record.
        $bytes = [System.IO.File]::ReadAllBytes((Resolve-Path -LiteralPath $file.FullName).ProviderPath)
        $patchHeaders = $headers + @{ 'If-Match' = $etag; 'Content-Type' = 'application/octet-stream' }
        Invoke-RestMethod -Uri "$base/$companySegment($uploadId)/extensionContent" -Method Patch -Headers $patchHeaders -Body $bytes -ErrorAction Stop | Out-Null

        # 3. Trigger the upload/installation action.
        Invoke-RestMethod -Uri "$base/$companySegment($uploadId)/Microsoft.NAV.upload" -Method Post -Headers ($headers + @{ 'If-Match' = $etag }) -ErrorAction Stop | Out-Null

        Write-ALbuildLog -Level Success "Scheduled per-tenant extension '$($file.Name)' ($SchemaSyncMode)."
    }
}