Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions drizzle-kit/src/cli/env-loader.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
4 changes: 4 additions & 0 deletions drizzle-kit/src/cli/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
15 changes: 15 additions & 0 deletions drizzle-kit/src/cli/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -116,6 +123,7 @@ export const migrate = command({
name: 'migrate',
options: {
config: optionConfig,
envFile: optionEnvFile,
},
transform: async (opts) => {
return await prepareMigrateConfig(opts.config);
Expand Down Expand Up @@ -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'),
Expand Down Expand Up @@ -407,6 +416,7 @@ export const check = command({
name: 'check',
options: {
config: optionConfig,
envFile: optionEnvFile,
dialect: optionDialect,
out: optionOut,
},
Expand All @@ -427,6 +437,7 @@ export const up = command({
name: 'up',
options: {
config: optionConfig,
envFile: optionEnvFile,
dialect: optionDialect,
out: optionOut,
},
Expand Down Expand Up @@ -472,6 +483,7 @@ export const pull = command({
aliases: ['pull'],
options: {
config: optionConfig,
envFile: optionEnvFile,
dialect: optionDialect,
out: optionOut,
breakpoints: optionBreakpoints,
Expand Down Expand Up @@ -634,6 +646,7 @@ export const drop = command({
name: 'drop',
options: {
config: optionConfig,
envFile: optionEnvFile,
out: optionOut,
driver: optionDriver,
},
Expand All @@ -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()
Expand Down Expand Up @@ -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'),
},
Expand Down
9 changes: 6 additions & 3 deletions drizzle-kit/src/cli/validations/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,19 @@ export type UniqueArrayOfUnion<TUnion, TArray extends TUnion[]> = Exclude<
export const assertCollisions = <
T extends Record<string, unknown>,
TKeys extends (keyof T)[],
TRemainingKeys extends Exclude<keyof T, TKeys[number] | 'config'>[],
TRemainingKeys extends Exclude<keyof T, TKeys[number] | 'config' | 'envFile'>[],
Exhaustive extends TRemainingKeys,
UNIQ extends UniqueArrayOfUnion<TRemainingKeys[number], Exhaustive>,
>(
command: Commands,
options: T,
whitelist: Exclude<TKeys, 'config'>,
whitelist: Exclude<TKeys, 'config' | 'envFile'>,
remainingKeys: UniqueArrayOfUnion<TRemainingKeys[number], Exhaustive>,
): IsUnion<LastTupleElement<UNIQ>> 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)) {
Expand Down
101 changes: 101 additions & 0 deletions drizzle-kit/tests/cli-env-file.test.ts
Original file line number Diff line number Diff line change
@@ -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=<path>', () => {
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 <path> (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');
});
});