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' + } + } +}