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..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); @@ -293,6 +291,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 +311,25 @@ 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", + ]); + + 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 +375,7 @@ describe("cf init", () => { "bun install", resolve("my-app") ); + expect(existsSync("my-app/pnpm-workspace.yaml")).toBe(false); }); it("rejects unknown package managers", async () => { @@ -363,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 { 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)) {