catdad/shellton

View on GitHub
shellton.js

Summary

Maintainability
C
1 day
Test Coverage
/* jshint node: true */

var util = require('util');
var path = require('path');
var child = require('child_process');

var async = require('async');
var _ = require('lodash');

// Add the node_modules to the PATH
var nodeModulesBin = path.resolve(__dirname, 'node_modules', '.bin');
var parentNodeModulesBin = path.resolve(__dirname, '..', '.bin');
var platform = /^win/.test(process.platform) ? 'win' : 'nix';

// In node 0.10, 'buffer' is not a correct encoding... it uses Buffer.isEncoding.
// Further, even though Buffer.isEncoding allows 'raw', node 0.10 always does a
// Buffer.toString, where 'raw' is not allowed... since, you know, that's not a
// string. So hack it is... I am like 42% sure that I can ask for binary output
// and then convert that back to a buffer.
var VERSION = process.versions.node.match(/([0-9]+)\.([0-9]+)\.([0-9]+)/);
var IS_NODE_0_10 = +VERSION[1] === 0 && +VERSION[2] < 12;
var BUFFER_ENCODING = IS_NODE_0_10 ? 'binary' : 'buffer';

var defaultShell = (function () {
    // adapted from https://github.com/sindresorhus/default-shell
    var env = process.env;

    if (process.platform === 'darwin') {
        return env.SHELL || '/bin/bash';
    }

    if (process.platform === 'win32') {
        // Powershell behaves differently at times, so I am reluctant to
        // just enable this, especially since Microsoft has started making
        // Powershell the default in newer versions of Windows.
        // return env.COMSPEC || 'cmd.exe';
        
        return 'cmd.exe';
    }

    return env.SHELL || '/bin/sh';
})();

function validateFunction(obj) {
    return _.isFunction(obj) ? obj : _.noop;
}

function getConfig(command) {
    var config = (typeof command === 'string') ? {
        task: command,
        cwd: process.cwd()
    } : command;
    
    config.encoding = config.encoding === 'buffer' ? BUFFER_ENCODING : 'utf8';
    
    return config;
}

function mergePaths() {
    
    var mergedPath = [].slice.call(arguments).reduce(function(memo, arg) {
        var p = arg.PATH || arg.Path || arg.path;

        if (!(_.isString(p) && p.length)) {
            return memo;
        }

        p = p.trim().replace(new RegExp(path.delimiter + '$'), '');

        return memo.concat(p.split(path.delimiter));
    }, []).join(path.delimiter);
    
    // because Windows, we need to overwrite all 3
    return {
        PATH: mergedPath
    };
}

function getEnv(config) {
    var env = newEnv(config.env, mergePaths(
        config.env || {},
        process.env,
        { PATH: nodeModulesBin },
        { PATH: parentNodeModulesBin }
    ));
    
    delete env.Path;
    delete env.path;
    
    return env;
}

function isIOStream(stream) {
    return process.stdin === stream ||
        process.stdout === stream ||
        process.stderr === stream;
}

function pipeStream(from, to, config) {
    var opts = isIOStream(to) ? { end: false } : { end: true };
    from.pipe(to, opts);
}

function collectStream(stream, encoding, callback) {
    var body = [];
    
    stream.on('data', function(chunk) {
        body.push(chunk);
    });

    stream.on('end', function() {
        var out = Buffer.concat(body);
        
        if (encoding !== BUFFER_ENCODING) {
            out = out.toString();
        }
        
        callback(undefined, out);
    });
}

function exec(command, done) {
    var config = getConfig(command);
    done = validateFunction(done);
    
    function encode(content) {
        // If these are not buffers when they are expected to be,
        // then we are in Node 0.10 and everythings sucks.
        if (config.encoding === BUFFER_ENCODING && !Buffer.isBuffer(content)) {
            return new Buffer(content, BUFFER_ENCODING);
        }
        
        return content;
    }
    
    var task = child.exec(config.task, {
        cwd: config.cwd || process.cwd(),
        env: getEnv(config),
        encoding: config.encoding
    }, function(err, stdout, stderr) {
        // to stay consistent with `spawn`, we will remove values here
        // if the streams were set to 'inherit'
        done(
            err,
            encode(config.stdout === 'inherit' ? '' : stdout),
            encode(config.stderr === 'inherit' ? '' : stderr)
        );
    });
    
    if (config.stdout) {
        pipeStream(task.stdout, config.stdout === 'inherit' ? process.stdout : config.stdout);
    }
    
    if (config.stderr) {
        pipeStream(task.stderr, config.stderr === 'inherit' ? process.stderr : config.stderr);
    }
    
    if (config.stdin) {
        config.stdin.pipe(config.stdin === 'inherit' ? process.stdin : task.stdin);
    }
    
    return task;
}

function spawn(command, done) {
    done = validateFunction(done);
    var config = getConfig(command);
    
    var stdio = [ 'pipe', 'pipe', 'pipe' ];
    var pipeStdout = true;
    var pipeStderr = true;
    var collectStdout = true;
    var collectStderr = true;
    
    // 'inherit' doesn't work in node 0.10, so we will just
    // pipe to the appropriate streams
    if (!IS_NODE_0_10) {
        if (command.stdin === 'inherit') {
            stdio[0] = 'inherit';
        }

        if (command.stdout === 'inherit') {
            stdio[1] = 'inherit';
            pipeStdout = collectStdout = false;
        }

        if (command.stderr === 'inherit') {
            stdio[2] = 'inherit';
            pipeStderr = collectStderr = false;
        }    
    } else {
        if (command.stdio === 'inherit') {
            command.stdin = process.stdin;
        }

        if (command.stdout === 'inherit') {
            command.stdout = process.stdout;
            collectStdout = false;
        }

        if (command.stderr === 'inherit') {
            command.stderr = process.stdout;
            collectStderr = false;
        }   
    }
    
    var firstToken = platform === 'win' ? '/c' : '-c';
    var tokens = [firstToken, config.task];
    
    var opts = {
        env: getEnv(config),
        cwd: config.cwd || process.cwd(),
        windowsHide : true,
        stdio: stdio
    };
    
    if (platform === 'win') {
        opts.windowsVerbatimArguments = config.windowsVerbatimArguments !== false;
    }
    
    var task = child.spawn(defaultShell, tokens, opts);
    
    task.on('error', function(err) {
        done(err);
    });
    
    var parallelTasks = {
        stdout: function(next) {
            if (!collectStdout) {
                return next(null, '');
            }
            
            collectStream(task.stdout, config.encoding, next);
        },
        stderr: function(next) {
            if (!collectStderr) {
                return next(null, '');
            }
            
            collectStream(task.stderr, config.encoding, next);
        },
        exitCode: function(next) {
            task.on('exit', function(code) {
                var err;
                if (code !== 0) {
                    err = new Error('Process exited with code: ' + code);
                    err.code = code;
                }
                next(null, err);
            });
        }
    };
    
    if (config.stdin && config.stdin.pipe) {
        config.stdin.pipe(task.stdin);
        
        // add a task to make sure this stream ends as well,
        // before exiting the task
        parallelTasks.stdin = function(next) {
            config.stdin.on('end', next);
        };
    }
    
    async.parallel(parallelTasks, function(err, results) {
        done(err || results.exitCode || null, results.stdout, results.stderr);
    });
    
    if (pipeStdout && config.stdout) {
        pipeStream(task.stdout, config.stdout);
    }
    
    if (pipeStderr && config.stderr) {
        pipeStream(task.stderr, config.stderr);
    }

    return task;
}

function extendToString(target) {
    var args = [].slice.call(arguments, 1);
    
    function extend(to, from) {
        for (var i in from) {
            to[i] = from[i].toString();
        }
        
        return to;
    }
    
    args.forEach(extend.bind(null, target));
    
    return target;
}

function newEnv() {
    return extendToString.apply(
        null,
        [_.cloneDeep(process.env)].concat(_.filter(arguments, _.isPlainObject))
    );
}

// module.exports = exec;
module.exports = spawn;

module.exports.spawn = util.deprecate(spawn, 'shellton.spawn() is deprecated, use shellton() instead');
module.exports.exec = util.deprecate(exec, 'shellton.exec() is deprecated, use shellton() instead');

module.exports.env = newEnv;