diff --git a/README.md b/README.md index 1b010a6..50179fc 100644 --- a/README.md +++ b/README.md @@ -106,9 +106,12 @@ By integrating the loopback-console you also gain the ability to configure its f The following configuration directives are supported, - `quiet`: Suppresses the help text on startup and the automatic printing of `result`. +- `historyPath`: The path to a file to persist command history. Use an empty string (`''`) to disable history. - All built-in configuration options for Node.js REPL, such as `prompt`. - `handles`: Disable any default handles, or pass additional handles that you would like available on the console. +Note, command history path can also be configured with the env-var `LOOPBACK_CONSOLE_HISTORY`. + ## Contributors - Heath Morrison (doublemarked) diff --git a/history.js b/history.js new file mode 100644 index 0000000..6f5e359 --- /dev/null +++ b/history.js @@ -0,0 +1,185 @@ +/* + * HM 2017-01-04: + * History processing was extracted from the internal Node REPL code with minimal changes, + * https://github.com/nodejs/node/blob/master/lib/internal/repl.js + * + * This internal history functionality is currently unpublished for various reasons + * discussed in the following PR: + * https://github.com/nodejs/node/pull/5789 + * + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * + * All extracted code remains under the general nodejs license terms: + * https://github.com/nodejs/node/blob/master/LICENSE + * + * Copyright Node.js contributors. All rights reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + */ + +'use strict'; + +const Interface = require('readline').Interface; +const path = require('path'); +const fs = require('fs'); +const os = require('os'); +const util = require('util'); +const debug = util.debuglog('repl'); + +// XXX(chrisdickinson): The 15ms debounce value is somewhat arbitrary. +// The debounce is to guard against code pasted into the REPL. +const kDebounceHistoryMS = 15; + +const DEFAULT_HISTORY_FILE = '.loopback_console_history'; + +module.exports = function initReplHistory(repl, historyPath) { + return new Promise((resolve, reject) => { + setupHistory(repl, historyPath, (err, repl) => { + if (err) { + reject(err); + } else { + resolve(repl); + } + }); + }); +}; + +function setupHistory(repl, historyPath, ready) { + // Empty string disables persistent history. + if (typeof historyPath === 'string') + historyPath = historyPath.trim(); + + if (historyPath === '') { + repl._historyPrev = _replHistoryMessage; + return ready(null, repl); + } + + if (!historyPath) { + try { + historyPath = path.join(os.homedir(), DEFAULT_HISTORY_FILE); + } catch (err) { + repl._writeToOutput('\nError: Could not get the home directory.\n' + + 'REPL session history will not be persisted.\n'); + repl._refreshLine(); + + debug(err.stack); + repl._historyPrev = _replHistoryMessage; + return ready(null, repl); + } + } + + var timer = null; + var writing = false; + var pending = false; + repl.pause(); + // History files are conventionally not readable by others: + // https://github.com/nodejs/node/issues/3392 + // https://github.com/nodejs/node/pull/3394 + fs.open(historyPath, 'a+', 0o0600, oninit); + + function oninit(err, hnd) { + if (err) { + // Cannot open history file. + // Don't crash, just don't persist history. + repl._writeToOutput('\nError: Could not open history file.\n' + + 'REPL session history will not be persisted.\n'); + repl._refreshLine(); + debug(err.stack); + + repl._historyPrev = _replHistoryMessage; + repl.resume(); + return ready(null, repl); + } + fs.close(hnd, onclose); + } + + function onclose(err) { + if (err) { + return ready(err); + } + fs.readFile(historyPath, 'utf8', onread); + } + + function onread(err, data) { + if (err) { + return ready(err); + } + + if (data) { + repl.history = data.split(/[\n\r]+/, repl.historySize); + } + + fs.open(historyPath, 'w', onhandle); + } + + function onhandle(err, hnd) { + if (err) { + return ready(err); + } + repl._historyHandle = hnd; + repl.on('line', online); + + // reading the file data out erases it + repl.once('flushHistory', function() { + repl.resume(); + ready(null, repl); + }); + flushHistory(); + } + + // ------ history listeners ------ + function online() { + repl._flushing = true; + + if (timer) { + clearTimeout(timer); + } + + timer = setTimeout(flushHistory, kDebounceHistoryMS); + } + + function flushHistory() { + timer = null; + if (writing) { + pending = true; + return; + } + writing = true; + const historyData = repl.history.join(os.EOL); + fs.write(repl._historyHandle, historyData, 0, 'utf8', onwritten); + } + + function onwritten(err, data) { + writing = false; + if (pending) { + pending = false; + online(); + } else { + repl._flushing = Boolean(timer); + if (!repl._flushing) { + repl.emit('flushHistory'); + } + } + } +}; + +function _replHistoryMessage() { + if (this.history.length === 0) { + this._writeToOutput( + '\nPersistent history support disabled. ' + + 'Set the history path to ' + + 'a valid, user-writable path to enable.\n' + ); + this._refreshLine(); + } + this._historyPrev = Interface.prototype._historyPrev; + return this._historyPrev(); +} diff --git a/index.js b/index.js index e11d3c4..b44eb78 100644 --- a/index.js +++ b/index.js @@ -7,6 +7,7 @@ const DEFAULT_REPL_CONFIG = { prompt: 'loopback > ', useGlobal: true, ignoreUndefined: true, + historyPath: process.env.LOOPBACK_CONSOLE_HISTORY, }; const DEFAULT_HANDLE_INFO = { @@ -24,7 +25,7 @@ const LoopbackConsole = module.exports = { start(app, config) { if (this._started) { - return this._ctx; + return Promise.resolve(this._ctx); } this._started = true; @@ -58,9 +59,10 @@ const LoopbackConsole = module.exports = { console.log(repl.usage(ctx)); } - ctx.repl = repl.start(ctx); - - return ctx; + return repl.start(ctx).then(repl => { + ctx.repl = repl; + return ctx; + }); }, }; diff --git a/package.json b/package.json index 941786c..585b08b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "loopback-console", - "version": "1.0.0", + "version": "1.1.0", "description": "A command-line tool for Loopback app debugging and administration", "main": "index.js", "license": "MIT", diff --git a/repl.js b/repl.js index 6e77a75..154fd64 100644 --- a/repl.js +++ b/repl.js @@ -1,6 +1,7 @@ 'use strict'; const repl = require('repl'); +const replHistory = require('./history'); const LoopbackRepl = module.exports = { start(ctx) { @@ -9,7 +10,6 @@ const LoopbackRepl = module.exports = { Object.assign(replServer.context, ctx.handles); - replServer.on('exit', process.exit); replServer.eval = wrapReplEval(replServer); if (ctx.handles.cb === true) { @@ -43,7 +43,17 @@ const LoopbackRepl = module.exports = { }, }); - return replServer; + replServer.on('exit', function() { + if (replServer._flushing) { + replServer.pause(); + return replServer.once('flushHistory', function() { + process.exit(); + }); + } + process.exit(); + }); + + return replHistory(replServer, config.historyPath).then(() => replServer); }, usage(ctx, details) {