private/New-WinPEStartupProfile.ps1
|
#requires -Version 5.1 function New-WinPEStartupProfile { <# .SYNOPSIS Creates a WinPEStartup profile interactively .DESCRIPTION Guides the user through settings consumed by Invoke-WinPEStartup and writes a flat JSON profile to the OSDeploy Boot-Assets winpestartup-profiles directory. Boolean, error-action, module, and startup, main, and shutdown command settings can inherit OSDCloud defaults or explicitly replace them. Module and command collections can also clear inherited values with an empty array. The function requires an interactive console, validates the profile name and generated JSON, displays the JSON before writing, and prompts before replacing an existing profile unless Force is specified. Profile commands are stored but are never executed by this function. No file is created when every setting is inherited, replacement is declined, or ShouldProcess declines the write. .PARAMETER Name Specifies the profile filename at position 0. The .json extension is added when omitted and removed before name validation when supplied. An omitted, empty, or invalid value causes the function to prompt until a valid filename is entered. .PARAMETER Force Bypasses the additional ShouldContinue prompt for replacing an existing profile. It does not bypass the ShouldProcess operation used by Confirm and WhatIf. .EXAMPLE PS> New-WinPEStartupProfile Opens the complete profile wizard and prompts for a profile name. .EXAMPLE PS> New-WinPEStartupProfile -Name 'BranchOffice' Opens the profile wizard and saves the result as BranchOffice.json. .INPUTS None. This function does not accept pipeline input. .OUTPUTS System.IO.FileInfo. Returns the created profile file. Returns no object when no file is created. .NOTES Author: David Segura Company: Recast Software Version: 1.0.0 Date: 2026-08-28 Requires an interactive console and an initialized OSDeploy Boot-Assets path. .LINK Invoke-WinPEStartup #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] [OutputType([System.IO.FileInfo])] param ( [Parameter(Position = 0)] [AllowEmptyString()] [string] $Name, [Parameter()] [switch] $Force ) $Error.Clear() Write-HostOSDeployBanner Write-Verbose "[$($MyInvocation.MyCommand.Name)] Start" try { if ([System.Console]::IsInputRedirected -or [System.Console]::IsOutputRedirected) { Write-Warning "[$(Get-Date -Format s)] An interactive console is required to create a WinPEStartup Profile." return } } catch { Write-Verbose "[$($MyInvocation.MyCommand.Name)] Console redirection state could not be determined." } $promptChoice = { param ( [string]$Prompt, [object[]]$Choices, [string]$DefaultKey ) Write-Host '' Write-Host -ForegroundColor Cyan $Prompt foreach ($choice in $Choices) { Write-Host (' [{0}] {1}' -f $choice.Key, $choice.Label) } while ($true) { $selection = Read-Host "Select an option [$DefaultKey]" if ([string]::IsNullOrWhiteSpace($selection)) { return $DefaultKey } $selection = $selection.Trim() $matchedChoice = $Choices | Where-Object { $_.Key -eq $selection -or $_.Label -eq $selection } | Select-Object -First 1 if ($matchedChoice) { return [string]$matchedChoice.Key } Write-Warning "[$(Get-Date -Format s)] Invalid selection '$selection'." } } $collectValues = { param ( [string]$Prompt, [string]$Example ) $values = @() Write-Host '' Write-Host -ForegroundColor Cyan $Prompt if ($Example) { Write-Host -ForegroundColor DarkGray "Example: $Example" } Write-Host -ForegroundColor DarkGray 'Enter one value per line. Press Enter on a blank line when finished.' while ($true) { $value = Read-Host 'Value' if ([string]::IsNullOrWhiteSpace($value)) { break } $values += $value.Trim() } return $values } $inheritBooleanChoices = @( [pscustomobject]@{ Key = 'I'; Label = 'Inherit OSDCloud default' } [pscustomobject]@{ Key = 'T'; Label = 'True' } [pscustomobject]@{ Key = 'F'; Label = 'False' } ) $collectionChoices = @( [pscustomobject]@{ Key = 'I'; Label = 'Inherit OSDCloud default' } [pscustomobject]@{ Key = 'R'; Label = 'Replace with entered values' } [pscustomobject]@{ Key = 'C'; Label = 'Clear inherited values' } ) $errorActionChoices = @( [pscustomobject]@{ Key = 'I'; Label = 'Inherit OSDCloud default' } [pscustomobject]@{ Key = 'C'; Label = 'Continue' } [pscustomobject]@{ Key = 'S'; Label = 'Stop' } ) $profileDirectory = Join-Path $script:OSDeployBootAssetsPath 'winpestartup-profiles' $profileName = $Name while ($true) { if ([string]::IsNullOrWhiteSpace($profileName)) { $profileName = Read-Host 'Enter a WinPEStartup Profile name' } $profileName = $profileName.Trim() if ($profileName.EndsWith('.json', [System.StringComparison]::OrdinalIgnoreCase)) { $profileName = $profileName.Substring(0, $profileName.Length - 5) } $invalidName = [string]::IsNullOrWhiteSpace($profileName) -or $profileName -in @('.', '..') -or [System.IO.Path]::IsPathRooted($profileName) -or $profileName.IndexOfAny([System.IO.Path]::GetInvalidFileNameChars()) -ge 0 -or $profileName.Contains('\') -or $profileName.Contains('/') if (-not $invalidName) { break } Write-Warning "[$(Get-Date -Format s)] '$profileName' is not a valid profile name." $profileName = $null } $profilePath = Join-Path $profileDirectory ($profileName + '.json') $resolvedDirectory = [System.IO.Path]::GetFullPath($profileDirectory).TrimEnd([char[]]@('\', '/')) $resolvedParent = [System.IO.Path]::GetDirectoryName([System.IO.Path]::GetFullPath($profilePath)).TrimEnd([char[]]@('\', '/')) if (-not $resolvedParent.Equals($resolvedDirectory, [System.StringComparison]::OrdinalIgnoreCase)) { throw "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] The profile path must remain under '$profileDirectory'." } if ((Test-Path -LiteralPath $profilePath -PathType Leaf) -and -not $Force -and -not $WhatIfPreference) { $confirmCaption = "Replace WinPEStartup Profile - $profileName" $confirmMessage = "Path : $profilePath`n`nReplace the existing WinPEStartup Profile?" if (-not $PSCmdlet.ShouldContinue($confirmMessage, $confirmCaption)) { Write-Verbose "[$($MyInvocation.MyCommand.Name)] User declined profile replacement." return } } $profileData = [ordered]@{} $prefix = 'Invoke-WinPEStartup:' $booleanPrompts = @( [pscustomobject]@{ Name = 'SkipOnScreenKeyboard'; Prompt = 'Skip the on-screen keyboard check?' } [pscustomobject]@{ Name = 'ShowPnpDevices'; Prompt = 'Show Plug and Play device hardware?' } [pscustomobject]@{ Name = 'ShowPnpErrors'; Prompt = 'Show Plug and Play device errors?' } [pscustomobject]@{ Name = 'SkipWiFi'; Prompt = 'Skip Wi-Fi startup and connection checks?' } [pscustomobject]@{ Name = 'SkipIPConfig'; Prompt = 'Skip the IP configuration display?' } [pscustomobject]@{ Name = 'SkipUpdateOSDCloud'; Prompt = 'Skip the OSDCloud module update?' } ) Write-Host -ForegroundColor DarkGray "[$(Get-Date -Format s)] [INFO] Configure general startup settings" foreach ($setting in $booleanPrompts) { $selection = & $promptChoice $setting.Prompt $inheritBooleanChoices 'I' if ($selection -ne 'I') { $profileData[$prefix + $setting.Name] = ($selection -eq 'T') } } $moduleSelection = & $promptChoice 'Configure additional PowerShell modules?' $collectionChoices 'I' if ($moduleSelection -eq 'R') { $profileData[$prefix + 'InstallModule'] = @(& $collectValues 'Enter PowerShell module names to update during startup.' 'Microsoft.Graph') } elseif ($moduleSelection -eq 'C') { $profileData[$prefix + 'InstallModule'] = @() } $phaseSettings = @( [pscustomobject]@{ Name = 'Startup'; Label = 'startup'; Example = 'https://contoso.example/startup.ps1' } [pscustomobject]@{ Name = 'Main'; Label = 'main'; Example = 'Deploy-OSDCloud' } [pscustomobject]@{ Name = 'Shutdown'; Label = 'shutdown'; Example = 'Restart-Computer -Force' } ) foreach ($phase in $phaseSettings) { $commandName = 'Invoke{0}Command' -f $phase.Name $commandSelection = & $promptChoice ("Configure {0} commands?" -f $phase.Label) $collectionChoices 'I' if ($commandSelection -eq 'R') { $profileData[$prefix + $commandName] = @(& $collectValues ("Enter {0} commands or URLs in execution order." -f $phase.Label) $phase.Example) } elseif ($commandSelection -eq 'C') { $profileData[$prefix + $commandName] = @() } $noExitName = $commandName + 'NoExit' $noExitSelection = & $promptChoice ("Keep the {0} child PowerShell open? Invoke-WinPEStartup waits until it closes." -f $phase.Label) $inheritBooleanChoices 'I' if ($noExitSelection -ne 'I') { $profileData[$prefix + $noExitName] = ($noExitSelection -eq 'T') } $errorActionName = $commandName + 'EA' $errorActionSelection = & $promptChoice ("How should {0} command failures be handled?" -f $phase.Label) $errorActionChoices 'I' if ($errorActionSelection -eq 'C') { $profileData[$prefix + $errorActionName] = 'Continue' } elseif ($errorActionSelection -eq 'S') { $profileData[$prefix + $errorActionName] = 'Stop' } } if ($profileData.Count -eq 0) { Write-Warning "[$(Get-Date -Format s)] No explicit profile settings were selected. No file was created." return } $booleanKeys = @($booleanPrompts.Name | ForEach-Object { $prefix + $_ }) + @( $prefix + 'InvokeStartupCommandNoExit' $prefix + 'InvokeMainCommandNoExit' $prefix + 'InvokeShutdownCommandNoExit' ) $collectionKeys = @( $prefix + 'InstallModule' $prefix + 'InvokeStartupCommand' $prefix + 'InvokeMainCommand' $prefix + 'InvokeShutdownCommand' ) $errorActionKeys = @( $prefix + 'InvokeStartupCommandEA' $prefix + 'InvokeMainCommandEA' $prefix + 'InvokeShutdownCommandEA' ) $knownKeys = @($booleanKeys + $collectionKeys + $errorActionKeys) foreach ($entry in $profileData.GetEnumerator()) { if ($entry.Key -notin $knownKeys) { throw "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] Unsupported profile property '$($entry.Key)'." } if ($entry.Key -in $booleanKeys -and $entry.Value -isnot [bool]) { throw "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] '$($entry.Key)' must be a boolean." } if ($entry.Key -in $collectionKeys) { if ($entry.Value -isnot [System.Array]) { throw "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] '$($entry.Key)' must be an array." } if (@($entry.Value | Where-Object { [string]::IsNullOrWhiteSpace([string]$_) }).Count -gt 0) { throw "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] '$($entry.Key)' contains an empty value." } } if ($entry.Key -in $errorActionKeys -and $entry.Value -notin @('Continue', 'Stop')) { throw "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] '$($entry.Key)' must be Continue or Stop." } } $json = $profileData | ConvertTo-Json -Depth 3 $null = ConvertFrom-Json -InputObject $json -ErrorAction Stop Write-Host -ForegroundColor DarkGray "[$(Get-Date -Format s)] [INFO] Profile path: $profilePath" Write-Host $json if (-not $PSCmdlet.ShouldProcess($profilePath, 'Create WinPEStartup Profile')) { return } if (-not (Test-Path -LiteralPath $profileDirectory -PathType Container)) { New-Item -Path $profileDirectory -ItemType Directory -Force -ErrorAction Stop | Out-Null } Set-Content -LiteralPath $profilePath -Value $json -Encoding UTF8 -Force -ErrorAction Stop $null = Get-Content -LiteralPath $profilePath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop Write-Host -ForegroundColor DarkGray "[$(Get-Date -Format s)] [INFO] Created WinPEStartup Profile: $profilePath" Write-Verbose "[$($MyInvocation.MyCommand.Name)] End" Get-Item -LiteralPath $profilePath } |