From 2ff427fb9077c1e4f23fc9cbe009d70eadf8257c Mon Sep 17 00:00:00 2001 From: Paulo Castro Date: Wed, 28 Aug 2019 19:51:56 +0100 Subject: [PATCH] Refactor oclif integration and preparser Change-type: patch Signed-off-by: Paulo Castro --- lib/actions-oclif/env/add.ts | 23 ++---- lib/actions/help.coffee | 17 ++-- lib/actions/help_ts.ts | 29 ++++--- lib/app-capitano.d.ts | 18 +++++ lib/app-oclif.ts | 13 +-- lib/app.ts | 148 ++------------------------------- lib/errors.ts | 8 +- lib/preparser.ts | 153 +++++++++++++++++++++++++++++++++++ lib/utils/common-flags.ts | 43 ++++++++++ lib/utils/oclif-utils.ts | 5 -- lib/utils/promote.ts | 12 ++- package.json | 3 +- 12 files changed, 277 insertions(+), 195 deletions(-) create mode 100644 lib/app-capitano.d.ts create mode 100644 lib/preparser.ts create mode 100644 lib/utils/common-flags.ts diff --git a/lib/actions-oclif/env/add.ts b/lib/actions-oclif/env/add.ts index a3c1f06b..c5594fa5 100644 --- a/lib/actions-oclif/env/add.ts +++ b/lib/actions-oclif/env/add.ts @@ -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 = { - 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'); diff --git a/lib/actions/help.coffee b/lib/actions/help.coffee index 503021aa..8d6c3a1f 100644 --- a/lib/actions/help.coffee +++ b/lib/actions/help.coffee @@ -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) -> diff --git a/lib/actions/help_ts.ts b/lib/actions/help_ts.ts index e7c94463..d4d632ec 100644 --- a/lib/actions/help_ts.ts +++ b/lib/actions/help_ts.ts @@ -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> = []; + 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 diff --git a/lib/app-capitano.d.ts b/lib/app-capitano.d.ts new file mode 100644 index 00000000..1aac72e2 --- /dev/null +++ b/lib/app-capitano.d.ts @@ -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[]); diff --git a/lib/app-oclif.ts b/lib/app-oclif.ts index 98ff7d63..0414c838 100644 --- a/lib/app-oclif.ts +++ b/lib/app-oclif.ts @@ -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); + } } diff --git a/lib/app.ts b/lib/app.ts index 7074f64a..9eb2992c 100644 --- a/lib/app.ts +++ b/lib/app.ts @@ -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); } diff --git a/lib/errors.ts b/lib/errors.ts index 0518db48..70e9e5b9 100644 --- a/lib/errors.ts +++ b/lib/errors.ts @@ -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( +const captureException = Bluebird.promisify( 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) diff --git a/lib/preparser.ts b/lib/preparser.ts new file mode 100644 index 00000000..a072a311 --- /dev/null +++ b/lib/preparser.ts @@ -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]; +} diff --git a/lib/utils/common-flags.ts b/lib/utils/common-flags.ts new file mode 100644 index 00000000..3bccb3fa --- /dev/null +++ b/lib/utils/common-flags.ts @@ -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 = import('@oclif/parser/lib/flags').IBooleanFlag; + +export const application = flags.string({ + char: 'a', + description: 'application name', +}); + +export const device = flags.string({ + char: 'd', + description: 'device UUID', +}); + +export const help: IBooleanFlag = flags.help({ char: 'h' }); + +export const quiet: IBooleanFlag = flags.boolean({ + char: 'q', + description: 'suppress warning messages', + default: false, +}); + +export const verbose: IBooleanFlag = flags.boolean({ + char: 'v', + description: 'produce verbose output', +}); diff --git a/lib/utils/oclif-utils.ts b/lib/utils/oclif-utils.ts index e03d191f..b087aec7 100644 --- a/lib/utils/oclif-utils.ts +++ b/lib/utils/oclif-utils.ts @@ -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 diff --git a/lib/utils/promote.ts b/lib/utils/promote.ts index 635e10de..fcb48a6b 100644 --- a/lib/utils/promote.ts +++ b/lib/utils/promote.ts @@ -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(); diff --git a/package.json b/package.json index 30dff94d..9d79ca85 100644 --- a/package.json +++ b/package.json @@ -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",