From d05c8d040f63e8f40ecc8d48c963eda5cf907e08 Mon Sep 17 00:00:00 2001
From: Marcelo Alves
Date: Sat, 17 Jan 2026 18:02:22 +0000
Subject: [PATCH 01/11] feat(desktop): add SSH remote workspace support
Add the ability to connect to remote servers via SSH and open remote
workspaces, similar to VS Code Remote SSH functionality.
## Features
- **SSH Config Import**: Parse and import hosts from ~/.ssh/config file
- **SSH Connection Management**: Connect to remote servers using key-based
or SSH agent authentication
- **Remote Projects**: Track remote project paths and associate them with
SSH connections
- **Remote Workspaces**: Create workspaces for remote projects with SSH
terminal sessions
- **Terminal Routing**: Route terminal operations (create, write, resize,
stream) to the appropriate backend (local or SSH) based on workspace type
## Implementation Details
### New Files
- `apps/desktop/src/lib/trpc/routers/ssh/` - SSH tRPC router for connection
management, config parsing, and remote project operations
- `apps/desktop/src/main/lib/ssh/` - SSH client, terminal manager, and
config parser using ssh2 library
- `apps/desktop/src/main/lib/workspace-runtime/ssh.ts` - SSH workspace
runtime adapter
- `apps/desktop/src/renderer/routes/_authenticated/settings/ssh/` - SSH
settings page for managing connections
- `apps/desktop/src/renderer/screens/main/components/StartView/OpenRemoteDialog.tsx` -
Dialog for connecting to SSH hosts
### Modified Files
- Terminal router now routes operations to SSH or local backend based on
workspace type using a paneId->workspaceId mapping
- Workspace query now checks both local and remote workspace tables
- StartView displays remote projects and allows opening them
- Stream subscription accepts workspaceId for correct terminal routing
### Database
- Added ssh_connections, remote_projects, and remote_workspaces tables
Co-Authored-By: Claude Opus 4.5
---
apps/desktop/package.json | 2 +
apps/desktop/src/lib/trpc/routers/index.ts | 2 +
.../desktop/src/lib/trpc/routers/ssh/index.ts | 738 +++++++++
.../src/lib/trpc/routers/terminal/terminal.ts | 184 ++-
.../routers/workspaces/procedures/query.ts | 71 +-
apps/desktop/src/main/lib/ssh/index.ts | 13 +
apps/desktop/src/main/lib/ssh/ssh-client.ts | 356 +++++
.../src/main/lib/ssh/ssh-config-parser.ts | 149 ++
.../src/main/lib/ssh/ssh-terminal-manager.ts | 378 +++++
apps/desktop/src/main/lib/ssh/types.ts | 99 ++
.../main/lib/workspace-runtime/registry.ts | 173 ++-
.../src/main/lib/workspace-runtime/ssh.ts | 267 ++++
.../_authenticated/settings/ssh/page.tsx | 482 ++++++
.../components/StartView/OpenRemoteDialog.tsx | 225 +++
.../main/components/StartView/index.tsx | 78 +-
.../TabsContent/Terminal/Terminal.tsx | 2 +-
bun.lock | 22 +
.../local-db/drizzle/0012_add_ssh_tables.sql | 49 +
.../local-db/drizzle/meta/0011_snapshot.json | 2 +-
.../local-db/drizzle/meta/0012_snapshot.json | 1335 +++++++++++++++++
packages/local-db/drizzle/meta/_journal.json | 9 +-
packages/local-db/src/schema/schema.ts | 104 ++
packages/local-db/src/schema/zod.ts | 26 +
23 files changed, 4697 insertions(+), 69 deletions(-)
create mode 100644 apps/desktop/src/lib/trpc/routers/ssh/index.ts
create mode 100644 apps/desktop/src/main/lib/ssh/index.ts
create mode 100644 apps/desktop/src/main/lib/ssh/ssh-client.ts
create mode 100644 apps/desktop/src/main/lib/ssh/ssh-config-parser.ts
create mode 100644 apps/desktop/src/main/lib/ssh/ssh-terminal-manager.ts
create mode 100644 apps/desktop/src/main/lib/ssh/types.ts
create mode 100644 apps/desktop/src/main/lib/workspace-runtime/ssh.ts
create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/ssh/page.tsx
create mode 100644 apps/desktop/src/renderer/screens/main/components/StartView/OpenRemoteDialog.tsx
create mode 100644 packages/local-db/drizzle/0012_add_ssh_tables.sql
create mode 100644 packages/local-db/drizzle/meta/0012_snapshot.json
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index e5c4598d5..25ec256d8 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -121,6 +121,7 @@
"semver": "^7.7.3",
"shell-quote": "^1.8.3",
"simple-git": "^3.30.0",
+ "ssh2": "^1.16.0",
"strip-ansi": "^7.1.2",
"superjson": "^2.2.5",
"tailwind-merge": "^3.4.0",
@@ -145,6 +146,7 @@
"@types/react-syntax-highlighter": "^15.5.13",
"@types/semver": "^7.7.1",
"@types/shell-quote": "^1.7.5",
+ "@types/ssh2": "^1.15.4",
"@vitejs/plugin-react": "^5.0.1",
"bun-types": "^1.3.1",
"code-inspector-plugin": "^1.2.2",
diff --git a/apps/desktop/src/lib/trpc/routers/index.ts b/apps/desktop/src/lib/trpc/routers/index.ts
index 2545a0f40..72d708c45 100644
--- a/apps/desktop/src/lib/trpc/routers/index.ts
+++ b/apps/desktop/src/lib/trpc/routers/index.ts
@@ -14,6 +14,7 @@ import { createPortsRouter } from "./ports";
import { createProjectsRouter } from "./projects";
import { createRingtoneRouter } from "./ringtone";
import { createSettingsRouter } from "./settings";
+import { createSSHRouter } from "./ssh";
import { createTerminalRouter } from "./terminal";
import { createUiStateRouter } from "./ui-state";
import { createWindowRouter } from "./window";
@@ -39,6 +40,7 @@ export const createAppRouter = (getWindow: () => BrowserWindow | null) => {
config: createConfigRouter(),
uiState: createUiStateRouter(),
ringtone: createRingtoneRouter(),
+ ssh: createSSHRouter(),
});
};
diff --git a/apps/desktop/src/lib/trpc/routers/ssh/index.ts b/apps/desktop/src/lib/trpc/routers/ssh/index.ts
new file mode 100644
index 000000000..8be709057
--- /dev/null
+++ b/apps/desktop/src/lib/trpc/routers/ssh/index.ts
@@ -0,0 +1,738 @@
+/**
+ * SSH Router
+ *
+ * tRPC router for managing SSH connections and remote workspaces.
+ * Provides CRUD operations for SSH connections and integration with
+ * the workspace runtime registry.
+ */
+
+import {
+ remoteProjects,
+ remoteWorkspaces,
+ sshConnections,
+ SSH_AUTH_METHODS,
+} from "@superset/local-db";
+import { TRPCError } from "@trpc/server";
+import { eq, desc } from "drizzle-orm";
+import { localDb } from "main/lib/local-db";
+import {
+ getWorkspaceRuntimeRegistry,
+ type ExtendedWorkspaceRuntimeRegistry,
+} from "main/lib/workspace-runtime/registry";
+import { getSSHConfigHosts, hasSSHConfig } from "main/lib/ssh";
+import { z } from "zod";
+import { publicProcedure, router } from "../..";
+import { observable } from "@trpc/server/observable";
+
+// Get the registry with SSH support
+function getRegistry(): ExtendedWorkspaceRuntimeRegistry {
+ return getWorkspaceRuntimeRegistry();
+}
+
+// SSH Connection input schema
+const sshConnectionInput = z.object({
+ name: z.string().min(1, "Name is required"),
+ host: z.string().min(1, "Host is required"),
+ port: z.number().min(1).max(65535).default(22),
+ username: z.string().min(1, "Username is required"),
+ authMethod: z.enum(SSH_AUTH_METHODS),
+ privateKeyPath: z.string().optional(),
+ agentForward: z.boolean().optional(),
+ remoteWorkDir: z.string().optional(),
+ keepAliveInterval: z.number().optional(),
+ connectionTimeout: z.number().optional(),
+});
+
+export const createSSHRouter = () => {
+ return router({
+ // =================================================================
+ // SSH Connection Management
+ // =================================================================
+
+ /**
+ * List all SSH connections
+ */
+ listConnections: publicProcedure.query(() => {
+ return localDb
+ .select()
+ .from(sshConnections)
+ .orderBy(desc(sshConnections.lastConnectedAt))
+ .all();
+ }),
+
+ /**
+ * Get a single SSH connection by ID
+ */
+ getConnection: publicProcedure
+ .input(z.object({ id: z.string() }))
+ .query(({ input }) => {
+ const connection = localDb
+ .select()
+ .from(sshConnections)
+ .where(eq(sshConnections.id, input.id))
+ .get();
+
+ if (!connection) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: `SSH connection ${input.id} not found`,
+ });
+ }
+
+ return connection;
+ }),
+
+ /**
+ * Create a new SSH connection
+ */
+ createConnection: publicProcedure
+ .input(sshConnectionInput)
+ .mutation(({ input }) => {
+ const connection = localDb
+ .insert(sshConnections)
+ .values(input)
+ .returning()
+ .get();
+
+ console.log(`[ssh/router] Created SSH connection: ${connection.name} (${connection.host})`);
+ return connection;
+ }),
+
+ /**
+ * Update an SSH connection
+ */
+ updateConnection: publicProcedure
+ .input(
+ z.object({
+ id: z.string(),
+ patch: sshConnectionInput.partial(),
+ }),
+ )
+ .mutation(async ({ input }) => {
+ const existing = localDb
+ .select()
+ .from(sshConnections)
+ .where(eq(sshConnections.id, input.id))
+ .get();
+
+ if (!existing) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: `SSH connection ${input.id} not found`,
+ });
+ }
+
+ // If the connection is active, disconnect it first
+ const registry = getRegistry();
+ await registry.disconnectSSHRuntime(input.id);
+
+ const updated = localDb
+ .update(sshConnections)
+ .set(input.patch)
+ .where(eq(sshConnections.id, input.id))
+ .returning()
+ .get();
+
+ console.log(`[ssh/router] Updated SSH connection: ${updated.name}`);
+ return updated;
+ }),
+
+ /**
+ * Delete an SSH connection
+ */
+ deleteConnection: publicProcedure
+ .input(z.object({ id: z.string() }))
+ .mutation(async ({ input }) => {
+ const registry = getRegistry();
+
+ // Disconnect the runtime if active
+ await registry.disconnectSSHRuntime(input.id);
+
+ // Delete associated remote projects (cascades to remote workspaces)
+ localDb
+ .delete(remoteProjects)
+ .where(eq(remoteProjects.sshConnectionId, input.id))
+ .run();
+
+ // Delete the connection
+ localDb
+ .delete(sshConnections)
+ .where(eq(sshConnections.id, input.id))
+ .run();
+
+ console.log(`[ssh/router] Deleted SSH connection: ${input.id}`);
+ return { success: true };
+ }),
+
+ /**
+ * Test SSH connection
+ */
+ testConnection: publicProcedure
+ .input(z.object({ id: z.string() }))
+ .mutation(async ({ input }) => {
+ const connection = localDb
+ .select()
+ .from(sshConnections)
+ .where(eq(sshConnections.id, input.id))
+ .get();
+
+ if (!connection) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: `SSH connection ${input.id} not found`,
+ });
+ }
+
+ const registry = getRegistry();
+ const runtime = registry.getSSHRuntime({
+ id: connection.id,
+ name: connection.name,
+ host: connection.host,
+ port: connection.port,
+ username: connection.username,
+ authMethod: connection.authMethod,
+ privateKeyPath: connection.privateKeyPath ?? undefined,
+ agentForward: connection.agentForward ?? undefined,
+ remoteWorkDir: connection.remoteWorkDir ?? undefined,
+ keepAliveInterval: connection.keepAliveInterval ?? undefined,
+ connectionTimeout: connection.connectionTimeout ?? undefined,
+ });
+
+ try {
+ await runtime.connect();
+
+ // Update last connected time
+ localDb
+ .update(sshConnections)
+ .set({ lastConnectedAt: Date.now() })
+ .where(eq(sshConnections.id, input.id))
+ .run();
+
+ return { success: true, message: "Connection successful" };
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Unknown error";
+ return { success: false, message };
+ }
+ }),
+
+ /**
+ * Connect to an SSH server
+ */
+ connect: publicProcedure
+ .input(z.object({ id: z.string() }))
+ .mutation(async ({ input }) => {
+ console.log(`[ssh/router] Connect requested for connection: ${input.id}`);
+
+ const connection = localDb
+ .select()
+ .from(sshConnections)
+ .where(eq(sshConnections.id, input.id))
+ .get();
+
+ if (!connection) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: `SSH connection ${input.id} not found`,
+ });
+ }
+
+ console.log(`[ssh/router] Found connection: ${connection.name} (${connection.username}@${connection.host})`);
+
+ const registry = getRegistry();
+ const runtime = registry.getSSHRuntime({
+ id: connection.id,
+ name: connection.name,
+ host: connection.host,
+ port: connection.port,
+ username: connection.username,
+ authMethod: connection.authMethod,
+ privateKeyPath: connection.privateKeyPath ?? undefined,
+ agentForward: connection.agentForward ?? undefined,
+ remoteWorkDir: connection.remoteWorkDir ?? undefined,
+ keepAliveInterval: connection.keepAliveInterval ?? undefined,
+ connectionTimeout: connection.connectionTimeout ?? undefined,
+ });
+
+ try {
+ console.log(`[ssh/router] Attempting to connect...`);
+ await runtime.connect();
+ console.log(`[ssh/router] Connected to ${connection.name}`);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ console.error(`[ssh/router] Connection failed: ${message}`);
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: `Failed to connect: ${message}`,
+ });
+ }
+
+ // Update last connected time
+ localDb
+ .update(sshConnections)
+ .set({ lastConnectedAt: Date.now() })
+ .where(eq(sshConnections.id, input.id))
+ .run();
+
+ return { success: true };
+ }),
+
+ /**
+ * Disconnect from an SSH server
+ */
+ disconnect: publicProcedure
+ .input(z.object({ id: z.string() }))
+ .mutation(async ({ input }) => {
+ const registry = getRegistry();
+ await registry.disconnectSSHRuntime(input.id);
+ console.log(`[ssh/router] Disconnected from ${input.id}`);
+ return { success: true };
+ }),
+
+ /**
+ * Get connection status
+ */
+ getConnectionStatus: publicProcedure
+ .input(z.object({ id: z.string() }))
+ .query(({ input }) => {
+ const registry = getRegistry();
+ const runtimes = registry.getActiveSSHRuntimes();
+ const runtime = runtimes.get(input.id);
+
+ if (!runtime) {
+ return { connected: false, state: "disconnected" as const };
+ }
+
+ return {
+ connected: runtime.isConnected(),
+ state: runtime.isConnected() ? "connected" as const : "disconnected" as const,
+ };
+ }),
+
+ /**
+ * Subscribe to connection status changes
+ */
+ onConnectionStatus: publicProcedure
+ .input(z.object({ id: z.string() }))
+ .subscription(({ input }) => {
+ return observable<{ state: string; error?: string }>((emit) => {
+ const registry = getRegistry();
+ const runtime = registry.getActiveSSHRuntimes().get(input.id);
+
+ if (!runtime) {
+ emit.next({ state: "disconnected" });
+ return () => {};
+ }
+
+ const handler = (status: { state: string; error?: string }) => {
+ emit.next(status);
+ };
+
+ runtime.terminal.on("connectionStatus", handler);
+
+ // Send initial state
+ emit.next({
+ state: runtime.isConnected() ? "connected" : "disconnected",
+ });
+
+ return () => {
+ runtime.terminal.off("connectionStatus", handler);
+ };
+ });
+ }),
+
+ // =================================================================
+ // Remote Project Management
+ // =================================================================
+
+ /**
+ * List all remote projects
+ */
+ listRemoteProjects: publicProcedure.query(() => {
+ return localDb
+ .select()
+ .from(remoteProjects)
+ .orderBy(desc(remoteProjects.lastOpenedAt))
+ .all();
+ }),
+
+ /**
+ * List remote projects for a specific SSH connection
+ */
+ listRemoteProjectsByConnection: publicProcedure
+ .input(z.object({ sshConnectionId: z.string() }))
+ .query(({ input }) => {
+ return localDb
+ .select()
+ .from(remoteProjects)
+ .where(eq(remoteProjects.sshConnectionId, input.sshConnectionId))
+ .orderBy(desc(remoteProjects.lastOpenedAt))
+ .all();
+ }),
+
+ /**
+ * Create a remote project
+ */
+ createRemoteProject: publicProcedure
+ .input(
+ z.object({
+ sshConnectionId: z.string(),
+ remotePath: z.string().min(1, "Remote path is required"),
+ name: z.string().min(1, "Name is required"),
+ color: z.string().default("#6366f1"),
+ defaultBranch: z.string().optional(),
+ }),
+ )
+ .mutation(({ input }) => {
+ // Verify SSH connection exists
+ const connection = localDb
+ .select()
+ .from(sshConnections)
+ .where(eq(sshConnections.id, input.sshConnectionId))
+ .get();
+
+ if (!connection) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: `SSH connection ${input.sshConnectionId} not found`,
+ });
+ }
+
+ const project = localDb
+ .insert(remoteProjects)
+ .values(input)
+ .returning()
+ .get();
+
+ console.log(`[ssh/router] Created remote project: ${project.name} at ${project.remotePath}`);
+ return project;
+ }),
+
+ /**
+ * Delete a remote project
+ */
+ deleteRemoteProject: publicProcedure
+ .input(z.object({ id: z.string() }))
+ .mutation(async ({ input }) => {
+ // Get all workspaces for this project
+ const workspaces = localDb
+ .select()
+ .from(remoteWorkspaces)
+ .where(eq(remoteWorkspaces.remoteProjectId, input.id))
+ .all();
+
+ // Unregister workspaces from SSH
+ const registry = getRegistry();
+ for (const ws of workspaces) {
+ registry.unregisterSSHWorkspace(ws.id);
+ }
+
+ // Delete project (cascades to workspaces)
+ localDb
+ .delete(remoteProjects)
+ .where(eq(remoteProjects.id, input.id))
+ .run();
+
+ console.log(`[ssh/router] Deleted remote project: ${input.id}`);
+ return { success: true };
+ }),
+
+ // =================================================================
+ // Remote Workspace Management
+ // =================================================================
+
+ /**
+ * List remote workspaces for a project
+ */
+ listRemoteWorkspaces: publicProcedure
+ .input(z.object({ remoteProjectId: z.string() }))
+ .query(({ input }) => {
+ return localDb
+ .select()
+ .from(remoteWorkspaces)
+ .where(eq(remoteWorkspaces.remoteProjectId, input.remoteProjectId))
+ .orderBy(remoteWorkspaces.tabOrder)
+ .all();
+ }),
+
+ /**
+ * Create a remote workspace
+ */
+ createRemoteWorkspace: publicProcedure
+ .input(
+ z.object({
+ remoteProjectId: z.string(),
+ branch: z.string().min(1, "Branch is required"),
+ name: z.string().min(1, "Name is required"),
+ }),
+ )
+ .mutation(({ input }) => {
+ // Verify remote project exists and get SSH connection
+ const project = localDb
+ .select()
+ .from(remoteProjects)
+ .where(eq(remoteProjects.id, input.remoteProjectId))
+ .get();
+
+ if (!project) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: `Remote project ${input.remoteProjectId} not found`,
+ });
+ }
+
+ // Get max tab order
+ const maxOrder = localDb
+ .select()
+ .from(remoteWorkspaces)
+ .where(eq(remoteWorkspaces.remoteProjectId, input.remoteProjectId))
+ .all()
+ .reduce((max, ws) => Math.max(max, ws.tabOrder), -1);
+
+ const workspace = localDb
+ .insert(remoteWorkspaces)
+ .values({
+ ...input,
+ tabOrder: maxOrder + 1,
+ })
+ .returning()
+ .get();
+
+ // Register workspace with SSH runtime
+ const registry = getRegistry();
+ registry.registerSSHWorkspace(workspace.id, project.sshConnectionId);
+
+ console.log(`[ssh/router] Created remote workspace: ${workspace.name}`);
+ return workspace;
+ }),
+
+ /**
+ * Delete a remote workspace
+ */
+ deleteRemoteWorkspace: publicProcedure
+ .input(z.object({ id: z.string() }))
+ .mutation(({ input }) => {
+ const registry = getRegistry();
+ registry.unregisterSSHWorkspace(input.id);
+
+ localDb
+ .delete(remoteWorkspaces)
+ .where(eq(remoteWorkspaces.id, input.id))
+ .run();
+
+ console.log(`[ssh/router] Deleted remote workspace: ${input.id}`);
+ return { success: true };
+ }),
+
+ /**
+ * Open a remote project - connects to SSH and returns the first workspace
+ */
+ openRemoteProject: publicProcedure
+ .input(z.object({ projectId: z.string() }))
+ .mutation(async ({ input }) => {
+ // Get the remote project
+ const project = localDb
+ .select()
+ .from(remoteProjects)
+ .where(eq(remoteProjects.id, input.projectId))
+ .get();
+
+ if (!project) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: `Remote project ${input.projectId} not found`,
+ });
+ }
+
+ // Get the SSH connection
+ const connection = localDb
+ .select()
+ .from(sshConnections)
+ .where(eq(sshConnections.id, project.sshConnectionId))
+ .get();
+
+ if (!connection) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: `SSH connection for project not found`,
+ });
+ }
+
+ // Get or create the SSH runtime and connect
+ const registry = getRegistry();
+ const runtime = registry.getSSHRuntime({
+ id: connection.id,
+ name: connection.name,
+ host: connection.host,
+ port: connection.port,
+ username: connection.username,
+ authMethod: connection.authMethod,
+ privateKeyPath: connection.privateKeyPath ?? undefined,
+ agentForward: connection.agentForward ?? undefined,
+ remoteWorkDir: project.remotePath,
+ keepAliveInterval: connection.keepAliveInterval ?? undefined,
+ connectionTimeout: connection.connectionTimeout ?? undefined,
+ });
+
+ // Connect if not already connected
+ if (!runtime.isConnected()) {
+ try {
+ await runtime.connect();
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: `Failed to connect to SSH: ${message}`,
+ });
+ }
+ }
+
+ // Get or create a workspace for this project
+ let workspace = localDb
+ .select()
+ .from(remoteWorkspaces)
+ .where(eq(remoteWorkspaces.remoteProjectId, project.id))
+ .orderBy(remoteWorkspaces.lastOpenedAt)
+ .get();
+
+ if (!workspace) {
+ // Create a default workspace
+ workspace = localDb
+ .insert(remoteWorkspaces)
+ .values({
+ remoteProjectId: project.id,
+ branch: "main",
+ name: "main",
+ tabOrder: 0,
+ })
+ .returning()
+ .get();
+ console.log(`[ssh/router] Created default workspace for project: ${project.name}`);
+ }
+
+ // Register workspace with SSH runtime
+ registry.registerSSHWorkspace(workspace.id, connection.id);
+
+ // Update last opened time
+ localDb
+ .update(remoteProjects)
+ .set({ lastOpenedAt: Date.now() })
+ .where(eq(remoteProjects.id, project.id))
+ .run();
+
+ localDb
+ .update(remoteWorkspaces)
+ .set({ lastOpenedAt: Date.now() })
+ .where(eq(remoteWorkspaces.id, workspace.id))
+ .run();
+
+ console.log(`[ssh/router] Opened remote project: ${project.name}, workspace: ${workspace.id}`);
+ return { project, workspace };
+ }),
+
+ /**
+ * Get active SSH runtimes
+ */
+ getActiveRuntimes: publicProcedure.query(() => {
+ const registry = getRegistry();
+ const runtimes = registry.getActiveSSHRuntimes();
+
+ return Array.from(runtimes.entries()).map(([id, runtime]) => ({
+ id,
+ connected: runtime.isConnected(),
+ config: runtime.getConfig(),
+ }));
+ }),
+
+ // =================================================================
+ // SSH Config Import
+ // =================================================================
+
+ /**
+ * Check if ~/.ssh/config exists
+ */
+ hasSSHConfig: publicProcedure.query(() => {
+ return hasSSHConfig();
+ }),
+
+ /**
+ * Get hosts from ~/.ssh/config (without importing)
+ */
+ getSSHConfigHosts: publicProcedure.query(() => {
+ return getSSHConfigHosts();
+ }),
+
+ /**
+ * Import hosts from ~/.ssh/config into the database
+ */
+ importFromSSHConfig: publicProcedure
+ .input(
+ z.object({
+ /** Specific host names to import, or empty to import all */
+ hostNames: z.array(z.string()).optional(),
+ /** Skip hosts that already exist (by name) */
+ skipExisting: z.boolean().default(true),
+ }).optional(),
+ )
+ .mutation(({ input }) => {
+ const options = input ?? {};
+ const configHosts = getSSHConfigHosts();
+
+ // Get existing connection names for deduplication
+ const existingNames = new Set(
+ localDb
+ .select({ name: sshConnections.name })
+ .from(sshConnections)
+ .all()
+ .map((c) => c.name.toLowerCase()),
+ );
+
+ // Filter hosts to import
+ let hostsToImport = configHosts;
+
+ if (options.hostNames && options.hostNames.length > 0) {
+ const namesSet = new Set(options.hostNames.map((n) => n.toLowerCase()));
+ hostsToImport = hostsToImport.filter((h) =>
+ namesSet.has(h.name.toLowerCase()),
+ );
+ }
+
+ if (options.skipExisting) {
+ hostsToImport = hostsToImport.filter(
+ (h) => !existingNames.has(h.name.toLowerCase()),
+ );
+ }
+
+ // Import the hosts
+ const imported: string[] = [];
+ const skipped: string[] = [];
+
+ for (const host of hostsToImport) {
+ try {
+ localDb
+ .insert(sshConnections)
+ .values({
+ name: host.name,
+ host: host.host,
+ port: host.port,
+ username: host.username,
+ authMethod: host.authMethod,
+ privateKeyPath: host.privateKeyPath,
+ agentForward: host.agentForward,
+ })
+ .run();
+ imported.push(host.name);
+ console.log(`[ssh/router] Imported SSH host: ${host.name}`);
+ } catch (error) {
+ console.error(`[ssh/router] Failed to import ${host.name}:`, error);
+ skipped.push(host.name);
+ }
+ }
+
+ return {
+ imported,
+ skipped,
+ total: configHosts.length,
+ };
+ }),
+ });
+};
diff --git a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts
index a53bdfac8..c116bd4b4 100644
--- a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts
+++ b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts
@@ -1,6 +1,6 @@
import fs from "node:fs/promises";
import path from "node:path";
-import { projects, workspaces, worktrees } from "@superset/local-db";
+import { projects, workspaces, worktrees, remoteWorkspaces, remoteProjects } from "@superset/local-db";
import { TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable";
import { eq } from "drizzle-orm";
@@ -17,6 +17,8 @@ let createOrAttachCallCounter = 0;
const TERMINAL_SESSION_KILLED_MESSAGE = "TERMINAL_SESSION_KILLED";
const userKilledSessions = new Set();
+// Track paneId -> workspaceId mapping for routing terminal operations
+const paneToWorkspace = new Map();
const SAFE_ID = z
.string()
.min(1)
@@ -26,6 +28,29 @@ const SAFE_ID = z
{ message: "Invalid id" },
);
+/**
+ * Helper to get terminal runtime for a workspace.
+ * Returns SSH terminal for SSH workspaces, local terminal otherwise.
+ */
+function getTerminalForWorkspace(workspaceId: string) {
+ const registry = getWorkspaceRuntimeRegistry();
+ return registry.getForWorkspaceId(workspaceId).terminal;
+}
+
+/**
+ * Helper to get terminal runtime for a pane.
+ * Uses the paneId -> workspaceId mapping to determine the correct terminal.
+ */
+function getTerminalForPane(paneId: string) {
+ const workspaceId = paneToWorkspace.get(paneId);
+ if (workspaceId) {
+ return getTerminalForWorkspace(workspaceId);
+ }
+ // Fall back to default terminal if mapping not found
+ const registry = getWorkspaceRuntimeRegistry();
+ return registry.getDefault().terminal;
+}
+
/**
* Terminal router using TerminalManager with node-pty
* Sessions are keyed by paneId and linked to workspaces for cwd resolution
@@ -43,11 +68,11 @@ const SAFE_ID = z
export const createTerminalRouter = () => {
// Get the workspace runtime registry (selects backend based on settings)
const registry = getWorkspaceRuntimeRegistry();
- const terminal = registry.getDefault().terminal;
+ const defaultTerminal = registry.getDefault().terminal;
if (DEBUG_TERMINAL) {
console.log(
"[Terminal Router] Using terminal runtime, capabilities:",
- terminal.capabilities,
+ defaultTerminal.capabilities,
);
}
@@ -96,18 +121,60 @@ export const createTerminalRouter = () => {
});
}
- // Resolve cwd: absolute paths stay as-is, relative paths resolve against workspace path
- const workspace = localDb
- .select()
- .from(workspaces)
- .where(eq(workspaces.id, workspaceId))
- .get();
- const workspacePath = workspace
- ? (getWorkspacePath(workspace) ?? undefined)
- : undefined;
- if (workspace?.type === "worktree") {
- assertWorkspaceUsable(workspaceId, workspacePath);
+ // Check if this is a remote workspace
+ const isRemote = registry.isSSHWorkspace(workspaceId);
+ let workspacePath: string | undefined;
+ let workspaceName: string | undefined;
+ let rootPath: string | undefined;
+
+ if (isRemote) {
+ // Remote workspace - get path from remoteWorkspaces/remoteProjects
+ const remoteWs = localDb
+ .select()
+ .from(remoteWorkspaces)
+ .where(eq(remoteWorkspaces.id, workspaceId))
+ .get();
+
+ if (remoteWs) {
+ workspaceName = remoteWs.name;
+ const remoteProject = localDb
+ .select()
+ .from(remoteProjects)
+ .where(eq(remoteProjects.id, remoteWs.remoteProjectId))
+ .get();
+
+ if (remoteProject) {
+ workspacePath = remoteProject.remotePath;
+ rootPath = remoteProject.remotePath;
+ }
+ }
+ } else {
+ // Local workspace - existing logic
+ const workspace = localDb
+ .select()
+ .from(workspaces)
+ .where(eq(workspaces.id, workspaceId))
+ .get();
+ workspacePath = workspace
+ ? (getWorkspacePath(workspace) ?? undefined)
+ : undefined;
+ workspaceName = workspace?.name;
+
+ if (workspace?.type === "worktree") {
+ assertWorkspaceUsable(workspaceId, workspacePath);
+ }
+
+ // Get project info for environment variables
+ const project = workspace
+ ? localDb
+ .select()
+ .from(projects)
+ .where(eq(projects.id, workspace.projectId))
+ .get()
+ : undefined;
+ rootPath = project?.mainRepoPath;
}
+
const cwd = resolveCwd(cwdOverride, workspacePath);
if (DEBUG_TERMINAL) {
@@ -119,26 +186,25 @@ export const createTerminalRouter = () => {
resolvedCwd: cwd,
cols,
rows,
+ isRemote,
});
}
- // Get project info for environment variables
- const project = workspace
- ? localDb
- .select()
- .from(projects)
- .where(eq(projects.id, workspace.projectId))
- .get()
- : undefined;
+ // Store paneId -> workspaceId mapping FIRST so concurrent operations
+ // (like stream subscription, resize) route to the correct terminal
+ paneToWorkspace.set(paneId, workspaceId);
+
+ // Get the terminal for this workspace (SSH or local)
+ const terminal = getTerminalForWorkspace(workspaceId);
try {
const result = await terminal.createOrAttach({
paneId,
tabId,
workspaceId,
- workspaceName: workspace?.name,
+ workspaceName,
workspacePath,
- rootPath: project?.mainRepoPath,
+ rootPath,
cwd,
cols,
rows,
@@ -190,6 +256,7 @@ export const createTerminalRouter = () => {
}),
)
.mutation(async ({ input }) => {
+ const terminal = getTerminalForPane(input.paneId);
try {
terminal.write(input);
} catch (error) {
@@ -218,6 +285,7 @@ export const createTerminalRouter = () => {
ackColdRestore: publicProcedure
.input(z.object({ paneId: z.string() }))
.mutation(({ input }) => {
+ const terminal = getTerminalForPane(input.paneId);
terminal.ackColdRestore(input.paneId);
}),
@@ -231,6 +299,7 @@ export const createTerminalRouter = () => {
}),
)
.mutation(async ({ input }) => {
+ const terminal = getTerminalForPane(input.paneId);
terminal.resize(input);
}),
@@ -242,6 +311,7 @@ export const createTerminalRouter = () => {
}),
)
.mutation(async ({ input }) => {
+ const terminal = getTerminalForPane(input.paneId);
terminal.signal(input);
}),
@@ -252,7 +322,9 @@ export const createTerminalRouter = () => {
}),
)
.mutation(async ({ input }) => {
+ const terminal = getTerminalForPane(input.paneId);
userKilledSessions.add(input.paneId);
+ paneToWorkspace.delete(input.paneId); // Clean up mapping
await terminal.kill(input);
}),
@@ -267,6 +339,7 @@ export const createTerminalRouter = () => {
}),
)
.mutation(async ({ input }) => {
+ const terminal = getTerminalForPane(input.paneId);
terminal.detach(input);
}),
@@ -281,27 +354,28 @@ export const createTerminalRouter = () => {
}),
)
.mutation(async ({ input }) => {
+ const terminal = getTerminalForPane(input.paneId);
await terminal.clearScrollback(input);
}),
listDaemonSessions: publicProcedure.query(async () => {
- // Use capability-based check instead of instanceof
- if (!terminal.management) {
+ // Use capability-based check instead of instanceof (local terminal only)
+ if (!defaultTerminal.management) {
return { daemonModeEnabled: false, sessions: [] };
}
- const response = await terminal.management.listSessions();
+ const response = await defaultTerminal.management.listSessions();
return { daemonModeEnabled: true, sessions: response.sessions };
}),
killAllDaemonSessions: publicProcedure.mutation(async () => {
- // Use capability-based check instead of instanceof
- if (!terminal.management) {
+ // Use capability-based check instead of instanceof (local terminal only)
+ if (!defaultTerminal.management) {
return { daemonModeEnabled: false, killedCount: 0, remainingCount: 0 };
}
// Get sessions before kill for accurate count
- const before = await terminal.management.listSessions();
+ const before = await defaultTerminal.management.listSessions();
const beforeIds = before.sessions.map((s) => s.sessionId);
for (const id of beforeIds) {
userKilledSessions.add(id);
@@ -314,7 +388,7 @@ export const createTerminalRouter = () => {
);
// Request kill of all sessions
- await terminal.management.killAllSessions();
+ await defaultTerminal.management.killAllSessions();
// Wait and verify loop - poll until sessions are actually dead
// This ensures we don't return success before daemon has finished cleanup
@@ -325,7 +399,7 @@ export const createTerminalRouter = () => {
for (let i = 0; i < MAX_RETRIES && remainingCount > 0; i++) {
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
- const after = await terminal.management.listSessions();
+ const after = await defaultTerminal.management.listSessions();
afterIds = after.sessions
.filter((s) => s.isAlive)
.map((s) => s.sessionId);
@@ -355,19 +429,19 @@ export const createTerminalRouter = () => {
killDaemonSessionsForWorkspace: publicProcedure
.input(z.object({ workspaceId: z.string() }))
.mutation(async ({ input }) => {
- // Use capability-based check instead of instanceof
- if (!terminal.management) {
+ // Use capability-based check instead of instanceof (local terminal only)
+ if (!defaultTerminal.management) {
return { daemonModeEnabled: false, killedCount: 0 };
}
- const { sessions } = await terminal.management.listSessions();
+ const { sessions } = await defaultTerminal.management.listSessions();
const toKill = sessions.filter(
(session) => session.workspaceId === input.workspaceId,
);
for (const session of toKill) {
userKilledSessions.add(session.sessionId);
- await terminal.kill({ paneId: session.sessionId });
+ await defaultTerminal.kill({ paneId: session.sessionId });
}
return { daemonModeEnabled: true, killedCount: toKill.length };
@@ -376,8 +450,8 @@ export const createTerminalRouter = () => {
clearTerminalHistory: publicProcedure.mutation(async () => {
// Note: Disk-based terminal history was removed. This is now a no-op
// for non-daemon mode. In daemon mode, it resets the history persistence.
- if (terminal.management) {
- await terminal.management.resetHistoryPersistence();
+ if (defaultTerminal.management) {
+ await defaultTerminal.management.resetHistoryPersistence();
}
return { success: true };
@@ -386,6 +460,7 @@ export const createTerminalRouter = () => {
getSession: publicProcedure
.input(z.string())
.query(async ({ input: paneId }) => {
+ const terminal = getTerminalForPane(paneId);
return terminal.getSession(paneId);
}),
@@ -396,6 +471,25 @@ export const createTerminalRouter = () => {
getWorkspaceCwd: publicProcedure
.input(z.string())
.query(({ input: workspaceId }) => {
+ // Check if it's a remote workspace
+ if (registry.isSSHWorkspace(workspaceId)) {
+ const remoteWs = localDb
+ .select()
+ .from(remoteWorkspaces)
+ .where(eq(remoteWorkspaces.id, workspaceId))
+ .get();
+ if (remoteWs) {
+ const remoteProject = localDb
+ .select()
+ .from(remoteProjects)
+ .where(eq(remoteProjects.id, remoteWs.remoteProjectId))
+ .get();
+ return remoteProject?.remotePath ?? null;
+ }
+ return null;
+ }
+
+ // Local workspace
const workspace = localDb
.select()
.from(workspaces)
@@ -467,8 +561,9 @@ export const createTerminalRouter = () => {
}),
stream: publicProcedure
- .input(z.string())
- .subscription(({ input: paneId }) => {
+ .input(z.object({ paneId: z.string(), workspaceId: z.string().optional() }))
+ .subscription(({ input }) => {
+ const { paneId, workspaceId } = input;
return observable<
| { type: "data"; data: string }
| { type: "exit"; exitCode: number; signal?: number }
@@ -476,9 +571,16 @@ export const createTerminalRouter = () => {
| { type: "error"; error: string; code?: string }
>((emit) => {
if (DEBUG_TERMINAL) {
- console.log(`[Terminal Stream] Subscribe: ${paneId}`);
+ console.log(`[Terminal Stream] Subscribe: ${paneId}, workspaceId: ${workspaceId}`);
}
+ // Get the terminal for this workspace/pane
+ // If workspaceId is provided, use it directly for routing
+ // Otherwise fall back to the paneId mapping
+ const terminal = workspaceId
+ ? getTerminalForWorkspace(workspaceId)
+ : getTerminalForPane(paneId);
+
let firstDataReceived = false;
const onData = (data: string) => {
diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts
index 1f7f34ee9..294875d4a 100644
--- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts
+++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts
@@ -1,4 +1,4 @@
-import { projects, workspaces, worktrees } from "@superset/local-db";
+import { projects, workspaces, worktrees, remoteWorkspaces, remoteProjects, sshConnections } from "@superset/local-db";
import { TRPCError } from "@trpc/server";
import { eq, isNotNull, isNull } from "drizzle-orm";
import { localDb } from "main/lib/local-db";
@@ -15,12 +15,72 @@ export const createQueryProcedures = () => {
get: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
+ // First check local workspaces
const workspace = getWorkspace(input.id);
+
+ // If not found in local workspaces, check remote workspaces
if (!workspace) {
- throw new TRPCError({
- code: "NOT_FOUND",
- message: `Workspace ${input.id} not found`,
- });
+ const remoteWorkspace = localDb
+ .select()
+ .from(remoteWorkspaces)
+ .where(eq(remoteWorkspaces.id, input.id))
+ .get();
+
+ if (!remoteWorkspace) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: `Workspace ${input.id} not found`,
+ });
+ }
+
+ // Get the remote project
+ const remoteProject = localDb
+ .select()
+ .from(remoteProjects)
+ .where(eq(remoteProjects.id, remoteWorkspace.remoteProjectId))
+ .get();
+
+ // Get the SSH connection
+ const connection = remoteProject
+ ? localDb
+ .select()
+ .from(sshConnections)
+ .where(eq(sshConnections.id, remoteProject.sshConnectionId))
+ .get()
+ : null;
+
+ return {
+ id: remoteWorkspace.id,
+ name: remoteWorkspace.name,
+ branch: remoteWorkspace.branch,
+ projectId: remoteWorkspace.remoteProjectId,
+ worktreeId: null,
+ tabOrder: remoteWorkspace.tabOrder,
+ createdAt: remoteWorkspace.createdAt,
+ updatedAt: remoteWorkspace.updatedAt,
+ lastOpenedAt: remoteWorkspace.lastOpenedAt,
+ isUnread: false,
+ deletingAt: null,
+ type: "remote" as const,
+ worktreePath: remoteProject?.remotePath ?? "",
+ project: remoteProject
+ ? {
+ id: remoteProject.id,
+ name: remoteProject.name,
+ mainRepoPath: remoteProject.remotePath,
+ }
+ : null,
+ worktree: null,
+ // SSH-specific fields
+ sshConnection: connection
+ ? {
+ id: connection.id,
+ name: connection.name,
+ host: connection.host,
+ username: connection.username,
+ }
+ : null,
+ };
}
const project = localDb
@@ -96,6 +156,7 @@ export const createQueryProcedures = () => {
gitStatus: worktree.gitStatus ?? null,
}
: null,
+ sshConnection: null,
};
}),
diff --git a/apps/desktop/src/main/lib/ssh/index.ts b/apps/desktop/src/main/lib/ssh/index.ts
new file mode 100644
index 000000000..cb725f4a3
--- /dev/null
+++ b/apps/desktop/src/main/lib/ssh/index.ts
@@ -0,0 +1,13 @@
+/**
+ * SSH Module Exports
+ */
+
+export * from "./types";
+export { SSHClient } from "./ssh-client";
+export { SSHTerminalManager } from "./ssh-terminal-manager";
+export {
+ parseSSHConfig,
+ getSSHConfigHosts,
+ hasSSHConfig,
+ convertToConnectionConfigs,
+} from "./ssh-config-parser";
diff --git a/apps/desktop/src/main/lib/ssh/ssh-client.ts b/apps/desktop/src/main/lib/ssh/ssh-client.ts
new file mode 100644
index 000000000..76822f023
--- /dev/null
+++ b/apps/desktop/src/main/lib/ssh/ssh-client.ts
@@ -0,0 +1,356 @@
+/**
+ * SSH Client
+ *
+ * Manages SSH connections to remote servers using ssh2 library.
+ * Handles connection lifecycle, authentication, and reconnection.
+ */
+
+import { EventEmitter } from "node:events";
+import * as fs from "node:fs";
+import * as os from "node:os";
+import * as path from "node:path";
+import { Client, type ClientChannel, type ConnectConfig } from "ssh2";
+import type {
+ SSHConnectionConfig,
+ SSHConnectionState,
+ SSHConnectionStatus,
+} from "./types";
+
+const DEFAULT_PORT = 22;
+const DEFAULT_KEEPALIVE_INTERVAL = 60;
+const DEFAULT_CONNECTION_TIMEOUT = 30000;
+const MAX_RECONNECT_ATTEMPTS = 5;
+const RECONNECT_DELAY_MS = 2000;
+
+export class SSHClient extends EventEmitter {
+ private client: Client;
+ private config: SSHConnectionConfig;
+ private state: SSHConnectionState = "disconnected";
+ private reconnectAttempts = 0;
+ private reconnectTimer: NodeJS.Timeout | null = null;
+ private channels: Map = new Map();
+
+ constructor(config: SSHConnectionConfig) {
+ super();
+ this.config = config;
+ this.client = new Client();
+ this.setupClientHandlers();
+ }
+
+ private setupClientHandlers(): void {
+ this.client.on("ready", () => {
+ console.log(`[ssh/client] Connected to ${this.config.host}`);
+ this.state = "connected";
+ this.reconnectAttempts = 0;
+ this.emitStatus();
+ });
+
+ this.client.on("error", (err) => {
+ console.error(`[ssh/client] Connection error:`, err.message);
+ this.state = "error";
+ this.emitStatus(err.message);
+ this.attemptReconnect();
+ });
+
+ this.client.on("close", () => {
+ console.log(`[ssh/client] Connection closed`);
+ const wasConnected = this.state === "connected";
+ this.state = "disconnected";
+ this.emitStatus();
+
+ // Clean up all channels
+ for (const [paneId, channel] of this.channels) {
+ channel.destroy();
+ this.emit(`exit:${paneId}`, 1, undefined);
+ }
+ this.channels.clear();
+
+ // Attempt reconnect if was previously connected
+ if (wasConnected) {
+ this.attemptReconnect();
+ }
+ });
+
+ this.client.on("end", () => {
+ console.log(`[ssh/client] Connection ended`);
+ this.state = "disconnected";
+ this.emitStatus();
+ });
+
+ this.client.on("keyboard-interactive", (_name, _instructions, _lang, prompts, finish) => {
+ // For keyboard-interactive auth, we don't support password prompts here
+ // This would require UI integration
+ console.warn(
+ "[ssh/client] Keyboard-interactive auth requested but not supported",
+ );
+ finish([]);
+ });
+ }
+
+ private emitStatus(error?: string): void {
+ const status: SSHConnectionStatus = {
+ state: this.state,
+ error,
+ reconnectAttempt:
+ this.state === "reconnecting" ? this.reconnectAttempts : undefined,
+ };
+ this.emit("connectionStatus", status);
+ }
+
+ private attemptReconnect(): void {
+ if (this.reconnectTimer) {
+ return;
+ }
+
+ if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
+ console.log(`[ssh/client] Max reconnect attempts reached`);
+ this.state = "error";
+ this.emitStatus("Max reconnect attempts reached");
+ return;
+ }
+
+ this.state = "reconnecting";
+ this.reconnectAttempts++;
+ this.emitStatus();
+
+ console.log(
+ `[ssh/client] Reconnecting in ${RECONNECT_DELAY_MS}ms (attempt ${this.reconnectAttempts})`,
+ );
+
+ this.reconnectTimer = setTimeout(() => {
+ this.reconnectTimer = null;
+ this.connect().catch((err) => {
+ console.error(`[ssh/client] Reconnect failed:`, err.message);
+ });
+ }, RECONNECT_DELAY_MS);
+ }
+
+ async connect(): Promise {
+ if (this.state === "connected" || this.state === "connecting") {
+ console.log(`[ssh/client] Already ${this.state}, skipping connect`);
+ return;
+ }
+
+ console.log(`[ssh/client] Connecting to ${this.config.host}:${this.config.port} as ${this.config.username}`);
+ this.state = "connecting";
+ this.emitStatus();
+
+ let connectConfig: ConnectConfig;
+ try {
+ connectConfig = await this.buildConnectConfig();
+ console.log(`[ssh/client] Auth method: ${this.config.authMethod}`);
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ console.error(`[ssh/client] Failed to build connect config: ${message}`);
+ this.state = "error";
+ this.emitStatus(message);
+ throw err;
+ }
+
+ return new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ console.error(`[ssh/client] Connection timeout after ${this.config.connectionTimeout ?? DEFAULT_CONNECTION_TIMEOUT}ms`);
+ this.client.end();
+ reject(new Error("Connection timeout"));
+ }, this.config.connectionTimeout ?? DEFAULT_CONNECTION_TIMEOUT);
+
+ this.client.once("ready", () => {
+ clearTimeout(timeout);
+ console.log(`[ssh/client] Connection ready`);
+ resolve();
+ });
+
+ this.client.once("error", (err) => {
+ clearTimeout(timeout);
+ console.error(`[ssh/client] Connection error: ${err.message}`);
+ reject(err);
+ });
+
+ this.client.connect(connectConfig);
+ });
+ }
+
+ private async buildConnectConfig(): Promise {
+ const config: ConnectConfig = {
+ host: this.config.host,
+ port: this.config.port ?? DEFAULT_PORT,
+ username: this.config.username,
+ keepaliveInterval:
+ (this.config.keepAliveInterval ?? DEFAULT_KEEPALIVE_INTERVAL) * 1000,
+ keepaliveCountMax: 3,
+ readyTimeout: this.config.connectionTimeout ?? DEFAULT_CONNECTION_TIMEOUT,
+ agentForward: this.config.agentForward,
+ };
+
+ switch (this.config.authMethod) {
+ case "key": {
+ let keyPath =
+ this.config.privateKeyPath ?? path.join(os.homedir(), ".ssh", "id_rsa");
+ // Expand ~ to home directory
+ if (keyPath.startsWith("~")) {
+ keyPath = keyPath.replace(/^~/, os.homedir());
+ }
+ console.log(`[ssh/client] Reading private key from: ${keyPath}`);
+ try {
+ config.privateKey = fs.readFileSync(keyPath);
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ throw new Error(`Failed to read private key at ${keyPath}: ${message}`);
+ }
+ break;
+ }
+ case "agent": {
+ // Use SSH agent from environment
+ config.agent = process.env.SSH_AUTH_SOCK;
+ if (!config.agent) {
+ throw new Error("SSH agent not available (SSH_AUTH_SOCK not set)");
+ }
+ break;
+ }
+ case "password": {
+ // Password would need to be passed securely
+ // For now, we rely on key or agent auth
+ throw new Error(
+ "Password authentication not implemented - use key or agent auth",
+ );
+ }
+ }
+
+ return config;
+ }
+
+ disconnect(): void {
+ if (this.reconnectTimer) {
+ clearTimeout(this.reconnectTimer);
+ this.reconnectTimer = null;
+ }
+ this.reconnectAttempts = MAX_RECONNECT_ATTEMPTS; // Prevent reconnect
+ this.client.end();
+ }
+
+ isConnected(): boolean {
+ return this.state === "connected";
+ }
+
+ getState(): SSHConnectionState {
+ return this.state;
+ }
+
+ /**
+ * Create a PTY channel for a terminal session
+ */
+ async createPtyChannel({
+ paneId,
+ cols,
+ rows,
+ cwd,
+ }: {
+ paneId: string;
+ cols: number;
+ rows: number;
+ cwd?: string;
+ }): Promise {
+ if (!this.isConnected()) {
+ throw new Error("SSH client not connected");
+ }
+
+ return new Promise((resolve, reject) => {
+ const ptyOptions = {
+ term: process.env.TERM || "xterm-256color",
+ cols,
+ rows,
+ modes: {},
+ };
+
+ this.client.shell(ptyOptions, (err, channel) => {
+ if (err) {
+ reject(err);
+ return;
+ }
+
+ this.channels.set(paneId, channel);
+
+ // Set up channel event handlers
+ channel.on("data", (data: Buffer) => {
+ this.emit(`data:${paneId}`, data.toString());
+ });
+
+ channel.stderr.on("data", (data: Buffer) => {
+ this.emit(`data:${paneId}`, data.toString());
+ });
+
+ channel.on("close", () => {
+ this.channels.delete(paneId);
+ this.emit(`exit:${paneId}`, 0, undefined);
+ });
+
+ channel.on("error", (err: Error) => {
+ console.error(`[ssh/client] Channel error for ${paneId}:`, err.message);
+ this.emit(`error:${paneId}`, err.message);
+ });
+
+ // Change directory if specified
+ if (cwd) {
+ channel.write(`cd ${JSON.stringify(cwd)} && clear\n`);
+ }
+
+ resolve(channel);
+ });
+ });
+ }
+
+ /**
+ * Write data to a PTY channel
+ */
+ write(paneId: string, data: string): void {
+ const channel = this.channels.get(paneId);
+ if (channel) {
+ channel.write(data);
+ }
+ }
+
+ /**
+ * Resize a PTY channel
+ */
+ resize(paneId: string, cols: number, rows: number): void {
+ const channel = this.channels.get(paneId);
+ if (channel) {
+ channel.setWindow(rows, cols, 0, 0);
+ }
+ }
+
+ /**
+ * Send a signal to a PTY channel
+ */
+ signal(paneId: string, signalName: string): void {
+ const channel = this.channels.get(paneId);
+ if (channel) {
+ channel.signal(signalName);
+ }
+ }
+
+ /**
+ * Kill/close a PTY channel
+ */
+ killChannel(paneId: string): void {
+ const channel = this.channels.get(paneId);
+ if (channel) {
+ channel.close();
+ this.channels.delete(paneId);
+ }
+ }
+
+ /**
+ * Check if a channel exists and is alive
+ */
+ hasChannel(paneId: string): boolean {
+ return this.channels.has(paneId);
+ }
+
+ /**
+ * Get all active channel pane IDs
+ */
+ getChannelIds(): string[] {
+ return Array.from(this.channels.keys());
+ }
+}
diff --git a/apps/desktop/src/main/lib/ssh/ssh-config-parser.ts b/apps/desktop/src/main/lib/ssh/ssh-config-parser.ts
new file mode 100644
index 000000000..1710ea2ee
--- /dev/null
+++ b/apps/desktop/src/main/lib/ssh/ssh-config-parser.ts
@@ -0,0 +1,149 @@
+/**
+ * SSH Config Parser
+ *
+ * Parses ~/.ssh/config file to extract host configurations.
+ * Supports common SSH config directives.
+ */
+
+import * as fs from "node:fs";
+import * as os from "node:os";
+import * as path from "node:path";
+import type { SSHConnectionConfig, SSHAuthMethod } from "./types";
+
+interface SSHConfigHost {
+ name: string;
+ hostName?: string;
+ user?: string;
+ port?: number;
+ identityFile?: string;
+ forwardAgent?: boolean;
+ proxyJump?: string;
+}
+
+/**
+ * Parse SSH config file and extract host configurations
+ */
+export function parseSSHConfig(configPath?: string): SSHConfigHost[] {
+ const sshConfigPath = configPath ?? path.join(os.homedir(), ".ssh", "config");
+
+ if (!fs.existsSync(sshConfigPath)) {
+ console.log(`[ssh-config] No SSH config found at ${sshConfigPath}`);
+ return [];
+ }
+
+ const content = fs.readFileSync(sshConfigPath, "utf-8");
+ const lines = content.split("\n");
+ const hosts: SSHConfigHost[] = [];
+ let currentHost: SSHConfigHost | null = null;
+
+ for (const rawLine of lines) {
+ const line = rawLine.trim();
+
+ // Skip comments and empty lines
+ if (line.startsWith("#") || line === "") {
+ continue;
+ }
+
+ // Parse key-value pairs (supports both "Key Value" and "Key=Value")
+ const match = line.match(/^(\S+)\s*[=\s]\s*(.+)$/);
+ if (!match) {
+ continue;
+ }
+
+ const [, key, value] = match;
+ const keyLower = key.toLowerCase();
+
+ if (keyLower === "host") {
+ // Skip wildcard hosts
+ if (value.includes("*") || value.includes("?")) {
+ currentHost = null;
+ continue;
+ }
+
+ // Start a new host entry
+ if (currentHost) {
+ hosts.push(currentHost);
+ }
+ currentHost = { name: value };
+ } else if (currentHost) {
+ // Add properties to current host
+ switch (keyLower) {
+ case "hostname":
+ currentHost.hostName = value;
+ break;
+ case "user":
+ currentHost.user = value;
+ break;
+ case "port":
+ currentHost.port = parseInt(value, 10);
+ break;
+ case "identityfile":
+ // Expand ~ to home directory
+ currentHost.identityFile = value.replace(/^~/, os.homedir());
+ break;
+ case "forwardagent":
+ currentHost.forwardAgent = value.toLowerCase() === "yes";
+ break;
+ case "proxyjump":
+ currentHost.proxyJump = value;
+ break;
+ }
+ }
+ }
+
+ // Don't forget the last host
+ if (currentHost) {
+ hosts.push(currentHost);
+ }
+
+ console.log(`[ssh-config] Parsed ${hosts.length} hosts from ${sshConfigPath}`);
+ return hosts;
+}
+
+/**
+ * Convert parsed SSH config hosts to SSHConnectionConfig format
+ */
+export function convertToConnectionConfigs(
+ hosts: SSHConfigHost[],
+): Omit[] {
+ return hosts
+ .filter((host) => {
+ // Must have either a hostname or use the host name as hostname
+ return host.hostName || host.name;
+ })
+ .map((host) => {
+ // Determine auth method
+ let authMethod: SSHAuthMethod = "agent"; // Default to agent
+ if (host.identityFile) {
+ authMethod = "key";
+ }
+
+ return {
+ name: host.name,
+ host: host.hostName ?? host.name,
+ port: host.port ?? 22,
+ username: host.user ?? os.userInfo().username,
+ authMethod,
+ privateKeyPath: host.identityFile,
+ agentForward: host.forwardAgent,
+ };
+ });
+}
+
+/**
+ * Get all SSH hosts from config file as connection configs
+ */
+export function getSSHConfigHosts(
+ configPath?: string,
+): Omit[] {
+ const hosts = parseSSHConfig(configPath);
+ return convertToConnectionConfigs(hosts);
+}
+
+/**
+ * Check if SSH config file exists
+ */
+export function hasSSHConfig(configPath?: string): boolean {
+ const sshConfigPath = configPath ?? path.join(os.homedir(), ".ssh", "config");
+ return fs.existsSync(sshConfigPath);
+}
diff --git a/apps/desktop/src/main/lib/ssh/ssh-terminal-manager.ts b/apps/desktop/src/main/lib/ssh/ssh-terminal-manager.ts
new file mode 100644
index 000000000..1ea9ec662
--- /dev/null
+++ b/apps/desktop/src/main/lib/ssh/ssh-terminal-manager.ts
@@ -0,0 +1,378 @@
+/**
+ * SSH Terminal Manager
+ *
+ * Manages SSH terminal sessions. Implements the same interface as
+ * the local TerminalManager to allow seamless swapping via the
+ * WorkspaceRuntime abstraction.
+ */
+
+import { EventEmitter } from "node:events";
+import type { SSHConnectionConfig, SSHSessionInfo } from "./types";
+import { SSHClient } from "./ssh-client";
+
+const DEFAULT_COLS = 80;
+const DEFAULT_ROWS = 24;
+
+interface SSHSession {
+ paneId: string;
+ workspaceId: string;
+ cwd: string;
+ cols: number;
+ rows: number;
+ lastActive: number;
+ isAlive: boolean;
+ viewportY?: number;
+}
+
+export class SSHTerminalManager extends EventEmitter {
+ private sshClient: SSHClient;
+ private sessions: Map = new Map();
+ private pendingCreates: Map> = new Map();
+ private config: SSHConnectionConfig;
+
+ constructor(config: SSHConnectionConfig) {
+ super();
+ this.config = config;
+ this.sshClient = new SSHClient(config);
+ this.setupEventForwarding();
+ }
+
+ private setupEventForwarding(): void {
+ // Forward connection status events
+ this.sshClient.on("connectionStatus", (status) => {
+ this.emit("connectionStatus", status);
+ });
+ }
+
+ /**
+ * Connect to the remote SSH server
+ */
+ async connect(): Promise {
+ await this.sshClient.connect();
+ }
+
+ /**
+ * Disconnect from the remote SSH server
+ */
+ disconnect(): void {
+ this.sshClient.disconnect();
+ this.sessions.clear();
+ }
+
+ /**
+ * Check if connected to SSH server
+ */
+ isConnected(): boolean {
+ return this.sshClient.isConnected();
+ }
+
+ /**
+ * Get the SSH configuration
+ */
+ getConfig(): SSHConnectionConfig {
+ return this.config;
+ }
+
+ /**
+ * Create a new terminal session or attach to existing one
+ */
+ async createOrAttach(params: {
+ paneId: string;
+ tabId: string;
+ workspaceId: string;
+ workspaceName?: string;
+ workspacePath?: string;
+ rootPath?: string;
+ cwd?: string;
+ cols?: number;
+ rows?: number;
+ initialCommands?: string[];
+ }): Promise<{
+ isNew: boolean;
+ scrollback: string;
+ wasRecovered: boolean;
+ viewportY?: number;
+ }> {
+ const { paneId } = params;
+
+ // Check for pending create
+ const pending = this.pendingCreates.get(paneId);
+ if (pending) {
+ return pending as Promise<{
+ isNew: boolean;
+ scrollback: string;
+ wasRecovered: boolean;
+ viewportY?: number;
+ }>;
+ }
+
+ // Check for existing session
+ const existing = this.sessions.get(paneId);
+ if (existing && existing.isAlive && this.sshClient.hasChannel(paneId)) {
+ existing.lastActive = Date.now();
+ return {
+ isNew: false,
+ scrollback: "",
+ wasRecovered: false,
+ viewportY: existing.viewportY,
+ };
+ }
+
+ // Create new session
+ const createPromise = this.createSession(params);
+ this.pendingCreates.set(paneId, createPromise);
+
+ try {
+ const result = await createPromise;
+ return result;
+ } finally {
+ this.pendingCreates.delete(paneId);
+ }
+ }
+
+ private async createSession(params: {
+ paneId: string;
+ workspaceId: string;
+ cwd?: string;
+ cols?: number;
+ rows?: number;
+ initialCommands?: string[];
+ }): Promise<{
+ isNew: boolean;
+ scrollback: string;
+ wasRecovered: boolean;
+ viewportY?: number;
+ }> {
+ const { paneId, workspaceId, cols, rows, initialCommands } = params;
+ // Use remote work dir from config, or provided cwd
+ const cwd = params.cwd ?? this.config.remoteWorkDir ?? "~";
+
+ // Ensure connected
+ if (!this.isConnected()) {
+ await this.connect();
+ }
+
+ // Create PTY channel
+ const channel = await this.sshClient.createPtyChannel({
+ paneId,
+ cols: cols ?? DEFAULT_COLS,
+ rows: rows ?? DEFAULT_ROWS,
+ cwd,
+ });
+
+ // Set up event listeners
+ this.sshClient.on(`data:${paneId}`, (data: string) => {
+ this.emit(`data:${paneId}`, data);
+ const session = this.sessions.get(paneId);
+ if (session) {
+ session.lastActive = Date.now();
+ }
+ });
+
+ this.sshClient.on(`exit:${paneId}`, (exitCode: number, signal?: number) => {
+ const session = this.sessions.get(paneId);
+ if (session) {
+ session.isAlive = false;
+ }
+ this.emit(`exit:${paneId}`, exitCode, signal);
+ this.emit("terminalExit", { paneId, exitCode, signal });
+ });
+
+ this.sshClient.on(`error:${paneId}`, (error: string) => {
+ this.emit(`error:${paneId}`, error);
+ });
+
+ // Create session record
+ const session: SSHSession = {
+ paneId,
+ workspaceId,
+ cwd,
+ cols: cols ?? DEFAULT_COLS,
+ rows: rows ?? DEFAULT_ROWS,
+ lastActive: Date.now(),
+ isAlive: true,
+ };
+ this.sessions.set(paneId, session);
+
+ // Run initial commands if any
+ if (initialCommands && initialCommands.length > 0) {
+ for (const cmd of initialCommands) {
+ channel.write(`${cmd}\n`);
+ }
+ }
+
+ return {
+ isNew: true,
+ scrollback: "",
+ wasRecovered: false,
+ };
+ }
+
+ /**
+ * Write data to terminal
+ */
+ write(params: { paneId: string; data: string }): void {
+ this.sshClient.write(params.paneId, params.data);
+ }
+
+ /**
+ * Resize terminal
+ */
+ resize(params: { paneId: string; cols: number; rows: number }): void {
+ const { paneId, cols, rows } = params;
+ this.sshClient.resize(paneId, cols, rows);
+
+ const session = this.sessions.get(paneId);
+ if (session) {
+ session.cols = cols;
+ session.rows = rows;
+ }
+ }
+
+ /**
+ * Send signal to terminal
+ */
+ signal(params: { paneId: string; signal?: string }): void {
+ const { paneId, signal } = params;
+ if (signal) {
+ this.sshClient.signal(paneId, signal);
+ }
+ }
+
+ /**
+ * Kill terminal session
+ */
+ async kill(params: { paneId: string }): Promise {
+ const { paneId } = params;
+ this.sshClient.killChannel(paneId);
+ this.sessions.delete(paneId);
+ }
+
+ /**
+ * Detach from terminal (save scroll position)
+ */
+ detach(params: { paneId: string; viewportY?: number }): void {
+ const session = this.sessions.get(params.paneId);
+ if (session) {
+ session.viewportY = params.viewportY;
+ }
+ }
+
+ /**
+ * Clear scrollback buffer
+ */
+ clearScrollback(_params: { paneId: string }): void {
+ // SSH sessions don't maintain local scrollback
+ // This is handled by xterm.js in the renderer
+ }
+
+ /**
+ * Acknowledge cold restore (no-op for SSH)
+ */
+ ackColdRestore(_paneId: string): void {
+ // SSH sessions don't support cold restore
+ }
+
+ /**
+ * Get session info
+ */
+ getSession(
+ paneId: string,
+ ): { isAlive: boolean; cwd: string; lastActive: number } | null {
+ const session = this.sessions.get(paneId);
+ if (!session) {
+ return null;
+ }
+ return {
+ isAlive: session.isAlive,
+ cwd: session.cwd,
+ lastActive: session.lastActive,
+ };
+ }
+
+ /**
+ * Kill all sessions for a workspace
+ */
+ async killByWorkspaceId(
+ workspaceId: string,
+ ): Promise<{ killed: number; failed: number }> {
+ let killed = 0;
+ let failed = 0;
+
+ for (const [paneId, session] of this.sessions) {
+ if (session.workspaceId === workspaceId) {
+ try {
+ this.sshClient.killChannel(paneId);
+ this.sessions.delete(paneId);
+ killed++;
+ } catch {
+ failed++;
+ }
+ }
+ }
+
+ return { killed, failed };
+ }
+
+ /**
+ * Get session count for workspace
+ */
+ async getSessionCountByWorkspaceId(workspaceId: string): Promise {
+ let count = 0;
+ for (const session of this.sessions.values()) {
+ if (session.workspaceId === workspaceId && session.isAlive) {
+ count++;
+ }
+ }
+ return count;
+ }
+
+ /**
+ * Refresh prompts for all terminals in a workspace
+ */
+ refreshPromptsForWorkspace(workspaceId: string): void {
+ for (const [paneId, session] of this.sessions) {
+ if (session.workspaceId === workspaceId && session.isAlive) {
+ this.sshClient.write(paneId, "\n");
+ }
+ }
+ }
+
+ /**
+ * Get all sessions info
+ */
+ getAllSessions(): SSHSessionInfo[] {
+ return Array.from(this.sessions.values()).map((s) => ({
+ paneId: s.paneId,
+ workspaceId: s.workspaceId,
+ cwd: s.cwd,
+ isAlive: s.isAlive,
+ lastActive: s.lastActive,
+ }));
+ }
+
+ /**
+ * Cleanup on app quit
+ */
+ async cleanup(): Promise {
+ // Kill all sessions
+ for (const paneId of this.sessions.keys()) {
+ this.sshClient.killChannel(paneId);
+ }
+ this.sessions.clear();
+
+ // Disconnect SSH
+ this.disconnect();
+ }
+
+ /**
+ * Remove all terminal event listeners
+ */
+ detachAllListeners(): void {
+ for (const paneId of this.sessions.keys()) {
+ this.removeAllListeners(`data:${paneId}`);
+ this.removeAllListeners(`exit:${paneId}`);
+ this.removeAllListeners(`error:${paneId}`);
+ }
+ }
+}
diff --git a/apps/desktop/src/main/lib/ssh/types.ts b/apps/desktop/src/main/lib/ssh/types.ts
new file mode 100644
index 000000000..68e8f5b1f
--- /dev/null
+++ b/apps/desktop/src/main/lib/ssh/types.ts
@@ -0,0 +1,99 @@
+/**
+ * SSH Types
+ *
+ * Type definitions for SSH remote workspace connections.
+ * These types support connecting to remote servers for terminal sessions.
+ */
+
+/**
+ * SSH authentication method
+ */
+export type SSHAuthMethod = "key" | "password" | "agent";
+
+/**
+ * SSH connection configuration stored in database
+ */
+export interface SSHConnectionConfig {
+ /** Unique identifier for this SSH config */
+ id: string;
+ /** Display name for the connection */
+ name: string;
+ /** Remote host address (IP or hostname) */
+ host: string;
+ /** SSH port (default: 22) */
+ port: number;
+ /** Username for SSH connection */
+ username: string;
+ /** Authentication method */
+ authMethod: SSHAuthMethod;
+ /** Path to private key file (for key auth) */
+ privateKeyPath?: string;
+ /** Use SSH agent forwarding */
+ agentForward?: boolean;
+ /** Remote working directory (default: home directory) */
+ remoteWorkDir?: string;
+ /** Keep-alive interval in seconds (default: 60) */
+ keepAliveInterval?: number;
+ /** Connection timeout in milliseconds (default: 30000) */
+ connectionTimeout?: number;
+}
+
+/**
+ * SSH connection state
+ */
+export type SSHConnectionState =
+ | "disconnected"
+ | "connecting"
+ | "connected"
+ | "error"
+ | "reconnecting";
+
+/**
+ * SSH connection status event
+ */
+export interface SSHConnectionStatus {
+ state: SSHConnectionState;
+ error?: string;
+ /** Reconnect attempt number (if reconnecting) */
+ reconnectAttempt?: number;
+}
+
+/**
+ * SSH session information
+ */
+export interface SSHSessionInfo {
+ paneId: string;
+ workspaceId: string;
+ cwd: string;
+ isAlive: boolean;
+ lastActive: number;
+}
+
+/**
+ * Parameters for creating an SSH terminal session
+ */
+export interface CreateSSHSessionParams {
+ paneId: string;
+ tabId: string;
+ workspaceId: string;
+ workspaceName?: string;
+ /** Remote working directory */
+ cwd?: string;
+ cols?: number;
+ rows?: number;
+ /** SSH connection configuration */
+ sshConfig: SSHConnectionConfig;
+ /** Initial commands to run after connection */
+ initialCommands?: string[];
+}
+
+/**
+ * Result of creating or attaching to an SSH session
+ */
+export interface SSHSessionResult {
+ isNew: boolean;
+ /** Any initial output from the session */
+ scrollback: string;
+ wasRecovered: boolean;
+ viewportY?: number;
+}
diff --git a/apps/desktop/src/main/lib/workspace-runtime/registry.ts b/apps/desktop/src/main/lib/workspace-runtime/registry.ts
index c39b7850c..526d7bd28 100644
--- a/apps/desktop/src/main/lib/workspace-runtime/registry.ts
+++ b/apps/desktop/src/main/lib/workspace-runtime/registry.ts
@@ -4,40 +4,93 @@
* Process-scoped registry for workspace runtime providers.
* The registry is cached for the lifetime of the process.
*
- * Current behavior:
- * - All workspaces use the LocalWorkspaceRuntime
- * - The runtime is selected once based on settings (requires restart to change)
+ * Supports:
+ * - LocalWorkspaceRuntime for local workspaces
+ * - SSHWorkspaceRuntime for remote SSH workspaces
*
- * Future behavior (cloud readiness):
- * - Per-workspace selection based on workspace metadata (cloudWorkspaceId, etc.)
- * - Local + cloud workspaces can coexist
+ * Runtime selection is based on workspace metadata (sshConnectionId).
*/
import { LocalWorkspaceRuntime } from "./local";
+import { SSHWorkspaceRuntime } from "./ssh";
import type { WorkspaceRuntime, WorkspaceRuntimeRegistry } from "./types";
+import type { SSHConnectionConfig } from "../ssh/types";
+
+// =============================================================================
+// Extended Registry Interface
+// =============================================================================
+
+/**
+ * Extended registry interface with SSH support.
+ */
+export interface ExtendedWorkspaceRuntimeRegistry extends WorkspaceRuntimeRegistry {
+ /**
+ * Get or create an SSH runtime for a connection.
+ * Reuses existing runtime if already connected to the same host.
+ */
+ getSSHRuntime(config: SSHConnectionConfig): SSHWorkspaceRuntime;
+
+ /**
+ * Register a workspace as using SSH.
+ * Call this when a workspace is associated with an SSH connection.
+ */
+ registerSSHWorkspace(workspaceId: string, sshConnectionId: string): void;
+
+ /**
+ * Unregister a workspace from SSH.
+ */
+ unregisterSSHWorkspace(workspaceId: string): void;
+
+ /**
+ * Check if a workspace is using SSH.
+ */
+ isSSHWorkspace(workspaceId: string): boolean;
+
+ /**
+ * Get all active SSH runtimes.
+ */
+ getActiveSSHRuntimes(): Map;
+
+ /**
+ * Disconnect and remove an SSH runtime.
+ */
+ disconnectSSHRuntime(sshConnectionId: string): Promise;
+
+ /**
+ * Cleanup all runtimes.
+ */
+ cleanupAll(): Promise;
+}
// =============================================================================
// Registry Implementation
// =============================================================================
/**
- * Default registry implementation.
+ * Default registry implementation with SSH support.
*
- * Currently returns the same LocalWorkspaceRuntime for all workspaces.
- * The interface supports per-workspace selection for future cloud work.
+ * - Local workspaces use LocalWorkspaceRuntime
+ * - SSH workspaces use SSHWorkspaceRuntime based on their sshConnectionId
*/
-class DefaultWorkspaceRuntimeRegistry implements WorkspaceRuntimeRegistry {
+class DefaultWorkspaceRuntimeRegistry implements ExtendedWorkspaceRuntimeRegistry {
private localRuntime: LocalWorkspaceRuntime | null = null;
+ private sshRuntimes: Map = new Map();
+ private workspaceToSSH: Map = new Map(); // workspaceId -> sshConnectionId
+ private sshConfigs: Map = new Map(); // sshConnectionId -> config
/**
* Get the runtime for a specific workspace.
*
- * Currently always returns the local runtime.
- * Future: will check workspace metadata to select local vs cloud.
+ * Returns SSH runtime if workspace is registered as SSH, otherwise local.
*/
- getForWorkspaceId(_workspaceId: string): WorkspaceRuntime {
- // Currently all workspaces use the local runtime
- // Future: check workspace metadata for cloudWorkspaceId to select cloud runtime
+ getForWorkspaceId(workspaceId: string): WorkspaceRuntime {
+ const sshConnectionId = this.workspaceToSSH.get(workspaceId);
+ if (sshConnectionId) {
+ const sshRuntime = this.sshRuntimes.get(sshConnectionId);
+ if (sshRuntime) {
+ return sshRuntime;
+ }
+ }
return this.getDefault();
}
@@ -53,13 +106,97 @@ class DefaultWorkspaceRuntimeRegistry implements WorkspaceRuntimeRegistry {
}
return this.localRuntime;
}
+
+ /**
+ * Get or create an SSH runtime for a connection configuration.
+ */
+ getSSHRuntime(config: SSHConnectionConfig): SSHWorkspaceRuntime {
+ let runtime = this.sshRuntimes.get(config.id);
+ if (!runtime) {
+ console.log(`[registry] Creating new SSH runtime for ${config.name} (${config.host})`);
+ runtime = new SSHWorkspaceRuntime(config);
+ this.sshRuntimes.set(config.id, runtime);
+ this.sshConfigs.set(config.id, config);
+ }
+ return runtime;
+ }
+
+ /**
+ * Register a workspace as using SSH.
+ */
+ registerSSHWorkspace(workspaceId: string, sshConnectionId: string): void {
+ console.log(`[registry] Registering workspace ${workspaceId} with SSH connection ${sshConnectionId}`);
+ this.workspaceToSSH.set(workspaceId, sshConnectionId);
+ }
+
+ /**
+ * Unregister a workspace from SSH.
+ */
+ unregisterSSHWorkspace(workspaceId: string): void {
+ this.workspaceToSSH.delete(workspaceId);
+ }
+
+ /**
+ * Check if a workspace is using SSH.
+ */
+ isSSHWorkspace(workspaceId: string): boolean {
+ return this.workspaceToSSH.has(workspaceId);
+ }
+
+ /**
+ * Get all active SSH runtimes.
+ */
+ getActiveSSHRuntimes(): Map {
+ return new Map(this.sshRuntimes);
+ }
+
+ /**
+ * Disconnect and remove an SSH runtime.
+ */
+ async disconnectSSHRuntime(sshConnectionId: string): Promise {
+ const runtime = this.sshRuntimes.get(sshConnectionId);
+ if (runtime) {
+ console.log(`[registry] Disconnecting SSH runtime for ${sshConnectionId}`);
+ await runtime.terminal.cleanup();
+ runtime.disconnect();
+ this.sshRuntimes.delete(sshConnectionId);
+ this.sshConfigs.delete(sshConnectionId);
+
+ // Remove all workspace mappings for this SSH connection
+ for (const [workspaceId, connId] of this.workspaceToSSH) {
+ if (connId === sshConnectionId) {
+ this.workspaceToSSH.delete(workspaceId);
+ }
+ }
+ }
+ }
+
+ /**
+ * Cleanup all runtimes.
+ */
+ async cleanupAll(): Promise {
+ // Cleanup local runtime
+ if (this.localRuntime) {
+ await this.localRuntime.terminal.cleanup();
+ }
+
+ // Cleanup all SSH runtimes
+ for (const [id, runtime] of this.sshRuntimes) {
+ console.log(`[registry] Cleaning up SSH runtime ${id}`);
+ await runtime.terminal.cleanup();
+ runtime.disconnect();
+ }
+ this.sshRuntimes.clear();
+ this.sshConfigs.clear();
+ this.workspaceToSSH.clear();
+ }
}
// =============================================================================
// Singleton Instance
// =============================================================================
-let registryInstance: WorkspaceRuntimeRegistry | null = null;
+let registryInstance: ExtendedWorkspaceRuntimeRegistry | null = null;
/**
* Get the workspace runtime registry.
@@ -70,9 +207,9 @@ let registryInstance: WorkspaceRuntimeRegistry | null = null;
* This design allows:
* 1. Stable runtime instances (no re-creation on each call)
* 2. Consistent event wiring (same backend for all listeners)
- * 3. Future per-workspace selection (local vs cloud)
+ * 3. Per-workspace selection (local vs SSH)
*/
-export function getWorkspaceRuntimeRegistry(): WorkspaceRuntimeRegistry {
+export function getWorkspaceRuntimeRegistry(): ExtendedWorkspaceRuntimeRegistry {
if (!registryInstance) {
registryInstance = new DefaultWorkspaceRuntimeRegistry();
}
diff --git a/apps/desktop/src/main/lib/workspace-runtime/ssh.ts b/apps/desktop/src/main/lib/workspace-runtime/ssh.ts
new file mode 100644
index 000000000..0a47c5f2e
--- /dev/null
+++ b/apps/desktop/src/main/lib/workspace-runtime/ssh.ts
@@ -0,0 +1,267 @@
+/**
+ * SSH Workspace Runtime
+ *
+ * This is the SSH/remote implementation of WorkspaceRuntime that wraps
+ * SSHTerminalManager for remote terminal sessions.
+ *
+ * Similar to LocalWorkspaceRuntime but connects to remote servers via SSH.
+ */
+
+import type {
+ TerminalCapabilities,
+ TerminalManagement,
+ TerminalRuntime,
+ WorkspaceRuntime,
+ WorkspaceRuntimeId,
+} from "./types";
+import type { SSHConnectionConfig } from "../ssh/types";
+import { SSHTerminalManager } from "../ssh/ssh-terminal-manager";
+
+// =============================================================================
+// SSH Terminal Runtime Adapter
+// =============================================================================
+
+/**
+ * Adapts SSHTerminalManager to the TerminalRuntime interface.
+ *
+ * This adapter wraps the SSH manager with the common interface,
+ * allowing it to be used interchangeably with local terminals.
+ */
+class SSHTerminalRuntime implements TerminalRuntime {
+ private readonly backend: SSHTerminalManager;
+
+ readonly management: TerminalManagement | null = null; // SSH doesn't support daemon management
+ readonly capabilities: TerminalCapabilities;
+
+ constructor(backend: SSHTerminalManager) {
+ this.backend = backend;
+
+ // SSH sessions don't persist across app restarts (no daemon)
+ this.capabilities = {
+ persistent: false,
+ coldRestore: false,
+ };
+ }
+
+ // ===========================================================================
+ // Session Operations (delegate to backend)
+ // ===========================================================================
+
+ createOrAttach: TerminalRuntime["createOrAttach"] = (params) => {
+ return this.backend.createOrAttach(params);
+ };
+
+ write: TerminalRuntime["write"] = (params) => {
+ return this.backend.write(params);
+ };
+
+ resize: TerminalRuntime["resize"] = (params) => {
+ return this.backend.resize(params);
+ };
+
+ signal: TerminalRuntime["signal"] = (params) => {
+ return this.backend.signal(params);
+ };
+
+ kill: TerminalRuntime["kill"] = (params) => {
+ return this.backend.kill(params);
+ };
+
+ detach: TerminalRuntime["detach"] = (params) => {
+ return this.backend.detach(params);
+ };
+
+ clearScrollback: TerminalRuntime["clearScrollback"] = (params) => {
+ return this.backend.clearScrollback(params);
+ };
+
+ ackColdRestore: TerminalRuntime["ackColdRestore"] = (paneId) => {
+ return this.backend.ackColdRestore(paneId);
+ };
+
+ getSession: TerminalRuntime["getSession"] = (paneId) => {
+ return this.backend.getSession(paneId);
+ };
+
+ // ===========================================================================
+ // Workspace Operations (delegate to backend)
+ // ===========================================================================
+
+ killByWorkspaceId: TerminalRuntime["killByWorkspaceId"] = (workspaceId) => {
+ return this.backend.killByWorkspaceId(workspaceId);
+ };
+
+ getSessionCountByWorkspaceId: TerminalRuntime["getSessionCountByWorkspaceId"] =
+ (workspaceId) => {
+ return this.backend.getSessionCountByWorkspaceId(workspaceId);
+ };
+
+ refreshPromptsForWorkspace: TerminalRuntime["refreshPromptsForWorkspace"] = (
+ workspaceId,
+ ) => {
+ return this.backend.refreshPromptsForWorkspace(workspaceId);
+ };
+
+ // ===========================================================================
+ // Event Source (delegate to backend EventEmitter)
+ // ===========================================================================
+
+ on(event: string | symbol, listener: (...args: unknown[]) => void): this {
+ this.backend.on(event, listener);
+ return this;
+ }
+
+ off(event: string | symbol, listener: (...args: unknown[]) => void): this {
+ this.backend.off(event, listener);
+ return this;
+ }
+
+ once(event: string | symbol, listener: (...args: unknown[]) => void): this {
+ this.backend.once(event, listener);
+ return this;
+ }
+
+ emit(event: string | symbol, ...args: unknown[]): boolean {
+ return this.backend.emit(event, ...args);
+ }
+
+ addListener(
+ event: string | symbol,
+ listener: (...args: unknown[]) => void,
+ ): this {
+ this.backend.addListener(event, listener);
+ return this;
+ }
+
+ removeListener(
+ event: string | symbol,
+ listener: (...args: unknown[]) => void,
+ ): this {
+ this.backend.removeListener(event, listener);
+ return this;
+ }
+
+ removeAllListeners(event?: string | symbol): this {
+ this.backend.removeAllListeners(event);
+ return this;
+ }
+
+ setMaxListeners(n: number): this {
+ this.backend.setMaxListeners(n);
+ return this;
+ }
+
+ getMaxListeners(): number {
+ return this.backend.getMaxListeners();
+ }
+
+ // biome-ignore lint/complexity/noBannedTypes: EventEmitter interface requires Function[]
+ listeners(event: string | symbol): Function[] {
+ return this.backend.listeners(event);
+ }
+
+ // biome-ignore lint/complexity/noBannedTypes: EventEmitter interface requires Function[]
+ rawListeners(event: string | symbol): Function[] {
+ return this.backend.rawListeners(event);
+ }
+
+ listenerCount(
+ event: string | symbol,
+ listener?: (...args: unknown[]) => void,
+ ): number {
+ return this.backend.listenerCount(event, listener);
+ }
+
+ prependListener(
+ event: string | symbol,
+ listener: (...args: unknown[]) => void,
+ ): this {
+ this.backend.prependListener(event, listener);
+ return this;
+ }
+
+ prependOnceListener(
+ event: string | symbol,
+ listener: (...args: unknown[]) => void,
+ ): this {
+ this.backend.prependOnceListener(event, listener);
+ return this;
+ }
+
+ eventNames(): (string | symbol)[] {
+ return this.backend.eventNames();
+ }
+
+ detachAllListeners(): void {
+ this.backend.detachAllListeners();
+ }
+
+ // ===========================================================================
+ // Cleanup
+ // ===========================================================================
+
+ cleanup: TerminalRuntime["cleanup"] = () => {
+ return this.backend.cleanup();
+ };
+}
+
+// =============================================================================
+// SSH Workspace Runtime
+// =============================================================================
+
+/**
+ * SSH workspace runtime implementation.
+ *
+ * This provides the WorkspaceRuntime interface for SSH/remote workspaces,
+ * wrapping an SSHTerminalManager.
+ */
+export class SSHWorkspaceRuntime implements WorkspaceRuntime {
+ readonly id: WorkspaceRuntimeId;
+ readonly terminal: TerminalRuntime;
+ readonly capabilities: WorkspaceRuntime["capabilities"];
+
+ private readonly sshManager: SSHTerminalManager;
+
+ constructor(config: SSHConnectionConfig) {
+ this.id = `ssh:${config.id}`;
+
+ // Create SSH terminal manager
+ this.sshManager = new SSHTerminalManager(config);
+
+ // Create terminal runtime adapter
+ this.terminal = new SSHTerminalRuntime(this.sshManager);
+
+ // Aggregate capabilities
+ this.capabilities = {
+ terminal: this.terminal.capabilities,
+ };
+ }
+
+ /**
+ * Connect to the remote SSH server
+ */
+ async connect(): Promise {
+ await this.sshManager.connect();
+ }
+
+ /**
+ * Disconnect from the remote SSH server
+ */
+ disconnect(): void {
+ this.sshManager.disconnect();
+ }
+
+ /**
+ * Check if connected
+ */
+ isConnected(): boolean {
+ return this.sshManager.isConnected();
+ }
+
+ /**
+ * Get the SSH configuration
+ */
+ getConfig(): SSHConnectionConfig {
+ return this.sshManager.getConfig();
+ }
+}
diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/ssh/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/ssh/page.tsx
new file mode 100644
index 000000000..1945250bd
--- /dev/null
+++ b/apps/desktop/src/renderer/routes/_authenticated/settings/ssh/page.tsx
@@ -0,0 +1,482 @@
+import { Button } from "@superset/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@superset/ui/dialog";
+import { Input } from "@superset/ui/input";
+import { Label } from "@superset/ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@superset/ui/select";
+import { cn } from "@superset/ui/utils";
+import { createFileRoute } from "@tanstack/react-router";
+import { useState } from "react";
+import {
+ HiOutlineCheck,
+ HiOutlineCloud,
+ HiOutlineCloudArrowDown,
+ HiOutlinePlus,
+ HiOutlineServer,
+ HiOutlineTrash,
+ HiOutlineXMark,
+} from "react-icons/hi2";
+import { electronTrpc } from "renderer/lib/electron-trpc";
+
+export const Route = createFileRoute("/_authenticated/settings/ssh/")({
+ component: SSHSettingsPage,
+});
+
+function SSHSettingsPage() {
+ const utils = electronTrpc.useUtils();
+ const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
+ const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
+ const [testingId, setTestingId] = useState(null);
+ const [testResults, setTestResults] = useState>({});
+
+ // Queries
+ const { data: connections, isLoading } = electronTrpc.ssh.listConnections.useQuery();
+ const { data: hasConfig } = electronTrpc.ssh.hasSSHConfig.useQuery();
+ const { data: configHosts } = electronTrpc.ssh.getSSHConfigHosts.useQuery();
+
+ // Mutations
+ const createConnection = electronTrpc.ssh.createConnection.useMutation({
+ onSuccess: () => {
+ utils.ssh.listConnections.invalidate();
+ setIsAddDialogOpen(false);
+ },
+ });
+
+ const deleteConnection = electronTrpc.ssh.deleteConnection.useMutation({
+ onSuccess: () => {
+ utils.ssh.listConnections.invalidate();
+ },
+ });
+
+ const testConnection = electronTrpc.ssh.testConnection.useMutation({
+ onSuccess: (result, variables) => {
+ setTestResults((prev) => ({
+ ...prev,
+ [variables.id]: result,
+ }));
+ setTestingId(null);
+ },
+ onError: (error, variables) => {
+ setTestResults((prev) => ({
+ ...prev,
+ [variables.id]: { success: false, message: error.message },
+ }));
+ setTestingId(null);
+ },
+ });
+
+ const [importResult, setImportResult] = useState<{
+ imported: string[];
+ skipped: string[];
+ total: number;
+ } | null>(null);
+
+ const importFromConfig = electronTrpc.ssh.importFromSSHConfig.useMutation({
+ onSuccess: (result) => {
+ utils.ssh.listConnections.invalidate();
+ setImportResult(result);
+ // Only close if something was imported
+ if (result.imported.length > 0) {
+ setIsImportDialogOpen(false);
+ setImportResult(null);
+ }
+ },
+ onError: (err) => {
+ console.error("[ssh/import] Failed to import:", err);
+ setImportResult(null);
+ },
+ });
+
+ const handleTest = (id: string) => {
+ setTestingId(id);
+ setTestResults((prev) => {
+ const next = { ...prev };
+ delete next[id];
+ return next;
+ });
+ testConnection.mutate({ id });
+ };
+
+ const handleImportAll = () => {
+ importFromConfig.mutate({ skipExisting: true });
+ };
+
+ return (
+
+
+
SSH Remote Servers
+
+ Connect to remote servers via SSH to work on projects hosted elsewhere
+
+
+
+ {/* Import from SSH Config */}
+ {hasConfig && configHosts && configHosts.length > 0 && (
+
+
+
+
+
+
+ Found {configHosts.length} hosts in ~/.ssh/config
+
+
+ Import your existing SSH configurations
+
+
+
+
+
+
+ )}
+
+ {/* Add Connection Button */}
+
+
+
+
+ {/* Connections List */}
+ {isLoading ? (
+
+ Loading connections...
+
+ ) : connections && connections.length > 0 ? (
+
+ {connections.map((conn) => {
+ const testResult = testResults[conn.id];
+ const isTesting = testingId === conn.id;
+
+ return (
+
+
+
+
+
+
+
{conn.name}
+
+ {conn.username}@{conn.host}:{conn.port}
+
+
+
+
+
+ {/* Test Result Badge */}
+ {testResult && (
+
+ {testResult.success ? (
+ <>
+
+ Connected
+ >
+ ) : (
+ <>
+
+ Failed
+ >
+ )}
+
+ )}
+
+ {/* Test Button */}
+
+
+ {/* Delete Button */}
+
+
+
+ );
+ })}
+
+ ) : (
+
+
+
No SSH connections configured
+
+ Add a connection to work on remote projects
+
+
+ )}
+
+ );
+}
+
+function AddConnectionForm({
+ onSubmit,
+ isPending,
+ onCancel,
+}: {
+ onSubmit: (data: {
+ name: string;
+ host: string;
+ port: number;
+ username: string;
+ authMethod: "key" | "agent";
+ privateKeyPath?: string;
+ remoteWorkDir?: string;
+ }) => void;
+ isPending: boolean;
+ onCancel: () => void;
+}) {
+ const [name, setName] = useState("");
+ const [host, setHost] = useState("");
+ const [port, setPort] = useState("22");
+ const [username, setUsername] = useState("");
+ const [authMethod, setAuthMethod] = useState<"key" | "agent">("agent");
+ const [privateKeyPath, setPrivateKeyPath] = useState("~/.ssh/id_rsa");
+ const [remoteWorkDir, setRemoteWorkDir] = useState("");
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ onSubmit({
+ name,
+ host,
+ port: parseInt(port, 10),
+ username,
+ authMethod,
+ privateKeyPath: authMethod === "key" ? privateKeyPath : undefined,
+ remoteWorkDir: remoteWorkDir || undefined,
+ });
+ };
+
+ return (
+
+ );
+}
diff --git a/apps/desktop/src/renderer/screens/main/components/StartView/OpenRemoteDialog.tsx b/apps/desktop/src/renderer/screens/main/components/StartView/OpenRemoteDialog.tsx
new file mode 100644
index 000000000..6323f172a
--- /dev/null
+++ b/apps/desktop/src/renderer/screens/main/components/StartView/OpenRemoteDialog.tsx
@@ -0,0 +1,225 @@
+import { useState } from "react";
+import { HiOutlineServer, HiOutlineCog6Tooth } from "react-icons/hi2";
+import { Link } from "@tanstack/react-router";
+import { electronTrpc } from "renderer/lib/electron-trpc";
+
+interface OpenRemoteDialogProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onError: (error: string) => void;
+}
+
+export function OpenRemoteDialog({
+ isOpen,
+ onClose,
+ onError,
+}: OpenRemoteDialogProps) {
+ const [selectedConnectionId, setSelectedConnectionId] = useState("");
+ const [remotePath, setRemotePath] = useState("");
+ const [projectName, setProjectName] = useState("");
+
+ const utils = electronTrpc.useUtils();
+ const { data: connections = [], isLoading: loadingConnections } =
+ electronTrpc.ssh.listConnections.useQuery();
+
+ const createRemoteProject = electronTrpc.ssh.createRemoteProject.useMutation({
+ onSuccess: (project) => {
+ // Create a workspace for the project
+ createRemoteWorkspace.mutate({
+ remoteProjectId: project.id,
+ branch: "main",
+ name: "main",
+ });
+ },
+ onError: (err) => {
+ onError(err.message || "Failed to create remote project");
+ },
+ });
+
+ const createRemoteWorkspace =
+ electronTrpc.ssh.createRemoteWorkspace.useMutation({
+ onSuccess: () => {
+ utils.ssh.listRemoteProjects.invalidate();
+ onClose();
+ resetForm();
+ },
+ onError: (err) => {
+ onError(err.message || "Failed to create remote workspace");
+ },
+ });
+
+ const connectToServer = electronTrpc.ssh.connect.useMutation({
+ onSuccess: () => {
+ // After connecting, create the remote project
+ createRemoteProject.mutate({
+ sshConnectionId: selectedConnectionId,
+ remotePath: remotePath.trim(),
+ name: projectName.trim() || remotePath.split("/").pop() || "Remote Project",
+ });
+ },
+ onError: (err) => {
+ onError(err.message || "Failed to connect to server");
+ },
+ });
+
+ const resetForm = () => {
+ setSelectedConnectionId("");
+ setRemotePath("");
+ setProjectName("");
+ };
+
+ const handleConnect = () => {
+ if (!selectedConnectionId) {
+ onError("Please select a server");
+ return;
+ }
+ if (!remotePath.trim()) {
+ onError("Please enter a remote path");
+ return;
+ }
+
+ // Connect to server and then create project
+ connectToServer.mutate({ id: selectedConnectionId });
+ };
+
+ if (!isOpen) return null;
+
+ const isLoading =
+ connectToServer.isPending ||
+ createRemoteProject.isPending ||
+ createRemoteWorkspace.isPending;
+
+ const selectedConnection = connections.find(
+ (c) => c.id === selectedConnectionId,
+ );
+
+ return (
+
+
+
+ Open Remote Project
+
+
+ {loadingConnections ? (
+
+ Loading servers...
+
+ ) : connections.length === 0 ? (
+
+
+
+ No SSH servers configured
+
+
+
+ Configure SSH Servers
+
+
+ ) : (
+
+ {/* Server Selection */}
+
+
+
+
+
+ {/* Remote Path */}
+
+
+ setRemotePath(e.target.value)}
+ placeholder={
+ selectedConnection?.remoteWorkDir ||
+ "/home/user/projects/my-project"
+ }
+ className="w-full px-3 py-2.5 bg-background border border-border rounded-md text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:border-ring transition-colors"
+ disabled={isLoading}
+ />
+
+
+ {/* Project Name (optional) */}
+
+
+ setProjectName(e.target.value)}
+ placeholder="Derived from path if not specified"
+ className="w-full px-3 py-2.5 bg-background border border-border rounded-md text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:border-ring transition-colors"
+ disabled={isLoading}
+ />
+
+
+ {/* Action Buttons */}
+
+
+ Manage Servers
+
+
+
+
+
+
+
+ )}
+
+
+ );
+}
diff --git a/apps/desktop/src/renderer/screens/main/components/StartView/index.tsx b/apps/desktop/src/renderer/screens/main/components/StartView/index.tsx
index f3ccd2d99..ce91d74e0 100644
--- a/apps/desktop/src/renderer/screens/main/components/StartView/index.tsx
+++ b/apps/desktop/src/renderer/screens/main/components/StartView/index.tsx
@@ -1,24 +1,43 @@
import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip";
+import { useNavigate } from "@tanstack/react-router";
import { useState } from "react";
-import { HiExclamationTriangle } from "react-icons/hi2";
+import { HiExclamationTriangle, HiOutlineServer } from "react-icons/hi2";
import { LuChevronUp, LuFolderGit, LuFolderOpen, LuX } from "react-icons/lu";
import { electronTrpc } from "renderer/lib/electron-trpc";
import { formatPathWithProject } from "renderer/lib/formatPath";
import { useOpenNew } from "renderer/react-query/projects";
import { useCreateBranchWorkspace } from "renderer/react-query/workspaces";
+import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation";
+import { useTabsStore } from "renderer/stores/tabs/store";
import { ActionCard } from "./ActionCard";
import { CloneRepoDialog } from "./CloneRepoDialog";
import { InitGitDialog } from "./InitGitDialog";
+import { OpenRemoteDialog } from "./OpenRemoteDialog";
import { StartTopBar } from "./StartTopBar";
export function StartView() {
+ const navigate = useNavigate();
const { data: recentProjects = [] } =
electronTrpc.projects.getRecents.useQuery();
+ const { data: remoteProjects = [] } =
+ electronTrpc.ssh.listRemoteProjects.useQuery();
const { data: homeDir } = electronTrpc.window.getHomeDir.useQuery();
const openNew = useOpenNew();
const createBranchWorkspace = useCreateBranchWorkspace();
+ const openRemoteProject = electronTrpc.ssh.openRemoteProject.useMutation({
+ onSuccess: (data) => {
+ // Add tab for the workspace
+ useTabsStore.getState().addTab(data.workspace.id);
+ // Navigate to the workspace
+ navigateToWorkspace(data.workspace.id, navigate);
+ },
+ onError: (err) => {
+ setError(err.message || "Failed to open remote project");
+ },
+ });
const [error, setError] = useState(null);
const [isCloneDialogOpen, setIsCloneDialogOpen] = useState(false);
+ const [isRemoteDialogOpen, setIsRemoteDialogOpen] = useState(false);
const [initGitDialog, setInitGitDialog] = useState<{
isOpen: boolean;
selectedPath: string;
@@ -75,7 +94,7 @@ export function StartView() {
? recentProjects.slice(0, visibleCount)
: recentProjects.slice(0, 5);
const hasMoreToLoad = showAllProjects && recentProjects.length > visibleCount;
- const isLoading = openNew.isPending || createBranchWorkspace.isPending;
+ const isLoading = openNew.isPending || createBranchWorkspace.isPending || openRemoteProject.isPending;
return (
@@ -142,6 +161,16 @@ export function StartView() {
}}
isLoading={isLoading}
/>
+
+
{
+ setError(null);
+ setIsRemoteDialogOpen(true);
+ }}
+ isLoading={isLoading}
+ />
{/* Recent Projects */}
@@ -223,6 +252,46 @@ export function StartView() {
)}
+
+ {/* Remote Projects */}
+ {remoteProjects.length > 0 && (
+
+
+
+
+
+ Remote projects
+
+
+
+
+ {remoteProjects.map((project) => (
+
+ ))}
+
+
+
+ )}
@@ -239,6 +308,11 @@ export function StartView() {
onClose={() => setInitGitDialog({ isOpen: false, selectedPath: "" })}
onError={setError}
/>
+ setIsRemoteDialogOpen(false)}
+ onError={setError}
+ />
);
}
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx
index 40f9073ff..700e14d70 100644
--- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx
+++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx
@@ -946,7 +946,7 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => {
}
};
- electronTrpc.terminal.stream.useSubscription(paneId, {
+ electronTrpc.terminal.stream.useSubscription({ paneId, workspaceId }, {
onData: handleStreamData,
enabled: true,
});
diff --git a/bun.lock b/bun.lock
index bde1db3c6..613bee688 100644
--- a/bun.lock
+++ b/bun.lock
@@ -212,6 +212,7 @@
"semver": "^7.7.3",
"shell-quote": "^1.8.3",
"simple-git": "^3.30.0",
+ "ssh2": "^1.16.0",
"strip-ansi": "^7.1.2",
"superjson": "^2.2.5",
"tailwind-merge": "^3.4.0",
@@ -236,6 +237,7 @@
"@types/react-syntax-highlighter": "^15.5.13",
"@types/semver": "^7.7.1",
"@types/shell-quote": "^1.7.5",
+ "@types/ssh2": "^1.15.4",
"@vitejs/plugin-react": "^5.0.1",
"bun-types": "^1.3.1",
"code-inspector-plugin": "^1.2.2",
@@ -1585,6 +1587,8 @@
"@types/shell-quote": ["@types/shell-quote@1.7.5", "", {}, "sha512-+UE8GAGRPbJVQDdxi16dgadcBfQ+KG2vgZhV1+3A1XmHbmwcdwhCUwIdy+d3pAGrbvgRoVSjeI9vOWyq376Yzw=="],
+ "@types/ssh2": ["@types/ssh2@1.15.5", "", { "dependencies": { "@types/node": "^18.11.18" } }, "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ=="],
+
"@types/stats.js": ["@types/stats.js@0.17.4", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="],
"@types/tedious": ["@types/tedious@4.0.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw=="],
@@ -1741,6 +1745,8 @@
"arrify": ["arrify@1.0.1", "", {}, "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA=="],
+ "asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="],
+
"assert-plus": ["assert-plus@1.0.0", "", {}, "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw=="],
"ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="],
@@ -1779,6 +1785,8 @@
"bcp-47-match": ["bcp-47-match@2.0.3", "", {}, "sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ=="],
+ "bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="],
+
"better-auth": ["better-auth@1.4.13", "", { "dependencies": { "@better-auth/core": "1.4.13", "@better-auth/telemetry": "1.4.13", "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.0.0", "@noble/hashes": "^2.0.0", "better-call": "1.1.7", "defu": "^6.1.4", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1", "zod": "^4.1.12" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/start-server-core": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": ">=0.41.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/start-server-core", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-frGQmYT0rglidLpx91SP9n4ztaNBFGBb0JrWSdMTAHvhBkmQlUT/43e0IboMK2mPrAZFlvhdcMV8jCnqpYVE9A=="],
"better-call": ["better-call@1.1.7", "", { "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", "rou3": "^0.7.10", "set-cookie-parser": "^2.7.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-6gaJe1bBIEgVebQu/7q9saahVzvBsGaByEnE8aDVncZEDiJO7sdNB28ot9I6iXSbR25egGmmZ6aIURXyQHRraQ=="],
@@ -1811,6 +1819,8 @@
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
+ "buildcheck": ["buildcheck@0.0.7", "", {}, "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA=="],
+
"builder-util": ["builder-util@26.3.4", "", { "dependencies": { "7zip-bin": "~5.2.0", "@types/debug": "^4.1.6", "app-builder-bin": "5.0.0-alpha.12", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "cross-spawn": "^7.0.6", "debug": "^4.3.4", "fs-extra": "^10.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "js-yaml": "^4.1.0", "sanitize-filename": "^1.6.3", "source-map-support": "^0.5.19", "stat-mode": "^1.0.0", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0" } }, "sha512-aRn88mYMktHxzdqDMF6Ayj0rKoX+ZogJ75Ck7RrIqbY/ad0HBvnS2xA4uHfzrGr5D2aLL3vU6OBEH4p0KMV2XQ=="],
"builder-util-runtime": ["builder-util-runtime@9.5.1", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="],
@@ -1949,6 +1959,8 @@
"cose-base": ["cose-base@1.0.3", "", { "dependencies": { "layout-base": "^1.0.0" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="],
+ "cpu-features": ["cpu-features@0.0.10", "", { "dependencies": { "buildcheck": "~0.0.6", "nan": "^2.19.0" } }, "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA=="],
+
"crc": ["crc@3.8.0", "", { "dependencies": { "buffer": "^5.1.0" } }, "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ=="],
"cross-dirname": ["cross-dirname@0.1.0", "", {}, "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q=="],
@@ -2893,6 +2905,8 @@
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+ "nan": ["nan@2.24.0", "", {}, "sha512-Vpf9qnVW1RaDkoNKFUvfxqAbtI8ncb8OJlqZ9wwpXzWPEsvsB1nvdUi6oYrHIkQ1Y/tMDnr1h4nczS0VB9Xykg=="],
+
"nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="],
"nanostores": ["nanostores@1.1.0", "", {}, "sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA=="],
@@ -3419,6 +3433,8 @@
"sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="],
+ "ssh2": ["ssh2@1.17.0", "", { "dependencies": { "asn1": "^0.2.6", "bcrypt-pbkdf": "^1.0.2" }, "optionalDependencies": { "cpu-features": "~0.0.10", "nan": "^2.23.0" } }, "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ=="],
+
"ssri": ["ssri@12.0.0", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ=="],
"stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="],
@@ -3587,6 +3603,8 @@
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
+ "tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="],
+
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
"type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
@@ -3887,6 +3905,8 @@
"@tanstack/router-plugin/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
+ "@types/ssh2/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
+
"@types/three/fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],
"@uiw/react-markdown-preview/react-markdown": ["react-markdown@9.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-Yk7Z94dbgYTOrdk41Z74GoKA7rThnsbbqBTRYuxoe08qvfQ9tJVhmAKw6BJS/ZORG7kTy/s1QvYzSuaoBA1qfw=="],
@@ -4291,6 +4311,8 @@
"@sentry/webpack-plugin/unplugin/webpack-virtual-modules": ["webpack-virtual-modules@0.5.0", "", {}, "sha512-kyDivFZ7ZM0BVOUteVbDFhlRt7Ah/CSPwJdi8hBpkK7QLumUqdLtVfm/PX/hkcnrvr0i77fO5+TjZ94Pe+C9iw=="],
+ "@types/ssh2/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
+
"@uiw/react-markdown-preview/rehype-prism-plus/refractor": ["refractor@4.9.0", "", { "dependencies": { "@types/hast": "^2.0.0", "@types/prismjs": "^1.0.0", "hastscript": "^7.0.0", "parse-entities": "^4.0.0" } }, "sha512-nEG1SPXFoGGx+dcjftjv8cAjEusIh6ED1xhf5DG3C0x/k+rmZ2duKnc3QLpt6qeHv5fPb8uwN3VWN2BT7fr3Og=="],
"app-builder-lib/which/isexe": ["isexe@3.1.1", "", {}, "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ=="],
diff --git a/packages/local-db/drizzle/0012_add_ssh_tables.sql b/packages/local-db/drizzle/0012_add_ssh_tables.sql
new file mode 100644
index 000000000..64b28352d
--- /dev/null
+++ b/packages/local-db/drizzle/0012_add_ssh_tables.sql
@@ -0,0 +1,49 @@
+CREATE TABLE `remote_projects` (
+ `id` text PRIMARY KEY NOT NULL,
+ `ssh_connection_id` text NOT NULL,
+ `remote_path` text NOT NULL,
+ `name` text NOT NULL,
+ `color` text NOT NULL,
+ `tab_order` integer,
+ `last_opened_at` integer NOT NULL,
+ `created_at` integer NOT NULL,
+ `default_branch` text,
+ FOREIGN KEY (`ssh_connection_id`) REFERENCES `ssh_connections`(`id`) ON UPDATE no action ON DELETE cascade
+);
+--> statement-breakpoint
+CREATE INDEX `remote_projects_ssh_connection_id_idx` ON `remote_projects` (`ssh_connection_id`);--> statement-breakpoint
+CREATE INDEX `remote_projects_last_opened_at_idx` ON `remote_projects` (`last_opened_at`);--> statement-breakpoint
+CREATE TABLE `remote_workspaces` (
+ `id` text PRIMARY KEY NOT NULL,
+ `remote_project_id` text NOT NULL,
+ `branch` text NOT NULL,
+ `name` text NOT NULL,
+ `tab_order` integer NOT NULL,
+ `created_at` integer NOT NULL,
+ `updated_at` integer NOT NULL,
+ `last_opened_at` integer NOT NULL,
+ `is_unread` integer DEFAULT false,
+ `deleting_at` integer,
+ FOREIGN KEY (`remote_project_id`) REFERENCES `remote_projects`(`id`) ON UPDATE no action ON DELETE cascade
+);
+--> statement-breakpoint
+CREATE INDEX `remote_workspaces_remote_project_id_idx` ON `remote_workspaces` (`remote_project_id`);--> statement-breakpoint
+CREATE INDEX `remote_workspaces_last_opened_at_idx` ON `remote_workspaces` (`last_opened_at`);--> statement-breakpoint
+CREATE TABLE `ssh_connections` (
+ `id` text PRIMARY KEY NOT NULL,
+ `name` text NOT NULL,
+ `host` text NOT NULL,
+ `port` integer DEFAULT 22 NOT NULL,
+ `username` text NOT NULL,
+ `auth_method` text NOT NULL,
+ `private_key_path` text,
+ `agent_forward` integer,
+ `remote_work_dir` text,
+ `keep_alive_interval` integer,
+ `connection_timeout` integer,
+ `created_at` integer NOT NULL,
+ `last_connected_at` integer
+);
+--> statement-breakpoint
+CREATE INDEX `ssh_connections_name_idx` ON `ssh_connections` (`name`);--> statement-breakpoint
+CREATE INDEX `ssh_connections_host_idx` ON `ssh_connections` (`host`);
\ No newline at end of file
diff --git a/packages/local-db/drizzle/meta/0011_snapshot.json b/packages/local-db/drizzle/meta/0011_snapshot.json
index c54b0aa63..a98c548ba 100644
--- a/packages/local-db/drizzle/meta/0011_snapshot.json
+++ b/packages/local-db/drizzle/meta/0011_snapshot.json
@@ -2,7 +2,7 @@
"version": "6",
"dialect": "sqlite",
"id": "b74ef022-acd9-4140-b9e8-b7c92dd13b16",
- "prevId": "3177be28-43bc-4b9b-ba61-763632dee908",
+ "prevId": "b74ef022-acd9-4140-b9e8-b7c92dd13b16",
"tables": {
"organization_members": {
"name": "organization_members",
diff --git a/packages/local-db/drizzle/meta/0012_snapshot.json b/packages/local-db/drizzle/meta/0012_snapshot.json
new file mode 100644
index 000000000..6261098e4
--- /dev/null
+++ b/packages/local-db/drizzle/meta/0012_snapshot.json
@@ -0,0 +1,1335 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "52faa097-6264-4512-80d4-27f34c269738",
+ "prevId": "b74ef022-acd9-4140-b9e8-b7c92dd13b16",
+ "tables": {
+ "organization_members": {
+ "name": "organization_members",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "organization_members_organization_id_idx": {
+ "name": "organization_members_organization_id_idx",
+ "columns": [
+ "organization_id"
+ ],
+ "isUnique": false
+ },
+ "organization_members_user_id_idx": {
+ "name": "organization_members_user_id_idx",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "organization_members_organization_id_organizations_id_fk": {
+ "name": "organization_members_organization_id_organizations_id_fk",
+ "tableFrom": "organization_members",
+ "tableTo": "organizations",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "organization_members_user_id_users_id_fk": {
+ "name": "organization_members_user_id_users_id_fk",
+ "tableFrom": "organization_members",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "organizations": {
+ "name": "organizations",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "clerk_org_id": {
+ "name": "clerk_org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "github_org": {
+ "name": "github_org",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "avatar_url": {
+ "name": "avatar_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "organizations_clerk_org_id_unique": {
+ "name": "organizations_clerk_org_id_unique",
+ "columns": [
+ "clerk_org_id"
+ ],
+ "isUnique": true
+ },
+ "organizations_slug_unique": {
+ "name": "organizations_slug_unique",
+ "columns": [
+ "slug"
+ ],
+ "isUnique": true
+ },
+ "organizations_slug_idx": {
+ "name": "organizations_slug_idx",
+ "columns": [
+ "slug"
+ ],
+ "isUnique": false
+ },
+ "organizations_clerk_org_id_idx": {
+ "name": "organizations_clerk_org_id_idx",
+ "columns": [
+ "clerk_org_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "projects": {
+ "name": "projects",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "main_repo_path": {
+ "name": "main_repo_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tab_order": {
+ "name": "tab_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_opened_at": {
+ "name": "last_opened_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "config_toast_dismissed": {
+ "name": "config_toast_dismissed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "default_branch": {
+ "name": "default_branch",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "github_owner": {
+ "name": "github_owner",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "projects_main_repo_path_idx": {
+ "name": "projects_main_repo_path_idx",
+ "columns": [
+ "main_repo_path"
+ ],
+ "isUnique": false
+ },
+ "projects_last_opened_at_idx": {
+ "name": "projects_last_opened_at_idx",
+ "columns": [
+ "last_opened_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "remote_projects": {
+ "name": "remote_projects",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ssh_connection_id": {
+ "name": "ssh_connection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "remote_path": {
+ "name": "remote_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tab_order": {
+ "name": "tab_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_opened_at": {
+ "name": "last_opened_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "default_branch": {
+ "name": "default_branch",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "remote_projects_ssh_connection_id_idx": {
+ "name": "remote_projects_ssh_connection_id_idx",
+ "columns": [
+ "ssh_connection_id"
+ ],
+ "isUnique": false
+ },
+ "remote_projects_last_opened_at_idx": {
+ "name": "remote_projects_last_opened_at_idx",
+ "columns": [
+ "last_opened_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "remote_projects_ssh_connection_id_ssh_connections_id_fk": {
+ "name": "remote_projects_ssh_connection_id_ssh_connections_id_fk",
+ "tableFrom": "remote_projects",
+ "tableTo": "ssh_connections",
+ "columnsFrom": [
+ "ssh_connection_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "remote_workspaces": {
+ "name": "remote_workspaces",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "remote_project_id": {
+ "name": "remote_project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "branch": {
+ "name": "branch",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tab_order": {
+ "name": "tab_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "last_opened_at": {
+ "name": "last_opened_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_unread": {
+ "name": "is_unread",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": false
+ },
+ "deleting_at": {
+ "name": "deleting_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "remote_workspaces_remote_project_id_idx": {
+ "name": "remote_workspaces_remote_project_id_idx",
+ "columns": [
+ "remote_project_id"
+ ],
+ "isUnique": false
+ },
+ "remote_workspaces_last_opened_at_idx": {
+ "name": "remote_workspaces_last_opened_at_idx",
+ "columns": [
+ "last_opened_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "remote_workspaces_remote_project_id_remote_projects_id_fk": {
+ "name": "remote_workspaces_remote_project_id_remote_projects_id_fk",
+ "tableFrom": "remote_workspaces",
+ "tableTo": "remote_projects",
+ "columnsFrom": [
+ "remote_project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "settings": {
+ "name": "settings",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "last_active_workspace_id": {
+ "name": "last_active_workspace_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_used_app": {
+ "name": "last_used_app",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "terminal_presets": {
+ "name": "terminal_presets",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "terminal_presets_initialized": {
+ "name": "terminal_presets_initialized",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "selected_ringtone_id": {
+ "name": "selected_ringtone_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "active_organization_id": {
+ "name": "active_organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "confirm_on_quit": {
+ "name": "confirm_on_quit",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "terminal_link_behavior": {
+ "name": "terminal_link_behavior",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "terminal_persistence": {
+ "name": "terminal_persistence",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "ssh_connections": {
+ "name": "ssh_connections",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "host": {
+ "name": "host",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "port": {
+ "name": "port",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 22
+ },
+ "username": {
+ "name": "username",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "auth_method": {
+ "name": "auth_method",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "private_key_path": {
+ "name": "private_key_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "agent_forward": {
+ "name": "agent_forward",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "remote_work_dir": {
+ "name": "remote_work_dir",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "keep_alive_interval": {
+ "name": "keep_alive_interval",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "connection_timeout": {
+ "name": "connection_timeout",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "last_connected_at": {
+ "name": "last_connected_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "ssh_connections_name_idx": {
+ "name": "ssh_connections_name_idx",
+ "columns": [
+ "name"
+ ],
+ "isUnique": false
+ },
+ "ssh_connections_host_idx": {
+ "name": "ssh_connections_host_idx",
+ "columns": [
+ "host"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tasks": {
+ "name": "tasks",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status_color": {
+ "name": "status_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status_type": {
+ "name": "status_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status_position": {
+ "name": "status_position",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "priority": {
+ "name": "priority",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "repository_id": {
+ "name": "repository_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "assignee_id": {
+ "name": "assignee_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "estimate": {
+ "name": "estimate",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "due_date": {
+ "name": "due_date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "labels": {
+ "name": "labels",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "branch": {
+ "name": "branch",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "pr_url": {
+ "name": "pr_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "external_provider": {
+ "name": "external_provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "external_id": {
+ "name": "external_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "external_key": {
+ "name": "external_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "external_url": {
+ "name": "external_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_synced_at": {
+ "name": "last_synced_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sync_error": {
+ "name": "sync_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "tasks_slug_unique": {
+ "name": "tasks_slug_unique",
+ "columns": [
+ "slug"
+ ],
+ "isUnique": true
+ },
+ "tasks_slug_idx": {
+ "name": "tasks_slug_idx",
+ "columns": [
+ "slug"
+ ],
+ "isUnique": false
+ },
+ "tasks_organization_id_idx": {
+ "name": "tasks_organization_id_idx",
+ "columns": [
+ "organization_id"
+ ],
+ "isUnique": false
+ },
+ "tasks_assignee_id_idx": {
+ "name": "tasks_assignee_id_idx",
+ "columns": [
+ "assignee_id"
+ ],
+ "isUnique": false
+ },
+ "tasks_status_idx": {
+ "name": "tasks_status_idx",
+ "columns": [
+ "status"
+ ],
+ "isUnique": false
+ },
+ "tasks_created_at_idx": {
+ "name": "tasks_created_at_idx",
+ "columns": [
+ "created_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "tasks_organization_id_organizations_id_fk": {
+ "name": "tasks_organization_id_organizations_id_fk",
+ "tableFrom": "tasks",
+ "tableTo": "organizations",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "tasks_assignee_id_users_id_fk": {
+ "name": "tasks_assignee_id_users_id_fk",
+ "tableFrom": "tasks",
+ "tableTo": "users",
+ "columnsFrom": [
+ "assignee_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "tasks_creator_id_users_id_fk": {
+ "name": "tasks_creator_id_users_id_fk",
+ "tableFrom": "tasks",
+ "tableTo": "users",
+ "columnsFrom": [
+ "creator_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "users": {
+ "name": "users",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "clerk_id": {
+ "name": "clerk_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "avatar_url": {
+ "name": "avatar_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "users_clerk_id_unique": {
+ "name": "users_clerk_id_unique",
+ "columns": [
+ "clerk_id"
+ ],
+ "isUnique": true
+ },
+ "users_email_unique": {
+ "name": "users_email_unique",
+ "columns": [
+ "email"
+ ],
+ "isUnique": true
+ },
+ "users_email_idx": {
+ "name": "users_email_idx",
+ "columns": [
+ "email"
+ ],
+ "isUnique": false
+ },
+ "users_clerk_id_idx": {
+ "name": "users_clerk_id_idx",
+ "columns": [
+ "clerk_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "workspaces": {
+ "name": "workspaces",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "worktree_id": {
+ "name": "worktree_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "branch": {
+ "name": "branch",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tab_order": {
+ "name": "tab_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "last_opened_at": {
+ "name": "last_opened_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_unread": {
+ "name": "is_unread",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": false
+ },
+ "deleting_at": {
+ "name": "deleting_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "workspaces_project_id_idx": {
+ "name": "workspaces_project_id_idx",
+ "columns": [
+ "project_id"
+ ],
+ "isUnique": false
+ },
+ "workspaces_worktree_id_idx": {
+ "name": "workspaces_worktree_id_idx",
+ "columns": [
+ "worktree_id"
+ ],
+ "isUnique": false
+ },
+ "workspaces_last_opened_at_idx": {
+ "name": "workspaces_last_opened_at_idx",
+ "columns": [
+ "last_opened_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "workspaces_project_id_projects_id_fk": {
+ "name": "workspaces_project_id_projects_id_fk",
+ "tableFrom": "workspaces",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "workspaces_worktree_id_worktrees_id_fk": {
+ "name": "workspaces_worktree_id_worktrees_id_fk",
+ "tableFrom": "workspaces",
+ "tableTo": "worktrees",
+ "columnsFrom": [
+ "worktree_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "worktrees": {
+ "name": "worktrees",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "path": {
+ "name": "path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "branch": {
+ "name": "branch",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "base_branch": {
+ "name": "base_branch",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "git_status": {
+ "name": "git_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "github_status": {
+ "name": "github_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "worktrees_project_id_idx": {
+ "name": "worktrees_project_id_idx",
+ "columns": [
+ "project_id"
+ ],
+ "isUnique": false
+ },
+ "worktrees_branch_idx": {
+ "name": "worktrees_branch_idx",
+ "columns": [
+ "branch"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "worktrees_project_id_projects_id_fk": {
+ "name": "worktrees_project_id_projects_id_fk",
+ "tableFrom": "worktrees",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/packages/local-db/drizzle/meta/_journal.json b/packages/local-db/drizzle/meta/_journal.json
index c74f53f0f..6ce3743b4 100644
--- a/packages/local-db/drizzle/meta/_journal.json
+++ b/packages/local-db/drizzle/meta/_journal.json
@@ -85,6 +85,13 @@
"when": 1767750000000,
"tag": "0011_add_terminal_persistence",
"breakpoints": true
+ },
+ {
+ "idx": 12,
+ "version": "6",
+ "when": 1768664385901,
+ "tag": "0012_add_ssh_tables",
+ "breakpoints": true
}
]
-}
+}
\ No newline at end of file
diff --git a/packages/local-db/src/schema/schema.ts b/packages/local-db/src/schema/schema.ts
index 171aec794..b35564fd5 100644
--- a/packages/local-db/src/schema/schema.ts
+++ b/packages/local-db/src/schema/schema.ts
@@ -5,6 +5,8 @@ import type {
ExternalApp,
GitHubStatus,
GitStatus,
+ SSHAuthMethod,
+ SSHConnectionConfig,
TerminalLinkBehavior,
TerminalPreset,
WorkspaceType,
@@ -145,6 +147,108 @@ export const settings = sqliteTable("settings", {
export type InsertSettings = typeof settings.$inferInsert;
export type SelectSettings = typeof settings.$inferSelect;
+/**
+ * SSH Connections table - stores SSH connection configurations for remote workspaces
+ */
+export const sshConnections = sqliteTable(
+ "ssh_connections",
+ {
+ id: text("id")
+ .primaryKey()
+ .$defaultFn(() => uuidv4()),
+ name: text("name").notNull(),
+ host: text("host").notNull(),
+ port: integer("port").notNull().default(22),
+ username: text("username").notNull(),
+ authMethod: text("auth_method").notNull().$type(),
+ privateKeyPath: text("private_key_path"),
+ agentForward: integer("agent_forward", { mode: "boolean" }),
+ remoteWorkDir: text("remote_work_dir"),
+ keepAliveInterval: integer("keep_alive_interval"),
+ connectionTimeout: integer("connection_timeout"),
+ createdAt: integer("created_at")
+ .notNull()
+ .$defaultFn(() => Date.now()),
+ lastConnectedAt: integer("last_connected_at"),
+ },
+ (table) => [
+ index("ssh_connections_name_idx").on(table.name),
+ index("ssh_connections_host_idx").on(table.host),
+ ],
+);
+
+export type InsertSSHConnection = typeof sshConnections.$inferInsert;
+export type SelectSSHConnection = typeof sshConnections.$inferSelect;
+
+/**
+ * Remote Projects table - represents a project on a remote SSH server
+ */
+export const remoteProjects = sqliteTable(
+ "remote_projects",
+ {
+ id: text("id")
+ .primaryKey()
+ .$defaultFn(() => uuidv4()),
+ sshConnectionId: text("ssh_connection_id")
+ .notNull()
+ .references(() => sshConnections.id, { onDelete: "cascade" }),
+ remotePath: text("remote_path").notNull(),
+ name: text("name").notNull(),
+ color: text("color").notNull(),
+ tabOrder: integer("tab_order"),
+ lastOpenedAt: integer("last_opened_at")
+ .notNull()
+ .$defaultFn(() => Date.now()),
+ createdAt: integer("created_at")
+ .notNull()
+ .$defaultFn(() => Date.now()),
+ defaultBranch: text("default_branch"),
+ },
+ (table) => [
+ index("remote_projects_ssh_connection_id_idx").on(table.sshConnectionId),
+ index("remote_projects_last_opened_at_idx").on(table.lastOpenedAt),
+ ],
+);
+
+export type InsertRemoteProject = typeof remoteProjects.$inferInsert;
+export type SelectRemoteProject = typeof remoteProjects.$inferSelect;
+
+/**
+ * Remote Workspaces table - represents an active workspace on a remote server
+ */
+export const remoteWorkspaces = sqliteTable(
+ "remote_workspaces",
+ {
+ id: text("id")
+ .primaryKey()
+ .$defaultFn(() => uuidv4()),
+ remoteProjectId: text("remote_project_id")
+ .notNull()
+ .references(() => remoteProjects.id, { onDelete: "cascade" }),
+ branch: text("branch").notNull(),
+ name: text("name").notNull(),
+ tabOrder: integer("tab_order").notNull(),
+ createdAt: integer("created_at")
+ .notNull()
+ .$defaultFn(() => Date.now()),
+ updatedAt: integer("updated_at")
+ .notNull()
+ .$defaultFn(() => Date.now()),
+ lastOpenedAt: integer("last_opened_at")
+ .notNull()
+ .$defaultFn(() => Date.now()),
+ isUnread: integer("is_unread", { mode: "boolean" }).default(false),
+ deletingAt: integer("deleting_at"),
+ },
+ (table) => [
+ index("remote_workspaces_remote_project_id_idx").on(table.remoteProjectId),
+ index("remote_workspaces_last_opened_at_idx").on(table.lastOpenedAt),
+ ],
+);
+
+export type InsertRemoteWorkspace = typeof remoteWorkspaces.$inferInsert;
+export type SelectRemoteWorkspace = typeof remoteWorkspaces.$inferSelect;
+
// =============================================================================
// Synced tables - mirrored from cloud Postgres via Electric SQL
// Column names match Postgres exactly (snake_case) so Electric data writes directly
diff --git a/packages/local-db/src/schema/zod.ts b/packages/local-db/src/schema/zod.ts
index 1aa69d75a..48adbbb17 100644
--- a/packages/local-db/src/schema/zod.ts
+++ b/packages/local-db/src/schema/zod.ts
@@ -108,3 +108,29 @@ export const TERMINAL_LINK_BEHAVIORS = [
] as const;
export type TerminalLinkBehavior = (typeof TERMINAL_LINK_BEHAVIORS)[number];
+
+/**
+ * SSH authentication methods
+ */
+export const SSH_AUTH_METHODS = ["key", "password", "agent"] as const;
+
+export type SSHAuthMethod = (typeof SSH_AUTH_METHODS)[number];
+
+/**
+ * SSH connection configuration
+ */
+export const sshConnectionConfigSchema = z.object({
+ id: z.string(),
+ name: z.string(),
+ host: z.string(),
+ port: z.number().default(22),
+ username: z.string(),
+ authMethod: z.enum(SSH_AUTH_METHODS),
+ privateKeyPath: z.string().optional(),
+ agentForward: z.boolean().optional(),
+ remoteWorkDir: z.string().optional(),
+ keepAliveInterval: z.number().optional(),
+ connectionTimeout: z.number().optional(),
+});
+
+export type SSHConnectionConfig = z.infer;
From fb5414b8d8d058364703226cfccea8f5c525cd23 Mon Sep 17 00:00:00 2001
From: Marcelo Alves
Date: Sat, 17 Jan 2026 18:11:36 +0000
Subject: [PATCH 02/11] feat(desktop): add Windows support for SSH connections
- Add Windows SSH agent support using OpenSSH named pipe
(\\.\pipe\openssh-ssh-agent) instead of SSH_AUTH_SOCK
- Auto-detect default SSH keys (id_ed25519, id_rsa, id_ecdsa)
instead of hardcoding id_rsa
- Fix tilde expansion to handle both forward and back slashes
for cross-platform path compatibility
Co-Authored-By: Claude Opus 4.5
---
apps/desktop/src/main/lib/ssh/ssh-client.ts | 42 +++++++++++++++++----
1 file changed, 34 insertions(+), 8 deletions(-)
diff --git a/apps/desktop/src/main/lib/ssh/ssh-client.ts b/apps/desktop/src/main/lib/ssh/ssh-client.ts
index 76822f023..e638c7e6e 100644
--- a/apps/desktop/src/main/lib/ssh/ssh-client.ts
+++ b/apps/desktop/src/main/lib/ssh/ssh-client.ts
@@ -184,11 +184,28 @@ export class SSHClient extends EventEmitter {
switch (this.config.authMethod) {
case "key": {
- let keyPath =
- this.config.privateKeyPath ?? path.join(os.homedir(), ".ssh", "id_rsa");
- // Expand ~ to home directory
+ let keyPath = this.config.privateKeyPath;
+
+ // If no key path specified, try common default locations
+ if (!keyPath) {
+ const sshDir = path.join(os.homedir(), ".ssh");
+ // Try id_ed25519 first (more common now), then id_rsa
+ const defaultKeys = ["id_ed25519", "id_rsa", "id_ecdsa"];
+ for (const keyName of defaultKeys) {
+ const candidatePath = path.join(sshDir, keyName);
+ if (fs.existsSync(candidatePath)) {
+ keyPath = candidatePath;
+ break;
+ }
+ }
+ if (!keyPath) {
+ keyPath = path.join(sshDir, "id_rsa"); // Fallback for error message
+ }
+ }
+
+ // Expand ~ to home directory (handles Unix-style paths in config files)
if (keyPath.startsWith("~")) {
- keyPath = keyPath.replace(/^~/, os.homedir());
+ keyPath = keyPath.replace(/^~[/\\]?/, os.homedir() + path.sep);
}
console.log(`[ssh/client] Reading private key from: ${keyPath}`);
try {
@@ -200,10 +217,19 @@ export class SSHClient extends EventEmitter {
break;
}
case "agent": {
- // Use SSH agent from environment
- config.agent = process.env.SSH_AUTH_SOCK;
- if (!config.agent) {
- throw new Error("SSH agent not available (SSH_AUTH_SOCK not set)");
+ // Use SSH agent - platform-specific handling
+ if (process.platform === "win32") {
+ // Windows: OpenSSH agent uses a named pipe
+ // Check for OpenSSH agent pipe (Windows 10+)
+ const opensshPipe = "\\\\.\\pipe\\openssh-ssh-agent";
+ config.agent = opensshPipe;
+ console.log(`[ssh/client] Using Windows OpenSSH agent pipe: ${opensshPipe}`);
+ } else {
+ // Unix/macOS: Use SSH_AUTH_SOCK environment variable
+ config.agent = process.env.SSH_AUTH_SOCK;
+ if (!config.agent) {
+ throw new Error("SSH agent not available (SSH_AUTH_SOCK not set)");
+ }
}
break;
}
From 4f6ec5ce0c2829746dca3d854429eb3c85b9137a Mon Sep 17 00:00:00 2001
From: Marcelo Alves
Date: Sat, 17 Jan 2026 18:26:37 +0000
Subject: [PATCH 03/11] fix(desktop): improve Windows SSH support reliability
- Add path.normalize() after tilde expansion to ensure consistent
path separators on Windows
- Verify Windows OpenSSH agent pipe exists before using it, with
helpful error message if the ssh-agent service is not running
Co-Authored-By: Claude Opus 4.5
---
apps/desktop/src/main/lib/ssh/ssh-client.ts | 19 +++++++++++++++----
1 file changed, 15 insertions(+), 4 deletions(-)
diff --git a/apps/desktop/src/main/lib/ssh/ssh-client.ts b/apps/desktop/src/main/lib/ssh/ssh-client.ts
index e638c7e6e..8253791c4 100644
--- a/apps/desktop/src/main/lib/ssh/ssh-client.ts
+++ b/apps/desktop/src/main/lib/ssh/ssh-client.ts
@@ -204,8 +204,11 @@ export class SSHClient extends EventEmitter {
}
// Expand ~ to home directory (handles Unix-style paths in config files)
+ // and normalize to ensure consistent path separators on Windows
if (keyPath.startsWith("~")) {
- keyPath = keyPath.replace(/^~[/\\]?/, os.homedir() + path.sep);
+ keyPath = path.normalize(
+ keyPath.replace(/^~[/\\]?/, os.homedir() + path.sep)
+ );
}
console.log(`[ssh/client] Reading private key from: ${keyPath}`);
try {
@@ -220,10 +223,18 @@ export class SSHClient extends EventEmitter {
// Use SSH agent - platform-specific handling
if (process.platform === "win32") {
// Windows: OpenSSH agent uses a named pipe
- // Check for OpenSSH agent pipe (Windows 10+)
const opensshPipe = "\\\\.\\pipe\\openssh-ssh-agent";
- config.agent = opensshPipe;
- console.log(`[ssh/client] Using Windows OpenSSH agent pipe: ${opensshPipe}`);
+ // Verify the agent is running by checking if the pipe exists
+ try {
+ fs.accessSync(opensshPipe, fs.constants.R_OK);
+ config.agent = opensshPipe;
+ console.log(`[ssh/client] Using Windows OpenSSH agent pipe: ${opensshPipe}`);
+ } catch {
+ throw new Error(
+ "Windows OpenSSH Agent not available. Ensure the ssh-agent service is running: " +
+ "Start-Service ssh-agent (PowerShell as Admin)"
+ );
+ }
} else {
// Unix/macOS: Use SSH_AUTH_SOCK environment variable
config.agent = process.env.SSH_AUTH_SOCK;
From 0fa023d2d31e3f2acb46c468dfdbb87f08f512a8 Mon Sep 17 00:00:00 2001
From: Marcelo Alves
Date: Sat, 17 Jan 2026 18:38:01 +0000
Subject: [PATCH 04/11] fix(desktop): address CodeRabbit review issues for SSH
support
- Use path.posix for remote workspace paths in resolveCwd to prevent
mangled paths on Windows when working with POSIX remote paths
- Fix shell injection vulnerability by adding shellEscape() and
isValidRemotePath() validation for SSH cwd handling
- Fix double error handling in SSH connect by tracking isInitialConnect
flag to prevent reconnect during initial connection attempts
- Add exponential backoff for reconnection (1s, 2s, 4s, 8s, 16s) capped
at 30 seconds
- Add warning logs when write/resize/signal is called on missing channels
Co-Authored-By: Claude Opus 4.5
---
.../src/lib/trpc/routers/terminal/terminal.ts | 2 +-
.../routers/terminal/utils/resolve-cwd.ts | 43 +++++++++--
apps/desktop/src/main/lib/ssh/ssh-client.ts | 74 +++++++++++++++++--
3 files changed, 104 insertions(+), 15 deletions(-)
diff --git a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts
index c116bd4b4..a86159d51 100644
--- a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts
+++ b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts
@@ -175,7 +175,7 @@ export const createTerminalRouter = () => {
rootPath = project?.mainRepoPath;
}
- const cwd = resolveCwd(cwdOverride, workspacePath);
+ const cwd = resolveCwd(cwdOverride, workspacePath, isRemote);
if (DEBUG_TERMINAL) {
console.log("[Terminal Router] createOrAttach called:", {
diff --git a/apps/desktop/src/lib/trpc/routers/terminal/utils/resolve-cwd.ts b/apps/desktop/src/lib/trpc/routers/terminal/utils/resolve-cwd.ts
index 4f35be7ff..ce61ec0bd 100644
--- a/apps/desktop/src/lib/trpc/routers/terminal/utils/resolve-cwd.ts
+++ b/apps/desktop/src/lib/trpc/routers/terminal/utils/resolve-cwd.ts
@@ -1,6 +1,6 @@
import { existsSync } from "node:fs";
import os from "node:os";
-import { isAbsolute, join } from "node:path";
+import path from "node:path";
/**
* Resolves a cwd path against a base worktree path.
@@ -10,21 +10,45 @@ import { isAbsolute, join } from "node:path";
* - If the resolved path doesn't exist, falls back to worktreePath
* - If no cwdOverride is provided, returns the worktreePath
* - Always validates that returned paths exist, falling back to os.homedir() as a last resort
+ *
+ * For remote workspaces (isRemote=true):
+ * - Uses POSIX path semantics (forward slashes) regardless of local platform
+ * - Skips existsSync checks since the path is on a remote server
+ * - Assumes remote paths are valid since we can't verify them locally
*/
export function resolveCwd(
cwdOverride: string | undefined,
worktreePath: string | undefined,
+ isRemote = false,
): string | undefined {
- // Validate worktreePath exists if provided
- const validWorktreePath =
- worktreePath && existsSync(worktreePath) ? worktreePath : undefined;
+ // For remote workspaces, use POSIX path operations
+ const pathModule = isRemote ? path.posix : path;
+
+ // For remote paths, we can't check existence locally
+ // For local paths, validate worktreePath exists
+ const validWorktreePath = isRemote
+ ? worktreePath
+ : worktreePath && existsSync(worktreePath)
+ ? worktreePath
+ : undefined;
if (!cwdOverride) {
return validWorktreePath;
}
+ // Check if path is absolute
+ // For remote (POSIX): starts with /
+ // For local: use platform-specific isAbsolute
+ const isAbsolutePath = isRemote
+ ? cwdOverride.startsWith("/")
+ : pathModule.isAbsolute(cwdOverride);
+
// Absolute path (Unix `/...`, Windows `C:\...`, UNC `\\...`) - use if exists, otherwise fall back
- if (isAbsolute(cwdOverride)) {
+ if (isAbsolutePath) {
+ // For remote paths, assume absolute paths are valid
+ if (isRemote) {
+ return cwdOverride;
+ }
if (existsSync(cwdOverride)) {
return cwdOverride;
}
@@ -34,7 +58,7 @@ export function resolveCwd(
// No valid worktree path to resolve against - can't resolve relative path
if (!validWorktreePath) {
- return os.homedir();
+ return isRemote ? undefined : os.homedir();
}
// Relative path - resolve against worktree
@@ -43,7 +67,12 @@ export function resolveCwd(
? cwdOverride.slice(2)
: cwdOverride;
- const resolvedPath = join(validWorktreePath, relativePath);
+ const resolvedPath = pathModule.join(validWorktreePath, relativePath);
+
+ // For remote paths, assume resolved path is valid
+ if (isRemote) {
+ return resolvedPath;
+ }
// Fall back to worktreePath if resolved path doesn't exist
if (!existsSync(resolvedPath)) {
diff --git a/apps/desktop/src/main/lib/ssh/ssh-client.ts b/apps/desktop/src/main/lib/ssh/ssh-client.ts
index 8253791c4..1a10fa63b 100644
--- a/apps/desktop/src/main/lib/ssh/ssh-client.ts
+++ b/apps/desktop/src/main/lib/ssh/ssh-client.ts
@@ -20,7 +20,38 @@ const DEFAULT_PORT = 22;
const DEFAULT_KEEPALIVE_INTERVAL = 60;
const DEFAULT_CONNECTION_TIMEOUT = 30000;
const MAX_RECONNECT_ATTEMPTS = 5;
-const RECONNECT_DELAY_MS = 2000;
+const BASE_RECONNECT_DELAY_MS = 1000;
+const MAX_RECONNECT_DELAY_MS = 30000;
+
+/**
+ * Escape a string for safe use in a shell command.
+ * Uses single quotes which prevent all shell expansions.
+ * Single quotes within the string are escaped using the pattern: 'text'"'"'more'
+ */
+function shellEscape(str: string): string {
+ // Wrap in single quotes and escape any single quotes within
+ return `'${str.replace(/'/g, "'\"'\"'")}'`;
+}
+
+/**
+ * Validate that a path is safe for use as a remote cwd.
+ * Must be an absolute POSIX path with no dangerous patterns.
+ */
+function isValidRemotePath(remotePath: string): boolean {
+ // Must start with /
+ if (!remotePath.startsWith("/")) {
+ return false;
+ }
+ // Disallow null bytes
+ if (remotePath.includes("\0")) {
+ return false;
+ }
+ // Disallow .. path traversal
+ if (remotePath.includes("..")) {
+ return false;
+ }
+ return true;
+}
export class SSHClient extends EventEmitter {
private client: Client;
@@ -29,6 +60,10 @@ export class SSHClient extends EventEmitter {
private reconnectAttempts = 0;
private reconnectTimer: NodeJS.Timeout | null = null;
private channels: Map = new Map();
+ // Tracks whether we're in an initial connection attempt (vs reconnecting after established connection)
+ // This prevents double handling: the persistent error handler should not call attemptReconnect()
+ // during initial connection because connect() handles errors via promise rejection.
+ private isInitialConnect = false;
constructor(config: SSHConnectionConfig) {
super();
@@ -42,6 +77,7 @@ export class SSHClient extends EventEmitter {
console.log(`[ssh/client] Connected to ${this.config.host}`);
this.state = "connected";
this.reconnectAttempts = 0;
+ this.isInitialConnect = false; // Mark initial connection complete
this.emitStatus();
});
@@ -49,7 +85,11 @@ export class SSHClient extends EventEmitter {
console.error(`[ssh/client] Connection error:`, err.message);
this.state = "error";
this.emitStatus(err.message);
- this.attemptReconnect();
+ // Only attempt reconnect if this is not an initial connection attempt.
+ // During initial connect, the connect() method handles errors via promise rejection.
+ if (!this.isInitialConnect) {
+ this.attemptReconnect();
+ }
});
this.client.on("close", () => {
@@ -113,8 +153,14 @@ export class SSHClient extends EventEmitter {
this.reconnectAttempts++;
this.emitStatus();
+ // Exponential backoff: delay = base * 2^(attempt-1), capped at max
+ const delay = Math.min(
+ BASE_RECONNECT_DELAY_MS * Math.pow(2, this.reconnectAttempts - 1),
+ MAX_RECONNECT_DELAY_MS,
+ );
+
console.log(
- `[ssh/client] Reconnecting in ${RECONNECT_DELAY_MS}ms (attempt ${this.reconnectAttempts})`,
+ `[ssh/client] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`,
);
this.reconnectTimer = setTimeout(() => {
@@ -122,7 +168,7 @@ export class SSHClient extends EventEmitter {
this.connect().catch((err) => {
console.error(`[ssh/client] Reconnect failed:`, err.message);
});
- }, RECONNECT_DELAY_MS);
+ }, delay);
}
async connect(): Promise {
@@ -133,6 +179,8 @@ export class SSHClient extends EventEmitter {
console.log(`[ssh/client] Connecting to ${this.config.host}:${this.config.port} as ${this.config.username}`);
this.state = "connecting";
+ // Mark as initial connect to prevent persistent error handler from triggering reconnect
+ this.isInitialConnect = true;
this.emitStatus();
let connectConfig: ConnectConfig;
@@ -143,6 +191,7 @@ export class SSHClient extends EventEmitter {
const message = err instanceof Error ? err.message : String(err);
console.error(`[ssh/client] Failed to build connect config: ${message}`);
this.state = "error";
+ this.isInitialConnect = false; // Clear flag on config build failure
this.emitStatus(message);
throw err;
}
@@ -150,6 +199,7 @@ export class SSHClient extends EventEmitter {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
console.error(`[ssh/client] Connection timeout after ${this.config.connectionTimeout ?? DEFAULT_CONNECTION_TIMEOUT}ms`);
+ this.isInitialConnect = false; // Clear flag on timeout
this.client.end();
reject(new Error("Connection timeout"));
}, this.config.connectionTimeout ?? DEFAULT_CONNECTION_TIMEOUT);
@@ -157,12 +207,14 @@ export class SSHClient extends EventEmitter {
this.client.once("ready", () => {
clearTimeout(timeout);
console.log(`[ssh/client] Connection ready`);
+ // Note: isInitialConnect is cleared in the persistent ready handler
resolve();
});
this.client.once("error", (err) => {
clearTimeout(timeout);
console.error(`[ssh/client] Connection error: ${err.message}`);
+ this.isInitialConnect = false; // Clear flag on error
reject(err);
});
@@ -326,9 +378,11 @@ export class SSHClient extends EventEmitter {
this.emit(`error:${paneId}`, err.message);
});
- // Change directory if specified
- if (cwd) {
- channel.write(`cd ${JSON.stringify(cwd)} && clear\n`);
+ // Change directory if specified (with validation and proper escaping)
+ if (cwd && isValidRemotePath(cwd)) {
+ channel.write(`cd ${shellEscape(cwd)} && clear\n`);
+ } else if (cwd) {
+ console.warn(`[ssh/client] Invalid remote path for cwd, ignoring: ${cwd}`);
}
resolve(channel);
@@ -343,6 +397,8 @@ export class SSHClient extends EventEmitter {
const channel = this.channels.get(paneId);
if (channel) {
channel.write(data);
+ } else {
+ console.warn(`[ssh/client] Cannot write to ${paneId}: channel not found`);
}
}
@@ -353,6 +409,8 @@ export class SSHClient extends EventEmitter {
const channel = this.channels.get(paneId);
if (channel) {
channel.setWindow(rows, cols, 0, 0);
+ } else {
+ console.warn(`[ssh/client] Cannot resize ${paneId} to ${cols}x${rows}: channel not found`);
}
}
@@ -363,6 +421,8 @@ export class SSHClient extends EventEmitter {
const channel = this.channels.get(paneId);
if (channel) {
channel.signal(signalName);
+ } else {
+ console.warn(`[ssh/client] Cannot send signal ${signalName} to ${paneId}: channel not found`);
}
}
From 333ce5dee81c9b8844a963d811ca2ba205a3181d Mon Sep 17 00:00:00 2001
From: Marcelo Alves
Date: Sat, 17 Jan 2026 20:19:20 +0000
Subject: [PATCH 05/11] fix(desktop): address additional CodeRabbit review
feedback
- Clean up paneToWorkspace mapping on createOrAttach failure to prevent
stale routing to workspaces that never attached
- Refactor resolveCwd to use params object instead of positional args
to follow coding guidelines and avoid boolean blindness
Co-Authored-By: Claude Opus 4.5
---
.../src/lib/trpc/routers/terminal/terminal.ts | 4 +++-
.../lib/trpc/routers/terminal/utils/resolve-cwd.ts | 12 ++++++++----
2 files changed, 11 insertions(+), 5 deletions(-)
diff --git a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts
index a86159d51..bdf35837a 100644
--- a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts
+++ b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts
@@ -175,7 +175,7 @@ export const createTerminalRouter = () => {
rootPath = project?.mainRepoPath;
}
- const cwd = resolveCwd(cwdOverride, workspacePath, isRemote);
+ const cwd = resolveCwd({ cwdOverride, worktreePath: workspacePath, isRemote });
if (DEBUG_TERMINAL) {
console.log("[Terminal Router] createOrAttach called:", {
@@ -235,6 +235,8 @@ export const createTerminalRouter = () => {
snapshot: result.snapshot,
};
} catch (error) {
+ // Clean up pane mapping on failure to prevent stale routing
+ paneToWorkspace.delete(paneId);
if (DEBUG_TERMINAL) {
console.warn("[Terminal Router] createOrAttach failed:", {
callId,
diff --git a/apps/desktop/src/lib/trpc/routers/terminal/utils/resolve-cwd.ts b/apps/desktop/src/lib/trpc/routers/terminal/utils/resolve-cwd.ts
index ce61ec0bd..879bf3ec7 100644
--- a/apps/desktop/src/lib/trpc/routers/terminal/utils/resolve-cwd.ts
+++ b/apps/desktop/src/lib/trpc/routers/terminal/utils/resolve-cwd.ts
@@ -16,11 +16,15 @@ import path from "node:path";
* - Skips existsSync checks since the path is on a remote server
* - Assumes remote paths are valid since we can't verify them locally
*/
-export function resolveCwd(
- cwdOverride: string | undefined,
- worktreePath: string | undefined,
+export function resolveCwd({
+ cwdOverride,
+ worktreePath,
isRemote = false,
-): string | undefined {
+}: {
+ cwdOverride?: string;
+ worktreePath?: string;
+ isRemote?: boolean;
+}): string | undefined {
// For remote workspaces, use POSIX path operations
const pathModule = isRemote ? path.posix : path;
From 2cd5b7380d96a23adcdc730beca765d0051bab0f Mon Sep 17 00:00:00 2001
From: Marcelo Alves
Date: Sun, 18 Jan 2026 15:10:37 +0000
Subject: [PATCH 06/11] docs(desktop): add docstrings to SSH client methods for
coverage
Co-Authored-By: Claude Opus 4.5
---
apps/desktop/src/main/lib/ssh/ssh-client.ts | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/apps/desktop/src/main/lib/ssh/ssh-client.ts b/apps/desktop/src/main/lib/ssh/ssh-client.ts
index 1a10fa63b..c5e2b12ce 100644
--- a/apps/desktop/src/main/lib/ssh/ssh-client.ts
+++ b/apps/desktop/src/main/lib/ssh/ssh-client.ts
@@ -72,6 +72,7 @@ export class SSHClient extends EventEmitter {
this.setupClientHandlers();
}
+ /** Sets up persistent event handlers for SSH client connection lifecycle */
private setupClientHandlers(): void {
this.client.on("ready", () => {
console.log(`[ssh/client] Connected to ${this.config.host}`);
@@ -127,6 +128,7 @@ export class SSHClient extends EventEmitter {
});
}
+ /** Emits connection status to listeners */
private emitStatus(error?: string): void {
const status: SSHConnectionStatus = {
state: this.state,
@@ -137,6 +139,7 @@ export class SSHClient extends EventEmitter {
this.emit("connectionStatus", status);
}
+ /** Attempts to reconnect with exponential backoff after connection loss */
private attemptReconnect(): void {
if (this.reconnectTimer) {
return;
@@ -171,6 +174,7 @@ export class SSHClient extends EventEmitter {
}, delay);
}
+ /** Establishes SSH connection to the remote server */
async connect(): Promise {
if (this.state === "connected" || this.state === "connecting") {
console.log(`[ssh/client] Already ${this.state}, skipping connect`);
@@ -222,6 +226,7 @@ export class SSHClient extends EventEmitter {
});
}
+ /** Builds SSH connection config based on authentication method */
private async buildConnectConfig(): Promise {
const config: ConnectConfig = {
host: this.config.host,
@@ -308,6 +313,7 @@ export class SSHClient extends EventEmitter {
return config;
}
+ /** Disconnects from the SSH server and prevents automatic reconnection */
disconnect(): void {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
@@ -317,10 +323,12 @@ export class SSHClient extends EventEmitter {
this.client.end();
}
+ /** Returns true if currently connected to the SSH server */
isConnected(): boolean {
return this.state === "connected";
}
+ /** Returns the current connection state */
getState(): SSHConnectionState {
return this.state;
}
From 40f3ef91bd9edd3cbc1d9e8cbc393c3c6514e34a Mon Sep 17 00:00:00 2001
From: Marcelo Alves
Date: Sun, 18 Jan 2026 15:14:31 +0000
Subject: [PATCH 07/11] fix(desktop): address CodeRabbit review issues
- Validate port in SSH config parser (1-65535 range)
- Fix listener leaks in SSH terminal manager by storing and cleaning up handlers
- Add error logging in killByWorkspaceId catch block
- Add try/catch in cleanupAll to prevent aborting on individual failures
- Add try/catch/finally in disconnectSSHRuntime for consistent state cleanup
- Use project.defaultBranch instead of hardcoded "main" in OpenRemoteDialog
- Fix duplicate ID in 0011_snapshot.json migration
Co-Authored-By: Claude Opus 4.5
---
.../src/main/lib/ssh/ssh-config-parser.ts | 8 ++-
.../src/main/lib/ssh/ssh-terminal-manager.ts | 58 ++++++++++++++++---
.../main/lib/workspace-runtime/registry.ts | 51 +++++++++++-----
.../components/StartView/OpenRemoteDialog.tsx | 7 ++-
.../local-db/drizzle/meta/0011_snapshot.json | 2 +-
5 files changed, 99 insertions(+), 27 deletions(-)
diff --git a/apps/desktop/src/main/lib/ssh/ssh-config-parser.ts b/apps/desktop/src/main/lib/ssh/ssh-config-parser.ts
index 1710ea2ee..c5ad1346b 100644
--- a/apps/desktop/src/main/lib/ssh/ssh-config-parser.ts
+++ b/apps/desktop/src/main/lib/ssh/ssh-config-parser.ts
@@ -74,9 +74,13 @@ export function parseSSHConfig(configPath?: string): SSHConfigHost[] {
case "user":
currentHost.user = value;
break;
- case "port":
- currentHost.port = parseInt(value, 10);
+ case "port": {
+ const port = parseInt(value, 10);
+ if (Number.isInteger(port) && port >= 1 && port <= 65535) {
+ currentHost.port = port;
+ }
break;
+ }
case "identityfile":
// Expand ~ to home directory
currentHost.identityFile = value.replace(/^~/, os.homedir());
diff --git a/apps/desktop/src/main/lib/ssh/ssh-terminal-manager.ts b/apps/desktop/src/main/lib/ssh/ssh-terminal-manager.ts
index 1ea9ec662..319bde9a5 100644
--- a/apps/desktop/src/main/lib/ssh/ssh-terminal-manager.ts
+++ b/apps/desktop/src/main/lib/ssh/ssh-terminal-manager.ts
@@ -24,9 +24,17 @@ interface SSHSession {
viewportY?: number;
}
+/** Stored event handlers for cleanup */
+interface SessionHandlers {
+ data: (data: string) => void;
+ exit: (exitCode: number, signal?: number) => void;
+ error: (error: string) => void;
+}
+
export class SSHTerminalManager extends EventEmitter {
private sshClient: SSHClient;
private sessions: Map = new Map();
+ private sessionHandlers: Map = new Map();
private pendingCreates: Map> = new Map();
private config: SSHConnectionConfig;
@@ -55,10 +63,28 @@ export class SSHTerminalManager extends EventEmitter {
* Disconnect from the remote SSH server
*/
disconnect(): void {
+ // Remove all session handlers before disconnecting
+ for (const [paneId, handlers] of this.sessionHandlers) {
+ this.sshClient.off(`data:${paneId}`, handlers.data);
+ this.sshClient.off(`exit:${paneId}`, handlers.exit);
+ this.sshClient.off(`error:${paneId}`, handlers.error);
+ }
+ this.sessionHandlers.clear();
this.sshClient.disconnect();
this.sessions.clear();
}
+ /** Removes event handlers for a specific session to prevent listener leaks */
+ private cleanupSessionHandlers(paneId: string): void {
+ const handlers = this.sessionHandlers.get(paneId);
+ if (handlers) {
+ this.sshClient.off(`data:${paneId}`, handlers.data);
+ this.sshClient.off(`exit:${paneId}`, handlers.exit);
+ this.sshClient.off(`error:${paneId}`, handlers.error);
+ this.sessionHandlers.delete(paneId);
+ }
+ }
+
/**
* Check if connected to SSH server
*/
@@ -160,27 +186,36 @@ export class SSHTerminalManager extends EventEmitter {
cwd,
});
- // Set up event listeners
- this.sshClient.on(`data:${paneId}`, (data: string) => {
+ // Set up event listeners (store references for cleanup)
+ const dataHandler = (data: string) => {
this.emit(`data:${paneId}`, data);
const session = this.sessions.get(paneId);
if (session) {
session.lastActive = Date.now();
}
- });
+ };
- this.sshClient.on(`exit:${paneId}`, (exitCode: number, signal?: number) => {
+ const exitHandler = (exitCode: number, signal?: number) => {
const session = this.sessions.get(paneId);
if (session) {
session.isAlive = false;
}
this.emit(`exit:${paneId}`, exitCode, signal);
this.emit("terminalExit", { paneId, exitCode, signal });
- });
+ // Clean up handlers on exit
+ this.cleanupSessionHandlers(paneId);
+ };
- this.sshClient.on(`error:${paneId}`, (error: string) => {
+ const errorHandler = (error: string) => {
this.emit(`error:${paneId}`, error);
- });
+ };
+
+ // Store handlers for later cleanup
+ this.sessionHandlers.set(paneId, { data: dataHandler, exit: exitHandler, error: errorHandler });
+
+ this.sshClient.on(`data:${paneId}`, dataHandler);
+ this.sshClient.on(`exit:${paneId}`, exitHandler);
+ this.sshClient.on(`error:${paneId}`, errorHandler);
// Create session record
const session: SSHSession = {
@@ -244,6 +279,7 @@ export class SSHTerminalManager extends EventEmitter {
*/
async kill(params: { paneId: string }): Promise {
const { paneId } = params;
+ this.cleanupSessionHandlers(paneId);
this.sshClient.killChannel(paneId);
this.sessions.delete(paneId);
}
@@ -302,10 +338,16 @@ export class SSHTerminalManager extends EventEmitter {
for (const [paneId, session] of this.sessions) {
if (session.workspaceId === workspaceId) {
try {
+ this.cleanupSessionHandlers(paneId);
this.sshClient.killChannel(paneId);
this.sessions.delete(paneId);
killed++;
- } catch {
+ } catch (error) {
+ console.error(`[ssh/terminal-manager] Failed to kill SSH channel`, {
+ paneId,
+ workspaceId,
+ error: error instanceof Error ? error.message : String(error),
+ });
failed++;
}
}
diff --git a/apps/desktop/src/main/lib/workspace-runtime/registry.ts b/apps/desktop/src/main/lib/workspace-runtime/registry.ts
index 526d7bd28..e9ab354cc 100644
--- a/apps/desktop/src/main/lib/workspace-runtime/registry.ts
+++ b/apps/desktop/src/main/lib/workspace-runtime/registry.ts
@@ -157,17 +157,28 @@ class DefaultWorkspaceRuntimeRegistry implements ExtendedWorkspaceRuntimeRegistr
const runtime = this.sshRuntimes.get(sshConnectionId);
if (runtime) {
console.log(`[registry] Disconnecting SSH runtime for ${sshConnectionId}`);
- await runtime.terminal.cleanup();
- runtime.disconnect();
- this.sshRuntimes.delete(sshConnectionId);
- this.sshConfigs.delete(sshConnectionId);
-
- // Remove all workspace mappings for this SSH connection
- for (const [workspaceId, connId] of this.workspaceToSSH) {
- if (connId === sshConnectionId) {
- this.workspaceToSSH.delete(workspaceId);
+ let cleanupError: Error | undefined;
+ try {
+ await runtime.terminal.cleanup();
+ runtime.disconnect();
+ } catch (error) {
+ cleanupError = error instanceof Error ? error : new Error(String(error));
+ console.error(`[registry] Error disconnecting SSH runtime ${sshConnectionId}:`, cleanupError.message);
+ } finally {
+ // Always clean up state even if cleanup/disconnect failed
+ this.sshRuntimes.delete(sshConnectionId);
+ this.sshConfigs.delete(sshConnectionId);
+
+ // Remove all workspace mappings for this SSH connection
+ for (const [workspaceId, connId] of this.workspaceToSSH) {
+ if (connId === sshConnectionId) {
+ this.workspaceToSSH.delete(workspaceId);
+ }
}
}
+ if (cleanupError) {
+ throw cleanupError;
+ }
}
}
@@ -177,14 +188,28 @@ class DefaultWorkspaceRuntimeRegistry implements ExtendedWorkspaceRuntimeRegistr
async cleanupAll(): Promise {
// Cleanup local runtime
if (this.localRuntime) {
- await this.localRuntime.terminal.cleanup();
+ try {
+ await this.localRuntime.terminal.cleanup();
+ } catch (error) {
+ console.error(`[registry] Error cleaning up local runtime:`, error instanceof Error ? error.message : String(error));
+ }
}
- // Cleanup all SSH runtimes
+ // Cleanup all SSH runtimes (continue even if individual cleanups fail)
for (const [id, runtime] of this.sshRuntimes) {
console.log(`[registry] Cleaning up SSH runtime ${id}`);
- await runtime.terminal.cleanup();
- runtime.disconnect();
+ try {
+ await runtime.terminal.cleanup();
+ } catch (error) {
+ console.error(`[registry] Error cleaning up SSH runtime ${id}:`, error instanceof Error ? error.message : String(error));
+ } finally {
+ // Always disconnect even if cleanup failed
+ try {
+ runtime.disconnect();
+ } catch (disconnectError) {
+ console.error(`[registry] Error disconnecting SSH runtime ${id}:`, disconnectError instanceof Error ? disconnectError.message : String(disconnectError));
+ }
+ }
}
this.sshRuntimes.clear();
this.sshConfigs.clear();
diff --git a/apps/desktop/src/renderer/screens/main/components/StartView/OpenRemoteDialog.tsx b/apps/desktop/src/renderer/screens/main/components/StartView/OpenRemoteDialog.tsx
index 6323f172a..1ea3bd4b3 100644
--- a/apps/desktop/src/renderer/screens/main/components/StartView/OpenRemoteDialog.tsx
+++ b/apps/desktop/src/renderer/screens/main/components/StartView/OpenRemoteDialog.tsx
@@ -24,11 +24,12 @@ export function OpenRemoteDialog({
const createRemoteProject = electronTrpc.ssh.createRemoteProject.useMutation({
onSuccess: (project) => {
- // Create a workspace for the project
+ // Create a workspace for the project using its default branch
+ const branch = project.defaultBranch || "main";
createRemoteWorkspace.mutate({
remoteProjectId: project.id,
- branch: "main",
- name: "main",
+ branch,
+ name: branch,
});
},
onError: (err) => {
diff --git a/packages/local-db/drizzle/meta/0011_snapshot.json b/packages/local-db/drizzle/meta/0011_snapshot.json
index a98c548ba..21e5a17c9 100644
--- a/packages/local-db/drizzle/meta/0011_snapshot.json
+++ b/packages/local-db/drizzle/meta/0011_snapshot.json
@@ -1,7 +1,7 @@
{
"version": "6",
"dialect": "sqlite",
- "id": "b74ef022-acd9-4140-b9e8-b7c92dd13b16",
+ "id": "24f884ea-2a0b-46e0-984a-643b88ec7c4d",
"prevId": "b74ef022-acd9-4140-b9e8-b7c92dd13b16",
"tables": {
"organization_members": {
From 9adcbd398d9f77fd8ce80aae146d0849c7270059 Mon Sep 17 00:00:00 2001
From: Marcelo Alves
Date: Sun, 18 Jan 2026 15:34:45 +0000
Subject: [PATCH 08/11] fix(desktop): fix test files and additional CodeRabbit
issues
- Update resolve-cwd.test.ts to use object params
- Update terminal.stream.test.ts to pass object to stream subscription
- Make getTerminalForPane throw instead of falling back to default
- Move runtime.disconnect() to finally block in disconnectSSHRuntime
Co-Authored-By: Claude Opus 4.5
---
.../routers/terminal/terminal.stream.test.ts | 6 +-
.../src/lib/trpc/routers/terminal/terminal.ts | 34 +++++++---
.../terminal/utils/resolve-cwd.test.ts | 48 ++++++++++----
.../main/lib/workspace-runtime/registry.ts | 64 +++++++++++++++----
4 files changed, 114 insertions(+), 38 deletions(-)
diff --git a/apps/desktop/src/lib/trpc/routers/terminal/terminal.stream.test.ts b/apps/desktop/src/lib/trpc/routers/terminal/terminal.stream.test.ts
index 54d5e2a8f..e06c70e46 100644
--- a/apps/desktop/src/lib/trpc/routers/terminal/terminal.stream.test.ts
+++ b/apps/desktop/src/lib/trpc/routers/terminal/terminal.stream.test.ts
@@ -78,7 +78,7 @@ describe("terminal.stream", () => {
const router = createTerminalRouter();
const caller = router.createCaller({} as never);
- const stream$ = await caller.stream("pane-1");
+ const stream$ = await caller.stream({ paneId: "pane-1" });
const events: Array<{ type: string }> = [];
let didComplete = false;
@@ -117,7 +117,7 @@ describe("terminal.stream", () => {
const router = createTerminalRouter();
const caller = router.createCaller({} as never);
- const stream$ = await caller.stream("pane-2");
+ const stream$ = await caller.stream({ paneId: "pane-2" });
const events: Array<{ type: string }> = [];
let didComplete = false;
@@ -145,7 +145,7 @@ describe("terminal.stream", () => {
const router = createTerminalRouter();
const caller = router.createCaller({} as never);
- const stream$ = await caller.stream("pane-3");
+ const stream$ = await caller.stream({ paneId: "pane-3" });
const events: Array<{ type: string }> = [];
let didComplete = false;
diff --git a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts
index bdf35837a..8b7d712c4 100644
--- a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts
+++ b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts
@@ -1,6 +1,12 @@
import fs from "node:fs/promises";
import path from "node:path";
-import { projects, workspaces, worktrees, remoteWorkspaces, remoteProjects } from "@superset/local-db";
+import {
+ projects,
+ remoteProjects,
+ remoteWorkspaces,
+ workspaces,
+ worktrees,
+} from "@superset/local-db";
import { TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable";
import { eq } from "drizzle-orm";
@@ -40,15 +46,17 @@ function getTerminalForWorkspace(workspaceId: string) {
/**
* Helper to get terminal runtime for a pane.
* Uses the paneId -> workspaceId mapping to determine the correct terminal.
+ * Throws if no mapping exists - callers should reattach the session.
*/
function getTerminalForPane(paneId: string) {
const workspaceId = paneToWorkspace.get(paneId);
- if (workspaceId) {
- return getTerminalForWorkspace(workspaceId);
+ if (!workspaceId) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: `No workspace mapping found for pane ${paneId}. Session may need to be reattached.`,
+ });
}
- // Fall back to default terminal if mapping not found
- const registry = getWorkspaceRuntimeRegistry();
- return registry.getDefault().terminal;
+ return getTerminalForWorkspace(workspaceId);
}
/**
@@ -175,7 +183,11 @@ export const createTerminalRouter = () => {
rootPath = project?.mainRepoPath;
}
- const cwd = resolveCwd({ cwdOverride, worktreePath: workspacePath, isRemote });
+ const cwd = resolveCwd({
+ cwdOverride,
+ worktreePath: workspacePath,
+ isRemote,
+ });
if (DEBUG_TERMINAL) {
console.log("[Terminal Router] createOrAttach called:", {
@@ -563,7 +575,9 @@ export const createTerminalRouter = () => {
}),
stream: publicProcedure
- .input(z.object({ paneId: z.string(), workspaceId: z.string().optional() }))
+ .input(
+ z.object({ paneId: z.string(), workspaceId: z.string().optional() }),
+ )
.subscription(({ input }) => {
const { paneId, workspaceId } = input;
return observable<
@@ -573,7 +587,9 @@ export const createTerminalRouter = () => {
| { type: "error"; error: string; code?: string }
>((emit) => {
if (DEBUG_TERMINAL) {
- console.log(`[Terminal Stream] Subscribe: ${paneId}, workspaceId: ${workspaceId}`);
+ console.log(
+ `[Terminal Stream] Subscribe: ${paneId}, workspaceId: ${workspaceId}`,
+ );
}
// Get the terminal for this workspace/pane
diff --git a/apps/desktop/src/lib/trpc/routers/terminal/utils/resolve-cwd.test.ts b/apps/desktop/src/lib/trpc/routers/terminal/utils/resolve-cwd.test.ts
index 57d94adbf..3ce8c697a 100644
--- a/apps/desktop/src/lib/trpc/routers/terminal/utils/resolve-cwd.test.ts
+++ b/apps/desktop/src/lib/trpc/routers/terminal/utils/resolve-cwd.test.ts
@@ -23,15 +23,15 @@ describe("resolveCwd", () => {
describe("when cwdOverride is undefined", () => {
test("returns worktreePath when it exists", () => {
- expect(resolveCwd(undefined, worktreePath)).toBe(worktreePath);
+ expect(resolveCwd({ worktreePath })).toBe(worktreePath);
});
test("returns undefined when worktreePath is also undefined", () => {
- expect(resolveCwd(undefined, undefined)).toBeUndefined();
+ expect(resolveCwd({})).toBeUndefined();
});
test("returns undefined when worktreePath does not exist", () => {
- expect(resolveCwd(undefined, "/nonexistent/path")).toBeUndefined();
+ expect(resolveCwd({ worktreePath: "/nonexistent/path" })).toBeUndefined();
});
});
@@ -39,52 +39,72 @@ describe("resolveCwd", () => {
test("returns absolute path if it exists", () => {
// Use os.tmpdir() which exists on all platforms
const tmpDir = os.tmpdir();
- expect(resolveCwd(tmpDir, worktreePath)).toBe(tmpDir);
+ expect(resolveCwd({ cwdOverride: tmpDir, worktreePath })).toBe(tmpDir);
});
test("falls back to worktreePath when absolute path does not exist", () => {
const nonExistentPath = "/this/path/does/not/exist";
- expect(resolveCwd(nonExistentPath, worktreePath)).toBe(worktreePath);
+ expect(resolveCwd({ cwdOverride: nonExistentPath, worktreePath })).toBe(
+ worktreePath,
+ );
});
test("falls back to homedir when absolute path does not exist and worktreePath is undefined", () => {
const nonExistentPath = "/this/path/does/not/exist";
- expect(resolveCwd(nonExistentPath, undefined)).toBe(homedir);
+ expect(resolveCwd({ cwdOverride: nonExistentPath })).toBe(homedir);
});
test("falls back to homedir when both absolute path and worktreePath do not exist", () => {
const nonExistentPath = "/this/path/does/not/exist";
- expect(resolveCwd(nonExistentPath, "/also/nonexistent")).toBe(homedir);
+ expect(
+ resolveCwd({
+ cwdOverride: nonExistentPath,
+ worktreePath: "/also/nonexistent",
+ }),
+ ).toBe(homedir);
});
});
describe("when cwdOverride is relative", () => {
test("resolves relative path against worktreePath when path exists", () => {
- expect(resolveCwd("apps/desktop", worktreePath)).toBe(existingSubdir);
+ expect(resolveCwd({ cwdOverride: "apps/desktop", worktreePath })).toBe(
+ existingSubdir,
+ );
});
test("resolves ./ prefixed path against worktreePath when path exists", () => {
- expect(resolveCwd("./apps/desktop", worktreePath)).toBe(existingSubdir);
+ expect(resolveCwd({ cwdOverride: "./apps/desktop", worktreePath })).toBe(
+ existingSubdir,
+ );
});
test("falls back to worktreePath when relative path does not exist", () => {
- expect(resolveCwd("non-existent-dir", worktreePath)).toBe(worktreePath);
+ expect(
+ resolveCwd({ cwdOverride: "non-existent-dir", worktreePath }),
+ ).toBe(worktreePath);
});
test("falls back to worktreePath when ./ prefixed path does not exist", () => {
- expect(resolveCwd("./non-existent-dir", worktreePath)).toBe(worktreePath);
+ expect(
+ resolveCwd({ cwdOverride: "./non-existent-dir", worktreePath }),
+ ).toBe(worktreePath);
});
test("handles . as current directory", () => {
- expect(resolveCwd(".", worktreePath)).toBe(worktreePath);
+ expect(resolveCwd({ cwdOverride: ".", worktreePath })).toBe(worktreePath);
});
test("falls back to homedir when worktreePath is undefined", () => {
- expect(resolveCwd("apps/desktop", undefined)).toBe(homedir);
+ expect(resolveCwd({ cwdOverride: "apps/desktop" })).toBe(homedir);
});
test("falls back to homedir when worktreePath does not exist", () => {
- expect(resolveCwd("apps/desktop", "/nonexistent/path")).toBe(homedir);
+ expect(
+ resolveCwd({
+ cwdOverride: "apps/desktop",
+ worktreePath: "/nonexistent/path",
+ }),
+ ).toBe(homedir);
});
});
});
diff --git a/apps/desktop/src/main/lib/workspace-runtime/registry.ts b/apps/desktop/src/main/lib/workspace-runtime/registry.ts
index e9ab354cc..6410f7a8d 100644
--- a/apps/desktop/src/main/lib/workspace-runtime/registry.ts
+++ b/apps/desktop/src/main/lib/workspace-runtime/registry.ts
@@ -11,10 +11,10 @@
* Runtime selection is based on workspace metadata (sshConnectionId).
*/
+import type { SSHConnectionConfig } from "../ssh/types";
import { LocalWorkspaceRuntime } from "./local";
import { SSHWorkspaceRuntime } from "./ssh";
import type { WorkspaceRuntime, WorkspaceRuntimeRegistry } from "./types";
-import type { SSHConnectionConfig } from "../ssh/types";
// =============================================================================
// Extended Registry Interface
@@ -23,7 +23,8 @@ import type { SSHConnectionConfig } from "../ssh/types";
/**
* Extended registry interface with SSH support.
*/
-export interface ExtendedWorkspaceRuntimeRegistry extends WorkspaceRuntimeRegistry {
+export interface ExtendedWorkspaceRuntimeRegistry
+ extends WorkspaceRuntimeRegistry {
/**
* Get or create an SSH runtime for a connection.
* Reuses existing runtime if already connected to the same host.
@@ -72,7 +73,9 @@ export interface ExtendedWorkspaceRuntimeRegistry extends WorkspaceRuntimeRegist
* - Local workspaces use LocalWorkspaceRuntime
* - SSH workspaces use SSHWorkspaceRuntime based on their sshConnectionId
*/
-class DefaultWorkspaceRuntimeRegistry implements ExtendedWorkspaceRuntimeRegistry {
+class DefaultWorkspaceRuntimeRegistry
+ implements ExtendedWorkspaceRuntimeRegistry
+{
private localRuntime: LocalWorkspaceRuntime | null = null;
private sshRuntimes: Map = new Map();
private workspaceToSSH: Map = new Map(); // workspaceId -> sshConnectionId
@@ -113,7 +116,9 @@ class DefaultWorkspaceRuntimeRegistry implements ExtendedWorkspaceRuntimeRegistr
getSSHRuntime(config: SSHConnectionConfig): SSHWorkspaceRuntime {
let runtime = this.sshRuntimes.get(config.id);
if (!runtime) {
- console.log(`[registry] Creating new SSH runtime for ${config.name} (${config.host})`);
+ console.log(
+ `[registry] Creating new SSH runtime for ${config.name} (${config.host})`,
+ );
runtime = new SSHWorkspaceRuntime(config);
this.sshRuntimes.set(config.id, runtime);
this.sshConfigs.set(config.id, config);
@@ -125,7 +130,9 @@ class DefaultWorkspaceRuntimeRegistry implements ExtendedWorkspaceRuntimeRegistr
* Register a workspace as using SSH.
*/
registerSSHWorkspace(workspaceId: string, sshConnectionId: string): void {
- console.log(`[registry] Registering workspace ${workspaceId} with SSH connection ${sshConnectionId}`);
+ console.log(
+ `[registry] Registering workspace ${workspaceId} with SSH connection ${sshConnectionId}`,
+ );
this.workspaceToSSH.set(workspaceId, sshConnectionId);
}
@@ -156,15 +163,33 @@ class DefaultWorkspaceRuntimeRegistry implements ExtendedWorkspaceRuntimeRegistr
async disconnectSSHRuntime(sshConnectionId: string): Promise {
const runtime = this.sshRuntimes.get(sshConnectionId);
if (runtime) {
- console.log(`[registry] Disconnecting SSH runtime for ${sshConnectionId}`);
+ console.log(
+ `[registry] Disconnecting SSH runtime for ${sshConnectionId}`,
+ );
let cleanupError: Error | undefined;
+ let disconnectError: Error | undefined;
try {
await runtime.terminal.cleanup();
- runtime.disconnect();
} catch (error) {
- cleanupError = error instanceof Error ? error : new Error(String(error));
- console.error(`[registry] Error disconnecting SSH runtime ${sshConnectionId}:`, cleanupError.message);
+ cleanupError =
+ error instanceof Error ? error : new Error(String(error));
+ console.error(
+ `[registry] Error cleaning up SSH runtime ${sshConnectionId}:`,
+ cleanupError.message,
+ );
} finally {
+ // Always disconnect even if cleanup failed
+ try {
+ runtime.disconnect();
+ } catch (error) {
+ disconnectError =
+ error instanceof Error ? error : new Error(String(error));
+ console.error(
+ `[registry] Error disconnecting SSH runtime ${sshConnectionId}:`,
+ disconnectError.message,
+ );
+ }
+
// Always clean up state even if cleanup/disconnect failed
this.sshRuntimes.delete(sshConnectionId);
this.sshConfigs.delete(sshConnectionId);
@@ -176,9 +201,13 @@ class DefaultWorkspaceRuntimeRegistry implements ExtendedWorkspaceRuntimeRegistr
}
}
}
+ // Propagate the first error encountered
if (cleanupError) {
throw cleanupError;
}
+ if (disconnectError) {
+ throw disconnectError;
+ }
}
}
@@ -191,7 +220,10 @@ class DefaultWorkspaceRuntimeRegistry implements ExtendedWorkspaceRuntimeRegistr
try {
await this.localRuntime.terminal.cleanup();
} catch (error) {
- console.error(`[registry] Error cleaning up local runtime:`, error instanceof Error ? error.message : String(error));
+ console.error(
+ `[registry] Error cleaning up local runtime:`,
+ error instanceof Error ? error.message : String(error),
+ );
}
}
@@ -201,13 +233,21 @@ class DefaultWorkspaceRuntimeRegistry implements ExtendedWorkspaceRuntimeRegistr
try {
await runtime.terminal.cleanup();
} catch (error) {
- console.error(`[registry] Error cleaning up SSH runtime ${id}:`, error instanceof Error ? error.message : String(error));
+ console.error(
+ `[registry] Error cleaning up SSH runtime ${id}:`,
+ error instanceof Error ? error.message : String(error),
+ );
} finally {
// Always disconnect even if cleanup failed
try {
runtime.disconnect();
} catch (disconnectError) {
- console.error(`[registry] Error disconnecting SSH runtime ${id}:`, disconnectError instanceof Error ? disconnectError.message : String(disconnectError));
+ console.error(
+ `[registry] Error disconnecting SSH runtime ${id}:`,
+ disconnectError instanceof Error
+ ? disconnectError.message
+ : String(disconnectError),
+ );
}
}
}
From 6563f59ac49d6cdc3f1f0d00639febf7fdfb18bd Mon Sep 17 00:00:00 2001
From: Marcelo Alves
Date: Sun, 18 Jan 2026 17:18:26 +0000
Subject: [PATCH 09/11] fix(desktop): fix test workspace mapping and add
warning logs
- Pass workspaceId in stream tests to avoid getTerminalForPane throwing
- Add warning when remote project not found for workspace
- Add warning when SSH runtime not found for mapped workspace
Co-Authored-By: Claude Opus 4.5
---
.../trpc/routers/terminal/terminal.stream.test.ts | 15 ++++++++++++---
.../src/lib/trpc/routers/terminal/terminal.ts | 4 ++++
.../src/main/lib/workspace-runtime/registry.ts | 3 +++
3 files changed, 19 insertions(+), 3 deletions(-)
diff --git a/apps/desktop/src/lib/trpc/routers/terminal/terminal.stream.test.ts b/apps/desktop/src/lib/trpc/routers/terminal/terminal.stream.test.ts
index e06c70e46..487ba966f 100644
--- a/apps/desktop/src/lib/trpc/routers/terminal/terminal.stream.test.ts
+++ b/apps/desktop/src/lib/trpc/routers/terminal/terminal.stream.test.ts
@@ -78,7 +78,10 @@ describe("terminal.stream", () => {
const router = createTerminalRouter();
const caller = router.createCaller({} as never);
- const stream$ = await caller.stream({ paneId: "pane-1" });
+ const stream$ = await caller.stream({
+ paneId: "pane-1",
+ workspaceId: "test-workspace",
+ });
const events: Array<{ type: string }> = [];
let didComplete = false;
@@ -117,7 +120,10 @@ describe("terminal.stream", () => {
const router = createTerminalRouter();
const caller = router.createCaller({} as never);
- const stream$ = await caller.stream({ paneId: "pane-2" });
+ const stream$ = await caller.stream({
+ paneId: "pane-2",
+ workspaceId: "test-workspace",
+ });
const events: Array<{ type: string }> = [];
let didComplete = false;
@@ -145,7 +151,10 @@ describe("terminal.stream", () => {
const router = createTerminalRouter();
const caller = router.createCaller({} as never);
- const stream$ = await caller.stream({ paneId: "pane-3" });
+ const stream$ = await caller.stream({
+ paneId: "pane-3",
+ workspaceId: "test-workspace",
+ });
const events: Array<{ type: string }> = [];
let didComplete = false;
diff --git a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts
index 8b7d712c4..e57369ba6 100644
--- a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts
+++ b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts
@@ -154,6 +154,10 @@ export const createTerminalRouter = () => {
if (remoteProject) {
workspacePath = remoteProject.remotePath;
rootPath = remoteProject.remotePath;
+ } else {
+ console.warn(
+ `[Terminal Router] Remote project not found for workspace ${workspaceId}, remoteProjectId: ${remoteWs.remoteProjectId}`,
+ );
}
}
} else {
diff --git a/apps/desktop/src/main/lib/workspace-runtime/registry.ts b/apps/desktop/src/main/lib/workspace-runtime/registry.ts
index 6410f7a8d..3e94a0823 100644
--- a/apps/desktop/src/main/lib/workspace-runtime/registry.ts
+++ b/apps/desktop/src/main/lib/workspace-runtime/registry.ts
@@ -93,6 +93,9 @@ class DefaultWorkspaceRuntimeRegistry
if (sshRuntime) {
return sshRuntime;
}
+ console.warn(
+ `[registry] Workspace ${workspaceId} mapped to SSH ${sshConnectionId} but runtime not found, falling back to local`,
+ );
}
return this.getDefault();
}
From 581fe2b512124fd67f87bf2495178b1309f4b154 Mon Sep 17 00:00:00 2001
From: Marcelo Alves
Date: Sun, 18 Jan 2026 17:22:14 +0000
Subject: [PATCH 10/11] fix(desktop): fix type error in importFromSSHConfig
Extract hostNames and skipExisting from optional input to preserve
type information instead of using `input ?? {}` which loses types.
Co-Authored-By: Claude Opus 4.5
---
.../desktop/src/lib/trpc/routers/ssh/index.ts | 72 ++++++++++++-------
1 file changed, 47 insertions(+), 25 deletions(-)
diff --git a/apps/desktop/src/lib/trpc/routers/ssh/index.ts b/apps/desktop/src/lib/trpc/routers/ssh/index.ts
index 8be709057..485490401 100644
--- a/apps/desktop/src/lib/trpc/routers/ssh/index.ts
+++ b/apps/desktop/src/lib/trpc/routers/ssh/index.ts
@@ -9,20 +9,20 @@
import {
remoteProjects,
remoteWorkspaces,
- sshConnections,
SSH_AUTH_METHODS,
+ sshConnections,
} from "@superset/local-db";
import { TRPCError } from "@trpc/server";
-import { eq, desc } from "drizzle-orm";
+import { observable } from "@trpc/server/observable";
+import { desc, eq } from "drizzle-orm";
import { localDb } from "main/lib/local-db";
+import { getSSHConfigHosts, hasSSHConfig } from "main/lib/ssh";
import {
- getWorkspaceRuntimeRegistry,
type ExtendedWorkspaceRuntimeRegistry,
+ getWorkspaceRuntimeRegistry,
} from "main/lib/workspace-runtime/registry";
-import { getSSHConfigHosts, hasSSHConfig } from "main/lib/ssh";
import { z } from "zod";
import { publicProcedure, router } from "../..";
-import { observable } from "@trpc/server/observable";
// Get the registry with SSH support
function getRegistry(): ExtendedWorkspaceRuntimeRegistry {
@@ -94,7 +94,9 @@ export const createSSHRouter = () => {
.returning()
.get();
- console.log(`[ssh/router] Created SSH connection: ${connection.name} (${connection.host})`);
+ console.log(
+ `[ssh/router] Created SSH connection: ${connection.name} (${connection.host})`,
+ );
return connection;
}),
@@ -210,7 +212,8 @@ export const createSSHRouter = () => {
return { success: true, message: "Connection successful" };
} catch (error) {
- const message = error instanceof Error ? error.message : "Unknown error";
+ const message =
+ error instanceof Error ? error.message : "Unknown error";
return { success: false, message };
}
}),
@@ -221,7 +224,9 @@ export const createSSHRouter = () => {
connect: publicProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input }) => {
- console.log(`[ssh/router] Connect requested for connection: ${input.id}`);
+ console.log(
+ `[ssh/router] Connect requested for connection: ${input.id}`,
+ );
const connection = localDb
.select()
@@ -236,7 +241,9 @@ export const createSSHRouter = () => {
});
}
- console.log(`[ssh/router] Found connection: ${connection.name} (${connection.username}@${connection.host})`);
+ console.log(
+ `[ssh/router] Found connection: ${connection.name} (${connection.username}@${connection.host})`,
+ );
const registry = getRegistry();
const runtime = registry.getSSHRuntime({
@@ -258,7 +265,8 @@ export const createSSHRouter = () => {
await runtime.connect();
console.log(`[ssh/router] Connected to ${connection.name}`);
} catch (error) {
- const message = error instanceof Error ? error.message : String(error);
+ const message =
+ error instanceof Error ? error.message : String(error);
console.error(`[ssh/router] Connection failed: ${message}`);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
@@ -304,7 +312,9 @@ export const createSSHRouter = () => {
return {
connected: runtime.isConnected(),
- state: runtime.isConnected() ? "connected" as const : "disconnected" as const,
+ state: runtime.isConnected()
+ ? ("connected" as const)
+ : ("disconnected" as const),
};
}),
@@ -403,7 +413,9 @@ export const createSSHRouter = () => {
.returning()
.get();
- console.log(`[ssh/router] Created remote project: ${project.name} at ${project.remotePath}`);
+ console.log(
+ `[ssh/router] Created remote project: ${project.name} at ${project.remotePath}`,
+ );
return project;
}),
@@ -578,7 +590,8 @@ export const createSSHRouter = () => {
try {
await runtime.connect();
} catch (error) {
- const message = error instanceof Error ? error.message : String(error);
+ const message =
+ error instanceof Error ? error.message : String(error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Failed to connect to SSH: ${message}`,
@@ -606,7 +619,9 @@ export const createSSHRouter = () => {
})
.returning()
.get();
- console.log(`[ssh/router] Created default workspace for project: ${project.name}`);
+ console.log(
+ `[ssh/router] Created default workspace for project: ${project.name}`,
+ );
}
// Register workspace with SSH runtime
@@ -625,7 +640,9 @@ export const createSSHRouter = () => {
.where(eq(remoteWorkspaces.id, workspace.id))
.run();
- console.log(`[ssh/router] Opened remote project: ${project.name}, workspace: ${workspace.id}`);
+ console.log(
+ `[ssh/router] Opened remote project: ${project.name}, workspace: ${workspace.id}`,
+ );
return { project, workspace };
}),
@@ -666,15 +683,18 @@ export const createSSHRouter = () => {
*/
importFromSSHConfig: publicProcedure
.input(
- z.object({
- /** Specific host names to import, or empty to import all */
- hostNames: z.array(z.string()).optional(),
- /** Skip hosts that already exist (by name) */
- skipExisting: z.boolean().default(true),
- }).optional(),
+ z
+ .object({
+ /** Specific host names to import, or empty to import all */
+ hostNames: z.array(z.string()).optional(),
+ /** Skip hosts that already exist (by name) */
+ skipExisting: z.boolean().default(true),
+ })
+ .optional(),
)
.mutation(({ input }) => {
- const options = input ?? {};
+ const hostNames = input?.hostNames;
+ const skipExisting = input?.skipExisting ?? true;
const configHosts = getSSHConfigHosts();
// Get existing connection names for deduplication
@@ -689,14 +709,16 @@ export const createSSHRouter = () => {
// Filter hosts to import
let hostsToImport = configHosts;
- if (options.hostNames && options.hostNames.length > 0) {
- const namesSet = new Set(options.hostNames.map((n) => n.toLowerCase()));
+ if (hostNames && hostNames.length > 0) {
+ const namesSet = new Set(
+ hostNames.map((n: string) => n.toLowerCase()),
+ );
hostsToImport = hostsToImport.filter((h) =>
namesSet.has(h.name.toLowerCase()),
);
}
- if (options.skipExisting) {
+ if (skipExisting) {
hostsToImport = hostsToImport.filter(
(h) => !existingNames.has(h.name.toLowerCase()),
);
From 5f1d6f4a5cbd61a8bf03fb459c26394b692b4e9f Mon Sep 17 00:00:00 2001
From: Marcelo Alves
Date: Mon, 19 Jan 2026 17:59:38 +0000
Subject: [PATCH 11/11] feat(desktop): add cloud workspace support to registry
and fix listener leak
- Extend WorkspaceRuntimeRegistry to support cloud workspaces alongside SSH
- Add CloudWorkspaceRuntime interface for cloud providers to implement
- Add unified workspaceToRemote mapping for both SSH and cloud types
- Add cloud-specific methods: registerCloudRuntime, getCloudRuntime,
registerCloudWorkspace, isCloudWorkspace, disconnectCloudRuntime
- Add generic methods: getWorkspaceType, unregisterRemoteWorkspace
- Fix memory leak: connectionStatus listener now properly removed on disconnect
- Update cleanupAll to handle local, SSH, and cloud runtimes
Co-Authored-By: Claude Opus 4.5
---
.../src/app/api/auth/desktop/connect/route.ts | 2 +
apps/desktop/electron-builder.ts | 15 +
apps/desktop/electron.vite.config.ts | 1 +
apps/desktop/scripts/copy-native-modules.ts | 8 +-
.../src/lib/trpc/routers/auth/index.ts | 2 +
.../routers/workspaces/procedures/query.ts | 9 +-
apps/desktop/src/main/index.ts | 14 +-
apps/desktop/src/main/lib/ssh/index.ts | 8 +-
apps/desktop/src/main/lib/ssh/ssh-client.ts | 58 +++-
.../src/main/lib/ssh/ssh-config-parser.ts | 6 +-
.../src/main/lib/ssh/ssh-terminal-manager.ts | 26 +-
.../main/lib/workspace-runtime/registry.ts | 327 ++++++++++++++++--
.../src/main/lib/workspace-runtime/ssh.ts | 4 +-
apps/desktop/src/main/windows/main.ts | 2 +
.../GeneralSettings/GeneralSettings.tsx | 9 +-
.../_authenticated/settings/ssh/page.tsx | 76 ++--
.../components/StartView/OpenRemoteDialog.tsx | 11 +-
.../main/components/StartView/index.tsx | 5 +-
.../TabsContent/Terminal/Terminal.tsx | 11 +-
.../web/src/app/auth/desktop/success/page.tsx | 8 +-
packages/local-db/src/schema/schema.ts | 1 -
21 files changed, 495 insertions(+), 108 deletions(-)
diff --git a/apps/api/src/app/api/auth/desktop/connect/route.ts b/apps/api/src/app/api/auth/desktop/connect/route.ts
index fa9ce7918..a9d00e9e5 100644
--- a/apps/api/src/app/api/auth/desktop/connect/route.ts
+++ b/apps/api/src/app/api/auth/desktop/connect/route.ts
@@ -7,6 +7,7 @@ export async function GET(request: Request) {
const url = new URL(request.url);
const provider = url.searchParams.get("provider");
const state = url.searchParams.get("state");
+ const protocol = url.searchParams.get("protocol") || "superset";
if (!provider || !state) {
return new Response("Missing provider or state", { status: 400 });
@@ -18,6 +19,7 @@ export async function GET(request: Request) {
const successUrl = new URL(`${env.NEXT_PUBLIC_WEB_URL}/auth/desktop/success`);
successUrl.searchParams.set("desktop_state", state);
+ successUrl.searchParams.set("protocol", protocol);
const result = await auth.api.signInSocial({
body: {
diff --git a/apps/desktop/electron-builder.ts b/apps/desktop/electron-builder.ts
index 83b8a4449..b35a436a9 100644
--- a/apps/desktop/electron-builder.ts
+++ b/apps/desktop/electron-builder.ts
@@ -42,6 +42,9 @@ const config: Configuration = {
"**/node_modules/bindings/**/*",
"**/node_modules/file-uri-to-path/**/*",
"**/node_modules/node-pty/**/*",
+ // ssh2 has native crypto module for SSH connections
+ "**/node_modules/ssh2/**/*",
+ "**/node_modules/cpu-features/**/*",
// Sound files must be unpacked so external audio players (afplay, paplay, etc.) can access them
"**/resources/sounds/**/*",
],
@@ -90,6 +93,18 @@ const config: Configuration = {
to: "node_modules/node-pty",
filter: ["**/*"],
},
+ // ssh2 for SSH remote connections
+ {
+ from: "node_modules/ssh2",
+ to: "node_modules/ssh2",
+ filter: ["**/*"],
+ },
+ // cpu-features is a dependency of ssh2
+ {
+ from: "node_modules/cpu-features",
+ to: "node_modules/cpu-features",
+ filter: ["**/*"],
+ },
"!**/.DS_Store",
],
diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts
index 7acdc41ac..12f9cc53a 100644
--- a/apps/desktop/electron.vite.config.ts
+++ b/apps/desktop/electron.vite.config.ts
@@ -76,6 +76,7 @@ export default defineConfig({
"electron",
"better-sqlite3",
"node-pty",
+ "ssh2",
/^@sentry\/electron/,
],
},
diff --git a/apps/desktop/scripts/copy-native-modules.ts b/apps/desktop/scripts/copy-native-modules.ts
index 0f28a56b4..137ef8263 100644
--- a/apps/desktop/scripts/copy-native-modules.ts
+++ b/apps/desktop/scripts/copy-native-modules.ts
@@ -17,10 +17,14 @@ import { cpSync, existsSync, lstatSync, realpathSync, rmSync } from "node:fs";
import { dirname, join } from "node:path";
// Native modules that must exist for the app to work
-const NATIVE_MODULES = ["better-sqlite3", "node-pty"] as const;
+const NATIVE_MODULES = ["better-sqlite3", "node-pty", "ssh2"] as const;
// Dependencies of native modules that need to be copied (may be hoisted or symlinked)
-const NATIVE_MODULE_DEPS = ["bindings", "file-uri-to-path"] as const;
+const NATIVE_MODULE_DEPS = [
+ "bindings",
+ "file-uri-to-path",
+ "cpu-features",
+] as const;
function copyModuleIfSymlink(
nodeModulesDir: string,
diff --git a/apps/desktop/src/lib/trpc/routers/auth/index.ts b/apps/desktop/src/lib/trpc/routers/auth/index.ts
index 7055b2bf0..47df1c524 100644
--- a/apps/desktop/src/lib/trpc/routers/auth/index.ts
+++ b/apps/desktop/src/lib/trpc/routers/auth/index.ts
@@ -4,6 +4,7 @@ import { AUTH_PROVIDERS } from "@superset/shared/constants";
import { observable } from "@trpc/server/observable";
import { shell } from "electron";
import { env } from "main/env.main";
+import { PROTOCOL_SCHEME } from "shared/constants";
import { z } from "zod";
import { publicProcedure, router } from "../..";
import {
@@ -72,6 +73,7 @@ export const createAuthRouter = () => {
);
connectUrl.searchParams.set("provider", input.provider);
connectUrl.searchParams.set("state", state);
+ connectUrl.searchParams.set("protocol", PROTOCOL_SCHEME);
await shell.openExternal(connectUrl.toString());
return { success: true };
} catch (err) {
diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts
index 294875d4a..9129d40e8 100644
--- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts
+++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts
@@ -1,4 +1,11 @@
-import { projects, workspaces, worktrees, remoteWorkspaces, remoteProjects, sshConnections } from "@superset/local-db";
+import {
+ projects,
+ remoteProjects,
+ remoteWorkspaces,
+ sshConnections,
+ workspaces,
+ worktrees,
+} from "@superset/local-db";
import { TRPCError } from "@trpc/server";
import { eq, isNotNull, isNull } from "drizzle-orm";
import { localDb } from "main/lib/local-db";
diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts
index 15b0a9f96..9c0fa9ccd 100644
--- a/apps/desktop/src/main/index.ts
+++ b/apps/desktop/src/main/index.ts
@@ -35,9 +35,12 @@ if (process.env.NODE_ENV === "development") {
// In development, we need to provide the execPath and args
if (process.defaultApp) {
if (process.argv.length >= 2) {
- app.setAsDefaultProtocolClient(PROTOCOL_SCHEME, process.execPath, [
- path.resolve(process.argv[1]),
- ]);
+ const execArgs = [path.resolve(process.argv[1])];
+ app.setAsDefaultProtocolClient(PROTOCOL_SCHEME, process.execPath, execArgs);
+ // In development, also register production protocol so OAuth redirects work
+ if (process.env.NODE_ENV === "development") {
+ app.setAsDefaultProtocolClient("superset", process.execPath, execArgs);
+ }
}
} else {
app.setAsDefaultProtocolClient(PROTOCOL_SCHEME);
@@ -59,7 +62,10 @@ async function processDeepLink(url: string): Promise {
* Find a deep link URL in argv
*/
function findDeepLinkInArgv(argv: string[]): string | undefined {
- return argv.find((arg) => arg.startsWith(`${PROTOCOL_SCHEME}://`));
+ return argv.find(
+ (arg) =>
+ arg.startsWith(`${PROTOCOL_SCHEME}://`) || arg.startsWith("superset://"),
+ );
}
/**
diff --git a/apps/desktop/src/main/lib/ssh/index.ts b/apps/desktop/src/main/lib/ssh/index.ts
index cb725f4a3..18c610496 100644
--- a/apps/desktop/src/main/lib/ssh/index.ts
+++ b/apps/desktop/src/main/lib/ssh/index.ts
@@ -2,12 +2,12 @@
* SSH Module Exports
*/
-export * from "./types";
export { SSHClient } from "./ssh-client";
-export { SSHTerminalManager } from "./ssh-terminal-manager";
export {
- parseSSHConfig,
+ convertToConnectionConfigs,
getSSHConfigHosts,
hasSSHConfig,
- convertToConnectionConfigs,
+ parseSSHConfig,
} from "./ssh-config-parser";
+export { SSHTerminalManager } from "./ssh-terminal-manager";
+export * from "./types";
diff --git a/apps/desktop/src/main/lib/ssh/ssh-client.ts b/apps/desktop/src/main/lib/ssh/ssh-client.ts
index c5e2b12ce..41ad2e966 100644
--- a/apps/desktop/src/main/lib/ssh/ssh-client.ts
+++ b/apps/desktop/src/main/lib/ssh/ssh-client.ts
@@ -118,14 +118,17 @@ export class SSHClient extends EventEmitter {
this.emitStatus();
});
- this.client.on("keyboard-interactive", (_name, _instructions, _lang, prompts, finish) => {
- // For keyboard-interactive auth, we don't support password prompts here
- // This would require UI integration
- console.warn(
- "[ssh/client] Keyboard-interactive auth requested but not supported",
- );
- finish([]);
- });
+ this.client.on(
+ "keyboard-interactive",
+ (_name, _instructions, _lang, _prompts, finish) => {
+ // For keyboard-interactive auth, we don't support password prompts here
+ // This would require UI integration
+ console.warn(
+ "[ssh/client] Keyboard-interactive auth requested but not supported",
+ );
+ finish([]);
+ },
+ );
}
/** Emits connection status to listeners */
@@ -158,7 +161,7 @@ export class SSHClient extends EventEmitter {
// Exponential backoff: delay = base * 2^(attempt-1), capped at max
const delay = Math.min(
- BASE_RECONNECT_DELAY_MS * Math.pow(2, this.reconnectAttempts - 1),
+ BASE_RECONNECT_DELAY_MS * 2 ** (this.reconnectAttempts - 1),
MAX_RECONNECT_DELAY_MS,
);
@@ -181,7 +184,9 @@ export class SSHClient extends EventEmitter {
return;
}
- console.log(`[ssh/client] Connecting to ${this.config.host}:${this.config.port} as ${this.config.username}`);
+ console.log(
+ `[ssh/client] Connecting to ${this.config.host}:${this.config.port} as ${this.config.username}`,
+ );
this.state = "connecting";
// Mark as initial connect to prevent persistent error handler from triggering reconnect
this.isInitialConnect = true;
@@ -202,7 +207,9 @@ export class SSHClient extends EventEmitter {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
- console.error(`[ssh/client] Connection timeout after ${this.config.connectionTimeout ?? DEFAULT_CONNECTION_TIMEOUT}ms`);
+ console.error(
+ `[ssh/client] Connection timeout after ${this.config.connectionTimeout ?? DEFAULT_CONNECTION_TIMEOUT}ms`,
+ );
this.isInitialConnect = false; // Clear flag on timeout
this.client.end();
reject(new Error("Connection timeout"));
@@ -264,7 +271,7 @@ export class SSHClient extends EventEmitter {
// and normalize to ensure consistent path separators on Windows
if (keyPath.startsWith("~")) {
keyPath = path.normalize(
- keyPath.replace(/^~[/\\]?/, os.homedir() + path.sep)
+ keyPath.replace(/^~[/\\]?/, os.homedir() + path.sep),
);
}
console.log(`[ssh/client] Reading private key from: ${keyPath}`);
@@ -272,7 +279,9 @@ export class SSHClient extends EventEmitter {
config.privateKey = fs.readFileSync(keyPath);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
- throw new Error(`Failed to read private key at ${keyPath}: ${message}`);
+ throw new Error(
+ `Failed to read private key at ${keyPath}: ${message}`,
+ );
}
break;
}
@@ -285,11 +294,13 @@ export class SSHClient extends EventEmitter {
try {
fs.accessSync(opensshPipe, fs.constants.R_OK);
config.agent = opensshPipe;
- console.log(`[ssh/client] Using Windows OpenSSH agent pipe: ${opensshPipe}`);
+ console.log(
+ `[ssh/client] Using Windows OpenSSH agent pipe: ${opensshPipe}`,
+ );
} catch {
throw new Error(
"Windows OpenSSH Agent not available. Ensure the ssh-agent service is running: " +
- "Start-Service ssh-agent (PowerShell as Admin)"
+ "Start-Service ssh-agent (PowerShell as Admin)",
);
}
} else {
@@ -382,7 +393,10 @@ export class SSHClient extends EventEmitter {
});
channel.on("error", (err: Error) => {
- console.error(`[ssh/client] Channel error for ${paneId}:`, err.message);
+ console.error(
+ `[ssh/client] Channel error for ${paneId}:`,
+ err.message,
+ );
this.emit(`error:${paneId}`, err.message);
});
@@ -390,7 +404,9 @@ export class SSHClient extends EventEmitter {
if (cwd && isValidRemotePath(cwd)) {
channel.write(`cd ${shellEscape(cwd)} && clear\n`);
} else if (cwd) {
- console.warn(`[ssh/client] Invalid remote path for cwd, ignoring: ${cwd}`);
+ console.warn(
+ `[ssh/client] Invalid remote path for cwd, ignoring: ${cwd}`,
+ );
}
resolve(channel);
@@ -418,7 +434,9 @@ export class SSHClient extends EventEmitter {
if (channel) {
channel.setWindow(rows, cols, 0, 0);
} else {
- console.warn(`[ssh/client] Cannot resize ${paneId} to ${cols}x${rows}: channel not found`);
+ console.warn(
+ `[ssh/client] Cannot resize ${paneId} to ${cols}x${rows}: channel not found`,
+ );
}
}
@@ -430,7 +448,9 @@ export class SSHClient extends EventEmitter {
if (channel) {
channel.signal(signalName);
} else {
- console.warn(`[ssh/client] Cannot send signal ${signalName} to ${paneId}: channel not found`);
+ console.warn(
+ `[ssh/client] Cannot send signal ${signalName} to ${paneId}: channel not found`,
+ );
}
}
diff --git a/apps/desktop/src/main/lib/ssh/ssh-config-parser.ts b/apps/desktop/src/main/lib/ssh/ssh-config-parser.ts
index c5ad1346b..fb6b8b4f6 100644
--- a/apps/desktop/src/main/lib/ssh/ssh-config-parser.ts
+++ b/apps/desktop/src/main/lib/ssh/ssh-config-parser.ts
@@ -8,7 +8,7 @@
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
-import type { SSHConnectionConfig, SSHAuthMethod } from "./types";
+import type { SSHAuthMethod, SSHConnectionConfig } from "./types";
interface SSHConfigHost {
name: string;
@@ -100,7 +100,9 @@ export function parseSSHConfig(configPath?: string): SSHConfigHost[] {
hosts.push(currentHost);
}
- console.log(`[ssh-config] Parsed ${hosts.length} hosts from ${sshConfigPath}`);
+ console.log(
+ `[ssh-config] Parsed ${hosts.length} hosts from ${sshConfigPath}`,
+ );
return hosts;
}
diff --git a/apps/desktop/src/main/lib/ssh/ssh-terminal-manager.ts b/apps/desktop/src/main/lib/ssh/ssh-terminal-manager.ts
index 319bde9a5..942986d3b 100644
--- a/apps/desktop/src/main/lib/ssh/ssh-terminal-manager.ts
+++ b/apps/desktop/src/main/lib/ssh/ssh-terminal-manager.ts
@@ -7,8 +7,8 @@
*/
import { EventEmitter } from "node:events";
-import type { SSHConnectionConfig, SSHSessionInfo } from "./types";
import { SSHClient } from "./ssh-client";
+import type { SSHConnectionConfig, SSHSessionInfo } from "./types";
const DEFAULT_COLS = 80;
const DEFAULT_ROWS = 24;
@@ -37,6 +37,9 @@ export class SSHTerminalManager extends EventEmitter {
private sessionHandlers: Map = new Map();
private pendingCreates: Map> = new Map();
private config: SSHConnectionConfig;
+ private connectionStatusHandler:
+ | ((status: { state: string }) => void)
+ | null = null;
constructor(config: SSHConnectionConfig) {
super();
@@ -46,10 +49,11 @@ export class SSHTerminalManager extends EventEmitter {
}
private setupEventForwarding(): void {
- // Forward connection status events
- this.sshClient.on("connectionStatus", (status) => {
+ // Forward connection status events (store handler for cleanup)
+ this.connectionStatusHandler = (status) => {
this.emit("connectionStatus", status);
- });
+ };
+ this.sshClient.on("connectionStatus", this.connectionStatusHandler);
}
/**
@@ -63,6 +67,12 @@ export class SSHTerminalManager extends EventEmitter {
* Disconnect from the remote SSH server
*/
disconnect(): void {
+ // Remove connectionStatus handler
+ if (this.connectionStatusHandler) {
+ this.sshClient.off("connectionStatus", this.connectionStatusHandler);
+ this.connectionStatusHandler = null;
+ }
+
// Remove all session handlers before disconnecting
for (const [paneId, handlers] of this.sessionHandlers) {
this.sshClient.off(`data:${paneId}`, handlers.data);
@@ -134,7 +144,7 @@ export class SSHTerminalManager extends EventEmitter {
// Check for existing session
const existing = this.sessions.get(paneId);
- if (existing && existing.isAlive && this.sshClient.hasChannel(paneId)) {
+ if (existing?.isAlive && this.sshClient.hasChannel(paneId)) {
existing.lastActive = Date.now();
return {
isNew: false,
@@ -211,7 +221,11 @@ export class SSHTerminalManager extends EventEmitter {
};
// Store handlers for later cleanup
- this.sessionHandlers.set(paneId, { data: dataHandler, exit: exitHandler, error: errorHandler });
+ this.sessionHandlers.set(paneId, {
+ data: dataHandler,
+ exit: exitHandler,
+ error: errorHandler,
+ });
this.sshClient.on(`data:${paneId}`, dataHandler);
this.sshClient.on(`exit:${paneId}`, exitHandler);
diff --git a/apps/desktop/src/main/lib/workspace-runtime/registry.ts b/apps/desktop/src/main/lib/workspace-runtime/registry.ts
index 3e94a0823..1508bd3a7 100644
--- a/apps/desktop/src/main/lib/workspace-runtime/registry.ts
+++ b/apps/desktop/src/main/lib/workspace-runtime/registry.ts
@@ -7,8 +7,9 @@
* Supports:
* - LocalWorkspaceRuntime for local workspaces
* - SSHWorkspaceRuntime for remote SSH workspaces
+ * - CloudWorkspaceRuntime for cloud-hosted workspaces (extensible)
*
- * Runtime selection is based on workspace metadata (sshConnectionId).
+ * Runtime selection is based on workspace metadata (sshConnectionId, cloudProviderId, etc.).
*/
import type { SSHConnectionConfig } from "../ssh/types";
@@ -16,15 +17,75 @@ import { LocalWorkspaceRuntime } from "./local";
import { SSHWorkspaceRuntime } from "./ssh";
import type { WorkspaceRuntime, WorkspaceRuntimeRegistry } from "./types";
+// =============================================================================
+// Remote Workspace Types
+// =============================================================================
+
+/**
+ * Remote workspace types supported by the registry.
+ * Extensible for future cloud providers.
+ */
+export type RemoteWorkspaceType = "ssh" | "cloud";
+
+/**
+ * Mapping info for a remote workspace.
+ */
+interface RemoteWorkspaceMapping {
+ type: RemoteWorkspaceType;
+ runtimeId: string; // sshConnectionId or cloudProviderId
+}
+
+/**
+ * Interface for cloud workspace runtimes.
+ * Cloud providers should implement this interface to integrate with the registry.
+ *
+ * Example implementation:
+ * ```typescript
+ * class FreestyleWorkspaceRuntime implements CloudWorkspaceRuntime {
+ * readonly id: string;
+ * readonly terminal: TerminalRuntime;
+ * readonly capabilities: { terminal: TerminalCapabilities };
+ *
+ * async connect(): Promise { ... }
+ * disconnect(): void { ... }
+ * isConnected(): boolean { ... }
+ * }
+ * ```
+ */
+export interface CloudWorkspaceRuntime extends WorkspaceRuntime {
+ connect(): Promise;
+ disconnect(): void;
+ isConnected(): boolean;
+}
+
// =============================================================================
// Extended Registry Interface
// =============================================================================
/**
- * Extended registry interface with SSH support.
+ * Extended registry interface with SSH and cloud workspace support.
*/
export interface ExtendedWorkspaceRuntimeRegistry
extends WorkspaceRuntimeRegistry {
+ // ===========================================================================
+ // Generic Remote Workspace Methods
+ // ===========================================================================
+
+ /**
+ * Get the remote workspace type for a workspace.
+ * Returns undefined if the workspace is local.
+ */
+ getWorkspaceType(workspaceId: string): RemoteWorkspaceType | undefined;
+
+ /**
+ * Unregister any remote workspace mapping.
+ */
+ unregisterRemoteWorkspace(workspaceId: string): void;
+
+ // ===========================================================================
+ // SSH Workspace Methods
+ // ===========================================================================
+
/**
* Get or create an SSH runtime for a connection.
* Reuses existing runtime if already connected to the same host.
@@ -39,6 +100,7 @@ export interface ExtendedWorkspaceRuntimeRegistry
/**
* Unregister a workspace from SSH.
+ * @deprecated Use unregisterRemoteWorkspace instead
*/
unregisterSSHWorkspace(workspaceId: string): void;
@@ -57,8 +119,55 @@ export interface ExtendedWorkspaceRuntimeRegistry
*/
disconnectSSHRuntime(sshConnectionId: string): Promise;
+ // ===========================================================================
+ // Cloud Workspace Methods
+ // ===========================================================================
+
+ /**
+ * Register a cloud runtime with the registry.
+ * Call this when initializing a cloud provider.
+ *
+ * @param providerId Unique identifier for the cloud provider instance
+ * @param runtime The cloud workspace runtime implementation
+ */
+ registerCloudRuntime(
+ providerId: string,
+ runtime: CloudWorkspaceRuntime,
+ ): void;
+
+ /**
+ * Get a cloud runtime by provider ID.
+ * Returns undefined if no runtime is registered for this provider.
+ */
+ getCloudRuntime(providerId: string): CloudWorkspaceRuntime | undefined;
+
+ /**
+ * Register a workspace as using a cloud provider.
+ * Call this when a workspace is associated with a cloud VM.
+ */
+ registerCloudWorkspace(workspaceId: string, providerId: string): void;
+
+ /**
+ * Check if a workspace is using a cloud provider.
+ */
+ isCloudWorkspace(workspaceId: string): boolean;
+
+ /**
+ * Get all active cloud runtimes.
+ */
+ getActiveCloudRuntimes(): Map;
+
+ /**
+ * Disconnect and remove a cloud runtime.
+ */
+ disconnectCloudRuntime(providerId: string): Promise;
+
+ // ===========================================================================
+ // Lifecycle
+ // ===========================================================================
+
/**
- * Cleanup all runtimes.
+ * Cleanup all runtimes (local, SSH, and cloud).
*/
cleanupAll(): Promise;
}
@@ -68,34 +177,49 @@ export interface ExtendedWorkspaceRuntimeRegistry
// =============================================================================
/**
- * Default registry implementation with SSH support.
+ * Default registry implementation with SSH and cloud workspace support.
*
* - Local workspaces use LocalWorkspaceRuntime
* - SSH workspaces use SSHWorkspaceRuntime based on their sshConnectionId
+ * - Cloud workspaces use CloudWorkspaceRuntime based on their providerId
*/
class DefaultWorkspaceRuntimeRegistry
implements ExtendedWorkspaceRuntimeRegistry
{
private localRuntime: LocalWorkspaceRuntime | null = null;
private sshRuntimes: Map = new Map();
- private workspaceToSSH: Map = new Map(); // workspaceId -> sshConnectionId
+ private cloudRuntimes: Map = new Map();
+ private workspaceToRemote: Map = new Map(); // workspaceId -> mapping
private sshConfigs: Map = new Map(); // sshConnectionId -> config
/**
* Get the runtime for a specific workspace.
*
- * Returns SSH runtime if workspace is registered as SSH, otherwise local.
+ * Returns the appropriate runtime based on workspace type:
+ * - SSH runtime for SSH workspaces
+ * - Cloud runtime for cloud workspaces
+ * - Local runtime for everything else
*/
getForWorkspaceId(workspaceId: string): WorkspaceRuntime {
- const sshConnectionId = this.workspaceToSSH.get(workspaceId);
- if (sshConnectionId) {
- const sshRuntime = this.sshRuntimes.get(sshConnectionId);
- if (sshRuntime) {
- return sshRuntime;
+ const mapping = this.workspaceToRemote.get(workspaceId);
+ if (mapping) {
+ if (mapping.type === "ssh") {
+ const sshRuntime = this.sshRuntimes.get(mapping.runtimeId);
+ if (sshRuntime) {
+ return sshRuntime;
+ }
+ console.warn(
+ `[registry] Workspace ${workspaceId} mapped to SSH ${mapping.runtimeId} but runtime not found, falling back to local`,
+ );
+ } else if (mapping.type === "cloud") {
+ const cloudRuntime = this.cloudRuntimes.get(mapping.runtimeId);
+ if (cloudRuntime) {
+ return cloudRuntime;
+ }
+ console.warn(
+ `[registry] Workspace ${workspaceId} mapped to cloud ${mapping.runtimeId} but runtime not found, falling back to local`,
+ );
}
- console.warn(
- `[registry] Workspace ${workspaceId} mapped to SSH ${sshConnectionId} but runtime not found, falling back to local`,
- );
}
return this.getDefault();
}
@@ -113,6 +237,28 @@ class DefaultWorkspaceRuntimeRegistry
return this.localRuntime;
}
+ // ===========================================================================
+ // Generic Remote Workspace Methods
+ // ===========================================================================
+
+ /**
+ * Get the remote workspace type for a workspace.
+ */
+ getWorkspaceType(workspaceId: string): RemoteWorkspaceType | undefined {
+ return this.workspaceToRemote.get(workspaceId)?.type;
+ }
+
+ /**
+ * Unregister any remote workspace mapping.
+ */
+ unregisterRemoteWorkspace(workspaceId: string): void {
+ this.workspaceToRemote.delete(workspaceId);
+ }
+
+ // ===========================================================================
+ // SSH Workspace Methods
+ // ===========================================================================
+
/**
* Get or create an SSH runtime for a connection configuration.
*/
@@ -136,21 +282,25 @@ class DefaultWorkspaceRuntimeRegistry
console.log(
`[registry] Registering workspace ${workspaceId} with SSH connection ${sshConnectionId}`,
);
- this.workspaceToSSH.set(workspaceId, sshConnectionId);
+ this.workspaceToRemote.set(workspaceId, {
+ type: "ssh",
+ runtimeId: sshConnectionId,
+ });
}
/**
* Unregister a workspace from SSH.
+ * @deprecated Use unregisterRemoteWorkspace instead
*/
unregisterSSHWorkspace(workspaceId: string): void {
- this.workspaceToSSH.delete(workspaceId);
+ this.unregisterRemoteWorkspace(workspaceId);
}
/**
* Check if a workspace is using SSH.
*/
isSSHWorkspace(workspaceId: string): boolean {
- return this.workspaceToSSH.has(workspaceId);
+ return this.workspaceToRemote.get(workspaceId)?.type === "ssh";
}
/**
@@ -198,9 +348,9 @@ class DefaultWorkspaceRuntimeRegistry
this.sshConfigs.delete(sshConnectionId);
// Remove all workspace mappings for this SSH connection
- for (const [workspaceId, connId] of this.workspaceToSSH) {
- if (connId === sshConnectionId) {
- this.workspaceToSSH.delete(workspaceId);
+ for (const [workspaceId, mapping] of this.workspaceToRemote) {
+ if (mapping.type === "ssh" && mapping.runtimeId === sshConnectionId) {
+ this.workspaceToRemote.delete(workspaceId);
}
}
}
@@ -214,8 +364,114 @@ class DefaultWorkspaceRuntimeRegistry
}
}
+ // ===========================================================================
+ // Cloud Workspace Methods
+ // ===========================================================================
+
+ /**
+ * Register a cloud runtime with the registry.
+ */
+ registerCloudRuntime(
+ providerId: string,
+ runtime: CloudWorkspaceRuntime,
+ ): void {
+ console.log(
+ `[registry] Registering cloud runtime for provider ${providerId}`,
+ );
+ this.cloudRuntimes.set(providerId, runtime);
+ }
+
/**
- * Cleanup all runtimes.
+ * Get a cloud runtime by provider ID.
+ */
+ getCloudRuntime(providerId: string): CloudWorkspaceRuntime | undefined {
+ return this.cloudRuntimes.get(providerId);
+ }
+
+ /**
+ * Register a workspace as using a cloud provider.
+ */
+ registerCloudWorkspace(workspaceId: string, providerId: string): void {
+ console.log(
+ `[registry] Registering workspace ${workspaceId} with cloud provider ${providerId}`,
+ );
+ this.workspaceToRemote.set(workspaceId, {
+ type: "cloud",
+ runtimeId: providerId,
+ });
+ }
+
+ /**
+ * Check if a workspace is using a cloud provider.
+ */
+ isCloudWorkspace(workspaceId: string): boolean {
+ return this.workspaceToRemote.get(workspaceId)?.type === "cloud";
+ }
+
+ /**
+ * Get all active cloud runtimes.
+ */
+ getActiveCloudRuntimes(): Map {
+ return new Map(this.cloudRuntimes);
+ }
+
+ /**
+ * Disconnect and remove a cloud runtime.
+ */
+ async disconnectCloudRuntime(providerId: string): Promise {
+ const runtime = this.cloudRuntimes.get(providerId);
+ if (runtime) {
+ console.log(`[registry] Disconnecting cloud runtime for ${providerId}`);
+ let cleanupError: Error | undefined;
+ let disconnectError: Error | undefined;
+ try {
+ await runtime.terminal.cleanup();
+ } catch (error) {
+ cleanupError =
+ error instanceof Error ? error : new Error(String(error));
+ console.error(
+ `[registry] Error cleaning up cloud runtime ${providerId}:`,
+ cleanupError.message,
+ );
+ } finally {
+ // Always disconnect even if cleanup failed
+ try {
+ runtime.disconnect();
+ } catch (error) {
+ disconnectError =
+ error instanceof Error ? error : new Error(String(error));
+ console.error(
+ `[registry] Error disconnecting cloud runtime ${providerId}:`,
+ disconnectError.message,
+ );
+ }
+
+ // Always clean up state even if cleanup/disconnect failed
+ this.cloudRuntimes.delete(providerId);
+
+ // Remove all workspace mappings for this cloud provider
+ for (const [workspaceId, mapping] of this.workspaceToRemote) {
+ if (mapping.type === "cloud" && mapping.runtimeId === providerId) {
+ this.workspaceToRemote.delete(workspaceId);
+ }
+ }
+ }
+ // Propagate the first error encountered
+ if (cleanupError) {
+ throw cleanupError;
+ }
+ if (disconnectError) {
+ throw disconnectError;
+ }
+ }
+ }
+
+ // ===========================================================================
+ // Lifecycle
+ // ===========================================================================
+
+ /**
+ * Cleanup all runtimes (local, SSH, and cloud).
*/
async cleanupAll(): Promise {
// Cleanup local runtime
@@ -254,9 +510,36 @@ class DefaultWorkspaceRuntimeRegistry
}
}
}
+
+ // Cleanup all cloud runtimes (continue even if individual cleanups fail)
+ for (const [id, runtime] of this.cloudRuntimes) {
+ console.log(`[registry] Cleaning up cloud runtime ${id}`);
+ try {
+ await runtime.terminal.cleanup();
+ } catch (error) {
+ console.error(
+ `[registry] Error cleaning up cloud runtime ${id}:`,
+ error instanceof Error ? error.message : String(error),
+ );
+ } finally {
+ // Always disconnect even if cleanup failed
+ try {
+ runtime.disconnect();
+ } catch (disconnectError) {
+ console.error(
+ `[registry] Error disconnecting cloud runtime ${id}:`,
+ disconnectError instanceof Error
+ ? disconnectError.message
+ : String(disconnectError),
+ );
+ }
+ }
+ }
+
this.sshRuntimes.clear();
+ this.cloudRuntimes.clear();
this.sshConfigs.clear();
- this.workspaceToSSH.clear();
+ this.workspaceToRemote.clear();
}
}
diff --git a/apps/desktop/src/main/lib/workspace-runtime/ssh.ts b/apps/desktop/src/main/lib/workspace-runtime/ssh.ts
index 0a47c5f2e..c8e5201f7 100644
--- a/apps/desktop/src/main/lib/workspace-runtime/ssh.ts
+++ b/apps/desktop/src/main/lib/workspace-runtime/ssh.ts
@@ -7,6 +7,8 @@
* Similar to LocalWorkspaceRuntime but connects to remote servers via SSH.
*/
+import { SSHTerminalManager } from "../ssh/ssh-terminal-manager";
+import type { SSHConnectionConfig } from "../ssh/types";
import type {
TerminalCapabilities,
TerminalManagement,
@@ -14,8 +16,6 @@ import type {
WorkspaceRuntime,
WorkspaceRuntimeId,
} from "./types";
-import type { SSHConnectionConfig } from "../ssh/types";
-import { SSHTerminalManager } from "../ssh/ssh-terminal-manager";
// =============================================================================
// SSH Terminal Runtime Adapter
diff --git a/apps/desktop/src/main/windows/main.ts b/apps/desktop/src/main/windows/main.ts
index 2e1c502d0..bef84f419 100644
--- a/apps/desktop/src/main/windows/main.ts
+++ b/apps/desktop/src/main/windows/main.ts
@@ -61,6 +61,8 @@ export async function MainWindow() {
// Isolate Electron session from system browser cookies
// This ensures desktop uses bearer token auth, not web cookies
partition: "persist:superset",
+ // Disable web security in development to bypass CORS when connecting to production API
+ webSecurity: process.env.NODE_ENV !== "development",
},
});
diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/components/GeneralSettings/GeneralSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/components/GeneralSettings/GeneralSettings.tsx
index 0c144d01d..589f7a442 100644
--- a/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/components/GeneralSettings/GeneralSettings.tsx
+++ b/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/components/GeneralSettings/GeneralSettings.tsx
@@ -7,6 +7,7 @@ import {
HiOutlineCommandLine,
HiOutlineComputerDesktop,
HiOutlinePaintBrush,
+ HiOutlineServer,
HiOutlineUser,
HiOutlineUserGroup,
} from "react-icons/hi2";
@@ -19,7 +20,8 @@ type SettingsRoute =
| "/settings/keyboard"
| "/settings/presets"
| "/settings/behavior"
- | "/settings/terminal";
+ | "/settings/terminal"
+ | "/settings/ssh";
const GENERAL_SECTIONS: {
id: SettingsRoute;
@@ -66,6 +68,11 @@ const GENERAL_SECTIONS: {
label: "Terminal",
icon: ,
},
+ {
+ id: "/settings/ssh",
+ label: "SSH Remote",
+ icon: ,
+ },
];
export function GeneralSettings() {
diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/ssh/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/ssh/page.tsx
index 1945250bd..201e0225f 100644
--- a/apps/desktop/src/renderer/routes/_authenticated/settings/ssh/page.tsx
+++ b/apps/desktop/src/renderer/routes/_authenticated/settings/ssh/page.tsx
@@ -40,10 +40,13 @@ function SSHSettingsPage() {
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
const [testingId, setTestingId] = useState(null);
- const [testResults, setTestResults] = useState>({});
+ const [testResults, setTestResults] = useState<
+ Record
+ >({});
// Queries
- const { data: connections, isLoading } = electronTrpc.ssh.listConnections.useQuery();
+ const { data: connections, isLoading } =
+ electronTrpc.ssh.listConnections.useQuery();
const { data: hasConfig } = electronTrpc.ssh.hasSSHConfig.useQuery();
const { data: configHosts } = electronTrpc.ssh.getSSHConfigHosts.useQuery();
@@ -138,10 +141,13 @@ function SSHSettingsPage() {
-