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 <vincentkoc@ieee.org>
This commit is contained in:
Xinhua Gu
2026-02-27 03:35:13 +01:00
committed by GitHub
parent 418111adb9
commit 7bbfb9de5e
5 changed files with 184 additions and 3 deletions

View File

@@ -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([]);
});
});

View File

@@ -28,6 +28,7 @@ const STEP_LABELS: Record<string, string> = {
"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))}`);
}