diff --git a/automation/capitanodoc/capitanodoc.ts b/automation/capitanodoc/capitanodoc.ts index d920747c..6b7dffbf 100644 --- a/automation/capitanodoc/capitanodoc.ts +++ b/automation/capitanodoc/capitanodoc.ts @@ -51,6 +51,15 @@ const capitanoDoc = { title: 'Device', files: [ 'build/actions/device.js', + 'build/actions-oclif/device/identify.js', + 'build/actions-oclif/device/index.js', + 'build/actions-oclif/device/move.js', + 'build/actions-oclif/device/reboot.js', + 'build/actions-oclif/device/register.js', + 'build/actions-oclif/device/rename.js', + 'build/actions-oclif/device/rm.js', + 'build/actions-oclif/device/shutdown.js', + 'build/actions-oclif/devices/index.js', 'build/actions-oclif/devices/supported.js', 'build/actions-oclif/device/public-url.js', ], diff --git a/doc/cli.markdown b/doc/cli.markdown index 631302d8..04f2dc3a 100644 --- a/doc/cli.markdown +++ b/doc/cli.markdown @@ -167,17 +167,17 @@ Users are encouraged to regularly update the balena CLI to the latest version. - Device - - [devices](#devices) - - [device <uuid>](#device-uuid) - - [device register <application>](#device-register-application) - - [device rm <uuid>](#device-rm-uuid) - - [device identify <uuid>](#device-identify-uuid) - - [device reboot <uuid>](#device-reboot-uuid) - - [device shutdown <uuid>](#device-shutdown-uuid) - - [device rename <uuid> [newName]](#device-rename-uuid-newname) - - [device move <uuid>](#device-move-uuid) - [device init](#device-init) - [device os-update <uuid>](#device-os-update-uuid) + - [device identify <uuid>](#device-identify-uuid) + - [device <uuid>](#device-uuid) + - [device move <uuid>](#device-move-uuid) + - [device reboot <uuid>](#device-reboot-uuid) + - [device register <application>](#device-register-application) + - [device rename <uuid> [newname]](#device-rename-uuid-newname) + - [device rm <uuid>](#device-rm-uuid) + - [device shutdown <uuid>](#device-shutdown-uuid) + - [devices](#devices) - [devices supported](#devices-supported) - [device public-url <uuid>](#device-public-url-uuid) @@ -454,132 +454,6 @@ Examples: # Device -## devices - -Use this command to list all devices that belong to you. - -You can filter the devices by application by using the `--application` option. - -Examples: - - $ balena devices - $ balena devices --application MyApp - $ balena devices --app MyApp - $ balena devices -a MyApp - -### Options - -#### --application, -a, --app <application> - -application name - -## device <uuid> - -Use this command to show information about a single device. - -Examples: - - $ balena device 7cf02a6 - -## device register <application> - -Use this command to register a device to an application. - -Examples: - - $ balena device register MyApp - $ balena device register MyApp --uuid - -### Options - -#### --uuid, -u <uuid> - -custom uuid - -## device rm <uuid> - -Use this command to remove a device from balena. - -Notice this command asks for confirmation interactively. -You can avoid this by passing the `--yes` boolean option. - -Examples: - - $ balena device rm 7cf02a6 - $ balena device rm 7cf02a6 --yes - -### Options - -#### --yes, -y - -confirm non interactively - -## device identify <uuid> - -Use this command to identify a device. - -In the Raspberry Pi, the ACT led is blinked several times. - -Examples: - - $ balena device identify 23c73a1 - -## device reboot <uuid> - -Use this command to remotely reboot a device - -Examples: - - $ balena device reboot 23c73a1 - -### Options - -#### --force, -f - -force action if the update lock is set - -## device shutdown <uuid> - -Use this command to remotely shutdown a device - -Examples: - - $ balena device shutdown 23c73a1 - -### Options - -#### --force, -f - -force action if the update lock is set - -## device rename <uuid> [newName] - -Use this command to rename a device. - -If you omit the name, you'll get asked for it interactively. - -Examples: - - $ balena device rename 7cf02a6 - $ balena device rename 7cf02a6 MyPi - -## device move <uuid> - -Use this command to move a device to another application you own. - -If you omit the application, you'll get asked for it interactively. - -Examples: - - $ balena device move 7cf02a6 - $ balena device move 7cf02a6 --application MyNewApp - -### Options - -#### --application, -a, --app <application> - -application name - ## device init Use this command to download the OS image of a certain application and write it to an SD Card. @@ -646,6 +520,197 @@ a balenaOS version confirm non interactively +## device identify <uuid> + +Identify a device by making the ACT LED blink (Raspberry Pi). + +Examples: + + $ balena device identify 23c73a1 + +### Arguments + +#### UUID + +the uuid of the device to identify + +### Options + +## device <uuid> + +Show information about a single device. + +Examples: + + $ balena device 7cf02a6 + +### Arguments + +#### UUID + +the device uuid + +### Options + +## device move <uuid> + +Move a device to another application. + +Note, if the application option is omitted it will be prompted +for interactively. + +Examples: + + $ balena device move 7cf02a6 + $ balena device move 7cf02a6 --application MyNewApp + +### Arguments + +#### UUID + +the uuid of the device to move + +### Options + +#### -a, --application APPLICATION + +application name + +#### --app APP + +same as '--application' + +## device reboot <uuid> + +Remotely reboot a device. + +Examples: + + $ balena device reboot 23c73a1 + +### Arguments + +#### UUID + +the uuid of the device to reboot + +### Options + +#### -f, --force + +force action if the update lock is set + +## device register <application> + +Register a device to an application. + +Examples: + + $ balena device register MyApp + $ balena device register MyApp --uuid + +### Arguments + +#### APPLICATION + +the name or id of application to register device with + +### Options + +#### -u, --uuid UUID + +custom uuid + +## device rename <uuid> [newName] + +Rename a device. + +Note, if the name is omitted, it will be prompted for interactively. + +Examples: + + $ balena device rename 7cf02a6 + $ balena device rename 7cf02a6 MyPi + +### Arguments + +#### UUID + +the uuid of the device to rename + +#### NEWNAME + +the new name for the device + +### Options + +## device rm <uuid> + +Remove a device from balena. + +Note this command asks for confirmation interactively. +You can avoid this by passing the `--yes` option. + +Examples: + + $ balena device rm 7cf02a6 + $ balena device rm 7cf02a6 --yes + +### Arguments + +#### UUID + +the uuid of the device to remove + +### Options + +#### -y, --yes + +answer "yes" to all questions (non interactive use) + +## device shutdown <uuid> + +Remotely shutdown a device. + +Examples: + + $ balena device shutdown 23c73a1 + +### Arguments + +#### UUID + +the uuid of the device to shutdown + +### Options + +#### -f, --force + +force action if the update lock is set + +## devices + +list all devices that belong to you. + +You can filter the devices by application by using the `--application` option. + +Examples: + + $ balena devices + $ balena devices --application MyApp + $ balena devices --app MyApp + $ balena devices -a MyApp + +### Options + +#### -a, --application APPLICATION + +application name + +#### --app APP + +same as '--application' + ## devices supported List the supported device types (like 'raspberrypi3' or 'intel-nuc'). diff --git a/lib/actions-oclif/device/identify.ts b/lib/actions-oclif/device/identify.ts new file mode 100644 index 00000000..ccf9d215 --- /dev/null +++ b/lib/actions-oclif/device/identify.ts @@ -0,0 +1,75 @@ +/** + * @license + * Copyright 2016-2020 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'; +import { IArg } from '@oclif/parser/lib/args'; +import { stripIndent } from 'common-tags'; +import Command from '../../command'; +import * as cf from '../../utils/common-flags'; +import { getBalenaSdk } from '../../utils/lazy'; +import { tryAsInteger } from '../../utils/validation'; +import { ExpectedError } from '../../errors'; + +interface FlagsDef { + help: void; +} + +interface ArgsDef { + uuid: string; +} + +export default class DeviceIdentifyCmd extends Command { + public static description = stripIndent` + Identify a device. + + Identify a device by making the ACT LED blink (Raspberry Pi). + `; + public static examples = ['$ balena device identify 23c73a1']; + + public static args: Array> = [ + { + name: 'uuid', + description: 'the uuid of the device to identify', + parse: (dev) => tryAsInteger(dev), + required: true, + }, + ]; + + public static usage = 'device identify '; + + public static flags: flags.Input = { + help: cf.help, + }; + + public static authenticated = true; + + public async run() { + const { args: params } = this.parse(DeviceIdentifyCmd); + + const balena = getBalenaSdk(); + + try { + await balena.models.device.identify(params.uuid); + } catch (e) { + if (e.message === 'Request error: No online device(s) found') { + throw new ExpectedError(`Device ${params.uuid} is not online`); + } else { + throw e; + } + } + } +} diff --git a/lib/actions-oclif/device/index.ts b/lib/actions-oclif/device/index.ts new file mode 100644 index 00000000..d6cbfd32 --- /dev/null +++ b/lib/actions-oclif/device/index.ts @@ -0,0 +1,110 @@ +/** + * @license + * Copyright 2016-2020 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'; +import { IArg } from '@oclif/parser/lib/args'; +import { stripIndent } from 'common-tags'; +import Command from '../../command'; +import * as cf from '../../utils/common-flags'; +import { expandForAppName } from '../../utils/helpers'; +import { getBalenaSdk, getVisuals } from '../../utils/lazy'; +import { tryAsInteger } from '../../utils/validation'; +import { Application, Device } from 'balena-sdk'; + +interface ExtendedDevice extends Device { + dashboard_url?: string; + application_name?: string; + commit?: string; +} + +interface FlagsDef { + help: void; +} + +interface ArgsDef { + uuid: string; +} + +export default class DeviceCmd extends Command { + public static description = stripIndent` + Show info about a single device. + + Show information about a single device. + `; + public static examples = ['$ balena device 7cf02a6']; + + public static args: Array> = [ + { + name: 'uuid', + description: 'the device uuid', + parse: (dev) => tryAsInteger(dev), + required: true, + }, + ]; + + public static usage = 'device '; + + public static flags: flags.Input = { + help: cf.help, + }; + + public static authenticated = true; + public static primary = true; + + public async run() { + const { args: params } = this.parse(DeviceCmd); + + const balena = getBalenaSdk(); + + const device: ExtendedDevice = await balena.models.device.get( + params.uuid, + expandForAppName, + ); + + const deviceStatus = await balena.models.device.getStatus(device.uuid); + device.status = deviceStatus; + + device.dashboard_url = balena.models.device.getDashboardUrl(device.uuid); + + const belongsToApplication = device.belongs_to__application as Application[]; + device.application_name = belongsToApplication?.[0] + ? belongsToApplication[0].app_name + : 'N/a'; + + device.commit = device.is_on__commit; + + console.log( + getVisuals().table.vertical(device, [ + `$${device.device_name}$`, + 'id', + 'device_type', + 'status', + 'is_online', + 'ip_address', + 'application_name', + 'last_seen', + 'uuid', + 'commit', + 'supervisor_version', + 'is_web_accessible', + 'note', + 'os_version', + 'dashboard_url', + ]), + ); + } +} diff --git a/lib/actions-oclif/device/move.ts b/lib/actions-oclif/device/move.ts new file mode 100644 index 00000000..006fe11f --- /dev/null +++ b/lib/actions-oclif/device/move.ts @@ -0,0 +1,131 @@ +/** + * @license + * Copyright 2016-2020 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'; +import { IArg } from '@oclif/parser/lib/args'; +import { Application, Device } from 'balena-sdk'; +import { stripIndent } from 'common-tags'; +import * as _ from 'lodash'; +import Command from '../../command'; +import * as cf from '../../utils/common-flags'; +import { expandForAppName } from '../../utils/helpers'; +import { getBalenaSdk } from '../../utils/lazy'; +import { tryAsInteger } from '../../utils/validation'; + +interface ExtendedDevice extends Device { + application_name?: string; +} + +interface FlagsDef { + application?: string; + app?: string; + help: void; +} + +interface ArgsDef { + uuid: string; +} + +export default class DeviceMoveCmd extends Command { + public static description = stripIndent` + Move a device to another application. + + Move a device to another application. + + Note, if the application option is omitted it will be prompted + for interactively. + `; + public static examples = [ + '$ balena device move 7cf02a6', + '$ balena device move 7cf02a6 --application MyNewApp', + ]; + + public static args: Array> = [ + { + name: 'uuid', + description: 'the uuid of the device to move', + parse: (dev) => tryAsInteger(dev), + required: true, + }, + ]; + + public static usage = 'device move '; + + public static flags: flags.Input = { + application: cf.application, + app: cf.app, + help: cf.help, + }; + + public static authenticated = true; + + public async run() { + const { args: params, flags: options } = this.parse( + DeviceMoveCmd, + ); + + const balena = getBalenaSdk(); + const patterns = await import('../../utils/patterns'); + + // Consolidate application options + options.application = options.application || options.app; + delete options.app; + + const device: ExtendedDevice = await balena.models.device.get( + params.uuid, + expandForAppName, + ); + + const belongsToApplication = device.belongs_to__application as Application[]; + device.application_name = belongsToApplication?.[0] + ? belongsToApplication[0].app_name + : 'N/a'; + + // Get destination application + let application; + if (options.application) { + application = options.application; + } else { + const [deviceDeviceType, deviceTypes] = await Promise.all([ + balena.models.device.getManifestBySlug(device.device_type), + balena.models.config.getDeviceTypes(), + ]); + + const compatibleDeviceTypes = deviceTypes.filter( + (dt) => + balena.models.os.isArchitectureCompatibleWith( + deviceDeviceType.arch, + dt.arch, + ) && + !!dt.isDependent === !!deviceDeviceType.isDependent && + dt.state !== 'DISCONTINUED', + ); + + application = await patterns.selectApplication((app: Application) => + _.every([ + _.some(compatibleDeviceTypes, (dt) => dt.slug === app.device_type), + // @ts-ignore using the extended device object prop + device.application_name !== app.app_name, + ]), + ); + } + + await balena.models.device.move(params.uuid, tryAsInteger(application)); + + console.info(`${params.uuid} was moved to ${application}`); + } +} diff --git a/lib/actions-oclif/device/reboot.ts b/lib/actions-oclif/device/reboot.ts new file mode 100644 index 00000000..0675048c --- /dev/null +++ b/lib/actions-oclif/device/reboot.ts @@ -0,0 +1,73 @@ +/** + * @license + * Copyright 2016-2020 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'; +import { IArg } from '@oclif/parser/lib/args'; +import { stripIndent } from 'common-tags'; +import Command from '../../command'; +import * as cf from '../../utils/common-flags'; +import { getBalenaSdk } from '../../utils/lazy'; +import { tryAsInteger } from '../../utils/validation'; + +interface FlagsDef { + force: boolean; + help: void; +} + +interface ArgsDef { + uuid: string; +} + +export default class DeviceRebootCmd extends Command { + public static description = stripIndent` + Restart a device. + + Remotely reboot a device. + `; + public static examples = ['$ balena device reboot 23c73a1']; + + public static args: Array> = [ + { + name: 'uuid', + description: 'the uuid of the device to reboot', + parse: (dev) => tryAsInteger(dev), + required: true, + }, + ]; + + public static usage = 'device reboot '; + + public static flags: flags.Input = { + force: cf.force, + help: cf.help, + }; + + public static authenticated = true; + + public async run() { + const { args: params, flags: options } = this.parse( + DeviceRebootCmd, + ); + + const balena = getBalenaSdk(); + + // The SDK current throws "BalenaDeviceNotFound: Device not found: xxxxx" + // when the device is not online, which may be confusing. + // https://github.com/balena-io/balena-cli/issues/1872 + await balena.models.device.reboot(params.uuid, options); + } +} diff --git a/lib/actions-oclif/device/register.ts b/lib/actions-oclif/device/register.ts new file mode 100644 index 00000000..d17b005a --- /dev/null +++ b/lib/actions-oclif/device/register.ts @@ -0,0 +1,83 @@ +/** + * @license + * Copyright 2016-2020 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'; +import { IArg } from '@oclif/parser/lib/args'; +import { stripIndent } from 'common-tags'; +import Command from '../../command'; +import * as cf from '../../utils/common-flags'; +import { getBalenaSdk } from '../../utils/lazy'; +import { tryAsInteger } from '../../utils/validation'; + +interface FlagsDef { + uuid?: string; + help: void; +} + +interface ArgsDef { + application: string; +} + +export default class DeviceRegisterCmd extends Command { + public static description = stripIndent` + Register a device. + + Register a device to an application. + `; + public static examples = [ + '$ balena device register MyApp', + '$ balena device register MyApp --uuid ', + ]; + + public static args: Array> = [ + { + name: 'application', + description: 'the name or id of application to register device with', + parse: (app) => tryAsInteger(app), + required: true, + }, + ]; + + public static usage = 'device register '; + + public static flags: flags.Input = { + uuid: flags.string({ + description: 'custom uuid', + char: 'u', + }), + help: cf.help, + }; + + public static authenticated = true; + + public async run() { + const { args: params, flags: options } = this.parse( + DeviceRegisterCmd, + ); + + const balena = getBalenaSdk(); + + const application = await balena.models.application.get(params.application); + const uuid = options.uuid ?? balena.models.device.generateUniqueKey(); + + console.info(`Registering to ${application.app_name}: ${uuid}`); + + const result = await balena.models.device.register(application.id, uuid); + + return result && result.uuid; + } +} diff --git a/lib/actions-oclif/device/rename.ts b/lib/actions-oclif/device/rename.ts new file mode 100644 index 00000000..1bf79cbf --- /dev/null +++ b/lib/actions-oclif/device/rename.ts @@ -0,0 +1,85 @@ +/** + * @license + * Copyright 2016-2020 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'; +import { IArg } from '@oclif/parser/lib/args'; +import { stripIndent } from 'common-tags'; +import Command from '../../command'; +import * as cf from '../../utils/common-flags'; +import { getBalenaSdk } from '../../utils/lazy'; +import { tryAsInteger } from '../../utils/validation'; + +interface FlagsDef { + help: void; +} + +interface ArgsDef { + uuid: string; + newName?: string; +} + +export default class DeviceRenameCmd extends Command { + public static description = stripIndent` + Rename a device. + + Rename a device. + + Note, if the name is omitted, it will be prompted for interactively. + `; + public static examples = [ + '$ balena device rename 7cf02a6', + '$ balena device rename 7cf02a6 MyPi', + ]; + + public static args: Array> = [ + { + name: 'uuid', + description: 'the uuid of the device to rename', + parse: (dev) => tryAsInteger(dev), + required: true, + }, + { + name: 'newName', + description: 'the new name for the device', + }, + ]; + + public static usage = 'device rename [newName]'; + + public static flags: flags.Input = { + help: cf.help, + }; + + public static authenticated = true; + + public async run() { + const { args: params } = this.parse(DeviceRenameCmd); + + const balena = getBalenaSdk(); + const form = await import('resin-cli-form'); + + const newName = + params.newName || + (await form.ask({ + message: 'How do you want to name this device?', + type: 'input', + })) || + ''; + + await balena.models.device.rename(params.uuid, newName); + } +} diff --git a/lib/actions-oclif/device/rm.ts b/lib/actions-oclif/device/rm.ts new file mode 100644 index 00000000..0d1ab38a --- /dev/null +++ b/lib/actions-oclif/device/rm.ts @@ -0,0 +1,84 @@ +/** + * @license + * Copyright 2016-2020 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'; +import { IArg } from '@oclif/parser/lib/args'; +import { stripIndent } from 'common-tags'; +import Command from '../../command'; +import * as cf from '../../utils/common-flags'; +import { getBalenaSdk } from '../../utils/lazy'; +import { tryAsInteger } from '../../utils/validation'; + +interface FlagsDef { + yes: boolean; + help: void; +} + +interface ArgsDef { + uuid: string; +} + +export default class DeviceRmCmd extends Command { + public static description = stripIndent` + Remove a device. + + Remove a device from balena. + + Note this command asks for confirmation interactively. + You can avoid this by passing the \`--yes\` option. + `; + public static examples = [ + '$ balena device rm 7cf02a6', + '$ balena device rm 7cf02a6 --yes', + ]; + + public static args: Array> = [ + { + name: 'uuid', + description: 'the uuid of the device to remove', + parse: (dev) => tryAsInteger(dev), + required: true, + }, + ]; + + public static usage = 'device rm '; + + public static flags: flags.Input = { + yes: cf.yes, + help: cf.help, + }; + + public static authenticated = true; + + public async run() { + const { args: params, flags: options } = this.parse( + DeviceRmCmd, + ); + + const balena = getBalenaSdk(); + const patterns = await import('../../utils/patterns'); + + // Confirm + await patterns.confirm( + options.yes, + 'Are you sure you want to delete the device?', + ); + + // Remove + await balena.models.device.remove(params.uuid); + } +} diff --git a/lib/actions-oclif/device/shutdown.ts b/lib/actions-oclif/device/shutdown.ts new file mode 100644 index 00000000..74438ff0 --- /dev/null +++ b/lib/actions-oclif/device/shutdown.ts @@ -0,0 +1,79 @@ +/** + * @license + * Copyright 2016-2020 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'; +import { IArg } from '@oclif/parser/lib/args'; +import { stripIndent } from 'common-tags'; +import Command from '../../command'; +import * as cf from '../../utils/common-flags'; +import { getBalenaSdk } from '../../utils/lazy'; +import { tryAsInteger } from '../../utils/validation'; +import { ExpectedError } from '../../errors'; + +interface FlagsDef { + force: boolean; + help: void; +} + +interface ArgsDef { + uuid: string; +} + +export default class DeviceShutdownCmd extends Command { + public static description = stripIndent` + Shutdown a device. + + Remotely shutdown a device. + `; + public static examples = ['$ balena device shutdown 23c73a1']; + + public static args: Array> = [ + { + name: 'uuid', + description: 'the uuid of the device to shutdown', + parse: (dev) => tryAsInteger(dev), + required: true, + }, + ]; + + public static usage = 'device shutdown '; + + public static flags: flags.Input = { + force: cf.force, + help: cf.help, + }; + + public static authenticated = true; + + public async run() { + const { args: params, flags: options } = this.parse( + DeviceShutdownCmd, + ); + + const balena = getBalenaSdk(); + + try { + await balena.models.device.shutdown(params.uuid, options); + } catch (e) { + if (e.message === 'Request error: No online device(s) found') { + throw new ExpectedError(`Device ${params.uuid} is not online`); + } else { + throw e; + } + } + } +} diff --git a/lib/actions-oclif/devices/index.ts b/lib/actions-oclif/devices/index.ts new file mode 100644 index 00000000..def9a2f3 --- /dev/null +++ b/lib/actions-oclif/devices/index.ts @@ -0,0 +1,113 @@ +/** + * @license + * Copyright 2016-2020 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'; +import { stripIndent } from 'common-tags'; +import * as _ from 'lodash'; +import Command from '../../command'; +import * as cf from '../../utils/common-flags'; +import { expandForAppName } from '../../utils/helpers'; +import { getBalenaSdk, getVisuals } from '../../utils/lazy'; +import { tryAsInteger } from '../../utils/validation'; +import { Device, Application } from 'balena-sdk'; + +interface ExtendedDevice extends Device { + dashboard_url?: string; + application_name?: string; +} + +interface FlagsDef { + application?: string; + app?: string; + help: void; +} + +export default class DevicesCmd extends Command { + public static description = stripIndent` + List all devices. + + list all devices that belong to you. + + You can filter the devices by application by using the \`--application\` option. + `; + public static examples = [ + '$ balena devices', + '$ balena devices --application MyApp', + '$ balena devices --app MyApp', + '$ balena devices -a MyApp', + ]; + + public static usage = 'devices'; + + public static flags: flags.Input = { + application: cf.application, + app: cf.app, + help: cf.help, + }; + + public static primary = true; + + public static authenticated = true; + + public async run() { + const { flags: options } = this.parse(DevicesCmd); + + const balena = getBalenaSdk(); + + // Consolidate application options + options.application = options.application || options.app; + delete options.app; + + let devices: ExtendedDevice[]; + + if (options.application != null) { + devices = await balena.models.device.getAllByApplication( + tryAsInteger(options.application), + expandForAppName, + ); + } else { + devices = await balena.models.device.getAll(expandForAppName); + } + + devices = _.map(devices, function (device) { + device.dashboard_url = balena.models.device.getDashboardUrl(device.uuid); + + const belongsToApplication = device.belongs_to__application as Application[]; + device.application_name = belongsToApplication?.[0] + ? belongsToApplication[0].app_name + : 'N/a'; + + device.uuid = device.uuid.slice(0, 7); + return device; + }); + + console.log( + getVisuals().table.horizontal(devices, [ + 'id', + 'uuid', + 'device_name', + 'device_type', + 'application_name', + 'status', + 'is_online', + 'supervisor_version', + 'os_version', + 'dashboard_url', + ]), + ); + } +} diff --git a/lib/actions/device.js b/lib/actions/device.js index e4413b2a..95d4211d 100644 --- a/lib/actions/device.js +++ b/lib/actions/device.js @@ -17,351 +17,7 @@ limitations under the License. import * as commandOptions from './command-options'; import * as _ from 'lodash'; -import { normalizeUuidProp } from '../utils/normalization'; -import { getBalenaSdk, getVisuals } from '../utils/lazy'; - -/** @type {import('balena-sdk').PineOptionsFor} */ -const expandForAppName = { - $expand: { belongs_to__application: { $select: 'app_name' } }, -}; - -export const list = { - signature: 'devices', - description: 'list all devices', - help: `\ -Use this command to list all devices that belong to you. - -You can filter the devices by application by using the \`--application\` option. - -Examples: - - $ balena devices - $ balena devices --application MyApp - $ balena devices --app MyApp - $ balena devices -a MyApp\ -`, - options: [commandOptions.optionalApplication], - permission: 'user', - primary: true, - action(_params, options) { - const Promise = require('bluebird'); - const balena = getBalenaSdk(); - - return Promise.try(function () { - if (options.application != null) { - return balena.models.device.getAllByApplication( - options.application, - expandForAppName, - ); - } - return balena.models.device.getAll(expandForAppName); - }).tap(function (devices) { - devices = _.map(devices, function (device) { - // @ts-ignore extending the device object with extra props - device.dashboard_url = balena.models.device.getDashboardUrl( - device.uuid, - ); - // @ts-ignore extending the device object with extra props - device.application_name = device.belongs_to__application?.[0] - ? device.belongs_to__application[0].app_name - : 'N/a'; - device.uuid = device.uuid.slice(0, 7); - return device; - }); - - console.log( - getVisuals().table.horizontal(devices, [ - 'id', - 'uuid', - 'device_name', - 'device_type', - 'application_name', - 'status', - 'is_online', - 'supervisor_version', - 'os_version', - 'dashboard_url', - ]), - ); - }); - }, -}; - -export const info = { - signature: 'device ', - description: 'list a single device', - help: `\ -Use this command to show information about a single device. - -Examples: - - $ balena device 7cf02a6\ -`, - permission: 'user', - primary: true, - action(params) { - normalizeUuidProp(params); - const balena = getBalenaSdk(); - - return balena.models.device - .get(params.uuid, expandForAppName) - .then((device) => - // @ts-ignore `device.getStatus` requires a device with service info, but - // this device isn't typed with them, possibly needs fixing? - balena.models.device.getStatus(params.uuid).then(function (status) { - device.status = status; - // @ts-ignore extending the device object with extra props - device.dashboard_url = balena.models.device.getDashboardUrl( - device.uuid, - ); - // @ts-ignore extending the device object with extra props - device.application_name = device.belongs_to__application?.[0] - ? device.belongs_to__application[0].app_name - : 'N/a'; - // @ts-ignore extending the device object with extra props - device.commit = device.is_on__commit; - - console.log( - getVisuals().table.vertical(device, [ - `$${device.device_name}$`, - 'id', - 'device_type', - 'status', - 'is_online', - 'ip_address', - 'mac_address', - 'application_name', - 'last_seen', - 'uuid', - 'commit', - 'supervisor_version', - 'is_web_accessible', - 'note', - 'os_version', - 'dashboard_url', - ]), - ); - }), - ); - }, -}; - -export const register = { - signature: 'device register ', - description: 'register a device', - help: `\ -Use this command to register a device to an application. - -Examples: - - $ balena device register MyApp - $ balena device register MyApp --uuid \ -`, - permission: 'user', - options: [ - { - signature: 'uuid', - description: 'custom uuid', - parameter: 'uuid', - alias: 'u', - }, - ], - action(params, options) { - const Promise = require('bluebird'); - const balena = getBalenaSdk(); - - return Promise.join( - balena.models.application.get(params.application), - options.uuid ?? balena.models.device.generateUniqueKey(), - function (application, uuid) { - console.info(`Registering to ${application.app_name}: ${uuid}`); - return balena.models.device.register(application.id, uuid); - }, - ).get('uuid'); - }, -}; - -export const remove = { - signature: 'device rm ', - description: 'remove a device', - help: `\ -Use this command to remove a device from balena. - -Notice this command asks for confirmation interactively. -You can avoid this by passing the \`--yes\` boolean option. - -Examples: - - $ balena device rm 7cf02a6 - $ balena device rm 7cf02a6 --yes\ -`, - options: [commandOptions.yes], - permission: 'user', - action(params, options) { - normalizeUuidProp(params); - const balena = getBalenaSdk(); - const patterns = require('../utils/patterns'); - - return patterns - .confirm(options.yes, 'Are you sure you want to delete the device?') - .then(() => balena.models.device.remove(params.uuid)); - }, -}; - -export const identify = { - signature: 'device identify ', - description: 'identify a device with a UUID', - help: `\ -Use this command to identify a device. - -In the Raspberry Pi, the ACT led is blinked several times. - -Examples: - - $ balena device identify 23c73a1\ -`, - permission: 'user', - action(params) { - normalizeUuidProp(params); - const balena = getBalenaSdk(); - return balena.models.device.identify(params.uuid); - }, -}; - -export const reboot = { - signature: 'device reboot ', - description: 'restart a device', - help: `\ -Use this command to remotely reboot a device - -Examples: - - $ balena device reboot 23c73a1\ -`, - options: [commandOptions.forceUpdateLock], - permission: 'user', - action(params, options) { - normalizeUuidProp(params); - const balena = getBalenaSdk(); - return balena.models.device.reboot(params.uuid, options); - }, -}; - -export const shutdown = { - signature: 'device shutdown ', - description: 'shutdown a device', - help: `\ -Use this command to remotely shutdown a device - -Examples: - - $ balena device shutdown 23c73a1\ -`, - options: [commandOptions.forceUpdateLock], - permission: 'user', - action(params, options) { - normalizeUuidProp(params); - const balena = getBalenaSdk(); - return balena.models.device.shutdown(params.uuid, options); - }, -}; - -export const rename = { - signature: 'device rename [newName]', - description: 'rename a balena device', - help: `\ -Use this command to rename a device. - -If you omit the name, you'll get asked for it interactively. - -Examples: - - $ balena device rename 7cf02a6 - $ balena device rename 7cf02a6 MyPi\ -`, - permission: 'user', - action(params) { - normalizeUuidProp(params); - const Promise = require('bluebird'); - const balena = getBalenaSdk(); - const form = require('resin-cli-form'); - - return Promise.try(function () { - if (!_.isEmpty(params.newName)) { - return params.newName; - } - - return form.ask({ - message: 'How do you want to name this device?', - type: 'input', - }); - }).then(_.partial(balena.models.device.rename, params.uuid)); - }, -}; - -export const move = { - signature: 'device move ', - description: 'move a device to another application', - help: `\ -Use this command to move a device to another application you own. - -If you omit the application, you'll get asked for it interactively. - -Examples: - - $ balena device move 7cf02a6 - $ balena device move 7cf02a6 --application MyNewApp\ -`, - permission: 'user', - options: [commandOptions.optionalApplication], - action(params, options) { - normalizeUuidProp(params); - const balena = getBalenaSdk(); - const patterns = require('../utils/patterns'); - - return balena.models.device - .get(params.uuid, expandForAppName) - .then(function (device) { - // @ts-ignore extending the device object with extra props - device.application_name = device.belongs_to__application?.[0] - ? device.belongs_to__application[0].app_name - : 'N/a'; - if (options.application) { - return options.application; - } - - return Promise.all([ - balena.models.device.getManifestBySlug(device.device_type), - balena.models.config.getDeviceTypes(), - ]).then(function ([deviceDeviceType, deviceTypes]) { - const compatibleDeviceTypes = deviceTypes.filter( - (dt) => - balena.models.os.isArchitectureCompatibleWith( - deviceDeviceType.arch, - dt.arch, - ) && - !!dt.isDependent === !!deviceDeviceType.isDependent && - dt.state !== 'DISCONTINUED', - ); - - return patterns.selectApplication((application) => - _.every([ - _.some( - compatibleDeviceTypes, - (dt) => dt.slug === application.device_type, - ), - // @ts-ignore using the extended device object prop - device.application_name !== application.app_name, - ]), - ); - }); - }) - .tap((application) => balena.models.device.move(params.uuid, application)) - .then((application) => { - console.info(`${params.uuid} was moved to ${application}`); - }); - }, -}; +import { getBalenaSdk } from '../utils/lazy'; export const init = { signature: 'device init', diff --git a/lib/app-capitano.js b/lib/app-capitano.js index 5d87d6f2..557f113b 100644 --- a/lib/app-capitano.js +++ b/lib/app-capitano.js @@ -53,17 +53,8 @@ capitano.command(actions.auth.logout); capitano.command(actions.auth.whoami); // ---------- Device Module ---------- -capitano.command(actions.device.list); -capitano.command(actions.device.rename); capitano.command(actions.device.init); -capitano.command(actions.device.remove); -capitano.command(actions.device.identify); -capitano.command(actions.device.reboot); -capitano.command(actions.device.shutdown); -capitano.command(actions.device.register); -capitano.command(actions.device.move); capitano.command(actions.device.osUpdate); -capitano.command(actions.device.info); // ---------- OS Module ---------- capitano.command(actions.os.versions); diff --git a/lib/errors.ts b/lib/errors.ts index 21624b22..124d1550 100644 --- a/lib/errors.ts +++ b/lib/errors.ts @@ -139,7 +139,8 @@ const EXPECTED_ERROR_REGEXES = [ /^BalenaApplicationNotFound/, // balena-sdk /^BalenaDeviceNotFound/, // balena-sdk /^BalenaExpiredToken/, // balena-sdk - /^Missing \w+$/, // Capitano, oclif parser: RequiredArgsError, RequiredFlagError + /^Missing \w+$/, // Capitano, + /^Missing \d required argument/, // oclif parser: RequiredArgsError, RequiredFlagError /^Unexpected argument/, // oclif parser: UnexpectedArgsError /to be one of/, // oclif parser: FlagInvalidOptionError, ArgInvalidOptionError ]; diff --git a/lib/preparser.ts b/lib/preparser.ts index e2217c97..ae0af587 100644 --- a/lib/preparser.ts +++ b/lib/preparser.ts @@ -141,7 +141,16 @@ export const convertedCommands = [ 'app:rm', 'apps', 'api-key:generate', + 'device', + 'device:identify', + 'device:move', 'device:public-url', + 'device:reboot', + 'device:register', + 'device:rename', + 'device:rm', + 'device:shutdown', + 'devices', 'devices:supported', 'envs', 'env:add', diff --git a/lib/utils/common-flags.ts b/lib/utils/common-flags.ts index 4fc36b71..1d97e192 100644 --- a/lib/utils/common-flags.ts +++ b/lib/utils/common-flags.ts @@ -23,6 +23,10 @@ export const application = flags.string({ char: 'a', description: 'application name', }); +// TODO: Consider remove second alias 'app' when we can, to simplify. +export const app = flags.string({ + description: "same as '--application'", +}); export const device = flags.string({ char: 'd', @@ -56,3 +60,8 @@ export const yes: IBooleanFlag = flags.boolean({ char: 'y', description: 'answer "yes" to all questions (non interactive use)', }); + +export const force: IBooleanFlag = flags.boolean({ + char: 'f', + description: 'force action if the update lock is set', +}); diff --git a/lib/utils/helpers.ts b/lib/utils/helpers.ts index 04ca52d1..3acb87ed 100644 --- a/lib/utils/helpers.ts +++ b/lib/utils/helpers.ts @@ -22,6 +22,7 @@ import * as _ from 'lodash'; import * as os from 'os'; import * as ShellEscape from 'shell-escape'; +import { Device, PineOptionsFor } from 'balena-sdk'; import { ExpectedError } from '../errors'; import { getBalenaSdk, getChalk, getVisuals } from './lazy'; @@ -466,3 +467,7 @@ export function getProxyConfig(): ProxyConfig | undefined { } } } + +export const expandForAppName: PineOptionsFor = { + $expand: { belongs_to__application: { $select: 'app_name' } }, +}; diff --git a/tests/commands/device/device-move.spec.ts b/tests/commands/device/device-move.spec.ts index 41591119..9698855b 100644 --- a/tests/commands/device/device-move.spec.ts +++ b/tests/commands/device/device-move.spec.ts @@ -20,20 +20,28 @@ import { BalenaAPIMock } from '../../balena-api-mock'; import { cleanOutput, runCommand } from '../../helpers'; const HELP_RESPONSE = ` -Usage: device move +Move a device to another application. -Use this command to move a device to another application you own. +USAGE + $ balena device move -If you omit the application, you'll get asked for it interactively. +ARGUMENTS + the uuid of the device to move -Examples: +OPTIONS + -a, --application application name + -h, --help show CLI help + --app same as '--application' -\t$ balena device move 7cf02a6 -\t$ balena device move 7cf02a6 --application MyNewApp +DESCRIPTION + Move a device to another application. -Options: + Note, if the application option is omitted it will be prompted + for interactively. - --application, -a, --app application name +EXAMPLES + $ balena device move 7cf02a6 + $ balena device move 7cf02a6 --application MyNewApp `; describe('balena device move', function () { @@ -49,7 +57,7 @@ describe('balena device move', function () { }); it('should print help text with the -h flag', async () => { - api.expectGetWhoAmI({ optional: true }); + api.expectGetWhoAmI({ optional: true, persist: true }); api.expectGetMixpanel({ optional: true }); const { out, err } = await runCommand('device move -h'); @@ -59,16 +67,15 @@ describe('balena device move', function () { expect(err).to.eql([]); }); - it.skip('should error if uuid not provided', async () => { - // TODO: Figure out how to test for expected errors with current setup - // including exit codes if possible. - api.expectGetWhoAmI({ optional: true }); + it('should error if uuid not provided', async () => { + api.expectGetWhoAmI({ optional: true, persist: true }); api.expectGetMixpanel({ optional: true }); const { out, err } = await runCommand('device move'); const errLines = cleanOutput(err); - expect(errLines[0]).to.equal('Missing uuid'); + expect(errLines[0]).to.equal('Missing 1 required argument:'); + expect(errLines[1]).to.equal('uuid : the uuid of the device to move'); expect(out).to.eql([]); }); diff --git a/tests/commands/device/device.spec.ts b/tests/commands/device/device.spec.ts index 7f6b71b1..a3cee520 100644 --- a/tests/commands/device/device.spec.ts +++ b/tests/commands/device/device.spec.ts @@ -22,13 +22,22 @@ import { apiResponsePath, BalenaAPIMock } from '../../balena-api-mock'; import { cleanOutput, runCommand } from '../../helpers'; const HELP_RESPONSE = ` -Usage: device +Show info about a single device. -Use this command to show information about a single device. +USAGE + $ balena device -Examples: +ARGUMENTS + the device uuid -\t$ balena device 7cf02a6 +OPTIONS + -h, --help show CLI help + +DESCRIPTION + Show information about a single device. + +EXAMPLE + $ balena device 7cf02a6 `; describe('balena device', function () { @@ -44,7 +53,7 @@ describe('balena device', function () { }); it('should print help text with the -h flag', async () => { - api.expectGetWhoAmI({ optional: true }); + api.expectGetWhoAmI({ optional: true, persist: true }); api.expectGetMixpanel({ optional: true }); const { out, err } = await runCommand('device -h'); @@ -54,16 +63,15 @@ describe('balena device', function () { expect(err).to.eql([]); }); - it.skip('should error if uuid not provided', async () => { - // TODO: Figure out how to test for expected errors with current setup - // including exit codes if possible. - api.expectGetWhoAmI({ optional: true }); + it('should error if uuid not provided', async () => { + api.expectGetWhoAmI({ optional: true, persist: true }); api.expectGetMixpanel({ optional: true }); const { out, err } = await runCommand('device'); const errLines = cleanOutput(err); - expect(errLines[0]).to.equal('Missing uuid'); + expect(errLines[0]).to.equal('Missing 1 required argument:'); + expect(errLines[1]).to.equal('uuid : the device uuid'); expect(out).to.eql([]); }); diff --git a/tests/commands/device/devices.spec.ts b/tests/commands/device/devices.spec.ts index a600ad7d..0bb223ff 100644 --- a/tests/commands/device/devices.spec.ts +++ b/tests/commands/device/devices.spec.ts @@ -22,22 +22,26 @@ import { apiResponsePath, BalenaAPIMock } from '../../balena-api-mock'; import { cleanOutput, runCommand } from '../../helpers'; const HELP_RESPONSE = ` -Usage: devices +List all devices. -Use this command to list all devices that belong to you. +USAGE + $ balena devices -You can filter the devices by application by using the \`--application\` option. +OPTIONS + -a, --application application name + -h, --help show CLI help + --app same as '--application' -Examples: +DESCRIPTION + list all devices that belong to you. -\t$ balena devices -\t$ balena devices --application MyApp -\t$ balena devices --app MyApp -\t$ balena devices -a MyApp + You can filter the devices by application by using the \`--application\` option. -Options: - - --application, -a, --app application name +EXAMPLES + $ balena devices + $ balena devices --application MyApp + $ balena devices --app MyApp + $ balena devices -a MyApp `; describe('balena devices', function () { @@ -53,7 +57,7 @@ describe('balena devices', function () { }); it('should print help text with the -h flag', async () => { - api.expectGetWhoAmI({ optional: true }); + api.expectGetWhoAmI({ optional: true, persist: true }); api.expectGetMixpanel({ optional: true }); const { out, err } = await runCommand('devices -h'); diff --git a/tests/commands/help.spec.ts b/tests/commands/help.spec.ts index 56e40c80..24fec796 100644 --- a/tests/commands/help.spec.ts +++ b/tests/commands/help.spec.ts @@ -14,7 +14,7 @@ Primary commands: apps list all applications app display information about a single application devices list all devices - device list a single device + device show info about a single device tunnel Tunnel local ports to your balenaOS device preload preload an app on a disk image (or Edison zip archive) build [source] Build a single image or a multicontainer project locally @@ -37,14 +37,14 @@ Additional commands: config read read a device configuration config reconfigure reconfigure a provisioned device config write write a device configuration - device identify identify a device with a UUID + device identify identify a device device init initialise a device with balenaOS device move move a device to another application device os-update Start a Host OS update for a device device public-url get or manage the public URL for a device device reboot restart a device device register register a device - device rename [newName] rename a balena device + device rename [newname] rename a device device rm remove a device device shutdown shutdown a device devices supported list the supported device types (like 'raspberrypi3' or 'intel-nuc') diff --git a/tests/errors.spec.ts b/tests/errors.spec.ts index 861843c9..0ae930f5 100644 --- a/tests/errors.spec.ts +++ b/tests/errors.spec.ts @@ -117,8 +117,9 @@ describe('handleError() function', () => { }); const messagesToMatch = [ - 'Missing argument', - 'Missing arguments', + 'Missing uuid', // Capitano + 'Missing 1 required argument', // oclif + 'Missing 2 required arguments', // oclif 'Unexpected argument', 'Unexpected arguments', 'to be one of',