From 42487d0dacc5abe537f371cee5f0e7d64421f703 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 09:49:44 +0100 Subject: [PATCH] fix(update): retry npm updates without optional deps --- CHANGELOG.md | 1 + src/cli/update-cli.test.ts | 70 ++++++++++++++++++++++++++++ src/cli/update-cli/update-command.ts | 21 ++++++++- 3 files changed, 91 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 188dc0bef90..bbd2cbd4a52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai - Gateway/Tailscale: let Tailscale-authenticated Control UI operator sessions with browser device identity skip the device-pairing round trip while still rejecting device-less and node-role connections. Refs #71986. Thanks @jokedul. - Doctor: honor `OPENCLAW_SERVICE_REPAIR_POLICY=external` by reporting gateway service health while skipping service install/start/restart/bootstrap, supervisor rewrites, and legacy service cleanup for externally managed environments. Thanks @shakkernerd. - CLI/update: run package post-update doctor with `--fix` so package updates repair config migrations before restart. Thanks @shakkernerd. +- CLI/update: retry failed npm global updates with `--omit=optional` and ignore the superseded first failure when the fallback succeeds. Thanks @shakkernerd. - Agents/Discord: keep raw `Agent failed before reply` runner failures out of Discord group/channel chats and show detailed runner errors in direct chats only when `/verbose` is enabled. Thanks @codex. - Package: include patched dependency files in the published npm package so downstream installs can resolve `patchedDependencies`. (#69224) Thanks @gucasbrg and @vincentkoc. - Plugins/channels: treat malformed bundled channel plugin loaders that return `undefined` as unavailable instead of crashing config and help paths. Fixes #69044. Thanks @frankhli843 and @vincentkoc. diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index fe63dde9f83..78fe06baa73 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -1283,6 +1283,76 @@ describe("update-cli", () => { ).not.toContain("already-current"); }); + it("retries package updates without optional deps when npm global update fails", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-optional-")); + const nodeModules = path.join(tempDir, "node_modules"); + const pkgRoot = path.join(nodeModules, "openclaw"); + mockPackageInstallStatus(pkgRoot); + await fs.mkdir(pkgRoot, { recursive: true }); + await fs.writeFile( + path.join(pkgRoot, "package.json"), + JSON.stringify({ name: "openclaw", version: "1.0.0" }), + "utf-8", + ); + + vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => { + if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") { + return { + stdout: `${nodeModules}\n`, + stderr: "", + code: 0, + signal: null, + killed: false, + termination: "exit", + }; + } + if ( + Array.isArray(argv) && + argv[0] === "npm" && + argv.includes("-g") && + !argv.includes("--omit=optional") + ) { + return { + stdout: "", + stderr: "node-gyp failed", + code: 1, + signal: null, + killed: false, + termination: "exit", + }; + } + return { + stdout: "", + stderr: "", + code: 0, + signal: null, + killed: false, + termination: "exit", + }; + }); + + await updateCommand({ yes: true, restart: false }); + + expect(runCommandWithTimeout).toHaveBeenCalledWith( + ["npm", "i", "-g", "openclaw@latest", "--no-fund", "--no-audit", "--loglevel=error"], + expect.any(Object), + ); + expect(runCommandWithTimeout).toHaveBeenCalledWith( + [ + "npm", + "i", + "-g", + "openclaw@latest", + "--omit=optional", + "--no-fund", + "--no-audit", + "--loglevel=error", + ], + expect.any(Object), + ); + expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1); + }); + it("uses the owning npm binary for package updates when PATH npm points elsewhere", async () => { const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); const brewPrefix = createCaseDir("brew-prefix"); diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 50af145670e..a896708e14f 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -37,6 +37,7 @@ import { canResolveRegistryVersionForPackageTarget, createGlobalInstallEnv, cleanupGlobalRenameDirs, + globalInstallFallbackArgs, globalInstallArgs, resolveExpectedInstalledVersionFromSpec, resolveGlobalInstallTarget, @@ -407,6 +408,21 @@ async function runPackageInstallUpdate(params: { }); const steps = [updateStep]; + let finalInstallStep = updateStep; + if (updateStep.exitCode !== 0) { + const fallbackArgv = globalInstallFallbackArgs(installTarget, installSpec); + if (fallbackArgv) { + const fallbackStep = await runUpdateStep({ + name: "global update (omit optional)", + argv: fallbackArgv, + env: installEnv, + timeoutMs: params.timeoutMs, + progress: params.progress, + }); + steps.push(fallbackStep); + finalInstallStep = fallbackStep; + } + } let afterVersion = beforeVersion; const verifiedPackageRoot = @@ -451,7 +467,10 @@ async function runPackageInstallUpdate(params: { } } - const failedStep = steps.find((step) => step.exitCode !== 0); + const failedStep = + finalInstallStep.exitCode !== 0 + ? finalInstallStep + : (steps.find((step) => step !== updateStep && step.exitCode !== 0) ?? null); return { status: failedStep ? "error" : "ok", mode: manager,