Skip to content

Support options: explicit opts.env and opts.allowUnknown #8

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 2 commits into
base: master
Choose a base branch
from
Draft
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
4 changes: 3 additions & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
language: node_js

node_js:
- 14
- 12
- 10
- 8

os:
- linux
- osx
- windows
4 changes: 2 additions & 2 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
Copyright © 2017 Seth Holladay <[email protected]> (https://seth-holladay.com)
Copyright © 2020 Project contributors
Copyright © 2017-2020 Seth Holladay <[email protected]> (https://seth-holladay.com)

Mozilla Public License, version 2.0

Expand Down Expand Up @@ -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.

80 changes: 57 additions & 23 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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);

Expand All @@ -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.
Expand All @@ -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));
Expand All @@ -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;
83 changes: 78 additions & 5 deletions test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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)
Expand All @@ -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);
});

Expand Down Expand Up @@ -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'
});
});