UNPKG

@controlplane/cli

Version:

Control Plane Corporation CLI

207 lines 9.67 kB
"use strict"; 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