Public/Remove-OSDAppStaging.ps1

function Remove-OSDAppStaging {
    <#
    .SYNOPSIS
    Removes selected applications from the pending OSDApps SetupComplete queue.
    .DESCRIPTION
    Unstages app-specific files on the offline Windows volume, without touching
    the USB cache or uninstalling software. The last app also removes only the
    OSDApps-owned SetupComplete blocks and standalone runtime scripts.
    Full Windows requires -TestMode, which is limited to an isolated TEMP folder.
    .EXAMPLE
    Remove-OSDAppStaging -Name MicrosoftTeams -WindowsPath 'C:\' -WhatIf
    .EXAMPLE
    Remove-OSDAppStaging -Name MicrosoftTeams -TestMode -Confirm:$false
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')]
    param(
        [Parameter(Mandatory, Position=0)]
        [ValidateNotNullOrEmpty()]
        [string[]]$Name,

        [string]$WindowsPath,

        [switch]$TestMode
    )

    $winPE = Test-OSDAppWinPE
    if ($TestMode) {
        if ($winPE) {
            throw '-TestMode is only for full Windows; use -WindowsPath in WinPE.'
        }
        if ($PSBoundParameters.ContainsKey('WindowsPath')) {
            throw '-TestMode uses only the isolated TEMP target. Omit -WindowsPath.'
        }
        $target = Get-OSDAppUITestWindowsPath
    }
    else {
        if (-not $winPE) {
            throw 'Removing staged apps on full Windows requires -TestMode to protect the active Windows installation.'
        }
        $target = Resolve-OSDAppWindowsPath -WindowsPath $WindowsPath
    }

    $stageRoot = Join-Path $target 'Windows\Temp\OSDApps'
    $manifestPath = Join-Path $stageRoot 'DeviceManifest.json'
    if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) {
        throw "DeviceManifest.json not found: $manifestPath"
    }

    $manifest = Get-Content -LiteralPath $manifestPath -Raw -Encoding UTF8 |
        ConvertFrom-Json -ErrorAction Stop
    if ($null -eq $manifest -or -not ($manifest.PSObject.Properties.Name -contains 'Apps')) {
        throw "Invalid DeviceManifest.json: Apps property is missing."
    }

    $requested = @($Name | Select-Object -Unique)
    foreach ($id in $requested) {
        if ([string]$id -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]*$' -or [string]$id -match '\.\.') {
            throw "Invalid application ID '$id'."
        }
    }

    $staged = @($manifest.Apps | Where-Object { $null -ne $_ })
    $toRemove = @($staged | Where-Object { [string]$_.Id -in $requested })
    if ($toRemove.Count -eq 0) {
        return [pscustomobject]@{
            WindowsPath = $target
            Removed = @()
            Remaining = @($staged | ForEach-Object { [string]$_.Id })
            Changed = $false
        }
    }

    # Validate all paths BEFORE any change, including the last-app cleanup.
    foreach ($app in $toRemove) {
        $id = [string]$app.Id
        if ($id -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]*$' -or $id -match '\.\.') {
            throw "Invalid staged application ID '$id'."
        }
        if ([string]$app.Source -notin @('BuiltIn','Repository')) {
            throw "Unexpected staged source '$($app.Source)' for '$id'."
        }
    }

    $remaining = @($staged | Where-Object { [string]$_.Id -notin $requested })
    if ($remaining.Count -eq 0) {
        # Preflight validation of the existing OSDApps SetupComplete blocks.
        # A malformed SetupComplete must not be edited, nor may the manifest.
        Remove-OSDAppSetupComplete -WindowsPath $target -WhatIf -ErrorAction Stop | Out-Null
    }

    $action = "Unstage $($toRemove.Count) application(s) from DeviceManifest and the offline Windows staging folder"
    if (-not $PSCmdlet.ShouldProcess($target, $action)) {
        return
    }

    if ($remaining.Count -eq 0) {
        Remove-OSDAppSetupComplete -WindowsPath $target -Confirm:$false -ErrorAction Stop | Out-Null
        Remove-Item -LiteralPath $manifestPath -Force -ErrorAction Stop
    }
    else {
        $manifest.Apps = @($remaining)
        $manifest.StagedAt = (Get-Date).ToUniversalTime().ToString('o')
        $manifest | ConvertTo-Json -Depth 20 |
            Set-Content -LiteralPath $manifestPath -Encoding UTF8 -ErrorAction Stop
    }

    foreach ($app in $toRemove) {
        $id = [string]$app.Id
        $relative = if ($app.Source -eq 'BuiltIn') {
            Join-Path 'BuiltIn' $id
        }
        else {
            Join-Path 'Packages' $id
        }
        $path = Join-Path $stageRoot $relative
        if (Test-Path -LiteralPath $path) {
            Remove-Item -LiteralPath $path -Recurse -Force -ErrorAction Stop
        }
    }

    if ($remaining.Count -eq 0) {
        # Remove only OSDApps-owned scripts. Leave other files and folders alone.
        foreach ($file in @('Invoke-OSDAppRunner.ps1','Invoke-OSDAppPreInstall.ps1')) {
            $path = Join-Path $stageRoot $file
            if (Test-Path -LiteralPath $path -PathType Leaf) {
                Remove-Item -LiteralPath $path -Force -ErrorAction Stop
            }
        }
    }

    [pscustomobject]@{
        PSTypeName = 'OSDApps.UnstagedApps'
        WindowsPath = $target
        Removed = @($toRemove | ForEach-Object { [string]$_.Id })
        Remaining = @($remaining | ForEach-Object { [string]$_.Id })
        Changed = $true
    }
}