Repository navigation
Expand file tree
/
Copy pathuninstall.sh
More file actions
executable file
·345 lines (312 loc) · 14.1 KB
/
Copy pathuninstall.sh
File metadata and controls
executable file
·345 lines (312 loc) · 14.1 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
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
#!/usr/bin/env bash
set -euo pipefail
# ============================================================================
# Clauster uninstaller (#816)
#
# curl -fsSL https://raw.githubusercontent.com/schubydoo/clauster/main/uninstall.sh | bash
#
# The counterpart to install.sh. Auto-detects how clauster was installed (the
# standalone frozen binary from install.sh, or a `uv tool` / `pipx` / `pip`
# package), removes the right artifact, stops + removes a clauster service unit
# if one was installed, and removes the state directory (clauster.db, state.json,
# hosted_state.json, tls/, backups/, sockets, logs) and the config yaml.
#
# Safe by construction:
# * --dry-run list exactly what WOULD be removed; change nothing.
# * confirmation prompt before any deletion (skip with -y).
# * --keep-config preserve clauster.yml (moved aside to a printed backup).
# * --keep-data preserve clauster.db (moved aside to a printed backup).
# * never deletes a path outside the known clauster locations, and refuses a
# state_dir that resolves to a dangerous root ($HOME, /, empty).
# * fails closed: if no install can be identified, it reports and exits non-zero
# rather than guessing and deleting.
#
# Environment overrides (mirror install.sh + the app's own resolution):
# CLAUSTER_INSTALL_DIR where the frozen binary lives; default: ~/.local/bin
# CLAUSTER_STATE_DIR state directory; default: read from config, else ~/.clauster
# CLAUSTER_CONFIG config path; default: the app's search order
# ============================================================================
TOOL_NAME="clauster"
# --- Colour output (disabled when stdout is not a TTY) ---
if [ -t 1 ]; then
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
else
RED=''; GREEN=''; YELLOW=''; BLUE=''; NC=''
fi
info() { printf "${BLUE}[INFO]${NC} %s\n" "$*"; }
ok() { printf "${GREEN}[ OK ]${NC} %s\n" "$*"; }
warn() { printf "${YELLOW}[WARN]${NC} %s\n" "$*"; }
err() { printf "${RED}[ERR ]${NC} %s\n" "$*" >&2; }
die() { err "$@"; exit 1; }
have() { command -v "$1" >/dev/null 2>&1; }
DRY_RUN=0
ASSUME_YES=0
KEEP_CONFIG=0
KEEP_DATA=0
KEEP_DEPS=0
usage() {
cat <<EOF
Usage: uninstall.sh [options]
--dry-run Show what would be removed without removing anything.
-y, --yes Do not prompt for confirmation.
--keep-config Preserve clauster.yml (moved aside to a printed backup path).
--keep-data Preserve clauster.db (moved aside to a printed backup path).
--keep-deps Preserve side-installed optional deps (pty/notify extras, Shawl)
in <state_dir>/deps (moved aside to a printed backup path).
-h, --help Show this help.
EOF
}
while [ $# -gt 0 ]; do
case "$1" in
--dry-run) DRY_RUN=1 ;;
-y|--yes) ASSUME_YES=1 ;;
--keep-config) KEEP_CONFIG=1 ;;
--keep-data) KEEP_DATA=1 ;;
--keep-deps) KEEP_DEPS=1 ;;
-h|--help) usage; exit 0 ;;
*) err "Unknown option: $1"; usage; exit 2 ;;
esac
shift
done
# --- Resolution ------------------------------------------------------------
# The config path, following the app's documented search order:
# $CLAUSTER_CONFIG -> ./clauster.yml -> $CLAUSTER_HOME/clauster.yml
resolve_config() {
# Expand a leading ~ the way the app (Path.expanduser) does — a user may set
# CLAUSTER_CONFIG=~/custom/clauster.yml literally.
local env_cfg; env_cfg="$(expand_tilde "${CLAUSTER_CONFIG:-}")"
if [ -n "$env_cfg" ] && [ -f "$env_cfg" ]; then
echo "$env_cfg"; return
fi
if [ -f "./clauster.yml" ]; then
echo "$(pwd)/clauster.yml"; return
fi
local env_home; env_home="$(expand_tilde "${CLAUSTER_HOME:-}")"
if [ -n "$env_home" ] && [ -f "${env_home}/clauster.yml" ]; then
echo "${env_home}/clauster.yml"; return
fi
echo ""
}
# Expand a leading ~ to $HOME (the config stores paths like "~/.clauster").
expand_tilde() {
case "$1" in
"~") echo "${HOME}" ;;
"~/"*) echo "${HOME}/${1#\~/}" ;;
*) echo "$1" ;;
esac
}
# The state directory: an explicit override, else the config's `state_dir:`, else
# the app default ~/.clauster. The grep is a best-effort read of a top-level scalar
# key (quotes stripped); an override or the default covers anything fancier.
resolve_state_dir() {
if [ -n "${CLAUSTER_STATE_DIR:-}" ]; then
expand_tilde "${CLAUSTER_STATE_DIR}"; return
fi
local cfg; cfg="$(resolve_config)"
if [ -n "$cfg" ]; then
local sd
sd="$(grep -E '^[[:space:]]*state_dir:[[:space:]]*' "$cfg" 2>/dev/null \
| head -n1 | sed -E 's/^[[:space:]]*state_dir:[[:space:]]*//; s/^["'\'']//; s/["'\'']$//; s/[[:space:]]+#.*$//' \
| sed -E 's/[[:space:]]+$//')"
if [ -n "$sd" ]; then expand_tilde "$sd"; return; fi
fi
echo "${HOME}/.clauster"
}
# Guard: refuse a state_dir that is empty, root, or a home directory — a bug in
# resolution must never turn into `rm -rf $HOME`.
state_dir_is_safe() {
local d="$1"
[ -n "$d" ] || return 1
case "$d" in
"/"|"${HOME}"|"${HOME}/") return 1 ;;
esac
# Must be an absolute path at least two segments deep (e.g. /home/u/.clauster).
case "$d" in
/*/*) return 0 ;;
*) return 1 ;;
esac
}
INSTALL_DIR="${CLAUSTER_INSTALL_DIR:-${HOME}/.local/bin}"
BINARY_PATH="${INSTALL_DIR}/${TOOL_NAME}"
STATE_DIR="$(resolve_state_dir)"
CONFIG_PATH="$(resolve_config)"
# Side-installed optional deps live under <state_dir>/deps (pip extras' wheels + a bin/ with
# Shawl). They're removed WITH the state dir below, but we enumerate them in the plan and offer
# --keep-deps so they aren't silently discarded (#904).
DEPS_DIR="${STATE_DIR}/deps"
# Report clauster's KNOWN optional deps present in the managed dir (not their transitive wheels).
list_managed_deps() {
[ -d "$DEPS_DIR" ] || return 0
local names="" known di
for known in pyte pywinpty apprise; do
for di in "$DEPS_DIR/${known}"-*.dist-info; do
[ -d "$di" ] && { names="${names:+$names, }${known}"; break; }
done
done
[ -f "$DEPS_DIR/bin/shawl.exe" ] && names="${names:+$names, }shawl"
echo "$names"
}
# Service unit paths. The defaults are where install-service writes them; the
# overrides exist so the uninstaller stays testable without a real service unit
# (the CI smoke test + local tests pin them at a throwaway path).
SYSTEMD_UNIT="${CLAUSTER_SYSTEMD_UNIT:-/etc/systemd/system/clauster.service}"
LAUNCHD_PLIST="${CLAUSTER_LAUNCHD_PLIST:-${HOME}/Library/LaunchAgents/org.clauster.daemon.plist}"
# --- Detection -------------------------------------------------------------
# Collect every install method present. Package managers and the frozen binary
# are normally mutually exclusive, but we detect + act on each independently so a
# mixed/leftover install is fully cleaned rather than half-removed.
DETECTED=() # human-readable method labels, for the plan + fail-closed check
is_uv_tool() { have uv && uv tool list 2>/dev/null | grep -qiE "^clauster( |$|\b)"; }
is_pipx() { have pipx && pipx list --short 2>/dev/null | grep -qiE "(^| )clauster( |$)"; }
is_pip() {
local pip=""
have pip && pip="pip" || { have pip3 && pip="pip3"; }
[ -n "$pip" ] || return 1
"$pip" show clauster >/dev/null 2>&1
}
# A standalone binary: a real file at the known install path. Detected INDEPENDENTLY
# of the package managers so a stale binary that coexists with a uv/pipx/pip install is
# still removed — the binary rm runs AFTER the package uninstalls (see the removal
# order below), so if the file was a package shim it's already gone (rm is a no-op) and
# a genuine leftover is cleaned up.
is_binary() { [ -f "$BINARY_PATH" ]; }
is_uv_tool && DETECTED+=("uv tool")
is_pipx && DETECTED+=("pipx")
is_pip && DETECTED+=("pip")
is_binary && DETECTED+=("binary:${BINARY_PATH}")
# --- Plan (what will be removed) -------------------------------------------
info "Clauster uninstaller${DRY_RUN:+ (dry run)}"
echo
info "Detected install method(s): ${DETECTED[*]:-none}"
info "State directory: ${STATE_DIR}$( [ -d "$STATE_DIR" ] || printf ' (absent)')"
MANAGED_DEPS="$(list_managed_deps)"
if [ -n "$MANAGED_DEPS" ]; then
if [ "$KEEP_DEPS" -eq 1 ]; then
info "Side-installed deps: ${MANAGED_DEPS} (KEPT — moved to backup)"
else
info "Side-installed deps: ${MANAGED_DEPS} (in ${DEPS_DIR}; keep with --keep-deps)"
fi
fi
info "Config file: ${CONFIG_PATH:-<none found>}"
if [ -e "$SYSTEMD_UNIT" ]; then info "systemd unit: ${SYSTEMD_UNIT}"; fi
if [ -e "$LAUNCHD_PLIST" ]; then info "launchd agent: ${LAUNCHD_PLIST}"; fi
echo
if [ ${#DETECTED[@]} -eq 0 ] && [ ! -d "$STATE_DIR" ] && [ -z "$CONFIG_PATH" ] \
&& [ ! -e "$SYSTEMD_UNIT" ] && [ ! -e "$LAUNCHD_PLIST" ]; then
die "No clauster install found (no binary/package, state dir, config, or service). Nothing to do."
fi
# Fail closed: a leftover state dir / config with NO identifiable install method is
# ambiguous — report it and let the operator remove it explicitly, don't guess.
if [ ${#DETECTED[@]} -eq 0 ]; then
warn "Could not identify how clauster was installed (no binary/package on PATH)."
warn "Leaving files in place. If you know they're clauster's, remove them manually:"
[ -d "$STATE_DIR" ] && warn " rm -rf ${STATE_DIR}"
[ -n "$CONFIG_PATH" ] && warn " rm -f ${CONFIG_PATH}"
die "Refusing to guess-and-delete."
fi
# --- Confirm ---------------------------------------------------------------
if [ "$DRY_RUN" -eq 0 ] && [ "$ASSUME_YES" -eq 0 ]; then
printf "%bProceed with removal? [y/N]%b " "$YELLOW" "$NC"
read -r reply </dev/tty || reply=""
case "$reply" in
y|Y|yes|YES) : ;;
*) info "Aborted."; exit 0 ;;
esac
fi
# --- Actions ---------------------------------------------------------------
# Every mutating action funnels through run()/rm_path() so --dry-run prints the
# exact command and does nothing.
run() {
if [ "$DRY_RUN" -eq 1 ]; then printf " would: %s\n" "$*"; else "$@"; fi
}
rm_path() { # rm_path <path> [rm-flags...]
local path="$1"; shift || true
[ -e "$path" ] || [ -L "$path" ] || return 0
if [ "$DRY_RUN" -eq 1 ]; then printf " would remove: %s\n" "$path"; else rm "$@" "$path"; fi
}
# 1) Service unit (best-effort; the privileged step is surfaced, never assumed).
# The unit NAME is derived from the resolved unit PATH — never hardcoded — so an
# override ($CLAUSTER_SYSTEMD_UNIT, used by the tests) fully isolates stop/disable
# too, and can't target the real `clauster.service` while pointing the rm elsewhere.
if [ -e "$SYSTEMD_UNIT" ]; then
unit_name="$(basename "$SYSTEMD_UNIT")"
info "Removing systemd service '${unit_name}' (needs privileges)..."
if have systemctl; then
run sudo systemctl stop "$unit_name"
run sudo systemctl disable "$unit_name"
fi
run sudo rm -f "$SYSTEMD_UNIT"
run sudo systemctl daemon-reload
fi
if [ -e "$LAUNCHD_PLIST" ]; then
info "Removing launchd agent..."
have launchctl && run launchctl unload "$LAUNCHD_PLIST"
rm_path "$LAUNCHD_PLIST" -f
fi
# 2) The binary / package, per detected method.
for m in "${DETECTED[@]:-}"; do
case "$m" in
"uv tool") info "Removing uv tool..."; run uv tool uninstall clauster ;;
"pipx") info "Removing pipx package..."; run pipx uninstall clauster ;;
"pip") info "Removing pip package..."
if have pip; then run pip uninstall -y clauster; else run pip3 uninstall -y clauster; fi ;;
binary:*) info "Removing binary..."; rm_path "${m#binary:}" -f ;;
esac
done
# 3) Preserve config / data on request (moved aside to a printed backup path).
BACKUP_DIR="${HOME}/clauster-uninstall-backup"
preserve() { # preserve <src> <label>
local src="$1" label="$2"
[ -f "$src" ] || return 0
if [ "$DRY_RUN" -eq 1 ]; then
printf " would keep %s: move %s -> %s/\n" "$label" "$src" "$BACKUP_DIR"; return
fi
mkdir -p "$BACKUP_DIR"
mv -f "$src" "$BACKUP_DIR/"
ok "Kept ${label}: ${BACKUP_DIR}/$(basename "$src")"
}
preserve_dir() { # preserve_dir <src-dir> <label>
local src="$1" label="$2"
[ -d "$src" ] || return 0
if [ "$DRY_RUN" -eq 1 ]; then
printf " would keep %s: move %s -> %s/\n" "$label" "$src" "$BACKUP_DIR"; return
fi
mkdir -p "$BACKUP_DIR"
mv -f "$src" "$BACKUP_DIR/"
ok "Kept ${label}: ${BACKUP_DIR}/$(basename "$src")"
}
[ "$KEEP_CONFIG" -eq 1 ] && [ -n "$CONFIG_PATH" ] && preserve "$CONFIG_PATH" "config"
[ "$KEEP_DATA" -eq 1 ] && preserve "${STATE_DIR}/clauster.db" "database"
# Move side-installed deps aside BEFORE the whole state_dir is removed below, so they survive.
[ "$KEEP_DEPS" -eq 1 ] && preserve_dir "$DEPS_DIR" "side-installed deps"
# 4) State directory (guarded). Removed WHOLE, in both modes: with --keep-data the
# preserve step above already moved clauster.db out to the backup, so nothing else
# under state_dir (session.secret / session.epoch auth material, sockets, logs) is
# left behind — a selective child list would silently strand exactly those secrets.
if [ -d "$STATE_DIR" ]; then
if state_dir_is_safe "$STATE_DIR"; then
info "Removing state directory..."
rm_path "$STATE_DIR" -rf
else
warn "Refusing to remove an unsafe state_dir path: ${STATE_DIR}"
warn "It may still hold session.secret / session.epoch — remove it yourself if it is clauster's."
CLEANUP_FAILED=1
fi
fi
# 5) Config yaml (unless kept, and only when it wasn't already moved aside).
if [ "$KEEP_CONFIG" -eq 0 ] && [ -n "$CONFIG_PATH" ] && [ -f "$CONFIG_PATH" ]; then
info "Removing config file..."
rm_path "$CONFIG_PATH" -f
fi
echo
if [ "$DRY_RUN" -eq 1 ]; then
ok "Dry run complete — nothing was removed."
elif [ "${CLEANUP_FAILED:-0}" -eq 1 ]; then
# Something was left behind (warned above) — never report a clean success, and exit
# non-zero so a caller/CI sees the partial cleanup.
warn "Clauster uninstall finished with errors — some files remain (see warnings above)."
exit 1
else
ok "Clauster uninstalled."
warn "Claude Code (the 'claude' CLI) was installed separately and is left untouched."
fi