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 }); }