diff --git a/.travis.yml b/.travis.yml index 4b85114..d4a152c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,9 +1,11 @@ language: node_js node_js: + - 14 + - 12 - 10 - - 8 os: - linux - osx + - windows diff --git a/LICENSE b/LICENSE index eb12304..267f4f0 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,5 @@ -Copyright © 2017 Seth Holladay (https://seth-holladay.com) +Copyright © 2020 Project contributors +Copyright © 2017-2020 Seth Holladay (https://seth-holladay.com) Mozilla Public License, version 2.0 @@ -362,4 +363,3 @@ Exhibit B - "Incompatible With Secondary Licenses" Notice This Source Code Form is "Incompatible With Secondary Licenses", as defined by the Mozilla Public License, v. 2.0. - diff --git a/index.js b/index.js index 073ed0f..ba7df0c 100644 --- a/index.js +++ b/index.js @@ -30,7 +30,8 @@ const assertHidden = (filepath) => { }; const assertIgnored = (filepath) => { - const failMessage = `File must be ignored by git. Fix: echo '${path.basename(filepath)}' >> .gitignore`; + const basename = path.basename(filepath); + const failMessage = `File must be ignored by git. Fix: echo '${basename}' >> .gitignore`; let ignores; try { ignores = fs.readFileSync(path.join(filepath, '..', '.gitignore'), 'utf8'); @@ -54,10 +55,44 @@ const isWindows = () => { return isWsl || process.platform === 'win32'; }; -// eslint-disable-next-line max-statements -const envy = (input) => { - const envPath = input || '.env'; +const assertSafePermissions = (filepath) => { + if (isWindows() && checkMode(filepath, permissionMask) !== windowsPermission) { + throw new Error(`File permissions are unsafe. Make them 555 '${filepath}'`); + } + else if (!isWindows() && checkMode(filepath, permissionMask) !== ownerReadWrite) { + throw new Error(`File permissions are unsafe. Fix: chmod 600 '${filepath}'`); + } +}; + +const applyAllowedKeys = (obj, allowUnknownKeys, keepKeys) => { + if (allowUnknownKeys === true) { + return obj; + } + return filterObj(obj, [...allowUnknownKeys, ...keepKeys]); +}; + +const hasStringValues = (obj) => { + return Object.values(obj).some((val) => { + return val !== ''; + }); +}; + +const normalizeOptions = (opts) => { + const options = opts && typeof opts === 'object' ? opts : { filepath : opts }; + return { + env : options.env || process.env, // eslint-disable-line no-process-env + filepath : options.filepath || '.env', + allowUnknown : options.allowUnknown || [] + }; +}; + +const envy = (opts) => { + const options = normalizeOptions(opts); + const envPath = options.filepath; const examplePath = envPath + '.example'; + const camelizedAllowUnknownEnvKeys = Array.isArray(options.allowUnknown) ? + options.allowUnknown.map(camelcase) : + options.allowUnknown; assertHidden(envPath); @@ -72,14 +107,11 @@ const envy = (input) => { if (exampleEnvKeys.length === 0) { throw new Error(`At least one entry is required in ${examplePath}`); } - const exampleHasValues = Object.values(exampleEnv).some((val) => { - return val !== ''; - }); - if (exampleHasValues) { + if (hasStringValues(exampleEnv)) { throw new Error(`No values are allowed in ${examplePath}, put them in ${envPath} instead`); } - const camelizedGlobalEnv = camelcaseKeys(process.env); + const camelizedGlobalEnv = camelcaseKeys(options.env); const camelizedGlobalEnvKeys = Object.keys(camelizedGlobalEnv); // We treat env vars as case insensitive, like Windows does. @@ -88,16 +120,14 @@ const envy = (input) => { }); if (!needsEnvFile) { - return filterObj(camelizedGlobalEnv, camelizedExampleEnvKeys); - } - - if (isWindows() && checkMode(envPath, permissionMask) !== windowsPermission) { - throw new Error(`File permissions are unsafe. Make them 555 '${envPath}'`); - } - else if (!isWindows() && checkMode(envPath, permissionMask) !== ownerReadWrite) { - throw new Error(`File permissions are unsafe. Fix: chmod 600 '${envPath}'`); + return applyAllowedKeys( + camelizedGlobalEnv, + camelizedAllowUnknownEnvKeys, + camelizedExampleEnvKeys + ); } + assertSafePermissions(envPath); assertIgnored(envPath); const camelizedLocalEnv = camelcaseKeys(loadEnvFile(envPath)); @@ -112,16 +142,20 @@ const envy = (input) => { return !camelizedMergedEnv[key] || !camelizedMergedEnvKeys.includes(key); }); if (camelizedMissingKeys.length > 0) { - const missingKeys = camelizedMissingKeys.map((camelizedMissingKey) => { - return exampleEnvKeys.find((exampleKey) => { - return camelcase(exampleKey) === camelizedMissingKey; - }); + const missingKeys = exampleEnvKeys.filter((exampleKey) => { + return camelizedMissingKeys.includes(camelcase(exampleKey)); }); throw new Error(`Environment variables are missing: ${missingKeys.join(', ')}`); } - const keepKeys = [...new Set([...Object.keys(camelizedLocalEnv), ...camelizedExampleEnvKeys])]; - return filterObj(camelizedMergedEnv, keepKeys); + return applyAllowedKeys( + camelizedMergedEnv, + camelizedAllowUnknownEnvKeys, + [ + ...Object.keys(camelizedLocalEnv), + ...camelizedExampleEnvKeys + ] + ); }; module.exports = envy; diff --git a/test.js b/test.js index c4f50d3..8b01726 100644 --- a/test.js +++ b/test.js @@ -3,8 +3,12 @@ import test from 'ava'; import camelcase from 'camelcase'; import envy from '.'; -const fixture = (dir, file = '.env') => { - return envy(path.join('fixture', dir, file)); +const fixturePath = (dir, file = '.env') => { + return path.join('fixture', dir, file); +}; + +const fixture = (dir, file) => { + return envy(fixturePath(dir, file)); }; const monkey = (obj, prop) => { @@ -108,7 +112,7 @@ test('requires at least one entry in .env.example', (t) => { test('does not modify process.env', (t) => { const oldEnvDescriptor = Object.getOwnPropertyDescriptor(process, 'env'); - const oldEnv = process.env; + const oldEnv = process.env; // eslint-disable-line no-process-env const envCopy = Object.create( Object.getPrototypeOf(oldEnv), Object.getOwnPropertyDescriptors(oldEnv) @@ -117,8 +121,8 @@ test('does not modify process.env', (t) => { myKey : 'my val', dog : 'woof' }); - t.is(process.env, oldEnv); - t.deepEqual(process.env, envCopy); + t.is(process.env, oldEnv); // eslint-disable-line no-process-env + t.deepEqual(process.env, envCopy); // eslint-disable-line no-process-env t.deepEqual(Object.getOwnPropertyDescriptor(process, 'env'), oldEnvDescriptor); }); @@ -153,3 +157,72 @@ test('requires all vars from .env.example', (t) => { }, Error); t.is(err.message, 'Environment variables are missing: MISSING, EMPTY'); }); + +test('supports filepath as option', (t) => { + const result = envy({ + filepath : fixturePath('normal') + }); + t.deepEqual(result, { + myKey : 'my val', + dog : 'woof' + }); +}); + +test('supports explicit env as option', (t) => { + const result = envy({ + filepath : fixturePath('normal'), + env : { + MY_KEY : 'my alternate val' + } + }); + t.deepEqual(result, { + myKey : 'my alternate val', + dog : 'woof' + }); +}); + +test('ignores unknown global env vars by default', (t) => { + const result = envy({ + filepath : fixturePath('normal'), + env : { + UNKNOWN_VAR : 'this should not appear in result' + } + }); + t.deepEqual(result, { + myKey : 'my val', + dog : 'woof' + }); +}); + +test('accepts all unknown global env vars when allowUnknown option is true', (t) => { + const result = envy({ + filepath : fixturePath('normal'), + allowUnknown : true, + env : { + DOG : 'overridden woof', + UNKNOWN_VAR : 'this should appear in result' + } + }); + t.deepEqual(result, { + myKey : 'my val', + dog : 'overridden woof', + unknownVar : 'this should appear in result' + }); +}); + +test('accepts specific unknown global env vars when allowUnknown option is an array', (t) => { + const result = envy({ + filepath : fixturePath('normal'), + allowUnknown : ['UNKNOWN_VAR'], + env : { + DOG : 'overridden woof', + UNKNOWN_VAR : 'this should appear in result', + VERY_UNKNOWN_VAR : 'this should not appear in result' + } + }); + t.deepEqual(result, { + myKey : 'my val', + dog : 'overridden woof', + unknownVar : 'this should appear in result' + }); +});