balena-cli/lib/help.ts

237 lines
6.3 KiB
TypeScript
Raw Normal View History

/**
* @license
* Copyright 2017-2020 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import Help from '@oclif/plugin-help';
import * as indent from 'indent-string';
import { getChalk } from './utils/lazy';
import { renderList } from '@oclif/plugin-help/lib/list';
import { ExpectedError } from './errors';
// Partially overrides standard implementation of help plugin
// https://github.com/oclif/plugin-help/blob/master/src/index.ts
function getHelpSubject(args: string[]): string | undefined {
for (const arg of args) {
if (arg === '--') {
return;
}
if (arg === 'help' || arg === '--help' || arg === '-h') {
continue;
}
if (arg.startsWith('-')) {
return;
}
return arg;
}
}
export default class BalenaHelp extends Help {
public static usage: 'help [command]';
public showHelp(argv: string[]) {
const chalk = getChalk();
const subject = getHelpSubject(argv);
if (!subject) {
const verbose = argv.includes('-v') || argv.includes('--verbose');
console.log(this.getCustomRootHelp(verbose));
return;
}
const command = this.config.findCommand(subject);
if (command) {
this.showCommandHelp(command);
return;
}
// If they've typed a topic (e.g. `balena os`) that isn't also a command (e.g. `balena device`)
// then list the associated commands.
const topicCommands = this.config.commands.filter((c) => {
return c.id.startsWith(`${subject}:`);
});
if (topicCommands.length > 0) {
console.log(`${chalk.yellow(subject)} commands include:`);
console.log(this.formatCommands(topicCommands));
console.log(
`\nRun ${chalk.cyan.bold(
'balena help -v',
)} for a list of all available commands,`,
);
console.log(
` or ${chalk.cyan.bold(
'balena help <command>',
)} for detailed help on a specific command.`,
);
return;
}
throw new ExpectedError(`command ${chalk.cyan.bold(subject)} not found`);
}
getCustomRootHelp(showAllCommands: boolean): string {
const { bold, cyan } = getChalk();
let commands = this.config.commands;
commands = commands.filter((c) => this.opts.all || !c.hidden);
// Get Primary Commands, sorted as in manual list
const primaryCommands = this.manuallySortedPrimaryCommands
.map((pc) => {
return commands.find((c) => c.id === pc.replace(' ', ':'));
})
.filter((c): c is typeof commands[0] => !!c);
let usageLength = 0;
for (const cmd of primaryCommands) {
usageLength = Math.max(usageLength, cmd.usage?.length || 0);
}
let additionalCmdSection: string[];
if (showAllCommands) {
// Get the rest as Additional Commands
const additionalCommands = commands.filter(
(c) =>
!this.manuallySortedPrimaryCommands.includes(c.id.replace(':', ' ')),
);
// Find longest usage, and pad usage of first command in each category
// This is to ensure that both categories align visually
for (const cmd of additionalCommands) {
usageLength = Math.max(usageLength, cmd.usage?.length || 0);
}
if (
typeof primaryCommands[0].usage === 'string' &&
typeof additionalCommands[0].usage === 'string'
) {
primaryCommands[0].usage = primaryCommands[0].usage.padEnd(usageLength);
additionalCommands[0].usage =
additionalCommands[0].usage.padEnd(usageLength);
}
additionalCmdSection = [
bold('\nADDITIONAL COMMANDS'),
this.formatCommands(additionalCommands),
];
} else {
const cmd = cyan.bold('balena help --verbose');
additionalCmdSection = [
`\n${bold('...MORE')} run ${cmd} to list additional commands.`,
];
}
const globalOps = [
['--help, -h', 'display command help'],
['--debug', 'enable debug output'],
[
'--unsupported',
`\
prevent exit with an error as per Deprecation Policy
See: https://git.io/JRHUW#deprecation-policy`,
],
];
globalOps[0][0] = globalOps[0][0].padEnd(usageLength);
const { deprecationPolicyNote, reachingOut } =
require('./utils/messages') as typeof import('./utils/messages');
return [
bold('USAGE'),
'$ balena [COMMAND] [OPTIONS]',
bold('\nPRIMARY COMMANDS'),
this.formatCommands(primaryCommands),
...additionalCmdSection,
bold('\nGLOBAL OPTIONS'),
this.formatGlobalOpts(globalOps),
bold('\nDeprecation Policy Reminder'),
deprecationPolicyNote,
reachingOut,
].join('\n');
}
protected formatGlobalOpts(opts: string[][]) {
const { dim } = getChalk();
const outLines: string[] = [];
let flagWidth = 0;
for (const opt of opts) {
flagWidth = Math.max(flagWidth, opt[0].length);
}
for (const opt of opts) {
const descriptionLines = opt[1].split('\n');
outLines.push(
` ${opt[0].padEnd(flagWidth + 2)}${dim(descriptionLines[0])}`,
);
outLines.push(
...descriptionLines
.slice(1)
.map((line) => ` ${' '.repeat(flagWidth + 2)}${dim(line)}`),
);
}
return outLines.join('\n');
}
protected formatCommands(commands: any[]): string {
if (commands.length === 0) {
return '';
}
const body = renderList(
commands
.filter((c) => c.usage != null && c.usage !== '')
.map((c) => [c.usage, this.formatDescription(c.description)]),
{
spacer: '\n',
stripAnsi: this.opts.stripAnsi,
maxWidth: this.opts.maxWidth - 2,
},
);
return indent(body, 2);
}
protected formatDescription(desc: string = '') {
const chalk = getChalk();
desc = desc.split('\n')[0];
// Remove any ending .
if (desc[desc.length - 1] === '.') {
desc = desc.substring(0, desc.length - 1);
}
// Lowercase first letter if second char is lowercase, to preserve e.g. 'SSH ...')
if (desc[1] === desc[1]?.toLowerCase()) {
desc = `${desc[0].toLowerCase()}${desc.substring(1)}`;
}
return chalk.grey(desc);
}
readonly manuallySortedPrimaryCommands = [
'login',
'push',
'logs',
'ssh',
'fleets',
'fleet',
'devices',
'device',
'tunnel',
'preload',
'build',
'deploy',
'join',
'leave',
'scan',
];
}