Skip to content

Commit 7bbfb9d

Browse files
xinhuaguvincentkoc
andauthored
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 <[email protected]>
1 parent 418111a commit 7bbfb9d

File tree

5 files changed

+184
-3
lines changed

5 files changed

+184
-3
lines changed
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { describe, expect, it } from "vitest";
2+
import type { UpdateRunResult } from "../../infra/update-runner.js";
3+
import { inferUpdateFailureHints } from "./progress.js";
4+
5+
function makeResult(
6+
stepName: string,
7+
stderrTail: string,
8+
mode: UpdateRunResult["mode"] = "npm",
9+
): UpdateRunResult {
10+
return {
11+
status: "error",
12+
mode,
13+
reason: stepName,
14+
steps: [
15+
{
16+
name: stepName,
17+
command: "npm i -g openclaw@latest",
18+
cwd: "/tmp",
19+
durationMs: 1,
20+
exitCode: 1,
21+
stderrTail,
22+
},
23+
],
24+
durationMs: 1,
25+
};
26+
}
27+
28+
describe("inferUpdateFailureHints", () => {
29+
it("returns EACCES hint for global update permission failures", () => {
30+
const result = makeResult(
31+
"global update",
32+
"npm ERR! code EACCES\nnpm ERR! Error: EACCES: permission denied",
33+
);
34+
const hints = inferUpdateFailureHints(result);
35+
expect(hints.join("\n")).toContain("EACCES");
36+
expect(hints.join("\n")).toContain("npm config set prefix ~/.local");
37+
});
38+
39+
it("returns native optional dependency hint for node-gyp/opus failures", () => {
40+
const result = makeResult(
41+
"global update",
42+
"node-pre-gyp ERR!\n@discordjs/opus\nnode-gyp rebuild failed",
43+
);
44+
const hints = inferUpdateFailureHints(result);
45+
expect(hints.join("\n")).toContain("--omit=optional");
46+
});
47+
48+
it("does not return npm hints for non-npm install modes", () => {
49+
const result = makeResult(
50+
"global update",
51+
"npm ERR! code EACCES\nnpm ERR! Error: EACCES: permission denied",
52+
"pnpm",
53+
);
54+
expect(inferUpdateFailureHints(result)).toEqual([]);
55+
});
56+
});

src/cli/update-cli/progress.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,48 @@ const STEP_LABELS: Record<string, string> = {
2828
"openclaw doctor": "Running doctor checks",
2929
"git rev-parse HEAD (after)": "Verifying update",
3030
"global update": "Updating via package manager",
31+
"global update (omit optional)": "Retrying update without optional deps",
3132
"global install": "Installing global package",
3233
};
3334

3435
function getStepLabel(step: UpdateStepInfo): string {
3536
return STEP_LABELS[step.name] ?? step.name;
3637
}
3738

39+
export function inferUpdateFailureHints(result: UpdateRunResult): string[] {
40+
if (result.status !== "error" || result.mode !== "npm") {
41+
return [];
42+
}
43+
const failedStep = [...result.steps].toReversed().find((step) => step.exitCode !== 0);
44+
if (!failedStep) {
45+
return [];
46+
}
47+
48+
const stderr = (failedStep.stderrTail ?? "").toLowerCase();
49+
const hints: string[] = [];
50+
51+
if (failedStep.name.startsWith("global update") && stderr.includes("eacces")) {
52+
hints.push(
53+
"Detected permission failure (EACCES). Re-run with a writable global prefix or sudo (for system-managed Node installs).",
54+
);
55+
hints.push("Example: npm config set prefix ~/.local && npm i -g openclaw@latest");
56+
}
57+
58+
if (
59+
failedStep.name.startsWith("global update") &&
60+
(stderr.includes("node-gyp") ||
61+
stderr.includes("@discordjs/opus") ||
62+
stderr.includes("prebuild"))
63+
) {
64+
hints.push(
65+
"Detected native optional dependency build failure (e.g. opus). The updater retries with --omit=optional automatically.",
66+
);
67+
hints.push("If it still fails: npm i -g openclaw@latest --omit=optional");
68+
}
69+
70+
return hints;
71+
}
72+
3873
export type ProgressController = {
3974
progress: UpdateStepProgress;
4075
stop: () => void;
@@ -151,6 +186,15 @@ export function printResult(result: UpdateRunResult, opts: PrintResultOptions):
151186
}
152187
}
153188

189+
const hints = inferUpdateFailureHints(result);
190+
if (hints.length > 0) {
191+
defaultRuntime.log("");
192+
defaultRuntime.log(theme.heading("Recovery hints:"));
193+
for (const hint of hints) {
194+
defaultRuntime.log(` - ${theme.warn(hint)}`);
195+
}
196+
}
197+
154198
defaultRuntime.log("");
155199
defaultRuntime.log(`Total time: ${theme.muted(formatDurationPrecise(result.durationMs))}`);
156200
}

src/infra/update-global.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ const PRIMARY_PACKAGE_NAME = "openclaw";
1414
const ALL_PACKAGE_NAMES = [PRIMARY_PACKAGE_NAME] as const;
1515
const GLOBAL_RENAME_PREFIX = ".";
1616
const NPM_GLOBAL_INSTALL_QUIET_FLAGS = ["--no-fund", "--no-audit", "--loglevel=error"] as const;
17+
const NPM_GLOBAL_INSTALL_OMIT_OPTIONAL_FLAGS = [
18+
"--omit=optional",
19+
...NPM_GLOBAL_INSTALL_QUIET_FLAGS,
20+
] as const;
1721

1822
async function tryRealpath(targetPath: string): Promise<string> {
1923
try {
@@ -139,6 +143,16 @@ export function globalInstallArgs(manager: GlobalInstallManager, spec: string):
139143
return ["npm", "i", "-g", spec, ...NPM_GLOBAL_INSTALL_QUIET_FLAGS];
140144
}
141145

146+
export function globalInstallFallbackArgs(
147+
manager: GlobalInstallManager,
148+
spec: string,
149+
): string[] | null {
150+
if (manager !== "npm") {
151+
return null;
152+
}
153+
return ["npm", "i", "-g", spec, ...NPM_GLOBAL_INSTALL_OMIT_OPTIONAL_FLAGS];
154+
}
155+
142156
export async function cleanupGlobalRenameDirs(params: {
143157
globalRoot: string;
144158
packageName: string;

src/infra/update-runner.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,51 @@ describe("runGatewayUpdate", () => {
417417
expect(await pathExists(staleDir)).toBe(false);
418418
});
419419

420+
it("retries global npm update with --omit=optional when initial install fails", async () => {
421+
const nodeModules = path.join(tempDir, "node_modules");
422+
const pkgRoot = path.join(nodeModules, "openclaw");
423+
await seedGlobalPackageRoot(pkgRoot);
424+
425+
let firstAttempt = true;
426+
const runCommand = async (argv: string[]) => {
427+
const key = argv.join(" ");
428+
if (key === `git -C ${pkgRoot} rev-parse --show-toplevel`) {
429+
return { stdout: "", stderr: "not a git repository", code: 128 };
430+
}
431+
if (key === "npm root -g") {
432+
return { stdout: nodeModules, stderr: "", code: 0 };
433+
}
434+
if (key === "pnpm root -g") {
435+
return { stdout: "", stderr: "", code: 1 };
436+
}
437+
if (key === "npm i -g openclaw@latest --no-fund --no-audit --loglevel=error") {
438+
firstAttempt = false;
439+
return { stdout: "", stderr: "node-gyp failed", code: 1 };
440+
}
441+
if (
442+
key === "npm i -g openclaw@latest --omit=optional --no-fund --no-audit --loglevel=error"
443+
) {
444+
await fs.writeFile(
445+
path.join(pkgRoot, "package.json"),
446+
JSON.stringify({ name: "openclaw", version: "2.0.0" }),
447+
"utf-8",
448+
);
449+
return { stdout: "ok", stderr: "", code: 0 };
450+
}
451+
return { stdout: "", stderr: "", code: 0 };
452+
};
453+
454+
const result = await runWithCommand(runCommand, { cwd: pkgRoot });
455+
456+
expect(firstAttempt).toBe(false);
457+
expect(result.status).toBe("ok");
458+
expect(result.mode).toBe("npm");
459+
expect(result.steps.map((s) => s.name)).toEqual([
460+
"global update",
461+
"global update (omit optional)",
462+
]);
463+
});
464+
420465
it("updates global bun installs when detected", async () => {
421466
const bunInstall = path.join(tempDir, "bun-install");
422467
await withEnvAsync({ BUN_INSTALL: bunInstall }, async () => {

src/infra/update-runner.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
cleanupGlobalRenameDirs,
2323
detectGlobalInstallManagerForRoot,
2424
globalInstallArgs,
25+
globalInstallFallbackArgs,
2526
} from "./update-global.js";
2627

2728
export type UpdateStepResult = {
@@ -875,6 +876,7 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
875876
const channel = opts.channel ?? DEFAULT_PACKAGE_CHANNEL;
876877
const tag = normalizeTag(opts.tag ?? channelToNpmTag(channel));
877878
const spec = `${packageName}@${tag}`;
879+
const steps: UpdateStepResult[] = [];
878880
const updateStep = await runStep({
879881
runCommand,
880882
name: "global update",
@@ -885,13 +887,33 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
885887
stepIndex: 0,
886888
totalSteps: 1,
887889
});
888-
const steps = [updateStep];
890+
steps.push(updateStep);
891+
892+
let finalStep = updateStep;
893+
if (updateStep.exitCode !== 0) {
894+
const fallbackArgv = globalInstallFallbackArgs(globalManager, spec);
895+
if (fallbackArgv) {
896+
const fallbackStep = await runStep({
897+
runCommand,
898+
name: "global update (omit optional)",
899+
argv: fallbackArgv,
900+
cwd: pkgRoot,
901+
timeoutMs,
902+
progress,
903+
stepIndex: 0,
904+
totalSteps: 1,
905+
});
906+
steps.push(fallbackStep);
907+
finalStep = fallbackStep;
908+
}
909+
}
910+
889911
const afterVersion = await readPackageVersion(pkgRoot);
890912
return {
891-
status: updateStep.exitCode === 0 ? "ok" : "error",
913+
status: finalStep.exitCode === 0 ? "ok" : "error",
892914
mode: globalManager,
893915
root: pkgRoot,
894-
reason: updateStep.exitCode === 0 ? undefined : updateStep.name,
916+
reason: finalStep.exitCode === 0 ? undefined : finalStep.name,
895917
before: { version: beforeVersion },
896918
after: { version: afterVersion },
897919
steps,

0 commit comments

Comments
 (0)