diff --git a/docs/site/includes/CLI-std-options.md b/docs/site/includes/CLI-std-options.md index 0ffcd82c8e85..c527878c7b40 100644 --- a/docs/site/includes/CLI-std-options.md +++ b/docs/site/includes/CLI-std-options.md @@ -10,4 +10,21 @@ `--skip-install` : Do not automatically install dependencies. Default is false. + +`-c, --config` +: JSON file name or value to configure options + +For example, +```sh +lb4 app --config config.json +lb4 app --config {"name":"my-app"} +cat config.json | lb4 app --config stdin +lb4 app --config stdin < config.json +lb4 app --config stdin << EOF +> {"name":"my-app"} +> EOF +``` + +`-y, --yes` +: Skip all confirmation prompts with default or provided value diff --git a/packages/cli/generators/controller/index.js b/packages/cli/generators/controller/index.js index ada170d048b0..d00926088baa 100644 --- a/packages/cli/generators/controller/index.js +++ b/packages/cli/generators/controller/index.js @@ -28,6 +28,7 @@ module.exports = class ControllerGenerator extends ArtifactGenerator { } _setupGenerator() { + super._setupGenerator(); this.artifactInfo = { type: 'controller', rootDir: 'src', @@ -54,8 +55,10 @@ module.exports = class ControllerGenerator extends ArtifactGenerator { required: false, description: 'Type for the ' + this.artifactInfo.type, }); + } - return super._setupGenerator(); + setOptions() { + return super.setOptions(); } checkLoopBackProject() { diff --git a/packages/cli/generators/datasource/index.js b/packages/cli/generators/datasource/index.js index b5d81e8ee3a9..e37d40680e44 100644 --- a/packages/cli/generators/datasource/index.js +++ b/packages/cli/generators/datasource/index.js @@ -60,6 +60,10 @@ module.exports = class DataSourceGenerator extends ArtifactGenerator { return super._setupGenerator(); } + setOptions() { + return super.setOptions(); + } + /** * Ensure CLI is being run in a LoopBack 4 project. */ diff --git a/packages/cli/lib/artifact-generator.js b/packages/cli/lib/artifact-generator.js index e27f7578f51d..466ffeab7b1e 100644 --- a/packages/cli/lib/artifact-generator.js +++ b/packages/cli/lib/artifact-generator.js @@ -19,23 +19,27 @@ module.exports = class ArtifactGenerator extends BaseGenerator { _setupGenerator() { debug('Setting up generator'); + super._setupGenerator(); this.argument('name', { type: String, required: false, description: 'Name for the ' + this.artifactInfo.type, }); + } + + setOptions() { // argument validation - if (this.args.length) { - const validationMsg = utils.validateClassName(this.args[0]); + const name = this.options.name; + if (name) { + const validationMsg = utils.validateClassName(name); if (typeof validationMsg === 'string') throw new Error(validationMsg); } - this.artifactInfo.name = this.args[0]; - this.artifactInfo.defaultName = 'new'; + this.artifactInfo.name = name; this.artifactInfo.relPath = path.relative( this.destinationPath(), this.artifactInfo.outDir, ); - super._setupGenerator(); + return super.setOptions(); } promptArtifactName() { @@ -48,6 +52,7 @@ module.exports = class ArtifactGenerator extends BaseGenerator { // capitalization message: utils.toClassName(this.artifactInfo.type) + ' class name:', when: this.artifactInfo.name === undefined, + default: this.artifactInfo.name, validate: utils.validateClassName, }, ]; diff --git a/packages/cli/lib/base-generator.js b/packages/cli/lib/base-generator.js index 2caa6ce1414f..2a73bd81994c 100644 --- a/packages/cli/lib/base-generator.js +++ b/packages/cli/lib/base-generator.js @@ -7,9 +7,12 @@ const Generator = require('yeoman-generator'); const chalk = require('chalk'); -const debug = require('./debug')('artifact-generator'); -const utils = require('./utils'); -const StatusConflicter = utils.StatusConflicter; +const {StatusConflicter, readTextFromStdin} = require('./utils'); +const path = require('path'); +const fs = require('fs'); +const readline = require('readline'); +const debug = require('./debug')('base-generator'); + /** * Base Generator for LoopBack 4 */ @@ -28,11 +31,206 @@ module.exports = class BaseGenerator extends Generator { * Subclasses can extend _setupGenerator() to set up the generator */ _setupGenerator() { + this.option('config', { + type: String, + alias: 'c', + description: 'JSON file name or value to configure options', + }); + + this.option('yes', { + type: Boolean, + alias: 'y', + description: + 'Skip all confirmation prompts with default or provided value', + }); + this.artifactInfo = this.artifactInfo || { rootDir: 'src', }; } + /** + * Read a json document from stdin + */ + async _readJSONFromStdin() { + if (process.stdin.isTTY) { + this.log( + chalk.green( + 'Please type in a json object line by line ' + + '(Press -D or type EOF to end):', + ), + ); + } + + try { + const jsonStr = await readTextFromStdin(); + return JSON.parse(jsonStr); + } catch (e) { + if (!process.stdin.isTTY) { + debug(e, jsonStr); + } + throw e; + } + } + + async setOptions() { + let opts = {}; + const jsonFileOrValue = this.options.config; + try { + if (jsonFileOrValue === 'stdin' || !process.stdin.isTTY) { + this.options['yes'] = true; + opts = await this._readJSONFromStdin(); + } else if (typeof jsonFileOrValue === 'string') { + const jsonFile = path.resolve(process.cwd(), jsonFileOrValue); + if (fs.existsSync(jsonFile)) { + opts = this.fs.readJSON(jsonFile); + } else { + // Try parse the config as stringified json + opts = JSON.parse(jsonFileOrValue); + } + } + } catch (e) { + this.exit(e); + return; + } + if (typeof opts !== 'object') { + this.exit('Invalid config file or value: ' + jsonFileOrValue); + return; + } + for (const o in opts) { + if (this.options[o] == null) { + this.options[o] = opts[o]; + } + } + } + + /** + * Check if a question can be skipped in `express` mode + * @param {object} question A yeoman prompt + */ + _isQuestionOptional(question) { + return ( + question.default != null || // Having a default value + this.options[question.name] != null || // Configured in options + question.type === 'list' || // A list + question.type === 'rawList' || // A raw list + question.type === 'checkbox' || // A checkbox + question.type === 'confirm' + ); // A confirmation + } + + /** + * Get the default answer for a question + * @param {*} question + */ + async _getDefaultAnswer(question, answers) { + let def = question.default; + if (typeof question.default === 'function') { + def = await question.default(answers); + } + let defaultVal = def; + + if (def == null) { + // No `default` is set for the question, check existing answers + defaultVal = answers[question.name]; + if (defaultVal != null) return defaultVal; + } + + if (question.type === 'confirm') { + return defaultVal != null ? defaultVal : true; + } + if (question.type === 'list' || question.type === 'rawList') { + // Default to 1st item + if (def == null) def = 0; + if (typeof def === 'number') { + // The `default` is an index + const choice = question.choices[def]; + if (choice) { + defaultVal = choice.value || choice.name; + } + } else { + // The default is a value + if (question.choices.map(c => c.value || c.name).includes(def)) { + defaultVal = def; + } + } + } else if (question.type === 'checkbox') { + if (def == null) { + defaultVal = question.choices + .filter(c => c.checked && !c.disabled) + .map(c => c.value || c.name); + } else { + defaultVal = def + .map(d => { + if (typeof d === 'number') { + const choice = question.choices[d]; + if (choice && !choice.disabled) { + return choice.value || choice.name; + } + } else { + if ( + question.choices.find( + c => !c.disabled && d === (c.value || c.name), + ) + ) { + return d; + } + } + return undefined; + }) + .filter(v => v != null); + } + } + return defaultVal; + } + + /** + * Override the base prompt to skip prompts with default answers + * @param questions One or more questions + */ + async prompt(questions) { + // Normalize the questions to be an array + if (!Array.isArray(questions)) { + questions = [questions]; + } + if (!this.options['yes']) { + if (!process.stdin.isTTY) { + const msg = 'The stdin is not a terminal. No prompt is allowed.'; + this.log(chalk.red(msg)); + this.exit(new Error(msg)); + return; + } + // Non-express mode, continue to prompt + return await super.prompt(questions); + } + + const answers = Object.assign({}, this.options); + + for (const q of questions) { + let when = q.when; + if (typeof when === 'function') { + when = await q.when(answers); + } + if (when === false) continue; + if (this._isQuestionOptional(q)) { + const answer = await this._getDefaultAnswer(q, answers); + debug('%s: %j', q.name, answer); + answers[q.name] = answer; + } else { + if (!process.stdin.isTTY) { + const msg = 'The stdin is not a terminal. No prompt is allowed.'; + this.log(chalk.red(msg)); + this.exit(new Error(msg)); + return; + } + // Only prompt for non-skipped questions + const props = await super.prompt([q]); + Object.assign(answers, props); + } + } + return answers; + } + /** * Override the usage text by replacing `yo loopback4:` with `lb4 `. */ @@ -96,7 +294,10 @@ module.exports = class BaseGenerator extends Generator { */ end() { if (this.shouldExit()) { + debug(this.exitGeneration); this.log(chalk.red('Generation is aborted:', this.exitGeneration)); + // Fail the process + process.exitCode = 1; return false; } return true; diff --git a/packages/cli/lib/project-generator.js b/packages/cli/lib/project-generator.js index f095895a6035..57ca34f22ec0 100644 --- a/packages/cli/lib/project-generator.js +++ b/packages/cli/lib/project-generator.js @@ -82,7 +82,9 @@ module.exports = class ProjectGenerator extends BaseGenerator { this.registerTransformStream(utils.renameEJS()); } - setOptions() { + async setOptions() { + await super.setOptions(); + if (this.shouldExit()) return false; if (this.options.name) { const msg = utils.validate(this.options.name); if (typeof msg === 'string') { @@ -148,6 +150,7 @@ module.exports = class ProjectGenerator extends BaseGenerator { return this.prompt(prompts).then(props => { Object.assign(this.projectInfo, props); + this.destinationRoot(this.projectInfo.outdir); }); } @@ -187,7 +190,6 @@ module.exports = class ProjectGenerator extends BaseGenerator { scaffold() { if (this.shouldExit()) return false; - this.destinationRoot(this.projectInfo.outdir); // First copy common files from ../../project/templates this.fs.copyTpl( diff --git a/packages/cli/lib/utils.js b/packages/cli/lib/utils.js index c27aa2bb8a44..6937f3e8ca38 100644 --- a/packages/cli/lib/utils.js +++ b/packages/cli/lib/utils.js @@ -10,6 +10,8 @@ const fs = require('fs'); const path = require('path'); const util = require('util'); const stream = require('stream'); +const readline = require('readline'); +const chalk = require('chalk'); var semver = require('semver'); const regenerate = require('regenerate'); const _ = require('lodash'); @@ -293,3 +295,36 @@ exports.validateStringObject = function(type) { return true; }; }; + +/** + * Use readline to read text from stdin + */ +exports.readTextFromStdin = function() { + const rl = readline.createInterface({ + input: process.stdin, + }); + + const lines = []; + let err; + return new Promise((resolve, reject) => { + rl.on('SIGINT', () => { + err = new Error('Canceled by user'); + rl.close(); + }) + .on('line', line => { + if (line === 'EOF') { + rl.close(); + } else { + lines.push(line); + } + }) + .on('close', () => { + if (err) reject(err); + else resolve(lines.join('\n')); + }) + .on('error', e => { + err = e; + rl.close(); + }); + }); +}; diff --git a/packages/cli/package.json b/packages/cli/package.json index 7af77538efdc..7a73f32bbd63 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -31,6 +31,7 @@ "glob": "^7.1.2", "mem-fs": "^1.1.3", "mem-fs-editor": "^4.0.0", + "mock-stdin": "^0.3.1", "nsp": "^3.2.1", "request": "^2.87.0", "request-promise-native": "^1.0.5", diff --git a/packages/cli/test/integration/lib/artifact-generator.js b/packages/cli/test/integration/lib/artifact-generator.js index b37ad3411e12..519a9333d260 100644 --- a/packages/cli/test/integration/lib/artifact-generator.js +++ b/packages/cli/test/integration/lib/artifact-generator.js @@ -16,19 +16,26 @@ module.exports = function(artiGenerator) { describe('args validation', () => { it('errors out if validation fails', () => { assert.throws(() => { - testUtils.testSetUpGen(artiGenerator, {args: '2foobar'}); + const gen = testUtils.testSetUpGen(artiGenerator, { + args: '2foobar', + }); + gen.setOptions(); }, Error); }); it('succeeds if no arg is provided', () => { assert.doesNotThrow(() => { - testUtils.testSetUpGen(artiGenerator); + const gen = testUtils.testSetUpGen(artiGenerator); + gen.setOptions(); }, Error); }); it('succeeds if arg is valid', () => { assert.doesNotThrow(() => { - testUtils.testSetUpGen(artiGenerator, {args: ['foobar']}); + const gen = testUtils.testSetUpGen(artiGenerator, { + args: ['foobar'], + }); + gen.setOptions(); }, Error); }); }); @@ -42,10 +49,11 @@ module.exports = function(artiGenerator) { assert(helpText.match(/Required: false/)); }); - it('sets up artifactInfo', () => { + it('sets up artifactInfo', async () => { let gen = testUtils.testSetUpGen(artiGenerator, {args: ['test']}); + await gen.setOptions(); assert(gen.artifactInfo); - assert(gen.artifactInfo.name == 'test'); + assert.equal(gen.artifactInfo.name, 'test'); }); }); diff --git a/packages/cli/test/integration/lib/base-config.json b/packages/cli/test/integration/lib/base-config.json new file mode 100644 index 000000000000..05fea4af5504 --- /dev/null +++ b/packages/cli/test/integration/lib/base-config.json @@ -0,0 +1,4 @@ +{ + "name": "xyz", + "description": "Test" +} diff --git a/packages/cli/test/integration/lib/base-generator.js b/packages/cli/test/integration/lib/base-generator.js index 5ff3d5379234..3ca8b87cd8e5 100644 --- a/packages/cli/test/integration/lib/base-generator.js +++ b/packages/cli/test/integration/lib/base-generator.js @@ -7,33 +7,142 @@ const assert = require('yeoman-assert'); const testUtils = require('../../test-utils'); +const path = require('path'); +const mockStdin = require('mock-stdin'); module.exports = function(generator) { return function() { describe('usage', () => { it('prints lb4', () => { - let gen = testUtils.testSetUpGen(generator); - let helpText = gen.help(); + const gen = testUtils.testSetUpGen(generator); + const helpText = gen.help(); assert(helpText.match(/lb4 /)); assert(!helpText.match(/loopback4:/)); }); }); + describe('exit', () => { it('does nothing if false is passed', () => { - let gen = testUtils.testSetUpGen(generator); + const gen = testUtils.testSetUpGen(generator); gen.exit(false); assert(gen.exitGeneration === undefined); }); it('sets "exitGeneration" to true if called with no argument', () => { - let gen = testUtils.testSetUpGen(generator); + const gen = testUtils.testSetUpGen(generator); gen.exit(); assert(gen.exitGeneration === true); }); it('sets "exitGeneration" to the error passed to itself', () => { - let gen = testUtils.testSetUpGen(generator); + const gen = testUtils.testSetUpGen(generator); gen.exit(new Error('oh no')); assert(gen.exitGeneration instanceof Error); - assert(gen.exitGeneration.message === 'oh no'); + assert.equal(gen.exitGeneration.message, 'oh no'); + }); + + after(() => { + // Reset the exit code so that mocha will not complain + process.exitCode = 0; + }); + }); + + describe('config from json file', () => { + it('accepts --config', async () => { + const jsonFile = path.join(__dirname, 'base-config.json'); + const gen = testUtils.testSetUpGen(generator, { + args: ['--config', jsonFile], + }); + await gen.setOptions(); + assert.equal(gen.options['config'], jsonFile); + assert.equal(gen.options.name, 'xyz'); + }); + + it('options from json file do not override', async () => { + const jsonFile = path.join(__dirname, 'base-config.json'); + const gen = testUtils.testSetUpGen(generator, { + args: ['--name', 'abc', '--config', jsonFile], + }); + await gen.setOptions(); + assert.equal(gen.options['config'], jsonFile); + assert.equal(gen.options.name, 'abc'); + assert.equal(gen.options.description, 'Test'); + }); + }); + + describe('config from json value', () => { + const jsonValue = `{ + "name": "xyz", + "description": "Test" + }`; + it('accepts --config', async () => { + const gen = testUtils.testSetUpGen(generator, { + args: ['--config', jsonValue], + }); + await gen.setOptions(); + assert.equal(gen.options['config'], jsonValue); + assert.equal(gen.options.name, 'xyz'); + }); + + it('options from json file do not override', async () => { + const gen = testUtils.testSetUpGen(generator, { + args: ['--name', 'abc', '--config', jsonValue], + }); + await gen.setOptions(); + assert.equal(gen.options['config'], jsonValue); + assert.equal(gen.options.name, 'abc'); + assert.equal(gen.options.description, 'Test'); + }); + }); + + describe('config from stdin', () => { + let mock; + + before(() => { + mock = mockStdin.stdin(); + }); + + after(() => { + mock.restore(); + }); + + afterEach(() => { + mock.reset(true); + }); + + it('accepts --config stdin', () => { + const gen = testUtils.testSetUpGen(generator, { + args: ['--config', 'stdin'], + }); + const promise = gen.setOptions(); + assert.equal(gen.options['config'], 'stdin'); + // Reading config from stdin will skip optional prompts + assert.equal(gen.options['yes'], true); + mock.send('{'); + mock.send('"name": "xyz"'); + mock.send('}'); + mock.end(); + return promise.then(() => { + assert.equal(gen.options.name, 'xyz'); + }); + }); + + it('reports invalid json from stdin', () => { + const gen = testUtils.testSetUpGen(generator, { + args: ['--config', 'stdin'], + }); + const promise = gen.setOptions(); + assert.equal(gen.options['config'], 'stdin'); + // Reading config from stdin will skip optional prompts + assert.equal(gen.options['yes'], true); + mock.send('{'); + mock.send('"name": xyz"'); + mock.send('}'); + mock.end(); + return promise.catch(err => { + assert.equal( + err, + 'SyntaxError: Unexpected token x in JSON at position 9', + ); + }); }); }); }; diff --git a/packages/cli/test/integration/lib/project-generator.js b/packages/cli/test/integration/lib/project-generator.js index 08688da30d7e..a24667a8b877 100644 --- a/packages/cli/test/integration/lib/project-generator.js +++ b/packages/cli/test/integration/lib/project-generator.js @@ -139,7 +139,7 @@ module.exports = function(projGenerator, props, projectType) { }); describe('setOptions', () => { - it('has projectInfo set up', () => { + it('has projectInfo set up', async () => { let gen = testUtils.testSetUpGen(projGenerator); gen.options = { name: 'foobar', @@ -151,7 +151,7 @@ module.exports = function(projGenerator, props, projectType) { loopbackBuild: null, vscode: null, }; - gen.setOptions(); + await gen.setOptions(); assert(gen.projectInfo.name === 'foobar'); assert( gen.projectInfo.dependencies['@loopback/context'] === @@ -452,11 +452,27 @@ module.exports = function(projGenerator, props, projectType) { }); }); - function testPrompt(gen, props, fnName) { - gen.setOptions(); + describe('with --skip-optional-prompts', () => { + before(() => { + return helpers.run(projGenerator).withOptions({ + name: props.name, + 'skip-optional-prompts': true, + }); + }); + + it('creates files', () => { + assert.jsonFileContent('package.json', { + name: props.name, + description: props.name, + }); + }); + }); + + async function testPrompt(gen, props, fnName) { + await gen.setOptions(); gen.prompt = sinon.stub(gen, 'prompt'); gen.prompt.resolves(props); - return gen[fnName](); + return await gen[fnName](); } }; }; diff --git a/packages/repository/package.json b/packages/repository/package.json index 937cf35d5e90..bb47f2542a12 100644 --- a/packages/repository/package.json +++ b/packages/repository/package.json @@ -32,7 +32,7 @@ "@loopback/core": "^0.10.1", "@loopback/dist-util": "^0.3.3", "lodash": "^4.17.10", - "loopback-datasource-juggler": "^3.20.2" + "loopback-datasource-juggler": "^3.22.1" }, "files": [ "README.md",