Repository navigation
Expand file tree
/
Copy pathuninstall.ps1
More file actions
324 lines (293 loc) · 16.4 KB
/
Copy pathuninstall.ps1
File metadata and controls
324 lines (293 loc) · 16.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
#Requires -Version 5.1
<#
.SYNOPSIS
Clauster uninstaller (Windows, PowerShell) - the counterpart to install.ps1 (#816).
irm https://raw.githubusercontent.com/schubydoo/clauster/main/uninstall.ps1 | iex
Auto-detects how clauster was installed (the standalone clauster.exe from
install.ps1, or a `uv tool` / `pipx` / `pip` / `scoop` package), removes the right
artifact, removes the install dir from the user PATH, and removes the state
directory (clauster.db, state.json, hosted_state.json, tls\, backups\, sockets,
logs) and the config yaml.
Safe by construction: -DryRun prints what would be removed and changes nothing; a
confirmation prompt guards any deletion (skip with -Yes); -KeepConfig / -KeepData
move clauster.yml / clauster.db aside to a printed backup path; it never deletes a
path outside the known clauster locations and refuses a state dir that resolves to
the user profile or a drive root; and it fails closed (reports, exits non-zero)
rather than guessing when no install can be identified.
Environment overrides (mirror install.ps1 + the app's resolution):
CLAUSTER_INSTALL_DIR where clauster.exe lives; default: %LOCALAPPDATA%\Programs\clauster
CLAUSTER_STATE_DIR state directory; default: read from config, else ~\.clauster
CLAUSTER_CONFIG config path; default: the app's search order
#>
param(
[switch]$DryRun,
[switch]$Yes,
[switch]$KeepConfig,
[switch]$KeepData,
[switch]$KeepDeps,
[switch]$Help
)
$ErrorActionPreference = 'Stop'
function Write-Info($m) { Write-Host "[INFO] $m" -ForegroundColor Blue }
function Write-Ok($m) { Write-Host "[ OK ] $m" -ForegroundColor Green }
function Write-Warn($m) { Write-Host "[WARN] $m" -ForegroundColor Yellow }
function Write-Err($m) { Write-Host "[ERR ] $m" -ForegroundColor Red }
function Show-Usage {
Write-Host @'
Usage: uninstall.ps1 [-DryRun] [-Yes] [-KeepConfig] [-KeepData] [-KeepDeps] [-Help]
-DryRun Show what would be removed without removing anything.
-Yes Do not prompt for confirmation.
-KeepConfig Preserve clauster.yml (moved aside to a printed backup path).
-KeepData Preserve clauster.db (moved aside to a printed backup path).
-KeepDeps Preserve side-installed optional deps (pty/notify extras, Shawl) in
<state_dir>\deps (moved aside to a printed backup path).
-Help Show this help.
'@
}
# --- Resolution ------------------------------------------------------------
function Resolve-ConfigPath {
if ($env:CLAUSTER_CONFIG) {
# Expand a leading ~ the way the app (Path.expanduser) does — -LiteralPath does not.
$envCfg = Expand-Home $env:CLAUSTER_CONFIG
if (Test-Path -LiteralPath $envCfg -PathType Leaf) { return (Resolve-Path -LiteralPath $envCfg).Path }
}
if (Test-Path -LiteralPath '.\clauster.yml' -PathType Leaf) {
return (Resolve-Path -LiteralPath '.\clauster.yml').Path
}
if ($env:CLAUSTER_HOME) {
$homeCfg = Join-Path (Expand-Home $env:CLAUSTER_HOME) 'clauster.yml'
if (Test-Path -LiteralPath $homeCfg -PathType Leaf) { return $homeCfg }
}
return $null
}
function Expand-Home([string]$p) {
if ($p -eq '~') { return $env:USERPROFILE }
if ($p -like '~[/\]*') { return (Join-Path $env:USERPROFILE $p.Substring(2)) }
return $p
}
function Resolve-StateDir {
if ($env:CLAUSTER_STATE_DIR) { return (Expand-Home $env:CLAUSTER_STATE_DIR) }
$cfg = Resolve-ConfigPath
if ($cfg) {
foreach ($line in Get-Content -LiteralPath $cfg) {
if ($line -match '^\s*state_dir:\s*(.+?)\s*(#.*)?$') {
$val = $Matches[1].Trim().Trim('"').Trim("'")
if ($val) { return (Expand-Home $val) }
}
}
}
return (Join-Path $env:USERPROFILE '.clauster')
}
# Guard: refuse a state dir that is the user profile or a bare drive root.
function Test-StateDirSafe([string]$d) {
if ([string]::IsNullOrWhiteSpace($d)) { return $false }
$full = [System.IO.Path]::GetFullPath($d).TrimEnd('\')
$profile = $env:USERPROFILE.TrimEnd('\')
if ($full -eq $profile) { return $false }
# A drive root like "C:\" has no parent directory.
if ($null -eq [System.IO.Path]::GetDirectoryName($full)) { return $false }
return $true
}
function Test-CommandHasClauster([string]$exe, [string[]]$listArgs, [string]$pattern) {
if (-not (Get-Command $exe -ErrorAction SilentlyContinue)) { return $false }
try { $out = & $exe @listArgs 2>$null } catch { return $false }
return (($out | Out-String) -match $pattern)
}
function Uninstall-Clauster {
if ($Help) { Show-Usage; $global:LASTEXITCODE = 0; return }
$installDir = if ($env:CLAUSTER_INSTALL_DIR) { $env:CLAUSTER_INSTALL_DIR }
else { Join-Path $env:LOCALAPPDATA 'Programs\clauster' }
$binaryPath = Join-Path $installDir 'clauster.exe'
$stateDir = Resolve-StateDir
$configPath = Resolve-ConfigPath
# Side-installed optional deps live under <state_dir>\deps (pip extras + bin\shawl.exe). Removed
# with the state dir below; enumerated in the plan, and -KeepDeps preserves them (#904).
$depsDir = Join-Path $stateDir 'deps'
$managedDeps = @()
if (Test-Path -LiteralPath $depsDir) {
foreach ($k in @('pyte', 'pywinpty', 'apprise')) {
if (Get-ChildItem -LiteralPath $depsDir -Directory -Filter "$k-*.dist-info" -ErrorAction SilentlyContinue) {
$managedDeps += $k
}
}
if (Test-Path -LiteralPath (Join-Path $depsDir 'bin\shawl.exe')) { $managedDeps += 'shawl' }
}
# Windows service registered by `install-service windows` (Shawl-wrapped, named "Clauster").
# Detected so uninstall stops + deletes it — otherwise it lingers pointing at a removed binary.
# The name is overridable ($CLAUSTER_WINDOWS_SERVICE) so tests can target a throwaway service.
$serviceName = if ($env:CLAUSTER_WINDOWS_SERVICE) { $env:CLAUSTER_WINDOWS_SERVICE } else { 'Clauster' }
$servicePresent = [bool](Get-Service -Name $serviceName -ErrorAction SilentlyContinue)
$script:RemovalFailed = $false # set true by Remove-Target on any removal error
# --- Detection: collect every install method present -------------------
$detected = New-Object System.Collections.Generic.List[string]
$isUv = Test-CommandHasClauster 'uv' @('tool','list') '(?im)^clauster\b'
$isPipx = Test-CommandHasClauster 'pipx' @('list','--short') '(?im)(^|\s)clauster(\s|$)'
$isScoop = Test-CommandHasClauster 'scoop' @('list') '(?im)(^|\s)clauster(\s|$)'
$pip = if (Get-Command 'pip' -ErrorAction SilentlyContinue) { 'pip' }
elseif (Get-Command 'pip3' -ErrorAction SilentlyContinue) { 'pip3' } else { $null }
$isPip = $false
if ($pip) { & $pip show clauster *> $null; $isPip = ($LASTEXITCODE -eq 0) }
# Detected independently of the package managers so a stale standalone exe that
# coexists with a uv/pipx/scoop install is still removed (its removal runs after the
# package uninstalls, so a shim already gone is a harmless no-op).
$isBinary = (Test-Path -LiteralPath $binaryPath)
if ($isUv) { $detected.Add('uv tool') }
if ($isPipx) { $detected.Add('pipx') }
if ($isScoop) { $detected.Add('scoop') }
if ($isPip) { $detected.Add('pip') }
if ($isBinary){ $detected.Add("binary:$binaryPath") }
# --- Plan --------------------------------------------------------------
$dryLabel = if ($DryRun) { ' (dry run)' } else { '' }
Write-Info "Clauster uninstaller$dryLabel"
Write-Host ''
Write-Info ("Detected install method(s): " + $(if ($detected.Count) { $detected -join ', ' } else { 'none' }))
Write-Info ("State directory: $stateDir" + $(if (Test-Path -LiteralPath $stateDir) { '' } else { ' (absent)' }))
if ($managedDeps.Count) {
$depsSummary = ($managedDeps -join ', ')
if ($KeepDeps) { Write-Info "Side-installed deps: $depsSummary (KEPT - moved to backup)" }
else { Write-Info "Side-installed deps: $depsSummary (in $depsDir; keep with -KeepDeps)" }
}
if ($servicePresent) { Write-Info "Windows service: $serviceName" }
Write-Info ("Config file: " + $(if ($configPath) { $configPath } else { '<none found>' }))
Write-Host ''
$stateExists = Test-Path -LiteralPath $stateDir
$configExists = $configPath -and (Test-Path -LiteralPath $configPath)
if ($detected.Count -eq 0 -and -not $stateExists -and -not $configExists -and -not $servicePresent) {
throw 'No clauster install found (no binary/package, state dir, config, or service). Nothing to do.'
}
# Fail closed: leftover files with no identifiable install method are ambiguous. A registered
# "Clauster" service IS unambiguously ours, so its presence lets the run proceed (and remove it).
if ($detected.Count -eq 0 -and -not $servicePresent) {
Write-Warn 'Could not identify how clauster was installed (no binary/package found).'
Write-Warn 'Leaving files in place. If you know they are clauster''s, remove them manually:'
if ($stateExists) { Write-Warn " Remove-Item -Recurse -Force '$stateDir'" }
if ($configExists) { Write-Warn " Remove-Item -Force '$configPath'" }
throw 'Refusing to guess-and-delete.'
}
# --- Confirm -----------------------------------------------------------
if (-not $DryRun -and -not $Yes) {
$reply = Read-Host 'Proceed with removal? [y/N]'
if ($reply -notmatch '^(y|yes)$') { Write-Info 'Aborted.'; $global:LASTEXITCODE = 0; return }
}
function Remove-Target([string]$path, [switch]$Recurse) {
if (-not (Test-Path -LiteralPath $path)) { return }
if ($DryRun) { Write-Host " would remove: $path"; return }
# Fail LOUD, never silent: a swallowed error (e.g. a locked file) would leave
# state — incl. session.secret / session.epoch auth material — behind while the
# script still reports success. Surface it + flag the run so the summary warns.
try { Remove-Item -LiteralPath $path -Force -Recurse:$Recurse -ErrorAction Stop }
catch {
Write-Warn "Could not remove $path - remove it manually: $($_.Exception.Message)"
$script:RemovalFailed = $true
}
}
function Invoke-Step([string]$desc, [scriptblock]$action) {
if ($DryRun) { Write-Host " would: $desc" } else { & $action }
}
# --- 1) Windows service (stop + delete) --------------------------------
# Do this BEFORE removing the binary so the service isn't left pointing at a deleted exe. The
# service was registered by Shawl (`shawl add` = sc create); `sc delete` unregisters it (Shawl
# exits when its wrapped process is stopped, so no separate Shawl teardown is needed).
if ($servicePresent) {
Write-Info "Removing Windows service '$serviceName' (needs elevation)..."
Invoke-Step "sc.exe stop $serviceName; sc.exe delete $serviceName" {
& sc.exe stop $serviceName | Out-Null # a not-running service failing to stop is benign
& sc.exe delete $serviceName | Out-Null
# sc.exe delete needs elevation; a non-elevated run fails with "Access is denied" (exit 5)
# which PS 5.1 does NOT turn into a terminating error. Check it so we never report a clean
# uninstall while the service is left pointing at a removed binary (fail loud, like Remove-Target).
if ($LASTEXITCODE -ne 0) {
Write-Warn "Could not delete service '$serviceName' (exit $LASTEXITCODE) - re-run elevated, or: sc.exe delete $serviceName"
$script:RemovalFailed = $true
}
}
}
# --- 2) Package / binary, per detected method --------------------------
foreach ($m in $detected) {
switch -Wildcard ($m) {
'uv tool' { Write-Info 'Removing uv tool...'; Invoke-Step 'uv tool uninstall clauster' { & uv tool uninstall clauster } }
'pipx' { Write-Info 'Removing pipx package...'; Invoke-Step 'pipx uninstall clauster' { & pipx uninstall clauster } }
'scoop' { Write-Info 'Removing scoop package...'; Invoke-Step 'scoop uninstall clauster' { & scoop uninstall clauster } }
'pip' { Write-Info 'Removing pip package...'; Invoke-Step "$pip uninstall -y clauster" { & $pip uninstall -y clauster } }
'binary:*' {
Write-Info 'Removing binary...'
Remove-Target ($m -replace '^binary:', '')
# Drop the install dir from the user PATH (mirrors install.ps1's add).
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
if ($userPath -and (($userPath -split ';') -contains $installDir)) {
$newPath = (($userPath -split ';') | Where-Object { $_ -and $_ -ne $installDir }) -join ';'
Invoke-Step "remove '$installDir' from user PATH" { [Environment]::SetEnvironmentVariable('Path', $newPath, 'User') }
}
# Remove the now-empty install dir.
if ((Test-Path -LiteralPath $installDir) -and -not (Get-ChildItem -LiteralPath $installDir -Force -ErrorAction SilentlyContinue)) {
Remove-Target $installDir -Recurse
}
}
}
}
# --- 3) Preserve config / data on request ------------------------------
$backupDir = Join-Path $env:USERPROFILE 'clauster-uninstall-backup'
function Save-Aside([string]$src, [string]$label) {
if (-not (Test-Path -LiteralPath $src -PathType Leaf)) { return }
if ($DryRun) { Write-Host " would keep ${label}: move $src -> $backupDir\"; return }
New-Item -ItemType Directory -Path $backupDir -Force | Out-Null
Move-Item -LiteralPath $src -Destination $backupDir -Force
Write-Ok "Kept ${label}: $backupDir\$(Split-Path -Leaf $src)"
}
function Save-AsideDir([string]$src, [string]$label) {
if (-not (Test-Path -LiteralPath $src -PathType Container)) { return }
if ($DryRun) { Write-Host " would keep ${label}: move $src -> $backupDir\"; return }
New-Item -ItemType Directory -Path $backupDir -Force | Out-Null
Move-Item -LiteralPath $src -Destination $backupDir -Force
Write-Ok "Kept ${label}: $backupDir\$(Split-Path -Leaf $src)"
}
if ($KeepConfig -and $configExists) { Save-Aside $configPath 'config' }
if ($KeepData) { Save-Aside (Join-Path $stateDir 'clauster.db') 'database' }
# Move side-installed deps aside BEFORE the whole state_dir is removed below, so they survive.
if ($KeepDeps) { Save-AsideDir $depsDir 'side-installed deps' }
# --- 4) State directory (guarded) --------------------------------------
if ($stateExists) {
if (Test-StateDirSafe $stateDir) {
# Removed WHOLE in both modes: with -KeepData the DB was already moved to the
# backup above, so session.secret / session.epoch and everything else go too
# (a selective child list would strand exactly that auth material).
Write-Info 'Removing state directory...'
Remove-Target $stateDir -Recurse
}
else {
Write-Warn "Refusing to remove an unsafe state_dir path: $stateDir"
Write-Warn 'It may still hold session.secret / session.epoch - remove it yourself if it is clauster''s.'
$script:RemovalFailed = $true
}
}
# --- 5) Config yaml (unless kept / already moved aside) ----------------
if (-not $KeepConfig -and $configExists -and (Test-Path -LiteralPath $configPath)) {
Write-Info 'Removing config file...'
Remove-Target $configPath
}
Write-Host ''
if ($DryRun) {
Write-Ok 'Dry run complete - nothing was removed.'
}
elseif ($script:RemovalFailed) {
# Something couldn't be removed (warned above) — do NOT report a clean success,
# and exit non-zero so a caller/CI sees the partial cleanup.
Write-Warn 'Clauster uninstall finished with errors - some files remain (see warnings above).'
$global:LASTEXITCODE = 1
return
}
else {
Write-Ok 'Clauster uninstalled.'
Write-Warn "Claude Code (the 'claude' CLI) was installed separately and is left untouched."
}
$global:LASTEXITCODE = 0
}
# Run via a function + try/catch (no `exit`, which would terminate the host when
# this script is piped into `iex`).
try {
Uninstall-Clauster
}
catch {
Write-Err $_.Exception.Message
$global:LASTEXITCODE = 1
}