diff --git a/README.md b/README.md
index 7c87311..034343d 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,28 @@ 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 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
+./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 +146,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..83df9a4
--- /dev/null
+++ b/docs/usb.md
@@ -0,0 +1,180 @@
+# 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.
+
+## ⚠️ 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
+# 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`. 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. |
+| 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.
+
+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. **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).
+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:
+
+```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` / `-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.** 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
+ 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..5cf1db6
--- /dev/null
+++ b/prepare-usb.ps1
@@ -0,0 +1,154 @@
+#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 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').
+
+.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()]
+ [ValidateSet('Store', 'WebDownload', 'Inbox')]
+ [string] $WslServicing,
+
+ [Parameter()]
+ [switch] $WslAutoReboot,
+
+ [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', 'WslServicing', 'ToolkitFolder') {
+ if ($PSBoundParameters.ContainsKey($name)) {
+ $usbParams[$name] = $PSBoundParameters[$name]
+ }
+}
+foreach ($switchName in 'InstallWsl', 'WslAutoReboot', '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..b258837
--- /dev/null
+++ b/src/WindowsIsoMaker/Private/PostInstallBootstrap.ps1
@@ -0,0 +1,366 @@
+#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.
+ .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
+ 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',
+
+ [Parameter()]
+ [ValidateSet('Store', 'WebDownload', 'Inbox')]
+ [string] $WslServicing = 'Store',
+
+ [Parameter()]
+ [switch] $WslAutoReboot
+ )
+
+ $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))")
+ }
+ # 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
+
+ # 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'
+
+# --- 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-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. ---
+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
+}
+
+$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
+ Write-Breadcrumb 'Post-install completed.'
+}
+catch {
+ Write-Breadcrumb "POST-INSTALL FAILED: $($_.Exception.Message)"
+ throw
+}
+finally {
+ Stop-Transcript | Out-Null
+}
+'@
+
+ 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
+ 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..bc88672
--- /dev/null
+++ b/src/WindowsIsoMaker/Private/UsbMedia.ps1
@@ -0,0 +1,281 @@
+#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
+ BusType = $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)"
+ }
+
+ # 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
+}
+
+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 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.
+
+ 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..eb5d441
--- /dev/null
+++ b/src/WindowsIsoMaker/Public/New-PostInstallUsb.ps1
@@ -0,0 +1,308 @@
+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.)
+
+ 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
+ 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 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'.
+ .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()]
+ [ValidateSet('Store', 'WebDownload', 'Inbox')]
+ [string] $WslServicing = 'Store',
+
+ [Parameter()]
+ [switch] $WslAutoReboot,
+
+ [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 `
+ -WslServicing $WslServicing -WslAutoReboot:$WslAutoReboot
+
+ 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') {
+ $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')) {
+ # 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
+ }
+
+ $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 - 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.'
+ )
+ }
+ 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..dab254b
--- /dev/null
+++ b/templates/autounattend/firstlogon.xml.template
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+{{FIRSTLOGON_FRAGMENT}}
+
+
+
+
diff --git a/tests/New-PostInstallUsb.Tests.ps1 b/tests/New-PostInstallUsb.Tests.ps1
new file mode 100644
index 0000000..5e4d4a7
--- /dev/null
+++ b/tests/New-PostInstallUsb.Tests.ps1
@@ -0,0 +1,384 @@
+#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*'
+ }
+}
+
+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'
+ }
+ }
+}