diff --git a/Readme.md b/Readme.md index f4e991325..1b43a9613 100644 --- a/Readme.md +++ b/Readme.md @@ -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) @@ -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 diff --git a/examples/hook.js b/examples/hook.js new file mode 100644 index 000000000..00aa10e9e --- /dev/null +++ b/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 ', '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 diff --git a/index.js b/index.js index 862358cda..3294e9f2b 100644 --- a/index.js +++ b/index.js @@ -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 = { @@ -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. * @@ -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. @@ -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)) { @@ -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; +} diff --git a/tests/command.chain.test.js b/tests/command.chain.test.js index 9cfe15158..43f463e15 100644 --- a/tests/command.chain.test.js +++ b/tests/command.chain.test.js @@ -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(); diff --git a/tests/command.hook.test.js b/tests/command.hook.test.js new file mode 100644 index 000000000..93033107c --- /dev/null +++ b/tests/command.hook.test.js @@ -0,0 +1,323 @@ +const commander = require('../'); + +test('when hook event wrong then throw', () => { + const program = new commander.Command(); + expect(() => { + program.hook('silly', () => {}); + }).toThrow(); +}); + +test('when no action then action hooks not called', () => { + const hook = jest.fn(); + const program = new commander.Command(); + program + .hook('preAction', hook) + .hook('postAction', hook); + program.parse([], { from: 'user' }); + expect(hook).not.toHaveBeenCalled(); +}); + +describe('action hooks with synchronous hooks, order', () => { + test('when hook preAction then hook called before action', () => { + const calls = []; + const program = new commander.Command(); + program + .hook('preAction', () => calls.push('before')) + .action(() => calls.push('action')); + program.parse([], { from: 'user' }); + expect(calls).toEqual(['before', 'action']); + }); + + test('when hook postAction then hook called after action', () => { + const calls = []; + const program = new commander.Command(); + program + .hook('postAction', () => calls.push('after')) + .action(() => calls.push('action')); + program.parse([], { from: 'user' }); + expect(calls).toEqual(['action', 'after']); + }); + + test('when hook preAction twice then hooks called FIFO', () => { + const calls = []; + const program = new commander.Command(); + program + .hook('preAction', () => calls.push('1')) + .hook('preAction', () => calls.push('2')) + .action(() => calls.push('action')); + program.parse([], { from: 'user' }); + expect(calls).toEqual(['1', '2', 'action']); + }); + + test('when hook postAction twice then hooks called LIFO', () => { + const calls = []; + const program = new commander.Command(); + program + .hook('postAction', () => calls.push('1')) + .hook('postAction', () => calls.push('2')) + .action(() => calls.push('action')); + program.parse([], { from: 'user' }); + expect(calls).toEqual(['action', '2', '1']); + }); + + test('when hook preAction at program and sub then hooks called program then sub', () => { + const calls = []; + const program = new commander.Command(); + program + .hook('preAction', () => calls.push('program')); + program.command('sub') + .hook('preAction', () => calls.push('sub')) + .action(() => calls.push('action')); + program.parse(['sub'], { from: 'user' }); + expect(calls).toEqual(['program', 'sub', 'action']); + }); + + test('when hook postAction at program and sub then hooks called sub then program', () => { + const calls = []; + const program = new commander.Command(); + program + .hook('postAction', () => calls.push('program')); + program.command('sub') + .hook('postAction', () => calls.push('sub')) + .action(() => calls.push('action')); + program.parse(['sub'], { from: 'user' }); + expect(calls).toEqual(['action', 'sub', 'program']); + }); + + test('when hook everything then hooks called nested', () => { + const calls = []; + const program = new commander.Command(); + program + .hook('preAction', () => calls.push('pb1')) + .hook('postAction', () => calls.push('pa1')); + program + .hook('preAction', () => calls.push('pb2')) + .hook('postAction', () => calls.push('pa2')); + program.command('sub') + .hook('preAction', () => calls.push('sb')) + .hook('postAction', () => calls.push('sa')) + .action(() => calls.push('action')); + program.parse(['sub'], { from: 'user' }); + expect(calls).toEqual(['pb1', 'pb2', 'sb', 'action', 'sa', 'pa2', 'pa1']); + }); +}); + +describe('action hooks context', () => { + test('when hook on program then passed program/program', () => { + const hook = jest.fn(); + const program = new commander.Command(); + program + .hook('preAction', hook) + .action(() => {}); + program.parse([], { from: 'user' }); + expect(hook).toHaveBeenCalledWith(program, program); + }); + + test('when hook on program and call sub then passed program/sub', () => { + const hook = jest.fn(); + const program = new commander.Command(); + program + .hook('preAction', hook); + const sub = program.command('sub') + .action(() => {}); + program.parse(['sub'], { from: 'user' }); + expect(hook).toHaveBeenCalledWith(program, sub); + }); + + test('when hook on sub and call sub then passed sub/sub', () => { + const hook = jest.fn(); + const program = new commander.Command(); + const sub = program.command('sub') + .hook('preAction', hook) + .action(() => {}); + program.parse(['sub'], { from: 'user' }); + expect(hook).toHaveBeenCalledWith(sub, sub); + }); + + test('when hook program on preAction then thisCommand has options set', () => { + expect.assertions(1); + const program = new commander.Command(); + program + .option('--debug') + .hook('preAction', (thisCommand) => { + expect(thisCommand.opts().debug).toEqual(true); + }) + .action(() => {}); + program.parse(['--debug'], { from: 'user' }); + }); + + test('when hook program on preAction and call sub then thisCommand has program options set', () => { + expect.assertions(1); + const program = new commander.Command(); + program + .option('--debug') + .hook('preAction', (thisCommand) => { + expect(thisCommand.opts().debug).toEqual(true); + }); + program.command('sub') + .action(() => {}); + program.parse(['sub', '--debug'], { from: 'user' }); + }); + + test('when hook program on preAction and call sub then actionCommand has sub options set', () => { + expect.assertions(1); + const program = new commander.Command(); + program + .hook('preAction', (thisCommand, actionCommand) => { + expect(actionCommand.opts().debug).toEqual(true); + }); + program.command('sub') + .option('--debug') + .action(() => {}); + program.parse(['sub', '--debug'], { from: 'user' }); + }); + + test('when hook program on preAction then actionCommand has args set', () => { + expect.assertions(1); + const program = new commander.Command(); + program + .argument('[arg]') + .hook('preAction', (thisCommand, actionCommand) => { + expect(actionCommand.args).toEqual(['value']); + }) + .action(() => {}); + program.parse(['value'], { from: 'user' }); + }); + + test('when hook program on preAction then actionCommand has args set with options removed', () => { + expect.assertions(1); + const program = new commander.Command(); + program + .argument('[arg]') + .option('--debug') + .hook('preAction', (thisCommand, actionCommand) => { + expect(actionCommand.args).toEqual(['value']); + }) + .action(() => {}); + program.parse(['value', '--debug'], { from: 'user' }); + }); + + test('when hook program on preAction and call sub then thisCommand has program args set', () => { + expect.assertions(1); + const program = new commander.Command(); + program + .argument('[arg]') + .hook('preAction', (thisCommand) => { + expect(thisCommand.args).toEqual(['sub', 'value']); + }); + program.command('sub') + .action(() => {}); + program.parse(['sub', 'value'], { from: 'user' }); + }); + + test('when hook program on preAction and call sub then actionCommand has sub args set', () => { + expect.assertions(1); + const program = new commander.Command(); + program + .hook('preAction', (thisCommand, actionCommand) => { + expect(actionCommand.args).toEqual(['value']); + }); + program.command('sub') + .action(() => {}); + program.parse(['sub', 'value'], { from: 'user' }); + }); +}); + +describe('action hooks async', () => { + test('when async preAction then async from preAction', async() => { + const calls = []; + const program = new commander.Command(); + program + .hook('preAction', async() => { + await 0; + calls.push('before'); + }) + .action(() => calls.push('action')); + const result = program.parseAsync([], { from: 'user' }); + expect(calls).toEqual([]); + await result; + expect(calls).toEqual(['before', 'action']); + }); + + test('when async postAction then async from postAction', async() => { + const calls = []; + const program = new commander.Command(); + program + .hook('postAction', async() => { + await 0; + calls.push('after'); + }) + .action(() => calls.push('action')); + const result = program.parseAsync([], { from: 'user' }); + expect(calls).toEqual(['action']); + await result; + expect(calls).toEqual(['action', 'after']); + }); + + test('when async action then async from action', async() => { + const calls = []; + const program = new commander.Command(); + program + .hook('preAction', () => calls.push('before')) + .hook('postAction', () => calls.push('after')) + .action(async() => { + await 0; + calls.push('action'); + }); + const result = program.parseAsync([], { from: 'user' }); + expect(calls).toEqual(['before']); + await result; + expect(calls).toEqual(['before', 'action', 'after']); + }); + + test('when async first preAction then async from first preAction', async() => { + const calls = []; + const program = new commander.Command(); + program + .hook('preAction', async() => { + await 0; + calls.push('1'); + }) + .hook('preAction', () => calls.push('2')) + .action(() => calls.push('action')); + const result = program.parseAsync([], { from: 'user' }); + expect(calls).toEqual([]); + await result; + expect(calls).toEqual(['1', '2', 'action']); + }); + + test('when async second preAction then async from second preAction', async() => { + const calls = []; + const program = new commander.Command(); + program + .hook('preAction', () => calls.push('1')) + .hook('preAction', async() => { + await 0; + calls.push('2'); + }) + .action(() => calls.push('action')); + const result = program.parseAsync([], { from: 'user' }); + expect(calls).toEqual(['1']); + await result; + expect(calls).toEqual(['1', '2', 'action']); + }); + + test('when async hook everything then hooks called nested', async() => { + const calls = []; + const program = new commander.Command(); + program + .hook('preAction', async() => { await 0; calls.push('pb1'); }) + .hook('postAction', async() => { await 0; calls.push('pa1'); }); + program + .hook('preAction', async() => { await 0; calls.push('pb2'); }) + .hook('postAction', async() => { await 0; calls.push('pa2'); }); + program.command('sub') + .hook('preAction', async() => { await 0; calls.push('sb'); }) + .hook('postAction', async() => { await 0; calls.push('sa'); }) + .action(async() => { await 0; calls.push('action'); }); + const result = program.parseAsync(['sub'], { from: 'user' }); + expect(calls).toEqual([]); + await result; + expect(calls).toEqual(['pb1', 'pb2', 'sb', 'action', 'sa', 'pa2', 'pa1']); + }); +}); diff --git a/typings/index.d.ts b/typings/index.d.ts index d0f2c7d50..abeab2be9 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -193,6 +193,7 @@ export interface OutputConfiguration { } type AddHelpTextPosition = 'beforeAll' | 'before' | 'after' | 'afterAll'; +type HookEvent = 'preAction' | 'postAction'; export interface OptionValues { [key: string]: any; @@ -328,6 +329,11 @@ export class Command { */ addHelpCommand(enableOrNameAndArgs?: string | boolean, description?: string): this; + /** + * Add hook for life cycle event. + */ + hook(event: HookEvent, listener: (thisCommand: Command, actionCommand: Command) => void | Promise): this; + /** * Register callback to use as replacement for calling process.exit. */ diff --git a/typings/index.test-d.ts b/typings/index.test-d.ts index 0e2a389a1..a5edb6cf4 100644 --- a/typings/index.test-d.ts +++ b/typings/index.test-d.ts @@ -3,6 +3,8 @@ import {expectType} from 'tsd'; // We are are not just checking return types here, we are also implicitly checking that the expected syntax is allowed. +/* eslint-disable @typescript-eslint/no-empty-function */ + const program: commander.Command = new commander.Command(); // @ts-expect-error Check that Command is strongly typed and does not allow arbitrary properties program.silly; // <-- Error, hurrah! @@ -70,14 +72,20 @@ expectType(program.exitOverride((err): void => { } })); -// action -expectType(program.action(() => { - // do nothing. -})); -expectType(program.action(async() => { - // do nothing. +// hook +expectType(program.hook('preAction', () => {})); +expectType(program.hook('postAction', () => {})); +expectType(program.hook('preAction', async() => {})); +expectType(program.hook('preAction', (thisCommand, actionCommand) => { + // implicit parameter types + expectType(thisCommand); + expectType(actionCommand); })); +// action +expectType(program.action(() => {})); +expectType(program.action(async() => {})); + // option expectType(program.option('-a,--alpha')); expectType(program.option('-p, --peppers', 'Add peppers')); @@ -240,9 +248,7 @@ expectType(program.addHelpText('beforeAll', (context) => { })); // on -expectType(program.on('command:foo', () => { - // do nothing. -})); +expectType(program.on('command:foo', () => {})); // createCommand expectType(program.createCommand());