From d50049777fb3c0500da2ea2484173fa8bcfbc086 Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Fri, 28 Feb 2014 02:03:02 -0800 Subject: [PATCH] A quick-and-dirty 'meteor publish' command. It more or less works, but needs lots of cleanup. --- tools/auth.js | 80 +++++++++++++++++++++++++++++++++ tools/commands.js | 97 +++++++++++++++++++++++++++++++++++++++++ tools/config.js | 8 ++++ tools/files.js | 36 ++++++++++++--- tools/help.txt | 4 ++ tools/main.js | 23 +++++++++- tools/package-client.js | 76 ++++++++++++++++++++++++++++++++ 7 files changed, 317 insertions(+), 7 deletions(-) create mode 100644 tools/package-client.js diff --git a/tools/auth.js b/tools/auth.js index 3378fd7c5c..a76efe3a92 100644 --- a/tools/auth.js +++ b/tools/auth.js @@ -408,6 +408,84 @@ var fetchGalaxyOAuthInfo = function (galaxyName, timeout) { } }; +// XXX De-dup with logInToGalaxy +// XXX make args options +var oauthFlow = function (conn, clientId, redirectUri, + domain, sessionType) { + var crypto = require('crypto'); + var credentialToken = crypto.randomBytes(16).toString('hex'); + var authCodeUrl = config.getOauthUrl() + "/authorize?" + + querystring.stringify({ + state: credentialToken, + response_type: "code", + client_id: clientId, + redirect_uri: redirectUri + }); + + // It's very important that we don't have request follow the + // redirect for us, but instead issue the second request ourselves, + // since request would pass our credentials along to the redirected + // URL. See comments in http-helpers.js. + try { + var codeResult = httpHelpers.request({ + url: authCodeUrl, + method: 'POST', + strictSSL: true, + useAuthHeader: true + }); + } catch (e) { + return { error: 'no-account-server' }; + } + var response = codeResult.response; + if (response.statusCode !== 302 || ! response.headers.location) { + return { error: 'access-denied' }; + } + + if (url.parse(response.headers.location).hostname !== + url.parse(redirectUri).hostname) { + // If we didn't get an immediate redirect to the redirectUri then + // presumably the oauth server is trying to interact with us (make + // us log in, authorize the client, or something like that). We're + // not a web browser so we can't participate in such things. + return { error: 'access-denied' }; + } + + try { + var redirectResult = httpHelpers.request({ + url: response.headers.location, + method: 'GET', + strictSSL: true + }); + } catch (e) { + return { error: 'no-package-server' }; + } + + response = redirectResult.response; + // 'access-denied' isn't exactly right because it's possible that the server + // went down since our last request, but close enough. + + if (response.statusCode !== 200) { + return { error: 'access-denied' }; + } + + // XXX tokenId??? + var loginResult = conn.apply('login', [{ + oauth: { credentialToken: credentialToken } + }], { wait: true }); + + if (loginResult.token && loginResult.id) { + var data = readSessionData(); + var session = getSession(data, domain); + ensureSessionType(session, sessionType); + session.token = loginResult.token; + writeSessionData(data); + return 0; + } else { + process.stderr.write('Login failed'); + return 1; + } +}; + // Uses meteor accounts to log in to the specified galaxy. Returns an // object with keys `token` and `tokenId` if the login was // successful. If an error occurred, returns one of: @@ -949,3 +1027,5 @@ exports.loggedInUsername = function () { var data = readSessionData(); return loggedIn(data) ? currentUsername(data) : false; }; + +exports.oauthFlow = oauthFlow; diff --git a/tools/commands.js b/tools/commands.js index 3a003dd6c5..ac46895b78 100644 --- a/tools/commands.js +++ b/tools/commands.js @@ -14,6 +14,9 @@ var config = require('./config.js'); var release = require('./release.js'); var Future = require('fibers/future'); var runLog = require('./run-log.js').runLog; +var packageClient = require('./package-client.js'); +var utils = require('./utils.js'); +var httpHelpers = require('./http-helpers.js'); // Given a site name passed on the command line (eg, 'mysite'), return // a fully-qualified hostname ('mysite.meteor.com'). @@ -1294,6 +1297,100 @@ main.registerCommand({ }); }); +/////////////////////////////////////////////////////////////////////////////// +// publish a package +/////////////////////////////////////////////////////////////////////////////// + +main.registerCommand({ + name: 'publish', + minArgs: 0, + maxArgs: 0, + options: { + versionString: { type: String, short: "v", required: true }, + // XXX A temporary option to create the package, until we sync + // package metadata to the client. + create: { type: Boolean } + }, + requiresPackage: true +}, function (options) { + var pkg = release.current.library.get(path.basename( + options.packageDir + )); + + var name = pkg.name; + var version = options.version; + + var conn = packageClient.loggedInPackagesConnection(); + if (! conn) { + process.stderr.write('Publish failed'); + return 1; + } + + // Create the package. + // XXX First sync package metadata and check if it exists. + if (options.create) { + process.stdout.write('Creating package...\n'); + var packageId = conn.call('createPackage', { + name: pkg.name + }); + } + + process.stdout.write('Creating package version...\n'); + var uploadInfo = conn.call('createPackageVersion', { + packageName: pkg.name, + version: options.versionString, + description: pkg.metadata.summary + }); + + process.stdout.write('Bundling source...\n'); + var tempTarball = path.join(options.packageDir, + 'source-' + utils.randomToken() + '.tgz'); + files.createTarball(options.packageDir, tempTarball, { + ignoreDotFiles: true + }); + + var size = fs.statSync(tempTarball).size; + var crypto = require('crypto'); + var hash = crypto.createHash('sha256'); + hash.setEncoding('base64'); + var rs = fs.createReadStream(tempTarball); + var fut = new Future(); + rs.on('end', function () { + fut.return(hash.digest('base64')); + }); + rs.pipe(hash, { end: false }); + var tarballHash = fut.wait(); + + rs.close(); + rs = fs.createReadStream(tempTarball); + + process.stdout.write('Uploading source...\n'); + httpHelpers.request({ + method: 'PUT', + url: uploadInfo.uploadUrl, + headers: { + 'content-length': size, + 'content-type': 'application/octet-stream', + 'x-amz-acl': 'public-read' + }, + bodyStream: rs + }); + + // XXX Make sure this gets cleaned up even if we throw above + fs.unlinkSync(tempTarball); + + // XXX Upload build tarball + + process.stdout.write('Publishing package version...\n'); + conn.call('publishPackageVersion', uploadInfo.uploadToken, tarballHash); + conn.close(); + process.stdout.write('Published ' + pkg.name + + ', version ' + options.versionString); + + process.stdout.write('\nDone!\n'); + return 0; +}); + /////////////////////////////////////////////////////////////////////////////// // dummy diff --git a/tools/config.js b/tools/config.js index 7d756a1490..82f97d8a2d 100644 --- a/tools/config.js +++ b/tools/config.js @@ -127,6 +127,14 @@ _.extend(exports, { return addScheme(host); }, + getPackageServerDomain: function () { + if (isLocalUniverse()) { + return localhostOffset(20); + } else { + return getUniverse(); + } + }, + // Return the domain name of the current Meteor Accounts server in // use. This is used as a key for storing your Meteor Accounts // login token. diff --git a/tools/files.js b/tools/files.js index 71dd92956e..d6c7b5a1b2 100644 --- a/tools/files.js +++ b/tools/files.js @@ -85,6 +85,18 @@ files.findAppDir = function (filepath) { return findUpwards(isAppDir, filepath); }; +files.findPackageDir = function (filepath) { + var isPackageDir = function (filepath) { + try { + return fs.statSync(path.join(filepath, 'package.js')).isFile(); + } catch (e) { + return false; + } + }; + + return findUpwards(isPackageDir, filepath); +}; + // create a .gitignore file in dirPath if one doesn't exist. add // 'entry' to the .gitignore on its own line at the bottom of the // file, if the exact line does not already exist in the file. @@ -404,17 +416,31 @@ files.extractTarGz = function (buffer, destPath) { // Tar-gzips a directory, returning a stream that can then be piped as // needed. The tar archive will contain a top-level directory named // after dirPath. -files.createTarGzStream = function (dirPath) { +// options: +// - ignoreDotFiles: boolean +files.createTarGzStream = function (dirPath, options) { var tar = require("tar"); var fstream = require('fstream'); var zlib = require("zlib"); - return fstream.Reader({ path: dirPath, type: 'Directory' }).pipe( - tar.Pack()).pipe(zlib.createGzip()); + var filter; + if (options.ignoreDotFiles) { + filter = function () { + return ! this.basename.match(/^\./); + }; + } + var reader = fstream.Reader({ + path: dirPath, + type: 'Directory', + filter: filter + }); + return reader.pipe(tar.Pack()).pipe(zlib.createGzip()); }; // Tar-gzips a directory into a tarball on disk, synchronously. // The tar archive will contain a top-level directory named after dirPath. -files.createTarball = function (dirPath, tarball) { +// options: +// - ignoreDotFiles: boolean +files.createTarball = function (dirPath, tarball, options) { var future = new Future; var out = fs.createWriteStream(tarball); out.on('error', function (err) { @@ -424,7 +450,7 @@ files.createTarball = function (dirPath, tarball) { future.return(); }); - files.createTarGzStream(dirPath).pipe(out); + files.createTarGzStream(dirPath, options).pipe(out); future.wait(); }; diff --git a/tools/help.txt b/tools/help.txt index 43316989ed..58bd4edd5b 100644 --- a/tools/help.txt +++ b/tools/help.txt @@ -387,3 +387,7 @@ Grant a permission on an official service Usage: meteor admin grant [XXX] Not yet implemented + +>>> publish +Publish a package +Usage: meteor publish --versionString diff --git a/tools/main.js b/tools/main.js index 6261287e57..b45c690243 100644 --- a/tools/main.js +++ b/tools/main.js @@ -515,13 +515,16 @@ Fiber(function () { rawArgs.push(term); } - // Figure out if we're running in a directory that is part of a - // Meteor application. Determine any additional directories to + // Figure out if we're running in a directory that is part of a Meteor + // application or package. Determine any additional directories to // search for packages. var appDir = files.findAppDir(); if (appDir) appDir = path.resolve(appDir); + var packageDir = files.findPackageDir(); + if (packageDir) + packageDir = path.resolve(packageDir); var packageDirs = []; if (appDir) @@ -910,6 +913,22 @@ commandName + ": You're not in a Meteor project directory.\n" + process.exit(1); } + // Same check for commands that can only be run from a package dir. + var requiresPackage = command.requiresPackage; + if (typeof requiresPackage === "function") { + requiresPackage = requiresPackage(options); + } + + if (packageDir) { + options.packageDir = packageDir; + } + + if (requiresPackage && ! options.packageDir) { + process.stderr.write( +commandName + ": You're not in a Meteor package directory.\n"); + process.exit(1); + } + if (command.requiresRelease && ! release.current) { process.stderr.write( "You must specify a Meteor version with --release when you work with this\n" + diff --git a/tools/package-client.js b/tools/package-client.js new file mode 100644 index 0000000000..d54e7926f5 --- /dev/null +++ b/tools/package-client.js @@ -0,0 +1,76 @@ +var auth = require('./auth.js'); +var config = require('./config.js'); +var httpHelpers = require('./http-helpers.js'); +var release = require('./release.js'); +var Future = require('fibers/future'); +var _ = require('underscore'); + +var getLoadedPackages = _.once(function () { + var unipackage = require('./unipackage.js'); + return unipackage.load({ + library: release.current.library, + packages: [ 'meteor', 'livedata', 'minimongo', 'mongo-livedata' ], + release: release.current.name + }); +}); + +var openPackageServerConnection = function () { + var DDP = getLoadedPackages().livedata.DDP; + return DDP.connect(config.getPackageServerUrl(), { + headers: { 'User-Agent': httpHelpers.getUserAgent() } + }); +}; + +// XXX onReconnect +exports.loggedInPackagesConnection = function () { + + if (! auth.isLoggedIn()) { + auth.doUsernamePasswordLogin({ retry: true }); + } + + var conn = openPackageServerConnection(); + var serviceConfigurations = new (getLoadedPackages()['meteor']. + Meteor.Collection)('meteor_accounts_loginServiceConfiguration', { + connection: conn + }); + var fut = new Future(); + var serviceConfigurationsSub = conn.subscribe( + 'meteor.loginServiceConfiguration', + fut.resolver() + ); + fut.wait(); + + var accountsConfiguration = serviceConfigurations.findOne({ + service: 'meteor-developer' + }); + + if (! accountsConfiguration) { + return null; + } + + var clientId = accountsConfiguration.clientId; + var loginResult; + + if (! auth.getSessionToken(config.getPackageServerDomain())) { + // Since we passed retry: true, we shouldn't ever get to this point + // unless we are now logged in with the accounts server. + var redirectUri = config.getPackageServerUrl() + + '/_oauth/meteor-developer?close'; + loginResult = auth.oauthFlow(conn, clientId, redirectUri, + config.getPackageServerDomain(), + 'package-server'); + if (! loginResult) { + conn.close(); + return null; + } + } else { + loginResult = conn.apply('login', [{ + resume: auth.getSessionToken(config.getPackageServerDomain()) + }], { wait: true }); + if (! loginResult || ! loginResult.token || ! loginResult.id) { + conn.close(); + return null; + } + } + return conn; +};