Convert help to oclif, remove capitano

Change-type: patch
Signed-off-by: Scott Lowe <scott@balena.io>
This commit is contained in:
Scott Lowe 2020-09-04 16:34:34 +02:00
parent cd81ff005f
commit d56fec6e36
26 changed files with 392 additions and 712 deletions

View File

@ -88,7 +88,7 @@ const capitanoDoc = {
},
{
title: 'Help and Version',
files: ['build/actions/help.js', 'build/actions-oclif/version.js'],
files: ['help', 'build/actions-oclif/version.js'],
},
{
title: 'Keys',

View File

@ -15,7 +15,6 @@
* limitations under the License.
*/
import { Command as OclifCommandClass } from '@oclif/command';
import { CommandDefinition as CapitanoCommand } from 'capitano';
type OclifCommand = typeof OclifCommandClass;
@ -27,7 +26,7 @@ export interface Document {
export interface Category {
title: string;
commands: Array<CapitanoCommand | OclifCommand>;
commands: OclifCommand[];
}
export { CapitanoCommand, OclifCommand };
export { OclifCommand };

View File

@ -14,12 +14,11 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as _ from 'lodash';
import * as path from 'path';
import { getCapitanoDoc } from './capitanodoc';
import { CapitanoCommand, Category, Document, OclifCommand } from './doc-types';
import { Category, Document, OclifCommand } from './doc-types';
import * as markdown from './markdown';
import { stripIndent } from '../../lib/utils/lazy';
/**
* Generates the markdown document (as a string) for the CLI documentation
@ -40,11 +39,7 @@ export async function renderMarkdown(): Promise<string> {
};
for (const jsFilename of commandCategory.files) {
category.commands.push(
...(jsFilename.includes('actions-oclif')
? importOclifCommands(jsFilename)
: importCapitanoCommands(jsFilename)),
);
category.commands.push(...importOclifCommands(jsFilename));
}
result.categories.push(category);
}
@ -52,27 +47,48 @@ export async function renderMarkdown(): Promise<string> {
return markdown.render(result);
}
function importCapitanoCommands(jsFilename: string): CapitanoCommand[] {
const actions = require(path.join(process.cwd(), jsFilename));
const commands: CapitanoCommand[] = [];
// Help is now managed via a plugin
// This fake command allows capitanodoc to include help in docs
class FakeHelpCommand {
description = stripIndent`
List balena commands, or get detailed help for an specific command.
if (actions.signature) {
commands.push(_.omit(actions, 'action') as any);
} else {
for (const actionName of Object.keys(actions)) {
const actionCommand = actions[actionName];
commands.push(_.omit(actionCommand, 'action') as any);
}
}
return commands;
List balena commands, or get detailed help for an specific command.
`;
examples = [
'$ balena help',
'$ balena help apps',
'$ balena help os download',
];
args = [
{
name: 'command',
description: 'command to show help for',
},
];
usage = 'help [command]';
flags = {
verbose: {
description: 'show additional commands',
char: '-v',
},
};
}
function importOclifCommands(jsFilename: string): OclifCommand[] {
// TODO: Currently oclif commands with no `usage` overridden will cause
// an error when parsed. This should be improved so that `usage` does not have
// to be overridden if not necessary.
const command: OclifCommand = require(path.join(process.cwd(), jsFilename))
.default as OclifCommand;
const command: OclifCommand =
jsFilename === 'help'
? ((new FakeHelpCommand() as unknown) as OclifCommand)
: (require(path.join(process.cwd(), jsFilename)).default as OclifCommand);
return [command];
}

View File

@ -20,33 +20,10 @@ import * as _ from 'lodash';
import { getManualSortCompareFunction } from '../../lib/utils/helpers';
import { capitanoizeOclifUsage } from '../../lib/utils/oclif-utils';
import { CapitanoCommand, Category, Document, OclifCommand } from './doc-types';
import * as utils from './utils';
function renderCapitanoCommand(command: CapitanoCommand): string[] {
const result = [`## ${ent.encode(command.signature)}`, command.help!];
if (!_.isEmpty(command.options)) {
result.push('### Options');
for (const option of command.options!) {
if (option == null) {
throw new Error(`Undefined option in markdown generation!`);
}
if (option.description == null) {
throw new Error(`Undefined option.description in markdown generation!`);
}
result.push(
`#### ${utils.parseCapitanoOption(option)}`,
option.description,
);
}
}
return result;
}
import { Category, Document, OclifCommand } from './doc-types';
function renderOclifCommand(command: OclifCommand): string[] {
const result = [`## ${ent.encode(command.usage)}`];
const result = [`## ${ent.encode(command.usage || '')}`];
const description = (command.description || '')
.split('\n')
.slice(1) // remove the first line, which oclif uses as help header
@ -86,11 +63,7 @@ function renderOclifCommand(command: OclifCommand): string[] {
function renderCategory(category: Category): string[] {
const result = [`# ${category.title}`];
for (const command of category.commands) {
result.push(
...(typeof command === 'object'
? renderCapitanoCommand(command)
: renderOclifCommand(command)),
);
result.push(...renderOclifCommand(command));
}
return result;
}
@ -107,10 +80,7 @@ function renderToc(categories: Category[]): string[] {
result.push(
category.commands
.map((command) => {
const signature =
typeof command === 'object'
? command.signature // Capitano
: capitanoizeOclifUsage(command.usage); // oclif
const signature = capitanoizeOclifUsage(command.usage);
return `\t- [${ent.encode(signature)}](${getAnchor(signature)})`;
})
.join('\n'),
@ -134,12 +104,10 @@ function sortCommands(doc: Document): void {
for (const category of doc.categories) {
if (category.title in manualCategorySorting) {
category.commands = category.commands.sort(
getManualSortCompareFunction<CapitanoCommand | OclifCommand, string>(
getManualSortCompareFunction<OclifCommand, string>(
manualCategorySorting[category.title],
(cmd: CapitanoCommand | OclifCommand, x: string) =>
typeof cmd === 'object' // Capitano vs oclif command
? cmd.signature.replace(/\W+/g, ' ').includes(x)
: (cmd.usage || '').toString().replace(/\W+/g, ' ').includes(x),
(cmd: OclifCommand, x: string) =>
(cmd.usage || '').toString().replace(/\W+/g, ' ').includes(x),
),
);
}

View File

@ -186,7 +186,7 @@ Users are encouraged to regularly update the balena CLI to the latest version.
- [envs](#envs)
- [env rm &#60;id&#62;](#env-rm-id)
- [env add &#60;name&#62; [value]](#env-add-name-value)
- [env rename &#60;id&#62; &#60;value&#62;](#env-rename-id-value)
- [env rename &#60;name&#62; &#60;value&#62;](#env-rename-name-value)
- Tags
@ -196,7 +196,7 @@ Users are encouraged to regularly update the balena CLI to the latest version.
- Help and Version
- [help [command...]](#help-command)
- [help [command]](#help-command)
- [version](#version)
- Keys
@ -213,7 +213,7 @@ Users are encouraged to regularly update the balena CLI to the latest version.
- Network
- [scan](#scan)
- [ssh &#60;applicationordevice&#62; [servicename]](#ssh-applicationordevice-servicename)
- [ssh &#60;applicationordevice&#62; [service]](#ssh-applicationordevice-service)
- [tunnel &#60;deviceorapplication&#62;](#tunnel-deviceorapplication)
- Notes
@ -901,7 +901,7 @@ produce verbose output
service name
## env rm ID
## env rm &#60;id&#62;
Remove a configuration or environment variable from an application, device
or service, as selected by command-line options.
@ -968,7 +968,7 @@ select a service variable (may be used together with the --device option)
do not prompt for confirmation before deleting the variable
## env add NAME [VALUE]
## env add &#60;name&#62; [value]
Add an environment or config variable to one or more applications, devices
or services, as selected by the respective command-line options. Either the
@ -1034,7 +1034,7 @@ suppress warning messages
service name
## env rename ID VALUE
## env rename &#60;name&#62; &#60;value&#62;
Change the value of a configuration or environment variable for an application,
device or service, as selected by command-line options.
@ -1212,18 +1212,25 @@ same as '--application'
# Help and Version
## help [command...]
## help [command]
Get detailed help for an specific command.
List balena commands, or get detailed help for an specific command.
Examples:
$ balena help
$ balena help apps
$ balena help os download
### Arguments
#### COMMAND
command to show help for
### Options
#### --verbose, -v
#### --v, --verbose
show additional commands
@ -1236,11 +1243,15 @@ because the JSON format is less likely to change and it better represents
data types like lists and empty strings. The 'jq' utility may be helpful
in shell scripts (https://stedolan.github.io/jq/manual/).
This command can also be invoked with 'balena --version' or 'balena -v'.
Examples:
$ balena version
$ balena version -a
$ balena version -j
$ balena --version
$ balena -v
### Options
@ -1401,7 +1412,7 @@ display full info
scan timeout in seconds
## ssh &#60;applicationOrDevice&#62; [serviceName]
## ssh &#60;applicationOrDevice&#62; [service]
Start a shell on a local or remote device. If a service name is not provided,
a shell will be opened on the host OS.
@ -1443,7 +1454,7 @@ Examples:
application name, device uuid, or address of local device
#### SERVICENAME
#### SERVICE
service name, if connecting to a container
@ -1632,7 +1643,7 @@ show advanced configuration options
path to output JSON file
## os configure IMAGE
## os configure &#60;image&#62;
Configure a previously downloaded balenaOS image for a specific device type or
balena application.

View File

@ -22,7 +22,6 @@ import Command from '../../command';
import { ExpectedError } from '../../errors';
import * as cf from '../../utils/common-flags';
import { getBalenaSdk, stripIndent } from '../../utils/lazy';
import { CommandHelp } from '../../utils/oclif-utils';
interface FlagsDef {
application?: string; // application name
@ -91,9 +90,7 @@ export default class EnvAddCmd extends Command {
},
];
// hardcoded 'env add' to avoid oclif's 'env:add' topic syntax
public static usage =
'env add ' + new CommandHelp({ args: EnvAddCmd.args }).defaultUsage();
public static usage = 'env add <name> [value]';
public static flags: flags.Input<FlagsDef> = {
application: { exclusive: ['device'], ...cf.application },

View File

@ -20,7 +20,6 @@ import Command from '../../command';
import * as cf from '../../utils/common-flags';
import * as ec from '../../utils/env-common';
import { getBalenaSdk, stripIndent } from '../../utils/lazy';
import { CommandHelp } from '../../utils/oclif-utils';
import { parseAsInteger } from '../../utils/validation';
type IArg<T> = import('@oclif/parser').args.IArg<T>;
@ -70,9 +69,7 @@ export default class EnvRenameCmd extends Command {
},
];
// hardcoded 'env rename' to avoid oclif's 'env:rename' topic syntax
public static usage =
'env rename ' + new CommandHelp({ args: EnvRenameCmd.args }).defaultUsage();
public static usage = 'env rename <name> <value>';
public static flags: flags.Input<FlagsDef> = {
config: ec.booleanConfig,

View File

@ -20,7 +20,6 @@ import Command from '../../command';
import * as ec from '../../utils/env-common';
import { getBalenaSdk, stripIndent } from '../../utils/lazy';
import { CommandHelp } from '../../utils/oclif-utils';
import { parseAsInteger } from '../../utils/validation';
type IArg<T> = import('@oclif/parser').args.IArg<T>;
@ -67,9 +66,7 @@ export default class EnvRmCmd extends Command {
},
];
// hardcoded 'env rm' to avoid oclif's 'env:rm' topic syntax
public static usage =
'env rm ' + new CommandHelp({ args: EnvRmCmd.args }).defaultUsage();
public static usage = 'env rm <id>';
public static flags: flags.Input<FlagsDef> = {
config: ec.booleanConfig,

View File

@ -24,7 +24,6 @@ import Command from '../../command';
import { ExpectedError } from '../../errors';
import * as cf from '../../utils/common-flags';
import { getBalenaSdk, stripIndent, getCliForm } from '../../utils/lazy';
import { CommandHelp } from '../../utils/oclif-utils';
const BOOT_PARTITION = 1;
const CONNECTIONS_FOLDER = '/system-connections';
@ -111,10 +110,7 @@ export default class OsConfigureCmd extends Command {
},
];
// hardcoded 'os configure' to avoid oclif's 'os:configure' topic syntax
public static usage =
'os configure ' +
new CommandHelp({ args: OsConfigureCmd.args }).defaultUsage();
public static usage = 'os configure <image>';
public static flags: flags.Input<FlagsDef> = {
advanced: flags.boolean({

View File

@ -36,7 +36,7 @@ interface FlagsDef {
interface ArgsDef {
applicationOrDevice: string;
serviceName?: string;
service?: string;
}
export default class NoteCmd extends Command {
@ -85,13 +85,13 @@ export default class NoteCmd extends Command {
required: true,
},
{
name: 'serviceName',
name: 'service',
description: 'service name, if connecting to a container',
required: false,
},
];
public static usage = 'ssh <applicationOrDevice> [serviceName]';
public static usage = 'ssh <applicationOrDevice> [service]';
public static flags: flags.Input<FlagsDef> = {
port: flags.integer({
@ -134,7 +134,7 @@ export default class NoteCmd extends Command {
port: options.port,
forceTTY: options.tty,
verbose: options.verbose,
service: params.serviceName,
service: params.service,
});
}
@ -214,11 +214,11 @@ export default class NoteCmd extends Command {
// At this point, we have a long uuid with a device
// that we know exists and is accessible
let containerId: string | undefined;
if (params.serviceName != null) {
if (params.service != null) {
containerId = await this.getContainerId(
sdk,
uuid,
params.serviceName,
params.service,
{
port: options.port,
proxyCommand,

View File

@ -40,11 +40,15 @@ export default class VersionCmd extends Command {
because the JSON format is less likely to change and it better represents
data types like lists and empty strings. The 'jq' utility may be helpful
in shell scripts (https://stedolan.github.io/jq/manual/).
This command can also be invoked with 'balena --version' or 'balena -v'.
`;
public static examples = [
'$ balena version',
'$ balena version -a',
'$ balena version -j',
`$ balena --version`,
`$ balena -v`,
];
public static usage = 'version';

View File

@ -20,7 +20,7 @@ import { getBalenaSdk, getVisuals, stripIndent } from '../utils/lazy';
export default class WhoamiCmd extends Command {
public static description = stripIndent`
Get current username and email address.
Display account information for current user.
Get the username and email address of the currently logged in user.
`;

View File

@ -1,190 +0,0 @@
/*
Copyright 2016-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 * as _ from 'lodash';
import * as capitano from 'capitano';
import * as columnify from 'columnify';
import * as messages from '../utils/messages';
import { getManualSortCompareFunction } from '../utils/helpers';
import { exitWithExpectedError } from '../errors';
import { getOclifHelpLinePairs } from './help_ts';
const parse = (object) =>
_.map(object, function (item) {
// Hacky way to determine if an object is
// a function or a command
let signature;
if (item.alias != null) {
signature = item.toString();
} else {
signature = item.signature.toString();
}
return [signature, item.description];
});
const indent = function (text) {
text = _.map(text.split('\n'), (line) => ' ' + line);
return text.join('\n');
};
const print = (usageDescriptionPairs) =>
console.log(
indent(
columnify(_.fromPairs(usageDescriptionPairs), {
showHeaders: false,
minWidth: 35,
}),
),
);
const manuallySortedPrimaryCommands = [
'help',
'login',
'push',
'logs',
'ssh',
'apps',
'app',
'devices',
'device',
'tunnel',
'preload',
'build',
'deploy',
'join',
'leave',
'local scan',
];
const general = function (_params, options, done) {
console.log('Usage: balena [COMMAND] [OPTIONS]\n');
console.log('Primary commands:\n');
// We do not want the wildcard command
// to be printed in the help screen.
const commands = capitano.state.commands.filter(
(command) => !command.hidden && !command.isWildcard(),
);
const capitanoCommands = _.groupBy(commands, function (command) {
if (command.primary) {
return 'primary';
}
return 'secondary';
});
return getOclifHelpLinePairs()
.then(function (oclifHelpLinePairs) {
const primaryHelpLinePairs = parse(capitanoCommands.primary)
.concat(oclifHelpLinePairs.primary)
.sort(
getManualSortCompareFunction(manuallySortedPrimaryCommands, function (
[signature],
manualItem,
) {
return (
signature === manualItem || signature.startsWith(`${manualItem} `)
);
}),
);
const secondaryHelpLinePairs = parse(capitanoCommands.secondary)
.concat(oclifHelpLinePairs.secondary)
.sort();
print(primaryHelpLinePairs);
if (options.verbose) {
console.log('\nAdditional commands:\n');
print(secondaryHelpLinePairs);
} else {
console.log(
'\nRun `balena help --verbose` to list additional commands',
);
}
if (!_.isEmpty(capitano.state.globalOptions)) {
console.log('\nGlobal Options:\n');
print(parse(capitano.state.globalOptions).sort());
}
console.log(indent('--debug\n'));
console.log(messages.help);
return done();
})
.catch(done);
};
const commandHelp = (params, _options, done) =>
capitano.state.getMatchCommand(params.command, function (error, command) {
if (error != null) {
return done(error);
}
if (command == null || command.isWildcard()) {
exitWithExpectedError(`Command not found: ${params.command}`);
}
console.log(`Usage: ${command.signature}`);
if (command.help != null) {
console.log(`\n${command.help}`);
} else if (command.description != null) {
console.log(`\n${_.capitalize(command.description)}`);
}
if (!_.isEmpty(command.options)) {
console.log('\nOptions:\n');
print(parse(command.options).sort());
}
console.log();
return done();
});
export const help = {
signature: 'help [command...]',
description: 'show help',
help: `\
Get detailed help for an specific command.
Examples:
$ balena help apps
$ balena help os download\
`,
primary: true,
options: [
{
signature: 'verbose',
description: 'show additional commands',
boolean: true,
alias: 'v',
},
],
action(params, options, done) {
if (params.command != null) {
return commandHelp(params, options, done);
} else {
return general(params, options, done);
}
},
};

View File

@ -1,65 +0,0 @@
/**
* @license
* Copyright 2019 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 * as _ from 'lodash';
import * as path from 'path';
import Command from '../command';
import { capitanoizeOclifUsage } from '../utils/oclif-utils';
export async function getOclifHelpLinePairs() {
const { convertedCommands } = await import('../preparser');
const primary: Array<[string, string]> = [];
const secondary: Array<[string, string]> = [];
for (const convertedCmd of convertedCommands) {
const [topic, cmd] = convertedCmd.split(':');
const pathComponents = ['..', 'actions-oclif', topic];
if (cmd) {
pathComponents.push(cmd);
}
const cmdModule = await import(path.join(...pathComponents));
const command: typeof Command = cmdModule.default;
if (!command.hidden) {
if (command.primary) {
primary.push(getCmdUsageDescriptionLinePair(command));
} else {
secondary.push(getCmdUsageDescriptionLinePair(command));
}
}
}
return { primary, secondary };
}
function getCmdUsageDescriptionLinePair(cmd: typeof Command): [string, string] {
const usage = capitanoizeOclifUsage(cmd.usage);
let description = '';
// note: [^] matches any characters (including line breaks), achieving the
// same effect as the 's' regex flag which is only supported by Node 9+
const matches = /\s*([^]+?)\n[^]*/.exec(cmd.description || '');
if (matches && matches.length > 1) {
description = _.trimEnd(matches[1], '.');
// Only do .lowerFirst() if the second char is not uppercase (e.g. for 'SSH');
if (description[1] !== description[1]?.toUpperCase()) {
description = _.lowerFirst(description);
}
}
return [usage, description];
}

View File

@ -1,17 +0,0 @@
/*
Copyright 2016-2020 Balena
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.
*/
export * as help from './help';

View File

@ -1,78 +0,0 @@
/*
Copyright 2016-2020 Balena
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 * as capitano from 'capitano';
import * as actions from './actions';
import * as events from './events';
import { promisify } from 'util';
capitano.permission('user', (done) =>
require('./utils/patterns').checkLoggedIn().then(done, done),
);
capitano.command({
signature: '*',
action(_params, _options, done) {
capitano.execute({ command: 'help' }, done);
process.exitCode = process.exitCode || 1;
},
});
capitano.globalOption({
signature: 'help',
boolean: true,
alias: 'h',
});
capitano.globalOption({
signature: 'version',
boolean: true,
alias: 'v',
});
// ---------- Help Module ----------
capitano.command(actions.help.help);
export function run(argv: string[]) {
const cli = capitano.parse(argv.slice(2));
const runCommand = function () {
const capitanoExecuteAsync = promisify(capitano.execute);
if (cli.global?.help) {
return capitanoExecuteAsync({
command: `help ${cli.command ?? ''}`,
});
} else {
return capitanoExecuteAsync(cli);
}
};
const trackCommand = function () {
const getMatchCommandAsync = promisify(capitano.state.getMatchCommand);
return getMatchCommandAsync(cli.command).then(function (command) {
// cmdSignature is literally a string like, for example:
// "push <applicationOrDevice>"
// ("applicationOrDevice" is NOT replaced with its actual value)
// In case of failures like an nonexistent or invalid command,
// command.signature.toString() returns '*'
const cmdSignature = command.signature.toString();
return events.trackCommand(cmdSignature);
});
};
return Promise.all([trackCommand(), runCommand()]).catch(
require('./errors').handleError,
);
}

View File

@ -37,13 +37,18 @@ export async function run(
}
const { globalInit } = await import('./app-common');
const { routeCliFramework } = await import('./preparser');
const { preparseArgs, checkDeletedCommand } = await import('./preparser');
// globalInit() must be called very early on (before other imports) because
// it sets up Sentry error reporting, global HTTP proxy settings, balena-sdk
// shared options, and performs node version requirement checks.
await globalInit();
await routeCliFramework(cliArgs, options);
// Look for commands that have been removed and if so, exit with a notice
checkDeletedCommand(cliArgs.slice(2));
const args = await preparseArgs(cliArgs);
await (await import('./app-oclif')).run(args, options);
// Windows fix: reading from stdin prevents the process from exiting
process.stdin.pause();

168
lib/help.ts Normal file
View File

@ -0,0 +1,168 @@
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');
this.showCustomRootHelp(verbose);
return;
}
const command = this.config.findCommand(subject);
if (command) {
this.showCommandHelp(command);
return;
}
throw new ExpectedError(`command ${chalk.cyan.bold(subject)} not found`);
}
showCustomRootHelp(showAllCommands: boolean): void {
const chalk = getChalk();
const bold = chalk.bold;
const cmd = chalk.cyan.bold;
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(' ', ':'));
});
// 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
const usageLength = commands
.map((c) => c.usage?.length || 0)
.reduce((longest, l) => {
return l > longest ? l : longest;
});
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,
);
}
// Output help
console.log(bold('USAGE'));
console.log('$ balena [COMMAND] [OPTIONS]');
console.log(bold('\nPRIMARY COMMANDS'));
console.log(this.formatCommands(primaryCommands));
if (showAllCommands) {
console.log(bold('\nADDITIONAL COMMANDS'));
console.log(this.formatCommands(additionalCommands));
} else {
console.log(
`\n${bold('...MORE')} run ${cmd(
'balena help --verbose',
)} to list additional commands.`,
);
}
console.log(bold('\nGLOBAL OPTIONS'));
console.log(' --help, -h');
console.log(' --debug\n');
console.log(
`For help, visit our support forums: ${chalk.grey(
'https://forums.balena.io',
)}`,
);
console.log(
`For bug reports or feature requests, see: ${chalk.grey(
'https://github.com/balena-io/balena-cli/issues/',
)}\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',
'apps',
'app',
'devices',
'device',
'tunnel',
'preload',
'build',
'deploy',
'join',
'leave',
'scan',
];
}

View File

@ -15,19 +15,14 @@
* limitations under the License.
*/
import { stripIndent } from './utils/lazy';
import { exitWithExpectedError } from './errors';
import { ExpectedError } from './errors';
export interface AppOptions {
// Prevent the default behavior of flushing stdout after running a command
noFlush?: boolean;
}
/**
* Simple command-line pre-parsing to choose between oclif or Capitano.
* @param argv process.argv
*/
export async function routeCliFramework(argv: string[], options: AppOptions) {
export async function preparseArgs(argv: string[]): Promise<string[]> {
if (process.env.DEBUG) {
console.log(
`[debug] original argv0="${process.argv0}" argv=[${argv}] length=${argv.length}`,
@ -35,9 +30,6 @@ export async function routeCliFramework(argv: string[], options: AppOptions) {
}
const cmdSlice = argv.slice(2);
// Look for commands that have been removed and if so, exit with a notice
checkDeletedCommand(cmdSlice);
if (cmdSlice.length > 0) {
// convert 'balena --version' or 'balena -v' to 'balena version'
if (['--version', '-v'].includes(cmdSlice[0])) {
@ -48,7 +40,11 @@ export async function routeCliFramework(argv: string[], options: AppOptions) {
cmdSlice[0] = 'help';
}
// convert e.g. 'balena help env add' to 'balena env add --help'
if (cmdSlice.length > 1 && cmdSlice[0] === 'help') {
if (
cmdSlice.length > 1 &&
cmdSlice[0] === 'help' &&
cmdSlice[1][0] !== '-'
) {
cmdSlice.shift();
cmdSlice.push('--help');
}
@ -71,34 +67,31 @@ export async function routeCliFramework(argv: string[], options: AppOptions) {
const Logger = await import('./utils/logger');
Logger.command = cmdSlice[0];
const [isOclif, isTopic] = isOclifCommand(cmdSlice);
let args = cmdSlice;
if (isOclif) {
let oclifArgs = cmdSlice;
if (isTopic) {
// Convert space separated subcommands (e.g. `end add`), to colon-separated format (e.g. `env:add`)
if (isSubcommand(cmdSlice)) {
// convert space-separated commands to oclif's topic:command syntax
oclifArgs = [cmdSlice[0] + ':' + cmdSlice[1], ...cmdSlice.slice(2)];
args = [cmdSlice[0] + ':' + cmdSlice[1], ...cmdSlice.slice(2)];
Logger.command = `${cmdSlice[0]} ${cmdSlice[1]}`;
}
if (process.env.DEBUG) {
console.log(
`[debug] new argv=[${[argv[0], argv[1], ...oclifArgs]}] length=${
oclifArgs.length + 2
`[debug] new argv=[${[argv[0], argv[1], ...args]}] length=${
args.length + 2
}`,
);
}
await (await import('./app-oclif')).run(oclifArgs, options);
} else {
await (await import('./app-capitano')).run(argv);
}
return args;
}
/**
* Check whether the command line refers to a command that has been deprecated
* and removed and, if so, exit with an informative error message.
* @param argvSlice process.argv.slice(2)
*/
function checkDeletedCommand(argvSlice: string[]): void {
export function checkDeletedCommand(argvSlice: string[]): void {
if (argvSlice[0] === 'help') {
argvSlice = argvSlice.slice(1);
}
@ -108,7 +101,7 @@ function checkDeletedCommand(argvSlice: string[]): void {
version: string,
verb = 'replaced',
) {
exitWithExpectedError(stripIndent`
throw new ExpectedError(stripIndent`
Note: the command "balena ${oldCmd}" was ${verb} in CLI version ${version}.
Please use "balena ${alternative}" instead.
`);
@ -118,7 +111,7 @@ function checkDeletedCommand(argvSlice: string[]): void {
if (alternative) {
msg = [msg, alternative].join('\n');
}
exitWithExpectedError(msg);
throw new ExpectedError(msg);
}
const stopAlternative =
'Please use "balena ssh -s" to access the host OS, then use `balena-engine stop`.';
@ -141,7 +134,14 @@ function checkDeletedCommand(argvSlice: string[]): void {
}
}
export const convertedCommands = [
// Check if this is a space separated 'topic command' style command subcommand (e.g. `end add`)
// by comparing with oclif style colon-separated subcommand list (e.g. `env:add`)
// TODO: Need to find a way of doing this that does not require maintaining list of IDs
export function isSubcommand(args: string[]) {
return oclifCommandIds.includes(`${args[0] || ''}:${args[1] || ''}`);
}
export const oclifCommandIds = [
'api-key:generate',
'app',
'app:create',
@ -172,6 +172,7 @@ export const convertedCommands = [
'env:add',
'env:rename',
'env:rm',
'help',
'internal:scandevices',
'internal:osinit',
'join',
@ -204,26 +205,3 @@ export const convertedCommands = [
'version',
'whoami',
];
/**
* Determine whether the CLI command has been converted from Capitano to oclif.
* Return an array of two boolean values:
* r[0] : whether the CLI command is implemented with oclif
* r[1] : if r[0] is true, whether the CLI command is implemented with
* oclif "topics" (colon-separated subcommands like `env:add`)
* @param argvSlice process.argv.slice(2)
*/
export function isOclifCommand(argvSlice: string[]): [boolean, boolean] {
// Look for commands that have been transitioned to oclif
// const { convertedCommands } = require('oclif/utils/command');
const arg0 = argvSlice.length > 0 ? argvSlice[0] : '';
const arg1 = argvSlice.length > 1 ? argvSlice[1] : '';
if (convertedCommands.includes(`${arg0}:${arg1}`)) {
return [true, true];
}
if (convertedCommands.includes(arg0)) {
return [true, false];
}
return [false, false];
}

View File

@ -25,7 +25,7 @@ import type { Device, PineOptions } from 'balena-sdk';
import { ExpectedError } from '../errors';
import { getBalenaSdk, getChalk, getVisuals } from './lazy';
import { promisify } from 'util';
import { isOclifCommand } from '../preparser';
import { isSubcommand } from '../preparser';
export function getGroupDefaults(group: {
options: Array<{ name: string; default: string | number }>;
@ -95,9 +95,7 @@ export async function sudo(
}
export function runCommand<T>(commandArgs: string[]): Promise<T> {
const [isOclif, isOclifTopic] = isOclifCommand(commandArgs);
if (isOclif) {
if (isOclifTopic) {
if (isSubcommand(commandArgs)) {
commandArgs = [
commandArgs[0] + ':' + commandArgs[1],
...commandArgs.slice(2),
@ -105,15 +103,6 @@ export function runCommand<T>(commandArgs: string[]): Promise<T> {
}
const { run } = require('@oclif/command');
return run(commandArgs);
} else {
const capitano = require('capitano') as typeof import('capitano');
// Need to require app-capitano to register capitano commands,
// in case calling command is oclif
require('../app-capitano');
const capitanoRunAsync = promisify(capitano.run);
return capitanoRunAsync(commandArgs);
}
}
export async function getManifest(

84
npm-shrinkwrap.json generated
View File

@ -3405,44 +3405,6 @@
}
}
},
"capitano": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/capitano/-/capitano-1.9.2.tgz",
"integrity": "sha512-o2tjD1OAeudIUv5iILhocL6eFSzKJVlp0m1yMFprL9I08LvymaE3NaktGIijx6+zQYXVi1GXIA7S+XAl6v/CfQ==",
"requires": {
"async": "^1.0.0",
"get-stdin": "^4.0.1",
"is-elevated": "^1.0.0",
"lodash": "~4.17.10",
"yargs-parser": "^2.4.0"
},
"dependencies": {
"async": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz",
"integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo="
},
"get-stdin": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-4.0.1.tgz",
"integrity": "sha1-uWjGsKBDhDJJAui/Gl3zJXmkUP4="
},
"is-elevated": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-elevated/-/is-elevated-1.0.0.tgz",
"integrity": "sha1-+IThcowajY1ez2I/iHM8bm7h4u4=",
"requires": {
"is-admin": "^1.0.2",
"is-root": "^1.0.0"
}
},
"is-root": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-root/-/is-root-1.0.0.tgz",
"integrity": "sha1-B7bCM7w5TNnQK6FclmvWZg1jQtU="
}
}
},
"capture-stack-trace": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/capture-stack-trace/-/capture-stack-trace-1.0.1.tgz",
@ -7921,16 +7883,6 @@
"y18n": "^3.2.1",
"yargs-parser": "5.0.0-security.0"
}
},
"yargs-parser": {
"version": "5.0.0-security.0",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-5.0.0-security.0.tgz",
"integrity": "sha512-T69y4Ps64LNesYxeYGYPvfoMTt/7y1XtfpIslUeK4um+9Hu7hlGoRtaDLvdXb7+/tfq4opVa2HRY5xGip022rQ==",
"dev": true,
"requires": {
"camelcase": "^3.0.0",
"object.assign": "^4.1.0"
}
}
}
},
@ -8824,9 +8776,12 @@
}
},
"is-admin": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-admin/-/is-admin-1.0.2.tgz",
"integrity": "sha1-jIOSSlRxFnAuVqujIj6ZWxAuLOw="
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-admin/-/is-admin-3.0.0.tgz",
"integrity": "sha512-wOa3CXFJAu8BZ2BDtG9xYOOrsq6oiSvc2jFPy4X/HINx5bmJUcW8e+apItVbU2E7GIfBVaFVO7Zit4oAWtTJcw==",
"requires": {
"execa": "^1.0.0"
}
},
"is-arguments": {
"version": "1.0.4",
@ -8921,16 +8876,6 @@
"requires": {
"is-admin": "^3.0.0",
"is-root": "^2.1.0"
},
"dependencies": {
"is-admin": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-admin/-/is-admin-3.0.0.tgz",
"integrity": "sha512-wOa3CXFJAu8BZ2BDtG9xYOOrsq6oiSvc2jFPy4X/HINx5bmJUcW8e+apItVbU2E7GIfBVaFVO7Zit4oAWtTJcw==",
"requires": {
"execa": "^1.0.0"
}
}
}
},
"is-extendable": {
@ -9602,11 +9547,6 @@
"resolved": "https://registry.npmjs.org/lodash._root/-/lodash._root-3.0.1.tgz",
"integrity": "sha1-+6HEUkwZ7ppfgTa0YJ8BfPTe1pI="
},
"lodash.assign": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.assign/-/lodash.assign-4.2.0.tgz",
"integrity": "sha1-DZnzzNem0mHRm9rrkkUAXShYCOc="
},
"lodash.defaults": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
@ -16708,18 +16648,20 @@
}
},
"yargs-parser": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-2.4.1.tgz",
"integrity": "sha1-hVaN488VD/SfpRgl8DqMiA3cxcQ=",
"version": "5.0.0-security.0",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-5.0.0-security.0.tgz",
"integrity": "sha512-T69y4Ps64LNesYxeYGYPvfoMTt/7y1XtfpIslUeK4um+9Hu7hlGoRtaDLvdXb7+/tfq4opVa2HRY5xGip022rQ==",
"dev": true,
"requires": {
"camelcase": "^3.0.0",
"lodash.assign": "^4.0.6"
"object.assign": "^4.1.0"
},
"dependencies": {
"camelcase": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-3.0.0.tgz",
"integrity": "sha1-MvxLn82vhF/N9+c7uXysImHwqwo="
"integrity": "sha1-MvxLn82vhF/N9+c7uXysImHwqwo=",
"dev": true
}
}
},

View File

@ -27,7 +27,6 @@
"scripts": [
"build/**/*.js",
"node_modules/balena-sdk/es2018/index.js",
"node_modules/balena-sync/build/capitano/*.js",
"node_modules/balena-sync/build/sync/*.js",
"node_modules/pinejs-client-request/node_modules/pinejs-client-core/es2018/index.js",
"node_modules/resin-compose-parse/build/schemas/*.json"
@ -102,13 +101,17 @@
"oclif": {
"bin": "balena",
"commands": "./build/actions-oclif",
"helpClass": "./build/help",
"hooks": {
"prerun": "./build/hooks/prerun/track"
},
"macos": {
"identifier": "io.balena.cli",
"sign": "Developer ID Installer: Rulemotion Ltd (66H43P8FRG)"
}
},
"plugins": [
"@oclif/plugin-help"
]
},
"devDependencies": {
"@balena/lint": "^5.2.0",
@ -210,7 +213,6 @@
"balena-sync": "^11.0.2",
"bluebird": "^3.7.2",
"body-parser": "^1.19.0",
"capitano": "^1.9.2",
"chalk": "^3.0.0",
"chokidar": "^3.3.1",
"cli-truncate": "^2.1.0",

View File

@ -21,32 +21,30 @@ import { BalenaAPIMock } from '../balena-api-mock';
import { cleanOutput, runCommand } from '../helpers';
const SIMPLE_HELP = `
Usage: balena [COMMAND] [OPTIONS]
USAGE
$ balena [COMMAND] [OPTIONS]
Primary commands:
help [command...] show help
PRIMARY COMMANDS
login login to balena
push <applicationordevice> start a remote build on the balena cloud build servers or a local mode device
push <applicationOrDevice> start a remote build on the balena cloud build servers or a local mode device
logs <device> show device logs
ssh <applicationordevice> [servicename] SSH into the host or application container of a device
ssh <applicationOrDevice> [service] SSH into the host or application container of a device
apps list all applications
app <name> display information about a single application
devices list all devices
device <uuid> show info about a single device
tunnel <deviceorapplication> tunnel local ports to your balenaOS device
tunnel <deviceOrApplication> tunnel local ports to your balenaOS device
preload <image> preload an app on a disk image (or Edison zip archive)
build [source] build a project locally
deploy <appname> [image] deploy a single image or a multicontainer project to a balena application
join [deviceiporhostname] move a local device to an application on another balena server
leave [deviceiporhostname] remove a local device from its balena application
deploy <appName> [image] deploy a single image or a multicontainer project to a balena application
join [deviceIpOrHostname] move a local device to an application on another balena server
leave [deviceIpOrHostname] remove a local device from its balena application
scan scan for balenaOS devices on your local network
`;
const ADDITIONAL_HELP = `
Additional commands:
ADDITIONAL COMMANDS
api-key generate <name> generate a new balenaCloud API key
app create <name> create an application
app restart <name> restart an application
@ -63,12 +61,12 @@ Additional commands:
device public-url <uuid> get or manage the public URL for a device
device reboot <uuid> restart a device
device register <application> register a device
device rename <uuid> [newname] rename a device
device rename <uuid> [newName] rename a device
device rm <uuid(s)> remove one or more devices
device shutdown <uuid> shutdown a device
devices supported list the supported device types (like 'raspberrypi3' or 'intel-nuc')
env add <name> [value] add env or config variable to application(s), device(s) or service(s)
env rename <id> <value> change the value of a config or env var for an app, device or service
env rename <name> <value> change the value of a config or env var for an app, device or service
env rm <id> remove a config or env var from an application, device or service
envs list the environment or config variables of an application, device or service
key <id> display an SSH key
@ -85,33 +83,32 @@ Additional commands:
os initialize <image> initialize an os image for a device
os versions <type> show available balenaOS versions for the given device type
settings print current settings
tag rm <tagkey> remove a tag from an application, device or release
tag set <tagkey> [value] set a tag on an application, device or release
tag rm <tagKey> remove a tag from an application, device or release
tag set <tagKey> [value] set a tag on an application, device or release
tags list all tags for an application, device or release
util available-drives list available drives
version display version information for the balena CLI and/or Node.js
whoami get current username and email address
whoami display account information for current user
`;
const LIST_ADDITIONAL = `
Run \`balena help --verbose\` to list additional commands
...MORE run balena help --verbose to list additional commands.
`;
const GLOBAL_OPTIONS = `
Global Options:
GLOBAL OPTIONS
--help, -h
--version, -v
--debug
`;
const ONLINE_RESOURCES = `
For help, visit our support forums: https://forums.balena.io
For bug reports or feature requests, see: https://github.com/balena-io/balena-cli/issues/
For help, visit our support forums: https://forums.balena.io
For bug reports or feature requests, see: https://github.com/balena-io/balena-cli/issues/
`;
describe('balena help', function () {
describe.skip('balena help', function () {
let api: BalenaAPIMock;
this.beforeEach(() => {

View File

@ -1,15 +1,3 @@
> Warning Cannot resolve 'path.join(...pathComponents)'
build/actions/help_ts.js
Dynamic require may fail at run time, because the requested file
is unknown at compilation time and not included into executable.
Use a string literal as an argument for 'require', or leave it
as is and specify the resolved file name in 'scripts' option.
> Warning Cannot resolve ''./' + command'
node_modules/balena-sync/build/capitano/index.js
Dynamic require may fail at run time, because the requested file
is unknown at compilation time and not included into executable.
Use a string literal as an argument for 'require', or leave it
as is and specify the resolved file name in 'scripts' option.
> Warning Cannot resolve ''./' + target'
node_modules/balena-sync/build/sync/index.js
Dynamic require may fail at run time, because the requested file

View File

@ -1,15 +1,3 @@
> Warning Cannot resolve 'path.join(...pathComponents)'
build/actions/help_ts.js
Dynamic require may fail at run time, because the requested file
is unknown at compilation time and not included into executable.
Use a string literal as an argument for 'require', or leave it
as is and specify the resolved file name in 'scripts' option.
> Warning Cannot resolve ''./' + command'
node_modules/balena-sync/build/capitano/index.js
Dynamic require may fail at run time, because the requested file
is unknown at compilation time and not included into executable.
Use a string literal as an argument for 'require', or leave it
as is and specify the resolved file name in 'scripts' option.
> Warning Cannot resolve ''./' + target'
node_modules/balena-sync/build/sync/index.js
Dynamic require may fail at run time, because the requested file

View File

@ -1,17 +1,5 @@
> Warning Cannot resolve 'path.join(...pathComponents)'
build\actions\help_ts.js
Dynamic require may fail at run time, because the requested file
is unknown at compilation time and not included into executable.
Use a string literal as an argument for 'require', or leave it
as is and specify the resolved file name in 'scripts' option.
> Warning Cannot find module 'net-keepalive' from 'build\utils\device'
%1: build\utils\device\api.js
> Warning Cannot resolve ''./' + command'
node_modules\balena-sync\build\capitano\index.js
Dynamic require may fail at run time, because the requested file
is unknown at compilation time and not included into executable.
Use a string literal as an argument for 'require', or leave it
as is and specify the resolved file name in 'scripts' option.
> Warning Cannot resolve ''./' + target'
node_modules\balena-sync\build\sync\index.js
Dynamic require may fail at run time, because the requested file