filer/src/shell/shell.js

560 lines
14 KiB
JavaScript

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: <String> the basename of the directory entry
* links: <Number> the number of links to the entry
* size: <Number> the size in bytes of the entry
* modified: <Number> the last modified date/time
* type: <String> the type of the entry
* contents: <Array> 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;