fix(update): bootstrap pnpm for dev preflight

This commit is contained in:
Peter Steinberger
2026-04-06 01:27:50 +01:00
parent e0354e71eb
commit ca462fb928
5 changed files with 129 additions and 5 deletions

View File

@@ -96,7 +96,7 @@ High-level:
3. Fetches upstream (dev only).
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.
5. Rebases onto the selected commit (dev only).
6. Installs deps (pnpm preferred; npm fallback; bun remains available as a secondary compatibility fallback).
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.
7. Builds + builds the Control UI.
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.

View File

@@ -26,6 +26,21 @@ function makeResult(
}
describe("inferUpdateFailureHints", () => {
it("returns a package-manager bootstrap hint for required manager failures", () => {
const result = {
status: "error",
mode: "git",
reason: "required-manager-unavailable",
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");
});
it("returns EACCES hint for global update permission failures", () => {
const result = makeResult(
"global update",

View File

@@ -37,7 +37,16 @@ function getStepLabel(step: UpdateStepInfo): string {
}
export function inferUpdateFailureHints(result: UpdateRunResult): string[] {
if (result.status !== "error" || result.mode !== "npm") {
if (result.status !== "error") {
return [];
}
if (result.reason === "required-manager-unavailable") {
return [
"This checkout requires its declared package manager and the updater could not bootstrap it automatically.",
"Install the missing package manager manually, then rerun the update command.",
];
}
if (result.mode !== "npm") {
return [];
}
const failedStep = [...result.steps].toReversed().find((step) => step.exitCode !== 0);

View File

@@ -573,6 +573,68 @@ describe("runGatewayUpdate", () => {
expect(pnpmEnvPaths.some((value) => value.includes("openclaw-update-pnpm-"))).toBe(true);
});
it("does not fall back to npm scripts when a pnpm repo cannot bootstrap pnpm", async () => {
await setupGitPackageManagerFixture();
const calls: string[] = [];
const upstreamSha = "upstream123";
const runCommand = async (
argv: string[],
_options?: { env?: NodeJS.ProcessEnv; cwd?: string; timeoutMs?: number },
) => {
const key = argv.join(" ");
calls.push(key);
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} rev-parse --abbrev-ref HEAD`) {
return { stdout: "main", stderr: "", code: 0 };
}
if (key === `git -C ${tempDir} status --porcelain -- :!dist/control-ui/`) {
return { stdout: "", stderr: "", code: 0 };
}
if (key === `git -C ${tempDir} rev-parse --abbrev-ref --symbolic-full-name @{upstream}`) {
return { stdout: "origin/main", stderr: "", code: 0 };
}
if (key === `git -C ${tempDir} fetch --all --prune --tags`) {
return { stdout: "", stderr: "", code: 0 };
}
if (key === `git -C ${tempDir} rev-parse @{upstream}`) {
return { stdout: upstreamSha, stderr: "", code: 0 };
}
if (key === `git -C ${tempDir} rev-list --max-count=10 ${upstreamSha}`) {
return { stdout: `${upstreamSha}\n`, stderr: "", code: 0 };
}
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 runWithCommand(runCommand, { channel: "dev" });
expect(result.status).toBe("error");
expect(result.reason).toBe("required-manager-unavailable");
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(
false,
);
});
it("skips update when no git root", async () => {
await fs.writeFile(
path.join(tempDir, "package.json"),

View File

@@ -91,8 +91,10 @@ type BuildManager = "pnpm" | "bun" | "npm";
type ResolvedBuildManager = {
manager: BuildManager;
fallback: boolean;
preferred: BuildManager;
env?: NodeJS.ProcessEnv;
cleanup?: () => Promise<void>;
requiredPreferredMissing?: boolean;
};
const DEFAULT_TIMEOUT_MS = 20 * 60_000;
@@ -375,10 +377,13 @@ async function resolveAvailableManager(
root: string,
timeoutMs: number,
baseEnv?: NodeJS.ProcessEnv,
opts?: {
requirePreferred?: boolean;
},
): Promise<ResolvedBuildManager> {
const preferred = await detectPackageManager(root);
if (preferred === "pnpm" && (await ensurePnpmAvailable(runCommand, timeoutMs, baseEnv))) {
return { manager: "pnpm", fallback: false };
return { manager: "pnpm", fallback: false, preferred };
}
if (preferred === "pnpm" && (await isManagerAvailable(runCommand, "npm", timeoutMs, baseEnv))) {
const pnpmBootstrap = await bootstrapPnpmViaNpm({
@@ -390,17 +395,26 @@ async function resolveAvailableManager(
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 };
return { manager, fallback: manager !== preferred, preferred };
}
}
return { manager: "npm", fallback: preferred !== "npm" };
return { manager: "npm", fallback: preferred !== "npm", preferred };
}
type RunStepOptions = {
@@ -720,7 +734,19 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
gitRoot,
timeoutMs,
defaultCommandEnv,
{ requirePreferred: true },
);
if (manager.requiredPreferredMissing) {
return {
status: "error",
mode: "git",
root: gitRoot,
reason: "required-manager-unavailable",
before: { sha: beforeSha, version: beforeVersion },
steps,
durationMs: Date.now() - startedAt,
};
}
const preflightRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-preflight-"));
const worktreeDir = path.join(preflightRoot, "worktree");
const worktreeStep = await runStep(
@@ -909,7 +935,19 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
gitRoot,
timeoutMs,
defaultCommandEnv,
{ requirePreferred: true },
);
if (manager.requiredPreferredMissing) {
return {
status: "error",
mode: "git",
root: gitRoot,
reason: "required-manager-unavailable",
before: { sha: beforeSha, version: beforeVersion },
steps,
durationMs: Date.now() - startedAt,
};
}
try {
const depsStep = await runStep(
step(