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 <paulo@balena.io>
This commit is contained in:
Paulo Castro 2019-04-02 12:26:21 +01:00
parent 13e3e5e8ea
commit abf573fa47
20 changed files with 737 additions and 254 deletions

View File

@ -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',

View File

@ -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<CapitanoCommand | OclifCommand>;
}
export { CommandDefinition as Command };
export { CapitanoCommand, OclifCommand };

View File

@ -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<string> {
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.

View File

@ -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(/-+/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<CapitanoCommand | OclifCommand, string>(
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');
}

View File

@ -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 <name> [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) {}

View File

@ -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();

View File

@ -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();

View File

@ -109,7 +109,7 @@ If you come across any problems or would like to get in touch:
- [envs](#envs)
- [env rm &#60;id&#62;](#env-rm-id)
- [env add &#60;key&#62; [value]](#env-add-key-value)
- [env add &#60;name&#62; [value]](#env-add-name-value)
- [env rename &#60;id&#62; &#60;value&#62;](#env-rename-id-value)
- Tags
@ -633,38 +633,47 @@ confirm non interactively
device
## env add &#60;key&#62; [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 &#60;application&#62;
#### -a, --application APPLICATION
application name
#### --device, -d &#60;device&#62;
#### -d, --device DEVICE
device uuid
device UUID
#### -q, --quiet
suppress warning messages
## env rename &#60;id&#62; &#60;value&#62;
@ -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.

150
lib/actions-oclif/env/add.ts vendored Normal file
View File

@ -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<FlagsDef, ArgsDef>(
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<string[]> {
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;
}

View File

@ -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<string[]> => {
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 <key> [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;

View File

@ -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')

35
lib/actions/help_ts.ts Normal file
View File

@ -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];
}

View File

@ -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)

107
lib/app-common.ts Normal file
View File

@ -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();
}

37
lib/app-oclif.ts Normal file
View File

@ -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);
});
}

86
lib/app.ts Normal file
View File

@ -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);
}

View File

@ -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));
};
}

View File

@ -223,3 +223,50 @@ export function retry<T>(
}
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<T, U = T>(
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;
}
};
}

53
lib/utils/oclif-utils.ts Normal file
View File

@ -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<T>(array: Array<T | undefined>): T[] {
return array.filter((a): a is T => !!a);
}
}

View File

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