Skip to content

Commit 5778a2a

Browse files
committed
feat(cli): add config and yes options
- 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
1 parent 24a3bb5 commit 5778a2a

File tree

13 files changed

+433
-28
lines changed

13 files changed

+433
-28
lines changed

docs/site/includes/CLI-std-options.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,21 @@
1010

1111
`--skip-install`
1212
: Do not automatically install dependencies. Default is false.
13+
14+
`-c, --config`
15+
: JSON file name or value to configure options
16+
17+
For example,
18+
```sh
19+
lb4 app --config config.json
20+
lb4 app --config {"name":"my-app"}
21+
cat config.json | lb4 app --config stdin
22+
lb4 app --config stdin < config.json
23+
lb4 app --config stdin << EOF
24+
> {"name":"my-app"}
25+
> EOF
26+
```
27+
28+
`-y, --yes`
29+
: Skip all confirmation prompts with default or provided value
1330
<!-- prettier-ignore-end -->

packages/cli/generators/controller/index.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ module.exports = class ControllerGenerator extends ArtifactGenerator {
2828
}
2929

3030
_setupGenerator() {
31+
super._setupGenerator();
3132
this.artifactInfo = {
3233
type: 'controller',
3334
rootDir: 'src',
@@ -54,8 +55,10 @@ module.exports = class ControllerGenerator extends ArtifactGenerator {
5455
required: false,
5556
description: 'Type for the ' + this.artifactInfo.type,
5657
});
58+
}
5759

58-
return super._setupGenerator();
60+
setOptions() {
61+
return super.setOptions();
5962
}
6063

6164
checkLoopBackProject() {

packages/cli/generators/datasource/index.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ module.exports = class DataSourceGenerator extends ArtifactGenerator {
6060
return super._setupGenerator();
6161
}
6262

63+
setOptions() {
64+
return super.setOptions();
65+
}
66+
6367
/**
6468
* Ensure CLI is being run in a LoopBack 4 project.
6569
*/

packages/cli/lib/artifact-generator.js

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,23 +19,27 @@ module.exports = class ArtifactGenerator extends BaseGenerator {
1919

2020
_setupGenerator() {
2121
debug('Setting up generator');
22+
super._setupGenerator();
2223
this.argument('name', {
2324
type: String,
2425
required: false,
2526
description: 'Name for the ' + this.artifactInfo.type,
2627
});
28+
}
29+
30+
setOptions() {
2731
// argument validation
28-
if (this.args.length) {
29-
const validationMsg = utils.validateClassName(this.args[0]);
32+
const name = this.options.name;
33+
if (name) {
34+
const validationMsg = utils.validateClassName(name);
3035
if (typeof validationMsg === 'string') throw new Error(validationMsg);
3136
}
32-
this.artifactInfo.name = this.args[0];
33-
this.artifactInfo.defaultName = 'new';
37+
this.artifactInfo.name = name;
3438
this.artifactInfo.relPath = path.relative(
3539
this.destinationPath(),
3640
this.artifactInfo.outDir,
3741
);
38-
super._setupGenerator();
42+
return super.setOptions();
3943
}
4044

4145
promptArtifactName() {
@@ -48,6 +52,7 @@ module.exports = class ArtifactGenerator extends BaseGenerator {
4852
// capitalization
4953
message: utils.toClassName(this.artifactInfo.type) + ' class name:',
5054
when: this.artifactInfo.name === undefined,
55+
default: this.artifactInfo.name,
5156
validate: utils.validateClassName,
5257
},
5358
];

packages/cli/lib/base-generator.js

Lines changed: 204 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,12 @@
77

88
const Generator = require('yeoman-generator');
99
const chalk = require('chalk');
10-
const debug = require('./debug')('artifact-generator');
11-
const utils = require('./utils');
12-
const StatusConflicter = utils.StatusConflicter;
10+
const {StatusConflicter, readTextFromStdin} = require('./utils');
11+
const path = require('path');
12+
const fs = require('fs');
13+
const readline = require('readline');
14+
const debug = require('./debug')('base-generator');
15+
1316
/**
1417
* Base Generator for LoopBack 4
1518
*/
@@ -28,11 +31,206 @@ module.exports = class BaseGenerator extends Generator {
2831
* Subclasses can extend _setupGenerator() to set up the generator
2932
*/
3033
_setupGenerator() {
34+
this.option('config', {
35+
type: String,
36+
alias: 'c',
37+
description: 'JSON file name or value to configure options',
38+
});
39+
40+
this.option('yes', {
41+
type: Boolean,
42+
alias: 'y',
43+
description:
44+
'Skip all confirmation prompts with default or provided value',
45+
});
46+
3147
this.artifactInfo = this.artifactInfo || {
3248
rootDir: 'src',
3349
};
3450
}
3551

52+
/**
53+
* Read a json document from stdin
54+
*/
55+
async _readJSONFromStdin() {
56+
if (process.stdin.isTTY) {
57+
this.log(
58+
chalk.green(
59+
'Please type in a json object line by line ' +
60+
'(Press <ctrl>-D or type EOF to end):',
61+
),
62+
);
63+
}
64+
65+
try {
66+
const jsonStr = await readTextFromStdin();
67+
return JSON.parse(jsonStr);
68+
} catch (e) {
69+
if (!process.stdin.isTTY) {
70+
debug(e, jsonStr);
71+
}
72+
throw e;
73+
}
74+
}
75+
76+
async setOptions() {
77+
let opts = {};
78+
const jsonFileOrValue = this.options.config;
79+
try {
80+
if (jsonFileOrValue === 'stdin' || !process.stdin.isTTY) {
81+
this.options['yes'] = true;
82+
opts = await this._readJSONFromStdin();
83+
} else if (typeof jsonFileOrValue === 'string') {
84+
const jsonFile = path.resolve(process.cwd(), jsonFileOrValue);
85+
if (fs.existsSync(jsonFile)) {
86+
opts = this.fs.readJSON(jsonFile);
87+
} else {
88+
// Try parse the config as stringified json
89+
opts = JSON.parse(jsonFileOrValue);
90+
}
91+
}
92+
} catch (e) {
93+
this.exit(e);
94+
return;
95+
}
96+
if (typeof opts !== 'object') {
97+
this.exit('Invalid config file or value: ' + jsonFileOrValue);
98+
return;
99+
}
100+
for (const o in opts) {
101+
if (this.options[o] == null) {
102+
this.options[o] = opts[o];
103+
}
104+
}
105+
}
106+
107+
/**
108+
* Check if a question can be skipped in `express` mode
109+
* @param {object} question A yeoman prompt
110+
*/
111+
_isQuestionOptional(question) {
112+
return (
113+
question.default != null || // Having a default value
114+
this.options[question.name] != null || // Configured in options
115+
question.type === 'list' || // A list
116+
question.type === 'rawList' || // A raw list
117+
question.type === 'checkbox' || // A checkbox
118+
question.type === 'confirm'
119+
); // A confirmation
120+
}
121+
122+
/**
123+
* Get the default answer for a question
124+
* @param {*} question
125+
*/
126+
async _getDefaultAnswer(question, answers) {
127+
let def = question.default;
128+
if (typeof question.default === 'function') {
129+
def = await question.default(answers);
130+
}
131+
let defaultVal = def;
132+
133+
if (def == null) {
134+
// No `default` is set for the question, check existing answers
135+
defaultVal = answers[question.name];
136+
if (defaultVal != null) return defaultVal;
137+
}
138+
139+
if (question.type === 'confirm') {
140+
return defaultVal != null ? defaultVal : true;
141+
}
142+
if (question.type === 'list' || question.type === 'rawList') {
143+
// Default to 1st item
144+
if (def == null) def = 0;
145+
if (typeof def === 'number') {
146+
// The `default` is an index
147+
const choice = question.choices[def];
148+
if (choice) {
149+
defaultVal = choice.value || choice.name;
150+
}
151+
} else {
152+
// The default is a value
153+
if (question.choices.map(c => c.value || c.name).includes(def)) {
154+
defaultVal = def;
155+
}
156+
}
157+
} else if (question.type === 'checkbox') {
158+
if (def == null) {
159+
defaultVal = question.choices
160+
.filter(c => c.checked && !c.disabled)
161+
.map(c => c.value || c.name);
162+
} else {
163+
defaultVal = def
164+
.map(d => {
165+
if (typeof d === 'number') {
166+
const choice = question.choices[d];
167+
if (choice && !choice.disabled) {
168+
return choice.value || choice.name;
169+
}
170+
} else {
171+
if (
172+
question.choices.find(
173+
c => !c.disabled && d === (c.value || c.name),
174+
)
175+
) {
176+
return d;
177+
}
178+
}
179+
return undefined;
180+
})
181+
.filter(v => v != null);
182+
}
183+
}
184+
return defaultVal;
185+
}
186+
187+
/**
188+
* Override the base prompt to skip prompts with default answers
189+
* @param questions One or more questions
190+
*/
191+
async prompt(questions) {
192+
// Normalize the questions to be an array
193+
if (!Array.isArray(questions)) {
194+
questions = [questions];
195+
}
196+
if (!this.options['yes']) {
197+
if (!process.stdin.isTTY) {
198+
const msg = 'The stdin is not a terminal. No prompt is allowed.';
199+
this.log(chalk.red(msg));
200+
this.exit(new Error(msg));
201+
return;
202+
}
203+
// Non-express mode, continue to prompt
204+
return await super.prompt(questions);
205+
}
206+
207+
const answers = Object.assign({}, this.options);
208+
209+
for (const q of questions) {
210+
let when = q.when;
211+
if (typeof when === 'function') {
212+
when = await q.when(answers);
213+
}
214+
if (when === false) continue;
215+
if (this._isQuestionOptional(q)) {
216+
const answer = await this._getDefaultAnswer(q, answers);
217+
debug('%s: %j', q.name, answer);
218+
answers[q.name] = answer;
219+
} else {
220+
if (!process.stdin.isTTY) {
221+
const msg = 'The stdin is not a terminal. No prompt is allowed.';
222+
this.log(chalk.red(msg));
223+
this.exit(new Error(msg));
224+
return;
225+
}
226+
// Only prompt for non-skipped questions
227+
const props = await super.prompt([q]);
228+
Object.assign(answers, props);
229+
}
230+
}
231+
return answers;
232+
}
233+
36234
/**
37235
* Override the usage text by replacing `yo loopback4:` with `lb4 `.
38236
*/
@@ -96,7 +294,10 @@ module.exports = class BaseGenerator extends Generator {
96294
*/
97295
end() {
98296
if (this.shouldExit()) {
297+
debug(this.exitGeneration);
99298
this.log(chalk.red('Generation is aborted:', this.exitGeneration));
299+
// Fail the process
300+
process.exitCode = 1;
100301
return false;
101302
}
102303
return true;

packages/cli/lib/project-generator.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,9 @@ module.exports = class ProjectGenerator extends BaseGenerator {
8282
this.registerTransformStream(utils.renameEJS());
8383
}
8484

85-
setOptions() {
85+
async setOptions() {
86+
await super.setOptions();
87+
if (this.shouldExit()) return false;
8688
if (this.options.name) {
8789
const msg = utils.validate(this.options.name);
8890
if (typeof msg === 'string') {
@@ -148,6 +150,7 @@ module.exports = class ProjectGenerator extends BaseGenerator {
148150

149151
return this.prompt(prompts).then(props => {
150152
Object.assign(this.projectInfo, props);
153+
this.destinationRoot(this.projectInfo.outdir);
151154
});
152155
}
153156

@@ -187,7 +190,6 @@ module.exports = class ProjectGenerator extends BaseGenerator {
187190

188191
scaffold() {
189192
if (this.shouldExit()) return false;
190-
this.destinationRoot(this.projectInfo.outdir);
191193

192194
// First copy common files from ../../project/templates
193195
this.fs.copyTpl(

0 commit comments

Comments
 (0)