Skip to content

Commit

Permalink
feat(cli): add config and yes options
Browse files Browse the repository at this point in the history
- Allow generator options to be passed in json format from
  a file, stdin, or stringified value
- Allow prompts to be skipped if a prompt has default value or
  the corresponding anwser exists in options
  • Loading branch information
raymondfeng committed Jun 26, 2018
1 parent c076eba commit aaab42d
Show file tree
Hide file tree
Showing 11 changed files with 418 additions and 25 deletions.
17 changes: 17 additions & 0 deletions docs/site/includes/CLI-std-options.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<!-- prettier-ignore-end -->
5 changes: 4 additions & 1 deletion packages/cli/generators/controller/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ module.exports = class ControllerGenerator extends ArtifactGenerator {
}

_setupGenerator() {
super._setupGenerator();
this.artifactInfo = {
type: 'controller',
rootDir: 'src',
Expand All @@ -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() {
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/generators/datasource/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
15 changes: 10 additions & 5 deletions packages/cli/lib/artifact-generator.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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,
},
];
Expand Down
231 changes: 230 additions & 1 deletion packages/cli/lib/base-generator.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,13 @@

const Generator = require('yeoman-generator');
const chalk = require('chalk');
const debug = require('./debug')('artifact-generator');
const utils = require('./utils');
const StatusConflicter = utils.StatusConflicter;
const path = require('path');
const fs = require('fs');
const readline = require('readline');
const debug = require('./debug')('base-generator');

/**
* Base Generator for LoopBack 4
*/
Expand All @@ -28,11 +32,235 @@ 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
*/
_readJSONFromStdin() {
const rl = readline.createInterface({
input: process.stdin,
});

const lines = [];
if (process.stdin.isTTY) {
this.log(
chalk.green(
'Please type in a json object line by line ' +
'(Press <ctrl>-D or type EOF to end):',
),
);
}

let err;
return new Promise((resolve, reject) => {
rl.on('SIGINT', () => {
err = new Error('Canceled by user');
rl.close();
reject(err);
})
.on('line', line => {
if (line === 'EOF') {
rl.close();
} else {
lines.push(line);
}
})
.on('close', () => {
if (err) return;
const jsonStr = lines.join('\n');
try {
const json = JSON.parse(jsonStr);
resolve(json);
} catch (e) {
if (!process.stdin.isTTY) {
debug(e, jsonStr);
}
reject(e);
}
})
.on('error', e => {
err = e;
rl.close();
reject(err);
});
});
}

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) {
this.log(
chalk.red('The stdin is not a terminal. No prompt is allowed.'),
);
process.exit(1);
}
// 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) {
this.log(
chalk.red('The stdin is not a terminal. No prompt is allowed.'),
);
process.exit(1);
}
// 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 `.
*/
Expand Down Expand Up @@ -96,6 +324,7 @@ module.exports = class BaseGenerator extends Generator {
*/
end() {
if (this.shouldExit()) {
debug(this.exitGeneration);
this.log(chalk.red('Generation is aborted:', this.exitGeneration));
return false;
}
Expand Down
6 changes: 4 additions & 2 deletions packages/cli/lib/project-generator.js
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down Expand Up @@ -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);
});
}

Expand Down Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading

0 comments on commit aaab42d

Please sign in to comment.