/* @module dotenv-check A module that performs deployment checks, based on info found on your .env files */
require("babel-polyfill");
const Promise = require('bluebird').Promise
const path = require('path')
const helpers = require('./helpers')
const errors = require('./errors')
const recursiveFileScan = require("recursive-readdir")
/**
* Reads a .env file and returns its content in utf-8 format
*
* @param {String} fileName The name of the .env file to check
*
* @returns {Promise} Promise object represents the contents of the file
*/
const readDotenv = async function (fileName) {
try {
return await helpers.readFileAsync(path.resolve(fileName), 'UTF8')
} catch(err){
throw new errors.DotenvCheckFileNotFoundError(`File ${fileName} could not be found`)
}
}
/**
* Splits the file into lines, in every linebreak, and then splits on = character
*
* @param {String} file The .env file as a utf-8 string
*
* @return {Array} An array containing each line formatted
*/
const transformDotenv = function (file) {
const lines = file.match(/[^\r\n]+/g) //split in new lines
if (!lines) return []
const processedLines = lines.map(line => {
// In case of pure whitespace, ignore
if (/^(\s)*$/.test(line)) {
return null
}
const lineParts = helpers.extendedSplit(line, '=', 2)
return lineParts
}).filter(line => line !== null) // filter out empty lines
return processedLines
}
/**
* Checks if all environment variables passed to the .env file have valid names, and if there are any duplicates
*
* @param {Array} processedLines An array, representing each line of the .env file as an array, that contains the environment variable name in index 0, and the value of the variable in index 1
*
* @return {Map} A map containing the environment variable names as its keys, and null as the value of these keys
*/
const validateDotenv = function(processedLines) {
const variables = new Map()
processedLines.forEach((line, idx) => {
const invalidLine = idx + 1
// Invalid syntax, correct example: NODE_ENV=production, wrong example: NODE_ENV
if (line.length !== 2) {
throw new errors.DotenvCheckInvalidDotenvFileStructureError(`Dotenv file is invalid in line ${invalidLine}`)
}
// Invalid environment variable name
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(line[0])) {
// Invalid first letter, first letter is more strict and must contain only characters from a-z (upper or lowercase) or an undersoce
if (!/^[a-zA-Z_]$/.test(line[0].split('')[0])) {
throw new errors.DotenvCheckInvalidDotenvVariableNameError(`Invalid variable name ${line[0]} in line ${invalidLine}. First letter allowed characters: latin letters, _`)
}
// Invalid variable name
else {
throw new errors.DotenvCheckInvalidDotenvVariableNameError(`Invalid variable name ${line[0]} in line ${invalidLine}. Allowed characters: latin letters, numbers, _`)
}
}
// Duplicate variable error handling
if (variables.has(line[0])) {
throw new errors.DotenvCheckDotenvDuplicateVariableError(`Variable ${line[0]} used twice in line ${invalidLine}`)
}
variables.set(line[0], '')
})
return variables
}
/**
* Checks if the sample and the original .env files contain the exact same variables
*
* @param {Map} sample The variables that were retrieved from the sample .env file
* @param {Map} original The variables that were retrieved from the .env file
*
* @return {Boolean} Returns true if both files match each other
*/
const compareDotenvs = function (sample, original) {
const sampleVariableNames = sample.keys()
for (var name of sampleVariableNames) {
if (!original.has(name)) throw new errors.DotenvCheckDotenvFilesMismatchError(`Variable: ${name} is missing in the original .env file`)
}
const originalVariableNames = original.keys()
for (var name of originalVariableNames) {
if (!sample.has(name)) throw new errors.DotenvCheckDotenvFilesMismatchError(`Variable: ${name} is missing in the sample .env file`)
}
return true
}
/**
* Checks if the variables retrieved from the .env file, exist in the current node running instance
*
* @param {Map} variables A Map of the variables that were retrieved from the .env file
*/
const checkEnvsAreExported = function (variables) {
const variableNames = variables.keys()
for (var name of variableNames) {
if (!process.env[name]) {
throw new errors.DotenvCheckVariableNotExportedError(`Environment variable ${name} has not been exported correctly`)
}
}
return variables
}
/**
* Scans source code files, finds used environment variables, and if there are variables that dont exist in the .env
* file, throws an error
*
* @param {*} sourceCodeFolder
* @param {*} originalVariables
*/
const checkSourceCode = async function (sourceCodeFolder, originalVariables) {
/**
* Function to ignore all files, except javascript ones
* @param {String} file
* @param {Object} stats
*/
function ignoreFunc(file, stats) {
return !stats.isDirectory() && path.extname(file) !== ".js";
}
const sourceCodePath = path.resolve(sourceCodeFolder)
const files = await recursiveFileScan(sourceCodePath, [ignoreFunc])
return Promise.map(files, async function(file) {
let fileContents = null
// Read each file
try {
fileContents = await helpers.readFileAsync(file, 'UTF8')
} catch (err) {
// Ignore read errors for now
// TODO: return an array with soft errors
}
if (fileContents) {
const variableMatchesInCode = fileContents.replace(/\/\*[\s\S]*?\*\/|([^:]|^)\/\/.*$/gm, '').match(/((process\.env\.){1}([a-zA-Z_1-9]*))\b/g)
if (variableMatchesInCode) {
variableMatchesInCode.forEach(item => {
const variableName = item.substring(12)
if (!originalVariables.has(variableName)) throw new Error(`Unknown variable ${variableName} found in code`)
})
}
}
}, {concurrency: 5})
}
/**
* @constructor
*
* @param {Object} [configuration] An object containing configuration about how the module will run
* @param {String} [configuration.envFile=null] - The path to the .env file, relative to the project path
* @param {String} [configuration.sampleEnvFile=.env.sample] The path to the .env.sample file, relative to the project path
* @param {Boolean} [configuration.checkEnvsExported=false] Whether the module should check if the environment variables from the .env file exist in the current node running instance
* @param {String} [configuration.sourceCode=null] The path of the source code to check, leave null if you dont want to check
*/
module.exports = function(configuration) {
this.configuration = Object.assign({}, {
envFile: null,
sampleEnvFile: '.env.sample',
checkEnvsExported: false,
sourceCode: null
}, configuration)
return {
/**
* Executes all validations
*/
execute: async () => {
try {
if (!this.configuration.sampleEnvFile) {
throw new errors.DotenvCheckConfigurationError('Sample .env file does not exist in your configuration')
}
await readDotenv(this.configuration.sampleEnvFile)
.then(transformDotenv)
.then(validateDotenv)
.then(variables => this.sampleVariables = variables)
// If .env file is used, perform extra checks
if (this.configuration.envFile) {
await readDotenv(this.configuration.envFile)
.then(transformDotenv)
.then(validateDotenv)
.then(variables => this.variables = variables)
compareDotenvs(this.sampleVariables, this.variables)
}
if (this.configuration.checkEnvsExported) checkEnvsAreExported(this.sampleVariables)
if (this.configuration.sourceCode) await checkSourceCode(this.configuration.sourceCode, this.sampleVariables)
return true
} catch (err) {
throw err
}
}
}
}.bind({}) // Bind an empty object for babel to work correctly