From 42d86734b05b3f0c421a7d59b54c8aa3522bcac2 Mon Sep 17 00:00:00 2001 From: sysadmin Date: Wed, 10 Jun 2026 08:51:35 +0100 Subject: [PATCH] feat(collector): answer-file generator (real account, no sm-bootstrap, embedded preconfig) --- windows/collector/New-SmAnswerFile.ps1 | 105 +++++++++++++++++++++++++ windows/tests/Collector.Tests.ps1 | 37 +++++++++ 2 files changed, 142 insertions(+) create mode 100644 windows/collector/New-SmAnswerFile.ps1 diff --git a/windows/collector/New-SmAnswerFile.ps1 b/windows/collector/New-SmAnswerFile.ps1 new file mode 100644 index 0000000..d0cc6a2 --- /dev/null +++ b/windows/collector/New-SmAnswerFile.ps1 @@ -0,0 +1,105 @@ +#Requires -Version 5.1 +# Pure generator: collected values -> Windows Setup answer-file XML string. +# No WinForms dependency (unit-testable). Mirrors the legacy autounattend.xml but with +# ONE real local-admin account (no sm-bootstrap) and an embedded preconfig.json that a +# specialize-pass command writes to C:\ProgramData\SilverMetal\preconfig.json. + +function New-SmAnswerFile { + param( + [string]$DisplayName, [string]$Username, [string]$Password, + [string]$ComputerName, + [string]$InputLocale = '0809:00000809', [string]$SystemLocale = 'en-GB', + [string]$UiLanguage = 'en-US', [string]$UserLocale = 'en-GB', + [string]$Flavour, [bool]$BitLockerEnable = $false, [string]$BitLockerPin = '' + ) + + $pre = [ordered]@{ + schemaVersion = 1 + flavour = $Flavour + bitlocker = [ordered]@{ enable = [bool]$BitLockerEnable; pin = $BitLockerPin } + apps = [ordered]@{ useFlavourDefaults = $true } + } + $preJson = ($pre | ConvertTo-Json -Depth 6 -Compress) + $preB64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($preJson)) + + function Esc([string]$s) { [Security.SecurityElement]::Escape($s) } + # Escape ONLY the characters XML element content requires (& < >). Unlike + # SecurityElement::Escape this leaves single/double quotes literal, so the + # embedded command keeps a working FromBase64String('...') literal. + function EscContent([string]$s) { $s.Replace('&','&').Replace('<','<').Replace('>','>') } + $dn = Esc $DisplayName; $un = Esc $Username; $pw = Esc $Password; $cn = Esc $ComputerName + + $writePre = "powershell -NoProfile -ExecutionPolicy Bypass -Command "" New-Item -ItemType Directory -Force 'C:\ProgramData\SilverMetal' | Out-Null; [IO.File]::WriteAllBytes('C:\ProgramData\SilverMetal\preconfig.json', [Convert]::FromBase64String('$preB64')) """ + +@" + + + + + $UiLanguage + $InputLocale$SystemLocale + $UiLanguage$UserLocale + + + + OnError + + 0true + + 1EFI300 + 2MSR16 + 3Primarytrue + + + 11FAT32 + 22 + 33NTFSC + + + + + 03 + /IMAGE/INDEX1 + + true + + + + + + + 1 + $(EscContent $writePre) + Write SilverMetal preconfig + + + + + + + $InputLocale$SystemLocale + $UiLanguage$UiLanguage$UserLocale + + + truetruetruetruetrue3 + + + $unAdministrators$dn + $pwtrue</PlainText></Password> + </LocalAccount> + </LocalAccounts></UserAccounts> + <AutoLogon><Enabled>true</Enabled><LogonCount>1</LogonCount><Username>$un</Username><Password><Value>$pw</Value><PlainText>true</PlainText></Password></AutoLogon> + <ComputerName>$cn</ComputerName> + <FirstLogonCommands> + <SynchronousCommand wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"> + <Order>1</Order> + <CommandLine>cmd /c powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -FilePath 'C:\Program Files\SilverOS\Welcome\SilverOS.Welcome.App.exe' -Verb RunAs"</CommandLine> + <Description>Launch SilverMetal toolbox (run-once)</Description> + </SynchronousCommand> + </FirstLogonCommands> + <RegisteredOwner>SilverMetal</RegisteredOwner><RegisteredOrganization>SilverLABS</RegisteredOrganization> + </component> + </settings> +</unattend> +"@ +} diff --git a/windows/tests/Collector.Tests.ps1 b/windows/tests/Collector.Tests.ps1 index 8a84196..b4ef7e2 100644 --- a/windows/tests/Collector.Tests.ps1 +++ b/windows/tests/Collector.Tests.ps1 @@ -30,3 +30,40 @@ Describe 'Test-SmComputerName' { It 'rejects > 15 chars' { (Test-SmComputerName ('A'*16)).Ok | Should -BeFalse } It 'rejects illegal chars' { (Test-SmComputerName 'bad name').Ok | Should -BeFalse } } + +Describe 'New-SmAnswerFile' { + BeforeAll { + . (Join-Path $PSScriptRoot '..\collector\New-SmAnswerFile.ps1') + $cfg = @{ + DisplayName = 'Jamie'; Username = 'jamie'; Password = 'Sup3rPass!' + ComputerName = 'SILVER-01' + InputLocale = '0809:00000809'; SystemLocale = 'en-GB'; UiLanguage = 'en-US'; UserLocale = 'en-GB' + Flavour = 'developer'; BitLockerEnable = $true; BitLockerPin = '246810' + } + $script:xml = New-SmAnswerFile @cfg + } + It 'is valid XML' { { [xml]$script:xml } | Should -Not -Throw } + It 'creates the real account in Administrators' { + $script:xml | Should -Match '<Name>jamie</Name>' + $script:xml | Should -Match '<Group>Administrators</Group>' + } + It 'does NOT contain sm-bootstrap' { $script:xml | Should -Not -Match 'sm-bootstrap' } + It 'sets AutoLogon once as the user' { + $script:xml | Should -Match '<LogonCount>1</LogonCount>' + $script:xml | Should -Match '<Username>jamie</Username>' + } + It 'sets the computer name' { $script:xml | Should -Match '<ComputerName>SILVER-01</ComputerName>' } + It 'keeps WillWipeDisk for disk 0' { $script:xml | Should -Match '<WillWipeDisk>true</WillWipeDisk>' } + It 'embeds a base64 preconfig write in specialize' { + $script:xml | Should -Match 'preconfig\.json' + $script:xml | Should -Match 'FromBase64String' + } + It 'embedded preconfig round-trips with the flavour and pin' { + $m = [regex]::Match($script:xml, "FromBase64String\('([^']+)'\)") + $m.Success | Should -BeTrue + $json = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($m.Groups[1].Value)) | ConvertFrom-Json + $json.flavour | Should -Be 'developer' + $json.bitlocker.pin | Should -Be '246810' + } + It 'launches the toolbox in FirstLogonCommands' { $script:xml | Should -Match 'SilverOS\.Welcome\.App\.exe' } +}