diff --git a/AGENTS.md b/AGENTS.md index 97cd936f..a2f3a7a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -262,6 +262,10 @@ If a Node built-in does the job, use it. Only reach for a dependency when the bu ## Git Workflow +- **Refresh main before assuming repo state.** At the start of every task, run `git fetch origin` and check `git status --branch` so you know whether the local branch and `origin/main` are current. Do not assume the cached `origin/main` ref is up to date. + +- **Only fast-forward automatically when safe.** If you are on `main` and the worktree is clean, update with `git pull --ff-only` before doing substantial work. If the worktree is dirty or you are on another branch, do not auto-pull; note the divergence and avoid mutating the user's branch history without an explicit request. + - **NEVER push directly to main.** Always create a feature branch and open a PR, even for small fixes. This ensures CI runs before changes are merged and provides a review checkpoint. - **Branch protection is enabled on main.** Required checks: Lint, Typecheck, Vitest, Playwright E2E. Pushing directly to main bypasses these protections and can introduce regressions. diff --git a/README.md b/README.md index a5c9a655..b18a65e8 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ Options: `-p / --port `, `-H / --hostname `, `--turbopack` (accepted Run `npm create next-app@latest` to create a new Next.js project, and then follow these instructions to migrate it to vinext. -In the future, we will have a proper `npm create vinext` new project workflow. +In the future, we will have a proper `npm create vinext` new project workflow. ### Migrating an existing Next.js project @@ -152,7 +152,7 @@ Both. File-system routing, SSR, client hydration, and deployment to Cloudflare W Next.js 16.x. No support for deprecated APIs from older versions. **Can I deploy to AWS/Netlify/other platforms?** -Yes. Add the [Nitro](https://v3.nitro.build/) Vite plugin alongside vinext, and you can deploy to Vercel, Netlify, AWS Amplify, Deno Deploy, Azure, and [many more](https://v3.nitro.build/deploy). See [Other platforms (via Nitro)](#other-platforms-via-nitro) for setup. For Cloudflare Workers, the native integration (`vinext deploy`) gives you the smoothest experience. Native adapters for more platforms are [planned](https://github.com/cloudflare/vinext/issues/80). +Yes. Add the [Nitro](https://v3.nitro.build/) Vite plugin alongside vinext, and you can deploy to Vercel, Netlify, AWS Amplify, Deno Deploy, Azure, Node.js, and [many more](https://v3.nitro.build/deploy). `vinext deploy` itself is Cloudflare-only; for other targets, use Nitro. See [Other platforms (via Nitro)](#other-platforms-via-nitro) for setup. For Cloudflare Workers, the native integration (`vinext deploy`) gives you the smoothest experience. Native adapters for more platforms are [planned](https://github.com/cloudflare/vinext/issues/80). **What happens when Next.js releases a new feature?** We track the public Next.js API surface and add support for new stable features. Experimental or unstable Next.js features are lower priority. The plan is to add commit-level tracking of the Next.js repo so we can stay current as new versions are released. @@ -169,12 +169,12 @@ Before running `vinext deploy` for the first time you need to authenticate with **Authentication — pick one:** -- **`wrangler login`** (recommended for local development) — opens a browser window to authenticate. Run it once and wrangler caches the token. -- **`CLOUDFLARE_API_TOKEN` env var** (CI / non-interactive) — create a token at [dash.cloudflare.com/profile/api-tokens](https://dash.cloudflare.com/profile/api-tokens) using the **Edit Cloudflare Workers** template. That template grants all the permissions `vinext deploy` needs. +- **`wrangler login`** (recommended for local development) — opens a browser window to authenticate. In an interactive terminal, `vinext deploy` will reuse your saved Wrangler session and prompt you through `wrangler login` if needed. Docs: +- **`CLOUDFLARE_API_TOKEN` env var** (CI / non-interactive) — use Cloudflare's **Edit Cloudflare Workers** template. Docs: and **Account ID:** -wrangler needs to know which Cloudflare account to deploy to. Add your account ID to `wrangler.jsonc`: +wrangler needs to know which Cloudflare account to deploy to. Set `CLOUDFLARE_ACCOUNT_ID`, or add `account_id` to `wrangler.jsonc` / `wrangler.toml` either at the top level or inside the selected env block: ```jsonc { @@ -183,9 +183,7 @@ wrangler needs to know which Cloudflare account to deploy to. Add your account I } ``` -Find your account ID in the Cloudflare dashboard URL (`dash.cloudflare.com/`) or by running `wrangler whoami` after logging in. - -Alternatively, set the `CLOUDFLARE_ACCOUNT_ID` environment variable instead of hardcoding it in the config file. +Find your account ID in the Cloudflare dashboard URL (`dash.cloudflare.com/`) or by running `wrangler whoami` after logging in. Account ID docs: `vinext deploy` auto-generates the necessary configuration files (`vite.config.ts`, `wrangler.jsonc`, `worker/index.ts`) if they don't exist, builds the application, and deploys to Workers. @@ -260,7 +258,7 @@ setCacheHandler(new KVCacheHandler(env.MY_KV_NAMESPACE)); #### Custom Vite configuration -If you need to customize the Vite config, create a `vite.config.ts`. vinext will merge its config with yours. This is required for Cloudflare Workers deployment with the App Router (RSC needs explicit plugin configuration): +If you need to customize the Vite config, create a `vite.config.ts`. vinext will merge its config with yours. For Cloudflare Workers, adding `@cloudflare/vite-plugin` yourself is recommended when you want Cloudflare-specific local behavior such as `cloudflare:workers` bindings in dev. If an existing config is missing the plugin, `vinext deploy` can inject it for the deploy build: ```ts import { defineConfig } from "vite"; @@ -289,7 +287,11 @@ See the [examples](#live-examples) for complete working configurations. ### Other platforms (via Nitro) -For deploying to platforms other than Cloudflare, vinext works with [Nitro](https://v3.nitro.build/) as a Vite plugin. Add `nitro` alongside `vinext` in your Vite config and deploy to any [Nitro-supported platform](https://v3.nitro.build/deploy). +`vinext deploy` is Cloudflare-only. For other targets, use vinext with the [Nitro](https://v3.nitro.build/) Vite plugin and deploy to any [Nitro-supported platform](https://v3.nitro.build/deploy). + +```bash +npm install nitro +``` ```ts import { defineConfig } from "vite"; @@ -301,19 +303,16 @@ export default defineConfig({ }); ``` -```bash -npm install nitro -``` - Nitro auto-detects the deployment platform in most CI/CD environments (Vercel, Netlify, AWS Amplify, Azure, and others), so you typically don't need to set a preset. For local builds, set the `NITRO_PRESET` environment variable: ```bash NITRO_PRESET=vercel npx vite build NITRO_PRESET=netlify npx vite build NITRO_PRESET=deno_deploy npx vite build +NITRO_PRESET=node npx vite build ``` -> **Deploying to Cloudflare?** You can use Nitro, but the native integration (`vinext deploy` / `@cloudflare/vite-plugin`) is recommended. It provides the best developer experience with `cloudflare:workers` bindings, KV caching, image optimization, and one-command deploys. +> **Deploying to Cloudflare?** You can use Nitro, but the native integration (`vinext deploy` / `@cloudflare/vite-plugin`) is recommended. It provides the best developer experience with Wrangler auth, `cloudflare:workers` bindings, KV caching, image optimization, and one-command deploys.
Vercel diff --git a/packages/vinext/src/cloudflare/tpr.ts b/packages/vinext/src/cloudflare/tpr.ts index 7eb96694..3800baa3 100644 --- a/packages/vinext/src/cloudflare/tpr.ts +++ b/packages/vinext/src/cloudflare/tpr.ts @@ -80,8 +80,10 @@ interface WranglerConfig { /** * Parse wrangler config (JSONC or TOML) to extract the fields TPR needs: * account_id, VINEXT_CACHE KV namespace ID, and custom domain. + * + * If `targetEnv` is provided, env-specific values override top-level config. */ -export function parseWranglerConfig(root: string): WranglerConfig | null { +export function parseWranglerConfig(root: string, targetEnv?: string): WranglerConfig | null { // Try JSONC / JSON first for (const filename of ["wrangler.jsonc", "wrangler.json"]) { const filepath = path.join(root, filename); @@ -89,7 +91,7 @@ export function parseWranglerConfig(root: string): WranglerConfig | null { const content = fs.readFileSync(filepath, "utf-8"); try { const json = JSON.parse(stripJsonComments(content)); - return extractFromJSON(json); + return extractFromJSON(json, targetEnv); } catch { continue; } @@ -100,7 +102,7 @@ export function parseWranglerConfig(root: string): WranglerConfig | null { const tomlPath = path.join(root, "wrangler.toml"); if (fs.existsSync(tomlPath)) { const content = fs.readFileSync(tomlPath, "utf-8"); - return extractFromTOML(content); + return extractFromTOML(content, targetEnv); } return null; @@ -179,17 +181,23 @@ function stripJsonComments(str: string): string { return result; } -function extractFromJSON(config: Record): WranglerConfig { +function extractFromJSON(config: Record, targetEnv?: string): WranglerConfig { const result: WranglerConfig = {}; + const envConfig = getJSONEnvConfig(config, targetEnv); // account_id - if (typeof config.account_id === "string") { + if (typeof envConfig?.account_id === "string") { + result.accountId = envConfig.account_id; + } else if (typeof config.account_id === "string") { result.accountId = config.account_id; } // KV namespace ID for VINEXT_CACHE - if (Array.isArray(config.kv_namespaces)) { - const vinextKV = config.kv_namespaces.find( + const kvNamespaces = Array.isArray(envConfig?.kv_namespaces) + ? envConfig.kv_namespaces + : config.kv_namespaces; + if (Array.isArray(kvNamespaces)) { + const vinextKV = kvNamespaces.find( (ns: Record) => ns && typeof ns === "object" && ns.binding === "VINEXT_CACHE", ); @@ -203,12 +211,32 @@ function extractFromJSON(config: Record): WranglerConfig { } // Custom domain — check routes[] and custom_domains[] - const domain = extractDomainFromRoutes(config.routes) ?? extractDomainFromCustomDomains(config); + const domain = extractDomainFromRoutes(envConfig?.routes) + ?? extractDomainFromRoutes(config.routes) + ?? extractDomainFromCustomDomains(envConfig) + ?? extractDomainFromCustomDomains(config); if (domain) result.customDomain = domain; return result; } +function getJSONEnvConfig( + config: Record, + targetEnv?: string, +): Record | null { + if (!targetEnv || !config.env || typeof config.env !== "object") { + return null; + } + + const envs = config.env as Record; + const envConfig = envs[targetEnv]; + if (!envConfig || typeof envConfig !== "object") { + return null; + } + + return envConfig as Record; +} + function extractDomainFromRoutes(routes: unknown): string | null { if (!Array.isArray(routes)) return null; @@ -233,7 +261,9 @@ function extractDomainFromRoutes(routes: unknown): string | null { return null; } -function extractDomainFromCustomDomains(config: Record): string | null { +function extractDomainFromCustomDomains(config: Record | null): string | null { + if (!config) return null; + // Workers Custom Domains: "custom_domains": ["example.com"] if (Array.isArray(config.custom_domains)) { for (const d of config.custom_domains) { @@ -259,16 +289,18 @@ function cleanDomain(raw: string): string | null { * Simple extraction of specific fields from wrangler.toml content. * Not a full TOML parser — just enough for the fields we need. */ -function extractFromTOML(content: string): WranglerConfig { +function extractFromTOML(content: string, targetEnv?: string): WranglerConfig { const result: WranglerConfig = {}; + const envBlock = getTOMLEnvBlock(content, targetEnv); // account_id = "..." - const accountMatch = content.match(/^account_id\s*=\s*"([^"]+)"/m); + const accountMatch = envBlock?.match(/^account_id\s*=\s*"([^"]+)"/m) + ?? content.match(/^account_id\s*=\s*"([^"]+)"/m); if (accountMatch) result.accountId = accountMatch[1]; // KV namespace with binding = "VINEXT_CACHE" // Look for [[kv_namespaces]] blocks - const kvBlocks = content.split(/\[\[kv_namespaces\]\]/); + const kvBlocks = (envBlock ?? content).split(/\[\[kv_namespaces\]\]/); for (let i = 1; i < kvBlocks.length; i++) { const block = kvBlocks[i].split(/\[\[/)[0]; // Take until next section const bindingMatch = block.match(/binding\s*=\s*"([^"]+)"/); @@ -284,7 +316,8 @@ function extractFromTOML(content: string): WranglerConfig { // routes — both string and table forms // route = "example.com/*" - const routeMatch = content.match(/^route\s*=\s*"([^"]+)"/m); + const routeMatch = envBlock?.match(/^route\s*=\s*"([^"]+)"/m) + ?? content.match(/^route\s*=\s*"([^"]+)"/m); if (routeMatch) { const domain = cleanDomain(routeMatch[1]); if (domain && !domain.includes("workers.dev")) { @@ -294,7 +327,7 @@ function extractFromTOML(content: string): WranglerConfig { // [[routes]] blocks if (!result.customDomain) { - const routeBlocks = content.split(/\[\[routes\]\]/); + const routeBlocks = (envBlock ?? content).split(/\[\[routes\]\]/); for (let i = 1; i < routeBlocks.length; i++) { const block = routeBlocks[i].split(/\[\[/)[0]; const patternMatch = block.match(/pattern\s*=\s*"([^"]+)"/); @@ -311,6 +344,21 @@ function extractFromTOML(content: string): WranglerConfig { return result; } +function getTOMLEnvBlock(content: string, targetEnv?: string): string | null { + if (!targetEnv) return null; + + const sectionHeader = `[env.${targetEnv}]`; + const start = content.indexOf(sectionHeader); + if (start === -1) return null; + + const nextSection = content.slice(start + sectionHeader.length).search(/^\[/m); + if (nextSection === -1) { + return content.slice(start + sectionHeader.length); + } + + return content.slice(start + sectionHeader.length, start + sectionHeader.length + nextSection); +} + // ─── Cloudflare API ────────────────────────────────────────────────────────── /** Resolve zone ID from a domain name via the Cloudflare API. */ diff --git a/packages/vinext/src/deploy.ts b/packages/vinext/src/deploy.ts index fd97e783..62115c96 100644 --- a/packages/vinext/src/deploy.ts +++ b/packages/vinext/src/deploy.ts @@ -18,8 +18,9 @@ import fs from "node:fs"; import path from "node:path"; import { createRequire } from "node:module"; import { execFileSync, type ExecSyncOptions } from "node:child_process"; +import { pathToFileURL } from "node:url"; import { parseArgs as nodeParseArgs } from "node:util"; -import { createBuilder, build } from "vite"; +import { createBuilder, build, loadConfigFromFile, mergeConfig } from "vite"; import { ensureESModule as _ensureESModule, renameCJSConfigs as _renameCJSConfigs, @@ -27,7 +28,7 @@ import { findInNodeModules as _findInNodeModules, } from "./utils/project.js"; import { getReactUpgradeDeps } from "./init.js"; -import { runTPR } from "./cloudflare/tpr.js"; +import { parseWranglerConfig, runTPR } from "./cloudflare/tpr.js"; import { loadDotenv } from "./config/dotenv.js"; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -920,12 +921,8 @@ interface GeneratedFile { /** * Check whether an existing vite.config file already imports and uses the - * Cloudflare Vite plugin. This is a heuristic text scan — it doesn't execute - * the config — so it may produce false negatives for unusual configurations. - * - * Returns true if `@cloudflare/vite-plugin` appears to be configured, false - * if it is missing (meaning the build will fail with "could not resolve - * virtual:vinext-rsc-entry"). + * Cloudflare Vite plugin. This is a heuristic text scan used for guidance only + * — the deploy build can still inject the plugin when needed. */ export function viteConfigHasCloudflarePlugin(root: string): boolean { const candidates = [ @@ -996,21 +993,84 @@ function writeGeneratedFiles(files: GeneratedFile[]): void { // ─── Build ─────────────────────────────────────────────────────────────────── +function findViteConfigPath(root: string): string | null { + for (const name of ["vite.config.ts", "vite.config.js", "vite.config.mjs"]) { + const filePath = path.join(root, name); + if (fs.existsSync(filePath)) return filePath; + } + return null; +} + +function flattenPlugins(plugins: unknown): Array<{ name?: unknown }> { + if (!plugins) return []; + if (Array.isArray(plugins)) { + return plugins.flatMap((plugin) => flattenPlugins(plugin)); + } + if (typeof plugins === "object") { + return [plugins as { name?: unknown }]; + } + return []; +} + +export function configNeedsCloudflarePlugin(config: { plugins?: unknown }): boolean { + const plugins = flattenPlugins(config.plugins); + return !plugins.some((plugin) => { + return typeof plugin.name === "string" && plugin.name.toLowerCase().includes("cloudflare"); + }); +} + +async function loadCloudflarePlugin(root: string): Promise<(options?: Record) => unknown> { + const req = createRequire(path.join(root, "package.json")); + const resolved = req.resolve("@cloudflare/vite-plugin"); + const mod = await import(pathToFileURL(resolved).href) as { + cloudflare: (options?: Record) => unknown; + }; + return mod.cloudflare; +} + +async function getDeployBuildConfig(info: ProjectInfo): Promise> { + const configPath = findViteConfigPath(info.root); + if (!configPath) { + return { root: info.root }; + } + + const loaded = await loadConfigFromFile({ command: "build", mode: "production" }, configPath, info.root); + const userConfig = loaded?.config ?? {}; + + if (!configNeedsCloudflarePlugin(userConfig)) { + return { root: info.root }; + } + + const cloudflare = await loadCloudflarePlugin(info.root); + const cloudflarePlugin = info.isAppRouter + ? cloudflare({ + viteEnvironment: { name: "rsc", childEnvironments: ["ssr"] }, + }) + : cloudflare(); + + return mergeConfig(userConfig, { + configFile: false, + root: info.root, + plugins: [cloudflarePlugin], + }); +} + async function runBuild(info: ProjectInfo): Promise { console.log("\n Building for Cloudflare Workers...\n"); - // Use Vite's JS API for the build. The user's vite.config.ts (or our - // generated one) has the cloudflare() plugin which handles the Worker - // output format. We just need to trigger the build. + // Use Vite's JS API for the build. If the project already has a Vite config + // but it is host-neutral (for example from `vinext init`), inject the + // Cloudflare Vite plugin only for this deploy run. // // For App Router, createBuilder().buildApp() handles multi-environment builds. // For Pages Router, a single build() call suffices (cloudflare plugin manages it). + const buildConfig = await getDeployBuildConfig(info); if (info.isAppRouter) { - const builder = await createBuilder({ root: info.root }); + const builder = await createBuilder(buildConfig); await builder.buildApp(); } else { - await build({ root: info.root }); + await build(buildConfig); } } @@ -1021,27 +1081,192 @@ export interface WranglerDeployArgs { env: string | undefined; } -export function buildWranglerDeployArgs(options: Pick): WranglerDeployArgs { - const args = ["deploy"]; - const env = options.env || (options.preview ? "preview" : undefined); - if (env) { - args.push("--env", env); +export interface WranglerTtyState { + stdin: boolean; + stdout: boolean; + stderr: boolean; +} + +export type WranglerAuthPlan = + | { kind: "token" } + | { kind: "interactive" } + | { kind: "login" } + | { kind: "error"; message: string }; + +const WRANGLER_LOGIN_DOCS_URL = "https://developers.cloudflare.com/workers/wrangler/commands/#login"; +const CLOUDFLARE_TOKEN_DOCS_URL = "https://developers.cloudflare.com/fundamentals/api/get-started/create-token/"; +const CLOUDFLARE_ACCOUNT_ID_DOCS_URL = + "https://developers.cloudflare.com/fundamentals/account/find-account-and-zone-ids/"; +const CLOUDFLARE_CI_DOCS_URL = + "https://developers.cloudflare.com/workers/ci-cd/external-cicd/github-actions/"; + +function getWranglerTtyState(): WranglerTtyState { + return { + stdin: Boolean(process.stdin.isTTY), + stdout: Boolean(process.stdout.isTTY), + stderr: Boolean(process.stderr.isTTY), + }; +} + +function getWranglerBin(root: string): string { + return _findInNodeModules(root, ".bin/wrangler") + ?? path.join(root, "node_modules", ".bin", "wrangler"); +} + +export function shouldUseInteractiveWranglerAuth( + env: NodeJS.ProcessEnv = process.env, + tty: WranglerTtyState = getWranglerTtyState(), +): boolean { + if (env.CLOUDFLARE_API_TOKEN) return false; + return tty.stdin && tty.stdout && tty.stderr; +} + +export function buildWranglerAuthHelpMessage(): string { + return [ + " Cloudflare authentication is required to run `vinext deploy` in a headless shell.", + " This shell is non-interactive, so Wrangler cannot open the browser-based `wrangler login` flow.", + "", + " Provide:", + ' - `CLOUDFLARE_API_TOKEN`: use Cloudflare\'s "Edit Cloudflare Workers" API token template', + ` ${CLOUDFLARE_TOKEN_DOCS_URL}`, + " - `CLOUDFLARE_ACCOUNT_ID`: your Cloudflare account ID, unless `account_id` is already set in `wrangler.jsonc` or `wrangler.toml`", + ` ${CLOUDFLARE_ACCOUNT_ID_DOCS_URL}`, + "", + " Local terminal auth:", + " - `npx wrangler login`", + ` ${WRANGLER_LOGIN_DOCS_URL}`, + "", + " CI setup reference:", + ` ${CLOUDFLARE_CI_DOCS_URL}`, + ].join("\n"); +} + +export function buildWranglerAccountIdHelpMessage(targetEnv?: string): string { + const envSuffix = targetEnv ? ` for Wrangler env \`${targetEnv}\`` : ""; + + return [ + ` \`CLOUDFLARE_API_TOKEN\` is set, but no Cloudflare account ID was found${envSuffix}.`, + " Provide one of:", + " - `CLOUDFLARE_ACCOUNT_ID`", + " - `account_id` in `wrangler.jsonc` or `wrangler.toml` (top-level or the selected env block)", + ` ${CLOUDFLARE_ACCOUNT_ID_DOCS_URL}`, + "", + " CI setup reference:", + ` ${CLOUDFLARE_CI_DOCS_URL}`, + ].join("\n"); +} + +export function resolveWranglerAccountId( + root: string, + env: NodeJS.ProcessEnv = process.env, + targetEnv?: string, +): string | null { + const envAccountId = env.CLOUDFLARE_ACCOUNT_ID?.trim(); + if (envAccountId) { + return envAccountId; } - return { args, env }; + + const configAccountId = parseWranglerConfig(root, targetEnv)?.accountId?.trim(); + return configAccountId || null; +} + +export function planWranglerAuth( + env: NodeJS.ProcessEnv = process.env, + tty: WranglerTtyState = getWranglerTtyState(), + hasSavedLogin = false, + hasAccountId = true, + targetEnv?: string, +): WranglerAuthPlan { + if (env.CLOUDFLARE_API_TOKEN) { + if (!hasAccountId) { + return { + kind: "error", + message: buildWranglerAccountIdHelpMessage(targetEnv), + }; + } + return { kind: "token" }; + } + + if (!shouldUseInteractiveWranglerAuth(env, tty)) { + return { + kind: "error", + message: buildWranglerAuthHelpMessage(), + }; + } + + return { kind: hasSavedLogin ? "interactive" : "login" }; } -function runWranglerDeploy(root: string, options: Pick): string { - // Walk up ancestor directories so the binary is found even when node_modules - // is hoisted to the workspace root in a monorepo. - const wranglerBin = _findInNodeModules(root, ".bin/wrangler") ?? - path.join(root, "node_modules", ".bin", "wrangler"); // fallback for error message clarity +export function buildWranglerExecOptions( + root: string, + useInteractiveAuth = shouldUseInteractiveWranglerAuth(), +): ExecSyncOptions { + if (useInteractiveAuth) { + return { + cwd: root, + stdio: "inherit", + }; + } - const execOpts: ExecSyncOptions = { + return { cwd: root, stdio: "pipe", encoding: "utf-8", }; +} + +function hasSavedWranglerLogin(root: string): boolean { + try { + execFileSync(getWranglerBin(root), ["whoami"], { + cwd: root, + stdio: "pipe", + encoding: "utf-8", + }); + return true; + } catch { + return false; + } +} + +function ensureWranglerAuth(root: string, options: Pick): void { + const tty = getWranglerTtyState(); + const { env: targetEnv } = buildWranglerDeployArgs(options); + const hasSavedLogin = shouldUseInteractiveWranglerAuth(process.env, tty) + ? hasSavedWranglerLogin(root) + : false; + const hasAccountId = Boolean(resolveWranglerAccountId(root, process.env, targetEnv)); + const plan = planWranglerAuth(process.env, tty, hasSavedLogin, hasAccountId, targetEnv); + + if (plan.kind === "token" || plan.kind === "interactive") { + return; + } + if (plan.kind === "login") { + console.log("\n No saved Wrangler session detected. Starting `wrangler login`...\n"); + try { + execFileSync(getWranglerBin(root), ["login"], buildWranglerExecOptions(root, true)); + return; + } catch { + console.error("\n Wrangler login did not complete. Re-run `npx wrangler login` and try again.\n"); + process.exit(1); + } + } + + console.error(`\n${plan.message}\n`); + process.exit(1); +} + +export function buildWranglerDeployArgs(options: Pick): WranglerDeployArgs { + const args = ["deploy"]; + const env = options.env || (options.preview ? "preview" : undefined); + if (env) { + args.push("--env", env); + } + return { args, env }; +} + +function runWranglerDeploy(root: string, options: Pick): string | null { + const wranglerBin = getWranglerBin(root); const { args, env } = buildWranglerDeployArgs(options); if (env) { @@ -1050,6 +1275,14 @@ function runWranglerDeploy(root: string, options: Pick { writeGeneratedFiles(filesToGenerate); } - // Warn if an existing vite.config.ts is missing the Cloudflare plugin. - // This is the most common cause of "could not resolve virtual:vinext-rsc-entry" - // errors — `vinext init` generates a minimal local-dev config without it. + // Surface the config mismatch, but keep going: deploy injects the Cloudflare + // plugin for this build when it is missing from an existing Vite config. if (info.hasViteConfig && !viteConfigHasCloudflarePlugin(root)) { console.warn(` - Warning: your vite.config.ts does not appear to import @cloudflare/vite-plugin. - Cloudflare Workers deployment requires it. Add the plugin to your config: + Note: your vite.config.ts does not appear to import @cloudflare/vite-plugin. + vinext deploy will inject it for this deploy run so Cloudflare Workers + deployment can proceed. Add the plugin yourself if you want Cloudflare- + specific local behavior such as \`cloudflare:workers\` bindings in dev: import { cloudflare } from "@cloudflare/vite-plugin"; @@ -1163,6 +1397,12 @@ export async function deploy(options: DeployOptions): Promise { return; } + // Step 4.5: Ensure Wrangler auth before any long-running build/deploy work. + ensureWranglerAuth(root, { + preview: options.preview ?? false, + env: options.env, + }); + // Step 5: Build if (!options.skipBuild) { await runBuild(info); @@ -1191,7 +1431,11 @@ export async function deploy(options: DeployOptions): Promise { env: options.env, }); - console.log("\n ─────────────────────────────────────────"); - console.log(` Deployed to: ${url}`); - console.log(" ─────────────────────────────────────────\n"); + if (url) { + console.log("\n ─────────────────────────────────────────"); + console.log(` Deployed to: ${url}`); + console.log(" ─────────────────────────────────────────\n"); + } else { + console.log("\n Deploy complete. See Wrangler output above.\n"); + } } diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index a9f939ca..3dc53d6e 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -11,8 +11,15 @@ import { generatePagesRouterViteConfig, getMissingDeps, getFilesToGenerate, + configNeedsCloudflarePlugin, ensureESModule, renameCJSConfigs, + shouldUseInteractiveWranglerAuth, + buildWranglerAuthHelpMessage, + buildWranglerAccountIdHelpMessage, + planWranglerAuth, + resolveWranglerAccountId, + buildWranglerExecOptions, buildWranglerDeployArgs, parseDeployArgs, isPackageResolvable, @@ -55,6 +62,203 @@ afterEach(() => { // ─── Wrangler deploy args ─────────────────────────────────────────────────── +describe("shouldUseInteractiveWranglerAuth", () => { + it("uses interactive auth when running in a TTY without an API token", () => { + expect(shouldUseInteractiveWranglerAuth({}, { stdin: true, stdout: true, stderr: true })).toBe(true); + }); + + it("uses token auth when CLOUDFLARE_API_TOKEN is set", () => { + expect( + shouldUseInteractiveWranglerAuth( + { CLOUDFLARE_API_TOKEN: "token" }, + { stdin: true, stdout: true, stderr: true }, + ), + ).toBe(false); + }); + + it("uses token auth when no TTY is available", () => { + expect(shouldUseInteractiveWranglerAuth({}, { stdin: true, stdout: false, stderr: true })).toBe(false); + }); +}); + +describe("buildWranglerExecOptions", () => { + it("preserves the terminal for local wrangler login flows", () => { + const useInteractiveAuth = shouldUseInteractiveWranglerAuth( + {}, + { stdin: true, stdout: true, stderr: true }, + ); + + expect(buildWranglerExecOptions(tmpDir, useInteractiveAuth)).toEqual({ + cwd: tmpDir, + stdio: "inherit", + }); + }); + + it("pipes output for headless or token-based deploys", () => { + const useInteractiveAuth = shouldUseInteractiveWranglerAuth( + { CLOUDFLARE_API_TOKEN: "token" }, + { stdin: true, stdout: true, stderr: true }, + ); + + expect(buildWranglerExecOptions(tmpDir, useInteractiveAuth)).toEqual({ + cwd: tmpDir, + stdio: "pipe", + encoding: "utf-8", + }); + }); +}); + +describe("planWranglerAuth", () => { + it("uses token auth when CLOUDFLARE_API_TOKEN is set", () => { + expect( + planWranglerAuth( + { CLOUDFLARE_API_TOKEN: "token" }, + { stdin: false, stdout: false, stderr: false }, + ), + ).toEqual({ kind: "token" }); + }); + + it("requires an account ID when using API token auth", () => { + expect( + planWranglerAuth( + { CLOUDFLARE_API_TOKEN: "token" }, + { stdin: false, stdout: false, stderr: false }, + false, + false, + "preview", + ), + ).toEqual({ + kind: "error", + message: buildWranglerAccountIdHelpMessage("preview"), + }); + }); + + it("reuses an existing interactive wrangler session when available", () => { + expect( + planWranglerAuth( + {}, + { stdin: true, stdout: true, stderr: true }, + true, + ), + ).toEqual({ kind: "interactive" }); + }); + + it("starts wrangler login when interactive auth is possible but no session exists", () => { + expect( + planWranglerAuth( + {}, + { stdin: true, stdout: true, stderr: true }, + false, + ), + ).toEqual({ kind: "login" }); + }); + + it("returns a helpful headless auth error when no token is available", () => { + expect( + planWranglerAuth( + {}, + { stdin: false, stdout: false, stderr: false }, + ), + ).toEqual({ + kind: "error", + message: buildWranglerAuthHelpMessage(), + }); + }); +}); + +describe("buildWranglerAuthHelpMessage", () => { + it("documents the required Cloudflare auth setup", () => { + const message = buildWranglerAuthHelpMessage(); + expect(message).toContain("CLOUDFLARE_API_TOKEN"); + expect(message).toContain("CLOUDFLARE_ACCOUNT_ID"); + expect(message).toContain("Edit Cloudflare Workers"); + expect(message).toContain("npx wrangler login"); + expect(message).toContain("developers.cloudflare.com/fundamentals/api/get-started/create-token/"); + expect(message).toContain("developers.cloudflare.com/fundamentals/account/find-account-and-zone-ids/"); + expect(message).toContain("developers.cloudflare.com/workers/ci-cd/external-cicd/github-actions/"); + expect(message).toContain("developers.cloudflare.com/workers/wrangler/commands/#login"); + }); +}); + +describe("buildWranglerAccountIdHelpMessage", () => { + it("documents the required account ID setup for token auth", () => { + const message = buildWranglerAccountIdHelpMessage("preview"); + expect(message).toContain("CLOUDFLARE_API_TOKEN"); + expect(message).toContain("CLOUDFLARE_ACCOUNT_ID"); + expect(message).toContain("account_id"); + expect(message).toContain("preview"); + expect(message).toContain("developers.cloudflare.com/fundamentals/account/find-account-and-zone-ids/"); + expect(message).toContain("developers.cloudflare.com/workers/ci-cd/external-cicd/github-actions/"); + }); +}); + +describe("resolveWranglerAccountId", () => { + it("prefers CLOUDFLARE_ACCOUNT_ID from the environment", () => { + expect( + resolveWranglerAccountId( + tmpDir, + { CLOUDFLARE_ACCOUNT_ID: " env-account " }, + undefined, + ), + ).toBe("env-account"); + }); + + it("reads a top-level account_id from wrangler.jsonc", () => { + writeFile(tmpDir, "wrangler.jsonc", '{\n "account_id": "jsonc-account"\n}\n'); + expect(resolveWranglerAccountId(tmpDir)).toBe("jsonc-account"); + }); + + it("prefers an env-specific account_id from wrangler.jsonc", () => { + writeFile( + tmpDir, + "wrangler.jsonc", + `{ + "account_id": "top-level-account", + "env": { + "preview": { + "account_id": "preview-account" + } + } +} +`, + ); + + expect(resolveWranglerAccountId(tmpDir, {}, "preview")).toBe("preview-account"); + }); + + it("reads an env-specific account_id from wrangler.toml", () => { + writeFile( + tmpDir, + "wrangler.toml", + `account_id = "top-level-account" + +[env.preview] +account_id = "preview-account" +`, + ); + + expect(resolveWranglerAccountId(tmpDir, {}, "preview")).toBe("preview-account"); + }); +}); + +describe("configNeedsCloudflarePlugin", () => { + it("returns true when plugins are missing", () => { + expect(configNeedsCloudflarePlugin({})).toBe(true); + }); + + it("returns true when no cloudflare plugin is present", () => { + expect(configNeedsCloudflarePlugin({ plugins: [{ name: "vinext" }] })).toBe(true); + }); + + it("returns false when a cloudflare plugin is present", () => { + expect(configNeedsCloudflarePlugin({ plugins: [{ name: "vite:cloudflare" }] })).toBe(false); + }); + + it("handles nested plugin arrays", () => { + expect(configNeedsCloudflarePlugin({ plugins: [[{ name: "vite:cloudflare" }]] })).toBe(false); + }); +}); + describe("buildWranglerDeployArgs", () => { it("uses plain deploy for production by default", () => { expect(buildWranglerDeployArgs({})).toEqual({ args: ["deploy"], env: undefined });