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",