Files
SilverMetal/windows/welcome-app-plan.md
sysadmin dfbf1d1ec8
All checks were successful
Build SilverMetal Enhanced - Windows ISO / build (push) Successful in 4m1s
docs(windows): SilverOS Welcome app implementation plan
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>
2026-06-09 01:53:41 +01:00

43 KiB
Raw Blame History

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 §AH 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 -Modules filter + -Params passthrough

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 24: Run (FAIL) → implement ApplyStep (StartAsync builds an ApplyRequest from WizardState with BootstrapUser="sm-bootstrap" and calls IApplyService.RunAsync with an IProgress<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 oobeSystem LocalAccount 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 §AH 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 §AH 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_ENABLED on; 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;
    • alice exists and is NOT in Administrators; SilverOS Admin exists and IS;
    • sm-bootstrap is gone; AutoAdminLogon = 0;
    • verify-report.json present.
  • 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 maui on 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 appSet is recorded/logged but 08-stack-install.ps1 stays a no-op until native Windows Stack builds exist; the app-set install path is wired but installs nothing real yet.
  • Apply failure UXApplyService already guarantees bootstrap teardown runs only after success; the ApplyStep UI must surface failures and offer retry (covered in Task 11 implementation).