456 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			456 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| 'use strict';
 | |
| 
 | |
| var PromiseA = require('bluebird');
 | |
| var colors = require('colors/safe');
 | |
| var stripAnsi = require('strip-ansi');
 | |
| 
 | |
| // https://www.novell.com/documentation/extend5/Docs/help/Composer/books/TelnetAppendixB.html
 | |
| var BKSP = String.fromCharCode(127);
 | |
| var WIN_BKSP = "\u0008";
 | |
| var ENTER = "\u0004";           // 13 // '\u001B[0m'
 | |
| var CRLF = "\r\n";
 | |
| var LF = "\n";
 | |
| var CTRL_C = "\u0003";
 | |
| var TAB = '\x09';
 | |
| var ARROW_UP = '\u001b[A';      // 38
 | |
| var ARROW_DOWN = '\u001b[B';    // 40
 | |
| var ARROW_RIGHT = '\u001b[C';   // 39
 | |
| var ARROW_LEFT = '\u001b[D';    // 37
 | |
| // "\033[2J\033[;H" CLS // "\x1b[2J\x1b[1;1H"
 | |
| // \033[0m RESET
 | |
| 
 | |
| var form = {
 | |
|   PromiseA: PromiseA
 | |
| , createWs: function (rrs, rws) {
 | |
|     rws = PromiseA.promisifyAll(rws);
 | |
| 
 | |
|     // the user just hit enter to run a program, so the terminal is at x position 0,
 | |
|     // however, we have no idea where y is, so we just make it really really negative
 | |
|     var startY = -65537;
 | |
|     // TODO update state on resize
 | |
|     //console.log('');
 | |
|     var ws = {
 | |
|       _x: 0
 | |
|     , _y: startY
 | |
|     , _rows: rws.rows
 | |
|     , _columns: rws.columns
 | |
|     , _prompt: ''
 | |
|     , _input: []
 | |
|     , _inputIndex: 0
 | |
|     , isTTY: rws.isTTY
 | |
|     , cursorTo: function (x, y) {
 | |
|         if ('number' !== typeof x || (0 !== x && !x)) {
 | |
|           throw new Error('cursorTo(x[, y]): x is not optional and must be a number');
 | |
|         }
 | |
|         ws._x = x;
 | |
|         if ('number' === typeof y) {
 | |
|           // TODO
 | |
|           // Enter Full Screen Mode
 | |
|           // if the developer is modifying the (absolute) y position,
 | |
|           // then it should be expected that we are going
 | |
|           // into full-screen mode, as there is no way
 | |
|           // to read the current cursor position to get back
 | |
|           // to a known line location.
 | |
|           ws._y = y;
 | |
|         }
 | |
|         rws.cursorTo(x, y);
 | |
|       }
 | |
|     , write: function (str) {
 | |
|         var rows = stripAnsi(str).split(/\r\n|\n|\r/g);
 | |
|         var len = rows[0].replace(/\t/g, '    ').length;
 | |
|         var x;
 | |
| 
 | |
|         switch (str) {
 | |
|           case BKSP:
 | |
|           case WIN_BKSP:
 | |
|           form.setStatus(rrs, ws, colors.dim(
 | |
|             "inputIndex: " + ws._inputIndex
 | |
|           + " input:" + ws._input.join('')
 | |
|           + " x:" + ws._x
 | |
|           ));
 | |
|             x = ws._x;
 | |
|             if (0 !== ws._inputIndex) {
 | |
|               ws._inputIndex -= 1;
 | |
|               x -= 1;
 | |
|             }
 | |
|             ws._input.splice(ws._inputIndex, 1);
 | |
|             ws.clearLine();
 | |
|             //ws.cursorTo(0, col);
 | |
|             ws.cursorTo(0);
 | |
|             ws.clearLine();
 | |
|             ws.write(ws._prompt);
 | |
|             ws.write(ws._input.join(''));
 | |
|             ws.cursorTo(x);
 | |
|             return;
 | |
| 
 | |
|           case ARROW_RIGHT:
 | |
|           form.setStatus(rrs, ws, colors.dim(
 | |
|             "inputIndex: " + ws._inputIndex
 | |
|           + " input:" + ws._input.join('')
 | |
|           + " x:" + ws._x
 | |
|           ));
 | |
|           if (ws._x === ws._prompt.length + ws._input.length) {
 | |
|             return;
 | |
|           }
 | |
|           ws._inputIndex += 1;
 | |
|           ws._x = ws._prompt.length + ws._inputIndex;
 | |
|           return rws.writeAsync(str);
 | |
| 
 | |
|           case ARROW_LEFT:
 | |
|           form.setStatus(rrs, ws, colors.dim(
 | |
|             "inputIndex: " + ws._inputIndex
 | |
|           + " input:" + ws._input.join('')
 | |
|           + " x:" + ws._x
 | |
|           ));
 | |
|           if (0 === ws._inputIndex) {
 | |
|             return;
 | |
|           }
 | |
|           ws._inputIndex = Math.max(0, ws._inputIndex - 1);
 | |
|           //ws._x = Math.max(0, ws._x - 1);
 | |
|           ws._x = Math.max(0, ws._x - 1);
 | |
|           return rws.writeAsync(str);
 | |
|         }
 | |
| 
 | |
|         if (rows.length > 1) {
 | |
|           ws._x = 0;
 | |
|         }
 | |
| 
 | |
|         if (ws._x + len > ws._columns) {
 | |
|           ws._x = (ws._x + len) % ws._columns;
 | |
|         }
 | |
|         else {
 | |
|           ws._x += len;
 | |
|         }
 | |
| 
 | |
|         return rws.writeAsync(str);
 | |
|       }
 | |
|     , moveCursor: function (dx, dy) {
 | |
|         if ('number' !== typeof dx || (0 !== dx && !dx)) {
 | |
|           throw new Error('cursorTo(x[, y]): x is not optional and must be a number');
 | |
|         }
 | |
|         ws._x = Math.max(0, Math.min(ws._columns, ws._x + dx));
 | |
|         if ('number' === typeof dy) {
 | |
|           ws._y = Math.max(startY, Math.min(ws._rows, ws._y + dy));
 | |
|         }
 | |
| 
 | |
|         rws.moveCursor(dx, dy);
 | |
|       }
 | |
|     , clearLine: function() {
 | |
|         ws._x = 0;
 | |
|         rws.clearLine();
 | |
|       }
 | |
|     };
 | |
| 
 | |
|     return ws;
 | |
|   }
 | |
| 
 | |
| , ask: function (rrs, ws, label, cbs) {
 | |
|     ws._prompt = label.label || label;
 | |
|     ws._input = [];
 | |
|     ws._inputIndex = 0;
 | |
| 
 | |
|     if ('object' === typeof label) {
 | |
|       cbs = label;
 | |
|     }
 | |
| 
 | |
|     if ('string' === typeof cbs) {
 | |
|       cbs = { type: cbs };
 | |
|     }
 | |
| 
 | |
|     if (cbs.type) {
 | |
|       if (!form.inputs[cbs.type]) {
 | |
|         return form.PromiseA.reject(new Error("input type '" + cbs.type + "' is not implemented"));
 | |
|       }
 | |
|       Object.keys(form.inputs[cbs.type]).forEach(function (key) {
 | |
|         if (!cbs[key]) {
 | |
|           cbs[key] = form.inputs[cbs.type][key];
 | |
|         }
 | |
|       });
 | |
|     }
 | |
| 
 | |
|     return new PromiseA(function (resolve) {
 | |
|       var ch;
 | |
|       var debouncer = {
 | |
|         set: function () {
 | |
|           if (!(cbs.onDebounceAsync||cbs.onDebounce)) {
 | |
|             return;
 | |
|           }
 | |
| 
 | |
|           clearTimeout(debouncer._timeout);
 | |
| 
 | |
|           if ('function' !== typeof fn) {
 | |
|             return;
 | |
|           }
 | |
| 
 | |
|           debouncer._timeout = setTimeout(function () {
 | |
|             rrs.pause();
 | |
|             return (cbs.onDebounceAsync||cbs.onDebounce)(ws._input.join(''), ch).then(function (result) {
 | |
|               rrs.resume();
 | |
| 
 | |
|               if (!result) {
 | |
|                 return;
 | |
|               }
 | |
| 
 | |
|               if (result.complete) {
 | |
|                 callback();
 | |
|               }
 | |
| 
 | |
|               return result.input;
 | |
|             }, function (err) {
 | |
|               var errmsg = colors.red(err.message);
 | |
|               form.setStatus(rrs, ws, errmsg);
 | |
|               // resume input
 | |
|               rrs.resume();
 | |
|             });
 | |
|           }, cbs.debounceTimeout || 300);
 | |
|         }
 | |
|       , clear: function () {
 | |
|           clearTimeout(debouncer._timeout);
 | |
|         }
 | |
|       };
 | |
| 
 | |
|       function callback() {
 | |
|         clearTimeout(debouncer._timeout);
 | |
|         rrs.removeListener('data', onData);
 | |
| 
 | |
|         rrs.pause();
 | |
| 
 | |
|         form.PromiseA.resolve((cbs.onReturnAsync||cbs.onReturn)(rrs, ws, ws._input.join(''), ch)).then(function (normalInput) {
 | |
|           if (!cbs.value) {
 | |
|             ws.write('\n');
 | |
|             ws.clearLine(); // person just hit enter, they are on the next line
 | |
|                             // (and this will clear the status, if any)
 | |
|                             // TODO count lines used below and clear all of them
 | |
|           }
 | |
|           rrs.setRawMode(false);
 | |
| 
 | |
|           var input = ws._input.join('');
 | |
|           ws._input = [];
 | |
|           ws._inputIndex = 0;
 | |
|           resolve({ input: input, result: normalInput });
 | |
|         }, function (err) {
 | |
|           var errmsg = colors.red(err.message);
 | |
|           form.setStatus(rrs, ws, errmsg);
 | |
|           if (!rrs.isTTY) {
 | |
|             return PromiseA.reject(err);
 | |
|           }
 | |
| 
 | |
|           rrs.on('data', onData);
 | |
|           rrs.setRawMode(true);
 | |
|           rrs.resume();
 | |
|         });
 | |
|       }
 | |
| 
 | |
|       function onData(chunk) {
 | |
|         var ch = chunk.toString('ascii');
 | |
|         var x;
 | |
| 
 | |
|         if (CTRL_C === ch) {
 | |
|           process.exit(0);
 | |
|           callback(new Error("cancelled"));
 | |
|         }
 | |
| 
 | |
|         switch (ch) {
 | |
|         case ENTER:
 | |
|         case CRLF:
 | |
|         case LF:
 | |
|         case "\n\r":
 | |
|         case "\r":
 | |
|           debouncer.clear();
 | |
|           callback();
 | |
|           break;
 | |
|         case BKSP:
 | |
|         case WIN_BKSP:
 | |
|         case ARROW_LEFT:
 | |
|         case ARROW_RIGHT:
 | |
|           debouncer.clear();
 | |
|           // Position-control and delete characters are handled by write wrapper
 | |
|           ws.write(ch);
 | |
|           break;
 | |
|         case ARROW_UP: // TODO history, show pass
 | |
|           break;
 | |
|         case ARROW_DOWN: // TODO history, hide pass
 | |
|           break;
 | |
|         case TAB:
 | |
|           // TODO auto-complete
 | |
|           break;
 | |
|         default:
 | |
|           debouncer.set();
 | |
|           form.setStatus(rrs, ws, colors.dim(
 | |
|             "inputIndex: " + ws._inputIndex
 | |
|           + " input:" + ws._input.join('')
 | |
|           + " x:" + ws._x
 | |
|           ));
 | |
| 
 | |
|           function onChar(ch) {
 | |
|             x = ws._x;
 | |
|             ws._input.splice(ws._inputIndex, 0, ch);
 | |
|             ws.write(ws._input.slice(ws._inputIndex).join(''));
 | |
|             ws._inputIndex += 1;
 | |
|             ws.cursorTo(x + 1);
 | |
|           }
 | |
| 
 | |
|           var ch8 = chunk.toString('utf8');
 | |
|           // TODO binary vs utf8 vs ascii
 | |
|           if (ch === ch8) {
 | |
|             ch.split('').forEach(onChar);
 | |
|           } else {
 | |
|             // TODO solve the 'Z͑ͫAͫ͗LͨͧG̑͗O͂̌!̿̋' problem
 | |
|             // https://github.com/selvan/grapheme-splitter
 | |
|             // https://mathiasbynens.be/notes/javascript-unicode
 | |
|             //
 | |
|             require('spliddit')(ch8).forEach(onChar);
 | |
|           }
 | |
|           break;
 | |
|         }
 | |
| 
 | |
|         // Normally we only get one character at a time, but on paste (ctrl+v)
 | |
|         // and perhaps one of those times when the cpu is so loaded that you can
 | |
|         // literally watch characters appear on the screen more than one character
 | |
|         // will come in and we have to figure out what to do about that
 | |
|       }
 | |
| 
 | |
|       if (cbs.value) {
 | |
|         ws._input = require('spliddit')(cbs.value);
 | |
|         ws._inputIndex = ws._input.length;
 | |
|         callback();
 | |
|         return;
 | |
|       }
 | |
| 
 | |
|       if (!rrs.isTTY) {
 | |
|         return PromiseA.reject("User input is required but stdio is not a TTY");
 | |
|       }
 | |
| 
 | |
|       rrs.setRawMode(true);
 | |
|       rrs.setEncoding('utf8');
 | |
|       rrs.resume();
 | |
| 
 | |
|       if (ws.isTTY) {
 | |
|         ws.cursorTo(0);
 | |
|         ws.write(ws._prompt);
 | |
|         //ws.cursorTo(0, ws._prompt.length);
 | |
|       }
 | |
|       rrs.on('data', onData);
 | |
|     });
 | |
|   }
 | |
| 
 | |
| , setStatus: function (rrs, ws, msg) {
 | |
|     //var errlen = (' ' + err.message).length;
 | |
|     var x = ws._x;
 | |
|     // down one, start of line
 | |
|     // TODO write newline?
 | |
|     //ws.moveCursor(0, 1);
 | |
|     ws.write('\n');
 | |
|     if (ws.isTTY) {
 | |
|       ws.clearLine();
 | |
|       ws.cursorTo(0);
 | |
|     }
 | |
|     // write from beginning of line
 | |
|     ws.write(msg);
 | |
|     // restore position
 | |
|     if (ws.isTTY) {
 | |
|       ws.cursorTo(x);
 | |
|       ws.moveCursor(0, -1);
 | |
|     }
 | |
|   }
 | |
| 
 | |
| , println: function (rrs, ws, msg) {
 | |
|     if ('string' !== typeof msg) {
 | |
|       msg = JSON.stringify(msg, null, 2);
 | |
|     }
 | |
|     return ws.write(msg + '\n');
 | |
|   }
 | |
| };
 | |
| 
 | |
| var inputs = {
 | |
|   text: {
 | |
|     onReturnAsync: function (rrs, ws, str) {
 | |
|       return str;
 | |
|     }
 | |
|   }
 | |
| , email: {
 | |
|     onReturnAsync: function (rrs, ws, str) {
 | |
|       str = str.trim();
 | |
|       var dns = PromiseA.promisifyAll(require('dns'));
 | |
|       var parts = str.split(/@/g);
 | |
| 
 | |
|       // http://emailregex.com/
 | |
|       if (2 !== parts.length || !/^[-a-z0-9~!$%^&*_=+}{\'?]+(\.[-a-z0-9~!$%^&*_=+}{\'?]+)*/.test(parts[0])) {
 | |
|         return PromiseA.reject(new Error("[X] That doesn't look like an email address"));
 | |
|       }
 | |
| 
 | |
|       rrs.pause();
 | |
|       form.setStatus(rrs, ws, colors.blue("testing `dig MX '" + parts[1] + "'` ... "));
 | |
| 
 | |
|       return dns.resolveMxAsync(parts[1]).then(function () {
 | |
|         return;
 | |
|       }, function () {
 | |
|         return PromiseA.reject(new Error("[X] '" + parts[1] + "' is not a valid email domain"));
 | |
|       });
 | |
|     }
 | |
|   }
 | |
| , url: {
 | |
|     onReturnAsync: function (rrs, ws, str) {
 | |
|       str = str.trim();
 | |
|       var dns = PromiseA.promisifyAll(require('dns'));
 | |
|       var url = require('url');
 | |
|       var urlObj = url.parse(str);
 | |
| 
 | |
|       if (!urlObj.protocol) {
 | |
|         str = 'https://' + str; // .replace(/^(https?:\/\/)?/i, 'https://');
 | |
|         urlObj = url.parse(str);
 | |
|       }
 | |
| 
 | |
|       if (!urlObj.protocol || !urlObj.hostname) {
 | |
|         return PromiseA.reject(new Error("[X] That doesn't look like a url"));
 | |
|       }
 | |
|       if ('https:' !== urlObj.protocol.toLowerCase()) {
 | |
|         return PromiseA.reject(new Error("[X] That url doesn't use https"));
 | |
|       }
 | |
|       if (/^www\./.test(urlObj.hostname)) {
 | |
|         return PromiseA.reject(new Error("[X] That url has a superfluous www. prefix"));
 | |
|       }
 | |
| 
 | |
|       rrs.pause();
 | |
|       form.setStatus(rrs, ws, colors.blue("testing `dig A '" + urlObj.hostname + "'` ... "));
 | |
| 
 | |
|       return dns.resolveAsync(urlObj.hostname).then(function () {
 | |
|         return str;
 | |
|       }, function () {
 | |
|         return PromiseA.reject(new Error("[X] '" + urlObj.hostname + "' doesn't look right (dns lookup failed)"));
 | |
|       });
 | |
|     }
 | |
|   }
 | |
| };
 | |
| 
 | |
| form.inputs = inputs;
 | |
| module.exports.inputs = inputs;
 | |
| module.exports.form = form;
 | |
| module.exports.create = function (rrs, rws) {
 | |
|   var ws = form.createWs(rrs, rws);
 | |
|   var f = {};
 | |
| 
 | |
|   Object.keys(form).forEach(function (key) {
 | |
|     if ('function' !== typeof form[key]) {
 | |
|       f[key] = form[key];
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     f[key] = function () {
 | |
|       var args = Array.prototype.slice.call(arguments);
 | |
|       args.unshift(ws);
 | |
|       args.unshift(rrs);
 | |
|       return form[key].apply(null, args);
 | |
|     };
 | |
|   });
 | |
| 
 | |
|   f.createWs = undefined;
 | |
|   f.inputs = inputs;
 | |
|   f.ws = ws;
 | |
|   f.rrs = rrs;
 | |
|   f.PromiseA = PromiseA;
 | |
| 
 | |
|   return f;
 | |
| 
 | |
| };
 |