diff --git a/docs/manage-sandboxes/lifecycle.mdx b/docs/manage-sandboxes/lifecycle.mdx
index 9245a2ab2e5..b76b7579a5a 100644
--- a/docs/manage-sandboxes/lifecycle.mdx
+++ b/docs/manage-sandboxes/lifecycle.mdx
@@ -257,6 +257,14 @@ nemoclaw uninstall
| `--keep-openshell` | Leave OpenShell binaries installed. |
| `--delete-models` | Also remove NemoClaw-pulled Ollama models. |
+
+`nemoclaw uninstall` preserves `~/.nemoclaw/rebuild-backups/` (host-side snapshots that `nemoclaw snapshot create` and `nemoclaw backup-all` write), `~/.nemoclaw/backups/` (workspace backups that `scripts/backup-workspace.sh` writes), and `~/.nemoclaw/sandboxes.json` (the sandbox registry) by default.
+Uninstall removes every other entry under `~/.nemoclaw/`.
+Interactive runs prompt before they remove the preserved entries; the default answer keeps them.
+For non-interactive runs (`--yes`, `NEMOCLAW_NON_INTERACTIVE=1`, or a non-TTY shell), set `NEMOCLAW_UNINSTALL_DESTROY_USER_DATA=1` to acknowledge data loss and remove the preserved entries as well.
+See [`nemoclaw uninstall`](/reference/commands#nemoclaw-uninstall) for the full preservation contract.
+
+
`nemoclaw uninstall` runs the version-pinned `uninstall.sh` that shipped with your installed CLI, so it does not fetch anything over the network at uninstall time.
If the `nemoclaw` CLI is missing or broken, fall back to the hosted script:
diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx
index 85451dca94b..d27d90d4bbb 100644
--- a/docs/reference/commands.mdx
+++ b/docs/reference/commands.mdx
@@ -1196,6 +1196,30 @@ On Linux, uninstall removes `~/.local/state/nemoclaw`, which contains Docker-dri
$ nemoclaw uninstall [--yes] [--keep-openshell] [--delete-models] [--gateway ]
```
+##### User-data preservation under `~/.nemoclaw/`
+
+To avoid uninstall destroying host-side user data, uninstall preserves the following entries under `~/.nemoclaw/` by default:
+
+| Entry | What it holds |
+|---|---|
+| `rebuild-backups/` | Host-side snapshots that `nemoclaw snapshot create` and `nemoclaw backup-all` write. `nemoclaw snapshot restore` reads them back after you reinstall. |
+| `backups/` | Host-side workspace backups that `scripts/backup-workspace.sh` writes (see [Backup and Restore](/manage-sandboxes/backup-restore)). |
+| `sandboxes.json` | Host-side sandbox registry. NemoClaw uses it to map sandbox names back to their persistence directories when you reinstall. |
+
+Uninstall removes every other entry under `~/.nemoclaw/` (gateway source, runtime state, the Ollama auth proxy PID file, etc.).
+
+Decision matrix:
+
+| Context | Behaviour |
+|---|---|
+| Interactive TTY, preserved entries present, no env override | Prompts `Also remove them? [y/N]`. Default `N` keeps the entries. |
+| Interactive TTY, user answers `y` | Removes everything under `~/.nemoclaw/` (the previous full-removal behaviour). |
+| Non-interactive (`--yes`, `NEMOCLAW_NON_INTERACTIVE=1`, or non-TTY shell) | Preserves the entries and prints a one-line notice. |
+| Any context with `NEMOCLAW_UNINSTALL_DESTROY_USER_DATA=1` | Skips the prompt and removes everything under `~/.nemoclaw/`. |
+
+The preserved entries survive uninstall as inert files on disk.
+Reinstall NemoClaw and re-onboard the sandbox before `nemoclaw snapshot restore` can use them.
+
#### `nemoclaw uninstall` vs. the hosted `uninstall.sh`
Both forms execute the same `uninstall.sh` with the same flags, but differ in where the script comes from and how much they trust the network.
diff --git a/src/lib/actions/uninstall/run-plan.test.ts b/src/lib/actions/uninstall/run-plan.test.ts
index 15384cd6973..1c8e4700650 100644
--- a/src/lib/actions/uninstall/run-plan.test.ts
+++ b/src/lib/actions/uninstall/run-plan.test.ts
@@ -618,6 +618,285 @@ describe("uninstall run plan", () => {
);
});
+ describe("user-data preservation under ~/.nemoclaw/", () => {
+ function setupStateDir(): { tmpHome: string; stateDir: string } {
+ const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-preserve-"));
+ const stateDir = path.join(tmpHome, ".nemoclaw");
+ fs.mkdirSync(path.join(stateDir, "rebuild-backups", "sb1", "20260101"), { recursive: true });
+ fs.writeFileSync(path.join(stateDir, "rebuild-backups", "sb1", "20260101", "manifest.json"), "{}");
+ fs.mkdirSync(path.join(stateDir, "backups", "20260320-120000"), { recursive: true });
+ fs.writeFileSync(path.join(stateDir, "backups", "20260320-120000", "USER.md"), "hello");
+ fs.writeFileSync(path.join(stateDir, "sandboxes.json"), "[]");
+ fs.writeFileSync(path.join(stateDir, "ollama-auth-proxy.pid"), "1234");
+ fs.mkdirSync(path.join(stateDir, "source"));
+ return { tmpHome, stateDir };
+ }
+
+ function tempScopedExistsSync(tmpHome: string): (target: string) => boolean {
+ return (target: string) => target.startsWith(tmpHome) && fs.existsSync(target);
+ }
+
+ it("preserves rebuild-backups/, backups/, and sandboxes.json by default in non-interactive runs", () => {
+ const { tmpHome, stateDir } = setupStateDir();
+ try {
+ const logs: string[] = [];
+ const result = runUninstallPlan(
+ { assumeYes: true, deleteModels: false, keepOpenShell: true },
+ {
+ commandExists: () => false,
+ env: { HOME: tmpHome } as NodeJS.ProcessEnv,
+ existsSync: tempScopedExistsSync(tmpHome),
+ isTty: false,
+ log: (line) => logs.push(line),
+ run: vi.fn(() => ok()),
+ runDocker: () => ok(""),
+ },
+ );
+
+ expect(result.exitCode).toBe(0);
+ expect(fs.existsSync(path.join(stateDir, "rebuild-backups", "sb1", "20260101", "manifest.json"))).toBe(true);
+ expect(fs.existsSync(path.join(stateDir, "backups", "20260320-120000", "USER.md"))).toBe(true);
+ expect(fs.existsSync(path.join(stateDir, "sandboxes.json"))).toBe(true);
+ expect(fs.existsSync(path.join(stateDir, "ollama-auth-proxy.pid"))).toBe(false);
+ expect(fs.existsSync(path.join(stateDir, "source"))).toBe(false);
+ expect(logs).toContain(`Preserving rebuild-backups, backups, sandboxes.json under ${stateDir}.`);
+ expect(logs.some((line) => line.includes("preserved: rebuild-backups, backups, sandboxes.json"))).toBe(true);
+ } finally {
+ fs.rmSync(tmpHome, { recursive: true, force: true });
+ }
+ });
+
+ it("purges the whole state dir when NEMOCLAW_UNINSTALL_DESTROY_USER_DATA=1 is set", () => {
+ const { tmpHome, stateDir } = setupStateDir();
+ try {
+ const logs: string[] = [];
+ const result = runUninstallPlan(
+ { assumeYes: true, deleteModels: false, keepOpenShell: true },
+ {
+ commandExists: () => false,
+ env: {
+ HOME: tmpHome,
+ NEMOCLAW_UNINSTALL_DESTROY_USER_DATA: "1",
+ } as NodeJS.ProcessEnv,
+ existsSync: tempScopedExistsSync(tmpHome),
+ isTty: false,
+ log: (line) => logs.push(line),
+ run: vi.fn(() => ok()),
+ runDocker: () => ok(""),
+ },
+ );
+
+ expect(result.exitCode).toBe(0);
+ expect(fs.existsSync(stateDir)).toBe(false);
+ expect(logs).toContain(`Removed ${stateDir}`);
+ expect(logs).toContain("NEMOCLAW_UNINSTALL_DESTROY_USER_DATA=1 set; purging user data under ~/.nemoclaw/.");
+ expect(logs.every((line) => !line.includes("preserved:"))).toBe(true);
+ } finally {
+ fs.rmSync(tmpHome, { recursive: true, force: true });
+ }
+ });
+
+ it("purges via interactive y/N prompt when user answers yes", () => {
+ const { tmpHome, stateDir } = setupStateDir();
+ try {
+ const logs: string[] = [];
+ const replies = ["yes", "y"];
+ const result = runUninstallPlan(
+ { assumeYes: false, deleteModels: false, keepOpenShell: true },
+ {
+ commandExists: () => false,
+ env: { HOME: tmpHome } as NodeJS.ProcessEnv,
+ existsSync: tempScopedExistsSync(tmpHome),
+ isTty: true,
+ log: (line) => logs.push(line),
+ readLine: () => replies.shift() ?? null,
+ run: vi.fn(() => ok()),
+ runDocker: () => ok(""),
+ },
+ );
+
+ expect(result.exitCode).toBe(0);
+ expect(fs.existsSync(stateDir)).toBe(false);
+ expect(logs).toContain("Also remove them? [y/N]");
+ expect(logs).toContain("Acknowledged; purging user data.");
+ } finally {
+ fs.rmSync(tmpHome, { recursive: true, force: true });
+ }
+ });
+
+ it("keeps user data when interactive prompt is declined", () => {
+ const { tmpHome, stateDir } = setupStateDir();
+ try {
+ const logs: string[] = [];
+ const replies = ["yes", ""];
+ const result = runUninstallPlan(
+ { assumeYes: false, deleteModels: false, keepOpenShell: true },
+ {
+ commandExists: () => false,
+ env: { HOME: tmpHome } as NodeJS.ProcessEnv,
+ existsSync: tempScopedExistsSync(tmpHome),
+ isTty: true,
+ log: (line) => logs.push(line),
+ readLine: () => replies.shift() ?? null,
+ run: vi.fn(() => ok()),
+ runDocker: () => ok(""),
+ },
+ );
+
+ expect(result.exitCode).toBe(0);
+ expect(fs.existsSync(path.join(stateDir, "rebuild-backups", "sb1", "20260101", "manifest.json"))).toBe(true);
+ expect(fs.existsSync(path.join(stateDir, "backups", "20260320-120000", "USER.md"))).toBe(true);
+ expect(fs.existsSync(path.join(stateDir, "sandboxes.json"))).toBe(true);
+ expect(logs).toContain("Keeping user data.");
+ } finally {
+ fs.rmSync(tmpHome, { recursive: true, force: true });
+ }
+ });
+
+ it("preserves entries on a TTY when NEMOCLAW_NON_INTERACTIVE=1 is set instead of --yes", () => {
+ const { tmpHome, stateDir } = setupStateDir();
+ const readLine = vi.fn(() => "yes");
+ try {
+ const logs: string[] = [];
+ const result = runUninstallPlan(
+ { assumeYes: false, deleteModels: false, keepOpenShell: true },
+ {
+ commandExists: () => false,
+ env: {
+ HOME: tmpHome,
+ NEMOCLAW_NON_INTERACTIVE: "1",
+ } as NodeJS.ProcessEnv,
+ existsSync: tempScopedExistsSync(tmpHome),
+ // Simulate a TTY so we exercise the env-var-only branch (the prior
+ // tests reach the silent-preserve branch via !isTty or assumeYes).
+ isTty: true,
+ log: (line) => logs.push(line),
+ readLine,
+ run: vi.fn(() => ok()),
+ runDocker: () => ok(""),
+ },
+ );
+
+ expect(result.exitCode).toBe(0);
+ expect(fs.existsSync(path.join(stateDir, "rebuild-backups", "sb1", "20260101", "manifest.json"))).toBe(true);
+ expect(fs.existsSync(path.join(stateDir, "backups", "20260320-120000", "USER.md"))).toBe(true);
+ expect(fs.existsSync(path.join(stateDir, "sandboxes.json"))).toBe(true);
+ expect(logs).toContain(`Preserving rebuild-backups, backups, sandboxes.json under ${stateDir}.`);
+ // Interactive y/N prompt must not fire when NEMOCLAW_NON_INTERACTIVE is set.
+ expect(logs.every((line) => line !== "Also remove them? [y/N]")).toBe(true);
+ // The earlier generic confirm() prompt still consumes one readLine for "Proceed? [y/N]";
+ // resolvePreserveSet must not consume another.
+ expect(readLine).toHaveBeenCalledTimes(1);
+ } finally {
+ fs.rmSync(tmpHome, { recursive: true, force: true });
+ }
+ });
+
+ it("exits non-zero and warns when lstat on ~/.nemoclaw fails with a non-ENOENT error", () => {
+ const { tmpHome, stateDir } = setupStateDir();
+ const realLstat = fs.lstatSync;
+ const lstatSpy = vi.spyOn(fs, "lstatSync").mockImplementation((p: fs.PathLike) => {
+ if (String(p) === stateDir) {
+ const err = new Error("permission denied") as NodeJS.ErrnoException;
+ err.code = "EACCES";
+ throw err;
+ }
+ return realLstat(p);
+ });
+ try {
+ const logs: string[] = [];
+ const warnings: string[] = [];
+ const result = runUninstallPlan(
+ { assumeYes: true, deleteModels: false, keepOpenShell: true },
+ {
+ commandExists: () => false,
+ env: { HOME: tmpHome } as NodeJS.ProcessEnv,
+ error: (line) => warnings.push(line),
+ existsSync: tempScopedExistsSync(tmpHome),
+ isTty: false,
+ log: (line) => logs.push(line),
+ run: vi.fn(() => ok()),
+ runDocker: () => ok(""),
+ },
+ );
+
+ expect(result.exitCode).toBe(1);
+ expect(warnings.some((line) => line.startsWith(`Failed to inspect ${stateDir}: `))).toBe(true);
+ expect(warnings).toContain(
+ "Uninstall completed with errors. Some state may remain on disk; see warnings above.",
+ );
+ expect(logs).not.toContain("Claws retracted. Until next time.");
+ expect(fs.existsSync(path.join(stateDir, "rebuild-backups", "sb1", "20260101", "manifest.json"))).toBe(true);
+ } finally {
+ lstatSpy.mockRestore();
+ fs.rmSync(tmpHome, { recursive: true, force: true });
+ }
+ });
+
+ it("removes ~/.nemoclaw wholesale when it is a symlink rather than a real directory", () => {
+ const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-preserve-"));
+ const realTarget = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-preserve-target-"));
+ const stateDir = path.join(tmpHome, ".nemoclaw");
+ fs.symlinkSync(realTarget, stateDir);
+ // Symlink target intentionally non-empty so that following it would
+ // tempt the selective-wipe path; lstat must short-circuit that.
+ fs.writeFileSync(path.join(realTarget, "rebuild-backups"), "should not be followed");
+ try {
+ const logs: string[] = [];
+ const result = runUninstallPlan(
+ { assumeYes: true, deleteModels: false, keepOpenShell: true },
+ {
+ commandExists: () => false,
+ env: { HOME: tmpHome } as NodeJS.ProcessEnv,
+ existsSync: (target: string) =>
+ target.startsWith(tmpHome) && fs.existsSync(target),
+ isTty: false,
+ log: (line) => logs.push(line),
+ run: vi.fn(() => ok()),
+ runDocker: () => ok(""),
+ },
+ );
+
+ expect(result.exitCode).toBe(0);
+ expect(fs.existsSync(stateDir)).toBe(false);
+ expect(fs.existsSync(realTarget)).toBe(true);
+ expect(logs).toContain(`Removed ${stateDir}`);
+ } finally {
+ fs.rmSync(tmpHome, { recursive: true, force: true });
+ fs.rmSync(realTarget, { recursive: true, force: true });
+ }
+ });
+
+ it("skips the preservation notice when no protected entries exist on disk", () => {
+ const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-preserve-"));
+ const stateDir = path.join(tmpHome, ".nemoclaw");
+ fs.mkdirSync(stateDir, { recursive: true });
+ fs.writeFileSync(path.join(stateDir, "ollama-auth-proxy.pid"), "1234");
+ try {
+ const logs: string[] = [];
+ const result = runUninstallPlan(
+ { assumeYes: true, deleteModels: false, keepOpenShell: true },
+ {
+ commandExists: () => false,
+ env: { HOME: tmpHome } as NodeJS.ProcessEnv,
+ existsSync: tempScopedExistsSync(tmpHome),
+ isTty: false,
+ log: (line) => logs.push(line),
+ run: vi.fn(() => ok()),
+ runDocker: () => ok(""),
+ },
+ );
+
+ expect(result.exitCode).toBe(0);
+ expect(fs.existsSync(stateDir)).toBe(false);
+ expect(logs).toContain(`Removed ${stateDir}`);
+ expect(logs.every((line) => !line.startsWith("Preserving "))).toBe(true);
+ } finally {
+ fs.rmSync(tmpHome, { recursive: true, force: true });
+ }
+ });
+ });
+
it("kills host openshell-gateway process during uninstall (#3516)", () => {
const logs: string[] = [];
const killed: number[] = [];
diff --git a/src/lib/actions/uninstall/run-plan.ts b/src/lib/actions/uninstall/run-plan.ts
index f288db89c9c..e14aca55fd4 100644
--- a/src/lib/actions/uninstall/run-plan.ts
+++ b/src/lib/actions/uninstall/run-plan.ts
@@ -111,6 +111,70 @@ function removePath(target: string, deps: Required snapshot create` and `nemoclaw backup-all`; `backups/`
+// holds host-side workspace backups from `scripts/backup-workspace.sh`;
+// `sandboxes.json` is the host-side sandbox registry. Full wipe still happens
+// when NEMOCLAW_UNINSTALL_DESTROY_USER_DATA=1 is set, or when the user answers
+// `y` to the interactive prompt.
+const PRESERVED_USER_DATA_ENTRIES: readonly string[] = [
+ "rebuild-backups",
+ "backups",
+ "sandboxes.json",
+];
+
+function removePathExcept(
+ target: string,
+ preserve: readonly string[],
+ deps: Required> &
+ Pick,
+): boolean {
+ if (!deps.existsSync(target)) return true;
+ if (preserve.length === 0) {
+ deps.rmSync(target, { force: true, recursive: true });
+ deps.log(`Removed ${target}`);
+ return true;
+ }
+ // Only enumerate when `target` is a real directory. A symlink or non-dir
+ // would make readdirSync follow into / fail noisily; treat those as
+ // wholesale removal, matching prior behaviour for unusual shapes.
+ let stat: fs.Stats;
+ try {
+ stat = fs.lstatSync(target);
+ } catch (err) {
+ // ENOENT — gone already, nothing to do. Any other error means we cannot
+ // safely decide whether to enumerate or remove; surface it and report
+ // failure so uninstall returns a non-zero exit instead of silently
+ // claiming success while leaving state on disk.
+ if ((err as NodeJS.ErrnoException)?.code === "ENOENT") return true;
+ deps.warn(`Failed to inspect ${target}: ${err instanceof Error ? err.message : String(err)}`);
+ return false;
+ }
+ if (!stat.isDirectory()) {
+ deps.rmSync(target, { force: true, recursive: true });
+ deps.log(`Removed ${target}`);
+ return true;
+ }
+ const preserveSet = new Set(preserve);
+ const children = fs.readdirSync(target);
+ for (const entry of children) {
+ if (preserveSet.has(entry)) continue;
+ deps.rmSync(path.join(target, entry), { force: true, recursive: true });
+ }
+ // Track preserved order against the declared allowlist so the log line is
+ // stable across filesystems with non-deterministic readdir ordering.
+ const childSet = new Set(children);
+ const preserved = preserve.filter((name) => childSet.has(name));
+ if (preserved.length === 0) {
+ deps.rmSync(target, { force: true, recursive: true });
+ deps.log(`Removed ${target}`);
+ return true;
+ }
+ deps.log(`Removed contents of ${target} (preserved: ${preserved.join(", ")})`);
+ return true;
+}
+
function removeFileWithOptionalSudo(target: string, deps: UninstallRuntime): void {
if (!deps.existsSync(target)) return;
const parent = path.dirname(target);
@@ -197,7 +261,8 @@ function confirm(options: UninstallRunOptions, runtime: UninstallRuntime): boole
runtime.log("What will be removed:");
runtime.log(` · All OpenShell sandboxes, gateway, and ${branding.display} providers`);
runtime.log(" · Related Docker containers, images, and volumes");
- runtime.log(" · ~/.nemoclaw ~/.config/openshell ~/.config/nemoclaw");
+ runtime.log(" · ~/.nemoclaw (preserves rebuild-backups/, backups/, sandboxes.json by default)");
+ runtime.log(" · ~/.config/openshell ~/.config/nemoclaw");
runtime.log(` · Global ${branding.display} CLI (npm package: nemoclaw)`);
runtime.log(options.deleteModels ? ` · Ollama models: ${NEMOCLAW_OLLAMA_MODELS.join(" ")}` : " · Ollama models: kept");
runtime.log("Proceed? [y/N]");
@@ -565,7 +630,57 @@ function removeManagedSwap(paths: UninstallPaths, runtime: UninstallRuntime): vo
else runtime.warn("Failed to remove /swapfile.");
}
-function executePlan(plan: UninstallPlan, paths: UninstallPaths, options: UninstallRunOptions, runtime: UninstallRuntime): void {
+function detectPreservableEntries(paths: UninstallPaths, runtime: UninstallRuntime): string[] {
+ if (!runtime.existsSync(paths.nemoclawStateDir)) return [];
+ return PRESERVED_USER_DATA_ENTRIES.filter((name) =>
+ runtime.existsSync(path.join(paths.nemoclawStateDir, name)),
+ );
+}
+
+function resolvePreserveSet(
+ paths: UninstallPaths,
+ options: UninstallRunOptions,
+ runtime: UninstallRuntime,
+): readonly string[] {
+ // Explicit acknowledgement env var → full purge, matches today's behaviour.
+ if (runtime.env.NEMOCLAW_UNINSTALL_DESTROY_USER_DATA === "1") {
+ runtime.log("NEMOCLAW_UNINSTALL_DESTROY_USER_DATA=1 set; purging user data under ~/.nemoclaw/.");
+ return [];
+ }
+ const preservable = detectPreservableEntries(paths, runtime);
+ // Nothing on disk worth preserving → no message, no prompt; treat as default
+ // preserve set so a later snapshot-create still survives if the user re-runs.
+ if (preservable.length === 0) return PRESERVED_USER_DATA_ENTRIES;
+ // Non-interactive (no TTY, --yes, or NEMOCLAW_NON_INTERACTIVE=1) → preserve
+ // silently with a one-line notice. Default behaviour is safe; users who want
+ // a destructive uninstall in CI must set the env var.
+ const nonInteractive =
+ !runtime.isTty || options.assumeYes || runtime.env.NEMOCLAW_NON_INTERACTIVE === "1";
+ if (nonInteractive) {
+ runtime.log(`Preserving ${preservable.join(", ")} under ${paths.nemoclawStateDir}.`);
+ runtime.log(" Set NEMOCLAW_UNINSTALL_DESTROY_USER_DATA=1 to purge user data on uninstall.");
+ return PRESERVED_USER_DATA_ENTRIES;
+ }
+ runtime.log(`The following user data under ${paths.nemoclawStateDir} is preserved by default:`);
+ for (const name of preservable) runtime.log(` · ${name}`);
+ runtime.log("Also remove them? [y/N]");
+ const reply = runtime.readLine();
+ if (reply && /^(y|yes)$/i.test(reply.trim())) {
+ runtime.log("Acknowledged; purging user data.");
+ return [];
+ }
+ runtime.log("Keeping user data.");
+ return PRESERVED_USER_DATA_ENTRIES;
+}
+
+function executePlan(
+ plan: UninstallPlan,
+ paths: UninstallPaths,
+ options: UninstallRunOptions,
+ runtime: UninstallRuntime,
+ preserveUnderStateDir: readonly string[],
+): { ok: boolean } {
+ let ok = true;
const branding = runtimeBranding(runtime);
for (const [index, step] of plan.steps.entries()) {
runtime.log(`[${index + 1}/${plan.steps.length}] ${planStepDisplayName(step.name, branding)}`);
@@ -601,12 +716,13 @@ function executePlan(plan: UninstallPlan, paths: UninstallPaths, options: Uninst
for (const pattern of paths.runtimeTempGlobs) removeGlob(pattern, runtime);
if (options.keepOpenShell) runtime.log("Keeping OpenShell binaries as requested.");
else for (const target of paths.openshellInstallPaths) removeFileWithOptionalSudo(target, runtime);
- removePath(paths.nemoclawStateDir, runtime);
+ if (!removePathExcept(paths.nemoclawStateDir, preserveUnderStateDir, runtime)) ok = false;
removePath(paths.gatewayLocalStateDir, runtime);
removePath(paths.openshellConfigDir, runtime);
removePath(paths.nemoclawConfigDir, runtime);
}
}
+ return { ok };
}
export function buildRunPlan(options: UninstallRunOptions, deps: UninstallRunDeps = {}): { paths: UninstallPaths; plan: UninstallPlan } {
@@ -632,7 +748,12 @@ export function runUninstallPlan(options: UninstallRunOptions, deps: UninstallRu
const { paths, plan } = buildRunPlan(options, { ...deps, env: runtime.env });
printBanner(runtime);
if (!confirm(options, runtime)) return { exitCode: 0, plan };
- executePlan(plan, paths, options, runtime);
- printBye(runtime);
- return { exitCode: 0, plan };
+ const preserveUnderStateDir = resolvePreserveSet(paths, options, runtime);
+ const { ok } = executePlan(plan, paths, options, runtime, preserveUnderStateDir);
+ if (ok) {
+ printBye(runtime);
+ } else {
+ runtime.error("Uninstall completed with errors. Some state may remain on disk; see warnings above.");
+ }
+ return { exitCode: ok ? 0 : 1, plan };
}