Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/c3-autoconfig-direct.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions packages/create-cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
120 changes: 120 additions & 0 deletions packages/create-cloudflare/src/__tests__/autoconfig-context.test.ts
Original file line number Diff line number Diff line change
@@ -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",
});
});
});
});
96 changes: 96 additions & 0 deletions packages/create-cloudflare/src/autoconfig-context.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>({
type: "confirm",
question: text,
label: "",
defaultValue,
acceptDefault: nonInteractive,
});
},
prompt: async (text, options) => {
return inputPrompt<string>({
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<string>({
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,
};
}
66 changes: 45 additions & 21 deletions packages/create-cloudflare/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -15,18 +16,15 @@ 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,
isIgnoredBuildsError,
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";
Expand All @@ -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[]) => {
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down
Loading
Loading