mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-23 16:01:17 +00:00
fix: harden update channel switching
This commit is contained in:
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user