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
}