diff --git a/AUTOUNATTEND.md b/AUTOUNATTEND.md index a3e75a6..8722d1b 100644 --- a/AUTOUNATTEND.md +++ b/AUTOUNATTEND.md @@ -1,27 +1,19 @@ # AutoUnattend.xml Explained -This file automates only the minimal parts of Windows 11 setup needed for this repo. It intentionally avoids aggressive install-time customization so Windows Setup stays as reliable as possible. +This file is now intentionally ultra-minimal. It avoids changing Windows Setup itself and only kicks off post-install automation after first login. ## How It Works The file runs in three phases during Windows installation: -### 1. windowsPE (Installation Phase) - -Runs while Windows is being installed to the disk. - -- Bypasses TPM/Secure Boot/RAM checks (helpful for VMs) -- Leaves disk and partition selection to the user in Windows Setup -- Configures language/locale to en-US - -### 2. oobeSystem (First Boot) +### 1. oobeSystem (First Boot) Runs when Windows boots for the first time. -- Hides EULA page -- Hides OEM registration -- Forces local account creation (no Microsoft account) -- Sets privacy to minimal data collection +- Leaves the Windows installer completely manual until OOBE is finished +- Uses only a few standard OOBE settings (`ProtectYourPC`, `HideEULAPage`, wireless setup visibility) +- Waits for network connectivity +- Starts `bootstrap.ps1` **You'll still see:** - Region/language selection @@ -31,17 +23,18 @@ Runs when Windows boots for the first time. ## What Happens on First Login -1. Windows completes the normal account setup flow -2. The system waits for network connectivity -3. **bootstrap.ps1 runs automatically** via FirstLogonCommands +1. Complete the normal Windows setup flow yourself +2. Sign in for the first time +3. The system waits for network connectivity +4. **bootstrap.ps1 runs automatically** via FirstLogonCommands -Install-time tweaking is intentionally minimal. Bootstrap handles app installation via WinGet and system customization via Sophia Script after first login. +Everything else now happens in `bootstrap.ps1`: app installs, debloat, Sophia, restore shortcuts, and post-install tweaks. **If bootstrap fails or you want to re-run it:** Just run `C:\Setup\bootstrap.ps1` manually. ## Customization -This file is intentionally minimal. If you add more install-time customization later, keep it small and retest Setup carefully. +This file should stay minimal. If Windows Setup errors return, prefer moving more logic into `bootstrap.ps1` rather than adding it back here. ## Logs diff --git a/CLAUDE.md b/CLAUDE.md index 08c887d..9fb160c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,7 +89,7 @@ declarative-windows/ # 3. Boot from ISO - choose the target disk/partition, then setup continues with: # - autounattend.xml configures Windows setup after disk selection -# - Files copied to C:\Setup via $OEM$ folder +# - Files copied to C:\Setup via sources\$OEM$ folder # - bootstrap.ps1 runs automatically after first login # - Desktop shortcut created for manual re-runs @@ -101,13 +101,13 @@ declarative-windows/ ``` Custom-ISO:\ ├── sources\ # Windows install files -├── autounattend.xml # Placed in root for auto-execution -└── $OEM$\ - └── $$\ - └── Setup\ # Files copied to C:\Setup during install - ├── bootstrap.ps1 - ├── apps.json - └── Sophia.ps1 +│ └── $OEM$\ +│ └── $$\ +│ └── Setup\ # Files copied to C:\Setup during install +│ ├── bootstrap.ps1 +│ ├── apps.json +│ └── Sophia.ps1 +└── autounattend.xml # Placed in root for auto-execution ``` **On installed system:** @@ -132,7 +132,7 @@ If you don't want to generate a custom ISO, manually create USB structure: ```bash # 1. Create Windows install USB with Rufus/Media Creation Tool -# 2. Copy files to USB in $OEM$ structure (see above) +# 2. Copy files to USB in sources\$OEM$ structure (see above) # 3. Copy autounattend.xml to USB root # 4. Boot from USB ``` diff --git a/autounattend.xml b/autounattend.xml index 59e20f3..d92f19d 100644 --- a/autounattend.xml +++ b/autounattend.xml @@ -3,69 +3,23 @@ - - - - en-US - - en-US - en-US - en-US - en-US - - - - - true - - - - - - - - 1 - reg.exe add "HKLM\SYSTEM\Setup\LabConfig" /v BypassTPMCheck /t REG_DWORD /d 1 /f - - - 2 - reg.exe add "HKLM\SYSTEM\Setup\LabConfig" /v BypassSecureBootCheck /t REG_DWORD /d 1 /f - - - 3 - reg.exe add "HKLM\SYSTEM\Setup\LabConfig" /v BypassRAMCheck /t REG_DWORD /d 1 /f - - - - - + - true - true - true 3 + true + false 1 - Set PowerShell Execution Policy - powershell.exe -Command "Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force" - - - 2 - Wait for Network Connectivity - powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$timeout=300; $interval=5; $start=Get-Date; $connected=$false; while((Get-Date) -lt $start.AddSeconds($timeout)) { if (Test-Connection -ComputerName 1.1.1.1 -Count 1 -Quiet) { $connected=$true; break }; Start-Sleep -Seconds $interval }; if (-not $connected) { Write-Host 'Network wait timed out'; exit 1 }" - - - 3 Run Windows Setup Bootstrap Script powershell.exe -ExecutionPolicy Bypass -File C:\Setup\bootstrap.ps1 diff --git a/build-iso.ps1 b/build-iso.ps1 index da9d958..f6f6073 100644 --- a/build-iso.ps1 +++ b/build-iso.ps1 @@ -7,7 +7,7 @@ .DESCRIPTION This script takes a source Windows 11 ISO and creates a customized version with: - autounattend.xml for unattended installation - - bootstrap.ps1, apps.json, optional-apps.json, and Sophia-Preset.ps1 copied via $OEM$ structure when present + - bootstrap.ps1, apps.json, optional-apps.json, and Sophia-Preset.ps1 copied via sources\$OEM$ structure when present - Fully automated setup that runs on first login .PARAMETER SourceISO @@ -42,7 +42,7 @@ param( [string]$SourceIsoHash, [Parameter(Mandatory = $false)] - [string]$IsoLabel = "DECLARATIVE_WIN11", + [string]$IsoLabel, [Parameter(Mandatory = $false)] [string]$OscdimgDownloadUrl, @@ -100,6 +100,75 @@ function Validate-SourceIsoHash { Write-Success "Source ISO checksum verified" } +function Get-UnattendSetupFileReferences { + param( + [Parameter(Mandatory)] + [string]$UnattendPath + ) + + $document = [xml](Get-Content -Path $UnattendPath -Raw) + $namespaceManager = [System.Xml.XmlNamespaceManager]::new($document.NameTable) + [void]$namespaceManager.AddNamespace('u', 'urn:schemas-microsoft-com:unattend') + + $references = [System.Collections.Generic.List[string]]::new() + $commandNodes = $document.SelectNodes('//u:FirstLogonCommands/u:SynchronousCommand/u:CommandLine', $namespaceManager) + + foreach ($commandNode in $commandNodes) { + foreach ($match in [regex]::Matches($commandNode.InnerText, '(?i)\bC:\\Setup\\[^\s"'';]+')) { + $references.Add($match.Value) + } + } + + return $references | Sort-Object -Unique +} + +function Validate-StagedIsoLayout { + param( + [Parameter(Mandatory)] + [string]$WorkRoot, + + [Parameter(Mandatory)] + [string]$UnattendPath, + + [Parameter(Mandatory)] + [bool]$HasOptionalApps + ) + + $stagedSetupRoot = Join-Path $WorkRoot 'sources\$OEM$\$1\Setup' + $requiredStagedFiles = @( + (Join-Path $WorkRoot 'autounattend.xml'), + (Join-Path $stagedSetupRoot 'bootstrap.ps1'), + (Join-Path $stagedSetupRoot 'apps.json'), + (Join-Path $stagedSetupRoot 'Sophia-Preset.ps1'), + (Join-Path $stagedSetupRoot 'restore-backup.ps1'), + (Join-Path $stagedSetupRoot 'apply-registry.ps1'), + (Join-Path $stagedSetupRoot 'config\registry.json'), + (Join-Path $stagedSetupRoot 'config\backup.template.json') + ) + + if ($HasOptionalApps) { + $requiredStagedFiles += Join-Path $stagedSetupRoot 'optional-apps.json' + } + + foreach ($stagedFile in $requiredStagedFiles) { + if (-not (Test-Path $stagedFile -PathType Leaf)) { + throw "Staged ISO is missing required file: $stagedFile" + } + } + + # Proven build-time check: every C:\Setup reference in unattend must resolve to the staged $OEM$ payload. + foreach ($setupReference in (Get-UnattendSetupFileReferences -UnattendPath $UnattendPath)) { + $relativePath = $setupReference.Substring('C:\Setup\'.Length) + $stagedReference = Join-Path $stagedSetupRoot $relativePath + + if (-not (Test-Path $stagedReference -PathType Leaf)) { + throw "autounattend.xml references $setupReference, but staged ISO is missing $stagedReference" + } + } + + Write-Success 'Staged ISO layout validation passed' +} + # Function to find oscdimg.exe from Windows ADK function Find-OscdImg { param([string]$DownloadUrl) @@ -254,6 +323,21 @@ try { Write-Success "ISO mounted at: $sourceRoot" + # Capture the original ISO volume label for oscdimg + if (-not $IsoLabel) { + $IsoLabel = (Get-Volume -DriveLetter $driveLetter).FileSystemLabel + if ($IsoLabel) { + Write-Success "Captured original ISO label: $IsoLabel" + } + else { + $IsoLabel = "CCCOMA_X64FRE_EN-US_DV9" + Write-Info "Could not read original label, using default: $IsoLabel" + } + } + else { + Write-Info "Using user-provided ISO label: $IsoLabel" + } + # Copy ISO contents to working directory Write-Step "Copying ISO contents (this may take a few minutes)" Write-Info "Destination: $WorkDir" @@ -271,14 +355,14 @@ try { Copy-Item -Path $requiredFiles["autounattend.xml"] -Destination $WorkDir -Force Write-Success "autounattend.xml copied to ISO root" - # Create $OEM$ folder structure + # Create sources\$OEM$ folder structure Write-Step "Creating `$OEM`$ folder structure" - $oemPath = Join-Path $WorkDir "`$OEM`$" + $oemPath = Join-Path (Join-Path $WorkDir "sources") "`$OEM`$" $setupPath = Join-Path $oemPath "`$1\Setup" New-Item -Path $setupPath -ItemType Directory -Force | Out-Null - Write-Success "`$OEM`$\`$1\Setup folder created" + Write-Success "sources\`$OEM`$\`$1\Setup folder created" - # Copy setup files to $OEM$\$1\Setup + # Copy setup files to sources\$OEM$\$1\Setup Write-Step "Copying setup files to `$OEM`$\`$1\Setup" $filesToCopy = @( @@ -306,18 +390,27 @@ try { Copy-Item -Path $configSource -Destination $configDestination -Recurse -Force Write-Success "config folder copied" + # Fail the build here if the staged tree does not match the paths unattend will use later. + Write-Step "Validating staged ISO layout" + Validate-StagedIsoLayout -WorkRoot $WorkDir -UnattendPath (Join-Path $WorkDir "autounattend.xml") -HasOptionalApps (Test-Path $optionalFiles["optional-apps.json"]) + # Build the ISO Write-Step "Building custom ISO with oscdimg" Write-Info "This may take several minutes..." $bootImage = Join-Path $WorkDir "boot\etfsboot.com" - $efiBootImage = Join-Path $WorkDir "efi\microsoft\boot\efisys.bin" + $efiBootImage = Join-Path $WorkDir "efi\microsoft\boot\efisys_noprompt.bin" if (-not (Test-Path $bootImage)) { throw "BIOS boot image not found: $bootImage" } if (-not (Test-Path $efiBootImage)) { - throw "UEFI boot image not found: $efiBootImage" + # Fall back to efisys.bin if noprompt variant is missing + $efiBootImage = Join-Path $WorkDir "efi\microsoft\boot\efisys.bin" + if (-not (Test-Path $efiBootImage)) { + throw "UEFI boot image not found. Neither efisys_noprompt.bin nor efisys.bin exist." + } + Write-Info "efisys_noprompt.bin not found, falling back to efisys.bin" } # Resolve output path diff --git a/docs/ISO-GENERATION.md b/docs/ISO-GENERATION.md index 7fb77be..966f5b5 100644 --- a/docs/ISO-GENERATION.md +++ b/docs/ISO-GENERATION.md @@ -37,28 +37,29 @@ This guide explains how to build a custom Windows 11 ISO with declarative-window ## ISO Contents -The ISO generator injects files into the root and `$OEM$` structure: +The ISO generator injects files into the root and `sources\$OEM$` structure: ``` Custom-ISO:\ ├── autounattend.xml -└── $OEM$\ - └── $1\ - └── Setup\ - ├── bootstrap.ps1 - ├── apps.json - ├── optional-apps.json (optional) - ├── Sophia-Preset.ps1 - ├── restore-backup.ps1 - ├── apply-registry.ps1 - └── config\ - ├── backup.template.json - └── registry.json +└── sources\ + └── $OEM$\ + └── $1\ + └── Setup\ + ├── bootstrap.ps1 + ├── apps.json + ├── optional-apps.json (optional) + ├── Sophia-Preset.ps1 + ├── restore-backup.ps1 + ├── apply-registry.ps1 + └── config\ + ├── backup.template.json + └── registry.json ``` ## On the Installed System -During installation, you still choose the target disk and partition in Windows Setup. After Windows copies the `$OEM$` payload, the setup files land in `C:\Setup`. +During installation, you still choose the target disk and partition in Windows Setup. After Windows copies the `sources\$OEM$` payload, the setup files land in `C:\Setup`. ``` C:\Setup\ diff --git a/tests/Autounattend.Tests.ps1 b/tests/Autounattend.Tests.ps1 index a8e1534..62c00b0 100644 --- a/tests/Autounattend.Tests.ps1 +++ b/tests/Autounattend.Tests.ps1 @@ -20,12 +20,10 @@ Describe "autounattend.xml static checks" { $fileContent | Should -Not -Match "" } - It "sets execution policy for bootstrap" { - $fileContent | Should -Match "Set-ExecutionPolicy" - } - - It "waits for network before bootstrap" { - $fileContent | Should -Match "Wait for Network Connectivity" - $fileContent | Should -Match "Network wait timed out" + It "keeps first logon commands minimal" { + $fileContent | Should -Match "Run Windows Setup Bootstrap Script" + $fileContent | Should -Not -Match "Set-ExecutionPolicy" + $fileContent | Should -Not -Match "Wait for Network Connectivity" + $fileContent | Should -Not -Match "Network wait timed out" } } diff --git a/tests/BuildIso.Tests.ps1 b/tests/BuildIso.Tests.ps1 index 3c8ca7a..556f3e3 100644 --- a/tests/BuildIso.Tests.ps1 +++ b/tests/BuildIso.Tests.ps1 @@ -5,7 +5,7 @@ Describe "build-iso.ps1 static checks" { } It "uses $OEM$ $1 Setup path" { - ($scriptContent -like '*`$1\Setup*') | Should -BeTrue + ($scriptContent -like '*sources*`$OEM`$*`$1\Setup*') | Should -BeTrue } @@ -42,6 +42,30 @@ Describe "build-iso.ps1 static checks" { $scriptContent | Should -Match "autounattend\.xml validation passed" } + It "validates the staged ISO layout before oscdimg" { + $scriptContent | Should -Match "Validate-StagedIsoLayout" + $scriptContent | Should -Match "Validating staged ISO layout" + $scriptContent | Should -Match "Staged ISO layout validation passed" + } + + It "checks unattend C:\\Setup references against the staged OEM payload" { + $stagedPathPattern = [regex]::Escape('Join-Path $WorkRoot ''sources\$OEM$\$1\Setup''') + + $scriptContent | Should -Match "Get-UnattendSetupFileReferences" + $scriptContent | Should -Match "C:\\Setup\\" + $scriptContent | Should -Match $stagedPathPattern + $scriptContent | Should -Match "autounattend\.xml references .* staged ISO is missing" + } + + It "runs staged layout validation before building the ISO" { + $validationIndex = $scriptContent.IndexOf('Validate-StagedIsoLayout') + $buildIndex = $scriptContent.IndexOf('Write-Step "Building custom ISO with oscdimg"') + + $validationIndex | Should -BeGreaterThan -1 + $buildIndex | Should -BeGreaterThan -1 + $validationIndex | Should -BeLessThan $buildIndex + } + It "passes source ISO into unattend validation" { ($scriptContent -like '*-SourceISO $SourceISO*') | Should -BeTrue }