mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-03 16:30:23 +00:00
fix: harden windows dev update fallback
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"];
|
||||
}
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user