Skip to content

Commit 47ccc3d

Browse files
committed
fix: bootstrap pnpm for git updates
1 parent 3528d06 commit 47ccc3d

2 files changed

Lines changed: 377 additions & 163 deletions

File tree

src/infra/update-runner.test.ts

Lines changed: 140 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,10 @@ describe("runGatewayUpdate", () => {
168168
buildCommand: string;
169169
uiBuildCommand: string;
170170
doctorCommand: string;
171-
onCommand?: (key: string) => Promise<CommandResponse | undefined> | CommandResponse | undefined;
171+
onCommand?: (
172+
key: string,
173+
options?: { env?: NodeJS.ProcessEnv; cwd?: string; timeoutMs?: number },
174+
) => Promise<CommandResponse | undefined> | CommandResponse | undefined;
172175
}) {
173176
const calls: string[] = [];
174177
const responses = {
@@ -179,10 +182,13 @@ describe("runGatewayUpdate", () => {
179182
[params.doctorCommand]: { stdout: "" },
180183
} satisfies Record<string, CommandResponse>;
181184

182-
const runCommand = async (argv: string[]) => {
185+
const runCommand = async (
186+
argv: string[],
187+
options?: { env?: NodeJS.ProcessEnv; cwd?: string; timeoutMs?: number },
188+
) => {
183189
const key = argv.join(" ");
184190
calls.push(key);
185-
const override = await params.onCommand?.(key);
191+
const override = await params.onCommand?.(key, options);
186192
if (override) {
187193
return toCommandResult(override);
188194
}
@@ -201,7 +207,7 @@ describe("runGatewayUpdate", () => {
201207
argv: string[],
202208
options?: { env?: NodeJS.ProcessEnv; cwd?: string; timeoutMs?: number },
203209
) => Promise<CommandResult>,
204-
options?: { channel?: "stable" | "beta"; tag?: string; cwd?: string },
210+
options?: { channel?: "stable" | "beta" | "dev"; tag?: string; cwd?: string },
205211
) {
206212
return runGatewayUpdate({
207213
cwd: options?.cwd ?? tempDir,
@@ -214,7 +220,7 @@ describe("runGatewayUpdate", () => {
214220

215221
async function runWithRunner(
216222
runner: (argv: string[]) => Promise<CommandResult>,
217-
options?: { channel?: "stable" | "beta"; tag?: string; cwd?: string },
223+
options?: { channel?: "stable" | "beta" | "dev"; tag?: string; cwd?: string },
218224
) {
219225
return runWithCommand(runner, options);
220226
}
@@ -376,39 +382,44 @@ describe("runGatewayUpdate", () => {
376382
expect(calls).not.toContain(`git -C ${tempDir} checkout --detach ${betaTag}`);
377383
});
378384

379-
it("falls back to npm when pnpm is unavailable for git installs", async () => {
385+
it("bootstraps pnpm via npm when pnpm and corepack are unavailable", async () => {
380386
await setupGitPackageManagerFixture();
381387
const stableTag = "v1.0.1-1";
382388
const { calls, runCommand } = createGitInstallRunner({
383389
stableTag,
384-
installCommand: "npm install --no-package-lock --legacy-peer-deps",
385-
buildCommand: "npm run build",
386-
uiBuildCommand: "npm run ui:build",
390+
installCommand: "pnpm install",
391+
buildCommand: "pnpm build",
392+
uiBuildCommand: "pnpm ui:build",
387393
doctorCommand: `${process.execPath} ${path.join(tempDir, "openclaw.mjs")} doctor --non-interactive`,
388-
onCommand: (key) => {
394+
onCommand: (key, options) => {
389395
if (key === "pnpm --version") {
396+
const envPath = options?.env?.PATH ?? options?.env?.Path ?? "";
397+
if (envPath.includes("openclaw-update-pnpm-")) {
398+
return { stdout: "10.0.0" };
399+
}
390400
throw new Error("spawn pnpm ENOENT");
391401
}
402+
if (key === "corepack --version") {
403+
throw new Error("spawn corepack ENOENT");
404+
}
392405
if (key === "npm --version") {
393406
return { stdout: "10.0.0" };
394407
}
408+
if (key.startsWith("npm install --prefix ") && key.endsWith(" pnpm@10")) {
409+
return { stdout: "added 1 package" };
410+
}
395411
return undefined;
396412
},
397413
});
398414

399-
const result = await runGatewayUpdate({
400-
cwd: tempDir,
401-
runCommand: async (argv, _options) => runCommand(argv),
402-
timeoutMs: 5000,
403-
channel: "stable",
404-
});
415+
const result = await runWithCommand(runCommand, { channel: "stable" });
405416

406417
expect(result.status).toBe("ok");
407418
expect(calls).toContain("pnpm --version");
408-
expect(calls).toContain("corepack --version");
419+
expect(calls.some((call) => call.startsWith("npm install --prefix "))).toBe(true);
409420
expect(calls).toContain("npm --version");
410-
expect(calls).toContain("npm install --no-package-lock --legacy-peer-deps");
411-
expect(calls).not.toContain("pnpm install");
421+
expect(calls).toContain("pnpm install");
422+
expect(calls).not.toContain("npm install --no-package-lock --legacy-peer-deps");
412423
});
413424

414425
it("bootstraps pnpm via corepack when pnpm is missing", async () => {
@@ -452,6 +463,116 @@ describe("runGatewayUpdate", () => {
452463
expect(calls).not.toContain("npm install --no-package-lock --legacy-peer-deps");
453464
});
454465

466+
it("uses npm-bootstrapped pnpm for dev preflight when pnpm and corepack are missing", async () => {
467+
await setupGitPackageManagerFixture();
468+
const calls: string[] = [];
469+
const pnpmEnvPaths: string[] = [];
470+
const upstreamSha = "upstream123";
471+
const doctorNodePath = await resolveStableNodePath(process.execPath);
472+
const doctorCommand = `${doctorNodePath} ${path.join(tempDir, "openclaw.mjs")} doctor --non-interactive --fix`;
473+
474+
const runCommand = async (
475+
argv: string[],
476+
options?: { env?: NodeJS.ProcessEnv; cwd?: string; timeoutMs?: number },
477+
) => {
478+
const key = argv.join(" ");
479+
calls.push(key);
480+
481+
if (key === `git -C ${tempDir} rev-parse --show-toplevel`) {
482+
return { stdout: tempDir, stderr: "", code: 0 };
483+
}
484+
if (key === `git -C ${tempDir} rev-parse HEAD`) {
485+
return { stdout: "abc123", stderr: "", code: 0 };
486+
}
487+
if (key === `git -C ${tempDir} rev-parse --abbrev-ref HEAD`) {
488+
return { stdout: "main", stderr: "", code: 0 };
489+
}
490+
if (key === `git -C ${tempDir} status --porcelain -- :!dist/control-ui/`) {
491+
return { stdout: "", stderr: "", code: 0 };
492+
}
493+
if (key === `git -C ${tempDir} rev-parse --abbrev-ref --symbolic-full-name @{upstream}`) {
494+
return { stdout: "origin/main", stderr: "", code: 0 };
495+
}
496+
if (key === `git -C ${tempDir} fetch --all --prune --tags`) {
497+
return { stdout: "", stderr: "", code: 0 };
498+
}
499+
if (key === `git -C ${tempDir} rev-parse @{upstream}`) {
500+
return { stdout: upstreamSha, stderr: "", code: 0 };
501+
}
502+
if (key === `git -C ${tempDir} rev-list --max-count=10 ${upstreamSha}`) {
503+
return { stdout: `${upstreamSha}\n`, stderr: "", code: 0 };
504+
}
505+
if (key === "pnpm --version") {
506+
const envPath = options?.env?.PATH ?? options?.env?.Path ?? "";
507+
if (envPath.includes("openclaw-update-pnpm-")) {
508+
pnpmEnvPaths.push(envPath);
509+
return { stdout: "10.0.0", stderr: "", code: 0 };
510+
}
511+
throw new Error("spawn pnpm ENOENT");
512+
}
513+
if (key === "corepack --version") {
514+
throw new Error("spawn corepack ENOENT");
515+
}
516+
if (key === "npm --version") {
517+
return { stdout: "10.0.0", stderr: "", code: 0 };
518+
}
519+
if (key.startsWith("npm install --prefix ") && key.endsWith(" pnpm@10")) {
520+
return { stdout: "added 1 package", stderr: "", code: 0 };
521+
}
522+
if (
523+
key.startsWith(`git -C ${tempDir} worktree add --detach /tmp/openclaw-update-preflight-`) &&
524+
key.endsWith(` /worktree ${upstreamSha}`)
525+
) {
526+
return { stdout: `HEAD is now at ${upstreamSha}`, stderr: "", code: 0 };
527+
}
528+
if (
529+
key.startsWith("git -C /tmp/openclaw-update-preflight-") &&
530+
key.includes("/worktree checkout --detach ") &&
531+
key.endsWith(upstreamSha)
532+
) {
533+
return { stdout: "", stderr: "", code: 0 };
534+
}
535+
if (key === "pnpm install" || key === "pnpm build" || key === "pnpm lint") {
536+
const envPath = options?.env?.PATH ?? options?.env?.Path ?? "";
537+
pnpmEnvPaths.push(envPath);
538+
return { stdout: "", stderr: "", code: 0 };
539+
}
540+
if (
541+
key.startsWith(`git -C ${tempDir} worktree remove --force /tmp/openclaw-update-preflight-`)
542+
) {
543+
return { stdout: "", stderr: "", code: 0 };
544+
}
545+
if (key === `git -C ${tempDir} worktree prune`) {
546+
return { stdout: "", stderr: "", code: 0 };
547+
}
548+
if (key === `git -C ${tempDir} rebase ${upstreamSha}`) {
549+
return { stdout: "", stderr: "", code: 0 };
550+
}
551+
if (key === "pnpm ui:build") {
552+
const envPath = options?.env?.PATH ?? options?.env?.Path ?? "";
553+
pnpmEnvPaths.push(envPath);
554+
return { stdout: "", stderr: "", code: 0 };
555+
}
556+
if (key === doctorCommand) {
557+
return { stdout: "", stderr: "", code: 0 };
558+
}
559+
if (key === `git -C ${tempDir} rev-parse HEAD`) {
560+
return { stdout: upstreamSha, stderr: "", code: 0 };
561+
}
562+
return { stdout: "", stderr: "", code: 0 };
563+
};
564+
565+
const result = await runWithCommand(runCommand, { channel: "dev" });
566+
567+
expect(result.status).toBe("ok");
568+
expect(calls.some((call) => call.startsWith("npm install --prefix "))).toBe(true);
569+
expect(calls).toContain("pnpm install");
570+
expect(calls).toContain("pnpm build");
571+
expect(calls).toContain("pnpm lint");
572+
expect(calls).toContain("pnpm ui:build");
573+
expect(pnpmEnvPaths.some((value) => value.includes("openclaw-update-pnpm-"))).toBe(true);
574+
});
575+
455576
it("skips update when no git root", async () => {
456577
await fs.writeFile(
457578
path.join(tempDir, "package.json"),

0 commit comments

Comments
 (0)