From 4b8161bfc6af82408636db466a63676b55699579 Mon Sep 17 00:00:00 2001 From: "Earl Tankard, Jr., Ph.D" <45021016+primetimetank21@users.noreply.github.com> Date: Wed, 8 Jul 2026 07:30:41 -0400 Subject: [PATCH 1/2] feat(flags): WI-2 --only selective install with order preservation (#468) Implements the --only/-Only flag for selective tool installation on both Linux/macOS and Windows. Closes the gap that unblocks #466 (git-delta) and #467 (lazygit) as opt-in tools reachable via --only. Changes: - scripts/linux/setup.sh: ARG_ONLY_SET sentinel + order-preserving build_final_toolset: iterates DEFAULT_TOOLS to filter requested tools (default order maintained), then appends opt-in tools alphabetically. Empty --only= now correctly exits 1. - scripts/windows/setup.ps1: \System.Management.Automation.PSBoundParametersDictionary.ContainsKey('Only') guard so empty -Only '' correctly exits 1; same order-preservation logic as Linux (DefaultTools filter + alphabetical opt-in append). - setup.ps1 (root): adds \/\ params + forwarding so root entry point correctly propagates -Only/-Skip to platform script. - tests/test_setup_flags.sh: 12 WI-2 bash tests (order-preservation, opt-in reachability, blank CSV validation, copilot-cli alias, backward compat gate, root forwarding). - tests/test_setup_flags_pwsh.ps1: 13 WI-2 PS tests (same coverage). Order-preservation invariant: --only=copilot-cli,nvm installs nvm BEFORE copilot-cli because DEFAULT_TOOLS defines that order. Opt-in tools (not in DEFAULT_TOOLS) append after default-ordered tools, alphabetically. Bash 3.2 safe: no mapfile/local -n/declare -n. PSScriptAnalyzer clean: approved verbs, singular nouns, used params. ASCII-only. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/linux/setup.sh | 39 +++++- scripts/windows/setup.ps1 | 14 ++- setup.ps1 | 8 +- tests/test_setup_flags.sh | 211 +++++++++++++++++++++++++++++++- tests/test_setup_flags_pwsh.ps1 | 197 ++++++++++++++++++++++++++++- 5 files changed, 459 insertions(+), 10 deletions(-) diff --git a/scripts/linux/setup.sh b/scripts/linux/setup.sh index b53f64a..253afd3 100755 --- a/scripts/linux/setup.sh +++ b/scripts/linux/setup.sh @@ -52,10 +52,11 @@ ARG_SKIP="" ARG_LIST=0 ARG_HELP=0 ARG_TOOLS_DIR="" # hidden test seam +ARG_ONLY_SET=0 # tracks whether --only was explicitly provided for arg in "$@"; do case "$arg" in - --only=*) ARG_ONLY="${arg#--only=}" ;; + --only=*) ARG_ONLY="${arg#--only=}"; ARG_ONLY_SET=1 ;; --skip=*) ARG_SKIP="${arg#--skip=}" ;; --list) ARG_LIST=1 ;; --help) ARG_HELP=1 ;; @@ -164,6 +165,8 @@ if [[ -n "$ARG_ONLY" && -n "$ARG_SKIP" ]]; then exit 1 fi +# (Note: ARG_ONLY_SET handles --only= with empty value; build_final_toolset validates.) + # --------------------------------------------------------------------------- # Build FinalToolSet -- populates global FINAL_TOOLS (bash 3.2 safe: no # local -n namerefs, no mapfile; use plain global array + while-read). @@ -179,7 +182,7 @@ build_final_toolset() { [[ -n "$_t" ]] && available+=("$_t") done < <(get_available_tools) - if [[ -n "$ARG_ONLY" ]]; then + if [[ $ARG_ONLY_SET -eq 1 ]]; then validate_csv_shape "$ARG_ONLY" local only_list=() IFS=',' read -ra only_list <<< "$ARG_ONLY" @@ -193,10 +196,40 @@ build_final_toolset() { if [[ $found -eq 0 ]]; then log_error "Unknown tool: ${name}" log_error "Available tools: $(get_available_tools | tr '\n' ' ')" + log_error "Use --list to see all available tools." exit 1 fi done - FINAL_TOOLS=("${only_list[@]}") + # ORDER PRESERVATION: iterate DEFAULT_TOOLS, include those requested. + # Do NOT use input order -- dependencies require the default sequence. + local tool _in_only + for tool in "${DEFAULT_TOOLS[@]}"; do + _in_only=0 + for name in "${only_list[@]}"; do + [[ "$name" == "$tool" ]] && _in_only=1 && break + done + [[ $_in_only -eq 1 ]] && FINAL_TOOLS+=("$tool") + done + # Opt-in tools (requested but NOT in DEFAULT_TOOLS): append alphabetically. + # These have no defined default position, so alphabetical is deterministic. + local _in_default + local _optin_names + _optin_names=() + for name in "${only_list[@]}"; do + _in_default=0 + for tool in "${DEFAULT_TOOLS[@]}"; do + [[ "$tool" == "$name" ]] && _in_default=1 && break + done + [[ $_in_default -eq 0 ]] && _optin_names+=("$name") + done + if [[ ${#_optin_names[@]} -gt 0 ]]; then + local _sorted_optin + _sorted_optin=() + while IFS= read -r _t; do + [[ -n "$_t" ]] && _sorted_optin+=("$_t") + done < <(printf '%s\n' "${_optin_names[@]}" | sort) + FINAL_TOOLS+=("${_sorted_optin[@]}") + fi elif [[ -n "$ARG_SKIP" ]]; then validate_csv_shape "$ARG_SKIP" diff --git a/scripts/windows/setup.ps1 b/scripts/windows/setup.ps1 index 155f603..d38545f 100644 --- a/scripts/windows/setup.ps1 +++ b/scripts/windows/setup.ps1 @@ -183,16 +183,26 @@ if ($Only -and $Skip) { $FinalTools = @() $Available = Get-AvailableTool -if ($Only) { +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 } } - $FinalTools = $names + # 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 + } + } + # Opt-in tools (requested but NOT in DefaultTools): append alphabetically. + $optIn = @($names | Where-Object { $DefaultTools -notcontains $_ } | Sort-Object) + foreach ($t in $optIn) { $FinalTools += $t } } elseif ($Skip) { $names = Split-ToolList -ToolList $Skip diff --git a/setup.ps1 b/setup.ps1 index 31b13be..f959898 100644 --- a/setup.ps1 +++ b/setup.ps1 @@ -6,13 +6,14 @@ # Usage: # powershell -ExecutionPolicy Bypass -File setup.ps1 [OPTIONS] # -# WI-1 flags forwarded to scripts\windows\setup.ps1: -List, -Help, -ToolsDir -# WI-2/WI-3 flags (-Only, -Skip) will be added when those work items ship. +# Flags forwarded to scripts\windows\setup.ps1: -List, -Help, -Only, -Skip, -ToolsDir # # For Linux/macOS/WSL, use setup.sh instead. [CmdletBinding()] param( + [string]$Only = '', + [string]$Skip = '', [switch]$List, [switch]$Help, [string]$ToolsDir = '' @@ -22,8 +23,9 @@ Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' # Build the forward hashtable at script scope so PSAnalyzer sees param usage. -# (Only/Skip are WI-2/WI-3 -- not declared here yet.) $_fwdParams = @{} +if ($Only) { $_fwdParams['Only'] = $Only } +if ($Skip) { $_fwdParams['Skip'] = $Skip } if ($List.IsPresent) { $_fwdParams['List'] = $true } if ($Help.IsPresent) { $_fwdParams['Help'] = $true } if ($ToolsDir) { $_fwdParams['ToolsDir'] = $ToolsDir } diff --git a/tests/test_setup_flags.sh b/tests/test_setup_flags.sh index cd7f64d..e8f20e2 100644 --- a/tests/test_setup_flags.sh +++ b/tests/test_setup_flags.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash -# tests/test_setup_flags.sh -- WI-1 baseline + --list/--help tests (#468) +# tests/test_setup_flags.sh -- WI-1 baseline + WI-2 --only tests (#468) # # Tests the framework spine: DEFAULT_TOOLS constant, --tools-dir seam, # --list, --help, root forwarding, baseline-diff. +# WI-2: --only selective install with ORDER PRESERVATION invariant. # # Usage: bash tests/test_setup_flags.sh # Requires: bash 3.2+ (macOS compatible), GNU diff @@ -198,6 +199,214 @@ else skip "T_root_help" "root setup.sh not found" fi +# --------------------------------------------------------------------------- +# WI-2: --only selective install +# Stub defaults.txt order: prereqs, alpha, bravo, charlie, dotfiles, git-hook +# Opt-in stubs (in dir but NOT in defaults.txt): delta, uv +# --------------------------------------------------------------------------- + +# Helper: compare run-log content to a literal expected string +assert_log_str() { + local expected="$1" + local actual + actual="$(cat "$RUN_LOG" 2>/dev/null || true)" + if [[ "$actual" == "$expected" ]]; then + return 0 + fi + echo " Expected: |$(echo "$expected" | cat)|" + echo " Actual: |$(echo "$actual" | cat)|" + return 1 +} + +# --------------------------------------------------------------------------- +# T_only_single: --only=alpha installs only alpha +# --------------------------------------------------------------------------- +echo "" +echo "--- T_only_single ---" +setup_harness +bash "$LINUX_SETUP" "--tools-dir=${STUB_DIR}" --only=alpha 2>&1 | grep -q . || true +if assert_log_str "alpha"; then + pass "T_only_single: --only=alpha logs only alpha" +else + fail "T_only_single: unexpected run-log" +fi +teardown_harness + +# --------------------------------------------------------------------------- +# T_only_multi: --only=alpha,bravo installs both in DEFAULT order +# --------------------------------------------------------------------------- +echo "" +echo "--- T_only_multi ---" +setup_harness +bash "$LINUX_SETUP" "--tools-dir=${STUB_DIR}" --only=alpha,bravo 2>&1 | grep -q . || true +if assert_log_str "$(printf 'alpha\nbravo')"; then + pass "T_only_multi: --only=alpha,bravo logs alpha then bravo (default order)" +else + fail "T_only_multi: unexpected run-log (order or content wrong)" +fi +teardown_harness + +# --------------------------------------------------------------------------- +# T_only_order_preserved: --only=bravo,alpha (reversed) must still install +# alpha BEFORE bravo (DEFAULT_TOOLS order, not input order). +# *** EXPECTED RED before WI-2 fix *** +# --------------------------------------------------------------------------- +echo "" +echo "--- T_only_order_preserved ---" +setup_harness +bash "$LINUX_SETUP" "--tools-dir=${STUB_DIR}" --only=bravo,alpha 2>&1 | grep -q . || true +if assert_log_str "$(printf 'alpha\nbravo')"; then + pass "T_only_order_preserved: reversed input yields default order (alpha then bravo)" +else + fail "T_only_order_preserved: order NOT preserved (input order used instead of default order)" +fi +teardown_harness + +# --------------------------------------------------------------------------- +# T_only_optin: --only=delta works (delta is opt-in, not in defaults.txt) +# --------------------------------------------------------------------------- +echo "" +echo "--- T_only_optin ---" +setup_harness +bash "$LINUX_SETUP" "--tools-dir=${STUB_DIR}" --only=delta 2>&1 | grep -q . || true +if assert_log_str "delta"; then + pass "T_only_optin: --only=delta (opt-in tool) works" +else + fail "T_only_optin: opt-in tool not reachable via --only" +fi +teardown_harness + +# --------------------------------------------------------------------------- +# T_only_optin_order: --only=delta,alpha -> alpha (default) first, delta +# (opt-in) appended after. *** EXPECTED RED before WI-2 fix *** +# --------------------------------------------------------------------------- +echo "" +echo "--- T_only_optin_order ---" +setup_harness +bash "$LINUX_SETUP" "--tools-dir=${STUB_DIR}" --only=delta,alpha 2>&1 | grep -q . || true +if assert_log_str "$(printf 'alpha\ndelta')"; then + pass "T_only_optin_order: default tool (alpha) before opt-in tool (delta)" +else + fail "T_only_optin_order: opt-in not appended after default-ordered tools" +fi +teardown_harness + +# --------------------------------------------------------------------------- +# T_only_unknown: --only=bogus exits 1 and prints available tools +# --------------------------------------------------------------------------- +echo "" +echo "--- T_only_unknown ---" +only_unk_out="$(bash "$LINUX_SETUP" "--tools-dir=${STUB_DIR}" --only=bogus 2>&1)" && only_unk_exit=$? || only_unk_exit=$? +if [[ $only_unk_exit -ne 0 ]]; then + if echo "$only_unk_out" | grep -qi "unknown\|bogus\|available\|--list"; then + pass "T_only_unknown: --only=bogus exits non-zero with helpful message" + else + fail "T_only_unknown: exits non-zero but message not helpful: $only_unk_out" + fi +else + fail "T_only_unknown: --only=bogus exited 0 (expected non-zero)" +fi + +# --------------------------------------------------------------------------- +# T_only_empty: --only= exits 1 +# --------------------------------------------------------------------------- +echo "" +echo "--- T_only_empty ---" +bash "$LINUX_SETUP" "--tools-dir=${STUB_DIR}" --only= 2>&1 | grep -q . || true && only_empty_exit=$? || only_empty_exit=$? +bash "$LINUX_SETUP" "--tools-dir=${STUB_DIR}" "--only=" >/dev/null 2>&1 && only_empty_exit=0 || only_empty_exit=$? +if [[ $only_empty_exit -ne 0 ]]; then + pass "T_only_empty: --only= exits non-zero" +else + fail "T_only_empty: --only= exited 0 (expected non-zero)" +fi + +# --------------------------------------------------------------------------- +# T_only_blank_trailing: --only=alpha, exits 1 +# --------------------------------------------------------------------------- +echo "" +echo "--- T_only_blank_trailing ---" +bash "$LINUX_SETUP" "--tools-dir=${STUB_DIR}" "--only=alpha," >/dev/null 2>&1 && bt_exit=0 || bt_exit=$? +if [[ $bt_exit -ne 0 ]]; then + pass "T_only_blank_trailing: --only=alpha, exits non-zero" +else + fail "T_only_blank_trailing: --only=alpha, exited 0 (expected non-zero)" +fi + +# --------------------------------------------------------------------------- +# T_only_blank_leading: --only=,alpha exits 1 +# --------------------------------------------------------------------------- +echo "" +echo "--- T_only_blank_leading ---" +bash "$LINUX_SETUP" "--tools-dir=${STUB_DIR}" "--only=,alpha" >/dev/null 2>&1 && bl_exit=0 || bl_exit=$? +if [[ $bl_exit -ne 0 ]]; then + pass "T_only_blank_leading: --only=,alpha exits non-zero" +else + fail "T_only_blank_leading: --only=,alpha exited 0 (expected non-zero)" +fi + +# --------------------------------------------------------------------------- +# T_only_blank_consecutive: --only=alpha,,bravo exits 1 +# --------------------------------------------------------------------------- +echo "" +echo "--- T_only_blank_consecutive ---" +bash "$LINUX_SETUP" "--tools-dir=${STUB_DIR}" "--only=alpha,,bravo" >/dev/null 2>&1 && bc_exit=0 || bc_exit=$? +if [[ $bc_exit -ne 0 ]]; then + pass "T_only_blank_consecutive: --only=alpha,,bravo exits non-zero" +else + fail "T_only_blank_consecutive: --only=alpha,,bravo exited 0 (expected non-zero)" +fi + +# --------------------------------------------------------------------------- +# T_only_copilot_alias: --list output includes copilot-cli (real tools dir) +# This confirms the real DEFAULT_TOOLS/tools/ expose copilot-cli as selectable +# --------------------------------------------------------------------------- +echo "" +echo "--- T_only_copilot_alias ---" +copilot_list="$(bash "$LINUX_SETUP" --list 2>&1)" && copilot_list_exit=$? || copilot_list_exit=$? +if [[ $copilot_list_exit -eq 0 ]]; then + if assert_contains "$copilot_list" "copilot-cli"; then + pass "T_only_copilot_alias: --list includes copilot-cli in real tools dir" + else + fail "T_only_copilot_alias: --list missing copilot-cli (check tools/copilot-cli.sh)" + echo " List output: $copilot_list" + fi +else + fail "T_only_copilot_alias: --list exited $copilot_list_exit" +fi + +# --------------------------------------------------------------------------- +# T_root_only: root setup.sh --only=alpha --tools-dir=... installs only alpha +# --------------------------------------------------------------------------- +echo "" +echo "--- T_root_only ---" +if [[ -f "$ROOT_SETUP" ]]; then + setup_harness + bash "$ROOT_SETUP" "--tools-dir=${STUB_DIR}" --only=alpha 2>&1 | grep -q . || true + if assert_log_str "alpha"; then + pass "T_root_only: root setup.sh --only=alpha forwards and installs only alpha" + else + fail "T_root_only: root --only=alpha did not produce expected log" + fi + teardown_harness +else + skip "T_root_only" "root setup.sh not found" +fi + +# --------------------------------------------------------------------------- +# T_backward_compat_gate: no-arg run still produces full defaults (WI-2 gate) +# Redundant with T_baseline_noarg but documents the WI-2 non-regression contract. +# --------------------------------------------------------------------------- +echo "" +echo "--- T_backward_compat_gate ---" +setup_harness +bash "$LINUX_SETUP" "--tools-dir=${STUB_DIR}" 2>&1 | grep -q . || true +if assert_log_equals "${STUB_DIR}/defaults.txt"; then + pass "T_backward_compat_gate: no-arg run still logs all defaults in order" +else + fail "T_backward_compat_gate: no-arg run changed (REGRESSION)" +fi +teardown_harness + # --------------------------------------------------------------------------- # Results # --------------------------------------------------------------------------- diff --git a/tests/test_setup_flags_pwsh.ps1 b/tests/test_setup_flags_pwsh.ps1 index b33364c..ca6cf29 100644 --- a/tests/test_setup_flags_pwsh.ps1 +++ b/tests/test_setup_flags_pwsh.ps1 @@ -1,7 +1,8 @@ -# tests/test_setup_flags_pwsh.ps1 -- WI-1 baseline + -List/-Help tests (#468) +# tests/test_setup_flags_pwsh.ps1 -- WI-1 baseline + WI-2 -Only tests (#468) # # Tests the framework spine: $DefaultTools constant, -ToolsDir seam, # -List, -Help, root forwarding, baseline-diff. +# WI-2: -Only selective install with ORDER PRESERVATION invariant. # # Usage: powershell -ExecutionPolicy Bypass -File tests\test_setup_flags_pwsh.ps1 # PS 5.1 ASCII-only: no smart quotes, em-dashes, arrows, or emoji. @@ -207,6 +208,200 @@ Test-Scenario "T_root_help: root setup.ps1 -Help exits 0" { } } +# --------------------------------------------------------------------------- +# WI-2: -Only selective install +# Stub defaults.txt order: prereqs, alpha, bravo, charlie, dotfiles, git-hook +# Opt-in stubs (in dir but NOT in defaults.txt): delta, uv +# --------------------------------------------------------------------------- + +function Assert-LogStr { + param([string[]]$Expected) + $actual = (Get-Content $script:RunLog -ErrorAction SilentlyContinue) + if ($null -eq $actual) { $actual = @() } + $diff = Compare-Object $Expected $actual -SyncWindow 0 + if ($diff) { + $exp = $Expected -join ', ' + $act = $actual -join ', ' + throw "Run-log mismatch. Expected: [$exp] Actual: [$act]" + } +} + +# --------------------------------------------------------------------------- +# T_only_single: -Only 'alpha' installs only alpha +# --------------------------------------------------------------------------- + +Test-Scenario "T_only_single: -Only 'alpha' logs only alpha" { + Setup-Harness + try { + powershell -NoProfile -ExecutionPolicy Bypass -File $WinSetup ` + -ToolsDir $StubDir -Only 'alpha' 2>&1 | Out-Null + Assert-LogStr @('alpha') + } + finally { Teardown-Harness } +} + +# --------------------------------------------------------------------------- +# T_only_multi: -Only 'alpha,bravo' installs both in DEFAULT order +# --------------------------------------------------------------------------- + +Test-Scenario "T_only_multi: -Only 'alpha,bravo' logs alpha then bravo (default order)" { + Setup-Harness + try { + powershell -NoProfile -ExecutionPolicy Bypass -File $WinSetup ` + -ToolsDir $StubDir -Only 'alpha,bravo' 2>&1 | Out-Null + Assert-LogStr @('alpha', 'bravo') + } + finally { Teardown-Harness } +} + +# --------------------------------------------------------------------------- +# T_only_order_preserved: -Only 'bravo,alpha' (reversed input) must still +# install alpha BEFORE bravo (DEFAULT_TOOLS order, not input order). +# *** EXPECTED RED before WI-2 fix *** +# --------------------------------------------------------------------------- + +Test-Scenario "T_only_order_preserved: reversed input yields default order (alpha then bravo)" { + Setup-Harness + try { + powershell -NoProfile -ExecutionPolicy Bypass -File $WinSetup ` + -ToolsDir $StubDir -Only 'bravo,alpha' 2>&1 | Out-Null + Assert-LogStr @('alpha', 'bravo') + } + finally { Teardown-Harness } +} + +# --------------------------------------------------------------------------- +# T_only_optin: -Only 'delta' works (opt-in, not in defaults.txt) +# --------------------------------------------------------------------------- + +Test-Scenario "T_only_optin: -Only 'delta' (opt-in tool) works" { + Setup-Harness + try { + powershell -NoProfile -ExecutionPolicy Bypass -File $WinSetup ` + -ToolsDir $StubDir -Only 'delta' 2>&1 | Out-Null + Assert-LogStr @('delta') + } + finally { Teardown-Harness } +} + +# --------------------------------------------------------------------------- +# T_only_optin_order: -Only 'delta,alpha' -> alpha (default) first, delta +# (opt-in) appended after. *** EXPECTED RED before WI-2 fix *** +# --------------------------------------------------------------------------- + +Test-Scenario "T_only_optin_order: default tool (alpha) before opt-in (delta)" { + Setup-Harness + try { + powershell -NoProfile -ExecutionPolicy Bypass -File $WinSetup ` + -ToolsDir $StubDir -Only 'delta,alpha' 2>&1 | Out-Null + Assert-LogStr @('alpha', 'delta') + } + finally { Teardown-Harness } +} + +# --------------------------------------------------------------------------- +# T_only_unknown: -Only 'bogus' exits 1 with helpful message +# --------------------------------------------------------------------------- + +Test-Scenario "T_only_unknown: -Only 'bogus' exits non-zero with error" { + $out = powershell -NoProfile -ExecutionPolicy Bypass -File $WinSetup ` + -ToolsDir $StubDir -Only 'bogus' 2>&1 | Out-String + if ($LASTEXITCODE -eq 0) { throw "Expected non-zero exit for unknown tool 'bogus'" } + if ($out -notmatch 'bogus|unknown|available') { + throw "Error message not helpful: $out" + } +} + +# --------------------------------------------------------------------------- +# T_only_empty: -Only '' exits 1 +# Note: in nested subprocess mode the empty string may cause "Missing argument" +# rather than propagating as a bound parameter. Both outcomes are valid failures. + +Test-Scenario "T_only_empty: -Only '' exits non-zero" { + $emptyFailed = $false + try { + powershell -NoProfile -ExecutionPolicy Bypass -File $WinSetup ` + -ToolsDir $StubDir -Only '' 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { $emptyFailed = $true } + } catch { + # "Missing an argument for parameter 'Only'" also satisfies the requirement + $emptyFailed = $true + } + if (-not $emptyFailed) { throw "Expected non-zero exit for empty -Only" } +} + +# --------------------------------------------------------------------------- +# T_only_blank_trailing: -Only 'alpha,' exits 1 +# --------------------------------------------------------------------------- + +Test-Scenario "T_only_blank_trailing: -Only 'alpha,' exits non-zero" { + powershell -NoProfile -ExecutionPolicy Bypass -File $WinSetup ` + -ToolsDir $StubDir -Only 'alpha,' 2>&1 | Out-Null + if ($LASTEXITCODE -eq 0) { throw "Expected non-zero exit for trailing comma" } +} + +# --------------------------------------------------------------------------- +# T_only_blank_leading: -Only ',alpha' exits 1 +# --------------------------------------------------------------------------- + +Test-Scenario "T_only_blank_leading: -Only ',alpha' exits non-zero" { + powershell -NoProfile -ExecutionPolicy Bypass -File $WinSetup ` + -ToolsDir $StubDir -Only ',alpha' 2>&1 | Out-Null + if ($LASTEXITCODE -eq 0) { throw "Expected non-zero exit for leading comma" } +} + +# --------------------------------------------------------------------------- +# T_only_blank_consecutive: -Only 'alpha,,bravo' exits 1 +# --------------------------------------------------------------------------- + +Test-Scenario "T_only_blank_consecutive: -Only 'alpha,,bravo' exits non-zero" { + powershell -NoProfile -ExecutionPolicy Bypass -File $WinSetup ` + -ToolsDir $StubDir -Only 'alpha,,bravo' 2>&1 | Out-Null + if ($LASTEXITCODE -eq 0) { throw "Expected non-zero exit for consecutive commas" } +} + +# --------------------------------------------------------------------------- +# T_only_copilot_alias: -List includes copilot-cli (real registry) +# Confirms the copilot-cli alias is registered on Windows. +# --------------------------------------------------------------------------- + +Test-Scenario "T_only_copilot_alias: -List includes copilot-cli (alias in real registry)" { + $out = powershell -NoProfile -ExecutionPolicy Bypass -File $WinSetup -List 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { throw "-List exited $LASTEXITCODE" } + if ($out -notmatch 'copilot-cli') { + throw "-List does not include 'copilot-cli' (alias missing from ToolRegistry)" + } +} + +# --------------------------------------------------------------------------- +# T_root_only: root setup.ps1 -Only 'alpha' -ToolsDir ... installs only alpha. +# *** EXPECTED RED before WI-2 fix (root does not forward -Only yet) *** +# --------------------------------------------------------------------------- + +Test-Scenario "T_root_only: root setup.ps1 -Only 'alpha' forwards and installs only alpha" { + Setup-Harness + try { + powershell -NoProfile -ExecutionPolicy Bypass -File $RootSetup ` + -ToolsDir $StubDir -Only 'alpha' 2>&1 | Out-Null + Assert-LogStr @('alpha') + } + finally { Teardown-Harness } +} + +# --------------------------------------------------------------------------- +# T_backward_compat_gate: no-arg run still produces full defaults (WI-2 gate) +# --------------------------------------------------------------------------- + +Test-Scenario "T_backward_compat_gate: no-arg run logs all defaults in order" { + Setup-Harness + try { + powershell -NoProfile -ExecutionPolicy Bypass -File $WinSetup ` + -ToolsDir $StubDir 2>&1 | Out-Null + Assert-LogEquals (Join-Path $StubDir 'defaults.txt') + } + finally { Teardown-Harness } +} + # --------------------------------------------------------------------------- # Results # --------------------------------------------------------------------------- From a2a90d0169ad65ce8853849cfb814af5ce4be99c Mon Sep 17 00:00:00 2001 From: "Earl Tankard, Jr., Ph.D" <45021016+primetimetank21@users.noreply.github.com> Date: Wed, 8 Jul 2026 09:22:10 -0400 Subject: [PATCH 2/2] fix(root): forward -Only/-Skip by presence not truthiness in setup.ps1 Root setup.ps1 used 'if (\)' to decide whether to forward the param. Empty string is falsy in PS, so '-Only ''' was never forwarded to the child script -- the child saw no -Only and ran the full default install (exit 0) instead of erroring as expected. Fix: use \System.Management.Automation.PSBoundParametersDictionary.ContainsKey for both -Only and -Skip so an explicitly-passed empty string is forwarded and the child's exit-1 guard fires correctly. Consistent with how scripts/linux/setup.sh forwards '\$@' verbatim and with the child script's own ContainsKey guard. Add T_root_only_empty to tests/test_setup_flags_pwsh.ps1 to close the test gap: asserts root setup.ps1 -Only '' exits non-zero. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- setup.ps1 | 13 ++++++++----- tests/test_setup_flags_pwsh.ps1 | 25 +++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/setup.ps1 b/setup.ps1 index f959898..2bc0551 100644 --- a/setup.ps1 +++ b/setup.ps1 @@ -23,12 +23,15 @@ Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' # Build the forward hashtable at script scope so PSAnalyzer sees param usage. +# Use $PSBoundParameters.ContainsKey so an explicitly-passed empty string +# ("-Only ''") is forwarded to the child and triggers the child's exit-1 guard, +# not silently dropped (empty string is falsy; "if ($Only)" would skip it). $_fwdParams = @{} -if ($Only) { $_fwdParams['Only'] = $Only } -if ($Skip) { $_fwdParams['Skip'] = $Skip } -if ($List.IsPresent) { $_fwdParams['List'] = $true } -if ($Help.IsPresent) { $_fwdParams['Help'] = $true } -if ($ToolsDir) { $_fwdParams['ToolsDir'] = $ToolsDir } +if ($PSBoundParameters.ContainsKey('Only')) { $_fwdParams['Only'] = $Only } +if ($PSBoundParameters.ContainsKey('Skip')) { $_fwdParams['Skip'] = $Skip } +if ($List.IsPresent) { $_fwdParams['List'] = $true } +if ($Help.IsPresent) { $_fwdParams['Help'] = $true } +if ($ToolsDir) { $_fwdParams['ToolsDir'] = $ToolsDir } # -- Logging helpers ----------------------------------------------------------- diff --git a/tests/test_setup_flags_pwsh.ps1 b/tests/test_setup_flags_pwsh.ps1 index ca6cf29..f0bfde5 100644 --- a/tests/test_setup_flags_pwsh.ps1 +++ b/tests/test_setup_flags_pwsh.ps1 @@ -388,6 +388,31 @@ Test-Scenario "T_root_only: root setup.ps1 -Only 'alpha' forwards and installs o finally { Teardown-Harness } } +# --------------------------------------------------------------------------- +# T_root_only_empty: root setup.ps1 -Only '' must forward the empty value to +# the child and exit non-zero (not silently default to a full install). +# Bug fixed: root used "if ($Only)" (falsy for '') instead of +# $PSBoundParameters.ContainsKey('Only'), so '' was never forwarded. +# Nested-subprocess tolerance: accept either clean exit-1 or "Missing argument" +# binding error -- both are non-zero and indicate the empty-Only is rejected. +# --------------------------------------------------------------------------- + +Test-Scenario "T_root_only_empty: root setup.ps1 -Only '' exits non-zero (not a full install)" { + $rootEmptyFailed = $false + try { + powershell -NoProfile -ExecutionPolicy Bypass -File $RootSetup ` + -ToolsDir $StubDir -Only '' 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { $rootEmptyFailed = $true } + } catch { + # "Missing an argument for parameter 'Only'" propagated from nested subprocess -- + # also satisfies the non-zero-exit requirement. + $rootEmptyFailed = $true + } + if (-not $rootEmptyFailed) { + throw "Root setup.ps1 -Only '' exited 0 (ran full install instead of erroring)" + } +} + # --------------------------------------------------------------------------- # T_backward_compat_gate: no-arg run still produces full defaults (WI-2 gate) # ---------------------------------------------------------------------------