UNPKG

14.1 kBJavaScriptView Raw
1/*
2 * Copyright 2014-2016 Guy Bedford (http://guybedford.com)
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17var chalk = require('chalk');
18
19var apiResolver;
20var logging = true;
21
22var logBuffer = '';
23var errBuffer = '';
24
25var logged = false;
26var cli = module.exports;
27var Promise = require('bluebird');
28
29var ui = exports;
30
31var readline = require('readline');
32
33var Writable = require('stream').Writable;
34
35var isWin = require('./common').isWin;
36
37function MutableStdout(opts) {
38 Writable.call(this, opts);
39}
40MutableStdout.prototype = Object.create(Writable.prototype);
41MutableStdout.prototype._write = function(chunk, encoding, callback) {
42 if (!this.muted)
43 process.stdout.write(chunk, encoding);
44 callback();
45};
46
47var mutableStdout = new MutableStdout();
48
49ui.logLevel = 3; // corresponds to 'info'
50var logTypes = ['err', 'warn', 'ok', 'info', 'debug'];
51
52// if storing any logs, dump them on exit
53var inputRedact;
54process.on('exit', function() {
55 if (!apiResolver) {
56 if (inputRedact) {
57 console.log('');
58 inputRedact();
59 }
60 if (logBuffer)
61 process.stdout.write(logBuffer);
62 if (errBuffer)
63 process.stderr.write(errBuffer);
64 }
65});
66
67exports.setResolver = function(resolver) {
68 apiResolver = resolver;
69};
70
71// use the default option in all prompts
72// throws an error for prompts that don't take a default
73var useDefaults;
74exports.useDefaults = function(_useDefaults) {
75 if (_useDefaults === undefined)
76 _useDefaults = true;
77 useDefaults = _useDefaults;
78};
79
80exports.setLogLevel = function(level) {
81 if (!level)
82 return;
83
84 var levelIndex = logTypes.indexOf(level);
85
86 if (levelIndex == -1)
87 ui.log('warn', 'Unknown log level: ' + level);
88 else
89 ui.logLevel = levelIndex;
90};
91
92var format = exports.format = {
93 q: function(msg, opt) {
94 return moduleMsg(msg + (opt ? ' [' + opt + ']' : '') + ': ');
95 },
96 err: function(msg) {
97 return '\n' + chalk.red.bold('err ') + moduleMsg(msg, false, false);
98 },
99 info: function(msg) {
100 return ' ' + moduleMsg(msg, true);
101 },
102 warn: function(msg) {
103 return '\n' + chalk.yellow.bold('warn ') + moduleMsg(msg, true);
104 },
105 ok: function(msg) {
106 return chalk.green.bold('ok ') + moduleMsg(msg, true);
107 }
108};
109
110exports.log = function(type, msg) {
111 if (apiResolver)
112 return apiResolver.emit('log', type, msg);
113
114 logged = true;
115
116 if (arguments.length === 1) {
117 msg = type;
118 type = null;
119 }
120
121 msg = msg || '';
122
123 msg = msg.toString();
124
125 if (type)
126 msg = (format[type] || format.info)(msg.toString());
127
128 var logLevel = logTypes.indexOf(type);
129 if (logLevel == -1)
130 logLevel = 3;
131 if (logLevel <= ui.logLevel) {
132 if (logging) {
133 if (type != 'err')
134 console.log(msg);
135 else
136 console.error(msg);
137 }
138 else {
139 if (type != 'err')
140 logBuffer += msg + '\n';
141 else
142 errBuffer += msg + '\n';
143 }
144 }
145};
146
147function isControlTerminateChar(c, control) {
148 var formatChars = ['%', '`', '_'];
149 formatChars.splice(formatChars.indexOf(control), 1);
150 return !c || ('\r\n ,.:?' + formatChars.join('')).indexOf(c) != -1;
151}
152
153function highlight(text) {
154 text = text.replace(/([\s\`\(]|^)%([^%]+)%/g, function(match, prefix, capture, index) {
155 if (isControlTerminateChar(text[index + match.length], '%'))
156 return match.replace('%' + capture + '%', chalk.bold(capture));
157 else
158 return match;
159 });
160 text = text.replace(/([\s\`\(]|^)\`([^\`]+)\`/g, function(match, prefix, capture, index) {
161 if (isControlTerminateChar(text[index + match.length], '`'))
162 return match.replace('`' + capture + '`', chalk.cyan(capture));
163 else
164 return match;
165 });
166 text = text.replace(/([\s\`\%]|^)\_([^\_]+)\_/g, function(match, prefix, capture, index) {
167 if (isControlTerminateChar(text[index + match.length], '_'))
168 return match.replace('_' + capture + '_', chalk.underline(capture));
169 else
170 return match;
171 });
172 return text.replace(/\t/g, ' ');
173}
174
175exports.wordWrap = wordWrap;
176function wordWrap(text, columns, leftIndent, rightIndent, skipFirstLineIndent) {
177 leftIndent = leftIndent || 0;
178 rightIndent = rightIndent || 0;
179
180 var leftSpaces = '';
181 var i;
182
183 for (i = 0; i < leftIndent; i++)
184 leftSpaces += ' ';
185
186 // simple word wrapping
187 columns = columns - leftIndent - rightIndent;
188 var output = [];
189 var lastBreak = 0;
190 var lastSpace = 0;
191 var skipLength = 0; // skip control characters
192 var controlStart = false;
193 for (i = 0; i < text.length; i++) {
194 if (text[i] == '`' || text[i] == '%' || text[i] == '_') {
195 if (controlStart && isControlTerminateChar(text[i + 1], text[i])) {
196 controlStart = false;
197 skipLength++;
198 continue;
199 }
200 else if (lastBreak == lastSpace || !text[i - 1] || (' `.,:?' + (text[i] == '_' ? '%' : '(')).indexOf(text[i - 1]) != -1) {
201 controlStart = true;
202 skipLength++;
203 continue;
204 }
205 }
206 if (text[i] == ' ')
207 lastSpace = i;
208
209 // existing newline adds buffer
210 if (text[i] == '\n') {
211 lastSpace = i;
212 output.push((output.length == 0 && skipFirstLineIndent ? '' : leftSpaces) + text.substring(lastBreak, i) + '\n');
213 lastSpace = i;
214 lastBreak = i + 1;
215 skipLength = 0;
216 }
217
218 // on expected breaking point
219 else if ((i - skipLength) != lastBreak &&
220 (i - lastBreak - skipLength) % columns == 0 &&
221 lastSpace > lastBreak) {
222 output.push((output.length == 0 && skipFirstLineIndent ? '' : leftSpaces) + text.substring(lastBreak, lastSpace) + '\n');
223 lastBreak = lastSpace + 1;
224 skipLength = 0;
225 }
226 }
227 output.push((output.length == 0 && skipFirstLineIndent ? '' : leftSpaces) + text.substr(lastBreak));
228 return output.join('');
229}
230
231function moduleMsg(msg, tab, wrap) {
232 if (tab)
233 msg = wordWrap(msg, process.stdout.columns + (isWin ? -1 : 0), tab ? 5 : 0).substr(5);
234 else if (wrap)
235 msg = wordWrap(msg, process.stdout.columns + (isWin ? -1 : 0));
236
237 // formatting
238 return highlight(msg);
239}
240
241var inputQueue = [];
242var confirm = exports.confirm = function(msg, def, options) {
243 if (typeof def == 'object') {
244 options = def;
245 def = undefined;
246 }
247 options = options || {};
248
249 if (apiResolver) {
250 if (useDefaults) {
251 apiResolver.emit('prompt', {
252 type: 'confirm',
253 message: msg,
254 default: def
255 });
256 return Promise.resolve(!!def);
257 }
258 else {
259 return new Promise(function(resolve) {
260 apiResolver.emit('prompt', {
261 type: 'confirm',
262 message: msg,
263 default: def
264 }, function(answer) {
265 resolve(answer);
266 });
267 });
268 }
269 }
270
271 var defText;
272 if (def === true)
273 defText = 'Yes';
274 else if (def === false)
275 defText = 'No';
276 else
277 def = undefined;
278
279 if (useDefaults) {
280 if (def === undefined)
281 defText = 'Yes';
282 process.stdout.write(format.q(msg) + defText + '\n');
283 return Promise.resolve(!!def);
284 }
285
286 return cli.input(msg + (defText ? '' : ' [Yes/No]'), defText, {
287 edit: options.edit,
288 info: options.info,
289 silent: options.silent,
290 options: ['Yes', 'No']
291 })
292 .then(function(reply) {
293 if (reply == 'Yes')
294 return true;
295 if (reply == 'No')
296 return false;
297 return confirm(msg, def, options);
298 });
299};
300
301var inputFunc = function() {};
302process.stdin.on('keypress', function(chunk, key) {
303 inputFunc(key);
304});
305
306exports.input = function(msg, def, options, queue) {
307 if (typeof def == 'object' && def !== null) {
308 options = def;
309 def = undefined;
310 }
311 options = options || {};
312 if (typeof options == 'boolean')
313 options = {
314 silent: true,
315 edit: false,
316 clearOnType: false,
317 info: undefined,
318 hideInfo: true,
319 completer: undefined,
320 validate: undefined,
321 validationError: undefined,
322 optionalOptions: false
323 };
324
325 options.completer = function(partialLine) {
326 if (!partialLine.length || !options.options)
327 return [[], partialLine];
328
329 partialLine = partialLine.toLowerCase().trim();
330
331 var hits = options.options.filter(function(c) {
332 return c.toLowerCase().indexOf(partialLine) == 0;
333 });
334
335 // we only return one option, to avoid printing out completions
336 return [hits.length ? [hits[0]] : [], partialLine];
337 };
338
339 options = options || {};
340
341 if (useDefaults) {
342 process.stdout.write(format.q(msg) + def + '\n');
343 return Promise.resolve(def);
344 }
345
346 if (apiResolver)
347 return new Promise(function(resolve) {
348 apiResolver.emit('prompt', {
349 type: 'input',
350 message: msg,
351 default: def
352 }, function(answer) {
353 resolve(answer.input);
354 });
355 });
356
357 return new Promise(function(resolve, reject) {
358 if (!logging && !queue)
359 return inputQueue.push({
360 args: [msg, def, options, true],
361 resolve: resolve,
362 reject: reject
363 });
364
365 // if there is info text for this question, first write out the hint text below
366 // then restore the cursor and write out the question
367 var infoLines = 1;
368 if (options.info || options.validationError) {
369 var infoText = '\n';
370 if (options.info)
371 infoText += highlight(wordWrap(options.info, process.stdout.columns, 5, 5)) + '\n';
372 if (options.validationError)
373 infoText += format.warn(wordWrap(options.validationError, process.stdout.columns, 0, 5)) + '\n';
374 infoText += '\n';
375 process.stdout.write(infoText);
376 infoLines = infoText.split('\n').length;
377 }
378 if (!('hideInfo' in options))
379 options.hideInfo = true;
380
381 if (logging && logged)
382 process.stdout.write('\n');
383 logging = false;
384
385 var rl = readline.createInterface({
386 input: process.stdin,
387 output: mutableStdout,
388 terminal: true,
389 completer: options.completer
390 });
391
392 var questionMessage = format.q(msg, !options.edit && def || '');
393 infoLines += questionMessage.split('\n').length - 1;
394
395 if (options.options)
396 var ctrlOpt = new CtrlOption(options.options, rl);
397 if (!options.edit)
398 inputFunc = function(key) {
399 if (!key)
400 return;
401 var i;
402 if (options.clearOnType && key.name != 'return' && key.name != 'backspace' && key.name != 'left') {
403 // this actually works, which was rather unexpected
404 for (i = 0; i < (def || '').length; i++)
405 process.stdin.emit('keypress', '\b', { name: 'backspace' });
406 }
407 // allow scrolling through default options
408 if (ctrlOpt) {
409 if (key.name === 'up')
410 ctrlOpt.moveUp();
411 if (key.name === 'down')
412 ctrlOpt.moveDown();
413 }
414 // for no default options, allow backtracking to default
415 else if (key.name == 'up' || key.name == 'down') {
416 rl.write(null, { ctrl: true, name: 'u' });
417 for (i = 0; i < def.length; i++)
418 process.stdin.emit('keypress', def[i], { name: def[i] });
419 }
420 };
421
422 rl.question(questionMessage, function(inputVal) {
423 inputFunc = function() {};
424 if (mutableStdout.muted)
425 console.log('');
426 rl.close();
427
428 // run completions by default on enter when provided
429 if (options.options && !options.optionalOptions)
430 inputVal = options.completer(inputVal)[0][0] || '';
431
432 inputVal = inputVal.trim();
433
434 if (!options.edit)
435 inputVal = inputVal || def;
436
437 var retryErr;
438 if (options.validate)
439 retryErr = options.validate(inputVal);
440
441 inputRedact(inputVal, !!retryErr, options.silent);
442 inputRedact = null;
443 mutableStdout.muted = false;
444
445 if (retryErr)
446 return cli.input(msg, def, {
447 info: options.info,
448 silent: options.silent,
449 edit: options.edit,
450 clearOnType: options.clearOnType,
451 completer: options.completer,
452 validate: options.validate,
453 validationError: retryErr.toString(),
454 optionalOptions: options.optionalOptions,
455 hideInfo: options.hideInfo
456 }, true).then(resolve, reject);
457
458 // bump the input queue
459 var next = inputQueue.shift();
460 if (next)
461 cli.input.apply(null, next.args).then(next.resolve, next.reject);
462 else {
463 process.stdout.write(logBuffer);
464 process.stderr.write(errBuffer);
465 logBuffer = '';
466 errBuffer = '';
467 logging = true;
468 logged = false;
469 }
470
471 resolve(inputVal);
472 });
473
474 inputRedact = function(inputVal, retry, silent) {
475 inputVal = inputVal || '';
476 var inputText = !retry ? format.q(msg, !options.edit && def || '') + (silent ? '' : inputVal) + '\n' : '';
477 // erase the help text, rewrite the question above, and begin the next question
478 process.stdout.write('\033[' + (options.hideInfo || retry ? infoLines : 0) + 'A\033[J' + inputText);
479 };
480
481 mutableStdout.muted = false;
482
483 if (options.silent)
484 mutableStdout.muted = true;
485
486 if (def && options.edit)
487 rl.write(def);
488 });
489};
490
491function CtrlOption(options, rl) {
492 this.options = options || {};
493 this.rl = rl;
494}
495CtrlOption.prototype = {
496 cursol: 0,
497 moveUp: function(){
498 var next = this.cursol + 1;
499 this.cursol = (next >= this.options.length ) ? 0 : next;
500 this.move();
501 },
502 moveDown: function(){
503 var next = this.cursol - 1;
504 this.cursol = (next === -1 ) ? (this.options.length - 1) : next ;
505 this.move();
506 },
507 move: function() {
508 this.rl.write(null, {ctrl: true, name: 'u'});
509
510 var input = this.options[this.cursol].split('');
511 for (var idx in input)
512 process.stdin.emit('keypress', input[idx], { name: input[idx] });
513 },
514};