Merge pull request from balena-io/1391-refactor-oclif

Migrate "env rename" and "envs" to oclif and refactor the preparser
This commit is contained in:
Paulo Castro 2019-09-18 14:11:14 +01:00 committed by GitHub
commit a6f329750c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 675 additions and 445 deletions

@ -49,8 +49,9 @@ const capitanoDoc = {
{
title: 'Environment Variables',
files: [
'build/actions/environment-variables.js',
'build/actions-oclif/envs.js',
'build/actions-oclif/env/add.js',
'build/actions-oclif/env/rename.js',
'build/actions-oclif/env/rm.js',
],
},

@ -572,16 +572,16 @@ confirm non interactively
## envs
Use this command to list the environment variables of an application
or device.
List the environment or config variables of an application or device,
as selected by the respective command-line options.
The --config option is used to list "config" variables that configure
balena features.
The --config option is used to list "configuration variables" that
control balena features.
Service-specific variables are not currently supported. The following
examples list variables that apply to all services in an app or device.
Example:
Examples:
$ balena envs --application MyApp
$ balena envs --application MyApp --config
@ -589,18 +589,22 @@ Example:
### Options
#### --application, -a, --app <application>
#### -a, --application APPLICATION
application name
#### --device, -d <device>
device uuid
#### --config, -c, -v, --verbose
#### -c, --config
show config variables
#### -d, --device DEVICE
device UUID
#### -v, --verbose
produce verbose output
## env rm ID
Remove an environment variable from an application or device, as selected
@ -624,7 +628,7 @@ Examples:
#### ID
environment variable id
environment variable numeric database ID
### Options
@ -678,12 +682,12 @@ device UUID
suppress warning messages
## env rename <id> <value>
## env rename ID VALUE
Use this command to change the value of an application or device
environment variable.
The --device option selects a device instead of an application.
Change the value of an environment variable for an application or device,
as selected by the '--device' option. The variable is identified by its
database ID, rather than its name. The 'balena envs' command can be used
to list the variable's ID.
Service-specific variables are not currently supported. The following
examples modify variables that apply to all services in an app or device.
@ -693,11 +697,21 @@ Examples:
$ balena env rename 376 emacs
$ balena env rename 376 emacs --device
### Arguments
#### ID
environment variable numeric database ID
#### VALUE
variable value; if omitted, use value from CLI's environment
### Options
#### --device, -d
#### -d, --device
device
select a device variable instead of an application variable
# Tags

@ -17,7 +17,9 @@
import { Command, flags } from '@oclif/command';
import { stripIndent } from 'common-tags';
import * as _ from 'lodash';
import * as cf from '../../utils/common-flags';
import { CommandHelp } from '../../utils/oclif-utils';
interface FlagsDef {
@ -71,69 +73,55 @@ export default class EnvAddCmd extends Command {
'env add ' + new CommandHelp({ args: EnvAddCmd.args }).defaultUsage();
public static flags: flags.Input<FlagsDef> = {
application: flags.string({
char: 'a',
description: 'application name',
exclusive: ['device'],
}),
device: flags.string({
char: 'd',
description: 'device UUID',
exclusive: ['application'],
}),
help: flags.help({ char: 'h' }),
quiet: flags.boolean({
char: 'q',
description: 'suppress warning messages',
default: false,
}),
application: _.assign({ exclusive: ['device'] }, cf.application),
device: _.assign({ exclusive: ['application'] }, cf.device),
help: cf.help,
quiet: cf.quiet,
};
public async run() {
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
EnvAddCmd,
);
const Bluebird = await import('bluebird');
const _ = await import('lodash');
const cmd = this;
const balena = (await import('balena-sdk')).fromSharedOptions();
const { exitWithExpectedError } = await import('../../utils/patterns');
const cmd = this;
if (params.value == null) {
params.value = process.env[params.name];
await Bluebird.try(async function() {
if (params.value == null) {
params.value = process.env[params.name];
if (params.value == null) {
throw new Error(
`Environment value not found for variable: ${params.name}`,
);
} else if (!options.quiet) {
cmd.warn(
`Using ${params.name}=${params.value} from CLI process environment`,
);
}
}
const reservedPrefixes = await getReservedPrefixes();
const isConfigVar = _.some(reservedPrefixes, prefix =>
_.startsWith(params.name, prefix),
);
if (options.application) {
return balena.models.application[
isConfigVar ? 'configVar' : 'envVar'
].set(options.application, params.name, params.value);
} else if (options.device) {
return balena.models.device[isConfigVar ? 'configVar' : 'envVar'].set(
options.device,
params.name,
params.value,
throw new Error(
`Environment value not found for variable: ${params.name}`,
);
} else if (!options.quiet) {
cmd.warn(
`Using ${params.name}=${params.value} from CLI process environment`,
);
} else {
exitWithExpectedError('You must specify an application or device');
}
});
}
const reservedPrefixes = await getReservedPrefixes();
const isConfigVar = _.some(reservedPrefixes, prefix =>
_.startsWith(params.name, prefix),
);
const varType = isConfigVar ? 'configVar' : 'envVar';
if (options.application) {
await balena.models.application[varType].set(
options.application,
params.name,
params.value,
);
} else if (options.device) {
await balena.models.device[varType].set(
options.device,
params.name,
params.value,
);
} else {
exitWithExpectedError('You must specify an application or device');
}
}
}

96
lib/actions-oclif/env/rename.ts vendored Normal file

@ -0,0 +1,96 @@
/**
* @license
* Copyright 2016-2019 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Command, flags } from '@oclif/command';
import { stripIndent } from 'common-tags';
import * as cf from '../../utils/common-flags';
import { CommandHelp } from '../../utils/oclif-utils';
type IArg<T> = import('@oclif/parser').args.IArg<T>;
interface FlagsDef {
device: boolean;
help: void;
}
interface ArgsDef {
id: number;
value: string;
}
export default class EnvRenameCmd extends Command {
public static description = stripIndent`
Change the value of an environment variable for an app or device.
Change the value of an environment variable for an application or device,
as selected by the '--device' option. The variable is identified by its
database ID, rather than its name. The 'balena envs' command can be used
to list the variable's ID.
Service-specific variables are not currently supported. The following
examples modify variables that apply to all services in an app or device.
`;
public static examples = [
'$ balena env rename 376 emacs',
'$ balena env rename 376 emacs --device',
];
public static args: Array<IArg<any>> = [
{
name: 'id',
required: true,
description: 'environment variable numeric database ID',
parse: input => parseInt(input, 10),
},
{
name: 'value',
required: true,
description:
"variable value; if omitted, use value from CLI's environment",
},
];
// hardcoded 'env add' to avoid oclif's 'env:add' topic syntax
public static usage =
'env rename ' + new CommandHelp({ args: EnvRenameCmd.args }).defaultUsage();
public static flags: flags.Input<FlagsDef> = {
device: flags.boolean({
char: 'd',
description:
'select a device variable instead of an application variable',
}),
help: cf.help,
};
public async run() {
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
EnvRenameCmd,
);
const balena = (await import('balena-sdk')).fromSharedOptions();
await balena.pine.patch({
resource: options.device
? 'device_environment_variable'
: 'application_environment_variable',
id: params.id,
body: {
value: params.value,
},
});
}
}

@ -54,7 +54,7 @@ export default class EnvRmCmd extends Command {
{
name: 'id',
required: true,
description: 'environment variable id',
description: 'environment variable numeric database ID',
},
];
@ -89,23 +89,23 @@ export default class EnvRmCmd extends Command {
);
}
return patterns
.confirm(
try {
await patterns.confirm(
options.yes || false,
'Are you sure you want to delete the environment variable?',
)
.then(function() {
if (options.device) {
return balena.pine.delete({
resource: 'device_environment_variable',
id: params.id,
});
} else {
return balena.pine.delete({
resource: 'application_environment_variable',
id: params.id,
});
}
});
);
} catch (err) {
if (err.message === 'Aborted') {
return patterns.exitWithExpectedError(err);
}
throw err;
}
await balena.pine.delete({
resource: options.device
? 'device_environment_variable'
: 'application_environment_variable',
id: params.id,
});
}
}

95
lib/actions-oclif/envs.ts Normal file

@ -0,0 +1,95 @@
/**
* @license
* Copyright 2016-2019 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Command, flags } from '@oclif/command';
import { ApplicationVariable, DeviceVariable } from 'balena-sdk';
import { stripIndent } from 'common-tags';
import * as _ from 'lodash';
import * as cf from '../utils/common-flags';
import { CommandHelp } from '../utils/oclif-utils';
interface FlagsDef {
application?: string;
config: boolean;
device?: string;
help: void;
verbose: boolean;
}
export default class EnvsCmd extends Command {
public static description = stripIndent`
List the environment or config variables of an app or device.
List the environment or config variables of an application or device,
as selected by the respective command-line options.
The --config option is used to list "configuration variables" that
control balena features.
Service-specific variables are not currently supported. The following
examples list variables that apply to all services in an app or device.
`;
public static examples = [
'$ balena envs --application MyApp',
'$ balena envs --application MyApp --config',
'$ balena envs --device 7cf02a6',
];
public static usage = (
'envs ' + new CommandHelp({ args: EnvsCmd.args }).defaultUsage()
).trim();
public static flags: flags.Input<FlagsDef> = {
application: _.assign({ exclusive: ['device'] }, cf.application),
config: flags.boolean({
char: 'c',
description: 'show config variables',
}),
device: _.assign({ exclusive: ['application'] }, cf.device),
help: cf.help,
verbose: cf.verbose,
};
public async run() {
const { flags: options } = this.parse<FlagsDef, {}>(EnvsCmd);
const balena = (await import('balena-sdk')).fromSharedOptions();
const visuals = await import('resin-cli-visuals');
const { exitWithExpectedError } = await import('../utils/patterns');
const cmd = this;
let environmentVariables: ApplicationVariable[] | DeviceVariable[];
if (options.application) {
environmentVariables = await balena.models.application[
options.config ? 'configVar' : 'envVar'
].getAllByApplication(options.application);
} else if (options.device) {
environmentVariables = await balena.models.device[
options.config ? 'configVar' : 'envVar'
].getAllByDevice(options.device);
} else {
return exitWithExpectedError('You must specify an application or device');
}
if (_.isEmpty(environmentVariables)) {
return exitWithExpectedError('No environment variables found');
}
cmd.log(
visuals.table.horizontal(environmentVariables, ['id', 'name', 'value']),
);
}
}

@ -1,154 +0,0 @@
/*
Copyright 2016-2017 Balena
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { ApplicationVariable, DeviceVariable } from 'balena-sdk';
import * as Bluebird from 'bluebird';
import { CommandDefinition } from 'capitano';
import { stripIndent } from 'common-tags';
import { normalizeUuidProp } from '../utils/normalization';
import * as commandOptions from './command-options';
export const list: CommandDefinition<
{},
{
application?: string;
device?: string;
config: boolean;
}
> = {
signature: 'envs',
description: 'list all environment variables',
help: stripIndent`
Use this command to list the environment variables of an application
or device.
The --config option is used to list "config" variables that configure
balena features.
Service-specific variables are not currently supported. The following
examples list variables that apply to all services in an app or device.
Example:
$ balena envs --application MyApp
$ balena envs --application MyApp --config
$ balena envs --device 7cf02a6
`,
options: [
commandOptions.optionalApplication,
commandOptions.optionalDevice,
{
signature: 'config',
description: 'show config variables',
boolean: true,
alias: ['c', 'v', 'verbose'],
},
],
permission: 'user',
async action(_params, options, done) {
normalizeUuidProp(options, 'device');
const _ = await import('lodash');
const balena = (await import('balena-sdk')).fromSharedOptions();
const visuals = await import('resin-cli-visuals');
const { exitWithExpectedError } = await import('../utils/patterns');
return Bluebird.try(function(): Bluebird<
DeviceVariable[] | ApplicationVariable[]
> {
if (options.application) {
return balena.models.application[
options.config ? 'configVar' : 'envVar'
].getAllByApplication(options.application);
} else if (options.device) {
return balena.models.device[
options.config ? 'configVar' : 'envVar'
].getAllByDevice(options.device);
} else {
return exitWithExpectedError(
'You must specify an application or device',
);
}
})
.tap(function(environmentVariables) {
if (_.isEmpty(environmentVariables)) {
exitWithExpectedError('No environment variables found');
}
console.log(
visuals.table.horizontal(environmentVariables, [
'id',
'name',
'value',
]),
);
})
.nodeify(done);
},
};
export const rename: CommandDefinition<
{
id: number;
value: string;
},
{
device: boolean;
}
> = {
signature: 'env rename <id> <value>',
description: 'rename an environment variable',
help: stripIndent`
Use this command to change the value of an application or device
environment variable.
The --device option selects a device instead of an application.
Service-specific variables are not currently supported. The following
examples modify variables that apply to all services in an app or device.
Examples:
$ balena env rename 376 emacs
$ balena env rename 376 emacs --device
`,
permission: 'user',
options: [commandOptions.booleanDevice],
async action(params, options, done) {
const balena = (await import('balena-sdk')).fromSharedOptions();
return Bluebird.try(function() {
if (options.device) {
return balena.pine.patch({
resource: 'device_environment_variable',
id: params.id,
body: {
value: params.value,
},
});
} else {
return balena.pine.patch({
resource: 'application_environment_variable',
id: params.id,
body: {
value: params.value,
},
});
}
}).nodeify(done);
},
};

@ -90,15 +90,20 @@ general = (params, options, done) ->
if options.verbose
console.log('\nAdditional commands:\n')
print parse(groupedCommands.secondary).concat(getOclifHelpLinePairs()).sort()
secondaryCommandPromise = getOclifHelpLinePairs()
.then (oclifHelpLinePairs) ->
print parse(groupedCommands.secondary).concat(oclifHelpLinePairs).sort()
else
console.log('\nRun `balena help --verbose` to list additional commands')
secondaryCommandPromise = Promise.resolve()
if not _.isEmpty(capitano.state.globalOptions)
console.log('\nGlobal Options:\n')
print parse(capitano.state.globalOptions).sort()
return done()
secondaryCommandPromise
.then ->
if not _.isEmpty(capitano.state.globalOptions)
console.log('\nGlobal Options:\n')
print parse(capitano.state.globalOptions).sort()
done()
.catch(done)
command = (params, options, done) ->
capitano.state.getMatchCommand params.command, (error, command) ->

@ -16,20 +16,29 @@
*/
import { Command } from '@oclif/command';
import * as Bluebird from 'bluebird';
import * as _ from 'lodash';
import * as path from 'path';
export function getOclifHelpLinePairs(): Array<[string, string]> {
// Although it's tempting to have these oclif commands 'require'd in a
// central place, it would impact on performance (CLI start time). An
// improvement would probably be to automatically scan the actions-oclif
// folder.
const EnvAddCmd = require('../actions-oclif/env/add').default;
const EnvRmCmd = require('../actions-oclif/env/rm').default;
const VersionCmd = require('../actions-oclif/version').default;
return [EnvAddCmd, EnvRmCmd, VersionCmd].map(getCmdUsageDescriptionLinePair);
export async function getOclifHelpLinePairs(): Promise<
Array<[string, string]>
> {
const { convertedCommands } = await import('../preparser');
const cmdClasses: Array<Promise<typeof Command>> = [];
for (const convertedCmd of convertedCommands) {
const [topic, cmd] = convertedCmd.split(':');
const pathComponents = ['..', 'actions-oclif', topic];
if (cmd) {
pathComponents.push(cmd);
}
// note that `import(path)` returns a promise
cmdClasses.push(import(path.join(...pathComponents)));
}
return Bluebird.map(cmdClasses, getCmdUsageDescriptionLinePair);
}
function getCmdUsageDescriptionLinePair(cmd: typeof Command): [string, string] {
function getCmdUsageDescriptionLinePair(cmdModule: any): [string, string] {
const cmd: typeof Command = cmdModule.default;
const usage = (cmd.usage || '').toString().toLowerCase();
let description = '';
// note: [^] matches any characters (including line breaks), achieving the

@ -19,7 +19,6 @@ module.exports =
app: require('./app')
auth: require('./auth')
device: require('./device')
env: require('./environment-variables')
tags: require('./tags')
keys: require('./keys')
logs: require('./logs')

@ -83,10 +83,6 @@ capitano.command(actions.keys.add)
capitano.command(actions.keys.info)
capitano.command(actions.keys.remove)
# ---------- Env Module ----------
capitano.command(actions.env.list)
capitano.command(actions.env.rename)
# ---------- Tags Module ----------
capitano.command(actions.tags.list)
capitano.command(actions.tags.set)

18
lib/app-capitano.d.ts vendored Normal file

@ -0,0 +1,18 @@
/**
* @license
* Copyright 2019 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export async function run(argv: string[]);

@ -18,7 +18,6 @@
import { Main } from '@oclif/command';
import { ExitError } from '@oclif/errors';
import { AppOptions } from './app';
import { trackPromise } from './hooks/prerun/track';
class CustomMain extends Main {
@ -32,10 +31,12 @@ class CustomMain extends Main {
}
}
type AppOptions = import('./preparser').AppOptions;
/**
* oclif CLI entrypoint
*/
export function run(command: string[], options: AppOptions) {
export async function run(command: string[], options: AppOptions) {
const runPromise = CustomMain.run(command).then(
() => {
if (!options.noFlush) {
@ -51,7 +52,9 @@ export function run(command: string[], options: AppOptions) {
}
},
);
return Promise.all([trackPromise, runPromise]).catch(
require('./errors').handleError,
);
try {
await Promise.all([trackPromise, runPromise]);
} catch (err) {
await (await import('./errors')).handleError(err);
}
}

@ -14,156 +14,18 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { stripIndent } from 'common-tags';
import { exitWithExpectedError } from './utils/patterns';
export interface AppOptions {
// Prevent the default behaviour of flushing stdout after running a command
noFlush?: boolean;
}
/**
* Simple command-line pre-parsing to choose between oclif or Capitano.
* @param argv process.argv
*/
function routeCliFramework(argv: string[], options: AppOptions): void {
if (process.env.DEBUG) {
console.log(
`[debug] original argv0="${process.argv0}" argv=[${argv}] length=${
argv.length
}`,
);
}
const cmdSlice = argv.slice(2);
// Look for commands that have been deleted, to print a notice
checkDeletedCommand(cmdSlice);
if (cmdSlice.length > 0) {
// convert 'balena --version' or 'balena -v' to 'balena version'
if (['--version', '-v'].includes(cmdSlice[0])) {
cmdSlice[0] = 'version';
}
// convert 'balena --help' or 'balena -h' to 'balena help'
else if (['--help', '-h'].includes(cmdSlice[0])) {
cmdSlice[0] = 'help';
}
// convert e.g. 'balena help env add' to 'balena env add --help'
if (cmdSlice.length > 1 && cmdSlice[0] === 'help') {
cmdSlice.shift();
cmdSlice.push('--help');
}
}
const [isOclif, isTopic] = isOclifCommand(cmdSlice);
if (isOclif) {
let oclifArgs = cmdSlice;
if (isTopic) {
// convert space-separated commands to oclif's topic:command syntax
oclifArgs = [cmdSlice[0] + ':' + cmdSlice[1], ...cmdSlice.slice(2)];
}
if (process.env.DEBUG) {
console.log(
`[debug] new argv=[${[
argv[0],
argv[1],
...oclifArgs,
]}] length=${oclifArgs.length + 2}`,
);
}
return require('./app-oclif').run(oclifArgs, options);
} else {
return require('./app-capitano').run(argv);
}
}
/**
*
* @param argvSlice process.argv.slice(2)
*/
function checkDeletedCommand(argvSlice: string[]): void {
if (argvSlice[0] === 'help') {
argvSlice = argvSlice.slice(1);
}
function replaced(
oldCmd: string,
alternative: string,
version: string,
verb = 'replaced',
) {
exitWithExpectedError(stripIndent`
Note: the command "balena ${oldCmd}" was ${verb} in CLI version ${version}.
Please use "balena ${alternative}" instead.
`);
}
function removed(oldCmd: string, alternative: string, version: string) {
let msg = `Note: the command "balena ${oldCmd}" was removed in CLI version ${version}.`;
if (alternative) {
msg = [msg, alternative].join('\n');
}
exitWithExpectedError(msg);
}
const stopAlternative =
'Please use "balena ssh -s" to access the host OS, then use `balena-engine stop`.';
const cmds: { [cmd: string]: [(...args: any) => void, ...string[]] } = {
sync: [replaced, 'push', 'v11.0.0', 'removed'],
'local logs': [replaced, 'logs', 'v11.0.0'],
'local push': [replaced, 'push', 'v11.0.0'],
'local scan': [replaced, 'scan', 'v11.0.0'],
'local ssh': [replaced, 'ssh', 'v11.0.0'],
'local stop': [removed, stopAlternative, 'v11.0.0'],
};
let cmd: string | undefined;
if (argvSlice.length > 1) {
cmd = [argvSlice[0], argvSlice[1]].join(' ');
} else if (argvSlice.length > 0) {
cmd = argvSlice[0];
}
if (cmd && Object.getOwnPropertyNames(cmds).includes(cmd)) {
cmds[cmd][0](cmd, ...cmds[cmd].slice(1));
}
}
/**
* Determine whether the CLI command has been converted from Capitano to oclif.
* Return an array of two boolean values:
* r[0] : whether the CLI command is implemented with oclif
* r[1] : if r[0] is true, whether the CLI command is implemented with
* oclif "topics" (colon-separated subcommands like `env:add`)
* @param argvSlice process.argv.slice(2)
*/
function isOclifCommand(argvSlice: string[]): [boolean, boolean] {
// Look for commands that have been transitioned to oclif
if (argvSlice.length > 0) {
// balena version
if (argvSlice[0] === 'version') {
return [true, false];
}
if (argvSlice.length > 1) {
// balena env add
if (argvSlice[0] === 'env' && argvSlice[1] === 'add') {
return [true, true];
}
// balena env rm
if (argvSlice[0] === 'env' && argvSlice[1] === 'rm') {
return [true, true];
}
}
}
return [false, false];
}
import { globalInit } from './app-common';
import { AppOptions, routeCliFramework } from './preparser';
/**
* CLI entrypoint, but see also `bin/balena` and `bin/balena-dev` which
* call this function.
*/
export function run(cliArgs = process.argv, options: AppOptions = {}): void {
export async function run(cliArgs = process.argv, options: AppOptions = {}) {
// globalInit() must be called very early on (before other imports) because
// it sets up Sentry error reporting, global HTTP proxy settings, balena-sdk
// shared options, and performs node version requirement checks.
require('./app-common').globalInit();
return routeCliFramework(cliArgs, options);
globalInit();
await routeCliFramework(cliArgs, options);
}

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import * as Promise from 'bluebird';
import * as Bluebird from 'bluebird';
import { stripIndent } from 'common-tags';
import * as _ from 'lodash';
import * as os from 'os';
@ -22,7 +22,7 @@ import * as Raven from 'raven';
import * as patterns from './utils/patterns';
const captureException = Promise.promisify<string, Error>(
const captureException = Bluebird.promisify<string, Error>(
Raven.captureException,
{ context: Raven },
);
@ -104,7 +104,7 @@ const messages: {
$ balena login`,
};
export function handleError(error: any) {
export async function handleError(error: any) {
let message = interpret(error);
if (message == null) {
return;
@ -116,7 +116,7 @@ export function handleError(error: any) {
patterns.printErrorMessage(message!);
return captureException(error)
await captureException(error)
.timeout(1000)
.catch(function() {
// Ignore any errors (from error logging, or timeouts)

159
lib/preparser.ts Normal file

@ -0,0 +1,159 @@
/**
* @license
* Copyright 2019 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { stripIndent } from 'common-tags';
import { exitWithExpectedError } from './utils/patterns';
export interface AppOptions {
// Prevent the default behaviour of flushing stdout after running a command
noFlush?: boolean;
}
/**
* Simple command-line pre-parsing to choose between oclif or Capitano.
* @param argv process.argv
*/
export async function routeCliFramework(argv: string[], options: AppOptions) {
if (process.env.DEBUG) {
console.log(
`[debug] original argv0="${process.argv0}" argv=[${argv}] length=${
argv.length
}`,
);
}
const cmdSlice = argv.slice(2);
// Look for commands that have been removed and if so, exit with a notice
checkDeletedCommand(cmdSlice);
if (cmdSlice.length > 0) {
// convert 'balena --version' or 'balena -v' to 'balena version'
if (['--version', '-v'].includes(cmdSlice[0])) {
cmdSlice[0] = 'version';
}
// convert 'balena --help' or 'balena -h' to 'balena help'
else if (['--help', '-h'].includes(cmdSlice[0])) {
cmdSlice[0] = 'help';
}
// convert e.g. 'balena help env add' to 'balena env add --help'
if (cmdSlice.length > 1 && cmdSlice[0] === 'help') {
cmdSlice.shift();
cmdSlice.push('--help');
}
}
const [isOclif, isTopic] = isOclifCommand(cmdSlice);
if (isOclif) {
let oclifArgs = cmdSlice;
if (isTopic) {
// convert space-separated commands to oclif's topic:command syntax
oclifArgs = [cmdSlice[0] + ':' + cmdSlice[1], ...cmdSlice.slice(2)];
}
if (process.env.DEBUG) {
console.log(
`[debug] new argv=[${[
argv[0],
argv[1],
...oclifArgs,
]}] length=${oclifArgs.length + 2}`,
);
}
await (await import('./app-oclif')).run(oclifArgs, options);
} else {
await (await import('./app-capitano')).run(argv);
}
}
/**
* Check whether the command line refers to a command that has been deprecated
* and removed and, if so, exit with an informative error message.
* @param argvSlice process.argv.slice(2)
*/
function checkDeletedCommand(argvSlice: string[]): void {
if (argvSlice[0] === 'help') {
argvSlice = argvSlice.slice(1);
}
function replaced(
oldCmd: string,
alternative: string,
version: string,
verb = 'replaced',
) {
exitWithExpectedError(stripIndent`
Note: the command "balena ${oldCmd}" was ${verb} in CLI version ${version}.
Please use "balena ${alternative}" instead.
`);
}
function removed(oldCmd: string, alternative: string, version: string) {
let msg = `Note: the command "balena ${oldCmd}" was removed in CLI version ${version}.`;
if (alternative) {
msg = [msg, alternative].join('\n');
}
exitWithExpectedError(msg);
}
const stopAlternative =
'Please use "balena ssh -s" to access the host OS, then use `balena-engine stop`.';
const cmds: { [cmd: string]: [(...args: any) => void, ...string[]] } = {
sync: [replaced, 'push', 'v11.0.0', 'removed'],
'local logs': [replaced, 'logs', 'v11.0.0'],
'local push': [replaced, 'push', 'v11.0.0'],
'local scan': [replaced, 'scan', 'v11.0.0'],
'local ssh': [replaced, 'ssh', 'v11.0.0'],
'local stop': [removed, stopAlternative, 'v11.0.0'],
};
let cmd: string | undefined;
if (argvSlice.length > 1) {
cmd = [argvSlice[0], argvSlice[1]].join(' ');
} else if (argvSlice.length > 0) {
cmd = argvSlice[0];
}
if (cmd && Object.getOwnPropertyNames(cmds).includes(cmd)) {
cmds[cmd][0](cmd, ...cmds[cmd].slice(1));
}
}
export const convertedCommands = [
'envs',
'env:add',
'env:rename',
'env:rm',
'version',
];
/**
* Determine whether the CLI command has been converted from Capitano to oclif.
* Return an array of two boolean values:
* r[0] : whether the CLI command is implemented with oclif
* r[1] : if r[0] is true, whether the CLI command is implemented with
* oclif "topics" (colon-separated subcommands like `env:add`)
* @param argvSlice process.argv.slice(2)
*/
function isOclifCommand(argvSlice: string[]): [boolean, boolean] {
// Look for commands that have been transitioned to oclif
// const { convertedCommands } = require('oclif/utils/command');
const arg0 = argvSlice.length > 0 ? argvSlice[0] : '';
const arg1 = argvSlice.length > 1 ? argvSlice[1] : '';
if (convertedCommands.includes(`${arg0}:${arg1}`)) {
return [true, true];
}
if (convertedCommands.includes(arg0)) {
return [true, false];
}
return [false, false];
}

43
lib/utils/common-flags.ts Normal file

@ -0,0 +1,43 @@
/**
* @license
* Copyright 2019 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { flags } from '@oclif/command';
type IBooleanFlag<T> = import('@oclif/parser/lib/flags').IBooleanFlag<T>;
export const application = flags.string({
char: 'a',
description: 'application name',
});
export const device = flags.string({
char: 'd',
description: 'device UUID',
});
export const help: IBooleanFlag<void> = flags.help({ char: 'h' });
export const quiet: IBooleanFlag<boolean> = flags.boolean({
char: 'q',
description: 'suppress warning messages',
default: false,
});
export const verbose: IBooleanFlag<boolean> = flags.boolean({
char: 'v',
description: 'produce verbose output',
});

@ -17,11 +17,6 @@
import * as Config from '@oclif/config';
export const convertedCommands = {
'env:add': 'env add',
'env:rm': 'env rm',
};
/**
* This class is a partial copy-and-paste of
* @oclif/plugin-help/command/CommandHelp, which is used to generate oclif's

@ -216,10 +216,14 @@ async function getOrSelectApplication(
throw new Error(`"${deviceType}" is not a valid device type`);
}
const compatibleDeviceTypes = _(allDeviceTypes)
.filter(dt =>
sdk.models.os.isArchitectureCompatibleWith(deviceTypeManifest.arch, dt.arch) &&
!!dt.isDependent === !!deviceTypeManifest.isDependent &&
dt.state !== 'DISCONTINUED'
.filter(
dt =>
sdk.models.os.isArchitectureCompatibleWith(
deviceTypeManifest.arch,
dt.arch,
) &&
!!dt.isDependent === !!deviceTypeManifest.isDependent &&
dt.state !== 'DISCONTINUED',
)
.map(type => type.slug)
.value();

26
npm-shrinkwrap.json generated

@ -7394,6 +7394,15 @@
}
}
},
"intercept-stdout": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/intercept-stdout/-/intercept-stdout-0.1.2.tgz",
"integrity": "sha1-Emq/H65sUJpCipjGGmMVWQQq6f0=",
"dev": true,
"requires": {
"lodash.toarray": "^3.0.0"
}
},
"interpret": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/interpret/-/interpret-1.2.0.tgz",
@ -8056,6 +8065,12 @@
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.11.tgz",
"integrity": "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg=="
},
"lodash._arraycopy": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/lodash._arraycopy/-/lodash._arraycopy-3.0.0.tgz",
"integrity": "sha1-due3wfH7klRzdIeKVi7Qaj5Q9uE=",
"dev": true
},
"lodash._basecopy": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/lodash._basecopy/-/lodash._basecopy-3.0.1.tgz",
@ -8170,6 +8185,17 @@
"lodash._reinterpolate": "~3.0.0"
}
},
"lodash.toarray": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/lodash.toarray/-/lodash.toarray-3.0.2.tgz",
"integrity": "sha1-KyBPD6T1HChcbwDIHRzqWiMEEXk=",
"dev": true,
"requires": {
"lodash._arraycopy": "^3.0.0",
"lodash._basevalues": "^3.0.0",
"lodash.keys": "^3.0.0"
}
},
"lodash.uniq": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz",

@ -2,7 +2,7 @@
"name": "balena-cli",
"version": "11.11.0",
"description": "The official balena CLI tool",
"main": "./build/actions/index.js",
"main": "./build/app.js",
"homepage": "https://github.com/balena-io/balena-cli",
"repository": {
"type": "git",
@ -29,6 +29,7 @@
"node_modules/raven/lib/instrumentation/*.js"
],
"assets": [
"build/**/*.js",
"build/actions-oclif",
"build/auth/pages/*.ejs",
"build/hooks",
@ -124,6 +125,7 @@
"gulp-coffee": "^2.2.0",
"gulp-inline-source": "^2.1.0",
"gulp-shell": "^0.5.2",
"intercept-stdout": "^0.1.2",
"mocha": "^6.2.0",
"nock": "^10.0.6",
"parse-link-header": "~1.0.1",

34
tests/commands/env/rename.spec.ts vendored Normal file

@ -0,0 +1,34 @@
/**
* @license
* Copyright 2016-2019 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as chai from 'chai';
import { balenaAPIMock, runCommand } from '../../helpers';
describe('balena env rename', function() {
it('should successfully rename an environment variable', async () => {
const mock = balenaAPIMock();
mock.patch(/device_environment_variable\(376\)/).reply(200, 'OK');
const { out, err } = await runCommand('env rename 376 emacs --device');
chai.expect(out.join('')).to.equal('');
chai.expect(err.join('')).to.equal('');
// @ts-ignore
mock.remove();
});
});

@ -59,9 +59,9 @@ Additional commands:
device shutdown <uuid> shutdown a device
devices supported list all supported devices
env add name [value] add an environment or config variable to an application or device
env rename <id> <value> rename an environment variable
env rename id value change the value of an environment variable for an app or device
env rm id remove an environment variable from an application or device
envs list all environment variables
envs list the environment or config variables of an app or device
key <id> list a single ssh key
key add <name> [path] add a SSH key to balena
key rm <id> remove a ssh key

@ -1,28 +1,42 @@
/**
* @license
* Copyright 2019 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import intercept = require('intercept-stdout');
import * as nock from 'nock';
import * as path from 'path';
import * as balenaCLI from '../build/app';
export const runCommand = async (cmd: string) => {
const preArgs = [process.argv[0], path.join(process.cwd(), 'bin', 'balena')];
const oldStdOut = process.stdout.write;
const oldStdErr = process.stderr.write;
const err: string[] = [];
const out: string[] = [];
// @ts-ignore
process.stdout.write = (log: string) => {
const stdoutHook = (log: string | Buffer) => {
// Skip over debug messages
if (!log.startsWith('[debug]')) {
if (typeof log === 'string' && !log.startsWith('[debug]')) {
out.push(log);
}
oldStdOut(log);
};
// @ts-ignore
process.stderr.write = (log: string) => {
const stderrHook = (log: string | Buffer) => {
// Skip over debug messages
if (
typeof log === 'string' &&
!log.startsWith('[debug]') &&
// TODO stop this warning message from appearing when running
// sdk.setSharedOptions multiple times in the same process
@ -30,26 +44,19 @@ export const runCommand = async (cmd: string) => {
) {
err.push(log);
}
oldStdErr(log);
};
const unhookIntercept = intercept(stdoutHook, stderrHook);
try {
await balenaCLI.run(preArgs.concat(cmd.split(' ')), {
noFlush: true,
});
process.stdout.write = oldStdOut;
process.stderr.write = oldStdErr;
return {
err,
out,
};
} catch (err) {
process.stdout.write = oldStdOut;
process.stderr.write = oldStdErr;
throw err;
} finally {
unhookIntercept();
}
};

28
typings/intercept-stdout/index.d.ts vendored Normal file

@ -0,0 +1,28 @@
/**
* @license
* Copyright 2019 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare module 'intercept-stdout' {
type hookFunction = (txt: string) => string | void;
type unhookFunction = () => void;
function intercept(
stdoutIntercept: hookFunction,
stderrIntercept?: hookFunction,
): unhookFunction;
export = intercept;
}