Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,9 @@ jobs:
- name: Analyze scripts/windows/setup.ps1
run: pwsh -Command "Invoke-ScriptAnalyzer -Path scripts/windows/setup.ps1 -EnableExit"

- name: Analyze scripts/windows/lib/tui.ps1
run: pwsh -Command "Invoke-ScriptAnalyzer -Path scripts/windows/lib/tui.ps1 -EnableExit"

validate-powershell:
name: Validate PowerShell Functions
runs-on: windows-latest
Expand All @@ -279,6 +282,9 @@ jobs:
- name: Flag test - -List (PowerShell)
run: .\setup.ps1 -List

- name: Run TUI and resolver tests (pwsh)
run: pwsh tests/test_tui_pwsh.ps1

validate-ps51:
name: Validate PowerShell 5.1 Compatibility
runs-on: windows-latest
Expand Down Expand Up @@ -322,12 +328,28 @@ jobs:
}
Write-Host "Syntax check passed"

- name: Syntax check tui.ps1 (PS 5.1)
shell: powershell
run: |
$errors = $null
$null = [System.Management.Automation.Language.Parser]::ParseFile(
"$env:GITHUB_WORKSPACE\scripts\windows\lib\tui.ps1",
[ref]$null,
[ref]$errors
)
if ($errors.Count -gt 0) {
$errors | ForEach-Object { Write-Error $_.Message }
exit 1
}
Write-Host "Syntax check passed"

- name: Run PSScriptAnalyzer under PS 5.1
shell: powershell
run: |
Install-Module -Name PSScriptAnalyzer -Force -Scope CurrentUser
$results = @()
$results += Invoke-ScriptAnalyzer -Path "$env:GITHUB_WORKSPACE\scripts\windows\setup.ps1"
$results += Invoke-ScriptAnalyzer -Path "$env:GITHUB_WORKSPACE\scripts\windows\lib\tui.ps1"
$results += Invoke-ScriptAnalyzer -Path "$env:GITHUB_WORKSPACE\setup.ps1"
if ($results.Count -gt 0) {
$results | Format-Table -AutoSize
Expand Down Expand Up @@ -358,6 +380,11 @@ jobs:
run: |
powershell -ExecutionPolicy Bypass -File tests\test_setup_flags_pwsh.ps1

- name: Run TUI and resolver tests (PS 5.1)
shell: powershell
run: |
powershell -ExecutionPolicy Bypass -File tests\test_tui_pwsh.ps1

- name: Configure git hooks path
shell: powershell
run: |
Expand Down
133 changes: 133 additions & 0 deletions scripts/windows/lib/tui.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# scripts/windows/lib/tui.ps1 -- PS 5.1 ASCII menu and toolset resolution (#495 Slice 3)
#
# Dot-sourced by scripts/windows/setup.ps1.
# Exports: Resolve-FinalToolset, Show-ToolMenu
#
# PS 5.1 ASCII-only: no smart quotes, em-dashes, arrows, or non-ASCII characters.
# No Write-Host. Console output via [Console]::Write / [Console]::WriteLine only.

# ---------------------------------------------------------------------------
# Resolve-FinalToolset: canonical ordered tool resolver.
# Pure function -- no exit, no console output, no validation.
# Callers must validate tool names before calling (non-interactive path).
#
# Returns [string[]] in install order:
# -Only path : DefaultTools order first, then opt-ins appended alphabetically.
# -Skip path : DefaultTools minus skipped, in default order.
# default : DefaultTools unchanged.
# ---------------------------------------------------------------------------
function Resolve-FinalToolset {
param(
[string[]]$DefaultTools = @(),
[string]$Only = '',
[bool]$OnlySet = $false,
[string]$Skip = '',
[bool]$SkipSet = $false
)
[string[]]$finalTools = @()
if ($OnlySet) {
$names = @($Only.Split(',') | Where-Object { $_ -ne '' })
# Preserve default order for requested defaults
foreach ($tool in $DefaultTools) {
if ($names -contains $tool) { $finalTools += $tool }
}
# Opt-ins (requested but not in DefaultTools): append alphabetically
$optIn = @($names | Where-Object { $DefaultTools -notcontains $_ } | Sort-Object)
foreach ($t in $optIn) { $finalTools += $t }
} elseif ($SkipSet) {
$names = @($Skip.Split(',') | Where-Object { $_ -ne '' })
foreach ($tool in $DefaultTools) {
if ($names -notcontains $tool) { $finalTools += $tool }
}
} else {
$finalTools = $DefaultTools
}
return ,$finalTools
}

# ---------------------------------------------------------------------------
# Show-ToolMenu: interactive ASCII checkbox menu.
#
# Returns:
# $null -- user cancelled (Esc/Q) OR ReadKey failure (safe cancel fallback)
# [string[]]@() -- user confirmed with nothing checked
# [string[]] -- user confirmed; array of checked tool names in display order
#
# ponytail: $null vs @() return -- verified safe: cancel returns $null (no comma),
# non-null returns use ,$selected (comma prefix) to prevent pipeline unrolling.
# Caller assigns [string[]]$x = Show-ToolMenu; $null-eq-check and Count-check
# are distinguishable. See test T_menu_noop_empty_ps.
#
# Display order: DefaultTools pre-checked (default), then sorted opt-ins unchecked.
# Keys: Up/Down = move cursor; Space = toggle; A = toggle all; Enter = confirm; Esc/Q = cancel.
# ---------------------------------------------------------------------------
function Show-ToolMenu {
param(
[string[]]$DefaultTools = @(),
[string[]]$Available = @()
)

# Build display order: defaults (pre-checked) then sorted opt-ins (unchecked)
$optIns = @($Available | Where-Object { $DefaultTools -notcontains $_ } | Sort-Object)
$items = @($DefaultTools) + $optIns
$nDef = $DefaultTools.Count
[bool[]]$checked = @()
for ($i = 0; $i -lt $nDef; $i++) { $checked += $true }
for ($i = 0; $i -lt $optIns.Count; $i++) { $checked += $false }

$cursor = 0
$confirmed = $false
$cancelled = $false

# Render-Menu as scriptblock (closure over $items, $checked, $cursor, $nDef)
$renderMenu = {
[Console]::WriteLine('Select tools to install (defaults pre-checked):')
[Console]::WriteLine('')
for ($i = 0; $i -lt $items.Count; $i++) {
$mark = if ($checked[$i]) { '[x]' } else { '[ ]' }
$label = if ($i -lt $nDef) { '(default)' } else { '(opt-in)' }
$arrow = if ($i -eq $cursor) { '>' } else { ' ' }
[Console]::WriteLine(" $arrow $mark $($items[$i]) $label")
}
[Console]::WriteLine('')
[Console]::WriteLine(' Up/Down=move Space=toggle A=all Enter=confirm Esc/Q=cancel')
}

try {
[Console]::Clear()
& $renderMenu

while (-not $confirmed -and -not $cancelled) {
$key = [Console]::ReadKey($true)
[Console]::Clear()
switch ($key.Key) {
'UpArrow' { $cursor = [Math]::Max(0, $cursor - 1) }
'DownArrow' { $cursor = [Math]::Min($items.Count - 1, $cursor + 1) }
'Spacebar' { $checked[$cursor] = -not $checked[$cursor] }
'Enter' { $confirmed = $true }
'Escape' { $cancelled = $true }
'Q' { $cancelled = $true }
'A' {
$anyUnchecked = $checked -contains $false
for ($i = 0; $i -lt $checked.Count; $i++) {
$checked[$i] = $anyUnchecked
}
}
}
if (-not $confirmed -and -not $cancelled) { & $renderMenu }
}
} catch {
# ReadKey failure in a nominally interactive host: one clear warning, install cancelled
[Console]::Error.WriteLine(
"WARNING: Interactive menu failed ($($_.Exception.Message)). Installation is being cancelled.")
return $null
}

if ($cancelled) { return $null }

[string[]]$selected = @()
for ($i = 0; $i -lt $items.Count; $i++) {
if ($checked[$i]) { $selected += $items[$i] }
}
return ,$selected
}
115 changes: 51 additions & 64 deletions scripts/windows/setup.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ $ErrorActionPreference = 'Stop'

. "$PSScriptRoot\lib\logging.ps1"
. "$PSScriptRoot\lib\path.ps1"
. "$PSScriptRoot\lib\tui.ps1"

# Dot-source all tool installer modules
. "$PSScriptRoot\tools\winget-check.ps1"
Expand Down Expand Up @@ -212,6 +213,8 @@ function Test-ShouldShowMenu {
[bool]$InteractiveRequested,
[bool]$SelectionFileSet
)
# ponytail: test seam -- remove when TTY simulation available in CI
if ($env:_PS_TUI_TEST_MENU -eq '1') { return $true }
if ($NonInteractiveRequested -or $OnlySet -or $SkipSet) { return $false }
# -Interactive + -SelectionFile: bypass CI/TTY detection so CI can test the menu path.
if ($InteractiveRequested -and $SelectionFileSet) { return $true }
Expand All @@ -221,82 +224,66 @@ function Test-ShouldShowMenu {
return $true
}

# ponytail: detection-only ceiling for Slice 1; Slice 3 wires the PowerShell menu here.
$null = Test-ShouldShowMenu `
-NonInteractiveRequested $NonInteractive.IsPresent `
-OnlySet ($PSBoundParameters.ContainsKey('Only')) `
-SkipSet ($PSBoundParameters.ContainsKey('Skip')) `
-InteractiveRequested $Interactive.IsPresent `
-SelectionFileSet ($PSBoundParameters.ContainsKey('SelectionFile'))

# ---------------------------------------------------------------------------
# Build FinalToolSet
# Determine final toolset: menu path (interactive) or flag path (non-interactive)
# ---------------------------------------------------------------------------
$FinalTools = @()
$Available = Get-AvailableTool
$UseSelectionFile = $PSBoundParameters.ContainsKey('SelectionFile') -and
-not $PSBoundParameters.ContainsKey('Only') -and
-not $PSBoundParameters.ContainsKey('Skip')
$Available = Get-AvailableTool
[string[]]$FinalTools = @()

# Selection-file: validate, join names, route through the canonical -Only path.
$EffectiveOnly = ''
$UseOnlyPath = $false
if (Test-ShouldShowMenu `
-NonInteractiveRequested $NonInteractive.IsPresent `
-OnlySet ($PSBoundParameters.ContainsKey('Only')) `
-SkipSet ($PSBoundParameters.ContainsKey('Skip')) `
-InteractiveRequested $Interactive.IsPresent `
-SelectionFileSet ($PSBoundParameters.ContainsKey('SelectionFile'))) {

if ($UseSelectionFile) {
if ([string]::IsNullOrEmpty($SelectionFile) -or -not (Test-Path -LiteralPath $SelectionFile -PathType Leaf)) {
Write-Err "Selection file not found: $SelectionFile"
exit 1
# Interactive path: selection-file seam (CI/test) or live menu
if ($PSBoundParameters.ContainsKey('SelectionFile') -and $SelectionFile) {
[string[]]$selectedNames = @(Get-Content -LiteralPath $SelectionFile |
Where-Object { -not [string]::IsNullOrEmpty($_) })
} else {
[string[]]$selectedNames = Show-ToolMenu -DefaultTools $DefaultTools -Available $Available
}
$fileNames = @(Get-Content -LiteralPath $SelectionFile | Where-Object { $_ -ne '' })
if ($fileNames.Count -eq 0) {
Write-Err "Flag requires at least one tool name."
exit 1
}
$EffectiveOnly = $fileNames -join ','
$UseOnlyPath = $true
} elseif ($PSBoundParameters.ContainsKey('Only')) {
$EffectiveOnly = $Only
$UseOnlyPath = $true
}

if ($UseOnlyPath) {
$names = Split-ToolList -ToolList $EffectiveOnly
foreach ($name in $names) {
if ($Available -notcontains $name) {
Write-Err "Unknown tool: $name"
Write-Err "Available tools: $($Available -join ', ')"
Write-Err "Use -List to see all available tools."
exit 1
}
if ($null -eq $selectedNames) {
Write-Output 'Install cancelled.'
exit 0
}
# ORDER PRESERVATION: iterate DefaultTools, include those requested.
# Do NOT use input order -- dependencies require the default sequence.
foreach ($tool in $DefaultTools) {
if ($names -contains $tool) {
$FinalTools += $tool
}
if ($selectedNames.Count -eq 0) {
Write-Output 'Nothing selected, exiting.'
exit 0
}
# Opt-in tools (requested but NOT in DefaultTools): append alphabetically.
$optIn = @($names | Where-Object { $DefaultTools -notcontains $_ } | Sort-Object)
foreach ($t in $optIn) { $FinalTools += $t }
$FinalTools = Resolve-FinalToolset -DefaultTools $DefaultTools `
-Only ($selectedNames -join ',') -OnlySet $true

} elseif ($PSBoundParameters.ContainsKey('Skip')) {
$names = Split-ToolList -ToolList $Skip
foreach ($name in $names) {
if ($Available -notcontains $name) {
Write-Err "Unknown tool: $name"
Write-Err "Available tools: $($Available -join ', ')"
exit 1
} else {
# Non-interactive path: validate tool names then resolve
if ($PSBoundParameters.ContainsKey('Only')) {
$names = Split-ToolList -ToolList $Only
foreach ($name in $names) {
if ($Available -notcontains $name) {
Write-Err "Unknown tool: $name"
Write-Err "Available tools: $($Available -join ', ')"
Write-Err "Use -List to see all available tools."
exit 1
}
}
}
foreach ($tool in $DefaultTools) {
if ($names -notcontains $tool) {
$FinalTools += $tool
$FinalTools = Resolve-FinalToolset -DefaultTools $DefaultTools `
-Only $Only -OnlySet $true
} elseif ($PSBoundParameters.ContainsKey('Skip')) {
$names = Split-ToolList -ToolList $Skip
foreach ($name in $names) {
if ($Available -notcontains $name) {
Write-Err "Unknown tool: $name"
Write-Err "Available tools: $($Available -join ', ')"
exit 1
}
}
$FinalTools = Resolve-FinalToolset -DefaultTools $DefaultTools `
-Skip $Skip -SkipSet $true
} else {
$FinalTools = Resolve-FinalToolset -DefaultTools $DefaultTools
}

} else {
$FinalTools = $DefaultTools
}

# ---------------------------------------------------------------------------
Expand Down
Loading
Loading