diff --git a/.changeset/c3-autoconfig-direct.md b/.changeset/c3-autoconfig-direct.md new file mode 100644 index 0000000000..caeca64d54 --- /dev/null +++ b/.changeset/c3-autoconfig-direct.md @@ -0,0 +1,7 @@ +--- +"create-cloudflare": patch +--- + +Configure experimental projects using `@cloudflare/autoconfig` directly + +When scaffolding experimental templates, C3 now runs the autoconfig flow in-process via `@cloudflare/autoconfig` instead of shelling out to `wrangler setup`. This produces the same configuration while making the setup step faster and no longer dependent on the `wrangler` CLI. diff --git a/packages/create-cloudflare/package.json b/packages/create-cloudflare/package.json index e0c4d19a4e..4e8d98bbf6 100644 --- a/packages/create-cloudflare/package.json +++ b/packages/create-cloudflare/package.json @@ -40,6 +40,7 @@ "@babel/parser": "^7.21.3", "@babel/types": "^7.21.4", "@clack/prompts": "^1.2.0", + "@cloudflare/autoconfig": "workspace:*", "@cloudflare/cli-shared-helpers": "workspace:*", "@cloudflare/codemod": "workspace:*", "@cloudflare/mock-npm-registry": "workspace:*", diff --git a/packages/create-cloudflare/src/__tests__/autoconfig-context.test.ts b/packages/create-cloudflare/src/__tests__/autoconfig-context.test.ts new file mode 100644 index 0000000000..b1f7faba23 --- /dev/null +++ b/packages/create-cloudflare/src/__tests__/autoconfig-context.test.ts @@ -0,0 +1,120 @@ +import { error, log, warn } from "@cloudflare/cli-shared-helpers"; +import { inputPrompt } from "@cloudflare/cli-shared-helpers/interactive"; +import { isNonInteractiveOrCI } from "@cloudflare/workers-utils"; +import { execaCommand } from "execa"; +import { beforeEach, describe, test, vi } from "vitest"; +import { createC3AutoConfigContext } from "../autoconfig-context"; + +vi.mock("@cloudflare/cli-shared-helpers"); +vi.mock("@cloudflare/cli-shared-helpers/interactive"); +vi.mock("@cloudflare/workers-utils"); +vi.mock("execa"); + +describe("createC3AutoConfigContext", () => { + beforeEach(() => { + vi.mocked(isNonInteractiveOrCI).mockReturnValue(false); + }); + + describe("logger", () => { + test("routes log and info to `log`, joining args with spaces", ({ + expect, + }) => { + const { logger } = createC3AutoConfigContext(); + + logger.log("hello", "world"); + logger.info("foo", 42); + + expect(log).toHaveBeenCalledWith("hello world"); + expect(log).toHaveBeenCalledWith("foo 42"); + }); + + test("routes warn to `warn`", ({ expect }) => { + const { logger } = createC3AutoConfigContext(); + + logger.warn("careful"); + + expect(warn).toHaveBeenCalledWith("careful"); + }); + + test("routes error to `error`", ({ expect }) => { + const { logger } = createC3AutoConfigContext(); + + logger.error("boom"); + + expect(error).toHaveBeenCalledWith("boom"); + }); + + test("suppresses debug output", ({ expect }) => { + const { logger } = createC3AutoConfigContext(); + + logger.debug("noisy"); + + expect(log).not.toHaveBeenCalled(); + expect(warn).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); + }); + }); + + describe("dialogs", () => { + test("confirm delegates to inputPrompt with a confirm prompt", async ({ + expect, + }) => { + vi.mocked(inputPrompt).mockResolvedValue(true as never); + const { dialogs } = createC3AutoConfigContext(); + + const result = await dialogs.confirm("Proceed?", { defaultValue: true }); + + expect(result).toBe(true); + expect(inputPrompt).toHaveBeenCalledWith( + expect.objectContaining({ + type: "confirm", + question: "Proceed?", + defaultValue: true, + }) + ); + }); + + test("select maps choices to inputPrompt options", async ({ expect }) => { + vi.mocked(inputPrompt).mockResolvedValue("react" as never); + const { dialogs } = createC3AutoConfigContext(); + + const result = await dialogs.select("Framework?", { + choices: [ + { title: "React", value: "react" }, + { title: "Vue", value: "vue" }, + ], + defaultOption: 1, + }); + + expect(result).toBe("react"); + expect(inputPrompt).toHaveBeenCalledWith( + expect.objectContaining({ + type: "select", + question: "Framework?", + options: [ + { label: "React", value: "react" }, + { label: "Vue", value: "vue" }, + ], + defaultValue: "vue", + }) + ); + }); + }); + + describe("runCommand", () => { + test("runs the command in a shell with the given cwd", async ({ + expect, + }) => { + vi.mocked(execaCommand).mockResolvedValue({} as never); + const context = createC3AutoConfigContext(); + + await context.runCommand("npm run build", "/tmp/project", "[build]"); + + expect(execaCommand).toHaveBeenCalledWith("npm run build", { + shell: true, + cwd: "/tmp/project", + stdio: "inherit", + }); + }); + }); +}); diff --git a/packages/create-cloudflare/src/autoconfig-context.ts b/packages/create-cloudflare/src/autoconfig-context.ts new file mode 100644 index 0000000000..da7f9c6d06 --- /dev/null +++ b/packages/create-cloudflare/src/autoconfig-context.ts @@ -0,0 +1,96 @@ +import { error, log, warn } from "@cloudflare/cli-shared-helpers"; +import { inputPrompt } from "@cloudflare/cli-shared-helpers/interactive"; +import { isNonInteractiveOrCI } from "@cloudflare/workers-utils"; +import { execaCommand } from "execa"; +import type { AutoConfigContext } from "@cloudflare/autoconfig"; + +/** + * Joins the variadic arguments passed to a logger method into a single + * space-separated string, matching the behaviour of `console.log`. + * + * @param args - The arguments passed to the logger method. + * @returns A single string with each argument stringified and space-separated. + */ +function stringifyLogArgs(args: unknown[]): string { + return args + .map((arg) => (typeof arg === "string" ? arg : String(arg))) + .join(" "); +} + +/** + * Creates an `AutoConfigContext` that wires C3's logging, prompting, and command + * execution infrastructure into the generic `@cloudflare/autoconfig` system. + * + * @returns A fully-configured `AutoConfigContext` for use with `@cloudflare/autoconfig`. + */ +export function createC3AutoConfigContext(): AutoConfigContext { + return { + logger: { + log: (...args) => log(stringifyLogArgs(args)), + info: (...args) => log(stringifyLogArgs(args)), + warn: (...args) => warn(stringifyLogArgs(args)), + error: (...args) => error(stringifyLogArgs(args)), + // C3 has no dedicated debug channel; debug output is suppressed to keep + // the scaffolding output clean. + debug: () => {}, + }, + dialogs: { + confirm: async (text, options) => { + const nonInteractive = isNonInteractiveOrCI(); + const defaultValue = nonInteractive + ? (options?.fallbackValue ?? options?.defaultValue ?? false) + : (options?.defaultValue ?? true); + + return inputPrompt({ + type: "confirm", + question: text, + label: "", + defaultValue, + acceptDefault: nonInteractive, + }); + }, + prompt: async (text, options) => { + return inputPrompt({ + type: "text", + question: text, + label: "", + defaultValue: options?.defaultValue, + acceptDefault: isNonInteractiveOrCI(), + validate: options?.validate + ? (value) => { + const result = options.validate?.(value as string); + // C3 only ever runs autoconfig with confirmations skipped, so the + // interactive (and possibly async) validation path is never hit. + // Treat anything we can't synchronously resolve as valid. + if (result instanceof Promise) { + return undefined; + } + if (result === true || result === undefined) { + return undefined; + } + return result === false ? "Invalid value" : result; + } + : undefined, + }); + }, + select: async (text, options) => { + return inputPrompt({ + type: "select", + question: text, + label: "", + options: options.choices.map((choice) => ({ + label: choice.title, + value: choice.value, + })), + defaultValue: options.choices[options.defaultOption ?? 0]?.value, + acceptDefault: isNonInteractiveOrCI(), + }); + }, + }, + runCommand: async (command, cwd, label) => { + log(`${label} Running: ${command}`); + await execaCommand(command, { shell: true, cwd, stdio: "inherit" }); + }, + isNonInteractiveOrCI, + }; +} diff --git a/packages/create-cloudflare/src/cli.ts b/packages/create-cloudflare/src/cli.ts index 9a1fc24ada..1e5dbf62a4 100644 --- a/packages/create-cloudflare/src/cli.ts +++ b/packages/create-cloudflare/src/cli.ts @@ -2,6 +2,7 @@ import { mkdirSync } from "node:fs"; import { dirname } from "node:path"; import { chdir } from "node:process"; +import * as autoConfig from "@cloudflare/autoconfig"; import { cancel, checkMacOSVersion, @@ -15,11 +16,7 @@ import { maybeAppendWranglerToGitIgnore } from "@cloudflare/cli-shared-helpers/g import { isInteractive } from "@cloudflare/cli-shared-helpers/interactive"; import { cliDefinition, parseArgs, processArgument } from "helpers/args"; import { C3_DEFAULTS, isUpdateAvailable, runLatest } from "helpers/cli"; -import { runWranglerCommand } from "helpers/command"; -import { - detectPackageManager, - rectifyPmMismatch, -} from "helpers/packageManagers"; +import { rectifyPmMismatch } from "helpers/packageManagers"; import { installWrangler, npmInstall } from "helpers/packages"; import { getPnpmIgnoredBuildsGuidance, @@ -27,6 +24,7 @@ import { writePnpmBuildApprovals, } from "helpers/pnpmBuildApprovals"; import { version } from "../package.json"; +import { createC3AutoConfigContext } from "./autoconfig-context"; import { maybeOpenBrowser, offerToDeploy, runDeploy } from "./deploy"; import { printSummary, printWelcomeMessage } from "./dialog"; import { gitCommit, offerGit } from "./git"; @@ -42,7 +40,10 @@ import { } from "./templates"; import { validateProjectDirectory } from "./validators"; import { addTypes } from "./workers"; -import { updateWranglerConfig } from "./wrangler/config"; +import { + loadProjectWranglerConfig, + updateWranglerConfig, +} from "./wrangler/config"; import type { C3Args, C3Context } from "types"; export const main = async (argv: string[]) => { @@ -164,27 +165,16 @@ const create = async (ctx: C3Context) => { const configure = async (ctx: C3Context) => { startSection( `Configuring your application for Cloudflare${ - ctx.args.experimental ? ` via \`wrangler setup\`` : "" + ctx.args.experimental ? ` via autoconfig` : "" }`, "Step 2 of 3" ); - // This is kept even in the autoconfig case because autoconfig will ultimately end up installing Wrangler anyway - // If we _didn't_ install Wrangler when using autoconfig we'd end up with a double install (one from `npx` and one from autoconfig) - await installWrangler(); - if (ctx.args.experimental) { - const { npx } = detectPackageManager(); - - await runWranglerCommand([ - npx, - "wrangler", - "setup", - "--yes", - "--no-completion-message", - "--no-install-wrangler", - ]); + await runAutoConfig(ctx); } else { + await installWrangler(); + // Note: This _must_ be called before the configure phase since // pre-existing workers assume its presence in their configure phase await updateWranglerConfig(ctx); @@ -208,6 +198,40 @@ const configure = async (ctx: C3Context) => { endSection(`Application configured`); }; +/** + * Configures a C3 project for Cloudflare by running the `@cloudflare/autoconfig` flow in-process. + * + * @param ctx - The C3 context for the project being created. + */ +const runAutoConfig = async (ctx: C3Context) => { + await reporter.collectAsyncMetrics({ + eventPrefix: "c3 autoconfig", + props: { args: ctx.args }, + async promise() { + const context = createC3AutoConfigContext(); + + const details = await autoConfig.getDetailsForAutoConfig({ + projectPath: ctx.project.path, + // Note: the started application might already include a wrangler config file + wranglerConfig: loadProjectWranglerConfig(ctx.project.path), + context, + }); + + reporter.setEventProperty("framework", details.framework?.id); + reporter.setEventProperty("configured", details.configured); + + if (!details.configured) { + await autoConfig.runAutoConfig(details, { + context, + skipConfirmations: true, + runBuild: false, + enableWranglerInstallation: true, + }); + } + }, + }); +}; + const deploy = async (ctx: C3Context) => { if (await offerToDeploy(ctx)) { await createProject(ctx); diff --git a/packages/create-cloudflare/src/event.ts b/packages/create-cloudflare/src/event.ts index eeca2b9175..985d554a94 100644 --- a/packages/create-cloudflare/src/event.ts +++ b/packages/create-cloudflare/src/event.ts @@ -522,4 +522,171 @@ export type Event = */ durationMs?: number; }; + } + | { + name: "c3 autoconfig started"; + properties: { + /** + * The OS platform the CLI is running on + * This could be "Mac OS", "Windows", "Linux", etc. + */ + platform?: string; + + /** + * The version of the create-cloudflare CLI used + */ + c3Version?: string; + + /** + * The name of the package manager used to run the CLI + */ + packageManager?: string; + + /** + * The CLI arguments set at the time the event is sent + */ + args?: Partial; + + /** + * Whether this is the first time the user is using the CLI + * Determined by checking if the user has a permission set in the metrics config + */ + isFirstUsage?: boolean; + }; + } + | { + name: "c3 autoconfig cancelled"; + properties: { + /** + * The OS platform the CLI is running on + * This could be "Mac OS", "Windows", "Linux", etc. + */ + platform?: string; + + /** + * The version of the create-cloudflare CLI used + */ + c3Version?: string; + + /** + * The name of the package manager used to run the CLI + */ + packageManager?: string; + + /** + * The CLI arguments set at the time the event is sent + */ + args?: Partial; + + /** + * Whether this is the first time the user is using the CLI + * Determined by checking if the user has a permission set in the metrics config + */ + isFirstUsage?: boolean; + + /** + * The id of the framework detected by autoconfig (if any) + */ + framework?: string; + + /** + * The duration of the autoconfig process since it started in milliseconds (ms) + */ + durationMs?: number; + }; + } + | { + name: "c3 autoconfig errored"; + properties: { + /** + * The OS platform the CLI is running on + * This could be "Mac OS", "Windows", "Linux", etc. + */ + platform?: string; + + /** + * The version of the create-cloudflare CLI used + */ + c3Version?: string; + + /** + * The name of the package manager used to run the CLI + */ + packageManager?: string; + + /** + * The CLI arguments set at the time the event is sent + */ + args?: Partial; + + /** + * Whether this is the first time the user is using the CLI + * Determined by checking if the user has a permission set in the metrics config + */ + isFirstUsage?: boolean; + + /** + * The id of the framework detected by autoconfig (if any) + */ + framework?: string; + + /** + * The error that caused the autoconfig process to fail + */ + error?: { + message: string | undefined; + stack: string | undefined; + }; + + /** + * The duration of the autoconfig process since it started in milliseconds (ms) + */ + durationMs?: number; + }; + } + | { + name: "c3 autoconfig completed"; + properties: { + /** + * The OS platform the CLI is running on + * This could be "Mac OS", "Windows", "Linux", etc. + */ + platform?: string; + + /** + * The version of the create-cloudflare CLI used + */ + c3Version?: string; + + /** + * The name of the package manager used to run the CLI + */ + packageManager?: string; + + /** + * The CLI arguments set at the time the event is sent + */ + args?: Partial; + + /** + * Whether this is the first time the user is using the CLI + * Determined by checking if the user has a permission set in the metrics config + */ + isFirstUsage?: boolean; + + /** + * The id of the framework detected by autoconfig (if any) + */ + framework?: string; + + /** + * Whether the project was already configured for Cloudflare before autoconfig ran + */ + configured?: boolean; + + /** + * The duration of the autoconfig process since it started in milliseconds (ms) + */ + durationMs?: number; + }; }; diff --git a/packages/create-cloudflare/src/wrangler/__tests__/load-config.test.ts b/packages/create-cloudflare/src/wrangler/__tests__/load-config.test.ts new file mode 100644 index 0000000000..27a2f79fc4 --- /dev/null +++ b/packages/create-cloudflare/src/wrangler/__tests__/load-config.test.ts @@ -0,0 +1,45 @@ +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { removeDirSync } from "@cloudflare/workers-utils"; +import { afterEach, beforeEach, describe, test } from "vitest"; +import { loadProjectWranglerConfig } from "../config"; + +describe("loadProjectWranglerConfig", () => { + let projectPath: string; + + beforeEach(() => { + projectPath = mkdtempSync(join(tmpdir(), "c3-load-config-")); + }); + + afterEach(() => { + removeDirSync(projectPath); + }); + + test("returns undefined when no wrangler config exists", ({ expect }) => { + expect(loadProjectWranglerConfig(projectPath)).toBeUndefined(); + }); + + test("loads an existing wrangler.jsonc with its configPath set", ({ + expect, + }) => { + writeFileSync( + join(projectPath, "wrangler.jsonc"), + JSON.stringify({ + name: "my-worker", + main: "src/worker.tsx", + compatibility_date: "2024-01-01", + }) + ); + + const config = loadProjectWranglerConfig(projectPath); + + expect(config).toBeDefined(); + expect(config?.name).toBe("my-worker"); + expect(config?.main).toBe(join(projectPath, "src", "worker.tsx")); + // `configPath` being set is what causes autoconfig to treat the project as + // already configured and skip re-configuration. + expect(config?.configPath).toContain("wrangler.jsonc"); + expect(config?.pages_build_output_dir).toBeUndefined(); + }); +}); diff --git a/packages/create-cloudflare/src/wrangler/config.ts b/packages/create-cloudflare/src/wrangler/config.ts index 24a214fc7a..b49a7cf754 100644 --- a/packages/create-cloudflare/src/wrangler/config.ts +++ b/packages/create-cloudflare/src/wrangler/config.ts @@ -1,6 +1,11 @@ import { existsSync, mkdirSync } from "node:fs"; import { resolve } from "node:path"; -import { isCompatDate } from "@cloudflare/workers-utils"; +import { + experimental_readRawConfig, + findWranglerConfig, + isCompatDate, + normalizeAndValidateConfig, +} from "@cloudflare/workers-utils"; import { getWorkerdCompatibilityDate } from "helpers/compatDate"; import { readFile, writeFile, writeJSON } from "helpers/files"; import { @@ -11,6 +16,7 @@ import { writeJSONWithComments, } from "helpers/json"; import TOML from "smol-toml"; +import type { Config } from "@cloudflare/workers-utils"; import type { CommentObject, Reviver } from "comment-json"; import type { TomlTable } from "smol-toml"; import type { C3Context } from "types"; @@ -385,3 +391,25 @@ function addNodejsCompatFlagToToml(wranglerConfig: TomlTable): void { wranglerConfig.compatibility_flags = ["nodejs_compat", ...existingFlags]; } + +/** + * Loads the project's existing Wrangler configuration, if any. + * + * @param projectPath - The path to the project being created. + * @returns The normalized Wrangler configuration, or `undefined` if none exists. + */ +export function loadProjectWranglerConfig( + projectPath: string +): Config | undefined { + const { userConfigPath } = findWranglerConfig(projectPath); + if (!userConfigPath) { + return undefined; + } + + const { rawConfig, configPath } = experimental_readRawConfig({ + config: userConfigPath, + }); + + return normalizeAndValidateConfig(rawConfig, configPath, userConfigPath, {}) + .config; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f15f4ec7e..2415c6619e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1821,6 +1821,9 @@ importers: '@clack/prompts': specifier: ^1.2.0 version: 1.2.0 + '@cloudflare/autoconfig': + specifier: workspace:* + version: link:../autoconfig '@cloudflare/cli-shared-helpers': specifier: workspace:* version: link:../cli