diff --git a/CHANGELOG.md b/CHANGELOG.md index 601c5315..832239a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file automatically by Versionist. DO NOT EDIT THIS FILE MANUALLY! This project adheres to [Semantic Versioning](http://semver.org/). +## v7.4.0 - 2018-05-10 + +* Add push command which starts a build on remote resin servers #868 [Cameron Diver] + ## v7.3.8 - 2018-05-03 * Catch require errors and provide helpful instructions #874 [Tim Perry] diff --git a/lib/actions/index.coffee b/lib/actions/index.coffee index b6303e11..37468209 100644 --- a/lib/actions/index.coffee +++ b/lib/actions/index.coffee @@ -37,3 +37,4 @@ module.exports = deploy: require('./deploy') util: require('./util') preload: require('./preload') + push: require('./push') diff --git a/lib/actions/push.ts b/lib/actions/push.ts new file mode 100644 index 00000000..5f7fac11 --- /dev/null +++ b/lib/actions/push.ts @@ -0,0 +1,142 @@ +/* +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 { CommandDefinition } from 'capitano'; +import { ResinSDK } from 'resin-sdk'; +import { stripIndent } from 'common-tags'; + +async function getAppOwner(sdk: ResinSDK, appName: string) { + const { + exitWithExpectedError, + selectFromList, + } = await import('../utils/patterns'); + const _ = await import('lodash'); + + const applications = await sdk.models.application.getAll({ + $expand: { + user: { + $select: ['username'], + }, + }, + $filter: { + app_name: appName, + }, + $select: ['id'], + }); + + if (applications == null || applications.length === 0) { + exitWithExpectedError( + stripIndent` + No applications found with name: ${appName}. + + This could mean that the application does not exist, or you do + not have the permissions required to access it.`, + ); + } + + if (applications.length === 1) { + return _.get(applications, '[0].user[0].username'); + } + + // 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 + const entries = _.map(applications, app => { + const username = _.get(app, 'user[0].username'); + return { + name: `${username}/${appName}`, + extra: username, + }; + }); + + const selected = await selectFromList( + `${ + entries.length + } applications found with that name, please select the application you would like to push to`, + entries, + ); + + return selected.extra; +} + +export const push: CommandDefinition< + { + application: string; + }, + { + source: string; + } +> = { + signature: 'push <application>', + description: 'Start a remote build on the resin.io cloud build servers', + help: stripIndent` + This command can be used to start a build on the remote + resin.io cloud builders. The given source directory will be sent to the + resin.io builder, and the build will proceed. This can be used as a drop-in + replacement for git push to deploy. + + Examples: + + $ resin push myApp + $ resin push myApp --source <source directory> + $ resin push myApp -s <source directory> + `, + permission: 'user', + options: [ + { + signature: 'source', + alias: 's', + description: + 'The source that should be sent to the resin builder to be built (defaults to the current directory)', + parameter: 'source', + }, + ], + async action(params, options, done) { + const sdk = (await import('resin-sdk')).fromSharedOptions(); + const Bluebird = await import('bluebird'); + const remote = await import('../utils/remote-build'); + const { exitWithExpectedError } = await import('../utils/patterns'); + + const app: string | null = params.application; + if (app == null) { + exitWithExpectedError('You must specify an application'); + } + + const source = options.source || '.'; + if (process.env.DEBUG) { + console.log(`[debug] Using ${source} as build source`); + } + + Bluebird.join( + sdk.auth.getToken(), + sdk.settings.get('resinUrl'), + getAppOwner(sdk, app), + (token, baseUrl, owner) => { + const args = { + app, + owner, + source, + auth: token, + baseUrl, + sdk, + }; + + return remote.startRemoteBuild(args); + }, + ).nodeify(done); + }, +}; diff --git a/lib/app.coffee b/lib/app.coffee index 4d1f42b6..3d7b3245 100644 --- a/lib/app.coffee +++ b/lib/app.coffee @@ -212,6 +212,9 @@ capitano.command(actions.internal.osInit) capitano.command(actions.build) capitano.command(actions.deploy) +#------------ Push/remote builds ------- +capitano.command(actions.push.push) + update.notify() cli = capitano.parse(process.argv) diff --git a/lib/utils/compose.coffee b/lib/utils/compose.coffee index 3a0c24a3..a45ff51d 100644 --- a/lib/utils/compose.coffee +++ b/lib/utils/compose.coffee @@ -102,7 +102,7 @@ toPosixPath = (systemPath) -> path = require('path') systemPath.replace(new RegExp('\\' + path.sep, 'g'), '/') -tarDirectory = (dir) -> +exports.tarDirectory = tarDirectory = (dir) -> tar = require('tar-stream') klaw = require('klaw') path = require('path') diff --git a/lib/utils/compose.d.ts b/lib/utils/compose.d.ts new file mode 100644 index 00000000..3b251828 --- /dev/null +++ b/lib/utils/compose.d.ts @@ -0,0 +1,3 @@ +import * as Stream from 'stream'; + +export function tarDirectory(source: string): Promise<Stream.Readable>; diff --git a/lib/utils/patterns.ts b/lib/utils/patterns.ts index cd4fc11d..e805929b 100644 --- a/lib/utils/patterns.ts +++ b/lib/utils/patterns.ts @@ -23,6 +23,11 @@ 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( @@ -245,6 +250,20 @@ export function inferOrSelectDevice(preferredUuid: string) { }); } +export function selectFromList( + message: string, + selections: ListSelectionEntry[], +): Promise<ListSelectionEntry> { + return form.ask({ + message, + type: 'list', + choices: _.map(selections, s => ({ + name: s.name, + value: s, + })), + }); +} + export function printErrorMessage(message: string) { console.error(chalk.red(message)); console.error(chalk.red(`\n${messages.getHelp}\n`)); diff --git a/lib/utils/remote-build.ts b/lib/utils/remote-build.ts new file mode 100644 index 00000000..0d4624ab --- /dev/null +++ b/lib/utils/remote-build.ts @@ -0,0 +1,215 @@ +/* +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 JSONStream from 'JSONStream'; +import * as request from 'request'; +import { ResinSDK } from 'resin-sdk'; +import * as Stream from 'stream'; + +import { tarDirectory } from './compose'; + +const DEBUG_MODE = !!process.env.DEBUG; + +const CURSOR_METADATA_REGEX = /([a-z]+)([0-9]+)?/; +const TRIM_REGEX = /\n+$/; + +export interface RemoteBuild { + app: string; + owner: string; + source: string; + auth: string; + baseUrl: string; + + sdk: ResinSDK; + + // For internal use + releaseId?: number; +} + +interface BuilderMessage { + message: string; + type?: string; + replace?: boolean; + // These will be set when the type === 'metadata' + resource?: string; + value?: string; +} + +async function getBuilderEndpoint( + baseUrl: string, + owner: string, + app: string, +): Promise<string> { + const querystring = await import('querystring'); + const args = querystring.stringify({ owner, app }); + return `https://builder.${baseUrl}/v3/build?${args}`; +} + +export async function startRemoteBuild(build: RemoteBuild): Promise<void> { + const Bluebird = await import('bluebird'); + + return new Bluebird(async (resolve, reject) => { + const stream = await getRequestStream(build); + + // Special windows handling (win64 also reports win32) + if (process.platform === 'win32') { + const readline = (await import('readline')).createInterface({ + input: process.stdin, + output: process.stdout, + }); + + readline.on('SIGINT', () => process.emit('SIGINT')); + } + + // Setup interrupt handlers so we can cancel the build if the user presses + // ctrl+c + + // This is necessary because the `exit-hook` module is used by several + // dependencies, and will exit without calling the following handler. + // Once https://github.com/resin-io/resin-cli/issues/867 has been solved, + // we are free to (and definitely should) remove the below line + process.removeAllListeners('SIGINT'); + process.on('SIGINT', () => { + process.stderr.write('Received SIGINT, cleaning up. Please wait.\n'); + cancelBuildIfNecessary(build).then(() => { + stream.end(); + process.exit(130); + }); + }); + + stream.on('data', getBuilderMessageHandler(build)); + stream.on('end', resolve); + stream.on('error', reject); + }).return(); +} + +async function handleBuilderMetadata(obj: BuilderMessage, build: RemoteBuild) { + const { stripIndent } = await import('common-tags'); + + switch (obj.resource) { + case 'cursor': + const readline = await import('readline'); + + if (obj.value == null) { + return; + } + + const match = obj.value.match(CURSOR_METADATA_REGEX); + + if (!match) { + // FIXME: Make this error nicer. + console.log( + stripIndent` + Warning: ignoring unknown builder command. You may experience + odd build output. Maybe you need to update resin-cli?`, + ); + return; + } + + const value = match[1]; + const amount = match[2] || 1; + + switch (value) { + case 'erase': + readline.clearLine(process.stdout, 0); + process.stdout.write('\r'); + break; + case 'up': + readline.moveCursor(process.stdout, 0, -amount); + break; + case 'down': + readline.moveCursor(process.stdout, 0, amount); + break; + } + + break; + case 'buildLogId': + // The name of this resource is slightly dated, but this is the release + // id from the API. We need to save this so that if the user ctrl+c's the + // build we can cancel it on the API. + build.releaseId = parseInt(obj.value!, 10); + break; + } +} + +function getBuilderMessageHandler( + build: RemoteBuild, +): (obj: BuilderMessage) => Promise<void> { + return async (obj: BuilderMessage) => { + if (DEBUG_MODE) { + console.log(`[debug] handling message: ${JSON.stringify(obj)}`); + } + if (obj.type != null && obj.type === 'metadata') { + return handleBuilderMetadata(obj, build); + } + if (obj.message) { + const readline = await import('readline'); + readline.clearLine(process.stdout, 0); + + const message = obj.message.replace(TRIM_REGEX, ''); + if (obj.replace) { + process.stdout.write(`\r${message}`); + } else { + process.stdout.write(`\r${message}\n`); + } + } + }; +} + +async function cancelBuildIfNecessary(build: RemoteBuild): Promise<void> { + if (build.releaseId != null) { + await build.sdk.pine.patch({ + resource: 'release', + id: build.releaseId, + body: { + status: 'cancelled', + end_timestamp: Date.now(), + }, + }); + } +} + +async function getRequestStream(build: RemoteBuild): Promise<Stream.Duplex> { + const path = await import('path'); + const visuals = await import('resin-cli-visuals'); + + const tarSpinner = new visuals.Spinner('Packaging the project source...'); + tarSpinner.start(); + // Tar the directory so that we can send it to the builder + const tarStream = await tarDirectory(path.resolve(build.source)); + tarSpinner.stop(); + + if (DEBUG_MODE) { + console.log('[debug] Opening builder connection'); + } + const post = request.post({ + url: await getBuilderEndpoint(build.baseUrl, build.owner, build.app), + auth: { + bearer: build.auth, + }, + }); + + const uploadSpinner = new visuals.Spinner( + 'Uploading source package to resin cloud', + ); + uploadSpinner.start(); + + tarStream.pipe(post); + + const parseStream = post.pipe(JSONStream.parse('*')); + parseStream.on('data', () => uploadSpinner.stop()); + return parseStream as Stream.Duplex; +} diff --git a/package.json b/package.json index a24567fb..acfa115a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "resin-cli", - "version": "7.3.8", + "version": "7.4.0", "description": "The official resin.io CLI tool", "main": "./build/actions/index.js", "homepage": "https://github.com/resin-io/resin-cli", @@ -89,6 +89,7 @@ }, "dependencies": { "@resin.io/valid-email": "^0.1.0", + "@types/stream-to-promise": "^2.2.0", "ansi-escapes": "^2.0.0", "any-promise": "^1.3.0", "archiver": "^2.1.0", @@ -117,6 +118,7 @@ "inquirer": "^3.1.1", "is-root": "^1.0.0", "js-yaml": "^3.10.0", + "JSONStream": "^1.0.3", "klaw": "^1.3.1", "lodash": "^4.17.4", "mixpanel": "^0.4.0", @@ -146,7 +148,7 @@ "resin-multibuild": "^0.5.1", "resin-preload": "^6.2.0", "resin-release": "^1.2.0", - "resin-sdk": "9.0.0-beta17", + "resin-sdk": "9.0.0-beta18", "resin-sdk-preconfigured": "^6.9.0", "resin-settings-client": "^3.6.1", "resin-stream-logger": "^0.1.0", diff --git a/typings/JSONStream.d.ts b/typings/JSONStream.d.ts new file mode 100644 index 00000000..0c9909f7 --- /dev/null +++ b/typings/JSONStream.d.ts @@ -0,0 +1,53 @@ +// These are the DefinitelyTyped typings for JSONStream, but because of this +// mismatch in case of jsonstream and JSONStream, it is necessary to include +// them this way, with an upper case module declaration. They have also +// been slightly edited to remove the extra `declare` keyworks (which are +// not necessary or accepted inside a `declare module '...' {` block) +declare module 'JSONStream' { + // Type definitions for JSONStream v0.8.0 + // Project: https://github.com/dominictarr/JSONStream + // Definitions by: Bart van der Schoor <https://github.com/Bartvds> + // Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped + + /// <reference types="node" /> + + export interface Options { + recurse: boolean; + } + + export function parse(pattern: any): NodeJS.ReadWriteStream; + export function parse(patterns: any[]): NodeJS.ReadWriteStream; + + /** + * Create a writable stream. + * you may pass in custom open, close, and seperator strings. But, by default, + * JSONStream.stringify() will create an array, + * (with default options open='[\n', sep='\n,\n', close='\n]\n') + */ + export function stringify(): NodeJS.ReadWriteStream; + + /** If you call JSONStream.stringify(false) the elements will only be seperated by a newline. */ + export function stringify( + newlineOnly: NewlineOnlyIndicator, + ): NodeJS.ReadWriteStream; + type NewlineOnlyIndicator = false; + + /** + * Create a writable stream. + * you may pass in custom open, close, and seperator strings. But, by default, + * JSONStream.stringify() will create an array, + * (with default options open='[\n', sep='\n,\n', close='\n]\n') + */ + export function stringify( + open: string, + sep: string, + close: string, + ): NodeJS.ReadWriteStream; + + export function stringifyObject(): NodeJS.ReadWriteStream; + export function stringifyObject( + open: string, + sep: string, + close: string, + ): NodeJS.ReadWriteStream; +}