mirror of
https://github.com/balena-io/balena-cli.git
synced 2025-04-28 23:09: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!
|
automatically by Versionist. DO NOT EDIT THIS FILE MANUALLY!
|
||||||
This project adheres to [Semantic Versioning](http://semver.org/).
|
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
|
## v7.7.4 - 2018-07-17
|
||||||
|
|
||||||
* Update TypeScript to 2.8.1 #923 [Tim Perry]
|
* Update TypeScript to 2.8.1 #923 [Tim Perry]
|
||||||
|
@ -102,8 +102,8 @@ exports.login =
|
|||||||
return patterns.askLoginType().then (loginType) ->
|
return patterns.askLoginType().then (loginType) ->
|
||||||
|
|
||||||
if loginType is 'register'
|
if loginType is 'register'
|
||||||
capitanoRunAsync = Promise.promisify(require('capitano').run)
|
{ runCommand } = require('../utils/helpers')
|
||||||
return capitanoRunAsync('signup')
|
return runCommand('signup')
|
||||||
|
|
||||||
options[loginType] = true
|
options[loginType] = true
|
||||||
return login(options)
|
return login(options)
|
||||||
|
@ -198,7 +198,7 @@ exports.reconfigure =
|
|||||||
Promise = require('bluebird')
|
Promise = require('bluebird')
|
||||||
config = require('resin-config-json')
|
config = require('resin-config-json')
|
||||||
visuals = require('resin-cli-visuals')
|
visuals = require('resin-cli-visuals')
|
||||||
capitanoRunAsync = Promise.promisify(require('capitano').run)
|
{ runCommand } = require('../utils/helpers')
|
||||||
umountAsync = Promise.promisify(require('umount').umount)
|
umountAsync = Promise.promisify(require('umount').umount)
|
||||||
|
|
||||||
Promise.try ->
|
Promise.try ->
|
||||||
@ -212,7 +212,7 @@ exports.reconfigure =
|
|||||||
configureCommand = "os configure #{drive} --device #{uuid}"
|
configureCommand = "os configure #{drive} --device #{uuid}"
|
||||||
if options.advanced
|
if options.advanced
|
||||||
configureCommand += ' --advanced'
|
configureCommand += ' --advanced'
|
||||||
return capitanoRunAsync(configureCommand)
|
return runCommand(configureCommand)
|
||||||
.then ->
|
.then ->
|
||||||
console.info('Done')
|
console.info('Done')
|
||||||
.nodeify(done)
|
.nodeify(done)
|
||||||
|
@ -415,7 +415,6 @@ exports.init =
|
|||||||
permission: 'user'
|
permission: 'user'
|
||||||
action: (params, options, done) ->
|
action: (params, options, done) ->
|
||||||
Promise = require('bluebird')
|
Promise = require('bluebird')
|
||||||
capitanoRunAsync = Promise.promisify(require('capitano').run)
|
|
||||||
rimraf = Promise.promisify(require('rimraf'))
|
rimraf = Promise.promisify(require('rimraf'))
|
||||||
tmp = require('tmp')
|
tmp = require('tmp')
|
||||||
tmpNameAsync = Promise.promisify(tmp.tmpName)
|
tmpNameAsync = Promise.promisify(tmp.tmpName)
|
||||||
@ -423,6 +422,7 @@ exports.init =
|
|||||||
|
|
||||||
resin = require('resin-sdk-preconfigured')
|
resin = require('resin-sdk-preconfigured')
|
||||||
patterns = require('../utils/patterns')
|
patterns = require('../utils/patterns')
|
||||||
|
{ runCommand } = require('../utils/helpers')
|
||||||
|
|
||||||
Promise.try ->
|
Promise.try ->
|
||||||
return options.application if options.application?
|
return options.application if options.application?
|
||||||
@ -433,12 +433,12 @@ exports.init =
|
|||||||
download = ->
|
download = ->
|
||||||
tmpNameAsync().then (tempPath) ->
|
tmpNameAsync().then (tempPath) ->
|
||||||
osVersion = options['os-version'] or 'default'
|
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) ->
|
.disposer (tempPath) ->
|
||||||
return rimraf(tempPath)
|
return rimraf(tempPath)
|
||||||
|
|
||||||
Promise.using download(), (tempPath) ->
|
Promise.using download(), (tempPath) ->
|
||||||
capitanoRunAsync("device register #{application.app_name}")
|
runCommand("device register #{application.app_name}")
|
||||||
.then(resin.models.device.get)
|
.then(resin.models.device.get)
|
||||||
.tap (device) ->
|
.tap (device) ->
|
||||||
configureCommand = "os configure '#{tempPath}' --device #{device.uuid}"
|
configureCommand = "os configure '#{tempPath}' --device #{device.uuid}"
|
||||||
@ -446,14 +446,14 @@ exports.init =
|
|||||||
configureCommand += " --config '#{options.config}'"
|
configureCommand += " --config '#{options.config}'"
|
||||||
else if options.advanced
|
else if options.advanced
|
||||||
configureCommand += ' --advanced'
|
configureCommand += ' --advanced'
|
||||||
capitanoRunAsync(configureCommand)
|
runCommand(configureCommand)
|
||||||
.then ->
|
.then ->
|
||||||
osInitCommand = "os initialize '#{tempPath}' --type #{application.device_type}"
|
osInitCommand = "os initialize '#{tempPath}' --type #{application.device_type}"
|
||||||
if options.yes
|
if options.yes
|
||||||
osInitCommand += ' --yes'
|
osInitCommand += ' --yes'
|
||||||
if options.drive
|
if options.drive
|
||||||
osInitCommand += " --drive #{options.drive}"
|
osInitCommand += " --drive #{options.drive}"
|
||||||
capitanoRunAsync(osInitCommand)
|
runCommand(osInitCommand)
|
||||||
# Make sure the device resource is removed if there is an
|
# Make sure the device resource is removed if there is an
|
||||||
# error when configuring or initializing a device image
|
# error when configuring or initializing a device image
|
||||||
.catch (error) ->
|
.catch (error) ->
|
||||||
|
@ -38,3 +38,5 @@ module.exports =
|
|||||||
util: require('./util')
|
util: require('./util')
|
||||||
preload: require('./preload')
|
preload: require('./preload')
|
||||||
push: require('./push')
|
push: require('./push')
|
||||||
|
join: require('./join')
|
||||||
|
leave: require('./leave')
|
||||||
|
@ -35,3 +35,53 @@ exports.osInit =
|
|||||||
init.initialize(params.image, params.type, config)
|
init.initialize(params.image, params.type, config)
|
||||||
.then(helpers.osProgressHandler)
|
.then(helpers.osProgressHandler)
|
||||||
.nodeify(done)
|
.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
|
primary: true
|
||||||
action: (params, options, done) ->
|
action: (params, options, done) ->
|
||||||
Promise = require('bluebird')
|
|
||||||
capitanoRunAsync = Promise.promisify(require('capitano').run)
|
|
||||||
resin = require('resin-sdk-preconfigured')
|
resin = require('resin-sdk-preconfigured')
|
||||||
patterns = require('../utils/patterns')
|
patterns = require('../utils/patterns')
|
||||||
|
{ runCommand } = require('../utils/helpers')
|
||||||
|
|
||||||
resin.auth.isLoggedIn().then (isLoggedIn) ->
|
resin.auth.isLoggedIn().then (isLoggedIn) ->
|
||||||
return if isLoggedIn
|
return if isLoggedIn
|
||||||
console.info('Looks like you\'re not logged in yet!')
|
console.info('Looks like you\'re not logged in yet!')
|
||||||
console.info('Lets go through a quick wizard to get you started.\n')
|
console.info("Let's go through a quick wizard to get you started.\n")
|
||||||
return capitanoRunAsync('login')
|
return runCommand('login')
|
||||||
.then ->
|
.then ->
|
||||||
return if params.name?
|
return if params.name?
|
||||||
patterns.selectOrCreateApplication().tap (applicationName) ->
|
patterns.selectOrCreateApplication().tap (applicationName) ->
|
||||||
resin.models.application.has(applicationName).then (hasApplication) ->
|
resin.models.application.has(applicationName).then (hasApplication) ->
|
||||||
return applicationName if hasApplication
|
return applicationName if hasApplication
|
||||||
capitanoRunAsync("app create #{applicationName}")
|
runCommand("app create #{applicationName}")
|
||||||
.then (applicationName) ->
|
.then (applicationName) ->
|
||||||
params.name = applicationName
|
params.name = applicationName
|
||||||
.then ->
|
.then ->
|
||||||
return capitanoRunAsync("device init --application #{params.name}")
|
return runCommand("device init --application #{params.name}")
|
||||||
.tap(patterns.awaitDevice)
|
.tap(patterns.awaitDevice)
|
||||||
.then (uuid) ->
|
.then (uuid) ->
|
||||||
return capitanoRunAsync("device #{uuid}")
|
return runCommand("device #{uuid}")
|
||||||
.then ->
|
.then ->
|
||||||
return resin.models.application.get(params.name)
|
return resin.models.application.get(params.name)
|
||||||
.then (application) ->
|
.then (application) ->
|
||||||
|
@ -207,6 +207,8 @@ capitano.command(actions.util.availableDrives)
|
|||||||
|
|
||||||
# ---------- Internal utils ----------
|
# ---------- Internal utils ----------
|
||||||
capitano.command(actions.internal.osInit)
|
capitano.command(actions.internal.osInit)
|
||||||
|
capitano.command(actions.internal.scanDevices)
|
||||||
|
capitano.command(actions.internal.sudo)
|
||||||
|
|
||||||
#------------ Local build and deploy -------
|
#------------ Local build and deploy -------
|
||||||
capitano.command(actions.build)
|
capitano.command(actions.build)
|
||||||
@ -215,6 +217,10 @@ capitano.command(actions.deploy)
|
|||||||
#------------ Push/remote builds -------
|
#------------ Push/remote builds -------
|
||||||
capitano.command(actions.push.push)
|
capitano.command(actions.push.push)
|
||||||
|
|
||||||
|
#------------ Join/Leave -------
|
||||||
|
capitano.command(actions.join.join)
|
||||||
|
capitano.command(actions.leave.leave)
|
||||||
|
|
||||||
update.notify()
|
update.notify()
|
||||||
|
|
||||||
cli = capitano.parse(process.argv)
|
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
|
See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
import * as fs from 'fs';
|
||||||
|
|
||||||
import Promise = require('bluebird');
|
import Promise = require('bluebird');
|
||||||
import ResinSdk = require('resin-sdk');
|
import ResinSdk = require('resin-sdk');
|
||||||
import deviceConfig = require('resin-device-config');
|
import deviceConfig = require('resin-device-config');
|
||||||
@ -20,6 +22,18 @@ import * as semver from 'resin-semver';
|
|||||||
|
|
||||||
const resin = ResinSdk.fromSharedOptions();
|
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(
|
export function generateBaseConfig(
|
||||||
application: ResinSdk.Application,
|
application: ResinSdk.Application,
|
||||||
options: { version?: string; appUpdatePollInterval?: number },
|
options: { version?: string; appUpdatePollInterval?: number },
|
||||||
@ -39,6 +53,9 @@ export function generateBaseConfig(
|
|||||||
registryUrl: resin.settings.get('registryUrl'),
|
registryUrl: resin.settings.get('registryUrl'),
|
||||||
deltaUrl: resin.settings.get('deltaUrl'),
|
deltaUrl: resin.settings.get('deltaUrl'),
|
||||||
apiConfig: resin.models.config.getAll(),
|
apiConfig: resin.models.config.getAll(),
|
||||||
|
rootCA: readRootCa().catch(() => {
|
||||||
|
console.warn('Could not read root CA');
|
||||||
|
}),
|
||||||
}).then(results => {
|
}).then(results => {
|
||||||
return deviceConfig.generate(
|
return deviceConfig.generate(
|
||||||
{
|
{
|
||||||
@ -57,6 +74,7 @@ export function generateBaseConfig(
|
|||||||
mixpanel: {
|
mixpanel: {
|
||||||
token: results.apiConfig.mixpanelToken,
|
token: results.apiConfig.mixpanelToken,
|
||||||
},
|
},
|
||||||
|
balenaRootCA: results.rootCA,
|
||||||
},
|
},
|
||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
|
@ -19,15 +19,12 @@ import Promise = require('bluebird');
|
|||||||
import _ = require('lodash');
|
import _ = require('lodash');
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import rindle = require('rindle');
|
import rindle = require('rindle');
|
||||||
import imagefs = require('resin-image-fs');
|
|
||||||
import visuals = require('resin-cli-visuals');
|
import visuals = require('resin-cli-visuals');
|
||||||
import ResinSdk = require('resin-sdk');
|
import ResinSdk = require('resin-sdk');
|
||||||
|
|
||||||
import { execute } from 'president';
|
|
||||||
import { InitializeEmitter, OperationState } from 'resin-device-init';
|
import { InitializeEmitter, OperationState } from 'resin-device-init';
|
||||||
|
|
||||||
const waitStreamAsync = Promise.promisify(rindle.wait);
|
const waitStreamAsync = Promise.promisify(rindle.wait);
|
||||||
const presidentExecuteAsync = Promise.promisify(execute);
|
|
||||||
|
|
||||||
const resin = ResinSdk.fromSharedOptions();
|
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') {
|
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(
|
export function getManifest(
|
||||||
image: string,
|
image: string,
|
||||||
deviceType: string,
|
deviceType: string,
|
||||||
): Promise<ResinSdk.DeviceType> {
|
): Promise<ResinSdk.DeviceType> {
|
||||||
|
const imagefs = require('resin-image-fs');
|
||||||
// Attempt to read manifest from the first
|
// Attempt to read manifest from the first
|
||||||
// partition, but fallback to the API if
|
// partition, but fallback to the API if
|
||||||
// we encounter any errors along the way.
|
// we encounter any errors along the way.
|
||||||
@ -132,7 +140,7 @@ export function getArchAndDeviceType(
|
|||||||
export function getApplication(applicationName: string) {
|
export function getApplication(applicationName: string) {
|
||||||
// Check for an app of the form `user/application`, and send
|
// Check for an app of the form `user/application`, and send
|
||||||
// that off to a special handler (before importing any modules)
|
// that off to a special handler (before importing any modules)
|
||||||
const match = /(\w+)\/(\w+)/.exec(applicationName);
|
const match = applicationName.split('/');
|
||||||
|
|
||||||
const extraOptions = {
|
const extraOptions = {
|
||||||
$expand: {
|
$expand: {
|
||||||
@ -142,10 +150,10 @@ export function getApplication(applicationName: string) {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (match) {
|
if (match.length > 1) {
|
||||||
return resin.models.application.getAppByOwner(
|
return resin.models.application.getAppByOwner(
|
||||||
match[2],
|
|
||||||
match[1],
|
match[1],
|
||||||
|
match[0],
|
||||||
extraOptions,
|
extraOptions,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -23,11 +23,6 @@ import chalk from 'chalk';
|
|||||||
import validation = require('./validation');
|
import validation = require('./validation');
|
||||||
import messages = require('./messages');
|
import messages = require('./messages');
|
||||||
|
|
||||||
export interface ListSelectionEntry {
|
|
||||||
name: string;
|
|
||||||
extra: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function authenticate(options: {}): Promise<void> {
|
export function authenticate(options: {}): Promise<void> {
|
||||||
return form
|
return form
|
||||||
.run(
|
.run(
|
||||||
@ -250,14 +245,14 @@ export function inferOrSelectDevice(preferredUuid: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function selectFromList(
|
export function selectFromList<T>(
|
||||||
message: string,
|
message: string,
|
||||||
selections: ListSelectionEntry[],
|
choices: Array<T & { name: string }>,
|
||||||
): Promise<ListSelectionEntry> {
|
): Promise<T> {
|
||||||
return form.ask({
|
return form.ask({
|
||||||
message,
|
message,
|
||||||
type: 'list',
|
type: 'list',
|
||||||
choices: _.map(selections, s => ({
|
choices: _.map(choices, s => ({
|
||||||
name: s.name,
|
name: s.name,
|
||||||
value: s,
|
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",
|
"name": "resin-cli",
|
||||||
"version": "7.7.4",
|
"version": "7.8.0",
|
||||||
"description": "The official resin.io CLI tool",
|
"description": "The official resin.io CLI tool",
|
||||||
"main": "./build/actions/index.js",
|
"main": "./build/actions/index.js",
|
||||||
"homepage": "https://github.com/resin-io/resin-cli",
|
"homepage": "https://github.com/resin-io/resin-cli",
|
||||||
@ -90,6 +90,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@resin.io/valid-email": "^0.1.0",
|
"@resin.io/valid-email": "^0.1.0",
|
||||||
"@types/stream-to-promise": "2.2.0",
|
"@types/stream-to-promise": "2.2.0",
|
||||||
|
"@types/through2": "^2.0.33",
|
||||||
"JSONStream": "^1.0.3",
|
"JSONStream": "^1.0.3",
|
||||||
"ansi-escapes": "^2.0.0",
|
"ansi-escapes": "^2.0.0",
|
||||||
"any-promise": "^1.3.0",
|
"any-promise": "^1.3.0",
|
||||||
@ -129,7 +130,6 @@
|
|||||||
"mz": "^2.6.0",
|
"mz": "^2.6.0",
|
||||||
"node-cleanup": "^2.1.2",
|
"node-cleanup": "^2.1.2",
|
||||||
"opn": "^5.1.0",
|
"opn": "^5.1.0",
|
||||||
"president": "^2.0.1",
|
|
||||||
"prettyjson": "^1.1.3",
|
"prettyjson": "^1.1.3",
|
||||||
"progress-stream": "^2.0.0",
|
"progress-stream": "^2.0.0",
|
||||||
"raven": "^2.5.0",
|
"raven": "^2.5.0",
|
||||||
@ -164,12 +164,14 @@
|
|||||||
"tar-stream": "^1.5.5",
|
"tar-stream": "^1.5.5",
|
||||||
"through2": "^2.0.3",
|
"through2": "^2.0.3",
|
||||||
"tmp": "0.0.31",
|
"tmp": "0.0.31",
|
||||||
|
"typed-error": "^2.0.0",
|
||||||
"umount": "^1.1.6",
|
"umount": "^1.1.6",
|
||||||
"unzip2": "^0.2.5",
|
"unzip2": "^0.2.5",
|
||||||
"update-notifier": "^2.2.0",
|
"update-notifier": "^2.2.0",
|
||||||
"window-size": "^1.1.0"
|
"window-size": "^1.1.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"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;
|
help: string;
|
||||||
options?: OptionDefinition[];
|
options?: OptionDefinition[];
|
||||||
permission?: 'user';
|
permission?: 'user';
|
||||||
|
root?: boolean;
|
||||||
|
primary?: boolean;
|
||||||
action(params: P, options: O, done: () => void): void;
|
action(params: P, options: O, done: () => void): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user