Public/New-HDTBootIso.ps1

function New-HDTBootIso {
    <#
        .SYNOPSIS
            Builds a bootable ISO from a WinPE media tree with oscdimg, staging
            the boot bits somewhere oscdimg can actually read them.
 
        .DESCRIPTION
            The ISO is a first-class debugging artifact, not a byproduct.
            Mounting it into a VM is the fastest loop for testing a sequence:
            no PXE stack, no DHCP scope, no WDS.
 
            Update-HDTBootImage calls this as its last step, but it is a command
            in its own right - point it at any WinPE media tree and it produces a
            bootable ISO.
 
            SIX STEPS, AND STEP 2 IS THE ONE THAT MATTERS:
 
              1. Resolve Oscdimg, EtfsBoot, EfiSys and EfiSysNoPrompt through
                 Get-HDTAdkPath. NEVER A LITERAL - PROJECT.md: "the layout has
                 moved between ADK releases."
 
              2. STAGE the boot bits it needs into -BootBitPath. THIS EXISTS
                 BECAUSE oscdimg REQUIRES IT, NOT FOR TIDINESS. The ADK lives under
                 'C:\Program Files (x86)\...', and oscdimg's -bootdata: cannot
                 take a quoted path: a quoted path arrives doubled and oscdimg
                 answers "ERROR: Could not open boot sector file
                 ""C:\Program Files (x86)\...\etfsboot.com"" / Error 123". The
                 verified fix is to copy the bits to a space-free directory and
                 build the argument unquoted. Get-HDTBootIsoArgument refuses a
                 -BootBitPath with a space, so the staging cannot be quietly
                 skipped.
 
              3. Get-HDTBootIsoArgument - all six firmware/no-prompt
                 combinations, asserted as exact strings in a unit suite.
 
              4. ShouldProcess on the ISO path. It overwrites a file a fleet may
                 be booting from.
 
              5. NewIso, through the injected IBootImageService.
 
              6. Report Path, SizeBytes, Firmware, NoPromptForKey, Label,
                 MediaRoot and Sha256.
 
            ONLY THE BITS IT NEEDS ARE STAGED. UEFI stages one file - efisys.bin
            or efisys_noprompt.bin; BIOS stages etfsboot.com; Both stages two.
            A test asserts which, because staging the wrong El Torito image
            produces an ISO that boots and prompts, which looks like success
            until an unattended VM sits at the prompt until it times out.
 
            -NoPromptForKey DEFAULTS OFF HERE and Update-HDTBootImage passes it
            ON unless -PromptForKey was given, because a boot image you mount to
            test something should just boot. A direct caller building media for
            a shelf gets the Microsoft default.
 
            THERE IS NO NO-PROMPT etfsboot. The Oscdimg folder holds
            oscdimg.exe, etfsboot.com, efisys.bin, efisys_noprompt.bin and the
            two _EX variants and nothing else, so -NoPromptForKey with
            -Firmware BIOS warns and builds an ISO that will prompt. HDT states
            this rather than pretending otherwise.
 
        .PARAMETER MediaRoot
            The WinPE media tree to burn - the ADK's Media template with
            sources\boot.wim in it.
 
        .PARAMETER Path
            The ISO to write. Overwritten if it exists.
 
        .PARAMETER Firmware
            UEFI (default), BIOS or Both.
 
        .PARAMETER NoPromptForKey
            Remove the "Press any key to boot from CD or DVD..." pause on the
            UEFI leg, by staging efisys_noprompt.bin. No effect on BIOS, which
            warns.
 
        .PARAMETER Label
            The ISO volume label, uppercased into the argument. HDTPE_x64 by
            default. A label with a space is refused.
 
        .PARAMETER BootBitPath
            Where to stage the boot bits. Defaults to
            $env:SystemDrive\HDTBootBits, which has no space by construction.
            Update-HDTBootImage passes its own scratch directory so the staging
            happens somewhere the build cleans.
 
        .PARAMETER AdkRoot
            An explicit ADK root, which wins over the registry.
 
        .PARAMETER Architecture
            amd64 (default) or arm64.
 
        .PARAMETER BootImageService
            An IBootImageService. Defaults to the real adapter.
 
        .PARAMETER FileSystem
            An IFileSystem. Defaults to the real adapter.
 
        .PARAMETER Registry
            An IRegistryService, for resolving the ADK. Defaults to the real
            adapter.
 
        .INPUTS
            None. This command does not accept pipeline input.
 
        .OUTPUTS
            System.Management.Automation.PSCustomObject with Path, SizeBytes,
            Sha256, Firmware, NoPromptForKey, Label and MediaRoot.
 
        .EXAMPLE
            New-HDTBootIso -MediaRoot 'C:\HDTLab\scratch\bootimage\work\media' `
                -Path 'C:\HDTLab\Share\Boot\HDTPE_x64.iso' -NoPromptForKey
 
            The debugging ISO: mount it into a Generation 2 VM and it boots
            straight into WinPE with no keypress.
 
        .EXAMPLE
            New-HDTBootIso -MediaRoot $media -Path $iso -Firmware Both
 
            A dual BIOS + UEFI ISO for legacy hardware. Without
            -NoPromptForKey, because the BIOS leg would prompt anyway.
    #>

    [CmdletBinding(SupportsShouldProcess = $true)]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string] $MediaRoot,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string] $Path,

        [Parameter()]
        [ValidateSet('UEFI', 'BIOS', 'Both')]
        [string] $Firmware = 'UEFI',

        [Parameter()]
        [switch] $NoPromptForKey,

        [Parameter()]
        [AllowEmptyString()]
        [string] $Label = 'HDTPE_x64',

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string] $BootBitPath = (Join-Path -Path $env:SystemDrive -ChildPath 'HDTBootBits'),

        [Parameter()]
        [AllowEmptyString()]
        [string] $AdkRoot = '',

        [Parameter()]
        [ValidateSet('amd64', 'arm64')]
        [string] $Architecture = 'amd64',

        [Parameter()]
        [AllowNull()]
        [object] $BootImageService,

        [Parameter()]
        [AllowNull()]
        [object] $FileSystem,

        [Parameter()]
        [AllowNull()]
        [object] $Registry
    )

    Set-StrictMode -Version Latest
    $ErrorActionPreference = 'Stop'

    if ($null -eq $FileSystem) { $FileSystem = New-HDTFileSystem }
    if ($null -eq $Registry) { $Registry = New-HDTRegistryService }
    if ($null -eq $BootImageService) { $BootImageService = New-HDTBootImageService }

    $adkSplat = @{
        Architecture = $Architecture
        Registry     = $Registry
        FileSystem   = $FileSystem
    }
    if (-not [string]::IsNullOrWhiteSpace($AdkRoot)) { $adkSplat['Root'] = $AdkRoot }

    # -- 1. the ADK, resolved and never written down -------------------------

    $oscdimg = Get-HDTAdkPath -Asset Oscdimg @adkSplat

    # Which El Torito images this firmware needs, and only those. Staging one
    # that is not needed is harmless; staging the wrong one produces an ISO that
    # boots AND PROMPTS, which looks like success until an unattended VM times
    # out at the prompt.
    $needed = New-Object -TypeName System.Collections.ArrayList

    if ($Firmware -eq 'BIOS' -or $Firmware -eq 'Both') {
        [void] $needed.Add((Get-HDTAdkPath -Asset EtfsBoot @adkSplat))
    }

    if ($Firmware -eq 'UEFI' -or $Firmware -eq 'Both') {
        $efiAsset = 'EfiSys'
        if ($NoPromptForKey) { $efiAsset = 'EfiSysNoPrompt' }

        [void] $needed.Add((Get-HDTAdkPath -Asset $efiAsset @adkSplat))
    }

    # -- 2. SPIKES S2's staging ----------------------------------------------
    #
    # THIS IS NOT TIDINESS. oscdimg's -bootdata: cannot take a quoted path and
    # the ADK path has spaces in it; the verified fix is to copy the bits
    # somewhere without one and build the argument unquoted. The argument builder
    # refuses a path with a space, so this cannot be skipped by a later caller.

    $bits = $BootBitPath.TrimEnd('\', '/')
    $FileSystem.CreateDirectory($bits)

    foreach ($source in $needed) {
        $FileSystem.CopyItem($source, [System.IO.Path]::Combine($bits, [System.IO.Path]::GetFileName($source)))
    }

    # -- 3. the argument ------------------------------------------------------

    $argument = Get-HDTBootIsoArgument -Firmware $Firmware -NoPromptForKey:$NoPromptForKey `
        -BootBitPath $bits -Label $Label

    # -- 4/5. the burn --------------------------------------------------------

    $description = 'Build a {0} boot ISO from {1}' -f $Firmware, $MediaRoot

    if (-not $PSCmdlet.ShouldProcess($Path, $description)) {
        return [pscustomobject] @{
            Path           = $Path
            SizeBytes      = [long] 0
            Sha256         = ''
            Firmware       = $Firmware
            NoPromptForKey = [bool] $NoPromptForKey
            Label          = $Label
            MediaRoot      = $MediaRoot
            Oscdimg        = $oscdimg
        }
    }

    $BootImageService.NewIso($MediaRoot, $Path, [string[]] @($argument))

    # -- 6. the row -----------------------------------------------------------

    return [pscustomobject] @{
        Path           = $Path
        SizeBytes      = [long] $FileSystem.GetLength($Path)
        Sha256         = [string] $FileSystem.GetHash($Path)
        Firmware       = $Firmware
        NoPromptForKey = [bool] $NoPromptForKey
        Label          = $Label
        MediaRoot      = $MediaRoot
        Oscdimg        = $oscdimg
    }
}