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