fix: harden windows dev update fallback

This commit is contained in:
Peter Steinberger
2026-04-06 14:41:00 +01:00
parent 00dcc1744e
commit 50082f91ff
5 changed files with 227 additions and 4 deletions

View File

@@ -19,6 +19,7 @@ type InstallGatewayDaemonResult = Awaited<ReturnType<typeof installGatewayDaemon
const installGatewayDaemonNonInteractiveMock = vi.hoisted(() =>
vi.fn(async (): Promise<InstallGatewayDaemonResult> => ({ installed: true })),
);
const healthCommandMock = vi.hoisted(() => vi.fn(async () => {}));
const gatewayServiceMock = vi.hoisted(() => ({
label: "LaunchAgent",
loadedText: "loaded",
@@ -89,6 +90,10 @@ vi.mock("./onboard-non-interactive/local/daemon-install.js", () => ({
installGatewayDaemonNonInteractive: installGatewayDaemonNonInteractiveMock,
}));
vi.mock("./health.js", () => ({
healthCommand: healthCommandMock,
}));
vi.mock("../daemon/service.js", () => ({
resolveGatewayService: () => gatewayServiceMock,
}));
@@ -233,6 +238,7 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
afterEach(() => {
waitForGatewayReachableMock = undefined;
installGatewayDaemonNonInteractiveMock.mockClear();
healthCommandMock.mockClear();
gatewayServiceMock.isLoaded.mockClear();
gatewayServiceMock.readRuntime.mockClear();
readLastGatewayErrorLineMock.mockClear();
@@ -563,6 +569,40 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
});
}, 60_000);
it("uses a longer Windows health command timeout when daemon install was requested", async () => {
await withStateDir("state-local-daemon-health-command-win-", async (stateDir) => {
waitForGatewayReachableMock = vi.fn(async () => ({ ok: true }));
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
try {
await runNonInteractiveSetup(
{
nonInteractive: true,
mode: "local",
workspace: path.join(stateDir, "openclaw"),
authChoice: "skip",
skipSkills: true,
skipHealth: false,
installDaemon: true,
gatewayBind: "loopback",
},
runtime,
);
} finally {
platformSpy.mockRestore();
}
expect(healthCommandMock).toHaveBeenCalledTimes(1);
expect(healthCommandMock).toHaveBeenCalledWith(
expect.objectContaining({
json: false,
timeoutMs: 90_000,
}),
runtime,
);
});
}, 60_000);
it("emits a daemon-install failure when Linux user systemd is unavailable", async () => {
await withStateDir("state-local-daemon-install-json-fail-", async (stateDir) => {
installGatewayDaemonNonInteractiveMock.mockResolvedValueOnce({

View File

@@ -29,7 +29,7 @@ const INSTALL_DAEMON_HEALTH_PROBE_TIMEOUT_MS = 10_000;
const WINDOWS_INSTALL_DAEMON_HEALTH_DEADLINE_MS = 90_000;
const WINDOWS_INSTALL_DAEMON_HEALTH_PROBE_TIMEOUT_MS = 15_000;
const INSTALL_DAEMON_HEALTH_COMMAND_TIMEOUT_MS = 10_000;
const WINDOWS_INSTALL_DAEMON_HEALTH_COMMAND_TIMEOUT_MS = 30_000;
const WINDOWS_INSTALL_DAEMON_HEALTH_COMMAND_TIMEOUT_MS = 90_000;
function resolveInstallDaemonGatewayHealthTiming(): {
deadlineMs: number;

View File

@@ -236,3 +236,13 @@ export function managerInstallArgs(manager: BuildManager, opts?: { compatFallbac
}
return ["npm", "install"];
}
export function managerInstallIgnoreScriptsArgs(manager: BuildManager): string[] | null {
if (manager === "pnpm") {
return ["pnpm", "install", "--ignore-scripts"];
}
if (manager === "bun") {
return ["bun", "install", "--ignore-scripts"];
}
return ["npm", "install", "--ignore-scripts"];
}

View File

@@ -569,6 +569,120 @@ describe("runGatewayUpdate", () => {
expect(pnpmEnvPaths.some((value) => value.includes("openclaw-update-pnpm-"))).toBe(true);
});
it("retries windows pnpm git installs with --ignore-scripts for dev updates", async () => {
await setupGitPackageManagerFixture();
const calls: string[] = [];
const upstreamSha = "upstream123";
const doctorNodePath = await resolveStableNodePath(process.execPath);
const doctorCommand = `${doctorNodePath} ${path.join(tempDir, "openclaw.mjs")} doctor --non-interactive --fix`;
let preflightInstallAttempts = 0;
let finalInstallAttempts = 0;
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
try {
const runCommand = async (
argv: string[],
options?: { env?: NodeJS.ProcessEnv; cwd?: string; timeoutMs?: number },
) => {
const key = argv.join(" ");
calls.push(key);
if (key === `git -C ${tempDir} rev-parse --show-toplevel`) {
return { stdout: tempDir, stderr: "", code: 0 };
}
if (key === `git -C ${tempDir} rev-parse HEAD`) {
return { stdout: "abc123", stderr: "", code: 0 };
}
if (key === `git -C ${tempDir} rev-parse --abbrev-ref HEAD`) {
return { stdout: "main", stderr: "", code: 0 };
}
if (key === `git -C ${tempDir} status --porcelain -- :!dist/control-ui/`) {
return { stdout: "", stderr: "", code: 0 };
}
if (key === `git -C ${tempDir} rev-parse --abbrev-ref --symbolic-full-name @{upstream}`) {
return { stdout: "origin/main", stderr: "", code: 0 };
}
if (key === `git -C ${tempDir} fetch --all --prune --tags`) {
return { stdout: "", stderr: "", code: 0 };
}
if (key === `git -C ${tempDir} rev-parse @{upstream}`) {
return { stdout: upstreamSha, stderr: "", code: 0 };
}
if (key === `git -C ${tempDir} rev-list --max-count=10 ${upstreamSha}`) {
return { stdout: `${upstreamSha}\n`, stderr: "", code: 0 };
}
if (key === "pnpm --version") {
return { stdout: "10.0.0", stderr: "", code: 0 };
}
if (
key.startsWith(
`git -C ${tempDir} worktree add --detach /tmp/openclaw-update-preflight-`,
) &&
key.endsWith(` /worktree ${upstreamSha}`)
) {
return { stdout: `HEAD is now at ${upstreamSha}`, stderr: "", code: 0 };
}
if (
key.startsWith("git -C /tmp/openclaw-update-preflight-") &&
key.includes("/worktree checkout --detach ") &&
key.endsWith(upstreamSha)
) {
return { stdout: "", stderr: "", code: 0 };
}
if (key === "pnpm install") {
if (options?.cwd?.includes(`${path.sep}openclaw-update-preflight-`)) {
preflightInstallAttempts += 1;
return preflightInstallAttempts === 1
? { stdout: "", stderr: "sharp: Please add node-gyp to your dependencies", code: 1 }
: { stdout: "", stderr: "", code: 0 };
}
if (options?.cwd === tempDir) {
finalInstallAttempts += 1;
return finalInstallAttempts === 1
? { stdout: "", stderr: "sharp: Please add node-gyp to your dependencies", code: 1 }
: { stdout: "", stderr: "", code: 0 };
}
}
if (key === "pnpm install --ignore-scripts") {
return { stdout: "", stderr: "", code: 0 };
}
if (key === "pnpm build" || key === "pnpm lint" || key === "pnpm ui:build") {
return { stdout: "", stderr: "", code: 0 };
}
if (
key.startsWith(
`git -C ${tempDir} worktree remove --force /tmp/openclaw-update-preflight-`,
)
) {
return { stdout: "", stderr: "", code: 0 };
}
if (key === `git -C ${tempDir} worktree prune`) {
return { stdout: "", stderr: "", code: 0 };
}
if (key === `git -C ${tempDir} rebase ${upstreamSha}`) {
return { stdout: "", stderr: "", code: 0 };
}
if (key === doctorCommand) {
return { stdout: "", stderr: "", code: 0 };
}
return { stdout: "", stderr: "", code: 0 };
};
const result = await runWithCommand(runCommand, { channel: "dev" });
expect(result.status).toBe("ok");
expect(preflightInstallAttempts).toBe(1);
expect(finalInstallAttempts).toBe(1);
expect(result.steps.map((step) => step.name)).toContain(
"preflight deps install (ignore scripts) (upstream)",
);
expect(result.steps.map((step) => step.name)).toContain("deps install (ignore scripts)");
expect(calls).toContain("pnpm install --ignore-scripts");
} finally {
platformSpy.mockRestore();
}
});
it("does not fall back to npm scripts when a pnpm repo cannot bootstrap pnpm", async () => {
await setupGitPackageManagerFixture();
const calls: string[] = [];

View File

@@ -31,6 +31,7 @@ import {
resolveGlobalInstallSpec,
} from "./update-global.js";
import {
managerInstallIgnoreScriptsArgs,
managerInstallArgs,
managerScriptArgs,
resolveUpdateBuildManager,
@@ -309,6 +310,34 @@ function normalizeTag(tag?: string) {
return normalizePackageTagInput(tag, ["openclaw", DEFAULT_PACKAGE_NAME]) ?? "latest";
}
function shouldRetryWindowsInstallIgnoringScripts(manager: "pnpm" | "bun" | "npm"): boolean {
return process.platform === "win32" && manager === "pnpm";
}
function isSupersededInstallFailure(
step: UpdateStepResult,
steps: readonly UpdateStepResult[],
): boolean {
if (step.exitCode === 0) {
return false;
}
if (step.name === "deps install") {
return steps.some(
(candidate) => candidate.name === "deps install (ignore scripts)" && candidate.exitCode === 0,
);
}
const preflightMatch = /^preflight deps install \((.+)\)$/.exec(step.name);
if (!preflightMatch) {
return false;
}
const retryName = `preflight deps install (ignore scripts) (${preflightMatch[1]})`;
return steps.some((candidate) => candidate.name === retryName && candidate.exitCode === 0);
}
function findBlockingGitFailure(steps: readonly UpdateStepResult[]): UpdateStepResult | undefined {
return steps.find((step) => step.exitCode !== 0 && !isSupersededInstallFailure(step, steps));
}
function mergeCommandEnvironments(
baseEnv: NodeJS.ProcessEnv | undefined,
overrideEnv: NodeJS.ProcessEnv | undefined,
@@ -608,7 +637,26 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
),
);
steps.push(depsStep);
if (depsStep.exitCode !== 0) {
let finalDepsStep = depsStep;
if (
depsStep.exitCode !== 0 &&
shouldRetryWindowsInstallIgnoringScripts(manager.manager)
) {
const retryArgv = managerInstallIgnoreScriptsArgs(manager.manager);
if (retryArgv) {
const retryStep = await runStep(
step(
`preflight deps install (ignore scripts) (${shortSha})`,
retryArgv,
worktreeDir,
manager.env,
),
);
steps.push(retryStep);
finalDepsStep = retryStep;
}
}
if (finalDepsStep.exitCode !== 0) {
continue;
}
@@ -771,7 +819,18 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
),
);
steps.push(depsStep);
if (depsStep.exitCode !== 0) {
let finalDepsStep = depsStep;
if (depsStep.exitCode !== 0 && shouldRetryWindowsInstallIgnoringScripts(manager.manager)) {
const retryArgv = managerInstallIgnoreScriptsArgs(manager.manager);
if (retryArgv) {
const retryStep = await runStep(
step("deps install (ignore scripts)", retryArgv, gitRoot, manager.env),
);
steps.push(retryStep);
finalDepsStep = retryStep;
}
}
if (finalDepsStep.exitCode !== 0) {
return {
status: "error",
mode: "git",
@@ -905,7 +964,7 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
}
}
const failedStep = steps.find((s) => s.exitCode !== 0);
const failedStep = findBlockingGitFailure(steps);
const afterShaStep = await runStep(
step("git rev-parse HEAD (after)", ["git", "-C", gitRoot, "rev-parse", "HEAD"], gitRoot),
);