Public/Set-TriadConfig.ps1

# Copyright (c) 2026 Jeffrey Snover. All rights reserved.
# Licensed under the MIT License. See LICENSE file in the project root.

function Set-TriadConfig {
    <#
    .SYNOPSIS
        Uploads a local runtime configuration to a taxonomy-editor deployment.
    .DESCRIPTION
        Reads a local config JSON file and POSTs it to the admin config endpoint,
        replacing the deployment's live runtime configuration. Requires admin
        credentials (GITHUB_TOKEN). Supports -WhatIf/-Confirm (ConfirmImpact High),
        an optional pre-upload diff, and an optional reload after upload. Pair with
        Get-TriadConfig to download-edit-upload safely.
    .PARAMETER InputPath
        Local path to the config JSON to upload. Default: ./runtime-config.json.
    .PARAMETER BaseUrl
        Base URL of the taxonomy-editor deployment. Defaults to the configured endpoint.
    .PARAMETER DiffFirst
        Show a diff between the live config and the local file before uploading.
    .PARAMETER Reload
        Trigger a config reload on the server after a successful upload.
    .EXAMPLE
        Set-TriadConfig -DiffFirst
        # Diff local vs live, then upload after confirmation.
    .EXAMPLE
        Set-TriadConfig -InputPath ./runtime-config.json -Reload
        # Upload and reload the server config in one step.
    .LINK
        Show-AITriadHelp
    .LINK
        Get-TriadConfig
    .LINK
        Invoke-TriadConfigReload
    .LINK
        Register-AIBackend
    .LINK
        Test-AIApiKey
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    param(
        [Parameter()]
        [string]$InputPath = './runtime-config.json',

        [Parameter()]
        [string]$BaseUrl,

        [Parameter()]
        [switch]$DiffFirst,

        [Parameter()]
        [switch]$Reload
    )

    Set-StrictMode -Version Latest

    if (-not (Test-Path $InputPath)) {
        throw (New-ActionableError `
            -Goal 'Upload runtime config to taxonomy-editor' `
            -Problem "Config file not found: $InputPath" `
            -Location 'Set-TriadConfig' `
            -NextSteps @("Create the file or specify a valid -InputPath",
                         "Use Get-TriadConfig to download the current config first"))
    }

    $RawJson = Get-Content -Path $InputPath -Raw -Encoding utf8
    try {
        $ConfigObj = $RawJson | ConvertFrom-Json -ErrorAction Stop
    }
    catch {
        throw (New-ActionableError `
            -Goal 'Parse local config file' `
            -Problem "Invalid JSON in $InputPath — $($_.Exception.Message)" `
            -Location 'Set-TriadConfig' `
            -NextSteps @('Fix JSON syntax errors in the config file',
                         'Validate with: Get-Content ./runtime-config.json | ConvertFrom-Json'))
    }

    $KnownSections = @('_meta', 'resilience', 'rateLimiting', 'tiers', 'quotas',
                        'sessions', 'analytics', 'flightRecorder', 'community',
                        'feedback', 'server', 'cache')
    $UnknownKeys = @($ConfigObj.PSObject.Properties |
        Where-Object { $_.MemberType -eq 'NoteProperty' -and $_.Name -notin $KnownSections } |
        ForEach-Object { $_.Name })
    if (@($UnknownKeys).Count -gt 0) {
        throw (New-ActionableError `
            -Goal 'Validate local config schema' `
            -Problem "Unknown config sections: $($UnknownKeys -join ', ')" `
            -Location 'Set-TriadConfig' `
            -NextSteps @('Remove unknown sections from the config file',
                         "Valid sections: $($KnownSections -join ', ')"))
    }

    $Req = Resolve-TriadConfigRequest -BaseUrl $BaseUrl -CallerName 'Set-TriadConfig'

    if ($DiffFirst) {
        Write-Verbose "Fetching current config for diff..."
        try {
            $Current = Invoke-RestMethod -Uri "$($Req.BaseUrl)/api/admin/config" `
                -Method GET -Headers $Req.Headers -ErrorAction Stop
            $CurrentJson = ($Current.config | ConvertTo-Json -Depth 10).Split("`n")
            $LocalJson = ($ConfigObj | ConvertTo-Json -Depth 10).Split("`n")

            $Diffs = Compare-Object -ReferenceObject $CurrentJson -DifferenceObject $LocalJson
            if (@($Diffs).Count -eq 0) {
                Write-Output "No differences — local config matches server."
                return
            }
            Write-Output "=== Config Diff (server => local) ==="
            foreach ($d in $Diffs) {
                $Indicator = if ($d.SideIndicator -eq '=>') { '+' } else { '-' }
                Write-Output "$Indicator $($d.InputObject)"
            }
            Write-Output "=== End Diff ==="
            Write-Output ""
        }
        catch {
            Write-Warning "Could not fetch current config for diff: $($_.Exception.Message)"
        }
    }

    if (-not $PSCmdlet.ShouldProcess("$($Req.BaseUrl)/api/admin/config", 'Upload runtime config')) {
        return
    }

    $UploadParams = @{
        Uri         = "$($Req.BaseUrl)/api/admin/config"
        Method      = 'PUT'
        Headers     = $Req.Headers
        Body        = (@{ config = $ConfigObj } | ConvertTo-Json -Depth 10 -Compress)
        ContentType = 'application/json'
        ErrorAction = 'Stop'
    }

    try {
        Write-Verbose "PUT $($UploadParams.Uri)"
        $Response = Invoke-RestMethod @UploadParams
    }
    catch {
        $StatusCode = $null
        if ($_.Exception.PSObject.Properties['Response'] -and $_.Exception.Response) {
            $StatusCode = [int]$_.Exception.Response.StatusCode
        }

        $Msg = if ($StatusCode -eq 400) { 'Validation failed — server rejected the config' }
               elseif ($StatusCode -eq 401) { 'Unauthorized — check GITHUB_TOKEN and admin status' }
               elseif ($StatusCode -eq 403) { 'Forbidden — admin access required' }
               elseif ($StatusCode) { "HTTP $StatusCode" }
               else { $_.Exception.Message }

        throw (New-ActionableError `
            -Goal 'Upload runtime config to taxonomy-editor' `
            -Problem "Config upload error: $Msg" `
            -Location 'Set-TriadConfig' `
            -NextSteps @('Verify the config file passes schema validation',
                         'Check server logs for detailed validation errors',
                         "Endpoint: $($UploadParams.Uri)"))
    }

    Write-Output "Config uploaded successfully."

    if ($Response -and $Response.PSObject.Properties['errors'] -and @($Response.errors).Count -gt 0) {
        Write-Warning "Server reported validation warnings:"
        foreach ($err in $Response.errors) { Write-Warning " $err" }
    }

    if ($Reload) {
        Write-Verbose "Triggering config reload..."
        Invoke-TriadConfigReload -BaseUrl $Req.BaseUrl
    }
}