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/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/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/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..485490401 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/ssh/index.ts @@ -0,0 +1,760 @@ +/** + * 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, + SSH_AUTH_METHODS, + sshConnections, +} from "@superset/local-db"; +import { TRPCError } from "@trpc/server"; +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 { + type ExtendedWorkspaceRuntimeRegistry, + getWorkspaceRuntimeRegistry, +} from "main/lib/workspace-runtime/registry"; +import { z } from "zod"; +import { publicProcedure, router } from "../.."; + +// 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 hostNames = input?.hostNames; + const skipExisting = input?.skipExisting ?? true; + 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 (hostNames && hostNames.length > 0) { + const namesSet = new Set( + hostNames.map((n: string) => n.toLowerCase()), + ); + hostsToImport = hostsToImport.filter((h) => + namesSet.has(h.name.toLowerCase()), + ); + } + + if (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.stream.test.ts b/apps/desktop/src/lib/trpc/routers/terminal/terminal.stream.test.ts index 54d5e2a8f..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("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("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("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 a53bdfac8..e57369ba6 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 } 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"; @@ -17,6 +23,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 +34,31 @@ 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. + * Throws if no mapping exists - callers should reattach the session. + */ +function getTerminalForPane(paneId: string) { + const workspaceId = paneToWorkspace.get(paneId); + if (!workspaceId) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `No workspace mapping found for pane ${paneId}. Session may need to be reattached.`, + }); + } + return getTerminalForWorkspace(workspaceId); +} + /** * Terminal router using TerminalManager with node-pty * Sessions are keyed by paneId and linked to workspaces for cwd resolution @@ -43,11 +76,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,19 +129,69 @@ 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 { + console.warn( + `[Terminal Router] Remote project not found for workspace ${workspaceId}, remoteProjectId: ${remoteWs.remoteProjectId}`, + ); + } + } + } 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); + + const cwd = resolveCwd({ + cwdOverride, + worktreePath: workspacePath, + isRemote, + }); if (DEBUG_TERMINAL) { console.log("[Terminal Router] createOrAttach called:", { @@ -119,26 +202,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, @@ -169,6 +251,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, @@ -190,6 +274,7 @@ export const createTerminalRouter = () => { }), ) .mutation(async ({ input }) => { + const terminal = getTerminalForPane(input.paneId); try { terminal.write(input); } catch (error) { @@ -218,6 +303,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 +317,7 @@ export const createTerminalRouter = () => { }), ) .mutation(async ({ input }) => { + const terminal = getTerminalForPane(input.paneId); terminal.resize(input); }), @@ -242,6 +329,7 @@ export const createTerminalRouter = () => { }), ) .mutation(async ({ input }) => { + const terminal = getTerminalForPane(input.paneId); terminal.signal(input); }), @@ -252,7 +340,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 +357,7 @@ export const createTerminalRouter = () => { }), ) .mutation(async ({ input }) => { + const terminal = getTerminalForPane(input.paneId); terminal.detach(input); }), @@ -281,27 +372,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 +406,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 +417,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 +447,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 +468,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 +478,7 @@ export const createTerminalRouter = () => { getSession: publicProcedure .input(z.string()) .query(async ({ input: paneId }) => { + const terminal = getTerminalForPane(paneId); return terminal.getSession(paneId); }), @@ -396,6 +489,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 +579,11 @@ 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 +591,18 @@ 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/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/lib/trpc/routers/terminal/utils/resolve-cwd.ts b/apps/desktop/src/lib/trpc/routers/terminal/utils/resolve-cwd.ts index 4f35be7ff..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 @@ -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,49 @@ 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, -): string | undefined { - // Validate worktreePath exists if provided - const validWorktreePath = - worktreePath && existsSync(worktreePath) ? worktreePath : undefined; +export function resolveCwd({ + cwdOverride, + worktreePath, + isRemote = false, +}: { + cwdOverride?: string; + worktreePath?: string; + isRemote?: boolean; +}): string | 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 +62,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 +71,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/lib/trpc/routers/workspaces/procedures/query.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts index 1f7f34ee9..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 } 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"; @@ -15,12 +22,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 +163,7 @@ export const createQueryProcedures = () => { gitStatus: worktree.gitStatus ?? null, } : null, + sshConnection: null, }; }), 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 new file mode 100644 index 000000000..18c610496 --- /dev/null +++ b/apps/desktop/src/main/lib/ssh/index.ts @@ -0,0 +1,13 @@ +/** + * SSH Module Exports + */ + +export { SSHClient } from "./ssh-client"; +export { + convertToConnectionConfigs, + getSSHConfigHosts, + hasSSHConfig, + 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 new file mode 100644 index 000000000..41ad2e966 --- /dev/null +++ b/apps/desktop/src/main/lib/ssh/ssh-client.ts @@ -0,0 +1,481 @@ +/** + * 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 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; + private config: SSHConnectionConfig; + private state: SSHConnectionState = "disconnected"; + 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(); + this.config = config; + this.client = new Client(); + 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}`); + this.state = "connected"; + this.reconnectAttempts = 0; + this.isInitialConnect = false; // Mark initial connection complete + this.emitStatus(); + }); + + this.client.on("error", (err) => { + console.error(`[ssh/client] Connection error:`, err.message); + this.state = "error"; + this.emitStatus(err.message); + // 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", () => { + 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([]); + }, + ); + } + + /** Emits connection status to listeners */ + private emitStatus(error?: string): void { + const status: SSHConnectionStatus = { + state: this.state, + error, + reconnectAttempt: + this.state === "reconnecting" ? this.reconnectAttempts : undefined, + }; + this.emit("connectionStatus", status); + } + + /** Attempts to reconnect with exponential backoff after connection loss */ + 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(); + + // Exponential backoff: delay = base * 2^(attempt-1), capped at max + const delay = Math.min( + BASE_RECONNECT_DELAY_MS * 2 ** (this.reconnectAttempts - 1), + MAX_RECONNECT_DELAY_MS, + ); + + console.log( + `[ssh/client] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`, + ); + + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + this.connect().catch((err) => { + console.error(`[ssh/client] Reconnect failed:`, err.message); + }); + }, 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`); + return; + } + + 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; + 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.isInitialConnect = false; // Clear flag on config build failure + 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.isInitialConnect = false; // Clear flag on timeout + 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`); + // 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); + }); + + this.client.connect(connectConfig); + }); + } + + /** Builds SSH connection config based on authentication method */ + 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; + + // 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) + // and normalize to ensure consistent path separators on Windows + if (keyPath.startsWith("~")) { + keyPath = path.normalize( + keyPath.replace(/^~[/\\]?/, os.homedir() + path.sep), + ); + } + 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 - platform-specific handling + if (process.platform === "win32") { + // Windows: OpenSSH agent uses a named pipe + const opensshPipe = "\\\\.\\pipe\\openssh-ssh-agent"; + // 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; + 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; + } + + /** Disconnects from the SSH server and prevents automatic reconnection */ + disconnect(): void { + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + this.reconnectAttempts = MAX_RECONNECT_ATTEMPTS; // Prevent reconnect + 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; + } + + /** + * 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 (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); + }); + }); + } + + /** + * Write data to a PTY channel + */ + write(paneId: string, data: string): void { + const channel = this.channels.get(paneId); + if (channel) { + channel.write(data); + } else { + console.warn(`[ssh/client] Cannot write to ${paneId}: channel not found`); + } + } + + /** + * 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); + } else { + console.warn( + `[ssh/client] Cannot resize ${paneId} to ${cols}x${rows}: channel not found`, + ); + } + } + + /** + * Send a signal to a PTY channel + */ + signal(paneId: string, signalName: string): void { + 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`, + ); + } + } + + /** + * 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..fb6b8b4f6 --- /dev/null +++ b/apps/desktop/src/main/lib/ssh/ssh-config-parser.ts @@ -0,0 +1,155 @@ +/** + * 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 { SSHAuthMethod, SSHConnectionConfig } 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": { + 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()); + 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..942986d3b --- /dev/null +++ b/apps/desktop/src/main/lib/ssh/ssh-terminal-manager.ts @@ -0,0 +1,434 @@ +/** + * 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 { SSHClient } from "./ssh-client"; +import type { SSHConnectionConfig, SSHSessionInfo } from "./types"; + +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; +} + +/** 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; + private connectionStatusHandler: + | ((status: { state: string }) => void) + | null = null; + + constructor(config: SSHConnectionConfig) { + super(); + this.config = config; + this.sshClient = new SSHClient(config); + this.setupEventForwarding(); + } + + private setupEventForwarding(): void { + // Forward connection status events (store handler for cleanup) + this.connectionStatusHandler = (status) => { + this.emit("connectionStatus", status); + }; + this.sshClient.on("connectionStatus", this.connectionStatusHandler); + } + + /** + * Connect to the remote SSH server + */ + async connect(): Promise { + await this.sshClient.connect(); + } + + /** + * 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); + 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 + */ + 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?.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 (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(); + } + }; + + 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); + }; + + 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 = { + 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.cleanupSessionHandlers(paneId); + 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.cleanupSessionHandlers(paneId); + this.sshClient.killChannel(paneId); + this.sessions.delete(paneId); + killed++; + } catch (error) { + console.error(`[ssh/terminal-manager] Failed to kill SSH channel`, { + paneId, + workspaceId, + error: error instanceof Error ? error.message : String(error), + }); + 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..1508bd3a7 100644 --- a/apps/desktop/src/main/lib/workspace-runtime/registry.ts +++ b/apps/desktop/src/main/lib/workspace-runtime/registry.ts @@ -4,40 +4,223 @@ * 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 + * - CloudWorkspaceRuntime for cloud-hosted workspaces (extensible) * - * 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, cloudProviderId, etc.). */ +import type { SSHConnectionConfig } from "../ssh/types"; 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 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. + */ + 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. + * @deprecated Use unregisterRemoteWorkspace instead + */ + 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; + + // =========================================================================== + // 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 (local, SSH, and cloud). + */ + cleanupAll(): Promise; +} + // ============================================================================= // Registry Implementation // ============================================================================= /** - * Default registry implementation. + * Default registry implementation with SSH and cloud workspace 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 + * - Cloud workspaces use CloudWorkspaceRuntime based on their providerId */ -class DefaultWorkspaceRuntimeRegistry implements WorkspaceRuntimeRegistry { +class DefaultWorkspaceRuntimeRegistry + implements ExtendedWorkspaceRuntimeRegistry +{ private localRuntime: LocalWorkspaceRuntime | null = null; + private sshRuntimes: Map = new Map(); + 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. * - * Currently always returns the local runtime. - * Future: will check workspace metadata to select local vs cloud. + * 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 { - // Currently all workspaces use the local runtime - // Future: check workspace metadata for cloudWorkspaceId to select cloud runtime + getForWorkspaceId(workspaceId: string): WorkspaceRuntime { + 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`, + ); + } + } return this.getDefault(); } @@ -53,13 +236,318 @@ class DefaultWorkspaceRuntimeRegistry implements WorkspaceRuntimeRegistry { } 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. + */ + 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.workspaceToRemote.set(workspaceId, { + type: "ssh", + runtimeId: sshConnectionId, + }); + } + + /** + * Unregister a workspace from SSH. + * @deprecated Use unregisterRemoteWorkspace instead + */ + unregisterSSHWorkspace(workspaceId: string): void { + this.unregisterRemoteWorkspace(workspaceId); + } + + /** + * Check if a workspace is using SSH. + */ + isSSHWorkspace(workspaceId: string): boolean { + return this.workspaceToRemote.get(workspaceId)?.type === "ssh"; + } + + /** + * 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}`, + ); + 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 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); + + // Remove all workspace mappings for this SSH connection + for (const [workspaceId, mapping] of this.workspaceToRemote) { + if (mapping.type === "ssh" && mapping.runtimeId === sshConnectionId) { + this.workspaceToRemote.delete(workspaceId); + } + } + } + // Propagate the first error encountered + if (cleanupError) { + throw cleanupError; + } + if (disconnectError) { + throw disconnectError; + } + } + } + + // =========================================================================== + // 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); + } + + /** + * 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 + if (this.localRuntime) { + 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 (continue even if individual cleanups fail) + for (const [id, runtime] of this.sshRuntimes) { + console.log(`[registry] Cleaning up SSH runtime ${id}`); + 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), + ); + } + } + } + + // 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.workspaceToRemote.clear(); + } } // ============================================================================= // Singleton Instance // ============================================================================= -let registryInstance: WorkspaceRuntimeRegistry | null = null; +let registryInstance: ExtendedWorkspaceRuntimeRegistry | null = null; /** * Get the workspace runtime registry. @@ -70,9 +558,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..c8e5201f7 --- /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 { SSHTerminalManager } from "../ssh/ssh-terminal-manager"; +import type { SSHConnectionConfig } from "../ssh/types"; +import type { + TerminalCapabilities, + TerminalManagement, + TerminalRuntime, + WorkspaceRuntime, + WorkspaceRuntimeId, +} from "./types"; + +// ============================================================================= +// 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/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 new file mode 100644 index 000000000..201e0225f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/ssh/page.tsx @@ -0,0 +1,496 @@ +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< + Record + >({}); + + // 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 +

+
+
+ { + setIsImportDialogOpen(open); + if (!open) setImportResult(null); + }} + > + + + + + + Import SSH Hosts + + The following hosts were found in your SSH config file + + +
+ {configHosts.map((host) => { + const existingNames = new Set( + (connections ?? []).map((c) => c.name.toLowerCase()), + ); + const alreadyExists = existingNames.has( + host.name.toLowerCase(), + ); + return ( +
+
+

{host.name}

+

+ {host.username}@{host.host}:{host.port} +

+
+ + {alreadyExists ? ( + + + Imported + + ) : ( + host.authMethod + )} + +
+ ); + })} +
+ + {importResult && importResult.imported.length === 0 && ( +

+ All hosts already exist in your connections. +

+ )} +
+ + {!importResult && ( + + )} +
+
+
+
+
+
+ )} + + {/* Add Connection Button */} +
+ + + + + + createConnection.mutate(data)} + isPending={createConnection.isPending} + onCancel={() => setIsAddDialogOpen(false)} + /> + + +
+ + {/* 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 ( +
+ + Add SSH Connection + + Enter the details for your SSH server + + + +
+
+ + setName(e.target.value)} + required + /> +
+ +
+
+ + setHost(e.target.value)} + required + /> +
+
+ + setPort(e.target.value)} + required + /> +
+
+ +
+ + setUsername(e.target.value)} + required + /> +
+ +
+ + +
+ + {authMethod === "key" && ( +
+ + setPrivateKeyPath(e.target.value)} + /> +
+ )} + +
+ + setRemoteWorkDir(e.target.value)} + /> +
+
+ + + + + +
+ ); +} 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..5b6f6e9ef --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/StartView/OpenRemoteDialog.tsx @@ -0,0 +1,229 @@ +import { Link } from "@tanstack/react-router"; +import { useState } from "react"; +import { HiOutlineCog6Tooth, HiOutlineServer } from "react-icons/hi2"; +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 using its default branch + const branch = project.defaultBranch || "main"; + createRemoteWorkspace.mutate({ + remoteProjectId: project.id, + branch, + name: branch, + }); + }, + 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..58b137827 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,10 @@ 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 +164,16 @@ export function StartView() { }} isLoading={isLoading} /> + + { + setError(null); + setIsRemoteDialogOpen(true); + }} + isLoading={isLoading} + />
{/* Recent Projects */} @@ -223,6 +255,46 @@ export function StartView() { )} + + {/* Remote Projects */} + {remoteProjects.length > 0 && ( +
+
+
+ + + Remote projects + +
+ +
+ {remoteProjects.map((project) => ( + + ))} +
+
+
+ )} @@ -239,6 +311,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..5cf4fd57b 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,10 +946,13 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { } }; - electronTrpc.terminal.stream.useSubscription(paneId, { - onData: handleStreamData, - enabled: true, - }); + electronTrpc.terminal.stream.useSubscription( + { paneId, workspaceId }, + { + onData: handleStreamData, + enabled: true, + }, + ); // Use ref to avoid triggering full terminal recreation when focus handler changes const handleTerminalFocusRef = useRef(() => {}); diff --git a/apps/web/src/app/auth/desktop/success/page.tsx b/apps/web/src/app/auth/desktop/success/page.tsx index b265f9b53..6f39bf7ec 100644 --- a/apps/web/src/app/auth/desktop/success/page.tsx +++ b/apps/web/src/app/auth/desktop/success/page.tsx @@ -8,9 +8,9 @@ import { DesktopRedirect } from "./components/DesktopRedirect"; export default async function DesktopSuccessPage({ searchParams, }: { - searchParams: Promise<{ desktop_state?: string }>; + searchParams: Promise<{ desktop_state?: string; protocol?: string }>; }) { - const { desktop_state: state } = await searchParams; + const { desktop_state: state, protocol: protocolParam } = await searchParams; if (!state) { return ( @@ -77,8 +77,8 @@ export default async function DesktopSuccessPage({ activeOrganizationId: session.session.activeOrganizationId, updatedAt: now, }); - const protocol = - process.env.NODE_ENV === "development" ? "superset-dev" : "superset"; + // Use protocol from URL param (passed from desktop app), fallback to production + const protocol = protocolParam || "superset"; const desktopUrl = `${protocol}://auth/callback?token=${encodeURIComponent(token)}&expiresAt=${encodeURIComponent(expiresAt.toISOString())}&state=${encodeURIComponent(state)}`; return ( 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..21e5a17c9 100644 --- a/packages/local-db/drizzle/meta/0011_snapshot.json +++ b/packages/local-db/drizzle/meta/0011_snapshot.json @@ -1,8 +1,8 @@ { "version": "6", "dialect": "sqlite", - "id": "b74ef022-acd9-4140-b9e8-b7c92dd13b16", - "prevId": "3177be28-43bc-4b9b-ba61-763632dee908", + "id": "24f884ea-2a0b-46e0-984a-643b88ec7c4d", + "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..0361d6542 100644 --- a/packages/local-db/src/schema/schema.ts +++ b/packages/local-db/src/schema/schema.ts @@ -5,6 +5,7 @@ import type { ExternalApp, GitHubStatus, GitStatus, + SSHAuthMethod, TerminalLinkBehavior, TerminalPreset, WorkspaceType, @@ -145,6 +146,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;