diff --git a/.github/skills/author-winpe-profile-json/SKILL.md b/.github/skills/author-winpe-profile-json/SKILL.md index 01c2136..7c76fff 100644 --- a/.github/skills/author-winpe-profile-json/SKILL.md +++ b/.github/skills/author-winpe-profile-json/SKILL.md @@ -9,8 +9,8 @@ Create profiles that configure `Invoke-WinpeStartup` without embedding workflow ## Profile location and discovery -- Store bundled profiles in `OSDCloud/core/winpestartup-profiles/`. -- Use a descriptive `.json` filename. The startup function discovers profile files from `WinpeStartup\Profiles` on attached drives. +- Store bundled profiles in a named folder under `OSDCloud/core/winpestartup-profiles/`, with the settings in `winpestartup.json`. +- Use a descriptive folder name. The startup function discovers `winpestartup.json` profile folders and legacy root-level JSON files from `WinpeStartup\Profiles` on attached drives. - A profile is a flat map of parameter names to scalar values or arrays, with one supported nested object: the top-level `Environment` section. - Prefer standard JSON. The loader tolerates comments, but comments are unnecessary and can make external validation fail. @@ -108,7 +108,7 @@ Use an unprefixed top-level `Environment` object to set process-scoped environme 5. Parse the edited file with PowerShell 5.1: ```powershell -$profile = Get-Content -LiteralPath '.\OSDCloud\core\winpestartup-profiles\.json' -Raw | ConvertFrom-Json +$profile = Get-Content -LiteralPath '.\OSDCloud\core\winpestartup-profiles\\winpestartup.json' -Raw | ConvertFrom-Json $profile | Out-Null ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index c06e94f..79fa868 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ All notable changes to this project will be documented in this file. +## 26.10.6.1 - October 6, 2026 + +### Added + +- Added WinpeStartup logging to `X:\Windows\Temp\winpestartup.log`; transcript start and stop failures warn without preventing startup. +- Import registry files from `X:\WinpeStartup\core\winpe-reg\*.reg` and certificates from `X:\WinpeStartup\core\winpe-root-cer\*.cer` during environment setup. Files are processed in sorted path order, and discovery, import, or launch failures warn and continue. +- Import process environment defaults from `X:\WinpeStartup\core\*\*.env`, including hidden `.env` files, in sorted path order before shell and selected-profile overrides. Entries use literal single-line assignments; invalid entries and file failures warn and continue without logging values. +- Added tests for profile discovery, environment defaults, startup logging, and registry and certificate imports. + +### Changed + +- WinpeStartup profile discovery now supports `\winpestartup.json` folders while retaining support for legacy root-level JSON profiles. +- Module version bumped from `26.10.4.1` to `26.10.6.1`. + +### Removed + +- Removed the obsolete `OSDCloud German CLI` startup profile. + ## 26.10.4.1 - October 4, 2026 ### Changed diff --git a/OSDCloud/OSDCloud.psd1 b/OSDCloud/OSDCloud.psd1 index 8323b66..70ac11c 100644 --- a/OSDCloud/OSDCloud.psd1 +++ b/OSDCloud/OSDCloud.psd1 @@ -7,7 +7,7 @@ @{ # --- Identity --- RootModule = 'OSDCloud.psm1' - ModuleVersion = '26.10.4.1' + ModuleVersion = '26.10.9.1' CompatiblePSEditions = @('Core', 'Desktop') GUID = '2fbd5c65-79c7-4561-9a2e-c4a4eebc89c7' diff --git a/OSDCloud/core/winpestartup-profiles/OSDCloud.json b/OSDCloud/core/winpestartup-profiles/OSDCloud/winpestartup.json similarity index 100% rename from OSDCloud/core/winpestartup-profiles/OSDCloud.json rename to OSDCloud/core/winpestartup-profiles/OSDCloud/winpestartup.json diff --git a/OSDCloud/private/Get-WinpeStartupProfileCandidates.Tests.ps1 b/OSDCloud/private/Get-WinpeStartupProfileCandidates.Tests.ps1 new file mode 100644 index 0000000..6935357 --- /dev/null +++ b/OSDCloud/private/Get-WinpeStartupProfileCandidates.Tests.ps1 @@ -0,0 +1,51 @@ +BeforeAll { + . "$PSScriptRoot\Get-WinpeStartupProfileCandidates.ps1" +} + +Describe 'Get-WinpeStartupProfileCandidates' { + It 'discovers folder profiles and legacy root-level JSON files' { + Mock Test-Path { + if ($LiteralPath -eq 'C:\WinpeStartup\profiles') { + return $true + } + if ($LiteralPath -eq (Join-Path (Join-Path 'C:\WinpeStartup\profiles' 'BranchOffice') 'winpestartup.json')) { + return $true + } + return $false + } + + Mock Get-ChildItem { + if ($File) { + return @( + [pscustomobject]@{ + BaseName = 'Legacy' + FullName = (Join-Path 'C:\WinpeStartup\profiles' 'Legacy.json') + } + ) + } + if ($Directory) { + return @( + [pscustomobject]@{ + Name = 'BranchOffice' + FullName = (Join-Path 'C:\WinpeStartup\profiles' 'BranchOffice') + } + [pscustomobject]@{ + Name = 'Empty' + FullName = (Join-Path 'C:\WinpeStartup\profiles' 'Empty') + } + ) + } + } -ParameterFilter { $LiteralPath -eq 'C:\WinpeStartup\profiles' } + + $profiles = @(Get-WinpeStartupProfileCandidates) + + $profiles.Count | Should -Be 2 + $profiles.Profile | Should -Contain 'BranchOffice' + $profiles.Profile | Should -Contain 'Legacy' + ($profiles | Where-Object Profile -eq 'BranchOffice').Path | + Should -Be (Join-Path (Join-Path 'C:\WinpeStartup\profiles' 'BranchOffice') 'winpestartup.json') + ($profiles | Where-Object Profile -eq 'Legacy').Path | + Should -Be (Join-Path 'C:\WinpeStartup\profiles' 'Legacy.json') + Should -Invoke Get-ChildItem -Times 2 -Exactly -ParameterFilter { $LiteralPath -eq 'C:\WinpeStartup\profiles' } + } +} diff --git a/OSDCloud/private/Get-WinpeStartupProfileCandidates.ps1 b/OSDCloud/private/Get-WinpeStartupProfileCandidates.ps1 new file mode 100644 index 0000000..a03f0da --- /dev/null +++ b/OSDCloud/private/Get-WinpeStartupProfileCandidates.ps1 @@ -0,0 +1,43 @@ +function Get-WinpeStartupProfileCandidates { + [CmdletBinding()] + param () + + $candidateProfiles = [System.Collections.Generic.List[object]]::new() + + foreach ($driveLetter in [char[]](67..90)) { + $profileRoot = '{0}:\WinpeStartup\profiles' -f $driveLetter + + if (-not (Test-Path -LiteralPath $profileRoot -PathType Container)) { + continue + } + + try { + $profileFiles = Get-ChildItem -LiteralPath $profileRoot -Filter '*.json' -File -ErrorAction Stop + $profileFolders = Get-ChildItem -LiteralPath $profileRoot -Directory -ErrorAction Stop + + foreach ($profileFolder in $profileFolders) { + $profileFilePath = Join-Path $profileFolder.FullName 'winpestartup.json' + if (Test-Path -LiteralPath $profileFilePath -PathType Leaf) { + [void]$candidateProfiles.Add([pscustomobject]@{ + Index = 0 + Profile = $profileFolder.Name + Path = $profileFilePath + }) + } + } + + foreach ($profileFile in ($profileFiles | Sort-Object FullName)) { + [void]$candidateProfiles.Add([pscustomobject]@{ + Index = 0 + Profile = $profileFile.BaseName + Path = $profileFile.FullName + }) + } + } + catch { + Write-Verbose "Invoke-WinpeStartup: Unable to enumerate '$profileRoot': $($_.Exception.Message)" + } + } + + return $candidateProfiles.ToArray() +} diff --git a/OSDCloud/private/WinpeStartup/Initialize-WinpeStartupEnvironment.Tests.ps1 b/OSDCloud/private/WinpeStartup/Initialize-WinpeStartupEnvironment.Tests.ps1 new file mode 100644 index 0000000..2348582 --- /dev/null +++ b/OSDCloud/private/WinpeStartup/Initialize-WinpeStartupEnvironment.Tests.ps1 @@ -0,0 +1,525 @@ +BeforeAll { + . "$PSScriptRoot\Initialize-WinpeStartupEnvironment.ps1" + . "$PSScriptRoot\..\..\public\WinPE\Invoke-WinpeStartup.ps1" + . "$PSScriptRoot\..\Get-WinpeStartupProfileCandidates.ps1" + . "$PSScriptRoot\Initialize-WinpeStartupDrivers.ps1" + . "$PSScriptRoot\Initialize-WinpeStartupFiles.ps1" + . "$PSScriptRoot\Initialize-WinpeStartupMain.ps1" + + function New-CoreEnvironmentFixture { + param ( + [string]$RelativePath, + [string[]]$Lines + ) + + $path = Join-Path -Path $fixtureRoot -ChildPath "WinpeStartup\core\$RelativePath" + [System.IO.Directory]::CreateDirectory((Split-Path -Path $path -Parent)) | Out-Null + Set-Content -LiteralPath $path -Value $Lines -Encoding UTF8 + return $path + } +} + +Describe 'Initialize-WinpeStartupEnvironment Core environment files' { + BeforeEach { + $savedEnvironment = [System.Environment]::GetEnvironmentVariables('Process') + $env:SystemDrive = 'X:' + $fixtureRoot = Join-Path -Path $TestDrive -ChildPath ([guid]::NewGuid().ToString()) + [System.IO.Directory]::CreateDirectory($fixtureRoot) | Out-Null + $fixtureCorePath = Join-Path -Path $fixtureRoot -ChildPath 'WinpeStartup\core' + + Mock Join-Path { + if ($Path -eq 'X:' -and $ChildPath -eq 'WinpeStartup\core') { + return $fixtureCorePath + } + return "$($Path.TrimEnd('\'))\$ChildPath" + } -ParameterFilter { $Path -like 'X:*' } + Mock New-Item {} + Mock Set-ItemProperty {} + Mock Write-Host {} + Mock Start-Process { [pscustomobject]@{ ExitCode = 0 } } + } + + AfterEach { + $currentEnvironment = [System.Environment]::GetEnvironmentVariables('Process') + foreach ($name in @($currentEnvironment.Keys)) { + if (-not $savedEnvironment.Contains($name)) { + [System.Environment]::SetEnvironmentVariable($name, $null, 'Process') + } + } + foreach ($name in $savedEnvironment.Keys) { + [System.Environment]::SetEnvironmentVariable($name, $savedEnvironment[$name], 'Process') + } + } + + It 'loads hidden .env and application.env from a hidden immediate Core folder' { + $path = New-CoreEnvironmentFixture -RelativePath 'main\.env' -Lines @( + '# DATABASE SETTINGS' + 'WSCORE_TEST_DB_HOST=localhost' + 'WSCORE_TEST_DB_PORT=5432' + 'WSCORE_TEST_FEATURE=true' + 'WSCORE_TEST_QUOTED="synthetic value"' + "WSCORE_TEST_SINGLE='synthetic string'" + ) + $applicationPath = New-CoreEnvironmentFixture -RelativePath 'main\application.env' -Lines @( + '# APPLICATION CONFIGURATION' + 'WSCORE_TEST_NODE_ENV=development' + 'WSCORE_TEST_PORT=3000' + 'WSCORE_TEST_DEBUG=true' + ) + [System.IO.File]::SetAttributes($path, [System.IO.FileAttributes]::Hidden) + [System.IO.File]::SetAttributes((Split-Path $applicationPath -Parent), [System.IO.FileAttributes]::Hidden) + + $output = @(Initialize-WinpeStartupEnvironment) + + $output.Count | Should -Be 0 + $env:WSCORE_TEST_DB_HOST | Should -Be 'localhost' + $env:WSCORE_TEST_DB_PORT | Should -Be '5432' + $env:WSCORE_TEST_FEATURE | Should -Be 'true' + $env:WSCORE_TEST_QUOTED | Should -Be 'synthetic value' + $env:WSCORE_TEST_SINGLE | Should -Be 'synthetic string' + $env:WSCORE_TEST_NODE_ENV | Should -Be 'development' + $env:WSCORE_TEST_PORT | Should -Be '3000' + $env:WSCORE_TEST_DEBUG | Should -Be 'true' + Should -Invoke Write-Host -Times 1 -Exactly -ParameterFilter { + $Object -match '^\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\] \[PASS\] Initialize env$' + } + } + + It 'sorts full paths across folders and lets the last file and line win' { + New-CoreEnvironmentFixture 'z-last\.env' @('WSCORE_TEST_ORDER=last folder') | Out-Null + New-CoreEnvironmentFixture 'a-first\z.env' @('WSCORE_TEST_ORDER=later file') | Out-Null + New-CoreEnvironmentFixture 'a-first\.env' @('WSCORE_TEST_ORDER=first file') | Out-Null + New-CoreEnvironmentFixture 'z-last\application.env' @( + 'WSCORE_TEST_ORDER=first line' + 'WSCORE_TEST_ORDER=last line' + ) | Out-Null + Mock Get-ChildItem { + @( + [pscustomobject]@{ FullName = (Join-Path $fixtureCorePath 'z-last') } + [pscustomobject]@{ FullName = (Join-Path $fixtureCorePath 'a-first') } + ) + } -ParameterFilter { $LiteralPath -eq $fixtureCorePath -and $Directory } + + Initialize-WinpeStartupEnvironment + + $env:WSCORE_TEST_ORDER | Should -Be 'last line' + } + + It 'ignores root-level, nested, non-env and profile-folder files' { + New-CoreEnvironmentFixture '.env' @('WSCORE_TEST_ROOT=unexpected') | Out-Null + New-CoreEnvironmentFixture 'main\nested\deeper.env' @('WSCORE_TEST_NESTED=unexpected') | Out-Null + New-CoreEnvironmentFixture 'main\settings.txt' @('WSCORE_TEST_TEXT=unexpected') | Out-Null + New-CoreEnvironmentFixture 'main\.env' @('WSCORE_TEST_INCLUDED=yes') | Out-Null + $profilesPath = Join-Path $fixtureRoot 'WinpeStartup\profiles' + [System.IO.Directory]::CreateDirectory($profilesPath) | Out-Null + Set-Content -LiteralPath (Join-Path $profilesPath '.env') -Value 'WSCORE_TEST_PROFILE_FILE=unexpected' + + Initialize-WinpeStartupEnvironment + + $env:WSCORE_TEST_INCLUDED | Should -Be 'yes' + $env:WSCORE_TEST_ROOT | Should -BeNullOrEmpty + $env:WSCORE_TEST_NESTED | Should -BeNullOrEmpty + $env:WSCORE_TEST_TEXT | Should -BeNullOrEmpty + $env:WSCORE_TEST_PROFILE_FILE | Should -BeNullOrEmpty + } + + It 'parses whitespace, quotes and embedded equals without evaluating content' { + New-CoreEnvironmentFixture 'main\literal.env' @( + '' + ' # ignored comment' + ' WSCORE_TEST_SPACES = value ' + 'WSCORE_TEST_EQUALS=a=b=c' + 'WSCORE_TEST_INNER=" inner space "' + 'WSCORE_TEST_HASH=value # literal' + 'WSCORE_TEST_LITERAL=$env:SystemDrive' + 'WSCORE_TEST_COMMAND=$(throw "must not execute")' + 'WSCORE_TEST_ESCAPE="line\ntext"' + ) | Out-Null + + Initialize-WinpeStartupEnvironment + + $env:WSCORE_TEST_SPACES | Should -Be 'value' + $env:WSCORE_TEST_EQUALS | Should -Be 'a=b=c' + $env:WSCORE_TEST_INNER | Should -Be ' inner space ' + $env:WSCORE_TEST_HASH | Should -Be 'value # literal' + $env:WSCORE_TEST_LITERAL | Should -Be '$env:SystemDrive' + $env:WSCORE_TEST_COMMAND | Should -Be '$(throw "must not execute")' + $env:WSCORE_TEST_ESCAPE | Should -Be 'line\ntext' + } + + It 'applies empty assignments using current runtime semantics' { + $env:WSCORE_TEST_EMPTY = 'previous' + $env:WSCORE_TEST_EMPTY_QUOTED = 'previous' + New-CoreEnvironmentFixture 'main\.env' @( + 'WSCORE_TEST_EMPTY=' + 'WSCORE_TEST_EMPTY_QUOTED=""' + ) | Out-Null + + Initialize-WinpeStartupEnvironment + + $env:WSCORE_TEST_EMPTY | Should -BeNullOrEmpty + $env:WSCORE_TEST_EMPTY_QUOTED | Should -BeNullOrEmpty + } + + It 'changes only process scope and passes imported values to child PowerShell' { + $machineValue = [System.Environment]::GetEnvironmentVariable('WSCORE_TEST_CHILD', 'Machine') + $userValue = [System.Environment]::GetEnvironmentVariable('WSCORE_TEST_CHILD', 'User') + New-CoreEnvironmentFixture 'main\.env' @('WSCORE_TEST_CHILD=child-inherited-value') | Out-Null + + Initialize-WinpeStartupEnvironment + $executable = (Get-Process -Id $PID).Path + $childValue = & $executable -NoLogo -NoProfile -Command '$env:WSCORE_TEST_CHILD' + + $LASTEXITCODE | Should -Be 0 + $childValue | Should -Be 'child-inherited-value' + [System.Environment]::GetEnvironmentVariable('WSCORE_TEST_CHILD', 'Machine') | Should -Be $machineValue + [System.Environment]::GetEnvironmentVariable('WSCORE_TEST_CHILD', 'User') | Should -Be $userValue + } + + It 'warns with source and line numbers for invalid entries and continues without leaking content' { + $path = New-CoreEnvironmentFixture 'main\.env' @( + 'sensitive-synthetic-no-equals' + '=sensitive-synthetic-value' + 'export WSCORE_TEST_EXPORT=sensitive-synthetic-value' + 'WSCORE_TEST_BAD="sensitive-synthetic-value' + "WSCORE_TEST_NUL=sensitive$([char]0)synthetic" + 'WSCORE_TEST_AFTER_INVALID=valid' + ) + $warnings = @() + + Initialize-WinpeStartupEnvironment -WarningVariable warnings -WarningAction SilentlyContinue + + $warnings.Count | Should -Be 5 + for ($index = 0; $index -lt $warnings.Count; $index++) { + $warnings[$index].ToString() | Should -Match ([regex]::Escape($path)) + $warnings[$index].ToString() | Should -Match "line $($index + 1)" + } + ($warnings | Out-String) | Should -Not -Match 'sensitive|synthetic' + $env:WSCORE_TEST_EXPORT | Should -BeNullOrEmpty + $env:WSCORE_TEST_BAD | Should -BeNullOrEmpty + $env:WSCORE_TEST_NUL | Should -BeNullOrEmpty + $env:WSCORE_TEST_AFTER_INVALID | Should -Be 'valid' + Should -Invoke Write-Host -Times 1 -Exactly -ParameterFilter { + $Object -match '^\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\] \[FAIL\] Initialize env$' + } + Should -Invoke Write-Host -Times 0 -Exactly -ParameterFilter { + $Object -match '\[PASS\] Initialize env$' + } + } + + It 'does not log imported values on the verbose stream' { + New-CoreEnvironmentFixture 'main\.env' @('WSCORE_TEST_VERBOSE=sensitive-synthetic-value') | Out-Null + + $messages = Initialize-WinpeStartupEnvironment -Verbose 4>&1 + + ($messages | Out-String) | Should -Match 'Loaded environment entry' + ($messages | Out-String) | Should -Not -Match 'sensitive-synthetic-value' + } + + It 'warns on read failure and continues with other files' { + $failedPath = New-CoreEnvironmentFixture 'main\.env' @('WSCORE_TEST_UNREADABLE=unexpected') + New-CoreEnvironmentFixture 'main\application.env' @('WSCORE_TEST_AFTER_READ_FAILURE=yes') | Out-Null + Mock Get-Content { throw 'sensitive-synthetic-exception' } -ParameterFilter { $LiteralPath -eq $failedPath } + $warnings = @() + + Initialize-WinpeStartupEnvironment -WarningVariable warnings -WarningAction SilentlyContinue + + $warnings.Count | Should -Be 1 + $warnings[0].ToString() | Should -Match 'Failed to read' + ($warnings | Out-String) | Should -Not -Match 'sensitive-synthetic-exception' + $env:WSCORE_TEST_AFTER_READ_FAILURE | Should -Be 'yes' + } + + It 'warns on folder discovery failure and continues with other folders' { + New-CoreEnvironmentFixture 'broken\.env' @('WSCORE_TEST_UNDISCOVERED=unexpected') | Out-Null + New-CoreEnvironmentFixture 'main\.env' @('WSCORE_TEST_AFTER_DISCOVERY_FAILURE=yes') | Out-Null + $brokenPath = Join-Path $fixtureCorePath 'broken' + Mock Get-ChildItem { throw 'sensitive-synthetic-exception' } -ParameterFilter { $LiteralPath -eq $brokenPath } + $warnings = @() + + Initialize-WinpeStartupEnvironment -WarningVariable warnings -WarningAction SilentlyContinue + + $warnings.Count | Should -Be 1 + $warnings[0].ToString() | Should -Match 'Failed to discover environment files' + ($warnings | Out-String) | Should -Not -Match 'sensitive-synthetic-exception' + $env:WSCORE_TEST_AFTER_DISCOVERY_FAILURE | Should -Be 'yes' + } + + It 'warns on Core discovery failure but retains shell initialization' { + Mock Test-Path { throw 'sensitive-synthetic-exception' } -ParameterFilter { $LiteralPath -eq $fixtureCorePath } + $warnings = @() + + Initialize-WinpeStartupEnvironment -WarningVariable warnings -WarningAction SilentlyContinue + + $warnings.Count | Should -Be 1 + $warnings[0].ToString() | Should -Match 'Failed to discover Core folders' + ($warnings | Out-String) | Should -Not -Match 'sensitive-synthetic-exception' + $env:USERPROFILE | Should -Be 'X:\windows\system32\config\systemprofile' + Should -Invoke Set-ItemProperty -Times 2 -Exactly + } + + It 'warns on PS 5.1 oversized-name assignment failure and continues' -Skip:($PSVersionTable.PSEdition -ne 'Desktop') { + $oversizedName = 'N' * 32768 + New-CoreEnvironmentFixture 'main\.env' @( + "$oversizedName=sensitive-synthetic-value" + 'WSCORE_TEST_AFTER_SET_FAILURE=yes' + ) | Out-Null + $warnings = @() + + Initialize-WinpeStartupEnvironment -WarningVariable warnings -WarningAction SilentlyContinue + + $warnings.Count | Should -Be 1 + $warnings[0].ToString() | Should -Match 'Failed to set environment entry.*line 1' + ($warnings | Out-String) | Should -Not -Match 'sensitive-synthetic-value' + $env:WSCORE_TEST_AFTER_SET_FAILURE | Should -Be 'yes' + } + + It 'allows missing Core content without warnings and preserves existing setup' { + $warnings = @() + + Initialize-WinpeStartupEnvironment -WarningVariable warnings + + $warnings.Count | Should -Be 0 + $env:APPDATA | Should -Be 'X:\windows\system32\config\systemprofile\AppData\Roaming' + $env:LOCALAPPDATA | Should -Be 'X:\windows\system32\config\systemprofile\AppData\Local' + $env:HOMEDRIVE | Should -Be 'X:' + $env:HOMEPATH | Should -Be '\windows\system32\config\systemprofile' + Should -Invoke New-Item -Times 6 -Exactly + Should -Invoke Set-ItemProperty -Times 1 -Exactly -ParameterFilter { + $Name -eq 'USERDATA' -and $Value -eq 'X:\Windows\System32\Config\SystemProfile' + } + Should -Invoke Set-ItemProperty -Times 1 -Exactly -ParameterFilter { + $Name -eq 'ExecutionPolicy' -and $Value -eq 'Bypass' + } + } + + It 'allows empty Core folders without warnings' { + [System.IO.Directory]::CreateDirectory((Join-Path $fixtureCorePath 'empty')) | Out-Null + $warnings = @() + + Initialize-WinpeStartupEnvironment -WarningVariable warnings + + $warnings.Count | Should -Be 0 + } + + It 'preserves shell variable precedence over Core assignments' { + New-CoreEnvironmentFixture 'main\.env' @( + 'APPDATA=core-default' + 'USERPROFILE=core-default' + 'HOMEDRIVE=core-default' + ) | Out-Null + + Initialize-WinpeStartupEnvironment + + $env:APPDATA | Should -Be 'X:\windows\system32\config\systemprofile\AppData\Roaming' + $env:USERPROFILE | Should -Be 'X:\windows\system32\config\systemprofile' + $env:HOMEDRIVE | Should -Be 'X:' + } + + It 'does not run initialization outside WinPE' { + $env:SystemDrive = 'C:' + $warnings = @() + + Initialize-WinpeStartupEnvironment -WarningVariable warnings -WarningAction SilentlyContinue + + $warnings.Count | Should -Be 1 + $warnings[0].ToString() | Should -Match 'Not running in WinPE' + Should -Invoke New-Item -Times 0 -Exactly + Should -Invoke Set-ItemProperty -Times 0 -Exactly + Should -Invoke Start-Process -Times 0 -Exactly + } + + It 'allows the selected startup profile to override Core process values' { + New-CoreEnvironmentFixture 'main\.env' @( + 'WSCORE_TEST_PRECEDENCE=core-default' + 'WSCORE_TEST_CORE_ONLY=retained' + ) | Out-Null + $profilePath = Join-Path $fixtureRoot 'profile.json' + Set-Content -LiteralPath $profilePath -Value '{"env":{"WSCORE_TEST_PRECEDENCE":"profile-value"}}' + $script:OSDCloudPSDefaultParameterValuesPath = Join-Path $fixtureRoot 'absent-defaults.json' + Mock Get-WinpeStartupProfileCandidates { + [pscustomobject]@{ Profile = 'Test'; Path = $profilePath; Index = 0 } + } + Mock Initialize-WinpeStartupDrivers {} + Mock Initialize-WinpeStartupFiles {} + Mock Initialize-WinpeStartupMain {} + Mock Start-Sleep {} + + Invoke-WinpeStartup -SkipOnScreenKeyboard -SkipWiFi -SkipIPConfig -SkipUpdateOSDCloud + + $env:WSCORE_TEST_PRECEDENCE | Should -Be 'profile-value' + $env:WSCORE_TEST_CORE_ONLY | Should -Be 'retained' + } + + It 'imports hidden registry files and root certificates with quoted paths from multiple Core folders' { + $registryPath = New-CoreEnvironmentFixture 'main folder\winpe-reg\settings file.reg' @('synthetic') + $certificatePath = New-CoreEnvironmentFixture 'main folder\winpe-root-cer\root file.cer' @('synthetic') + New-CoreEnvironmentFixture 'additional\winpe-reg\extra.reg' @('synthetic') | Out-Null + New-CoreEnvironmentFixture 'additional\winpe-root-cer\extra.cer' @('synthetic') | Out-Null + [System.IO.File]::SetAttributes($registryPath, [System.IO.FileAttributes]::Hidden) + [System.IO.File]::SetAttributes($certificatePath, [System.IO.FileAttributes]::Hidden) + [System.IO.File]::SetAttributes((Split-Path $registryPath -Parent), [System.IO.FileAttributes]::Hidden) + [System.IO.File]::SetAttributes((Split-Path $certificatePath -Parent), [System.IO.FileAttributes]::Hidden) + + $output = @(Initialize-WinpeStartupEnvironment) + + $output.Count | Should -Be 0 + Should -Invoke Start-Process -Times 4 -Exactly + Should -Invoke Start-Process -Times 1 -Exactly -ParameterFilter { + $FilePath -eq 'reg.exe' -and $ArgumentList.Count -eq 2 -and + $ArgumentList[0] -eq 'import' -and $ArgumentList[1] -eq "`"$registryPath`"" -and + $Wait -and $PassThru -and $NoNewWindow -and $ErrorAction -eq 'Stop' -and + -not [string]::IsNullOrWhiteSpace($RedirectStandardOutput) -and + -not [string]::IsNullOrWhiteSpace($RedirectStandardError) + } + Should -Invoke Start-Process -Times 1 -Exactly -ParameterFilter { + $FilePath -eq 'certutil.exe' -and $ArgumentList.Count -eq 3 -and + $ArgumentList[0] -eq '-addstore' -and $ArgumentList[1] -eq 'root' -and + $ArgumentList[2] -eq "`"$certificatePath`"" -and + $Wait -and $PassThru -and $NoNewWindow -and $ErrorAction -eq 'Stop' -and + -not [string]::IsNullOrWhiteSpace($RedirectStandardOutput) -and + -not [string]::IsNullOrWhiteSpace($RedirectStandardError) + } + Should -Invoke Write-Host -Times 1 -Exactly -ParameterFilter { + $Object -match '^\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\] \[PASS\] Initialize certificates$' + } + Should -Invoke Write-Host -Times 1 -Exactly -ParameterFilter { + $Object -match '^\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\] \[PASS\] Initialize registry$' + } + } + + It 'imports environment files before registry files and certificates and sorts native imports within each type' { + $paths = @( + New-CoreEnvironmentFixture 'z-last\winpe-root-cer\z.cer' @('synthetic') + New-CoreEnvironmentFixture 'z-last\winpe-reg\z.reg' @('synthetic') + New-CoreEnvironmentFixture 'a-first\winpe-root-cer\a.cer' @('synthetic') + New-CoreEnvironmentFixture 'a-first\winpe-reg\a.reg' @('synthetic') + ) + $environmentPath = New-CoreEnvironmentFixture 'a-first\.env' @('WSCORE_TEST_IMPORT_ORDER=loaded') + $calls = [System.Collections.Generic.List[string]]::new() + Mock Get-Content { + $calls.Add("env $LiteralPath") + 'WSCORE_TEST_IMPORT_ORDER=loaded' + } -ParameterFilter { $LiteralPath -eq $environmentPath } + Mock Start-Process { + $calls.Add("$FilePath $($ArgumentList -join ' ')") + [pscustomobject]@{ ExitCode = 0 } + } + + Initialize-WinpeStartupEnvironment + + $calls.Count | Should -Be 5 + $calls[0] | Should -Be "env $environmentPath" + $calls[1] | Should -Be "reg.exe import `"$($paths[3])`"" + $calls[2] | Should -Be "reg.exe import `"$($paths[1])`"" + $calls[3] | Should -Be "certutil.exe -addstore root `"$($paths[2])`"" + $calls[4] | Should -Be "certutil.exe -addstore root `"$($paths[0])`"" + } + + It 'does not import files at incorrect levels or with incorrect extensions' { + foreach ($relativePath in @( + 'winpe-reg\root.reg' + 'winpe-root-cer\root.cer' + 'main\file.reg' + 'main\file.cer' + 'main\nested\winpe-reg\deep.reg' + 'main\nested\winpe-root-cer\deep.cer' + 'main\winpe-reg\nested\deep.reg' + 'main\winpe-root-cer\nested\deep.cer' + 'main\winpe-reg\ignore.txt' + 'main\winpe-root-cer\ignore.txt' + )) { + New-CoreEnvironmentFixture $relativePath @('synthetic') | Out-Null + } + + Initialize-WinpeStartupEnvironment + + Should -Invoke Start-Process -Times 0 -Exactly + } + + It 'warns on nonzero tool exit codes and continues with remaining imports under ErrorAction Stop' { + $failedRegistry = New-CoreEnvironmentFixture 'main\winpe-reg\a-failed.reg' @('synthetic') + $failedCertificate = New-CoreEnvironmentFixture 'main\winpe-root-cer\a-failed.cer' @('synthetic') + New-CoreEnvironmentFixture 'main\winpe-reg\z-valid.reg' @('synthetic') | Out-Null + New-CoreEnvironmentFixture 'main\winpe-root-cer\z-valid.cer' @('synthetic') | Out-Null + Mock Start-Process { + if ($ArgumentList[-1] -like '*a-failed*') { + return [pscustomobject]@{ ExitCode = 1 } + } + return [pscustomobject]@{ ExitCode = 0 } + } + $warnings = @() + + Initialize-WinpeStartupEnvironment -ErrorAction Stop -WarningVariable warnings -WarningAction SilentlyContinue + + $warnings.Count | Should -Be 2 + $warnings[0].ToString() | Should -Match ([regex]::Escape($failedRegistry)) + $warnings[1].ToString() | Should -Match ([regex]::Escape($failedCertificate)) + ($warnings | Out-String) | Should -Match 'exit code 1' + Should -Invoke Start-Process -Times 4 -Exactly + Should -Invoke Write-Host -Times 0 -Exactly -ParameterFilter { + $Object -match '\[PASS\] Initialize certificates$' + } + Should -Invoke Write-Host -Times 1 -Exactly -ParameterFilter { + $Object -match '^\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\] \[FAIL\] Initialize certificates$' + } + Should -Invoke Write-Host -Times 0 -Exactly -ParameterFilter { + $Object -match '\[PASS\] Initialize registry$' + } + Should -Invoke Write-Host -Times 1 -Exactly -ParameterFilter { + $Object -match '^\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\] \[FAIL\] Initialize registry$' + } + } + + It 'warns on process launch errors and continues with subsequent registry and certificate files' { + New-CoreEnvironmentFixture 'main\winpe-reg\a-failed.reg' @('synthetic') | Out-Null + New-CoreEnvironmentFixture 'main\winpe-root-cer\a-failed.cer' @('synthetic') | Out-Null + New-CoreEnvironmentFixture 'main\winpe-reg\z-valid.reg' @('synthetic') | Out-Null + New-CoreEnvironmentFixture 'main\winpe-root-cer\z-valid.cer' @('synthetic') | Out-Null + Mock Start-Process { + if ($ArgumentList[-1] -like '*a-failed*') { + throw 'Tool could not start' + } + [pscustomobject]@{ ExitCode = 0 } + } + $warnings = @() + + Initialize-WinpeStartupEnvironment -ErrorAction Stop -WarningVariable warnings -WarningAction SilentlyContinue + + $warnings.Count | Should -Be 2 + ($warnings | Out-String) | Should -Match 'reg.exe' + ($warnings | Out-String) | Should -Match 'certutil.exe' + Should -Invoke Start-Process -Times 4 -Exactly + } + + It 'warns on import folder discovery errors and continues with other Core folders' { + New-CoreEnvironmentFixture 'broken\winpe-reg\file.reg' @('synthetic') | Out-Null + New-CoreEnvironmentFixture 'broken\winpe-root-cer\file.cer' @('synthetic') | Out-Null + New-CoreEnvironmentFixture 'main\winpe-reg\file.reg' @('synthetic') | Out-Null + New-CoreEnvironmentFixture 'main\winpe-root-cer\file.cer' @('synthetic') | Out-Null + $brokenRegistry = Join-Path $fixtureCorePath 'broken\winpe-reg' + $brokenCertificates = Join-Path $fixtureCorePath 'broken\winpe-root-cer' + Mock Get-ChildItem { throw 'Cannot enumerate folder' } -ParameterFilter { + $LiteralPath -eq $brokenRegistry -or $LiteralPath -eq $brokenCertificates + } + $warnings = @() + + Initialize-WinpeStartupEnvironment -ErrorAction Stop -WarningVariable warnings -WarningAction SilentlyContinue + + $warnings.Count | Should -Be 2 + ($warnings | Out-String) | Should -Match 'Failed to discover' + Should -Invoke Start-Process -Times 2 -Exactly + } + + It 'allows missing or empty import folders without warnings or native tool calls' { + [System.IO.Directory]::CreateDirectory((Join-Path $fixtureCorePath 'main\winpe-reg')) | Out-Null + [System.IO.Directory]::CreateDirectory((Join-Path $fixtureCorePath 'main\winpe-root-cer')) | Out-Null + [System.IO.Directory]::CreateDirectory((Join-Path $fixtureCorePath 'additional')) | Out-Null + $warnings = @() + + Initialize-WinpeStartupEnvironment -WarningVariable warnings + + $warnings.Count | Should -Be 0 + Should -Invoke Start-Process -Times 0 -Exactly + } +} diff --git a/OSDCloud/private/WinpeStartup/Initialize-WinpeStartupEnvironment.ps1 b/OSDCloud/private/WinpeStartup/Initialize-WinpeStartupEnvironment.ps1 index b542eeb..9307e7f 100644 --- a/OSDCloud/private/WinpeStartup/Initialize-WinpeStartupEnvironment.ps1 +++ b/OSDCloud/private/WinpeStartup/Initialize-WinpeStartupEnvironment.ps1 @@ -15,25 +15,72 @@ function Initialize-WinpeStartupEnvironment { in the WinPE boot sequence. Actions performed: + - Imports X:\WinpeStartup\core\*\*.env into the process environment - Creates shell profile directories under SystemDrive - Sets APPDATA, HOMEDRIVE, HOMEPATH, LOCALAPPDATA, USERPROFILE - Writes the USERDATA registry value to the Session Manager Environment key - Sets the PowerShell execution policy to Bypass via registry + - Imports X:\WinpeStartup\core\*\winpe-reg\*.reg using reg.exe import + - Adds X:\WinpeStartup\core\*\winpe-root-cer\*.cer to the machine Root store + + Core files, including hidden files named .env, are loaded from immediate + Core subfolders in sorted full-path order. Later assignments overwrite + earlier values. Shell profile variables and the selected startup profile + are applied afterward and take precedence over Core values. + + Entries use literal, single-line NAME=VALUE syntax. Blank lines and + full-line # comments are ignored. Surrounding whitespace and matching + outer quotes are removed; embedded equals signs are preserved. Expressions, + variable references, escape sequences, and inline comments are not expanded. + Multiline values and export syntax are not supported. Empty values use + the current runtime's process environment semantics (removal on PS 5.1). + Invalid entries and file failures warn and continue without logging values. + Missing Core content is allowed. Imported values are not persisted. + Environment import output is summarized by one pass or fail message when + files are discovered or discovery fails. + + After built-in registry setup, Core registry files are imported, followed + by root certificates using certutil.exe -addstore root. Each file type is + processed in sorted full-path order, including hidden files, without + searching deeper folders. Missing import folders are allowed. Discovery, + process launch, and nonzero exit-code failures warn and continue with + remaining files. Registry imports may overwrite existing keys; certificates + are trusted in the local machine Root store for the current WinPE instance. + Registry and certificate tool output is suppressed and summarized by one + pass or fail message for each file type. .EXAMPLE - Initialize-WinpeStartupEnvironment + PS> Initialize-WinpeStartupEnvironment - Creates all shell folders, sets environment variables, and writes - registry keys for the current WinPE session. + Imports Core environment files, creates shell folders, sets environment + variables, and writes registry keys for the current WinPE session. .EXAMPLE - Initialize-WinpeStartupEnvironment -Verbose + PS> Initialize-WinpeStartupEnvironment -Verbose Runs the full environment initialization with detailed progress output. + .INPUTS + None. This function does not accept pipeline input. + + .OUTPUTS + None. + .NOTES Author: David Segura + Company: Recast Software + Version: 1.0.0 + Date: 2026-10-06 Module: OSDCloud + Runs only when SystemDrive is X:. + Core imports require reg.exe and certutil.exe and permission to modify + the WinPE registry and local machine certificate store. + + .LINK + https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/reg-import + + .LINK + https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/certutil #> [CmdletBinding()] [OutputType([void])] @@ -56,6 +103,92 @@ function Initialize-WinpeStartupEnvironment { process { if ($skipExecution) { return } Write-Host -ForegroundColor DarkGray "[$(Get-Date -format s)] [INFO] Initialize WinpeStartup" + + $corePath = Join-Path -Path $systemDrive -ChildPath 'WinpeStartup\core' + $coreFolders = @() + $coreDiscoverySucceeded = $true + try { + if (Test-Path -LiteralPath $corePath -PathType Container -ErrorAction Stop) { + $coreFolders = @(Get-ChildItem -LiteralPath $corePath -Directory -Force -ErrorAction Stop) + } + } + catch { + $coreDiscoverySucceeded = $false + Write-Warning "Initialize-WinpeStartupEnvironment: Failed to discover Core folders in '$corePath'." + } + + $allEnvironmentImportsSucceeded = $coreDiscoverySucceeded + $environmentFiles = @( + foreach ($coreFolder in $coreFolders) { + try { + Get-ChildItem -LiteralPath $coreFolder.FullName -File -Filter '*.env' -Force -ErrorAction Stop | + Where-Object { $_.Name -like '*.env' } + } + catch { + $allEnvironmentImportsSucceeded = $false + Write-Warning "Initialize-WinpeStartupEnvironment: Failed to discover environment files in '$($coreFolder.FullName)'." + } + } + ) + + foreach ($environmentFile in ($environmentFiles | Sort-Object FullName)) { + try { + $environmentLines = @(Get-Content -LiteralPath $environmentFile.FullName -ErrorAction Stop) + } + catch { + $allEnvironmentImportsSucceeded = $false + Write-Warning "Initialize-WinpeStartupEnvironment: Failed to read environment file '$($environmentFile.FullName)'." + continue + } + + $lineNumber = 0 + foreach ($environmentLine in $environmentLines) { + $lineNumber++ + $line = $environmentLine.Trim() + if ([string]::IsNullOrWhiteSpace($line) -or $line.StartsWith('#')) { + continue + } + + $separatorIndex = $line.IndexOf('=') + if ($separatorIndex -le 0) { + $allEnvironmentImportsSucceeded = $false + Write-Warning "Initialize-WinpeStartupEnvironment: Invalid environment entry in '$($environmentFile.FullName)' at line $lineNumber. Expected NAME=VALUE." + continue + } + + $name = $line.Substring(0, $separatorIndex).Trim() + $value = $line.Substring($separatorIndex + 1).Trim() + if ($name -match '[\s=\x00]' -or [string]::IsNullOrWhiteSpace($name) -or $value.Contains([char]0)) { + $allEnvironmentImportsSucceeded = $false + Write-Warning "Initialize-WinpeStartupEnvironment: Invalid environment name or value in '$($environmentFile.FullName)' at line $lineNumber." + continue + } + + if ($value.StartsWith('"') -or $value.StartsWith("'")) { + if ($value.Length -lt 2 -or $value[$value.Length - 1] -ne $value[0]) { + $allEnvironmentImportsSucceeded = $false + Write-Warning "Initialize-WinpeStartupEnvironment: Unmatched environment value quotes in '$($environmentFile.FullName)' at line $lineNumber." + continue + } + $value = $value.Substring(1, $value.Length - 2) + } + + try { + [System.Environment]::SetEnvironmentVariable($name, $value, [System.EnvironmentVariableTarget]::Process) + Write-Verbose "Initialize-WinpeStartupEnvironment: Loaded environment entry from '$($environmentFile.FullName)' at line $lineNumber." + } + catch { + $allEnvironmentImportsSucceeded = $false + Write-Warning "Initialize-WinpeStartupEnvironment: Failed to set environment entry from '$($environmentFile.FullName)' at line $lineNumber." + } + } + } + + if ($environmentFiles.Count -gt 0 -or -not $allEnvironmentImportsSucceeded) { + $environmentStatus = if ($allEnvironmentImportsSucceeded) { 'PASS' } else { 'FAIL' } + Write-Host "[$(Get-Date -format s)] [$environmentStatus] Initialize env" + } + # ── Shell Folders ─────────────────────────────────────────────── $shellFolders = @( Join-Path -Path $systemDrive -ChildPath 'Program Files\WindowsPowerShell\Scripts' @@ -96,6 +229,85 @@ function Initialize-WinpeStartupEnvironment { Set-ItemProperty -Path $psRegKeyPath -Name 'ExecutionPolicy' -Value 'Bypass' -Type String -Force Write-Verbose 'Set registry ExecutionPolicy = Bypass' + + $coreImports = @( + @{ + Folder = 'winpe-reg' + Filter = '*.reg' + Executable = 'reg.exe' + Arguments = @('import') + Status = 'Initialize registry' + } + @{ + Folder = 'winpe-root-cer' + Filter = '*.cer' + Executable = 'certutil.exe' + Arguments = @('-addstore', 'root') + Status = 'Initialize certificates' + } + ) + + foreach ($coreImport in $coreImports) { + $allImportsSucceeded = $coreDiscoverySucceeded + $importFiles = @( + foreach ($coreFolder in $coreFolders) { + $importPath = Join-Path -Path $coreFolder.FullName -ChildPath $coreImport.Folder + try { + if (Test-Path -LiteralPath $importPath -PathType Container -ErrorAction Stop) { + Get-ChildItem -LiteralPath $importPath -File -Filter $coreImport.Filter -Force -ErrorAction Stop | + Where-Object { $_.Name -like $coreImport.Filter } + } + } + catch { + $allImportsSucceeded = $false + Write-Warning "Initialize-WinpeStartupEnvironment: Failed to discover '$($coreImport.Filter)' files in '$importPath'." + } + } + ) + + foreach ($importFile in ($importFiles | Sort-Object FullName)) { + $standardOutputPath = $null + $standardErrorPath = $null + try { + # Start-Process joins arguments, so retain quotes around paths with spaces. + $importArguments = @($coreImport.Arguments) + @("`"$($importFile.FullName)`"") + $startProcessParameters = @{ + FilePath = $coreImport.Executable + ArgumentList = $importArguments + NoNewWindow = $true + Wait = $true + PassThru = $true + ErrorAction = 'Stop' + } + $standardOutputPath = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath "$([System.IO.Path]::GetRandomFileName()).stdout" + $standardErrorPath = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath "$([System.IO.Path]::GetRandomFileName()).stderr" + $startProcessParameters.RedirectStandardOutput = $standardOutputPath + $startProcessParameters.RedirectStandardError = $standardErrorPath + + $importProcess = Start-Process @startProcessParameters + if ($importProcess.ExitCode -ne 0) { + $allImportsSucceeded = $false + Write-Warning "Initialize-WinpeStartupEnvironment: '$($coreImport.Executable)' failed for '$($importFile.FullName)' with exit code $($importProcess.ExitCode)." + continue + } + Write-Verbose "Initialize-WinpeStartupEnvironment: Imported '$($importFile.FullName)' using '$($coreImport.Executable)'." + } + catch { + $allImportsSucceeded = $false + Write-Warning "Initialize-WinpeStartupEnvironment: Failed to import '$($importFile.FullName)' using '$($coreImport.Executable)': $($_.Exception.Message)" + } + finally { + @($standardOutputPath, $standardErrorPath) | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + ForEach-Object { Remove-Item -LiteralPath $_ -Force -ErrorAction SilentlyContinue } + } + } + + if ($importFiles.Count -gt 0 -or -not $allImportsSucceeded) { + $importStatus = if ($allImportsSucceeded) { 'PASS' } else { 'FAIL' } + Write-Host "[$(Get-Date -format s)] [$importStatus] $($coreImport.Status)" + } + } } end { diff --git a/OSDCloud/private/WinpeStartup/Initialize-WinpeStartupMain.ps1 b/OSDCloud/private/WinpeStartup/Initialize-WinpeStartupMain.ps1 index 796b9a7..e54f445 100644 --- a/OSDCloud/private/WinpeStartup/Initialize-WinpeStartupMain.ps1 +++ b/OSDCloud/private/WinpeStartup/Initialize-WinpeStartupMain.ps1 @@ -42,8 +42,8 @@ function Initialize-WinpeStartupMain { process { if ($skipExecution) { return } - Write-Host -ForegroundColor DarkGray "[$(Get-Date -format s)] [INFO] Initialize wpeinit" - Write-Host -ForegroundColor DarkGray "[$(Get-Date -format s)] [INFO] Initialize wpeutil" + Write-Host -ForegroundColor DarkGray "[$(Get-Date -format s)] [INFO] Initialize winpe" + # Write-Host -ForegroundColor DarkGray "[$(Get-Date -format s)] [INFO] Initialize wpeutil" Write-Verbose 'Running wpeinit.exe' Invoke-WpeInit Start-Sleep -Seconds 2 # Wait for wpeinit to complete before running wpeutil commands @@ -63,7 +63,7 @@ function Initialize-WinpeStartupMain { Write-Verbose 'Running wpeutil UpdateBootInfo' Invoke-WpeUtil -Command 'UpdateBootInfo' - Start-Sleep -Seconds 2 # Wait for Set-WinpeStartupUSBDriveLetter to complete before proceeding + Start-Sleep -Seconds 2 # Wait for wpeutil UpdateBootInfo to complete before proceeding Write-Host -ForegroundColor DarkGray "[$(Get-Date -format s)] [INFO] Initialize network" ipconfig /release | Out-Null diff --git a/OSDCloud/public/WinPE/Invoke-WinpeStartup.ps1 b/OSDCloud/public/WinPE/Invoke-WinpeStartup.ps1 index 5dcf7ee..977d553 100644 --- a/OSDCloud/public/WinPE/Invoke-WinpeStartup.ps1 +++ b/OSDCloud/public/WinPE/Invoke-WinpeStartup.ps1 @@ -11,6 +11,21 @@ function Invoke-WinpeStartup { apply a startup profile, and then run startup steps in order including environment setup, drivers, files, hardware checks, connectivity, module updates, script execution, and optional URL/command invocations. + Startup output is transcribed to X:\Windows\Temp\winpestartup.log. + + Environment setup imports literal NAME=VALUE entries from + X:\WinpeStartup\core\*\*.env, including hidden .env files, in sorted + full-path order without searching deeper folders. Core entries are + process-scoped defaults; shell profile variables take precedence. + Blank lines and full-line # comments are ignored, matching outer quotes + are removed, and expressions are not evaluated. Invalid entries and + file failures warn and continue without logging values. + + Environment setup also imports X:\WinpeStartup\core\*\winpe-reg\*.reg + into the WinPE registry using reg.exe import, then adds certificates from + X:\WinpeStartup\core\*\winpe-root-cer\*.cer to the local machine Root + store using certutil.exe -addstore root. Files are sorted by full path + within each type; discovery and import failures warn and continue. A selected profile may include an env or Environment object. Supported scalar values are converted to strings and assigned to the current @@ -169,6 +184,7 @@ function Invoke-WinpeStartup { begin { $Error.Clear() $skipExecution = $false + $startupTranscriptStarted = $false if ($env:SystemDrive -ne 'X:') { Write-Warning 'Invoke-WinpeStartup: Not running in WinPE (SystemDrive is not X:). Exiting.' @@ -176,6 +192,20 @@ function Invoke-WinpeStartup { return } + $startupLogPath = 'X:\Windows\Temp\winpestartup.log' + try { + $startupLogDirectory = Split-Path -Path $startupLogPath -Parent + if (-not (Test-Path -LiteralPath $startupLogDirectory -PathType Container)) { + New-Item -Path $startupLogDirectory -ItemType Directory -Force -ErrorAction Stop | Out-Null + } + + $null = Start-Transcript -Path $startupLogPath -Force -ErrorAction Stop + $startupTranscriptStarted = $true + } + catch { + Write-Warning "Invoke-WinpeStartup: Failed to start log '$startupLogPath': $($_.Exception.Message)" + } + $switchLikeParameters = @( 'SkipOnScreenKeyboard', 'ShowPnpDevices', @@ -450,30 +480,7 @@ function Invoke-WinpeStartup { Initialize-WinpeStartupMain Start-Sleep -Seconds 3 - $candidateProfiles = [System.Collections.Generic.List[object]]::new() - - foreach ($driveLetter in [char[]](67..90)) { - $profileRoot = '{0}:\WinpeStartup\profiles' -f $driveLetter - - if (-not (Test-Path -LiteralPath $profileRoot -PathType Container)) { - continue - } - - try { - $profileFiles = Get-ChildItem -LiteralPath $profileRoot -Filter '*.json' -File -ErrorAction Stop | Sort-Object FullName - - foreach ($profileFile in $profileFiles) { - [void]$candidateProfiles.Add([pscustomobject]@{ - Index = 0 - Profile = $profileFile.BaseName - Path = $profileFile.FullName - }) - } - } - catch { - Write-Verbose "Invoke-WinpeStartup: Unable to enumerate '$profileRoot': $($_.Exception.Message)" - } - } + $candidateProfiles = @(Get-WinpeStartupProfileCandidates) if ($candidateProfiles.Count -gt 0) { # Force array semantics so .Count and indexing behave reliably on PS 5.1 even with a single item. @@ -492,12 +499,10 @@ function Invoke-WinpeStartup { } else { Write-Host '' - Write-Host 'WinpeStartup Profiles:' + Write-Host -ForegroundColor Cyan 'WinpeStartup Profiles:' $orderedProfiles | Select-Object Index, Profile, Path | - Format-Table -AutoSize | - Out-String | - Write-Host + Format-Table -AutoSize | Out-String | Write-Host while (-not $selectedProfile) { $selection = Read-Host 'Select a profile by number, or press Enter to cancel' @@ -833,7 +838,17 @@ function Invoke-WinpeStartup { } end { - if ($skipExecution) { return } - Write-Verbose 'Invoke-WinpeStartup: Complete' + if (-not $skipExecution) { + Write-Verbose 'Invoke-WinpeStartup: Complete' + } + + if ($startupTranscriptStarted) { + try { + $null = Stop-Transcript -ErrorAction Stop + } + catch { + Write-Warning "Invoke-WinpeStartup: Failed to stop log '$startupLogPath': $($_.Exception.Message)" + } + } } } diff --git a/README.md b/README.md index 97a04b8..d43c9bc 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ OSDCloud is a PowerShell module for deploying Windows with cloud-hosted operatin ## Overview -- Current repository module version: `26.10.4.1`. +- Current repository module version: `26.10.6.1`. - Defaults to Windows 11 26H2 for amd64 and arm64 deployments. - Focused on Windows deployment workflows driven by PowerShell. - Supports WinpeStartup helpers and deployment UX options. @@ -14,13 +14,13 @@ OSDCloud is a PowerShell module for deploying Windows with cloud-hosted operatin --- -## What's New in 26.10.4.1 +## What's New in 26.10.6.1 -Released October 4, 2026. +Released October 6, 2026. -- **Consistent WinpeStartup naming:** Exported startup cmdlets, private helpers, module defaults, bundled profiles, messages, and documentation now use `WinpeStartup`, including `Invoke-WinpeStartup` and the `Invoke-WinpeStartup:` JSON key prefix. -- **Consistent media paths:** Startup content discovery uses `WinpeStartup\Drivers`, `WinpeStartup\Files`, and `WinpeStartup\profiles`. -- **Existing behavior preserved:** This is a naming-consistency release. Startup parameters, default settings, and deployment behavior are unchanged, and existing PEStartup compatibility aliases remain available. +- **Improved startup configuration:** WinpeStartup supports folder-based profiles, `.env` environment defaults, and importing registry and certificate files during environment setup. +- **Startup logging:** Startup output is transcribed to `X:\Windows\Temp\winpestartup.log`. +- **Profile cleanup:** Removed the obsolete German CLI startup profile. See [CHANGELOG.md](CHANGELOG.md) for the complete release history and [WinpeStartup documentation](docs/about_winpestartup.md) for startup configuration. diff --git a/docs/06-unattended-usb-profile.md b/docs/06-unattended-usb-profile.md index bdaba99..a5012b2 100644 --- a/docs/06-unattended-usb-profile.md +++ b/docs/06-unattended-usb-profile.md @@ -33,17 +33,18 @@ passed on the `Invoke-WinpeStartup` command line override both. ## How to create a profile -### 1. Drop a file on USB +### 1. Add a profile folder on USB -Place a JSON file at: +Place the JSON file inside a profile-named folder: ``` -:\WinpeStartup\profiles\.json +:\WinpeStartup\profiles\\winpestartup.json ``` Any partition on any drive connected at boot is scanned. The OSDCloud USB's NTFS data partition (`OSDCloud` label, created by `New-OSDeployBootUSB`) is the usual home for it. +Legacy `.json` files directly in `profiles` remain supported. - If exactly **one** profile is found, it is applied silently. - If **multiple** profiles are found, a numbered menu is shown and the @@ -167,7 +168,7 @@ Empty `InvokeMainCommand` means startup runs but no deployment is triggered On any Windows machine with the module installed: ```powershell -Get-Content ':\WinpeStartup\profiles\mysite.json' -Raw | +Get-Content ':\WinpeStartup\profiles\mysite\winpestartup.json' -Raw | ForEach-Object { $_ -replace '//[^\r\n]*','' -replace '/\*.*?\*/','' } | ConvertFrom-Json ``` diff --git a/docs/09-troubleshooting.md b/docs/09-troubleshooting.md index 6cde76d..2efed48 100644 --- a/docs/09-troubleshooting.md +++ b/docs/09-troubleshooting.md @@ -135,10 +135,14 @@ session — most steps are skipped outside WinPE (only those with ### A USB profile isn't applied ```powershell -Get-ChildItem -Path *:\WinpeStartup\profiles\*.json +Get-ChildItem -Path '*:\WinpeStartup\profiles' -File -Filter '*.json' +Get-ChildItem -Path '*:\WinpeStartup\profiles' -Directory | + ForEach-Object { Join-Path $_.FullName 'winpestartup.json' } | + Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } ``` -- Profile must be at `:\WinpeStartup\profiles\*.json` — exact path. +- New profiles must be at `:\WinpeStartup\profiles\\winpestartup.json`. +- Legacy `:\WinpeStartup\profiles\.json` files are also supported. - File must parse as JSON (after stripping `//` and `/* */` comments). Validate on Windows: ```powershell Get-Content profile.json -Raw | diff --git a/docs/about_winpestartup.md b/docs/about_winpestartup.md index 090b5f5..ee6e38d 100644 --- a/docs/about_winpestartup.md +++ b/docs/about_winpestartup.md @@ -56,9 +56,12 @@ Module defaults are read from the OSDCloud PSDefaultParameterValues JSON file. K Startup profiles are JSON files under this layout on any attached drive: ```text -H:\WinpeStartup\profiles\BranchOffice.json +H:\WinpeStartup\profiles\BranchOffice\winpestartup.json ``` +Legacy root-level files such as `H:\WinpeStartup\profiles\BranchOffice.json` +are still discovered. + Profiles may use either prefixed keys, such as `Invoke-WinpeStartup:SkipWiFi`, or plain splat-style keys, such as `SkipWiFi`. That makes profiles easier to read while still supporting the same parameters. A profile may also include an unprefixed `Environment` object: