| File | Purpose |
| --- | --- |
| `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-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`, or `-VirtualMachine Hyper-V` / `-VirtualMachine none`, 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, NASM and Android Studio installs: is what is on the box already current? Those are the 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-no-uac.ps1` | The non-elevated, per-user half: `%USERPROFILE%\.local\bin` (where `claude.exe` lives), 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. |
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
+ does, and — with the reason — whether it is x64-only (`-X64Only`), kept off a
+ Windows that is itself a virtual machine (`-NotInVM`), 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
`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.
+ in `%USERPROFILE%\.local\bin`, per-user. The `LocalBinPath` step in
+ `setup-windows-no-uac.ps1` makes sure that directory is on the user `PATH`
+ (creating it if need be); the installer itself only prints a reminder. 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
Windows 7 *x86* guest, which neither VirtualBox nor Hyper-V on ARM64 can run.
(That script itself is unchanged; it runs inside the guest, so it never sees
the host architecture.)
+- **VirtualBox is also skipped inside a virtual machine**, on any architecture.
+ The planner reads the firmware's SMBIOS manufacturer and model
+ (`Win32_ComputerSystem`) and recognises VirtualBox, VMware, Hyper-V (and
+ Azure), QEMU/KVM, Parallels, Xen, EC2 and GCE; the plan's `machine:` line
+ names what it found. It does not use `HypervisorPresent`, which is also true
+ on physical hardware running Hyper-V, WSL2 or VBS. The test VMs belong on the
+ physical host.
- **The `v141` / Windows XP toolset is skipped**, and the verification step says
`n/a` instead of warning about it. The components are still *listed* in the
catalog on Arm, so this is a deliberate skip rather than a hard unavailability
elevated would configure the *administrator's* profile instead of yours.
What this installs / configures:
+ - %USERPROFILE%\.local\bin on the user PATH: where Claude Code's native
+ installer puts claude.exe, and nothing else guarantees it is on the PATH
- WinMerge on the user PATH (x64 only)
- vswhere.exe on the user PATH: the Visual Studio installer puts it in
%ProgramFiles(x86)%\Microsoft Visual Studio\Installer, which nothing adds
}
}
+function Add-LocalBinToUserPath {
+ # %USERPROFILE%\.local\bin is where Claude Code's native installer puts
+ # claude.exe (see setup-windows.bat). The installer only prints a reminder
+ # when the directory is not on the PATH, and it is the per-user bin
+ # directory other tools reach for too, so put it there regardless and create
+ # it if it is missing - an entry for a directory that is not there yet would
+ # only be dead until something fills it.
+ Write-Step '%USERPROFILE%\.local\bin on the user PATH'
+ $dir = Join-Path $env:USERPROFILE '.local\bin'
+ if (-not (Test-Path $dir)) {
+ New-Item -ItemType Directory -Path $dir | Out-Null
+ Write-Host " Created $dir."
+ }
+ Add-ToUserPath $dir
+}
+
function Add-WinMergeToUserPath {
Write-Step 'WinMerge on the user PATH'
$candidates = @(
# 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 = @{
+ LocalBinPath = { Add-LocalBinToUserPath }
WinMergePath = { Add-WinMergeToUserPath }
VsWhere = { Add-VsWhereToUserPath }
BinSkim = { Install-BinSkim }
# 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)
+ -Aliases @{ WinMerge = 'WinMergePath' } -IsArm64 $IsArm64 `
+ -VirtualMachine (Get-SetupVirtualMachine))
Write-SetupPlan $plan 'Plan for setup-windows-no-uac.ps1'
$runCount = @($plan | Where-Object Selected).Count
if ($runCount -eq 0) {
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
+ powershell -File .\setup-windows-plan.ps1 -WhatIf -VirtualMachine Hyper-V
The steps, and where each one is unavailable and why, come from
setup-windows-steps.ps1; this script only presents them and records the
# here the same way when this script is run on its own.
[string] $HostArch,
[ValidateSet('ALREADY', 'PROMPT', 'NOLUA')] [string] $Elevation,
+ # The hypervisor this Windows is a guest of, or 'none' for physical
+ # hardware. Detected when not given; setup-windows.bat leaves it to that.
+ [string] $VirtualMachine,
[string] $OutFile
)
Write-Host ''
Write-Host 'Setup plan - nothing has been done yet' -ForegroundColor Cyan
Write-Host " host: $ArchLabel"
+ Write-Host " machine: $VmLabel"
Write-Host " elevation: $ElevLabel"
foreach ($g in $groups.Keys) {
$head = " $g $($groups[$g])"
}
}
+# A guest VM is not somewhere to put a hypervisor of its own: VirtualBox, for
+# one, is for the test VMs, which belong on the physical host. Detected once
+# here; the steps that care are -NotInVM in setup-windows-steps.ps1.
+if (-not $VirtualMachine) { $VirtualMachine = Get-SetupVirtualMachine }
+if ($VirtualMachine -eq 'none') { $VirtualMachine = $null }
+$VmLabel = if ($VirtualMachine) {
+ "virtual machine ($VirtualMachine) - steps meant for the physical host are marked [-]"
+} else {
+ 'physical hardware'
+}
+
$ArchLabel = switch ($HostArch) {
'ARM64' { 'ARM64 - the lean build box, one C/C++ compiler; x64-only steps are marked [-]' }
'AMD64' { 'x64 - the full workstation' }
try {
$plan = @(Resolve-SetupSelection -Steps (Get-SetupCatalog -IsArm64 $IsArm64) `
-Only $(if ($onlyGiven) { $Only } else { $null }) -Skip $Skip `
- -IsArm64 $IsArm64 -Elevation $Elevation)
+ -IsArm64 $IsArm64 -Elevation $Elevation -VirtualMachine $VirtualMachine)
} catch {
# A mistyped -Only / -Skip. Said plainly, and no hand-off file, so the batch
# file stops before doing anything.
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
+ reason, keeps a step off ARM64; -NotInVM, with its reason, keeps it off a
+ Windows that is itself a virtual machine; -Admin keeps it off an account that
+ cannot elevate. The planner shows each as a "[-]" row 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
[string] $Package,
# Not on ARM64, because <this>. Shown in the plan and in the run log.
[string] $X64Only,
+ # Not when this Windows is a guest in a virtual machine, because <this>.
+ # Shown in the plan and in the run log, like -X64Only.
+ [string] $NotInVM,
# Needs an account that can elevate: a machine-wide installer, or part
# of the elevated half.
[switch] $Admin,
Text = $shown
Package = $Package
X64Only = $X64Only
+ NotInVM = $NotInVM
Admin = [bool]$Admin
Needs = $Needs
}
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'
+ -X64Only 'no ARM64 host build, so no Windows 7 x86 test VM either' `
+ -NotInVM 'the test VMs belong on the physical host, not nested in a guest'
# --- 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 VTune E uac -Admin -Text 'Intel VTune Profiler: report whether it is installed (installs nothing)'
# --- setup-windows-no-uac.ps1: per-user configuration ---
+ # First, so the PATH it fixes is in place before anything reports on it.
+ New-SetupStep LocalBinPath C user -Text '%USERPROFILE%\.local\bin on the user PATH (where Claude Code''s launcher lives)'
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'
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)
+ # consulted, which is the more useful reason to show. Then the machine
+ # itself, then the account.
+ param($Step, [bool] $IsArm64, [string] $Elevation, [string] $VirtualMachine)
if ($IsArm64 -and $Step.X64Only) { return "not on ARM64 - $($Step.X64Only)" }
+ if ($VirtualMachine -and $Step.NotInVM) {
+ return "not in a virtual machine ($VirtualMachine) - $($Step.NotInVM)"
+ }
if ($Elevation -eq 'NOLUA' -and $Step.Admin) {
return 'needs an administrator - this account cannot elevate'
}
$null
}
+function Get-SetupVirtualMachine {
+ # The hypervisor this Windows is running under as a guest, by name, or $null
+ # on physical hardware.
+ #
+ # Read from the SMBIOS strings the firmware reports - Win32_ComputerSystem's
+ # Manufacturer and Model - which every common hypervisor fills in with its own
+ # name. NOT from HypervisorPresent: that is true on a PHYSICAL box too as soon
+ # as Hyper-V, WSL2, Windows Sandbox or virtualization-based security is on,
+ # which is most Windows 11 machines - the host OS then runs in the root
+ # partition, above a hypervisor, without being anyone's guest.
+ #
+ # A lookup that fails says "physical": this only ever takes steps away, and a
+ # step wrongly offered on a VM is one you can switch off in the plan.
+ $cs = Get-CimInstance Win32_ComputerSystem -ErrorAction SilentlyContinue
+ if (-not $cs) { return $null }
+ $smbios = "$($cs.Manufacturer) | $($cs.Model)"
+ # Hyper-V's pair is matched whole: Microsoft is also the manufacturer of
+ # every Surface, and "Virtual Machine" is the model only in a guest. Azure
+ # guests report the same pair.
+ $known = [ordered]@{
+ 'innotek|VirtualBox' = 'VirtualBox'
+ 'VMware' = 'VMware'
+ '^Microsoft Corporation \| Virtual Machine$' = 'Hyper-V'
+ 'QEMU|KVM|Bochs' = 'QEMU/KVM'
+ 'Parallels' = 'Parallels'
+ '\bXen\b|HVM domU' = 'Xen'
+ 'Amazon EC2' = 'Amazon EC2'
+ 'Google Compute Engine' = 'Google Compute Engine'
+ }
+ foreach ($pattern in $known.Keys) {
+ if ($smbios -match $pattern) { return $known[$pattern] }
+ }
+ $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.
[string[]] $Skip = @(),
[hashtable] $Aliases = @{},
[bool] $IsArm64,
- [string] $Elevation
+ [string] $Elevation,
+ # The hypervisor's name when this Windows is a guest (see
+ # Get-SetupVirtualMachine), $null or empty on physical hardware.
+ [string] $VirtualMachine
)
$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
+ $why = Get-SetupStepBlocker $s $IsArm64 $Elevation $VirtualMachine
if ($why -and $named -contains $s.Id) {
Write-Warning "$($s.Id) was asked for but cannot run here: $why"
}
# Elevation is a given here (#Requires above), so nothing is blocked on it.\r
$Plan = @(Resolve-SetupSelection -Steps @(Get-SetupCatalog -IsArm64 $IsArm64 | Where-Object Runner -eq 'uac') `\r
-Only $(if ($PSBoundParameters.ContainsKey('Only')) { $Only } else { $null }) -Skip $Skip `\r
- -IsArm64 $IsArm64 -Elevation 'ALREADY')\r
+ -IsArm64 $IsArm64 -Elevation 'ALREADY' -VirtualMachine (Get-SetupVirtualMachine))\r
$StepIds = @($Plan | ForEach-Object { $_.Id })\r
$RunSteps = @($Plan | Where-Object Selected | ForEach-Object { $_.Id })\r
$AskedSteps = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase)\r
@rem This is also what makes setup-windows-7-test-env.bat unreachable from an\r
@rem ARM64 host: it prepares a Windows 7 *x86* guest, and neither VirtualBox nor\r
@rem Hyper-V on ARM64 can run one.\r
+@rem\r
+@rem Nor is it installed when this Windows is itself a virtual machine, which the\r
+@rem planner reads from the firmware's SMBIOS strings: the test VMs belong on the\r
+@rem physical host, and nesting VirtualBox inside a guest needs nested\r
+@rem virtualization the outer hypervisor usually does not offer. The plan marks it\r
+@rem -NotInVM, with that reason, and names the hypervisor it found.\r
call :winget VirtualBox Oracle.VirtualBox\r
\r
@rem ---------------------------------------------------------------------------\r