Skip to content

Commit ca462fb

Browse files
committed
fix(update): bootstrap pnpm for dev preflight
1 parent e0354e7 commit ca462fb

5 files changed

Lines changed: 129 additions & 5 deletions

File tree

docs/cli/update.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ High-level:
9696
3. Fetches upstream (dev only).
9797
4. Dev only: preflight lint + TypeScript build in a temp worktree; if the tip fails, walks back up to 10 commits to find the newest clean build.
9898
5. Rebases onto the selected commit (dev only).
99-
6. Installs deps (pnpm preferred; npm fallback; bun remains available as a secondary compatibility fallback).
99+
6. Installs deps with the repo package manager. For pnpm checkouts, the updater bootstraps `pnpm` on demand (via `corepack` first, then a temporary `npm install pnpm@10` fallback) instead of running `npm run build` inside a pnpm workspace.
100100
7. Builds + builds the Control UI.
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.

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,21 @@ function makeResult(
2626
}
2727

2828
describe("inferUpdateFailureHints", () => {
29+
it("returns a package-manager bootstrap hint for required manager failures", () => {
30+
const result = {
31+
status: "error",
32+
mode: "git",
33+
reason: "required-manager-unavailable",
34+
steps: [],
35+
durationMs: 1,
36+
} satisfies UpdateRunResult;
37+
38+
const hints = inferUpdateFailureHints(result);
39+
40+
expect(hints.join("\n")).toContain("requires its declared package manager");
41+
expect(hints.join("\n")).toContain("Install the missing package manager manually");
42+
});
43+
2944
it("returns EACCES hint for global update permission failures", () => {
3045
const result = makeResult(
3146
"global update",

src/cli/update-cli/progress.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,16 @@ function getStepLabel(step: UpdateStepInfo): string {
3737
}
3838

3939
export function inferUpdateFailureHints(result: UpdateRunResult): string[] {
40-
if (result.status !== "error" || result.mode !== "npm") {
40+
if (result.status !== "error") {
41+
return [];
42+
}
43+
if (result.reason === "required-manager-unavailable") {
44+
return [
45+
"This checkout requires its declared package manager and the updater could not bootstrap it automatically.",
46+
"Install the missing package manager manually, then rerun the update command.",
47+
];
48+
}
49+
if (result.mode !== "npm") {
4150
return [];
4251
}
4352
const failedStep = [...result.steps].toReversed().find((step) => step.exitCode !== 0);

src/infra/update-runner.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -573,6 +573,68 @@ describe("runGatewayUpdate", () => {
573573
expect(pnpmEnvPaths.some((value) => value.includes("openclaw-update-pnpm-"))).toBe(true);
574574
});
575575

576+
it("does not fall back to npm scripts when a pnpm repo cannot bootstrap pnpm", async () => {
577+
await setupGitPackageManagerFixture();
578+
const calls: string[] = [];
579+
const upstreamSha = "upstream123";
580+
581+
const runCommand = async (
582+
argv: string[],
583+
_options?: { env?: NodeJS.ProcessEnv; cwd?: string; timeoutMs?: number },
584+
) => {
585+
const key = argv.join(" ");
586+
calls.push(key);
587+
588+
if (key === `git -C ${tempDir} rev-parse --show-toplevel`) {
589+
return { stdout: tempDir, stderr: "", code: 0 };
590+
}
591+
if (key === `git -C ${tempDir} rev-parse HEAD`) {
592+
return { stdout: "abc123", stderr: "", code: 0 };
593+
}
594+
if (key === `git -C ${tempDir} rev-parse --abbrev-ref HEAD`) {
595+
return { stdout: "main", stderr: "", code: 0 };
596+
}
597+
if (key === `git -C ${tempDir} status --porcelain -- :!dist/control-ui/`) {
598+
return { stdout: "", stderr: "", code: 0 };
599+
}
600+
if (key === `git -C ${tempDir} rev-parse --abbrev-ref --symbolic-full-name @{upstream}`) {
601+
return { stdout: "origin/main", stderr: "", code: 0 };
602+
}
603+
if (key === `git -C ${tempDir} fetch --all --prune --tags`) {
604+
return { stdout: "", stderr: "", code: 0 };
605+
}
606+
if (key === `git -C ${tempDir} rev-parse @{upstream}`) {
607+
return { stdout: upstreamSha, stderr: "", code: 0 };
608+
}
609+
if (key === `git -C ${tempDir} rev-list --max-count=10 ${upstreamSha}`) {
610+
return { stdout: `${upstreamSha}\n`, stderr: "", code: 0 };
611+
}
612+
if (key === "pnpm --version") {
613+
throw new Error("spawn pnpm ENOENT");
614+
}
615+
if (key === "corepack --version") {
616+
throw new Error("spawn corepack ENOENT");
617+
}
618+
if (key === "npm --version") {
619+
return { stdout: "10.0.0", stderr: "", code: 0 };
620+
}
621+
if (key.startsWith("npm install --prefix ") && key.endsWith(" pnpm@10")) {
622+
return { stdout: "", stderr: "network exploded", code: 1 };
623+
}
624+
return { stdout: "", stderr: "", code: 0 };
625+
};
626+
627+
const result = await runWithCommand(runCommand, { channel: "dev" });
628+
629+
expect(result.status).toBe("error");
630+
expect(result.reason).toBe("required-manager-unavailable");
631+
expect(calls.some((call) => call === "npm run build")).toBe(false);
632+
expect(calls.some((call) => call === "npm run lint")).toBe(false);
633+
expect(calls.some((call) => call.startsWith("git -C /tmp/openclaw-update-preflight-"))).toBe(
634+
false,
635+
);
636+
});
637+
576638
it("skips update when no git root", async () => {
577639
await fs.writeFile(
578640
path.join(tempDir, "package.json"),

src/infra/update-runner.ts

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,10 @@ type BuildManager = "pnpm" | "bun" | "npm";
9191
type ResolvedBuildManager = {
9292
manager: BuildManager;
9393
fallback: boolean;
94+
preferred: BuildManager;
9495
env?: NodeJS.ProcessEnv;
9596
cleanup?: () => Promise<void>;
97+
requiredPreferredMissing?: boolean;
9698
};
9799

98100
const DEFAULT_TIMEOUT_MS = 20 * 60_000;
@@ -375,10 +377,13 @@ async function resolveAvailableManager(
375377
root: string,
376378
timeoutMs: number,
377379
baseEnv?: NodeJS.ProcessEnv,
380+
opts?: {
381+
requirePreferred?: boolean;
382+
},
378383
): Promise<ResolvedBuildManager> {
379384
const preferred = await detectPackageManager(root);
380385
if (preferred === "pnpm" && (await ensurePnpmAvailable(runCommand, timeoutMs, baseEnv))) {
381-
return { manager: "pnpm", fallback: false };
386+
return { manager: "pnpm", fallback: false, preferred };
382387
}
383388
if (preferred === "pnpm" && (await isManagerAvailable(runCommand, "npm", timeoutMs, baseEnv))) {
384389
const pnpmBootstrap = await bootstrapPnpmViaNpm({
@@ -390,17 +395,26 @@ async function resolveAvailableManager(
390395
return {
391396
manager: "pnpm",
392397
fallback: false,
398+
preferred,
393399
env: pnpmBootstrap.env,
394400
cleanup: pnpmBootstrap.cleanup,
395401
};
396402
}
397403
}
404+
if (preferred === "pnpm" && opts?.requirePreferred) {
405+
return {
406+
manager: "pnpm",
407+
fallback: false,
408+
preferred,
409+
requiredPreferredMissing: true,
410+
};
411+
}
398412
for (const manager of managerPreferenceOrder(preferred)) {
399413
if (await isManagerAvailable(runCommand, manager, timeoutMs, baseEnv)) {
400-
return { manager, fallback: manager !== preferred };
414+
return { manager, fallback: manager !== preferred, preferred };
401415
}
402416
}
403-
return { manager: "npm", fallback: preferred !== "npm" };
417+
return { manager: "npm", fallback: preferred !== "npm", preferred };
404418
}
405419

406420
type RunStepOptions = {
@@ -720,7 +734,19 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
720734
gitRoot,
721735
timeoutMs,
722736
defaultCommandEnv,
737+
{ requirePreferred: true },
723738
);
739+
if (manager.requiredPreferredMissing) {
740+
return {
741+
status: "error",
742+
mode: "git",
743+
root: gitRoot,
744+
reason: "required-manager-unavailable",
745+
before: { sha: beforeSha, version: beforeVersion },
746+
steps,
747+
durationMs: Date.now() - startedAt,
748+
};
749+
}
724750
const preflightRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-preflight-"));
725751
const worktreeDir = path.join(preflightRoot, "worktree");
726752
const worktreeStep = await runStep(
@@ -909,7 +935,19 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
909935
gitRoot,
910936
timeoutMs,
911937
defaultCommandEnv,
938+
{ requirePreferred: true },
912939
);
940+
if (manager.requiredPreferredMissing) {
941+
return {
942+
status: "error",
943+
mode: "git",
944+
root: gitRoot,
945+
reason: "required-manager-unavailable",
946+
before: { sha: beforeSha, version: beforeVersion },
947+
steps,
948+
durationMs: Date.now() - startedAt,
949+
};
950+
}
913951
try {
914952
const depsStep = await runStep(
915953
step(

0 commit comments

Comments
 (0)