From 2ff427fb9077c1e4f23fc9cbe009d70eadf8257c Mon Sep 17 00:00:00 2001 From: Paulo Castro Date: Wed, 28 Aug 2019 19:51:56 +0100 Subject: [PATCH 1/3] 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", From b3bef9e55695401d0faf0da8ae8292877e9854d8 Mon Sep 17 00:00:00 2001 From: Paulo Castro Date: Fri, 6 Sep 2019 14:42:54 +0100 Subject: [PATCH 2/3] Simplify/refactor 'env add' and 'env rm' implementation Change-type: patch Signed-off-by: Paulo Castro --- lib/actions-oclif/env/add.ts | 65 ++++++++++++++++++------------------ lib/actions-oclif/env/rm.ts | 34 +++++++++---------- 2 files changed, 49 insertions(+), 50 deletions(-) diff --git a/lib/actions-oclif/env/add.ts b/lib/actions-oclif/env/add.ts index c5594fa5..9793908d 100644 --- a/lib/actions-oclif/env/add.ts +++ b/lib/actions-oclif/env/add.ts @@ -83,46 +83,45 @@ export default class EnvAddCmd extends Command { const { args: params, flags: options } = this.parse( EnvAddCmd, ); - const Bluebird = await import('bluebird'); + const cmd = this; const balena = (await import('balena-sdk')).fromSharedOptions(); const { exitWithExpectedError } = await import('../../utils/patterns'); - const cmd = this; + if (params.value == null) { + params.value = process.env[params.name]; - await Bluebird.try(async function() { if (params.value == null) { - params.value = process.env[params.name]; - - if (params.value == null) { - throw new Error( - `Environment value not found for variable: ${params.name}`, - ); - } else if (!options.quiet) { - cmd.warn( - `Using ${params.name}=${params.value} from CLI process environment`, - ); - } - } - - const reservedPrefixes = await getReservedPrefixes(); - const isConfigVar = _.some(reservedPrefixes, prefix => - _.startsWith(params.name, prefix), - ); - - if (options.application) { - return balena.models.application[ - isConfigVar ? 'configVar' : 'envVar' - ].set(options.application, params.name, params.value); - } else if (options.device) { - return balena.models.device[isConfigVar ? 'configVar' : 'envVar'].set( - options.device, - params.name, - params.value, + throw new Error( + `Environment value not found for variable: ${params.name}`, + ); + } else if (!options.quiet) { + cmd.warn( + `Using ${params.name}=${params.value} from CLI process environment`, ); - } else { - exitWithExpectedError('You must specify an application or device'); } - }); + } + + const reservedPrefixes = await getReservedPrefixes(); + const isConfigVar = _.some(reservedPrefixes, prefix => + _.startsWith(params.name, prefix), + ); + const varType = isConfigVar ? 'configVar' : 'envVar'; + + if (options.application) { + await balena.models.application[varType].set( + options.application, + params.name, + params.value, + ); + } else if (options.device) { + await balena.models.device[varType].set( + options.device, + params.name, + params.value, + ); + } else { + exitWithExpectedError('You must specify an application or device'); + } } } diff --git a/lib/actions-oclif/env/rm.ts b/lib/actions-oclif/env/rm.ts index a7eb8561..8236f920 100644 --- a/lib/actions-oclif/env/rm.ts +++ b/lib/actions-oclif/env/rm.ts @@ -54,7 +54,7 @@ export default class EnvRmCmd extends Command { { name: 'id', required: true, - description: 'environment variable id', + description: 'environment variable numeric database ID', }, ]; @@ -89,23 +89,23 @@ export default class EnvRmCmd extends Command { ); } - return patterns - .confirm( + try { + await patterns.confirm( options.yes || false, 'Are you sure you want to delete the environment variable?', - ) - .then(function() { - if (options.device) { - return balena.pine.delete({ - resource: 'device_environment_variable', - id: params.id, - }); - } else { - return balena.pine.delete({ - resource: 'application_environment_variable', - id: params.id, - }); - } - }); + ); + } catch (err) { + if (err.message === 'Aborted') { + return patterns.exitWithExpectedError(err); + } + throw err; + } + + await balena.pine.delete({ + resource: options.device + ? 'device_environment_variable' + : 'application_environment_variable', + id: params.id, + }); } } From c07b28e694e06732d88cdb0b8d3fc1705f169e5a Mon Sep 17 00:00:00 2001 From: Paulo Castro Date: Fri, 6 Sep 2019 14:43:53 +0100 Subject: [PATCH 3/3] Migrate 'envs' and 'env rename' commands to oclif Change-type: patch Signed-off-by: Paulo Castro --- automation/capitanodoc/capitanodoc.ts | 3 +- doc/cli.markdown | 52 +++++---- lib/actions-oclif/env/rename.ts | 96 ++++++++++++++++ lib/actions-oclif/envs.ts | 95 ++++++++++++++++ lib/actions/environment-variables.ts | 154 -------------------------- lib/actions/index.coffee | 1 - lib/app-capitano.coffee | 4 - lib/preparser.ts | 8 +- npm-shrinkwrap.json | 26 +++++ package.json | 1 + tests/commands/env/rename.spec.ts | 34 ++++++ tests/commands/help.spec.ts | 4 +- tests/helpers.ts | 45 ++++---- typings/intercept-stdout/index.d.ts | 28 +++++ 14 files changed, 350 insertions(+), 201 deletions(-) create mode 100644 lib/actions-oclif/env/rename.ts create mode 100644 lib/actions-oclif/envs.ts delete mode 100644 lib/actions/environment-variables.ts create mode 100644 tests/commands/env/rename.spec.ts create mode 100644 typings/intercept-stdout/index.d.ts diff --git a/automation/capitanodoc/capitanodoc.ts b/automation/capitanodoc/capitanodoc.ts index 56e2e7b5..2e40bf72 100644 --- a/automation/capitanodoc/capitanodoc.ts +++ b/automation/capitanodoc/capitanodoc.ts @@ -49,8 +49,9 @@ const capitanoDoc = { { title: 'Environment Variables', files: [ - 'build/actions/environment-variables.js', + 'build/actions-oclif/envs.js', 'build/actions-oclif/env/add.js', + 'build/actions-oclif/env/rename.js', 'build/actions-oclif/env/rm.js', ], }, diff --git a/doc/cli.markdown b/doc/cli.markdown index 9e731b02..ceab091b 100644 --- a/doc/cli.markdown +++ b/doc/cli.markdown @@ -572,16 +572,16 @@ confirm non interactively ## envs -Use this command to list the environment variables of an application -or device. +List the environment or config variables of an application or device, +as selected by the respective command-line options. -The --config option is used to list "config" variables that configure -balena features. +The --config option is used to list "configuration variables" that +control balena features. Service-specific variables are not currently supported. The following examples list variables that apply to all services in an app or device. -Example: +Examples: $ balena envs --application MyApp $ balena envs --application MyApp --config @@ -589,18 +589,22 @@ Example: ### Options -#### --application, -a, --app <application> +#### -a, --application APPLICATION application name -#### --device, -d <device> - -device uuid - -#### --config, -c, -v, --verbose +#### -c, --config show config variables +#### -d, --device DEVICE + +device UUID + +#### -v, --verbose + +produce verbose output + ## env rm ID Remove an environment variable from an application or device, as selected @@ -624,7 +628,7 @@ Examples: #### ID -environment variable id +environment variable numeric database ID ### Options @@ -678,12 +682,12 @@ device UUID suppress warning messages -## env rename <id> <value> +## env rename ID VALUE -Use this command to change the value of an application or device -environment variable. - -The --device option selects a device instead of an application. +Change the value of an environment variable for an application or device, +as selected by the '--device' option. The variable is identified by its +database ID, rather than its name. The 'balena envs' command can be used +to list the variable's ID. Service-specific variables are not currently supported. The following examples modify variables that apply to all services in an app or device. @@ -693,11 +697,21 @@ Examples: $ balena env rename 376 emacs $ balena env rename 376 emacs --device +### Arguments + +#### ID + +environment variable numeric database ID + +#### VALUE + +variable value; if omitted, use value from CLI's environment + ### Options -#### --device, -d +#### -d, --device -device +select a device variable instead of an application variable # Tags diff --git a/lib/actions-oclif/env/rename.ts b/lib/actions-oclif/env/rename.ts new file mode 100644 index 00000000..6834a557 --- /dev/null +++ b/lib/actions-oclif/env/rename.ts @@ -0,0 +1,96 @@ +/** + * @license + * Copyright 2016-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 { Command, flags } from '@oclif/command'; +import { stripIndent } from 'common-tags'; + +import * as cf from '../../utils/common-flags'; +import { CommandHelp } from '../../utils/oclif-utils'; + +type IArg = import('@oclif/parser').args.IArg; + +interface FlagsDef { + device: boolean; + help: void; +} + +interface ArgsDef { + id: number; + value: string; +} + +export default class EnvRenameCmd extends Command { + public static description = stripIndent` + Change the value of an environment variable for an app or device. + + Change the value of an environment variable for an application or device, + as selected by the '--device' option. The variable is identified by its + database ID, rather than its name. The 'balena envs' command can be used + to list the variable's ID. + + Service-specific variables are not currently supported. The following + examples modify variables that apply to all services in an app or device. +`; + public static examples = [ + '$ balena env rename 376 emacs', + '$ balena env rename 376 emacs --device', + ]; + + public static args: Array> = [ + { + name: 'id', + required: true, + description: 'environment variable numeric database ID', + parse: input => parseInt(input, 10), + }, + { + name: 'value', + required: true, + description: + "variable value; if omitted, use value from CLI's environment", + }, + ]; + + // hardcoded 'env add' to avoid oclif's 'env:add' topic syntax + public static usage = + 'env rename ' + new CommandHelp({ args: EnvRenameCmd.args }).defaultUsage(); + + public static flags: flags.Input = { + device: flags.boolean({ + char: 'd', + description: + 'select a device variable instead of an application variable', + }), + help: cf.help, + }; + + public async run() { + const { args: params, flags: options } = this.parse( + EnvRenameCmd, + ); + const balena = (await import('balena-sdk')).fromSharedOptions(); + + await balena.pine.patch({ + resource: options.device + ? 'device_environment_variable' + : 'application_environment_variable', + id: params.id, + body: { + value: params.value, + }, + }); + } +} diff --git a/lib/actions-oclif/envs.ts b/lib/actions-oclif/envs.ts new file mode 100644 index 00000000..12b8266e --- /dev/null +++ b/lib/actions-oclif/envs.ts @@ -0,0 +1,95 @@ +/** + * @license + * Copyright 2016-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 { Command, flags } from '@oclif/command'; +import { ApplicationVariable, DeviceVariable } from 'balena-sdk'; +import { stripIndent } from 'common-tags'; +import * as _ from 'lodash'; + +import * as cf from '../utils/common-flags'; +import { CommandHelp } from '../utils/oclif-utils'; + +interface FlagsDef { + application?: string; + config: boolean; + device?: string; + help: void; + verbose: boolean; +} + +export default class EnvsCmd extends Command { + public static description = stripIndent` + List the environment or config variables of an app or device. + + List the environment or config variables of an application or device, + as selected by the respective command-line options. + + The --config option is used to list "configuration variables" that + control balena features. + + Service-specific variables are not currently supported. The following + examples list variables that apply to all services in an app or device. +`; + public static examples = [ + '$ balena envs --application MyApp', + '$ balena envs --application MyApp --config', + '$ balena envs --device 7cf02a6', + ]; + + public static usage = ( + 'envs ' + new CommandHelp({ args: EnvsCmd.args }).defaultUsage() + ).trim(); + + public static flags: flags.Input = { + application: _.assign({ exclusive: ['device'] }, cf.application), + config: flags.boolean({ + char: 'c', + description: 'show config variables', + }), + device: _.assign({ exclusive: ['application'] }, cf.device), + help: cf.help, + verbose: cf.verbose, + }; + + public async run() { + const { flags: options } = this.parse(EnvsCmd); + const balena = (await import('balena-sdk')).fromSharedOptions(); + const visuals = await import('resin-cli-visuals'); + const { exitWithExpectedError } = await import('../utils/patterns'); + const cmd = this; + + let environmentVariables: ApplicationVariable[] | DeviceVariable[]; + if (options.application) { + environmentVariables = await balena.models.application[ + options.config ? 'configVar' : 'envVar' + ].getAllByApplication(options.application); + } else if (options.device) { + environmentVariables = await balena.models.device[ + options.config ? 'configVar' : 'envVar' + ].getAllByDevice(options.device); + } else { + return exitWithExpectedError('You must specify an application or device'); + } + + if (_.isEmpty(environmentVariables)) { + return exitWithExpectedError('No environment variables found'); + } + + cmd.log( + visuals.table.horizontal(environmentVariables, ['id', 'name', 'value']), + ); + } +} diff --git a/lib/actions/environment-variables.ts b/lib/actions/environment-variables.ts deleted file mode 100644 index abda7710..00000000 --- a/lib/actions/environment-variables.ts +++ /dev/null @@ -1,154 +0,0 @@ -/* -Copyright 2016-2017 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 { ApplicationVariable, DeviceVariable } from 'balena-sdk'; -import * as Bluebird from 'bluebird'; -import { CommandDefinition } from 'capitano'; -import { stripIndent } from 'common-tags'; - -import { normalizeUuidProp } from '../utils/normalization'; -import * as commandOptions from './command-options'; - -export const list: CommandDefinition< - {}, - { - application?: string; - device?: string; - config: boolean; - } -> = { - signature: 'envs', - description: 'list all environment variables', - help: stripIndent` - Use this command to list the environment variables of an application - or device. - - The --config option is used to list "config" variables that configure - balena features. - - Service-specific variables are not currently supported. The following - examples list variables that apply to all services in an app or device. - - Example: - - $ balena envs --application MyApp - $ balena envs --application MyApp --config - $ balena envs --device 7cf02a6 - `, - options: [ - commandOptions.optionalApplication, - commandOptions.optionalDevice, - - { - signature: 'config', - description: 'show config variables', - boolean: true, - alias: ['c', 'v', 'verbose'], - }, - ], - permission: 'user', - async action(_params, options, done) { - normalizeUuidProp(options, 'device'); - const _ = await import('lodash'); - const balena = (await import('balena-sdk')).fromSharedOptions(); - const visuals = await import('resin-cli-visuals'); - - const { exitWithExpectedError } = await import('../utils/patterns'); - - return Bluebird.try(function(): Bluebird< - DeviceVariable[] | ApplicationVariable[] - > { - if (options.application) { - return balena.models.application[ - options.config ? 'configVar' : 'envVar' - ].getAllByApplication(options.application); - } else if (options.device) { - return balena.models.device[ - options.config ? 'configVar' : 'envVar' - ].getAllByDevice(options.device); - } else { - return exitWithExpectedError( - 'You must specify an application or device', - ); - } - }) - .tap(function(environmentVariables) { - if (_.isEmpty(environmentVariables)) { - exitWithExpectedError('No environment variables found'); - } - - console.log( - visuals.table.horizontal(environmentVariables, [ - 'id', - 'name', - 'value', - ]), - ); - }) - .nodeify(done); - }, -}; - -export const rename: CommandDefinition< - { - id: number; - value: string; - }, - { - device: boolean; - } -> = { - signature: 'env rename ', - description: 'rename an environment variable', - help: stripIndent` - Use this command to change the value of an application or device - environment variable. - - The --device option selects a device instead of an application. - - Service-specific variables are not currently supported. The following - examples modify variables that apply to all services in an app or device. - - Examples: - - $ balena env rename 376 emacs - $ balena env rename 376 emacs --device - `, - permission: 'user', - options: [commandOptions.booleanDevice], - async action(params, options, done) { - const balena = (await import('balena-sdk')).fromSharedOptions(); - - return Bluebird.try(function() { - if (options.device) { - return balena.pine.patch({ - resource: 'device_environment_variable', - id: params.id, - body: { - value: params.value, - }, - }); - } else { - return balena.pine.patch({ - resource: 'application_environment_variable', - id: params.id, - body: { - value: params.value, - }, - }); - } - }).nodeify(done); - }, -}; diff --git a/lib/actions/index.coffee b/lib/actions/index.coffee index 9ad6c7eb..85504045 100644 --- a/lib/actions/index.coffee +++ b/lib/actions/index.coffee @@ -19,7 +19,6 @@ module.exports = app: require('./app') auth: require('./auth') device: require('./device') - env: require('./environment-variables') tags: require('./tags') keys: require('./keys') logs: require('./logs') diff --git a/lib/app-capitano.coffee b/lib/app-capitano.coffee index a987fe29..5cfb5188 100644 --- a/lib/app-capitano.coffee +++ b/lib/app-capitano.coffee @@ -83,10 +83,6 @@ capitano.command(actions.keys.add) capitano.command(actions.keys.info) capitano.command(actions.keys.remove) -# ---------- Env Module ---------- -capitano.command(actions.env.list) -capitano.command(actions.env.rename) - # ---------- Tags Module ---------- capitano.command(actions.tags.list) capitano.command(actions.tags.set) diff --git a/lib/preparser.ts b/lib/preparser.ts index a072a311..09eac4d9 100644 --- a/lib/preparser.ts +++ b/lib/preparser.ts @@ -127,7 +127,13 @@ function checkDeletedCommand(argvSlice: string[]): void { } } -export const convertedCommands = ['env:add', 'env:rm', 'version']; +export const convertedCommands = [ + 'envs', + 'env:add', + 'env:rename', + 'env:rm', + 'version', +]; /** * Determine whether the CLI command has been converted from Capitano to oclif. diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 72c20a3b..175ed116 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -7394,6 +7394,15 @@ } } }, + "intercept-stdout": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/intercept-stdout/-/intercept-stdout-0.1.2.tgz", + "integrity": "sha1-Emq/H65sUJpCipjGGmMVWQQq6f0=", + "dev": true, + "requires": { + "lodash.toarray": "^3.0.0" + } + }, "interpret": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.2.0.tgz", @@ -8056,6 +8065,12 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.11.tgz", "integrity": "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==" }, + "lodash._arraycopy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lodash._arraycopy/-/lodash._arraycopy-3.0.0.tgz", + "integrity": "sha1-due3wfH7klRzdIeKVi7Qaj5Q9uE=", + "dev": true + }, "lodash._basecopy": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/lodash._basecopy/-/lodash._basecopy-3.0.1.tgz", @@ -8170,6 +8185,17 @@ "lodash._reinterpolate": "~3.0.0" } }, + "lodash.toarray": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/lodash.toarray/-/lodash.toarray-3.0.2.tgz", + "integrity": "sha1-KyBPD6T1HChcbwDIHRzqWiMEEXk=", + "dev": true, + "requires": { + "lodash._arraycopy": "^3.0.0", + "lodash._basevalues": "^3.0.0", + "lodash.keys": "^3.0.0" + } + }, "lodash.uniq": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", diff --git a/package.json b/package.json index 9d79ca85..fcd77d39 100644 --- a/package.json +++ b/package.json @@ -125,6 +125,7 @@ "gulp-coffee": "^2.2.0", "gulp-inline-source": "^2.1.0", "gulp-shell": "^0.5.2", + "intercept-stdout": "^0.1.2", "mocha": "^6.2.0", "nock": "^10.0.6", "parse-link-header": "~1.0.1", diff --git a/tests/commands/env/rename.spec.ts b/tests/commands/env/rename.spec.ts new file mode 100644 index 00000000..fc37020b --- /dev/null +++ b/tests/commands/env/rename.spec.ts @@ -0,0 +1,34 @@ +/** + * @license + * Copyright 2016-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 chai from 'chai'; +import { balenaAPIMock, runCommand } from '../../helpers'; + +describe('balena env rename', function() { + it('should successfully rename an environment variable', async () => { + const mock = balenaAPIMock(); + mock.patch(/device_environment_variable\(376\)/).reply(200, 'OK'); + + const { out, err } = await runCommand('env rename 376 emacs --device'); + + chai.expect(out.join('')).to.equal(''); + chai.expect(err.join('')).to.equal(''); + + // @ts-ignore + mock.remove(); + }); +}); diff --git a/tests/commands/help.spec.ts b/tests/commands/help.spec.ts index 6fb32a36..98505017 100644 --- a/tests/commands/help.spec.ts +++ b/tests/commands/help.spec.ts @@ -59,9 +59,9 @@ Additional commands: device shutdown shutdown a device devices supported list all supported devices env add name [value] add an environment or config variable to an application or device - env rename rename an environment variable + env rename id value change the value of an environment variable for an app or device env rm id remove an environment variable from an application or device - envs list all environment variables + envs list the environment or config variables of an app or device key list a single ssh key key add [path] add a SSH key to balena key rm remove a ssh key diff --git a/tests/helpers.ts b/tests/helpers.ts index 94d57915..4a69fdd6 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -1,28 +1,42 @@ +/** + * @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 intercept = require('intercept-stdout'); import * as nock from 'nock'; import * as path from 'path'; + import * as balenaCLI from '../build/app'; export const runCommand = async (cmd: string) => { const preArgs = [process.argv[0], path.join(process.cwd(), 'bin', 'balena')]; - const oldStdOut = process.stdout.write; - const oldStdErr = process.stderr.write; - const err: string[] = []; const out: string[] = []; - // @ts-ignore - process.stdout.write = (log: string) => { + const stdoutHook = (log: string | Buffer) => { // Skip over debug messages - if (!log.startsWith('[debug]')) { + if (typeof log === 'string' && !log.startsWith('[debug]')) { out.push(log); } - oldStdOut(log); }; - // @ts-ignore - process.stderr.write = (log: string) => { + const stderrHook = (log: string | Buffer) => { // Skip over debug messages if ( + typeof log === 'string' && !log.startsWith('[debug]') && // TODO stop this warning message from appearing when running // sdk.setSharedOptions multiple times in the same process @@ -30,26 +44,19 @@ export const runCommand = async (cmd: string) => { ) { err.push(log); } - oldStdErr(log); }; + const unhookIntercept = intercept(stdoutHook, stderrHook); try { await balenaCLI.run(preArgs.concat(cmd.split(' ')), { noFlush: true, }); - - process.stdout.write = oldStdOut; - process.stderr.write = oldStdErr; - return { err, out, }; - } catch (err) { - process.stdout.write = oldStdOut; - process.stderr.write = oldStdErr; - - throw err; + } finally { + unhookIntercept(); } }; diff --git a/typings/intercept-stdout/index.d.ts b/typings/intercept-stdout/index.d.ts new file mode 100644 index 00000000..37a4b6c0 --- /dev/null +++ b/typings/intercept-stdout/index.d.ts @@ -0,0 +1,28 @@ +/** + * @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. + */ + +declare module 'intercept-stdout' { + type hookFunction = (txt: string) => string | void; + type unhookFunction = () => void; + + function intercept( + stdoutIntercept: hookFunction, + stderrIntercept?: hookFunction, + ): unhookFunction; + + export = intercept; +}