diff --git a/CHANGELOG.md b/CHANGELOG.md index 8867697..1199e82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to this project will be documented in this file. +## 26.10.13.1 - October 13, 2026 + +### Added + +- Added `New-WinpeStartupProfile` to author named startup profiles in existing Boot-Assets and on writable USB volumes labeled OSDCloud. Writes all startup settings using bundled OSDCloud defaults in grouped property order, records the current username and profile name in environment metadata, opens saved profiles in Notepad, and supports safe replacement with `-Force` and `-WhatIf`/`-Confirm`. +- Added focused tests for profile serialization, destination discovery, permissions, overwrite protection, and module exports. + +### Changed + +- Module version bumped from `26.10.10.1` to `26.10.13.1`. + ## 26.10.10.1 - October 10, 2026 ### Added diff --git a/OSDCloud/OSDCloud.psd1 b/OSDCloud/OSDCloud.psd1 index f3b4e3f..41c4dc2 100644 --- a/OSDCloud/OSDCloud.psd1 +++ b/OSDCloud/OSDCloud.psd1 @@ -7,7 +7,7 @@ @{ # --- Identity --- RootModule = 'OSDCloud.psm1' - ModuleVersion = '26.10.10.1' + ModuleVersion = '26.10.13.1' CompatiblePSEditions = @('Core', 'Desktop') GUID = '2fbd5c65-79c7-4561-9a2e-c4a4eebc89c7' @@ -31,6 +31,7 @@ Windows 11 26H2 GA Release 'Deploy-OSDCloud', 'Get-OSDCloudModulePath', 'Get-OSDCloudModuleVersion', + 'New-WinpeStartupProfile', 'Show-OSDCloudDeviceInfo', 'Start-OSDCloudExplorer', # WinPE diff --git a/OSDCloud/public/WinOS/New-WinpeStartupProfile.Tests.ps1 b/OSDCloud/public/WinOS/New-WinpeStartupProfile.Tests.ps1 new file mode 100644 index 0000000..521d9c0 --- /dev/null +++ b/OSDCloud/public/WinOS/New-WinpeStartupProfile.Tests.ps1 @@ -0,0 +1,566 @@ +BeforeAll { + . "$PSScriptRoot\New-WinpeStartupProfile.ps1" + . "$PSScriptRoot\..\WinPE\Invoke-WinpeStartup.ps1" + $script:bootAssetsPath = 'C:\ProgramData\OSDeployCore\boot-assets\winpestartup-profiles' + if (-not ('WinpeStartupProfileTests.DecliningHost' -as [type])) { + Add-Type -TypeDefinition @' +using System; +using System.Collections.ObjectModel; +using System.Globalization; +using System.Management.Automation; +using System.Management.Automation.Host; +using System.Security; + +namespace WinpeStartupProfileTests { + public sealed class DecliningHost : PSHost { + private readonly PSHost inner; + private readonly DecliningUI ui; + public DecliningHost(PSHost inner) { this.inner = inner; ui = new DecliningUI(inner.UI); } + public int PromptCount { get { return ui.PromptCount; } } + public override Guid InstanceId { get { return inner.InstanceId; } } + public override string Name { get { return "DecliningProfileTestHost"; } } + public override Version Version { get { return inner.Version; } } + public override CultureInfo CurrentCulture { get { return inner.CurrentCulture; } } + public override CultureInfo CurrentUICulture { get { return inner.CurrentUICulture; } } + public override PSHostUserInterface UI { get { return ui; } } + public override void SetShouldExit(int code) { throw new NotSupportedException(); } + public override void EnterNestedPrompt() { throw new NotSupportedException(); } + public override void ExitNestedPrompt() { throw new NotSupportedException(); } + public override void NotifyBeginApplication() { inner.NotifyBeginApplication(); } + public override void NotifyEndApplication() { inner.NotifyEndApplication(); } + } + public sealed class DecliningUI : PSHostUserInterface { + private readonly PSHostUserInterface inner; + public DecliningUI(PSHostUserInterface inner) { this.inner = inner; } + public int PromptCount { get; private set; } + public override PSHostRawUserInterface RawUI { get { return inner.RawUI; } } + public override int PromptForChoice(string caption, string message, Collection choices, int defaultChoice) { + PromptCount++; + for (int i = 0; i < choices.Count; i++) { + if (choices[i].Label.Replace("&", "") == "No") { return i; } + } + throw new InvalidOperationException("Expected a No confirmation choice."); + } + public override string ReadLine() { throw new NotSupportedException(); } + public override SecureString ReadLineAsSecureString() { throw new NotSupportedException(); } + public override System.Collections.Generic.Dictionary Prompt(string caption, string message, Collection fields) { throw new NotSupportedException(); } + public override PSCredential PromptForCredential(string caption, string message, string userName, string targetName) { throw new NotSupportedException(); } + public override PSCredential PromptForCredential(string caption, string message, string userName, string targetName, PSCredentialTypes types, PSCredentialUIOptions options) { throw new NotSupportedException(); } + public override void Write(string value) { inner.Write(value); } + public override void Write(ConsoleColor foreground, ConsoleColor background, string value) { inner.Write(foreground, background, value); } + public override void WriteLine(string value) { inner.WriteLine(value); } + public override void WriteErrorLine(string value) { inner.WriteErrorLine(value); } + public override void WriteDebugLine(string value) { inner.WriteDebugLine(value); } + public override void WriteVerboseLine(string value) { inner.WriteVerboseLine(value); } + public override void WriteWarningLine(string value) { inner.WriteWarningLine(value); } + public override void WriteProgress(long sourceId, ProgressRecord record) { inner.WriteProgress(sourceId, record); } + } +} +'@ + } +} + +Describe 'New-WinpeStartupProfile' { + BeforeEach { + $script:bootExists = $false + $script:elevated = $true + $script:testRoot = [System.IO.Path]::Combine($TestDrive, [guid]::NewGuid().ToString('N')) + $script:bootRoot = [System.IO.Path]::Combine($script:testRoot, 'BootAssets') + $script:volumes = @([pscustomobject]@{ FileSystemLabel = 'OSDCloud'; DriveLetter = 'F' }) + $script:disk = [pscustomobject]@{ BusType = 'USB'; IsReadOnly = $false; IsOffline = $false } + $script:partition = [pscustomobject]@{ DiskNumber = 7; IsReadOnly = $false; IsOffline = $false } + Mock Test-Path { $script:bootExists } -ParameterFilter { $LiteralPath -eq $script:bootAssetsPath } + Mock Join-Path { + [System.IO.Path]::Combine($script:bootRoot, $ChildPath) + } -ParameterFilter { $Path -eq $script:bootAssetsPath } + Mock Join-Path { + [System.IO.Path]::Combine($script:testRoot, "USB-$($Path.Substring(0, 1))", $ChildPath) + } -ParameterFilter { $Path -match '^[A-Z]:\\$' } + Mock Get-Volume { $script:volumes } + Mock Get-Partition { $script:partition } + Mock Get-Disk { $script:disk } + Mock Start-Process {} + Mock New-Object { + $principal = [pscustomobject]@{} + $principal | Add-Member -MemberType ScriptMethod -Name IsInRole -Value { $script:elevated } + $principal + } -ParameterFilter { $TypeName -eq 'System.Security.Principal.WindowsPrincipal' } + } + + It 'exposes every runtime setting with the same type and error-action validation' { + $runtime = Get-Command Invoke-WinpeStartup + $author = Get-Command New-WinpeStartupProfile + $common = [System.Management.Automation.PSCmdlet]::CommonParameters + $settings = @($runtime.Parameters.Keys | Where-Object { $_ -notin $common }) + $settings.Count | Should -Be 16 + foreach ($name in $settings) { + $author.Parameters[$name].ParameterType | Should -Be $runtime.Parameters[$name].ParameterType + if ($name -like '*EA') { + ($author.Parameters[$name].Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] }).ValidValues | + Should -Be @('Continue', 'Stop') + } + } + } + + It 'writes all 16 settings using exact names and native JSON types without authoring or common parameters' { + $settings = @{ + ProfileName = 'AllSettings' + SkipOnScreenKeyboard = $true + ShowPnpDevices = $false + ShowPnpErrors = $true + SkipWiFi = $false + SkipIPConfig = $true + SkipUpdateOSDCloud = $false + InstallModule = @('ModuleOne', 'ModuleTwo') + InvokeStartupCommand = @('Write-Output preparation', 'https://example.invalid/start.ps1') + InvokeStartupCommandNoExit = $true + InvokeStartupCommandEA = 'Stop' + InvokeMainCommand = @('Write-Output main') + InvokeMainCommandNoExit = $false + InvokeMainCommandEA = 'Continue' + InvokeShutdownCommand = @() + InvokeShutdownCommandNoExit = $true + InvokeShutdownCommandEA = 'Stop' + PassThru = $true + Force = $true + Confirm = $false + Verbose = $false + ErrorAction = 'Stop' + } + $file = New-WinpeStartupProfile @settings + $file | Should -BeOfType ([System.IO.FileInfo]) + $file.FullName | Should -Be ([System.IO.Path]::Combine($script:testRoot, 'USB-F', 'WinpeStartup', 'Profiles', 'AllSettings', 'winpestartup.json')) + $savedSettings = Get-Content -LiteralPath $file.FullName -Raw | ConvertFrom-Json + @($savedSettings.PSObject.Properties).Count | Should -Be 17 + foreach ($name in @('SkipOnScreenKeyboard', 'ShowPnpDevices', 'ShowPnpErrors', 'SkipWiFi', 'SkipIPConfig', 'SkipUpdateOSDCloud', + 'InvokeStartupCommandNoExit', 'InvokeMainCommandNoExit', 'InvokeShutdownCommandNoExit')) { + $savedSettings."Invoke-WinpeStartup:$name" | Should -BeOfType ([bool]) + $savedSettings."Invoke-WinpeStartup:$name" | Should -Be $settings[$name] + } + foreach ($name in @('InstallModule', 'InvokeStartupCommand', 'InvokeMainCommand', 'InvokeShutdownCommand')) { + $savedSettings."Invoke-WinpeStartup:$name".GetType().IsArray | Should -BeTrue + $savedSettings."Invoke-WinpeStartup:$name" | Should -Be $settings[$name] + } + foreach ($name in @('InvokeStartupCommandEA', 'InvokeMainCommandEA', 'InvokeShutdownCommandEA')) { + $savedSettings."Invoke-WinpeStartup:$name" | Should -Be $settings[$name] + } + } + + It 'includes unspecified defaults and preserves explicit false' { + $file = New-WinpeStartupProfile -ProfileName 'ExplicitFalse' -InvokeMainCommandNoExit:$false -PassThru + $savedSettings = Get-Content -LiteralPath $file.FullName -Raw | ConvertFrom-Json + @($savedSettings.PSObject.Properties).Count | Should -Be 17 + $savedSettings.'Invoke-WinpeStartup:InvokeMainCommandNoExit' | Should -BeFalse + } + + It 'preserves non-ASCII commands through the runtime Get-Content path' { + $command = "Write-Output 'caf$([char]0x00e9)'" + $file = New-WinpeStartupProfile -ProfileName 'Unicode' -InvokeMainCommand $command -PassThru + $savedSettings = Get-Content -LiteralPath $file.FullName -Raw | ConvertFrom-Json + $savedSettings.'Invoke-WinpeStartup:InvokeMainCommand'[0] | Should -BeExactly $command + $bytes = [System.IO.File]::ReadAllBytes($file.FullName) + @($bytes[0..2]) | Should -Be @(239, 187, 191) + } + + It 'creates a complete profile matching bundled OSDCloud defaults with no success-stream output' { + @(New-WinpeStartupProfile -ProfileName 'Empty').Count | Should -Be 0 + $path = [System.IO.Path]::Combine($script:testRoot, 'USB-F', 'WinpeStartup', 'Profiles', 'Empty', 'winpestartup.json') + $savedSettings = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json + $defaults = Get-Content -LiteralPath "$PSScriptRoot\..\..\core\PSDefaultParameterValues.json" -Raw | ConvertFrom-Json + $bundled = Get-Content -LiteralPath "$PSScriptRoot\..\..\core\winpestartup-profiles\OSDCloud\winpestartup.json" -Raw | ConvertFrom-Json + @($savedSettings.PSObject.Properties).Count | Should -Be 17 + foreach ($property in $defaults.PSObject.Properties) { + $expected = $property.Value + if ($bundled.PSObject.Properties[$property.Name]) { + $expected = $bundled.($property.Name) + } + $savedSettings.($property.Name) | Should -Be $expected + } + $savedSettings.Environment.WINPESTARTUP_AUTHOR | Should -BeExactly ([System.Environment]::UserName) + $savedSettings.Environment.WINPESTARTUP_PROFILE | Should -BeExactly 'Empty' + $savedSettings.PSObject.Properties.Name | Should -Be @( + 'Environment' + 'Invoke-WinpeStartup:ShowPnpDevices' + 'Invoke-WinpeStartup:ShowPnpErrors' + 'Invoke-WinpeStartup:SkipOnScreenKeyboard' + 'Invoke-WinpeStartup:SkipWiFi' + 'Invoke-WinpeStartup:SkipIPConfig' + 'Invoke-WinpeStartup:SkipUpdateOSDCloud' + 'Invoke-WinpeStartup:InstallModule' + 'Invoke-WinpeStartup:InvokeStartupCommand' + 'Invoke-WinpeStartup:InvokeStartupCommandEA' + 'Invoke-WinpeStartup:InvokeStartupCommandNoExit' + 'Invoke-WinpeStartup:InvokeMainCommand' + 'Invoke-WinpeStartup:InvokeMainCommandEA' + 'Invoke-WinpeStartup:InvokeMainCommandNoExit' + 'Invoke-WinpeStartup:InvokeShutdownCommand' + 'Invoke-WinpeStartup:InvokeShutdownCommandEA' + 'Invoke-WinpeStartup:InvokeShutdownCommandNoExit' + ) + $savedSettings.Environment.PSObject.Properties.Name | Should -Be @('WINPESTARTUP_AUTHOR', 'WINPESTARTUP_PROFILE') + $script:expectedEditorPath = $path + Should -Invoke Start-Process -Times 1 -Exactly -ParameterFilter { + $FilePath -eq 'notepad.exe' -and $ArgumentList -eq "`"$script:expectedEditorPath`"" + } + } + + It 'normalizes error-action values to canonical runtime values' { + $file = New-WinpeStartupProfile -ProfileName 'Canonical' -InvokeMainCommandEA stop -InvokeShutdownCommandEA continue -PassThru + $savedSettings = Get-Content -LiteralPath $file.FullName -Raw | ConvertFrom-Json + $savedSettings.'Invoke-WinpeStartup:InvokeMainCommandEA' | Should -BeExactly 'Stop' + $savedSettings.'Invoke-WinpeStartup:InvokeShutdownCommandEA' | Should -BeExactly 'Continue' + } + + It 'stores scalar environment values without changing the author environment' { + $original = [System.Environment]::GetEnvironmentVariable('OSDCLOUD_PROFILE_AUTHOR_TEST', 'Process') + $file = New-WinpeStartupProfile -ProfileName 'Environment' -Environment @{ + OSDCLOUD_PROFILE_AUTHOR_TEST = 'site' + Ring = 2 + Interactive = $true + Fraction = [decimal]1.25 + Empty = '' + } -PassThru + $savedSettings = Get-Content -LiteralPath $file.FullName -Raw | ConvertFrom-Json + @($savedSettings.PSObject.Properties).Count | Should -Be 17 + $savedSettings.Environment.OSDCLOUD_PROFILE_AUTHOR_TEST | Should -Be 'site' + $savedSettings.Environment.Ring | Should -Be 2 + $savedSettings.Environment.Interactive | Should -BeOfType ([bool]) + $savedSettings.Environment.Fraction | Should -Be 1.25 + $savedSettings.Environment.Empty | Should -BeExactly '' + [System.Environment]::GetEnvironmentVariable('OSDCLOUD_PROFILE_AUTHOR_TEST', 'Process') | Should -Be $original + } + + It 'retains generated metadata when Environment is explicitly empty' { + $file = New-WinpeStartupProfile -ProfileName 'EmptyEnvironment' -Environment @{} -PassThru + $savedSettings = Get-Content -LiteralPath $file.FullName -Raw | ConvertFrom-Json + $savedSettings.PSObject.Properties.Name | Should -Contain 'Environment' + $savedSettings.Environment | Should -BeOfType ([pscustomobject]) + @($savedSettings.Environment.PSObject.Properties).Count | Should -Be 2 + $savedSettings.Environment.WINPESTARTUP_AUTHOR | Should -BeExactly ([System.Environment]::UserName) + $savedSettings.Environment.WINPESTARTUP_PROFILE | Should -BeExactly 'EmptyEnvironment' + } + + It 'overrides supplied metadata without modifying the caller hashtable' { + $environmentValues = @{ + WINPESTARTUP_AUTHOR = 'OtherUser' + WINPESTARTUP_PROFILE = 'OtherProfile' + Site = 'BranchOffice' + } + $file = New-WinpeStartupProfile -ProfileName 'ActualProfile' -Environment $environmentValues -PassThru + $savedSettings = Get-Content -LiteralPath $file.FullName -Raw | ConvertFrom-Json + $savedSettings.Environment.WINPESTARTUP_AUTHOR | Should -BeExactly ([System.Environment]::UserName) + $savedSettings.Environment.WINPESTARTUP_PROFILE | Should -BeExactly 'ActualProfile' + $savedSettings.Environment.Site | Should -BeExactly 'BranchOffice' + $environmentValues.WINPESTARTUP_AUTHOR | Should -BeExactly 'OtherUser' + $environmentValues.WINPESTARTUP_PROFILE | Should -BeExactly 'OtherProfile' + } + + It 'rejects invalid folder name before discovery or writes' -ForEach @( + @{ Name = '.' }, @{ Name = '..' }, @{ Name = 'C:\Profile' }, + @{ Name = 'Parent\Child' }, @{ Name = 'Parent/Child' }, @{ Name = ' ' }, + @{ Name = 'Trailing.' }, @{ Name = 'Trailing ' }, @{ Name = 'CON' }, + @{ Name = 'nul.json' }, @{ Name = 'COM1' }, @{ Name = 'LPT9.txt' }, + @{ Name = 'CONIN$' }, @{ Name = 'CONOUT$' }, @{ Name = ('a' * 256) }, + @{ Name = 'Wild*Card' }, @{ Name = 'Wild?Card' }, @{ Name = 'Control' + [char]1 } + ) { + { New-WinpeStartupProfile -ProfileName $Name } | Should -Throw '*single valid Windows folder name*' + Should -Invoke Get-Volume -Times 0 -Exactly + } + + It 'rejects invalid array setting ' -ForEach @( + @{ Setting = 'InstallModule'; Value = $null }, + @{ Setting = 'InvokeStartupCommand'; Value = @('') }, + @{ Setting = 'InvokeMainCommand'; Value = @('valid', ' ') }, + @{ Setting = 'InvokeShutdownCommand'; Value = @($null) } + ) { + $arguments = @{ ProfileName = 'Invalid'; $Setting = $Value } + { New-WinpeStartupProfile @arguments } | Should -Throw '*array of non-empty strings*' + Should -Invoke Get-Volume -Times 0 -Exactly + } + + It 'rejects unsupported error-action settings before discovery' { + { New-WinpeStartupProfile -ProfileName 'InvalidEA' -InvokeMainCommandEA Ignore } | Should -Throw + Should -Invoke Get-Volume -Times 0 -Exactly + } + + It 'rejects invalid environment entry before discovery' -ForEach @( + @{ Case = 'null'; Value = @{ Site = $null } }, + @{ Case = 'array'; Value = @{ Site = @('one') } }, + @{ Case = 'object'; Value = @{ Site = @{ Nested = 'one' } } }, + @{ Case = 'date'; Value = @{ Site = [datetime]'2026-01-01' } }, + @{ Case = 'NaN'; Value = @{ Site = [double]::NaN } }, + @{ Case = 'infinity'; Value = @{ Site = [single]::PositiveInfinity } }, + @{ Case = 'enum'; Value = @{ Site = [System.DayOfWeek]::Monday } }, + @{ Case = 'equals in name'; Value = @{ 'Bad=Name' = 'site' } }, + @{ Case = 'empty name'; Value = @{ '' = 'site' } }, + @{ Case = 'non-string name'; Value = @{ 42 = 'site' } }, + @{ Case = 'null character in name'; Value = @{ ("Bad" + [char]0) = 'site' } } + ) { + { New-WinpeStartupProfile -ProfileName 'InvalidEnvironment' -Environment $Value } | Should -Throw '*Environment variable*' + Should -Invoke Get-Volume -Times 0 -Exactly + } + + It 'saves identical content to Boot-Assets and multiple actual USB drive letters' { + $script:bootExists = $true + $script:volumes = @( + [pscustomobject]@{ FileSystemLabel = 'OSDCloud'; DriveLetter = 'H' } + [pscustomobject]@{ FileSystemLabel = 'OSDCloud'; DriveLetter = 'Q' } + ) + $files = @(New-WinpeStartupProfile -ProfileName 'Multiple' -InvokeMainCommand 'Write-Output ready' -PassThru) + $files.Count | Should -Be 3 + $files[0].FullName | Should -Be ([System.IO.Path]::Combine($script:bootRoot, 'Multiple', 'winpestartup.json')) + $files[1].FullName | Should -Match '\\USB-H\\' + $files[2].FullName | Should -Match '\\USB-Q\\' + (Get-Content -LiteralPath $files[0].FullName -Raw) | Should -BeExactly (Get-Content -LiteralPath $files[1].FullName -Raw) + (Get-Content -LiteralPath $files[0].FullName -Raw) | Should -BeExactly (Get-Content -LiteralPath $files[2].FullName -Raw) + Should -Invoke Start-Process -Times 3 -Exactly -ParameterFilter { $FilePath -eq 'notepad.exe' } + } + + It 'does not create a missing Boot-Assets root or require elevation for USB' { + $script:elevated = $false + New-WinpeStartupProfile -ProfileName 'USBOnly' + Test-Path -LiteralPath $script:bootRoot | Should -BeFalse + Should -Invoke New-Object -Times 0 -Exactly -ParameterFilter { $TypeName -eq 'System.Security.Principal.WindowsPrincipal' } + } + + It 'reports insufficient elevation for Boot-Assets but still saves to USB' { + $script:bootExists = $true + $script:elevated = $false + $files = @(New-WinpeStartupProfile -ProfileName 'NonAdmin' -PassThru -ErrorAction SilentlyContinue -ErrorVariable saveErrors) + $files.Count | Should -Be 1 + $files[0].FullName | Should -Match '\\USB-F\\' + Should -Invoke Start-Process -Times 1 -Exactly -ParameterFilter { $ArgumentList -like '*USB-F*' } + @($saveErrors | Where-Object { $_.FullyQualifiedErrorId -like 'WinpeStartupProfileSaveFailed*' }).Count | Should -Be 1 + $saveErrors.Exception.Message | Should -Contain "Administrator rights are required to save '$([System.IO.Path]::Combine($script:bootRoot, 'NonAdmin', 'winpestartup.json'))'. Run PowerShell as administrator." + Test-Path -LiteralPath $script:bootRoot | Should -BeFalse + } + + It 'stops at the first save failure when ErrorAction Stop is requested' { + $script:bootExists = $true + $script:elevated = $false + { New-WinpeStartupProfile -ProfileName 'StopOnFailure' -ErrorAction Stop } | Should -Throw '*Administrator rights*' + Test-Path -LiteralPath ([System.IO.Path]::Combine($script:testRoot, 'USB-F')) | Should -BeFalse + } + + It 'ignores partial labels and volumes without a drive letter' { + $script:volumes = @( + [pscustomobject]@{ FileSystemLabel = 'OSDCloud-Data'; DriveLetter = 'F' } + [pscustomobject]@{ FileSystemLabel = 'NotOSDCloud'; DriveLetter = 'H' } + [pscustomobject]@{ FileSystemLabel = 'OSDCloud'; DriveLetter = $null } + ) + { New-WinpeStartupProfile -ProfileName 'NoMatch' } | Should -Throw '*No usable destinations*' + Should -Invoke Get-Partition -Times 0 -Exactly + } + + It 'excludes non-USB volumes' { + $script:disk.BusType = 'NVMe' + { New-WinpeStartupProfile -ProfileName 'LocalDisk' } | Should -Throw '*No usable destinations*' + Test-Path -LiteralPath ([System.IO.Path]::Combine($script:testRoot, 'USB-F')) | Should -BeFalse + } + + It 'excludes unavailable media: ' -ForEach @( + @{ Case = 'read-only disk'; ObjectName = 'disk'; Property = 'IsReadOnly' }, + @{ Case = 'offline disk'; ObjectName = 'disk'; Property = 'IsOffline' }, + @{ Case = 'read-only partition'; ObjectName = 'partition'; Property = 'IsReadOnly' }, + @{ Case = 'offline partition'; ObjectName = 'partition'; Property = 'IsOffline' } + ) { + (Get-Variable -Name $ObjectName -Scope Script -ValueOnly).$Property = $true + Mock Write-Warning {} + { New-WinpeStartupProfile -ProfileName 'Unavailable' } | Should -Throw '*No usable destinations*' + Should -Invoke Write-Warning -Times 1 -Exactly -ParameterFilter { $Message -match 'read-only OSDCloud USB volume' } + Test-Path -LiteralPath ([System.IO.Path]::Combine($script:testRoot, 'USB-F')) | Should -BeFalse + } + + It 'reports volume discovery failure but saves to Boot-Assets when available' { + $script:bootExists = $true + Mock Get-Volume { throw 'Storage discovery failed' } + $file = New-WinpeStartupProfile -ProfileName 'DiscoveryFailure' -PassThru -ErrorAction SilentlyContinue -ErrorVariable discoveryErrors + $file.FullName | Should -Be ([System.IO.Path]::Combine($script:bootRoot, 'DiscoveryFailure', 'winpestartup.json')) + $discoveryErrors.Exception.Message | Should -Contain 'Storage discovery failed' + } + + It 'reports Boot-Assets discovery failure but still saves to USB' { + Mock Test-Path { throw 'Boot-Assets discovery denied' } -ParameterFilter { $LiteralPath -eq $script:bootAssetsPath } + $file = New-WinpeStartupProfile -ProfileName 'BootDiscoveryFailure' -PassThru -ErrorAction SilentlyContinue -ErrorVariable discoveryErrors + $file.FullName | Should -Match '\\USB-F\\' + $discoveryErrors.Exception.Message | Should -Contain 'Boot-Assets discovery denied' + } + + It 'reports per-volume discovery failure and still processes the next volume' { + $script:volumes = @( + [pscustomobject]@{ FileSystemLabel = 'OSDCloud'; DriveLetter = 'F' } + [pscustomobject]@{ FileSystemLabel = 'OSDCloud'; DriveLetter = 'H' } + ) + Mock Get-Partition { throw 'Partition discovery failed' } -ParameterFilter { $DriveLetter -eq 'F' } + $file = New-WinpeStartupProfile -ProfileName 'PartitionFailure' -PassThru -ErrorAction SilentlyContinue -ErrorVariable discoveryErrors + $file.FullName | Should -Match '\\USB-H\\' + $discoveryErrors.Exception.Message | Should -Contain 'Partition discovery failed' + } + + It 'reports actual filesystem failure and still saves to another destination' { + $script:bootExists = $true + $null = New-Item -Path $script:bootRoot -ItemType Directory -Force + $null = New-Item -Path ([System.IO.Path]::Combine($script:bootRoot, 'Blocked')) -ItemType File + $file = New-WinpeStartupProfile -ProfileName 'Blocked' -PassThru -ErrorAction SilentlyContinue -ErrorVariable saveErrors + $file.FullName | Should -Match '\\USB-F\\' + @($saveErrors | Where-Object { $_.FullyQualifiedErrorId -like 'WinpeStartupProfileSaveFailed*' }).Count | Should -Be 1 + } + + It 'refuses overwrite and leaves existing content unchanged' { + $file = New-WinpeStartupProfile -ProfileName 'Existing' -SkipWiFi -PassThru + $original = Get-Content -LiteralPath $file.FullName -Raw + { New-WinpeStartupProfile -ProfileName 'Existing' -SkipIPConfig -ErrorAction Stop } | Should -Throw '*Use -Force*' + (Get-Content -LiteralPath $file.FullName -Raw) | Should -BeExactly $original + @(Get-ChildItem -LiteralPath $file.DirectoryName -Filter '*.tmp').Count | Should -Be 0 + Should -Invoke Start-Process -Times 1 -Exactly + } + + It 'replaces existing content with Force and removes temporary files' { + $file = New-WinpeStartupProfile -ProfileName 'Replace' -SkipWiFi -PassThru + New-WinpeStartupProfile -ProfileName 'Replace' -SkipIPConfig -Force + $savedSettings = Get-Content -LiteralPath $file.FullName -Raw | ConvertFrom-Json + @($savedSettings.PSObject.Properties).Count | Should -Be 17 + $savedSettings.'Invoke-WinpeStartup:SkipIPConfig' | Should -BeTrue + $savedSettings.'Invoke-WinpeStartup:SkipWiFi' | Should -BeFalse + @(Get-ChildItem -LiteralPath $file.DirectoryName -Filter '*.tmp').Count | Should -Be 0 + } + + It 'preserves the old file and cleans temporary files after a failed replacement' { + $file = New-WinpeStartupProfile -ProfileName 'Locked' -SkipWiFi -PassThru + $original = Get-Content -LiteralPath $file.FullName -Raw + $lock = [System.IO.File]::Open($file.FullName, 'Open', 'Read', 'None') + try { + { New-WinpeStartupProfile -ProfileName 'Locked' -SkipIPConfig -Force -ErrorAction Stop } | Should -Throw + } + finally { + $lock.Dispose() + } + (Get-Content -LiteralPath $file.FullName -Raw) | Should -BeExactly $original + @(Get-ChildItem -LiteralPath $file.DirectoryName -Filter '*.tmp').Count | Should -Be 0 + } + + It 'errors when no save succeeds and cleans temporary files after publication fails' { + $directory = [System.IO.Path]::Combine($script:testRoot, 'USB-F', 'WinpeStartup', 'Profiles', 'BlockedTarget') + $null = New-Item -Path ([System.IO.Path]::Combine($directory, 'winpestartup.json')) -ItemType Directory -Force + { New-WinpeStartupProfile -ProfileName 'BlockedTarget' -Force -ErrorAction SilentlyContinue } | Should -Throw '*No profile was saved*' + Test-Path -LiteralPath ([System.IO.Path]::Combine($directory, 'winpestartup.json')) -PathType Container | Should -BeTrue + @(Get-ChildItem -LiteralPath $directory -Filter '*.tmp').Count | Should -Be 0 + Should -Invoke Start-Process -Times 0 -Exactly + } + + It 'reports Notepad launch failure separately without losing the saved profile' { + Mock Start-Process { throw 'Notepad unavailable' } + $file = New-WinpeStartupProfile -ProfileName 'EditorFailure' -PassThru -ErrorAction SilentlyContinue -ErrorVariable editorErrors + $file.Exists | Should -BeTrue + @($editorErrors | Where-Object { $_.FullyQualifiedErrorId -like 'WinpeStartupProfileEditorFailed*' }).Count | Should -Be 1 + $editorErrors.Exception.Message | Should -Contain 'Notepad unavailable' + (Get-Content -LiteralPath $file.FullName -Raw | ConvertFrom-Json).'Invoke-WinpeStartup:InvokeMainCommand' | + Should -Be @('Show-OSDCloudDeviceInfo', 'Deploy-OSDCloud') + } + + It 'does not write or open an editor if bundled defaults cannot be read' { + Mock Get-Content { throw 'Bundled profile unavailable' } -ParameterFilter { $LiteralPath -like '*winpestartup-profiles\OSDCloud\winpestartup.json' } + { New-WinpeStartupProfile -ProfileName 'MissingDefaults' } | Should -Throw '*Bundled profile unavailable*' + Should -Invoke Get-Volume -Times 0 -Exactly + Should -Invoke Start-Process -Times 0 -Exactly + } + + It 'does not mutate any destination with WhatIf, even with Force and missing elevation' { + $script:bootExists = $true + $script:elevated = $false + @(New-WinpeStartupProfile -ProfileName 'Preview' -SkipWiFi -Force -PassThru -WhatIf).Count | Should -Be 0 + Test-Path -LiteralPath $script:bootRoot | Should -BeFalse + Test-Path -LiteralPath ([System.IO.Path]::Combine($script:testRoot, 'USB-F')) | Should -BeFalse + Should -Invoke New-Object -Times 0 -Exactly -ParameterFilter { $TypeName -eq 'System.Security.Principal.WindowsPrincipal' } + Should -Invoke Start-Process -Times 0 -Exactly + } + + It 'does not replace an existing profile under WhatIf' { + $file = New-WinpeStartupProfile -ProfileName 'PreviewExisting' -SkipWiFi -PassThru + $original = Get-Content -LiteralPath $file.FullName -Raw + New-WinpeStartupProfile -ProfileName 'PreviewExisting' -SkipIPConfig -Force -WhatIf + (Get-Content -LiteralPath $file.FullName -Raw) | Should -BeExactly $original + @(Get-ChildItem -LiteralPath $file.DirectoryName -Filter '*.tmp').Count | Should -Be 0 + Should -Invoke Start-Process -Times 1 -Exactly + } + + It 'does not create files when confirmation is declined, even with Force' { + $decliningHost = [WinpeStartupProfileTests.DecliningHost]::new($Host) + $runspace = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace($decliningHost) + $runspace.Open() + $session = [powershell]::Create() + $session.Runspace = $runspace + try { + $null = $session.AddScript({ + param($FunctionPath, $DestinationRoot) + . $FunctionPath + $script:destinationRoot = $DestinationRoot + function Test-Path { + [CmdletBinding()] + param($LiteralPath, $PathType) + $true + } + function Get-Volume { + [CmdletBinding()] + param() + @() + } + function Join-Path { + param($Path, $ChildPath) + if ($Path -eq 'C:\ProgramData\OSDeployCore\boot-assets\winpestartup-profiles') { + $Path = $script:destinationRoot + } + [System.IO.Path]::Combine($Path, $ChildPath) + } + New-WinpeStartupProfile -ProfileName 'Declined' -Force -PassThru -Confirm + }).AddArgument("$PSScriptRoot\New-WinpeStartupProfile.ps1").AddArgument($script:bootRoot) + $result = @($session.Invoke()) + $session.HadErrors | Should -BeFalse -Because (($session.Streams.Error | Out-String).Trim()) + $decliningHost.PromptCount | Should -Be 1 + $result.Count | Should -Be 0 + [System.IO.Directory]::Exists($script:bootRoot) | Should -BeFalse + } + finally { + $session.Dispose() + $runspace.Dispose() + } + } + + It 'documents every declared authoring parameter' { + $help = Get-Help New-WinpeStartupProfile -Full + $common = @([System.Management.Automation.PSCmdlet]::CommonParameters + [System.Management.Automation.PSCmdlet]::OptionalCommonParameters) + foreach ($name in (Get-Command New-WinpeStartupProfile).Parameters.Keys | Where-Object { $_ -notin $common }) { + $help.Parameters.Parameter.Name | Should -Contain $name + } + @($help.Examples.Example).Count | Should -BeGreaterOrEqual 2 + } + + It 'registers the export in the manifest and loads the file in both environments' { + $moduleRoot = [System.IO.Path]::GetFullPath("$PSScriptRoot\..\..") + $manifest = Import-PowerShellDataFile -LiteralPath "$moduleRoot\OSDCloud.psd1" + $manifest.FunctionsToExport | Should -Contain 'New-WinpeStartupProfile' + foreach ($systemDrive in @('C:', 'X:')) { + $session = [powershell]::Create() + try { + $null = $session.AddScript({ + param($ModuleRoot, $SystemDrive) + $originalSystemDrive = $env:SystemDrive + try { + $env:SystemDrive = $SystemDrive + Import-Module "$ModuleRoot\OSDCloud.psd1" -Force -ErrorAction Stop + Get-Command New-WinpeStartupProfile -Module OSDCloud -ErrorAction Stop | Select-Object -ExpandProperty Name + } + finally { + $env:SystemDrive = $originalSystemDrive + } + }).AddArgument($moduleRoot).AddArgument($systemDrive) + $result = $session.Invoke() + $session.HadErrors | Should -BeFalse -Because (($session.Streams.Error | Out-String).Trim()) + $result | Should -Contain 'New-WinpeStartupProfile' + } + finally { + $session.Dispose() + } + } + } +} diff --git a/OSDCloud/public/WinOS/New-WinpeStartupProfile.ps1 b/OSDCloud/public/WinOS/New-WinpeStartupProfile.ps1 new file mode 100644 index 0000000..2266cb1 --- /dev/null +++ b/OSDCloud/public/WinOS/New-WinpeStartupProfile.ps1 @@ -0,0 +1,447 @@ +#requires -Version 5.1 + +function New-WinpeStartupProfile { + <# + .SYNOPSIS + Creates a WinpeStartup profile for OSDCloud + + .DESCRIPTION + Saves \winpestartup.json under the existing + C:\ProgramData\OSDeployCore\boot-assets\winpestartup-profiles folder and + WinpeStartup\Profiles on each mounted, writable USB volume labeled OSDCloud. + Uses the actual USB drive letters. Administrator rights are required only + for the Boot-Assets destination; USB destinations are still attempted when + the session is not elevated. + + Writes all 16 startup settings with Invoke-WinpeStartup: prefixes and an + unprefixed Environment object. Defaults come from the module's + PSDefaultParameterValues.json, overlaid by the bundled OSDCloud + winpestartup.json. Supplied parameters override those defaults, including + explicit false switches and empty arrays. A supplied Environment + hashtable replaces the bundled environment values. Environment is first + in the JSON, followed by display/skip settings, modules, and the startup, + main, and shutdown command groups. WINPESTARTUP_AUTHOR is always the + current username and WINPESTARTUP_PROFILE is always ProfileName. + + Existing files require Force for replacement. Destination errors are + reported independently and remaining destinations are attempted unless + ErrorAction Stop is requested. Successful saves are not rolled back. + No usable destinations or no successful saves produces a terminating error. + WhatIf and Confirm gate all filesystem changes. Stored commands are never + executed, and environment variables are not set during profile creation. + Each successfully saved file opens in Notepad for editing without waiting + for the editor to close. Copies are independent; edits are not synchronized. + Editor failures are reported separately and do not undo saved files. + + .PARAMETER ProfileName + A single Windows folder name. Paths, invalid filename characters, reserved + device names, and names ending in a space or period are not accepted. + + .PARAMETER SkipOnScreenKeyboard + Stores whether Invoke-WinpeStartup skips the on-screen keyboard check. + + .PARAMETER ShowPnpDevices + Stores whether Invoke-WinpeStartup displays Plug and Play hardware. + + .PARAMETER ShowPnpErrors + Stores whether Invoke-WinpeStartup displays Plug and Play errors. + + .PARAMETER SkipWiFi + Stores whether Invoke-WinpeStartup skips Wi-Fi startup and connection checks. + + .PARAMETER SkipIPConfig + Stores whether Invoke-WinpeStartup skips IP configuration display. + + .PARAMETER SkipUpdateOSDCloud + Stores whether Invoke-WinpeStartup skips updating the OSDCloud module. + + .PARAMETER InstallModule + Additional module names to update. An empty array clears inherited modules. + Entries must be non-empty strings. + + .PARAMETER InvokeStartupCommand + Ordered commands or URLs for the startup phase. HTTP/HTTPS URLs are + interpreted by Invoke-WinpeStartup, not downloaded by this cmdlet. + An empty array clears inherited commands. Entries must be non-empty strings. + + .PARAMETER InvokeStartupCommandNoExit + Stores whether the startup child PowerShell remains open. The runtime + waits for that process to close. Use :$false to explicitly disable it. + + .PARAMETER InvokeStartupCommandEA + Stores Continue (warn and proceed) or Stop (terminate startup) for startup + child-process failures. If omitted, uses the bundled default. + + .PARAMETER InvokeMainCommand + Ordered commands or URLs for the main phase. An empty array clears inherited + commands. Entries must be non-empty strings. Commands are not executed here. + + .PARAMETER InvokeMainCommandNoExit + Stores whether the main child PowerShell remains open. Use :$false to + explicitly disable it. + + .PARAMETER InvokeMainCommandEA + Stores Continue or Stop for main child-process failures. + If omitted, uses the bundled default. + + .PARAMETER InvokeShutdownCommand + Ordered commands or URLs for the shutdown phase. An empty array clears + inherited commands. Entries must be non-empty strings. + + .PARAMETER InvokeShutdownCommandNoExit + Stores whether the shutdown child PowerShell remains open. Use :$false to + explicitly disable it. + + .PARAMETER InvokeShutdownCommandEA + Stores Continue or Stop for shutdown child-process failures. + If omitted, uses the bundled default. + + .PARAMETER Environment + A hashtable of process environment variables for the selected profile. + Names must be non-empty strings without equals signs or null characters. + Values must be strings, finite numbers, or booleans. Nulls, arrays, and + nested objects are not accepted. Values are stored in plaintext; do not + include secrets. An empty hashtable removes bundled environment values + except the generated WINPESTARTUP_AUTHOR and WINPESTARTUP_PROFILE metadata. + These two values cannot be overridden through this parameter. + + .PARAMETER Force + Allows replacing existing profile files. Does not bypass permissions, + WhatIf, or Confirm. + + .PARAMETER PassThru + Returns a FileInfo object for each successfully saved profile. + + .EXAMPLE + PS> New-WinpeStartupProfile -ProfileName 'Deploy-And-Restart' -InvokeMainCommand 'Deploy-OSDCloud' -InvokeMainCommandEA Stop -InvokeShutdownCommand 'Restart-Computer -Force' -PassThru + + Saves a deployment and restart profile to all available destinations. + Run elevated to include the existing Boot-Assets destination. + + .EXAMPLE + PS> New-WinpeStartupProfile -ProfileName 'BranchOffice' -SkipWiFi:$false -InstallModule @() -Environment @{ OSDCLOUD_SITE = 'BranchOffice' } -Force -WhatIf + + Previews replacement without writing files. The profile explicitly enables + Wi-Fi checks, clears inherited additional modules, and sets a site variable. + + .EXAMPLE + PS> New-WinpeStartupProfile -ProfileName 'Runtime-Defaults' + + Creates a complete profile using the bundled OSDCloud defaults and opens + each saved copy in Notepad. + + .INPUTS + None. This function does not accept pipeline input. + + .OUTPUTS + System.IO.FileInfo. Returned only when PassThru is supplied, once per save. + + .NOTES + Author: David Segura + Company: Recast Software + Version: 1.0.0 + Date: 2026-10-13 + Requires Windows PowerShell 5.1 or PowerShell on Windows and Storage + cmdlets for USB discovery, and notepad.exe for editing. + Does not create the shared Boot-Assets root. + + .LINK + Invoke-WinpeStartup + #> + [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] + [OutputType([System.IO.FileInfo])] + param ( + [Parameter(Mandatory = $true)] + [ValidateScript({ + if ([string]::IsNullOrWhiteSpace($_) -or $_.Length -gt 255 -or + $_ -match '[<>:"/\\|?*\x00-\x1f]' -or + $_ -match '[ .]$' -or + $_ -match '^(?i:CON|CONIN\$|CONOUT\$|PRN|AUX|NUL|COM[1-9\u00b9\u00b2\u00b3]|LPT[1-9\u00b9\u00b2\u00b3])(?:\.|$)') { + throw 'ProfileName must be a single valid Windows folder name, not a path or reserved device name.' + } + $true + })] + [string]$ProfileName, + + [Parameter()] + [switch]$SkipOnScreenKeyboard, + [Parameter()] + [switch]$ShowPnpDevices, + [Parameter()] + [switch]$ShowPnpErrors, + [Parameter()] + [switch]$SkipWiFi, + [Parameter()] + [switch]$SkipIPConfig, + [Parameter()] + [switch]$SkipUpdateOSDCloud, + [Parameter()] + [AllowEmptyCollection()] + [string[]]$InstallModule, + [Parameter()] + [AllowEmptyCollection()] + [string[]]$InvokeStartupCommand, + [Parameter()] + [switch]$InvokeStartupCommandNoExit, + [Parameter()] + [ValidateSet('Continue', 'Stop')] + [string]$InvokeStartupCommandEA, + [Parameter()] + [AllowEmptyCollection()] + [string[]]$InvokeMainCommand, + [Parameter()] + [switch]$InvokeMainCommandNoExit, + [Parameter()] + [ValidateSet('Continue', 'Stop')] + [string]$InvokeMainCommandEA, + [Parameter()] + [AllowEmptyCollection()] + [string[]]$InvokeShutdownCommand, + [Parameter()] + [switch]$InvokeShutdownCommandNoExit, + [Parameter()] + [ValidateSet('Continue', 'Stop')] + [string]$InvokeShutdownCommandEA, + [Parameter()] + [ValidateNotNull()] + [hashtable]$Environment, + [Parameter()] + [switch]$Force, + [Parameter()] + [switch]$PassThru + ) + + Write-Verbose "[$($MyInvocation.MyCommand.Name)] Start" + $profileSettings = [ordered]@{ Environment = $null } + $arrayParameters = @('InstallModule', 'InvokeStartupCommand', 'InvokeMainCommand', 'InvokeShutdownCommand') + $switchParameters = @( + 'SkipOnScreenKeyboard', 'ShowPnpDevices', 'ShowPnpErrors', 'SkipWiFi', + 'SkipIPConfig', 'SkipUpdateOSDCloud', 'InvokeStartupCommandNoExit', + 'InvokeMainCommandNoExit', 'InvokeShutdownCommandNoExit' + ) + $moduleDefaultsPath = Join-Path -Path $PSScriptRoot -ChildPath '..\..\core\PSDefaultParameterValues.json' + $bundledProfilePath = Join-Path -Path $PSScriptRoot -ChildPath '..\..\core\winpestartup-profiles\OSDCloud\winpestartup.json' + $moduleDefaults = Get-Content -LiteralPath $moduleDefaultsPath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop + $bundledProfile = Get-Content -LiteralPath $bundledProfilePath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop + if ($moduleDefaults -isnot [pscustomobject] -or $bundledProfile -isnot [pscustomobject]) { + throw 'New-WinpeStartupProfile: Module defaults and the bundled OSDCloud profile must be JSON objects.' + } + $orderedParameters = @( + 'ShowPnpDevices', 'ShowPnpErrors', 'SkipOnScreenKeyboard', + 'SkipWiFi', 'SkipIPConfig', 'SkipUpdateOSDCloud', 'InstallModule', + 'InvokeStartupCommand', 'InvokeStartupCommandEA', 'InvokeStartupCommandNoExit', + 'InvokeMainCommand', 'InvokeMainCommandEA', 'InvokeMainCommandNoExit', + 'InvokeShutdownCommand', 'InvokeShutdownCommandEA', 'InvokeShutdownCommandNoExit' + ) + foreach ($parameterName in $orderedParameters) { + $propertyName = "Invoke-WinpeStartup:$parameterName" + if ($PSBoundParameters.ContainsKey($parameterName)) { + $value = $PSBoundParameters[$parameterName] + } + elseif ($bundledProfile.PSObject.Properties[$propertyName]) { + $value = $bundledProfile.$propertyName + } + elseif ($moduleDefaults.PSObject.Properties[$propertyName]) { + $value = $moduleDefaults.$propertyName + } + else { + throw "New-WinpeStartupProfile: Missing bundled default for '$parameterName'." + } + + if ($parameterName -in $arrayParameters) { + if ($null -eq $value -or @($value | Where-Object { [string]::IsNullOrWhiteSpace($_) }).Count -gt 0) { + throw "$parameterName must be an array of non-empty strings; use @() to clear inherited values." + } + $profileSettings["Invoke-WinpeStartup:$parameterName"] = [string[]]$value + } + elseif ($parameterName -in $switchParameters) { + if ($value -isnot [bool] -and $value -isnot [System.Management.Automation.SwitchParameter]) { + throw "New-WinpeStartupProfile: '$parameterName' must have a boolean default." + } + $profileSettings[$propertyName] = [bool]$value + } + else { + if ($value -notin @('Continue', 'Stop')) { + throw "New-WinpeStartupProfile: '$parameterName' must be Continue or Stop." + } + $profileSettings[$propertyName] = if ($value -ieq 'Stop') { 'Stop' } else { 'Continue' } + } + } + + if (-not $PSBoundParameters.ContainsKey('Environment')) { + $Environment = @{} + $bundledEnvironment = $bundledProfile.Environment + if ($bundledProfile.PSObject.Properties['env']) { + $bundledEnvironment = $bundledProfile.env + } + if ($null -ne $bundledEnvironment) { + if ($bundledEnvironment -isnot [pscustomobject]) { + throw 'New-WinpeStartupProfile: The bundled environment must be a JSON object.' + } + foreach ($property in $bundledEnvironment.PSObject.Properties) { + $Environment[$property.Name] = $property.Value + } + } + } + if ($null -ne $Environment) { + foreach ($entry in $Environment.GetEnumerator()) { + if ($entry.Key -isnot [string] -or [string]::IsNullOrWhiteSpace($entry.Key) -or + $entry.Key.Contains('=') -or $entry.Key.Contains([char]0)) { + throw 'Environment variable names must be non-empty strings without equals signs or null characters.' + } + $typeCode = if ($null -ne $entry.Value) { [System.Type]::GetTypeCode($entry.Value.GetType()) } else { [System.TypeCode]::Empty } + if ($typeCode -notin @( + [System.TypeCode]::String, [System.TypeCode]::Boolean, + [System.TypeCode]::Byte, [System.TypeCode]::SByte, + [System.TypeCode]::Int16, [System.TypeCode]::UInt16, + [System.TypeCode]::Int32, [System.TypeCode]::UInt32, + [System.TypeCode]::Int64, [System.TypeCode]::UInt64, + [System.TypeCode]::Single, [System.TypeCode]::Double, [System.TypeCode]::Decimal + ) -or $entry.Value -is [System.Enum] -or + (($entry.Value -is [double] -or $entry.Value -is [single]) -and + ([double]::IsNaN($entry.Value) -or [double]::IsInfinity($entry.Value)))) { + throw "Environment variable '$($entry.Key)' must have a string, finite numeric, or boolean value." + } + } + $profileEnvironment = [ordered]@{ + WINPESTARTUP_AUTHOR = [System.Environment]::UserName + WINPESTARTUP_PROFILE = $ProfileName + } + foreach ($entry in $Environment.GetEnumerator() | Sort-Object -Property Key) { + if ($entry.Key -notin @('WINPESTARTUP_AUTHOR', 'WINPESTARTUP_PROFILE')) { + $profileEnvironment[$entry.Key] = $entry.Value + } + } + $profileSettings['Environment'] = $profileEnvironment + } + $json = ConvertTo-Json -InputObject $profileSettings -Depth 4 -ErrorAction Stop + + $destinations = [System.Collections.Generic.List[object]]::new() + $bootAssetsRoot = 'C:\ProgramData\OSDeployCore\boot-assets\winpestartup-profiles' + try { + if (Test-Path -LiteralPath $bootAssetsRoot -PathType Container -ErrorAction Stop) { + $destinations.Add([pscustomobject]@{ Root = $bootAssetsRoot; RequiresAdministrator = $true }) + } + } + catch { + $PSCmdlet.WriteError($_) + } + $volumes = @() + try { + $volumes = @(Get-Volume -ErrorAction Stop | Where-Object { $_.FileSystemLabel -eq 'OSDCloud' -and $_.DriveLetter }) + } + catch { + $PSCmdlet.WriteError($_) + } + foreach ($volume in $volumes) { + try { + $partition = Get-Partition -DriveLetter $volume.DriveLetter -ErrorAction Stop + $disk = Get-Disk -Number $partition.DiskNumber -ErrorAction Stop + if ($disk.BusType -ne 'USB') { + continue + } + if ($disk.IsReadOnly -or $disk.IsOffline -or $partition.IsReadOnly -or $partition.IsOffline) { + Write-Warning "[$($MyInvocation.MyCommand.Name)] Skipping unavailable or read-only OSDCloud USB volume '$($volume.DriveLetter):'." + continue + } + $destinations.Add([pscustomobject]@{ + Root = Join-Path -Path "$($volume.DriveLetter):\" -ChildPath 'WinpeStartup\Profiles' + RequiresAdministrator = $false + }) + } + catch { + $PSCmdlet.WriteError($_) + } + } + + if ($destinations.Count -eq 0) { + throw 'New-WinpeStartupProfile: No usable destinations found. The Boot-Assets folder must exist or a writable USB volume labeled OSDCloud must be mounted.' + } + + $savedCount = 0 + $attemptedCount = 0 + foreach ($destination in $destinations) { + $profileDirectory = Join-Path -Path $destination.Root -ChildPath $ProfileName + $profilePath = Join-Path -Path $profileDirectory -ChildPath 'winpestartup.json' + if (-not $PSCmdlet.ShouldProcess($profilePath, 'Save WinpeStartup profile')) { + continue + } + $attemptedCount++ + $temporaryPath = $null + $profileSaved = $false + try { + if ($destination.RequiresAdministrator) { + $principal = New-Object -TypeName System.Security.Principal.WindowsPrincipal -ArgumentList ([System.Security.Principal.WindowsIdentity]::GetCurrent()) + if (-not $principal.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw [System.UnauthorizedAccessException]::new("Administrator rights are required to save '$profilePath'. Run PowerShell as administrator.") + } + } + if ([System.IO.File]::Exists($profilePath) -and -not $Force) { + throw [System.IO.IOException]::new("Profile '$profilePath' already exists. Use -Force to replace it.") + } + + $null = [System.IO.Directory]::CreateDirectory($profileDirectory) + $candidatePath = Join-Path -Path $profileDirectory -ChildPath "$([guid]::NewGuid().ToString('N')).tmp" + $stream = [System.IO.File]::Open($candidatePath, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None) + $temporaryPath = $candidatePath + try { + # A BOM lets Windows PowerShell 5.1 read non-ASCII commands without guessing ANSI. + $encoding = [System.Text.UTF8Encoding]::new($true) + $preamble = $encoding.GetPreamble() + $stream.Write($preamble, 0, $preamble.Length) + $bytes = $encoding.GetBytes($json + [System.Environment]::NewLine) + $stream.Write($bytes, 0, $bytes.Length) + } + finally { + $stream.Dispose() + } + + # Move refuses concurrent creation; Replace preserves the old file if publication fails. + if ($Force -and [System.IO.File]::Exists($profilePath)) { + [System.IO.File]::Replace($temporaryPath, $profilePath, [System.Management.Automation.Language.NullString]::Value) + } + else { + [System.IO.File]::Move($temporaryPath, $profilePath) + } + $savedCount++ + $profileSaved = $true + Write-Verbose "[$($MyInvocation.MyCommand.Name)] Saved '$profilePath'." + if ($PassThru) { + Get-Item -LiteralPath $profilePath -ErrorAction Stop + } + } + catch { + $errorRecord = [System.Management.Automation.ErrorRecord]::new( + $_.Exception, 'WinpeStartupProfileSaveFailed', + [System.Management.Automation.ErrorCategory]::WriteError, $profilePath + ) + $PSCmdlet.WriteError($errorRecord) + } + finally { + if ($temporaryPath -and [System.IO.File]::Exists($temporaryPath)) { + try { + [System.IO.File]::Delete($temporaryPath) + } + catch { + $PSCmdlet.WriteError($_) + } + } + } + if ($profileSaved) { + try { + Start-Process -FilePath 'notepad.exe' -ArgumentList "`"$profilePath`"" -ErrorAction Stop + } + catch { + $errorRecord = [System.Management.Automation.ErrorRecord]::new( + $_.Exception, 'WinpeStartupProfileEditorFailed', + [System.Management.Automation.ErrorCategory]::OpenError, $profilePath + ) + $PSCmdlet.WriteError($errorRecord) + } + } + } + if ($attemptedCount -gt 0 -and $savedCount -eq 0) { + throw "New-WinpeStartupProfile: No profile was saved to any of the $attemptedCount attempted destinations." + } + Write-Verbose "[$($MyInvocation.MyCommand.Name)] End" +} diff --git a/README.md b/README.md index 3896b69..ad10207 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.10.1`. +- Current repository module version: `26.10.13.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,11 @@ OSDCloud is a PowerShell module for deploying Windows with cloud-hosted operatin --- -## What's New in 26.10.10.1 +## What's New in 26.10.13.1 -Released October 10, 2026. +Released October 13, 2026. -- **Expanded device information:** `Show-OSDCloudDeviceInfo` displays device and BIOS details, processor and memory configuration, disk drives, and network adapters with IPv4 addresses. -- **Richer hardware inventory:** OSDCore collects physical memory module details and includes IPv4 addresses in network adapter inventory; memory below 6 GB is flagged with a warning. -- **Clearer time-sync reporting:** Status messages now identify the clock as "Device DateTime." +- **Create WinpeStartup profiles:** `New-WinpeStartupProfile` authors named startup profiles in existing Boot-Assets folders and writable OSDCloud USB volumes, using bundled defaults and safe replacement options. See [CHANGELOG.md](CHANGELOG.md) for the complete release history and [WinpeStartup documentation](docs/about_winpestartup.md) for startup configuration. @@ -62,6 +60,7 @@ Deploy-OSDCloud | `Deploy-OSDCloud` | Starts an OS deployment workflow (GUI or CLI mode). | | `Get-OSDCloudModulePath` | Returns the module installation directory. | | `Get-OSDCloudModuleVersion` | Returns the loaded module version. | +| `New-WinpeStartupProfile` | Creates startup profile JSON in existing Boot-Assets and on writable OSDCloud USB volumes. | | `Show-OSDCloudDeviceInfo` | Displays device hardware and environment information. | | `Start-OSDCloudExplorer` | Opens a WinForms file browser (useful in WinPE/WinRE). | diff --git a/docs/about_winpestartup.md b/docs/about_winpestartup.md index ee6e38d..c038e73 100644 --- a/docs/about_winpestartup.md +++ b/docs/about_winpestartup.md @@ -87,6 +87,95 @@ If exactly one profile is found, OSDCloud selects it automatically. If several p Explicit parameters passed to `Invoke-WinpeStartup` take precedence over JSON values. That rule is important: a profile can supply defaults, but a real command-line choice from the operator should not be silently overridden. +## Creating profiles with PowerShell + +`New-WinpeStartupProfile` is available in both full Windows and WinPE. It creates +`\winpestartup.json` in **both** available destination types: + +- `C:\ProgramData\OSDeployCore\boot-assets\winpestartup-profiles`, only if this + shared Boot-Assets folder already exists. Run PowerShell as administrator to + write here. The cmdlet does not create the shared root. +- `:\WinpeStartup\Profiles` on each mounted, writable USB volume + with the exact label `OSDCloud`. The actual drive letter is detected; it is not + required to be `F:`. USB-only saves do not require elevation. + +Missing destination subfolders are created. `ProfileName` must be a single valid +Windows folder name, not a path or reserved device name. + +```powershell +New-WinpeStartupProfile -ProfileName 'Deploy-And-Restart' ` + -InvokeMainCommand 'Show-OSDCloudDeviceInfo', 'Deploy-OSDCloud' ` + -InvokeMainCommandEA Stop ` + -InvokeShutdownCommand 'Restart-Computer -Force' ` + -PassThru +``` + +All 16 settings accepted by `Invoke-WinpeStartup` are available as parameters, +including the three command phases, their `NoExit` and `EA` settings, additional +modules, and skip/display switches. Every generated JSON includes all 16 settings +with `Invoke-WinpeStartup:` prefixes and an `Environment` object. Defaults are +loaded from the module's `PSDefaultParameterValues.json`, then overlaid with the +bundled OSDCloud `winpestartup.json`. This includes the default main commands +`Show-OSDCloudDeviceInfo` and `Deploy-OSDCloud`, main `NoExit` enabled, and the +generated author/profile environment values. Supplied parameters override these +defaults; explicit false switches and empty arrays are preserved: + +```powershell +New-WinpeStartupProfile -ProfileName 'BranchOffice' ` + -SkipWiFi:$false -InstallModule @() ` + -Environment @{ OSDCLOUD_SITE = 'BranchOffice'; OSDCLOUD_DEPLOYMENT_RING = 2 } +``` + +`Environment` is stored as an unprefixed object. It accepts non-empty variable +names without `=` or null characters and string, finite numeric, or boolean +values. Nulls, arrays, and nested objects are rejected before any files are +written. `Environment` appears first in the JSON, followed by display/skip settings, +modules, and the startup, main, and shutdown groups. Each command group is ordered +as command, error action, and NoExit. + +`WINPESTARTUP_AUTHOR` is always the current username running the cmdlet, and +`WINPESTARTUP_PROFILE` is always `ProfileName`. These two properties appear first +inside `Environment` and cannot be overridden through `-Environment`. A supplied +hashtable replaces other bundled environment values; `-Environment @{}` retains +only the generated metadata. The caller's hashtable is not modified. +Creation never changes the author's environment or executes stored +commands. Profiles are plaintext: do not store secrets. + +To create a complete editable profile using the bundled OSDCloud defaults, +supply only its name: + +```powershell +New-WinpeStartupProfile -ProfileName 'Runtime-Defaults' +``` + +All configurable properties are written, including false booleans and empty +arrays. An explicitly empty command or module array clears that default collection. +Unlike an omitted property in a hand-authored profile, generated properties +explicitly override future runtime defaults. + +Each successfully written copy opens in Notepad for editing, without waiting +for the editor to close. If multiple destinations were saved, each copy opens +separately; edits are not synchronized between copies. A Notepad launch failure +is reported as an error but does not remove the saved profile. + +Existing profiles are not replaced unless `-Force` is supplied. Use `-WhatIf` +to preview destinations without creating directories, writing files, or opening +Notepad, or +`-Confirm` to approve each destination. `-Force` does not bypass these controls +or filesystem permissions. + +```powershell +New-WinpeStartupProfile -ProfileName 'BranchOffice' -SkipWiFi -Force -WhatIf +``` + +Destination failures are reported as errors and other destinations are still +attempted by default, including USB saves when Boot-Assets cannot be written +without elevation. Successful saves are not rolled back if another destination +fails. `-ErrorAction Stop` stops on the first reported error. No usable +destinations, or failure of every attempted save, produces a terminating error. +Without `-PassThru` there is no success-stream output; with it, each successful +save returns a `System.IO.FileInfo`. + ## Command hooks `Invoke-WinpeStartup` supports three command phases: