diff --git a/.gitignore b/.gitignore index 6704566..fd09255 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +.vscode + # Logs logs *.log diff --git a/README.md b/README.md index 03a2ffd..b8b956c 100644 --- a/README.md +++ b/README.md @@ -71,8 +71,8 @@ Autocomplete and Strongly Typed Keys are your new best friend! Using `simple-env | Autocomplete | ✅ | ❌ | ❌ | | Strongly Typed Keys | ✅ | ❌ | ❌ | | Single Location Refactor | ✅ | ❌ | ❌ | +| Loads .env | ✅ | ✅ | ❌ | | Return Type Helpers | 🔜 | ❌ | ✅ | -| Loads .env | 🔜 | ✅ | ❌ | Let's see how some of the features above look in code: @@ -161,6 +161,55 @@ export const env = setEnv({ > **NOTE**: if you choose to assign `optional` and `required` env vars individually, `setEnv` should only be done _once_ for each or you will overwrite your previously defined values. +### Loading .env files + +If you set a path to an `.env` file, `simple-env` will parse the file and import the contents into the environment automatically. These will be available in the `process.env` object. + +> If you don't specify a path, `simple-env` **will not** automatically load any `.env` files. This is to avoid a breaking change. + +```typescript +// src/env.ts +import setEnv from '@americanairlines/simple-env'; + +export const env = setEnv({ + required: { + nodeEnv: 'NODE_ENV', + someRequiredSecret: 'SOME_REQUIRED_SECRET', + }, + options: { + envPath: "./.env" + } +}); +``` +Each variable needs to be declared on a separate line. + +Comments must be on their own line, inline comments will be read in as part of the value to the variable! + +If `simple-env` finds something that it doesn't know how to parse, it just skips it and moves on to the next thing! + +Here's a sample `.env` file that will make us all happy :^) +``` +# Comments like this will be ignored +// These will be ignored too :^) + +# All of these vars are gonna work just fine! +NODE_ENV=development +SOME_REQUIRED_SECRET='Single quotes are fine!' +ANOTHER_SECRET="Double quotes are fine too, we don't discriminate :^)" +lowercase=no problem + SECRET_2_ELECTRIC_BOOGALOO = "We don't mind whitespace between the equal signs, or before the var definition" +``` + +Here's a sample `.env` file that will make us all sad :^( +``` +# Uh-oh, these ones are invalid, so we'll have to skip them +1BAD_VAR="Variables can't begin with numbers" +ANOTHER BAD VAR="no whitespace allowed in var names" +KEBAB-CASE="can't do it" +VAR_WITHOUT_EQUAL_IS_SKIPPED +loose text like this will also get skipped +``` + # Contributing Interested in contributing to the project? Check out our [Contributing Guidelines](./.github/CONTRIBUTING.md). diff --git a/package-lock.json b/package-lock.json index 7cce232..4b9ab6f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@americanairlines/simple-env", - "version": "1.0.4", + "version": "1.1.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 881bfea..3ae5b03 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@americanairlines/simple-env", - "version": "1.0.4", + "version": "1.1.0", "description": "An intuitive, strongly typed, and scalable way to retrieve environment variables", "keywords": [ "env", diff --git a/src/index.ts b/src/index.ts index 0c3d237..dc00504 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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'; @@ -7,6 +8,7 @@ let _symbolizedEnvVars: Record; let _options: InternalOptions = { required: {}, optional: {}, + options: {}, }; const createSymbol = (description: string): SymbolWithDescription => Symbol(description) as SymbolWithDescription; @@ -43,6 +45,10 @@ export default function setEnv>(_options.required); _requiredEnvVars = Object.values(symbolizedRequiredEnvVars); diff --git a/src/parser.ts b/src/parser.ts new file mode 100644 index 0000000..928c55a --- /dev/null +++ b/src/parser.ts @@ -0,0 +1,50 @@ +import * as fs from 'fs'; +import { join } from 'path'; +import { ConfigOptions } from './types/Options'; + +function parseLine(line: string): Record { + 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.envPath || join(process.cwd(), '.env'); + + if (!fs.existsSync(fullPath)) { + return; + } + + const envFileContents = fs.readFileSync(fullPath).toString(); + let envVarPairs: Record = {}; + 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; diff --git a/src/test/index.test.ts b/src/test/index.test.ts index b7af759..fac1231 100644 --- a/src/test/index.test.ts +++ b/src/test/index.test.ts @@ -1,9 +1,12 @@ import 'jest'; +import fs from 'fs'; import setEnvDefault from '..'; 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 @@ -54,6 +57,7 @@ describe('simple-env', () => { describe('set', () => { afterEach(() => { + process.env = {}; jest.resetModules(); }); @@ -99,5 +103,23 @@ describe('simple-env', () => { expect(Object.getOwnPropertyDescriptors(env)).not.toHaveProperty('something'); expect(Object.getOwnPropertyDescriptors(env)).toHaveProperty('somethingElse'); }); + + it('will invoke the parser if envPath is set', () => { + existsSyncSpy.mockReturnValue(true); + readFileSpy.mockImplementation(() => Buffer.from('TEST=test')); + + setEnv({ optional: { something: 'SOMETHING' }, options: { envPath: './a/.env' } }); + + 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'); + }); }); }); diff --git a/src/test/parser.test.ts b/src/test/parser.test.ts new file mode 100644 index 0000000..d11d6dd --- /dev/null +++ b/src/test/parser.test.ts @@ -0,0 +1,79 @@ +import fs from 'fs'; +import parseEnvFile from '../parser'; + +describe('parser', () => { + const readFileSpy = jest.spyOn(fs, 'readFileSync'); + const existsSyncSpy = jest.spyOn(fs, 'existsSync'); + + beforeEach(async () => { + jest.resetModules(); + 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'); + }); +}); diff --git a/src/types/Options.ts b/src/types/Options.ts index ea5a9e1..00e7458 100644 --- a/src/types/Options.ts +++ b/src/types/Options.ts @@ -1,9 +1,14 @@ import { DefaultEnvVars, UndefinedEnvVars } from './EnvVars'; import { RemoveKeys } from './helpers'; +export interface ConfigOptions { + envPath?: string; +} + export interface Options { required?: Required; optional?: RemoveKeys; + options?: ConfigOptions; } export interface InternalOptions extends Omit, 'optional'> {