diff --git a/lib/actions/auth.coffee b/lib/actions/auth.coffee index a6d87b0f..ceda3883 100644 --- a/lib/actions/auth.coffee +++ b/lib/actions/auth.coffee @@ -102,8 +102,8 @@ exports.login = return patterns.askLoginType().then (loginType) -> if loginType is 'register' - capitanoRunAsync = Promise.promisify(require('capitano').run) - return capitanoRunAsync('signup') + { runCommand } = require('../utils/helpers') + return runCommand('signup') options[loginType] = true return login(options) diff --git a/lib/actions/config.coffee b/lib/actions/config.coffee index 361dd88e..32b9a127 100644 --- a/lib/actions/config.coffee +++ b/lib/actions/config.coffee @@ -198,7 +198,7 @@ exports.reconfigure = Promise = require('bluebird') config = require('resin-config-json') visuals = require('resin-cli-visuals') - capitanoRunAsync = Promise.promisify(require('capitano').run) + { runCommand } = require('../utils/helpers') umountAsync = Promise.promisify(require('umount').umount) Promise.try -> @@ -212,7 +212,7 @@ exports.reconfigure = configureCommand = "os configure #{drive} --device #{uuid}" if options.advanced configureCommand += ' --advanced' - return capitanoRunAsync(configureCommand) + return runCommand(configureCommand) .then -> console.info('Done') .nodeify(done) diff --git a/lib/actions/device.coffee b/lib/actions/device.coffee index f0243203..20d86288 100644 --- a/lib/actions/device.coffee +++ b/lib/actions/device.coffee @@ -415,7 +415,6 @@ exports.init = permission: 'user' action: (params, options, done) -> Promise = require('bluebird') - capitanoRunAsync = Promise.promisify(require('capitano').run) rimraf = Promise.promisify(require('rimraf')) tmp = require('tmp') tmpNameAsync = Promise.promisify(tmp.tmpName) @@ -423,6 +422,7 @@ exports.init = resin = require('resin-sdk-preconfigured') patterns = require('../utils/patterns') + { runCommand } = require('../utils/helpers') Promise.try -> return options.application if options.application? @@ -433,12 +433,12 @@ exports.init = download = -> tmpNameAsync().then (tempPath) -> osVersion = options['os-version'] or 'default' - capitanoRunAsync("os download #{application.device_type} --output '#{tempPath}' --version #{osVersion}") + runCommand("os download #{application.device_type} --output '#{tempPath}' --version #{osVersion}") .disposer (tempPath) -> return rimraf(tempPath) Promise.using download(), (tempPath) -> - capitanoRunAsync("device register #{application.app_name}") + runCommand("device register #{application.app_name}") .then(resin.models.device.get) .tap (device) -> configureCommand = "os configure '#{tempPath}' --device #{device.uuid}" @@ -446,14 +446,14 @@ exports.init = configureCommand += " --config '#{options.config}'" else if options.advanced configureCommand += ' --advanced' - capitanoRunAsync(configureCommand) + runCommand(configureCommand) .then -> osInitCommand = "os initialize '#{tempPath}' --type #{application.device_type}" if options.yes osInitCommand += ' --yes' if options.drive osInitCommand += " --drive #{options.drive}" - capitanoRunAsync(osInitCommand) + runCommand(osInitCommand) # Make sure the device resource is removed if there is an # error when configuring or initializing a device image .catch (error) -> diff --git a/lib/actions/index.coffee b/lib/actions/index.coffee index 37468209..6946d74a 100644 --- a/lib/actions/index.coffee +++ b/lib/actions/index.coffee @@ -38,3 +38,5 @@ module.exports = util: require('./util') preload: require('./preload') push: require('./push') + join: require('./join') + leave: require('./leave') diff --git a/lib/actions/internal.coffee b/lib/actions/internal.coffee index 52c8ecef..07823d53 100644 --- a/lib/actions/internal.coffee +++ b/lib/actions/internal.coffee @@ -35,3 +35,53 @@ exports.osInit = init.initialize(params.image, params.type, config) .then(helpers.osProgressHandler) .nodeify(done) + +exports.scanDevices = + signature: 'internal scandevices' + description: 'scan for local resin-enabled devices and show a picker to choose one' + help: ''' + Don't use this command directly! + ''' + hidden: true + root: true + action: (params, options, done) -> + Promise = require('bluebird') + { forms } = require('resin-sync') + + return Promise.try -> + forms.selectLocalResinOsDevice() + .then (hostnameOrIp) -> + console.error("==> Selected device: #{hostnameOrIp}") + .nodeify(done) + +exports.sudo = + signature: 'internal sudo ' + description: 'execute arbitrary commands in a privileged subprocess' + help: ''' + Don't use this command directly! + + must be passed as a single argument. That means, you need to make sure + you enclose in quotes (eg. resin internal sudo 'ls -alF') if for + whatever reason you invoke the command directly or, typically, pass + as a single argument to spawn (eg. `spawn('resin', [ 'internal', 'sudo', 'ls -alF' ])`). + + Furthermore, this command will naively split on whitespace and directly + forward the parts as arguments to `sudo`, so be careful. + ''' + hidden: true + action: (params, options, done) -> + os = require('os') + Promise = require('bluebird') + + return Promise.try -> + if os.platform() is 'win32' + windosu = require('windosu') + windosu.exec(params.command, {}) + else + { spawn } = require('child_process') + { wait } = require('rindle') + cmd = params.command.split(' ') + ps = spawn('sudo', cmd, stdio: 'inherit', env: process.env) + wait(ps) + + .nodeify(done) diff --git a/lib/actions/join.ts b/lib/actions/join.ts new file mode 100644 index 00000000..173f2bf9 --- /dev/null +++ b/lib/actions/join.ts @@ -0,0 +1,62 @@ +/* +Copyright 2016-2017 Resin.io + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import * as Bluebird from 'bluebird'; +import { CommandDefinition } from 'capitano'; +import { stripIndent } from 'common-tags'; + +interface Args { + deviceIp?: string; +} + +interface Options { + application?: string; +} + +export const join: CommandDefinition = { + signature: 'join [deviceIp]', + description: + 'Promote a local device running unmanaged resinOS to join a resin.io application', + help: stripIndent` + Examples: + + $ resin join + $ resin join resin.local + $ resin join resin.local --application MyApp + $ resin join 192.168.1.25 + $ resin join 192.168.1.25 --application MyApp + `, + options: [ + { + signature: 'application', + parameter: 'application', + alias: 'a', + description: 'The name of the application the device should join', + }, + ], + + primary: true, + + async action(params, options, done) { + const resin = await import('resin-sdk'); + const Logger = await import('../utils/logger'); + const promote = await import('../utils/promote'); + const sdk = resin.fromSharedOptions(); + const logger = new Logger(); + return Bluebird.try(() => { + return promote.join(logger, sdk, params.deviceIp, options.application); + }).nodeify(done); + }, +}; diff --git a/lib/actions/leave.ts b/lib/actions/leave.ts new file mode 100644 index 00000000..8db11e5c --- /dev/null +++ b/lib/actions/leave.ts @@ -0,0 +1,49 @@ +/* +Copyright 2016-2017 Resin.io + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import * as Bluebird from 'bluebird'; +import { CommandDefinition } from 'capitano'; +import { stripIndent } from 'common-tags'; + +interface Args { + deviceIp?: string; +} + +export const leave: CommandDefinition = { + signature: 'leave [deviceIp]', + description: 'Detach a local device from its resin.io application', + help: stripIndent` + Examples: + + $ resin leave + $ resin leave resin.local + $ resin leave 192.168.1.25 + `, + options: [], + + permission: 'user', + primary: true, + + async action(params, _options, done) { + const resin = await import('resin-sdk'); + const Logger = await import('../utils/logger'); + const promote = await import('../utils/promote'); + const sdk = resin.fromSharedOptions(); + const logger = new Logger(); + return Bluebird.try(() => { + return promote.leave(logger, sdk, params.deviceIp); + }).nodeify(done); + }, +}; diff --git a/lib/actions/wizard.coffee b/lib/actions/wizard.coffee index a388e8dd..52bdc0df 100644 --- a/lib/actions/wizard.coffee +++ b/lib/actions/wizard.coffee @@ -34,29 +34,28 @@ exports.wizard = ''' primary: true action: (params, options, done) -> - Promise = require('bluebird') - capitanoRunAsync = Promise.promisify(require('capitano').run) resin = require('resin-sdk-preconfigured') patterns = require('../utils/patterns') + { runCommand } = require('../utils/helpers') resin.auth.isLoggedIn().then (isLoggedIn) -> return if isLoggedIn console.info('Looks like you\'re not logged in yet!') - console.info('Lets go through a quick wizard to get you started.\n') - return capitanoRunAsync('login') + console.info("Let's go through a quick wizard to get you started.\n") + return runCommand('login') .then -> return if params.name? patterns.selectOrCreateApplication().tap (applicationName) -> resin.models.application.has(applicationName).then (hasApplication) -> return applicationName if hasApplication - capitanoRunAsync("app create #{applicationName}") + runCommand("app create #{applicationName}") .then (applicationName) -> params.name = applicationName .then -> - return capitanoRunAsync("device init --application #{params.name}") + return runCommand("device init --application #{params.name}") .tap(patterns.awaitDevice) .then (uuid) -> - return capitanoRunAsync("device #{uuid}") + return runCommand("device #{uuid}") .then -> return resin.models.application.get(params.name) .then (application) -> diff --git a/lib/app.coffee b/lib/app.coffee index 3d7b3245..701a09b7 100644 --- a/lib/app.coffee +++ b/lib/app.coffee @@ -207,6 +207,8 @@ capitano.command(actions.util.availableDrives) # ---------- Internal utils ---------- capitano.command(actions.internal.osInit) +capitano.command(actions.internal.scanDevices) +capitano.command(actions.internal.sudo) #------------ Local build and deploy ------- capitano.command(actions.build) @@ -215,6 +217,10 @@ capitano.command(actions.deploy) #------------ Push/remote builds ------- capitano.command(actions.push.push) +#------------ Join/Leave ------- +capitano.command(actions.join.join) +capitano.command(actions.leave.leave) + update.notify() cli = capitano.parse(process.argv) diff --git a/lib/utils/helpers.ts b/lib/utils/helpers.ts index 4f3d0d95..fbf41146 100644 --- a/lib/utils/helpers.ts +++ b/lib/utils/helpers.ts @@ -23,11 +23,9 @@ import imagefs = require('resin-image-fs'); import visuals = require('resin-cli-visuals'); import ResinSdk = require('resin-sdk'); -import { execute } from 'president'; import { InitializeEmitter, OperationState } from 'resin-device-init'; const waitStreamAsync = Promise.promisify(rindle.wait); -const presidentExecuteAsync = Promise.promisify(execute); const resin = ResinSdk.fromSharedOptions(); @@ -63,14 +61,24 @@ export function stateToString(state: OperationState) { } } -export function sudo(command: string[]) { +export function sudo( + command: string[], + { stderr, msg }: { stderr?: NodeJS.WritableStream; msg?: string }, +) { + const { executeWithPrivileges } = require('./sudo'); + if (os.platform() !== 'win32') { - console.log('If asked please type your computer password to continue'); + console.log( + msg || 'If asked please type your computer password to continue', + ); } - command = _.union(_.take(process.argv, 2), command); + return executeWithPrivileges(command, stderr); +} - return presidentExecuteAsync(command); +export function runCommand(command: string): Promise { + const capitano = require('capitano'); + return Promise.fromCallback(resolver => capitano.run(command, resolver)); } export function getManifest( @@ -132,7 +140,7 @@ export function getArchAndDeviceType( export function getApplication(applicationName: string) { // Check for an app of the form `user/application`, and send // that off to a special handler (before importing any modules) - const match = /(\w+)\/(\w+)/.exec(applicationName); + const match = applicationName.split('/'); const extraOptions = { $expand: { @@ -142,10 +150,10 @@ export function getApplication(applicationName: string) { }, }; - if (match) { + if (match.length > 1) { return resin.models.application.getAppByOwner( - match[2], match[1], + match[0], extraOptions, ); } diff --git a/lib/utils/patterns.ts b/lib/utils/patterns.ts index a6f3e0fc..5960ae9b 100644 --- a/lib/utils/patterns.ts +++ b/lib/utils/patterns.ts @@ -245,7 +245,10 @@ export function inferOrSelectDevice(preferredUuid: string) { }); } -export function selectFromList(message: string, choices: Array): Promise { +export function selectFromList( + message: string, + choices: Array, +): Promise { return form.ask({ message, type: 'list', diff --git a/lib/utils/promote.ts b/lib/utils/promote.ts new file mode 100644 index 00000000..5c81dc1c --- /dev/null +++ b/lib/utils/promote.ts @@ -0,0 +1,323 @@ +import { stripIndent } from 'common-tags'; +import { ResinSDK, Application } from 'resin-sdk'; + +import Logger = require('./logger'); + +import { runCommand } from './helpers'; +import { exec, execBuffered } from './ssh'; + +const MIN_RESINOS_VERSION = 'v2.14.0'; + +export async function join( + logger: Logger, + sdk: ResinSDK, + deviceHostnameOrIp?: string, + appName?: string, +): Promise { + logger.logDebug('Checking login...'); + const isLoggedIn = await sdk.auth.isLoggedIn(); + if (!isLoggedIn) { + logger.logInfo("Looks like you're not logged in yet!"); + logger.logInfo("Let's go through a quick wizard to get you started.\n"); + await runCommand('login'); + } + + logger.logDebug('Determining device...'); + const deviceIp = await getOrSelectLocalDevice(deviceHostnameOrIp); + await assertDeviceIsCompatible(deviceIp); + logger.logDebug(`Using device: ${deviceIp}`); + + logger.logDebug('Determining device type...'); + const deviceType = await getDeviceType(deviceIp); + logger.logDebug(`Device type: ${deviceType}`); + + logger.logDebug('Determining application...'); + const app = await getOrSelectApplication(sdk, deviceType, appName); + logger.logDebug(`Using application: ${app.app_name} (${app.device_type})`); + if (app.device_type != deviceType) { + logger.logDebug(`Forcing device type to: ${deviceType}`); + app.device_type = deviceType; + } + + logger.logDebug('Generating application config...'); + const config = await generateApplicationConfig(sdk, app); + logger.logDebug(`Using config: ${JSON.stringify(config, null, 2)}`); + + logger.logDebug('Configuring...'); + await configure(deviceIp, config); + logger.logDebug('All done.'); + + const platformUrl = await sdk.settings.get('resinUrl'); + logger.logSuccess(`Device successfully joined ${platformUrl}!`); +} + +export async function leave( + logger: Logger, + _sdk: ResinSDK, + deviceHostnameOrIp?: string, +): Promise { + logger.logDebug('Determining device...'); + const deviceIp = await getOrSelectLocalDevice(deviceHostnameOrIp); + await assertDeviceIsCompatible(deviceIp); + logger.logDebug(`Using device: ${deviceIp}`); + + logger.logDebug('Deconfiguring...'); + await deconfigure(deviceIp); + logger.logDebug('All done.'); + + logger.logSuccess('Device successfully left the platform.'); +} + +async function execCommand( + deviceIp: string, + cmd: string, + msg: string, +): Promise { + const through = await import('through2'); + const visuals = await import('resin-cli-visuals'); + + const spinner = new visuals.Spinner(`[${deviceIp}] Connecting...`); + const innerSpinner = spinner.spinner; + + const stream = through(function(data, _enc, cb) { + innerSpinner.setSpinnerTitle(`%s [${deviceIp}] ${msg}`); + cb(null, data); + }); + + spinner.start(); + await exec(deviceIp, cmd, stream); + spinner.stop(); +} + +async function configure(deviceIp: string, config: any): Promise { + // Passing the JSON is slightly tricky due to the many layers of indirection + // so we just base64-encode it here and decode it at the other end, when invoking + // os-config. + const json = JSON.stringify(config); + const b64 = Buffer.from(json).toString('base64'); + const str = `"$(base64 -d <<< ${b64})"`; + await execCommand(deviceIp, `os-config join '${str}'`, 'Configuring...'); +} + +async function deconfigure(deviceIp: string): Promise { + await execCommand(deviceIp, 'os-config leave', 'Configuring...'); +} + +async function assertDeviceIsCompatible(deviceIp: string): Promise { + const { exitWithExpectedError } = await import('../utils/patterns'); + try { + await execBuffered(deviceIp, 'os-config --version'); + } catch (err) { + exitWithExpectedError(stripIndent` + Device "${deviceIp}" is incompatible and cannot join or leave an application. + Please select or provision device with resinOS newer than ${MIN_RESINOS_VERSION}.`); + } +} + +async function getDeviceType(deviceIp: string): Promise { + const output = await execBuffered(deviceIp, 'cat /etc/os-release'); + const match = /^SLUG="([^"]+)"$/m.exec(output); + if (!match) { + throw new Error('Failed to determine device type'); + } + return match[1]; +} + +async function getOrSelectLocalDevice(deviceIp?: string): Promise { + if (deviceIp) { + return deviceIp; + } + + const through = await import('through2'); + + let ip: string | null = null; + const stream = through(function(data, _enc, cb) { + const match = /^==> Selected device: (.*)$/m.exec(data.toString()); + if (match) { + ip = match[1]; + cb(); + } else { + cb(null, data); + } + }); + + stream.pipe(process.stderr); + + const { sudo } = await import('../utils/helpers'); + const command = process.argv.slice(0, 2).concat(['internal', 'scandevices']); + await sudo(command, { + stderr: stream, + msg: + 'Scanning for local devices. If asked, please type your computer password.', + }); + + if (!ip) { + throw new Error('No device selected'); + } + + return ip; +} + +async function getOrSelectApplication( + sdk: ResinSDK, + deviceType: string, + appName?: string, +): Promise { + const _ = await import('lodash'); + const form = await import('resin-cli-form'); + const { selectFromList } = await import('../utils/patterns'); + + const allDeviceTypes = await sdk.models.config.getDeviceTypes(); + const deviceTypeManifest = _.find(allDeviceTypes, { slug: deviceType }); + if (!deviceTypeManifest) { + throw new Error(`"${deviceType}" is not a valid device type`); + } + const compatibleDeviceTypes = _(allDeviceTypes) + .filter({ arch: deviceTypeManifest.arch }) + .map(type => type.slug) + .value(); + + const options: any = { + $expand: { user: { $select: ['username'] } }, + $filter: { device_type: { $in: compatibleDeviceTypes } }, + }; + + if (!appName) { + // No application specified, show a list to select one. + const applications = await sdk.models.application.getAll(options); + if (applications.length === 0) { + const shouldCreateApp = await form.ask({ + message: + 'You have no applications this device can join.\n' + + 'Would you like to create one now?', + type: 'confirm', + default: true, + }); + if (shouldCreateApp) { + return createApplication(sdk, deviceType); + } + process.exit(1); + } + return selectFromList( + 'Select application', + _.map(applications, app => _.merge({ name: app.app_name }, app)), + ); + } + + // We're given an application; resolve it if it's ambiguous and also validate + // it's of appropriate device type. + options.$filter = { app_name: appName }; + + // Check for an app of the form `user/application` and update the API query. + const match = appName.split('/'); + if (match.length > 1) { + // These will match at most one app, so we'll return early. + options.$expand.user.$filter = { username: match[0] }; + options.$filter.app_name = match[1]; + } + + // Fetch all applications with the given name that are accessible to the user + const applications = await sdk.pine.get({ + resource: 'application', + options, + }); + + if (applications.length === 0) { + const shouldCreateApp = await form.ask({ + message: + `No application found with name "${appName}".\n` + + 'Would you like to create it now?', + type: 'confirm', + default: true, + }); + if (shouldCreateApp) { + return createApplication(sdk, deviceType, options.$filter.app_name); + } + process.exit(1); + } + + // We've found at least one app with the given name. + // Filter out apps for non-matching device types and see what we're left with. + const validApplications = applications.filter(app => + _.includes(compatibleDeviceTypes, app.device_type), + ); + + if (validApplications.length === 1) { + return validApplications[0]; + } + + // If we got more than one application with the same name it means that the + // user has access to a collab app with the same name as a personal app. We + // present a list to the user which shows the fully qualified application + // name (user/appname) and allows them to select + return selectFromList( + 'Found multiple applications with that name; please select the one to use', + _.map(validApplications, app => { + const owner = _.get(app, 'user[0].username'); + return _.merge({ name: `${owner}/${app.app_name}` }, app); + }), + ); +} + +async function createApplication( + sdk: ResinSDK, + deviceType: string, + name?: string, +): Promise { + const form = await import('resin-cli-form'); + const validation = await import('./validation'); + const patterns = await import('./patterns'); + + const user = await sdk.auth.getUserId(); + const queryOptions = { + $filter: { user }, + }; + + const appName = await new Promise(async (resolve, reject) => { + while (true) { + try { + const appName = await form.ask({ + message: 'Enter a name for your new application:', + type: 'input', + default: name, + validate: validation.validateApplicationName, + }); + + try { + await sdk.models.application.get(appName, queryOptions); + patterns.printErrorMessage( + 'You already have an application with that name; please choose another.', + ); + continue; + } catch (err) { + return resolve(appName); + } + } catch (err) { + return reject(err); + } + } + }); + + return sdk.models.application.create({ + name: appName, + deviceType, + }); +} + +async function generateApplicationConfig(sdk: ResinSDK, app: Application) { + const form = await import('resin-cli-form'); + const { generateApplicationConfig: configGen } = await import('./config'); + + const manifest = await sdk.models.device.getManifestBySlug(app.device_type); + const opts = + manifest.options && manifest.options.filter(opt => opt.name !== 'network'); + const values = await form.run(opts); + + const config = await configGen(app, values); + if (config.connectivity === 'connman') { + delete config.connectivity; + delete config.files; + } + + return config; +} diff --git a/lib/utils/ssh.ts b/lib/utils/ssh.ts new file mode 100644 index 00000000..c1595485 --- /dev/null +++ b/lib/utils/ssh.ts @@ -0,0 +1,65 @@ +import { spawn } from 'child_process'; +import * as Bluebird from 'bluebird'; +import TypedError = require('typed-error'); + +import { getSubShellCommand } from './helpers'; + +export class ExecError extends TypedError { + public cmd: string; + public exitCode: number; + + constructor(cmd: string, exitCode: number) { + super(`Command '${cmd}' failed with error: ${exitCode}`); + this.cmd = cmd; + this.exitCode = exitCode; + } +} + +export async function exec( + deviceIp: string, + cmd: string, + stdout?: NodeJS.WritableStream, +): Promise { + const command = `ssh \ + -t \ + -p 22222 \ + -o LogLevel=ERROR \ + -o StrictHostKeyChecking=no \ + -o UserKnownHostsFile=/dev/null \ + root@${deviceIp} \ + ${cmd}`; + + const stdio = ['ignore', stdout ? 'pipe' : 'inherit', 'ignore']; + const { program, args } = getSubShellCommand(command); + + const exitCode = await new Bluebird((resolve, reject) => { + const ps = spawn(program, args, { stdio }) + .on('error', reject) + .on('close', resolve); + + if (stdout) { + ps.stdout.pipe(stdout); + } + }); + if (exitCode != 0) { + throw new ExecError(cmd, exitCode); + } +} + +export async function execBuffered( + deviceIp: string, + cmd: string, + enc?: string, +): Promise { + const through = await import('through2'); + const buffer: string[] = []; + await exec( + deviceIp, + cmd, + through(function(data, _enc, cb) { + buffer.push(data.toString(enc)); + cb(); + }), + ); + return buffer.join(''); +} diff --git a/lib/utils/sudo.ts b/lib/utils/sudo.ts new file mode 100644 index 00000000..41c9de63 --- /dev/null +++ b/lib/utils/sudo.ts @@ -0,0 +1,26 @@ +import { spawn } from 'child_process'; + +import * as Bluebird from 'bluebird'; +import * as rindle from 'rindle'; + +export async function executeWithPrivileges( + command: string[], + stderr?: NodeJS.WritableStream, +): Promise { + const opts = { + stdio: ['inherit', 'inherit', stderr ? 'pipe' : 'inherit'], + env: process.env, + }; + + const args = process.argv + .slice(0, 2) + .concat(['internal', 'sudo', command.join(' ')]); + + const ps = spawn(args[0], args.slice(1), opts); + + if (stderr) { + ps.stderr.pipe(stderr); + } + + return Bluebird.fromCallback(resolver => rindle.wait(ps, resolver)); +} diff --git a/package.json b/package.json index 301fc9c3..2bbdb942 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "dependencies": { "@resin.io/valid-email": "^0.1.0", "@types/stream-to-promise": "2.2.0", + "@types/through2": "^2.0.33", "JSONStream": "^1.0.3", "ansi-escapes": "^2.0.0", "any-promise": "^1.3.0", @@ -129,7 +130,6 @@ "mz": "^2.6.0", "node-cleanup": "^2.1.2", "opn": "^5.1.0", - "president": "^2.0.1", "prettyjson": "^1.1.3", "progress-stream": "^2.0.0", "raven": "^2.5.0", @@ -164,12 +164,14 @@ "tar-stream": "^1.5.5", "through2": "^2.0.3", "tmp": "0.0.31", + "typed-error": "^2.0.0", "umount": "^1.1.6", "unzip2": "^0.2.5", "update-notifier": "^2.2.0", "window-size": "^1.1.0" }, "optionalDependencies": { - "removedrive": "^1.0.0" + "removedrive": "^1.0.0", + "windosu": "^0.2.0" } } diff --git a/typings/capitano.d.ts b/typings/capitano.d.ts index aaa385b3..acefe3ae 100644 --- a/typings/capitano.d.ts +++ b/typings/capitano.d.ts @@ -21,6 +21,8 @@ declare module 'capitano' { help: string; options?: OptionDefinition[]; permission?: 'user'; + root?: boolean; + primary?: boolean; action(params: P, options: O, done: () => void): void; }