diff --git a/packages/cli/package.json b/packages/cli/package.json index 12f1f142..ab2fff47 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.18.0", + "version": "0.18.1", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/commands/connect.ts b/packages/cli/src/commands/connect.ts index ca44edb9..1beffd95 100644 --- a/packages/cli/src/commands/connect.ts +++ b/packages/cli/src/commands/connect.ts @@ -223,3 +223,57 @@ export async function cmdEnterAgent( tunnelHandle.stop(); } } + +/** Open the web dashboard for a VM by establishing an SSH tunnel and launching the browser. + * Blocks until the user presses Enter, then tears down the tunnel. */ +export async function cmdOpenDashboard(connection: VMConnection): Promise { + const validation = tryCatch(() => { + validateConnectionIP(connection.ip); + validateUsername(connection.user); + }); + if (!validation.ok) { + p.log.error(`Security validation failed: ${getErrorMessage(validation.error)}`); + return; + } + + const tunnelPort = connection.metadata?.tunnel_remote_port; + const urlTemplate = connection.metadata?.tunnel_browser_url_template; + if (!tunnelPort) { + p.log.error("No dashboard tunnel info found for this server."); + return; + } + + p.log.step("Opening SSH tunnel to dashboard..."); + const keys = await ensureSshKeys(); + const tunnelResult = await asyncTryCatchIf(isOperationalError, () => + startSshTunnel({ + host: connection.ip, + user: connection.user, + remotePort: Number(tunnelPort), + sshKeyOpts: getSshKeyOpts(keys), + }), + ); + if (!tunnelResult.ok) { + p.log.error("Failed to open SSH tunnel to dashboard."); + return; + } + + const handle = tunnelResult.data; + if (urlTemplate) { + const url = urlTemplate.replace("__PORT__", String(handle.localPort)); + openBrowser(url); + p.log.success(`Dashboard opened at ${pc.cyan(url)}`); + } else { + p.log.success(`Dashboard tunnel open on localhost:${handle.localPort}`); + } + + p.log.info("Press Enter to close the dashboard tunnel."); + await new Promise((resolve) => { + process.stdin.setRawMode?.(false); + process.stdin.resume(); + process.stdin.once("data", () => resolve()); + }); + + handle.stop(); + p.log.step("Dashboard tunnel closed."); +} diff --git a/packages/cli/src/commands/list.ts b/packages/cli/src/commands/list.ts index aeb3e23e..231d335a 100644 --- a/packages/cli/src/commands/list.ts +++ b/packages/cli/src/commands/list.ts @@ -7,7 +7,7 @@ import pc from "picocolors"; import { clearHistory, filterHistory, getActiveServers, removeRecord } from "../history.js"; import { agentKeys, cloudKeys, loadManifest } from "../manifest.js"; import { asyncTryCatch, tryCatch, unwrapOr } from "../shared/result.js"; -import { cmdConnect, cmdEnterAgent } from "./connect.js"; +import { cmdConnect, cmdEnterAgent, cmdOpenDashboard } from "./connect.js"; import { confirmAndDelete } from "./delete.js"; import { fixSpawn } from "./fix.js"; import { cmdRun } from "./run.js"; @@ -295,6 +295,14 @@ export async function handleRecordAction( }); } + if (!conn.deleted && conn.metadata?.tunnel_remote_port) { + options.push({ + value: "dashboard", + label: "Open Dashboard", + hint: "Open web dashboard in browser", + }); + } + if (!conn.deleted) { options.push({ value: "reconnect", @@ -353,6 +361,14 @@ export async function handleRecordAction( return RecordActionOutcome.Exit; } + if (action === "dashboard") { + const dashResult = await asyncTryCatch(() => cmdOpenDashboard(selected.connection)); + if (!dashResult.ok) { + p.log.error(`Dashboard failed: ${getErrorMessage(dashResult.error)}`); + } + return RecordActionOutcome.Back; + } + if (action === "reconnect") { const reconnectResult = await asyncTryCatch(() => cmdConnect(selected.connection)); if (!reconnectResult.ok) {