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

Add hooks for life cycle events: using .hook() #1514

Merged
merged 26 commits into from May 18, 2021
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
3963c6c
First cut at action hooks
shadowspawn May 7, 2021
3bd17db
First cut at promise support in hooks
shadowspawn May 7, 2021
30b4175
Move static routines out of class
shadowspawn May 7, 2021
6723c50
Add error for unexpport event type
shadowspawn May 7, 2021
ed7207d
Add typings
shadowspawn May 7, 2021
4f73ad4
Add typings
shadowspawn May 7, 2021
814b2a3
Merge branch 'feature/hook' of github.com:shadowspawn/commander.js in…
shadowspawn May 7, 2021
ce73dff
Support multiple hooks on same command
shadowspawn May 8, 2021
bc7cc77
Add some synchronous tests
shadowspawn May 8, 2021
014b7fe
Add async tests
shadowspawn May 8, 2021
d3d6169
Add tests that options and args are available from context in hooks
shadowspawn May 8, 2021
812d7fa
Expand test names
shadowspawn May 8, 2021
ec673d3
Spell life cycle as two words
shadowspawn May 8, 2021
39193b7
Spell life cycle as two words
shadowspawn May 8, 2021
8fc6134
Fix typo in JSDoc
shadowspawn May 8, 2021
cefaa62
Add example for hooks
shadowspawn May 8, 2021
5b7349d
Make comment more accurate
shadowspawn May 8, 2021
712697e
First cut at README
shadowspawn May 9, 2021
4757b94
Small README improvements
shadowspawn May 9, 2021
f2f9390
Add note about async and multiple hooks
shadowspawn May 9, 2021
2554f78
Merge remote-tracking branch 'upstream/release/8.x' into feature/hook
shadowspawn May 10, 2021
01197a8
Rename hooks and pass parameters rather than context hash
shadowspawn May 16, 2021
6b75268
Make example scan better
shadowspawn May 16, 2021
28c3156
Merge branch 'release/8.x' into feature/hook
shadowspawn May 17, 2021
f751962
Fix stale comment
shadowspawn May 17, 2021
710e62f
Merge remote-tracking branch 'upstream/release/8.x' into feature/hook
shadowspawn May 18, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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 @@ -554,6 +555,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 @@ -165,4 +165,10 @@ describe('Command methods that should return this for chaining', () => {
const result = program.enablePositionalOptions();
expect(result).toBe(program);
});

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