mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-12 18:11:51 +08:00
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:
@@ -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`)
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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 +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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────
|
||||
|
||||
@@ -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)');
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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']]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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[@]}"
|
||||
Reference in New Issue
Block a user