From 5d81947b050b102ec9f85aefc83b5e14671bff6e Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 6 Oct 2026 07:57:02 -0500 Subject: [PATCH 1/8] feat(winpestartup): enhance profile discovery and add support for folder-based profiles --- .../skills/author-winpe-profile-json/SKILL.md | 6 +-- CHANGELOG.md | 6 +++ .../OSDCloud German CLI/winpestartup.json | 12 +++++ .../winpestartup.json} | 0 ...et-WinpeStartupProfileCandidates.Tests.ps1 | 51 +++++++++++++++++++ .../Get-WinpeStartupProfileCandidates.ps1 | 43 ++++++++++++++++ .../Initialize-WinpeStartupMain.ps1 | 6 +-- OSDCloud/public/WinPE/Invoke-WinpeStartup.ps1 | 31 ++--------- docs/06-unattended-usb-profile.md | 9 ++-- docs/09-troubleshooting.md | 8 ++- docs/about_winpestartup.md | 5 +- 11 files changed, 136 insertions(+), 41 deletions(-) create mode 100644 OSDCloud/core/winpestartup-profiles/OSDCloud German CLI/winpestartup.json rename OSDCloud/core/winpestartup-profiles/{OSDCloud.json => OSDCloud/winpestartup.json} (100%) create mode 100644 OSDCloud/private/Get-WinpeStartupProfileCandidates.Tests.ps1 create mode 100644 OSDCloud/private/Get-WinpeStartupProfileCandidates.ps1 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..89e9c21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to this project will be documented in this file. +## [Unreleased] + +### Changed + +- WinpeStartup profile discovery now supports `\winpestartup.json` folders while retaining support for legacy root-level JSON profiles. + ## 26.10.4.1 - October 4, 2026 ### Changed diff --git a/OSDCloud/core/winpestartup-profiles/OSDCloud German CLI/winpestartup.json b/OSDCloud/core/winpestartup-profiles/OSDCloud German CLI/winpestartup.json new file mode 100644 index 0000000..a45676b --- /dev/null +++ b/OSDCloud/core/winpestartup-profiles/OSDCloud German CLI/winpestartup.json @@ -0,0 +1,12 @@ +{ + "env": { + "WINPESTARTUP_AUTHOR": "OSDeploy", + "WINPESTARTUP_PROFILE": "OSDCloud German CLI" + }, + "Invoke-WinpeStartup:InvokeMainCommand": [ + "Show-OSDCloudDeviceInfo", + "Deploy-OSDCloud -OSLanguage de-de -CLI" + ], + "Invoke-WinpeStartup:InvokeMainCommandNoExit": true, + "Invoke-WinpeStartup:InvokeMainCommandEA": "Continue" +} 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-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..95a6093 100644 --- a/OSDCloud/public/WinPE/Invoke-WinpeStartup.ps1 +++ b/OSDCloud/public/WinPE/Invoke-WinpeStartup.ps1 @@ -450,30 +450,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 +469,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' 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: From 8aa5fe24cefc06636f1d1ce7bba3a23058a35074 Mon Sep 17 00:00:00 2001 From: David Segura Date: Tue, 6 Oct 2026 14:39:40 -0500 Subject: [PATCH 2/8] feat(winpestartup): enhance environment file handling and add tests --- CHANGELOG.md | 4 + ...itialize-WinpeStartupEnvironment.Tests.ps1 | 342 ++++++++++++++++++ .../Initialize-WinpeStartupEnvironment.ps1 | 105 +++++- OSDCloud/public/WinPE/Invoke-WinpeStartup.ps1 | 8 + 4 files changed, 455 insertions(+), 4 deletions(-) create mode 100644 OSDCloud/private/WinpeStartup/Initialize-WinpeStartupEnvironment.Tests.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 89e9c21..27a5cb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added + +- WinpeStartup imports 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. + ### Changed - WinpeStartup profile discovery now supports `\winpestartup.json` folders while retaining support for legacy root-level JSON profiles. diff --git a/OSDCloud/private/WinpeStartup/Initialize-WinpeStartupEnvironment.Tests.ps1 b/OSDCloud/private/WinpeStartup/Initialize-WinpeStartupEnvironment.Tests.ps1 new file mode 100644 index 0000000..4d090a2 --- /dev/null +++ b/OSDCloud/private/WinpeStartup/Initialize-WinpeStartupEnvironment.Tests.ps1 @@ -0,0 +1,342 @@ +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 {} + } + + 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' + } + + 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' + } + + 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 + } + + 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' + } +} diff --git a/OSDCloud/private/WinpeStartup/Initialize-WinpeStartupEnvironment.ps1 b/OSDCloud/private/WinpeStartup/Initialize-WinpeStartupEnvironment.ps1 index b542eeb..f6d6fec 100644 --- a/OSDCloud/private/WinpeStartup/Initialize-WinpeStartupEnvironment.ps1 +++ b/OSDCloud/private/WinpeStartup/Initialize-WinpeStartupEnvironment.ps1 @@ -15,25 +15,50 @@ 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 + 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. + .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:. #> [CmdletBinding()] [OutputType([void])] @@ -56,6 +81,78 @@ 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 = @() + try { + if (Test-Path -LiteralPath $corePath -PathType Container -ErrorAction Stop) { + $coreFolders = @(Get-ChildItem -LiteralPath $corePath -Directory -Force -ErrorAction Stop) + } + } + catch { + Write-Warning "Initialize-WinpeStartupEnvironment: Failed to discover Core folders in '$corePath'." + } + + $environmentFiles = @( + foreach ($coreFolder in $coreFolders) { + try { + Get-ChildItem -LiteralPath $coreFolder.FullName -File -Filter '*.env' -Force -ErrorAction Stop | + Where-Object { $_.Name -like '*.env' } + } + catch { + 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 { + 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) { + 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)) { + 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]) { + 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 { + Write-Warning "Initialize-WinpeStartupEnvironment: Failed to set environment entry from '$($environmentFile.FullName)' at line $lineNumber." + } + } + } + # ── Shell Folders ─────────────────────────────────────────────── $shellFolders = @( Join-Path -Path $systemDrive -ChildPath 'Program Files\WindowsPowerShell\Scripts' diff --git a/OSDCloud/public/WinPE/Invoke-WinpeStartup.ps1 b/OSDCloud/public/WinPE/Invoke-WinpeStartup.ps1 index 95a6093..644c7d3 100644 --- a/OSDCloud/public/WinPE/Invoke-WinpeStartup.ps1 +++ b/OSDCloud/public/WinPE/Invoke-WinpeStartup.ps1 @@ -12,6 +12,14 @@ function Invoke-WinpeStartup { environment setup, drivers, files, hardware checks, connectivity, module updates, script execution, and optional URL/command invocations. + 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. + A selected profile may include an env or Environment object. Supported scalar values are converted to strings and assigned to the current process, overwriting existing values. Child PowerShell command sessions From f343cf89dbeb31a6b54a932ef1945eaec63294cf Mon Sep 17 00:00:00 2001 From: David Segura Date: Tue, 6 Oct 2026 16:25:28 -0500 Subject: [PATCH 3/8] feat(winpe): import registry and certificate files during environment setup --- CHANGELOG.md | 1 + ...itialize-WinpeStartupEnvironment.Tests.ps1 | 183 ++++++++++++++++++ .../Initialize-WinpeStartupEnvironment.ps1 | 115 +++++++++++ OSDCloud/public/WinPE/Invoke-WinpeStartup.ps1 | 6 + 4 files changed, 305 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27a5cb8..deb139f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file. ### Added +- WinpeStartup imports Core `winpe-reg\*.reg` files into the WinPE registry and `winpe-root-cer\*.cer` files into the local machine Root certificate store. Files are processed in sorted path order; discovery, launch, and nonzero exit-code failures warn and continue. - WinpeStartup imports 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. ### Changed diff --git a/OSDCloud/private/WinpeStartup/Initialize-WinpeStartupEnvironment.Tests.ps1 b/OSDCloud/private/WinpeStartup/Initialize-WinpeStartupEnvironment.Tests.ps1 index 4d090a2..2348582 100644 --- a/OSDCloud/private/WinpeStartup/Initialize-WinpeStartupEnvironment.Tests.ps1 +++ b/OSDCloud/private/WinpeStartup/Initialize-WinpeStartupEnvironment.Tests.ps1 @@ -36,6 +36,7 @@ Describe 'Initialize-WinpeStartupEnvironment Core environment files' { Mock New-Item {} Mock Set-ItemProperty {} Mock Write-Host {} + Mock Start-Process { [pscustomobject]@{ ExitCode = 0 } } } AfterEach { @@ -79,6 +80,9 @@ Describe 'Initialize-WinpeStartupEnvironment Core environment files' { $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' { @@ -195,6 +199,12 @@ Describe 'Initialize-WinpeStartupEnvironment Core environment files' { $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' { @@ -316,6 +326,7 @@ Describe 'Initialize-WinpeStartupEnvironment Core environment files' { $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' { @@ -339,4 +350,176 @@ Describe 'Initialize-WinpeStartupEnvironment Core environment files' { $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 f6d6fec..9307e7f 100644 --- a/OSDCloud/private/WinpeStartup/Initialize-WinpeStartupEnvironment.ps1 +++ b/OSDCloud/private/WinpeStartup/Initialize-WinpeStartupEnvironment.ps1 @@ -20,6 +20,8 @@ function Initialize-WinpeStartupEnvironment { - 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 @@ -34,6 +36,18 @@ function Initialize-WinpeStartupEnvironment { 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 PS> Initialize-WinpeStartupEnvironment @@ -59,6 +73,14 @@ function Initialize-WinpeStartupEnvironment { 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])] @@ -84,15 +106,18 @@ function Initialize-WinpeStartupEnvironment { $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 { @@ -100,6 +125,7 @@ function Initialize-WinpeStartupEnvironment { Where-Object { $_.Name -like '*.env' } } catch { + $allEnvironmentImportsSucceeded = $false Write-Warning "Initialize-WinpeStartupEnvironment: Failed to discover environment files in '$($coreFolder.FullName)'." } } @@ -110,6 +136,7 @@ function Initialize-WinpeStartupEnvironment { $environmentLines = @(Get-Content -LiteralPath $environmentFile.FullName -ErrorAction Stop) } catch { + $allEnvironmentImportsSucceeded = $false Write-Warning "Initialize-WinpeStartupEnvironment: Failed to read environment file '$($environmentFile.FullName)'." continue } @@ -124,6 +151,7 @@ function Initialize-WinpeStartupEnvironment { $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 } @@ -131,12 +159,14 @@ function Initialize-WinpeStartupEnvironment { $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 } @@ -148,11 +178,17 @@ function Initialize-WinpeStartupEnvironment { 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' @@ -193,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/public/WinPE/Invoke-WinpeStartup.ps1 b/OSDCloud/public/WinPE/Invoke-WinpeStartup.ps1 index 644c7d3..10cba8b 100644 --- a/OSDCloud/public/WinPE/Invoke-WinpeStartup.ps1 +++ b/OSDCloud/public/WinPE/Invoke-WinpeStartup.ps1 @@ -20,6 +20,12 @@ function Invoke-WinpeStartup { 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 process, overwriting existing values. Child PowerShell command sessions From 7fb04675b83692fc784e9ec0da2579d06c2b1ec4 Mon Sep 17 00:00:00 2001 From: David Segura Date: Tue, 6 Oct 2026 20:28:01 -0500 Subject: [PATCH 4/8] feat(winpe): add logging functionality to startup process --- OSDCloud/public/WinPE/Invoke-WinpeStartup.ps1 | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/OSDCloud/public/WinPE/Invoke-WinpeStartup.ps1 b/OSDCloud/public/WinPE/Invoke-WinpeStartup.ps1 index 10cba8b..977d553 100644 --- a/OSDCloud/public/WinPE/Invoke-WinpeStartup.ps1 +++ b/OSDCloud/public/WinPE/Invoke-WinpeStartup.ps1 @@ -11,6 +11,7 @@ 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 @@ -183,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.' @@ -190,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', @@ -822,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)" + } + } } } From 0be9ce777721e4a28630430fb5b56f2e70ab0fb0 Mon Sep 17 00:00:00 2001 From: David Segura Date: Tue, 6 Oct 2026 20:30:01 -0500 Subject: [PATCH 5/8] chore(winpestartup): remove obsolete German CLI startup profile --- .../OSDCloud German CLI/winpestartup.json | 12 ------------ 1 file changed, 12 deletions(-) delete mode 100644 OSDCloud/core/winpestartup-profiles/OSDCloud German CLI/winpestartup.json diff --git a/OSDCloud/core/winpestartup-profiles/OSDCloud German CLI/winpestartup.json b/OSDCloud/core/winpestartup-profiles/OSDCloud German CLI/winpestartup.json deleted file mode 100644 index a45676b..0000000 --- a/OSDCloud/core/winpestartup-profiles/OSDCloud German CLI/winpestartup.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "env": { - "WINPESTARTUP_AUTHOR": "OSDeploy", - "WINPESTARTUP_PROFILE": "OSDCloud German CLI" - }, - "Invoke-WinpeStartup:InvokeMainCommand": [ - "Show-OSDCloudDeviceInfo", - "Deploy-OSDCloud -OSLanguage de-de -CLI" - ], - "Invoke-WinpeStartup:InvokeMainCommandNoExit": true, - "Invoke-WinpeStartup:InvokeMainCommandEA": "Continue" -} From 5e9e60123a696252d3fa67d0b842bc84b7fc5e1c Mon Sep 17 00:00:00 2001 From: David Segura Date: Tue, 6 Oct 2026 20:31:05 -0500 Subject: [PATCH 6/8] chore(module): update module version to 26.10.6.1 --- OSDCloud/OSDCloud.psd1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OSDCloud/OSDCloud.psd1 b/OSDCloud/OSDCloud.psd1 index 8323b66..89d246f 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.6.1' CompatiblePSEditions = @('Core', 'Desktop') GUID = '2fbd5c65-79c7-4561-9a2e-c4a4eebc89c7' From f5749a87b14f7528cf6d5adeb564243843d28b44 Mon Sep 17 00:00:00 2001 From: David Segura Date: Tue, 6 Oct 2026 20:32:31 -0500 Subject: [PATCH 7/8] chore(release): update version to 26.10.6.1 and document changes --- CHANGELOG.md | 13 ++++++++++--- README.md | 12 ++++++------ 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index deb139f..79fa868 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,16 +2,23 @@ All notable changes to this project will be documented in this file. -## [Unreleased] +## 26.10.6.1 - October 6, 2026 ### Added -- WinpeStartup imports Core `winpe-reg\*.reg` files into the WinPE registry and `winpe-root-cer\*.cer` files into the local machine Root certificate store. Files are processed in sorted path order; discovery, launch, and nonzero exit-code failures warn and continue. -- WinpeStartup imports 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 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 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. From ffaa9c0f117c06a3e5ea48773d3f757c44b1c769 Mon Sep 17 00:00:00 2001 From: David Segura Date: Fri, 9 Oct 2026 17:14:17 -0500 Subject: [PATCH 8/8] chore(module): update module version to 26.10.9.1 --- OSDCloud/OSDCloud.psd1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OSDCloud/OSDCloud.psd1 b/OSDCloud/OSDCloud.psd1 index 89d246f..70ac11c 100644 --- a/OSDCloud/OSDCloud.psd1 +++ b/OSDCloud/OSDCloud.psd1 @@ -7,7 +7,7 @@ @{ # --- Identity --- RootModule = 'OSDCloud.psm1' - ModuleVersion = '26.10.6.1' + ModuleVersion = '26.10.9.1' CompatiblePSEditions = @('Core', 'Desktop') GUID = '2fbd5c65-79c7-4561-9a2e-c4a4eebc89c7'