diff --git a/docs/manage-sandboxes/lifecycle.md b/docs/manage-sandboxes/lifecycle.md index 7fc1430908e..3b5f8fadc30 100644 --- a/docs/manage-sandboxes/lifecycle.md +++ b/docs/manage-sandboxes/lifecycle.md @@ -213,11 +213,14 @@ The upgrade flow is non-destructive by default because NemoClaw preserves manife ```console $ nemoclaw snapshot create --name pre-upgrade # optional, recommended -$ curl -fsSL https://www.nvidia.com/nemoclaw.sh | bash # updates CLI; auto-upgrades stale running sandboxes +$ nemoclaw update --yes # updates CLI through the maintained installer flow $ nemoclaw upgrade-sandboxes --check # verify or list remaining stale/unknown sandboxes $ nemoclaw upgrade-sandboxes # manually rebuild remaining stale running sandboxes ``` +`nemoclaw update` is the CLI wrapper around the same installer path as `curl -fsSL https://www.nvidia.com/nemoclaw.sh | bash`. +Use `nemoclaw update --check` when you only want to inspect version state and see the maintained update command. + For scripted manual rebuilds, use `nemoclaw upgrade-sandboxes --auto` to skip the confirmation prompt. If the upgraded sandbox needs its workspace state reverted, restore the pre-upgrade snapshot into the running sandbox. diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 41f0faf5144..11a3718d258 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -643,6 +643,28 @@ If an archive command reports partial output while still producing usable data, If any required state path still cannot be backed up, `rebuild` exits before destroying the original sandbox. After restore, the command runs `openclaw doctor --fix` for cross-version structure repair. +### `nemoclaw update` + +Check for a NemoClaw CLI update and, when requested, run the maintained installer flow. +This command is a discoverable CLI wrapper around the supported installer path: + +```console +$ curl -fsSL https://www.nvidia.com/nemoclaw.sh | bash +``` + +```console +$ nemoclaw update [--check] [--yes|-y] +``` + +| Flag | Description | +|------|-------------| +| `--check` | Show the current version, latest maintained version, install type, and maintained update command without changing anything | +| `--yes`, `-y` | Skip the confirmation prompt and run the maintained installer flow | + +`nemoclaw update` updates the host-side NemoClaw installation. +It does not replace `nemoclaw upgrade-sandboxes`; use that command to inspect or rebuild existing sandboxes after the CLI has been updated. +When the command is running from a source checkout, it reports that state and does not replace the checkout with a global package install. + ### `nemoclaw upgrade-sandboxes` Rebuild sandboxes whose base image is older than the one currently pinned by NemoClaw. diff --git a/src/commands/update.ts b/src/commands/update.ts new file mode 100644 index 00000000000..6e113098538 --- /dev/null +++ b/src/commands/update.ts @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import Command from "../lib/commands/maintenance/update"; +import { CLI_DISPLAY_NAME } from "../lib/cli/branding"; +import { withCommandDisplay } from "../lib/cli/command-display"; + +export default withCommandDisplay(Command, [ + { + usage: "nemoclaw update", + description: `Run the maintained ${CLI_DISPLAY_NAME} installer update flow`, + flags: "(--check, --yes|-y)", + group: "Upgrade", + scope: "global", + order: 40, + }, +]); diff --git a/src/lib/actions/update.test.ts b/src/lib/actions/update.test.ts new file mode 100644 index 00000000000..abfc2cae230 --- /dev/null +++ b/src/lib/actions/update.test.ts @@ -0,0 +1,307 @@ +// 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, vi } from "vitest"; + +import { + detectInstallType, + getLatestNemoClawVersionFromGitLatestTag, + NEMOCLAW_UPDATE_COMMAND, + runUpdateAction, +} from "./update"; + +describe("runUpdateAction", () => { + it("--check reports update availability without running the installer", async () => { + const spawnSyncImpl = vi.fn(); + const log = vi.fn(); + + const result = await runUpdateAction( + { check: true }, + { + currentVersion: () => "0.1.0", + getLatestVersion: () => "0.2.0", + isSourceCheckout: () => false, + log, + spawnSyncImpl, + }, + ); + + expect(result).toEqual( + expect.objectContaining({ + ranInstaller: false, + status: 0, + updateAvailable: true, + }), + ); + expect(spawnSyncImpl).not.toHaveBeenCalled(); + expect(log).toHaveBeenCalledWith(expect.stringContaining("Current NemoClaw version: 0.1.0")); + expect(log).toHaveBeenCalledWith(expect.stringContaining("Latest maintained version: 0.2.0")); + }); + + it("--check renders NemoHermes branding and installer guidance when the Hermes alias is active", async () => { + const log = vi.fn(); + + const result = await runUpdateAction( + { check: true }, + { + currentVersion: () => "0.1.0", + env: { ...process.env, NEMOCLAW_AGENT: "hermes" }, + getLatestVersion: () => "0.2.0", + isSourceCheckout: () => false, + log, + spawnSyncImpl: vi.fn(), + }, + ); + + expect(result.status).toBe(0); + expect(log).toHaveBeenCalledWith(expect.stringContaining("Current NemoHermes version: 0.1.0")); + expect(log).toHaveBeenCalledWith( + expect.stringContaining("curl -fsSL https://www.nvidia.com/nemoclaw.sh | NEMOCLAW_AGENT=hermes bash"), + ); + }); + + it("does not run the installer for developer source checkouts", async () => { + const error = vi.fn(); + const spawnSyncImpl = vi.fn(); + + const result = await runUpdateAction( + { yes: true }, + { + currentVersion: () => "0.1.0", + error, + getLatestVersion: () => "0.2.0", + isSourceCheckout: () => true, + log: vi.fn(), + spawnSyncImpl, + }, + ); + + expect(result.status).toBe(1); + expect(result.ranInstaller).toBe(false); + expect(error).toHaveBeenCalledWith(expect.stringContaining("source checkout")); + expect(spawnSyncImpl).not.toHaveBeenCalled(); + }); + + it("allows the installer-managed clone under ~/.nemoclaw/source", async () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-update-home-")); + try { + const rootDir = path.join(home, ".nemoclaw", "source"); + fs.mkdirSync(path.join(rootDir, ".git"), { recursive: true }); + const spawnSyncImpl = vi.fn(() => ({ status: 0, stdout: "", stderr: "", signal: null } as never)); + + const result = await runUpdateAction( + { yes: true }, + { + currentVersion: () => "0.1.0", + env: { ...process.env, HOME: home }, + getLatestVersion: () => "0.2.0", + log: vi.fn(), + rootDir, + spawnSyncImpl, + }, + ); + + expect(result.installType).toBe("installer"); + expect(result.status).toBe(0); + expect(result.ranInstaller).toBe(true); + } finally { + fs.rmSync(home, { force: true, recursive: true }); + } + }); + + it("prompts before running the maintained installer", async () => { + const prompt = vi.fn(async () => "yes"); + const spawnSyncImpl = vi.fn(() => ({ status: 0, stdout: "", stderr: "", signal: null } as never)); + + const result = await runUpdateAction( + {}, + { + currentVersion: () => "0.1.0", + getLatestVersion: () => "0.2.0", + isSourceCheckout: () => false, + log: vi.fn(), + prompt, + spawnSyncImpl, + }, + ); + + expect(result.status).toBe(0); + expect(result.ranInstaller).toBe(true); + expect(prompt).toHaveBeenCalledWith(expect.stringContaining("Run the maintained NemoClaw installer")); + expect(spawnSyncImpl).toHaveBeenCalledWith( + "bash", + ["-o", "pipefail", "-lc", NEMOCLAW_UPDATE_COMMAND], + expect.objectContaining({ stdio: "inherit" }), + ); + }); + + it("--yes runs the maintained installer without prompting", async () => { + const prompt = vi.fn(async () => "no"); + const spawnSyncImpl = vi.fn(() => ({ status: 0, stdout: "", stderr: "", signal: null } as never)); + + const result = await runUpdateAction( + { yes: true }, + { + currentVersion: () => "0.1.0", + getLatestVersion: () => "0.2.0", + isSourceCheckout: () => false, + log: vi.fn(), + prompt, + spawnSyncImpl, + }, + ); + + expect(result.status).toBe(0); + expect(result.ranInstaller).toBe(true); + expect(prompt).not.toHaveBeenCalled(); + }); + + it("refuses to prompt in non-interactive mode without --yes", async () => { + const prompt = vi.fn(async () => "yes"); + const spawnSyncImpl = vi.fn(); + + const result = await runUpdateAction( + {}, + { + currentVersion: () => "0.1.0", + env: { ...process.env, NEMOCLAW_NON_INTERACTIVE: "1" }, + error: vi.fn(), + getLatestVersion: () => "0.2.0", + isSourceCheckout: () => false, + log: vi.fn(), + prompt, + spawnSyncImpl, + }, + ); + + expect(result.status).toBe(1); + expect(prompt).not.toHaveBeenCalled(); + expect(spawnSyncImpl).not.toHaveBeenCalled(); + }); + + it("does not pass shell startup or release override env into the installer shell", async () => { + const spawnSyncImpl = vi.fn(() => ({ status: 0, stdout: "", stderr: "", signal: null } as never)); + + await runUpdateAction( + { yes: true }, + { + currentVersion: () => "0.1.0", + env: { + ...process.env, + BASH_ENV: "/tmp/review-bash-env", + ENV: "/tmp/review-env", + NEMOCLAW_INSTALL_REF: "refs/heads/not-maintained", + NEMOCLAW_INSTALL_TAG: "not-maintained", + }, + getLatestVersion: () => "0.2.0", + isSourceCheckout: () => false, + log: vi.fn(), + spawnSyncImpl, + }, + ); + + const calls = spawnSyncImpl.mock.calls as unknown as Array< + [string, readonly string[], { env?: NodeJS.ProcessEnv }] + >; + const options = calls[0]?.[2]; + expect(options?.env?.BASH_ENV).toBeUndefined(); + expect(options?.env?.ENV).toBeUndefined(); + expect(options?.env?.NEMOCLAW_INSTALL_REF).toBeUndefined(); + expect(options?.env?.NEMOCLAW_INSTALL_TAG).toBeUndefined(); + }); + + it("preserves the Hermes agent selection while sanitizing installer env", async () => { + const spawnSyncImpl = vi.fn(() => ({ status: 0, stdout: "", stderr: "", signal: null } as never)); + const log = vi.fn(); + + await runUpdateAction( + { yes: true }, + { + currentVersion: () => "0.1.0", + env: { + ...process.env, + BASH_ENV: "/tmp/review-bash-env", + NEMOCLAW_AGENT: "hermes", + NEMOCLAW_INSTALL_REF: "refs/heads/not-maintained", + }, + getLatestVersion: () => "0.2.0", + isSourceCheckout: () => false, + log, + spawnSyncImpl, + }, + ); + + const calls = spawnSyncImpl.mock.calls as unknown as Array< + [string, readonly string[], { env?: NodeJS.ProcessEnv }] + >; + const options = calls[0]?.[2]; + expect(options?.env?.NEMOCLAW_AGENT).toBe("hermes"); + expect(options?.env?.BASH_ENV).toBeUndefined(); + expect(options?.env?.NEMOCLAW_INSTALL_REF).toBeUndefined(); + expect(log).toHaveBeenCalledWith(expect.stringContaining("Running maintained NemoHermes installer")); + expect(log).toHaveBeenCalledWith( + expect.stringContaining("Installer completed. Run `nemohermes upgrade-sandboxes --check`"), + ); + }); + + it("skips installer when package install is already current", async () => { + const spawnSyncImpl = vi.fn(); + + const result = await runUpdateAction( + { yes: true }, + { + currentVersion: () => "0.2.0", + getLatestVersion: () => "0.2.0", + isSourceCheckout: () => false, + log: vi.fn(), + spawnSyncImpl, + }, + ); + + expect(result.status).toBe(0); + expect(result.ranInstaller).toBe(false); + expect(spawnSyncImpl).not.toHaveBeenCalled(); + }); +}); + +describe("detectInstallType", () => { + it("classifies arbitrary git roots as source and installer roots as managed installs", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-update-detect-")); + try { + const managedRoot = path.join(home, ".nemoclaw", "source"); + const sourceRoot = path.join(home, "dev", "NemoClaw"); + const packageRoot = path.join(home, "package"); + fs.mkdirSync(path.join(managedRoot, ".git"), { recursive: true }); + fs.mkdirSync(path.join(sourceRoot, ".git"), { recursive: true }); + fs.mkdirSync(packageRoot, { recursive: true }); + + expect(detectInstallType(managedRoot, { ...process.env, HOME: home })).toBe("installer"); + expect(detectInstallType(sourceRoot, { ...process.env, HOME: home })).toBe("source"); + expect(detectInstallType(packageRoot, { ...process.env, HOME: home })).toBe("package"); + } finally { + fs.rmSync(home, { force: true, recursive: true }); + } + }); +}); + +describe("getLatestNemoClawVersionFromGitLatestTag", () => { + it("resolves the version tag that points at the maintained latest tag", () => { + const spawnSyncImpl = vi.fn(() => ({ + status: 0, + stdout: [ + "abc123\trefs/tags/latest", + "older\trefs/tags/v0.0.36", + "abc123\trefs/tags/v0.0.37", + "future\trefs/tags/v0.1.0", + ].join("\n"), + stderr: "", + signal: null, + }) as never); + + expect(getLatestNemoClawVersionFromGitLatestTag({ spawnSyncImpl })).toBe("0.0.37"); + }); +}); diff --git a/src/lib/actions/update.ts b/src/lib/actions/update.ts new file mode 100644 index 00000000000..54e133661dc --- /dev/null +++ b/src/lib/actions/update.ts @@ -0,0 +1,304 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync, type SpawnSyncReturns } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { versionGte } from "../domain/installer/version"; + +export const NEMOCLAW_INSTALLER_URL = "https://www.nvidia.com/nemoclaw.sh"; +export const NEMOCLAW_REPO_URL = "https://github.com/NVIDIA/NemoClaw.git"; +export const NEMOCLAW_UPDATE_COMMAND = `curl -fsSL ${NEMOCLAW_INSTALLER_URL} | bash`; + +type LogFn = (message?: string) => void; +type PromptFn = (question: string) => Promise; +type SpawnSyncFn = ( + command: string, + args: readonly string[], + options: { env?: NodeJS.ProcessEnv; stdio: "inherit" | "pipe"; encoding?: BufferEncoding }, +) => SpawnSyncReturns; + +export interface RunUpdateOptions { + check?: boolean; + yes?: boolean; +} + +export interface RunUpdateDeps { + currentVersion: () => string; + env?: NodeJS.ProcessEnv; + error?: LogFn; + getLatestVersion?: () => string | null; + isSourceCheckout?: () => boolean; + log?: LogFn; + prompt?: PromptFn; + rootDir?: string; + spawnSyncImpl?: SpawnSyncFn; +} + +export interface RunUpdateResult { + currentVersion: string; + installType: "installer" | "package" | "source"; + latestVersion: string | null; + ranInstaller: boolean; + status: number; + updateAvailable: boolean | null; +} + +interface UpdateBranding { + cliName: string; + displayName: string; + maintainedUpdateCommand: string; +} + +function trimOutput(value: string | Buffer | null | undefined): string { + return String(value ?? "").trim(); +} + +function updateBranding(env: NodeJS.ProcessEnv): UpdateBranding { + if (env.NEMOCLAW_AGENT === "hermes") { + return { + cliName: "nemohermes", + displayName: "NemoHermes", + maintainedUpdateCommand: `curl -fsSL ${NEMOCLAW_INSTALLER_URL} | NEMOCLAW_AGENT=hermes bash`, + }; + } + return { + cliName: "nemoclaw", + displayName: "NemoClaw", + maintainedUpdateCommand: NEMOCLAW_UPDATE_COMMAND, + }; +} + +function realOrResolved(inputPath: string): string { + try { + return fs.realpathSync(inputPath); + } catch { + return path.resolve(inputPath); + } +} + +function isSameOrChildPath(candidate: string, parent: string): boolean { + const relative = path.relative(parent, candidate); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +function managedInstallRoot(env: NodeJS.ProcessEnv): string { + const home = env.HOME || os.homedir(); + return path.join(realOrResolved(home), ".nemoclaw", "source"); +} + +export function detectInstallType( + rootDir: string, + env: NodeJS.ProcessEnv = process.env, +): RunUpdateResult["installType"] { + if (!fs.existsSync(path.join(rootDir, ".git"))) return "package"; + + const root = realOrResolved(rootDir); + if (isSameOrChildPath(root, managedInstallRoot(env))) return "installer"; + + return "source"; +} + +export function isSourceCheckout(rootDir: string, env: NodeJS.ProcessEnv = process.env): boolean { + return detectInstallType(rootDir, env) === "source"; +} + +export function getLatestNemoClawVersionFromGitLatestTag( + deps: { + env?: NodeJS.ProcessEnv; + gitCommand?: string; + repoUrl?: string; + spawnSyncImpl?: SpawnSyncFn; + } = {}, +): string | null { + const result = (deps.spawnSyncImpl ?? spawnSync)( + deps.gitCommand ?? "git", + [ + "ls-remote", + "--tags", + deps.repoUrl ?? NEMOCLAW_REPO_URL, + "refs/tags/latest", + "refs/tags/latest^{}", + "refs/tags/v*", + ], + { + encoding: "utf-8", + env: deps.env ?? process.env, + stdio: "pipe", + }, + ); + if (result.error || (result.status ?? 1) !== 0) return null; + + const versionsBySha = new Map(); + let latestSha: string | null = null; + for (const line of trimOutput(result.stdout).split(/\r?\n/)) { + const [sha, ref] = line.trim().split(/\s+/, 2); + if (!sha || !ref) continue; + if (ref === "refs/tags/latest^{}" || (ref === "refs/tags/latest" && !latestSha)) { + latestSha = sha; + continue; + } + const match = /^refs\/tags\/v(.+?)(\^\{\})?$/.exec(ref); + if (match?.[1]) versionsBySha.set(sha, match[1]); + } + return latestSha ? versionsBySha.get(latestSha) ?? null : null; +} + +function updateAvailable(currentVersion: string, latestVersion: string | null): boolean | null { + if (!latestVersion) return null; + return !versionGte(currentVersion, latestVersion); +} + +function printStatus(input: { + branding: UpdateBranding; + currentVersion: string; + installType: RunUpdateResult["installType"]; + latestVersion: string | null; + log: LogFn; + updateAvailable: boolean | null; +}): void { + input.log(` Current ${input.branding.displayName} version: ${input.currentVersion}`); + input.log(` Latest maintained version:${input.latestVersion ? ` ${input.latestVersion}` : " unknown"}`); + const installTypeLabel = + input.installType === "source" + ? "source checkout" + : input.installType === "installer" + ? "installer-managed clone" + : "package"; + input.log(` Install type: ${installTypeLabel}`); + input.log( + ` Update available: ${ + input.updateAvailable === null ? "unknown" : input.updateAvailable ? "yes" : "no" + }`, + ); + input.log(` Maintained update path: ${input.branding.maintainedUpdateCommand}`); +} + +function updateInstallerEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const next = { ...env }; + delete next.BASH_ENV; + delete next.ENV; + delete next.NEMOCLAW_INSTALL_REF; + delete next.NEMOCLAW_INSTALL_TAG; + return next; +} + +export async function runUpdateAction( + options: RunUpdateOptions, + deps: RunUpdateDeps, +): Promise { + const log = deps.log ?? console.log; + const error = deps.error ?? console.error; + const env = deps.env ?? process.env; + const rootDir = deps.rootDir ?? process.cwd(); + const currentVersion = deps.currentVersion(); + const branding = updateBranding(env); + const latestVersion = (deps.getLatestVersion ?? (() => getLatestNemoClawVersionFromGitLatestTag({ env })))(); + const installType = deps.isSourceCheckout + ? deps.isSourceCheckout() + ? "source" + : "package" + : detectInstallType(rootDir, env); + const available = updateAvailable(currentVersion, latestVersion); + + printStatus({ branding, currentVersion, installType, latestVersion, log, updateAvailable: available }); + + if (options.check) { + return { + currentVersion, + installType, + latestVersion, + ranInstaller: false, + status: 0, + updateAvailable: available, + }; + } + + if (installType === "source") { + error(" This command is running from a source checkout."); + error(" Update this checkout with git, or run the maintained installer outside the checkout."); + return { + currentVersion, + installType, + latestVersion, + ranInstaller: false, + status: 1, + updateAvailable: available, + }; + } + + if (available === false) { + log(` ${branding.displayName} is already up to date.`); + return { + currentVersion, + installType, + latestVersion, + ranInstaller: false, + status: 0, + updateAvailable: available, + }; + } + + if (!options.yes) { + if (env.NEMOCLAW_NON_INTERACTIVE === "1") { + error(" Refusing to prompt in non-interactive mode. Re-run with --yes to update."); + return { + currentVersion, + installType, + latestVersion, + ranInstaller: false, + status: 1, + updateAvailable: available, + }; + } + const prompt = deps.prompt; + if (!prompt) { + error(" Refusing to run the installer without confirmation. Re-run with --yes for non-interactive update."); + return { + currentVersion, + installType, + latestVersion, + ranInstaller: false, + status: 1, + updateAvailable: available, + }; + } + const answer = (await prompt(` Run the maintained ${branding.displayName} installer now? [y/N]: `)) + .trim() + .toLowerCase(); + if (answer !== "y" && answer !== "yes") { + log(" Update cancelled."); + return { + currentVersion, + installType, + latestVersion, + ranInstaller: false, + status: 0, + updateAvailable: available, + }; + } + } + + log(` Running maintained ${branding.displayName} installer...`); + const result = (deps.spawnSyncImpl ?? spawnSync)("bash", ["-o", "pipefail", "-lc", NEMOCLAW_UPDATE_COMMAND], { + env: updateInstallerEnv(env), + stdio: "inherit", + }); + const status = result.status ?? 1; + if (status === 0) { + log(` Installer completed. Run \`${branding.cliName} upgrade-sandboxes --check\` to verify sandbox state.`); + } else { + error(` Installer failed with exit ${status}.`); + } + + return { + currentVersion, + installType, + latestVersion, + ranInstaller: true, + status, + updateAvailable: available, + }; +} diff --git a/src/lib/cli/command-registry.test.ts b/src/lib/cli/command-registry.test.ts index 0a13eb90773..9813d7f5212 100644 --- a/src/lib/cli/command-registry.test.ts +++ b/src/lib/cli/command-registry.test.ts @@ -17,10 +17,10 @@ import type { CommandDef } from "./command-registry"; describe("command-registry", () => { describe("COMMANDS array", () => { - it("should contain exactly 52 commands", () => { - // 23 global (18 visible + 5 hidden help/version aliases) + it("should contain exactly 53 commands", () => { + // 24 global (19 visible + 5 hidden help/version aliases) // 29 sandbox (23 visible + 6 hidden shields/config) - expect(COMMANDS).toHaveLength(52); + expect(COMMANDS).toHaveLength(53); }); it("should have no duplicate usage strings", () => { @@ -39,9 +39,9 @@ describe("command-registry", () => { }); describe("globalCommands()", () => { - it("should return exactly 23 entries", () => { - // 18 visible + 5 hidden (help, --help, -h, --version, -v) - expect(globalCommands()).toHaveLength(23); + it("should return exactly 24 entries", () => { + // 19 visible + 5 hidden (help, --help, -h, --version, -v) + expect(globalCommands()).toHaveLength(24); }); it("every entry has scope global", () => { @@ -65,10 +65,10 @@ describe("command-registry", () => { }); describe("visibleCommands()", () => { - it("should exclude 11 hidden commands (41 visible)", () => { + it("should exclude 11 hidden commands (42 visible)", () => { // 5 hidden global (help, --help, -h, --version, -v) + // 6 hidden sandbox (shields×3, config get/set/rotate-token) - expect(visibleCommands()).toHaveLength(41); + expect(visibleCommands()).toHaveLength(42); }); it("no visible command has hidden=true", () => { @@ -146,10 +146,11 @@ describe("command-registry", () => { }); describe("globalCommandTokens()", () => { - it("returns the exact set of 20 tokens matching the old GLOBAL_COMMANDS", () => { + it("returns the exact set of 21 tokens matching the old GLOBAL_COMMANDS", () => { const tokens = globalCommandTokens(); const expected = new Set([ "onboard", + "update", "list", "deploy", "setup", diff --git a/src/lib/cli/oclif-dispatch.test.ts b/src/lib/cli/oclif-dispatch.test.ts index 20c8f20f521..045ce60be73 100644 --- a/src/lib/cli/oclif-dispatch.test.ts +++ b/src/lib/cli/oclif-dispatch.test.ts @@ -12,6 +12,11 @@ describe("resolveGlobalOclifDispatch", () => { commandId: "list", args: ["--json"], }); + expect(resolveGlobalOclifDispatch("update", ["--check"])).toEqual({ + kind: "oclif", + commandId: "update", + args: ["--check"], + }); expect(resolveGlobalOclifDispatch("tunnel", ["start"])).toEqual({ kind: "oclif", commandId: "tunnel:start", diff --git a/src/lib/cli/oclif-dispatch.ts b/src/lib/cli/oclif-dispatch.ts index e408e365f36..42bc3e8d888 100644 --- a/src/lib/cli/oclif-dispatch.ts +++ b/src/lib/cli/oclif-dispatch.ts @@ -100,6 +100,7 @@ const GLOBAL_ROUTES: Readonly> = { status: "status", debug: "debug", uninstall: "uninstall", + update: "update", list: "list", "backup-all": "backup-all", "upgrade-sandboxes": "upgrade-sandboxes", diff --git a/src/lib/commands/maintenance/update.ts b/src/lib/commands/maintenance/update.ts new file mode 100644 index 00000000000..3a03080a2e6 --- /dev/null +++ b/src/lib/commands/maintenance/update.ts @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Flags } from "@oclif/core"; + +import { runUpdateAction } from "../../actions/update"; +import { CLI_DISPLAY_NAME } from "../../cli/branding"; +import { NemoClawCommand } from "../../cli/nemoclaw-oclif-command"; +import { getVersion } from "../../core/version"; +import { prompt } from "../../credentials/store"; + +export default class UpdateCommand extends NemoClawCommand { + static id = "update"; + static strict = true; + static summary = `Run the maintained ${CLI_DISPLAY_NAME} installer update flow`; + static description = `Check for a ${CLI_DISPLAY_NAME} CLI update and run the maintained installer flow.`; + static usage = ["update [--check] [--yes|-y]"]; + static examples = [ + "<%= config.bin %> update --check", + "<%= config.bin %> update", + "<%= config.bin %> update --yes", + ]; + static flags = { + check: Flags.boolean({ description: "Check update availability without running the installer" }), + yes: Flags.boolean({ char: "y", description: "Skip the confirmation prompt" }), + }; + + public async run(): Promise { + const { flags } = await this.parse(UpdateCommand); + const result = await runUpdateAction( + { + check: flags.check === true, + yes: flags.yes === true, + }, + { + currentVersion: () => getVersion({ rootDir: this.config.root }), + env: process.env, + error: console.error, + log: console.log, + prompt, + rootDir: this.config.root, + }, + ); + if (result.status !== 0) { + this.exit(result.status); + } + } +} diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index d3099918e05..2ad225b8f48 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -4544,6 +4544,7 @@ const RESERVED_SANDBOX_NAMES = new Set([ "status", "debug", "uninstall", + "update", "credentials", "help", "sandbox", diff --git a/test/cli.test.ts b/test/cli.test.ts index f9ef2012bbe..a18f2a428f5 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -363,6 +363,8 @@ describe("CLI dispatch", () => { expect(r.out.includes("Compatibility Commands")).toBeTruthy(); expect(r.out).toContain("nemoclaw upgrade-sandboxes"); expect(r.out).toContain("(--check, --auto, --yes|-y)"); + expect(r.out).toContain("nemoclaw update"); + expect(r.out).toContain("(--check, --yes|-y)"); expect(r.out).toContain("nemoclaw gc"); expect(r.out).toContain("(--yes|-y|--force, --dry-run)"); expect(r.out).toContain("nemoclaw onboard"); diff --git a/test/onboard.test.ts b/test/onboard.test.ts index 609638fea27..e5dbeb10a2c 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -4281,6 +4281,14 @@ const { setupInference } = require(${onboardPath}); /const recordedSandboxName =\s*session\?\.steps\?\.sandbox\?\.status === "complete" \? session\?\.sandboxName \|\| null : null;\s*let sandboxName = recordedSandboxName \|\| requestedSandboxName \|\| null;\s*if \(sandboxName && RESERVED_SANDBOX_NAMES\.has\(sandboxName\)\) \{[\s\S]*?process\.exit\(1\);\s*\}/, ); }); + it("reserves update as a sandbox name because it is a global command", () => { + const source = fs.readFileSync( + path.join(import.meta.dirname, "..", "src", "lib", "onboard.ts"), + "utf-8", + ); + + assert.match(source, /const RESERVED_SANDBOX_NAMES = new Set\([\s\S]*?"update"[\s\S]*?\]\);/); + }); it("delegates sandbox-create progress streaming to the extracted helper module", () => { const onboardSource = fs.readFileSync( path.join(import.meta.dirname, "..", "src", "lib", "onboard.ts"), diff --git a/test/update.test.ts b/test/update.test.ts new file mode 100644 index 00000000000..393b32e22c8 --- /dev/null +++ b/test/update.test.ts @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { execSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +const TEST_DIR = path.dirname(fileURLToPath(import.meta.url)); +const CLI = path.join(TEST_DIR, "..", "bin", "nemoclaw.js"); +const HERMES_CLI = path.join(TEST_DIR, "..", "bin", "nemohermes.js"); + +describe("nemoclaw update command", () => { + it("appears in root help as an Upgrade command", () => { + const output = execSync(`node "${CLI}" help`, { encoding: "utf-8" }); + expect(output).toContain("Upgrade"); + expect(output).toMatch(/nemoclaw update\s+Run the maintained NemoClaw installer update flow\s+\(--check, --yes\|-y\)/); + }); + + it("prints oclif help for update-specific flags", () => { + const output = execSync(`node "${CLI}" update --help`, { encoding: "utf-8" }); + expect(output).toContain("update [--check] [--yes|-y]"); + expect(output).toContain("--check"); + expect(output).toContain("--yes"); + }); + + it("renders NemoHermes command names and product copy for the Hermes alias", () => { + const rootHelp = execSync(`node "${HERMES_CLI}" help`, { encoding: "utf-8" }); + expect(rootHelp).toMatch( + /nemohermes update\s+Run the maintained NemoHermes installer update flow\s+\(--check, --yes\|-y\)/, + ); + + const updateHelp = execSync(`node "${HERMES_CLI}" update --help`, { encoding: "utf-8" }); + expect(updateHelp).toContain("$ nemohermes update [--check] [--yes|-y]"); + expect(updateHelp).toContain("Run the maintained NemoHermes installer update flow"); + expect(updateHelp).toContain("Check for a NemoHermes CLI update"); + expect(updateHelp).not.toContain("NemoClaw CLI update"); + }); +});