From 13c804fc6ca00c6b1425cb3f4151fe2a091114be Mon Sep 17 00:00:00 2001 From: Devayan Dewri <287486912+h2m6jcm94s-eng@users.noreply.github.com> Date: Fri, 12 Jun 2026 18:11:01 +0530 Subject: [PATCH] [general-kit]: add --env-file CLI flag for loading dotenv files Adds support for loading environment variables from one or more files before the drizzle config is evaluated, matching the semantics of Node 22's --env-file flag: drizzle-kit migrate --env-file=.env.local drizzle-kit push --env-file=.env --env-file=.env.local The flag is parsed in a new ./env-loader module imported as the first side-effect in src/cli/index.ts. It runs before src/cli/schema.ts (whose top-level `import 'dotenv/config'` loads the default .env), so values from --env-file are visible to the config file alongside any default .env values. Precedence (highest to lowest): 1. Existing process.env (shell-exported vars), never overwritten 2. The last --env-file on the command line 3. Earlier --env-file occurrences 4. The auto-loaded .env (existing behavior) The flag is stripped from process.argv after parsing so brocli, which doesn't share an option across every command, never sees it. It is still registered as an option on every subcommand so it appears in --help output and assertCollisions ignores it the same way it ignores --config. Tests cover the argv extractor (multiple forms, ordering) and the loader semantics (single file, multi-file precedence, shell-var protection) in drizzle-kit/tests/cli-env-file.test.ts. Closes #4588 --- drizzle-kit/src/cli/env-loader.ts | 64 ++++++++++++++ drizzle-kit/src/cli/index.ts | 4 + drizzle-kit/src/cli/schema.ts | 15 ++++ drizzle-kit/src/cli/validations/common.ts | 9 +- drizzle-kit/tests/cli-env-file.test.ts | 101 ++++++++++++++++++++++ 5 files changed, 190 insertions(+), 3 deletions(-) create mode 100644 drizzle-kit/src/cli/env-loader.ts create mode 100644 drizzle-kit/tests/cli-env-file.test.ts diff --git a/drizzle-kit/src/cli/env-loader.ts b/drizzle-kit/src/cli/env-loader.ts new file mode 100644 index 0000000000..14f72055f0 --- /dev/null +++ b/drizzle-kit/src/cli/env-loader.ts @@ -0,0 +1,64 @@ +import { parse as parseDotenv } from 'dotenv'; +import { readFileSync } from 'fs'; + +// Parses --env-file occurrences out of an argv array, returning the +// requested paths in declaration order and the remaining argv. Supports +// `--env-file path`, `--env-file=path`, `-e path` and `-e=path`. +export const extractEnvFiles = ( + argv: readonly string[], +): { paths: string[]; remaining: string[] } => { + const paths: string[] = []; + const remaining: string[] = []; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]!; + if (a === '--env-file' || a === '-e') { + const next = argv[i + 1]; + if (next !== undefined) { + paths.push(next); + i++; + } + } else if (a.startsWith('--env-file=')) { + paths.push(a.slice('--env-file='.length)); + } else if (a.startsWith('-e=')) { + paths.push(a.slice('-e='.length)); + } else { + remaining.push(a); + } + } + return { paths, remaining }; +}; + +// Loads each path into `env` in declaration order. Values that already +// exist in `env` when this function starts (i.e. shell-exported vars) +// are never overwritten, matching Node 22+'s --env-file precedence. +// Later --env-file values override earlier --env-file values for the +// same key (also matching Node's behaviour). +export const applyEnvFiles = ( + paths: readonly string[], + env: NodeJS.ProcessEnv, +): void => { + const shellKeys = new Set(Object.keys(env)); + for (const path of paths) { + const parsed = parseDotenv(readFileSync(path, 'utf8')); + for (const [key, value] of Object.entries(parsed)) { + if (!shellKeys.has(key)) { + env[key] = value; + } + } + } +}; + +const { paths, remaining } = extractEnvFiles(process.argv.slice(2)); +if (paths.length > 0) { + // Strip the flags so brocli (which doesn't know about --env-file as a + // shared option) doesn't error on them. + process.argv = [process.argv[0]!, process.argv[1]!, ...remaining]; + try { + applyEnvFiles(paths, process.env); + } catch (err) { + console.error( + `drizzle-kit: failed to load --env-file: ${(err as Error).message}`, + ); + process.exit(1); + } +} diff --git a/drizzle-kit/src/cli/index.ts b/drizzle-kit/src/cli/index.ts index 42730be1d5..bd6c81937b 100644 --- a/drizzle-kit/src/cli/index.ts +++ b/drizzle-kit/src/cli/index.ts @@ -1,3 +1,7 @@ +// Side-effect import: parses and applies --env-file flags from argv +// before any module that depends on env vars (notably ./schema, which +// triggers `import 'dotenv/config'`) is evaluated. +import './env-loader'; import { command, run } from '@drizzle-team/brocli'; import chalk from 'chalk'; import { check, drop, exportRaw, generate, migrate, pull, push, studio, up } from './schema'; diff --git a/drizzle-kit/src/cli/schema.ts b/drizzle-kit/src/cli/schema.ts index 3ea88dc5dd..f7b603683d 100644 --- a/drizzle-kit/src/cli/schema.ts +++ b/drizzle-kit/src/cli/schema.ts @@ -46,11 +46,18 @@ const optionDriver = string() .desc('Database driver'); const optionCasing = string().enum('camelCase', 'snake_case').desc('Casing for serialization'); +// Documented here for help output. The flag is intercepted and removed +// from argv by ./env-loader before brocli parses the command, so the +// option value will always be undefined when handlers run. +const optionEnvFile = string('env-file').desc( + 'Load environment variables from a file before evaluating the drizzle config (can be passed multiple times; later files override earlier ones; existing env vars are never overwritten)', +); export const generate = command({ name: 'generate', options: { config: optionConfig, + envFile: optionEnvFile, dialect: optionDialect, driver: optionDriver, casing: optionCasing, @@ -116,6 +123,7 @@ export const migrate = command({ name: 'migrate', options: { config: optionConfig, + envFile: optionEnvFile, }, transform: async (opts) => { return await prepareMigrateConfig(opts.config); @@ -248,6 +256,7 @@ export const push = command({ name: 'push', options: { config: optionConfig, + envFile: optionEnvFile, dialect: optionDialect, casing: optionCasing, schema: string().desc('Path to a schema file or folder'), @@ -407,6 +416,7 @@ export const check = command({ name: 'check', options: { config: optionConfig, + envFile: optionEnvFile, dialect: optionDialect, out: optionOut, }, @@ -427,6 +437,7 @@ export const up = command({ name: 'up', options: { config: optionConfig, + envFile: optionEnvFile, dialect: optionDialect, out: optionOut, }, @@ -472,6 +483,7 @@ export const pull = command({ aliases: ['pull'], options: { config: optionConfig, + envFile: optionEnvFile, dialect: optionDialect, out: optionOut, breakpoints: optionBreakpoints, @@ -634,6 +646,7 @@ export const drop = command({ name: 'drop', options: { config: optionConfig, + envFile: optionEnvFile, out: optionOut, driver: optionDriver, }, @@ -653,6 +666,7 @@ export const studio = command({ name: 'studio', options: { config: optionConfig, + envFile: optionEnvFile, port: number().desc('Custom port for drizzle studio [default=4983]'), host: string().desc('Custom host for drizzle studio [default=0.0.0.0]'), verbose: boolean() @@ -810,6 +824,7 @@ export const exportRaw = command({ options: { sql: boolean('sql').default(true).desc('Generate as sql'), config: optionConfig, + envFile: optionEnvFile, dialect: optionDialect, schema: string().desc('Path to a schema file or folder'), }, diff --git a/drizzle-kit/src/cli/validations/common.ts b/drizzle-kit/src/cli/validations/common.ts index 721f6effae..9abc55d93c 100644 --- a/drizzle-kit/src/cli/validations/common.ts +++ b/drizzle-kit/src/cli/validations/common.ts @@ -30,16 +30,19 @@ export type UniqueArrayOfUnion = Exclude< export const assertCollisions = < T extends Record, TKeys extends (keyof T)[], - TRemainingKeys extends Exclude[], + TRemainingKeys extends Exclude[], Exhaustive extends TRemainingKeys, UNIQ extends UniqueArrayOfUnion, >( command: Commands, options: T, - whitelist: Exclude, + whitelist: Exclude, remainingKeys: UniqueArrayOfUnion, ): IsUnion> extends false ? 'cli' | 'config' : TKeys => { - const { config, ...rest } = options; + // envFile is intercepted by ./env-loader before brocli parses, so it is + // always undefined here and is excluded from the collision check the same + // way config is. + const { config, envFile, ...rest } = options; let atLeastOneParam = false; for (const key of Object.keys(rest)) { diff --git a/drizzle-kit/tests/cli-env-file.test.ts b/drizzle-kit/tests/cli-env-file.test.ts new file mode 100644 index 0000000000..22fec1661c --- /dev/null +++ b/drizzle-kit/tests/cli-env-file.test.ts @@ -0,0 +1,101 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { applyEnvFiles, extractEnvFiles } from '../src/cli/env-loader'; + +describe('extractEnvFiles', () => { + test('parses --env-file=', () => { + const { paths, remaining } = extractEnvFiles([ + 'migrate', + '--env-file=.env.local', + '--config=drizzle.config.ts', + ]); + expect(paths).toEqual(['.env.local']); + expect(remaining).toEqual(['migrate', '--config=drizzle.config.ts']); + }); + + test('parses --env-file (space-separated)', () => { + const { paths, remaining } = extractEnvFiles([ + 'migrate', + '--env-file', + '.env.local', + ]); + expect(paths).toEqual(['.env.local']); + expect(remaining).toEqual(['migrate']); + }); + + test('parses short -e alias both forms', () => { + const { paths, remaining } = extractEnvFiles([ + 'migrate', + '-e=.env.a', + '-e', + '.env.b', + ]); + expect(paths).toEqual(['.env.a', '.env.b']); + expect(remaining).toEqual(['migrate']); + }); + + test('preserves order of multiple --env-file flags', () => { + const { paths } = extractEnvFiles([ + '--env-file=.env.shared', + 'migrate', + '--env-file=.env.local', + ]); + expect(paths).toEqual(['.env.shared', '.env.local']); + }); + + test('returns empty paths when no --env-file present', () => { + const { paths, remaining } = extractEnvFiles(['migrate', '--config=x.ts']); + expect(paths).toEqual([]); + expect(remaining).toEqual(['migrate', '--config=x.ts']); + }); +}); + +describe('applyEnvFiles', () => { + let tmp: string; + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'drizzle-kit-env-')); + }); + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); + }); + + const write = (name: string, body: string): string => { + const p = join(tmp, name); + writeFileSync(p, body); + return p; + }; + + test('loads variables from a single .env file', () => { + const path = write('.env', 'DATABASE_URL=postgres://from-env-file/db\n'); + const env: NodeJS.ProcessEnv = {}; + applyEnvFiles([path], env); + expect(env.DATABASE_URL).toBe('postgres://from-env-file/db'); + }); + + test('does not overwrite pre-existing (shell) env vars', () => { + const path = write('.env', 'DATABASE_URL=postgres://from-env-file/db\n'); + const env: NodeJS.ProcessEnv = { DATABASE_URL: 'postgres://from-shell/db' }; + applyEnvFiles([path], env); + expect(env.DATABASE_URL).toBe('postgres://from-shell/db'); + }); + + test('later --env-file overrides earlier --env-file for the same key', () => { + const a = write('.env.a', 'DATABASE_URL=postgres://a/db\nA_ONLY=1\n'); + const b = write('.env.b', 'DATABASE_URL=postgres://b/db\nB_ONLY=2\n'); + const env: NodeJS.ProcessEnv = {}; + applyEnvFiles([a, b], env); + expect(env.DATABASE_URL).toBe('postgres://b/db'); + expect(env.A_ONLY).toBe('1'); + expect(env.B_ONLY).toBe('2'); + }); + + test('shell var still wins over multiple --env-file overrides', () => { + const a = write('.env.a', 'DATABASE_URL=postgres://a/db\n'); + const b = write('.env.b', 'DATABASE_URL=postgres://b/db\n'); + const env: NodeJS.ProcessEnv = { DATABASE_URL: 'postgres://shell/db' }; + applyEnvFiles([a, b], env); + expect(env.DATABASE_URL).toBe('postgres://shell/db'); + }); +});