Fix 'os configure --config', and migrate it to oclif + TypeScript

Also add more non-interactive configuration options:
--config-network, --config-wifi-*, --config-app-update-poll-interval

Change-type: minor
Signed-off-by: Paulo Castro <paulo@balena.io>
This commit is contained in:
Paulo Castro 2019-10-31 01:46:14 +00:00
parent a25a52c21b
commit d94a74dfee
12 changed files with 500 additions and 182 deletions

@ -85,7 +85,7 @@ const capitanoDoc = {
},
{
title: 'OS',
files: ['build/actions/os.js'],
files: ['build/actions/os.js', 'build/actions-oclif/os/configure.js'],
},
{
title: 'Config',

@ -118,6 +118,13 @@ function renderToc(categories: Category[]): string[] {
const manualCategorySorting: { [category: string]: string[] } = {
'Environment Variables': ['envs', 'env rm', 'env add', 'env rename'],
OS: [
'os versions',
'os download',
'os build config',
'os configure',
'os initialize',
],
};
function sortCommands(doc: Document): void {

@ -1132,23 +1132,22 @@ show advanced configuration options
the path to the output JSON file
## os configure &#60;image&#62;
## os configure IMAGE
Use this command to configure a previously downloaded operating system image for
the specific device or for an application generally.
Configure a previously downloaded balenaOS image for a specific device type or
balena application.
This command will try to automatically determine the operating system version in order
to correctly configure the image. It may fail to do so however, in which case you'll
have to call this command again with the exact version number of the targeted image.
Configuration settings such as WiFi authentication will be taken from the
following sources, in precedence order:
1. Command-line options like `--config-wifi-ssid`
2. A given `config.json` file specified with the `--config` option.
3. User input through interactive prompts (text menus).
Note that device api keys are only supported on balenaOS 2.0.3+.
The --device-type option may be used to override the application's default
device type, in case of an application with mixed device types.
This command still supports the *deprecated* format where the UUID and optionally device key
are passed directly on the command line, but the recommended way is to pass either an --app or
--device argument. The deprecated format will be removed in a future release.
In case that you want to configure an image for an application with mixed device types,
you can pass the --device-type argument along with --app to specify the target device type.
The --device-api-key option is deprecated and will be removed in a future release.
A suitable key is automatically generated or fetched if this option is omitted.
Examples:
@ -1157,36 +1156,63 @@ Examples:
$ balena os configure ../path/rpi3.img --app MyApp
$ balena os configure ../path/rpi3.img --app MyApp --version 2.12.7
$ balena os configure ../path/rpi3.img --app MyFinApp --device-type raspberrypi3
$ balena os configure ../path/rpi3.img --app MyFinApp --device-type raspberrypi3 --config myWifiConfig.json
### Arguments
#### IMAGE
path to a balenaOS image file, e.g. "rpi3.img"
### Options
#### --advanced, -v
#### -v, --advanced
show advanced configuration options
ask advanced configuration questions (when in interactive mode)
#### --application, -a, --app &#60;application&#62;
#### --app APP
same as '--application'
#### -a, --application APPLICATION
application name
#### --device, -d &#60;device&#62;
#### --config CONFIG
device uuid
path to a pre-generated config.json file to be injected in the OS image
#### --deviceApiKey, -k &#60;device-api-key&#62;
#### --config-app-update-poll-interval CONFIG-APP-UPDATE-POLL-INTERVAL
custom device key - note that this is only supported on balenaOS 2.0.3+
interval (in minutes) for the on-device balena supervisor periodic app update check
#### --deviceType &#60;device-type&#62;
#### --config-network CONFIG-NETWORK
device type slug
device network type (non-interactive configuration)
#### --version &#60;version&#62;
#### --config-wifi-key CONFIG-WIFI-KEY
a balenaOS version
WiFi key (password) (non-interactive configuration)
#### --config &#60;config&#62;
#### --config-wifi-ssid CONFIG-WIFI-SSID
path to the config JSON file, see `balena os build-config`
WiFi SSID (network name) (non-interactive configuration)
#### -d, --device DEVICE
device UUID
#### -k, --device-api-key DEVICE-API-KEY
custom device API key (DEPRECATED and only supported with balenaOS 2.0.3+)
#### --device-type DEVICE-TYPE
device type slug (e.g. "raspberrypi3") to override the application device type
#### --version VERSION
balenaOS version, for example "2.32.0" or "2.44.0+rev1"
## os initialize &#60;image&#62;

@ -0,0 +1,431 @@
/**
* @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 BalenaSdk = require('balena-sdk');
import { stripIndent } from 'common-tags';
import * as _ from 'lodash';
import { ExpectedError } from '../../errors';
import * as cf from '../../utils/common-flags';
import { CommandHelp } from '../../utils/oclif-utils';
interface FlagsDef {
advanced?: boolean;
app?: string;
application?: string;
config?: string;
'config-app-update-poll-interval'?: number;
'config-network'?: string;
'config-wifi-key'?: string;
'config-wifi-ssid'?: string;
device?: string; // device UUID
'device-api-key'?: string;
'device-type'?: string;
help?: void;
version?: string;
}
interface ArgsDef {
image: string;
}
interface DeferredDevice extends BalenaSdk.Device {
belongs_to__application: BalenaSdk.PineDeferred;
}
interface Answers {
appUpdatePollInterval: number; // in minutes
deviceType: string; // e.g. "raspberrypi3"
network: 'ethernet' | 'wifi';
version: string; // e.g. "2.32.0+rev1"
wifiSsid?: string;
wifiKey?: string;
}
const deviceApiKeyDeprecationMsg = stripIndent`
The --device-api-key option is deprecated and will be removed in a future release.
A suitable key is automatically generated or fetched if this option is omitted.`;
export default class OsConfigureCmd extends Command {
public static description = stripIndent`
Configure a previously downloaded balenaOS image.
Configure a previously downloaded balenaOS image for a specific device type or
balena application.
Configuration settings such as WiFi authentication will be taken from the
following sources, in precedence order:
1. Command-line options like \`--config-wifi-ssid\`
2. A given \`config.json\` file specified with the \`--config\` option.
3. User input through interactive prompts (text menus).
The --device-type option may be used to override the application's default
device type, in case of an application with mixed device types.
${deviceApiKeyDeprecationMsg.split('\n').join('\n\t\t')}
`;
public static examples = [
'$ balena os configure ../path/rpi3.img --device 7cf02a6',
'$ balena os configure ../path/rpi3.img --device 7cf02a6 --device-api-key <existingDeviceKey>',
'$ balena os configure ../path/rpi3.img --app MyApp',
'$ balena os configure ../path/rpi3.img --app MyApp --version 2.12.7',
'$ balena os configure ../path/rpi3.img --app MyFinApp --device-type raspberrypi3',
'$ balena os configure ../path/rpi3.img --app MyFinApp --device-type raspberrypi3 --config myWifiConfig.json',
];
public static args = [
{
name: 'image',
required: true,
description: 'path to a balenaOS image file, e.g. "rpi3.img"',
},
];
// hardcoded 'os configure' to avoid oclif's 'os:configure' topic syntax
public static usage =
'os configure ' +
new CommandHelp({ args: OsConfigureCmd.args }).defaultUsage();
public static flags: flags.Input<FlagsDef> = {
advanced: flags.boolean({
char: 'v',
description:
'ask advanced configuration questions (when in interactive mode)',
}),
app: flags.string({
description: "same as '--application'",
exclusive: ['application', 'device'],
}),
application: _.assign({ exclusive: ['app', 'device'] }, cf.application),
config: flags.string({
description:
'path to a pre-generated config.json file to be injected in the OS image',
}),
'config-app-update-poll-interval': flags.integer({
description:
'interval (in minutes) for the on-device balena supervisor periodic app update check',
}),
'config-network': flags.string({
description: 'device network type (non-interactive configuration)',
options: ['ethernet', 'wifi'],
}),
'config-wifi-key': flags.string({
description: 'WiFi key (password) (non-interactive configuration)',
}),
'config-wifi-ssid': flags.string({
description: 'WiFi SSID (network name) (non-interactive configuration)',
}),
device: _.assign({ exclusive: ['app', 'application'] }, cf.device),
'device-api-key': flags.string({
char: 'k',
description:
'custom device API key (DEPRECATED and only supported with balenaOS 2.0.3+)',
}),
'device-type': flags.string({
description:
'device type slug (e.g. "raspberrypi3") to override the application device type',
}),
help: cf.help,
version: flags.string({
description: 'balenaOS version, for example "2.32.0" or "2.44.0+rev1"',
}),
};
public async run() {
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
OsConfigureCmd,
);
// Prefer options.application over options.app
options.application = options.application || options.app;
options.app = undefined;
await validateOptions(options);
const devInit = await import('balena-device-init');
const balena = (await import('balena-sdk')).fromSharedOptions();
const fs = await import('mz/fs');
const { generateDeviceConfig, generateApplicationConfig } = await import(
'../../utils/config'
);
const helpers = await import('../../utils/helpers');
let app: BalenaSdk.Application | undefined;
let device: BalenaSdk.Device | undefined;
let deviceTypeSlug: string;
if (options.device) {
device = await balena.models['device'].get(options.device);
deviceTypeSlug = device.device_type;
} else {
app = await balena.models['application'].get(options.application!);
await checkDeviceTypeCompatibility(balena, options, app);
deviceTypeSlug = options['device-type'] || app.device_type;
}
const deviceTypeManifest = await helpers.getManifest(
params.image,
deviceTypeSlug,
);
let configJson: import('../../utils/config').ImgConfig | undefined;
if (options.config) {
const rawConfig = await fs.readFile(options.config, 'utf8');
configJson = JSON.parse(rawConfig);
}
const answers: Answers = await askQuestionsForDeviceType(
deviceTypeManifest,
options,
configJson,
);
if (options.application) {
answers.deviceType = deviceTypeSlug;
}
answers.version =
options.version ||
(await getOsVersionFromImage(params.image, deviceTypeManifest, devInit));
if (_.isEmpty(configJson)) {
if (device) {
configJson = await generateDeviceConfig(
device as DeferredDevice,
options['device-api-key'],
answers,
);
} else {
configJson = await generateApplicationConfig(app!, answers);
}
}
console.info('Configuring operating system image');
await helpers.osProgressHandler(
await devInit.configure(
params.image,
deviceTypeManifest,
configJson || {},
answers,
),
);
}
}
async function validateOptions(options: FlagsDef) {
if (process.platform === 'win32') {
throw new ExpectedError(stripIndent`
Unsupported platform error: the 'balena os configure' command currently requires
the Windows Subsystem for Linux in order to run on Windows. It was tested with
the Ubuntu 18.04 distribution from the Microsoft Store. With WSL, a balena CLI
release for Linux (rather than Windows) should be installed: for example, the
standalone zip package for Linux. (It is possible to have both a Windows CLI
release and a Linux CLI release installed simultaneously.) For more information
on WSL and the balena CLI installation options, please check:
- https://docs.microsoft.com/en-us/windows/wsl/about
- https://github.com/balena-io/balena-cli/blob/master/INSTALL.md
`);
}
// The 'device' and 'application' options are declared "exclusive" in the oclif
// flag definitions above, so oclif will enforce that they are not both used together.
if (!options.device && !options.application) {
throw new ExpectedError(
"Either the '--device' or the '--application' option must be provided",
);
}
if (!options.application && options['device-type']) {
throw new ExpectedError(
"The '--device-type' option can only be used in conjunction with the '--application' option",
);
}
if (options['device-api-key']) {
console.error(stripIndent`
-------------------------------------------------------------------------------------------
Warning: ${deviceApiKeyDeprecationMsg.split('\n').join('\n\t\t\t')}
-------------------------------------------------------------------------------------------
`);
}
const { checkLoggedIn } = await import('../../utils/patterns');
await checkLoggedIn();
}
/**
* Wrapper around balena-device-init.getImageOsVersion(). Throws ExpectedError
* if the OS image could not be read or the OS version could not be extracted
* from it.
* @param imagePath Local filesystem path to a balenaOS image file
* @param deviceTypeManifest Device type manifest object
*/
async function getOsVersionFromImage(
imagePath: string,
deviceTypeManifest: BalenaSdk.DeviceType,
devInit: typeof import('balena-device-init'),
): Promise<string> {
const osVersion = await devInit.getImageOsVersion(
imagePath,
deviceTypeManifest,
);
if (!osVersion) {
throw new ExpectedError(stripIndent`
Could not read OS version from the image. Please specify the balenaOS
version manually with the --version command-line option.`);
}
return osVersion;
}
/**
* Check that options['device-type'], e.g. 'raspberrypi3', is compatible with
* app.device_type, e.g. 'raspberry-pi2'. Throws ExpectedError if they are not
* compatible.
* @param sdk Balena Node SDK instance
* @param options oclif command-line options object
* @param app Balena SDK Application model object
*/
async function checkDeviceTypeCompatibility(
sdk: BalenaSdk.BalenaSDK,
options: FlagsDef,
app: BalenaSdk.Application,
) {
if (options['device-type']) {
const [appDeviceType, optionDeviceType] = await Promise.all([
sdk.models.device.getManifestBySlug(app.device_type),
sdk.models.device.getManifestBySlug(options['device-type']),
]);
const helpers = await import('../../utils/helpers');
if (!helpers.areDeviceTypesCompatible(appDeviceType, optionDeviceType)) {
throw new ExpectedError(
`Device type ${
options['device-type']
} is incompatible with application ${options.application}`,
);
}
}
}
/**
* Check if the given options or configJson objects (in this order) contain
* the answers to some configuration questions, and interactively ask the
* user the questions for which answers are missing. Questions such as:
*
* ? Network Connection (Use arrow keys)
* ethernet
* wifi
* ? Network Connection wifi
* ? Wifi SSID i-ssid
* ? Wifi Passphrase [input is hidden]
*
* The questions are extracted from the given deviceType "manifest".
*/
async function askQuestionsForDeviceType(
deviceType: BalenaSdk.DeviceType,
options: FlagsDef,
configJson?: import('../../utils/config').ImgConfig,
): Promise<Answers> {
const form = await import('resin-cli-form');
const helpers = await import('../../utils/helpers');
const answerSources: any[] = [camelifyConfigOptions(options)];
const defaultAnswers: Partial<Answers> = {};
const questions: any = deviceType.options;
let extraOpts: { override: object } | undefined;
if (!_.isEmpty(configJson)) {
answerSources.push(configJson);
}
if (!options.advanced) {
const advancedGroup: any = _.find(questions, {
name: 'advanced',
isGroup: true,
});
if (!_.isEmpty(advancedGroup)) {
answerSources.push(helpers.getGroupDefaults(advancedGroup));
}
}
for (const questionName of getQuestionNames(deviceType)) {
for (const answerSource of answerSources) {
if (answerSource[questionName] != null) {
defaultAnswers[questionName] = answerSource[questionName];
break;
}
}
}
if (
!defaultAnswers.network &&
(defaultAnswers.wifiSsid || defaultAnswers.wifiKey)
) {
defaultAnswers.network = 'wifi';
}
if (!_.isEmpty(defaultAnswers)) {
extraOpts = { override: defaultAnswers };
}
return form.run(questions, extraOpts);
}
/**
* Given a deviceType "manifest" containing "options" properties, return an
* array of "question names" as in the following example.
*
* @param deviceType Device type "manifest", for example:
* { "slug": "raspberrypi3",
* "options": [{
* "options": [ {
* "name": "network",
* "choices": ["ethernet", "wifi"],
* ... }, {
* "name": "wifiSsid",
* "type": "text",
* ... }, {
* "options": [ {
* "name": "appUpdatePollInterval",
* "default": 10,
* ...
* @return Array of question names, for example:
* [ 'network', 'wifiSsid', 'wifiKey', 'appUpdatePollInterval' ]
*/
function getQuestionNames(
deviceType: BalenaSdk.DeviceType,
): Array<keyof Answers> {
const questionNames: string[] = _.chain(deviceType.options)
.flatMap(
(group: BalenaSdk.DeviceTypeOptions) =>
(group.isGroup && group.options) || [],
)
.map((groupOption: BalenaSdk.DeviceTypeOptionsGroup) => groupOption.name)
.filter()
.value();
return questionNames as Array<keyof Answers>;
}
/**
* Create and return a new object with the key-value pairs from the input object,
* renaming keys that start with the 'config-' prefix as follows:
* Sample input:
* { app: 'foo', 'config-wifi-key': 'mykey', 'config-wifi-ssid': 'myssid' }
* Output:
* { app: 'foo', wifiKey: 'mykey', wifiSsid: 'myssid' }
*/
function camelifyConfigOptions(options: FlagsDef): { [key: string]: any } {
return _.mapKeys(options, (_value, key) => {
if (key.startsWith('config-')) {
return key
.substring('config-'.length)
.replace(/-[a-z]/g, match => match.substring(1).toUpperCase());
}
return key;
});
}

@ -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.
@ -16,7 +16,6 @@ limitations under the License.
commandOptions = require('./command-options')
_ = require('lodash')
{ normalizeUuidProp } = require('../utils/normalization')
formatVersion = (v, isRecommended) ->
result = "v#{v}"
@ -202,143 +201,6 @@ exports.buildConfig =
writeFileAsync(options.output, JSON.stringify(answers, null, 4))
.nodeify(done)
exports.configure =
signature: 'os configure <image>'
description: 'configure an os image'
help: '''
Use this command to configure a previously downloaded operating system image for
the specific device or for an application generally.
This command will try to automatically determine the operating system version in order
to correctly configure the image. It may fail to do so however, in which case you'll
have to call this command again with the exact version number of the targeted image.
Note that device api keys are only supported on balenaOS 2.0.3+.
This command still supports the *deprecated* format where the UUID and optionally device key
are passed directly on the command line, but the recommended way is to pass either an --app or
--device argument. The deprecated format will be removed in a future release.
In case that you want to configure an image for an application with mixed device types,
you can pass the --device-type argument along with --app to specify the target device type.
Examples:
$ balena os configure ../path/rpi3.img --device 7cf02a6
$ balena os configure ../path/rpi3.img --device 7cf02a6 --device-api-key <existingDeviceKey>
$ balena os configure ../path/rpi3.img --app MyApp
$ balena os configure ../path/rpi3.img --app MyApp --version 2.12.7
$ balena os configure ../path/rpi3.img --app MyFinApp --device-type raspberrypi3
'''
permission: 'user'
options: [
commandOptions.advancedConfig
commandOptions.optionalApplication
commandOptions.optionalDevice
commandOptions.optionalDeviceApiKey
commandOptions.optionalDeviceType
commandOptions.optionalOsVersion
{
signature: 'config'
description: 'path to the config JSON file, see `balena os build-config`'
parameter: 'config'
}
]
action: (params, options, done) ->
normalizeUuidProp(options, 'device')
fs = require('fs')
Promise = require('bluebird')
readFileAsync = Promise.promisify(fs.readFile)
balena = require('balena-sdk').fromSharedOptions()
init = require('balena-device-init')
helpers = require('../utils/helpers')
patterns = require('../utils/patterns')
{ generateDeviceConfig, generateApplicationConfig } = require('../utils/config')
if _.filter([
options.device
options.application
]).length != 1
patterns.exitWithExpectedError '''
To configure an image, you must provide exactly one of:
* A device, with --device <uuid>
* An application, with --app <appname>
See the help page for examples:
$ balena help os configure
'''
if !options.application and options.deviceType
patterns.exitWithExpectedError '''
Specifying a different device type is only supported when
configuring an image using an application as a parameter:
* An application, with --app <appname>
* A specific device type, with --device-type <deviceTypeSlug>
See the help page for examples:
$ balena help os configure
'''
uuid = options.device
deviceApiKey = options.deviceApiKey
console.info('Configuring operating system image')
configurationResourceType = if uuid then 'device' else 'application'
balena.models[configurationResourceType].get(uuid || options.application)
.then (appOrDevice) ->
deviceType = options.deviceType || appOrDevice.device_type
manifestPromise = helpers.getManifest(params.image, deviceType)
if options.application && options.deviceType
app = appOrDevice
appManifestPromise = balena.models.device.getManifestBySlug(app.device_type)
paramManifestPromise = balena.models.device.getManifestBySlug(options.deviceType)
manifestPromise = Promise.resolve(manifestPromise).tap ->
Promise.join appManifestPromise, paramManifestPromise, (appDeviceType, paramDeviceType) ->
if not helpers.areDeviceTypesCompatible(appDeviceType, paramDeviceType)
throw new balena.errors.BalenaInvalidDeviceType(
"Device type #{options.deviceType} is incompatible with application #{options.application}"
)
answersPromise = Promise.try ->
if options.config
return readFileAsync(options.config, 'utf8')
.then(JSON.parse)
return manifestPromise.then (deviceTypeManifest) ->
buildConfigForDeviceType(deviceTypeManifest, options.advanced)
Promise.join answersPromise, manifestPromise, (answers, manifest) ->
answers.version = options.version
if configurationResourceType == 'application'
answers.deviceType = deviceType
if not answers.version?
answers.version = Promise.resolve(helpers.getOsVersion(params.image, manifest)).tap (version) ->
if not version?
throw new Error(
'Could not read OS version from the image. ' +
'Please specify the version manually with the ' +
'--version argument to this command.'
)
Promise.props(answers).then (answers) ->
(if configurationResourceType == 'device'
generateDeviceConfig(appOrDevice, deviceApiKey, answers)
else
generateApplicationConfig(appOrDevice, answers)
)
.then (config) ->
init.configure(params.image, manifest, config, answers)
.then(helpers.osProgressHandler)
.nodeify(done)
INIT_WARNING_MESSAGE = '''
Note: Initializing the device may ask for administrative permissions
because we need to access the raw devices directly.

@ -93,7 +93,6 @@ capitano.command(actions.tags.remove)
capitano.command(actions.os.versions)
capitano.command(actions.os.download)
capitano.command(actions.os.buildConfig)
capitano.command(actions.os.configure)
capitano.command(actions.os.initialize)
# ---------- Config Module ----------

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

@ -132,6 +132,7 @@ export const convertedCommands = [
'env:add',
'env:rename',
'env:rm',
'os:configure',
'version',
];

@ -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.
@ -19,7 +19,7 @@ import * as semver from 'resin-semver';
const balena = BalenaSdk.fromSharedOptions();
interface ImgConfig {
export interface ImgConfig {
applicationName: string;
applicationId: number;
deviceType: string;
@ -103,7 +103,7 @@ export function generateDeviceConfig(
device: BalenaSdk.Device & {
belongs_to__application: BalenaSdk.PineDeferred;
},
deviceApiKey: string | true | null,
deviceApiKey: string | true | undefined,
options: { version: string },
) {
return balena.models.application

@ -28,7 +28,7 @@ const balena = BalenaSdk.fromSharedOptions();
export function getGroupDefaults(group: {
options: Array<{ name: string; default?: string }>;
}): { [name: string]: string | undefined } {
}): { [name: string]: string | number | undefined } {
return _.chain(group)
.get('options')
.map(question => [question.name, question.default])
@ -122,14 +122,6 @@ export const areDeviceTypesCompatible = (
deviceTypeA.arch === deviceTypeB.arch &&
!!deviceTypeA.isDependent === !!deviceTypeB.isDependent;
export async function getOsVersion(
image: string,
manifest: BalenaSdk.DeviceType,
): Promise<string | null> {
const init = await import('balena-device-init');
return init.getImageOsVersion(image, manifest);
}
export async function osProgressHandler(step: InitializeEmitter) {
step.on('stdout', process.stdout.write.bind(process.stdout));
step.on('stderr', process.stderr.write.bind(process.stderr));

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

@ -71,7 +71,7 @@ Additional commands:
logout logout from balena
note <|note> set a device note
os build-config <image> <device-type> build the OS config and save it to the JSON file
os configure <image> configure an os image
os configure <image> configure a previously downloaded balenaOS image
os download <type> download an unconfigured os image
os initialize <image> initialize an os image
os versions <type> show the available balenaOS versions for the given device type