diff --git a/CLAUDE.md b/CLAUDE.md index a04244377..0a8ba3c26 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). | +| `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 b43d9a155..9c6aee780 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 nanoclaw.sh --uninstall +``` + +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?** Only security fixes, bug fixes, and clear improvements will be accepted to the base configuration. That's all. 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..6a48fad71 100755 --- a/nanoclaw.sh +++ b/nanoclaw.sh @@ -25,6 +25,44 @@ 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. + # 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" + # 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/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/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..c0925f8cb --- /dev/null +++ b/setup/uninstall/flow.ts @@ -0,0 +1,365 @@ +/** + * 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 fs from 'fs'; +import os from 'os'; +import path from 'path'; + +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`)); + // 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…'); + 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); + + // 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, + 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, + }, + { 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 + // 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]); + + 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); +} + +/** 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..fd99e27c8 --- /dev/null +++ b/setup/uninstall/plan.test.ts @@ -0,0 +1,156 @@ +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('removes .env only via the atomic backup action, never a bare delete', () => { + const actions = buildRemovalPlan(inventory(), allYes()); + 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', () => { + 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('always re-sweeps containers and processes with a confirmed service group', () => { + const inv = inventory({ service: { containerIds: [] } }); + const actions = buildRemovalPlan(inv, allYes()); + const actionKinds = kinds(actions); + expect(actionKinds).not.toContain('rmi'); + expect(actionKinds).not.toContain('unload-service'); + // 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 new file mode 100644 index 000000000..8eab71d8f --- /dev/null +++ b/setup/uninstall/plan.ts @@ -0,0 +1,130 @@ +/** + * 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 } + /** + * 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 }; + +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`, + }); + // 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 }); + } + 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) { + if (item === env) continue; // removed by backup-env, never a bare delete + 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..d32f383aa --- /dev/null +++ b/setup/uninstall/remove.test.ts @@ -0,0 +1,212 @@ +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('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) => { + 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..153ef08ff --- /dev/null +++ b/setup/uninstall/remove.ts @@ -0,0 +1,193 @@ +/** + * 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': { + // 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) { + 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 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); + fs.rmSync(action.envPath, { force: true }); + log(`✓ removed .env (backup at ${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..2e380f6fb --- /dev/null +++ b/setup/uninstall/scan.ts @@ -0,0 +1,278 @@ +/** + * 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') { + const unit = getSystemdUnit(projectRoot); + return ( + fs.existsSync(path.join(home, '.config', 'systemd', 'user', `${unit}.service`)) || + fs.existsSync(`/etc/systemd/system/${unit}.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 new file mode 100755 index 000000000..22b26fd1f --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# The uninstaller lives in the setup driver now (setup/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[@]}"