From 54c888337f6280e9acb921a0cc41012a943f12cc Mon Sep 17 00:00:00 2001 From: jithinraj Date: Sun, 22 Mar 2026 21:14:51 +0530 Subject: [PATCH] feat: add uninstall script Closes #273. Adds bin/gstack-uninstall to cleanly remove gstack from a system: - Stops running browse daemons (SIGTERM with graceful fallback) - Removes Claude skills (~/.claude/skills/gstack + per-skill symlinks) - Removes Codex skills (~/.codex/skills/gstack*) - Removes per-project .agents/ sidecar and .gstack/ state - Removes global ~/.gstack/ state directory - Cleans up /tmp session files Supports --force (skip confirmation), --keep-state (preserve ~/.gstack/ data), and GSTACK_STATE_DIR env override for testing. Uses set -uo pipefail (no -e) so uninstall never aborts partway. Handles broken symlinks. Follows existing bin/ script conventions. --- bin/gstack-uninstall | 228 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100755 bin/gstack-uninstall diff --git a/bin/gstack-uninstall b/bin/gstack-uninstall new file mode 100755 index 0000000000..6bad7c1bfa --- /dev/null +++ b/bin/gstack-uninstall @@ -0,0 +1,228 @@ +#!/usr/bin/env bash +# gstack-uninstall — remove gstack skills, state, and browse daemons +# +# Usage: +# gstack-uninstall — interactive uninstall (prompts before removing) +# gstack-uninstall --force — remove everything without prompting +# gstack-uninstall --keep-state — remove skills but keep ~/.gstack/ data +# +# What gets REMOVED: +# ~/.claude/skills/gstack — global Claude skill install (git clone or vendored) +# ~/.claude/skills/{skill} — per-skill symlinks created by setup +# ~/.codex/skills/gstack* — Codex skill install + per-skill symlinks +# ~/.kiro/skills/gstack* — Kiro skill install + per-skill symlinks +# ~/.gstack/ — global state (config, analytics, sessions, projects, +# repos, installation-id, browse error logs) +# .claude/skills/gstack* — project-local skill install (--local installs) +# .gstack/ — per-project browse state (in current git repo) +# .gstack-worktrees/ — per-project test worktrees (in current git repo) +# .agents/skills/gstack* — Codex/Gemini/Cursor sidecar (in current git repo) +# Running browse daemons — stopped via SIGTERM before cleanup +# +# What is NOT REMOVED: +# ~/Library/Caches/ms-playwright/ — Playwright Chromium (shared, may be used by other tools) +# ~/.gstack-dev/ — developer eval artifacts (only present in gstack contributors) +# +# Env overrides (for testing): +# GSTACK_DIR — override auto-detected gstack root +# GSTACK_STATE_DIR — override ~/.gstack state directory +# +# NOTE: Uses set -uo pipefail (no -e) — uninstall must never abort partway. +set -uo pipefail + +if [ -z "${HOME:-}" ]; then + echo "ERROR: \$HOME is not set" >&2 + exit 1 +fi + +GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" +STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}" +_GIT_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)" + +# ─── Parse flags ───────────────────────────────────────────── +FORCE=0 +KEEP_STATE=0 +while [ $# -gt 0 ]; do + case "$1" in + --force) FORCE=1; shift ;; + --keep-state) KEEP_STATE=1; shift ;; + -h|--help) + sed -n '2,/^[^#]/{ /^#/s/^# \{0,1\}//p; }' "$0" + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + echo "Usage: gstack-uninstall [--force] [--keep-state]" >&2 + exit 1 + ;; + esac +done + +# ─── Confirmation ──────────────────────────────────────────── +if [ "$FORCE" -eq 0 ]; then + echo "This will remove gstack from your system:" + { [ -d "$HOME/.claude/skills/gstack" ] || [ -L "$HOME/.claude/skills/gstack" ]; } && echo " ~/.claude/skills/gstack (+ per-skill symlinks)" + [ -d "$HOME/.codex/skills" ] && echo " ~/.codex/skills/gstack*" + [ -d "$HOME/.kiro/skills" ] && echo " ~/.kiro/skills/gstack*" + [ "$KEEP_STATE" -eq 0 ] && [ -d "$STATE_DIR" ] && echo " $STATE_DIR" + + if [ -n "$_GIT_ROOT" ]; then + [ -d "$_GIT_ROOT/.claude/skills/gstack" ] && echo " $_GIT_ROOT/.claude/skills/gstack (project-local)" + [ -d "$_GIT_ROOT/.gstack" ] && echo " $_GIT_ROOT/.gstack/ (browse state + reports)" + [ -d "$_GIT_ROOT/.gstack-worktrees" ] && echo " $_GIT_ROOT/.gstack-worktrees/" + [ -d "$_GIT_ROOT/.agents/skills" ] && echo " $_GIT_ROOT/.agents/skills/gstack*" + fi + + # Preview running daemons + if [ -n "$_GIT_ROOT" ] && [ -f "$_GIT_ROOT/.gstack/browse.json" ]; then + _PREVIEW_PID="$(awk -F'[:,]' '/"pid"/ { for(i=1;i<=NF;i++) if($i ~ /"pid"/) { gsub(/[^0-9]/, "", $(i+1)); print $(i+1); exit } }' "$_GIT_ROOT/.gstack/browse.json" 2>/dev/null || true)" + [ -n "$_PREVIEW_PID" ] && kill -0 "$_PREVIEW_PID" 2>/dev/null && echo " browse daemon (PID $_PREVIEW_PID) will be stopped" + fi + + printf "\nContinue? [y/N] " + read -r REPLY + case "$REPLY" in + y|Y|yes|YES) ;; + *) echo "Aborted."; exit 0 ;; + esac +fi + +REMOVED=() + +# ─── Stop running browse daemons ───────────────────────────── +# Browse servers write PID to {project}/.gstack/browse.json. +# Stop any we can find before removing state directories. +stop_browse_daemon() { + local state_file="$1" + if [ ! -f "$state_file" ]; then + return + fi + local pid + pid="$(awk -F'[:,]' '/"pid"/ { for(i=1;i<=NF;i++) if($i ~ /"pid"/) { gsub(/[^0-9]/, "", $(i+1)); print $(i+1); exit } }' "$state_file" 2>/dev/null || true)" + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + kill "$pid" 2>/dev/null || true + # Wait up to 2s for graceful shutdown + local waited=0 + while [ "$waited" -lt 4 ] && kill -0 "$pid" 2>/dev/null; do + sleep 0.5 + waited=$(( waited + 1 )) + done + if kill -0 "$pid" 2>/dev/null; then + kill -9 "$pid" 2>/dev/null || true + fi + REMOVED+=("browse daemon (PID $pid)") + fi +} + +# Stop daemon in current project +if [ -n "$_GIT_ROOT" ] && [ -f "$_GIT_ROOT/.gstack/browse.json" ]; then + stop_browse_daemon "$_GIT_ROOT/.gstack/browse.json" +fi + +# Stop daemons tracked in global projects directory +if [ -d "$STATE_DIR/projects" ]; then + while IFS= read -r _BJ; do + stop_browse_daemon "$_BJ" + done < <(find "$STATE_DIR/projects" -name browse.json -path '*/.gstack/*' 2>/dev/null || true) +fi + +# ─── Remove global Claude skills ──────────────────────────── +CLAUDE_SKILLS="$HOME/.claude/skills" +if [ -d "$CLAUDE_SKILLS/gstack" ] || [ -L "$CLAUDE_SKILLS/gstack" ]; then + # Remove per-skill symlinks that point into gstack/ + for _LINK in "$CLAUDE_SKILLS"/*; do + [ -L "$_LINK" ] || continue + _NAME="$(basename "$_LINK")" + [ "$_NAME" = "gstack" ] && continue + _TARGET="$(readlink "$_LINK" 2>/dev/null || true)" + case "$_TARGET" in + gstack/*|*/gstack/*) rm -f "$_LINK"; REMOVED+=("claude/$_NAME") ;; + esac + done + + rm -rf "$CLAUDE_SKILLS/gstack" + REMOVED+=("~/.claude/skills/gstack") +fi + +# ─── Remove project-local Claude skills (--local installs) ── +if [ -n "$_GIT_ROOT" ] && [ -d "$_GIT_ROOT/.claude/skills" ]; then + for _LINK in "$_GIT_ROOT/.claude/skills"/*; do + [ -L "$_LINK" ] || continue + _TARGET="$(readlink "$_LINK" 2>/dev/null || true)" + case "$_TARGET" in + gstack/*|*/gstack/*) rm -f "$_LINK"; REMOVED+=("local claude/$(basename "$_LINK")") ;; + esac + done + if [ -d "$_GIT_ROOT/.claude/skills/gstack" ] || [ -L "$_GIT_ROOT/.claude/skills/gstack" ]; then + rm -rf "$_GIT_ROOT/.claude/skills/gstack" + REMOVED+=("$_GIT_ROOT/.claude/skills/gstack") + fi +fi + +# ─── Remove Codex skills ──────────────────────────────────── +CODEX_SKILLS="$HOME/.codex/skills" +if [ -d "$CODEX_SKILLS" ]; then + for _ITEM in "$CODEX_SKILLS"/gstack*; do + [ -e "$_ITEM" ] || [ -L "$_ITEM" ] || continue + rm -rf "$_ITEM" + REMOVED+=("codex/$(basename "$_ITEM")") + done +fi + +# ─── Remove Kiro skills ───────────────────────────────────── +KIRO_SKILLS="$HOME/.kiro/skills" +if [ -d "$KIRO_SKILLS" ]; then + for _ITEM in "$KIRO_SKILLS"/gstack*; do + [ -e "$_ITEM" ] || [ -L "$_ITEM" ] || continue + rm -rf "$_ITEM" + REMOVED+=("kiro/$(basename "$_ITEM")") + done +fi + +# ─── Remove per-project .agents/ sidecar ───────────────────── +if [ -n "$_GIT_ROOT" ] && [ -d "$_GIT_ROOT/.agents/skills" ]; then + for _ITEM in "$_GIT_ROOT/.agents/skills"/gstack*; do + [ -e "$_ITEM" ] || [ -L "$_ITEM" ] || continue + rm -rf "$_ITEM" + REMOVED+=("agents/$(basename "$_ITEM")") + done + + rmdir "$_GIT_ROOT/.agents/skills" 2>/dev/null || true + rmdir "$_GIT_ROOT/.agents" 2>/dev/null || true +fi + +# ─── Remove per-project state ─────────────────────────────── +if [ -n "$_GIT_ROOT" ]; then + if [ -d "$_GIT_ROOT/.gstack" ]; then + rm -rf "$_GIT_ROOT/.gstack" + REMOVED+=("$_GIT_ROOT/.gstack/") + fi + if [ -d "$_GIT_ROOT/.gstack-worktrees" ]; then + rm -rf "$_GIT_ROOT/.gstack-worktrees" + REMOVED+=("$_GIT_ROOT/.gstack-worktrees/") + fi +fi + +# ─── Remove global state ──────────────────────────────────── +if [ "$KEEP_STATE" -eq 0 ] && [ -d "$STATE_DIR" ]; then + rm -rf "$STATE_DIR" + REMOVED+=("$STATE_DIR") +fi + +# ─── Clean up temp files ──────────────────────────────────── +for _TMP in /tmp/gstack-latest-version /tmp/gstack-sketch-*.html /tmp/gstack-sketch.png /tmp/gstack-sync-*; do + if [ -e "$_TMP" ]; then + rm -f "$_TMP" + REMOVED+=("$(basename "$_TMP")") + fi +done + +# ─── Summary ──────────────────────────────────────────────── +if [ ${#REMOVED[@]} -gt 0 ]; then + echo "Removed: ${REMOVED[*]}" + echo "gstack uninstalled." +else + echo "Nothing to remove — gstack is not installed." +fi + +exit 0