From 170d14f7cea3532da27ae01cc5337f075754e148 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 28 Oct 2026 10:45:23 +0800 Subject: [PATCH 1/3] chore(cli): stop naming wrangler where Void now runs the cf CLI The provisioner and deploy steps run the `cf` CLI, but three provision errors still said "wrangler" and several comments still described wrangler 4.131. The CLI output rewrote the word before printing, so users saw the right text, but the source no longer matched what runs. The errors now name the Cloudflare config and Cloudflare tooling, and the comments describe current behaviour without the stale version. Co-Authored-By: Claude Opus 5.5 --- packages/deploy-cloudflare/src/version.ts | 2 +- .../test/unit/version.test.ts | 2 +- packages/void/src/cli/deploy-transport.ts | 25 ++++++++++--------- packages/void/src/cli/provision.ts | 19 +++++++------- .../void/test/unit/cloudflare-deploy.test.ts | 2 +- .../test/unit/provision-resources.test.ts | 2 +- 6 files changed, 27 insertions(+), 25 deletions(-) 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/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/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/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"', ); }); From 813a7140aee98ca466f1dd2f729c729d22608f0b Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 28 Oct 2026 10:45:26 +0800 Subject: [PATCH 2/3] fix(config): pin the default compatibility date without creating void.json When a project had no config file and no date, the first dev server, build or deploy created a new void.json, a format the docs call legacy. Adding void.config.ts later then failed with "Found both". Now the date goes where the project already keeps its config: - a new project gets a generated void.config.ts with only the date, as `void init` writes it, and `.void-wrangler.jsonc` is added to .gitignore; - framework apps (Astro, SvelteKit, Nuxt, Analog) and projects with a root wrangler.json(c) get it in the root Wrangler file their adapter reads; - a project with wrangler.toml keeps getting it in void.json, because void.config.ts would hide the toml's bindings. A date already in void.json is still read, so existing projects keep their date. The .gitignore helper moves from `void migrate` to cloudflare-config.ts so both paths share it. Co-Authored-By: Claude Opus 5.5 --- docs/reference/config.md | 2 +- packages/void/src/cli/migrate.ts | 15 +--- packages/void/src/cli/wrangler.ts | 58 ++++++++----- packages/void/src/cloudflare-config.ts | 17 +++- packages/void/src/plugin-framework.ts | 11 ++- .../void/test/unit/plugin-inference.test.ts | 23 ++++-- packages/void/test/unit/wrangler.test.ts | 81 ++++++++++++++++--- 7 files changed, 154 insertions(+), 53 deletions(-) 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/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/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/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/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); }); }); From dd0cd5f8cfb1eba72e01c294d818d7da4db04b11 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 28 Oct 2026 10:45:28 +0800 Subject: [PATCH 3/3] docs(platform): add upgrade order and sandbox token steps Operators were not told to upgrade the platform before the team updates the CLI, although a newer CLI refuses an older platform. They were also not told that a runtime token from an older setup link lacks the Containers and Cloudchamber permissions that sandbox apps need, which an upgrade does not add. Co-Authored-By: Claude Opus 5.5 --- docs/guide/platform/installation/maintenance.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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: