modules/ISC.OperatorConsole.psm1
|
#Requires -Version 5.1 Set-StrictMode -Version Latest function Import-IscModule { param( [Parameter(Mandatory)][string]$Path, [switch]$Force, [switch]$Global = $true ) $importParams = @{ Name = $Path WarningAction = 'SilentlyContinue' Global = [bool]$Global } if ($Force) { $importParams['Force'] = $true } Import-Module @importParams } function Initialize-OperatorConsole { param([switch]$NonInteractive) $script:NonInteractive = [bool]$NonInteractive $script:MenuFallbackReported = $false $script:MenuBlockerDetail = 'the console window is too short' $script:PromptCount = 0 $script:WizardCursor = 0 $script:WizardResume = 0 $script:WizardStep = -1 $script:WizardStepPromptCount = 0 $script:WizardAskedSteps = @{} $script:InWizardPrompt = $false $script:BannerDisplayed = $false } # Ensure script-scoped variables exist under Set-StrictMode -Version Latest Initialize-OperatorConsole function Test-OperatorNonInteractive { return [bool]$script:NonInteractive } if (-not ('PromptBackException' -as [type])) { try { Add-Type -TypeDefinition @' using System; using System.Management.Automation; public class PromptBackException : RuntimeException { public PromptBackException() : base("PROMPT_BACK") {} public PromptBackException(string message) : base(message) {} public PromptBackException(string message, Exception innerException) : base(message, innerException) {} } '@ -ErrorAction Stop } catch { # Fallback if Add-Type is unavailable } } $script:Esc = [char]27 $script:AnsiEraseLine = "$([char]27)[K" $script:PromptBackToken = 'PROMPT_BACK' function Write-IntegrationProjectLogIfAvailable { param( [ValidateSet('STEP', 'OK', 'INFO', 'WARN', 'ERROR')] [string]$Level, [Parameter(Mandatory)][string]$Message ) if (Get-Command Write-IntegrationProjectLogLine -ErrorAction SilentlyContinue) { Write-IntegrationProjectLogLine -Level $Level -Message $Message } } function Write-Step { param([string]$Message) Write-Host '' Write-Host ">> $Message" -ForegroundColor Cyan Write-IntegrationProjectLogIfAvailable -Level 'STEP' -Message $Message } function Write-Ok { param([string]$Message) Write-Host " $Message" -ForegroundColor Green Write-IntegrationProjectLogIfAvailable -Level 'OK' -Message $Message } function Write-Info { param([string]$Message) Write-Host " $Message" -ForegroundColor DarkGray Write-IntegrationProjectLogIfAvailable -Level 'INFO' -Message $Message } function Write-ConnectionSettings { param( [Parameter(Mandatory)][System.Collections.Specialized.OrderedDictionary]$Fields, [Parameter(Mandatory)][string]$Title, [Parameter(Mandatory)][string]$Path ) $directory = Split-Path -Parent $Path if ($directory -and -not (Test-Path -LiteralPath $directory)) { New-Item -ItemType Directory -Path $directory -Force | Out-Null } $lines = [System.Collections.Generic.List[string]]::new() foreach ($name in $Fields.Keys) { $lines.Add("### $name") $lines.Add([string]$Fields[$name]) $lines.Add('') } Set-Content -LiteralPath $Path -Value ($lines -join [Environment]::NewLine) -Encoding UTF8 Write-Ok "Saved $Title to $Path" Write-IntegrationProjectLogIfAvailable -Level 'INFO' -Message "Wrote connection settings file: $Path" } function Test-PromptBack { param($ErrorRecord) if ($null -eq $ErrorRecord) { return $false } $ex = if ($ErrorRecord -is [System.Management.Automation.ErrorRecord]) { $ErrorRecord.Exception } elseif ($ErrorRecord -is [System.Exception]) { $ErrorRecord } else { return ([string]$ErrorRecord -eq 'PROMPT_BACK' -or [string]$ErrorRecord -eq $script:PromptBackToken) } while ($ex) { if ($ex -is [PromptBackException] -or $ex.GetType().Name -eq 'PromptBackException' -or $ex.Message -eq 'PROMPT_BACK' -or $ex.Message -eq $script:PromptBackToken) { return $true } $ex = $ex.InnerException } return $false } function Invoke-PromptBack { if ('PromptBackException' -as [type]) { throw [PromptBackException]::new() } throw $script:PromptBackToken } function Invoke-PromptExit { Write-Host '' Write-Host 'Cancelled.' -ForegroundColor Yellow $e = [System.Management.Automation.PipelineStoppedException]::new() $er = [System.Management.Automation.ErrorRecord]::new($e, 'Cancelled', [System.Management.Automation.ErrorCategory]::OperationStopped, $null) throw $er } function Start-WizardPass { $script:WizardCursor = 0 $script:InWizardPrompt = $false } function Enter-WizardPrompt { $step = $script:WizardCursor $script:WizardCursor++ if ($step -lt $script:WizardResume) { return $false } $script:WizardStep = $step $script:WizardStepPromptCount = $script:PromptCount $script:InWizardPrompt = $true return $true } function Complete-WizardPrompt { if (-not $script:InWizardPrompt) { return } $script:InWizardPrompt = $false if ($script:PromptCount -gt $script:WizardStepPromptCount) { $script:WizardAskedSteps[$script:WizardStep] = $true } $script:WizardResume = $script:WizardCursor } function Move-WizardBack { $before = if ($script:InWizardPrompt) { $script:WizardStep } else { $script:WizardStep + 1 } $target = -1 foreach ($step in $script:WizardAskedSteps.Keys) { if ($step -lt $before -and $step -gt $target) { $target = $step } } if ($target -lt 0) { return $false } foreach ($step in @($script:WizardAskedSteps.Keys)) { if ($step -gt $target) { $script:WizardAskedSteps.Remove($step) } } $script:WizardResume = $target return $true } function Test-CancelledNavigation { param($ErrorRecord) if (Test-PromptBack $ErrorRecord) { return $true } return $ErrorRecord.Exception -is [System.Management.Automation.PipelineStoppedException] } function Get-ConsoleMenuBlocker { if ($script:NonInteractive) { return 'non-interactive mode' } if ($Host.Name -eq 'Windows PowerShell ISE Host') { return 'the ISE cannot read single keystrokes' } if ($Host.Name -eq 'ConsoleHost') { try { $null = [Console]::KeyAvailable } catch { return 'this console cannot read single keystrokes' } try { if ([Console]::IsOutputRedirected) { return 'console output is redirected' } } catch { } } $platform = if ($PSVersionTable.PSObject.Properties['Platform']) { $PSVersionTable.Platform } else { 'Win32NT' } if ($platform -ne 'Unix') { $vt = $Host.UI.PSObject.Properties['SupportsVirtualTerminal'] if ($vt -and -not $vt.Value) { return 'this console does not support virtual terminal sequences' } } return $null } function Read-MenuKey { $info = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown') $code = [int]$info.VirtualKeyCode $character = $info.Character $ctrl = $false try { $ctrl = ([int]$info.ControlKeyState -band 0x0C) -ne 0 } catch { } if ([int][char]$character -eq 3 -or $code -eq 3 -or ($ctrl -and $code -eq 67)) { return [PSCustomObject]@{ Name = 'CtrlC'; Char = [char]0; Character = $character } } $name = switch ($code) { 8 { 'Backspace' } 33 { 'PageUp' } 34 { 'PageDown' } 38 { 'Up' } 40 { 'Down' } 36 { 'Home' } 35 { 'End' } 32 { 'Space' } 13 { 'Enter' } 27 { 'Escape' } default { 'Other' } } return [PSCustomObject]@{ Name = $name Char = [char]::ToLowerInvariant($character) Character = $character } } function Write-MenuFallbackNotice { param([string]$Reason) if ($script:NonInteractive -or -not $Reason -or $script:MenuFallbackReported) { return } $script:MenuFallbackReported = $true Write-Info "Arrow-key menus are unavailable here ($Reason); using numbered prompts." } function Get-MenuWindowSize { try { return @{ Width = [Console]::WindowWidth; Height = [Console]::WindowHeight } } catch { } try { $size = $Host.UI.RawUI.WindowSize return @{ Width = $size.Width; Height = $size.Height } } catch { } return @{ Width = 0; Height = 0 } } function Get-MenuWidth { $width = (Get-MenuWindowSize).Width if ($width -le 20) { return 80 } return $width - 1 } # A label carrying newlines or tabs would print taller or wider than the row count the redraw # moves back over, leaving a stale copy of the menu behind on every keystroke. function ConvertTo-MenuLine { param([AllowNull()][string]$Text) if ([string]::IsNullOrEmpty($Text)) { return '' } return ($Text -replace '[\r\n\t]+', ' ') } function Invoke-ConsoleMenu { param( [Parameter(Mandatory)][string]$Prompt, [Parameter(Mandatory)][string[]]$Labels, [switch]$MultiSelect, [switch]$EscapeMeansDefault, [int]$InitialIndex = 0 ) $count = $Labels.Count if ($count -eq 0) { return , @() } $Prompt = ConvertTo-MenuLine $Prompt $Labels = @(foreach ($label in $Labels) { ConvertTo-MenuLine $label }) $selected = New-Object 'bool[]' $count $cursor = [Math]::Min([Math]::Max($InitialIndex, 0), $count - 1) $hint = if ($MultiSelect) { 'Up/Down move Space select A all N none PgUp/PgDn page Enter confirm Esc back Ctrl+C exit' } elseif ($EscapeMeansDefault) { 'Up/Down move Enter select Esc done Ctrl+C exit' } else { 'Up/Down move Enter select Esc back Ctrl+C exit' } # Scroll long lists instead of falling back to numbered prompts. # Chrome: prompt + hint + status line; keep a usable viewport in short terminals. $chromeRows = 3 $windowHeight = (Get-MenuWindowSize).Height $minWindow = 8 if ($windowHeight -gt 0 -and $windowHeight -lt $minWindow) { $script:MenuBlockerDetail = "the console is $windowHeight rows tall (need at least $minWindow)" return $null } $maxBody = if ($windowHeight -gt 0) { [Math]::Max(3, $windowHeight - $chromeRows - 1) } else { $count } $pageSize = [Math]::Min($count, $maxBody) $rows = $chromeRows + $pageSize $scrollTop = [ref]0 $ensureCursorVisible = { if ($cursor -lt $scrollTop.Value) { $scrollTop.Value = $cursor } elseif ($cursor -ge ($scrollTop.Value + $pageSize)) { $scrollTop.Value = $cursor - $pageSize + 1 } if ($scrollTop.Value -lt 0) { $scrollTop.Value = 0 } $maxTop = [Math]::Max(0, $count - $pageSize) if ($scrollTop.Value -gt $maxTop) { $scrollTop.Value = $maxTop } } $drawn = $false $draw = { . $ensureCursorVisible $top = $scrollTop.Value $width = Get-MenuWidth if ($drawn) { Write-Host ("{0}[{1}A" -f $script:Esc, $rows) -NoNewline } $drawn = $true $from = $top + 1 $to = [Math]::Min($top + $pageSize, $count) $status = if ($count -gt $pageSize) { " showing $from-$to of $count (scroll with Up/Down)" } else { " $count option$(if ($count -eq 1) { '' } else { 's' })" } $lines = @(, @($Prompt, [System.ConsoleColor]::White)) $lines += , @(" $hint", [System.ConsoleColor]::DarkGray) $lines += , @($status, [System.ConsoleColor]::DarkGray) for ($i = $top; $i -lt ($top + $pageSize) -and $i -lt $count; $i++) { $marker = if ($i -eq $cursor) { '>' } else { ' ' } $box = if ($MultiSelect) { if ($selected[$i]) { '[x] ' } else { '[ ] ' } } else { '' } $color = if ($i -eq $cursor) { [System.ConsoleColor]::Cyan } elseif ($MultiSelect -and $selected[$i]) { [System.ConsoleColor]::Green } else { [System.ConsoleColor]::Gray } $lines += , @((" {0} {1}{2}" -f $marker, $box, $Labels[$i]), $color) } # Pad to fixed height when the last page is short (keeps ANSI cursor math stable). while ($lines.Count -lt $rows) { $lines += , @('', [System.ConsoleColor]::Gray) } foreach ($line in $lines) { $text = $line[0] if ($text.Length -gt $width) { $text = $text.Substring(0, $width) } Write-Host ($text + $script:AnsiEraseLine) -ForegroundColor $line[1] } } try { try { [Console]::CursorVisible = $false } catch { } while ($true) { . $draw try { $key = Read-MenuKey } catch { $script:MenuBlockerDetail = 'this host cannot read single keystrokes' return $null } if ($key.Name -eq 'CtrlC') { Invoke-PromptExit } if ($key.Name -eq 'Up' -or $key.Char -eq 'k') { $cursor = ($cursor - 1 + $count) % $count } elseif ($key.Name -eq 'Down' -or $key.Char -eq 'j') { $cursor = ($cursor + 1) % $count } elseif ($key.Name -eq 'PageUp') { $cursor = [Math]::Max(0, $cursor - $pageSize) } elseif ($key.Name -eq 'PageDown') { $cursor = [Math]::Min($count - 1, $cursor + $pageSize) } elseif ($key.Name -eq 'Home') { $cursor = 0 } elseif ($key.Name -eq 'End') { $cursor = $count - 1 } elseif ($key.Name -eq 'Space') { if ($MultiSelect) { $selected[$cursor] = -not $selected[$cursor] } else { return , @($cursor) } } elseif ($key.Name -eq 'Enter') { if (-not $MultiSelect) { return , @($cursor) } return , @(for ($i = 0; $i -lt $count; $i++) { if ($selected[$i]) { $i } }) } elseif ($key.Name -eq 'Escape') { if ($EscapeMeansDefault) { return , @() } Invoke-PromptBack } elseif ($MultiSelect -and $key.Char -eq 'a') { for ($i = 0; $i -lt $count; $i++) { $selected[$i] = $true } } elseif ($MultiSelect -and $key.Char -eq 'n') { for ($i = 0; $i -lt $count; $i++) { $selected[$i] = $false } } } } finally { try { . $draw } catch { } try { [Console]::CursorVisible = $true } catch { } } } function Read-TypedLine { param( [string]$PromptText, [switch]$Mask ) if (Get-ConsoleMenuBlocker) { if ($Mask) { $sec = Read-Host $PromptText -AsSecureString if ($null -eq $sec) { return '' } return [System.Net.NetworkCredential]::new('', $sec).Password } return Read-Host $PromptText } Write-Host "${PromptText}: " -NoNewline $buffer = [System.Text.StringBuilder]::new() while ($true) { try { $key = Read-MenuKey } catch { Write-Host '' if ($Mask) { $sec = Read-Host $PromptText -AsSecureString if ($null -eq $sec) { return '' } return [System.Net.NetworkCredential]::new('', $sec).Password } return Read-Host $PromptText } switch ($key.Name) { 'CtrlC' { Invoke-PromptExit } 'Escape' { Write-Host '' Invoke-PromptBack } 'Enter' { Write-Host '' return $buffer.ToString() } 'Backspace' { if ($buffer.Length -gt 0) { $null = $buffer.Remove($buffer.Length - 1, 1) Write-Host "`b `b" -NoNewline } } default { $ch = $key.Character if ($ch -and [int][char]$ch -ge 32) { $null = $buffer.Append($ch) $display = if ($Mask) { '*' } else { $ch } Write-Host $display -NoNewline } } } } } function Read-InputString { param( [Parameter(Mandatory)][string]$Prompt, [string]$Default, [switch]$Required, [switch]$Mask, [scriptblock]$Validate ) if ($script:NonInteractive) { if ($Required -and [string]::IsNullOrWhiteSpace($Default)) { throw "Non-interactive mode requires a value for: $Prompt" } return $Default } $script:PromptCount++ $suffix = if ($Default) { if ($Mask) { " [***]" } else { " [$Default]" } } else { '' } while ($true) { $value = Read-TypedLine -PromptText "$Prompt$suffix" -Mask:$Mask if ([string]::IsNullOrWhiteSpace($value)) { $value = $Default } $value = if ($null -eq $value) { '' } else { $value.Trim() } if ($Required -and [string]::IsNullOrWhiteSpace($value)) { Write-Host ' A value is required.' -ForegroundColor Yellow continue } if ($Validate -and -not [string]::IsNullOrWhiteSpace($value)) { $failure = & $Validate $value if ($failure) { Write-Host " $failure" -ForegroundColor Yellow continue } } return $value } } function Read-Choice { param( [Parameter(Mandatory)][string]$Prompt, [Parameter(Mandatory)][string[]]$Options, [string[]]$Labels, [string]$Default, [switch]$EscapeMeansDefault ) if (-not $Labels) { $Labels = $Options } if ($Labels.Count -ne $Options.Count) { throw "Read-Choice requires Labels and Options to have the same length (Labels=$($Labels.Count), Options=$($Options.Count)). If you passed a single string as -Options, wrap it as [string[]]@(...). " } if ($script:NonInteractive) { if ([string]::IsNullOrWhiteSpace($Default)) { throw "Non-interactive mode requires a choice for: $Prompt" } return $Default } $script:PromptCount++ $defaultIndex = if ($Default) { [array]::IndexOf($Options, $Default) } else { 0 } if ($defaultIndex -lt 0) { $defaultIndex = 0 } $blocker = Get-ConsoleMenuBlocker if (-not $blocker) { $menuLabels = @( for ($i = 0; $i -lt $Options.Count; $i++) { if ($Options[$i] -eq $Default) { "$($Labels[$i]) (default)" } else { $Labels[$i] } } ) $picked = Invoke-ConsoleMenu -Prompt $Prompt -Labels $menuLabels -InitialIndex $defaultIndex ` -EscapeMeansDefault:$EscapeMeansDefault if ($null -ne $picked) { if (@($picked).Count -gt 0) { return $Options[@($picked)[0]] } return $Options[$defaultIndex] } $blocker = $script:MenuBlockerDetail } Write-MenuFallbackNotice -Reason $blocker Write-Host $Prompt -ForegroundColor White for ($i = 0; $i -lt $Options.Count; $i++) { $marker = if ($Options[$i] -eq $Default) { '*' } else { ' ' } Write-Host (" {0} {1}) {2}" -f $marker, ($i + 1), $Labels[$i]) } $defaultNumber = if ($Default) { $defaultIndex + 1 } else { 0 } while ($true) { $label = if ($EscapeMeansDefault) { if ($defaultNumber -gt 0) { "Select 1-$($Options.Count) [$defaultNumber]" } else { "Select 1-$($Options.Count)" } } else { if ($defaultNumber -gt 0) { "Select 1-$($Options.Count) [$defaultNumber], or b to go back" } else { "Select 1-$($Options.Count), or b to go back" } } $raw = Read-Host $label if (-not $EscapeMeansDefault -and $raw -match '^(b|back)$') { Invoke-PromptBack } if ([string]::IsNullOrWhiteSpace($raw) -and $defaultNumber -gt 0) { return $Options[$defaultNumber - 1] } $n = 0 if ([int]::TryParse($raw, [ref]$n) -and $n -ge 1 -and $n -le $Options.Count) { return $Options[$n - 1] } Write-Host ' Enter a number from the list.' -ForegroundColor Yellow } } function Read-MultiChoice { param( [Parameter(Mandatory)][AllowEmptyCollection()][AllowEmptyString()][string[]]$Options, [Parameter(Mandatory)][string]$Prompt, [AllowEmptyCollection()][AllowEmptyString()][string[]]$Labels ) $Options = [string[]]@($Options) if (-not $Labels) { $Labels = $Options } else { $Labels = [string[]]@($Labels) } if ($Labels.Count -ne $Options.Count) { throw "Read-MultiChoice requires Labels and Options to have the same length (Labels=$($Labels.Count), Options=$($Options.Count))." } if ($Options.Count -eq 0 -or ($Options | Where-Object { [string]::IsNullOrWhiteSpace($_) })) { throw 'Read-MultiChoice requires non-empty option values (a lone empty string often means entitlement ids failed to resolve).' } if ($script:NonInteractive) { return @() } $script:PromptCount++ $blocker = Get-ConsoleMenuBlocker if (-not $blocker) { $picked = Invoke-ConsoleMenu -Prompt $Prompt -Labels $Labels -MultiSelect if ($null -ne $picked) { return @(@($picked) | ForEach-Object { $Options[$_] }) } $blocker = $script:MenuBlockerDetail } Write-MenuFallbackNotice -Reason $blocker Write-Host $Prompt -ForegroundColor White for ($i = 0; $i -lt $Options.Count; $i++) { Write-Host (" {0}) {1}" -f ($i + 1), $Labels[$i]) } while ($true) { $raw = Read-Host "Select numbers separated by commas, Enter for none, or b to go back" if ($raw -match '^(b|back)$') { Invoke-PromptBack } if ([string]::IsNullOrWhiteSpace($raw)) { return @() } $selected = [System.Collections.Generic.List[string]]::new() $valid = $true foreach ($token in ($raw -split ',')) { $n = 0 if ([int]::TryParse($token.Trim(), [ref]$n) -and $n -ge 1 -and $n -le $Options.Count) { if (-not $selected.Contains($Options[$n - 1])) { $selected.Add($Options[$n - 1]) } } else { $valid = $false break } } if ($valid) { return $selected.ToArray() } Write-Host ' Enter numbers from the list, separated by commas.' -ForegroundColor Yellow } } function Read-YesNo { param( [Parameter(Mandatory)][string]$Prompt, [bool]$Default = $true ) if ($script:NonInteractive) { return $Default } $defaultOption = if ($Default) { 'Yes' } else { 'No' } return (Read-Choice -Prompt $Prompt -Options @('Yes', 'No') -Default $defaultOption) -eq 'Yes' } function Copy-ToClipboard { param([Parameter(Mandatory)][AllowEmptyString()][string]$Text) $setClipboard = Get-Command Set-Clipboard -ErrorAction SilentlyContinue if ($setClipboard) { try { Set-Clipboard -Value $Text -ErrorAction Stop return $true } catch { } } foreach ($tool in @('pbcopy', 'wl-copy', 'xclip')) { $cmd = Get-Command $tool -ErrorAction SilentlyContinue if (-not $cmd) { continue } try { $toolArgs = if ($tool -eq 'xclip') { @('-selection', 'clipboard') } else { @() } $Text | & $cmd.Source @toolArgs return $true } catch { } } return $false } function Open-Url { param([Parameter(Mandatory)][string]$Url) try { $platform = if ($PSVersionTable.PSObject.Properties['Platform']) { $PSVersionTable.Platform } else { 'Win32NT' } if ($platform -eq 'Unix') { $opener = if (Get-Command open -ErrorAction SilentlyContinue) { 'open' } else { 'xdg-open' } Start-Process $opener $Url -ErrorAction Stop | Out-Null } else { Start-Process $Url -ErrorAction Stop | Out-Null } return $true } catch { return $false } } function Get-MaskedSecretDisplay { param([AllowNull()][string]$Value) if ([string]::IsNullOrEmpty($Value)) { return '(empty)' } if ($Value.Length -le 4) { return '***' } return ('***' + $Value.Substring($Value.Length - 4)) } function Get-CompletionPreview { param( [AllowNull()][string]$Value, [switch]$Mask ) if ($Mask) { return Get-MaskedSecretDisplay -Value $Value } if ([string]::IsNullOrEmpty($Value)) { return '(empty)' } $Value = ConvertTo-MenuLine $Value if ($Value.Length -le 72) { return $Value } return ($Value.Substring(0, 69) + '...') } function Get-CompletionSaveToDiskLabel { param([string]$Path) $name = if ([string]::IsNullOrWhiteSpace($Path)) { 'results.txt' } else { Split-Path -Leaf $Path } return "Save to disk ($name)" } function Get-CompletionActionMenuChoices { param( [Parameter(Mandatory)][object[]]$Items, [switch]$AllowSaveToDisk, [string]$SavePath ) $labels = [System.Collections.Generic.List[string]]::new() foreach ($item in $Items) { $preview = Get-CompletionPreview -Value ([string]$item.Value) -Mask:([bool]$item.Mask) $verb = if ($item.Kind -eq 'Open') { 'Open' } else { 'Copy' } $labels.Add(('{0}: {1} ({2})' -f $item.Label, $preview, $verb)) } if ($AllowSaveToDisk) { $labels.Add((Get-CompletionSaveToDiskLabel -Path $SavePath)) } $labels.Add('Done') return $labels.ToArray() } function Protect-CompletionResultsFile { param([Parameter(Mandatory)][string]$Path) $onWindows = $false if ($PSVersionTable.PSObject.Properties['Platform']) { $onWindows = $PSVersionTable.Platform -eq 'Win32NT' } elseif ($env:OS -like 'Windows*') { $onWindows = $true } if ($onWindows) { $acl = Get-Acl -LiteralPath $Path $rule = New-Object System.Security.AccessControl.FileSystemAccessRule( [System.Security.Principal.WindowsIdentity]::GetCurrent().Name, 'FullControl', 'Allow' ) $acl.SetAccessRule($rule) $acl | Set-Acl -LiteralPath $Path } else { & chmod 600 $Path } } function Save-CompletionResultsToDisk { param( [Parameter(Mandatory)][object[]]$Items, [Parameter(Mandatory)][string]$Path, [string]$Title, [string[]]$Situation ) $directory = Split-Path -Parent $Path if ($directory -and -not (Test-Path -LiteralPath $directory)) { New-Item -ItemType Directory -Path $directory -Force | Out-Null } $lines = [System.Collections.Generic.List[string]]::new() if ($Title) { $lines.Add("### $Title") $lines.Add('') } foreach ($line in @($Situation)) { if (-not [string]::IsNullOrWhiteSpace($line)) { $lines.Add($line) } } if ($lines.Count -gt 0) { $lines.Add('') } foreach ($item in $Items) { $lines.Add("### $($item.Label)") $lines.Add([string]$item.Value) $lines.Add('') } Set-Content -LiteralPath $Path -Value ($lines -join [Environment]::NewLine) -Encoding UTF8 Protect-CompletionResultsFile -Path $Path Write-Ok "Saved results (secrets included) to $Path" return $Path } function Write-CompletionSummary { param( [Parameter(Mandatory)][string]$Title, [Parameter(Mandatory)][string[]]$Situation, [Parameter(Mandatory)][object[]]$Items ) Write-Host '' Write-Host " $Title" -ForegroundColor Cyan Write-Host (' ' + ('-' * [Math]::Min($Title.Length, 60))) -ForegroundColor Cyan foreach ($line in $Situation) { Write-Host " $line" -ForegroundColor White } if ($Items.Count -eq 0) { return } Write-Host '' $labelWidth = ($Items | ForEach-Object { ([string]$_.Label).Length } | Measure-Object -Maximum).Maximum foreach ($item in $Items) { $valueLines = @(([string]$item.Value) -split "`r?`n") Write-Host (" {0} : {1}" -f ([string]$item.Label).PadRight($labelWidth), $valueLines[0]) -ForegroundColor Yellow foreach ($extra in ($valueLines | Select-Object -Skip 1)) { Write-Host (" {0} {1}" -f (' ' * $labelWidth), $extra.Trim()) -ForegroundColor Yellow } } } function Invoke-CompletionActionMenu { param( [Parameter(Mandatory)][string]$Title, [Parameter(Mandatory)][string[]]$Situation, [Parameter(Mandatory)][object[]]$Items, [string]$Instruction = 'Copy each value into the matching ISC Connection Settings field, or open a link to finish pending manual steps.', [switch]$AllowSaveToDisk, [string]$SavePath ) Write-Host '' Write-Host " $Title" -ForegroundColor Cyan Write-Host (' ' + ('-' * [Math]::Min($Title.Length, 60))) -ForegroundColor Cyan foreach ($line in $Situation) { Write-Host " $line" -ForegroundColor White } Write-Host '' Write-Host " $Instruction" -ForegroundColor Yellow if ($Items.Count -eq 0) { return } if ($script:NonInteractive) { Write-Host '' foreach ($item in $Items) { $preview = Get-CompletionPreview -Value ([string]$item.Value) -Mask:([bool]$item.Mask) $verb = if ($item.Kind -eq 'Open') { 'Open' } else { 'Copy' } Write-Host " $($item.Label): $preview ($verb)" -ForegroundColor Yellow } return } $doneLabel = 'Done' $saveLabel = Get-CompletionSaveToDiskLabel -Path $SavePath while ($true) { $labels = @(Get-CompletionActionMenuChoices -Items $Items -AllowSaveToDisk:$AllowSaveToDisk -SavePath $SavePath) $picked = Read-Choice -Prompt 'Select a value to copy or a link to open:' ` -Options $labels ` -Default $doneLabel ` -EscapeMeansDefault if ($picked -eq $doneLabel) { return } if ($AllowSaveToDisk -and $picked -eq $saveLabel) { $defaultPath = $SavePath try { $path = Read-InputString -Prompt 'File path for results (secrets included)' -Default $defaultPath -Required } catch { if (Test-PromptBack $_) { continue } throw } Save-CompletionResultsToDisk -Items $Items -Path $path -Title $Title -Situation $Situation | Out-Null continue } $index = [array]::IndexOf($labels, $picked) if ($index -lt 0 -or $index -ge $Items.Count) { continue } $selected = $Items[$index] if ($selected.Kind -eq 'Open') { if (Open-Url -Url ([string]$selected.Value)) { Write-Ok "Opened $($selected.Label)" } else { Write-Warning "Could not open a browser. URL: $($selected.Value)" } } else { if (Copy-ToClipboard -Text ([string]$selected.Value)) { Write-Ok "$($selected.Label) copied to the clipboard" } else { Write-Warning 'No clipboard tool is available (Set-Clipboard, pbcopy, wl-copy, or xclip). Copy from the list above.' } } } } function Get-SailPointConnectVersion { $m = Get-Module -Name 'SailPoint.Connect' -ErrorAction SilentlyContinue if ($m -and $m.Version) { return $m.Version.ToString() } $root = Split-Path $PSScriptRoot -Parent $manifestPath = Join-Path $root 'SailPoint.Connect.psd1' if (Test-Path -LiteralPath $manifestPath) { try { $data = Import-PowerShellDataFile -LiteralPath $manifestPath -ErrorAction SilentlyContinue if ($data -and $data['ModuleVersion']) { return [string]$data['ModuleVersion'] } } catch {} } return '1.4.0' } function Write-SailPointConnectBanner { [CmdletBinding()] param( [string]$ProjectName, [string]$Version, [switch]$Force ) if (Test-OperatorNonInteractive) { return } if ($script:BannerDisplayed -and -not $Force) { return } if (-not $Version) { $Version = Get-SailPointConnectVersion } $title = 'SailPoint Connect' $versionText = if ($Version -match '^v') { $Version } else { "v$Version" } $tagline = 'Guided setup for ISC connectors and Entro onboarding' $lines = [System.Collections.Generic.List[hashtable]]::new() $lines.Add(@{ Text = "$title $versionText"; Color = 'Cyan' }) $lines.Add(@{ Text = $tagline; Color = 'DarkGray' }) if ($ProjectName) { $lines.Add(@{ Text = "Workspace: $ProjectName"; Color = 'Yellow' }) } $contentWidth = 60 foreach ($line in $lines) { if ($line.Text.Length -gt $contentWidth) { $contentWidth = $line.Text.Length } } Write-Host '' Write-Host (" ┌─" + ('─' * $contentWidth) + "─┐") -ForegroundColor Cyan foreach ($line in $lines) { $padded = $line.Text.PadRight($contentWidth) Write-Host " │ $padded │" -ForegroundColor $line.Color } Write-Host (" └─" + ('─' * $contentWidth) + "─┘") -ForegroundColor Cyan Write-Host '' $script:BannerDisplayed = $true } function Write-ReportStdout { [CmdletBinding()] param( [Parameter(Mandatory)][string]$Content ) if (-not [string]::IsNullOrWhiteSpace($Content)) { [Console]::Out.WriteLine($Content.TrimEnd()) [Console]::Out.WriteLine() } } function Format-TerminalHyperlink { [CmdletBinding()] param( [Parameter(Mandatory, Position = 0)][string]$Path, [Parameter(Position = 1)][string]$Label ) if ([string]::IsNullOrWhiteSpace($Path)) { return '' } $displayText = if ([string]::IsNullOrEmpty($Label)) { $Path } else { $Label } if (Test-OperatorNonInteractive) { return $displayText } if ($env:TERM -eq 'dumb' -or $env:NO_COLOR) { return $displayText } try { $uri = if ($Path -match '^[a-zA-Z][a-zA-Z0-9+.-]*://') { $Path } else { $fullPath = [System.IO.Path]::GetFullPath($Path) [System.Uri]::new($fullPath).AbsoluteUri } $esc = [char]27 return "$esc]8;;$uri$esc\$displayText$esc]8;;$esc\" } catch { return $displayText } } Export-ModuleMember -Function @( 'Import-IscModule' 'Initialize-OperatorConsole' 'Test-OperatorNonInteractive' 'Write-Step' 'Write-Ok' 'Write-Info' 'Write-ConnectionSettings' 'Test-PromptBack' 'Invoke-PromptBack' 'Invoke-PromptExit' 'Start-WizardPass' 'Enter-WizardPrompt' 'Complete-WizardPrompt' 'Move-WizardBack' 'Test-CancelledNavigation' 'Get-ConsoleMenuBlocker' 'Invoke-ConsoleMenu' 'Read-TypedLine' 'Read-InputString' 'Read-Choice' 'Read-MultiChoice' 'Read-YesNo' 'Copy-ToClipboard' 'Open-Url' 'Get-MaskedSecretDisplay' 'Get-CompletionPreview' 'Get-CompletionSaveToDiskLabel' 'Get-CompletionActionMenuChoices' 'Save-CompletionResultsToDisk' 'Write-CompletionSummary' 'Invoke-CompletionActionMenu' 'ConvertTo-MenuLine' 'Get-SailPointConnectVersion' 'Write-SailPointConnectBanner' 'Write-ReportStdout' 'Format-TerminalHyperlink' ) |