diff --git a/.changeset/smart-adults-run.md b/.changeset/smart-adults-run.md new file mode 100644 index 000000000000..bfcee0d431ae --- /dev/null +++ b/.changeset/smart-adults-run.md @@ -0,0 +1,10 @@ +--- +"wrangler": minor +--- + +Add Pages detection to autoconfig flows + +When running the autoconfig logic (via `wrangler setup`, `wrangler deploy --x-autoconfig`, or the programmatic autoconfig API), Wrangler now detects when a project appears to be a Pages project and handles it appropriately: + +- For `wrangler deploy`, it warns the user but still allows them to proceed +- For `wrangler setup` and the programmatic autoconfig API, it throws a fatal error diff --git a/packages/wrangler/src/__tests__/autoconfig/details/get-details-for-auto-config.test.ts b/packages/wrangler/src/__tests__/autoconfig/details/get-details-for-auto-config.test.ts index 0116a1df036e..b998978e54d8 100644 --- a/packages/wrangler/src/__tests__/autoconfig/details/get-details-for-auto-config.test.ts +++ b/packages/wrangler/src/__tests__/autoconfig/details/get-details-for-auto-config.test.ts @@ -1,17 +1,21 @@ import { randomUUID } from "node:crypto"; import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; import { seed } from "@cloudflare/workers-utils/test-helpers"; /* eslint-disable workers-sdk/no-vitest-import-expect -- it.each patterns */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; /* eslint-enable workers-sdk/no-vitest-import-expect */ import * as details from "../../../autoconfig/details"; +import * as configCache from "../../../config-cache"; import { clearOutputFilePath } from "../../../output"; import { getPackageManager, NpmPackageManager, PnpmPackageManager, } from "../../../package-manager"; +import { PAGES_CONFIG_CACHE_FILENAME } from "../../../pages/constants"; import { mockConsoleMethods } from "../../helpers/mock-console"; +import { mockConfirm } from "../../helpers/mock-dialogs"; import { useMockIsTTY } from "../../helpers/mock-istty"; import { runInTempDir } from "../../helpers/run-in-tmp"; import type { Config } from "@cloudflare/workers-utils"; @@ -205,4 +209,107 @@ describe("autoconfig details - getDetailsForAutoConfig()", () => { workerName: "overridden-worker-name", }); }); + + describe("Pages project detection", () => { + it("should detect Pages project when pages_build_output_dir is set in wrangler config", async () => { + await seed({ + "public/index.html": `

Hello World

`, + }); + + const result = await details.getDetailsForAutoConfig({ + wranglerConfig: { + configPath: "/tmp/wrangler.toml", + pages_build_output_dir: "./dist", + } as Config, + }); + + expect(result.configured).toBe(false); + expect(result.framework?.id).toBe("cloudflare-pages"); + expect(result.framework?.name).toBe("Cloudflare Pages"); + }); + + it("should detect Pages project when pages.json cache file exists", async () => { + const cacheFolder = join(process.cwd(), ".cache"); + await seed({ + "public/index.html": `

Hello World

`, + // Create a cache folder in the temp directory and add pages.json to it + [join(cacheFolder, PAGES_CONFIG_CACHE_FILENAME)]: JSON.stringify({ + account_id: "test-account", + }), + }); + + // Mock getCacheFolder to return our temp cache folder + const getCacheFolderSpy = vi + .spyOn(configCache, "getCacheFolder") + .mockReturnValue(cacheFolder); + + try { + const result = await details.getDetailsForAutoConfig(); + + expect(result.framework?.id).toBe("cloudflare-pages"); + expect(result.framework?.name).toBe("Cloudflare Pages"); + } finally { + getCacheFolderSpy.mockRestore(); + } + }); + + it("should detect Pages project when functions directory exists, no framework is detected and the user confirms that it is", async () => { + await seed({ + "public/index.html": `

Hello World

`, + "functions/hello.js": ` + export function onRequest(context) { + return new Response("Hello, world!"); + } + `, + }); + + mockConfirm({ + text: "We have identified a `functions` directory in this project, which might indicate you have an active Cloudflare Pages deployment. Is this correct?", + result: true, + }); + + const result = await details.getDetailsForAutoConfig(); + + expect(result.framework?.id).toBe("cloudflare-pages"); + expect(result.framework?.name).toBe("Cloudflare Pages"); + }); + + it("should not detect Pages project when the user denies that, even it the functions directory exists and no framework is detected", async () => { + await seed({ + "public/index.html": `

Hello World

`, + "functions/hello.js": ` + export function onRequest(context) { + return new Response("Hello, world!"); + } + `, + }); + + mockConfirm({ + text: "We have identified a `functions` directory in this project, which might indicate you have an active Cloudflare Pages deployment. Is this correct?", + result: false, + }); + + const result = await details.getDetailsForAutoConfig(); + + expect(result.framework?.id).toBe("static"); + expect(result.framework?.name).toBe("Static"); + }); + + it("should not detect Pages project when functions directory exists but a framework is detected", async () => { + await seed({ + "functions/hello.js": + "export const myFun = () => { console.log('Hello!'); };", + "package.json": JSON.stringify({ + dependencies: { + astro: "5", + }, + }), + }); + + const result = await details.getDetailsForAutoConfig(); + + // Should detect Astro, not Pages + expect(result.framework?.id).toBe("astro"); + }); + }); }); diff --git a/packages/wrangler/src/__tests__/autoconfig/run.test.ts b/packages/wrangler/src/__tests__/autoconfig/run.test.ts index 8bbc23ea3673..4c510fcbfa0d 100644 --- a/packages/wrangler/src/__tests__/autoconfig/run.test.ts +++ b/packages/wrangler/src/__tests__/autoconfig/run.test.ts @@ -142,6 +142,42 @@ describe("autoconfig (deploy)", () => { expect(runSpy).not.toHaveBeenCalled(); }); + it("should warn and prompt when Pages project is detected", async () => { + vi.spyOn(details, "getDetailsForAutoConfig").mockImplementationOnce(() => + Promise.resolve({ + configured: false, + projectPath: process.cwd(), + workerName: "my-worker", + framework: { + id: "cloudflare-pages", + name: "Cloudflare Pages", + autoConfigSupported: false, + configure: async () => ({ wranglerConfig: {} }), + isConfigured: () => false, + }, + outputDir: "public", + }) + ); + const runSpy = vi.spyOn(run, "runAutoConfig"); + + // User declines to proceed + mockConfirm({ + text: "Are you sure that you want to proceed?", + result: false, + }); + + // Should not throw - just return early + await runWrangler("deploy --x-autoconfig"); + + // Should show warning about Pages project + expect(std.warn).toContain( + "It seems that you have run `wrangler deploy` on a Pages project" + ); + + // Should NOT run autoconfig since it's a Pages project + expect(runSpy).not.toHaveBeenCalled(); + }); + describe("runAutoConfig()", () => { let installSpy: MockInstance; beforeEach(() => { @@ -392,5 +428,55 @@ describe("autoconfig (deploy)", () => { `[AssertionError: The Output Directory is unexpectedly missing]` ); }); + + it("errors with Pages-specific message when framework is cf-pages", async () => { + mockConfirm({ + text: "Do you want to modify these settings?", + result: false, + }); + + await expect( + run.runAutoConfig({ + projectPath: process.cwd(), + configured: false, + framework: { + id: "cloudflare-pages", + name: "Cloudflare Pages", + autoConfigSupported: false, + configure: async () => ({ wranglerConfig: {} }), + isConfigured: () => false, + }, + workerName: "my-worker", + outputDir: "dist", + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: The target project seems to be using Cloudflare Pages. Automatically migrating from a Pages project to a Workers one is not yet supported.]` + ); + }); + + it("errors with generic message when unsupported framework is not cf-pages", async () => { + mockConfirm({ + text: "Do you want to modify these settings?", + result: false, + }); + + await expect( + run.runAutoConfig({ + projectPath: process.cwd(), + configured: false, + framework: { + id: "some-unsupported", + name: "Some Unsupported Framework", + autoConfigSupported: false, + configure: async () => ({ wranglerConfig: {} }), + isConfigured: () => false, + }, + workerName: "my-worker", + outputDir: "dist", + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: The detected framework ("Some Unsupported Framework") cannot be automatically configured.]` + ); + }); }); }); diff --git a/packages/wrangler/src/__tests__/deploy.test.ts b/packages/wrangler/src/__tests__/deploy.test.ts index 5df3f1b0c925..7ecd72da89c1 100644 --- a/packages/wrangler/src/__tests__/deploy.test.ts +++ b/packages/wrangler/src/__tests__/deploy.test.ts @@ -579,6 +579,80 @@ describe("deploy", () => { ); }); + it("should attempt to run the autoconfig flow when pages_build_output_dir and (--x-autoconfig is used)", async () => { + writeWranglerConfig({ + pages_build_output_dir: "public", + name: "test-name", + }); + + const getDetailsForAutoConfigSpy = vi + .spyOn(await import("../autoconfig/details"), "getDetailsForAutoConfig") + .mockResolvedValue({ + configured: false, + projectPath: process.cwd(), + workerName: "test-name", + framework: { + id: "cloudflare-pages", + name: "Cloudflare Pages", + autoConfigSupported: false, + configure: async () => ({ wranglerConfig: {} }), + isConfigured: () => false, + }, + outputDir: "public", + }); + + mockConfirm({ + text: "Are you sure that you want to proceed?", + options: { defaultValue: false }, + result: false, + }); + + await runWrangler("deploy --x-autoconfig"); + + expect(getDetailsForAutoConfigSpy).toHaveBeenCalled(); + + expect(std.warn).toContain( + "It seems that you have run `wrangler deploy` on a Pages project" + ); + }); + + it("in non-interactive mode, attempts to deploy a Pages project when --x-autoconfig is used", async () => { + setIsTTY(false); + writeWranglerConfig({ + pages_build_output_dir: "public", + name: "test-name", + }); + + const getDetailsForAutoConfigSpy = vi + .spyOn(await import("../autoconfig/details"), "getDetailsForAutoConfig") + .mockResolvedValue({ + configured: false, + projectPath: process.cwd(), + workerName: "test-name", + framework: { + id: "cloudflare-pages", + name: "Cloudflare Pages", + autoConfigSupported: false, + configure: async () => ({ wranglerConfig: {} }), + isConfigured: () => false, + }, + outputDir: "public", + }); + + // The command will fail later due to missing entry-point, but we can still verify + // that the deployment of the (Pages) project was attempted + await expect(runWrangler("deploy --x-autoconfig")).rejects.toThrow(); + + expect(getDetailsForAutoConfigSpy).toHaveBeenCalled(); + + expect(std.warn).toContain( + "It seems that you have run `wrangler deploy` on a Pages project" + ); + expect(std.out).toContain( + "Using fallback value in non-interactive context: yes" + ); + }); + describe("output additional script information", () => { it("for first party workers, it should print worker information at log level", async () => { setIsTTY(false); diff --git a/packages/wrangler/src/autoconfig/details.ts b/packages/wrangler/src/autoconfig/details.ts index 39ac6d127be1..4db10269cf65 100644 --- a/packages/wrangler/src/autoconfig/details.ts +++ b/packages/wrangler/src/autoconfig/details.ts @@ -1,5 +1,5 @@ import assert from "node:assert"; -import { statSync } from "node:fs"; +import { existsSync, statSync } from "node:fs"; import { readdir, stat } from "node:fs/promises"; import { basename, join, relative, resolve } from "node:path"; import { brandColor } from "@cloudflare/cli/colors"; @@ -12,11 +12,13 @@ import { import { Project } from "@netlify/build-info"; import { NodeFS } from "@netlify/build-info/node"; import { captureException } from "@sentry/node"; +import { getCacheFolder } from "../config-cache"; import { getErrorType } from "../core/handle-errors"; import { confirm, prompt, select } from "../dialogs"; import { logger } from "../logger"; import { sendMetricsEvent } from "../metrics"; import { getPackageManager } from "../package-manager"; +import { PAGES_CONFIG_CACHE_FILENAME } from "../pages/constants"; import { allKnownFrameworks, getFramework } from "./frameworks/get-framework"; import { getAutoConfigId, @@ -27,7 +29,6 @@ import type { AutoConfigDetailsForNonConfiguredProject, } from "./types"; import type { Config, PackageJSON } from "@cloudflare/workers-utils"; -import type { Settings } from "@netlify/build-info"; /** * Asserts that the current project being targeted for autoconfig is not already configured. @@ -65,9 +66,9 @@ async function hasIndexHtml(dir: string): Promise { } /** - * If we haven't detected a framework being used, we need to "guess" what output dir the user is intending to use. - * This is best-effort, and so will not be accurate all the time. The heuristic we use is the first child directory - * with an `index.html` file present. + * If we haven't detected a framework being used, or the project is a Pages one, we need to "guess" what output dir the + * user is intending to use. This is best-effort, and so will not be accurate all the time. The heuristic we use is the + * first child directory with an `index.html` file present. */ async function findAssetsDir(from: string): Promise { if (await hasIndexHtml(from)) { @@ -91,6 +92,91 @@ function getWorkerName(projectOrWorkerName = "", projectPath: string): string { return toValidWorkerName(rawName); } +type DetectedFramework = { + framework: { + name: string; + id: string; + }; + buildCommand?: string | undefined; + dist?: string; +}; + +async function isPagesProject( + projectPath: string, + wranglerConfig: Config | undefined, + detectedFramework?: DetectedFramework | undefined +): Promise { + if (wranglerConfig?.pages_build_output_dir) { + // The `pages_build_output_dir` is set only for Pages projects + return true; + } + + const cacheFolder = getCacheFolder(); + if (cacheFolder) { + const pagesConfigCache = join(cacheFolder, PAGES_CONFIG_CACHE_FILENAME); + if (existsSync(pagesConfigCache)) { + // If there is a cached pages.json we can safely assume that the project + // is a Pages one + return true; + } + } + + if (detectedFramework === undefined) { + const functionsPath = join(projectPath, "functions"); + if (existsSync(functionsPath)) { + const functionsStat = statSync(functionsPath); + if (functionsStat.isDirectory()) { + const pagesConfirmed = await confirm( + "We have identified a `functions` directory in this project, which might indicate you have an active Cloudflare Pages deployment. Is this correct?", + { + defaultValue: true, + // In CI we do want to fallback to `false` so that we can proceed with the autoconfig flow + fallbackValue: false, + } + ); + return pagesConfirmed; + } + } + } + + return false; +} + +async function detectFramework( + projectPath: string, + wranglerConfig?: Config +): Promise { + const fs = new NodeFS(); + + fs.logger = logger; + const project = new Project(fs, projectPath, projectPath) + .setEnvironment(process.env) + .setNodeVersion(process.version) + .setReportFn((err) => { + captureException(err); + }); + + const buildSettings = await project.getBuildSettings(); + + // If we've detected multiple frameworks, it's too complex for us to try and configure—let's just bail + if (buildSettings && buildSettings?.length > 1) { + throw new MultipleFrameworksError(buildSettings.map((b) => b.name)); + } + + const detectedFramework: DetectedFramework | undefined = buildSettings[0]; + + if (await isPagesProject(projectPath, wranglerConfig, detectedFramework)) { + return { + framework: { + name: "Cloudflare Pages", + id: "cloudflare-pages", + }, + }; + } + + return detectedFramework; +} + /** * Derives a valid worker name from a project directory. * @@ -139,32 +225,20 @@ export async function getDetailsForAutoConfig({ {} ); - // If a real Wrangler config has been found & used, don't run autoconfig - if (wranglerConfig?.configPath) { + if ( + // If a real Wrangler config has been found the project is already configured for Workers + wranglerConfig?.configPath && + // Unless `pages_build_output_dir` is set, since that indicates that the project is a Pages one instead + !wranglerConfig.pages_build_output_dir + ) { return { configured: true, projectPath, workerName: getWorkerName(wranglerConfig.name, projectPath), }; } - const fs = new NodeFS(); - - fs.logger = logger; - const project = new Project(fs, projectPath, projectPath) - .setEnvironment(process.env) - .setNodeVersion(process.version) - .setReportFn((err) => { - captureException(err); - }); - - const buildSettings = await project.getBuildSettings(); - - // If we've detected multiple frameworks, it's too complex for us to try and configure—let's just bail - if (buildSettings.length > 1) { - throw new MultipleFrameworksError(buildSettings.map((b) => b.name)); - } - const detectedFramework = buildSettings.at(0); + const detectedFramework = await detectFramework(projectPath, wranglerConfig); const framework = getFramework(detectedFramework?.framework?.id); const packageJsonPath = resolve(projectPath, "package.json"); @@ -216,7 +290,7 @@ export async function getDetailsForAutoConfig({ if (!outputDir) { const errorMessage = - framework.id === "static" + framework.id === "static" || framework.id === "cloudflare-pages" ? "Could not detect a directory containing static files (e.g. html, css and js) for the project" : "Failed to detect an output directory for the project"; @@ -275,7 +349,7 @@ export async function getDetailsForAutoConfig({ * @returns A runnable command for the build process if detected, undefined otherwise */ async function getProjectBuildCommand( - detectedFramework: Settings + detectedFramework: DetectedFramework ): Promise { if (!detectedFramework.buildCommand) { return undefined; diff --git a/packages/wrangler/src/autoconfig/frameworks/get-framework.ts b/packages/wrangler/src/autoconfig/frameworks/get-framework.ts index 2895b66e8e3d..35bdda33f8c5 100644 --- a/packages/wrangler/src/autoconfig/frameworks/get-framework.ts +++ b/packages/wrangler/src/autoconfig/frameworks/get-framework.ts @@ -4,6 +4,7 @@ import { Astro } from "./astro"; import { Hono } from "./hono"; import { NextJs } from "./next"; import { Nuxt } from "./nuxt"; +import { CloudflarePages } from "./pages"; import { Qwik } from "./qwik"; import { ReactRouter } from "./react-router"; import { SolidStart } from "./solid-start"; @@ -43,6 +44,7 @@ export const allKnownFrameworks = [ { id: "vite", name: "Vite", class: Vite }, { id: "vike", name: "Vike", class: Vike }, { id: "waku", name: "Waku", class: Waku }, + { id: "cloudflare-pages", name: "Cloudflare Pages", class: CloudflarePages }, ] as const satisfies FrameworkInfo[]; export function getFramework(frameworkId?: FrameworkInfo["id"]): Framework { diff --git a/packages/wrangler/src/autoconfig/frameworks/pages.ts b/packages/wrangler/src/autoconfig/frameworks/pages.ts new file mode 100644 index 000000000000..e22b04d43a59 --- /dev/null +++ b/packages/wrangler/src/autoconfig/frameworks/pages.ts @@ -0,0 +1,13 @@ +import { Framework } from "."; +import type { ConfigurationResults } from "."; + +export class CloudflarePages extends Framework { + async configure(): Promise { + return { + wranglerConfig: {}, + }; + } + + // Autoconfiguring a Pages project into a Workers one is not yet supported + autoConfigSupported = false; +} diff --git a/packages/wrangler/src/autoconfig/run.ts b/packages/wrangler/src/autoconfig/run.ts index 253870cbb845..39fa2d4a388f 100644 --- a/packages/wrangler/src/autoconfig/run.ts +++ b/packages/wrangler/src/autoconfig/run.ts @@ -79,17 +79,19 @@ export async function runAutoConfig( autoConfigDetails = updatedAutoConfigDetails; assertNonConfigured(autoConfigDetails); - assert( - autoConfigDetails.outputDir, - "The Output Directory is unexpectedly missing" - ); - if (!autoConfigDetails.framework.autoConfigSupported) { throw new FatalError( - `The detected framework ("${autoConfigDetails.framework.name}") cannot be automatically configured.` + autoConfigDetails.framework.id === "cloudflare-pages" + ? `The target project seems to be using Cloudflare Pages. Automatically migrating from a Pages project to a Workers one is not yet supported.` + : `The detected framework ("${autoConfigDetails.framework.name}") cannot be automatically configured.` ); } + assert( + autoConfigDetails.outputDir, + "The Output Directory is unexpectedly missing" + ); + const { date: compatibilityDate } = getLocalWorkerdCompatibilityDate({ projectPath: autoConfigDetails.projectPath, }); diff --git a/packages/wrangler/src/config-cache.ts b/packages/wrangler/src/config-cache.ts index 7e052ecf3469..5315d83aa325 100644 --- a/packages/wrangler/src/config-cache.ts +++ b/packages/wrangler/src/config-cache.ts @@ -7,7 +7,7 @@ import { logger } from "./logger"; let cacheMessageShown = false; let __cacheFolder: string | null | undefined; -function getCacheFolder() { +export function getCacheFolder() { if (__cacheFolder || __cacheFolder === null) { return __cacheFolder; } diff --git a/packages/wrangler/src/deploy/index.ts b/packages/wrangler/src/deploy/index.ts index e741e64adb64..68e942510f3b 100644 --- a/packages/wrangler/src/deploy/index.ts +++ b/packages/wrangler/src/deploy/index.ts @@ -268,14 +268,6 @@ export const deployCommand = createCommand({ } }, async handler(args, { config }) { - if (config.pages_build_output_dir) { - throw new UserError( - "It looks like you've run a Workers-specific command in a Pages project.\n" + - "For Pages, please run `wrangler pages deploy` instead.", - { telemetryMessage: true } - ); - } - const shouldRunAutoConfig = args.experimentalAutoconfig && // If there is a positional parameter or an assets directory specified via --assets then @@ -284,6 +276,18 @@ export const deployCommand = createCommand({ !args.script && !args.assets; + if ( + config.pages_build_output_dir && + // Note: autoconfig handle Pages projects on its own, so we don't want to hard fail here if autoconfig run + !shouldRunAutoConfig + ) { + throw new UserError( + "It looks like you've run a Workers-specific command in a Pages project.\n" + + "For Pages, please run `wrangler pages deploy` instead.", + { telemetryMessage: true } + ); + } + if (shouldRunAutoConfig) { sendAutoConfigProcessStartedMetricsEvent({ command: "wrangler deploy", @@ -295,8 +299,29 @@ export const deployCommand = createCommand({ wranglerConfig: config, }); - // Only run auto config if the project is not already configured - if (!details.configured) { + if (details.framework?.id === "cloudflare-pages") { + // If the project is a Pages project then warn the user but allow them to proceed if they wish so + logger.warn( + "It seems that you have run `wrangler deploy` on a Pages project, `wrangler pages deploy` should be used instead. Proceeding will likely produce unwanted results." + ); + const proceedWithPagesProject = await confirm( + "Are you sure that you want to proceed?", + { + defaultValue: false, + fallbackValue: true, + } + ); + + if (!proceedWithPagesProject) { + sendAutoConfigProcessEndedMetricsEvent({ + success: false, + command: "wrangler deploy", + dryRun: !!args.dryRun, + }); + return; + } + } else if (!details.configured) { + // Only run auto config if the project is not already configured const autoConfigSummary = await runAutoConfig(details); writeOutput({