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:
56
src/cli/update-cli/progress.test.ts
Normal file
56
src/cli/update-cli/progress.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
@@ -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))}`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user