Skip to content

Commit

Permalink
Add hooks for life cycle events: using .hook() (#1514)
Browse files Browse the repository at this point in the history
* First cut at action hooks

* First cut at promise support in hooks

* Move static routines out of class

* Add error for unexpport event type

* Add typings

* Add typings

* Support multiple hooks on same command

* Add some synchronous tests

* Add async tests

* Add tests that options and args are available from context in hooks

* Expand test names

* Spell life cycle as two words

* Spell life cycle as two words

* Fix typo in JSDoc

* Add example for hooks

* Make comment more accurate

* First cut at README

* Small README improvements

* Add note about async and multiple hooks

* Rename hooks and pass parameters rather than context hash

* Make example scan better

* Fix stale comment
  • Loading branch information
shadowspawn committed May 18, 2021
1 parent 6c06528 commit c20284d
Show file tree
Hide file tree
Showing 7 changed files with 525 additions and 10 deletions.
28 changes: 28 additions & 0 deletions Readme.md
Expand Up @@ -26,6 +26,7 @@ Read this in other languages: English | [简体中文](./Readme_zh-CN.md)
- [Custom argument processing](#custom-argument-processing)
- [Action handler](#action-handler)
- [Stand-alone executable (sub)commands](#stand-alone-executable-subcommands)
- [Life cycle hooks](#life-cycle-hooks)
- [Automated help](#automated-help)
- [Custom help](#custom-help)
- [Display help from code](#display-help-from-code)
Expand Down Expand Up @@ -557,6 +558,33 @@ program.parse(process.argv);
If the program is designed to be installed globally, make sure the executables have proper modes, like `755`.
### Life cycle hooks
You can add callback hooks to a command for life cycle events.
Example file: [hook.js](./examples/hook.js)
```js
program
.option('-t, --trace', 'display trace statements for commands')
.hook('preAction', (thisCommand, actionCommand) => {
if (thisCommand.opts().trace) {
console.log(`About to call action handler for subcommand: ${actionCommand.name()}`);
console.log('arguments: %O', actionCommand.args);
console.log('options: %o', actionCommand.opts());
}
});
```
The callback hook can be `async`, in which case you call `.parseAsync` rather than `.parse`. You can add multiple hooks per event.
The supported events are:
- `preAction`: called before action handler for this command and its subcommands
- `postAction`: called after action handler for this command and its subcommands
The hook is passed the command it was added to, and the command running the action handler.
## Automated help
The help information is auto-generated based on the information commander already knows about your program. The default
Expand Down
56 changes: 56 additions & 0 deletions examples/hook.js
@@ -0,0 +1,56 @@
#!/usr/bin/env node

// const commander = require('commander'); // (normal include)
const commander = require('../'); // include commander in git clone of commander repo
const program = new commander.Command();

// This example shows using some hooks for life cycle events.

const timeLabel = 'command duration';
program
.option('-p, --profile', 'show how long command takes')
.hook('preAction', (thisCommand) => {
if (thisCommand.opts().profile) {
console.time(timeLabel);
}
})
.hook('postAction', (thisCommand) => {
if (thisCommand.opts().profile) {
console.timeEnd(timeLabel);
}
});

program
.option('-t, --trace', 'display trace statements for commands')
.hook('preAction', (thisCommand, actionCommand) => {
if (thisCommand.opts().trace) {
console.log('>>>>');
console.log(`About to call action handler for subcommand: ${actionCommand.name()}`);
console.log('arguments: %O', actionCommand.args);
console.log('options: %o', actionCommand.opts());
console.log('<<<<');
}
});

program.command('delay')
.option('--message <value>', 'custom message to display', 'Thanks for waiting')
.argument('[seconds]', 'how long to delay', '1')
.action(async(waitSeconds, options) => {
await new Promise(resolve => setTimeout(resolve, parseInt(waitSeconds) * 1000));
console.log(options.message);
});

program.command('hello')
.option('-e, --example')
.action(() => console.log('Hello, world'));

// Some of the hooks or actions are async, so call parseAsync rather than parse.
program.parseAsync().then(() => {});

// Try the following:
// node hook.js hello
// node hook.js --profile hello
// node hook.js --trace hello --example
// node hook.js delay
// node hook.js --trace delay 5 --message bye
// node hook.js --profile delay
92 changes: 91 additions & 1 deletion index.js
Expand Up @@ -678,6 +678,7 @@ class Command extends EventEmitter {
this._argsDescription = undefined; // legacy
this._enablePositionalOptions = false;
this._passThroughOptions = false;
this._lifeCycleHooks = {}; // a hash of arrays

// see .configureOutput() for docs
this._outputConfiguration = {
Expand Down Expand Up @@ -988,6 +989,28 @@ class Command extends EventEmitter {
return this._addImplicitHelpCommand;
};

/**
* Add hook for life cycle event.
*
* @param {string} event
* @param {Function} listener
* @return {Command} `this` command for chaining
*/

hook(event, listener) {
const allowedValues = ['preAction', 'postAction'];
if (!allowedValues.includes(event)) {
throw new Error(`Unexpected value for event passed to hook : '${event}'.
Expecting one of '${allowedValues.join("', '")}'`);
}
if (this._lifeCycleHooks[event]) {
this._lifeCycleHooks[event].push(listener);
} else {
this._lifeCycleHooks[event] = [listener];
}
return this;
}

/**
* Register callback to use as replacement for calling process.exit.
*
Expand Down Expand Up @@ -1640,6 +1663,55 @@ class Command extends EventEmitter {
return actionArgs;
}

/**
* Once we have a promise we chain, but call synchronously until then.
*
* @param {Promise|undefined} promise
* @param {Function} fn
* @return {Promise|undefined}
*/

_chainOrCall(promise, fn) {
// thenable
if (promise && promise.then && typeof promise.then === 'function') {
// already have a promise, chain callback
return promise.then(() => fn());
}
// callback might return a promise
return fn();
}

/**
*
* @param {Promise|undefined} promise
* @param {string} event
* @return {Promise|undefined}
* @api private
*/

_chainOrCallHooks(promise, event) {
let result = promise;
const hooks = [];
getCommandAndParents(this)
.reverse()
.filter(cmd => cmd._lifeCycleHooks[event] !== undefined)
.forEach(hookedCommand => {
hookedCommand._lifeCycleHooks[event].forEach((callback) => {
hooks.push({ hookedCommand, callback });
});
});
if (event === 'postAction') {
hooks.reverse();
}

hooks.forEach((hookDetail) => {
result = this._chainOrCall(result, () => {
return hookDetail.callback(hookDetail.hookedCommand, this);
});
});
return result;
}

/**
* Process arguments in context of this command.
* Returns action result, in case it is a promise.
Expand Down Expand Up @@ -1700,8 +1772,12 @@ class Command extends EventEmitter {
if (this._actionHandler) {
checkForUnknownOptions();
checkNumberOfArguments();
const actionResult = this._actionHandler(this._getActionArguments());

let actionResult;
actionResult = this._chainOrCallHooks(actionResult, 'preAction');
actionResult = this._chainOrCall(actionResult, () => this._actionHandler(this._getActionArguments()));
if (this.parent) this.parent.emit(commandEvent, operands, unknown); // legacy
actionResult = this._chainOrCallHooks(actionResult, 'postAction');
return actionResult;
}
if (this.parent && this.parent.listenerCount(commandEvent)) {
Expand Down Expand Up @@ -2424,3 +2500,17 @@ function incrementNodeInspectorPort(args) {
return arg;
});
}

/**
* @param {Command} startCommand
* @returns {Command[]}
* @api private
*/

function getCommandAndParents(startCommand) {
const result = [];
for (let command = startCommand; command; command = command.parent) {
result.push(command);
}
return result;
}
6 changes: 6 additions & 0 deletions tests/command.chain.test.js
Expand Up @@ -166,6 +166,12 @@ describe('Command methods that should return this for chaining', () => {
expect(result).toBe(program);
});

test('when call .hook() then returns this', () => {
const program = new Command();
const result = program.hook('preAction', () => {});
expect(result).toBe(program);
});

test('when call .setOptionValue() then returns this', () => {
const program = new Command();
const result = program.setOptionValue();
Expand Down

0 comments on commit c20284d

Please sign in to comment.