From 7846af390ee1e54f425ad04e8c3bedea9d0d2931 Mon Sep 17 00:00:00 2001 From: Akis Kesoglou Date: Fri, 18 May 2018 23:31:08 +0300 Subject: [PATCH 1/5] Improve `selectFromList` function signature to be much more reusable --- lib/utils/patterns.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/lib/utils/patterns.ts b/lib/utils/patterns.ts index e805929b..a6f3e0fc 100644 --- a/lib/utils/patterns.ts +++ b/lib/utils/patterns.ts @@ -23,11 +23,6 @@ import chalk from 'chalk'; import validation = require('./validation'); import messages = require('./messages'); -export interface ListSelectionEntry { - name: string; - extra: any; -} - export function authenticate(options: {}): Promise { return form .run( @@ -250,14 +245,11 @@ export function inferOrSelectDevice(preferredUuid: string) { }); } -export function selectFromList( - message: string, - selections: ListSelectionEntry[], -): Promise { +export function selectFromList(message: string, choices: Array): Promise { return form.ask({ message, type: 'list', - choices: _.map(selections, s => ({ + choices: _.map(choices, s => ({ name: s.name, value: s, })), From 5cbe1c410f7081ff78b41e8a6d9c0f06ad92934e Mon Sep 17 00:00:00 2001 From: Akis Kesoglou Date: Tue, 22 May 2018 18:12:51 +0300 Subject: [PATCH 2/5] Add join/leave commands to promote and move devices between platforms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both commands work with local devices by remotely invoking the `os-config` executable via SSH. This requires an as of yet unreleased resinOS (that will most likely be v2.14) and the commands ascertain compatibility merely by looking for the `os-config` executable in the device, and bail out if it’s not present. `join` and `leave` accept a couple of optional arguments and implement a wizard-style interface if these are not given. They allow to interactively select the device and the application to promote to. If the user has no apps, `join` will offer the user to create one. `join` will also offer the user to login or create an account if they’re not logged in already without exiting the wizard. `resin-sync` (that's used internally to discover local devices) requires admin privileges. If no device has been specified as an argument, the commands will launch the device scanning process in a privileged subprocess via two new internal commands: `internal sudo` and `internal scanDevices`. This avoids having the user to invoke the commands with sudo and only request escalation if truly needed. This commit also removes the dependency to “president”, implementing “sudo” functionality within the CLI. Change-Type: minor --- lib/actions/auth.coffee | 4 +- lib/actions/config.coffee | 4 +- lib/actions/device.coffee | 10 +- lib/actions/index.coffee | 2 + lib/actions/internal.coffee | 50 ++++++ lib/actions/join.ts | 62 +++++++ lib/actions/leave.ts | 49 ++++++ lib/actions/wizard.coffee | 13 +- lib/app.coffee | 6 + lib/utils/helpers.ts | 26 ++- lib/utils/patterns.ts | 5 +- lib/utils/promote.ts | 323 ++++++++++++++++++++++++++++++++++++ lib/utils/ssh.ts | 65 ++++++++ lib/utils/sudo.ts | 26 +++ package.json | 6 +- typings/capitano.d.ts | 2 + 16 files changed, 625 insertions(+), 28 deletions(-) create mode 100644 lib/actions/join.ts create mode 100644 lib/actions/leave.ts create mode 100644 lib/utils/promote.ts create mode 100644 lib/utils/ssh.ts create mode 100644 lib/utils/sudo.ts diff --git a/lib/actions/auth.coffee b/lib/actions/auth.coffee index a6d87b0f..ceda3883 100644 --- a/lib/actions/auth.coffee +++ b/lib/actions/auth.coffee @@ -102,8 +102,8 @@ exports.login = return patterns.askLoginType().then (loginType) -> if loginType is 'register' - capitanoRunAsync = Promise.promisify(require('capitano').run) - return capitanoRunAsync('signup') + { runCommand } = require('../utils/helpers') + return runCommand('signup') options[loginType] = true return login(options) diff --git a/lib/actions/config.coffee b/lib/actions/config.coffee index 361dd88e..32b9a127 100644 --- a/lib/actions/config.coffee +++ b/lib/actions/config.coffee @@ -198,7 +198,7 @@ exports.reconfigure = Promise = require('bluebird') config = require('resin-config-json') visuals = require('resin-cli-visuals') - capitanoRunAsync = Promise.promisify(require('capitano').run) + { runCommand } = require('../utils/helpers') umountAsync = Promise.promisify(require('umount').umount) Promise.try -> @@ -212,7 +212,7 @@ exports.reconfigure = configureCommand = "os configure #{drive} --device #{uuid}" if options.advanced configureCommand += ' --advanced' - return capitanoRunAsync(configureCommand) + return runCommand(configureCommand) .then -> console.info('Done') .nodeify(done) diff --git a/lib/actions/device.coffee b/lib/actions/device.coffee index f0243203..20d86288 100644 --- a/lib/actions/device.coffee +++ b/lib/actions/device.coffee @@ -415,7 +415,6 @@ exports.init = permission: 'user' action: (params, options, done) -> Promise = require('bluebird') - capitanoRunAsync = Promise.promisify(require('capitano').run) rimraf = Promise.promisify(require('rimraf')) tmp = require('tmp') tmpNameAsync = Promise.promisify(tmp.tmpName) @@ -423,6 +422,7 @@ exports.init = resin = require('resin-sdk-preconfigured') patterns = require('../utils/patterns') + { runCommand } = require('../utils/helpers') Promise.try -> return options.application if options.application? @@ -433,12 +433,12 @@ exports.init = download = -> tmpNameAsync().then (tempPath) -> osVersion = options['os-version'] or 'default' - capitanoRunAsync("os download #{application.device_type} --output '#{tempPath}' --version #{osVersion}") + runCommand("os download #{application.device_type} --output '#{tempPath}' --version #{osVersion}") .disposer (tempPath) -> return rimraf(tempPath) Promise.using download(), (tempPath) -> - capitanoRunAsync("device register #{application.app_name}") + runCommand("device register #{application.app_name}") .then(resin.models.device.get) .tap (device) -> configureCommand = "os configure '#{tempPath}' --device #{device.uuid}" @@ -446,14 +446,14 @@ exports.init = configureCommand += " --config '#{options.config}'" else if options.advanced configureCommand += ' --advanced' - capitanoRunAsync(configureCommand) + runCommand(configureCommand) .then -> osInitCommand = "os initialize '#{tempPath}' --type #{application.device_type}" if options.yes osInitCommand += ' --yes' if options.drive osInitCommand += " --drive #{options.drive}" - capitanoRunAsync(osInitCommand) + runCommand(osInitCommand) # Make sure the device resource is removed if there is an # error when configuring or initializing a device image .catch (error) -> diff --git a/lib/actions/index.coffee b/lib/actions/index.coffee index 37468209..6946d74a 100644 --- a/lib/actions/index.coffee +++ b/lib/actions/index.coffee @@ -38,3 +38,5 @@ module.exports = util: require('./util') preload: require('./preload') push: require('./push') + join: require('./join') + leave: require('./leave') diff --git a/lib/actions/internal.coffee b/lib/actions/internal.coffee index 52c8ecef..07823d53 100644 --- a/lib/actions/internal.coffee +++ b/lib/actions/internal.coffee @@ -35,3 +35,53 @@ exports.osInit = init.initialize(params.image, params.type, config) .then(helpers.osProgressHandler) .nodeify(done) + +exports.scanDevices = + signature: 'internal scandevices' + description: 'scan for local resin-enabled devices and show a picker to choose one' + help: ''' + Don't use this command directly! + ''' + hidden: true + root: true + action: (params, options, done) -> + Promise = require('bluebird') + { forms } = require('resin-sync') + + return Promise.try -> + forms.selectLocalResinOsDevice() + .then (hostnameOrIp) -> + console.error("==> Selected device: #{hostnameOrIp}") + .nodeify(done) + +exports.sudo = + signature: 'internal sudo ' + description: 'execute arbitrary commands in a privileged subprocess' + help: ''' + Don't use this command directly! + + must be passed as a single argument. That means, you need to make sure + you enclose in quotes (eg. resin internal sudo 'ls -alF') if for + whatever reason you invoke the command directly or, typically, pass + as a single argument to spawn (eg. `spawn('resin', [ 'internal', 'sudo', 'ls -alF' ])`). + + Furthermore, this command will naively split on whitespace and directly + forward the parts as arguments to `sudo`, so be careful. + ''' + hidden: true + action: (params, options, done) -> + os = require('os') + Promise = require('bluebird') + + return Promise.try -> + if os.platform() is 'win32' + windosu = require('windosu') + windosu.exec(params.command, {}) + else + { spawn } = require('child_process') + { wait } = require('rindle') + cmd = params.command.split(' ') + ps = spawn('sudo', cmd, stdio: 'inherit', env: process.env) + wait(ps) + + .nodeify(done) diff --git a/lib/actions/join.ts b/lib/actions/join.ts new file mode 100644 index 00000000..173f2bf9 --- /dev/null +++ b/lib/actions/join.ts @@ -0,0 +1,62 @@ +/* +Copyright 2016-2017 Resin.io + +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 Bluebird from 'bluebird'; +import { CommandDefinition } from 'capitano'; +import { stripIndent } from 'common-tags'; + +interface Args { + deviceIp?: string; +} + +interface Options { + application?: string; +} + +export const join: CommandDefinition = { + signature: 'join [deviceIp]', + description: + 'Promote a local device running unmanaged resinOS to join a resin.io application', + help: stripIndent` + Examples: + + $ resin join + $ resin join resin.local + $ resin join resin.local --application MyApp + $ resin join 192.168.1.25 + $ resin join 192.168.1.25 --application MyApp + `, + options: [ + { + signature: 'application', + parameter: 'application', + alias: 'a', + description: 'The name of the application the device should join', + }, + ], + + primary: true, + + async action(params, options, done) { + const resin = await import('resin-sdk'); + const Logger = await import('../utils/logger'); + const promote = await import('../utils/promote'); + const sdk = resin.fromSharedOptions(); + const logger = new Logger(); + return Bluebird.try(() => { + return promote.join(logger, sdk, params.deviceIp, options.application); + }).nodeify(done); + }, +}; diff --git a/lib/actions/leave.ts b/lib/actions/leave.ts new file mode 100644 index 00000000..8db11e5c --- /dev/null +++ b/lib/actions/leave.ts @@ -0,0 +1,49 @@ +/* +Copyright 2016-2017 Resin.io + +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 Bluebird from 'bluebird'; +import { CommandDefinition } from 'capitano'; +import { stripIndent } from 'common-tags'; + +interface Args { + deviceIp?: string; +} + +export const leave: CommandDefinition = { + signature: 'leave [deviceIp]', + description: 'Detach a local device from its resin.io application', + help: stripIndent` + Examples: + + $ resin leave + $ resin leave resin.local + $ resin leave 192.168.1.25 + `, + options: [], + + permission: 'user', + primary: true, + + async action(params, _options, done) { + const resin = await import('resin-sdk'); + const Logger = await import('../utils/logger'); + const promote = await import('../utils/promote'); + const sdk = resin.fromSharedOptions(); + const logger = new Logger(); + return Bluebird.try(() => { + return promote.leave(logger, sdk, params.deviceIp); + }).nodeify(done); + }, +}; diff --git a/lib/actions/wizard.coffee b/lib/actions/wizard.coffee index a388e8dd..52bdc0df 100644 --- a/lib/actions/wizard.coffee +++ b/lib/actions/wizard.coffee @@ -34,29 +34,28 @@ exports.wizard = ''' primary: true action: (params, options, done) -> - Promise = require('bluebird') - capitanoRunAsync = Promise.promisify(require('capitano').run) resin = require('resin-sdk-preconfigured') patterns = require('../utils/patterns') + { runCommand } = require('../utils/helpers') resin.auth.isLoggedIn().then (isLoggedIn) -> return if isLoggedIn console.info('Looks like you\'re not logged in yet!') - console.info('Lets go through a quick wizard to get you started.\n') - return capitanoRunAsync('login') + console.info("Let's go through a quick wizard to get you started.\n") + return runCommand('login') .then -> return if params.name? patterns.selectOrCreateApplication().tap (applicationName) -> resin.models.application.has(applicationName).then (hasApplication) -> return applicationName if hasApplication - capitanoRunAsync("app create #{applicationName}") + runCommand("app create #{applicationName}") .then (applicationName) -> params.name = applicationName .then -> - return capitanoRunAsync("device init --application #{params.name}") + return runCommand("device init --application #{params.name}") .tap(patterns.awaitDevice) .then (uuid) -> - return capitanoRunAsync("device #{uuid}") + return runCommand("device #{uuid}") .then -> return resin.models.application.get(params.name) .then (application) -> diff --git a/lib/app.coffee b/lib/app.coffee index 3d7b3245..701a09b7 100644 --- a/lib/app.coffee +++ b/lib/app.coffee @@ -207,6 +207,8 @@ capitano.command(actions.util.availableDrives) # ---------- Internal utils ---------- capitano.command(actions.internal.osInit) +capitano.command(actions.internal.scanDevices) +capitano.command(actions.internal.sudo) #------------ Local build and deploy ------- capitano.command(actions.build) @@ -215,6 +217,10 @@ capitano.command(actions.deploy) #------------ Push/remote builds ------- capitano.command(actions.push.push) +#------------ Join/Leave ------- +capitano.command(actions.join.join) +capitano.command(actions.leave.leave) + update.notify() cli = capitano.parse(process.argv) diff --git a/lib/utils/helpers.ts b/lib/utils/helpers.ts index 4f3d0d95..fbf41146 100644 --- a/lib/utils/helpers.ts +++ b/lib/utils/helpers.ts @@ -23,11 +23,9 @@ import imagefs = require('resin-image-fs'); import visuals = require('resin-cli-visuals'); import ResinSdk = require('resin-sdk'); -import { execute } from 'president'; import { InitializeEmitter, OperationState } from 'resin-device-init'; const waitStreamAsync = Promise.promisify(rindle.wait); -const presidentExecuteAsync = Promise.promisify(execute); const resin = ResinSdk.fromSharedOptions(); @@ -63,14 +61,24 @@ export function stateToString(state: OperationState) { } } -export function sudo(command: string[]) { +export function sudo( + command: string[], + { stderr, msg }: { stderr?: NodeJS.WritableStream; msg?: string }, +) { + const { executeWithPrivileges } = require('./sudo'); + if (os.platform() !== 'win32') { - console.log('If asked please type your computer password to continue'); + console.log( + msg || 'If asked please type your computer password to continue', + ); } - command = _.union(_.take(process.argv, 2), command); + return executeWithPrivileges(command, stderr); +} - return presidentExecuteAsync(command); +export function runCommand(command: string): Promise { + const capitano = require('capitano'); + return Promise.fromCallback(resolver => capitano.run(command, resolver)); } export function getManifest( @@ -132,7 +140,7 @@ export function getArchAndDeviceType( export function getApplication(applicationName: string) { // Check for an app of the form `user/application`, and send // that off to a special handler (before importing any modules) - const match = /(\w+)\/(\w+)/.exec(applicationName); + const match = applicationName.split('/'); const extraOptions = { $expand: { @@ -142,10 +150,10 @@ export function getApplication(applicationName: string) { }, }; - if (match) { + if (match.length > 1) { return resin.models.application.getAppByOwner( - match[2], match[1], + match[0], extraOptions, ); } diff --git a/lib/utils/patterns.ts b/lib/utils/patterns.ts index a6f3e0fc..5960ae9b 100644 --- a/lib/utils/patterns.ts +++ b/lib/utils/patterns.ts @@ -245,7 +245,10 @@ export function inferOrSelectDevice(preferredUuid: string) { }); } -export function selectFromList(message: string, choices: Array): Promise { +export function selectFromList( + message: string, + choices: Array, +): Promise { return form.ask({ message, type: 'list', diff --git a/lib/utils/promote.ts b/lib/utils/promote.ts new file mode 100644 index 00000000..5c81dc1c --- /dev/null +++ b/lib/utils/promote.ts @@ -0,0 +1,323 @@ +import { stripIndent } from 'common-tags'; +import { ResinSDK, Application } from 'resin-sdk'; + +import Logger = require('./logger'); + +import { runCommand } from './helpers'; +import { exec, execBuffered } from './ssh'; + +const MIN_RESINOS_VERSION = 'v2.14.0'; + +export async function join( + logger: Logger, + sdk: ResinSDK, + deviceHostnameOrIp?: string, + appName?: string, +): Promise { + logger.logDebug('Checking login...'); + const isLoggedIn = await sdk.auth.isLoggedIn(); + if (!isLoggedIn) { + logger.logInfo("Looks like you're not logged in yet!"); + logger.logInfo("Let's go through a quick wizard to get you started.\n"); + await runCommand('login'); + } + + logger.logDebug('Determining device...'); + const deviceIp = await getOrSelectLocalDevice(deviceHostnameOrIp); + await assertDeviceIsCompatible(deviceIp); + logger.logDebug(`Using device: ${deviceIp}`); + + logger.logDebug('Determining device type...'); + const deviceType = await getDeviceType(deviceIp); + logger.logDebug(`Device type: ${deviceType}`); + + logger.logDebug('Determining application...'); + const app = await getOrSelectApplication(sdk, deviceType, appName); + logger.logDebug(`Using application: ${app.app_name} (${app.device_type})`); + if (app.device_type != deviceType) { + logger.logDebug(`Forcing device type to: ${deviceType}`); + app.device_type = deviceType; + } + + logger.logDebug('Generating application config...'); + const config = await generateApplicationConfig(sdk, app); + logger.logDebug(`Using config: ${JSON.stringify(config, null, 2)}`); + + logger.logDebug('Configuring...'); + await configure(deviceIp, config); + logger.logDebug('All done.'); + + const platformUrl = await sdk.settings.get('resinUrl'); + logger.logSuccess(`Device successfully joined ${platformUrl}!`); +} + +export async function leave( + logger: Logger, + _sdk: ResinSDK, + deviceHostnameOrIp?: string, +): Promise { + logger.logDebug('Determining device...'); + const deviceIp = await getOrSelectLocalDevice(deviceHostnameOrIp); + await assertDeviceIsCompatible(deviceIp); + logger.logDebug(`Using device: ${deviceIp}`); + + logger.logDebug('Deconfiguring...'); + await deconfigure(deviceIp); + logger.logDebug('All done.'); + + logger.logSuccess('Device successfully left the platform.'); +} + +async function execCommand( + deviceIp: string, + cmd: string, + msg: string, +): Promise { + const through = await import('through2'); + const visuals = await import('resin-cli-visuals'); + + const spinner = new visuals.Spinner(`[${deviceIp}] Connecting...`); + const innerSpinner = spinner.spinner; + + const stream = through(function(data, _enc, cb) { + innerSpinner.setSpinnerTitle(`%s [${deviceIp}] ${msg}`); + cb(null, data); + }); + + spinner.start(); + await exec(deviceIp, cmd, stream); + spinner.stop(); +} + +async function configure(deviceIp: string, config: any): Promise { + // Passing the JSON is slightly tricky due to the many layers of indirection + // so we just base64-encode it here and decode it at the other end, when invoking + // os-config. + const json = JSON.stringify(config); + const b64 = Buffer.from(json).toString('base64'); + const str = `"$(base64 -d <<< ${b64})"`; + await execCommand(deviceIp, `os-config join '${str}'`, 'Configuring...'); +} + +async function deconfigure(deviceIp: string): Promise { + await execCommand(deviceIp, 'os-config leave', 'Configuring...'); +} + +async function assertDeviceIsCompatible(deviceIp: string): Promise { + const { exitWithExpectedError } = await import('../utils/patterns'); + try { + await execBuffered(deviceIp, 'os-config --version'); + } catch (err) { + exitWithExpectedError(stripIndent` + Device "${deviceIp}" is incompatible and cannot join or leave an application. + Please select or provision device with resinOS newer than ${MIN_RESINOS_VERSION}.`); + } +} + +async function getDeviceType(deviceIp: string): Promise { + const output = await execBuffered(deviceIp, 'cat /etc/os-release'); + const match = /^SLUG="([^"]+)"$/m.exec(output); + if (!match) { + throw new Error('Failed to determine device type'); + } + return match[1]; +} + +async function getOrSelectLocalDevice(deviceIp?: string): Promise { + if (deviceIp) { + return deviceIp; + } + + const through = await import('through2'); + + let ip: string | null = null; + const stream = through(function(data, _enc, cb) { + const match = /^==> Selected device: (.*)$/m.exec(data.toString()); + if (match) { + ip = match[1]; + cb(); + } else { + cb(null, data); + } + }); + + stream.pipe(process.stderr); + + const { sudo } = await import('../utils/helpers'); + const command = process.argv.slice(0, 2).concat(['internal', 'scandevices']); + await sudo(command, { + stderr: stream, + msg: + 'Scanning for local devices. If asked, please type your computer password.', + }); + + if (!ip) { + throw new Error('No device selected'); + } + + return ip; +} + +async function getOrSelectApplication( + sdk: ResinSDK, + deviceType: string, + appName?: string, +): Promise { + const _ = await import('lodash'); + const form = await import('resin-cli-form'); + const { selectFromList } = await import('../utils/patterns'); + + const allDeviceTypes = await sdk.models.config.getDeviceTypes(); + const deviceTypeManifest = _.find(allDeviceTypes, { slug: deviceType }); + if (!deviceTypeManifest) { + throw new Error(`"${deviceType}" is not a valid device type`); + } + const compatibleDeviceTypes = _(allDeviceTypes) + .filter({ arch: deviceTypeManifest.arch }) + .map(type => type.slug) + .value(); + + const options: any = { + $expand: { user: { $select: ['username'] } }, + $filter: { device_type: { $in: compatibleDeviceTypes } }, + }; + + if (!appName) { + // No application specified, show a list to select one. + const applications = await sdk.models.application.getAll(options); + if (applications.length === 0) { + const shouldCreateApp = await form.ask({ + message: + 'You have no applications this device can join.\n' + + 'Would you like to create one now?', + type: 'confirm', + default: true, + }); + if (shouldCreateApp) { + return createApplication(sdk, deviceType); + } + process.exit(1); + } + return selectFromList( + 'Select application', + _.map(applications, app => _.merge({ name: app.app_name }, app)), + ); + } + + // We're given an application; resolve it if it's ambiguous and also validate + // it's of appropriate device type. + options.$filter = { app_name: appName }; + + // Check for an app of the form `user/application` and update the API query. + const match = appName.split('/'); + if (match.length > 1) { + // These will match at most one app, so we'll return early. + options.$expand.user.$filter = { username: match[0] }; + options.$filter.app_name = match[1]; + } + + // Fetch all applications with the given name that are accessible to the user + const applications = await sdk.pine.get({ + resource: 'application', + options, + }); + + if (applications.length === 0) { + const shouldCreateApp = await form.ask({ + message: + `No application found with name "${appName}".\n` + + 'Would you like to create it now?', + type: 'confirm', + default: true, + }); + if (shouldCreateApp) { + return createApplication(sdk, deviceType, options.$filter.app_name); + } + process.exit(1); + } + + // We've found at least one app with the given name. + // Filter out apps for non-matching device types and see what we're left with. + const validApplications = applications.filter(app => + _.includes(compatibleDeviceTypes, app.device_type), + ); + + if (validApplications.length === 1) { + return validApplications[0]; + } + + // If we got more than one application with the same name it means that the + // user has access to a collab app with the same name as a personal app. We + // present a list to the user which shows the fully qualified application + // name (user/appname) and allows them to select + return selectFromList( + 'Found multiple applications with that name; please select the one to use', + _.map(validApplications, app => { + const owner = _.get(app, 'user[0].username'); + return _.merge({ name: `${owner}/${app.app_name}` }, app); + }), + ); +} + +async function createApplication( + sdk: ResinSDK, + deviceType: string, + name?: string, +): Promise { + const form = await import('resin-cli-form'); + const validation = await import('./validation'); + const patterns = await import('./patterns'); + + const user = await sdk.auth.getUserId(); + const queryOptions = { + $filter: { user }, + }; + + const appName = await new Promise(async (resolve, reject) => { + while (true) { + try { + const appName = await form.ask({ + message: 'Enter a name for your new application:', + type: 'input', + default: name, + validate: validation.validateApplicationName, + }); + + try { + await sdk.models.application.get(appName, queryOptions); + patterns.printErrorMessage( + 'You already have an application with that name; please choose another.', + ); + continue; + } catch (err) { + return resolve(appName); + } + } catch (err) { + return reject(err); + } + } + }); + + return sdk.models.application.create({ + name: appName, + deviceType, + }); +} + +async function generateApplicationConfig(sdk: ResinSDK, app: Application) { + const form = await import('resin-cli-form'); + const { generateApplicationConfig: configGen } = await import('./config'); + + const manifest = await sdk.models.device.getManifestBySlug(app.device_type); + const opts = + manifest.options && manifest.options.filter(opt => opt.name !== 'network'); + const values = await form.run(opts); + + const config = await configGen(app, values); + if (config.connectivity === 'connman') { + delete config.connectivity; + delete config.files; + } + + return config; +} diff --git a/lib/utils/ssh.ts b/lib/utils/ssh.ts new file mode 100644 index 00000000..c1595485 --- /dev/null +++ b/lib/utils/ssh.ts @@ -0,0 +1,65 @@ +import { spawn } from 'child_process'; +import * as Bluebird from 'bluebird'; +import TypedError = require('typed-error'); + +import { getSubShellCommand } from './helpers'; + +export class ExecError extends TypedError { + public cmd: string; + public exitCode: number; + + constructor(cmd: string, exitCode: number) { + super(`Command '${cmd}' failed with error: ${exitCode}`); + this.cmd = cmd; + this.exitCode = exitCode; + } +} + +export async function exec( + deviceIp: string, + cmd: string, + stdout?: NodeJS.WritableStream, +): Promise { + const command = `ssh \ + -t \ + -p 22222 \ + -o LogLevel=ERROR \ + -o StrictHostKeyChecking=no \ + -o UserKnownHostsFile=/dev/null \ + root@${deviceIp} \ + ${cmd}`; + + const stdio = ['ignore', stdout ? 'pipe' : 'inherit', 'ignore']; + const { program, args } = getSubShellCommand(command); + + const exitCode = await new Bluebird((resolve, reject) => { + const ps = spawn(program, args, { stdio }) + .on('error', reject) + .on('close', resolve); + + if (stdout) { + ps.stdout.pipe(stdout); + } + }); + if (exitCode != 0) { + throw new ExecError(cmd, exitCode); + } +} + +export async function execBuffered( + deviceIp: string, + cmd: string, + enc?: string, +): Promise { + const through = await import('through2'); + const buffer: string[] = []; + await exec( + deviceIp, + cmd, + through(function(data, _enc, cb) { + buffer.push(data.toString(enc)); + cb(); + }), + ); + return buffer.join(''); +} diff --git a/lib/utils/sudo.ts b/lib/utils/sudo.ts new file mode 100644 index 00000000..41c9de63 --- /dev/null +++ b/lib/utils/sudo.ts @@ -0,0 +1,26 @@ +import { spawn } from 'child_process'; + +import * as Bluebird from 'bluebird'; +import * as rindle from 'rindle'; + +export async function executeWithPrivileges( + command: string[], + stderr?: NodeJS.WritableStream, +): Promise { + const opts = { + stdio: ['inherit', 'inherit', stderr ? 'pipe' : 'inherit'], + env: process.env, + }; + + const args = process.argv + .slice(0, 2) + .concat(['internal', 'sudo', command.join(' ')]); + + const ps = spawn(args[0], args.slice(1), opts); + + if (stderr) { + ps.stderr.pipe(stderr); + } + + return Bluebird.fromCallback(resolver => rindle.wait(ps, resolver)); +} diff --git a/package.json b/package.json index 301fc9c3..2bbdb942 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "dependencies": { "@resin.io/valid-email": "^0.1.0", "@types/stream-to-promise": "2.2.0", + "@types/through2": "^2.0.33", "JSONStream": "^1.0.3", "ansi-escapes": "^2.0.0", "any-promise": "^1.3.0", @@ -129,7 +130,6 @@ "mz": "^2.6.0", "node-cleanup": "^2.1.2", "opn": "^5.1.0", - "president": "^2.0.1", "prettyjson": "^1.1.3", "progress-stream": "^2.0.0", "raven": "^2.5.0", @@ -164,12 +164,14 @@ "tar-stream": "^1.5.5", "through2": "^2.0.3", "tmp": "0.0.31", + "typed-error": "^2.0.0", "umount": "^1.1.6", "unzip2": "^0.2.5", "update-notifier": "^2.2.0", "window-size": "^1.1.0" }, "optionalDependencies": { - "removedrive": "^1.0.0" + "removedrive": "^1.0.0", + "windosu": "^0.2.0" } } diff --git a/typings/capitano.d.ts b/typings/capitano.d.ts index aaa385b3..acefe3ae 100644 --- a/typings/capitano.d.ts +++ b/typings/capitano.d.ts @@ -21,6 +21,8 @@ declare module 'capitano' { help: string; options?: OptionDefinition[]; permission?: 'user'; + root?: boolean; + primary?: boolean; action(params: P, options: O, done: () => void): void; } From 27b877dd339ec752ed3848e66d1f5123347e8baa Mon Sep 17 00:00:00 2001 From: Akis Kesoglou Date: Thu, 19 Jul 2018 22:18:43 +0300 Subject: [PATCH 3/5] Forward root CA to device config if one is present --- lib/utils/config.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/lib/utils/config.ts b/lib/utils/config.ts index fcbfef59..37d883ef 100644 --- a/lib/utils/config.ts +++ b/lib/utils/config.ts @@ -13,6 +13,8 @@ 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 fs from 'fs'; + import Promise = require('bluebird'); import ResinSdk = require('resin-sdk'); import deviceConfig = require('resin-device-config'); @@ -20,6 +22,18 @@ import * as semver from 'resin-semver'; const resin = ResinSdk.fromSharedOptions(); +function readRootCa(): Promise { + const caFile = process.env.NODE_EXTRA_CA_CERTS; + if (!caFile) { + return Promise.resolve(); + } + return Promise.fromCallback(cb => + fs.readFile(caFile, { encoding: 'utf8' }, cb), + ) + .then(pem => Buffer.from(pem).toString('base64')) + .catch({ code: 'ENOENT' }, () => {}); +} + export function generateBaseConfig( application: ResinSdk.Application, options: { version?: string; appUpdatePollInterval?: number }, @@ -39,6 +53,9 @@ export function generateBaseConfig( registryUrl: resin.settings.get('registryUrl'), deltaUrl: resin.settings.get('deltaUrl'), apiConfig: resin.models.config.getAll(), + rootCA: readRootCa().catch(() => { + console.warn('Could not read root CA'); + }), }).then(results => { return deviceConfig.generate( { @@ -57,6 +74,7 @@ export function generateBaseConfig( mixpanel: { token: results.apiConfig.mixpanelToken, }, + balenaRootCA: results.rootCA, }, options, ); From 916cc364309795146dd64033230cd362765fcf11 Mon Sep 17 00:00:00 2001 From: Akis Kesoglou Date: Fri, 20 Jul 2018 13:04:26 +0300 Subject: [PATCH 4/5] Lazily import `resin-image-fs` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If for whatever reason resin-image-fs is not importable — eg. if it’s built for another arch — any command that imports `helpers.ts` will just quit without any error/traceback. --- lib/utils/helpers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/utils/helpers.ts b/lib/utils/helpers.ts index fbf41146..f61cdefd 100644 --- a/lib/utils/helpers.ts +++ b/lib/utils/helpers.ts @@ -19,7 +19,6 @@ import Promise = require('bluebird'); import _ = require('lodash'); import chalk from 'chalk'; import rindle = require('rindle'); -import imagefs = require('resin-image-fs'); import visuals = require('resin-cli-visuals'); import ResinSdk = require('resin-sdk'); @@ -85,6 +84,7 @@ export function getManifest( image: string, deviceType: string, ): Promise { + const imagefs = require('resin-image-fs'); // Attempt to read manifest from the first // partition, but fallback to the API if // we encounter any errors along the way. From 1fa7141b58ccd80128ca8a56602845f8ee77733c Mon Sep 17 00:00:00 2001 From: "resin-io-versionbot[bot]" Date: Fri, 20 Jul 2018 10:40:22 +0000 Subject: [PATCH 5/5] v7.8.0 --- CHANGELOG.md | 4 ++++ package.json | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4e9076e..5ab01ccb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file automatically by Versionist. DO NOT EDIT THIS FILE MANUALLY! This project adheres to [Semantic Versioning](http://semver.org/). +## v7.8.0 - 2018-07-20 + +* Add join/leave commands to promote and move devices between platforms #895 [Akis Kesoglou] + ## v7.7.4 - 2018-07-17 * Update TypeScript to 2.8.1 #923 [Tim Perry] diff --git a/package.json b/package.json index 2bbdb942..2f3ece55 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "resin-cli", - "version": "7.7.4", + "version": "7.8.0", "description": "The official resin.io CLI tool", "main": "./build/actions/index.js", "homepage": "https://github.com/resin-io/resin-cli",