UNPKG

@controlplane/cli

Version:

Control Plane Corporation CLI

356 lines 16.8 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.SandboxConnector = exports.SANDBOX_IDES = void 0; const fs = require("fs"); const os = require("os"); const path = require("path"); const child_process_1 = require("child_process"); const resolver_1 = require("../commands/resolver"); const workload_1 = require("../util/workload"); const logger_1 = require("../util/logger"); const terminal_server_1 = require("../terminal-server/terminal-server"); const port_forwarder_1 = require("../portforward/port-forwarder"); const open = require("open"); // ANCHOR - Constants exports.SANDBOX_IDES = ['vscode', 'cursor', 'ssh', 'browser', 'terminal']; // Available IDEs / connection modes for "sandbox connect". const SANDBOX_TYPE_TAG = 'cpln/dev-type'; const SANDBOX_TYPE_VALUE = 'devbox'; const SANDBOX_IDE_PASSWORD_TAG = 'cpln/dev-ide-password'; const REMOTE_SSH_PORT = 22; const SSH_USER = 'devuser'; const WORKSPACE_PATH = '/workspace'; const IDE_URL_PATH = '/_ide/'; const TERM_URL_PATH = '/_term/'; const SSH_BIND_ADDRESS = '127.0.0.1'; const SSH_DIR = path.join(os.homedir(), '.ssh'); const SSH_DIR_MODE = 0o700; // Owner-only (0700) permissions for the ~/.ssh directory. const SSH_KEY_TYPE = 'ed25519'; const SSH_KEY_FILE = path.join(SSH_DIR, `cpln_dev_${SSH_KEY_TYPE}`); const SSH_CONFIG_FILE = path.join(SSH_DIR, 'config'); const SSH_KNOWN_HOSTS = path.join(SSH_DIR, 'cpln_known_hosts'); /** * Owns the `cpln sandbox connect` flow: resolves the sandbox, injects the dedicated SSH key, * starts the port-forward, writes the SSH config, and launches the requested IDE * (browser / VS Code / Cursor / ssh). */ class SandboxConnector { constructor(client, session) { this.client = client; this.session = session; } // Public Methods // /** * Runs the `cpln sandbox connect` flow for the chosen mode: opens the browser surface, or sets * up the SSH tunnel (key + port-forward + config) and either launches the editor or prints how * to connect. The ssh / editor modes keep the tunnel alive until Ctrl+C. * * @param {SandboxConnectStartOptions} options - The connect target and IDE selection. */ async connect(options) { const sandbox = await this.resolveSandbox(options.ref); switch (options.ide) { case 'browser': case 'terminal': return this.openWeb(sandbox, options.ide === 'terminal' ? TERM_URL_PATH : IDE_URL_PATH); case 'ssh': { const host = await this.openSshTunnel(options); this.session.err(`SSH tunnel ready. Connect with "ssh ${host}", or use "${host}" as a Remote-SSH host in your IDE.`); this.session.err(` Press Ctrl+C to stop the port-forward when done.`); return; } case 'vscode': case 'cursor': default: { const host = await this.openSshTunnel(options); const command = options.ide === 'cursor' ? 'cursor' : 'code'; const reconnect = this.remoteIdeCommand(command, host).join(' '); const ideProcess = this.launchRemoteIde(command, host); // Print the launch guidance only once the editor process has actually spawned ideProcess.on('spawn', () => { this.session.err(`${command === 'code' ? 'VS Code' : 'Cursor'} launched. Press Ctrl+C to stop the port-forward when done.`); this.session.err(` Reconnect anytime with: ${reconnect}`); }); // Point at the manual paths when the editor binary is missing or fails to start ideProcess.on('error', (err) => { this.session.err(`WARN: Failed to launch ${options.ide}: ${err.message}`); this.session.err(` Install the "${command}" command-line launcher, or connect directly with: ssh ${host}`); }); } } } // Private Methods // /** * Resolves the sandbox workload named `ref` in the session's org/gvc: fetches it, confirms * it's a sandbox, and returns its endpoint + password. Fails with an actionable message * if the workload is missing, isn't a sandbox, or isn't ready yet. * * @param {string} ref - The workload name to find. * @returns {Promise<ResolvedSandbox>} The resolved sandbox details. */ async resolveSandbox(ref) { var _a, _b, _c; let workload; const context = this.session.context; try { workload = await this.client.get((0, resolver_1.kindResolver)('workload').resourceLink(ref, context)); } catch (e) { if (((_a = e.response) === null || _a === void 0 ? void 0 : _a.status) === 404) { throw new Error(`ERROR: Sandbox "${ref}" not found in ${context.org}/${context.gvc}. Check the name, and that --gvc points to the right GVC.`); } throw e; } if (((_b = workload.tags) === null || _b === void 0 ? void 0 : _b[SANDBOX_TYPE_TAG]) !== SANDBOX_TYPE_VALUE) { throw new Error(`ERROR: "${ref}" in ${context.org}/${context.gvc} is not a sandbox. "sandbox connect" only works with dev sandboxes — for a regular workload use "cpln workload connect ${ref}".`); } const endpoint = (0, workload_1.getWorkloadEndpoint)(workload); if (!endpoint) { throw new Error(`ERROR: Sandbox "${ref}" has no endpoint yet — it may still be starting up. Wait a few seconds and try again; if it persists, check the sandbox in the console.`); } const password = (_c = workload.tags) === null || _c === void 0 ? void 0 : _c[SANDBOX_IDE_PASSWORD_TAG]; if (!password) { throw new Error(`ERROR: Sandbox "${ref}" has no password yet — it may still be starting up. Wait a few seconds and try again.`); } return { endpoint, password }; } /** * Shows the sandbox password, then opens the browser-gated surface (IDE or terminal) * and prints the URL as a fallback. * * @param {ResolvedSandbox} sandbox - The resolved sandbox. * @param {string} urlPath - The gateway path to open (e.g. IDE_URL_PATH, TERM_URL_PATH). */ async openWeb(sandbox, urlPath) { const url = `${sandbox.endpoint}${urlPath}`; this.session.err(`Your sandbox password: ${sandbox.password}`); // Open the browser; a launch failure is non-fatal because the URL is printed below const opener = await open(url); opener.on('error', (err) => { this.session.err(`WARN: Failed to open the browser: ${err.message}`); }); this.session.err(`If your browser did not open, visit: ${url}`); } /** * Sets up the SSH path to the sandbox: injects the dedicated key, starts the port-forward, * writes the SSH config, and returns the host alias to connect to. * * @param {SandboxConnectStartOptions} options - The connect options (workload ref, local port). * @returns {Promise<string>} The SSH host alias (`ssh <alias>` / editor remote). */ async openSshTunnel(options) { var _a; await this.injectSshKey(options.ref); const forwarder = new port_forwarder_1.PortForwarder(this.client, this.session, true); const bound = await forwarder.start({ workload: options.ref, ports: [`${(_a = options.port) !== null && _a !== void 0 ? _a : ''}:${REMOTE_SSH_PORT}`], addresses: [SSH_BIND_ADDRESS], }); const sshListener = bound.find((info) => info.address === SSH_BIND_ADDRESS); if (!sshListener) { throw new Error('ERROR: Port-forward did not bind a local SSH listener.'); } return this.writeSshConfig(options.ref, sshListener.port); } /** * Ensures the dedicated cpln SSH key exists and injects its public half into the sandbox * workload via a one-shot exec. * * Assumes the standard devbox image: a POSIX shell with `bash`, `base64`, and `grep`, and * a `devuser` login user (uid 1000). If a different base image doesn't meet that, the * exec fails and the error is surfaced. * * @param {string} workload - The sandbox workload name. */ async injectSshKey(workload) { const key = this.ensureSshKey(); if (key.generated) { this.session.err(` Generated ${SSH_KEY_FILE}`); } const publicKey = key.publicKey; const encodedKey = Buffer.from(publicKey).toString('base64'); const script = [ `KEY=$(echo '${encodedKey}' | base64 -d)`, 'mkdir -p /home/devuser/.ssh', 'chmod 700 /home/devuser/.ssh', 'touch /home/devuser/.ssh/authorized_keys', 'grep -qF "$KEY" /home/devuser/.ssh/authorized_keys || echo "$KEY" >> /home/devuser/.ssh/authorized_keys', 'chmod 600 /home/devuser/.ssh/authorized_keys', 'chown -R devuser:devuser /home/devuser/.ssh', ].join(' && '); const terminalServer = new terminal_server_1.TerminalServer(this.session, this.client, true); const config = await terminalServer.createConfig(workload); config.request.command = ['bash', '-c', script]; // Run the command in the workload replica const exitCode = await terminalServer.exec(config); if (exitCode !== 0) { throw new Error(`ERROR: SSH key injection failed (exit code ${exitCode})`); } } // SSH Helpers // /** * Ensures the dedicated cpln sandbox keypair exists, generating it if necessary. * Never reads, reuses, or creates the user's personal SSH keys. * * @returns {SshKeyResult} The public key content and whether it was newly generated. */ ensureSshKey() { const publicKeyPath = `${SSH_KEY_FILE}.pub`; if (fs.existsSync(publicKeyPath)) { const existing = fs.readFileSync(publicKeyPath, 'utf-8').trim(); if (this.isValidSshPublicKey(existing)) { return { publicKey: existing, generated: false }; } } fs.mkdirSync(SSH_DIR, { recursive: true, mode: SSH_DIR_MODE }); fs.rmSync(SSH_KEY_FILE, { force: true }); fs.rmSync(publicKeyPath, { force: true }); // Generate the keypair, wrapping the raw failure with the operation that failed try { (0, child_process_1.execFileSync)('ssh-keygen', ['-t', SSH_KEY_TYPE, '-f', SSH_KEY_FILE, '-N', '', '-q']); } catch (e) { throw new Error(`ERROR: Failed to generate the SSH key with ssh-keygen: ${e.message}`); } return { publicKey: fs.readFileSync(publicKeyPath, 'utf-8').trim(), generated: true }; } /** * Validates that a string is a well-formed SSH public key. * * @param {string} pubkey - The public key string to validate. * @returns {boolean} True if the key matches expected SSH public key formats. */ isValidSshPublicKey(pubkey) { return /^(ssh-ed25519|ssh-rsa|ecdsa-sha2-\S+) [A-Za-z0-9+/=]+ ?\S*$/.test(pubkey); } /** * Writes (or replaces) the sandbox's host entry in ~/.ssh/config and returns the host alias * the caller should connect to (`ssh <alias>` / editor remote). Idempotent per sandbox — the * prior block for the host is removed before the new one is appended. * * @param {string} ref - The sandbox workload name. * @param {number} port - The local forwarded port. * @returns {string} The SSH host alias for this sandbox. */ writeSshConfig(ref, port) { const host = `cpln-dev-${ref}`; fs.mkdirSync(SSH_DIR, { recursive: true, mode: SSH_DIR_MODE }); if (fs.existsSync(SSH_CONFIG_FILE)) { const filtered = this.removeHostBlock(fs.readFileSync(SSH_CONFIG_FILE, 'utf-8'), host); fs.writeFileSync(SSH_CONFIG_FILE, filtered); } // Drop the host key recorded for this alias — a recreated sandbox presents a new // host key, and the stale entry would make ssh refuse the connection this.clearKnownHostEntry(host); fs.appendFileSync(SSH_CONFIG_FILE, this.buildHostBlock(host, port, SSH_USER)); return host; } /** * Removes the recorded host key for an alias from the dedicated known-hosts file. * * @param {string} host - The SSH host alias whose entry should be removed. */ clearKnownHostEntry(host) { // Nothing to clear when the known-hosts file does not exist yet if (!fs.existsSync(SSH_KNOWN_HOSTS)) { return; } // Remove the alias's entry; ssh records the current key again on the next connection try { (0, child_process_1.execFileSync)('ssh-keygen', ['-R', host, '-f', SSH_KNOWN_HOSTS], { stdio: 'ignore' }); } catch (e) { // Best effort — ssh surfaces any remaining host key mismatch itself logger_1.logger.debug(`Failed to remove the known-hosts entry for ${host}: ${e.message}`); } // Delete the backup file ssh-keygen leaves behind fs.rmSync(`${SSH_KNOWN_HOSTS}.old`, { force: true }); } /** * Removes an SSH config host block from config file content. * * @param {string} content - The full SSH config file content. * @param {string} host - The host alias to remove. * @returns {string} The content with the host block removed. */ removeHostBlock(content, host) { const lines = content.split('\n'); const filtered = []; let skipping = false; for (const line of lines) { if (line.trimStart().startsWith(`Host ${host}`) && line.trim() === `Host ${host}`) { skipping = true; continue; } if (skipping) { // Keep skipping the block's indented option lines and surrounding blank lines if (line.trim() === '' || /^\s/.test(line)) { continue; } // The first top-level line ends the block; keep it (including comment lines) skipping = false; filtered.push(line); continue; } filtered.push(line); } return filtered.join('\n'); } /** * Builds the SSH config host block for a sandbox. * * @param {string} host - The SSH host alias. * @param {number} port - The local forwarded port. * @param {string} user - The remote user. * @returns {string} The formatted host block. */ buildHostBlock(host, port, user) { return [ '', `Host ${host}`, ` HostName ${SSH_BIND_ADDRESS}`, ` Port ${port}`, ` User ${user}`, ` IdentityFile ${SSH_KEY_FILE}`, ` IdentitiesOnly yes`, ` StrictHostKeyChecking accept-new`, ` UserKnownHostsFile ${SSH_KNOWN_HOSTS}`, ` HostKeyAlias ${host}`, ` ServerAliveInterval 15`, ` ServerAliveCountMax 4`, '', ].join('\n'); } /** * Builds the editor's remote-connect command as [binary, ...args]. Single source of truth * for both spawning the editor and printing the reconnect / manual-launch command. * * @param {string} command - The editor binary (e.g. `code`, `cursor`). * @param {string} host - The SSH host alias. * @returns {string[]} The command as [binary, ...args]. */ remoteIdeCommand(command, host) { // Cursor needs --classic to open the editor instead of its agent home; code has no such flag. const classic = command === 'cursor' ? ['--classic'] : []; return [command, ...classic, '--remote', `ssh-remote+${host}`, WORKSPACE_PATH]; } /** * Launches a remote editor (VS Code or Cursor) connected via SSH. * The editor process is detached so it runs independently. * * @param {string} command - The editor binary (e.g. `code`, `cursor`). * @param {string} host - The SSH host alias. * @returns {ChildProcess} The detached editor process. */ launchRemoteIde(command, host) { const [binary, ...args] = this.remoteIdeCommand(command, host); const child = (0, child_process_1.spawn)(binary, args, { detached: true, stdio: 'ignore', }); child.unref(); return child; } } exports.SandboxConnector = SandboxConnector; //# sourceMappingURL=sandbox-connector.js.map