mirror of
https://github.com/balena-io/balena-cli.git
synced 2024-12-19 05:37:51 +00:00
Auto-merge for PR #895 via VersionBot
Add join/leave commands to promote and move devices between platforms
This commit is contained in:
commit
171632f83f
@ -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]
|
||||
|
@ -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)
|
||||
|
@ -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<string | void> {
|
||||
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,
|
||||
);
|
||||
|
@ -19,15 +19,12 @@ 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');
|
||||
|
||||
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,20 +60,31 @@ 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(
|
||||
image: string,
|
||||
deviceType: string,
|
||||
): Promise<ResinSdk.DeviceType> {
|
||||
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.
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
@ -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<void> {
|
||||
return form
|
||||
.run(
|
||||
@ -250,14 +245,14 @@ export function inferOrSelectDevice(preferredUuid: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function selectFromList(
|
||||
export function selectFromList<T>(
|
||||
message: string,
|
||||
selections: ListSelectionEntry[],
|
||||
): Promise<ListSelectionEntry> {
|
||||
choices: Array<T & { name: string }>,
|
||||
): Promise<T> {
|
||||
return form.ask({
|
||||
message,
|
||||
type: 'list',
|
||||
choices: _.map(selections, s => ({
|
||||
choices: _.map(choices, s => ({
|
||||
name: s.name,
|
||||
value: s,
|
||||
})),
|
||||
|
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));
|
||||
}
|
@ -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",
|
||||
@ -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…
Reference in New Issue
Block a user