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 a per-tenant extension 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.
 
    .PARAMETER AutomationBaseUrl
        The automation API base URL, e.g.
        https://api.businesscentral.dynamics.com/v2.0/{tenant}/{environment}/api/microsoft/automation/v2.0
 
    .PARAMETER CompanyId
        The company id (GUID) in the environment.
 
    .PARAMETER AppFile
        Path to the .app file.
 
    .PARAMETER AccessToken
        OAuth2 bearer token for the environment.
 
    .PARAMETER SchemaSyncMode
        Schema sync mode: Add (default) or ForceSync.
    #>

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

    Assert-ALbuildLicensed -Feature 'OnPrem'
    if (-not (Test-Path -LiteralPath $AppFile)) { throw "App file not found: '$AppFile'." }
    if (-not $PSCmdlet.ShouldProcess($AutomationBaseUrl, "Publish PTE $(Split-Path $AppFile -Leaf)")) { return }

    $base = $AutomationBaseUrl.TrimEnd('/')
    $headers = @{ Authorization = "Bearer $AccessToken" }
    $companySegment = "companies($CompanyId)/extensionUpload"

    # 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 $AppFile).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 '$(Split-Path $AppFile -Leaf)' ($SchemaSyncMode)."
}