From 5e6719e1ac7e7dc3d223f1ca1e5a14276bf4cd72 Mon Sep 17 00:00:00 2001 From: Jean-Paul van Ravensberg <14926452+DevSecNinja@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:59:12 +0200 Subject: [PATCH 1/2] feat: prepare a USB stick with the post-install toolkit (New-PostInstallUsb) Adds a third, low-effort way to use the tool: flash a STOCK Windows 11 ISO (e.g. from a Visual Studio subscription) to a USB stick, then point prepare-usb.ps1 at that stick once. New-PostInstallUsb validates the target (removable volume root, real Windows Setup media, media architecture from the UEFI boot loader, catalog selection, free space), stages the toolkit into a folder at the stick root, and generates a self-contained bootstrap that self-elevates, copies itself to %ProgramData%\windows-iso-maker so it survives unplugging and reboots, and runs post-install.ps1 with the chosen profile under a transcript. In the default FirstLogon mode it also writes a MINIMAL Autounattend.xml containing only an oobeSystem FirstLogonCommands block, from the new templates/autounattend/firstlogon.xml.template. Windows Setup therefore stays completely stock (interactive edition, partitioning and OOBE, including an Entra ID sign-in) and no disk is ever wiped. -Mode Toolkit stages the toolkit without any answer file. New files are ASCII-only: Windows PowerShell 5.1 reads no-BOM files as ANSI, so an em dash inside a double-quoted string becomes a smart quote and breaks parsing. Verified the module imports and the bootstrap runs end to end under powershell.exe 5.1. Docs: new docs/usb.md, a "Three ways to use this tool" section in the README, and cross-links from post-install.md / autounattend.md. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c85d9b13-c960-464c-be72-29d9f33ce3b1 --- README.md | 46 ++- docs/autounattend.md | 8 + docs/post-install.md | 4 + docs/usb.md | 136 +++++++++ prepare-usb.ps1 | 141 +++++++++ .../Private/PostInstallBootstrap.ps1 | 236 ++++++++++++++ src/WindowsIsoMaker/Private/UsbMedia.ps1 | 253 +++++++++++++++ .../Public/New-PostInstallUsb.ps1 | 288 ++++++++++++++++++ src/WindowsIsoMaker/WindowsIsoMaker.psd1 | 1 + .../autounattend/firstlogon.xml.template | 34 +++ tests/New-PostInstallUsb.Tests.ps1 | 248 +++++++++++++++ 11 files changed, 1394 insertions(+), 1 deletion(-) create mode 100644 docs/usb.md create mode 100644 prepare-usb.ps1 create mode 100644 src/WindowsIsoMaker/Private/PostInstallBootstrap.ps1 create mode 100644 src/WindowsIsoMaker/Private/UsbMedia.ps1 create mode 100644 src/WindowsIsoMaker/Public/New-PostInstallUsb.ps1 create mode 100644 templates/autounattend/firstlogon.xml.template create mode 100644 tests/New-PostInstallUsb.Tests.ps1 diff --git a/README.md b/README.md index 7c87311..9753093 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,34 @@ evidence-graded and reversible** (grade-3/community changes are never on by defa be run **hands-off** (auto local or Entra account, generic/genuine product key, opt-in Hyper-V or VMware boot test) and **repeatably** (locally or in CI, amd64 and arm64), not clicked through once by hand. +## Three ways to use this tool + +There is more than one way to end up with a clean, documented Windows 11. Pick the one that fits +how much effort you want to spend up front — all three apply the **same** cited, evidence-graded +[change catalog](docs/change-rationale.md). + +| # | Way | You do | Effort | Best when | +|---|-----|--------|--------|-----------| +| **1** | **Post-install on an existing PC**
[`post-install.ps1`](docs/post-install.md) | Install/reset Windows however you like, sign in, run one elevated command | Lowest | The machine already exists, or you just reset it. | +| **2** | **Prepare a USB stick**
[`prepare-usb.ps1`](docs/usb.md) | Flash a **stock** ISO (e.g. from Visual Studio) to a stick, point this at the stick once | Low | New machines from your own media. Setup stays interactive; the catalog applies itself at first logon. | +| **3** | **Build a custom ISO**
[`build.ps1`](docs/usage.md) | Service the image offline with DISM and repackage it | Highest | You want the very **first boot** already clean, an unattended install, and SBOM/provenance artifacts. | + +Ways 1 and 2 are the everyday paths and are the most widely applicable — they work with any Windows +11 media and need no ADK. Way 3 is the fully reproducible, auditable pipeline (and the one CI +exercises); it takes the most setup, so reach for it when the image itself is the deliverable. + +```powershell +# 1 — apply the catalog to THIS machine (elevated) +./post-install.ps1 -Profile opinionated -WhatIf # preview +./post-install.ps1 -Profile opinionated + +# 2 — prepare a USB stick you already flashed with a stock Windows 11 ISO +./prepare-usb.ps1 -Path E: -Profile opinionated + +# 3 — build a custom ISO +./build.ps1 -Edition Pro -IsoPath 'C:\isos\Win11_24H2_Business_x64.iso' -UseGenericProductKey +``` + ## Quick start (local, on Windows) Requires Windows with administrator rights, PowerShell 5.1+/7+, and the Windows ADK @@ -83,12 +111,27 @@ subscription) instead of a custom ISO? Run the same catalog directly on the mach ./post-install.ps1 -Profile opinionated ``` +### Installing from your own ISO? Put the toolkit on the USB stick + +Flash your stock Windows 11 ISO to a USB stick as usual, then prepare that stick once. Windows Setup +stays completely interactive (edition, partitioning, OOBE, Entra ID sign-in — **nothing is wiped**); +the catalog is applied automatically at the first logon. See [docs/usb.md](docs/usb.md). + +```powershell +# Validate the stick and show what would be staged — writes nothing +./prepare-usb.ps1 -Path E: -Profile opinionated -WhatIf + +# Prepare it +./prepare-usb.ps1 -Path E: -Profile opinionated +``` + ## Documentation | Topic | Doc | |-------|-----| | Local usage & configuration | [docs/usage.md](docs/usage.md) | | Post-install (existing machine) | [docs/post-install.md](docs/post-install.md) | +| USB stick (stock ISO + post-install) | [docs/usb.md](docs/usb.md) | | Change catalog & rationale | [docs/change-rationale.md](docs/change-rationale.md) | | Evidence grading | [docs/evidence-grading.md](docs/evidence-grading.md) | | CI / GitHub Actions | [docs/ci.md](docs/ci.md) | @@ -102,9 +145,10 @@ subscription) instead of a custom ISO? Run the same catalog directly on the mach ``` build.ps1 # Thin local entry point -> Invoke-IsoBuild post-install.ps1 # Thin local entry point -> Invoke-PostInstallSetup (apply to a running PC) +prepare-usb.ps1 # Thin local entry point -> New-PostInstallUsb (stage the toolkit on a USB stick) config/ # build.config.psd1 + catalog.*.psd1 (the change catalog) src/WindowsIsoMaker/ # The PowerShell module (Public/ + Private/) -templates/autounattend/ # Autounattend.xml template +templates/autounattend/ # Autounattend.xml templates (full build + minimal first-logon) tests/ # Pester v5 tests (incl. the catalog documentation gate) .github/workflows/ # ci.yml (lint+test+SBOM) and build-image.yml (manual matrix) specs/ # Spec-Driven Development artifacts (spec, plan, tasks, ...) diff --git a/docs/autounattend.md b/docs/autounattend.md index 6136ab1..90f4e48 100644 --- a/docs/autounattend.md +++ b/docs/autounattend.md @@ -14,6 +14,14 @@ Two different layers configure the image: | DISM offline servicing | Image build time | Remove provisioned apps, apply registry hive tweaks, enable optional features (e.g. WSL). | | **Autounattend.xml** | Install / OOBE time | Select the edition + install target, skip OOBE prompts, set locale/keyboard/timezone, disk layout, create a local account **or present the Entra ID sign-in**, run first-logon/SetupComplete commands. | +> **Not to be confused with the minimal first-logon answer file.** `prepare-usb.ps1` writes a +> *different*, deliberately tiny answer file to a **stock** USB stick — it carries only an +> `oobeSystem` `FirstLogonCommands` block (template +> [`firstlogon.xml.template`](../templates/autounattend/firstlogon.xml.template)) so Setup stays +> fully interactive and **no disk is repartitioned**. The full file described on this page — with +> `DiskConfiguration`, `WillWipeDisk` and edition selection — is only ever placed on an ISO this +> tool builds. See [usb.md](usb.md). + ## Why per-architecture The unattend `` elements carry a `processorArchitecture` attribute that differs diff --git a/docs/post-install.md b/docs/post-install.md index c981e49..775b31b 100644 --- a/docs/post-install.md +++ b/docs/post-install.md @@ -16,6 +16,10 @@ audit trail — directly to the running system. > catalog entry (see [change-rationale.md](change-rationale.md)). Nothing new is invented for the > online path; it is the identical selection logic (`Resolve-CatalogSelection`) applied online. +> 💡 **Installing from your own USB stick?** [`prepare-usb.ps1`](usb.md) stages this same toolkit +> onto the stick and can run it automatically at the first logon, so you don't have to fetch the +> repo on the new machine. + ## Quick start Run from an **elevated** PowerShell session (Administrator): diff --git a/docs/usb.md b/docs/usb.md new file mode 100644 index 0000000..159616b --- /dev/null +++ b/docs/usb.md @@ -0,0 +1,136 @@ +# Prepare a USB stick (stock ISO + post-install, no custom image) + +You already have a **stock Windows 11 ISO** — say one you downloaded from your Visual Studio +subscription — and you just want a machine that ends up configured the same documented way, without +building a custom image. That is what `prepare-usb.ps1` (→ `New-PostInstallUsb`) is for. + +You flash the ISO to a USB stick with your usual tool (Rufus, Ventoy, the Media Creation Tool, +`dd`, …). This tool then **adds** two things to that stick: + +1. a copy of this toolkit in a folder at the stick's root, and +2. (default) a **minimal `Autounattend.xml`** at the root that runs the toolkit **once, elevated, at + the first logon**. + +> **Windows Setup stays completely stock.** The generated answer file contains nothing but an +> `oobeSystem` `FirstLogonCommands` block — no disk layout, no edition selection, no OOBE skip, no +> product key. Edition choice, partitioning and OOBE (including an **Entra ID** sign-in) stay +> interactive, and **no disk is ever wiped by this tool**. The full unattended answer file, which +> *does* configure disks and editions, belongs to the ISO build path — see +> [autounattend.md](autounattend.md). +> +> The Windows Setup files on the stick are never modified. + +## Quick start + +```powershell +# Preview: validate the stick and show exactly what would be staged — writes nothing +./prepare-usb.ps1 -Path E: -Profile opinionated -WhatIf + +# Prepare the stick +./prepare-usb.ps1 -Path E: -Profile opinionated +``` + +Then: boot the target PC from the stick, install Windows as usual, sign in — and the catalog is +applied automatically. Elevation is **not** required to prepare the stick (you are only writing to +the USB drive); the run on the target machine self-elevates. + +## What it checks before writing + +| Check | Behaviour | +|-------|-----------| +| Target exists | Hard error if the path/drive isn't there. | +| Target is removable | Refuses the **root of a fixed drive** (a mistyped `C:`) unless `-Force`. A folder target is always allowed. | +| Windows Setup media | Requires `setup.exe`, `sources\install.wim` (or `.esd`) and a boot loader (`efi\` / `boot\`). Tells you to flash the ISO first, or `-Force` to stage anyway. | +| Architecture | Derived from the media's UEFI boot loader — `bootx64.efi` → `amd64`, `bootaa64.efi` → `arm64`. Override with `-Architecture`. | +| Catalog selection | Resolves the `Profile` / `EnableCatalogId` / `DisableCatalogId` **before** touching the stick, so a typo'd id fails here instead of on the new PC. | +| Free space | Fails early if the stick can't hold the toolkit. | +| Existing `Autounattend.xml` | Never overwritten silently — needs `-Force` (or use `-Mode Toolkit`). | + +## Modes + +| Mode | What lands on the stick | What you do on the new PC | +|------|--------------------------|----------------------------| +| `FirstLogon` (default) | Toolkit folder **+** minimal `Autounattend.xml` | Install Windows, sign in — the catalog applies itself. | +| `Toolkit` | Toolkit folder only | Install Windows, sign in, then run `\\Invoke-PostInstall.cmd` from the stick (it self-elevates). | + +Use `-Mode Toolkit` if the machine is enrolled through **Autopilot** or another provisioning flow +you'd rather not interleave with, or if you simply want to decide per machine. + +## What lands on the stick + +``` +E:\ +├── Autounattend.xml # only in FirstLogon mode; oobeSystem/FirstLogonCommands only +└── windows-iso-maker\ # -ToolkitFolder (default 'windows-iso-maker') + ├── Invoke-PostInstall.ps1 # generated bootstrap; your settings are baked in at the top + ├── Invoke-PostInstall.cmd # double-clickable launcher (self-elevates) + ├── post-install.ps1 # the normal entry point + ├── src\WindowsIsoMaker\ # the module + ├── config\ # build config + the change catalog + └── docs\ +``` + +The rest of the stick — `sources\`, `boot\`, `efi\`, `setup.exe` — is untouched. + +## What happens on the target machine + +The generated `Invoke-PostInstall.ps1`: + +1. **Self-elevates** if it isn't already running elevated (not needed under `FirstLogon`, which is + already elevated, but it makes the manual double-click path work). +2. **Copies the toolkit to `C:\ProgramData\windows-iso-maker`**, so the run survives the stick being + unplugged and can be repeated after a reboot (a WSL install spans reboots). +3. Starts a **transcript** in `C:\ProgramData\windows-iso-maker\logs\`. +4. Runs `post-install.ps1` with the settings baked in when you prepared the stick, writing the usual + auditable run report to `C:\ProgramData\windows-iso-maker\out\`. + +Because every catalog change is idempotent, re-running it is always safe: + +```powershell +# On the new machine, any time afterwards (elevated) +C:\ProgramData\windows-iso-maker\Invoke-PostInstall.ps1 + +# Preview only — needs no elevation +C:\ProgramData\windows-iso-maker\Invoke-PostInstall.ps1 -Preview +``` + +The settings live in an editable `$PostInstallSettings` hashtable at the top of that script, so you +can adjust the profile on the machine without re-preparing the stick. + +## Parameters + +| Parameter | Purpose | +|-----------|---------| +| `-Path` | The stick: `E:`, `E:\`, or any directory. | +| `-Mode` | `FirstLogon` (default) or `Toolkit`. | +| `-Profile` | One or more of `minimal` \| `default` \| `aggressive` \| `gaming` \| `opinionated` (UNIONed). Defaults to `default`. | +| `-EnableCatalogId` / `-DisableCatalogId` | Opt-in / opt-out catalog ids for the staged run (explicit ids win). | +| `-Scope` | Per-user target of the staged run: `CurrentUser`, `FutureUsers`, `Both` (default). | +| `-Architecture` | `amd64` \| `arm64`. Auto-detected from the media. | +| `-InstallWsl` / `-WslDistribution` | Have the staged run install WSL (implied by `opinionated`) and which distribution. | +| `-ToolkitFolder` | Folder name at the stick's root (default `windows-iso-maker`). | +| `-Force` | Allow a fixed-drive root or non-Setup media, and overwrite an existing `Autounattend.xml` / staged toolkit. | +| `-WhatIf` | Validate everything and report the plan without writing. | + +## Caveats + +- **Entra ID / work accounts.** `FirstLogonCommands` runs at the first interactive logon, which is + the account you signed in with during OOBE — so per-user tweaks land on your Entra profile + (`-Scope Both` also seeds the new-user template). If the device goes through **Autopilot** with an + Enrollment Status Page, prefer `-Mode Toolkit` and run it yourself once the desktop settles. +- **The commands are synchronous.** The desktop appears only after the run finishes. Expect a few + minutes on the first logon; the transcript shows progress. +- **Reboots.** Additive features (notably WSL) finish after a reboot — just re-run the bootstrap + from `C:\ProgramData\windows-iso-maker`. See [wsl.md](wsl.md). +- **Hardware-conditional entries** are evaluated here (unlike an offline build) because this runs on + the actual machine — see [change-rationale.md](change-rationale.md#condition--hardware-specific-entries). +- **Ventoy and other multi-ISO loaders** boot the ISO, not the stick's file system, so Setup will not + find an `Autounattend.xml` you placed next to it. Use `-Mode Toolkit` there. +- This path produces **no ISO, no SBOM and no provenance bundle** — those belong to the offline build + path ([provenance-bom.md](provenance-bom.md)). It shares the change catalog and the run report. + +## Related + +- [post-install.md](post-install.md) — running the catalog on a machine that is already installed. +- [usage.md](usage.md) — building a custom ISO instead. +- [autounattend.md](autounattend.md) — the *full* answer file used by the ISO build path. diff --git a/prepare-usb.ps1 b/prepare-usb.ps1 new file mode 100644 index 0000000..b11f1a5 --- /dev/null +++ b/prepare-usb.ps1 @@ -0,0 +1,141 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Prepare a Windows 11 USB installation stick so the post-install catalog travels with it. + +.DESCRIPTION + prepare-usb.ps1 is a thin dispatcher (Constitution Principle I & V): it enables strict mode, + imports the WindowsIsoMaker module, and forwards to the shipped New-PostInstallUsb command. + + Use it when you flash a STOCK Windows 11 ISO (for example one from your Visual Studio + subscription) to a USB stick and want this tool's documented changes applied afterwards + WITHOUT building a custom ISO. It validates the stick, stages the toolkit onto it and - in the + default 'FirstLogon' mode - writes a MINIMAL Autounattend.xml that applies the catalog once, + elevated, at the first logon. + + Windows Setup itself stays completely stock: edition, partitioning and OOBE (including an + Entra ID sign-in) remain interactive. Nothing on the media is modified and no disk is wiped by + this script. See docs/usb.md. + +.PARAMETER Path + The USB stick to prepare: a drive specification ('E:' or 'E:\') or a directory. + +.PARAMETER Mode + 'FirstLogon' (default) stages the toolkit AND hooks it into the first logon; 'Toolkit' only + stages it for you to run by hand. + +.PARAMETER Profile + Catalog profile baseline(s) the staged run applies: one or more of 'minimal' | 'default' | + 'aggressive' | 'gaming' | 'opinionated' (UNIONed). Defaults to 'default'. + +.PARAMETER EnableCatalogId + Opt-in catalog ids to force-enable (e.g. 'remove-edge','feature-wsl'). + +.PARAMETER DisableCatalogId + Catalog ids to force-disable (explicit ids win). + +.PARAMETER Scope + Which per-user targets the staged run touches: 'CurrentUser', 'FutureUsers' or 'Both'. + +.PARAMETER Architecture + Override the target architecture ('amd64' | 'arm64'). Auto-detected from the media otherwise. + +.PARAMETER InstallWsl + Have the staged run also install WSL and a distribution (implied by the opinionated profile). + +.PARAMETER WslDistribution + The Linux distribution the staged run installs when WSL is included (default 'Debian'). + +.PARAMETER ToolkitFolder + Folder created at the stick's root to hold the toolkit (default 'windows-iso-maker'). + +.PARAMETER Force + Proceed on a non-removable target or non-Setup media, and overwrite an existing + Autounattend.xml / staged toolkit. + +.EXAMPLE + ./prepare-usb.ps1 -Path E: -Profile opinionated + Stages the toolkit on E: and applies the opinionated profile at the first logon. + +.EXAMPLE + ./prepare-usb.ps1 -Path E: -Profile opinionated -WhatIf + Validates the stick and shows what would be staged, writing nothing. + +.EXAMPLE + ./prepare-usb.ps1 -Path E: -Mode Toolkit + Only carries the toolkit on the stick; run it yourself after signing in. + +.NOTES + Does not require elevation - it only writes to the USB stick. See docs/usb.md. +#> +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidAssignmentToAutomaticVariable', 'Profile', + Justification = "'Profile' is the documented, user-facing catalog concept (minimal/default/aggressive/gaming/opinionated). The parameter is locally scoped and never writes the global profile path.")] +[CmdletBinding(SupportsShouldProcess = $true)] +param( + [Parameter(Mandatory = $true, Position = 0)] + [ValidateNotNullOrEmpty()] + [string] $Path, + + [Parameter()] + [ValidateSet('FirstLogon', 'Toolkit')] + [string] $Mode, + + [Parameter()] + [ValidateSet('minimal', 'default', 'aggressive', 'gaming', 'opinionated')] + [string[]] $Profile, + + [Parameter()] + [string[]] $EnableCatalogId, + + [Parameter()] + [string[]] $DisableCatalogId, + + [Parameter()] + [ValidateSet('CurrentUser', 'FutureUsers', 'Both')] + [string] $Scope, + + [Parameter()] + [ValidateSet('amd64', 'arm64')] + [string] $Architecture, + + [Parameter()] + [switch] $InstallWsl, + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] $WslDistribution, + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] $ToolkitFolder, + + [Parameter()] + [switch] $Force +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# Import the shipped module (single source of change logic - Principle V). +$modulePath = Join-Path -Path $PSScriptRoot -ChildPath 'src/WindowsIsoMaker' +Import-Module -Name $modulePath -Force -ErrorAction Stop + +# Forward only the parameters the user actually set, so command defaults stay authoritative. +$usbParams = @{ Path = $Path } +foreach ($name in 'Mode', 'Profile', 'EnableCatalogId', 'DisableCatalogId', 'Scope', 'Architecture', 'WslDistribution', 'ToolkitFolder') { + if ($PSBoundParameters.ContainsKey($name)) { + $usbParams[$name] = $PSBoundParameters[$name] + } +} +foreach ($switchName in 'InstallWsl', 'Force') { + if ($PSBoundParameters.ContainsKey($switchName)) { + $usbParams[$switchName] = [switch]$PSBoundParameters[$switchName] + } +} + +# Honor -WhatIf from the dispatcher through to the command (preview path, FR-016). +if ($WhatIfPreference) { + $usbParams['WhatIf'] = $true +} + +New-PostInstallUsb @usbParams diff --git a/src/WindowsIsoMaker/Private/PostInstallBootstrap.ps1 b/src/WindowsIsoMaker/Private/PostInstallBootstrap.ps1 new file mode 100644 index 0000000..c09ba9b --- /dev/null +++ b/src/WindowsIsoMaker/Private/PostInstallBootstrap.ps1 @@ -0,0 +1,236 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Generators for the self-contained post-install bootstrap staged onto a USB stick by + New-PostInstallUsb. +.DESCRIPTION + The bootstrap is what actually runs on the freshly installed machine - either automatically + (first logon, via the minimal Autounattend.xml) or manually (double-clicking the .cmd). It is + generated rather than shipped verbatim so the chosen profile / catalog ids / scope are baked + in as readable, editable settings. + + Generated code follows the same rules as committed code: no aliases, full parameter names, + strict mode, and $ErrorActionPreference = 'Stop'. +#> + +function ConvertTo-PowerShellLiteral { + <# + .SYNOPSIS + Render a string, boolean or string array as PowerShell source text. + .DESCRIPTION + Used to bake resolved settings into the generated bootstrap script. Strings are emitted + single-quoted with embedded quotes doubled, so no injected value can break out of the + literal. + .PARAMETER Value + The value to render ([string], [bool] or [string[]]). + .EXAMPLE + ConvertTo-PowerShellLiteral -Value @('gaming','opinionated') # -> @('gaming', 'opinionated') + .OUTPUTS + System.String + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $true)] + [AllowNull()] + [object] $Value + ) + + if ($null -eq $Value) { return '$null' } + if ($Value -is [bool]) { if ($Value) { return '$true' } else { return '$false' } } + + if ($Value -is [array]) { + $items = @($Value | ForEach-Object { "'" + ([string]$_).Replace("'", "''") + "'" }) + return '@(' + ($items -join ', ') + ')' + } + + return "'" + ([string]$Value).Replace("'", "''") + "'" +} + +function New-PostInstallBootstrapScript { + <# + .SYNOPSIS + Generate the PowerShell bootstrap that applies the catalog on the freshly installed PC. + .DESCRIPTION + The generated script self-elevates when needed, copies the staged toolkit from the USB + stick to %ProgramData%\windows-iso-maker (so the run survives the stick being unplugged and + can be repeated after a reboot), starts a transcript, and then invokes the staged + post-install.ps1 with the settings baked in here. + .PARAMETER Profile + Catalog profile baseline(s) to bake in. + .PARAMETER EnableCatalogId + Catalog ids to force-enable. + .PARAMETER DisableCatalogId + Catalog ids to force-disable. + .PARAMETER Scope + Per-user scope ('CurrentUser' | 'FutureUsers' | 'Both'). + .PARAMETER Architecture + Target architecture ('amd64' | 'arm64'). + .PARAMETER InstallWsl + $true/$false to force the WSL install on or off; $null to leave it to the profile default. + .PARAMETER WslDistribution + Distribution to install when WSL is included. + .EXAMPLE + New-PostInstallBootstrapScript -Profile @('opinionated') -Scope Both -Architecture amd64 + .OUTPUTS + System.String - the generated script text. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Pure generator: returns the script text as a string and writes nothing. The caller (New-PostInstallUsb) owns ShouldProcess for the actual file write.')] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidAssignmentToAutomaticVariable', 'Profile', + Justification = "'Profile' is the documented, user-facing catalog concept. The parameter is locally scoped and never writes the global profile path.")] + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $true)] + [string[]] $Profile, + + [Parameter()] + [string[]] $EnableCatalogId = @(), + + [Parameter()] + [string[]] $DisableCatalogId = @(), + + [Parameter(Mandatory = $true)] + [ValidateSet('CurrentUser', 'FutureUsers', 'Both')] + [string] $Scope, + + [Parameter(Mandatory = $true)] + [ValidateSet('amd64', 'arm64')] + [string] $Architecture, + + [Parameter()] + [AllowNull()] + [object] $InstallWsl = $null, + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] $WslDistribution = 'Debian' + ) + + $settings = [System.Collections.Generic.List[string]]::new() + $settings.Add(" Profile = $(ConvertTo-PowerShellLiteral -Value @($Profile))") + $settings.Add(" Scope = $(ConvertTo-PowerShellLiteral -Value $Scope)") + $settings.Add(" Architecture = $(ConvertTo-PowerShellLiteral -Value $Architecture)") + if (@($EnableCatalogId).Count -gt 0) { + $settings.Add(" EnableCatalogId = $(ConvertTo-PowerShellLiteral -Value @($EnableCatalogId))") + } + if (@($DisableCatalogId).Count -gt 0) { + $settings.Add(" DisableCatalogId = $(ConvertTo-PowerShellLiteral -Value @($DisableCatalogId))") + } + if ($null -ne $InstallWsl) { + $settings.Add(" InstallWsl = $(ConvertTo-PowerShellLiteral -Value ([bool]$InstallWsl))") + $settings.Add(" WslDistribution = $(ConvertTo-PowerShellLiteral -Value $WslDistribution)") + } + + $settingsBlock = $settings -join [Environment]::NewLine + + # Single-quoted here-strings: nothing inside is expanded, so the generated script keeps its own + # $variables. The settings block is spliced in afterwards. + $header = @' +#Requires -Version 5.1 +<# +.SYNOPSIS + Apply the windows-iso-maker change catalog to THIS machine. +.DESCRIPTION + Generated by New-PostInstallUsb and staged on the Windows 11 installation USB stick. It + self-elevates, copies the toolkit from the stick to %ProgramData%\windows-iso-maker so the run + survives the stick being unplugged (and can be repeated after a reboot), and then runs + post-install.ps1 with the settings below. + + Safe to re-run: every catalog change is idempotent. +.PARAMETER Preview + Preview every change without applying anything (forwards -WhatIf). Needs no elevation. +.EXAMPLE + .\Invoke-PostInstall.ps1 -Preview +.NOTES + Edit the $PostInstallSettings block below to change what is applied. +#> +[CmdletBinding()] +param( + [switch] $Preview +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# --- Settings baked in when the USB stick was prepared. Edit freely. --- +$PostInstallSettings = @{ +'@ + + $footer = @' +} + +$LocalRoot = Join-Path -Path $env:ProgramData -ChildPath 'windows-iso-maker' +$LogDirectory = Join-Path -Path $LocalRoot -ChildPath 'logs' + +# --- Elevation: machine-wide (HKLM / DISM) changes need it; a preview does not. --- +$identity = [System.Security.Principal.WindowsIdentity]::GetCurrent() +$principal = New-Object -TypeName System.Security.Principal.WindowsPrincipal -ArgumentList $identity +$isElevated = $principal.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator) + +if (-not $isElevated -and -not $Preview) { + Write-Host 'Elevation required - relaunching as administrator...' + $relaunchArguments = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', ('"{0}"' -f $PSCommandPath)) + Start-Process -FilePath 'powershell.exe' -Verb RunAs -ArgumentList $relaunchArguments -Wait + return +} + +# --- Copy the toolkit off the removable stick so the run survives unplugging and reboots. --- +if ($PSScriptRoot -ne $LocalRoot) { + New-Item -ItemType Directory -Path $LocalRoot -Force | Out-Null + Get-ChildItem -LiteralPath $PSScriptRoot -Force | + Where-Object { $_.Name -ne 'logs' -and $_.Name -ne 'out' } | + ForEach-Object { Copy-Item -LiteralPath $_.FullName -Destination $LocalRoot -Recurse -Force } +} + +New-Item -ItemType Directory -Path $LogDirectory -Force | Out-Null +$transcriptPath = Join-Path -Path $LogDirectory -ChildPath ('post-install-{0}.log' -f (Get-Date -Format 'yyyyMMdd-HHmmss')) +Start-Transcript -LiteralPath $transcriptPath | Out-Null + +try { + $postInstallScript = Join-Path -Path $LocalRoot -ChildPath 'post-install.ps1' + if (-not (Test-Path -LiteralPath $postInstallScript)) { + throw "post-install.ps1 was not found at '$postInstallScript'. Re-stage the USB stick with New-PostInstallUsb." + } + + $parameters = @{} + foreach ($key in $PostInstallSettings.Keys) { $parameters[$key] = $PostInstallSettings[$key] } + $parameters['OutputDirectory'] = Join-Path -Path $LocalRoot -ChildPath 'out' + if ($Preview) { $parameters['WhatIf'] = $true } + + & $postInstallScript @parameters +} +finally { + Stop-Transcript | Out-Null +} +'@ + + return ($header, $settingsBlock, $footer) -join [Environment]::NewLine +} + +function New-PostInstallLauncherCmd { + <# + .SYNOPSIS + Generate the .cmd launcher that runs the bootstrap from Explorer. + .DESCRIPTION + A one-line batch wrapper so the staged toolkit can be started by double-clicking it on the + stick. The bootstrap it launches self-elevates, so no "run as administrator" is needed. + .EXAMPLE + New-PostInstallLauncherCmd + .OUTPUTS + System.String - the generated .cmd text. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Pure generator: returns the .cmd text as a string and writes nothing. The caller (New-PostInstallUsb) owns ShouldProcess for the actual file write.')] + [CmdletBinding()] + [OutputType([string])] + param() + + return @' +@echo off +REM Generated by windows-iso-maker (New-PostInstallUsb). +REM Runs the staged post-install bootstrap; it self-elevates when needed. +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Invoke-PostInstall.ps1" %* +'@ +} diff --git a/src/WindowsIsoMaker/Private/UsbMedia.ps1 b/src/WindowsIsoMaker/Private/UsbMedia.ps1 new file mode 100644 index 0000000..4fe7597 --- /dev/null +++ b/src/WindowsIsoMaker/Private/UsbMedia.ps1 @@ -0,0 +1,253 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Private helpers for preparing a Windows 11 USB installation stick with the post-install + toolkit (New-PostInstallUsb). +.DESCRIPTION + These helpers inspect a target volume, validate that it really carries Windows Setup media, + derive the media architecture from its EFI boot loader, and render the MINIMAL + (oobeSystem-only) answer file used to hook post-install into the first logon. + + They deliberately never modify the Windows Setup media itself: the stick is treated as + read-only apart from the additional toolkit folder and the optional Autounattend.xml. +#> + +function Get-UsbTargetInfo { + <# + .SYNOPSIS + Probe a target path/drive and report volume facts used to validate a USB stick. + .DESCRIPTION + Returns the normalized root, whether the path exists and is a volume root, and (on Windows, + when the target is a drive letter) the volume label, file system, free space and whether the + drive is removable. Probing is best-effort: on non-Windows hosts, or for a plain folder + target, the volume facts are reported as $null/unknown instead of throwing, so the caller + can still stage a toolkit into a directory. + .PARAMETER Path + The target path: a drive specification ('E:', 'E:\') or any directory. + .EXAMPLE + Get-UsbTargetInfo -Path 'E:' + .OUTPUTS + PSCustomObject (WindowsIsoMaker.UsbTargetInfo). + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string] $Path + ) + + # Normalize 'E:' -> 'E:\' so Join-Path yields 'E:\folder' and not the per-drive working dir. + $root = $Path.Trim() + if ($root -match '^[A-Za-z]:$') { $root = "$root\" } + + $driveLetter = $null + if ($root -match '^([A-Za-z]):') { $driveLetter = $Matches[1].ToUpperInvariant() } + + $info = [pscustomobject]@{ + PSTypeName = 'WindowsIsoMaker.UsbTargetInfo' + Path = $root + DriveLetter = $driveLetter + IsVolumeRoot = ($root -match '^[A-Za-z]:\\?$') + Exists = (Test-Path -LiteralPath $root) + Label = $null + FileSystem = $null + FreeSpaceByte = $null + IsRemovable = $null + DriveType = $null + } + + if (-not $driveLetter) { return $info } + + try { + $drive = Get-CimInstance -ClassName 'Win32_LogicalDisk' ` + -Filter ("DeviceID='{0}:'" -f $driveLetter) -ErrorAction Stop + if ($drive) { + # Win32_LogicalDisk DriveType: 2 = Removable, 3 = Local (fixed), 5 = CD-ROM. + # https://learn.microsoft.com/windows/win32/cimwin32prov/win32-logicaldisk + $info.DriveType = [int]$drive.DriveType + $info.IsRemovable = ([int]$drive.DriveType -eq 2) + $info.Label = [string]$drive.VolumeName + $info.FileSystem = [string]$drive.FileSystem + $info.FreeSpaceByte = [int64]$drive.FreeSpace + } + } + catch { + # Not Windows, no CIM, or the drive is not a local volume (e.g. a network share). + Write-BuildLog -Level Verbose -Component 'Get-UsbTargetInfo' -Message "Could not query volume '$($driveLetter):': $($_.Exception.Message)" + } + + return $info +} + +function Test-WindowsSetupMedia { + <# + .SYNOPSIS + Verify that a path is the root of Windows Setup installation media. + .DESCRIPTION + Checks for the files Windows Setup media always carries: setup.exe, a sources directory + holding install.wim or install.esd, and a boot loader directory (efi\ or boot\). Returns a + result object describing what was found rather than throwing, so the caller decides how + strict to be. + .PARAMETER Path + Root of the media to inspect (typically the USB stick's drive root). + .EXAMPLE + Test-WindowsSetupMedia -Path 'E:\' + .OUTPUTS + PSCustomObject (WindowsIsoMaker.SetupMediaInfo). + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string] $Path + ) + + $setupExe = Join-Path -Path $Path -ChildPath 'setup.exe' + $sources = Join-Path -Path $Path -ChildPath 'sources' + $installWim = Join-Path -Path $sources -ChildPath 'install.wim' + $installEsd = Join-Path -Path $sources -ChildPath 'install.esd' + $efiDir = Join-Path -Path $Path -ChildPath 'efi' + $bootDir = Join-Path -Path $Path -ChildPath 'boot' + + $hasSetup = Test-Path -LiteralPath $setupExe -PathType Leaf + $hasWim = Test-Path -LiteralPath $installWim -PathType Leaf + $hasEsd = Test-Path -LiteralPath $installEsd -PathType Leaf + $hasBoot = (Test-Path -LiteralPath $efiDir -PathType Container) -or (Test-Path -LiteralPath $bootDir -PathType Container) + + $missing = [System.Collections.Generic.List[string]]::new() + if (-not $hasSetup) { $missing.Add('setup.exe') } + if (-not ($hasWim -or $hasEsd)) { $missing.Add('sources\install.wim or sources\install.esd') } + if (-not $hasBoot) { $missing.Add('efi\ or boot\ (boot loader)') } + + $imageFile = $null + $imageFormat = $null + if ($hasWim) { $imageFile = $installWim; $imageFormat = 'wim' } + elseif ($hasEsd) { $imageFile = $installEsd; $imageFormat = 'esd' } + + return [pscustomobject]@{ + PSTypeName = 'WindowsIsoMaker.SetupMediaInfo' + Path = $Path + IsSetupMedia = ($missing.Count -eq 0) + ImageFile = $imageFile + ImageFormat = $imageFormat + Missing = @($missing) + } +} + +function Get-SetupMediaArchitecture { + <# + .SYNOPSIS + Derive the architecture of Windows Setup media from its EFI boot loader. + .DESCRIPTION + Windows media ships an architecture-specific UEFI boot loader: efi\boot\bootx64.efi for + amd64 and efi\boot\bootaa64.efi for arm64. That file is the most reliable architecture + marker available without mounting the image, so it is used here. Returns $null when the + media cannot be classified (the caller then falls back to an explicit -Architecture or the + running host). + .PARAMETER Path + Root of the media to inspect. + .EXAMPLE + Get-SetupMediaArchitecture -Path 'E:\' + .OUTPUTS + System.String - 'amd64', 'arm64', or $null. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string] $Path + ) + + $efiBoot = Join-Path -Path (Join-Path -Path $Path -ChildPath 'efi') -ChildPath 'boot' + if (Test-Path -LiteralPath (Join-Path -Path $efiBoot -ChildPath 'bootaa64.efi')) { return 'arm64' } + if (Test-Path -LiteralPath (Join-Path -Path $efiBoot -ChildPath 'bootx64.efi')) { return 'amd64' } + return $null +} + +function New-FirstLogonUnattendXml { + <# + .SYNOPSIS + Render the MINIMAL (oobeSystem-only) Autounattend.xml that runs commands at first logon. + .DESCRIPTION + Unlike New-AutounattendXml - which renders the full answer file for an ISO this tool builds + (disk layout, edition selection, OOBE skip) - this renders an answer file that contains + NOTHING but a FirstLogonCommands block. Windows Setup therefore behaves exactly as it + normally would (interactive edition/partition/OOBE, including an Entra ID sign-in); the only + addition is that the given commands run once, elevated, at the first logon. + + That distinction matters: dropping the full build answer file onto stock media would wipe + the configured disk. This file never touches the install phase. + + Rendering is deterministic - the same input yields byte-identical output. No password or + secret is ever written (Constitution Principle VII). + .PARAMETER Command + One or more command lines to run, in order, at the first logon. + .PARAMETER Architecture + Target architecture ('amd64' | 'arm64') written into processorArchitecture. + .PARAMETER Description + Optional human-readable note rendered into the file's header comment. + .PARAMETER TemplatePath + Directory containing firstlogon.xml.template. Defaults to templates/autounattend/. + .EXAMPLE + New-FirstLogonUnattendXml -Command 'powershell.exe -File X.ps1' -Architecture amd64 + .OUTPUTS + System.String - the rendered XML. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Pure renderer: returns the XML as a string and writes nothing. The caller (New-PostInstallUsb) owns ShouldProcess for the actual file write.')] + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string[]] $Command, + + [Parameter(Mandatory = $true)] + [ValidateSet('amd64', 'arm64')] + [string] $Architecture, + + [Parameter()] + [string] $Description = '', + + [Parameter()] + [string] $TemplatePath + ) + + if (-not $TemplatePath) { + $repoRoot = Split-Path -Parent (Split-Path -Parent $script:ModuleRoot) + $TemplatePath = Join-Path -Path $repoRoot -ChildPath 'templates/autounattend' + } + $templateFile = Join-Path -Path $TemplatePath -ChildPath 'firstlogon.xml.template' + if (-not (Test-Path -LiteralPath $templateFile)) { + throw "First-logon unattend template not found: '$templateFile'." + } + + $builder = [System.Text.StringBuilder]::new() + [void]$builder.AppendLine(' ') + $order = 1 + foreach ($line in $Command) { + $safeCommand = [System.Security.SecurityElement]::Escape([string]$line) + [void]$builder.AppendLine(' ') + [void]$builder.AppendLine(" $order") + [void]$builder.AppendLine(" $safeCommand") + [void]$builder.AppendLine(" windows-iso-maker post-install") + [void]$builder.AppendLine(' ') + $order++ + } + [void]$builder.Append(' ') + + $xml = Get-Content -LiteralPath $templateFile -Raw + $replacements = @{ + '{{PROCESSOR_ARCHITECTURE}}' = $Architecture + '{{DESCRIPTION}}' = [System.Security.SecurityElement]::Escape($Description) + '{{FIRSTLOGON_FRAGMENT}}' = $builder.ToString() + } + foreach ($token in $replacements.Keys) { + $xml = $xml.Replace($token, $replacements[$token]) + } + + return $xml +} diff --git a/src/WindowsIsoMaker/Public/New-PostInstallUsb.ps1 b/src/WindowsIsoMaker/Public/New-PostInstallUsb.ps1 new file mode 100644 index 0000000..089091d --- /dev/null +++ b/src/WindowsIsoMaker/Public/New-PostInstallUsb.ps1 @@ -0,0 +1,288 @@ +function New-PostInstallUsb { + <# + .SYNOPSIS + Prepare a Windows 11 USB installation stick so the post-install change catalog is carried + with it - and, optionally, applied automatically at the first logon. + .DESCRIPTION + Point this at a USB stick you already flashed with a STOCK Windows 11 ISO (for example one + downloaded from your Visual Studio subscription, written with Rufus or the Media Creation + Tool). It validates the stick, stages this toolkit onto it, and - in the default + 'FirstLogon' mode - writes a MINIMAL Autounattend.xml to the stick's root that runs + post-install once, elevated, at the first logon of the freshly installed machine. + + What it checks before touching anything: + * the target exists and (on Windows, for a drive letter) is a REMOVABLE volume, + * it really carries Windows Setup media (setup.exe, sources\install.wim|esd, boot loader), + * the media architecture, derived from the UEFI boot loader (bootx64.efi / bootaa64.efi), + * that the requested Profile / catalog ids resolve to a valid selection, + * that there is enough free space for the staged toolkit. + + What it does NOT do: it never modifies the Windows Setup media itself, and it never writes + the full build answer file. The rendered Autounattend.xml contains ONLY an oobeSystem + FirstLogonCommands block, so Windows Setup stays exactly as it is on stock media - + interactive edition and partition selection, normal OOBE, including an Entra ID sign-in. + Nothing is repartitioned or wiped by this tool. (The unattended-install answer file, with + disk layout and edition selection, belongs to the ISO build path - see New-AutounattendXml.) + + The generated bootstrap copies the toolkit from the stick to + %ProgramData%\windows-iso-maker before running it, so the changes survive the stick being + removed and can be re-run after a reboot (WSL installs span reboots). Every run writes a + transcript and the usual auditable run-report JSON. + + Use -Mode Toolkit to skip the answer file entirely and just carry the toolkit on the stick, + which you then run by hand after signing in. + .PARAMETER Path + The USB stick to prepare: a drive specification ('E:' or 'E:\') or a directory. + .PARAMETER Mode + 'FirstLogon' (default) stages the toolkit AND writes the minimal Autounattend.xml that runs + it at the first logon. 'Toolkit' only stages the toolkit; you run it manually after signing + in. Setup itself is interactive in both modes. + .PARAMETER Profile + Catalog profile baseline(s) the staged run will apply: one or more of 'minimal' | + 'default' | 'aggressive' | 'gaming' | 'opinionated' (UNIONed). Defaults to 'default'. + .PARAMETER EnableCatalogId + Opt-in catalog ids to force-enable in the staged run (e.g. 'remove-edge','feature-wsl'). + .PARAMETER DisableCatalogId + Catalog ids to force-disable in the staged run (explicit ids win). + .PARAMETER Scope + Which per-user targets the staged run touches: 'CurrentUser', 'FutureUsers' or 'Both' + (default). + .PARAMETER Architecture + Override the target architecture ('amd64' | 'arm64'). Auto-detected from the media's UEFI + boot loader when omitted, falling back to the running host. + .PARAMETER InstallWsl + Have the staged run also install WSL and a distribution. Implied by the 'opinionated' + profile; pass -InstallWsl:$false to suppress it there. + .PARAMETER WslDistribution + The Linux distribution the staged run installs when WSL is included (default 'Debian'). + .PARAMETER ToolkitFolder + Folder name created at the stick's root to hold the toolkit. Defaults to + 'windows-iso-maker'. + .PARAMETER Force + Proceed even when the target is the root of a non-removable drive or does not look like + Windows Setup media, and overwrite an existing Autounattend.xml or staged toolkit folder. + .EXAMPLE + New-PostInstallUsb -Path E: -Profile opinionated + Stages the toolkit on E: and hooks the opinionated profile into the first logon. + .EXAMPLE + New-PostInstallUsb -Path E: -Profile opinionated -WhatIf + Validates the stick and shows exactly what would be staged, writing nothing. + .EXAMPLE + New-PostInstallUsb -Path E: -Mode Toolkit -Profile gaming,opinionated + Only carries the toolkit on the stick; run it yourself after signing in. + .OUTPUTS + PSCustomObject (WindowsIsoMaker.PostInstallUsbResult). + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidAssignmentToAutomaticVariable', 'Profile', + Justification = "'Profile' is the documented, user-facing catalog concept (minimal/default/aggressive/gaming/opinionated). The parameter is locally scoped and never writes the global profile path.")] + [CmdletBinding(SupportsShouldProcess = $true)] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory = $true, Position = 0)] + [ValidateNotNullOrEmpty()] + [string] $Path, + + [Parameter()] + [ValidateSet('FirstLogon', 'Toolkit')] + [string] $Mode = 'FirstLogon', + + [Parameter()] + [ValidateSet('minimal', 'default', 'aggressive', 'gaming', 'opinionated')] + [string[]] $Profile = @('default'), + + [Parameter()] + [string[]] $EnableCatalogId = @(), + + [Parameter()] + [string[]] $DisableCatalogId = @(), + + [Parameter()] + [ValidateSet('CurrentUser', 'FutureUsers', 'Both')] + [string] $Scope = 'Both', + + [Parameter()] + [ValidateSet('amd64', 'arm64')] + [string] $Architecture, + + [Parameter()] + [switch] $InstallWsl, + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] $WslDistribution = 'Debian', + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] $ToolkitFolder = 'windows-iso-maker', + + [Parameter()] + [switch] $Force + ) + + $isPreview = $WhatIfPreference + $repoRoot = Split-Path -Parent (Split-Path -Parent $script:ModuleRoot) + + # --- 1. Probe the target volume. --- + $target = Get-UsbTargetInfo -Path $Path + if (-not $target.Exists) { + throw "USB target '$($target.Path)' was not found. Plug the stick in (or pass an existing directory) and retry." + } + + # Guard the dangerous case: writing to the root of a FIXED volume (e.g. C:\) because a drive + # letter was mistyped. A folder target is an explicit choice and is always allowed. + if ($target.IsVolumeRoot -and $target.IsRemovable -eq $false -and -not $Force.IsPresent) { + throw ("Target '$($target.Path)' is the root of a FIXED drive (DriveType=$($target.DriveType)), not a removable USB stick. " + + 'Refusing to write to it by accident - re-run with -Force if this really is your target.') + } + if ($target.IsVolumeRoot -and $null -eq $target.IsRemovable) { + Write-BuildLog -Level Verbose -Component 'New-PostInstallUsb' -Message "Could not determine whether '$($target.Path)' is removable; continuing." + } + + # --- 2. Validate that the stick actually carries Windows Setup media. --- + $media = Test-WindowsSetupMedia -Path $target.Path + if (-not $media.IsSetupMedia) { + $detail = "Missing: $($media.Missing -join ', ')." + if (-not $Force.IsPresent) { + throw ("'$($target.Path)' does not look like Windows 11 installation media. $detail " + + 'Write your Windows 11 ISO to the stick first (e.g. with Rufus or the Media Creation Tool), then re-run. ' + + 'Use -Force to stage the toolkit anyway.') + } + Write-BuildLog -Level Warning -Component 'New-PostInstallUsb' -Message "'$($target.Path)' does not look like Windows installation media ($detail) - continuing because -Force was supplied." + } + + # --- 3. Resolve the architecture: media boot loader > explicit override > running host. --- + $mediaArch = Get-SetupMediaArchitecture -Path $target.Path + $arch = if ($PSBoundParameters.ContainsKey('Architecture') -and $Architecture) { + if ($mediaArch -and $mediaArch -ne $Architecture) { + Write-BuildLog -Level Warning -Component 'New-PostInstallUsb' -Message "Media looks like '$mediaArch' but -Architecture '$Architecture' was supplied; using '$Architecture'." + } + $Architecture + } + elseif ($mediaArch) { $mediaArch } + else { Get-OnlineArchitecture } + + # --- 4. Validate the requested selection now, so a bad id fails here and not on the new PC. --- + $catalog = Import-ChangeCatalog + $selected = @(Resolve-CatalogSelection -Catalog $catalog -Architecture $arch ` + -Profile $Profile -Toggles @{} ` + -EnableCatalogId @($EnableCatalogId) -DisableCatalogId @($DisableCatalogId)) + + $toolkitPath = Join-Path -Path $target.Path -ChildPath $ToolkitFolder + $bootstrapPath = Join-Path -Path $toolkitPath -ChildPath 'Invoke-PostInstall.ps1' + $launcherPath = Join-Path -Path $toolkitPath -ChildPath 'Invoke-PostInstall.cmd' + $autounattendPath = Join-Path -Path $target.Path -ChildPath 'Autounattend.xml' + + Write-BuildLog -Level Information -Component 'New-PostInstallUsb' -Message "Preparing '$($target.Path)' (Mode=$Mode, Arch=$arch, Profile=$($Profile -join ','), Entries=$($selected.Count), Preview=$isPreview)." + + # --- 5. Work out what to stage and whether it fits. --- + $sourceItems = @('src', 'config', 'post-install.ps1', 'docs', 'LICENSE') | + ForEach-Object { Join-Path -Path $repoRoot -ChildPath $_ } | + Where-Object { Test-Path -LiteralPath $_ } + + $stagedBytes = 0L + foreach ($item in $sourceItems) { + if (Test-Path -LiteralPath $item -PathType Container) { + $stagedBytes += (Get-ChildItem -LiteralPath $item -Recurse -File | + Measure-Object -Property Length -Sum).Sum + } + else { + $stagedBytes += (Get-Item -LiteralPath $item).Length + } + } + + if ($null -ne $target.FreeSpaceByte -and $target.FreeSpaceByte -lt ($stagedBytes * 2)) { + throw ("Not enough free space on '$($target.Path)': need about $([math]::Round(($stagedBytes * 2) / 1MB, 1)) MB, " + + "but only $([math]::Round($target.FreeSpaceByte / 1MB, 1)) MB is free.") + } + + # --- 6. Refuse to silently clobber an answer file the user put there. --- + if ($Mode -eq 'FirstLogon' -and (Test-Path -LiteralPath $autounattendPath) -and -not $Force.IsPresent) { + throw "'$autounattendPath' already exists. Re-run with -Force to overwrite it, or use -Mode Toolkit to leave it alone." + } + + # --- 7. Stage the toolkit onto the stick (idempotent: the folder is replaced wholesale). --- + $stagedFileCount = 0 + if ($PSCmdlet.ShouldProcess($toolkitPath, "Stage the windows-iso-maker toolkit ($([math]::Round($stagedBytes / 1MB, 1)) MB)")) { + if (Test-Path -LiteralPath $toolkitPath) { + Remove-Item -LiteralPath $toolkitPath -Recurse -Force + } + New-Item -ItemType Directory -Path $toolkitPath -Force | Out-Null + foreach ($item in $sourceItems) { + Copy-Item -LiteralPath $item -Destination $toolkitPath -Recurse -Force + } + $stagedFileCount = @(Get-ChildItem -LiteralPath $toolkitPath -Recurse -File).Count + Write-BuildLog -Level Information -Component 'New-PostInstallUsb' -Message "Staged $stagedFileCount file(s) -> '$toolkitPath'." + } + + # --- 8. Generate the self-contained bootstrap that the first logon (or you) runs. --- + $installWslArgument = $null + if ($PSBoundParameters.ContainsKey('InstallWsl')) { $installWslArgument = [bool]$InstallWsl } + + $bootstrapScript = New-PostInstallBootstrapScript -Profile $Profile -EnableCatalogId @($EnableCatalogId) ` + -DisableCatalogId @($DisableCatalogId) -Scope $Scope -Architecture $arch ` + -InstallWsl $installWslArgument -WslDistribution $WslDistribution + + if ($PSCmdlet.ShouldProcess($bootstrapPath, 'Write the post-install bootstrap')) { + Set-Content -LiteralPath $bootstrapPath -Value $bootstrapScript -Encoding UTF8 + Set-Content -LiteralPath $launcherPath -Value (New-PostInstallLauncherCmd) -Encoding Ascii + } + + # --- 9. Hook it into the first logon via the MINIMAL answer file (never the build one). --- + $writtenAutounattend = $null + if ($Mode -eq 'FirstLogon') { + # Discover the toolkit by scanning the file-system drives for the bootstrap: the stick's + # drive letter after installation is not knowable at preparation time. + $discovery = "`$ErrorActionPreference='SilentlyContinue'; foreach (`$d in (Get-PSDrive -PSProvider FileSystem)) { " + + "`$p = Join-Path `$d.Root '$ToolkitFolder\Invoke-PostInstall.ps1'; " + + "if (Test-Path -LiteralPath `$p) { & `$p; break } }" + $command = "powershell.exe -NoProfile -ExecutionPolicy Bypass -Command `"$discovery`"" + + $description = "Runs the windows-iso-maker '$($Profile -join ',')' profile ($($selected.Count) catalog entries) once at first logon." + $xml = New-FirstLogonUnattendXml -Command $command -Architecture $arch -Description $description + + if ($PSCmdlet.ShouldProcess($autounattendPath, 'Write the first-logon Autounattend.xml')) { + Set-Content -LiteralPath $autounattendPath -Value $xml -Encoding UTF8 -NoNewline + Write-BuildLog -Level Information -Component 'New-PostInstallUsb' -Message "Wrote first-logon Autounattend.xml -> '$autounattendPath'." + } + $writtenAutounattend = $autounattendPath + } + + $nextSteps = if ($Mode -eq 'FirstLogon') { + @( + "Boot the target PC from '$($target.Path)' and install Windows normally (edition, disk and OOBE stay interactive).", + 'Sign in for the first time (a local or Entra ID account) - the catalog is applied automatically, elevated.', + "Review the run report under C:\ProgramData\windows-iso-maker\out\ and the transcript under ...\logs\.", + 'Re-run the same bootstrap after a reboot if WSL asked for one.' + ) + } + else { + @( + "Boot the target PC from '$($target.Path)' and install Windows normally.", + 'Sign in, then run the staged toolkit elevated:', + " $ToolkitFolder\Invoke-PostInstall.cmd (from the stick; it self-elevates)" + ) + } + + return [pscustomobject]@{ + PSTypeName = 'WindowsIsoMaker.PostInstallUsbResult' + Path = $target.Path + DriveLetter = $target.DriveLetter + Label = $target.Label + FileSystem = $target.FileSystem + IsRemovable = $target.IsRemovable + MediaValidated = $media.IsSetupMedia + MediaImageFormat = $media.ImageFormat + MediaArchitecture = $mediaArch + Architecture = $arch + Mode = $Mode + Profile = @($Profile) + SelectedEntryCount = $selected.Count + ToolkitPath = $toolkitPath + BootstrapPath = $bootstrapPath + LauncherPath = $launcherPath + AutounattendPath = $writtenAutounattend + StagedFileCount = $stagedFileCount + Preview = $isPreview + NextSteps = $nextSteps + } +} diff --git a/src/WindowsIsoMaker/WindowsIsoMaker.psd1 b/src/WindowsIsoMaker/WindowsIsoMaker.psd1 index 1468805..8395f99 100644 --- a/src/WindowsIsoMaker/WindowsIsoMaker.psd1 +++ b/src/WindowsIsoMaker/WindowsIsoMaker.psd1 @@ -23,6 +23,7 @@ 'Enable-WindowsFeature', 'New-AutounattendXml', 'New-BootableIso', + 'New-PostInstallUsb', 'Compress-BuildArtifact', 'Test-ImageIntegrity', 'Export-ImageBom', diff --git a/templates/autounattend/firstlogon.xml.template b/templates/autounattend/firstlogon.xml.template new file mode 100644 index 0000000..45a3f6c --- /dev/null +++ b/templates/autounattend/firstlogon.xml.template @@ -0,0 +1,34 @@ + + + + + + + +{{FIRSTLOGON_FRAGMENT}} + + + + diff --git a/tests/New-PostInstallUsb.Tests.ps1 b/tests/New-PostInstallUsb.Tests.ps1 new file mode 100644 index 0000000..3e9a316 --- /dev/null +++ b/tests/New-PostInstallUsb.Tests.ps1 @@ -0,0 +1,248 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Tests for New-PostInstallUsb - preparing a Windows 11 USB stick with the post-install toolkit. +.DESCRIPTION + A temporary directory stands in for the USB stick, populated with the marker files real + Windows Setup media carries. Because a plain directory has no drive letter, the removable-media + probe reports "unknown" and is skipped, which keeps these tests runnable on any OS. +#> + +BeforeAll { + $script:RepoRoot = Split-Path -Parent $PSScriptRoot + Import-Module (Join-Path $script:RepoRoot 'src/WindowsIsoMaker') -Force + + function script:New-FakeUsb { + param( + [ValidateSet('amd64', 'arm64', 'none')] + [string] $Architecture = 'amd64', + [switch] $NoMedia + ) + + $root = Join-Path ([System.IO.Path]::GetTempPath()) ("usb-" + [guid]::NewGuid().ToString('N').Substring(0, 8)) + New-Item -ItemType Directory -Path $root -Force | Out-Null + if ($NoMedia) { return $root } + + New-Item -ItemType Directory -Path (Join-Path $root 'sources') -Force | Out-Null + Set-Content -LiteralPath (Join-Path $root 'sources/install.wim') -Value 'fake' -Encoding Ascii + Set-Content -LiteralPath (Join-Path $root 'setup.exe') -Value 'fake' -Encoding Ascii + + $efiBoot = Join-Path $root 'efi/boot' + New-Item -ItemType Directory -Path $efiBoot -Force | Out-Null + switch ($Architecture) { + 'amd64' { Set-Content -LiteralPath (Join-Path $efiBoot 'bootx64.efi') -Value 'fake' -Encoding Ascii } + 'arm64' { Set-Content -LiteralPath (Join-Path $efiBoot 'bootaa64.efi') -Value 'fake' -Encoding Ascii } + default { } + } + return $root + } +} + +Describe 'Test-WindowsSetupMedia' { + + It 'accepts a directory carrying setup.exe, sources\install.wim and a boot loader' { + $usb = script:New-FakeUsb + try { + InModuleScope WindowsIsoMaker -Parameters @{ Usb = $usb } { + param($Usb) + $result = Test-WindowsSetupMedia -Path $Usb + $result.IsSetupMedia | Should -BeTrue + $result.ImageFormat | Should -Be 'wim' + $result.Missing | Should -BeNullOrEmpty + } + } + finally { Remove-Item $usb -Recurse -Force -ErrorAction SilentlyContinue } + } + + It 'reports every missing marker on an empty directory' { + $usb = script:New-FakeUsb -NoMedia + try { + InModuleScope WindowsIsoMaker -Parameters @{ Usb = $usb } { + param($Usb) + $result = Test-WindowsSetupMedia -Path $Usb + $result.IsSetupMedia | Should -BeFalse + $result.Missing.Count | Should -Be 3 + } + } + finally { Remove-Item $usb -Recurse -Force -ErrorAction SilentlyContinue } + } +} + +Describe 'Get-SetupMediaArchitecture' { + + It 'derives arm64 from bootaa64.efi' { + $usb = script:New-FakeUsb -Architecture arm64 + try { + InModuleScope WindowsIsoMaker -Parameters @{ Usb = $usb } { + param($Usb) + Get-SetupMediaArchitecture -Path $Usb | Should -Be 'arm64' + } + } + finally { Remove-Item $usb -Recurse -Force -ErrorAction SilentlyContinue } + } + + It 'derives amd64 from bootx64.efi' { + $usb = script:New-FakeUsb -Architecture amd64 + try { + InModuleScope WindowsIsoMaker -Parameters @{ Usb = $usb } { + param($Usb) + Get-SetupMediaArchitecture -Path $Usb | Should -Be 'amd64' + } + } + finally { Remove-Item $usb -Recurse -Force -ErrorAction SilentlyContinue } + } + + It 'returns nothing when the media cannot be classified' { + $usb = script:New-FakeUsb -Architecture none + try { + InModuleScope WindowsIsoMaker -Parameters @{ Usb = $usb } { + param($Usb) + Get-SetupMediaArchitecture -Path $Usb | Should -BeNullOrEmpty + } + } + finally { Remove-Item $usb -Recurse -Force -ErrorAction SilentlyContinue } + } +} + +Describe 'New-PostInstallUsb' { + + BeforeEach { $script:Usb = script:New-FakeUsb } + AfterEach { Remove-Item $script:Usb -Recurse -Force -ErrorAction SilentlyContinue } + + It 'stages the toolkit, the bootstrap and the first-logon answer file' { + $result = New-PostInstallUsb -Path $script:Usb -Profile opinionated -InformationAction SilentlyContinue + + $result.Mode | Should -Be 'FirstLogon' + $result.Architecture | Should -Be 'amd64' + $result.MediaValidated | Should -BeTrue + $result.SelectedEntryCount | Should -BeGreaterThan 0 + $result.StagedFileCount | Should -BeGreaterThan 0 + + Test-Path (Join-Path $script:Usb 'windows-iso-maker/post-install.ps1') | Should -BeTrue + Test-Path (Join-Path $script:Usb 'windows-iso-maker/src/WindowsIsoMaker/WindowsIsoMaker.psd1') | Should -BeTrue + Test-Path (Join-Path $script:Usb 'windows-iso-maker/config/build.config.psd1') | Should -BeTrue + Test-Path (Join-Path $script:Usb 'windows-iso-maker/Invoke-PostInstall.ps1') | Should -BeTrue + Test-Path (Join-Path $script:Usb 'windows-iso-maker/Invoke-PostInstall.cmd') | Should -BeTrue + Test-Path (Join-Path $script:Usb 'Autounattend.xml') | Should -BeTrue + } + + It 'writes an answer file that only runs a first-logon command (never a disk layout)' { + New-PostInstallUsb -Path $script:Usb -Profile default -InformationAction SilentlyContinue | Out-Null + + $xml = Get-Content -LiteralPath (Join-Path $script:Usb 'Autounattend.xml') -Raw + $xml | Should -Match 'FirstLogonCommands' + $xml | Should -Match 'Invoke-PostInstall.ps1' + $xml | Should -Match 'processorArchitecture="amd64"' + # The stock media must remain fully interactive: no install phase, no disk wipe. + $xml | Should -Not -Match 'DiskConfiguration' + $xml | Should -Not -Match 'WillWipeDisk' + $xml | Should -Not -Match 'windowsPE' + ([xml]$xml) | Should -Not -BeNullOrEmpty + } + + It 'bakes the requested profile and catalog ids into the bootstrap' { + New-PostInstallUsb -Path $script:Usb -Profile gaming, opinionated -EnableCatalogId feature-wsl ` + -Scope CurrentUser -InformationAction SilentlyContinue | Out-Null + + $bootstrap = Get-Content -LiteralPath (Join-Path $script:Usb 'windows-iso-maker/Invoke-PostInstall.ps1') -Raw + $bootstrap | Should -Match "Profile\s+=\s+@\('gaming', 'opinionated'\)" + $bootstrap | Should -Match "EnableCatalogId\s+=\s+@\('feature-wsl'\)" + $bootstrap | Should -Match "Scope\s+=\s+'CurrentUser'" + # It must be valid PowerShell. + { [scriptblock]::Create($bootstrap) } | Should -Not -Throw + } + + It 'honours -Mode Toolkit by leaving the media without an answer file' { + $result = New-PostInstallUsb -Path $script:Usb -Mode Toolkit -InformationAction SilentlyContinue + + $result.AutounattendPath | Should -BeNullOrEmpty + Test-Path (Join-Path $script:Usb 'Autounattend.xml') | Should -BeFalse + Test-Path (Join-Path $script:Usb 'windows-iso-maker/Invoke-PostInstall.ps1') | Should -BeTrue + } + + It 'changes nothing under -WhatIf' { + $result = New-PostInstallUsb -Path $script:Usb -WhatIf -InformationAction SilentlyContinue + + $result.Preview | Should -BeTrue + Test-Path (Join-Path $script:Usb 'windows-iso-maker') | Should -BeFalse + Test-Path (Join-Path $script:Usb 'Autounattend.xml') | Should -BeFalse + } + + It 'refuses a target that is not Windows installation media' { + $empty = script:New-FakeUsb -NoMedia + try { + { New-PostInstallUsb -Path $empty -InformationAction SilentlyContinue } | + Should -Throw '*does not look like Windows 11 installation media*' + } + finally { Remove-Item $empty -Recurse -Force -ErrorAction SilentlyContinue } + } + + It 'stages onto non-Setup media when -Force is supplied' { + $empty = script:New-FakeUsb -NoMedia + try { + $result = New-PostInstallUsb -Path $empty -Force -Architecture amd64 ` + -InformationAction SilentlyContinue -WarningAction SilentlyContinue + $result.MediaValidated | Should -BeFalse + Test-Path (Join-Path $empty 'windows-iso-maker/Invoke-PostInstall.ps1') | Should -BeTrue + } + finally { Remove-Item $empty -Recurse -Force -ErrorAction SilentlyContinue } + } + + It 'refuses to overwrite an existing Autounattend.xml without -Force' { + Set-Content -LiteralPath (Join-Path $script:Usb 'Autounattend.xml') -Value '' -Encoding Ascii + + { New-PostInstallUsb -Path $script:Usb -InformationAction SilentlyContinue } | + Should -Throw '*already exists*' + + (Get-Content -LiteralPath (Join-Path $script:Usb 'Autounattend.xml') -Raw).Trim() | Should -Be '' + } + + It 'overwrites an existing Autounattend.xml with -Force' { + Set-Content -LiteralPath (Join-Path $script:Usb 'Autounattend.xml') -Value '' -Encoding Ascii + + New-PostInstallUsb -Path $script:Usb -Force -InformationAction SilentlyContinue | Out-Null + + (Get-Content -LiteralPath (Join-Path $script:Usb 'Autounattend.xml') -Raw) | Should -Match 'FirstLogonCommands' + } + + It 'takes the architecture from the media when not specified' { + $arm = script:New-FakeUsb -Architecture arm64 + try { + $result = New-PostInstallUsb -Path $arm -InformationAction SilentlyContinue + $result.MediaArchitecture | Should -Be 'arm64' + $result.Architecture | Should -Be 'arm64' + (Get-Content -LiteralPath (Join-Path $arm 'Autounattend.xml') -Raw) | Should -Match 'processorArchitecture="arm64"' + } + finally { Remove-Item $arm -Recurse -Force -ErrorAction SilentlyContinue } + } + + It 'rejects an unknown catalog id before touching the stick' { + { New-PostInstallUsb -Path $script:Usb -EnableCatalogId 'no-such-entry' -InformationAction SilentlyContinue } | + Should -Throw + + Test-Path (Join-Path $script:Usb 'windows-iso-maker') | Should -BeFalse + } + + It 'is idempotent - re-staging replaces the toolkit cleanly' { + New-PostInstallUsb -Path $script:Usb -Force -InformationAction SilentlyContinue | Out-Null + $strayFile = Join-Path $script:Usb 'windows-iso-maker/stray.txt' + Set-Content -LiteralPath $strayFile -Value 'stale' -Encoding Ascii + + New-PostInstallUsb -Path $script:Usb -Force -InformationAction SilentlyContinue | Out-Null + + Test-Path $strayFile | Should -BeFalse + Test-Path (Join-Path $script:Usb 'windows-iso-maker/Invoke-PostInstall.ps1') | Should -BeTrue + } + + It 'uses a custom toolkit folder name in both the stick layout and the answer file' { + New-PostInstallUsb -Path $script:Usb -ToolkitFolder 'wim' -InformationAction SilentlyContinue | Out-Null + + Test-Path (Join-Path $script:Usb 'wim/Invoke-PostInstall.ps1') | Should -BeTrue + (Get-Content -LiteralPath (Join-Path $script:Usb 'Autounattend.xml') -Raw) | Should -Match 'wim\\Invoke-PostInstall.ps1' + } + + It 'refuses the root of a fixed drive' -Skip:($env:OS -ne 'Windows_NT') { + { New-PostInstallUsb -Path "$env:SystemDrive\" -InformationAction SilentlyContinue } | + Should -Throw '*root of a FIXED drive*' + } +} From 6684b219dd114590831e4455b7edc59beae0ac76 Mon Sep 17 00:00:00 2001 From: Jean-Paul van Ravensberg <14926452+DevSecNinja@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:16:38 +0200 Subject: [PATCH 2/2] fix: make the USB first-logon path fail loudly instead of silently Follow-up to the review of New-PostInstallUsb. The staging half was sound; the first-logon execution model was where the real risk sat. Verified against Microsoft's documentation that FirstLogonCommands run elevated only when the first user to sign in is a local administrator - a standard user gets a consent prompt, and nothing runs if it is declined or if UAC is disabled. Whether an Entra ID account becomes a local admin is decided by Entra/Intune, so the automatic run is NOT guaranteed in the scenario this feature targets. The docs previously asserted Entra compatibility as fact; they now state the caveat, cite the source, and point at -Mode Toolkit as the hook-free alternative. Because a silent no-op on a brand-new machine is the worst possible failure: - The generated discovery command now writes a breadcrumb to %ProgramData%\windows-iso-maker\logs\bootstrap.log before it searches, and records explicitly when no toolkit is found instead of exiting 0 in silence. - The bootstrap logs before elevation, reports ELEVATION FAILED with guidance when the relaunch throws, and - found by running the real generated command end to end - now checks the elevated child's exit code, since Start-Process succeeding only means the process started. A child that died instantly was previously reported as "Elevated run finished". - Discovery moved into New-PostInstallDiscoveryCommand so it is unit tested, rejects quote characters in the folder name, and is asserted to contain no double quote (it is embedded in -Command "..." inside XML). Also: - Removable detection no longer relies on Win32_LogicalDisk DriveType alone. USB SSDs and USB-NVMe enclosures report as fixed, so a volume on a USB bus (MSFT_Disk BusType 7) is now accepted rather than requiring -Force. - Autounattend.xml is written as UTF-8 WITHOUT a BOM. Set-Content -Encoding UTF8 emits a BOM under PS 5.1, contradicting the repository's encoding rule. - -WslServicing and -WslAutoReboot are forwarded, closing a capability gap against post-install.ps1. - 11 new tests cover the CIM probing branch (previously uncovered, because a temp-dir fake USB is not a volume root), the discovery command, the bootstrap generator and the literal escaping. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c85d9b13-c960-464c-be72-29d9f33ce3b1 --- README.md | 3 +- docs/usb.md | 70 ++++++-- prepare-usb.ps1 | 17 +- .../Private/PostInstallBootstrap.ps1 | 152 ++++++++++++++++-- src/WindowsIsoMaker/Private/UsbMedia.ps1 | 30 +++- .../Public/New-PostInstallUsb.ps1 | 40 +++-- .../autounattend/firstlogon.xml.template | 10 +- tests/New-PostInstallUsb.Tests.ps1 | 136 ++++++++++++++++ 8 files changed, 418 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 9753093..034343d 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,8 @@ subscription) instead of a custom ISO? Run the same catalog directly on the mach Flash your stock Windows 11 ISO to a USB stick as usual, then prepare that stick once. Windows Setup stays completely interactive (edition, partitioning, OOBE, Entra ID sign-in — **nothing is wiped**); -the catalog is applied automatically at the first logon. See [docs/usb.md](docs/usb.md). +the catalog is then applied at the first logon. See [docs/usb.md](docs/usb.md), which also covers the +case where that automatic run can't fire (the first account must be a local administrator). ```powershell # Validate the stick and show what would be staged — writes nothing diff --git a/docs/usb.md b/docs/usb.md index 159616b..83df9a4 100644 --- a/docs/usb.md +++ b/docs/usb.md @@ -20,6 +20,39 @@ You flash the ISO to a USB stick with your usual tool (Rufus, Ventoy, the Media > > The Windows Setup files on the stick are never modified. +## ⚠️ When the automatic run does *not* fire + +`FirstLogonCommands` is a Windows mechanism with a real limitation. Microsoft +[documents](https://learn.microsoft.com/windows-hardware/customize/desktop/unattend/microsoft-windows-shell-setup-firstlogoncommands-synchronouscommand) +that the commands run with elevated privileges **only when the first user to sign in is a local +administrator**. If that account is a standard user: + +- with UAC enabled, a consent dialog appears and the commands **don't run if it is declined**; +- with UAC disabled, the commands **don't run at all**. + +**This matters for Entra ID sign-in**, which is probably why you're here. Whether the account you +sign in with becomes a local administrator on the device is decided by your **Entra / Intune device +settings**, not by this tool — so the automatic run is **not guaranteed** in that scenario. Related: +`FirstLogonCommands` does not run in **Autopilot** pre-provisioning / self-deploying flows. + +Because a silent no-op on a brand-new machine is the worst possible failure, the generated command +**always leaves a breadcrumb**: + +``` +C:\ProgramData\windows-iso-maker\logs\bootstrap.log +``` + +It records that discovery started, which toolkit it found (or that it found none), and whether +elevation succeeded. If nothing was applied, that file says so — and you can simply run the toolkit +by hand: + +```powershell +# elevated +C:\ProgramData\windows-iso-maker\Invoke-PostInstall.ps1 +``` + +If you'd rather not depend on the first-logon hook at all, use `-Mode Toolkit`. + ## Quick start ```powershell @@ -39,7 +72,7 @@ the USB drive); the run on the target machine self-elevates. | Check | Behaviour | |-------|-----------| | Target exists | Hard error if the path/drive isn't there. | -| Target is removable | Refuses the **root of a fixed drive** (a mistyped `C:`) unless `-Force`. A folder target is always allowed. | +| Target is removable | Refuses the **root of a fixed drive** (a mistyped `C:`) unless `-Force`. USB SSDs that report as "fixed" are still accepted when they sit on a USB bus. A folder target is always allowed. | | Windows Setup media | Requires `setup.exe`, `sources\install.wim` (or `.esd`) and a boot loader (`efi\` / `boot\`). Tells you to flash the ISO first, or `-Force` to stage anyway. | | Architecture | Derived from the media's UEFI boot loader — `bootx64.efi` → `amd64`, `bootaa64.efi` → `arm64`. Override with `-Architecture`. | | Catalog selection | Resolves the `Profile` / `EnableCatalogId` / `DisableCatalogId` **before** touching the stick, so a typo'd id fails here instead of on the new PC. | @@ -72,16 +105,23 @@ E:\ The rest of the stick — `sources\`, `boot\`, `efi\`, `setup.exe` — is untouched. +Windows Setup finds the answer file through its documented +[implicit search order](https://learn.microsoft.com/windows-hardware/manufacture/desktop/windows-setup-automation-overview#implicit-answer-file-search-order): +removable media at the root of the drive, and — for a USB SSD that reports as fixed — the drive +Setup itself is running from. + ## What happens on the target machine The generated `Invoke-PostInstall.ps1`: -1. **Self-elevates** if it isn't already running elevated (not needed under `FirstLogon`, which is - already elevated, but it makes the manual double-click path work). -2. **Copies the toolkit to `C:\ProgramData\windows-iso-maker`**, so the run survives the stick being +1. **Writes a breadcrumb** to `C:\ProgramData\windows-iso-maker\logs\bootstrap.log` before anything + else, so even a run that cannot elevate leaves evidence. +2. **Self-elevates** if it isn't already running elevated, and reports loudly (log + warning) if that + fails rather than exiting quietly. +3. **Copies the toolkit to `C:\ProgramData\windows-iso-maker`**, so the run survives the stick being unplugged and can be repeated after a reboot (a WSL install spans reboots). -3. Starts a **transcript** in `C:\ProgramData\windows-iso-maker\logs\`. -4. Runs `post-install.ps1` with the settings baked in when you prepared the stick, writing the usual +4. Starts a **transcript** in `C:\ProgramData\windows-iso-maker\logs\`. +5. Runs `post-install.ps1` with the settings baked in when you prepared the stick, writing the usual auditable run report to `C:\ProgramData\windows-iso-maker\out\`. Because every catalog change is idempotent, re-running it is always safe: @@ -107,19 +147,23 @@ can adjust the profile on the machine without re-preparing the stick. | `-EnableCatalogId` / `-DisableCatalogId` | Opt-in / opt-out catalog ids for the staged run (explicit ids win). | | `-Scope` | Per-user target of the staged run: `CurrentUser`, `FutureUsers`, `Both` (default). | | `-Architecture` | `amd64` \| `arm64`. Auto-detected from the media. | -| `-InstallWsl` / `-WslDistribution` | Have the staged run install WSL (implied by `opinionated`) and which distribution. | +| `-InstallWsl` / `-WslDistribution` / `-WslServicing` / `-WslAutoReboot` | Have the staged run install WSL (implied by `opinionated`), which distribution, how WSL is obtained, and whether it may reboot on its own. | | `-ToolkitFolder` | Folder name at the stick's root (default `windows-iso-maker`). | | `-Force` | Allow a fixed-drive root or non-Setup media, and overwrite an existing `Autounattend.xml` / staged toolkit. | | `-WhatIf` | Validate everything and report the plan without writing. | ## Caveats -- **Entra ID / work accounts.** `FirstLogonCommands` runs at the first interactive logon, which is - the account you signed in with during OOBE — so per-user tweaks land on your Entra profile - (`-Scope Both` also seeds the new-user template). If the device goes through **Autopilot** with an - Enrollment Status Page, prefer `-Mode Toolkit` and run it yourself once the desktop settles. -- **The commands are synchronous.** The desktop appears only after the run finishes. Expect a few - minutes on the first logon; the transcript shows progress. +- **Entra ID / work accounts.** See [the section above](#-when-the-automatic-run-does-not-fire) — + the first-logon hook only runs elevated when the signed-in account is a local administrator, which + Entra/Intune decides. Check `bootstrap.log`, and prefer `-Mode Toolkit` for **Autopilot** devices. +- **Security.** The answer file makes Windows run a script from removable media, elevated, with + `-ExecutionPolicy Bypass`, at the first logon. That is inherent to the mechanism. Anyone who can + modify the staged toolkit could already modify `install.wim` on the same stick, so treat the stick + itself as the trust boundary and don't prepare a stick you then leave unattended. +- **The commands run in order, one at a time.** Modern Windows runs `FirstLogonCommands` + asynchronously with respect to other logon work, but each command still runs to completion in + sequence. Expect the first logon to be busy for several minutes; the transcript shows progress. - **Reboots.** Additive features (notably WSL) finish after a reboot — just re-run the bootstrap from `C:\ProgramData\windows-iso-maker`. See [wsl.md](wsl.md). - **Hardware-conditional entries** are evaluated here (unlike an offline build) because this runs on diff --git a/prepare-usb.ps1 b/prepare-usb.ps1 index b11f1a5..5cf1db6 100644 --- a/prepare-usb.ps1 +++ b/prepare-usb.ps1 @@ -46,6 +46,12 @@ .PARAMETER WslDistribution The Linux distribution the staged run installs when WSL is included (default 'Debian'). +.PARAMETER WslServicing + How the staged run obtains WSL: 'Store' (default), 'WebDownload' or 'Inbox'. + +.PARAMETER WslAutoReboot + Let the staged run reboot the machine automatically when the WSL install needs it. + .PARAMETER ToolkitFolder Folder created at the stick's root to hold the toolkit (default 'windows-iso-maker'). @@ -105,6 +111,13 @@ param( [ValidateNotNullOrEmpty()] [string] $WslDistribution, + [Parameter()] + [ValidateSet('Store', 'WebDownload', 'Inbox')] + [string] $WslServicing, + + [Parameter()] + [switch] $WslAutoReboot, + [Parameter()] [ValidateNotNullOrEmpty()] [string] $ToolkitFolder, @@ -122,12 +135,12 @@ Import-Module -Name $modulePath -Force -ErrorAction Stop # Forward only the parameters the user actually set, so command defaults stay authoritative. $usbParams = @{ Path = $Path } -foreach ($name in 'Mode', 'Profile', 'EnableCatalogId', 'DisableCatalogId', 'Scope', 'Architecture', 'WslDistribution', 'ToolkitFolder') { +foreach ($name in 'Mode', 'Profile', 'EnableCatalogId', 'DisableCatalogId', 'Scope', 'Architecture', 'WslDistribution', 'WslServicing', 'ToolkitFolder') { if ($PSBoundParameters.ContainsKey($name)) { $usbParams[$name] = $PSBoundParameters[$name] } } -foreach ($switchName in 'InstallWsl', 'Force') { +foreach ($switchName in 'InstallWsl', 'WslAutoReboot', 'Force') { if ($PSBoundParameters.ContainsKey($switchName)) { $usbParams[$switchName] = [switch]$PSBoundParameters[$switchName] } diff --git a/src/WindowsIsoMaker/Private/PostInstallBootstrap.ps1 b/src/WindowsIsoMaker/Private/PostInstallBootstrap.ps1 index c09ba9b..b258837 100644 --- a/src/WindowsIsoMaker/Private/PostInstallBootstrap.ps1 +++ b/src/WindowsIsoMaker/Private/PostInstallBootstrap.ps1 @@ -70,6 +70,10 @@ function New-PostInstallBootstrapScript { $true/$false to force the WSL install on or off; $null to leave it to the profile default. .PARAMETER WslDistribution Distribution to install when WSL is included. + .PARAMETER WslServicing + How WSL itself is obtained: 'Store', 'WebDownload' or 'Inbox'. + .PARAMETER WslAutoReboot + Let the staged run reboot automatically when the WSL install needs it. .EXAMPLE New-PostInstallBootstrapScript -Profile @('opinionated') -Scope Both -Architecture amd64 .OUTPUTS @@ -105,7 +109,14 @@ function New-PostInstallBootstrapScript { [Parameter()] [ValidateNotNullOrEmpty()] - [string] $WslDistribution = 'Debian' + [string] $WslDistribution = 'Debian', + + [Parameter()] + [ValidateSet('Store', 'WebDownload', 'Inbox')] + [string] $WslServicing = 'Store', + + [Parameter()] + [switch] $WslAutoReboot ) $settings = [System.Collections.Generic.List[string]]::new() @@ -120,7 +131,15 @@ function New-PostInstallBootstrapScript { } if ($null -ne $InstallWsl) { $settings.Add(" InstallWsl = $(ConvertTo-PowerShellLiteral -Value ([bool]$InstallWsl))") + } + # WSL servicing settings only matter when the staged run installs WSL, which the 'opinionated' + # profile implies even without an explicit -InstallWsl. + if ($null -ne $InstallWsl -or @($Profile) -contains 'opinionated') { $settings.Add(" WslDistribution = $(ConvertTo-PowerShellLiteral -Value $WslDistribution)") + $settings.Add(" WslServicing = $(ConvertTo-PowerShellLiteral -Value $WslServicing)") + if ($WslAutoReboot.IsPresent) { + $settings.Add(" WslAutoReboot = `$true") + } } $settingsBlock = $settings -join [Environment]::NewLine @@ -164,27 +183,81 @@ $PostInstallSettings = @{ $LocalRoot = Join-Path -Path $env:ProgramData -ChildPath 'windows-iso-maker' $LogDirectory = Join-Path -Path $LocalRoot -ChildPath 'logs' -# --- Elevation: machine-wide (HKLM / DISM) changes need it; a preview does not. --- +# --- Breadcrumb log. Written FIRST and independently of the transcript, because the most +# confusing failures (first logon not elevated, UAC cancelled, stick already unplugged) +# happen before any real work starts. Without this they would be completely silent on a +# machine that has no other diagnostics. --- +$BreadcrumbLog = Join-Path -Path $LogDirectory -ChildPath 'bootstrap.log' +function Write-Breadcrumb { + param( + [Parameter(Mandatory = $true)] + [string] $Message + ) + $line = '{0} {1}' -f (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ'), $Message + Write-Host $line + try { + if (-not (Test-Path -LiteralPath $LogDirectory)) { + New-Item -ItemType Directory -Path $LogDirectory -Force | Out-Null + } + Add-Content -LiteralPath $BreadcrumbLog -Value $line -Encoding UTF8 -ErrorAction Stop + } + catch { + # A breadcrumb must never be the thing that breaks the run. + } +} + +Write-Breadcrumb "Bootstrap started from '$PSScriptRoot' (Preview=$($Preview.IsPresent), User='$env:USERNAME')." + +# --- Elevation: machine-wide (HKLM / DISM) changes need it; a preview does not. +# At first logon this script inherits the signed-in user's token. Microsoft documents that +# FirstLogonCommands only run elevated when that user is a local administrator; a standard +# user gets a consent prompt, or nothing runs at all. That case is reported loudly here +# instead of silently doing nothing. --- $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent() $principal = New-Object -TypeName System.Security.Principal.WindowsPrincipal -ArgumentList $identity $isElevated = $principal.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator) if (-not $isElevated -and -not $Preview) { - Write-Host 'Elevation required - relaunching as administrator...' - $relaunchArguments = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', ('"{0}"' -f $PSCommandPath)) - Start-Process -FilePath 'powershell.exe' -Verb RunAs -ArgumentList $relaunchArguments -Wait + Write-Breadcrumb 'Not elevated - relaunching as administrator...' + try { + $relaunchArguments = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', ('"{0}"' -f $PSCommandPath)) + $child = Start-Process -FilePath 'powershell.exe' -Verb RunAs -ArgumentList $relaunchArguments -Wait -PassThru -ErrorAction Stop + # Start-Process succeeding only means the process STARTED. Check that it actually did the + # work: a child that dies immediately (for example because the script path is not reachable + # from the elevated session) would otherwise be reported as a success. + if ($null -ne $child -and $child.ExitCode -ne 0) { + Write-Breadcrumb "ELEVATED RUN FAILED with exit code $($child.ExitCode). NOTHING MAY HAVE BEEN APPLIED - check the entries above and re-run '$PSCommandPath' from an elevated prompt." + Write-Warning "windows-iso-maker: the elevated run exited with code $($child.ExitCode). See '$BreadcrumbLog'." + } + else { + Write-Breadcrumb 'Elevated run finished.' + } + } + catch { + Write-Breadcrumb "ELEVATION FAILED: $($_.Exception.Message)" + Write-Breadcrumb ("NOTHING WAS APPLIED. The signed-in account is not a local administrator, or the " + + "consent prompt was declined. Sign in with an administrator account and run " + + "'$PSCommandPath' again (or the copy under $LocalRoot).") + Write-Warning "windows-iso-maker: no changes were applied. See '$BreadcrumbLog'." + } return } # --- Copy the toolkit off the removable stick so the run survives unplugging and reboots. --- -if ($PSScriptRoot -ne $LocalRoot) { - New-Item -ItemType Directory -Path $LocalRoot -Force | Out-Null - Get-ChildItem -LiteralPath $PSScriptRoot -Force | - Where-Object { $_.Name -ne 'logs' -and $_.Name -ne 'out' } | - ForEach-Object { Copy-Item -LiteralPath $_.FullName -Destination $LocalRoot -Recurse -Force } +try { + if ($PSScriptRoot -ne $LocalRoot) { + New-Item -ItemType Directory -Path $LocalRoot -Force | Out-Null + Get-ChildItem -LiteralPath $PSScriptRoot -Force | + Where-Object { $_.Name -ne 'logs' -and $_.Name -ne 'out' } | + ForEach-Object { Copy-Item -LiteralPath $_.FullName -Destination $LocalRoot -Recurse -Force } + Write-Breadcrumb "Toolkit copied to '$LocalRoot'." + } +} +catch { + Write-Breadcrumb "TOOLKIT COPY FAILED: $($_.Exception.Message)" + throw } -New-Item -ItemType Directory -Path $LogDirectory -Force | Out-Null $transcriptPath = Join-Path -Path $LogDirectory -ChildPath ('post-install-{0}.log' -f (Get-Date -Format 'yyyyMMdd-HHmmss')) Start-Transcript -LiteralPath $transcriptPath | Out-Null @@ -200,6 +273,11 @@ try { if ($Preview) { $parameters['WhatIf'] = $true } & $postInstallScript @parameters + Write-Breadcrumb 'Post-install completed.' +} +catch { + Write-Breadcrumb "POST-INSTALL FAILED: $($_.Exception.Message)" + throw } finally { Stop-Transcript | Out-Null @@ -209,6 +287,58 @@ finally { return ($header, $settingsBlock, $footer) -join [Environment]::NewLine } +function New-PostInstallDiscoveryCommand { + <# + .SYNOPSIS + Generate the single command line that the first-logon answer file runs. + .DESCRIPTION + The USB stick's drive letter on the installed machine is not knowable when the stick is + prepared, so the command scans the file-system drives for the staged bootstrap and runs the + first one it finds. (The toolkit copy under %ProgramData% lives in a different folder, so it + cannot be matched by accident.) + + Crucially, it writes a breadcrumb either way. A first logon that finds nothing - because the + stick was unplugged, or the answer file outlived the toolkit - would otherwise fail + completely silently on a machine with no other diagnostics. + + The result is embedded in XML inside a `powershell.exe -Command "..."` argument, so the + generated code uses single quotes exclusively; it must contain no double quote. + .PARAMETER ToolkitFolder + Name of the folder holding the staged toolkit at the stick's root. + .EXAMPLE + New-PostInstallDiscoveryCommand -ToolkitFolder 'windows-iso-maker' + .OUTPUTS + System.String - the full command line. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Pure generator: returns the command line as a string and writes nothing. The caller (New-PostInstallUsb) owns ShouldProcess for the actual file write.')] + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string] $ToolkitFolder + ) + + if ($ToolkitFolder.Contains("'") -or $ToolkitFolder.Contains('"')) { + throw "ToolkitFolder must not contain quote characters: '$ToolkitFolder'." + } + + $statements = @( + "`$ErrorActionPreference='SilentlyContinue'" + "`$logDirectory=Join-Path `$env:ProgramData 'windows-iso-maker\logs'" + "New-Item -ItemType Directory -Path `$logDirectory -Force | Out-Null" + "`$log=Join-Path `$logDirectory 'bootstrap.log'" + "Add-Content -LiteralPath `$log -Value ((Get-Date).ToUniversalTime().ToString('o')+' First-logon discovery started.')" + "`$found=`$null" + "foreach (`$drive in (Get-PSDrive -PSProvider FileSystem)) { `$candidate=Join-Path `$drive.Root '$ToolkitFolder\Invoke-PostInstall.ps1'; if (Test-Path -LiteralPath `$candidate) { `$found=`$candidate; break } }" + "if (`$found) { Add-Content -LiteralPath `$log -Value ((Get-Date).ToUniversalTime().ToString('o')+' Found toolkit at '+`$found); & `$found } else { Add-Content -LiteralPath `$log -Value ((Get-Date).ToUniversalTime().ToString('o')+' NO TOOLKIT FOUND - nothing was applied. Re-attach the prepared USB stick and run $ToolkitFolder\Invoke-PostInstall.ps1 manually.') }" + ) + + $script = $statements -join '; ' + return "powershell.exe -NoProfile -ExecutionPolicy Bypass -Command `"$script`"" +} + function New-PostInstallLauncherCmd { <# .SYNOPSIS diff --git a/src/WindowsIsoMaker/Private/UsbMedia.ps1 b/src/WindowsIsoMaker/Private/UsbMedia.ps1 index 4fe7597..bc88672 100644 --- a/src/WindowsIsoMaker/Private/UsbMedia.ps1 +++ b/src/WindowsIsoMaker/Private/UsbMedia.ps1 @@ -55,6 +55,7 @@ function Get-UsbTargetInfo { FreeSpaceByte = $null IsRemovable = $null DriveType = $null + BusType = $null } if (-not $driveLetter) { return $info } @@ -77,6 +78,30 @@ function Get-UsbTargetInfo { Write-BuildLog -Level Verbose -Component 'Get-UsbTargetInfo' -Message "Could not query volume '$($driveLetter):': $($_.Exception.Message)" } + # DriveType alone is not a reliable "is this a USB stick" test: USB SSDs and USB-NVMe + # enclosures - increasingly what people use for Windows media, because the install image is + # large - report DriveType 3 (fixed). Fall back to the physical bus so those are not rejected. + if ($info.IsRemovable -eq $false) { + try { + $partition = Get-CimInstance -ClassName 'MSFT_Partition' -Namespace 'root/Microsoft/Windows/Storage' ` + -Filter ("DriveLetter='{0}'" -f $driveLetter) -ErrorAction Stop | Select-Object -First 1 + if ($partition) { + $disk = Get-CimInstance -ClassName 'MSFT_Disk' -Namespace 'root/Microsoft/Windows/Storage' ` + -Filter ("Number={0}" -f $partition.DiskNumber) -ErrorAction Stop | Select-Object -First 1 + # MSFT_Disk BusType 7 = USB. + # https://learn.microsoft.com/previous-versions/windows/desktop/stormgmt/msft-disk + if ($disk -and [int]$disk.BusType -eq 7) { + $info.BusType = 'USB' + $info.IsRemovable = $true + Write-BuildLog -Level Verbose -Component 'Get-UsbTargetInfo' -Message "Volume '$($driveLetter):' reports as fixed but sits on a USB bus; treating it as removable." + } + } + } + catch { + Write-BuildLog -Level Verbose -Component 'Get-UsbTargetInfo' -Message "Could not query the storage bus for '$($driveLetter):': $($_.Exception.Message)" + } + } + return $info } @@ -176,7 +201,10 @@ function New-FirstLogonUnattendXml { (disk layout, edition selection, OOBE skip) - this renders an answer file that contains NOTHING but a FirstLogonCommands block. Windows Setup therefore behaves exactly as it normally would (interactive edition/partition/OOBE, including an Entra ID sign-in); the only - addition is that the given commands run once, elevated, at the first logon. + addition is that the given commands run once at the first logon. + + Note that FirstLogonCommands run elevated only when the first user to sign in is a local + administrator - see the template's header comment and New-PostInstallUsb's help. That distinction matters: dropping the full build answer file onto stock media would wipe the configured disk. This file never touches the install phase. diff --git a/src/WindowsIsoMaker/Public/New-PostInstallUsb.ps1 b/src/WindowsIsoMaker/Public/New-PostInstallUsb.ps1 index 089091d..eb5d441 100644 --- a/src/WindowsIsoMaker/Public/New-PostInstallUsb.ps1 +++ b/src/WindowsIsoMaker/Public/New-PostInstallUsb.ps1 @@ -24,6 +24,17 @@ function New-PostInstallUsb { Nothing is repartitioned or wiped by this tool. (The unattended-install answer file, with disk layout and edition selection, belongs to the ISO build path - see New-AutounattendXml.) + IMPORTANT - when the automatic run does NOT fire. Microsoft documents that + FirstLogonCommands only run elevated if the first user to sign in is a local administrator; + a standard user gets a consent prompt (and nothing runs if it is declined or if UAC is + disabled). With an Entra ID sign-in, whether that account is a local administrator depends + on your Entra/Intune device settings, so the automatic run is NOT guaranteed there. + FirstLogonCommands also do not run in Autopilot pre-provisioning / self-deploying flows. + The generated bootstrap therefore records what happened to + %ProgramData%\windows-iso-maker\logs\bootstrap.log and warns loudly rather than failing + silently; you can always re-run it by hand. Use -Mode Toolkit if you would rather not rely + on the first-logon hook at all. + The generated bootstrap copies the toolkit from the stick to %ProgramData%\windows-iso-maker before running it, so the changes survive the stick being removed and can be re-run after a reboot (WSL installs span reboots). Every run writes a @@ -55,6 +66,10 @@ function New-PostInstallUsb { profile; pass -InstallWsl:$false to suppress it there. .PARAMETER WslDistribution The Linux distribution the staged run installs when WSL is included (default 'Debian'). + .PARAMETER WslServicing + How the staged run obtains WSL: 'Store' (default), 'WebDownload' or 'Inbox'. + .PARAMETER WslAutoReboot + Let the staged run reboot the machine automatically when the WSL install needs it. .PARAMETER ToolkitFolder Folder name created at the stick's root to hold the toolkit. Defaults to 'windows-iso-maker'. @@ -111,6 +126,13 @@ function New-PostInstallUsb { [ValidateNotNullOrEmpty()] [string] $WslDistribution = 'Debian', + [Parameter()] + [ValidateSet('Store', 'WebDownload', 'Inbox')] + [string] $WslServicing = 'Store', + + [Parameter()] + [switch] $WslAutoReboot, + [Parameter()] [ValidateNotNullOrEmpty()] [string] $ToolkitFolder = 'windows-iso-maker', @@ -220,7 +242,8 @@ function New-PostInstallUsb { $bootstrapScript = New-PostInstallBootstrapScript -Profile $Profile -EnableCatalogId @($EnableCatalogId) ` -DisableCatalogId @($DisableCatalogId) -Scope $Scope -Architecture $arch ` - -InstallWsl $installWslArgument -WslDistribution $WslDistribution + -InstallWsl $installWslArgument -WslDistribution $WslDistribution ` + -WslServicing $WslServicing -WslAutoReboot:$WslAutoReboot if ($PSCmdlet.ShouldProcess($bootstrapPath, 'Write the post-install bootstrap')) { Set-Content -LiteralPath $bootstrapPath -Value $bootstrapScript -Encoding UTF8 @@ -230,18 +253,15 @@ function New-PostInstallUsb { # --- 9. Hook it into the first logon via the MINIMAL answer file (never the build one). --- $writtenAutounattend = $null if ($Mode -eq 'FirstLogon') { - # Discover the toolkit by scanning the file-system drives for the bootstrap: the stick's - # drive letter after installation is not knowable at preparation time. - $discovery = "`$ErrorActionPreference='SilentlyContinue'; foreach (`$d in (Get-PSDrive -PSProvider FileSystem)) { " + - "`$p = Join-Path `$d.Root '$ToolkitFolder\Invoke-PostInstall.ps1'; " + - "if (Test-Path -LiteralPath `$p) { & `$p; break } }" - $command = "powershell.exe -NoProfile -ExecutionPolicy Bypass -Command `"$discovery`"" + $command = New-PostInstallDiscoveryCommand -ToolkitFolder $ToolkitFolder $description = "Runs the windows-iso-maker '$($Profile -join ',')' profile ($($selected.Count) catalog entries) once at first logon." $xml = New-FirstLogonUnattendXml -Command $command -Architecture $arch -Description $description if ($PSCmdlet.ShouldProcess($autounattendPath, 'Write the first-logon Autounattend.xml')) { - Set-Content -LiteralPath $autounattendPath -Value $xml -Encoding UTF8 -NoNewline + # UTF-8 WITHOUT a BOM: Set-Content -Encoding UTF8 emits a BOM under Windows PowerShell + # 5.1, which contradicts the repository's encoding rule. + [System.IO.File]::WriteAllText($autounattendPath, $xml, (New-Object System.Text.UTF8Encoding($false))) Write-BuildLog -Level Information -Component 'New-PostInstallUsb' -Message "Wrote first-logon Autounattend.xml -> '$autounattendPath'." } $writtenAutounattend = $autounattendPath @@ -250,8 +270,8 @@ function New-PostInstallUsb { $nextSteps = if ($Mode -eq 'FirstLogon') { @( "Boot the target PC from '$($target.Path)' and install Windows normally (edition, disk and OOBE stay interactive).", - 'Sign in for the first time (a local or Entra ID account) - the catalog is applied automatically, elevated.', - "Review the run report under C:\ProgramData\windows-iso-maker\out\ and the transcript under ...\logs\.", + "Sign in for the first time - the catalog is applied automatically IF that account is a local administrator (see docs/usb.md).", + "Check C:\ProgramData\windows-iso-maker\logs\bootstrap.log to confirm it ran; the run report lands in ...\out\.", 'Re-run the same bootstrap after a reboot if WSL asked for one.' ) } diff --git a/templates/autounattend/firstlogon.xml.template b/templates/autounattend/firstlogon.xml.template index 45a3f6c..dab254b 100644 --- a/templates/autounattend/firstlogon.xml.template +++ b/templates/autounattend/firstlogon.xml.template @@ -9,8 +9,14 @@ This file deliberately contains ONLY the oobeSystem pass with FirstLogonCommands. It does NOT configure disks, editions, product keys or OOBE, so Windows Setup runs exactly as it normally would on the stock media (interactive edition / partition selection, normal OOBE including an - Entra ID sign-in). The only added behaviour is that the listed commands run once, elevated, at - the first logon. + Entra ID sign-in). The only added behaviour is that the listed commands run once at the first + logon. + + Elevation caveat: Microsoft documents that FirstLogonCommands run with elevated privileges only + when the first user to sign in is a local administrator. A standard user gets a consent prompt, + and the commands do not run if it is declined or if UAC is disabled. The command below therefore + logs what it did (or could not do) to %ProgramData%\windows-iso-maker\logs\bootstrap.log. + See https://learn.microsoft.com/windows-hardware/customize/desktop/unattend/microsoft-windows-shell-setup-firstlogoncommands-synchronouscommand For the full answer file (unattended install, disk layout, edition selection) see templates/autounattend/autounattend.xml.template, which is used by the ISO build path. diff --git a/tests/New-PostInstallUsb.Tests.ps1 b/tests/New-PostInstallUsb.Tests.ps1 index 3e9a316..5e4d4a7 100644 --- a/tests/New-PostInstallUsb.Tests.ps1 +++ b/tests/New-PostInstallUsb.Tests.ps1 @@ -246,3 +246,139 @@ Describe 'New-PostInstallUsb' { Should -Throw '*root of a FIXED drive*' } } + +Describe 'Get-UsbTargetInfo volume probing' { + + It 'reports a removable volume from Win32_LogicalDisk DriveType 2' { + InModuleScope WindowsIsoMaker { + Mock Get-CimInstance -MockWith { + [pscustomobject]@{ DriveType = 2; VolumeName = 'WIN11'; FileSystem = 'FAT32'; FreeSpace = [int64]8GB } + } -ParameterFilter { $ClassName -eq 'Win32_LogicalDisk' } + + $info = Get-UsbTargetInfo -Path 'E:' + $info.Path | Should -Be 'E:\' + $info.IsVolumeRoot | Should -BeTrue + $info.IsRemovable | Should -BeTrue + $info.Label | Should -Be 'WIN11' + $info.FreeSpaceByte | Should -Be ([int64]8GB) + } + } + + It 'treats a fixed-reporting volume on a USB bus as removable (USB SSD)' { + InModuleScope WindowsIsoMaker { + Mock Get-CimInstance -MockWith { + [pscustomobject]@{ DriveType = 3; VolumeName = 'SSD'; FileSystem = 'NTFS'; FreeSpace = [int64]200GB } + } -ParameterFilter { $ClassName -eq 'Win32_LogicalDisk' } + Mock Get-CimInstance -MockWith { [pscustomobject]@{ DiskNumber = 2 } } -ParameterFilter { $ClassName -eq 'MSFT_Partition' } + # MSFT_Disk BusType 7 = USB. + Mock Get-CimInstance -MockWith { [pscustomobject]@{ BusType = 7 } } -ParameterFilter { $ClassName -eq 'MSFT_Disk' } + + $info = Get-UsbTargetInfo -Path 'E:' + $info.DriveType | Should -Be 3 + $info.BusType | Should -Be 'USB' + $info.IsRemovable | Should -BeTrue + } + } + + It 'keeps a genuinely internal disk marked as not removable' { + InModuleScope WindowsIsoMaker { + Mock Get-CimInstance -MockWith { + [pscustomobject]@{ DriveType = 3; VolumeName = 'OS'; FileSystem = 'NTFS'; FreeSpace = [int64]100GB } + } -ParameterFilter { $ClassName -eq 'Win32_LogicalDisk' } + Mock Get-CimInstance -MockWith { [pscustomobject]@{ DiskNumber = 0 } } -ParameterFilter { $ClassName -eq 'MSFT_Partition' } + # BusType 17 = NVMe. + Mock Get-CimInstance -MockWith { [pscustomobject]@{ BusType = 17 } } -ParameterFilter { $ClassName -eq 'MSFT_Disk' } + + (Get-UsbTargetInfo -Path 'C:').IsRemovable | Should -BeFalse + } + } + + It 'reports unknown volume facts instead of throwing when CIM is unavailable' { + InModuleScope WindowsIsoMaker { + Mock Get-CimInstance -MockWith { throw 'no CIM here' } + + $info = Get-UsbTargetInfo -Path 'E:' + $info.IsRemovable | Should -BeNullOrEmpty + $info.DriveType | Should -BeNullOrEmpty + } + } + + It 'does not treat a folder as a volume root' { + InModuleScope WindowsIsoMaker { + (Get-UsbTargetInfo -Path 'C:\some\folder').IsVolumeRoot | Should -BeFalse + } + } +} + +Describe 'New-PostInstallDiscoveryCommand' { + + It 'emits valid PowerShell that targets the toolkit and logs either outcome' { + InModuleScope WindowsIsoMaker { + $command = New-PostInstallDiscoveryCommand -ToolkitFolder 'windows-iso-maker' + + $command | Should -BeLike 'powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "*"' + $command | Should -Match 'windows-iso-maker\\Invoke-PostInstall\.ps1' + $command | Should -Match 'bootstrap\.log' + $command | Should -Match 'NO TOOLKIT FOUND' + + # The inner script is embedded in a -Command "..." argument, so it must not itself + # contain a double quote, and it must parse. + $inner = $command -replace '^[^"]*"', '' -replace '"$', '' + $inner | Should -Not -Match '"' + { [scriptblock]::Create($inner) } | Should -Not -Throw + } + } + + It 'rejects a toolkit folder containing quotes' { + InModuleScope WindowsIsoMaker { + { New-PostInstallDiscoveryCommand -ToolkitFolder "eviL'; rm -rf /" } | Should -Throw '*quote characters*' + } + } +} + +Describe 'New-PostInstallBootstrapScript' { + + It 'reports loudly instead of exiting silently when elevation fails' { + InModuleScope WindowsIsoMaker { + $script = New-PostInstallBootstrapScript -Profile @('default') -Scope Both -Architecture amd64 + + $script | Should -Match 'ELEVATION FAILED' + $script | Should -Match 'NOTHING WAS APPLIED' + $script | Should -Match 'Write-Breadcrumb' + # A child that starts and then dies must not be reported as a success. + $script | Should -Match 'ELEVATED RUN FAILED' + $script | Should -Match '-PassThru' + { [scriptblock]::Create($script) } | Should -Not -Throw + } + } + + It 'carries the WSL servicing settings when the opinionated profile implies WSL' { + InModuleScope WindowsIsoMaker { + $script = New-PostInstallBootstrapScript -Profile @('opinionated') -Scope Both -Architecture amd64 ` + -WslDistribution 'Ubuntu' -WslServicing 'WebDownload' -WslAutoReboot + + $script | Should -Match "WslDistribution\s+=\s+'Ubuntu'" + $script | Should -Match "WslServicing\s+=\s+'WebDownload'" + $script | Should -Match 'WslAutoReboot\s+=\s+\$true' + } + } + + It 'omits WSL settings for a profile that does not install WSL' { + InModuleScope WindowsIsoMaker { + $script = New-PostInstallBootstrapScript -Profile @('minimal') -Scope Both -Architecture amd64 + $script | Should -Not -Match 'WslServicing' + } + } +} + +Describe 'ConvertTo-PowerShellLiteral' { + + It 'escapes embedded single quotes so a value cannot break out of the literal' { + InModuleScope WindowsIsoMaker { + ConvertTo-PowerShellLiteral -Value "it's" | Should -Be "'it''s'" + ConvertTo-PowerShellLiteral -Value @("a'b", 'c') | Should -Be "@('a''b', 'c')" + ConvertTo-PowerShellLiteral -Value $true | Should -Be '$true' + ConvertTo-PowerShellLiteral -Value $null | Should -Be '$null' + } + } +}