Private.ps1
|
function Add-KrAspNetCoreType { [CmdletBinding()] [OutputType([bool])] param ( [Parameter()] [ValidateSet('net8.0', 'net9.0', 'net10.0')] [string]$Version = 'net8.0' ) $versionNumber = $Version -replace 'net(\d+).*', '$1' $dotnetPath = (Get-Command -Name 'dotnet' -ErrorAction Stop).Source $realDotnetPath = (Get-Item $dotnetPath).Target if (-not $realDotnetPath) { $realDotnetPath = $dotnetPath }elseif ($realDotnetPath -notmatch '^/') { # If the target is a relative path, resolve it from the parent of $dotnetPath (e.g. symlink in same folder) $realDotnetPath = Join-Path -Path (Split-Path -Path $dotnetPath -Parent) -ChildPath $realDotnetPath $realDotnetPath = [System.IO.Path]::GetFullPath($realDotnetPath) } $dotnetDir = Split-Path -Path $realDotnetPath -Parent if (-not $dotnetDir) { throw 'Could not determine the path to the dotnet executable.' } $baseDir = Join-Path -Path $dotnetDir -ChildPath 'shared\Microsoft.AspNetCore.App' if (-not (Test-Path -Path $baseDir -PathType Container)) { throw "ASP.NET Core shared framework not found at $baseDir." } $versionDirs = Get-ChildItem -Path $baseDir -Directory | Where-Object { $_.Name -like "$($versionNumber).*" } | Sort-Object Name -Descending # Try each version directory until we find one with all required assemblies $verDir = $versionDirs | Select-Object -First 1 if (-not $verDir) { Write-Error "Could not find ASP.NET Core assemblies for version $Version in $baseDir." Write-Warning "Please download the Runtime $Version from https://dotnet.microsoft.com/en-us/download/dotnet/$versionNumber.0" throw "Microsoft.AspNetCore.App version $Version not found in $baseDir." } # Collect required assemblies $assemblies = @() Get-ChildItem -Path $verDir.FullName -Filter '*.dll' | Where-Object { $_.Name -like 'Microsoft.*.dll' -or $_.Name -eq 'System.IO.Pipelines.dll' } | ForEach-Object { if ($assemblies -notcontains $_.Name) { $assemblies += $_.Name } } Write-Verbose "Collected assemblies: $($assemblies -join ', ')" # Check if all required assemblies are present $allFound = $true foreach ($asm in $assemblies) { $asmPath = Join-Path -Path $verDir.FullName -ChildPath $asm if (-not (Test-Path -Path $asmPath)) { Write-Verbose "Assembly $asm not found in $($verDir.FullName)" $allFound = $false break } } if (-not $allFound) { return $false } # Load all assemblies $result = $true foreach ($asm in $assemblies) { $asmPath = Join-Path -Path $verDir.FullName -ChildPath $asm $result = $result -and (Assert-KrAssemblyLoaded -AssemblyPath $asmPath) } Write-Verbose "Loaded ASP.NET Core assemblies from $($verDir.FullName)" return $result } function Add-KrCodeAnalysisType { [CmdletBinding()] [OutputType([bool])] param ( [Parameter(Mandatory = $true)] [string]$ModuleRootPath, [Parameter(Mandatory = $true)] [string]$Version ) $codeAnalysisassemblyLoadPath = Join-Path -Path $ModuleRootPath -ChildPath 'lib' -AdditionalChildPath 'Microsoft.CodeAnalysis', $Version return( (Assert-KrAssemblyLoaded -AssemblyPath (Join-Path -Path "$codeAnalysisassemblyLoadPath" -ChildPath 'Microsoft.CodeAnalysis.dll')) -and (Assert-KrAssemblyLoaded -AssemblyPath (Join-Path -Path "$codeAnalysisassemblyLoadPath" -ChildPath 'Microsoft.CodeAnalysis.Workspaces.dll')) -and (Assert-KrAssemblyLoaded -AssemblyPath (Join-Path -Path "$codeAnalysisassemblyLoadPath" -ChildPath 'Microsoft.CodeAnalysis.CSharp.dll')) -and (Assert-KrAssemblyLoaded -AssemblyPath (Join-Path -Path "$codeAnalysisassemblyLoadPath" -ChildPath 'Microsoft.CodeAnalysis.CSharp.Scripting.dll')) -and #Assert-KrAssemblyLoaded -AssemblyPath (Join-Path -Path "$codeAnalysisassemblyLoadPath" -ChildPath "Microsoft.CodeAnalysis.Razor.dll") (Assert-KrAssemblyLoaded -AssemblyPath (Join-Path -Path "$codeAnalysisassemblyLoadPath" -ChildPath 'Microsoft.CodeAnalysis.VisualBasic.dll')) -and (Assert-KrAssemblyLoaded -AssemblyPath (Join-Path -Path "$codeAnalysisassemblyLoadPath" -ChildPath 'Microsoft.CodeAnalysis.VisualBasic.Workspaces.dll')) -and (Assert-KrAssemblyLoaded -AssemblyPath (Join-Path -Path "$codeAnalysisassemblyLoadPath" -ChildPath 'Microsoft.CodeAnalysis.CSharp.Workspaces.dll')) -and (Assert-KrAssemblyLoaded -AssemblyPath (Join-Path -Path "$codeAnalysisassemblyLoadPath" -ChildPath 'Microsoft.CodeAnalysis.Scripting.dll')) ) } function Assert-KrAssemblyLoaded { [CmdletBinding()] [OutputType([bool])] param( [Parameter(Mandatory)] [string]$AssemblyPath, # Default = require it to be loadable from the Default ALC (fixes VSCode/PSES split-load issue) [System.Runtime.Loader.AssemblyLoadContext]$LoadContext = [System.Runtime.Loader.AssemblyLoadContext]::Default ) if (-not (Test-Path -LiteralPath $AssemblyPath -PathType Leaf)) { throw "Assembly not found at path: $AssemblyPath" } $full = (Resolve-Path -LiteralPath $AssemblyPath).Path $asmName = [System.Reflection.AssemblyName]::GetAssemblyName($full) # Find an already-loaded assembly with same simple name *in the desired ALC* $loadedInContext = [AppDomain]::CurrentDomain.GetAssemblies() | Where-Object { $_.GetName().Name -eq $asmName.Name -and ([System.Runtime.Loader.AssemblyLoadContext]::GetLoadContext($_) -eq $LoadContext) } | Select-Object -First 1 if ($loadedInContext) { Write-Verbose ('Assembly already loaded in {0} ALC: {1} {2} from {3}' -f ` ($LoadContext.Name ?? 'Default'), $loadedInContext.GetName().Name, $loadedInContext.GetName().Version, $loadedInContext.Location) return $true } Write-Verbose ('Loading assembly into {0} ALC: {1}' -f ($LoadContext.Name ?? 'Default'), $full) try { # Load into the requested context (Default by default) [void]$LoadContext.LoadFromAssemblyPath($full) return $true } catch { Write-Error "Failed to load assembly: $full" Write-Error $_ return $false } } function Get-EntryScriptPath { [CmdletBinding()] [OutputType([string])] param() $self = $PSCommandPath ? (Resolve-Path -LiteralPath $PSCommandPath).ProviderPath : $null # Take the last real script caller in the stack $stack = @(Get-PSCallStack) [System.Array]::Reverse($stack) foreach ($f in $stack) { $p = $f.InvocationInfo.ScriptName if (-not $p) { continue } try { $resolved = (Resolve-Path -LiteralPath $p -ErrorAction Stop).ProviderPath } catch { Write-Debug "Failed to resolve path '$p': $_" continue } if ($resolved -and $resolved -ne $self) { return $resolved } } return $null } function Get-KrCommandsByContext { [CmdletBinding(DefaultParameterSetName = 'AnyOf')] param( [Parameter(Mandatory, ParameterSetName = 'AnyOf')] [ValidateSet('Definition', 'Route', 'Schedule', 'ScheduleAndDefinition', 'Runtime', 'Everywhere')] [string[]]$AnyOf, [Parameter(Mandatory, ParameterSetName = 'AllOf')] [ValidateSet('Definition', 'Route', 'Schedule')] [string[]]$AllOf, [ValidateSet('Definition', 'Route', 'Schedule', 'ScheduleAndDefinition', 'Runtime', 'Everywhere')] [string[]]$Not, [string]$Module = 'Kestrun', [switch]$IncludeNonExported, [switch]$Exact, [object[]]$Functions ) function _KrNameToMask { param([Parameter(Mandatory)][string]$Name) switch ($Name) { 'Definition' { 1 } 'Route' { 2 } 'Schedule' { 4 } 'ScheduleAndDefinition' { 5 } 'Runtime' { 6 } 'Everywhere' { 7 } default { throw "Unknown context '$Name'." } } } $cmds = if ($Functions) { $Functions } else { if ($IncludeNonExported) { Get-Command -Module $Module -All } else { Get-Command -Module $Module } } $target = 0 if ($PSCmdlet.ParameterSetName -eq 'AnyOf') { foreach ($n in $AnyOf) { $target = $target -bor (_KrNameToMask $n) } } else { foreach ($n in $AllOf) { $target = $target -bor (_KrNameToMask $n) } } $notMask = 0 foreach ($n in ($Not | ForEach-Object { $_ })) { $notMask = $notMask -bor (_KrNameToMask $n) } $match = if ($PSCmdlet.ParameterSetName -eq 'AnyOf') { if ($Exact) { { param($m) $m -eq $target } } else { { param($m) ($m -band $target) -ne 0 } } } else { if ($Exact) { { param($m) $m -eq $target } } else { { param($m) ($m -band $target) -eq $target } } } foreach ($c in $cmds) { $m = 0 if ($c.CommandType -eq 'Function') { $m = Get-KrFunctionContextMask -Function $c } elseif ($c.CommandType -eq 'Cmdlet' -and $c.ImplementingType) { $a = $c.ImplementingType.GetCustomAttributes($true) | Where-Object { $_.GetType().Name -eq 'KestrunRuntimeApiAttribute' } | Select-Object -First 1 if ($a) { $m = [int]([KestrunApiContext]$a.Contexts) } } if ($m -eq 0) { continue } if ($notMask -ne 0 -and ($m -band $notMask) -ne 0) { continue } # exclude forbidden bits if (& $match $m) { $c } } } function Get-KrDocSet { [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateSet( 'DefinitionOnly', 'RouteOnly', 'ScheduleOnly', 'RouteAndSchedule', 'ScheduleAndDefinition', 'Runtime', # Route|Schedule 'Everywhere', 'ConfigOnly' # Definition but not Route/Schedule )] [string]$Name, [string]$Module = 'Kestrun', [switch]$IncludeNonExported, [object[]]$Functions ) switch ($Name) { 'DefinitionOnly' { Get-KrCommandsByContext -AnyOf Definition -Exact -Module $Module -IncludeNonExported:$IncludeNonExported -Functions $Functions } 'RouteOnly' { Get-KrCommandsByContext -AnyOf Route -Exact -Module $Module -IncludeNonExported:$IncludeNonExported -Functions $Functions } 'ScheduleOnly' { Get-KrCommandsByContext -AnyOf Schedule -Exact -Module $Module -IncludeNonExported:$IncludeNonExported -Functions $Functions } 'RouteAndSchedule' { Get-KrCommandsByContext -AllOf Route, Schedule -Exact -Module $Module -IncludeNonExported:$IncludeNonExported -Functions $Functions } 'ScheduleAndDefinition' { Get-KrCommandsByContext -AnyOf ScheduleAndDefinition -Exact -Module $Module -IncludeNonExported:$IncludeNonExported -Functions $Functions } 'Runtime' { Get-KrCommandsByContext -AnyOf Runtime -Exact -Module $Module -IncludeNonExported:$IncludeNonExported -Functions $Functions } 'Everywhere' { Get-KrCommandsByContext -AnyOf Everywhere -Exact -Module $Module -IncludeNonExported:$IncludeNonExported -Functions $Functions } 'ConfigOnly' { # Definition but NOT Route or Schedule Get-KrCommandsByContext -AnyOf Definition -Exact -Module $Module -IncludeNonExported:$IncludeNonExported -Functions $Functions } } } function Get-KrFunctionContextMask { param([System.Management.Automation.FunctionInfo]$Function) if (-not $Function.ScriptBlock) { return 0 } $fnAst = $Function.ScriptBlock.Ast. Find({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq $Function.Name }, $true) if (-not $fnAst) { return 0 } $attrs = @() if ($fnAst.Attributes) { $attrs += $fnAst.Attributes } if ($fnAst.Body -and $fnAst.Body.ParamBlock -and $fnAst.Body.ParamBlock.Attributes) { $attrs += $fnAst.Body.ParamBlock.Attributes } $kr = $attrs | Where-Object { $_.TypeName.Name -eq 'KestrunRuntimeApi' } | Select-Object -First 1 if (-not $kr) { return 0 } $txt = (($kr.PositionalArguments + $kr.NamedArguments.Expression) | Where-Object { $_ }).Extent.Text #| ForEach-Object { $_.Extent.Text } -join ' ' $mask = switch ($txt) { "'Everywhere'" { 7 } "'Runtime'" { 6 } "'ScheduleAndDefinition'" { 5 } "'Definition'" { 1 } "'Route'" { 2 } "'Schedule'" { 4 } } return $mask } function Resolve-KrSchemaTypeLiteral { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingInvokeExpression', '')] [CmdletBinding()] param( [Parameter(Mandatory)] [object] $Schema ) if ($Schema -is [type]) { return $Schema } elseif ($Schema -is [string]) { $s = $Schema.Trim() # Require PowerShell type-literal form: [TypeName] or [Namespace.TypeName] # Disallow generics, arrays, scripts, whitespace, operators, etc. if ($s -notmatch '^\[[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*\]$') { throw "Invalid -Schema '$Schema'. Only type literals like '[OpenApiDate]' are allowed." } # Optional: reject some known-dangerous tokens defensively (belt + suspenders) if ($s -match '[\s;|&`$(){}<>]') { throw "Invalid -Schema '$Schema'. Disallowed characters detected." } $Schema = Invoke-Expression $s if ($Schema -isnot [type]) { throw "Invalid -Schema '$Schema'. Evaluation did not produce a [Type]." } return $Schema } else { throw "Invalid -Schema type '$($Schema.GetType().FullName)'. Use ([string]) or 'System.String'." } } function Test-KrFunctionHasAttribute { [CmdletBinding()] [outputType([bool])] param( [Parameter(Mandatory = $true)] [System.Management.Automation.CommandInfo]$Command, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string]$AttributeNameRegex ) try { $sb = $Command.ScriptBlock if (-not $sb) { return $false } # Prefer runtime attributes: this is what PowerShell actually binds foreach ($a in @($sb.Attributes)) { $t = $a.GetType() if ($t.Name -match $AttributeNameRegex -or $t.FullName -match $AttributeNameRegex) { return $true } } # Fallback: parse the definition and scan AttributeAst nodes $def = $Command.Definition if (-not $def) { return $false } $tokens = $null $parseErrors = $null $ast = [System.Management.Automation.Language.Parser]::ParseInput($def, [ref]$tokens, [ref]$parseErrors) if ($parseErrors -and $parseErrors.Count -gt 0) { return $false } $found = $ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.AttributeAst] -and ($n.TypeName?.Name -match $AttributeNameRegex) }, $true) return ($found.Count -gt 0) } catch { return $false } return $false } function ConvertTo-DateTimeOffset { [CmdletBinding()] [OutputType('System.DateTimeOffset')] param( [Parameter(Mandatory)] [object]$InputObject ) # Already DateTimeOffset if ($InputObject -is [DateTimeOffset]) { return $InputObject } # DateTime -> DateTimeOffset (local) if ($InputObject -is [DateTime]) { return [DateTimeOffset]::new([DateTime]$InputObject) } # String → try absolute date/time first if ($InputObject -is [string]) { $s = $InputObject.Trim() $dto = $null if ([DateTimeOffset]::TryParse($s, [ref]$dto)) { return $dto } # If not a date, try treating the string as a duration and add to now try { $ts = ConvertTo-TimeSpan -InputObject $s return [DateTimeOffset]::Now.Add($ts) } catch { throw "Invalid Expires value '$s'. Provide an absolute date (e.g. '2025-09-10T23:00Z') or a duration (e.g. '2h', '1d')." } } # TimeSpan => relative expiry from now if ($InputObject -is [TimeSpan]) { return [DateTimeOffset]::Now.Add([TimeSpan]$InputObject) } # Numeric seconds => relative expiry if ($InputObject -is [int] -or $InputObject -is [long] -or $InputObject -is [double] -or $InputObject -is [decimal]) { return [DateTimeOffset]::Now.AddSeconds([double]$InputObject) } throw "Cannot convert value of type [$($InputObject.GetType().FullName)] to [DateTimeOffset]." } function ConvertTo-KrThreadSafeValue { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '')] [CmdletBinding()] param( [Parameter(Mandatory)] [object]$Value ) if ($null -eq $Value) { return $null } # --- Hashtable (@{}) --- if ($Value -is [hashtable]) { return [hashtable]::Synchronized($Value) } # --- OrderedDictionary ([ordered]@{}) --- if ($Value -is [System.Collections.Specialized.OrderedDictionary]) { # Copy into a normal hashtable and wrap $ht = @{} foreach ($entry in $Value.GetEnumerator()) { $ht[$entry.Key] = $entry.Value } return [hashtable]::Synchronized($ht) } # --- ArrayList --- if ($Value -is [System.Collections.ArrayList]) { return [System.Collections.ArrayList]::Synchronized($Value) } # --- Any other IDictionary (generic or not, but not handled above) --- if ($Value -is [System.Collections.IDictionary]) { $dict = [System.Collections.Concurrent.ConcurrentDictionary[object, object]]::new() foreach ($entry in $Value.GetEnumerator()) { $null = $dict.TryAdd($entry.Key, $entry.Value) } return $dict } # --- Arrays: treat as immutable snapshots --- if ($Value -is [Array]) { return $Value } # --- PSCustomObject, scalars, etc.: just return as-is --- return $Value } function ConvertTo-TimeSpan { [CmdletBinding()] [OutputType('System.TimeSpan')] param( [Parameter(Mandatory)] [object]$InputObject ) # 1) Already a TimeSpan if ($InputObject -is [TimeSpan]) { return $InputObject } # 2) Numeric => seconds if ($InputObject -is [int] -or $InputObject -is [long] -or $InputObject -is [double] -or $InputObject -is [decimal]) { return [TimeSpan]::FromSeconds([double]$InputObject) } # 3) String parsing if ($InputObject -is [string]) { $s = $InputObject.Trim() # Try .NET built-in formats first: "c", "g", "G" (e.g., "00:30:00", "1.02:03:04") $ts = [TimeSpan]::Zero if ([TimeSpan]::TryParse($s, [ref]$ts)) { return $ts } # Compact tokens: 1d2h30m15s250ms (order-insensitive, any subset) if ($s -match '^(?i)(?:\s*(?<d>\d+)\s*d)?(?:\s*(?<h>\d+)\s*h)?(?:\s*(?<m>\d+)\s*m)?(?:\s*(?<s>\d+)\s*s)?(?:\s*(?<ms>\d+)\s*ms)?\s*$') { $days = [int]::Parse(('0' + $Matches['d'])) $hrs = [int]::Parse(('0' + $Matches['h'])) $min = [int]::Parse(('0' + $Matches['m'])) $sec = [int]::Parse(('0' + $Matches['s'])) $msec = [int]::Parse(('0' + $Matches['ms'])) return [TimeSpan]::new($days, $hrs, $min, $sec, $msec) } throw "Invalid TimeSpan format: '$s'. Try '00:30:00', '1.02:03:04', or tokens like '1d2h30m15s'." } throw "Cannot convert value of type [$($InputObject.GetType().FullName)] to [TimeSpan]." } function Get-KrFormattedMessage { param( [Parameter(Mandatory = $true)] [Serilog.ILogger]$Logger, [Parameter(Mandatory = $true)] [Serilog.Events.LogEventLevel]$Level, [parameter(Mandatory = $true)] [AllowEmptyString()] [string]$Message, [Parameter(Mandatory = $false)] [AllowNull()] [object[]]$Values, [Parameter(Mandatory = $false)] [AllowNull()] [System.Exception]$Exception ) $parsedTemplate = $null $boundProperties = $null if ($Logger.BindMessage($Message, $Values, [ref]$parsedTemplate, [ref]$boundProperties)) { $logEvent = [Serilog.Events.LogEvent]::new([System.DateTimeOffset]::Now, $Level, $Exception, $parsedTemplate, $boundProperties) $strWriter = [System.IO.StringWriter]::new() # Use the global TextFormatter if available, otherwise use the default formatter from Kestrun.Logging [Kestrun.Logging]::TextFormatter.Format($logEvent, $strWriter) $message = $strWriter.ToString() $strWriter.Dispose() $message } } function Set-KrLogLevelToPreference { [CmdletBinding(SupportsShouldProcess = $true)] param( [Parameter(Mandatory = $true)] [Serilog.Events.LogEventLevel]$Level ) if ($PSCmdlet.ShouldProcess('Set log level preferences')) { if ([int]$Level -le [int]([Serilog.Events.LogEventLevel]::Verbose)) { Set-Variable VerbosePreference -Value 'Continue' -Scope Global } else { Set-Variable VerbosePreference -Value 'SilentlyContinue' -Scope Global } if ([int]$Level -le [int]([Serilog.Events.LogEventLevel]::Debug)) { Set-Variable DebugPreference -Value 'Continue' -Scope Global } else { Set-Variable DebugPreference -Value 'SilentlyContinue' -Scope Global } if ([int]$Level -le [int]([Serilog.Events.LogEventLevel]::Information)) { Set-Variable InformationPreference -Value 'Continue' -Scope Global } else { Set-Variable InformationPreference -Value 'SilentlyContinue' -Scope Global } if ([int]$Level -le [int]([Serilog.Events.LogEventLevel]::Warning)) { Set-Variable WarningPreference -Value 'Continue' -Scope Global } else { Set-Variable WarningPreference -Value 'SilentlyContinue' -Scope Global } } } function Write-KrOutsideRouteWarning { param ( [string]$FunctionName = $PSCmdlet.MyInvocation.InvocationName ) if (Test-KrLogger) { Write-KrLog -Level Warning -Message '{function} must be called inside a route script block where $Context is available.' -Values $FunctionName } else { Write-Warning -Message "$FunctionName must be called inside a route script block where `$Context is available." } } function Write-KrSinkPowerShell { param( [Parameter(Mandatory = $true)] [Serilog.Events.LogEvent]$LogEvent, [Parameter(Mandatory = $true)] [string]$RenderedMessage ) switch ($LogEvent.Level) { Verbose { Write-Verbose -Message $RenderedMessage } Debug { Write-Debug -Message $RenderedMessage } Information { Write-Information -MessageData $RenderedMessage } Warning { Write-Warning -Message $RenderedMessage } default { Write-Information -MessageData $RenderedMessage -InformationAction 'Continue' } } } function Get-KrAnnotatedFunctionsLoaded { [CmdletBinding()] param( [Parameter(Mandatory = $false, ValueFromPipeline = $true)] [Kestrun.Hosting.KestrunHost]$Server, [Parameter()] [string]$DocId = [Kestrun.OpenApi.OpenApiDocDescriptor]::DefaultDocumentationId ) begin { # Ensure the server instance is resolved $Server = Resolve-KestrunServer -Server $Server # All loaded functions now in the runspace $funcs = @(Get-Command -CommandType Function | Where-Object { $null -eq $_.Module -and $null -eq $_.PsDrive }) } process { if ( -not $Server.OpenApiDocumentDescriptor.ContainsKey($DocId)) { throw "OpenAPI document with ID '$DocId' does not exist on the server." } $doc = $Server.OpenApiDocumentDescriptor[$DocId] $doc.LoadAnnotatedFunctions( $funcs ) } } function _KrJoin-Route { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseApprovedVerbs', '')] param([string]$base, [string]$child) $b = ($base ?? '').TrimEnd('/') $c = ($child ?? '').TrimStart('/') if ([string]::IsNullOrWhiteSpace($b)) { "/$c".TrimEnd('/') -replace '^$', '/' } elseif ([string]::IsNullOrWhiteSpace($c)) { if ($b) { $b } else { '/' } } else { "$b/$c" } } function _KrMerge-Args { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseApprovedVerbs', '')] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '')] param([hashtable]$a, [hashtable]$b) if (-not $a) { return $b } if (-not $b) { return $a } $m = @{} foreach ($k in $a.Keys) { $m[$k] = $a[$k] } foreach ($k in $b.Keys) { $m[$k] = $b[$k] } # child overrides $m } function _KrMerge-MRO { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseApprovedVerbs', '')] param( [Parameter(Mandatory)][Kestrun.Hosting.Options.MapRouteOptions]$Parent, [Parameter(Mandatory)][Kestrun.Hosting.Options.MapRouteOptions]$Child ) $pattern = if ($Child.Pattern) { if ($Parent.Pattern) { "$($Parent.Pattern)/$($Child.Pattern)" } else { $Child.Pattern } } else { $Parent.Pattern } $extraRefs = if ($null -ne $Child.ScriptCode.ExtraRefs) { if ($Parent.ScriptCode.ExtraRefs) { $Parent.ScriptCode.ExtraRefs + $Child.ScriptCode.ExtraRefs } else { $Child.ScriptCode.ExtraRefs } } else { $Parent.ScriptCode.ExtraRefs } $merged = @{ Pattern = $pattern.Replace('//', '/') HttpVerbs = if ($null -ne $Child.HttpVerbs -and ($Child.HttpVerbs.Count -gt 0)) { $Child.HttpVerbs } else { $Parent.HttpVerbs } RequireSchemes = _KrMerge-Unique $Parent.RequireSchemes $Child.RequireSchemes RequirePolicies = _KrMerge-Unique $Parent.RequirePolicies $Child.RequirePolicies CorsPolicy = if ($Child.CorsPolicy) { $Child.CorsPolicy } else { $Parent.CorsPolicy } OpenAPI = if ($Child.OpenAPI) { $Child.OpenAPI } else { $Parent.OpenAPI } ThrowOnDuplicate = $Child.ThrowOnDuplicate -or $Parent.ThrowOnDuplicate ScriptCode = @{ Code = if ($Child.ScriptCode.Code) { $Child.ScriptCode.Code } else { $Parent.ScriptCode.Code } Language = if ($null -ne $Child.ScriptCode.Language) { $Child.ScriptCode.Language } else { $Parent.ScriptCode.Language } ExtraImports = _KrMerge-Unique $Parent.ScriptCode.ExtraImports $Child.ScriptCode.ExtraImports ExtraRefs = $extraRefs Arguments = _KrMerge-Args $Parent.ScriptCode.Arguments $Child.ScriptCode.Arguments } } return New-KrMapRouteOption -Property $merged } function _KrMerge-Unique { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseApprovedVerbs', '')] param([string[]]$a, [string[]]$b) @(($a + $b | Where-Object { $_ -ne $null } | Select-Object -Unique)) } function _KrWith-MRO { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseApprovedVerbs', '')] param( [Parameter(Mandatory)][Kestrun.Hosting.Options.MapRouteOptions]$Base, [Parameter()][hashtable]$Override = @{} ) $h = @{ Pattern = $Base.Pattern HttpVerbs = $Base.HttpVerbs Code = $Base.Code Language = $Base.Language ExtraImports = $Base.ExtraImports ExtraRefs = $Base.ExtraRefs RequireSchemes = $Base.RequireSchemes RequirePolicies = $Base.RequirePolicies CorsPolicy = $Base.CorsPolicy Arguments = $Base.Arguments OpenAPI = $Base.OpenAPI ThrowOnDuplicate = $Base.ThrowOnDuplicate } foreach ($k in $Override.Keys) { $h[$k] = $Override[$k] } return New-KrMapRouteOption -Property $h } function Get-KrUserImportedModule { [CmdletBinding()] [OutputType([System.Management.Automation.PSModuleInfo])] param() # ----- constants ---------------------------------------------------------- $inboxRoot = [IO.Path]::GetFullPath( (Join-Path $PSHOME 'Modules') ) # regex fragment that matches “…\.vscode\extensions\ms-vscode.powershell…” $vsCodeRegex = [Regex]::Escape( [IO.Path]::Combine('.vscode', 'extensions', 'ms-vscode.powershell') ) -replace '\\\\', '[\\/]' # make path-separator agnostic # ------------------------------------------------------------------------- Get-Module | Where-Object { $path = [IO.Path]::GetFullPath($_.ModuleBase) $isInbox = $path.StartsWith($inboxRoot, $IsWindows ? 'OrdinalIgnoreCase' : 'Ordinal') $isVSCode = $path -match $vsCodeRegex $isMSPSSpace = $_.Name -like 'Microsoft.PowerShell.*' -not ($isInbox -or $isVSCode -or $isMSPSSpace) } } function Resolve-KestrunServer { param ( [Kestrun.Hosting.KestrunHost]$Server ) if ($null -eq $Server) { if ($null -ne $KrServer) { Write-KrLog -Level Verbose -Message "No server specified, using global `$KrServer variable." # If no server is specified, use the global $KrServer variable # This is useful for scripts that run in the context of a Kestrun server $Server = $KrServer } else { # Try to get the default Kestrun server instance $Server = [Kestrun.KestrunHostManager]::Default } if ($null -eq $Server) { throw 'No Kestrun server instance found. Please create a Kestrun server instance.' } } return $Server } function _NormalizeValueToDictionary([object]$Value, [int]$Depth, [int]$MaxRecursionDepth = 8) { if ($null -eq $Value) { return $null } if ($Depth -gt $MaxRecursionDepth) { return ($Value.ToString()) } # Unwrap PSObject shell if ($Value -is [System.Management.Automation.PSObject]) { $base = $Value.BaseObject if ($null -eq $base -or $base -eq $Value) { return $Value.ToString() } return _NormalizeValueToDictionary $base ($Depth + 1) } # Hashtable / IDictionary → new Dictionary[string, object] if ($Value -is [System.Collections.IDictionary]) { $out = [System.Collections.Generic.Dictionary[string, object]]::new() foreach ($key in $Value.Keys) { if ([string]::IsNullOrWhiteSpace([string]$key)) { continue } $nv = _NormalizeValue $Value[$key] ($Depth + 1) if ($null -ne $nv) { $out[[string]$key] = $nv } } return $out } # Enumerable (but not string) → List<object> if ($Value -is [System.Collections.IEnumerable] -and -not ($Value -is [string])) { $list = New-Object System.Collections.Generic.List[object] foreach ($item in $Value) { $list.Add((_NormalizeValueToDictionary $item ($depth + 1))) } return $list } return $Value # primitive / POCO } function Get-KrAssignedVariable { [CmdletBinding(DefaultParameterSetName = 'Given')] [OutputType([object[]])] [OutputType([System.Collections.Generic.Dictionary[string, object]])] param( [Parameter(ParameterSetName = 'Given', Position = 0)] [scriptblock]$ScriptBlock, # NEW: use the caller's scriptblock (parent frame) [Parameter(ParameterSetName = 'FromParent', Mandatory)] [switch]$FromParent, # How many frames up to climb (1 = immediate caller) [Parameter(ParameterSetName = 'FromParent')] [int]$Up = 1, # Optional: skip frames from these modules when searching [Parameter(ParameterSetName = 'FromParent')] [string[]]$ExcludeModules = @('Kestrun'), [Parameter()] [switch]$IncludeSetVariable, [Parameter()] [switch]$ResolveValues, [Parameter()] [ValidateSet('Array', 'Dictionary', 'StringObjectMap')] [string]$OutputStructure = 'Array', [Parameter()] [ValidateSet('Local', 'Script', 'Global')] [string]$DefaultScope = 'Script', [Parameter()] [string[]]$ExcludeVariables, [Parameter()] [switch]$WithoutAttributesOnly ) $excludeSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $excludeList = if ($null -ne $ExcludeVariables) { $ExcludeVariables } else { @() } foreach ($ev in $excludeList) { if ([string]::IsNullOrWhiteSpace($ev)) { continue } $n = $ev.Trim() if ($n.StartsWith('$')) { $n = $n.Substring(1) } if (-not [string]::IsNullOrWhiteSpace($n)) { [void]$excludeSet.Add($n) } } $rows = [System.Collections.Generic.List[object]]::new() function _GetScopeAndNameFromVariablePath([System.Management.Automation.VariablePath] $variablePath) { $raw = $variablePath.UserPath if (-not $raw) { $raw = $variablePath.UnqualifiedPath } if (-not $raw) { return $null } $name = $raw $scopeHint = $DefaultScope if ($name -match '^(?<scope>global|script|local|private):(?<rest>.+)$') { $scopeHint = ($Matches.scope.Substring(0, 1).ToUpperInvariant() + $Matches.scope.Substring(1).ToLowerInvariant()) $name = $Matches.rest } # ignore member/index forms and ${foo} if ($name.Contains('.') -or $name.Contains('[')) { return $null } if ($name.StartsWith('{') -and $name.EndsWith('}')) { $name = $name.Substring(1, $name.Length - 2) } if (-not $name) { return $null } [pscustomobject]@{ Name = $name ScopeHint = $scopeHint ProviderPath = "variable:$($scopeHint):$name" } } function _TryResolveValue([string] $name, [string] $scopeHint) { if (-not $ResolveValues.IsPresent) { return $null } # When using -FromParent, prefer numeric scope resolution so we can see the caller's values. if ($FromParent.IsPresent) { # Use Get-Variable (not provider) so we can distinguish "not found" from "$null". # Scope depth can vary depending on wrapper frames (VS Code, Invoke-Command, etc.). # Try a small window around the computed scopeUp first, then scan a bounded range. $scopeCandidates = [System.Collections.Generic.List[int]]::new() if ($scopeUp -ge 0) { $scopeCandidates.Add([int]$scopeUp) } if ($scopeUp -ge 0) { $scopeCandidates.Add([int]($scopeUp + 1)) } if ($scopeUp -gt 0) { $scopeCandidates.Add([int]($scopeUp - 1)) } $maxAdditionalScopes = 25 $start = [Math]::Max(1, [int]($scopeUp - 2)) $end = [Math]::Max($start, [int]($scopeUp + $maxAdditionalScopes)) for ($s = $start; $s -le $end; $s++) { $scopeCandidates.Add([int]$s) } foreach ($s in ($scopeCandidates | Select-Object -Unique)) { try { $v = Get-Variable -Name $name -Scope $s -ErrorAction SilentlyContinue if ($null -ne $v) { return $v.Value } } catch { # ignore Write-krLog -Level Verbose -Message "Variable '$name' not found at scope depth $s." } } return $null } try { return (Get-Item -ErrorAction SilentlyContinue "variable:${scopeHint}:$name").Value } catch { return $null } } function _TryGetInitializerValueFromAssignment([System.Management.Automation.Language.AssignmentStatementAst] $assignmentAst) { if (-not $ResolveValues.IsPresent) { return $null } if ($assignmentAst.Operator -ne [System.Management.Automation.Language.TokenKind]::Equals) { return $null } $expr = $null if ($assignmentAst.Right -is [System.Management.Automation.Language.CommandExpressionAst]) { $expr = $assignmentAst.Right.Expression } elseif ($assignmentAst.Right -is [System.Management.Automation.Language.PipelineAst]) { $p = $assignmentAst.Right if ($p.PipelineElements.Count -eq 1 -and $p.PipelineElements[0] -is [System.Management.Automation.Language.CommandExpressionAst]) { $expr = $p.PipelineElements[0].Expression } } function _EvalStatement([System.Management.Automation.Language.StatementAst] $s) { if ($null -eq $s) { return $null } if ($s -is [System.Management.Automation.Language.CommandExpressionAst]) { return _EvalExpr $s.Expression } if ($s -is [System.Management.Automation.Language.PipelineAst]) { if ($s.PipelineElements.Count -eq 1 -and $s.PipelineElements[0] -is [System.Management.Automation.Language.CommandExpressionAst]) { return _EvalExpr $s.PipelineElements[0].Expression } } return $null } function _EvalKeyValuePart($node) { if ($null -eq $node) { return $null } if ($node -is [System.Management.Automation.Language.ExpressionAst]) { return _EvalExpr $node } if ($node -is [System.Management.Automation.Language.StatementAst]) { return _EvalStatement $node } return $null } function _EvalExpr([System.Management.Automation.Language.ExpressionAst] $e) { if ($null -eq $e) { return $null } switch ($e.GetType().FullName) { 'System.Management.Automation.Language.ConstantExpressionAst' { return $e.Value } 'System.Management.Automation.Language.StringConstantExpressionAst' { return $e.Value } 'System.Management.Automation.Language.ExpandableStringExpressionAst' { return $e.Value } 'System.Management.Automation.Language.TypeExpressionAst' { return $e.TypeName.FullName } 'System.Management.Automation.Language.ParenthesisExpressionAst' { if ($e.Pipeline -and $e.Pipeline.PipelineElements.Count -eq 1 -and $e.Pipeline.PipelineElements[0] -is [System.Management.Automation.Language.CommandExpressionAst]) { return _EvalExpr $e.Pipeline.PipelineElements[0].Expression } return $null } 'System.Management.Automation.Language.ConvertExpressionAst' { # e.g. [int]42 or [ordered]@{...} if ($e.Child -is [System.Management.Automation.Language.ExpressionAst]) { $childValue = _EvalExpr $e.Child if ($e.Type -and $e.Type.TypeName -and ($e.Type.TypeName.FullName -ieq 'ordered') -and ($e.Child -is [System.Management.Automation.Language.HashtableAst])) { # Rebuild as an ordered dictionary $ordered = [ordered]@{} foreach ($kv in $e.Child.KeyValuePairs) { $k = _EvalKeyValuePart $kv.Item1 $v = _EvalKeyValuePart $kv.Item2 if ($null -eq $k) { return $null } $ordered[$k] = $v } return $ordered } return $childValue } return $null } 'System.Management.Automation.Language.ArrayExpressionAst' { # @( ... ) if (-not $e.SubExpression) { return @() } $items = @() foreach ($st in $e.SubExpression.Statements) { $vv = _EvalStatement $st if ($null -eq $vv) { return $null } $items += $vv } return , $items } 'System.Management.Automation.Language.SubExpressionAst' { # $( ... ) $items = @() foreach ($st in $e.Statements) { $vv = _EvalStatement $st if ($null -eq $vv) { return $null } $items += $vv } return , $items } 'System.Management.Automation.Language.UnaryExpressionAst' { $v = $null if ($e.Child -is [System.Management.Automation.Language.ExpressionAst]) { $v = _EvalExpr $e.Child } if ($e.TokenKind -eq [System.Management.Automation.Language.TokenKind]::Minus -and $v -is [ValueType]) { try { return -1 * $v } catch { return $null } } return $null } 'System.Management.Automation.Language.ArrayLiteralAst' { $arr = @() foreach ($el in $e.Elements) { if ($el -isnot [System.Management.Automation.Language.ExpressionAst]) { return $null } $vv = _EvalExpr $el if ($null -eq $vv -and ($el -isnot [System.Management.Automation.Language.ConstantExpressionAst])) { return $null } $arr += $vv } return , $arr } 'System.Management.Automation.Language.HashtableAst' { $ht = @{} foreach ($kv in $e.KeyValuePairs) { $k = _EvalKeyValuePart $kv.Item1 $v = _EvalKeyValuePart $kv.Item2 if ($null -eq $k) { return $null } $ht[$k] = $v } return $ht } default { return $null } } } if ($expr -is [System.Management.Automation.Language.ExpressionAst]) { return _EvalExpr $expr } return $null } function _GetTypeInfoFromDeclaredType([string] $declaredType) { $underlyingName = $declaredType $isNullable = $false if (-not [string]::IsNullOrWhiteSpace($declaredType)) { # Typical PowerShell nullable syntax: Nullable[datetime] if ($declaredType -match '^(System\.)?Nullable(`1)?\[(?<inner>.+)\]$') { $underlyingName = $Matches.inner $isNullable = $true } } $resolvedType = $null if (-not [string]::IsNullOrWhiteSpace($underlyingName)) { try { $resolvedType = [System.Management.Automation.PSTypeName]::new($underlyingName).Type } catch { $resolvedType = $null } } return [pscustomobject]@{ Type = $resolvedType DeclaredType = $declaredType IsNullable = $isNullable } } # ---------- resolve $ScriptBlock source ---------- if ($FromParent.IsPresent) { $allFrames = Get-PSCallStack # 0 = this function, 1 = immediate caller, 2+ = higher parents $frames = $allFrames | Select-Object -Skip 1 if ($ExcludeModules.Count) { $frames = $frames | Where-Object { $mn = $_.InvocationInfo.MyCommand.ModuleName -not ($mn -and ($mn -in $ExcludeModules)) } } # pick the desired parent frame $frame = $frames | Select-Object -Skip ($Up - 1) -First 1 if (-not $frame) { throw "No parent frame found (Up=$Up)." } # Numeric scope depth for Get-Variable (0=this function, 1=caller, ...). # The frame index in Get-PSCallStack already matches the numeric scope depth. $scopeUp = $allFrames.IndexOf($frame) if ($scopeUp -lt 1) { throw 'Parent frame not found.' } # prefer its live ScriptBlock; if null, rebuild from file $ScriptBlock = $frame.InvocationInfo.MyCommand.ScriptBlock if (-not $ScriptBlock -and $frame.ScriptName) { $ScriptBlock = [scriptblock]::Create((Get-Content -Raw -LiteralPath $frame.ScriptName)) } if (-not $ScriptBlock) { throw 'Parent frame has no scriptblock or script file to parse.' } } if (-not $ScriptBlock) { throw 'No scriptblock provided. Use -FromParent or pass a ScriptBlock.' } # Use the original script text so offsets match exactly $scriptText = $ScriptBlock.Ast.Extent.Text # Find the first *actual command* invocation named Enable-KrConfiguration $enableCmd = $ScriptBlock.Ast.FindAll({ param($node) if ($node -isnot [System.Management.Automation.Language.CommandAst]) { return $false } $name = $node.GetCommandName() return $name -and ($name -ieq 'Enable-KrConfiguration') }, $true) | Select-Object -First 1 if ($enableCmd) { # Cut everything before that command $pre = $scriptText.Substring(0, $enableCmd.Extent.StartOffset).TrimEnd() # Preserve your brace-closing hack if ($pre.TrimStart().StartsWith('{')) { $pre += "`n}" } $ScriptBlock = [scriptblock]::Create($pre) } function _IsInFunction([System.Management.Automation.Language.Ast] $node) { $p = $node.Parent while ($p) { if ($p -is [System.Management.Automation.Language.FunctionDefinitionAst]) { return $true } if ($p -is [System.Management.Automation.Language.ScriptBlockAst]) { break } $p = $p.Parent } return $false } function _IsInAssignment([System.Management.Automation.Language.Ast] $node) { # IMPORTANT: AST parent chains can escape the scanned ScriptBlockAst # (e.g. when the scriptblock literal is part of an outer assignment). # We only care about assignments that occur *within* the scanned scriptblock. $p = $node.Parent while ($p) { if ($p -is [System.Management.Automation.Language.AssignmentStatementAst]) { return $true } if ($p -is [System.Management.Automation.Language.ScriptBlockAst]) { break } $p = $p.Parent } return $false } function _HasAttributes([System.Management.Automation.Language.Ast] $node) { $p = $node while ($p) { if ($p -is [System.Management.Automation.Language.AttributedExpressionAst]) { # Note: AttributedExpressionAst stores a single attribute in the `Attribute` property. # Multiple attributes are represented as nested AttributedExpressionAst nodes. # Type constraints like [int] are represented as TypeConstraintAst and should NOT # count as "attributes" for -WithoutAttributesOnly filtering. if ($p.Attribute -is [System.Management.Automation.Language.AttributeAst]) { return $true } # Otherwise, it's a type constraint (e.g. [int]); keep walking up because # there may be an outer AttributedExpressionAst with a real attribute. } if ($p -is [System.Management.Automation.Language.ScriptBlockAst]) { break } $p = $p.Parent } return $false } $assignAsts = $ScriptBlock.Ast.FindAll( { param($n) $n -is [System.Management.Automation.Language.AssignmentStatementAst] }, $true) foreach ($a in $assignAsts) { $declaredType = $null $typeInfo = $null $hasAttributes = _HasAttributes $a.Left # If assignment target is typed ([int]$x = 1), capture the declared type from the LHS. $lhsConvert = $a.Left.Find( { param($n) $n -is [System.Management.Automation.Language.ConvertExpressionAst] -and $n.Child -is [System.Management.Automation.Language.VariableExpressionAst] }, $true ) | Select-Object -First 1 if ($lhsConvert -and $lhsConvert.Type -and $lhsConvert.Type.TypeName) { $declaredType = $lhsConvert.Type.TypeName.FullName $typeInfo = _GetTypeInfoFromDeclaredType -declaredType $declaredType } $varAst = $a.Left.Find( { param($n) $n -is [System.Management.Automation.Language.VariableExpressionAst] }, $true ) | Select-Object -First 1 if (-not $varAst) { continue } $info = _GetScopeAndNameFromVariablePath $varAst.VariablePath if (-not $info) { continue } if ($excludeSet.Contains($info.Name)) { continue } $val = _TryResolveValue -name $info.Name -scopeHint $info.ScopeHint if ($null -eq $val) { # If runtime resolution fails (common when scanning caller scripts from a module), # fall back to reading simple constant initializers directly from the AST. $val = _TryGetInitializerValueFromAssignment -assignmentAst $a } $type = $null if ($null -ne $val) { $type = $val.GetType() } elseif ($typeInfo -and $null -ne $typeInfo.Type) { # If value is $null but LHS is typed, surface the underlying declared type. $type = $typeInfo.Type } [void]$rows.Add([pscustomobject]@{ Name = $info.Name ScopeHint = $info.ScopeHint ProviderPath = $info.ProviderPath Source = 'Assignment' Operator = $a.Operator.ToString() Type = $type DeclaredType = if ($typeInfo) { $typeInfo.DeclaredType } else { $null } IsNullable = if ($typeInfo) { $typeInfo.IsNullable } else { $null } HasAttributes = $hasAttributes Value = $val }) } # Also capture declaration-only typed variables like: [int]$x (no assignment) # We scan ConvertExpressionAst directly to reliably catch both plain and attributed declarations. $convertAsts = $ScriptBlock.Ast.FindAll( { param($n) $n -is [System.Management.Automation.Language.ConvertExpressionAst] -and $n.Child -is [System.Management.Automation.Language.VariableExpressionAst] }, $true ) foreach ($c in $convertAsts) { # Skip casts that are part of assignments (those are already handled as assignments) if (_IsInAssignment $c) { continue } $hasAttributes = _HasAttributes $c $varExpr = [System.Management.Automation.Language.VariableExpressionAst]$c.Child $info = _GetScopeAndNameFromVariablePath $varExpr.VariablePath if (-not $info) { continue } if ($excludeSet.Contains($info.Name)) { continue } $declaredType = $c.Type.TypeName.FullName $typeInfo = _GetTypeInfoFromDeclaredType -declaredType $declaredType $val = _TryResolveValue -name $info.Name -scopeHint $info.ScopeHint [void]$rows.Add([pscustomobject]@{ Name = $info.Name ScopeHint = $info.ScopeHint ProviderPath = $info.ProviderPath Source = 'Declaration' Operator = $null Type = $typeInfo.Type DeclaredType = $typeInfo.DeclaredType IsNullable = $typeInfo.IsNullable HasAttributes = $hasAttributes Value = $val }) } if ($IncludeSetVariable) { $cmdAsts = $ScriptBlock.Ast.FindAll( { param($n) $n -is [System.Management.Automation.Language.CommandAst] -and -not (_IsInFunction $n) }, $true) foreach ($c in $cmdAsts) { $cmd = $c.GetCommandName() if ($cmd -notin 'Set-Variable', 'New-Variable') { continue } $named = @{} for ($i = 0; $i -lt $c.CommandElements.Count; $i++) { $e = $c.CommandElements[$i] if ($e -isnot [System.Management.Automation.Language.CommandParameterAst]) { continue } if ($e.ParameterName -notin 'Name', 'Scope') { continue } $argAst = $e.Argument if (-not $argAst -and ($i + 1) -lt $c.CommandElements.Count) { $next = $c.CommandElements[$i + 1] if ($next -is [System.Management.Automation.Language.StringConstantExpressionAst]) { $argAst = $next $i++ } } if ($argAst -is [System.Management.Automation.Language.StringConstantExpressionAst]) { $named[$e.ParameterName] = $argAst.Value } } if ($named.ContainsKey('Name')) { $name = $named['Name'] if ($excludeSet.Contains($name)) { continue } $scope = if ($named.ContainsKey('Scope') -and -not [string]::IsNullOrWhiteSpace($named['Scope'])) { $named['Scope'] } else { $DefaultScope } $provider = "variable:$($scope):$name" $val = $null; $type = $null if ($ResolveValues) { try { $val = (Get-Item -EA SilentlyContinue $provider).Value if ($null -ne $val) { $type = $val.GetType() } } catch { Write-Warning "Failed to resolve variable '$name' in scope '$scope': $_" } } [pscustomobject]@{ Name = $name ScopeHint = $scope ProviderPath = $provider Source = $cmd Operator = $null Type = $type DeclaredType = $null IsNullable = $null HasAttributes = $false Value = $val } | ForEach-Object { [void]$rows.Add($_) } } } } # keep last occurrence per (ScopeHint, Name) $final = @($rows | Group-Object ScopeHint, Name | ForEach-Object { $_.Group[-1] }) if ($WithoutAttributesOnly.IsPresent) { $final = @($final | Where-Object { -not $_.HasAttributes }) } switch ($OutputStructure) { 'Dictionary' { $dict = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($v in $final) { # Preserve declared type/nullable metadata for declaration-only (and typed-null) variables. # Typed variables (i.e., DeclaredType present) are wrapped so C# can see Type/IsNullable # even when the runtime value is non-null (e.g. [int]$paginationLimit = 20). # Untyped variables keep the old behavior (value only). $wrap = -not [string]::IsNullOrWhiteSpace($v.DeclaredType) if ($wrap) { $meta = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase) $meta['__kestrunVariable'] = $true $meta['Value'] = $v.Value $meta['Type'] = $v.Type $meta['DeclaredType'] = $v.DeclaredType $meta['IsNullable'] = $v.IsNullable $dict[$v.Name] = $meta } else { $dict[$v.Name] = $v.Value } } return $dict } 'StringObjectMap' { $dict = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($v in $final) { $dict[$v.Name] = $v.Value } return $dict } default { return $final } } } function Convert-DateTimeOffsetToDateTime { param( [Parameter()] [AllowNull()] $InputObject ) if ($null -eq $InputObject) { return $null } if ($InputObject -is [DateTimeOffset]) { # Preserve naive timestamps (those without explicit zone) as 'Unspecified' kind so no offset is appended $dto = [DateTimeOffset]$InputObject return [DateTime]::SpecifyKind($dto.DateTime, [System.DateTimeKind]::Unspecified) } if ($InputObject -is [System.Collections.IDictionary]) { foreach ($k in @($InputObject.Keys)) { $InputObject[$k] = Convert-DateTimeOffsetToDateTime $InputObject[$k] } return $InputObject } if ($InputObject -is [System.Collections.IList]) { for ($j = 0; $j -lt $InputObject.Count; $j++) { $InputObject[$j] = Convert-DateTimeOffsetToDateTime $InputObject[$j] } return $InputObject } return $InputObject } # Portions derived from PowerShell-Yaml (https://github.com/cloudbase/powershell-yaml) # Copyright (c) 2016–2024 Cloudbase Solutions Srl # Licensed under the Apache License, Version 2.0 (Apache-2.0). # You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 # Modifications Copyright (c) 2025 Kestrun Contributors function Convert-HashtableToDictionary { param( [Parameter(Mandatory = $true)] [hashtable]$Data ) # Preserve original insertion order: PowerShell hashtable preserves insertion order internally $ordered = [System.Collections.Specialized.OrderedDictionary]::new() foreach ($k in $Data.Keys) { $ordered.Add($k, (Convert-PSObjectToGenericObject $Data[$k])) } return $ordered } # Portions derived from PowerShell-Yaml (https://github.com/cloudbase/powershell-yaml) # Copyright (c) 2016–2024 Cloudbase Solutions Srl # Licensed under the Apache License, Version 2.0 (Apache-2.0). # You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 # Modifications Copyright (c) 2025 Kestrun Contributors function Convert-ListToGenericList { param( [Parameter(Mandatory = $false)] [array]$Data = @() ) $ret = [System.Collections.Generic.List[object]]::new() for ($i = 0; $i -lt $Data.Count; $i++) { $ret.Add((Convert-PSObjectToGenericObject $Data[$i])) } # Return the generic list directly (do NOT wrap in a single-element array) so single-element # sequences remain proper YAML sequences and do not collapse into mappings during round-trip. return $ret } # Portions derived from PowerShell-Yaml (https://github.com/cloudbase/powershell-yaml) # Copyright (c) 2016–2024 Cloudbase Solutions Srl # Licensed under the Apache License, Version 2.0 (Apache-2.0). # You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 # Modifications Copyright (c) 2025 Kestrun Contributors function Convert-OrderedHashtableToDictionary { param( [Parameter(Mandatory = $true)] [System.Collections.Specialized.OrderedDictionary] $Data ) foreach ($i in $($data.PSBase.Keys)) { $Data[$i] = Convert-PSObjectToGenericObject $Data[$i] } return $Data } # Portions derived from PowerShell-Yaml (https://github.com/cloudbase/powershell-yaml) # Copyright (c) 2016–2024 Cloudbase Solutions Srl # Licensed under the Apache License, Version 2.0 (Apache-2.0). # You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 # Modifications Copyright (c) 2025 Kestrun Contributors function Convert-PSObjectToGenericObject { param( [Parameter(Mandatory = $false)] [System.Object]$InputObject ) if ($null -eq $InputObject) { return $InputObject } # Use PowerShell's -is operator for efficient type checking if ($InputObject -is [System.Collections.Specialized.OrderedDictionary]) { return Convert-OrderedHashtableToDictionary $InputObject } elseif ($InputObject -is [System.Collections.IDictionary]) { return Convert-HashtableToDictionary $InputObject } elseif ($InputObject -is [System.Collections.IList]) { return Convert-ListToGenericList $InputObject } return $InputObject } |