From 7bbfb9de5e80898dfc2d1be7447dbba5466d5fba Mon Sep 17 00:00:00 2001 From: Xinhua Gu Date: Fri, 27 Feb 2026 03:35:13 +0100 Subject: [PATCH] fix(update): fallback to --omit=optional when global npm update fails (#24896) * fix(update): fallback to --omit=optional when global npm update fails * fix(update): add recovery hints and fallback for npm global update failures * chore(update): align fallback progress step index ordering * chore(update): label omit-optional retry step in progress output * chore(update): avoid showing 1/2 when fallback path is not used * chore(ci): retrigger after unrelated test OOM * fix(update): scope recovery hints to npm failures * test(update): cover non-npm hint suppression --------- Co-authored-by: Vincent Koc --- src/cli/update-cli/progress.test.ts | 56 +++++++++++++++++++++++++++++ src/cli/update-cli/progress.ts | 44 +++++++++++++++++++++++ src/infra/update-global.ts | 14 ++++++++ src/infra/update-runner.test.ts | 45 +++++++++++++++++++++++ src/infra/update-runner.ts | 28 +++++++++++++-- 5 files changed, 184 insertions(+), 3 deletions(-) create mode 100644 src/cli/update-cli/progress.test.ts diff --git a/src/cli/update-cli/progress.test.ts b/src/cli/update-cli/progress.test.ts new file mode 100644 index 000000000..d8ddf5212 --- /dev/null +++ b/src/cli/update-cli/progress.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import type { UpdateRunResult } from "../../infra/update-runner.js"; +import { inferUpdateFailureHints } from "./progress.js"; + +function makeResult( + stepName: string, + stderrTail: string, + mode: UpdateRunResult["mode"] = "npm", +): UpdateRunResult { + return { + status: "error", + mode, + reason: stepName, + steps: [ + { + name: stepName, + command: "npm i -g openclaw@latest", + cwd: "/tmp", + durationMs: 1, + exitCode: 1, + stderrTail, + }, + ], + durationMs: 1, + }; +} + +describe("inferUpdateFailureHints", () => { + it("returns EACCES hint for global update permission failures", () => { + const result = makeResult( + "global update", + "npm ERR! code EACCES\nnpm ERR! Error: EACCES: permission denied", + ); + const hints = inferUpdateFailureHints(result); + expect(hints.join("\n")).toContain("EACCES"); + expect(hints.join("\n")).toContain("npm config set prefix ~/.local"); + }); + + it("returns native optional dependency hint for node-gyp/opus failures", () => { + const result = makeResult( + "global update", + "node-pre-gyp ERR!\n@discordjs/opus\nnode-gyp rebuild failed", + ); + const hints = inferUpdateFailureHints(result); + expect(hints.join("\n")).toContain("--omit=optional"); + }); + + it("does not return npm hints for non-npm install modes", () => { + const result = makeResult( + "global update", + "npm ERR! code EACCES\nnpm ERR! Error: EACCES: permission denied", + "pnpm", + ); + expect(inferUpdateFailureHints(result)).toEqual([]); + }); +}); diff --git a/src/cli/update-cli/progress.ts b/src/cli/update-cli/progress.ts index 1fd2f3d20..edaf4d3d6 100644 --- a/src/cli/update-cli/progress.ts +++ b/src/cli/update-cli/progress.ts @@ -28,6 +28,7 @@ const STEP_LABELS: Record = { "openclaw doctor": "Running doctor checks", "git rev-parse HEAD (after)": "Verifying update", "global update": "Updating via package manager", + "global update (omit optional)": "Retrying update without optional deps", "global install": "Installing global package", }; @@ -35,6 +36,40 @@ function getStepLabel(step: UpdateStepInfo): string { return STEP_LABELS[step.name] ?? step.name; } +export function inferUpdateFailureHints(result: UpdateRunResult): string[] { + if (result.status !== "error" || result.mode !== "npm") { + return []; + } + const failedStep = [...result.steps].toReversed().find((step) => step.exitCode !== 0); + if (!failedStep) { + return []; + } + + const stderr = (failedStep.stderrTail ?? "").toLowerCase(); + const hints: string[] = []; + + if (failedStep.name.startsWith("global update") && stderr.includes("eacces")) { + hints.push( + "Detected permission failure (EACCES). Re-run with a writable global prefix or sudo (for system-managed Node installs).", + ); + hints.push("Example: npm config set prefix ~/.local && npm i -g openclaw@latest"); + } + + if ( + failedStep.name.startsWith("global update") && + (stderr.includes("node-gyp") || + stderr.includes("@discordjs/opus") || + stderr.includes("prebuild")) + ) { + hints.push( + "Detected native optional dependency build failure (e.g. opus). The updater retries with --omit=optional automatically.", + ); + hints.push("If it still fails: npm i -g openclaw@latest --omit=optional"); + } + + return hints; +} + export type ProgressController = { progress: UpdateStepProgress; stop: () => void; @@ -151,6 +186,15 @@ export function printResult(result: UpdateRunResult, opts: PrintResultOptions): } } + const hints = inferUpdateFailureHints(result); + if (hints.length > 0) { + defaultRuntime.log(""); + defaultRuntime.log(theme.heading("Recovery hints:")); + for (const hint of hints) { + defaultRuntime.log(` - ${theme.warn(hint)}`); + } + } + defaultRuntime.log(""); defaultRuntime.log(`Total time: ${theme.muted(formatDurationPrecise(result.durationMs))}`); } diff --git a/src/infra/update-global.ts b/src/infra/update-global.ts index e85949f3c..03a405b8f 100644 --- a/src/infra/update-global.ts +++ b/src/infra/update-global.ts @@ -14,6 +14,10 @@ const PRIMARY_PACKAGE_NAME = "openclaw"; const ALL_PACKAGE_NAMES = [PRIMARY_PACKAGE_NAME] as const; const GLOBAL_RENAME_PREFIX = "."; const NPM_GLOBAL_INSTALL_QUIET_FLAGS = ["--no-fund", "--no-audit", "--loglevel=error"] as const; +const NPM_GLOBAL_INSTALL_OMIT_OPTIONAL_FLAGS = [ + "--omit=optional", + ...NPM_GLOBAL_INSTALL_QUIET_FLAGS, +] as const; async function tryRealpath(targetPath: string): Promise { try { @@ -139,6 +143,16 @@ export function globalInstallArgs(manager: GlobalInstallManager, spec: string): return ["npm", "i", "-g", spec, ...NPM_GLOBAL_INSTALL_QUIET_FLAGS]; } +export function globalInstallFallbackArgs( + manager: GlobalInstallManager, + spec: string, +): string[] | null { + if (manager !== "npm") { + return null; + } + return ["npm", "i", "-g", spec, ...NPM_GLOBAL_INSTALL_OMIT_OPTIONAL_FLAGS]; +} + export async function cleanupGlobalRenameDirs(params: { globalRoot: string; packageName: string; diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts index 2ad843057..26ae50a86 100644 --- a/src/infra/update-runner.test.ts +++ b/src/infra/update-runner.test.ts @@ -417,6 +417,51 @@ describe("runGatewayUpdate", () => { expect(await pathExists(staleDir)).toBe(false); }); + it("retries global npm update with --omit=optional when initial install fails", async () => { + const nodeModules = path.join(tempDir, "node_modules"); + const pkgRoot = path.join(nodeModules, "openclaw"); + await seedGlobalPackageRoot(pkgRoot); + + let firstAttempt = true; + const runCommand = async (argv: string[]) => { + const key = argv.join(" "); + if (key === `git -C ${pkgRoot} rev-parse --show-toplevel`) { + return { stdout: "", stderr: "not a git repository", code: 128 }; + } + if (key === "npm root -g") { + return { stdout: nodeModules, stderr: "", code: 0 }; + } + if (key === "pnpm root -g") { + return { stdout: "", stderr: "", code: 1 }; + } + if (key === "npm i -g openclaw@latest --no-fund --no-audit --loglevel=error") { + firstAttempt = false; + return { stdout: "", stderr: "node-gyp failed", code: 1 }; + } + if ( + key === "npm i -g openclaw@latest --omit=optional --no-fund --no-audit --loglevel=error" + ) { + await fs.writeFile( + path.join(pkgRoot, "package.json"), + JSON.stringify({ name: "openclaw", version: "2.0.0" }), + "utf-8", + ); + return { stdout: "ok", stderr: "", code: 0 }; + } + return { stdout: "", stderr: "", code: 0 }; + }; + + const result = await runWithCommand(runCommand, { cwd: pkgRoot }); + + expect(firstAttempt).toBe(false); + expect(result.status).toBe("ok"); + expect(result.mode).toBe("npm"); + expect(result.steps.map((s) => s.name)).toEqual([ + "global update", + "global update (omit optional)", + ]); + }); + it("updates global bun installs when detected", async () => { const bunInstall = path.join(tempDir, "bun-install"); await withEnvAsync({ BUN_INSTALL: bunInstall }, async () => { diff --git a/src/infra/update-runner.ts b/src/infra/update-runner.ts index 6631b6dd3..8a9d56158 100644 --- a/src/infra/update-runner.ts +++ b/src/infra/update-runner.ts @@ -22,6 +22,7 @@ import { cleanupGlobalRenameDirs, detectGlobalInstallManagerForRoot, globalInstallArgs, + globalInstallFallbackArgs, } from "./update-global.js"; export type UpdateStepResult = { @@ -875,6 +876,7 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< const channel = opts.channel ?? DEFAULT_PACKAGE_CHANNEL; const tag = normalizeTag(opts.tag ?? channelToNpmTag(channel)); const spec = `${packageName}@${tag}`; + const steps: UpdateStepResult[] = []; const updateStep = await runStep({ runCommand, name: "global update", @@ -885,13 +887,33 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< stepIndex: 0, totalSteps: 1, }); - const steps = [updateStep]; + steps.push(updateStep); + + let finalStep = updateStep; + if (updateStep.exitCode !== 0) { + const fallbackArgv = globalInstallFallbackArgs(globalManager, spec); + if (fallbackArgv) { + const fallbackStep = await runStep({ + runCommand, + name: "global update (omit optional)", + argv: fallbackArgv, + cwd: pkgRoot, + timeoutMs, + progress, + stepIndex: 0, + totalSteps: 1, + }); + steps.push(fallbackStep); + finalStep = fallbackStep; + } + } + const afterVersion = await readPackageVersion(pkgRoot); return { - status: updateStep.exitCode === 0 ? "ok" : "error", + status: finalStep.exitCode === 0 ? "ok" : "error", mode: globalManager, root: pkgRoot, - reason: updateStep.exitCode === 0 ? undefined : updateStep.name, + reason: finalStep.exitCode === 0 ? undefined : finalStep.name, before: { version: beforeVersion }, after: { version: afterVersion }, steps,