├── .eslintrc ├── .gitignore ├── bin └── loopback-console ├── package.json ├── LICENSE ├── index.js ├── repl.js ├── README.md └── history.js /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "loopback" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.dat 2 | *.iml 3 | *.log 4 | *.out 5 | *.pid 6 | *.seed 7 | *.sublime-* 8 | *.swo 9 | *.swp 10 | *.tgz 11 | *.xml 12 | .DS_Store 13 | .idea 14 | .project 15 | .strong-pm 16 | .env 17 | coverage 18 | node_modules 19 | npm-debug.log 20 | -------------------------------------------------------------------------------- /bin/loopback-console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | 'use strict'; 4 | 5 | const path = require('path'); 6 | 7 | const LoopbackConsole = require('..'); 8 | const appPath = process.argv[2] || '.'; 9 | 10 | var failBadPath = function() { 11 | console.error('Error: Loopback app not loadable at path', appPath); 12 | process.exit(1); 13 | }; 14 | 15 | try { 16 | const app = require(path.resolve(appPath)); 17 | if (!app.loopback) { 18 | failBadPath(); 19 | } 20 | 21 | LoopbackConsole.start(app); 22 | } catch (err) { 23 | if (err.code === 'MODULE_NOT_FOUND') { 24 | failBadPath(); 25 | } else { 26 | throw err; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "loopback-console", 3 | "version": "1.1.0", 4 | "description": "A command-line tool for Loopback app debugging and administration", 5 | "main": "index.js", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/doublemarked/loopback-console.git" 10 | }, 11 | "author": "Heath Morrison ", 12 | "homepage": "https://github.com/doublemarked/loopback-console", 13 | "contributors": [ 14 | { 15 | "name": "Heath Morrison", 16 | "email": "heath@govright.org" 17 | } 18 | ], 19 | "scripts": { 20 | "lint": "eslint ." 21 | }, 22 | "bin": { 23 | "loopback-console": "./bin/loopback-console" 24 | }, 25 | "dependencies": {}, 26 | "devDependencies": { 27 | "eslint": "^3.12.2", 28 | "eslint-config-loopback": "^6.1.0" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Heath Morrison 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const repl = require('./repl'); 4 | 5 | const DEFAULT_REPL_CONFIG = { 6 | quiet: false, 7 | prompt: 'loopback > ', 8 | useGlobal: true, 9 | ignoreUndefined: true, 10 | historyPath: process.env.LOOPBACK_CONSOLE_HISTORY, 11 | }; 12 | 13 | const DEFAULT_HANDLE_INFO = { 14 | app: 'The Loopback app handle', 15 | cb: 'A simplistic results callback that stores and prints', 16 | result: 'The handle on which cb() stores results', 17 | }; 18 | 19 | const LoopbackConsole = module.exports = { 20 | activated() { 21 | return process.env.LOOPBACK_CONSOLE == 'true' || 22 | process.env.LOOPBACK_CONSOLE == 1 || 23 | process.argv.includes('--console'); 24 | }, 25 | 26 | start(app, config) { 27 | if (this._started) { 28 | return Promise.resolve(this._ctx); 29 | } 30 | this._started = true; 31 | 32 | config = Object.assign({}, DEFAULT_REPL_CONFIG, config); 33 | const ctx = this._ctx = { 34 | app, 35 | config, 36 | handles: config.handles || {}, 37 | handleInfo: config.handleInfo || {}, 38 | models: {}, 39 | }; 40 | 41 | Object.keys(app.models).forEach(modelName => { 42 | if (!(modelName in ctx.handles)) { 43 | ctx.models[modelName] = ctx.handles[modelName] = app.models[modelName]; 44 | } 45 | }); 46 | 47 | if (!('app' in ctx.handles)) { 48 | ctx.handles.app = app; 49 | ctx.handleInfo.app = DEFAULT_HANDLE_INFO.app; 50 | } 51 | 52 | if (ctx.handles.cb === true || !('cb' in ctx.handles)) { 53 | ctx.handles.cb = true; 54 | ctx.handleInfo.cb = DEFAULT_HANDLE_INFO.cb; 55 | ctx.handleInfo.result = DEFAULT_HANDLE_INFO.result; 56 | } 57 | 58 | if (!config.quiet) { 59 | console.log(repl.usage(ctx)); 60 | } 61 | 62 | return repl.start(ctx).then(repl => { 63 | ctx.repl = repl; 64 | return ctx; 65 | }); 66 | }, 67 | 68 | }; 69 | -------------------------------------------------------------------------------- /repl.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const repl = require('repl'); 4 | const replHistory = require('./history'); 5 | 6 | const LoopbackRepl = module.exports = { 7 | start(ctx) { 8 | const config = Object.assign({}, ctx.config); 9 | const replServer = repl.start(config); 10 | 11 | Object.assign(replServer.context, ctx.handles); 12 | 13 | replServer.eval = wrapReplEval(replServer); 14 | 15 | if (ctx.handles.cb === true) { 16 | replServer.context.result = undefined; 17 | replServer.context.cb = (err, result) => { 18 | replServer.context.err = err; 19 | replServer.context.result = result; 20 | 21 | if (err) { 22 | console.error('Error: ' + err); 23 | } 24 | if (!config.quiet) { 25 | console.log(result); 26 | } 27 | }; 28 | } 29 | 30 | replServer.defineCommand('usage', { 31 | help: 'Detailed Loopback Console usage information', 32 | action() { 33 | this.outputStream.write(LoopbackRepl.usage(ctx, true)); 34 | this.displayPrompt(); 35 | }, 36 | }); 37 | 38 | replServer.defineCommand('models', { 39 | help: 'Display available Loopback models', 40 | action() { 41 | this.outputStream.write(Object.keys(ctx.models).join(', ') + '\n'); 42 | this.displayPrompt(); 43 | }, 44 | }); 45 | 46 | replServer.on('exit', function() { 47 | if (replServer._flushing) { 48 | replServer.pause(); 49 | return replServer.once('flushHistory', function() { 50 | process.exit(); 51 | }); 52 | } 53 | process.exit(); 54 | }); 55 | 56 | return replHistory(replServer, config.historyPath).then(() => replServer); 57 | }, 58 | 59 | usage(ctx, details) { 60 | const modelHandleNames = Object.keys(ctx.models); 61 | const customHandleNames = Object.keys(ctx.handles).filter(k => { 62 | return !ctx.handleInfo[k] && !ctx.models[k]; 63 | }); 64 | 65 | let usage = 66 | '============================================\n' + 67 | 'Loopback Console\n\n' + 68 | 'Primary handles available:\n'; 69 | 70 | Object.keys(ctx.handleInfo).forEach(key => { 71 | usage += ` - ${key}: ${ctx.handleInfo[key]}\n`; 72 | }); 73 | 74 | if (modelHandleNames.length > 0 || ctx.customHandleNames.length > 0) { 75 | usage += '\nOther handles available:\n'; 76 | } 77 | if (modelHandleNames.length > 0) { 78 | usage += ` - Models: ${ modelHandleNames.join(', ') }\n`; 79 | } 80 | if (customHandleNames.length > 0) { 81 | usage += ` - Custom: ${ customHandleNames.join(',') }\n`; 82 | } 83 | 84 | if (details) { 85 | usage += 86 | '\nExamples:\n' + 87 | ctx.config.prompt + 88 | 'myUser = User.findOne({ where: { username: \'heath\' })\n' + 89 | ctx.config.prompt + 90 | 'myUser.updateAttribute(\'fullName\', \'Heath Morrison\')\n' + 91 | ctx.config.prompt + 92 | 'myUser.widgets.add({ ... })\n\n'; 93 | } 94 | usage += '============================================\n\n'; 95 | 96 | return usage; 97 | }, 98 | }; 99 | 100 | // Wrap the default eval with a handler that resolves promises 101 | function wrapReplEval(replServer) { 102 | const defaultEval = replServer.eval; 103 | 104 | return function(code, context, file, cb) { 105 | return defaultEval.call(this, code, context, file, (err, result) => { 106 | if (!result || !result.then) { 107 | return cb(err, result); 108 | } 109 | 110 | result.then(resolved => { 111 | resolvePromises(result, resolved); 112 | cb(null, resolved); 113 | }).catch(err => { 114 | resolvePromises(result, err); 115 | 116 | console.log('\x1b[31m' + '[Promise Rejection]' + '\x1b[0m'); 117 | if (err && err.message) { 118 | console.log('\x1b[31m' + err.message + '\x1b[0m'); 119 | } 120 | 121 | // Application errors are not REPL errors 122 | cb(null, err); 123 | }); 124 | }); 125 | 126 | function resolvePromises(promise, resolved) { 127 | Object.keys(context).forEach(key => { 128 | // Replace any promise handles in the REPL context with the resolved promise 129 | if (context[key] === promise) { 130 | context[key] = resolved; 131 | } 132 | }); 133 | } 134 | }; 135 | } 136 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # loopback-console 2 | A command-line tool for Loopback app debugging and administration. 3 | 4 | 5 | 6 | The loopback-console is a command-line tool for interacting with your Loopback app. It works like the built-in 7 | Node REPL, but provides a handful of features that are helpful when debugging or generally 8 | working within your app's environment. Features include, 9 | 10 | - Easy availability of your app's models and important handles. See [Available Handles](#available-handles) 11 | - Automatic promise resolution, for intuitive access to Loopback ORM responses. 12 | 13 | # Installation 14 | 15 | The console can be used easily by just installing it and running its binary: 16 | 17 | ``` 18 | npm install loopback-console --save 19 | $(npm bin)/loopback-console 20 | ``` 21 | 22 | Assuming you install it within your project, the default setup will detect your project's location 23 | and bootstrap your app based on your current working directory. if you'd instead like to load a specific app in the console, execute it with a path to the app's main script: 24 | 25 | ``` 26 | loopback-console [path/to/server/server.js] 27 | ``` 28 | 29 | The recommended configuration is to add the console to your `package.json` scripts, as follows: 30 | 31 | ``` 32 | "scripts": { 33 | "console": "loopback-console" 34 | } 35 | ``` 36 | 37 | Once added you may launch the console by running, 38 | 39 | ``` 40 | npm run console 41 | ``` 42 | 43 | ## Examples 44 | 45 | The loopback-console makes it easy to work with your Loopback models. 46 | 47 | ```Javascript 48 | loopback > .models 49 | User, AccessToken, ACL, RoleMapping, Role, Widget 50 | loopback > Widget.count() 51 | 0 52 | loopback > Object.keys(Widget.definition.properties) 53 | [ 'name', 'description', 'created', 'id' ] 54 | loopback > w = Widget.create({ name: 'myWidget01', description: 'My new Widget'}) 55 | { name: 'myWidget01', description: 'My new Widget', id: 1 } 56 | loopback > Widget.count() 57 | 1 58 | loopback > w.name='super-widget'; 59 | 'super-widget' 60 | loopback > w.save() 61 | { name: 'super-widget', description: 'My new Widget' } 62 | loopback > Widget.find() 63 | [ { name: 'super-widget', description: 'My new Widget', id: 1 } ] 64 | ``` 65 | 66 | ## Available Handles 67 | 68 | By default the loopback-console provides a few handles designed to make it easier 69 | to work with your project, 70 | 71 | - Models: All of your app's Loopback models are available directly. For example, `User`. Type `.models` to see a list. 72 | - `app`: The Loopback app handle. 73 | - `cb`: A simplified callback function that, 74 | - Has signature `function (err, result)` 75 | - Stores results on the REPL's `result` handle. 76 | - Prints errors with `console.error` and results with `console.log` 77 | - `result`: The storage target of the `cb` function 78 | 79 | ## Advanced Setup 80 | 81 | In some cases you may want to perform operations each time the console loads 82 | to better integrate it with your app's environment. 83 | 84 | To integrate loopback-console with your app the following additions must be made 85 | to your app's `server/server.js` file, 86 | 87 | 1. Include the library: `const LoopbackConsole = require('loopback-console');` 88 | 2. Integrate it with server execution: 89 | ``` 90 | // LoopbackConsole.activated() checks whether the conditions are right to launch 91 | // the console instead of the web server. The console can be activated by passing 92 | // the argument --console or by setting env-var LOOPBACK_CONSOLE=1 93 | if (LoopbackConsole.activated()) { 94 | LoopbackConsole.start(app, { 95 | prompt: "my-app # ", 96 | // Other REPL or loopback-console config 97 | }); 98 | } else if (require.main === module) { 99 | app.start(); 100 | } 101 | ``` 102 | 103 | ### Configuration 104 | 105 | By integrating the loopback-console you also gain the ability to configure its functionality. 106 | The following configuration directives are supported, 107 | 108 | - `quiet`: Suppresses the help text on startup and the automatic printing of `result`. 109 | - `historyPath`: The path to a file to persist command history. Use an empty string (`''`) to disable history. 110 | - All built-in configuration options for Node.js REPL, such as `prompt`. 111 | - `handles`: Disable any default handles, or pass additional handles that you would like available on the console. 112 | 113 | Note, command history path can also be configured with the env-var `LOOPBACK_CONSOLE_HISTORY`. 114 | 115 | ## Contributors 116 | 117 | - Heath Morrison (doublemarked) 118 | 119 | Special thanks to the following people for their testing and feedback, 120 | 121 | - Pulkit Singhal (pulkitsinghal) 122 | 123 | ## License 124 | 125 | loopback-console uses the MIT license. See [LICENSE](https://github.com/doublemarked/loopback-console/blob/master/LICENSE) for more details. 126 | -------------------------------------------------------------------------------- /history.js: -------------------------------------------------------------------------------- 1 | /* 2 | * HM 2017-01-04: 3 | * History processing was extracted from the internal Node REPL code with minimal changes, 4 | * https://github.com/nodejs/node/blob/master/lib/internal/repl.js 5 | * 6 | * This internal history functionality is currently unpublished for various reasons 7 | * discussed in the following PR: 8 | * https://github.com/nodejs/node/pull/5789 9 | * 10 | * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * 11 | * 12 | * All extracted code remains under the general nodejs license terms: 13 | * https://github.com/nodejs/node/blob/master/LICENSE 14 | * 15 | * Copyright Node.js contributors. All rights reserved. 16 | * 17 | * Permission is hereby granted, free of charge, to any person obtaining a copy 18 | * of this software and associated documentation files (the "Software"), to 19 | * deal in the Software without restriction, including without limitation the 20 | * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 21 | * sell copies of the Software, and to permit persons to whom the Software is 22 | * furnished to do so, subject to the following conditions: 23 | * 24 | * The above copyright notice and this permission notice shall be included in 25 | * all copies or substantial portions of the Software. 26 | */ 27 | 28 | 'use strict'; 29 | 30 | const Interface = require('readline').Interface; 31 | const path = require('path'); 32 | const fs = require('fs'); 33 | const os = require('os'); 34 | const util = require('util'); 35 | const debug = util.debuglog('repl'); 36 | 37 | // XXX(chrisdickinson): The 15ms debounce value is somewhat arbitrary. 38 | // The debounce is to guard against code pasted into the REPL. 39 | const kDebounceHistoryMS = 15; 40 | 41 | const DEFAULT_HISTORY_FILE = '.loopback_console_history'; 42 | 43 | module.exports = function initReplHistory(repl, historyPath) { 44 | return new Promise((resolve, reject) => { 45 | setupHistory(repl, historyPath, (err, repl) => { 46 | if (err) { 47 | reject(err); 48 | } else { 49 | resolve(repl); 50 | } 51 | }); 52 | }); 53 | }; 54 | 55 | function setupHistory(repl, historyPath, ready) { 56 | // Empty string disables persistent history. 57 | if (typeof historyPath === 'string') 58 | historyPath = historyPath.trim(); 59 | 60 | if (historyPath === '') { 61 | repl._historyPrev = _replHistoryMessage; 62 | return ready(null, repl); 63 | } 64 | 65 | if (!historyPath) { 66 | try { 67 | historyPath = path.join(os.homedir(), DEFAULT_HISTORY_FILE); 68 | } catch (err) { 69 | repl._writeToOutput('\nError: Could not get the home directory.\n' + 70 | 'REPL session history will not be persisted.\n'); 71 | repl._refreshLine(); 72 | 73 | debug(err.stack); 74 | repl._historyPrev = _replHistoryMessage; 75 | return ready(null, repl); 76 | } 77 | } 78 | 79 | var timer = null; 80 | var writing = false; 81 | var pending = false; 82 | repl.pause(); 83 | // History files are conventionally not readable by others: 84 | // https://github.com/nodejs/node/issues/3392 85 | // https://github.com/nodejs/node/pull/3394 86 | fs.open(historyPath, 'a+', 0o0600, oninit); 87 | 88 | function oninit(err, hnd) { 89 | if (err) { 90 | // Cannot open history file. 91 | // Don't crash, just don't persist history. 92 | repl._writeToOutput('\nError: Could not open history file.\n' + 93 | 'REPL session history will not be persisted.\n'); 94 | repl._refreshLine(); 95 | debug(err.stack); 96 | 97 | repl._historyPrev = _replHistoryMessage; 98 | repl.resume(); 99 | return ready(null, repl); 100 | } 101 | fs.close(hnd, onclose); 102 | } 103 | 104 | function onclose(err) { 105 | if (err) { 106 | return ready(err); 107 | } 108 | fs.readFile(historyPath, 'utf8', onread); 109 | } 110 | 111 | function onread(err, data) { 112 | if (err) { 113 | return ready(err); 114 | } 115 | 116 | if (data) { 117 | repl.history = data.split(/[\n\r]+/, repl.historySize); 118 | } 119 | 120 | fs.open(historyPath, 'w', onhandle); 121 | } 122 | 123 | function onhandle(err, hnd) { 124 | if (err) { 125 | return ready(err); 126 | } 127 | repl._historyHandle = hnd; 128 | repl.on('line', online); 129 | 130 | // reading the file data out erases it 131 | repl.once('flushHistory', function() { 132 | repl.resume(); 133 | ready(null, repl); 134 | }); 135 | flushHistory(); 136 | } 137 | 138 | // ------ history listeners ------ 139 | function online() { 140 | repl._flushing = true; 141 | 142 | if (timer) { 143 | clearTimeout(timer); 144 | } 145 | 146 | timer = setTimeout(flushHistory, kDebounceHistoryMS); 147 | } 148 | 149 | function flushHistory() { 150 | timer = null; 151 | if (writing) { 152 | pending = true; 153 | return; 154 | } 155 | writing = true; 156 | const historyData = repl.history.join(os.EOL); 157 | fs.write(repl._historyHandle, historyData, 0, 'utf8', onwritten); 158 | } 159 | 160 | function onwritten(err, data) { 161 | writing = false; 162 | if (pending) { 163 | pending = false; 164 | online(); 165 | } else { 166 | repl._flushing = Boolean(timer); 167 | if (!repl._flushing) { 168 | repl.emit('flushHistory'); 169 | } 170 | } 171 | } 172 | }; 173 | 174 | function _replHistoryMessage() { 175 | if (this.history.length === 0) { 176 | this._writeToOutput( 177 | '\nPersistent history support disabled. ' + 178 | 'Set the history path to ' + 179 | 'a valid, user-writable path to enable.\n' 180 | ); 181 | this._refreshLine(); 182 | } 183 | this._historyPrev = Interface.prototype._historyPrev; 184 | return this._historyPrev(); 185 | } 186 | --------------------------------------------------------------------------------