cli-input
Version:
Prompt and user input library.
753 lines (652 loc) • 20.1 kB
JavaScript
var EOL = require('os').EOL
, events = require('events')
, util = require('util')
, path = require('path')
, async = require('async')
, utils = require('cli-util')
, merge = utils.merge
, native = require('cli-native')
, read = require('./lib/read')
, history = require('./lib/history')
, sets = require('./lib/sets')
, definitions = sets.definitions
, PromptDefinition = require('./lib/definition');
function noop(){};
var schema;
try{
schema = require('async-validate');
}catch(e){}
var types = {
binary: 'binary',
password: 'password'
}
/**
* Create a prompt.
*/
function Prompt(options, rl) {
options = options || {};
this.rl = rl || {};
this.rl.completer = this.rl.completer || options.completer;
this.rl.input = options.input || process.stdin;
this.rl.output = options.output || process.stdout;
this.rl.terminal = !!(options.terminal || this.rl.output.isTTY);
this.readline = read.open(this.rl);
// no not store these in the options
// to prevent cyclical reference on merge
this.input = options.input || this.rl.input;
this.output = options.output || this.rl.output;
delete options.input;
delete options.output;
this.formats = options.formats || {};
// format for default values
this.formats.default = this.formats.default || '(%s) ';
// format for select options
this.formats.option = this.formats.select || '%s) %s';
this.name = options.name || path.basename(process.argv[1]);
this.fmt = (options.format === '' ? '' :
(options.format || ':name :delimiter :health :location :status :message :default'));
options.validator = options.validator !== undefined
? options.validator : {};
// default prompt
options.prompt = options.prompt || '>';
// default replacement character for silent
options.replace = options.replace || '*';
// determine if a prompt should be re-displayed at the end of a run
options.infinite = options.infinite !== undefined ? options.infinite : false;
// convert to native types
options.native = options.native !== undefined ? options.native : null;
// when running in infinite mode, restore to default prompt at end of run
options.restore = options.restore !== undefined ? options.restore : true;
// when a validation error occurs repeat the last prompt
// until we get a valid value
options.repeat = options.repeat !== undefined ? options.repeat : true;
// trim leading and trailing whitespace from input lines
options.trim = options.trim !== undefined ? options.trim : false;
// split values into array
options.split = options.split !== undefined ? options.split : null;
// delimiter that comes after the name
options.delimiter = options.delimiter || '⚡';
// color callback functions
options.colors = options.colors || {};
// history has cyclical references
if(options.history) {
this.history = options.history;
delete options.history;
}
this.keys = this.fmt.split(' ').map(function(value) {
return value.replace(/^:/, '');
})
this.options = options;
this._use = {};
}
util.inherits(Prompt, events.EventEmitter);
Prompt.prototype.use = function(props) {
this._use = merge(props, this._use);
}
Prompt.prototype.transform = function(k, v, options) {
var fmts = merge(this.formats, {}, {copy: true});
fmts = merge(options.formats || {}, fmts, {copy: true});
if(fmts[k] && v) {
v = util.format(fmts[k], v);
}
return v;
}
Prompt.prototype.replace = function(format, source, options) {
var s = '' + format, k, v;
var items = {}, keys = this.keys;
function clean(s) {
// strip extraneous keys
for(var i = 0;i < keys.length;i++) {
s = s.replace(new RegExp(':' + keys[i], 'g'), '');
}
// strip multiple whitespace
s = s.replace(/ +/g, ' ');
return s;
}
var highlights = this.rl.output
&& this.rl.output.isTTY && this._use.colors !== false;
var prefixed = this.options.colors && this.options.colors.prefix;
var name = source.name
, delimiter = source.delimiter;
if(prefixed) {
if(highlights) {
prefixed = this.options.colors.prefix(name, delimiter);
}
delete source.name;
delete source.delimiter;
}
var replaces = false;
for(k in source) {
v = source[k];
if(typeof v === 'function') {
v = v(k, options);
}
replaces = Array.isArray(options.parameters) && k === 'message';
// store them for processing later
items[k] = {k: k, v: v}
// parameter replacment
if(replaces) {
items[k].v = util.format(v, options.parameters);
if(highlights && typeof this.options.colors.parameters === 'function') {
items[k].c =
util.format(v, this.options.colors.parameters(options.parameters));
}
}
// get colorized values
if(highlights
&& typeof this.options.colors[k] === 'function' && !replaces) {
items[k].c = this.options.colors[k](v);
}
}
// build up plain string so we can get the length
var raw = '' + format;
for(k in items) {
v = items[k].v;
v = this.transform(k, v, options);
raw = raw.replace(new RegExp(':' + k, 'gi'), v ? v : '');
}
raw = clean(raw);
// now build up a colorized version
s = '' + format;
for(k in items) {
v = items[k].c || items[k].v;
v = this.transform(k, v, options);
s = s.replace(new RegExp(':' + k, 'gi'), v ? v : '');
}
s = clean(s);
if(prefixed && prefixed.value && prefixed.color) {
raw = prefixed.value + raw;
s = prefixed.color + s;
}
return {prompt: s, raw: raw};
}
Prompt.prototype.format = function(options) {
var source = options.data || {};
source.name = source.name || this.name;
source.date = new Date();
source.message = options.message;
source.delimiter = options.delimiter || this.options.delimiter;
source.default = options.default;
return this.replace(options.format || this.fmt, source, options);
}
Prompt.prototype.merge = function(options) {
var o = merge(this.options, {}, {copy: true}), fmt;
o = merge(options, o, {copy: true});
if(typeof this.options.prompt === 'function') {
o.prompt = this.options.prompt(options, o, this);
}else{
fmt = this.format(o);
o.raw = fmt.raw;
// plain prompt length with no color (ANSI)
// store string length so we can workaround
// #3860, fix available from 0.11.3 node
o.length = fmt.raw.length;
o.prompt = fmt.prompt;
}
if(o.silent && !o.replace) {
o.replace = this.options.replace;
}
return o;
}
Prompt.prototype.getDefaultPrompt = function() {
return {
key: 'default',
prompt: this.options.prompt
}
}
/**
* Pause the prompt when running a set or in infinite mode
* prevents the next call to run() from being executed until
* resume() is called.
*/
Prompt.prototype.pause = function pause() {
this._paused = true;
this.emit('pause', this);
}
/**
* Determine if this prompt is paused.
*/
Prompt.prototype.isPaused = function isPaused() {
return this._paused;
}
/**
* Close the readline interface.
*/
Prompt.prototype.close = function close() {
this.readline.close();
}
/**
* Resume a paused prompt.
*/
Prompt.prototype.resume = function resume(options, cb) {
if(!this._paused) return;
var scope = this;
this._paused = false;
options = options || {};
if(options.infinite || this.options.infinite) {
this.exec(options || this.getDefaultPrompt(), cb);
}
this.emit('resume', this);
}
/**
* Display a prompt with the specified options.
*/
Prompt.prototype.prompt = function(options, cb) {
this.exec(options, cb);
}
/**
* Display a prompt from a set.
*/
Prompt.prototype.run = function(prompts, opts, cb) {
if(typeof prompts === 'function') {
cb = prompts;
prompts = null;
}
if(typeof opts === 'function') {
cb = opts;
opts = null;
}
cb = typeof cb === 'function' ? cb : noop;
opts = opts || {};
var scope = this, options = this.options;
prompts = prompts || [scope.getDefaultPrompt()];
var map = {};
async.concatSeries(prompts, function(item, callback) {
scope.exec(item, function(err, result) {
if(item.key) {
map[item.key] = result;
}
callback(err, result);
});
}, function(err, result) {
if(err && err.cancel) return scope.emit('cancel', prompts, scope);
if(err && err.timeout) return scope.emit('timeout', prompts, scope);
if(err && err.paused) return scope.emit('paused', prompts, scope);
if(err) {
scope.emit('error', prompts, scope);
}
var res = {list: result, map: map};
function done() {
//console.dir('run complete');
scope.emit('complete', res);
cb(null, res);
if((opts.infinite || scope.options.infinite) && !scope._paused) {
return scope.run(prompts, opts, cb);
}
}
if(opts.schema && res && res.map) {
scope.validate(res.map, opts.schema, function(errors, fields) {
if(errors && errors.length) {
return scope.emit('error', errors[0], errors, fields, res, scope);
}
done();
})
}else{
done();
}
})
}
/**
* Default implementation for formatting option values.
*
* @param index The index into the options list.
* @param value The value for the option.
* @param default A default value for the list.
*/
Prompt.prototype.option = function(index, value, def) {
if(!def || def !== value) {
return util.format(this.formats.option, index + 1, value);
}else if(def === value){
return util.format(this.formats.option, index + 1, value)
+ ' ' + util.format(this.formats.default, 'default');
}
}
/**
* Select from a list of options.
*
* Display numbers are 1 based.
*/
Prompt.prototype.select = function(options, cb) {
if(typeof options === 'function') {
cb = options;
options = null;
}
options = options || {};
var scope = this, i, s, map = [];
var output = options.output || this.output;
var list = options.list || [];
var validate = options.validate !== undefined
? options.validate : true;
var formatter = typeof options.formatter === 'function'
? options.formatter : this.option.bind(this);
var prompt = options.prompt || definitions.option.clone();
var defaultOption = options.default;
var defaultIndex = -1, def;
if(defaultOption !== undefined) {
if(typeof defaultOption === 'number') {
def = list[defaultOption];
if(def) {
defaultIndex = defaultOption;
defaultOption = def;
}
}else if(typeof defaultOption === 'string') {
defaultIndex = list.indexOf(defaultOption);
if(defaultIndex === -1) {
defaultOption = undefined;
}
}
}
if(defaultOption && defaultIndex > -1) {
prompt.required = false;
}
// print list
for(i = 0;i < list.length;i++) {
s = formatter(i, list[i], defaultOption);
map.push({display: i + 1, index: i, value: list[i]});
output.write(s + EOL);
}
// show prompt
function show() {
scope.exec(prompt, function(err, res) {
if(err) return cb(err);
var int = parseInt(res)
, oint = int
, val = !isNaN(int) ? map[--int] : null
, invalid = isNaN(int) || !val;
if(!res && defaultOption && defaultIndex > -1) {
val = map[defaultIndex];
if(val) {
int = defaultIndex;
invalid = false;
}
}
if(validate && invalid) {
scope.emit('invalid', res, oint, options, scope);
}
if(options.repeat || prompt.repeat && (validate && invalid)) {
return show();
}
if(!invalid) cb(err, val, int, res);
});
}
show();
}
/**
* Collect multiline into a string.
*/
Prompt.prototype.multiline = function(options, cb, lines, vpos) {
if(typeof options === 'function') {
cb = options;
options = null;
}
options = options || {};
lines = lines || [];
var raw;
var scope = this
, readline = this.readline
, rl = require('readline')
, line = ''
, input = this.input
, output = this.output
, key = options.key || '\u0004'
, newline = options.newline !== undefined ? options.newline : true
, prompt = options.prompt || {blank: true};
if(typeof key !== 'function') {
key = (function(key, input) {
return key === input;
}).bind(this, key);
}
// disable history for multiline
var history = readline.history;
readline.history = [];
function onkeypress(c, props) {
props = props || {};
// handle exit key
if(key(c)) {
input.removeListener('keypress', onkeypress);
if(newline) {
output.write(EOL);
}
// handle trailing lines with no newline
if(line !== undefined && !~line.indexOf(EOL)) {
lines.push(line);
}
raw = lines.join(EOL);
// restore history
readline.history = history;
//console.log('final lines');
//console.dir(lines);
//console.dir(raw);
// parse as JSON
if(options.json) {
try {
lines = JSON.parse(raw);
}catch(e) {
return cb(e, lines, raw);
}
}
return cb(null, lines, raw);
}
}
vpos = vpos || 0;
// this is a hack and uses the readline internals
// but saves us duplicating all the logic for *where*
// to insert the current character
var insert = readline._insertString;
readline._insertString = function(c) {
insert.call(readline, c);
line = readline.line;
}
var delLeft = readline._deleteLeft;
readline._deleteLeft = function() {
var ln = lines[vpos];
// backspace at beginning of line
if(!readline.line && vpos && !ln) {
if(ln === undefined) ln = lines[vpos-1];
if(ln !== undefined) {
--vpos;
readline.line = ln;
rl.moveCursor(readline.input, ln.length, -1);
readline.cursor = ln.length;
readline._refreshLine();
return lines.pop();
}
}else if(vpos < lines.length && vpos >= 0) {
// here we are not on the last line
// and the default implementation would
// clearScreenDown() which removes subsequent lines
ln = readline.line;
var pos = readline.cursor;
var beg = ln.substr(0, pos - 1);
var end = ln.substr(pos);
ln = beg + end;
rl.clearLine(readline.output, 0);
readline.line = ln;
lines[vpos] = ln;
rl.cursorTo(readline.input, 0);
readline.output.write(ln);
readline.cursor = pos - 1;
}else{
delLeft.call(readline);
}
}
// support navigating up/down with cursor keys
var previous = readline._historyPrev;
var next = readline._historyNext;
readline._historyPrev = function() {
if(vpos === 0) return;
var cl = readline.line;
var nl = lines[--vpos] || line || '';
var x = 0, pos = readline.cursor;
if(nl.length < pos) {
x = nl.length - pos;
}
rl.moveCursor(readline.input, x, -1);
readline.line = nl;
readline.cursor = pos + x;
}
readline._historyNext = function() {
if(!lines.length || vpos >= lines.length) return;
var cl = readline.line;
var nl = lines[++vpos] || line || '';
var x = 0, pos = readline.cursor;
if(nl.length < pos) {
x = nl.length - pos;
}
rl.moveCursor(readline.input, x, 1);
readline.line = nl;
}
input.on('keypress', onkeypress);
prompt.expand = false;
// must re-assign the prompt for recursive calls to respect
// our configuration
options.prompt = prompt;
scope.exec(prompt, function online(err, val) {
input.removeListener('keypress', onkeypress);
if(err) return cb(err);
lines.push(val);
scope.multiline(options, cb, lines, ++vpos);
});
}
/**
* Validate against a schema.
*/
Prompt.prototype.validate = function(source, descriptor, cb) {
if(!schema) return cb();
var validator = new schema(descriptor);
validator.validate(source, this.options.validator,
function onvalidate(errors, fields) {
cb(errors, fields);
}
);
}
/**
* @private
*/
Prompt.prototype.exec = function(options, cb) {
if(typeof options === 'function') {
cb = options;
options = null;
}
options = options || {};
options = this.merge(options);
cb = typeof cb === 'function' ? cb : noop;
var scope = this;
var opts = {}, k;
var trim = options.trim;
for(k in options) opts[k] = options[k];
opts.rl = this.rl;
opts.emitter = this;
this.emit('before', opts, options, scope);
if(options.blank) {
opts.prompt = '';
opts.length = 0;
}
read(opts, function(err, value, rl) {
if(err) return cb(err);
//console.log('got read value "%s" (%s)', value, typeof value);
var val = (value || '').trim();
if(!val) {
scope.emit('empty', options, scope);
}
// required and repeat, prompt until we get a value
if(!val && options.required && options.repeat) {
return scope.exec(options, cb);
}
if(options.native && val) {
val =
native.to(val, options.native.delimiter, options.native.json);
}
if(!trim && typeof val === 'string') {
val = value;
}
if((typeof options.split === 'string' || options.split instanceof RegExp)
&& val && typeof val === 'string') {
val = val.split(options.split);
val = val.filter(function(part) {
return part;
});
}
//console.log('emitting value %j', options.key);
//console.log('emitting value %s', cb);
if(options.history === false && rl.history) {
rl.history.shift();
}
if(options.type === types.binary) {
var accept = options.accept
, reject = options.reject;
if(accept.test(val)) {
val = {result: val, accept: true}
scope.emit('accepted', val, scope);
}else if(reject.test(val)) {
val = {result: val, accept: false}
scope.emit('rejected', val, scope);
}else{
val = {result: val, accept: null}
scope.emit('unacceptable', val, options, scope);
if(options.repeat) return scope.exec(options, cb);
}
}
if(options.type === types.password && options.equal) {
if(!options.pass1) {
options.pass1 = val;
options.default = options.confirmation;
// gather password confirmation
return scope.exec(options, cb);
}else{
options.pass2 = val;
if(options.pass1 !== options.pass2) {
scope.emit('mismatch',
options.pass1, options.pass2, options, scope);
delete options.pass1;
delete options.pass2;
delete options.default;
return scope.exec(options, cb);
}
}
}
if(options.type === undefined) {
// convert to command array for items with no type
if(typeof val === 'string' && options.expand !== false) {
val = val.split(/\s+/);
}
//console.dir('emitting value with cb: ' + cb);
scope.emit('value', val, options, scope);
if(scope.options.infinite && !scope._paused && cb === noop) {
return scope.exec(options, cb);
}
}else{
scope.emit(options.type, val, options, scope);
}
// validate on a schema assigned to the prompt
if(schema && options.schema && options.key) {
var source = {}
, descriptor = {type: 'object', fields: {}}
source[options.key] = value;
descriptor.fields[options.key] = options.schema;
scope.validate(source, descriptor, function(errors, fields) {
if(errors && errors.length) {
if(options.repeat) {
scope.emit('error', errors[0], options, cb);
return scope.exec(options, cb);
}
return cb(errors[0], value);
}
cb(null, val);
});
}else{
cb(null, val);
}
});
}
function prompt(options) {
return new Prompt(options);
}
prompt.read = read;
prompt.errors = read.errors,
prompt.sets = sets;
prompt.PromptDefinition = PromptDefinition;
prompt.history = history;
prompt.History = history.History;
prompt.HistoryFile = history.HistoryFile;
module.exports = prompt;