diff --git a/LICENSE b/LICENSE index 377fa81..a384282 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2018 Todd Bluhm +Copyright (c) 2019 Todd Bluhm Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/lib/help.js b/lib/help.js new file mode 100644 index 0000000..6bdb8fe --- /dev/null +++ b/lib/help.js @@ -0,0 +1,10 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +/** + * Prints out some minor help text + * @return {String} Help text + */ +function PrintHelp() { + return "\nUsage: env-cmd [options] [env_file | env_name] command [command options]\n\nA simple utility for running a cli application using an env config file.\n\nAlso supports using a .env-cmdrc json file in the execution directory to support multiple\nenvironment configs in one file.\n\nOptions:\n --no-override - do not override existing process env vars with file env vars\n --fallback - if provided env file does not exist, attempt to use fallback .env file in root dir\n "; +} +exports.PrintHelp = PrintHelp; diff --git a/lib/index.js b/lib/index.js index b0d7a29..d5e3396 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,375 +1,32 @@ -'use strict' - -const spawn = require('cross-spawn').spawn -const path = require('path') -const fs = require('fs') -const os = require('os') -const rcFileLocation = path.join(process.cwd(), '.env-cmdrc') -const envFilePathDefault = path.join(process.cwd(), '.env') -const terminateSpawnedProcessFuncHandlers = {} -let terminateProcessFuncHandler -const SIGNALS_TO_HANDLE = [ - 'SIGINT', 'SIGTERM', 'SIGHUP' -] -const sharedState = { - exitCalled: false -} - +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +var spawn = require("cross-spawn"); +var signal_termination_1 = require("./signal-termination"); +var parse_args_1 = require("./parse-args"); /** * The main process for reading, parsing, applying and then running the process with env vars - * @param {Array} args And array if strings representing cli args - * - * @return {Object} The child process */ -function EnvCmd (args) { - // First Parse the args from the command line - const parsedArgs = ParseArgs(args) - - // If a .rc file was found then use that - let parsedEnv - if (fs.existsSync(rcFileLocation)) { - parsedEnv = UseRCFile({ envFile: parsedArgs.envFile }) - } else { - // Try to use a .env file - parsedEnv = UseCmdLine({ envFile: parsedArgs.envFile, useFallback: parsedArgs.useFallback }) - } - - let env - // Override the merge order if --no-override flag set - if (parsedArgs.noOverride) { - env = Object.assign({}, parsedEnv, process.env) - } else { - // Add in the system environment variables to our environment list - env = Object.assign({}, process.env, parsedEnv) - } - - // Execute the command with the given environment variables - const proc = spawn(parsedArgs.command, parsedArgs.commandArgs, { - stdio: 'inherit', - env - }) - - // Handle a few special signals and then the general node exit event - // on both parent and spawned process - SIGNALS_TO_HANDLE.forEach(signal => { - terminateSpawnedProcessFuncHandlers[signal] = TerminateSpawnedProc.bind(sharedState, proc, signal) - process.once(signal, terminateSpawnedProcessFuncHandlers[signal]) - }) - process.once('exit', terminateSpawnedProcessFuncHandlers['SIGTERM']) - - terminateProcessFuncHandler = TerminateParentProcess.bind(sharedState) - proc.on('exit', terminateProcessFuncHandler) - - return proc -} - -/** - * Parses the arguments passed into the cli - * @param {Array} args An array of strings to parse the options out of - * - * @return {Object} An object containing cli options and commands - */ -function ParseArgs (args) { - if (args.length < 2) { - throw new Error('Error! Too few arguments passed to env-cmd.') - } - - let envFile - let command - let noOverride - let useFallback - let commandArgs = args.slice() - while (commandArgs.length) { - const arg = commandArgs.shift() - if (arg === '--fallback') { - useFallback = true - continue +function EnvCmd(args) { + // First Parse the args from the command line + var parsedArgs = parse_args_1.parseArgs(args); + var env; + // Override the merge order if --no-override flag set + if (parsedArgs.options.noOverride) { + env = Object.assign({}, parsedArgs.envValues, process.env); } - if (arg === '--no-override') { - noOverride = true - continue + else { + // Add in the system environment variables to our environment list + env = Object.assign({}, process.env, parsedArgs.envValues); } - // assume the first arg is the env file (or if using .rc the environment name) - if (!envFile) { - envFile = arg - } else { - command = arg - break - } - } - - return { - envFile, - command, - commandArgs, - noOverride, - useFallback - } + // Execute the command with the given environment variables + var proc = spawn(parsedArgs.command, parsedArgs.commandArgs, { + stdio: 'inherit', + env: env + }); + // Handle any termination signals for parent and child proceses + signal_termination_1.handleTermSignals(proc); + return env; } - -/** - * Strips out comments from env file string - * @param {String} envString The .env file string - * - * @return {String} The .env file string with comments stripped out - */ -function StripComments (envString) { - const commentsRegex = /(^#.*$)/gim - let match = commentsRegex.exec(envString) - let newString = envString - while (match != null) { - newString = newString.replace(match[1], '') - match = commentsRegex.exec(envString) - } - return newString -} - -/** - * Strips out newlines from env file string - * @param {String} envString The .env file string - * - * @return {String} The .env file string with newlines stripped out - */ -function StripEmptyLines (envString) { - const emptyLinesRegex = /(^\n)/gim - return envString.replace(emptyLinesRegex, '') -} - -/** - * Parse out all env vars from an env file string - * @param {String} envString The .env file string - * - * @return {Object} Key/Value pairs corresponding to the .env file data - */ -function ParseEnvVars (envString) { - const envParseRegex = /^((.+?)[=](.*))$/gim - const matches = {} - let match - while ((match = envParseRegex.exec(envString)) !== null) { - // Note: match[1] is the full env=var line - const key = match[2].trim() - let value = match[3].trim() || '' - - // remove any surrounding quotes - value = value.replace(/(^['"]|['"]$)/g, '') - - matches[key] = value - } - return matches -} - -/** - * Parse out all env vars from a given env file string and return an object - * @param {String} envString The .env file string - * - * @return {Object} Key/Value pairs of all env vars parsed from files - */ -function ParseEnvString (envFileString) { - // First thing we do is stripe out all comments - envFileString = StripComments(envFileString.toString()) - - // Next we stripe out all the empty lines - envFileString = StripEmptyLines(envFileString) - - // Merge the file env vars with the current process env vars (the file vars overwrite process vars) - return ParseEnvVars(envFileString) -} - -/** - * Reads and parses the .env-cmdrc file - * @param {String} fileData the .env-cmdrc file data (which should be a valid json string) - * - * @return {Object} The .env-cmdrc as a parsed JSON object - */ -function ParseRCFile (fileData) { - let data - try { - data = JSON.parse(fileData) - } catch (e) { - console.error(`Error: - Could not parse the .env-cmdrc file. - Please make sure its in a valid JSON format.`) - throw new Error(`Unable to parse JSON in .env-cmdrc file.`) - } - return data -} - -/** - * Uses the rc file to get env vars - * @param {Object} options - * @param {String} options.envFile The .env-cmdrc file environment to use - * - * @return {Object} Key/Value pair of env vars from the .env-cmdrc file - */ -function UseRCFile (options) { - const fileData = fs.readFileSync(rcFileLocation, { encoding: 'utf8' }) - const parsedData = ParseRCFile(fileData) - - let result = {} - const envNames = options.envFile.split(',') - if (envNames.length === 1 && !parsedData[envNames[0]]) { - console.error(`Error: - Could not find environment: - ${options.envFile} - in .rc file: - ${rcFileLocation}`) - throw new Error(`Missing environment ${options.envFile} in .env-cmdrc file.`) - } - - envNames.forEach(function (name) { - const envVars = parsedData[name] - if (envVars) { - result = Object.assign(result, envVars) - } - }) - return result -} - -/** - * Uses the cli passed env file to get env vars - * @param {Object} options - * @param {String} options.envFile The .env file name/relative path - * @param {Boolean} options.useFallback Should we attempt to find a fallback file - * - * @return {Object} Key/Value pairing of env vars found in .env file - */ -function UseCmdLine (options) { - const envFilePath = ResolveEnvFilePath(options.envFile) - - // Attempt to open the provided file - let file - try { - file = fs.readFileSync(envFilePath, { encoding: 'utf8' }) - } catch (err) { - if (!options.useFallback) { - return {} - } - } - - // If we don't have a main file try the fallback file - if (!file && options.useFallback) { - try { - file = fs.readFileSync(envFilePathDefault) - } catch (e) { - throw new Error(`Error! Could not find fallback file or read env file at ${envFilePathDefault}`) - } - } - - // Get the file extension - const ext = path.extname(envFilePath).toLowerCase() - - // Parse the env file string using the correct parser - const env = ext === '.json' || ext === '.js' - ? require(envFilePath) - : ParseEnvString(file) - - return env -} - -/** - * Prints out some minor help text - * @return {String} Help text - */ -function PrintHelp () { - return ` -Usage: env-cmd [options] [env_file | env_name] command [command options] - -A simple utility for running a cli application using an env config file. - -Also supports using a .env-cmdrc json file in the execution directory to support multiple -environment configs in one file. - -Options: - --no-override - do not override existing process env vars with file env vars - --fallback - if provided env file does not exist, attempt to use fallback .env file in root dir - ` -} - -/** - * General exception handler - * @param {Error} e The exception error to handle - */ -function HandleUncaughtExceptions (e) { - if (e.message.match(/passed/gi)) { - console.log(PrintHelp()) - } - console.log(e.message) - process.exit(1) -} - -/** - * A simple function for resolving the path the user entered - * @param {String} userPath A path - * @return {String} The fully qualified absolute path - */ -function ResolveEnvFilePath (userPath) { - // Make sure a home directory exist - const home = os.homedir() - if (home) { - userPath = userPath.replace(/^~($|\/|\\)/, `${home}$1`) - } - return path.resolve(process.cwd(), userPath) -} - -/** - * Helper for terminating the spawned process - * @param {ProccessHandler} proc The spawned process handler - */ -function TerminateSpawnedProc (proc, signal, code) { - RemoveProcessListeners() - - if (!this.exitCalled) { - this.exitCalled = true - proc.kill(signal) - } - - if (code) { - return process.exit(code) - } - - process.kill(process.pid, signal) -} - -/** - * Helper for terminating the parent process - */ -function TerminateParentProcess (code, signal) { - RemoveProcessListeners() - - if (!this.exitCalled) { - this.exitCalled = true - if (signal) { - return process.kill(process.pid, signal) - } - process.exit(code) - } -} - -/** - * Helper for removing all termination signal listeners from parent process - */ -function RemoveProcessListeners () { - SIGNALS_TO_HANDLE.forEach(signal => { - process.removeListener(signal, terminateSpawnedProcessFuncHandlers[signal]) - }) - process.removeListener('exit', terminateSpawnedProcessFuncHandlers['SIGTERM']) -} - -process.on('uncaughtException', HandleUncaughtExceptions) - module.exports = { - EnvCmd, - ParseArgs, - ParseEnvString, - PrintHelp, - HandleUncaughtExceptions, - TerminateSpawnedProc, - TerminateParentProcess, - StripComments, - StripEmptyLines, - ParseEnvVars, - ParseRCFile, - UseRCFile, - UseCmdLine, - ResolveEnvFilePath -} + EnvCmd: EnvCmd +}; diff --git a/lib/parse-args.js b/lib/parse-args.js new file mode 100644 index 0000000..0f77204 --- /dev/null +++ b/lib/parse-args.js @@ -0,0 +1,99 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +var program = require("commander"); +var utils_1 = require("./utils"); +var parse_rc_file_1 = require("./parse-rc-file"); +var parse_env_file_1 = require("./parse-env-file"); +var RC_FILE_DEFAULT_LOCATIONS = ['./.env-cmdrc', './.env-cmdrc.json']; +var ENV_FILE_DEFAULT_LOCATION = './.env'; +/** +* Parses the arguments passed into the cli +*/ +function parseArgs(args) { + program + .version('1.0.0', '-v, --version') + .usage('[options] [...args]') + .option('-f, --file [path]', 'Custom .env file location') + .option('-r, --rc-file [path]', 'Custom .env-cmdrc file location') + .option('-e, --environments [env...]', 'The rc-file environment to select', utils_1.parseArgList) + .option('--fallback', 'Enables auto fallback to default env file location') + .option('--no-override', 'Do not override existing env vars') + .parse(['_', '_'].concat(args)); + // get the command and command args + var command = program.args[0]; + var commandArgs = program.args.slice(1); + var noOverride = !program.override; + var returnValue = { + command: command, + commandArgs: commandArgs, + options: { + noOverride: noOverride + } + }; + // Check for rc file usage + var env; + if (program.environments) { + // user provided an .rc file path + if (program.rcFile) { + try { + env = parse_rc_file_1.useRCFile({ environments: program.environments, path: program.rcFile }); + } + catch (e) { + console.log(program.outputHelp()); + throw new Error("Unable to locate .rc file at location (" + program.rcFile + ")"); + } + // Use the default .rc file locations + } + else { + for (var _i = 0, RC_FILE_DEFAULT_LOCATIONS_1 = RC_FILE_DEFAULT_LOCATIONS; _i < RC_FILE_DEFAULT_LOCATIONS_1.length; _i++) { + var path = RC_FILE_DEFAULT_LOCATIONS_1[_i]; + try { + env = parse_rc_file_1.useRCFile({ environments: program.environments, path: path }); + break; + } + catch (e) { } + } + if (!env) { + console.log(program.outputHelp()); + throw new Error("Unable to locate .rc file at default locations (" + RC_FILE_DEFAULT_LOCATIONS + ")"); + } + } + if (env) { + returnValue.envValues = env; + return returnValue; + } + } + // Use env file + if (program.file) { + try { + env = parse_env_file_1.useCmdLine(program.file); + } + catch (e) { + if (!program.fallback) { + console.log(program.outputHelp()); + console.error("Unable to locate env file at location (" + program.file + ")"); + throw e; + } + } + if (env) { + returnValue.envValues = env; + return returnValue; + } + } + // Use the default env file location + try { + env = parse_env_file_1.useCmdLine(ENV_FILE_DEFAULT_LOCATION); + } + catch (e) { + console.log(program.outputHelp()); + console.error("Unable to locate env file at default locations (" + ENV_FILE_DEFAULT_LOCATION + ")"); + throw e; + } + if (env) { + returnValue.envValues = env; + return returnValue; + } + console.log(program.outputHelp()); + throw Error('Unable to locate any files to read environment data!'); +} +exports.parseArgs = parseArgs; diff --git a/lib/parse-env-file.js b/lib/parse-env-file.js new file mode 100644 index 0000000..4cb373e --- /dev/null +++ b/lib/parse-env-file.js @@ -0,0 +1,77 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +var fs = require("fs"); +var path = require("path"); +var utils_1 = require("./utils"); +/** + * Uses the cli passed env file path to get env vars + */ +function useCmdLine(envFilePath) { + var absolutePath = utils_1.resolveEnvFilePath(envFilePath); + if (!fs.existsSync(absolutePath)) { + throw new Error("Invalid env file path (" + envFilePath + ")."); + } + // Get the file extension + var ext = path.extname(absolutePath).toLowerCase(); + var env; + if (ext === '.json') { + env = require(absolutePath); + } + else { + var file = fs.readFileSync(absolutePath, { encoding: 'utf8' }); + env = parseEnvString(file); + } + return env; +} +exports.useCmdLine = useCmdLine; +/** + * Parse out all env vars from a given env file string and return an object + */ +function parseEnvString(envFileString) { + // First thing we do is stripe out all comments + envFileString = stripComments(envFileString.toString()); + // Next we stripe out all the empty lines + envFileString = stripEmptyLines(envFileString); + // Merge the file env vars with the current process env vars (the file vars overwrite process vars) + return parseEnvVars(envFileString); +} +exports.parseEnvString = parseEnvString; +/** + * Parse out all env vars from an env file string + */ +function parseEnvVars(envString) { + var envParseRegex = /^((.+?)[=](.*))$/gim; + var matches = {}; + var match; + while ((match = envParseRegex.exec(envString)) !== null) { + // Note: match[1] is the full env=var line + var key = match[2].trim(); + var value = match[3].trim() || ''; + // remove any surrounding quotes + matches[key] = value.replace(/(^['"]|['"]$)/g, ''); + } + return matches; +} +exports.parseEnvVars = parseEnvVars; +/** + * Strips out comments from env file string + */ +function stripComments(envString) { + var commentsRegex = /(^#.*$)/gim; + var match = commentsRegex.exec(envString); + var newString = envString; + while (match != null) { + newString = newString.replace(match[1], ''); + match = commentsRegex.exec(envString); + } + return newString; +} +exports.stripComments = stripComments; +/** + * Strips out newlines from env file string + */ +function stripEmptyLines(envString) { + var emptyLinesRegex = /(^\n)/gim; + return envString.replace(emptyLinesRegex, ''); +} +exports.stripEmptyLines = stripEmptyLines; diff --git a/lib/parse-rc-file.js b/lib/parse-rc-file.js new file mode 100644 index 0000000..c5940de --- /dev/null +++ b/lib/parse-rc-file.js @@ -0,0 +1,63 @@ +"use strict"; +var __assign = (this && this.__assign) || function () { + __assign = Object.assign || function(t) { + for (var s, i = 1, n = arguments.length; i < n; i++) { + s = arguments[i]; + for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) + t[p] = s[p]; + } + return t; + }; + return __assign.apply(this, arguments); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +var fs = require("fs"); +var utils_1 = require("./utils"); +/** + * Uses the rc file and rc environment to get env vars + */ +function useRCFile(_a) { + var environments = _a.environments, path = _a.path; + var absolutePath = utils_1.resolveEnvFilePath(path); + console.log(absolutePath); + if (!fs.existsSync(absolutePath)) { + throw new Error('Invalid .rc file path.'); + } + var fileData = fs.readFileSync(absolutePath, { encoding: 'utf8' }); + var parsedData = parseRCFile(fileData); + if (environments.length === 1 && !parsedData[environments[0]]) { + console.error("Error:\n Could not find environment:\n " + environments[0] + "\n in .rc file:\n " + absolutePath); + throw new Error("Missing environment " + environments[0] + " in .env-cmdrc file."); + } + // Parse and merge multiple rc environments together + var result = {}; + var environmentFound = false; + environments.forEach(function (name) { + var envVars = parsedData[name]; + if (envVars) { + environmentFound = true; + result = __assign({}, result, envVars); + } + }); + if (!environmentFound) { + console.error("Error:\n Could not find any environments:\n " + environments + "\n in .rc file:\n " + absolutePath); + throw new Error("All environments (" + environments + ") are missing in in .rc file (" + absolutePath + ")."); + } + return result; +} +exports.useRCFile = useRCFile; +/** + * Reads and parses the .env-cmdrc file + */ +function parseRCFile(fileData) { + var data; + try { + data = JSON.parse(fileData); + } + catch (e) { + console.error("Error:\n Could not parse the .env-cmdrc file.\n Please make sure its in a valid JSON format."); + throw new Error("Unable to parse JSON in .env-cmdrc file."); + } + return data; +} +exports.parseRCFile = parseRCFile; diff --git a/lib/signal-termination.js b/lib/signal-termination.js new file mode 100644 index 0000000..d44e221 --- /dev/null +++ b/lib/signal-termination.js @@ -0,0 +1,74 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +var program = require("commander"); +var SIGNALS_TO_HANDLE = [ + 'SIGINT', 'SIGTERM', 'SIGHUP' +]; +var terminateSpawnedProcessFuncHandlers = {}; +var sharedState = { + exitCalled: false +}; +function handleTermSignals(proc) { + // Handle a few special signals and then the general node exit event + // on both parent and spawned process + SIGNALS_TO_HANDLE.forEach(function (signal) { + terminateSpawnedProcessFuncHandlers[signal] = + function (signal, code) { return terminateSpawnedProc(proc, signal, sharedState, code); }; + process.once(signal, terminateSpawnedProcessFuncHandlers[signal]); + }); + process.once('exit', terminateSpawnedProcessFuncHandlers['SIGTERM']); + var terminateProcessFuncHandler = function (code, signal) { return terminateParentProcess(code, signal, sharedState); }; + proc.on('exit', terminateProcessFuncHandler); +} +exports.handleTermSignals = handleTermSignals; +/** + * Helper for terminating the spawned process + */ +function terminateSpawnedProc(proc, signal, sharedState, code) { + removeProcessListeners(); + if (!sharedState.exitCalled) { + sharedState.exitCalled = true; + proc.kill(signal); + } + if (code) { + return process.exit(code); + } + process.kill(process.pid, signal); +} +exports.terminateSpawnedProc = terminateSpawnedProc; +/** + * Helper for terminating the parent process + */ +function terminateParentProcess(code, signal, sharedState) { + removeProcessListeners(); + if (!sharedState.exitCalled) { + sharedState.exitCalled = true; + if (signal) { + return process.kill(process.pid, signal); + } + process.exit(code); + } +} +exports.terminateParentProcess = terminateParentProcess; +/** + * Helper for removing all termination signal listeners from parent process + */ +function removeProcessListeners() { + SIGNALS_TO_HANDLE.forEach(function (signal) { + process.removeListener(signal, terminateSpawnedProcessFuncHandlers[signal]); + }); + process.removeListener('exit', terminateSpawnedProcessFuncHandlers['SIGTERM']); +} +exports.removeProcessListeners = removeProcessListeners; +/** + * General exception handler + */ +function handleUncaughtExceptions(e) { + if (e.message.match(/passed/gi)) { + console.log(program.outputHelp()); + } + console.log(e.message); + process.exit(1); +} +exports.handleUncaughtExceptions = handleUncaughtExceptions; +process.on('uncaughtException', handleUncaughtExceptions); diff --git a/lib/utils.js b/lib/utils.js new file mode 100644 index 0000000..42af268 --- /dev/null +++ b/lib/utils.js @@ -0,0 +1,23 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +var path = require("path"); +var os = require("os"); +/** + * A simple function for resolving the path the user entered + */ +function resolveEnvFilePath(userPath) { + // Make sure a home directory exist + var home = os.homedir(); + if (home) { + userPath = userPath.replace(/^~($|\/|\\)/, home + "$1"); + } + return path.resolve(process.cwd(), userPath); +} +exports.resolveEnvFilePath = resolveEnvFilePath; +/** + * A simple function that parses a comma separated string into an array of strings + */ +function parseArgList(list) { + return list.split(','); +} +exports.parseArgList = parseArgList; diff --git a/package.json b/package.json index f58a210..df43f9d 100644 --- a/package.json +++ b/package.json @@ -10,11 +10,12 @@ "env-cmd": "bin/env-cmd.js" }, "scripts": { - "test": "mocha", + "test": "mocha -r ts-node/register ./**/*.ts", "test-cover": "nyc --reporter=lcov --reporter=text npm test", "test-lint": "standard", "coveralls": "coveralls < coverage/lcov.info", - "lint": "standard --fix" + "lint": "standard --env mocha --fix ./**/*.ts", + "build": "tsc" }, "repository": { "type": "git", @@ -41,15 +42,28 @@ }, "homepage": "https://github.com/toddbluhm/env-cmd#readme", "dependencies": { + "commander": "^2.19.0", "cross-spawn": "^6.0.5" }, "devDependencies": { + "@types/cross-spawn": "^6.0.0", + "@types/node": "^10.12.20", + "@typescript-eslint/eslint-plugin": "^1.1.1", + "@typescript-eslint/parser": "^1.1.1", "better-assert": "^1.0.2", "coveralls": "^3.0.1", "mocha": "^5.1.1", "nyc": "^13.1.0", "proxyquire": "^2.0.1", - "sinon": "^7.0.0", - "standard": "^12.0.1" + "sinon": "^7.2.3", + "standard": "^12.0.1", + "ts-node": "^8.0.2", + "typescript": "~3.2.1" + }, + "standard": { + "parser": "@typescript-eslint/parser", + "plugins": [ + "@typescript-eslint" + ] } } diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..d2ac565 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,35 @@ +import * as spawn from 'cross-spawn' +import { handleTermSignals } from './signal-termination' +import { parseArgs } from './parse-args' + +/** + * The main process for reading, parsing, applying and then running the process with env vars + */ +function EnvCmd (args: string[]): { [key: string]: any } { + // First Parse the args from the command line + const parsedArgs = parseArgs(args) + + let env + // Override the merge order if --no-override flag set + if (parsedArgs.options.noOverride) { + env = Object.assign({}, parsedArgs.envValues, process.env) + } else { + // Add in the system environment variables to our environment list + env = Object.assign({}, process.env, parsedArgs.envValues) + } + + // Execute the command with the given environment variables + const proc = spawn(parsedArgs.command, parsedArgs.commandArgs, { + stdio: 'inherit', + env + }) + + // Handle any termination signals for parent and child proceses + handleTermSignals(proc) + + return env +} + +module.exports = { + EnvCmd +} diff --git a/src/parse-args.ts b/src/parse-args.ts new file mode 100644 index 0000000..6f5626f --- /dev/null +++ b/src/parse-args.ts @@ -0,0 +1,110 @@ +import * as program from 'commander' +import { parseArgList } from './utils' +import { useRCFile } from './parse-rc-file' +import { useCmdLine } from './parse-env-file' + +const RC_FILE_DEFAULT_LOCATIONS = ['./.env-cmdrc', './.env-cmdrc.json'] +const ENV_FILE_DEFAULT_LOCATION = './.env' + +type parseArgsReturnVal = { + envValues: { [key: string]: any } + command: string + commandArgs: string[] + options: { + noOverride: boolean + } +} + +/** +* Parses the arguments passed into the cli +*/ +export function parseArgs (args: string[]): parseArgsReturnVal { + program + .version('1.0.0', '-v, --version') + .usage('[options] [...args]') + .option('-f, --file [path]', 'Custom .env file location') + .option('-r, --rc-file [path]', 'Custom .env-cmdrc file location') + .option('-e, --environments [env...]', 'The rc-file environment to select', parseArgList) + .option('--fallback', 'Enables auto fallback to default env file location') + .option('--no-override', 'Do not override existing env vars') + .parse(['_', '_', ...args]) + + // get the command and command args + const command = program.args[0] + const commandArgs = program.args.slice(1) + const noOverride = !program.override + + const returnValue: any = { + command, + commandArgs, + options: { + noOverride + } + } + + // Check for rc file usage + let env + if (program.environments) { + // user provided an .rc file path + if (program.rcFile) { + try { + env = useRCFile({ environments: program.environments, path: program.rcFile }) + } catch (e) { + console.log(program.outputHelp()) + throw new Error(`Unable to locate .rc file at location (${program.rcFile})`) + } + // Use the default .rc file locations + } else { + for (const path of RC_FILE_DEFAULT_LOCATIONS) { + try { + env = useRCFile({ environments: program.environments, path }) + break + } catch (e) {} + } + if (!env) { + console.log(program.outputHelp()) + throw new Error(`Unable to locate .rc file at default locations (${RC_FILE_DEFAULT_LOCATIONS})`) + } + } + + if (env) { + returnValue.envValues = env + return returnValue + } + } + + // Use env file + if (program.file) { + try { + env = useCmdLine(program.file) + } catch (e) { + if (!program.fallback) { + console.log(program.outputHelp()) + console.error(`Unable to locate env file at location (${program.file})`) + throw e + } + } + + if (env) { + returnValue.envValues = env + return returnValue + } + } + + // Use the default env file location + try { + env = useCmdLine(ENV_FILE_DEFAULT_LOCATION) + } catch (e) { + console.log(program.outputHelp()) + console.error(`Unable to locate env file at default locations (${ENV_FILE_DEFAULT_LOCATION})`) + throw e + } + + if (env) { + returnValue.envValues = env + return returnValue + } + + console.log(program.outputHelp()) + throw Error('Unable to locate any files to read environment data!') +} diff --git a/src/parse-env-file.ts b/src/parse-env-file.ts new file mode 100644 index 0000000..f37c3d5 --- /dev/null +++ b/src/parse-env-file.ts @@ -0,0 +1,79 @@ + +import * as fs from 'fs' +import * as path from 'path' +import { resolveEnvFilePath } from './utils' + +/** + * Uses the cli passed env file path to get env vars + */ +export function useCmdLine (envFilePath: string): { [key: string]: any } { + const absolutePath = resolveEnvFilePath(envFilePath) + if (!fs.existsSync(absolutePath)) { + throw new Error(`Invalid env file path (${envFilePath}).`) + } + + // Get the file extension + const ext = path.extname(absolutePath).toLowerCase() + let env + if (ext === '.json') { + env = require(absolutePath) + } else { + const file = fs.readFileSync(absolutePath, { encoding: 'utf8' }) + env = parseEnvString(file) + } + return env +} + +/** + * Parse out all env vars from a given env file string and return an object + */ +export function parseEnvString (envFileString: string): { [key: string]: string } { + // First thing we do is stripe out all comments + envFileString = stripComments(envFileString.toString()) + + // Next we stripe out all the empty lines + envFileString = stripEmptyLines(envFileString) + + // Merge the file env vars with the current process env vars (the file vars overwrite process vars) + return parseEnvVars(envFileString) +} + +/** + * Parse out all env vars from an env file string + */ +export function parseEnvVars (envString: string): { [key: string]: string } { + const envParseRegex = /^((.+?)[=](.*))$/gim + const matches: { [key: string]: string } = {} + let match + while ((match = envParseRegex.exec(envString)) !== null) { + // Note: match[1] is the full env=var line + const key = match[2].trim() + const value = match[3].trim() || '' + + // remove any surrounding quotes + matches[key] = value.replace(/(^['"]|['"]$)/g, '') + } + return matches +} + +/** + * Strips out comments from env file string + */ +export function stripComments (envString: string): string { + const commentsRegex = /(^#.*$)/gim + let match = commentsRegex.exec(envString) + let newString = envString + while (match != null) { + newString = newString.replace(match[1], '') + match = commentsRegex.exec(envString) + } + return newString +} + +/** + * Strips out newlines from env file string + */ +export function stripEmptyLines (envString: string): string { + const emptyLinesRegex = /(^\n)/gim + return envString.replace(emptyLinesRegex, '') +} diff --git a/src/parse-rc-file.ts b/src/parse-rc-file.ts new file mode 100644 index 0000000..20da090 --- /dev/null +++ b/src/parse-rc-file.ts @@ -0,0 +1,68 @@ +import * as fs from 'fs' +import { resolveEnvFilePath } from './utils' + +/** + * Uses the rc file and rc environment to get env vars + */ +export function useRCFile ( + { environments, path }: + { environments: string[], path: string } +): { [key: string]: any } { + const absolutePath = resolveEnvFilePath(path) + console.log(absolutePath) + if (!fs.existsSync(absolutePath)) { + throw new Error('Invalid .rc file path.') + } + const fileData = fs.readFileSync(absolutePath, { encoding: 'utf8' }) + const parsedData = parseRCFile(fileData) + + if (environments.length === 1 && !parsedData[environments[0]]) { + console.error(`Error: + Could not find environment: + ${environments[0]} + in .rc file: + ${absolutePath}`) + throw new Error(`Missing environment ${environments[0]} in .env-cmdrc file.`) + } + + // Parse and merge multiple rc environments together + let result = {} + let environmentFound = false + environments.forEach(name => { + const envVars = parsedData[name] + if (envVars) { + environmentFound = true + result = { + ...result, + ...envVars + } + } + }) + + if (!environmentFound) { + console.error(`Error: + Could not find any environments: + ${environments} + in .rc file: + ${absolutePath}`) + throw new Error(`All environments (${environments}) are missing in in .rc file (${absolutePath}).`) + } + + return result +} + +/** + * Reads and parses the .env-cmdrc file + */ +export function parseRCFile (fileData: string): { [key: string]: any } { + let data + try { + data = JSON.parse(fileData) + } catch (e) { + console.error(`Error: + Could not parse the .env-cmdrc file. + Please make sure its in a valid JSON format.`) + throw new Error(`Unable to parse JSON in .env-cmdrc file.`) + } + return data +} diff --git a/src/signal-termination.ts b/src/signal-termination.ts new file mode 100644 index 0000000..bf0e5b3 --- /dev/null +++ b/src/signal-termination.ts @@ -0,0 +1,84 @@ +import { ChildProcess } from 'child_process' // eslint-disable-line +import * as program from 'commander' + +const SIGNALS_TO_HANDLE: NodeJS.Signals[] = [ + 'SIGINT', 'SIGTERM', 'SIGHUP' +] + +const terminateSpawnedProcessFuncHandlers: {[key: string]: any } = {} +const sharedState = { + exitCalled: false +} + +export function handleTermSignals (proc: ChildProcess) { + // Handle a few special signals and then the general node exit event + // on both parent and spawned process + SIGNALS_TO_HANDLE.forEach(signal => { + terminateSpawnedProcessFuncHandlers[signal] = + (signal: any, code: any) => terminateSpawnedProc(proc, signal, sharedState, code) + process.once(signal, terminateSpawnedProcessFuncHandlers[signal]) + }) + process.once('exit', terminateSpawnedProcessFuncHandlers['SIGTERM']) + + const terminateProcessFuncHandler = + (code: any, signal: any) => terminateParentProcess(code, signal, sharedState) + proc.on('exit', terminateProcessFuncHandler) +} + +/** + * Helper for terminating the spawned process + */ +export function terminateSpawnedProc ( + proc: ChildProcess, signal: string, sharedState: any, code?: number +) { + removeProcessListeners() + + if (!sharedState.exitCalled) { + sharedState.exitCalled = true + proc.kill(signal) + } + + if (code) { + return process.exit(code) + } + + process.kill(process.pid, signal) +} + +/** + * Helper for terminating the parent process + */ +export function terminateParentProcess (code: number, signal: string, sharedState: any) { + removeProcessListeners() + + if (!sharedState.exitCalled) { + sharedState.exitCalled = true + if (signal) { + return process.kill(process.pid, signal) + } + process.exit(code) + } +} + +/** + * Helper for removing all termination signal listeners from parent process + */ +export function removeProcessListeners () { + SIGNALS_TO_HANDLE.forEach(signal => { + process.removeListener(signal, terminateSpawnedProcessFuncHandlers[signal]) + }) + process.removeListener('exit', terminateSpawnedProcessFuncHandlers['SIGTERM']) +} + +/** + * General exception handler + */ +export function handleUncaughtExceptions (e: Error) { + if (e.message.match(/passed/gi)) { + console.log(program.outputHelp()) + } + console.log(e.message) + process.exit(1) +} + +process.on('uncaughtException', handleUncaughtExceptions) diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..362b91f --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,20 @@ +import * as path from 'path' +import * as os from 'os' + +/** + * A simple function for resolving the path the user entered + */ +export function resolveEnvFilePath (userPath: string): string { + // Make sure a home directory exist + const home = os.homedir() + if (home) { + userPath = userPath.replace(/^~($|\/|\\)/, `${home}$1`) + } + return path.resolve(process.cwd(), userPath) +} +/** + * A simple function that parses a comma separated string into an array of strings + */ +export function parseArgList (list: string): string[] { + return list.split(',') +} diff --git a/test/index.ts b/test/index.ts new file mode 100644 index 0000000..79a5a68 --- /dev/null +++ b/test/index.ts @@ -0,0 +1,417 @@ +// import * as assert from 'better-assert' +// import { describe, it, afterEach, beforeEach, before, after } from 'mocha' +// import * as path from 'path' +// import * as proxyquire from 'proxyquire' +// import * as sinon from 'sinon' +// import * as fs from 'fs' + +// let userHomeDir = '/Users/hitchhikers-guide-to-the-galaxy' +// const spawnStub = sinon.spy(() => ({ +// on: sinon.stub(), +// exit: sinon.stub(), +// kill: sinon.stub() +// })) + +// const lib = proxyquire('../lib', { +// 'cross-spawn': { +// spawn: spawnStub +// }, +// [path.resolve(process.cwd(), 'test/.env.json')]: { +// BOB: 'COOL', +// NODE_ENV: 'dev', +// ANSWER: '42' +// }, +// [path.resolve(process.cwd(), 'test/.env.js')]: { +// BOB: 'COOL', +// NODE_ENV: 'dev', +// ANSWER: '42' +// }, +// 'os': { +// homedir: () => userHomeDir +// } +// }) +// const EnvCmd = lib.EnvCmd +// const parseArgs = lib.parseArgs +// const ParseEnvString = lib.ParseEnvString +// const StripComments = lib.StripComments +// const StripEmptyLines = lib.StripEmptyLines +// const ParseEnvVars = lib.ParseEnvVars +// const ResolveEnvFilePath = lib.ResolveEnvFilePath + +// describe('env-cmd', function () { +// describe('parseArgs', function () { +// it('should parse out --no-override option ', function () { +// const parsedArgs = parseArgs(['--no-override', './test/envFile', 'command', 'cmda1', 'cmda2']) +// assert(parsedArgs.option. === true) +// }) + +// it('should parse out the envfile', function () { +// const parsedArgs = ParseArgs(['./test/envFile', 'command', 'cmda1', 'cmda2']) +// assert(parsedArgs.envFile === './test/envFile') +// }) + +// it('should parse out the command', function () { +// const parsedArgs = ParseArgs(['./test/envFile', 'command', 'cmda1', 'cmda2']) +// assert(parsedArgs.command === 'command') +// }) + +// it('should parse out the command args', function () { +// const parsedArgs = ParseArgs(['./test/envFile', 'command', 'cmda1', 'cmda2']) +// assert(parsedArgs.commandArgs.length === 2) +// assert(parsedArgs.commandArgs[0] === 'cmda1') +// assert(parsedArgs.commandArgs[1] === 'cmda2') +// }) + +// it('should error out if incorrect number of args passed', function () { +// try { +// ParseArgs(['./test/envFile']) +// } catch (e) { +// assert(e.message === 'Error! Too few arguments passed to env-cmd.') +// return +// } +// assert(!'No exception thrown') +// }) +// }) + +// describe('ParseEnvString', function () { +// it('should parse env vars and merge (overwrite) with process.env vars', function () { +// const env = ParseEnvString('BOB=COOL\nNODE_ENV=dev\nANSWER=42\n') +// assert(env.BOB === 'COOL') +// assert(env.NODE_ENV === 'dev') +// assert(env.ANSWER === '42') +// }) +// }) + +// describe('StripComments', function () { +// it('should strip out all full line comments', function () { +// const envString = StripComments('#BOB=COOL\nNODE_ENV=dev\nANSWER=42 AND COUNTING\n#AnotherComment\n') +// assert(envString === '\nNODE_ENV=dev\nANSWER=42 AND COUNTING\n\n') +// }) +// }) + +// describe('StripEmptyLines', function () { +// it('should strip out all empty lines', function () { +// const envString = StripEmptyLines('\nBOB=COOL\n\nNODE_ENV=dev\n\nANSWER=42 AND COUNTING\n\n') +// assert(envString === 'BOB=COOL\nNODE_ENV=dev\nANSWER=42 AND COUNTING\n') +// }) +// }) + +// describe('ParseEnvVars', function () { +// it('should parse out all env vars in string when not ending with \'\\n\'', function () { +// const envVars = ParseEnvVars('BOB=COOL\nNODE_ENV=dev\nANSWER=42 AND COUNTING') +// assert(envVars.BOB === 'COOL') +// assert(envVars.NODE_ENV === 'dev') +// assert(envVars.ANSWER === '42 AND COUNTING') +// }) + +// it('should parse out all env vars in string with format \'key=value\'', function () { +// const envVars = ParseEnvVars('BOB=COOL\nNODE_ENV=dev\nANSWER=42 AND COUNTING\n') +// assert(envVars.BOB === 'COOL') +// assert(envVars.NODE_ENV === 'dev') +// assert(envVars.ANSWER === '42 AND COUNTING') +// }) + +// it('should ignore invalid lines', function () { +// const envVars = ParseEnvVars('BOB=COOL\nTHISIS$ANDINVALIDLINE\nANSWER=42 AND COUNTING\n') +// assert(Object.keys(envVars).length === 2) +// assert(envVars.BOB === 'COOL') +// assert(envVars.ANSWER === '42 AND COUNTING') +// }) + +// it('should default an empty value to an empty string', function () { +// const envVars = ParseEnvVars('EMPTY=\n') +// assert(envVars.EMPTY === '') +// }) + +// it('should escape double quoted values', function () { +// const envVars = ParseEnvVars('DOUBLE_QUOTES="double_quotes"\n') +// assert(envVars.DOUBLE_QUOTES === 'double_quotes') +// }) + +// it('should escape single quoted values', function () { +// const envVars = ParseEnvVars('SINGLE_QUOTES=\'single_quotes\'\n') +// assert(envVars.SINGLE_QUOTES === 'single_quotes') +// }) + +// it('should preserve embedded double quotes', function () { +// const envVars = ParseEnvVars('DOUBLE=""""\nDOUBLE_ONE=\'"double_one"\'\nDOUBLE_TWO=""double_two""\n') +// assert(envVars.DOUBLE === '""') +// assert(envVars.DOUBLE_ONE === '"double_one"') +// assert(envVars.DOUBLE_TWO === '"double_two"') +// }) + +// it('should preserve embedded single quotes', function () { +// const envVars = ParseEnvVars('SINGLE=\'\'\'\'\nSINGLE_ONE=\'\'single_one\'\'\nSINGLE_TWO="\'single_two\'"\n') +// assert(envVars.SINGLE === '\'\'') +// assert(envVars.SINGLE_ONE === '\'single_one\'') +// assert(envVars.SINGLE_TWO === '\'single_two\'') +// }) + +// it('should parse out all env vars ignoring spaces around = sign', function () { +// const envVars = ParseEnvVars('BOB = COOL\nNODE_ENV =dev\nANSWER= 42 AND COUNTING') +// assert(envVars.BOB === 'COOL') +// assert(envVars.NODE_ENV === 'dev') +// assert(envVars.ANSWER === '42 AND COUNTING') +// }) + +// it('should parse out all env vars ignoring spaces around = sign', function () { +// const envVars = ParseEnvVars('BOB = "COOL "\nNODE_ENV = dev\nANSWER= \' 42 AND COUNTING\'') +// assert(envVars.BOB === 'COOL ') +// assert(envVars.NODE_ENV === 'dev') +// assert(envVars.ANSWER === ' 42 AND COUNTING') +// }) +// }) + +// describe('JSON and JS format support', function () { +// before(function () { +// this.readFileStub = sinon.stub(fs, 'readFileSync') +// proxyquire.noCallThru() +// }) +// after(function () { +// this.readFileStub.restore() +// proxyquire.callThru() +// }) +// afterEach(function () { +// spawnStub.resetHistory() +// }) +// it('should parse env vars from JSON with node module loader if file extension is .json', function () { +// EnvCmd(['./test/.env.json', 'echo', '$BOB']) +// assert(spawnStub.args[0][0] === 'echo') +// assert(spawnStub.args[0][1][0] === '$BOB') +// assert(spawnStub.args[0][2].env.BOB === 'COOL') +// assert(spawnStub.args[0][2].env.NODE_ENV === 'dev') +// assert(spawnStub.args[0][2].env.ANSWER === '42') +// }) +// it('should parse env vars from JavaScript with node module loader if file extension is .js', function () { +// EnvCmd(['./test/.env.js', 'echo', '$BOB']) +// assert(spawnStub.args[0][0] === 'echo') +// assert(spawnStub.args[0][1][0] === '$BOB') +// assert(spawnStub.args[0][2].env.BOB === 'COOL') +// assert(spawnStub.args[0][2].env.NODE_ENV === 'dev') +// assert(spawnStub.args[0][2].env.ANSWER === '42') +// }) +// }) + +// describe('.RC file support (.env-cmdrc)', function () { +// before(function () { +// this.readFileStub = sinon.stub(fs, 'readFileSync') +// this.readFileStub.returns(`{ +// "development": { +// "BOB": "COOL", +// "NODE_ENV": "dev", +// "ANSWER": "42", +// "TEST_CASES": true +// }, +// "production": { +// "BOB": "COOL", +// "NODE_ENV": "prod", +// "ANSWER": "43" +// } +// }`) +// this.existsSyncStub = sinon.stub(fs, 'existsSync') +// this.existsSyncStub.returns(true) +// proxyquire.noCallThru() +// }) +// after(function () { +// this.readFileStub.restore() +// this.existsSyncStub.restore() +// proxyquire.callThru() +// }) +// afterEach(function () { +// spawnStub.resetHistory() +// }) +// it('should parse env vars from .env-cmdrc file using development env', function () { +// EnvCmd(['development', 'echo', '$BOB']) +// assert(spawnStub.args[0][0] === 'echo') +// assert(spawnStub.args[0][1][0] === '$BOB') +// assert(spawnStub.args[0][2].env.BOB === 'COOL') +// assert(spawnStub.args[0][2].env.NODE_ENV === 'dev') +// assert(spawnStub.args[0][2].env.ANSWER === '42') +// }) + +// it('should parse env vars from .env-cmdrc file using production env', function () { +// EnvCmd(['production', 'echo', '$BOB']) +// assert(spawnStub.args[0][0] === 'echo') +// assert(spawnStub.args[0][1][0] === '$BOB') +// assert(spawnStub.args[0][2].env.BOB === 'COOL') +// assert(spawnStub.args[0][2].env.NODE_ENV === 'prod') +// assert(spawnStub.args[0][2].env.ANSWER === '43') +// }) + +// it('should throw error if env not in .rc file', function () { +// try { +// EnvCmd(['staging', 'echo', '$BOB']) +// assert(!'Should throw missing environment error.') +// } catch (e) { +// assert(e.message.includes('staging')) +// assert(e.message.includes(`.env-cmdrc`)) +// } +// }) + +// it('should parse env vars from .env-cmdrc file using both development and production env', function () { +// EnvCmd(['development,production', 'echo', '$BOB']) +// assert(spawnStub.args[0][0] === 'echo') +// assert(spawnStub.args[0][1][0] === '$BOB') +// assert(spawnStub.args[0][2].env.BOB === 'COOL') +// assert(spawnStub.args[0][2].env.NODE_ENV === 'prod') +// assert(spawnStub.args[0][2].env.ANSWER === '43') +// assert(spawnStub.args[0][2].env.TEST_CASES === true) +// }) + +// it('should parse env vars from .env-cmdrc file using both development and production env in reverse order', function () { +// EnvCmd(['production,development', 'echo', '$BOB']) +// assert(spawnStub.args[0][0] === 'echo') +// assert(spawnStub.args[0][1][0] === '$BOB') +// assert(spawnStub.args[0][2].env.BOB === 'COOL') +// assert(spawnStub.args[0][2].env.NODE_ENV === 'dev') +// assert(spawnStub.args[0][2].env.ANSWER === '42') +// assert(spawnStub.args[0][2].env.TEST_CASES === true) +// }) + +// it('should not fail if only one environment name exists', function () { +// EnvCmd(['production,test', 'echo', '$BOB']) +// assert(spawnStub.args[0][0] === 'echo') +// assert(spawnStub.args[0][1][0] === '$BOB') +// assert(spawnStub.args[0][2].env.BOB === 'COOL') +// assert(spawnStub.args[0][2].env.NODE_ENV === 'prod') +// assert(spawnStub.args[0][2].env.ANSWER === '43') +// }) + +// it('should throw error if .rc file is not valid JSON', function () { +// this.readFileStub.returns(`{ +// "development": { +// "BOB": "COOL", +// "NODE_ENV": "dev", +// "ANSWER": "42" +// }, +// "production": { +// "BOB": 'COOL', +// "NODE_ENV": "prod", +// "ANSWER": "43" +// } +// }`) +// try { +// EnvCmd(['staging', 'echo', '$BOB']) +// assert(!'Should throw invalid JSON error.') +// } catch (e) { +// assert(e.message.includes(`.env-cmdrc`)) +// assert(e.message.includes(`parse`)) +// assert(e.message.includes(`JSON`)) +// } +// }) +// }) + +// describe('EnvCmd', function () { +// before(function () { +// this.readFileStub = sinon.stub(fs, 'readFileSync') +// }) +// after(function () { +// this.readFileStub.restore() +// }) +// afterEach(function () { +// spawnStub.resetHistory() +// this.readFileStub.resetHistory() +// process.removeAllListeners() +// }) +// it('should spawn a new process with the env vars set', function () { +// this.readFileStub.returns('BOB=COOL\nNODE_ENV=dev\nANSWER=42\n') +// EnvCmd(['./test/.env', 'echo', '$BOB']) +// assert(this.readFileStub.args[0][0] === path.join(process.cwd(), 'test/.env')) +// assert(spawnStub.args[0][0] === 'echo') +// assert(spawnStub.args[0][1][0] === '$BOB') +// assert(spawnStub.args[0][2].env.BOB === 'COOL') +// assert(spawnStub.args[0][2].env.NODE_ENV === 'dev') +// assert(spawnStub.args[0][2].env.ANSWER === '42') +// }) + +// it('should spawn a new process without overriding shell env vars', function () { +// process.env.NODE_ENV = 'development' +// process.env.BOB = 'SUPERCOOL' +// this.readFileStub.returns('BOB=COOL\nNODE_ENV=dev\nANSWER=42\n') +// EnvCmd(['--no-override', './test/.env', 'echo', '$BOB']) +// assert(this.readFileStub.args[0][0] === path.join(process.cwd(), 'test/.env')) +// assert(spawnStub.args[0][0] === 'echo') +// assert(spawnStub.args[0][1][0] === '$BOB') +// assert(spawnStub.args[0][2].env.BOB === 'SUPERCOOL') +// assert(spawnStub.args[0][2].env.NODE_ENV === 'development') +// assert(spawnStub.args[0][2].env.ANSWER === '42') +// }) + +// it('should throw error if file and fallback does not exist with --fallback option', function () { +// this.readFileStub.restore() + +// try { +// EnvCmd(['--fallback', './test/.non-existent-file', 'echo', '$BOB']) +// } catch (e) { +// const resolvedPath = path.join(process.cwd(), '.env') +// assert(e.message === `Error! Could not find fallback file or read env file at ${resolvedPath}`) +// return +// } +// assert(!'No exception thrown') +// }) + +// it('should execute successfully if no env file found', function () { +// this.readFileStub.restore() +// process.env.NODE_ENV = 'dev' +// process.env.ANSWER = '42' +// delete process.env.BOB + +// EnvCmd(['./test/.non-existent-file', 'echo', '$BOB']) +// assert(spawnStub.args[0][0] === 'echo') +// assert(spawnStub.args[0][1][0] === '$BOB') +// assert(spawnStub.args[0][2].env.BOB === undefined) +// assert(spawnStub.args[0][2].env.NODE_ENV === 'dev') +// assert(spawnStub.args[0][2].env.ANSWER === '42') +// }) +// }) + +// describe('PrintHelp', function () { +// it('should return help text when run', function () { +// const helpText = PrintHelp() +// assert(typeof helpText === 'string') +// assert(helpText.match(/Usage/g).length !== 0) +// assert(helpText.match(/env-cmd/).length !== 0) +// assert(helpText.match(/env_file/).length !== 0) +// assert(helpText.match(/env_name/).length !== 0) +// }) +// }) + +// describe('ResolveEnvFilePath', function () { +// beforeEach(function () { +// this.cwdStub = sinon.stub(process, 'cwd') +// this.cwdStub.returns('/Users/hitchhikers-guide-to-the-galaxy/Thanks') +// }) +// afterEach(function () { +// this.cwdStub.restore() +// }) +// it('should add "fish.env" to the end of the current directory', function () { +// const abPath = ResolveEnvFilePath('fish.env') +// assert(abPath === '/Users/hitchhikers-guide-to-the-galaxy/Thanks/fish.env') +// }) +// it('should add "./fish.env" to the end of the current directory', function () { +// const abPath = ResolveEnvFilePath('./fish.env') +// assert(abPath === '/Users/hitchhikers-guide-to-the-galaxy/Thanks/fish.env') +// }) +// it('should add "../fish.env" to the end of the current directory', function () { +// const abPath = ResolveEnvFilePath('../fish.env') +// assert(abPath === '/Users/hitchhikers-guide-to-the-galaxy/fish.env') +// }) +// it('should add "for-all-the/fish.env" to the end of the current directory', function () { +// const abPath = ResolveEnvFilePath('for-all-the/fish.env') +// assert(abPath === '/Users/hitchhikers-guide-to-the-galaxy/Thanks/for-all-the/fish.env') +// }) +// it('should set the absolute path to "/thanks/for-all-the/fish.env"', function () { +// const abPath = ResolveEnvFilePath('/thanks/for-all-the/fish.env') +// assert(abPath === '/thanks/for-all-the/fish.env') +// }) +// it('should use "~" to add "fish.env" to the end of user directory', function () { +// const abPath = ResolveEnvFilePath('~/fish.env') +// assert(abPath === '/Users/hitchhikers-guide-to-the-galaxy/fish.env') +// }) +// it('should leave "~" in path if no user home directory found', function () { +// userHomeDir = '' +// const abPath = ResolveEnvFilePath('~/fish.env') +// assert(abPath === '/Users/hitchhikers-guide-to-the-galaxy/Thanks/~/fish.env') +// }) +// }) +// }) diff --git a/test/parse-args.ts b/test/parse-args.ts new file mode 100644 index 0000000..91317cb --- /dev/null +++ b/test/parse-args.ts @@ -0,0 +1,49 @@ +// import * as assert from 'better-assert' +// import * as path from 'path' +// import * as proxyquire from 'proxyquire' +// import * as sinon from 'sinon' +// import * as fs from 'fs' +// import { parseArgs } from '../src/parse-args' + +// let userHomeDir = '/Users/hitchhikers-guide-to-the-galaxy' +// const spawnStub = sinon.spy(() => ({ +// on: sinon.stub(), +// exit: sinon.stub(), +// kill: sinon.stub() +// })) + +// describe('env-cmd', function () { +// describe('parseArgs', function () { +// it('should parse out --no-override option ', function () { +// const parsedArgs = parseArgs(['--no-override', './test/envFile', 'command', 'cmda1', 'cmda2']) +// assert(parsedArgs.options.noOverride === true) +// }) + +// it('should parse out the envfile', function () { +// const parsedArgs = parseArgs(['-f', './test/envFile', 'command', 'cmda1', 'cmda2']) +// assert(parsedArgs.envValues === './test/envFile') +// }) + +// it('should parse out the command', function () { +// const parsedArgs = ParseArgs(['./test/envFile', 'command', 'cmda1', 'cmda2']) +// assert(parsedArgs.command === 'command') +// }) + +// it('should parse out the command args', function () { +// const parsedArgs = ParseArgs(['./test/envFile', 'command', 'cmda1', 'cmda2']) +// assert(parsedArgs.commandArgs.length === 2) +// assert(parsedArgs.commandArgs[0] === 'cmda1') +// assert(parsedArgs.commandArgs[1] === 'cmda2') +// }) + +// it('should error out if incorrect number of args passed', function () { +// try { +// ParseArgs(['./test/envFile']) +// } catch (e) { +// assert(e.message === 'Error! Too few arguments passed to env-cmd.') +// return +// } +// assert(!'No exception thrown') +// }) +// }) +// }) diff --git a/test/signal-termination.ts b/test/signal-termination.ts new file mode 100644 index 0000000..675a12a --- /dev/null +++ b/test/signal-termination.ts @@ -0,0 +1,128 @@ + +import * as assert from 'better-assert' +import * as sinon from 'sinon' +import { + handleUncaughtExceptions, terminateSpawnedProc, terminateParentProcess +} from '../src/signal-termination' + +describe('signal-termination', () => { + describe('handleUncaughtExceptions', function () { + beforeEach(function () { + this.logStub = sinon.stub(console, 'log') + this.processStub = sinon.stub(process, 'exit') + }) + afterEach(function () { + this.logStub.restore() + this.processStub.restore() + }) + it('should print help text and error if error contains \'passed\'', function () { + handleUncaughtExceptions(new Error('print help text passed now')) + assert(this.logStub.calledTwice) + this.logStub.restore() // restore here so test success logs get printed + }) + + it('should print just there error if error does not contain \'passed\'', function () { + handleUncaughtExceptions(new Error('do not print help text now')) + assert(this.logStub.calledOnce) + this.logStub.restore() // restore here so test success logs get printed + }) + }) + + describe('terminateSpawnedProc', function () { + beforeEach(function () { + this.procStub = sinon.stub() + this.exitStub = sinon.stub(process, 'exit') + this.killStub = sinon.stub(process, 'kill') + this.proc = { + kill: this.procStub + } + this.exitCalled = false + }) + + afterEach(function () { + this.exitStub.restore() + this.killStub.restore() + }) + + it('should call kill method on spawned process', function () { + terminateSpawnedProc.call(this, this.proc) + assert(this.procStub.callCount === 1) + }) + + it('should not call kill method more than once', function () { + terminateSpawnedProc.call(this, this.proc) + terminateSpawnedProc.call(this, this.proc) + assert(this.procStub.callCount === 1) + }) + + it('should not call kill method if the spawn process is already dying', function () { + this.exitCalled = true + terminateSpawnedProc.call(this, this.proc) + assert(this.procStub.callCount === 0) + }) + + it('should call kill method on spawned process with correct signal', function () { + terminateSpawnedProc.call(this, this.proc, 'SIGINT') + assert(this.procStub.callCount === 1) + assert(this.procStub.args[0][0] === 'SIGINT') + }) + + it('should call kill method on parent process with correct signal', function () { + terminateSpawnedProc.call(this, this.proc, 'SIGINT') + assert(this.exitStub.callCount === 0) + assert(this.killStub.callCount === 1) + assert(this.killStub.args[0][1] === 'SIGINT') + }) + + it('should call exit method on parent process with correct exit code', function () { + terminateSpawnedProc.call(this, this.proc, 'SIGINT', 1) + assert(this.killStub.callCount === 0) + assert(this.exitStub.callCount === 1) + assert(this.exitStub.args[0][0] === 1) + }) + }) + + describe('terminateParentProcess', function () { + beforeEach(function () { + this.exitStub = sinon.stub(process, 'exit') + this.killStub = sinon.stub(process, 'kill') + this.exitCalled = false + }) + + afterEach(function () { + this.exitStub.restore() + this.killStub.restore() + }) + + it('should call exit method on parent process', function () { + terminateParentProcess.call(this) + assert(this.exitStub.callCount === 1) + }) + + it('should not call exit method more than once', function () { + terminateParentProcess.call(this) + terminateParentProcess.call(this) + assert(this.exitStub.callCount === 1) + }) + + it('should not call exit method if the process is already dying', function () { + this.exitCalled = true + terminateParentProcess.call(this) + assert(this.exitStub.callCount === 0) + }) + + it('should call exit method with correct error code', function () { + terminateParentProcess.call(this, 0) + assert(this.killStub.callCount === 0) + assert(this.exitStub.callCount === 1) + assert(this.exitStub.args[0][0] === 0) + }) + + it('should call kill method with correct kill signal', function () { + terminateParentProcess.call(this, null, 'SIGINT') + assert(this.killStub.callCount === 1) + assert(this.exitStub.callCount === 0) + assert(this.killStub.args[0][1] === 'SIGINT') + }) + }) +}) diff --git a/test/test.js b/test/test.js deleted file mode 100644 index 7e55c49..0000000 --- a/test/test.js +++ /dev/null @@ -1,548 +0,0 @@ -'use strict' - -const assert = require('better-assert') -const describe = require('mocha').describe -const it = require('mocha').it -const afterEach = require('mocha').afterEach -const beforeEach = require('mocha').beforeEach -const before = require('mocha').before -const after = require('mocha').after -const path = require('path') -const proxyquire = require('proxyquire') -const sinon = require('sinon') -const fs = require('fs') -let userHomeDir = '/Users/hitchhikers-guide-to-the-galaxy' - -const spawnStub = sinon.spy(() => ({ - on: sinon.stub(), - exit: sinon.stub(), - kill: sinon.stub() -})) - -const lib = proxyquire('../lib', { - 'cross-spawn': { - spawn: spawnStub - }, - [path.resolve(process.cwd(), 'test/.env.json')]: { - BOB: 'COOL', - NODE_ENV: 'dev', - ANSWER: '42' - }, - [path.resolve(process.cwd(), 'test/.env.js')]: { - BOB: 'COOL', - NODE_ENV: 'dev', - ANSWER: '42' - }, - 'os': { - homedir: () => userHomeDir - } -}) -const EnvCmd = lib.EnvCmd -const ParseArgs = lib.ParseArgs -const ParseEnvString = lib.ParseEnvString -const PrintHelp = lib.PrintHelp -const HandleUncaughtExceptions = lib.HandleUncaughtExceptions -const StripComments = lib.StripComments -const StripEmptyLines = lib.StripEmptyLines -const ParseEnvVars = lib.ParseEnvVars -const ResolveEnvFilePath = lib.ResolveEnvFilePath -const TerminateSpawnedProc = lib.TerminateSpawnedProc -const TerminateParentProcess = lib.TerminateParentProcess - -describe('env-cmd', function () { - describe('ParseArgs', function () { - it('should parse out --no-override option ', function () { - const parsedArgs = ParseArgs(['--no-override', './test/envFile', 'command', 'cmda1', 'cmda2']) - assert(parsedArgs.noOverride === true) - }) - - it('should parse out the envfile', function () { - const parsedArgs = ParseArgs(['./test/envFile', 'command', 'cmda1', 'cmda2']) - assert(parsedArgs.envFile === './test/envFile') - }) - - it('should parse out the command', function () { - const parsedArgs = ParseArgs(['./test/envFile', 'command', 'cmda1', 'cmda2']) - assert(parsedArgs.command === 'command') - }) - - it('should parse out the command args', function () { - const parsedArgs = ParseArgs(['./test/envFile', 'command', 'cmda1', 'cmda2']) - assert(parsedArgs.commandArgs.length === 2) - assert(parsedArgs.commandArgs[0] === 'cmda1') - assert(parsedArgs.commandArgs[1] === 'cmda2') - }) - - it('should error out if incorrect number of args passed', function () { - try { - ParseArgs(['./test/envFile']) - } catch (e) { - assert(e.message === 'Error! Too few arguments passed to env-cmd.') - return - } - assert(!'No exception thrown') - }) - }) - - describe('ParseEnvString', function () { - it('should parse env vars and merge (overwrite) with process.env vars', function () { - const env = ParseEnvString('BOB=COOL\nNODE_ENV=dev\nANSWER=42\n') - assert(env.BOB === 'COOL') - assert(env.NODE_ENV === 'dev') - assert(env.ANSWER === '42') - }) - }) - - describe('StripComments', function () { - it('should strip out all full line comments', function () { - const envString = StripComments('#BOB=COOL\nNODE_ENV=dev\nANSWER=42 AND COUNTING\n#AnotherComment\n') - assert(envString === '\nNODE_ENV=dev\nANSWER=42 AND COUNTING\n\n') - }) - }) - - describe('StripEmptyLines', function () { - it('should strip out all empty lines', function () { - const envString = StripEmptyLines('\nBOB=COOL\n\nNODE_ENV=dev\n\nANSWER=42 AND COUNTING\n\n') - assert(envString === 'BOB=COOL\nNODE_ENV=dev\nANSWER=42 AND COUNTING\n') - }) - }) - - describe('ParseEnvVars', function () { - it('should parse out all env vars in string when not ending with \'\\n\'', function () { - const envVars = ParseEnvVars('BOB=COOL\nNODE_ENV=dev\nANSWER=42 AND COUNTING') - assert(envVars.BOB === 'COOL') - assert(envVars.NODE_ENV === 'dev') - assert(envVars.ANSWER === '42 AND COUNTING') - }) - - it('should parse out all env vars in string with format \'key=value\'', function () { - const envVars = ParseEnvVars('BOB=COOL\nNODE_ENV=dev\nANSWER=42 AND COUNTING\n') - assert(envVars.BOB === 'COOL') - assert(envVars.NODE_ENV === 'dev') - assert(envVars.ANSWER === '42 AND COUNTING') - }) - - it('should ignore invalid lines', function () { - const envVars = ParseEnvVars('BOB=COOL\nTHISIS$ANDINVALIDLINE\nANSWER=42 AND COUNTING\n') - assert(Object.keys(envVars).length === 2) - assert(envVars.BOB === 'COOL') - assert(envVars.ANSWER === '42 AND COUNTING') - }) - - it('should default an empty value to an empty string', function () { - const envVars = ParseEnvVars('EMPTY=\n') - assert(envVars.EMPTY === '') - }) - - it('should escape double quoted values', function () { - const envVars = ParseEnvVars('DOUBLE_QUOTES="double_quotes"\n') - assert(envVars.DOUBLE_QUOTES === 'double_quotes') - }) - - it('should escape single quoted values', function () { - const envVars = ParseEnvVars('SINGLE_QUOTES=\'single_quotes\'\n') - assert(envVars.SINGLE_QUOTES === 'single_quotes') - }) - - it('should preserve embedded double quotes', function () { - const envVars = ParseEnvVars('DOUBLE=""""\nDOUBLE_ONE=\'"double_one"\'\nDOUBLE_TWO=""double_two""\n') - assert(envVars.DOUBLE === '""') - assert(envVars.DOUBLE_ONE === '"double_one"') - assert(envVars.DOUBLE_TWO === '"double_two"') - }) - - it('should preserve embedded single quotes', function () { - const envVars = ParseEnvVars('SINGLE=\'\'\'\'\nSINGLE_ONE=\'\'single_one\'\'\nSINGLE_TWO="\'single_two\'"\n') - assert(envVars.SINGLE === '\'\'') - assert(envVars.SINGLE_ONE === '\'single_one\'') - assert(envVars.SINGLE_TWO === '\'single_two\'') - }) - - it('should parse out all env vars ignoring spaces around = sign', function () { - const envVars = ParseEnvVars('BOB = COOL\nNODE_ENV =dev\nANSWER= 42 AND COUNTING') - assert(envVars.BOB === 'COOL') - assert(envVars.NODE_ENV === 'dev') - assert(envVars.ANSWER === '42 AND COUNTING') - }) - - it('should parse out all env vars ignoring spaces around = sign', function () { - const envVars = ParseEnvVars('BOB = "COOL "\nNODE_ENV = dev\nANSWER= \' 42 AND COUNTING\'') - assert(envVars.BOB === 'COOL ') - assert(envVars.NODE_ENV === 'dev') - assert(envVars.ANSWER === ' 42 AND COUNTING') - }) - }) - - describe('JSON and JS format support', function () { - before(function () { - this.readFileStub = sinon.stub(fs, 'readFileSync') - proxyquire.noCallThru() - }) - after(function () { - this.readFileStub.restore() - proxyquire.callThru() - }) - afterEach(function () { - spawnStub.resetHistory() - }) - it('should parse env vars from JSON with node module loader if file extension is .json', function () { - EnvCmd(['./test/.env.json', 'echo', '$BOB']) - assert(spawnStub.args[0][0] === 'echo') - assert(spawnStub.args[0][1][0] === '$BOB') - assert(spawnStub.args[0][2].env.BOB === 'COOL') - assert(spawnStub.args[0][2].env.NODE_ENV === 'dev') - assert(spawnStub.args[0][2].env.ANSWER === '42') - }) - it('should parse env vars from JavaScript with node module loader if file extension is .js', function () { - EnvCmd(['./test/.env.js', 'echo', '$BOB']) - assert(spawnStub.args[0][0] === 'echo') - assert(spawnStub.args[0][1][0] === '$BOB') - assert(spawnStub.args[0][2].env.BOB === 'COOL') - assert(spawnStub.args[0][2].env.NODE_ENV === 'dev') - assert(spawnStub.args[0][2].env.ANSWER === '42') - }) - }) - - describe('.RC file support (.env-cmdrc)', function () { - before(function () { - this.readFileStub = sinon.stub(fs, 'readFileSync') - this.readFileStub.returns(`{ - "development": { - "BOB": "COOL", - "NODE_ENV": "dev", - "ANSWER": "42", - "TEST_CASES": true - }, - "production": { - "BOB": "COOL", - "NODE_ENV": "prod", - "ANSWER": "43" - } - }`) - this.existsSyncStub = sinon.stub(fs, 'existsSync') - this.existsSyncStub.returns(true) - proxyquire.noCallThru() - }) - after(function () { - this.readFileStub.restore() - this.existsSyncStub.restore() - proxyquire.callThru() - }) - afterEach(function () { - spawnStub.resetHistory() - }) - it('should parse env vars from .env-cmdrc file using development env', function () { - EnvCmd(['development', 'echo', '$BOB']) - assert(spawnStub.args[0][0] === 'echo') - assert(spawnStub.args[0][1][0] === '$BOB') - assert(spawnStub.args[0][2].env.BOB === 'COOL') - assert(spawnStub.args[0][2].env.NODE_ENV === 'dev') - assert(spawnStub.args[0][2].env.ANSWER === '42') - }) - - it('should parse env vars from .env-cmdrc file using production env', function () { - EnvCmd(['production', 'echo', '$BOB']) - assert(spawnStub.args[0][0] === 'echo') - assert(spawnStub.args[0][1][0] === '$BOB') - assert(spawnStub.args[0][2].env.BOB === 'COOL') - assert(spawnStub.args[0][2].env.NODE_ENV === 'prod') - assert(spawnStub.args[0][2].env.ANSWER === '43') - }) - - it('should throw error if env not in .rc file', function () { - try { - EnvCmd(['staging', 'echo', '$BOB']) - assert(!'Should throw missing environment error.') - } catch (e) { - assert(e.message.includes('staging')) - assert(e.message.includes(`.env-cmdrc`)) - } - }) - - it('should parse env vars from .env-cmdrc file using both development and production env', function () { - EnvCmd(['development,production', 'echo', '$BOB']) - assert(spawnStub.args[0][0] === 'echo') - assert(spawnStub.args[0][1][0] === '$BOB') - assert(spawnStub.args[0][2].env.BOB === 'COOL') - assert(spawnStub.args[0][2].env.NODE_ENV === 'prod') - assert(spawnStub.args[0][2].env.ANSWER === '43') - assert(spawnStub.args[0][2].env.TEST_CASES === true) - }) - - it('should parse env vars from .env-cmdrc file using both development and production env in reverse order', function () { - EnvCmd(['production,development', 'echo', '$BOB']) - assert(spawnStub.args[0][0] === 'echo') - assert(spawnStub.args[0][1][0] === '$BOB') - assert(spawnStub.args[0][2].env.BOB === 'COOL') - assert(spawnStub.args[0][2].env.NODE_ENV === 'dev') - assert(spawnStub.args[0][2].env.ANSWER === '42') - assert(spawnStub.args[0][2].env.TEST_CASES === true) - }) - - it('should not fail if only one environment name exists', function () { - EnvCmd(['production,test', 'echo', '$BOB']) - assert(spawnStub.args[0][0] === 'echo') - assert(spawnStub.args[0][1][0] === '$BOB') - assert(spawnStub.args[0][2].env.BOB === 'COOL') - assert(spawnStub.args[0][2].env.NODE_ENV === 'prod') - assert(spawnStub.args[0][2].env.ANSWER === '43') - }) - - it('should throw error if .rc file is not valid JSON', function () { - this.readFileStub.returns(`{ - "development": { - "BOB": "COOL", - "NODE_ENV": "dev", - "ANSWER": "42" - }, - "production": { - "BOB": 'COOL', - "NODE_ENV": "prod", - "ANSWER": "43" - } - }`) - try { - EnvCmd(['staging', 'echo', '$BOB']) - assert(!'Should throw invalid JSON error.') - } catch (e) { - assert(e.message.includes(`.env-cmdrc`)) - assert(e.message.includes(`parse`)) - assert(e.message.includes(`JSON`)) - } - }) - }) - - describe('EnvCmd', function () { - before(function () { - this.readFileStub = sinon.stub(fs, 'readFileSync') - }) - after(function () { - this.readFileStub.restore() - }) - afterEach(function () { - spawnStub.resetHistory() - this.readFileStub.resetHistory() - process.removeAllListeners() - }) - it('should spawn a new process with the env vars set', function () { - this.readFileStub.returns('BOB=COOL\nNODE_ENV=dev\nANSWER=42\n') - EnvCmd(['./test/.env', 'echo', '$BOB']) - assert(this.readFileStub.args[0][0] === path.join(process.cwd(), 'test/.env')) - assert(spawnStub.args[0][0] === 'echo') - assert(spawnStub.args[0][1][0] === '$BOB') - assert(spawnStub.args[0][2].env.BOB === 'COOL') - assert(spawnStub.args[0][2].env.NODE_ENV === 'dev') - assert(spawnStub.args[0][2].env.ANSWER === '42') - }) - - it('should spawn a new process without overriding shell env vars', function () { - process.env.NODE_ENV = 'development' - process.env.BOB = 'SUPERCOOL' - this.readFileStub.returns('BOB=COOL\nNODE_ENV=dev\nANSWER=42\n') - EnvCmd(['--no-override', './test/.env', 'echo', '$BOB']) - assert(this.readFileStub.args[0][0] === path.join(process.cwd(), 'test/.env')) - assert(spawnStub.args[0][0] === 'echo') - assert(spawnStub.args[0][1][0] === '$BOB') - assert(spawnStub.args[0][2].env.BOB === 'SUPERCOOL') - assert(spawnStub.args[0][2].env.NODE_ENV === 'development') - assert(spawnStub.args[0][2].env.ANSWER === '42') - }) - - it('should throw error if file and fallback does not exist with --fallback option', function () { - this.readFileStub.restore() - - try { - EnvCmd(['--fallback', './test/.non-existent-file', 'echo', '$BOB']) - } catch (e) { - const resolvedPath = path.join(process.cwd(), '.env') - assert(e.message === `Error! Could not find fallback file or read env file at ${resolvedPath}`) - return - } - assert(!'No exception thrown') - }) - - it('should execute successfully if no env file found', function () { - this.readFileStub.restore() - process.env.NODE_ENV = 'dev' - process.env.ANSWER = '42' - delete process.env.BOB - - EnvCmd(['./test/.non-existent-file', 'echo', '$BOB']) - assert(spawnStub.args[0][0] === 'echo') - assert(spawnStub.args[0][1][0] === '$BOB') - assert(spawnStub.args[0][2].env.BOB === undefined) - assert(spawnStub.args[0][2].env.NODE_ENV === 'dev') - assert(spawnStub.args[0][2].env.ANSWER === '42') - }) - }) - - describe('PrintHelp', function () { - it('should return help text when run', function () { - const helpText = PrintHelp() - assert(typeof helpText === 'string') - assert(helpText.match(/Usage/g).length !== 0) - assert(helpText.match(/env-cmd/).length !== 0) - assert(helpText.match(/env_file/).length !== 0) - assert(helpText.match(/env_name/).length !== 0) - }) - }) - - describe('HandleUncaughtExceptions', function () { - beforeEach(function () { - this.logStub = sinon.stub(console, 'log') - this.processStub = sinon.stub(process, 'exit') - }) - afterEach(function () { - this.logStub.restore() - this.processStub.restore() - }) - it('should print help text and error if error contains \'passed\'', function () { - HandleUncaughtExceptions(new Error('print help text passed now')) - assert(this.logStub.calledTwice) - this.logStub.restore() // restore here so test success logs get printed - }) - - it('should print just there error if error does not contain \'passed\'', function () { - HandleUncaughtExceptions(new Error('do not print help text now')) - assert(this.logStub.calledOnce) - this.logStub.restore() // restore here so test success logs get printed - }) - }) - - describe('ResolveEnvFilePath', function () { - beforeEach(function () { - this.cwdStub = sinon.stub(process, 'cwd') - this.cwdStub.returns('/Users/hitchhikers-guide-to-the-galaxy/Thanks') - }) - afterEach(function () { - this.cwdStub.restore() - }) - it('should add "fish.env" to the end of the current directory', function () { - const abPath = ResolveEnvFilePath('fish.env') - assert(abPath === '/Users/hitchhikers-guide-to-the-galaxy/Thanks/fish.env') - }) - it('should add "./fish.env" to the end of the current directory', function () { - const abPath = ResolveEnvFilePath('./fish.env') - assert(abPath === '/Users/hitchhikers-guide-to-the-galaxy/Thanks/fish.env') - }) - it('should add "../fish.env" to the end of the current directory', function () { - const abPath = ResolveEnvFilePath('../fish.env') - assert(abPath === '/Users/hitchhikers-guide-to-the-galaxy/fish.env') - }) - it('should add "for-all-the/fish.env" to the end of the current directory', function () { - const abPath = ResolveEnvFilePath('for-all-the/fish.env') - assert(abPath === '/Users/hitchhikers-guide-to-the-galaxy/Thanks/for-all-the/fish.env') - }) - it('should set the absolute path to "/thanks/for-all-the/fish.env"', function () { - const abPath = ResolveEnvFilePath('/thanks/for-all-the/fish.env') - assert(abPath === '/thanks/for-all-the/fish.env') - }) - it('should use "~" to add "fish.env" to the end of user directory', function () { - const abPath = ResolveEnvFilePath('~/fish.env') - assert(abPath === '/Users/hitchhikers-guide-to-the-galaxy/fish.env') - }) - it('should leave "~" in path if no user home directory found', function () { - userHomeDir = '' - const abPath = ResolveEnvFilePath('~/fish.env') - assert(abPath === '/Users/hitchhikers-guide-to-the-galaxy/Thanks/~/fish.env') - }) - }) - - describe('TerminateSpawnedProc', function () { - beforeEach(function () { - this.procStub = sinon.stub() - this.exitStub = sinon.stub(process, 'exit') - this.killStub = sinon.stub(process, 'kill') - this.proc = { - kill: this.procStub - } - this.exitCalled = false - }) - - afterEach(function () { - this.exitStub.restore() - this.killStub.restore() - }) - - it('should call kill method on spawned process', function () { - TerminateSpawnedProc.call(this, this.proc) - assert(this.procStub.callCount === 1) - }) - - it('should not call kill method more than once', function () { - TerminateSpawnedProc.call(this, this.proc) - TerminateSpawnedProc.call(this, this.proc) - assert(this.procStub.callCount === 1) - }) - - it('should not call kill method if the spawn process is already dying', function () { - this.exitCalled = true - TerminateSpawnedProc.call(this, this.proc) - assert(this.procStub.callCount === 0) - }) - - it('should call kill method on spawned process with correct signal', function () { - TerminateSpawnedProc.call(this, this.proc, 'SIGINT') - assert(this.procStub.callCount === 1) - assert(this.procStub.args[0][0] === 'SIGINT') - }) - - it('should call kill method on parent process with correct signal', function () { - TerminateSpawnedProc.call(this, this.proc, 'SIGINT') - assert(this.exitStub.callCount === 0) - assert(this.killStub.callCount === 1) - assert(this.killStub.args[0][1] === 'SIGINT') - }) - - it('should call exit method on parent process with correct exit code', function () { - TerminateSpawnedProc.call(this, this.proc, 'SIGINT', 1) - assert(this.killStub.callCount === 0) - assert(this.exitStub.callCount === 1) - assert(this.exitStub.args[0][0] === 1) - }) - }) - - describe('TerminateParentProcess', function () { - beforeEach(function () { - this.exitStub = sinon.stub(process, 'exit') - this.killStub = sinon.stub(process, 'kill') - this.exitCalled = false - }) - - afterEach(function () { - this.exitStub.restore() - this.killStub.restore() - }) - - it('should call exit method on parent process', function () { - TerminateParentProcess.call(this) - assert(this.exitStub.callCount === 1) - }) - - it('should not call exit method more than once', function () { - TerminateParentProcess.call(this) - TerminateParentProcess.call(this) - assert(this.exitStub.callCount === 1) - }) - - it('should not call exit method if the process is already dying', function () { - this.exitCalled = true - TerminateParentProcess.call(this) - assert(this.exitStub.callCount === 0) - }) - - it('should call exit method with correct error code', function () { - TerminateParentProcess.call(this, 0) - assert(this.killStub.callCount === 0) - assert(this.exitStub.callCount === 1) - assert(this.exitStub.args[0][0] === 0) - }) - - it('should call kill method with correct kill signal', function () { - TerminateParentProcess.call(this, null, 'SIGINT') - assert(this.killStub.callCount === 1) - assert(this.exitStub.callCount === 0) - assert(this.killStub.args[0][1] === 'SIGINT') - }) - }) -}) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2966aa3 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "outDir": "./lib", + "allowJs": true, + "target": "es5", + "strict": true, + "lib": [ + "es2015", + "es2016", + "es2017", + "es2018" + ] + }, + "include": [ + "./src/**/*" + ] +} \ No newline at end of file