diff --git a/src/lib/actions/uninstall-plan.test.ts b/src/lib/actions/uninstall-plan.test.ts new file mode 100644 index 00000000000..c00764591b1 --- /dev/null +++ b/src/lib/actions/uninstall-plan.test.ts @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +import { buildHostUninstallPlan, classifyShimPath } from "./uninstall-plan"; +import { flattenUninstallPlan } from "../domain/uninstall/plan"; + +describe("uninstall plan actions", () => { + it("classifies an on-disk dev shim", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-plan-shim-")); + const shim = path.join(tmp, "nemoclaw"); + try { + fs.writeFileSync( + shim, + [ + "#!/usr/bin/env bash", + "# NemoClaw dev-shim - managed by scripts/npm-link-or-shim.sh", + 'export PATH="/tmp/node-bin:$PATH"', + 'exec "/tmp/checkout/bin/nemoclaw.js" "$@"', + "", + ].join("\n"), + ); + + expect(classifyShimPath(shim)).toMatchObject({ kind: "managed-dev-shim", remove: true }); + } finally { + fs.rmSync(tmp, { force: true, recursive: true }); + } + }); + + it("builds a host uninstall plan with shim classification and env-derived paths", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-plan-")); + const shimDir = path.join(tmp, ".local", "bin"); + const shim = path.join(shimDir, "nemoclaw"); + fs.mkdirSync(shimDir, { recursive: true }); + fs.writeFileSync(shim, "#!/usr/bin/env bash\necho user\n"); + + try { + const plan = buildHostUninstallPlan({ + deleteModels: false, + env: { HOME: tmp, TMPDIR: path.join(tmp, "tmp") }, + keepOpenShell: false, + }); + const actions = flattenUninstallPlan(plan); + + expect(actions).toEqual(expect.arrayContaining([{ kind: "preserve-shim", reason: "regular file is not an installer-managed shim" }])); + expect(actions).toEqual(expect.arrayContaining([{ kind: "delete-path", path: path.join(tmp, ".nemoclaw") }])); + expect(actions).toEqual(expect.arrayContaining([{ kind: "delete-runtime-glob", pattern: path.join(tmp, "tmp", "nemoclaw-create-*.log") }])); + } finally { + fs.rmSync(tmp, { force: true, recursive: true }); + } + }); +}); diff --git a/src/lib/actions/uninstall-plan.ts b/src/lib/actions/uninstall-plan.ts new file mode 100644 index 00000000000..124dd67e3b3 --- /dev/null +++ b/src/lib/actions/uninstall-plan.ts @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; + +import { defaultUninstallPaths } from "../domain/uninstall/paths"; +import { buildUninstallPlan, type UninstallPlan, type UninstallPlanOptions } from "../domain/uninstall/plan"; +import { classifyNemoclawShim, type ShimClassification } from "../domain/uninstall/shims"; + +export interface FileSystemDeps { + lstatSync?: typeof fs.lstatSync; + readFileSync?: typeof fs.readFileSync; +} + +export interface HostUninstallPlanOptions extends Omit { + env: Partial>; + fs?: FileSystemDeps; +} + +export function classifyShimPath(shimPath: string, deps: FileSystemDeps = {}): ShimClassification { + const lstatSync = deps.lstatSync ?? fs.lstatSync; + const readFileSync = deps.readFileSync ?? fs.readFileSync; + try { + const stat = lstatSync(shimPath); + const isFile = stat.isFile(); + return classifyNemoclawShim({ + contents: isFile ? String(readFileSync(shimPath, "utf-8")) : undefined, + exists: true, + isFile, + isSymlink: stat.isSymbolicLink(), + }); + } catch (error) { + const code = error && typeof error === "object" ? (error as { code?: string }).code : undefined; + if (code === "ENOENT") { + return classifyNemoclawShim({ exists: false, isFile: false, isSymlink: false }); + } + throw error; + } +} + +export function buildHostUninstallPlan(options: HostUninstallPlanOptions): UninstallPlan { + const home = options.env.HOME || "/tmp"; + const paths = defaultUninstallPaths({ + home, + tmpDir: options.env.TMPDIR, + xdgBinHome: options.env.XDG_BIN_HOME, + }); + return buildUninstallPlan(paths, { + deleteModels: options.deleteModels, + gatewayName: options.gatewayName, + keepOpenShell: options.keepOpenShell, + shim: classifyShimPath(paths.nemoclawShimPath, options.fs), + }); +} diff --git a/src/lib/domain/uninstall/paths.test.ts b/src/lib/domain/uninstall/paths.test.ts new file mode 100644 index 00000000000..cb40cfd4c92 --- /dev/null +++ b/src/lib/domain/uninstall/paths.test.ts @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +import { defaultUninstallPaths, gatewayVolumeCandidates, uninstallStatePaths } from "./paths"; + +describe("uninstall paths", () => { + it("returns the gateway volume candidate used by uninstall.sh", () => { + expect(gatewayVolumeCandidates("nemoclaw")).toEqual(["openshell-cluster-nemoclaw"]); + }); + + it("builds host state, shim, OpenShell, and temp cleanup paths", () => { + const paths = defaultUninstallPaths({ home: "/home/test", tmpDir: "/tmp/nemo", xdgBinHome: "/xdg/bin" }); + + expect(paths.nemoclawStateDir).toBe(path.join("/home/test", ".nemoclaw")); + expect(paths.openshellConfigDir).toBe(path.join("/home/test", ".config", "openshell")); + expect(paths.nemoclawConfigDir).toBe(path.join("/home/test", ".config", "nemoclaw")); + expect(paths.nemoclawShimPath).toBe(path.join("/home/test", ".local", "bin", "nemoclaw")); + expect(paths.openshellInstallPaths).toEqual(["/usr/local/bin/openshell", path.join("/xdg/bin", "openshell")]); + expect(paths.helperServiceGlob).toBe(path.join("/tmp/nemo", "nemoclaw-services-*")); + expect(paths.runtimeTempGlobs).toEqual([ + path.join("/tmp/nemo", "nemoclaw-create-*.log"), + path.join("/tmp/nemo", "nemoclaw-tg-ssh-*.conf"), + ]); + }); + + it("returns state removal paths in shell cleanup order", () => { + const paths = defaultUninstallPaths({ home: "/home/test" }); + expect(uninstallStatePaths(paths)).toEqual([ + path.join("/home/test", ".nemoclaw"), + path.join("/home/test", ".config", "openshell"), + path.join("/home/test", ".config", "nemoclaw"), + ]); + }); +}); diff --git a/src/lib/domain/uninstall/paths.ts b/src/lib/domain/uninstall/paths.ts new file mode 100644 index 00000000000..06a3c29fb8e --- /dev/null +++ b/src/lib/domain/uninstall/paths.ts @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import path from "node:path"; + +export const DEFAULT_GATEWAY_NAME = "nemoclaw"; +export const NEMOCLAW_PROVIDERS = [ + "nvidia-nim", + "vllm-local", + "ollama-local", + "nvidia-ncp", + "nim-local", +] as const; +export const NEMOCLAW_OLLAMA_MODELS = ["nemotron-3-super:120b", "nemotron-3-nano:30b"] as const; + +export interface UninstallPathOptions { + home: string; + tmpDir?: string; + xdgBinHome?: string; +} + +export interface UninstallPaths { + helperServiceGlob: string; + nemoclawConfigDir: string; + nemoclawShimPath: string; + nemoclawStateDir: string; + openshellConfigDir: string; + openshellInstallPaths: string[]; + runtimeTempGlobs: string[]; +} + +export function gatewayVolumeCandidates(gatewayName = DEFAULT_GATEWAY_NAME): string[] { + return [`openshell-cluster-${gatewayName}`]; +} + +export function defaultUninstallPaths(options: UninstallPathOptions): UninstallPaths { + const xdgBinHome = options.xdgBinHome || path.join(options.home, ".local", "bin"); + const tmpDir = options.tmpDir || "/tmp"; + return { + helperServiceGlob: path.join(tmpDir, "nemoclaw-services-*"), + nemoclawConfigDir: path.join(options.home, ".config", "nemoclaw"), + nemoclawShimPath: path.join(options.home, ".local", "bin", "nemoclaw"), + nemoclawStateDir: path.join(options.home, ".nemoclaw"), + openshellConfigDir: path.join(options.home, ".config", "openshell"), + openshellInstallPaths: ["/usr/local/bin/openshell", path.join(xdgBinHome, "openshell")], + runtimeTempGlobs: [path.join(tmpDir, "nemoclaw-create-*.log"), path.join(tmpDir, "nemoclaw-tg-ssh-*.conf")], + }; +} + +export function uninstallStatePaths(paths: Pick): string[] { + return [paths.nemoclawStateDir, paths.openshellConfigDir, paths.nemoclawConfigDir]; +} diff --git a/src/lib/domain/uninstall/plan.test.ts b/src/lib/domain/uninstall/plan.test.ts new file mode 100644 index 00000000000..da8b7fc8708 --- /dev/null +++ b/src/lib/domain/uninstall/plan.test.ts @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { defaultUninstallPaths } from "./paths"; +import { buildUninstallPlan, flattenUninstallPlan } from "./plan"; + +describe("uninstall plan", () => { + it("models the six uninstall.sh cleanup steps", () => { + const paths = defaultUninstallPaths({ home: "/home/test", tmpDir: "/tmp/nemo" }); + const plan = buildUninstallPlan(paths, { + deleteModels: false, + keepOpenShell: false, + shim: { kind: "managed-wrapper", reason: "installer-managed wrapper contents", remove: true }, + }); + + expect(plan.steps.map((step) => step.name)).toEqual([ + "Stopping services", + "OpenShell resources", + "NemoClaw CLI", + "Docker resources", + "Ollama models", + "State and binaries", + ]); + expect(flattenUninstallPlan(plan)).toEqual( + expect.arrayContaining([ + { kind: "delete-openshell-provider", name: "nvidia-nim" }, + { kind: "destroy-openshell-gateway", name: "nemoclaw" }, + { kind: "delete-shim", reason: "installer-managed wrapper contents" }, + { kind: "delete-docker-volume", name: "openshell-cluster-nemoclaw" }, + { kind: "preserve-ollama-models", names: ["nemotron-3-super:120b", "nemotron-3-nano:30b"] }, + { kind: "delete-openshell-binary", path: "/usr/local/bin/openshell" }, + ]), + ); + }); + + it("respects delete-models, keep-openshell, custom gateway, and foreign shim decisions", () => { + const paths = defaultUninstallPaths({ home: "/home/test", xdgBinHome: "/bin" }); + const actions = flattenUninstallPlan( + buildUninstallPlan(paths, { + deleteModels: true, + gatewayName: "custom", + keepOpenShell: true, + shim: { kind: "preserve-foreign-file", reason: "regular file is not an installer-managed shim", remove: false }, + }), + ); + + expect(actions).toEqual(expect.arrayContaining([{ kind: "delete-docker-volume", name: "openshell-cluster-custom" }])); + expect(actions).toEqual(expect.arrayContaining([{ kind: "delete-ollama-model", name: "nemotron-3-super:120b" }])); + expect(actions).toEqual( + expect.arrayContaining([{ kind: "preserve-openshell-binary", paths: ["/usr/local/bin/openshell", "/bin/openshell"] }]), + ); + expect(actions).toEqual( + expect.arrayContaining([{ kind: "preserve-shim", reason: "regular file is not an installer-managed shim" }]), + ); + }); +}); diff --git a/src/lib/domain/uninstall/plan.ts b/src/lib/domain/uninstall/plan.ts new file mode 100644 index 00000000000..aaa876c4f63 --- /dev/null +++ b/src/lib/domain/uninstall/plan.ts @@ -0,0 +1,106 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + DEFAULT_GATEWAY_NAME, + gatewayVolumeCandidates, + NEMOCLAW_OLLAMA_MODELS, + NEMOCLAW_PROVIDERS, + type UninstallPaths, + uninstallStatePaths, +} from "./paths"; +import type { ShimClassification } from "./shims"; + +export interface UninstallPlanOptions { + deleteModels: boolean; + gatewayName?: string; + keepOpenShell: boolean; + shim?: ShimClassification; +} + +export type UninstallPlanAction = + | { kind: "delete-docker-volume"; name: string } + | { kind: "delete-ollama-model"; name: string } + | { kind: "delete-openshell-binary"; path: string } + | { kind: "delete-openshell-provider"; name: string } + | { kind: "delete-path"; path: string } + | { kind: "delete-runtime-glob"; pattern: string } + | { kind: "delete-shim"; reason: string } + | { kind: "destroy-openshell-gateway"; name: string } + | { kind: "preserve-ollama-models"; names: string[] } + | { kind: "preserve-openshell-binary"; paths: string[] } + | { kind: "preserve-shim"; reason: string } + | { kind: "stop-helper-services" } + | { kind: "stop-openshell-forward-processes" } + | { kind: "stop-orphaned-openshell-processes" } + | { kind: "uninstall-npm-package"; name: "nemoclaw" }; + +export interface UninstallPlanStep { + actions: UninstallPlanAction[]; + name: string; +} + +export interface UninstallPlan { + gatewayName: string; + steps: UninstallPlanStep[]; +} + +function cliActions(shim?: ShimClassification): UninstallPlanAction[] { + const actions: UninstallPlanAction[] = [{ kind: "uninstall-npm-package", name: "nemoclaw" }]; + if (!shim) return actions; + actions.push(shim.remove ? { kind: "delete-shim", reason: shim.reason } : { kind: "preserve-shim", reason: shim.reason }); + return actions; +} + +export function buildUninstallPlan(paths: UninstallPaths, options: UninstallPlanOptions): UninstallPlan { + const gatewayName = options.gatewayName || DEFAULT_GATEWAY_NAME; + return { + gatewayName, + steps: [ + { + name: "Stopping services", + actions: [ + { kind: "stop-helper-services" }, + { kind: "delete-runtime-glob", pattern: paths.helperServiceGlob }, + { kind: "stop-openshell-forward-processes" }, + { kind: "stop-orphaned-openshell-processes" }, + ], + }, + { + name: "OpenShell resources", + actions: [ + ...NEMOCLAW_PROVIDERS.map((name) => ({ kind: "delete-openshell-provider" as const, name })), + { kind: "destroy-openshell-gateway", name: gatewayName }, + ], + }, + { + name: "NemoClaw CLI", + actions: cliActions(options.shim), + }, + { + name: "Docker resources", + actions: gatewayVolumeCandidates(gatewayName).map((name) => ({ kind: "delete-docker-volume" as const, name })), + }, + { + name: "Ollama models", + actions: options.deleteModels + ? NEMOCLAW_OLLAMA_MODELS.map((name) => ({ kind: "delete-ollama-model" as const, name })) + : [{ kind: "preserve-ollama-models", names: [...NEMOCLAW_OLLAMA_MODELS] }], + }, + { + name: "State and binaries", + actions: [ + ...paths.runtimeTempGlobs.map((pattern) => ({ kind: "delete-runtime-glob" as const, pattern })), + ...(options.keepOpenShell + ? [{ kind: "preserve-openshell-binary" as const, paths: paths.openshellInstallPaths }] + : paths.openshellInstallPaths.map((path) => ({ kind: "delete-openshell-binary" as const, path }))), + ...uninstallStatePaths(paths).map((path) => ({ kind: "delete-path" as const, path })), + ], + }, + ], + }; +} + +export function flattenUninstallPlan(plan: UninstallPlan): UninstallPlanAction[] { + return plan.steps.flatMap((step) => step.actions); +} diff --git a/src/lib/domain/uninstall/shims.test.ts b/src/lib/domain/uninstall/shims.test.ts new file mode 100644 index 00000000000..18b01332ba1 --- /dev/null +++ b/src/lib/domain/uninstall/shims.test.ts @@ -0,0 +1,77 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { classifyNemoclawShim, DEV_SHIM_MARKER, isDevShimContents, isInstallerManagedWrapperContents } from "./shims"; + +function wrapper(extra = ""): string { + return ["#!/usr/bin/env bash", 'export PATH="/tmp/node-bin:$PATH"', 'exec "/tmp/prefix/bin/nemoclaw" "$@"', extra] + .filter((line) => line !== undefined) + .join("\n"); +} + +describe("uninstall shim classification", () => { + it("classifies symlinks as managed shims", () => { + expect(classifyNemoclawShim({ exists: true, isFile: false, isSymlink: true })).toMatchObject({ + kind: "managed-symlink", + remove: true, + }); + }); + + it("recognizes installer-managed wrapper files", () => { + const contents = wrapper(""); + expect(isInstallerManagedWrapperContents(contents)).toBe(true); + expect(classifyNemoclawShim({ contents, exists: true, isFile: true, isSymlink: false })).toMatchObject({ + kind: "managed-wrapper", + remove: true, + }); + }); + + it("recognizes dev-install shims from npm-link-or-shim", () => { + const contents = [ + "#!/usr/bin/env bash", + DEV_SHIM_MARKER, + 'export PATH="/tmp/node-bin:$PATH"', + 'exec "/tmp/checkout/bin/nemoclaw.js" "$@"', + "", + ].join("\n"); + + expect(isDevShimContents(contents)).toBe(true); + expect(classifyNemoclawShim({ contents, exists: true, isFile: true, isSymlink: false })).toMatchObject({ + kind: "managed-dev-shim", + remove: true, + }); + }); + + it("preserves user-managed and wrapper-like files with extra content", () => { + expect( + classifyNemoclawShim({ + contents: "#!/usr/bin/env bash\necho user\n", + exists: true, + isFile: true, + isSymlink: false, + }), + ).toMatchObject({ kind: "preserve-foreign-file", remove: false }); + + expect( + classifyNemoclawShim({ + contents: wrapper("echo user-extra"), + exists: true, + isFile: true, + isSymlink: false, + }), + ).toMatchObject({ kind: "preserve-foreign-file", remove: false }); + }); + + it("treats missing and non-regular paths as no-remove cases", () => { + expect(classifyNemoclawShim({ exists: false, isFile: false, isSymlink: false })).toMatchObject({ + kind: "missing", + remove: false, + }); + expect(classifyNemoclawShim({ exists: true, isFile: false, isSymlink: false })).toMatchObject({ + kind: "unsupported-path-type", + remove: false, + }); + }); +}); diff --git a/src/lib/domain/uninstall/shims.ts b/src/lib/domain/uninstall/shims.ts new file mode 100644 index 00000000000..9d58d4e7349 --- /dev/null +++ b/src/lib/domain/uninstall/shims.ts @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export const DEV_SHIM_MARKER = "# NemoClaw dev-shim - managed by scripts/npm-link-or-shim.sh"; + +export type ShimKind = + | "missing" + | "managed-dev-shim" + | "managed-symlink" + | "managed-wrapper" + | "preserve-foreign-file" + | "unsupported-path-type"; + +export interface ShimInput { + contents?: string; + exists: boolean; + isFile: boolean; + isSymlink: boolean; +} + +export interface ShimClassification { + kind: ShimKind; + remove: boolean; + reason: string; +} + +function stripCommandSubstitutionTrailingNewlines(contents: string): string { + // Bash command substitution strips trailing newlines before the case pattern in uninstall.sh runs. + return contents.replace(/\n+$/u, ""); +} + +export function isInstallerManagedWrapperContents(contents: string): boolean { + const normalized = stripCommandSubstitutionTrailingNewlines(contents); + const lines = normalized.split("\n"); + if (lines.length !== 3) return false; + const [shebang, pathLine, execLine] = lines; + return ( + shebang === "#!/usr/bin/env bash" && + pathLine.startsWith('export PATH="') && + pathLine.endsWith(':$PATH"') && + execLine.startsWith('exec "') && + execLine.endsWith('/nemoclaw" "$@"') + ); +} + +export function isDevShimContents(contents: string): boolean { + const normalized = stripCommandSubstitutionTrailingNewlines(contents); + const lines = normalized.split("\n"); + if (lines.length !== 4) return false; + const [shebang, marker, pathLine, execLine] = lines; + return ( + shebang === "#!/usr/bin/env bash" && + marker === DEV_SHIM_MARKER && + pathLine.startsWith('export PATH="') && + pathLine.endsWith(':$PATH"') && + execLine.startsWith('exec "') && + execLine.endsWith('" "$@"') + ); +} + +export function classifyNemoclawShim(input: ShimInput): ShimClassification { + if (!input.exists) { + return { kind: "missing", remove: false, reason: "shim path does not exist" }; + } + + if (input.isSymlink) { + return { kind: "managed-symlink", remove: true, reason: "shim path is a symlink" }; + } + + if (!input.isFile) { + return { kind: "unsupported-path-type", remove: false, reason: "shim path is not a regular file" }; + } + + const contents = input.contents ?? ""; + if (isInstallerManagedWrapperContents(contents)) { + return { kind: "managed-wrapper", remove: true, reason: "installer-managed wrapper contents" }; + } + + if (isDevShimContents(contents)) { + return { kind: "managed-dev-shim", remove: true, reason: "NemoClaw dev shim marker" }; + } + + return { + kind: "preserve-foreign-file", + remove: false, + reason: "regular file is not an installer-managed shim", + }; +}