feat(welcome): SilverOS Welcome first-logon wizard (flavour engine + apply orchestrator + MAUI UI + image bake) #4
@@ -6,13 +6,20 @@ public sealed class AccountService(IProcessRunner runner) : IAccountService
|
||||
// Daily account = Standard User (Users group only — NOT Administrators).
|
||||
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);
|
||||
$"Add-LocalGroupMember -Group 'Users' -Member '{Esc(user)}'", "Daily account creation", 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);
|
||||
$"Add-LocalGroupMember -Group 'Administrators' -Member 'SilverOS Admin'", "Admin account creation", ct);
|
||||
}
|
||||
|
||||
// $ErrorActionPreference='Stop' turns the (otherwise non-terminating) cmdlet errors into a
|
||||
// non-zero exit so EnsureSuccess can surface them instead of silently continuing.
|
||||
private async Task Ps(string script, string operation, CancellationToken ct)
|
||||
{
|
||||
var r = await runner.RunAsync("powershell.exe",
|
||||
$"-NoProfile -ExecutionPolicy Bypass -Command \"$ErrorActionPreference='Stop'; {script}\"", ct);
|
||||
r.EnsureSuccess(operation);
|
||||
}
|
||||
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("'", "''");
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
namespace SilverOS.Welcome.Core.Apply;
|
||||
public sealed class BitLockerService(IProcessRunner runner) : IBitLockerService
|
||||
{
|
||||
public Task EnableAsync(string pin, CancellationToken ct = default)
|
||||
public async Task EnableAsync(string pin, CancellationToken ct = default)
|
||||
{
|
||||
var p = pin.Replace("'", "''");
|
||||
|
||||
@@ -32,9 +32,15 @@ public sealed class BitLockerService(IProcessRunner runner) : IBitLockerService
|
||||
"$kp=(Get-BitLockerVolume -MountPoint $mp).KeyProtector; ",
|
||||
"if ($kp | Where-Object { $_.KeyProtectorType -eq 'TpmPin' }) { ",
|
||||
"$kp | Where-Object { $_.KeyProtectorType -eq 'Tpm' } | ForEach-Object { ",
|
||||
"Remove-BitLockerKeyProtector -MountPoint $mp -KeyProtectorId $_.KeyProtectorId | Out-Null } }");
|
||||
"Remove-BitLockerKeyProtector -MountPoint $mp -KeyProtectorId $_.KeyProtectorId | Out-Null } }; ",
|
||||
// Outcome check: fail loudly (non-zero exit) if a TPM+PIN protector is not present at
|
||||
// the end — this is what actually matters (a benign non-terminating warning alone
|
||||
// must not pass, and a real failure must not stay silent).
|
||||
"$ok=(Get-BitLockerVolume -MountPoint $mp).KeyProtector | Where-Object { $_.KeyProtectorType -eq 'TpmPin' }; ",
|
||||
"if (-not $ok) { Write-Error 'TPM+PIN protector not present after enrollment'; exit 1 }");
|
||||
|
||||
return runner.RunAsync("powershell.exe",
|
||||
var r = await runner.RunAsync("powershell.exe",
|
||||
$"-NoProfile -ExecutionPolicy Bypass -Command \"{script}\"", ct);
|
||||
r.EnsureSuccess("BitLocker enrollment");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,21 @@ 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'";
|
||||
// DefaultPassword may legitimately be absent → keep its per-cmdlet -EA SilentlyContinue.
|
||||
await Ps($"Set-ItemProperty {key} -Name AutoAdminLogon -Value 0; " +
|
||||
$"Remove-ItemProperty {key} -Name DefaultPassword -EA SilentlyContinue", ct);
|
||||
$"Remove-ItemProperty {key} -Name DefaultPassword -EA SilentlyContinue", "Disable auto-logon", ct);
|
||||
var u = Esc(bootstrapUser);
|
||||
await Ps($"Remove-LocalUser -Name '{u}' -EA SilentlyContinue", ct);
|
||||
await Ps($"Remove-LocalUser -Name '{u}' -EA SilentlyContinue", "Remove bootstrap account", ct);
|
||||
}
|
||||
private static string Esc(string s) => s.Replace("'", "''");
|
||||
private Task Ps(string s, CancellationToken ct) =>
|
||||
runner.RunAsync("powershell.exe", $"-NoProfile -ExecutionPolicy Bypass -Command \"{s}\"", ct);
|
||||
|
||||
// $ErrorActionPreference='Stop' surfaces unexpected hard errors (e.g. a bad registry path);
|
||||
// the intentional per-cmdlet -EA SilentlyContinue above still overrides it for the known
|
||||
// best-effort cleanups.
|
||||
private async Task Ps(string s, string operation, CancellationToken ct)
|
||||
{
|
||||
var r = await runner.RunAsync("powershell.exe",
|
||||
$"-NoProfile -ExecutionPolicy Bypass -Command \"$ErrorActionPreference='Stop'; {s}\"", ct);
|
||||
r.EnsureSuccess(operation);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace SilverOS.Welcome.Core.Apply;
|
||||
|
||||
internal static class ProcessResultExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Throws an <see cref="InvalidOperationException"/> with a scrubbed message when the
|
||||
/// process exited non-zero, so a failed privileged step surfaces to the wizard (and a
|
||||
/// failed apply does not proceed to bootstrap teardown) instead of failing silently.
|
||||
/// </summary>
|
||||
public static void EnsureSuccess(in this ProcessResult result, string operation)
|
||||
{
|
||||
if (result.ExitCode == 0) return;
|
||||
var firstLine = (result.StdErr ?? string.Empty)
|
||||
.Split('\n')
|
||||
.Select(l => l.Trim())
|
||||
.FirstOrDefault(l => l.Length > 0) ?? string.Empty;
|
||||
if (firstLine.Length > 200) firstLine = firstLine[..200];
|
||||
throw new InvalidOperationException($"{operation} failed (exit {result.ExitCode}): {firstLine}");
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,35 @@ public class ApplyServicesTests
|
||||
return m;
|
||||
}
|
||||
|
||||
private static Mock<IProcessRunner> Fail()
|
||||
{
|
||||
var m = new Mock<IProcessRunner>();
|
||||
m.Setup(r => r.RunAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ProcessResult(1, "", "the operation failed"));
|
||||
return m;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AccountService_throws_on_nonzero_exit()
|
||||
{
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
new AccountService(Fail().Object).CreateAccountsAsync("alice", "pw", "apw"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BitLockerService_throws_on_nonzero_exit()
|
||||
{
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
new BitLockerService(Fail().Object).EnableAsync("123456"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BootstrapService_throws_on_nonzero_exit()
|
||||
{
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
new BootstrapService(Fail().Object).TearDownAsync("sm-bootstrap"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AccountService_creates_standard_daily_and_admin()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user