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))}`);
}

View File

@@ -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<string> {
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;

View File

@@ -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 () => {

View File

@@ -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,