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
feat: reworking yargs API to make it easier to run in headless environments, e.g., Slack #646
Conversation
var completions = '' | ||
var argv = yargs(['--get-yargs-completions']) | ||
.completion('completion', function (current, argv) { | ||
return new Promise(function (resolve, reject) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Perhaps we should add require('es6-promise').polyfill()
to this file (test/yargs.js
) as well.
if (!result) { | ||
usage.fail(__('Argument check failed: %s', f.toString())) | ||
} else if (typeof result === 'string' || result instanceof Error) { | ||
usage.fail(result.toString()) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If result instanceof Error
, maybe we should pass it as the 2nd argument to usage.fail()
here.
exitFn = function (code) { | ||
if (exiting) return | ||
exiting = true | ||
process.nextTick(function () { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If validation fails, the following will execute (when they wouldn't before) because this is asynchronous:
- a command handler
- any top-level logic that runs after parsing
return argv | ||
// if a custom exit handler has been provided, | ||
// for API consistency always trigger it. | ||
if (exitFn !== process.exit) exitFn(0) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is called before validation of positional args for a command, so the current exiting
check isn't quite sufficient (since, if positional arg validation fails, the custom exit handler would be given a code of 0 instead of 1).
I think an API that's more "transactional", i.e. exchange one input for one output, would be more ideal than registering a custom logger and exit handler. My idea is that the input is a string or array representing a message or args to parse/execute, and the output is an object containing the following (somewhat similar to the way the
@bcoe What do you think? How would you like to proceed? |
…his makes it easier to build an async API around this new API: collect output from the logger., always return your response when exit is called
… have settled on an extension to the parse() function. BREAKING CHANGE: exitProcess(false) now acts as a global setting.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is so awesome! 😄
@bcoe this is so ace 💯 think of all the things people can build!! |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm mainly requesting changes for enhanced docs, the other nits are not really necessary.
Otherwise, I think it'd be cool to register a parse callback only once, as part of chained config, but it's not a huge deal since you need a new yargs instance every time anyway.
var args = Array.prototype.slice.call(arguments) | ||
if (!silent) console.log.apply(console, args) | ||
hasOutput = true | ||
output += args.join(' ') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For consistency with console.log
and console.error
, I think we need to include line feeds too, something like:
if (output.length) output += '\n'
output += args.join(' ')
Same for _logger.error
. Otherwise the spacing in output
is not exactly what would have been output to the console. What do you think?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great point 👍 adding.
// has yargs output an error our help | ||
// message in the current execution context. | ||
self._hasOutput = function () { | ||
return !!hasOutput |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since this is a private variable that is always a boolean, we could get away with just return hasOutput
here.
if (typeof shortCircuit === 'function') { | ||
parseFn = shortCircuit | ||
shortCircuit = null | ||
silent = true |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We could possibly replace silent
checks with self._hasParseCallback()
.
// providing the `fn` argument to `parse()` runs yargs in headless mode, this | ||
// makes it easy to use yargs in contexts other than the CLI, e.g., writing | ||
// a chat-bot. | ||
yargs() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we need to really emphasize the use of require('yargs/yargs')
and constructing/configuring a new yargs()
instance for every call to parse(msg, cb)
. It wasn't clear to me that this was necessary, but testing discovered that it was (due to the interesting way config is reset()
for commands).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Interesting-- this will mean that bot authors will need to make a little factory to make a new yargs object & configure it every time they want to parse some input. This adds a little boilerplate but the docs are clear that you need it, so no problem.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@nexdrew @ceejbot okay; I took a bit more time, and without a huge amount of refactoring managed to get things working such that you can call parse()
multiple times, without creating a new yargs instance each run:
const parser = require('./')
.command('a <cat>', 'a command', function () {}, function (argv) {
console.log('a>', argv)
})
.command('b', 'b command', function (yargs) {
yargs.demand('banana')
}, function (argv) {
console.log('b>', argv)
})
.command('c', 'c command', function (yargs) {
yargs.command('d <coolio>')
}, function (argv) {
console.log('b>', argv)
})
.help()
parser.parse('c d this-worked!', function (err, argv) {
if (err) console.log(err)
else console.log(argv)
})
parser.parse('a --help', function (err, argv, output) {
if (err) console.log(err)
else console.log(output)
})
parser.parse('c help', function (err, argv, output) {
if (err) console.log(err)
console.log(output)
})
I'd still like to eventually refactor some of the state logic; perhaps pulling this into a separate module called context.js
, and try to ensure that all other components are stateless. I feel this can be future work however, and this current iteration is the API we all dream of.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@bcoe "[...] this current iteration is the API we all dream of." Almost. Would it be possible to implement a way to pass in contextual variables that get passed to the command's builder / handler? For the bot I'm implementing, the commands need to know who issued the command (for commands that respond directly to the user) and the chat in which the command was issued (for actually responding). Perhaps there's already a way (at work and don't have time to do anything with the bot atm), so I'm interested to see what ideas you have to solve this problem.
Example:
Current typical command implementation:
// Node packaged modules
import chance from 'chance';
function dice(bot) {
const self = {};
const steamFriends = bot.client.handlers.get('steamFriends');
self.name = 'dice';
self.create = (userId, chatId) => {
return {
'command' : 'dice [roll]',
'description' : 'Roll dice.',
builder(yargs) {
// Some commands have optional 'user' arguments that default to the user who issued the command
yargs.option('roll', { 'default': '1e6', 'type': 'string' });
},
handler(argv) {
const message = chance.rpg(argv.roll).join(' ');
steamFriends.sendChatMessage(chatId, message);
}
};
};
return self;
}
export default dice;
Current chat message event listener implementation:
// Node packaged modules
import yargs from 'yargs/yargs';
// Local modules
import cmd from '../commands/cmd';
function runCommand(bot) {
const self = {};
const steamFriends = bot.client.handlers.get('steamFriends');
function onChatMessage(message, userId, chatId) {
yargs()
.command(cmd.create(userId, chatId)) // Create root command, which creates all subcommands e.g. `dice`
.parse(message);
}
self.name = 'run-command';
self.create = () => {
steamFriends.addListener('chatMessage', onChatMessage);
};
self.destroy = () => {
steamFriends.removeListener('chatMessage', onChatMessage);
};
return self;
}
export default runCommand;
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@scriptdaemon I love this idea, and think it would make things a lot more powerful ... I think I have an idea as to how to add this functionality without too major of an API change. What do you think of this:
yargs
.command('foo')
.parse('foo --bar', {user: 'bcoe}, function (err, argv, output) {
})
where {user: "bcoe"}
would be treated like passing in a configuration file.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's perfect. And if it was optional (allowing yargs.parse('command', function () { ... });) then it shouldn't require an API change at all.
…less API; configure once, all parse N times
* reinstate exitProcess and reset parseFn after it is called * add note about temporarily disabling exitProcess * test edge cases for exitProcess and parseFn * clarify note per @bcoe's review [skip ci] * maybe lowercase anchor would be better [skip ci]
I think this looks great, nice work! For future enhancements, I agree it would be better to refactor state management (of chained configuration and context) into its own submodule, to hopefully clean up all the individual pieces of state that are modified in different places right now. I also think it would be nice to register a single Otherwise, I think this PR certainly makes yargs viable for "single config and multiple parses" functionality desirable in non-CLI use-cases. (Many apologies for being so picky about this and for dragging this out for much longer than you wanted!) Great job! |
@scriptdaemon I implemented your idea, allowing a context configuration object to be passed to |
@bcoe Looks great. Since it works basically the same as |
@scriptdaemon I hadn't thought of this side-effect, but yes that totally works: const yargs = require('./')
const parser = yargs
.command('lunch-train [restaurant]', 'start lunch train', function () {}, function (argv) {
console.log(argv.restaurant, argv.time)
// api.scheduleLunch(argv.restaurant, moment(argv.time))
})
.help()
parser.parse('lunch-train', {restaurant: "Rudy's"}, function (err, argv, output) {
if (output) bot.respond(output)
})``` |
@@ -443,8 +438,8 @@ function Yargs (processArgs, cwd, parentRequire) { | |||
var parsed = parseArgs(args, shortCircuit) | |||
if (parseFn) { | |||
parseFn(exitError, parsed, output) | |||
self.reset() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@bcoe This is a lot more (unnecessary) overhead than just calling resetForNextParse()
, meaning reset()
will be called N+1 times (where N is the number of commands) for yargs.parse(msg, cb)
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
see: #670
@bcoe |
@kevupton yes, we don't actually override Instead of outputting to |
after discussion today, @nexdrew and I settled on what I think is a pretty clever extension to the
parse()
function, as a simple API extension that supports running yargs in headless mode (yargs with no terminal output).A callback
fn
can optionally be provided as the second argument to.parse()
.If a callback is given, it will be invoked with three arguments:
err
: populated if any validation errors raised while parsing.argv
: the parsed argv object.output
: any text that would have been output to the terminal, had acallback not been provided.
Long story short,
parse()
now accepts an optionalfn
, which if provided will:err
,argv
,output
(allowing output to be routed somewhere elsewhere, .e.g., a chat-room).reviewers: @nexdrew, @maxrimue, @lrlna