diff --git a/docs/reference/commands-nemohermes.mdx b/docs/reference/commands-nemohermes.mdx index 4f0f716d472..58271642f31 100644 --- a/docs/reference/commands-nemohermes.mdx +++ b/docs/reference/commands-nemohermes.mdx @@ -1352,13 +1352,14 @@ curl -fsSL https://www.nvidia.com/nemoclaw.sh | bash ``` ```bash -nemohermes update [--check] [--yes|-y] +nemohermes update [--check] [--fresh] [--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 | +| `--check` | Show the current version, latest maintained version, install type, and maintained update command without changing anything. | +| `--fresh` | Reinstall the maintained build even when already up to date (clean re-clone of `~/.nemoclaw/source`); useful to repair a broken-but-current install. Does not reset onboarding state. | +| `--yes`, `-y` | Skip the confirmation prompt and run the maintained installer flow. | `nemohermes update` updates the host-side NemoClaw installation. The maintained installer flow follows the admin-promoted `lkg` release tag by default, so it may trail the newest semver or `latest` tag while validation completes. diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 0dac54e74c2..b4b89871d38 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1721,13 +1721,14 @@ curl -fsSL https://www.nvidia.com/nemoclaw.sh | bash ``` ```bash -$$nemoclaw update [--check] [--yes|-y] +$$nemoclaw update [--check] [--fresh] [--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 | +| `--check` | Show the current version, latest maintained version, install type, and maintained update command without changing anything. | +| `--fresh` | Reinstall the maintained build even when already up to date (clean re-clone of `~/.nemoclaw/source`); useful to repair a broken-but-current install. Does not reset onboarding state. | +| `--yes`, `-y` | Skip the confirmation prompt and run the maintained installer flow. | `$$nemoclaw update` updates the host-side NemoClaw installation. The maintained installer flow follows the admin-promoted `lkg` release tag by default, so it may trail the newest semver or `latest` tag while validation completes. diff --git a/src/commands/update.ts b/src/commands/update.ts index 222d995ee00..14a6d7888f6 100644 --- a/src/commands/update.ts +++ b/src/commands/update.ts @@ -16,16 +16,21 @@ export default class UpdateCommand extends NemoClawCommand { 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 usage = ["update [--check] [--fresh] [--yes|-y]"]; static examples = [ "<%= config.bin %> update --check", "<%= config.bin %> update", + "<%= config.bin %> update --fresh", "<%= config.bin %> update --yes", ]; static flags = { check: Flags.boolean({ description: "Check update availability without running the installer", }), + fresh: Flags.boolean({ + description: + "Reinstall the maintained build even when already up to date (clean re-clone; useful to repair a broken install)", + }), yes: yesFlag(), }; @@ -34,6 +39,7 @@ export default class UpdateCommand extends NemoClawCommand { const result = await runUpdateAction( { check: flags.check === true, + fresh: flags.fresh === true, yes: flags.yes === true, }, { diff --git a/src/lib/actions/update.test.ts b/src/lib/actions/update.test.ts index 28a4fa3d0c5..b90d3235f56 100644 --- a/src/lib/actions/update.test.ts +++ b/src/lib/actions/update.test.ts @@ -143,6 +143,97 @@ describe("runUpdateAction", () => { } }); + it("does not run the installer when already up to date without --fresh", async () => { + const spawnSyncImpl = vi.fn(); + const log = vi.fn(); + + const result = await runUpdateAction( + { yes: true }, + { + currentVersion: () => "0.2.0", + getLatestVersion: () => "0.2.0", + isSourceCheckout: () => false, + log, + spawnSyncImpl, + }, + ); + + expect(result.updateAvailable).toBe(false); + expect(result.ranInstaller).toBe(false); + expect(spawnSyncImpl).not.toHaveBeenCalled(); + expect(log).toHaveBeenCalledWith(expect.stringContaining("already up to date")); + }); + + it("reinstalls even when already up to date with --fresh (#5960)", async () => { + const spawnSyncImpl = vi.fn( + () => ({ status: 0, stdout: "", stderr: "", signal: null }) as never, + ); + const log = vi.fn(); + + const result = await runUpdateAction( + { fresh: true, yes: true }, + { + currentVersion: () => "0.2.0", + getLatestVersion: () => "0.2.0", + isSourceCheckout: () => false, + log, + spawnSyncImpl, + }, + ); + + expect(result.updateAvailable).toBe(false); + expect(result.ranInstaller).toBe(true); + expect(spawnSyncImpl).toHaveBeenCalledWith( + "bash", + ["-o", "pipefail", "-lc", NEMOCLAW_UPDATE_COMMAND], + expect.objectContaining({ stdio: "inherit" }), + ); + expect(log).toHaveBeenCalledWith(expect.stringContaining("reinstalling anyway (--fresh)")); + }); + + it("does not announce a --fresh reinstall when the user declines the prompt (#5960)", async () => { + const spawnSyncImpl = vi.fn(); + const log = vi.fn(); + const prompt = vi.fn(async () => "n"); + + const result = await runUpdateAction( + { fresh: true }, + { + currentVersion: () => "0.2.0", + getLatestVersion: () => "0.2.0", + isSourceCheckout: () => false, + log, + prompt, + spawnSyncImpl, + }, + ); + + expect(result.ranInstaller).toBe(false); + expect(spawnSyncImpl).not.toHaveBeenCalled(); + // The reinstall claim must not print before/without confirmation. + expect(log).not.toHaveBeenCalledWith(expect.stringContaining("reinstalling anyway")); + expect(log).toHaveBeenCalledWith(expect.stringContaining("Update cancelled")); + }); + + it("still refuses --fresh from a developer source checkout (no reinstall)", async () => { + const spawnSyncImpl = vi.fn(); + + const result = await runUpdateAction( + { fresh: true, yes: true }, + { + currentVersion: () => "0.2.0", + error: vi.fn(), + getLatestVersion: () => "0.2.0", + isSourceCheckout: () => true, + log: vi.fn(), + spawnSyncImpl, + }, + ); + + expect(result.ranInstaller).toBe(false); + expect(spawnSyncImpl).not.toHaveBeenCalled(); + }); + it("prompts before running the maintained installer", async () => { const prompt = vi.fn(async () => "yes"); const spawnSyncImpl = vi.fn( @@ -232,6 +323,7 @@ describe("runUpdateAction", () => { ...process.env, BASH_ENV: "/tmp/review-bash-env", ENV: "/tmp/review-env", + NEMOCLAW_FRESH: "1", NEMOCLAW_INSTALL_REF: "refs/heads/not-maintained", NEMOCLAW_INSTALL_TAG: "not-maintained", }, @@ -248,6 +340,7 @@ describe("runUpdateAction", () => { const options = calls[0]?.[2]; expect(options?.env?.BASH_ENV).toBeUndefined(); expect(options?.env?.ENV).toBeUndefined(); + expect(options?.env?.NEMOCLAW_FRESH).toBeUndefined(); expect(options?.env?.NEMOCLAW_INSTALL_REF).toBeUndefined(); expect(options?.env?.NEMOCLAW_INSTALL_TAG).toBeUndefined(); }); diff --git a/src/lib/actions/update.ts b/src/lib/actions/update.ts index 329fc6747ad..5a6592f2895 100644 --- a/src/lib/actions/update.ts +++ b/src/lib/actions/update.ts @@ -1,7 +1,7 @@ // 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 { type SpawnSyncReturns, spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -24,6 +24,13 @@ type SpawnSyncFn = ( export interface RunUpdateOptions { check?: boolean; + /** + * Reinstall the maintained build even when already up to date. The installer + * re-clones `~/.nemoclaw/source`, so this repairs a broken-but-current + * install. Does not reset onboarding state (distinct from the installer's + * onboard-scoped `--fresh`/`NEMOCLAW_FRESH`). + */ + fresh?: boolean; yes?: boolean; } @@ -198,6 +205,7 @@ function updateInstallerEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const next = { ...env }; delete next.BASH_ENV; delete next.ENV; + delete next.NEMOCLAW_FRESH; delete next.NEMOCLAW_INSTALL_REF; delete next.NEMOCLAW_INSTALL_TAG; return next; @@ -256,7 +264,7 @@ export async function runUpdateAction( }; } - if (available === false) { + if (available === false && !options.fresh) { log(` ${branding.displayName} is already up to date.`); return { currentVersion, @@ -312,6 +320,15 @@ export async function runUpdateAction( } } + // Only announce the --fresh reinstall once the user has actually confirmed + // (or passed --yes): before this point the run could still be declined, and + // claiming a reinstall was happening would be untrue (CodeRabbit review #5963). + if (available === false && options.fresh) { + log( + ` ${branding.displayName} is already up to date; reinstalling anyway (--fresh) for a clean re-clone.`, + ); + } + log(` Running maintained ${branding.displayName} installer...`); const result = (deps.spawnSyncImpl ?? spawnSync)( "bash", diff --git a/src/lib/cli/public-display-defaults.ts b/src/lib/cli/public-display-defaults.ts index 8c15407c53a..e3c5e080bc5 100644 --- a/src/lib/cli/public-display-defaults.ts +++ b/src/lib/cli/public-display-defaults.ts @@ -543,7 +543,7 @@ const PUBLIC_DISPLAY_LAYOUT: Record = { { group: "Upgrade", order: 40, - flags: "(--check, --yes|-y)", + flags: "(--check, --fresh, --yes|-y)", }, ], "upgrade-sandboxes": [ diff --git a/test/cli/dispatch-basics.test.ts b/test/cli/dispatch-basics.test.ts index ec3fe2d9569..e0d7c20d93a 100644 --- a/test/cli/dispatch-basics.test.ts +++ b/test/cli/dispatch-basics.test.ts @@ -130,7 +130,7 @@ describe("CLI dispatch", () => { 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("(--check, --fresh, --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/update.test.ts b/test/update.test.ts index 8dc0e6d0836..9626cf8b199 100644 --- a/test/update.test.ts +++ b/test/update.test.ts @@ -25,13 +25,13 @@ describe("nemoclaw update 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\)/, + /nemoclaw update\s+Run the maintained NemoClaw installer update flow\s+\(--check, --fresh, --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("update [--check] [--fresh] [--yes|-y]"); expect(output).toContain("--check"); expect(output).toContain("--yes"); }); @@ -39,11 +39,11 @@ describe("nemoclaw update command", () => { 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\)/, + /nemohermes update\s+Run the maintained NemoHermes installer update flow\s+\(--check, --fresh, --yes\|-y\)/, ); const updateHelp = execSync(`node "${HERMES_CLI}" update --help`, { encoding: "utf-8" }); - expect(updateHelp).toContain("$ nemohermes update [--check] [--yes|-y]"); + expect(updateHelp).toContain("$ nemohermes update [--check] [--fresh] [--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"); @@ -52,13 +52,13 @@ describe("nemoclaw update command", () => { it("renders NemoDeepAgents command names and product copy for the Deep Agents alias", () => { const rootHelp = execSync(`"${DEEPAGENTS_CLI}" help`, { encoding: "utf-8" }); expect(rootHelp).toMatch( - /nemo-deepagents update\s+Run the maintained NemoDeepAgents installer update flow\s+\(--check, --yes\|-y\)/, + /nemo-deepagents update\s+Run the maintained NemoDeepAgents installer update flow\s+\(--check, --fresh, --yes\|-y\)/, ); const updateHelp = execSync(`"${DEEPAGENTS_CLI}" update --help`, { encoding: "utf-8", }); - expect(updateHelp).toContain("$ nemo-deepagents update [--check] [--yes|-y]"); + expect(updateHelp).toContain("$ nemo-deepagents update [--check] [--fresh] [--yes|-y]"); expect(updateHelp).toContain("Run the maintained NemoDeepAgents installer update flow"); expect(updateHelp).toContain("Check for a NemoDeepAgents CLI update"); expect(updateHelp).not.toContain("NemoClaw CLI update");