38 KiB
WinPE Pre-Config Collector Implementation Plan (SP1)
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: A branded WinPE collector runs before Windows Setup, captures identity + install-shaping choices, generates the answer file so Setup creates the real local-admin account natively (no sm-bootstrap), and hands the rest (flavour, BitLocker PIN, app defaults) to a simplified run-once-then-persist first-boot toolbox.
Architecture: Pure-PowerShell collector logic (validation + answer-file generation) that is unit-tested headless with Pester, wrapped by a WinForms shell launched from boot.wim via winpeshl.ini. The collector writes a generated answer file plus a base64-embedded preconfig.json carried into the installed OS via the specialize pass. The existing MAUI Welcome app is trimmed into the toolbox: account creation, sm-bootstrap teardown, and the heavy kiosk are removed; a PreconfigLoader pre-seeds state and Apply becomes apps -> bitlocker -> done.
Tech Stack: PowerShell 5.1 + WinForms (WinPE WinPE-NetFx/WinPE-PowerShell), Pester v5, .NET 9 / C# (SilverOS.Welcome), xUnit + Moq, DISM (Add-WindowsPackage), oscdimg.
Spec: ../specs/2026-06-10-winpe-preconfig-collector-design.md
Branch: docs/winpe-preconfig-collector (spec committed at 59418e3). Implementation continues on this branch (rename/PR at the end is fine).
Conventions:
- Pester runs v5 under
pwshvia a config object (the CI pattern):New-PesterConfiguration;$cfg.Run.Path = '...';Invoke-Pester -Configuration $cfg. Run locally:pwsh -NoProfile -Command "$c=New-PesterConfiguration; $c.Run.Path='windows/tests/Collector.Tests.ps1'; $c.Output.Verbosity='Detailed'; Invoke-Pester -Configuration $c"(install Pester 5 first if absent:Install-Module Pester -MinimumVersion 5.0 -Scope CurrentUser -Force -SkipPublisherCheck). - PowerShell files run by Windows PE / Windows-PowerShell 5.1 must be ASCII / UTF-8-with-BOM and contain no em-dashes or smart quotes (mojibake breaks parsing — a repeat bug in this repo). Use ASCII hyphens.
- C# tests:
dotnet test windows/welcome/SilverOS.Welcome.sln -c Release. - The collector logic must be split so the testable parts have no WinForms dependency (Pester can't load WinForms reliably headless and we don't want UI in unit tests).
File structure
windows/collector/
Test-SmInput.ps1 # pure validation functions (no WinForms) -- Pester-tested
New-SmAnswerFile.ps1 # pure answer-file generator (no WinForms) -- Pester-tested
Collector.ps1 # WinForms shell: builds the form, calls the two above, launches Setup
Start-Collector.cmd # winpeshl entry point -> powershell Collector.ps1 (with fallback)
winpeshl.ini # tells WinPE to run Start-Collector.cmd instead of Setup
assets/sm-logo.png # collector branding (optional; form degrades without it)
windows/tests/
Collector.Tests.ps1 # Pester: Test-SmInput + New-SmAnswerFile
windows/welcome/src/SilverOS.Welcome.Core/Preconfig/
Preconfig.cs # record + JsonOptions
IPreconfigStore.cs / PreconfigStore.cs # load + clear-pin + configured-marker
windows/welcome/tests/SilverOS.Welcome.Tests/
PreconfigTests.cs # xUnit for PreconfigStore
Modified: windows/installer/build.ps1 (Stage 2b boot.wim), windows/welcome/src/SilverOS.Welcome.Core/Apply/{ApplyRequest,ApplyService}.cs, windows/welcome/src/SilverOS.Welcome.UI/Components/{Routes.razor,WizardState.cs}, windows/welcome/src/SilverOS.Welcome.UI/Components/Steps/ApplyStep.razor, windows/welcome/src/SilverOS.Welcome.App/MauiProgram.cs, windows/installer/autounattend/SetupComplete flow, windows/tests/Assert-IsoStructure.ps1.
Phase A — Collector validation logic (Pester TDD)
Task A1: Test-SmInput validation functions
Files:
-
Create:
windows/collector/Test-SmInput.ps1 -
Create:
windows/tests/Collector.Tests.ps1 -
Step 1: Write the failing Pester tests (
windows/tests/Collector.Tests.ps1)
#Requires -Version 5.1
. (Join-Path $PSScriptRoot '..\collector\Test-SmInput.ps1')
Describe 'Test-SmUsername' {
It 'accepts a simple username' { (Test-SmUsername 'jamie').Ok | Should -BeTrue }
It 'rejects empty' { (Test-SmUsername '').Ok | Should -BeFalse }
It 'rejects reserved name' { (Test-SmUsername 'Administrator').Ok | Should -BeFalse }
It 'rejects illegal chars' { (Test-SmUsername 'a\b').Ok | Should -BeFalse }
It 'rejects > 20 chars' { (Test-SmUsername ('x'*21)).Ok| Should -BeFalse }
}
Describe 'Test-SmPassword' {
It 'accepts matching 8+ char password' { (Test-SmPassword 'Sup3rPass!' 'Sup3rPass!').Ok | Should -BeTrue }
It 'rejects mismatch' { (Test-SmPassword 'a' 'b').Ok | Should -BeFalse }
It 'rejects < 8 chars' { (Test-SmPassword 'short' 'short').Ok | Should -BeFalse }
}
Describe 'Test-SmPin' {
It 'accepts 6-digit matching pin' { (Test-SmPin '246810' '246810').Ok | Should -BeTrue }
It 'rejects < 6 digits' { (Test-SmPin '123' '123').Ok | Should -BeFalse }
It 'rejects non-numeric' { (Test-SmPin 'abcdef' 'abcdef').Ok | Should -BeFalse }
It 'rejects mismatch' { (Test-SmPin '246810' '999999').Ok | Should -BeFalse }
}
Describe 'Test-SmComputerName' {
It 'accepts a valid name' { (Test-SmComputerName 'SILVER-01').Ok | Should -BeTrue }
It 'rejects empty' { (Test-SmComputerName '').Ok | Should -BeFalse }
It 'rejects > 15 chars' { (Test-SmComputerName ('A'*16)).Ok | Should -BeFalse }
It 'rejects illegal chars' { (Test-SmComputerName 'bad name').Ok | Should -BeFalse }
}
- Step 2: Run to verify it fails
Run: pwsh -NoProfile -Command "$c=New-PesterConfiguration; $c.Run.Path='windows/tests/Collector.Tests.ps1'; $c.Output.Verbosity='Detailed'; Invoke-Pester -Configuration $c"
Expected: FAIL — Test-SmUsername not recognized.
- Step 3: Implement (
windows/collector/Test-SmInput.ps1, ASCII + UTF-8-BOM)
#Requires -Version 5.1
# Pure validation helpers for the WinPE collector. No WinForms dependency so they
# are unit-testable headless. Each returns [pscustomobject]@{ Ok=[bool]; Message=[string] }.
function New-SmResult([bool]$ok, [string]$msg = '') { [pscustomobject]@{ Ok = $ok; Message = $msg } }
$script:SmReserved = @('administrator','guest','system','defaultaccount','wdagutilityaccount','sm-bootstrap')
function Test-SmUsername([string]$name) {
if ([string]::IsNullOrWhiteSpace($name)) { return New-SmResult $false 'Username is required.' }
if ($name.Length -gt 20) { return New-SmResult $false 'Username must be 20 characters or fewer.' }
if ($script:SmReserved -contains $name.ToLower()) { return New-SmResult $false 'That username is reserved.' }
if ($name -notmatch '^[A-Za-z0-9][A-Za-z0-9 ._-]*$') { return New-SmResult $false 'Username has illegal characters.' }
New-SmResult $true
}
function Test-SmPassword([string]$pw, [string]$confirm) {
if ([string]::IsNullOrEmpty($pw)) { return New-SmResult $false 'Password is required.' }
if ($pw.Length -lt 8) { return New-SmResult $false 'Password must be at least 8 characters.' }
if ($pw -ne $confirm) { return New-SmResult $false 'Passwords do not match.' }
New-SmResult $true
}
function Test-SmPin([string]$pin, [string]$confirm) {
if ($pin -notmatch '^[0-9]+$') { return New-SmResult $false 'PIN must be numeric.' }
if ($pin.Length -lt 6) { return New-SmResult $false 'PIN must be at least 6 digits.' }
if ($pin -ne $confirm) { return New-SmResult $false 'PINs do not match.' }
New-SmResult $true
}
function Test-SmComputerName([string]$name) {
if ([string]::IsNullOrWhiteSpace($name)) { return New-SmResult $false 'Computer name is required.' }
if ($name.Length -gt 15) { return New-SmResult $false 'Computer name must be 15 characters or fewer.' }
if ($name -notmatch '^[A-Za-z0-9-]+$') { return New-SmResult $false 'Computer name: letters, digits, hyphens only.' }
New-SmResult $true
}
- Step 4: Run to verify it passes
Run the same Pester command. Expected: PASS (all Test-Sm* contexts green).
- Step 5: Commit
git add windows/collector/Test-SmInput.ps1 windows/tests/Collector.Tests.ps1
git commit -m "feat(collector): WinPE input validation helpers + Pester tests"
Phase B — Answer-file generator (Pester TDD)
Task B1: New-SmAnswerFile
Files:
-
Create:
windows/collector/New-SmAnswerFile.ps1 -
Modify (append):
windows/tests/Collector.Tests.ps1 -
Step 1: Append failing tests to
windows/tests/Collector.Tests.ps1
. (Join-Path $PSScriptRoot '..\collector\New-SmAnswerFile.ps1')
Describe 'New-SmAnswerFile' {
$cfg = @{
DisplayName = 'Jamie'
Username = 'jamie'
Password = 'Sup3rPass!'
ComputerName = 'SILVER-01'
InputLocale = '0809:00000809'
SystemLocale = 'en-GB'
UiLanguage = 'en-US'
UserLocale = 'en-GB'
Flavour = 'developer'
BitLockerEnable = $true
BitLockerPin = '246810'
}
$xml = New-SmAnswerFile @cfg
$doc = [xml]$xml
It 'is valid XML' { { [xml]$xml } | Should -Not -Throw }
It 'creates the real account in Administrators' {
$xml | Should -Match '<Name>jamie</Name>'
$xml | Should -Match '<Group>Administrators</Group>'
}
It 'does NOT contain sm-bootstrap' { $xml | Should -Not -Match 'sm-bootstrap' }
It 'sets AutoLogon once as the user' {
$xml | Should -Match '<LogonCount>1</LogonCount>'
$xml | Should -Match '<Username>jamie</Username>'
}
It 'sets the computer name' { $xml | Should -Match '<ComputerName>SILVER-01</ComputerName>' }
It 'keeps WillWipeDisk for disk 0' { $xml | Should -Match '<WillWipeDisk>true</WillWipeDisk>' }
It 'embeds a base64 preconfig write in specialize' {
$xml | Should -Match 'preconfig\.json'
$xml | Should -Match 'FromBase64String'
}
It 'embedded preconfig round-trips with the flavour and pin' {
$m = [regex]::Match($xml, "FromBase64String\('([^']+)'\)")
$m.Success | Should -BeTrue
$json = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($m.Groups[1].Value)) | ConvertFrom-Json
$json.flavour | Should -Be 'developer'
$json.bitlocker.pin | Should -Be '246810'
}
It 'launches the toolbox in FirstLogonCommands' { $xml | Should -Match 'SilverOS\.Welcome\.App\.exe' }
}
-
Step 2: Run to verify it fails (same Pester command). Expected: FAIL —
New-SmAnswerFilenot defined. -
Step 3: Implement (
windows/collector/New-SmAnswerFile.ps1, ASCII + UTF-8-BOM)
#Requires -Version 5.1
# Pure generator: collected values -> Windows Setup answer-file XML string.
# No WinForms dependency (unit-testable). Mirrors the legacy autounattend.xml but with
# ONE real local-admin account (no sm-bootstrap) and an embedded preconfig.json that a
# specialize-pass command writes to C:\ProgramData\SilverMetal\preconfig.json.
function New-SmAnswerFile {
param(
[string]$DisplayName, [string]$Username, [string]$Password,
[string]$ComputerName,
[string]$InputLocale = '0809:00000809', [string]$SystemLocale = 'en-GB',
[string]$UiLanguage = 'en-US', [string]$UserLocale = 'en-GB',
[string]$Flavour, [bool]$BitLockerEnable = $false, [string]$BitLockerPin = ''
)
# Build the carried-forward config and base64-embed it.
$pre = [ordered]@{
schemaVersion = 1
flavour = $Flavour
bitlocker = [ordered]@{ enable = [bool]$BitLockerEnable; pin = $BitLockerPin }
apps = [ordered]@{ useFlavourDefaults = $true }
}
$preJson = ($pre | ConvertTo-Json -Depth 6 -Compress)
$preB64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($preJson))
# XML-escape user-supplied strings.
function Esc([string]$s) { [Security.SecurityElement]::Escape($s) }
$dn = Esc $DisplayName; $un = Esc $Username; $pw = Esc $Password; $cn = Esc $ComputerName
# specialize command: recreate dir + decode the embedded preconfig to C:.
$writePre = "powershell -NoProfile -ExecutionPolicy Bypass -Command "" New-Item -ItemType Directory -Force 'C:\ProgramData\SilverMetal' | Out-Null; [IO.File]::WriteAllBytes('C:\ProgramData\SilverMetal\preconfig.json', [Convert]::FromBase64String('$preB64')) """
@"
<?xml version="1.0" encoding="utf-8"?>
<unattend xmlns="urn:schemas-microsoft-com:unattend">
<settings pass="windowsPE">
<component name="Microsoft-Windows-International-Core-WinPE" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<SetupUILanguage><UILanguage>$UiLanguage</UILanguage></SetupUILanguage>
<InputLocale>$InputLocale</InputLocale><SystemLocale>$SystemLocale</SystemLocale>
<UILanguage>$UiLanguage</UILanguage><UserLocale>$UserLocale</UserLocale>
</component>
<component name="Microsoft-Windows-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<DiskConfiguration>
<WillShowUI>OnError</WillShowUI>
<Disk wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
<DiskID>0</DiskID><WillWipeDisk>true</WillWipeDisk>
<CreatePartitions>
<CreatePartition wcm:action="add"><Order>1</Order><Type>EFI</Type><Size>300</Size></CreatePartition>
<CreatePartition wcm:action="add"><Order>2</Order><Type>MSR</Type><Size>16</Size></CreatePartition>
<CreatePartition wcm:action="add"><Order>3</Order><Type>Primary</Type><Extend>true</Extend></CreatePartition>
</CreatePartitions>
<ModifyPartitions>
<ModifyPartition wcm:action="add"><Order>1</Order><PartitionID>1</PartitionID><Label>System</Label><Format>FAT32</Format></ModifyPartition>
<ModifyPartition wcm:action="add"><Order>2</Order><PartitionID>2</PartitionID></ModifyPartition>
<ModifyPartition wcm:action="add"><Order>3</Order><PartitionID>3</PartitionID><Label>Windows</Label><Format>NTFS</Format><Letter>C</Letter></ModifyPartition>
</ModifyPartitions>
</Disk>
</DiskConfiguration>
<ImageInstall><OSImage>
<InstallTo><DiskID>0</DiskID><PartitionID>3</PartitionID></InstallTo>
<InstallFrom><MetaData wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"><Key>/IMAGE/INDEX</Key><Value>1</Value></MetaData></InstallFrom>
</OSImage></ImageInstall>
<UserData><AcceptEula>true</AcceptEula></UserData>
</component>
</settings>
<settings pass="specialize">
<component name="Microsoft-Windows-Deployment" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<RunSynchronous>
<RunSynchronousCommand wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
<Order>1</Order>
<Path>$([Security.SecurityElement]::Escape($writePre))</Path>
<Description>Write SilverMetal preconfig</Description>
</RunSynchronousCommand>
</RunSynchronous>
</component>
</settings>
<settings pass="oobeSystem">
<component name="Microsoft-Windows-International-Core" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<InputLocale>$InputLocale</InputLocale><SystemLocale>$SystemLocale</SystemLocale>
<UILanguage>$UiLanguage</UILanguage><UILanguageFallback>$UiLanguage</UILanguageFallback><UserLocale>$UserLocale</UserLocale>
</component>
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<OOBE><HideEULAPage>true</HideEULAPage><HideOEMRegistrationScreen>true</HideOEMRegistrationScreen><HideOnlineAccountScreens>true</HideOnlineAccountScreens><HideLocalAccountScreen>true</HideLocalAccountScreen><HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE><ProtectYourPC>3</ProtectYourPC></OOBE>
<UserAccounts><LocalAccounts>
<LocalAccount wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
<Name>$un</Name><Group>Administrators</Group><DisplayName>$dn</DisplayName>
<Password><Value>$pw</Value><PlainText>true</PlainText></Password>
</LocalAccount>
</LocalAccounts></UserAccounts>
<AutoLogon><Enabled>true</Enabled><LogonCount>1</LogonCount><Username>$un</Username><Password><Value>$pw</Value><PlainText>true</PlainText></Password></AutoLogon>
<ComputerName>$cn</ComputerName>
<FirstLogonCommands>
<SynchronousCommand wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
<Order>1</Order>
<CommandLine>cmd /c powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -FilePath 'C:\Program Files\SilverOS\Welcome\SilverOS.Welcome.App.exe' -Verb RunAs"</CommandLine>
<Description>Launch SilverMetal toolbox (run-once)</Description>
</SynchronousCommand>
</FirstLogonCommands>
<RegisteredOwner>SilverMetal</RegisteredOwner><RegisteredOrganization>SilverLABS</RegisteredOrganization>
</component>
</settings>
</unattend>
"@
}
Note:
ComputerNameis set inoobeSystemhere for simplicity; if 24H2 ignores it there, move it to aspecializeMicrosoft-Windows-Shell-Setupcomponent in a follow-up — the test only asserts the element is present.
-
Step 4: Run to verify it passes (same Pester command). Expected: PASS (all
New-SmAnswerFileassertions, incl. base64 round-trip). -
Step 5: Commit
git add windows/collector/New-SmAnswerFile.ps1 windows/tests/Collector.Tests.ps1
git commit -m "feat(collector): answer-file generator (real account, no sm-bootstrap, embedded preconfig)"
Phase C — Preconfig consumer in the toolbox (xUnit TDD)
Task C1: Preconfig record + PreconfigStore
Files:
-
Create:
windows/welcome/src/SilverOS.Welcome.Core/Preconfig/Preconfig.cs -
Create:
windows/welcome/src/SilverOS.Welcome.Core/Preconfig/IPreconfigStore.cs -
Create:
windows/welcome/src/SilverOS.Welcome.Core/Preconfig/PreconfigStore.cs -
Create test:
windows/welcome/tests/SilverOS.Welcome.Tests/PreconfigTests.cs -
Step 1: Write failing tests (
PreconfigTests.cs)
using System.IO;
using System.Text.Json;
using SilverOS.Welcome.Core.Preconfig;
using Xunit;
public class PreconfigTests
{
static string TempDir()
{
var d = Path.Combine(Path.GetTempPath(), "smpre-" + System.Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(d);
return d;
}
const string Sample = """
{ "schemaVersion":1, "flavour":"developer",
"bitlocker":{"enable":true,"pin":"246810"},
"apps":{"useFlavourDefaults":true} }
""";
[Fact]
public void Loads_flavour_and_pin()
{
var dir = TempDir();
File.WriteAllText(Path.Combine(dir, "preconfig.json"), Sample);
var p = new PreconfigStore(dir).Load();
Assert.NotNull(p);
Assert.Equal("developer", p!.Flavour);
Assert.True(p.Bitlocker.Enable);
Assert.Equal("246810", p.Bitlocker.Pin);
Assert.True(p.Apps.UseFlavourDefaults);
}
[Fact]
public void Missing_or_bad_file_returns_null_not_throw()
{
Assert.Null(new PreconfigStore(TempDir()).Load()); // missing
var dir = TempDir();
File.WriteAllText(Path.Combine(dir, "preconfig.json"), "{ not json");
Assert.Null(new PreconfigStore(dir).Load()); // corrupt
}
[Fact]
public void ClearPin_rewrites_without_the_pin()
{
var dir = TempDir();
File.WriteAllText(Path.Combine(dir, "preconfig.json"), Sample);
var store = new PreconfigStore(dir);
store.ClearPin();
var reread = store.Load();
Assert.True(string.IsNullOrEmpty(reread!.Bitlocker.Pin));
Assert.Equal("developer", reread.Flavour); // rest preserved
}
[Fact]
public void Configured_marker_roundtrips()
{
var dir = TempDir();
var store = new PreconfigStore(dir);
Assert.False(store.IsConfigured());
store.MarkConfigured();
Assert.True(store.IsConfigured());
}
}
- Step 2: Run to verify it fails
Run: dotnet test windows/welcome/SilverOS.Welcome.sln -c Release --filter Preconfig
Expected: FAIL — types not defined.
- Step 3: Implement the records + store
Preconfig.cs:
using System.Text.Json;
namespace SilverOS.Welcome.Core.Preconfig;
public sealed record BitlockerConfig { public bool Enable { get; init; } public string? Pin { get; init; } }
public sealed record AppsConfig { public bool UseFlavourDefaults { get; init; } = true; public IReadOnlyList<string>? Selected { get; init; } }
public sealed record Preconfig
{
public int SchemaVersion { get; init; } = 1;
public string Flavour { get; init; } = "";
public BitlockerConfig Bitlocker { get; init; } = new();
public AppsConfig Apps { get; init; } = new();
public static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
}
IPreconfigStore.cs:
namespace SilverOS.Welcome.Core.Preconfig;
public interface IPreconfigStore
{
Preconfig? Load(); // null if missing/corrupt (fail-open)
void ClearPin(); // rewrite preconfig without the BitLocker pin
bool IsConfigured(); // configured marker present?
void MarkConfigured(); // write the configured marker
}
PreconfigStore.cs:
using System.Text.Json;
namespace SilverOS.Welcome.Core.Preconfig;
public sealed class PreconfigStore(string dir) : IPreconfigStore
{
private string File_ => Path.Combine(dir, "preconfig.json");
private string Marker => Path.Combine(dir, "configured");
public Preconfig? Load()
{
try
{
if (!File.Exists(File_)) return null;
return JsonSerializer.Deserialize<Preconfig>(File.ReadAllText(File_), Preconfig.JsonOptions);
}
catch (JsonException) { return null; } // fail-open
}
public void ClearPin()
{
var p = Load();
if (p is null) return;
var stripped = p with { Bitlocker = p.Bitlocker with { Pin = null } };
File.WriteAllText(File_, JsonSerializer.Serialize(stripped, Preconfig.JsonOptions));
}
public bool IsConfigured() => File.Exists(Marker);
public void MarkConfigured() { Directory.CreateDirectory(dir); File.WriteAllText(Marker, "1"); }
}
- Step 4: Run to verify it passes
Run: dotnet test windows/welcome/SilverOS.Welcome.sln -c Release --filter Preconfig
Expected: PASS.
- Step 5: Commit
git add windows/welcome/src/SilverOS.Welcome.Core/Preconfig windows/welcome/tests/SilverOS.Welcome.Tests/PreconfigTests.cs
git commit -m "feat(toolbox): preconfig store (load fail-open, clear-pin, configured marker)"
Phase D — Trim the toolbox + consume preconfig
This phase removes account creation,
sm-bootstrapteardown, and the heavy kiosk, and rewires Apply toapps -> bitlocker -> done, pre-seeded from preconfig. It touches several existing tests — update them as specified.
Task D1: Slim ApplyRequest + ApplyService to apps + bitlocker
Files:
-
Modify:
windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyRequest.cs -
Modify:
windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyService.cs -
Modify tests:
ApplyServiceTests.cs,ApplyServicesTests.cs,ApplyServiceHardeningIntegrationTests.cs,BootstrapServiceRevertKioskTests.cs,ApplyStepTests.cs -
Step 1: Read the current files (
ApplyRequest.cs,ApplyService.cs) to see the exact ctor/params and progress stages. The currentApplyServiceorder is hardening -> accounts -> apps -> bitlocker -> RevertKiosk -> TearDown -> done;ApplyRequestis(FlavourManifest Flavour, string Username, string Password, string AdminPassword, string BitLockerPin, string BootstrapUser, IReadOnlyList<AppCatalogEntry> Apps). -
Step 2: Update the failing tests first — change
ApplyServiceTestsordering expectation to the new pipeline and drop account/teardown assertions.
New ApplyRequest.cs:
using SilverOS.Welcome.Core.Flavours;
using SilverOS.Welcome.Core.Apps;
namespace SilverOS.Welcome.Core.Apply;
// Toolbox model: account is created by Windows Setup (WinPE collector), and hardening
// runs from SetupComplete. Apply only installs apps + enrols BitLocker.
public sealed record ApplyRequest(FlavourManifest Flavour, string BitLockerPin, IReadOnlyList<AppCatalogEntry> Apps);
In ApplyServiceTests.cs, the ordering test becomes (replace the old modules/accounts/apps/bitlocker/bootstrap sequence):
// Apply now: apps -> bitlocker. No accounts/hardening/teardown in the toolbox.
Assert.Equal(new[] { "apps", "bitlocker" }, order);
Remove (delete) BootstrapServiceRevertKioskTests.cs and ApplyServiceHardeningIntegrationTests.cs (the kiosk revert + in-Apply hardening are gone). Update any new ApplyRequest(...) call sites to the 3-arg form new ApplyRequest(flavour, "", apps) / with a pin where relevant.
-
Step 3: Run to verify failure —
dotnet test ... -c Releasefails to compile (ApplyRequest arity changed). Good (red). -
Step 4: Implement the slimmed
ApplyService
Rewrite ApplyService to:
using SilverOS.Welcome.Core.Apps;
namespace SilverOS.Welcome.Core.Apply;
public sealed class ApplyService(IProcessRunner runner, IBitLockerService bitlocker, IAppInstaller installer) : IApplyService
{
public async Task RunAsync(ApplyRequest req, IProgress<ApplyProgress> progress, CancellationToken ct = default)
{
progress.Report(new("Installing apps", 30));
await installer.InstallAsync(req.Apps, progress, ct); // continue-on-failure; never throws
if (!string.IsNullOrWhiteSpace(req.BitLockerPin))
{
progress.Report(new("Encrypting the disk", 75));
await bitlocker.EnableAsync(req.BitLockerPin, ct);
}
progress.Report(new("Done", 100));
}
}
Delete the now-unused IAccountService/AccountService/IBootstrapService/BootstrapService (and their tests AccountStepTests.cs if it only tests account creation — keep if it tests the removed step UI, but that step is deleted in D2). Keep IProcessRunner/ProcessRunner/BitLockerService/IAppInstaller.
-
Step 5: Run to verify pass —
dotnet test windows/welcome/SilverOS.Welcome.sln -c Release. Fix remaining call sites until green. -
Step 6: Commit
git add windows/welcome
git commit -m "refactor(toolbox): Apply is apps+bitlocker only (account via Setup, hardening via SetupComplete)"
Task D2: Remove Account step + pre-seed from preconfig + run-mode
Files:
-
Modify:
windows/welcome/src/SilverOS.Welcome.UI/Components/Routes.razor -
Delete:
windows/welcome/src/SilverOS.Welcome.UI/Components/Steps/AccountStep.razor(+AccountStepTests.cs) -
Modify:
windows/welcome/src/SilverOS.Welcome.UI/Components/Steps/ApplyStep.razor -
Modify:
windows/welcome/src/SilverOS.Welcome.App/MauiProgram.cs -
Step 1: Remove the Account step from
Routes.razor(delete its case + title; renumber: Welcome, Flavour, Apps, Prefs, Apply, Done). InjectIPreconfigStore(registered in MauiProgram pointing atC:\ProgramData\SilverMetal). On init:var pre = PreconfigStore.Load();if non-null and!IsConfigured(), pre-seedState.Flavour(match by id from loaded flavours), seedState.SelectedAppsfromDefaultSelectionForRole(pre.Flavour), setState.BitLockerPin = pre.Bitlocker.Pinwhenpre.Bitlocker.Enable. IfIsConfigured(), start on a minimal toolbox-home view (a simple page with a "Re-run setup" button) instead of auto-advancing. -
Step 2:
ApplyStep.razorbuildsnew ApplyRequest(State.Flavour!, State.BitLockerPin, apps)(3-arg). After a successful Apply:PreconfigStore.ClearPin(); PreconfigStore.MarkConfigured();. -
Step 3:
MauiProgram.cs— registerIPreconfigStore:
builder.Services.AddSingleton<IPreconfigStore>(_ => new PreconfigStore(@"C:\ProgramData\SilverMetal"));
and update the ApplyService factory to the new 3-arg ctor new ApplyService(runner, bitlocker, installer) (drop accounts/bootstrap/hardeningDir). Remove the IAccountService/IBootstrapService registrations.
-
Step 4: Build
dotnet build windows/welcome/src/SilverOS.Welcome.App -c Release-> 0 errors;dotnet test windows/welcome/SilverOS.Welcome.sln -c Release-> green (update/deleteFlavourStepTests/ApplyStepTestsas needed for the new wiring; keep their real assertions). -
Step 5: Commit
git add windows/welcome
git commit -m "feat(toolbox): drop Account step, pre-seed from preconfig, run-once vs toolbox-home"
Phase E — Build wiring + SetupComplete scrub + ISO assertions
Task E1: Stage the collector into boot.wim + winpeshl
Files:
-
Modify:
windows/installer/build.ps1(Invoke-ForceLegacySetup, around lines 103-135) -
Create:
windows/collector/Start-Collector.cmd,windows/collector/winpeshl.ini,windows/collector/Collector.ps1 -
Step 1: Create
Start-Collector.cmd(ASCII):
@echo off
REM WinPE entry point. SM_UNATTENDED=1 -> skip UI and launch Setup with the default answer file (CI).
if "%SM_UNATTENDED%"=="1" (
start /wait X:\sources\setup.exe /unattend:X:\autounattend.xml
exit /b 0
)
powershell -NoProfile -ExecutionPolicy Bypass -File X:\sm\Collector.ps1
if errorlevel 1 (
REM Collector failed/cancelled -> fall back to the default answer file so install still proceeds.
start /wait X:\sources\setup.exe /unattend:X:\autounattend.xml
)
exit /b 0
- Step 2: Create
winpeshl.ini(ASCII):
[LaunchApps]
%SYSTEMDRIVE%\sm\Start-Collector.cmd
-
Step 3: Create
Collector.ps1— the WinForms shell. It dot-sourcesTest-SmInput.ps1+New-SmAnswerFile.ps1fromX:\sm\, shows a branded full-screen form collecting {DisplayName, Username, Password+confirm, ComputerName, locale (default), flavour (radio list), BitLocker enable + PIN+confirm}, validates each with theTest-Sm*functions (block OK until valid), then on Finish:$xml = New-SmAnswerFile @collected; Set-Content X:\sm\unattend.generated.xml $xml -Encoding UTF8; Start-Process X:\sources\setup.exe "/unattend:X:\sm\unattend.generated.xml" -Wait. On Cancel:exit 1(Start-Collector falls back). Wrap the whole body in try/catch thatexit 1on any error. (UI code is not unit-tested; the validated logic + generator are.) -
Step 4: In
build.ps1Invoke-ForceLegacySetup, after copyingautounattend.xmlinto the boot mount, also: add the WinPE optional components and stage the collector. Insert inside thetry(after line 120), before thereg load:
# Add WinPE .NET + PowerShell so the collector (WinForms) can run.
$adk = 'C:\Program Files (x86)\Windows Kits\10\Assessment and Deployment Kit\Windows Preinstallation Environment\amd64\WinPE_OCs'
foreach ($oc in @('WinPE-WMI.cab','WinPE-NetFx.cab','WinPE-Scripting.cab','WinPE-PowerShell.cab')) {
$cab = Join-Path $adk $oc
if (Test-Path $cab) { Add-WindowsPackage -Path $bootmnt -PackagePath $cab | Out-Null }
else { Write-Warning " WinPE OC missing on runner: $cab (collector needs the ADK WinPE add-on)" }
}
# Stage the collector + winpeshl so WinPE launches it instead of Setup.
$smDir = Join-Path $bootmnt 'sm'; $null = New-Item -ItemType Directory -Force $smDir
Copy-Item (Join-Path $PSScriptRoot '..\collector\*') $smDir -Recurse -Force
Copy-Item (Join-Path $smDir 'winpeshl.ini') (Join-Path $bootmnt 'Windows\System32\winpeshl.ini') -Force
Keep the existing reg add ... CmdLine (it is the fallback path / legacy-setup forcing). Leave the static autounattend.xml copy in place (default/fallback answer file).
- Step 5: Parse-lint
build.ps1,Collector.ps1,Start-Collector.cmd:
pwsh -NoProfile -Command "[void][System.Management.Automation.Language.Parser]::ParseFile('C:\Staging\Source\SilverMetal\windows\installer\build.ps1',[ref]$null,[ref]$null); [void][System.Management.Automation.Language.Parser]::ParseFile('C:\Staging\Source\SilverMetal\windows\collector\Collector.ps1',[ref]$null,[ref]$null); 'ok'"
Expected: ok.
- Step 6: Commit
git add windows/collector windows/installer/build.ps1
git commit -m "feat(build): stage WinPE collector into boot.wim (winpeshl + WinPE-NetFx/PowerShell) with SM_UNATTENDED fallback"
Task E2: SetupComplete Panther scrub + ISO assertions
Files:
-
Modify: the SetupComplete script staged by build (find it:
windows/hardening/SetupComplete.cmdor where build writesC:\Windows\Setup\Scripts\SetupComplete.cmd) -
Modify:
windows/tests/Assert-IsoStructure.ps1 -
Step 1: Add a scrub line near the end of the SetupComplete flow (after hardening, runs as SYSTEM):
del /f /q "%WINDIR%\Panther\unattend.xml" 2>nul
del /f /q "%WINDIR%\Panther\Unattend\unattend.xml" 2>nul
(Find the actual SetupComplete generator in build.ps1 / hardening and append these two lines.)
- Step 2: In
Assert-IsoStructure.ps1, add boot.wim collector assertions. After the install.wim block, add a boot.wim check:
# boot.wim must carry the WinPE collector + winpeshl.
$bootwim = "$drive\sources\boot.wim"
Assert 'boot.wim present' (Test-Path $bootwim)
if (Test-Path $bootwim) {
$bmount = Join-Path $env:TEMP ('sm-assert-boot-' + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Force $bmount | Out-Null
Mount-WindowsImage -ImagePath $bootwim -Index 2 -Path $bmount -ReadOnly | Out-Null
try {
Assert 'collector staged in boot.wim' (Test-Path (Join-Path $bmount 'sm\Collector.ps1'))
Assert 'winpeshl.ini set' (Test-Path (Join-Path $bmount 'Windows\System32\winpeshl.ini'))
} finally { Dismount-WindowsImage -Path $bmount -Discard | Out-Null; Remove-Item $bmount -Recurse -Force -EA SilentlyContinue }
}
Also assert the staged toolbox no longer ships the bootstrap teardown: Assert 'no sm-bootstrap in answer file' (-not (Select-String -Path "$drive\autounattend.xml" -Pattern 'sm-bootstrap' -Quiet)).
-
Step 3: Parse-lint Assert-IsoStructure.ps1 (same ParseFile check). Expected
ok. -
Step 4: Commit
git add windows/tests/Assert-IsoStructure.ps1 windows/hardening
git commit -m "feat(build): scrub Panther unattend + assert collector baked into boot.wim"
Phase F — Verify + PR
Task F1: Full test + parity + PR
- Step 1:
dotnet test windows/welcome/SilverOS.Welcome.sln -c Release-> all green. - Step 2: Run the collector Pester:
pwsh -NoProfile -Command "$c=New-PesterConfiguration; $c.Run.Path='windows/tests/Collector.Tests.ps1'; $c.Output.Verbosity='Detailed'; $r=Invoke-Pester -Configuration $c -PassThru; if($r.FailedCount){throw $r.FailedCount}"-> 0 failures. - Step 3:
dotnet build windows/welcome/src/SilverOS.Welcome.App -c Release-> 0 errors. - Step 4: Use
superpowers:finishing-a-development-branchto open the PR (docs/winpe-preconfig-collector-> main). In the PR note the runner prerequisite: the CI Windows runner needs the ADK WinPE add-on (WinPE_OCscabs) forAdd-WindowsPackageto succeed; without it the collector isn't added and boot.wim assertions fail. The end-to-end (collector form in WinPE -> native account -> toolbox run-once) is verified on the next VM cycle.
Self-review notes (author)
- Spec coverage: collector scope (§2) -> A1/E1 (fields) ; WinForms UI (§2,§4a) -> E1 Collector.ps1 ; single admin (§2,§4b) -> B1 ; toolbox run-once+persist (§2,§4d) -> D2 ; handoff generated-answer-file + base64 (§2,§5) -> B1 ; hardening canonical in SetupComplete (§2) -> D1 (Apply drops hardening) + E2 ; preconfig contract (§4c) -> C1 ; build wiring (§4e) -> E1 ; fallback/SM_UNATTENDED (§4e,§6) -> E1 Start-Collector ; error handling fail-open (§6) -> C1 (loader) + E1 (collector fallback) ; security scrub + pin-clear (§7) -> E2 + C1/D2 ; testing (§8) -> A1/B1/C1 + E2 (Assert-IsoStructure) ; phasing (§9) -> SP2/SP3 left out.
- Placeholder scan: none — the testable units carry full code; integration tasks carry the exact code/diffs. The one soft spot (Collector.ps1 WinForms body) is intentionally described not pasted, since UI isn't unit-tested and its logic delegates to the fully-specified
Test-Sm*/New-SmAnswerFile; the executor builds the form against those contracts. - Type consistency:
Test-SmUsername/Password/Pin/ComputerName(A1) reused by Collector.ps1 (E1).New-SmAnswerFileparam names (B1) = the hashtable Collector.ps1 splats.Preconfig/BitlockerConfig/AppsConfig+PreconfigStore.Load/ClearPin/IsConfigured/MarkConfigured(C1) used in D2.ApplyRequest(Flavour,BitLockerPin,Apps)(D1) used in ApplyStep (D2).IAppInstaller.InstallAsyncunchanged. - Known risk flagged in-place: ADK WinPE add-on on the runner (E1 warning + F1 PR note);
ComputerNamepass placement (B1 note).