fix: harden update channel switching

This commit is contained in:
Peter Steinberger
2026-03-22 15:01:12 -07:00
parent 601f560682
commit e06b8d3e62
8 changed files with 305 additions and 13 deletions

View File

@@ -389,6 +389,7 @@ describe("argv helpers", () => {
["node", "openclaw", "models", "list"],
["node", "openclaw", "models", "status"],
["node", "openclaw", "memory", "status"],
["node", "openclaw", "update", "status", "--json"],
["node", "openclaw", "agent", "--message", "hi"],
] as const;
const mutatingArgv = [
@@ -406,6 +407,7 @@ describe("argv helpers", () => {
it.each([
{ path: ["status"], expected: false },
{ path: ["update", "status"], expected: false },
{ path: ["config", "get"], expected: false },
{ path: ["models", "status"], expected: false },
{ path: ["agents", "list"], expected: true },

View File

@@ -308,6 +308,9 @@ export function shouldMigrateStateFromPath(path: string[]): boolean {
if (primary === "health" || primary === "status" || primary === "sessions") {
return false;
}
if (primary === "update" && secondary === "status") {
return false;
}
if (primary === "config" && (secondary === "get" || secondary === "unset")) {
return false;
}

View File

@@ -7,6 +7,7 @@ import { setProgramContext } from "./program-context.js";
export function buildProgram() {
const program = new Command();
program.enablePositionalOptions();
const ctx = createProgramContext();
const argv = process.argv;

View File

@@ -104,6 +104,11 @@ describe("ensureConfigReady", () => {
commandPath: ["status"],
expectedDoctorCalls: 0,
},
{
name: "skips doctor flow for update status",
commandPath: ["update", "status"],
expectedDoctorCalls: 0,
},
{
name: "runs doctor flow for commands that may mutate state",
commandPath: ["message"],
@@ -145,7 +150,6 @@ describe("ensureConfigReady", () => {
await ensureConfigReady({ runtime: runtimeA as never, commandPath: ["message"] });
await ensureConfigReady({ runtime: runtimeB as never, commandPath: ["message"] });
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledTimes(1);
});

View File

@@ -1,3 +1,4 @@
import { Command } from "commander";
import fs from "node:fs/promises";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -449,6 +450,24 @@ describe("update-cli", () => {
}
});
it("parses update status --json as the subcommand option", async () => {
const program = new Command();
program.name("openclaw");
program.enablePositionalOptions();
let seenJson = false;
const update = program.command("update").option("--json", "", false);
update
.command("status")
.option("--json", "", false)
.action((opts) => {
seenJson = Boolean(opts.json);
});
await program.parseAsync(["node", "openclaw", "update", "status", "--json"]);
expect(seenJson).toBe(true);
});
it.each([
{
name: "defaults to dev channel for git installs when unset",

View File

@@ -32,6 +32,7 @@ function inheritedUpdateTimeout(
}
export function registerUpdateCli(program: Command) {
program.enablePositionalOptions();
const update = program
.command("update")
.description("Update OpenClaw and inspect update channel status")

View File

@@ -325,6 +325,156 @@ describe("runGatewayUpdate", () => {
expect(calls).not.toContain(`git -C ${tempDir} checkout --detach ${betaTag}`);
});
it("falls back to npm when pnpm is unavailable for git installs", async () => {
await fs.mkdir(path.join(tempDir, ".git"));
await fs.writeFile(
path.join(tempDir, "package.json"),
JSON.stringify({ name: "openclaw", version: "1.0.0", packageManager: "pnpm@8.0.0" }),
"utf-8",
);
const uiIndexPath = path.join(tempDir, "dist", "control-ui", "index.html");
await fs.mkdir(path.dirname(uiIndexPath), { recursive: true });
await fs.writeFile(uiIndexPath, "<html></html>", "utf-8");
const stableTag = "v1.0.1-1";
const calls: string[] = [];
const runCommand = async (argv: string[]) => {
const key = argv.join(" ");
calls.push(key);
if (key === "pnpm --version") {
throw new Error("spawn pnpm ENOENT");
}
if (key === "npm --version") {
return { stdout: "10.0.0", stderr: "", code: 0 };
}
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} status --porcelain -- :!dist/control-ui/`) {
return { stdout: "", stderr: "", code: 0 };
}
if (key === `git -C ${tempDir} fetch --all --prune --tags`) {
return { stdout: "", stderr: "", code: 0 };
}
if (key === `git -C ${tempDir} tag --list v* --sort=-v:refname`) {
return { stdout: `${stableTag}\n`, stderr: "", code: 0 };
}
if (key === `git -C ${tempDir} checkout --detach ${stableTag}`) {
return { stdout: "", stderr: "", code: 0 };
}
if (key === "npm install --no-package-lock --legacy-peer-deps") {
return { stdout: "", stderr: "", code: 0 };
}
if (key === "npm run build") {
return { stdout: "", stderr: "", code: 0 };
}
if (key === "npm run ui:build") {
return { stdout: "", stderr: "", code: 0 };
}
if (
key === `${process.execPath} ${path.join(tempDir, "openclaw.mjs")} doctor --non-interactive`
) {
return { stdout: "", stderr: "", code: 0 };
}
return { stdout: "", stderr: "", code: 0 };
};
const result = await runGatewayUpdate({
cwd: tempDir,
runCommand: async (argv, _options) => runCommand(argv),
timeoutMs: 5000,
channel: "stable",
});
expect(result.status).toBe("ok");
expect(calls).toContain("pnpm --version");
expect(calls).toContain("corepack --version");
expect(calls).toContain("npm --version");
expect(calls).toContain("npm install --no-package-lock --legacy-peer-deps");
expect(calls).not.toContain("pnpm install");
});
it("bootstraps pnpm via corepack when pnpm is missing", async () => {
await fs.mkdir(path.join(tempDir, ".git"));
await fs.writeFile(
path.join(tempDir, "package.json"),
JSON.stringify({ name: "openclaw", version: "1.0.0", packageManager: "pnpm@8.0.0" }),
"utf-8",
);
const uiIndexPath = path.join(tempDir, "dist", "control-ui", "index.html");
await fs.mkdir(path.dirname(uiIndexPath), { recursive: true });
await fs.writeFile(uiIndexPath, "<html></html>", "utf-8");
const stableTag = "v1.0.1-1";
const calls: string[] = [];
let pnpmVersionChecks = 0;
const runCommand = async (argv: string[]) => {
const key = argv.join(" ");
calls.push(key);
if (key === "pnpm --version") {
pnpmVersionChecks += 1;
if (pnpmVersionChecks === 1) {
throw new Error("spawn pnpm ENOENT");
}
return { stdout: "10.0.0", stderr: "", code: 0 };
}
if (key === "corepack --version") {
return { stdout: "0.30.0", stderr: "", code: 0 };
}
if (key === "corepack enable") {
return { stdout: "", stderr: "", code: 0 };
}
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} status --porcelain -- :!dist/control-ui/`) {
return { stdout: "", stderr: "", code: 0 };
}
if (key === `git -C ${tempDir} fetch --all --prune --tags`) {
return { stdout: "", stderr: "", code: 0 };
}
if (key === `git -C ${tempDir} tag --list v* --sort=-v:refname`) {
return { stdout: `${stableTag}\n`, stderr: "", code: 0 };
}
if (key === `git -C ${tempDir} checkout --detach ${stableTag}`) {
return { stdout: "", stderr: "", code: 0 };
}
if (key === "pnpm install") {
return { stdout: "", stderr: "", code: 0 };
}
if (key === "pnpm build") {
return { stdout: "", stderr: "", code: 0 };
}
if (key === "pnpm ui:build") {
return { stdout: "", stderr: "", code: 0 };
}
if (
key === `${process.execPath} ${path.join(tempDir, "openclaw.mjs")} doctor --non-interactive`
) {
return { stdout: "", stderr: "", code: 0 };
}
return { stdout: "", stderr: "", code: 0 };
};
const result = await runGatewayUpdate({
cwd: tempDir,
runCommand: async (argv, _options) => runCommand(argv),
timeoutMs: 5000,
channel: "stable",
});
expect(result.status).toBe("ok");
expect(calls).toContain("corepack enable");
expect(calls).toContain("pnpm install");
expect(calls).not.toContain("npm install --no-package-lock --legacy-peer-deps");
});
it("skips update when no git root", async () => {
await fs.writeFile(
path.join(tempDir, "package.json"),

View File

@@ -83,6 +83,8 @@ type UpdateRunnerOptions = {
progress?: UpdateStepProgress;
};
type BuildManager = "pnpm" | "bun" | "npm";
const DEFAULT_TIMEOUT_MS = 20 * 60_000;
const MAX_LOG_CHARS = 8000;
const PREFLIGHT_MAX_COMMITS = 10;
@@ -242,10 +244,91 @@ async function findPackageRoot(candidates: string[]) {
return null;
}
async function detectPackageManager(root: string) {
async function detectPackageManager(root: string): Promise<BuildManager> {
return (await detectPackageManagerImpl(root)) ?? "npm";
}
function managerPreferenceOrder(preferred: BuildManager): BuildManager[] {
if (preferred === "pnpm") {
return ["pnpm", "npm", "bun"];
}
if (preferred === "bun") {
return ["bun", "npm", "pnpm"];
}
return ["npm", "pnpm", "bun"];
}
function managerVersionArgs(manager: BuildManager): string[] {
if (manager === "pnpm") {
return ["pnpm", "--version"];
}
if (manager === "bun") {
return ["bun", "--version"];
}
return ["npm", "--version"];
}
async function isManagerAvailable(
runCommand: CommandRunner,
manager: BuildManager,
timeoutMs: number,
): Promise<boolean> {
try {
const res = await runCommand(managerVersionArgs(manager), { timeoutMs });
return res.code === 0;
} catch {
return false;
}
}
async function isCommandAvailable(
runCommand: CommandRunner,
argv: string[],
timeoutMs: number,
): Promise<boolean> {
try {
const res = await runCommand(argv, { timeoutMs });
return res.code === 0;
} catch {
return false;
}
}
async function ensurePnpmAvailable(runCommand: CommandRunner, timeoutMs: number): Promise<boolean> {
if (await isManagerAvailable(runCommand, "pnpm", timeoutMs)) {
return true;
}
if (!(await isCommandAvailable(runCommand, ["corepack", "--version"], timeoutMs))) {
return false;
}
try {
const res = await runCommand(["corepack", "enable"], { timeoutMs });
if (res.code !== 0) {
return false;
}
} catch {
return false;
}
return await isManagerAvailable(runCommand, "pnpm", timeoutMs);
}
async function resolveAvailableManager(
runCommand: CommandRunner,
root: string,
timeoutMs: number,
): Promise<{ manager: BuildManager; fallback: boolean }> {
const preferred = await detectPackageManager(root);
if (preferred === "pnpm" && (await ensurePnpmAvailable(runCommand, timeoutMs))) {
return { manager: "pnpm", fallback: false };
}
for (const manager of managerPreferenceOrder(preferred)) {
if (await isManagerAvailable(runCommand, manager, timeoutMs)) {
return { manager, fallback: manager !== preferred };
}
}
return { manager: "npm", fallback: preferred !== "npm" };
}
type RunStepOptions = {
runCommand: CommandRunner;
name: string;
@@ -295,7 +378,7 @@ async function runStep(opts: RunStepOptions): Promise<UpdateStepResult> {
};
}
function managerScriptArgs(manager: "pnpm" | "bun" | "npm", script: string, args: string[] = []) {
function managerScriptArgs(manager: BuildManager, script: string, args: string[] = []) {
if (manager === "pnpm") {
return ["pnpm", script, ...args];
}
@@ -308,13 +391,18 @@ function managerScriptArgs(manager: "pnpm" | "bun" | "npm", script: string, args
return ["npm", "run", script];
}
function managerInstallArgs(manager: "pnpm" | "bun" | "npm") {
function managerInstallArgs(manager: BuildManager, opts?: { compatFallback?: boolean }) {
if (manager === "pnpm") {
return ["pnpm", "install"];
}
if (manager === "bun") {
return ["bun", "install"];
}
if (opts?.compatFallback) {
// pnpm/bun workspaces can hit npm-only peer resolution conflicts and should not create
// a package-lock.json when npm is only acting as a compatibility fallback.
return ["npm", "install", "--no-package-lock", "--legacy-peer-deps"];
}
return ["npm", "install"];
}
@@ -533,7 +621,7 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
};
}
const manager = await detectPackageManager(gitRoot);
const manager = await resolveAvailableManager(runCommand, gitRoot, timeoutMs);
const preflightRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-preflight-"));
const worktreeDir = path.join(preflightRoot, "worktree");
const worktreeStep = await runStep(
@@ -574,7 +662,13 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
}
const depsStep = await runStep(
step(`preflight deps install (${shortSha})`, managerInstallArgs(manager), worktreeDir),
step(
`preflight deps install (${shortSha})`,
managerInstallArgs(manager.manager, {
compatFallback: manager.fallback && manager.manager === "npm",
}),
worktreeDir,
),
);
steps.push(depsStep);
if (depsStep.exitCode !== 0) {
@@ -582,7 +676,11 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
}
const buildStep = await runStep(
step(`preflight build (${shortSha})`, managerScriptArgs(manager, "build"), worktreeDir),
step(
`preflight build (${shortSha})`,
managerScriptArgs(manager.manager, "build"),
worktreeDir,
),
);
steps.push(buildStep);
if (buildStep.exitCode !== 0) {
@@ -590,7 +688,11 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
}
const lintStep = await runStep(
step(`preflight lint (${shortSha})`, managerScriptArgs(manager, "lint"), worktreeDir),
step(
`preflight lint (${shortSha})`,
managerScriptArgs(manager.manager, "lint"),
worktreeDir,
),
);
steps.push(lintStep);
if (lintStep.exitCode !== 0) {
@@ -699,9 +801,17 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
}
}
const manager = await detectPackageManager(gitRoot);
const manager = await resolveAvailableManager(runCommand, gitRoot, timeoutMs);
const depsStep = await runStep(step("deps install", managerInstallArgs(manager), gitRoot));
const depsStep = await runStep(
step(
"deps install",
managerInstallArgs(manager.manager, {
compatFallback: manager.fallback && manager.manager === "npm",
}),
gitRoot,
),
);
steps.push(depsStep);
if (depsStep.exitCode !== 0) {
return {
@@ -715,7 +825,9 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
};
}
const buildStep = await runStep(step("build", managerScriptArgs(manager, "build"), gitRoot));
const buildStep = await runStep(
step("build", managerScriptArgs(manager.manager, "build"), gitRoot),
);
steps.push(buildStep);
if (buildStep.exitCode !== 0) {
return {
@@ -730,7 +842,7 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
}
const uiBuildStep = await runStep(
step("ui:build", managerScriptArgs(manager, "ui:build"), gitRoot),
step("ui:build", managerScriptArgs(manager.manager, "ui:build"), gitRoot),
);
steps.push(uiBuildStep);
if (uiBuildStep.exitCode !== 0) {
@@ -781,7 +893,7 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
const uiIndexHealth = await resolveControlUiDistIndexHealth({ root: gitRoot });
if (!uiIndexHealth.exists) {
const repairArgv = managerScriptArgs(manager, "ui:build");
const repairArgv = managerScriptArgs(manager.manager, "ui:build");
const started = Date.now();
const repairResult = await runCommand(repairArgv, { cwd: gitRoot, timeoutMs });
const repairStep: UpdateStepResult = {