# 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`](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):
```bash
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**

```bash
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**

```csharp
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**

```csharp
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**

```bash
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**

```csharp
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**

```csharp
// IFlavourLoader.cs
namespace SilverOS.Welcome.Core.Flavours;
public interface IFlavourLoader { IReadOnlyList<FlavourManifest> Load(string directory); }
```

```csharp
// 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**

```bash
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):
```json
{
  "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`:
```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`:
```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`:
```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**

```csharp
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**

```bash
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**

```csharp
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**

```csharp
// 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);
}
```

```csharp
// 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**

```bash
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:
```powershell
#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**

```bash
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)**

```csharp
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**

```csharp
// IAccountService.cs
namespace SilverOS.Welcome.Core.Apply;
public interface IAccountService { Task CreateAccountsAsync(string user, string password, string adminPassword, CancellationToken ct = default); }
```
```csharp
// 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("'", "''");
}
```
```csharp
// 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);
}
```
```csharp
// 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**

```bash
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)**

```csharp
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**

```csharp
// 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); }
```
```csharp
// 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**

```bash
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:
```bash
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();`:
```csharp
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`:
```csharp
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**

```bash
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**

```csharp
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`:
```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**

```bash
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**

```csharp
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` (`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**

```bash
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:
```xml
<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**

```bash
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.
```bat
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:
```bash
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'`:
```powershell
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:
```bash
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**

```yaml
      - 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:
```bash
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 UX** — `ApplyService` already guarantees bootstrap teardown runs only after success; the `ApplyStep` UI must surface failures and offer retry (covered in Task 11 implementation).
