@controlplane/cli
Version:
Control Plane Corporation CLI
356 lines • 16.8 kB
JavaScript
;
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