fix: harden windows native updates

This commit is contained in:
Peter Steinberger
2026-03-12 23:41:28 +00:00
parent 35aafd7ca8
commit 91b701e183
7 changed files with 290 additions and 52 deletions

View File

@@ -1,3 +1,4 @@
import fs from "node:fs/promises";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig, ConfigFileSnapshot } from "../config/types.openclaw.js";
@@ -390,14 +391,13 @@ describe("update-cli", () => {
},
{
name: "defaults to stable channel for package installs when unset",
mode: "npm" as const,
options: { yes: true },
prepare: async () => {
const tempDir = createCaseDir("openclaw-update");
mockPackageInstallStatus(tempDir);
},
expectedChannel: "stable" as const,
expectedTag: "latest",
expectedChannel: undefined as "stable" | undefined,
expectedTag: undefined as string | undefined,
},
{
name: "uses stored beta channel when configured",
@@ -414,14 +414,25 @@ describe("update-cli", () => {
},
])("$name", async ({ mode, options, prepare, expectedChannel, expectedTag }) => {
await prepare();
vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult({ mode }));
if (mode) {
vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult({ mode }));
}
await updateCommand(options);
const call = expectUpdateCallChannel(expectedChannel);
if (expectedTag !== undefined) {
expect(call?.tag).toBe(expectedTag);
if (expectedChannel !== undefined) {
const call = expectUpdateCallChannel(expectedChannel);
if (expectedTag !== undefined) {
expect(call?.tag).toBe(expectedTag);
}
return;
}
expect(runGatewayUpdate).not.toHaveBeenCalled();
expect(runCommandWithTimeout).toHaveBeenCalledWith(
["npm", "i", "-g", "openclaw@latest", "--no-fund", "--no-audit", "--loglevel=error"],
expect.any(Object),
);
});
it("falls back to latest when beta tag is older than release", async () => {
@@ -436,32 +447,78 @@ describe("update-cli", () => {
tag: "latest",
version: "1.2.3-1",
});
vi.mocked(runGatewayUpdate).mockResolvedValue(
makeOkUpdateResult({
mode: "npm",
}),
);
await updateCommand({});
const call = expectUpdateCallChannel("beta");
expect(call?.tag).toBe("latest");
expect(runGatewayUpdate).not.toHaveBeenCalled();
expect(runCommandWithTimeout).toHaveBeenCalledWith(
["npm", "i", "-g", "openclaw@latest", "--no-fund", "--no-audit", "--loglevel=error"],
expect.any(Object),
);
});
it("honors --tag override", async () => {
const tempDir = createCaseDir("openclaw-update");
vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue(tempDir);
vi.mocked(runGatewayUpdate).mockResolvedValue(
makeOkUpdateResult({
mode: "npm",
}),
);
mockPackageInstallStatus(tempDir);
await updateCommand({ tag: "next" });
const call = vi.mocked(runGatewayUpdate).mock.calls[0]?.[0];
expect(call?.tag).toBe("next");
expect(runGatewayUpdate).not.toHaveBeenCalled();
expect(runCommandWithTimeout).toHaveBeenCalledWith(
["npm", "i", "-g", "openclaw@next", "--no-fund", "--no-audit", "--loglevel=error"],
expect.any(Object),
);
});
it("prepends portable Git PATH for package updates on Windows", async () => {
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
const tempDir = createCaseDir("openclaw-update");
const localAppData = createCaseDir("openclaw-localappdata");
const portableGitMingw = path.join(
localAppData,
"OpenClaw",
"deps",
"portable-git",
"mingw64",
"bin",
);
const portableGitUsr = path.join(
localAppData,
"OpenClaw",
"deps",
"portable-git",
"usr",
"bin",
);
await fs.mkdir(portableGitMingw, { recursive: true });
await fs.mkdir(portableGitUsr, { recursive: true });
mockPackageInstallStatus(tempDir);
pathExists.mockImplementation(
async (candidate: string) => candidate === portableGitMingw || candidate === portableGitUsr,
);
await withEnvAsync({ LOCALAPPDATA: localAppData }, async () => {
await updateCommand({ yes: true });
});
platformSpy.mockRestore();
const updateCall = vi
.mocked(runCommandWithTimeout)
.mock.calls.find(
(call) =>
Array.isArray(call[0]) &&
call[0][0] === "npm" &&
call[0][1] === "i" &&
call[0][2] === "-g",
);
const mergedPath = updateCall?.[1]?.env?.Path ?? updateCall?.[1]?.env?.PATH ?? "";
expect(mergedPath.split(path.delimiter).slice(0, 2)).toEqual([
portableGitMingw,
portableGitUsr,
]);
expect(updateCall?.[1]?.env?.NPM_CONFIG_SCRIPT_SHELL).toBe("cmd.exe");
expect(updateCall?.[1]?.env?.NODE_LLAMA_CPP_SKIP_DOWNLOAD).toBe("1");
});
it("updateCommand outputs JSON when --json is set", async () => {
@@ -648,15 +705,15 @@ describe("update-cli", () => {
name: "requires confirmation without --yes",
options: {},
shouldExit: true,
shouldRunUpdate: false,
shouldRunPackageUpdate: false,
},
{
name: "allows downgrade with --yes",
options: { yes: true },
shouldExit: false,
shouldRunUpdate: true,
shouldRunPackageUpdate: true,
},
])("$name in non-interactive mode", async ({ options, shouldExit, shouldRunUpdate }) => {
])("$name in non-interactive mode", async ({ options, shouldExit, shouldRunPackageUpdate }) => {
await setupNonInteractiveDowngrade();
await updateCommand(options);
@@ -667,7 +724,12 @@ describe("update-cli", () => {
expect(vi.mocked(defaultRuntime.exit).mock.calls.some((call) => call[0] === 1)).toBe(
shouldExit,
);
expect(vi.mocked(runGatewayUpdate).mock.calls.length > 0).toBe(shouldRunUpdate);
expect(vi.mocked(runGatewayUpdate).mock.calls.length > 0).toBe(false);
expect(
vi
.mocked(runCommandWithTimeout)
.mock.calls.some((call) => Array.isArray(call[0]) && call[0][0] === "npm"),
).toBe(shouldRunPackageUpdate);
});
it("dry-run bypasses downgrade confirmation checks in non-interactive mode", async () => {