From 5c31794d15c393881f032ca52ae8d9b01c9906e6 Mon Sep 17 00:00:00 2001 From: Aleksey Bykhun Date: Fri, 28 Nov 2025 16:35:21 -0800 Subject: [PATCH 01/24] feat(desktop): add cloud terminal button with yolocode e2b api integration - add CloudSandbox type and cloudSandbox field to Worktree - create cloud-api-client.ts for yolocode E2B sandbox API (uses gh token auth) - add cloud-sandbox-create and cloud-sandbox-delete IPC channels - add New Cloud Terminal button in TabsView with blue styling - extract github repo URL from local repo path using git remote --- apps/desktop/src/main/index.ts | 2 + apps/desktop/src/main/lib/cloud-api-client.ts | 185 ++++++++++++++++++ apps/desktop/src/main/lib/cloud-ipcs.ts | 81 ++++++++ .../WorkspaceView/Sidebar/TabsView/index.tsx | 101 ++++++++-- .../src/shared/ipc-channels/worktree.ts | 24 ++- apps/desktop/src/shared/types.ts | 12 ++ 6 files changed, 393 insertions(+), 12 deletions(-) create mode 100644 apps/desktop/src/main/lib/cloud-api-client.ts create mode 100644 apps/desktop/src/main/lib/cloud-ipcs.ts diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index b7d6ac94041..9675af6ee2d 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -2,6 +2,7 @@ import path from "node:path"; import { app } from "electron"; import { makeAppSetup } from "lib/electron-app/factories/app/setup"; import { setupAgentHooks } from "./lib/agent-setup"; +import { registerCloudHandlers } from "./lib/cloud-ipcs"; import { initDb } from "./lib/db"; import { registerStorageHandlers } from "./lib/storage-ipcs"; import { terminalManager } from "./lib/terminal-manager"; @@ -28,6 +29,7 @@ app.on("open-url", (event, _url) => { }); registerStorageHandlers(); +registerCloudHandlers(); // Allow multiple instances - removed single instance lock (async () => { diff --git a/apps/desktop/src/main/lib/cloud-api-client.ts b/apps/desktop/src/main/lib/cloud-api-client.ts new file mode 100644 index 00000000000..3297967d01e --- /dev/null +++ b/apps/desktop/src/main/lib/cloud-api-client.ts @@ -0,0 +1,185 @@ +import { execSync } from "node:child_process"; +import type { CloudSandbox } from "shared/types"; + +interface CreateSandboxParams { + name: string; + githubRepo?: string; + taskDescription?: string; + envVars?: Record; +} + +interface CreateSandboxResponse { + id: string; + name: string; + template: string; + status: string; + createdAt: string; + metadata: { + userId: string; + userLogin: string; + displayName: string; + name: string; + actualSandboxName: string; + githubRepo?: string; + autoPause: string; + }; + githubRepo?: string; + host: string; + websshHost: string; + claudeHost: string; +} + +/** + * Client for interacting with yolocode cloud API + * Uses GitHub token for authentication + */ +class CloudApiClient { + private baseUrl = "http://localhost:3001/api/e2b-sandboxes"; + + /** + * Get GitHub token from gh CLI + */ + private getGithubToken(): string | null { + try { + const token = execSync("gh auth token", { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + return token; + } catch (error) { + console.error("Failed to get GitHub token:", error); + return null; + } + } + + /** + * Create a new cloud sandbox + */ + async createSandbox( + params: CreateSandboxParams, + ): Promise<{ success: boolean; sandbox?: CloudSandbox; error?: string }> { + const token = this.getGithubToken(); + if (!token) { + return { + success: false, + error: "GitHub authentication required. Please run 'gh auth login'", + }; + } + + try { + // Get Claude Code auth token from .env.local + const claudeAuthToken = process.env.CLAUDE_CODE_OAUTH_TOKEN; + + const requestBody = { + name: params.name, + template: "yolocode", + githubRepo: params.githubRepo, + taskDescription: params.taskDescription, + envVars: { + ...params.envVars, + ...(claudeAuthToken && { + CLAUDE_CODE_OAUTH_TOKEN: claudeAuthToken, + }), + }, + }; + + // Log request but mask sensitive data + console.log("Creating sandbox with params:", { + name: requestBody.name, + template: requestBody.template, + githubRepo: requestBody.githubRepo, + taskDescription: requestBody.taskDescription, + envVars: claudeAuthToken + ? { CLAUDE_CODE_OAUTH_TOKEN: "***" } + : undefined, + }); + + const response = await fetch(this.baseUrl, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error("API error:", errorText); + console.error("Response status:", response.status); + console.error("Response statusText:", response.statusText); + return { + success: false, + error: `Failed to create sandbox: ${response.statusText}. Details: ${errorText}`, + }; + } + + const data: CreateSandboxResponse = await response.json(); + + // Override claudeHost to use port 7030 for web UI + const claudeHost = + data.claudeHost?.replace(/:\d+/, ":7030") || data.claudeHost; + + const sandbox: CloudSandbox = { + id: data.id, + name: data.name, + status: "running", + websshHost: data.websshHost, + claudeHost: claudeHost, + createdAt: data.createdAt, + }; + + console.log("Created sandbox:", sandbox); + + return { success: true, sandbox }; + } catch (error) { + console.error("Failed to create sandbox:", error); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + /** + * Delete a cloud sandbox + */ + async deleteSandbox( + sandboxId: string, + ): Promise<{ success: boolean; error?: string }> { + const token = this.getGithubToken(); + if (!token) { + return { + success: false, + error: "GitHub authentication required", + }; + } + + try { + const response = await fetch(`${this.baseUrl}/${sandboxId}`, { + method: "DELETE", + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + return { + success: false, + error: `Failed to delete sandbox: ${response.statusText}`, + }; + } + + return { success: true }; + } catch (error) { + console.error("Failed to delete sandbox:", error); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } +} + +export const cloudApiClient = new CloudApiClient(); +export default cloudApiClient; diff --git a/apps/desktop/src/main/lib/cloud-ipcs.ts b/apps/desktop/src/main/lib/cloud-ipcs.ts new file mode 100644 index 00000000000..e25149cda9d --- /dev/null +++ b/apps/desktop/src/main/lib/cloud-ipcs.ts @@ -0,0 +1,81 @@ +import { execSync } from "node:child_process"; +import { ipcMain } from "electron"; +import { cloudApiClient } from "./cloud-api-client"; +import { db } from "./db"; + +/** + * Extract GitHub repo URL from a local git repository path + */ +function getGithubRepoUrl(repoPath: string): string | null { + try { + const remoteUrl = execSync("git remote get-url origin", { + cwd: repoPath, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + + // Convert SSH URL to HTTPS if needed + // git@github.com:user/repo.git -> https://github.com/user/repo + if (remoteUrl.startsWith("git@github.com:")) { + const path = remoteUrl + .replace("git@github.com:", "") + .replace(/\.git$/, ""); + return `https://github.com/${path}`; + } + + // Already HTTPS, just clean up + if (remoteUrl.includes("github.com")) { + return remoteUrl.replace(/\.git$/, ""); + } + + return remoteUrl; + } catch (error) { + console.error("Failed to get GitHub repo URL:", error); + return null; + } +} + +/** + * Register cloud sandbox IPC handlers + */ +export function registerCloudHandlers() { + ipcMain.handle( + "cloud-sandbox-create", + async ( + _event, + input: { name: string; projectId: string; taskDescription?: string }, + ) => { + // Look up project to get mainRepoPath + const project = db.data.projects.find((p) => p.id === input.projectId); + if (!project) { + return { + success: false, + error: `Project ${input.projectId} not found`, + }; + } + + // Extract GitHub URL from local repo path + const githubRepo = getGithubRepoUrl(project.mainRepoPath); + if (!githubRepo) { + return { + success: false, + error: + "Could not determine GitHub repository URL. Make sure the repo has a GitHub origin.", + }; + } + + return cloudApiClient.createSandbox({ + name: input.name, + githubRepo, + taskDescription: input.taskDescription, + }); + }, + ); + + ipcMain.handle( + "cloud-sandbox-delete", + async (_event, input: { sandboxId: string }) => { + return cloudApiClient.deleteSandbox(input.sandboxId); + }, + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/index.tsx index 6843fc3d7a6..a93c92301c7 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/index.tsx @@ -1,7 +1,7 @@ import { Button } from "@superset/ui/button"; import { LayoutGroup, motion } from "framer-motion"; -import { useMemo } from "react"; -import { HiMiniPlus } from "react-icons/hi2"; +import { useMemo, useState } from "react"; +import { HiMiniCloud, HiMiniPlus } from "react-icons/hi2"; import { trpc } from "renderer/lib/trpc"; import { useAddTab, useTabs } from "renderer/stores"; import { TabItem } from "./TabItem"; @@ -12,6 +12,7 @@ export function TabsView() { const activeWorkspaceId = activeWorkspace?.id; const allTabs = useTabs(); const addTab = useAddTab(); + const [isCreatingCloud, setIsCreatingCloud] = useState(false); const tabs = useMemo( () => @@ -32,20 +33,98 @@ export function TabsView() { } }; + const handleAddCloudTerminal = async () => { + if (!activeWorkspace) return; + + setIsCreatingCloud(true); + try { + // Generate random two-word name for cloud sandbox + const adjectives = [ + "happy", + "sleepy", + "brave", + "clever", + "gentle", + "bright", + "calm", + "bold", + "swift", + "quiet", + ]; + const nouns = [ + "cat", + "fox", + "owl", + "bear", + "wolf", + "deer", + "hawk", + "lynx", + "seal", + "dove", + ]; + const randomAdj = + adjectives[Math.floor(Math.random() * adjectives.length)]; + const randomNoun = nouns[Math.floor(Math.random() * nouns.length)]; + const timestamp = Date.now().toString(36); + const sandboxName = `${randomAdj}-${randomNoun}-${timestamp}`; + + // Create cloud sandbox + const result = await window.ipcRenderer.invoke("cloud-sandbox-create", { + name: sandboxName, + projectId: activeWorkspace.projectId, + taskDescription: `Cloud development for ${activeWorkspace.name}`, + }); + + if (result.success && result.sandbox?.claudeHost) { + // Open the Claude host URL in the default browser + const claudeUrl = result.sandbox.claudeHost.startsWith("http") + ? result.sandbox.claudeHost + : `https://${result.sandbox.claudeHost}`; + window.open(claudeUrl, "_blank"); + } else { + console.error("Failed to create cloud sandbox:", result.error); + alert( + `Failed to create cloud terminal: ${result.error || "Unknown error"}`, + ); + } + } catch (error) { + console.error("Error creating cloud terminal:", error); + alert( + `Error creating cloud terminal: ${error instanceof Error ? error.message : String(error)}`, + ); + } finally { + setIsCreatingCloud(false); + } + }; + return (