Public/Set-HDTWorkspaceProperty.ps1

function Set-HDTWorkspaceProperty {
    <#
        .SYNOPSIS
            Changes the single-value settings of a workspace document, leaving
            every other line byte-identical.
 
        .DESCRIPTION
            The command an administrator types to change what a share is called,
            where its clients reach it, how loudly it logs and how its boot image
            is built - and the one anything with a properties page has to run.
 
            IT REPLACES A KEY, NOT A FILE. Each setting is a line, and only the
            lines named are rewritten. The comment header, the notes beside the
            keys and the spacing between everything come back exactly as they went
            in, which is the whole point: workspace.yaml is hand-edited from the
            day it is created, and an editor that reformats it makes every change
            unreviewable.
 
            THE ID IS NOT HERE, AND THAT IS DELIBERATE. It is carried into every
            boot image and written into log and artifact names, so changing it
            after a share has produced anything leaves artifacts that no longer
            agree with the share that made them. Create the share you meant with
            New-HDTWorkspace.
 
            IT BUILDS THE bootImage BLOCK WHEN THERE IS NONE. New-HDTWorkspace
            writes no boot image settings at all - an omitted setting takes the
            engine's default, and a copied-out default is one that goes stale - so
            the first one written has to create the block.
 
            EVERY VALUE IS CHECKED BEFORE ANY LINE IS RETURNED, against the same
            rules the engine applies when it loads the file. A scratch space
            outside the range DISM accepts is refused here, naming the range,
            rather than at Save with several more edits stacked on top.
 
            IT RETURNS LINES AND WRITES NOTHING. Save-HDTWorkspaceDocument is what
            touches the share.
 
        .PARAMETER Line
            The document, already split into lines.
 
        .PARAMETER Name
            The display name an administrator reads in the console and in a log
            line.
 
        .PARAMETER DeployRoot
            The path a machine that has booted the image uses to reach this share
            - usually a UNC path, and not necessarily the path you are editing
            through. It is carried into the boot image, so a change here takes
            effect the next time the image is built. Empty means the technician is
            asked for one at the Welcome screen.
 
        .PARAMETER LogLevel
            How much every deployment from this share writes.
 
        .PARAMETER CredentialUser
            The account a boot image signs in to the share as, written as
            credential.username. The PASSWORD is not here and never will be -
            Set-HDTShareCredential writes that into
            Control\share-credential.json, because this document is hand-edited
            and committed.
 
        .PARAMETER BootImageName
            The artifact base name, which becomes Boot\<name>.wim and
            Boot\<name>.iso.
 
        .PARAMETER Architecture
            The WinPE architecture the ADK ships.
 
        .PARAMETER Language
            The ADK language folder, for example en-us.
 
        .PARAMETER ScratchSpaceMB
            The writable RAM disk inside WinPE, in megabytes. DISM accepts 32 to
            1024.
 
        .PARAMETER PromptForKey
            Whether the ISO stops at "Press any key to boot from CD or DVD".
            False - the default for every image HDT has ever built - is the
            quiet UEFI boot sector, so a machine nobody is standing at boots
            without a keypress. It is written whichever way it is passed,
            because 'false' stated is what tells the next reader that somebody
            decided rather than never looked.
 
        .PARAMETER EntryCommand
            What startnet.cmd launches instead of the deployment payload - a
            diagnostic image, or standalone media with its own entry point.
 
        .INPUTS
            None. This command does not accept pipeline input.
 
        .OUTPUTS
            System.String[] - the document with those settings changed.
 
        .EXAMPLE
            Set-HDTWorkspaceProperty -Line $line -DeployRoot '\\HDT-HOST\HdtShare'
 
        .EXAMPLE
            Set-HDTWorkspaceProperty -Line $line -LogLevel 'Debug' -ScratchSpaceMB 1024
 
        .EXAMPLE
            Set-HDTWorkspaceProperty -Line $line -EntryCommand 'powershell.exe -NoProfile -ExecutionPolicy Bypass -File X:\HDT\Start-HDTDiagnostic.ps1'
 
            A diagnostic image, which boots to something other than a deployment.
 
        .LINK
            Save-HDTWorkspaceDocument
 
        .LINK
            New-HDTWorkspace
    #>

    # NAMED, NOT BLANKET. The analyzer reads 'Credential' in the parameter name
    # and asks for a SecureString; this one is a USERNAME, and the reason it is
    # a plain string is the same reason there is no -Password here at all -
    # workspace.yaml is hand-edited and committed, so the secret lives in
    # Control\share-credential.json and Set-HDTShareCredential writes it.
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPlainTextForPassword', 'CredentialUser',
        Justification = 'A username, not a secret. No password may be written to workspace.yaml; Set-HDTShareCredential owns that.')]
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low')]
    [OutputType([string[]])]
    param(
        [Parameter(Mandatory = $true, Position = 0)]
        [AllowEmptyCollection()]
        [AllowEmptyString()]
        [string[]] $Line,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string] $Name,

        [Parameter()]
        [AllowEmptyString()]
        [string] $DeployRoot,

        [Parameter()]
        [ValidateSet('Error', 'Warning', 'Info', 'Debug')]
        [string] $LogLevel,

        # THE DECLARATION, NOT THE SECRET, AND THERE IS NO -Password HERE.
        # Set-HDTShareCredential writes the password into
        # Control\share-credential.json; workspace.yaml is the document an
        # administrator hand-edits and commits, and a secret in it ends up in
        # git. Update-HDTBootImage refuses a build where the document declares
        # an account and no secret has been written for it, so both halves have
        # to be settable.
        [Parameter()]
        [AllowEmptyString()]
        [string] $CredentialUser,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string] $BootImageName,

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

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string] $Language,

        # NOT ValidateRange. The range is DISM's, and a caller who computed a
        # number outside it deserves the sentence that says whose range it is and
        # what it is - which is a terminating HDTConfigurationError like every
        # other refusal here, rather than a parameter binding failure.
        [Parameter()]
        [int] $ScratchSpaceMB,

        [Parameter()]
        [bool] $PromptForKey,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string] $EntryCommand
    )

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

    $minimumScratchSpaceMB = 32
    $maximumScratchSpaceMB = 1024

    # The root settings and the boot image settings, in the order a workspace
    # document is written in. Written once so the loop below is the only place
    # that knows how a scalar is spliced.
    $setting = @(
        @{ Parameter = 'Name'; Path = @('name') }
        @{ Parameter = 'DeployRoot'; Path = @('deployRoot') }
        @{ Parameter = 'LogLevel'; Path = @('logLevel') }
        @{ Parameter = 'CredentialUser'; Path = @('credential', 'username') }
        @{ Parameter = 'BootImageName'; Path = @('bootImage', 'name') }
        @{ Parameter = 'Architecture'; Path = @('bootImage', 'architecture') }
        @{ Parameter = 'Language'; Path = @('bootImage', 'language') }
        @{ Parameter = 'ScratchSpaceMB'; Path = @('bootImage', 'scratchSpaceMB') }
        @{ Parameter = 'PromptForKey'; Path = @('bootImage', 'promptForKey') }
        @{ Parameter = 'EntryCommand'; Path = @('bootImage', 'entryCommand') }
    )

    $asked = @($setting | Where-Object { $PSBoundParameters.ContainsKey([string] $_.Parameter) })

    if (@($asked).Count -eq 0) {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -TargetObject 'workspace.yaml' `
                    -Message ("nothing was asked to change. Pass one of -{0}." -f
                        (@($setting | ForEach-Object { [string] $_.Parameter }) -join ', -'))))
    }

    # -- what is being asked for ---------------------------------------------

    if ($PSBoundParameters.ContainsKey('DeployRoot') -and $DeployRoot -like '*..*') {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -TargetObject $DeployRoot `
                    -Message ("deployRoot '{0}' contains '..'. A deployment root is named outright, not walked up to." -f $DeployRoot)))
    }

    if ($PSBoundParameters.ContainsKey('ScratchSpaceMB') -and
        ($ScratchSpaceMB -lt $minimumScratchSpaceMB -or $ScratchSpaceMB -gt $maximumScratchSpaceMB)) {

        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -TargetObject $ScratchSpaceMB `
                    -Message ("scratchSpaceMB {0} is outside the range DISM accepts. It must be between {1} and {2}." -f
                        $ScratchSpaceMB, $minimumScratchSpaceMB, $maximumScratchSpaceMB)))
    }

    if ($PSBoundParameters.ContainsKey('BootImageName') -and
        $BootImageName -notmatch '^[A-Za-z0-9][A-Za-z0-9_.-]*$') {

        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -TargetObject $BootImageName `
                    -Message ("bootImage name '{0}' is not a legal artifact name. It becomes the base name of the .wim and the .iso under Boot\, so it must start with a letter or a digit and hold only letters, digits, underscore, dot and hyphen." -f $BootImageName)))
    }

    if ($PSBoundParameters.ContainsKey('EntryCommand') -and
        $EntryCommand.IndexOfAny([char[]] @("`r", "`n")) -ge 0) {

        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -TargetObject $EntryCommand `
                    -Message 'entryCommand must be one command on one line. A line break here becomes a second command inside startnet.cmd that nobody reading the workspace document would see.'))
    }

    # The document has to be readable before it is worth editing.
    [void] (ConvertFrom-HDTWorkspaceLine -Line $Line)

    if (-not $PSCmdlet.ShouldProcess((@($asked | ForEach-Object { [string] $_.Parameter }) -join ', '),
            'Change the workspace settings')) {
        return [string[]] @($Line)
    }

    # -- the splices ----------------------------------------------------------
    #
    # ONE KEY AT A TIME, RESOLVED AGAINST THE RESULT OF THE LAST. Each splice
    # changes how many lines are above the next, so a range held across two of
    # them points at the wrong lines for the second.

    $result = [string[]] @($Line)

    foreach ($current in $asked) {
        $path = [string[]] @($current.Path)
        $key = [string] $path[@($path).Count - 1]
        $value = $PSBoundParameters[[string] $current.Parameter]

        $result = [string[]] @(Set-HDTWorkspaceKey -Line $result -Path $path `
                -Text ([string[]] @('{0}: {1}' -f $key, (ConvertTo-HDTRuleScalarText -Value $value))))
    }

    try {
        [void] (ConvertFrom-HDTWorkspaceLine -Line $result)
    } catch {
        $PSCmdlet.ThrowTerminatingError($_)
    }

    return [string[]] $result
}