Skip to content
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

Add support for loading env values #23

Open
wants to merge 20 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 11 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
21 changes: 21 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"name": "vscode-jest-tests",
"request": "launch",
"args": [
"--runInBand"
],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"disableOptimisticBPs": true,
"program": "${workspaceFolder}/node_modules/jest/bin/jest"
}
]
}
10 changes: 8 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import parseEnvFile from './parser';
import { EnvVarSymbols, UndefinedEnvVars } from './types/EnvVars';
import { SymbolWithDescription } from './types/helpers';
import { InternalOptions, Options } from './types/Options';
Expand All @@ -7,6 +8,7 @@ let _symbolizedEnvVars: Record<string, SymbolWithDescription>;
let _options: InternalOptions = {
required: {},
optional: {},
options: {},
};

const createSymbol = (description: string): SymbolWithDescription => Symbol(description) as SymbolWithDescription;
Expand Down Expand Up @@ -36,13 +38,17 @@ function symbolizeVars<T>(input: Record<string, string>) {
export default function setEnv<T extends UndefinedEnvVars, V extends UndefinedEnvVars>(
options: Options<T, V>,
): {
readonly [K in keyof (T & Partial<V>)]: (T & Partial<V>)[K];
} {
readonly [K in keyof (T & Partial<V>)]: (T & Partial<V>)[K];
} {
_options = {
..._options,
...options,
};

if (_options.options.loadDotEnv) {
parseEnvFile(_options.options);
}

const symbolizedRequiredEnvVars = symbolizeVars<EnvVarSymbols<T>>(_options.required);
_requiredEnvVars = Object.values(symbolizedRequiredEnvVars);

Expand Down
50 changes: 50 additions & 0 deletions src/parser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import * as fs from 'fs';
import { join } from 'path';
import { ConfigOptions } from './types/Options';

function parseLine(line: string): Record<string, string> {
const delimiter = '=';
const lineRegex = /^\s*[a-zA-Z_][a-zA-Z0-9_]*\s*="?'?.*'?"?$/g;
let [key, val] = line.split(delimiter);

// Ignore comments, or lines which don't conform to acceptable patterns
if (key.startsWith('#') || key.startsWith('//') || !lineRegex.test(line)) {
return {};
}

key = key.trim();
val = val.trim();
// Get rid of wrapping double or single quotes
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
val = val.substr(1, val.length - 2);
}

return { [key]: val };
}

export function parseEnvFile(options: ConfigOptions = {}): void {
const fullPath = options.envFile || join(process.cwd(), '.env');

if (!fs.existsSync(fullPath)) {
return;
}

const envFileContents = fs.readFileSync(fullPath).toString();
let envVarPairs: Record<string, string> = {};
const eol = /\r?\n/;

const lines = envFileContents.split(eol);
lines.forEach((line: string) => {
envVarPairs = { ...envVarPairs, ...parseLine(line) };
});

// Toss everything into the environment
Object.entries(envVarPairs).forEach(([key, val]) => {
// Prefer env vars that have been set by the OS
if (key in process.env === false) {
process.env[key] = val;
}
});
}

export default parseEnvFile;
98 changes: 98 additions & 0 deletions src/test/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import 'jest';
import fs from 'fs';
import setEnvDefault from '..';
import parseEnvFile from '../parser';

describe('simple-env', () => {
// Fresh setEnv for each test
let setEnv: typeof setEnvDefault;
const readFileSpy = jest.spyOn(fs, 'readFileSync');
const existsSyncSpy = jest.spyOn(fs, 'existsSync');

beforeEach(async () => {
// Reset module cache and dynamically import it again
Expand Down Expand Up @@ -54,6 +58,7 @@ describe('simple-env', () => {

describe('set', () => {
afterEach(() => {
process.env = {};
jest.resetModules();
});

Expand Down Expand Up @@ -99,5 +104,98 @@ describe('simple-env', () => {
expect(Object.getOwnPropertyDescriptors(env)).not.toHaveProperty('something');
expect(Object.getOwnPropertyDescriptors(env)).toHaveProperty('somethingElse');
});

it('will invoke the parser is loadDotEnv is true', () => {
existsSyncSpy.mockReturnValue(true);
readFileSpy.mockImplementation(() => Buffer.from('TEST=test'));

setEnv({ optional: { something: 'SOMETHING' }, options: { loadDotEnv: true } });

expect(process.env).toHaveProperty('TEST');
});

it('will not invoke the parser by default', () => {
existsSyncSpy.mockReturnValue(true);
readFileSpy.mockImplementation(() => Buffer.from('TEST=test'));

setEnv({ optional: { something: 'SOMETHING' } });

expect(process.env).not.toHaveProperty('TEST');
});
});

describe('parser', () => {
beforeEach(() => {
process.env = {};
});

afterEach(() => {
jest.resetModules();
});

it('will not overwrite vars that already exist', () => {
const originalValue = 'I already exist';
const newValue = 'I should not';
process.env = { TEST: originalValue };

readFileSpy.mockImplementation(() => Buffer.from(`TEST=${newValue}`));
existsSyncSpy.mockReturnValue(true);

parseEnvFile();

expect(process.env.TEST).toEqual(originalValue);
});

it('will reject malformed lines', () => {
const fakeFile =
`
bad
good=this
4=bad
good2='this'
good3="this"
`;
readFileSpy.mockImplementation(() => Buffer.from(fakeFile));
existsSyncSpy.mockReturnValue(true);

parseEnvFile();

expect(process.env.good).toEqual('this');
expect(process.env.good2).toEqual('this');
expect(process.env.good3).toEqual('this');
});

it('will ignore comments in the file', () => {
const fakeFile =
`
#comment\n
//comment\n
TEST=test
`;
readFileSpy.mockImplementation(() => Buffer.from(fakeFile));
existsSyncSpy.mockReturnValue(true);

parseEnvFile();

expect(process.env.TEST).toEqual('test');
});

it('will not do anything if the .env file does not exist', () => {
readFileSpy.mockImplementation(() => Buffer.from('TEST=test'));
existsSyncSpy.mockReturnValue(false);

parseEnvFile();

expect(process.env.TEST).toBeUndefined();
});

it('will read an env variable from a .env file into process.env', () => {
readFileSpy.mockImplementation(() => Buffer.from('TEST=test'));
existsSyncSpy.mockReturnValue(true);

parseEnvFile();

expect(process.env.TEST).toEqual('test');
});
});
});
6 changes: 6 additions & 0 deletions src/types/Options.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import { DefaultEnvVars, UndefinedEnvVars } from './EnvVars';
import { RemoveKeys } from './helpers';

export interface ConfigOptions {
envFile?: string;
loadDotEnv?: boolean;
}

export interface Options<Required extends UndefinedEnvVars = DefaultEnvVars, Optional extends UndefinedEnvVars = DefaultEnvVars> {
required?: Required;
optional?: RemoveKeys<Optional, keyof Required>;
options?: ConfigOptions;
}

export interface InternalOptions extends Omit<Required<Options>, 'optional'> {
Expand Down