diff --git a/src/cli/argv.test.ts b/src/cli/argv.test.ts index de7c26cd01e..afecb06f6fd 100644 --- a/src/cli/argv.test.ts +++ b/src/cli/argv.test.ts @@ -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 }, diff --git a/src/cli/argv.ts b/src/cli/argv.ts index 7f8e5423b03..2b9675bd23b 100644 --- a/src/cli/argv.ts +++ b/src/cli/argv.ts @@ -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; } diff --git a/src/cli/program/build-program.ts b/src/cli/program/build-program.ts index 72cc798e62e..75d9de3dbd3 100644 --- a/src/cli/program/build-program.ts +++ b/src/cli/program/build-program.ts @@ -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; diff --git a/src/cli/program/config-guard.test.ts b/src/cli/program/config-guard.test.ts index 9b156acf3ea..58e5a2a903f 100644 --- a/src/cli/program/config-guard.test.ts +++ b/src/cli/program/config-guard.test.ts @@ -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); }); diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index abab0eb5cf4..0cd409fa1c5 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -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", diff --git a/src/cli/update-cli.ts b/src/cli/update-cli.ts index 529b65cd917..f0c669de3b8 100644 --- a/src/cli/update-cli.ts +++ b/src/cli/update-cli.ts @@ -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") diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts index 35716f84c2f..4d0436eb5ba 100644 --- a/src/infra/update-runner.test.ts +++ b/src/infra/update-runner.test.ts @@ -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, "", "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, "", "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"), diff --git a/src/infra/update-runner.ts b/src/infra/update-runner.ts index e39380c864b..65bd4bff815 100644 --- a/src/infra/update-runner.ts +++ b/src/infra/update-runner.ts @@ -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 { 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 { + 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 { + try { + const res = await runCommand(argv, { timeoutMs }); + return res.code === 0; + } catch { + return false; + } +} + +async function ensurePnpmAvailable(runCommand: CommandRunner, timeoutMs: number): Promise { + 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 { }; } -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 = {