@controlplane/cli
Version:
Control Plane Corporation CLI
207 lines • 9.67 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.RemoteGitConnect = void 0;
const browser_1 = require("../../util/browser");
const time_1 = require("../../util/time");
const config_1 = require("./config");
const errors_1 = require("./errors");
// ANCHOR - Constants
// An SSH remote the way `git remote -v` prints it: user@host:path.
const SSH_REMOTE_PATTERN = /^[\w.-]+@[\w.-]+:/;
// URL parsing strips these, so they must be rejected before they reach the build service.
const CONTROL_CHARACTERS = /[\u0000-\u0020\u007f]/;
// ANCHOR - RemoteGitConnect
/**
* Validates repo sources and ensures the org's git provider is connected before a
* repo build. It asks the build service once whether the repo can be built as-is;
* only when a connection is needed and missing does it run the one-time connect flow
* — opening the provider's install page and polling until the connection binds. A
* browser that fails to open is not fatal: the URL is already on screen and polling
* continues.
*/
class RemoteGitConnect {
constructor(service, events, deps = {}) {
var _a, _b, _c, _d;
this.service = service;
this.events = events;
this.open = (_a = deps.open) !== null && _a !== void 0 ? _a : browser_1.openInBrowser;
this.sleep = (_b = deps.sleep) !== null && _b !== void 0 ? _b : time_1.sleep;
this.now = (_c = deps.now) !== null && _c !== void 0 ? _c : Date.now;
this.isInteractive = (_d = deps.isInteractive) !== null && _d !== void 0 ? _d : sessionIsInteractive;
}
// Public Methods //
/**
* Rejects repository URLs the build service cannot clone: SSH remotes (with the
* https rewrite named), unparseable or non-https URLs, URLs embedding credentials,
* and unsupported providers. Returns the canonical form, so the service receives
* the URL this check actually validated.
*
* @param {string} repoUrl - The repository URL as the user passed it.
* @returns {string} The canonical https URL to build.
*/
normalizeRepoUrl(repoUrl) {
if (SSH_REMOTE_PATTERN.test(repoUrl)) {
throw new errors_1.RemoteBuildError('context', `"${repoUrl}" is an SSH remote`, 'Use the https:// form instead, e.g. https://github.com/org/repo.');
}
// URL parsing would silently strip these, leaving the service a different URL.
if (CONTROL_CHARACTERS.test(repoUrl)) {
throw new errors_1.RemoteBuildError('context', 'the repository URL contains whitespace or control characters', 'Pass the https:// URL of the repository, e.g. https://github.com/org/repo.');
}
let url;
try {
url = new URL(repoUrl);
}
catch (_a) {
throw new errors_1.RemoteBuildError('context', `"${repoUrl}" is not a valid repository URL`, 'Pass the https:// URL of the repository.');
}
if (url.protocol !== 'https:') {
throw new errors_1.RemoteBuildError('context', `"${repoUrl}" is not an https:// URL`, 'Pass the https:// URL of the repository, e.g. https://github.com/org/repo.');
}
// Credentials in the URL would travel to the build service and its logs.
if (url.username !== '' || url.password !== '') {
throw new errors_1.RemoteBuildError('context', 'the repository URL embeds credentials', "Remove them and re-run the build; private repositories authenticate through the org's git connection.");
}
const host = url.hostname.toLowerCase();
if (config_1.SUPPORTED_GIT_HOSTS.includes(host)) {
return url.href;
}
const provider = gitProviderLabel(host);
throw new errors_1.RemoteBuildError('context', `${provider} is not a supported git provider. "cpln image build --remote --repo" supports GitHub and GitLab`, `To request support for ${provider}, email ${config_1.SUPPORT_EMAIL}.`);
}
/**
* Ensures the org's provider is connected for the repo. Asks the build service
* once; if the repo can be built as-is (a public repo, or an already-connected
* provider) it returns immediately, otherwise it runs the connect flow and resolves
* only after the connection binds.
*
* @param {string} repoUrl - The repository being built; its host routes the lookup.
* @param {string} org - The org the connection binds to.
* @returns {Promise<void>} Resolves once the repo can be built.
*/
async ensureConnected(repoUrl, org) {
let status;
const host = new URL(repoUrl).hostname;
try {
status = await this.service.getConnection({ host, org, repoUrl });
}
catch (e) {
throw (0, errors_1.requestFailure)(e, 'check the provider connection', org);
}
if (status.connected || !status.pending) {
return;
}
await this.runConnectFlow(status.pending, repoUrl, org);
}
// Private Methods //
/**
* Opens the provider's authorize page and waits for the connection to bind. A
* non-interactive session fails with the connect URL instead of opening a browser.
*
* @param {NotConnectedPayload} pending - The provider, connect URL, and poll nonce.
* @param {string} repoUrl - The repository being built; its host routes the poll.
* @param {string} org - The org the connection binds to.
* @returns {Promise<void>} Resolves once the connection is bound.
*/
async runConnectFlow(pending, repoUrl, org) {
assertHttpsConnectUrl(pending.connectUrl);
if (!this.isInteractive()) {
throw new errors_1.RemoteBuildError('request-failed', `${pending.provider} is not connected for org "${org}"`, `Open ${pending.connectUrl} in a browser to connect it, then re-run the build. The link is single-use and expires shortly, so do not share it.`);
}
this.events.onConnectStarted(pending.provider, org, pending.connectUrl);
try {
await this.open(pending.connectUrl);
}
catch (_a) {
// The URL is already on screen; the user can open it themselves while we poll.
}
await this.waitUntilBound(pending, repoUrl, org);
}
/**
* Polls the connection status until it binds, the credentials fail, or the
* deadline passes. Transient poll failures resolve once the callback lands.
*
* @param {NotConnectedPayload} pending - The provider, connect URL, and poll nonce.
* @param {string} repoUrl - The repository being built; its host routes the poll.
* @param {string} org - The org the connection binds to.
* @returns {Promise<void>} Resolves once the connection is bound.
*/
async waitUntilBound(pending, repoUrl, org) {
const host = new URL(repoUrl).hostname;
const deadline = this.now() + config_1.CONNECT_TIMEOUT_MS;
while (true) {
await this.sleep(config_1.CONNECT_POLL_MS);
try {
const status = await this.service.getConnection({ host, nonce: pending.nonce, org, repoUrl });
if (status.connected) {
this.events.onConnected();
return;
}
}
catch (e) {
// An auth failure will not heal by polling; anything else resolves once the callback lands.
const status = (0, errors_1.httpStatus)(e);
if (status === 401 || status === 403) {
throw (0, errors_1.requestFailure)(e, 'check the provider connection', org);
}
}
if (this.now() > deadline) {
throw new errors_1.RemoteBuildError('timeout', `timed out waiting for the ${pending.provider} connection`, 'Re-run the build after connecting.');
}
}
}
}
exports.RemoteGitConnect = RemoteGitConnect;
// SECTION - Functions
/**
* Rejects a connect URL that is not a plain https:// URL. The value comes from the
* build service and is handed to the user's browser, so anything else — another
* scheme, a local path, an embedded quote — must never reach the launcher.
*
* @param {string} connectUrl - The connect URL as the service sent it.
* @returns {void}
*/
function assertHttpsConnectUrl(connectUrl) {
let url;
try {
url = new URL(connectUrl);
}
catch (_a) {
throw connectUrlFailure();
}
if (url.protocol !== 'https:' || CONTROL_CHARACTERS.test(connectUrl)) {
throw connectUrlFailure();
}
}
/**
* Builds the failure for a connect URL the CLI refuses to open.
*
* @returns {RemoteBuildError} The classified failure.
*/
function connectUrlFailure() {
return new errors_1.RemoteBuildError('request-failed', 'the build service returned an invalid provider connect link', `Re-run the build, and if it persists email ${config_1.SUPPORT_EMAIL}.`);
}
/**
* Returns a friendly name for a git host: recognized providers by name, others by host.
*
* @param {string} host - The lowercased repository hostname.
* @returns {string} The provider label.
*/
function gitProviderLabel(host) {
if (host === 'dev.azure.com' || host.endsWith('.visualstudio.com')) {
return 'Azure DevOps';
}
if (host === 'bitbucket.org') {
return 'Bitbucket';
}
return host;
}
/**
* Reports whether the session can drive an interactive browser flow.
*
* @returns {boolean} True when both stdin and stderr are terminals.
*/
function sessionIsInteractive() {
return Boolean(process.stdin.isTTY && process.stderr.isTTY);
}
// !SECTION
//# sourceMappingURL=connect.js.map