From 978eddf215c8ff36ddc0416a74eb808b82329c78 Mon Sep 17 00:00:00 2001 From: karthic Date: Mon, 9 Nov 2026 14:39:39 +0530 Subject: [PATCH] fix(init): stop un-ignoring .env.example in scaffolded projects Void refuses to start when .env.example exists, so the scaffolded .gitignore should not allow committing it. Mention env.ts in the error for anyone adding the file out of habit. Closes #757 Co-Authored-By: Claude Opus 5.5 --- .github/scripts/packed-consumer.mjs | 4 ++-- packages/void/src/cli/env-validation.ts | 3 ++- packages/void/src/cli/template.ts | 1 - packages/void/src/plugin-env-schema.ts | 3 ++- packages/void/src/shared/env.ts | 10 ++++++++++ packages/void/test/integration/env-cli.test.ts | 10 ++++++++++ .../void/test/integration/plugin-env-schema.test.ts | 10 ++++++++++ packages/void/test/unit/init.test.ts | 2 +- 8 files changed, 37 insertions(+), 6 deletions(-) diff --git a/.github/scripts/packed-consumer.mjs b/.github/scripts/packed-consumer.mjs index 94778ffe0..4c47be4a5 100644 --- a/.github/scripts/packed-consumer.mjs +++ b/.github/scripts/packed-consumer.mjs @@ -239,8 +239,8 @@ try { ) .trim() .split('\n'), - ['node_modules/dependency', '.env', '.env.local', '.env.production'], - 'Init must ignore local environment files while allowing the example', + ['node_modules/dependency', '.env', '.env.local', '.env.production', '.env.example'], + 'Init must ignore local environment files', ); writeFileSync( join(fixture, '.npmrc'), diff --git a/packages/void/src/cli/env-validation.ts b/packages/void/src/cli/env-validation.ts index c926b4d3e..6b060646b 100644 --- a/packages/void/src/cli/env-validation.ts +++ b/packages/void/src/cli/env-validation.ts @@ -16,6 +16,7 @@ import { findLegacyEnvFiles, findReservedEnvKeys, isClientEnvKey, + legacyEnvExampleHint, loadLocalEnv, LOCAL_ENV_FILE, reservedEnvKeysMessage, @@ -110,7 +111,7 @@ export async function validateProdEnv( const legacyFiles = findLegacyEnvFiles(root); if (legacyFiles.length > 0) { throw new Error( - `env: only .env is supported for local development. Remove ${legacyFiles.join(', ')} and follow the env migration guide.`, + `env: only .env is supported for local development. Remove ${legacyFiles.join(', ')} and follow the env migration guide.${legacyEnvExampleHint(legacyFiles)}`, ); } // Same rule as the Vite plugin, which stops dev and build on these names. diff --git a/packages/void/src/cli/template.ts b/packages/void/src/cli/template.ts index bc892cf0e..417cf607f 100644 --- a/packages/void/src/cli/template.ts +++ b/packages/void/src/cli/template.ts @@ -13,7 +13,6 @@ export const SCAFFOLD_GITIGNORE_LINES = [ '.void-wrangler.jsonc', '.env', '.env.*', - '!.env.example', '*.tsbuildinfo', '.DS_Store', ] as const; diff --git a/packages/void/src/plugin-env-schema.ts b/packages/void/src/plugin-env-schema.ts index a4ca70308..946d70906 100644 --- a/packages/void/src/plugin-env-schema.ts +++ b/packages/void/src/plugin-env-schema.ts @@ -13,6 +13,7 @@ import { findLegacyEnvFiles, findReservedEnvKeys, isClientEnvKey, + legacyEnvExampleHint, loadLocalEnv, LOCAL_ENV_FILE, reservedEnvKeysMessage, @@ -328,7 +329,7 @@ export function envSchemaPlugin(root: string, options: EnvSchemaPluginOptions = const legacyFiles = findLegacyEnvFiles(root); if (legacyFiles.length > 0) { throw new Error( - `void/env supports one local file: .env. Remove ${legacyFiles.join(', ')} and follow the env migration guide.`, + `void/env supports one local file: .env. Remove ${legacyFiles.join(', ')} and follow the env migration guide.${legacyEnvExampleHint(legacyFiles)}`, ); } // Every mode (serve, build, preview), with or without env.ts. Only the diff --git a/packages/void/src/shared/env.ts b/packages/void/src/shared/env.ts index 6b3d2c126..fdb3322eb 100644 --- a/packages/void/src/shared/env.ts +++ b/packages/void/src/shared/env.ts @@ -61,6 +61,16 @@ export function findLegacyEnvFiles(root: string): Array { } } +/** + * Sentence to append to a legacy-file error when `.env.example` is among the + * files, since it is usually added out of habit to document expected keys. + */ +export function legacyEnvExampleHint(legacyFiles: ReadonlyArray): string { + return legacyFiles.includes('.env.example') + ? ' env.ts lists the keys this app expects, so .env.example is not needed.' + : ''; +} + /** * Resolve whether the plugin should run in "remote" mode (proxy real * D1/KV/R2 via the platform) based on the project config plus an optional diff --git a/packages/void/test/integration/env-cli.test.ts b/packages/void/test/integration/env-cli.test.ts index a69aef40c..2fddff1e7 100644 --- a/packages/void/test/integration/env-cli.test.ts +++ b/packages/void/test/integration/env-cli.test.ts @@ -98,6 +98,16 @@ describe('void env check (subprocess)', () => { expect(status).toBe(1); expect(stdout).toContain('only .env is supported'); expect(stdout).toContain('.env.local'); + expect(stdout).not.toContain('env.ts lists the keys'); + }); + + it('points .env.example users at env.ts', () => { + const root = createProject('{ STRIPE_KEY: string() }'); + writeFileSync(join(root, '.env.example'), 'STRIPE_KEY=\n'); + const { stdout, status } = runVoid(root, ['env', 'check']); + expect(status).toBe(1); + expect(stdout).toContain('Remove .env.example'); + expect(stdout).toContain('env.ts lists the keys'); }); }); diff --git a/packages/void/test/integration/plugin-env-schema.test.ts b/packages/void/test/integration/plugin-env-schema.test.ts index b2b448a11..79fa37027 100644 --- a/packages/void/test/integration/plugin-env-schema.test.ts +++ b/packages/void/test/integration/plugin-env-schema.test.ts @@ -180,6 +180,16 @@ describe('envSchemaPlugin auto-discovery', () => { writeFileSync(join(root, '.env.production'), 'API_URL=https://example.com\n'); await expect(runConfig(envSchemaPlugin(root))).rejects.toThrow('supports one local file: .env'); + await expect(runConfig(envSchemaPlugin(root))).rejects.not.toThrow('env.ts lists the keys'); + }); + + it('points .env.example users at env.ts', async () => { + const root = createTempRoot(); + writeFileSync(join(root, '.env.example'), 'API_URL=\n'); + + await expect(runConfig(envSchemaPlugin(root))).rejects.toThrow( + 'Remove .env.example and follow the env migration guide. env.ts lists the keys', + ); }); it('removes local .env client values from production builds', async () => { diff --git a/packages/void/test/unit/init.test.ts b/packages/void/test/unit/init.test.ts index b51ff91cd..329912075 100644 --- a/packages/void/test/unit/init.test.ts +++ b/packages/void/test/unit/init.test.ts @@ -2620,7 +2620,7 @@ export default config; expect(gitignore).toContain('.wrangler'); expect(gitignore).toContain('.env'); expect(gitignore.split('\n')).toContain('.env.*'); - expect(gitignore.split('\n')).toContain('!.env.example'); + expect(gitignore.split('\n')).not.toContain('!.env.example'); } finally { rmSync(root, { recursive: true, force: true }); }