mirror of
https://github.com/balena-io/balena-cli.git
synced 2025-02-20 17:33:18 +00:00
Add join/leave commands to promote and move devices between platforms
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
This commit is contained in:
parent
7846af390e
commit
5cbe1c410f
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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) ->
|
||||
|
@ -38,3 +38,5 @@ module.exports =
|
||||
util: require('./util')
|
||||
preload: require('./preload')
|
||||
push: require('./push')
|
||||
join: require('./join')
|
||||
leave: require('./leave')
|
||||
|
@ -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 <command>'
|
||||
description: 'execute arbitrary commands in a privileged subprocess'
|
||||
help: '''
|
||||
Don't use this command directly!
|
||||
|
||||
<command> must be passed as a single argument. That means, you need to make sure
|
||||
you enclose <command> in quotes (eg. resin internal sudo 'ls -alF') if for
|
||||
whatever reason you invoke the command directly or, typically, pass <command>
|
||||
as a single argument to spawn (eg. `spawn('resin', [ 'internal', 'sudo', 'ls -alF' ])`).
|
||||
|
||||
Furthermore, this command will naively split <command> 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)
|
||||
|
62
lib/actions/join.ts
Normal file
62
lib/actions/join.ts
Normal file
@ -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<Args, Options> = {
|
||||
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);
|
||||
},
|
||||
};
|
49
lib/actions/leave.ts
Normal file
49
lib/actions/leave.ts
Normal file
@ -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<Args, {}> = {
|
||||
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);
|
||||
},
|
||||
};
|
@ -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) ->
|
||||
|
@ -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)
|
||||
|
@ -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<void> {
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
@ -245,7 +245,10 @@ export function inferOrSelectDevice(preferredUuid: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function selectFromList<T>(message: string, choices: Array<T & { name: string }>): Promise<T> {
|
||||
export function selectFromList<T>(
|
||||
message: string,
|
||||
choices: Array<T & { name: string }>,
|
||||
): Promise<T> {
|
||||
return form.ask({
|
||||
message,
|
||||
type: 'list',
|
||||
|
323
lib/utils/promote.ts
Normal file
323
lib/utils/promote.ts
Normal file
@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
await execCommand(deviceIp, 'os-config leave', 'Configuring...');
|
||||
}
|
||||
|
||||
async function assertDeviceIsCompatible(deviceIp: string): Promise<void> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<Application> {
|
||||
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<Application>({
|
||||
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<Application> {
|
||||
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<string>(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;
|
||||
}
|
65
lib/utils/ssh.ts
Normal file
65
lib/utils/ssh.ts
Normal file
@ -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<void> {
|
||||
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<number>((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<string> {
|
||||
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('');
|
||||
}
|
26
lib/utils/sudo.ts
Normal file
26
lib/utils/sudo.ts
Normal file
@ -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<void> {
|
||||
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));
|
||||
}
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
2
typings/capitano.d.ts
vendored
2
typings/capitano.d.ts
vendored
@ -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;
|
||||
}
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user