diff --git a/docs/guide/platform/installation/maintenance.md b/docs/guide/platform/installation/maintenance.md index 5405b607b..956923738 100644 --- a/docs/guide/platform/installation/maintenance.md +++ b/docs/guide/platform/installation/maintenance.md @@ -100,7 +100,9 @@ Repair recreates missing infrastructure that the installer owns. It does not res ### Upgrade the platform -Keep the platform runtime current when updating the Void CLI. +Keep the platform runtime current when updating the Void CLI. Upgrade the platform before your team updates its CLI: a newer CLI refuses to work with an older platform and asks for `void platform upgrade`. Install the new CLI on the administrator's machine, run the upgrade below, then have the team update. + +If the platform will deploy apps that use `void/sandbox`, its runtime token needs **Account → Containers → Edit** and **Account → Cloudchamber → Edit**. A token made from an older setup link may lack them, and an upgrade does not add them. Edit the token in the Cloudflare dashboard, or set `VOID_PLATFORM_RUNTIME_CLOUDFLARE_API_TOKEN` to a new token when you upgrade. See [Cloudflare API Tokens](/guide/platform/installation/credentials#runtime-token-permissions). Use the installed CLI's packaged runtime to upgrade: diff --git a/docs/reference/config.md b/docs/reference/config.md index a89b56a3b..0d9fd77b3 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -297,7 +297,7 @@ Void-managed WebSocket route files (`*.ws.ts`) are Cloudflare-only because they ### `worker` -Curated Cloudflare Workers configuration. `void init` pins `compatibility_date` here. Binding arrays such as `d1_databases`, `kv_namespaces`, and `r2_buckets` go in [`cloudflare`](#cloudflare), where Void preserves custom settings and resolved IDs during deployment. +Curated Cloudflare Workers configuration. `void init` pins `compatibility_date` here. If a project has no config file and no date, the first dev server or build creates `void.config.ts` with only this date and adds `.void-wrangler.jsonc` to `.gitignore`. Astro, SvelteKit, Nuxt, and Analog apps get the date in a root `wrangler.jsonc` instead, which their adapter reads by default. A project with a `wrangler.toml` gets it in `void.json`. Binding arrays such as `d1_databases`, `kv_namespaces`, and `r2_buckets` go in [`cloudflare`](#cloudflare), where Void preserves custom settings and resolved IDs during deployment. | Field | Type | Description | | --------------------- | ---------- | -------------------------------------- | diff --git a/packages/deploy-cloudflare/src/version.ts b/packages/deploy-cloudflare/src/version.ts index d951b63d6..db6ade285 100644 --- a/packages/deploy-cloudflare/src/version.ts +++ b/packages/deploy-cloudflare/src/version.ts @@ -50,7 +50,7 @@ export type CloudflareTriggerConfig = Readonly<{ queueConsumers: ReadonlyArray; workflows: ReadonlyArray; /** - * Email Routing addresses (`addresses` in Wrangler config). Wrangler 4.131 applies its + * Email Routing addresses (`addresses` in Wrangler config). The Cloudflare CLI applies its * Email Routing plan inside `triggers deploy` — `undefined` skips the plan entirely, while * `[]` is a destructive plan that deletes every rule this Worker owns — so the two must stay * distinct through the snapshot. Absent from annotations written before this field existed. diff --git a/packages/deploy-cloudflare/test/unit/version.test.ts b/packages/deploy-cloudflare/test/unit/version.test.ts index e5ea46d61..c132592a7 100644 --- a/packages/deploy-cloudflare/test/unit/version.test.ts +++ b/packages/deploy-cloudflare/test/unit/version.test.ts @@ -1713,7 +1713,7 @@ describe('Cloudflare version activation', () => { ).not.toHaveProperty('contentHash'); }); - // Wrangler 4.131 applies its Email Routing plan inside `triggers deploy`, reading the + // The Cloudflare CLI applies its Email Routing plan inside `triggers deploy`, reading the // config's `addresses`. The trigger-only config Void hands it must therefore carry the // snapshot's addresses verbatim — and carry NOTHING when the snapshot has none, because an // absent key skips the plan while `[]` deletes every rule the Worker owns. diff --git a/packages/void/src/cli/deploy-transport.ts b/packages/void/src/cli/deploy-transport.ts index 7261ee1e0..0ceb894ac 100644 --- a/packages/void/src/cli/deploy-transport.ts +++ b/packages/void/src/cli/deploy-transport.ts @@ -46,10 +46,9 @@ export function scrubManagedDeployBuildMarkers(env: NodeJS.ProcessEnv): NodeJS.P * top-level config this flow provisions + preflights. Scrubbed from every child env * and fenced in the project dotenv (see below), on top of the provisioner's transport * scrub/fence which owns the endpoint-redirect + `CLOUDFLARE_ENV` vars: - * - `WRANGLER_CI_OVERRIDE_NAME` — wrangler 4.131 applies it AFTER config load - * (`mergeSharedConfigArgs` → `getCIOverrideName()`), REPLACING the upload name, so a - * bare `wrangler deploy` uploads a worker whose name the generated-config check never - * validated. + * - `WRANGLER_CI_OVERRIDE_NAME` — the Cloudflare tooling (wrangler and the `cf` CLI) + * applies it AFTER config load (`getCIOverrideName()`), REPLACING the upload name, so a + * bare deploy uploads a worker whose name the generated-config check never validated. * - `CLOUDFLARE_VITE_WRANGLER_CONFIG_PATH` — the CF Vite plugin points the build at an * ALTERNATE wrangler config, flattening a different binding set into * `dist/ssr/wrangler.json`. @@ -60,10 +59,12 @@ const DEPLOY_CONFIG_OVERRIDE_ENV_VARS = [ ] as const; /** - * The dotenv files wrangler 4.131 reloads into `process.env` (`override: true`) for - * the orchestrator's `--env=` calls (`getDefaultEnvFiles("")`), so a repo dotenv here - * would resurrect `WRANGLER_CI_OVERRIDE_NAME` at the DEPLOY step after the child-env - * scrub removed it. + * The dotenv files wrangler loads into `process.env` (`override: true`) when no + * `--env` is named (`getDefaultEnvFiles("")`). Void runs its deploy steps through the + * `cf` CLI, not wrangler, but this set is kept for a user who runs `wrangler deploy` on + * the same config. It also covers the `.env` / `.env.local` files `cf` reads. A repo + * dotenv here would bring back `WRANGLER_CI_OVERRIDE_NAME` after the child-env scrub + * removed it. */ const DEPLOY_WRANGLER_DOTENV_FILES = ['.env', '.env.local', '.env.', '.env..local']; @@ -71,7 +72,7 @@ const DEPLOY_WRANGLER_DOTENV_FILES = ['.env', '.env.local', '.env.', '.env..loca * The dotenv files Vite's `loadEnv(mode, …)` reads for a production `vite build` * (`.env`, `.env.local`, `.env.production`, `.env.production.local`), where the CF Vite * plugin picks up `CLOUDFLARE_ENV` / `CLOUDFLARE_VITE_WRANGLER_CONFIG_PATH` — a set - * DISJOINT from wrangler's `--env=` files, so it is fenced separately for the build. + * that differs from the wrangler dotenv files above, so it is fenced separately for the build. */ const DEPLOY_VITE_DOTENV_FILES = ['.env', '.env.local', '.env.production', '.env.production.local']; @@ -133,7 +134,7 @@ export function findWranglerCiOverrideDotenvFile( * FAIL CLOSED when the project's dotenv files declare a deploy-time config-override * selector. wrangler / Vite reload dotenv on every command, so a repo dotenv would * reintroduce a scrubbed var AFTER the child-env scrub — reselecting the upload worker - * name (wrangler `--env=` files) or the build's wrangler config (Vite production files). + * name (the wrangler dotenv files) or the build's wrangler config (Vite production files). * The provisioner's transport fence ({@link assertNoTransportRedirectInProjectDotenv}) * owns `CLOUDFLARE_ENV` + the endpoint-redirect vars; this closes the deploy-specific * name/config-path selectors it does not cover, across BOTH consumers' file sets. @@ -143,7 +144,7 @@ export function assertNoConfigOverrideInProjectDotenv( root: string, platform: NodeJS.Platform = process.platform, ): void { - // WRANGLER_CI_OVERRIDE_NAME reaches the DEPLOY step via wrangler's `--env=` dotenv reload. + // WRANGLER_CI_OVERRIDE_NAME reaches a deploy through the wrangler dotenv reload. const ciOverrideFile = findWranglerCiOverrideDotenvFile(root, platform); if (ciOverrideFile) { throw new Error( @@ -154,7 +155,7 @@ export function assertNoConfigOverrideInProjectDotenv( ); } // CLOUDFLARE_ENV / CLOUDFLARE_VITE_WRANGLER_CONFIG_PATH reach the BUILD via Vite's - // production `loadEnv`, whose file set is disjoint from wrangler's `--env=` files. + // production `loadEnv`, whose file set differs from the wrangler dotenv files. for (const file of DEPLOY_VITE_DOTENV_FILES) { const offender = findDotenvDeclaration( root, diff --git a/packages/void/src/cli/migrate.ts b/packages/void/src/cli/migrate.ts index 075baf931..b5274038b 100644 --- a/packages/void/src/cli/migrate.ts +++ b/packages/void/src/cli/migrate.ts @@ -13,6 +13,7 @@ import { log } from '@voidzero-dev/vite-plus-prompts'; import { parseTree, type Node } from 'jsonc-parser'; import { join } from 'pathslash'; import { + ignoreMaterializedCloudflareConfig, seedCloudflareConfig, stashCloudflareDeployPointer, VOID_LOCK_FILE, @@ -96,18 +97,6 @@ function isOrphanedCompatibilityConfig( } } -function ignoreGeneratedCloudflareConfig(root: string): void { - const path = join(root, '.gitignore'); - const source = existsSync(path) ? readFileSync(path, 'utf8') : ''; - if (source.split(/\r?\n/).some((line) => /^\/?\.void-wrangler\.jsonc$/.test(line.trim()))) { - return; - } - writeFileSync( - path, - `${source}${source && !source.endsWith('\n') ? '\n' : ''}.void-wrangler.jsonc\n`, - ); -} - /** Move Void and Cloudflare JSON/JSONC settings into a typed, checked-in project config. */ export function migrateLegacyVoidConfig(root: string): boolean { const legacyPath = join(root, LEGACY_FILE); @@ -226,7 +215,7 @@ export function migrateLegacyVoidConfig(root: string): boolean { if (wranglerName) { unlinkSync(join(root, wranglerName)); } - ignoreGeneratedCloudflareConfig(root); + ignoreMaterializedCloudflareConfig(root); stashCloudflareDeployPointer(root); return true; } finally { diff --git a/packages/void/src/cli/provision.ts b/packages/void/src/cli/provision.ts index 92e6b9bfa..c057223be 100644 --- a/packages/void/src/cli/provision.ts +++ b/packages/void/src/cli/provision.ts @@ -267,8 +267,9 @@ const defaultLogger: ProvisionLogger = { }; /** - * Env vars that move wrangler's API transport to another endpoint or select another config - * env. Provisioning pins one base ({@link provisionEnv}) and rejects them in project dotenv. + * Env vars that move the Cloudflare CLI's API transport to another endpoint or select another + * config env. Provisioning pins one base ({@link provisionEnv}) and rejects them in project + * dotenv. */ const TRANSPORT_REDIRECT_ENV_VARS = [ 'CLOUDFLARE_API_BASE_URL', @@ -289,7 +290,7 @@ const OUTPUT_FILE_ENV_VARS = [ /** * The one API base URL this provision uses, resolved like wrangler's `getCloudflareApiBaseUrl` - * so the Hyperdrive REST lister and wrangler agree: + * so the Hyperdrive REST lister and the Cloudflare CLI agree: * 1. a present `CLOUDFLARE_API_BASE_URL` (then `CF_API_BASE_URL`) is used as-is, even empty; * 2. otherwise the staging and compliance selectors derive it, and invalid values fail; * 3. the config's `compliance_region` is always validated. @@ -300,7 +301,7 @@ export function resolvePinnedApiBaseUrl( ): string { const configRegion = configComplianceRegion !== undefined - ? assertComplianceChoice(configComplianceRegion, 'the wrangler config "compliance_region"') + ? assertComplianceChoice(configComplianceRegion, 'the Cloudflare config "compliance_region"') : undefined; const direct = env.CLOUDFLARE_API_BASE_URL !== undefined ? env.CLOUDFLARE_API_BASE_URL : env.CF_API_BASE_URL; @@ -322,8 +323,8 @@ function resolveApiEnvironment(env: NodeJS.ProcessEnv): 'production' | 'staging' if (raw !== 'production' && raw !== 'staging') { throw new Error( `provision: the WRANGLER_API_ENVIRONMENT environment variable must be one of ` + - `"production" or "staging", got ${JSON.stringify(raw)}. wrangler rejects this value, so ` + - `provisioning refuses to guess an endpoint; fix or unset it and retry.`, + `"production" or "staging", got ${JSON.stringify(raw)}. The Cloudflare tooling rejects ` + + `this value, so provisioning refuses to guess an endpoint; fix or unset it and retry.`, ); } return raw; @@ -358,9 +359,9 @@ function resolveComplianceRegion( throw new Error( `provision: the Cloudflare compliance region is set to conflicting values — the ` + `CLOUDFLARE_COMPLIANCE_REGION environment variable is ${JSON.stringify(envRegion)} but the ` + - `wrangler config "compliance_region" is ${JSON.stringify(configRegion)}. wrangler refuses to ` + - `run with a mismatched compliance region; reconcile them (remove one, or set them equal) and ` + - `retry.`, + `Cloudflare config "compliance_region" is ${JSON.stringify(configRegion)}. The Cloudflare ` + + `tooling refuses to run with a mismatched compliance region; reconcile them (remove one, or ` + + `set them equal) and retry.`, ); } return envRegion ?? configRegion ?? 'public'; diff --git a/packages/void/src/cli/wrangler.ts b/packages/void/src/cli/wrangler.ts index 2d4b46f64..44376e973 100644 --- a/packages/void/src/cli/wrangler.ts +++ b/packages/void/src/cli/wrangler.ts @@ -20,11 +20,13 @@ import { } from 'jsonc-parser'; import { basename, dirname, join, normalize, resolve } from 'pathslash'; import { + ignoreMaterializedCloudflareConfig, materializeCloudflareConfig, MATERIALIZED_CLOUDFLARE_FILE, persistCloudflareConfig, } from '../cloudflare-config.ts'; import { TYPED_CONFIG_FILE } from '../config-loader.ts'; +import { writeVoidProjectConfig } from '../config-write.ts'; import { readConfig, type VoidConfig } from '../config.ts'; import type { DurableObjectModule } from '../durable/scan.ts'; import type { InferredBindings } from '../inference/walk.ts'; @@ -209,7 +211,6 @@ export type WranglerCompat = { }; type WranglerCompatFields = { - voidConfig: VoidConfig | undefined; voidDate: string | undefined; voidFlags: Array | undefined; wranglerDate: string | undefined; @@ -372,17 +373,23 @@ export function readWranglerCompat( return resolveWranglerCompat(readWranglerCompatFields(root, config, buildOutputDir)); } -/** Like `readWranglerCompat()`, but pins the default date when no source sets one. */ +/** + * Like `readWranglerCompat()`, but pins the default date when no source sets one. + * With `rootWrangler`, a project with no config gets the date in a root wrangler.jsonc + * instead of a new void.config.ts, so a framework adapter that reads the root file by + * default still finds it. + */ export function ensureWranglerCompat( root: string, config?: VoidConfig, buildOutputDir?: string, + options?: { rootWrangler?: boolean }, ): WranglerCompat { const fields = readWranglerCompatFields(root, config, buildOutputDir); const hasExplicitDate = fields.voidDate ?? fields.wranglerDate ?? fields.buildDate; const defaultDate = hasExplicitDate ? undefined - : ensureVoidCompatibilityDate(root, fields.voidConfig); + : ensureVoidCompatibilityDate(root, options?.rootWrangler === true); return resolveWranglerCompat(fields, defaultDate ? { defaultDate } : undefined); } @@ -442,7 +449,6 @@ function readWranglerCompatFields( } return { - voidConfig, voidDate: voidConfig?.worker?.compatibility_date, voidFlags: voidConfig?.worker?.compatibility_flags, wranglerDate, @@ -489,29 +495,41 @@ function readVoidConfigIfExists(root: string): VoidConfig | undefined { : undefined; } -function ensureVoidCompatibilityDate(root: string, config?: VoidConfig): string { +function ensureVoidCompatibilityDate(root: string, rootWrangler: boolean): string { if (existsSync(join(root, TYPED_CONFIG_FILE))) { writeProvisionedWranglerConfig(root, { compatibilityDate: LATEST_KNOWN_COMPAT_DATE }); return LATEST_KNOWN_COMPAT_DATE; } const filePath = join(root, VOID_CONFIG_FILE); - let nextConfig: VoidConfig = {}; + if (!existsSync(filePath)) { + if (existsSync(join(root, 'wrangler.toml'))) { + // void.config.ts would point the Cloudflare plugin away from wrangler.toml and drop + // its bindings, so keep the date in void.json. + const pinned: VoidConfig = { worker: { compatibility_date: LATEST_KNOWN_COMPAT_DATE } }; + writeFileSync(filePath, `${JSON.stringify(pinned, null, 2)}\n`); + } else if (rootWrangler || projectWranglerPaths(root).some((path) => existsSync(path))) { + // A root Wrangler config cannot sit next to void.config.ts, so pin the date in it. + writeProvisionedWranglerConfig(root, { compatibilityDate: LATEST_KNOWN_COMPAT_DATE }); + } else { + // New projects get the date in void.config.ts, as `void init` writes it. + writeVoidProjectConfig(root, { worker: { compatibility_date: LATEST_KNOWN_COMPAT_DATE } }); + ignoreMaterializedCloudflareConfig(root); + } + return LATEST_KNOWN_COMPAT_DATE; + } - if (existsSync(filePath)) { - try { - const parsed = JSON.parse(readFileSync(filePath, 'utf-8')); - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - throw new Error(`config: ${VOID_CONFIG_FILE} must be a JSON object.`); - } - nextConfig = parsed as VoidConfig; - } catch (error) { - if (error instanceof SyntaxError) { - throw new Error(`config: Invalid JSON in ${VOID_CONFIG_FILE}.`); - } - throw error; + let nextConfig: VoidConfig; + try { + const parsed = JSON.parse(readFileSync(filePath, 'utf-8')); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error(`config: ${VOID_CONFIG_FILE} must be a JSON object.`); + } + nextConfig = parsed as VoidConfig; + } catch (error) { + if (error instanceof SyntaxError) { + throw new Error(`config: Invalid JSON in ${VOID_CONFIG_FILE}.`); } - } else if (config) { - nextConfig = { ...config }; + throw error; } nextConfig.worker = { diff --git a/packages/void/src/cloudflare-config.ts b/packages/void/src/cloudflare-config.ts index fea602209..0243cf33e 100644 --- a/packages/void/src/cloudflare-config.ts +++ b/packages/void/src/cloudflare-config.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { isDeepStrictEqual } from 'node:util'; import { isRecord } from '@void/deploy-core'; import { join } from 'pathslash'; @@ -11,6 +11,21 @@ export const MATERIALIZED_CLOUDFLARE_FILE = '.void-wrangler.jsonc'; export const VOID_LOCK_FILE = 'void.lock.json'; const PREVIEW_POINTER_FILE = 'preview-deploy-config.json'; +/** Add the generated Cloudflare config to `.gitignore` unless a line already ignores it. */ +export function ignoreMaterializedCloudflareConfig(root: string): void { + const path = join(root, '.gitignore'); + const source = existsSync(path) ? readFileSync(path, 'utf8') : ''; + const ignored = source.split('\n').some((line) => { + const entry = line.trim(); + return entry === MATERIALIZED_CLOUDFLARE_FILE || entry === `/${MATERIALIZED_CLOUDFLARE_FILE}`; + }); + if (ignored) { + return; + } + const separator = source && !source.endsWith('\n') ? '\n' : ''; + writeFileSync(path, `${source}${separator}${MATERIALIZED_CLOUDFLARE_FILE}\n`); +} + type JsonObject = Record; type CloudflareState = { version: 1; diff --git a/packages/void/src/plugin-framework.ts b/packages/void/src/plugin-framework.ts index a0accd704..b45e758e3 100644 --- a/packages/void/src/plugin-framework.ts +++ b/packages/void/src/plugin-framework.ts @@ -86,13 +86,18 @@ export function createFrameworkPlugins(input: { } }; + // Adapters read the root Wrangler file by default, so a project with no config gets + // the date there instead of in a new void.config.ts. + const ensureFrameworkCompat = () => + ensureWranglerCompat(root, config, undefined, { rootWrangler: true }); + // Ensure required compat flags are written before framework adapters // initialize Cloudflare dev runtime / Miniflare. const wranglerCompatPlugin: Plugin = { name: 'void:wrangler-compat', enforce: 'pre', config(_, env) { - syncFrameworkCompat(ensureWranglerCompat(root, config)); + syncFrameworkCompat(ensureFrameworkCompat()); if (env.command !== 'serve') { return; } @@ -102,10 +107,10 @@ export function createFrameworkPlugins(input: { } }, buildStart() { - syncFrameworkCompat(ensureWranglerCompat(root, config)); + syncFrameworkCompat(ensureFrameworkCompat()); }, configureServer() { - syncFrameworkCompat(ensureWranglerCompat(root, config)); + syncFrameworkCompat(ensureFrameworkCompat()); }, }; diff --git a/packages/void/test/unit/cloudflare-deploy.test.ts b/packages/void/test/unit/cloudflare-deploy.test.ts index 74ce099f0..bf10099a6 100644 --- a/packages/void/test/unit/cloudflare-deploy.test.ts +++ b/packages/void/test/unit/cloudflare-deploy.test.ts @@ -8716,7 +8716,7 @@ describe('checkWranglerMigrationApplySet', () => { }); }); -describe('compareWranglerMigrationPaths (wrangler 4.131 numeric-prefix comparator)', () => { +describe('compareWranglerMigrationPaths (wrangler numeric-prefix comparator)', () => { it('sorts by leading numeric prefix (2 before 10), matching wrangler', () => { const sorted = ['10_b.sql', '2_a.sql', '1_z.sql'].sort(compareWranglerMigrationPaths); expect(sorted).toEqual(['1_z.sql', '2_a.sql', '10_b.sql']); diff --git a/packages/void/test/unit/plugin-inference.test.ts b/packages/void/test/unit/plugin-inference.test.ts index 36914624b..51fe6239a 100644 --- a/packages/void/test/unit/plugin-inference.test.ts +++ b/packages/void/test/unit/plugin-inference.test.ts @@ -578,6 +578,7 @@ describe('detectFramework', () => { try { voidPlugin(); expect(existsSync(join(root, 'void.json'))).toBe(false); + expect(existsSync(join(root, 'void.config.ts'))).toBe(false); } finally { cwd.mockRestore(); } @@ -601,16 +602,24 @@ describe('detectFramework', () => { ) => void; expect(compatPlugin).toBeTruthy(); - expect(existsSync(join(root, 'void.json'))).toBe(false); + expect(existsSync(join(root, 'void.config.ts'))).toBe(false); config.call({}, {}, { command: 'serve', mode: 'development' } as import('vite').ConfigEnv); - expect(JSON.parse(readFileSync(join(root, 'void.json'), 'utf-8'))).toEqual({ - worker: { compatibility_date: LATEST_KNOWN_COMPAT_DATE }, - }); + // The adapter reads the root Wrangler file by default, so the date and the dev + // flags land there, not in a new void.config.ts. + expect(existsSync(join(root, 'void.json'))).toBe(false); + expect(existsSync(join(root, 'void.config.ts'))).toBe(false); + expect(existsSync(join(root, '.void-wrangler.jsonc'))).toBe(false); expect(JSON.parse(readFileSync(join(root, 'wrangler.jsonc'), 'utf-8'))).toMatchObject({ compatibility_date: LATEST_KNOWN_COMPAT_DATE, + compatibility_flags: ['nodejs_als'], }); + + // A second run reads the pinned date and leaves the root file alone. + const written = readFileSync(join(root, 'wrangler.jsonc'), 'utf-8'); + config.call({}, {}, { command: 'serve', mode: 'development' } as import('vite').ConfigEnv); + expect(readFileSync(join(root, 'wrangler.jsonc'), 'utf-8')).toBe(written); } finally { cwd.mockRestore(); } @@ -650,7 +659,7 @@ describe('detectFramework', () => { } }); - it('persists default void.json compatibility during build startup', () => { + it('persists default compatibility in void.config.ts during build startup', () => { const root = createTempRoot(); writeFileSync(join(root, 'package.json'), JSON.stringify({ type: 'module' })); const cwd = vi.spyOn(process, 'cwd').mockReturnValue(root); @@ -664,7 +673,9 @@ describe('detectFramework', () => { expect(persistPlugin).toBeTruthy(); buildStart.call({}); - expect(JSON.parse(readFileSync(join(root, 'void.json'), 'utf-8'))).toEqual({ + expect(existsSync(join(root, 'void.json'))).toBe(false); + expect(existsSync(join(root, 'wrangler.jsonc'))).toBe(false); + expect(readConfig(root)).toEqual({ worker: { compatibility_date: LATEST_KNOWN_COMPAT_DATE }, }); } finally { diff --git a/packages/void/test/unit/provision-resources.test.ts b/packages/void/test/unit/provision-resources.test.ts index 754975bde..52fb3d2cb 100644 --- a/packages/void/test/unit/provision-resources.test.ts +++ b/packages/void/test/unit/provision-resources.test.ts @@ -3096,7 +3096,7 @@ describe('provisionCloudflareResources — config compliance_region pins the tra it('rejects an invalid config compliance_region value', () => { expect(() => resolvePinnedApiBaseUrl({}, 'somewhere')).toThrow( - /one of "public" or "fedramp_high"/, + 'the Cloudflare config "compliance_region" must be one of "public" or "fedramp_high"', ); }); diff --git a/packages/void/test/unit/wrangler.test.ts b/packages/void/test/unit/wrangler.test.ts index 22b706be6..8260fdfa3 100644 --- a/packages/void/test/unit/wrangler.test.ts +++ b/packages/void/test/unit/wrangler.test.ts @@ -267,25 +267,87 @@ describe('readWranglerCompat', () => { }); describe('ensureWranglerCompat', () => { - it('pins the latest known-good compatibility date in void.json when none exists', () => { + it('pins the latest known-good compatibility date in a new void.config.ts', () => { rmSync(tmpDir, { recursive: true, force: true }); mkdirSync(tmpDir, { recursive: true }); - expect(ensureWranglerCompat(tmpDir)).toEqual({ + expect(ensureWranglerCompat(tmpDir, readConfig(tmpDir))).toEqual({ compatibilityDate: LATEST_KNOWN_COMPAT_DATE, }); - expect(JSON.parse(readFileSync(join(tmpDir, 'void.json'), 'utf-8'))).toEqual({ + expect(existsSync(join(tmpDir, 'void.json'))).toBe(false); + expect(existsSync(join(tmpDir, 'wrangler.jsonc'))).toBe(false); + expect(existsSync(join(tmpDir, 'void.lock.json'))).toBe(false); + expect(readRaw('void.config.ts')).toBe( + "// Generated by void init.\nimport { defineConfig } from 'void/config';\n\n" + + `export default defineConfig({\n "worker": {\n "compatibility_date": "${LATEST_KNOWN_COMPAT_DATE}"\n }\n});\n`, + ); + expect(readConfig(tmpDir)).toEqual({ worker: { compatibility_date: LATEST_KNOWN_COMPAT_DATE }, }); + // The generated Cloudflare config that void.config.ts implies stays out of Git. + expect(readRaw('.gitignore')).toBe('.void-wrangler.jsonc\n'); + // The date is now read from void.config.ts, so nothing is written again. + const written = readRaw('void.config.ts'); + expect(ensureWranglerCompat(tmpDir)).toEqual({ compatibilityDate: LATEST_KNOWN_COMPAT_DATE }); + expect(readRaw('void.config.ts')).toBe(written); }); - it('tells the user how to resolve the pinned void.json when void.config.ts is added later', () => { + it('adds the generated Cloudflare config to an existing .gitignore once', () => { + setup('.gitignore', 'node_modules'); + ensureWranglerCompat(tmpDir); + expect(readRaw('.gitignore')).toBe('node_modules\n.void-wrangler.jsonc\n'); + + rmSync(join(tmpDir, 'void.config.ts')); + writeFileSync(join(tmpDir, '.gitignore'), '/.void-wrangler.jsonc\r\n'); + ensureWranglerCompat(tmpDir); + expect(readRaw('.gitignore')).toBe('/.void-wrangler.jsonc\r\n'); + }); + + it('keeps a wrangler.toml project on void.json so the toml stays in use', () => { + setup('wrangler.toml', 'name = "app"\n'); + expect(ensureWranglerCompat(tmpDir)).toEqual({ compatibilityDate: LATEST_KNOWN_COMPAT_DATE }); + expect(existsSync(join(tmpDir, 'void.config.ts'))).toBe(false); + expect(existsSync(join(tmpDir, 'wrangler.jsonc'))).toBe(false); + expect(existsSync(join(tmpDir, '.void-wrangler.jsonc'))).toBe(false); + expect(JSON.parse(readRaw('void.json'))).toEqual({ + worker: { compatibility_date: LATEST_KNOWN_COMPAT_DATE }, + }); + // The next run reads the date from void.json and still adds no void.config.ts. + expect(ensureWranglerCompat(tmpDir)).toEqual({ compatibilityDate: LATEST_KNOWN_COMPAT_DATE }); + expect(existsSync(join(tmpDir, 'void.config.ts'))).toBe(false); + }); + + it('pins the date in a new root wrangler.jsonc when asked to keep the root Wrangler file', () => { rmSync(tmpDir, { recursive: true, force: true }); mkdirSync(tmpDir, { recursive: true }); - ensureWranglerCompat(tmpDir); - writeFileSync(join(tmpDir, 'void.config.ts'), 'export default {};\n'); - expect(() => readConfig(tmpDir)).toThrow( - 'config: Found both void.config.ts and void.json. Move the settings from void.json into void.config.ts, then delete void.json.', - ); + expect(ensureWranglerCompat(tmpDir, undefined, undefined, { rootWrangler: true })).toEqual({ + compatibilityDate: LATEST_KNOWN_COMPAT_DATE, + }); + expect(existsSync(join(tmpDir, 'void.config.ts'))).toBe(false); + expect(existsSync(join(tmpDir, 'void.json'))).toBe(false); + expect(existsSync(join(tmpDir, '.gitignore'))).toBe(false); + expect(parseRaw('wrangler.jsonc')).toEqual({ compatibility_date: LATEST_KNOWN_COMPAT_DATE }); + }); + + it('writes only the date to void.config.ts, not the passed config', () => { + rmSync(tmpDir, { recursive: true, force: true }); + mkdirSync(tmpDir, { recursive: true }); + ensureWranglerCompat(tmpDir, { database: 'pg', routing: { isr: true } } as VoidConfig); + expect(readConfig(tmpDir)).toEqual({ + worker: { compatibility_date: LATEST_KNOWN_COMPAT_DATE }, + }); + }); + + it('pins the date in an existing root Wrangler config instead of adding void.config.ts', () => { + setup('wrangler.jsonc', '{\n // app name\n "name": "app"\n}\n'); + expect(ensureWranglerCompat(tmpDir)).toEqual({ compatibilityDate: LATEST_KNOWN_COMPAT_DATE }); + expect(existsSync(join(tmpDir, 'void.config.ts'))).toBe(false); + expect(existsSync(join(tmpDir, 'void.json'))).toBe(false); + expect(readRaw('wrangler.jsonc')).toContain('// app name'); + expect(parseRaw('wrangler.jsonc')).toEqual({ + name: 'app', + compatibility_date: LATEST_KNOWN_COMPAT_DATE, + }); + expect(() => readConfig(tmpDir)).not.toThrow(); }); it('preserves existing void.json fields when pinning compatibility_date', () => { @@ -309,6 +371,7 @@ describe('ensureWranglerCompat', () => { compatibility_date: LATEST_KNOWN_COMPAT_DATE, }, }); + expect(existsSync(join(tmpDir, 'void.config.ts'))).toBe(false); }); });