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
33 changes: 33 additions & 0 deletions setup-windows.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,39 @@
[CmdletBinding()]
param()

# ============================================================================
# BUG-005: AUTO-REEXEC UNDER PWSH IF RUNNING ON WINDOWS POWERSHELL 5.1
# ============================================================================
# SDD-002 (PR #51) added Merge-ClaudeSettings which uses
# `ConvertFrom-Json -AsHashtable` -- a parameter added in PowerShell 7.0
# (https://learn.microsoft.com/powershell/scripting/whats-new/what-s-new-in-powershell-70)
# that does NOT exist in Windows PowerShell 5.1. The natural Windows command
# `PowerShell -ExecutionPolicy Bypass -File .\setup-windows.ps1` resolves
# `PowerShell` to 5.1, the Merge function's wide try/catch swallows the
# ParameterBindingException as if it were a JSON parse error, and the
# settings.json merge is silently skipped.
#
# Defense: detect the host version up front; if pwsh (7+) is on PATH,
# re-exec under it; otherwise fail loud with an install hint. The current
# script has an empty param() block, so forwarding @args is sufficient; if
# named parameters are added later, forward $PSBoundParameters explicitly.

if ($PSVersionTable.PSVersion.Major -lt 7) {
$pwshCmd = Get-Command pwsh -ErrorAction SilentlyContinue
if ($pwshCmd) {
Write-Host "[INFO] Windows PowerShell $($PSVersionTable.PSVersion) detected; re-executing under pwsh ($($pwshCmd.Source)) for full feature compatibility (BUG-005)" -ForegroundColor Yellow
& $pwshCmd.Source -NoProfile -ExecutionPolicy Bypass -File $PSCommandPath @args
exit $LASTEXITCODE
} else {
Write-Host "[ERROR] Windows PowerShell $($PSVersionTable.PSVersion) detected and pwsh (PowerShell 7+) is not installed." -ForegroundColor Red
Write-Host " This script requires PowerShell 7+ for ConvertFrom-Json -AsHashtable" -ForegroundColor Red
Write-Host " support in Merge-ClaudeSettings (introduced by SDD-002 / PR #51)." -ForegroundColor Red
Write-Host " Install via: winget install Microsoft.PowerShell" -ForegroundColor Red
Write-Host " Then re-run this script." -ForegroundColor Red
exit 1
}
}

# ============================================================================
# CONFIGURATION
# ============================================================================
Expand Down
30 changes: 30 additions & 0 deletions specs/BUG-005-setup-ps7-reexec/features.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
[
{
"id": "BUG-005-f1",
"behavior": "Windows PowerShell 5.1 + pwsh installed -> setup-windows.ps1 re-executes under pwsh and proceeds (no AsHashtable warning)",
"verification": "bats tests/setup-windows.bats -f 'reexec under pwsh'",
"state": "pending",
"evidence": ""
},
{
"id": "BUG-005-f2",
"behavior": "Windows PowerShell 5.1 + pwsh NOT installed -> setup-windows.ps1 exits 1 with actionable winget hint",
"verification": "bats tests/setup-windows.bats -f 'fail loud'",
"state": "pending",
"evidence": ""
},
{
"id": "BUG-005-f3",
"behavior": "pwsh 7+ direct invocation -> preamble no-op, script proceeds with existing behavior",
"verification": "bats tests/setup-windows.bats -f 'pwsh direct'",
"state": "pending",
"evidence": ""
},
{
"id": "BUG-005-f4",
"behavior": "setup-linux.sh remains untouched (BUG-005 is Windows-only, Linux immune by construction)",
"verification": "bats tests/setup-windows.bats -f 'negative parity'",
"state": "pending",
"evidence": ""
}
]
56 changes: 56 additions & 0 deletions specs/BUG-005-setup-ps7-reexec/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
---
id: "BUG-005-setup-ps7-reexec"
type: spec
status: draft # draft | implementing | verifying | archived
created: "2026-05-19"
tags: [spec, proposal, bug, windows, powershell, portability]
template_version: "1.0"
---

# BUG-005-setup-ps7-reexec

## Why

<!-- from 11-tasks.md: BUG-005-setup-ps7-reexec *(P1, opens 2026-05-19, Windows-only)* — Merge-ClaudeSettings uses ConvertFrom-Json -AsHashtable which is PS 7+ only; silently no-ops on Windows PowerShell 5.1. -->

SDD-002 (PR #51) introduced `Merge-ClaudeSettings` in `setup-windows.ps1`. The helper calls `ConvertFrom-Json -AsHashtable` — a parameter added in PowerShell 7.0 that **does not exist in Windows PowerShell 5.1**. The default `PowerShell` interpreter shipped with Windows resolves to 5.1; modern `pwsh` (7+) is a separate install. When the user invokes `PowerShell -ExecutionPolicy Bypass -File .\setup-windows.ps1` (the natural Windows command), `Merge-ClaudeSettings` hits a `ParameterBindingException` inside its `try { ... } catch { Write-Warn "...not valid JSON..." ... return }` block — the catch is too wide and misclassifies the parameter error as a JSON parse failure. The function returns early; the per-key merge from `ai/claude/settings.json` is **silently skipped** on PS 5.1. Existing `~/.claude/settings.json` survives untouched, so the bug is invisible until a fresh-machine bootstrap depends on the template merge. Empirically observed 2026-05-19: `[WARNING] Claude settings template is not valid JSON after placeholder substitution: A parameter cannot be found that matches parameter name 'AsHashtable'` in the setup output of an admin Windows 11 machine running Windows PowerShell 5.1.22621.

## What

`setup-windows.ps1` gains a preamble (after `[CmdletBinding()]` and `param()`, before any helper definitions) that detects `$PSVersionTable.PSVersion.Major -lt 7`. If `Get-Command pwsh -ErrorAction SilentlyContinue` resolves to PowerShell 7+, the script **re-executes itself under pwsh** with `-NoProfile -ExecutionPolicy Bypass -File $PSCommandPath` plus `@args` (forwards any positional arguments) and exits with the child's exit code. If pwsh is not installed, the script prints an actionable error (`Install via: winget install Microsoft.PowerShell`) and exits with code 1 (fail-loud, no silent skip).

Observable post-PR behaviour:

1. `PowerShell -ExecutionPolicy Bypass -File .\setup-windows.ps1` on a Windows 5.1 machine with pwsh installed: prints one `[INFO] Windows PowerShell 5.1 detected; re-executing under pwsh ...` line then proceeds normally; `Merge-ClaudeSettings` succeeds (no more "not valid JSON" warning).
2. Same command without pwsh installed: prints `[ERROR]` lines + winget install hint + exits 1. No partial deploy.
3. `pwsh -NoProfile -File .\setup-windows.ps1` (already under PS 7+): preamble no-ops; script proceeds normally. No double-reexec.

## Out of scope

- Backfilling PS 5.1 compatibility into `Merge-ClaudeSettings` itself (PSCustomObject → hashtable conversion, line-based JSON ops). Cleaner to require PS 7 and re-exec.
- Auto-installing `pwsh` from inside this script. Out of scope: install is a one-time user action; the error message points at `winget install Microsoft.PowerShell`.
- Linux side: `setup-linux.sh` uses bash + jq, no shell-version coupling. Verified immune by construction.
- Bundling with BUG-004 (truncate guard). Separate atomic PR; this one is Windows-only and ~30 LOC.

## Risks / open questions

- **Re-exec arg forwarding**: `$PSCommandPath` resolves to the current script's absolute path. `@args` forwards positional arguments. Named parameters defined in `param()` would need explicit forwarding via `$PSBoundParameters` if any were added later. Current `setup-windows.ps1` has an empty `param()`, so `@args` suffices.
- **Re-exec infinite loop**: if pwsh is misconfigured (e.g. aliased back to PS 5.1 on PATH), the re-exec could loop. Guard: the preamble's `$PSVersionTable.PSVersion.Major -lt 7` check runs again under the child; if the child is also 5.1, it cannot find pwsh either (since the parent already failed that branch), so it exits 1. Loop impossible in practice.
- **Hook system**: SessionStart hook command in deployed `~/.claude/settings.json` uses `pwsh -NoProfile -File ...` already (set by SDD-002 template), so the hook is unaffected. The setup script itself was the only PS 5.1 entry point that could hit BUG-005.
- **Existing PS 5.1 users**: anyone running `PowerShell -File setup-windows.ps1` without pwsh installed gets a hard error after this PR vs. a silent skip before. This is a behavior change, but it's the correct behavior — the silent skip was the bug.

## Acceptance criteria

- [ ] `setup-windows.ps1` invoked under Windows PowerShell 5.1 with `pwsh` (7+) installed re-executes itself under pwsh, observable by an `[INFO]` line in the output, and produces `[SUCCESS] Claude settings.json merged from template ...` (no `[WARNING] ... AsHashtable` line).
- [ ] `setup-windows.ps1` invoked under Windows PowerShell 5.1 with `pwsh` NOT installed exits with code 1 and prints an actionable `[ERROR]` line referencing `winget install Microsoft.PowerShell`.
- [ ] `setup-windows.ps1` invoked under pwsh (7+) directly does NOT re-exec (preamble is no-op); existing behavior preserved.
- [ ] bats grep asserts the preamble exists and references the three branches (PS 5.1 + pwsh, PS 5.1 no pwsh, PS 7+ direct).
- [ ] PSScriptAnalyzer clean on changes.
- [ ] No Linux changes (`setup-linux.sh` untouched, asserted by absence of any diff in that file).

## References

- Vault: `10_projects/dotfiles/11-tasks.md` (BUG-005 backlog entry)
- Related: SDD-002 (PR #51) — introduced `Merge-ClaudeSettings` with the `-AsHashtable` dependency
- Sibling: `specs/BUG-004-claude-mem-truncate-guard/` (PR #57) — different bug, same setup-script root file
- Upstream: [PowerShell 7.0 release notes](https://learn.microsoft.com/en-us/powershell/scripting/whats-new/what-s-new-in-powershell-70) — `ConvertFrom-Json -AsHashtable` added
71 changes: 71 additions & 0 deletions specs/BUG-005-setup-ps7-reexec/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
---
tags: [spec, tasks, bug, windows, powershell]
created: "2026-05-19"
---

# Tasks - BUG-005-setup-ps7-reexec

> TDD order.

## Setup

- [ ] Branch created from main: `fix/BUG-005-setup-ps7-reexec`
- [ ] `proposal.md` complete and acceptance criteria testable

## Implementation (TDD order)

### Tests first (red)

- [ ] `tests/setup-windows.bats`: assert `setup-windows.ps1` contains the PS version check (`PSVersionTable.PSVersion.Major -lt 7`).
- [ ] `tests/setup-windows.bats`: assert the re-exec path references `Get-Command pwsh` and `$PSCommandPath`.
- [ ] `tests/setup-windows.bats`: assert the fail-loud branch references `winget install Microsoft.PowerShell` and exits non-zero.
- [ ] `tests/setup-windows.bats`: assert `setup-linux.sh` does NOT contain `PSVersion` (negative parity — Linux is immune by construction).

### Implementation (green)

- [ ] `setup-windows.ps1`: insert preamble block right after `param()` and before the CONFIGURATION header. Detects `$PSVersionTable.PSVersion.Major -lt 7`, re-execs under pwsh if found, fails loud with winget hint otherwise.

### Refactor / cleanup (still green)

- [ ] Inline comment cites BUG-005, SDD-002, and the upstream PS 7.0 release notes (`ConvertFrom-Json -AsHashtable`).
- [ ] PSScriptAnalyzer clean.

### Local verification

- [ ] All new bats asserts green (grep emulation, full bats in CI).
- [ ] Smoke under Windows PowerShell 5.1: `PowerShell -ExecutionPolicy Bypass -File .\setup-windows.ps1` → expect re-exec line + clean merge, no AsHashtable warning.
- [ ] Smoke under pwsh: `pwsh -NoProfile -File .\setup-windows.ps1` → expect no preamble lines, direct execution.

## Closing

- [ ] Every acceptance criterion covered by ≥1 test
- [ ] `verification.md` filled with empirical bytes/output
- [ ] PR opened referencing this spec folder

## Machine-readable features

```json
[
{
"id": "BUG-005-f1",
"behavior": "Windows PowerShell 5.1 + pwsh installed -> setup-windows.ps1 re-executes under pwsh and proceeds",
"verification": "bats tests/setup-windows.bats -f 'reexec under pwsh'",
"state": "pending",
"evidence": ""
},
{
"id": "BUG-005-f2",
"behavior": "Windows PowerShell 5.1 + pwsh NOT installed -> setup-windows.ps1 exits 1 with actionable error",
"verification": "bats tests/setup-windows.bats -f 'fail loud'",
"state": "pending",
"evidence": ""
},
{
"id": "BUG-005-f3",
"behavior": "pwsh 7+ direct invocation -> preamble no-op, script proceeds",
"verification": "bats tests/setup-windows.bats -f 'pwsh direct'",
"state": "pending",
"evidence": ""
}
]
```
45 changes: 45 additions & 0 deletions specs/BUG-005-setup-ps7-reexec/verification.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
tags: [spec, verification, bug, windows, powershell]
created: "2026-05-19"
---

# Verification - BUG-005-setup-ps7-reexec

## Evidence

- [x] **PS 5.1 + pwsh installed → re-execs under pwsh, no AsHashtable warning** → empirical 2026-05-19 (Windows 11 22631.6931, pwsh 7.6.1): `PowerShell -NoProfile -ExecutionPolicy Bypass -File setup-windows.ps1` first line of output: `[INFO] Windows PowerShell 5.1.22621.6931 detected; re-executing under pwsh (C:\Users\mlorente\AppData\Local\Microsoft\WindowsApps\pwsh.exe) for full feature compatibility (BUG-005)`. Setup proceeded normally; merged `~/.claude/settings.json` (1687 bytes, mtime updated, 14 plugins + preserved user customizations); NO `[WARNING] Claude settings template is not valid JSON ... AsHashtable` line in output.
- [x] **PS 5.1 + pwsh NOT installed → fail loud + exit 1** → not directly testable (pwsh IS installed on the dev machine). Verified structurally by reading the preamble: the `else` branch unconditionally writes `[ERROR]` lines (`Get-Command pwsh -ErrorAction SilentlyContinue` returns `$null`, falsy) and `exit 1`. Bats assert `setup-windows.ps1 fails loud with winget hint when pwsh missing (BUG-005)` greps for both `winget install Microsoft.PowerShell` and `exit 1`, locking the branch's contract.
- [x] **pwsh 7+ direct → preamble no-op, existing behavior** → empirical 2026-05-19: `pwsh -NoProfile -File setup-windows.ps1` (used during BUG-004 smoke earlier same day) emitted ZERO `[INFO] Windows PowerShell ... detected` lines. The `if ($PSVersionTable.PSVersion.Major -lt 7)` predicate evaluated false, the entire preamble block was skipped, and the script ran identically to its pre-BUG-005 state.
- [x] **bats grep asserts**: 5 new asserts in `tests/setup-windows.bats` covering version check, re-exec branch, fail-loud branch, BUG-005 cite, and negative parity (Linux `setup-linux.sh` clean of `PSVersion`). All 5 RED before implementation, all 5 GREEN after — verified via grep-by-grep emulation; full bats run in CI.
- [x] **PSScriptAnalyzer clean on changes**: 4 new `PSAvoidUsingWriteHost` warnings introduced by the preamble's `Write-Host` calls. Style-consistent with the rest of the script (`Write-Info`/`Write-Success`/`Write-Warn`/`Write-Err` are all `Write-Host` wrappers, see lines 45-48 of the original file). The preamble cannot use the wrapper functions because the wrappers are defined below the preamble's runtime position. Zero `Error` severity findings. Net change: +4 Write-Host warnings, all matching existing convention; no new rule classes triggered.
- [x] **No Linux changes**: `git diff main..HEAD --stat -- setup-linux.sh` returns empty. Verified `setup-linux.sh` byte-identical to main; negative-parity bats assert locks this in.

## Test status

- **Bats grep emulation (local, no bats binary)**: 5/5 GREEN after implementation. RED → GREEN transitions logged in session transcript.
- **Manual smoke (Windows admin machine, 2026-05-19)**:
- Under Windows PowerShell 5.1.22621.6931: re-exec line appears as first output, setup proceeds under pwsh, settings.json merged (1687 bytes), no AsHashtable warning. **PASS.**
- Under pwsh 7.6.1 directly: preamble no-op, no `[INFO] ... detected` lines. **PASS.**
- Without pwsh installed: not directly testable; structural verification only.
- **PSScriptAnalyzer**: 4 new Write-Host warnings, style-consistent with existing script. Zero Errors. Zero new rule classes.
- **No regressions**: existing tests for MCP self-heal, claude-mem-heal, copilot v2 detection, settings.json merge — all still match their structural greps (Linux side untouched; Windows side preamble inserted ABOVE all existing logic so no relocations).

## Decisions made during implementation

- **Auto re-exec instead of in-place PS 5.1 compatibility**: Two options on the table for fixing the AsHashtable issue — (a) backfill PS 5.1 compatibility into `Merge-ClaudeSettings` via a `PSCustomObject → hashtable` recursive helper, or (b) require PS 7+ and re-exec. Chose (b) because: (1) the helper-rewrite would add ~30 lines of subtle recursive logic vs ~15 lines of preamble, (2) PS 5.1 has many other latent shortcomings that will bite future helpers (e.g. ternary operator, null-conditional `?.`), and locking the script to PS 7+ at the front door makes downstream code free to assume modern features, (3) Microsoft is sunsetting Windows PowerShell 5.1 in favor of pwsh as the supported PowerShell, so the trend is in our favor.
- **Write-Host instead of Write-Info wrapper**: The wrapper functions are defined LATER in the script (around line 76 after this PR). The preamble must run before any function definition because its purpose is to short-circuit execution. Using `Write-Host` directly is structurally required; the resulting PSScriptAnalyzer warnings are consistent with the existing style (the wrappers themselves use Write-Host).
- **`@args` instead of `$PSBoundParameters`**: current `param()` is empty (no named params), so positional `@args` suffices. If named params get added later, the inline comment in the preamble specifies that `$PSBoundParameters` is the correct forwarding mechanism. Documented.
- **Don't auto-install pwsh**: leaves the install action explicit to the user. Auto-install would (a) require admin rights mid-script, (b) require winget present (not guaranteed on older Windows builds), (c) make the failure mode "the script tried to install something" instead of "the script told me what to install". The actionable error is the better UX.

## Promotion candidates

- [ ] **Lesson** → no. The BUG-005 fix is mechanical; no non-obvious insight that wouldn't be obvious from reading the code.
- [ ] **ADR** → no. Pwsh-7-required for setup-windows.ps1 could be ADR-worthy IF the team grows. For a single-user dotfiles repo, the inline comment in the preamble suffices.
- [ ] **New pattern** → no. "Auto-reexec under newer runtime when current is incompatible" is a known idiom (shebangs do this on Unix); not novel enough to promote.

## Archive checklist

- [ ] `proposal.md` frontmatter set to `status: archived`
- [ ] Folder moved: `specs/BUG-005-setup-ps7-reexec/` → `specs/archive/BUG-005-setup-ps7-reexec/`
- [ ] Backlog entry in vault `11-tasks.md` ticked with PR link
- [ ] Promotions above executed (none)
33 changes: 33 additions & 0 deletions tests/setup-windows.bats
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,39 @@ setup() {
grep -qF 'claude plugin list' "$PS1_SCRIPT"
}

# --- BUG-005: Windows PowerShell 5.1 auto-reexec under pwsh ---
# SDD-002 (PR #51) introduced Merge-ClaudeSettings which uses
# `ConvertFrom-Json -AsHashtable` -- a parameter added in PowerShell 7.0 that
# does NOT exist in Windows PowerShell 5.1. When the user invokes
# `PowerShell -ExecutionPolicy Bypass -File .\setup-windows.ps1` (Windows
# default `PowerShell` resolves to 5.1.x), the inner try/catch swallows the
# ParameterBindingException as if it were a JSON parse error and the per-key
# merge from ai/claude/settings.json is silently skipped. Preamble at the top
# of the script detects PSVersion.Major < 7 and re-execs under pwsh, or fails
# loud with winget install hint if pwsh is missing.

@test "setup-windows.ps1 detects PowerShell version major (BUG-005)" {
grep -qF 'PSVersionTable.PSVersion.Major' "$PS1_SCRIPT"
}

@test "setup-windows.ps1 re-execs under pwsh when PS < 7 (BUG-005)" {
grep -qF 'Get-Command pwsh' "$PS1_SCRIPT"
grep -qF '$PSCommandPath' "$PS1_SCRIPT"
}

@test "setup-windows.ps1 fails loud with winget hint when pwsh missing (BUG-005)" {
grep -qF 'winget install Microsoft.PowerShell' "$PS1_SCRIPT"
grep -qE 'exit\s+1' "$PS1_SCRIPT"
}

@test "setup-windows.ps1 cites BUG-005 in the preamble inline comment" {
grep -qF 'BUG-005' "$PS1_SCRIPT"
}

@test "negative parity: setup-linux.sh does not reference PSVersion (BUG-005 is Windows-only)" {
! grep -qF 'PSVersion' "$DOTFILES_DIR/setup-linux.sh"
}

@test "setup-windows.ps1 deploys SSH config" {
grep -q 'Setting up SSH config' "$PS1_SCRIPT"
}
Expand Down
Loading