public/WinPE/Invoke-WinPEStartup.ps1
|
#requires -Version 5.1 function Invoke-WinPEStartup { <# .SYNOPSIS Runs the WinPE startup workflow for OSDCloud. .DESCRIPTION Executes the OSDCloud WinPE startup sequence from a single entry point. The function can optionally load defaults from module JSON, discover and apply a startup profile, and then run startup steps in order including environment setup, drivers, files, hardware checks, connectivity, module updates, script execution, and optional URL/command invocations. This function only runs in WinPE where SystemDrive is X:. If it is called outside WinPE, it writes a warning and exits without running startup steps. .EXAMPLE Invoke-WinPEStartup Runs the startup workflow with default behavior. .EXAMPLE Invoke-WinPEStartup -Verbose Runs the startup workflow and writes verbose progress details. .EXAMPLE Invoke-WinPEStartup -SkipWiFi -SkipIPConfig Runs startup but skips Wi-Fi and IP configuration display steps. .PARAMETER SkipOnScreenKeyboard Skips launching the on-screen keyboard check. .PARAMETER ShowPnpDevices Shows the Plug and Play device hardware window (`Show-WinPEStartupDevices`). By default this window is not displayed. .PARAMETER ShowPnpErrors Shows the Plug and Play device error window (`Show-WinPEStartupDeviceErrors`). By default this window is not displayed. .PARAMETER SkipWiFi Skips Wi-Fi startup and connection checks. .PARAMETER SkipIPConfig Skips displaying IP configuration details. .PARAMETER SkipUpdateOSDCloud Skips updating the OSDCloud module. .PARAMETER InstallModule One or more additional module names to update during startup. .PARAMETER InvokeStartupCommand One or more PowerShell command lines or URLs to execute during startup in a single child PowerShell process. Entries beginning with 'http://' or 'https://' are automatically wrapped as 'Invoke-RestMethod -Uri <url> | Invoke-Expression'. All entries are joined and executed together in one child process. .PARAMETER InvokeStartupCommandNoExit When specified, the child PowerShell window launched for InvokeStartupCommand remains open after the script completes (-NoExit). .PARAMETER InvokeStartupCommandEA Controls error handling when the InvokeStartupCommand child process fails or exits with a non-zero code. 'Continue' writes a warning and proceeds; 'Stop' throws a terminating error. Default is 'Continue'. .PARAMETER InvokeMainCommand One or more PowerShell command lines or URLs to execute during the main phase in a single child PowerShell process. Entries beginning with 'http://' or 'https://' are automatically wrapped as 'Invoke-RestMethod -Uri <url> | Invoke-Expression'. All entries are joined and executed together in one child process. .PARAMETER InvokeMainCommandNoExit When specified, the child PowerShell window launched for InvokeMainCommand remains open after the script completes (-NoExit). .PARAMETER InvokeMainCommandEA Controls error handling when the InvokeMainCommand child process fails or exits with a non-zero code. 'Continue' writes a warning and proceeds; 'Stop' throws a terminating error. Default is 'Continue'. .PARAMETER InvokeShutdownCommand One or more PowerShell command lines or URLs to execute during the shutdown phase in a single child PowerShell process. Entries beginning with 'http://' or 'https://' are automatically wrapped as 'Invoke-RestMethod -Uri <url> | Invoke-Expression'. All entries are joined and executed together in one child process. .PARAMETER InvokeShutdownCommandNoExit When specified, the child PowerShell window launched for InvokeShutdownCommand remains open after the script completes (-NoExit). .PARAMETER InvokeShutdownCommandEA Controls error handling when the InvokeShutdownCommand child process fails or exits with a non-zero code. 'Continue' writes a warning and proceeds; 'Stop' throws a terminating error. Default is 'Continue'. .OUTPUTS System.Void .NOTES Author: David Segura Module: OSDCloud This function is intended for WinPE scenarios. #> [CmdletBinding()] [OutputType([void])] param ( [Parameter()] [switch]$SkipOnScreenKeyboard, [Parameter()] [switch]$ShowPnpDevices, [Parameter()] [switch]$ShowPnpErrors, [Parameter()] [switch]$SkipWiFi, [Parameter()] [switch]$SkipIPConfig, [Parameter()] [switch]$SkipUpdateOSDCloud, [Parameter()] [string[]]$InstallModule, [Parameter()] [string[]]$InvokeStartupCommand, [Parameter()] [switch]$InvokeStartupCommandNoExit, [Parameter()] [ValidateSet('Continue', 'Stop')] [string]$InvokeStartupCommandEA = 'Continue', [Parameter()] [string[]]$InvokeMainCommand, [Parameter()] [switch]$InvokeMainCommandNoExit, [Parameter()] [ValidateSet('Continue', 'Stop')] [string]$InvokeMainCommandEA = 'Continue', [Parameter()] [string[]]$InvokeShutdownCommand, [Parameter()] [switch]$InvokeShutdownCommandNoExit, [Parameter()] [ValidateSet('Continue', 'Stop')] [string]$InvokeShutdownCommandEA = 'Continue' ) begin { $Error.Clear() $skipExecution = $false if ($env:SystemDrive -ne 'X:') { Write-Warning 'Invoke-WinPEStartup: Not running in WinPE (SystemDrive is not X:). Exiting.' $skipExecution = $true return } $switchLikeParameters = @( 'SkipOnScreenKeyboard', 'ShowPnpDevices', 'ShowPnpErrors', 'SkipWiFi', 'SkipIPConfig', 'SkipUpdateOSDCloud', 'InvokeStartupCommandNoExit', 'InvokeMainCommandNoExit', 'InvokeShutdownCommandNoExit' ) $arrayParameters = @( 'InstallModule', 'InvokeStartupCommand', 'InvokeMainCommand', 'InvokeShutdownCommand' ) $stringParameters = @( 'InvokeStartupCommandEA', 'InvokeMainCommandEA', 'InvokeShutdownCommandEA' ) $knownParameters = @($switchLikeParameters + $arrayParameters + $stringParameters) $defaultsPrefix = 'Invoke-WinPEStartup:' $resolvedDefaults = [ordered]@{} $selectedProfile = $null # Snapshot the parameter names that were pre-bound via $global:PSDefaultParameterValues. # These must not block profile overrides — only truly explicit caller args should win. $globalPreBoundParameters = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($globalKey in $global:PSDefaultParameterValues.Keys) { if ($globalKey -like "$($defaultsPrefix)*") { [void]$globalPreBoundParameters.Add($globalKey.Substring($defaultsPrefix.Length)) } } function ConvertFrom-WinPEStartupJsonContent { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string]$RawContent ) $sanitizedJson = $RawContent -replace '(?m)(?<=^([^"]|"[^"]*")*)//.*' -replace '(?ms)/\*.*?\*/' return (ConvertFrom-Json -InputObject $sanitizedJson -ErrorAction Stop) } function ConvertTo-WinPEStartupBoolean { [CmdletBinding()] param ( [Parameter()] $Value ) if ($Value -is [bool]) { return $Value } if ($Value -is [System.Management.Automation.SwitchParameter]) { return $Value.IsPresent } if ($Value -is [string]) { switch -Regex ($Value.Trim()) { '^(?i:true|1|yes|y|on)$' { return $true } '^(?i:false|0|no|n|off)$' { return $false } default { return [bool]$Value } } } if ($null -eq $Value) { return $false } return [bool]$Value } function ConvertTo-WinPEStartupStringArray { [CmdletBinding()] param ( [Parameter()] $Value ) if ($null -eq $Value) { return @() } if ($Value -is [string]) { return @( ($Value -split "(`r`n|`n|`r)") | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } ) } if ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [string]) { return @( $Value | ForEach-Object { [string]$_ } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } ) } return @([string]$Value) } function Add-WinPEStartupDefaults { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] $InputObject, [Parameter(Mandatory = $true)] [string]$SourceName, [Parameter(Mandatory = $true)] [ValidateSet('Prefixed', 'Splat', 'Any')] [string]$KeyFormat ) $entries = @() if ($InputObject -is [System.Collections.IDictionary]) { foreach ($entry in $InputObject.GetEnumerator()) { $entries += [pscustomobject]@{ Name = [string]$entry.Key Value = $entry.Value } } } else { foreach ($property in $InputObject.PSObject.Properties) { $entries += [pscustomobject]@{ Name = [string]$property.Name Value = $property.Value } } } foreach ($entry in $entries) { if ([string]::IsNullOrWhiteSpace($entry.Name)) { Write-Warning "Invoke-WinPEStartup: Skipping empty key from '$SourceName'." continue } if ($entry.Value -is [System.Management.Automation.PSCustomObject] -or $entry.Value -is [System.Collections.IDictionary]) { Write-Warning "Invoke-WinPEStartup: Skipping nested object value for '$($entry.Name)' from '$SourceName'. Profiles and defaults must be flat key-value maps." continue } $parameterName = $entry.Name $hasPrefix = $parameterName.StartsWith($defaultsPrefix, [System.StringComparison]::OrdinalIgnoreCase) if ($KeyFormat -eq 'Prefixed' -and -not $hasPrefix) { Write-Verbose "Invoke-WinPEStartup: Ignoring non-prefixed key '$($entry.Name)' from '$SourceName'." continue } if ($KeyFormat -eq 'Splat' -and $hasPrefix) { Write-Verbose "Invoke-WinPEStartup: Ignoring prefixed key '$($entry.Name)' from '$SourceName'." continue } if ($hasPrefix) { $parameterName = $parameterName.Substring($defaultsPrefix.Length) } if ([string]::IsNullOrWhiteSpace($parameterName)) { continue } if ($knownParameters -notcontains $parameterName) { Write-Verbose "Invoke-WinPEStartup: Ignoring unknown default key '$($entry.Name)' from '$SourceName'." continue } $resolvedDefaults[$parameterName] = $entry.Value } } if (Test-Path -LiteralPath $Script:OSDCloudPSDefaultParameterValuesPath -PathType Leaf) { try { $rawDefaults = Get-Content -LiteralPath $Script:OSDCloudPSDefaultParameterValuesPath -Raw -ErrorAction Stop $moduleDefaults = ConvertFrom-WinPEStartupJsonContent -RawContent $rawDefaults Add-WinPEStartupDefaults -InputObject $moduleDefaults -SourceName $Script:OSDCloudPSDefaultParameterValuesPath -KeyFormat Prefixed } catch { Write-Warning "Invoke-WinPEStartup: Failed to load defaults from '$Script:OSDCloudPSDefaultParameterValuesPath': $($_.Exception.Message)" } } # Initialize WinPE environment (shell folders, env vars, registry) Initialize-WinPEStartupEnvironment # Load drivers from WinPEStartup\Drivers on attached drives Initialize-WinPEStartupDrivers # Copy files from WinPEStartup\Files on attached drives into the RAM disk Initialize-WinPEStartupFiles # Run wpeinit and wpeutil commands and wait for initialization to complete Initialize-WinPEStartupMain Start-Sleep -Seconds 3 $candidateProfiles = [System.Collections.Generic.List[object]]::new() foreach ($driveLetter in [char[]](67..90)) { $profileRoot = '{0}:\WinPEStartup\Profiles' -f $driveLetter if (-not (Test-Path -LiteralPath $profileRoot -PathType Container)) { continue } try { $profileFiles = Get-ChildItem -LiteralPath $profileRoot -Filter '*.json' -File -ErrorAction Stop | Sort-Object FullName foreach ($profileFile in $profileFiles) { [void]$candidateProfiles.Add([pscustomobject]@{ Index = 0 Name = $profileFile.Name Drive = '{0}:' -f $driveLetter Path = $profileFile.FullName }) } } catch { Write-Verbose "Invoke-WinPEStartup: Unable to enumerate '$profileRoot': $($_.Exception.Message)" } } if ($candidateProfiles.Count -gt 0) { # Force array semantics so .Count and indexing behave reliably on PS 5.1 even with a single item. $orderedProfiles = @($candidateProfiles | Sort-Object Path) $currentIndex = 1 foreach ($menuEntry in $orderedProfiles) { $menuEntry.Index = $currentIndex $currentIndex += 1 } # Use the underlying list's Count, which is always reliable, to decide auto-select vs. prompt. if ($candidateProfiles.Count -eq 1) { $selectedProfile = $orderedProfiles[0] Write-Verbose "Invoke-WinPEStartup: Auto-selected only available profile '$($selectedProfile.Path)'" } else { Write-Host '' Write-Host 'Available WinPE Startup profiles:' $orderedProfiles | Select-Object Index, Name, Drive, Path | Format-Table -AutoSize | Out-String | Write-Host while (-not $selectedProfile) { $selection = Read-Host 'Select a profile by number, or press Enter to cancel' if ([string]::IsNullOrWhiteSpace($selection)) { Write-Warning 'Invoke-WinPEStartup: Profile selection cancelled.' $skipExecution = $true return } if ($selection -match '^(?i)q(?:uit)?$') { Write-Warning 'Invoke-WinPEStartup: Profile selection cancelled.' $skipExecution = $true return } $selectedIndex = 0 if ([int]::TryParse($selection, [ref]$selectedIndex)) { $selectedProfile = $orderedProfiles | Where-Object { $_.Index -eq $selectedIndex } | Select-Object -First 1 } if (-not $selectedProfile) { Write-Warning "Invoke-WinPEStartup: Invalid selection '$selection'." } } } } if ($selectedProfile) { Write-Verbose "Invoke-WinPEStartup: Selected profile '$($selectedProfile.Path)'" try { $rawProfile = Get-Content -LiteralPath $selectedProfile.Path -Raw -ErrorAction Stop $profileDefaults = ConvertFrom-WinPEStartupJsonContent -RawContent $rawProfile Add-WinPEStartupDefaults -InputObject $profileDefaults -SourceName $selectedProfile.Path -KeyFormat Any Write-Host "WinPE profile applied: $($selectedProfile.Path)" } catch { Write-Warning "Invoke-WinPEStartup: Failed to load profile '$($selectedProfile.Path)': $($_.Exception.Message)" $skipExecution = $true return } } foreach ($parameterName in $knownParameters) { if ($PSBoundParameters.ContainsKey($parameterName) -and -not $globalPreBoundParameters.Contains($parameterName)) { Write-Verbose "Invoke-WinPEStartup: Skipping JSON default '$parameterName' because it is already bound." continue } if (-not $resolvedDefaults.Contains($parameterName)) { continue } $parameterValue = $resolvedDefaults[$parameterName] switch ($parameterName) { 'SkipOnScreenKeyboard' { $SkipOnScreenKeyboard = ConvertTo-WinPEStartupBoolean -Value $parameterValue } 'ShowPnpDevices' { $ShowPnpDevices = ConvertTo-WinPEStartupBoolean -Value $parameterValue } 'ShowPnpErrors' { $ShowPnpErrors = ConvertTo-WinPEStartupBoolean -Value $parameterValue } 'SkipWiFi' { $SkipWiFi = ConvertTo-WinPEStartupBoolean -Value $parameterValue } 'SkipIPConfig' { $SkipIPConfig = ConvertTo-WinPEStartupBoolean -Value $parameterValue } 'SkipUpdateOSDCloud' { $SkipUpdateOSDCloud = ConvertTo-WinPEStartupBoolean -Value $parameterValue } 'InstallModule' { $InstallModule = ConvertTo-WinPEStartupStringArray -Value $parameterValue } 'InvokeStartupCommand' { $InvokeStartupCommand = ConvertTo-WinPEStartupStringArray -Value $parameterValue } 'InvokeMainCommand' { $InvokeMainCommand = ConvertTo-WinPEStartupStringArray -Value $parameterValue } 'InvokeShutdownCommand' { $InvokeShutdownCommand = ConvertTo-WinPEStartupStringArray -Value $parameterValue } 'InvokeStartupCommandNoExit' { $InvokeStartupCommandNoExit = ConvertTo-WinPEStartupBoolean -Value $parameterValue } 'InvokeMainCommandNoExit' { $InvokeMainCommandNoExit = ConvertTo-WinPEStartupBoolean -Value $parameterValue } 'InvokeShutdownCommandNoExit' { $InvokeShutdownCommandNoExit = ConvertTo-WinPEStartupBoolean -Value $parameterValue } 'InvokeStartupCommandEA' { if ($parameterValue -notin @('Continue', 'Stop')) { Write-Warning "Invoke-WinPEStartup: Invalid value '$parameterValue' for 'InvokeStartupCommandEA' from JSON. Expected 'Continue' or 'Stop'. Skipping." } else { $InvokeStartupCommandEA = [string]$parameterValue } } 'InvokeMainCommandEA' { if ($parameterValue -notin @('Continue', 'Stop')) { Write-Warning "Invoke-WinPEStartup: Invalid value '$parameterValue' for 'InvokeMainCommandEA' from JSON. Expected 'Continue' or 'Stop'. Skipping." } else { $InvokeMainCommandEA = [string]$parameterValue } } 'InvokeShutdownCommandEA' { if ($parameterValue -notin @('Continue', 'Stop')) { Write-Warning "Invoke-WinPEStartup: Invalid value '$parameterValue' for 'InvokeShutdownCommandEA' from JSON. Expected 'Continue' or 'Stop'. Skipping." } else { $InvokeShutdownCommandEA = [string]$parameterValue } } } Write-Verbose "Invoke-WinPEStartup: Applied default '$parameterName' from JSON configuration." } Write-Verbose 'Invoke-WinPEStartup: Starting full WinPE startup sequence' } process { if ($skipExecution) { return } # On Screen Keyboard if one is not detected if (-not $SkipOnScreenKeyboard) { Invoke-WinPEStartupManager OSK } if ($ShowPnpDevices) { Invoke-WinPEStartupManager DeviceHardware } if ($ShowPnpErrors) { Invoke-WinPEStartupManager DeviceErrors } if (-not $SkipWiFi) { Invoke-WinPEStartupManager WiFi } if (-not $SkipIPConfig) { Invoke-WinPEStartupManager IPConfig } if ($InstallModule) { foreach ($module in $InstallModule) { Invoke-WinPEStartupManager UpdateModule -Value $module } } if (-not $SkipUpdateOSDCloud) { Invoke-WinPEStartupManager UpdateModule -Value OSDCloud } if ($InvokeStartupCommand) { $startupCommandList = $InvokeStartupCommand | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } if ($startupCommandList) { $commandFailed = $null try { $scriptLines = foreach ($entry in $startupCommandList) { if ($entry -match '^https?://') { $escapedUrl = $entry.Replace("'", "''") "Invoke-RestMethod -Uri '$escapedUrl' | Invoke-Expression" } else { $entry } } $invokeCommand = $scriptLines -join [Environment]::NewLine $encodedCommand = [Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($invokeCommand)) $psArgs = [System.Collections.Generic.List[string]]::new() [void]$psArgs.Add('-NoLogo') [void]$psArgs.Add('-NoProfile') [void]$psArgs.Add('-ExecutionPolicy') [void]$psArgs.Add('Bypass') if ($InvokeStartupCommandNoExit) { [void]$psArgs.Add('-NoExit') } [void]$psArgs.Add('-EncodedCommand') [void]$psArgs.Add($encodedCommand) $process = Start-Process -FilePath 'powershell.exe' -ArgumentList $psArgs -PassThru -Wait -ErrorAction Stop if ($process.ExitCode -ne 0) { $commandFailed = "Step 11: InvokeStartupCommand session exited with code $($process.ExitCode)." } } catch { $commandFailed = "Step 11: Failed InvokeStartupCommand session. $_" } if ($commandFailed) { if ($InvokeStartupCommandEA -eq 'Stop') { $errorRecord = [System.Management.Automation.ErrorRecord]::new( [System.Exception]::new($commandFailed), 'InvokeStartupCommandFailed', [System.Management.Automation.ErrorCategory]::InvalidOperation, $startupCommandList ) $PSCmdlet.ThrowTerminatingError($errorRecord) } else { Write-Warning "$commandFailed Continuing." } } } } # Initialize-WinPEStartupScript -FileName 'startup.cmd' # Initialize-WinPEStartupScript -FileName 'startup.ps1' # Initialize-WinPEStartupScript -FileName 'main.cmd' # Initialize-WinPEStartupScript -FileName 'main.ps1' if ($InvokeMainCommand) { $mainCommandList = $InvokeMainCommand | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } if ($mainCommandList) { $commandFailed = $null try { $scriptLines = foreach ($entry in $mainCommandList) { if ($entry -match '^https?://') { $escapedUrl = $entry.Replace("'", "''") "Invoke-RestMethod -Uri '$escapedUrl' | Invoke-Expression" } else { $entry } } $invokeCommand = $scriptLines -join [Environment]::NewLine $encodedCommand = [Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($invokeCommand)) $psArgs = [System.Collections.Generic.List[string]]::new() [void]$psArgs.Add('-NoLogo') [void]$psArgs.Add('-NoProfile') [void]$psArgs.Add('-ExecutionPolicy') [void]$psArgs.Add('Bypass') if ($InvokeMainCommandNoExit) { [void]$psArgs.Add('-NoExit') } [void]$psArgs.Add('-EncodedCommand') [void]$psArgs.Add($encodedCommand) $process = Start-Process -FilePath 'powershell.exe' -ArgumentList $psArgs -PassThru -Wait -ErrorAction Stop if ($process.ExitCode -ne 0) { $commandFailed = "Step 15: InvokeMainCommand session exited with code $($process.ExitCode)." } } catch { $commandFailed = "Step 15: Failed InvokeMainCommand session. $_" } if ($commandFailed) { if ($InvokeMainCommandEA -eq 'Stop') { $errorRecord = [System.Management.Automation.ErrorRecord]::new( [System.Exception]::new($commandFailed), 'InvokeMainCommandFailed', [System.Management.Automation.ErrorCategory]::InvalidOperation, $mainCommandList ) $PSCmdlet.ThrowTerminatingError($errorRecord) } else { Write-Warning "$commandFailed Continuing." } } } } # Initialize-WinPEStartupScript -NewProcess -NoExit -FileName 'main-wait.ps1' # Initialize-WinPEStartupScript -FileName 'shutdown.cmd' # Initialize-WinPEStartupScript -FileName 'shutdown.ps1' if ($InvokeShutdownCommand) { $shutdownCommandList = $InvokeShutdownCommand | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } if ($shutdownCommandList) { $commandFailed = $null try { $scriptLines = foreach ($entry in $shutdownCommandList) { if ($entry -match '^https?://') { $escapedUrl = $entry.Replace("'", "''") "Invoke-RestMethod -Uri '$escapedUrl' | Invoke-Expression" } else { $entry } } $invokeCommand = $scriptLines -join [Environment]::NewLine $encodedCommand = [Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($invokeCommand)) $psArgs = [System.Collections.Generic.List[string]]::new() [void]$psArgs.Add('-NoLogo') [void]$psArgs.Add('-NoProfile') [void]$psArgs.Add('-ExecutionPolicy') [void]$psArgs.Add('Bypass') if ($InvokeShutdownCommandNoExit) { [void]$psArgs.Add('-NoExit') } [void]$psArgs.Add('-EncodedCommand') [void]$psArgs.Add($encodedCommand) $process = Start-Process -FilePath 'powershell.exe' -ArgumentList $psArgs -PassThru -Wait -ErrorAction Stop if ($process.ExitCode -ne 0) { $commandFailed = "Step 20: InvokeShutdownCommand session exited with code $($process.ExitCode)." } } catch { $commandFailed = "Step 20: Failed InvokeShutdownCommand session. $_" } if ($commandFailed) { if ($InvokeShutdownCommandEA -eq 'Stop') { $errorRecord = [System.Management.Automation.ErrorRecord]::new( [System.Exception]::new($commandFailed), 'InvokeShutdownCommandFailed', [System.Management.Automation.ErrorCategory]::InvalidOperation, $shutdownCommandList ) $PSCmdlet.ThrowTerminatingError($errorRecord) } else { Write-Warning "$commandFailed Continuing." } } } } } end { if ($skipExecution) { return } Write-Verbose 'Invoke-WinPEStartup: Complete' } } |