diff --git a/.changeset/twelve-signs-fry.md b/.changeset/twelve-signs-fry.md new file mode 100644 index 0000000000..ea7081f4e7 --- /dev/null +++ b/.changeset/twelve-signs-fry.md @@ -0,0 +1,7 @@ +--- +"wrangler": patch +--- + +Skip lock file warning for static projects during autoconfig + +Previously, running autoconfig on a static project (one with no framework detected) would emit a misleading warning about a missing lock file, suggesting the project might be in a workspace. Since static projects don't require a lock file, this warning is now suppressed for them. diff --git a/packages/wrangler/src/__tests__/autoconfig/details/framework-detection/basic-framework-detection.test.ts b/packages/wrangler/src/__tests__/autoconfig/details/framework-detection/basic-framework-detection.test.ts new file mode 100644 index 0000000000..11e72791c6 --- /dev/null +++ b/packages/wrangler/src/__tests__/autoconfig/details/framework-detection/basic-framework-detection.test.ts @@ -0,0 +1,48 @@ +import { writeFile } from "node:fs/promises"; +import { seed } from "@cloudflare/workers-utils/test-helpers"; +import { describe, it } from "vitest"; +import { detectFramework } from "../../../../autoconfig/details/framework-detection"; +import { runInTempDir } from "../../../helpers/run-in-tmp"; + +describe("detectFramework() / basic framework detection", () => { + runInTempDir(); + + it("defaults to the static framework when no framework is detected", async ({ + expect, + }) => { + await writeFile( + "package-lock.json", + JSON.stringify({ lockfileVersion: 3 }) + ); + + const result = await detectFramework(process.cwd()); + + expect(result.detectedFramework.framework.id).toBe("static"); + }); + + it("detects astro when astro is in dependencies", async ({ expect }) => { + await seed({ + "package.json": JSON.stringify({ dependencies: { astro: "5" } }), + "package-lock.json": JSON.stringify({ lockfileVersion: 3 }), + }); + + const result = await detectFramework(process.cwd()); + + expect(result.detectedFramework?.framework.id).toBe("astro"); + expect(result.detectedFramework?.framework.name).toBe("Astro"); + }); + + it("includes buildCommand in detectedFramework when available", async ({ + expect, + }) => { + await seed({ + "package.json": JSON.stringify({ dependencies: { astro: "5" } }), + "package-lock.json": JSON.stringify({ lockfileVersion: 3 }), + }); + + const result = await detectFramework(process.cwd()); + + expect(result.detectedFramework?.buildCommand).toBeDefined(); + expect(result.detectedFramework?.buildCommand).toContain("astro build"); + }); +}); diff --git a/packages/wrangler/src/__tests__/autoconfig/details/framework-detection/lock-file-warning.test.ts b/packages/wrangler/src/__tests__/autoconfig/details/framework-detection/lock-file-warning.test.ts new file mode 100644 index 0000000000..eeb1d9352f --- /dev/null +++ b/packages/wrangler/src/__tests__/autoconfig/details/framework-detection/lock-file-warning.test.ts @@ -0,0 +1,46 @@ +import { seed } from "@cloudflare/workers-utils/test-helpers"; +import { describe, it } from "vitest"; +import { detectFramework } from "../../../../autoconfig/details/framework-detection"; +import { mockConsoleMethods } from "../../../helpers/mock-console"; +import { runInTempDir } from "../../../helpers/run-in-tmp"; + +const noLockFileWarning = + "No lock file has been detected in the current working directory. This might indicate that the project is part of a workspace."; + +describe("detectFramework() / lock file warning", () => { + runInTempDir(); + const std = mockConsoleMethods(); + + it("warns when no lock file is detected", async ({ expect }) => { + await seed({ + "package.json": JSON.stringify({ dependencies: { astro: "5" } }), + }); + + await detectFramework(process.cwd()); + + expect(std.warn).toContain(noLockFileWarning); + }); + + it("does not warn for static projects without a lock file", async ({ + expect, + }) => { + await seed({ + "package.json": JSON.stringify({}), + }); + + await detectFramework(process.cwd()); + + expect(std.warn).not.toContain(noLockFileWarning); + }); + + it("does not warn when a lock file exists", async ({ expect }) => { + await seed({ + "package.json": JSON.stringify({ dependencies: { astro: "5" } }), + "package-lock.json": JSON.stringify({ lockfileVersion: 3 }), + }); + + await detectFramework(process.cwd()); + + expect(std.warn).not.toContain(noLockFileWarning); + }); +}); diff --git a/packages/wrangler/src/__tests__/autoconfig/details/framework-detection/multiple-frameworks-detected.test.ts b/packages/wrangler/src/__tests__/autoconfig/details/framework-detection/multiple-frameworks-detected.test.ts new file mode 100644 index 0000000000..ef54e79c38 --- /dev/null +++ b/packages/wrangler/src/__tests__/autoconfig/details/framework-detection/multiple-frameworks-detected.test.ts @@ -0,0 +1,151 @@ +import { seed } from "@cloudflare/workers-utils/test-helpers"; +import { afterEach, beforeEach, describe, it, vi } from "vitest"; +import { detectFramework } from "../../../../autoconfig/details/framework-detection"; +import * as isInteractiveModule from "../../../../is-interactive"; +import { runInTempDir } from "../../../helpers/run-in-tmp"; +import type { MockInstance } from "vitest"; + +describe("detectFramework() / multiple frameworks detected", () => { + runInTempDir(); + let isNonInteractiveOrCISpy: MockInstance; + + beforeEach(() => { + isNonInteractiveOrCISpy = vi + .spyOn(isInteractiveModule, "isNonInteractiveOrCI") + .mockReturnValue(false); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + isNonInteractiveOrCISpy.mockRestore(); + }); + + describe("non-CI environment", () => { + beforeEach(() => { + isNonInteractiveOrCISpy.mockReturnValue(false); + }); + + it("returns the known framework when multiple are detected but only one is known", async ({ + expect, + }) => { + // gatsby is not in allKnownFrameworks, only astro is + await seed({ + "package.json": JSON.stringify({ + dependencies: { astro: "5", gatsby: "5" }, + }), + "package-lock.json": JSON.stringify({ lockfileVersion: 3 }), + }); + + const result = await detectFramework(process.cwd()); + + expect(result.detectedFramework?.framework.id).toBe("astro"); + }); + + it("filters out Vite and returns the other known framework", async ({ + expect, + }) => { + await seed({ + "package.json": JSON.stringify({ + dependencies: { next: "14", vite: "5" }, + }), + "package-lock.json": JSON.stringify({ lockfileVersion: 3 }), + }); + + const result = await detectFramework(process.cwd()); + + expect(result.detectedFramework?.framework.id).toBe("next"); + }); + + it("returns Waku (not Hono) when both Waku and Hono are detected", async ({ + expect, + }) => { + await seed({ + "package.json": JSON.stringify({ + dependencies: { waku: "0.21", hono: "4" }, + }), + "package-lock.json": JSON.stringify({ lockfileVersion: 3 }), + }); + + const result = await detectFramework(process.cwd()); + + expect(result.detectedFramework?.framework.id).toBe("waku"); + }); + + it("returns first framework without throwing when multiple unknown frameworks are detected", async ({ + expect, + }) => { + // Both gatsby and gridsome are unknown to wrangler + await seed({ + "package.json": JSON.stringify({ + dependencies: { gatsby: "5", gridsome: "1" }, + }), + "package-lock.json": JSON.stringify({ lockfileVersion: 3 }), + }); + + // Should not throw even with multiple unknowns in non-CI mode + await expect(detectFramework(process.cwd())).resolves.toBeDefined(); + }); + }); + + describe("CI environment", () => { + beforeEach(() => { + isNonInteractiveOrCISpy.mockReturnValue(true); + }); + + it("throws MultipleFrameworksCIError when multiple known frameworks are detected", async ({ + expect, + }) => { + await seed({ + "package.json": JSON.stringify({ + dependencies: { astro: "5", nuxt: "3" }, + }), + "package-lock.json": JSON.stringify({ lockfileVersion: 3 }), + }); + + await expect( + detectFramework(process.cwd()) + ).rejects.toThrowErrorMatchingInlineSnapshot( + ` + [Error: Wrangler was unable to automatically configure your project to work with Cloudflare, since multiple frameworks were found: Astro, Nuxt. + + To fix this issue either: + - check your project's configuration to make sure that the target framework + is the only configured one and try again + - run \`wrangler setup\` locally to get an interactive user experience where + you can specify what framework you want to target + ] + ` + ); + }); + + it("throws MultipleFrameworksCIError when multiple unknown frameworks are detected", async ({ + expect, + }) => { + await seed({ + "package.json": JSON.stringify({ + dependencies: { gatsby: "5", gridsome: "1" }, + }), + "package-lock.json": JSON.stringify({ lockfileVersion: 3 }), + }); + + await expect(detectFramework(process.cwd())).rejects.toThrowError( + /Wrangler was unable to automatically configure your project to work with Cloudflare, since multiple frameworks were found/ + ); + }); + + it("does not throw when Vite and another known framework are detected (Vite is filtered out)", async ({ + expect, + }) => { + await seed({ + "package.json": JSON.stringify({ + dependencies: { astro: "5", vite: "5" }, + }), + "package-lock.json": JSON.stringify({ lockfileVersion: 3 }), + }); + + const result = await detectFramework(process.cwd()); + + expect(result.detectedFramework?.framework.id).toBe("astro"); + }); + }); +}); diff --git a/packages/wrangler/src/__tests__/autoconfig/details/framework-detection/package-manager-detection.test.ts b/packages/wrangler/src/__tests__/autoconfig/details/framework-detection/package-manager-detection.test.ts new file mode 100644 index 0000000000..8ebb32fa38 --- /dev/null +++ b/packages/wrangler/src/__tests__/autoconfig/details/framework-detection/package-manager-detection.test.ts @@ -0,0 +1,70 @@ +import { seed } from "@cloudflare/workers-utils/test-helpers"; +import { describe, it } from "vitest"; +import { detectFramework } from "../../../../autoconfig/details/framework-detection"; +import { + BunPackageManager, + NpmPackageManager, + PnpmPackageManager, + YarnPackageManager, +} from "../../../../package-manager"; +import { runInTempDir } from "../../../helpers/run-in-tmp"; + +describe("detectFramework() / package manager detection", () => { + runInTempDir(); + + it("detects npm when package-lock.json is present", async ({ expect }) => { + await seed({ + "package.json": JSON.stringify({ dependencies: { astro: "5" } }), + "package-lock.json": JSON.stringify({ lockfileVersion: 3 }), + }); + + const result = await detectFramework(process.cwd()); + + expect(result.packageManager).toStrictEqual(NpmPackageManager); + }); + + it("detects pnpm when pnpm-lock.yaml is present", async ({ expect }) => { + await seed({ + "package.json": JSON.stringify({ dependencies: { astro: "5" } }), + "pnpm-lock.yaml": "lockfileVersion: '6.0'\n", + }); + + const result = await detectFramework(process.cwd()); + + expect(result.packageManager).toStrictEqual(PnpmPackageManager); + }); + + it("detects yarn when yarn.lock is present", async ({ expect }) => { + await seed({ + "package.json": JSON.stringify({ dependencies: { astro: "5" } }), + "yarn.lock": "# yarn lockfile v1\n", + }); + + const result = await detectFramework(process.cwd()); + + expect(result.packageManager).toStrictEqual(YarnPackageManager); + }); + + it("detects bun when bun.lock is present", async ({ expect }) => { + await seed({ + "package.json": JSON.stringify({ dependencies: { astro: "5" } }), + "bun.lock": "", + }); + + const result = await detectFramework(process.cwd()); + + expect(result.packageManager).toStrictEqual(BunPackageManager); + }); + + it("falls back to npm when no package manager lock file is present", async ({ + expect, + }) => { + await seed({ + "package.json": JSON.stringify({ dependencies: { astro: "5" } }), + }); + + const result = await detectFramework(process.cwd()); + + expect(result.packageManager).toStrictEqual(NpmPackageManager); + }); +}); diff --git a/packages/wrangler/src/__tests__/autoconfig/details/framework-detection/pages-project-detection.test.ts b/packages/wrangler/src/__tests__/autoconfig/details/framework-detection/pages-project-detection.test.ts new file mode 100644 index 0000000000..19ed3dc3b1 --- /dev/null +++ b/packages/wrangler/src/__tests__/autoconfig/details/framework-detection/pages-project-detection.test.ts @@ -0,0 +1,123 @@ +import { join } from "node:path"; +import { seed } from "@cloudflare/workers-utils/test-helpers"; +import { afterEach, beforeEach, describe, it, vi } from "vitest"; +import { detectFramework } from "../../../../autoconfig/details/framework-detection"; +import * as configCache from "../../../../config-cache"; +import * as isInteractiveModule from "../../../../is-interactive"; +import { PAGES_CONFIG_CACHE_FILENAME } from "../../../../pages/constants"; +import { mockConfirm } from "../../../helpers/mock-dialogs"; +import { runInTempDir } from "../../../helpers/run-in-tmp"; +import type { Config } from "@cloudflare/workers-utils"; +import type { MockInstance } from "vitest"; + +describe("detectFramework() / Pages project detection", () => { + runInTempDir(); + let isNonInteractiveOrCISpy: MockInstance; + + beforeEach(() => { + isNonInteractiveOrCISpy = vi + .spyOn(isInteractiveModule, "isNonInteractiveOrCI") + .mockReturnValue(false); + }); + + afterEach(() => { + isNonInteractiveOrCISpy.mockRestore(); + }); + + it("returns Cloudflare Pages framework when pages_build_output_dir is set in wrangler config", async ({ + expect, + }) => { + await seed({ + "package.json": JSON.stringify({}), + "package-lock.json": JSON.stringify({ lockfileVersion: 3 }), + }); + + const result = await detectFramework(process.cwd(), { + pages_build_output_dir: "./dist", + } as Config); + + expect(result.detectedFramework?.framework.id).toBe("cloudflare-pages"); + expect(result.detectedFramework?.framework.name).toBe("Cloudflare Pages"); + expect(result.detectedFramework?.dist).toBe("./dist"); + }); + + it("returns Cloudflare Pages framework when the pages cache file exists", async ({ + expect, + }) => { + const cacheFolder = join(process.cwd(), ".cache"); + await seed({ + "package.json": JSON.stringify({}), + "package-lock.json": JSON.stringify({ lockfileVersion: 3 }), + [join(cacheFolder, PAGES_CONFIG_CACHE_FILENAME)]: JSON.stringify({ + account_id: "test-account", + }), + }); + + const getCacheFolderSpy = vi + .spyOn(configCache, "getCacheFolder") + .mockReturnValue(cacheFolder); + + try { + const result = await detectFramework(process.cwd()); + + expect(result.detectedFramework?.framework.id).toBe("cloudflare-pages"); + expect(result.detectedFramework?.framework.name).toBe("Cloudflare Pages"); + } finally { + getCacheFolderSpy.mockRestore(); + } + }); + + it("returns Cloudflare Pages when a functions/ directory exists, no framework is detected, and the user confirms", async ({ + expect, + }) => { + await seed({ + "package.json": JSON.stringify({}), + "package-lock.json": JSON.stringify({ lockfileVersion: 3 }), + "functions/hello.js": `export function onRequest() { return new Response("hi"); }`, + }); + + 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 detectFramework(process.cwd()); + + expect(result.detectedFramework?.framework.id).toBe("cloudflare-pages"); + expect(result.detectedFramework?.framework.name).toBe("Cloudflare Pages"); + }); + + it("does not return Cloudflare Pages when the user denies the functions/ directory prompt", async ({ + expect, + }) => { + await seed({ + "package.json": JSON.stringify({}), + "package-lock.json": JSON.stringify({ lockfileVersion: 3 }), + "functions/hello.js": `export function onRequest() { return new Response("hi"); }`, + }); + + 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 detectFramework(process.cwd()); + + expect(result.detectedFramework?.framework.id).not.toBe("cloudflare-pages"); + }); + + it("does not return Cloudflare Pages when a functions/ directory exists alongside a detected framework", async ({ + expect, + }) => { + await seed({ + "package.json": JSON.stringify({ dependencies: { astro: "5" } }), + "functions/hello.js": `export function onRequest() { return new Response("hi"); }`, + }); + + const result = await detectFramework(process.cwd()); + + // Astro is detected, so Pages detection via functions/ is skipped + expect(result.detectedFramework?.framework.id).not.toBe("cloudflare-pages"); + expect(result.detectedFramework?.framework.id).toBe("astro"); + }); +}); diff --git a/packages/wrangler/src/__tests__/autoconfig/details/framework-detection/workspace-root-handling.test.ts b/packages/wrangler/src/__tests__/autoconfig/details/framework-detection/workspace-root-handling.test.ts new file mode 100644 index 0000000000..7a978a4b05 --- /dev/null +++ b/packages/wrangler/src/__tests__/autoconfig/details/framework-detection/workspace-root-handling.test.ts @@ -0,0 +1,58 @@ +import { seed } from "@cloudflare/workers-utils/test-helpers"; +import { describe, it } from "vitest"; +import { detectFramework } from "../../../../autoconfig/details/framework-detection"; +import { runInTempDir } from "../../../helpers/run-in-tmp"; + +describe("detectFramework() / workspace root handling", () => { + runInTempDir(); + + it("sets isWorkspaceRoot to false for regular (non-monorepo) projects", async ({ + expect, + }) => { + await seed({ + "package.json": JSON.stringify({ name: "my-app" }), + "package-lock.json": JSON.stringify({ lockfileVersion: 3 }), + }); + + const result = await detectFramework(process.cwd()); + + expect(result.isWorkspaceRoot).toBe(false); + }); + + it("sets isWorkspaceRoot to true when the workspace root is itself a workspace package", async ({ + expect, + }) => { + await seed({ + "pnpm-workspace.yaml": "packages:\n - 'packages/*'\n - '.'\n", + "package.json": JSON.stringify({ + name: "my-workspace", + workspaces: ["packages/*", "."], + }), + "packages/my-app/package.json": JSON.stringify({ name: "my-app" }), + }); + + const result = await detectFramework(process.cwd()); + + expect(result.isWorkspaceRoot).toBe(true); + }); + + it("throws UserError when run from a workspace root that does not include the root as a package", async ({ + expect, + }) => { + await seed({ + "pnpm-workspace.yaml": "packages:\n - 'packages/*'\n", + "package.json": JSON.stringify({ + name: "my-workspace", + workspaces: ["packages/*"], + }), + "packages/my-app/package.json": JSON.stringify({ name: "my-app" }), + "packages/my-app/index.html": "

Hello World

", + }); + + await expect( + detectFramework(process.cwd()) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: The Wrangler application detection logic has been run in the root of a workspace instead of targeting a specific project. Change your working directory to one of the applications in the workspace and try again.]` + ); + }); +}); 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 ce405e7cb7..17f47f09ad 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 @@ -166,7 +166,7 @@ describe("autoconfig details - getDetailsForAutoConfig()", () => { await seed({ "package.json": JSON.stringify({ name: "my-app", - dependencies: {}, + dependencies: { astro: "6" }, }), "index.html": "

Hello World

", }); diff --git a/packages/wrangler/src/__tests__/autoconfig/frameworks/get-framework.test.ts b/packages/wrangler/src/__tests__/autoconfig/frameworks/get-framework.test.ts index 036475bf35..730e3fa063 100644 --- a/packages/wrangler/src/__tests__/autoconfig/frameworks/get-framework.test.ts +++ b/packages/wrangler/src/__tests__/autoconfig/frameworks/get-framework.test.ts @@ -2,15 +2,6 @@ import { describe, it } from "vitest"; import { getFramework } from "../../../autoconfig/frameworks/get-framework"; describe("getFramework()", () => { - it("should return a Static framework when frameworkId is undefined", ({ - expect, - }) => { - const framework = getFramework(undefined); - - expect(framework.id).toBe("static"); - expect(framework.name).toBe("Static"); - }); - it("should return a Static framework when frameworkId is unknown", ({ expect, }) => { diff --git a/packages/wrangler/src/autoconfig/details/framework-detection.ts b/packages/wrangler/src/autoconfig/details/framework-detection.ts new file mode 100644 index 0000000000..9f0c57385b --- /dev/null +++ b/packages/wrangler/src/autoconfig/details/framework-detection.ts @@ -0,0 +1,330 @@ +import assert from "node:assert"; +import { existsSync, statSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { FatalError, UserError } from "@cloudflare/workers-utils"; +import { Project } from "@netlify/build-info"; +import { NodeFS } from "@netlify/build-info/node"; +import { captureException } from "@sentry/node"; +import chalk from "chalk"; +import dedent from "ts-dedent"; +import { getCacheFolder } from "../../config-cache"; +import { confirm } from "../../dialogs"; +import { isNonInteractiveOrCI } from "../../is-interactive"; +import { logger } from "../../logger"; +import { + BunPackageManager, + NpmPackageManager, + PnpmPackageManager, + YarnPackageManager, +} from "../../package-manager"; +import { PAGES_CONFIG_CACHE_FILENAME } from "../../pages/constants"; +import { + allKnownFrameworksIds, + staticFramework, +} from "../frameworks/get-framework"; +import type { PackageManager } from "../../package-manager"; +import type { KnownFrameworkId } from "../frameworks/get-framework"; +import type { Config } from "@cloudflare/workers-utils"; +import type { Settings } from "@netlify/build-info"; + +/** + * Detects the framework used by the project at the given path. + * + * Uses `@netlify/build-info` to analyze build settings and identify the + * framework, then maps the detected package manager to wrangler's own type. + * + * If the project is identified as a Cloudflare Pages project the function + * returns early with a synthetic "Cloudflare Pages" framework entry. + * + * @param projectPath Path to the project root + * @param wranglerConfig Optional parsed wrangler config for the project + * @returns An object containing: + * - `detectedFramework`: The matched framework together with its build + * command and output directory. + * - `packageManager`: The package manager detected in the project. + * - `isWorkspaceRoot`: `true` when the project path is the root of a + * monorepo workspace (only present when relevant). + * @throws {UserError} When called from a workspace root that does not itself + * contain the targeted project path. + * @throws {MultipleFrameworksCIError} (via `findDetectedFramework`) in CI / + * non-interactive environments when multiple known frameworks are detected + * and no clear winner can be determined. + */ +export async function detectFramework( + projectPath: string, + wranglerConfig?: Config +): Promise<{ + detectedFramework: DetectedFramework; + packageManager: PackageManager; + isWorkspaceRoot?: boolean; +}> { + 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(); + + const isWorkspaceRoot = !!project.workspace?.isRoot; + + if (isWorkspaceRoot) { + const resolvedProjectPath = resolve(projectPath); + + const workspaceRootIncludesProject = project.workspace?.packages.some( + (pkg) => resolve(pkg.path) === resolvedProjectPath + ); + + if (!workspaceRootIncludesProject) { + throw new UserError( + "The Wrangler application detection logic has been run in the root of a workspace instead of targeting a specific project. Change your working directory to one of the applications in the workspace and try again." + ); + } + } + + // Convert the package manager detected by @netlify/build-info to our PackageManager type. + // This is populated after getBuildSettings() runs, which triggers the full detection chain. + const packageManager = convertDetectedPackageManager(project.packageManager); + + const lockFileExists = packageManager.lockFiles.some((lockFile) => + existsSync(join(projectPath, lockFile)) + ); + + const maybeDetectedFramework = maybeFindDetectedFramework(buildSettings); + + if ( + await isPagesProject(projectPath, wranglerConfig, maybeDetectedFramework) + ) { + return { + detectedFramework: { + framework: { + name: "Cloudflare Pages", + id: "cloudflare-pages", + }, + dist: wranglerConfig?.pages_build_output_dir, + }, + packageManager, + }; + } + + const detectedFramework = maybeDetectedFramework ?? { + framework: { + id: staticFramework.id, + name: staticFramework.name, + }, + }; + + if ( + !lockFileExists && + detectedFramework.framework.id !== staticFramework.id + ) { + logger.warn( + "No lock file has been detected in the current working directory." + + " This might indicate that the project is part of a workspace. Auto-configuration of " + + `projects inside workspaces is limited. See ${chalk.hex("#3B818D")( + "https://developers.cloudflare.com/workers/framework-guides/automatic-configuration/#workspaces" + )}` + ); + } + + return { + detectedFramework, + packageManager, + isWorkspaceRoot, + }; +} + +/** + * Converts the package manager detected by @netlify/build-info to our PackageManager type. + * Falls back to npm if no package manager was detected. + * + * @param pkgManager The package manager detected by @netlify/build-info (from project.packageManager) + * @returns A PackageManager object compatible with wrangler's package manager utilities + */ +function convertDetectedPackageManager( + pkgManager: { name: string } | null +): PackageManager { + if (!pkgManager) { + return NpmPackageManager; + } + + switch (pkgManager?.name) { + case "pnpm": + return PnpmPackageManager; + case "yarn": + return YarnPackageManager; + case "bun": + return BunPackageManager; + case "npm": + default: + return NpmPackageManager; + } +} + +class MultipleFrameworksCIError extends FatalError { + constructor(frameworks: string[]) { + super( + dedent`Wrangler was unable to automatically configure your project to work with Cloudflare, since multiple frameworks were found: ${frameworks.join( + ", " + )}. + + To fix this issue either: + - check your project's configuration to make sure that the target framework + is the only configured one and try again + - run \`wrangler setup\` locally to get an interactive user experience where + you can specify what framework you want to target + + `, + 1, + { telemetryMessage: true } + ); + } +} + +function throwMultipleFrameworksNonInteractiveError( + settings: Settings[] +): never { + throw new MultipleFrameworksCIError(settings.map((b) => b.name)); +} + +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; +} + +/** + * Selects the most appropriate framework from a list of detected framework settings. + * + * When there is a good level of confidence that the selected framework is correct or + * the process is running locally (where the user can choose a different framework or + * abort the process) the framework is returned. If there is no such confidence and the + * process is running in non interactive mode (where the user doesn't have the option to + * change the detected framework) an error is instead thrown. + * + * @param settings The array of framework settings + * @returns The selected framework settings, or `undefined` if none provided + * @throws {MultipleFrameworksCIError} In CI environments when multiple known frameworks + * are detected and no clear winner can be determined + */ +function maybeFindDetectedFramework( + settings: Settings[] +): DetectedFramework | undefined { + if (settings.length === 0) { + return undefined; + } + + if (settings.length === 1) { + return settings[0]; + } + + const settingsForOnlyKnownFrameworks = settings.filter(({ framework }) => + allKnownFrameworksIds.has(framework.id as KnownFrameworkId) + ); + + if (settingsForOnlyKnownFrameworks.length === 0) { + if (isNonInteractiveOrCI()) { + // If we're in a non interactive session (e.g. CI) let's throw to be on the safe side + throwMultipleFrameworksNonInteractiveError(settings); + } + // Locally we can just return the first one since the user can anyways choose a different + // framework or abort the process anyways + return settings[0]; + } + + if (settingsForOnlyKnownFrameworks.length === 1) { + // If there is a single known framework it's quite safe to assume that that's the + // one we care about + return settingsForOnlyKnownFrameworks[0]; + } + + if (settingsForOnlyKnownFrameworks.length === 2) { + const frameworkIdsFound = new Set( + settings.map(({ framework }) => framework.id as KnownFrameworkId) + ); + + const viteId = "vite"; + + if (frameworkIdsFound.has(viteId)) { + const knownNonViteSettings = settingsForOnlyKnownFrameworks.find( + ({ framework }) => framework.id !== viteId + ); + + // Here knownNonViteSettings should always be defined, it only could be undefined if the detected frameworks are both Vite. + if (knownNonViteSettings) { + // If we've found a known framework and Vite, then most likely the Vite is there only either as an extra build tool + // or as part of a Vitest installation, so it's pretty safe to ignore it in this case + return knownNonViteSettings; + } + } + + if (frameworkIdsFound.has("waku") && frameworkIdsFound.has("hono")) { + // The waku framework has a tight integration with hono, so it's likely that hono can also + // be detected in waku projects, if that's the case let's filter hono out + const wakuSettings = settingsForOnlyKnownFrameworks.find( + ({ framework }) => framework.id === "waku" + ); + assert(wakuSettings); + return wakuSettings; + } + } + + // If we've detected multiple frameworks, and we're in a non interactive session (e.g. CI) let's stay on the safe side and error + // (otherwise we just pick the first one as the user is always able to choose a different framework or terminate the process anyways) + if (isNonInteractiveOrCI()) { + throw new MultipleFrameworksCIError( + settingsForOnlyKnownFrameworks.map((b) => b.name) + ); + } + + return settingsForOnlyKnownFrameworks[0]; +} diff --git a/packages/wrangler/src/autoconfig/details.ts b/packages/wrangler/src/autoconfig/details/index.ts similarity index 58% rename from packages/wrangler/src/autoconfig/details.ts rename to packages/wrangler/src/autoconfig/details/index.ts index 5e8879659b..155ca62836 100644 --- a/packages/wrangler/src/autoconfig/details.ts +++ b/packages/wrangler/src/autoconfig/details/index.ts @@ -1,5 +1,5 @@ import assert from "node:assert"; -import { existsSync, statSync } from "node:fs"; +import { 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"; @@ -8,70 +8,24 @@ import { getCIOverrideName, parsePackageJSON, readFileSync, - UserError, } from "@cloudflare/workers-utils"; -import { Project } from "@netlify/build-info"; -import { NodeFS } from "@netlify/build-info/node"; -import { captureException } from "@sentry/node"; -import chalk from "chalk"; -import dedent from "ts-dedent"; -import { getCacheFolder } from "../config-cache"; -import { getErrorType } from "../core/handle-errors"; -import { confirm, prompt, select } from "../dialogs"; -import { isNonInteractiveOrCI } from "../is-interactive"; -import { logger } from "../logger"; -import { sendMetricsEvent } from "../metrics"; -import { - BunPackageManager, - NpmPackageManager, - PnpmPackageManager, - YarnPackageManager, -} from "../package-manager"; -import { PAGES_CONFIG_CACHE_FILENAME } from "../pages/constants"; -import { - allKnownFrameworks, - allKnownFrameworksIds, - getFramework, -} from "./frameworks/get-framework"; +import { getErrorType } from "../../core/handle-errors"; +import { confirm, prompt, select } from "../../dialogs"; +import { logger } from "../../logger"; +import { sendMetricsEvent } from "../../metrics"; +import { NpmPackageManager } from "../../package-manager"; +import { allKnownFrameworks, getFramework } from "../frameworks/get-framework"; import { getAutoConfigId, getAutoConfigTriggerCommand, -} from "./telemetry-utils"; -import type { PackageManager } from "../package-manager"; -import type { KnownFrameworkId } from "./frameworks/get-framework"; +} from "../telemetry-utils"; +import { detectFramework } from "./framework-detection"; +import type { PackageManager } from "../../package-manager"; import type { AutoConfigDetails, AutoConfigDetailsForNonConfiguredProject, -} from "./types"; +} from "../types"; import type { Config, PackageJSON } from "@cloudflare/workers-utils"; -import type { Settings } from "@netlify/build-info"; - -/** - * Converts the package manager detected by @netlify/build-info to our PackageManager type. - * Falls back to npm if no package manager was detected. - * - * @param pkgManager The package manager detected by @netlify/build-info (from project.packageManager) - * @returns A PackageManager object compatible with wrangler's package manager utilities - */ -function convertDetectedPackageManager( - pkgManager: { name: string } | null -): PackageManager { - if (!pkgManager) { - return NpmPackageManager; - } - - switch (pkgManager?.name) { - case "pnpm": - return PnpmPackageManager; - case "yarn": - return YarnPackageManager; - case "bun": - return BunPackageManager; - case "npm": - default: - return NpmPackageManager; - } -} /** * Asserts that the current project being targeted for autoconfig is not already configured. @@ -87,33 +41,6 @@ export function assertNonConfigured( ); } -class MultipleFrameworksCIError extends FatalError { - constructor(frameworks: string[]) { - super( - dedent`Wrangler was unable to automatically configure your project to work with Cloudflare, since multiple frameworks were found: ${frameworks.join( - ", " - )}. - - To fix this issue either: - - check your project's configuration to make sure that the target framework - is the only configured one and try again - - run \`wrangler setup\` locally to get an interactive user experience where - you can specify what framework you want to target - - `, - 1, - { telemetryMessage: true } - ); - } -} - -function throwMultipleFrameworksNonInteractiveError( - settings: Settings[] -): never { - // assert(isNonInteractiveOrCI()); - throw new MultipleFrameworksCIError(settings.map((b) => b.name)); -} - async function hasIndexHtml(dir: string): Promise { const children = await readdir(dir); for (const child of children) { @@ -161,205 +88,6 @@ type DetectedFramework = { 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<{ - detectedFramework: DetectedFramework | undefined; - packageManager: PackageManager; - isWorkspaceRoot?: boolean; -}> { - 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(); - - const isWorkspaceRoot = !!project.workspace?.isRoot; - - if (isWorkspaceRoot) { - const resolvedProjectPath = resolve(projectPath); - - const workspaceRootIncludesProject = project.workspace?.packages.some( - (pkg) => resolve(pkg.path) === resolvedProjectPath - ); - - if (!workspaceRootIncludesProject) { - throw new UserError( - "The Wrangler application detection logic has been run in the root of a workspace instead of targeting a specific project. Change your working directory to one of the applications in the workspace and try again." - ); - } - } - - const detectedFramework = findDetectedFramework(buildSettings); - - // Convert the package manager detected by @netlify/build-info to our PackageManager type. - // This is populated after getBuildSettings() runs, which triggers the full detection chain. - const packageManager = convertDetectedPackageManager(project.packageManager); - - const lockFileExists = packageManager.lockFiles.some((lockFile) => - existsSync(join(projectPath, lockFile)) - ); - - if (!lockFileExists) { - logger.warn( - "No lock file has been detected in the current working directory." + - " This might indicate that the project is part of a workspace. Auto-configuration of " + - `projects inside workspaces is limited. See ${chalk.hex("#3B818D")( - "https://developers.cloudflare.com/workers/framework-guides/automatic-configuration/#workspaces" - )}` - ); - } - - if (await isPagesProject(projectPath, wranglerConfig, detectedFramework)) { - return { - detectedFramework: { - framework: { - name: "Cloudflare Pages", - id: "cloudflare-pages", - }, - dist: wranglerConfig?.pages_build_output_dir, - }, - packageManager, - }; - } - - return { detectedFramework, packageManager, isWorkspaceRoot }; -} - -/** - * Selects the most appropriate framework from a list of detected framework settings. - * - * When there is a good level of confidence that the selected framework is correct or - * the process is running locally (where the user can choose a different framework or - * abort the process) the framework is returned. If there is no such confidence and the - * process is running in non interactive mode (where the user doesn't have the option to - * change the detected framework) an error is instead thrown. - * - * @param settings The array of framework settings - * @returns The selected framework settings, or `undefined` if none provided - * @throws {MultipleFrameworksCIError} In CI environments when multiple known frameworks - * are detected and no clear winner can be determined - */ -function findDetectedFramework( - settings: Settings[] -): DetectedFramework | undefined { - if (settings.length === 0) { - return undefined; - } - - if (settings.length === 1) { - return settings[0]; - } - - const settingsForOnlyKnownFrameworks = settings.filter(({ framework }) => - allKnownFrameworksIds.has(framework.id as KnownFrameworkId) - ); - - if (settingsForOnlyKnownFrameworks.length === 0) { - if (isNonInteractiveOrCI()) { - // If we're in a non interactive session (e.g. CI) let's throw to be on the safe side - throwMultipleFrameworksNonInteractiveError(settings); - } - // Locally we can just return the first one since the user can anyways choose a different - // framework or abort the process anyways - return settings[0]; - } - - if (settingsForOnlyKnownFrameworks.length === 1) { - // If there is a single known framework it's quite safe to assume that that's the - // one we care about - return settingsForOnlyKnownFrameworks[0]; - } - - if (settingsForOnlyKnownFrameworks.length === 2) { - const frameworkIdsFound = new Set( - settings.map(({ framework }) => framework.id as KnownFrameworkId) - ); - - const viteId = "vite"; - - if (frameworkIdsFound.has(viteId)) { - const knownNonViteSettings = settingsForOnlyKnownFrameworks.find( - ({ framework }) => framework.id !== viteId - ); - - // Here knownNonViteSettings should always be defined, it only could be undefined if the detected frameworks are both Vite. - if (knownNonViteSettings) { - // If we've found a known framework and Vite, then most likely the Vite is there only either as an extra build tool - // or as part of a Vitest installation, so it's pretty safe to ignore it in this case - return knownNonViteSettings; - } - } - - if (frameworkIdsFound.has("waku") && frameworkIdsFound.has("hono")) { - // The waku framework has a tight integration with hono, so it's likely that hono can also - // be detected in waku projects, if that's the case let's filter hono out - return settingsForOnlyKnownFrameworks.find( - ({ framework }) => framework.id === "waku" - ); - } - } - - // If we've detected multiple frameworks, and we're in a non interactive session (e.g. CI) let's stay on the safe side and error - // (otherwise we just pick the first one as the user is always able to choose a different framework or terminate the process anyways) - if (isNonInteractiveOrCI()) { - throw new MultipleFrameworksCIError( - settingsForOnlyKnownFrameworks.map((b) => b.name) - ); - } - - return settingsForOnlyKnownFrameworks[0]; -} - /** * Derives a valid worker name from a project directory. * diff --git a/packages/wrangler/src/autoconfig/frameworks/get-framework.ts b/packages/wrangler/src/autoconfig/frameworks/get-framework.ts index 6a8053b943..5a4c7da879 100644 --- a/packages/wrangler/src/autoconfig/frameworks/get-framework.ts +++ b/packages/wrangler/src/autoconfig/frameworks/get-framework.ts @@ -22,7 +22,7 @@ export type FrameworkInfo = { class: typeof Framework; }; -const staticFramework = { +export const staticFramework = { id: "static", name: "Static", class: Static, @@ -53,7 +53,7 @@ export const allKnownFrameworksIds = new Set( allKnownFrameworks.map(({ id }) => id) ); -export function getFramework(frameworkId?: FrameworkInfo["id"]): Framework { +export function getFramework(frameworkId: FrameworkInfo["id"]): Framework { const targetedFramework = allKnownFrameworks.find( (framework) => framework.id === frameworkId ); diff --git a/packages/wrangler/src/autoconfig/run.ts b/packages/wrangler/src/autoconfig/run.ts index be68fbfb1d..ed24469562 100644 --- a/packages/wrangler/src/autoconfig/run.ts +++ b/packages/wrangler/src/autoconfig/run.ts @@ -164,7 +164,11 @@ export async function runAutoConfig( } logger.debug( - `Running autoconfig with:\n${JSON.stringify(autoConfigDetails, null, 2)}...` + `Running autoconfig with:\n${JSON.stringify( + autoConfigDetails, + null, + 2 + )}...` ); if (autoConfigSummary.wranglerInstall && enableWranglerInstallation) { diff --git a/packages/wrangler/src/deploy/index.ts b/packages/wrangler/src/deploy/index.ts index c1cc519cc6..d77170b5b7 100644 --- a/packages/wrangler/src/deploy/index.ts +++ b/packages/wrangler/src/deploy/index.ts @@ -417,7 +417,9 @@ export const deployCommand = createCommand({ if (args.latest) { logger.warn( - `Using the latest version of the Workers runtime. To silence this warning, please choose a specific version of the runtime with --compatibility-date, or add a compatibility_date to your ${configFileName(config.configPath)} file.` + `Using the latest version of the Workers runtime. To silence this warning, please choose a specific version of the runtime with --compatibility-date, or add a compatibility_date to your ${configFileName( + config.configPath + )} file.` ); } @@ -597,7 +599,9 @@ export async function handleMaybeAssetsDeployment( // Ask if user wants to write config file const writeConfig = await confirm( - `Do you want Wrangler to write a wrangler.json config file to store this configuration?\n${chalk.dim("This will allow you to simply run `wrangler deploy` on future deployments.")}` + `Do you want Wrangler to write a wrangler.json config file to store this configuration?\n${chalk.dim( + "This will allow you to simply run `wrangler deploy` on future deployments." + )}` ); if (writeConfig) { @@ -614,7 +618,9 @@ export async function handleMaybeAssetsDeployment( writeFileSync(configPath, jsonString); logger.log(`Wrote \n${jsonString}\n to ${chalk.bold(configPath)}.`); logger.log( - `Please run ${chalk.bold("`wrangler deploy`")} instead of ${chalk.bold(`\`wrangler deploy ${args.assets}\``)} next time. Wrangler will automatically use the configuration saved to wrangler.jsonc.` + `Please run ${chalk.bold("`wrangler deploy`")} instead of ${chalk.bold( + `\`wrangler deploy ${args.assets}\`` + )} next time. Wrangler will automatically use the configuration saved to wrangler.jsonc.` ); } else { logger.log( diff --git a/packages/wrangler/src/setup.ts b/packages/wrangler/src/setup.ts index b30ae5727d..a3b7ce1f83 100644 --- a/packages/wrangler/src/setup.ts +++ b/packages/wrangler/src/setup.ts @@ -107,7 +107,9 @@ export const setupCommand = createCommand({ if (!args.dryRun) { const { type } = await getPackageManager(); logCompletionMessage( - `You can now deploy with ${brandColor(details.packageJson ? `${type} run deploy` : "wrangler deploy")}` + `You can now deploy with ${brandColor( + details.packageJson ? `${type} run deploy` : "wrangler deploy" + )}` ); } },