diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index dea7a34fbe1..27b101e3b17 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -837,6 +837,33 @@ describe("update-cli", () => { ); }); + it("skips package-manager updates when the installed version already matches the target", async () => { + const tempDir = createCaseDir("openclaw-update"); + mockPackageInstallStatus(tempDir); + readPackageVersion.mockResolvedValue("2026.4.22"); + vi.mocked(resolveNpmChannelTag).mockResolvedValue({ + tag: "latest", + version: "2026.4.22", + }); + + await updateCommand({ yes: true }); + + const installCalls = vi + .mocked(runCommandWithTimeout) + .mock.calls.filter( + ([argv]) => Array.isArray(argv) && argv[0] === "npm" && argv[1] === "i" && argv[2] === "-g", + ); + expect(installCalls).toEqual([]); + expect(syncPluginsForUpdateChannel).not.toHaveBeenCalled(); + expect(updateNpmInstalledPlugins).not.toHaveBeenCalled(); + expect(replaceConfigFile).not.toHaveBeenCalled(); + expect(runRestartScript).not.toHaveBeenCalled(); + expect(runDaemonRestart).not.toHaveBeenCalled(); + expect(defaultRuntime.exit).toHaveBeenCalledWith(0); + const logs = vi.mocked(defaultRuntime.log).mock.calls.map((call) => String(call[0])); + expect(logs.join("\n")).toContain("already-current"); + }); + it("blocks package updates when the target requires a newer Node runtime", async () => { mockPackageInstallStatus(createCaseDir("openclaw-update")); vi.mocked(fetchNpmPackageTargetStatus).mockResolvedValue({ diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 275de1afed6..962513f6476 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -1055,6 +1055,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { let downgradeRisk = false; let fallbackToLatest = false; let packageInstallSpec: string | null = null; + let packageAlreadyCurrent = false; if (updateInstallKind !== "git") { currentVersion = switchToPackage ? null : await readPackageVersion(root); @@ -1069,6 +1070,13 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { } const cmp = currentVersion && targetVersion ? compareSemverStrings(currentVersion, targetVersion) : null; + packageAlreadyCurrent = + updateInstallKind === "package" && + !switchToPackage && + currentVersion != null && + targetVersion != null && + currentVersion === targetVersion && + (requestedChannel === null || requestedChannel === storedChannel); downgradeRisk = canResolveRegistryVersionForPackageTarget(tag) && !fallbackToLatest && @@ -1103,16 +1111,20 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { actions.push(`Switch install mode from git to package manager (${mode})`); } else if (updateInstallKind === "git") { actions.push(`Run git update flow on channel ${channel} (fetch/rebase/build/doctor)`); + } else if (packageAlreadyCurrent) { + actions.push(`Skip package update; current version already matches ${targetVersion}`); } else { actions.push(`Run global package manager update with spec ${packageInstallSpec ?? tag}`); } - actions.push("Run plugin update sync after core update"); - actions.push("Refresh shell completion cache (if needed)"); - actions.push( - shouldRestart - ? "Restart gateway service and run doctor checks" - : "Skip restart (because --no-restart is set)", - ); + if (!packageAlreadyCurrent) { + actions.push("Run plugin update sync after core update"); + actions.push("Refresh shell completion cache (if needed)"); + actions.push( + shouldRestart + ? "Restart gateway service and run doctor checks" + : "Skip restart (because --no-restart is set)", + ); + } const notes: string[] = []; if (opts.tag && updateInstallKind === "git") { @@ -1183,6 +1195,25 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { ); } + if (packageAlreadyCurrent) { + const mode = isPackageManagerUpdateMode(updateStatus.packageManager) + ? updateStatus.packageManager + : "unknown"; + const result: UpdateRunResult = { + status: "skipped", + mode, + root, + reason: "already-current", + before: { version: currentVersion }, + after: { version: currentVersion }, + steps: [], + durationMs: 0, + }; + printResult(result, opts); + defaultRuntime.exit(0); + return; + } + if (updateInstallKind === "package") { const runtimePreflightError = await resolvePackageRuntimePreflightError({ tag,