diff --git a/.changeset/add-migrate-command.md b/.changeset/add-migrate-command.md new file mode 100644 index 000000000..191b2a534 --- /dev/null +++ b/.changeset/add-migrate-command.md @@ -0,0 +1,7 @@ +--- +"@opennextjs/cloudflare": minor +--- + +feature: add migrate command to set up OpenNext.js for Cloudflare + +This command helps users migrate existing Next.js applications to OpenNext.js for Cloudflare by automatically setting up all necessary configuration files, dependencies, and scripts. It provides an interactive package manager selection (npm, pnpm, yarn, bun, deno) with keyboard navigation and performs comprehensive setup including wrangler.jsonc, open-next.config.ts, .dev.vars, package.json scripts, Next.js config updates, and edge runtime detection. diff --git a/packages/cloudflare/src/cli/commands/migrate.ts b/packages/cloudflare/src/cli/commands/migrate.ts new file mode 100644 index 000000000..278b87991 --- /dev/null +++ b/packages/cloudflare/src/cli/commands/migrate.ts @@ -0,0 +1,320 @@ +import type yargs from "yargs"; +import { execSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import Enquirer from "enquirer"; + +interface PackageManager { + name: string; + install: string; + installDev: string; +} + +const packageManagers: Record = { + pnpm: { name: "pnpm", install: "pnpm add", installDev: "pnpm add -D" }, + npm: { name: "npm", install: "npm install", installDev: "npm install --save-dev" }, + bun: { name: "bun", install: "bun add", installDev: "bun add -D" }, + yarn: { name: "yarn", install: "yarn add", installDev: "yarn add -D" }, + deno: { name: "deno", install: "deno add", installDev: "deno add --dev" }, +}; + +async function selectPackageManager(): Promise { + const choices = Object.entries(packageManagers).map(([key, pm], index) => ({ + name: key, + message: `${index + 1}. ${pm.name}`, + value: key, + })); + + const answer = await Enquirer.prompt<{ packageManager: string }>({ + type: "select", + name: "packageManager", + message: "šŸ“¦ Select your package manager:", + choices, + }); + + return packageManagers[answer.packageManager] || packageManagers.npm; +} + +function findFilesRecursive( + dir: string, + extensions: string[], + fileList: string[] = [] +): string[] { + const files = fs.readdirSync(dir); + + files.forEach((file) => { + const filePath = path.join(dir, file); + const stat = fs.statSync(filePath); + + if (stat.isDirectory()) { + // Skip node_modules, .next, .open-next, and other common build/cache directories + if (!["node_modules", ".next", ".open-next", ".git", "dist", "build"].includes(file)) { + findFilesRecursive(filePath, extensions, fileList); + } + } else if (stat.isFile()) { + const ext = path.extname(file).toLowerCase(); + if (extensions.includes(ext)) { + fileList.push(filePath); + } + } + }); + + return fileList; +} + +/** + * Implementation of the `opennextjs-cloudflare migrate` command. + * + * @param args + */ +async function migrateCommand(_args: Record): Promise { + console.log("šŸš€ Setting up OpenNext.js for Cloudflare...\n"); + + // Check if running on Windows + if (process.platform === "win32") { + console.log("āš ļø Windows Support Notice:"); + console.log("OpenNext can be used on Windows systems but Windows full support is not guaranteed."); + console.log("Please read more: https://opennext.js.org/cloudflare#windows-support\n"); + } + + // Package manager selection + const selectedPM = await selectPackageManager(); + console.log(""); + + // Step 1: Install dependencies + console.log(`šŸ“¦ Installing dependencies with ${selectedPM.name}...`); + try { + execSync(`${selectedPM.install} @opennextjs/cloudflare@latest`, { stdio: "inherit" }); + execSync(`${selectedPM.installDev} wrangler@latest`, { stdio: "inherit" }); + console.log("āœ… Dependencies installed\n"); + } catch (error) { + console.error("āŒ Failed to install dependencies:", (error as Error).message); + process.exit(1); + } + + // Step 2: Read package.json to get app name + let appName = "my-app"; + try { + if (fs.existsSync("package.json")) { + const packageJson = JSON.parse(fs.readFileSync("package.json", "utf8")) as { + name?: string; + }; + if (packageJson.name) { + appName = packageJson.name; + } + } + } catch (error) { + console.log('āš ļø Could not read package.json, using default name "my-app"'); + } + + // Step 3: Create/update wrangler.jsonc + console.log("āš™ļø Creating wrangler.jsonc..."); + const wranglerConfig = `{ + "$schema": "node_modules/wrangler/config-schema.json", + "main": ".open-next/worker.js", + "name": "${appName}", + "compatibility_date": "2024-12-30", + "compatibility_flags": [ + // Enable Node.js API + // see https://developers.cloudflare.com/workers/configuration/compatibility-flags/#nodejs-compatibility-flag + "nodejs_compat", + // Allow to fetch URLs in your app + // see https://developers.cloudflare.com/workers/configuration/compatibility-flags/#global-fetch-strictly-public + "global_fetch_strictly_public", + ], + "assets": { + "directory": ".open-next/assets", + "binding": "ASSETS", + }, + "services": [ + { + "binding": "WORKER_SELF_REFERENCE", + // The service should match the "name" of your worker + "service": "${appName}", + }, + ], + "r2_buckets": [ + // Create a R2 binding with the binding name "NEXT_INC_CACHE_R2_BUCKET" + // { + // "binding": "NEXT_INC_CACHE_R2_BUCKET", + // "bucket_name": "", + // }, + ], +}`; + fs.writeFileSync("wrangler.jsonc", wranglerConfig); + console.log("āœ… wrangler.jsonc created\n"); + + // Step 4: Create open-next.config.ts + console.log("āš™ļø Creating open-next.config.ts..."); + const openNextConfig = `import { defineCloudflareConfig } from "@opennextjs/cloudflare"; +import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache"; + +export default defineCloudflareConfig({ + incrementalCache: r2IncrementalCache, +}); +`; + + if (!fs.existsSync("open-next.config.ts")) { + fs.writeFileSync("open-next.config.ts", openNextConfig); + console.log("āœ… open-next.config.ts created\n"); + } else { + console.log("āœ… open-next.config.ts already exists\n"); + } + + // Step 5: Create .dev.vars + console.log("šŸ“ Creating .dev.vars..."); + const devVarsContent = `NEXTJS_ENV=development +`; + + if (!fs.existsSync(".dev.vars")) { + fs.writeFileSync(".dev.vars", devVarsContent); + console.log("āœ… .dev.vars created\n"); + } else { + console.log("āœ… .dev.vars already exists\n"); + } + + // Step 6: Create _headers in public folder + console.log("šŸ“ Creating _headers in public folder..."); + if (!fs.existsSync("public")) { + fs.mkdirSync("public"); + } + const headersContent = `/_next/static/* + Cache-Control: public,max-age=31536000,immutable +`; + fs.writeFileSync("public/_headers", headersContent); + console.log("āœ… _headers created in public folder\n"); + + // Step 7: Update package.json scripts + console.log("šŸ“ Updating package.json scripts..."); + try { + let packageJson: { scripts?: Record } = {}; + if (fs.existsSync("package.json")) { + packageJson = JSON.parse(fs.readFileSync("package.json", "utf8")) as { + scripts?: Record; + }; + } + + if (!packageJson.scripts) { + packageJson.scripts = {}; + } + + packageJson.scripts.build = "next build"; + packageJson.scripts.preview = "opennextjs-cloudflare build && opennextjs-cloudflare preview"; + packageJson.scripts.deploy = "opennextjs-cloudflare build && opennextjs-cloudflare deploy"; + packageJson.scripts.upload = "opennextjs-cloudflare build && opennextjs-cloudflare upload"; + packageJson.scripts["cf-typegen"] = + "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts"; + + fs.writeFileSync("package.json", JSON.stringify(packageJson, null, 2)); + console.log("āœ… package.json scripts updated\n"); + } catch (error) { + console.error("āŒ Failed to update package.json:", (error as Error).message); + } + + // Step 8: Add .open-next to .gitignore + console.log("šŸ“‹ Updating .gitignore..."); + let gitignoreContent = ""; + if (fs.existsSync(".gitignore")) { + gitignoreContent = fs.readFileSync(".gitignore", "utf8"); + } + + if (!gitignoreContent.includes(".open-next")) { + gitignoreContent += "\n# OpenNext\n.open-next\n"; + fs.writeFileSync(".gitignore", gitignoreContent); + console.log("āœ… .open-next added to .gitignore\n"); + } else { + console.log("āœ… .open-next already in .gitignore\n"); + } + + // Step 9: Update Next.js config + console.log("āš™ļø Updating Next.js config..."); + const configFiles = ["next.config.ts", "next.config.js", "next.config.mjs"]; + let configFile: string | null = null; + + for (const file of configFiles) { + if (fs.existsSync(file)) { + configFile = file; + break; + } + } + + if (configFile) { + let configContent = fs.readFileSync(configFile, "utf8"); + const importLine = 'import { initOpenNextCloudflareForDev } from "@opennextjs/cloudflare";'; + const initLine = "initOpenNextCloudflareForDev();"; + + if (!configContent.includes(importLine)) { + // Add import at the top + configContent = importLine + "\n" + configContent; + } + + if (!configContent.includes(initLine)) { + // Add init call at the end + configContent += "\n" + initLine + "\n"; + } + + fs.writeFileSync(configFile, configContent); + console.log(`āœ… ${configFile} updated\n`); + } else { + console.log("āš ļø No Next.js config file found, you may need to create one\n"); + } + + // Step 10: Check for edge runtime usage + console.log("šŸ” Checking for edge runtime usage..."); + try { + const extensions = [".ts", ".tsx", ".js", ".jsx", ".mjs"]; + const files = findFilesRecursive(".", extensions).slice(0, 100); // Limit to first 100 files + let foundEdgeRuntime = false; + + for (const file of files) { + try { + const content = fs.readFileSync(file, "utf8"); + if (content.includes('export const runtime = "edge"')) { + console.log(`āš ļø Found edge runtime in: ${file}`); + foundEdgeRuntime = true; + } + } catch (error) { + // Skip files that can't be read + } + } + + if (foundEdgeRuntime) { + console.log("\n🚨 WARNING:"); + console.log("Remove any export const runtime = \"edge\"; if present"); + console.log( + "Before deploying your app, remove the export const runtime = \"edge\"; line from any of your source files." + ); + console.log("The edge runtime is not supported yet with @opennextjs/cloudflare.\n"); + } else { + console.log("āœ… No edge runtime declarations found\n"); + } + } catch (error) { + console.log("āš ļø Could not check for edge runtime usage\n"); + } + + console.log("šŸŽ‰ OpenNext.js for Cloudflare setup complete!"); + console.log("\nNext steps:"); + const runCommand = + selectedPM.name === "npm" + ? "npm run" + : selectedPM.name === "yarn" + ? "yarn" + : `${selectedPM.name} run`; + console.log(`1. Run: ${runCommand} build`); + console.log(`2. Run: ${runCommand} preview (to test locally)`); + console.log(`3. Run: ${runCommand} deploy (to deploy to Cloudflare)`); + console.log(`\nFor development, continue using: ${runCommand} dev`); +} + +/** + * Add the `migrate` command to yargs configuration. + */ +export function addMigrateCommand(y: T) { + return y.command( + "migrate", + "Set up OpenNext.js for Cloudflare in an existing Next.js project", + () => ({}), + (args) => migrateCommand(args) + ); +} + diff --git a/packages/cloudflare/src/cli/index.ts b/packages/cloudflare/src/cli/index.ts index 6f982c67e..18cd59eb8 100644 --- a/packages/cloudflare/src/cli/index.ts +++ b/packages/cloudflare/src/cli/index.ts @@ -4,6 +4,7 @@ import yargs from "yargs"; import { addBuildCommand } from "./commands/build.js"; import { addDeployCommand } from "./commands/deploy.js"; +import { addMigrateCommand } from "./commands/migrate.js"; import { addPopulateCacheCommand } from "./commands/populate-cache.js"; import { addPreviewCommand } from "./commands/preview.js"; import { addUploadCommand } from "./commands/upload.js"; @@ -18,6 +19,7 @@ export function runCommand() { addDeployCommand(y); addUploadCommand(y); addPopulateCacheCommand(y); + addMigrateCommand(y); return y.demandCommand(1, 1).parse(); }