Skip to content
Merged
75 changes: 75 additions & 0 deletions examples/help-groups.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
const { Command, Option } = require('commander');

// Show the two approaches for adding help groups, and how to customise the built-in help and version.

const program = new Command();
const devOptionsTitle = 'Development Options:';
const managementCommandsTitle = 'Management Commands:';

// The high-level approach is use .optionsGroup() and .commandsGroup() before adding the options/commands.
const docker1 = program
.command('docker1')
.description('help groups created using .optionsGroup() and .commandsGroup()')
.addOption(new Option('-h, --hostname <name>', 'container host name'))
.addOption(new Option('-p, --port <number>', 'container port number'))
.optionsGroup(devOptionsTitle)
.option('-d, --debug', 'add extra trace information')
.option('-w, --watch', 'run and relaunch service on file changes');

docker1
.command('run')
.description('create and run a new container from an image');
docker1.command('exec').description('execute a command in a running container');

docker1.commandsGroup(managementCommandsTitle);
docker1.command('images').description('manage images');
docker1.command('volumes').description('manage volumes');

// The low-level approach is using .helpGroup() on the Option or Command.
const docker2 = program
.command('docker2')
.description('help groups created using .helpGroup()')
.addOption(new Option('-h, --hostname <name>', 'container host name'))
.addOption(new Option('-p, --port <number>', 'container port number'))
.addOption(
new Option('-d, --debug', 'add extra trace information').helpGroup(
devOptionsTitle,
),
)
.addOption(
new Option(
'-w, --watch',
'run and relaunch service on file changes',
).helpGroup(devOptionsTitle),
);

docker2
.command('run')
.description('create and run a new container from an image');
docker2.command('exec').description('execute a command in a running container');

docker2
.command('images')
.description('manage images')
.helpGroup(managementCommandsTitle);
docker2
.command('volumes')
.description('manage volumes')
.helpGroup(managementCommandsTitle);

// Customise group for built-ins by explicitly adding them with default group set.
program
.command('built-in')
.description('help groups for help and version')
.optionsGroup('Built-in Options:')
.version('v2.3.4')
.helpOption('-h, --help')
.commandsGroup('Built-in Commands:')
.helpCommand('help');

program.parse();

// Try the following:
// node help-groups.js help docker1
// node help-groups.js help docker2
// node help-groups.js help built-in
97 changes: 88 additions & 9 deletions lib/command.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ class Command extends EventEmitter {
/** @type {Command} */
this._helpCommand = undefined; // lazy initialised, inherited
this._helpConfiguration = {};
/** @type {string | undefined} */
this._helpGroupTitle = undefined; // soft initialised when added to parent
/** @type {string | undefined} */
this._defaultCommandGroup = undefined;
/** @type {string | undefined} */
this._defaultOptionGroup = undefined;
}

/**
Expand Down Expand Up @@ -403,8 +409,8 @@ class Command extends EventEmitter {
return this;
}

enableOrNameAndArgs = enableOrNameAndArgs ?? 'help [command]';
const [, helpName, helpArgs] = enableOrNameAndArgs.match(/([^ ]+) *(.*)/);
const nameAndArgs = enableOrNameAndArgs ?? 'help [command]';
const [, helpName, helpArgs] = nameAndArgs.match(/([^ ]+) *(.*)/);
const helpDescription = description ?? 'display help for command';

const helpCommand = this.createCommand(helpName);
Expand All @@ -414,6 +420,8 @@ class Command extends EventEmitter {

this._addImplicitHelpCommand = true;
this._helpCommand = helpCommand;
// init group unless lazy create
if (enableOrNameAndArgs || description) this._initCommandGroup(helpCommand);

return this;
}
Expand All @@ -435,6 +443,7 @@ class Command extends EventEmitter {

this._addImplicitHelpCommand = true;
this._helpCommand = helpCommand;
this._initCommandGroup(helpCommand);
return this;
}

Expand Down Expand Up @@ -613,6 +622,7 @@ Expecting one of '${allowedValues.join("', '")}'`);
- already used by option '${matchingOption.flags}'`);
}

this._initOptionGroup(option);
this.options.push(option);
}

Expand Down Expand Up @@ -640,6 +650,7 @@ Expecting one of '${allowedValues.join("', '")}'`);
);
}

this._initCommandGroup(command);
this.commands.push(command);
}

Expand Down Expand Up @@ -2294,6 +2305,72 @@ Expecting one of '${allowedValues.join("', '")}'`);
return this;
}

/**
* Set/get the help group for this subcommand in parent command's help.
*
* @param {string} [str]
* @return {Command | string}
*/

helpGroup(str) {
if (str === undefined) return this._helpGroupTitle ?? '';
this._helpGroupTitle = str;
return this;
}

/**
* Set/get the default help group title for next subcommands added to this command.
*
* @example
* program.commandsGroup('Development Commands:);
* program.command('watch')...
* program.command('lint')...
* ...
*
* @param {string} [title]
* @returns {Command | string}
*/
commandsGroup(title) {
if (title === undefined) return this._defaultCommandGroup ?? '';
this._defaultCommandGroup = title;
return this;
}

/**
* Set/get the default help group title for next options added to this command.
*
* @example
* program.optionsGroup('Development Options:')
* .option('-d, --debug', 'output extra debugging')
* .option('-p, --profile', 'output profiling information')
*
* @param {string} [title]
* @returns {Command | string}
*/
optionsGroup(title) {
if (title === undefined) return this._defaultOptionGroup ?? '';
this._defaultOptionGroup = title;
return this;
}

/**
* @param {Option} option
* @private
*/
_initOptionGroup(option) {
if (this._defaultOptionGroup && !option.helpGroupTitle)
option.helpGroup(this._defaultOptionGroup);
}

/**
* @param {Command} cmd
* @private
*/
_initCommandGroup(cmd) {
if (this._defaultCommandGroup && !cmd.helpGroup())
cmd.helpGroup(this._defaultCommandGroup);
}

/**
* Set the name of the command from script filename, such as process.argv[1],
* or require.main.filename, or __filename.
Expand Down Expand Up @@ -2448,22 +2525,23 @@ Expecting one of '${allowedValues.join("', '")}'`);
*/

helpOption(flags, description) {
// Support disabling built-in help option.
// Support enabling/disabling built-in help option.
if (typeof flags === 'boolean') {
// true is not an expected value. Do something sensible but no unit-test.
// istanbul ignore if
if (flags) {
this._helpOption = this._helpOption ?? undefined; // preserve existing option
if (this._helpOption === null) this._helpOption = undefined;
} else {
this._helpOption = null; // disable
}
return this;
}

// Customise flags and description.
flags = flags ?? '-h, --help';
description = description ?? 'display help for command';
this._helpOption = this.createOption(flags, description);
this._helpOption = this.createOption(
flags ?? '-h, --help',
description ?? 'display help for command',
);
// init group unless lazy create
if (flags || description) this._initOptionGroup(this._helpOption);

return this;
}
Expand Down Expand Up @@ -2492,6 +2570,7 @@ Expecting one of '${allowedValues.join("', '")}'`);
*/
addHelpOption(option) {
this._helpOption = option;
this._initOptionGroup(option);
return this;
}

Expand Down
106 changes: 68 additions & 38 deletions lib/help.js
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,42 @@ class Help {
return argument.description;
}

/**
* @param {string} title
* @param {string[]} items
* @returns string[]
*/
formatItemList(title, items, helper) {
if (items.length === 0) return [];

return [helper.styleTitle(title), ...items, ''];
}

/**
*
* @param {Command[] | Option[]} unsortedItems
* @param {Command[] | Option[]} visibleItems
* @param {Function} getGroup
* @returns {Map<string, Command[] | Option[]>}
*/
groupItems(unsortedItems, visibleItems, getGroup) {
const result = new Map();
// Add groups in order of appearance in unsortedItems.
unsortedItems.forEach((item) => {
const group = getGroup(item);
if (!result.has(group)) result.set(group, []);
});
// Add items in order of appearance in visibleItems.
visibleItems.forEach((item) => {
const group = getGroup(item);
if (!result.has(group)) {
result.set(group, []);
}
result.get(group).push(item);
});
return result;
}

/**
* Generate the built-in help text.
*
Expand Down Expand Up @@ -429,28 +465,25 @@ class Help {
helper.styleArgumentDescription(helper.argumentDescription(argument)),
);
});
if (argumentList.length > 0) {
output = output.concat([
helper.styleTitle('Arguments:'),
...argumentList,
'',
]);
}
output = output.concat(
this.formatItemList('Arguments:', argumentList, helper),
);

// Options
const optionList = helper.visibleOptions(cmd).map((option) => {
return callFormatItem(
helper.styleOptionTerm(helper.optionTerm(option)),
helper.styleOptionDescription(helper.optionDescription(option)),
);
const optionGroups = this.groupItems(
cmd.options,
helper.visibleOptions(cmd),
(option) => option.helpGroupTitle ?? 'Options:',
);
optionGroups.forEach((options, group) => {
const optionList = options.map((option) => {
return callFormatItem(
helper.styleOptionTerm(helper.optionTerm(option)),
helper.styleOptionDescription(helper.optionDescription(option)),
);
});
output = output.concat(this.formatItemList(group, optionList, helper));
});
if (optionList.length > 0) {
output = output.concat([
helper.styleTitle('Options:'),
...optionList,
'',
]);
}

if (helper.showGlobalOptions) {
const globalOptionList = helper
Expand All @@ -461,29 +494,26 @@ class Help {
helper.styleOptionDescription(helper.optionDescription(option)),
);
});
if (globalOptionList.length > 0) {
output = output.concat([
helper.styleTitle('Global Options:'),
...globalOptionList,
'',
]);
}
output = output.concat(
this.formatItemList('Global Options:', globalOptionList, helper),
);
}

// Commands
const commandList = helper.visibleCommands(cmd).map((cmd) => {
return callFormatItem(
helper.styleSubcommandTerm(helper.subcommandTerm(cmd)),
helper.styleSubcommandDescription(helper.subcommandDescription(cmd)),
);
const commandGroups = this.groupItems(
cmd.commands,
helper.visibleCommands(cmd),
(sub) => sub._helpGroupTitle || 'Commands:',
);
commandGroups.forEach((commands, group) => {
const commandList = commands.map((sub) => {
return callFormatItem(
helper.styleSubcommandTerm(helper.subcommandTerm(sub)),
helper.styleSubcommandDescription(helper.subcommandDescription(sub)),
);
});
output = output.concat(this.formatItemList(group, commandList, helper));
});
if (commandList.length > 0) {
output = output.concat([
helper.styleTitle('Commands:'),
...commandList,
'',
]);
}

return output.join('\n');
}
Expand Down
Loading