Private/Invoke-AzCliJson.ps1

function Invoke-AzCliJson {
    <#
    .SYNOPSIS
        Internal helper that invokes an arbitrary 'az' subcommand and safely
        parses the JSON response, using the stderr/stdout stream-split pattern
        from Invoke-AzRestJson / Invoke-AzResourceGraphQuery.
    .DESCRIPTION
        v0.7.67: factored out so 'az' calls outside the ARM 'az rest' path
        (notably 'az account show') do not have to repeat the
        2>&1 + Where-Object stream-split + ConvertFrom-Json scaffolding inline.
 
        Previously, three sites used the unsafe pattern:
 
            $json = az <cmd> ... 2>&1 | ConvertFrom-Json
 
        which silently corrupts the JSON when the CLI emits a stderr warning
        line - for example the cp1252 encode warning seen on Windows runners
        with non-UTF-8 console code pages. Even with --only-show-errors the
        warning can leak through some CLI subcommands, and the merged 2>&1
        capture would feed both streams to ConvertFrom-Json. This helper
        always feeds only the string (stdout) stream to ConvertFrom-Json.
 
        Designed as a thin generic shim for one-off 'az' subcommands. For ARM
        REST calls (POST/GET/PATCH/PUT against management.azure.com), prefer
        Invoke-AzRestJson which adds token-refresh-on-401 retry and body
        handling.
    .PARAMETER Arguments
        The 'az' CLI arguments as a string array. --only-show-errors is
        appended automatically so callers do not need to include it.
 
        Example: @('account', 'show', '--subscription', $subId)
    .OUTPUTS
        PSCustomObject with:
            Ok - [bool] $true when CLI exit was 0 and JSON parsed (or empty).
            Data - parsed JSON object, or $null if the response body was empty.
            Error - [string] - scrubbed error text when Ok=$false, else $null.
    .EXAMPLE
        $res = Invoke-AzCliJson -Arguments @('account', 'show', '--subscription', $id)
        if ($res.Ok) { $res.Data.name } else { "(failed: $($res.Error))" }
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string[]]$Arguments
    )

    $prevPyEncoding = $env:PYTHONIOENCODING
    try {
        # Best-effort UTF-8 hint (az.cmd's python is -I/-E so this is mostly
        # defence-in-depth; the real fix is --only-show-errors + the post-capture
        # stream split below).
        $env:PYTHONIOENCODING = 'utf-8'

        $azArgs = @($Arguments) + '--only-show-errors'
        $raw = & az @azArgs 2>&1
        $exit = $LASTEXITCODE

        # Split merged stdout+stderr by stream type. Stderr lines (Python
        # warnings, deprecation notices) surface as ErrorRecord objects when
        # using 2>&1; stdout lines surface as strings. We only pass the string
        # stream to ConvertFrom-Json so a stray stderr warning can never
        # corrupt JSON parsing.
        $stderrLines = @($raw | Where-Object { $_ -is [System.Management.Automation.ErrorRecord] })
        $stdoutLines = @($raw | Where-Object { $_ -isnot [System.Management.Automation.ErrorRecord] })

        if ($exit -ne 0) {
            return [PSCustomObject]@{
                Ok    = $false
                Data  = $null
                Error = ConvertTo-ScrubbedCliOutput -Text ((($stderrLines + $stdoutLines) | Out-String).Trim())
            }
        }

        $rawText = ($stdoutLines | Out-String).Trim()
        if ([string]::IsNullOrWhiteSpace($rawText)) {
            return [PSCustomObject]@{ Ok = $true; Data = $null; Error = $null }
        }
        try {
            $parsed = $rawText | ConvertFrom-Json -ErrorAction Stop
            return [PSCustomObject]@{ Ok = $true; Data = $parsed; Error = $null }
        }
        catch {
            $snippet = $rawText.Substring(0, [Math]::Min(500, $rawText.Length))
            return [PSCustomObject]@{
                Ok    = $false
                Data  = $null
                Error = "JSON parse failure: $($_.Exception.Message); raw: $(ConvertTo-ScrubbedCliOutput -Text $snippet)"
            }
        }
    }
    finally {
        if ($null -eq $prevPyEncoding) {
            Remove-Item Env:PYTHONIOENCODING -ErrorAction SilentlyContinue -WhatIf:$false
        }
        else {
            $env:PYTHONIOENCODING = $prevPyEncoding
        }
    }
}