From 028b3556a68d79d17c4a85ff2b1b33a00762bb51 Mon Sep 17 00:00:00 2001 From: Dan Kerimdzhanov Date: Tue, 5 Dec 2023 03:46:52 +0000 Subject: [PATCH] feat(dotenv-flow): implement `options.files`, closes #83 Allow explicitly specify a list (and the order) of `.env*` files to load using `options.files`. --- README.md | 18 +++ lib/dotenv-flow.d.ts | 1 + lib/dotenv-flow.js | 48 ++++-- test/types/dotenv-flow.ts | 2 + test/unit/dotenv-flow-api.spec.js | 234 +++++++++++++++++++++++++++++- 5 files changed, 289 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 984465a..18dd92e 100644 --- a/README.md +++ b/README.md @@ -460,6 +460,24 @@ For example, if you set the pattern to `".env/[local/]env[.node_env]"`, › Please refer to [`.listFiles([options])`](#listfiles-options--string) to dive deeper. +##### `options.files` +###### Type: `string[]` + +Allows explicitly specifying a list (and the order) of `.env*` files to load. + +Note that options like `node_env`, `default_node_env`, and `pattern` are ignored in this case. + +```js +require('dotenv-flow').config({ + files: [ + '.env', + '.env.local', + `.env.${process.env.NODE_ENV}`, // '.env.development' + `.env.${process.env.NODE_ENV}.local` // '.env.development.local' + ] +}); +``` + ##### `options.encoding` ###### Type: `string` ###### Default: `"utf8"` diff --git a/lib/dotenv-flow.d.ts b/lib/dotenv-flow.d.ts index 33ac826..4b99ea6 100644 --- a/lib/dotenv-flow.d.ts +++ b/lib/dotenv-flow.d.ts @@ -113,6 +113,7 @@ export function unload(filenames: string | string[], options?: DotenvFlowParseOp export type DotenvFlowConfigOptions = DotenvFlowListFilesOptions & DotenvFlowLoadOptions & { default_node_env?: string; purge_dotenv?: boolean; + files?: string[]; } export type DotenvFlowConfigResult = DotenvFlowLoadResult; diff --git a/lib/dotenv-flow.js b/lib/dotenv-flow.js index 1d4528b..9b9da30 100644 --- a/lib/dotenv-flow.js +++ b/lib/dotenv-flow.js @@ -272,6 +272,7 @@ const CONFIG_OPTION_KEYS = [ 'default_node_env', 'path', 'pattern', + 'files', 'encoding', 'purge_dotenv', 'silent' @@ -287,6 +288,7 @@ const CONFIG_OPTION_KEYS = [ * @param {string} [options.default_node_env] - the default node environment * @param {string} [options.path=process.cwd()] - path to `.env*` files directory * @param {string} [options.pattern=".env[.node_env][.local]"] - `.env*` files' naming convention pattern + * @param {string[]} [options.files] - an explicit list of `.env*` files to load (note that `options.[default_]node_env` and `options.pattern` are ignored in this case) * @param {string} [options.encoding="utf8"] - encoding of `.env*` files * @param {boolean} [options.purge_dotenv=false] - perform the `.env` file {@link unload} * @param {boolean} [options.debug=false] - turn on detailed logging to help debug why certain variables are not being set as you expect @@ -302,8 +304,6 @@ function config(options = {}) { .forEach(key => debug(`| options.${key} =`, options[key])); } - const node_env = getEffectiveNodeEnv(options); - const { path = process.cwd(), pattern = DEFAULT_PATTERN @@ -324,17 +324,43 @@ function config(options = {}) { } try { - const filenames = listFiles({ node_env, path, pattern, debug: options.debug }); - - if (filenames.length === 0) { - const _pattern = node_env - ? pattern.replace(NODE_ENV_PLACEHOLDER_REGEX, `[$1${node_env}$2]`) - : pattern; + let filenames; - return failure( - new Error(`no ".env*" files matching pattern "${_pattern}" in "${path}" dir`), - options + if (options.files) { + options.debug && debug( + 'using explicit list of `.env*` files: %s…', + options.files.join(', ') ); + + filenames = options.files + .reduce((list, basename) => { + const filename = p.resolve(path, basename); + + if (fs.existsSync(filename)) { + list.push(filename); + } + else if (options.debug) { + debug('>> %s does not exist, skipping…', filename); + } + + return list; + }, []); + } + else { + const node_env = getEffectiveNodeEnv(options); + + filenames = listFiles({ node_env, path, pattern, debug: options.debug }); + + if (filenames.length === 0) { + const _pattern = node_env + ? pattern.replace(NODE_ENV_PLACEHOLDER_REGEX, `[$1${node_env}$2]`) + : pattern; + + return failure( + new Error(`no ".env*" files matching pattern "${_pattern}" in "${path}" dir`), + options + ); + } } const result = load(filenames, { diff --git a/test/types/dotenv-flow.ts b/test/types/dotenv-flow.ts index 28d5451..4301ce3 100644 --- a/test/types/dotenv-flow.ts +++ b/test/types/dotenv-flow.ts @@ -84,6 +84,7 @@ dotenvFlow.config({ node_env: 'production' }); dotenvFlow.config({ default_node_env: 'development' }); dotenvFlow.config({ path: '/path/to/project' }); dotenvFlow.config({ pattern: '.env[.node_env][.local]' }); +dotenvFlow.config({ files: ['.env', '.env.local'] }); dotenvFlow.config({ encoding: 'utf8' }); dotenvFlow.config({ purge_dotenv: true }); dotenvFlow.config({ debug: true }); @@ -93,6 +94,7 @@ dotenvFlow.config({ default_node_env: 'development', path: '/path/to/project', pattern: '.env[.node_env][.local]', + files: ['.env', '.env.local'], encoding: 'utf8', purge_dotenv: true, debug: true, diff --git a/test/unit/dotenv-flow-api.spec.js b/test/unit/dotenv-flow-api.spec.js index 522e91a..15e2164 100644 --- a/test/unit/dotenv-flow-api.spec.js +++ b/test/unit/dotenv-flow-api.spec.js @@ -1465,6 +1465,211 @@ describe('dotenv-flow (API)', () => { }); }); + describe('when `options.files` is given', () => { + let options; + + beforeEach('setup `options.files`', () => { + options = { + files: [ + '.env', + '.env.production', + '.env.local' + ] + }; + }); + + it('loads the given list of files', () => { + mockFS({ + '/path/to/project/.env': 'DEFAULT_ENV_VAR=ok', + '/path/to/project/.env.local': 'LOCAL_ENV_VAR=ok', + '/path/to/project/.env.production': 'PRODUCTION_ENV_VAR=ok', + '/path/to/project/.env.production.local': 'LOCAL_PRODUCTION_ENV_VAR=ok' + }); + + expect(process.env) + .to.not.have.keys([ + 'DEFAULT_ENV_VAR', + 'PRODUCTION_ENV_VAR', + 'LOCAL_ENV_VAR', + 'LOCAL_PRODUCTION_ENV_VAR' + ]); + + const result = dotenvFlow.config(options); + + expect(result) + .to.be.an('object') + .with.property('parsed') + .that.deep.equals({ + DEFAULT_ENV_VAR: 'ok', + PRODUCTION_ENV_VAR: 'ok', + LOCAL_ENV_VAR: 'ok' + }); + + expect(process.env) + .to.include({ + DEFAULT_ENV_VAR: 'ok', + PRODUCTION_ENV_VAR: 'ok', + LOCAL_ENV_VAR: 'ok' + }); + + expect(process.env) + .to.not.have.key('LOCAL_PRODUCTION_ENV_VAR'); + }); + + it('ignores `options.node_env`', () => { + options.node_env = 'development'; + + mockFS({ + '/path/to/project/.env': 'DEFAULT_ENV_VAR=ok', + '/path/to/project/.env.local': 'LOCAL_ENV_VAR=ok', + '/path/to/project/.env.development': 'DEVELOPMENT_ENV_VAR=ok', + '/path/to/project/.env.production': 'PRODUCTION_ENV_VAR=ok' + }); + + expect(process.env) + .to.not.have.keys([ + 'DEFAULT_ENV_VAR', + 'DEVELOPMENT_ENV_VAR', + 'PRODUCTION_ENV_VAR', + 'LOCAL_ENV_VAR' + ]); + + const result = dotenvFlow.config(options); + + expect(result) + .to.be.an('object') + .with.property('parsed') + .that.deep.equals({ + DEFAULT_ENV_VAR: 'ok', + PRODUCTION_ENV_VAR: 'ok', + LOCAL_ENV_VAR: 'ok' + }); + + expect(process.env) + .to.include({ + DEFAULT_ENV_VAR: 'ok', + PRODUCTION_ENV_VAR: 'ok', + LOCAL_ENV_VAR: 'ok' + }); + + expect(process.env) + .to.not.have.key('DEVELOPMENT_ENV_VAR'); + }); + + it('loads the list of files in the given order', () => { + mockFS({ + '/path/to/project/.env': ( + 'DEFAULT_ENV_VAR=ok\n' + + 'PRODUCTION_ENV_VAR="should be overwritten by `.env.production"`' + ), + '/path/to/project/.env.local': ( + 'LOCAL_ENV_VAR=ok' + ), + '/path/to/project/.env.production': ( + 'LOCAL_ENV_VAR="should be overwritten by `.env.local"`\n' + + 'PRODUCTION_ENV_VAR=ok' + ) + }); + + expect(process.env) + .to.not.have.keys([ + 'DEFAULT_ENV_VAR', + 'PRODUCTION_ENV_VAR', + 'LOCAL_ENV_VAR' + ]); + + const result = dotenvFlow.config(options); + + expect(result) + .to.be.an('object') + .with.property('parsed') + .that.deep.equals({ + DEFAULT_ENV_VAR: 'ok', + PRODUCTION_ENV_VAR: 'ok', + LOCAL_ENV_VAR: 'ok' + }); + + expect(process.env) + .to.include({ + DEFAULT_ENV_VAR: 'ok', + PRODUCTION_ENV_VAR: 'ok', + LOCAL_ENV_VAR: 'ok' + }); + }); + + it('ignores missing files', () => { + mockFS({ + '/path/to/project/.env': 'DEFAULT_ENV_VAR=ok' + }); + + expect(process.env) + .to.not.have.keys([ + 'DEFAULT_ENV_VAR', + 'PRODUCTION_ENV_VAR', + 'LOCAL_ENV_VAR' + ]); + + const result = dotenvFlow.config(options); + + expect(result) + .to.be.an('object') + .with.property('parsed') + .that.deep.equals({ + DEFAULT_ENV_VAR: 'ok' + }); + + expect(process.env) + .to.include({ + DEFAULT_ENV_VAR: 'ok' + }); + + expect(process.env) + .to.not.have.keys([ + 'PRODUCTION_ENV_VAR', + 'LOCAL_ENV_VAR' + ]); + }); + + describe('… and `options.path` is given', () => { + beforeEach('setup `options.path`', () => { + options.path = '/path/to/another/project'; + }); + + it('uses the given `options.path` as a working directory', () => { + mockFS({ + '/path/to/another/project/.env': 'DEFAULT_ENV_VAR=ok', + '/path/to/another/project/.env.production': 'PRODUCTION_ENV_VAR=ok', + '/path/to/another/project/.env.local': 'LOCAL_ENV_VAR=ok' + }); + + expect(process.env) + .to.not.have.keys([ + 'DEFAULT_ENV_VAR', + 'PRODUCTION_ENV_VAR', + 'LOCAL_ENV_VAR' + ]); + + const result = dotenvFlow.config(options); + + expect(result) + .to.be.an('object') + .with.property('parsed') + .that.deep.equals({ + DEFAULT_ENV_VAR: 'ok', + PRODUCTION_ENV_VAR: 'ok', + LOCAL_ENV_VAR: 'ok' + }); + + expect(process.env) + .to.include({ + DEFAULT_ENV_VAR: 'ok', + PRODUCTION_ENV_VAR: 'ok', + LOCAL_ENV_VAR: 'ok' + }); + }); + }); + }); + describe('when `options.encoding` is given', () => { let options; @@ -1560,9 +1765,7 @@ describe('dotenv-flow (API)', () => { let options; beforeEach('setup `options.debug`', () => { - options = { - debug: true - }; + options = { debug: true }; }); beforeEach('stub `console.debug`', () => { @@ -1658,6 +1861,31 @@ describe('dotenv-flow (API)', () => { .to.have.been.calledWithMatch('options.silent', false); }); + it('prints out initialization options [4]', () => { + dotenvFlow.config({ + ...options, + path: '/path/to/another/project', + files: [ + '.env', + '.env.production', + '.env.local' + ], + }); + + expect(console.debug) + .to.have.been.calledWithMatch(/dotenv-flow\b.*init/); + + expect(console.debug) + .to.have.been.calledWithMatch('options.path', '/path/to/another/project'); + + expect(console.debug) + .to.have.been.calledWithMatch('options.files', [ + '.env', + '.env.production', + '.env.local' + ]); + }); + it('prints out effective node_env set by `options.node_env`', () => { dotenvFlow.config({ ...options,