Skip to content

Commit d37b97c

Browse files
committed
refactor(update): extract package manager bootstrap logic
1 parent 0793136 commit d37b97c

8 files changed

Lines changed: 376 additions & 217 deletions

File tree

docs/cli/update.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,8 @@ High-level:
101101
8. Runs `openclaw doctor` as the final “safe update” check.
102102
9. Syncs plugins to the active channel (dev uses bundled extensions; stable/beta uses npm) and updates npm-installed plugins.
103103

104+
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.
105+
104106
## `--update` shorthand
105107

106108
`openclaw --update` rewrites to `openclaw update` (useful for shells and launcher scripts).

docs/install/updating.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ To return to latest: `git checkout main && git pull`.
132132
## If you are stuck
133133

134134
- Run `openclaw doctor` again and read the output carefully.
135+
- 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.
135136
- Check: [Troubleshooting](/gateway/troubleshooting)
136137
- Ask in Discord: [https://discord.gg/clawd](https://discord.gg/clawd)
137138

src/cli/update-cli/progress.test.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,19 +26,34 @@ function makeResult(
2626
}
2727

2828
describe("inferUpdateFailureHints", () => {
29-
it("returns a package-manager bootstrap hint for required manager failures", () => {
29+
it("returns a package-manager bootstrap hint for pnpm npm-bootstrap failures", () => {
3030
const result = {
3131
status: "error",
3232
mode: "git",
33-
reason: "required-manager-unavailable",
33+
reason: "pnpm-npm-bootstrap-failed",
3434
steps: [],
3535
durationMs: 1,
3636
} satisfies UpdateRunResult;
3737

3838
const hints = inferUpdateFailureHints(result);
3939

40-
expect(hints.join("\n")).toContain("requires its declared package manager");
41-
expect(hints.join("\n")).toContain("Install the missing package manager manually");
40+
expect(hints.join("\n")).toContain("bootstrap pnpm from npm");
41+
expect(hints.join("\n")).toContain("Install pnpm manually");
42+
});
43+
44+
it("returns a corepack hint when corepack is missing", () => {
45+
const result = {
46+
status: "error",
47+
mode: "git",
48+
reason: "pnpm-corepack-missing",
49+
steps: [],
50+
durationMs: 1,
51+
} satisfies UpdateRunResult;
52+
53+
const hints = inferUpdateFailureHints(result);
54+
55+
expect(hints.join("\n")).toContain("corepack is missing");
56+
expect(hints.join("\n")).toContain("Install pnpm manually");
4257
});
4358

4459
it("returns EACCES hint for global update permission failures", () => {

src/cli/update-cli/progress.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,27 @@ export function inferUpdateFailureHints(result: UpdateRunResult): string[] {
4040
if (result.status !== "error") {
4141
return [];
4242
}
43-
if (result.reason === "required-manager-unavailable") {
43+
if (result.reason === "pnpm-corepack-missing") {
4444
return [
45-
"This checkout requires its declared package manager and the updater could not bootstrap it automatically.",
45+
"This pnpm checkout could not auto-enable pnpm because corepack is missing.",
46+
"Install pnpm manually or install Node with corepack available, then rerun the update command.",
47+
];
48+
}
49+
if (result.reason === "pnpm-corepack-enable-failed") {
50+
return [
51+
"This pnpm checkout could not auto-enable pnpm via corepack.",
52+
"Run `corepack enable` manually or install pnpm manually, then rerun the update command.",
53+
];
54+
}
55+
if (result.reason === "pnpm-npm-bootstrap-failed") {
56+
return [
57+
"This pnpm checkout could not bootstrap pnpm from npm automatically.",
58+
"Install pnpm manually, then rerun the update command.",
59+
];
60+
}
61+
if (result.reason === "preferred-manager-unavailable") {
62+
return [
63+
"This checkout requires its declared package manager and the updater could not find it.",
4664
"Install the missing package manager manually, then rerun the update command.",
4765
];
4866
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
resolveUpdateBuildManager,
4+
type PackageManagerCommandRunner,
5+
} from "./update-package-manager.js";
6+
7+
describe("resolveUpdateBuildManager", () => {
8+
it("bootstraps pnpm via npm when pnpm and corepack are unavailable", async () => {
9+
const paths: string[] = [];
10+
const runCommand: PackageManagerCommandRunner = async (argv, options) => {
11+
const key = argv.join(" ");
12+
if (key === "pnpm --version") {
13+
const envPath = options.env?.PATH ?? options.env?.Path ?? "";
14+
if (envPath.includes("openclaw-update-pnpm-")) {
15+
paths.push(envPath);
16+
return { stdout: "10.0.0", stderr: "", code: 0 };
17+
}
18+
throw new Error("spawn pnpm ENOENT");
19+
}
20+
if (key === "corepack --version") {
21+
throw new Error("spawn corepack ENOENT");
22+
}
23+
if (key === "npm --version") {
24+
return { stdout: "10.0.0", stderr: "", code: 0 };
25+
}
26+
if (key.startsWith("npm install --prefix ") && key.endsWith(" pnpm@10")) {
27+
return { stdout: "added 1 package", stderr: "", code: 0 };
28+
}
29+
return { stdout: "", stderr: "", code: 0 };
30+
};
31+
32+
const result = await resolveUpdateBuildManager(runCommand, process.cwd(), 5000, undefined);
33+
34+
expect(result.kind).toBe("resolved");
35+
if (result.kind === "resolved") {
36+
expect(result.manager).toBe("pnpm");
37+
expect(paths.some((value) => value.includes("openclaw-update-pnpm-"))).toBe(true);
38+
await result.cleanup?.();
39+
}
40+
});
41+
42+
it("returns a specific bootstrap failure when pnpm cannot be installed from npm", async () => {
43+
const runCommand: PackageManagerCommandRunner = async (argv) => {
44+
const key = argv.join(" ");
45+
if (key === "pnpm --version") {
46+
throw new Error("spawn pnpm ENOENT");
47+
}
48+
if (key === "corepack --version") {
49+
throw new Error("spawn corepack ENOENT");
50+
}
51+
if (key === "npm --version") {
52+
return { stdout: "10.0.0", stderr: "", code: 0 };
53+
}
54+
if (key.startsWith("npm install --prefix ") && key.endsWith(" pnpm@10")) {
55+
return { stdout: "", stderr: "network exploded", code: 1 };
56+
}
57+
return { stdout: "", stderr: "", code: 0 };
58+
};
59+
60+
const result = await resolveUpdateBuildManager(
61+
runCommand,
62+
process.cwd(),
63+
5000,
64+
undefined,
65+
"require-preferred",
66+
);
67+
68+
expect(result).toEqual({
69+
kind: "missing-required",
70+
preferred: "pnpm",
71+
reason: "pnpm-npm-bootstrap-failed",
72+
});
73+
});
74+
});
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
import fs from "node:fs/promises";
2+
import os from "node:os";
3+
import path from "node:path";
4+
import { detectPackageManager as detectPackageManagerImpl } from "./detect-package-manager.js";
5+
import { applyPathPrepend } from "./path-prepend.js";
6+
7+
export type BuildManager = "pnpm" | "bun" | "npm";
8+
9+
export type UpdatePackageManagerRequirement = "allow-fallback" | "require-preferred";
10+
11+
export type UpdatePackageManagerFailureReason =
12+
| "preferred-manager-unavailable"
13+
| "pnpm-corepack-enable-failed"
14+
| "pnpm-corepack-missing"
15+
| "pnpm-npm-bootstrap-failed";
16+
17+
export type PackageManagerCommandRunner = (
18+
argv: string[],
19+
options: { timeoutMs: number; env?: NodeJS.ProcessEnv },
20+
) => Promise<{ stdout: string; stderr: string; code: number | null }>;
21+
22+
export type ResolvedBuildManager =
23+
| {
24+
kind: "resolved";
25+
manager: BuildManager;
26+
preferred: BuildManager;
27+
fallback: boolean;
28+
env?: NodeJS.ProcessEnv;
29+
cleanup?: () => Promise<void>;
30+
}
31+
| {
32+
kind: "missing-required";
33+
preferred: BuildManager;
34+
reason: UpdatePackageManagerFailureReason;
35+
};
36+
37+
export async function detectBuildManager(root: string): Promise<BuildManager> {
38+
return (await detectPackageManagerImpl(root)) ?? "npm";
39+
}
40+
41+
function managerPreferenceOrder(preferred: BuildManager): BuildManager[] {
42+
if (preferred === "pnpm") {
43+
return ["pnpm", "npm", "bun"];
44+
}
45+
if (preferred === "bun") {
46+
return ["bun", "npm", "pnpm"];
47+
}
48+
return ["npm", "pnpm", "bun"];
49+
}
50+
51+
function managerVersionArgs(manager: BuildManager): string[] {
52+
if (manager === "pnpm") {
53+
return ["pnpm", "--version"];
54+
}
55+
if (manager === "bun") {
56+
return ["bun", "--version"];
57+
}
58+
return ["npm", "--version"];
59+
}
60+
61+
async function isManagerAvailable(
62+
runCommand: PackageManagerCommandRunner,
63+
manager: BuildManager,
64+
timeoutMs: number,
65+
env?: NodeJS.ProcessEnv,
66+
): Promise<boolean> {
67+
try {
68+
const res = await runCommand(managerVersionArgs(manager), { timeoutMs, env });
69+
return res.code === 0;
70+
} catch {
71+
return false;
72+
}
73+
}
74+
75+
async function isCommandAvailable(
76+
runCommand: PackageManagerCommandRunner,
77+
argv: string[],
78+
timeoutMs: number,
79+
env?: NodeJS.ProcessEnv,
80+
): Promise<boolean> {
81+
try {
82+
const res = await runCommand(argv, { timeoutMs, env });
83+
return res.code === 0;
84+
} catch {
85+
return false;
86+
}
87+
}
88+
89+
function cloneCommandEnv(env?: NodeJS.ProcessEnv): Record<string, string> {
90+
return Object.fromEntries(
91+
Object.entries(env ?? process.env)
92+
.filter(([, value]) => value != null)
93+
.map(([key, value]) => [key, String(value)]),
94+
) as Record<string, string>;
95+
}
96+
97+
async function enablePnpmViaCorepack(
98+
runCommand: PackageManagerCommandRunner,
99+
timeoutMs: number,
100+
env?: NodeJS.ProcessEnv,
101+
): Promise<"enabled" | "missing" | "failed"> {
102+
if (!(await isCommandAvailable(runCommand, ["corepack", "--version"], timeoutMs, env))) {
103+
return "missing";
104+
}
105+
try {
106+
const res = await runCommand(["corepack", "enable"], { timeoutMs, env });
107+
if (res.code !== 0) {
108+
return "failed";
109+
}
110+
} catch {
111+
return "failed";
112+
}
113+
return (await isManagerAvailable(runCommand, "pnpm", timeoutMs, env)) ? "enabled" : "failed";
114+
}
115+
116+
async function bootstrapPnpmViaNpm(params: {
117+
runCommand: PackageManagerCommandRunner;
118+
timeoutMs: number;
119+
baseEnv?: NodeJS.ProcessEnv;
120+
}): Promise<{ env: NodeJS.ProcessEnv; cleanup: () => Promise<void> } | null> {
121+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-pnpm-"));
122+
const cleanup = async () => {
123+
await fs.rm(tempRoot, { recursive: true, force: true }).catch(() => {});
124+
};
125+
try {
126+
const installResult = await params.runCommand(
127+
["npm", "install", "--prefix", tempRoot, "pnpm@10"],
128+
{
129+
timeoutMs: params.timeoutMs,
130+
env: params.baseEnv,
131+
},
132+
);
133+
if (installResult.code !== 0) {
134+
await cleanup();
135+
return null;
136+
}
137+
const env = cloneCommandEnv(params.baseEnv);
138+
applyPathPrepend(env, [path.join(tempRoot, "node_modules", ".bin")]);
139+
if (!(await isManagerAvailable(params.runCommand, "pnpm", params.timeoutMs, env))) {
140+
await cleanup();
141+
return null;
142+
}
143+
return { env, cleanup };
144+
} catch {
145+
await cleanup();
146+
return null;
147+
}
148+
}
149+
150+
export async function resolveUpdateBuildManager(
151+
runCommand: PackageManagerCommandRunner,
152+
root: string,
153+
timeoutMs: number,
154+
baseEnv?: NodeJS.ProcessEnv,
155+
requirement: UpdatePackageManagerRequirement = "allow-fallback",
156+
): Promise<ResolvedBuildManager> {
157+
const preferred = await detectBuildManager(root);
158+
if (preferred === "pnpm") {
159+
if (await isManagerAvailable(runCommand, "pnpm", timeoutMs, baseEnv)) {
160+
return { kind: "resolved", manager: "pnpm", preferred, fallback: false };
161+
}
162+
163+
const corepackStatus = await enablePnpmViaCorepack(runCommand, timeoutMs, baseEnv);
164+
if (corepackStatus === "enabled") {
165+
return { kind: "resolved", manager: "pnpm", preferred, fallback: false };
166+
}
167+
168+
const npmAvailable = await isManagerAvailable(runCommand, "npm", timeoutMs, baseEnv);
169+
if (npmAvailable) {
170+
const pnpmBootstrap = await bootstrapPnpmViaNpm({
171+
runCommand,
172+
timeoutMs,
173+
baseEnv,
174+
});
175+
if (pnpmBootstrap) {
176+
return {
177+
kind: "resolved",
178+
manager: "pnpm",
179+
preferred,
180+
fallback: false,
181+
env: pnpmBootstrap.env,
182+
cleanup: pnpmBootstrap.cleanup,
183+
};
184+
}
185+
if (requirement === "require-preferred") {
186+
return { kind: "missing-required", preferred, reason: "pnpm-npm-bootstrap-failed" };
187+
}
188+
}
189+
190+
if (requirement === "require-preferred") {
191+
if (corepackStatus === "missing") {
192+
return { kind: "missing-required", preferred, reason: "pnpm-corepack-missing" };
193+
}
194+
if (corepackStatus === "failed") {
195+
return { kind: "missing-required", preferred, reason: "pnpm-corepack-enable-failed" };
196+
}
197+
return { kind: "missing-required", preferred, reason: "preferred-manager-unavailable" };
198+
}
199+
}
200+
201+
for (const manager of managerPreferenceOrder(preferred)) {
202+
if (await isManagerAvailable(runCommand, manager, timeoutMs, baseEnv)) {
203+
return { kind: "resolved", manager, preferred, fallback: manager !== preferred };
204+
}
205+
}
206+
207+
if (requirement === "require-preferred") {
208+
return { kind: "missing-required", preferred, reason: "preferred-manager-unavailable" };
209+
}
210+
211+
return { kind: "resolved", manager: "npm", preferred, fallback: preferred !== "npm" };
212+
}
213+
214+
export function managerScriptArgs(manager: BuildManager, script: string, args: string[] = []) {
215+
if (manager === "pnpm") {
216+
return ["pnpm", script, ...args];
217+
}
218+
if (manager === "bun") {
219+
return ["bun", "run", script, ...args];
220+
}
221+
if (args.length > 0) {
222+
return ["npm", "run", script, "--", ...args];
223+
}
224+
return ["npm", "run", script];
225+
}
226+
227+
export function managerInstallArgs(manager: BuildManager, opts?: { compatFallback?: boolean }) {
228+
if (manager === "pnpm") {
229+
return ["pnpm", "install"];
230+
}
231+
if (manager === "bun") {
232+
return ["bun", "install"];
233+
}
234+
if (opts?.compatFallback) {
235+
return ["npm", "install", "--no-package-lock", "--legacy-peer-deps"];
236+
}
237+
return ["npm", "install"];
238+
}

0 commit comments

Comments
 (0)