Public/Remove-HDTOperatingSystem.ps1

function Remove-HDTOperatingSystem {
    <#
        .SYNOPSIS
            Removes an imported operating system from a deployment share.
 
        .DESCRIPTION
            THE OTHER HALF OF Import-HDTOperatingSystem, and until it existed an
            OS imported by mistake had to be removed with Explorer - which is
            how somebody deletes the wrong folder. Deployment Workbench offers
            Delete on the right-click menu of an imported OS; this is the
            command behind it.
 
            IT DELETES A FOLDER, so the guards are the ones CLAUDE.md demands of
            anything that does. The id names a folder under OperatingSystems\
            and is checked TWICE: once as typed - no separators, no spaces, not
            . or .. - and once as resolved, because what matters is where it
            ended up rather than how it looked. A target that is not provably
            inside this share's OperatingSystems folder is refused with nothing
            removed.
 
            A FOLDER WITH NO os.yaml IS NOT AN OPERATING SYSTEM. It is somebody's
            staging directory that happens to sit under OperatingSystems\, and
            this command must not be the thing that removes it.
 
            WHICH SEQUENCES WERE USING IT IS REPORTED, NOT ENFORCED. Removing
            media a sequence applies leaves a deployment that fails at Apply
            Operating System - minutes in, on the machine in front of somebody -
            so every sequence naming this id comes back in UsedBy. It is not a
            refusal: an administrator replacing media knows more than this
            command does, and a delete that could not be completed because
            something referred to it is a delete nobody can ever do.
 
            ConfirmImpact IS High, so it prompts unless the caller says
            -Confirm:$false. The console says it, having asked in its own dialog.
 
        .PARAMETER Workspace
            The share's root.
 
        .PARAMETER Id
            The operating system's id - the folder name under OperatingSystems\.
 
        .PARAMETER FileSystem
            An IFileSystem. Defaults to the real one.
 
        .INPUTS
            None. This command does not accept pipeline input.
 
        .OUTPUTS
            System.Management.Automation.PSCustomObject with Id, Path and
            UsedBy - the ids of the task sequences that named it.
 
        .EXAMPLE
            Remove-HDTOperatingSystem -Workspace 'C:\HDTLab\Share' -Id 'WS2025-Std'
 
        .LINK
            Import-HDTOperatingSystem
    #>

    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory = $true, Position = 0)]
        [ValidateNotNullOrEmpty()]
        [string] $Workspace,

        [Parameter(Mandatory = $true, Position = 1)]
        [ValidateNotNullOrEmpty()]
        [string] $Id,

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

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

    if ($null -eq $FileSystem) { $FileSystem = New-HDTFileSystem }

    # -- the id, before it becomes a path ------------------------------------

    if ($Id -match '[\\/:*?"<>|]' -or $Id -match '\s' -or $Id -eq '.' -or $Id -eq '..') {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -TargetObject $Id `
                    -Message ("'{0}' cannot be an operating system id. An id is a folder name: no spaces, and none of \ / : * ? `" < > |. This command deletes the folder it names, so an id that is a path is refused rather than resolved." -f $Id)))
    }

    $folder = Get-HDTWorkspacePath -Root $Workspace -Kind OperatingSystems -ChildPath $Id
    $document = Join-Path -Path $folder -ChildPath 'os.yaml'

    # -- and the path, after -------------------------------------------------
    #
    # THE SECOND CHECK IS NOT THE FIRST ONE AGAIN. That one judged what was
    # typed; this judges what it resolved to, which is the thing about to be
    # removed.
    $operatingSystemRoot = Get-HDTWorkspacePath -Root $Workspace -Kind OperatingSystems

    if (-not ([System.IO.Path]::GetFullPath($folder)).StartsWith(
            ([System.IO.Path]::GetFullPath($operatingSystemRoot)).TrimEnd('\') + '\',
            [System.StringComparison]::OrdinalIgnoreCase)) {

        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -TargetObject $folder `
                    -Message ("'{0}' resolves to '{1}', which is not inside this workspace's OperatingSystems folder. Nothing was removed." -f $Id, $folder)))
    }

    # -- is it an imported operating system at all? --------------------------

    if (-not $FileSystem.TestPath($folder)) {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -TargetObject $folder -Category ObjectNotFound `
                    -Message ("this workspace has no operating system called '{0}'." -f $Id)))
    }

    if (-not $FileSystem.TestPath($document)) {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -TargetObject $folder `
                    -Message ("'{0}' holds no os.yaml, so it is not an imported operating system - it is a folder that happens to sit under OperatingSystems. Remove it yourself if that is what you meant." -f $folder)))
    }

    # -- who was using it ----------------------------------------------------
    #
    # READ BEFORE THE DELETE, because afterwards the answer is the same and the
    # media is gone. A sequence that will not read is skipped rather than
    # failing the removal: it has its own broken row in the console already.
    $usedBy = New-Object -TypeName System.Collections.ArrayList
    $sequenceRoot = Get-HDTWorkspacePath -Root $Workspace -Kind TaskSequences

    if ($FileSystem.TestPath($sequenceRoot)) {
        foreach ($child in @($FileSystem.GetChildItem($sequenceRoot))) {

            $sequencePath = Join-Path -Path ([string] $child) -ChildPath 'sequence.yaml'
            if (-not $FileSystem.TestPath($sequencePath)) { continue }

            $text = [string] $FileSystem.ReadAllText($sequencePath)

            # THE KEY IS operatingSystem OR os, because both spellings reach the
            # ApplyImage step - matching the id anywhere in the file would count
            # a sequence that merely mentions it in a comment.
            if ($text -notmatch ('(?m)^\s*(operatingSystem|os)\s*:\s*[''"]?{0}[''"]?\s*$' -f [regex]::Escape($Id))) { continue }

            $named = ''

            try {
                $parsed = ConvertFrom-HDTYaml -Yaml $text -Path $sequencePath
                $named = [string] $parsed['id']
            } catch {
                $named = ''
            }

            if ([string]::IsNullOrWhiteSpace($named)) {
                $named = [System.IO.Path]::GetFileName(([string] $child).TrimEnd('\', '/'))
            }

            [void] $usedBy.Add($named)
        }
    }

    $said = "Remove operating system '{0}' and everything in its folder" -f $Id
    if (@($usedBy).Count -gt 0) {
        $said = '{0}. It is named by: {1}' -f $said, (@($usedBy) -join ', ')
    }

    if (-not $PSCmdlet.ShouldProcess($folder, $said)) {
        return [pscustomobject] @{
            Id     = $Id
            Path   = $folder
            UsedBy = [string[]] @($usedBy)
        }
    }

    $FileSystem.RemoveItem($folder, $true)

    return [pscustomobject] @{
        Id     = $Id
        Path   = $folder
        UsedBy = [string[]] @($usedBy)
    }
}