Merge pull request #2719 from amit-shafnir/feat/uninstall-script

feat: add uninstall.sh — per-copy uninstaller with confirmation, dry-run, and OneCLI agent cleanup
This commit is contained in:
Daniel M
2026-06-10 23:02:11 +03:00
committed by GitHub
17 changed files with 1967 additions and 8 deletions
+1
View File
@@ -83,6 +83,7 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t
| `groups/<folder>/` | 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`)
+8
View File
@@ -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.
+1 -1
View File
@@ -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. |
+38
View File
@@ -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"
+44
View File
@@ -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<void> {
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<void> {
.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…',
+16 -7
View File
@@ -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<string, string | number | boolean | undefined> = {},
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,
});
+26
View File
@@ -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 ───────────────────────────────────────────────────
+365
View File
@@ -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<never> {
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<T>(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<boolean> {
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<VaultAgent[]> {
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)');
}
+150
View File
@@ -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([]);
});
});
+141
View File
@@ -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<string, unknown>;
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<string>;
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<string>,
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;
}
+156
View File
@@ -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> = {}): 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',
);
});
});
+130
View File
@@ -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;
}
+212
View File
@@ -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> = {}): 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']]);
});
});
+193
View File
@@ -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;
}
}
+196
View File
@@ -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<string, (args: string[]) => { status: number | null; stdout: string }>,
): RunCommand {
return (cmd, args) => (handlers[cmd] ?? (() => ({ status: 1, stdout: '' })))(args);
}
function deps(overrides: Partial<ScanDeps> = {}): 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, '<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);
});
});
+278
View File
@@ -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;
}
Executable
+12
View File
@@ -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[@]}"