diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 66586e81..8941c1a1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -152,7 +152,7 @@ PR titles should follow the conventional commit format as they are used for rele src/ ├── cli.ts # Main CLI entry point ├── commands/ # Command implementations -│ ├── devbox/ # Devbox commands (ssh, pty, exec, etc.) +│ ├── devbox/ # Devbox commands (pty, exec, etc.) │ ├── snapshot/ # Snapshot commands │ ├── blueprint/ # Blueprint commands │ └── object/ # Object storage commands diff --git a/README.md b/README.md index 762c6d17..188a59b1 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ rli devbox delete - 🎯 **CLI mode** — Traditional commands with text, JSON, and YAML output for scripting - ⚡ Fast and responsive with pagination - 📦 Manage devboxes, snapshots, and blueprints -- 🚀 Execute commands, SSH, PTY shell, view logs in devboxes +- 🚀 Execute commands, PTY shell, view logs in devboxes - 🤖 **Model Context Protocol (MCP) server for AI integration** ## Installation @@ -68,7 +68,6 @@ The URL must be of the form `https://api.`, optionally with a `:port` (e |----------|------| | API | `https://api.` (the value of `RUNLOOP_BASE_URL`) | | Platform | `https://platform.` | -| SSH | `ssh.:443` | | Tunnels | `tunnel.` (PTY sessions reach the devbox over a tunnel created via the API) | ## Usage @@ -108,11 +107,7 @@ rli devbox get-async # Get status of an async execution rli devbox suspend # Suspend a devbox rli devbox resume # Resume a suspended devbox rli devbox shutdown # Shutdown a devbox -rli devbox ssh # SSH into a devbox rli devbox pty # Connect to a devbox PTY session via W... -rli devbox scp # Copy files to/from a devbox using scp... -rli devbox rsync # Sync files to/from a devbox using rsy... -rli devbox tunnel # Create a port-forwarding tunnel to a ... rli devbox read # Read a file from a devbox using the API rli devbox write # Write a file to a devbox using the API rli devbox download # Download a file from a devbox diff --git a/jest.e2e.config.js b/jest.e2e.config.js index b873e3ff..205586c7 100644 --- a/jest.e2e.config.js +++ b/jest.e2e.config.js @@ -44,6 +44,7 @@ export default { // e2e tests may take a while (devbox creation, transfers, etc.) testTimeout: 180_000, + passWithNoTests: true, moduleFileExtensions: ["ts", "js", "json"], clearMocks: true, restoreMocks: true, diff --git a/llms.txt b/llms.txt index f3346628..ea8897dc 100644 --- a/llms.txt +++ b/llms.txt @@ -31,9 +31,6 @@ Get your API key from [https://runloop.ai/settings](https://runloop.ai/settings) - `rli devbox list` - List all devboxes (filter with `-s running`, `-s suspended`, etc.) - `rli devbox create --name ` - Create a new devbox - `rli devbox exec ` - Execute a command in a devbox -- `rli devbox ssh ` - SSH into a devbox -- `rli devbox scp ` - Copy files to/from a devbox (e.g., `rli devbox scp dbx_id:/remote ./local`) -- `rli devbox rsync ` - Sync files to/from a devbox using rsync - `rli devbox delete ` - Shutdown and delete a devbox - `rli devbox logs ` - View devbox logs (use `-o json` for structured output) - `rli snapshot list` - List snapshots diff --git a/misc/rli-ssh-demo.gif b/misc/rli-ssh-demo.gif deleted file mode 100644 index d0aed613..00000000 Binary files a/misc/rli-ssh-demo.gif and /dev/null differ diff --git a/scripts/generate-command-docs.js b/scripts/generate-command-docs.js index 85e14aa8..47b87992 100644 --- a/scripts/generate-command-docs.js +++ b/scripts/generate-command-docs.js @@ -43,10 +43,6 @@ function generateCommandStructure(program) { suspend: "Suspend", resume: "Resume", shutdown: "Shutdown", - ssh: "SSH", - scp: "Copy files (scp)", - rsync: "Sync files (rsync)", - tunnel: "Create tunnel", logs: "View logs", status: "Get status", install: "Install", @@ -239,10 +235,6 @@ function getCommandIcon(cmdName) { suspend: "pause", resume: "play", shutdown: "power-off", - ssh: "terminal", - scp: "copy", - rsync: "rotate", - tunnel: "network-wired", logs: "scroll", status: "info", start: "play", @@ -361,14 +353,6 @@ export RUNLOOP_API_KEY=your_api_key_here Search devboxes in interactive mode - - ### SSH to Devbox - - From the interactive menu, select a devbox and choose **SSH** to open a secure shell session directly into your devbox. The CLI handles all the SSH key setup and connection details automatically. - - - SSH into a Devbox - Use traditional commands for scripting and automation: @@ -408,8 +392,6 @@ export RUNLOOP_API_KEY=your_api_key_here \`\`\` \`\`\`bash - # SSH into a devbox - rli devbox ssh dbx_1234567890 \`\`\` diff --git a/src/commands/devbox/list.tsx b/src/commands/devbox/list.tsx index 20de58b5..7f786388 100644 --- a/src/commands/devbox/list.tsx +++ b/src/commands/devbox/list.tsx @@ -371,13 +371,6 @@ const ListDevboxesUI = ({ icon: figures.circleFilled, shortcut: "n", }, - { - key: "ssh", - label: "SSH onto the box", - color: colors.primary, - icon: figures.arrowRight, - shortcut: "s", - }, { key: "tunnel", label: "Open Tunnel", diff --git a/src/commands/devbox/pty.ts b/src/commands/devbox/pty.ts index 13e493b1..3ea2d995 100644 --- a/src/commands/devbox/pty.ts +++ b/src/commands/devbox/pty.ts @@ -1,7 +1,7 @@ import WebSocket from "ws"; import { cliStatus } from "../../utils/cliStatus.js"; import { outputError } from "../../utils/output.js"; -import { waitForReady } from "../../utils/ssh.js"; +import { waitForReady } from "../../utils/devboxReady.js"; import { getPtyBaseUrl, createPtySessionReleaser, diff --git a/src/commands/devbox/rsync.ts b/src/commands/devbox/rsync.ts deleted file mode 100644 index 85353ef5..00000000 --- a/src/commands/devbox/rsync.ts +++ /dev/null @@ -1,131 +0,0 @@ -/** - * Rsync files to/from devbox command - * - * Supports standard rsync-like syntax where the devbox ID (dbx_*) is used as a hostname: - * rli devbox rsync dbx_abc123:/remote/path ./local/path # download - * rli devbox rsync ./local/path dbx_abc123:/remote/path # upload - * rli devbox rsync root@dbx_abc123:/remote/path ./local/path # explicit user - * - * If no user is specified for a remote path, the devbox's configured user is used. - * Paths without a dbx_ hostname are treated as local paths. - */ - -import { execFile } from "child_process"; -import { promisify } from "util"; -import { output, outputError } from "../../utils/output.js"; -import { getProxyCommand, checkSSHTools } from "../../utils/ssh.js"; -import { parseSCPPath, resolveRemote, type ParsedSCPPath } from "./scp.js"; - -const execFileAsync = promisify(execFile); - -export interface RsyncOptions { - rsyncOptions?: string; - output?: string; -} - -/** - * Build the rsync command for a single-remote transfer (local <-> devbox). - */ -export function buildRsyncCommand(opts: { - sshInfo: { keyfilePath: string; url: string }; - proxyCommand: string; - parsedSrc: ParsedSCPPath; - parsedDst: ParsedSCPPath; - defaultUser: string; - rsyncOptions?: string; -}): string[] { - // Rsync re-splits the -e value on whitespace internally, so the - // ProxyCommand (which contains spaces) must be single-quoted. - const sshTransport = `ssh -i ${opts.sshInfo.keyfilePath} -o 'ProxyCommand=${opts.proxyCommand}' -o StrictHostKeyChecking=no`; - - const rsyncCommand = [ - "rsync", - "-vrz", // v: verbose, r: recursive, z: compress - "-e", - sshTransport, - ]; - - if (opts.rsyncOptions) { - rsyncCommand.push(...opts.rsyncOptions.split(" ")); - } - - // Build src argument - if (opts.parsedSrc.isRemote) { - const user = opts.parsedSrc.user || opts.defaultUser; - rsyncCommand.push(`${user}@${opts.sshInfo.url}:${opts.parsedSrc.path}`); - } else { - rsyncCommand.push(opts.parsedSrc.path); - } - - // Build dst argument - if (opts.parsedDst.isRemote) { - const user = opts.parsedDst.user || opts.defaultUser; - rsyncCommand.push(`${user}@${opts.sshInfo.url}:${opts.parsedDst.path}`); - } else { - rsyncCommand.push(opts.parsedDst.path); - } - - return rsyncCommand; -} - -export async function rsyncFiles( - src: string, - dst: string, - options: RsyncOptions, -) { - try { - // Check if SSH tools are available - const sshToolsAvailable = await checkSSHTools(); - if (!sshToolsAvailable) { - outputError( - "SSH tools (ssh, rsync, openssl) are not available on this system", - ); - } - - const parsedSrc = parseSCPPath(src); - const parsedDst = parseSCPPath(dst); - - if (!parsedSrc.isRemote && !parsedDst.isRemote) { - outputError( - "At least one of src or dst must be a remote devbox path (e.g. dbx_:/path)", - ); - } - - if (parsedSrc.isRemote && parsedDst.isRemote) { - outputError( - "Devbox-to-devbox rsync is not supported. Only one side can be a remote devbox path.", - ); - } - - const devboxId = parsedSrc.isRemote ? parsedSrc.host! : parsedDst.host!; - const remote = await resolveRemote(devboxId); - const proxyCommand = getProxyCommand(); - - const rsyncCommand = buildRsyncCommand({ - sshInfo: remote.sshInfo, - proxyCommand, - parsedSrc, - parsedDst, - defaultUser: remote.defaultUser, - rsyncOptions: options.rsyncOptions, - }); - - const [cmd, ...args] = rsyncCommand; - await execFileAsync(cmd, args); - - // Default: just output the destination for easy scripting - if (!options.output || options.output === "text") { - console.log(dst); - } else { - output( - { - source: src, - destination: dst, - }, - { format: options.output, defaultFormat: "json" }, - ); - } - } catch (error) { - outputError("Rsync operation failed", error); - } -} diff --git a/src/commands/devbox/scp.ts b/src/commands/devbox/scp.ts deleted file mode 100644 index 47de809a..00000000 --- a/src/commands/devbox/scp.ts +++ /dev/null @@ -1,295 +0,0 @@ -/** - * SCP files to/from devbox command - * - * Supports standard SCP-like syntax where the devbox ID (dbx_*) is used as a hostname: - * rli devbox scp dbx_abc123:/remote/path ./local/path # download - * rli devbox scp ./local/path dbx_abc123:/remote/path # upload - * rli devbox scp root@dbx_abc123:/remote/path ./local/path # explicit user - * rli devbox scp dbx_src:/file dbx_dst:/file # devbox-to-devbox - * - * If no user is specified for a remote path, the devbox's configured user is used. - * Paths without a dbx_ hostname are treated as local paths. - * - * Devbox-to-devbox transfers use scp -3 to route data through the local machine, - * with a temporary SSH config so each devbox uses its own key. - */ - -import { execFile } from "child_process"; -import { promisify } from "util"; -import { writeFile, unlink } from "fs/promises"; -import { join } from "path"; -import { tmpdir } from "os"; -import { randomUUID } from "crypto"; -import { getClient } from "../../utils/client.js"; -import { output, outputError } from "../../utils/output.js"; -import { - getSSHKey, - getProxyCommand, - checkSSHTools, - SSHKeyInfo, -} from "../../utils/ssh.js"; - -const execFileAsync = promisify(execFile); - -export interface SCPOptions { - scpOptions?: string; - output?: string; -} - -export interface ParsedSCPPath { - user?: string; - host?: string; - path: string; - isRemote: boolean; -} - -/** - * Resolved info for a remote devbox endpoint. - */ -export interface ResolvedRemote { - devboxId: string; - defaultUser: string; - sshInfo: SSHKeyInfo; -} - -/** - * Parse an SCP-style path into its components. - * - * Supported formats: - * user@dbx_id:path -> remote with explicit user - * dbx_id:path -> remote with default user - * /local/path -> local (absolute) - * ./relative -> local (relative) - * filename -> local (bare filename, no colon) - */ -export function parseSCPPath(input: string): ParsedSCPPath { - // Match [user@]host:path where host is a devbox ID (dbx_*). - // This avoids false positives on local paths that happen to contain colons. - const match = input.match(/^(?:([^@/:]+)@)?(dbx_[^@/:]+):(.*)$/); - if (match) { - return { - user: match[1] || undefined, - host: match[2], - path: match[3], - isRemote: true, - }; - } - return { path: input, isRemote: false }; -} - -/** - * Resolve a devbox ID to its SSH info and default user. - */ -export async function resolveRemote(devboxId: string): Promise { - const client = getClient(); - const devbox = await client.devboxes.retrieve(devboxId); - const defaultUser = - devbox.launch_parameters?.user_parameters?.username || "user"; - - const sshInfo = await getSSHKey(devboxId); - if (!sshInfo) { - throw new Error(`Failed to create SSH key for ${devboxId}`); - } - - return { devboxId, defaultUser, sshInfo }; -} - -/** - * Build the SCP command for a single-remote transfer (local <-> devbox). - */ -export function buildSCPCommand(opts: { - sshInfo: { keyfilePath: string; url: string }; - proxyCommand: string; - parsedSrc: ParsedSCPPath; - parsedDst: ParsedSCPPath; - defaultUser: string; - scpOptions?: string; -}): string[] { - const scpCommand = [ - "scp", - "-i", - opts.sshInfo.keyfilePath, - "-o", - `ProxyCommand=${opts.proxyCommand}`, - "-o", - "StrictHostKeyChecking=no", - ]; - - if (opts.scpOptions) { - scpCommand.push(...opts.scpOptions.split(" ")); - } - - // Build src argument - if (opts.parsedSrc.isRemote) { - const user = opts.parsedSrc.user || opts.defaultUser; - scpCommand.push(`${user}@${opts.sshInfo.url}:${opts.parsedSrc.path}`); - } else { - scpCommand.push(opts.parsedSrc.path); - } - - // Build dst argument - if (opts.parsedDst.isRemote) { - const user = opts.parsedDst.user || opts.defaultUser; - scpCommand.push(`${user}@${opts.sshInfo.url}:${opts.parsedDst.path}`); - } else { - scpCommand.push(opts.parsedDst.path); - } - - return scpCommand; -} - -/** - * Build the SCP command for a dual-remote transfer (devbox -> devbox). - * Uses scp -3 to route data through the local machine and a temporary - * SSH config file so each devbox resolves to its own key/proxy. - */ -export function buildDualRemoteSCPCommand(opts: { - srcRemote: ResolvedRemote; - dstRemote: ResolvedRemote; - proxyCommand: string; - parsedSrc: ParsedSCPPath; - parsedDst: ParsedSCPPath; - sshConfigPath: string; - scpOptions?: string; -}): string[] { - const scpCommand = [ - "scp", - "-3", - "-F", - opts.sshConfigPath, - "-o", - "StrictHostKeyChecking=no", - ]; - - if (opts.scpOptions) { - scpCommand.push(...opts.scpOptions.split(" ")); - } - - const srcUser = opts.parsedSrc.user || opts.srcRemote.defaultUser; - scpCommand.push( - `${srcUser}@${opts.srcRemote.sshInfo.url}:${opts.parsedSrc.path}`, - ); - - const dstUser = opts.parsedDst.user || opts.dstRemote.defaultUser; - scpCommand.push( - `${dstUser}@${opts.dstRemote.sshInfo.url}:${opts.parsedDst.path}`, - ); - - return scpCommand; -} - -/** - * Generate a temporary SSH config file for dual-remote transfers. - * Maps each devbox URL to its identity file and proxy command. - */ -export function generateSCPConfig( - remotes: ResolvedRemote[], - proxyCommand: string, -): string { - return remotes - .map( - (r) => - `Host ${r.sshInfo.url}\n` + - ` IdentityFile ${r.sshInfo.keyfilePath}\n` + - ` ProxyCommand ${proxyCommand}\n` + - ` StrictHostKeyChecking no`, - ) - .join("\n\n"); -} - -export async function scpFiles(src: string, dst: string, options: SCPOptions) { - try { - // Check if SSH tools are available - const sshToolsAvailable = await checkSSHTools(); - if (!sshToolsAvailable) { - outputError( - "SSH tools (ssh, scp, openssl) are not available on this system", - ); - } - - const parsedSrc = parseSCPPath(src); - const parsedDst = parseSCPPath(dst); - - if (!parsedSrc.isRemote && !parsedDst.isRemote) { - outputError( - "At least one of src or dst must be a remote devbox path (e.g. dbx_:/path)", - ); - } - - const proxyCommand = getProxyCommand(); - - let scpCommand: string[]; - - if (parsedSrc.isRemote && parsedDst.isRemote) { - // Both sides are remote devboxes — resolve both in parallel - const [srcRemote, dstRemote] = await Promise.all([ - resolveRemote(parsedSrc.host!), - resolveRemote(parsedDst.host!), - ]); - - // Write a temporary SSH config so scp can find the right key per host - const configContent = generateSCPConfig( - [srcRemote, dstRemote], - proxyCommand, - ); - const configPath = join(tmpdir(), `rli-scp-${randomUUID()}.conf`); - const configHeader = - "# Temporary SSH config generated by `rli devbox scp` for a dual-remote transfer.\n" + - "# Safe to delete.\n\n"; - await writeFile(configPath, configHeader + configContent, { - mode: 0o600, - }); - - try { - scpCommand = buildDualRemoteSCPCommand({ - srcRemote, - dstRemote, - proxyCommand, - parsedSrc, - parsedDst, - sshConfigPath: configPath, - scpOptions: options.scpOptions, - }); - - const [cmd, ...args] = scpCommand; - await execFileAsync(cmd, args); - } finally { - // Clean up the temp SSH config file. - // Note: SSH key files in ~/.runloop/ssh_keys/ are intentionally kept — - // they're shared across rli commands (ssh, scp, etc.). - await unlink(configPath).catch(() => {}); - } - } else { - // Single remote — one side is local - const devboxId = parsedSrc.isRemote ? parsedSrc.host! : parsedDst.host!; - const remote = await resolveRemote(devboxId); - - scpCommand = buildSCPCommand({ - sshInfo: remote.sshInfo, - proxyCommand, - parsedSrc, - parsedDst, - defaultUser: remote.defaultUser, - scpOptions: options.scpOptions, - }); - - const [cmd, ...args] = scpCommand; - await execFileAsync(cmd, args); - } - - // Default: just output the destination for easy scripting - if (!options.output || options.output === "text") { - console.log(dst); - } else { - output( - { - source: src, - destination: dst, - }, - { format: options.output, defaultFormat: "json" }, - ); - } - } catch (error) { - outputError("SCP operation failed", error); - } -} diff --git a/src/commands/devbox/ssh.ts b/src/commands/devbox/ssh.ts deleted file mode 100644 index 7b7bf020..00000000 --- a/src/commands/devbox/ssh.ts +++ /dev/null @@ -1,120 +0,0 @@ -/** - * SSH into devbox command - */ - -import { spawn } from "child_process"; -import { getClient } from "../../utils/client.js"; -import { cliStatus } from "../../utils/cliStatus.js"; -import { output, outputError } from "../../utils/output.js"; -import { processUtils } from "../../utils/processUtils.js"; -import { - getSSHKey, - waitForReady, - generateSSHConfig, - checkSSHTools, - getProxyCommand, -} from "../../utils/ssh.js"; - -interface SSHOptions { - configOnly?: boolean; - noWait?: boolean; - timeout?: number; - pollInterval?: number; - output?: string; -} - -export async function sshDevbox(devboxId: string, options: SSHOptions = {}) { - try { - // Check if SSH tools are available - const sshToolsAvailable = await checkSSHTools(); - if (!sshToolsAvailable) { - outputError( - "SSH tools (ssh, scp, rsync, openssl) are not available on this system", - ); - } - - const client = getClient(); - - // Wait for devbox to be ready unless --no-wait is specified - if (!options.noWait) { - if (!options.configOnly) { - cliStatus(`Waiting for devbox ${devboxId} to be ready...`); - } - const isReady = await waitForReady( - devboxId, - options.timeout || 180, - options.pollInterval || 3, - { quiet: options.configOnly }, - ); - if (!isReady) { - outputError(`Devbox ${devboxId} is not ready. Please try again later.`); - } - } - - // Get devbox details to determine user - const devbox = await client.devboxes.retrieve(devboxId); - const user = devbox.launch_parameters?.user_parameters?.username || "user"; - - // Get SSH key - const sshInfo = await getSSHKey(devboxId); - if (!sshInfo) { - outputError("Failed to create SSH key"); - } - - if (options.configOnly) { - const config = generateSSHConfig( - devboxId, - user, - sshInfo!.keyfilePath, - sshInfo!.url, - ); - const format = options.output ?? "text"; - if (format === "text") { - console.log(config); - } else { - output({ config }, { format, defaultFormat: "text" }); - } - return; - } - - // If output format is specified, just return the connection info - if (options.output && options.output !== "text") { - output( - { - devboxId, - user, - keyfilePath: sshInfo!.keyfilePath, - url: sshInfo!.url, - }, - { format: options.output, defaultFormat: "json" }, - ); - return; - } - - // Actually start SSH session - const proxyCommand = getProxyCommand(); - const sshArgs = [ - "-i", - sshInfo!.keyfilePath, - "-o", - `ProxyCommand=${proxyCommand}`, - "-o", - "StrictHostKeyChecking=no", - `${user}@${sshInfo!.url}`, - ]; - - const sshProcess = spawn("/usr/bin/ssh", sshArgs, { - stdio: "inherit", - }); - - sshProcess.on("close", (code) => { - processUtils.exit(code || 0); - }); - - sshProcess.on("error", (err) => { - outputError("SSH connection failed", err); - }); - } catch (error) { - outputError("Failed to setup SSH connection", error); - } -} diff --git a/src/commands/devbox/tunnel.ts b/src/commands/devbox/tunnel.ts deleted file mode 100644 index 1a24eca2..00000000 --- a/src/commands/devbox/tunnel.ts +++ /dev/null @@ -1,103 +0,0 @@ -/** - * Create SSH tunnel to devbox command - */ - -import { spawn } from "child_process"; -import { getClient } from "../../utils/client.js"; -import { output, outputError } from "../../utils/output.js"; -import { processUtils } from "../../utils/processUtils.js"; -import { getSSHKey, getProxyCommand, checkSSHTools } from "../../utils/ssh.js"; -import { openInBrowser } from "../../utils/browser.js"; - -interface TunnelOptions { - ports: string; - output?: string; - open?: boolean; -} - -export async function createTunnel(devboxId: string, options: TunnelOptions) { - try { - // Check if SSH tools are available - const sshToolsAvailable = await checkSSHTools(); - if (!sshToolsAvailable) { - outputError("SSH tools (ssh, openssl) are not available on this system"); - } - - if (!options.ports.includes(":")) { - outputError("Ports must be specified as 'local:remote'"); - } - - const [localPort, remotePort] = options.ports.split(":"); - - const client = getClient(); - - // Get devbox details to determine user - const devbox = await client.devboxes.retrieve(devboxId); - const user = devbox.launch_parameters?.user_parameters?.username || "user"; - - // Get SSH key - const sshInfo = await getSSHKey(devboxId); - if (!sshInfo) { - outputError("Failed to create SSH key"); - } - - // If output format is specified, just return the tunnel info - if (options.output && options.output !== "text") { - output( - { - devboxId, - localPort, - remotePort, - user, - keyfilePath: sshInfo!.keyfilePath, - }, - { format: options.output, defaultFormat: "json" }, - ); - return; - } - - const proxyCommand = getProxyCommand(); - const tunnelArgs = [ - "-i", - sshInfo!.keyfilePath, - "-o", - `ProxyCommand=${proxyCommand}`, - "-o", - "StrictHostKeyChecking=no", - "-N", // Do not execute a remote command - "-L", - `${localPort}:localhost:${remotePort}`, - `${user}@${sshInfo!.url}`, - ]; - - const tunnelUrl = `http://localhost:${localPort}`; - console.log( - `Starting tunnel: local port ${localPort} -> remote port ${remotePort}`, - ); - console.log(`Tunnel URL: ${tunnelUrl}`); - console.log("Press Ctrl+C to stop the tunnel."); - - const tunnelProcess = spawn("/usr/bin/ssh", tunnelArgs, { - stdio: "inherit", - }); - - // Open browser if --open flag is set - if (options.open) { - // Small delay to let the tunnel establish - setTimeout(() => { - openInBrowser(tunnelUrl); - }, 1000); // TODO: Not going to need this soon with tunnels v2 - } - - tunnelProcess.on("close", (code) => { - console.log("\nTunnel closed."); - processUtils.exit(code || 0); - }); - - tunnelProcess.on("error", (err) => { - outputError("Tunnel creation failed", err); - }); - } catch (error) { - outputError("Failed to create SSH tunnel", error); - } -} diff --git a/src/components/DevboxActionsMenu.tsx b/src/components/DevboxActionsMenu.tsx index d359dfd8..51eec487 100644 --- a/src/components/DevboxActionsMenu.tsx +++ b/src/components/DevboxActionsMenu.tsx @@ -12,7 +12,6 @@ import { ConfirmationPrompt } from "./ConfirmationPrompt.js"; import { colors } from "../utils/theme.js"; import { openInBrowser } from "../utils/browser.js"; import { copyToClipboard } from "../utils/clipboard.js"; -import { sshGatewayHostname } from "../utils/config.js"; import { useViewportHeight } from "../hooks/useViewportHeight.js"; import { useNavigation } from "../store/navigationStore.js"; import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js"; @@ -23,7 +22,6 @@ import { uploadFile, createSnapshot as createDevboxSnapshot, createTunnel, - createSSHKey, } from "../services/devboxService.js"; import { StreamingLogsViewer } from "./StreamingLogsViewer.js"; import { DevboxView } from "@runloop/api-client/resources/devboxes.mjs"; @@ -34,13 +32,12 @@ import { getPtyTunnelBaseUrl, settleAfterPtyTunnel, } from "../lib/pty-client.js"; -import { waitForReady } from "../utils/ssh.js"; +import { waitForReady } from "../utils/devboxReady.js"; type Operation = | "exec" | "upload" | "snapshot" - | "ssh" | "pty" | "logs" | "tunnel" @@ -169,13 +166,6 @@ export const DevboxActionsMenu = ({ icon: figures.circleFilled, shortcut: "n", }, - { - key: "ssh", - label: "SSH onto the box", - color: colors.primary, - icon: figures.arrowRight, - shortcut: "s", - }, { key: "pty", label: "PTY Shell", @@ -260,7 +250,7 @@ export const DevboxActionsMenu = ({ // Auto-execute operations that don't need input (except delete which needs confirmation) React.useEffect(() => { - const autoExecuteOps = ["ssh", "pty", "logs", "suspend", "resume"]; + const autoExecuteOps = ["pty", "logs", "suspend", "resume"]; if ( executingOperation && autoExecuteOps.includes(executingOperation) && @@ -656,47 +646,6 @@ export const DevboxActionsMenu = ({ setSnapshotFormField("name"); break; - case "ssh": - // Use service layer - const sshKey = await createSSHKey(devbox.id); - - const fsModule = await import("fs"); - const pathModule = await import("path"); - const osModule = await import("os"); - - const sshDir = pathModule.join( - osModule.homedir(), - ".runloop", - "ssh_keys", - ); - fsModule.mkdirSync(sshDir, { recursive: true }); - const keyPath = pathModule.join(sshDir, `${devbox.id}.pem`); - - fsModule.writeFileSync(keyPath, sshKey.ssh_private_key, { - mode: 0o600, - }); - - const sshUser = - devbox.launch_parameters?.user_parameters?.username || "user"; - const sshHost = sshGatewayHostname(); - // macOS openssl doesn't support -verify_quiet, use compatible flags - // servername should be %h (target hostname) - SSH will replace %h with the actual hostname from the SSH command - // This matches the reference implementation where servername is the target hostname - const proxyCommand = `openssl s_client -quiet -servername %h -connect ${sshHost}:443 2>/dev/null`; - - // Navigate to SSH session screen - navigate("ssh-session", { - keyPath, - proxyCommand, - sshUser, - url: sshKey.url, - devboxId: devbox.id, - devboxName: devbox.name || devbox.id, - returnScreen: currentScreen, - returnParams: params, - }); - break; - case "pty": { await waitForReady(devbox.id, 180, 3); @@ -1451,7 +1400,6 @@ export const DevboxActionsMenu = ({ if (!needsInput) { const messages: Record = { - ssh: "Creating SSH key...", logs: "Fetching logs...", suspend: "Suspending devbox...", resume: "Resuming devbox...", diff --git a/src/components/DevboxDetailPage.tsx b/src/components/DevboxDetailPage.tsx index 278f52bb..7c26d951 100644 --- a/src/components/DevboxDetailPage.tsx +++ b/src/components/DevboxDetailPage.tsx @@ -77,13 +77,6 @@ export const DevboxDetailPage = ({ devbox, onBack }: DevboxDetailPageProps) => { icon: figures.circleFilled, shortcut: "n", }, - { - key: "ssh", - label: "SSH onto the box", - color: colors.primary, - icon: figures.arrowRight, - shortcut: "s", - }, { key: "tunnel", label: "Open Tunnel", diff --git a/src/components/InteractiveSpawn.tsx b/src/components/InteractiveSpawn.tsx index f40b9de7..9249e851 100644 --- a/src/components/InteractiveSpawn.tsx +++ b/src/components/InteractiveSpawn.tsx @@ -1,7 +1,7 @@ /** * InteractiveSpawn - Custom component for running interactive subprocesses * Based on Ink's subprocess-output example pattern - * Handles proper TTY allocation for interactive commands like SSH + * Handles proper TTY allocation for interactive commands */ import React from "react"; import { spawn, ChildProcess } from "child_process"; @@ -28,7 +28,7 @@ function releaseTerminal(): void { process.stdin.pause(); // Disable raw mode so the subprocess can control terminal echo and line buffering - // SSH needs to set its own terminal modes + // Interactive commands need to set their own terminal modes if (processUtils.stdin.isTTY && processUtils.stdin.setRawMode) { processUtils.stdin.setRawMode(false); } diff --git a/src/router/Router.tsx b/src/router/Router.tsx index beeddaeb..d9371416 100644 --- a/src/router/Router.tsx +++ b/src/router/Router.tsx @@ -52,7 +52,6 @@ const KNOWN_SCREENS: Set = new Set([ "object-list", "object-detail", "object-create", - "ssh-session", "pty-session", ]); @@ -126,7 +125,6 @@ import { AxonDetailScreen } from "../screens/AxonDetailScreen.js"; import { ObjectListScreen } from "../screens/ObjectListScreen.js"; import { ObjectDetailScreen } from "../screens/ObjectDetailScreen.js"; import { ObjectCreateScreen } from "../screens/ObjectCreateScreen.js"; -import { SSHSessionScreen } from "../screens/SSHSessionScreen.js"; import { PtySessionScreen } from "../screens/PtySessionScreen.js"; /** @@ -315,9 +313,6 @@ export function Router() { {currentScreen === "object-create" && ( )} - {currentScreen === "ssh-session" && ( - - )} {currentScreen === "pty-session" && ( )} diff --git a/src/screens/SSHSessionScreen.tsx b/src/screens/SSHSessionScreen.tsx deleted file mode 100644 index ac6e0717..00000000 --- a/src/screens/SSHSessionScreen.tsx +++ /dev/null @@ -1,94 +0,0 @@ -/** - * SSHSessionScreen - SSH session using custom InteractiveSpawn - * Runs SSH as a subprocess within the Ink UI without exiting - */ -import React from "react"; -import { Box, Text } from "ink"; -import { InteractiveSpawn } from "../components/InteractiveSpawn.js"; -import { - useNavigation, - type ScreenName, - type RouteParams, -} from "../store/navigationStore.js"; -import { Breadcrumb } from "../components/Breadcrumb.js"; -import { colors } from "../utils/theme.js"; -import figures from "figures"; - -export function SSHSessionScreen() { - const { params, replace } = useNavigation(); - - // NOTE: Do NOT use useExitOnCtrlC here - SSH handles Ctrl+C itself - // Using useInput would conflict with the subprocess's terminal control - - // Extract SSH config from params - const keyPath = params.keyPath; - const proxyCommand = params.proxyCommand; - const sshUser = params.sshUser; - const url = params.url; - const devboxName = params.devboxName || params.devboxId || "devbox"; - const returnScreen = (params.returnScreen as ScreenName) || "devbox-list"; - const returnParams = (params.returnParams as RouteParams) || {}; - - // Validate required params - if (!keyPath || !proxyCommand || !sshUser || !url) { - return ( - <> - - - - {figures.cross} Missing SSH configuration. Returning... - - - - ); - } - - // Build SSH command args - // The proxy command needs to be passed as a single value in the -o option - const sshArgs = React.useMemo( - () => [ - "-t", // Force pseudo-terminal allocation for proper input handling - "-i", - keyPath!, - "-o", - `ProxyCommand=${proxyCommand}`, - "-o", - "StrictHostKeyChecking=no", - "-o", - "UserKnownHostsFile=/dev/null", - `${sshUser}@${url}`, - ], - [keyPath, proxyCommand, sshUser, url], - ); - - return ( - <> - - - - {figures.play} Connecting to {devboxName}... - - - Press Ctrl+C, Ctrl+D, or type exit to disconnect - - - { - // Replace current screen (don't add SSH to history stack) - // Using replace() instead of navigate() prevents "escape goes back to SSH" bug - setTimeout(() => { - replace(returnScreen, returnParams || {}); - }, 100); - }} - onError={(_error) => { - // On error, replace current screen as well - setTimeout(() => { - replace(returnScreen, returnParams || {}); - }, 100); - }} - /> - - ); -} diff --git a/src/services/devboxService.ts b/src/services/devboxService.ts index 63ddb95e..1f5d77ff 100644 --- a/src/services/devboxService.ts +++ b/src/services/devboxService.ts @@ -237,23 +237,6 @@ export async function createSnapshot( }; } -/** - * Create SSH key for devbox - */ -export async function createSSHKey(id: string): Promise<{ - ssh_private_key: string; - url: string; -}> { - const client = getClient(); - const result = await client.devboxes.createSSHKey(id); - - // Truncate keys if they're unexpectedly long (shouldn't happen, but safety) - return { - ssh_private_key: String(result.ssh_private_key || "").substring(0, 10000), - url: String(result.url || "").substring(0, 500), - }; -} - /** * Enable V2 HTTP tunnel on devbox and return the public URL for the given port. */ diff --git a/src/store/navigationStore.tsx b/src/store/navigationStore.tsx index 61a29cc4..90680f53 100644 --- a/src/store/navigationStore.tsx +++ b/src/store/navigationStore.tsx @@ -44,7 +44,6 @@ export type ScreenName = | "object-list" | "object-detail" | "object-create" - | "ssh-session" | "pty-session"; export interface RouteParams { @@ -62,11 +61,6 @@ export interface RouteParams { operation?: string; focusDevboxId?: string; status?: string; - // SSH session params - keyPath?: string; - proxyCommand?: string; - sshUser?: string; - url?: string; devboxName?: string; returnScreen?: ScreenName; returnParams?: RouteParams; diff --git a/src/utils/cliStatus.ts b/src/utils/cliStatus.ts index ff190709..e9958767 100644 --- a/src/utils/cliStatus.ts +++ b/src/utils/cliStatus.ts @@ -1,6 +1,6 @@ /** * Status / progress lines for the CLI. Uses stderr so stdout stays free for - * data users redirect or pipe (e.g. SSH config snippets, JSON). + * data users redirect or pipe (e.g. JSON). * * Prefer this over console.error for non-failure messages—console.error reads * like a runtime error to humans and tools. diff --git a/src/utils/commands.ts b/src/utils/commands.ts index 106bf832..f15d4c22 100644 --- a/src/utils/commands.ts +++ b/src/utils/commands.ts @@ -200,30 +200,6 @@ export function createProgram(): Command { await shutdownDevbox(id, options); }); - devbox - .command("ssh ") - .description("SSH into a devbox") - .option("--config-only", "Print SSH config only") - .option("--no-wait", "Do not wait for devbox to be ready") - .option( - "--timeout ", - "Timeout in seconds to wait for readiness", - "180", - ) - .option( - "--poll-interval ", - "Polling interval in seconds while waiting", - "3", - ) - .option( - "-o, --output [format]", - "Output format: text|json|yaml (default: text)", - ) - .action(async (id, options) => { - const { sshDevbox } = await import("../commands/devbox/ssh.js"); - await sshDevbox(id, options); - }); - devbox .command("pty ") .alias("shell") @@ -246,49 +222,6 @@ export function createProgram(): Command { await ptyDevbox(id, options); }); - devbox - .command("scp ") - .description( - "Copy files to/from a devbox using scp (e.g. rli devbox scp dbx_id:/remote ./local)", - ) - .option("--scp-options ", "Additional scp options (quoted)") - .option( - "-o, --output [format]", - "Output format: text|json|yaml (default: text)", - ) - .action(async (src, dst, options) => { - const { scpFiles } = await import("../commands/devbox/scp.js"); - await scpFiles(src, dst, options); - }); - - devbox - .command("rsync ") - .description( - "Sync files to/from a devbox using rsync (e.g. rli devbox rsync dbx_id:/remote ./local)", - ) - .option("--rsync-options ", "Additional rsync options (quoted)") - .option( - "-o, --output [format]", - "Output format: text|json|yaml (default: text)", - ) - .action(async (src, dst, options) => { - const { rsyncFiles } = await import("../commands/devbox/rsync.js"); - await rsyncFiles(src, dst, options); - }); - - devbox - .command("tunnel ") - .description("Create a port-forwarding tunnel to a devbox") - .option("--open", "Open the tunnel URL in browser automatically") - .option( - "-o, --output [format]", - "Output format: text|json|yaml (default: text)", - ) - .action(async (id, ports, options) => { - const { createTunnel } = await import("../commands/devbox/tunnel.js"); - await createTunnel(id, { ports, ...options }); - }); - devbox .command("read ") .description("Read a file from a devbox using the API") diff --git a/src/utils/config.ts b/src/utils/config.ts index 0e7ff36a..4ee31939 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -124,16 +124,6 @@ export function tunnelBaseHostname(): string { return `tunnel.${runloopBaseDomain()}`; } -/** SSH gateway hostname (TLS/SNI), without port. */ -export function sshGatewayHostname(): string { - return `ssh.${runloopBaseDomain()}`; -} - -/** `host:443` for `openssl s_client -connect` (SSH over HTTPS). */ -export function sshUrl(): string { - return `${sshGatewayHostname()}:443`; -} - export function getCacheDir(): string { return join(homedir(), ".runloop", "rl-cli"); } diff --git a/src/utils/devboxReady.ts b/src/utils/devboxReady.ts new file mode 100644 index 00000000..871c95f4 --- /dev/null +++ b/src/utils/devboxReady.ts @@ -0,0 +1,77 @@ +import { getClient } from "./client.js"; +import { cliStatus } from "./cliStatus.js"; + +export interface WaitForReadyOptions { + /** If true, omit periodic poll lines (e.g. when stdout must stay machine-clean). */ + quiet?: boolean; +} + +/** + * Wait for a devbox to be ready + */ +export async function waitForReady( + devboxId: string, + timeoutSeconds: number = 180, + pollIntervalSeconds: number = 3, + waitOptions?: WaitForReadyOptions, +): Promise { + const quiet = waitOptions?.quiet ?? false; + const startTime = Date.now(); + const client = getClient(); + + while (true) { + try { + const devbox = await client.devboxes.retrieve(devboxId); + const elapsed = (Date.now() - startTime) / 1000; + const remaining = timeoutSeconds - elapsed; + + if (devbox.status === "running") { + return true; + } else if (devbox.status === "failure") { + cliStatus( + `Devbox ${devboxId} failed to start (status: ${devbox.status})`, + ); + return false; + } else if (["shutdown", "suspended"].includes(devbox.status)) { + cliStatus( + `Devbox ${devboxId} is not running (status: ${devbox.status})`, + ); + return false; + } else { + if (!quiet) { + cliStatus( + `Devbox ${devboxId} is still ${devbox.status}... (elapsed: ${elapsed.toFixed(0)}s, remaining: ${remaining.toFixed(0)}s)`, + ); + } + + if (elapsed >= timeoutSeconds) { + cliStatus( + `Timeout waiting for devbox ${devboxId} to be ready after ${timeoutSeconds} seconds`, + ); + return false; + } + + await new Promise((resolve) => + setTimeout(resolve, pollIntervalSeconds * 1000), + ); + } + } catch (error) { + const elapsed = (Date.now() - startTime) / 1000; + if (elapsed >= timeoutSeconds) { + cliStatus( + `Timeout waiting for devbox ${devboxId} to be ready after ${timeoutSeconds} seconds (error: ${error})`, + ); + return false; + } + + if (!quiet) { + cliStatus( + `Error checking devbox status: ${error}, retrying in ${pollIntervalSeconds} seconds...`, + ); + } + await new Promise((resolve) => + setTimeout(resolve, pollIntervalSeconds * 1000), + ); + } + } +} diff --git a/src/utils/ssh.ts b/src/utils/ssh.ts deleted file mode 100644 index 944e8bbd..00000000 --- a/src/utils/ssh.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { exec } from "child_process"; -import { promisify } from "util"; -import { writeFile, mkdir, chmod } from "fs/promises"; -import { join } from "path"; -import { homedir } from "os"; -import { getClient } from "./client.js"; -import { cliStatus } from "./cliStatus.js"; -import { processUtils } from "./processUtils.js"; -import { sshUrl as sshTlsConnectEndpoint } from "./config.js"; - -const execAsync = promisify(exec); - -export interface SSHKeyInfo { - keyfilePath: string; - privateKey: string; - url: string; -} - -export function constructSSHConfig(options: { - hostname: string; - username: string; - keyPath: string; - port: number; -}): string { - return `Host ${options.hostname} - User ${options.username} - Hostname ${options.hostname} - Port ${options.port} - IdentityFile ${options.keyPath} - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - ProxyCommand openssl s_client -connect ${options.hostname}:${options.port} -quiet -`; -} - -export interface SSHOptions { - devboxId: string; - configOnly?: boolean; - noWait?: boolean; - timeout?: number; - pollInterval?: number; -} - -/** - * Get or create SSH key for a devbox - */ -export async function getSSHKey(devboxId: string): Promise { - try { - const client = getClient(); - const result = await client.devboxes.createSSHKey(devboxId); - - if (!result || !result.ssh_private_key || !result.url) { - throw new Error("Failed to create SSH key"); - } - - // Create SSH keys directory - const sshDir = join(homedir(), ".runloop", "ssh_keys"); - await mkdir(sshDir, { recursive: true }); - - // Save private key to file - const keyfilePath = join(sshDir, `${devboxId}.pem`); - await writeFile(keyfilePath, result.ssh_private_key, { mode: 0o600 }); - await chmod(keyfilePath, 0o600); - - return { - keyfilePath, - privateKey: result.ssh_private_key, - url: result.url, - }; - } catch (error) { - console.error("Failed to create SSH key:", error); - return null; - } -} - -export interface WaitForReadyOptions { - /** If true, omit periodic poll lines (e.g. when stdout must stay machine-clean). */ - quiet?: boolean; -} - -/** - * Wait for a devbox to be ready - */ -export async function waitForReady( - devboxId: string, - timeoutSeconds: number = 180, - pollIntervalSeconds: number = 3, - waitOptions?: WaitForReadyOptions, -): Promise { - const quiet = waitOptions?.quiet ?? false; - const startTime = Date.now(); - const client = getClient(); - - while (true) { - try { - const devbox = await client.devboxes.retrieve(devboxId); - const elapsed = (Date.now() - startTime) / 1000; - const remaining = timeoutSeconds - elapsed; - - if (devbox.status === "running") { - return true; - } else if (devbox.status === "failure") { - cliStatus( - `Devbox ${devboxId} failed to start (status: ${devbox.status})`, - ); - return false; - } else if (["shutdown", "suspended"].includes(devbox.status)) { - cliStatus( - `Devbox ${devboxId} is not running (status: ${devbox.status})`, - ); - return false; - } else { - if (!quiet) { - cliStatus( - `Devbox ${devboxId} is still ${devbox.status}... (elapsed: ${elapsed.toFixed(0)}s, remaining: ${remaining.toFixed(0)}s)`, - ); - } - - if (elapsed >= timeoutSeconds) { - cliStatus( - `Timeout waiting for devbox ${devboxId} to be ready after ${timeoutSeconds} seconds`, - ); - return false; - } - - await new Promise((resolve) => - setTimeout(resolve, pollIntervalSeconds * 1000), - ); - } - } catch (error) { - const elapsed = (Date.now() - startTime) / 1000; - if (elapsed >= timeoutSeconds) { - cliStatus( - `Timeout waiting for devbox ${devboxId} to be ready after ${timeoutSeconds} seconds (error: ${error})`, - ); - return false; - } - - if (!quiet) { - cliStatus( - `Error checking devbox status: ${error}, retrying in ${pollIntervalSeconds} seconds...`, - ); - } - await new Promise((resolve) => - setTimeout(resolve, pollIntervalSeconds * 1000), - ); - } - } -} - -/** - * Get proxy command for SSH over HTTPS - */ -export function getProxyCommand(): string { - const sshUrl = sshTlsConnectEndpoint(); - // macOS openssl doesn't support -verify_quiet, use compatible flags - // servername should be %h (target hostname) - SSH will replace %h with the actual hostname from the SSH command - return `openssl s_client -quiet -servername %h -connect ${sshUrl} 2>/dev/null`; -} - -/** - * Execute SSH command - */ -export async function executeSSH( - devboxId: string, - user: string, - keyfilePath: string, - url: string, - additionalArgs: string[] = [], -): Promise { - const proxyCommand = getProxyCommand(); - const command = [ - "/usr/bin/ssh", - "-i", - keyfilePath, - "-o", - `ProxyCommand=${proxyCommand}`, - "-o", - "StrictHostKeyChecking=no", - ...additionalArgs, - `${user}@${url}`, - ]; - - try { - const { stdout, stderr } = await execAsync(command.join(" ")); - if (stdout) console.log(stdout); - if (stderr) console.error(stderr); - } catch (error) { - console.error("SSH command failed:", error); - processUtils.exit(1); - } -} - -/** - * Generate SSH config for a devbox - */ -export function generateSSHConfig( - devboxId: string, - user: string, - keyfilePath: string, - url: string, -): string { - const proxyCommand = getProxyCommand(); - return ` -Host ${devboxId} - Hostname ${url} - User ${user} - IdentityFile ${keyfilePath} - StrictHostKeyChecking no - ProxyCommand ${proxyCommand} - `.trim(); -} - -/** - * Check if SSH tools are available - */ -export async function checkSSHTools(): Promise { - try { - await execAsync("which ssh"); - await execAsync("which scp"); - await execAsync("which rsync"); - await execAsync("which openssl"); - return true; - } catch { - return false; - } -} diff --git a/src/utils/sshSession.ts b/src/utils/sshSession.ts deleted file mode 100644 index e020f4e5..00000000 --- a/src/utils/sshSession.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * SSH Session types - kept for compatibility and type references - * Actual SSH session handling is now done via ink-spawn in SSHSessionScreen - */ - -export interface SSHSessionConfig { - keyPath: string; - proxyCommand: string; - sshUser: string; - url: string; - devboxId: string; - devboxName: string; -} diff --git a/tests/__tests__/commands/devbox/rsync.test.ts b/tests/__tests__/commands/devbox/rsync.test.ts deleted file mode 100644 index 3e34f714..00000000 --- a/tests/__tests__/commands/devbox/rsync.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -/** - * Tests for devbox rsync command - * - * Covers buildRsyncCommand (command construction with parsed paths). - * Path parsing is tested in scp.test.ts since rsync reuses parseSCPPath. - */ - -import { describe, it, expect } from "@jest/globals"; -import { parseSCPPath } from "@/commands/devbox/scp.js"; -import { buildRsyncCommand } from "@/commands/devbox/rsync.js"; - -// ── buildRsyncCommand ─────────────────────────────────────────────────── - -describe("buildRsyncCommand", () => { - const sshInfo = { - keyfilePath: "/tmp/key.pem", - url: "dbx_abc123.runloop.dev", - }; - const proxyCommand = - "openssl s_client -quiet -servername %h -connect ssh.runloop.dev:443 2>/dev/null"; - - it("should build command for remote src → local dst with default user", () => { - const cmd = buildRsyncCommand({ - sshInfo, - proxyCommand, - parsedSrc: parseSCPPath("dbx_abc123:/home/devuser/data/"), - parsedDst: parseSCPPath("./data/"), - defaultUser: "devuser", - }); - - const joined = cmd.join(" "); - expect(joined).toContain("rsync"); - expect(joined).toContain("-vrz"); - expect(joined).toContain( - "devuser@dbx_abc123.runloop.dev:/home/devuser/data/", - ); - expect(joined).toContain("./data/"); - }); - - it("should build command for local src → remote dst with default user", () => { - const cmd = buildRsyncCommand({ - sshInfo, - proxyCommand, - parsedSrc: parseSCPPath("./data/"), - parsedDst: parseSCPPath("dbx_abc123:/home/devuser/data/"), - defaultUser: "devuser", - }); - - const joined = cmd.join(" "); - expect(joined).toContain("./data/"); - expect(joined).toContain( - "devuser@dbx_abc123.runloop.dev:/home/devuser/data/", - ); - // Ensure local path comes before remote path in the command - expect(joined.indexOf("./data/")).toBeLessThan( - joined.indexOf("devuser@dbx_abc123.runloop.dev"), - ); - }); - - it("should use explicit user when specified in remote path", () => { - const cmd = buildRsyncCommand({ - sshInfo, - proxyCommand, - parsedSrc: parseSCPPath("root@dbx_abc123:/etc/config/"), - parsedDst: parseSCPPath("./config/"), - defaultUser: "devuser", - }); - - const joined = cmd.join(" "); - expect(joined).toContain("root@dbx_abc123.runloop.dev:/etc/config/"); - expect(joined).not.toContain("devuser@"); - }); - - it("should use default user when no user is specified in remote path", () => { - const cmd = buildRsyncCommand({ - sshInfo, - proxyCommand, - parsedSrc: parseSCPPath("dbx_abc123:/data/"), - parsedDst: parseSCPPath("/tmp/data/"), - defaultUser: "custom-user", - }); - - const joined = cmd.join(" "); - expect(joined).toContain("custom-user@dbx_abc123.runloop.dev:/data/"); - }); - - it("should include additional rsync options when provided", () => { - const cmd = buildRsyncCommand({ - sshInfo, - proxyCommand, - parsedSrc: parseSCPPath("dbx_abc123:/data/"), - parsedDst: parseSCPPath("/tmp/data/"), - defaultUser: "user", - rsyncOptions: "--delete --exclude=node_modules", - }); - - expect(cmd).toContain("--delete"); - expect(cmd).toContain("--exclude=node_modules"); - }); - - it("should include SSH transport with key, proxy, and host checking", () => { - const cmd = buildRsyncCommand({ - sshInfo, - proxyCommand, - parsedSrc: parseSCPPath("dbx_abc123:/data/"), - parsedDst: parseSCPPath("/tmp/data/"), - defaultUser: "user", - }); - - // The -e argument should be a single string containing the full ssh command - const eIdx = cmd.indexOf("-e"); - expect(eIdx).toBeGreaterThan(-1); - const sshTransport = cmd[eIdx + 1]; - expect(sshTransport).toContain("ssh"); - expect(sshTransport).toContain("-i /tmp/key.pem"); - expect(sshTransport).toContain(`ProxyCommand=${proxyCommand}`); - expect(sshTransport).toContain("StrictHostKeyChecking=no"); - }); - - it("should include -vrz flags for verbose, recursive, and compressed transfer", () => { - const cmd = buildRsyncCommand({ - sshInfo, - proxyCommand, - parsedSrc: parseSCPPath("dbx_abc123:/data/"), - parsedDst: parseSCPPath("/tmp/data/"), - defaultUser: "user", - }); - - expect(cmd).toContain("-vrz"); - }); - - it("should handle remote src with empty path (home directory)", () => { - const cmd = buildRsyncCommand({ - sshInfo, - proxyCommand, - parsedSrc: parseSCPPath("dbx_abc123:"), - parsedDst: parseSCPPath("/tmp/backup/"), - defaultUser: "user", - }); - - const joined = cmd.join(" "); - expect(joined).toContain("user@dbx_abc123.runloop.dev:"); - }); -}); diff --git a/tests/__tests__/commands/devbox/scp.test.ts b/tests/__tests__/commands/devbox/scp.test.ts deleted file mode 100644 index 0d493ff1..00000000 --- a/tests/__tests__/commands/devbox/scp.test.ts +++ /dev/null @@ -1,359 +0,0 @@ -/** - * Tests for devbox scp command - * - * Covers parseSCPPath (path parsing), buildSCPCommand (single-remote), - * buildDualRemoteSCPCommand (devbox-to-devbox), and generateSCPConfig. - */ - -import { describe, it, expect } from "@jest/globals"; -import { - parseSCPPath, - buildSCPCommand, - buildDualRemoteSCPCommand, - generateSCPConfig, - type ResolvedRemote, -} from "@/commands/devbox/scp.js"; - -// ── parseSCPPath ──────────────────────────────────────────────────────── - -describe("parseSCPPath", () => { - it("should parse dbx_id:path as remote with default user", () => { - const result = parseSCPPath("dbx_abc123:/home/user/file.txt"); - expect(result).toEqual({ - host: "dbx_abc123", - path: "/home/user/file.txt", - isRemote: true, - user: undefined, - }); - }); - - it("should parse user@dbx_id:path as remote with explicit user", () => { - const result = parseSCPPath("root@dbx_abc123:/etc/config"); - expect(result).toEqual({ - user: "root", - host: "dbx_abc123", - path: "/etc/config", - isRemote: true, - }); - }); - - it("should parse dbx_id: with empty path as remote (home directory)", () => { - const result = parseSCPPath("dbx_abc123:"); - expect(result).toEqual({ - host: "dbx_abc123", - path: "", - isRemote: true, - user: undefined, - }); - }); - - it("should parse absolute path as local", () => { - const result = parseSCPPath("/tmp/file.txt"); - expect(result).toEqual({ path: "/tmp/file.txt", isRemote: false }); - }); - - it("should parse relative path as local", () => { - const result = parseSCPPath("./myfile.txt"); - expect(result).toEqual({ path: "./myfile.txt", isRemote: false }); - }); - - it("should parse bare filename as local", () => { - const result = parseSCPPath("file.txt"); - expect(result).toEqual({ path: "file.txt", isRemote: false }); - }); - - it("should treat path with slash before colon as local", () => { - const result = parseSCPPath("./dir:name"); - expect(result).toEqual({ path: "./dir:name", isRemote: false }); - }); - - it("should not treat a path with @ and / before colon as remote", () => { - const result = parseSCPPath("./user@host:file"); - expect(result).toEqual({ path: "./user@host:file", isRemote: false }); - }); - - it("should treat non-dbx hostname:path as local (not a devbox)", () => { - // A regular hostname like "myserver:/path" should NOT be treated as remote - const result = parseSCPPath("myserver:/some/path"); - expect(result).toEqual({ path: "myserver:/some/path", isRemote: false }); - }); - - it("should only match hosts starting with dbx_", () => { - // "notdbx_123:/path" should be local - const result = parseSCPPath("notdbx_123:/path"); - expect(result).toEqual({ path: "notdbx_123:/path", isRemote: false }); - }); -}); - -// ── buildSCPCommand ───────────────────────────────────────────────────── - -describe("buildSCPCommand", () => { - const sshInfo = { - keyfilePath: "/tmp/key.pem", - url: "dbx_abc123.runloop.dev", - }; - const proxyCommand = - "openssl s_client -quiet -servername %h -connect ssh.runloop.dev:443 2>/dev/null"; - - it("should build command for remote src → local dst with default user", () => { - const cmd = buildSCPCommand({ - sshInfo, - proxyCommand, - parsedSrc: parseSCPPath("dbx_abc123:/home/devuser/data.txt"), - parsedDst: parseSCPPath("/tmp/data.txt"), - defaultUser: "devuser", - }); - - const joined = cmd.join(" "); - expect(joined).toContain("scp"); - expect(joined).toContain("-i /tmp/key.pem"); - expect(joined).toContain( - "devuser@dbx_abc123.runloop.dev:/home/devuser/data.txt", - ); - expect(joined).toContain("/tmp/data.txt"); - }); - - it("should build command for local src → remote dst with default user", () => { - const cmd = buildSCPCommand({ - sshInfo, - proxyCommand, - parsedSrc: parseSCPPath("/tmp/upload.txt"), - parsedDst: parseSCPPath("dbx_abc123:/home/devuser/upload.txt"), - defaultUser: "devuser", - }); - - const joined = cmd.join(" "); - expect(joined).toContain("/tmp/upload.txt"); - expect(joined).toContain( - "devuser@dbx_abc123.runloop.dev:/home/devuser/upload.txt", - ); - // Ensure local path comes before remote path in the command - expect(joined.indexOf("/tmp/upload.txt")).toBeLessThan( - joined.indexOf("devuser@dbx_abc123.runloop.dev"), - ); - }); - - it("should use explicit user when specified in remote path", () => { - const cmd = buildSCPCommand({ - sshInfo, - proxyCommand, - parsedSrc: parseSCPPath("root@dbx_abc123:/etc/hosts"), - parsedDst: parseSCPPath("/tmp/hosts"), - defaultUser: "devuser", - }); - - const joined = cmd.join(" "); - expect(joined).toContain("root@dbx_abc123.runloop.dev:/etc/hosts"); - expect(joined).not.toContain("devuser@"); - }); - - it("should use default user when no user is specified in remote path", () => { - const cmd = buildSCPCommand({ - sshInfo, - proxyCommand, - parsedSrc: parseSCPPath("dbx_abc123:/file"), - parsedDst: parseSCPPath("/tmp/file"), - defaultUser: "custom-user", - }); - - const joined = cmd.join(" "); - expect(joined).toContain( - "custom-user@dbx_abc123.runloop.dev:/file", - ); - }); - - it("should include additional scp options when provided", () => { - const cmd = buildSCPCommand({ - sshInfo, - proxyCommand, - parsedSrc: parseSCPPath("dbx_abc123:/file"), - parsedDst: parseSCPPath("/tmp/file"), - defaultUser: "user", - scpOptions: "-r -v", - }); - - expect(cmd).toContain("-r"); - expect(cmd).toContain("-v"); - }); - - it("should include ProxyCommand and StrictHostKeyChecking options", () => { - const cmd = buildSCPCommand({ - sshInfo, - proxyCommand, - parsedSrc: parseSCPPath("dbx_abc123:/file"), - parsedDst: parseSCPPath("/tmp/file"), - defaultUser: "user", - }); - - const joined = cmd.join(" "); - expect(joined).toContain(`ProxyCommand=${proxyCommand}`); - expect(joined).toContain("StrictHostKeyChecking=no"); - }); - - it("should include SSH key path", () => { - const cmd = buildSCPCommand({ - sshInfo, - proxyCommand, - parsedSrc: parseSCPPath("dbx_abc123:/file"), - parsedDst: parseSCPPath("/tmp/file"), - defaultUser: "user", - }); - - expect(cmd).toContain("-i"); - expect(cmd).toContain("/tmp/key.pem"); - }); - - it("should handle remote src with empty path (home directory)", () => { - const cmd = buildSCPCommand({ - sshInfo, - proxyCommand, - parsedSrc: parseSCPPath("dbx_abc123:"), - parsedDst: parseSCPPath("/tmp/file"), - defaultUser: "user", - }); - - const joined = cmd.join(" "); - expect(joined).toContain("user@dbx_abc123.runloop.dev:"); - }); -}); - -// ── buildDualRemoteSCPCommand (devbox-to-devbox) ──────────────────────── - -describe("buildDualRemoteSCPCommand", () => { - const proxyCommand = - "openssl s_client -quiet -servername %h -connect ssh.runloop.dev:443 2>/dev/null"; - - const srcRemote: ResolvedRemote = { - devboxId: "dbx_src111", - defaultUser: "srcuser", - sshInfo: { - keyfilePath: "/tmp/dbx_src111.pem", - privateKey: "src-key", - url: "dbx_src111.runloop.dev", - }, - }; - - const dstRemote: ResolvedRemote = { - devboxId: "dbx_dst222", - defaultUser: "dstuser", - sshInfo: { - keyfilePath: "/tmp/dbx_dst222.pem", - privateKey: "dst-key", - url: "dbx_dst222.runloop.dev", - }, - }; - - it("should include -3 flag to route through local machine", () => { - const cmd = buildDualRemoteSCPCommand({ - srcRemote, - dstRemote, - proxyCommand, - parsedSrc: parseSCPPath("dbx_src111:/data/file.txt"), - parsedDst: parseSCPPath("dbx_dst222:/data/file.txt"), - sshConfigPath: "/tmp/scp.conf", - }); - - expect(cmd).toContain("-3"); - }); - - it("should reference the SSH config file with -F", () => { - const cmd = buildDualRemoteSCPCommand({ - srcRemote, - dstRemote, - proxyCommand, - parsedSrc: parseSCPPath("dbx_src111:/file"), - parsedDst: parseSCPPath("dbx_dst222:/file"), - sshConfigPath: "/tmp/scp.conf", - }); - - const fIdx = cmd.indexOf("-F"); - expect(fIdx).toBeGreaterThan(-1); - expect(cmd[fIdx + 1]).toBe("/tmp/scp.conf"); - }); - - it("should use each devbox's default user", () => { - const cmd = buildDualRemoteSCPCommand({ - srcRemote, - dstRemote, - proxyCommand, - parsedSrc: parseSCPPath("dbx_src111:/file"), - parsedDst: parseSCPPath("dbx_dst222:/file"), - sshConfigPath: "/tmp/scp.conf", - }); - - const joined = cmd.join(" "); - expect(joined).toContain("srcuser@dbx_src111.runloop.dev:/file"); - expect(joined).toContain("dstuser@dbx_dst222.runloop.dev:/file"); - }); - - it("should use explicit user when specified in path", () => { - const cmd = buildDualRemoteSCPCommand({ - srcRemote, - dstRemote, - proxyCommand, - parsedSrc: parseSCPPath("root@dbx_src111:/etc/hosts"), - parsedDst: parseSCPPath("admin@dbx_dst222:/etc/hosts"), - sshConfigPath: "/tmp/scp.conf", - }); - - const joined = cmd.join(" "); - expect(joined).toContain("root@dbx_src111.runloop.dev:/etc/hosts"); - expect(joined).toContain("admin@dbx_dst222.runloop.dev:/etc/hosts"); - expect(joined).not.toContain("srcuser@"); - expect(joined).not.toContain("dstuser@"); - }); - - it("should include additional scp options", () => { - const cmd = buildDualRemoteSCPCommand({ - srcRemote, - dstRemote, - proxyCommand, - parsedSrc: parseSCPPath("dbx_src111:/file"), - parsedDst: parseSCPPath("dbx_dst222:/file"), - sshConfigPath: "/tmp/scp.conf", - scpOptions: "-r -v", - }); - - expect(cmd).toContain("-r"); - expect(cmd).toContain("-v"); - }); -}); - -// ── generateSCPConfig ─────────────────────────────────────────────────── - -describe("generateSCPConfig", () => { - const proxyCommand = - "openssl s_client -quiet -servername %h -connect ssh.runloop.dev:443 2>/dev/null"; - - it("should generate config entries for each remote", () => { - const remotes: ResolvedRemote[] = [ - { - devboxId: "dbx_aaa", - defaultUser: "user1", - sshInfo: { - keyfilePath: "/keys/dbx_aaa.pem", - privateKey: "k1", - url: "dbx_aaa.runloop.dev", - }, - }, - { - devboxId: "dbx_bbb", - defaultUser: "user2", - sshInfo: { - keyfilePath: "/keys/dbx_bbb.pem", - privateKey: "k2", - url: "dbx_bbb.runloop.dev", - }, - }, - ]; - - const config = generateSCPConfig(remotes, proxyCommand); - - expect(config).toContain("Host dbx_aaa.runloop.dev"); - expect(config).toContain("IdentityFile /keys/dbx_aaa.pem"); - expect(config).toContain("Host dbx_bbb.runloop.dev"); - expect(config).toContain("IdentityFile /keys/dbx_bbb.pem"); - expect(config).toContain(`ProxyCommand ${proxyCommand}`); - expect(config).toContain("StrictHostKeyChecking no"); - }); -}); diff --git a/tests/__tests__/components/InteractiveSpawn.test.tsx b/tests/__tests__/components/InteractiveSpawn.test.tsx index 6664d9f0..ed09b164 100644 --- a/tests/__tests__/components/InteractiveSpawn.test.tsx +++ b/tests/__tests__/components/InteractiveSpawn.test.tsx @@ -36,7 +36,7 @@ describe('InteractiveSpawn', () => { render( { - const result = await client.devboxes.executeSync(devboxId, { command }); - return (result as any).stdout?.trim() ?? ""; -} - -/** Poll until devbox reaches a target status. */ -async function waitForStatus( - targetStatus: string, - timeoutMs = 120_000, -): Promise { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - const devbox = await client.devboxes.retrieve(devboxId); - if (devbox.status === targetStatus) return; - await new Promise((r) => setTimeout(r, 3_000)); - } - throw new Error( - `Devbox ${devboxId} did not reach status "${targetStatus}" within ${timeoutMs}ms`, - ); -} - -// ── lifecycle ──────────────────────────────────────────────────────────── - -beforeAll(async () => { - client = getClient(); - - // Create a small devbox - const devbox = await client.devboxes.create({ - entrypoint: "sleep infinity", - launch_parameters: { - keep_alive_time_seconds: 300, - }, - }); - devboxId = devbox.id; - console.log(`Created devbox ${devboxId}, waiting for it to be running…`); - - await waitForStatus("running"); - console.log(`Devbox ${devboxId} is running.`); - - // Create the remote test directory - await execOnDevbox(`mkdir -p ${REMOTE_DIR}`); -}); - -afterAll(async () => { - if (devboxId) { - console.log(`Shutting down devbox ${devboxId}…`); - await client.devboxes.shutdown(devboxId).catch(() => {}); - } -}); - -// ── SCP tests ──────────────────────────────────────────────────────────── - -describe("scp e2e", () => { - const TEST_CONTENT = `hello from scp e2e test ${RUN_ID}`; - - it("should upload a file to the devbox", async () => { - const localDir = makeTempDir("scp-upload"); - const localFile = join(localDir, "upload.txt"); - writeFileSync(localFile, TEST_CONTENT); - - const remotePath = `${REMOTE_DIR}/upload.txt`; - await scpFiles(localFile, `${devboxId}:${remotePath}`, {}); - - // Verify the file landed on the devbox - const remoteContent = await execOnDevbox(`cat ${remotePath}`); - expect(remoteContent).toBe(TEST_CONTENT); - - rmSync(localDir, { recursive: true, force: true }); - }); - - it("should download a file from the devbox", async () => { - const remotePath = `${REMOTE_DIR}/download-src.txt`; - await execOnDevbox( - `echo -n '${TEST_CONTENT}' > ${remotePath}`, - ); - - const localDir = makeTempDir("scp-download"); - const localFile = join(localDir, "download.txt"); - - await scpFiles(`${devboxId}:${remotePath}`, localFile, {}); - - expect(existsSync(localFile)).toBe(true); - expect(readFileSync(localFile, "utf-8")).toBe(TEST_CONTENT); - - rmSync(localDir, { recursive: true, force: true }); - }); -}); - -// ── Rsync tests ────────────────────────────────────────────────────────── - -describe("rsync e2e", () => { - it("should upload a directory to the devbox", async () => { - const localDir = makeTempDir("rsync-upload"); - writeFileSync(join(localDir, "a.txt"), "file-a"); - writeFileSync(join(localDir, "b.txt"), "file-b"); - - const remoteSubdir = `${REMOTE_DIR}/rsync-up/`; - await execOnDevbox(`mkdir -p ${remoteSubdir}`); - - // Trailing slash on src means "contents of this directory" - await rsyncFiles(`${localDir}/`, `${devboxId}:${remoteSubdir}`, {}); - - const remoteA = await execOnDevbox(`cat ${remoteSubdir}a.txt`); - const remoteB = await execOnDevbox(`cat ${remoteSubdir}b.txt`); - expect(remoteA).toBe("file-a"); - expect(remoteB).toBe("file-b"); - - rmSync(localDir, { recursive: true, force: true }); - }); - - it("should download a directory from the devbox", async () => { - const remoteSubdir = `${REMOTE_DIR}/rsync-down/`; - await execOnDevbox( - `mkdir -p ${remoteSubdir} && echo -n 'x-data' > ${remoteSubdir}x.txt && echo -n 'y-data' > ${remoteSubdir}y.txt`, - ); - - const localDir = makeTempDir("rsync-download"); - - await rsyncFiles(`${devboxId}:${remoteSubdir}`, `${localDir}/`, {}); - - expect(readFileSync(join(localDir, "x.txt"), "utf-8")).toBe("x-data"); - expect(readFileSync(join(localDir, "y.txt"), "utf-8")).toBe("y-data"); - - rmSync(localDir, { recursive: true, force: true }); - }); -}); diff --git a/tests/fixtures/mocks.js b/tests/fixtures/mocks.js index 09c8957b..44d685c1 100644 --- a/tests/fixtures/mocks.js +++ b/tests/fixtures/mocks.js @@ -74,7 +74,6 @@ export const mockAPIClient = () => ({ writeFileContents: jest.fn(), uploadFile: jest.fn(), downloadFile: jest.fn(), - createSSHKey: jest.fn(), snapshotDiskAsync: jest.fn(), diskSnapshots: { queryStatus: jest.fn(), @@ -99,10 +98,6 @@ export const mockAPIClient = () => ({ complete: jest.fn() } }); -export const mockSSHKey = () => ({ - ssh_private_key: '-----BEGIN PRIVATE KEY-----\ntest-key-content\n-----END PRIVATE KEY-----', - url: 'test-host.example.com' -}); export const mockLogEntry = (overrides = {}) => ({ timestamp_ms: 1710000000000, source: 'entrypoint', diff --git a/tests/fixtures/mocks.ts b/tests/fixtures/mocks.ts index 9aeec0e2..b5e9a368 100644 --- a/tests/fixtures/mocks.ts +++ b/tests/fixtures/mocks.ts @@ -87,7 +87,6 @@ export const mockAPIClient = () => ({ writeFileContents: jest.fn(), uploadFile: jest.fn(), downloadFile: jest.fn(), - createSSHKey: jest.fn(), snapshotDiskAsync: jest.fn(), diskSnapshots: { queryStatus: jest.fn(), @@ -113,12 +112,6 @@ export const mockAPIClient = () => ({ }, }); -export const mockSSHKey = () => ({ - ssh_private_key: - "-----BEGIN PRIVATE KEY-----\ntest-key-content\n-----END PRIVATE KEY-----", - url: "test-host.example.com", -}); - export const mockLogEntry = (overrides = {}) => ({ timestamp_ms: 1710000000000, source: "entrypoint", diff --git a/tests/setup-components.ts b/tests/setup-components.ts index 2ff490a6..ed3c67c2 100644 --- a/tests/setup-components.ts +++ b/tests/setup-components.ts @@ -167,9 +167,6 @@ jest.mock("../src/services/devboxService.ts", () => ({ uploadFile: jest.fn().mockResolvedValue(undefined), createSnapshot: jest.fn().mockResolvedValue({ id: "snap-test" }), createTunnel: jest.fn().mockResolvedValue({ url: "https://tunnel.test" }), - createSSHKey: jest - .fn() - .mockResolvedValue({ ssh_private_key: "key", url: "test" }), getDevbox: jest.fn().mockResolvedValue(null), })); diff --git a/tests/setup-router.ts b/tests/setup-router.ts index af46c09b..a3082ba4 100644 --- a/tests/setup-router.ts +++ b/tests/setup-router.ts @@ -169,9 +169,6 @@ jest.mock("../src/services/devboxService.ts", () => ({ uploadFile: jest.fn().mockResolvedValue(undefined), createSnapshot: jest.fn().mockResolvedValue({ id: "snap-test" }), createTunnel: jest.fn().mockResolvedValue({ url: "https://tunnel.test" }), - createSSHKey: jest - .fn() - .mockResolvedValue({ ssh_private_key: "key", url: "test" }), getDevbox: jest.fn().mockResolvedValue(null), })); diff --git a/tests/setup.ts b/tests/setup.ts index fad235be..5a44d120 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -156,9 +156,6 @@ jest.mock("../src/services/devboxService.ts", () => ({ uploadFile: jest.fn().mockResolvedValue(undefined), createSnapshot: jest.fn().mockResolvedValue({ id: "snap-test" }), createTunnel: jest.fn().mockResolvedValue({ url: "https://tunnel.test" }), - createSSHKey: jest - .fn() - .mockResolvedValue({ ssh_private_key: "key", url: "test" }), getDevbox: jest.fn().mockResolvedValue(null), }));