From 0b4125206461c1a63a971ac9cf503319bcb05798 Mon Sep 17 00:00:00 2001 From: Max Vilimpoc Date: Mon, 28 Sep 2026 13:04:48 +0200 Subject: [PATCH 1/1] dotfiles: show the plan, and let it be chosen, before anything runs setup-windows.bat used to start installing the moment it was run. It now lists every step it and its two halves can take on this machine - grouped, numbered, with the reason beside anything that cannot run here - and waits. Enter runs what is ticked; numbers, ranges, step names and group letters switch steps on and off first. -WhatIf shows the plan and stops, -Only and -Skip set the starting selection, -Yes takes it unattended, and with no console to ask on it stops rather than assume yes. setup-windows-steps.ps1 is the one list of steps and the one place their availability is decided: -X64Only, with its reason, replaces both the :x64only subroutine and the no-uac half's $Arm64Dropped table, and -Admin keeps machine-wide installers off an account that cannot elevate. setup-windows-plan.ps1 presents it and hands the choice back as a scratch .cmd of set lines. The batch file gates every install on :want / :winget; the two halves take -Only / -Skip / -Yes, print their part of the plan, and ask first when run by hand. The scripts check each other: a step the list does not know is refused, and a selected step that nothing implements fails the run rather than quietly not happening. (The no-uac WinMerge step is now WinMergePath; the old name is still accepted.) New steps: - Rustup (winget), then RustToolchain, which installs stable MSVC for this host - x86_64 or aarch64 - and pins it as both rustup's default host and default toolchain, rather than trusting rustup's own host detection, which an x64 rustup on ARM64 gets wrong. - PlatformTools (adb, fastboot). The manifest calls it x64, but every binary in the r37.0.1 zip is 32-bit x86 and there is no ARM64 build. It stays on ARM64, like iperf3: bound by the link, not the CPU. Claude Code now comes from Anthropic's installer, not winget, through which recent releases are broken. That script picks its build from $env:PROCESSOR_ARCHITECTURE alone - wrong in an emulated x64 shell on ARM64 - so the batch file sets it from the registry for the installer's process, and runs it through Sysnative, since it refuses a 32-bit PowerShell. A copy left over from winget is reported, not removed. Re-running no longer reinstalls anything at the version it already has. winget install already only upgrades; the gaps were the installs winget cannot see, or that never check: Claude Code (its installer always downloads the whole release), the per-user NASM (registered nowhere, so reinstalled on every run), the WiX tool, the rsync zip (now compared with its published .sha256 first) and the Visual Studio passes (skipped when vswhere -requires finds every component). setup-windows-current.ps1 answers "already current?" for the first two, and a check that cannot decide installs as before. The elevation launch line is rebuilt. Its "" pairs lost the script path's closing quote - powershell.exe's parser reads them as a quote, then nothing - which only went unnoticed because the path was the last argument. With -Only and -Yes after it, they were swallowed into the -File path and the elevated PowerShell refused a script that did not exist (exit -196608) before running a line. The path and step list now travel in environment variables, with the one quote built inside PowerShell. The launch also stops reporting a failed elevation as success - `exit $p.ExitCode` with no process exited 0 - and the no-log message says "declined" only when it was (1223), otherwise the exit code. Co-Authored-By: Claude Opus 5.5 --- README.md | 199 ++++++- setup-windows-current.ps1 | 121 ++++ setup-windows-no-uac.ps1 | 247 +++++++-- setup-windows-plan.ps1 | 296 ++++++++++ setup-windows-steps.ps1 | 261 +++++++++ setup-windows-with-uac.ps1 | 1070 +++++++++++++++++++++--------------- setup-windows.bat | 418 +++++++++++--- 7 files changed, 2010 insertions(+), 602 deletions(-) create mode 100644 setup-windows-current.ps1 create mode 100644 setup-windows-plan.ps1 create mode 100644 setup-windows-steps.ps1 diff --git a/README.md b/README.md index 933c534..55244d7 100644 --- a/README.md +++ b/README.md @@ -5,22 +5,31 @@ Windows development-box provisioning scripts. `setup-windows.bat` takes a fresh Windows install to a working C++ / native development environment: editors and shells, Python, the Visual Studio toolchain (including the Clang and Windows XP targeting toolsets), the Windows -Driver Kit, and a handful of analysis tools (Sysinternals, OpenCppCoverage, -BinSkim, the Windows Performance Toolkit). It also sets the box up to be driven -remotely: OpenSSH Server plus an rsync build for Windows, which is what makes a -throwaway VM reachable from a Linux host. +Driver Kit, Rust (rustup, with the stable MSVC toolchain for the host as the +default), Android's `adb`, and a handful of analysis tools (Sysinternals, +OpenCppCoverage, BinSkim, the Windows Performance Toolkit). It also sets the +box up to be driven remotely: OpenSSH Server plus an rsync build for Windows, +which is what makes a throwaway VM reachable from a Linux host. + +**Nothing happens until you have seen the plan.** It first lists every step it +can take on this machine and lets you choose which ones to run — see +[Choosing what runs](#choosing-what-runs). Runs on **x64 and on ARM64** (Windows 11 on Arm), and installs *different things* -on each: x64 is the full workstation, ARM64 a lean single-compiler build box. See +on each: x64 is the full workstation, ARM64 a lean build box with a single +C/C++ compiler. See [Running on ARM64](#running-on-arm64) for what differs and why. ## Files | File | Purpose | | --- | --- | -| `setup-windows.bat` | Entry point. Runs the winget installs, then launches the elevated half and prints its log, then runs the non-elevated script. | -| `setup-windows-no-uac.ps1` | The non-elevated, per-user half: WinMerge, BinSkim and upstream LLVM on the user `PATH` (x64 only), the global git config (identity, plus `core.sshCommand`), the native `ninja` pinned ahead of the one Visual Studio bundles, and an architecture audit of everything provisioned. Can also be run directly from an ordinary prompt. | -| `setup-windows-with-uac.ps1` | The elevated half, started via UAC by the batch file. Enables `ssh-agent`, installs the OpenSSH Client and Server capabilities and starts `sshd`, unpacks the `rsync-windows` release zip for this architecture (`rsync.exe` plus the `ssh.exe` it runs) into `C:\Tools\rsync` on the machine `PATH`, then installs Visual Studio Community with the required components, the WDK, and the Windows Performance Toolkit, and reports whether Intel VTune Profiler is present. Can also be run directly from an Administrator prompt. | +| `setup-windows.bat` | Entry point. Shows the plan and lets you choose, then runs the winget installs, launches the elevated half and prints its log, then runs the non-elevated script. | +| `setup-windows-plan.ps1` | The plan. Run by the batch file before anything else: lists every step for this machine, grouped and numbered, and takes your choice. Run it on its own with `-WhatIf` to preview; add `-HostArch ARM64 -Elevation NOLUA` to preview another kind of box. | +| `setup-windows-steps.ps1` | The catalog the other four read: every step's name, what it does, and where it cannot run and why. Defines functions only; it is dot-sourced, never run. | +| `setup-windows-current.ps1` | Asked by the batch file before the Claude Code and NASM installs: is what is on the box already current? Those are the two installs that would otherwise run in full every time — see [Re-running](#re-running). | +| `setup-windows-no-uac.ps1` | The non-elevated, per-user half: WinMerge, BinSkim and upstream LLVM on the user `PATH` (x64 only), the global git config (identity, plus `core.sshCommand`), the native `ninja` pinned ahead of the one Visual Studio bundles, and an architecture audit of everything provisioned. Can also be run directly from an ordinary prompt; it lists its steps and asks first. | +| `setup-windows-with-uac.ps1` | The elevated half, started via UAC by the batch file. Enables `ssh-agent`, installs the OpenSSH Client and Server capabilities and starts `sshd`, unpacks the `rsync-windows` release zip for this architecture (`rsync.exe` plus the `ssh.exe` it runs) into `C:\Tools\rsync` on the machine `PATH`, then installs Visual Studio Community with the required components, the WDK, and the Windows Performance Toolkit, and reports whether Intel VTune Profiler is present. Can also be run directly from an Administrator prompt; it lists its steps and asks first. | | `setup-windows-7-test-env.bat` | Prepares a **Windows 7 VM** as a test target driven from the host by `VBoxManage guestcontrol`. Copy it into the guest and run it there; it is idempotent, so re-run it after any snapshot restore. The per-user half needs no UAC (crash-dialog suppression, no screen blanking, a staging directory, the shared folder on `Z:`); the machine-wide half is skipped with a notice unless run elevated inside the guest. It then reports what the box can actually test: DWM composition, printers, audio capture devices. | ## Usage @@ -44,12 +53,107 @@ on each: x64 is the full workstation, ARM64 a lean single-compiler build box. Se setup-windows.bat ``` - It will raise a single UAC prompt for the elevated half. Accept it — declining - leaves Visual Studio and the WDK uninstalled, and the script says so. + Before doing anything it prints the plan — every step it can take on this + machine, grouped and numbered — and waits: + + ```text + Setup plan - nothing has been done yet + host: x64 - the full workstation + elevation: not elevated; expect a UAC prompt partway through, for the elevated half + + U Per-user installs (winget, except Claude Code) + [x] 1 ClaudeCode Claude Code, native installer: irm https://claude.ai/install.ps1 | iex + [x] 2 Brave Brave.Brave + ... + E Elevated setup (setup-windows-with-uac.ps1) - one UAC prompt, runs in its own window + [x] 24 OpenSshClient OpenSSH Client capability (ssh.exe, and the libcrypto.dll rsync's ssh.exe needs) + ... + [x] 28 VisualStudio Visual Studio 2022 Community, C++: MSVC for x86/x64 + ARM64, SDK 26100 (several GB) + ... + + 42 of 42 steps selected. + + Enter runs the selected steps. To change them, type step numbers or ranges + (3 5-7), step names, or a group letter (U M E C) to switch them on or off; + 'all' or 'none' for everything; 'q' to quit without doing anything. + ``` + + Press Enter to run everything marked `[x]`, or switch steps off and on first; + the list is redrawn after each change. Steps that cannot run on this machine + are shown as `[-]` with the reason and have no number. + + After Enter it runs unattended apart from a single UAC prompt for the + elevated half — none at all if you switched every elevated step off. Accept + it; declining leaves Visual Studio and the WDK uninstalled, and the script + says so. 3. Restart your shell afterwards so the updated user `PATH` is picked up, and reboot if a step reported that a restart was required. +## Choosing what runs + +Arguments to `setup-windows.bat` set the starting point of the plan; you can +still adjust it before pressing Enter unless you also pass `-Yes`. + +| | | +| --- | --- | +| `setup-windows.bat -WhatIf` | Show the plan for this machine and stop. Nothing is changed. | +| `setup-windows.bat -Skip VirtualBox,Brave` | Start with those switched off. | +| `setup-windows.bat -Only E` | Start with only the elevated half. | +| `setup-windows.bat -Yes` | Do not ask: run the plan as it stands. For unattended runs. | +| `setup-windows.bat /?` | Usage. | + +- Steps are named as the plan shows them, comma-separated. A group letter works + anywhere a name does: `U` per-user installs, `M` machine-wide installs and the + WiX pin, `E` the elevated half, `C` per-user configuration and Pillow. A name + it does not recognise stops the run before anything happens — a mistyped + `-Skip` that was quietly ignored would install exactly what you meant to leave + out. +- **With no console to ask on** — piped, scheduled, a non-interactive `ssh` + command — it prints the plan and stops with exit code 2 unless given `-Yes`. + It never assumes yes. +- The two halves take `-Only`, `-Skip` and `-Yes` too, and when run on their own + print their part of the plan and ask before doing anything, e.g. + `.\setup-windows-with-uac.ps1 -Only VisualStudio,Wdk`. From the batch file + they are handed exactly what the plan left selected, with `-Yes`, since you + were asked already. With nothing selected for a half it is not started — so + no UAC prompt is raised for an elevated half with no steps. +- Every step is declared once, in `setup-windows-steps.ps1`: its name, what it + does, and — with the reason — whether it is x64-only (`-X64Only`) or needs an + administrator (`-Admin`). The planner, the batch file and both halves read + that one list and check each other: a step the list does not know is refused + rather than run, and a selected step that nothing implements fails the run + rather than quietly not happening. Adding a step means a row there plus the + code that does it, gated on the same name. +- Some steps build on others — the WiX pin needs the .NET SDK, the Clang and XP + toolsets need Visual Studio. The plan notes it when you switch the + prerequisite off, but does not stop you: it may well be installed already. + +## Re-running + +Every step checks before it acts, so running `setup-windows.bat` again on a +provisioned box installs only what is missing or has a newer version out. +Nothing is reinstalled at the version it already has. Where a check cannot +decide — no network, a version string it does not recognise — it installs as +before rather than skipping: a failed check must never be why something is left +missing or out of date. + +| step | how it decides | +| --- | --- | +| winget packages | `winget install` on a package winget can see is installed upgrades it when a newer version exists and otherwise does nothing — winget's own default. `--force`, which would install regardless, is never used. | +| `ClaudeCode` | Anthropic's installer always downloads and reinstalls, so `setup-windows-current.ps1` asks first: is `%USERPROFILE%\.local\bin\claude.exe` the latest release — the version the installer's own endpoint names — and the build for this host? Only both skip it. | +| `NASM` | Its per-user fallback install registers nothing, so winget cannot see it and would rerun the installer every time. `setup-windows-current.ps1` compares that `nasm.exe` with the version winget offers. A machine-wide NASM is left to winget. | +| `Rustup` | Needs no help: rustup registers itself under the manifest's product code and rewrites its version when it updates itself, so winget sees what is really there. | +| `WiX` | Skipped when 5.0.2 is already in the .NET global tool store. | +| `OpenSshClient`, `OpenSshServer` | Installed only when the capability is not. | +| `Rsync` | The release's `.sha256` is fetched first and compared with the hash recorded when the files were installed (`rsync-windows.sha256`). The zip is downloaded only when they differ or a file is missing. | +| `VisualStudio`, `VsClang`, `VsWinXP` | `vswhere -requires` says whether the install already has every component of a pass. A pass with nothing to add does not run the installer, and with nothing to add anywhere the bootstrapper is not even downloaded. The script never updates Visual Studio itself; the Visual Studio Installer does that. | +| `Wdk`, `Wpt` | Skipped when the kit, or the toolkit, is already there. | +| `BinSkim` | Asks NuGet for the newest stable version before downloading a package well over 100 MB; `nupkg-version.txt` beside the tool records what is installed. | +| `RustToolchain` | `rustup toolchain install` updates stable when there is a newer one and otherwise reports it unchanged. | +| `Pillow` | `pip install` leaves an installed Pillow alone. It does not upgrade it either. | +| the rest | Configuration: `PATH` entries are added only when missing, and settings are written with the values they already have. | + ## Notes - The elevated half writes a transcript to `setup-windows-uac.log` next to the @@ -76,15 +180,12 @@ on each: x64 is the full workstation, ARM64 a lean single-compiler build box. Se fast `ssh.exe` has to exist before the git config step can prefer it. The non-elevated half still runs even when the elevated one failed; it just falls back. Re-run `setup-windows-no-uac.ps1` on its own at any time to re-pick. -- All three scripts are idempotent — re-running skips anything already installed. - BinSkim in particular checks NuGet for the newest stable version *before* - downloading: the package is a self-contained .NET build well over 100 MB, and - re-provisioning an up-to-date box should not pay for it. The installed version - is tracked in `nupkg-version.txt` beside the tool. +- All the scripts are safe to re-run: nothing is reinstalled at the version it + already has. See [Re-running](#re-running) for how each step decides. - `setup-windows-no-uac.ps1` runs its steps independently: one failing warns and the rest still run, and it exits 1 if any did. The `.bat` reports that and - carries on to the elevated half, which is the part worth the UAC prompt. Use - `-Skip` to re-run a subset, e.g. `.\setup-windows-no-uac.ps1 -Skip BinSkim`. + carries on. Use `-Only` / `-Skip` to re-run a subset, e.g. + `.\setup-windows-no-uac.ps1 -Skip BinSkim`. Run it **non-elevated**: it writes per-user state (the `HKCU` `PATH`, the `.gitconfig` under `%USERPROFILE%`), so an elevated run would configure the administrator's profile instead. It warns if you do. @@ -231,6 +332,46 @@ on each: x64 is the full workstation, ARM64 a lean single-compiler build box. Se from that descriptor rather than deleting the value, which also carries entries for SYSTEM, Administrators and two service accounts. +- **Claude Code is not installed through winget.** Recent releases of + `Anthropic.ClaudeCode` are broken through that route, so the batch file runs + Anthropic's own installer, `irm https://claude.ai/install.ps1 | iex`: it + checks the download against the release manifest's SHA-256 and puts `claude` + in `%USERPROFILE%\.local\bin`, per-user. Two things are fixed up around it. + The script picks its ARM64 or x64 build from `$env:PROCESSOR_ARCHITECTURE` + alone, which reads `AMD64` in an emulated x64 shell on ARM64 (see + [Detecting the architecture](#detecting-the-architecture)), so the batch file + sets that variable from the registry's answer for the installer's process. + And the script refuses a 32-bit PowerShell, which is what `powershell` is from a + 32-bit `cmd.exe`, so it is run through `%SystemRoot%\Sysnative` when that + exists. A copy left over from the winget route can shadow the new one on the + `PATH`; the step warns if it finds one (`winget uninstall + Anthropic.ClaudeCode` removes it), and the architecture audit shows which + `claude.exe` wins. +- **Rust.** winget installs rustup (`Rustlang.Rustup`, per-user, under + `%USERPROFILE%\.cargo` and `.rustup`). Its silent install is + `rustup-init -y -q`, which already sets up a stable toolchain for whatever + host rustup detects. The `RustToolchain` step in `setup-windows-no-uac.ps1` + then pins the answer: `stable-x86_64-pc-windows-msvc` on x64, + `stable-aarch64-pc-windows-msvc` on ARM64, installed, made the default, and set + as rustup's default host. That matters on ARM64, where an x64 rustup — + an older install, or one started from an emulated shell — would otherwise + quietly set up an emulated compiler. The `-msvc` toolchains link with the + Visual Studio the elevated half installs, but only need it to *build*: + installing rustup first is fine, and with `-y` rustup-init just warns that + the prerequisites are missing rather than installing a Visual Studio of its + own. Re-running the step updates stable. It also resets the default to + stable, so leave `RustToolchain` out of the plan if you have chosen another + default on purpose. The architecture audit reports the compiler *inside* the + toolchain: `.cargo\bin\rustc.exe` is a rustup proxy, so its architecture is + rustup's, not the compiler's. +- **`adb` and `fastboot`** come from `Google.PlatformTools`, a portable zip that + winget unpacks under `%LOCALAPPDATA%\Microsoft\WinGet\Packages` and puts on the + user `PATH` (`adb.exe` needs the `AdbWinApi` DLLs beside it). The manifest + calls it x64, but every binary in it is 32-bit **x86** — checked on r37.0.1 — + and there is no ARM64 build. It runs under WOW64 on x64 and under x86 + emulation on ARM64, which every Windows on Arm has; the audit lists it as + expected. It stays on ARM64 for the same reason `iperf3` does: it is bound by + the link to the device, not by the CPU. - The scripts were extracted from a native Windows project, so the component selection is tuned for that: Spectre-mitigated runtimes, the v141/XP toolset, and driver-kit headers. Trim the component lists in the `.ps1` if you don't @@ -242,7 +383,8 @@ All three scripts detect the host architecture and adapt. Nothing needs a flag run `setup-windows.bat` exactly as on x64. **The two architectures deliberately install different things.** x64 is the full -workstation; ARM64 is provisioned as a lean, single-compiler build box. See +workstation; ARM64 is provisioned as a lean build box with a single C/C++ +compiler. See [ARM64 installs less, on purpose](#arm64-installs-less-on-purpose) below for the list and the reasoning. @@ -314,9 +456,10 @@ duplicates it or that exists only as an x64 binary.** | Windows Driver Kit | `setup-windows-with-uac.ps1` | see below | | BinSkim | `setup-windows-no-uac.ps1` | the NuGet package publishes `win-x64` only | -x64 keeps every one of them. `:x64only` at the bottom of `setup-windows.bat` is -the single place that decision is applied, so each skip prints its reason into -the run log rather than leaving a silent gap. +x64 keeps every one of them. The decision is made in one place, +`setup-windows-steps.ps1` (`-X64Only`, with the reason): the plan shows each as +`[-]` with that reason, and the run log repeats it where the step would have +run, rather than leaving a silent gap. **`clang-cl` is dropped by choice, not by limitation.** A native ARM64 `clang-cl` *is* offered on Arm and did work here before the split. Two things establish @@ -371,12 +514,19 @@ agrees with itself about. Watch the `PATH` there: a bare `clang-cl` may resolve to upstream while CMake's `-T ClangCL` keeps using VS's, so pass a full `-DCMAKE_CXX_COMPILER` when it matters. +**Rust is on both boxes, and native on ARM64.** It is a different language +rather than a second C/C++ compiler, so it does not bend the one-compiler rule, +and its `-msvc` toolchain links through MSVC's own `link.exe`. The ARM64 box +gets `stable-aarch64-pc-windows-msvc`; to build x64 Rust code from there, add +the x64 *target* (`rustup target add x86_64-pc-windows-msvc`) rather than an x64 +host toolchain, which would run emulated. + What you end up with on ARM64: | | On ARM64 | | --- | --- | -| **Native ARM64** | Git, Python, .NET SDK, PowerShell, VS Code, Windows Terminal, Sysinternals, Claude Code, CMake, Ninja, the VS installer, **MSVC**, MSBuild, the SDK tools (`rc`, `signtool`), the in-box OpenSSH client, and the Windows Performance Toolkit. | -| **Emulated x64** | `iperf3` (network-bound) and the ADK *installer* (the toolkit it lays down is native). | +| **Native ARM64** | Git, Python, .NET SDK, PowerShell, VS Code, Windows Terminal, Sysinternals, Claude Code, CMake, Ninja, the VS installer, **MSVC**, MSBuild, the SDK tools (`rc`, `signtool`), rustup and **Rust** (`stable-aarch64-pc-windows-msvc`), the in-box OpenSSH client, and the Windows Performance Toolkit. | +| **Emulated** | `iperf3` (x64; network-bound), `adb` and `fastboot` (x86 — Google ships no other Windows build; bound by the USB/TCP link to the device), and the ADK *installer* (x64; the toolkit it lays down is native). | | **Emulated, minor** | `py.exe` (python.org ships the launcher shim as x86; it execs the native `python.exe`) and `vswhere.exe` (Microsoft ships x86 only; runs once). | | **Not installed** | Brave, WinMerge, upstream LLVM, NASM, Android GPU Inspector, OpenCppCoverage, BinSkim, VS `clang-cl`, and the WDK — see the table above. | | **Unavailable** | VirtualBox, the Windows 7 x86 test VM, Intel VTune, and the `v141` / Windows XP targeting toolset. | @@ -427,7 +577,8 @@ packages that sit in the "per-user" section but are machine-wide installers. They used to fail on every run with opaque codes — `exit 5` (`ERROR_ACCESS_DENIED`) for the .NET SDK, MSI `1603` for CMake and Android GPU Inspector, `exit 1` for OpenCppCoverage, whose installer self-elevates. They are -now skipped with one clear line instead. None blocks a build: the .NET SDK is +now shown as unavailable in the plan, with the reason, and skipped with one +clear line each. None blocks a build: the .NET SDK is only there for the WiX MSI tooling, CMake also ships inside Visual Studio, AGI profiles Android devices, and OpenCppCoverage cannot instrument ARM64 binaries anyway. diff --git a/setup-windows-current.ps1 b/setup-windows-current.ps1 new file mode 100644 index 0000000..be07ef7 --- /dev/null +++ b/setup-windows-current.ps1 @@ -0,0 +1,121 @@ +<# + setup-windows-current.ps1 - is this step's program already current? + + setup-windows.bat asks this before the two installs that would otherwise run + in full on every re-run, because neither the installer nor winget can tell + that what is on the box is already the latest: + + ClaudeCode Anthropic's installer script always downloads the release + (a couple of hundred MB) and reinstalls it; it has no "already + current" check of its own. + NASM its installer falls back to a per-user directory when it cannot + write to Program Files, and registers nothing there - so winget + cannot see it, and reruns the installer every time. + + Exit code 0 means already current: skip the install. Anything else means go + ahead as before - and that includes this script failing (no network, a version + string it does not recognise). A check that cannot decide must never be the + reason something is left missing or out of date, so its fallback is the old + behaviour, not a skip. + + Every other install in the setup was already safe to re-run; the README's + "Re-running" note lists how each one decides. +#> +param( + [Parameter(Mandatory)] [ValidateSet('ClaudeCode', 'NASM')] [string] $Step, + # As setup-windows.bat worked it out, from the registry: AMD64, ARM64 or x86. + [string] $HostArch +) + +# Continue, not Stop: this runs native tools that may write to stderr, and every +# decision below is made explicitly. Anything that does throw lands in the catch +# at the bottom and becomes "go ahead and install". +$ErrorActionPreference = 'Continue' + +function Say([string] $Msg) { Write-Host "[setup-windows] $Msg" } + +function Get-PEMachine([string] $Path) { + # The machine type in the COFF header, as Get-PEMachine in + # setup-windows-no-uac.ps1 reads it: what the file will actually run as. + $fs = [IO.File]::OpenRead($Path) + $br = New-Object IO.BinaryReader($fs) + try { + $fs.Seek(0x3c, 'Begin') | Out-Null + $fs.Seek($br.ReadInt32() + 4, 'Begin') | Out-Null + switch ($br.ReadUInt16()) { 0x8664 { 'x64' } 0xAA64 { 'ARM64' } 0x014c { 'x86' } default { 'unknown' } } + } finally { $br.Close(); $fs.Close() } +} + +function Get-Version([string] $Text) { + # The first dotted version number in some output, or $null. + $m = [regex]::Match("$Text", '\d+(\.\d+)+') + if ($m.Success) { $m.Value } else { $null } +} + +function Test-ClaudeCodeCurrent { + # Current means: the native install's claude.exe is the latest release AND + # the build for this host. The second half matters on ARM64, where an install + # run from an emulated shell got the x64 build - the same version, but the + # wrong binary, and worth replacing. + # + # The launcher in .local\bin is the full binary, not a shim, so its PE header + # is the build that runs. A copy from the old winget route is deliberately + # NOT consulted: the batch file reports that one separately. + $exe = Join-Path $env:USERPROFILE '.local\bin\claude.exe' + if (-not (Test-Path $exe)) { Say 'ClaudeCode: no native install yet.'; return $false } + + $have = Get-Version (& $exe --version 2>$null | Out-String) + # The same endpoint the installer asks, so "latest" means the same thing. + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + $latest = Get-Version (Invoke-RestMethod -Uri 'https://downloads.claude.ai/claude-code-releases/latest' -ErrorAction Stop) + if (-not $have -or -not $latest) { + Say "ClaudeCode: could not compare versions (installed '$have', latest '$latest'); running the installer." + return $false + } + $want = if ($HostArch -eq 'ARM64') { 'ARM64' } else { 'x64' } + $mach = Get-PEMachine $exe + if ($have -ne $latest) { + Say "ClaudeCode: $have installed, $latest available; updating." + return $false + } + if ($mach -ne $want) { + Say "ClaudeCode: $have is current, but it is the $mach build on an $HostArch host; reinstalling for this host." + return $false + } + Say "ClaudeCode: $have is the latest release, $mach build; not reinstalling." + $true +} + +function Test-NasmCurrent { + # Only the per-user fallback install needs this. A machine-wide NASM has an + # Add/Remove Programs entry, so winget sees it and upgrades it only when + # there is something newer - that case is left to winget. + $exe = Join-Path $env:LOCALAPPDATA 'bin\NASM\nasm.exe' + if (-not (Test-Path $exe)) { return $false } + + $have = Get-Version (& $exe -v 2>$null | Out-String) + $show = winget show --id NASM.NASM --exact --accept-source-agreements --disable-interactivity 2>$null | Out-String + $m = [regex]::Match($show, '(?m)^\s*Version:\s*(\S+)') + $latest = if ($m.Success) { $m.Groups[1].Value } else { $null } + if (-not $have -or -not $latest) { + Say "NASM: could not compare versions (installed '$have', winget '$latest'); leaving it to winget." + return $false + } + if ($have -ne $latest) { + Say "NASM: $have installed per-user, $latest available; updating." + return $false + } + Say "NASM: $have (per-user, which winget cannot see) is the version winget offers; not reinstalling." + $true +} + +try { + $current = switch ($Step) { + 'ClaudeCode' { Test-ClaudeCodeCurrent } + 'NASM' { Test-NasmCurrent } + } +} catch { + Say "${Step}: the version check failed ($($_.Exception.Message)); installing as usual." + $current = $false +} +exit $(if ($current -eq $true) { 0 } else { 1 }) diff --git a/setup-windows-no-uac.ps1 b/setup-windows-no-uac.ps1 index 50cdd89..c44bb1a 100644 --- a/setup-windows-no-uac.ps1 +++ b/setup-windows-no-uac.ps1 @@ -21,25 +21,35 @@ - Global git identity, and core.sshCommand pointed at a Win32-OpenSSH client so git shares the Windows ssh-agent: the fast ssh.exe the elevated half unpacks beside rsync.exe if it is there, the in-box one otherwise + - Rust's stable MSVC toolchain for this host - x86_64 on x64, aarch64 on + ARM64 - installed through rustup and made the default - An architecture audit: the real PE machine type of every tool this box provisions, resolved the way a shell would. Informational, never fatal. Anything running emulated without a listed reason is called out. FILL IN $GitUserName / $GitUserEmail below before the first run. + The steps are the 'user' rows of setup-windows-steps.ps1. The batch file runs + this with -Only -Yes, having already asked. + Run by hand, it lists what it will do and asks before doing any of it; -Only + and -Skip pick steps, and -Yes skips the question. + Steps are independent: one failing warns and the rest still run. The exit code - is 1 if any step failed, 0 otherwise. + is 1 if any step failed, 2 if there was no console to ask on (re-run with + -Yes), 0 otherwise. #> [CmdletBinding()] param( - # Skip individual steps. Note that `powershell -File` cannot pass more than - # one value to an array parameter (neither comma- nor space-separated), so - # for several, dot-call the script or use -Command: + # Steps to take (-Only) or leave out (-Skip), by the names the plan shows. + # For several, separate them with commas. `powershell -File` hands "A,B" to + # an array parameter as ONE string, so it is split here and either way works: # .\setup-windows-no-uac.ps1 -Skip BinSkim,GitConfig - # powershell -File .\setup-windows-no-uac.ps1 -Skip BinSkim - [ValidateSet('WinMerge', 'VsWhere', 'BinSkim', 'GitConfig', 'NinjaPath', 'LlvmPath', 'ArchAudit')] - [string[]] $Skip = @() + # powershell -File .\setup-windows-no-uac.ps1 -Skip BinSkim,GitConfig + [string[]] $Only, + [string[]] $Skip = @(), + # Do not ask first. setup-windows.bat passes it, having asked already. + [switch] $Yes ) $ErrorActionPreference = 'Stop' @@ -59,21 +69,18 @@ if (-not $rawArch) { $rawArch = [Runtime.InteropServices.RuntimeInformation]::OS $IsArm64 = ($rawArch -eq 'ARM64') $HostArch = if ($IsArm64) { 'Arm64' } elseif ($rawArch -eq 'AMD64') { 'X64' } else { $rawArch } -# Steps that do not run on ARM64, with the reason printed in place of the step. +# Steps that do not run on ARM64 - WinMergePath, BinSkim and LlvmPath - are +# marked -X64Only, with the reason, in setup-windows-steps.ps1. The plan printed +# before anything runs shows each of them with that reason. # -# The ARM64 machine is provisioned as a single-compiler build box - see the -# x64-only set in setup-windows.bat - so the packages these three steps wire up -# are not installed there. Skipping the step rather than letting it find nothing -# matters: Install-BinSkim would happily download and PATH the x64 build, and -# Add-LlvmToUserPath is the step that put an emulated-adjacent toolchain on the -# PATH ahead of MSVC in the first place. +# The ARM64 machine is provisioned as a single-compiler build box, so the +# packages these three steps wire up are not installed there. Skipping the step +# rather than letting it find nothing matters: Install-BinSkim would happily +# download and PATH the x64 build, and Add-LlvmToUserPath is the step that put an +# emulated-adjacent toolchain on the PATH ahead of MSVC in the first place. # -# -Skip still works on top of this; it can only subtract. -$Arm64Dropped = [ordered]@{ - WinMerge = 'not installed on ARM64 - the only build an unelevated winget can fetch is the emulated x64 one' - BinSkim = 'not installed on ARM64 - the NuGet package publishes win-x64 only' - LlvmPath = 'not installed on ARM64 - this box carries MSVC alone' -} +# -Only and -Skip work on top of this; they can only subtract. +. (Join-Path $PSScriptRoot 'setup-windows-steps.ps1') # --- Global git identity: FILL THESE IN BEFORE RUNNING --- # Left empty, Set-GlobalGitConfig skips the identity and says so, rather than @@ -92,6 +99,17 @@ $BinSkimDir = Join-Path $env:LOCALAPPDATA 'Programs\BinSkim' # setup-windows-with-uac.ps1 if you move the install. $RsyncDir = 'C:\Tools\rsync' +# Where rustup keeps itself (.cargo) and its toolchains (.rustup): rustup-init's +# own defaults, unless CARGO_HOME / RUSTUP_HOME say otherwise. The host triple is +# the one Rust toolchain this box is set up with - see Set-RustDefaultToolchain. +$CargoHome = if ($env:CARGO_HOME) { $env:CARGO_HOME } else { Join-Path $env:USERPROFILE '.cargo' } +$RustupHome = if ($env:RUSTUP_HOME) { $env:RUSTUP_HOME } else { Join-Path $env:USERPROFILE '.rustup' } +$RustHost = switch ($HostArch) { + 'Arm64' { 'aarch64-pc-windows-msvc' } + 'X64' { 'x86_64-pc-windows-msvc' } + default { $null } +} + function Write-Step([string]$Msg) { Write-Host "`n==> $Msg" -ForegroundColor Cyan } @@ -499,6 +517,100 @@ function Add-LlvmToUserPath { Add-ToUserPath $dir } +function Get-RustupPath { + # winget installed rustup moments ago, but this process inherited its PATH + # before rustup-init added .cargo\bin to it, so Get-Command can miss it on a + # first run. Prefer one already on the PATH, then the cargo home. + $onPath = Get-Command rustup.exe -ErrorAction SilentlyContinue + if ($onPath) { return $onPath.Source } + $exe = Join-Path $CargoHome 'bin\rustup.exe' + if (Test-Path $exe) { return $exe } + $null +} + +function Set-RustDefaultToolchain { + # Install Rust's stable MSVC toolchain for THIS host and make it the default: + # stable-x86_64-pc-windows-msvc on x64, stable-aarch64-pc-windows-msvc on + # ARM64. + # + # winget's rustup install mostly does this already - it runs + # `rustup-init -y -q`, which installs stable for the host rustup detects - so + # on a fresh box this step largely confirms it. It exists to make the answer + # explicit rather than inherited. rustup takes its default host from its own + # build, so an x64 rustup on an ARM64 box - an install from before the + # manifest had an arm64 rustup-init, or one started from an emulated x64 + # shell - quietly sets up an x86_64 host and an emulated compiler. Naming the + # triple pins both the toolchain installed here and, through + # `set default-host`, what a bare `stable` means to rustup from then on. + # + # MSVC rather than GNU: the -msvc toolchains link with link.exe and the + # Windows SDK from the Visual Studio the elevated half installs - including + # the ARM64 build tools an aarch64 host links with. Those are needed to BUILD, + # not to install, so this step does not depend on the elevated half. + # + # Re-runs are cheap: `toolchain install` on an installed toolchain updates it + # to the current stable, or reports it unchanged. Setting the default each + # time is the point of the step - a default you changed by hand (to nightly, + # say) is put back to stable, so leave the step out of the plan to keep it. + Write-Step 'Rust: stable MSVC toolchain as the default' + if (-not $RustHost) { + Write-Warning "No Rust host toolchain is set up for host architecture '$HostArch'; skipping." + return + } + $rustup = Get-RustupPath + if (-not $rustup) { + Write-Warning 'rustup not found. Install it with: winget install Rustlang.Rustup' + return + } + Write-Host " using $rustup ($(Get-PEMachine $rustup))" + + # EAP back to Continue for the calls: rustup reports progress on STDERR, and + # under $ErrorActionPreference = 'Stop' a native command's stderr can become + # a terminating error. Judged by the exit code instead - cleared first, as + # for ssh.exe above, so an exe that cannot start at all does not inherit the + # last one's 0. + $toolchain = "stable-$RustHost" + $calls = @( + @('set', 'default-host', $RustHost), + @('toolchain', 'install', $toolchain), + @('default', $toolchain) + ) + $prevEap = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + try { + foreach ($call in $calls) { + $global:LASTEXITCODE = $null + & $rustup @call + if ($LASTEXITCODE -ne 0) { + $why = if ($null -eq $LASTEXITCODE) { 'it would not start' } else { "exit $LASTEXITCODE" } + throw "rustup $($call -join ' ') failed ($why)" + } + } + } finally { + $ErrorActionPreference = $prevEap + } + + # Report the compiler itself. .cargo\bin\rustc.exe is a rustup proxy - a copy + # of rustup.exe - so its architecture says nothing about the toolchain's. + $rustc = Join-Path $RustupHome "toolchains\$toolchain\bin\rustc.exe" + if (Test-Path $rustc) { + Write-Host " default: $toolchain - $(& $rustc -V) ($(Get-PEMachine $rustc))" + } else { + Write-Warning "rustup reported success, but $rustc is not there." + } + + # An x86_64 host toolchain left on an ARM64 box - from before this step + # pinned the triple - is an emulated compiler that is no longer the default. + # Named rather than removed; whether it goes is your call. Building x64 code + # from here wants the x64 TARGET on the native toolchain, not an x64 host. + $x64Stable = Join-Path $RustupHome 'toolchains\stable-x86_64-pc-windows-msvc' + if ($IsArm64 -and (Test-Path $x64Stable)) { + Write-Host ' Also installed: stable-x86_64-pc-windows-msvc, an x64 compiler that runs emulated here.' -ForegroundColor Yellow + Write-Host " Remove it with 'rustup toolchain uninstall stable-x86_64-pc-windows-msvc'; to build" -ForegroundColor Yellow + Write-Host " x64 code from this box, use 'rustup target add x86_64-pc-windows-msvc' instead." -ForegroundColor Yellow + } +} + function Get-PEMachine { # Architecture of a PE, read straight from the COFF header: the 2 bytes at # the e_lfanew offset + 4. Cheap, and it answers the only question that @@ -584,6 +696,7 @@ function Invoke-ArchAudit { 'iperf3.exe' = 'no ARM64 build published; network-bound anyway' 'py.exe' = 'python.org ships the launcher shim as x86; it execs the native python.exe' 'vswhere.exe' = 'Microsoft ships x86 only; runs once per script' + 'adb.exe' = 'Google ships platform-tools as x86 only; bound by the USB/TCP link' } # Fixable cases: a native build DOES exist, something just resolved ahead of @@ -595,14 +708,16 @@ function Invoke-ArchAudit { # an emulated one is paid over and over rather than once. The NinjaPath step # puts the native copy first; this is the check that it worked. # - # Only two left, and both are for tools ARM64 still installs. The clang-cl and - # WinMerge remedies were removed with their rows: advising an install that the + # All of these are for tools ARM64 still installs. The clang-cl and WinMerge + # remedies were removed with their rows: advising an install that the # x64-only set has just declined to do would be the audit arguing with the # provisioning. $Remedies = @{ 'ninja.exe' = 'Visual Studio bundles an x64 ninja. Install the native one (winget install Ninja-build.Ninja) and re-run - the NinjaPath step puts its directory at the front of the user PATH.' 'cmake.exe' = 'Install the native build with: winget install Kitware.CMake (its MSI is machine-scope, so it needs an administrator).' 'rsync.exe' = 'Left over from before the release published an arm64 asset. Re-run setup-windows-with-uac.ps1 as an administrator to replace it, and the ARM64 ssh.exe beside it, with the native build.' + 'claude.exe' = 'Not the build for this host. Re-run setup-windows.bat -Only ClaudeCode, which installs the right one. If the path above is under WinGet, it is the old winget copy: winget uninstall Anthropic.ClaudeCode.' + 'rustc.exe' = 'Only the x64 host toolchain is installed. Run .\setup-windows-no-uac.ps1 -Only RustToolchain: it installs stable for this host and makes it the default.' } $vs = $null @@ -613,7 +728,10 @@ function Invoke-ArchAudit { # name -> extra candidate paths searched when the name is not on the PATH. $targets = [ordered]@{ - 'git.exe' = @("$env:LOCALAPPDATA\Programs\Git\cmd\git.exe", (Join-Path $env:ProgramFiles 'Git\cmd\git.exe')) + # The native installer's launcher, then the old winget route's link. + # PATH-first like the rest, so this reports whichever claude wins. + 'claude.exe' = @((Join-Path $env:USERPROFILE '.local\bin\claude.exe'), (Join-Path $env:LOCALAPPDATA 'Microsoft\WinGet\Links\claude.exe')) + 'git.exe' =@("$env:LOCALAPPDATA\Programs\Git\cmd\git.exe", (Join-Path $env:ProgramFiles 'Git\cmd\git.exe')) 'python.exe' = @() # Both homes of the launcher: C:\Windows for an all-users install, the # per-user Launcher directory otherwise. Often neither - it is a separate @@ -630,6 +748,17 @@ function Invoke-ArchAudit { (Join-Path $env:LOCALAPPDATA 'Programs\LLVM\bin\clang-cl.exe') (Join-Path ([Environment]::GetFolderPath('MyDocuments')) 'LLVM\bin\clang-cl.exe') ) + # rustup itself, then the compiler the stable toolchain really runs. The + # rustc.exe in .cargo\bin that a shell would find is a rustup proxy - a + # copy of rustup.exe - so its architecture is rustup's, not the + # toolchain's. The labelled row looks inside the toolchain instead, + # native host first, so an x64 toolchain on ARM64 only shows up when it + # is the one there is. + 'rustup.exe' = @((Join-Path $CargoHome 'bin\rustup.exe')) + 'rustc.exe (stable)' = @( + $(if ($RustHost) { Join-Path $RustupHome "toolchains\stable-$RustHost\bin\rustc.exe" }) + (Join-Path $RustupHome 'toolchains\stable-x86_64-pc-windows-msvc\bin\rustc.exe') + ) 'dotnet.exe' = @((Join-Path $env:ProgramFiles 'dotnet\dotnet.exe')) 'WinMergeU.exe' = @((Join-Path $env:ProgramFiles 'WinMerge\WinMergeU.exe'), (Join-Path ${env:ProgramFiles(x86)} 'WinMerge\WinMergeU.exe')) 'BinSkim.exe' = @((Join-Path $BinSkimDir 'BinSkim.exe')) @@ -648,6 +777,11 @@ function Invoke-ArchAudit { (Join-Path ${env:ProgramFiles(x86)} 'NASM\nasm.exe') ) 'iperf3.exe' = @() + # A winget portable package: unpacked under WinGet\Packages, with that + # directory put on the user PATH. This finds it should the PATH entry + # have gone missing. + 'adb.exe' = @(Resolve-Path (Join-Path $env:LOCALAPPDATA 'Microsoft\WinGet\Packages\Google.PlatformTools_*\platform-tools\adb.exe') -ErrorAction SilentlyContinue | + ForEach-Object { $_.Path }) 'OpenCppCoverage.exe' = @((Join-Path $env:ProgramFiles 'OpenCppCoverage\OpenCppCoverage.exe'), (Join-Path ${env:ProgramFiles(x86)} 'OpenCppCoverage\OpenCppCoverage.exe')) 'vswhere.exe' = @($vsWhereExe) 'xperf.exe' = @((Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\Windows Performance Toolkit\xperf.exe')) @@ -733,34 +867,61 @@ if (([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]:: Write-Warning 'Running elevated. The user PATH and .gitconfig changes below will apply to the administrator profile, not yours.' } -$steps = [ordered]@{ - WinMerge = { Add-WinMergeToUserPath } - VsWhere = { Add-VsWhereToUserPath } - BinSkim = { Install-BinSkim } - GitConfig = { Set-GlobalGitConfig } - NinjaPath = { Set-NativeNinjaFirst } - LlvmPath = { Add-LlvmToUserPath } - # Last on purpose: it reports on what the steps above (and the winget installs - # in setup-windows.bat) actually put on the box. - ArchAudit = { Invoke-ArchAudit } +# What each step runs. The ORDER comes from setup-windows-steps.ps1, which lists +# ArchAudit last on purpose: it reports on what the steps before it (and the +# winget installs in setup-windows.bat) actually put on the box. +$steps = @{ + WinMergePath = { Add-WinMergeToUserPath } + VsWhere = { Add-VsWhereToUserPath } + BinSkim = { Install-BinSkim } + GitConfig = { Set-GlobalGitConfig } + NinjaPath = { Set-NativeNinjaFirst } + LlvmPath = { Add-LlvmToUserPath } + RustToolchain = { Set-RustDefaultToolchain } + ArchAudit = { Invoke-ArchAudit } +} + +# The catalog and the table above have to agree exactly. A catalog row with no +# code here would be shown in the plan and then never taken; an entry here with +# no row would never be shown, so never selected. Either is a bug in these +# scripts, not in the box, so it stops the run before anything happens. +$catalog = @(Get-SetupCatalog -IsArm64 $IsArm64 | Where-Object Runner -eq 'user') +$unbacked = @($catalog | Where-Object { -not $steps.ContainsKey($_.Id) } | ForEach-Object { $_.Id }) +$unlisted = @($steps.Keys | Where-Object { @($catalog.Id) -notcontains $_ }) +if ($unbacked -or $unlisted) { + $diff = @() + if ($unbacked) { $diff += "in the catalog but not here: $($unbacked -join ', ')" } + if ($unlisted) { $diff += "here but not in the catalog: $($unlisted -join ', ')" } + throw "setup-windows-steps.ps1 and this script disagree - $($diff -join '; ')." } +# 'WinMerge' was this step's name until the catalog put it beside the winget +# step of the same name; still accepted. +$plan = @(Resolve-SetupSelection -Steps $catalog ` + -Only $(if ($PSBoundParameters.ContainsKey('Only')) { $Only } else { $null }) -Skip $Skip ` + -Aliases @{ WinMerge = 'WinMergePath' } -IsArm64 $IsArm64) +Write-SetupPlan $plan 'Plan for setup-windows-no-uac.ps1' +$runCount = @($plan | Where-Object Selected).Count +if ($runCount -eq 0) { + Write-Host ' Nothing selected, so there is nothing to do.' + exit 0 +} +$ok = Confirm-SetupPlan -Yes:$Yes -Count $runCount +if ($ok -ne $true) { exit $(if ($null -eq $ok) { 2 } else { 0 }) } + $failed = @() -foreach ($name in $steps.Keys) { - if ($Skip -contains $name) { - Write-Host "`n==> $name (skipped)" -ForegroundColor DarkGray - continue - } - if ($IsArm64 -and $Arm64Dropped.Contains($name)) { - Write-Host "`n==> $name (skipped: $($Arm64Dropped[$name]))" -ForegroundColor DarkGray +foreach ($p in $plan) { + if (-not $p.Selected) { + # Only the skips with a reason; the plan above already listed the rest. + if ($p.Why) { Write-Host "`n==> $($p.Id) (skipped: $($p.Why))" -ForegroundColor DarkGray } continue } try { - & $steps[$name] + & $steps[$p.Id] } catch { # One broken step must not cost the others. Collect and report at the end. - Write-Warning "$name failed: $($_.Exception.Message)" - $failed += $name + Write-Warning "$($p.Id) failed: $($_.Exception.Message)" + $failed += $p.Id } } diff --git a/setup-windows-plan.ps1 b/setup-windows-plan.ps1 new file mode 100644 index 0000000..add6305 --- /dev/null +++ b/setup-windows-plan.ps1 @@ -0,0 +1,296 @@ +<# + setup-windows-plan.ps1 - say what setup-windows.bat is about to do, and let + you choose, before any of it happens. + + setup-windows.bat runs this first and passes its own arguments through: + + setup-windows.bat show the plan, ask, then run it + setup-windows.bat -WhatIf show the plan and stop + setup-windows.bat -Skip VirtualBox,Brave start with those switched off + setup-windows.bat -Only E just the elevated half + setup-windows.bat -Yes no questions, for unattended runs + setup-windows.bat /? this usage + + Interactively, the plan is a numbered list: switch steps on and off, then press + Enter to run what is left. Nothing is installed or changed until you do, and + with no console to ask on it stops after printing the plan unless given -Yes. + + Run it on its own to preview the plan for another kind of box: + + powershell -File .\setup-windows-plan.ps1 -WhatIf -HostArch ARM64 -Elevation NOLUA + + The steps, and where each one is unavailable and why, come from + setup-windows-steps.ps1; this script only presents them and records the + choice. It writes that to -OutFile as `set` lines for setup-windows.bat to + call: + PLAN_RUN, PLAN_RC 1 to go on; otherwise stop and exit with PLAN_RC + STEP_, WHY_ 1 or 0 for each step the batch file takes, and why not + PKG_ the winget package the plan showed for that step + UAC_STEPS, USER_STEPS comma lists for the two halves' -Only + No -OutFile (run on its own) means nothing is written. +#> +[CmdletBinding()] +param( + # Step names or group letters, comma-separated. -Only starts from nothing, + # -Skip from everything; both can be adjusted interactively afterwards. + [string[]] $Only, + [string[]] $Skip = @(), + # Take the plan as it stands, without asking. + [switch] $Yes, + # Show the plan and stop. + [switch] $WhatIf, + [switch] $Help, + # Supplied by setup-windows.bat, which has already worked both out. Detected + # here the same way when this script is run on its own. + [string] $HostArch, + [ValidateSet('ALREADY', 'PROMPT', 'NOLUA')] [string] $Elevation, + [string] $OutFile +) + +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'setup-windows-steps.ps1') + +function Show-Usage { + Write-Host @' + +setup-windows.bat [-WhatIf] [-Yes] [-Only ] [-Skip ] + + Shows everything it is about to do and lets you choose before doing any of it. + + -WhatIf show the plan for this machine, then stop + -Only start from just these steps + -Skip start from everything except these + -Yes do not ask: run the plan as it stands (for unattended runs) + + is a comma-separated list of step names and group letters, as the + plan shows them: U per-user installs, M machine-wide installs, + E the elevated half, C per-user configuration. + + setup-windows.bat -Skip VirtualBox,Brave + setup-windows.bat -Only E -Yes +'@ +} + +function ConvertTo-CmdValue([string] $Value) { + # Safe inside `set "NAME=..."` in a called .cmd: a quote would end the + # assignment early and % would expand. cmd's other special characters are + # inert inside the quotes, and :want prints the value with delayed expansion, + # which never re-parses it. ASCII only, as cmd reads the file in the OEM + # code page. + $Value -replace '"', "'" -replace '%', '%%' -replace '[^\x20-\x7E]', '?' +} + +function Write-Handoff { + param([object[]] $Plan, [bool] $Run, [int] $Rc = 0) + if (-not $OutFile) { return } + $lines = @( + '@rem Written by setup-windows-plan.ps1 for setup-windows.bat, which deletes it.' + "set `"PLAN_RUN=$([int]$Run)`"" + "set `"PLAN_RC=$Rc`"" + ) + # Every 'bat' step gets a line whether or not it runs: :want treats a step + # with no STEP_ line at all as a catalog/batch-file mismatch. WHY_ is empty + # for a step you switched off - the plan already said so, and repeating it + # at every call site just buries the skips that have a reason. + foreach ($p in ($Plan | Where-Object { $_.Step.Runner -eq 'bat' })) { + $lines += "set `"STEP_$($p.Id)=$([int]$p.Selected)`"" + $lines += "set `"WHY_$($p.Id)=$(ConvertTo-CmdValue $p.Why)`"" + if ($p.Step.Package) { $lines += "set `"PKG_$($p.Id)=$($p.Step.Package)`"" } + } + $uac = @($Plan | Where-Object { $_.Step.Runner -eq 'uac' -and $_.Selected } | ForEach-Object { $_.Id }) + $user = @($Plan | Where-Object { $_.Step.Runner -eq 'user' -and $_.Selected } | ForEach-Object { $_.Id }) + $lines += "set `"UAC_STEPS=$($uac -join ',')`"" + $lines += "set `"USER_STEPS=$($user -join ',')`"" + Set-Content -Path $OutFile -Value $lines -Encoding ascii +} + +function Show-Plan { + param([object[]] $Plan, [string[]] $Messages = @()) + $groups = Get-SetupGroups + Write-Host '' + Write-Host 'Setup plan - nothing has been done yet' -ForegroundColor Cyan + Write-Host " host: $ArchLabel" + Write-Host " elevation: $ElevLabel" + foreach ($g in $groups.Keys) { + $head = " $g $($groups[$g])" + if ($GroupNotes[$g]) { $head += " - $($GroupNotes[$g])" } + Write-Host '' + Write-Host $head -ForegroundColor Cyan + foreach ($p in ($Plan | Where-Object { $_.Step.Group -eq $g })) { + if ($p.Why) { + Write-Host (' [-] {0,-19} {1}' -f $p.Id, $p.Why) -ForegroundColor DarkGray + } elseif ($p.Selected) { + Write-Host (' [x] {0,2} {1,-19} {2}' -f $p.Number, $p.Id, $p.Step.Text) + } else { + Write-Host (' [ ] {0,2} {1,-19} {2}' -f $p.Number, $p.Id, $p.Step.Text) -ForegroundColor DarkGray + } + } + } + + # Switched-off prerequisites. Advisory: the thing may well be installed + # already, which is the usual reason for switching its install off. + Write-Host '' + foreach ($p in ($Plan | Where-Object Selected)) { + foreach ($need in $p.Step.Needs) { + $dep = $Plan | Where-Object { $_.Id -eq $need } | Select-Object -First 1 + if ($dep -and -not $dep.Selected -and -not $dep.Why) { + Write-Host " note: $($p.Id) builds on $need, which is switched off - fine if it is already installed." -ForegroundColor Yellow + } + } + } + $on = @($Plan | Where-Object Selected).Count + $can = @($Plan | Where-Object { -not $_.Why }).Count + Write-Host " $on of $can steps selected." + foreach ($m in $Messages) { Write-Host " $m" -ForegroundColor Yellow } +} + +function Update-Selection { + # Apply one line of input to the plan. Returns a message for each piece of it + # that could not be used, to show under the redrawn plan. + param([object[]] $Plan, [string] $Line) + $avail = @($Plan | Where-Object { -not $_.Why }) + $groups = @((Get-SetupGroups).Keys) + foreach ($tok in ($Line -split '[,\s]+' | Where-Object { $_ })) { + if ($tok -eq 'all' -or $tok -eq 'none') { + foreach ($p in $avail) { $p.Selected = ($tok -eq 'all') } + continue + } + if ($tok -match '^(\d+)(?:-(\d+))?$') { + $lo = [int]$Matches[1] + $hi = if ($Matches[2]) { [int]$Matches[2] } else { $lo } + if ($hi -lt $lo) { $lo, $hi = $hi, $lo } + $rows = @($avail | Where-Object { $_.Number -ge $lo -and $_.Number -le $hi }) + if (-not $rows) { "There is no step $tok."; continue } + foreach ($p in $rows) { $p.Selected = -not $p.Selected } + continue + } + $step = $Plan | Where-Object { $_.Id -eq $tok } | Select-Object -First 1 + if ($step) { + if ($step.Why) { "$($step.Id) cannot run here: $($step.Why)" } + else { $step.Selected = -not $step.Selected } + continue + } + if ($groups -contains $tok) { + # A group switches as a unit: anything on turns it all off, otherwise + # it all comes on. Toggling each member would just scramble it. + $members = @($avail | Where-Object { $_.Step.Group -eq $tok }) + if (-not $members) { "Nothing in group $($tok.ToUpper()) can run here."; continue } + $anyOn = @($members | Where-Object Selected).Count -gt 0 + foreach ($p in $members) { $p.Selected = -not $anyOn } + continue + } + "Not a step number, step name or group letter: $tok" + } +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +if ($Help) { + Show-Usage + Write-Handoff -Plan @() -Run $false + exit 0 +} + +# Same sources, in the same order, as setup-windows.bat - see the comments there +# for why the registry wins over the environment. +if (-not $HostArch) { + $HostArch = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment' -ErrorAction SilentlyContinue).PROCESSOR_ARCHITECTURE + if (-not $HostArch) { $HostArch = $env:PROCESSOR_ARCHITECTURE } +} +$IsArm64 = $HostArch -eq 'ARM64' +if (-not $Elevation) { + $id = [Security.Principal.WindowsIdentity]::GetCurrent() + $Elevation = if (([Security.Principal.WindowsPrincipal]$id).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + 'ALREADY' + } elseif ((Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' -ErrorAction SilentlyContinue).EnableLUA -eq 0) { + 'NOLUA' + } else { + 'PROMPT' + } +} + +$ArchLabel = switch ($HostArch) { + 'ARM64' { 'ARM64 - the lean build box, one C/C++ compiler; x64-only steps are marked [-]' } + 'AMD64' { 'x64 - the full workstation' } + default { $HostArch } +} +switch ($Elevation) { + 'ALREADY' { + $ElevLabel = 'already elevated, so the elevated half runs directly with no prompt' + $GroupNotes = @{ E = 'runs in this window' } + } + 'PROMPT' { + $ElevLabel = 'not elevated; expect a UAC prompt partway through, for the elevated half' + $GroupNotes = @{ M = 'each installer may ask for elevation itself'; E = 'one UAC prompt, runs in its own window' } + } + 'NOLUA' { + $ElevLabel = 'cannot elevate: UAC is off and this is not an administrator' + $GroupNotes = @{ M = 'these installers need an administrator'; E = 'not possible from this account' } + } +} + +$onlyGiven = $PSBoundParameters.ContainsKey('Only') +try { + $plan = @(Resolve-SetupSelection -Steps (Get-SetupCatalog -IsArm64 $IsArm64) ` + -Only $(if ($onlyGiven) { $Only } else { $null }) -Skip $Skip ` + -IsArm64 $IsArm64 -Elevation $Elevation) +} catch { + # A mistyped -Only / -Skip. Said plainly, and no hand-off file, so the batch + # file stops before doing anything. + Write-Host "`n $($_.Exception.Message)" -ForegroundColor Red + Write-Host ' Run setup-windows.bat -WhatIf to see the plan and its step names.' + exit 1 +} + +# Numbers for the steps that can run, in plan order. The ones that cannot get +# none - there is nothing to switch. +$n = 0 +foreach ($p in $plan) { + $num = $null + if (-not $p.Why) { $n++; $num = $n } + $p | Add-Member -NotePropertyName Number -NotePropertyValue $num +} + +Show-Plan $plan + +if ($WhatIf) { + Write-Host ' -WhatIf: stopping here. Nothing has been done.' + Write-Handoff -Plan $plan -Run $false + exit 0 +} + +if (-not $Yes) { + if ([Console]::IsInputRedirected) { + Write-Host ' No console to ask on, so nothing has been done. Re-run with -Yes to take' -ForegroundColor Yellow + Write-Host ' the selected steps unattended.' -ForegroundColor Yellow + Write-Handoff -Plan $plan -Run $false -Rc 2 + exit 0 + } + while ($true) { + Write-Host '' + Write-Host ' Enter runs the selected steps. To change them, type step numbers or ranges' + Write-Host ' (3 5-7), step names, or a group letter (U M E C) to switch them on or off;' + Write-Host " 'all' or 'none' for everything; 'q' to quit without doing anything." + $line = "$(Read-Host ' Enter to run, or change')".Trim() + if (-not $line) { break } + if ($line -match '^(q|quit|exit)$') { + Write-Host ' Nothing done.' + Write-Handoff -Plan $plan -Run $false + exit 0 + } + $messages = @(Update-Selection $plan $line) + Show-Plan $plan $messages + } +} + +$count = @($plan | Where-Object Selected).Count +if ($count -eq 0) { + Write-Host ' Nothing selected, so there is nothing to do.' + Write-Handoff -Plan $plan -Run $false + exit 0 +} +Write-Host '' +Write-Host " Going ahead with $count step(s). Anything not marked [x] will not be done." -ForegroundColor Green +Write-Handoff -Plan $plan -Run $true +exit 0 diff --git a/setup-windows-steps.ps1 b/setup-windows-steps.ps1 new file mode 100644 index 0000000..02c1515 --- /dev/null +++ b/setup-windows-steps.ps1 @@ -0,0 +1,261 @@ +<# + setup-windows-steps.ps1 - every step the Windows setup can take, in one list. + + Dot-sourced, never run: it defines functions and nothing else, so loading it + changes no state. It deliberately has no param block either - dot-sourcing + binds one in the CALLER's scope, which would reset the caller's own -Only and + -Skip to their defaults. + + Who reads it: + setup-windows-plan.ps1 shows it as the plan before anything happens, + and lets you choose; setup-windows.bat runs it first + setup-windows.bat takes the Runner 'bat' steps, as the planner hands + them over (it never reads this file itself) + setup-windows-with-uac.ps1 takes the 'uac' steps - the elevated half + setup-windows-no-uac.ps1 takes the 'user' steps - per-user configuration + + THIS IS THE ONE PLACE a step's availability is decided. -X64Only, with its + reason, keeps a step off ARM64; -Admin keeps it off an account that cannot + elevate. The planner shows both as "[-]" rows with the reason, the batch file + prints the same reason in the log where the step would have run, and the two + halves apply them again when run on their own. This replaces the :x64only + subroutine that used to sit at the bottom of setup-windows.bat and the + $Arm64Dropped table in setup-windows-no-uac.ps1. + + Adding a step: add a row below, in the order it runs, and gate the code that + does it on the same Id in the script its Runner names. The scripts check that + the two agree: an Id the catalog does not know is refused, and a catalog row + that nothing implements is reported rather than silently dropped. +#> + +function Get-SetupGroups { + # Display order is run order. The letter is what you type in the planner, or + # pass to -Only / -Skip, to take a whole group at once. + [ordered]@{ + U = 'Per-user installs (winget, except Claude Code)' + M = 'Machine-wide installs (winget), then the WiX .NET tool' + E = 'Elevated setup (setup-windows-with-uac.ps1)' + C = 'Per-user configuration (setup-windows-no-uac.ps1), then Pillow' + } +} + +function New-SetupStep { + param( + [Parameter(Mandatory, Position = 0)] [string] $Id, + [Parameter(Mandatory, Position = 1)] [ValidateSet('U', 'M', 'E', 'C')] [string] $Group, + # Which script takes the step. + [Parameter(Mandatory, Position = 2)] [ValidateSet('bat', 'uac', 'user')] [string] $Runner, + # What the plan says the step does. For a winget step it follows the + # package id, so it only needs to add what the id does not say. + [string] $Text, + [string] $Package, + # Not on ARM64, because . Shown in the plan and in the run log. + [string] $X64Only, + # Needs an account that can elevate: a machine-wide installer, or part + # of the elevated half. + [switch] $Admin, + # Steps this one builds on. Advisory only - the planner notes it when + # one of these is switched off, since it may already be installed. + [string[]] $Needs = @() + ) + $shown = if (-not $Package) { $Text } elseif ($Text) { "$Package - $Text" } else { $Package } + [pscustomobject]@{ + Id = $Id + Group = $Group + Runner = $Runner + Text = $shown + Package = $Package + X64Only = $X64Only + Admin = [bool]$Admin + Needs = $Needs + } +} + +function Get-SetupCatalog { + # Every step, in the order setup-windows.bat takes them. The long-form + # reasoning for each one stays in a comment beside the code that does it; + # the text here is what a person deciding needs to see. + param([bool] $IsArm64) + + $vs = if ($IsArm64) { + 'Visual Studio 2026 Community, C++: MSVC for ARM64 + x86/x64, SDK 26100 (several GB)' + } else { + 'Visual Studio 2022 Community, C++: MSVC for x86/x64 + ARM64, SDK 26100 (several GB)' + } + $rust = if ($IsArm64) { + 'Rust stable-aarch64-pc-windows-msvc (native), installed and made the default' + } else { + 'Rust stable-x86_64-pc-windows-msvc, installed and made the default' + } + + @( + # --- setup-windows.bat: per-user winget installs --- + # Not winget: recent Anthropic.ClaudeCode releases are broken through it. + New-SetupStep ClaudeCode U bat -Text 'Claude Code, native installer: irm https://claude.ai/install.ps1 | iex' + New-SetupStep Brave U bat -Package Brave.Brave ` + -X64Only 'no ARM64 build, and Edge is in-box and native' + New-SetupStep Git U bat -Package Git.Git + New-SetupStep PowerShell U bat -Package Microsoft.PowerShell + New-SetupStep ProcessExplorer U bat -Package Microsoft.Sysinternals.ProcessExplorer + New-SetupStep ProcessMonitor U bat -Package Microsoft.Sysinternals.ProcessMonitor + New-SetupStep SDelete U bat -Package Microsoft.Sysinternals.SDelete + New-SetupStep VSCode U bat -Package Microsoft.VisualStudioCode + New-SetupStep WindowsTerminal U bat -Package Microsoft.WindowsTerminal + New-SetupStep Python U bat -Package Python.Python.3.13 + New-SetupStep WinMerge U bat -Package WinMerge.WinMerge ` + -X64Only 'its ARM64 build is machine-scope only; unelevated winget gets the x64 one' + New-SetupStep Ninja U bat -Package Ninja-build.Ninja -Text 'native; Visual Studio bundles an x64 one' + New-SetupStep LLVM U bat -Package LLVM.LLVM -Text 'upstream Clang, a second compiler beside clang-cl' ` + -X64Only 'this box carries MSVC alone; no second Clang to cross-check' + New-SetupStep Rustup U bat -Package Rustlang.Rustup -Text 'the Rust toolchain manager, per-user; installs stable too' + New-SetupStep iPerf3 U bat -Package ar51an.iPerf3 + # On ARM64 too, like iperf3: an x86-only build, but bound by the link to + # the device, not the CPU. See the comment beside it in setup-windows.bat. + New-SetupStep PlatformTools U bat -Package Google.PlatformTools -Text 'Android SDK Platform-Tools: adb, fastboot (an x86 build)' + New-SetupStep NASM U bat -Package NASM.NASM ` + -X64Only 'x86/x64-only assembler; MSVC armasm64 is the native one' + New-SetupStep VirtualBox U bat -Package Oracle.VirtualBox -Text 'for the Windows 7 test VM' ` + -X64Only 'no ARM64 host build, so no Windows 7 x86 test VM either' + + # --- setup-windows.bat: machine-wide installers, despite running unelevated --- + New-SetupStep DotNetSdk M bat -Admin -Package Microsoft.DotNet.SDK.10 -Text 'for the WiX tooling' + New-SetupStep CMake M bat -Admin -Package Kitware.CMake -Text 'standalone, so a plain cmake works in any shell' + New-SetupStep AndroidGPUInspector M bat -Admin -Package Google.AndroidGPUInspector ` + -X64Only 'x64-only build, and it profiles Android devices, not this box' + New-SetupStep OpenCppCoverage M bat -Admin -Package OpenCppCoverage.OpenCppCoverage -Text 'native line coverage' ` + -X64Only 'it steps x86/x64 instructions, so it cannot cover an ARM64 binary' + New-SetupStep WiX M bat -Needs DotNetSdk ` + -Text 'WiX 5.0.2 as a .NET global tool, pinned: 6.0 and later carry a maintenance-fee EULA' + + # --- setup-windows-with-uac.ps1: the elevated half --- + New-SetupStep OpenSshClient E uac -Admin -Text 'OpenSSH Client capability (ssh.exe, and the libcrypto.dll rsync''s ssh.exe needs)' + New-SetupStep SshAgent E uac -Admin -Needs OpenSshClient -Text 'ssh-agent service: start automatically, and start it now' + New-SetupStep OpenSshServer E uac -Admin -Text 'OpenSSH Server: sshd automatic and running, inbound TCP 22 on all profiles' + New-SetupStep Rsync E uac -Admin -Needs OpenSshClient -Text 'rsync and its ssh.exe into C:\Tools\rsync, on the machine PATH' + New-SetupStep VisualStudio E uac -Admin -Text $vs + New-SetupStep VsClang E uac -Admin -Needs VisualStudio -Text 'Visual Studio Clang/LLVM toolset (clang-cl, for the ClangCL presets)' ` + -X64Only 'by choice: this box carries MSVC alone' + New-SetupStep VsWinXP E uac -Admin -Needs VisualStudio -Text 'Visual Studio v141 + Windows XP targeting toolset' ` + -X64Only 'v141 has no ARM64-hosted compiler; no XP targeting from Arm' + New-SetupStep Wdk E uac -Admin -Needs VisualStudio -Text 'Windows Driver Kit 10.0.26100 (pairs with SDK 26100)' ` + -X64Only 'user-mode box; this WDK will not install beside VS 2026''s SDK' + New-SetupStep Wpt E uac -Admin -Text 'Windows Performance Toolkit (xperf, wpr, WPA); the Windows ADK if it is missing' + New-SetupStep VTune E uac -Admin -Text 'Intel VTune Profiler: report whether it is installed (installs nothing)' + + # --- setup-windows-no-uac.ps1: per-user configuration --- + New-SetupStep WinMergePath C user -Needs WinMerge -Text 'WinMerge on the user PATH' ` + -X64Only 'WinMerge is not installed on ARM64' + New-SetupStep VsWhere C user -Needs VisualStudio -Text 'vswhere.exe on the user PATH' + New-SetupStep BinSkim C user -Text 'BinSkim into %LOCALAPPDATA%\Programs\BinSkim, on the user PATH (100+ MB)' ` + -X64Only 'the NuGet package publishes win-x64 only' + New-SetupStep GitConfig C user -Needs Git -Text 'global git: identity if filled in, core.sshCommand to a Win32-OpenSSH client' + New-SetupStep NinjaPath C user -Needs Ninja -Text 'the native ninja first on the user PATH' + New-SetupStep LlvmPath C user -Needs LLVM -Text 'upstream LLVM on the user PATH' ` + -X64Only 'LLVM is not installed on ARM64' + New-SetupStep RustToolchain C user -Needs Rustup, VisualStudio -Text $rust + New-SetupStep ArchAudit C user -Text 'architecture audit: native or emulated, for every tool (read-only)' + + # --- setup-windows.bat, after both halves --- + New-SetupStep Pillow C bat -Needs Python -Text 'pip install Pillow into the newest Python' + ) +} + +function Get-SetupStepBlocker { + # Why this step cannot run on this machine at all, or $null if it can. + # Architecture first: an x64-only step is off ARM64 before elevation is even + # consulted, which is the more useful reason to show. + param($Step, [bool] $IsArm64, [string] $Elevation) + if ($IsArm64 -and $Step.X64Only) { return "not on ARM64 - $($Step.X64Only)" } + if ($Elevation -eq 'NOLUA' -and $Step.Admin) { + return 'needs an administrator - this account cannot elevate' + } + $null +} + +function Split-SetupNames { + # `powershell -File` hands a whole "A,B,C" to an array parameter as ONE + # string, so split here and accept either spelling. + param([string[]] $Value) + @($Value | ForEach-Object { $_ -split '[,\s]+' } | Where-Object { $_ }) +} + +function Resolve-SetupStepNames { + # Step ids (case-insensitive) or group letters, as typed, to step ids. + # Anything else is an error: a mistyped -Skip that was quietly ignored would + # install exactly what you asked it not to. + param([string[]] $Names, [object[]] $Steps, [hashtable] $Aliases = @{}) + $groups = @((Get-SetupGroups).Keys) + foreach ($name in (Split-SetupNames $Names)) { + if ($Aliases.ContainsKey($name)) { $name = $Aliases[$name] } + $hit = @($Steps | Where-Object { $_.Id -eq $name }) + if (-not $hit) { $hit = @($Steps | Where-Object { $_.Group -eq $name }) } + if ($hit) { $hit.Id; continue } + # A real group with no steps in this script's share of the catalog. + if ($groups -contains $name) { continue } + throw "Unknown step '$name'. The steps are: $($Steps.Id -join ', '). Groups: $($groups -join ', ')." + } +} + +function Resolve-SetupSelection { + # The plan: one row per step, in run order, saying whether it is selected + # and - when it cannot run on this machine at all - why not. + # + # -Only $null means "everything that can run"; an empty -Only means nothing. + param( + [object[]] $Steps, + [AllowNull()] [string[]] $Only, + [string[]] $Skip = @(), + [hashtable] $Aliases = @{}, + [bool] $IsArm64, + [string] $Elevation + ) + $want = if ($null -eq $Only) { @($Steps.Id) } else { @(Resolve-SetupStepNames $Only $Steps $Aliases) } + $drop = @(Resolve-SetupStepNames $Skip $Steps $Aliases) + $named = @(Split-SetupNames $Only | ForEach-Object { if ($Aliases.ContainsKey($_)) { $Aliases[$_] } else { $_ } }) + foreach ($s in $Steps) { + $why = Get-SetupStepBlocker $s $IsArm64 $Elevation + if ($why -and $named -contains $s.Id) { + Write-Warning "$($s.Id) was asked for but cannot run here: $why" + } + [pscustomobject]@{ + Id = $s.Id + Step = $s + Why = $why + Selected = (-not $why) -and ($want -contains $s.Id) -and ($drop -notcontains $s.Id) + } + } +} + +function Write-SetupPlan { + # The short form of the plan, for the two halves: what this script will do + # and what it will not, before it does any of it. + param([object[]] $Plan, [string] $Title) + Write-Host "`n==> $Title" -ForegroundColor Cyan + foreach ($p in $Plan) { + if ($p.Selected) { + Write-Host (' run {0,-14} {1}' -f $p.Id, $p.Step.Text) + } else { + $why = if ($p.Why) { $p.Why } else { 'not selected' } + Write-Host (' skip {0,-14} {1}' -f $p.Id, $why) -ForegroundColor DarkGray + } + } +} + +function Confirm-SetupPlan { + # $true to go ahead, $false if declined, $null if there is no one to ask. + # + # Never assumes yes on its own. A run with no console behind it - piped, + # scheduled, a non-interactive ssh command - stops after printing the plan, + # and says how to run it unattended (-Yes) on purpose. + param([switch] $Yes, [int] $Count) + if ($Yes) { return $true } + if ([Console]::IsInputRedirected) { + Write-Host ' No console to ask on, so nothing has been done. Re-run with -Yes to take' -ForegroundColor Yellow + Write-Host ' the steps marked "run" unattended.' -ForegroundColor Yellow + return $null + } + $answer = Read-Host " Take the $Count step(s) marked run? [Y/n]" + if ($answer -match '^\s*(y|yes)?\s*$') { return $true } + Write-Host ' Nothing done.' + $false +} diff --git a/setup-windows-with-uac.ps1 b/setup-windows-with-uac.ps1 index 4b7ea94..4e3602f 100644 --- a/setup-windows-with-uac.ps1 +++ b/setup-windows-with-uac.ps1 @@ -4,6 +4,14 @@ Elevated portion of the Windows provisioning. Invoked by setup-windows.bat via Start-Process -Verb RunAs, or run manually from an Administrator prompt. + Its steps are the 'uac' rows of setup-windows-steps.ps1. The batch file runs it + with -Only -Yes, having already asked. Run + by hand, it lists what it will do and asks before doing any of it: + + .\setup-windows-with-uac.ps1 all of it, after asking + .\setup-windows-with-uac.ps1 -Only VisualStudio,Wdk just these + .\setup-windows-with-uac.ps1 -Skip VTune -Yes without asking + What this installs / configures: - OpenSSH Client capability (the ssh.exe rsync shells out to, and the System32 libcrypto.dll the release's own ssh.exe links against) @@ -37,6 +45,16 @@ What changes is called out at each step, and $HostArch below is what drives it. #> +[CmdletBinding()] +param( + # Step names from setup-windows-steps.ps1, comma-separated. `powershell -File` + # hands "A,B" to an array parameter as one string; it is split, so both work. + [string[]] $Only, + [string[]] $Skip = @(), + # Do not ask first. setup-windows.bat passes it, having asked already. + [switch] $Yes +) + $ErrorActionPreference = 'Stop' # --------------------------------------------------------------------------- @@ -53,14 +71,16 @@ $ErrorActionPreference = 'Stop' # under .NET Framework on Prism it is not, so it is kept only as a fallback. # # $IsArm64 gates: +# - which steps run at all. VsClang, VsWinXP and Wdk are x64-only, and that is +# decided in setup-windows-steps.ps1 (-X64Only, with the reason the plan +# shows); the comments at each step here carry the full reasoning. In short: +# the v141/XP toolset has no ARM64-hosted compiler and Microsoft does not +# ship Windows XP targeting for Arm hosts; Clang/LLVM and the WDK are out by +# policy rather than by limitation - both are available and native on Arm # - which rsync-windows release zip is fetched (x86 / x64 / arm64, all native) # - which Visual Studio generation is driven ($VsChannel: 18 on Arm, 17 on x64) -# - the Visual Studio component groups: ARM64 build tools in; the v141/XP -# toolset out because it has no ARM64-hosted compiler and Microsoft does not -# ship Windows XP targeting for Arm hosts; Clang/LLVM and the WDK VSIX out by -# policy rather than by limitation - both are available and native on Arm -# - whether the Windows Driver Kit is installed at all -# - what the VTune and WPT steps report +# - the base Visual Studio components: ARM64 build tools in, the WDK VSIX out +# - what the verification, VTune and WPT steps report # --------------------------------------------------------------------------- $rawArch = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment' -ErrorAction SilentlyContinue).PROCESSOR_ARCHITECTURE if (-not $rawArch) { $rawArch = [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() } @@ -121,6 +141,18 @@ function Get-VsInstallPath { Select-Object -First 1 } +function Test-VsHasComponents([string[]] $Ids) { + # True when the Visual Studio this script drives already has every one of + # these workload / component ids. vswhere's -requires matches only an + # instance that has ALL of them (checked: one id it does not have, and the + # instance is not returned), and it is scoped to $VsChannel the same way + # Get-VsInstallPath is, so the answer is about the install the passes target. + if (-not $Ids -or -not $script:InstallPath -or -not (Test-Path $script:VsWhere)) { return $false } + $range = "[$script:VsChannel.0,$($script:VsChannel + 1).0)" + $found = & $script:VsWhere -products '*' -version $range -requires $Ids -property installationPath -format value + @($found) -contains $script:InstallPath +} + function Invoke-VsModify { # Run one VS install/modify pass for a named group of components. Splitting # the install into separate passes makes it obvious WHICH group fails: each @@ -140,6 +172,14 @@ function Invoke-VsModify { Write-Host ' (no components in this group for this architecture - skipping)' -ForegroundColor DarkGray return } + # Everything already there: running the installer would change nothing and + # cost minutes. This script never UPDATES Visual Studio - `modify` adds + # components, it does not move the product version - so skipping loses + # nothing. Updates come from the Visual Studio Installer, as always. + if (Test-VsHasComponents $Ids) { + Write-Host " All $($Ids.Count) components already installed in $script:InstallPath; not running the installer." + return + } $addStr = ($Ids | ForEach-Object { "--add $_" }) -join ' ' # --installPath must be quoted: it contains spaces ("C:\Program Files\..."). # Windows PowerShell 5.1's Start-Process does not quote array elements, so we @@ -165,6 +205,38 @@ function Invoke-VsModify { if (-not $script:InstallPath) { $script:InstallPath = Get-VsInstallPath } } +# --------------------------------------------------------------------------- +# Which steps to take +# +# The steps, and which of them cannot run on this machine, come from the 'uac' +# rows of setup-windows-steps.ps1 - the same catalog setup-windows.bat builds its +# plan from. The plan is printed first, inside the transcript, so the log the +# batch file shows afterwards starts with what this half was asked to do. +# +# Every section below is gated on Test-SetupStep with its catalog Id, which also +# records that it was asked. Both kinds of disagreement with the catalog are +# loud: an Id the catalog does not have throws where it is used, and a selected +# step that no section ever asks about fails the run at the end rather than +# letting "All done." cover a step that was never taken. +# --------------------------------------------------------------------------- +. (Join-Path $PSScriptRoot 'setup-windows-steps.ps1') + +# Elevation is a given here (#Requires above), so nothing is blocked on it. +$Plan = @(Resolve-SetupSelection -Steps @(Get-SetupCatalog -IsArm64 $IsArm64 | Where-Object Runner -eq 'uac') ` + -Only $(if ($PSBoundParameters.ContainsKey('Only')) { $Only } else { $null }) -Skip $Skip ` + -IsArm64 $IsArm64 -Elevation 'ALREADY') +$StepIds = @($Plan | ForEach-Object { $_.Id }) +$RunSteps = @($Plan | Where-Object Selected | ForEach-Object { $_.Id }) +$AskedSteps = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase) + +function Test-SetupStep([string] $Id) { + if ($script:StepIds -notcontains $Id) { + throw "Test-SetupStep: '$Id' is not a setup-windows-with-uac.ps1 step in setup-windows-steps.ps1." + } + [void]$script:AskedSteps.Add($Id) + $script:RunSteps -contains $Id +} + # --------------------------------------------------------------------------- # This runs in a separate elevated window that closes the moment it exits, so # the non-elevated caller (setup-windows.bat) can't see what happened. Mirror @@ -177,6 +249,17 @@ try { Start-Transcript -Path $LogFile -Force | Out-Null } catch {} try { +Write-SetupPlan $Plan "Plan for setup-windows-with-uac.ps1 (host: $HostArch)" +if (-not $RunSteps) { + Write-Host ' Nothing selected, so there is nothing to do.' + exit 0 +} +# From the batch file, -Yes: the question was asked before anything ran. By +# hand, ask now. Either way `exit` here still runs the finally below, so the +# transcript is closed and its ACL relaxed. +$ok = Confirm-SetupPlan -Yes:$Yes -Count $RunSteps.Count +if ($ok -ne $true) { exit $(if ($null -eq $ok) { 2 } else { 0 }) } + Write-Step "Host architecture: $HostArch" if ($IsArm64) { # x64 emulation ("Prism") is what carries every x64-only tool this script @@ -209,29 +292,33 @@ if ($IsArm64) { # Non-fatal, like the server half below: a box that cannot have it should still # finish provisioning. # --------------------------------------------------------------------------- -Write-Step 'OpenSSH Client' -try { - $sshc = Get-WindowsCapability -Online -Name 'OpenSSH.Client*' | - Select-Object -First 1 - if (-not $sshc) { - Write-Warning 'OpenSSH.Client capability not offered by this Windows image - skipping.' - } elseif ($sshc.State -eq 'Installed') { - Write-Host " OK: $($sshc.Name) already installed" - } else { - Write-Host " Installing $($sshc.Name) ..." - $r = Add-WindowsCapability -Online -Name $sshc.Name - if ($r.RestartNeeded) { Write-Host ' [reboot required after OpenSSH Client]' -ForegroundColor Yellow } +if (Test-SetupStep 'OpenSshClient') { + Write-Step 'OpenSSH Client' + try { + $sshc = Get-WindowsCapability -Online -Name 'OpenSSH.Client*' | + Select-Object -First 1 + if (-not $sshc) { + Write-Warning 'OpenSSH.Client capability not offered by this Windows image - skipping.' + } elseif ($sshc.State -eq 'Installed') { + Write-Host " OK: $($sshc.Name) already installed" + } else { + Write-Host " Installing $($sshc.Name) ..." + $r = Add-WindowsCapability -Online -Name $sshc.Name + if ($r.RestartNeeded) { Write-Host ' [reboot required after OpenSSH Client]' -ForegroundColor Yellow } + } + } catch { + Write-Warning "OpenSSH Client setup failed: $($_.Exception.Message)" } -} catch { - Write-Warning "OpenSSH Client setup failed: $($_.Exception.Message)" } # --------------------------------------------------------------------------- # SSH agent # --------------------------------------------------------------------------- -Write-Step 'Enabling ssh-agent' -Set-Service -Name ssh-agent -StartupType Automatic -if ((Get-Service ssh-agent).Status -ne 'Running') { Start-Service ssh-agent } +if (Test-SetupStep 'SshAgent') { + Write-Step 'Enabling ssh-agent' + Set-Service -Name ssh-agent -StartupType Automatic + if ((Get-Service ssh-agent).Status -ne 'Running') { Start-Service ssh-agent } +} # --------------------------------------------------------------------------- # OpenSSH Server (sshd) @@ -245,48 +332,50 @@ if ((Get-Service ssh-agent).Status -ne 'Running') { Start-Service ssh-agent } # # Non-fatal: a box that can't run sshd should still finish provisioning. # --------------------------------------------------------------------------- -Write-Step 'OpenSSH Server (sshd)' -try { - $sshd = Get-WindowsCapability -Online -Name 'OpenSSH.Server*' | - Select-Object -First 1 - if (-not $sshd) { - Write-Warning 'OpenSSH.Server capability not offered by this Windows image - skipping.' - } else { - if ($sshd.State -ne 'Installed') { - Write-Host " Installing $($sshd.Name) ..." - $r = Add-WindowsCapability -Online -Name $sshd.Name - if ($r.RestartNeeded) { Write-Host ' [reboot required after OpenSSH Server]' -ForegroundColor Yellow } +if (Test-SetupStep 'OpenSshServer') { + Write-Step 'OpenSSH Server (sshd)' + try { + $sshd = Get-WindowsCapability -Online -Name 'OpenSSH.Server*' | + Select-Object -First 1 + if (-not $sshd) { + Write-Warning 'OpenSSH.Server capability not offered by this Windows image - skipping.' } else { - Write-Host " OK: $($sshd.Name) already installed" - } + if ($sshd.State -ne 'Installed') { + Write-Host " Installing $($sshd.Name) ..." + $r = Add-WindowsCapability -Online -Name $sshd.Name + if ($r.RestartNeeded) { Write-Host ' [reboot required after OpenSSH Server]' -ForegroundColor Yellow } + } else { + Write-Host " OK: $($sshd.Name) already installed" + } - Set-Service -Name sshd -StartupType Automatic - if ((Get-Service sshd).Status -ne 'Running') { Start-Service sshd } - Write-Host ' sshd: Automatic + running' + Set-Service -Name sshd -StartupType Automatic + if ((Get-Service sshd).Status -ne 'Running') { Start-Service sshd } + Write-Host ' sshd: Automatic + running' - # Firewall: allow inbound 22 on all profiles. VirtualBox host-only and - # bridged adapters are frequently classified Public, and the capability's - # own rule is Private-only on some images, which is what leaves a plainly - # running sshd plainly unreachable. - # - # OpenSSH-Server-In-TCP is the name the capability itself uses, so this - # WIDENS that rule rather than adding a second one next to it. Creating - # our own under a different name would leave the narrow rule in place and - # the box still unreachable on a Public-classified adapter; creating one - # under the same name would collide. Adopt it if present, create it if not. - $ruleName = 'OpenSSH-Server-In-TCP' - if (Get-NetFirewallRule -Name $ruleName -ErrorAction SilentlyContinue) { - Set-NetFirewallRule -Name $ruleName -Enabled True -Profile Any - Write-Host " Widened firewall rule $ruleName to all profiles" - } else { - New-NetFirewallRule -Name $ruleName -DisplayName 'OpenSSH SSH Server (sshd)' ` - -Enabled True -Direction Inbound -Protocol TCP -Action Allow ` - -LocalPort 22 -Profile Any | Out-Null - Write-Host " Added firewall rule $ruleName (TCP 22, all profiles)" + # Firewall: allow inbound 22 on all profiles. VirtualBox host-only and + # bridged adapters are frequently classified Public, and the capability's + # own rule is Private-only on some images, which is what leaves a plainly + # running sshd plainly unreachable. + # + # OpenSSH-Server-In-TCP is the name the capability itself uses, so this + # WIDENS that rule rather than adding a second one next to it. Creating + # our own under a different name would leave the narrow rule in place and + # the box still unreachable on a Public-classified adapter; creating one + # under the same name would collide. Adopt it if present, create it if not. + $ruleName = 'OpenSSH-Server-In-TCP' + if (Get-NetFirewallRule -Name $ruleName -ErrorAction SilentlyContinue) { + Set-NetFirewallRule -Name $ruleName -Enabled True -Profile Any + Write-Host " Widened firewall rule $ruleName to all profiles" + } else { + New-NetFirewallRule -Name $ruleName -DisplayName 'OpenSSH SSH Server (sshd)' ` + -Enabled True -Direction Inbound -Protocol TCP -Action Allow ` + -LocalPort 22 -Profile Any | Out-Null + Write-Host " Added firewall rule $ruleName (TCP 22, all profiles)" + } } + } catch { + Write-Warning "OpenSSH Server setup failed: $($_.Exception.Message)" } -} catch { - Write-Warning "OpenSSH Server setup failed: $($_.Exception.Message)" } # --------------------------------------------------------------------------- @@ -319,177 +408,200 @@ try { # # Non-fatal: a download failure only warns. # --------------------------------------------------------------------------- -Write-Step 'rsync for Windows' -$RsyncRepo = 'nuket/rsync-windows' -# The release publishes x86, x64 and - since v3.5.0-gdeeda96f - arm64, so every -# architecture this script runs on gets a native build. ARM64 used to take the -# x64 zip under emulation, which was defensible (a transfer is bound by the -# socket, not by emulated CPU) but cost the release's ssh.exe: that x64 binary -# could not load the ARM64 libcrypto.dll in System32 and had to be deleted on -# every run. The arm64 asset ships an ARM64 rsync.exe AND an ARM64 ssh.exe, so -# both halves are now native and the fast client survives. -# -# Selected off $HostArch rather than Is64BitOperatingSystem, which answers "true" -# on ARM64 and so cannot tell the two 64-bit cases apart. -$RsyncAsset = switch ($HostArch) { - 'X64' { 'rsync-windows-x64.zip' } - 'Arm64' { 'rsync-windows-arm64.zip' } - default { 'rsync-windows-x86.zip' } -} -if ($IsArm64) { - Write-Host ' ARM64: using the native arm64 asset (rsync.exe and ssh.exe both ARM64).' -ForegroundColor Green -} -# The /releases/latest/download/ redirect rather than the API: unauthenticated -# API calls are rate-limited to 60/hour per IP, which a provisioning run behind a -# shared NAT can genuinely exhaust, and the redirect costs none of that budget. -# To hold a box on a known build, pin the tag instead: -# .../releases/download/v3.5.0-gABCDEF0/$RsyncAsset -$RsyncUrl = "https://github.com/$RsyncRepo/releases/latest/download/$RsyncAsset" -$RsyncDir = 'C:\Tools\rsync' -try { - New-Item -ItemType Directory -Force -Path $RsyncDir | Out-Null - $RsyncExe = Join-Path $RsyncDir 'rsync.exe' - - # Does the release's ssh.exe have the libcrypto it needs? Decided before the - # download so the answer can also gate what comes out of the zip. This is a - # cheap pre-filter only - the authoritative check is running the thing, which - # happens after the unpack below. - $SysCrypto = Join-Path $env:WINDIR 'System32\libcrypto.dll' - $WantSsh = Test-Path $SysCrypto - if (-not $WantSsh) { - Write-Warning "$SysCrypto is missing - the OpenSSH Client capability is not installed - and the release's ssh.exe needs it. Installing rsync.exe only; rsync will use the ssh on the PATH." - } else { - $v = (Get-Item $SysCrypto).VersionInfo.FileVersion - if ($v -and ([version]($v -replace '[^0-9.]', '')) -lt [version]'3.8.2') { - Write-Warning "$SysCrypto is LibreSSL $v; the release's ssh.exe is built against 3.8.2 (Windows OpenSSH Client 9.5). Update Windows, or expect ssh.exe not to start." - } - # No architecture special-case here any more: the asset chosen above - # matches the host, so the release's ssh.exe and System32's libcrypto.dll - # are the same architecture on every box. The run-it check below still - # happens on all of them - it is cheap, and it is what caught the ARM64 - # mismatch back when this took the x64 zip. - } - - # Download and unpack beside the targets, not over them, so an interrupted - # transfer can't leave a truncated rsync.exe sitting on the PATH. The scratch - # name still has to END in .zip: Windows PowerShell 5.1's Expand-Archive - # refuses any other extension outright ("*.download is not a supported - # archive file format"), where PowerShell 7 just reads the file. - [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 - $tmpZip = Join-Path $RsyncDir "download-$RsyncAsset" - Invoke-WebRequest -Uri $RsyncUrl -OutFile $tmpZip -UseBasicParsing - Write-Host " Downloaded $RsyncAsset ($([math]::Round((Get-Item $tmpZip).Length / 1MB, 2)) MB)" - - # Verify against the .sha256 published beside it. Same origin, so this is an - # integrity check on the transfer rather than a defence against a hostile - # release - but a truncated or proxy-mangled download is the failure that - # actually happens, and it fails here instead of mid-transfer later. +if (Test-SetupStep 'Rsync') { + Write-Step 'rsync for Windows' + $RsyncRepo = 'nuket/rsync-windows' + # The release publishes x86, x64 and - since v3.5.0-gdeeda96f - arm64, so every + # architecture this script runs on gets a native build. ARM64 used to take the + # x64 zip under emulation, which was defensible (a transfer is bound by the + # socket, not by emulated CPU) but cost the release's ssh.exe: that x64 binary + # could not load the ARM64 libcrypto.dll in System32 and had to be deleted on + # every run. The arm64 asset ships an ARM64 rsync.exe AND an ARM64 ssh.exe, so + # both halves are now native and the fast client survives. # - # -OutFile, not .Content: GitHub serves the .sha256 as - # application/octet-stream, and Invoke-WebRequest hands back a byte[] rather - # than a string for any non-text content type, so .Content would compare the - # first BYTE against the hash and fail on every correct download. - $tmpSha = "$tmpZip.sha256" - Invoke-WebRequest -Uri "$RsyncUrl.sha256" -OutFile $tmpSha -UseBasicParsing - $want = (((Get-Content $tmpSha -Raw) -split '\s+')[0]).Trim().ToLower() - Remove-Item $tmpSha -Force -ErrorAction SilentlyContinue - $got = (Get-FileHash $tmpZip -Algorithm SHA256).Hash.ToLower() - if ($want -and $want -ne $got) { - Remove-Item $tmpZip -Force - throw "SHA-256 mismatch for ${RsyncAsset}: expected $want, got $got" + # Selected off $HostArch rather than Is64BitOperatingSystem, which answers "true" + # on ARM64 and so cannot tell the two 64-bit cases apart. + $RsyncAsset = switch ($HostArch) { + 'X64' { 'rsync-windows-x64.zip' } + 'Arm64' { 'rsync-windows-arm64.zip' } + default { 'rsync-windows-x86.zip' } } - Write-Host " SHA-256 verified: $got" - - # Unpack to a scratch directory and move out the files we asked for, rather - # than expanding straight over the install directory: the zip is the unit - # that was checksummed, and this way a future release adding something to it - # cannot quietly drop that something onto the machine PATH. - $unpack = Join-Path $RsyncDir '.unpack' - if (Test-Path $unpack) { Remove-Item -Recurse -Force $unpack } - Expand-Archive -Path $tmpZip -DestinationPath $unpack -Force - Remove-Item $tmpZip -Force - foreach ($f in 'rsync.exe', 'ssh.exe', 'COPYING.txt', 'NOTICE-ssh.txt') { - $src = Join-Path $unpack $f - if (-not (Test-Path $src)) { continue } - if ($f -eq 'ssh.exe' -and -not $WantSsh) { continue } - Move-Item -Path $src -Destination (Join-Path $RsyncDir $f) -Force + if ($IsArm64) { + Write-Host ' ARM64: using the native arm64 asset (rsync.exe and ssh.exe both ARM64).' -ForegroundColor Green } - Remove-Item -Recurse -Force $unpack + # The /releases/latest/download/ redirect rather than the API: unauthenticated + # API calls are rate-limited to 60/hour per IP, which a provisioning run behind a + # shared NAT can genuinely exhaust, and the redirect costs none of that budget. + # To hold a box on a known build, pin the tag instead: + # .../releases/download/v3.5.0-gABCDEF0/$RsyncAsset + $RsyncUrl = "https://github.com/$RsyncRepo/releases/latest/download/$RsyncAsset" + $RsyncDir = 'C:\Tools\rsync' + try { + New-Item -ItemType Directory -Force -Path $RsyncDir | Out-Null + $RsyncExe = Join-Path $RsyncDir 'rsync.exe' - # Prove the unpacked ssh.exe actually starts, and DELETE it if it does not. - # - # This matters more than it looks. rsync.exe prefers an ssh.exe sitting in its - # own directory, so a present-but-unstartable one does not degrade to the - # in-box client - it breaks rsync outright, and the error you get is a remote - # shell that died rather than anything naming ssh.exe. The way to land there - # is a missing or too-old System32 libcrypto.dll. Removing it is the repair: - # rsync then falls back to the ssh on the PATH, which is the in-box client. - # - # This also used to fire on every ARM64 run, when the x64 asset was the only - # 64-bit one published and its ssh.exe could not load the ARM64 libcrypto - # (exit 0xC0000135, STATUS_DLL_NOT_FOUND). The arm64 asset fixed that at the - # source; the check stays because it is how that was found in the first place. - # - # EAP back to Continue for the call: ssh -V writes its version to STDERR, and - # under $ErrorActionPreference = 'Stop' a native command's stderr becomes a - # terminating error, so a WORKING client would look like a broken one. - # $global:LASTEXITCODE is cleared first because an exe that cannot start at - # all throws without setting one, and the stale 0 from the previous native - # command would otherwise read as success. - # - # DRAIN THE OUTPUT BEFORE READING $LASTEXITCODE. `... | Select-Object -First 1` - # was the original spelling here, and it is a trap: Select-Object stops the - # pipeline the instant it has its one object, and that is when ssh.exe has - # WRITTEN its banner - not when it has exited. PowerShell then tears the - # pipeline down, kills the still-running process, and reports -1. Measured on a - # healthy binary on an x64 box: 24 of 25 runs reported exit -1 through - # Select-Object, and 25 of 25 reported 0 when the output was collected first. - # - # So this check was deleting a perfectly good ssh.exe on almost every run, on - # every architecture, and the resulting fallback to the in-box client - the - # ~17MB/s one this whole step exists to avoid - looked like a property of the - # release rather than a bug in the test. Collect, THEN take the first line. - $SshExe = Join-Path $RsyncDir 'ssh.exe' - if ($WantSsh -and (Test-Path $SshExe)) { - $prevEap = $ErrorActionPreference - $ErrorActionPreference = 'Continue' - $global:LASTEXITCODE = $null - $sshVer = $null - try { $sshOut = (& $SshExe -V 2>&1); $sshVer = $sshOut | Select-Object -First 1 } catch { } - finally { $ErrorActionPreference = $prevEap } - if ($LASTEXITCODE -eq 0) { - Write-Host " ssh.exe runs: $sshVer" + # Does the release's ssh.exe have the libcrypto it needs? Decided before the + # download so the answer can also gate what comes out of the zip. This is a + # cheap pre-filter only - the authoritative check is running the thing, which + # happens after the unpack below. + $SysCrypto = Join-Path $env:WINDIR 'System32\libcrypto.dll' + $WantSsh = Test-Path $SysCrypto + if (-not $WantSsh) { + Write-Warning "$SysCrypto is missing - the OpenSSH Client capability is not installed - and the release's ssh.exe needs it. Installing rsync.exe only; rsync will use the ssh on the PATH." } else { - $why = if ($null -eq $LASTEXITCODE) { 'it would not start' } else { "exit $LASTEXITCODE" } - Write-Warning "The release's ssh.exe does not run here ($why)$(if ($sshVer) { ": $sshVer" }). Removing it so rsync.exe falls back to the ssh on the PATH instead of failing on it." - Remove-Item $SshExe -Force -ErrorAction SilentlyContinue - $WantSsh = $false + $v = (Get-Item $SysCrypto).VersionInfo.FileVersion + if ($v -and ([version]($v -replace '[^0-9.]', '')) -lt [version]'3.8.2') { + Write-Warning "$SysCrypto is LibreSSL $v; the release's ssh.exe is built against 3.8.2 (Windows OpenSSH Client 9.5). Update Windows, or expect ssh.exe not to start." + } + # No architecture special-case here any more: the asset chosen above + # matches the host, so the release's ssh.exe and System32's libcrypto.dll + # are the same architecture on every box. The run-it check below still + # happens on all of them - it is cheap, and it is what caught the ARM64 + # mismatch back when this took the x64 zip. } - } - Write-Host " Installed $RsyncExe$(if ($WantSsh) { ' and the ssh.exe it runs' })" - - # Machine PATH (HKLM environment). Idempotent: only appends if absent. - $m = [Environment]::GetEnvironmentVariable('Path', 'Machine') - if (-not $m) { $m = '' } - if (($m -split ';') -notcontains $RsyncDir) { - $new = if ($m.Trim()) { $m.TrimEnd(';') + ';' + $RsyncDir } else { $RsyncDir } - [Environment]::SetEnvironmentVariable('Path', $new, 'Machine') - Write-Host " Added $RsyncDir to the machine PATH (restart shells / sshd to pick it up)." - # sshd caches the environment it was started with, so an already-running - # service would not see the new PATH until restarted. - if ((Get-Service sshd -ErrorAction SilentlyContinue).Status -eq 'Running') { - Restart-Service sshd - Write-Host ' Restarted sshd so it inherits the updated machine PATH.' + + # Download and unpack beside the targets, not over them, so an interrupted + # transfer can't leave a truncated rsync.exe sitting on the PATH. The scratch + # name still has to END in .zip: Windows PowerShell 5.1's Expand-Archive + # refuses any other extension outright ("*.download is not a supported + # archive file format"), where PowerShell 7 just reads the file. + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + $tmpZip = Join-Path $RsyncDir "download-$RsyncAsset" + + # The .sha256 published beside the zip is fetched FIRST, for two jobs. It + # verifies the download below. Same origin, so that is an integrity check on + # the transfer rather than a defence against a hostile release - but a + # truncated or proxy-mangled download is the failure that actually happens, + # and it fails here instead of mid-transfer later. + # + # And it says whether there is anything new to fetch at all. The hash of the + # zip the installed files came from is recorded beside them in + # rsync-windows.sha256, written only once they have all landed (as BinSkim's + # nupkg-version.txt is). When the release's hash still matches and the files + # are all there, the download and unpack are skipped - a re-run costs one + # small request instead of the zip. An ssh.exe that was wanted but is gone + # (removed below for not starting) does NOT count as there, so each run + # tries it again: whatever stopped it starting may have been fixed since. + # + # -OutFile, not .Content: GitHub serves the .sha256 as + # application/octet-stream, and Invoke-WebRequest hands back a byte[] rather + # than a string for any non-text content type, so .Content would compare the + # first BYTE against the hash and fail on every correct download. + $tmpSha = "$tmpZip.sha256" + Invoke-WebRequest -Uri "$RsyncUrl.sha256" -OutFile $tmpSha -UseBasicParsing + $want = (((Get-Content $tmpSha -Raw) -split '\s+')[0]).Trim().ToLower() + Remove-Item $tmpSha -Force -ErrorAction SilentlyContinue + + $RsyncMarker = Join-Path $RsyncDir 'rsync-windows.sha256' + $InstalledSha = if (Test-Path $RsyncMarker) { (Get-Content $RsyncMarker -Raw).Trim().ToLower() } else { '' } + $RsyncCurrent = $want -and ($InstalledSha -eq $want) -and (Test-Path $RsyncExe) -and + (-not $WantSsh -or (Test-Path (Join-Path $RsyncDir 'ssh.exe'))) + if ($RsyncCurrent) { + Write-Host " $RsyncAsset unchanged since it was installed (SHA-256 $want); not downloading it again." + } else { + Invoke-WebRequest -Uri $RsyncUrl -OutFile $tmpZip -UseBasicParsing + Write-Host " Downloaded $RsyncAsset ($([math]::Round((Get-Item $tmpZip).Length / 1MB, 2)) MB)" + $got = (Get-FileHash $tmpZip -Algorithm SHA256).Hash.ToLower() + if ($want -and $want -ne $got) { + Remove-Item $tmpZip -Force + throw "SHA-256 mismatch for ${RsyncAsset}: expected $want, got $got" + } + Write-Host " SHA-256 verified: $got" + + # Unpack to a scratch directory and move out the files we asked for, rather + # than expanding straight over the install directory: the zip is the unit + # that was checksummed, and this way a future release adding something to it + # cannot quietly drop that something onto the machine PATH. + $unpack = Join-Path $RsyncDir '.unpack' + if (Test-Path $unpack) { Remove-Item -Recurse -Force $unpack } + Expand-Archive -Path $tmpZip -DestinationPath $unpack -Force + Remove-Item $tmpZip -Force + foreach ($f in 'rsync.exe', 'ssh.exe', 'COPYING.txt', 'NOTICE-ssh.txt') { + $src = Join-Path $unpack $f + if (-not (Test-Path $src)) { continue } + if ($f -eq 'ssh.exe' -and -not $WantSsh) { continue } + Move-Item -Path $src -Destination (Join-Path $RsyncDir $f) -Force + } + Remove-Item -Recurse -Force $unpack + # Written last: the marker must only claim a release that fully landed. + if ($want) { Set-Content -Path $RsyncMarker -Value $want -Encoding ascii } } - } else { - Write-Host " OK: $RsyncDir already in the machine PATH" - } - & $RsyncExe --version | Select-Object -First 1 -} catch { - Write-Warning "rsync install failed: $($_.Exception.Message)" - Write-Warning "Download $RsyncAsset from https://github.com/$RsyncRepo/releases manually" - Write-Warning "and unpack it into $RsyncDir, keeping rsync.exe and ssh.exe together." + # Prove the unpacked ssh.exe actually starts, and DELETE it if it does not. + # + # This matters more than it looks. rsync.exe prefers an ssh.exe sitting in its + # own directory, so a present-but-unstartable one does not degrade to the + # in-box client - it breaks rsync outright, and the error you get is a remote + # shell that died rather than anything naming ssh.exe. The way to land there + # is a missing or too-old System32 libcrypto.dll. Removing it is the repair: + # rsync then falls back to the ssh on the PATH, which is the in-box client. + # + # This also used to fire on every ARM64 run, when the x64 asset was the only + # 64-bit one published and its ssh.exe could not load the ARM64 libcrypto + # (exit 0xC0000135, STATUS_DLL_NOT_FOUND). The arm64 asset fixed that at the + # source; the check stays because it is how that was found in the first place. + # + # EAP back to Continue for the call: ssh -V writes its version to STDERR, and + # under $ErrorActionPreference = 'Stop' a native command's stderr becomes a + # terminating error, so a WORKING client would look like a broken one. + # $global:LASTEXITCODE is cleared first because an exe that cannot start at + # all throws without setting one, and the stale 0 from the previous native + # command would otherwise read as success. + # + # DRAIN THE OUTPUT BEFORE READING $LASTEXITCODE. `... | Select-Object -First 1` + # was the original spelling here, and it is a trap: Select-Object stops the + # pipeline the instant it has its one object, and that is when ssh.exe has + # WRITTEN its banner - not when it has exited. PowerShell then tears the + # pipeline down, kills the still-running process, and reports -1. Measured on a + # healthy binary on an x64 box: 24 of 25 runs reported exit -1 through + # Select-Object, and 25 of 25 reported 0 when the output was collected first. + # + # So this check was deleting a perfectly good ssh.exe on almost every run, on + # every architecture, and the resulting fallback to the in-box client - the + # ~17MB/s one this whole step exists to avoid - looked like a property of the + # release rather than a bug in the test. Collect, THEN take the first line. + $SshExe = Join-Path $RsyncDir 'ssh.exe' + if ($WantSsh -and (Test-Path $SshExe)) { + $prevEap = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + $global:LASTEXITCODE = $null + $sshVer = $null + try { $sshOut = (& $SshExe -V 2>&1); $sshVer = $sshOut | Select-Object -First 1 } catch { } + finally { $ErrorActionPreference = $prevEap } + if ($LASTEXITCODE -eq 0) { + Write-Host " ssh.exe runs: $sshVer" + } else { + $why = if ($null -eq $LASTEXITCODE) { 'it would not start' } else { "exit $LASTEXITCODE" } + Write-Warning "The release's ssh.exe does not run here ($why)$(if ($sshVer) { ": $sshVer" }). Removing it so rsync.exe falls back to the ssh on the PATH instead of failing on it." + Remove-Item $SshExe -Force -ErrorAction SilentlyContinue + $WantSsh = $false + } + } + Write-Host " $(if ($RsyncCurrent) { 'Kept' } else { 'Installed' }) $RsyncExe$(if ($WantSsh) { ' and the ssh.exe it runs' })" + + # Machine PATH (HKLM environment). Idempotent: only appends if absent. + $m = [Environment]::GetEnvironmentVariable('Path', 'Machine') + if (-not $m) { $m = '' } + if (($m -split ';') -notcontains $RsyncDir) { + $new = if ($m.Trim()) { $m.TrimEnd(';') + ';' + $RsyncDir } else { $RsyncDir } + [Environment]::SetEnvironmentVariable('Path', $new, 'Machine') + Write-Host " Added $RsyncDir to the machine PATH (restart shells / sshd to pick it up)." + # sshd caches the environment it was started with, so an already-running + # service would not see the new PATH until restarted. + if ((Get-Service sshd -ErrorAction SilentlyContinue).Status -eq 'Running') { + Restart-Service sshd + Write-Host ' Restarted sshd so it inherits the updated machine PATH.' + } + } else { + Write-Host " OK: $RsyncDir already in the machine PATH" + } + + & $RsyncExe --version | Select-Object -First 1 + } catch { + Write-Warning "rsync install failed: $($_.Exception.Message)" + Write-Warning "Download $RsyncAsset from https://github.com/$RsyncRepo/releases manually" + Write-Warning "and unpack it into $RsyncDir, keeping rsync.exe and ssh.exe together." + } } # --------------------------------------------------------------------------- @@ -554,20 +666,19 @@ if (-not $IsArm64) { # It is dropped on ARM64 because that machine is a constrained VM provisioned as # a single-compiler build box: MSVC and nothing else. Compiler diversity - MSVC, # VS clang-cl and upstream LLVM over the same sources - now lives entirely on the -# x64 machine, which is the one with room for three toolchains. Empty here means -# Invoke-VsModify is never called for this group. -$ClangComponents = if ($IsArm64) { @() } else { - @( - 'Microsoft.VisualStudio.Component.VC.Llvm.Clang' - 'Microsoft.VisualStudio.Component.VC.Llvm.ClangToolset' - ) -} +# x64 machine, which is the one with room for three toolchains. The VsClang step +# is -X64Only in setup-windows-steps.ps1, so on ARM64 this group is never asked +# for. +$ClangComponents = @( + 'Microsoft.VisualStudio.Component.VC.Llvm.Clang' + 'Microsoft.VisualStudio.Component.VC.Llvm.ClangToolset' +) # Windows XP targeting (v141_xp toolset, used in CMakePresets.json). The v141 # (VS2017) build tools provide the 14.16 compiler that the XP toolset wraps; # WinXP layers the XP-compatible CRT/SDK on top of it. # -# NOT USED ON ARM64, and left empty there. The components are still listed in the +# NOT USED ON ARM64 - VsWinXP is -X64Only. The components are still listed in the # catalog on Arm, so this is not strictly "unavailable" - but the 14.16 toolset # predates Windows on Arm as a host and ships HostX86/HostX64 compilers only, so # the best you could get is an x86-emulated compiler, and Microsoft does not @@ -575,12 +686,10 @@ $ClangComponents = if ($IsArm64) { @() } else { # used: Windows XP never ran on ARM64, so an XP-targeting build from an ARM64 # host has no purpose beyond producing x86 binaries, which the current toolset # does natively via -A Win32. On x64 the group is installed exactly as before. -$XpComponents = if ($IsArm64) { @() } else { - @( - 'Microsoft.VisualStudio.Component.VC.v141.x86.x64' - 'Microsoft.VisualStudio.Component.WinXP' - ) -} +$XpComponents = @( + 'Microsoft.VisualStudio.Component.VC.v141.x86.x64' + 'Microsoft.VisualStudio.Component.WinXP' +) # Which Visual Studio generation to drive: 17 = VS 2022, 18 = VS 2026. Both have # native ARM64 installers and ARM64-hosted MSVC. This picks the bootstrapper URL, @@ -613,13 +722,30 @@ $VsChannelPath = if ($VsChannel -ge 18) { 'stable' } else { 'release' } $VsWhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' $InstallPath = Get-VsInstallPath +# Three steps drive Visual Studio - the base install and the two optional groups +# layered on it. They share the bootstrapper download and the verification +# below, which happen once if any of them runs. Asked once, here. +$DoVsBase = Test-SetupStep 'VisualStudio' +$DoVsClang = Test-SetupStep 'VsClang' +$DoVsXp = Test-SetupStep 'VsWinXP' +$DoVs = $DoVsBase -or $DoVsClang -or $DoVsXp + +# Whether any selected pass still has something to add. When none does, the +# bootstrapper is not even downloaded: each pass would find its components +# already installed (Test-VsHasComponents) and return before using it. The Clang +# and XP passes only count with an install to add them to - without one they +# stand down below, and would not use the bootstrapper either. +$VsNeedsInstaller = ($DoVsBase -and -not (Test-VsHasComponents $BaseComponents)) -or + ($DoVsClang -and $InstallPath -and -not (Test-VsHasComponents $ClangComponents)) -or + ($DoVsXp -and $InstallPath -and -not (Test-VsHasComponents $XpComponents)) + # Report any OTHER Visual Studio generations on the box. They are left alone - # the passes below only ever touch $InstallPath - but when none of them matches # $VsChannel this script is about to download and install a second, largely # redundant toolchain, and that should be a visible decision rather than a # surprise 10GB. (On ARM64, where $VsChannel is 18, an existing VS 2026 IS the # match and gets modified in place rather than duplicated.) -if (Test-Path $VsWhere) { +if ($DoVs -and (Test-Path $VsWhere)) { $others = & $VsWhere -products '*' -format value -property installationPath | Where-Object { $_ -and $_ -ne $InstallPath } if ($others) { @@ -630,23 +756,29 @@ if (Test-Path $VsWhere) { } } -Write-Step "Downloading VS $VsChannel $VsEdition bootstrapper (host: $HostArch)" -# aka.ms serves the bootstrapper for the requesting machine's architecture, so on -# ARM64 this is the native ARM64 installer - no --arch flag needed or offered. -$VsInstallerUrl = "https://aka.ms/vs/$VsChannel/$VsChannelPath/vs_$VsEdition.exe" -$VsBootstrapper = Join-Path $TempDir "vs_$VsEdition.exe" -Write-Host " $VsInstallerUrl" -ForegroundColor DarkGray -Invoke-WebRequest -Uri $VsInstallerUrl -OutFile $VsBootstrapper -UseBasicParsing - -# Prove we got an installer and not a web page. The Bing redirect described above -# returns 200 with HTML, and every other aka.ms typo behaves the same way, so a -# bad channel/edition combination is otherwise only discovered when the "exe" -# fails to start. 'MZ' is the DOS header every PE begins with. -$vsHead = [IO.File]::ReadAllBytes($VsBootstrapper) | Select-Object -First 2 -if (-not ($vsHead.Count -eq 2 -and $vsHead[0] -eq 0x4D -and $vsHead[1] -eq 0x5A)) { - throw "Visual Studio: $VsInstallerUrl did not return an executable (no MZ header; $((Get-Item $VsBootstrapper).Length) bytes). Check `$VsChannel / `$VsChannelPath / `$VsEdition." +if ($DoVs -and -not $VsNeedsInstaller -and $InstallPath) { + Write-Step 'Visual Studio' + Write-Host " Every selected component group is already in $InstallPath; not downloading the installer." +} +if ($VsNeedsInstaller) { + Write-Step "Downloading VS $VsChannel $VsEdition bootstrapper (host: $HostArch)" + # aka.ms serves the bootstrapper for the requesting machine's architecture, so on + # ARM64 this is the native ARM64 installer - no --arch flag needed or offered. + $VsInstallerUrl = "https://aka.ms/vs/$VsChannel/$VsChannelPath/vs_$VsEdition.exe" + $VsBootstrapper = Join-Path $TempDir "vs_$VsEdition.exe" + Write-Host " $VsInstallerUrl" -ForegroundColor DarkGray + Invoke-WebRequest -Uri $VsInstallerUrl -OutFile $VsBootstrapper -UseBasicParsing + + # Prove we got an installer and not a web page. The Bing redirect described above + # returns 200 with HTML, and every other aka.ms typo behaves the same way, so a + # bad channel/edition combination is otherwise only discovered when the "exe" + # fails to start. 'MZ' is the DOS header every PE begins with. + $vsHead = [IO.File]::ReadAllBytes($VsBootstrapper) | Select-Object -First 2 + if (-not ($vsHead.Count -eq 2 -and $vsHead[0] -eq 0x4D -and $vsHead[1] -eq 0x5A)) { + throw "Visual Studio: $VsInstallerUrl did not return an executable (no MZ header; $((Get-Item $VsBootstrapper).Length) bytes). Check `$VsChannel / `$VsChannelPath / `$VsEdition." + } + Write-Host " OK: bootstrapper is a PE ($([math]::Round((Get-Item $VsBootstrapper).Length / 1MB, 2)) MB)" } -Write-Host " OK: bootstrapper is a PE ($([math]::Round((Get-Item $VsBootstrapper).Length / 1MB, 2)) MB)" # Install in three sequential passes. The base set is installed first (this is # the configuration that previously worked); Clang and the XP toolset are added @@ -656,22 +788,27 @@ Write-Host " OK: bootstrapper is a PE ($([math]::Round((Get-Item $VsBootstrap # needed to build with MSVC, and on a host where one of them is simply not # offered a hard failure here would cost you the whole toolchain over a component # you can add later from the installer UI. -Invoke-VsModify -Label 'base toolset + workload' -Ids $BaseComponents -if ($IsArm64) { - Write-Step 'Visual Studio: Clang / LLVM' - Write-Host ' Skipped on ARM64 by choice, not by limitation: a native ARM64 clang-cl is' -ForegroundColor Yellow - Write-Host ' offered here and works. This box is provisioned with MSVC alone; the' -ForegroundColor Yellow - Write-Host ' multi-compiler builds belong on the x64 machine.' -ForegroundColor Yellow -} else { - Invoke-VsModify -Label 'Clang / LLVM' -Ids $ClangComponents -Optional +# +# On ARM64 only the first pass is ever selected: VsClang and VsWinXP are +# -X64Only in setup-windows-steps.ps1, and the plan printed at the top says why. +# +# The last two MODIFY an existing install. With the base step switched off and +# no Visual Studio of this generation on the box, there is nothing to modify - +# and letting the pass run anyway would install a Visual Studio holding that one +# component group and no C++ workload. So they say so and stand down instead. +if ($DoVsBase) { + Invoke-VsModify -Label 'base toolset + workload' -Ids $BaseComponents } -if ($IsArm64) { - Write-Step 'Visual Studio: Windows XP (v141 + WinXP)' - Write-Host ' Skipped on ARM64: the v141 (14.16) toolset ships x86/x64-hosted compilers only,' -ForegroundColor Yellow - Write-Host ' and Windows XP targeting is not offered for Arm hosts. Build x86 with the' -ForegroundColor Yellow - Write-Host ' current toolset instead (cmake -A Win32), which is native here.' -ForegroundColor Yellow -} else { - Invoke-VsModify -Label 'Windows XP (v141 + WinXP)' -Ids $XpComponents -Optional +foreach ($pass in @( + @{ Run = $DoVsClang; Label = 'Clang / LLVM'; Ids = $ClangComponents } + @{ Run = $DoVsXp; Label = 'Windows XP (v141 + WinXP)'; Ids = $XpComponents })) { + if (-not $pass.Run) { continue } + if ($InstallPath) { + Invoke-VsModify -Label $pass.Label -Ids $pass.Ids -Optional + } else { + Write-Step "Visual Studio: $($pass.Label)" + Write-Warning "No Visual Studio $VsChannel is installed to add this to. Select the VisualStudio step as well." + } } # --------------------------------------------------------------------------- @@ -687,78 +824,85 @@ if ($IsArm64) { # the installer UI, not a reason to fail a provisioning run that installed a # working compiler. # --------------------------------------------------------------------------- -Write-Step 'Verifying the installed toolsets' -$InstallPath = Get-VsInstallPath -if (-not $InstallPath) { - Write-Warning "No Visual Studio $VsChannel installation found after the passes above; cannot verify toolsets." -} else { - Write-Host " Install path: $InstallPath" - - # What MSVC versions landed, and which host toolchains each one carries. - # HostARM64 is the directory that proves the native ARM64 compiler is here - # rather than an x64 one that would run under emulation. - $msvcRoot = Join-Path $InstallPath 'VC\Tools\MSVC' - foreach ($v in (Get-ChildItem $msvcRoot -Directory -ErrorAction SilentlyContinue | Sort-Object Name)) { - $hosts = Get-ChildItem (Join-Path $v.FullName 'bin') -Directory -ErrorAction SilentlyContinue | - ForEach-Object { $_.Name } - Write-Host " MSVC $($v.Name): $(if ($hosts) { $hosts -join ', ' } else { '(no bin dir)' })" - } - if ($IsArm64) { - $armHost = Test-Path (Join-Path $msvcRoot '*\bin\HostARM64\ARM64\cl.exe') - if ($armHost) { - Write-Host ' OK: native ARM64-hosted cl.exe present.' -ForegroundColor Green - } else { - Write-Warning 'No HostARM64 cl.exe found - MSVC would run under x64 emulation. Add "MSVC v14x - VS 2022 C++ ARM64/ARM64EC build tools" in the installer.' +if ($DoVs) { + Write-Step 'Verifying the installed toolsets' + $InstallPath = Get-VsInstallPath + if (-not $InstallPath) { + Write-Warning "No Visual Studio $VsChannel installation found after the passes above; cannot verify toolsets." + } else { + Write-Host " Install path: $InstallPath" + + # What MSVC versions landed, and which host toolchains each one carries. + # HostARM64 is the directory that proves the native ARM64 compiler is here + # rather than an x64 one that would run under emulation. + $msvcRoot = Join-Path $InstallPath 'VC\Tools\MSVC' + foreach ($v in (Get-ChildItem $msvcRoot -Directory -ErrorAction SilentlyContinue | Sort-Object Name)) { + $hosts = Get-ChildItem (Join-Path $v.FullName 'bin') -Directory -ErrorAction SilentlyContinue | + ForEach-Object { $_.Name } + Write-Host " MSVC $($v.Name): $(if ($hosts) { $hosts -join ', ' } else { '(no bin dir)' })" + } + if ($IsArm64) { + $armHost = Test-Path (Join-Path $msvcRoot '*\bin\HostARM64\ARM64\cl.exe') + if ($armHost) { + Write-Host ' OK: native ARM64-hosted cl.exe present.' -ForegroundColor Green + } else { + Write-Warning 'No HostARM64 cl.exe found - MSVC would run under x64 emulation. Add "MSVC v14x - VS 2022 C++ ARM64/ARM64EC build tools" in the installer.' + } } - } - # clang-cl, for the ClangCL toolset in CMakePresets.json. Checked per host - # directory, because the Llvm tree is partitioned by HOST architecture and - # only the matching one is a native compiler. - # - # Look for clang-cl.exe specifically, NOT for the directory. VC\Tools\Llvm\*\bin - # holds clang-format.exe and clang-tidy.exe on every host whether or not the - # Clang component was ever installed - those ship with the NativeDesktop - # workload - so a present ARM64\bin proves nothing on its own. That is the - # false positive to avoid when checking this by hand. - $llvmRoot = Join-Path $InstallPath 'VC\Tools\Llvm' - $clangArm = Test-Path (Join-Path $llvmRoot 'ARM64\bin\clang-cl.exe') - $clangX64 = Test-Path (Join-Path $llvmRoot 'x64\bin\clang-cl.exe') - $clangX86 = Test-Path (Join-Path $llvmRoot 'bin\clang-cl.exe') - $clangAny = $clangArm -or $clangX64 -or $clangX86 - if ($IsArm64) { - # Not installed here by design, so its absence is the expected result and - # must not read as a fault. Its PRESENCE is the thing worth a line: a box - # provisioned before this split still carries it, and the component stays - # until it is explicitly removed - the installer never uninstalls what you - # simply stopped asking for. - if ($clangAny) { - Write-Host ' clang-cl: present but no longer provisioned on ARM64 - left over from an earlier run.' -ForegroundColor Yellow - Write-Host ' Remove it with: vs_installer.exe modify --installPath "" --remove Microsoft.VisualStudio.Component.VC.Llvm.Clang --remove Microsoft.VisualStudio.Component.VC.Llvm.ClangToolset --quiet' -ForegroundColor Yellow + # clang-cl, for the ClangCL toolset in CMakePresets.json. Checked per host + # directory, because the Llvm tree is partitioned by HOST architecture and + # only the matching one is a native compiler. + # + # Look for clang-cl.exe specifically, NOT for the directory. VC\Tools\Llvm\*\bin + # holds clang-format.exe and clang-tidy.exe on every host whether or not the + # Clang component was ever installed - those ship with the NativeDesktop + # workload - so a present ARM64\bin proves nothing on its own. That is the + # false positive to avoid when checking this by hand. + $llvmRoot = Join-Path $InstallPath 'VC\Tools\Llvm' + $clangArm = Test-Path (Join-Path $llvmRoot 'ARM64\bin\clang-cl.exe') + $clangX64 = Test-Path (Join-Path $llvmRoot 'x64\bin\clang-cl.exe') + $clangX86 = Test-Path (Join-Path $llvmRoot 'bin\clang-cl.exe') + $clangAny = $clangArm -or $clangX64 -or $clangX86 + if ($IsArm64) { + # Not installed here by design, so its absence is the expected result and + # must not read as a fault. Its PRESENCE is the thing worth a line: a box + # provisioned before this split still carries it, and the component stays + # until it is explicitly removed - the installer never uninstalls what you + # simply stopped asking for. + if ($clangAny) { + Write-Host ' clang-cl: present but no longer provisioned on ARM64 - left over from an earlier run.' -ForegroundColor Yellow + Write-Host ' Remove it with: vs_installer.exe modify --installPath "" --remove Microsoft.VisualStudio.Component.VC.Llvm.Clang --remove Microsoft.VisualStudio.Component.VC.Llvm.ClangToolset --quiet' -ForegroundColor Yellow + } else { + Write-Host ' clang-cl: n/a on ARM64 (MSVC only by design; use the x64 box for ClangCL).' -ForegroundColor DarkGray + } + } elseif ($clangAny) { + Write-Host " clang-cl: $(@(if ($clangArm) {'ARM64'}; if ($clangX64) {'x64'}; if ($clangX86) {'x86'}) -join ', ')" -ForegroundColor Green + } elseif (-not $DoVsClang) { + # Switched off in the plan: a choice, so not worth a warning. + Write-Host ' clang-cl: not installed (the VsClang step was not selected).' -ForegroundColor DarkGray } else { - Write-Host ' clang-cl: n/a on ARM64 (MSVC only by design; use the x64 box for ClangCL).' -ForegroundColor DarkGray + Write-Warning 'clang-cl not found - the ClangCL presets will fail. Add the "C++ Clang tools for Windows" component.' } - } elseif ($clangAny) { - Write-Host " clang-cl: $(@(if ($clangArm) {'ARM64'}; if ($clangX64) {'x64'}; if ($clangX86) {'x86'}) -join ', ')" -ForegroundColor Green - } else { - Write-Warning 'clang-cl not found - the ClangCL presets will fail. Add the "C++ Clang tools for Windows" component.' - } - # v141 / XP. Only meaningful where the toolset can exist at all; on ARM64 the - # group above was deliberately skipped, so warning here would be noise about - # a decision this script made on purpose two steps ago. - if ($IsArm64) { - Write-Host ' v141 / Windows XP toolset: n/a on ARM64 (not offered for Arm hosts).' -ForegroundColor DarkGray - } else { - $V141 = Get-ChildItem $msvcRoot -Directory -ErrorAction SilentlyContinue | - Where-Object { $_.Name -like '14.16.*' } | Select-Object -First 1 - if ($V141) { - Write-Host " OK: v141 toolset present ($($V141.Name))" -ForegroundColor Green + # v141 / XP. Only meaningful where the toolset can exist at all; on ARM64 the + # group above was deliberately skipped, so warning here would be noise about + # a decision this script made on purpose two steps ago. + if ($IsArm64) { + Write-Host ' v141 / Windows XP toolset: n/a on ARM64 (not offered for Arm hosts).' -ForegroundColor DarkGray } else { - Write-Warning 'v141 (14.16.x) toolset NOT found - the XP build presets will fail.' - Write-Warning 'Add it via Visual Studio Installer > Modify > Individual components:' - Write-Warning ' - MSVC v141 - VS 2017 C++ x64/x86 build tools (v14.16)' - Write-Warning ' - C++ Windows XP Support for VS 2017 (v141) tools' + $V141 = Get-ChildItem $msvcRoot -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.Name -like '14.16.*' } | Select-Object -First 1 + if ($V141) { + Write-Host " OK: v141 toolset present ($($V141.Name))" -ForegroundColor Green + } elseif (-not $DoVsXp) { + Write-Host ' v141 / Windows XP toolset: not installed (the VsWinXP step was not selected).' -ForegroundColor DarkGray + } else { + Write-Warning 'v141 (14.16.x) toolset NOT found - the XP build presets will fail.' + Write-Warning 'Add it via Visual Studio Installer > Modify > Individual components:' + Write-Warning ' - MSVC v141 - VS 2017 C++ x64/x86 build tools (v14.16)' + Write-Warning ' - C++ Windows XP Support for VS 2017 (v141) tools' + } } } } @@ -788,47 +932,46 @@ if (-not $InstallPath) { # pairs VS 2026 with the 28000.x kit - not this link. # # On x64 with VS 2022 the SDK and this WDK are the matched pair, so it installs. +# +# The x64-only rule itself is the Wdk step's -X64Only in setup-windows-steps.ps1, +# so on ARM64 this step never runs and the plan says why. # --------------------------------------------------------------------------- -$WdkVersion = '10.0.26100' -# Both hives. The WDK installer is a 32-bit program, so on x64 it writes under -# WOW6432Node - but which hive a given kit lands in has varied across kit -# versions and architectures, and reading only one of them makes an installed WDK -# look absent, which costs a needless multi-GB reinstall on every run. Check the -# native hive too and take whichever answers. -$WdkInstalledRoot = @( - 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows Kits\Installed Roots' - 'HKLM:\SOFTWARE\Microsoft\Windows Kits\Installed Roots' -) | ForEach-Object { (Get-ItemProperty $_ -ErrorAction SilentlyContinue).WdkBinRootVersioned } | - Where-Object { $_ } | Select-Object -First 1 +if (Test-SetupStep 'Wdk') { + $WdkVersion = '10.0.26100' + # Both hives. The WDK installer is a 32-bit program, so on x64 it writes under + # WOW6432Node - but which hive a given kit lands in has varied across kit + # versions and architectures, and reading only one of them makes an installed WDK + # look absent, which costs a needless multi-GB reinstall on every run. Check the + # native hive too and take whichever answers. + $WdkInstalledRoot = @( + 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows Kits\Installed Roots' + 'HKLM:\SOFTWARE\Microsoft\Windows Kits\Installed Roots' + ) | ForEach-Object { (Get-ItemProperty $_ -ErrorAction SilentlyContinue).WdkBinRootVersioned } | + Where-Object { $_ } | Select-Object -First 1 -if ($IsArm64) { - Write-Step 'Windows Driver Kit' - Write-Host ' Skipped on ARM64: this box builds user-mode software only, and WDK 26100.6584' -ForegroundColor Yellow - Write-Host ' cannot install beside the 26100.7705 SDK that Visual Studio 2026 brings - the' -ForegroundColor Yellow - Write-Host ' kit needs the matching SDK revision and they are not side-by-side. See the' -ForegroundColor Yellow - Write-Host ' comment above this step for the exit code and what to use instead.' -ForegroundColor Yellow -} elseif ($WdkInstalledRoot -and $WdkInstalledRoot -match [regex]::Escape($WdkVersion)) { - # Re-running wdksetup.exe for an already-present version returns exit code - # 2008 (maintenance mode / nothing to do), which is not a real failure. - Write-Step "WDK $WdkVersion already installed - skipping ($WdkInstalledRoot)" -} else { - Write-Step 'Downloading WDK installer' - $WdkUrl = 'https://go.microsoft.com/fwlink/?linkid=2335869' - $WdkInstaller = Join-Path $TempDir 'wdksetup.exe' - Invoke-WebRequest -Uri $WdkUrl -OutFile $WdkInstaller -UseBasicParsing - - Write-Step 'Installing WDK' - # wdksetup.exe is a 32-bit binary and runs under emulation on ARM64; the kit - # it lays down does include the ARM64 target headers, libs and tools (the - # signing/deployment tools under bin\arm64), so an ARM64 driver builds from - # an ARM64 host. Only the installer is emulated, not the toolchain. - $proc = Start-Process -FilePath $WdkInstaller -ArgumentList '/quiet /norestart' -Wait -PassThru -NoNewWindow - Write-Host " WDK installer exit code: $($proc.ExitCode)" - if ($proc.ExitCode -eq 2008) { - # 2008 = the WDK is already present; the installer has nothing to do. - Write-Host ' [WDK already installed (exit 2008) - treating as success]' -ForegroundColor Yellow + if ($WdkInstalledRoot -and $WdkInstalledRoot -match [regex]::Escape($WdkVersion)) { + # Re-running wdksetup.exe for an already-present version returns exit code + # 2008 (maintenance mode / nothing to do), which is not a real failure. + Write-Step "WDK $WdkVersion already installed - skipping ($WdkInstalledRoot)" } else { - Assert-ExitCode $proc.ExitCode 'WDK' + Write-Step 'Downloading WDK installer' + $WdkUrl = 'https://go.microsoft.com/fwlink/?linkid=2335869' + $WdkInstaller = Join-Path $TempDir 'wdksetup.exe' + Invoke-WebRequest -Uri $WdkUrl -OutFile $WdkInstaller -UseBasicParsing + + Write-Step 'Installing WDK' + # wdksetup.exe is a 32-bit binary and runs under emulation on ARM64; the kit + # it lays down does include the ARM64 target headers, libs and tools (the + # signing/deployment tools under bin\arm64), so an ARM64 driver builds from + # an ARM64 host. Only the installer is emulated, not the toolchain. + $proc = Start-Process -FilePath $WdkInstaller -ArgumentList '/quiet /norestart' -Wait -PassThru -NoNewWindow + Write-Host " WDK installer exit code: $($proc.ExitCode)" + if ($proc.ExitCode -eq 2008) { + # 2008 = the WDK is already present; the installer has nothing to do. + Write-Host ' [WDK already installed (exit 2008) - treating as success]' -ForegroundColor Yellow + } else { + Assert-ExitCode $proc.ExitCode 'WDK' + } } } @@ -858,66 +1001,68 @@ if ($IsArm64) { # # Idempotent and non-fatal - it never aborts provisioning. # --------------------------------------------------------------------------- -Write-Step 'Windows Performance Toolkit (xperf / wpr / WPA)' -$WptDirs = @( - (Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\Windows Performance Toolkit'), - (Join-Path $env:ProgramFiles 'Windows Kits\10\Windows Performance Toolkit'), - (Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\Assessment and Deployment Kit\Windows Performance Toolkit') -) -function Find-WptDir { $script:WptDirs | Where-Object { Test-Path (Join-Path $_ 'xperf.exe') } | Select-Object -First 1 } +if (Test-SetupStep 'Wpt') { + Write-Step 'Windows Performance Toolkit (xperf / wpr / WPA)' + $WptDirs = @( + (Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\Windows Performance Toolkit'), + (Join-Path $env:ProgramFiles 'Windows Kits\10\Windows Performance Toolkit'), + (Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\Assessment and Deployment Kit\Windows Performance Toolkit') + ) + function Find-WptDir { $script:WptDirs | Where-Object { Test-Path (Join-Path $_ 'xperf.exe') } | Select-Object -First 1 } -$WptDir = Find-WptDir -if ($WptDir) { - Write-Host " OK: WPT already present ($WptDir)" -ForegroundColor Green -} else { - try { - # The ADK manifest offers no ARM64 installer, so on ARM64 winget fetches - # the x64 one; it runs under emulation and lays down a toolkit that does - # include the ARM64 binaries. The SDK feature is the lighter route on any - # architecture and is worth preferring if this fallback ever gives - # trouble - see the winsdksetup.exe line in the comment above. - if ($IsArm64) { - Write-Host ' ARM64: the ADK installer is x64 (emulated); the toolkit it installs is ARM64.' -ForegroundColor Yellow + $WptDir = Find-WptDir + if ($WptDir) { + Write-Host " OK: WPT already present ($WptDir)" -ForegroundColor Green + } else { + try { + # The ADK manifest offers no ARM64 installer, so on ARM64 winget fetches + # the x64 one; it runs under emulation and lays down a toolkit that does + # include the ARM64 binaries. The SDK feature is the lighter route on any + # architecture and is worth preferring if this fallback ever gives + # trouble - see the winsdksetup.exe line in the comment above. + if ($IsArm64) { + Write-Host ' ARM64: the ADK installer is x64 (emulated); the toolkit it installs is ARM64.' -ForegroundColor Yellow + } + winget install --id Microsoft.WindowsADK --exact --silent --disable-interactivity ` + --accept-source-agreements --accept-package-agreements + Write-Host ' Windows ADK (includes Windows Performance Toolkit) installed.' + $WptDir = Find-WptDir + } catch { + Write-Warning "WPT install failed: $($_.Exception.Message)" + Write-Warning 'Install manually: winget install Microsoft.WindowsADK, or add the' + Write-Warning 'Windows SDK "Windows Performance Toolkit" optional feature.' } - winget install --id Microsoft.WindowsADK --exact --silent --disable-interactivity ` - --accept-source-agreements --accept-package-agreements - Write-Host ' Windows ADK (includes Windows Performance Toolkit) installed.' - $WptDir = Find-WptDir - } catch { - Write-Warning "WPT install failed: $($_.Exception.Message)" - Write-Warning 'Install manually: winget install Microsoft.WindowsADK, or add the' - Write-Warning 'Windows SDK "Windows Performance Toolkit" optional feature.' } -} -if ($WptDir) { - # Report what actually landed. wpa.exe is the piece people come looking for - # and it is the one that is absent if a trimmed toolkit ever shows up. - foreach ($tool in 'xperf.exe', 'wpr.exe', 'wpa.exe', 'wpaexporter.exe') { - $p = Join-Path $WptDir $tool - if (Test-Path $p) { - Write-Host " $tool $((Get-Item $p).VersionInfo.ProductVersion)" - } else { - Write-Warning "$tool is missing from $WptDir" + if ($WptDir) { + # Report what actually landed. wpa.exe is the piece people come looking for + # and it is the one that is absent if a trimmed toolkit ever shows up. + foreach ($tool in 'xperf.exe', 'wpr.exe', 'wpa.exe', 'wpaexporter.exe') { + $p = Join-Path $WptDir $tool + if (Test-Path $p) { + Write-Host " $tool $((Get-Item $p).VersionInfo.ProductVersion)" + } else { + Write-Warning "$tool is missing from $WptDir" + } } - } - # The WPT installer normally adds this to the machine PATH itself (and the - # Start Menu gets "Windows Kits > Windows Performance Toolkit" shortcuts for - # WPA and WPR). Re-assert it anyway: on the machine PATH rather than a user - # one so it also resolves for the non-interactive sshd sessions this box is - # driven through, which build their environment from the registry PATH. - # Compared trailing-backslash-insensitively - the installer's own entry has - # one, and adding a second spelling of the same directory is just noise. - $m = [Environment]::GetEnvironmentVariable('Path', 'Machine') - if (-not $m) { $m = '' } - $have = ($m -split ';') | Where-Object { $_.TrimEnd('\') -eq $WptDir.TrimEnd('\') } - if ($have) { - Write-Host " OK: $WptDir already in the machine PATH" - } else { - $new = if ($m.Trim()) { $m.TrimEnd(';') + ';' + $WptDir } else { $WptDir } - [Environment]::SetEnvironmentVariable('Path', $new, 'Machine') - Write-Host " Added $WptDir to the machine PATH (restart shells to pick it up)." + # The WPT installer normally adds this to the machine PATH itself (and the + # Start Menu gets "Windows Kits > Windows Performance Toolkit" shortcuts for + # WPA and WPR). Re-assert it anyway: on the machine PATH rather than a user + # one so it also resolves for the non-interactive sshd sessions this box is + # driven through, which build their environment from the registry PATH. + # Compared trailing-backslash-insensitively - the installer's own entry has + # one, and adding a second spelling of the same directory is just noise. + $m = [Environment]::GetEnvironmentVariable('Path', 'Machine') + if (-not $m) { $m = '' } + $have = ($m -split ';') | Where-Object { $_.TrimEnd('\') -eq $WptDir.TrimEnd('\') } + if ($have) { + Write-Host " OK: $WptDir already in the machine PATH" + } else { + $new = if ($m.Trim()) { $m.TrimEnd(';') + ';' + $WptDir } else { $WptDir } + [Environment]::SetEnvironmentVariable('Path', $new, 'Machine') + Write-Host " Added $WptDir to the machine PATH (restart shells to pick it up)." + } } } @@ -939,41 +1084,52 @@ if ($WptDir) { # and run it elevated; it installs unattended with # intel-vtune-_offline.exe -a --silent --cli --eula accept # --------------------------------------------------------------------------- -Write-Step 'Intel VTune Profiler (status only)' -if ($IsArm64) { - # Not a "not installed yet" case - there is no Windows-on-Arm build of VTune, - # and there is nothing for it to sample: its whole value is reading Intel PMU - # counters. Say so plainly and point at what does work here, rather than - # printing a download link for a product this box cannot run. - Write-Host ' n/a on ARM64: Intel ships no Windows-on-Arm build, and hardware event-based' -ForegroundColor DarkGray - Write-Host ' sampling reads Intel PMU counters. Use the Windows Performance Toolkit above' -ForegroundColor DarkGray - Write-Host ' (wpr / xperf to collect, wpa to analyse) for profiling on this box.' -ForegroundColor DarkGray - Write-Host ' Arm also publishes Arm Performance Studio / Streamline for Arm PMU sampling.' -ForegroundColor DarkGray -} else { - $UninstallKeys = @( - 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*' - 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' - ) - $vtune = Get-ItemProperty $UninstallKeys -ErrorAction SilentlyContinue | - Where-Object { $_.DisplayName -match 'VTune' } | - Select-Object -First 1 - if ($vtune) { - Write-Host " Installed: $($vtune.DisplayName.Trim()) $($vtune.DisplayVersion)" -ForegroundColor Green - # The oneAPI layout keeps a `latest` junction beside the versioned directory, - # so this path stays right across upgrades. - $VTuneCli = Join-Path $vtune.InstallLocation 'vtune\latest\bin64\vtune.exe' - if (Test-Path $VTuneCli) { Write-Host " CLI: $VTuneCli" } +if (Test-SetupStep 'VTune') { + Write-Step 'Intel VTune Profiler (status only)' + if ($IsArm64) { + # Not a "not installed yet" case - there is no Windows-on-Arm build of VTune, + # and there is nothing for it to sample: its whole value is reading Intel PMU + # counters. Say so plainly and point at what does work here, rather than + # printing a download link for a product this box cannot run. + Write-Host ' n/a on ARM64: Intel ships no Windows-on-Arm build, and hardware event-based' -ForegroundColor DarkGray + Write-Host ' sampling reads Intel PMU counters. Use the Windows Performance Toolkit above' -ForegroundColor DarkGray + Write-Host ' (wpr / xperf to collect, wpa to analyse) for profiling on this box.' -ForegroundColor DarkGray + Write-Host ' Arm also publishes Arm Performance Studio / Streamline for Arm PMU sampling.' -ForegroundColor DarkGray } else { - Write-Host ' Not installed.' -ForegroundColor Yellow - Write-Host ' https://www.intel.com/content/www/us/en/developer/tools/oneapi/vtune-profiler-download.html' -ForegroundColor Yellow - $cpu = (Get-CimInstance Win32_Processor -ErrorAction SilentlyContinue | Select-Object -First 1).Manufacturer - if ($cpu -and $cpu -notmatch 'Intel') { - Write-Host " (This CPU reports itself as '$cpu' - VTune's hardware event-based sampling wants Intel silicon.)" -ForegroundColor Yellow + $UninstallKeys = @( + 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*' + 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' + ) + $vtune = Get-ItemProperty $UninstallKeys -ErrorAction SilentlyContinue | + Where-Object { $_.DisplayName -match 'VTune' } | + Select-Object -First 1 + if ($vtune) { + Write-Host " Installed: $($vtune.DisplayName.Trim()) $($vtune.DisplayVersion)" -ForegroundColor Green + # The oneAPI layout keeps a `latest` junction beside the versioned directory, + # so this path stays right across upgrades. + $VTuneCli = Join-Path $vtune.InstallLocation 'vtune\latest\bin64\vtune.exe' + if (Test-Path $VTuneCli) { Write-Host " CLI: $VTuneCli" } + } else { + Write-Host ' Not installed.' -ForegroundColor Yellow + Write-Host ' https://www.intel.com/content/www/us/en/developer/tools/oneapi/vtune-profiler-download.html' -ForegroundColor Yellow + $cpu = (Get-CimInstance Win32_Processor -ErrorAction SilentlyContinue | Select-Object -First 1).Manufacturer + if ($cpu -and $cpu -notmatch 'Intel') { + Write-Host " (This CPU reports itself as '$cpu' - VTune's hardware event-based sampling wants Intel silicon.)" -ForegroundColor Yellow + } } } } # --------------------------------------------------------------------------- +# A selected step that no section above asked about means this script and the +# catalog disagree - an Id renamed in one and not the other, or a row added with +# nothing behind it. That is a failure: the plan promised a step that was not +# taken. +$neverAsked = @($RunSteps | Where-Object { -not $AskedSteps.Contains($_) }) +if ($neverAsked) { + throw "Selected in the plan but not implemented by setup-windows-with-uac.ps1: $($neverAsked -join ', ')" +} + Write-Host "`nAll done." -ForegroundColor Green Write-Host "Host architecture was $HostArch." Write-Host 'If a reboot was flagged above, restart before opening VS or building drivers.' diff --git a/setup-windows.bat b/setup-windows.bat index c9c7efb..7445de5 100644 --- a/setup-windows.bat +++ b/setup-windows.bat @@ -1,10 +1,17 @@ @echo off +@rem The plan below hands back a few dozen STEP_/WHY_/PKG_ variables; keep them, +@rem and everything else this script sets, out of the calling cmd session. +setlocal @rem --------------------------------------------------------------------------- @rem setup-windows.bat - provision a fresh Windows box for native development @rem @rem Runs on x64 and on ARM64 (Windows 11 on Arm). See the ARM64 block below for @rem what changes on Arm silicon. +@rem +@rem Before it does anything, it shows the plan - every step it and its two +@rem halves can take, marked for this machine - and lets you choose. See "The +@rem plan" below, or run `setup-windows.bat /?`. @rem --------------------------------------------------------------------------- @rem --------------------------------------------------------------------------- @@ -22,14 +29,14 @@ @rem So take the machine-level registry value as the answer and keep the @rem environment pair only as the fallback. The registry key is per-machine and @rem not subject to per-process emulation, and it is not one of the redirected -@rem hives. This is load-bearing now: %IS_ARM64% decides which packages are -@rem installed at all, so reading it wrong provisions the wrong machine. +@rem hives. This is load-bearing now: the architecture decides which packages +@rem are installed at all, so reading it wrong provisions the wrong machine. @rem -@rem The value drives three things below, and nothing else - winget resolves the +@rem The value drives two things below, and nothing else - winget resolves the @rem installer architecture on its own (native first, then whatever the box can @rem emulate), so the ordinary installs need no help: -@rem - VirtualBox is skipped on ARM64 (see below) -@rem - the x64-only package set is skipped on ARM64 (see :x64only) +@rem - the plan: every x64-only step, VirtualBox included, is marked unavailable +@rem on ARM64 and so never selected (see setup-windows-steps.ps1) @rem - the summary tells you which tools you got as emulated x64 @rem --------------------------------------------------------------------------- set "HOST_ARCH=%PROCESSOR_ARCHITECTURE%" @@ -63,8 +70,8 @@ for /f "usebackq tokens=*" %%A in (`powershell -NoProfile -ExecutionPolicy Bypas echo [setup-windows] Elevation: %ELEV% if "%ELEV%"=="NOLUA" ( echo [setup-windows] This account cannot elevate ^(UAC off, not an administrator^). Packages that - echo [setup-windows] need a machine-wide install will be SKIPPED rather than left to fail; details - echo [setup-windows] at each one, and a summary before the elevated half. + echo [setup-windows] need a machine-wide install will be SKIPPED rather than left to fail; the + echo [setup-windows] plan below marks each one, and there is a summary before the elevated half. ) @rem --------------------------------------------------------------------------- @@ -73,10 +80,12 @@ if "%ELEV%"=="NOLUA" ( @rem The ARM64 machine here is a small VM, and everything it was installing @rem without a native build was going to run under Prism emulation anyway. So the @rem two architectures now provision different things on purpose: x64 keeps the -@rem full set, ARM64 installs ONE compiler - MSVC from Visual Studio - and +@rem full set, ARM64 installs ONE C/C++ compiler - MSVC from Visual Studio - and @rem nothing that merely duplicates it or that exists only as an x64 binary. +@rem Rust does not break that rule: it is a different language, not a second +@rem C/C++ compiler, and its native aarch64 MSVC toolchain links through MSVC. @rem -@rem Dropped on ARM64, and where each one is dropped: +@rem Dropped on ARM64, and which script would otherwise take each one: @rem Brave.Brave here no ARM64 build; Edge is in-box and native @rem WinMerge.WinMerge here ARM64 build is machine-scope only @rem LLVM.LLVM here second Clang, dropped with VS's clang-cl @@ -87,9 +96,94 @@ if "%ELEV%"=="NOLUA" ( @rem Windows Driver Kit with-uac see the WDK step @rem BinSkim no-uac NuGet publishes win-x64 only @rem -@rem :x64only at the bottom of this file is the one place that decision is -@rem applied, so the skip and its reason land in the log together. +@rem The decision itself is made in ONE place, setup-windows-steps.ps1, which +@rem marks each of these -X64Only with its reason. The plan shows them as [-] +@rem with that reason before anything runs, and :want prints it again in the log +@rem where the step would have run, so the skip and its reason stay together. +@rem --------------------------------------------------------------------------- + @rem --------------------------------------------------------------------------- +@rem The plan: what this run will do, shown BEFORE any of it happens +@rem +@rem setup-windows-plan.ps1 lists every step this file and its two halves can +@rem take - from the catalog in setup-windows-steps.ps1, marked for this host's +@rem architecture and elevation - and lets you switch steps off and on. Nothing +@rem below runs unless it was on that list and still selected when you pressed +@rem Enter; with no console to ask on, it stops unless given -Yes. Arguments to +@rem this script go straight to the planner: +@rem setup-windows.bat -WhatIf show the plan, do nothing +@rem setup-windows.bat -Skip VirtualBox,Brave start with those switched off +@rem setup-windows.bat -Only E -Yes just the elevated half, no questions +@rem +@rem The choice comes back as a scratch .cmd of `set` lines, called here: +@rem STEP_, WHY_, PKG_ per step this file takes - see :want +@rem UAC_STEPS, USER_STEPS comma lists passed to the halves as -Only +@rem PLAN_RUN, PLAN_RC go on (1), or stop and exit with PLAN_RC +@rem No file at all means the planner failed - it exits 0 even on a mistyped +@rem parameter - so that stops the run too, before anything has been done. +@rem --------------------------------------------------------------------------- +set "PLAN_ARGS=%*" +if "%~1"=="/?" set "PLAN_ARGS=-Help" +if "%~1"=="-?" set "PLAN_ARGS=-Help" +set "PLAN_CMD=%TEMP%\setup-windows-plan-%RANDOM%%RANDOM%.cmd" +if exist "%PLAN_CMD%" del "%PLAN_CMD%" +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0setup-windows-plan.ps1" -HostArch "%HOST_ARCH%" -Elevation "%ELEV%" -OutFile "%PLAN_CMD%" %PLAN_ARGS% +if not exist "%PLAN_CMD%" ( + echo [setup-windows] No plan was made ^(see above^), so nothing has been done. + exit /b 1 +) +call "%PLAN_CMD%" +del "%PLAN_CMD%" +if not "%PLAN_RUN%"=="1" exit /b %PLAN_RC% + +@rem --- Claude Code, from Anthropic's own installer rather than winget --- +@rem +@rem NOT `winget install Anthropic.ClaudeCode` any more: recent releases are +@rem broken through that route. This is the documented native install, +@rem `irm https://claude.ai/install.ps1 | iex`. The script downloads the current +@rem release's claude.exe, checks it against the SHA-256 in that release's +@rem manifest, and runs `claude install`, which puts the launcher in +@rem %USERPROFILE%\.local\bin - per-user, no elevation. Re-running it updates to +@rem the latest release. +@rem +@rem Two things are fixed up around it, both about which build it gets: +@rem - It picks win32-arm64 or win32-x64 from $env:PROCESSOR_ARCHITECTURE and +@rem nothing else - the variable the block at the top of this file shows to be +@rem WRONG in an emulated x64 shell on ARM64, where it would hand you the +@rem emulated build. So %HOST_ARCH%, the registry's answer, is written into it +@rem for the installer's process first. That only corrects the value; anything +@rem else in that process that reads it sees the truth too. +@rem - It refuses a 32-bit PowerShell outright ("does not support 32-bit +@rem Windows"), and a bare `powershell` from a 32-bit cmd.exe is exactly that. +@rem %SystemRoot%\Sysnative reaches the 64-bit one from there - it exists only +@rem for 32-bit processes - so it is used when present. +@rem +@rem A copy left behind by the old winget route is a second claude.exe, reached +@rem through WinGet\Links on the user PATH, and it can win over this one. It is +@rem reported, not removed: `winget uninstall Anthropic.ClaudeCode` is yours to run. +@rem +@rem The installer has no "already current" check - it downloads and reinstalls +@rem the whole release every time - so setup-windows-current.ps1 asks first: is +@rem the native claude.exe already the latest release, and the build for this +@rem host? Only a clear yes skips it; a check that cannot decide installs. +set "PS64=powershell" +if exist "%SystemRoot%\Sysnative\WindowsPowerShell\v1.0\powershell.exe" set "PS64=%SystemRoot%\Sysnative\WindowsPowerShell\v1.0\powershell.exe" +call :want ClaudeCode || goto :after_claude +"%PS64%" -NoProfile -ExecutionPolicy Bypass -File "%~dp0setup-windows-current.ps1" -Step ClaudeCode -HostArch "%HOST_ARCH%" +if "%ERRORLEVEL%"=="0" goto :claude_leftover +echo [setup-windows] ClaudeCode: irm https://claude.ai/install.ps1 ^| iex ^(as %HOST_ARCH%^) +"%PS64%" -NoProfile -ExecutionPolicy Bypass -Command "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $env:PROCESSOR_ARCHITECTURE = '%HOST_ARCH%'; irm https://claude.ai/install.ps1 | iex" +if not "%ERRORLEVEL%"=="0" echo [setup-windows] WARNING: the Claude Code installer failed ^(exit %ERRORLEVEL%^); see above. Continuing. +:claude_leftover +set "CLAUDE_WINGET=" +if exist "%LOCALAPPDATA%\Microsoft\WinGet\Links\claude.exe" set "CLAUDE_WINGET=1" +for /d %%D in ("%LOCALAPPDATA%\Microsoft\WinGet\Packages\Anthropic.ClaudeCode_*") do set "CLAUDE_WINGET=1" +if defined CLAUDE_WINGET ( + echo [setup-windows] WARNING: Claude Code is ALSO installed through winget ^(Anthropic.ClaudeCode^), the + echo [setup-windows] route this script no longer uses. That claude.exe can win over the new one on + echo [setup-windows] the PATH. Remove it with: winget uninstall Anthropic.ClaudeCode +) +:after_claude @rem --- Non-admin (per-user) winget installs --- @rem @@ -106,11 +200,19 @@ if "%ELEV%"=="NOLUA" ( @rem the zip holds procexp.exe (x86), procexp64.exe (x64) and procexp64a.exe @rem (ARM64). On ARM64, run the *64a.exe variants - plain procexp.exe is the x86 @rem one and will run emulated. -winget install Anthropic.ClaudeCode -call :x64only Brave.Brave "no ARM64 build - the emulated x64 browser is the slowest thing on the box, and Edge is in-box and native" -winget install Git.Git -winget install Microsoft.PowerShell Microsoft.Sysinternals.ProcessExplorer Microsoft.Sysinternals.ProcessMonitor Microsoft.Sysinternals.SDelete Microsoft.VisualStudioCode Microsoft.WindowsTerminal -winget install Python.Python.3.13 +@rem +@rem One :winget call per package, each named by its step in the plan, so any of +@rem them can be switched off. Brave is x64-only: the emulated x64 browser is the +@rem slowest thing on an ARM64 box, and Edge is in-box and native there. +call :winget Brave Brave.Brave +call :winget Git Git.Git +call :winget PowerShell Microsoft.PowerShell +call :winget ProcessExplorer Microsoft.Sysinternals.ProcessExplorer +call :winget ProcessMonitor Microsoft.Sysinternals.ProcessMonitor +call :winget SDelete Microsoft.Sysinternals.SDelete +call :winget VSCode Microsoft.VisualStudioCode +call :winget WindowsTerminal Microsoft.WindowsTerminal +call :winget Python Python.Python.3.13 @rem WinMerge is the one package here where winget's own preference order picks @rem the WRONG architecture, and it is worth knowing why: upstream publishes the @@ -123,7 +225,7 @@ winget install Python.Python.3.13 @rem normal user can install there is the emulated one, and asking for --architecture @rem arm64 just fails unless the run is elevated. VS Code's diff view and @rem `git difftool` cover the same ground natively. -call :x64only WinMerge.WinMerge "upstream ships ARM64 as a machine-scope installer only, so an unelevated winget can install nothing but the emulated x64 build" +call :winget WinMerge WinMerge.WinMerge @rem --- Build drivers, standalone and native --- @rem @@ -149,7 +251,7 @@ call :x64only WinMerge.WinMerge "upstream ships ARM64 as a machine-scope install @rem Ninja here, CMake in the machine-wide group below: Ninja is a portable zip @rem winget unpacks into the user profile, CMake is an MSI that installs for the @rem whole machine. -winget install Ninja-build.Ninja +call :winget Ninja Ninja-build.Ninja @rem --- Second, independent Clang: compiler diversity, on x64 only --- @rem @@ -171,12 +273,49 @@ winget install Ninja-build.Ninja @rem `-T ClangCL` toolset keeps using the VS copy - so the two can be selected @rem independently, but only if you know which one you are asking for. Prefer @rem -DCMAKE_CXX_COMPILER with a full path when you care. -call :x64only LLVM.LLVM "ARM64 carries MSVC alone - VS's clang-cl is dropped there too, so a second Clang has nothing to cross-check against" +call :winget LLVM LLVM.LLVM + +@rem --- Rust, via rustup --- +@rem +@rem Per-user on both architectures: rustup-init keeps rustup and every +@rem toolchain it manages under %USERPROFILE%\.cargo and \.rustup, and puts +@rem .cargo\bin on the user PATH. The manifest publishes an aarch64 rustup-init, +@rem so ARM64 gets a native rustup. +@rem +@rem winget runs it as `rustup-init -y -q`, which also installs a stable +@rem toolchain for whatever host rustup detects. Which toolchain is the DEFAULT - +@rem stable MSVC for this host, x86_64 or aarch64 - is then pinned explicitly by +@rem the RustToolchain step in setup-windows-no-uac.ps1 rather than left to that +@rem detection; see Set-RustDefaultToolchain there for why. +@rem +@rem The -msvc toolchains link with MSVC's link.exe, so building needs the Visual +@rem Studio the elevated half installs. Installing rustup before it is fine: +@rem with -y, rustup-init only warns "installing msvc toolchain without its +@rem prerequisites" (rustup 1.29.1, src/cli/self_update/windows.rs) - it does not +@rem go and install a Visual Studio of its own. +@rem +@rem Safe to re-run as it stands. rustup registers itself in Add/Remove Programs +@rem under the key "Rustup" - the manifest's ProductCode - and rewrites its +@rem DisplayVersion when it updates itself, so winget always sees the version +@rem that is really there and only runs rustup-init again for a newer one. +call :winget Rustup Rustlang.Rustup @rem x64-only manifest: winget falls back to the x64 installer on ARM64 and it runs @rem under Prism emulation. Harmless for what it does here - iperf3 is a network @rem benchmark bounded by the link, not by CPU. -winget install ar51an.iPerf3 +call :winget iPerf3 ar51an.iPerf3 + +@rem Android SDK Platform-Tools: adb and fastboot. A portable zip, so per-user: +@rem winget unpacks it under %LOCALAPPDATA%\Microsoft\WinGet\Packages and puts +@rem that directory on the user PATH, rather than linking the exes one by one, +@rem because adb.exe needs AdbWinApi.dll and AdbWinUsbApi.dll beside it. +@rem +@rem The manifest labels it x64, but every binary in the zip is 32-bit x86 +@rem (machine type 0x014C, checked on r37.0.1), and there is no ARM64 build. So it +@rem runs under WOW64 on x64 and under x86 emulation on ARM64 - which, unlike x64 +@rem emulation, every Windows on Arm has. Kept on ARM64 anyway, for the iperf3 +@rem reason: adb is bound by the USB or TCP link to the device, not by the CPU. +call :winget PlatformTools Google.PlatformTools @rem NASM assembles x86/x86-64 only - there is no ARM64 target in it and no ARM64 @rem build of it. x64 keeps it; ARM64 does not, because an emulated assembler is @@ -187,22 +326,26 @@ winget install ar51an.iPerf3 @rem directory when it cannot write to Program Files - C:\Users\\AppData\ @rem Local\bin\NASM, registered nowhere, so `winget uninstall` cannot find it @rem afterwards. Uninstall.exe in that directory is the only way back out. -call :x64only NASM.NASM "x86/x86-64 assembler with no ARM64 target and no ARM64 build - MSVC's armasm64.exe is the native one" +@rem +@rem Registered nowhere also means winget cannot see it is installed, so it +@rem reruns the installer on every run - measured, with the same 3.02 on both +@rem sides. setup-windows-current.ps1 compares that per-user nasm.exe with the +@rem version winget offers first, and only a match skips the install. A +@rem machine-wide NASM is registered, so it is left to winget as usual. +call :want NASM || goto :after_nasm +"%PS64%" -NoProfile -ExecutionPolicy Bypass -File "%~dp0setup-windows-current.ps1" -Step NASM -HostArch "%HOST_ARCH%" +if not "%ERRORLEVEL%"=="0" call :winget NASM NASM.NASM +:after_nasm @rem VirtualBox has no ARM64 Windows host build, and the x64 one cannot be made to @rem work by emulation: a hypervisor is kernel-mode, and its x64 driver will not @rem load on an ARM64 kernel. Installing it on ARM64 gets you a broken product and -@rem a failed driver install, so skip it and say so. +@rem a failed driver install, so the plan marks it x64-only and says so. @rem @rem This is also what makes setup-windows-7-test-env.bat unreachable from an @rem ARM64 host: it prepares a Windows 7 *x86* guest, and neither VirtualBox nor @rem Hyper-V on ARM64 can run one. -if "%IS_ARM64%"=="1" ( - echo [setup-windows] Skipping Oracle.VirtualBox: no ARM64 Windows host build ^(x64 hypervisor drivers cannot load on an ARM64 kernel^). - echo [setup-windows] The Windows 7 x86 test-VM workflow ^(setup-windows-7-test-env.bat^) is therefore unavailable on this box. -) else ( - winget install Oracle.VirtualBox -) +call :winget VirtualBox Oracle.VirtualBox @rem --------------------------------------------------------------------------- @rem Machine-wide installers, despite sitting in the "per-user" section @@ -218,6 +361,9 @@ if "%IS_ARM64%"=="1" ( @rem guaranteed failure four times per run - and paying the download for it - @rem teaches nobody anything, and the four opaque error codes bury the one line @rem that matters. On an account that can elevate they run exactly as before. +@rem The four are -Admin in setup-windows-steps.ps1, so on such an account the +@rem plan shows them as unavailable, with the reason, and :want skips each one. +@rem Re-run from an administrator account to get them. @rem @rem NOTE none of these is a build-blocker: the .NET SDK is only here for the WiX @rem MSI tooling, CMake also ships inside Visual Studio, AGI profiles Android @@ -225,20 +371,12 @@ if "%IS_ARM64%"=="1" ( @rem @rem On ARM64 the group is just the first two - AGI and OpenCppCoverage are in the @rem x64-only set, so there they are skipped before elevation is even consulted. +@rem AGI has no ARM64 build, and it profiles Android devices rather than +@rem anything compiled here, so emulating it is not worth the disk. @rem --------------------------------------------------------------------------- -if "%ELEV%"=="NOLUA" ( - echo. - echo [setup-windows] Skipping the machine-wide installers - this account cannot elevate: - echo [setup-windows] Microsoft.DotNet.SDK.10 and Kitware.CMake, plus - on x64 only - - echo [setup-windows] Google.AndroidGPUInspector and OpenCppCoverage. - echo [setup-windows] None blocks a build. Re-run from an administrator account to get them. - echo. - goto :after_admin_pkgs -) - -winget install Microsoft.DotNet.SDK.10 -winget install Kitware.CMake -call :x64only Google.AndroidGPUInspector "x64-only build, and it profiles Android devices rather than anything compiled here" +call :winget DotNetSdk Microsoft.DotNet.SDK.10 +call :winget CMake Kitware.CMake +call :winget AndroidGPUInspector Google.AndroidGPUInspector @rem OpenCppCoverage: native (PE) line coverage for the C++ binaries. run-coverage-occ.py drives the @rem pytest suite under it to produce an HTML report (the binaries under test build with PDBs, @@ -249,9 +387,7 @@ call :x64only Google.AndroidGPUInspector "x64-only build, and it profiles Androi @rem stepping x86/x64 instructions, so it can cover x86/x64 binaries but NOT an @rem ARM64 one. That is what puts it in the x64-only set: on ARM64 it could only @rem ever cover the cross-compiled x64 build, which is a job for the x64 box. -call :x64only OpenCppCoverage.OpenCppCoverage "it collects coverage by stepping x86/x64 instructions, so it cannot instrument an ARM64 binary at all" - -:after_admin_pkgs +call :winget OpenCppCoverage OpenCppCoverage.OpenCppCoverage @rem --- WiX 5.0.2, pinned on purpose --- @rem WiX packages our proprietary software into MSIs, and the version is pinned to keep that @@ -275,22 +411,36 @@ call :x64only OpenCppCoverage.OpenCppCoverage "it collects coverage by stepping @rem install-then-update is for re-runs - install fails once the tool is there, and update @rem then holds it at exactly 5.0.2 - which keeps this script idempotent like the rest. @rem +@rem But update reinstalls even when 5.0.2 is already what is there, so first look in the +@rem global tool store, which keeps one directory per installed version under +@rem %USERPROFILE%\.dotnet\tools\.store\wix. With 5.0.2 there and its shim in place, nothing +@rem runs; install-then-update is left for a first install, or for pulling a tool that has +@rem drifted to another version back to the pin. +@rem @rem %ProgramFiles%\dotnet is the NATIVE SDK's root on every architecture, ARM64 @rem included - what winget just installed. An x64 SDK installed alongside it on @rem an ARM64 box goes to %ProgramFiles%\dotnet\x64 instead, so fall back there @rem for a machine that only ever got the x64 one. Prefer the native root: the @rem ARM64 SDK runs the wix tool natively, and building an MSI is not something @rem worth doing through emulation. +call :want WiX || goto :after_wix +set "WIX_VERSION=5.0.2" +set "WIX_SHIM=%USERPROFILE%\.dotnet\tools\wix.exe" +if exist "%USERPROFILE%\.dotnet\tools\.store\wix\%WIX_VERSION%\" if exist "%WIX_SHIM%" ( + echo [setup-windows] WiX: %WIX_VERSION% is already the installed version; not reinstalling. + goto :wix_report +) set "DOTNET_EXE=%ProgramFiles%\dotnet\dotnet.exe" if not exist "%DOTNET_EXE%" set "DOTNET_EXE=%ProgramFiles%\dotnet\x64\dotnet.exe" -if not exist "%DOTNET_EXE%" echo [setup-windows] WARNING: dotnet.exe not found; skipping the WiX 5.0.2 pin. +if not exist "%DOTNET_EXE%" echo [setup-windows] WARNING: dotnet.exe not found; skipping the WiX %WIX_VERSION% pin. if not exist "%DOTNET_EXE%" goto :after_wix -"%DOTNET_EXE%" tool install --global wix --version 5.0.2 || "%DOTNET_EXE%" tool update --global wix --version 5.0.2 +"%DOTNET_EXE%" tool install --global wix --version %WIX_VERSION% || "%DOTNET_EXE%" tool update --global wix --version %WIX_VERSION% @rem Report what the pin actually produced. By full path again, and because the shim lands in @rem a directory this session's PATH predates: expect "5.0.2+", not 7.x. -"%USERPROFILE%\.dotnet\tools\wix.exe" --version +:wix_report +"%WIX_SHIM%" --version :after_wix @@ -315,25 +465,74 @@ if not exist "%DOTNET_EXE%" goto :after_wix @rem The elevated script runs in its own window and logs to setup-windows-uac.log. @rem -PassThru + $p.ExitCode propagates its real exit code back through to ERRORLEVEL. set "UAC_LOG=%~dp0setup-windows-uac.log" -if exist "%UAC_LOG%" del "%UAC_LOG%" @rem %ELEV% was worked out at the top of this script - see the comment there for @rem why `Start-Process -Verb RunAs` cannot be trusted to report this itself. +@rem +@rem It is handed exactly the elevated steps the plan left selected (-Only, as one +@rem comma list, which it splits) and -Yes, since the question was asked before +@rem anything ran. With none selected there is nothing to elevate FOR, so no +@rem prompt is raised at all. if "%ELEV%"=="NOLUA" goto :elev_impossible +if not defined UAC_STEPS goto :elev_not_selected + +@rem The last run's log goes only now, when a new one is about to replace it: a +@rem run that leaves the elevated half out keeps the log of the one that did it. +if exist "%UAC_LOG%" del "%UAC_LOG%" if "%ELEV%"=="ALREADY" goto :elev_direct -@rem Not elevated, UAC is on: request it. Expect a prompt. +@rem Not elevated, UAC is on: request it. Expect a prompt - a Yes/No for an +@rem administrator, and for a standard account one that asks for an +@rem administrator's name and password, after which the elevated half runs as +@rem THAT account. +@rem +@rem Process.Start with the runas verb, NOT Start-Process -Verb RunAs, which this +@rem line used to be. Two reasons: +@rem - When Start-Process fails - a cancelled prompt, most often - `$p` stays +@rem empty and `exit $p.ExitCode` exited 0: a failed elevation reported as a +@rem successful one. +@rem - It cannot be told apart even when caught. Windows PowerShell's +@rem Start-Process rethrows the failure as a plain InvalidOperationException +@rem and drops the Win32Exception that says why (checked: a missing program +@rem came back as exit 1, not 2). Process.Start keeps it, so the exit code +@rem here is the real Windows error - 1223, ERROR_CANCELLED, for a declined +@rem prompt - and the message is printed where it happens. +@rem If the elevated process does start, its own exit code comes back as before. +@rem Arguments is one string, passed through verbatim - the same hand-built form +@rem Invoke-VsModify uses in the elevated half. +@rem +@rem NO QUOTE CHARACTERS INSIDE THE -Command STRING. This line used to wrap the +@rem script path in "" pairs, and powershell.exe's command-line parser does not +@rem read those the way they look: the first "" yields a quote but leaves the +@rem quoted region, and the second - now outside it - yields nothing at all. The +@rem path's CLOSING quote vanished. That went unnoticed while the path was the +@rem last argument, because an unclosed quote simply runs to the end of the line. +@rem Once -Only and -Yes followed it, they were swallowed into the -File path, +@rem and the elevated PowerShell refused a script that did not exist (exit +@rem -196608) before running a single line: no transcript, and a window that +@rem closed at once. Measured with a stand-in that records its own command line: +@rem -File "C:\...\setup-windows-with-uac.ps1 -Only OpenSshClient,... -Yes +@rem So the path and the step list travel as environment variables, and the one +@rem quote the child needs is [char]34, made inside PowerShell where no parser in +@rem between can eat it. echo [setup-windows] Requesting elevation ^(expect a UAC prompt^)... -powershell -NoProfile -Command "$p = Start-Process powershell -Verb RunAs -ArgumentList '-NoProfile','-ExecutionPolicy','Bypass','-File','""%~dp0setup-windows-with-uac.ps1""' -Wait -PassThru; exit $p.ExitCode" +set "UAC_SCRIPT=%~dp0setup-windows-with-uac.ps1" +powershell -NoProfile -Command "try { $q = [char]34; $i = New-Object Diagnostics.ProcessStartInfo 'powershell'; $i.Verb = 'runas'; $i.UseShellExecute = $true; $i.Arguments = '-NoProfile -ExecutionPolicy Bypass -File ' + $q + $env:UAC_SCRIPT + $q + ' -Only ' + $env:UAC_STEPS + ' -Yes'; $p = [Diagnostics.Process]::Start($i); $p.WaitForExit(); exit $p.ExitCode } catch { $e = $_.Exception; while ($e -and $e -isnot [ComponentModel.Win32Exception]) { $e = $e.InnerException }; Write-Host ('[setup-windows] Could not start the elevated half: ' + $(if ($e) { $e.Message } else { $_.Exception.Message })); if ($e) { exit $e.NativeErrorCode } else { exit 1 } }" set "UAC_RC=%ERRORLEVEL%" goto :elev_done :elev_direct echo [setup-windows] Already running elevated; running the elevated half directly. -powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0setup-windows-with-uac.ps1" +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0setup-windows-with-uac.ps1" -Only %UAC_STEPS% -Yes set "UAC_RC=%ERRORLEVEL%" goto :elev_done +:elev_not_selected +echo. +echo [setup-windows] No elevated steps were selected, so no elevation is requested. +set "UAC_RC=NOTSELECTED" +goto :elev_done + :elev_impossible echo. echo [setup-windows] SKIPPING the elevated half: this account cannot elevate. @@ -358,10 +557,15 @@ set "UAC_RC=SKIPPED" @rem @rem A missing log is NOT self-explanatory, so do not guess at one cause. The @rem elevated script writes its transcript as almost its first act, so no log -@rem means it never got as far as running: either the prompt was declined, or it -@rem started unelevated and stopped on its own #Requires line. Which of those it -@rem was is already known from %ELEV%, so report that instead of speculating. +@rem means it never got as far as running. That has more than one cause: the +@rem prompt was declined; it started unelevated and stopped on its own #Requires +@rem line; or it was elevated and then refused to start in PowerShell's OWN +@rem startup, before the script's first line. The last one is real: the lost +@rem quote described at the launch line above produced it, and it was reported +@rem here as a declined prompt when the prompt had in fact been accepted. The +@rem exit code tells them apart, so report that instead of guessing. if "%UAC_RC%"=="SKIPPED" goto :after_uac_log +if "%UAC_RC%"=="NOTSELECTED" goto :after_uac_log if exist "%UAC_LOG%" ( echo. echo ===== elevated setup log ^(%UAC_LOG%^) ===== @@ -369,8 +573,12 @@ if exist "%UAC_LOG%" ( echo ===== end of elevated setup log ===== ) else ( echo [setup-windows] WARNING: no elevated log found at "%UAC_LOG%". - if "%ELEV%"=="PROMPT" echo [setup-windows] The elevated window never started - the UAC prompt was most likely declined. - if "%ELEV%"=="ALREADY" echo [setup-windows] The elevated half exited before writing its transcript; see its output above. + if "%UAC_RC%"=="1223" echo [setup-windows] The UAC prompt was declined or cancelled, so the elevated half never ran. + if not "%UAC_RC%"=="1223" if "%ELEV%"=="ALREADY" echo [setup-windows] The elevated half exited ^(code %UAC_RC%^) before writing its log; see its output above. + if not "%UAC_RC%"=="1223" if "%ELEV%"=="PROMPT" echo [setup-windows] The elevated half exited ^(code %UAC_RC%^) before it could write its log. Its window + if not "%UAC_RC%"=="1223" if "%ELEV%"=="PROMPT" echo [setup-windows] closes as it exits, so to read why, run it by hand from an Administrator prompt: + if not "%UAC_RC%"=="1223" if "%ELEV%"=="PROMPT" echo [setup-windows] powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0setup-windows-with-uac.ps1" + if "%UAC_RC%"=="-196608" echo [setup-windows] -196608 is PowerShell rejecting its own command line, e.g. a -File path that does not exist as given. ) :after_uac_log @@ -392,14 +600,23 @@ if exist "%UAC_LOG%" ( @rem @rem Non-fatal: these are conveniences, and the elevated half is the part worth @rem the UAC prompt. A failure warns and provisioning continues. -powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0setup-windows-no-uac.ps1" +@rem +@rem Handed the per-user steps the plan left selected, and -Yes, just as the +@rem elevated half is; with none selected it is not started at all. +if not defined USER_STEPS goto :user_not_selected +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0setup-windows-no-uac.ps1" -Only %USER_STEPS% -Yes if not "%ERRORLEVEL%"=="0" echo [setup-windows] WARNING: setup-windows-no-uac.ps1 reported a failure ^(see above^); continuing. +goto :after_user_steps +:user_not_selected +echo [setup-windows] No per-user configuration steps were selected; setup-windows-no-uac.ps1 not run. +:after_user_steps @rem Skipping the elevated half is a reported, understood outcome on a box where @rem elevation is impossible - not a failure to exit non-zero over. The per-user @rem provisioning above did run, and re-running from an administrator account is -@rem the documented next step. +@rem the documented next step. Not selecting any of it is a choice, not a failure. if "%UAC_RC%"=="SKIPPED" goto :uac_reported +if "%UAC_RC%"=="NOTSELECTED" goto :uac_reported if not "%UAC_RC%"=="0" ( echo. echo [setup-windows] ELEVATED SETUP FAILED ^(exit code %UAC_RC%^). See log above. @@ -441,6 +658,7 @@ if "%UAC_RC%"=="SKIPPED" echo [setup-windows] Per-user setup complete; the eleva @rem here. Reverse name order so the newest Python wins, and require python.exe @rem inside the directory - a half-removed version leaves the folder behind with @rem only Doc and Lib in it, which is exactly what this box has. +call :want Pillow || goto :after_pillow set "PY_EXE=" if exist "%LOCALAPPDATA%\Programs\Python\Launcher\py.exe" set "PY_EXE=%LOCALAPPDATA%\Programs\Python\Launcher\py.exe" if not defined PY_EXE if exist "%WINDIR%\py.exe" set "PY_EXE=%WINDIR%\py.exe" @@ -457,18 +675,20 @@ if not defined PY_EXE ( echo [setup-windows] Python: %PY_EXE% "%PY_EXE%" -m pip install Pillow ) +:after_pillow if "%IS_ARM64%"=="1" ( echo. echo [setup-windows] ARM64 notes: echo [setup-windows] native ARM64: Git, Python, .NET SDK, PowerShell, VS Code, Windows Terminal, echo [setup-windows] Sysinternals ^(the *64a.exe variants^), Claude Code, CMake, - echo [setup-windows] Ninja, and the SDK/WPT tools. - echo [setup-windows] compiler : MSVC from Visual Studio 2026, native ARM64. One compiler by - echo [setup-windows] design - clang-cl and upstream LLVM are x64-box only. - echo [setup-windows] emulated x64: iperf3, which is network-bound anyway. + echo [setup-windows] Ninja, rustup, and the SDK/WPT tools. + echo [setup-windows] compilers : MSVC from Visual Studio 2026, native ARM64 - the only C/C++ + echo [setup-windows] compiler by design; clang-cl and upstream LLVM are x64-box only. + echo [setup-windows] Rust stable-aarch64-pc-windows-msvc, native, linking through MSVC. + echo [setup-windows] emulated : iperf3 ^(x64^) and adb ^(x86^), both bound by their link, not the CPU. echo [setup-windows] not here : Brave, WinMerge, LLVM, NASM, AGI, OpenCppCoverage, BinSkim - echo [setup-windows] and the WDK - dropped on ARM64, see the x64-only set above. + echo [setup-windows] and the WDK - dropped on ARM64; the plan above says why. echo [setup-windows] unavailable : VirtualBox, the Windows 7 x86 test VM, Intel VTune, echo [setup-windows] and the v141 / Windows XP targeting toolset. ) @@ -483,20 +703,62 @@ if "%ELEV%"=="NOLUA" ( exit /b 0 @rem --------------------------------------------------------------------------- -@rem :x64only "" -@rem -@rem Install a package on x64 and skip it on ARM64, saying why. One subroutine -@rem rather than a gate at each call site so that the reason is never separated -@rem from the skip: the log line a future reader sees is the same text this file -@rem carries, and there is exactly one place to change the policy. -@rem -@rem The reason is a real argument, not a comment, because the interesting case is -@rem reading the run log six months later and wondering where NASM went. +@rem :want +@rem +@rem Exit 0 if the plan selected this step, so the command after it runs. +@rem Otherwise exit 1, first printing the reason if it is one that cannot run on +@rem this machine at all. A step you simply switched off passes silently: the +@rem plan said so a moment ago, and a line for each would bury the skips that +@rem have a reason. Used as +@rem call :want Id && +@rem call :want Id || goto :after_id +@rem +@rem It replaces :x64only and keeps that subroutine's rule: the reason is never +@rem separated from the skip. The log line a future reader sees is the text the +@rem plan showed, from setup-windows-steps.ps1 - still exactly one place to change +@rem the policy - because the interesting case is reading the run log six months +@rem later and wondering where NASM went. +@rem +@rem A step with no STEP_ line at all is one the catalog does not know, so the +@rem two have drifted apart. It is refused rather than run: it was never on the +@rem plan you agreed to. +@rem +@rem Delayed expansion, kept local, to read STEP_ by a computed name and to +@rem print the reason without cmd re-parsing an & | < > or paren inside it. @rem --------------------------------------------------------------------------- -:x64only -if not "%IS_ARM64%"=="1" ( - winget install %1 - goto :eof +:want +setlocal EnableDelayedExpansion +set "_state=!STEP_%~1!" +if "!_state!"=="1" (endlocal & exit /b 0) +if not defined STEP_%~1 ( + echo [setup-windows] BUG: step %~1 is not in the plan - setup-windows-steps.ps1 and this file disagree. Not run. + endlocal & exit /b 1 ) -echo [setup-windows] Skipping %1 on ARM64: %~2 -goto :eof +if defined WHY_%~1 echo [setup-windows] Skipping %~1: !WHY_%~1! +endlocal & exit /b 1 + +@rem --------------------------------------------------------------------------- +@rem :winget +@rem +@rem Re-running is safe and cheap. On a package winget can see is installed, +@rem `winget install` upgrades it when a newer version exists and otherwise does +@rem nothing - its documented default ("check package installed status and try to +@rem perform an upgrade if applicable"; only --force, never used here, installs +@rem regardless). The installs winget cannot see are checked before they get here. +@rem +@rem winget install the package, if the plan selected the step. The package id is +@rem written at the call site as well as in the catalog so that this file still +@rem says what it installs. Should the two ever disagree, the plan showed you +@rem something other than what is about to be installed, so it is not run. +@rem --------------------------------------------------------------------------- +:winget +call :want %1 || exit /b 0 +setlocal EnableDelayedExpansion +if /i not "!PKG_%~1!"=="%~2" ( + echo [setup-windows] BUG: the plan showed %~1 as "!PKG_%~1!" but this file installs "%~2". Not run. + endlocal & exit /b 0 +) +endlocal +echo [setup-windows] %~1: winget install %~2 +winget install %2 +exit /b 0 -- 2.56.0