From abf573fa479f08710983c25e60f94509ab4db08d Mon Sep 17 00:00:00 2001 From: Paulo Castro Date: Tue, 2 Apr 2019 12:26:21 +0100 Subject: [PATCH] Begin the transition to oclif with 'balena env add' (fix dropped leading zero in device UUID). This commit is fairly chunky because it adds the oclif dependency for the first time, and refactors the CLI help and docs generation code to accommodate both Capitano and oclif. Change-type: patch Signed-off-by: Paulo Castro --- automation/capitanodoc/capitanodoc.ts | 5 +- automation/capitanodoc/doc-types.d.ts | 25 +++- automation/capitanodoc/index.ts | 41 +++++-- automation/capitanodoc/markdown.ts | 145 ++++++++++++++++------- automation/capitanodoc/utils.ts | 13 +- bin/balena | 3 +- bin/balena-dev | 2 +- doc/cli.markdown | 46 +++++--- lib/actions-oclif/env/add.ts | 150 ++++++++++++++++++++++++ lib/actions/environment-variables.ts | 93 --------------- lib/actions/help.coffee | 11 +- lib/actions/help_ts.ts | 35 ++++++ lib/{app.coffee => app-capitano.coffee} | 74 +----------- lib/app-common.ts | 107 +++++++++++++++++ lib/app-oclif.ts | 37 ++++++ lib/app.ts | 86 ++++++++++++++ lib/errors.ts | 4 +- lib/utils/helpers.ts | 47 ++++++++ lib/utils/oclif-utils.ts | 53 +++++++++ package.json | 14 ++- 20 files changed, 737 insertions(+), 254 deletions(-) create mode 100644 lib/actions-oclif/env/add.ts create mode 100644 lib/actions/help_ts.ts rename lib/{app.coffee => app-capitano.coffee} (67%) create mode 100644 lib/app-common.ts create mode 100644 lib/app-oclif.ts create mode 100644 lib/app.ts create mode 100644 lib/utils/oclif-utils.ts diff --git a/automation/capitanodoc/capitanodoc.ts b/automation/capitanodoc/capitanodoc.ts index 423d5dbb..b599a9fc 100644 --- a/automation/capitanodoc/capitanodoc.ts +++ b/automation/capitanodoc/capitanodoc.ts @@ -48,7 +48,10 @@ const capitanoDoc = { }, { title: 'Environment Variables', - files: ['build/actions/environment-variables.js'], + files: [ + 'build/actions/environment-variables.js', + 'build/actions-oclif/env/add.js', + ], }, { title: 'Tags', diff --git a/automation/capitanodoc/doc-types.d.ts b/automation/capitanodoc/doc-types.d.ts index 536cf6c8..195fa47b 100644 --- a/automation/capitanodoc/doc-types.d.ts +++ b/automation/capitanodoc/doc-types.d.ts @@ -1,4 +1,23 @@ -import { CommandDefinition } from 'capitano'; +/** + * @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 { Command as OclifCommandClass } from '@oclif/command'; +import { CommandDefinition as CapitanoCommand } from 'capitano'; + +type OclifCommand = typeof OclifCommandClass; export interface Document { title: string; @@ -8,7 +27,7 @@ export interface Document { export interface Category { title: string; - commands: CommandDefinition[]; + commands: Array; } -export { CommandDefinition as Command }; +export { CapitanoCommand, OclifCommand }; diff --git a/automation/capitanodoc/index.ts b/automation/capitanodoc/index.ts index d0d08864..150f27d2 100644 --- a/automation/capitanodoc/index.ts +++ b/automation/capitanodoc/index.ts @@ -18,7 +18,7 @@ import * as _ from 'lodash'; import * as path from 'path'; import { getCapitanoDoc } from './capitanodoc'; -import { Category, Document } from './doc-types'; +import { CapitanoCommand, Category, Document, OclifCommand } from './doc-types'; import * as markdown from './markdown'; /** @@ -39,25 +39,40 @@ export async function renderMarkdown(): Promise { commands: [], }; - for (const file of commandCategory.files) { - const actions: any = require(path.join(process.cwd(), file)); - - if (actions.signature) { - category.commands.push(_.omit(actions, 'action')); - } else { - for (const actionName of Object.keys(actions)) { - const actionCommand = actions[actionName]; - category.commands.push(_.omit(actionCommand, 'action')); - } - } + for (const jsFilename of commandCategory.files) { + category.commands.push( + ...(jsFilename.includes('actions-oclif') + ? importOclifCommands(jsFilename) + : importCapitanoCommands(jsFilename)), + ); } - result.categories.push(category); } return markdown.render(result); } +function importCapitanoCommands(jsFilename: string): CapitanoCommand[] { + const actions = require(path.join(process.cwd(), jsFilename)); + const commands: CapitanoCommand[] = []; + + if (actions.signature) { + commands.push(_.omit(actions, 'action')); + } else { + for (const actionName of Object.keys(actions)) { + const actionCommand = actions[actionName]; + commands.push(_.omit(actionCommand, 'action')); + } + } + return commands; +} + +function importOclifCommands(jsFilename: string): OclifCommand[] { + const command: OclifCommand = require(path.join(process.cwd(), jsFilename)) + .default as OclifCommand; + return [command]; +} + /** * Print the CLI docs markdown to stdout. * See package.json for how the output is redirected to a file. diff --git a/automation/capitanodoc/markdown.ts b/automation/capitanodoc/markdown.ts index 20b4b6c9..808e309d 100644 --- a/automation/capitanodoc/markdown.ts +++ b/automation/capitanodoc/markdown.ts @@ -14,81 +14,136 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { flagUsages } from '@oclif/parser'; import * as ent from 'ent'; import * as _ from 'lodash'; -import { Category, Command, Document } from './doc-types'; +import { getManualSortCompareFunction } from '../../lib/utils/helpers'; +import { CapitanoCommand, Category, Document, OclifCommand } from './doc-types'; import * as utils from './utils'; -export function renderCommand(command: Command) { - let result = `## ${ent.encode(command.signature)}\n\n${command.help}\n`; +function renderCapitanoCommand(command: CapitanoCommand): string[] { + const result = [`## ${ent.encode(command.signature)}`, command.help]; if (!_.isEmpty(command.options)) { - result += '\n### Options'; + result.push('### Options'); for (const option of command.options!) { - result += `\n\n#### ${utils.parseSignature(option)}\n\n${ - option.description - }`; + result.push( + `#### ${utils.parseCapitanoOption(option)}`, + option.description, + ); } - - result += '\n'; } - return result; } -export function renderCategory(category: Category) { - let result = `# ${category.title}\n`; +function renderOclifCommand(command: OclifCommand): string[] { + const result = [`## ${ent.encode(command.usage)}`]; + const description = (command.description || '') + .split('\n') + .slice(1) // remove the first line, which oclif uses as help header + .join('\n') + .trim(); + result.push(description); + if (!_.isEmpty(command.examples)) { + result.push('Examples:', command.examples!.map(v => `\t${v}`).join('\n')); + } + + if (!_.isEmpty(command.args)) { + result.push('### Arguments'); + for (const arg of command.args!) { + result.push(`#### ${arg.name.toUpperCase()}`, arg.description || ''); + } + } + + if (!_.isEmpty(command.flags)) { + result.push('### Options'); + for (const [name, flag] of Object.entries(command.flags!)) { + if (name === 'help') { + continue; + } + flag.name = name; + const flagUsage = flagUsages([flag]) + .map(([usage, _description]) => usage) + .join() + .trim(); + result.push(`#### ${flagUsage}`); + result.push(flag.description || ''); + } + } + return result; +} + +function renderCategory(category: Category): string[] { + const result = [`# ${category.title}`]; for (const command of category.commands) { - result += `\n${renderCommand(command)}`; + result.push( + ...(typeof command === 'object' + ? renderCapitanoCommand(command) + : renderOclifCommand(command)), + ); } - return result; } -function getAnchor(command: Command) { - return ( - '#' + - command.signature - .replace(/\s/g, '-') - .replace(//g, '-') - .replace(/\[/g, '-') - .replace(/\]/g, '-') - .replace(/-+/g, '-') - .replace(/-$/, '') - .replace(/\.\.\./g, '') - .replace(/\|/g, '') - .toLowerCase() - ); +function getAnchor(cmdSignature: string): string { + return `#${_.trim(cmdSignature.replace(/\W+/g, '-'), '-').toLowerCase()}`; } -export function renderToc(categories: Category[]) { - let result = `# CLI Command Reference\n`; +function renderToc(categories: Category[]): string[] { + const result = [`# CLI Command Reference`]; for (const category of categories) { - result += `\n- ${category.title}\n\n`; + result.push(`- ${category.title}`); + result.push( + category.commands + .map(command => { + const signature = + typeof command === 'object' + ? command.signature // Capitano + : utils.capitanoizeOclifUsage(command.usage); // oclif + return `\t- [${ent.encode(signature)}](${getAnchor(signature)})`; + }) + .join('\n'), + ); + } + return result; +} - for (const command of category.commands) { - result += `\t- [${ent.encode(command.signature)}](${getAnchor( - command, - )})\n`; +const manualCategorySorting: { [category: string]: string[] } = { + 'Environment Variables': ['envs', 'env rm', 'env add', 'env rename'], +}; + +function sortCommands(doc: Document): void { + for (const category of doc.categories) { + if (category.title in manualCategorySorting) { + category.commands = category.commands.sort( + getManualSortCompareFunction( + 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), + ), + ); } } - - return result; } export function render(doc: Document) { - let result = `# ${doc.title}\n\n${doc.introduction}\n\n${renderToc( - doc.categories, - )}`; - + sortCommands(doc); + const result = [ + `# ${doc.title}`, + doc.introduction, + ...renderToc(doc.categories), + ]; for (const category of doc.categories) { - result += `\n${renderCategory(category)}`; + result.push(...renderCategory(category)); } - - return result; + return result.join('\n\n'); } diff --git a/automation/capitanodoc/utils.ts b/automation/capitanodoc/utils.ts index 060cf6f8..2eca5c7b 100644 --- a/automation/capitanodoc/utils.ts +++ b/automation/capitanodoc/utils.ts @@ -14,6 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { OptionDefinition } from 'capitano'; import * as ent from 'ent'; import * as fs from 'fs'; @@ -32,7 +33,7 @@ export function getOptionSignature(signature: string) { return `${getOptionPrefix(signature)}${signature}`; } -export function parseSignature(option: OptionDefinition) { +export function parseCapitanoOption(option: OptionDefinition): string { let result = getOptionSignature(option.signature); if (_.isArray(option.alias)) { @@ -50,6 +51,16 @@ export function parseSignature(option: OptionDefinition) { return ent.encode(result); } +/** Convert e.g. 'env add NAME [VALUE]' to 'env add [value]' */ +export function capitanoizeOclifUsage( + oclifUsage: string | string[] | undefined, +): string { + return (oclifUsage || '') + .toString() + .replace(/(?<=\s)[A-Z]+(?=(\s|$))/g, match => `<${match}>`) + .toLowerCase(); +} + export class MarkdownFileParser { constructor(public mdFilePath: string) {} diff --git a/bin/balena b/bin/balena index cc67e77c..69f81d46 100755 --- a/bin/balena +++ b/bin/balena @@ -8,4 +8,5 @@ process.env.UV_THREADPOOL_SIZE = '64'; require('fast-boot2').start({ cacheFile: __dirname + '/.fast-boot.json' }) -require('../build/app'); +// Run the CLI +require('../build/app').run(); diff --git a/bin/balena-dev b/bin/balena-dev index bcd1dd2d..2b0dfdb4 100755 --- a/bin/balena-dev +++ b/bin/balena-dev @@ -20,4 +20,4 @@ require('coffeescript/register'); // it is supposed to run faster. We still benefit from type checking when // running 'npm run build'. require('ts-node/register/transpile-only'); -require('../lib/app'); +require('../lib/app').run(); diff --git a/doc/cli.markdown b/doc/cli.markdown index a2b4196e..62419945 100644 --- a/doc/cli.markdown +++ b/doc/cli.markdown @@ -109,7 +109,7 @@ If you come across any problems or would like to get in touch: - [envs](#envs) - [env rm <id>](#env-rm-id) - - [env add <key> [value]](#env-add-key-value) + - [env add <name> [value]](#env-add-name-value) - [env rename <id> <value>](#env-rename-id-value) - Tags @@ -633,38 +633,47 @@ confirm non interactively device -## env add <key> [value] +## env add NAME [VALUE] -Use this command to add an enviroment or config variable to an application -or device. +Add an enviroment or config variable to an application or device, as selected +by the respective command-line options. -If value is omitted, the tool will attempt to use the variable's value -as defined in your host machine. +If VALUE is omitted, the CLI will attempt to use the value of the environment +variable of same name in the CLI process' environment. In this case, a warning +message will be printed. Use `--quiet` to suppress it. -Use the `--device` option if you want to assign the environment variable -to a specific device. - -If the value is grabbed from the environment, a warning message will be printed. -Use `--quiet` to remove it. - -Service-specific variables are not currently supported. The following -examples set variables that apply to all services in an app or device. +Service-specific variables are not currently supported. The given command line +examples variables that apply to all services in an app or device. Examples: - $ balena env add EDITOR vim --application MyApp $ balena env add TERM --application MyApp + $ balena env add EDITOR vim --application MyApp $ balena env add EDITOR vim --device 7cf02a6 +### Arguments + +#### NAME + +environment or config variable name + +#### VALUE + +variable value; if omitted, use value from CLI's enviroment + ### Options -#### --application, -a, --app <application> +#### -a, --application APPLICATION application name -#### --device, -d <device> +#### -d, --device DEVICE -device uuid +device UUID + +#### -q, --quiet + +suppress warning messages ## env rename <id> <value> @@ -2082,4 +2091,3 @@ Examples: Use this command to list your machine's drives usable for writing the OS image to. Skips the system drives. - diff --git a/lib/actions-oclif/env/add.ts b/lib/actions-oclif/env/add.ts new file mode 100644 index 00000000..00e1d96f --- /dev/null +++ b/lib/actions-oclif/env/add.ts @@ -0,0 +1,150 @@ +/** + * @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 { Command, flags } from '@oclif/command'; +import { stripIndent } from 'common-tags'; + +import { CommandHelp } from '../../utils/oclif-utils'; + +interface FlagsDef { + application?: string; + device?: string; + help: void; + quiet: boolean; +} + +interface ArgsDef { + name: string; + value?: string; +} + +export default class EnvAddCmd extends Command { + public static description = stripIndent` + Add an enviroment or config variable to an application or device. + + Add an enviroment or config variable to an application or device, as selected + by the respective command-line options. + + If VALUE is omitted, the CLI will attempt to use the value of the environment + variable of same name in the CLI process' environment. In this case, a warning + message will be printed. Use \`--quiet\` to suppress it. + + Service-specific variables are not currently supported. The given command line + examples variables that apply to all services in an app or device. +`; + public static examples = [ + '$ balena env add TERM --application MyApp', + '$ balena env add EDITOR vim --application MyApp', + '$ balena env add EDITOR vim --device 7cf02a6', + ]; + + public static args = [ + { + name: 'name', + required: true, + description: 'environment or config variable name', + }, + { + name: 'value', + required: false, + description: + "variable value; if omitted, use value from CLI's enviroment", + }, + ]; + + // hardcoded 'env add' to avoid oclif's 'env:add' topic syntax + public static usage = + 'env add ' + new CommandHelp({ args: EnvAddCmd.args }).defaultUsage(); + + public static flags = { + 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, + }), + }; + + public async run() { + const { args: params, flags: options } = this.parse( + EnvAddCmd, + ); + const Bluebird = await import('bluebird'); + const _ = await import('lodash'); + const balena = (await import('balena-sdk')).fromSharedOptions(); + const { exitWithExpectedError } = await import('../../utils/patterns'); + + const cmd = this; + + 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, + ); + } else { + exitWithExpectedError('You must specify an application or device'); + } + }); + } +} + +async function getReservedPrefixes(): Promise { + const balena = (await import('balena-sdk')).fromSharedOptions(); + const settings = await balena.settings.getAll(); + + const response = await balena.request.send({ + baseUrl: settings.apiUrl, + url: '/config/vars', + }); + + return response.body.reservedNamespaces; +} diff --git a/lib/actions/environment-variables.ts b/lib/actions/environment-variables.ts index 32ea05cf..4bbcb810 100644 --- a/lib/actions/environment-variables.ts +++ b/lib/actions/environment-variables.ts @@ -13,7 +13,6 @@ 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'; @@ -22,18 +21,6 @@ import { stripIndent } from 'common-tags'; import { normalizeUuidProp } from '../utils/normalization'; import * as commandOptions from './command-options'; -const getReservedPrefixes = async (): Promise => { - const balena = (await import('balena-sdk')).fromSharedOptions(); - const settings = await balena.settings.getAll(); - - const response = await balena.request.send({ - baseUrl: settings.apiUrl, - url: '/config/vars', - }); - - return response.body.reservedNamespaces; -}; - export const list: CommandDefinition< {}, { @@ -171,86 +158,6 @@ export const remove: CommandDefinition< }, }; -export const add: CommandDefinition< - { - key: string; - value?: string; - }, - { - application?: string; - device?: string; - } -> = { - signature: 'env add [value]', - description: 'add an environment or config variable', - help: stripIndent` - Use this command to add an enviroment or config variable to an application - or device. - - If value is omitted, the tool will attempt to use the variable's value - as defined in your host machine. - - Use the \`--device\` option if you want to assign the environment variable - to a specific device. - - If the value is grabbed from the environment, a warning message will be printed. - Use \`--quiet\` to remove it. - - Service-specific variables are not currently supported. The following - examples set variables that apply to all services in an app or device. - - Examples: - - $ balena env add EDITOR vim --application MyApp - $ balena env add TERM --application MyApp - $ balena env add EDITOR vim --device 7cf02a6 - `, - options: [commandOptions.optionalApplication, commandOptions.optionalDevice], - permission: 'user', - async action(params, options, done) { - normalizeUuidProp(options, 'device'); - const _ = await import('lodash'); - const balena = (await import('balena-sdk')).fromSharedOptions(); - - const { exitWithExpectedError } = await import('../utils/patterns'); - - return Bluebird.try(async function() { - if (params.value == null) { - params.value = process.env[params.key]; - - if (params.value == null) { - throw new Error(`Environment value not found for key: ${params.key}`); - } else { - console.info( - `Warning: using ${params.key}=${ - params.value - } from host environment`, - ); - } - } - - const reservedPrefixes = await getReservedPrefixes(); - const isConfigVar = _.some(reservedPrefixes, prefix => - _.startsWith(params.key, prefix), - ); - - if (options.application) { - return balena.models.application[ - isConfigVar ? 'configVar' : 'envVar' - ].set(options.application, params.key, params.value); - } else if (options.device) { - return balena.models.device[isConfigVar ? 'configVar' : 'envVar'].set( - options.device, - params.key, - params.value, - ); - } else { - exitWithExpectedError('You must specify an application or device'); - } - }).nodeify(done); - }, -}; - export const rename: CommandDefinition< { id: number; diff --git a/lib/actions/help.coffee b/lib/actions/help.coffee index adb5e57d..c57fc765 100644 --- a/lib/actions/help.coffee +++ b/lib/actions/help.coffee @@ -17,11 +17,13 @@ limitations under the License. _ = require('lodash') capitano = require('capitano') columnify = require('columnify') + messages = require('../utils/messages') { exitWithExpectedError } = require('../utils/patterns') +{ getOclifHelpLinePairs } = require('./help_ts') parse = (object) -> - return _.fromPairs _.map(object, (item) -> + return _.map object, (item) -> # Hacky way to determine if an object is # a function or a command @@ -33,14 +35,15 @@ parse = (object) -> return [ signature item.description - ]).sort() + ] indent = (text) -> text = _.map text.split('\n'), (line) -> return ' ' + line return text.join('\n') -print = (data) -> +print = (usageDescriptionPairs...) -> + data = _.fromPairs([].concat(usageDescriptionPairs...).sort()) console.log indent columnify data, showHeaders: false minWidth: 35 @@ -64,7 +67,7 @@ general = (params, options, done) -> if options.verbose console.log('\nAdditional commands:\n') - print(parse(groupedCommands.secondary)) + print(parse(groupedCommands.secondary), getOclifHelpLinePairs()) else console.log('\nRun `balena help --verbose` to list additional commands') diff --git a/lib/actions/help_ts.ts b/lib/actions/help_ts.ts new file mode 100644 index 00000000..172e7550 --- /dev/null +++ b/lib/actions/help_ts.ts @@ -0,0 +1,35 @@ +/** + * @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 { Command } from '@oclif/command'; +import * as _ from 'lodash'; + +import EnvAddCmd from '../actions-oclif/env/add'; + +export function getOclifHelpLinePairs(): [[string, string]] { + return [getCmdUsageDescriptionLinePair(EnvAddCmd)]; +} + +function getCmdUsageDescriptionLinePair(cmd: typeof Command): [string, string] { + const usage = (cmd.usage || '').toString().toLowerCase(); + let description = ''; + const matches = /\s*(.+?)\n.*/s.exec(cmd.description || ''); + if (matches && matches.length > 1) { + description = _.lowerFirst(_.trimEnd(matches[1], '.')); + } + return [usage, description]; +} diff --git a/lib/app.coffee b/lib/app-capitano.coffee similarity index 67% rename from lib/app.coffee rename to lib/app-capitano.coffee index 99083be8..5697b9f6 100644 --- a/lib/app.coffee +++ b/lib/app-capitano.coffee @@ -1,5 +1,5 @@ ### -Copyright 2016-2017 Balena +Copyright 2016-2019 Balena Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,77 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. ### -Raven = require('raven') -Raven.disableConsoleAlerts() -Raven.config require('./config').sentryDsn, - captureUnhandledRejections: true, - autoBreadcrumbs: true, - release: require('../package.json').version -.install (logged, error) -> - console.error(error) - process.exit(1) -Raven.setContext - extra: - args: process.argv - node_version: process.version - -validNodeVersions = require('../package.json').engines.node -if not require('semver').satisfies(process.version, validNodeVersions) - console.warn """ - Warning: this version of Node does not match the requirements of this package. - This package expects #{validNodeVersions}, but you're using #{process.version}. - This may cause unexpected behaviour. - - To upgrade your Node, visit https://nodejs.org/en/download/ - - """ - - -# Doing this before requiring any other modules, -# including the 'balena-sdk', to prevent any module from reading the http proxy config -# before us -globalTunnel = require('global-tunnel-ng') -settings = require('balena-settings-client') -try - proxy = settings.get('proxy') or null -catch - proxy = null -# Init the tunnel even if the proxy is not configured -# because it can also get the proxy from the http(s)_proxy env var -# If that is not set as well the initialize will do nothing -globalTunnel.initialize(proxy) - -# TODO: make this a feature of capitano https://github.com/balena-io/capitano/issues/48 -global.PROXY_CONFIG = globalTunnel.proxyConfig - Promise = require('bluebird') capitano = require('capitano') -capitanoExecuteAsync = Promise.promisify(capitano.execute) - -# We don't yet use balena-sdk directly everywhere, but we set up shared -# options correctly so we can do safely in submodules -BalenaSdk = require('balena-sdk') -BalenaSdk.setSharedOptions( - apiUrl: settings.get('apiUrl') - imageMakerUrl: settings.get('imageMakerUrl') - dataDirectory: settings.get('dataDirectory') - retries: 2 -) - actions = require('./actions') -errors = require('./errors') events = require('./events') -update = require('./utils/update') -{ exitIfNotLoggedIn } = require('./utils/patterns') - -# Assign bluebird as the global promise library -# stream-to-promise will produce native promises if not -# for this module, which could wreak havoc in this -# bluebird-only codebase. -require('any-promise/register/bluebird') capitano.permission 'user', (done) -> - exitIfNotLoggedIn() + require('./utils/patterns').exitIfNotLoggedIn() .then(done, done) capitano.command @@ -147,7 +83,6 @@ capitano.command(actions.keys.remove) # ---------- Env Module ---------- capitano.command(actions.env.list) -capitano.command(actions.env.add) capitano.command(actions.env.rename) capitano.command(actions.env.remove) @@ -216,14 +151,13 @@ capitano.command(actions.push.push) capitano.command(actions.join.join) capitano.command(actions.leave.leave) -update.notify() - cli = capitano.parse(process.argv) runCommand = -> + capitanoExecuteAsync = Promise.promisify(capitano.execute) if cli.global?.help capitanoExecuteAsync(command: "help #{cli.command ? ''}") else capitanoExecuteAsync(cli) Promise.all([events.trackCommand(cli), runCommand()]) -.catch(errors.handle) +.catch(require('./errors').handleError) diff --git a/lib/app-common.ts b/lib/app-common.ts new file mode 100644 index 00000000..a3589c4d --- /dev/null +++ b/lib/app-common.ts @@ -0,0 +1,107 @@ +/** + * @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. + */ + +/** + * Sentry.io setup + * @see https://docs.sentry.io/clients/node/ + */ +function setupRaven() { + const Raven = require('raven'); + Raven.disableConsoleAlerts(); + Raven.config(require('./config').sentryDsn, { + captureUnhandledRejections: true, + autoBreadcrumbs: true, + release: require('../package.json').version, + }).install(function(_logged: any, error: Error) { + console.error(error); + return process.exit(1); + }); + + Raven.setContext({ + extra: { + args: process.argv, + node_version: process.version, + }, + }); +} + +function checkNodeVersion() { + const validNodeVersions = require('../package.json').engines.node; + if (!require('semver').satisfies(process.version, validNodeVersions)) { + const { stripIndent } = require('common-tags'); + console.warn(stripIndent` + ------------------------------------------------------------------------------ + Warning: Node version "${ + process.version + }" does not match required versions "${validNodeVersions}". + This may cause unexpected behaviour. To upgrade Node, visit: + https://nodejs.org/en/download/ + ------------------------------------------------------------------------------ + `); + } +} + +function setupGlobalHttpProxy() { + // Doing this before requiring any other modules, + // including the 'balena-sdk', to prevent any module from reading the http proxy config + // before us + const globalTunnel = require('global-tunnel-ng'); + const settings = require('balena-settings-client'); + let proxy; + try { + proxy = settings.get('proxy') || null; + } catch (error1) { + proxy = null; + } + + // Init the tunnel even if the proxy is not configured + // because it can also get the proxy from the http(s)_proxy env var + // If that is not set as well the initialize will do nothing + globalTunnel.initialize(proxy); + + // TODO: make this a feature of capitano https://github.com/balena-io/capitano/issues/48 + (global as any).PROXY_CONFIG = globalTunnel.proxyConfig; +} + +function setupBalenaSdkSharedOptions() { + // We don't yet use balena-sdk directly everywhere, but we set up shared + // options correctly so we can do safely in submodules + const BalenaSdk = require('balena-sdk'); + const settings = require('balena-settings-client'); + BalenaSdk.setSharedOptions({ + apiUrl: settings.get('apiUrl'), + imageMakerUrl: settings.get('imageMakerUrl'), + dataDirectory: settings.get('dataDirectory'), + retries: 2, + }); +} + +export function globalInit() { + setupRaven(); + checkNodeVersion(); + setupGlobalHttpProxy(); + setupBalenaSdkSharedOptions(); + + // Assign bluebird as the global promise library. + // stream-to-promise will produce native promises if not for this module, + // which is likely to lead to errors as much of the CLI coffeescript code + // expects bluebird promises. + require('any-promise/register/bluebird'); + + // check for CLI updates once a day + require('./utils/update').notify(); +} diff --git a/lib/app-oclif.ts b/lib/app-oclif.ts new file mode 100644 index 00000000..5ade084b --- /dev/null +++ b/lib/app-oclif.ts @@ -0,0 +1,37 @@ +/** + * @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 { ExitError } from '@oclif/errors'; + +import { handleError } from './errors'; + +/** + * oclif CLI entrypoint + */ +export function run(argv: string[]) { + process.argv = argv; + require('@oclif/command') + .run() + .then(require('@oclif/command/flush')) + .catch((error: Error) => { + // oclif sometimes exits with ExitError code 0 (not an error) + if (error instanceof ExitError && error.oclif.exit === 0) { + return; + } + handleError(error); + }); +} diff --git a/lib/app.ts b/lib/app.ts new file mode 100644 index 00000000..78c7864b --- /dev/null +++ b/lib/app.ts @@ -0,0 +1,86 @@ +/** + * @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. + */ + +/** + * Simple command-line pre-parsing to choose between oclif or Capitano. + * @param argv process.argv + */ +function routeCliFramework(argv: string[]): void { + if (process.env.DEBUG) { + console.log( + `Debug: original argv0="${process.argv0}" argv=[${argv}] length=${ + argv.length + }`, + ); + } + const cmdSlice = argv.slice(2); + let isOclif = false; + + if (cmdSlice.length > 1) { + // convert e.g. 'balena help env add' to 'balena env add --help' + if (cmdSlice[0] === 'help') { + cmdSlice.shift(); + cmdSlice.push('--help'); + } + // Look for commands that have been transitioned to oclif + isOclif = isOclifCommand(cmdSlice); + if (isOclif) { + // convert space-separated commands to oclif's topic:command syntax + argv = [ + argv[0], + argv[1], + cmdSlice[0] + ':' + cmdSlice[1], + ...cmdSlice.slice(2), + ]; + } + } + if (isOclif) { + if (process.env.DEBUG) { + console.log(`Debug: oclif new argv=[${argv}] length=${argv.length}`); + } + require('./app-oclif').run(argv); + } else { + require('./app-capitano'); + } +} + +/** + * Determine whether the CLI command has been converted from Capitano to ocif. + * @param argvSlice process.argv.slice(2) + */ +function isOclifCommand(argvSlice: string[]): boolean { + // Look for commands that have been transitioned to oclif + if (argvSlice.length > 1) { + // balena env add + if (argvSlice[0] === 'env' && argvSlice[1] === 'add') { + return true; + } + } + return false; +} + +/** + * CLI entrypoint, but see also `bin/balena` and `bin/balena-dev` which + * call this function. + */ +export function run(): void { + // 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(); + routeCliFramework(process.argv); +} diff --git a/lib/errors.ts b/lib/errors.ts index 728b65ec..e68c3338 100644 --- a/lib/errors.ts +++ b/lib/errors.ts @@ -104,7 +104,7 @@ const messages: { $ balena login`, }; -exports.handle = function(error: any) { +export function handleError(error: any) { let message = interpret(error); if (message == null) { return; @@ -122,4 +122,4 @@ exports.handle = function(error: any) { // Ignore any errors (from error logging, or timeouts) }) .finally(() => process.exit(error.exitCode || 1)); -}; +} diff --git a/lib/utils/helpers.ts b/lib/utils/helpers.ts index a387a488..7d0d96b7 100644 --- a/lib/utils/helpers.ts +++ b/lib/utils/helpers.ts @@ -223,3 +223,50 @@ export function retry( } return promise; } + +/** + * Return a compare(a, b) function suitable for use as the argument for the + * sort() method of an array. That function will use the given manuallySortedArray + * as "sorting guidance": + * - If both a and b are found in the manuallySortedArray, the returned + * compare(a, b) function will follow that ordering. + * - If neither a nor b are found in the manuallySortedArray, the returned + * compare(a, b) function will compare a and b using the standard '<' and + * '>' Javascript operators. + * - If only a or only b are found in the manuallySortedArray, the returned + * compare(a, b) function will consider the found element as being + * "smaller than" the not-found element (i.e. found elements appeare before + * not-found elements in sorted order). + * + * The equalityFunc() argument is a function used to compare the array items + * against the manuallySortedArray. For example, if equalityFunc was (a, x) => + * a.startsWith(x), where a is an item being sorted and x is an item in the + * manuallySortedArray, then the manuallySortedArray could contain prefix + * substrings to guide the sorting. + * + * @param manuallySortedArray A pre-sorted array to guide the sorting + * @param equalityFunc An optional function used to compare the items being + * sorted against items in manuallySortedArray. It should return true if + * the two items compare equal, otherwise false. The arguments are the + * same as provided by the standard Javascript array.findIndex() method. + */ +export function getManualSortCompareFunction( + manuallySortedArray: U[], + equalityFunc: (a: T, x: U, index: number, array: U[]) => boolean, +): (a: T, b: T) => number { + return function(a: T, b: T): number { + const indexA = manuallySortedArray.findIndex((x, index, array) => + equalityFunc(a, x, index, array), + ); + const indexB = manuallySortedArray.findIndex((x, index, array) => + equalityFunc(b, x, index, array), + ); + if (indexA >= 0 && indexB >= 0) { + return indexA - indexB; + } else if (indexA < 0 && indexB < 0) { + return a < b ? -1 : a > b ? 1 : 0; + } else { + return indexA < 0 ? 1 : -1; + } + }; +} diff --git a/lib/utils/oclif-utils.ts b/lib/utils/oclif-utils.ts new file mode 100644 index 00000000..e507cffe --- /dev/null +++ b/lib/utils/oclif-utils.ts @@ -0,0 +1,53 @@ +/** + * @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 Config from '@oclif/config'; + +export const convertedCommands = { + 'env:add': 'env add', +}; + +/** + * This class is a partial copy-and-paste of + * @oclif/plugin-help/command/CommandHelp, which is used to generate oclif's + * command help output. + */ +export class CommandHelp { + constructor(public command: { args: any[] }) {} + + protected arg(arg: Config.Command['args'][0]): string { + const name = arg.name.toUpperCase(); + if (arg.required) { + return `${name}`; + } + return `[${name}]`; + } + + public defaultUsage(): string { + return CommandHelp.compact([ + // this.command.id, + this.command.args + .filter(a => !a.hidden) + .map(a => this.arg(a)) + .join(' '), + ]).join(' '); + } + + public static compact(array: Array): T[] { + return array.filter((a): a is T => !!a); + } +} diff --git a/package.json b/package.json index 44b8e200..a2f3caaf 100644 --- a/package.json +++ b/package.json @@ -62,13 +62,22 @@ "engines": { "node": ">=8.0" }, + "oclif": { + "bin": "balena", + "commands": "./build/actions-oclif", + "macos": { + "identifier": "io.balena.cli" + } + }, "devDependencies": { + "@oclif/dev-cli": "^1.22.0", + "@oclif/config": "^1.12.12", + "@oclif/parser": "^3.7.3", "@types/archiver": "2.1.2", "@types/bluebird": "3.5.21", "@types/chokidar": "^1.7.5", "@types/common-tags": "1.4.0", "@types/dockerode": "2.5.5", - "@types/es6-promise": "0.0.32", "@types/fs-extra": "5.0.4", "@types/is-root": "1.0.0", "@types/lodash": "4.14.112", @@ -104,6 +113,8 @@ "typescript": "3.4.3" }, "dependencies": { + "@oclif/command": "^1.5.12", + "@oclif/errors": "^1.2.2", "@resin.io/valid-email": "^0.1.0", "@zeit/dockerignore": "0.0.3", "JSONStream": "^1.0.3", @@ -156,6 +167,7 @@ "moment-duration-format": "~2.2.2", "mz": "^2.6.0", "node-cleanup": "^2.1.2", + "oclif": "^1.13.1", "opn": "^5.5.0", "prettyjson": "^1.1.3", "progress-stream": "^2.0.0",