Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enable json config options and non-interactive mode #1384

Merged
merged 1 commit into from
Jul 8, 2018

Conversation

raymondfeng
Copy link
Contributor

@raymondfeng raymondfeng commented May 30, 2018

The PR adds the following features:

  • Allow options to be passed in via --config from a json file, stdin, or stringified value
  • Allow --yes option so that optional questions will be skipped with default values

For example,

lb4 app my-app --yes
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

Checklist

  • npm test passes on your machine
  • New tests added or existing tests modified to cover all changes
  • Code conforms with the style guide
  • API Documentation in code was updated
  • Documentation in /docs/site was updated
  • Affected artifact templates in packages/cli were updated
  • Affected example projects in examples/* were updated

@raymondfeng raymondfeng requested a review from bajtos as a code owner May 30, 2018 22:15
@raymondfeng raymondfeng changed the title Enable json/json-file options and express mode [WIP] Enable json/json-file options and express mode May 30, 2018
@@ -21,11 +22,113 @@ module.exports = class BaseGenerator extends Generator {
* Subclasses can extend _setupGenerator() to set up the generator
*/
_setupGenerator() {
this.option('json', {
type: String,
description: 'Stringified JSON object to configure options',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It makes me wonder how practical it is to pass JSON strings via exec arguments. IIRC, there are limitations on the maximum supported length - e.g. Linux limits the length to ~100KB according to this post, Windows has a limit of 32KB according to this post.

In my experience, CLI programs usually accept large content from stdin stream.

I am proposing to slightly different set of CLI options and behavior:

# Read JSON instructions from the given file
$ lb4 app --json commands.json
# No file was provided, read instruction from stdin
$ cat commands.json | lb4 app --json

Thoughts?

this.option('express', {
type: Boolean,
description:
'Run the generator in express mode to skip optional questions',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When I first saw the name of the options, I assumed express would be related to Express.js framework.

Can we find a better option name please, for example --non-interactive or --skip-prompts?

Copy link
Member

@bajtos bajtos left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Besides my comments below, please add documentation explaining how to use the new express/non-interactive mode. Ideally, the pull request description should be updated to match the actual implementation too.

* keyword 'loopback' under 'keywords' attribute in package.json.
* 'keywords' is an array
*/
checkLoopBackProject() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't we already have another implementation of checkLoopBackProject? I think @virkt25 was using one in his datasource work. Let's create a single implementation of this check and move it to a place where all generators can import it from.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, all the artifact generators call super.checkLoopBackProject() which is implemented in BaseGenerator which is what this generator (ArtifactGenerator) extends. This implementation seems to be the same as the one found in the base generator so not sure why it's duplicated.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@raymondfeng PTAL ☝️

if (process.stdin.isTTY) {
this.log(
chalk.green(
'Please type in a json object line by line ' +
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uh oh, do you really expect people to type JSON by hand? What if they make a typo, e.g. forget to wrap a key in double quotes - how are they going to fix the type? Will they have to re-type everything again?

In my opinion, adding readline support is overkill. The JSON mode is intended for machines, humans should use interactive prompt mode.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@raymondfeng PTAL ☝️

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm thinking about copy-paste of json objects into stdin.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please also note the readline support is needed for unix pipe or redirection, for example:

  • cat config.json | lb4 app --config stdin
  • lb4 app --config stdin < config.json
  • lb4 app --config stdin <<EOF

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generator needs to read from stdin - which is the source of input from unix pipes. The readline module is used to enable that.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In Node.js, process.stdin is a regular stream that you can read the input from. No need to wrap it in readline, unless I am missing something specific to how Yeoman modifies stdin.

A typical solution for reading all input from stdin into a string is to pipe stdin to concat-stream.

A mock-up:

const concat = require('concat-stream');
 
// NOTE: returns Promise<Buffer>, not Promise<string>!
function readStream(src) {
  return new Promise((resolve, reject) => {
    const concatStream = concat(resolve);
    src('error', reject);
    src.pipe(concatStream);
  });
}

// usage
const rawJsonBuffer = await readStream(stdin);
const data = JSON.parse(rawJsonBuffer);
// fortunately, JSON.parse works with buffers too

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, that's just a different way of implementing readJSONFromStdin. Is leveraging readline to read from stdin a bad idea?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The other option is the use the following code:

function readBufferFromStdin() {
	const stdin = process.stdin;

	const data = [];
	let len = 0;

	return new Promise((resolve, reject) => {

		stdin.on('readable', () => {
			let chunk;

			while ((chunk = stdin.read())) {
				data.push(chunk);
				len += chunk.length;
			}
		});

		stdin.on('end', () => {
			resolve(Buffer.concat(data, len));
		});

		stdin.on('error', err => {
			reject(err);
		});
	});
};

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, that's just a different way of implementing readJSONFromStdin. Is leveraging readline to read from stdin a bad idea?

I suppose using readline is not a bad idea per se. My main objection is against the complexity of the current implementation of _readJSONFromStdin. To me, the currently proposed code requires maintainers to know a lot about readline, TTY, SIGINT, etc. and the way how all things are wired together.

Compare it with my proposed solution that's just 5 lines of relatively straightforward code.

return new Promise((resolve, reject) => {
  const concatStream = concat(resolve);
  src.on('error', reject);
  src.pipe(concatStream);
});

Your second example, using ReadableStream directly, looks better to me than the original readline-based one. But why to re-implement reading a stream into a buffer when concat-stream module already does it well? Dealing with streams is tricky and it's very easy to introduce subtle bugs that are difficult to spot by casual readers.

Not a big deal though. If you prefer a hand-written readBufferFromStdin then I am ok with that too.

q.type === 'confirm'; // A confirmation

// Get the default answer for a question
const defaultAnswer = async q => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

async function defaultAnswer(q) {
  // ...
}

The implementation of this function is way too long, please refactor it by extracting bits and pieces into standalone functions.

return defaultVal != null ? defaultVal : true;
}
if (q.type === 'list' || q.type === 'rawList') {
// Default to 1st item
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Handling of list/rawList is a prime candidate to be extracted into a new function.

}
}
} else if (q.type === 'checkbox') {
if (def == null) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditto here - please extract checkbox case into a function.

@bajtos bajtos changed the title [WIP] Enable json/json-file options and express mode Enable json/json-file options and non-interactive mode Jun 14, 2018
Copy link
Contributor

@virkt25 virkt25 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great PR 👏 ! I'm excited to see this PR land in the CLI :D -- Just a few quick comments but looks great otherwise.

* keyword 'loopback' under 'keywords' attribute in package.json.
* 'keywords' is an array
*/
checkLoopBackProject() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, all the artifact generators call super.checkLoopBackProject() which is implemented in BaseGenerator which is what this generator (ArtifactGenerator) extends. This implementation seems to be the same as the one found in the base generator so not sure why it's duplicated.

if (process.stdin.isTTY) {
this.log(
chalk.green(
'Please type in a json object line by line ' +
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1


this.option('skip-optional-prompts', {
type: Boolean,
alias: 'b',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is the alias for this prompt b? I'd image maybe s for skip?

description: 'JSON file to configure options',
});

this.option('skip-optional-prompts', {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure how I feel about a such a long flag name from a UX perspective. Can we consider a simpler / easier to type / use / remember flag such as --skip or --skip-optional or --express (but may be confusing with Express framework) or --quick or --defaults ... anything smaller than the current flag.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was proposing --non-interactive, but that name has a slightly different meaning.

To be honest, I don't see much value in --skip-default-prompts unless it's used with the JSON mode. I am proposing to merge these two modes together and use the --config option to drive the behavior.

  • Answers are read from JSON - use non-interactive mode (skip default prompts)
  • Answers are read from UI prompts - use interactive mode (ask all prompts)

That way there is only one new CLI option to add (--config).

@raymondfeng raymondfeng force-pushed the enable-cli-json branch 5 times, most recently from 9b49a49 to 041b5d2 Compare June 25, 2018 17:18
Copy link
Member

@bajtos bajtos left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW, there are few older comments waiting to be addressed or discussed.

* keyword 'loopback' under 'keywords' attribute in package.json.
* 'keywords' is an array
*/
checkLoopBackProject() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@raymondfeng PTAL ☝️

if (process.stdin.isTTY) {
this.log(
chalk.green(
'Please type in a json object line by line ' +
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@raymondfeng PTAL ☝️

@raymondfeng
Copy link
Contributor Author

The checkLoopBackProject is a duplicate from merging. Will remove it.

@raymondfeng raymondfeng changed the title Enable json/json-file options and non-interactive mode Enable json config options and non-interactive mode Jun 26, 2018
@raymondfeng raymondfeng force-pushed the enable-cli-json branch 2 times, most recently from 4718144 to aaab42d Compare June 26, 2018 17:21
@raymondfeng
Copy link
Contributor Author

After switching to stream-based implementation of reading stdin, the following error is thrown:

tty.js:59
  this._handle.setRawMode(flag);
               ^

TypeError: Cannot read property 'setRawMode' of null
    at ReadStream.setRawMode (tty.js:59:16)
    at Interface._setRawMode (readline.js:243:16)
    at new Interface (readline.js:203:10)
    at Object.createInterface (readline.js:64:10)
    at new UI (/Users/rfeng/Projects/loopback4/loopback-next/packages/cli/node_modules/yeoman-environment/node_modules/inquirer/lib/ui/baseUI.js:15:26)
    at new PromptUI (/Users/rfeng/Projects/loopback4/loopback-next/packages/cli/node_modules/yeoman-environment/node_modules/inquirer/lib/ui/prompt.js:14:5)
    at TerminalAdapter.promptModule (/Users/rfeng/Projects/loopback4/loopback-next/packages/cli/node_modules/yeoman-environment/node_modules/inquirer/lib/inquirer.js:27:14)
    at TerminalAdapter.prompt (/Users/rfeng/Projects/loopback4/loopback-next/packages/cli/node_modules/yeoman-environment/lib/adapter.js:46:26)
    at StatusConflicter._ask (/Users/rfeng/Projects/loopback4/loopback-next/packages/cli/node_modules/yeoman-generator/lib/util/conflicter.js:172:18)
    at StatusConflicter.collision (/Users/rfeng/Projects/loopback4/loopback-next/packages/cli/node_modules/yeoman-generator/lib/util/conflicter.js:125:12)

I'm not sure how to fix it at the moment.

BTW, the stream based approach needs to deal with mock-stdin which seems to be a legacy stream implementation which only emits data events instead of readable.

@raymondfeng
Copy link
Contributor Author

raymondfeng commented Jun 29, 2018

I was able to get around the issue using the following code after reading readline source.

/**
 * Read string from stdin
 */
exports.readStringFromStdin = function(log) {
  const stdin = process.stdin;

  if (stdin.isTTY && log) {
    log(
      chalk.green('Please type in a json object ' + '(Press <ctrl>-D to end):'),
    );
  }

  const data = [];
  let len = 0;

  function onData(chunk) {
    data.push(chunk);
    len += chunk.length;
  }

  // Restore stdin
  function close() {
    stdin.removeListener('data', onData);
    stdin.pause(); // Pause the stdin so that other prompts can happen
  }

  return new Promise((resolve, reject) => {
    // Set up `data` handler to make it compatible with mock-stdin which is
    // an old style stream which does not emit `readable` events
    stdin.on('data', onData);

    stdin.once('end', () => {
      const buf = Buffer.concat(data, len);
      close();
      resolve(buf.toString('utf-8'));
    });

    stdin.once('error', err => {
      close();
      reject(err);
    });
  });
};

@bajtos Do you really think this is better/simpler than the current code using readline? Please note inquirer internally uses readline too. IMO, it's probably more robust to keep the current code which leverages a core module from Node.js itself.

@bajtos bajtos mentioned this pull request Jul 2, 2018
@bajtos
Copy link
Member

bajtos commented Jul 2, 2018

BTW, the stream based approach needs to deal with mock-stdin which seems to be a legacy stream implementation which only emits data events instead of readable.

Bummer ☹️

Do you really think this is better/simpler than the current code using readline? Please note inquirer internally uses readline too. IMO, it's probably more robust to keep the current code which leverages a core module from Node.js itself.

Yeah, I agree with you that it's more robust to keep the readline-based implementation then. Thank you for looking under the hood and helping us both to better understand design constraints.

To me, this is another argument for not using yeoman (see #844).

Copy link
Member

@bajtos bajtos left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks mostly good.

Please address my comments below. Feel free to get somebody else (@virkt25?) to give a final approval for this pull request, so that you don't have to wait for me in case I am not around.

if (err) return;
const jsonStr = lines.join('\n');
try {
const json = JSON.parse(jsonStr);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please move handling of the input outside of the code dealing with stdin.

Ideally, I'd like to see something like the following in _readJSONFromStdin:

async _readJSONFromStdin() {
  const input = await readFromStdin();
  try {
    return JSON.parse(input);
  } catch (err) {
    if (!process.stdin.isTTY) {
      debug(err, jsonStr);
    }
    throw err;
  }
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Refactor the function into utils.

this.log(
chalk.red('The stdin is not a terminal. No prompt is allowed.'),
);
process.exit(1);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't exit the process please, it disrupts mocha tests. Call this.exit or throw new Error instead.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

this.log(
chalk.red('The stdin is not a terminal. No prompt is allowed.'),
);
process.exit(1);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditto. Don't exit the process, end the generator execution only.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

@raymondfeng
Copy link
Contributor Author

@bajtos @virkt25 PTAL

if (process.stdin.isTTY && log) {
log(
chalk.green(
'Please type in a json object line by line ' +
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In my view, this message does not belong to readTextFromStdin as readTextFromStdin can be used to read arbitrary text, not only JSON.

I am proposing to move it back to _readJSONFromStdin and also remove the no-longer used log argument.

Copy link
Contributor

@virkt25 virkt25 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apart from the one comment left by @bajtos, the changes look good to me. Excited to see this PR land. :D

- 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
@raymondfeng raymondfeng merged commit 5778a2a into master Jul 8, 2018
@bajtos bajtos deleted the enable-cli-json branch October 4, 2018 06:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants