UNPKG

vorpal

Version:

Node's first framework for building immersive CLI apps.

585 lines (522 loc) 14 kB
'use strict'; /** * Module dependencies. */ var _ = require('lodash'); var inquirer = require('inquirer'); var EventEmitter = require('events').EventEmitter; var chalk = require('chalk'); var util = require('./util'); var logUpdate = require('log-update'); var ui = { /** * Sets intial variables and registers * listeners. This is called once in a * process thread regardless of how many * instances of Vorpal have been generated. * * @api private */ _init: function () { var self = this; // Attached vorpal instance. The UI can // only attach to one instance of Vorpal // at a time, and directs all events to that // instance. this.parent = undefined; // Hook to reference active inquirer prompt. this._activePrompt = undefined; // Fail-safe to ensure there is no double // prompt in odd situations. this._midPrompt = false; // Handle for inquirer's prompt. this.inquirer = inquirer; // Whether a prompt is currently in cancel mode. this._cancelled = false; // Middleware for piping stdout through. this._pipeFn = undefined; // Custom function on sigint event. this._sigintCalled = false; this._sigintCount = 0; this._sigint = function () { if (self._sigintCount > 1) { process.exit(2); } else { var text = self.input(); // There are commands running if // cancelCommands function is available. if (self.parent.session.cancelCommands) { self.parent.session.emit('vorpal_command_cancel'); self.imprint(); self.submit(''); self._sigintCalled = false; self._sigintCount = 0; } else if (String(text).trim() !== '') { self.imprint(); self.submit(''); self._sigintCalled = false; self._sigintCount = 0; } else { self.delimiter(' '); self.submit(''); self.log('(^C again to quit)'); } } }; process.stdin.on('keypress', function (letter, key) { key = key || {}; if (key.ctrl === true && key.shift === false && key.meta === false && ['c', 'C'].indexOf(key.name) > -1) { self._sigintCount++; if (self._sigint && !self._sigintCalled) { self._sigintCalled = true; self._sigint.call(self.parent); } } else { self._sigintCalled = false; self._sigintCount = 0; } }); // Extend the render function to steal the active prompt object, // as inquirer doesn't expose it and we need it. var prompts = ['input', 'checkbox', 'confirm', 'expand', 'list', 'password', 'rawlist']; for (var i = 0; i < prompts.length; ++i) { // Hook in to steal inquirer's keypress. inquirer.prompt.prompts[prompts[i]].prototype.onKeypress = function (e) { // Inquirer seems to have a bug with release v0.10.1 // (not 0.10.0 though) that triggers keypresses for // the previous prompt in addition to the current one. // So if the prompt is answered, shut it up. if (this.status && this.status === 'answered') { return; } self._activePrompt = this; self.parent.emit('client_keypress', e); self._keypressHandler(e, this); }; (function (render) { inquirer.prompt.prompts[prompts[i]].prototype.render = function () { self._activePrompt = this; return render.call(this); }; })(inquirer.prompt.prompts[prompts[i]].prototype.render); } // Sigint handling - make it more graceful. process.on('SIGINT', function () { if (_.isFunction(self._sigint) && !self._sigintCalled) { self._sigintCalled = true; self._sigint.call(self.parent); } }); process.on('SIGTERM', function () { if (_.isFunction(self._sigint) && !self._sigintCalled) { self._sigintCalled = true; self._sigint.call(self.parent); } }); }, /** * Hook for sigint event. * * @param {Object} options * @param {Function} cb * @api public */ sigint: function (fn) { if (_.isFunction(fn)) { this._sigint = fn; } else { throw new Error('vorpal.ui.sigint must be passed in a valid function.'); } return this; }, /** * Creates an inquirer prompt on the TTY. * * @param {Object} options * @param {Function} cb * @api public */ prompt: function (options, cb) { var self = this; var prompt; options = options || {}; if (!this.parent) { return prompt; } if (options.delimiter) { this.setDelimiter(options.delimiter); } if (options.message) { this.setDelimiter(options.message); } if (self._midPrompt) { console.log('Prompt called when mid prompt...'); throw new Error('UI Prompt called when already mid prompt.'); } self._midPrompt = true; try { prompt = inquirer.prompt(options, function (result) { self._midPrompt = false; if (self._cancel === true) { self._cancel = false; } else { cb(result); } }); // Temporary hack. We need to pull the active // prompt from inquirer as soon as possible, // however we can't just assign it sync, as // the prompt isn't ready yet. // I am trying to get inquirer extended to // fire an event instead. setTimeout(function () { // self._activePrompt = prompt._activePrompt; }, 100); } catch (e) { console.log('Vorpal Prompt error:', e); } return prompt; }, /** * Returns a boolean as to whether user * is mid another pr ompt. * * @return {Boolean} * @api public */ midPrompt: function () { var mid = (this._midPrompt === true && this.parent !== undefined); return mid; }, setDelimiter: function (str) { var self = this; if (!this.parent) { return; } str = String(str).trim() + ' '; this._lastDelimiter = str; inquirer.prompt.prompts.password.prototype.getQuestion = function () { self._activePrompt = this; return this.opt.message; }; inquirer.prompt.prompts.input.prototype.getQuestion = function () { self._activePrompt = this; return this.opt.message; }; }, /** * Event handler for keypresses - deals with command history * and tabbed auto-completion. * * @param {Event} e * @param {Prompt} prompt * @api private */ _keypressHandler: function (e, prompt) { // Re-write render function. var width = prompt.rl.line.length; prompt.rl.line = prompt.rl.line.replace(/\t+/, ''); var newWidth = prompt.rl.line.length; var diff = newWidth - width; prompt.rl.cursor += diff; var cursor = 0; var message = prompt.getQuestion(); var addition = (prompt.status === 'answered') ? chalk.cyan(prompt.answer) : prompt.rl.line; message += addition; prompt.screen.render(message, {cursor: cursor}); var key = (e.key || {}).name; var value = (prompt) ? String(prompt.rl.line).trim() : undefined; this.emit('vorpal_ui_keypress', {key: key, value: value, e: e}); }, /** * Pauses active prompt, returning * the value of what had been typed so far. * * @return {String} val * @api public */ pause: function () { if (!this.parent) { return false; } if (!this._activePrompt) { return false; } if (!this._midPrompt) { return false; } var val = this._lastDelimiter + this._activePrompt.rl.line; this._midPrompt = false; var rl = this._activePrompt.screen.rl; var screen = this._activePrompt.screen; rl.output.unmute(); screen.clean(); rl.output.write(''); return val; }, /** * Resumes active prompt, accepting * a string, which will fill the prompt * with that text and put the cursor at * the end. * * @param {String} val * @api public */ resume: function (val) { if (!this.parent) { return this; } val = val || ''; if (!this._activePrompt) { return this; } if (this._midPrompt) { return this; } var rl = this._activePrompt.screen.rl; rl.output.write(val); this._midPrompt = true; return this; }, /** * Cancels the active prompt, essentially * but cutting out of the inquirer loop. * * @api public */ cancel: function () { if (this.midPrompt()) { this._cancel = true; this.submit(''); this._midPrompt = false; } return this; }, /** * Attaches TTY prompt to a given Vorpal instance. * * @param {Vorpal} vorpal * @return {UI} * @api public */ attach: function (vorpal) { this.parent = vorpal; this.refresh(); this.parent._prompt(); return this; }, /** * Detaches UI from a given Vorpal instance. * * @param {Vorpal} vorpal * @return {UI} * @api public */ detach: function (vorpal) { if (vorpal === this.parent) { this.parent = undefined; } return this; }, /** * Receives and runs logging through * a piped function is one is provided * through ui.pipe(). Pauses any active * prompts, logs the data and then if * paused, resumes the prompt. * * @return {UI} * @api public */ log: function () { var args = util.fixArgsForApply(arguments); args = (_.isFunction(this._pipeFn)) ? this._pipeFn(args) : args; if (args === '') { return this; } args = util.fixArgsForApply(args); if (this.midPrompt()) { var data = this.pause(); console.log.apply(console.log, args); if (typeof data !== 'undefined' && data !== false) { this.resume(data); } else { console.log('Log got back \'false\' as data. This shouldn\'t happen.', data); } } else { console.log.apply(console.log, args); } return this; }, /** * Submits a given prompt. * * @param {String} value * @return {UI} * @api public */ submit: function () { if (this._activePrompt) { // this._activePrompt.screen.onClose(); this._activePrompt.rl.emit('line'); // this._activePrompt.onEnd({isValid: true, value: value}); // to do - I don't know a good way to do this. } return this; }, /** * Does a literal, one-time write to the * *current* prompt delimiter. * * @param {String} str * @return {UI} * @api public */ delimiter: function (str) { if (!this._activePrompt) { return this; } var prompt = this._activePrompt; if (str === undefined) { return prompt.opt.message; } prompt.opt.message = str; this.refresh(); return this; }, /** * Re-writes the input of an Inquirer prompt. * If no string is passed, it gets the current * input. * * @param {String} str * @return {String} * @api public */ input: function (str) { if (!this._activePrompt) { return undefined; } var prompt = this._activePrompt; if (str === undefined) { return prompt.rl.line; } var width = prompt.rl.line.length; prompt.rl.line = str; var newWidth = prompt.rl.line.length; var diff = newWidth - width; prompt.rl.cursor += diff; var cursor = 0; var message = prompt.getQuestion(); var addition = (prompt.status === 'answered') ? chalk.cyan(prompt.answer) : prompt.rl.line; message += addition; prompt.screen.render(message, {cursor: cursor}); return this; }, /** * Logs the current delimiter and typed data. * * @return {UI} * @api public */ imprint: function () { if (!this.parent) { return this; } var val = this._activePrompt.rl.line; var delimiter = this._lastDelimiter || this.delimiter() || ''; this.log(delimiter + val); return this; }, /** * Redraws the inquirer prompt with a new string. * * @param {String} str * @return {UI} * @api private */ refresh: function () { if (!this.parent || !this._activePrompt) { return this; } this._activePrompt.screen.clean(); this._activePrompt.render(); this._activePrompt.rl.output.write(this._activePrompt.rl.line); return this; }, /** * Writes over existing logging. * * @param {String} str * @return {UI} * @api public */ redraw: function (str) { logUpdate(str); return this; } }; /** * Clears logging from `ui.redraw` * permanently. * * @return {UI} * @api public */ ui.redraw.clear = function () { logUpdate.clear(); return ui; }; /** * Prints logging from `ui.redraw` * permanently. * * @return {UI} * @api public */ ui.redraw.done = function () { logUpdate.done(); ui.refresh(); return ui; }; /** * Make UI an EventEmitter. */ _.assign(ui, EventEmitter.prototype); /** * Expose `ui`. * * Modifying global? WTF?!? Yes. It is evil. * However node.js prompts are also quite * evil in a way. Nothing prevents dual prompts * between applications in the same terminal, * and inquirer doesn't catch or deal with this, so * if you want to start two independent instances of * vorpal, you need to know that prompt listeners * have already been initiated, and that you can * only attach the tty to one vorpal instance * at a time. * When you fire inqurier twice, you get a double-prompt, * where every keypress fires twice and it's just a * total mess. So forgive me. */ global.__vorpal = global.__vorpal || {}; global.__vorpal.ui = global.__vorpal.ui || { exists: false, exports: undefined }; if (!global.__vorpal.ui.exists) { global.__vorpal.ui.exists = true; global.__vorpal.ui.exports = ui; module.exports = exports = ui; ui._init(); } else { module.exports = global.__vorpal.ui.exports; }