From adecb48f357097d839ed0e1d69952a297347e96b Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 27 Aug 2026 16:13:18 -0700 Subject: [PATCH 1/5] Pin the Type-Checked Platform to Linux mypy and pyright default to the platform they run on, so a host running either natively on Windows or macOS sees a POSIX-only stdlib attribute (os.mkfifo, os.geteuid) as missing, even where a runtime unittest.skipUnless/skipTest guard already excludes it there, since mypy's own flow analysis does not narrow that away. CI always runs mypy on ubuntu-latest, so pinning both checkers to linux keeps a local run in step with it. --- pyproject.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 2bd04de1..75c189f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,12 +9,17 @@ extend-select = ["I"] # isort import ordering, on top of the default rules [tool.mypy] python_version = "3.13" +# CI always runs this on ubuntu-latest, so pinning the platform keeps a host running mypy natively on Windows or macOS in step with it. +# Otherwise a POSIX-only stdlib attribute a runtime skip guard already excludes on that host still fails here, since mypy's own flow analysis does not narrow it away. +platform = "linux" files = ["spec", "host-setup", "scripts"] mypy_path = [".github/actions/prose-gate", ".github/actions/repo-gate"] exclude = 'scripts/(prose_lint|repo_gate)\.py' [tool.pyright] pythonVersion = "3.13" +# Matches [tool.mypy]'s platform pin above, for the same reason: Pylance in an editor otherwise flags the same POSIX-only attributes live on a Windows or macOS host. +pythonPlatform = "Linux" typeCheckingMode = "standard" include = ["spec", "host-setup", "scripts"] exclude = ["**/__pycache__"] From db3b413b6cd7e2bc54a9d3f196216813006bc498 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 27 Aug 2026 16:13:37 -0700 Subject: [PATCH 2/5] Add menu.ps1, the Windows Counterpart to menu.sh Fronts host-setup\windows\ and the hub's repo-level tools with the same looping, hub/downstream-aware menu menu.sh already provides on Linux, including one entry with no Linux peer (setup-wsl.ps1 -Status). Shares bootstrap.ps1's PowerShell 5.1 to pwsh 7 handoff, and clones the hub with git the same way menu.sh does. Updates host-setup/README.md and host-setup/windows/README.md to document it. --- host-setup/README.md | 22 +- host-setup/menu.ps1 | 511 +++++++++++++++++++++++++++++++++++ host-setup/windows/README.md | 11 + 3 files changed, 538 insertions(+), 6 deletions(-) create mode 100644 host-setup/menu.ps1 diff --git a/host-setup/README.md b/host-setup/README.md index f079d8de..057a5806 100644 --- a/host-setup/README.md +++ b/host-setup/README.md @@ -6,7 +6,8 @@ What a machine needs before it can be worked in, and the tooling that puts it th - [`bootstrap.sh`][bootstrap] stands a Debian or Ubuntu host up from nothing. It is the one file fetched on its own, because a host with no git and no checkout is what it exists to fix. It fetches this repository and runs the tooling from that tree. - [`bootstrap.ps1`][bootstrap-ps1] does the same for native Windows. It runs under Windows PowerShell 5.1, the version every fresh Windows host guarantees, and hands off to PowerShell 7 once it has found or installed it, since every script it drives requires that version. -- [`menu.sh`][menu] is a human-facing front end over this repository's tooling: the host actions above, plus the repo-level tools ([`spec/audit.py`][audit-runner], [`scripts/carry.py`][carry], [`scripts/build_dist.py`][build-dist]) most of this repository authors for an agent following instructions rather than for a person choosing from a menu. It has no Windows counterpart yet. +- [`menu.sh`][menu] is a human-facing front end over this repository's tooling: the host actions above, plus the repo-level tools ([`spec/audit.py`][audit-runner], [`scripts/carry.py`][carry], [`scripts/build_dist.py`][build-dist]) most of this repository authors for an agent following instructions rather than for a person choosing from a menu. +- [`menu.ps1`][menu-ps1] is the same front end for native Windows, over `host-setup\windows\` instead. It runs under Windows PowerShell 5.1 and hands off to PowerShell 7 the same way `bootstrap.ps1` does, and it adds one menu entry with no Linux peer, reporting the WSL platform through `setup-wsl.ps1`. - [`linux/`][linux] holds the tooling itself, for Debian and Ubuntu based hosts, Proxmox and WSL included. `install-tools.sh` installs and upgrades the host tools, `upgrade-host.sh` upgrades the packages of the current release or moves to the next one, `setup-github.sh` configures the SSH key, git, and commit signing, and `install-skills.sh` drives the hub's skills installer from the same tree. - [`windows/`][windows] holds the tooling for native Windows, through `winget` and PowerShell 7. `install-tools.ps1` installs and upgrades the host tools, `upgrade-host.ps1` upgrades the winget packages and updates the WSL platform, `setup-github.ps1` configures the SSH key, git, and commit signing, `setup-wsl.ps1` installs a WSL distribution and reports the Docker Desktop integration, and `install-skills.ps1` drives the hub's skills installer from the same tree. - [`agent-safety/`][agent-safety] holds the write-safety guards, deployed per machine and per account. @@ -78,9 +79,17 @@ bash menu.sh host-setup/menu.sh # from a checkout of this repository, or of any other repo in the fleet ``` -It answers a different question than `bootstrap.sh` does. `bootstrap.sh` stands a host up and stops. `menu.sh` loops, because a person sitting down with it usually wants more than one thing done in a sitting, and it tells the hub apart from whichever repo it happens to be run from: a checkout of `ptr727/ProjectTemplate` gets the hub tasks (audit a cataloged repo, check the generated Skills distributions), a checkout of any other repo gets the downstream tasks too (check or pull the hub's verbatim-owned files into that repo's own worktree, per [`scripts/carry.py`][carry]), and every run gets the host tasks this directory's `linux/` tooling already provides. Only the downstream tasks need a repo to run from. The hub tasks fetch the hub themselves when there is no local checkout to reuse, so they still show and still work when the menu is run entirely standalone, off no checkout at all. +`menu.ps1` is its Windows counterpart, fetchable the same way and pasted into a stock `powershell.exe` console: -Reaching `spec/audit.py` and `scripts/carry.py` from outside a hub checkout means fetching one, the same "hosted and reached, never carried" model [`scripts/README.md`][scripts-readme] states for those tools generally. `menu.sh` clones fresh rather than reusing `bootstrap.sh`'s tarball, since `scripts/carry.py` itself checks that its hub argument is a real git checkout on a freshly fetched `origin/main` with no local changes, and a full clone rather than a shallow one, since `spec/audit.py` walks the hub's own commit history to judge whether a carried copy is trailing the file it was copied from. Run from inside the hub itself, that same freshness is confirmed against the local checkout before a hub task uses it, falling back to a fresh clone when the local checkout has moved on, so an audit or a Skills-distribution check never silently reads a stale or feature-branch tree. +```powershell +Invoke-WebRequest -UseBasicParsing -Uri https://raw.githubusercontent.com/ptr727/ProjectTemplate/main/host-setup/menu.ps1 -OutFile menu.ps1 +powershell -ExecutionPolicy Bypass -File menu.ps1 +host-setup\menu.ps1 # from a checkout of this repository, or of any other repo in the fleet +``` + +It answers a different question than `bootstrap.sh`/`bootstrap.ps1` do. Those stand a host up and stop. The menu loops, because a person sitting down with it usually wants more than one thing done in a sitting, and it tells the hub apart from whichever repo it happens to be run from: a checkout of `ptr727/ProjectTemplate` gets the hub tasks (audit a cataloged repo, check the generated Skills distributions), a checkout of any other repo gets the downstream tasks too (check or pull the hub's verbatim-owned files into that repo's own worktree, per [`scripts/carry.py`][carry]), and every run gets the host tasks this directory's `linux/` or `windows/` tooling already provides. Only the downstream tasks need a repo to run from. The hub tasks fetch the hub themselves when there is no local checkout to reuse, so they still show and still work when the menu is run entirely standalone, off no checkout at all. + +Reaching `spec/audit.py` and `scripts/carry.py` from outside a hub checkout means fetching one, the same "hosted and reached, never carried" model [`scripts/README.md`][scripts-readme] states for those tools generally. The menu clones fresh rather than reusing the bootstrap loader's tarball, since `scripts/carry.py` itself checks that its hub argument is a real git checkout on a freshly fetched `origin/main` with no local changes, and a full clone rather than a shallow one, since `spec/audit.py` walks the hub's own commit history to judge whether a carried copy is trailing the file it was copied from. Run from inside the hub itself, that same freshness is confirmed against the local checkout before a hub task uses it, falling back to a fresh clone when the local checkout has moved on, so an audit or a Skills-distribution check never silently reads a stale or feature-branch tree. ## Which Revision a Run Used @@ -90,11 +99,11 @@ Reaching `spec/audit.py` and `scripts/carry.py` from outside a hub checkout mean ## Three Rules This Directory Follows -**Group by whichever axis has one member.** `agent-safety/` is one concern across three platforms, so it is a concern directory holding `install.sh`, `install.ps1` and `install.py`. `linux/` is three concerns on one platform, so it is a platform directory. Windows host tooling therefore sits at `windows/` rather than beside the Linux scripts, because the `winget` equivalent of `install-tools.sh` is a different program rather than a translation of one. It carries one registry record per tool where the Linux script carries four functions, since every Windows source is `winget` and the per-tool variation those functions exist for does not arise. `windows/` also carries a fourth script with no Linux peer, because WSL is a Windows-side concern. The loader is the same shape as `agent-safety/`, not as `linux/`/`windows/`: one concern, two platforms, so `bootstrap.ps1` sits beside `bootstrap.sh` at the top level rather than inside `windows/`. +**Group by whichever axis has one member.** `agent-safety/` is one concern across three platforms, so it is a concern directory holding `install.sh`, `install.ps1` and `install.py`. `linux/` is three concerns on one platform, so it is a platform directory. Windows host tooling therefore sits at `windows/` rather than beside the Linux scripts, because the `winget` equivalent of `install-tools.sh` is a different program rather than a translation of one. It carries one registry record per tool where the Linux script carries four functions, since every Windows source is `winget` and the per-tool variation those functions exist for does not arise. `windows/` also carries a fourth script with no Linux peer, because WSL is a Windows-side concern. The loader and the menu are the same shape as `agent-safety/`, not as `linux/`/`windows/`: one concern, two platforms, so `bootstrap.ps1` sits beside `bootstrap.sh`, and `menu.ps1` beside `menu.sh`, at the top level rather than inside `windows/`. -**Nothing here needs Python to stand a host up, and neither loader needs an interpreter to fetch what it drives.** [`docs/host-setup.md`][host-setup] carries that as part of the contract, with the reasoning. `bootstrap.sh` needs only `curl` and `tar`. `bootstrap.ps1` needs only `tar.exe`, which has shipped with Windows since 1803, and installs its one further dependency, `pwsh`, itself through `winget`. The one exception is the `install-skills` pair, which drives the Python installer at `scripts/skills_install.py` and runs last in a stand-up for exactly that reason: `install-tools` has provided the interpreter by then, and run alone on a host without one it stops and names the tools step as its prerequisite. `menu.sh` needs `git` to fetch the hub, and it checks for `python3` the same lazy way: only the tasks that call a Python tool ask for it, and every host task still works without one. Neither loader runs a gate as a closing step: [`scripts/host_gate.py`][host-gate] measures a host against the floors and is not called from here, and nothing here is called from it. A host set up by hand years ago is an ordinary host, so the gate reports what it is missing and running this tooling is a remedy a person chooses. The two are joined at code time instead, by [`scripts/tests/test_bootstrap.py`][test-bootstrap] asserting that every tool the spec requires is one this tooling can provide. +**Nothing here needs Python to stand a host up, and neither loader needs an interpreter to fetch what it drives.** [`docs/host-setup.md`][host-setup] carries that as part of the contract, with the reasoning. `bootstrap.sh` needs only `curl` and `tar`. `bootstrap.ps1` needs only `tar.exe`, which has shipped with Windows since 1803, and installs its one further dependency, `pwsh`, itself through `winget`. The one exception is the `install-skills` pair, which drives the Python installer at `scripts/skills_install.py` and runs last in a stand-up for exactly that reason: `install-tools` has provided the interpreter by then, and run alone on a host without one it stops and names the tools step as its prerequisite. The menu needs `git` to fetch the hub, and it checks for a working Python the same lazy way: only the tasks that call a Python tool ask for it, and every host task still works without one. Neither loader runs a gate as a closing step: [`scripts/host_gate.py`][host-gate] measures a host against the floors and is not called from here, and nothing here is called from it. A host set up by hand years ago is an ordinary host, so the gate reports what it is missing and running this tooling is a remedy a person chooses. The two are joined at code time instead, by [`scripts/tests/test_bootstrap.py`][test-bootstrap] asserting that every tool the spec requires is one this tooling can provide. -**The scripts under `linux/` and `windows/` share no file, and the duplication is deliberate.** Each is independently fetchable and runnable on its own, which is the property that lets a host with no checkout use one without the others. A shared helper file would take that away: the moment one script sources a sibling, fetching it alone yields a script that dies on a missing file. What is duplicated is about thirty lines each of logging, the dry-run wrapper, the confirmation prompt, and a temporary directory, and those copies are identical rather than merely similar. Do not factor them out. `bootstrap.ps1` now exercises the same fetchability argument `bootstrap.sh` always has, rather than merely being written to allow for it. The `install-skills` pair is the one recorded exception to independent fetchability: it drives `scripts/skills_install.py` at the tree root, because the skills content lives in the tree, so a copy fetched alone has nothing to install and the property cannot apply to it. +**The scripts under `linux/` and `windows/` share no file, and the duplication is deliberate.** Each is independently fetchable and runnable on its own, which is the property that lets a host with no checkout use one without the others. A shared helper file would take that away: the moment one script sources a sibling, fetching it alone yields a script that dies on a missing file. What is duplicated is about thirty lines each of logging, the dry-run wrapper, the confirmation prompt, and a temporary directory, and those copies are identical rather than merely similar. Do not factor them out. `bootstrap.ps1` and `menu.ps1` exercise the same fetchability argument their `.sh` counterparts always have, rather than merely being written to allow for it. The `install-skills` pair is the one recorded exception to independent fetchability: it drives `scripts/skills_install.py` at the tree root, because the skills content lives in the tree, so a copy fetched alone has nothing to install and the property cannot apply to it. @@ -109,6 +118,7 @@ Reaching `spec/audit.py` and `scripts/carry.py` from outside a hub checkout mean [host-setup]: ../docs/host-setup.md [linux]: ./linux/ [menu]: ./menu.sh +[menu-ps1]: ./menu.ps1 [scripts-readme]: ../scripts/README.md [test-bootstrap]: ../scripts/tests/test_bootstrap.py [windows]: ./windows/ diff --git a/host-setup/menu.ps1 b/host-setup/menu.ps1 new file mode 100644 index 00000000..76bce7ef --- /dev/null +++ b/host-setup/menu.ps1 @@ -0,0 +1,511 @@ +# A human-facing front end over the scripts this fleet otherwise authors for an agent following instructions: the host tooling in host-setup\windows\, and the repo-level tools in scripts\ and spec\ that ptr727/ProjectTemplate hosts and every other repo reaches rather than carries. +# Menu options rather than a command a human has to already know, and a forcing function on the tools it fronts: a task with no discoverable menu entry is a gap in the tools themselves. +# +# Fetchable on its own, like bootstrap.ps1: run from a hub checkout directly, or download this one file into a downstream repo and it clones the hub itself. +# Where bootstrap.ps1 stands a host up and stops, this loops so a human answers more than one question in a sitting, and it knows the difference between "the hub" and "a repo this host happens to be sitting in" so it can offer each their own tasks. +# +# Runs under Windows PowerShell 5.1, the one shell a fresh Windows host guarantees, and hands off to PowerShell 7 the same way bootstrap.ps1 does, since every host-setup\windows script it drives requires that version. + +[CmdletBinding()] +param( + [Alias('n')][switch]$DryRun, + [Alias('y')][switch]$Yes, + [string]$Ref, + [string]$Dir, + [switch]$Keep, + [Alias('h')][switch]$Help +) + +# Captured before anything else touches scope, so the pwsh handoff below can rebuild the argument list this process was bound with, rather than reaching for an automatic variable from inside a nested function. +$SCRIPT_BOUND_PARAMETERS = $PSBoundParameters + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +# A non-zero exit from git or a driven script is an answer here rather than a failure. +$PSNativeCommandUseErrorActionPreference = $false + +$HUB_REPO = 'ptr727/ProjectTemplate' +$HUB_URL = "https://github.com/$HUB_REPO" +$DEFAULT_REF = 'main' + +# Every parameter is read into a variable here rather than from inside a function, matching bootstrap.ps1: a parameter reached only from a nested scope reads as declared and never used. +$DRY_RUN = [bool]$DryRun +$ASSUME_YES = [bool]$Yes +$REF = if ($Ref) { $Ref } else { $DEFAULT_REF } +$KEEP = [bool]$Keep +$WANT_HELP = [bool]$Help + +$HUB_ROOT = '' +$HUB_FETCHED = $false +$IS_HUB_CHECKOUT = $false +$DOWNSTREAM_ROOT = '' +$DOWNSTREAM_NAME = '' +$PWSH_PATH = '' +$QUIT = $false + +# --- Output --- + +function log { param([string]$Message = '') Write-Host $Message } +function info { param([string]$Message) Write-Host " $Message" } +function step { param([string]$Message) Write-Host "`n==> $Message" } +function warn { param([string]$Message) [Console]::Error.WriteLine("WARNING: $Message") } +function die { param([string]$Message) [Console]::Error.WriteLine("ERROR: $Message"); exit 1 } +# Reports a task-time error without ending the process, unlike die: a dispatched action's failure returns to the menu, and only a startup failure (bad arguments, no git) is fatal. +function fail { param([string]$Message) [Console]::Error.WriteLine("ERROR: $Message") } + +function usage { + # The closing marker of a here-string has to sit at column 0, so this block is deliberately unindented. + Write-Host @' +Usage: menu.ps1 [options] + +An interactive menu over this fleet's host and repo tooling: update the host tools, upgrade the +OS packages, install the fleet skills, audit a cataloged repo, and pull the hub's verbatim-owned +files into a downstream repo's own worktree. Run from a hub checkout or from any other repo. The +menu shows each the tasks that apply to it. + +Options: + -y, -Yes Pass -Yes to each tool this menu runs, so a tool does not prompt. The menu's + own choice, confirmation, and repo-name prompts still ask. + -n, -DryRun Print what each step would run, change nothing + -Ref REF Hub branch, tag, pull request ref, or commit to run from, default main + -Dir PATH Where a fetched hub checkout is cloned, default %LOCALAPPDATA%\host-setup + -Keep Leave a fetched hub checkout in place, which is removed by default + -h, -Help Show this help + +With no console to ask on, this prints the same reminder bootstrap.ps1 does and exits, since a +redirected run is not a place to answer a menu. +'@ +} + +# --- pwsh handoff --- +# +# Everything above this point, and the four functions below, run under Windows PowerShell 5.1: no ternary or null-coalescing operator, no multi-argument Join-Path, nothing newer than that runtime parses. +# Everything past the handoff may use whatever pwsh 7 accepts, though it mostly does not need to. +# Identical in shape to bootstrap.ps1's own handoff, kept as a separate copy rather than a shared module: a script fetched alone would otherwise fail on a missing sibling file. + +function Resolve-Pwsh { + $command = Get-Command pwsh -ErrorAction SilentlyContinue + if ($command) { return $command.Source } + $candidates = @((Join-Path $env:ProgramFiles 'PowerShell\7\pwsh.exe')) + if (${env:ProgramFiles(x86)}) { $candidates += (Join-Path ${env:ProgramFiles(x86)} 'PowerShell\7\pwsh.exe') } + foreach ($candidate in $candidates) { + if (Test-Path $candidate) { return $candidate } + } + return $null +} + +# PowerShell 7 is deliberately not a managed host tool: a host that cannot run these scripts cannot be repaired by them. +function Install-Pwsh { + if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { + die 'pwsh (PowerShell 7) is not installed, and winget is not on this host to install it. Install "App Installer" from the Microsoft Store, or install PowerShell 7 directly from https://aka.ms/PSWindows, then run this again.' + } + step 'Installing PowerShell 7' + & winget install --id Microsoft.PowerShell --exact --source winget --accept-source-agreements --accept-package-agreements --silent --disable-interactivity | Out-Host + $wingetExit = $LASTEXITCODE + $found = Resolve-Pwsh + if (-not $found) { + die "winget exited $wingetExit installing PowerShell 7, and pwsh could still not be found. Close this console and paste the setup lines again, or install it from https://aka.ms/PSWindows." + } + return $found +} + +# Rebuilds the arguments this process was bound with, since a param() bound script has no raw $args left to forward. +function Get-ForwardedArgument { + $forward = @() + foreach ($key in $script:SCRIPT_BOUND_PARAMETERS.Keys) { + $value = $script:SCRIPT_BOUND_PARAMETERS[$key] + if ($value -is [switch]) { + if ($value.IsPresent) { $forward += "-$key" } + } else { + $forward += "-$key" + $forward += "$value" + } + } + return , $forward +} + +function Invoke-PwshHandoff { + $pwshPath = Resolve-Pwsh + if (-not $pwshPath) { $pwshPath = Install-Pwsh } + $forward = Get-ForwardedArgument + & $pwshPath -NoProfile -ExecutionPolicy Bypass -File $PSCommandPath @forward + exit $LASTEXITCODE +} + +# --- Hub resolution --- + +# The owner/name from a remote.origin.url in any of the shapes git or the GitHub UI hand out, or empty where the checkout carries no origin at all. +function Get-OriginSlug { + param([Parameter(Mandatory)][string]$Root) + $url = (& git -C $Root config --get remote.origin.url 2>$null) + if ($LASTEXITCODE -ne 0 -or -not $url) { return '' } + $slug = "$url".Trim() + $slug = $slug -replace '^git@github\.com:', '' + $slug = $slug -replace '^ssh://git@github\.com/', '' + $slug = $slug -replace '^https://github\.com/', '' + $slug = $slug -replace '\.git$', '' + return $slug +} + +# A marker sitting beside the clone rather than inside it, so a -Dir pointed at a directory this run does not own is never the one removed on exit. +function Get-MarkerPath { Join-Path $script:DIR 'hub.owned' } + +# Refuses to remove an existing $DIR\hub this run did not create, rather than trusting the name. +function Test-UnownedHub { + $hubPath = Join-Path $script:DIR 'hub' + if (-not (Test-Path $hubPath)) { return $true } + if (Test-Path (Get-MarkerPath)) { return $true } + fail "$hubPath exists and this run did not create it, so it will not be removed. Pass -Dir to choose another cache location." + return $false +} + +# Windows has no peer to flock, so a session-scoped named mutex serializes a fetch against a second menu.ps1 sharing this -Dir instead. +function Get-HubMutexName { + $safe = ($script:DIR -replace '[^A-Za-z0-9]', '_') + return "ProjectTemplateHostSetupMenu_$safe" +} + +function Invoke-FetchHub { + # -DryRun promises to change nothing, and fetching is the one real change this whole script makes to the host. + if ($script:DRY_RUN) { + fail "This task needs a fetched hub checkout, and fetching one is itself a change -DryRun does not make. Run without -DryRun, or from inside a hub checkout already on $script:DEFAULT_REF." + return $false + } + New-Item -ItemType Directory -Path $script:DIR -Force | Out-Null + $mutex = New-Object System.Threading.Mutex($false, (Get-HubMutexName)) + $acquired = $false + try { + try { + $acquired = $mutex.WaitOne() + } catch [System.Threading.AbandonedMutexException] { + # A prior holder crashed mid-fetch, which leaves the tree it was writing rather than the lock itself in a bad state, so ownership passes to this run. + $acquired = $true + } + return (Invoke-FetchHubLocked) + } finally { + if ($acquired) { $mutex.ReleaseMutex() } + $mutex.Dispose() + } +} + +function Invoke-FetchHubLocked { + step "Fetching $script:HUB_REPO at $script:REF" + if (-not (Test-UnownedHub)) { return $false } + $hubPath = Join-Path $script:DIR 'hub' + if (Test-Path $hubPath) { Remove-Item -Recurse -Force $hubPath } + # A full clone of the default branch first, whatever -Ref names: spec\audit.py walks the hub's own history to judge whether a carried copy is trailing the file it was copied from, and a shallow clone would read every file as changed at the truncation boundary. + # Piped to Out-Host rather than left bare: an unassigned native call's stdout otherwise joins this function's own return value, which return $true/$false below would then be appended to instead of replacing. + & git clone --quiet --branch $script:DEFAULT_REF --single-branch $script:HUB_URL $hubPath | Out-Host + if ($LASTEXITCODE -ne 0) { + fail "Could not clone $script:HUB_REPO. Check that this host reaches github.com." + return $false + } + # Marked as ours the moment the clone lands rather than only once every later step also succeeds, so a failure below still leaves a tree Test-UnownedHub will clean up on the next run. + New-Item -ItemType File -Path (Get-MarkerPath) -Force | Out-Null + if ($script:REF -ne $script:DEFAULT_REF) { + & git -C $hubPath fetch --quiet origin $script:REF | Out-Host + if ($LASTEXITCODE -ne 0) { + fail "Could not fetch $script:REF from $script:HUB_REPO. Check the ref exists." + return $false + } + & git -C $hubPath checkout --quiet FETCH_HEAD | Out-Host + if ($LASTEXITCODE -ne 0) { + fail "Could not check out $script:REF" + return $false + } + } + $script:HUB_ROOT = $hubPath + $script:HUB_FETCHED = $true + info "Cloned to $script:HUB_ROOT" + return $true +} + +# Whether the current checkout is the hub, by origin identity alone, independent of -Ref or of whether that checkout is fresh enough to reuse. +function Test-HubCheckout { + $top = (& git rev-parse --show-toplevel 2>$null) + if ($LASTEXITCODE -ne 0 -or -not $top) { return } + $top = "$top".Trim() + if ((Get-OriginSlug $top) -ne $script:HUB_REPO) { return } + $script:IS_HUB_CHECKOUT = $true + # Only for the default ref: naming any other -Ref always fetches fresh, even from inside the hub itself. + if ($script:REF -eq $script:DEFAULT_REF) { $script:HUB_ROOT = $top } +} + +# Confirms a tentative local HUB_ROOT still matches a clean, freshly fetched origin/main before any tool reads it. +# A local checkout that has moved on falls back to a real fetch rather than being trusted. +function Confirm-HubRoot { + if ($script:DRY_RUN) { + fail 'This task needs to confirm the hub checkout is fresh, and confirming it means fetching, which -DryRun does not do. Run without -DryRun.' + return $false + } + if (-not $script:HUB_ROOT) { + return (Invoke-FetchHub) + } + & git -C $script:HUB_ROOT fetch --quiet origin $script:DEFAULT_REF | Out-Host + if ($LASTEXITCODE -eq 0) { + $status = & git -C $script:HUB_ROOT status --porcelain + if (-not $status) { + $head = "$(& git -C $script:HUB_ROOT rev-parse HEAD)".Trim() + $originHead = "$(& git -C $script:HUB_ROOT rev-parse "origin/$script:DEFAULT_REF")".Trim() + if ($head -eq $originHead) { return $true } + } + } + $script:HUB_ROOT = '' + return (Invoke-FetchHub) +} + +# A downstream repo is whatever git repo the menu is run from, when that repo is not the hub itself. +# Gated on IS_HUB_CHECKOUT rather than HUB_ROOT: the hub is never a downstream repo, even when -Ref left HUB_ROOT unset. +function Test-DownstreamCheckout { + if ($script:IS_HUB_CHECKOUT) { return } + $top = (& git rev-parse --show-toplevel 2>$null) + if ($LASTEXITCODE -ne 0 -or -not $top) { return } + $top = "$top".Trim() + $script:DOWNSTREAM_ROOT = $top + $slug = Get-OriginSlug $top + # A checkout with no origin falls back to its own directory name, more useful than an empty slug's basename. + $script:DOWNSTREAM_NAME = if ($slug) { Split-Path -Leaf $slug } else { Split-Path -Leaf $top } +} + +function Invoke-Cleanup { + if ($script:KEEP -or -not $script:HUB_FETCHED) { return } + if (-not (Test-Path (Get-MarkerPath))) { return } + Remove-Item -Recurse -Force (Join-Path $script:DIR 'hub') -ErrorAction SilentlyContinue + Remove-Item -Force (Get-MarkerPath) -ErrorAction SilentlyContinue +} + +# --- Running a tool --- + +# Every host tool runs from inside the hub tree, and this is the only place a path inside it is named. +# Spawned as its own pwsh process rather than dot-sourced, since every host-setup\windows script ends its own main with exit, which would otherwise end this menu too. +function Invoke-HostTool { + param([Parameter(Mandatory)][string]$Tool, [Parameter(ValueFromRemainingArguments)][string[]]$Arguments) + if (-not (Confirm-HubRoot)) { return 1 } + $path = Join-Path $script:HUB_ROOT 'host-setup\windows' | Join-Path -ChildPath $Tool + if (-not (Test-Path $path)) { + fail "$script:HUB_ROOT carries no $Tool at host-setup\windows, so this ref is not one to run tasks from" + return 1 + } + $flags = @() + if ($script:ASSUME_YES) { $flags += '-Yes' } + if ($script:DRY_RUN) { $flags += '-DryRun' } + # Out-Host again, for the same reason as the git calls above: bare, this would join the exit code below into one leaked return value. + & $script:PWSH_PATH -NoProfile -ExecutionPolicy Bypass -File $path @Arguments @flags | Out-Host + return $LASTEXITCODE +} + +# The first candidate that is a Python 3.7+, the same probe install-skills.ps1 uses: none of py, python3 or python guarantees that version by construction on Windows. +function Find-Python { + $candidates = @( + @{ Exe = 'py'; Arguments = @('-3') }, + @{ Exe = 'python3'; Arguments = @() }, + @{ Exe = 'python'; Arguments = @() } + ) + foreach ($candidate in $candidates) { + if (-not (Get-Command $candidate.Exe -ErrorAction SilentlyContinue)) { continue } + & $candidate.Exe @($candidate.Arguments) -c 'import sys; sys.exit(0 if sys.version_info >= (3, 7) else 1)' 2>$null + if ($LASTEXITCODE -eq 0) { return $candidate } + } + return $null +} + +# The Python tools under scripts\ and spec\ resolve their own root from __file__ rather than the working directory, so they are called by absolute path from wherever this script runs. +# Checked here rather than upfront in main: a host with no interpreter yet can still use every host action, and only the actions that need one name it as their own prerequisite. +function Invoke-HubPython { + param([Parameter(Mandatory)][string]$ScriptPath, [Parameter(ValueFromRemainingArguments)][string[]]$Arguments) + $python = Find-Python + if (-not $python) { + fail 'Python 3.7+ is required for this task. host-setup\windows\install-tools.ps1 provides it.' + return 127 + } + # Out-Host again, for the same reason as Invoke-HostTool. + & $python.Exe @($python.Arguments) (Join-Path $script:HUB_ROOT $ScriptPath) @Arguments | Out-Host + return $LASTEXITCODE +} + +# --- Actions --- + +function Invoke-AuditRepo { + $default = if ($script:DOWNSTREAM_NAME) { $script:DOWNSTREAM_NAME } else { $script:HUB_REPO } + $default = $default.Split('/')[-1] + $name = Read-Host "Repo to audit [$default]" + if (-not $name) { $name = $default } + if (-not (Confirm-HubRoot)) { return 1 } + return (Invoke-HubPython -ScriptPath 'spec/audit.py' -Arguments $name) +} + +function Invoke-CheckSkillsDist { + if (-not (Confirm-HubRoot)) { return 1 } + $rc = Invoke-HubPython -ScriptPath 'scripts/build_dist.py' -Arguments '--check' + # Only 0 (clean) and 1 (stale) are outcomes scripts\build_dist.py --check documents for itself, so only those two read as a check result. + # Anything else is this task failing to run rather than a finding. + switch ($rc) { + 0 { info 'Every generated Skills distribution matches .agents/skills/'; return 0 } + 1 { info 'A generated Skills distribution is stale. This menu does not regenerate it from a fetched checkout, since the result has to be committed in the hub itself.'; return 0 } + default { + fail "scripts/build_dist.py --check did not run to completion (exit $rc)" + return 1 + } + } +} + +function Invoke-CarryAction { + param([Parameter(Mandatory)][string]$Mode) + if (-not $script:DOWNSTREAM_ROOT) { + fail "No downstream repo checkout found. Run this menu from inside the target repo's own worktree." + return 1 + } + # A non-default -Ref checks out something other than the origin/main scripts/carry.py's hub argument requires, so this is refused here with the actual reason rather than failing deep inside that tool. + if ($script:REF -ne $script:DEFAULT_REF) { + fail "Pulling hub files needs the hub's $script:DEFAULT_REF branch, and this session was started with -Ref $script:REF. Run without -Ref, or start a separate session on $script:DEFAULT_REF for this task." + return 1 + } + $default = $script:DOWNSTREAM_NAME + $name = Read-Host "Repo name as cataloged in registry/repos.json [$default]" + if (-not $name) { $name = $default } + if (-not (Confirm-HubRoot)) { return 1 } + return (Invoke-HubPython -ScriptPath 'scripts/carry.py' -Arguments $Mode, $name, '--target', $script:DOWNSTREAM_ROOT) +} + +# --- Menu --- + +function Show-MenuHeading { + $hubSuffix = if ($script:HUB_ROOT) { " ($script:HUB_ROOT)" } else { '' } + log "Hub: $script:HUB_REPO$hubSuffix" + if ($script:DOWNSTREAM_ROOT) { + log "Downstream: $script:DOWNSTREAM_NAME ($script:DOWNSTREAM_ROOT)" + } else { + log "Downstream: none (run from inside a repo's own checkout for the pull-from-hub tasks)" + } +} + +function Show-Menu { + log '' + Show-MenuHeading + log '' + log 'Host, on this machine:' + log ' 1 Report installed host tools' + log ' 2 Install missing host tools' + log ' 3 Upgrade the host tools that trail upstream' + log ' 4 Report the host OS upgrade status' + log ' 5 Upgrade the host OS packages' + log ' 6 Report git and GitHub setup' + log ' 7 Configure git and GitHub' + # Only setup-wsl.ps1's -Status runs here: its -Install needs a distribution name, which no menu entry collects, and installing one nobody asked for is exactly what this stays out of. + log ' 8 Report the WSL platform and the distributions installed' + log ' 9 Report fleet Skills install status' + log ' 10 Install or update the fleet Skills' + log '' + log 'Hub, ptr727/ProjectTemplate:' + log ' 11 Audit a cataloged repo' + log ' 12 Check the generated Skills distributions are current' + # Also gated on REF: scripts/carry.py always rejects a hub checkout that is not exactly on the default ref. + if ($script:DOWNSTREAM_ROOT -and $script:REF -eq $script:DEFAULT_REF) { + log '' + log 'Downstream, the repo this menu is run from:' + log ' 13 Check what the hub would change here, change nothing' + log " 14 Pull the hub's verbatim-owned files into this repo" + } + log '' + log ' q Quit' + log '' +} + +# A failing task is reported and returns to the menu rather than ending the session, so this cannot double as "quit": QUIT is a separate flag the q/Q case sets, read by the loop after every dispatch regardless of whether the task it ran succeeded. +function Invoke-Dispatch { + param([string]$Choice) + switch ($Choice) { + '1' { return (Invoke-HostTool -Tool 'install-tools.ps1' -Arguments '-Report') } + '2' { return (Invoke-HostTool -Tool 'install-tools.ps1' -Arguments '-Install') } + '3' { return (Invoke-HostTool -Tool 'install-tools.ps1' -Arguments '-Upgrade') } + '4' { return (Invoke-HostTool -Tool 'upgrade-host.ps1' -Arguments '-Status') } + '5' { return (Invoke-HostTool -Tool 'upgrade-host.ps1' -Arguments '-Packages') } + '6' { return (Invoke-HostTool -Tool 'setup-github.ps1' -Arguments '-Status') } + '7' { return (Invoke-HostTool -Tool 'setup-github.ps1' -Arguments '-Configure') } + '8' { return (Invoke-HostTool -Tool 'setup-wsl.ps1' -Arguments '-Status') } + '9' { return (Invoke-HostTool -Tool 'install-skills.ps1' -Arguments '-Report') } + '10' { return (Invoke-HostTool -Tool 'install-skills.ps1') } + '11' { return (Invoke-AuditRepo) } + '12' { return (Invoke-CheckSkillsDist) } + '13' { return (Invoke-CarryAction 'check') } + '14' { + # -DryRun changes nothing, and scripts/carry.py itself has no dry-run mode, so a dry-run apply reads as its own check instead of silently mutating the downstream worktree. + if ($script:DRY_RUN) { return (Invoke-CarryAction 'check') } + return (Invoke-CarryAction 'apply') + } + { $_ -in @('q', 'Q') } { $script:QUIT = $true; return 0 } + default { + warn 'Not one of the choices' + return 2 + } + } +} + +function Invoke-InteractiveMenu { + while ($true) { + Show-Menu + $choice = Read-Host 'Choose' + $script:QUIT = $false + $rc = Invoke-Dispatch $choice + if ($script:QUIT) { break } + # An unrecognized choice is rc 2, already warned by Invoke-Dispatch, so this loops straight back rather than reading a pointless confirmation. + if ($rc -eq 2) { continue } + if ($rc -eq 0) { + step 'Done' + } else { + warn 'That task ended with an error' + } + Read-Host 'Press Enter to return to the menu' | Out-Null + } +} + +# --- Entry --- + +# Both are checked because a scheduled task reports one and not the other, and either alone misses a case. +# The ISE is checked apart, because it answers both of those as interactive and then blocks on a Read-Host it does not render usably. +function Test-Interactive { + if (-not [Environment]::UserInteractive -or [Console]::IsInputRedirected) { return $false } + if ($Host.Name -eq 'Windows PowerShell ISE Host') { return $false } + return $true +} + +# An absolute path, and never a drive root, since everything below it is created and removed under it. +function Resolve-Directory { + if (-not $script:Dir) { return (Join-Path $env:LOCALAPPDATA 'host-setup') } + if (-not [IO.Path]::IsPathRooted($script:Dir)) { die "-Dir takes an absolute path, and `"$($script:Dir)`" is relative" } + $trimmed = $script:Dir.TrimEnd('\', '/') + if ((-not $trimmed) -or ($trimmed -match '^[A-Za-z]:$')) { die '-Dir may not be a drive root' } + return $trimmed +} + +function main { + if ($script:WANT_HELP) { usage; exit 0 } + + if ($PSVersionTable.PSVersion.Major -lt 7) { + Invoke-PwshHandoff + } + + # Reached only under a confirmed pwsh 7, either started that way or handed off to above. + $script:PWSH_PATH = (Get-Process -Id $PID).Path + $script:DIR = Resolve-Directory + + if (-not (Test-Interactive)) { + warn 'No console to ask on, so there is no menu to show' + info 'Download the file and run it, rather than piping it, to reach the menu:' + info " Invoke-WebRequest -UseBasicParsing -Uri https://raw.githubusercontent.com/$script:HUB_REPO/$script:DEFAULT_REF/host-setup/menu.ps1 -OutFile menu.ps1" + info ' powershell -ExecutionPolicy Bypass -File menu.ps1' + exit 0 + } + + if (-not (Get-Command git -ErrorAction SilentlyContinue)) { die 'git is required' } + + try { + Test-HubCheckout + Test-DownstreamCheckout + Invoke-InteractiveMenu + } finally { + Invoke-Cleanup + } +} + +main diff --git a/host-setup/windows/README.md b/host-setup/windows/README.md index d574a594..2070eeff 100644 --- a/host-setup/windows/README.md +++ b/host-setup/windows/README.md @@ -74,6 +74,15 @@ pwsh -NoProfile -File ..\bootstrap.ps1 -Help ..\bootstrap.ps1 -Report -DryRun ``` +## menu.ps1 + +[`menu.ps1`][menu-ps1] sits beside [`menu.sh`][menu] at the top of [`host-setup/`][host-setup-readme] for the same reason `bootstrap.ps1` sits beside `bootstrap.sh`: it fronts this registry's scripts rather than joining it, and [`host-setup/README.md`][host-setup-readme] "The Human Menu" carries its full contract. It shares `bootstrap.ps1`'s PowerShell 5.1 to `pwsh` handoff, and it adds one entry with no Linux peer, `setup-wsl.ps1 -Status`, since WSL is a Windows-side concern. + +```powershell +pwsh -NoProfile -File ..\menu.ps1 -Help +..\menu.ps1 -DryRun +``` + ## Docker Desktop and WSL `setup-wsl.ps1` **reports** the Docker Desktop integration and never writes it. Docker holds those settings in memory and rewrites its settings file from that copy while it runs, so an edit made here is discarded at Docker's next save and an edit made while it is stopped is undone by the next start. Change it in Docker Desktop under Settings, Resources, WSL integration. @@ -146,6 +155,8 @@ The scripts are checked by `PSScriptAnalyzer`, which runs in CI as the peer of t [host-setup]: ../../docs/host-setup.md [host-setup-readme]: ../README.md [install-tools]: ./install-tools.ps1 +[menu]: ../menu.sh +[menu-ps1]: ../menu.ps1 [setup-github]: ./setup-github.ps1 [setup-wsl]: ./setup-wsl.ps1 [test-bootstrap]: ../../scripts/tests/test_bootstrap.py From f64b8be45cbe31327b1e4e911be8899ff0d92c4f Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 27 Aug 2026 16:35:31 -0700 Subject: [PATCH 3/5] Fix Real Findings from menu.ps1's Review qodo-code-review and coderabbitai on PR #1045: - A failed or interrupted git clone left an unmarked partial hub directory that blocked every retry until removed by hand. Marks ownership before the clone rather than after, and cleans up on failure. Fixed in both menu.ps1 and menu.sh, since the same gap exists in both. - -Dir accepted a lexically non-root path that resolves to a drive or UNC share root ("C:\temp\.."), bypassing the root guard. Canonicalizes before checking, matching menu.sh's own readlink -m step. - -DryRun refused every menu choice outright whenever a hub checkout was already known, defeating its own "print what each step would run" contract. Now trusts an already-known checkout under -DryRun instead of fetching to confirm it is fresh. Fixed in both menu.ps1 and menu.sh. - Test-UnownedHub is renamed Test-HubRemovable so its return value matches its name. - Documents two patterns a reviewer read as bugs but are deliberate, established conventions this file already follows: the mutex's release timing (matches menu.sh's own flock, same accepted trade-off), and the case-insensitive $DIR/-Dir aliasing (matches bootstrap.ps1's own pattern). - Adds the TLS 1.2 line menu.ps1's own fetch example was missing, matching bootstrap.ps1's identical Invoke-WebRequest reminder. --- host-setup/README.md | 1 + host-setup/menu.ps1 | 52 +++++++++++++++++++++++++------------------- host-setup/menu.sh | 29 ++++++++++++------------ 3 files changed, 46 insertions(+), 36 deletions(-) diff --git a/host-setup/README.md b/host-setup/README.md index 057a5806..674474cf 100644 --- a/host-setup/README.md +++ b/host-setup/README.md @@ -82,6 +82,7 @@ host-setup/menu.sh # from a checkout of this repository, or of any other repo `menu.ps1` is its Windows counterpart, fetchable the same way and pasted into a stock `powershell.exe` console: ```powershell +[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 Invoke-WebRequest -UseBasicParsing -Uri https://raw.githubusercontent.com/ptr727/ProjectTemplate/main/host-setup/menu.ps1 -OutFile menu.ps1 powershell -ExecutionPolicy Bypass -File menu.ps1 host-setup\menu.ps1 # from a checkout of this repository, or of any other repo in the fleet diff --git a/host-setup/menu.ps1 b/host-setup/menu.ps1 index 76bce7ef..e3790ae2 100644 --- a/host-setup/menu.ps1 +++ b/host-setup/menu.ps1 @@ -35,6 +35,7 @@ $REF = if ($Ref) { $Ref } else { $DEFAULT_REF } $KEEP = [bool]$Keep $WANT_HELP = [bool]$Help +# No placeholder for $DIR here, matching bootstrap.ps1: PowerShell variable names are case-insensitive, so $DIR and the -Dir parameter $Dir are the same variable, and Resolve-Directory's own assignment in main is what gives it its resolved value. $HUB_ROOT = '' $HUB_FETCHED = $false $IS_HUB_CHECKOUT = $false @@ -150,8 +151,8 @@ function Get-OriginSlug { # A marker sitting beside the clone rather than inside it, so a -Dir pointed at a directory this run does not own is never the one removed on exit. function Get-MarkerPath { Join-Path $script:DIR 'hub.owned' } -# Refuses to remove an existing $DIR\hub this run did not create, rather than trusting the name. -function Test-UnownedHub { +# Answers whether an existing $DIR\hub may be removed: absent, or created by this run. +function Test-HubRemovable { $hubPath = Join-Path $script:DIR 'hub' if (-not (Test-Path $hubPath)) { return $true } if (Test-Path (Get-MarkerPath)) { return $true } @@ -183,6 +184,8 @@ function Invoke-FetchHub { } return (Invoke-FetchHubLocked) } finally { + # Released here, once the fetch itself finishes, rather than held for the rest of this session: Invoke-InteractiveMenu's loop keeps a session alive well past its one fetch, and holding the lock that long would block every other menu.ps1 sharing this -Dir until this session quits. + # A second session starting its own fetch while this one is still reading the tree it just cloned is the accepted residual race left by that choice, the same one menu.sh's own flock accepts for the same reason. if ($acquired) { $mutex.ReleaseMutex() } $mutex.Dispose() } @@ -190,18 +193,19 @@ function Invoke-FetchHub { function Invoke-FetchHubLocked { step "Fetching $script:HUB_REPO at $script:REF" - if (-not (Test-UnownedHub)) { return $false } + if (-not (Test-HubRemovable)) { return $false } $hubPath = Join-Path $script:DIR 'hub' if (Test-Path $hubPath) { Remove-Item -Recurse -Force $hubPath } + # Marked as ours before git can create anything under $hubPath, not only once the clone also succeeds: git can leave a partial directory behind on a failed or interrupted clone, and an unmarked one would then block every retry until removed by hand. + New-Item -ItemType File -Path (Get-MarkerPath) -Force | Out-Null # A full clone of the default branch first, whatever -Ref names: spec\audit.py walks the hub's own history to judge whether a carried copy is trailing the file it was copied from, and a shallow clone would read every file as changed at the truncation boundary. # Piped to Out-Host rather than left bare: an unassigned native call's stdout otherwise joins this function's own return value, which return $true/$false below would then be appended to instead of replacing. & git clone --quiet --branch $script:DEFAULT_REF --single-branch $script:HUB_URL $hubPath | Out-Host if ($LASTEXITCODE -ne 0) { fail "Could not clone $script:HUB_REPO. Check that this host reaches github.com." + Remove-Item -Recurse -Force $hubPath -ErrorAction SilentlyContinue return $false } - # Marked as ours the moment the clone lands rather than only once every later step also succeeds, so a failure below still leaves a tree Test-UnownedHub will clean up on the next run. - New-Item -ItemType File -Path (Get-MarkerPath) -Force | Out-Null if ($script:REF -ne $script:DEFAULT_REF) { & git -C $hubPath fetch --quiet origin $script:REF | Out-Host if ($LASTEXITCODE -ne 0) { @@ -234,23 +238,24 @@ function Test-HubCheckout { # Confirms a tentative local HUB_ROOT still matches a clean, freshly fetched origin/main before any tool reads it. # A local checkout that has moved on falls back to a real fetch rather than being trusted. function Confirm-HubRoot { + if ($script:HUB_ROOT) { + # -DryRun trusts an already-known checkout (this hub checkout, or one this run already fetched) as is, rather than fetching to confirm it is still fresh: confirming means fetching, and fetching is a change -DryRun does not make. + if ($script:DRY_RUN) { return $true } + & git -C $script:HUB_ROOT fetch --quiet origin $script:DEFAULT_REF | Out-Host + if ($LASTEXITCODE -eq 0) { + $status = & git -C $script:HUB_ROOT status --porcelain + if (-not $status) { + $head = "$(& git -C $script:HUB_ROOT rev-parse HEAD)".Trim() + $originHead = "$(& git -C $script:HUB_ROOT rev-parse "origin/$script:DEFAULT_REF")".Trim() + if ($head -eq $originHead) { return $true } + } + } + $script:HUB_ROOT = '' + } if ($script:DRY_RUN) { - fail 'This task needs to confirm the hub checkout is fresh, and confirming it means fetching, which -DryRun does not do. Run without -DryRun.' + fail "This task needs a fetched hub checkout, and fetching one is itself a change -DryRun does not make. Run without -DryRun, or from inside a hub checkout already on $script:DEFAULT_REF." return $false } - if (-not $script:HUB_ROOT) { - return (Invoke-FetchHub) - } - & git -C $script:HUB_ROOT fetch --quiet origin $script:DEFAULT_REF | Out-Host - if ($LASTEXITCODE -eq 0) { - $status = & git -C $script:HUB_ROOT status --porcelain - if (-not $status) { - $head = "$(& git -C $script:HUB_ROOT rev-parse HEAD)".Trim() - $originHead = "$(& git -C $script:HUB_ROOT rev-parse "origin/$script:DEFAULT_REF")".Trim() - if ($head -eq $originHead) { return $true } - } - } - $script:HUB_ROOT = '' return (Invoke-FetchHub) } @@ -469,12 +474,14 @@ function Test-Interactive { return $true } -# An absolute path, and never a drive root, since everything below it is created and removed under it. +# An absolute path, and never a drive or UNC share root, since everything below it is created and removed under it. function Resolve-Directory { if (-not $script:Dir) { return (Join-Path $env:LOCALAPPDATA 'host-setup') } if (-not [IO.Path]::IsPathRooted($script:Dir)) { die "-Dir takes an absolute path, and `"$($script:Dir)`" is relative" } - $trimmed = $script:Dir.TrimEnd('\', '/') - if ((-not $trimmed) -or ($trimmed -match '^[A-Za-z]:$')) { die '-Dir may not be a drive root' } + # Canonicalized before the root check, since a lexically rooted "C:\temp\.." is not the string "C:\" but resolves to it the moment anything below opens a path under it, matching menu.sh's own readlink -m step. + $canonical = [IO.Path]::GetFullPath($script:Dir) + $trimmed = $canonical.TrimEnd('\', '/') + if ((-not $trimmed) -or ($trimmed -match '^[A-Za-z]:$') -or ($trimmed -match '^\\\\[^\\]+\\[^\\]+$')) { die '-Dir may not be a drive or share root' } return $trimmed } @@ -492,6 +499,7 @@ function main { if (-not (Test-Interactive)) { warn 'No console to ask on, so there is no menu to show' info 'Download the file and run it, rather than piping it, to reach the menu:' + info ' [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12' info " Invoke-WebRequest -UseBasicParsing -Uri https://raw.githubusercontent.com/$script:HUB_REPO/$script:DEFAULT_REF/host-setup/menu.ps1 -OutFile menu.ps1" info ' powershell -ExecutionPolicy Bypass -File menu.ps1' exit 0 diff --git a/host-setup/menu.sh b/host-setup/menu.sh index 6c094679..13520e87 100755 --- a/host-setup/menu.sh +++ b/host-setup/menu.sh @@ -112,14 +112,15 @@ fetch_hub_locked() { step "Fetching $HUB_REPO at $REF" remove_unowned_hub_check || return 1 rm -rf "$DIR/hub" + # Marked as ours before git can create anything under $DIR/hub, not only once the clone also succeeds: git can leave a partial directory behind on a failed or interrupted clone, and an unmarked one would then block every retry until removed by hand. + touch "$(marker_path)" # A full clone of the default branch first, whatever $REF names: spec/audit.py walks the hub's own history to judge whether a carried copy is trailing the file it was copied from, and a shallow clone would read every file as changed at the truncation boundary and misreport every repo as stale. git clone --quiet --branch "$DEFAULT_REF" --single-branch "$HUB_URL" "$DIR/hub" || { fail "Could not clone $HUB_REPO. Check that this host reaches github.com." + rm -rf "$DIR/hub" return 1 } - # Marked as ours the moment the clone lands rather than only once every later step also succeeds, so a failure below still leaves a tree remove_unowned_hub_check will clean up on the next run instead of blocking every retry as somebody else's. - touch "$(marker_path)" # A branch name is already checked out by the clone above. # A tag, a pull request ref, or a commit needs an explicit fetch and checkout, since "git clone --branch" only takes a branch or a tag, not an arbitrary commit. if [[ $REF != "$DEFAULT_REF" ]]; then @@ -158,21 +159,21 @@ detect_hub_root() { # Confirms a tentative local HUB_ROOT still matches a clean, freshly fetched origin/main before any tool reads it, checked here rather than at startup so opening the menu costs no network call until a hub-dependent task actually runs. # A local checkout that has moved on (a feature branch, a commit behind, an uncommitted edit) falls back to a real fetch rather than being trusted, the same freshness and cleanliness carry.py's own verify_hub already requires of its own hub argument. ensure_hub_root() { - # The freshness check below itself fetches, which updates FETCH_HEAD and the remote-tracking ref even though it touches no working file, so it is as much a change as fetch_hub's own clone and is refused for the same reason. + if [[ -n $HUB_ROOT ]]; then + # -DryRun trusts an already-known checkout (this hub checkout) as is, rather than fetching to confirm it is still fresh: confirming means fetching, and fetching is a change --dry-run does not make. + [[ $DRY_RUN == true ]] && return 0 + # The freshness check below itself fetches, which updates FETCH_HEAD and the remote-tracking ref even though it touches no working file, so it is as much a change as fetch_hub's own clone. + if git -C "$HUB_ROOT" fetch --quiet origin "$DEFAULT_REF" && + [[ -z $(git -C "$HUB_ROOT" status --porcelain) ]] && + [[ $(git -C "$HUB_ROOT" rev-parse HEAD) == "$(git -C "$HUB_ROOT" rev-parse "origin/$DEFAULT_REF")" ]]; then + return 0 + fi + HUB_ROOT="" + fi [[ $DRY_RUN == true ]] && { - fail "This task needs to confirm the hub checkout is fresh, and confirming it means fetching, which --dry-run does not do. Run without --dry-run." + fail "This task needs a fetched hub checkout, and fetching one is itself a change --dry-run does not make. Run without --dry-run, or from inside a hub checkout already on $DEFAULT_REF." return 1 } - if [[ -z $HUB_ROOT ]]; then - fetch_hub - return - fi - if git -C "$HUB_ROOT" fetch --quiet origin "$DEFAULT_REF" && - [[ -z $(git -C "$HUB_ROOT" status --porcelain) ]] && - [[ $(git -C "$HUB_ROOT" rev-parse HEAD) == "$(git -C "$HUB_ROOT" rev-parse "origin/$DEFAULT_REF")" ]]; then - return 0 - fi - HUB_ROOT="" fetch_hub } From 899f3112f6b034e3a638523e6dc2a10172769f60 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 27 Aug 2026 16:54:26 -0700 Subject: [PATCH 4/5] Fix Two More Real Findings from CodeRabbit's Second Pass - A failed clone removed the partial hub directory but left its ownership marker behind, which would grant false ownership to whatever unrelated directory later occupies that same path. Removes the marker along with the directory on a failed clone, and only removes the marker after a successful cleanup removal too. Fixed in both menu.ps1 and menu.sh. - The prior fix for -DryRun trusting an already-known hub checkout only checked origin identity (Test-HubCheckout/detect_hub_root), not whether that checkout is clean and actually on main. A dirty or feature-branch checkout could therefore be read as the hub's main under -DryRun. Confirm-HubRoot/ensure_hub_root now check both, locally, with no network call. Fixed in both menu.ps1 and menu.sh. --- host-setup/menu.ps1 | 34 ++++++++++++++++++++++------------ host-setup/menu.sh | 32 +++++++++++++++++++++----------- 2 files changed, 43 insertions(+), 23 deletions(-) diff --git a/host-setup/menu.ps1 b/host-setup/menu.ps1 index e3790ae2..6766db1d 100644 --- a/host-setup/menu.ps1 +++ b/host-setup/menu.ps1 @@ -203,7 +203,9 @@ function Invoke-FetchHubLocked { & git clone --quiet --branch $script:DEFAULT_REF --single-branch $script:HUB_URL $hubPath | Out-Host if ($LASTEXITCODE -ne 0) { fail "Could not clone $script:HUB_REPO. Check that this host reaches github.com." + # The marker goes with the directory it marked: an orphaned one left behind would grant false ownership to whatever unrelated directory a person later puts at this same path. Remove-Item -Recurse -Force $hubPath -ErrorAction SilentlyContinue + Remove-Item -Force (Get-MarkerPath) -ErrorAction SilentlyContinue return $false } if ($script:REF -ne $script:DEFAULT_REF) { @@ -239,21 +241,27 @@ function Test-HubCheckout { # A local checkout that has moved on falls back to a real fetch rather than being trusted. function Confirm-HubRoot { if ($script:HUB_ROOT) { - # -DryRun trusts an already-known checkout (this hub checkout, or one this run already fetched) as is, rather than fetching to confirm it is still fresh: confirming means fetching, and fetching is a change -DryRun does not make. - if ($script:DRY_RUN) { return $true } - & git -C $script:HUB_ROOT fetch --quiet origin $script:DEFAULT_REF | Out-Host - if ($LASTEXITCODE -eq 0) { + if ($script:DRY_RUN) { + # No network call, since confirming freshness against origin means fetching, which -DryRun does not do, but this still confirms the checkout is clean and actually on main: Test-HubCheckout checked only origin identity, and a dirty or feature-branch checkout must not be read as the hub's main either. $status = & git -C $script:HUB_ROOT status --porcelain - if (-not $status) { - $head = "$(& git -C $script:HUB_ROOT rev-parse HEAD)".Trim() - $originHead = "$(& git -C $script:HUB_ROOT rev-parse "origin/$script:DEFAULT_REF")".Trim() - if ($head -eq $originHead) { return $true } + $branch = "$(& git -C $script:HUB_ROOT rev-parse --abbrev-ref HEAD)".Trim() + if ((-not $status) -and ($branch -eq $script:DEFAULT_REF)) { return $true } + $script:HUB_ROOT = '' + } else { + & git -C $script:HUB_ROOT fetch --quiet origin $script:DEFAULT_REF | Out-Host + if ($LASTEXITCODE -eq 0) { + $status = & git -C $script:HUB_ROOT status --porcelain + if (-not $status) { + $head = "$(& git -C $script:HUB_ROOT rev-parse HEAD)".Trim() + $originHead = "$(& git -C $script:HUB_ROOT rev-parse "origin/$script:DEFAULT_REF")".Trim() + if ($head -eq $originHead) { return $true } + } } + $script:HUB_ROOT = '' } - $script:HUB_ROOT = '' } if ($script:DRY_RUN) { - fail "This task needs a fetched hub checkout, and fetching one is itself a change -DryRun does not make. Run without -DryRun, or from inside a hub checkout already on $script:DEFAULT_REF." + fail "This task needs a fetched hub checkout, and fetching one is itself a change -DryRun does not make. Run without -DryRun, or from inside a clean hub checkout already on $script:DEFAULT_REF." return $false } return (Invoke-FetchHub) @@ -275,8 +283,10 @@ function Test-DownstreamCheckout { function Invoke-Cleanup { if ($script:KEEP -or -not $script:HUB_FETCHED) { return } if (-not (Test-Path (Get-MarkerPath))) { return } - Remove-Item -Recurse -Force (Join-Path $script:DIR 'hub') -ErrorAction SilentlyContinue - Remove-Item -Force (Get-MarkerPath) -ErrorAction SilentlyContinue + $hubPath = Join-Path $script:DIR 'hub' + # The marker is removed only once the directory it marks is actually gone, rather than unconditionally alongside it: a suppressed removal failure must not leave a leftover hub with no marker to explain it. + Remove-Item -Recurse -Force $hubPath -ErrorAction SilentlyContinue + if (-not (Test-Path $hubPath)) { Remove-Item -Force (Get-MarkerPath) -ErrorAction SilentlyContinue } } # --- Running a tool --- diff --git a/host-setup/menu.sh b/host-setup/menu.sh index 13520e87..3076f4d0 100755 --- a/host-setup/menu.sh +++ b/host-setup/menu.sh @@ -118,7 +118,8 @@ fetch_hub_locked() { git clone --quiet --branch "$DEFAULT_REF" --single-branch "$HUB_URL" "$DIR/hub" || { fail "Could not clone $HUB_REPO. Check that this host reaches github.com." - rm -rf "$DIR/hub" + # The marker goes with the directory it marked: an orphaned one left behind would grant false ownership to whatever unrelated directory a person later puts at this same path. + rm -rf "$DIR/hub" "$(marker_path)" return 1 } # A branch name is already checked out by the clone above. @@ -160,18 +161,25 @@ detect_hub_root() { # A local checkout that has moved on (a feature branch, a commit behind, an uncommitted edit) falls back to a real fetch rather than being trusted, the same freshness and cleanliness carry.py's own verify_hub already requires of its own hub argument. ensure_hub_root() { if [[ -n $HUB_ROOT ]]; then - # -DryRun trusts an already-known checkout (this hub checkout) as is, rather than fetching to confirm it is still fresh: confirming means fetching, and fetching is a change --dry-run does not make. - [[ $DRY_RUN == true ]] && return 0 - # The freshness check below itself fetches, which updates FETCH_HEAD and the remote-tracking ref even though it touches no working file, so it is as much a change as fetch_hub's own clone. - if git -C "$HUB_ROOT" fetch --quiet origin "$DEFAULT_REF" && - [[ -z $(git -C "$HUB_ROOT" status --porcelain) ]] && - [[ $(git -C "$HUB_ROOT" rev-parse HEAD) == "$(git -C "$HUB_ROOT" rev-parse "origin/$DEFAULT_REF")" ]]; then - return 0 + if [[ $DRY_RUN == true ]]; then + # No network call, since confirming freshness against origin means fetching, which --dry-run does not do, but this still confirms the checkout is clean and actually on main: detect_hub_root checked only origin identity, and a dirty or feature-branch checkout must not be read as the hub's main either. + if [[ -z $(git -C "$HUB_ROOT" status --porcelain) ]] && + [[ $(git -C "$HUB_ROOT" rev-parse --abbrev-ref HEAD) == "$DEFAULT_REF" ]]; then + return 0 + fi + HUB_ROOT="" + else + # The freshness check below itself fetches, which updates FETCH_HEAD and the remote-tracking ref even though it touches no working file, so it is as much a change as fetch_hub's own clone. + if git -C "$HUB_ROOT" fetch --quiet origin "$DEFAULT_REF" && + [[ -z $(git -C "$HUB_ROOT" status --porcelain) ]] && + [[ $(git -C "$HUB_ROOT" rev-parse HEAD) == "$(git -C "$HUB_ROOT" rev-parse "origin/$DEFAULT_REF")" ]]; then + return 0 + fi + HUB_ROOT="" fi - HUB_ROOT="" fi [[ $DRY_RUN == true ]] && { - fail "This task needs a fetched hub checkout, and fetching one is itself a change --dry-run does not make. Run without --dry-run, or from inside a hub checkout already on $DEFAULT_REF." + fail "This task needs a fetched hub checkout, and fetching one is itself a change --dry-run does not make. Run without --dry-run, or from inside a clean hub checkout already on $DEFAULT_REF." return 1 } fetch_hub @@ -191,7 +199,9 @@ detect_downstream_root() { cleanup() { [[ $KEEP == true || $HUB_FETCHED == false ]] && return 0 [[ -e "$(marker_path)" ]] || return 0 - rm -rf "$DIR/hub" "$(marker_path)" + # The marker is removed only once the directory it marks is actually gone, rather than unconditionally alongside it: a suppressed removal failure must not leave a leftover hub with no marker to explain it. + rm -rf "$DIR/hub" + [[ -e "$DIR/hub" ]] || rm -f "$(marker_path)" } # --- Running a tool --- From 1f13db1ee7052075fd8c20b519af5a51ed196c6c Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 27 Aug 2026 17:07:52 -0700 Subject: [PATCH 5/5] Check Git Command Exit Status Before Trusting Its Output The dry-run clean-and-on-main check (and the pre-existing freshness check beside it) tested only a git command's captured stdout, not whether the command itself succeeded. A failed git status or rev-parse with empty stdout read the same as a genuinely clean result, silently accepting an unverified checkout. Each git call's own exit status is now checked before its output is trusted, in both menu.ps1 (Test-HubCleanOnDefaultRef) and menu.sh (hub_root_clean_on_default_ref). --- host-setup/menu.ps1 | 25 ++++++++++++++++++------- host-setup/menu.sh | 22 ++++++++++++++++------ 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/host-setup/menu.ps1 b/host-setup/menu.ps1 index 6766db1d..d139978d 100644 --- a/host-setup/menu.ps1 +++ b/host-setup/menu.ps1 @@ -237,24 +237,35 @@ function Test-HubCheckout { if ($script:REF -eq $script:DEFAULT_REF) { $script:HUB_ROOT = $top } } +# Whether HUB_ROOT is clean and sitting on $DEFAULT_REF, with no assumption that it agrees with origin. +# A failed git call must never read the same as its emptiest possible success, so $LASTEXITCODE is checked after each one rather than only its output. +function Test-HubCleanOnDefaultRef { + $status = & git -C $script:HUB_ROOT status --porcelain + if ($LASTEXITCODE -ne 0 -or $status) { return $false } + $branch = & git -C $script:HUB_ROOT rev-parse --abbrev-ref HEAD + if ($LASTEXITCODE -ne 0) { return $false } + return ("$branch".Trim() -eq $script:DEFAULT_REF) +} + # Confirms a tentative local HUB_ROOT still matches a clean, freshly fetched origin/main before any tool reads it. # A local checkout that has moved on falls back to a real fetch rather than being trusted. function Confirm-HubRoot { if ($script:HUB_ROOT) { if ($script:DRY_RUN) { # No network call, since confirming freshness against origin means fetching, which -DryRun does not do, but this still confirms the checkout is clean and actually on main: Test-HubCheckout checked only origin identity, and a dirty or feature-branch checkout must not be read as the hub's main either. - $status = & git -C $script:HUB_ROOT status --porcelain - $branch = "$(& git -C $script:HUB_ROOT rev-parse --abbrev-ref HEAD)".Trim() - if ((-not $status) -and ($branch -eq $script:DEFAULT_REF)) { return $true } + if (Test-HubCleanOnDefaultRef) { return $true } $script:HUB_ROOT = '' } else { & git -C $script:HUB_ROOT fetch --quiet origin $script:DEFAULT_REF | Out-Host if ($LASTEXITCODE -eq 0) { $status = & git -C $script:HUB_ROOT status --porcelain - if (-not $status) { - $head = "$(& git -C $script:HUB_ROOT rev-parse HEAD)".Trim() - $originHead = "$(& git -C $script:HUB_ROOT rev-parse "origin/$script:DEFAULT_REF")".Trim() - if ($head -eq $originHead) { return $true } + if ($LASTEXITCODE -eq 0 -and -not $status) { + $head = & git -C $script:HUB_ROOT rev-parse HEAD + $headOk = ($LASTEXITCODE -eq 0) + if ($headOk) { + $originHead = & git -C $script:HUB_ROOT rev-parse "origin/$script:DEFAULT_REF" + if ($LASTEXITCODE -eq 0 -and "$head".Trim() -eq "$originHead".Trim()) { return $true } + } } } $script:HUB_ROOT = '' diff --git a/host-setup/menu.sh b/host-setup/menu.sh index 3076f4d0..cec737c4 100755 --- a/host-setup/menu.sh +++ b/host-setup/menu.sh @@ -157,22 +157,32 @@ detect_hub_root() { return 0 } +# Whether HUB_ROOT is clean and sitting on $DEFAULT_REF, with no assumption that it agrees with origin. +# A failed git command must never read the same as its emptiest possible success, so each one's own exit status is captured rather than only its output. +hub_root_clean_on_default_ref() { + local status branch + status=$(git -C "$HUB_ROOT" status --porcelain) || return 1 + [[ -z $status ]] || return 1 + branch=$(git -C "$HUB_ROOT" rev-parse --abbrev-ref HEAD) || return 1 + [[ $branch == "$DEFAULT_REF" ]] +} + # Confirms a tentative local HUB_ROOT still matches a clean, freshly fetched origin/main before any tool reads it, checked here rather than at startup so opening the menu costs no network call until a hub-dependent task actually runs. # A local checkout that has moved on (a feature branch, a commit behind, an uncommitted edit) falls back to a real fetch rather than being trusted, the same freshness and cleanliness carry.py's own verify_hub already requires of its own hub argument. ensure_hub_root() { if [[ -n $HUB_ROOT ]]; then if [[ $DRY_RUN == true ]]; then # No network call, since confirming freshness against origin means fetching, which --dry-run does not do, but this still confirms the checkout is clean and actually on main: detect_hub_root checked only origin identity, and a dirty or feature-branch checkout must not be read as the hub's main either. - if [[ -z $(git -C "$HUB_ROOT" status --porcelain) ]] && - [[ $(git -C "$HUB_ROOT" rev-parse --abbrev-ref HEAD) == "$DEFAULT_REF" ]]; then - return 0 - fi + hub_root_clean_on_default_ref && return 0 HUB_ROOT="" else # The freshness check below itself fetches, which updates FETCH_HEAD and the remote-tracking ref even though it touches no working file, so it is as much a change as fetch_hub's own clone. + local status head origin_head if git -C "$HUB_ROOT" fetch --quiet origin "$DEFAULT_REF" && - [[ -z $(git -C "$HUB_ROOT" status --porcelain) ]] && - [[ $(git -C "$HUB_ROOT" rev-parse HEAD) == "$(git -C "$HUB_ROOT" rev-parse "origin/$DEFAULT_REF")" ]]; then + status=$(git -C "$HUB_ROOT" status --porcelain) && [[ -z $status ]] && + head=$(git -C "$HUB_ROOT" rev-parse HEAD) && + origin_head=$(git -C "$HUB_ROOT" rev-parse "origin/$DEFAULT_REF") && + [[ $head == "$origin_head" ]]; then return 0 fi HUB_ROOT=""