Files
meteor/tools/meteor-services/deploy.js
2022-11-03 17:42:40 +01:00

993 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
}