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

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