From 79eebf4a9923888f0387312498518559f5362c5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Fri, 8 May 2026 19:18:55 +0200 Subject: [PATCH 01/17] feat(runtime): add execution and connection profile foundations Introduce server-scoped execution profiles for local, WSL, Docker, and command runtimes so workspace creation can choose a runtime strategy instead of relying on a single default binary path. Add device-scoped connection profile foundations for remote server shortcuts and SSH bootstrap metadata, then surface both profile systems in the home screen and settings flows so runtime selection and remote reconnect UX can evolve together. Implement initial Docker and custom-command launch builders plus SSH bootstrap and tunnel session management for remote connections. Validation ran with the server and UI typechecks, the UI production build, and targeted tests for execution profile resolution and launch command generation. --- packages/server/src/api-types.ts | 89 +++++ packages/server/src/index.ts | 10 + packages/server/src/server/http-server.ts | 7 + .../src/server/routes/remote-connections.ts | 44 +++ .../server/src/server/routes/workspaces.ts | 5 +- packages/server/src/server/ssh-connections.ts | 272 +++++++++++++ packages/server/src/settings/binaries.test.ts | 158 ++++++++ packages/server/src/settings/binaries.ts | 138 ++++++- packages/server/src/settings/service.ts | 63 +++ .../src/workspaces/execution-launch.test.ts | 63 +++ .../server/src/workspaces/execution-launch.ts | 114 ++++++ packages/server/src/workspaces/manager.ts | 30 +- packages/server/src/workspaces/runtime.ts | 6 +- packages/ui/src/App.tsx | 6 +- .../src/components/folder-selection-view.tsx | 179 ++++++++- .../connection-profiles-settings-section.tsx | 244 ++++++++++++ .../execution-profiles-settings-section.tsx | 355 +++++++++++++++++ .../settings/opencode-settings-section.tsx | 3 + .../remote-access-settings-section.tsx | 3 + packages/ui/src/lib/api-client.ts | 11 + .../lib/i18n/messages/en/folderSelection.ts | 2 + .../ui/src/lib/i18n/messages/en/settings.ts | 84 ++++ .../lib/i18n/messages/es/folderSelection.ts | 2 + .../ui/src/lib/i18n/messages/es/settings.ts | 84 ++++ .../lib/i18n/messages/fr/folderSelection.ts | 2 + .../ui/src/lib/i18n/messages/fr/settings.ts | 84 ++++ .../lib/i18n/messages/he/folderSelection.ts | 2 + .../ui/src/lib/i18n/messages/he/settings.ts | 84 ++++ .../lib/i18n/messages/ja/folderSelection.ts | 2 + .../ui/src/lib/i18n/messages/ja/settings.ts | 84 ++++ .../lib/i18n/messages/ru/folderSelection.ts | 2 + .../ui/src/lib/i18n/messages/ru/settings.ts | 84 ++++ .../i18n/messages/zh-Hans/folderSelection.ts | 2 + .../src/lib/i18n/messages/zh-Hans/settings.ts | 84 ++++ packages/ui/src/stores/instances.ts | 7 +- packages/ui/src/stores/preferences.tsx | 359 ++++++++++++++++-- packages/ui/src/types/instance.ts | 3 + 37 files changed, 2699 insertions(+), 72 deletions(-) create mode 100644 packages/server/src/server/routes/remote-connections.ts create mode 100644 packages/server/src/server/ssh-connections.ts create mode 100644 packages/server/src/settings/binaries.test.ts create mode 100644 packages/server/src/workspaces/execution-launch.test.ts create mode 100644 packages/server/src/workspaces/execution-launch.ts create mode 100644 packages/ui/src/components/settings/connection-profiles-settings-section.tsx create mode 100644 packages/ui/src/components/settings/execution-profiles-settings-section.tsx diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index c40d05e37..f7c522409 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -14,6 +14,44 @@ import type { export type WorkspaceStatus = "starting" | "ready" | "stopped" | "error" +export type ExecutionProfileKind = "local" | "wsl" | "docker" | "command" +export type ExecutionProfileCwdMode = "workspace" | "inherit" + +export interface ExecutionProfileBase { + id: string + name: string + kind: ExecutionProfileKind +} + +export interface LocalExecutionProfile extends ExecutionProfileBase { + kind: "local" + binaryPath: string +} + +export interface WslExecutionProfile extends ExecutionProfileBase { + kind: "wsl" + distro: string + binaryPath: string +} + +export interface DockerExecutionProfile extends ExecutionProfileBase { + kind: "docker" + image: string + workspaceMountPath: string + configMountPath: string + command?: string[] + extraDockerArgs?: string[] +} + +export interface CommandExecutionProfile extends ExecutionProfileBase { + kind: "command" + executable: string + args?: string[] + cwdMode?: ExecutionProfileCwdMode +} + +export type ExecutionProfile = LocalExecutionProfile | WslExecutionProfile | DockerExecutionProfile | CommandExecutionProfile + export interface WorkspaceDescriptor { id: string /** Absolute path on the server host. */ @@ -29,6 +67,9 @@ export interface WorkspaceDescriptor { binaryId: string binaryLabel: string binaryVersion?: string + executionProfileId?: string + executionProfileName?: string + executionProfileKind?: ExecutionProfileKind createdAt: string updatedAt: string /** Present when `status` is "error". */ @@ -38,6 +79,7 @@ export interface WorkspaceDescriptor { export interface WorkspaceCreateRequest { path: string name?: string + executionProfileId?: string } export type WorkspaceCreateResponse = WorkspaceDescriptor @@ -320,6 +362,17 @@ export interface VoiceModeStateResponse { enabled: boolean } +export type ConnectionProfileKind = "remote-server" | "ssh" + +export interface ConnectionProfileBase { + id: string + name: string + kind: ConnectionProfileKind + createdAt: string + updatedAt: string + lastConnectedAt?: string +} + export interface RemoteServerProfile { id: string name: string @@ -330,6 +383,42 @@ export interface RemoteServerProfile { lastConnectedAt?: string } +export interface RemoteServerConnectionProfile extends ConnectionProfileBase { + kind: "remote-server" + baseUrl: string + skipTlsVerify: boolean +} + +export interface SshConnectionProfile extends ConnectionProfileBase { + kind: "ssh" + host: string + port?: number + username?: string + remotePath?: string + remoteServerPort?: number + bootstrapScript?: string +} + +export interface SshConnectionBootstrapRequest { + connectionProfileId?: string + name?: string + host: string + port?: number + username?: string + remotePath?: string + remoteServerPort?: number + bootstrapScript?: string +} + +export interface SshConnectionBootstrapResponse { + sessionId: string + baseUrl: string + localPort: number + remoteServerPort: number +} + +export type ConnectionProfile = RemoteServerConnectionProfile | SshConnectionProfile + export interface RemoteServerProbeRequest { baseUrl: string skipTlsVerify?: boolean diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 17de82f7f..a340b0986 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -22,6 +22,7 @@ import { resolveUi } from "./ui/remote-ui" import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_COOKIE_NAME, DEFAULT_AUTH_USERNAME } from "./auth/manager" import { resolveHttpsOptions } from "./server/tls" import { RemoteProxySessionManager } from "./server/remote-proxy" +import { SshConnectionSessionManager } from "./server/ssh-connections" import { resolveNetworkAddresses, resolveRemoteAddresses } from "./server/network-addresses" import { startDevReleaseMonitor } from "./releases/dev-release-monitor" import { SpeechService } from "./speech/service" @@ -400,6 +401,7 @@ async function main() { logger: logger.child({ component: "remote-proxy" }), httpsOptions: tlsResolution?.httpsOptions, }) + const sshConnectionSessionManager = new SshConnectionSessionManager(logger.child({ component: "ssh-connections" })) const voiceModeManager = new VoiceModeManager({ connections: clientConnectionManager, channel: pluginChannel, @@ -440,6 +442,7 @@ async function main() { pluginChannel, voiceModeManager, remoteProxySessionManager, + sshConnectionSessionManager, uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR, uiDevServerUrl: uiResolution.uiDevServerUrl, logger, @@ -466,6 +469,7 @@ async function main() { pluginChannel, voiceModeManager, remoteProxySessionManager, + sshConnectionSessionManager, uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR, uiDevServerUrl: undefined, logger, @@ -576,6 +580,12 @@ async function main() { logger.warn({ err: error }, "Client connection manager shutdown failed") } + try { + await sshConnectionSessionManager.shutdown() + } catch (error) { + logger.warn({ err: error }, "SSH connection session shutdown failed") + } + try { await workspaceManager.shutdown() logger.info("Workspace manager shutdown complete") diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index cf7dae364..ba5f6d471 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -27,6 +27,7 @@ import { registerWorktreeRoutes } from "./routes/worktrees" import { registerSpeechRoutes } from "./routes/speech" import { registerRemoteServerRoutes } from "./routes/remote-servers" import { registerRemoteProxyRoutes } from "./routes/remote-proxy" +import { registerRemoteConnectionRoutes } from "./routes/remote-connections" import { registerSideCarRoutes } from "./routes/sidecars" import { ServerMeta } from "../api-types" import { InstanceStore } from "../storage/instance-store" @@ -40,6 +41,7 @@ import { PluginChannelManager } from "../plugins/channel" import { VoiceModeManager } from "../plugins/voice-mode" import type { SideCarManager } from "../sidecars/manager" import type { RemoteProxySessionManager } from "./remote-proxy" +import type { SshConnectionSessionManager } from "./ssh-connections" interface HttpServerDeps { bindHost: string @@ -61,6 +63,7 @@ interface HttpServerDeps { pluginChannel: PluginChannelManager voiceModeManager: VoiceModeManager remoteProxySessionManager: RemoteProxySessionManager + sshConnectionSessionManager: SshConnectionSessionManager uiStaticDir: string uiDevServerUrl?: string logger: Logger @@ -282,6 +285,10 @@ export function createHttpServer(deps: HttpServerDeps) { workspaceManager: deps.workspaceManager, }) registerRemoteServerRoutes(app, { logger: apiLogger }) + registerRemoteConnectionRoutes(app, { + logger: apiLogger, + sshConnectionSessionManager: deps.sshConnectionSessionManager, + }) registerRemoteProxyRoutes(app, { logger: proxyLogger, sessionManager: deps.remoteProxySessionManager }) registerSpeechRoutes(app, { speechService: deps.speechService }) registerSideCarRoutes(app, { sidecarManager: deps.sidecarManager }) diff --git a/packages/server/src/server/routes/remote-connections.ts b/packages/server/src/server/routes/remote-connections.ts new file mode 100644 index 000000000..d76480720 --- /dev/null +++ b/packages/server/src/server/routes/remote-connections.ts @@ -0,0 +1,44 @@ +import type { FastifyInstance } from "fastify" +import { z } from "zod" +import type { SshConnectionBootstrapResponse } from "../../api-types" +import type { Logger } from "../../logger" +import type { SshConnectionSessionManager } from "../ssh-connections" + +interface RouteDeps { + logger: Logger + sshConnectionSessionManager: SshConnectionSessionManager +} + +const SshConnectSchema = z.object({ + connectionProfileId: z.string().trim().optional(), + name: z.string().trim().optional(), + host: z.string().trim().min(1), + port: z.number().int().positive().max(65535).optional(), + username: z.string().trim().optional(), + remotePath: z.string().trim().optional(), + remoteServerPort: z.number().int().positive().max(65535).optional(), + bootstrapScript: z.string().optional(), +}) + +export function registerRemoteConnectionRoutes(app: FastifyInstance, deps: RouteDeps) { + app.post( + "/api/remote-connections/ssh/connect", + async (request, reply): Promise => { + try { + const body = SshConnectSchema.parse(request.body ?? {}) + reply.code(201) + return await deps.sshConnectionSessionManager.connect(body) + } catch (error) { + deps.logger.warn({ err: error }, "Failed to establish SSH remote connection") + reply.code(400) + return { error: error instanceof Error ? error.message : "Failed to establish SSH remote connection" } + } + }, + ) + + app.delete<{ Params: { id: string } }>("/api/remote-connections/ssh/:id", async (request, reply) => { + await deps.sshConnectionSessionManager.disposeByProfileId(request.params.id) + reply.code(204) + return undefined + }) +} diff --git a/packages/server/src/server/routes/workspaces.ts b/packages/server/src/server/routes/workspaces.ts index 9f2a68a2e..67b43132e 100644 --- a/packages/server/src/server/routes/workspaces.ts +++ b/packages/server/src/server/routes/workspaces.ts @@ -13,6 +13,7 @@ interface RouteDeps { const WorkspaceCreateSchema = z.object({ path: z.string(), name: z.string().optional(), + executionProfileId: z.string().trim().optional(), }) const WorkspaceFilesQuerySchema = z.object({ @@ -59,7 +60,9 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) { app.post("/api/workspaces", async (request, reply) => { try { const body = WorkspaceCreateSchema.parse(request.body ?? {}) - const workspace = await deps.workspaceManager.create(body.path, body.name) + const workspace = await deps.workspaceManager.create(body.path, body.name, { + executionProfileId: body.executionProfileId, + }) reply.code(201) return workspace } catch (error) { diff --git a/packages/server/src/server/ssh-connections.ts b/packages/server/src/server/ssh-connections.ts new file mode 100644 index 000000000..73e6ac777 --- /dev/null +++ b/packages/server/src/server/ssh-connections.ts @@ -0,0 +1,272 @@ +import { spawn, type ChildProcess } from "child_process" +import { randomUUID } from "crypto" +import { createServer } from "net" +import { fetch } from "undici" +import type { Logger } from "../logger" +import type { SshConnectionBootstrapRequest, SshConnectionBootstrapResponse } from "../api-types" + +const LOOPBACK_HOST = "127.0.0.1" +const DEFAULT_SSH_PORT = 22 +const DEFAULT_REMOTE_SERVER_PORT = 9898 +const PROBE_TIMEOUT_MS = 15_000 +const PROBE_INTERVAL_MS = 500 + +interface ActiveSshSession { + sessionId: string + connectionProfileId?: string + child: ChildProcess + baseUrl: string + localPort: number + remoteServerPort: number + stderr: string[] +} + +export class SshConnectionSessionManager { + private readonly sessions = new Map() + private readonly sessionIdByProfileId = new Map() + + constructor(private readonly logger: Logger) {} + + async connect(request: SshConnectionBootstrapRequest): Promise { + const connectionProfileId = request.connectionProfileId?.trim() || undefined + if (connectionProfileId) { + const existingSessionId = this.sessionIdByProfileId.get(connectionProfileId) + if (existingSessionId) { + const existing = this.sessions.get(existingSessionId) + if (existing && (await this.isReachable(existing.baseUrl))) { + return { + sessionId: existing.sessionId, + baseUrl: existing.baseUrl, + localPort: existing.localPort, + remoteServerPort: existing.remoteServerPort, + } + } + await this.disposeByProfileId(connectionProfileId) + } + } + + const remoteServerPort = request.remoteServerPort ?? DEFAULT_REMOTE_SERVER_PORT + if (request.bootstrapScript?.trim()) { + await this.runBootstrapScript({ ...request, remoteServerPort }) + } + + const localPort = await getAvailablePort() + const target = buildSshTarget(request) + const args = [ + "-p", + String(request.port ?? DEFAULT_SSH_PORT), + "-o", + "ExitOnForwardFailure=yes", + "-o", + "ServerAliveInterval=30", + "-N", + "-L", + `${LOOPBACK_HOST}:${localPort}:${LOOPBACK_HOST}:${remoteServerPort}`, + target, + ] + + const child = spawn("ssh", args, { + stdio: ["ignore", "ignore", "pipe"], + detached: false, + }) + + const sessionId = randomUUID() + const baseUrl = `http://${LOOPBACK_HOST}:${localPort}` + const active: ActiveSshSession = { + sessionId, + connectionProfileId, + child, + baseUrl, + localPort, + remoteServerPort, + stderr: [], + } + + child.stderr?.on("data", (chunk: Buffer) => { + const text = chunk.toString("utf8").trim() + if (!text) return + active.stderr.push(text) + if (active.stderr.length > 20) { + active.stderr.shift() + } + }) + + child.once("exit", (code, signal) => { + this.logger.info({ sessionId, code, signal }, "SSH tunnel session exited") + this.sessions.delete(sessionId) + if (connectionProfileId) { + this.sessionIdByProfileId.delete(connectionProfileId) + } + }) + + this.sessions.set(sessionId, active) + if (connectionProfileId) { + this.sessionIdByProfileId.set(connectionProfileId, sessionId) + } + + try { + await this.waitForReachable(baseUrl, child, active.stderr) + } catch (error) { + await this.disposeSession(sessionId) + throw error + } + + return { + sessionId, + baseUrl, + localPort, + remoteServerPort, + } + } + + async disposeByProfileId(connectionProfileId: string): Promise { + const sessionId = this.sessionIdByProfileId.get(connectionProfileId) + if (!sessionId) return + await this.disposeSession(sessionId) + } + + async shutdown(): Promise { + await Promise.allSettled(Array.from(this.sessions.keys()).map((sessionId) => this.disposeSession(sessionId))) + } + + private async disposeSession(sessionId: string): Promise { + const session = this.sessions.get(sessionId) + if (!session) return + + this.sessions.delete(sessionId) + if (session.connectionProfileId) { + this.sessionIdByProfileId.delete(session.connectionProfileId) + } + + if (!session.child.killed) { + session.child.kill("SIGTERM") + await waitForChildExit(session.child, 2_000).catch(() => { + if (!session.child.killed) { + session.child.kill("SIGKILL") + } + }) + } + } + + private async runBootstrapScript(request: SshConnectionBootstrapRequest & { remoteServerPort: number }): Promise { + const target = buildSshTarget(request) + const args = [ + "-p", + String(request.port ?? DEFAULT_SSH_PORT), + target, + "sh", + "-s", + "--", + String(request.remoteServerPort), + request.remotePath?.trim() || "", + ] + + const prelude = [ + 'export CODENOMAD_REMOTE_PORT="$1"', + 'export CODENOMAD_REMOTE_PATH="$2"', + "shift 2", + request.bootstrapScript?.trim() || "", + "", + ].join("\n") + + await new Promise((resolve, reject) => { + const child = spawn("ssh", args, { stdio: ["pipe", "pipe", "pipe"] }) + let stderr = "" + let stdout = "" + + child.stdout?.on("data", (chunk: Buffer) => { + stdout += chunk.toString("utf8") + }) + child.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString("utf8") + }) + child.once("error", reject) + child.once("close", (code) => { + if (code === 0) { + resolve() + return + } + const detail = stderr.trim() || stdout.trim() || `SSH bootstrap exited with code ${code}` + reject(new Error(detail)) + }) + + child.stdin?.end(prelude) + }) + } + + private async waitForReachable(baseUrl: string, child: ChildProcess, stderrLines: string[]): Promise { + const startedAt = Date.now() + while (Date.now() - startedAt < PROBE_TIMEOUT_MS) { + if (child.exitCode !== null) { + throw new Error(stderrLines[stderrLines.length - 1] || "SSH tunnel exited before the remote server became reachable") + } + + if (await this.isReachable(baseUrl)) { + return + } + + await new Promise((resolve) => setTimeout(resolve, PROBE_INTERVAL_MS)) + } + + throw new Error(stderrLines[stderrLines.length - 1] || "Timed out waiting for the remote server over SSH") + } + + private async isReachable(baseUrl: string): Promise { + try { + const response = await fetch(new URL("/api/auth/status", `${baseUrl}/`), { + method: "GET", + headers: { Accept: "application/json" }, + }) + if (!response.ok) { + return false + } + const payload = (await response.json()) as { authenticated?: unknown } + return typeof payload?.authenticated === "boolean" + } catch { + return false + } + } +} + +function buildSshTarget(request: Pick): string { + const host = request.host.trim() + if (!host) { + throw new Error("SSH host is required") + } + const username = request.username?.trim() + return username ? `${username}@${host}` : host +} + +async function getAvailablePort(): Promise { + return await new Promise((resolve, reject) => { + const server = createServer() + server.unref() + server.once("error", reject) + server.listen(0, LOOPBACK_HOST, () => { + const address = server.address() + if (!address || typeof address === "string") { + server.close(() => reject(new Error("Failed to reserve a local port for SSH tunneling"))) + return + } + + const port = address.port + server.close((error) => { + if (error) { + reject(error) + return + } + resolve(port) + }) + }) + }) +} + +async function waitForChildExit(child: ChildProcess, timeoutMs: number): Promise { + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error("Timed out waiting for SSH process to exit")), timeoutMs) + child.once("exit", () => { + clearTimeout(timeout) + resolve() + }) + }) +} diff --git a/packages/server/src/settings/binaries.test.ts b/packages/server/src/settings/binaries.test.ts new file mode 100644 index 000000000..e728b244d --- /dev/null +++ b/packages/server/src/settings/binaries.test.ts @@ -0,0 +1,158 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import type { ExecutionProfile } from "../api-types" +import { BinaryResolver } from "./binaries" + +function createSettings(input?: { + server?: Record + ui?: Record +}) { + return { + getOwner(kind: "config" | "state", owner: string) { + if (kind === "config" && owner === "server") { + return input?.server ?? {} + } + if (kind === "state" && owner === "ui") { + return input?.ui ?? {} + } + return {} + }, + } +} + +describe("BinaryResolver", () => { + it("falls back to the configured default binary when no launch profile is selected", () => { + const resolver = new BinaryResolver( + createSettings({ + server: { opencodeBinary: "opencode-custom" }, + ui: { opencodeBinaries: [{ path: "opencode-custom", label: "Custom OpenCode", version: "1.2.3" }] }, + }) as any, + ) + + assert.deepEqual(resolver.resolveActive(), { + kind: "local", + path: "opencode-custom", + label: "Custom OpenCode", + version: "1.2.3", + }) + }) + + it("resolves an explicit local launch profile", () => { + const profile: ExecutionProfile = { + id: "local-default", + name: "Local Default", + kind: "local", + binaryPath: "C:/Tools/opencode.exe", + } + + const resolver = new BinaryResolver( + createSettings({ + server: { executionProfiles: [profile] }, + }) as any, + ) + + assert.deepEqual(resolver.resolveActive(profile.id), { + kind: "local", + path: "C:/Tools/opencode.exe", + label: "Local Default", + executionProfileId: "local-default", + executionProfileName: "Local Default", + executionProfileKind: "local", + }) + }) + + it("resolves a default WSL launch profile from server config", () => { + const profile: ExecutionProfile = { + id: "wsl-ubuntu", + name: "WSL Ubuntu", + kind: "wsl", + distro: "Ubuntu", + binaryPath: String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`, + } + + const resolver = new BinaryResolver( + createSettings({ + server: { + executionProfiles: [profile], + defaultExecutionProfileId: profile.id, + opencodeBinary: "opencode", + }, + }) as any, + ) + + assert.deepEqual(resolver.resolveActive(), { + kind: "wsl", + path: String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`, + label: "WSL Ubuntu", + executionProfileId: "wsl-ubuntu", + executionProfileName: "WSL Ubuntu", + executionProfileKind: "wsl", + }) + }) + + it("resolves a docker execution profile", () => { + const profile: ExecutionProfile = { + id: "docker-sandbox", + name: "Docker Sandbox", + kind: "docker", + image: "ghcr.io/example/opencode:latest", + workspaceMountPath: "/workspace", + configMountPath: "/root/.config/opencode", + command: ["opencode"], + extraDockerArgs: ["--init"], + } + + const resolver = new BinaryResolver( + createSettings({ + server: { executionProfiles: [profile] }, + }) as any, + ) + + assert.deepEqual(resolver.resolveActive(profile.id), { + kind: "docker", + label: "Docker Sandbox", + image: "ghcr.io/example/opencode:latest", + workspaceMountPath: "/workspace", + configMountPath: "/root/.config/opencode", + command: ["opencode"], + extraDockerArgs: ["--init"], + executionProfileId: "docker-sandbox", + executionProfileName: "Docker Sandbox", + executionProfileKind: "docker", + }) + }) + + it("resolves a command execution profile", () => { + const profile: ExecutionProfile = { + id: "ssh-wrapper", + name: "SSH Wrapper", + kind: "command", + executable: "ssh", + args: ["user@example.com"], + cwdMode: "inherit", + } + + const resolver = new BinaryResolver( + createSettings({ + server: { executionProfiles: [profile] }, + }) as any, + ) + + assert.deepEqual(resolver.resolveActive(profile.id), { + kind: "command", + label: "SSH Wrapper", + executable: "ssh", + args: ["user@example.com"], + cwdMode: "inherit", + executionProfileId: "ssh-wrapper", + executionProfileName: "SSH Wrapper", + executionProfileKind: "command", + }) + }) + + it("throws when an explicit execution profile id does not exist", () => { + const resolver = new BinaryResolver(createSettings() as any) + assert.throws(() => resolver.resolveActive("missing-profile"), /Execution profile not found/) + }) +}) diff --git a/packages/server/src/settings/binaries.ts b/packages/server/src/settings/binaries.ts index e4b25960b..59a08b057 100644 --- a/packages/server/src/settings/binaries.ts +++ b/packages/server/src/settings/binaries.ts @@ -1,4 +1,12 @@ import type { SettingsService } from "./service" +import type { + CommandExecutionProfile, + DockerExecutionProfile, + ExecutionProfile, + ExecutionProfileKind, + LocalExecutionProfile, + WslExecutionProfile, +} from "../api-types" export interface OpenCodeBinaryEntry { path: string @@ -7,12 +15,37 @@ export interface OpenCodeBinaryEntry { label?: string } -export interface ResolvedBinary { - path: string +interface ResolvedExecutionBase { label: string version?: string + executionProfileId?: string + executionProfileName?: string + executionProfileKind?: ExecutionProfileKind +} + +export interface ResolvedHostExecution extends ResolvedExecutionBase { + kind: "local" | "wsl" + path: string +} + +export interface ResolvedDockerExecution extends ResolvedExecutionBase { + kind: "docker" + image: string + workspaceMountPath: string + configMountPath: string + command?: string[] + extraDockerArgs?: string[] +} + +export interface ResolvedCommandExecution extends ResolvedExecutionBase { + kind: "command" + executable: string + args?: string[] + cwdMode?: "workspace" | "inherit" } +export type ResolvedBinary = ResolvedHostExecution | ResolvedDockerExecution | ResolvedCommandExecution + function prettyLabel(p: string): string { const parts = p.split(/[\\/]/) const last = parts[parts.length - 1] || p @@ -32,6 +65,23 @@ function readDefaultBinaryPath(settings: SettingsService): string | undefined { return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined } +function isExecutionProfile(value: unknown): value is ExecutionProfile { + return !!value && typeof value === "object" && typeof (value as any).id === "string" && typeof (value as any).kind === "string" +} + +function readExecutionProfiles(settings: SettingsService): ExecutionProfile[] { + const server = settings.getOwner("config", "server") + const list = (server as any)?.executionProfiles + if (!Array.isArray(list)) return [] + return list.filter(isExecutionProfile) +} + +function readDefaultExecutionProfileId(settings: SettingsService): string | undefined { + const server = settings.getOwner("config", "server") + const value = (server as any)?.defaultExecutionProfileId + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined +} + export class BinaryResolver { constructor(private readonly settings: SettingsService) {} @@ -39,6 +89,28 @@ export class BinaryResolver { return readUiBinaries(this.settings) } + listExecutionProfiles(): ExecutionProfile[] { + return readExecutionProfiles(this.settings) + } + + resolveActive(executionProfileId?: string): ResolvedBinary { + const profiles = this.listExecutionProfiles() + const requestedId = executionProfileId?.trim() || readDefaultExecutionProfileId(this.settings) + if (!requestedId) { + return this.resolveDefault() + } + + const profile = profiles.find((entry) => entry.id === requestedId) + if (!profile) { + if (executionProfileId?.trim()) { + throw new Error(`Execution profile not found: ${executionProfileId}`) + } + return this.resolveDefault() + } + + return this.resolveProfile(profile) + } + resolveDefault(): ResolvedBinary { const binaries = this.list() const configuredDefault = readDefaultBinaryPath(this.settings) @@ -47,9 +119,71 @@ export class BinaryResolver { const entry = binaries.find((b) => b.path === path) return { + kind: "local", path, label: entry?.label ?? prettyLabel(path), version: entry?.version, } } + + private resolveProfile(profile: ExecutionProfile): ResolvedBinary { + const shared = { + label: profile.name, + executionProfileId: profile.id, + executionProfileName: profile.name, + executionProfileKind: profile.kind, + } + + if (profile.kind === "local") { + return this.resolveLocalProfile(profile, shared) + } + + if (profile.kind === "wsl") { + return this.resolveWslProfile(profile, shared) + } + + if (profile.kind === "docker") { + return this.resolveDockerProfile(profile, shared) + } + + return this.resolveCommandProfile(profile, shared) + } + + private resolveLocalProfile(profile: LocalExecutionProfile, shared: Omit): ResolvedHostExecution { + return { + ...shared, + kind: "local", + path: profile.binaryPath, + } + } + + private resolveWslProfile(profile: WslExecutionProfile, shared: Omit): ResolvedHostExecution { + return { + ...shared, + kind: "wsl", + path: profile.binaryPath, + } + } + + private resolveDockerProfile(profile: DockerExecutionProfile, shared: Omit): ResolvedDockerExecution { + return { + ...shared, + kind: "docker", + image: profile.image, + workspaceMountPath: profile.workspaceMountPath, + configMountPath: profile.configMountPath, + command: profile.command, + extraDockerArgs: profile.extraDockerArgs, + } + } + + private resolveCommandProfile(profile: CommandExecutionProfile, shared: Omit): ResolvedCommandExecution { + return { + ...shared, + kind: "command", + executable: profile.executable, + args: profile.args, + cwdMode: profile.cwdMode, + } + } } diff --git a/packages/server/src/settings/service.ts b/packages/server/src/settings/service.ts index f4f0409c2..c4f3ba7d4 100644 --- a/packages/server/src/settings/service.ts +++ b/packages/server/src/settings/service.ts @@ -14,6 +14,52 @@ const CanonicalLogLevelSchema = z.preprocess( z.enum(["DEBUG", "INFO", "WARN", "ERROR"]), ) +const ExecutionProfileIdSchema = z.string().trim().min(1) +const ExecutionProfileNameSchema = z.string().trim().min(1) +const ExecutionProfileStringListSchema = z.array(z.string().trim().min(1)).max(64) + +const LocalExecutionProfileSchema = z.object({ + id: ExecutionProfileIdSchema, + name: ExecutionProfileNameSchema, + kind: z.literal("local"), + binaryPath: z.string().trim().min(1), +}) + +const WslExecutionProfileSchema = z.object({ + id: ExecutionProfileIdSchema, + name: ExecutionProfileNameSchema, + kind: z.literal("wsl"), + distro: z.string().trim().min(1), + binaryPath: z.string().trim().min(1), +}) + +const DockerExecutionProfileSchema = z.object({ + id: ExecutionProfileIdSchema, + name: ExecutionProfileNameSchema, + kind: z.literal("docker"), + image: z.string().trim().min(1), + workspaceMountPath: z.string().trim().min(1), + configMountPath: z.string().trim().min(1), + command: ExecutionProfileStringListSchema.optional(), + extraDockerArgs: ExecutionProfileStringListSchema.optional(), +}) + +const CommandExecutionProfileSchema = z.object({ + id: ExecutionProfileIdSchema, + name: ExecutionProfileNameSchema, + kind: z.literal("command"), + executable: z.string().trim().min(1), + args: ExecutionProfileStringListSchema.optional(), + cwdMode: z.enum(["workspace", "inherit"]).optional(), +}) + +const ExecutionProfileSchema = z.discriminatedUnion("kind", [ + LocalExecutionProfileSchema, + WslExecutionProfileSchema, + DockerExecutionProfileSchema, + CommandExecutionProfileSchema, +]) + function isPlainObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value) } @@ -39,6 +85,23 @@ function normalizeServerConfigOwner(value: SettingsDoc): SettingsDoc { } else if (next.logLevel !== undefined) { next.logLevel = "DEBUG" } + + const parsedExecutionProfiles = z.array(ExecutionProfileSchema).safeParse(next.executionProfiles) + if (parsedExecutionProfiles.success) { + next.executionProfiles = parsedExecutionProfiles.data + } else if (next.executionProfiles !== undefined) { + next.executionProfiles = [] + } + + const parsedDefaultExecutionProfileId = ExecutionProfileIdSchema.safeParse(next.defaultExecutionProfileId) + if (parsedDefaultExecutionProfileId.success) { + const profiles = Array.isArray(next.executionProfiles) ? next.executionProfiles : [] + const exists = profiles.some((profile) => isPlainObject(profile) && profile.id === parsedDefaultExecutionProfileId.data) + next.defaultExecutionProfileId = exists ? parsedDefaultExecutionProfileId.data : undefined + } else if (next.defaultExecutionProfileId !== undefined) { + next.defaultExecutionProfileId = undefined + } + return next } diff --git a/packages/server/src/workspaces/execution-launch.test.ts b/packages/server/src/workspaces/execution-launch.test.ts new file mode 100644 index 000000000..c5e0ea76a --- /dev/null +++ b/packages/server/src/workspaces/execution-launch.test.ts @@ -0,0 +1,63 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import type { ResolvedBinary } from "../settings/binaries" +import { buildLaunchCommand } from "./execution-launch" + +describe("buildLaunchCommand", () => { + it("builds a command execution profile launch", () => { + const execution: ResolvedBinary = { + kind: "command", + label: "Wrapper", + executable: "ssh", + args: ["user@example.com"], + cwdMode: "inherit", + } + + const result = buildLaunchCommand({ + execution, + workspacePath: "D:/CodeNomad", + environment: { CODENOMAD_INSTANCE_ID: "abc123" }, + logLevel: "DEBUG", + }) + + assert.equal(result.command, "ssh") + assert.deepEqual(result.args, ["user@example.com", "serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"]) + assert.equal(result.cwd, undefined) + assert.deepEqual(result.environment, { CODENOMAD_INSTANCE_ID: "abc123" }) + }) + + it("builds a docker execution profile launch with rewritten paths and URLs", () => { + const execution: ResolvedBinary = { + kind: "docker", + label: "Docker Sandbox", + image: "ghcr.io/example/opencode:latest", + workspaceMountPath: "/workspace", + configMountPath: "/root/.config/opencode", + command: ["opencode"], + extraDockerArgs: ["--init"], + } + + const result = buildLaunchCommand({ + execution, + workspacePath: "D:/CodeNomad", + environment: { + OPENCODE_CONFIG_DIR: "C:/Users/Admin/.config/opencode", + NODE_EXTRA_CA_CERTS: "C:/Users/Admin/.config/codenomad/certs.pem", + CODENOMAD_BASE_URL: "https://127.0.0.1:9898", + OPENCODE_SERVER_BASE_URL: "https://127.0.0.1:9898/workspaces/abc/worktrees/root/instance", + }, + logLevel: "INFO", + }) + + assert.equal(result.command, "docker") + assert.ok(result.args.includes("ghcr.io/example/opencode:latest")) + assert.ok(result.args.includes("D:/CodeNomad:/workspace")) + assert.ok(result.args.includes("C:/Users/Admin/.config/opencode:/root/.config/opencode")) + assert.ok(result.args.includes("C:/Users/Admin/.config/codenomad/certs.pem:/tmp/codenomad-node-extra-ca.pem:ro")) + assert.ok(result.args.includes("CODENOMAD_BASE_URL=https://host.docker.internal:9898")) + assert.ok(result.args.includes("OPENCODE_CONFIG_DIR=/root/.config/opencode")) + assert.ok(result.args.includes("NODE_EXTRA_CA_CERTS=/tmp/codenomad-node-extra-ca.pem")) + assert.deepEqual(result.args.slice(-6), ["serve", "--port", "0", "--print-logs", "--log-level", "INFO"]) + }) +}) diff --git a/packages/server/src/workspaces/execution-launch.ts b/packages/server/src/workspaces/execution-launch.ts new file mode 100644 index 000000000..53bd65105 --- /dev/null +++ b/packages/server/src/workspaces/execution-launch.ts @@ -0,0 +1,114 @@ +import { URL } from "url" +import type { ResolvedBinary } from "../settings/binaries" + +const DOCKER_HOST_ALIAS = "host.docker.internal" +const DOCKER_CA_CERT_PATH = "/tmp/codenomad-node-extra-ca.pem" + +export interface LaunchCommandSpec { + command: string + args: string[] + cwd?: string + environment?: Record +} + +interface BuildLaunchCommandParams { + execution: ResolvedBinary + workspacePath: string + environment: Record + logLevel: string +} + +export function buildLaunchCommand(params: BuildLaunchCommandParams): LaunchCommandSpec { + const openCodeArgs = ["serve", "--port", "0", "--print-logs", "--log-level", params.logLevel] + + if (params.execution.kind === "docker") { + return buildDockerLaunchCommand(params.execution, params.workspacePath, params.environment, openCodeArgs) + } + + if (params.execution.kind === "command") { + return { + command: params.execution.executable, + args: [...(params.execution.args ?? []), ...openCodeArgs], + cwd: params.execution.cwdMode === "inherit" ? undefined : params.workspacePath, + environment: params.environment, + } + } + + return { + command: params.execution.path, + args: openCodeArgs, + cwd: params.workspacePath, + environment: params.environment, + } +} + +function buildDockerLaunchCommand( + execution: Extract, + workspacePath: string, + environment: Record, + openCodeArgs: string[], +): LaunchCommandSpec { + const configDir = environment.OPENCODE_CONFIG_DIR?.trim() + if (!configDir) { + throw new Error("OPENCODE_CONFIG_DIR is required for Docker execution profiles") + } + + const containerEnvironment: Record = { ...environment } + containerEnvironment.OPENCODE_CONFIG_DIR = execution.configMountPath + + if (containerEnvironment.CODENOMAD_BASE_URL) { + containerEnvironment.CODENOMAD_BASE_URL = rewriteDockerBaseUrl(containerEnvironment.CODENOMAD_BASE_URL) + } + if (containerEnvironment.OPENCODE_SERVER_BASE_URL) { + containerEnvironment.OPENCODE_SERVER_BASE_URL = rewriteDockerBaseUrl(containerEnvironment.OPENCODE_SERVER_BASE_URL) + } + + const nodeExtraCaCerts = containerEnvironment.NODE_EXTRA_CA_CERTS?.trim() + const dockerArgs = [ + "run", + "--rm", + "-i", + "--workdir", + execution.workspaceMountPath, + "--add-host", + `${DOCKER_HOST_ALIAS}:host-gateway`, + "-v", + `${workspacePath}:${execution.workspaceMountPath}`, + "-v", + `${configDir}:${execution.configMountPath}`, + ] + + if (nodeExtraCaCerts) { + dockerArgs.push("-v", `${nodeExtraCaCerts}:${DOCKER_CA_CERT_PATH}:ro`) + containerEnvironment.NODE_EXTRA_CA_CERTS = DOCKER_CA_CERT_PATH + } + + for (const [key, value] of Object.entries(containerEnvironment)) { + dockerArgs.push("-e", `${key}=${value}`) + } + + if (execution.extraDockerArgs?.length) { + dockerArgs.push(...execution.extraDockerArgs) + } + + dockerArgs.push(execution.image) + dockerArgs.push(...(execution.command?.length ? execution.command : ["opencode"])) + dockerArgs.push(...openCodeArgs) + + return { + command: "docker", + args: dockerArgs, + } +} + +function rewriteDockerBaseUrl(input: string): string { + try { + const url = new URL(input) + if (url.hostname === "127.0.0.1" || url.hostname === "localhost") { + url.hostname = DOCKER_HOST_ALIAS + } + return url.toString().replace(/\/$/, "") + } catch { + return input + } +} diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index 063c2cbb9..3355cc7e3 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -11,6 +11,7 @@ import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../ import { WorkspaceRuntime, ProcessExitInfo } from "./runtime" import { Logger } from "../logger" import { getOpencodeConfigDir } from "../opencode-config.js" +import { buildLaunchCommand } from "./execution-launch" import { OPENCODE_SERVER_BASE_URL_ENV, buildOpencodeBasicAuthHeader, @@ -89,15 +90,17 @@ export class WorkspaceManager { browser.writeFile(relativePath, contents) } - async create(folder: string, name?: string): Promise { + async create(folder: string, name?: string, options?: { executionProfileId?: string }): Promise { const id = `${Date.now().toString(36)}` - const binary = this.options.binaryResolver.resolveDefault() - const resolvedBinaryPath = this.resolveBinaryPath(binary.path) + const execution = this.options.binaryResolver.resolveActive(options?.executionProfileId) + const resolvedBinaryPath = this.resolveBinaryPath( + execution.kind === "command" ? execution.executable : execution.kind === "docker" ? "docker" : execution.path, + ) const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder) clearWorkspaceSearchCache(workspacePath) - this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: resolvedBinaryPath }, "Creating workspace") + this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: resolvedBinaryPath, executionKind: execution.kind }, "Creating workspace") const proxyPath = `/workspaces/${id}/worktrees/root/instance` @@ -109,8 +112,11 @@ export class WorkspaceManager { status: "starting", proxyPath, binaryId: resolvedBinaryPath, - binaryLabel: binary.label, - binaryVersion: binary.version, + binaryLabel: execution.label, + binaryVersion: execution.version, + executionProfileId: execution.executionProfileId, + executionProfileName: execution.executionProfileName, + executionProfileKind: execution.executionProfileKind, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), } @@ -148,13 +154,21 @@ export class WorkspaceManager { } const logLevel = (serverConfig as any)?.logLevel + const launchCommand = buildLaunchCommand({ + execution, + workspacePath, + environment, + logLevel: typeof logLevel === "string" ? logLevel.toUpperCase() : "DEBUG", + }) try { const { pid, port, exitPromise, getLastOutput } = await this.runtime.launch({ workspaceId: id, folder: workspacePath, - binaryPath: resolvedBinaryPath, - environment, + binaryPath: launchCommand.command, + commandArgs: launchCommand.args, + spawnCwd: launchCommand.cwd, + environment: launchCommand.environment, logLevel, onExit: (info) => this.handleProcessExit(info.workspaceId, info), }) diff --git a/packages/server/src/workspaces/runtime.ts b/packages/server/src/workspaces/runtime.ts index efc77f9a1..06aa772d2 100644 --- a/packages/server/src/workspaces/runtime.ts +++ b/packages/server/src/workspaces/runtime.ts @@ -25,6 +25,8 @@ interface LaunchOptions { workspaceId: string folder: string binaryPath: string + commandArgs?: string[] + spawnCwd?: string environment?: Record logLevel?: string onExit?: (info: ProcessExitInfo) => void @@ -55,7 +57,7 @@ export class WorkspaceRuntime { this.validateFolder(options.folder) const logLevel = typeof options.logLevel === "string" ? options.logLevel.toUpperCase() : "DEBUG" - const args = ["serve", "--port", "0", "--print-logs", "--log-level", logLevel] + const args = options.commandArgs ?? ["serve", "--port", "0", "--print-logs", "--log-level", logLevel] const env = { ...process.env, ...(options.environment ?? {}) } let exitResolve: ((info: ProcessExitInfo) => void) | null = null @@ -83,7 +85,7 @@ export class WorkspaceRuntime { return new Promise((resolve, reject) => { const propagatedEnvKeys = Object.keys(options.environment ?? {}) const spec = buildSpawnSpec(options.binaryPath, args, { - cwd: options.folder, + cwd: options.spawnCwd ?? options.folder, env, propagateEnvKeys: propagatedEnvKeys, wslPidMarker: WSL_PID_MARKER, diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 774d16b84..bcaff2ed2 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -257,7 +257,7 @@ const App: Component = () => { const launchErrorMessage = () => launchError()?.message ?? "" - async function handleSelectFolder(folderPath: string, binaryPath?: string) { + async function handleSelectFolder(folderPath: string, binaryPath?: string, options?: { executionProfileId?: string }) { if (!folderPath) { return } @@ -266,7 +266,9 @@ const App: Component = () => { try { recordWorkspaceLaunch(folderPath, selectedBinary) clearLaunchError() - const instanceId = await createInstance(folderPath, selectedBinary) + const instanceId = await createInstance(folderPath, selectedBinary, { + executionProfileId: options?.executionProfileId, + }) selectInstanceTab(instanceId) setShowFolderSelection(false) diff --git a/packages/ui/src/components/folder-selection-view.tsx b/packages/ui/src/components/folder-selection-view.tsx index 41c627898..3eebbedce 100644 --- a/packages/ui/src/components/folder-selection-view.tsx +++ b/packages/ui/src/components/folder-selection-view.tsx @@ -1,6 +1,7 @@ import { Dialog } from "@kobalte/core/dialog" import { Select } from "@kobalte/core/select" -import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js" +import { Component, createSignal, Show, For, onMount, onCleanup, createEffect, createMemo } from "solid-js" +import type { ConnectionProfile, RemoteServerConnectionProfile, SshConnectionProfile } from "../../../server/src/api-types" import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown, X, Globe, Loader2 } from "lucide-solid" import { useConfig } from "../stores/preferences" import DirectoryBrowserDialog from "./directory-browser-dialog" @@ -27,7 +28,7 @@ type HomeTab = "local" | "servers" interface FolderSelectionViewProps { - onSelectFolder: (folder: string, binaryPath?: string) => void + onSelectFolder: (folder: string, binaryPath?: string, options?: { executionProfileId?: string }) => void onOpenSidecar?: () => void isLoading?: boolean onClose?: () => void @@ -40,15 +41,21 @@ const FolderSelectionView: Component = (props) => { preferences, updatePreferences, serverSettings, - remoteServers, + executionProfiles, + defaultExecutionProfileId, + lastSelectedExecutionProfileId, + setLastSelectedExecutionProfileId, + connectionProfiles, + saveConnectionProfile, + removeConnectionProfile, saveRemoteServerProfile, markRemoteServerConnected, - removeRemoteServerProfile, } = useConfig() const { t, locale } = useI18n() const [selectedIndex, setSelectedIndex] = createSignal(0) const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent") const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode") + const [selectedExecutionProfileId, setSelectedExecutionProfileId] = createSignal(lastSelectedExecutionProfileId() ?? defaultExecutionProfileId() ?? null) const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false) const [activeTab, setActiveTab] = createSignal("local") const [isServerDialogOpen, setIsServerDialogOpen] = createSignal(false) @@ -61,6 +68,7 @@ const FolderSelectionView: Component = (props) => { let recentListRef: HTMLDivElement | undefined type LanguageOption = { value: Locale; label: string } + type ExecutionProfileOption = { value: string; label: string; subtitle: string } const languageOptions: LanguageOption[] = [ { value: "en", label: "English" }, @@ -73,9 +81,21 @@ const FolderSelectionView: Component = (props) => { ] const selectedLanguageOption = () => languageOptions.find((opt) => opt.value === locale()) ?? languageOptions[0] + const executionProfileOptions = createMemo(() => + executionProfiles().map((profile) => ({ + value: profile.id, + label: profile.name, + subtitle: profile.kind, + })), + ) + const selectedExecutionProfileOption = createMemo(() => { + const options = executionProfileOptions() + const selectedId = selectedExecutionProfileId() + return options.find((option) => option.value === selectedId) ?? options[0] + }) const folders = () => recentFolders() - const serverList = () => remoteServers() + const serverList = () => connectionProfiles() const isLoading = () => Boolean(props.isLoading) const canUseRemoteServerWindows = () => canOpenRemoteWindows() @@ -90,6 +110,34 @@ const FolderSelectionView: Component = (props) => { setSelectedBinary((current) => (current === lastUsed ? current : lastUsed)) }) + createEffect(() => { + const options = executionProfileOptions() + if (options.length === 0) { + setSelectedExecutionProfileId(null) + return + } + + const defaultId = defaultExecutionProfileId() + const selectedId = selectedExecutionProfileId() + const targetId = + selectedId && options.some((option) => option.value === selectedId) + ? selectedId + : lastSelectedExecutionProfileId() && options.some((option) => option.value === lastSelectedExecutionProfileId()) + ? lastSelectedExecutionProfileId()! + : defaultId && options.some((option) => option.value === defaultId) + ? defaultId + : options[0]?.value + + setSelectedExecutionProfileId((current) => (current === targetId ? current : targetId ?? null)) + }) + + createEffect(() => { + const selectedId = selectedExecutionProfileId() + if (!selectedId) return + if (lastSelectedExecutionProfileId() === selectedId) return + setLastSelectedExecutionProfileId(selectedId) + }) + function scrollToIndex(index: number) { const container = recentListRef @@ -200,7 +248,7 @@ const FolderSelectionView: Component = (props) => { const server = serverList()[index] if (server) { - void handleConnectSavedServer(server.id) + void handleConnectSavedConnection(server.id) } } @@ -273,7 +321,9 @@ const FolderSelectionView: Component = (props) => { function handleFolderSelect(path: string) { if (isLoading()) return - props.onSelectFolder(path, selectedBinary()) + props.onSelectFolder(path, selectedBinary(), { + executionProfileId: selectedExecutionProfileId() ?? undefined, + }) } function resetServerDialog() { @@ -362,13 +412,41 @@ const FolderSelectionView: Component = (props) => { } } - async function handleConnectSavedServer(id: string) { + async function handleConnectSavedConnection(id: string) { if (!canUseRemoteServerWindows()) return - const target = remoteServers().find((entry) => entry.id === id) + const target = connectionProfiles().find((entry) => entry.id === id) if (!target || connectingServerId()) return setConnectingServerId(id) try { - await probeAndOpenServer(target, true) + if (target.kind === "remote-server") { + await probeAndOpenServer(target, true) + return + } + + const result = await serverApi.connectSshRemote({ + connectionProfileId: target.id, + name: target.name, + host: target.host, + port: target.port, + username: target.username, + remotePath: target.remotePath, + remoteServerPort: target.remoteServerPort, + bootstrapScript: target.bootstrapScript, + }) + + const now = new Date().toISOString() + await saveConnectionProfile({ + ...target, + updatedAt: now, + lastConnectedAt: now, + }) + + await openRemoteServerWindow({ + id: target.id, + name: target.name, + baseUrl: result.baseUrl, + skipTlsVerify: false, + }) } catch (error) { showAlertDialog(error instanceof Error ? error.message : String(error), { title: t("folderSelection.servers.errorTitle"), @@ -379,6 +457,13 @@ const FolderSelectionView: Component = (props) => { } } + async function handleRemoveSavedConnection(profile: ConnectionProfile) { + if (profile.kind === "ssh") { + await serverApi.disconnectSshRemote(profile.id).catch(() => undefined) + } + removeConnectionProfile(profile.id) + } + async function handleBrowse() { if (isLoading()) return setFocusMode("new") @@ -685,7 +770,7 @@ const FolderSelectionView: Component = (props) => { color: activeTab() === "servers" ? "var(--text-muted)" : "var(--text-secondary)", }} > - {t("folderSelection.servers.count", { count: remoteServers().length })} + {t("folderSelection.servers.count", { count: serverList().length })}

@@ -696,7 +781,7 @@ const FolderSelectionView: Component = (props) => { when={activeTab() === "local"} fallback={ 0} + when={canUseRemoteServerWindows() && serverList().length > 0} fallback={
@@ -721,7 +806,7 @@ const FolderSelectionView: Component = (props) => { class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto" ref={(el) => (recentListRef = el)} > - + {(server, index) => (
= (props) => {
-
+
+ 0}> +
+
+
{t("folderSelection.executionProfile.label")}
+
{t("folderSelection.executionProfile.subtitle")}
+
+ + value={selectedExecutionProfileOption()} + onChange={(option) => { + if (!option) return + setSelectedExecutionProfileId(option.value) + }} + options={executionProfileOptions()} + optionValue="value" + optionTextValue="label" + itemComponent={(itemProps) => ( + +
+ {itemProps.item.rawValue.label} + {itemProps.item.rawValue.subtitle} +
+
+ )} + > + +
+ > + {(state) => ( +
+ + {state.selectedOption()?.label} + + {state.selectedOption()?.subtitle} +
+ )} + +
+ + + +
+ + + + + + + +
+
+