Public/Get-HDTConsoleStepCatalog.ps1

function Get-HDTConsoleStepCatalog {
    <#
        .SYNOPSIS
            Builds the Add button's drop-down: every step type this engine can
            run, by category, under the names an MDT administrator knows.
 
        .DESCRIPTION
            ADD IS A MENU, WHICH IS WORKBENCH'S SHAPE. MDT and ConfigMgr both
            hang a drop-down off Add - General, Disks, Images - and an
            administrator picks 'Apply Operating System' rather than typing a
            type name. This console is meant to be close enough that muscle
            memory transfers, so the menu is a decision, it is made here, and
            every item of it is asserted in
            tests/unit/ConsoleStepCatalog.Tests.ps1. The window hangs the items
            off a button and formats none of them.
 
            THE LIST IS Get-HDTStepType'S, NOT A LITERAL. That cmdlet is the
            engine's registry and it discovers third-party step types dropped
            into Modules\. A hard-coded menu would offer the ten
            that shipped and quietly omit the one somebody installed this
            morning - a failure that is hard to notice precisely because the
            menu still looks complete.
 
            THE YAML IS THE ENGINE'S TOO, AND THAT IS THE POINT OF THIS COMMAND.
            The console is a wrapper around the HDT command line, so a menu item
            can only exist where a command exists to carry it out. Each item's
            Block comes from that type's own Get-HDT<Type>StepTemplate; a type
            the engine cannot author reports CanAdd false and is LEFT OUT, even
            though it is a real type that runs. An earlier version wrote a
            two-line block here for anything in the registry, which made this
            window the only thing in HDT that could create a step and meant it
            was guessing the file format on the engine's behalf.
 
            THE CATALOG ONLY ADDS THE NAME AND THE SHELF. A type the catalog has
            never heard of is still offered, under Custom, named by its own
            type: being absent from a curated list is not a reason to be
            unbuildable, as long as the engine can build it. That is also why
            the categories are filtered to what is actually registered rather
            than drawn from the catalog - an engine with no imaging steps should
            not show an empty Images submenu.
 
            'NEW GROUP' COMES FIRST AND IS NOT A STEP TYPE. It has no Invoke
            command and never appears in the registry, so its YAML comes from
            Get-HDTGroupTemplate rather than from a type. It is added through
            Add-HDTStep's -Block route, which is the same paste path the
            Copy button uses. Its Kind says Group so the window can tell the two
            apart without inspecting the command string.
 
        .PARAMETER StepType
            A pre-built Get-HDTStepType registry. Defaults to the engine's.
 
        .INPUTS
            None. This command does not accept pipeline input.
 
        .OUTPUTS
            System.Management.Automation.PSCustomObject per category, each with:
 
              Category the submenu's name
              Item its entries, each with Text, Type, Kind, Source and the
                        Add-HDTStep call that would create it
 
        .EXAMPLE
            Get-HDTConsoleStepCatalog | ForEach-Object { $_.Category }
 
        .EXAMPLE
            (Get-HDTConsoleStepCatalog).Item | Format-Table Text, Type, Source
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject[]])]
    param(
        [Parameter(Position = 0)]
        [AllowNull()]
        [AllowEmptyCollection()]
        [object[]] $StepType
    )

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

    $registry = $StepType

    if ($null -eq $registry) {
        $registry = @(Get-HDTStepType)
    }

    # The name each type is offered under, and the shelf it sits on. The names
    # are MDT's where MDT has one for the same job, because an administrator
    # arriving from Workbench is looking for the words they already use.
    $known = @{
        'CommandLine'   = @{ Text = 'Run Command Line'; Category = 'General'; Order = 1 }
        'PowerShell'    = @{ Text = 'Run PowerShell Script'; Category = 'General'; Order = 2 }
        'SetVariable'   = @{ Text = 'Set Task Sequence Variable'; Category = 'General'; Order = 3 }
        'Validate'      = @{ Text = 'Validate'; Category = 'General'; Order = 4 }
        'Restart'       = @{ Text = 'Restart Computer'; Category = 'General'; Order = 5 }
        'NoOp'          = @{ Text = 'Do Nothing'; Category = 'General'; Order = 6 }
        'InstallApplications' = @{ Text = 'Install Applications'; Category = 'General'; Order = 7 }
        # TWO ENTRIES FOR ONE TYPE, AS MDT'S OWN SEQUENCE HAS. Its Standard
        # Client task sequence carries "Format and Partition Disk (BIOS)" and
        # "(UEFI)", each conditioned on the firmware, because the two disks are
        # laid out differently and one sequence has to deploy to both kinds of
        # machine. Offering one and leaving the author to add the condition is
        # how a sequence comes to lay a GPT disk out on a BIOS machine.
        #
        # Variant is what the template is called with; a type without one is
        # offered once, exactly as before.
        'DiskPartition' = @{
            Text = 'Format and Partition Disk'; Category = 'Disks'; Order = 1
            Variant = @(
                @{ Suffix = ' (UEFI)'; Argument = @{ Firmware = 'UEFI' } }
                @{ Suffix = ' (BIOS)'; Argument = @{ Firmware = 'BIOS' } }
            )
        }
        'ApplyImage'    = @{ Text = 'Apply Operating System'; Category = 'Images'; Order = 1 }
        'ApplyUnattend' = @{ Text = 'Apply Windows Settings'; Category = 'Images'; Order = 2 }
        'ConfigureBoot' = @{ Text = 'Configure Boot'; Category = 'Images'; Order = 3 }
        'EnableBitLocker' = @{ Text = 'Enable BitLocker'; Category = 'Disks'; Order = 2 }
        'InstallRoles'  = @{ Text = 'Install Roles and Features'; Category = 'Roles'; Order = 1 }

        # ON THE IMAGES SHELF, AFTER Apply Windows Settings, because that is
        # where it belongs in a sequence: it acts on the volume the image was
        # just applied to, and it has to be there before the Restart below it.
        'InstallCertificate' = @{ Text = 'Install Certificates'; Category = 'Images'; Order = 4 }
    }

    # Workbench's order, and Custom last because it is whatever this particular
    # installation added.
    # Roles sits after Images and before Custom: a server sequence is mostly that
    # one menu, and Workbench gives it a folder of its own for the same reason.
    $shelf = @('General', 'Disks', 'Images', 'Roles', 'Custom')

    $entry = New-Object -TypeName System.Collections.ArrayList

    foreach ($type in $registry) {
        $name = [string] $type.Type

        # A TYPE THE ENGINE CANNOT AUTHOR IS NOT ON THE MENU. It still runs -
        # a sequence naming it executes exactly as before - but nothing here
        # knows what to write for a new one, and inventing it is what this
        # command was changed to stop doing.
        $template = Get-HDTConsoleStepTemplateCommand -StepType $type

        if ($null -eq $template) { continue }

        $text = $name
        $category = 'Custom'
        $order = 0

        # ONE VARIANT BY DEFAULT, WHICH IS THE TYPE ITSELF. A type that needs
        # more than one menu item says so in the table above; everything else
        # goes round this loop once and is unchanged by the existence of the
        # mechanism.
        $variant = @(@{ Suffix = ''; Argument = @{} })

        if ($known.ContainsKey($name)) {
            $text = [string] $known[$name].Text
            $category = [string] $known[$name].Category
            $order = [int] $known[$name].Order

            if ($known[$name].ContainsKey('Variant')) { $variant = @($known[$name].Variant) }
        }

        $offset = 0

        foreach ($current in $variant) {
            $offset++

            $label = '{0}{1}' -f $text, [string] $current.Suffix
            $argument = @{}
            foreach ($key in @($current.Argument.Keys)) { $argument[$key] = $current.Argument[$key] }

            # The display name goes to the template as -Name, so the label an
            # administrator picked and the name written into the file are the
            # same string. The type keeps its own default for anything not in
            # the table above, which is the vendor's wording rather than ours.
            $block = [string[]] @(& $template -Name $label @argument)

            [void] $entry.Add([pscustomobject] @{
                    Text     = $label
                    Type     = $name
                    Kind     = 'Step'
                    Source   = [string] $type.Source

                    Category = $category

                    # Variants keep their declared order among themselves rather
                    # than sorting alphabetically, so (UEFI) stays above (BIOS):
                    # the list is read top to bottom by somebody choosing, and
                    # the common answer belongs first.
                    Order    = ($order * 100) + $offset

                    Command  = ("Add-HDTStep -Line `$line -After '<the selected step>' -Name '{0}' -Type {1}" -f $label, $name)

                    # The lines the menu item actually splices in, straight from
                    # the step type. Every item carries one, group and step
                    # alike, so the handler behind the menu calls Add-HDTStep
                    # -Block and never has to choose a parameter set.
                    Block    = $block
                })
        }
    }

    $result = New-Object -TypeName System.Collections.ArrayList

    # A group is a block of YAML rather than a registered type, so it is added
    # the way a paste is - and it opens the menu, where Workbench puts it.
    [void] $result.Add([pscustomobject] @{
            Category = 'New'
            Item     = [pscustomobject[]] @(
                [pscustomobject] @{
                    Text    = 'New Group'
                    Type    = ''
                    Kind    = 'Group'
                    Source  = 'Hephaestus'
                    Command = "Add-HDTStep -Line `$line -After '<the selected step>' -Block (Get-HDTGroupTemplate)"

                    # Straight from the engine - this window does not get to
                    # decide what a group looks like on disk any more than it
                    # decides what a step looks like. It is a group and nothing
                    # else: an empty group is a document the engine reads, so
                    # there is no placeholder step to delete afterwards.
                    Block   = [string[]] @(Get-HDTGroupTemplate)
                }
            )
        })

    foreach ($name in $shelf) {
        $item = @($entry | Where-Object { $_.Category -eq $name } |
                Sort-Object -Property Order, Text |
                ForEach-Object {
                    [pscustomobject] @{
                        Text    = $_.Text
                        Type    = $_.Type
                        Kind    = $_.Kind
                        Source  = $_.Source
                        Command = $_.Command
                        Block   = $_.Block
                    }
                })

        # An engine with no imaging steps must not show an empty Images
        # submenu - a shelf with nothing on it reads as a broken menu.
        if (@($item).Count -eq 0) { continue }

        [void] $result.Add([pscustomobject] @{
                Category = $name
                Item     = [pscustomobject[]] @($item)
            })
    }

    return [pscustomobject[]] @($result)
}