Public/Uninstall-GreenroomInstance.ps1
|
# SPDX-License-Identifier: AGPL-3.0-or-later # Copyright (C) 2026 Tyler Vigario <# .SYNOPSIS Remove one greenroom instance: its task, its running session and its state. .DESCRIPTION NEVER touches the working directory -- it holds your work. Trust entries in ~/.claude.json are also left alone, which is a known gap rather than a decision: install seeds two per instance and nothing removes them. A third of the script this replaces is simply gone. -InstallDir and -RemoveScripts existed to delete copied scripts and a generated cmd shim out of a bin directory, along with the marker-matching that stopped it deleting somebody else's file of the same name. A module has none of that: the code lives in the module, and removing the code is Uninstall-PSResource. Removing an INSTANCE and removing the SOFTWARE are different operations, and conflating them is what made that flag necessary. Order matters. The task is unregistered first, so its trigger cannot start a replacement watchdog while the rest of this runs. Kills are identity-re-verified immediately before each one, which the script did not do: it enumerated, then killed. A pid recorded moments earlier can already belong to something else. .PARAMETER Name The instance to remove. .PARAMETER KeepState Leave the state directory (config, window record, logs) in place. .PARAMETER NoElevate Refuse rather than re-launch elevated when the instance runs elevated and this shell does not. Without it, every elevated instance named is removed by ONE elevated run at the end, under one UAC prompt, and -KeepState goes with it. .OUTPUTS Greenroom.UninstallResult -- what was actually removed, as data rather than prose. .EXAMPLE Uninstall-GreenroomInstance -Name modtest .EXAMPLE Uninstall-GreenroomInstance -Name modtest -WhatIf .EXAMPLE Uninstall-GreenroomInstance -Name modtest -KeepState Keeps the logs, which is what you want when removing an instance to diagnose it. #> function Uninstall-GreenroomInstance { [CmdletBinding(SupportsShouldProcess)] [OutputType('Greenroom.UninstallResult')] param( [Parameter(Mandatory, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)] [Alias('Instance')] # The same rule Install- enforces. The name becomes a path that is deleted # recursively, so anything install could never have created is refused here: # `..` would name ~/.claude itself, and `*` every instance's state at once. [ValidatePattern('^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,30}[A-Za-z0-9_-])?$')] [string]$Name, [switch]$KeepState, [switch]$NoElevate ) begin { $deferred = [System.Collections.Generic.List[string]]::new() } process { $task = "greenroom-$Name" $root = Get-GreenroomStateRoot $stateDir = Join-Path $root $Name # The pattern refuses the one quirk known to matter -- Windows drops a trailing dot, # so `probe.` would resolve to probe's directory -- but this deletes recursively, so # the resolved path is checked as well: exactly <state root>\<Name>, or nothing. $full = [IO.Path]::GetFullPath($stateDir) if ((Split-Path $full -Parent) -ne [IO.Path]::GetFullPath($root).TrimEnd('\') -or (Split-Path $full -Leaf) -ne $Name) { Write-Error -Category InvalidArgument -Message ("'$Name' does not name its own directory under " + "$root -- it resolves to $full. Nothing was removed.") return } # Read the config BEFORE anything is removed. config.json lives inside the state # directory, so reading it afterwards always returns $null. $cfg = Get-InstanceConfig -Name $Name $taskExists = [bool](Get-ScheduledTask -TaskPath '\' -TaskName $task -ErrorAction Ignore) if (-not $taskExists -and -not (Test-Path -LiteralPath $stateDir)) { Write-Error -Category ObjectNotFound -Message "'$Name' is not installed: no task '$task' and no state directory." return } if (-not $PSCmdlet.ShouldProcess($Name, 'Uninstall-GreenroomInstance')) { return } # The guards Stop- and Restart- have. Uninstalling the session this shell runs inside # kills its own ancestor part-way through, after the task is gone, leaving the state # half-removed. if (Test-SelfIsInstance -Name $Name) { Write-Error -Category InvalidOperation -Message ( "'$Name' is the session this shell is running inside. Uninstalling it from here would kill " + 'this process part-way through and leave it half-removed. Run it from a shell outside the session.') return } # An elevated instance's processes cannot be seen or stopped from an unelevated shell, # and its task cannot be unregistered -- the state would go while the watchdog lived on # and crash-looped for want of its config. It is removed by one elevated run instead. if (-not (Assert-CanActOnInstance -Name $Name -Command 'Uninstall-GreenroomInstance' -NoElevate:$NoElevate -Defer $deferred)) { return } # The task first. Its trigger would otherwise be free to start a replacement # watchdog while the kills below are still running. if ($taskExists) { Stop-ScheduledTask -TaskName $task -ErrorAction SilentlyContinue Unregister-ScheduledTask -TaskName $task -Confirm:$false Write-Verbose "task removed: $task" } else { Write-Verbose "no scheduled task named $task" } $shells = 'pwsh.exe', 'powershell.exe' $watchdog = Stop-VerifiedProcess -ProcessName $shells -Pattern (Get-InstanceScriptPattern -Script 'greenroom-watchdog.ps1' -Name $Name) -Label 'watchdog' $session = Stop-VerifiedProcess -ProcessName 'claude.exe' -SessionOf $Name -Label 'session' $launcher = Stop-VerifiedProcess -ProcessName $shells -Pattern (Get-InstanceScriptPattern -Script 'greenroom-launch.ps1' -Name $Name) -Label 'launcher' $stateRemoved = $false if ($KeepState) { Write-Verbose "state kept: $stateDir" } elseif (Test-Path -LiteralPath $stateDir) { Remove-Item -LiteralPath $stateDir -Recurse -Force $stateRemoved = $true Write-Verbose "state removed: $stateDir" } # Returned rather than printed. The facts about what survived are the ones an # operator needs, and as data they can be asserted on instead of read. [PSCustomObject]@{ PSTypeName = 'Greenroom.UninstallResult' Instance = $Name TaskRemoved = $taskExists WatchdogStopped = $watchdog SessionStopped = $session LauncherStopped = $launcher StateRemoved = $stateRemoved WorkingDirectory = if ($cfg) { $cfg.workingDirectory } else { $null } } } end { if ($deferred.Count) { $carry = if ($KeepState) { @('KeepState') } else { @() } Invoke-DeferredElevation -Command 'Uninstall-GreenroomInstance' -Name $deferred -Cmdlet $PSCmdlet -Forward $carry } } } |