diff --git a/lib/actions/os.coffee b/lib/actions/os.coffee deleted file mode 100644 index dae2e6b8..00000000 --- a/lib/actions/os.coffee +++ /dev/null @@ -1,288 +0,0 @@ -### -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. -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. -### - -commandOptions = require('./command-options') -_ = require('lodash') -{ getBalenaSdk, getVisuals } = require('../utils/lazy') - -formatVersion = (v, isRecommended) -> - result = "v#{v}" - if isRecommended - result += ' (recommended)' - return result - -resolveVersion = (deviceType, version) -> - if version isnt 'menu' - if version[0] == 'v' - version = version.slice(1) - return Promise.resolve(version) - - form = require('resin-cli-form') - balena = getBalenaSdk() - - balena.models.os.getSupportedVersions(deviceType) - .then ({ versions, recommended }) -> - choices = versions.map (v) -> - value: v - name: formatVersion(v, v is recommended) - - return form.ask - message: 'Select the OS version:' - type: 'list' - choices: choices - default: recommended - -exports.versions = - signature: 'os versions ' - description: 'show the available balenaOS versions for the given device type' - help: ''' - Use this command to show the available balenaOS versions for a certain device type. - Check available types with `balena devices supported` - - Example: - - $ balena os versions raspberrypi3 - ''' - action: (params, options) -> - balena = getBalenaSdk() - - balena.models.os.getSupportedVersions(params.type) - .then ({ versions, recommended }) -> - versions.forEach (v) -> - console.log(formatVersion(v, v is recommended)) - -exports.download = - signature: 'os download ' - description: 'download an unconfigured os image' - help: ''' - Use this command to download an unconfigured os image for a certain device type. - Check available types with `balena devices supported` - - If version is not specified the newest stable (non-pre-release) version of OS - is downloaded if available, or the newest version otherwise (if all existing - versions for the given device type are pre-release). - - You can pass `--version menu` to pick the OS version from the interactive menu - of all available versions. - - Examples: - - $ balena os download raspberrypi3 -o ../foo/bar/raspberry-pi.img - $ balena os download raspberrypi3 -o ../foo/bar/raspberry-pi.img --version 1.24.1 - $ balena os download raspberrypi3 -o ../foo/bar/raspberry-pi.img --version ^1.20.0 - $ balena os download raspberrypi3 -o ../foo/bar/raspberry-pi.img --version latest - $ balena os download raspberrypi3 -o ../foo/bar/raspberry-pi.img --version default - $ balena os download raspberrypi3 -o ../foo/bar/raspberry-pi.img --version menu - ''' - permission: 'user' - options: [ - { - signature: 'output' - description: 'output path' - parameter: 'output' - alias: 'o' - required: 'You have to specify the output location' - } - commandOptions.osVersionOrSemver - ] - action: (params, options) -> - Promise = require('bluebird') - unzip = require('node-unzip-2') - fs = require('fs') - rindle = require('rindle') - manager = require('balena-image-manager') - - console.info("Getting device operating system for #{params.type}") - - displayVersion = '' - Promise.try -> - if not options.version - console.warn('OS version is not specified, using the default version: - the newest stable (non-pre-release) version if available, - or the newest version otherwise (if all existing - versions for the given device type are pre-release).') - return 'default' - return resolveVersion(params.type, options.version) - .then (version) -> - if version isnt 'default' - displayVersion = " #{version}" - return manager.get(params.type, version) - .then (stream) -> - visuals = getVisuals() - bar = new visuals.Progress("Downloading Device OS#{displayVersion}") - spinner = new visuals.Spinner("Downloading Device OS#{displayVersion} (size unknown)") - - stream.on 'progress', (state) -> - if state? - bar.update(state) - else - spinner.start() - - stream.on 'end', -> - spinner.stop() - - # We completely rely on the `mime` custom property - # to make this decision. - # The actual stream should be checked instead. - if stream.mime is 'application/zip' - output = unzip.Extract(path: options.output) - else - output = fs.createWriteStream(options.output) - - return rindle.wait(stream.pipe(output)).return(options.output) - .tap (output) -> - console.info('The image was downloaded successfully') - -buildConfigForDeviceType = (deviceType, advanced = false) -> - form = require('resin-cli-form') - helpers = require('../utils/helpers') - - questions = deviceType.options - if not advanced - advancedGroup = _.find questions, - name: 'advanced' - isGroup: true - - if advancedGroup? - override = helpers.getGroupDefaults(advancedGroup) - - return form.run(questions, { override }) - -buildConfig = (image, deviceTypeSlug, advanced = false) -> - Promise = require('bluebird') - helpers = require('../utils/helpers') - - Promise.resolve(helpers.getManifest(image, deviceTypeSlug)) - .then (deviceTypeManifest) -> - buildConfigForDeviceType(deviceTypeManifest, advanced) - -exports.buildConfig = - signature: 'os build-config ' - description: 'build the OS config and save it to the JSON file' - help: ''' - Use this command to prebuild the OS config once and skip the interactive part of `balena os configure`. - - Example: - - $ balena os build-config ../path/rpi3.img raspberrypi3 --output rpi3-config.json - $ balena os configure ../path/rpi3.img --device 7cf02a6 --config rpi3-config.json - ''' - permission: 'user' - options: [ - commandOptions.advancedConfig - { - signature: 'output' - description: 'the path to the output JSON file' - alias: 'o' - required: 'the output path is required' - parameter: 'output' - } - ] - action: (params, options) -> - fs = require('fs') - Promise = require('bluebird') - writeFileAsync = Promise.promisify(fs.writeFile) - - buildConfig(params.image, params['device-type'], options.advanced) - .then (answers) -> - writeFileAsync(options.output, JSON.stringify(answers, null, 4)) - -INIT_WARNING_MESSAGE = ''' - Note: Initializing the device may ask for administrative permissions - because we need to access the raw devices directly. -''' - -exports.initialize = - signature: 'os initialize ' - description: 'initialize an os image' - help: """ - Use this command to initialize a device with previously configured operating system image. - - #{INIT_WARNING_MESSAGE} - - Examples: - - $ balena os initialize ../path/rpi.img --type 'raspberry-pi' - """ - permission: 'user' - options: [ - commandOptions.yes - { - signature: 'type' - description: 'device type (Check available types with `balena devices supported`)' - parameter: 'type' - alias: 't' - required: 'You have to specify a device type' - } - commandOptions.drive - ] - action: (params, options) -> - Promise = require('bluebird') - umountAsync = Promise.promisify(require('umount').umount) - form = require('resin-cli-form') - patterns = require('../utils/patterns') - helpers = require('../utils/helpers') - - console.info(""" - Initializing device - - #{INIT_WARNING_MESSAGE} - """) - Promise.resolve(helpers.getManifest(params.image, options.type)) - .then (manifest) -> - return manifest.initialization?.options - .then (questions) -> - return form.run questions, - override: - drive: options.drive - .tap (answers) -> - return if not answers.drive? - patterns.confirm( - options.yes - "This will erase #{answers.drive}. Are you sure?" - "Going to erase #{answers.drive}." - true - ) - .return(answers.drive) - .then(umountAsync) - .tap (answers) -> - return helpers.sudo([ - 'internal' - 'osinit' - params.image - options.type - JSON.stringify(answers) - ]) - .then (answers) -> - return if not answers.drive? - - # TODO: balena local makes use of ejectAsync, see below - # DO we need this / should we do that here? - - # getDrive = (drive) -> - # driveListAsync().then (drives) -> - # selectedDrive = _.find(drives, device: drive) - - # if not selectedDrive? - # throw new Error("Drive not found: #{drive}") - - # return selectedDrive - # if (os.platform() is 'win32') and selectedDrive.mountpoint? - # ejectAsync = Promise.promisify(require('removedrive').eject) - # return ejectAsync(selectedDrive.mountpoint) - - umountAsync(answers.drive).tap -> - console.info("You can safely remove #{answers.drive} now") diff --git a/lib/actions/os.js b/lib/actions/os.js new file mode 100644 index 00000000..eac6a499 --- /dev/null +++ b/lib/actions/os.js @@ -0,0 +1,353 @@ +/* +Copyright 2016-2020 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 * as commandOptions from './command-options'; + +import * as _ from 'lodash'; +import { getBalenaSdk, getVisuals } from '../utils/lazy'; + +const formatVersion = function(v, isRecommended) { + let result = `v${v}`; + if (isRecommended) { + result += ' (recommended)'; + } + return result; +}; + +const resolveVersion = function(deviceType, version) { + if (version !== 'menu') { + if (version[0] === 'v') { + version = version.slice(1); + } + return Promise.resolve(version); + } + + const form = require('resin-cli-form'); + const balena = getBalenaSdk(); + + return balena.models.os + .getSupportedVersions(deviceType) + .then(function({ versions: vs, recommended }) { + const choices = vs.map(v => ({ + value: v, + name: formatVersion(v, v === recommended), + })); + + return form.ask({ + message: 'Select the OS version:', + type: 'list', + choices, + default: recommended, + }); + }); +}; + +export const versions = { + signature: 'os versions ', + description: 'show the available balenaOS versions for the given device type', + help: `\ +Use this command to show the available balenaOS versions for a certain device type. +Check available types with \`balena devices supported\` + +Example: + + $ balena os versions raspberrypi3\ +`, + action(params) { + const balena = getBalenaSdk(); + + return balena.models.os + .getSupportedVersions(params.type) + .then(({ versions: vs, recommended }) => { + vs.forEach(v => { + console.log(formatVersion(v, v === recommended)); + }); + }); + }, +}; + +export const download = { + signature: 'os download ', + description: 'download an unconfigured os image', + help: `\ +Use this command to download an unconfigured os image for a certain device type. +Check available types with \`balena devices supported\` + +If version is not specified the newest stable (non-pre-release) version of OS +is downloaded if available, or the newest version otherwise (if all existing +versions for the given device type are pre-release). + +You can pass \`--version menu\` to pick the OS version from the interactive menu +of all available versions. + +Examples: + + $ balena os download raspberrypi3 -o ../foo/bar/raspberry-pi.img + $ balena os download raspberrypi3 -o ../foo/bar/raspberry-pi.img --version 1.24.1 + $ balena os download raspberrypi3 -o ../foo/bar/raspberry-pi.img --version ^1.20.0 + $ balena os download raspberrypi3 -o ../foo/bar/raspberry-pi.img --version latest + $ balena os download raspberrypi3 -o ../foo/bar/raspberry-pi.img --version default + $ balena os download raspberrypi3 -o ../foo/bar/raspberry-pi.img --version menu\ +`, + permission: 'user', + options: [ + { + signature: 'output', + description: 'output path', + parameter: 'output', + alias: 'o', + required: 'You have to specify the output location', + }, + commandOptions.osVersionOrSemver, + ], + action(params, options) { + const Promise = require('bluebird'); + const unzip = require('node-unzip-2'); + const fs = require('fs'); + const rindle = require('rindle'); + const manager = require('balena-image-manager'); + + console.info(`Getting device operating system for ${params.type}`); + + let displayVersion = ''; + return Promise.try(function() { + if (!options.version) { + console.warn(`OS version is not specified, using the default version: \ +the newest stable (non-pre-release) version if available, \ +or the newest version otherwise (if all existing \ +versions for the given device type are pre-release).`); + return 'default'; + } + return resolveVersion(params.type, options.version); + }) + .then(function(version) { + if (version !== 'default') { + displayVersion = ` ${version}`; + } + return manager.get(params.type, version); + }) + .then(function(stream) { + const visuals = getVisuals(); + const bar = new visuals.Progress( + `Downloading Device OS${displayVersion}`, + ); + const spinner = new visuals.Spinner( + `Downloading Device OS${displayVersion} (size unknown)`, + ); + + stream.on('progress', function(state) { + if (state != null) { + return bar.update(state); + } else { + return spinner.start(); + } + }); + + stream.on('end', () => { + spinner.stop(); + }); + + // We completely rely on the `mime` custom property + // to make this decision. + // The actual stream should be checked instead. + let output; + if (stream.mime === 'application/zip') { + output = unzip.Extract({ path: options.output }); + } else { + output = fs.createWriteStream(options.output); + } + + return rindle.wait(stream.pipe(output)).return(options.output); + }) + .tap(() => { + console.info('The image was downloaded successfully'); + }); + }, +}; + +const buildConfigForDeviceType = function(deviceType, advanced) { + if (advanced == null) { + advanced = false; + } + const form = require('resin-cli-form'); + const helpers = require('../utils/helpers'); + + let override; + const questions = deviceType.options; + if (!advanced) { + const advancedGroup = _.find(questions, { + name: 'advanced', + isGroup: true, + }); + + if (advancedGroup != null) { + override = helpers.getGroupDefaults(advancedGroup); + } + } + + return form.run(questions, { override }); +}; + +const $buildConfig = function(image, deviceTypeSlug, advanced) { + if (advanced == null) { + advanced = false; + } + const Promise = require('bluebird'); + const helpers = require('../utils/helpers'); + + return Promise.resolve( + helpers.getManifest(image, deviceTypeSlug), + ).then(deviceTypeManifest => + buildConfigForDeviceType(deviceTypeManifest, advanced), + ); +}; + +export const buildConfig = { + signature: 'os build-config ', + description: 'build the OS config and save it to the JSON file', + help: `\ +Use this command to prebuild the OS config once and skip the interactive part of \`balena os configure\`. + +Example: + + $ balena os build-config ../path/rpi3.img raspberrypi3 --output rpi3-config.json + $ balena os configure ../path/rpi3.img --device 7cf02a6 --config rpi3-config.json\ +`, + permission: 'user', + options: [ + commandOptions.advancedConfig, + { + signature: 'output', + description: 'the path to the output JSON file', + alias: 'o', + required: 'the output path is required', + parameter: 'output', + }, + ], + action(params, options) { + const fs = require('fs'); + const Promise = require('bluebird'); + const writeFileAsync = Promise.promisify(fs.writeFile); + + return $buildConfig( + params.image, + params['device-type'], + options.advanced, + ).then(answers => + writeFileAsync(options.output, JSON.stringify(answers, null, 4)), + ); + }, +}; + +const INIT_WARNING_MESSAGE = `\ +Note: Initializing the device may ask for administrative permissions +because we need to access the raw devices directly.\ +`; + +export const initialize = { + signature: 'os initialize ', + description: 'initialize an os image', + help: `\ +Use this command to initialize a device with previously configured operating system image. + +${INIT_WARNING_MESSAGE} + +Examples: + + $ balena os initialize ../path/rpi.img --type 'raspberry-pi'\ +`, + permission: 'user', + options: [ + commandOptions.yes, + { + signature: 'type', + description: + 'device type (Check available types with `balena devices supported`)', + parameter: 'type', + alias: 't', + required: 'You have to specify a device type', + }, + commandOptions.drive, + ], + action(params, options) { + const Promise = require('bluebird'); + const umountAsync = Promise.promisify(require('umount').umount); + const form = require('resin-cli-form'); + const patterns = require('../utils/patterns'); + const helpers = require('../utils/helpers'); + + console.info(`\ +Initializing device + +${INIT_WARNING_MESSAGE}\ +`); + return Promise.resolve(helpers.getManifest(params.image, options.type)) + .then(manifest => + form.run(manifest.initialization?.options, { + override: { + drive: options.drive, + }, + }), + ) + .tap(function(answers) { + if (answers.drive == null) { + return; + } + return patterns + .confirm( + options.yes, + `This will erase ${answers.drive}. Are you sure?`, + `Going to erase ${answers.drive}.`, + true, + ) + .return(answers.drive) + .then(umountAsync); + }) + .tap(answers => + helpers.sudo([ + 'internal', + 'osinit', + params.image, + options.type, + JSON.stringify(answers), + ]), + ) + .then(function(answers) { + if (answers.drive == null) { + return; + } + + // TODO: balena local makes use of ejectAsync, see below + // DO we need this / should we do that here? + + // getDrive = (drive) -> + // driveListAsync().then (drives) -> + // selectedDrive = _.find(drives, device: drive) + + // if not selectedDrive? + // throw new Error("Drive not found: #{drive}") + + // return selectedDrive + // if (os.platform() is 'win32') and selectedDrive.mountpoint? + // ejectAsync = Promise.promisify(require('removedrive').eject) + // return ejectAsync(selectedDrive.mountpoint) + + return umountAsync(answers.drive).tap(() => { + console.info(`You can safely remove ${answers.drive} now`); + }); + }); + }, +}; diff --git a/typings/resin-cli-form/index.d.ts b/typings/resin-cli-form/index.d.ts index 6b72017e..7f3856ad 100644 --- a/typings/resin-cli-form/index.d.ts +++ b/typings/resin-cli-form/index.d.ts @@ -46,8 +46,8 @@ declare module 'resin-cli-form' { const form: { ask: (options: AskOptions) => Bluebird; run: ( - questions: RunQuestion[], - extraOptions?: { override: object }, + questions?: RunQuestion[], + extraOptions?: { override?: object }, ) => Bluebird; }; diff --git a/typings/rindle/index.d.ts b/typings/rindle/index.d.ts index 8cd20fbc..8442dd9b 100644 --- a/typings/rindle/index.d.ts +++ b/typings/rindle/index.d.ts @@ -15,16 +15,18 @@ * limitations under the License. */ +import * as Bluebird from 'bluebird'; + declare module 'rindle' { export function extract( stream: NodeJS.ReadableStream, callback: (error: Error, data: string) => void, ): void; - export function wait( + export function wait( stream: { - on(event: string, callback: (...args: any[]) => void): void; + on(event: string, callback: (...args: T) => void): void; }, - callback: (error: Error, data: string) => void, - ): void; + callback?: (error: Error, data: string) => void, + ): Bluebird; }