Private/PivTool/Invoke-PWSHYBKPIVTool.ps1

function Invoke-PWSHYBKPIVTool {
    <#
    .SYNOPSIS
        Builds a yubico-piv-tool.exe argument list from config and BoundParameters, runs it,
        and returns its raw output.
    .DESCRIPTION
        The generic core shared by every public PivTool cmdlet. Looks up
        CmdletWrapping.actions.<Action> and, for every option name in its requiredOptions plus
        optionalOptions, checks BoundParameters (that cmdlet's $PSBoundParameters, dynamic
        parameters included) for a matching value:
        - Missing required options throw (defense in depth; the calling cmdlet's dynamic
          Mandatory parameter should already have enforced this).
        - switch-typed options add only their bare cliFlag, and only when the bound value is
          true.
        - secureString-typed options are converted to plain text immediately before being added
          to the argument list (see ConvertFrom-PWSHYBKPIVSecureString) and never logged.
        - Every other type adds cliFlag followed by the value's string form.

        Runs the exe via the call operator with stderr merged into stdout ("2>&1"), mirroring
        ADCSCertutil's certutil.exe invocation pattern, and reads $LASTEXITCODE for the result.
        yubico-piv-tool.exe writes routine status text - including success messages - to stderr
        as well as stdout, and PowerShell wraps stderr lines redirected via 2>&1 in ErrorRecord
        objects; every line is flattened to a plain string before being returned, so a line's
        origin stream never causes a successful run to be displayed or treated as an error. Only
        $exitCode determines success or failure: a non-zero exit code throws, including the
        tool's own output in the exception message. Output parsing into typed objects (where an
        action's output has predictable structure) is left to the calling public cmdlet, since
        yubico-piv-tool.exe's output shape varies too widely across actions (PEM blobs, free-text
        status, nothing at all) to generalize here.
    .PARAMETER ExePath
        Full path to yubico-piv-tool.exe.
    .PARAMETER Action
        The action name to look up in CmdletWrapping.actions, e.g. 'generate'.
    .PARAMETER CmdletWrapping
        The CmdletWrapping section of the parsed configuration (Read-PWSHYBKPIVConfigFile
        output's .cmdletWrapping property).
    .PARAMETER BoundParameters
        The calling cmdlet's $PSBoundParameters.
    .OUTPUTS
        PSCustomObject with ExitCode and Output properties.
    .EXAMPLE
        Invoke-PWSHYBKPIVTool -ExePath $exePath -Action 'status' -CmdletWrapping $config.cmdletWrapping -BoundParameters $PSBoundParameters
        Runs "yubico-piv-tool.exe --action status" (plus any bound optional flags) and returns its output.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory)]
        [string] $ExePath,

        [Parameter(Mandatory)]
        [string] $Action,

        [Parameter(Mandatory)]
        [PSCustomObject] $CmdletWrapping,

        [Parameter(Mandatory)]
        [hashtable] $BoundParameters
    )

    $actionConfig = $CmdletWrapping.actions.$Action
    if (-not $actionConfig) {
        throw "Unknown yubico-piv-tool action '$Action'."
    }

    $requiredNames = @($actionConfig.requiredOptions)
    $optionNames   = $requiredNames + @($actionConfig.optionalOptions)

    foreach ($requiredName in $requiredNames) {
        $requiredParamName = ConvertTo-PWSHYBKPIVParameterName -Name $requiredName
        if (-not $BoundParameters.ContainsKey($requiredParamName)) {
            throw "Action '$Action' requires the -$requiredParamName parameter."
        }
    }

    $argumentList = [System.Collections.Generic.List[string]]::new()
    $argumentList.Add('--action')
    $argumentList.Add($actionConfig.cliAction)

    foreach ($optionName in $optionNames) {
        $optionDef = $CmdletWrapping.options.$optionName
        if (-not $optionDef) { continue }

        $parameterName = ConvertTo-PWSHYBKPIVParameterName -Name $optionName
        if (-not $BoundParameters.ContainsKey($parameterName)) { continue }

        $value = $BoundParameters[$parameterName]

        switch ($optionDef.type) {
            'switch' {
                if ($value) { $argumentList.Add($optionDef.cliFlag) }
            }
            'secureString' {
                $plainText = ConvertFrom-PWSHYBKPIVSecureString -SecureString $value
                $argumentList.Add($optionDef.cliFlag)
                $argumentList.Add($plainText)
            }
            default {
                $argumentList.Add($optionDef.cliFlag)
                $argumentList.Add([string]$value)
            }
        }
    }

    $rawOutput = & $ExePath @argumentList 2>&1
    $exitCode = $LASTEXITCODE

    # yubico-piv-tool.exe writes routine status text (including success messages) to stderr as
    # well as stdout. Merging via 2>&1 wraps stderr lines in ErrorRecord objects; left as-is,
    # returning those objects as cmdlet output makes PowerShell display a successful run as an
    # error. Flatten everything to plain strings so success/failure is determined solely by
    # $exitCode, never by which stream a line came from.
    $output = @($rawOutput | ForEach-Object {
        if ($_ -is [System.Management.Automation.ErrorRecord]) { $_.ToString() } else { $_ }
    })

    if ($exitCode -ne 0) {
        throw "yubico-piv-tool.exe action '$Action' failed with exit code $exitCode. Output: $($output -join ' ')"
    }

    [PSCustomObject]@{
        ExitCode = $exitCode
        Output   = $output
    }
}