public/WinOS/New-WinpeStartupProfile.ps1
|
#requires -Version 5.1 function New-WinpeStartupProfile { <# .SYNOPSIS Creates a WinpeStartup profile for OSDCloud .DESCRIPTION Saves <ProfileName>\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" } |