var {promisify} = require('es6-promisify'); var Path = require('../path.js'); var Errors = require('../errors.js'); var Environment = require('./environment.js'); var async = require('../../lib/async.js'); var minimatch = require('minimatch'); function Shell(fs, options) { options = options || {}; var env = new Environment(options.env); var cwd = '/'; /** * The bound FileSystem (cannot be changed) */ Object.defineProperty(this, 'fs', { get: function() { return fs; }, enumerable: true }); /** * The shell's environment (e.g., for things like * path, tmp, and other env vars). Use env.get() * and env.set() to work with variables. */ Object.defineProperty(this, 'env', { get: function() { return env; }, enumerable: true }); /** * Change the current working directory. We * include `cd` on the `this` vs. proto so that * we can access cwd without exposing it externally. */ this.cd = function(path, callback) { path = Path.resolve(cwd, path); // Make sure the path actually exists, and is a dir fs.stat(path, function(err, stats) { if(err) { callback(new Errors.ENOTDIR(null, path)); return; } if(stats.type === 'DIRECTORY') { cwd = path; callback(); } else { callback(new Errors.ENOTDIR(null, path)); } }); }; /** * Get the current working directory (changed with `cd()`) */ this.pwd = function() { return cwd; }; this.promises = {}; /** * Public API for Shell converted to Promise based */ [ 'cd', 'exec', 'touch', 'cat', 'ls', 'rm', 'tempDir', 'mkdirp', 'find' ].forEach((methodName)=>{ this.promises[methodName] = promisify(this[methodName].bind(this)); }); } /** * Execute the .js command located at `path`. Such commands * should assume the existence of 3 arguments, which will be * defined at runtime: * * * fs - the current shell's bound filesystem object * * args - a list of arguments for the command, or an empty list if none * * callback - a callback function(error, result) to call when done. * * The .js command's contents should be the body of a function * that looks like this: * * function(fs, args, callback) { * // .js code here * } */ Shell.prototype.exec = function(path, args, callback) { /* jshint evil:true */ var sh = this; var fs = sh.fs; if(typeof args === 'function') { callback = args; args = []; } args = args || []; callback = callback || function(){}; path = Path.resolve(sh.pwd(), path); fs.readFile(path, 'utf8', function(error, data) { if(error) { callback(error); return; } try { var cmd = new Function('fs', 'args', 'callback', data); cmd(fs, args, callback); } catch(e) { callback(e); } }); }; /** * Create a file if it does not exist, or update access and * modified times if it does. Valid options include: * * * updateOnly - whether to create the file if missing (defaults to false) * * date - use the provided Date value instead of current date/time */ Shell.prototype.touch = function(path, options, callback) { var sh = this; var fs = sh.fs; if(typeof options === 'function') { callback = options; options = {}; } options = options || {}; callback = callback || function(){}; path = Path.resolve(sh.pwd(), path); function createFile(path) { fs.writeFile(path, '', callback); } function updateTimes(path) { var now = Date.now(); var atime = options.date || now; var mtime = options.date || now; fs.utimes(path, atime, mtime, callback); } fs.stat(path, function(error) { if(error) { if(options.updateOnly === true) { callback(); } else { createFile(path); } } else { updateTimes(path); } }); }; /** * Concatenate multiple files into a single String, with each * file separated by a newline. The `files` argument should * be a String (path to single file) or an Array of Strings * (multiple file paths). */ Shell.prototype.cat = function(files, callback) { var sh = this; var fs = sh.fs; var all = ''; callback = callback || function(){}; if(!files) { callback(new Errors.EINVAL('Missing files argument')); return; } files = typeof files === 'string' ? [ files ] : files; function append(item, callback) { var filename = Path.resolve(sh.pwd(), item); fs.readFile(filename, 'utf8', function(error, data) { if(error) { callback(error); return; } all += data + '\n'; callback(); }); } async.eachSeries(files, append, function(error) { if(error) { callback(error); } else { callback(null, all.replace(/\n$/, '')); } }); }; /** * Get the listing of a directory, returning an array of * file entries in the following form: * * { * path: the basename of the directory entry * links: the number of links to the entry * size: the size in bytes of the entry * modified: the last modified date/time * type: the type of the entry * contents: an optional array of child entries * } * * By default ls() gives a shallow listing. If you want * to follow directories as they are encountered, use * the `recursive=true` option. */ Shell.prototype.ls = function(dir, options, callback) { var sh = this; var fs = sh.fs; if(typeof options === 'function') { callback = options; options = {}; } options = options || {}; callback = callback || function(){}; if(!dir) { callback(new Errors.EINVAL('Missing dir argument')); return; } function list(path, callback) { var pathname = Path.resolve(sh.pwd(), path); var result = []; fs.readdir(pathname, function(error, entries) { if(error) { callback(error); return; } function getDirEntry(name, callback) { name = Path.join(pathname, name); fs.stat(name, function(error, stats) { if(error) { callback(error); return; } var entry = stats; if(options.recursive && stats.type === 'DIRECTORY') { list(Path.join(pathname, entry.name), function(error, items) { if(error) { callback(error); return; } entry.contents = items; result.push(entry); callback(); }); } else { result.push(entry); callback(); } }); } async.eachSeries(entries, getDirEntry, function(error) { callback(error, result); }); }); } list(dir, callback); }; /** * Removes the file or directory at `path`. If `path` is a file * it will be removed. If `path` is a directory, it will be * removed if it is empty, otherwise the callback will receive * an error. In order to remove non-empty directories, use the * `recursive=true` option. */ Shell.prototype.rm = function(path, options, callback) { var sh = this; var fs = sh.fs; if(typeof options === 'function') { callback = options; options = {}; } options = options || {}; callback = callback || function(){}; if(!path) { callback(new Errors.EINVAL('Missing path argument')); return; } function remove(pathname, callback) { pathname = Path.resolve(sh.pwd(), pathname); fs.stat(pathname, function(error, stats) { if(error) { callback(error); return; } // If this is a file, delete it and we're done if(stats.type === 'FILE') { fs.unlink(pathname, callback); return; } // If it's a dir, check if it's empty fs.readdir(pathname, function(error, entries) { if(error) { callback(error); return; } // If dir is empty, delete it and we're done if(entries.length === 0) { fs.rmdir(pathname, callback); return; } // If not, see if we're allowed to delete recursively if(!options.recursive) { callback(new Errors.ENOTEMPTY(null, pathname)); return; } // Remove each dir entry recursively, then delete the dir. entries = entries.map(function(filename) { // Root dir entries absolutely return Path.join(pathname, filename); }); async.eachSeries(entries, remove, function(error) { if(error) { callback(error); return; } fs.rmdir(pathname, callback); }); }); }); } remove(path, callback); }; /** * Gets the path to the temporary directory, creating it if not * present. The directory used is the one specified in * env.TMP. The callback receives (error, tempDirName). */ Shell.prototype.tempDir = function(callback) { var sh = this; var fs = sh.fs; var tmp = sh.env.get('TMP'); callback = callback || function(){}; // Try and create it, and it will either work or fail // but either way it's now there. fs.mkdir(tmp, function() { callback(null, tmp); }); }; /** * Recursively creates the directory at `path`. If the parent * of `path` does not exist, it will be created. * Based off EnsureDir by Sam X. Xu * https://www.npmjs.org/package/ensureDir * MIT License */ Shell.prototype.mkdirp = function(path, callback) { var sh = this; var fs = sh.fs; callback = callback || function(){}; if(!path) { callback(new Errors.EINVAL('Missing path argument')); return; } path = Path.resolve(sh.pwd(), path); if (path === '/') { callback(); return; } function _mkdirp(path, callback) { fs.stat(path, function (err, stat) { if(stat) { if(stat.isDirectory()) { callback(); return; } else if (stat.isFile()) { callback(new Errors.ENOTDIR(null, path)); return; } } else if (err && err.code !== 'ENOENT') { callback(err); return; } else { var parent = Path.dirname(path); if(parent === '/') { fs.mkdir(path, function (err) { if (err && err.code !== 'EEXIST') { callback(err); return; } callback(); return; }); } else { _mkdirp(parent, function (err) { if (err) return callback(err); fs.mkdir(path, function (err) { if (err && err.code !== 'EEXIST') { callback(err); return; } callback(); return; }); }); } } }); } _mkdirp(path, callback); }; /** * Recursively walk a directory tree, reporting back all paths * that were found along the way. The `path` must be a dir. * Valid options include a `regex` for pattern matching paths * and an `exec` function of the form `function(path, next)` where * `path` is the current path that was found (dir paths have an '/' * appended) and `next` is a callback to call when done processing * the current path, passing any error object back as the first argument. * `find` returns a flat array of absolute paths for all matching/found * paths as the final argument to the callback. */ Shell.prototype.find = function(path, options, callback) { var sh = this; var fs = sh.fs; if(typeof options === 'function') { callback = options; options = {}; } options = options || {}; callback = callback || function(){}; var exec = options.exec || function(path, next) { next(); }; var found = []; if(!path) { callback(new Errors.EINVAL('Missing path argument')); return; } function processPath(path, callback) { exec(path, function(err) { if(err) { callback(err); return; } found.push(path); callback(); }); } function maybeProcessPath(path, callback) { // Test the path against the user's regex, name, path primaries (if any) // and remove any trailing slashes added previously. var rawPath = Path.removeTrailing(path); // Check entire path against provided regex, if any if(options.regex && !options.regex.test(rawPath)) { callback(); return; } // Check basename for matches against name primary, if any if(options.name && !minimatch(Path.basename(rawPath), options.name)) { callback(); return; } // Check dirname for matches against path primary, if any if(options.path && !minimatch(Path.dirname(rawPath), options.path)) { callback(); return; } processPath(path, callback); } function walk(path, callback) { path = Path.resolve(sh.pwd(), path); // The path is either a file or dir, and instead of doing // a stat() to determine it first, we just try to readdir() // and it will either work or not, and we handle the non-dir error. fs.readdir(path, function(err, entries) { if(err) { if(err.code === 'ENOTDIR' /* file case, ignore error */) { maybeProcessPath(path, callback); } else { callback(err); } return; } // Path is really a dir, add a trailing / and report it found maybeProcessPath(Path.addTrailing(path), function(err) { if(err) { callback(err); return; } entries = entries.map(function(entry) { return Path.join(path, entry); }); async.eachSeries(entries, walk, function(err) { callback(err, found); }); }); }); } // Make sure we are starting with a dir path fs.stat(path, function(err, stats) { if(err) { callback(err); return; } if(!stats.isDirectory()) { callback(new Errors.ENOTDIR(null, path)); return; } walk(path, callback); }); }; module.exports = Shell;