src/public/Execution/Invoke-AitherScript.ps1

#Requires -Version 7.0

<#
.SYNOPSIS
    Invoke automation scripts with runtime parameter discovery and validation
 
.DESCRIPTION
    Executes automation scripts from the automation-scripts directory with intelligent
    parameter discovery, validation, and help generation. Supports script numbers (e.g., 0501)
    or full script names.
 
    This cmdlet automatically discovers script parameters at runtime, validates them, and provides
    helpful error messages if parameters are missing or invalid. It also enables transcript logging
    by default to capture all script output for debugging and auditing purposes.
 
.PARAMETER Script
    Script identifier - can be a number (e.g., '0501') or script name pattern.
    This parameter is REQUIRED and identifies which automation script to execute.
 
    Examples:
    - "0501" - Executes script starting with 0501
    - "system-config" - Executes script with "system-config" in the name
    - You can also pipe script objects from Get-AitherScript
 
    If the script is not found, an error will be thrown with suggestions for similar scripts.
 
.PARAMETER Arguments
    Arguments to pass to the script. Can be provided in multiple formats:
    - Hashtable: @{ ParameterName = 'Value'; AnotherParam = 123 }
    - String: "-Parameter1 Value1 -Parameter2 Value2"
    - Array: @('-Parameter1', 'Value1', '-Parameter2', 'Value2')
 
    The cmdlet automatically parses these formats and passes them to the script.
    Use Get-AitherScript -Script <number> -ShowParameters to see what parameters a script accepts.
 
.PARAMETER Parameters
    Alias for Arguments parameter. Use whichever name is more intuitive for you.
 
.PARAMETER OutputPath
    Path for script output (if script supports it). This is automatically added to the script's
    parameters. Useful for redirecting script output to a specific location.
 
    Example: "C:\Reports\output.txt" or "/home/user/reports/output.txt"
 
.PARAMETER ShowHelp
    Show parameter help for the script without executing. This is useful to understand what
    parameters a script accepts before running it. Displays all parameters with their types,
    mandatory status, and descriptions.
 
.PARAMETER ListParameters
    List all available parameters for the script. Similar to ShowHelp but in a more compact format.
 
.PARAMETER DryRun
    Show what would be executed without actually running the script. Displays the script path
    and all parameters that would be passed. Use this to verify your command before execution.
 
.PARAMETER Transcript
    Enable transcript logging for this script execution. Default is $true (enabled).
    Transcripts capture all console output and are saved to the logs directory with a timestamp.
    Set to $false to disable transcript logging.
 
.PARAMETER TranscriptPath
    Custom path for transcript log. If not specified, transcripts are saved to the logs directory
    with an automatically generated filename based on the script name and timestamp.
 
.INPUTS
    System.String
    You can pipe script identifiers to Invoke-AitherScript.
 
    PSCustomObject
    You can pipe script objects from Get-AitherScript to Invoke-AitherScript.
 
.OUTPUTS
    The output depends on what the script returns. Most scripts return objects or write to console.
 
.EXAMPLE
    Invoke-AitherScript -Script 0501
 
    Executes script 0501 with default parameters and transcript logging enabled.
 
.EXAMPLE
    Invoke-AitherScript -Script 0501 -Arguments @{ OutputFormat = 'Detailed' }
 
    Executes script 0501 with OutputFormat parameter set to 'Detailed'.
 
.EXAMPLE
    Invoke-AitherScript -Script 0501 -Arguments "-OutputFormat Detailed -AsJson"
 
    Executes script 0501 with multiple parameters provided as a string.
 
.EXAMPLE
    Invoke-AitherScript -Script 0501 -ShowHelp
 
    Shows help for script 0501 without executing it.
 
.EXAMPLE
    Invoke-AitherScript -Script 0501 -OutputPath "C:\my home\dir with spaces\"
 
    Executes script 0501 and passes OutputPath parameter with a path containing spaces.
 
.EXAMPLE
    Get-AitherScript -Script 0501 | Invoke-AitherScript -Arguments @{ Verbose = $true }
 
    Pipes script information from Get-AitherScript to Invoke-AitherScript.
 
.EXAMPLE
    '0501', '0502' | Invoke-AitherScript
 
    Executes multiple scripts by piping script identifiers.
 
.EXAMPLE
    Invoke-AitherScript -Script 0501 -DryRun
 
    Shows what would be executed without actually running the script.
 
.NOTES
    Scripts are located in library/automation-scripts/ directory.
    Each script has its own transcript logging enabled by default for debugging and auditing.
 
    If a script fails, check the transcript log in the logs directory for detailed error information.
    Transcript logs are named: transcript-<scriptname>-<timestamp>.log
 
.LINK
    Get-AitherScript
    Get-AitherExecutionHistory
#>

function Invoke-AitherScript {
    [OutputType([System.Object])]
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory = $false, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName, HelpMessage = "The script identifier (number or name) to execute.")]
        [Alias('ScriptNumber', 'Number', 'Id')]
        [AllowEmptyString()]
        [ArgumentCompleter({
                param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)

                if (Get-Command Get-AitherScript -ErrorAction SilentlyContinue) {
                    Get-AitherScript |
                    Where-Object { $_.Name -like "*$wordToComplete*" -or $_.Number -like "$wordToComplete*" } |
                    ForEach-Object {
                        [System.Management.Automation.CompletionResult]::new(
                            $_.Number,
                            $_.Name,
                            [System.Management.Automation.CompletionResultType]::ParameterValue,
                            $_.Name
                        )
                    }
                }
            })]
        [object]$Script,

        [Parameter(HelpMessage = "Arguments to pass to the script (Hashtable, String, or Array).")]
        [Alias('Parameters')]
        [object]$Arguments,

        [Parameter(HelpMessage = "Condition to evaluate before running (Hashtable). Keys: Path, Command, ScriptBlock, Negate.")]
        [Alias('If')]
        [object]$Condition,

        [Parameter(HelpMessage = "Path to redirect script output.")]
        [string]$OutputPath,

        [Parameter(HelpMessage = "Show parameter help for the script without executing.")]
        [switch]$ShowHelp,

        [Parameter(HelpMessage = "List all available parameters for the script.")]
        [switch]$ListParameters,

        [Parameter(HelpMessage = "Show what would be executed without actually running the script.")]
        [switch]$DryRun,

        [Parameter(HelpMessage = "Run scripts in parallel.")]
        [switch]$Parallel,

        [Parameter(HelpMessage = "Maximum number of concurrent scripts.")]
        [int]$ThrottleLimit = 5,

        [Parameter(HelpMessage = "Enable transcript logging.")]
        [bool]$Transcript = $true,

        [Parameter(HelpMessage = "Custom path for transcript log.")]
        [string]$TranscriptPath,

        [Parameter(HelpMessage = "Show script output in console.")]
        [switch]$ShowOutput,

        [Parameter(HelpMessage = "Display the transcript content after execution.")]
        [switch]$ShowTranscript
    )

    begin {
        # Manage logging targets for this execution
        $originalLogTargets = $script:AitherLogTargets
        if ($ShowOutput) {
            if ($script:AitherLogTargets -notcontains 'Console') {
                $script:AitherLogTargets += 'Console'
            }
        }
        else {
            # Ensure Console is NOT in targets if ShowOutput is not specified
            $script:AitherLogTargets = $script:AitherLogTargets | Where-Object { $_ -ne 'Console' }
        }

        # Get scripts directory using robust discovery
        try {
            $scriptsPath = Get-AitherScriptsPath
            Write-Verbose "Using scripts path: $scriptsPath"
        }
        catch {
            Write-AitherLog -Level Warning -Message "Could not resolve scripts path using Get-AitherScriptsPath: $($_.Exception.Message)" -Source 'Invoke-AitherScript'
            # Fallback for extreme cases
            $scriptsPath = Join-Path $PSScriptRoot "library/automation-scripts"
        }

        Write-Verbose "ScriptsPath resolved to: $scriptsPath"

        if (-not (Test-Path $scriptsPath)) {
            Write-AitherLog -Level Error -Message "Scripts path does not exist: $scriptsPath" -Source 'Invoke-AitherScript'
            throw "Scripts path does not exist: $scriptsPath"
        }

        # Use shared helper function

        function Get-ScriptParameters {
            param([string]$ScriptPath)

            try {
                $ast = [System.Management.Automation.Language.Parser]::ParseFile(
                    $ScriptPath,
                    [ref]$null,
                    [ref]$null
                )

                $params = @{}
                $ast.FindAll({ $args[0] -is [System.Management.Automation.Language.ParameterAst] }, $true) |
                ForEach-Object {
                    $paramName = $_.Name.VariablePath.UserPath
                    # StrictMode-safe Mandatory extraction. The previous one-liner
                    # (`.Attributes.Where({…}).Attributes.Where({…}).Argument.Value`)
                    # chained `.Attributes` onto a collection, which throws a
                    # property-not-found TERMINATING error under Set-StrictMode -Latest.
                    # That error propagated out of the ForEach-Object and was caught by
                    # the function-level catch, making Get-ScriptParameters return an
                    # EMPTY map for EVERY script — which in turn disabled the param
                    # filter below (playbook phases then failed with "a parameter cannot
                    # be found that matches parameter name 'App'").
                    $paramMandatory = $false
                    try {
                        foreach ($attr in @($_.Attributes)) {
                            if ($attr.TypeName -and $attr.TypeName.Name -eq 'Parameter' -and $attr.NamedArguments) {
                                foreach ($na in @($attr.NamedArguments)) {
                                    if ($na.ArgumentName -eq 'Mandatory') {
                                        $paramMandatory = ($na.Argument.Extent.Text -match '\$true')
                                    }
                                }
                            }
                        }
                    }
                    catch { $paramMandatory = $false }
                    $paramInfo = @{
                        Name      = $paramName
                        # ParameterAst has NO .TypeName property (that access threw
                        # "property 'TypeName' cannot be found" under StrictMode and made
                        # this whole parse fail). The resolved type is .StaticType.
                        Type      = if ($_.StaticType) { $_.StaticType.FullName } else { 'object' }
                        Mandatory = $paramMandatory
                        Help      = ''
                    }

                    # Try to get help from comment-based help
                    $help = $null
                    try {
                        $commentAstType = [System.Management.Automation.Language.CommentAst]
                        $help = $ast.FindAll({ param($node) $node -is $commentAstType }, $true) |
                        Where-Object { $_.Text -match "\.PARAMETER\s+$paramName" } |
                        Select-Object -First 1
                    }
                    catch {
                        # Fallback: skip help extraction if AST type not available
                        $help = $null
                    }

                    if ($help) {
                        $lines = $help.Text -split "`n"
                        $inParam = $false
                        $helpText = @()
                        foreach ($line in $lines) {
                            if ($line -match "\.PARAMETER\s+$paramName") {
                                $inParam = $true
                                continue
                            }
                            if ($inParam -and $line -match '^\s*\.') {
                                break
                            }
                            if ($inParam) {
                                $helpText += $line.Trim()
                            }
                        }
                        $paramInfo.Help = ($helpText -join ' ').Trim()
                    }

                    $params[$paramName] = $paramInfo
                }
                return $params
            }
            catch {
                Write-AitherLog -Level Warning -Message "Failed to parse script parameters: $_" -Source 'Invoke-AitherScript'
                return @{}
            }
        }

        function Format-Arguments {
            param(
                [object]$Arguments,
                [hashtable]$ScriptParams
            )

            $paramHash = @{}
            if ($null -eq $Arguments) {
                return $paramHash
            }

            # Handle hashtable
            if ($Arguments -is [hashtable]) {
                # Filter to only parameters the target script actually DECLARES.
                # The playbook engine merges ALL playbook variables (App, Node,
                # PlanOnly, ...) into every script's param bag; splatting an
                # undeclared key onto `& $scriptPath @paramHash` throws
                # "A parameter cannot be found that matches parameter name 'App'".
                # When we know the declared params (AST parse succeeded), drop the
                # extras. If AST parse returned nothing, pass through unchanged so
                # we never accidentally strip everything.
                if ($ScriptParams -and $ScriptParams.Keys.Count -gt 0) {
                    $filtered = @{}
                    foreach ($k in $Arguments.Keys) {
                        if ($ScriptParams.ContainsKey($k)) {
                            $filtered[$k] = $Arguments[$k]
                        }
                    }
                    return $filtered
                }
                return $Arguments
            }

            # Handle string arguments
            if ($Arguments -is [string]) {
                # Parse string like "-Parameter1 Value1 -Parameter2 Value2"
                $parts = $Arguments -split '\s+(?=-)' | Where-Object { $_ }
                for ($i = 0; $i -lt $parts.Length; $i++) {
                    if ($parts[$i] -match '^-(\w+)') {
                        $paramName = $matches[1]
                        $paramValue = $null

                        # Check if value is in the same part (e.g., "-Path ./src")
                        # Regex replace to remove -ParamName and leading spaces
                        $rest = $parts[$i] -replace "^-$paramName\s*", ""

                        if (-not [string]::IsNullOrWhiteSpace($rest)) {
                            $paramValue = $rest.Trim()
                            # Strip surrounding quotes if present
                            if ($paramValue -match "^['`"](.*)['`"]$") {
                                $paramValue = $matches[1]
                            }
                        }
                        elseif ($i + 1 -lt $parts.Length -and $parts[$i + 1] -notmatch '^-') {
                            $paramValue = $parts[$i + 1]
                            $i++
                        }
                        else {
                            $paramValue = $true  # Switch parameter
                        }

                        $paramHash[$paramName] = $paramValue
                    }
                }
                return $paramHash
            }

            # Handle array
            if ($Arguments -is [array]) {
                for ($i = 0; $i -lt $Arguments.Length; $i++) {
                    if ($Arguments[$i] -match '^-(\w+)') {
                        $paramName = $matches[1]
                        $paramValue = $null

                        if ($i + 1 -lt $Arguments.Length -and $Arguments[$i + 1] -notmatch '^-') {
                            $paramValue = $Arguments[$i + 1]
                            $i++
                        }
                        else {
                            $paramValue = $true
                        }

                        $paramHash[$paramName] = $paramValue
                    }
                }
                return $paramHash
            }

            return $paramHash
        }
    }

    process {
        # Manage logging targets for this execution
        $originalLogTargets = $script:AitherLogTargets
        if ($ShowOutput) {
            if ($script:AitherLogTargets -notcontains 'Console') {
                $script:AitherLogTargets += 'Console'
            }
        }
        else {
            # Ensure Console is NOT in targets if ShowOutput is not specified
            # This enforces "no output by default" even if global default changes
            $script:AitherLogTargets = $script:AitherLogTargets | Where-Object { $_ -ne 'Console' }
        }

        try {
            # During module validation, Script may be empty - skip validation
            if ($PSCmdlet.MyInvocation.InvocationName -eq '.' -and [string]::IsNullOrWhiteSpace($Script)) {
                return
            }

            # Handle piped script objects or multiple scripts
            $scriptsToRun = @()
            if ($Script -is [PSCustomObject] -and $Script.Path) {
                $scriptsToRun += @{
                    Path = $Script.Path
                    Id   = if ($Script.Number) { $Script.Number } else { $Script.Name }
                }
            }
            elseif ($Script -is [array]) {
                foreach ($s in $Script) {
                    $scriptFile = Find-AitherScriptFile -ScriptId $s -ScriptsPath $scriptsPath -ThrowOnNotFound
                    $scriptsToRun += @{
                        Path = $scriptFile.FullName
                        Id   = $s
                    }
                }
            }
            else {
                # Find script file
                $scriptFile = Find-AitherScriptFile -ScriptId $Script -ScriptsPath $scriptsPath -ThrowOnNotFound
                $scriptsToRun += @{
                    Path = $scriptFile.FullName
                    Id   = $Script
                }
            }

            # Parallel Execution
            if ($Parallel -and $scriptsToRun.Count -gt 1) {
                Write-ScriptLog "Executing $($scriptsToRun.Count) scripts in parallel (ThrottleLimit: $ThrottleLimit)"

                $scriptsToRun | ForEach-Object -Parallel {
                    $scriptInfo = $_
                    $scriptPath = $scriptInfo.Path
                    $scriptId = $scriptInfo.Id

                    # Re-import module in parallel runspace if needed, or rely on scope
                    # Note: Functions defined in 'begin' block are NOT available here automatically
                    # We need to duplicate logic or use a shared module

                    # Simple execution for now
                    Write-AitherLog -Level Information -Message "Starting parallel execution: $scriptId" -Source 'Invoke-AitherScript'
                    try {
                        & $scriptPath
                    }
                    catch {
                        Write-AitherLog -Level Error -Message "Failed executing $scriptId : $_" -Source 'Invoke-AitherScript' -Exception $_
                        throw
                    }
                } -ThrottleLimit $ThrottleLimit

                return
            }

            foreach ($scriptItem in $scriptsToRun) {
                $scriptPath = $scriptItem.Path
                $scriptId = $scriptItem.Id
                Write-ScriptLog "Found script: $scriptPath"

                # Get script parameters
                $scriptParams = Get-ScriptParameters -ScriptPath $scriptPath

                # Handle help requests
                if ($ShowHelp -or $ListParameters) {
                    Write-AitherLog -Level Information -Message "Script: $scriptId" -Source 'Invoke-AitherScript'
                    Write-AitherLog -Level Information -Message "Path: $scriptPath" -Source 'Invoke-AitherScript'

                    if ($scriptParams.Count -eq 0) {
                        Write-AitherLog -Level Warning -Message "No parameters found or script could not be parsed." -Source 'Invoke-AitherScript'
                        continue
                    }

                    Write-AitherLog -Level Information -Message "Available Parameters:" -Source 'Invoke-AitherScript'
                    Write-AitherLog -Level Information -Message ("=" * 50) -Source 'Invoke-AitherScript'

                    foreach ($param in $scriptParams.Values | Sort-Object Name) {
                        $mandatory = if ($param.Mandatory) { "[MANDATORY] " } else { "" }
                        $paramLine = "-$($param.Name) $mandatory($($param.Type))"
                        Write-AitherLog -Level Information -Message $paramLine -Source 'Invoke-AitherScript'

                        if ($param.Help) {
                            Write-AitherLog -Level Information -Message " $($param.Help)" -Source 'Invoke-AitherScript'
                        }
                    }

                    continue
                }

                # Format arguments
                $argsToUse = if ($Arguments) { $Arguments } else { $Parameters }
                $paramHash = Format-Arguments -Arguments $argsToUse -ScriptParams $scriptParams

                # Drop any argument the target script does NOT declare. The playbook
                # runner merges the whole shared variable bag (App/Node/PlanOnly/…) into
                # every phase's parameters; splatting an undeclared key to `& $script`
                # throws "a parameter cannot be found that matches parameter name '…'".
                # Source the declared-parameter set from PowerShell's OWN command
                # metadata ((Get-Command <script>).Parameters) — authoritative and
                # StrictMode-safe — rather than the custom AST walker, which can return
                # an empty map and silently disable this filter.
                $declaredKeys = $null
                try {
                    $cmdInfo = Get-Command -Name $scriptPath -CommandType ExternalScript -ErrorAction Stop
                    if ($cmdInfo -and $cmdInfo.Parameters) { $declaredKeys = @($cmdInfo.Parameters.Keys) }
                }
                catch { $declaredKeys = $null }
                if ((-not $declaredKeys -or $declaredKeys.Count -eq 0) -and $scriptParams -and $scriptParams.Count -gt 0) {
                    $declaredKeys = @($scriptParams.Keys)
                }
                if ($declaredKeys -and $declaredKeys.Count -gt 0) {
                    foreach ($__k in @($paramHash.Keys)) {
                        if ($declaredKeys -notcontains $__k) { $paramHash.Remove($__k) }
                    }
                }

                # Add OutputPath if specified
                if ($OutputPath) {
                    $paramHash['OutputPath'] = $OutputPath
                }

                # Add Configuration if Get-AitherConfigs is available AND script accepts it
                if ($scriptParams.ContainsKey('Configuration') -and (Get-Command Get-AitherConfigs -ErrorAction SilentlyContinue)) {
                    $config = Get-AitherConfigs
                    $paramHash['Configuration'] = $config
                }

                # Check Condition (Idempotency)
                if ($Condition) {
                    Write-ScriptLog "Evaluating execution condition..."

                    if (Get-Command Test-AitherCondition -ErrorAction SilentlyContinue) {
                        # Flatten hashtable for splatting to Test-AitherCondition
                        # Note: Test-AitherCondition accepts Path, Command, ScriptBlock, Negate
                        $conditionResult = $true
                        try {
                            $conditionResult = Test-AitherCondition @Condition
                        }
                        catch {
                            Write-AitherLog -Level Warning -Message "Failed to evaluate condition: $_" -Source 'Invoke-AitherScript' -Exception $_
                            # Default to running if check fails, or fail safely?
                            # Safer to fail or run? Let's assume run if check fails is risky, but failing stops workflow.
                            # For now, treat error as 'False' (don't run) or just log.
                            # Let's treat as FALSE to prevent destructive actions if check is bad.
                            $conditionResult = $false
                        }

                        if (-not $conditionResult) {
                            Write-AitherLog -Level Warning -Message "Condition met (Result: False). Skipping script execution." -Source 'Invoke-AitherScript'
                            Write-AitherLog -Level Information -Message "Skipping $scriptId (Condition not met)" -Source 'Invoke-AitherScript'
                            continue
                        }
                    }
                    else {
                        Write-AitherLog -Level Warning -Message "Test-AitherCondition cmdlet not found. Skipping condition check." -Source 'Invoke-AitherScript'
                    }
                }

                # Dry run
                if ($DryRun) {
                    Write-AitherLog -Level Information -Message "[DRY RUN] Would execute:" -Source 'Invoke-AitherScript'
                    Write-AitherLog -Level Information -Message " Script: $scriptPath" -Source 'Invoke-AitherScript'
                    Write-AitherLog -Level Information -Message " Parameters:" -Source 'Invoke-AitherScript'
                    $paramHash.GetEnumerator() | ForEach-Object {
                        Write-AitherLog -Level Information -Message " -$($_.Key): $($_.Value)" -Source 'Invoke-AitherScript'
                    }

                    # If the script implements its OWN dry-run, run it that way so the
                    # path is actually exercised. Logging "would execute" proves only
                    # that we found the file -- it can never catch a broken -DryRun
                    # branch, which is precisely what a migration rehearsal must catch.
                    #
                    # Capability-gated on purpose: a script that does NOT declare
                    # -DryRun is never invoked here, because "run it and hope it is
                    # read-only" is how a rehearsal becomes a destructive action.
                    $supportsDryRun = $false
                    try {
                        $scriptCmd = Get-Command -Name $scriptPath -CommandType ExternalScript -ErrorAction Stop
                        $supportsDryRun = $scriptCmd.Parameters.ContainsKey('DryRun')
                    }
                    catch {
                        Write-AitherLog -Level Warning -Message " Could not inspect parameters of $scriptPath : $($_.Exception.Message)" -Source 'Invoke-AitherScript'
                    }

                    if ($supportsDryRun) {
                        Write-AitherLog -Level Information -Message " Script declares -DryRun; executing its dry-run path" -Source 'Invoke-AitherScript'
                        $dryParams = @{} + $paramHash
                        $dryParams['DryRun'] = $true
                        & $scriptPath @dryParams
                    }
                    else {
                        Write-AitherLog -Level Information -Message " Script does not declare -DryRun; listed only (not executed)" -Source 'Invoke-AitherScript'
                    }
                    continue
                }

                # Start transcript if enabled
                $transcriptStarted = $false
                if ($Transcript) {
                    try {
                        if (-not $TranscriptPath) {
                            $logsDir = Join-Path $moduleRoot 'logs'
                            if (-not (Test-Path $logsDir)) {
                                New-Item -ItemType Directory -Path $logsDir -Force | Out-Null
                            }
                            $scriptName = [System.IO.Path]::GetFileNameWithoutExtension($scriptPath)
                            $currentTranscriptPath = Join-Path $logsDir "transcript-${scriptName}-$(Get-Date -Format 'yyyy-MM-dd-HHmmss').log"
                        }
                        else {
                            $currentTranscriptPath = $TranscriptPath
                        }

                        # Try to stop any existing transcript first, ignoring errors if none is running
                        try { Stop-Transcript -ErrorAction Stop | Out-Null } catch { }

                        Start-Transcript -Path $currentTranscriptPath -Append -IncludeInvocationHeader | Out-Null
                        $transcriptStarted = $true
                        Write-AitherLog -Level Information -Message "Transcript logging enabled: $currentTranscriptPath" -Source 'Invoke-AitherScript'
                    }
                    catch {
                        Write-AitherLog -Level Warning -Message "Failed to start transcript: $_" -Source 'Invoke-AitherScript' -Exception $_
                    }
                }

                # Execute script
                try {
                    Write-ScriptLog "Executing script: $scriptId"

                    if ($PSCmdlet.ShouldProcess($scriptPath, "Execute script")) {
                        & $scriptPath @paramHash
                        # NOTE: a script's `exit N` is deliberately NOT converted to a
                        # failure here. $LASTEXITCODE cannot distinguish a script's own
                        # `exit N` from a benign native command inside the script setting
                        # a non-zero code (native-bleed), so throwing on it caused false
                        # failures for unrelated playbooks. Real errors throw and are
                        # caught below; phases that must hard-stop rely on that + their
                        # own fail-closed guards (e.g. 0312 refuses to apply if 0107 did
                        # not produce the cert files).
                    }
                }
                finally {
                    if ($transcriptStarted) {
                        try {
                            Stop-Transcript | Out-Null
                        }
                        catch {
                            # Ignore errors stopping transcript
                        }

                        if ($ShowTranscript) {
                            Write-AitherLog -Level Information -Message "--- Transcript: $currentTranscriptPath ---" -Source 'Invoke-AitherScript'
                            if (Test-Path $currentTranscriptPath) {
                                Get-Content $currentTranscriptPath | ForEach-Object { Write-AitherLog -Level Information -Message $_ -Source 'Invoke-AitherScript' }
                            }
                            Write-AitherLog -Level Information -Message "--- End Transcript ---" -Source 'Invoke-AitherScript'
                        }
                    }
                }
            }
        }
        catch {
            Invoke-AitherErrorHandler -ErrorRecord $_ -Operation "Invoking script: $Script" -Parameters $PSBoundParameters -ThrowOnError
        }
        finally {
            # Restore original log targets
            $script:AitherLogTargets = $originalLogTargets
        }
    }


}