UNPKG

shelljs

Version:

Portable Unix shell commands for Node.js

546 lines (482 loc) 16.3 kB
// Ignore warning about 'new String()' and use of the Buffer constructor /* eslint no-new-wrappers: "off", no-buffer-constructor: "off" */ 'use strict'; var os = require('os'); var fs = require('fs'); var glob = require('fast-glob'); var shell = {}; exports.shell = shell; var shellMethods = Object.create(shell); exports.extend = Object.assign; // Check if we're running under electron var isElectron = Boolean(process.versions.electron); // Module globals (assume no execPath by default) var DEFAULT_CONFIG = { fatal: false, globOptions: {}, maxdepth: 255, noglob: false, silent: false, verbose: false, execPath: null, bufLength: 64 * 1024, // 64KB }; var config = { reset() { Object.assign(this, DEFAULT_CONFIG); if (!isElectron) { this.execPath = process.execPath; } }, resetForTesting() { this.reset(); this.silent = true; }, }; config.reset(); exports.config = config; // Note: commands should generally consider these as read-only values. var state = { error: null, errorCode: 0, currentCmd: 'shell.js', }; exports.state = state; delete process.env.OLDPWD; // initially, there's no previous directory // Reliably test if something is any sort of javascript object function isObject(a) { return typeof a === 'object' && a !== null; } exports.isObject = isObject; function log() { /* istanbul ignore next */ if (!config.silent) { console.error.apply(console, arguments); } } exports.log = log; // Converts strings to be equivalent across all platforms. Primarily responsible // for making sure we use '/' instead of '\' as path separators, but this may be // expanded in the future if necessary function convertErrorOutput(msg) { if (typeof msg !== 'string') { throw new TypeError('input must be a string'); } return msg.replace(/\\/g, '/'); } exports.convertErrorOutput = convertErrorOutput; // An exception class to help propagate command errors (e.g., non-zero exit // status) up to the top-level. {@param value} should be a ShellString. class CommandError extends Error { constructor(value) { super(value.toString()); this.returnValue = value; } } exports.CommandError = CommandError; // visible for testing // Shows error message. Throws if fatal is true (defaults to config.fatal, overridable with options.fatal) function error(msg, _code, options) { // Validate input if (typeof msg !== 'string') throw new Error('msg must be a string'); var DEFAULT_OPTIONS = { continue: false, code: 1, prefix: state.currentCmd + ': ', silent: false, fatal: config.fatal, }; if (typeof _code === 'number' && isObject(options)) { options.code = _code; } else if (isObject(_code)) { // no 'code' options = _code; } else if (typeof _code === 'number') { // no 'options' options = { code: _code }; } else if (typeof _code !== 'number') { // only 'msg' options = {}; } options = Object.assign({}, DEFAULT_OPTIONS, options); if (!state.errorCode) state.errorCode = options.code; var logEntry = convertErrorOutput(options.prefix + msg); state.error = state.error ? state.error + '\n' : ''; state.error += logEntry; // Throw an error, or log the entry if (options.fatal) { var err = new Error(logEntry); err.code = options.code; throw err; } if (msg.length > 0 && !options.silent) log(logEntry); if (!options.continue) { throw new CommandError(new ShellString('', state.error, state.errorCode)); } } exports.error = error; //@ //@ ### ShellString(str) //@ //@ Examples: //@ //@ ```javascript //@ var foo = new ShellString('hello world'); //@ ``` //@ //@ This is a dedicated type returned by most ShellJS methods, which wraps a //@ string (or array) value. This has all the string (or array) methods, but //@ also exposes extra methods: [`.to()`](#shellstringprototypetofile), //@ [`.toEnd()`](#shellstringprototypetoendfile), and all the pipe-able methods //@ (ex. `.cat()`, `.grep()`, etc.). This can be easily converted into a string //@ by calling `.toString()`. //@ //@ This type also exposes the corresponding command's stdout, stderr, and //@ return status code via the `.stdout` (string), `.stderr` (string), and //@ `.code` (number) properties respectively. function ShellString(stdout, stderr, code) { var that; if (stdout instanceof Array) { that = stdout; that.stdout = stdout.join('\n'); if (stdout.length > 0) that.stdout += '\n'; } else { that = new String(stdout); that.stdout = stdout; } that.stderr = stderr; that.code = code; // A list of all commands that can appear on the right-hand side of a pipe // (populated by calls to common.wrap()) pipeMethods.forEach(function (cmd) { that[cmd] = shellMethods[cmd].bind(that); }); return that; } exports.ShellString = ShellString; // Returns {'alice': true, 'bob': false} when passed a string and dictionary as follows: // parseOptions('-a', {'a':'alice', 'b':'bob'}); // Returns {'reference': 'string-value', 'bob': false} when passed two dictionaries of the form: // parseOptions({'-r': 'string-value'}, {'r':'reference', 'b':'bob'}); // Throws an error when passed a string that does not start with '-': // parseOptions('a', {'a':'alice'}); // throws function parseOptions(opt, map, errorOptions) { errorOptions = errorOptions || {}; // Validate input if (typeof opt !== 'string' && !isObject(opt)) { throw new TypeError('options must be strings or key-value pairs'); } else if (!isObject(map)) { throw new TypeError('parseOptions() internal error: map must be an object'); } else if (!isObject(errorOptions)) { throw new TypeError( 'parseOptions() internal error: errorOptions must be object', ); } if (opt === '--') { // This means there are no options. return {}; } // All options are false by default var options = {}; Object.keys(map).forEach(function (letter) { var optName = map[letter]; if (optName[0] !== '!') { options[optName] = false; } }); if (opt === '') return options; // defaults if (typeof opt === 'string') { if (opt[0] !== '-') { throw new Error("Options string must start with a '-'"); } // e.g. chars = ['R', 'f'] var chars = opt.slice(1).split(''); chars.forEach(function (c) { if (c in map) { var optionName = map[c]; if (optionName[0] === '!') { options[optionName.slice(1)] = false; } else { options[optionName] = true; } } else { error('option not recognized: ' + c, errorOptions); } }); } else { // opt is an Object Object.keys(opt).forEach(function (key) { if (key[0] === '-') { // key is a string of the form '-r', '-d', etc. var c = key[1]; if (c in map) { var optionName = map[c]; options[optionName] = opt[key]; // assign the given value } else { error('option not recognized: ' + c, errorOptions); } } else if (key in options) { // key is a "long option", so it should be the same options[key] = opt[key]; } else { error('option not recognized: {' + key + ':...}', errorOptions); } }); } return options; } exports.parseOptions = parseOptions; function globOptions() { // These options are just to make fast-glob be compatible with POSIX (bash) // wildcard behavior. var defaultGlobOptions = { onlyFiles: false, followSymbolicLinks: false, }; var newGlobOptions = Object.assign({}, config.globOptions); var optionRenames = { // node-glob's 'nodir' is not quote the same as fast-glob's 'onlyFiles'. // Compatibility for this is implemented at the call site. mark: 'markDirectories', matchBase: 'baseNameMatch', }; Object.keys(optionRenames).forEach(function (oldKey) { var newKey = optionRenames[oldKey]; if (oldKey in config.globOptions) { newGlobOptions[newKey] = config.globOptions[oldKey]; } }); var invertedOptionRenames = { nobrace: 'braceExpansion', noglobstar: 'globstar', noext: 'extglob', nocase: 'caseSensitiveMatch', }; Object.keys(invertedOptionRenames).forEach(function (oldKey) { var newKey = invertedOptionRenames[oldKey]; if (oldKey in config.globOptions) { newGlobOptions[newKey] = !config.globOptions[oldKey]; } }); return Object.assign({}, defaultGlobOptions, newGlobOptions); } // Expands wildcards with matching (ie. existing) file names. // For example: // expand(['file*.js']) = ['file1.js', 'file2.js', ...] // (if the files 'file1.js', 'file2.js', etc, exist in the current dir) function expand(list) { if (!Array.isArray(list)) { throw new TypeError('must be an array'); } var expanded = []; list.forEach(function (listEl) { // Don't expand non-strings if (typeof listEl !== 'string') { expanded.push(listEl); } else { var ret; var globOpts = globOptions(); try { ret = glob.sync(listEl, globOpts); } catch (e) { // if glob fails, interpret the string literally ret = [listEl]; } // if nothing matched, interpret the string literally ret = ret.length > 0 ? ret.sort() : [listEl]; if (globOpts.nodir) { ret = ret.filter(function (file) { return !statNoFollowLinks(file).isDirectory(); }); } expanded = expanded.concat(ret); } }); return expanded; } exports.expand = expand; // Normalizes Buffer creation, using Buffer.alloc if possible. // Also provides a good default buffer length for most use cases. var buffer = typeof Buffer.alloc === 'function' ? function (len) { return Buffer.alloc(len || config.bufLength); } : function (len) { return new Buffer(len || config.bufLength); }; exports.buffer = buffer; // Normalizes _unlinkSync() across platforms to match Unix behavior, i.e. // file can be unlinked even if it's read-only, see https://github.com/joyent/node/issues/3006 function unlinkSync(file) { try { fs.unlinkSync(file); } catch (e) { // Try to override file permission /* istanbul ignore next */ if (e.code === 'EPERM') { fs.chmodSync(file, '0666'); fs.unlinkSync(file); } else { throw e; } } } exports.unlinkSync = unlinkSync; // wrappers around common.statFollowLinks and common.statNoFollowLinks that clarify intent // and improve readability function statFollowLinks() { return fs.statSync.apply(fs, arguments); } exports.statFollowLinks = statFollowLinks; function statNoFollowLinks() { return fs.lstatSync.apply(fs, arguments); } exports.statNoFollowLinks = statNoFollowLinks; // e.g. 'shelljs_a5f185d0443ca...' function randomFileName() { function randomHash(count) { if (count === 1) { return parseInt(16 * Math.random(), 10).toString(16); } var hash = ''; for (var i = 0; i < count; i++) { hash += randomHash(1); } return hash; } return 'shelljs_' + randomHash(20); } exports.randomFileName = randomFileName; // Common wrapper for all Unix-like commands that performs glob expansion, // command-logging, and other nice things function wrap(cmd, fn, options) { options = options || {}; return function () { var retValue = null; state.currentCmd = cmd; state.error = null; state.errorCode = 0; try { var args = [].slice.call(arguments, 0); // Log the command to stderr, if appropriate if (config.verbose) { console.error.apply(console, [cmd].concat(args)); } // If this is coming from a pipe, let's set the pipedValue (otherwise, set // it to the empty string) state.pipedValue = (this && typeof this.stdout === 'string') ? this.stdout : ''; if (options.unix === false) { // this branch is for exec() retValue = fn.apply(this, args); } else { // and this branch is for everything else if (isObject(args[0]) && args[0].constructor.name === 'Object') { // a no-op, allowing the syntax `touch({'-r': file}, ...)` } else if (args.length === 0 || typeof args[0] !== 'string' || args[0].length <= 1 || args[0][0] !== '-') { args.unshift(''); // only add dummy option if '-option' not already present } // flatten out arrays that are arguments, to make the syntax: // `cp([file1, file2, file3], dest);` // equivalent to: // `cp(file1, file2, file3, dest);` args = args.reduce(function (accum, cur) { if (Array.isArray(cur)) { return accum.concat(cur); } accum.push(cur); return accum; }, []); // Convert ShellStrings (basically just String objects) to regular strings args = args.map(function (arg) { if (isObject(arg) && arg.constructor.name === 'String') { return arg.toString(); } return arg; }); // Expand the '~' if appropriate var homeDir = os.homedir(); args = args.map(function (arg) { if (typeof arg === 'string' && arg.slice(0, 2) === '~/' || arg === '~') { return arg.replace(/^~/, homeDir); } return arg; }); // Perform glob-expansion on all arguments after globStart, but preserve // the arguments before it (like regexes for sed and grep) if (!config.noglob && options.allowGlobbing === true) { args = args.slice(0, options.globStart).concat(expand(args.slice(options.globStart))); } try { // parse options if options are provided if (isObject(options.cmdOptions)) { args[0] = parseOptions(args[0], options.cmdOptions); } retValue = fn.apply(this, args); } catch (e) { /* istanbul ignore else */ if (e instanceof CommandError) { retValue = e.returnValue; } else { throw e; // this is probably a bug that should be thrown up the call stack } } } } catch (e) { /* istanbul ignore next */ if (!state.error) { // If state.error hasn't been set it's an error thrown by Node, not us - probably a bug... e.name = 'ShellJSInternalError'; throw e; } if (config.fatal || options.handlesFatalDynamically) throw e; } if (options.wrapOutput && (typeof retValue === 'string' || Array.isArray(retValue))) { retValue = new ShellString(retValue, state.error, state.errorCode); } state.currentCmd = 'shell.js'; return retValue; }; } // wrap exports.wrap = wrap; // This returns all the input that is piped into the current command (or the // empty string, if this isn't on the right-hand side of a pipe function _readFromPipe() { return state.pipedValue; } exports.readFromPipe = _readFromPipe; var DEFAULT_WRAP_OPTIONS = { allowGlobbing: true, canReceivePipe: false, cmdOptions: null, globStart: 1, handlesFatalDynamically: false, pipeOnly: false, wrapOutput: true, unix: true, }; // This is populated during plugin registration var pipeMethods = []; // Register a new ShellJS command function _register(name, implementation, wrapOptions) { wrapOptions = wrapOptions || {}; // Validate options Object.keys(wrapOptions).forEach(function (option) { if (!DEFAULT_WRAP_OPTIONS.hasOwnProperty(option)) { throw new Error("Unknown option '" + option + "'"); } if (typeof wrapOptions[option] !== typeof DEFAULT_WRAP_OPTIONS[option]) { throw new TypeError("Unsupported type '" + typeof wrapOptions[option] + "' for option '" + option + "'"); } }); // If an option isn't specified, use the default wrapOptions = Object.assign({}, DEFAULT_WRAP_OPTIONS, wrapOptions); if (shell.hasOwnProperty(name)) { throw new Error('Command `' + name + '` already exists'); } if (wrapOptions.pipeOnly) { wrapOptions.canReceivePipe = true; shellMethods[name] = wrap(name, implementation, wrapOptions); } else { shell[name] = wrap(name, implementation, wrapOptions); } if (wrapOptions.canReceivePipe) { pipeMethods.push(name); } } exports.register = _register;