16-task, 5-phase TDD plan: flavour engine (manifest/loader/4 flavours) -> apply orchestrator (parameterised Invoke-Hardening, account/BitLocker/bootstrap services, ApplyService) -> MAUI Blazor wizard -> bootstrap/build integration (autounattend AutoLogon, SetupComplete defers to Welcome, build bakes app+flavours, CI) -> VM e2e. Daily account = Standard + SilverOS Admin; Daily-Driver default; Stack stubbed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
43 KiB
SilverOS Welcome App — Implementation Plan
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: Build the SilverOS Welcome app — a first-logon Blazor Hybrid (.NET MAUI) wizard that lets the user pick a flavour, create a least-privilege account + BitLocker PIN, and have the device configure itself by orchestrating the existing §A–H PowerShell hardening modules.
Architecture: A net9.0-windows solution under windows/welcome/. A Core class library holds the flavour engine (manifest model/loader/validator) and the apply-orchestrator (runs PS modules per manifest, creates accounts, enrols BitLocker, tears down the bootstrap) behind interfaces so it is unit-testable headless. A MAUI Blazor app hosts the wizard UI and calls Core. The image build (build.ps1) bakes the published app + flavour manifests and wires a self-destructing bootstrap auto-login. Hardening logic stays in PowerShell (single source of truth); Core only orchestrates it.
Tech Stack: .NET 9, .NET MAUI Blazor Hybrid (WebView2), C#, xUnit + bUnit + Moq, System.Text.Json, PowerShell (existing modules), Windows local-account + BitLocker cmdlets.
Build/test environment: This solution targets Windows x64 and must be built/tested on the silverlabs-runner-win runner or a Windows x64 VM (the repo's dev box is ARM64). CI integration mirrors build-iso-windows.yaml.
Spec: welcome-app-spec.md. Read it before starting.
File structure
windows/welcome/
├── SilverOS.Welcome.sln
├── src/
│ ├── SilverOS.Welcome.Core/ # class lib (net9.0-windows) — no UI
│ │ ├── SilverOS.Welcome.Core.csproj
│ │ ├── Flavours/
│ │ │ ├── FlavourManifest.cs # the manifest model (records)
│ │ │ ├── IFlavourLoader.cs
│ │ │ └── FlavourLoader.cs # load + validate *.json from a dir
│ │ ├── Apply/
│ │ │ ├── IProcessRunner.cs # abstraction over running a process
│ │ │ ├── ProcessRunner.cs # real powershell.exe runner
│ │ │ ├── IAccountService.cs
│ │ │ ├── AccountService.cs # create standard + admin local accounts
│ │ │ ├── IBitLockerService.cs
│ │ │ ├── BitLockerService.cs # Enable-BitLocker TPM+PIN
│ │ │ ├── IBootstrapService.cs
│ │ │ ├── BootstrapService.cs # remove AutoLogon + bootstrap account
│ │ │ ├── ApplyRequest.cs # inputs for an apply run
│ │ │ ├── ApplyProgress.cs # progress event model
│ │ │ ├── IApplyService.cs
│ │ │ └── ApplyService.cs # the orchestrator
│ │ └── SilverOS.Welcome.Core.csproj
│ └── SilverOS.Welcome.App/ # MAUI Blazor Hybrid (net9.0-windows)
│ ├── SilverOS.Welcome.App.csproj
│ ├── MauiProgram.cs
│ ├── Components/
│ │ ├── Routes.razor / App.razor / _Imports.razor
│ │ ├── WizardState.cs # holds selections across steps
│ │ └── Steps/
│ │ ├── WelcomeStep.razor
│ │ ├── FlavourStep.razor
│ │ ├── AccountStep.razor
│ │ ├── PrefsStep.razor
│ │ ├── ApplyStep.razor
│ │ └── DoneStep.razor
│ └── wwwroot/ (Mercury-aligned css)
├── tests/
│ └── SilverOS.Welcome.Tests/ # xUnit + bUnit + Moq
└── (manifests live at) windows/flavours/*.json
Plus changes to: windows/installer/build.ps1, windows/installer/autounattend/autounattend.xml, windows/hardening/Invoke-Hardening.ps1, .gitea/workflows/build-iso-windows.yaml.
Phase 1 — Flavour engine (Core)
Task 1: Solution + Core project skeleton
Files:
-
Create:
windows/welcome/SilverOS.Welcome.sln -
Create:
windows/welcome/src/SilverOS.Welcome.Core/SilverOS.Welcome.Core.csproj -
Create:
windows/welcome/tests/SilverOS.Welcome.Tests/SilverOS.Welcome.Tests.csproj -
Step 1: Create the solution + projects
Run (on a Windows x64 box with .NET 9 SDK):
cd windows/welcome
dotnet new sln -n SilverOS.Welcome
dotnet new classlib -n SilverOS.Welcome.Core -o src/SilverOS.Welcome.Core -f net9.0-windows
dotnet new xunit -n SilverOS.Welcome.Tests -o tests/SilverOS.Welcome.Tests -f net9.0-windows
dotnet sln add src/SilverOS.Welcome.Core tests/SilverOS.Welcome.Tests
dotnet add tests/SilverOS.Welcome.Tests reference src/SilverOS.Welcome.Core
dotnet add tests/SilverOS.Welcome.Tests package Moq
- Step 2: Verify it builds
Run: dotnet build
Expected: Build succeeded, 0 errors.
- Step 3: Commit
git add windows/welcome
git commit -m "feat(welcome): solution + Core/Test project skeleton"
Task 2: Flavour manifest model
Files:
-
Create:
windows/welcome/src/SilverOS.Welcome.Core/Flavours/FlavourManifest.cs -
Test:
windows/welcome/tests/SilverOS.Welcome.Tests/FlavourManifestTests.cs -
Step 1: Write the failing test
using System.Text.Json;
using SilverOS.Welcome.Core.Flavours;
using Xunit;
public class FlavourManifestTests
{
[Fact]
public void Deserializes_a_full_manifest()
{
var json = """
{
"id": "daily-driver", "label": "Daily-Driver",
"description": "Balanced.", "isDefault": true,
"hardening": { "modules": ["00","03","05"], "params": { "wdac": "audit" } },
"appSet": ["SilverBrowser"], "settings": { "autoLock": 120 }
}
""";
var m = JsonSerializer.Deserialize<FlavourManifest>(json, FlavourManifest.JsonOptions)!;
Assert.Equal("daily-driver", m.Id);
Assert.True(m.IsDefault);
Assert.Equal(new[] { "00", "03", "05" }, m.Hardening.Modules);
Assert.Equal("audit", m.Hardening.Params["wdac"]);
Assert.Contains("SilverBrowser", m.AppSet);
}
}
- Step 2: Run test to verify it fails
Run: dotnet test --filter FlavourManifestTests
Expected: FAIL — FlavourManifest does not exist.
- Step 3: Write the model
using System.Text.Json;
using System.Text.Json.Serialization;
namespace SilverOS.Welcome.Core.Flavours;
public sealed record FlavourManifest
{
public string Id { get; init; } = "";
public string Label { get; init; } = "";
public string Description { get; init; } = "";
public bool IsDefault { get; init; }
public HardeningSpec Hardening { get; init; } = new();
public IReadOnlyList<string> AppSet { get; init; } = Array.Empty<string>();
public IReadOnlyDictionary<string, JsonElement> Settings { get; init; }
= new Dictionary<string, JsonElement>();
public static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true
};
}
public sealed record HardeningSpec
{
public IReadOnlyList<string> Modules { get; init; } = Array.Empty<string>();
public IReadOnlyDictionary<string, string> Params { get; init; }
= new Dictionary<string, string>();
}
- Step 4: Run test to verify it passes
Run: dotnet test --filter FlavourManifestTests
Expected: PASS.
- Step 5: Commit
git add windows/welcome
git commit -m "feat(welcome): flavour manifest model"
Task 3: Flavour loader + validation
Files:
-
Create:
windows/welcome/src/SilverOS.Welcome.Core/Flavours/IFlavourLoader.cs -
Create:
windows/welcome/src/SilverOS.Welcome.Core/Flavours/FlavourLoader.cs -
Test:
windows/welcome/tests/SilverOS.Welcome.Tests/FlavourLoaderTests.cs -
Step 1: Write the failing tests
using SilverOS.Welcome.Core.Flavours;
using Xunit;
public class FlavourLoaderTests
{
private static string WriteTemp(params (string name, string json)[] files)
{
var dir = Directory.CreateTempSubdirectory("flav").FullName;
foreach (var (name, json) in files) File.WriteAllText(Path.Combine(dir, name), json);
return dir;
}
[Fact]
public void Loads_all_manifests_sorted_with_default_first()
{
var dir = WriteTemp(
("privacy-max.json", """{ "id":"privacy-max","label":"Privacy-Max","hardening":{"modules":["00"]} }"""),
("daily-driver.json", """{ "id":"daily-driver","label":"Daily-Driver","isDefault":true,"hardening":{"modules":["00"]} }"""));
var loaded = new FlavourLoader().Load(dir);
Assert.Equal(2, loaded.Count);
Assert.Equal("daily-driver", loaded[0].Id); // default first
}
[Fact]
public void Throws_when_a_manifest_has_no_id()
{
var dir = WriteTemp(("bad.json", """{ "label":"No Id","hardening":{"modules":["00"]} }"""));
var ex = Assert.Throws<FlavourValidationException>(() => new FlavourLoader().Load(dir));
Assert.Contains("bad.json", ex.Message);
}
[Fact]
public void Throws_when_no_default_flavour_present()
{
var dir = WriteTemp(("a.json", """{ "id":"a","label":"A","hardening":{"modules":["00"]} }"""));
Assert.Throws<FlavourValidationException>(() => new FlavourLoader().Load(dir));
}
}
- Step 2: Run tests to verify they fail
Run: dotnet test --filter FlavourLoaderTests
Expected: FAIL — FlavourLoader / FlavourValidationException do not exist.
- Step 3: Write the loader
// IFlavourLoader.cs
namespace SilverOS.Welcome.Core.Flavours;
public interface IFlavourLoader { IReadOnlyList<FlavourManifest> Load(string directory); }
// FlavourLoader.cs
using System.Text.Json;
namespace SilverOS.Welcome.Core.Flavours;
public sealed class FlavourValidationException(string message) : Exception(message);
public sealed class FlavourLoader : IFlavourLoader
{
public IReadOnlyList<FlavourManifest> Load(string directory)
{
var list = new List<FlavourManifest>();
foreach (var file in Directory.EnumerateFiles(directory, "*.json").OrderBy(f => f))
{
FlavourManifest m;
try { m = JsonSerializer.Deserialize<FlavourManifest>(File.ReadAllText(file), FlavourManifest.JsonOptions)
?? throw new FlavourValidationException($"{Path.GetFileName(file)}: empty"); }
catch (JsonException ex) { throw new FlavourValidationException($"{Path.GetFileName(file)}: {ex.Message}"); }
if (string.IsNullOrWhiteSpace(m.Id)) throw new FlavourValidationException($"{Path.GetFileName(file)}: missing id");
if (m.Hardening.Modules.Count == 0) throw new FlavourValidationException($"{Path.GetFileName(file)}: no hardening modules");
list.Add(m);
}
if (list.Count == 0) throw new FlavourValidationException("no flavour manifests found");
if (list.Count(m => m.IsDefault) != 1) throw new FlavourValidationException("exactly one flavour must be isDefault");
return list.OrderByDescending(m => m.IsDefault).ThenBy(m => m.Label).ToList();
}
}
- Step 4: Run tests to verify they pass
Run: dotnet test --filter FlavourLoaderTests
Expected: PASS (3 tests).
- Step 5: Commit
git add windows/welcome
git commit -m "feat(welcome): flavour loader + validation"
Task 4: Author the four flavour manifests
Files:
-
Create:
windows/flavours/daily-driver.json -
Create:
windows/flavours/privacy-max.json -
Create:
windows/flavours/journalist.json -
Create:
windows/flavours/developer.json -
Test:
windows/welcome/tests/SilverOS.Welcome.Tests/ShippedFlavoursTests.cs -
Step 1: Write the four manifests
daily-driver.json (default):
{
"id": "daily-driver",
"label": "Daily-Driver",
"description": "Balanced privacy and usability. Full hardened baseline; app control in audit.",
"isDefault": true,
"hardening": { "modules": ["00","01","02","03","04","05","06","07"], "params": { "wdac": "audit" } },
"appSet": ["SilverBrowser","SilverVPN","SilverKeys"],
"settings": { "autoLock": 120 }
}
privacy-max.json:
{
"id": "privacy-max",
"label": "Privacy-Max",
"description": "Maximum lockdown. App control enforced, tightest toggles.",
"isDefault": false,
"hardening": { "modules": ["00","01","02","03","04","05","06","07"], "params": { "wdac": "enforce" } },
"appSet": ["SilverBrowser","SilverVPN","SilverKeys","SilverDuress","SilverChat"],
"settings": { "autoLock": 60 }
}
journalist.json:
{
"id": "journalist",
"label": "Journalist",
"description": "Privacy-first with duress + secure comms emphasis.",
"isDefault": false,
"hardening": { "modules": ["00","01","02","03","04","05","06","07"], "params": { "wdac": "enforce" } },
"appSet": ["SilverBrowser","SilverVPN","SilverChat","SilverDuress","SilverKeys","SilverSync"],
"settings": { "autoLock": 60 }
}
developer.json:
{
"id": "developer",
"label": "Developer",
"description": "Hardened baseline with developer tooling allowances.",
"isDefault": false,
"hardening": { "modules": ["00","01","02","03","04","05","06","07"], "params": { "wdac": "audit" } },
"appSet": ["SilverBrowser","SilverVPN","SilverKeys"],
"settings": { "autoLock": 300 }
}
- Step 2: Write a test that the shipped manifests all load
using SilverOS.Welcome.Core.Flavours;
using Xunit;
public class ShippedFlavoursTests
{
private static string FlavoursDir()
{
var d = AppContext.BaseDirectory;
while (d is not null && !Directory.Exists(Path.Combine(d, "windows", "flavours")))
d = Directory.GetParent(d)?.FullName;
return Path.Combine(d!, "windows", "flavours");
}
[Fact]
public void All_shipped_flavours_are_valid_and_one_is_default()
{
var loaded = new FlavourLoader().Load(FlavoursDir());
Assert.Equal(4, loaded.Count);
Assert.Equal("daily-driver", loaded[0].Id);
}
}
- Step 3: Run it
Run: dotnet test --filter ShippedFlavoursTests
Expected: PASS.
- Step 4: Commit
git add windows/flavours windows/welcome
git commit -m "feat(welcome): author Daily-Driver/Privacy-Max/Journalist/Developer flavours"
Phase 2 — Apply orchestrator (Core)
Task 5: Process runner abstraction
Files:
-
Create:
windows/welcome/src/SilverOS.Welcome.Core/Apply/IProcessRunner.cs -
Create:
windows/welcome/src/SilverOS.Welcome.Core/Apply/ProcessRunner.cs -
Test:
windows/welcome/tests/SilverOS.Welcome.Tests/ProcessRunnerTests.cs -
Step 1: Write the failing test
using SilverOS.Welcome.Core.Apply;
using Xunit;
public class ProcessRunnerTests
{
[Fact]
public async Task Runs_powershell_and_captures_output_and_exit()
{
var r = await new ProcessRunner().RunAsync(
"powershell.exe", "-NoProfile -Command \"Write-Output hello; exit 3\"");
Assert.Equal(3, r.ExitCode);
Assert.Contains("hello", r.StdOut);
}
}
- Step 2: Run it to verify it fails
Run: dotnet test --filter ProcessRunnerTests
Expected: FAIL — ProcessRunner not defined.
- Step 3: Implement
// IProcessRunner.cs
namespace SilverOS.Welcome.Core.Apply;
public readonly record struct ProcessResult(int ExitCode, string StdOut, string StdErr);
public interface IProcessRunner
{
Task<ProcessResult> RunAsync(string file, string args, CancellationToken ct = default);
}
// ProcessRunner.cs
using System.Diagnostics;
namespace SilverOS.Welcome.Core.Apply;
public sealed class ProcessRunner : IProcessRunner
{
public async Task<ProcessResult> RunAsync(string file, string args, CancellationToken ct = default)
{
using var p = new Process { StartInfo = new ProcessStartInfo(file, args)
{
RedirectStandardOutput = true, RedirectStandardError = true,
UseShellExecute = false, CreateNoWindow = true
}};
p.Start();
var outT = p.StandardOutput.ReadToEndAsync(ct);
var errT = p.StandardError.ReadToEndAsync(ct);
await p.WaitForExitAsync(ct);
return new ProcessResult(p.ExitCode, await outT, await errT);
}
}
- Step 4: Run it to verify it passes
Run: dotnet test --filter ProcessRunnerTests
Expected: PASS.
- Step 5: Commit
git add windows/welcome
git commit -m "feat(welcome): process runner abstraction"
Task 6: Parameterise the PowerShell hardening runner
Files:
-
Modify:
windows/hardening/Invoke-Hardening.ps1 -
Step 1: Add a
-Modulesfilter +-Paramspassthrough
Replace the body of Invoke-Hardening.ps1 with:
#Requires -Version 5.1
<# Runs the §A-H modules (optionally a subset) then Verify.
-Modules "00","03","05" -> run only those numeric-prefixed modules (default: all 0*).
-ParamsJson '{"wdac":"audit"}' -> exported as $env:SM_PARAMS for modules to read. #>
[CmdletBinding()] param([string[]]$Modules, [string]$ParamsJson)
$ErrorActionPreference = 'Continue'
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
if ($ParamsJson) { $env:SM_PARAMS = $ParamsJson }
Write-Host "=== SilverMetal hardening modules ==="
$all = Get-ChildItem (Join-Path $here '0*.ps1') | Sort-Object Name
if ($Modules) { $all = $all | Where-Object { $Modules -contains $_.Name.Substring(0,2) } }
foreach ($f in $all) {
Write-Host "--> $($f.Name)"
try { & $f.FullName } catch { Write-Warning "$($f.Name) FAILED: $_" }
}
Write-Host "=== Verify ==="
try { & (Join-Path $here 'Verify-SilverMetalWindows.ps1') } catch { Write-Warning "Verify error: $_" }
Write-Host "=== runner done ==="
- Step 2: Manually sanity-check the param parse on a Windows box
Run: powershell -File windows\hardening\Invoke-Hardening.ps1 -Modules 00 -ParamsJson '{"wdac":"audit"}'
Expected: runs only 00-provisioning.ps1 then Verify; $env:SM_PARAMS set. (Full effect only on a real install; here just confirm it filters + doesn't error on parse.)
- Step 3: Commit
git add windows/hardening/Invoke-Hardening.ps1
git commit -m "feat(welcome): Invoke-Hardening accepts -Modules subset + -ParamsJson"
Task 7: Account + BitLocker + Bootstrap services
Files:
-
Create:
windows/welcome/src/SilverOS.Welcome.Core/Apply/IAccountService.cs+AccountService.cs -
Create:
windows/welcome/src/SilverOS.Welcome.Core/Apply/IBitLockerService.cs+BitLockerService.cs -
Create:
windows/welcome/src/SilverOS.Welcome.Core/Apply/IBootstrapService.cs+BootstrapService.cs -
Test:
windows/welcome/tests/SilverOS.Welcome.Tests/ApplyServicesTests.cs -
Step 1: Write failing tests (verify the right commands are issued via a mocked runner)
using Moq;
using SilverOS.Welcome.Core.Apply;
using Xunit;
public class ApplyServicesTests
{
private static Mock<IProcessRunner> Ok()
{
var m = new Mock<IProcessRunner>();
m.Setup(r => r.RunAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ProcessResult(0, "", ""));
return m;
}
[Fact]
public async Task AccountService_creates_standard_daily_and_admin()
{
var run = Ok();
await new AccountService(run.Object).CreateAccountsAsync("alice", "pw1", "adminpw");
// daily user is a Standard user (added to Users, NOT Administrators)
run.Verify(r => r.RunAsync("powershell.exe", It.Is<string>(s =>
s.Contains("New-LocalUser") && s.Contains("alice")), It.IsAny<CancellationToken>()));
run.Verify(r => r.RunAsync("powershell.exe", It.Is<string>(s =>
s.Contains("'SilverOS Admin'") && s.Contains("Administrators")), It.IsAny<CancellationToken>()));
}
[Fact]
public async Task BitLockerService_enables_tpm_and_pin()
{
var run = Ok();
await new BitLockerService(run.Object).EnableAsync("123456");
run.Verify(r => r.RunAsync("powershell.exe", It.Is<string>(s =>
s.Contains("Enable-BitLocker") && s.Contains("TpmAndPinProtector")), It.IsAny<CancellationToken>()));
}
[Fact]
public async Task BootstrapService_removes_autologon_and_account()
{
var run = Ok();
await new BootstrapService(run.Object).TearDownAsync("sm-bootstrap");
run.Verify(r => r.RunAsync("powershell.exe", It.Is<string>(s =>
s.Contains("AutoAdminLogon") && s.Contains("0")), It.IsAny<CancellationToken>()));
run.Verify(r => r.RunAsync("powershell.exe", It.Is<string>(s =>
s.Contains("Remove-LocalUser") && s.Contains("sm-bootstrap")), It.IsAny<CancellationToken>()));
}
}
- Step 2: Run to verify failure
Run: dotnet test --filter ApplyServicesTests
Expected: FAIL — services not defined.
- Step 3: Implement the three services
// IAccountService.cs
namespace SilverOS.Welcome.Core.Apply;
public interface IAccountService { Task CreateAccountsAsync(string user, string password, string adminPassword, CancellationToken ct = default); }
// AccountService.cs
namespace SilverOS.Welcome.Core.Apply;
public sealed class AccountService(IProcessRunner runner) : IAccountService
{
public async Task CreateAccountsAsync(string user, string password, string adminPassword, CancellationToken ct = default)
{
// Daily account = Standard User (Users group only).
await Ps($"$p=ConvertTo-SecureString '{Esc(password)}' -AsPlainText -Force; " +
$"New-LocalUser -Name '{Esc(user)}' -Password $p -FullName '{Esc(user)}' -AccountNeverExpires; " +
$"Add-LocalGroupMember -Group 'Users' -Member '{Esc(user)}'", ct);
// Separate elevation account.
await Ps($"$a=ConvertTo-SecureString '{Esc(adminPassword)}' -AsPlainText -Force; " +
$"New-LocalUser -Name 'SilverOS Admin' -Password $a -AccountNeverExpires; " +
$"Add-LocalGroupMember -Group 'Administrators' -Member 'SilverOS Admin'", ct);
}
private Task Ps(string script, CancellationToken ct) =>
runner.RunAsync("powershell.exe", $"-NoProfile -ExecutionPolicy Bypass -Command \"{script}\"", ct);
private static string Esc(string s) => s.Replace("'", "''");
}
// IBitLockerService.cs / BitLockerService.cs
namespace SilverOS.Welcome.Core.Apply;
public interface IBitLockerService { Task EnableAsync(string pin, CancellationToken ct = default); }
public sealed class BitLockerService(IProcessRunner runner) : IBitLockerService
{
public Task EnableAsync(string pin, CancellationToken ct = default) =>
runner.RunAsync("powershell.exe",
$"-NoProfile -ExecutionPolicy Bypass -Command \"$p=ConvertTo-SecureString '{pin.Replace("'","''")}' -AsPlainText -Force; " +
"Enable-BitLocker -MountPoint $env:SystemDrive -EncryptionMethod XtsAes256 -TpmAndPinProtector -Pin $p -SkipHardwareTest\"", ct);
}
// IBootstrapService.cs / BootstrapService.cs
namespace SilverOS.Welcome.Core.Apply;
public interface IBootstrapService { Task TearDownAsync(string bootstrapUser, CancellationToken ct = default); }
public sealed class BootstrapService(IProcessRunner runner) : IBootstrapService
{
public async Task TearDownAsync(string bootstrapUser, CancellationToken ct = default)
{
const string key = "'HKLM:\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Winlogon'";
await Ps($"Set-ItemProperty {key} -Name AutoAdminLogon -Value 0; " +
$"Remove-ItemProperty {key} -Name DefaultPassword -EA SilentlyContinue", ct);
await Ps($"Remove-LocalUser -Name '{bootstrapUser}' -EA SilentlyContinue", ct);
}
private Task Ps(string s, CancellationToken ct) =>
runner.RunAsync("powershell.exe", $"-NoProfile -ExecutionPolicy Bypass -Command \"{s}\"", ct);
}
- Step 4: Run to verify pass
Run: dotnet test --filter ApplyServicesTests
Expected: PASS (3 tests).
- Step 5: Commit
git add windows/welcome
git commit -m "feat(welcome): account + BitLocker + bootstrap services"
Task 8: ApplyService orchestrator
Files:
-
Create:
windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyRequest.cs,ApplyProgress.cs,IApplyService.cs,ApplyService.cs -
Test:
windows/welcome/tests/SilverOS.Welcome.Tests/ApplyServiceTests.cs -
Step 1: Write the failing test (ordering + teardown-last + progress)
using Moq;
using SilverOS.Welcome.Core.Apply;
using SilverOS.Welcome.Core.Flavours;
using Xunit;
public class ApplyServiceTests
{
[Fact]
public async Task Runs_modules_then_accounts_then_bitlocker_then_bootstrap_last()
{
var order = new List<string>();
var run = new Mock<IProcessRunner>();
run.Setup(r => r.RunAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Callback<string,string,CancellationToken>((_, a, _) => { if (a.Contains("Invoke-Hardening")) order.Add("modules"); })
.ReturnsAsync(new ProcessResult(0, "", ""));
var acct = new Mock<IAccountService>(); acct.Setup(a => a.CreateAccountsAsync(It.IsAny<string>(),It.IsAny<string>(),It.IsAny<string>(),It.IsAny<CancellationToken>())).Callback(() => order.Add("accounts")).Returns(Task.CompletedTask);
var bl = new Mock<IBitLockerService>(); bl.Setup(b => b.EnableAsync(It.IsAny<string>(),It.IsAny<CancellationToken>())).Callback(() => order.Add("bitlocker")).Returns(Task.CompletedTask);
var boot = new Mock<IBootstrapService>(); boot.Setup(b => b.TearDownAsync(It.IsAny<string>(),It.IsAny<CancellationToken>())).Callback(() => order.Add("bootstrap")).Returns(Task.CompletedTask);
var sut = new ApplyService(run.Object, acct.Object, bl.Object, boot.Object, "C:\\hard");
var flavour = new FlavourManifest { Id="daily-driver", Hardening = new HardeningSpec { Modules = new[]{"00"} } };
var req = new ApplyRequest(flavour, "alice", "pw", "adminpw", "123456", "sm-bootstrap");
var progress = new List<string>();
await sut.RunAsync(req, new Progress<ApplyProgress>(p => progress.Add(p.Stage)));
Assert.Equal(new[]{"modules","accounts","bitlocker","bootstrap"}, order);
Assert.Contains("Applying hardening", progress);
}
[Fact]
public async Task Does_not_tear_down_bootstrap_if_account_creation_fails()
{
var run = new Mock<IProcessRunner>(); run.Setup(r => r.RunAsync(It.IsAny<string>(),It.IsAny<string>(),It.IsAny<CancellationToken>())).ReturnsAsync(new ProcessResult(0,"",""));
var acct = new Mock<IAccountService>(); acct.Setup(a => a.CreateAccountsAsync(It.IsAny<string>(),It.IsAny<string>(),It.IsAny<string>(),It.IsAny<CancellationToken>())).ThrowsAsync(new InvalidOperationException("boom"));
var bl = new Mock<IBitLockerService>(); var boot = new Mock<IBootstrapService>();
var sut = new ApplyService(run.Object, acct.Object, bl.Object, boot.Object, "C:\\hard");
var req = new ApplyRequest(new FlavourManifest{ Hardening=new HardeningSpec{Modules=new[]{"00"}}}, "a","b","c","123456","sm-bootstrap");
await Assert.ThrowsAsync<InvalidOperationException>(() => sut.RunAsync(req, new Progress<ApplyProgress>(_ => {})));
boot.Verify(b => b.TearDownAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
}
- Step 2: Run to verify failure
Run: dotnet test --filter ApplyServiceTests
Expected: FAIL — types not defined.
- Step 3: Implement
// ApplyRequest.cs
using SilverOS.Welcome.Core.Flavours;
namespace SilverOS.Welcome.Core.Apply;
public sealed record ApplyRequest(FlavourManifest Flavour, string Username, string Password,
string AdminPassword, string BitLockerPin, string BootstrapUser);
// ApplyProgress.cs
namespace SilverOS.Welcome.Core.Apply;
public sealed record ApplyProgress(string Stage, int Percent);
// IApplyService.cs
namespace SilverOS.Welcome.Core.Apply;
public interface IApplyService { Task RunAsync(ApplyRequest req, IProgress<ApplyProgress> progress, CancellationToken ct = default); }
// ApplyService.cs
using System.Text.Json;
using SilverOS.Welcome.Core.Flavours;
namespace SilverOS.Welcome.Core.Apply;
public sealed class ApplyService(IProcessRunner runner, IAccountService accounts,
IBitLockerService bitlocker, IBootstrapService bootstrap, string hardeningDir) : IApplyService
{
public async Task RunAsync(ApplyRequest req, IProgress<ApplyProgress> progress, CancellationToken ct = default)
{
progress.Report(new("Applying hardening", 10));
var mods = string.Join(",", req.Flavour.Hardening.Modules.Select(m => $"'{m}'"));
var pjson = JsonSerializer.Serialize(req.Flavour.Hardening.Params).Replace("\"", "\\\"");
var script = Path.Combine(hardeningDir, "Invoke-Hardening.ps1");
var res = await runner.RunAsync("powershell.exe",
$"-NoProfile -ExecutionPolicy Bypass -File \"{script}\" -Modules {mods} -ParamsJson \"{pjson}\"", ct);
if (res.ExitCode != 0) throw new InvalidOperationException($"hardening failed: {res.StdErr}");
progress.Report(new("Creating your account", 55));
await accounts.CreateAccountsAsync(req.Username, req.Password, req.AdminPassword, ct);
progress.Report(new("Encrypting the disk", 75));
await bitlocker.EnableAsync(req.BitLockerPin, ct);
progress.Report(new("Finishing up", 95));
await bootstrap.TearDownAsync(req.BootstrapUser, ct); // last — only after success
progress.Report(new("Done", 100));
}
}
- Step 4: Run to verify pass
Run: dotnet test --filter ApplyServiceTests
Expected: PASS (2 tests). Then dotnet test (whole suite) — all green.
- Step 5: Commit
git add windows/welcome
git commit -m "feat(welcome): ApplyService orchestrator (modules->accounts->bitlocker->teardown)"
Phase 3 — MAUI Blazor wizard UI
Task 9: MAUI Blazor app skeleton + DI
Files:
-
Create:
windows/welcome/src/SilverOS.Welcome.App/(MAUI Blazor project) -
Modify:
windows/welcome/src/SilverOS.Welcome.App/MauiProgram.cs -
Create:
windows/welcome/src/SilverOS.Welcome.App/Components/WizardState.cs -
Step 1: Create the MAUI Blazor project
Run:
cd windows/welcome
dotnet new maui-blazor -n SilverOS.Welcome.App -o src/SilverOS.Welcome.App -f net9.0-windows10.0.19041.0
dotnet sln add src/SilverOS.Welcome.App
dotnet add src/SilverOS.Welcome.App reference src/SilverOS.Welcome.Core
(Windows-only target; remove other TFMs from the csproj <TargetFrameworks> so it is net9.0-windows10.0.19041.0 only.)
- Step 2: Register Core services + WizardState in DI
In MauiProgram.cs, before return builder.Build();:
var flavoursDir = Path.Combine(AppContext.BaseDirectory, "flavours");
var hardeningDir = @"C:\Windows\Setup\Scripts\hardening";
builder.Services.AddSingleton<IProcessRunner, ProcessRunner>();
builder.Services.AddSingleton<IFlavourLoader, FlavourLoader>();
builder.Services.AddSingleton<IAccountService, AccountService>();
builder.Services.AddSingleton<IBitLockerService, BitLockerService>();
builder.Services.AddSingleton<IBootstrapService, BootstrapService>();
builder.Services.AddSingleton<IApplyService>(sp => new ApplyService(
sp.GetRequiredService<IProcessRunner>(), sp.GetRequiredService<IAccountService>(),
sp.GetRequiredService<IBitLockerService>(), sp.GetRequiredService<IBootstrapService>(), hardeningDir));
builder.Services.AddScoped<WizardState>();
With WizardState.cs:
using SilverOS.Welcome.Core.Flavours;
namespace SilverOS.Welcome.App.Components;
public sealed class WizardState
{
public FlavourManifest? Flavour { get; set; }
public string Username { get; set; } = "";
public string Password { get; set; } = "";
public string AdminPassword { get; set; } = "";
public string BitLockerPin { get; set; } = "";
}
- Step 3: Build
Run: dotnet build src/SilverOS.Welcome.App
Expected: Build succeeded.
- Step 4: Commit
git add windows/welcome
git commit -m "feat(welcome): MAUI Blazor app skeleton + DI wiring"
Task 10: Wizard steps (UI) with a bUnit navigation test
Files:
-
Create:
Components/Steps/*.razor(Welcome/Flavour/Account/Prefs/Apply/Done) -
Modify:
Components/Routes.razor(single-page wizard host) -
Add bUnit:
dotnet add tests/SilverOS.Welcome.Tests package bunit -
Test:
windows/welcome/tests/SilverOS.Welcome.Tests/FlavourStepTests.cs -
Step 1: Write a failing bUnit test for the flavour step
using Bunit;
using Microsoft.Extensions.DependencyInjection;
using SilverOS.Welcome.App.Components.Steps;
using SilverOS.Welcome.App.Components;
using SilverOS.Welcome.Core.Flavours;
using Xunit;
public class FlavourStepTests : TestContext
{
[Fact]
public void Renders_one_card_per_flavour_and_preselects_default()
{
var flavours = new[]
{
new FlavourManifest { Id="daily-driver", Label="Daily-Driver", IsDefault=true, Hardening=new(){Modules=new[]{"00"}} },
new FlavourManifest { Id="privacy-max", Label="Privacy-Max", Hardening=new(){Modules=new[]{"00"}} },
};
Services.AddSingleton(new WizardState());
var cut = RenderComponent<FlavourStep>(p => p.Add(s => s.Flavours, flavours));
Assert.Equal(2, cut.FindAll(".flavour-card").Count);
Assert.Contains("selected", cut.Find(".flavour-card[data-id=daily-driver]").ClassList);
}
}
- Step 2: Run to verify failure
Run: dotnet test --filter FlavourStepTests
Expected: FAIL — FlavourStep not defined.
- Step 3: Implement the steps
Components/Steps/FlavourStep.razor:
@inject WizardState State
<div class="step">
<h1>What's this device for?</h1>
<div class="flavour-grid">
@foreach (var f in Flavours)
{
<div class="flavour-card @(State.Flavour?.Id == f.Id ? "selected" : "")"
data-id="@f.Id" @onclick="() => Select(f)">
<h3>@f.Label</h3><p>@f.Description</p>
</div>
}
</div>
</div>
@code {
[Parameter] public IReadOnlyList<FlavourManifest> Flavours { get; set; } = Array.Empty<FlavourManifest>();
protected override void OnInitialized() => State.Flavour ??= Flavours.FirstOrDefault(f => f.IsDefault);
void Select(FlavourManifest f) => State.Flavour = f;
}
Implement WelcomeStep, AccountStep (daily user/password + admin password + BitLocker PIN fields, with validation), PrefsStep, ApplyStep (calls IApplyService.RunAsync with an IProgress bound to a progress bar), DoneStep (a "Restart now" button → shutdown /r /t 5). The wizard host (Routes.razor or a Wizard.razor) tracks the current step index and Next/Back, loading flavours via IFlavourLoader from the flavours dir on init.
- Step 4: Run to verify pass
Run: dotnet test --filter FlavourStepTests
Expected: PASS. Then full dotnet test — all green.
- Step 5: Commit
git add windows/welcome
git commit -m "feat(welcome): wizard steps + flavour selection UI"
Task 11: Apply step wiring + Mercury styling
Files:
-
Modify:
Components/Steps/ApplyStep.razor,wwwroot/css/app.css -
Test:
windows/welcome/tests/SilverOS.Welcome.Tests/ApplyStepTests.cs -
Step 1: Write a failing bUnit test that Apply invokes IApplyService with the wizard selections
using Bunit; using Moq; using Microsoft.Extensions.DependencyInjection;
using SilverOS.Welcome.App.Components; using SilverOS.Welcome.App.Components.Steps;
using SilverOS.Welcome.Core.Apply; using SilverOS.Welcome.Core.Flavours; using Xunit;
public class ApplyStepTests : TestContext
{
[Fact]
public async Task Calls_apply_with_the_wizard_selections()
{
var apply = new Mock<IApplyService>();
var state = new WizardState { Flavour = new FlavourManifest{Id="daily-driver",Hardening=new(){Modules=new[]{"00"}}},
Username="alice", Password="pw", AdminPassword="apw", BitLockerPin="123456" };
Services.AddSingleton(state); Services.AddSingleton(apply.Object);
var cut = RenderComponent<ApplyStep>();
await cut.InvokeAsync(() => cut.Instance.StartAsync());
apply.Verify(a => a.RunAsync(It.Is<ApplyRequest>(r => r.Username=="alice" && r.Flavour.Id=="daily-driver"),
It.IsAny<IProgress<ApplyProgress>>(), It.IsAny<CancellationToken>()), Times.Once);
}
}
-
Step 2–4: Run (FAIL) → implement
ApplyStep(StartAsyncbuilds anApplyRequestfromWizardStatewithBootstrapUser="sm-bootstrap"and callsIApplyService.RunAsyncwith anIProgress<ApplyProgress>updating a bound percent/label; on completion advance to Done) → run (PASS). Add Mercury-aligned CSS. -
Step 5: Commit
git add windows/welcome
git commit -m "feat(welcome): apply step wiring + Mercury styling"
Phase 4 — Bootstrap + build integration
Task 12: Answer file → bootstrap account + AutoLogon + RunOnce
Files:
-
Modify:
windows/installer/autounattend/autounattend.xml -
Step 1: Replace the
oobeSystemLocalAccount with a locked bootstrap + AutoLogon
Change the Microsoft-Windows-Shell-Setup (oobeSystem) component so the created LocalAccount is sm-bootstrap (Administrators), and add:
<AutoLogon>
<Enabled>true</Enabled>
<LogonCount>1</LogonCount>
<Username>sm-bootstrap</Username>
<Password><Value>bootstrap-OneTime!</Value><PlainText>true</PlainText></Password>
</AutoLogon>
<FirstLogonCommands>
<SynchronousCommand wcm:action="add"><Order>1</Order>
<CommandLine>cmd /c "C:\Program Files\SilverOS\Welcome\SilverOS.Welcome.App.exe"</CommandLine>
<Description>SilverOS Welcome</Description>
</SynchronousCommand>
</FirstLogonCommands>
(The bootstrap password is one-time and the account self-destructs at end of onboarding; document that clearly. Keep HideOnlineAccountScreens/HideLocalAccountScreen so OOBE still auto-completes into the bootstrap session.)
-
Step 2: Validate XML:
[xml](Get-Content windows\installer\autounattend\autounattend.xml -Raw)→ no error. -
Step 3: Commit
git add windows/installer/autounattend/autounattend.xml
git commit -m "feat(welcome): bootstrap auto-login launches the Welcome app"
Task 13: SetupComplete stops applying hardening (Welcome owns it)
Files:
-
Modify:
windows/installer/oem/SetupComplete.cmd -
Step 1: Gate the §A–H run behind the absence of the Welcome app
Replace the hardening invocation so that, when WELCOME_ENABLED, SetupComplete.cmd only logs "deferred to SilverOS Welcome" and does NOT run Invoke-Hardening.ps1 (the Welcome app runs the flavour's subset). Keep the direct-hardening path when Welcome is disabled.
if exist "C:\Program Files\SilverOS\Welcome\SilverOS.Welcome.App.exe" (
echo [%DATE% %TIME%] hardening deferred to SilverOS Welcome >> "%LOG%"
) else (
powershell -NoProfile -ExecutionPolicy Bypass -File "%HARD%\Invoke-Hardening.ps1" >> "%LOG%" 2>&1
)
- Step 2: Commit:
git add windows/installer/oem/SetupComplete.cmd
git commit -m "feat(welcome): SetupComplete defers hardening to Welcome when present"
Task 14: build.ps1 publishes + bakes the Welcome app and flavours
Files:
-
Modify:
windows/installer/build.ps1 -
Step 1: Add a stage (between ServiceWim and InjectUnattend) that publishes + bakes the app
Add an Invoke-StageWelcome that, when $env:SILVERMETAL_WELCOME_ENABLED -ne '0':
function Invoke-StageWelcome {
Write-Stage 'Stage 3c: stage SilverOS Welcome app + flavours'
$proj = Join-Path $WindowsDir 'welcome\src\SilverOS.Welcome.App'
$out = Join-Path $WorkDir 'welcome-publish'
& dotnet publish $proj -c Release -r win-x64 --self-contained true -o $out
if ($LASTEXITCODE -ne 0) { throw 'Welcome publish failed' }
# mount install.wim is already serviced/dismounted; re-mount to inject app, OR
# inject during Invoke-ServiceWim. SIMPLEST: stage into the WIM in ServiceWim.
Copy-Item "$out\*" (Join-Path $mount 'Program Files\SilverOS\Welcome') -Recurse -Force
Copy-Item (Join-Path $WindowsDir 'flavours\*.json') (Join-Path $mount 'Program Files\SilverOS\Welcome\flavours') -Force
}
Implementation note: the cleanest is to fold this copy into Invoke-ServiceWim (while install.wim is mounted) — do the dotnet publish first, then copy into $mount\Program Files\SilverOS\Welcome alongside the existing hardening-payload copy. Refactor Invoke-ServiceWim to call a Copy-WelcomePayload helper. Keep the §A–H modules staged too (Welcome runs them).
- Step 2: Parse-check
build.ps1; commit:
git add windows/installer/build.ps1
git commit -m "feat(welcome): build bakes the published Welcome app + flavours into the image"
Task 15: CI builds + tests the Welcome solution
Files:
-
Modify:
.gitea/workflows/build-iso-windows.yaml -
Step 1: Add a step (before the ISO build) that builds + tests the solution
- name: Build + test SilverOS Welcome
shell: pwsh
run: |
dotnet test windows/welcome/SilverOS.Welcome.sln -c Release
(Requires the .NET 9 SDK + MAUI workload on the runner: dotnet workload install maui in an ensure-step, mirroring the ADK ensure-step.)
- Step 2: Commit:
git add .gitea/workflows/build-iso-windows.yaml
git commit -m "ci(welcome): build + test the Welcome solution before the ISO build"
Phase 5 — End-to-end VM validation
Task 16: Full VM run via the SLAB01 harness
Files: none (operational), using the VM-test approach proven in this repo's history.
- Step 1: Build the ISO (CI green) with
WELCOME_ENABLEDon; transfer to SLAB01; reset VM 102 clean; boot (legacy setup, 1 Alt+N) → OOBE auto → bootstrap auto-login launches the Welcome app. - Step 2: Drive the wizard (or scripted sendkeys): pick Daily-Driver, create
alice/password + admin password + BitLocker PIN, apply. - Step 3: After reboot, offline-mount the disk (
qemu-nbd -r+mount -t ntfs3 -o ro) and assert:- first-boot/apply log shows the Daily-Driver module subset ran;
aliceexists and is NOT in Administrators;SilverOS Adminexists and IS;sm-bootstrapis gone; AutoAdminLogon = 0;verify-report.jsonpresent.
- Step 4: Document the result in
welcome-app-spec.md(a "Validated" note) and commit.
Notes / risks (carry into execution)
- MAUI workload + WebView2 on the runner and in the image — confirm
dotnet workload install mauion the runner and that WebView2 Evergreen is present on IoT Enterprise LTSC (else bundle the fixed-version runtime in Task 14). - Kiosk lock-down of the bootstrap session is NOT in v1 tasks above (the wizard runs as a normal app under
sm-bootstrap). Add a hardening pass (disable Explorer shell escape) before shipping — tracked as a follow-up, not a v1 blocker for the VM test. - Stack installs remain stubs — the
appSetis recorded/logged but08-stack-install.ps1stays a no-op until native Windows Stack builds exist; the app-set install path is wired but installs nothing real yet. - Apply failure UX —
ApplyServicealready guarantees bootstrap teardown runs only after success; theApplyStepUI must surface failures and offer retry (covered in Task 11 implementation).