From 6ae83f48ac80f749d939a6e92afddcad29af5642 Mon Sep 17 00:00:00 2001 From: Amit Shafnir Date: Tue, 9 Jun 2026 21:57:05 +0300 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20add=20uninstall.sh=20=E2=80=94=20pe?= =?UTF-8?q?r-copy=20uninstaller=20with=20confirmation,=20dry-run,=20and=20?= =?UTF-8?q?OneCLI=20agent=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes only what belongs to this checkout (slug-scoped): background service, containers + image, data/, logs/, groups/, ncl symlink, and this copy's OneCLI vault agents. Shared tools (OneCLI app, credentials, other copies) are left alone. Interactive per-group confirmation with --dry-run and --yes modes; .env is backed up before removal. Documented in README FAQ and the CLAUDE.md key-files table. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 1 + README.md | 8 + uninstall.sh | 543 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 552 insertions(+) create mode 100755 uninstall.sh diff --git a/CLAUDE.md b/CLAUDE.md index a04244377..64e379975 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,6 +83,7 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t | `groups//` | Per-agent-group filesystem (CLAUDE.md, skills, per-group `agent-runner-src/` overlay) | | `scripts/init-first-agent.ts` | Bootstrap the first DM-wired agent (used by `/init-first-agent` skill) | | `migrate-v2.sh` + `setup/migrate-v2/` | v1→v2 migration. Standalone script: `bash migrate-v2.sh`. Seeds DB, copies groups/sessions, installs channels, builds container, offers service switchover, then hands off to `/migrate-from-v1` skill for owner setup and CLAUDE.md cleanup. See [docs/migration-dev.md](docs/migration-dev.md). | +| `uninstall.sh` | Uninstall this copy only (slug-scoped): service, containers + image, `data/`, `logs/`, `groups/`, this copy's OneCLI agents. Confirms per group; `--dry-run` previews, `--yes` skips prompts. Other copies and the shared OneCLI app are untouched. | ## Admin CLI (`ncl`) diff --git a/README.md b/README.md index b43d9a155..691fde65f 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,14 @@ Ask Claude Code. "Why isn't the scheduler running?" "What's in the recent logs?" If a step fails, `nanoclaw.sh` hands off to Claude Code to diagnose and resume. If that doesn't resolve it, run `claude`, then `/debug`. If Claude identifies an issue likely to affect other users, open a PR against the relevant setup step or skill. +**How do I uninstall NanoClaw?** + +```bash +bash uninstall.sh +``` + +Every install is tagged with a per-checkout id, so the script removes only what belongs to that copy: the background service, containers and image, app data and logs, your agents' files, and this copy's OneCLI vault agents. Shared things — the OneCLI app and your credentials, other NanoClaw copies on the machine — are left alone. It shows exactly what it found and asks for confirmation per group; nothing is deleted until you say yes. Use `--dry-run` to preview without changing anything, or `--yes` to skip the prompts. Your `.env` is backed up before removal. To finish, delete the checkout folder itself. + **What changes will be accepted into the codebase?** Only security fixes, bug fixes, and clear improvements will be accepted to the base configuration. That's all. diff --git a/uninstall.sh b/uninstall.sh new file mode 100755 index 000000000..369399ca9 --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,543 @@ +#!/usr/bin/env bash +# +# uninstall.sh — Safely remove a NanoClaw installation from this computer. +# +# Everything NanoClaw creates is tagged with a per-checkout "install id" +# (sha1(PROJECT_ROOT)[:8]), so several copies can live on one machine. This +# script removes ONLY things belonging to THIS copy. Other copies and shared +# tools (the OneCLI app/vault, your shell PATH line, host-wide config) are +# left alone and listed at the end. +# +# It first checks what actually exists, then — for each group that has +# something to remove — shows a table of exactly what will be deleted and +# asks you to confirm. Groups with nothing are skipped. If nothing is found +# at all, it says so and exits. Nothing is removed until you type "y". +# +# bash uninstall.sh # interactive — confirm each group that has something +# bash uninstall.sh --dry-run # just show what would be deleted, change nothing +# bash uninstall.sh --yes # delete everything found without asking (full wipe) +# bash uninstall.sh --help +# +set -euo pipefail + +# --- resolve project root (the dir this script lives in) -------------------- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)" +PROJECT_ROOT="$SCRIPT_DIR" +cd "$PROJECT_ROOT" + +# Slug helpers must hash the same root that setup used (it runs from the +# project root), so export it explicitly for the helper. +export NANOCLAW_PROJECT_ROOT="$PROJECT_ROOT" +# shellcheck source=setup/lib/install-slug.sh +source "$PROJECT_ROOT/setup/lib/install-slug.sh" + +SLUG="$(_nanoclaw_install_slug)" +LABEL="$(launchd_label)" # com.nanoclaw-v2- +UNIT="$(systemd_unit)" # nanoclaw-v2- +IMAGE_BASE="$(container_image_base)" # nanoclaw-agent-v2- +IMAGE="${IMAGE_BASE}:latest" +INSTALL_LABEL="nanoclaw-install=${SLUG}" +CONTAINER_RUNTIME="${CONTAINER_RUNTIME:-docker}" + +HOME_DIR="${HOME:-$(echo ~)}" +OS="$(uname -s)" + +# --- flags ------------------------------------------------------------------ +DRY_RUN=0 +ASSUME_YES=0 + +usage() { sed -n '3,19p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0; } + +for arg in "$@"; do + case "$arg" in + -n|--dry-run) DRY_RUN=1 ;; + -y|--yes) ASSUME_YES=1 ;; + -h|--help) usage ;; + *) echo "Unknown flag: $arg (try --help)" >&2; exit 2 ;; + esac +done + +# --- colors ----------------------------------------------------------------- +c_bold=$'\033[1m'; c_dim=$'\033[2m'; c_red=$'\033[31m'; c_grn=$'\033[32m' +c_yel=$'\033[33m'; c_cyn=$'\033[36m'; c_rst=$'\033[0m' +if [ ! -t 1 ]; then c_bold=; c_dim=; c_red=; c_grn=; c_yel=; c_cyn=; c_rst=; fi + +have_cmd() { command -v "$1" >/dev/null 2>&1; } +tilde() { case "$1" in "$HOME_DIR"*) printf '~%s' "${1#"$HOME_DIR"}";; *) printf '%s' "$1";; esac; } + +# --- table buffer ----------------------------------------------------------- +# A scan_* fn fills ROWS with the items it FOUND (only found items — absent +# things are not listed, since we already skip empty groups). FOUND is the +# count for the current group. +ROWS=() +FOUND=0 +reset_rows() { ROWS=(); FOUND=0; } +row() { ROWS+=("$1"$'\t'"$2"); FOUND=$((FOUND + 1)); return 0; } # what, where +group_head() { + printf '\n%s%s%s\n' "$c_bold" "$1" "$c_rst" + printf '%s%s%s\n' "$c_dim" "$2" "$c_rst" +} + +confirm() { + [ "$ASSUME_YES" = "1" ] && return 0 + printf '\n %s%s%s [y/N] ' "$c_yel" "$1" "$c_rst" + local ans=""; read -r ans /dev/null || ans="" + case "$ans" in y|Y|yes|YES) return 0 ;; *) return 1 ;; esac +} + +SKIPPED_NOTES=() +note_skip() { SKIPPED_NOTES+=("$1"); } + +list_containers() { + have_cmd "$CONTAINER_RUNTIME" || { echo ""; return; } + "$CONTAINER_RUNTIME" ps -aq --filter "label=${INSTALL_LABEL}" 2>/dev/null || echo "" +} + +# =========================================================================== +# Scanners — fill ROWS with only the items that EXIST for this copy. +# =========================================================================== +scan_service() { + reset_rows + case "$OS" in + Darwin) + local plist="$HOME_DIR/Library/LaunchAgents/${LABEL}.plist" + [ -f "$plist" ] && row "Background service" "$(tilde "$plist")" + ;; + Linux) + local uu="$HOME_DIR/.config/systemd/user/${UNIT}.service" + local us="/etc/systemd/system/${UNIT}.service" + [ -f "$uu" ] && row "Background service" "$(tilde "$uu")" + [ -f "$us" ] && row "Background service (system)" "$us" + [ -f "$PROJECT_ROOT/nanoclaw.pid" ] && row "Running process" "nanoclaw.pid" + ;; + esac + local cids; cids="$(list_containers)" + [ -n "$cids" ] && row "Running containers" "$(echo "$cids" | wc -l | tr -d ' ') container(s)" + if have_cmd "$CONTAINER_RUNTIME" && "$CONTAINER_RUNTIME" image inspect "$IMAGE" >/dev/null 2>&1; then + row "Docker image" "$IMAGE" + fi + local link="$HOME_DIR/.local/bin/ncl" + [ -L "$link" ] && row "Command-line tool (ncl)" "$(tilde "$link")" + return 0 +} + +scan_data() { + reset_rows + [ -e "$PROJECT_ROOT/data" ] && row "Database & conversations" "$(tilde "$PROJECT_ROOT/data")/" + [ -e "$PROJECT_ROOT/logs" ] && row "Logs" "$(tilde "$PROJECT_ROOT/logs")/" + [ -e "$PROJECT_ROOT/dist" ] && row "Build output" "$(tilde "$PROJECT_ROOT/dist")/" + [ -e "$PROJECT_ROOT/node_modules" ] && row "Installed dependencies" "$(tilde "$PROJECT_ROOT/node_modules")/" + [ -e "$PROJECT_ROOT/.env" ] && row "Secrets / API keys (.env)" "backed up before removal" + [ -e "$PROJECT_ROOT/start-nanoclaw.sh" ] && row "Start script" "start-nanoclaw.sh" + return 0 +} + +scan_user() { + reset_rows + [ -e "$PROJECT_ROOT/groups" ] && row "Agent memory & files" "$(tilde "$PROJECT_ROOT/groups")/" + [ -e "$PROJECT_ROOT/store" ] && row "Migrated data store" "$(tilde "$PROJECT_ROOT/store")/" + return 0 +} + +# OneCLI agents fall into two sets, computed once by scan_onecli and reused by +# the dry-run preview, the group-4 decision, and do_onecli. Each entry is +# "\t\t" — deletion is BY UUID (the identifier, +# i.e. the agent-group id, is NOT a valid --id; see container-runner.ts). +ONECLI_MINE=() # vault agents whose identifier IS in this copy's data/v2.db +ONECLI_ORPHANS=() # ag-* vault agents NOT in our DB (maybe another copy's) +ONECLI_DELETE=() # resolved set to actually delete (filled by decide_onecli) + +scan_onecli() { + reset_rows + ONECLI_MINE=() + ONECLI_ORPHANS=() + + have_cmd onecli || return 0 + + # Build the vault map once: identifieruuidname for non-default agents. + local vault="" + if have_cmd jq; then + vault="$(onecli agents list 2>/dev/null \ + | jq -r '.data[] | select(.isDefault|not) | select(.identifier != "default") | "\(.identifier)\t\(.id)\t\(.name)"' 2>/dev/null)" || vault="" + elif have_cmd python3; then + vault="$(onecli agents list 2>/dev/null | python3 -c ' +import json, sys +try: + d = json.load(sys.stdin) +except Exception: + sys.exit(0) +for a in d.get("data", []): + if a.get("isDefault"): + continue + ident = a.get("identifier", "") + if ident == "default": + continue + print("\t".join([ident, a.get("id", ""), a.get("name", "")])) +' 2>/dev/null)" || vault="" + else + note_skip "OneCLI agents: need 'jq' or 'python3' to read the vault; list/remove manually with 'onecli agents list' / 'onecli agents delete --id '." + return 0 + fi + + [ -z "$vault" ] && return 0 + + # Our agent-group ids from the local DB (present during a normal uninstall, + # since OneCLI cleanup runs before do_data wipes data/). Newline-delimited so + # we can do a membership test without bash-4 associative arrays. + # + # Prefer the in-tree query wrapper (goes through better-sqlite3, which setup + # always installs) over the sqlite3 CLI (which setup deliberately avoids + # depending on — see setup/verify.ts). ids_known distinguishes "this copy has + # zero agent groups" from "we couldn't read the DB at all"; without it, a + # missing sqlite3 would mislabel every ag-* agent as an orphan and --yes would + # silently leave this copy's agents behind. + local our_ids="" ids_known=0 + if [ -f "$PROJECT_ROOT/data/v2.db" ]; then + if have_cmd pnpm && [ -f "$PROJECT_ROOT/scripts/q.ts" ]; then + if our_ids="$(pnpm exec tsx scripts/q.ts data/v2.db "SELECT id FROM agent_groups;" 2>/dev/null)"; then + ids_known=1 + else + our_ids="" + fi + fi + if [ "$ids_known" = "0" ] && have_cmd sqlite3; then + if our_ids="$(sqlite3 "$PROJECT_ROOT/data/v2.db" "SELECT id FROM agent_groups;" 2>/dev/null)"; then + ids_known=1 + else + our_ids="" + fi + fi + fi + + local saw_orphan=0 + local identifier uuid name + while IFS=$'\t' read -r identifier uuid name; do + [ -z "$identifier" ] && continue + [ "$identifier" = "default" ] && continue + case $'\n'"$our_ids"$'\n' in + *$'\n'"$identifier"$'\n'*) + ONECLI_MINE+=("$uuid"$'\t'"$identifier"$'\t'"$name") + row "OneCLI agent" "$name — $identifier" + ;; + *) + # Not ours. Only treat NanoClaw-style (ag-*) ids as orphans we surface. + case "$identifier" in + ag-*) + saw_orphan=1 + ONECLI_ORPHANS+=("$uuid"$'\t'"$identifier"$'\t'"$name") + row "OneCLI agent (orphan)" "$name — $identifier" + ;; + esac + ;; + esac + done <<<"$vault" + + # If we couldn't read agent_groups, every ag-* agent was forced into the + # orphan bucket — warn so the user isn't misled and --yes leaving them behind + # is explained. + if [ "$ids_known" = "0" ] && [ "$saw_orphan" = "1" ]; then + note_skip "Couldn't read agent_groups (need pnpm/tsx or sqlite3); OneCLI agents shown as 'orphan' may actually belong to this copy." + fi + + return 0 +} + +# =========================================================================== +# Removers +# =========================================================================== +do_service() { + printf '\n %sRemoving app & background service...%s\n' "$c_dim" "$c_rst" + case "$OS" in + Darwin) + local plist="$HOME_DIR/Library/LaunchAgents/${LABEL}.plist" + if [ -f "$plist" ]; then + launchctl unload "$plist" >/dev/null 2>&1 || true + rm -f "$plist" && printf ' %s✓%s background service removed\n' "$c_grn" "$c_rst" + fi + ;; + Linux) + local uu="$HOME_DIR/.config/systemd/user/${UNIT}.service" + local us="/etc/systemd/system/${UNIT}.service" + if [ -f "$uu" ]; then + systemctl --user disable --now "${UNIT}.service" >/dev/null 2>&1 || true + rm -f "$uu"; systemctl --user daemon-reload >/dev/null 2>&1 || true + printf ' %s✓%s background service removed\n' "$c_grn" "$c_rst" + fi + if [ -f "$us" ]; then + if [ "$(id -u)" = "0" ]; then + systemctl disable --now "${UNIT}.service" >/dev/null 2>&1 || true + rm -f "$us"; systemctl daemon-reload >/dev/null 2>&1 || true + printf ' %s✓%s system service removed\n' "$c_grn" "$c_rst" + else + printf ' %s!%s system service needs root — left in place\n' "$c_yel" "$c_rst" + note_skip "System service $us — re-run with sudo to remove." + fi + fi + if [ -f "$PROJECT_ROOT/nanoclaw.pid" ]; then + local oldpid; oldpid="$(cat "$PROJECT_ROOT/nanoclaw.pid" 2>/dev/null || echo "")" + [ -n "$oldpid" ] && kill -0 "$oldpid" 2>/dev/null && kill "$oldpid" 2>/dev/null || true + fi + ;; + esac + have_cmd pkill && pkill -f "${PROJECT_ROOT}/dist/index.js" 2>/dev/null && \ + printf ' %s✓%s stopped leftover host process\n' "$c_grn" "$c_rst" || true + if have_cmd "$CONTAINER_RUNTIME"; then + local cids; cids="$(list_containers)" + if [ -n "$cids" ]; then + # shellcheck disable=SC2086 + "$CONTAINER_RUNTIME" rm -f $cids >/dev/null 2>&1 || true + printf ' %s✓%s removed %s container(s)\n' "$c_grn" "$c_rst" "$(echo "$cids" | wc -l | tr -d ' ')" + fi + if "$CONTAINER_RUNTIME" image inspect "$IMAGE" >/dev/null 2>&1; then + "$CONTAINER_RUNTIME" rmi "$IMAGE" >/dev/null 2>&1 \ + && printf ' %s✓%s removed Docker image\n' "$c_grn" "$c_rst" \ + || printf ' %s!%s could not remove image (in use?)\n' "$c_yel" "$c_rst" + fi + else + note_skip "Containers/image: '$CONTAINER_RUNTIME' not found; remove later with: $CONTAINER_RUNTIME ps -aq --filter label=${INSTALL_LABEL} | xargs -r $CONTAINER_RUNTIME rm -f; $CONTAINER_RUNTIME rmi $IMAGE" + fi + local link="$HOME_DIR/.local/bin/ncl" + if [ -L "$link" ]; then + local target abs + target="$(readlink "$link")" + case "$target" in + /*) abs="$target" ;; + *) abs="$(cd "$(dirname "$link")" && cd "$(dirname "$target")" 2>/dev/null && pwd)/$(basename "$target")" ;; + esac + if [ "$abs" = "$PROJECT_ROOT/bin/ncl" ]; then + rm -f "$link" && printf ' %s✓%s removed ncl command\n' "$c_grn" "$c_rst" + else + printf ' %s!%s ncl points to another copy — left in place\n' "$c_yel" "$c_rst" + note_skip "ncl command $link points to another NanoClaw copy; left untouched." + fi + fi +} + +# Decide which OneCLI agents to delete. MINE is a single yes/no; ORPHANS get a +# separate, default-No prompt with an explicit cross-copy warning. Under --yes +# we delete MINE but never ORPHANS (orphans require explicit human intent). +# Anything left behind is reported with the exact manual command (delete by uuid). +decide_onecli() { + ONECLI_DELETE=() + local entry uuid identifier name + + if [ "${#ONECLI_MINE[@]}" -gt 0 ]; then + if [ "$ASSUME_YES" = "1" ] || confirm "Delete this copy's ${#ONECLI_MINE[@]} OneCLI agent(s)?"; then + for entry in "${ONECLI_MINE[@]}"; do ONECLI_DELETE+=("$entry"); done + else + note_skip "OneCLI agents (this copy): kept by your choice." + fi + fi + + if [ "${#ONECLI_ORPHANS[@]}" -gt 0 ]; then + local keep_orphans=1 + if [ "$ASSUME_YES" = "1" ]; then + printf '\n %s%d other NanoClaw-style agent(s) in the vault are not linked to this copy;\n --yes does NOT delete them (they may belong to another copy).%s\n' \ + "$c_yel" "${#ONECLI_ORPHANS[@]}" "$c_rst" + else + printf '\n %sFound %d other NanoClaw-style agent(s) in the vault not linked to this copy —\n they may belong to ANOTHER NanoClaw copy on this machine.%s\n' \ + "$c_yel" "${#ONECLI_ORPHANS[@]}" "$c_rst" + if confirm "Delete them too?"; then + keep_orphans=0 + for entry in "${ONECLI_ORPHANS[@]}"; do ONECLI_DELETE+=("$entry"); done + fi + fi + if [ "$keep_orphans" = "1" ]; then + note_skip "OneCLI orphan agents (${#ONECLI_ORPHANS[@]}): left in place — remove manually if they're yours:" + for entry in "${ONECLI_ORPHANS[@]}"; do + IFS=$'\t' read -r uuid identifier name <<<"$entry" + note_skip " onecli agents delete --id $uuid # $name — $identifier" + done + fi + fi + + [ "${#ONECLI_DELETE[@]}" -gt 0 ] && DO[3]=1 + return 0 +} + +do_onecli() { + printf '\n %sRemoving OneCLI agents...%s\n' "$c_dim" "$c_rst" + if ! have_cmd onecli; then + note_skip "OneCLI agents: 'onecli' not on PATH; remove via 'onecli agents list' / 'onecli agents delete --id '." + return 0 + fi + [ "${#ONECLI_DELETE[@]}" -gt 0 ] || return 0 + local entry uuid identifier name + for entry in "${ONECLI_DELETE[@]}"; do + IFS=$'\t' read -r uuid identifier name <<<"$entry" + [ -z "$uuid" ] && continue + if onecli agents delete --id "$uuid" >/dev/null 2>&1; then + printf ' %s✓%s deleted %s (%s)\n' "$c_grn" "$c_rst" "$name" "$identifier" + else + printf ' %s!%s %s already gone\n' "$c_yel" "$c_rst" "$identifier" + fi + done +} + +do_data() { + printf '\n %sRemoving app data, logs & secrets...%s\n' "$c_dim" "$c_rst" + if [ -f "$PROJECT_ROOT/.env" ]; then + # Don't clobber an existing backup — fall back to a timestamped name. + local bak="$PROJECT_ROOT/.env.bak" + [ -e "$bak" ] && bak="$PROJECT_ROOT/.env.bak.$(date +%Y%m%d-%H%M%S)" + cp -p "$PROJECT_ROOT/.env" "$bak" + rm -f "$PROJECT_ROOT/.env" + printf ' %s✓%s removed .env (backup at %s)\n' "$c_grn" "$c_rst" "$(tilde "$bak")" + fi + local p + for p in data logs dist node_modules start-nanoclaw.sh nanoclaw.pid; do + [ -e "$PROJECT_ROOT/$p" ] && rm -rf "${PROJECT_ROOT:?}/$p" && printf ' %s✓%s removed %s\n' "$c_grn" "$c_rst" "$p" || true + done +} + +do_user() { + printf '\n %sRemoving agent memory & files...%s\n' "$c_dim" "$c_rst" + local p + for p in groups store; do + [ -e "$PROJECT_ROOT/$p" ] && rm -rf "${PROJECT_ROOT:?}/$p" && printf ' %s✓%s removed %s\n' "$c_grn" "$c_rst" "$p" || true + done +} + +# =========================================================================== +# Main +# =========================================================================== +printf '\n%sUninstall NanoClaw%s (copy id: %s)\n' "$c_bold" "$c_rst" "$SLUG" +printf '%sFolder: %s%s\n' "$c_dim" "$PROJECT_ROOT" "$c_rst" +printf '%sChecking what exists for this copy...%s\n' "$c_dim" "$c_rst" + +# Group metadata: title | description | scan fn | remove fn | confirm prompt +G_TITLE=( + "1) App & background service" + "2) App data, logs & secrets" + "3) Your agents' memory & files" + "4) OneCLI credential agents" +) +G_DESC=( + "Runs NanoClaw in the background. Removing this stops the assistant. None of your data lives here." + "Message database, conversation history, logs, build files, and your .env (API keys / tokens). Removing this erases stored conversations and saved credentials." + "Notes and memory your agents created (groups/) and any migrated data (store/). Content you made — it cannot be recovered after deletion." + "Per-agent entries this copy registered in the OneCLI vault. The OneCLI app, your credentials, and the gateway are NOT touched." +) +G_SCAN=(scan_service scan_data scan_user scan_onecli) +G_DO=(do_service do_data do_user do_onecli) +G_PROMPT=( + "Delete the app & background service shown above?" + "Delete app data, logs & secrets shown above? (erases conversations + API keys)" + "Delete your agents' memory & files shown above? (cannot be undone)" + "Delete this copy's OneCLI agents shown above?" +) +# Per-group buffers, captured during the scan pass. +G_ROWS=() # newline-joined rows per group (tab-separated within a row) +G_FOUND=() # count per group + +TOTAL_FOUND=0 +EMPTY_LIST="" +for i in 0 1 2 3; do + "${G_SCAN[$i]}" + G_FOUND[$i]=$FOUND + # serialize ROWS (may be empty) + if [ "$FOUND" -gt 0 ]; then + G_ROWS[$i]="$(printf '%s\n' "${ROWS[@]}")" + TOTAL_FOUND=$((TOTAL_FOUND + FOUND)) + else + G_ROWS[$i]="" + EMPTY_LIST="${EMPTY_LIST:+$EMPTY_LIST, }${G_TITLE[$i]}" + fi +done + +# Nothing at all → already clean. +if [ "$TOTAL_FOUND" -eq 0 ]; then + printf '\n%s✓ Nothing to uninstall — this copy (%s) is already clean.%s\n' "$c_grn" "$SLUG" "$c_rst" + printf '%s (No service, containers, image, data, or OneCLI agents found for this folder.)%s\n' "$c_dim" "$c_rst" + exit 0 +fi + +# Helper to print a group's buffered table. +print_buffered() { # index + local i="$1" + group_head "${G_TITLE[$i]}" "${G_DESC[$i]}" + printf ' %s%-26s %s%s\n' "$c_dim" "WHAT" "WHERE" "$c_rst" + local what where line + while IFS= read -r line; do + [ -z "$line" ] && continue + IFS=$'\t' read -r what where <<<"$line" + printf ' %s●%s %-26s %s\n' "$c_red" "$c_rst" "$what" "$where" + done <<<"${G_ROWS[$i]}" +} + +# --- dry run: show only groups that have something, then exit --------------- +if [ "$DRY_RUN" = "1" ]; then + printf '\n%sPREVIEW ONLY — this shows what would be deleted and changes nothing.%s\n' "$c_cyn" "$c_rst" + for i in 0 1 2 3; do + [ "${G_FOUND[$i]}" -gt 0 ] || continue + # Group 3 (OneCLI) mixes MINE and orphan rows; print_buffered would show + # orphans inside the same "would be deleted" table, contradicting the note + # that orphans are never auto-deleted. Render the two subsets separately to + # match the interactive/--yes path (decide_onecli). + if [ "$i" = "3" ]; then + group_head "${G_TITLE[3]}" "${G_DESC[3]}" + printf ' %sWould be deleted (after confirmation):%s\n' "$c_dim" "$c_rst" + for entry in "${ONECLI_MINE[@]:-}"; do + [ -n "$entry" ] || continue + IFS=$'\t' read -r uuid identifier name <<<"$entry" + printf ' %s●%s %s — %s\n' "$c_red" "$c_rst" "$name" "$identifier" + done + printf ' %sLeft in place — may belong to another copy:%s\n' "$c_dim" "$c_rst" + for entry in "${ONECLI_ORPHANS[@]:-}"; do + [ -n "$entry" ] || continue + IFS=$'\t' read -r uuid identifier name <<<"$entry" + printf ' %s○%s %s — %s\n' "$c_yel" "$c_rst" "$name" "$identifier" + done + else + print_buffered "$i" + fi + done + if [ -n "$EMPTY_LIST" ]; then + printf '\n%sNothing found for: %s%s\n' "$c_dim" "$EMPTY_LIST" "$c_rst" + fi + # Surface scan-time notes (e.g. the M3 "couldn't read agent_groups" warning) + # here too — dry-run exits before the closing summary that normally prints + # them, and the whole point is to warn the user before they decide. + for n in "${SKIPPED_NOTES[@]:-}"; do [ -n "$n" ] && printf '%s • %s%s\n' "$c_dim" "$n" "$c_rst"; done + printf '\n%sPreview complete. Nothing was changed.%s\n' "$c_cyn" "$c_rst" + exit 0 +fi + +if [ "$ASSUME_YES" = "1" ]; then + printf '\n%s--yes given: deleting everything found below without asking.%s\n' "$c_yel" "$c_rst" +else + printf '\n%sYou will be asked about each group that has something. Default is to keep\n(just press Enter). Type "y" to delete a group.%s\n' "$c_dim" "$c_rst" +fi + +# --- interactive / --yes: only groups with something ------------------------ +DO=(0 0 0 0) +for i in 0 1 2 3; do + [ "${G_FOUND[$i]}" -gt 0 ] || continue + print_buffered "$i" + # Group 4 (OneCLI) has two sub-decisions (this copy's agents vs. orphans) that + # the single-prompt loop can't express, so it's special-cased. + if [ "$i" = "3" ]; then + decide_onecli + elif confirm "${G_PROMPT[$i]}"; then + DO[$i]=1 + else + note_skip "${G_TITLE[$i]}: kept by your choice." + fi +done + +# Execute. OneCLI deletion (index 3) must run BEFORE data (index 1), which +# removes data/v2.db that the OneCLI step reads. +if [ "${DO[0]}" = "1" ]; then do_service; fi +if [ "${DO[3]}" = "1" ]; then do_onecli; fi +if [ "${DO[1]}" = "1" ]; then do_data; fi +if [ "${DO[2]}" = "1" ]; then do_user; fi + +# --- closing summary -------------------------------------------------------- +printf '\n%s── Left alone (shared / not ours) ──%s\n' "$c_bold" "$c_rst" +printf '%s • OneCLI app, vault & credentials: ~/.local/share/onecli, ~/.local/bin/onecli\n' "$c_dim" +printf ' • Host-wide config: ~/.config/nanoclaw/ (mount/sender allowlists)\n' +printf ' • PATH line in ~/.bashrc and ~/.zshrc\n' +printf ' • Other NanoClaw copies on this machine%s\n' "$c_rst" +for n in "${SKIPPED_NOTES[@]:-}"; do [ -n "$n" ] && printf '%s • %s%s\n' "$c_dim" "$n" "$c_rst"; done + +printf '\n%s✓ Done. NanoClaw copy %s has been uninstalled.%s\n' "$c_grn" "$SLUG" "$c_rst" From 41a720dd5911a746cb0f4e04fada53ce214c5340 Mon Sep 17 00:00:00 2001 From: Amit Shafnir Date: Wed, 10 Jun 2026 13:09:22 +0300 Subject: [PATCH 2/3] feat: port uninstaller to TS, wire nanoclaw.sh --uninstall, detect existing installs in setup Replaces the standalone bash uninstall.sh with a TypeScript flow inside the setup driver (setup/uninstall/): scan (slug-scoped inventory), plan (pure ordered removal actions), remove (per-action executor that absorbs failures into notes), and flow (clack UI). uninstall.sh is now a 3-line pointer that execs nanoclaw.sh --uninstall. - nanoclaw.sh --uninstall short-circuits before diagnostics/bootstrap; with no node_modules it prints manual cleanup commands and exits 1 - setup:auto routes --uninstall before initProgressionLog so an uninstall never resets logs/setup.log - fresh setup runs detect an existing install (service registration or data/v2.db) and offer keep-and-continue (default) or uninstall-and-exit; suppressed on fail()-retry and sg re-exec resumes - self-deletion safety: static imports only, dist/ + node_modules/ removed dead last, nothing but console.log after the runtime tail - --yes never deletes orphan ag-* vault agents; their manual delete commands (by vault uuid) are printed instead Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 2 +- README.md | 4 +- docs/setup-flow.md | 2 +- nanoclaw.sh | 37 ++ setup/auto.ts | 44 +++ setup/lib/setup-config.ts | 26 ++ setup/uninstall/flow.ts | 349 +++++++++++++++++ setup/uninstall/onecli-agents.test.ts | 150 +++++++ setup/uninstall/onecli-agents.ts | 141 +++++++ setup/uninstall/plan.test.ts | 153 ++++++++ setup/uninstall/plan.ts | 117 ++++++ setup/uninstall/remove.test.ts | 147 +++++++ setup/uninstall/remove.ts | 162 ++++++++ setup/uninstall/scan.test.ts | 196 ++++++++++ setup/uninstall/scan.ts | 276 +++++++++++++ uninstall.sh | 544 +------------------------- 16 files changed, 1804 insertions(+), 546 deletions(-) create mode 100644 setup/uninstall/flow.ts create mode 100644 setup/uninstall/onecli-agents.test.ts create mode 100644 setup/uninstall/onecli-agents.ts create mode 100644 setup/uninstall/plan.test.ts create mode 100644 setup/uninstall/plan.ts create mode 100644 setup/uninstall/remove.test.ts create mode 100644 setup/uninstall/remove.ts create mode 100644 setup/uninstall/scan.test.ts create mode 100644 setup/uninstall/scan.ts diff --git a/CLAUDE.md b/CLAUDE.md index 64e379975..0a8ba3c26 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,7 +83,7 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t | `groups//` | Per-agent-group filesystem (CLAUDE.md, skills, per-group `agent-runner-src/` overlay) | | `scripts/init-first-agent.ts` | Bootstrap the first DM-wired agent (used by `/init-first-agent` skill) | | `migrate-v2.sh` + `setup/migrate-v2/` | v1→v2 migration. Standalone script: `bash migrate-v2.sh`. Seeds DB, copies groups/sessions, installs channels, builds container, offers service switchover, then hands off to `/migrate-from-v1` skill for owner setup and CLAUDE.md cleanup. See [docs/migration-dev.md](docs/migration-dev.md). | -| `uninstall.sh` | Uninstall this copy only (slug-scoped): service, containers + image, `data/`, `logs/`, `groups/`, this copy's OneCLI agents. Confirms per group; `--dry-run` previews, `--yes` skips prompts. Other copies and the shared OneCLI app are untouched. | +| `nanoclaw.sh --uninstall` + `setup/uninstall/` | Uninstall this copy only (slug-scoped): service, containers + image, `data/`, `logs/`, `groups/`, this copy's OneCLI agents. Confirms per group; `--dry-run` previews, `--yes` skips prompts. Other copies and the shared OneCLI app are untouched. Bypasses bootstrap entirely; `uninstall.sh` is a pointer that execs it. | ## Admin CLI (`ncl`) diff --git a/README.md b/README.md index 691fde65f..9c6aee780 100644 --- a/README.md +++ b/README.md @@ -199,10 +199,10 @@ If a step fails, `nanoclaw.sh` hands off to Claude Code to diagnose and resume. **How do I uninstall NanoClaw?** ```bash -bash uninstall.sh +bash nanoclaw.sh --uninstall ``` -Every install is tagged with a per-checkout id, so the script removes only what belongs to that copy: the background service, containers and image, app data and logs, your agents' files, and this copy's OneCLI vault agents. Shared things — the OneCLI app and your credentials, other NanoClaw copies on the machine — are left alone. It shows exactly what it found and asks for confirmation per group; nothing is deleted until you say yes. Use `--dry-run` to preview without changing anything, or `--yes` to skip the prompts. Your `.env` is backed up before removal. To finish, delete the checkout folder itself. +Every install is tagged with a per-checkout id, so the uninstaller removes only what belongs to that copy: the background service, containers and image, app data and logs, your agents' files, and this copy's OneCLI vault agents. Shared things — the OneCLI app and your credentials, other NanoClaw copies on the machine — are left alone. It shows exactly what it found and asks for confirmation per group; nothing is deleted until you say yes. Use `--dry-run` to preview without changing anything, or `--yes` to skip the prompts. Your `.env` is backed up before removal. To finish, delete the checkout folder itself. **What changes will be accepted into the codebase?** diff --git a/docs/setup-flow.md b/docs/setup-flow.md index 800411cf3..2a31e8bb5 100644 --- a/docs/setup-flow.md +++ b/docs/setup-flow.md @@ -187,7 +187,7 @@ leaking the token to disk outweighs the debugging value. | File | Role | |---|---| -| `nanoclaw.sh` | Top-level wrapper. Phase 1 (bootstrap) and phase 2 (setup:auto) orchestration. Writes bootstrap's raw log + progression entry. | +| `nanoclaw.sh` | Top-level wrapper. Phase 1 (bootstrap) and phase 2 (setup:auto) orchestration. Writes bootstrap's raw log + progression entry. `--uninstall` bypasses bootstrap entirely — it execs setup:auto directly (the flow lives in `setup/uninstall/`), or prints manual-cleanup guidance and exits 1 when the TS toolchain is missing. | | `setup.sh` | Phase 1 bootstrap: Node, pnpm, native-module verify. Emits its own `BOOTSTRAP` status block (historically printed to stdout; now goes to the bootstrap raw log). | | `setup/auto.ts` | Phase 2 driver. Orchestrates the clack UI, step execution, user prompts, and writes to all three log levels for every step it spawns. | | `setup/logs.ts` | The logging primitives (`logStep`, `logUserInput`, `logComplete`, `stepRawLog`, `initSetupLog`). Single source of truth for level 2/3 formatting and file paths. | diff --git a/nanoclaw.sh b/nanoclaw.sh index 0b10fc63a..718773f5e 100755 --- a/nanoclaw.sh +++ b/nanoclaw.sh @@ -25,6 +25,43 @@ set -euo pipefail PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$PROJECT_ROOT" +# ─── --uninstall: short-circuit before any setup work ────────────────── +# Never install dependencies just to uninstall. With the TS toolchain +# present, hand straight off to setup:auto (the flow lives in +# setup/uninstall/); without it, print manual cleanup guidance. Runs +# before diagnostics.sh is sourced so a pure uninstall doesn't emit +# setup_launched, and before all pre-flights/bootstrap. +for arg in "$@"; do + if [ "$arg" = "--uninstall" ]; then + # exec tsx directly rather than `pnpm run -- …`: pnpm passes the `--` + # separator through to the script, where the flag parser treats + # everything after it as positional args and the flags get dropped. + if command -v pnpm >/dev/null 2>&1 && [ -x "$PROJECT_ROOT/node_modules/.bin/tsx" ]; then + exec "$PROJECT_ROOT/node_modules/.bin/tsx" "$PROJECT_ROOT/setup/auto.ts" "$@" + fi + export NANOCLAW_PROJECT_ROOT="$PROJECT_ROOT" + # shellcheck source=setup/lib/install-slug.sh + source "$PROJECT_ROOT/setup/lib/install-slug.sh" + UNINSTALL_RUNTIME="${CONTAINER_RUNTIME:-docker}" + echo "Can't run the uninstaller: dependencies are missing (node_modules/)." + echo "Either re-run 'bash nanoclaw.sh' once to restore them, or clean up manually:" + echo "" + if [ "$(uname -s)" = "Darwin" ]; then + echo " launchctl unload ~/Library/LaunchAgents/$(launchd_label).plist" + echo " rm -f ~/Library/LaunchAgents/$(launchd_label).plist" + else + echo " systemctl --user disable --now $(systemd_unit).service" + echo " rm -f ~/.config/systemd/user/$(systemd_unit).service && systemctl --user daemon-reload" + fi + echo " $UNINSTALL_RUNTIME ps -aq --filter label=nanoclaw-install=$(_nanoclaw_install_slug) | xargs -r $UNINSTALL_RUNTIME rm -f" + echo " $UNINSTALL_RUNTIME rmi $(container_image_base):latest" + echo " rm -f ~/.local/bin/ncl # only if it points at this folder" + echo "" + echo "Then back up $PROJECT_ROOT/.env if you need the keys, and delete the folder." + exit 1 + fi +done + LOGS_DIR="$PROJECT_ROOT/logs" STEPS_DIR="$LOGS_DIR/setup-steps" PROGRESS_LOG="$LOGS_DIR/setup.log" diff --git a/setup/auto.ts b/setup/auto.ts index 5428d03ca..5de68b4db 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -48,6 +48,8 @@ import { } from './lib/setup-config-parse.js'; import { runAdvancedScreen } from './lib/setup-config-screen.js'; import { runWindowedStep } from './lib/windowed-runner.js'; +import { runUninstallFlow } from './uninstall/flow.js'; +import { detectExistingInstall } from './uninstall/scan.js'; import { detectRegisteredGroups, detectExistingDisplayName } from './environment.js'; import { pollHealth } from './onecli.js'; import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js'; @@ -88,6 +90,17 @@ async function main(): Promise { let configValues = { ...readFromEnv(), ...flagResult.values }; applyToEnv(configValues); + // --uninstall routes to the uninstall flow before any setup side effects — + // in particular before initProgressionLog(), so an uninstall never resets + // logs/setup.log on its way to (possibly) deleting logs/ entirely. + if (configValues.uninstall === true) { + await runUninstallFlow({ + dryRun: configValues.dryRun === true, + yes: configValues.yes === true, + invokedFrom: 'flag', + }); + } + printIntro(); initProgressionLog(); phEmit('auto_started'); @@ -121,6 +134,37 @@ async function main(): Promise { .filter(Boolean), ); + // Offer removal when setup lands on an existing install. Skipped on every + // resume path — both the fail() retry and the sg-docker re-exec pass + // NANOCLAW_SKIP (and the latter sets NANOCLAW_REEXEC_SG) — so the prompt + // appears at most once per fresh run. + const isResume = process.env.NANOCLAW_REEXEC_SG === '1' || skip.size > 0; + if (!isResume && detectExistingInstall(process.cwd())) { + const action = ensureAnswer( + await brightSelect<'keep' | 'uninstall'>({ + message: 'NanoClaw is already installed in this folder. What would you like to do?', + options: [ + { + value: 'keep', + label: 'Keep it & continue setup', + hint: 'recommended — re-running setup is safe', + }, + { + value: 'uninstall', + label: 'Uninstall NanoClaw & exit', + hint: 'removes service, data, and agent files — asks before each step', + }, + ], + initialValue: 'keep', + }), + ) as 'keep' | 'uninstall'; + setupLog.userInput('existing_install', action); + phEmit('existing_install_detected', { action }); + if (action === 'uninstall') { + await runUninstallFlow({ dryRun: false, yes: false, invokedFrom: 'setup-detection' }); + } + } + if (!skip.has('environment')) { const res = await runQuietStep('environment', { running: 'Checking your system…', diff --git a/setup/lib/setup-config.ts b/setup/lib/setup-config.ts index 5028486dc..dcb05b84e 100644 --- a/setup/lib/setup-config.ts +++ b/setup/lib/setup-config.ts @@ -132,6 +132,32 @@ export const CONFIG: Entry[] = [ type: 'boolean', default: false, }, + + // Uninstall route — handled in auto.ts before any setup work begins. + { + key: 'uninstall', + label: 'Uninstall', + help: 'Remove this NanoClaw copy (service, containers, data, vault agents). Asks per group.', + surface: 'flag', + type: 'boolean', + default: false, + }, + { + key: 'dryRun', + label: 'Uninstall dry run', + help: 'With --uninstall: preview what would be removed without changing anything.', + surface: 'flag', + type: 'boolean', + default: false, + }, + { + key: 'yes', + label: 'Uninstall without prompts', + help: 'With --uninstall: delete everything found without asking (orphan vault agents are still kept).', + surface: 'flag', + type: 'boolean', + default: false, + }, ]; // ─── name derivation ─────────────────────────────────────────────────── diff --git a/setup/uninstall/flow.ts b/setup/uninstall/flow.ts new file mode 100644 index 000000000..2fe226966 --- /dev/null +++ b/setup/uninstall/flow.ts @@ -0,0 +1,349 @@ +/** + * Uninstall flow — clack UI orchestration over scan/plan/remove. + * + * Self-deletion constraint: this flow runs on tsx out of the node_modules + * it deletes. All imports are static (loaded before any deletion), dist/ + * and node_modules/ are removed last (the runtime tail), and once execution + * starts nothing here writes to logs/ (which would recreate it) or does a + * dynamic import. After the runtime tail, the only output is console.log. + * + * Removes ONLY what belongs to this checkout (per-checkout install slug). + * Each non-empty group shows a WHAT/WHERE table and asks a default-No + * confirm. Nothing is deleted until every decision has been made, so + * Ctrl-C anywhere in the confirm phase leaves the install untouched. + */ +import { spawnSync } from 'child_process'; +import os from 'os'; + +import * as p from '@clack/prompts'; +import k from 'kleur'; + +import { emit as phEmit } from '../lib/diagnostics.js'; +import { note } from '../lib/theme.js'; +import * as setupLog from '../logs.js'; +import { + resolveOnecliDeletions, + type RunCommand, + type VaultAgent, +} from './onecli-agents.js'; +import { buildRemovalPlan, type Decisions } from './plan.js'; +import { executePlan, type ExecDeps } from './remove.js'; +import { scanInstall, tilde, type Inventory } from './scan.js'; + +const GROUPS = { + service: { + title: '1) App & background service', + desc: 'Runs NanoClaw in the background. Removing this stops the assistant. None of your data lives here.', + prompt: 'Delete the app & background service shown above?', + }, + data: { + title: '2) App data, logs & secrets', + desc: 'Message database, conversation history, logs, build files, and your .env (API keys / tokens). Removing this erases stored conversations and saved credentials.', + prompt: 'Delete app data, logs & secrets shown above? (erases conversations + API keys)', + }, + user: { + title: "3) Your agents' memory & files", + desc: 'Notes and memory your agents created (groups/) and any migrated data (store/). Content you made — it cannot be recovered after deletion.', + prompt: "Delete your agents' memory & files shown above? (cannot be undone)", + }, + onecli: { + title: '4) OneCLI credential agents', + desc: 'Per-agent entries this copy registered in the OneCLI vault. The OneCLI app, your credentials, and the gateway are NOT touched.', + }, +} as const; + +const runCommand: RunCommand = (cmd, args) => { + const res = spawnSync(cmd, args, { encoding: 'utf-8' }); + return { status: res.status, stdout: res.stdout ?? '' }; +}; + +export async function runUninstallFlow(opts: { + dryRun: boolean; + yes: boolean; + invokedFrom: 'flag' | 'setup-detection'; +}): Promise { + const { dryRun, yes } = opts; + + if (!process.stdin.isTTY && !yes && !dryRun) { + console.error( + 'Uninstall needs an interactive terminal. Re-run with --yes to delete everything found without prompts, or --dry-run to preview.', + ); + process.exit(1); + } + + const projectRoot = process.cwd(); + const home = os.homedir(); + + p.intro(k.bold(`Uninstall NanoClaw`)); + phEmit('uninstall_started', { invokedFrom: opts.invokedFrom, dryRun, yes }); + + const spinner = p.spinner(); + spinner.start('Checking what exists for this copy…'); + const inv = scanInstall({ + projectRoot, + home, + platform: process.platform, + runCommand, + }); + spinner.stop(`Scanned copy ${inv.slug} at ${tilde(projectRoot, home)}.`); + + const svcRows = serviceRows(inv, home); + const dataRows = [...inv.data, ...inv.runtime].map(({ what, where }) => ({ what, where })); + const userRows = inv.user.map(({ what, where }) => ({ what, where })); + const totalFound = + svcRows.length + + dataRows.length + + userRows.length + + inv.onecli.mine.length + + inv.onecli.orphans.length; + + if (totalFound === 0) { + p.outro( + `✓ Nothing to uninstall — this copy (${inv.slug}) is already clean.\n` + + k.dim(' (No service, containers, image, data, or OneCLI agents found for this folder.)'), + ); + process.exit(0); + } + + if (dryRun) { + p.log.message( + k.cyan('PREVIEW ONLY — this shows what would be deleted and changes nothing.'), + ); + if (svcRows.length > 0) note(groupBody(GROUPS.service.desc, svcRows), GROUPS.service.title); + if (dataRows.length > 0) note(groupBody(GROUPS.data.desc, dataRows), GROUPS.data.title); + if (userRows.length > 0) note(groupBody(GROUPS.user.desc, userRows), GROUPS.user.title); + if (inv.onecli.mine.length > 0 || inv.onecli.orphans.length > 0) { + const lines = [GROUPS.onecli.desc, '']; + lines.push('Would be deleted (after confirmation):'); + for (const a of inv.onecli.mine) lines.push(` ● ${a.name} — ${a.identifier}`); + if (inv.onecli.mine.length === 0) lines.push(' (none)'); + lines.push('Left in place — may belong to another copy:'); + for (const a of inv.onecli.orphans) lines.push(` ○ ${a.name} — ${a.identifier}`); + if (inv.onecli.orphans.length === 0) lines.push(' (none)'); + note(lines.join('\n'), GROUPS.onecli.title); + } + const empty = emptyGroupTitles(svcRows.length, dataRows.length, userRows.length, inv); + if (empty.length > 0) p.log.message(k.dim(`Nothing found for: ${empty.join(', ')}`)); + for (const n of inv.notes) p.log.message(k.dim(`• ${n}`)); + p.outro('Preview complete. Nothing was changed.'); + process.exit(0); + } + + if (yes) { + p.log.warn('--yes given: deleting everything found below without asking.'); + } else { + p.log.message( + k.dim( + 'You will be asked about each group that has something. Default is to keep\n(just press Enter). Type "y" to delete a group.', + ), + ); + } + + // ── confirm phase — nothing is deleted until every decision is made ── + + let serviceYes = false; + if (svcRows.length > 0) { + note(groupBody(GROUPS.service.desc, svcRows), GROUPS.service.title); + serviceYes = await confirmGroup(GROUPS.service.prompt, yes); + } + + let dataYes = false; + if (dataRows.length > 0) { + note(groupBody(GROUPS.data.desc, dataRows), GROUPS.data.title); + dataYes = await confirmGroup(GROUPS.data.prompt, yes); + } + + let userYes = false; + if (userRows.length > 0) { + note(groupBody(GROUPS.user.desc, userRows), GROUPS.user.title); + userYes = await confirmGroup(GROUPS.user.prompt, yes); + } + + const keptNotes: string[] = []; + if (!serviceYes && svcRows.length > 0) keptNotes.push(`${GROUPS.service.title}: kept by your choice.`); + if (!dataYes && dataRows.length > 0) keptNotes.push(`${GROUPS.data.title}: kept by your choice.`); + if (!userYes && userRows.length > 0) keptNotes.push(`${GROUPS.user.title}: kept by your choice.`); + + const onecliDelete = await decideOnecli(inv, yes, keptNotes); + + // Last point where logs/ is guaranteed to still exist — record the + // decisions before execution can delete it. + setupLog.userInput( + 'uninstall_decisions', + JSON.stringify({ + service: serviceYes, + data: dataYes, + user: userYes, + onecliAgentsDeleted: onecliDelete.length, + }), + ); + + const decisions: Decisions = { + service: serviceYes, + data: dataYes, + user: userYes, + onecliDelete, + }; + const actions = buildRemovalPlan(inv, decisions); + + if (actions.length === 0) { + printLeftAlone([...inv.notes, ...keptNotes]); + p.outro('Nothing selected — nothing was changed.'); + process.exit(0); + } + + phEmit('uninstall_executed', { + invokedFrom: opts.invokedFrom, + service: serviceYes, + data: dataYes, + user: userYes, + onecliAgentsDeleted: onecliDelete.length, + }); + + // The runtime tail (dist/, node_modules/) runs after every other action + // AND after the summary — nothing but console.log may happen once the + // modules we're running from are gone. + const head = actions.filter((a) => a.kind !== 'delete-runtime-path'); + const tail = actions.filter((a) => a.kind === 'delete-runtime-path'); + + const deps: ExecDeps = { + runCommand, + log: (line) => p.log.message(line), + isRoot: process.getuid?.() === 0, + }; + const { notes: execNotes } = executePlan(head, deps); + + printLeftAlone([...inv.notes, ...keptNotes, ...execNotes]); + + executePlan(tail, { ...deps, log: (line) => console.log(` ${line}`) }); + console.log(`\n✓ Done. NanoClaw copy ${inv.slug} has been uninstalled.`); + process.exit(0); +} + +/** Unwrap a confirm result; Ctrl-C / Esc cancels the whole uninstall — nothing deleted. */ +function answered(value: T | symbol): T { + if (p.isCancel(value)) { + p.cancel('Uninstall cancelled. Nothing was deleted.'); + process.exit(0); + } + return value as T; +} + +async function confirmGroup(prompt: string, yes: boolean): Promise { + if (yes) return true; + return answered(await p.confirm({ message: prompt, initialValue: false })); +} + +/** + * Group 4 has two sub-decisions the single-prompt loop can't express: + * MINE is one yes/no; ORPHANS get a separate default-No prompt with an + * explicit cross-copy warning. --yes deletes MINE but never ORPHANS + * (enforced in resolveOnecliDeletions); anything kept is reported with + * the exact manual delete command (by vault uuid). + */ +async function decideOnecli( + inv: Inventory, + yes: boolean, + keptNotes: string[], +): Promise { + const { mine, orphans } = inv.onecli; + if (mine.length === 0 && orphans.length === 0) return []; + + const rows = [ + ...mine.map((a) => ({ what: 'OneCLI agent', where: `${a.name} — ${a.identifier}` })), + ...orphans.map((a) => ({ what: 'OneCLI agent (orphan)', where: `${a.name} — ${a.identifier}` })), + ]; + note(groupBody(GROUPS.onecli.desc, rows), GROUPS.onecli.title); + + let deleteMine = false; + if (mine.length > 0 && !yes) { + deleteMine = answered( + await p.confirm({ + message: `Delete this copy's ${mine.length} OneCLI agent(s)?`, + initialValue: false, + }), + ); + if (!deleteMine) keptNotes.push('OneCLI agents (this copy): kept by your choice.'); + } + + let deleteOrphans = false; + if (orphans.length > 0) { + if (yes) { + p.log.warn( + `${orphans.length} other NanoClaw-style agent(s) in the vault are not linked to this copy;\n--yes does NOT delete them (they may belong to another copy).`, + ); + } else { + p.log.warn( + `Found ${orphans.length} other NanoClaw-style agent(s) in the vault not linked to this copy —\nthey may belong to ANOTHER NanoClaw copy on this machine.`, + ); + deleteOrphans = answered( + await p.confirm({ message: 'Delete them too?', initialValue: false }), + ); + } + if (yes || !deleteOrphans) { + keptNotes.push( + `OneCLI orphan agents (${orphans.length}): left in place — remove manually if they're yours:`, + ); + for (const a of orphans) { + keptNotes.push(` onecli agents delete --id ${a.uuid} # ${a.name} — ${a.identifier}`); + } + } + } + + return resolveOnecliDeletions({ + mine, + orphans, + assumeYes: yes, + deleteMine, + deleteOrphans, + }); +} + +function serviceRows(inv: Inventory, home: string): { what: string; where: string }[] { + const s = inv.service; + const rows: { what: string; where: string }[] = []; + if (s.launchdPlist) rows.push({ what: 'Background service', where: tilde(s.launchdPlist, home) }); + if (s.systemdUserUnit) rows.push({ what: 'Background service', where: tilde(s.systemdUserUnit, home) }); + if (s.systemdSystemUnit) rows.push({ what: 'Background service (system)', where: s.systemdSystemUnit }); + if (s.pidFile) rows.push({ what: 'Running process', where: 'nanoclaw.pid' }); + if (s.containerIds.length > 0) { + rows.push({ what: 'Running containers', where: `${s.containerIds.length} container(s)` }); + } + if (s.image) rows.push({ what: 'Container image', where: s.image }); + if (s.nclSymlink) rows.push({ what: 'Command-line tool (ncl)', where: tilde(s.nclSymlink, home) }); + return rows; +} + +function groupBody(desc: string, rows: { what: string; where: string }[]): string { + const width = Math.max(...rows.map((r) => r.what.length), 'WHAT'.length); + const lines = [desc, '', `${'WHAT'.padEnd(width + 2)}WHERE`]; + for (const r of rows) lines.push(`${r.what.padEnd(width + 2)}${r.where}`); + return lines.join('\n'); +} + +function emptyGroupTitles( + svcCount: number, + dataCount: number, + userCount: number, + inv: Inventory, +): string[] { + const empty: string[] = []; + if (svcCount === 0) empty.push(GROUPS.service.title); + if (dataCount === 0) empty.push(GROUPS.data.title); + if (userCount === 0) empty.push(GROUPS.user.title); + if (inv.onecli.mine.length === 0 && inv.onecli.orphans.length === 0) { + empty.push(GROUPS.onecli.title); + } + return empty; +} + +function printLeftAlone(notes: string[]): void { + const lines = [ + '• OneCLI app, vault & credentials: ~/.local/share/onecli, ~/.local/bin/onecli', + '• Host-wide config: ~/.config/nanoclaw/ (mount/sender allowlists)', + '• PATH line in ~/.bashrc and ~/.zshrc', + '• Other NanoClaw copies on this machine', + ...notes.map((n) => `• ${n}`), + ]; + note(lines.join('\n'), 'Left alone (shared / not ours)'); +} diff --git a/setup/uninstall/onecli-agents.test.ts b/setup/uninstall/onecli-agents.test.ts new file mode 100644 index 000000000..4bb6386f8 --- /dev/null +++ b/setup/uninstall/onecli-agents.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import Database from 'better-sqlite3'; + +import { + listVaultAgents, + readAgentGroupIds, + resolveOnecliDeletions, + splitVaultAgents, + type VaultAgent, +} from './onecli-agents.js'; + +const agent = (uuid: string, identifier: string, name = identifier): VaultAgent => ({ + uuid, + identifier, + name, +}); + +describe('listVaultAgents', () => { + it('parses non-default agents from onecli JSON output', () => { + const payload = JSON.stringify({ + data: [ + { id: 'u-1', identifier: 'ag-main', name: 'Main', isDefault: false }, + { id: 'u-2', identifier: 'default', name: 'Default', isDefault: false }, + { id: 'u-3', identifier: 'ag-dev', name: 'Dev', isDefault: true }, + ], + }); + const result = listVaultAgents(() => ({ status: 0, stdout: payload })); + expect(result.available).toBe(true); + expect(result.agents).toEqual([agent('u-1', 'ag-main', 'Main')]); + }); + + it('reports unavailable when the command fails', () => { + expect(listVaultAgents(() => ({ status: 1, stdout: '' })).available).toBe(false); + }); + + it('reports unavailable when the command cannot be spawned', () => { + const result = listVaultAgents(() => { + throw new Error('ENOENT'); + }); + expect(result.available).toBe(false); + expect(result.agents).toEqual([]); + }); + + it('reports unavailable on unparseable output', () => { + expect(listVaultAgents(() => ({ status: 0, stdout: 'not json' })).available).toBe(false); + expect(listVaultAgents(() => ({ status: 0, stdout: '{"nope":1}' })).available).toBe(false); + }); +}); + +describe('readAgentGroupIds', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-uninstall-test-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('reads ids from a real DB', () => { + const dbPath = path.join(tempDir, 'v2.db'); + const db = new Database(dbPath); + db.exec('CREATE TABLE agent_groups (id TEXT PRIMARY KEY)'); + db.prepare('INSERT INTO agent_groups (id) VALUES (?)').run('ag-one'); + db.prepare('INSERT INTO agent_groups (id) VALUES (?)').run('ag-two'); + db.close(); + + const result = readAgentGroupIds(dbPath); + expect(result.known).toBe(true); + expect(result.ids).toEqual(new Set(['ag-one', 'ag-two'])); + }); + + it('returns known:false for a missing file', () => { + const result = readAgentGroupIds(path.join(tempDir, 'missing.db')); + expect(result.known).toBe(false); + expect(result.ids.size).toBe(0); + }); + + it('returns known:false for a corrupt file', () => { + const dbPath = path.join(tempDir, 'corrupt.db'); + fs.writeFileSync(dbPath, 'this is not a sqlite database at all'); + const result = readAgentGroupIds(dbPath); + expect(result.known).toBe(false); + expect(result.ids.size).toBe(0); + }); +}); + +describe('splitVaultAgents', () => { + it('splits mine vs ag-* orphans and ignores foreign identifiers', () => { + const agents = [ + agent('u-1', 'ag-mine'), + agent('u-2', 'ag-other'), + agent('u-3', 'some-tool'), + ]; + const { mine, orphans } = splitVaultAgents(agents, new Set(['ag-mine']), true); + expect(mine).toEqual([agent('u-1', 'ag-mine')]); + expect(orphans).toEqual([agent('u-2', 'ag-other')]); + }); + + it('forces all ag-* agents into orphans when ids are unknown', () => { + const agents = [agent('u-1', 'ag-mine'), agent('u-2', 'ag-other')]; + // ids set even contains ag-mine — known:false must override. + const { mine, orphans } = splitVaultAgents(agents, new Set(['ag-mine']), false); + expect(mine).toEqual([]); + expect(orphans).toEqual(agents); + }); +}); + +describe('resolveOnecliDeletions', () => { + const mine = [agent('u-1', 'ag-mine')]; + const orphans = [agent('u-2', 'ag-other')]; + + it('never deletes orphans under --yes, even if asked to', () => { + const deletions = resolveOnecliDeletions({ + mine, + orphans, + assumeYes: true, + deleteMine: false, + deleteOrphans: true, + }); + expect(deletions).toEqual(mine); + }); + + it('deletes orphans only on explicit interactive consent', () => { + expect( + resolveOnecliDeletions({ + mine, + orphans, + assumeYes: false, + deleteMine: true, + deleteOrphans: true, + }), + ).toEqual([...mine, ...orphans]); + + expect( + resolveOnecliDeletions({ + mine, + orphans, + assumeYes: false, + deleteMine: false, + deleteOrphans: false, + }), + ).toEqual([]); + }); +}); diff --git a/setup/uninstall/onecli-agents.ts b/setup/uninstall/onecli-agents.ts new file mode 100644 index 000000000..e37b684b2 --- /dev/null +++ b/setup/uninstall/onecli-agents.ts @@ -0,0 +1,141 @@ +/** + * OneCLI vault-agent inventory for the uninstaller. + * + * Vault agents split into two sets: MINE (identifier matches an agent-group + * id in this copy's data/v2.db) and ORPHANS (NanoClaw-style `ag-*` + * identifiers not in our DB — possibly another copy's). Deletion is always + * by the vault's internal uuid: the agent-group id is NOT a valid + * `onecli agents delete --id` value (see src/container-runner.ts). + */ +import fs from 'fs'; + +import Database from 'better-sqlite3'; + +export interface VaultAgent { + /** Internal vault uuid — the only valid `onecli agents delete --id` value. */ + uuid: string; + /** What the agent was registered under, e.g. a NanoClaw agent-group id (`ag-*`). */ + identifier: string; + name: string; +} + +export type RunCommand = ( + cmd: string, + args: string[], +) => { status: number | null; stdout: string }; + +/** + * List non-default vault agents via `onecli agents list`. `available: false` + * means the vault couldn't be read at all (binary missing, command failed, + * or unparseable output) — distinct from an empty vault. + */ +export function listVaultAgents(run: RunCommand): { + available: boolean; + agents: VaultAgent[]; +} { + let result: { status: number | null; stdout: string }; + try { + result = run('onecli', ['agents', 'list']); + } catch { + return { available: false, agents: [] }; + } + if (result.status !== 0) return { available: false, agents: [] }; + + let parsed: unknown; + try { + parsed = JSON.parse(result.stdout); + } catch { + return { available: false, agents: [] }; + } + + const data = + parsed !== null && typeof parsed === 'object' && 'data' in parsed + ? (parsed as { data: unknown }).data + : null; + if (!Array.isArray(data)) return { available: false, agents: [] }; + + const agents: VaultAgent[] = []; + for (const entry of data) { + if (entry === null || typeof entry !== 'object') continue; + const a = entry as Record; + if (a.isDefault === true) continue; + const identifier = typeof a.identifier === 'string' ? a.identifier : ''; + const uuid = typeof a.id === 'string' ? a.id : ''; + if (!identifier || identifier === 'default' || !uuid) continue; + agents.push({ + uuid, + identifier, + name: typeof a.name === 'string' ? a.name : '', + }); + } + return { available: true, agents }; +} + +/** + * Read this copy's agent-group ids from data/v2.db (readonly). + * + * `known: false` distinguishes "we couldn't read the DB at all" from "this + * copy has zero agent groups" — without it every ag-* vault agent would be + * mislabeled an orphan and --yes would silently leave this copy's agents + * behind. + */ +export function readAgentGroupIds(dbPath: string): { + ids: Set; + known: boolean; +} { + if (!fs.existsSync(dbPath)) return { ids: new Set(), known: false }; + + let db: Database.Database | null = null; + try { + db = new Database(dbPath, { readonly: true }); + const rows = db.prepare('SELECT id FROM agent_groups').all() as { + id: string; + }[]; + return { ids: new Set(rows.map((r) => r.id)), known: true }; + } catch { + return { ids: new Set(), known: false }; + } finally { + db?.close(); + } +} + +/** + * Split vault agents into MINE (identifier ∈ ids) and ORPHANS (ag-* not in + * ids). Non-NanoClaw identifiers are ignored entirely. With `known: false` + * nothing can be MINE, so every ag-* agent lands in ORPHANS — the caller is + * responsible for warning that the labels are unreliable. + */ +export function splitVaultAgents( + agents: VaultAgent[], + ids: Set, + known: boolean, +): { mine: VaultAgent[]; orphans: VaultAgent[] } { + const mine: VaultAgent[] = []; + const orphans: VaultAgent[] = []; + for (const agent of agents) { + if (known && ids.has(agent.identifier)) { + mine.push(agent); + } else if (agent.identifier.startsWith('ag-')) { + orphans.push(agent); + } + } + return { mine, orphans }; +} + +/** + * Resolve the vault-agent delete set from the user's answers. Under --yes + * (`assumeYes`) MINE is always deleted but ORPHANS never are — deleting + * what may be another copy's agents requires explicit human intent. + */ +export function resolveOnecliDeletions(input: { + mine: VaultAgent[]; + orphans: VaultAgent[]; + assumeYes: boolean; + deleteMine: boolean; + deleteOrphans: boolean; +}): VaultAgent[] { + const out: VaultAgent[] = []; + if (input.assumeYes || input.deleteMine) out.push(...input.mine); + if (!input.assumeYes && input.deleteOrphans) out.push(...input.orphans); + return out; +} diff --git a/setup/uninstall/plan.test.ts b/setup/uninstall/plan.test.ts new file mode 100644 index 000000000..01fb689ce --- /dev/null +++ b/setup/uninstall/plan.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect } from 'vitest'; + +import type { VaultAgent } from './onecli-agents.js'; +import { buildRemovalPlan, type Decisions, type RemovalAction } from './plan.js'; +import type { Inventory, PathItem } from './scan.js'; + +const item = (p: string, what: string): PathItem => ({ what, where: p, path: p }); + +const agent = (uuid: string, identifier: string): VaultAgent => ({ + uuid, + identifier, + name: identifier, +}); + +function inventory(overrides: Partial = {}): Inventory { + return { + slug: 'abcd1234', + projectRoot: '/proj', + containerRuntime: 'docker', + service: { + launchdPlist: '/home/u/Library/LaunchAgents/com.nanoclaw-v2-abcd1234.plist', + containerIds: ['c1', 'c2'], + image: 'nanoclaw-agent-v2-abcd1234:latest', + nclSymlink: '/home/u/.local/bin/ncl', + }, + data: [ + item('/proj/data', 'Database & conversations'), + item('/proj/logs', 'Logs'), + item('/proj/.env', 'Secrets / API keys (.env)'), + item('/proj/start-nanoclaw.sh', 'Start script'), + ], + runtime: [ + // node_modules deliberately FIRST — the planner must still order it last. + item('/proj/node_modules', 'Installed dependencies'), + item('/proj/dist', 'Build output'), + ], + user: [item('/proj/groups', 'Agent memory & files'), item('/proj/store', 'Migrated data store')], + onecli: { mine: [], orphans: [], idsKnown: true }, + notes: [], + ...overrides, + }; +} + +const allYes = (onecliDelete: VaultAgent[] = []): Decisions => ({ + service: true, + data: true, + user: true, + onecliDelete, +}); + +const kinds = (actions: RemovalAction[]) => actions.map((a) => a.kind); + +describe('buildRemovalPlan ordering invariants', () => { + it('backs up .env strictly before deleting it', () => { + const actions = buildRemovalPlan(inventory(), allYes()); + const backupIdx = actions.findIndex((a) => a.kind === 'backup-env'); + const envDeleteIdx = actions.findIndex( + (a) => a.kind === 'delete-path' && a.item.path === '/proj/.env', + ); + expect(backupIdx).toBeGreaterThanOrEqual(0); + expect(envDeleteIdx).toBeGreaterThan(backupIdx); + }); + + it('puts the runtime tail strictly last, with node_modules final', () => { + const actions = buildRemovalPlan(inventory(), allYes([agent('u-1', 'ag-mine')])); + const tail = actions.slice(-2); + expect(tail.map((a) => a.kind)).toEqual(['delete-runtime-path', 'delete-runtime-path']); + expect(tail.map((a) => (a.kind === 'delete-runtime-path' ? a.item.path : ''))).toEqual([ + '/proj/dist', + '/proj/node_modules', + ]); + // No non-tail action after the first runtime delete. + const firstTailIdx = actions.findIndex((a) => a.kind === 'delete-runtime-path'); + expect( + actions.slice(firstTailIdx).every((a) => a.kind === 'delete-runtime-path'), + ).toBe(true); + }); + + it('deletes OneCLI agents before the data group (which removes data/v2.db)', () => { + const actions = buildRemovalPlan(inventory(), allYes([agent('u-1', 'ag-mine')])); + const onecliIdx = actions.findIndex((a) => a.kind === 'delete-onecli-agent'); + const dataIdx = actions.findIndex( + (a) => a.kind === 'delete-path' && a.item.path === '/proj/data', + ); + expect(onecliIdx).toBeGreaterThanOrEqual(0); + expect(dataIdx).toBeGreaterThan(onecliIdx); + }); + + it('runs service teardown before container removal so the host cannot respawn them', () => { + const actions = buildRemovalPlan(inventory(), allYes()); + const unloadIdx = actions.findIndex((a) => a.kind === 'unload-service'); + const pkillIdx = actions.findIndex((a) => a.kind === 'pkill-host'); + const rmContainersIdx = actions.findIndex((a) => a.kind === 'rm-containers'); + expect(unloadIdx).toBeLessThan(rmContainersIdx); + expect(pkillIdx).toBeLessThan(rmContainersIdx); + }); +}); + +describe('buildRemovalPlan declined groups', () => { + it('declined data yields no data deletes and no runtime tail', () => { + const actions = buildRemovalPlan(inventory(), { + service: true, + data: false, + user: true, + onecliDelete: [], + }); + expect(kinds(actions)).not.toContain('backup-env'); + expect(kinds(actions)).not.toContain('delete-runtime-path'); + expect( + actions.some((a) => a.kind === 'delete-path' && a.item.path.startsWith('/proj/data')), + ).toBe(false); + }); + + it('all declined yields an empty plan', () => { + const actions = buildRemovalPlan(inventory(), { + service: false, + data: false, + user: false, + onecliDelete: [], + }); + expect(actions).toEqual([]); + }); + + it('declined service yields no service actions', () => { + const actions = buildRemovalPlan(inventory(), { + service: false, + data: true, + user: false, + onecliDelete: [], + }); + for (const kind of ['unload-service', 'pkill-host', 'rm-containers', 'rmi', 'rm-ncl-symlink']) { + expect(kinds(actions)).not.toContain(kind); + } + }); +}); + +describe('buildRemovalPlan conditional actions', () => { + it('skips backup-env when there is no .env', () => { + const inv = inventory({ data: [item('/proj/data', 'Database & conversations')] }); + expect(kinds(buildRemovalPlan(inv, allYes()))).not.toContain('backup-env'); + }); + + it('skips container/image actions when nothing was found', () => { + const inv = inventory({ service: { containerIds: [] } }); + const actionKinds = kinds(buildRemovalPlan(inv, allYes())); + expect(actionKinds).not.toContain('rm-containers'); + expect(actionKinds).not.toContain('rmi'); + expect(actionKinds).not.toContain('unload-service'); + // pkill always runs with a confirmed service group — a manually started + // host has no plist/unit but must still be stopped. + expect(actionKinds).toContain('pkill-host'); + }); +}); diff --git a/setup/uninstall/plan.ts b/setup/uninstall/plan.ts new file mode 100644 index 000000000..f49e12849 --- /dev/null +++ b/setup/uninstall/plan.ts @@ -0,0 +1,117 @@ +/** + * Pure removal planner: inventory + per-group decisions → ordered actions. + * + * The order is load-bearing: + * 1. Service / processes / containers / image / symlink — stop the host + * first so it can't respawn containers mid-removal. + * 2. OneCLI agent deletions — before the data group, which removes the + * data/v2.db the mine/orphan split was computed from. + * 3. Data group, with the .env backup strictly before its deletion. + * 4. User group (groups/, store/). + * 5. Runtime tail: dist/ then node_modules/ — ALWAYS last. The uninstaller + * runs on tsx out of node_modules; nothing may load after this. + */ +import path from 'path'; + +import type { VaultAgent } from './onecli-agents.js'; +import type { Inventory, PathItem } from './scan.js'; + +export interface Decisions { + service: boolean; + data: boolean; + user: boolean; + onecliDelete: VaultAgent[]; +} + +export type RemovalAction = + | { + kind: 'unload-service'; + flavor: 'launchd' | 'systemd-user' | 'systemd-system'; + unitPath: string; + /** systemd unit name without .service (unused for launchd). */ + unitName: string; + } + | { kind: 'kill-pid'; pidFile: string } + | { kind: 'pkill-host'; pattern: string } + | { kind: 'rm-containers'; runtime: string; containerIds: string[] } + | { kind: 'rmi'; runtime: string; image: string } + | { kind: 'rm-ncl-symlink'; linkPath: string } + | { kind: 'delete-onecli-agent'; agent: VaultAgent } + | { kind: 'backup-env'; envPath: string } + | { kind: 'delete-path'; item: PathItem } + | { kind: 'delete-runtime-path'; item: PathItem }; + +export function buildRemovalPlan(inv: Inventory, d: Decisions): RemovalAction[] { + const actions: RemovalAction[] = []; + + if (d.service) { + const s = inv.service; + if (s.launchdPlist) { + actions.push({ + kind: 'unload-service', + flavor: 'launchd', + unitPath: s.launchdPlist, + unitName: path.basename(s.launchdPlist, '.plist'), + }); + } + if (s.systemdUserUnit) { + actions.push({ + kind: 'unload-service', + flavor: 'systemd-user', + unitPath: s.systemdUserUnit, + unitName: path.basename(s.systemdUserUnit, '.service'), + }); + } + if (s.systemdSystemUnit) { + actions.push({ + kind: 'unload-service', + flavor: 'systemd-system', + unitPath: s.systemdSystemUnit, + unitName: path.basename(s.systemdSystemUnit, '.service'), + }); + } + if (s.pidFile) actions.push({ kind: 'kill-pid', pidFile: s.pidFile }); + actions.push({ + kind: 'pkill-host', + pattern: `${inv.projectRoot}/dist/index.js`, + }); + if (s.containerIds.length > 0) { + actions.push({ + kind: 'rm-containers', + runtime: inv.containerRuntime, + containerIds: s.containerIds, + }); + } + if (s.image) { + actions.push({ kind: 'rmi', runtime: inv.containerRuntime, image: s.image }); + } + if (s.nclSymlink) { + actions.push({ kind: 'rm-ncl-symlink', linkPath: s.nclSymlink }); + } + } + + for (const agent of d.onecliDelete) { + actions.push({ kind: 'delete-onecli-agent', agent }); + } + + if (d.data) { + const env = inv.data.find((i) => path.basename(i.path) === '.env'); + if (env) actions.push({ kind: 'backup-env', envPath: env.path }); + for (const item of inv.data) actions.push({ kind: 'delete-path', item }); + } + + if (d.user) { + for (const item of inv.user) actions.push({ kind: 'delete-path', item }); + } + + if (d.data) { + const tail = [...inv.runtime].sort( + (a, b) => + Number(path.basename(a.path) === 'node_modules') - + Number(path.basename(b.path) === 'node_modules'), + ); + for (const item of tail) actions.push({ kind: 'delete-runtime-path', item }); + } + + return actions; +} diff --git a/setup/uninstall/remove.test.ts b/setup/uninstall/remove.test.ts new file mode 100644 index 000000000..1ae3f58e4 --- /dev/null +++ b/setup/uninstall/remove.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import type { RunCommand } from './onecli-agents.js'; +import type { RemovalAction } from './plan.js'; +import { backupEnv, executePlan, type ExecDeps } from './remove.js'; + +let tempDir: string; + +beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-remove-test-')); +}); + +afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +function deps(overrides: Partial = {}): ExecDeps { + return { + runCommand: () => ({ status: 0, stdout: '' }), + log: () => {}, + isRoot: false, + ...overrides, + }; +} + +describe('backupEnv', () => { + it('backs up to .env.bak', () => { + const envPath = path.join(tempDir, '.env'); + fs.writeFileSync(envPath, 'KEY=secret'); + + const backup = backupEnv(envPath); + + expect(backup).toBe(path.join(tempDir, '.env.bak')); + expect(fs.readFileSync(backup, 'utf-8')).toBe('KEY=secret'); + }); + + it('falls back to a timestamped name when .env.bak exists', () => { + const envPath = path.join(tempDir, '.env'); + fs.writeFileSync(envPath, 'KEY=new'); + fs.writeFileSync(path.join(tempDir, '.env.bak'), 'KEY=old'); + + const backup = backupEnv(envPath); + + expect(path.basename(backup)).toMatch(/^\.env\.bak\.\d{8}-\d{6}$/); + expect(fs.readFileSync(backup, 'utf-8')).toBe('KEY=new'); + // The earlier backup is never clobbered. + expect(fs.readFileSync(path.join(tempDir, '.env.bak'), 'utf-8')).toBe('KEY=old'); + }); +}); + +describe('executePlan', () => { + it('deletes paths recursively', () => { + const dir = path.join(tempDir, 'data'); + fs.mkdirSync(path.join(dir, 'nested'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'nested', 'f.txt'), 'x'); + + const { notes } = executePlan( + [{ kind: 'delete-path', item: { what: 'Data', where: dir, path: dir } }], + deps(), + ); + + expect(fs.existsSync(dir)).toBe(false); + expect(notes).toEqual([]); + }); + + it('continues past a failing action and records a note', () => { + const dir = path.join(tempDir, 'logs'); + fs.mkdirSync(dir); + const actions: RemovalAction[] = [ + { + kind: 'unload-service', + flavor: 'launchd', + unitPath: path.join(tempDir, 'svc.plist'), + unitName: 'com.nanoclaw-v2-test', + }, + { kind: 'delete-path', item: { what: 'Logs', where: dir, path: dir } }, + ]; + const failing: RunCommand = () => { + throw new Error('launchctl exploded'); + }; + + const { notes } = executePlan(actions, deps({ runCommand: failing })); + + expect(notes).toHaveLength(1); + expect(notes[0]).toContain('unload-service'); + expect(notes[0]).toContain('launchctl exploded'); + // Later actions still ran. + expect(fs.existsSync(dir)).toBe(false); + }); + + it('leaves a system unit in place without root and notes the sudo command', () => { + const unitPath = path.join(tempDir, 'nanoclaw-v2-test.service'); + fs.writeFileSync(unitPath, '[Unit]'); + const calls: string[] = []; + const recorder: RunCommand = (cmd) => { + calls.push(cmd); + return { status: 0, stdout: '' }; + }; + + const { notes } = executePlan( + [ + { + kind: 'unload-service', + flavor: 'systemd-system', + unitPath, + unitName: 'nanoclaw-v2-test', + }, + ], + deps({ runCommand: recorder, isRoot: false }), + ); + + expect(fs.existsSync(unitPath)).toBe(true); + expect(calls).toEqual([]); + expect(notes.some((n) => n.includes('re-run with sudo'))).toBe(true); + }); + + it('notes a failed image removal with the retry command', () => { + const { notes } = executePlan( + [{ kind: 'rmi', runtime: 'docker', image: 'img:latest' }], + deps({ runCommand: () => ({ status: 1, stdout: '' }) }), + ); + expect(notes.some((n) => n.includes('docker rmi img:latest'))).toBe(true); + }); + + it('deletes OneCLI agents by vault uuid, never by identifier', () => { + const calls: string[][] = []; + const recorder: RunCommand = (cmd, args) => { + calls.push([cmd, ...args]); + return { status: 0, stdout: '' }; + }; + + executePlan( + [ + { + kind: 'delete-onecli-agent', + agent: { uuid: 'u-123', identifier: 'ag-mine', name: 'Mine' }, + }, + ], + deps({ runCommand: recorder }), + ); + + expect(calls).toEqual([['onecli', 'agents', 'delete', '--id', 'u-123']]); + }); +}); diff --git a/setup/uninstall/remove.ts b/setup/uninstall/remove.ts new file mode 100644 index 000000000..26a3dbb40 --- /dev/null +++ b/setup/uninstall/remove.ts @@ -0,0 +1,162 @@ +/** + * Removal-plan executor. Each action runs in its own try/catch: a failure + * becomes a summary note and execution continues (re-running the + * uninstaller is idempotent — the next scan only finds what's left). + * + * Must stay safe to run after logs/ and node_modules/ are gone: only static + * imports, no dynamic import(), no setup-log writes. Output goes through + * the injected `log` callback. + */ +import fs from 'fs'; +import path from 'path'; + +import type { RunCommand } from './onecli-agents.js'; +import type { RemovalAction } from './plan.js'; + +export interface ExecDeps { + runCommand: RunCommand; + log: (line: string) => void; + /** True when running as root — required to remove a system-level unit. */ + isRoot: boolean; +} + +export function executePlan( + actions: RemovalAction[], + deps: ExecDeps, +): { notes: string[] } { + const notes: string[] = []; + for (const action of actions) { + try { + runAction(action, deps, notes); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + notes.push( + `${action.kind}: failed (${msg}) — re-run the uninstaller to retry.`, + ); + } + } + return { notes }; +} + +/** + * Copy .env aside before deletion. Never clobbers an existing backup — + * falls back to a timestamped name on collision. Returns the backup path. + */ +export function backupEnv(envPath: string): string { + const dir = path.dirname(envPath); + let backup = path.join(dir, '.env.bak'); + if (fs.existsSync(backup)) { + const stamp = new Date() + .toISOString() + .replace(/[-:]/g, '') + .replace('T', '-') + .slice(0, 15); + backup = path.join(dir, `.env.bak.${stamp}`); + } + fs.copyFileSync(envPath, backup); + return backup; +} + +function runAction(action: RemovalAction, deps: ExecDeps, notes: string[]): void { + const { runCommand, log } = deps; + switch (action.kind) { + case 'unload-service': + switch (action.flavor) { + case 'launchd': + runCommand('launchctl', ['unload', action.unitPath]); + fs.rmSync(action.unitPath, { force: true }); + log('✓ background service removed'); + break; + case 'systemd-user': + runCommand('systemctl', [ + '--user', + 'disable', + '--now', + `${action.unitName}.service`, + ]); + fs.rmSync(action.unitPath, { force: true }); + runCommand('systemctl', ['--user', 'daemon-reload']); + log('✓ background service removed'); + break; + case 'systemd-system': + if (!deps.isRoot) { + log('! system service needs root — left in place'); + notes.push( + `System service ${action.unitPath} — re-run with sudo to remove.`, + ); + break; + } + runCommand('systemctl', ['disable', '--now', `${action.unitName}.service`]); + fs.rmSync(action.unitPath, { force: true }); + runCommand('systemctl', ['daemon-reload']); + log('✓ system service removed'); + break; + } + break; + case 'kill-pid': { + let pid = NaN; + try { + pid = Number(fs.readFileSync(action.pidFile, 'utf-8').trim()); + } catch { + // pidfile already gone + } + if (Number.isInteger(pid) && pid > 0) { + try { + process.kill(pid); + log('✓ stopped host process'); + } catch { + // not running + } + } + break; + } + case 'pkill-host': + // Exit 1 = no matching process — not a failure. + runCommand('pkill', ['-f', action.pattern]); + break; + case 'rm-containers': + runCommand(action.runtime, ['rm', '-f', ...action.containerIds]); + log(`✓ removed ${action.containerIds.length} container(s)`); + break; + case 'rmi': { + const res = runCommand(action.runtime, ['rmi', action.image]); + if (res.status === 0) { + log('✓ removed container image'); + } else { + log('! could not remove image (in use?)'); + notes.push( + `Image ${action.image}: not removed — retry with: ${action.runtime} rmi ${action.image}`, + ); + } + break; + } + case 'rm-ncl-symlink': + fs.rmSync(action.linkPath, { force: true }); + log('✓ removed ncl command'); + break; + case 'delete-onecli-agent': { + const res = runCommand('onecli', [ + 'agents', + 'delete', + '--id', + action.agent.uuid, + ]); + if (res.status === 0) { + log(`✓ deleted OneCLI agent ${action.agent.name} (${action.agent.identifier})`); + } else { + log(`! OneCLI agent ${action.agent.identifier} already gone`); + } + break; + } + case 'backup-env': { + const backup = backupEnv(action.envPath); + log(`✓ .env backed up to ${backup}`); + break; + } + case 'delete-path': + case 'delete-runtime-path': + fs.rmSync(action.item.path, { recursive: true, force: true }); + log(`✓ removed ${action.item.what}`); + break; + } +} diff --git a/setup/uninstall/scan.test.ts b/setup/uninstall/scan.test.ts new file mode 100644 index 000000000..86dd9ddac --- /dev/null +++ b/setup/uninstall/scan.test.ts @@ -0,0 +1,196 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import Database from 'better-sqlite3'; + +import { getLaunchdLabel, getSystemdUnit } from '../../src/install-slug.js'; +import type { RunCommand } from './onecli-agents.js'; +import { detectExistingInstall, scanInstall, type ScanDeps } from './scan.js'; + +let root: string; +let home: string; + +beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-scan-root-')); + home = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-scan-home-')); +}); + +afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(home, { recursive: true, force: true }); +}); + +/** Fake runCommand: unhandled commands fail (binary missing / daemon down). */ +function fakeRun( + handlers: Record { status: number | null; stdout: string }>, +): RunCommand { + return (cmd, args) => (handlers[cmd] ?? (() => ({ status: 1, stdout: '' })))(args); +} + +function deps(overrides: Partial = {}): ScanDeps { + return { + projectRoot: root, + home, + platform: 'darwin', + runCommand: fakeRun({}), + ...overrides, + }; +} + +const dockerUp = (containerIds: string[], hasImage: boolean) => + fakeRun({ + docker: (args) => { + if (args[0] === 'ps') return { status: 0, stdout: containerIds.join('\n') + '\n' }; + if (args[0] === 'image') return { status: hasImage ? 0 : 1, stdout: '' }; + return { status: 1, stdout: '' }; + }, + }); + +describe('scanInstall path groups', () => { + it('puts dist and node_modules in runtime, not data', () => { + for (const dir of ['data', 'logs', 'dist', 'node_modules', 'groups', 'store']) { + fs.mkdirSync(path.join(root, dir)); + } + fs.writeFileSync(path.join(root, '.env'), 'KEY=v'); + fs.writeFileSync(path.join(root, 'start-nanoclaw.sh'), '#!/bin/bash'); + + const inv = scanInstall(deps()); + + expect(inv.data.map((i) => path.basename(i.path))).toEqual([ + 'data', + 'logs', + '.env', + 'start-nanoclaw.sh', + ]); + expect(inv.runtime.map((i) => path.basename(i.path))).toEqual([ + 'dist', + 'node_modules', + ]); + expect(inv.user.map((i) => path.basename(i.path))).toEqual(['groups', 'store']); + }); + + it('finds nothing in an empty checkout', () => { + const inv = scanInstall(deps()); + expect(inv.data).toEqual([]); + expect(inv.runtime).toEqual([]); + expect(inv.user).toEqual([]); + expect(inv.service.containerIds).toEqual([]); + expect(inv.service.image).toBeUndefined(); + }); +}); + +describe('scanInstall service artifacts', () => { + it('detects the launchd plist on macOS', () => { + const plist = path.join( + home, + 'Library', + 'LaunchAgents', + `${getLaunchdLabel(root)}.plist`, + ); + fs.mkdirSync(path.dirname(plist), { recursive: true }); + fs.writeFileSync(plist, ''); + + const inv = scanInstall(deps()); + expect(inv.service.launchdPlist).toBe(plist); + expect(inv.service.systemdUserUnit).toBeUndefined(); + }); + + it('detects systemd user unit and pidfile on Linux', () => { + const unit = path.join( + home, + '.config', + 'systemd', + 'user', + `${getSystemdUnit(root)}.service`, + ); + fs.mkdirSync(path.dirname(unit), { recursive: true }); + fs.writeFileSync(unit, '[Unit]'); + fs.writeFileSync(path.join(root, 'nanoclaw.pid'), '12345'); + + const inv = scanInstall(deps({ platform: 'linux' })); + expect(inv.service.systemdUserUnit).toBe(unit); + expect(inv.service.pidFile).toBe(path.join(root, 'nanoclaw.pid')); + expect(inv.service.launchdPlist).toBeUndefined(); + }); + + it('captures container ids and image when docker is up', () => { + const inv = scanInstall(deps({ runCommand: dockerUp(['abc123', 'def456'], true) })); + expect(inv.service.containerIds).toEqual(['abc123', 'def456']); + expect(inv.service.image).toMatch(/^nanoclaw-agent-v2-[0-9a-f]{8}:latest$/); + expect(inv.notes).toEqual([]); + }); + + it('degrades with a manual-cleanup note when docker is unavailable', () => { + const inv = scanInstall(deps()); + expect(inv.service.containerIds).toEqual([]); + expect(inv.service.image).toBeUndefined(); + expect(inv.notes.some((n) => n.includes("'docker' unavailable"))).toBe(true); + }); +}); + +describe('scanInstall ncl symlink', () => { + const link = () => path.join(home, '.local', 'bin', 'ncl'); + + it('includes the symlink only when it targets this checkout', () => { + fs.mkdirSync(path.dirname(link()), { recursive: true }); + fs.symlinkSync(path.join(root, 'bin', 'ncl'), link()); + + const inv = scanInstall(deps()); + expect(inv.service.nclSymlink).toBe(link()); + }); + + it('leaves a symlink pointing at another copy, with a note', () => { + fs.mkdirSync(path.dirname(link()), { recursive: true }); + fs.symlinkSync('/some/other/copy/bin/ncl', link()); + + const inv = scanInstall(deps()); + expect(inv.service.nclSymlink).toBeUndefined(); + expect(inv.notes.some((n) => n.includes('points to another NanoClaw copy'))).toBe(true); + }); +}); + +describe('scanInstall OneCLI agents', () => { + const vault = JSON.stringify({ + data: [ + { id: 'u-1', identifier: 'ag-mine', name: 'Mine', isDefault: false }, + { id: 'u-2', identifier: 'ag-other', name: 'Other', isDefault: false }, + ], + }); + const onecliUp = fakeRun({ onecli: () => ({ status: 0, stdout: vault }) }); + + it('splits mine vs orphans against the central DB', () => { + fs.mkdirSync(path.join(root, 'data')); + const db = new Database(path.join(root, 'data', 'v2.db')); + db.exec('CREATE TABLE agent_groups (id TEXT PRIMARY KEY)'); + db.prepare('INSERT INTO agent_groups (id) VALUES (?)').run('ag-mine'); + db.close(); + + const inv = scanInstall(deps({ runCommand: onecliUp })); + expect(inv.onecli.idsKnown).toBe(true); + expect(inv.onecli.mine.map((a) => a.identifier)).toEqual(['ag-mine']); + expect(inv.onecli.orphans.map((a) => a.identifier)).toEqual(['ag-other']); + }); + + it('flags orphan labels as unreliable when the DB is unreadable', () => { + const inv = scanInstall(deps({ runCommand: onecliUp })); + expect(inv.onecli.idsKnown).toBe(false); + expect(inv.onecli.mine).toEqual([]); + expect(inv.onecli.orphans.map((a) => a.identifier)).toEqual(['ag-mine', 'ag-other']); + expect(inv.notes.some((n) => n.includes("Couldn't read agent_groups"))).toBe(true); + }); +}); + +describe('detectExistingInstall', () => { + it('is false for an empty checkout', () => { + expect(detectExistingInstall(root)).toBe(false); + }); + + it('is true when the central DB exists', () => { + fs.mkdirSync(path.join(root, 'data')); + const db = new Database(path.join(root, 'data', 'v2.db')); + db.close(); + expect(detectExistingInstall(root)).toBe(true); + }); +}); diff --git a/setup/uninstall/scan.ts b/setup/uninstall/scan.ts new file mode 100644 index 000000000..c6c96712a --- /dev/null +++ b/setup/uninstall/scan.ts @@ -0,0 +1,276 @@ +/** + * Uninstall inventory scan — find every artifact this checkout created. + * + * Everything NanoClaw creates is tagged with the per-checkout install slug + * (sha1(projectRoot)[:8]), so several copies can coexist on one machine. + * The scan reports ONLY things belonging to the given project root; shared + * tools (the OneCLI app/vault, shell PATH lines, host-wide config) are + * never inventoried. + * + * External commands (docker, onecli) go through the injected `runCommand` + * so tests can fake them; filesystem checks are real — tests use temp dirs. + * A missing/down docker daemon degrades to an empty result plus a note with + * manual cleanup commands; it never throws. + * + * Deliberately does NOT import src/config.ts (import-time side effects). + */ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { + getContainerImageBase, + getInstallSlug, + getLaunchdLabel, + getSystemdUnit, +} from '../../src/install-slug.js'; +import { + listVaultAgents, + readAgentGroupIds, + splitVaultAgents, + type RunCommand, + type VaultAgent, +} from './onecli-agents.js'; + +export interface PathItem { + /** Human label, e.g. "Database & conversations". */ + what: string; + /** Display location (tilde-abbreviated). */ + where: string; + /** Absolute path to remove. */ + path: string; +} + +export interface ServiceInventory { + launchdPlist?: string; + systemdUserUnit?: string; + systemdSystemUnit?: string; + pidFile?: string; + containerIds: string[]; + image?: string; + nclSymlink?: string; +} + +export interface OnecliInventory { + mine: VaultAgent[]; + orphans: VaultAgent[]; + /** False when agent_groups couldn't be read — orphan labels are then unreliable. */ + idsKnown: boolean; +} + +export interface Inventory { + slug: string; + projectRoot: string; + containerRuntime: string; + service: ServiceInventory; + /** Group 2: app data, logs & secrets. */ + data: PathItem[]; + /** + * dist/ + node_modules/ — displayed with the data group but removed dead + * last: the uninstaller itself runs on tsx out of node_modules. + */ + runtime: PathItem[]; + /** Group 3: groups/ and store/ — user content, unrecoverable. */ + user: PathItem[]; + onecli: OnecliInventory; + notes: string[]; +} + +export interface ScanDeps { + projectRoot: string; + home: string; + platform: NodeJS.Platform; + runCommand: RunCommand; +} + +export function tilde(p: string, home: string): string { + return p.startsWith(home) ? `~${p.slice(home.length)}` : p; +} + +export function scanInstall(deps: ScanDeps): Inventory { + const { projectRoot, home, runCommand } = deps; + const slug = getInstallSlug(projectRoot); + const containerRuntime = process.env.CONTAINER_RUNTIME ?? 'docker'; + const notes: string[] = []; + + const service = scanService(deps, slug, containerRuntime, notes); + + const data = existingItems(projectRoot, home, [ + { rel: 'data', what: 'Database & conversations' }, + { rel: 'logs', what: 'Logs' }, + { rel: '.env', what: 'Secrets / API keys (.env)', where: 'backed up before removal' }, + { rel: 'start-nanoclaw.sh', what: 'Start script', where: 'start-nanoclaw.sh' }, + { rel: 'nanoclaw.pid', what: 'PID file', where: 'nanoclaw.pid' }, + ]); + + const runtime = existingItems(projectRoot, home, [ + { rel: 'dist', what: 'Build output' }, + { rel: 'node_modules', what: 'Installed dependencies' }, + ]); + + const user = existingItems(projectRoot, home, [ + { rel: 'groups', what: 'Agent memory & files' }, + { rel: 'store', what: 'Migrated data store' }, + ]); + + const onecli = scanOnecli(projectRoot, runCommand, notes); + + return { + slug, + projectRoot, + containerRuntime, + service, + data, + runtime, + user, + onecli, + notes, + }; +} + +/** + * Cheap existing-install probe for mid-setup detection: service registration + * (per-platform) or a central DB. No docker or onecli calls. + */ +export function detectExistingInstall(projectRoot: string): boolean { + if (fs.existsSync(path.join(projectRoot, 'data', 'v2.db'))) return true; + const home = os.homedir(); + if (process.platform === 'darwin') { + return fs.existsSync( + path.join(home, 'Library', 'LaunchAgents', `${getLaunchdLabel(projectRoot)}.plist`), + ); + } + if (process.platform === 'linux') { + return fs.existsSync( + path.join(home, '.config', 'systemd', 'user', `${getSystemdUnit(projectRoot)}.service`), + ); + } + return false; +} + +function scanService( + deps: ScanDeps, + slug: string, + containerRuntime: string, + notes: string[], +): ServiceInventory { + const { projectRoot, home, platform, runCommand } = deps; + const service: ServiceInventory = { containerIds: [] }; + + if (platform === 'darwin') { + const plist = path.join( + home, + 'Library', + 'LaunchAgents', + `${getLaunchdLabel(projectRoot)}.plist`, + ); + if (fs.existsSync(plist)) service.launchdPlist = plist; + } else if (platform === 'linux') { + const unit = getSystemdUnit(projectRoot); + const userUnit = path.join(home, '.config', 'systemd', 'user', `${unit}.service`); + const systemUnit = `/etc/systemd/system/${unit}.service`; + if (fs.existsSync(userUnit)) service.systemdUserUnit = userUnit; + if (fs.existsSync(systemUnit)) service.systemdSystemUnit = systemUnit; + const pidFile = path.join(projectRoot, 'nanoclaw.pid'); + if (fs.existsSync(pidFile)) service.pidFile = pidFile; + } + + // Container label matches what container-runner.ts stamps at spawn time. + const installLabel = `nanoclaw-install=${slug}`; + const image = `${getContainerImageBase(projectRoot)}:latest`; + let runtimeOk = true; + try { + const ps = runCommand(containerRuntime, [ + 'ps', + '-aq', + '--filter', + `label=${installLabel}`, + ]); + if (ps.status === 0) { + service.containerIds = ps.stdout + .split('\n') + .map((s) => s.trim()) + .filter(Boolean); + } else { + runtimeOk = false; + } + } catch { + runtimeOk = false; + } + if (runtimeOk) { + try { + const inspect = runCommand(containerRuntime, ['image', 'inspect', image]); + if (inspect.status === 0) service.image = image; + } catch { + runtimeOk = false; + } + } + if (!runtimeOk) { + notes.push( + `Containers/image: '${containerRuntime}' unavailable; remove later with: ` + + `${containerRuntime} ps -aq --filter label=${installLabel} | xargs -r ${containerRuntime} rm -f; ` + + `${containerRuntime} rmi ${image}`, + ); + } + + const link = path.join(home, '.local', 'bin', 'ncl'); + let linkStat: fs.Stats | null = null; + try { + linkStat = fs.lstatSync(link); + } catch { + linkStat = null; + } + if (linkStat?.isSymbolicLink()) { + let target = fs.readlinkSync(link); + if (!path.isAbsolute(target)) { + target = path.resolve(path.dirname(link), target); + } + if (path.resolve(target) === path.join(projectRoot, 'bin', 'ncl')) { + service.nclSymlink = link; + } else { + notes.push( + `ncl command ${tilde(link, home)} points to another NanoClaw copy; left untouched.`, + ); + } + } + + return service; +} + +function scanOnecli( + projectRoot: string, + runCommand: RunCommand, + notes: string[], +): OnecliInventory { + const vault = listVaultAgents(runCommand); + if (!vault.available || vault.agents.length === 0) { + return { mine: [], orphans: [], idsKnown: false }; + } + + const { ids, known } = readAgentGroupIds(path.join(projectRoot, 'data', 'v2.db')); + const { mine, orphans } = splitVaultAgents(vault.agents, ids, known); + if (!known && orphans.length > 0) { + notes.push( + "Couldn't read agent_groups from data/v2.db; OneCLI agents shown as 'orphan' may actually belong to this copy.", + ); + } + return { mine, orphans, idsKnown: known }; +} + +function existingItems( + projectRoot: string, + home: string, + specs: { rel: string; what: string; where?: string }[], +): PathItem[] { + const items: PathItem[] = []; + for (const spec of specs) { + const p = path.join(projectRoot, spec.rel); + if (!fs.existsSync(p)) continue; + items.push({ + what: spec.what, + where: spec.where ?? `${tilde(p, home)}/`, + path: p, + }); + } + return items; +} diff --git a/uninstall.sh b/uninstall.sh index 369399ca9..bee97b493 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -1,543 +1,3 @@ #!/usr/bin/env bash -# -# uninstall.sh — Safely remove a NanoClaw installation from this computer. -# -# Everything NanoClaw creates is tagged with a per-checkout "install id" -# (sha1(PROJECT_ROOT)[:8]), so several copies can live on one machine. This -# script removes ONLY things belonging to THIS copy. Other copies and shared -# tools (the OneCLI app/vault, your shell PATH line, host-wide config) are -# left alone and listed at the end. -# -# It first checks what actually exists, then — for each group that has -# something to remove — shows a table of exactly what will be deleted and -# asks you to confirm. Groups with nothing are skipped. If nothing is found -# at all, it says so and exits. Nothing is removed until you type "y". -# -# bash uninstall.sh # interactive — confirm each group that has something -# bash uninstall.sh --dry-run # just show what would be deleted, change nothing -# bash uninstall.sh --yes # delete everything found without asking (full wipe) -# bash uninstall.sh --help -# -set -euo pipefail - -# --- resolve project root (the dir this script lives in) -------------------- -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)" -PROJECT_ROOT="$SCRIPT_DIR" -cd "$PROJECT_ROOT" - -# Slug helpers must hash the same root that setup used (it runs from the -# project root), so export it explicitly for the helper. -export NANOCLAW_PROJECT_ROOT="$PROJECT_ROOT" -# shellcheck source=setup/lib/install-slug.sh -source "$PROJECT_ROOT/setup/lib/install-slug.sh" - -SLUG="$(_nanoclaw_install_slug)" -LABEL="$(launchd_label)" # com.nanoclaw-v2- -UNIT="$(systemd_unit)" # nanoclaw-v2- -IMAGE_BASE="$(container_image_base)" # nanoclaw-agent-v2- -IMAGE="${IMAGE_BASE}:latest" -INSTALL_LABEL="nanoclaw-install=${SLUG}" -CONTAINER_RUNTIME="${CONTAINER_RUNTIME:-docker}" - -HOME_DIR="${HOME:-$(echo ~)}" -OS="$(uname -s)" - -# --- flags ------------------------------------------------------------------ -DRY_RUN=0 -ASSUME_YES=0 - -usage() { sed -n '3,19p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0; } - -for arg in "$@"; do - case "$arg" in - -n|--dry-run) DRY_RUN=1 ;; - -y|--yes) ASSUME_YES=1 ;; - -h|--help) usage ;; - *) echo "Unknown flag: $arg (try --help)" >&2; exit 2 ;; - esac -done - -# --- colors ----------------------------------------------------------------- -c_bold=$'\033[1m'; c_dim=$'\033[2m'; c_red=$'\033[31m'; c_grn=$'\033[32m' -c_yel=$'\033[33m'; c_cyn=$'\033[36m'; c_rst=$'\033[0m' -if [ ! -t 1 ]; then c_bold=; c_dim=; c_red=; c_grn=; c_yel=; c_cyn=; c_rst=; fi - -have_cmd() { command -v "$1" >/dev/null 2>&1; } -tilde() { case "$1" in "$HOME_DIR"*) printf '~%s' "${1#"$HOME_DIR"}";; *) printf '%s' "$1";; esac; } - -# --- table buffer ----------------------------------------------------------- -# A scan_* fn fills ROWS with the items it FOUND (only found items — absent -# things are not listed, since we already skip empty groups). FOUND is the -# count for the current group. -ROWS=() -FOUND=0 -reset_rows() { ROWS=(); FOUND=0; } -row() { ROWS+=("$1"$'\t'"$2"); FOUND=$((FOUND + 1)); return 0; } # what, where -group_head() { - printf '\n%s%s%s\n' "$c_bold" "$1" "$c_rst" - printf '%s%s%s\n' "$c_dim" "$2" "$c_rst" -} - -confirm() { - [ "$ASSUME_YES" = "1" ] && return 0 - printf '\n %s%s%s [y/N] ' "$c_yel" "$1" "$c_rst" - local ans=""; read -r ans /dev/null || ans="" - case "$ans" in y|Y|yes|YES) return 0 ;; *) return 1 ;; esac -} - -SKIPPED_NOTES=() -note_skip() { SKIPPED_NOTES+=("$1"); } - -list_containers() { - have_cmd "$CONTAINER_RUNTIME" || { echo ""; return; } - "$CONTAINER_RUNTIME" ps -aq --filter "label=${INSTALL_LABEL}" 2>/dev/null || echo "" -} - -# =========================================================================== -# Scanners — fill ROWS with only the items that EXIST for this copy. -# =========================================================================== -scan_service() { - reset_rows - case "$OS" in - Darwin) - local plist="$HOME_DIR/Library/LaunchAgents/${LABEL}.plist" - [ -f "$plist" ] && row "Background service" "$(tilde "$plist")" - ;; - Linux) - local uu="$HOME_DIR/.config/systemd/user/${UNIT}.service" - local us="/etc/systemd/system/${UNIT}.service" - [ -f "$uu" ] && row "Background service" "$(tilde "$uu")" - [ -f "$us" ] && row "Background service (system)" "$us" - [ -f "$PROJECT_ROOT/nanoclaw.pid" ] && row "Running process" "nanoclaw.pid" - ;; - esac - local cids; cids="$(list_containers)" - [ -n "$cids" ] && row "Running containers" "$(echo "$cids" | wc -l | tr -d ' ') container(s)" - if have_cmd "$CONTAINER_RUNTIME" && "$CONTAINER_RUNTIME" image inspect "$IMAGE" >/dev/null 2>&1; then - row "Docker image" "$IMAGE" - fi - local link="$HOME_DIR/.local/bin/ncl" - [ -L "$link" ] && row "Command-line tool (ncl)" "$(tilde "$link")" - return 0 -} - -scan_data() { - reset_rows - [ -e "$PROJECT_ROOT/data" ] && row "Database & conversations" "$(tilde "$PROJECT_ROOT/data")/" - [ -e "$PROJECT_ROOT/logs" ] && row "Logs" "$(tilde "$PROJECT_ROOT/logs")/" - [ -e "$PROJECT_ROOT/dist" ] && row "Build output" "$(tilde "$PROJECT_ROOT/dist")/" - [ -e "$PROJECT_ROOT/node_modules" ] && row "Installed dependencies" "$(tilde "$PROJECT_ROOT/node_modules")/" - [ -e "$PROJECT_ROOT/.env" ] && row "Secrets / API keys (.env)" "backed up before removal" - [ -e "$PROJECT_ROOT/start-nanoclaw.sh" ] && row "Start script" "start-nanoclaw.sh" - return 0 -} - -scan_user() { - reset_rows - [ -e "$PROJECT_ROOT/groups" ] && row "Agent memory & files" "$(tilde "$PROJECT_ROOT/groups")/" - [ -e "$PROJECT_ROOT/store" ] && row "Migrated data store" "$(tilde "$PROJECT_ROOT/store")/" - return 0 -} - -# OneCLI agents fall into two sets, computed once by scan_onecli and reused by -# the dry-run preview, the group-4 decision, and do_onecli. Each entry is -# "\t\t" — deletion is BY UUID (the identifier, -# i.e. the agent-group id, is NOT a valid --id; see container-runner.ts). -ONECLI_MINE=() # vault agents whose identifier IS in this copy's data/v2.db -ONECLI_ORPHANS=() # ag-* vault agents NOT in our DB (maybe another copy's) -ONECLI_DELETE=() # resolved set to actually delete (filled by decide_onecli) - -scan_onecli() { - reset_rows - ONECLI_MINE=() - ONECLI_ORPHANS=() - - have_cmd onecli || return 0 - - # Build the vault map once: identifieruuidname for non-default agents. - local vault="" - if have_cmd jq; then - vault="$(onecli agents list 2>/dev/null \ - | jq -r '.data[] | select(.isDefault|not) | select(.identifier != "default") | "\(.identifier)\t\(.id)\t\(.name)"' 2>/dev/null)" || vault="" - elif have_cmd python3; then - vault="$(onecli agents list 2>/dev/null | python3 -c ' -import json, sys -try: - d = json.load(sys.stdin) -except Exception: - sys.exit(0) -for a in d.get("data", []): - if a.get("isDefault"): - continue - ident = a.get("identifier", "") - if ident == "default": - continue - print("\t".join([ident, a.get("id", ""), a.get("name", "")])) -' 2>/dev/null)" || vault="" - else - note_skip "OneCLI agents: need 'jq' or 'python3' to read the vault; list/remove manually with 'onecli agents list' / 'onecli agents delete --id '." - return 0 - fi - - [ -z "$vault" ] && return 0 - - # Our agent-group ids from the local DB (present during a normal uninstall, - # since OneCLI cleanup runs before do_data wipes data/). Newline-delimited so - # we can do a membership test without bash-4 associative arrays. - # - # Prefer the in-tree query wrapper (goes through better-sqlite3, which setup - # always installs) over the sqlite3 CLI (which setup deliberately avoids - # depending on — see setup/verify.ts). ids_known distinguishes "this copy has - # zero agent groups" from "we couldn't read the DB at all"; without it, a - # missing sqlite3 would mislabel every ag-* agent as an orphan and --yes would - # silently leave this copy's agents behind. - local our_ids="" ids_known=0 - if [ -f "$PROJECT_ROOT/data/v2.db" ]; then - if have_cmd pnpm && [ -f "$PROJECT_ROOT/scripts/q.ts" ]; then - if our_ids="$(pnpm exec tsx scripts/q.ts data/v2.db "SELECT id FROM agent_groups;" 2>/dev/null)"; then - ids_known=1 - else - our_ids="" - fi - fi - if [ "$ids_known" = "0" ] && have_cmd sqlite3; then - if our_ids="$(sqlite3 "$PROJECT_ROOT/data/v2.db" "SELECT id FROM agent_groups;" 2>/dev/null)"; then - ids_known=1 - else - our_ids="" - fi - fi - fi - - local saw_orphan=0 - local identifier uuid name - while IFS=$'\t' read -r identifier uuid name; do - [ -z "$identifier" ] && continue - [ "$identifier" = "default" ] && continue - case $'\n'"$our_ids"$'\n' in - *$'\n'"$identifier"$'\n'*) - ONECLI_MINE+=("$uuid"$'\t'"$identifier"$'\t'"$name") - row "OneCLI agent" "$name — $identifier" - ;; - *) - # Not ours. Only treat NanoClaw-style (ag-*) ids as orphans we surface. - case "$identifier" in - ag-*) - saw_orphan=1 - ONECLI_ORPHANS+=("$uuid"$'\t'"$identifier"$'\t'"$name") - row "OneCLI agent (orphan)" "$name — $identifier" - ;; - esac - ;; - esac - done <<<"$vault" - - # If we couldn't read agent_groups, every ag-* agent was forced into the - # orphan bucket — warn so the user isn't misled and --yes leaving them behind - # is explained. - if [ "$ids_known" = "0" ] && [ "$saw_orphan" = "1" ]; then - note_skip "Couldn't read agent_groups (need pnpm/tsx or sqlite3); OneCLI agents shown as 'orphan' may actually belong to this copy." - fi - - return 0 -} - -# =========================================================================== -# Removers -# =========================================================================== -do_service() { - printf '\n %sRemoving app & background service...%s\n' "$c_dim" "$c_rst" - case "$OS" in - Darwin) - local plist="$HOME_DIR/Library/LaunchAgents/${LABEL}.plist" - if [ -f "$plist" ]; then - launchctl unload "$plist" >/dev/null 2>&1 || true - rm -f "$plist" && printf ' %s✓%s background service removed\n' "$c_grn" "$c_rst" - fi - ;; - Linux) - local uu="$HOME_DIR/.config/systemd/user/${UNIT}.service" - local us="/etc/systemd/system/${UNIT}.service" - if [ -f "$uu" ]; then - systemctl --user disable --now "${UNIT}.service" >/dev/null 2>&1 || true - rm -f "$uu"; systemctl --user daemon-reload >/dev/null 2>&1 || true - printf ' %s✓%s background service removed\n' "$c_grn" "$c_rst" - fi - if [ -f "$us" ]; then - if [ "$(id -u)" = "0" ]; then - systemctl disable --now "${UNIT}.service" >/dev/null 2>&1 || true - rm -f "$us"; systemctl daemon-reload >/dev/null 2>&1 || true - printf ' %s✓%s system service removed\n' "$c_grn" "$c_rst" - else - printf ' %s!%s system service needs root — left in place\n' "$c_yel" "$c_rst" - note_skip "System service $us — re-run with sudo to remove." - fi - fi - if [ -f "$PROJECT_ROOT/nanoclaw.pid" ]; then - local oldpid; oldpid="$(cat "$PROJECT_ROOT/nanoclaw.pid" 2>/dev/null || echo "")" - [ -n "$oldpid" ] && kill -0 "$oldpid" 2>/dev/null && kill "$oldpid" 2>/dev/null || true - fi - ;; - esac - have_cmd pkill && pkill -f "${PROJECT_ROOT}/dist/index.js" 2>/dev/null && \ - printf ' %s✓%s stopped leftover host process\n' "$c_grn" "$c_rst" || true - if have_cmd "$CONTAINER_RUNTIME"; then - local cids; cids="$(list_containers)" - if [ -n "$cids" ]; then - # shellcheck disable=SC2086 - "$CONTAINER_RUNTIME" rm -f $cids >/dev/null 2>&1 || true - printf ' %s✓%s removed %s container(s)\n' "$c_grn" "$c_rst" "$(echo "$cids" | wc -l | tr -d ' ')" - fi - if "$CONTAINER_RUNTIME" image inspect "$IMAGE" >/dev/null 2>&1; then - "$CONTAINER_RUNTIME" rmi "$IMAGE" >/dev/null 2>&1 \ - && printf ' %s✓%s removed Docker image\n' "$c_grn" "$c_rst" \ - || printf ' %s!%s could not remove image (in use?)\n' "$c_yel" "$c_rst" - fi - else - note_skip "Containers/image: '$CONTAINER_RUNTIME' not found; remove later with: $CONTAINER_RUNTIME ps -aq --filter label=${INSTALL_LABEL} | xargs -r $CONTAINER_RUNTIME rm -f; $CONTAINER_RUNTIME rmi $IMAGE" - fi - local link="$HOME_DIR/.local/bin/ncl" - if [ -L "$link" ]; then - local target abs - target="$(readlink "$link")" - case "$target" in - /*) abs="$target" ;; - *) abs="$(cd "$(dirname "$link")" && cd "$(dirname "$target")" 2>/dev/null && pwd)/$(basename "$target")" ;; - esac - if [ "$abs" = "$PROJECT_ROOT/bin/ncl" ]; then - rm -f "$link" && printf ' %s✓%s removed ncl command\n' "$c_grn" "$c_rst" - else - printf ' %s!%s ncl points to another copy — left in place\n' "$c_yel" "$c_rst" - note_skip "ncl command $link points to another NanoClaw copy; left untouched." - fi - fi -} - -# Decide which OneCLI agents to delete. MINE is a single yes/no; ORPHANS get a -# separate, default-No prompt with an explicit cross-copy warning. Under --yes -# we delete MINE but never ORPHANS (orphans require explicit human intent). -# Anything left behind is reported with the exact manual command (delete by uuid). -decide_onecli() { - ONECLI_DELETE=() - local entry uuid identifier name - - if [ "${#ONECLI_MINE[@]}" -gt 0 ]; then - if [ "$ASSUME_YES" = "1" ] || confirm "Delete this copy's ${#ONECLI_MINE[@]} OneCLI agent(s)?"; then - for entry in "${ONECLI_MINE[@]}"; do ONECLI_DELETE+=("$entry"); done - else - note_skip "OneCLI agents (this copy): kept by your choice." - fi - fi - - if [ "${#ONECLI_ORPHANS[@]}" -gt 0 ]; then - local keep_orphans=1 - if [ "$ASSUME_YES" = "1" ]; then - printf '\n %s%d other NanoClaw-style agent(s) in the vault are not linked to this copy;\n --yes does NOT delete them (they may belong to another copy).%s\n' \ - "$c_yel" "${#ONECLI_ORPHANS[@]}" "$c_rst" - else - printf '\n %sFound %d other NanoClaw-style agent(s) in the vault not linked to this copy —\n they may belong to ANOTHER NanoClaw copy on this machine.%s\n' \ - "$c_yel" "${#ONECLI_ORPHANS[@]}" "$c_rst" - if confirm "Delete them too?"; then - keep_orphans=0 - for entry in "${ONECLI_ORPHANS[@]}"; do ONECLI_DELETE+=("$entry"); done - fi - fi - if [ "$keep_orphans" = "1" ]; then - note_skip "OneCLI orphan agents (${#ONECLI_ORPHANS[@]}): left in place — remove manually if they're yours:" - for entry in "${ONECLI_ORPHANS[@]}"; do - IFS=$'\t' read -r uuid identifier name <<<"$entry" - note_skip " onecli agents delete --id $uuid # $name — $identifier" - done - fi - fi - - [ "${#ONECLI_DELETE[@]}" -gt 0 ] && DO[3]=1 - return 0 -} - -do_onecli() { - printf '\n %sRemoving OneCLI agents...%s\n' "$c_dim" "$c_rst" - if ! have_cmd onecli; then - note_skip "OneCLI agents: 'onecli' not on PATH; remove via 'onecli agents list' / 'onecli agents delete --id '." - return 0 - fi - [ "${#ONECLI_DELETE[@]}" -gt 0 ] || return 0 - local entry uuid identifier name - for entry in "${ONECLI_DELETE[@]}"; do - IFS=$'\t' read -r uuid identifier name <<<"$entry" - [ -z "$uuid" ] && continue - if onecli agents delete --id "$uuid" >/dev/null 2>&1; then - printf ' %s✓%s deleted %s (%s)\n' "$c_grn" "$c_rst" "$name" "$identifier" - else - printf ' %s!%s %s already gone\n' "$c_yel" "$c_rst" "$identifier" - fi - done -} - -do_data() { - printf '\n %sRemoving app data, logs & secrets...%s\n' "$c_dim" "$c_rst" - if [ -f "$PROJECT_ROOT/.env" ]; then - # Don't clobber an existing backup — fall back to a timestamped name. - local bak="$PROJECT_ROOT/.env.bak" - [ -e "$bak" ] && bak="$PROJECT_ROOT/.env.bak.$(date +%Y%m%d-%H%M%S)" - cp -p "$PROJECT_ROOT/.env" "$bak" - rm -f "$PROJECT_ROOT/.env" - printf ' %s✓%s removed .env (backup at %s)\n' "$c_grn" "$c_rst" "$(tilde "$bak")" - fi - local p - for p in data logs dist node_modules start-nanoclaw.sh nanoclaw.pid; do - [ -e "$PROJECT_ROOT/$p" ] && rm -rf "${PROJECT_ROOT:?}/$p" && printf ' %s✓%s removed %s\n' "$c_grn" "$c_rst" "$p" || true - done -} - -do_user() { - printf '\n %sRemoving agent memory & files...%s\n' "$c_dim" "$c_rst" - local p - for p in groups store; do - [ -e "$PROJECT_ROOT/$p" ] && rm -rf "${PROJECT_ROOT:?}/$p" && printf ' %s✓%s removed %s\n' "$c_grn" "$c_rst" "$p" || true - done -} - -# =========================================================================== -# Main -# =========================================================================== -printf '\n%sUninstall NanoClaw%s (copy id: %s)\n' "$c_bold" "$c_rst" "$SLUG" -printf '%sFolder: %s%s\n' "$c_dim" "$PROJECT_ROOT" "$c_rst" -printf '%sChecking what exists for this copy...%s\n' "$c_dim" "$c_rst" - -# Group metadata: title | description | scan fn | remove fn | confirm prompt -G_TITLE=( - "1) App & background service" - "2) App data, logs & secrets" - "3) Your agents' memory & files" - "4) OneCLI credential agents" -) -G_DESC=( - "Runs NanoClaw in the background. Removing this stops the assistant. None of your data lives here." - "Message database, conversation history, logs, build files, and your .env (API keys / tokens). Removing this erases stored conversations and saved credentials." - "Notes and memory your agents created (groups/) and any migrated data (store/). Content you made — it cannot be recovered after deletion." - "Per-agent entries this copy registered in the OneCLI vault. The OneCLI app, your credentials, and the gateway are NOT touched." -) -G_SCAN=(scan_service scan_data scan_user scan_onecli) -G_DO=(do_service do_data do_user do_onecli) -G_PROMPT=( - "Delete the app & background service shown above?" - "Delete app data, logs & secrets shown above? (erases conversations + API keys)" - "Delete your agents' memory & files shown above? (cannot be undone)" - "Delete this copy's OneCLI agents shown above?" -) -# Per-group buffers, captured during the scan pass. -G_ROWS=() # newline-joined rows per group (tab-separated within a row) -G_FOUND=() # count per group - -TOTAL_FOUND=0 -EMPTY_LIST="" -for i in 0 1 2 3; do - "${G_SCAN[$i]}" - G_FOUND[$i]=$FOUND - # serialize ROWS (may be empty) - if [ "$FOUND" -gt 0 ]; then - G_ROWS[$i]="$(printf '%s\n' "${ROWS[@]}")" - TOTAL_FOUND=$((TOTAL_FOUND + FOUND)) - else - G_ROWS[$i]="" - EMPTY_LIST="${EMPTY_LIST:+$EMPTY_LIST, }${G_TITLE[$i]}" - fi -done - -# Nothing at all → already clean. -if [ "$TOTAL_FOUND" -eq 0 ]; then - printf '\n%s✓ Nothing to uninstall — this copy (%s) is already clean.%s\n' "$c_grn" "$SLUG" "$c_rst" - printf '%s (No service, containers, image, data, or OneCLI agents found for this folder.)%s\n' "$c_dim" "$c_rst" - exit 0 -fi - -# Helper to print a group's buffered table. -print_buffered() { # index - local i="$1" - group_head "${G_TITLE[$i]}" "${G_DESC[$i]}" - printf ' %s%-26s %s%s\n' "$c_dim" "WHAT" "WHERE" "$c_rst" - local what where line - while IFS= read -r line; do - [ -z "$line" ] && continue - IFS=$'\t' read -r what where <<<"$line" - printf ' %s●%s %-26s %s\n' "$c_red" "$c_rst" "$what" "$where" - done <<<"${G_ROWS[$i]}" -} - -# --- dry run: show only groups that have something, then exit --------------- -if [ "$DRY_RUN" = "1" ]; then - printf '\n%sPREVIEW ONLY — this shows what would be deleted and changes nothing.%s\n' "$c_cyn" "$c_rst" - for i in 0 1 2 3; do - [ "${G_FOUND[$i]}" -gt 0 ] || continue - # Group 3 (OneCLI) mixes MINE and orphan rows; print_buffered would show - # orphans inside the same "would be deleted" table, contradicting the note - # that orphans are never auto-deleted. Render the two subsets separately to - # match the interactive/--yes path (decide_onecli). - if [ "$i" = "3" ]; then - group_head "${G_TITLE[3]}" "${G_DESC[3]}" - printf ' %sWould be deleted (after confirmation):%s\n' "$c_dim" "$c_rst" - for entry in "${ONECLI_MINE[@]:-}"; do - [ -n "$entry" ] || continue - IFS=$'\t' read -r uuid identifier name <<<"$entry" - printf ' %s●%s %s — %s\n' "$c_red" "$c_rst" "$name" "$identifier" - done - printf ' %sLeft in place — may belong to another copy:%s\n' "$c_dim" "$c_rst" - for entry in "${ONECLI_ORPHANS[@]:-}"; do - [ -n "$entry" ] || continue - IFS=$'\t' read -r uuid identifier name <<<"$entry" - printf ' %s○%s %s — %s\n' "$c_yel" "$c_rst" "$name" "$identifier" - done - else - print_buffered "$i" - fi - done - if [ -n "$EMPTY_LIST" ]; then - printf '\n%sNothing found for: %s%s\n' "$c_dim" "$EMPTY_LIST" "$c_rst" - fi - # Surface scan-time notes (e.g. the M3 "couldn't read agent_groups" warning) - # here too — dry-run exits before the closing summary that normally prints - # them, and the whole point is to warn the user before they decide. - for n in "${SKIPPED_NOTES[@]:-}"; do [ -n "$n" ] && printf '%s • %s%s\n' "$c_dim" "$n" "$c_rst"; done - printf '\n%sPreview complete. Nothing was changed.%s\n' "$c_cyn" "$c_rst" - exit 0 -fi - -if [ "$ASSUME_YES" = "1" ]; then - printf '\n%s--yes given: deleting everything found below without asking.%s\n' "$c_yel" "$c_rst" -else - printf '\n%sYou will be asked about each group that has something. Default is to keep\n(just press Enter). Type "y" to delete a group.%s\n' "$c_dim" "$c_rst" -fi - -# --- interactive / --yes: only groups with something ------------------------ -DO=(0 0 0 0) -for i in 0 1 2 3; do - [ "${G_FOUND[$i]}" -gt 0 ] || continue - print_buffered "$i" - # Group 4 (OneCLI) has two sub-decisions (this copy's agents vs. orphans) that - # the single-prompt loop can't express, so it's special-cased. - if [ "$i" = "3" ]; then - decide_onecli - elif confirm "${G_PROMPT[$i]}"; then - DO[$i]=1 - else - note_skip "${G_TITLE[$i]}: kept by your choice." - fi -done - -# Execute. OneCLI deletion (index 3) must run BEFORE data (index 1), which -# removes data/v2.db that the OneCLI step reads. -if [ "${DO[0]}" = "1" ]; then do_service; fi -if [ "${DO[3]}" = "1" ]; then do_onecli; fi -if [ "${DO[1]}" = "1" ]; then do_data; fi -if [ "${DO[2]}" = "1" ]; then do_user; fi - -# --- closing summary -------------------------------------------------------- -printf '\n%s── Left alone (shared / not ours) ──%s\n' "$c_bold" "$c_rst" -printf '%s • OneCLI app, vault & credentials: ~/.local/share/onecli, ~/.local/bin/onecli\n' "$c_dim" -printf ' • Host-wide config: ~/.config/nanoclaw/ (mount/sender allowlists)\n' -printf ' • PATH line in ~/.bashrc and ~/.zshrc\n' -printf ' • Other NanoClaw copies on this machine%s\n' "$c_rst" -for n in "${SKIPPED_NOTES[@]:-}"; do [ -n "$n" ] && printf '%s • %s%s\n' "$c_dim" "$n" "$c_rst"; done - -printf '\n%s✓ Done. NanoClaw copy %s has been uninstalled.%s\n' "$c_grn" "$SLUG" "$c_rst" +# The uninstaller lives in the setup driver now (setup/uninstall/). +exec bash "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/nanoclaw.sh" --uninstall "$@" From d8748e3a45223bda3d5d1a035e7ed48881a52541 Mon Sep 17 00:00:00 2001 From: Amit Shafnir Date: Wed, 10 Jun 2026 14:10:47 +0300 Subject: [PATCH 3/3] fix: address uninstaller review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .env backup and removal are now one atomic action: a failed backup throws into executePlan's catch and the deletion never runs (the bash original's set -e gave the same guarantee; the port had lost it) - containers are re-listed by install label at removal time instead of removed from scan-time ids — the live host can spawn containers during the confirm phase - uninstall telemetry no longer creates data/install-id (persistId:false on emit), so --dry-run truly changes nothing and the already-clean exit can fire - runtime-tail failure notes are printed before the Done line instead of being discarded - uninstall.sh translates the old short flags (-n/-y) instead of silently dropping them (-n used to fall through to a real interactive uninstall) - nanoclaw.sh gates the TS uninstaller on node (tsx's interpreter), not pnpm, which the direct-exec path never uses - detectExistingInstall also checks the system-level systemd unit - a delete-onecli-agent spawn failure now notes the manual command instead of claiming the agent was already gone - setupLog.userInput is skipped when logs/ is absent so the uninstall doesn't recreate it Co-Authored-By: Claude Fable 5 --- nanoclaw.sh | 3 +- setup/lib/diagnostics.ts | 23 ++++++++---- setup/uninstall/flow.ts | 56 ++++++++++++++++++----------- setup/uninstall/plan.test.ts | 27 +++++++------- setup/uninstall/plan.ts | 31 +++++++++++----- setup/uninstall/remove.test.ts | 65 ++++++++++++++++++++++++++++++++++ setup/uninstall/remove.ts | 39 +++++++++++++++++--- setup/uninstall/scan.ts | 6 ++-- uninstall.sh | 11 +++++- 9 files changed, 205 insertions(+), 56 deletions(-) diff --git a/nanoclaw.sh b/nanoclaw.sh index 718773f5e..6a48fad71 100755 --- a/nanoclaw.sh +++ b/nanoclaw.sh @@ -36,7 +36,8 @@ for arg in "$@"; do # exec tsx directly rather than `pnpm run -- …`: pnpm passes the `--` # separator through to the script, where the flag parser treats # everything after it as positional args and the flags get dropped. - if command -v pnpm >/dev/null 2>&1 && [ -x "$PROJECT_ROOT/node_modules/.bin/tsx" ]; then + # Gate on node (tsx's shebang interpreter) — pnpm isn't used here. + if command -v node >/dev/null 2>&1 && [ -x "$PROJECT_ROOT/node_modules/.bin/tsx" ]; then exec "$PROJECT_ROOT/node_modules/.bin/tsx" "$PROJECT_ROOT/setup/auto.ts" "$@" fi export NANOCLAW_PROJECT_ROOT="$PROJECT_ROOT" diff --git a/setup/lib/diagnostics.ts b/setup/lib/diagnostics.ts index 30605a785..af77199e0 100644 --- a/setup/lib/diagnostics.ts +++ b/setup/lib/diagnostics.ts @@ -16,7 +16,13 @@ const INSTALL_ID_PATH = path.join('data', 'install-id'); let cached: string | null = null; -export function installId(): string { +/** + * `persist: false` reads an existing id but never creates `data/install-id` + * — required by the uninstall path, which must not mutate the filesystem + * before (or instead of) removing it. Events in one process still join: + * the generated id is cached. + */ +export function installId(persist = true): string { if (cached) return cached; try { const existing = fs.readFileSync(INSTALL_ID_PATH, 'utf-8').trim(); @@ -28,11 +34,13 @@ export function installId(): string { // fall through to create } const id = randomUUID().toLowerCase(); - try { - fs.mkdirSync(path.dirname(INSTALL_ID_PATH), { recursive: true }); - fs.writeFileSync(INSTALL_ID_PATH, id); - } catch { - // best-effort; still return the id so the event fires + if (persist) { + try { + fs.mkdirSync(path.dirname(INSTALL_ID_PATH), { recursive: true }); + fs.writeFileSync(INSTALL_ID_PATH, id); + } catch { + // best-effort; still return the id so the event fires + } } cached = id; return id; @@ -41,6 +49,7 @@ export function installId(): string { export function emit( event: string, props: Record = {}, + opts: { persistId?: boolean } = {}, ): void { if (process.env.NANOCLAW_NO_DIAGNOSTICS === '1') return; @@ -53,7 +62,7 @@ export function emit( const body = JSON.stringify({ api_key: POSTHOG_KEY, event, - distinct_id: installId(), + distinct_id: installId(opts.persistId !== false), properties: cleaned, }); diff --git a/setup/uninstall/flow.ts b/setup/uninstall/flow.ts index 2fe226966..c0925f8cb 100644 --- a/setup/uninstall/flow.ts +++ b/setup/uninstall/flow.ts @@ -13,7 +13,9 @@ * Ctrl-C anywhere in the confirm phase leaves the install untouched. */ import { spawnSync } from 'child_process'; +import fs from 'fs'; import os from 'os'; +import path from 'path'; import * as p from '@clack/prompts'; import k from 'kleur'; @@ -75,7 +77,10 @@ export async function runUninstallFlow(opts: { const home = os.homedir(); p.intro(k.bold(`Uninstall NanoClaw`)); - phEmit('uninstall_started', { invokedFrom: opts.invokedFrom, dryRun, yes }); + // persistId: false — the emit must not create data/install-id, which would + // both break --dry-run's "changes nothing" promise and resurrect a data/ + // row in the very inventory we are about to scan. + phEmit('uninstall_started', { invokedFrom: opts.invokedFrom, dryRun, yes }, { persistId: false }); const spinner = p.spinner(); spinner.start('Checking what exists for this copy…'); @@ -166,17 +171,20 @@ export async function runUninstallFlow(opts: { const onecliDelete = await decideOnecli(inv, yes, keptNotes); - // Last point where logs/ is guaranteed to still exist — record the - // decisions before execution can delete it. - setupLog.userInput( - 'uninstall_decisions', - JSON.stringify({ - service: serviceYes, - data: dataYes, - user: userYes, - onecliAgentsDeleted: onecliDelete.length, - }), - ); + // Record the decisions before execution can delete logs/ — but only into + // an existing logs/ (userInput would otherwise mkdir it back into + // existence, leaving a fresh logs/setup.log behind after the uninstall). + if (fs.existsSync(path.join(projectRoot, 'logs'))) { + setupLog.userInput( + 'uninstall_decisions', + JSON.stringify({ + service: serviceYes, + data: dataYes, + user: userYes, + onecliAgentsDeleted: onecliDelete.length, + }), + ); + } const decisions: Decisions = { service: serviceYes, @@ -192,13 +200,17 @@ export async function runUninstallFlow(opts: { process.exit(0); } - phEmit('uninstall_executed', { - invokedFrom: opts.invokedFrom, - service: serviceYes, - data: dataYes, - user: userYes, - onecliAgentsDeleted: onecliDelete.length, - }); + phEmit( + 'uninstall_executed', + { + invokedFrom: opts.invokedFrom, + service: serviceYes, + data: dataYes, + user: userYes, + onecliAgentsDeleted: onecliDelete.length, + }, + { persistId: false }, + ); // The runtime tail (dist/, node_modules/) runs after every other action // AND after the summary — nothing but console.log may happen once the @@ -215,7 +227,11 @@ export async function runUninstallFlow(opts: { printLeftAlone([...inv.notes, ...keptNotes, ...execNotes]); - executePlan(tail, { ...deps, log: (line) => console.log(` ${line}`) }); + const { notes: tailNotes } = executePlan(tail, { + ...deps, + log: (line) => console.log(` ${line}`), + }); + for (const n of tailNotes) console.log(` • ${n}`); console.log(`\n✓ Done. NanoClaw copy ${inv.slug} has been uninstalled.`); process.exit(0); } diff --git a/setup/uninstall/plan.test.ts b/setup/uninstall/plan.test.ts index 01fb689ce..fd99e27c8 100644 --- a/setup/uninstall/plan.test.ts +++ b/setup/uninstall/plan.test.ts @@ -51,14 +51,12 @@ const allYes = (onecliDelete: VaultAgent[] = []): Decisions => ({ const kinds = (actions: RemovalAction[]) => actions.map((a) => a.kind); describe('buildRemovalPlan ordering invariants', () => { - it('backs up .env strictly before deleting it', () => { + it('removes .env only via the atomic backup action, never a bare delete', () => { const actions = buildRemovalPlan(inventory(), allYes()); - const backupIdx = actions.findIndex((a) => a.kind === 'backup-env'); - const envDeleteIdx = actions.findIndex( - (a) => a.kind === 'delete-path' && a.item.path === '/proj/.env', - ); - expect(backupIdx).toBeGreaterThanOrEqual(0); - expect(envDeleteIdx).toBeGreaterThan(backupIdx); + expect(actions.filter((a) => a.kind === 'backup-env')).toHaveLength(1); + expect( + actions.some((a) => a.kind === 'delete-path' && a.item.path === '/proj/.env'), + ).toBe(false); }); it('puts the runtime tail strictly last, with node_modules final', () => { @@ -140,14 +138,19 @@ describe('buildRemovalPlan conditional actions', () => { expect(kinds(buildRemovalPlan(inv, allYes()))).not.toContain('backup-env'); }); - it('skips container/image actions when nothing was found', () => { + it('always re-sweeps containers and processes with a confirmed service group', () => { const inv = inventory({ service: { containerIds: [] } }); - const actionKinds = kinds(buildRemovalPlan(inv, allYes())); - expect(actionKinds).not.toContain('rm-containers'); + const actions = buildRemovalPlan(inv, allYes()); + const actionKinds = kinds(actions); expect(actionKinds).not.toContain('rmi'); expect(actionKinds).not.toContain('unload-service'); - // pkill always runs with a confirmed service group — a manually started - // host has no plist/unit but must still be stopped. + // pkill and rm-containers run unconditionally — a manually started host + // has no plist/unit, and the live host may have spawned containers the + // scan never saw. Removal re-lists by install label, not scan-time ids. expect(actionKinds).toContain('pkill-host'); + const rm = actions.find((a) => a.kind === 'rm-containers'); + expect(rm && rm.kind === 'rm-containers' ? rm.labelFilter : '').toBe( + 'nanoclaw-install=abcd1234', + ); }); }); diff --git a/setup/uninstall/plan.ts b/setup/uninstall/plan.ts index f49e12849..8eab71d8f 100644 --- a/setup/uninstall/plan.ts +++ b/setup/uninstall/plan.ts @@ -33,10 +33,20 @@ export type RemovalAction = } | { kind: 'kill-pid'; pidFile: string } | { kind: 'pkill-host'; pattern: string } - | { kind: 'rm-containers'; runtime: string; containerIds: string[] } + /** + * Containers are re-listed by label at removal time, not removed from + * scan-time ids — the host stays alive through the whole confirm phase + * and can spawn new containers after the scan. + */ + | { kind: 'rm-containers'; runtime: string; labelFilter: string } | { kind: 'rmi'; runtime: string; image: string } | { kind: 'rm-ncl-symlink'; linkPath: string } | { kind: 'delete-onecli-agent'; agent: VaultAgent } + /** + * Backs up AND removes .env as one atomic action: a failed backup must + * never be followed by the deletion (the backup is the user's only copy + * of their API keys). .env is deliberately excluded from `delete-path`. + */ | { kind: 'backup-env'; envPath: string } | { kind: 'delete-path'; item: PathItem } | { kind: 'delete-runtime-path'; item: PathItem }; @@ -75,13 +85,13 @@ export function buildRemovalPlan(inv: Inventory, d: Decisions): RemovalAction[] kind: 'pkill-host', pattern: `${inv.projectRoot}/dist/index.js`, }); - if (s.containerIds.length > 0) { - actions.push({ - kind: 'rm-containers', - runtime: inv.containerRuntime, - containerIds: s.containerIds, - }); - } + // Unconditional (like pkill): the scan may have found zero containers + // while the still-running host spawned one since. + actions.push({ + kind: 'rm-containers', + runtime: inv.containerRuntime, + labelFilter: `nanoclaw-install=${inv.slug}`, + }); if (s.image) { actions.push({ kind: 'rmi', runtime: inv.containerRuntime, image: s.image }); } @@ -97,7 +107,10 @@ export function buildRemovalPlan(inv: Inventory, d: Decisions): RemovalAction[] if (d.data) { const env = inv.data.find((i) => path.basename(i.path) === '.env'); if (env) actions.push({ kind: 'backup-env', envPath: env.path }); - for (const item of inv.data) actions.push({ kind: 'delete-path', item }); + for (const item of inv.data) { + if (item === env) continue; // removed by backup-env, never a bare delete + actions.push({ kind: 'delete-path', item }); + } } if (d.user) { diff --git a/setup/uninstall/remove.test.ts b/setup/uninstall/remove.test.ts index 1ae3f58e4..d32f383aa 100644 --- a/setup/uninstall/remove.test.ts +++ b/setup/uninstall/remove.test.ts @@ -125,6 +125,71 @@ describe('executePlan', () => { expect(notes.some((n) => n.includes('docker rmi img:latest'))).toBe(true); }); + it('removes .env only after a successful backup', () => { + const envPath = path.join(tempDir, '.env'); + fs.writeFileSync(envPath, 'KEY=secret'); + + const { notes } = executePlan([{ kind: 'backup-env', envPath }], deps()); + + expect(fs.existsSync(envPath)).toBe(false); + expect(fs.readFileSync(path.join(tempDir, '.env.bak'), 'utf-8')).toBe('KEY=secret'); + expect(notes).toEqual([]); + }); + + it('keeps .env when the backup fails', () => { + const envPath = path.join(tempDir, '.env'); + fs.writeFileSync(envPath, 'KEY=secret'); + fs.chmodSync(tempDir, 0o555); // backup destination unwritable + + try { + const { notes } = executePlan([{ kind: 'backup-env', envPath }], deps()); + expect(fs.existsSync(envPath)).toBe(true); + expect(notes.some((n) => n.includes('backup-env'))).toBe(true); + } finally { + fs.chmodSync(tempDir, 0o755); + } + }); + + it('re-lists containers by label at removal time instead of using scan-time ids', () => { + const calls: string[][] = []; + const docker: RunCommand = (cmd, args) => { + calls.push([cmd, ...args]); + if (args[0] === 'ps') return { status: 0, stdout: 'fresh1\nfresh2\n' }; + return { status: 0, stdout: '' }; + }; + + executePlan( + [{ kind: 'rm-containers', runtime: 'docker', labelFilter: 'nanoclaw-install=abcd1234' }], + deps({ runCommand: docker }), + ); + + expect(calls).toEqual([ + ['docker', 'ps', '-aq', '--filter', 'label=nanoclaw-install=abcd1234'], + ['docker', 'rm', '-f', 'fresh1', 'fresh2'], + ]); + }); + + it('notes a manual command when the container runtime is unavailable', () => { + const { notes } = executePlan( + [{ kind: 'rm-containers', runtime: 'docker', labelFilter: 'nanoclaw-install=x' }], + deps({ runCommand: () => ({ status: null, stdout: '' }) }), + ); + expect(notes.some((n) => n.includes('xargs -r docker rm -f'))).toBe(true); + }); + + it('notes a manual delete when onecli itself cannot be run', () => { + const { notes } = executePlan( + [ + { + kind: 'delete-onecli-agent', + agent: { uuid: 'u-123', identifier: 'ag-mine', name: 'Mine' }, + }, + ], + deps({ runCommand: () => ({ status: null, stdout: '' }) }), + ); + expect(notes.some((n) => n.includes('onecli agents delete --id u-123'))).toBe(true); + }); + it('deletes OneCLI agents by vault uuid, never by identifier', () => { const calls: string[][] = []; const recorder: RunCommand = (cmd, args) => { diff --git a/setup/uninstall/remove.ts b/setup/uninstall/remove.ts index 26a3dbb40..153ef08ff 100644 --- a/setup/uninstall/remove.ts +++ b/setup/uninstall/remove.ts @@ -114,10 +114,31 @@ function runAction(action: RemovalAction, deps: ExecDeps, notes: string[]): void // Exit 1 = no matching process — not a failure. runCommand('pkill', ['-f', action.pattern]); break; - case 'rm-containers': - runCommand(action.runtime, ['rm', '-f', ...action.containerIds]); - log(`✓ removed ${action.containerIds.length} container(s)`); + case 'rm-containers': { + // Re-list at removal time: the host was alive during the confirm + // phase and may have spawned containers the scan never saw. + const ps = runCommand(action.runtime, [ + 'ps', + '-aq', + '--filter', + `label=${action.labelFilter}`, + ]); + if (ps.status !== 0) { + notes.push( + `Containers: '${action.runtime}' unavailable — remove later with: ` + + `${action.runtime} ps -aq --filter label=${action.labelFilter} | xargs -r ${action.runtime} rm -f`, + ); + break; + } + const ids = ps.stdout + .split('\n') + .map((s) => s.trim()) + .filter(Boolean); + if (ids.length === 0) break; + runCommand(action.runtime, ['rm', '-f', ...ids]); + log(`✓ removed ${ids.length} container(s)`); break; + } case 'rmi': { const res = runCommand(action.runtime, ['rmi', action.image]); if (res.status === 0) { @@ -143,14 +164,24 @@ function runAction(action: RemovalAction, deps: ExecDeps, notes: string[]): void ]); if (res.status === 0) { log(`✓ deleted OneCLI agent ${action.agent.name} (${action.agent.identifier})`); + } else if (res.status === null) { + // spawn failure (binary gone since the scan), not a missing agent + log(`! couldn't run onecli for ${action.agent.identifier}`); + notes.push( + `OneCLI agent ${action.agent.name} (${action.agent.identifier}): couldn't run onecli — ` + + `delete manually with: onecli agents delete --id ${action.agent.uuid}`, + ); } else { log(`! OneCLI agent ${action.agent.identifier} already gone`); } break; } case 'backup-env': { + // Backup and removal are one action so a failed backup (which throws + // into executePlan's catch) can never be followed by the deletion. const backup = backupEnv(action.envPath); - log(`✓ .env backed up to ${backup}`); + fs.rmSync(action.envPath, { force: true }); + log(`✓ removed .env (backup at ${backup})`); break; } case 'delete-path': diff --git a/setup/uninstall/scan.ts b/setup/uninstall/scan.ts index c6c96712a..2e380f6fb 100644 --- a/setup/uninstall/scan.ts +++ b/setup/uninstall/scan.ts @@ -141,8 +141,10 @@ export function detectExistingInstall(projectRoot: string): boolean { ); } if (process.platform === 'linux') { - return fs.existsSync( - path.join(home, '.config', 'systemd', 'user', `${getSystemdUnit(projectRoot)}.service`), + const unit = getSystemdUnit(projectRoot); + return ( + fs.existsSync(path.join(home, '.config', 'systemd', 'user', `${unit}.service`)) || + fs.existsSync(`/etc/systemd/system/${unit}.service`) ); } return false; diff --git a/uninstall.sh b/uninstall.sh index bee97b493..22b26fd1f 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -1,3 +1,12 @@ #!/usr/bin/env bash # The uninstaller lives in the setup driver now (setup/uninstall/). -exec bash "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/nanoclaw.sh" --uninstall "$@" +# Translate the short flags the old bash uninstaller accepted. +ARGS=() +for arg in "$@"; do + case "$arg" in + -n) ARGS+=("--dry-run") ;; + -y) ARGS+=("--yes") ;; + *) ARGS+=("$arg") ;; + esac +done +exec bash "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/nanoclaw.sh" --uninstall "${ARGS[@]}"