plugins/vault/private/Update-JaxVaultRepoConfig.ps1

function Update-JaxVaultRepoConfig {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string] $RepoRoot,
        [string] $VaultAddress,
        [string] $TokenDir,
        [string] $Policy,
        [string] $TokenTtl
    )

    $commonParams = Get-JaxCommonParameters -BoundParameters $PSBoundParameters

    if ([string]::IsNullOrWhiteSpace($RepoRoot)) {
        $RepoRoot = Get-JaxRepoRoot @commonParams
    }
    $repoRootResolved = (Resolve-Path -Path $RepoRoot).Path

    $configDir = Join-Path $repoRootResolved '.jax'
    if (-not (Test-Path -Path $configDir -PathType Container)) {
        New-Item -ItemType Directory -Path $configDir -Force | Out-Null
    }

    $configPath = Join-Path $configDir 'jax.config.yml'

    # IMPORTANT: avoid full YAML re-serialization here; it can reorder keys and can corrupt emojis/comments.
    # We'll do a minimal in-file update of only the vault-related lines and plugin enablement.

    $lines = @()
    $raw = $null
    $configWasMissing = -not (Test-Path -Path $configPath -PathType Leaf)
    if (-not $configWasMissing) {
        $raw = Get-Content -Path $configPath -Raw -ErrorAction Stop
        # Split on newlines robustly (regex), preserving indentation and avoiding accidental "single-line" parses.
        $lines = @($raw -split '\r?\n')
    } else {
        $lines = @(
            'jax:',
            ' plugins:',
            ' enabled: []',
            ' disabled: []',
            ' paths: []',
            ' config:',
            ' vault:',
            ' enabled: true'
        )
    }

    function Normalize-TokenDirValue {
        param([string] $value)
        if ([string]::IsNullOrWhiteSpace($value)) { return $null }
        $v = $value.Trim()
        $hasSeparators = $v.Contains('/') -or $v.Contains('\\')
        $looksLikeRelative = $v.StartsWith('.')
        if (-not $hasSeparators -and -not $looksLikeRelative -and -not [System.IO.Path]::IsPathRooted($v)) {
            # Shorthand like 'project-key' becomes '.jax/project-key/vault'.
            return (Join-Path '.jax' (Join-Path $v 'vault'))
        }
        return $v
    }

    function Normalize-TokenTtlValue {
        param([string] $value)
        if ([string]::IsNullOrWhiteSpace($value)) { return $null }
        $v = $value.Trim()
        if ($v -match '^\d+$') {
            # allow shorthand "87600" meaning hours -> "87600h"
            return "${v}h"
        }
        return $v
    }

    $TokenDir = Normalize-TokenDirValue $TokenDir
    $TokenTtl = Normalize-TokenTtlValue $TokenTtl
    if (-not [string]::IsNullOrWhiteSpace($VaultAddress)) {
        $VaultAddress = $VaultAddress.Trim()
    }
    if (-not [string]::IsNullOrWhiteSpace($Policy)) {
        $Policy = $Policy.Trim()
    }

    # Also support manually minimized or older `jax: {}` repositories. Expand
    # only the plugin contract required for Vault, not every internal default.
    if ($configWasMissing -or $raw -match '^\s*jax\s*:\s*\{\s*\}\s*$') {
        $effectiveConfig = Get-JaxConfig -RepoRoot $repoRootResolved
        $enabledPlugins = @($effectiveConfig.plugins.enabled)
        if ($enabledPlugins -notcontains 'vault') {
            $enabledPlugins += 'vault'
        }
        $disabledPlugins = @($effectiveConfig.plugins.disabled | Where-Object { $_ -ne 'vault' })

        $vaultConfig = [ordered]@{ enabled = $true }
        if (-not [string]::IsNullOrWhiteSpace($VaultAddress)) { $vaultConfig['address'] = $VaultAddress }
        if (-not [string]::IsNullOrWhiteSpace($TokenDir)) { $vaultConfig['tokenDir'] = $TokenDir }
        if (-not [string]::IsNullOrWhiteSpace($Policy)) { $vaultConfig['policy'] = $Policy }
        if (-not [string]::IsNullOrWhiteSpace($TokenTtl)) { $vaultConfig['tokenTtl'] = $TokenTtl }

        $payload = [ordered]@{
            jax = [ordered]@{
                plugins = [ordered]@{
                    enabled  = $enabledPlugins
                    disabled = $disabledPlugins
                    config   = [ordered]@{
                        vault = $vaultConfig
                    }
                }
            }
        }
        Write-JaxYaml -Path $configPath -InputObject $payload
        return $configPath
    }

    function Remove-DuplicateMapKeysInBlock {
        param(
            [string[]] $inputLines,
            [int] $startIdx,
            [int] $baseIndent
        )
        $seen = @{}
        $out = New-Object System.Collections.Generic.List[string]
        for ($i = 0; $i -lt $inputLines.Count; $i++) { $out.Add($inputLines[$i]) | Out-Null }

        $i = $startIdx + 1
        while ($i -lt $out.Count) {
            $line = $out[$i]
            if ($null -eq $line) { $i++; continue }
            if ($line.Trim().Length -eq 0) { $i++; continue }
            $indent = ($line -replace '^(\s*).+$', '$1').Length
            if ($indent -le $baseIndent) { break }

            # match "key: value" at next indent level
            $trimmed = $line.Trim()
            if ($trimmed -match '^([A-Za-z0-9_\\-]+)\\s*:') {
                $k = $Matches[1]
                if ($seen.ContainsKey($k)) {
                    $out.RemoveAt($i)
                    continue
                }
                $seen[$k] = $true
            }
            $i++
        }
        return ,$out
    }

    # If file already got corrupted with duplicate icon keys, fix it (keep first occurrence).
    for ($idx = 0; $idx -lt $lines.Count; $idx++) {
        $l = $lines[$idx]
        if ($l -match '^\s{4}clientIcons:\s*$') {
            $lines = Remove-DuplicateMapKeysInBlock -inputLines $lines -startIdx $idx -baseIndent 4
        }
        if ($l -match '^\s{4}flowIcons:\s*$') {
            $lines = Remove-DuplicateMapKeysInBlock -inputLines $lines -startIdx $idx -baseIndent 4
        }
    }

    # Enable vault plugin in plugins.enabled / remove from plugins.disabled
    function Ensure-ListContainsItem {
        param(
            [System.Collections.Generic.List[string]] $list,
            [int] $blockStartIdx,
            [int] $keyIndent,
            [string] $item
        )
        $itemLine = (' ' * ($keyIndent + 2)) + "- $item"
        $i = $blockStartIdx + 1
        $found = $false
        while ($i -lt $list.Count) {
            $line = $list[$i]
            $indent = ($line -replace '^(\s*).+$', '$1').Length
            if ($indent -le $keyIndent) { break }
            if ($line.Trim() -eq "- $item") { $found = $true }
            $i++
        }
        if (-not $found) {
            $list.Insert($i, $itemLine)
        }
    }

    function Remove-ItemFromListBlock {
        param(
            [System.Collections.Generic.List[string]] $list,
            [int] $blockStartIdx,
            [int] $keyIndent,
            [string] $item
        )
        $i = $blockStartIdx + 1
        while ($i -lt $list.Count) {
            $line = $list[$i]
            $indent = ($line -replace '^(\s*).+$', '$1').Length
            if ($indent -le $keyIndent) { break }
            if ($line.Trim() -eq "- $item") {
                $list.RemoveAt($i)
                continue
            }
            $i++
        }
    }

    $listLines = New-Object System.Collections.Generic.List[string]
    foreach ($l in $lines) { $listLines.Add($l) | Out-Null }

    $enabledIdx = -1
    $disabledIdx = -1
    for ($i = 0; $i -lt $listLines.Count; $i++) {
        if ($listLines[$i] -match '^\s{4}enabled:\s*$') { $enabledIdx = $i }
        if ($listLines[$i] -match '^\s{4}disabled:\s*$') { $disabledIdx = $i }
    }
    if ($enabledIdx -ge 0) { Ensure-ListContainsItem -list $listLines -blockStartIdx $enabledIdx -keyIndent 4 -item 'vault' }
    if ($disabledIdx -ge 0) { Remove-ItemFromListBlock -list $listLines -blockStartIdx $disabledIdx -keyIndent 4 -item 'vault' }

    # Update vault config block (jax.plugins.config.vault)
    $vaultIdx = -1
    for ($i = 0; $i -lt $listLines.Count; $i++) {
        if ($listLines[$i] -match '^\s{6}vault:\s*$') { $vaultIdx = $i; break }
    }
    if ($vaultIdx -lt 0) {
        # insert vault block after " config:" if present, else append a minimal block.
        $configIdx = -1
        for ($i = 0; $i -lt $listLines.Count; $i++) {
            if ($listLines[$i] -match '^\s{4}config:\s*$') { $configIdx = $i; break }
        }
        if ($configIdx -ge 0) {
            $vaultIdx = $configIdx + 1
            $listLines.Insert($vaultIdx, ' vault:')
            $listLines.Insert($vaultIdx + 1, ' enabled: true')
        } else {
            $vaultIdx = $listLines.Count
            $listLines.Add(' plugins:') | Out-Null
            $listLines.Add(' enabled:') | Out-Null
            $listLines.Add(' - vault') | Out-Null
            $listLines.Add(' config:') | Out-Null
            $listLines.Add(' vault:') | Out-Null
            $listLines.Add(' enabled: true') | Out-Null
        }
    }

    # Ensure enabled:true + address/tokenDir lines exist in vault block.
    $vaultBlockEnd = $vaultIdx + 1
    while ($vaultBlockEnd -lt $listLines.Count) {
        $line = $listLines[$vaultBlockEnd]
        $indent = ($line -replace '^(\s*).+$', '$1').Length
        if ($indent -le 6) { break }
        $vaultBlockEnd++
    }

    $hasEnabledLine = $false
    $hasAddressLine = $false
    $hasTokenDirLine = $false
    $hasPolicyLine = $false
    $hasTokenTtlLine = $false
    for ($i = $vaultIdx + 1; $i -lt $vaultBlockEnd; $i++) {
        $trimmed = $listLines[$i].Trim()
        if ($trimmed -match '^enabled\s*:') { $hasEnabledLine = $true; $listLines[$i] = ' enabled: true' }
        if ($trimmed -match '^address\s*:') { $hasAddressLine = $true; if (-not [string]::IsNullOrWhiteSpace($VaultAddress)) { $listLines[$i] = " address: $VaultAddress" } }
        if ($trimmed -match '^tokenDir\s*:') { $hasTokenDirLine = $true; if (-not [string]::IsNullOrWhiteSpace($TokenDir)) { $listLines[$i] = " tokenDir: $TokenDir" } }
        if ($trimmed -match '^policy\s*:') { $hasPolicyLine = $true; if (-not [string]::IsNullOrWhiteSpace($Policy)) { $listLines[$i] = " policy: $Policy" } }
        if ($trimmed -match '^tokenTtl\s*:') { $hasTokenTtlLine = $true; if (-not [string]::IsNullOrWhiteSpace($TokenTtl)) { $listLines[$i] = " tokenTtl: $TokenTtl" } }
    }
    $insertAt = $vaultIdx + 1
    if (-not $hasEnabledLine) { $listLines.Insert($insertAt, ' enabled: true'); $insertAt++ }
    if (-not $hasAddressLine -and -not [string]::IsNullOrWhiteSpace($VaultAddress)) { $listLines.Insert($insertAt, " address: $VaultAddress"); $insertAt++ }
    if (-not $hasTokenDirLine -and -not [string]::IsNullOrWhiteSpace($TokenDir)) { $listLines.Insert($insertAt, " tokenDir: $TokenDir"); $insertAt++ }
    if (-not $hasPolicyLine -and -not [string]::IsNullOrWhiteSpace($Policy)) { $listLines.Insert($insertAt, " policy: $Policy"); $insertAt++ }
    if (-not $hasTokenTtlLine -and -not [string]::IsNullOrWhiteSpace($TokenTtl)) { $listLines.Insert($insertAt, " tokenTtl: $TokenTtl"); $insertAt++ }

    # write back
    $outText = ($listLines -join "`n").TrimEnd() + "`n"
    Set-Content -Path $configPath -Value $outText -Encoding utf8
    return $configPath
}