diff --git a/docs/cli/update.md b/docs/cli/update.md index 64d68ccafc4..fae3f6d649c 100644 --- a/docs/cli/update.md +++ b/docs/cli/update.md @@ -101,6 +101,8 @@ High-level: 8. Runs `openclaw doctor` as the final “safe update” check. 9. Syncs plugins to the active channel (dev uses bundled extensions; stable/beta uses npm) and updates npm-installed plugins. +If pnpm bootstrap still fails, the updater now stops early with a package-manager-specific error instead of trying `npm run build` inside the checkout. + ## `--update` shorthand `openclaw --update` rewrites to `openclaw update` (useful for shells and launcher scripts). diff --git a/docs/install/updating.md b/docs/install/updating.md index 882c117deab..d12309c3e6d 100644 --- a/docs/install/updating.md +++ b/docs/install/updating.md @@ -132,6 +132,7 @@ To return to latest: `git checkout main && git pull`. ## If you are stuck - Run `openclaw doctor` again and read the output carefully. +- For `openclaw update --channel dev` on source checkouts, the updater auto-bootstraps `pnpm` when needed. If you see a pnpm/corepack bootstrap error, install `pnpm` manually (or re-enable `corepack`) and rerun the update. - Check: [Troubleshooting](/gateway/troubleshooting) - Ask in Discord: [https://discord.gg/clawd](https://discord.gg/clawd) diff --git a/src/cli/update-cli/progress.test.ts b/src/cli/update-cli/progress.test.ts index 8700fc5b178..1c4c7998c40 100644 --- a/src/cli/update-cli/progress.test.ts +++ b/src/cli/update-cli/progress.test.ts @@ -26,19 +26,34 @@ function makeResult( } describe("inferUpdateFailureHints", () => { - it("returns a package-manager bootstrap hint for required manager failures", () => { + it("returns a package-manager bootstrap hint for pnpm npm-bootstrap failures", () => { const result = { status: "error", mode: "git", - reason: "required-manager-unavailable", + reason: "pnpm-npm-bootstrap-failed", steps: [], durationMs: 1, } satisfies UpdateRunResult; const hints = inferUpdateFailureHints(result); - expect(hints.join("\n")).toContain("requires its declared package manager"); - expect(hints.join("\n")).toContain("Install the missing package manager manually"); + expect(hints.join("\n")).toContain("bootstrap pnpm from npm"); + expect(hints.join("\n")).toContain("Install pnpm manually"); + }); + + it("returns a corepack hint when corepack is missing", () => { + const result = { + status: "error", + mode: "git", + reason: "pnpm-corepack-missing", + steps: [], + durationMs: 1, + } satisfies UpdateRunResult; + + const hints = inferUpdateFailureHints(result); + + expect(hints.join("\n")).toContain("corepack is missing"); + expect(hints.join("\n")).toContain("Install pnpm manually"); }); it("returns EACCES hint for global update permission failures", () => { diff --git a/src/cli/update-cli/progress.ts b/src/cli/update-cli/progress.ts index 326fde69a8c..676231a0e7f 100644 --- a/src/cli/update-cli/progress.ts +++ b/src/cli/update-cli/progress.ts @@ -40,9 +40,27 @@ export function inferUpdateFailureHints(result: UpdateRunResult): string[] { if (result.status !== "error") { return []; } - if (result.reason === "required-manager-unavailable") { + if (result.reason === "pnpm-corepack-missing") { return [ - "This checkout requires its declared package manager and the updater could not bootstrap it automatically.", + "This pnpm checkout could not auto-enable pnpm because corepack is missing.", + "Install pnpm manually or install Node with corepack available, then rerun the update command.", + ]; + } + if (result.reason === "pnpm-corepack-enable-failed") { + return [ + "This pnpm checkout could not auto-enable pnpm via corepack.", + "Run `corepack enable` manually or install pnpm manually, then rerun the update command.", + ]; + } + if (result.reason === "pnpm-npm-bootstrap-failed") { + return [ + "This pnpm checkout could not bootstrap pnpm from npm automatically.", + "Install pnpm manually, then rerun the update command.", + ]; + } + if (result.reason === "preferred-manager-unavailable") { + return [ + "This checkout requires its declared package manager and the updater could not find it.", "Install the missing package manager manually, then rerun the update command.", ]; } diff --git a/src/infra/update-package-manager.test.ts b/src/infra/update-package-manager.test.ts new file mode 100644 index 00000000000..0a28fc278a8 --- /dev/null +++ b/src/infra/update-package-manager.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { + resolveUpdateBuildManager, + type PackageManagerCommandRunner, +} from "./update-package-manager.js"; + +describe("resolveUpdateBuildManager", () => { + it("bootstraps pnpm via npm when pnpm and corepack are unavailable", async () => { + const paths: string[] = []; + const runCommand: PackageManagerCommandRunner = async (argv, options) => { + const key = argv.join(" "); + if (key === "pnpm --version") { + const envPath = options.env?.PATH ?? options.env?.Path ?? ""; + if (envPath.includes("openclaw-update-pnpm-")) { + paths.push(envPath); + return { stdout: "10.0.0", stderr: "", code: 0 }; + } + throw new Error("spawn pnpm ENOENT"); + } + if (key === "corepack --version") { + throw new Error("spawn corepack ENOENT"); + } + if (key === "npm --version") { + return { stdout: "10.0.0", stderr: "", code: 0 }; + } + if (key.startsWith("npm install --prefix ") && key.endsWith(" pnpm@10")) { + return { stdout: "added 1 package", stderr: "", code: 0 }; + } + return { stdout: "", stderr: "", code: 0 }; + }; + + const result = await resolveUpdateBuildManager(runCommand, process.cwd(), 5000, undefined); + + expect(result.kind).toBe("resolved"); + if (result.kind === "resolved") { + expect(result.manager).toBe("pnpm"); + expect(paths.some((value) => value.includes("openclaw-update-pnpm-"))).toBe(true); + await result.cleanup?.(); + } + }); + + it("returns a specific bootstrap failure when pnpm cannot be installed from npm", async () => { + const runCommand: PackageManagerCommandRunner = async (argv) => { + const key = argv.join(" "); + if (key === "pnpm --version") { + throw new Error("spawn pnpm ENOENT"); + } + if (key === "corepack --version") { + throw new Error("spawn corepack ENOENT"); + } + if (key === "npm --version") { + return { stdout: "10.0.0", stderr: "", code: 0 }; + } + if (key.startsWith("npm install --prefix ") && key.endsWith(" pnpm@10")) { + return { stdout: "", stderr: "network exploded", code: 1 }; + } + return { stdout: "", stderr: "", code: 0 }; + }; + + const result = await resolveUpdateBuildManager( + runCommand, + process.cwd(), + 5000, + undefined, + "require-preferred", + ); + + expect(result).toEqual({ + kind: "missing-required", + preferred: "pnpm", + reason: "pnpm-npm-bootstrap-failed", + }); + }); +}); diff --git a/src/infra/update-package-manager.ts b/src/infra/update-package-manager.ts new file mode 100644 index 00000000000..144c5d2c869 --- /dev/null +++ b/src/infra/update-package-manager.ts @@ -0,0 +1,238 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { detectPackageManager as detectPackageManagerImpl } from "./detect-package-manager.js"; +import { applyPathPrepend } from "./path-prepend.js"; + +export type BuildManager = "pnpm" | "bun" | "npm"; + +export type UpdatePackageManagerRequirement = "allow-fallback" | "require-preferred"; + +export type UpdatePackageManagerFailureReason = + | "preferred-manager-unavailable" + | "pnpm-corepack-enable-failed" + | "pnpm-corepack-missing" + | "pnpm-npm-bootstrap-failed"; + +export type PackageManagerCommandRunner = ( + argv: string[], + options: { timeoutMs: number; env?: NodeJS.ProcessEnv }, +) => Promise<{ stdout: string; stderr: string; code: number | null }>; + +export type ResolvedBuildManager = + | { + kind: "resolved"; + manager: BuildManager; + preferred: BuildManager; + fallback: boolean; + env?: NodeJS.ProcessEnv; + cleanup?: () => Promise; + } + | { + kind: "missing-required"; + preferred: BuildManager; + reason: UpdatePackageManagerFailureReason; + }; + +export async function detectBuildManager(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: PackageManagerCommandRunner, + manager: BuildManager, + timeoutMs: number, + env?: NodeJS.ProcessEnv, +): Promise { + try { + const res = await runCommand(managerVersionArgs(manager), { timeoutMs, env }); + return res.code === 0; + } catch { + return false; + } +} + +async function isCommandAvailable( + runCommand: PackageManagerCommandRunner, + argv: string[], + timeoutMs: number, + env?: NodeJS.ProcessEnv, +): Promise { + try { + const res = await runCommand(argv, { timeoutMs, env }); + return res.code === 0; + } catch { + return false; + } +} + +function cloneCommandEnv(env?: NodeJS.ProcessEnv): Record { + return Object.fromEntries( + Object.entries(env ?? process.env) + .filter(([, value]) => value != null) + .map(([key, value]) => [key, String(value)]), + ) as Record; +} + +async function enablePnpmViaCorepack( + runCommand: PackageManagerCommandRunner, + timeoutMs: number, + env?: NodeJS.ProcessEnv, +): Promise<"enabled" | "missing" | "failed"> { + if (!(await isCommandAvailable(runCommand, ["corepack", "--version"], timeoutMs, env))) { + return "missing"; + } + try { + const res = await runCommand(["corepack", "enable"], { timeoutMs, env }); + if (res.code !== 0) { + return "failed"; + } + } catch { + return "failed"; + } + return (await isManagerAvailable(runCommand, "pnpm", timeoutMs, env)) ? "enabled" : "failed"; +} + +async function bootstrapPnpmViaNpm(params: { + runCommand: PackageManagerCommandRunner; + timeoutMs: number; + baseEnv?: NodeJS.ProcessEnv; +}): Promise<{ env: NodeJS.ProcessEnv; cleanup: () => Promise } | null> { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-pnpm-")); + const cleanup = async () => { + await fs.rm(tempRoot, { recursive: true, force: true }).catch(() => {}); + }; + try { + const installResult = await params.runCommand( + ["npm", "install", "--prefix", tempRoot, "pnpm@10"], + { + timeoutMs: params.timeoutMs, + env: params.baseEnv, + }, + ); + if (installResult.code !== 0) { + await cleanup(); + return null; + } + const env = cloneCommandEnv(params.baseEnv); + applyPathPrepend(env, [path.join(tempRoot, "node_modules", ".bin")]); + if (!(await isManagerAvailable(params.runCommand, "pnpm", params.timeoutMs, env))) { + await cleanup(); + return null; + } + return { env, cleanup }; + } catch { + await cleanup(); + return null; + } +} + +export async function resolveUpdateBuildManager( + runCommand: PackageManagerCommandRunner, + root: string, + timeoutMs: number, + baseEnv?: NodeJS.ProcessEnv, + requirement: UpdatePackageManagerRequirement = "allow-fallback", +): Promise { + const preferred = await detectBuildManager(root); + if (preferred === "pnpm") { + if (await isManagerAvailable(runCommand, "pnpm", timeoutMs, baseEnv)) { + return { kind: "resolved", manager: "pnpm", preferred, fallback: false }; + } + + const corepackStatus = await enablePnpmViaCorepack(runCommand, timeoutMs, baseEnv); + if (corepackStatus === "enabled") { + return { kind: "resolved", manager: "pnpm", preferred, fallback: false }; + } + + const npmAvailable = await isManagerAvailable(runCommand, "npm", timeoutMs, baseEnv); + if (npmAvailable) { + const pnpmBootstrap = await bootstrapPnpmViaNpm({ + runCommand, + timeoutMs, + baseEnv, + }); + if (pnpmBootstrap) { + return { + kind: "resolved", + manager: "pnpm", + preferred, + fallback: false, + env: pnpmBootstrap.env, + cleanup: pnpmBootstrap.cleanup, + }; + } + if (requirement === "require-preferred") { + return { kind: "missing-required", preferred, reason: "pnpm-npm-bootstrap-failed" }; + } + } + + if (requirement === "require-preferred") { + if (corepackStatus === "missing") { + return { kind: "missing-required", preferred, reason: "pnpm-corepack-missing" }; + } + if (corepackStatus === "failed") { + return { kind: "missing-required", preferred, reason: "pnpm-corepack-enable-failed" }; + } + return { kind: "missing-required", preferred, reason: "preferred-manager-unavailable" }; + } + } + + for (const manager of managerPreferenceOrder(preferred)) { + if (await isManagerAvailable(runCommand, manager, timeoutMs, baseEnv)) { + return { kind: "resolved", manager, preferred, fallback: manager !== preferred }; + } + } + + if (requirement === "require-preferred") { + return { kind: "missing-required", preferred, reason: "preferred-manager-unavailable" }; + } + + return { kind: "resolved", manager: "npm", preferred, fallback: preferred !== "npm" }; +} + +export function managerScriptArgs(manager: BuildManager, script: string, args: string[] = []) { + if (manager === "pnpm") { + return ["pnpm", script, ...args]; + } + if (manager === "bun") { + return ["bun", "run", script, ...args]; + } + if (args.length > 0) { + return ["npm", "run", script, "--", ...args]; + } + return ["npm", "run", script]; +} + +export function managerInstallArgs(manager: BuildManager, opts?: { compatFallback?: boolean }) { + if (manager === "pnpm") { + return ["pnpm", "install"]; + } + if (manager === "bun") { + return ["bun", "install"]; + } + if (opts?.compatFallback) { + return ["npm", "install", "--no-package-lock", "--legacy-peer-deps"]; + } + return ["npm", "install"]; +} diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts index ca452111cb4..a73bc3927af 100644 --- a/src/infra/update-runner.test.ts +++ b/src/infra/update-runner.test.ts @@ -627,7 +627,7 @@ describe("runGatewayUpdate", () => { const result = await runWithCommand(runCommand, { channel: "dev" }); expect(result.status).toBe("error"); - expect(result.reason).toBe("required-manager-unavailable"); + expect(result.reason).toBe("pnpm-npm-bootstrap-failed"); expect(calls.some((call) => call === "npm run build")).toBe(false); expect(calls.some((call) => call === "npm run lint")).toBe(false); expect(calls.some((call) => call.startsWith("git -C /tmp/openclaw-update-preflight-"))).toBe( diff --git a/src/infra/update-runner.ts b/src/infra/update-runner.ts index 750f0a4804d..130c4366a0c 100644 --- a/src/infra/update-runner.ts +++ b/src/infra/update-runner.ts @@ -6,10 +6,8 @@ import { resolveControlUiDistIndexHealth, resolveControlUiDistIndexPathForRoot, } from "./control-ui-assets.js"; -import { detectPackageManager as detectPackageManagerImpl } from "./detect-package-manager.js"; import { readPackageName, readPackageVersion } from "./package-json.js"; import { normalizePackageTagInput } from "./package-tag.js"; -import { applyPathPrepend } from "./path-prepend.js"; import { trimLogTail } from "./restart-sentinel.js"; import { resolveStableNodePath } from "./stable-node-path.js"; import { @@ -32,6 +30,12 @@ import { resolveGlobalInstallTarget, resolveGlobalInstallSpec, } from "./update-global.js"; +import { + managerInstallArgs, + managerScriptArgs, + resolveUpdateBuildManager, + type UpdatePackageManagerFailureReason, +} from "./update-package-manager.js"; export type UpdateStepResult = { name: string; @@ -87,15 +91,11 @@ type UpdateRunnerOptions = { progress?: UpdateStepProgress; }; -type BuildManager = "pnpm" | "bun" | "npm"; -type ResolvedBuildManager = { - manager: BuildManager; - fallback: boolean; - preferred: BuildManager; - env?: NodeJS.ProcessEnv; - cleanup?: () => Promise; - requiredPreferredMissing?: boolean; -}; +function mapManagerResolutionFailure( + reason: UpdatePackageManagerFailureReason, +): UpdateRunResult["reason"] { + return reason; +} const DEFAULT_TIMEOUT_MS = 20 * 60_000; const MAX_LOG_CHARS = 8000; @@ -256,167 +256,6 @@ async function findPackageRoot(candidates: string[]) { return null; } -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, - env?: NodeJS.ProcessEnv, -): Promise { - try { - const res = await runCommand(managerVersionArgs(manager), { timeoutMs, env }); - return res.code === 0; - } catch { - return false; - } -} - -async function isCommandAvailable( - runCommand: CommandRunner, - argv: string[], - timeoutMs: number, - env?: NodeJS.ProcessEnv, -): Promise { - try { - const res = await runCommand(argv, { timeoutMs, env }); - return res.code === 0; - } catch { - return false; - } -} - -async function ensurePnpmAvailable( - runCommand: CommandRunner, - timeoutMs: number, - env?: NodeJS.ProcessEnv, -): Promise { - if (await isManagerAvailable(runCommand, "pnpm", timeoutMs, env)) { - return true; - } - if (!(await isCommandAvailable(runCommand, ["corepack", "--version"], timeoutMs, env))) { - return false; - } - try { - const res = await runCommand(["corepack", "enable"], { timeoutMs, env }); - if (res.code !== 0) { - return false; - } - } catch { - return false; - } - return await isManagerAvailable(runCommand, "pnpm", timeoutMs, env); -} - -function cloneCommandEnv(env?: NodeJS.ProcessEnv): Record { - return Object.fromEntries( - Object.entries(env ?? process.env) - .filter(([, value]) => value != null) - .map(([key, value]) => [key, String(value)]), - ) as Record; -} - -async function bootstrapPnpmViaNpm(params: { - runCommand: CommandRunner; - timeoutMs: number; - baseEnv?: NodeJS.ProcessEnv; -}): Promise<{ env: NodeJS.ProcessEnv; cleanup: () => Promise } | null> { - const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-pnpm-")); - const cleanup = async () => { - await fs.rm(tempRoot, { recursive: true, force: true }).catch(() => {}); - }; - try { - const installResult = await params.runCommand( - ["npm", "install", "--prefix", tempRoot, "pnpm@10"], - { - timeoutMs: params.timeoutMs, - env: params.baseEnv, - }, - ); - if (installResult.code !== 0) { - await cleanup(); - return null; - } - const env = cloneCommandEnv(params.baseEnv); - applyPathPrepend(env, [path.join(tempRoot, "node_modules", ".bin")]); - if (!(await isManagerAvailable(params.runCommand, "pnpm", params.timeoutMs, env))) { - await cleanup(); - return null; - } - return { env, cleanup }; - } catch { - await cleanup(); - return null; - } -} - -async function resolveAvailableManager( - runCommand: CommandRunner, - root: string, - timeoutMs: number, - baseEnv?: NodeJS.ProcessEnv, - opts?: { - requirePreferred?: boolean; - }, -): Promise { - const preferred = await detectPackageManager(root); - if (preferred === "pnpm" && (await ensurePnpmAvailable(runCommand, timeoutMs, baseEnv))) { - return { manager: "pnpm", fallback: false, preferred }; - } - if (preferred === "pnpm" && (await isManagerAvailable(runCommand, "npm", timeoutMs, baseEnv))) { - const pnpmBootstrap = await bootstrapPnpmViaNpm({ - runCommand, - timeoutMs, - baseEnv, - }); - if (pnpmBootstrap) { - return { - manager: "pnpm", - fallback: false, - preferred, - env: pnpmBootstrap.env, - cleanup: pnpmBootstrap.cleanup, - }; - } - } - if (preferred === "pnpm" && opts?.requirePreferred) { - return { - manager: "pnpm", - fallback: false, - preferred, - requiredPreferredMissing: true, - }; - } - for (const manager of managerPreferenceOrder(preferred)) { - if (await isManagerAvailable(runCommand, manager, timeoutMs, baseEnv)) { - return { manager, fallback: manager !== preferred, preferred }; - } - } - return { manager: "npm", fallback: preferred !== "npm", preferred }; -} - type RunStepOptions = { runCommand: CommandRunner; name: string; @@ -466,34 +305,6 @@ async function runStep(opts: RunStepOptions): Promise { }; } -function managerScriptArgs(manager: BuildManager, script: string, args: string[] = []) { - if (manager === "pnpm") { - return ["pnpm", script, ...args]; - } - if (manager === "bun") { - return ["bun", "run", script, ...args]; - } - if (args.length > 0) { - return ["npm", "run", script, "--", ...args]; - } - return ["npm", "run", script]; -} - -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"]; -} - function normalizeTag(tag?: string) { return normalizePackageTagInput(tag, ["openclaw", DEFAULT_PACKAGE_NAME]) ?? "latest"; } @@ -729,19 +540,19 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< }; } - const manager = await resolveAvailableManager( - runCommand, + const manager = await resolveUpdateBuildManager( + (argv, options) => runCommand(argv, { timeoutMs: options.timeoutMs, env: options.env }), gitRoot, timeoutMs, defaultCommandEnv, - { requirePreferred: true }, + "require-preferred", ); - if (manager.requiredPreferredMissing) { + if (manager.kind === "missing-required") { return { status: "error", mode: "git", root: gitRoot, - reason: "required-manager-unavailable", + reason: mapManagerResolutionFailure(manager.reason), before: { sha: beforeSha, version: beforeVersion }, steps, durationMs: Date.now() - startedAt, @@ -930,19 +741,19 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< } } - const manager = await resolveAvailableManager( - runCommand, + const manager = await resolveUpdateBuildManager( + (argv, options) => runCommand(argv, { timeoutMs: options.timeoutMs, env: options.env }), gitRoot, timeoutMs, defaultCommandEnv, - { requirePreferred: true }, + "require-preferred", ); - if (manager.requiredPreferredMissing) { + if (manager.kind === "missing-required") { return { status: "error", mode: "git", root: gitRoot, - reason: "required-manager-unavailable", + reason: mapManagerResolutionFailure(manager.reason), before: { sha: beforeSha, version: beforeVersion }, steps, durationMs: Date.now() - startedAt,