diff --git a/docs/manage-sandboxes/lifecycle.mdx b/docs/manage-sandboxes/lifecycle.mdx index 0cc57c357e2..4e463598149 100644 --- a/docs/manage-sandboxes/lifecycle.mdx +++ b/docs/manage-sandboxes/lifecycle.mdx @@ -250,17 +250,22 @@ To remove NemoClaw and all resources created during setup, run the CLI's built-i $$nemoclaw uninstall ``` -| Flag | Effect | -|--------------------|------------------------------------------------------| -| `--yes` | Skip the confirmation prompt. | -| `--keep-openshell` | Leave OpenShell binaries installed. | -| `--delete-models` | Also remove NemoClaw-pulled Ollama models. | +| Flag | Effect | +|-----------------------|---------------------------------------------------------------------------------------| +| `--yes` | Skip the confirmation prompt. | +| `--keep-openshell` | Leave OpenShell binaries installed. | +| `--delete-models` | Also remove NemoClaw-pulled Ollama models. | +| `--destroy-user-data` | Also remove preserved user data (`rebuild-backups/`, `backups/`, `sandboxes.json`). | +| `--gateway ` | Override the gateway name to remove (default: `nemoclaw`). | The uninstall command preserves `~/.nemoclaw/rebuild-backups/` (host-side snapshots written by snapshot and `backup-all` commands), `~/.nemoclaw/backups/` (workspace backups written by `scripts/backup-workspace.sh`), 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. +For non-interactive runs (`--yes`, `NEMOCLAW_NON_INTERACTIVE=1`, or a non-TTY shell), pass `--destroy-user-data` (or set `NEMOCLAW_UNINSTALL_DESTROY_USER_DATA=1`) to acknowledge data loss and remove the preserved entries as well. +`--yes` stays non-destructive by design. +It only acknowledges the global confirmation prompt and never purges preserved user data on its own. +Full purge always requires an explicit `--destroy-user-data` or the matching env var, so existing automation using `--yes` retains its safe behaviour. Refer to the [Commands reference](../reference/commands#$$nemoclaw-uninstall) for the full preservation contract. @@ -272,7 +277,7 @@ If the CLI is missing or broken, fall back to the hosted script: curl -fsSL https://raw.githubusercontent.com/NVIDIA/NemoClaw/refs/heads/main/uninstall.sh | bash ``` -The same `--yes`, `--keep-openshell`, and `--delete-models` flags listed above also apply to the hosted script. Pass them after `bash -s --`. +The same `--yes`, `--keep-openshell`, `--delete-models`, `--destroy-user-data`, and `--gateway` flags listed above also apply to the hosted script. Pass them after `bash -s --`. ```bash curl -fsSL https://raw.githubusercontent.com/NVIDIA/NemoClaw/refs/heads/main/uninstall.sh | bash -s -- --yes --delete-models diff --git a/docs/reference/commands-nemohermes.mdx b/docs/reference/commands-nemohermes.mdx index a88fc147eeb..b23c4a741ad 100644 --- a/docs/reference/commands-nemohermes.mdx +++ b/docs/reference/commands-nemohermes.mdx @@ -1659,10 +1659,11 @@ On Linux, uninstall removes `~/.local/state/nemoclaw`, which contains Docker-dri | `--yes` | Skip the confirmation prompt | | `--keep-openshell` | Leave OpenShell binaries installed | | `--delete-models` | Also remove NemoClaw-pulled Ollama models | +| `--destroy-user-data` | Also remove preserved user data (`rebuild-backups/`, `backups/`, `sandboxes.json`) | | `--gateway ` | Override the gateway name to remove (default: `nemohermes`) | ```bash -nemohermes uninstall [--yes] [--keep-openshell] [--delete-models] [--gateway ] +nemohermes uninstall [--yes] [--keep-openshell] [--delete-models] [--destroy-user-data] [--gateway ] ``` ##### User-data preservation under `~/.nemoclaw/` @@ -1677,6 +1678,11 @@ To avoid uninstall destroying host-side user data, uninstall preserves the follo Uninstall removes every other entry under `~/.nemoclaw/` (gateway source, runtime state, the Ollama auth proxy PID file, etc.). +`--yes` deliberately remains non-destructive for user data. +It only acknowledges the global `Proceed?` confirmation prompt and still preserves the listed entries. +Removing the preserved entries always requires an explicit opt-in flag (`--destroy-user-data`) or the matching env var (`NEMOCLAW_UNINSTALL_DESTROY_USER_DATA=1`). +Existing automation using `--yes` therefore retains its safe behaviour and never loses host-side state by accident. + Decision matrix: | Context | Behaviour | @@ -1684,7 +1690,8 @@ Decision matrix: | 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/`. | +| `--destroy-user-data` | Skips the secondary user-data prompt and removes the preserved entries under `~/.nemoclaw/`. The global `Proceed?` confirmation still applies unless `--yes` is also passed. | +| `NEMOCLAW_UNINSTALL_DESTROY_USER_DATA=1` | Skips the secondary user-data prompt and removes the preserved entries. The global `Proceed?` confirmation still applies unless `--yes` is also passed. | The preserved entries survive uninstall as inert files on disk. Reinstall NemoClaw and re-onboard the sandbox before `nemohermes snapshot restore` can use them. @@ -1976,6 +1983,7 @@ The following flags change defaults for commands that manage existing sandboxes. | `NEMOCLAW_DISABLE_INFERENCE_ROUTE_REPAIR` | `1` to enable | Skips the automatic DNS-proxy repair for stale `inference.local` routes during `nemohermes connect` and `nemohermes connect --probe-only`. Use only as a troubleshooting escape hatch. | | `NEMOCLAW_SHIELDS_ACCEPT_LEGACY_BASELINE` | `1` to opt in | Allows advanced immutable-config verification to trust the current on-disk bytes for older or partial content baselines. Use only after you have rebuilt or manually inspected the sandbox state and accepted that the baseline is operator-approved. | | `NEMOCLAW_SHIELDS_SETTLE_MS` | milliseconds (default `750`, clamped to `0` to `10000`) | Settle window NemoClaw waits after re-applying a config lockdown (during shields auto-restore and `nemohermes shields up` drift remediation) before re-confirming the lock still holds. Detects when an in-sandbox reconciler changes config file permissions after lockdown and re-applies the lock; if NemoClaw cannot re-confirm the lock within the retry budget, shields stay down. This narrows the window in which a reconciler can revert permissions rather than eliminating it. The best-effort `chattr +i` immutable bit remains the only fully durable lock. Raise it on hosts where the gateway settles slowly. | +| `NEMOCLAW_UNINSTALL_DESTROY_USER_DATA` | `1` to opt in | Acknowledges data loss during `nemohermes uninstall` and removes the otherwise-preserved entries (`rebuild-backups/`, `backups/`, `sandboxes.json`) under `~/.nemoclaw/`. Equivalent to passing the `--destroy-user-data` flag; the global `Proceed?` confirmation still applies unless `--yes` is also passed. | ### Legacy `nemohermes setup` diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index d1ee4695f80..fe71a8476ed 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -2017,10 +2017,11 @@ On Linux, uninstall removes `~/.local/state/nemoclaw`, which contains Docker-dri | `--yes` | Skip the confirmation prompt | | `--keep-openshell` | Leave OpenShell binaries installed | | `--delete-models` | Also remove NemoClaw-pulled Ollama models | +| `--destroy-user-data` | Also remove preserved user data (`rebuild-backups/`, `backups/`, `sandboxes.json`) | | `--gateway ` | Override the gateway name to remove (default: `$$nemoclaw`) | ```bash -$$nemoclaw uninstall [--yes] [--keep-openshell] [--delete-models] [--gateway ] +$$nemoclaw uninstall [--yes] [--keep-openshell] [--delete-models] [--destroy-user-data] [--gateway ] ``` ##### User-data preservation under `~/.nemoclaw/` @@ -2035,6 +2036,11 @@ To avoid uninstall destroying host-side user data, uninstall preserves the follo Uninstall removes every other entry under `~/.nemoclaw/` (gateway source, runtime state, the Ollama auth proxy PID file, etc.). +`--yes` deliberately remains non-destructive for user data. +It only acknowledges the global `Proceed?` confirmation prompt and still preserves the listed entries. +Removing the preserved entries always requires an explicit opt-in flag (`--destroy-user-data`) or the matching env var (`NEMOCLAW_UNINSTALL_DESTROY_USER_DATA=1`). +Existing automation using `--yes` therefore retains its safe behaviour and never loses host-side state by accident. + Decision matrix: | Context | Behaviour | @@ -2042,7 +2048,8 @@ Decision matrix: | 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/`. | +| `--destroy-user-data` | Skips the secondary user-data prompt and removes the preserved entries under `~/.nemoclaw/`. The global `Proceed?` confirmation still applies unless `--yes` is also passed. | +| `NEMOCLAW_UNINSTALL_DESTROY_USER_DATA=1` | Skips the secondary user-data prompt and removes the preserved entries. The global `Proceed?` confirmation still applies unless `--yes` is also passed. | The preserved entries survive uninstall as inert files on disk. Reinstall NemoClaw and re-onboard the sandbox before `$$nemoclaw snapshot restore` can use them. @@ -2429,6 +2436,7 @@ The following flags change defaults for commands that manage existing sandboxes. | `NEMOCLAW_DISABLE_INFERENCE_ROUTE_REPAIR` | `1` to enable | Skips the automatic DNS-proxy repair for stale `inference.local` routes during `$$nemoclaw connect` and `$$nemoclaw connect --probe-only`. Use only as a troubleshooting escape hatch. | | `NEMOCLAW_SHIELDS_ACCEPT_LEGACY_BASELINE` | `1` to opt in | Allows advanced immutable-config verification to trust the current on-disk bytes for older or partial content baselines. Use only after you have rebuilt or manually inspected the sandbox state and accepted that the baseline is operator-approved. | | `NEMOCLAW_SHIELDS_SETTLE_MS` | milliseconds (default `750`, clamped to `0` to `10000`) | Settle window NemoClaw waits after re-applying a config lockdown (during shields auto-restore and `$$nemoclaw shields up` drift remediation) before re-confirming the lock still holds. Detects when an in-sandbox reconciler changes config file permissions after lockdown and re-applies the lock; if NemoClaw cannot re-confirm the lock within the retry budget, shields stay down. This narrows the window in which a reconciler can revert permissions rather than eliminating it. The best-effort `chattr +i` immutable bit remains the only fully durable lock. Raise it on hosts where the gateway settles slowly. | +| `NEMOCLAW_UNINSTALL_DESTROY_USER_DATA` | `1` to opt in | Acknowledges data loss during `$$nemoclaw uninstall` and removes the otherwise-preserved entries (`rebuild-backups/`, `backups/`, `sandboxes.json`) under `~/.nemoclaw/`. Equivalent to passing the `--destroy-user-data` flag; the global `Proceed?` confirmation still applies unless `--yes` is also passed. | ### Remote Deployment diff --git a/src/commands/internal/uninstall/run-plan.ts b/src/commands/internal/uninstall/run-plan.ts index 26809e33e35..aad6acc5637 100644 --- a/src/commands/internal/uninstall/run-plan.ts +++ b/src/commands/internal/uninstall/run-plan.ts @@ -2,17 +2,18 @@ // SPDX-License-Identifier: Apache-2.0 import { Flags } from "@oclif/core"; -import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; - import { runUninstallPlan } from "../../../lib/actions/uninstall/run-plan"; import { CLI_DISPLAY_NAME, CLI_NAME } from "../../../lib/cli/branding"; +import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; export default class InternalUninstallRunPlanCommand extends NemoClawCommand { static hidden = true; static strict = true; static summary = `${CLI_DISPLAY_NAME} Uninstaller`; static description = `Remove host-side ${CLI_DISPLAY_NAME} resources.`; - static usage = ["internal uninstall run-plan [--yes] [--keep-openshell] [--delete-models]"]; + static usage = [ + "internal uninstall run-plan [--yes] [--keep-openshell] [--delete-models] [--destroy-user-data]", + ]; static examples = [`${CLI_NAME} internal uninstall run-plan --yes`]; static flags = { yes: Flags.boolean({ description: "Skip the confirmation prompt" }), @@ -20,6 +21,10 @@ export default class InternalUninstallRunPlanCommand extends NemoClawCommand { "delete-models": Flags.boolean({ description: `Remove ${CLI_DISPLAY_NAME}-pulled Ollama models`, }), + "destroy-user-data": Flags.boolean({ + description: + "Also remove preserved user data under ~/.nemoclaw/ (rebuild-backups/, backups/, sandboxes.json)", + }), gateway: Flags.string({ description: "Gateway name", default: "nemoclaw" }), }; @@ -28,6 +33,7 @@ export default class InternalUninstallRunPlanCommand extends NemoClawCommand { const result = runUninstallPlan({ assumeYes: flags.yes ?? false, deleteModels: flags["delete-models"] ?? false, + destroyUserData: flags["destroy-user-data"] ?? false, gatewayName: flags.gateway, keepOpenShell: flags["keep-openshell"] ?? false, }); diff --git a/src/lib/actions/root-help.ts b/src/lib/actions/root-help.ts index cb9bdc75693..589d6d9c759 100644 --- a/src/lib/actions/root-help.ts +++ b/src/lib/actions/root-help.ts @@ -4,9 +4,9 @@ import { AGENT_PRODUCT_NAME, CLI_DISPLAY_NAME, CLI_NAME } from "../cli/branding"; import { brandedPublicText, + type CommandDef, commandsByGroup, visibleCommands, - type CommandDef, } from "../cli/command-registry"; import { getRegisteredOclifCommandSummary } from "../cli/oclif-metadata"; import { getVersion } from "../core/version"; @@ -76,6 +76,9 @@ export function help(): void { lines.push(` --yes${" ".repeat(29)}Skip the confirmation prompt`); lines.push(` --keep-openshell${" ".repeat(18)}Leave the openshell binary installed`); lines.push(` --delete-models${" ".repeat(19)}Remove ${CLI_DISPLAY_NAME}-pulled Ollama models`); + lines.push( + ` --destroy-user-data${" ".repeat(15)}Remove preserved ~/.nemoclaw/ user data (rebuild-backups/, backups/, sandboxes.json)`, + ); lines.push(""); lines.push(` ${G}Reconfiguration (after onboard):${R}`); diff --git a/src/lib/actions/uninstall/run-plan.test.ts b/src/lib/actions/uninstall/run-plan.test.ts index 50dc57e74aa..f6db340fcd4 100644 --- a/src/lib/actions/uninstall/run-plan.test.ts +++ b/src/lib/actions/uninstall/run-plan.test.ts @@ -7,7 +7,7 @@ import path from "node:path"; import { describe, expect, it, vi } from "vitest"; -import { buildRunPlan, type RunResult, runUninstallPlan } from "./run-plan"; +import { buildRunPlan, type RunResult, runUninstallPlan, type UninstallRunDeps } from "./run-plan"; function ok(stdout = ""): RunResult { return { status: 0, stdout, stderr: "" }; @@ -904,34 +904,58 @@ describe("uninstall run plan", () => { return (target: string) => target.startsWith(tmpHome) && fs.existsSync(target); } + function preserveCaseDeps( + tmpHome: string, + logs: string[], + opts: { + envOverrides?: Record; + isTty?: boolean; + readLine?: UninstallRunDeps["readLine"]; + } = {}, + ): UninstallRunDeps { + return { + commandExists: () => false, + env: { + HOME: tmpHome, + NEMOCLAW_NON_INTERACTIVE: "", + NEMOCLAW_UNINSTALL_DESTROY_USER_DATA: "", + ...(opts.envOverrides ?? {}), + } as NodeJS.ProcessEnv, + existsSync: tempScopedExistsSync(tmpHome), + isTty: opts.isTty ?? false, + log: (line) => logs.push(line), + ...(opts.readLine ? { readLine: opts.readLine } : {}), + run: vi.fn(() => ok()), + runDocker: () => ok(""), + }; + } + + function expectPreservedEntries(stateDir: string): void { + 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); + } + + function expectNoPreserveSignals(logs: string[]): void { + expect(logs.every((line) => !line.startsWith("Preserving "))).toBe(true); + expect(logs.every((line) => !line.includes("preserved:"))).toBe(true); + } + 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, - NEMOCLAW_UNINSTALL_DESTROY_USER_DATA: "", - } as NodeJS.ProcessEnv, - existsSync: tempScopedExistsSync(tmpHome), - isTty: false, - log: (line) => logs.push(line), - run: vi.fn(() => ok()), - runDocker: () => ok(""), - }, + preserveCaseDeps(tmpHome, logs), ); 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); + expectPreservedEntries(stateDir); expect(fs.existsSync(path.join(stateDir, "ollama-auth-proxy.pid"))).toBe(false); expect(fs.existsSync(path.join(stateDir, "source"))).toBe(false); expect(logs).toContain( @@ -951,18 +975,9 @@ describe("uninstall run plan", () => { 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(""), - }, + preserveCaseDeps(tmpHome, logs, { + envOverrides: { NEMOCLAW_UNINSTALL_DESTROY_USER_DATA: "1" }, + }), ); expect(result.exitCode).toBe(0); @@ -971,7 +986,145 @@ describe("uninstall run plan", () => { expect(logs).toContain( "NEMOCLAW_UNINSTALL_DESTROY_USER_DATA=1 set; purging user data under ~/.nemoclaw/.", ); - expect(logs.every((line) => !line.includes("preserved:"))).toBe(true); + expectNoPreserveSignals(logs); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); + + it("purges the whole state dir when destroyUserData is set, even with --yes on a non-TTY", () => { + const { tmpHome, stateDir } = setupStateDir(); + try { + const logs: string[] = []; + const result = runUninstallPlan( + { assumeYes: true, deleteModels: false, destroyUserData: true, keepOpenShell: true }, + preserveCaseDeps(tmpHome, logs), + ); + + expect(result.exitCode).toBe(0); + expect(fs.existsSync(stateDir)).toBe(false); + expect(logs).toContain(`Removed ${stateDir}`); + expect(logs).toContain("--destroy-user-data set; purging user data under ~/.nemoclaw/."); + expectNoPreserveSignals(logs); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); + + it("destroyUserData purges on a TTY without prompting", () => { + const { tmpHome, stateDir } = setupStateDir(); + const readLine = vi.fn(() => "y"); + try { + const logs: string[] = []; + const result = runUninstallPlan( + { assumeYes: true, deleteModels: false, destroyUserData: true, keepOpenShell: true }, + preserveCaseDeps(tmpHome, logs, { isTty: true, readLine }), + ); + + expect(result.exitCode).toBe(0); + expect(fs.existsSync(stateDir)).toBe(false); + expect(logs).toContain("--destroy-user-data set; purging user data under ~/.nemoclaw/."); + expect(logs.every((line) => line !== "Also remove them? [y/N]")).toBe(true); + expect(readLine).not.toHaveBeenCalled(); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); + + it("destroyUserData without --yes renders a purge-aware global confirmation and skips the user-data prompt", () => { + const { tmpHome, stateDir } = setupStateDir(); + try { + const logs: string[] = []; + const result = runUninstallPlan( + { assumeYes: false, deleteModels: false, destroyUserData: true, keepOpenShell: true }, + preserveCaseDeps(tmpHome, logs, { isTty: true, readLine: () => "y" }), + ); + + expect(result.exitCode).toBe(0); + expect(fs.existsSync(stateDir)).toBe(false); + expect(logs).toContain( + " · ~/.nemoclaw (removes rebuild-backups/, backups/, sandboxes.json: --destroy-user-data set)", + ); + expect( + logs.every( + (line) => + line !== + " · ~/.nemoclaw (preserves rebuild-backups/, backups/, sandboxes.json by default)", + ), + ).toBe(true); + expect(logs.every((line) => line !== "Also remove them? [y/N]")).toBe(true); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); + + it("env var without --yes renders a purge-aware global confirmation", () => { + const { tmpHome, stateDir } = setupStateDir(); + try { + const logs: string[] = []; + const result = runUninstallPlan( + { assumeYes: false, deleteModels: false, keepOpenShell: true }, + preserveCaseDeps(tmpHome, logs, { + envOverrides: { NEMOCLAW_UNINSTALL_DESTROY_USER_DATA: "1" }, + isTty: true, + readLine: () => "y", + }), + ); + + expect(result.exitCode).toBe(0); + expect(fs.existsSync(stateDir)).toBe(false); + expect(logs).toContain( + " · ~/.nemoclaw (removes rebuild-backups/, backups/, sandboxes.json: NEMOCLAW_UNINSTALL_DESTROY_USER_DATA=1)", + ); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); + + it("destroyUserData takes precedence over NEMOCLAW_UNINSTALL_DESTROY_USER_DATA env var", () => { + const { tmpHome, stateDir } = setupStateDir(); + try { + const logs: string[] = []; + const result = runUninstallPlan( + { assumeYes: true, deleteModels: false, destroyUserData: true, keepOpenShell: true }, + preserveCaseDeps(tmpHome, logs, { + envOverrides: { NEMOCLAW_UNINSTALL_DESTROY_USER_DATA: "1" }, + }), + ); + + expect(result.exitCode).toBe(0); + expect(fs.existsSync(stateDir)).toBe(false); + expect(logs).toContain("--destroy-user-data set; purging user data under ~/.nemoclaw/."); + expect( + logs.every( + (line) => + line !== + "NEMOCLAW_UNINSTALL_DESTROY_USER_DATA=1 set; purging user data under ~/.nemoclaw/.", + ), + ).toBe(true); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); + + it("non-interactive hint mentions --destroy-user-data alongside the env var on non-TTY without --yes", () => { + const { tmpHome, stateDir } = setupStateDir(); + try { + const logs: string[] = []; + const result = runUninstallPlan( + { assumeYes: false, deleteModels: false, keepOpenShell: true }, + preserveCaseDeps(tmpHome, logs, { readLine: () => "y" }), + ); + + expect(result.exitCode).toBe(0); + expectPreservedEntries(stateDir); + expect( + logs.some( + (line) => + line.includes("--destroy-user-data") && + line.includes("NEMOCLAW_UNINSTALL_DESTROY_USER_DATA=1"), + ), + ).toBe(true); } finally { fs.rmSync(tmpHome, { recursive: true, force: true }); } @@ -984,20 +1137,10 @@ describe("uninstall run plan", () => { const replies = ["yes", "y"]; const result = runUninstallPlan( { assumeYes: false, deleteModels: false, keepOpenShell: true }, - { - commandExists: () => false, - env: { - HOME: tmpHome, - NEMOCLAW_NON_INTERACTIVE: "", - NEMOCLAW_UNINSTALL_DESTROY_USER_DATA: "", - } as NodeJS.ProcessEnv, - existsSync: tempScopedExistsSync(tmpHome), + preserveCaseDeps(tmpHome, logs, { isTty: true, - log: (line) => logs.push(line), readLine: () => replies.shift() ?? null, - run: vi.fn(() => ok()), - runDocker: () => ok(""), - }, + }), ); expect(result.exitCode).toBe(0); @@ -1016,30 +1159,14 @@ describe("uninstall run plan", () => { const replies = ["yes", ""]; const result = runUninstallPlan( { assumeYes: false, deleteModels: false, keepOpenShell: true }, - { - commandExists: () => false, - env: { - HOME: tmpHome, - NEMOCLAW_NON_INTERACTIVE: "", - NEMOCLAW_UNINSTALL_DESTROY_USER_DATA: "", - } as NodeJS.ProcessEnv, - existsSync: tempScopedExistsSync(tmpHome), + preserveCaseDeps(tmpHome, logs, { 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); + expectPreservedEntries(stateDir); expect(logs).toContain("Keeping user data."); } finally { fs.rmSync(tmpHome, { recursive: true, force: true }); @@ -1053,39 +1180,19 @@ describe("uninstall run plan", () => { const logs: string[] = []; const result = runUninstallPlan( { assumeYes: false, deleteModels: false, keepOpenShell: true }, - { - commandExists: () => false, - env: { - HOME: tmpHome, - NEMOCLAW_NON_INTERACTIVE: "1", - NEMOCLAW_UNINSTALL_DESTROY_USER_DATA: "", - } 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). + preserveCaseDeps(tmpHome, logs, { + envOverrides: { NEMOCLAW_NON_INTERACTIVE: "1" }, 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); + expectPreservedEntries(stateDir); 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 }); @@ -1109,17 +1216,8 @@ describe("uninstall run plan", () => { const result = runUninstallPlan( { assumeYes: true, deleteModels: false, keepOpenShell: true }, { - commandExists: () => false, - env: { - HOME: tmpHome, - NEMOCLAW_UNINSTALL_DESTROY_USER_DATA: "", - } as NodeJS.ProcessEnv, + ...preserveCaseDeps(tmpHome, logs), error: (line) => warnings.push(line), - existsSync: tempScopedExistsSync(tmpHome), - isTty: false, - log: (line) => logs.push(line), - run: vi.fn(() => ok()), - runDocker: () => ok(""), }, ); diff --git a/src/lib/actions/uninstall/run-plan.ts b/src/lib/actions/uninstall/run-plan.ts index c1cd3fbe822..e21c9bde1db 100644 --- a/src/lib/actions/uninstall/run-plan.ts +++ b/src/lib/actions/uninstall/run-plan.ts @@ -17,8 +17,8 @@ import { type UninstallPaths, } from "../../domain/uninstall/paths"; import { buildUninstallPlan, type UninstallPlan } from "../../domain/uninstall/plan"; -import { isModelRouterCommandLineForPort } from "../../onboard/model-router-process"; import { stopHostGatewayProcesses } from "../../onboard/host-gateway-process"; +import { isModelRouterCommandLineForPort } from "../../onboard/model-router-process"; import { stopStaleDashboardListeners } from "../../onboard/stale-gateway-cleanup"; import { classifyShimPath, type FileSystemDeps } from "./plan"; @@ -31,6 +31,7 @@ export interface RunResult { export interface UninstallRunOptions { assumeYes: boolean; deleteModels: boolean; + destroyUserData?: boolean; gatewayName?: string; keepOpenShell: boolean; } @@ -281,13 +282,23 @@ function printBye(runtime: UninstallRuntime): void { runtime.log(branding.uninstallGoodbye); } +function userDataDispositionLine(options: UninstallRunOptions, runtime: UninstallRuntime): string { + if (options.destroyUserData) { + return " · ~/.nemoclaw (removes rebuild-backups/, backups/, sandboxes.json: --destroy-user-data set)"; + } + if (runtime.env.NEMOCLAW_UNINSTALL_DESTROY_USER_DATA === "1") { + return " · ~/.nemoclaw (removes rebuild-backups/, backups/, sandboxes.json: NEMOCLAW_UNINSTALL_DESTROY_USER_DATA=1)"; + } + return " · ~/.nemoclaw (preserves rebuild-backups/, backups/, sandboxes.json by default)"; +} + function confirm(options: UninstallRunOptions, runtime: UninstallRuntime): boolean { const branding = runtimeBranding(runtime); if (options.assumeYes) return true; 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 (preserves rebuild-backups/, backups/, sandboxes.json by default)"); + runtime.log(userDataDispositionLine(options, runtime)); runtime.log(" · ~/.config/openshell ~/.config/nemoclaw"); runtime.log(` · Global ${branding.display} CLI (npm package: nemoclaw)`); runtime.log( @@ -797,7 +808,10 @@ function resolvePreserveSet( options: UninstallRunOptions, runtime: UninstallRuntime, ): readonly string[] { - // Explicit acknowledgement env var → full purge, matches today's behaviour. + if (options.destroyUserData) { + runtime.log("--destroy-user-data set; purging user data under ~/.nemoclaw/."); + return []; + } if (runtime.env.NEMOCLAW_UNINSTALL_DESTROY_USER_DATA === "1") { runtime.log( "NEMOCLAW_UNINSTALL_DESTROY_USER_DATA=1 set; purging user data under ~/.nemoclaw/.", @@ -805,17 +819,14 @@ function resolvePreserveSet( 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."); + runtime.log( + " Pass --destroy-user-data (or 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:`); diff --git a/test/root-help.test.ts b/test/root-help.test.ts index d6d3c6d1bd3..f16ae674be0 100644 --- a/test/root-help.test.ts +++ b/test/root-help.test.ts @@ -50,4 +50,15 @@ describe("root help", () => { ); } }); + + it("lists --destroy-user-data under uninstall flags without unsupported --keep flags", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + + renderRootHelp(); + + const output = log.mock.calls.map(([line]) => String(line)).join("\n"); + expect(output).toMatch(/Uninstall flags:[\s\S]*--destroy-user-data/); + expect(output).not.toMatch(/--keep-user-data/); + expect(output).not.toMatch(/--keep-backups/); + }); }); diff --git a/test/uninstall.test.ts b/test/uninstall.test.ts index 9ba76ea4e5e..40825d4ab1f 100644 --- a/test/uninstall.test.ts +++ b/test/uninstall.test.ts @@ -1,11 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, it, expect } from "vitest"; +import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { spawnSync } from "node:child_process"; +import { describe, expect, it } from "vitest"; const UNINSTALL_SCRIPT = path.join(import.meta.dirname, "..", "uninstall.sh"); @@ -19,6 +19,44 @@ describe("uninstall CLI flags", () => { } } + function seedPreservedState(tmp: string): string { + const stateDir = path.join(tmp, ".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"), "[]"); + return stateDir; + } + + function sanitizedParentEnv(): NodeJS.ProcessEnv { + return Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.startsWith("NEMOCLAW_")), + ) as NodeJS.ProcessEnv; + } + + function runUninstall( + tmp: string, + args: string[], + extraEnv: NodeJS.ProcessEnv = {}, + ): ReturnType { + return spawnSync("bash", [UNINSTALL_SCRIPT, ...args], { + cwd: path.join(import.meta.dirname, ".."), + encoding: "utf-8", + env: { + ...sanitizedParentEnv(), + HOME: tmp, + PATH: `${path.join(tmp, "bin")}:/usr/bin:/bin`, + NEMOCLAW_NODE: process.execPath, + TMPDIR: tmp, + ...extraEnv, + }, + }); + } + it("exits 0 and shows usage for --help", () => { const result = spawnSync("bash", [UNINSTALL_SCRIPT, "--help"], { cwd: path.join(import.meta.dirname, ".."), @@ -31,6 +69,7 @@ describe("uninstall CLI flags", () => { expect(output).toMatch(/--yes/); expect(output).toMatch(/--keep-openshell/); expect(output).toMatch(/--delete-models/); + expect(output).toMatch(/--destroy-user-data/); }); it("uses NemoHermes branding for --help when Hermes is active", () => { @@ -54,23 +93,9 @@ describe("uninstall CLI flags", () => { it("skips the confirmation prompt and completes successfully for --yes", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-yes-")); - const fakeBin = path.join(tmp, "bin"); - writeFakeTools(fakeBin); - + writeFakeTools(path.join(tmp, "bin")); try { - const result = spawnSync("bash", [UNINSTALL_SCRIPT, "--yes"], { - cwd: path.join(import.meta.dirname, ".."), - encoding: "utf-8", - env: { - ...process.env, - HOME: tmp, - PATH: `${fakeBin}:/usr/bin:/bin`, - // The wrapper needs Node even when the test constrains PATH to fake system tools. - NEMOCLAW_NODE: process.execPath, - // Keep helper-service glob cleanup isolated from concurrently running tests. - TMPDIR: tmp, - }, - }); + const result = runUninstall(tmp, ["--yes"]); expect(result.status).toBe(0); const output = `${result.stdout}${result.stderr}`; @@ -81,24 +106,47 @@ describe("uninstall CLI flags", () => { } }, 60_000); + it("preserves rebuild-backups, backups, and sandboxes.json under ~/.nemoclaw for --yes", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-yes-preserve-")); + writeFakeTools(path.join(tmp, "bin")); + const stateDir = seedPreservedState(tmp); + try { + const result = runUninstall(tmp, ["--yes"]); + + expect(result.status).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); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }, 60_000); + + it("purges preserved ~/.nemoclaw entries through the public wrapper for --yes --destroy-user-data", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-destroy-")); + writeFakeTools(path.join(tmp, "bin")); + const stateDir = seedPreservedState(tmp); + try { + const result = runUninstall(tmp, ["--yes", "--destroy-user-data"]); + + expect(result.status).toBe(0); + const output = `${result.stdout}${result.stderr}`; + expect(output).toMatch(/--destroy-user-data set; purging user data under ~\/\.nemoclaw\//); + expect(fs.existsSync(stateDir)).toBe(false); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }, 60_000); + it("uses NemoHermes branding for --yes when Hermes is active", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemohermes-uninstall-yes-")); - const fakeBin = path.join(tmp, "bin"); - writeFakeTools(fakeBin); - + writeFakeTools(path.join(tmp, "bin")); try { - const result = spawnSync("bash", [UNINSTALL_SCRIPT, "--yes"], { - cwd: path.join(import.meta.dirname, ".."), - encoding: "utf-8", - env: { - ...process.env, - HOME: tmp, - NEMOCLAW_AGENT: "hermes", - PATH: `${fakeBin}:/usr/bin:/bin`, - NEMOCLAW_NODE: process.execPath, - TMPDIR: tmp, - }, - }); + const result = runUninstall(tmp, ["--yes"], { NEMOCLAW_AGENT: "hermes" }); expect(result.status).toBe(0); const output = `${result.stdout}${result.stderr}`; diff --git a/uninstall.sh b/uninstall.sh index 160aee13fd9..3a46a12cffa 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -4,7 +4,7 @@ # # Compatibility wrapper for the TypeScript NemoClaw uninstaller. # -# Usage: ./uninstall.sh [--yes] [--keep-openshell] [--delete-models] +# Usage: ./uninstall.sh [--yes] [--keep-openshell] [--delete-models] [--destroy-user-data] set -euo pipefail