private/uitool/Get-UiDefinition.ps1
|
<#
.SYNOPSIS Introspects a PowerShell command and returns a UI definition schema. #> function Get-UiDefinition { [CmdletBinding()] param( # Command can be: cmdlet name, function name, script path, or CommandInfo object [Parameter(Mandatory, Position = 0)] [object]$Command, [string]$ParameterSet, [string[]]$ExcludeParameters = @(), [switch]$IncludeCommonParameters, # Input helper detection [string[]]$FilePickerParameters = @(), [string[]]$FolderPickerParameters = @(), [string[]]$ComputerPickerParameters = @(), [string[]]$UserPickerParameters = @(), [string[]]$GroupPickerParameters = @(), [string[]]$MemberPickerParameters = @(), [string[]]$OUPickerParameters = @(), [switch]$NoAutoHelpers, # The calling script's SessionState, for looking up its local functions. [System.Management.Automation.SessionState]$CallerSessionState ) # Collect all unique SessionStates from the call stack for function lookup # This handles nested scriptblocks (button actions, child windows, etc.) $callStackSessionStates = [System.Collections.Generic.List[System.Management.Automation.SessionState]]::new() if ($CallerSessionState) { $callStackSessionStates.Add($CallerSessionState) } # Walk up the call stack and collect all unique SessionStates try { $callStack = Get-PSCallStack $flags = [System.Reflection.BindingFlags]'Instance, NonPublic, Public' $sbProp = [System.Management.Automation.ScriptBlock].GetProperty('SessionState', $flags) foreach ($frame in $callStack) { if ($frame.InvocationInfo.MyCommand.ScriptBlock -and $sbProp) { $frameState = $sbProp.GetValue($frame.InvocationInfo.MyCommand.ScriptBlock) if ($frameState -and !$callStackSessionStates.Contains($frameState)) { $callStackSessionStates.Add($frameState) } } } Write-Debug "Collected $($callStackSessionStates.Count) unique SessionStates from call stack" } catch { Write-Verbose "[Get-UiDefinition] Could not walk call stack: $_" } # Helper to look up function from any SessionState in the call stack $lookupLocalFunction = { param([string]$funcName) if ($callStackSessionStates.Count -eq 0) { return $null } # Search through all collected SessionStates foreach ($sessionState in $callStackSessionStates) { # Try InvokeCommand.GetCommand first try { $cmd = $sessionState.InvokeCommand.GetCommand( $funcName, [System.Management.Automation.CommandTypes]::Function ) if ($cmd) { Write-Debug "Found '$funcName' via SessionState lookup" return $cmd } } catch { Write-Debug "GetCommand lookup failed: $_" } # Try reflection to access internal function table try { $internal = $null $field = $sessionState.GetType().GetField( '_sessionState', [System.Reflection.BindingFlags]'Instance, NonPublic' ) if ($field) { $internal = $field.GetValue($sessionState) } if (!$internal) { $prop = $sessionState.GetType().GetProperty( 'Internal', [System.Reflection.BindingFlags]'Instance, NonPublic' ) if ($prop) { $internal = $prop.GetValue($sessionState) } } if ($internal) { $methods = $internal.GetType().GetMethods( [System.Reflection.BindingFlags]'Instance, Public, NonPublic' ) | Where-Object { $_.Name -eq 'GetFunction' } foreach ($method in $methods) { try { $params = $method.GetParameters() if ($params.Count -eq 1 -and $params[0].ParameterType -eq [string]) { $funcInfo = $method.Invoke($internal, @($funcName)) if ($funcInfo -and $funcInfo.ScriptBlock) { return [PSCustomObject]@{ Name = $funcName Definition = $funcInfo.ScriptBlock.ToString() ScriptBlock = $funcInfo.ScriptBlock CommandType = 'Function' } } } } catch { Write-Debug "Reflection invoke failed: $_" } } } } catch { Write-Debug "Reflection access failed: $_" } } return $null } # Result structure, filled in from whatever is being parsed. $cmdInfo = $null $commandDefinition = $null $commandInvocation = $null $isExternalScript = $false $functionFile = $null $fileHelp = $null # Resolve the command based on input type if ($Command -is [System.Management.Automation.CommandInfo]) { $cmdInfo = $Command if ($cmdInfo -is [System.Management.Automation.FunctionInfo]) { $commandDefinition = $cmdInfo.Definition $commandInvocation = $cmdInfo.Name } elseif ($cmdInfo -is [System.Management.Automation.ExternalScriptInfo]) { $isExternalScript = $true $commandInvocation = $cmdInfo.Path } else { $commandInvocation = $cmdInfo.Name } } elseif ($Command -is [string]) { $commandStr = $Command.Trim() # Path separators or .ps1 extension = probably a script file $looksLikeScript = $commandStr -match '\\|/' -or $commandStr -match '\.ps1$' if ($looksLikeScript) { # Resolve to absolute path - try Resolve-Path first (works for relative paths from CWD) $scriptPath = $null try { $resolved = Resolve-Path $commandStr -ErrorAction Stop $scriptPath = $resolved.Path } catch { # Resolve-Path failed, so try relative to the calling script. if ([System.IO.Path]::IsPathRooted($commandStr)) { $scriptPath = $commandStr } else { $callerPath = (Get-PSCallStack)[2].ScriptName # [2] = caller of New-UiTool if ($callerPath) { $callerDir = Split-Path $callerPath -Parent $scriptPath = Join-Path $callerDir $commandStr } else { throw "Script not found: $commandStr" } } } if (!(Test-Path $scriptPath)) { throw "Script not found: $scriptPath" } # Parse AST to check for embedded function definitions $scriptContent = Get-Content $scriptPath -Raw -ErrorAction Stop $tokens = $null $parseErrors = $null $ast = [System.Management.Automation.Language.Parser]::ParseInput($scriptContent, [ref]$tokens, [ref]$parseErrors) # A script level param() block means a UI can be built for it. $hasScriptParams = $ast.ParamBlock -and $ast.ParamBlock.Parameters.Count -gt 0 # Find all function definitions in the script $functionDefs = $ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $false) # If script has its own param block, treat it as a parameterized script # even if it contains internal helper functions if ($hasScriptParams) { # Parameterized script with internal helpers - use script params $cmdInfo = Get-Command $scriptPath -ErrorAction Stop $isExternalScript = $true $commandInvocation = $cmdInfo.Path } elseif ($functionDefs.Count -gt 0) { # Script contains function definitions but no script params - extract the target function $targetFunc = $null $scriptBaseName = [System.IO.Path]::GetFileNameWithoutExtension($scriptPath) # function global:Get-Report keeps the scope in its Name, which Get-Command and the file name both lack $scopePrefix = '^(global|local|script|private):' if ($functionDefs.Count -eq 1) { # Single function - use it $targetFunc = $functionDefs[0] } else { # Multiple functions - look for one matching the filename $targetFunc = $functionDefs | Where-Object { ($_.Name -replace $scopePrefix) -eq $scriptBaseName } | Select-Object -First 1 if (!$targetFunc) { $funcNames = ($functionDefs | ForEach-Object { $_.Name }) -join ', ' throw "Script '$scriptPath' contains multiple functions ($funcNames), and none is named after the file. Rename the file after the one to wrap, and the rest come along as its helpers." } } $targetName = $targetFunc.Name -replace $scopePrefix # Run dot sources the whole file before each call so anything at the top level besides definitions would rerun on every click $looseCode = foreach ($block in @($ast.BeginBlock, $ast.ProcessBlock, $ast.EndBlock)) { if (!$block) { continue } foreach ($statement in $block.Statements) { if ($statement -is [System.Management.Automation.Language.FunctionDefinitionAst]) { continue } if ($statement -is [System.Management.Automation.Language.TypeDefinitionAst]) { continue } $statement } } if ($looseCode) { $looseLine = @($looseCode)[0].Extent.StartLineNumber throw "Script '$scriptPath' runs code outside its functions (line $looseLine), and New-UiTool dot sources the whole file on every Run to bring $targetName and its helpers along. Move that code out of the file, or give the script a param block so it runs as a script." } # Run and Help load the file elsewhere without this session's PSDrives $functionFile = Convert-Path -LiteralPath $scriptPath # Child scope, so the file's functions go once this returns $loaded = & { param($path, $name) . $path @{ Command = Get-Command -Name $name -CommandType Function -ErrorAction Stop Help = Get-Help -Name $name -Full -ErrorAction SilentlyContinue } } $functionFile $targetName $cmdInfo = $loaded.Command $fileHelp = $loaded.Help $commandDefinition = $cmdInfo.Definition $commandInvocation = $cmdInfo.Name } else { # No functions and no script params - still try as script $cmdInfo = Get-Command $scriptPath -ErrorAction Stop $isExternalScript = $true $commandInvocation = $cmdInfo.Path } } else { # Try local function lookup first $localFunc = & $lookupLocalFunction $commandStr if ($localFunc) { Write-Debug "Found local function '$commandStr', ParameterSets=$($localFunc.ParameterSets.Count)" # Use localFunc directly if it has parameter sets, otherwise create temp function # InvokeCommand.GetCommand sometimes returns FunctionInfo with empty ParameterSets if ($localFunc.ParameterSets.Count -gt 0) { $cmdInfo = $localFunc } else { # Parameter sets are empty - create temp global function via Invoke-Expression so PS properly parses the CmdletBinding/param block $sb = $localFunc.ScriptBlock if (!$sb -and $localFunc.Definition) { $sb = [scriptblock]::Create($localFunc.Definition) } if ($sb) { $tempFuncName = "_UiDef_Temp_$([guid]::NewGuid().ToString('N').Substring(0,8))" $funcDefText = "function global:$tempFuncName { $($sb.ToString()) }" try { Invoke-Expression $funcDefText $cmdInfo = Get-Command $tempFuncName -ErrorAction Stop Write-Debug "Created temp function, ParameterSets=$($cmdInfo.ParameterSets.Count)" $cmdInfo | Add-Member -NotePropertyName 'OriginalName' -NotePropertyValue $commandStr -Force } catch { Write-Debug "Temp function creation failed: $_" $cmdInfo = $localFunc } # 5.1 throws past a finally when a click opens the tool. # Remove-Item quietly skips a function:global: path. Remove-Item -Path "function:\$tempFuncName" -ErrorAction SilentlyContinue } else { $cmdInfo = Get-Command $commandStr -ErrorAction SilentlyContinue } } $commandDefinition = if ($localFunc.Definition) { $localFunc.Definition } else { $localFunc.ScriptBlock.ToString() } $commandInvocation = $commandStr } else { $cmdInfo = Get-Command $commandStr -ErrorAction Stop if ($cmdInfo -is [System.Management.Automation.FunctionInfo]) { $commandDefinition = $cmdInfo.Definition } $commandInvocation = $cmdInfo.Name } } } else { throw "Invalid -Command type. Expected string, CommandInfo, or script path. Got: $($Command.GetType().Name)" } if (!$cmdInfo) { throw "Command '$Command' not found." } # Display name for scripts shows filename, functions show name $commandDisplayName = if ($cmdInfo.PSObject.Properties['OriginalName']) { $cmdInfo.OriginalName } elseif ($isExternalScript) { [System.IO.Path]::GetFileNameWithoutExtension($cmdInfo.Path) } else { $cmdInfo.Name } # Cmdlet's own lists so ProgressAction comes along on 7.4 and up $commonParams = @([System.Management.Automation.Cmdlet]::CommonParameters) + @([System.Management.Automation.Cmdlet]::OptionalCommonParameters) $excludeList = [System.Collections.Generic.List[string]]::new() if ($ExcludeParameters) { foreach ($param in $ExcludeParameters) { $excludeList.Add($param) } } if (!$IncludeCommonParameters) { foreach ($param in $commonParams) { $excludeList.Add($param) } } # Detect available parameter sets $allParams = $cmdInfo.Parameters $parameterSets = $cmdInfo.ParameterSets | Where-Object { $_.Name -ne '__AllParameterSets' } | ForEach-Object { $_.Name } $hasMultipleSets = $parameterSets.Count -gt 1 # Use explicit set, then default, then first available (some cmdlets have no default) $parameterSetName = if ($ParameterSet) { $ParameterSet } elseif ($cmdInfo.DefaultParameterSet) { $cmdInfo.DefaultParameterSet } elseif ($parameterSets.Count -gt 0) { $parameterSets[0] } else { $null } $paramSetDef = $null if ($parameterSetName) { $paramSetDef = $cmdInfo.ParameterSets | Where-Object { $_.Name -eq $parameterSetName } } $astDefaults = @{} try { $defAst = if ($cmdInfo.ScriptBlock) { $cmdInfo.ScriptBlock.Ast } else { $null } # A function's param block sits down in Body $astParams = if ($defAst -is [System.Management.Automation.Language.FunctionDefinitionAst]) { if ($defAst.Body.ParamBlock) { $defAst.Body.ParamBlock.Parameters } else { $defAst.Parameters } } elseif ($defAst -and $defAst.ParamBlock) { $defAst.ParamBlock.Parameters } foreach ($astParam in $astParams) { if (!$astParam.DefaultValue) { continue } $pName = $astParam.Name.VariablePath.UserPath try { $astDefaults[$pName] = Get-UiParameterDefault -Ast $astParam.DefaultValue } catch { Write-Debug "Leaving -$pName empty, its default needs code to run: $($astParam.DefaultValue.Extent.Text)" # Keeping the key lets HasDefault leave a date picker blank instead of setting it to today $astDefaults[$pName] = $null } } } catch { Write-Verbose "[Get-UiDefinition] Could not extract AST defaults: $_" } # Build parameter definitions $parameters = [System.Collections.Generic.List[object]]::new() foreach ($paramName in $allParams.Keys) { if ($excludeList -contains $paramName) { continue } $param = $allParams[$paramName] # Filter by parameter set if ($parameterSetName) { $inSet = $param.ParameterSets.ContainsKey($parameterSetName) -or $param.ParameterSets.ContainsKey('__AllParameterSets') if (!$inSet) { continue } } # Check mandatory for this specific parameter set $isMandatoryInSet = $false if ($paramSetDef) { $paramInSet = $paramSetDef.Parameters | Where-Object { $_.Name -eq $paramName } if ($paramInSet) { $isMandatoryInSet = $paramInSet.IsMandatory } } else { # Fall back to reading the [Parameter(Mandatory)] attribute directly $mandatoryAttr = $param.Attributes | Where-Object { $_ -is [System.Management.Automation.ParameterAttribute] -and $_.Mandatory } if ($mandatoryAttr) { $isMandatoryInSet = $true } } # A switch is "set-defining" if its name matches the parameter set name $isSetDefiningSwitch = $false if ($param.ParameterType -eq [switch] -and $parameterSetName) { if ($paramName -eq $parameterSetName) { $hasMandatoryParams = $paramSetDef.Parameters | Where-Object { $_.IsMandatory } | Select-Object -First 1 if (!$hasMandatoryParams) { $isSetDefiningSwitch = $true } } } # Determine control type based on parameter metadata $controlType = 'TextBox' # Default $controlOptions = @{} $validateSet = ($param.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] }).ValidValues $validateRange = $param.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateRangeAttribute] } | Select-Object -First 1 if ($validateSet -and $validateSet.Count -gt 0) { $controlType = 'Dropdown' $controlOptions.Items = $validateSet } # Ahead of the number arms in the switch, since a ranged int or double wants a slider rather than a box. elseif (($param.ParameterType -eq [int] -or $param.ParameterType -eq [double]) -and $validateRange) { $controlType = 'Slider' $controlOptions.Minimum = $validateRange.MinRange $controlOptions.Maximum = $validateRange.MaxRange } else { # Every label is a distinct type, so no value reaches two clauses and none of them needs a break. switch ($param.ParameterType) { ([switch]) { $controlType = 'Toggle' } ([bool]) { $controlType = 'Toggle' } ([datetime]) { $controlType = 'DatePicker' } ([System.Security.SecureString]) { $controlType = 'Password' } ([System.Management.Automation.PSCredential]) { $controlType = 'Credential' } ([string[]]) { $controlType = 'TextArea' } ([object[]]) { $controlType = 'TextArea' } ([int]) { $controlType = 'NumberInput'; $controlOptions.IsInteger = $true } ([long]) { $controlType = 'NumberInput'; $controlOptions.IsInteger = $true } ([double]) { $controlType = 'NumberInput'; $controlOptions.IsInteger = $false } ([float]) { $controlType = 'NumberInput'; $controlOptions.IsInteger = $false } ([decimal]) { $controlType = 'NumberInput'; $controlOptions.IsInteger = $false } } } $parameters.Add([PSCustomObject]@{ Name = $paramName Type = $param.ParameterType TypeName = $param.ParameterType.Name ControlType = $controlType ControlOptions = $controlOptions IsMandatory = $isMandatoryInSet -or $isSetDefiningSwitch HelpMessage = ($param.Attributes | Where-Object { $_ -is [System.Management.Automation.ParameterAttribute] }).HelpMessage | Select-Object -First 1 ValidateSet = $validateSet ValidateRange = $validateRange DefaultValue = $astDefaults[$paramName] HasDefault = $astDefaults.ContainsKey($paramName) Aliases = $param.Aliases IsSwitch = $param.ParameterType -eq [switch] Position = ($param.Attributes | Where-Object { $_ -is [System.Management.Automation.ParameterAttribute] }).Position | Where-Object { $_ -ge 0 } | Select-Object -First 1 }) } # Sort by mandatory first, then position, then alphabetical $parameters = $parameters | Sort-Object @{Expression={!$_.IsMandatory}}, @{Expression={if ($null -eq $_.Position) { 999 } else { $_.Position }}}, Name # Check for empty parameters, where the command has no knobs to configure. if (!$parameters -or $parameters.Count -eq 0) { $cmdDesc = if ($isExternalScript) { "Script '$commandInvocation'" } else { "Command '$commandDisplayName'" } throw "$cmdDesc has no parameters. New-UiTool requires a command with configurable parameters to generate a UI." } # Get help information $helpTarget = if ($isExternalScript) { $commandInvocation } else { $commandDisplayName } $helpInfo = if ($fileHelp) { $fileHelp } else { Get-Help $helpTarget -Full -ErrorAction SilentlyContinue } # ConvertTo-FormattedTextBlock renders the emphasis and code ticks on the About card $description = if ($helpInfo.Description) { ConvertFrom-UiHelpMarkdown -Text @($helpInfo.Description | ForEach-Object { $_.Text }) } else { # Fall back to the synopsis, or a command carrying only a .SYNOPSIS opens with an empty About card. # Get-Help makes one up for a command without help, and what it makes up is the syntax line, so that structure gets ignored $synopsis = ("$($helpInfo.Synopsis)" -replace '\s+', ' ').Trim() $ownName = [regex]::Escape("$($helpInfo.Name)") if ($synopsis -and $synopsis -notmatch "^$ownName(\s+[\[-]|$)") { $synopsis } else { $null } } # Build parameter descriptions from help $paramDescriptions = @{} if ($helpInfo.parameters.parameter) { foreach ($hp in $helpInfo.parameters.parameter) { if ($hp.Description) { $descText = ConvertFrom-UiHelpMarkdown -Text @($hp.Description | ForEach-Object { $_.Text }) -Inline if (![string]::IsNullOrWhiteSpace($descText)) { $paramDescriptions[$hp.Name] = $descText } } } } # Grab online help URI from the command (available even without Update-Help) $helpUri = if ($cmdInfo.HelpUri) { $cmdInfo.HelpUri } else { $null } # Build input helpers configuration $inputHelpers = @{ FilePicker = [System.Collections.Generic.List[string]]::new() FolderPicker = [System.Collections.Generic.List[string]]::new() ComputerPicker = [System.Collections.Generic.List[string]]::new() UserPicker = [System.Collections.Generic.List[string]]::new() GroupPicker = [System.Collections.Generic.List[string]]::new() MemberPicker = [System.Collections.Generic.List[string]]::new() OUPicker = [System.Collections.Generic.List[string]]::new() FilterBuilder = @{} } if ($FilePickerParameters) { $inputHelpers.FilePicker.AddRange($FilePickerParameters) } if ($FolderPickerParameters) { $inputHelpers.FolderPicker.AddRange($FolderPickerParameters) } if ($ComputerPickerParameters) { $inputHelpers.ComputerPicker.AddRange($ComputerPickerParameters) } if ($UserPickerParameters) { $inputHelpers.UserPicker.AddRange($UserPickerParameters) } if ($GroupPickerParameters) { $inputHelpers.GroupPicker.AddRange($GroupPickerParameters) } if ($MemberPickerParameters) { $inputHelpers.MemberPicker.AddRange($MemberPickerParameters) } if ($OUPickerParameters) { $inputHelpers.OUPicker.AddRange($OUPickerParameters) } # Detect command type to determine filter mode $cmdName = $cmdInfo.Name $filterMode = 'Generic' # switch runs every clause that matches, so each one breaks to keep the first match winning. switch -Regex ($cmdName) { '^Get-AD|^Set-AD|^New-AD|^Remove-AD' { $filterMode = 'AD'; break } '^Get-Wmi|^Get-Cim|^Invoke-Wmi|^Invoke-Cim' { $filterMode = 'WMI'; break } '^Get-ChildItem$|^Get-Item$|^Copy-Item$|^Move-Item$|^Remove-Item$|^Rename-Item$' { $filterMode = 'File'; break } default { # Scripts and functions count as file mode when they take a path and a filter both. $paramNames = $parameters | ForEach-Object { $_.Name } $hasPathParam = $paramNames | Where-Object { $_ -match '^Path$|Directory|Folder' } $hasFilterParam = $paramNames | Where-Object { $_ -match '^Filter$' } if ($hasPathParam -and $hasFilterParam) { $filterMode = 'File' } } } if (!$NoAutoHelpers) { foreach ($param in $parameters) { $pName = $param.Name if ($inputHelpers.FilePicker -contains $pName -or $inputHelpers.FolderPicker -contains $pName -or $inputHelpers.ComputerPicker -contains $pName -or $inputHelpers.UserPicker -contains $pName -or $inputHelpers.GroupPicker -contains $pName -or $inputHelpers.MemberPicker -contains $pName -or $inputHelpers.OUPicker -contains $pName) { continue } # FileServer hits the File pattern and the Server pattern both, so the breaks matter more here. switch -Regex ($pName) { 'Directory|Folder|FolderPath|DirectoryPath|^Path$|^LiteralPath$' { $inputHelpers.FolderPicker.Add($pName); break } 'File|FileName|FilePath' { $inputHelpers.FilePicker.Add($pName); break } '^Filter$|^Include$|^Exclude$' { $inputHelpers.FilterBuilder[$pName] = $filterMode; break } '^OU$|^SearchBase$|^BaseDN$|^SearchRoot$|^TargetOU$|OrganizationalUnit' { $inputHelpers.OUPicker.Add($pName); break } '^Owner$|^Manager$|^User$|^UserName$|^SamAccountName$|^UserPrincipalName$|^UPN$' { $inputHelpers.UserPicker.Add($pName); break } '^Group$|^GroupName$|^MemberOf$|^GroupDN$' { $inputHelpers.GroupPicker.Add($pName); break } '^Member$|^Members$' { $inputHelpers.MemberPicker.Add($pName); break } 'ComputerName|Computer|Server|ServerName|HostName|Host|^CN$|MachineName|Machine' { $inputHelpers.ComputerPicker.Add($pName); break } } } } # Return the complete definition schema (no WPF objects!) [PSCustomObject]@{ # Command metadata CommandInfo = $cmdInfo CommandName = $commandInvocation CommandDefinition = $commandDefinition FunctionFile = $functionFile DisplayName = $commandDisplayName Description = $description HelpUri = $helpUri IsExternalScript = $isExternalScript # Parameter set info ParameterSetName = $parameterSetName ParameterSets = $parameterSets HasMultipleSets = $hasMultipleSets # Parameter definitions (the schema) Parameters = $parameters ParamDescriptions = $paramDescriptions ParameterDefaults = $astDefaults IncludeCommon = [bool]$IncludeCommonParameters # Input helper configuration InputHelpers = $inputHelpers } } |