From 8ff1666a18d260a3f4b654df69dcb2a728472a7b Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Wed, 30 Sep 2026 19:54:42 +0100 Subject: [PATCH 1/2] fix: approve pnpm builds in cf init projects --- .changeset/pnpm-init-build-approvals.md | 10 ++++ .../cli/src/__tests__/commands/init.test.ts | 46 +++++++++++++++---- packages/cli/src/commands/init/template.ts | 14 ++++++ packages/cli/src/commands/init/workers.ts | 1 + 4 files changed, 62 insertions(+), 9 deletions(-) create mode 100644 .changeset/pnpm-init-build-approvals.md diff --git a/.changeset/pnpm-init-build-approvals.md b/.changeset/pnpm-init-build-approvals.md new file mode 100644 index 000000000..5ac95cf64 --- /dev/null +++ b/.changeset/pnpm-init-build-approvals.md @@ -0,0 +1,10 @@ +--- +"cf": patch +--- + +Allow required dependency build scripts in pnpm Worker projects + +`cf init` now writes `pnpm-workspace.yaml` when pnpm is selected, approving the +`esbuild` and `workerd` build scripts before dependency installation. This also +applies with `--no-install` so a later `pnpm install` succeeds on pnpm 11 and +newer. diff --git a/packages/cli/src/__tests__/commands/init.test.ts b/packages/cli/src/__tests__/commands/init.test.ts index 07ba7dab9..12b65244d 100644 --- a/packages/cli/src/__tests__/commands/init.test.ts +++ b/packages/cli/src/__tests__/commands/init.test.ts @@ -153,12 +153,10 @@ describe("cf init", () => { }); it("treats cf init workers as the same initializer", async () => { - const result = await runCf([ - "init", - "workers", - "other-app", - "--no-install", - ]); + const result = await runCf( + ["init", "workers", "other-app", "--no-install"], + { npm_config_user_agent: undefined } + ); expect(result.exitCode).toBe(0); expect(readdirSync("other-app").sort()).toEqual(TEMPLATE_FILES); @@ -181,7 +179,9 @@ describe("cf init", () => { vi.mocked(isNonInteractiveOrCI).mockReturnValue(false); vi.mocked(prompt).mockResolvedValueOnce("."); - const result = await runCf(["init", "--no-install"]); + const result = await runCf(["init", "--no-install"], { + npm_config_user_agent: undefined, + }); expect(result.exitCode).toBe(0); expect(prompt).toHaveBeenCalledExactlyOnceWith( @@ -201,7 +201,9 @@ describe("cf init", () => { vi.mocked(isNonInteractiveOrCI).mockReturnValue(false); vi.mocked(prompt).mockResolvedValueOnce(" prompted-app "); - const result = await runCf(["init", "--no-install"]); + const result = await runCf(["init", "--no-install"], { + npm_config_user_agent: undefined, + }); expect(result.exitCode).toBe(0); expect(readdirSync("prompted-app").sort()).toEqual(TEMPLATE_FILES); @@ -237,7 +239,9 @@ describe("cf init", () => { it("initializes a directory that only contains .git", async () => { await seed({ "repo/.git/HEAD": "ref: refs/heads/main\n" }); - const result = await runCf(["init", "repo", "--no-install"]); + const result = await runCf(["init", "repo", "--no-install"], { + npm_config_user_agent: undefined, + }); expect(result.exitCode).toBe(0); expect(readdirSync("repo").sort()).toEqual( @@ -293,6 +297,13 @@ describe("cf init", () => { describe("dependency installation", () => { it("installs with the package manager that launched cf", async () => { + vi.mocked(runProjectCommand).mockImplementationOnce(async () => { + expect(readFileSync("my-app/pnpm-workspace.yaml", "utf8")).toBe( + "# Approve build scripts needed by the generated Worker's dependencies.\n" + + "allowBuilds:\n esbuild: true\n workerd: true\n" + ); + return { exitCode: 0 }; + }); const result = await runCf(["init", "my-app"], { npm_config_user_agent: PNPM_USER_AGENT, }); @@ -306,6 +317,22 @@ describe("cf init", () => { expect(stdout()).not.toContain("Install dependencies"); }); + it("writes pnpm build approvals when installation is skipped", async () => { + const result = await runCf( + ["init", "my-app", "--package-manager", "pnpm", "--no-install"], + { npm_config_user_agent: undefined } + ); + + expect(result.exitCode).toBe(0); + expect(readdirSync("my-app").sort()).toEqual( + [...TEMPLATE_FILES, "pnpm-workspace.yaml"].sort() + ); + expect(readFileSync("my-app/pnpm-workspace.yaml", "utf8")).toContain( + " workerd: true" + ); + expect(runProjectCommand).not.toHaveBeenCalled(); + }); + it("shows the target directory when prompting for the package manager", async () => { let outputBeforePrompt = ""; vi.mocked(select).mockImplementationOnce(async () => { @@ -351,6 +378,7 @@ describe("cf init", () => { "bun install", resolve("my-app") ); + expect(existsSync("my-app/pnpm-workspace.yaml")).toBe(false); }); it("rejects unknown package managers", async () => { diff --git a/packages/cli/src/commands/init/template.ts b/packages/cli/src/commands/init/template.ts index e65eed590..e47c08bea 100644 --- a/packages/cli/src/commands/init/template.ts +++ b/packages/cli/src/commands/init/template.ts @@ -1,3 +1,5 @@ +import type { PackageManager } from "./workers.js"; + export const WORKER_TEMPLATE_DEV_DEPENDENCIES = { "@cloudflare/vite-plugin": "beta", typescript: "^7.0.2", @@ -8,6 +10,7 @@ export interface WorkerTemplateOptions { name: string; compatibilityDate: string; cfVersion: string; + packageManager: PackageManager; } export function renderWorkerTemplate( @@ -17,12 +20,23 @@ export function renderWorkerTemplate( ".gitignore": renderGitignore(), "cloudflare.config.ts": renderCloudflareConfig(options), "package.json": renderPackageJson(options), + ...(options.packageManager === "pnpm" + ? { "pnpm-workspace.yaml": renderPnpmWorkspace() } + : {}), "src/index.ts": renderEntrypoint(), "tsconfig.json": renderTsconfig(), "vite.config.ts": renderViteConfig(), }; } +function renderPnpmWorkspace(): string { + return `# Approve build scripts needed by the generated Worker's dependencies. +allowBuilds: + esbuild: true + workerd: true +`; +} + export function cfDependencyRange(version: string): string { return /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version) ? `^${version}` diff --git a/packages/cli/src/commands/init/workers.ts b/packages/cli/src/commands/init/workers.ts index 7e6f26fbd..9b01db76f 100644 --- a/packages/cli/src/commands/init/workers.ts +++ b/packages/cli/src/commands/init/workers.ts @@ -153,6 +153,7 @@ async function createWorkerProject( name, compatibilityDate: DEFAULT_COMPAT_DATE, cfVersion: VERSION, + packageManager, }); for (const [path, contents] of Object.entries(files)) { From e3a9d5f6525df6ff62e7e56ff1453afe4dece23f Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Fri, 2 Oct 2026 16:23:10 +0100 Subject: [PATCH 2/2] test: isolate package manager in CLI test helper --- .../cli/src/__tests__/commands/init.test.ts | 41 ++++++++----------- packages/cli/src/__tests__/helpers/run-cf.ts | 7 +++- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/packages/cli/src/__tests__/commands/init.test.ts b/packages/cli/src/__tests__/commands/init.test.ts index 12b65244d..a6ddd0304 100644 --- a/packages/cli/src/__tests__/commands/init.test.ts +++ b/packages/cli/src/__tests__/commands/init.test.ts @@ -82,9 +82,7 @@ describe("cf init", () => { describe("new projects", () => { it("creates a hello-world Worker in a new directory", async () => { - const result = await runCf(["init", "my-app", "--no-install"], { - npm_config_user_agent: undefined, - }); + const result = await runCf(["init", "my-app", "--no-install"]); expect(result.exitCode).toBe(0); expect(readdirSync("my-app").sort()).toEqual(TEMPLATE_FILES); @@ -153,10 +151,12 @@ describe("cf init", () => { }); it("treats cf init workers as the same initializer", async () => { - const result = await runCf( - ["init", "workers", "other-app", "--no-install"], - { npm_config_user_agent: undefined } - ); + const result = await runCf([ + "init", + "workers", + "other-app", + "--no-install", + ]); expect(result.exitCode).toBe(0); expect(readdirSync("other-app").sort()).toEqual(TEMPLATE_FILES); @@ -179,9 +179,7 @@ describe("cf init", () => { vi.mocked(isNonInteractiveOrCI).mockReturnValue(false); vi.mocked(prompt).mockResolvedValueOnce("."); - const result = await runCf(["init", "--no-install"], { - npm_config_user_agent: undefined, - }); + const result = await runCf(["init", "--no-install"]); expect(result.exitCode).toBe(0); expect(prompt).toHaveBeenCalledExactlyOnceWith( @@ -201,9 +199,7 @@ describe("cf init", () => { vi.mocked(isNonInteractiveOrCI).mockReturnValue(false); vi.mocked(prompt).mockResolvedValueOnce(" prompted-app "); - const result = await runCf(["init", "--no-install"], { - npm_config_user_agent: undefined, - }); + const result = await runCf(["init", "--no-install"]); expect(result.exitCode).toBe(0); expect(readdirSync("prompted-app").sort()).toEqual(TEMPLATE_FILES); @@ -239,9 +235,7 @@ describe("cf init", () => { it("initializes a directory that only contains .git", async () => { await seed({ "repo/.git/HEAD": "ref: refs/heads/main\n" }); - const result = await runCf(["init", "repo", "--no-install"], { - npm_config_user_agent: undefined, - }); + const result = await runCf(["init", "repo", "--no-install"]); expect(result.exitCode).toBe(0); expect(readdirSync("repo").sort()).toEqual( @@ -318,10 +312,13 @@ describe("cf init", () => { }); it("writes pnpm build approvals when installation is skipped", async () => { - const result = await runCf( - ["init", "my-app", "--package-manager", "pnpm", "--no-install"], - { npm_config_user_agent: undefined } - ); + const result = await runCf([ + "init", + "my-app", + "--package-manager", + "pnpm", + "--no-install", + ]); expect(result.exitCode).toBe(0); expect(readdirSync("my-app").sort()).toEqual( @@ -391,9 +388,7 @@ describe("cf init", () => { it("keeps the files and reports a failed install", async () => { vi.mocked(runProjectCommand).mockResolvedValueOnce({ exitCode: 7 }); - const result = await runCf(["init", "my-app"], { - npm_config_user_agent: undefined, - }); + const result = await runCf(["init", "my-app"]); expect(result.exitCode).toBe(7); expect(readdirSync("my-app").sort()).toEqual(TEMPLATE_FILES); diff --git a/packages/cli/src/__tests__/helpers/run-cf.ts b/packages/cli/src/__tests__/helpers/run-cf.ts index 3554a8f6c..ee506f864 100644 --- a/packages/cli/src/__tests__/helpers/run-cf.ts +++ b/packages/cli/src/__tests__/helpers/run-cf.ts @@ -14,6 +14,8 @@ import { CliExit } from "../../lib/cli-exit.js"; * failures, handler throws that aren't `CliExit`) propagate. * * `env` lets a test stub specific env vars for the duration of the call. + * The package manager user agent is cleared by default so tests do not + * depend on which package manager launched Vitest. * Vitest's `unstubEnvs: true` (in `vite.config.ts`) auto-restores * after each test so callers don't need to clean up. */ @@ -21,7 +23,10 @@ export async function runCf( argv: string[] = [], env: Record = {} ): Promise<{ exitCode: number }> { - for (const [key, value] of Object.entries(env)) { + for (const [key, value] of Object.entries({ + npm_config_user_agent: undefined, + ...env, + })) { vi.stubEnv(key, value); } try {