/* * prompt.js: Simple prompt for prompting information from the command line * * (C) 2010, Nodejitsu Inc. * */ var events = require('events'), readline = require('readline'), utile = require('utile'), async = utile.async, read = require('read'), validate = require('revalidator').validate, winston = require('winston'), colors = require('colors/safe'); // // Monkey-punch readline.Interface to work-around // https://github.com/joyent/node/issues/3860 // readline.Interface.prototype.setPrompt = function(prompt, length) { this._prompt = prompt; if (length) { this._promptLength = length; } else { var lines = prompt.split(/[\r\n]/); var lastLine = lines[lines.length - 1]; this._promptLength = lastLine.replace(/\u001b\[(\d+(;\d+)*)?m/g, '').length; } }; // // Expose version using `pkginfo` // require('pkginfo')(module, 'version'); var stdin, stdout, history = []; var prompt = module.exports = Object.create(events.EventEmitter.prototype); var logger = prompt.logger = new winston.Logger({ transports: [new (winston.transports.Console)()] }); prompt.started = false; prompt.paused = false; prompt.stopped = true; prompt.allowEmpty = false; prompt.message = 'prompt'; prompt.delimiter = ': '; prompt.colors = true; // // Create an empty object for the properties // known to `prompt` // prompt.properties = {}; // // Setup the default winston logger to use // the `cli` levels and colors. // logger.cli(); // // ### function start (options) // #### @options {Object} **Optional** Options to consume by prompt // Starts the prompt by listening to the appropriate events on `options.stdin` // and `options.stdout`. If no streams are supplied, then `process.stdin` // and `process.stdout` are used, respectively. // prompt.start = function (options) { if (prompt.started) { return; } options = options || {}; stdin = options.stdin || process.stdin; stdout = options.stdout || process.stdout; // // By default: Remember the last `10` prompt property / // answer pairs and don't allow empty responses globally. // prompt.memory = options.memory || 10; prompt.allowEmpty = options.allowEmpty || false; prompt.message = options.message || prompt.message; prompt.delimiter = options.delimiter || prompt.delimiter; prompt.colors = options.colors || prompt.colors; if (process.platform !== 'win32') { // windows falls apart trying to deal with SIGINT process.on('SIGINT', function () { stdout.write('\n'); process.exit(1); }); } prompt.emit('start'); prompt.started = true; prompt.stopped = false; return prompt; }; // // ### function pause () // Pauses input coming in from stdin // prompt.pause = function () { if (!prompt.started || prompt.stopped || prompt.paused) { return; } stdin.pause(); prompt.emit('pause'); prompt.paused = true; return prompt; }; // // ### function stop () // Stops input coming in from stdin // prompt.stop = function () { if (prompt.stopped || !prompt.started) { return; } stdin.destroy(); prompt.emit('stop'); prompt.stopped = true; prompt.started = false; prompt.paused = false; return prompt; } // // ### function resume () // Resumes input coming in from stdin // prompt.resume = function () { if (!prompt.started || !prompt.paused) { return; } stdin.resume(); prompt.emit('resume'); prompt.paused = false; return prompt; }; // // ### function history (search) // #### @search {Number|string} Index or property name to find. // Returns the `property:value` pair from within the prompts // `history` array. // prompt.history = function (search) { if (typeof search === 'number') { return history[search] || {}; } var names = history.map(function (pair) { return typeof pair.property === 'string' ? pair.property : pair.property.name; }); if (!~names.indexOf(search)) { return null; } return history.filter(function (pair) { return typeof pair.property === 'string' ? pair.property === search : pair.property.name === search; })[0]; }; // // ### function get (schema, callback) // #### @schema {Array|Object|string} Set of variables to get input for. // #### @callback {function} Continuation to pass control to when complete. // Gets input from the user via stdin for the specified message(s) `msg`. // prompt.get = function (schema, callback) { // // Transforms a full JSON-schema into an array describing path and sub-schemas. // Used for iteration purposes. // function untangle(schema, path) { var results = []; path = path || []; if (schema.properties) { // // Iterate over the properties in the schema and use recursion // to process sub-properties. // Object.keys(schema.properties).forEach(function (key) { var obj = {}; obj[key] = schema.properties[key]; // // Concat a sub-untangling to the results. // results = results.concat(untangle(obj[key], path.concat(key))); }); // Return the results. return results; } // // This is a schema "leaf". // return { path: path, schema: schema }; } // // Iterate over the values in the schema, represented as // a legit single-property object subschemas. Accepts `schema` // of the forms: // // 'prop-name' // // ['string-name', { path: ['or-well-formed-subschema'], properties: ... }] // // { path: ['or-well-formed-subschema'], properties: ... ] } // // { properties: { 'schema-with-no-path' } } // // And transforms them all into // // { path: ['path', 'to', 'property'], properties: { path: { to: ...} } } // function iterate(schema, get, done) { var iterator = [], result = {}; if (typeof schema === 'string') { // // We can iterate over a single string. // iterator.push({ path: [schema], schema: prompt.properties[schema.toLowerCase()] || {} }); } else if (Array.isArray(schema)) { // // An array of strings and/or single-prop schema and/or no-prop schema. // iterator = schema.map(function (element) { if (typeof element === 'string') { return { path: [element], schema: prompt.properties[element.toLowerCase()] || {} }; } else if (element.properties) { return { path: [Object.keys(element.properties)[0]], schema: element.properties[Object.keys(element.properties)[0]] }; } else if (element.path && element.schema) { return element; } else { return { path: [element.name || 'question'], schema: element }; } }); } else if (schema.properties) { // // Or a complete schema `untangle` it for use. // iterator = untangle(schema); } else { // // Or a partial schema and path. // TODO: Evaluate need for this option. // iterator = [{ schema: schema.schema ? schema.schema : schema, path: schema.path || [schema.name || 'question'] }]; } // // Now, iterate and assemble the result. // async.forEachSeries(iterator, function (branch, next) { get(branch, function assembler(err, line) { if (err) { return next(err); } function build(path, line) { var obj = {}; if (path.length) { obj[path[0]] = build(path.slice(1), line); return obj; } return line; } function attach(obj, attr) { var keys; if (typeof attr !== 'object' || attr instanceof Array) { return attr; } keys = Object.keys(attr); if (keys.length) { if (!obj[keys[0]]) { obj[keys[0]] = {}; } obj[keys[0]] = attach(obj[keys[0]], attr[keys[0]]); } return obj; } result = attach(result, build(branch.path, line)); next(); }); }, function (err) { return err ? done(err) : done(null, result); }); } iterate(schema, function get(target, next) { prompt.getInput(target, function (err, line) { return err ? next(err) : next(null, line); }); }, callback); return prompt; }; // // ### function confirm (msg, callback) // #### @msg {Array|Object|string} set of message to confirm // #### @callback {function} Continuation to pass control to when complete. // Confirms a single or series of messages by prompting the user for a Y/N response. // Returns `true` if ALL messages are answered in the affirmative, otherwise `false` // // `msg` can be a string, or object (or array of strings/objects). // An object may have the following properties: // // { // description: 'yes/no' // message to prompt user // pattern: /^[yntf]{1}/i // optional - regex defining acceptable responses // yes: /^[yt]{1}/i // optional - regex defining `affirmative` responses // message: 'yes/no' // optional - message to display for invalid responses // } // prompt.confirm = function (/* msg, options, callback */) { var args = Array.prototype.slice.call(arguments), msg = args.shift(), callback = args.pop(), opts = args.shift(), vars = !Array.isArray(msg) ? [msg] : msg, RX_Y = /^[yt]{1}/i, RX_YN = /^[yntf]{1}/i; function confirm(target, next) { var yes = target.yes || RX_Y, options = utile.mixin({ description: typeof target === 'string' ? target : target.description||'yes/no', pattern: target.pattern || RX_YN, name: 'confirm', message: target.message || 'yes/no' }, opts || {}); prompt.get([options], function (err, result) { next(err ? false : yes.test(result[options.name])); }); } async.rejectSeries(vars, confirm, function(result) { callback(null, result.length===0); }); }; // Variables needed outside of getInput for multiline arrays. var tmp = []; // ### function getInput (prop, callback) // #### @prop {Object|string} Variable to get input for. // #### @callback {function} Continuation to pass control to when complete. // Gets input from the user via stdin for the specified message `msg`. // prompt.getInput = function (prop, callback) { var schema = prop.schema || prop, propName = prop.path && prop.path.join(':') || prop, storedSchema = prompt.properties[propName.toLowerCase()], delim = prompt.delimiter, defaultLine, against, hidden, length, valid, name, raw, msg; // // If there is a stored schema for `propName` in `propmpt.properties` // then use it. // if (schema instanceof Object && !Object.keys(schema).length && typeof storedSchema !== 'undefined') { schema = storedSchema; } // // Build a proper validation schema if we just have a string // and no `storedSchema`. // if (typeof prop === 'string' && !storedSchema) { schema = {}; } schema = convert(schema); defaultLine = schema.default; name = prop.description || schema.description || propName; raw = prompt.colors ? [colors.grey(name), colors.grey(delim)] : [name, delim]; if (prompt.message) raw.unshift(prompt.message, delim); prop = { schema: schema, path: propName.split(':') }; // // If the schema has no `properties` value then set // it to an object containing the current schema // for `propName`. // if (!schema.properties) { schema = (function () { var obj = { properties: {} }; obj.properties[propName] = schema; return obj; })(); } // // Handle overrides here. // TODO: Make overrides nestable // if (prompt.override && prompt.override[propName]) { if (prompt._performValidation(name, prop, prompt.override, schema, -1, callback)) { return callback(null, prompt.override[propName]); } delete prompt.override[propName]; } // // Check if we should skip this prompt // if (typeof prop.schema.ask === 'function' && !prop.schema.ask()) { return callback(null, prop.schema.default || ''); } var type = (schema.properties && schema.properties[propName] && schema.properties[propName].type || '').toLowerCase().trim(), wait = type === 'array'; if (type === 'array') { length = prop.schema.maxItems; if (length) { msg = (tmp.length + 1).toString() + '/' + length.toString(); } else { msg = (tmp.length + 1).toString(); } msg += delim; raw.push(prompt.colors ? msg.grey : msg); } // // Calculate the raw length and colorize the prompt // length = raw.join('').length; raw[0] = raw[0]; msg = raw.join(''); if (schema.help) { schema.help.forEach(function (line) { logger.help(line); }); } // // Emit a "prompting" event // prompt.emit('prompt', prop); // // If there is no default line, set it to an empty string // if(typeof defaultLine === 'undefined') { defaultLine = ''; } // // set to string for readline ( will not accept Numbers ) // defaultLine = defaultLine.toString(); // // Make the actual read // read({ prompt: msg, silent: prop.schema && prop.schema.hidden, replace: prop.schema && prop.schema.replace, default: defaultLine, input: stdin, output: stdout }, function (err, line) { if (err && wait === false) { return callback(err); } var against = {}, numericInput, isValid; if (line !== '') { if (schema.properties[propName]) { var type = (schema.properties[propName].type || '').toLowerCase().trim() || undefined; // // If type is some sort of numeric create a Number object to pass to revalidator // if (type === 'number' || type === 'integer') { line = Number(line); } // // Attempt to parse input as a boolean if the schema expects a boolean // if (type == 'boolean') { if(line.toLowerCase() === "true" || line.toLowerCase() === 't') { line = true; } else if(line.toLowerCase() === "false" || line.toLowerCase() === 'f') { line = false; } } // // If the type is an array, wait for the end. Fixes #54 // if (type == 'array') { var length = prop.schema.maxItems; if (err) { if (err.message == 'canceled') { wait = false; stdout.write('\n'); } } else { if (length) { if (tmp.length + 1 < length) { isValid = false; wait = true; } else { isValid = true; wait = false; } } else { isValid = false; wait = true; } tmp.push(line); } line = tmp; } } against[propName] = line; } if (prop && prop.schema.before) { line = prop.schema.before(line); } // Validate if (isValid === undefined) isValid = prompt._performValidation(name, prop, against, schema, line, callback); if (!isValid) { return prompt.getInput(prop, callback); } // // Log the resulting line, append this `property:value` // pair to the history for `prompt` and respond to // the callback. // logger.input(line.yellow); prompt._remember(propName, line); callback(null, line); // Make sure `tmp` is emptied tmp = []; }); }; // // ### function performValidation (name, prop, against, schema, line, callback) // #### @name {Object} Variable name // #### @prop {Object|string} Variable to get input for. // #### @against {Object} Input // #### @schema {Object} Validation schema // #### @line {String|Boolean} Input line // #### @callback {function} Continuation to pass control to when complete. // Perfoms user input validation, print errors if needed and returns value according to validation // prompt._performValidation = function (name, prop, against, schema, line, callback) { var numericInput, valid, msg; try { valid = validate(against, schema); } catch (err) { return (line !== -1) ? callback(err) : false; } if (!valid.valid) { if (prop.schema.message) { logger.error(prop.schema.message); } else { msg = line !== -1 ? 'Invalid input for ' : 'Invalid command-line input for '; if (prompt.colors) { logger.error(msg + name.grey); } else { logger.error(msg + name); } } prompt.emit('invalid', prop, line); } return valid.valid; }; // // ### function addProperties (obj, properties, callback) // #### @obj {Object} Object to add properties to // #### @properties {Array} List of properties to get values for // #### @callback {function} Continuation to pass control to when complete. // Prompts the user for values each of the `properties` if `obj` does not already // have a value for the property. Responds with the modified object. // prompt.addProperties = function (obj, properties, callback) { properties = properties.filter(function (prop) { return typeof obj[prop] === 'undefined'; }); if (properties.length === 0) { return callback(obj); } prompt.get(properties, function (err, results) { if (err) { return callback(err); } else if (!results) { return callback(null, obj); } function putNested (obj, path, value) { var last = obj, key; while (path.length > 1) { key = path.shift(); if (!last[key]) { last[key] = {}; } last = last[key]; } last[path.shift()] = value; } Object.keys(results).forEach(function (key) { putNested(obj, key.split('.'), results[key]); }); callback(null, obj); }); return prompt; }; // // ### @private function _remember (property, value) // #### @property {Object|string} Property that the value is in response to. // #### @value {string} User input captured by `prompt`. // Prepends the `property:value` pair into the private `history` Array // for `prompt` so that it can be accessed later. // prompt._remember = function (property, value) { history.unshift({ property: property, value: value }); // // If the length of the `history` Array // has exceeded the specified length to remember, // `prompt.memory`, truncate it. // if (history.length > prompt.memory) { history.splice(prompt.memory, history.length - prompt.memory); } }; // // ### @private function convert (schema) // #### @schema {Object} Schema for a property // Converts the schema into new format if it is in old format // function convert(schema) { var newProps = Object.keys(validate.messages), newSchema = false, key; newProps = newProps.concat(['description', 'dependencies']); for (key in schema) { if (newProps.indexOf(key) > 0) { newSchema = true; break; } } if (!newSchema || schema.validator || schema.warning || typeof schema.empty !== 'undefined') { schema.description = schema.message; schema.message = schema.warning; if (typeof schema.validator === 'function') { schema.conform = schema.validator; } else { schema.pattern = schema.validator; } if (typeof schema.empty !== 'undefined') { schema.required = !(schema.empty); } delete schema.warning; delete schema.validator; delete schema.empty; } return schema; }