diff --git a/README.md b/README.md index 4484030b..82fcaf08 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,32 @@ line'} ``` - inner quotes are maintained (think JSON) (`JSON={"foo": "bar"}` becomes `{JSON:"{\"foo\": \"bar\"}"`) +## Command line interface + +Interface for command line usage is essential when you need to use .env file with `browserify` and `envify`. +Greatly inspired by [cross-env](https://github.com/kentcdodds/cross-env). + +``` +Usage: dotenv [options] [inline env] [child command] + +Standard Options: + + --path Custom path if your file containing environment variables is named or located differently (default: .env). + + --encoding Encoding of your file containing environment variables (default: utf8). +``` + +### Example ### + +``` +{ + "scripts": { + "watch": "dotenv watchify src/index.js -v -o dist/build.js", + "build": "dotenv browserify src/index.js -v -o dist/build.js" + } +} +``` + ## FAQ ### Should I commit my `.env` file? diff --git a/bin/dotenv.js b/bin/dotenv.js new file mode 100644 index 00000000..0749bbdc --- /dev/null +++ b/bin/dotenv.js @@ -0,0 +1,2 @@ +#!/usr/bin/env node +require('../lib/cli')(process.argv.slice(2)) diff --git a/lib/cli.js b/lib/cli.js new file mode 100644 index 00000000..a2f4655e --- /dev/null +++ b/lib/cli.js @@ -0,0 +1,132 @@ +'use strict' + +var isWindows = require('is-windows') +var spawn = require('cross-spawn').spawn +var config = require('./main').config + +var dotenvOptionsRegex = /--(\w+)=(.+)/ // --path=.env --encoding=utf-8 +var envSetterRegex = /(\w+)=('(.+)'|"(.+)"|(.+))/ // foo=bar, foo="bar", foo='bar' +var envUseUnixRegex = /\$(\w+)/g // $my_var +var envUseWinRegex = /%(.*?)%/g // %my_var% + +/** + * Get next command, args, and env variables for child process + * @param {Object} args - args provided by cli + * @returns {array} command, args, and env variables + */ +function getCommandArgsAndEnvVars (args) { + args = args || [] + var dotEnvOptions = getDotenvOptions(args) + var envVars = getEnvVars(dotEnvOptions) + var commandArgs = args.map(convertCommand) + var command = getCommand(commandArgs, envVars) + return [command, commandArgs, envVars] +} + +/** + * Get dotenv options defined in command args + * Warn! Options extracted from args are deleted with mutation + * eg: dotenv --path=.env --encoding=utf-8 ... + * @returns {Object} options - options extracted from args + */ +function getDotenvOptions (args) { + var dotenvOptions = {} + + if (Array.isArray(args)) { + while (args.length) { + if (dotenvOptionsRegex.test(args[0])) { + var res = args.shift().substring(2).split('=') + var key = res[0] + var value = res[1] + dotenvOptions[key] = value + continue + } + break + } + } + return dotenvOptions +} + +/** + * Get environment vars need to be forwarded + * Merge process.env and variables loaded by dotenv + * @param {Object} options - valid options: path ('.env'), encoding ('utf8') + * @returns {Object} - env variables + */ +function getEnvVars (options) { + var envVars = Object.assign(config(options).parsed || {}, process.env) + if (process.env.APPDATA) { + envVars.APPDATA = process.env.APPDATA + } + return envVars +} + +/** + * Converts an environment variable usage to be appropriate for the current OS + * @param {String} variable - variable to convert + * @returns {String} converted variable + */ +function convertCommand (variable) { + var isWin = isWindows() + var envExtract = isWin ? envUseUnixRegex : envUseWinRegex + var match = envExtract.exec(variable) + if (match) { + variable = isWin ? `%${match[1]}%` : `$${match[1]}` + } + return variable +} + +/** + * Get future command from rest args + * Check each command args step by step, set env variable if arg match + * Warn! Env var extracted are deleted from command args with mutation + * @param {array} commandArgs - command args provided by cli + * @param {Object} envVars - environment variables need to be forwared + * @return {string|null} command name if presents, otherwise null + */ +function getCommand (commandArgs, envVars) { + if (Array.isArray(commandArgs)) { + while (commandArgs.length) { + var shifted = commandArgs.shift() + var match = envSetterRegex.exec(shifted) + if (!shifted.startsWith('--') && match) { + if (envVars) { + envVars[match[1]] = match[3] || match[4] || match[5] + } + } else { + return shifted + } + } + } + return null +} + +/** + * Forward environment variables to the next process + * Fetch environment variables processed by dotenv before call next command + * @param {Object} args - command args provided by cli + * @returns {ChildProcess|null} child process if another command need to be called, otherwhise null + */ +function forwardEnv (args) { + var res = getCommandArgsAndEnvVars(args) + var command = res[0] + var commandArgs = res[1] + var env = res[2] + if (command) { + var proc = spawn(command, commandArgs, {stdio: 'inherit', env}) + process.on('SIGTERM', () => proc.kill('SIGTERM')) + process.on('SIGINT', () => proc.kill('SIGINT')) + process.on('SIGBREAK', () => proc.kill('SIGBREAK')) + process.on('SIGHUP', () => proc.kill('SIGHUP')) + proc.on('exit', process.exit) + return proc + } + return null +} + +module.exports = forwardEnv +module.exports.getCommand = getCommand +module.exports.convertCommand = convertCommand +module.exports.getEnvVars = getEnvVars +module.exports.getDotenvOptions = getDotenvOptions +module.exports.getCommandArgsAndEnvVars = getCommandArgsAndEnvVars diff --git a/package.json b/package.json index 38558492..07fcc757 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,9 @@ "postlint": "npm run lint-md", "lint-md": "standard-markdown" }, + "bin": { + "dotenv": "bin/dotenv.js" + }, "repository": { "type": "git", "url": "git://github.com/motdotla/dotenv.git" @@ -29,7 +32,9 @@ "devDependencies": { "babel": "5.8.23", "coveralls": "^2.11.9", + "cross-spawn": "5.1.0", "lab": "11.1.0", + "is-windows": "1.0.0", "semver": "5.3.0", "should": "11.1.1", "sinon": "1.17.6", diff --git a/test/cli.js b/test/cli.js new file mode 100644 index 00000000..6cccc24d --- /dev/null +++ b/test/cli.js @@ -0,0 +1,213 @@ +'use strict' + +var should = require('should') +var sinon = require('sinon') +var Lab = require('lab') +var lab = exports.lab = Lab.script() +var describe = lab.experiment +var beforeEach = lab.beforeEach +var afterEach = lab.afterEach +var after = lab.after +var it = lab.test +var cli = require('../lib/cli') + +describe('cli', function () { + beforeEach(function (done) { + done() + }) + + afterEach(function (done) { + done() + }) + + describe('get command', function () { + it('should be equals null when no parameter provided', function (done) { + should(cli.getCommand()).eql(null) + done() + }) + + it('should be equals null when empy array provided', function (done) { + should(cli.getCommand([])).eql(null) + done() + }) + + it('should returns command name', function (done) { + should(cli.getCommand(['command'])).eql('command') + done() + }) + + it('should skips env var and returns command name', function (done) { + should(cli.getCommand(['FOO=BAR', 'command'])).eql('command') + done() + }) + + it('should returns mutate env vars', function (done) { + var enVars = {} + cli.getCommand(['FOO=BAR'], enVars) + enVars.should.be.instanceOf(Object) + enVars.should.have.ownProperty('FOO', 'BAR') + cli.getCommand(['BAR="BAR"'], enVars) + enVars.should.be.instanceOf(Object) + enVars.should.have.ownProperty('BAR', 'BAR') + cli.getCommand(['BAZ=\'BAR\''], enVars) + enVars.should.be.instanceOf(Object) + enVars.should.have.ownProperty('BAZ', 'BAR') + done() + }) + + it('should returns skips dotenv options', function (done) { + var enVars = {} + should(cli.getCommand(['--foo=bar'], enVars)) + enVars.should.be.instanceOf(Object) + enVars.should.be.empty() + done() + }) + }) + + describe('command convert', function () { + var nativeOSType = process.env.OSTYPE + + after(function (done) { + process.env.OSTYPE = nativeOSType + done() + }) + + it('should returns undefined', function (done) { + should(cli.convertCommand()).eql(undefined) + done() + }) + + it('should returns null', function (done) { + should(cli.convertCommand(null)).eql(null) + done() + }) + + it('should returns command', function (done) { + should(cli.convertCommand('command')).eql('command') + done() + }) + + it('should returns an env variable usage to be appropriate for the current OS', function (done) { + should(cli.convertCommand('$foo_bar')).eql('$foo_bar') + should(cli.convertCommand('%foo_bar%')).eql('$foo_bar') + process.env.OSTYPE = 'cygwin' // Tricky hock for fake is-windows module >:D + should(cli.convertCommand('$foo_bar')).eql('%foo_bar%') + should(cli.convertCommand('%foo_bar%')).eql('%foo_bar%') + done() + }) + }) + + describe('get env variables', function () { + it('should returns environment variable from process.env', function (done) { + process.env.FOO = 'BAR' + should(cli.getEnvVars()).ownProperty('FOO', 'BAR') + delete process.env.FOO + done() + }) + + it('should be forward APPDATA variable if set (windows)', function (done) { + process.env.APPDATA = 0 + should(cli.getEnvVars()).ownProperty('APPDATA', 0) + done() + }) + + it('should returns environment variable from .env', function (done) { + cli.getEnvVars({path: './test/.env', encoding: 'utf-8'}).should.have.ownProperty('BASIC', 'basic') + done() + }) + }) + + describe('get dotenv option', function () { + it('should returns empty object when no options forwarded', function (done) { + cli.getDotenvOptions().should.be.empty() + cli.getDotenvOptions(['command']).should.be.empty() + cli.getDotenvOptions(['FOO=BAR', 'command']).should.be.empty() + done() + }) + + it('should returns object with options needed by dotenv', function (done) { + var dotenvOptions = cli.getDotenvOptions(['--path=./test/.env', '--encoding=utf-8', 'command', '--foo=bar']) + dotenvOptions.should.have.properties({'path': './test/.env', 'encoding': 'utf-8'}) + dotenvOptions.should.not.have.property('foo', 'bar') + done() + }) + }) + + describe('get command args env vars', function () { + it('should returns no future command and args', function (done) { + process.env.FOO = 'BAR' + var res = cli.getCommandArgsAndEnvVars() + var command = res[0] + var args = res[1] + var env = res[2] + should(command).eql(null) + args.should.be.instanceOf(Array) + args.should.be.empty() + env.should.have.ownProperty('FOO', 'BAR') + delete process.env.FOO + done() + }) + + it('should returns future command, args, and .env variables', function (done) { + var res = cli.getCommandArgsAndEnvVars(['--path=./test/.env', 'FOO=BAR', 'command', '--foo=bar']) + var command = res[0] + var args = res[1] + var env = res[2] + should(command).eql('command') + args.should.be.instanceOf(Array) + args.should.have.length(1) + args[0].should.be.eql('--foo=bar') + env.should.have.properties({'FOO': 'BAR', 'BASIC': 'basic'}) + done() + }) + }) + + describe('get dotenv option', function () { + it('should returns empty object when no options forwarded', function (done) { + cli.getDotenvOptions().should.be.empty() + cli.getDotenvOptions(['command']).should.be.empty() + cli.getDotenvOptions(['FOO=BAR', 'command']).should.be.empty() + done() + }) + + it('should returns object with options needed by dotenv', function (done) { + var dotenvOptions = cli.getDotenvOptions(['--path=./test/.env', '--encoding=utf-8', 'command', '--foo=bar']) + dotenvOptions.should.have.properties({'path': './test/.env', 'encoding': 'utf-8'}) + dotenvOptions.should.not.have.property('foo', 'bar') + done() + }) + }) + + describe('forward env into child process', function () { + var s, childProcessStub + + beforeEach(function (done) { + s = sinon.sandbox.create() + done() + }) + + afterEach(function (done) { + s.restore() + done() + }) + + it('should returns null', function (done) { + should(cli()).eql(null) + done() + }) + + it('should returns child process', function (done) { + var childProcess = cli(['node', '-e', 'setTimeout(() => {}, 0)']) + var sigs = ['SIGTERM', 'SIGINT', 'SIGBREAK', 'SIGHUP'] + childProcessStub = s.stub(childProcess, 'kill').returns(0) + sigs.forEach(sig => process.emit(sig)) + childProcessStub.calledThrice.should.be.ok + childProcessStub.returnValues.should.be.containEql(0, 0, 0) + // Kill child process without exit parent + childProcessStub.restore() + childProcess.removeListener('exit', process.exit) + childProcess.kill() + done() + }) + }) +})