Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,9 @@ jobs:
- name: Lint config/dotfiles/.aliases
run: shellcheck -s bash config/dotfiles/.aliases

- name: Run Bash flag compatibility gates
run: bash tests/test_setup_flags.sh

lint-powershell:
name: Lint PowerShell Scripts
runs-on: ubuntu-latest
Expand Down Expand Up @@ -350,6 +353,11 @@ jobs:
run: |
powershell -ExecutionPolicy Bypass -File tests\test_windows_setup.ps1

- name: Run PS 5.1 flag compatibility gates
shell: powershell
run: |
powershell -ExecutionPolicy Bypass -File tests\test_setup_flags_pwsh.ps1

- name: Configure git hooks path
shell: powershell
run: |
Expand Down
12 changes: 8 additions & 4 deletions scripts/dev/regenerate-baseline-fixtures.sh
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ LINUX_FIXTURE="${REPO_ROOT}/tests/fixtures/baseline-tools-linux.txt"
WIN_FIXTURE="${REPO_ROOT}/tests/fixtures/baseline-tools-windows.txt"
LINUX_SETUP="${REPO_ROOT}/scripts/linux/setup.sh"
WIN_SETUP="${REPO_ROOT}/scripts/windows/setup.ps1"
WIN_SETUP_PS="$WIN_SETUP"
if command -v cygpath >/dev/null 2>&1; then
WIN_SETUP_PS="$(cygpath -w "$WIN_SETUP")"
fi

# Extract Linux DEFAULT_TOOLS from source (bash array literal)
extract_linux() {
Expand All @@ -29,24 +33,24 @@ extract_linux() {
extract_windows() {
if command -v pwsh >/dev/null 2>&1; then
pwsh -NoProfile -Command "
\$content = Get-Content '$WIN_SETUP' -Raw
\$content = Get-Content '$WIN_SETUP_PS' -Raw
if (\$content -match '(?s)\\\$DefaultTools\s*=\s*@\((.*?)\)') {
\$block = \$Matches[1]
\$block.Split([char[]]@([char]13,[char]10)) |
ForEach-Object { \$_.Trim().Trim(\"'\").Trim('\"') } |
Where-Object { \$_ -and \$_ -notmatch '^#' }
}
"
" | tr -d '\r'
elif command -v powershell >/dev/null 2>&1; then
powershell -NoProfile -Command "
\$content = Get-Content '$WIN_SETUP' -Raw
\$content = Get-Content '$WIN_SETUP_PS' -Raw
if (\$content -match '(?s)\\\$DefaultTools\s*=\s*@\((.*?)\)') {
\$block = \$Matches[1]
\$block.Split([char[]]@([char]13,[char]10)) |
ForEach-Object { \$_.Trim().Trim(\"'\").Trim('\"') } |
Where-Object { \$_ -and \$_ -notmatch '^#' }
}
"
" | tr -d '\r'
else
echo "ERROR: pwsh/powershell not found -- cannot extract Windows defaults" >&2
exit 1
Expand Down
76 changes: 74 additions & 2 deletions scripts/linux/setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,22 @@
#
# Usage (direct):
# bash scripts/linux/setup.sh [--list] [--help] [--only=a,b] [--skip=a,b]
# [--interactive | --non-interactive]
#
# Flags:
# --list Print available tools (alphabetical), exit 0. No install.
# --help Print usage, exit 0.
# --only=a,b,c Install ONLY the listed tools (comma-separated).
# --skip=a,b,c Install all default tools EXCEPT the listed ones.
# --interactive Show the tool picker when a TTY is available.
# --non-interactive
# Never show the tool picker.
# --only and --skip are mutually exclusive.
#
# Hidden test seam (not in --help):
# --tools-dir=<path> Override the tools directory (test use only).
# --selection-file=<path>
# Read selected tools from a file (test use only).

set -euo pipefail
exec 2>&1 # Merge stderr into stdout for ordered output in piped/Devcontainer environments
Expand Down Expand Up @@ -52,16 +58,26 @@ ARG_SKIP=""
ARG_LIST=0
ARG_HELP=0
ARG_TOOLS_DIR="" # hidden test seam
ARG_SELECTION_FILE="" # hidden test seam
ARG_ONLY_SET=0 # tracks whether --only was explicitly provided
ARG_SKIP_SET=0 # tracks whether --skip was explicitly provided
ARG_INTERACTIVE_SET=0
ARG_NON_INTERACTIVE_SET=0
ARG_SELECTION_FILE_SET=0

for arg in "$@"; do
case "$arg" in
--only=*) ARG_ONLY="${arg#--only=}"; ARG_ONLY_SET=1 ;;
--skip=*) ARG_SKIP="${arg#--skip=}"; ARG_SKIP_SET=1 ;;
--list) ARG_LIST=1 ;;
--help) ARG_HELP=1 ;;
--interactive) ARG_INTERACTIVE_SET=1 ;;
--non-interactive) ARG_NON_INTERACTIVE_SET=1 ;;
--tools-dir=*) ARG_TOOLS_DIR="${arg#--tools-dir=}" ;;
--selection-file=*)
ARG_SELECTION_FILE="${arg#--selection-file=}"
ARG_SELECTION_FILE_SET=1
;;
*)
log_error "Unknown argument: $arg"
log_error "Run with --help for usage."
Expand Down Expand Up @@ -137,6 +153,8 @@ Options:
--help Print this help message, exit 0.
--only=a,b,c Install ONLY the listed tools (comma-separated).
--skip=a,b,c Install all default tools EXCEPT the listed ones.
--interactive Show the tool picker when a TTY is available.
--non-interactive Never show the tool picker.

Notes:
--only and --skip are mutually exclusive.
Expand Down Expand Up @@ -166,8 +184,43 @@ if [[ $ARG_ONLY_SET -eq 1 && $ARG_SKIP_SET -eq 1 ]]; then
exit 1
fi

if [[ $ARG_INTERACTIVE_SET -eq 1 && $ARG_NON_INTERACTIVE_SET -eq 1 ]]; then
log_error "--interactive and --non-interactive are mutually exclusive."
exit 1
fi

if [[ $ARG_NON_INTERACTIVE_SET -eq 1 && $ARG_SELECTION_FILE_SET -eq 1 ]]; then
log_error "--non-interactive and --selection-file are mutually exclusive."
exit 1
fi

# (Note: ARG_ONLY_SET / ARG_SKIP_SET handle empty-value sentinels; build_final_toolset validates.)

# ---------------------------------------------------------------------------
# Interactive guard. Slice 1 only detects whether a future menu may run.
# No menu is invoked until Slice 2.
# ---------------------------------------------------------------------------
is_interactive() {
if [[ $ARG_NON_INTERACTIVE_SET -eq 1 || $ARG_ONLY_SET -eq 1 || $ARG_SKIP_SET -eq 1 ]]; then
return 1
fi
# --interactive + --selection-file: bypass CI/TTY detection so CI can test the menu path.
if [[ $ARG_INTERACTIVE_SET -eq 1 && $ARG_SELECTION_FILE_SET -eq 1 ]]; then
return 0
fi
if [[ "${SETUP_NON_INTERACTIVE:-}" == "1" || -n "${CI:-}" || -n "${GITHUB_ACTIONS:-}" ]]; then
return 1
fi
if [[ ! -t 0 || ! -t 1 ]]; then
return 1
fi
return 0
}

if is_interactive; then
: # ponytail: detection-only ceiling for Slice 1; Slice 2 wires the Bash menu here.
fi

# ---------------------------------------------------------------------------
# Build FinalToolSet -- populates global FINAL_TOOLS (bash 3.2 safe: no
# local -n namerefs, no mapfile; use plain global array + while-read).
Expand All @@ -176,6 +229,24 @@ FINAL_TOOLS=()
build_final_toolset() {
FINAL_TOOLS=()

if [[ $ARG_SELECTION_FILE_SET -eq 1 && $ARG_ONLY_SET -eq 0 && $ARG_SKIP_SET -eq 0 ]]; then
if [[ -z "$ARG_SELECTION_FILE" || ! -f "$ARG_SELECTION_FILE" ]]; then
log_error "Selection file not found: ${ARG_SELECTION_FILE}"
exit 1
fi
local _selection
while IFS= read -r _selection || [[ -n "$_selection" ]]; do
_selection="${_selection%$'\r'}"
[[ -z "$_selection" ]] && continue
if [[ -n "$ARG_ONLY" ]]; then
ARG_ONLY="${ARG_ONLY},${_selection}"
else
ARG_ONLY="$_selection"
fi
done < "$ARG_SELECTION_FILE"
ARG_ONLY_SET=1
fi

# Build available-tools list into a local array (while-read, not mapfile)
local available=()
local _t
Expand Down Expand Up @@ -256,7 +327,9 @@ build_final_toolset() {
for s in "${skip_list[@]}"; do
[[ "$s" == "$tool" ]] && skip=1 && break
done
[[ $skip -eq 0 ]] && FINAL_TOOLS+=("$tool")
if [[ $skip -eq 0 ]]; then
FINAL_TOOLS+=("$tool")
fi
done

else
Expand Down Expand Up @@ -301,4 +374,3 @@ main() {
}

main

78 changes: 75 additions & 3 deletions scripts/windows/setup.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,14 @@
# -Help Print usage, exit 0.
# -Only "a,b,c" Install ONLY the listed tools (comma-separated).
# -Skip "a,b,c" Install all default tools EXCEPT the listed ones.
# -Interactive Show the tool picker when a console is available.
# -NonInteractive Never show the tool picker.
# -Only and -Skip are mutually exclusive.
#
# Hidden test seam (not in -Help):
# -ToolsDir <path> Override the tools directory (test use only).
# -SelectionFile <path>
# Read selected tools from a file (test use only).
#
# PS 5.1 ASCII-only: no smart quotes, em-dashes, or non-ASCII characters.

Expand All @@ -23,7 +27,10 @@ param(
[string]$Skip = '',
[switch]$List,
[switch]$Help,
[string]$ToolsDir = ''
[switch]$Interactive,
[switch]$NonInteractive,
[string]$ToolsDir = '',
[string]$SelectionFile = ''
)

Set-StrictMode -Version Latest
Expand Down Expand Up @@ -153,6 +160,8 @@ function Show-Help {
Write-Output " -Help Print this help message, exit 0."
Write-Output " -Only 'a,b,c' Install ONLY the listed tools (comma-separated)."
Write-Output " -Skip 'a,b,c' Install all default tools EXCEPT the listed ones."
Write-Output " -Interactive Show the tool picker when a console is available."
Write-Output " -NonInteractive Never show the tool picker."
Write-Output ""
Write-Output "Notes:"
Write-Output " -Only and -Skip are mutually exclusive."
Expand Down Expand Up @@ -181,14 +190,77 @@ if ($PSBoundParameters.ContainsKey('Only') -and $PSBoundParameters.ContainsKey('
exit 1
}

if ($Interactive -and $NonInteractive) {
Write-Err "-Interactive and -NonInteractive are mutually exclusive."
exit 1
}

if ($NonInteractive -and $PSBoundParameters.ContainsKey('SelectionFile')) {
Write-Err "-NonInteractive and -SelectionFile are mutually exclusive."
exit 1
}

# ---------------------------------------------------------------------------
# Interactive guard. Slice 1 only detects whether a future menu may run.
# No menu is invoked until Slice 3.
# ---------------------------------------------------------------------------
function Test-ShouldShowMenu {
param(
[bool]$NonInteractiveRequested,
[bool]$OnlySet,
[bool]$SkipSet,
[bool]$InteractiveRequested,
[bool]$SelectionFileSet
)
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 }
if ($env:SETUP_NON_INTERACTIVE -eq '1' -or $env:CI -or $env:GITHUB_ACTIONS) { return $false }
if ([Console]::IsInputRedirected -or -not [Environment]::UserInteractive) { return $false }
if ($null -eq $Host.UI.RawUI) { return $false }
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
# ---------------------------------------------------------------------------
$FinalTools = @()
$Available = Get-AvailableTool
$UseSelectionFile = $PSBoundParameters.ContainsKey('SelectionFile') -and
-not $PSBoundParameters.ContainsKey('Only') -and
-not $PSBoundParameters.ContainsKey('Skip')

# Selection-file: validate, join names, route through the canonical -Only path.
$EffectiveOnly = ''
$UseOnlyPath = $false

if ($UseSelectionFile) {
if ([string]::IsNullOrEmpty($SelectionFile) -or -not (Test-Path -LiteralPath $SelectionFile -PathType Leaf)) {
Write-Err "Selection file not found: $SelectionFile"
exit 1
}
$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 ($PSBoundParameters.ContainsKey('Only')) {
$names = Split-ToolList -ToolList $Only
if ($UseOnlyPath) {
$names = Split-ToolList -ToolList $EffectiveOnly
foreach ($name in $names) {
if ($Available -notcontains $name) {
Write-Err "Unknown tool: $name"
Expand Down
13 changes: 11 additions & 2 deletions setup.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
# Usage:
# powershell -ExecutionPolicy Bypass -File setup.ps1 [OPTIONS]
#
# Flags forwarded to scripts\windows\setup.ps1: -List, -Help, -Only, -Skip, -ToolsDir
# Flags forwarded to scripts\windows\setup.ps1:
# -List, -Help, -Only, -Skip, -Interactive, -NonInteractive
#
# For Linux/macOS/WSL, use setup.sh instead.

Expand All @@ -16,7 +17,10 @@ param(
[string]$Skip = '',
[switch]$List,
[switch]$Help,
[string]$ToolsDir = ''
[switch]$Interactive,
[switch]$NonInteractive,
[string]$ToolsDir = '',
[string]$SelectionFile = ''
)

Set-StrictMode -Version Latest
Expand All @@ -31,7 +35,12 @@ 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 ($Interactive.IsPresent) { $_fwdParams['Interactive'] = $true }
if ($NonInteractive.IsPresent) { $_fwdParams['NonInteractive'] = $true }
if ($ToolsDir) { $_fwdParams['ToolsDir'] = $ToolsDir }
if ($PSBoundParameters.ContainsKey('SelectionFile')) {
$_fwdParams['SelectionFile'] = $SelectionFile
}

# -- Logging helpers -----------------------------------------------------------

Expand Down
3 changes: 3 additions & 0 deletions tests/fixtures/stub-tools/linux/selection.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
delta

alpha
3 changes: 3 additions & 0 deletions tests/fixtures/stub-tools/windows/selection.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
delta

alpha
Loading
Loading