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 winpestartup.json inside a named folder in 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. A same-name legacy root-level JSON profile is replaced in place; newly created profiles use the folder layout. 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 folder name at position 0. A trailing .json extension is removed for backward compatibility. An omitted, empty, or invalid value causes the function to prompt until a valid name 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\winpestartup.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 } $profileFolderPath = Join-Path $profileDirectory $profileName $profilePath = Join-Path $profileFolderPath 'winpestartup.json' $legacyProfilePath = Join-Path $profileDirectory ($profileName + '.json') $profileFolderExists = Test-Path -LiteralPath $profileFolderPath -PathType Container $legacyProfileExists = Test-Path -LiteralPath $legacyProfilePath -PathType Leaf if (-not $profileFolderExists -and $legacyProfileExists) { $profilePath = $legacyProfilePath } $resolvedDirectory = [System.IO.Path]::GetFullPath($profileDirectory).TrimEnd([char[]]@('\', '/')) $resolvedParent = [System.IO.Path]::GetDirectoryName([System.IO.Path]::GetFullPath($profileFolderPath)).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 (($profileFolderExists -or $legacyProfileExists) -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 } $profileParentPath = Split-Path -Path $profilePath -Parent if (-not (Test-Path -LiteralPath $profileParentPath -PathType Container)) { New-Item -Path $profileParentPath -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 } |