Refactor oclif integration and preparser

Change-type: patch
Signed-off-by: Paulo Castro <paulo@balena.io>
This commit is contained in:
Paulo Castro 2019-08-28 19:51:56 +01:00
parent 3d89b0c7a1
commit 2ff427fb90
12 changed files with 277 additions and 195 deletions

View File

@ -17,7 +17,9 @@
import { Command, flags } from '@oclif/command';
import { stripIndent } from 'common-tags';
import * as _ from 'lodash';
import * as cf from '../../utils/common-flags';
import { CommandHelp } from '../../utils/oclif-utils';
interface FlagsDef {
@ -71,22 +73,10 @@ export default class EnvAddCmd extends Command {
'env add ' + new CommandHelp({ args: EnvAddCmd.args }).defaultUsage();
public static flags: flags.Input<FlagsDef> = {
application: flags.string({
char: 'a',
description: 'application name',
exclusive: ['device'],
}),
device: flags.string({
char: 'd',
description: 'device UUID',
exclusive: ['application'],
}),
help: flags.help({ char: 'h' }),
quiet: flags.boolean({
char: 'q',
description: 'suppress warning messages',
default: false,
}),
application: _.assign({ exclusive: ['device'] }, cf.application),
device: _.assign({ exclusive: ['application'] }, cf.device),
help: cf.help,
quiet: cf.quiet,
};
public async run() {
@ -94,7 +84,6 @@ export default class EnvAddCmd extends Command {
EnvAddCmd,
);
const Bluebird = await import('bluebird');
const _ = await import('lodash');
const balena = (await import('balena-sdk')).fromSharedOptions();
const { exitWithExpectedError } = await import('../../utils/patterns');

View File

@ -90,15 +90,20 @@ general = (params, options, done) ->
if options.verbose
console.log('\nAdditional commands:\n')
print parse(groupedCommands.secondary).concat(getOclifHelpLinePairs()).sort()
secondaryCommandPromise = getOclifHelpLinePairs()
.then (oclifHelpLinePairs) ->
print parse(groupedCommands.secondary).concat(oclifHelpLinePairs).sort()
else
console.log('\nRun `balena help --verbose` to list additional commands')
secondaryCommandPromise = Promise.resolve()
if not _.isEmpty(capitano.state.globalOptions)
console.log('\nGlobal Options:\n')
print parse(capitano.state.globalOptions).sort()
return done()
secondaryCommandPromise
.then ->
if not _.isEmpty(capitano.state.globalOptions)
console.log('\nGlobal Options:\n')
print parse(capitano.state.globalOptions).sort()
done()
.catch(done)
command = (params, options, done) ->
capitano.state.getMatchCommand params.command, (error, command) ->

View File

@ -16,20 +16,29 @@
*/
import { Command } from '@oclif/command';
import * as Bluebird from 'bluebird';
import * as _ from 'lodash';
import * as path from 'path';
export function getOclifHelpLinePairs(): Array<[string, string]> {
// Although it's tempting to have these oclif commands 'require'd in a
// central place, it would impact on performance (CLI start time). An
// improvement would probably be to automatically scan the actions-oclif
// folder.
const EnvAddCmd = require('../actions-oclif/env/add').default;
const EnvRmCmd = require('../actions-oclif/env/rm').default;
const VersionCmd = require('../actions-oclif/version').default;
return [EnvAddCmd, EnvRmCmd, VersionCmd].map(getCmdUsageDescriptionLinePair);
export async function getOclifHelpLinePairs(): Promise<
Array<[string, string]>
> {
const { convertedCommands } = await import('../preparser');
const cmdClasses: Array<Promise<typeof Command>> = [];
for (const convertedCmd of convertedCommands) {
const [topic, cmd] = convertedCmd.split(':');
const pathComponents = ['..', 'actions-oclif', topic];
if (cmd) {
pathComponents.push(cmd);
}
// note that `import(path)` returns a promise
cmdClasses.push(import(path.join(...pathComponents)));
}
return Bluebird.map(cmdClasses, getCmdUsageDescriptionLinePair);
}
function getCmdUsageDescriptionLinePair(cmd: typeof Command): [string, string] {
function getCmdUsageDescriptionLinePair(cmdModule: any): [string, string] {
const cmd: typeof Command = cmdModule.default;
const usage = (cmd.usage || '').toString().toLowerCase();
let description = '';
// note: [^] matches any characters (including line breaks), achieving the

18
lib/app-capitano.d.ts vendored Normal file
View File

@ -0,0 +1,18 @@
/**
* @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.
*/
export async function run(argv: string[]);

View File

@ -18,7 +18,6 @@
import { Main } from '@oclif/command';
import { ExitError } from '@oclif/errors';
import { AppOptions } from './app';
import { trackPromise } from './hooks/prerun/track';
class CustomMain extends Main {
@ -32,10 +31,12 @@ class CustomMain extends Main {
}
}
type AppOptions = import('./preparser').AppOptions;
/**
* oclif CLI entrypoint
*/
export function run(command: string[], options: AppOptions) {
export async function run(command: string[], options: AppOptions) {
const runPromise = CustomMain.run(command).then(
() => {
if (!options.noFlush) {
@ -51,7 +52,9 @@ export function run(command: string[], options: AppOptions) {
}
},
);
return Promise.all([trackPromise, runPromise]).catch(
require('./errors').handleError,
);
try {
await Promise.all([trackPromise, runPromise]);
} catch (err) {
await (await import('./errors')).handleError(err);
}
}

View File

@ -14,156 +14,18 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { stripIndent } from 'common-tags';
import { exitWithExpectedError } from './utils/patterns';
export interface AppOptions {
// Prevent the default behaviour of flushing stdout after running a command
noFlush?: boolean;
}
/**
* Simple command-line pre-parsing to choose between oclif or Capitano.
* @param argv process.argv
*/
function routeCliFramework(argv: string[], options: AppOptions): void {
if (process.env.DEBUG) {
console.log(
`[debug] original argv0="${process.argv0}" argv=[${argv}] length=${
argv.length
}`,
);
}
const cmdSlice = argv.slice(2);
// Look for commands that have been deleted, to print a notice
checkDeletedCommand(cmdSlice);
if (cmdSlice.length > 0) {
// convert 'balena --version' or 'balena -v' to 'balena version'
if (['--version', '-v'].includes(cmdSlice[0])) {
cmdSlice[0] = 'version';
}
// convert 'balena --help' or 'balena -h' to 'balena help'
else if (['--help', '-h'].includes(cmdSlice[0])) {
cmdSlice[0] = 'help';
}
// convert e.g. 'balena help env add' to 'balena env add --help'
if (cmdSlice.length > 1 && cmdSlice[0] === 'help') {
cmdSlice.shift();
cmdSlice.push('--help');
}
}
const [isOclif, isTopic] = isOclifCommand(cmdSlice);
if (isOclif) {
let oclifArgs = cmdSlice;
if (isTopic) {
// convert space-separated commands to oclif's topic:command syntax
oclifArgs = [cmdSlice[0] + ':' + cmdSlice[1], ...cmdSlice.slice(2)];
}
if (process.env.DEBUG) {
console.log(
`[debug] new argv=[${[
argv[0],
argv[1],
...oclifArgs,
]}] length=${oclifArgs.length + 2}`,
);
}
return require('./app-oclif').run(oclifArgs, options);
} else {
return require('./app-capitano').run(argv);
}
}
/**
*
* @param argvSlice process.argv.slice(2)
*/
function checkDeletedCommand(argvSlice: string[]): void {
if (argvSlice[0] === 'help') {
argvSlice = argvSlice.slice(1);
}
function replaced(
oldCmd: string,
alternative: string,
version: string,
verb = 'replaced',
) {
exitWithExpectedError(stripIndent`
Note: the command "balena ${oldCmd}" was ${verb} in CLI version ${version}.
Please use "balena ${alternative}" instead.
`);
}
function removed(oldCmd: string, alternative: string, version: string) {
let msg = `Note: the command "balena ${oldCmd}" was removed in CLI version ${version}.`;
if (alternative) {
msg = [msg, alternative].join('\n');
}
exitWithExpectedError(msg);
}
const stopAlternative =
'Please use "balena ssh -s" to access the host OS, then use `balena-engine stop`.';
const cmds: { [cmd: string]: [(...args: any) => void, ...string[]] } = {
sync: [replaced, 'push', 'v11.0.0', 'removed'],
'local logs': [replaced, 'logs', 'v11.0.0'],
'local push': [replaced, 'push', 'v11.0.0'],
'local scan': [replaced, 'scan', 'v11.0.0'],
'local ssh': [replaced, 'ssh', 'v11.0.0'],
'local stop': [removed, stopAlternative, 'v11.0.0'],
};
let cmd: string | undefined;
if (argvSlice.length > 1) {
cmd = [argvSlice[0], argvSlice[1]].join(' ');
} else if (argvSlice.length > 0) {
cmd = argvSlice[0];
}
if (cmd && Object.getOwnPropertyNames(cmds).includes(cmd)) {
cmds[cmd][0](cmd, ...cmds[cmd].slice(1));
}
}
/**
* 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)
*/
function isOclifCommand(argvSlice: string[]): [boolean, boolean] {
// Look for commands that have been transitioned to oclif
if (argvSlice.length > 0) {
// balena version
if (argvSlice[0] === 'version') {
return [true, false];
}
if (argvSlice.length > 1) {
// balena env add
if (argvSlice[0] === 'env' && argvSlice[1] === 'add') {
return [true, true];
}
// balena env rm
if (argvSlice[0] === 'env' && argvSlice[1] === 'rm') {
return [true, true];
}
}
}
return [false, false];
}
import { globalInit } from './app-common';
import { AppOptions, routeCliFramework } from './preparser';
/**
* CLI entrypoint, but see also `bin/balena` and `bin/balena-dev` which
* call this function.
*/
export function run(cliArgs = process.argv, options: AppOptions = {}): void {
export async function run(cliArgs = process.argv, options: AppOptions = {}) {
// 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.
require('./app-common').globalInit();
return routeCliFramework(cliArgs, options);
globalInit();
await routeCliFramework(cliArgs, options);
}

View File

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import * as Promise from 'bluebird';
import * as Bluebird from 'bluebird';
import { stripIndent } from 'common-tags';
import * as _ from 'lodash';
import * as os from 'os';
@ -22,7 +22,7 @@ import * as Raven from 'raven';
import * as patterns from './utils/patterns';
const captureException = Promise.promisify<string, Error>(
const captureException = Bluebird.promisify<string, Error>(
Raven.captureException,
{ context: Raven },
);
@ -104,7 +104,7 @@ const messages: {
$ balena login`,
};
export function handleError(error: any) {
export async function handleError(error: any) {
let message = interpret(error);
if (message == null) {
return;
@ -116,7 +116,7 @@ export function handleError(error: any) {
patterns.printErrorMessage(message!);
return captureException(error)
await captureException(error)
.timeout(1000)
.catch(function() {
// Ignore any errors (from error logging, or timeouts)

153
lib/preparser.ts Normal file
View File

@ -0,0 +1,153 @@
/**
* @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 { stripIndent } from 'common-tags';
import { exitWithExpectedError } from './utils/patterns';
export interface AppOptions {
// Prevent the default behaviour 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) {
if (process.env.DEBUG) {
console.log(
`[debug] original argv0="${process.argv0}" argv=[${argv}] length=${
argv.length
}`,
);
}
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])) {
cmdSlice[0] = 'version';
}
// convert 'balena --help' or 'balena -h' to 'balena help'
else if (['--help', '-h'].includes(cmdSlice[0])) {
cmdSlice[0] = 'help';
}
// convert e.g. 'balena help env add' to 'balena env add --help'
if (cmdSlice.length > 1 && cmdSlice[0] === 'help') {
cmdSlice.shift();
cmdSlice.push('--help');
}
}
const [isOclif, isTopic] = isOclifCommand(cmdSlice);
if (isOclif) {
let oclifArgs = cmdSlice;
if (isTopic) {
// convert space-separated commands to oclif's topic:command syntax
oclifArgs = [cmdSlice[0] + ':' + cmdSlice[1], ...cmdSlice.slice(2)];
}
if (process.env.DEBUG) {
console.log(
`[debug] new argv=[${[
argv[0],
argv[1],
...oclifArgs,
]}] length=${oclifArgs.length + 2}`,
);
}
await (await import('./app-oclif')).run(oclifArgs, options);
} else {
await (await import('./app-capitano')).run(argv);
}
}
/**
* 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 {
if (argvSlice[0] === 'help') {
argvSlice = argvSlice.slice(1);
}
function replaced(
oldCmd: string,
alternative: string,
version: string,
verb = 'replaced',
) {
exitWithExpectedError(stripIndent`
Note: the command "balena ${oldCmd}" was ${verb} in CLI version ${version}.
Please use "balena ${alternative}" instead.
`);
}
function removed(oldCmd: string, alternative: string, version: string) {
let msg = `Note: the command "balena ${oldCmd}" was removed in CLI version ${version}.`;
if (alternative) {
msg = [msg, alternative].join('\n');
}
exitWithExpectedError(msg);
}
const stopAlternative =
'Please use "balena ssh -s" to access the host OS, then use `balena-engine stop`.';
const cmds: { [cmd: string]: [(...args: any) => void, ...string[]] } = {
sync: [replaced, 'push', 'v11.0.0', 'removed'],
'local logs': [replaced, 'logs', 'v11.0.0'],
'local push': [replaced, 'push', 'v11.0.0'],
'local scan': [replaced, 'scan', 'v11.0.0'],
'local ssh': [replaced, 'ssh', 'v11.0.0'],
'local stop': [removed, stopAlternative, 'v11.0.0'],
};
let cmd: string | undefined;
if (argvSlice.length > 1) {
cmd = [argvSlice[0], argvSlice[1]].join(' ');
} else if (argvSlice.length > 0) {
cmd = argvSlice[0];
}
if (cmd && Object.getOwnPropertyNames(cmds).includes(cmd)) {
cmds[cmd][0](cmd, ...cmds[cmd].slice(1));
}
}
export const convertedCommands = ['env:add', 'env:rm', 'version'];
/**
* 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)
*/
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];
}

43
lib/utils/common-flags.ts Normal file
View File

@ -0,0 +1,43 @@
/**
* @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 { flags } from '@oclif/command';
type IBooleanFlag<T> = import('@oclif/parser/lib/flags').IBooleanFlag<T>;
export const application = flags.string({
char: 'a',
description: 'application name',
});
export const device = flags.string({
char: 'd',
description: 'device UUID',
});
export const help: IBooleanFlag<void> = flags.help({ char: 'h' });
export const quiet: IBooleanFlag<boolean> = flags.boolean({
char: 'q',
description: 'suppress warning messages',
default: false,
});
export const verbose: IBooleanFlag<boolean> = flags.boolean({
char: 'v',
description: 'produce verbose output',
});

View File

@ -17,11 +17,6 @@
import * as Config from '@oclif/config';
export const convertedCommands = {
'env:add': 'env add',
'env:rm': 'env rm',
};
/**
* This class is a partial copy-and-paste of
* @oclif/plugin-help/command/CommandHelp, which is used to generate oclif's

View File

@ -216,10 +216,14 @@ async function getOrSelectApplication(
throw new Error(`"${deviceType}" is not a valid device type`);
}
const compatibleDeviceTypes = _(allDeviceTypes)
.filter(dt =>
sdk.models.os.isArchitectureCompatibleWith(deviceTypeManifest.arch, dt.arch) &&
!!dt.isDependent === !!deviceTypeManifest.isDependent &&
dt.state !== 'DISCONTINUED'
.filter(
dt =>
sdk.models.os.isArchitectureCompatibleWith(
deviceTypeManifest.arch,
dt.arch,
) &&
!!dt.isDependent === !!deviceTypeManifest.isDependent &&
dt.state !== 'DISCONTINUED',
)
.map(type => type.slug)
.value();

View File

@ -2,7 +2,7 @@
"name": "balena-cli",
"version": "11.11.0",
"description": "The official balena CLI tool",
"main": "./build/actions/index.js",
"main": "./build/app.js",
"homepage": "https://github.com/balena-io/balena-cli",
"repository": {
"type": "git",
@ -29,6 +29,7 @@
"node_modules/raven/lib/instrumentation/*.js"
],
"assets": [
"build/**/*.js",
"build/actions-oclif",
"build/auth/pages/*.ejs",
"build/hooks",