meteor/meteor

View on GitHub
tools/meteor-services/deploy.js

Summary

Maintainability
F
6 days
Test Coverage
// URL parsing and validation
// RPC to server (endpoint, arguments)
// see if RPC requires password
// prompt for password
// send RPC with or without password as required

import {
  pathJoin,
  createTarGzStream,
  getSettings,
  mkdtemp,
  changeTempDirStatus,
  exists,
  findGitCommitHash,
} from '../fs/files';
import { request } from '../utils/http-helpers.js';
import buildmessage from '../utils/buildmessage.js';
import {
  pollForRegistrationCompletion,
  doInteractivePasswordLogin,
  loggedInUsername,
  isLoggedIn,
} from './auth.js';
import { recordPackages } from './stats.js';
import { Console } from '../console/console.js';
import { Profile } from '../tool-env/profile';

function sleepForMilliseconds(millisecondsToWait) {
  return new Promise(function(resolve) {
    let time = setTimeout(() => resolve(null), millisecondsToWait)
  });
}

const hasOwn = Object.prototype.hasOwnProperty;

const CAPABILITIES = ['showDeployMessages', 'canTransferAuthorization'];

// Make a synchronous RPC to the "classic" Meteor Software deploy API. The
// deploy API has the following contract:
//
// - Parameters are always sent in the query string.
// - A tarball can be sent in the body (when deploying an app).
// - On success, all calls return HTTP 200. Those that return a value
//   either return a JSON payload or a plaintext payload and the
//   Content-Type header is set appropriately.
// - On failure, calls return some non-200 HTTP status code and
//   provide a human-readable error message in the body.
// - URLs are of the form "/[operation]/[site]".
// - Body encodings are always utf8.
// - Meteor Accounts auth is possible using first-party Meteor Software cookies
//   (rather than OAuth).
//
// Options include:
// - method: GET, POST, or DELETE. default GET
// - operation: "info", "logs", "mongo", "deploy", "authorized-apps",
//   "version-status"
// - site: site name. Pass this even if the site isn't part of the URL
//   so that Galaxy discovery works properly
// - operand: the part of the URL after the operation. If not set,
//   defaults to site
// - expectPayload: an array of key names. if present, then we expect
//   the server to return JSON content on success and to return an
//   object with all of these key names.
// - expectMessage: if true, then we expect the server to return text
//   content on success.
// - bodyStream: if provided, a stream to use as the request body
// - any other parameters accepted by the node 'request' module, for example
//   'qs' to set query string parameters
// - printDeployURL: provided if we should show the deploy URL; set this
//   for the first RPC of any user command
//
// Waits until server responds, then returns an object with the
// following keys:
//
// - statusCode: HTTP status code, or null if the server couldn't be
//   contacted
// - payload: if successful, and the server returned a JSON body, the
//   parsed JSON body
// - message: if successful, and the server returned a text body, the
//   body as a string
// - errorMessage: if unsuccessful, a human-readable error message,
//   derived from either a transport-level exception, the response
//   body, or a generic 'try again later' message, as appropriate

function deployRpc(options) {
  options = Object.assign({}, options);
  options.headers = Object.assign({}, options.headers || {});
  if (options.headers.cookie) {
    throw new Error("sorry, can't combine cookie headers yet");
  }
  options.qs = Object.assign(
    {},
    options.qs,
    { capabilities: CAPABILITIES.slice() },
    options.deployWithTokenProps || {}
  );
  // If we are waiting for deploy, we let Galaxy know so it can
  // use that information to send us the right deploy message response.
  if (options.waitForDeploy) {
    options.qs.capabilities.push('willPollVersionStatus');
  }

  const deployURLBase = getDeployURL(options.site).await();

  if (options.printDeployURL) {
    Console.info("Talking to Galaxy servers at " + deployURLBase);
  }

  let operand = '';
  if (options.operand) {
    operand = `/${options.operand}`;
  } else if (options.site) {
    operand = `/${options.site}`;
  }

  // XXX: Reintroduce progress for upload
  try {
    var result = request(Object.assign(options, {
      url: deployURLBase + '/' + options.operation +
        operand,
      method: options.method || 'GET',
      bodyStream: options.bodyStream,
      useAuthHeader: true,
      encoding: 'utf8' // Hack, but good enough for the deploy server..
    }));
  } catch (e) {
    return {
      statusCode: null,
      errorMessage: "Connection error (" + e.message + ")"
    };
  }

  var response = result.response;
  var body = result.body;
  var ret = { statusCode: response.statusCode };

  if (response.statusCode !== 200) {
    if (body.length > 0) {
      ret.errorMessage = body;
    } else {
      ret.errorMessage = "Server error " + response.statusCode +
        " (please try again later)";
    }
    return ret;
  }

  var contentType = response.headers["content-type"] || '';
  if (contentType === "application/json; charset=utf-8") {
    try {
      ret.payload = JSON.parse(body);
    } catch (e) {
      ret.errorMessage =
        "Server error (please try again later)\n"
        + "Invalid JSON: " + body;
      return ret;
    }
  } else if (contentType === "text/plain; charset=utf-8") {
    ret.message = body;
  }

  const hasAllExpectedKeys =
    (options.expectPayload || [])
      .map(key => ret.payload && hasOwn.call(ret.payload, key))
      .every(x => x);

  if ((options.expectPayload && ! hasOwn.call(ret, 'payload')) ||
      (options.expectMessage && ! hasOwn.call(ret, 'message')) ||
      ! hasAllExpectedKeys) {
    delete ret.payload;
    delete ret.message;

    ret.errorMessage = "Server error (please try again later)\n" +
      "Response missing expected keys.";
  }

  return ret;
};

// Just like deployRpc, but also presents authentication. It will
// prompt the user for a password, or use a Meteor Accounts
// credential, as necessary.
//
// Additional options (beyond deployRpc):
//
// - preflight: if true, do everything but the actual RPC. The only
//   other necessary option is 'site'. On failure, returns an object
//   with errorMessage (just like deployRpc). On success, returns an
//   object without an errorMessage key and with possible keys
//   'protection' (value either 'password' or 'account') and
//   'authorized' (true if the current user is an authorized user on
//   this app).
// - promptIfAuthFails: if true, then we think we are logged in with the
//   accounts server but our authentication actually fails, then prompt
//   the user to log in with a username and password and then resend the
//   RPC.
function authedRpc(options) {
  var rpcOptions = Object.assign({}, options);
  var preflight = rpcOptions.preflight;
  delete rpcOptions.preflight;

  // Fetch auth info
  var infoResult = deployRpc({
    operation: 'info',
    site: rpcOptions.site,
    expectPayload: [],
    qs: options.qs,
    printDeployURL: options.printDeployURL,
    waitForDeploy: options.waitForDeploy,
  });
  delete rpcOptions.printDeployURL;

  if (infoResult.statusCode === 401 && rpcOptions.promptIfAuthFails) {
    Console.error("Authentication failed or login token expired.");

    if (!Console.isInteractive()) {
      return {
        statusCode: 401,
        errorMessage: "login failed."
      };
    }

    // Our authentication didn't validate, so prompt the user to log in
    // again, and resend the RPC if the login succeeds.
    var username = Console.readLine({
      prompt: "Username: ",
      stream: process.stderr
    });
    var loginOptions = {
      username: username,
      suppressErrorMessage: true
    };
    if (doInteractivePasswordLogin(loginOptions)) {
      return authedRpc(options);
    } else {
      return {
        statusCode: 403,
        errorMessage: "login failed."
      };
    }
  }

  if (infoResult.statusCode === 404) {
    // Doesn't exist, therefore not protected.
    return preflight ? { } : deployRpc(rpcOptions);
  }

  if (infoResult.errorMessage) {
    return infoResult;
  }
  var info = infoResult.payload;

  if (! hasOwn.call(info, 'protection')) {
    // Not protected.
    //
    // XXX should prompt the user to claim the app (only if deploying?)
    return preflight ? { } : deployRpc(rpcOptions);
  }

  if (info.protection === "account") {
    if (! hasOwn.call(info, 'authorized')) {
      // Absence of this implies that we are not an authorized user on
      // this app
      if (preflight) {
        return { protection: info.protection };
      } else {
        return {
          statusCode: null,
          errorMessage: isLoggedIn() ?
            // XXX better error message (probably need to break out of
            // the 'errorMessage printed with brief prefix' pattern)
            "Not an authorized user on this site" :
            "Not logged in"
        };
      }
    }

    // Sweet, we're an authorized user.
    if (preflight) {
      return {
        protection: info.protection,
        authorized: info.authorized
      };
    } else {
      return deployRpc(rpcOptions);
    }
  }

  return {
    statusCode: null,
    errorMessage: "You need a newer version of Meteor to work with this site"
  };
};

// When the user is trying to do something with an app that they are not
// authorized for, instruct them to get added via 'meteor authorized
// --add' or switch accounts.
function printUnauthorizedMessage() {
  var username = loggedInUsername();
  Console.error("Sorry, that site belongs to a different user.");
  if (username) {
    Console.error("You are currently logged in as " + username + ".");
  }
  Console.error();
  Console.error(
    "Either have the site owner use " +
    Console.command("'meteor authorized --add'") + " to add you as an " +
    "authorized developer for the site, or switch to an authorized account " +
    "with " + Console.command("'meteor login'") + ".");
};

// Take a proposed sitename for deploying to. If it looks
// syntactically good, canonicalize it (this essentially means
// stripping 'http://' or a trailing '/' if present) and return it. If
// not, print an error message to stderr and return null.
function canonicalizeSite(site) {
  // There are actually two different bugs here. One is that the meteor deploy
  // server does not support apps whose total site length is greater than 63
  // (because of how it generates Mongo database names); that can be fixed on
  // the server. After that, this check will be too strong, but we still will
  // want to check that each *component* of the hostname is at most 63
  // characters (url.parse will do something very strange if a component is
  // larger than 63, which is the maximum legal length).
  if (site.length > 63) {
    Console.error(
      "The maximum hostname length currently supported is 63 characters: " +
      site + " is too long. " +
      "Please try again with a shorter URL for your site.");
    return false;
  }

  var url = site;
  if (!url.match(':\/\/')) {
    url = 'http://' + url;
  }

  var parsed = require('url').parse(url);

  if (! parsed.hostname) {
    Console.info(
      "Please specify a domain to connect to, such as www.example.com or " +
      "http://www.example.com/");
    return false;
  }

  if (parsed.pathname != '/' || parsed.hash || parsed.query) {
    Console.info(
      "Sorry, Meteor does not yet support specific path URLs, such as " +
      Console.url("http://www.example.com/blog") + " .  Please specify the root of a domain.");
    return false;
  }

  return parsed.hostname;
};

// Executes the poll to check for deployment success and outputs proper messages
// to user about the status of their app during the polling process
async function pollForDeploymentSuccess(versionId, deployPollTimeout, result, site, deployWithTokenProps) {
  // Create a default polling configuration for polling for deploy / build
  // In the future, we may change this to be user-configurable or smart
  // The user can only currently configure the polling timeout via a flag
  const pollingState = new PollingState(deployPollTimeout);
  await sleepForMilliseconds(pollingState.initialWaitTimeMs);
  const deploymentPollResult = await pollForDeploy(pollingState, versionId, site, deployWithTokenProps);
  if (deploymentPollResult && deploymentPollResult.isActive) {
    return 0;
  }
  return 1;
}

// Creates a polling configuration with defaults if fields left unset
// Right now we only use the default unless timeout is specified
// We envision potentially creating this configuration object in a programmatic
// way or via user-specification in the future.
// Default initialWaitTime is 10 seconds – this is the time to wait before checking at all
// Default pollInterval is 700 milliseconds – this is the wait interval between polls
// Default timeout is 15 minutes
// `start` tracks the time when we started polling
// `currentMessage` tracks what the current status message is for this version
class PollingState {
  constructor(timeoutMs,
    initialWaitTimeMs,
    pollIntervalMs,
    maxErrors) {
      const FIFTEEN_MINUTES_MS = 15*60*1000;
      const MAX_ERRORS = 5;
      this.initialWaitTimeMs = initialWaitTimeMs || 10*1000;
      this.pollIntervalMs = pollIntervalMs || 700;
      this.deadline = timeoutMs ? new Date(new Date().getTime() + timeoutMs) :
        new Date(new Date().getTime() + FIFTEEN_MINUTES_MS);
      this.start = new Date();
      this.currentMessage = '';
      this.errors = 0;
      this.maxErrors = maxErrors || MAX_ERRORS;
  }
}

// Poll the "version-status" endpoint for the build and deploy status
// of a specified version ID with a polling configuration.
// This will only end successfully when the polling endpoint reports that
// the version deployment is finished. The version-status endpoints will report
// messages pertaining to the status of the version, which will then be reported
// directly to the user. When the poll is complete, it will return an object
// with information about the final state of the version and the app.
async function pollForDeploy(pollingState, versionId, site, deployWithTokenProps) {
  const {
    deadline,
    pollIntervalMs,
    currentMessage,
  } = pollingState;

  // Do a call to the version-status endpoint for the specified versionId
  const versionStatusResult = deployRpc({
    method: 'GET',
    operation: 'version-status',
    site,
    operand: versionId,
    expectPayload: ['message', 'finishStatus'],
    printDeployURL: false,
    deployWithTokenProps
  });

  // Check the details of the Version Status response and compare message to last call
  if (versionStatusResult &&
    versionStatusResult.payload &&
    versionStatusResult.payload.message) {
      const message = versionStatusResult.payload.message;
      if (currentMessage !== message) {
        Console.info(message);
        pollingState.currentMessage = message;
      }
  } else {
    // If we did not get a valid Version Status response, just fail silently and
    // keep polling as per usual – this may have just been a whiff from Galaxy.
    // We do the retry here because we might hit an error if we try to parse the
    // result of the version-status call below.
    pollingState.errors++;
    const errorMessage = versionStatusResult.errorMessage || 'Unexpected error from Galaxy';
    if (pollingState.errors >= pollingState.maxErrors) {
      Console.error(`Error checking deploy status; giving up: ${errorMessage}`);
      return 1;
    } else if (new Date() < deadline) {
      Console.warn(`Error checking deploy status; will retry: ${errorMessage}`);
      await sleepForMilliseconds(pollIntervalMs);
      return await pollForDeploy(pollingState, versionId, site, deployWithTokenProps);
    }
  }

  const finishStatus = versionStatusResult.payload.finishStatus;
  // Poll again if version isn't finished and we haven't exceeded the timeout
  if(new Date() < deadline && !finishStatus.isFinished) {
    // Wait for a set interval and then poll again
    await sleepForMilliseconds(pollIntervalMs);
    return await pollForDeploy(pollingState, versionId, site, deployWithTokenProps);
  } else if (!finishStatus.isFinished) {
    Console.info(`Polling timed out. To check the status of your app, visit
    ${versionStatusResult.payload.galaxyUrl}. To wait longer, pass a timeout
    in milliseconds to the '--deploy-polling-timeout' option of 'meteor deploy'.`);
  }
  return finishStatus;
}


// Run the bundler and deploy the result. Print progress
// messages. Return a command exit code.
//
// Options:
// - projectContext: the ProjectContext for the app
// - site: site to deploy as
// - settingsFile: file from which to read deploy settings (undefined
//   to leave unchanged from previous deploy of the app, if any)
// - recordPackageUsage: (defaults to true) if set to false, don't
//   send information about packages used by this app to the package
//   stats server.
// - buildOptions: the 'buildOptions' argument to the bundler
// - rawOptions: any unknown options that were passed to the command line tool
// - waitForDeploy: whether to poll Galaxy after upload for deploy status
// - isCacheBuildEnabled: Reuses the build already created if the git commit
//   hash is the same
// - deployPollingTimeoutMs: user overridden timeout for polling Galaxy
//   for deploy status
export async function bundleAndDeploy(options) {
  if (options.recordPackageUsage === undefined) {
    options.recordPackageUsage = true;
  }

  // we don't need site for build-only
  let site = null;
  let preflightPassword = null;

  if (options.isBuildOnly) {
    Console.info('Skipping pre authentication as the option --build-only was provided.');
  } else {
    site = options.site && canonicalizeSite(options.site)
    if (! site) {
      Console.error("Error deploying application: site is required.");
      Console.error("Your deploy command should be like: meteor deploy <site>");
      Console.error(
        "For more help, see " + Console.command("'meteor deploy --help'") + ".");
      return 1;
    }

    // We should give a username/password prompt if the user was logged in
    // but the credentials are expired, unless the user is logged in but
    // doesn't have a username (in which case they should hit the email
    // prompt -- a user without a username shouldn't be given a username
    // prompt). There's an edge case where things happen in the following
    // order: user creates account, user sets username, credential expires
    // or is revoked, user comes back to deploy again. In that case,
    // they'll get an email prompt instead of a username prompt because
    // the command-line tool didn't have time to learn about their
    // username before the credential was expired.
    pollForRegistrationCompletion({
      noLogout: true
    });
    const promptIfAuthFails = (loggedInUsername() !== null);

    // Check auth up front, rather than after the (potentially lengthy)
    // bundling process.
    const preflight = authedRpc({
      site: site,
      preflight: true,
      promptIfAuthFails: promptIfAuthFails,
      qs: Object.assign(
        {},
        options.rawOptions,
        {
          deployToken: options.deployToken,
          owner: options.owner,
        }
      ),
      printDeployURL: true
    });

    if (preflight.errorMessage) {
      Console.error("Error deploying application: " + preflight.errorMessage);
      return 1;
    }

    if (preflight.protection === "account" &&
      !preflight.authorized) {
      printUnauthorizedMessage();
      return 1;
    }

    preflightPassword = preflight.preflightPassword;
  }

  const projectDir = options.projectContext.getProjectLocalDirectory('');
  const gitCommitHash = process.env.METEOR_GIT_COMMIT_HASH || findGitCommitHash(projectDir);

  const buildCache = options.projectContext.getBuildCache();
  let isCacheBuildValid = options.isCacheBuildEnabled;
  if (options.isCacheBuildEnabled) {
    if (!buildCache ||
      !exists(buildCache.buildDir) ||
      !exists(buildCache.bundlePath) ||
      !buildCache.gitCommitHash ||
      !gitCommitHash ||
      buildCache.gitCommitHash !== gitCommitHash) {
      Console.warn(`We don't have a valid build cache so a new build will be performed.`);
      isCacheBuildValid = false;
    }
  }

  function getBuildDirAndBundlePath() {
    if (isCacheBuildValid) {
      return buildCache;
    }

    const buildDir = mkdtemp('build_tar');
    if (options.isCacheBuildEnabled) {
      changeTempDirStatus(buildDir, false);
      Console.info(`The --cache-build was used so the build folder (${buildDir}) will not be deleted on exit...`);
    }
    const bundlePath = pathJoin(buildDir, 'bundle');
    return { buildDir, bundlePath };
  }

  const {buildDir, bundlePath} = getBuildDirAndBundlePath();

  if (options.isCacheBuildEnabled) {
    Console.info('Saving build in cache (--cache-build)...');
    options.projectContext.saveBuildCache({
      buildDir,
      bundlePath,
      gitCommitHash
    });
  }

  Console.info('Preparing to build your app...');

  var settings = null;
  var messages = buildmessage.capture({
    title: "preparing to deploy",
    rootPath: process.cwd()
  }, function () {
    if (options.settingsFile) {
      settings = getSettings(options.settingsFile);
    }
  });

  if (! messages.hasMessages()) {

    if(isCacheBuildValid) {
      Console.info('Skipping build (--cache-build)...');
    } else {
      const bundler = require('../isobuild/bundler.js');

      const bundleResult = bundler.bundle({
        projectContext: options.projectContext,
        outputPath: bundlePath,
        buildOptions: options.buildOptions,
      });

      if (bundleResult.errors) {
        messages = bundleResult.errors;
      }
    }
  }

  if (messages.hasMessages()) {
    Console.info("\nErrors prevented deploying:");
    Console.info(messages.formatMessages());
    return 1;
  }

  if (options.recordPackageUsage) {
    recordPackages({
      what: "sdk.deploy",
      projectContext: options.projectContext,
      site: site
    });
  }

  if (options.isBuildOnly) {
    Console.info(
      '\nYour build is ready. As you used the option --build-only the process finished after the build.'
    );
    return 0;
  }

  const deployWithTokenProps = {
    deployToken: options.deployToken,
    owner: options.owner
  };

  Console.info('Preparing to upload your app...');
  const result = buildmessage.enterJob({
    title: "uploading"
  }, Profile("upload bundle", function () {
    return authedRpc({
      method: 'POST',
      operation: 'deploy',
      site: site,
      qs: Object.assign(
        {},
        options.rawOptions,
        settings !== null ? {settings: settings} : {},
        {
          free: options.free,
          plan: options.plan,
          containerSize: options.containerSize,
          mongo: options.mongo,
          ...deployWithTokenProps,
        },
      ),
      bodyStream: createTarGzStream(pathJoin(buildDir, 'bundle')),
      expectPayload: ['url'],
      preflightPassword,
      // Disable the HTTP timeout for this POST request.
      timeout: null,
      waitForDeploy: options.waitForDeploy,
    });
  }));

  if (result.errorMessage) {
    Console.error("\nError deploying application: " + result.errorMessage);
    return 1;
  }

  // This will allow Galaxy to report messages to users ad-hoc
  // Also if we are using the --no-wait flag, this will contain the message
  // that Galaxy used to send after upload success.
  if (result.payload.message) {
    Console.info(result.payload.message);
  }

  // After an upload succeeds, we want to poll Galaxy to see if the
  // build / deploy succeed. We indicate that Meteor should poll for version
  // status by including a newVersionId in the payload.
  if (options.waitForDeploy && result.payload.newVersionId) {
    Console.info('Waiting for deployment updates from Galaxy...');
    return await pollForDeploymentSuccess(
      result.payload.newVersionId,
      options.deployPollingTimeoutMs,
      result,
      site,
      deployWithTokenProps,
    );
  }
  return 0;
};

export function deleteApp(site) {
  site = canonicalizeSite(site);
  if (! site) {
    return 1;
  }

  var result = authedRpc({
    method: 'DELETE',
    operation: 'deploy',
    site: site,
    promptIfAuthFails: true,
    printDeployURL: true
  });

  if (result.errorMessage) {
    Console.error("Couldn't delete application: " + result.errorMessage);
    return 1;
  }

  Console.info("Deleted.");
  return 0;
};

// Helper that does a preflight request to check auth, and prints the
// appropriate error message if auth fails or if this is a legacy
// password-protected app. If auth succeeds, then it runs the actual
// RPC. 'site' and 'operation' are the site and operation for the
// RPC. 'what' is a string describing the operation, for use in error
// messages.  Returns the result of the RPC if successful, or null
// otherwise (including if auth failed or if the user is not authorized
// for this site).
function checkAuthThenSendRpc(site, operation, what) {
  var preflight = authedRpc({
    operation: operation,
    site: site,
    preflight: true,
    promptIfAuthFails: true,
    printDeployURL: true
  });

  if (preflight.errorMessage) {
    Console.error("Couldn't " + what + ": " + preflight.errorMessage);
    return null;
  }

  if (preflight.protection === "account" &&
             ! preflight.authorized) {
    if (! isLoggedIn()) {
      // Maybe the user is authorized for this app but not logged in
      // yet, so give them a login prompt.
      var loginResult = doUsernamePasswordLogin({ retry: true });
      if (loginResult) {
        // Once we've logged in, retry the whole operation. We need to
        // do the preflight request again instead of immediately moving
        // on to the real RPC because we don't yet know if the newly
        // logged-in user is authorized for this app, and if they
        // aren't, then we want to print the nice unauthorized error
        // message.
        return checkAuthThenSendRpc(site, operation, what);
      } else {
        // Shouldn't ever get here because we set the retry flag on the
        // login, but just in case.
        Console.error(
          "\nYou must be logged in to " + what + " for this app. Use " +
           Console.command("'meteor login'") + "to log in.");
        Console.error();
        Console.error(
          "If you don't have a Meteor developer account yet, you can quickly " +
          "create one at www.meteor.com.");
        return null;
      }
    } else { // User is logged in but not authorized for this app
      Console.error();
      printUnauthorizedMessage();
      return null;
    }
  }

  // User is authorized for the app; go ahead and do the actual RPC.

  var result = authedRpc({
    operation: operation,
    site: site,
    expectMessage: true,
    promptIfAuthFails: true
  });

  if (result.errorMessage) {
    Console.error("Couldn't " + what + ": " + result.errorMessage);
    return null;
  }

  return result;
};

// On failure, prints a message to stderr and returns null. Otherwise,
// returns a temporary authenticated Mongo URL allowing access to this
// site's database.
export function temporaryMongoUrl(site) {
  site = canonicalizeSite(site);
  if (! site) {
    // canonicalizeSite printed an error
    return null;
  }

  var result = checkAuthThenSendRpc(site, 'mongo', 'open a mongo connection');

  if (result !== null) {
    return result.message;
  } else {
    return null;
  }
};

export function listAuthorized(site) {
  site = canonicalizeSite(site);
  if (! site) {
    return 1;
  }

  var result = deployRpc({
    operation: 'info',
    site: site,
    expectPayload: [],
    printDeployURL: true
  });
  if (result.errorMessage) {
    Console.error("Couldn't get authorized users list: " + result.errorMessage);
    return 1;
  }
  var info = result.payload;

  if (! hasOwn.call(info, 'protection')) {
    Console.info("<anyone>");
    return 0;
  }

  if (info.protection === "account") {
    if (! hasOwn.call(info, 'authorized')) {
      Console.error("Couldn't get authorized users list: " +
                    "You are not authorized");
      return 1;
    }

    Console.info((loggedInUsername() || "<you>"));
    info.authorized.forEach(username => {
      if (username) {
        // Current username rules don't let you register anything that we might
        // want to split over multiple lines (ex: containing a space), but we
        // don't want confusion if we ever change some implementation detail.
        Console.rawInfo(username + "\n");
      }
    });
    return 0;
  }
};

// action is "add", "transfer" or "remove"
export function changeAuthorized(site, action, username) {
  site = canonicalizeSite(site);
  if (! site) {
    // canonicalizeSite will have already printed an error
    return 1;
  }

  var result = authedRpc({
    method: 'POST',
    operation: 'authorized',
    site: site,
    qs: {[action]: username},
    promptIfAuthFails: true,
    printDeployURL: true
  });

  if (result.errorMessage) {
    Console.error("Couldn't change authorized users: " + result.errorMessage);
    return 1;
  }

  const verbs = {
    add: "added to",
    remove: "removed from",
    transfer: "transferred to"
  };
  Console.info(`${site}: ${verbs[action]} ${username}`);
  return 0;
};

export function listSites() {
  var result = deployRpc({
    method: "GET",
    operation: "authorized-apps",
    promptIfAuthFails: true,
    expectPayload: ["sites"]
  });

  if (result.errorMessage) {
    Console.error("Couldn't list sites: " + result.errorMessage);
    return 1;
  }

  if (! result.payload ||
      ! result.payload.sites ||
      ! result.payload.sites.length) {
    Console.info("You don't have any sites yet.");
  } else {
    result.payload.sites
      .sort()
      .forEach(site => Console.info(site));
  }
  return 0;
};

// Given a hostname, add "http://" or "https://" as
// appropriate. (localhost gets http; anything else is always https.)
function addScheme(hostOrURL) {
  if (hostOrURL.match(/^http/)) {
    return hostOrURL;
  } else if (hostOrURL.match(/^localhost(:\d+)?$/)) {
    return "http://" + hostOrURL;
  } else {
    return "https://" + hostOrURL;
  }
};

// Maps from "site" to Promise<deploy URL>, so we don't have to re-ping on each
// RPC (even if the calls to getDeployURL overlap).
const galaxyDiscoveryCache = new Map;

// getDeployURL returns the a Promise for the base deploy URL for the given app.
// "app" may be falsey for certain RPCs (eg meteor list-sites).
function getDeployURL(site) {
  // Always trust explicitly configuration via env.
  if (process.env.DEPLOY_HOSTNAME) {
    return Promise.resolve(addScheme(process.env.DEPLOY_HOSTNAME.trim()));
  }

  const defaultURL = "https://us-east-1.galaxy-deploy.meteor.com";

  // No site? Just use the default.
  if (!site) {
    return Promise.resolve(defaultURL);
  }

  // If we have a site, we can try to do Galaxy discovery.

  // Do we already have an answer?
  if (galaxyDiscoveryCache.has(site)) {
    return galaxyDiscoveryCache.get(site);
  }

  // Otherwise, try https first, then http, then just use the default.
  const p = discoverGalaxy(site, "https")
          .catch(() => discoverGalaxy(site, "http"))
          .catch(() => defaultURL);
  galaxyDiscoveryCache.set(site, p);
  return p;
}

// discoverGalaxy returns the URL to use for Galaxy discovery, or an error if it
// couldn't be fetched.
async function discoverGalaxy(site, scheme) {
  const discoveryURL =
          scheme + "://" + site + "/.well-known/meteor/deploy-url";
  // If httpHelpers.request throws, the returned Promise will reject, which is
  // fine.
  const { response, body } = request({
    url: discoveryURL,
    json: true,
    strictSSL: true,
    // We don't want to be confused by, eg, a non-Galaxy-hosted site which
    // redirects to a Galaxy-hosted site.
    followRedirect: false
  });
  if (response.statusCode !== 200) {
    throw new Error("bad status code: " + response.statusCode);
  }
  if (!body) {
    throw new Error("response had no body");
  }
  if (body.galaxyDiscoveryVersion !== "galaxy-1") {
    throw new Error(
      "unexpected galaxyDiscoveryVersion: " + body.galaxyDiscoveryVersion);
  }
  if (! hasOwn.call(body, "deployURL")) {
    throw new Error("no deployURL");
  }
  return body.deployURL;
}