Modules/businessdev.ALbuild.Apps/Private/Publish-BcAppToDevEndpoint.ps1

function Publish-BcAppToDevEndpoint {
    <#
    .SYNOPSIS
        POSTs an .app to a Business Central development service endpoint (the VS Code 'Publish' mechanism).
 
    .DESCRIPTION
        Uploads the .app as multipart/form-data to a '<devUrl>/dev/apps' endpoint with HTTP Basic auth,
        which publishes + synchronises + installs it as a development (ModernDev) extension - replaceable
        without a version bump. Mirrors ALbuild V1 / BcContainerHelper -useDevEndpoint. Kept as its own
        function so callers can resolve the URL however they like (e.g. a container's IP) and so it is
        mockable in tests.
 
    .PARAMETER Url
        The dev endpoint URL including query string, e.g.
        'https://172.17.0.2:7049/BC/dev/apps?SchemaUpdateMode=synchronize'.
 
    .PARAMETER AppFile
        Host path to the .app file to upload.
 
    .PARAMETER Credential
        BC user credential for Basic authentication.
 
    .PARAMETER IgnoreSslErrors
        Accept the endpoint's (self-signed) TLS certificate. Uses the per-handler validator on PowerShell 7
        (.NET) and the global ServicePointManager hook on Windows PowerShell 5.1 (.NET Framework).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Url,
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $AppFile,
        [Parameter(Mandatory)] [pscredential] $Credential,
        [switch] $IgnoreSslErrors
    )

    Add-Type -AssemblyName System.Net.Http
    $handler = [System.Net.Http.HttpClientHandler]::new()
    if ($IgnoreSslErrors) {
        # PowerShell 7 (.NET) exposes a per-handler validator; Windows PowerShell 5.1 (.NET Framework - the
        # pipeline runtime) has no such property, so fall back to the global ServicePointManager hook.
        if ([System.Net.Http.HttpClientHandler].GetProperty('ServerCertificateCustomValidationCallback')) {
            $handler.ServerCertificateCustomValidationCallback = [System.Net.Http.HttpClientHandler]::DangerousAcceptAnyServerCertificateValidator
        }
        [System.Net.ServicePointManager]::ServerCertificateValidationCallback = { $true }
        [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor [System.Net.SecurityProtocolType]::Tls12
    }

    $client = [System.Net.Http.HttpClient]::new($handler)
    $client.Timeout = [System.Threading.Timeout]::InfiniteTimeSpan
    $client.DefaultRequestHeaders.ExpectContinue = $false
    $pair = "$($Credential.UserName):$($Credential.GetNetworkCredential().Password)"
    $client.DefaultRequestHeaders.Authorization = [System.Net.Http.Headers.AuthenticationHeaderValue]::new(
        'Basic', [System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes($pair)))

    $leaf = Split-Path -Path $AppFile -Leaf
    $multipart = [System.Net.Http.MultipartFormDataContent]::new()
    $fs = [System.IO.FileStream]::new((Resolve-Path -LiteralPath $AppFile).ProviderPath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read)
    try {
        $disp = [System.Net.Http.Headers.ContentDispositionHeaderValue]::new('form-data')
        $disp.Name = $leaf; $disp.FileName = $leaf
        $content = [System.Net.Http.StreamContent]::new($fs)
        $content.Headers.ContentDisposition = $disp
        $multipart.Add($content)
        $resp = $client.PostAsync($Url, $multipart).GetAwaiter().GetResult()
        if (-not $resp.IsSuccessStatusCode) {
            $detail = ''
            try { $detail = $resp.Content.ReadAsStringAsync().GetAwaiter().GetResult() } catch { $detail = '' }
            try { $j = $detail | ConvertFrom-Json; if ($j.Message) { $detail = $j.Message } } catch { $null = $_ }
            throw "Dev endpoint returned $([int]$resp.StatusCode) $($resp.ReasonPhrase). $($detail.Trim())"
        }
    }
    finally { $fs.Dispose() }
}