diff --git a/.github/workflows/validate-task.yml b/.github/workflows/validate-task.yml index 329e79d8..fbeb9272 100644 --- a/.github/workflows/validate-task.yml +++ b/.github/workflows/validate-task.yml @@ -52,6 +52,30 @@ jobs: mapfile -t scripts < <(git ls-files '*.sh') docker run --rm --pull=always -v "$PWD":/mnt --workdir /mnt koalaman/shellcheck:stable "${scripts[@]}" + # The peer of the step above, built the same way: the file list comes from git, and the checker runs as a container rather than an install. + # The module version is pinned beside the image because the image alone does not fix it, and a floating install would make this a different check here than the one a maintainer runs locally. + # 1.23.0 rather than the newest: 1.24.0 needs a newer System.Management.Automation than this image carries, so it installs and then fails to import, which reads as a broken gate rather than a version mismatch. + # The count is printed because a checker that read no files reports the same clean as one that read them all, which is how this step first passed having read nothing. + # The list splits on whitespace rather than on a newline, because the same command is documented for a local run and a PowerShell caller joins the file list with spaces where a shell joins it with newlines. + # Splitting on the newline alone hands the analyzer one path holding every file, which it reports as one file it cannot find and then a count of one and no findings, having analyzed nothing at all. + - name: Check PowerShell scripts step + run: | + set -Eeuo pipefail + PS_SCRIPTS="$(git ls-files '*.ps1')" + docker run --rm --pull=always -e PS_SCRIPTS="$PS_SCRIPTS" -v "$PWD":/mnt --workdir /mnt mcr.microsoft.com/powershell:latest \ + pwsh -NoProfile -Command ' + Set-PSRepository PSGallery -InstallationPolicy Trusted + Install-Module PSScriptAnalyzer -RequiredVersion 1.23.0 -Force -Scope AllUsers + Import-Module PSScriptAnalyzer + $files = $env:PS_SCRIPTS -split "\s+" | Where-Object { $_ } + if (-not $files) { Write-Host "no PowerShell scripts are tracked"; exit 0 } + $found = @() + foreach ($file in $files) { $found += Invoke-ScriptAnalyzer -Path $file -Settings ./PSScriptAnalyzerSettings.psd1 } + Write-Host "Checked $($files.Count) file(s)" + if ($found) { $found | Format-Table RuleName,Severity,ScriptName,Line,Message -AutoSize | Out-String -Width 200 | Write-Host; exit 1 } + Write-Host "no findings" + ' + - name: Validate registry and spec step run: | set -Eeuo pipefail diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 89231cff..37398c04 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -369,7 +369,7 @@ CI runs the full lint set, but run the linters locally before pushing to catch i **Each surface runs the lint with the tool that fits it, all from the same config files** (`.markdownlint-cli2.jsonc`, `cspell.json`, `.editorconfig`): -- **CI (authoritative)** runs **markdownlint-cli2**, **cspell**, and **actionlint** as pinned action wrappers (Dependabot bumps them), plus **editorconfig-checker** via Docker `:latest` (its action only installs the CLI, so the Docker one-liner is what actually runs the check). markdownlint covers all `**/*.md`, and **cspell is scoped to `README.md` + `HISTORY.md`** (see [CODESTYLE.md](./CODESTYLE.md) "Markdown and Spelling" for why), matching the cspell one-liner below. +- **CI (authoritative)** runs **markdownlint-cli2**, **cspell**, and **actionlint** as pinned action wrappers (Dependabot bumps them), plus **editorconfig-checker** and **PSScriptAnalyzer** via Docker `:latest` (editorconfig-checker's action only installs the CLI, and PSScriptAnalyzer has no action, so the Docker one-liner is what actually runs each check). markdownlint covers all `**/*.md`, and **cspell is scoped to `README.md` + `HISTORY.md`** (see [CODESTYLE.md](./CODESTYLE.md) "Markdown and Spelling" for why), matching the cspell one-liner below. - **The `.husky/pre-commit` hook** runs **language formatting** and the **diff-scoped doc gates**, never Docker and never a network call, so it stays fast. The formatting half is whatever the repo's own language needs, CSharpier and `dotnet format` for .NET or ruff for Python, via native tooling. A repo adds each half once its tree passes that half, since a gate that fails on the corpus it guards blocks every commit from the moment it lands, so a hook running one half is a repo mid-convergence rather than a repo out of conformance. The doc half runs each gate at the scope that fits it. The prose gate is scoped to what the commit changes rather than swept over the tree, which is the difference between about 2.2 seconds and about 0.13 and is what makes it affordable in a hook at all. A whole-repo check belongs there too when it is already fast and takes no file list, which the line-ending consistency check is, so scope is a property of the gate rather than a rule the hook applies to all of them. `repo_gate.py --check sha-pin` stays out, since it resolves a same-owner pin against the GitHub API and a hook that needs a network fails offline. A repo enables the hook per clone with `git config core.hooksPath .husky`, and CI remains the authoritative run either way. - **The VS Code Lint tasks** run the full doc-lint set via Docker `:latest` on demand, the local surface for Markdown, spelling, workflow, and EditorConfig checks. @@ -401,6 +401,28 @@ The Docker invocations below are the same ones the VS Code tasks use, for ad-hoc docker run --rm --pull=always -v "$PWD":/workdir --workdir /workdir ghcr.io/streetsidesoftware/cspell:latest --no-progress README.md HISTORY.md ``` +- **PSScriptAnalyzer** (PowerShell, the peer of the shellcheck step, with the excluded rules and their reasons in [`PSScriptAnalyzerSettings.psd1`](./PSScriptAnalyzerSettings.psd1)): + + ```sh + docker run --rm --pull=always -e PS_SCRIPTS="$(git ls-files '*.ps1')" -v "$PWD":/mnt --workdir /mnt mcr.microsoft.com/powershell:latest \ + pwsh -NoProfile -Command ' + Set-PSRepository PSGallery -InstallationPolicy Trusted + Install-Module PSScriptAnalyzer -RequiredVersion 1.23.0 -Force -Scope AllUsers + Import-Module PSScriptAnalyzer + $files = $env:PS_SCRIPTS -split "\s+" | Where-Object { $_ } + if (-not $files) { Write-Host "no PowerShell scripts are tracked"; exit 0 } + $found = @() + foreach ($file in $files) { $found += Invoke-ScriptAnalyzer -Path $file -Settings ./PSScriptAnalyzerSettings.psd1 } + Write-Host "Checked $($files.Count) file(s)" + if ($found) { $found | Format-Table RuleName,Severity,ScriptName,Line,Message -AutoSize | Out-String -Width 200 | Write-Host; exit 1 } + Write-Host "no findings" + ' + ``` + + The module version is pinned beside the image, because the image alone does not fix it and a floating install makes a local run a different check from CI. 1.23.0 rather than the newest, since 1.24.0 needs a newer `System.Management.Automation` than the image carries and fails to import after installing cleanly. The file list comes from `git ls-files` for the same reason the shellcheck step uses it, and the count is printed because a run that read no files reports the same clean as one that read them all. + + **The list splits on whitespace rather than on a newline, and the regex is double-quoted.** A shell joins the file list with newlines and PowerShell joins it with spaces, so a newline-only split hands the analyzer one path holding every file, which it reports as one file it cannot find followed by a clean run over nothing. The double quotes are what let the whole invocation stay inside the single-quoted `-Command` a shell passes, since PowerShell escapes with a backtick and leaves the backslash alone. Run verbatim it reports `Checked 5 file(s)` from either shell. + In a configured editor the davidanson extension is enough. Use the Docker CLI when there's no IDE (agent/headless) or to confirm a clean run before pushing. When pulling a public image fails on a Docker-Desktop/WSL credential-helper error (`docker-credential-desktop.exe: exec format error`), retry with an empty Docker config: `DOCKER_CONFIG=$(mktemp -d) docker run ...` after writing `{}` to `$DOCKER_CONFIG/config.json`. diff --git a/OPERATIONS.md b/OPERATIONS.md index f0848843..28dbf5a2 100644 --- a/OPERATIONS.md +++ b/OPERATIONS.md @@ -22,8 +22,12 @@ python3 scripts/prose_lint.py . --check charset-unknown --summary for f in registry/*.json spec/*.json repo-config/*.json; do jq empty "$f"; done python3 spec/validate.py docker run --rm --pull=always -v "$PWD":/check --workdir /check mstruebing/editorconfig-checker:latest +scripts=(); while IFS= read -r f; do scripts+=("$f"); done < <(git ls-files '*.sh'); docker run --rm --pull=always -v "$PWD":/mnt --workdir /mnt koalaman/shellcheck:stable "${scripts[@]}" +docker run --rm --pull=always -e PS_SCRIPTS="$(git ls-files '*.ps1')" -v "$PWD":/mnt --workdir /mnt mcr.microsoft.com/powershell:latest pwsh -NoProfile -Command 'Set-PSRepository PSGallery -InstallationPolicy Trusted; Install-Module PSScriptAnalyzer -RequiredVersion 1.23.0 -Force -Scope AllUsers; Import-Module PSScriptAnalyzer; $files = $env:PS_SCRIPTS -split "\s+" | Where-Object { $_ }; if (-not $files) { Write-Host "no PowerShell scripts are tracked"; exit 0 }; $found = @(); foreach ($f in $files) { $found += Invoke-ScriptAnalyzer -Path $f -Settings ./PSScriptAnalyzerSettings.psd1 }; Write-Host "Checked $($files.Count) file(s)"; if ($found) { $found | Format-Table RuleName,Severity,ScriptName,Line,Message -AutoSize | Out-String -Width 200 | Write-Host; exit 1 }; Write-Host "no findings"' ``` +The two container lines that take a file list differ from the workflow in **form** and not in what they check, and both differences exist because this runbook runs on a developer's machine where the workflow runs on `ubuntu-latest`. The shell list is collected with a `while read` loop rather than the workflow's `mapfile`, since `mapfile` arrives in bash 4 and macOS ships 3.2, and it stays an array so a path carrying whitespace is still passed as one argument. The PowerShell list splits on whitespace rather than on a newline, since a shell joins `git ls-files` output with newlines and PowerShell joins it with spaces, and splitting on the newline alone hands the analyzer one argument holding every path, which it reports as one file it cannot find and a clean run over nothing. + Two gaps in that list are CI's rather than this runbook's, reproduced here so a local run matches CI rather than quietly exceeding it. The `jq` glob covers `repo-config/*.json` and does not reach `repo-config/operational/develop.json`, so a malformed operational payload passes. The second is that `sentence-split` is implemented and tested but named by no invocation, so nothing runs it. Run the `editorconfig-checker` line before pushing a new file, and before pushing an existing file that a script rewrote rather than an editor. This repository defaults to CRLF and most tooling writes LF, so a new file fails that check on its first CI run rather than locally. A scripted rewrite is the same hazard on a file that was already correct, since reading and rewriting a whole file in text mode converts every line ending in it, which no prose or Markdown gate reports. diff --git a/PSScriptAnalyzerSettings.psd1 b/PSScriptAnalyzerSettings.psd1 new file mode 100644 index 00000000..61acfa36 --- /dev/null +++ b/PSScriptAnalyzerSettings.psd1 @@ -0,0 +1,13 @@ +@{ + # Every default rule runs, and two are excluded with their reasons, matching how the shell gate takes shellcheck's defaults and disables a finding inline where the finding is wrong for the program being quoted. + ExcludeRules = @( + # The scripts under host-setup/windows write their report to the console as their whole purpose, mirroring the printf calls in their Linux siblings. + # Write-Output is not merely a different spelling here: these functions return exit codes through the pipeline, so report text on the same stream would arrive at the caller as a return value. + 'PSAvoidUsingWriteHost' + + # Every script here already carries a preview and a consent step, in the shape its Linux sibling uses: -DryRun prints what would run, and a confirm prompt asks before the host changes. + # Supporting ShouldProcess would add -WhatIf and -Confirm beside them, so a reader would face two spellings of preview and two of consent, one of them undocumented. + # A function is named for what it does instead, and the rule is declined here rather than worked around by renaming setters into something they are not. + 'PSUseShouldProcessForStateChangingFunctions' + ) +} diff --git a/TODO.md b/TODO.md index 5914e8cc..71be78e7 100644 --- a/TODO.md +++ b/TODO.md @@ -401,6 +401,33 @@ One pull request writing down the agent-to-agent messaging this fleet has now us - **Settled** - A peer's finding is checked rather than adopted. Two of those four did not reproduce at the hub, the `AGENTS.md` anchor rewrite and the settings-diff exposure, and one did and shipped as #653. So the write-up states verification as a step rather than as a courtesy. - **Settled** - The boundary that matters most is not politeness but permission. A peer cannot widen what the asking session may do, so work blocked in one session goes back to the maintainer rather than sideways to another agent. +### What Building the Windows Host Tooling Surfaced + +Three findings raised while writing [`host-setup/windows/`][host-setup-windows], each about the Linux side or the fleet rather than about the new scripts, and none blocking them. + +**State** `ready` for the first two, `decision` for the third. **Touches** [`docs/host-setup.md`][host-setup-doc], and the three scripts under `host-setup/linux/`. **Cost** one hub edit each, and no re-vendor, since nothing under `host-setup/` is carried. + +- **Record why the host tooling carries no linter category, or decide that it should.** No installer on either platform manages `markdownlint`, `cspell`, `actionlint`, `editorconfig-checker`, `shellcheck`, `PSScriptAnalyzer` or `ruff`, and nothing states that as a decision, so the absence is correct and reachable only by inference. + - **Blocked by** - Nothing. + - **Issue** - [#671][issue-671]. + - **Checked** - `main` at `1d5b076` on 2026-08-11, where `readonly TOOLS=(git gh jq git-restore-mtime node python uv dotnet)` names no linter and no comment says why. + - **Settled** - The reasoning holds and is worth writing down once rather than per platform: each linter runs as a pinned image or through `uvx`, so the image tag fixes the version and a local run matches CI, and installing native copies would put a second unpinned version on the host and break exactly that. + - **Open** - Whether it belongs in [`docs/host-setup.md`][host-setup-doc] as a fleet fact, which is what covers Linux by the same sentence, or stays per platform where only the Windows README states it today. + +- **Report the `gh` git protocol in `setup-github.sh`, as its Windows peer does.** A host can pass every check the fleet runs while `gh` is configured for https, and a checkout made through `gh` then authenticates by token where every other checkout on that host authenticates by key. + - **Blocked by** - Nothing. + - **Issue** - [#672][issue-672]. + - **Checked** - Measured on the maintainer's Windows host on 2026-08-11, where `gh auth status` reports `Git operations protocol: https` against `gh` 2.97.0, an SSH key that signs and verifies, and `scripts/host_gate.py` exiting 0 over all seven declared tools. + - **Settled** - Reported rather than written, since rewriting a working authentication configuration is the operator's call, and `setup-github.sh` touches `gh` nowhere today. + - **Open** - Whether `--configure` should set it, which is the only part where the two platforms could still diverge. + +- **Align the Linux scripts onto "name one action" instead of "the last one given wins".** Overwriting `MODE` in the arg loop discards an intent silently, and it discards it in the dangerous direction: `--report --install` drops the safe action and keeps the one that changes the host. + - **Blocked by** - Nothing. + - **Issue** - [#673][issue-673]. + - **Checked** - `main` at `1d5b076` on 2026-08-11, where all three scripts document last-wins, no documented example passes two actions, `bootstrap.sh` passes exactly one per `run_tool` call, and no test asserts the behavior. + - **Settled** - The Windows tooling already refuses this way. That began as a constraint, since a PowerShell `param()` block records which switches were given and not their order, and the constraint produced the better behavior. + - **Open** - Nothing about the change itself, which is three `usage()` heredocs and three `parse_args()` bodies. The decision is only whether the fleet wants the stricter contract, and taking it deletes the differences-table row in [`host-setup/windows/README.md`][host-setup-windows] rather than leaving a permanent divergence. + ## Standalone Chores Small work with no research to preserve, selectable one bullet at a time. @@ -483,6 +510,7 @@ Regenerate [reports/divergences.md][divergences-report] before using it as the w - **Finish the host rollout and fill the tooling matrix, which are one visit each.** The rollout needs the matrix to be repeatable and the matrix is only worth filling if the rollout uses it. - **Hub state** - Done for the documentary half, verified `develop` at `1ed0cc8` on 2026-08-03. - **Outstanding** - Four machines, WSL2 Ubuntu, the MacBook Air and both ThinkPads, plus any headless or cron environment running with the token. macOS needs someone on that platform, the Proxmox question is whether that host also runs containers which decides whether Docker is required there, and the engine-inside-the-distro variant of the WSL2 Docker cell is unverified. + - **Detail** - The Windows half is a visit rather than a visit plus an unwritten script, since [`host-setup/windows/`][host-setup-windows] now carries the tooling and it was written and run on a Windows host. - **Issue** - [#365][issue-365] and [#483][issue-483]. - **Rides with** - Nothing on the hub, since the write-guard newline fix has landed on `develop` and a machine keeps running the old hook until the installer is re-run there. - **Detail** - A ticked row means the host-wide rules text and not the hook, since only running the installer deploys both layers, and the proxmox host proved that distinction by carrying the documentary half alone for eight days on the machine where the incident originated. @@ -529,6 +557,9 @@ Nothing is awaiting close today. [#578][issue-578] was the last entry here and c [issue-623]: https://github.com/ptr727/ProjectTemplate/issues/623 [issue-633]: https://github.com/ptr727/ProjectTemplate/issues/633 [issue-639]: https://github.com/ptr727/ProjectTemplate/issues/639 +[issue-671]: https://github.com/ptr727/ProjectTemplate/issues/671 +[issue-672]: https://github.com/ptr727/ProjectTemplate/issues/672 +[issue-673]: https://github.com/ptr727/ProjectTemplate/issues/673 @@ -553,6 +584,8 @@ Nothing is awaiting close today. [#578][issue-578] was the last entry here and c [fidelity-honesty]: ./spec/fidelity_honesty.py [files]: ./spec/files.json [governance]: ./GOVERNANCE.md +[host-setup-doc]: ./docs/host-setup.md +[host-setup-windows]: ./host-setup/windows/ [install-tools]: ./host-setup/linux/install-tools.sh [markdownlint]: ./.markdownlint-cli2.jsonc [matrix]: ./reports/conformance-matrix.md diff --git a/docs/host-setup.md b/docs/host-setup.md index 663c10b4..43c48954 100644 --- a/docs/host-setup.md +++ b/docs/host-setup.md @@ -8,7 +8,7 @@ Supported hosts: - **macOS** - both the devcontainer flow and the host-install flow. - **Windows** - the devcontainer flow requires **WSL2**, and native Windows (PowerShell + winget) is supported only for the host-install flow described in `README.md`. The bind-mounts in `.devcontainer/dotnet/devcontainer.json` and `.devcontainer/python/devcontainer.json` rely on POSIX paths and only work from Linux/macOS/WSL2. -> **Shell assumptions in this doc**: every command snippet below assumes a **POSIX shell** (bash/zsh) and POSIX path conventions (`~/.ssh/...`, `mkdir -p`, `$(...)` command substitution), with one exception. A block marked `powershell` is the **Windows-native** form of the step it sits in, meant to run in PowerShell rather than translated. On Windows, run the POSIX snippets from **WSL2** or **Git Bash**, since they will not work as-is in PowerShell or `cmd.exe`. The git config and `gh` commands are portable, and only the file and path manipulation differs by shell. +> **Shell assumptions in this doc**: every command snippet below assumes a **POSIX shell** (bash/zsh) and POSIX path conventions (`~/.ssh/...`, `mkdir -p`, `$(...)` command substitution), except where a block is marked `powershell`. Such a block is the **Windows-native** form of the step it sits in, meant to run in PowerShell rather than translated. On Windows, run the POSIX snippets from **WSL2** or **Git Bash**, since they will not work as-is in PowerShell or `cmd.exe`. The git config and `gh` commands are portable, and only the file and path manipulation differs by shell. ## What a Host Must Provide @@ -45,7 +45,7 @@ Neither `node` nor `dotnet` is in the table above, deliberately: they serve the **A host being stood up needs no Python.** The tooling under [`host-setup/`][host-setup-dir] is shell, deliberately, because requiring an interpreter to upgrade a package or install a tool would make the first step of standing a host up depend on the thing that step exists to provide. The Python floor above is a development requirement, meaning [`scripts/`][scripts-dir] and [`spec/`][spec-dir], and a host that only runs services never has to meet it. `bootstrap.sh` needs `curl` and `tar`, both of which a base install carries or can install without a network tool of its own. -**Standing a host up.** [`host-setup/`][host-setup-dir] carries the tooling that makes a host satisfy this contract, and its README is the usage. A host with nothing runs [`host-setup/bootstrap.sh`][bootstrap], which fetches this repository and runs that tooling from the fetched tree. It is not called by [`scripts/host_gate.py`][host-gate] and it does not call it: the gate measures a host against the floors above, and the tooling is a remedy a person chooses when the gate reports a gap. +**Standing a host up.** [`host-setup/`][host-setup-dir] carries the tooling that makes a host satisfy this contract, and its README is the usage. A host with nothing runs [`host-setup/bootstrap.sh`][bootstrap], which fetches this repository and runs that tooling from the fetched tree. A native Windows host runs the PowerShell peers in [`host-setup/windows/`][host-setup-windows] from a checkout instead, since no loader reaches those yet. It is not called by [`scripts/host_gate.py`][host-gate] and it does not call it: the gate measures a host against the floors above, and the tooling is a remedy a person chooses when the gate reports a gap. A repository that needs more than the fleet does adds its own `host-tools.json` at its root, which the gate layers over the hub's. It may add a tool nobody else uses, raise a floor, or turn an optional tool required. It may **not** lower a floor or turn a required tool optional, since those edits retire a fleet check from inside the repository it protects, and the gate reports a rejected relaxation rather than dropping it. @@ -228,7 +228,20 @@ If signing fails locally, the devcontainer will fail too, so fix here first. The gate replaced a line that ran `--version` on each tool and read only whether it answered. That form reported a host carrying the broken `gh` as fully set up, which is the failure it exists to stop. It exits non-zero on a missing required tool or one below its floor, prints the defect behind the floor rather than the number alone, and names where to install from. -**This block is POSIX, and on native Windows the interpreter line needs translating**, since `python3` is the one name a correctly set-up Windows host does not have. Read it as `py -3 scripts/host_gate.py` there, matching the contract table above, and run the rest from WSL2 or Git Bash per the shell note. Git Bash inherits the Windows `PATH`, so `python3` reaches the same Store alias stub it does in PowerShell and reports a working interpreter as missing. A PowerShell equivalent of this block is deliberately **not** given here, because it has not been run on a Windows host, and an unverified verification command is worse than none. [#483][issue-483] is where one belongs once someone has executed it. +**This block is POSIX, and on native Windows two lines need translating.** Run the POSIX form from WSL2 or Git Bash per the shell note, or use the PowerShell form below. Git Bash inherits the Windows `PATH`, so `python3` reaches the same Store alias stub it does in PowerShell and reports a working interpreter as missing. + +```powershell +py -3 scripts/host_gate.py # presence and version floors, from spec/host-tools.json +git config --global --list | Select-String "user\.|signing|gpg\." +ssh-add -L # should list your public key +git -c gpg.format=ssh commit -S --allow-empty -m "verify-signing" +git log --show-signature -1 +gh auth status +``` + +Verified on Windows 11 Pro 10.0.26200 with PowerShell 7.6.4, where `py -3 scripts/host_gate.py` exits 0 over seven declared tools. It was supplied under [#483][issue-483], which had deferred it until somebody had executed it on a Windows host. Only two lines differ from the POSIX block: the interpreter, and the filter, because `grep` has no Windows peer and `Select-String` is the one that ships. + +**`py -3` rather than `python`, and the reason is not only the Store stub.** Both names reach the same interpreter on a correctly set-up host, so the stub rules out `python3` and chooses nothing between the other two. What chooses is that an activated virtual environment puts its own interpreter first, so `python` resolves to that environment's. That is right for running project code and wrong here, because this gate measures **the host's** interpreter against a floor, and run as `python` from an activated environment it grades the environment instead. `py` is the launcher and reaches a registered system interpreter whatever is active. The prescription is therefore narrow: `py -3` for this gate, and `python` for everything else. **What the host can do once this passes**, which is the point of the contract above: @@ -256,6 +269,7 @@ A host that fails any row is not ready for the procedure that row names, and the [governance-git-and-commit-rules]: ../GOVERNANCE.md#git-and-commit-rules [host-gate]: ../scripts/host_gate.py [host-setup-dir]: ../host-setup/ +[host-setup-windows]: ../host-setup/windows/ [host-tools]: ../spec/host-tools.json [issue-483]: https://github.com/ptr727/ProjectTemplate/issues/483 [operations]: ../OPERATIONS.md diff --git a/host-setup/README.md b/host-setup/README.md index 82188fb1..d42937e4 100644 --- a/host-setup/README.md +++ b/host-setup/README.md @@ -6,6 +6,7 @@ What a machine needs before it can be worked in, and the tooling that puts it th - [`bootstrap.sh`][bootstrap] stands a 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. - [`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, and `setup-github.sh` configures the SSH key, git, and commit signing. +- [`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, and `setup-wsl.ps1` installs a WSL distribution and reports the Docker Desktop integration. - [`agent-safety/`][agent-safety] holds the write-safety guards, deployed per machine and per account. ## Standing a Host Up @@ -38,6 +39,18 @@ host-setup/linux/upgrade-host.sh --status host-setup/linux/setup-github.sh --status ``` +On native Windows, in PowerShell 7: + +```powershell +host-setup\windows\install-tools.ps1 # report +host-setup\windows\install-tools.ps1 -Install +host-setup\windows\upgrade-host.ps1 -Status +host-setup\windows\setup-github.ps1 -Status +host-setup\windows\setup-wsl.ps1 -Status +``` + +There is no `bootstrap.ps1`, so a Windows host obtains this repository first. The problem `bootstrap.sh` solves is a host with no git and no checkout, and the Windows form of that problem has no one-liner anybody here has run, so none is offered. + ## Which Revision a Run Used `bootstrap.sh` resolves the ref it was given to the commit it names, prints that commit, and downloads that exact revision. A run therefore says which revision of the tooling it used, and a second run of the same ref cannot silently be a different tree. Where the resolve fails, which an unauthenticated rate limit can cause, the run says it cannot attribute itself and continues, since the download itself is unaffected. @@ -46,11 +59,11 @@ host-setup/linux/setup-github.sh --status ## 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 lands at `windows/` rather than beside the Linux scripts, because a `winget` equivalent of `install-tools.sh` is a different program rather than a translation of one. +**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. **Nothing here needs Python, and `bootstrap.sh` needs only `curl`.** [`docs/host-setup.md`][host-setup] carries that as part of the contract, with the reasoning. It is why `bootstrap.sh` runs no 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/test_bootstrap.py`][test-bootstrap] asserting that every tool the spec requires is one this tooling can provide. -**The scripts under `linux/` 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. +**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. On the Windows side the fetchability argument is one no loader exercises yet, and the duplication is kept anyway so a loader added later inherits the property rather than having to introduce it. @@ -61,3 +74,4 @@ host-setup/linux/setup-github.sh --status [host-setup]: ../docs/host-setup.md [linux]: ./linux/ [test-bootstrap]: ../scripts/test_bootstrap.py +[windows]: ./windows/ diff --git a/host-setup/windows/README.md b/host-setup/windows/README.md new file mode 100644 index 00000000..5a33ae42 --- /dev/null +++ b/host-setup/windows/README.md @@ -0,0 +1,130 @@ +# Windows Host Setup + +The tooling that makes a native Windows host satisfy the contract in [`docs/host-setup.md`][host-setup], through `winget` and PowerShell 7. That document is the contract, meaning which tools a host must provide and why each floor exists. This directory is how a Windows host comes to satisfy it. + +## What Is Here + +- [`install-tools.ps1`][install-tools] installs and upgrades the host tools, and reports what each one is installed at, where it came from, and which scope it sits in. +- [`upgrade-host.ps1`][upgrade-host] upgrades the packages `winget` manages and updates the WSL platform. +- [`setup-github.ps1`][setup-github] configures the SSH key, git, and commit signing. +- [`setup-wsl.ps1`][setup-wsl] installs a WSL distribution and reports how Docker Desktop is integrated with the ones this host runs. + +Each runs on its own, and each takes `-Help`. + +```powershell +host-setup\windows\install-tools.ps1 # report +host-setup\windows\install-tools.ps1 -Install +host-setup\windows\upgrade-host.ps1 -Status +host-setup\windows\setup-github.ps1 -Status +host-setup\windows\setup-wsl.ps1 -Status +``` + +## Requirements + +**PowerShell 7 or later**, which is `pwsh` rather than the `powershell.exe` that ships with Windows. Each script refuses an older one and prints `winget install --id Microsoft.PowerShell --exact --source winget` as the remedy. `pwsh` is deliberately not a managed tool: a host that cannot run these scripts cannot be repaired by them. + +**winget**, which arrives with App Installer from the Microsoft Store. + +**Script execution.** A `git clone` carries no mark of the web, so these run under the default `RemoteSigned` policy. A browser-downloaded zip does carry one, and is blocked until `Unblock-File` clears the mark. The `.\` prefix is required when running a script from the current directory, exactly as it is for [`agent-safety/install.ps1`][agent-safety]. + +`pwsh -File .\install-tools.ps1` answers the `.\` rule and **not** the policy, which still applies to it: on a marked file under `RemoteSigned` it fails with a `SecurityError` naming the file as unsigned. The form that runs whatever the policy says is `pwsh -ExecutionPolicy Bypass -File .\install-tools.ps1`, which is what [`docs/host-setup.md`][host-setup] already gives for the write-safety installer. Prefer clearing the mark with `Unblock-File` over bypassing, since the bypass covers every script that run touches. + +## Why winget Is the Only Source + +Every tool the contract names has a winget package, so nothing here carries a fallback. That is the whole difference from the Linux script, which needs three kinds of source because the distribution's package trails upstream on `gh`, on `node` and on `uv`. Where `winget` tracks upstream, the machinery that exists to work around a stale feed has nothing to do. + +A tool that turns out to have no winget package is a finding to raise rather than a second source to add quietly, because the moment one tool comes from somewhere else this directory stops being one program and becomes two. + +## Elevation and Scope + +**Run these unelevated.** No `--scope` is passed unless `-Scope` names one, so `winget` acts on the copy it finds and an installer that needs administrator raises its own prompt. That is the path with the fewest failures, for three reasons that point the same way: forcing user scope installs a second copy beside a working machine wide one rather than upgrading it, some installers fail outright when launched from an already elevated process, and a user scope install made from an elevated process lands in the administrator's profile rather than the caller's. + +Nothing here elevates itself. A run that is already elevated says so and carries on, since that is a caution rather than a refusal. + +**Scope is measured, not assumed.** `winget list --scope user` and `--scope machine` answer separately, so the report names where each tool actually sits and catches the case worth catching, which is a tool installed in **both** scopes with one copy shadowing the other on `PATH`. + +**`-Reinstall` is the only action that removes anything**, and it always asks first. An `-Upgrade` whose `-Scope` disagrees with the installed copy refuses and names it, rather than upgrading in place or adding a second copy. + +**A state that could not be read is never reported as an absence.** Where `winget` does not answer what is installed, the tool reports `unreadable` rather than `missing`, and an install or upgrade skips it and collects it as a failure. Installing against a state nobody measured is how a second copy lands beside a first one that was there all along. + +**What provenance can and cannot be detected.** Scope is solid, and so is a tool that answers on `PATH` while `winget` knows no package for it, which reports as `unmanaged`. Whether a package was installed *by* winget is not solid and is not claimed: winget runs the vendor's own installer for an `exe` or an `msi`, so the resulting uninstall entry is identical whether winget invoked it or a person did. The one positive marker is the uninstall key winget writes for itself on a portable or archive package, which the report names where it is present and says nothing about where it is absent. + +## Self-Updating Packages + +Some applications update themselves and never rewrite the version recorded at install time. `winget` reports them as permanently behind, and its manifest marks them as requiring explicit targeting so an upgrade of everything leaves them alone. + +These are listed apart, left alone, and printed with **no command beside them**. Offering one invites a full reinstall over a working, already current copy in pursuit of a number that will not move. `MSYS2` is the worked example: it upgrades through `pacman` from inside the msys shell, and the version `winget` shows is the installer's rather than the one it runs. `install-tools.ps1` reports such a tool as `self-updating` rather than `outdated` for the same reason. + +## Why There Is No Linter Category + +Neither this tooling nor its Linux sibling installs `markdownlint`, `cspell`, `actionlint`, `editorconfig-checker`, `shellcheck`, `PSScriptAnalyzer` or `ruff`, and that is a decision rather than a gap. + +Each of those runs as a pinned container image or through `uvx`, which is what keeps a local run and CI the same check: the image tag fixes the version. Installing native copies through `winget` would put a second, unpinned version of each on the host, and a local run would then differ from CI, which is the exact property the pinned images exist to guarantee. The only host requirements any of it creates are `docker` and `uv`, and both are already in the registry. + +## Why There Is No bootstrap.ps1 + +[`bootstrap.sh`][bootstrap] exists to stand up a host that has no git and no checkout. The Windows form of that problem has no one-liner anybody here has run, and an unverified loader is worse than none, which is the same rule [`docs/host-setup.md`][host-setup] applies to its own verification block. A Windows host therefore obtains this repository first and runs these scripts from the checkout. + +## 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. + +`upgrade-host.ps1 -Wsl` **refuses while Docker Desktop is running**, because Docker holds the WSL service open and the update then fails part way rather than declining. Quit Docker from its tray icon first, since pausing it is not enough. The refusal fires under `-DryRun` too, so a dry run reports the truth rather than printing a command that would not have worked. + +Docker's own `docker-desktop` distribution is excluded from every distribution listing, since it is Docker's rather than one an operator installed. + +## Differences From the Linux Tooling + +| Linux | Windows | Why | +| --- | --- | --- | +| `upgrade-host.sh --release` moves to the next distribution release | no peer | Windows Update owns a feature update, and an action pretending to drive one is the one thing this must not carry | +| `install-tools.sh` carries four functions per tool | `install-tools.ps1` carries one registry record per tool | Every source is `winget`, so the per-tool variation those functions exist for does not arise | +| Actions, the last one given wins | Actions, name one | A `param()` block records which switches were given and not their order, and refusing beats silently discarding an intent | +| `git-restore-mtime` is managed | not managed | The spec declares it not applicable on Windows, since it serves a Linux deploy path | +| `docker` is not managed | `docker` is managed | `Docker.DockerDesktop` is one winget package, where the Linux answer differs by host role | +| `sudo` re-runs a command as root | nothing elevates | `winget` raises UAC per installer, which is the path with the fewest failures | +| `unmanaged` means the upstream repository is unconfigured | `unmanaged` means the tool is on `PATH` and winget knows no package for it | The same question, by a different mechanism | +| `credential.helper cache --timeout=3600` | `credential.helper manager`, and only where unset | Git Credential Manager ships with Git for Windows | +| `ssh-agent` is a socket, started per shell | `ssh-agent` is a Windows service, reported and not started | Starting it needs administrator, and nothing here elevates | +| no WSL script | `setup-wsl.ps1` | WSL is a Windows-side concern with no Linux-side peer | + +The scripts here share no file with each other, and the roughly thirty duplicated lines of logging, the dry-run wrapper and the confirmation prompt are identical rather than merely similar. That is the same rule the Linux scripts follow, for the same reason, and it is stated in [`host-setup/README.md`][host-setup-readme]. Do not factor them out. + +## Verification + +Read-only first, and nothing below changes the host. + +```powershell +pwsh -NoProfile -File host-setup\windows\install-tools.ps1 -Help +host-setup\windows\install-tools.ps1 -List +host-setup\windows\install-tools.ps1 -Report +host-setup\windows\upgrade-host.ps1 -Status +host-setup\windows\setup-github.ps1 -Status +host-setup\windows\setup-wsl.ps1 -Status +``` + +Then the dry runs, which print what each action would do: + +```powershell +host-setup\windows\install-tools.ps1 -Upgrade -DryRun +host-setup\windows\upgrade-host.ps1 -Packages -DryRun +host-setup\windows\setup-github.ps1 -Configure -DryRun +host-setup\windows\setup-wsl.ps1 -Install Debian -DryRun +``` + +Two of those are guards rather than previews, and each prints a refusal rather than a command: `upgrade-host.ps1 -Wsl -DryRun` on a host running Docker Desktop, and an `-Upgrade` whose `-Scope` disagrees with the installed copy. A `[dry run]` line from either means the guard sits in the wrong place. + +The scripts are checked by `PSScriptAnalyzer`, which runs in CI as the peer of the `shellcheck` step and locally through the invocation in [`GOVERNANCE.md`][governance]. [`scripts/test_bootstrap.py`][test-bootstrap] asserts that every tool the spec requires is one this registry carries, and that no script here opens with a shebang. + + + +[agent-safety]: ../agent-safety/install.ps1 +[bootstrap]: ../bootstrap.sh +[governance]: ../../GOVERNANCE.md +[host-setup]: ../../docs/host-setup.md +[host-setup-readme]: ../README.md +[install-tools]: ./install-tools.ps1 +[setup-github]: ./setup-github.ps1 +[setup-wsl]: ./setup-wsl.ps1 +[test-bootstrap]: ../../scripts/test_bootstrap.py +[upgrade-host]: ./upgrade-host.ps1 diff --git a/host-setup/windows/install-tools.ps1 b/host-setup/windows/install-tools.ps1 new file mode 100644 index 00000000..5965c193 --- /dev/null +++ b/host-setup/windows/install-tools.ps1 @@ -0,0 +1,634 @@ +# Installs and upgrades the host tools the fleet's repositories expect, on native Windows, through winget. +# Every tool in the contract has a winget package, so winget is the only source here, where the Linux script needs three because an apt feed trails upstream on half its tools. +# No version is written into this script, and winget is asked what each package carries now, so the script does not go stale between releases. +# +# Every step is idempotent. +# A package is installed only where winget reports none, and upgraded only where the installed version differs from what the source carries. +# Re-running repairs drift rather than assuming a clean host. +# +# Nothing here elevates, and no scope is passed unless the caller names one. +# An installer that needs administrator raises its own prompt, which is the path with the fewest failures: forcing user scope installs a second copy beside a machine wide one, and some installers fail outright when launched from an already elevated process. + +# CmdletBinding with an explicit position on the tool list is what keeps every other parameter named only. +# Without it a stray word binds to the first parameter that takes a value, and a mistyped tool name is reported against -Scope instead. +[CmdletBinding()] +param( + [Alias('r')][switch]$Report, + [Alias('i')][switch]$Install, + [Alias('u')][switch]$Upgrade, + [switch]$Reinstall, + [Alias('l')][switch]$List, + [Alias('n')][switch]$DryRun, + [Alias('y')][switch]$Yes, + [Alias('o')][switch]$Optional, + [ValidateSet('user', 'machine')][string]$Scope, + [Alias('h')][switch]$Help, + [Parameter(Position = 0, ValueFromRemainingArguments)][string[]]$Name +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +# A non-zero exit from winget is an answer here rather than a failure. +# Setting this keeps a profile that turned it on from turning every read into a terminating error. +$PSNativeCommandUseErrorActionPreference = $false + +# Returned by winget when nothing matches the query, which is an answer rather than a failure. +$NOT_FOUND = -1978335212 + +# Every parameter is read into a variable here rather than from inside a function. +# A script parameter reached only from a nested scope reads as declared and never used, which is a finding on each one and hides a parameter that genuinely is unused. +$ACTIONS = [ordered]@{ + report = [bool]$Report + install = [bool]$Install + upgrade = [bool]$Upgrade + reinstall = [bool]$Reinstall + list = [bool]$List +} +$WANT_HELP = [bool]$Help +# Filtered rather than wrapped, because wrapping an unset parameter yields a one element list holding nothing, which reads as one tool named the empty string. +$WANT_TOOLS = @($Name | Where-Object { $_ }) + +$MODE = 'report' +$DRY_RUN = [bool]$DryRun +$ASSUME_YES = [bool]$Yes +$WITH_OPTIONAL = [bool]$Optional +$WANT_SCOPE = $Scope +$ELEVATED = $false +$SELECTED = @() +$NOTES = @() +$FAILED = @() +$CHANGED = @() +$EXPLICIT = $null + +# --- 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 } + +function note { param([string]$Tool, [string]$Message) $script:NOTES += "${Tool}: $Message" } + +# A path with the home directory replaced by the variable that names it. +# A report is written to be pasted into an issue or a pull request, so a path it prints carries the account name into wherever it is pasted, and the comments here already avoid writing one for the same reason. +# The variable is what a reader expands themselves, so the path stays as actionable as it was. +function Hide-Home { + param([string]$Path) + if (-not $Path -or -not $HOME) { return $Path } + if ($Path.StartsWith($HOME, [StringComparison]::OrdinalIgnoreCase)) { + return '%USERPROFILE%' + $Path.Substring($HOME.Length) + } + return $Path +} + +function usage { + # The closing marker of a here-string has to sit at column 0, so this block is deliberately unindented. + Write-Host @' +Usage: install-tools.ps1 [options] [tool ...] + +Installs the host tools the fleet's repositories expect, from winget, which is the only source +every one of them has. With no tool named, every managed tool is selected. + +Actions, name one, default -Report: + -r, -Report Report installed and available versions, change nothing + -i, -Install Install what is missing, leave an installed tool at its version + -u, -Upgrade Install what is missing and upgrade what is behind + -Reinstall Remove the installed copy, then install it again + -l, -List List the managed tools and their winget package ids + -h, -Help Show this help + +Options: + -n, -DryRun Print the commands instead of running them + -y, -Yes Do not prompt before changing the host + -o, -Optional Include the optional package set, where a tool has one + -Scope Name a scope, either user or machine, for the copy to act on + +Run this without elevation. No scope is passed unless -Scope names one, so winget acts on the copy +it finds and an installer that needs administrator asks for it itself. Naming a scope that +disagrees with the installed copy would add a second copy beside the first rather than replacing +it, so an upgrade refuses that case and names -Reinstall, which removes the old copy first. + +Examples: + install-tools.ps1 Report on every tool + install-tools.ps1 -Install Install what is missing + install-tools.ps1 -Upgrade -Yes Bring every tool current, no prompt + install-tools.ps1 -Upgrade uv jq Bring two tools current + install-tools.ps1 -Install -Optional dotnet + install-tools.ps1 -Upgrade -DryRun Show what an upgrade would run + install-tools.ps1 -Reinstall jq -Scope machine +'@ +} + +# --- Host --- + +# Read the host identity, and refuse a host this script cannot install for. +# A host that cannot run this script cannot be repaired by it, so each refusal prints the one command that fixes it. +function Test-HostSupported { + if ($PSVersionTable.PSVersion.Major -lt 7) { + die "This script needs PowerShell 7 or later, and this is $($PSVersionTable.PSVersion). Install it with: winget install --id Microsoft.PowerShell --exact --source winget" + } + if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { + die 'winget not found, and this script installs winget packages. Install App Installer from the Microsoft Store, then run this again.' + } + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $script:ELEVATED = ([Security.Principal.WindowsPrincipal]$identity).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +# --- Execution --- + +# Run a command, or print it under -DryRun. +# A read used to decide what to do runs either way, and only a command that changes the host goes through here. +function run { + param([Parameter(Mandatory)][string]$Command, [Parameter(ValueFromRemainingArguments)][string[]]$Arguments) + if ($script:DRY_RUN) { + Write-Host " [dry run] $Command $($Arguments -join ' ')" + return 0 + } + # The command's own output goes to the console rather than down the pipeline. + # A native command writes to this function's output stream, so without this the caller receives every line the command printed with the exit code appended, and a check against 0 then compares against the first line of output. + & $Command @Arguments | Out-Host + return $LASTEXITCODE +} + +function confirm { + param([Parameter(Mandatory)][string]$Question) + if ($script:ASSUME_YES -or $script:DRY_RUN) { return $true } + # Both are checked because a scheduled task reports one and not the other, and either alone misses a case. + if (-not [Environment]::UserInteractive -or [Console]::IsInputRedirected) { + die 'Not a terminal and -Yes was not given, refusing to change the host unattended' + } + return ((Read-Host "$Question [y/N]") -match '^(y|yes)$') +} + +# --- winget --- + +# The version column of every row winget printed for one id. +# The offsets come from the header rather than from a whitespace split, because a package name carries spaces and splitting on them moves the version into the name. +# A row is kept only where the id column holds the id that was asked for, which drops the trailing count line without a list of strings to ignore. +# Every list returning function here returns with a leading comma, which is what stops PowerShell unrolling a one element array into the element. +# Without it a single installed version arrives as a string, and asking a string for its Count is an error under Set-StrictMode rather than the 1 the caller expects. +function Read-WingetTable { + param([string]$Text, [string]$Id) + $rows = @() + $lines = $Text -split "`r?`n" + $header = $lines | Where-Object { $_ -match '^Name\s+Id\s+Version' } | Select-Object -First 1 + if (-not $header) { return , $rows } + $idColumn = $header.IndexOf('Id') + $versionColumn = $header.IndexOf('Version') + foreach ($line in $lines) { + if ($line.Length -le $versionColumn) { continue } + if (-not $line.Substring($idColumn).StartsWith($Id)) { continue } + $rows += ($line.Substring($versionColumn) -split '\s+')[0] + } + return , $rows +} + +# The installed versions of one package id, or an empty list where none is installed. +# The exit code decides rather than the output text, since a missing id and an unreadable source both print prose and only the code tells them apart. +# A null answer means the question could not be answered, which is not the same as none installed. +function Get-WingetInstalled { + param([Parameter(Mandatory)][string]$Id, [string]$InScope) + $arguments = @('list', '--id', $Id, '--exact', '--source', 'winget', '--disable-interactivity') + if ($InScope) { $arguments += @('--scope', $InScope) } + $text = (& winget @arguments 2>&1 | Out-String -Width 500) + if ($LASTEXITCODE -eq $script:NOT_FOUND) { return , @() } + if ($LASTEXITCODE -ne 0) { return $null } + return , (Read-WingetTable -Text $text -Id $Id) +} + +# One version for a package winget lists more than once, or nothing where the rows do not describe one product. +# Rows sharing a major version are side by side builds of one product and the newest is the answer, which is what a dotnet SDK line looks like. +# Rows whose majors differ are two products sharing an id, which is what the legacy WSL installer looks like beside WSL itself, and there no single version compares. +function Resolve-InstalledVersion { + param([string[]]$Version) + if (-not $Version -or $Version.Count -eq 0) { return $null } + if ($Version.Count -eq 1) { return $Version[0] } + $majors = @($Version | ForEach-Object { ($_ -split '\.')[0] } | Sort-Object -Unique) + if ($majors.Count -ne 1) { return $null } + + # Compared component by component rather than sorted, because Sort-Object orders a version as text and 10.0.9 then outranks 10.0.10. + # Three side by side dotnet builds hid this, since 110, 204 and 302 are all three digits and sort the same either way. + $newest = $Version[0] + foreach ($candidate in $Version) { + if ((Compare-HostVersion $candidate $newest) -gt 0) { $newest = $candidate } + } + return $newest +} + +# What the source carries now, read without installing anything. +# The show command prints one Version line even for an id the list command answers with several rows, which is what makes it the reader for the target rather than a second list call. +function Get-WingetAvailable { + param([Parameter(Mandatory)][string]$Id) + $text = (& winget show --id $Id --exact --source winget --disable-interactivity 2>&1 | Out-String -Width 500) + if ($LASTEXITCODE -ne 0) { return $null } + if ($text -match '(?m)^Version:\s+(\S+)\s*$') { + if ($Matches[1] -eq 'Unknown') { return $null } + return $Matches[1] + } + return $null +} + +# Which scopes carry a copy, as a sorted list. +# Two probes rather than one reading, because winget reports no scope column and a package installed in both scopes is the case worth catching. +function Get-WingetScope { + param([Parameter(Mandatory)][string]$Id) + $found = @() + foreach ($candidate in 'user', 'machine') { + $rows = Get-WingetInstalled -Id $Id -InScope $candidate + if ($null -ne $rows -and $rows.Count -gt 0) { $found += $candidate } + } + return , $found +} + +# Whether winget wrote the uninstall entry for this package itself, which it does for a portable or an archive package and not for an exe or an msi. +# This is the only positive evidence of provenance available, and its absence proves nothing: winget runs the vendor's own installer for an exe or an msi, so that entry is identical whether winget invoked it or a person did. +# Reported where present and silent where not, rather than being turned into a claim it cannot support. +function Test-WingetOwnedEntry { + param([Parameter(Mandatory)][string]$Id) + $suffix = '_Microsoft.Winget.Source_8wekyb3d8bbwe' + $roots = @( + 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' + 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' + 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall' + ) + foreach ($root in $roots) { + if (Test-Path (Join-Path $root ($Id + $suffix))) { return $true } + } + return $false +} + +# The package ids winget refuses to move as part of an upgrade of everything. +# A package lands here because its manifest asks for it, which is the author saying the application updates itself, and the version winget reports is then the one the installer first wrote rather than the one the application runs. +# Read once and cached, since it costs a full upgrade query. +function Get-ExplicitUpgrade { + if ($null -ne $script:EXPLICIT) { return , $script:EXPLICIT } + $script:EXPLICIT = @() + $text = (& winget upgrade --include-unknown --disable-interactivity 2>&1 | Out-String -Width 500) + if ($LASTEXITCODE -ne 0) { return $script:EXPLICIT } + $marker = 'require explicit targeting for upgrade' + $index = $text.IndexOf($marker) + if ($index -lt 0) { return $script:EXPLICIT } + $tail = $text.Substring($index) + $lines = $tail -split "`r?`n" + $header = $lines | Where-Object { $_ -match '^Name\s+Id\s+Version' } | Select-Object -First 1 + if (-not $header) { return $script:EXPLICIT } + $idColumn = $header.IndexOf('Id') + $versionColumn = $header.IndexOf('Version') + foreach ($line in $lines) { + if ($line.Length -le $versionColumn) { continue } + if ($line -match '^Name\s+Id\s+Version') { continue } + $script:EXPLICIT += ($line.Substring($idColumn, $versionColumn - $idColumn)).Trim() + } + return , $script:EXPLICIT +} + +# Install, upgrade and remove, named here so the flags appear once in this file. +# No scope is passed unless the caller named one, because winget then acts on the copy it found and naming a different scope adds a copy rather than replacing one. +function Invoke-WingetInstall { + param([Parameter(Mandatory)][string]$Id) + $arguments = @('install', '--id', $Id, '--exact', '--source', 'winget', '--disable-interactivity', + '--accept-source-agreements', '--accept-package-agreements', '--silent') + if ($script:WANT_SCOPE) { $arguments += @('--scope', $script:WANT_SCOPE) } + return (run 'winget' @arguments) +} + +# An upgrade takes the named scope too, on the same rule as an install. +# Reaching here with a scope that disagrees with the installed copy is already refused, so the scope named here is one a copy sits in, and passing it is what says which copy to move where a tool is installed in both. +function Invoke-WingetUpgrade { + param([Parameter(Mandatory)][string]$Id) + $arguments = @('upgrade', '--id', $Id, '--exact', '--source', 'winget', '--disable-interactivity', + '--accept-source-agreements', '--accept-package-agreements', '--silent', '--include-unknown') + if ($script:WANT_SCOPE) { $arguments += @('--scope', $script:WANT_SCOPE) } + return (run 'winget' @arguments) +} + +# The scope to remove from is the one the copy was found in, which the caller passes, rather than the one the caller asked to end up in. +# Removing in the requested scope finds nothing where the copy sits in the other one, so the old copy survives and the install that follows adds a second beside it, which is the state -Reinstall exists to clear. +function Invoke-WingetRemove { + param([Parameter(Mandatory)][string]$Id, [string]$InScope) + $arguments = @('uninstall', '--id', $Id, '--exact', '--disable-interactivity', '--silent') + if ($InScope) { $arguments += @('--scope', $InScope) } + return (run -Command 'winget' -Arguments $arguments) +} + +# --- Tools --- + +# Managed tools, in the order a report lists them. +# Every one is a single winget package, which is what makes this a table where the Linux script needs four functions per tool. +# Probe names the executable that proves the tool is present when winget knows no package for it, and it is py for python because that is the name a correctly set up Windows host carries. +$TOOLS = @( + @{ Name = 'git'; Package = 'Git.Git'; Probe = 'git'; Optional = @() } + @{ Name = 'gh'; Package = 'GitHub.cli'; Probe = 'gh'; Optional = @() } + @{ Name = 'jq'; Package = 'jqlang.jq'; Probe = 'jq'; Optional = @() } + @{ Name = 'python'; Package = 'Python.Python.3.13'; Probe = 'py'; Optional = @() } + @{ Name = 'uv'; Package = 'astral-sh.uv'; Probe = 'uv'; Optional = @() } + @{ Name = 'docker'; Package = 'Docker.DockerDesktop'; Probe = 'docker'; Optional = @() } + @{ Name = 'node'; Package = 'OpenJS.NodeJS.LTS'; Probe = 'node'; Optional = @() } + @{ Name = 'dotnet'; Package = 'Microsoft.DotNet.SDK.10'; Probe = 'dotnet'; Optional = @('Microsoft.DotNet.SDK.9', 'Microsoft.DotNet.SDK.8') } +) + +function Get-Tool { + param([Parameter(Mandatory)][string]$ToolName) + return ($script:TOOLS | Where-Object { $_.Name -eq $ToolName } | Select-Object -First 1) +} + +# --- Status --- + +# A version as a comparable list of integers, with anything non numeric dropped. +# Mirrors the gate's own comparison so a host reads the same either side of it. +function Get-VersionKey { + param([string]$Version) + $parts = @() + foreach ($part in ($Version -split '[._-]')) { + if ($part -match '^\d+$') { $parts += [int]$part } else { break } + } + if ($parts.Count -eq 0) { return , @(0) } + return , $parts +} + +# Compare two versions, padding the shorter with zeros so more components alone does not read as newer. +function Compare-HostVersion { + param([string]$Left, [string]$Right) + $a = Get-VersionKey $Left + $b = Get-VersionKey $Right + for ($i = 0; $i -lt [Math]::Max($a.Count, $b.Count); $i++) { + $x = if ($i -lt $a.Count) { $a[$i] } else { 0 } + $y = if ($i -lt $b.Count) { $b[$i] } else { 0 } + if ($x -lt $y) { return -1 } + if ($x -gt $y) { return 1 } + } + return 0 +} + +# Everything a report and an apply both need about one tool, read once. +function Get-ToolState { + param([Parameter(Mandatory)][hashtable]$Tool) + $rows = Get-WingetInstalled -Id $Tool.Package + $state = @{ + Installed = $null + Rows = @() + # Whether the installed state was read at all, kept apart from what it said. + # Folding a failed read into an empty list would report a tool whose state is unknown as one that is not installed, and an install would then run against a host nobody measured. + Readable = ($null -ne $rows) + Available = (Get-WingetAvailable -Id $Tool.Package) + Scope = @() + Status = 'unknown' + } + if ($state.Readable) { + $state.Rows = $rows + $state.Installed = Resolve-InstalledVersion -Version $rows + if ($rows.Count -gt 0) { $state.Scope = Get-WingetScope -Id $Tool.Package } + } + $state.Status = Get-ToolStatus -Tool $Tool -State $state + return $state +} + +# One word for what a tool needs. +# The three Windows meanings sit beside the five the Linux peer carries, and each names a different reason a version comparison would mislead. +function Get-ToolStatus { + param([Parameter(Mandatory)][hashtable]$Tool, [Parameter(Mandatory)][hashtable]$State) + if (-not $State.Readable) { return 'unreadable' } + if ($State.Rows.Count -eq 0) { + # A tool answering on PATH that winget knows no package for is one winget cannot manage at all. + if (Get-Command $Tool.Probe -ErrorAction SilentlyContinue) { return 'unmanaged' } + if (-not $State.Available) { return 'unavailable' } + return 'missing' + } + if (-not $State.Installed) { return 'multiple' } + if (-not $State.Available) { return 'unknown' } + if ((Get-ExplicitUpgrade) -contains $Tool.Package) { return 'self-updating' } + if ((Compare-HostVersion $State.Installed $State.Available) -ge 0) { return 'current' } + return 'outdated' +} + +# Per tool detail worth a line under the report, rather than a column of its own. +function Add-ToolNote { + param([Parameter(Mandatory)][hashtable]$Tool, [Parameter(Mandatory)][hashtable]$State) + if ($Tool.Name -eq 'python') { + # Written unexpanded, because the expanded form names a real account and the prose gate rejects that. + note 'python' 'python3 resolves to the Microsoft Store alias stub under %LOCALAPPDATA%\Microsoft\WindowsApps, so py -3 is the name this contract uses here' + $resolved = Get-Command python -ErrorAction SilentlyContinue + if ($resolved -and $resolved.Source -notmatch 'Python\d') { + note 'python' "python resolves to $(Hide-Home $resolved.Source), which is not the interpreter winget installed" + } + } + if ($State.Scope.Count -gt 1) { + note $Tool.Name 'installed in both scopes, so one copy shadows the other on PATH, and -Reinstall removes one' + } + if ($State.Rows.Count -gt 0 -and (Test-WingetOwnedEntry -Id $Tool.Package)) { + note $Tool.Name 'winget wrote this uninstall entry, so winget installed it' + } + if ($State.Status -eq 'multiple') { + note $Tool.Name "winget lists $($State.Rows -join ', ') under one id, and their major versions differ, so no single installed version compares" + } + if ($State.Status -eq 'unmanaged') { + note $Tool.Name 'answers on PATH and winget knows no package for it, so winget cannot upgrade it and -Reinstall does not apply' + } + if ($State.Status -eq 'unreadable') { + note $Tool.Name 'winget did not answer what is installed, so this row reports nothing rather than reporting it as absent' + } + if ($State.Status -eq 'self-updating') { + note $Tool.Name 'updates itself, so the version winget reports is the one it was installed at rather than the one it runs' + } + if ($Tool.Name -eq 'dotnet' -and -not $script:WITH_OPTIONAL) { + note 'dotnet' "optional set not selected: $($Tool.Optional -join ', ')" + } +} + +# --- Actions --- + +function Show-List { + $format = '{0,-10} {1,-24} {2}' + log ($format -f 'TOOL', 'PACKAGE', 'OPTIONAL') + foreach ($tool in $script:SELECTED) { + $record = Get-Tool $tool + $optional = if ($record.Optional.Count -gt 0) { $record.Optional -join ', ' } else { '-' } + log ($format -f $record.Name, $record.Package, $optional) + } +} + +function Show-Report { + $format = '{0,-10} {1,-16} {2,-16} {3,-24} {4,-13} {5}' + log ($format -f 'TOOL', 'INSTALLED', 'AVAILABLE', 'SOURCE', 'SCOPE', 'STATUS') + + foreach ($tool in $script:SELECTED) { + $record = Get-Tool $tool + $state = Get-ToolState -Tool $record + # Every row is printed only where they did not resolve to one version, since a dotnet line carrying three side by side builds resolves cleanly and listing all three would overflow the column for nothing. + $installed = if ($state.Status -eq 'multiple') { $state.Rows -join ',' } elseif ($state.Installed) { $state.Installed } else { '-' } + $available = if ($state.Available) { $state.Available } else { '-' } + $scope = if ($state.Scope.Count -gt 0) { $state.Scope -join '+' } else { '-' } + log ($format -f $record.Name, $installed, $available, $record.Package, $scope, $state.Status) + Add-ToolNote -Tool $record -State $state + } + + if ($script:ELEVATED) { + note 'report' 'this pwsh is elevated, and some installers fail when launched from an elevated process, so an unelevated run is the one to prefer' + } + + if ($script:NOTES.Count -eq 0) { return } + log '' + log 'Notes:' + foreach ($entry in $script:NOTES) { info $entry } +} + +# Install, upgrade or reinstall one tool. +# A tool whose install returns non-zero is collected rather than fatal, so one failure does not strand the rest of the run. +# A refusal is not a failure and does end the run: a declined prompt, or a scope that disagrees with the installed copy, stops everything rather than being collected, because continuing past either would install a copy nobody asked for. +function Invoke-ToolApply { + param([Parameter(Mandatory)][string]$ToolName) + $record = Get-Tool $ToolName + $state = Get-ToolState -Tool $record + + if ($state.Status -eq 'unmanaged') { + log "${ToolName}: answers on PATH and winget knows no package for it, leaving it alone" + return + } + + # Collected rather than fatal, on the same rule as a failed install, and never installed past. + # Installing against a state nobody could read is how a second copy lands beside a first one that was there all along. + if ($state.Status -eq 'unreadable') { + warn "$ToolName skipped, winget did not answer what is installed and this will not install against an unknown state" + $script:FAILED += $ToolName + return + } + + # Naming a scope the installed copy does not sit in would add a second copy beside it, so the removal is asked for rather than done on the way past. + if ($script:WANT_SCOPE -and $state.Rows.Count -gt 0 -and $state.Scope.Count -gt 0 -and + $state.Scope -notcontains $script:WANT_SCOPE -and $script:MODE -ne 'reinstall') { + die "${ToolName}: installed $($state.Scope -join ' and ') wide at $($state.Installed), and -Scope $($script:WANT_SCOPE) was given. Installing would add a second copy beside it. Remove the existing copy first with: install-tools.ps1 -Reinstall $ToolName -Scope $($script:WANT_SCOPE)" + } + + if ($script:MODE -eq 'reinstall') { + if ($state.Rows.Count -eq 0) { + log "${ToolName}: not installed, so there is nothing to remove" + } else { + $where = if ($state.Scope.Count -gt 0) { " installed $($state.Scope -join ' and ') wide" } else { '' } + if (-not (confirm "Remove $($record.Package) at $($state.Rows -join ', ')$where and install it again?")) { + die 'Declined' + } + # Every copy is removed, each in the scope it was found in, since a tool present in both scopes is exactly the shadowing this action exists to clear. + # An empty scope means winget reported none, and there the removal names none either and lets winget act on what it finds. + $found = if ($state.Scope.Count -gt 0) { $state.Scope } else { @('') } + foreach ($scope in $found) { + if ((Invoke-WingetRemove -Id $record.Package -InScope $scope) -ne 0) { + warn "$ToolName failed to uninstall$(if ($scope) { " the $scope wide copy" })" + $script:FAILED += $ToolName + return + } + } + } + } elseif ($state.Status -eq 'current') { + log "${ToolName}: current at $($state.Installed), leaving it alone" + return + } elseif ($state.Status -eq 'self-updating') { + log "${ToolName}: updates itself, and winget does not move it" + return + } elseif ($state.Status -eq 'multiple') { + log "${ToolName}: winget lists $($state.Rows -join ', ') under one id, so -Reinstall is the action that resolves it" + return + } elseif ($script:MODE -eq 'install' -and $state.Status -eq 'outdated') { + log "${ToolName}: at $($state.Installed), the source carries $($state.Available), -Upgrade moves it" + return + } + + if ($script:MODE -ne 'reinstall') { + log "${ToolName}: $($state.Status)$(if ($state.Available) { ", the source carries $($state.Available)" })" + } + + $packages = @($record.Package) + if ($script:WITH_OPTIONAL) { $packages += $record.Optional } + + foreach ($package in $packages) { + $code = if ($state.Rows.Count -gt 0 -and $script:MODE -eq 'upgrade' -and $package -eq $record.Package) { + Invoke-WingetUpgrade -Id $package + } else { + Invoke-WingetInstall -Id $package + } + if ($code -ne 0) { + warn "$ToolName failed on $package, winget exited $code" + # Windows will not replace a file that is open, and winget reports that as an access denial naming the file rather than whatever holds it. + # The holder is usually the tool itself, left running by an editor or a language server, so the process is named here and the reader is spared guessing at a permission problem that is not one. + $running = @(Get-Process -Name $record.Probe -ErrorAction SilentlyContinue) + if ($running.Count -gt 0) { + info "$($record.Probe) is running as process $($running.Id -join ', '), and Windows cannot replace a running executable" + info 'Close whatever is running it, then run this again' + } + $script:FAILED += $ToolName + return + } + } + + $now = Resolve-InstalledVersion -Version (Get-WingetInstalled -Id $record.Package) + if ($now -ne $state.Installed) { + $before = if ($state.Installed) { $state.Installed } else { '-' } + $after = if ($now) { $now } else { '-' } + $script:CHANGED += "$ToolName $before -> $after" + } +} + +function Invoke-Apply { + log "Selected: $($script:SELECTED -join ' ')" + if ($script:ELEVATED) { + warn 'This pwsh is elevated, and some installers fail when launched from an elevated process. An unelevated run lets each installer ask for administrator only where it needs it.' + } + if (-not (confirm "$($script:MODE) these tools?")) { die 'Declined' } + + foreach ($tool in $script:SELECTED) { + step $tool + Invoke-ToolApply -ToolName $tool + } + + log '' + if ($script:CHANGED.Count -gt 0) { + log 'Changed:' + foreach ($entry in $script:CHANGED) { info $entry } + } else { + log 'Nothing changed' + } + + if ($script:FAILED.Count -gt 0) { + warn "Failed: $($script:FAILED -join ' ')" + return 1 + } + return 0 +} + +# --- Entry --- + +# PowerShell records which switches were given and not the order they came in, so two actions is a refusal rather than the last one winning. +# Refusing is also the better answer: an action silently discarded is one the caller believes ran. +function Resolve-Mode { + $given = @($script:ACTIONS.Keys | Where-Object { $script:ACTIONS[$_] }) + if ($given.Count -gt 1) { die "More than one action given ($($given -join ', ')), name one" } + if ($given.Count -eq 0) { return 'report' } + return $given[0] +} + +function Resolve-Selection { + $names = @($script:TOOLS | ForEach-Object { $_.Name }) + if ($script:WANT_TOOLS.Count -eq 0) { return , $names } + foreach ($candidate in $script:WANT_TOOLS) { + if ($names -notcontains $candidate) { + die "Unknown tool `"$candidate`", -List names the managed tools" + } + } + # Sorted into registry order rather than the order they were typed, so a run reads the same however it was asked for. + return , @($names | Where-Object { $script:WANT_TOOLS -contains $_ }) +} + +function main { + if ($script:WANT_HELP) { usage; exit 0 } + $script:MODE = Resolve-Mode + Test-HostSupported + $script:SELECTED = Resolve-Selection + + switch ($script:MODE) { + 'list' { Show-List; exit 0 } + 'report' { Show-Report; exit 0 } + default { exit (Invoke-Apply) } + } +} + +main diff --git a/host-setup/windows/setup-github.ps1 b/host-setup/windows/setup-github.ps1 new file mode 100644 index 00000000..dde3b53d --- /dev/null +++ b/host-setup/windows/setup-github.ps1 @@ -0,0 +1,612 @@ +# Sets up git and GitHub on a Windows host: the SSH key, the git configuration, and commit signing. +# Every step is idempotent, so a re-run repairs a half configured host rather than duplicating what is already there. +# +# Two steps cannot be automated, because they happen in a browser: registering the public key as an authentication key, and registering the same key as a signing key. +# Both are gates rather than suggestions. +# The script stops at each, prints the key to paste and where to paste it, and checks afterwards that the registration took, by reading the keys GitHub publishes for the account. +# +# The path settings are written in the tilde form the Linux peer writes, because git expands it on Windows too, and a home shared with a WSL distribution then carries one value rather than two that disagree. + +[CmdletBinding()] +param( + [Alias('s')][switch]$Status, + [Alias('c')][switch]$Configure, + [Alias('n')][switch]$DryRun, + [Alias('y')][switch]$Yes, + [string]$Name, + [string]$Email, + [string]$SharedCheckout, + [Alias('h')][switch]$Help +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +# A non-zero exit from git or ssh is an answer here rather than a failure. +# Setting this keeps a profile that turned it on from turning every read into a terminating error. +$PSNativeCommandUseErrorActionPreference = $false + +$KEY = Join-Path $HOME '.ssh\id_ed25519' +$ALLOWED_SIGNERS = Join-Path $HOME '.config\git\allowed_signers' + +# Git expands a leading tilde in a path setting on Windows as well, and the hosts already configured by hand hold the tilde form, so writing that form leaves an already configured host untouched. +# File operations use the expanded paths above, since only git expands the tilde. +$KEY_SETTING = '~/.ssh/id_ed25519.pub' +$ALLOWED_SIGNERS_SETTING = '~/.config/git/allowed_signers' +$KEY_SETTINGS_URL = 'https://github.com/settings/ssh/new' + +# The identity the maintainer's commits carry, used only where the host names none of its own. +$DEFAULT_NAME = 'Pieter Viljoen' +$DEFAULT_EMAIL = 'ptr727@users.noreply.github.com' + +# Every parameter is read into a variable here rather than from inside a function. +# A script parameter reached only from a nested scope reads as declared and never used, which is a finding on each one and hides a parameter that genuinely is unused. +$ACTIONS = [ordered]@{ + status = [bool]$Status + configure = [bool]$Configure +} +$WANT_HELP = [bool]$Help + +$MODE = 'status' +$DRY_RUN = [bool]$DryRun +$ASSUME_YES = [bool]$Yes +$WANT_NAME = $Name +$WANT_EMAIL = $Email +$SHARED = $SharedCheckout +$GITHUB_USER = '' +$MANAGED_KEY_AUTHENTICATES = $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 } + +function ok { param([string]$Message) Write-Host " [ ok ] $Message" } +function missing { param([string]$Message) Write-Host " [ ] $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: setup-github.ps1 [options] + +Sets up git and GitHub on this host: the SSH key, the git configuration, and commit signing. + +Actions, name one, default -Status: + -s, -Status Report what is set up and what is not, change nothing + -c, -Configure Create the key, apply the configuration, and check both registrations + -h, -Help Show this help + +Options: + -n, -DryRun Print the commands instead of running them + -y, -Yes Do not prompt before changing the host + -Name Name for git commits + -Email Email for git commits + -SharedCheckout PATH + Configure a checkout that several accounts share, at PATH. This turns off git's + ownership check for that path, so it is named rather than assumed: a host one + account uses needs it for nothing. "*" applies it to every path on the host, + which is the broadest form and is reported as such. + +The identity comes from -Name and -Email, or from what this host already carries, or from the +default the maintainer commits under, in that order. A host configured for somebody else keeps its +own identity rather than being quietly rewritten. + +Registering the key is done in a browser and cannot be automated, so -Configure stops at each +registration and prints what to paste. The same key is registered twice, once as an authentication +key and once as a signing key: authentication reaches private repositories, signing is what marks a +commit verified. Each is checked against the keys GitHub publishes for the account. + +Two host settings are reported and never written, because both need administrator and rewriting a +working host is worse than naming what to change: the ssh-agent service, and the key file's ACL. + +Examples: + setup-github.ps1 Report, change nothing + setup-github.ps1 -Configure Set the host up + setup-github.ps1 -Configure -DryRun Show what it would do + setup-github.ps1 -Configure -Email you@users.noreply.github.com +'@ +} + +# --- Execution --- + +# Run a command, or print it under -DryRun. +# A read used to decide what to do runs either way, and only a command that changes the host goes through here. +function run { + param([Parameter(Mandatory)][string]$Command, [Parameter(ValueFromRemainingArguments)][string[]]$Arguments) + if ($script:DRY_RUN) { + Write-Host " [dry run] $Command $($Arguments -join ' ')" + return 0 + } + # The command's own output goes to the console rather than down the pipeline. + # A native command writes to this function's output stream, so without this the caller receives every line the command printed with the exit code appended, and a check against 0 then compares against the first line of output. + & $Command @Arguments | Out-Host + return $LASTEXITCODE +} + +function confirm { + param([Parameter(Mandatory)][string]$Question) + if ($script:ASSUME_YES -or $script:DRY_RUN) { return $true } + # Both are checked because a scheduled task reports one and not the other, and either alone misses a case. + if (-not [Environment]::UserInteractive -or [Console]::IsInputRedirected) { + die 'Not a terminal and -Yes was not given, refusing to change the host unattended' + } + return ((Read-Host "$Question [y/N]") -match '^(y|yes)$') +} + +# --- Prerequisites --- + +# Both git and ssh ship with Git for Windows, and neither is installed from here. +# Installing them belongs to install-tools.ps1, so a gap is named with the command that closes it rather than closed twice in two scripts. +# Every executable this script calls is named, rather than the three it is most obviously about. +# One left out fails partway through with whatever that command says when it is missing, in place of the one message here that names the remedy. +function Test-Prerequisite { + $absent = @() + foreach ($tool in 'git', 'ssh', 'ssh-keygen', 'ssh-keyscan', 'ssh-add') { + if (-not (Get-Command $tool -ErrorAction SilentlyContinue)) { $absent += $tool } + } + if ($absent.Count -eq 0) { return } + die "Not found on this host: $($absent -join ', '). All of these ship with Git for Windows, which install-tools.ps1 -Install git installs." +} + +# --- Key --- + +function Get-KeyBody { + if (-not (Test-Path "$script:KEY.pub")) { return $null } + $text = (Get-Content "$script:KEY.pub" -Raw).Trim() + if (-not $text) { return $null } + return $text +} + +function New-KeyIfAbsent { + step 'Checking the SSH key' + if (Test-Path $script:KEY) { + info "Already at $($script:KEY)" + return + } + $directory = Split-Path -Parent $script:KEY + if (-not (Test-Path $directory)) { + if ($script:DRY_RUN) { info "[dry run] create $directory" } else { New-Item -ItemType Directory -Path $directory -Force | Out-Null } + } + info "Creating $($script:KEY)" + # An empty passphrase is not chosen here, so ssh-keygen asks, which is the one question only the operator can answer. + $code = run -Command 'ssh-keygen' -Arguments '-t', 'ed25519', '-C', $script:WANT_EMAIL, '-f', $script:KEY + if ($code -ne 0) { die "ssh-keygen exited $code" } +} + +# The ACL is reported and never rewritten. +# Creating the key with ssh-keygen already sets it correctly on Windows, and rewriting an ACL is hard to undo and hard to preview under -DryRun, so a wrong one is named with the command that repairs it. +function Test-KeyAcl { + if (-not (Test-Path $script:KEY)) { return $null } + $acl = Get-Acl $script:KEY + $others = @($acl.Access | Where-Object { $_.IdentityReference.Value -ne $acl.Owner -and $_.IdentityReference.Value -notmatch 'NT AUTHORITY\\SYSTEM|BUILTIN\\Administrators' }) + return ($others.Count -eq 0) +} + +function Add-KnownHost { + step 'Checking that github.com is a known host' + $known = Join-Path $HOME '.ssh\known_hosts' + if ((& ssh-keygen -F github.com 2>&1 | Out-String) -match 'found') { + info 'Already known' + return + } + info 'Adding the github.com host keys' + if ($script:DRY_RUN) { + info "[dry run] check the github.com host key against the fingerprints GitHub publishes, then record it in $known" + return + } + + # Comment lines carry the banner rather than a key, and ssh-keygen refuses a file holding one. + $scanned = @(& ssh-keyscan github.com 2> $null | Where-Object { $_ -and $_ -notmatch '^\s*#' }) + if ($scanned.Count -eq 0) { + die 'ssh-keyscan returned no host key for github.com. The OpenSSH under %SystemRoot%\System32 is older than the key exchange github.com offers and fails with "unsupported KEX method", where the copy shipped with Git for Windows under %ProgramFiles%\Git\usr\bin succeeds. Put that one first on PATH and run this again.' + } + + # What was offered is checked against what GitHub publishes before it is recorded, matching the Linux peer. + # Recording whatever answered would persist a substituted key on the one run where nothing yet pins the real one, and every later connection would then verify against it. + $temp = Join-Path ([IO.Path]::GetTempPath()) ([Guid]::NewGuid().ToString() + '.pub') + try { + [IO.File]::WriteAllText($temp, ($scanned -join "`n") + "`n") + $offered = @(& ssh-keygen -lf $temp 2> $null | + ForEach-Object { ($_ -split '\s+')[1] -replace '^SHA256:', '' } | + Where-Object { $_ } | Sort-Object -Unique) + } finally { + Remove-Item $temp -Force -ErrorAction SilentlyContinue + } + if ($offered.Count -eq 0) { die 'The host key github.com offered could not be fingerprinted, so it is not being recorded' } + + $published = @() + try { + $meta = Invoke-RestMethod -Uri 'https://api.github.com/meta' -TimeoutSec 10 + $published = @($meta.ssh_key_fingerprints.PSObject.Properties | + Where-Object { $_.Name -like 'SHA256*' } | ForEach-Object { $_.Value }) + } catch { + $published = @() + } + if ($published.Count -eq 0) { + die 'Cannot read the host key fingerprints GitHub publishes, so the key it offered cannot be checked. Compare it by hand against https://docs.github.com/authentication/keeping-your-account-secure/githubs-ssh-key-fingerprints and record it with ssh-keyscan.' + } + + foreach ($fingerprint in $offered) { + if ($published -notcontains $fingerprint) { + die "github.com offered a host key GitHub does not publish (SHA256:$fingerprint), so it is not being recorded" + } + } + info 'The offered host key matches what GitHub publishes' + + $directory = Split-Path -Parent $known + if (-not (Test-Path $directory)) { New-Item -ItemType Directory -Path $directory -Force | Out-Null } + Add-Content -Path $known -Value $scanned +} + +# --- GitHub, read only --- + +# Every probe refuses to write a host key, so asking whether authentication works cannot enroll github.com behind the reader's back and a status run stays a read-only action. +# A status run also refuses to prompt, since it may be run unattended and a passphrase prompt would hang it. +function Get-SshGreeting { + param([switch]$ManagedKeyOnly) + $options = @('-o', 'StrictHostKeyChecking=yes', '-o', 'ConnectTimeout=10') + if ($script:MODE -eq 'status') { $options += @('-o', 'BatchMode=yes') } + if ($ManagedKeyOnly) { + if (-not (Test-Path $script:KEY)) { return '' } + # The ssh client offers the default identity files and an agent's keys besides whatever is named, so a host carrying another account's key authenticates as that account. + # An empty config file is what isolates the managed key, since the default identity files count as configured identities on their own. + $options += @('-F', 'NUL', '-o', 'IdentitiesOnly=yes', '-i', $script:KEY) + } + return (& ssh @options -T 'git@github.com' 2>&1 | Out-String) +} + +function Get-GreetingUser { + param([string]$Greeting) + if ($Greeting -match '(?m)^Hi ([^!]+)!') { return $Matches[1] } + return '' +} + +# Prefer the account the managed key belongs to, since that is the one the registration checks are about, and say so when the host answers as somebody else. +function Resolve-GitHubUser { + $general = Get-GreetingUser (Get-SshGreeting) + $managed = Get-GreetingUser (Get-SshGreeting -ManagedKeyOnly) + + $script:MANAGED_KEY_AUTHENTICATES = [bool]$managed + if ($managed) { + $script:GITHUB_USER = $managed + if ($general -and $general -ne $managed) { + warn "This host authenticates as $general with another key, while the managed key belongs to $managed" + } + return + } + $script:GITHUB_USER = $general +} + +# GitHub publishes both key lists for an account, so a registration can be checked from the host without a token and without the browser that made it. +# Each returns registered, missing, or unknown, and the third is not the second: a momentary failure to reach GitHub reported as "not registered" sends the reader to register a key that is already there. +# PowerShell separates those two by itself, since an account with no signing key returns an empty list where an unreachable GitHub throws. +function Test-KeyRegistered { + param([ValidateSet('auth', 'signing')][string]$Kind) + $body = Get-KeyBody + if (-not $body -or -not $script:GITHUB_USER) { return 'unknown' } + # The comment field is not part of what GitHub publishes, so only the type and the key itself are compared. + $wanted = (($body -split '\s+') | Select-Object -First 2) -join ' ' + try { + if ($Kind -eq 'auth') { + $keys = (Invoke-RestMethod -Uri "https://github.com/$($script:GITHUB_USER).keys" -TimeoutSec 10) -split "`n" + } else { + $keys = @(Invoke-RestMethod -Uri "https://api.github.com/users/$($script:GITHUB_USER)/ssh_signing_keys" -TimeoutSec 10 | ForEach-Object { $_.key }) + } + } catch { + return 'unknown' + } + foreach ($key in $keys) { + if ($key.Trim() -eq $wanted) { return 'registered' } + } + return 'missing' +} + +# A registration is a browser step, so this prints what to paste and where, then stops. +function Show-RegistrationNeeded { + param([string]$Kind, [string]$Type) + log '' + log "The key is not registered for $Kind. This step happens in a browser:" + info "1. Open $($script:KEY_SETTINGS_URL)" + info "2. Set the key type to `"$Type key`"" + info "3. Paste the key below, and give it this host's name" + log '' + # A dry run reports what it would create rather than creating it, so the key this block exists to print may not be there. + $body = Get-KeyBody + if ($body) { log $body } else { info "No key at $($script:KEY).pub yet, so there is nothing to paste. A run that is not a dry run creates it." } + log '' +} + +# --- git configuration --- + +function Get-GitConfig { + param([string]$Key) + $value = (& git config --global --get $Key 2>$null | Out-String).Trim() + return $value +} + +# Set a value only where it differs, so a re-run is silent rather than rewriting the same file. +function Set-GitConfig { + param([string]$Key, [string]$Value) + if ((Get-GitConfig $Key) -eq $Value) { return $false } + run -Command 'git' -Arguments 'config', '--global', $Key, $Value | Out-Null + return $true +} + +# The safe.directory setting is multi valued, so setting it again appends a duplicate rather than replacing it. +function Add-GitConfigOnce { + param([string]$Key, [string]$Value) + $current = @(& git config --global --get-all $Key 2>$null) + if ($current -contains $Value) { return $false } + run -Command 'git' -Arguments 'config', '--global', '--add', $Key, $Value | Out-Null + return $true +} + +# The flag wins, then whatever the host already carries, then the default. +# Reading the host first is what keeps a machine configured for somebody else from being rewritten by a run meant to be safe to repeat. +function Resolve-Identity { + if (-not $script:WANT_NAME) { + $existing = Get-GitConfig 'user.name' + $script:WANT_NAME = if ($existing) { $existing } else { $script:DEFAULT_NAME } + } + if (-not $script:WANT_EMAIL) { + $existing = Get-GitConfig 'user.email' + $script:WANT_EMAIL = if ($existing) { $existing } else { $script:DEFAULT_EMAIL } + } +} + +function Set-GitIdentity { + step 'Configuring git' + info "Identity: $($script:WANT_NAME) <$($script:WANT_EMAIL)>" + + $changed = 0 + if (Set-GitConfig 'user.name' $script:WANT_NAME) { $changed++ } + if (Set-GitConfig 'user.email' $script:WANT_EMAIL) { $changed++ } + # Git Credential Manager ships with Git for Windows and is what gh expects there, where the Linux peer writes a cache helper because no manager exists. + # Written only where nothing is set, so a host already carrying a helper keeps it. + if (-not (Get-GitConfig 'credential.helper')) { + if (Set-GitConfig 'credential.helper' 'manager') { $changed++ } + } + + if ($changed -eq 0) { info 'Already configured' } else { info "$changed setting(s) written" } +} + +# A checkout several accounts share needs two settings that are relaxations rather than defaults, so they are applied only for a path the caller names. +function Set-SharedCheckout { + if (-not $script:SHARED) { return } + step "Configuring the shared checkout at $($script:SHARED)" + + $changed = 0 + if (Set-GitConfig 'core.sharedRepository' 'group') { $changed++ } + if (Add-GitConfigOnce 'safe.directory' $script:SHARED) { $changed++ } + + if ($script:SHARED -eq '*') { + warn 'safe.directory is set to every path on this host, which turns the ownership check off everywhere' + } elseif (-not (Test-Path $script:SHARED)) { + info "$($script:SHARED) does not exist yet, and the setting waits for it" + } + + if ($changed -eq 0) { info 'Already configured' } else { info "$changed setting(s) written" } +} + +function Set-Signing { + step 'Configuring commit signing' + + $changed = 0 + if (Set-GitConfig 'gpg.format' 'ssh') { $changed++ } + if (Set-GitConfig 'user.signingkey' $script:KEY_SETTING) { $changed++ } + if (Set-GitConfig 'commit.gpgsign' 'true') { $changed++ } + if (Set-GitConfig 'tag.gpgsign' 'true') { $changed++ } + if (Set-GitConfig 'gpg.ssh.allowedSignersFile' $script:ALLOWED_SIGNERS_SETTING) { $changed++ } + + # The allowed signers file is what verifies a signature locally, and it is appended to rather than rewritten, since it can carry other identities. + $body = Get-KeyBody + if ($body) { + $entry = "$($script:WANT_EMAIL) namespaces=`"git`" $body" + $existing = if (Test-Path $script:ALLOWED_SIGNERS) { @(Get-Content $script:ALLOWED_SIGNERS) } else { @() } + if ($existing -notcontains $entry) { + if ($script:DRY_RUN) { + info "[dry run] append this host's key to $($script:ALLOWED_SIGNERS)" + } else { + $directory = Split-Path -Parent $script:ALLOWED_SIGNERS + if (-not (Test-Path $directory)) { New-Item -ItemType Directory -Path $directory -Force | Out-Null } + Add-Content -Path $script:ALLOWED_SIGNERS -Value $entry + } + $changed++ + } + } + + if ($changed -eq 0) { info 'Already configured' } else { info "$changed setting(s) written" } +} + +# Sign a commit in a throwaway repository and verify it. +# This proves the configuration end to end, which reading the settings back cannot: a wrong allowed signers entry reads as correct and fails only when a signature is checked. +function Test-Signing { + $repository = Join-Path ([IO.Path]::GetTempPath()) ([Guid]::NewGuid().ToString()) + try { + New-Item -ItemType Directory -Path $repository -Force | Out-Null + & git init -q $repository 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { return $false } + & git -C $repository commit -q --allow-empty -m 'signing check' 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { return $false } + & git -C $repository verify-commit HEAD 2>&1 | Out-Null + return ($LASTEXITCODE -eq 0) + } catch { + return $false + } finally { + Remove-Item $repository -Recurse -Force -ErrorAction SilentlyContinue + } +} + +# --- Agent --- + +# On Windows the agent is a service rather than a socket, and both starting it and setting it to start automatically need administrator. +# Reported with the command that fixes it rather than run, on the same rule as the key ACL: this script does not elevate, and naming the change is better than half applying it. +function Show-AgentStatus { + log '' + log 'SSH agent' + $service = Get-Service ssh-agent -ErrorAction SilentlyContinue + if (-not $service) { + missing 'the ssh-agent service exists, and OpenSSH is installed as a Windows optional feature' + return + } + if ($service.Status -eq 'Running') { + ok 'the ssh-agent service is running' + } else { + missing "the ssh-agent service is running, it is $($service.Status), start it with: Set-Service ssh-agent -StartupType Automatic; Start-Service ssh-agent" + return + } + + $body = Get-KeyBody + $loaded = (& ssh-add -L 2>&1 | Out-String) + if ($body -and $loaded -match [regex]::Escape((($body -split '\s+') | Select-Object -First 2) -join ' ')) { + ok 'the managed key is loaded in the agent' + } else { + missing "the managed key is loaded in the agent, add it with: ssh-add $($script:KEY)" + } +} + +# --- Actions --- + +function Show-Status { + log 'Host identity' + foreach ($tool in 'git', 'ssh') { + if (Get-Command $tool -ErrorAction SilentlyContinue) { ok "$tool installed" } else { missing "$tool installed, which install-tools.ps1 -Install git provides" } + } + if (Test-Path $script:KEY) { ok "SSH key at $($script:KEY)" } else { missing "SSH key at $($script:KEY)" } + + $acl = Test-KeyAcl + if ($null -eq $acl) { missing 'the key file ACL, unknown until the key exists' } + elseif ($acl) { ok 'the key file is readable only by its owner' } + else { missing "the key file is readable only by its owner, repair it with: icacls `"$($script:KEY)`" /inheritance:r /grant:r `"`$env:USERNAME:F`"" } + + $known = (& ssh-keygen -F github.com 2>&1 | Out-String) -match 'found' + Resolve-GitHubUser + if ($script:GITHUB_USER) { ok "SSH authentication to GitHub, as $($script:GITHUB_USER)" } + elseif (-not $known) { missing 'SSH authentication to GitHub, unchecked because github.com is not in known_hosts, which -Configure adds' } + else { missing 'SSH authentication to GitHub' } + + if ($script:MANAGED_KEY_AUTHENTICATES) { ok 'The managed key is the one that authenticates' } + elseif ($script:GITHUB_USER) { missing 'The managed key authenticates, so the account above answered with another key or through this host ssh config' } + else { missing 'The managed key authenticates' } + + log '' + log 'Registration, as GitHub publishes it' + # Which account to ask about comes from authentication, so with none there is no question to ask, and that reads differently from having asked and failed. + if (-not $script:GITHUB_USER -or -not (Get-KeyBody)) { + missing 'Authentication key registered, unknown until authentication works' + missing 'Signing key registered, unknown until authentication works' + } else { + foreach ($pair in @(@('auth', 'Authentication'), @('signing', 'Signing'))) { + switch (Test-KeyRegistered -Kind $pair[0]) { + 'registered' { ok "$($pair[1]) key registered" } + 'unknown' { missing "$($pair[1]) key registered, GitHub could not be reached to check" } + default { missing "$($pair[1]) key registered" } + } + } + } + + log '' + log 'git configuration' + foreach ($key in 'user.name', 'user.email', 'credential.helper', 'gpg.format', + 'user.signingkey', 'commit.gpgsign', 'tag.gpgsign', 'gpg.ssh.allowedSignersFile') { + $value = Get-GitConfig $key + if ($value) { ok "$key = $value" } else { missing $key } + } + + # The shared checkout settings are reported apart, because absent is the right state for a host one account uses and listing them as missing would read as two gaps to close. + $shared = @(& git config --global --get-all safe.directory 2>$null) + if ($shared.Count -gt 0) { + $unique = @($shared | Sort-Object -Unique) + ok "safe.directory = $($unique -join ' ')" + if ($shared.Count -gt $unique.Count) { + info "$($shared.Count) entries for $($unique.Count) path(s), so this setting was added more than once" + } + } else { + info '[ ] safe.directory not set, which -SharedCheckout sets where a checkout is shared' + } + + Show-AgentStatus + + log '' + log 'GitHub CLI' + if (-not (Get-Command gh -ErrorAction SilentlyContinue)) { + missing 'gh installed, which install-tools.ps1 -Install gh provides' + } else { + $auth = (& gh auth status 2>&1 | Out-String) + if ($auth -match 'Logged in to \S+ account (\S+)') { ok "authenticated as $($Matches[1])" } else { missing 'authenticated, log in with: gh auth login --hostname github.com --git-protocol ssh' } + # Reported and never set, matching the Linux peer, which touches gh nowhere. + # An https protocol makes a checkout made through gh authenticate by token where every other checkout on the host authenticates by key. + if ($auth -match 'Git operations protocol: (\S+)') { + if ($Matches[1] -eq 'ssh') { ok 'git protocol is ssh' } + else { missing "git protocol is ssh, it is $($Matches[1]), set it with: gh config set git_protocol ssh" } + } + } + + log '' + log 'Signing' + if (Test-Signing) { ok 'A commit signs and verifies on this host' } else { missing 'A commit signs and verifies on this host' } +} + +function Invoke-Configure { + Test-Prerequisite + Resolve-Identity + New-KeyIfAbsent + Add-KnownHost + + # The local configuration comes first, because none of it needs GitHub. + # A host that cannot authenticate yet, which is every host between creating its key and registering it, still ends this run with its identity, its signing configuration, and a commit that verifies locally. + Set-GitIdentity + Set-SharedCheckout + Set-Signing + + step 'Signing a commit to check the configuration' + if ($script:DRY_RUN) { + info '[dry run] sign and verify a commit in a throwaway repository' + } elseif (Test-Signing) { + info 'A commit signs and verifies' + } else { + die "A commit did not verify. Check $($script:ALLOWED_SIGNERS) holds this host key against $($script:WANT_EMAIL)." + } + + step 'Checking the key registrations at GitHub' + Resolve-GitHubUser + if (-not $script:GITHUB_USER) { + Show-RegistrationNeeded -Kind 'authentication' -Type 'Authentication' + warn 'Authentication does not work yet, so nothing that reaches GitHub will work until the key above is registered' + return + } + info "Authenticated as $($script:GITHUB_USER)" + + if ((Test-KeyRegistered -Kind 'signing') -eq 'missing') { + Show-RegistrationNeeded -Kind 'signing' -Type 'Signing' + warn 'Commits sign and verify locally, and show as unverified on GitHub until the key above is registered as a signing key' + } else { + info 'The signing key is registered' + } + + Show-AgentStatus + step 'Done' +} + +# --- Entry --- + +# PowerShell records which switches were given and not the order they came in, so two actions is a refusal rather than the last one winning. +# Refusing is also the better answer: an action silently discarded is one the caller believes ran. +function Resolve-Mode { + $given = @($script:ACTIONS.Keys | Where-Object { $script:ACTIONS[$_] }) + if ($given.Count -gt 1) { die "More than one action given ($($given -join ', ')), name one" } + if ($given.Count -eq 0) { return 'status' } + return $given[0] +} + +function main { + if ($script:WANT_HELP) { usage; exit 0 } + $script:MODE = Resolve-Mode + Test-Prerequisite + + if ($script:MODE -eq 'status') { Show-Status } else { Invoke-Configure } +} + +main diff --git a/host-setup/windows/setup-wsl.ps1 b/host-setup/windows/setup-wsl.ps1 new file mode 100644 index 00000000..50f6dac1 --- /dev/null +++ b/host-setup/windows/setup-wsl.ps1 @@ -0,0 +1,350 @@ +# Installs the WSL distributions this host runs, and reports how Docker Desktop is integrated with them. +# It is a fourth script rather than part of upgrade-host.ps1, because installing a distribution stands a new environment up where a platform update brings this one current, and those are different actions on different subjects. +# +# The Docker integration is reported and never written. +# Docker Desktop holds these 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. + +[CmdletBinding()] +param( + [Alias('s')][switch]$Status, + [Alias('l')][switch]$List, + [Alias('i')][switch]$Install, + [switch]$Default, + [Alias('n')][switch]$DryRun, + [Alias('y')][switch]$Yes, + [Alias('h')][switch]$Help, + [Parameter(Position = 0, ValueFromRemainingArguments)][string[]]$Name +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +# A non-zero exit from wsl is an answer here rather than a failure. +# Setting this keeps a profile that turned it on from turning every read into a terminating error. +$PSNativeCommandUseErrorActionPreference = $false + +# Docker Desktop registers these itself, so they are not distributions the operator installed and a report that named them would count two as three. +$DOCKER_DISTRIBUTIONS = @('docker-desktop', 'docker-desktop-data') + +$SETTINGS = Join-Path $env:APPDATA 'Docker\settings-store.json' + +# Every parameter is read into a variable here rather than from inside a function. +# A script parameter reached only from a nested scope reads as declared and never used, which is a finding on each one and hides a parameter that genuinely is unused. +$ACTIONS = [ordered]@{ + status = [bool]$Status + list = [bool]$List + install = [bool]$Install +} +$WANT_HELP = [bool]$Help +$WANT_DEFAULT = [bool]$Default +# Filtered rather than wrapped, because wrapping an unset parameter yields a one element list holding nothing, which reads as one distribution named the empty string. +$WANT_NAMES = @($Name | Where-Object { $_ }) + +$MODE = 'status' +$DRY_RUN = [bool]$DryRun +$ASSUME_YES = [bool]$Yes + +# --- 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 } + +function ok { param([string]$Message) Write-Host " [ ok ] $Message" } +function missing { param([string]$Message) Write-Host " [ ] $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: setup-wsl.ps1 [options] [distribution] + +Installs a WSL distribution, and reports the ones this host runs and how Docker Desktop is +integrated with them. + +Actions, name one, default -Status: + -s, -Status Report the platform, the distributions, and the Docker integration + -l, -List List the distributions this host can install + -i, -Install Install the named distribution + -h, -Help Show this help + +Options: + -n, -DryRun Print the commands instead of running them + -y, -Yes Do not prompt before changing the host + -Default Also make the installed distribution the default + +An install does not launch the distribution, so it skips the first run account setup and is safe +unattended. The new distribution has no user until it is launched once, which is a person's step. + +The Docker Desktop integration is reported and never written, because Docker rewrites its settings +file from memory while it runs, so an edit made here does not survive. Change it in Docker Desktop +under Settings, Resources, WSL integration. + +Examples: + setup-wsl.ps1 Report the platform and the distributions + setup-wsl.ps1 -List List what can be installed + setup-wsl.ps1 -Install Debian Install Debian, without launching it + setup-wsl.ps1 -Install Ubuntu-24.04 -Default + setup-wsl.ps1 -Install Debian -DryRun +'@ +} + +# --- Execution --- + +# Run a command, or print it under -DryRun. +# A read used to decide what to do runs either way, and only a command that changes the host goes through here. +function run { + param([Parameter(Mandatory)][string]$Command, [Parameter(ValueFromRemainingArguments)][string[]]$Arguments) + if ($script:DRY_RUN) { + Write-Host " [dry run] $Command $($Arguments -join ' ')" + return 0 + } + # The command's own output goes to the console rather than down the pipeline. + # A native command writes to this function's output stream, so without this the caller receives every line the command printed with the exit code appended, and a check against 0 then compares against the first line of output. + & $Command @Arguments | Out-Host + return $LASTEXITCODE +} + +function confirm { + param([Parameter(Mandatory)][string]$Question) + if ($script:ASSUME_YES -or $script:DRY_RUN) { return $true } + # Both are checked because a scheduled task reports one and not the other, and either alone misses a case. + if (-not [Environment]::UserInteractive -or [Console]::IsInputRedirected) { + die 'Not a terminal and -Yes was not given, refusing to change the host unattended' + } + return ((Read-Host "$Question [y/N]") -match '^(y|yes)$') +} + +# --- WSL --- + +# Every wsl.exe call goes through here, because wsl.exe emits UTF-16 by default and its output then reads as NUL separated characters. +# WSL_UTF8 changes what wsl.exe emits, where setting the console encoding would only change how this process decodes it and would corrupt in-distribution output that is already UTF-8. +# A host whose WSL predates WSL_UTF8 still answers in UTF-16, so a result carrying a NUL is stripped rather than reported as unreadable. +function Invoke-Wsl { + param([Parameter(ValueFromRemainingArguments)][string[]]$Arguments) + $previous = $env:WSL_UTF8 + try { + $env:WSL_UTF8 = '1' + $text = (& wsl.exe @Arguments 2>&1 | Out-String -Width 500) + if ($text.Contains([char]0)) { $text = $text -replace "`0", '' } + return $text + } finally { + if ($null -eq $previous) { Remove-Item Env:\WSL_UTF8 -ErrorAction SilentlyContinue } + else { $env:WSL_UTF8 = $previous } + } +} + +function Test-WslPresent { + return [bool](Get-Command wsl.exe -ErrorAction SilentlyContinue) +} + +function Get-WslPlatform { + $platform = [ordered]@{} + $text = Invoke-Wsl '--version' + if ($LASTEXITCODE -ne 0) { return $platform } + foreach ($label in 'WSL version', 'Kernel version', 'WSLg version', 'Windows version') { + if ($text -match "(?m)^$label`:\s*(\S+)\s*$") { $platform[$label] = $Matches[1] } + } + return $platform +} + +# The distributions this host has registered, with Docker's own excluded. +# The verbose listing marks the default with a leading asterisk, which is the only place that fact is published. +function Get-WslDistribution { + $rows = @() + $text = Invoke-Wsl '--list' '--verbose' + if ($LASTEXITCODE -ne 0) { return , $rows } + foreach ($line in ($text -split "`r?`n")) { + if ($line -notmatch '^(\*?)\s+(\S+)\s+(\S+)\s+(\d+)\s*$') { continue } + $distribution = $Matches[2] + if ($script:DOCKER_DISTRIBUTIONS -contains $distribution) { continue } + $rows += @{ + Name = $distribution + State = $Matches[3] + Version = $Matches[4] + IsDefault = ($Matches[1] -eq '*') + } + } + return , $rows +} + +function Get-WslAvailable { + $rows = @() + $text = Invoke-Wsl '--list' '--online' + if ($LASTEXITCODE -ne 0) { return , $rows } + $started = $false + foreach ($line in ($text -split "`r?`n")) { + if ($line -match '^NAME\s+FRIENDLY NAME') { $started = $true; continue } + if (-not $started) { continue } + if ($line -notmatch '^(\S+)\s\s+(.+?)\s*$') { continue } + $rows += @{ Name = $Matches[1]; Friendly = $Matches[2] } + } + return , $rows +} + +# --- Docker --- + +# How Docker Desktop records its WSL integration, read from its settings file. +# Reported and never written: Docker holds these in memory and rewrites the file from that copy while it runs, so an edit here is discarded at its next save. +function Get-DockerIntegration { + if (-not (Test-Path $script:SETTINGS)) { return $null } + try { + return (Get-Content $script:SETTINGS -Raw | ConvertFrom-Json) + } catch { + warn "Docker's settings file at $($script:SETTINGS) could not be read as JSON, so the integration is not reported: $($_.Exception.Message)" + return $null + } +} + +function Get-JsonMember { + param($Object, [string]$Member) + if ($null -eq $Object) { return $null } + $property = $Object.PSObject.Properties[$Member] + if ($null -eq $property) { return $null } + return $property.Value +} + +# --- Actions --- + +function Show-Platform { + log 'Platform' + $platform = Get-WslPlatform + if ($platform.Count -eq 0) { + info 'WSL is installed but did not report a version, so it may need "wsl --update"' + return + } + foreach ($label in $platform.Keys) { info ("{0,-16}{1}" -f $label, $platform[$label]) } +} + +function Show-Distribution { + log '' + log 'Distributions' + $rows = Get-WslDistribution + if ($rows.Count -eq 0) { + info 'None registered, and -List names what this host can install' + return , $rows + } + $format = ' {0,-16} {1,-12} {2,-9} {3}' + log ($format -f 'NAME', 'STATE', 'VERSION', 'DEFAULT') + foreach ($row in $rows) { + log ($format -f $row.Name, $row.State, $row.Version, $(if ($row.IsDefault) { 'yes' } else { '' })) + } + return , $rows +} + +function Show-Integration { + param([array]$Distribution) + log '' + log 'Docker Desktop integration, as Docker records it' + + $settings = Get-DockerIntegration + if ($null -eq $settings) { + info 'Docker Desktop is not installed here, or has never been started, so there is no integration to report' + return + } + + $engine = Get-JsonMember $settings 'WslEngineEnabled' + if ($engine) { ok 'WSL engine enabled' } else { missing 'WSL engine enabled' } + + $withDefault = Get-JsonMember $settings 'EnableIntegrationWithDefaultWslDistro' + if ($withDefault) { ok 'integration with the default distribution' } else { missing 'integration with the default distribution' } + + $integrated = @(Get-JsonMember $settings 'IntegratedWslDistros') + foreach ($row in $Distribution) { + if ($integrated -contains $row.Name) { ok $row.Name } else { missing $row.Name } + } + + if (Get-JsonMember $settings 'WslUpdateRequired') { + warn 'Docker reports that WSL needs updating, which upgrade-host.ps1 -Wsl does once Docker is quit' + } + + log '' + info 'Change this in Docker Desktop under Settings, Resources, WSL integration.' + info 'It is reported here and never written, because Docker rewrites its settings file from memory while it runs.' +} + +function Show-Status { + if (-not (Test-WslPresent)) { + die 'wsl.exe not found, so WSL is not installed on this host. Install it with: wsl --install --no-distribution' + } + Show-Platform + $distribution = Show-Distribution + Show-Integration -Distribution $distribution +} + +function Show-List { + if (-not (Test-WslPresent)) { + die 'wsl.exe not found, so WSL is not installed on this host. Install it with: wsl --install --no-distribution' + } + $rows = Get-WslAvailable + if ($rows.Count -eq 0) { + die 'WSL listed no installable distributions, which usually means it could not reach the distribution index' + } + $format = '{0,-32} {1}' + log ($format -f 'NAME', 'FRIENDLY NAME') + foreach ($row in $rows) { log ($format -f $row.Name, $row.Friendly) } +} + +function Install-Distribution { + if (-not (Test-WslPresent)) { + die 'wsl.exe not found, so WSL is not installed on this host. Install it with: wsl --install --no-distribution' + } + if ($script:WANT_NAMES.Count -ne 1) { + die 'Name one distribution to install, and -List names the ones this host can install' + } + $wanted = $script:WANT_NAMES[0] + + # Checked against the index rather than left to wsl, whose failure for an unknown name is a long help text rather than a message naming the problem. + $available = Get-WslAvailable + if ($available.Count -gt 0 -and ($available | ForEach-Object { $_.Name }) -notcontains $wanted) { + die "`"$wanted`" is not a distribution this host can install, and -List names the ones it can" + } + + $installed = Get-WslDistribution + if (($installed | ForEach-Object { $_.Name }) -contains $wanted) { + log "$wanted is already installed, leaving it alone" + return + } + + # The name is wrapped because a question mark is a legal character in a variable name, so "$wanted?" reads as a variable nobody set. + if (-not (confirm "Install $($wanted)?")) { die 'Declined' } + + step "Installing $wanted" + # The first run account setup is skipped, which is what makes this safe unattended, and it leaves the distribution with no user until somebody launches it. + $code = run -Command 'wsl.exe' -Arguments '--install', $wanted, '--no-launch' + if ($code -ne 0) { die "wsl --install exited $code" } + + if ($script:WANT_DEFAULT) { + step "Making $wanted the default distribution" + $code = run -Command 'wsl.exe' -Arguments '--set-default', $wanted + if ($code -ne 0) { warn "wsl --set-default exited $code" } + } + + step 'Done' + info "Launch it once with `"wsl -d $wanted`" to create its user account, which this deliberately did not do" + info 'Enable it for Docker in Docker Desktop under Settings, Resources, WSL integration' +} + +# --- Entry --- + +# PowerShell records which switches were given and not the order they came in, so two actions is a refusal rather than the last one winning. +# Refusing is also the better answer: an action silently discarded is one the caller believes ran. +function Resolve-Mode { + $given = @($script:ACTIONS.Keys | Where-Object { $script:ACTIONS[$_] }) + if ($given.Count -gt 1) { die "More than one action given ($($given -join ', ')), name one" } + if ($given.Count -eq 0) { return 'status' } + return $given[0] +} + +function main { + if ($script:WANT_HELP) { usage; exit 0 } + $script:MODE = Resolve-Mode + + switch ($script:MODE) { + 'list' { Show-List } + 'install' { Install-Distribution } + default { Show-Status } + } +} + +main diff --git a/host-setup/windows/upgrade-host.ps1 b/host-setup/windows/upgrade-host.ps1 new file mode 100644 index 00000000..3e8e0e2f --- /dev/null +++ b/host-setup/windows/upgrade-host.ps1 @@ -0,0 +1,342 @@ +# Upgrades this host, on native Windows. +# Two things are brought current: the packages winget manages, which is routine, and the WSL platform itself, which is the Windows peer of a kernel and moves on its own release schedule. +# +# A WSL platform update is refused while Docker Desktop is running, because Docker holds the WSL service open and the update then fails part way rather than declining. +# Refusing is the point of running this rather than the two commands by hand: each is one line, and the guard between them is not. +# +# There is no action for a Windows feature update, where the Linux peer moves a host to the next release. +# Windows Update owns that upgrade, it is not driven the way an apt sources rewrite is, and an action that pretended otherwise would be the one thing this script must not carry. + +[CmdletBinding()] +param( + [Alias('s')][switch]$Status, + [Alias('p')][switch]$Packages, + [Alias('w')][switch]$Wsl, + [Alias('a')][switch]$All, + [Alias('n')][switch]$DryRun, + [Alias('y')][switch]$Yes, + [Alias('h')][switch]$Help +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +# A non-zero exit from winget or wsl is an answer here rather than a failure. +# Setting this keeps a profile that turned it on from turning every read into a terminating error. +$PSNativeCommandUseErrorActionPreference = $false + +# The processes that mean Docker Desktop is up, and the reason each is here rather than the obvious alternative, is in Assert-DockerStopped. +$DOCKER_PROCESSES = @('Docker Desktop', 'com.docker.backend', 'com.docker.build') + +# Where a package that winget will not move is actually upgraded from. +# One line per package rather than a rule, because the answer is the application's own and nothing about it can be worked out from winget. +$SELF_UPDATE_NOTE = @{ + 'MSYS2.MSYS2' = 'Upgraded from inside msys2 with pacman, not from here.' +} + +# Every parameter is read into a variable here rather than from inside a function. +# A script parameter reached only from a nested scope reads as declared and never used, which is a finding on each one and hides a parameter that genuinely is unused. +$ACTIONS = [ordered]@{ + status = [bool]$Status + packages = [bool]$Packages + wsl = [bool]$Wsl + all = [bool]$All +} +$WANT_HELP = [bool]$Help + +$MODE = 'packages' +$DRY_RUN = [bool]$DryRun +$ASSUME_YES = [bool]$Yes +$ELEVATED = $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 } + +function usage { + # The closing marker of a here-string has to sit at column 0, so this block is deliberately unindented. + Write-Host @' +Usage: upgrade-host.ps1 [options] + +Upgrades this host: the packages winget manages, and the WSL platform. A Windows feature update is +Windows Update's to make and has no action here. + +Actions, name one, default -Packages: + -s, -Status Report the host, what is upgradable, and the WSL platform + -p, -Packages Upgrade every winget package that has an upgrade + -w, -Wsl Update the WSL platform only + -a, -All Upgrade the packages, then update the WSL platform + -h, -Help Show this help + +Options: + -n, -DryRun Print the commands instead of running them + -y, -Yes Do not prompt before changing the host + +A WSL platform update stops the WSL service, and Docker Desktop holds it open, so this refuses to +start one while Docker is running. Quit Docker from its tray icon first, since pausing it is not +enough. Updating WSL also restarts every distribution, so anything running inside one is stopped. + +Some packages report a version winget cannot move, because the application updates itself and the +version it was installed at is the one recorded. Those are listed apart and left alone, and the +report names where each is actually upgraded from. + +Examples: + upgrade-host.ps1 Upgrade the winget packages + upgrade-host.ps1 -Status Report, change nothing + upgrade-host.ps1 -Packages -Yes Upgrade unattended + upgrade-host.ps1 -All Upgrade the packages, then the WSL platform + upgrade-host.ps1 -Wsl -DryRun Show what a WSL update would run +'@ +} + +# --- Host --- + +function Test-HostSupported { + if ($PSVersionTable.PSVersion.Major -lt 7) { + die "This script needs PowerShell 7 or later, and this is $($PSVersionTable.PSVersion). Install it with: winget install --id Microsoft.PowerShell --exact --source winget" + } + if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { + die 'winget not found, and this script upgrades winget packages. Install App Installer from the Microsoft Store, then run this again.' + } + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $script:ELEVATED = ([Security.Principal.WindowsPrincipal]$identity).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +function Get-HostDescription { + $caption = (Get-CimInstance Win32_OperatingSystem).Caption + $wingetVersion = (& winget --version 2>&1 | Out-String).Trim() + return "$caption $([Environment]::OSVersion.Version), pwsh $($PSVersionTable.PSVersion), winget $wingetVersion" +} + +# --- Execution --- + +# Run a command, or print it under -DryRun. +# A read used to decide what to do runs either way, and only a command that changes the host goes through here. +function run { + param([Parameter(Mandatory)][string]$Command, [Parameter(ValueFromRemainingArguments)][string[]]$Arguments) + if ($script:DRY_RUN) { + Write-Host " [dry run] $Command $($Arguments -join ' ')" + return 0 + } + # The command's own output goes to the console rather than down the pipeline. + # A native command writes to this function's output stream, so without this the caller receives every line the command printed with the exit code appended, and a check against 0 then compares against the first line of output. + & $Command @Arguments | Out-Host + return $LASTEXITCODE +} + +function confirm { + param([Parameter(Mandatory)][string]$Question) + if ($script:ASSUME_YES -or $script:DRY_RUN) { return $true } + # Both are checked because a scheduled task reports one and not the other, and either alone misses a case. + if (-not [Environment]::UserInteractive -or [Console]::IsInputRedirected) { + die 'Not a terminal and -Yes was not given, refusing to change the host unattended' + } + return ((Read-Host "$Question [y/N]") -match '^(y|yes)$') +} + +# --- winget --- + +# What winget would upgrade, split into the packages it will move and the ones it will not. +# The two halves are counted here rather than read off winget's own closing count, which sums both and so reports a number that matches neither list. +# Each row is parsed by header offset, since a package name carries spaces and splitting on them moves the version into the name. +function Get-Upgradable { + $result = @{ Ready = @(); Explicit = @() } + $text = (& winget upgrade --include-unknown --disable-interactivity 2>&1 | Out-String -Width 500) + if ($LASTEXITCODE -ne 0) { return $result } + + $marker = 'require explicit targeting for upgrade' + $index = $text.IndexOf($marker) + $ready = if ($index -ge 0) { $text.Substring(0, $index) } else { $text } + $explicit = if ($index -ge 0) { $text.Substring($index) } else { '' } + + $result.Ready = Read-UpgradeTable -Text $ready + $result.Explicit = Read-UpgradeTable -Text $explicit + return $result +} + +function Read-UpgradeTable { + param([string]$Text) + $rows = @() + if (-not $Text) { return , $rows } + $lines = $Text -split "`r?`n" + $header = $lines | Where-Object { $_ -match '^Name\s+Id\s+Version\s+Available' } | Select-Object -First 1 + if (-not $header) { return , $rows } + $idColumn = $header.IndexOf('Id') + $versionColumn = $header.IndexOf('Version') + $availableColumn = $header.IndexOf('Available') + foreach ($line in $lines) { + if ($line.Length -le $availableColumn) { continue } + if ($line -match '^Name\s+Id\s+Version') { continue } + if ($line -match '^-+$') { continue } + $id = ($line.Substring($idColumn, $versionColumn - $idColumn)).Trim() + # A row whose id column is blank is wrapped output or a progress line rather than a package. + if (-not $id -or $id -notmatch '^\S+$') { continue } + $rows += @{ + Id = $id + Version = ($line.Substring($versionColumn, $availableColumn - $versionColumn)).Trim() + Available = (($line.Substring($availableColumn) -split '\s+')[0]).Trim() + } + } + return , $rows +} + +# --- WSL --- + +# Every wsl.exe call goes through here, because wsl.exe emits UTF-16 by default and its output then reads as NUL separated characters. +# WSL_UTF8 changes what wsl.exe emits, where setting the console encoding would only change how this process decodes it and would corrupt in-distribution output that is already UTF-8. +# A host whose WSL predates WSL_UTF8 still answers in UTF-16, so a result carrying a NUL is stripped rather than reported as unreadable. +function Invoke-Wsl { + param([Parameter(ValueFromRemainingArguments)][string[]]$Arguments) + $previous = $env:WSL_UTF8 + try { + $env:WSL_UTF8 = '1' + $text = (& wsl.exe @Arguments 2>&1 | Out-String -Width 500) + if ($text.Contains([char]0)) { $text = $text -replace "`0", '' } + return $text + } finally { + if ($null -eq $previous) { Remove-Item Env:\WSL_UTF8 -ErrorAction SilentlyContinue } + else { $env:WSL_UTF8 = $previous } + } +} + +function Get-WslVersion { + if (-not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) { return $null } + $text = Invoke-Wsl '--version' + if ($LASTEXITCODE -ne 0) { return $null } + $parts = @() + foreach ($label in 'WSL version', 'Kernel version', 'WSLg version') { + if ($text -match "(?m)^$label`:\s*(\S+)\s*$") { $parts += "$label $($Matches[1])" } + } + if ($parts.Count -eq 0) { return $null } + return ($parts -join ', ') +} + +# --- Docker --- + +# Whether Docker Desktop is running, which is what blocks a WSL platform update. +# The service is not the probe: com.docker.service reads Stopped on a host with Docker Desktop plainly running, since it is the elevated helper rather than the engine. +# Neither wslservice nor vmmemWSL is the probe either, since both run for any distribution and would report Docker on a host that has none. +function Get-DockerProcess { + return , @(Get-Process -Name $script:DOCKER_PROCESSES -ErrorAction SilentlyContinue) +} + +# A WSL platform update stops and restarts the WSL service, and Docker Desktop holds it open, so the update fails part way rather than declining. +# The guard runs under -DryRun too, since it is a read that decides what to do rather than a change to the host, and a dry run that printed the command would say the update was available when it is not. +function Assert-DockerStopped { + $running = Get-DockerProcess + if ($running.Count -eq 0) { return } + info "Running: $((($running | ForEach-Object { $_.Name }) | Sort-Object -Unique) -join ', ')" + die 'Docker Desktop is running, and a WSL platform update fails part way while it holds the WSL service open. Quit Docker Desktop from its tray icon and wait for it to report that it has stopped, then run this again. Pausing it is not enough.' +} + +# --- Actions --- + +function Show-Status { + log "Host : $(Get-HostDescription)" + if ($script:ELEVATED) { + log 'Elevation : elevated, and an unelevated run is the one to prefer' + } else { + log 'Elevation : not elevated, which is the state to prefer' + } + + $upgradable = Get-Upgradable + log "Upgradable: $($upgradable.Ready.Count) package(s)" + if ($upgradable.Explicit.Count -gt 0) { + log "Self-updating: $($upgradable.Explicit.Count) package(s), which winget does not move" + } + + $wslVersion = Get-WslVersion + log "WSL : $(if ($wslVersion) { $wslVersion } else { 'not installed, or its version could not be read' })" + + $docker = Get-DockerProcess + if ($docker.Count -gt 0) { + $names = (($docker | ForEach-Object { $_.Name }) | Sort-Object -Unique) -join ', ' + log "Docker : running ($names), so a WSL platform update is refused" + } else { + log 'Docker : not running' + } + + if ($upgradable.Explicit.Count -eq 0) { return } + log '' + log 'Self-updating, so winget reports the version it was installed at rather than the version it runs:' + foreach ($row in $upgradable.Explicit) { + info "$($row.Id) installed at $($row.Version), source carries $($row.Available)" + # No command is printed for these, deliberately: winget cannot move them, and offering one invites a full reinstall over a working copy in pursuit of a number that does not change. + if ($script:SELF_UPDATE_NOTE.ContainsKey($row.Id)) { + info " $($script:SELF_UPDATE_NOTE[$row.Id])" + } else { + info ' This application updates itself, so upgrade it from its own tooling.' + } + } +} + +function Invoke-PackageUpgrade { + $upgradable = Get-Upgradable + if ($upgradable.Ready.Count -eq 0) { + step 'Upgrading winget packages' + info 'Nothing to upgrade' + } else { + step "Upgrading $($upgradable.Ready.Count) winget package(s)" + foreach ($row in $upgradable.Ready) { info "$($row.Id) $($row.Version) -> $($row.Available)" } + $code = run -Command 'winget' -Arguments 'upgrade', '--all', '--include-unknown', '--disable-interactivity', + '--accept-source-agreements', '--accept-package-agreements', '--silent' + if ($code -ne 0) { warn "winget exited $code, so one or more packages did not upgrade" } + } + + # Named rather than silently skipped, because a package winget leaves alone reads as one it upgraded. + if ($upgradable.Explicit.Count -gt 0) { + step "Left alone, $($upgradable.Explicit.Count) package(s) that update themselves" + foreach ($row in $upgradable.Explicit) { info $row.Id } + } +} + +function Invoke-WslUpdate { + step 'Updating the WSL platform' + if (-not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) { + warn 'wsl.exe not found, so there is no WSL platform to update' + return + } + Assert-DockerStopped + info 'This restarts every distribution, so anything running inside one is stopped' + $code = run -Command 'wsl.exe' -Arguments '--update' + if ($code -ne 0) { warn "wsl --update exited $code" } +} + +function Invoke-Upgrade { + log "Host: $(Get-HostDescription)" + log "Mode: $($script:MODE)$(if ($script:DRY_RUN) { ' (dry run)' })" + + # The guard runs before the prompt, so a run that cannot finish says so rather than asking first and refusing after. + if ($script:MODE -in @('wsl', 'all')) { Assert-DockerStopped } + + if (-not (confirm 'Upgrade this host?')) { die 'Declined' } + + if ($script:MODE -in @('packages', 'all')) { Invoke-PackageUpgrade } + if ($script:MODE -in @('wsl', 'all')) { Invoke-WslUpdate } + + step 'Done' +} + +# --- Entry --- + +# PowerShell records which switches were given and not the order they came in, so two actions is a refusal rather than the last one winning. +# Refusing is also the better answer: an action silently discarded is one the caller believes ran. +function Resolve-Mode { + $given = @($script:ACTIONS.Keys | Where-Object { $script:ACTIONS[$_] }) + if ($given.Count -gt 1) { die "More than one action given ($($given -join ', ')), name one" } + if ($given.Count -eq 0) { return 'packages' } + return $given[0] +} + +function main { + if ($script:WANT_HELP) { usage; exit 0 } + $script:MODE = Resolve-Mode + Test-HostSupported + + if ($script:MODE -eq 'status') { Show-Status } else { Invoke-Upgrade } +} + +main diff --git a/scripts/pr_review.py b/scripts/pr_review.py index fdae5b9c..ffc0220a 100644 --- a/scripts/pr_review.py +++ b/scripts/pr_review.py @@ -351,10 +351,13 @@ def gh_graphql(query: str, **variables) -> dict: `errors` is checked rather than trusted to the exit code, since a GraphQL document can fail per-field while the request itself succeeds, and the caller would read the null that leaves. """ + # Every read below decodes as UTF-8 rather than as whatever the platform's locale is. + # `gh` emits UTF-8 on every platform, where a Windows console locale is cp1252. + # A review body carrying one typographic quote crashed the decode and left the caller reading a null stdout. argv = ['gh', 'api', 'graphql', '-f', f'query={query}'] for name, value in variables.items(): argv += ['-F' if isinstance(value, int) else '-f', f'{name}={value}'] - r = subprocess.run(argv, capture_output=True, text=True) + r = subprocess.run(argv, capture_output=True, text=True, encoding='utf-8') if r.returncode != 0: sys.stderr.write(r.stderr[:800]) raise SystemExit(f'gh graphql failed rc={r.returncode}') @@ -381,7 +384,7 @@ def timeline(owner: str, repo: str, num: int) -> list[tuple[str, str]]: r = subprocess.run( ['gh', 'api', '--paginate', f'repos/{owner}/{repo}/issues/{num}/timeline?per_page=100', '--jq', TIMELINE_JQ], - capture_output=True, text=True) + capture_output=True, text=True, encoding='utf-8') if r.returncode != 0: sys.stderr.write(r.stderr[:800]) raise SystemExit(f'gh timeline failed rc={r.returncode}') @@ -1265,7 +1268,7 @@ def origin_owner() -> str | None: try: url = subprocess.run(['git', '-C', str(Path(__file__).resolve().parent), 'remote', 'get-url', 'origin'], - capture_output=True, text=True, timeout=5).stdout.strip() + capture_output=True, text=True, encoding='utf-8', timeout=5).stdout.strip() except Exception: return None m = re.search(r'[:/]([A-Za-z0-9_.\-]+)/([A-Za-z0-9_.\-]+?)(?:\.git)?/?$', url) @@ -1412,7 +1415,7 @@ def gh_rest(path: str, jq: str | None = None) -> subprocess.CompletedProcess: """ argv = ['gh', 'api', path] + (['--jq', jq] if jq else []) try: - return subprocess.run(argv, capture_output=True, text=True, timeout=30) + return subprocess.run(argv, capture_output=True, text=True, encoding='utf-8', timeout=30) except (OSError, subprocess.SubprocessError): return subprocess.CompletedProcess(argv, 1, '', 'gh could not be run') diff --git a/scripts/test_bootstrap.py b/scripts/test_bootstrap.py index 193308d6..fe2cd75b 100644 --- a/scripts/test_bootstrap.py +++ b/scripts/test_bootstrap.py @@ -14,31 +14,50 @@ be declared required and be one nothing here can install, which a host would discover as a gate it cannot satisfy. +That assertion runs once per platform, because the two installers do not manage the same set and the +difference is a decision rather than an accident. `git-restore-mtime` serves a Linux deploy path and +the spec declares it not applicable on Windows. `docker` is the reverse: one winget package there, +and on Linux an answer that differs by whether the host is a hypervisor, a WSL distribution, or a +workstation. + Run: python3 scripts/test_bootstrap.py """ from __future__ import annotations import json import re +import subprocess from pathlib import Path ROOT = Path(__file__).resolve().parent.parent BOOTSTRAP = ROOT / 'host-setup' / 'bootstrap.sh' LINUX = ROOT / 'host-setup' / 'linux' +WINDOWS = ROOT / 'host-setup' / 'windows' HOST_TOOLS = ROOT / 'spec' / 'host-tools.json' # The tools the linux tooling manages, read from the script rather than restated here, so the two cannot drift while both look correct. TOOLS_DECLARATION = re.compile(r'^readonly TOOLS=\(([^)]*)\)', re.MULTILINE) +# The same, for the windows tooling, whose registry is a list of records rather than a flat array. +# The names are read out of the records themselves rather than from a second list beside them, so there is one declaration to keep true rather than two that can agree wrongly. +PS_TOOLS_OPEN = re.compile(r'^\$TOOLS\s*=\s*@\(', re.MULTILINE) +PS_TOOLS_CLOSE = re.compile(r'^\)', re.MULTILINE) +PS_TOOL_NAME = re.compile(r"^\s*@\{\s*Name\s*=\s*'([^']+)'", re.MULTILINE) + # A spec tool whose name differs from the name the installer knows it by, and why. ALIASES = { - 'python3': 'python', + 'linux': {'python3': 'python'}, + 'windows': {'python3': 'python'}, } -# A spec tool the installer deliberately does not manage, and the reason, recorded so an omission is a decision somebody made rather than one nobody noticed. +# A spec tool an installer deliberately does not manage, and the reason, recorded so an omission is a decision somebody made rather than one nobody noticed. +# The windows set is empty rather than absent, which is itself the assertion: Docker Desktop is one winget package there, where on Linux a hypervisor and a workstation want different answers, so an entry appearing here later is a decision to justify rather than a gap to fill. NOT_MANAGED = { - 'docker': 'Installed from the vendor script per the distribution, and a hypervisor or a WSL ' - 'distribution wants a different answer than a workstation does.', + 'linux': { + 'docker': 'Installed from the vendor script per the distribution, and a hypervisor or a WSL ' + 'distribution wants a different answer than a workstation does.', + }, + 'windows': {}, } failures: list[str] = [] @@ -59,6 +78,28 @@ def declared_tools() -> set[str]: return {name.strip() for name in match.group(1).split() if name.strip()} +def declared_windows_tools() -> set[str]: + """The tool names `install-tools.ps1` manages.""" + text = (WINDOWS / 'install-tools.ps1').read_text(encoding='utf-8') + opened = PS_TOOLS_OPEN.search(text) + if not opened: + failures.append('install-tools.ps1 declares no $TOOLS registry, so coverage cannot be checked') + return set() + + # A missing close marker is a failure rather than a scan to end of file. + # Reading on past the registry collects every later `Name = '...'` in the script, so a broken registry answers with a larger tool set than it declares and the coverage check below passes on it. + closed = PS_TOOLS_CLOSE.search(text, opened.end()) + if not closed: + failures.append('install-tools.ps1 opens a $TOOLS registry this cannot find the end of, so coverage cannot be checked') + return set() + + body = text[opened.end():closed.start()] + names = set(PS_TOOL_NAME.findall(body)) + if not names: + failures.append('install-tools.ps1 declares a $TOOLS registry with no Name fields this can read') + return names + + def spec_tools() -> list[dict]: """The tools the spec declares, or an empty list and a recorded failure.""" # A malformed or unreadable spec is a finding this file reports beside the others, rather than a traceback that ends the run and takes the checks after it with it. @@ -70,7 +111,12 @@ def spec_tools() -> list[dict]: def test_loader_reads_one_path_into_the_tree() -> None: - """The loader references exactly one directory inside the tree it fetches.""" + """The loader references exactly one directory inside the tree it fetches. + + The expected set names the Linux path alone, and stays that way while `host-setup/windows` + carries no loader of its own. Widening it to admit a Windows path before one exists would + retire the invariant ahead of the thing it protects. + """ text = BOOTSTRAP.read_text(encoding='utf-8') # Every reference to the fetched tree goes through the variable holding its location, so the paths it names are countable rather than scattered. @@ -100,9 +146,8 @@ def test_loader_needs_no_python() -> None: ) -def test_every_required_linux_tool_is_installable() -> None: - """A tool the spec requires on Linux is one the tooling can provide, or a recorded exception.""" - managed = declared_tools() +def assert_coverage(platform: str, managed: set[str], installer: str) -> None: + """A tool the spec requires is one the named installer can provide, or a recorded exception.""" if not managed: return @@ -111,31 +156,96 @@ def test_every_required_linux_tool_is_installable() -> None: if not tool.get('required', False): continue - # Every required tool is checked, including one whose declaration names no Linux source. - # A tool is in scope because the spec requires it, never because its declaration happens to describe where Linux gets it. + # Every required tool is checked, including one whose declaration names no source for this platform. + # A tool is in scope because the spec requires it, never because its declaration happens to describe where this platform gets it. # Reading a missing `source.linux` as "not a Linux tool" skipped docker, git and uv, which is half the required set and the whole of what NOT_MANAGED exists to record. - expected = ALIASES.get(name, name) - if name in NOT_MANAGED: + expected = ALIASES[platform].get(name, name) + if name in NOT_MANAGED[platform]: check( expected not in managed, - f'{name} is recorded as not managed, but install-tools.sh manages it, so the record is stale', + f'{name} is recorded as not managed on {platform}, but {installer} manages it, so the record is stale', ) continue check( expected in managed, - f'the spec requires {name} on Linux and install-tools.sh does not manage it, ' + f'the spec requires {name} on {platform} and {installer} does not manage it, ' f'so a host cannot satisfy the gate by running the tooling', ) +def test_every_required_linux_tool_is_installable() -> None: + """A tool the spec requires on Linux is one the tooling can provide, or a recorded exception.""" + assert_coverage('linux', declared_tools(), 'install-tools.sh') + + +def test_every_required_windows_tool_is_installable() -> None: + """A tool the spec requires on Windows is one the tooling can provide, or a recorded exception.""" + assert_coverage('windows', declared_windows_tools(), 'install-tools.ps1') + + +def indexed_modes() -> dict[str, str]: + """The file modes git records, keyed by repo-relative path. + + Git's mode is read rather than the filesystem's, because the filesystem does not carry one on + every platform this runs on. NTFS has no exec bit, so `st_mode` reports every file as + non-executable and the assertion below fails on Windows against a tree that is correct. What the + loader actually depends on is the mode a Linux checkout gets, and that is the one git stores. + """ + try: + listing = subprocess.run( + ['git', 'ls-files', '-s', '--', 'host-setup'], + capture_output=True, text=True, check=True, cwd=ROOT, + ).stdout + except (OSError, subprocess.CalledProcessError) as error: + failures.append(f'git could not report the recorded file modes, so executability is unchecked: {error}') + return {} + + modes: dict[str, str] = {} + for line in listing.splitlines(): + # Each row is " \t", so the tab is what separates the fields from the path. + fields, _, path = line.partition('\t') + if path: + modes[path] = fields.split()[0] + return modes + + def test_every_managed_tool_is_executable() -> None: """Each script the loader hands control to is present and executable.""" + modes = indexed_modes() for name in ('install-tools.sh', 'upgrade-host.sh', 'setup-github.sh'): path = LINUX / name check(path.is_file(), f'{name} is missing from host-setup/linux') + if path.is_file() and modes: + mode = modes.get(f'host-setup/linux/{name}', '') + check( + mode == '100755', + f'{name} is recorded as {mode or "untracked"} rather than 100755, ' + f'so a fresh checkout cannot run it', + ) + + +def test_every_windows_script_is_present() -> None: + """Each Windows script is present, and none opens with a shebang. + + The exec bit is the Linux form of "this will run", and on Windows the equivalent property is the + absence of a shebang: `scripts/repo_gate.py --check eol-coverage` requires git to resolve any + tracked file opening `#!` to `eol=lf`, and these files are CRLF by the `[*]` .editorconfig + default with no pin of their own. A shebang added later would fail that gate from a file nobody + would think to look at. + """ + scripts = ('install-tools.ps1', 'upgrade-host.ps1', 'setup-github.ps1', 'setup-wsl.ps1') + for name in scripts + ('README.md',): + check((WINDOWS / name).is_file(), f'{name} is missing from host-setup/windows') + + for name in scripts: + path = WINDOWS / name if path.is_file(): - check(path.stat().st_mode & 0o111 != 0, f'{name} is not executable, so the loader cannot run it') + check( + not path.read_bytes().startswith(b'#!'), + f'{name} opens with a shebang, which the eol-coverage gate then pins to LF, ' + f'against the CRLF these files are written with', + ) def main() -> int: @@ -143,7 +253,9 @@ def main() -> int: test_loader_reads_one_path_into_the_tree, test_loader_needs_no_python, test_every_required_linux_tool_is_installable, + test_every_required_windows_tool_is_installable, test_every_managed_tool_is_executable, + test_every_windows_script_is_present, ): test()