diff --git a/drizzle/0008_medical_vulcan.sql b/drizzle/0008_medical_vulcan.sql new file mode 100644 index 0000000..23ebec5 --- /dev/null +++ b/drizzle/0008_medical_vulcan.sql @@ -0,0 +1,4 @@ +ALTER TABLE `apps` ADD `vercel_project_id` text;--> statement-breakpoint +ALTER TABLE `apps` ADD `vercel_project_name` text;--> statement-breakpoint +ALTER TABLE `apps` ADD `vercel_team_id` text;--> statement-breakpoint +ALTER TABLE `apps` ADD `vercel_deployment_url` text; \ No newline at end of file diff --git a/drizzle/meta/0008_snapshot.json b/drizzle/meta/0008_snapshot.json new file mode 100644 index 0000000..441f283 --- /dev/null +++ b/drizzle/meta/0008_snapshot.json @@ -0,0 +1,412 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "553360d1-7173-4bb0-9f31-ab49a0010279", + "prevId": "035de440-2d81-4a70-8068-ad4702c9fe32", + "tables": { + "apps": { + "name": "apps", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "github_org": { + "name": "github_org", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_repo": { + "name": "github_repo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_branch": { + "name": "github_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "supabase_project_id": { + "name": "supabase_project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vercel_project_id": { + "name": "vercel_project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vercel_project_name": { + "name": "vercel_project_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vercel_team_id": { + "name": "vercel_team_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vercel_deployment_url": { + "name": "vercel_deployment_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "chat_context": { + "name": "chat_context", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chats": { + "name": "chats", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "app_id": { + "name": "app_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "initial_commit_hash": { + "name": "initial_commit_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": { + "chats_app_id_apps_id_fk": { + "name": "chats_app_id_apps_id_fk", + "tableFrom": "chats", + "tableTo": "apps", + "columnsFrom": [ + "app_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "language_model_providers": { + "name": "language_model_providers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "api_base_url": { + "name": "api_base_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "env_var_name": { + "name": "env_var_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "language_models": { + "name": "language_models", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "api_name": { + "name": "api_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "builtin_provider_id": { + "name": "builtin_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_provider_id": { + "name": "custom_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_output_tokens": { + "name": "max_output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "context_window": { + "name": "context_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": { + "language_models_custom_provider_id_language_model_providers_id_fk": { + "name": "language_models_custom_provider_id_language_model_providers_id_fk", + "tableFrom": "language_models", + "tableTo": "language_model_providers", + "columnsFrom": [ + "custom_provider_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "messages": { + "name": "messages", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "chat_id": { + "name": "chat_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "approval_state": { + "name": "approval_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commit_hash": { + "name": "commit_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": { + "messages_chat_id_chats_id_fk": { + "name": "messages_chat_id_chats_id_fk", + "tableFrom": "messages", + "tableTo": "chats", + "columnsFrom": [ + "chat_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/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index a8b283e..6576f59 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1750186036000, "tag": "0007_dapper_overlord", "breakpoints": true + }, + { + "idx": 8, + "version": "6", + "when": 1752625491756, + "tag": "0008_medical_vulcan", + "breakpoints": true } ] } \ No newline at end of file diff --git a/e2e-tests/snapshots/github.spec.ts_disconnect-from-repo-1.aria.yml b/e2e-tests/snapshots/github.spec.ts_disconnect-from-repo-1.aria.yml index 3890030..90b875b 100644 --- a/e2e-tests/snapshots/github.spec.ts_disconnect-from-repo-1.aria.yml +++ b/e2e-tests/snapshots/github.spec.ts_disconnect-from-repo-1.aria.yml @@ -1,5 +1,4 @@ -- button "Set up your GitHub repo": - - img +- button "Set up your GitHub repo" - button "Create new repo" - button "Connect to existing repo" - text: Repository Name diff --git a/package-lock.json b/package-lock.json index 168c2e9..0a687d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dyad", - "version": "0.11.1", + "version": "0.13.0-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dyad", - "version": "0.11.1", + "version": "0.13.0-beta.1", "license": "MIT", "dependencies": { "@ai-sdk/anthropic": "^1.2.8", @@ -38,6 +38,7 @@ "@tanstack/react-query": "^5.75.5", "@tanstack/react-router": "^1.114.34", "@types/uuid": "^10.0.0", + "@vercel/sdk": "^1.10.0", "@vitejs/plugin-react": "^4.3.4", "ai": "^4.3.4", "better-sqlite3": "^11.9.1", @@ -6588,6 +6589,23 @@ "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "license": "ISC" }, + "node_modules/@vercel/sdk": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@vercel/sdk/-/sdk-1.10.0.tgz", + "integrity": "sha512-Z3bTFhDkQoEt2wviWxbvmkrkTPxVCYZaRlkV2Y3O/oRwVRnYiZ1tAK7NkjnSNtc19vARRkEAma/DfiaqVMlPzQ==", + "bin": { + "mcp": "bin/mcp-server.js" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": ">=1.5.0 <1.10.0", + "zod": "^3" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, "node_modules/@vitejs/plugin-react": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.4.1.tgz", diff --git a/package.json b/package.json index b6ecfa0..b37ea8a 100644 --- a/package.json +++ b/package.json @@ -111,6 +111,7 @@ "@tanstack/react-query": "^5.75.5", "@tanstack/react-router": "^1.114.34", "@types/uuid": "^10.0.0", + "@vercel/sdk": "^1.10.0", "@vitejs/plugin-react": "^4.3.4", "ai": "^4.3.4", "better-sqlite3": "^11.9.1", diff --git a/src/app/TitleBar.tsx b/src/app/TitleBar.tsx index 3a6487a..e316806 100644 --- a/src/app/TitleBar.tsx +++ b/src/app/TitleBar.tsx @@ -131,7 +131,7 @@ function WindowsControls() { return (
@@ -879,7 +875,11 @@ function UnconnectedGitHubConnector({ ); } -export function GitHubConnector({ appId, folderName }: GitHubConnectorProps) { +export function GitHubConnector({ + appId, + folderName, + expanded, +}: GitHubConnectorProps) { const { app, refreshApp } = useLoadApp(appId); const { settings, refreshSettings } = useSettings(); @@ -899,6 +899,7 @@ export function GitHubConnector({ appId, folderName }: GitHubConnectorProps) { settings={settings} refreshSettings={refreshSettings} refreshApp={refreshApp} + expanded={expanded} /> ); } diff --git a/src/components/VercelConnector.tsx b/src/components/VercelConnector.tsx new file mode 100644 index 0000000..7c8caf9 --- /dev/null +++ b/src/components/VercelConnector.tsx @@ -0,0 +1,680 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { Button } from "@/components/ui/button"; +import { Globe } from "lucide-react"; +import { IpcClient } from "@/ipc/ipc_client"; +import { useSettings } from "@/hooks/useSettings"; +import { useLoadApp } from "@/hooks/useLoadApp"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import {} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { App, VercelDeployment } from "@/ipc/ipc_types"; + +interface VercelConnectorProps { + appId: number | null; + folderName: string; +} + +interface VercelProject { + id: string; + name: string; + framework: string | null; +} + +interface ConnectedVercelConnectorProps { + appId: number; + app: App; + refreshApp: () => void; +} + +interface UnconnectedVercelConnectorProps { + appId: number | null; + folderName: string; + settings: any; + refreshSettings: () => void; + refreshApp: () => void; +} + +function ConnectedVercelConnector({ + appId, + app, + refreshApp, +}: ConnectedVercelConnectorProps) { + const [isLoadingDeployments, setIsLoadingDeployments] = useState(false); + const [deploymentsError, setDeploymentsError] = useState(null); + const [deployments, setDeployments] = useState([]); + const [isDisconnecting, setIsDisconnecting] = useState(false); + const [disconnectError, setDisconnectError] = useState(null); + + const handleDisconnectProject = async () => { + setIsDisconnecting(true); + setDisconnectError(null); + try { + await IpcClient.getInstance().disconnectVercelProject({ appId }); + refreshApp(); + } catch (err: any) { + setDisconnectError(err.message || "Failed to disconnect project."); + } finally { + setIsDisconnecting(false); + } + }; + + const handleGetDeployments = async () => { + setIsLoadingDeployments(true); + setDeploymentsError(null); + + try { + const result = await IpcClient.getInstance().getVercelDeployments({ + appId, + }); + setDeployments(result); + } catch (err: any) { + setDeploymentsError( + err.message || "Failed to get deployments from Vercel.", + ); + } finally { + setIsLoadingDeployments(false); + } + }; + + return ( +
+

+ Connected to Vercel Project: +

+
{ + e.preventDefault(); + IpcClient.getInstance().openExternalUrl( + `https://vercel.com/${app.vercelTeamSlug}/${app.vercelProjectName}`, + ); + }} + className="cursor-pointer text-blue-600 hover:underline dark:text-blue-400" + target="_blank" + rel="noopener noreferrer" + > + {app.vercelProjectName} + + {app.vercelDeploymentUrl && ( +
+

+ Live URL:{" "} + { + e.preventDefault(); + if (app.vercelDeploymentUrl) { + IpcClient.getInstance().openExternalUrl( + app.vercelDeploymentUrl, + ); + } + }} + className="cursor-pointer text-blue-600 hover:underline dark:text-blue-400 font-mono" + target="_blank" + rel="noopener noreferrer" + > + {app.vercelDeploymentUrl} + +

+
+ )} +
+ + +
+ {deploymentsError && ( +
+

{deploymentsError}

+
+ )} + {deployments.length > 0 && ( +
+

Recent Deployments:

+
+ {deployments.map((deployment) => ( +
+ +
+ ))} +
+
+ )} + {disconnectError && ( +

{disconnectError}

+ )} +
+ ); +} + +function UnconnectedVercelConnector({ + appId, + folderName, + settings, + refreshSettings, + refreshApp, +}: UnconnectedVercelConnectorProps) { + // --- Manual Token Entry State --- + const [accessToken, setAccessToken] = useState(""); + const [isSavingToken, setIsSavingToken] = useState(false); + const [tokenError, setTokenError] = useState(null); + const [tokenSuccess, setTokenSuccess] = useState(false); + + // --- Project Setup State --- + const [projectSetupMode, setProjectSetupMode] = useState< + "create" | "existing" + >("create"); + const [availableProjects, setAvailableProjects] = useState( + [], + ); + const [isLoadingProjects, setIsLoadingProjects] = useState(false); + const [selectedProject, setSelectedProject] = useState(""); + + // Create new project state + const [projectName, setProjectName] = useState(folderName); + const [projectAvailable, setProjectAvailable] = useState( + null, + ); + const [projectCheckError, setProjectCheckError] = useState( + null, + ); + const [isCheckingProject, setIsCheckingProject] = useState(false); + const [isCreatingProject, setIsCreatingProject] = useState(false); + const [createProjectError, setCreateProjectError] = useState( + null, + ); + const [createProjectSuccess, setCreateProjectSuccess] = + useState(false); + + const debounceTimeoutRef = useRef(null); + + // Load available projects when Vercel is connected + useEffect(() => { + if (settings?.vercelAccessToken && projectSetupMode === "existing") { + loadAvailableProjects(); + } + }, [settings?.vercelAccessToken, projectSetupMode]); + + // Cleanup debounce timer on unmount + useEffect(() => { + return () => { + if (debounceTimeoutRef.current) { + clearTimeout(debounceTimeoutRef.current); + } + }; + }, []); + + const loadAvailableProjects = async () => { + setIsLoadingProjects(true); + try { + const projects = await IpcClient.getInstance().listVercelProjects(); + setAvailableProjects(projects); + } catch (error) { + console.error("Failed to load Vercel projects:", error); + } finally { + setIsLoadingProjects(false); + } + }; + + const handleSaveAccessToken = async (e: React.FormEvent) => { + e.preventDefault(); + if (!accessToken.trim()) return; + + setIsSavingToken(true); + setTokenError(null); + setTokenSuccess(false); + + try { + await IpcClient.getInstance().saveVercelAccessToken({ + token: accessToken.trim(), + }); + setTokenSuccess(true); + setAccessToken(""); + refreshSettings(); + } catch (err: any) { + setTokenError(err.message || "Failed to save access token."); + } finally { + setIsSavingToken(false); + } + }; + + const checkProjectAvailability = useCallback(async (name: string) => { + setProjectCheckError(null); + setProjectAvailable(null); + if (!name) return; + setIsCheckingProject(true); + try { + const result = await IpcClient.getInstance().isVercelProjectAvailable({ + name, + }); + setProjectAvailable(result.available); + if (!result.available) { + setProjectCheckError(result.error || "Project name is not available."); + } + } catch (err: any) { + setProjectCheckError( + err.message || "Failed to check project availability.", + ); + } finally { + setIsCheckingProject(false); + } + }, []); + + const debouncedCheckProjectAvailability = useCallback( + (name: string) => { + if (debounceTimeoutRef.current) { + clearTimeout(debounceTimeoutRef.current); + } + debounceTimeoutRef.current = setTimeout(() => { + checkProjectAvailability(name); + }, 500); + }, + [checkProjectAvailability], + ); + + const handleSetupProject = async (e: React.FormEvent) => { + e.preventDefault(); + if (!appId) return; + + setCreateProjectError(null); + setIsCreatingProject(true); + setCreateProjectSuccess(false); + + try { + if (projectSetupMode === "create") { + await IpcClient.getInstance().createVercelProject({ + name: projectName, + appId, + }); + } else { + await IpcClient.getInstance().connectToExistingVercelProject({ + projectId: selectedProject, + appId, + }); + } + setCreateProjectSuccess(true); + setProjectCheckError(null); + refreshApp(); + } catch (err: any) { + setCreateProjectError( + err.message || + `Failed to ${projectSetupMode === "create" ? "create" : "connect to"} project.`, + ); + } finally { + setIsCreatingProject(false); + } + }; + + if (!settings?.vercelAccessToken) { + return ( +
+
+
+

Connect to Vercel

+
+ +
+
+

+ To connect your app to Vercel, you'll need to create an access + token: +

+
    +
  1. If you don't have a Vercel account, sign up first
  2. +
  3. Go to Vercel settings to create a token
  4. +
  5. Copy the token and paste it below
  6. +
+ +
+ + +
+
+ +
+
+ + setAccessToken(e.target.value)} + disabled={isSavingToken} + className="w-full" + /> +
+ + +
+ + {tokenError && ( +
+

+ {tokenError} +

+
+ )} + + {tokenSuccess && ( +
+

+ Successfully connected to Vercel! You can now set up your + project below. +

+
+ )} +
+
+
+ ); + } + + return ( +
+ {/* Collapsible Header */} +
Set up your Vercel project
+ + {/* Collapsible Content */} +
+
+ {/* Mode Selection */} +
+
+ + +
+
+ +
+ {projectSetupMode === "create" ? ( + <> +
+ + { + const newValue = e.target.value; + setProjectName(newValue); + setProjectAvailable(null); + setProjectCheckError(null); + debouncedCheckProjectAvailability(newValue); + }} + disabled={isCreatingProject} + /> + {isCheckingProject && ( +

+ Checking availability... +

+ )} + {projectAvailable === true && ( +

+ Project name is available! +

+ )} + {projectAvailable === false && ( +

+ {projectCheckError} +

+ )} +
+ + ) : ( + <> +
+ + +
+ + )} + + +
+ + {createProjectError && ( +

{createProjectError}

+ )} + {createProjectSuccess && ( +

+ {projectSetupMode === "create" + ? "Project created and linked!" + : "Connected to project!"} +

+ )} +
+
+
+ ); +} + +export function VercelConnector({ appId, folderName }: VercelConnectorProps) { + const { app, refreshApp } = useLoadApp(appId); + const { settings, refreshSettings } = useSettings(); + + if (app?.vercelProjectId && appId) { + return ( + + ); + } else { + return ( + + ); + } +} diff --git a/src/components/VercelIntegration.tsx b/src/components/VercelIntegration.tsx new file mode 100644 index 0000000..e4bad9a --- /dev/null +++ b/src/components/VercelIntegration.tsx @@ -0,0 +1,61 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { useSettings } from "@/hooks/useSettings"; +import { showSuccess, showError } from "@/lib/toast"; + +export function VercelIntegration() { + const { settings, updateSettings } = useSettings(); + const [isDisconnecting, setIsDisconnecting] = useState(false); + + const handleDisconnectFromVercel = async () => { + setIsDisconnecting(true); + try { + const result = await updateSettings({ + vercelAccessToken: undefined, + }); + if (result) { + showSuccess("Successfully disconnected from Vercel"); + } else { + showError("Failed to disconnect from Vercel"); + } + } catch (err: any) { + showError( + err.message || "An error occurred while disconnecting from Vercel", + ); + } finally { + setIsDisconnecting(false); + } + }; + + const isConnected = !!settings?.vercelAccessToken; + + if (!isConnected) { + return null; + } + + return ( +
+
+

+ Vercel Integration +

+

+ Your account is connected to Vercel. +

+
+ + +
+ ); +} diff --git a/src/components/preview_panel/PreviewHeader.tsx b/src/components/preview_panel/PreviewHeader.tsx index f7aed4a..81c5690 100644 --- a/src/components/preview_panel/PreviewHeader.tsx +++ b/src/components/preview_panel/PreviewHeader.tsx @@ -10,6 +10,7 @@ import { Trash2, AlertTriangle, Wrench, + Globe, } from "lucide-react"; import { motion } from "framer-motion"; import { useEffect, useRef, useState, useCallback } from "react"; @@ -32,7 +33,12 @@ import { useMutation } from "@tanstack/react-query"; import { useCheckProblems } from "@/hooks/useCheckProblems"; import { isPreviewOpenAtom } from "@/atoms/viewAtoms"; -export type PreviewMode = "preview" | "code" | "problems" | "configure"; +export type PreviewMode = + | "preview" + | "code" + | "problems" + | "configure" + | "publish"; const BUTTON_CLASS_NAME = "no-app-region-drag cursor-pointer relative flex items-center gap-1 px-2 py-1 rounded-md text-[13px] font-medium z-10 hover:bg-[var(--background)]"; @@ -46,12 +52,13 @@ export const PreviewHeader = () => { const codeRef = useRef(null); const problemsRef = useRef(null); const configureRef = useRef(null); + const publishRef = useRef(null); const [indicatorStyle, setIndicatorStyle] = useState({ left: 0, width: 0 }); const [windowWidth, setWindowWidth] = useState(window.innerWidth); const { problemReport } = useCheckProblems(selectedAppId); const { restartApp, refreshAppIframe } = useRunApp(); - const isCompact = windowWidth < 840; + const isCompact = windowWidth < 860; // Track window width useEffect(() => { @@ -128,6 +135,9 @@ export const PreviewHeader = () => { case "configure": targetRef = configureRef; break; + case "publish": + targetRef = publishRef; + break; default: return; } @@ -239,6 +249,13 @@ export const PreviewHeader = () => { "Configure", "configure-mode-button", )} + {renderButton( + "publish", + publishRef, + , + "Publish", + "publish-mode-button", + )}
diff --git a/src/components/preview_panel/PreviewPanel.tsx b/src/components/preview_panel/PreviewPanel.tsx index 0403f38..42297f8 100644 --- a/src/components/preview_panel/PreviewPanel.tsx +++ b/src/components/preview_panel/PreviewPanel.tsx @@ -15,6 +15,7 @@ import { useEffect, useRef, useState } from "react"; import { PanelGroup, Panel, PanelResizeHandle } from "react-resizable-panels"; import { Console } from "./Console"; import { useRunApp } from "@/hooks/useRunApp"; +import { PublishPanel } from "./PublishPanel"; interface ConsoleHeaderProps { isOpen: boolean; @@ -116,6 +117,8 @@ export function PreviewPanel() { ) : previewMode === "configure" ? ( + ) : previewMode === "publish" ? ( + ) : ( )} diff --git a/src/components/preview_panel/PublishPanel.tsx b/src/components/preview_panel/PublishPanel.tsx new file mode 100644 index 0000000..38e6398 --- /dev/null +++ b/src/components/preview_panel/PublishPanel.tsx @@ -0,0 +1,169 @@ +import { useAtomValue } from "jotai"; +import { selectedAppIdAtom } from "@/atoms/appAtoms"; +import { useLoadApp } from "@/hooks/useLoadApp"; +import { GitHubConnector } from "@/components/GitHubConnector"; +import { VercelConnector } from "@/components/VercelConnector"; +import { IpcClient } from "@/ipc/ipc_client"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +export const PublishPanel = () => { + const selectedAppId = useAtomValue(selectedAppIdAtom); + const { app, loading } = useLoadApp(selectedAppId); + + if (loading) { + return ( +
+
+ + + + +
+

+ Loading... +

+
+ ); + } + + if (!selectedAppId || !app) { + return ( +
+
+ + + +
+

+ No App Selected +

+

+ Select an app to view publishing options. +

+
+ ); + } + + return ( +
+
+
+

+ Publish App +

+
+ + {/* GitHub Section */} + + + + + + + GitHub + + + +

+ Sync your code to GitHub for collaboration. +

+ +
+
+ + {/* Vercel Section */} + + + + + + + +

+ Publish your app by deploying it to Vercel. +

+ + {!app?.githubOrg || !app?.githubRepo ? ( +
+
+ + + +
+

+ GitHub Required for Vercel Deployment +

+

+ Deploying to Vercel requires connecting to GitHub first. + Please set up your GitHub repository above. +

+
+
+
+ ) : ( + + )} +
+
+
+
+ ); +}; diff --git a/src/db/schema.ts b/src/db/schema.ts index 63affa3..4fae2f3 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -16,6 +16,10 @@ export const apps = sqliteTable("apps", { githubRepo: text("github_repo"), githubBranch: text("github_branch"), supabaseProjectId: text("supabase_project_id"), + vercelProjectId: text("vercel_project_id"), + vercelProjectName: text("vercel_project_name"), + vercelTeamId: text("vercel_team_id"), + vercelDeploymentUrl: text("vercel_deployment_url"), chatContext: text("chat_context", { mode: "json" }), }); diff --git a/src/ipc/handlers/app_handlers.ts b/src/ipc/handlers/app_handlers.ts index a6b3be6..7278f52 100644 --- a/src/ipc/handlers/app_handlers.ts +++ b/src/ipc/handlers/app_handlers.ts @@ -46,6 +46,7 @@ import { gitCommit } from "../utils/git_utils"; import { safeSend } from "../utils/safe_sender"; import { normalizePath } from "../../../shared/normalizePath"; import { isServerFunction } from "@/supabase_admin/supabase_utils"; +import { getVercelTeamSlug } from "../utils/vercel_utils"; async function copyDir( source: string, @@ -370,10 +371,16 @@ export function registerAppHandlers() { supabaseProjectName = await getSupabaseProjectName(app.supabaseProjectId); } + let vercelTeamSlug: string | null = null; + if (app.vercelTeamId) { + vercelTeamSlug = await getVercelTeamSlug(app.vercelTeamId); + } + return { ...app, files, supabaseProjectName, + vercelTeamSlug, }; }); diff --git a/src/ipc/handlers/vercel_handlers.ts b/src/ipc/handlers/vercel_handlers.ts new file mode 100644 index 0000000..ec0fe7c --- /dev/null +++ b/src/ipc/handlers/vercel_handlers.ts @@ -0,0 +1,524 @@ +import { ipcMain, IpcMainInvokeEvent } from "electron"; +import { Vercel } from "@vercel/sdk"; +import { writeSettings, readSettings } from "../../main/settings"; +import * as schema from "../../db/schema"; +import { db } from "../../db"; +import { apps } from "../../db/schema"; +import { eq } from "drizzle-orm"; +import log from "electron-log"; +import { IS_TEST_BUILD } from "../utils/test_utils"; +import * as fs from "fs"; +import * as path from "path"; +import { CreateProjectFramework } from "@vercel/sdk/models/createprojectop.js"; +import { getDyadAppPath } from "@/paths/paths"; +import { + CreateVercelProjectParams, + IsVercelProjectAvailableParams, + SaveVercelAccessTokenParams, + VercelProject, +} from "../ipc_types"; +import { ConnectToExistingVercelProjectParams } from "../ipc_types"; +import { GetVercelDeploymentsParams } from "../ipc_types"; +import { DisconnectVercelProjectParams } from "../ipc_types"; +import { createLoggedHandler } from "./safe_handle"; + +const logger = log.scope("vercel_handlers"); +const handle = createLoggedHandler(logger); + +// Use test server URLs when in test mode +const TEST_SERVER_BASE = "http://localhost:3500"; + +const VERCEL_API_BASE = IS_TEST_BUILD + ? `${TEST_SERVER_BASE}/vercel/api` + : "https://api.vercel.com"; + +// --- Helper Functions --- + +function createVercelClient(token: string): Vercel { + return new Vercel({ + bearerToken: token, + ...(IS_TEST_BUILD && { serverURL: VERCEL_API_BASE }), + }); +} + +async function validateVercelToken(token: string): Promise { + try { + const vercel = createVercelClient(token); + await vercel.user.getAuthUser(); + return true; + } catch (error) { + logger.error("Error validating Vercel token:", error); + return false; + } +} + +async function getDefaultTeamId(token: string): Promise { + try { + const response = await fetch(`${VERCEL_API_BASE}/v2/teams?limit=1`, { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch teams: ${response.status} ${response.statusText}`, + ); + } + + const data = await response.json(); + + // Use the first team (typically the personal account or default team) + if (data.teams && data.teams.length > 0) { + return data.teams[0].id; + } + + throw new Error("No teams found for this user"); + } catch (error) { + logger.error("Error getting default team ID:", error); + throw new Error("Failed to get team information"); + } +} + +async function detectFramework( + appPath: string, +): Promise { + try { + // Check for specific config files first + const configFiles: Array<{ + file: string; + framework: CreateProjectFramework; + }> = [ + { file: "next.config.js", framework: "nextjs" }, + { file: "next.config.mjs", framework: "nextjs" }, + { file: "next.config.ts", framework: "nextjs" }, + { file: "vite.config.js", framework: "vite" }, + { file: "vite.config.ts", framework: "vite" }, + { file: "vite.config.mjs", framework: "vite" }, + { file: "nuxt.config.js", framework: "nuxtjs" }, + { file: "nuxt.config.ts", framework: "nuxtjs" }, + { file: "astro.config.js", framework: "astro" }, + { file: "astro.config.mjs", framework: "astro" }, + { file: "astro.config.ts", framework: "astro" }, + { file: "svelte.config.js", framework: "svelte" }, + ]; + + for (const { file, framework } of configFiles) { + if (fs.existsSync(path.join(appPath, file))) { + return framework; + } + } + + // Check package.json for dependencies + const packageJsonPath = path.join(appPath, "package.json"); + if (fs.existsSync(packageJsonPath)) { + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + const dependencies = { + ...packageJson.dependencies, + ...packageJson.devDependencies, + }; + + // Check for framework dependencies in order of preference + if (dependencies.next) return "nextjs"; + if (dependencies.vite) return "vite"; + if (dependencies.nuxt) return "nuxtjs"; + if (dependencies.astro) return "astro"; + if (dependencies.svelte) return "svelte"; + if (dependencies["@angular/core"]) return "angular"; + if (dependencies.vue) return "vue"; + if (dependencies["react-scripts"]) return "create-react-app"; + if (dependencies.gatsby) return "gatsby"; + if (dependencies.remix) return "remix"; + } + + // Default fallback + return undefined; + } catch (error) { + logger.error("Error detecting framework:", error); + return undefined; + } +} + +// --- IPC Handlers --- + +async function handleSaveVercelToken( + event: IpcMainInvokeEvent, + { token }: SaveVercelAccessTokenParams, +): Promise { + logger.debug("Saving Vercel access token"); + + if (!token || token.trim() === "") { + throw new Error("Access token is required."); + } + + try { + // Validate the token by making a test API call + const isValid = await validateVercelToken(token.trim()); + if (!isValid) { + throw new Error( + "Invalid access token. Please check your token and try again.", + ); + } + + writeSettings({ + vercelAccessToken: { + value: token.trim(), + }, + }); + + logger.log("Successfully saved Vercel access token."); + } catch (error: any) { + logger.error("Error saving Vercel token:", error); + throw new Error(`Failed to save access token: ${error.message}`); + } +} + +// --- Vercel List Projects Handler --- +async function handleListVercelProjects(): Promise { + try { + const settings = readSettings(); + const accessToken = settings.vercelAccessToken?.value; + if (!accessToken) { + throw new Error("Not authenticated with Vercel."); + } + + const vercel = createVercelClient(accessToken); + const response = await vercel.projects.getProjects({}); + + if (!response.projects) { + throw new Error("Failed to retrieve projects from Vercel."); + } + + return response.projects.map((project) => ({ + id: project.id, + name: project.name, + framework: project.framework || null, + })); + } catch (err: any) { + logger.error("[Vercel Handler] Failed to list projects:", err); + throw new Error(err.message || "Failed to list Vercel projects."); + } +} + +// --- Vercel Project Availability Handler --- +async function handleIsProjectAvailable( + event: IpcMainInvokeEvent, + { name }: IsVercelProjectAvailableParams, +): Promise<{ available: boolean; error?: string }> { + try { + const settings = readSettings(); + const accessToken = settings.vercelAccessToken?.value; + if (!accessToken) { + return { available: false, error: "Not authenticated with Vercel." }; + } + + const vercel = createVercelClient(accessToken); + + // Check if project name is available by searching for projects with that name + const response = await vercel.projects.getProjects({ + search: name, + }); + + if (!response.projects) { + return { + available: false, + error: "Failed to check project availability.", + }; + } + + const projectExists = response.projects.some( + (project) => project.name === name, + ); + + return { + available: !projectExists, + error: projectExists ? "Project name is not available." : undefined, + }; + } catch (err: any) { + return { available: false, error: err.message || "Unknown error" }; + } +} + +// --- Vercel Create Project Handler --- +async function handleCreateProject( + event: IpcMainInvokeEvent, + { name, appId }: CreateVercelProjectParams, +): Promise { + const settings = readSettings(); + const accessToken = settings.vercelAccessToken?.value; + if (!accessToken) { + throw new Error("Not authenticated with Vercel."); + } + + try { + logger.info(`Creating Vercel project: ${name} for app ${appId}`); + + // Get app details to determine the framework + const app = await db.query.apps.findFirst({ where: eq(apps.id, appId) }); + if (!app) { + throw new Error("App not found."); + } + + // Check if app has GitHub repository configured + if (!app.githubOrg || !app.githubRepo) { + throw new Error( + "App must be connected to a GitHub repository before creating a Vercel project.", + ); + } + + // Detect the framework from the app's directory + const detectedFramework = await detectFramework(getDyadAppPath(app.path)); + + logger.info( + `Detected framework: ${detectedFramework || "none detected"} for app at ${app.path}`, + ); + + const vercel = createVercelClient(accessToken); + + const projectData = await vercel.projects.createProject({ + requestBody: { + name: name, + gitRepository: { + type: "github", + repo: `${app.githubOrg}/${app.githubRepo}`, + }, + framework: detectedFramework, + }, + }); + + if (!projectData.id) { + throw new Error("Failed to create project: No project ID returned."); + } + + // Get the default team ID + const teamId = await getDefaultTeamId(accessToken); + + // Store project info in the app's DB row + await updateAppVercelProject({ + appId, + projectId: projectData.id, + projectName: projectData.name, + teamId: teamId, + deploymentUrl: null, // Will be set after first deployment + }); + + logger.info( + `Successfully created Vercel project: ${projectData.id} with GitHub repo: ${app.githubOrg}/${app.githubRepo}`, + ); + + // Trigger the first deployment + logger.info(`Triggering first deployment for project: ${projectData.id}`); + try { + // Create deployment via Vercel SDK using the project settings we just created + const deploymentData = await vercel.deployments.createDeployment({ + requestBody: { + name: projectData.name, + project: projectData.id, + target: "production", + gitSource: { + type: "github", + org: app.githubOrg, + repo: app.githubRepo, + ref: app.githubBranch || "main", + }, + // projectSettings: { + // framework: "vite", + // }, + }, + }); + + if (deploymentData.url) { + // Update deployment URL in the database + const deploymentUrl = `https://${deploymentData.url}`; + await db + .update(apps) + .set({ vercelDeploymentUrl: deploymentUrl }) + .where(eq(apps.id, appId)); + + logger.info(`First deployment successful: ${deploymentUrl}`); + } else { + logger.warn("First deployment failed: No deployment URL returned"); + } + } catch (deployError: any) { + logger.warn(`First deployment failed with error: ${deployError.message}`); + // Don't throw here - project creation was successful, deployment failure is non-critical + } + } catch (err: any) { + logger.error("[Vercel Handler] Failed to create project:", err); + throw new Error(err.message || "Failed to create Vercel project."); + } +} + +// --- Vercel Connect to Existing Project Handler --- +async function handleConnectToExistingProject( + event: IpcMainInvokeEvent, + { projectId, appId }: ConnectToExistingVercelProjectParams, +): Promise { + try { + const settings = readSettings(); + const accessToken = settings.vercelAccessToken?.value; + if (!accessToken) { + throw new Error("Not authenticated with Vercel."); + } + + logger.info( + `Connecting to existing Vercel project: ${projectId} for app ${appId}`, + ); + + const vercel = createVercelClient(accessToken); + + // Verify the project exists and get its details + const response = await vercel.projects.getProjects({}); + const projectData = response.projects?.find( + (p) => p.id === projectId || p.name === projectId, + ); + + if (!projectData) { + throw new Error("Project not found. Please check the project ID."); + } + + // Get the default team ID + const teamId = await getDefaultTeamId(accessToken); + + // Store project info in the app's DB row + await updateAppVercelProject({ + appId, + projectId: projectData.id, + projectName: projectData.name, + teamId: teamId, + deploymentUrl: projectData.targets?.production?.url + ? `https://${projectData.targets.production.url}` + : null, + }); + + logger.info(`Successfully connected to Vercel project: ${projectData.id}`); + } catch (err: any) { + logger.error( + "[Vercel Handler] Failed to connect to existing project:", + err, + ); + throw new Error(err.message || "Failed to connect to existing project."); + } +} + +// --- Vercel Get Deployments Handler --- +async function handleGetVercelDeployments( + event: IpcMainInvokeEvent, + { appId }: GetVercelDeploymentsParams, +): Promise< + { + uid: string; + url: string; + state: string; + createdAt: number; + target: string; + readyState: string; + }[] +> { + try { + const settings = readSettings(); + const accessToken = settings.vercelAccessToken?.value; + if (!accessToken) { + throw new Error("Not authenticated with Vercel."); + } + + const app = await db.query.apps.findFirst({ where: eq(apps.id, appId) }); + if (!app || !app.vercelProjectId) { + throw new Error("App is not linked to a Vercel project."); + } + + logger.info( + `Getting deployments for Vercel project: ${app.vercelProjectId} for app ${appId}`, + ); + + const vercel = createVercelClient(accessToken); + + // Get deployments for the project + const deploymentsResponse = await vercel.deployments.getDeployments({ + projectId: app.vercelProjectId, + limit: 3, // Get last 3 deployments + }); + + if (!deploymentsResponse.deployments) { + throw new Error("Failed to retrieve deployments from Vercel."); + } + + // Map deployments to our interface format + return deploymentsResponse.deployments.map((deployment) => ({ + uid: deployment.uid, + url: deployment.url, + state: deployment.state || "unknown", + createdAt: deployment.createdAt || 0, + target: deployment.target || "production", + readyState: deployment.readyState || "unknown", + })); + } catch (err: any) { + logger.error("[Vercel Handler] Failed to get deployments:", err); + throw new Error(err.message || "Failed to get Vercel deployments."); + } +} + +async function handleDisconnectVercelProject( + event: IpcMainInvokeEvent, + { appId }: DisconnectVercelProjectParams, +): Promise { + logger.log(`Disconnecting Vercel project for appId: ${appId}`); + + const app = await db.query.apps.findFirst({ + where: eq(apps.id, appId), + }); + + if (!app) { + throw new Error("App not found"); + } + + // Update app in database to remove Vercel project info + await db + .update(apps) + .set({ + vercelProjectId: null, + vercelProjectName: null, + vercelTeamId: null, + vercelDeploymentUrl: null, + }) + .where(eq(apps.id, appId)); +} + +// --- Registration --- +export function registerVercelHandlers() { + // DO NOT LOG this handler because tokens are sensitive + ipcMain.handle("vercel:save-token", handleSaveVercelToken); + + // Logged handlers + handle("vercel:list-projects", handleListVercelProjects); + handle("vercel:is-project-available", handleIsProjectAvailable); + handle("vercel:create-project", handleCreateProject); + handle("vercel:connect-existing-project", handleConnectToExistingProject); + handle("vercel:get-deployments", handleGetVercelDeployments); + handle("vercel:disconnect", handleDisconnectVercelProject); +} + +export async function updateAppVercelProject({ + appId, + projectId, + projectName, + teamId, + deploymentUrl, +}: { + appId: number; + projectId: string; + projectName: string; + teamId: string; + deploymentUrl?: string | null; +}): Promise { + await db + .update(schema.apps) + .set({ + vercelProjectId: projectId, + vercelProjectName: projectName, + vercelTeamId: teamId, + vercelDeploymentUrl: deploymentUrl, + }) + .where(eq(schema.apps.id, appId)); +} diff --git a/src/ipc/ipc_client.ts b/src/ipc/ipc_client.ts index b2c34ff..ebfcf80 100644 --- a/src/ipc/ipc_client.ts +++ b/src/ipc/ipc_client.ts @@ -40,6 +40,15 @@ import type { EditAppFileReturnType, GetAppEnvVarsParams, SetAppEnvVarsParams, + ConnectToExistingVercelProjectParams, + IsVercelProjectAvailableResponse, + CreateVercelProjectParams, + VercelDeployment, + GetVercelDeploymentsParams, + DisconnectVercelProjectParams, + IsVercelProjectAvailableParams, + SaveVercelAccessTokenParams, + VercelProject, } from "./ipc_types"; import type { AppChatContext, ProposalResult } from "@/lib/schemas"; import { showError } from "@/lib/toast"; @@ -646,6 +655,51 @@ export class IpcClient { } // --- End GitHub Repo Management --- + // --- Vercel Token Management --- + public async saveVercelAccessToken( + params: SaveVercelAccessTokenParams, + ): Promise { + await this.ipcRenderer.invoke("vercel:save-token", params); + } + // --- End Vercel Token Management --- + + // --- Vercel Project Management --- + public async listVercelProjects(): Promise { + return this.ipcRenderer.invoke("vercel:list-projects", undefined); + } + + public async connectToExistingVercelProject( + params: ConnectToExistingVercelProjectParams, + ): Promise { + await this.ipcRenderer.invoke("vercel:connect-existing-project", params); + } + + public async isVercelProjectAvailable( + params: IsVercelProjectAvailableParams, + ): Promise { + return this.ipcRenderer.invoke("vercel:is-project-available", params); + } + + public async createVercelProject( + params: CreateVercelProjectParams, + ): Promise { + await this.ipcRenderer.invoke("vercel:create-project", params); + } + + // Get Vercel Deployments + public async getVercelDeployments( + params: GetVercelDeploymentsParams, + ): Promise { + return this.ipcRenderer.invoke("vercel:get-deployments", params); + } + + public async disconnectVercelProject( + params: DisconnectVercelProjectParams, + ): Promise { + await this.ipcRenderer.invoke("vercel:disconnect", params); + } + // --- End Vercel Project Management --- + // Get the main app version public async getAppVersion(): Promise { const result = await this.ipcRenderer.invoke("get-app-version"); diff --git a/src/ipc/ipc_host.ts b/src/ipc/ipc_host.ts index abb4904..50a3262 100644 --- a/src/ipc/ipc_host.ts +++ b/src/ipc/ipc_host.ts @@ -5,6 +5,7 @@ import { registerSettingsHandlers } from "./handlers/settings_handlers"; import { registerShellHandlers } from "./handlers/shell_handler"; import { registerDependencyHandlers } from "./handlers/dependency_handlers"; import { registerGithubHandlers } from "./handlers/github_handlers"; +import { registerVercelHandlers } from "./handlers/vercel_handlers"; import { registerNodeHandlers } from "./handlers/node_handlers"; import { registerProposalHandlers } from "./handlers/proposal_handlers"; import { registerDebugHandlers } from "./handlers/debug_handlers"; @@ -34,6 +35,7 @@ export function registerIpcHandlers() { registerShellHandlers(); registerDependencyHandlers(); registerGithubHandlers(); + registerVercelHandlers(); registerNodeHandlers(); registerProblemsHandlers(); registerProposalHandlers(); diff --git a/src/ipc/ipc_types.ts b/src/ipc/ipc_types.ts index 7f7066f..99cd9cf 100644 --- a/src/ipc/ipc_types.ts +++ b/src/ipc/ipc_types.ts @@ -81,6 +81,10 @@ export interface App { githubBranch: string | null; supabaseProjectId: string | null; supabaseProjectName: string | null; + vercelProjectId: string | null; + vercelProjectName: string | null; + vercelTeamSlug: string | null; + vercelDeploymentUrl: string | null; } export interface Version { @@ -266,3 +270,49 @@ export interface SetAppEnvVarsParams { export interface GetAppEnvVarsParams { appId: number; } + +export interface VercelDeployment { + uid: string; + url: string; + state: string; + createdAt: number; + target: string; + readyState: string; +} + +export interface ConnectToExistingVercelProjectParams { + projectId: string; + appId: number; +} + +export interface IsVercelProjectAvailableResponse { + available: boolean; + error?: string; +} + +export interface CreateVercelProjectParams { + name: string; + appId: number; +} + +export interface GetVercelDeploymentsParams { + appId: number; +} + +export interface DisconnectVercelProjectParams { + appId: number; +} + +export interface IsVercelProjectAvailableParams { + name: string; +} + +export interface SaveVercelAccessTokenParams { + token: string; +} + +export interface VercelProject { + id: string; + name: string; + framework: string | null; +} diff --git a/src/ipc/utils/vercel_utils.ts b/src/ipc/utils/vercel_utils.ts new file mode 100644 index 0000000..6f2ab42 --- /dev/null +++ b/src/ipc/utils/vercel_utils.ts @@ -0,0 +1,48 @@ +import { readSettings } from "../../main/settings"; +import log from "electron-log"; +import { IS_TEST_BUILD } from "./test_utils"; + +const logger = log.scope("vercel_utils"); + +// Use test server URLs when in test mode +const TEST_SERVER_BASE = "http://localhost:3500"; + +const VERCEL_API_BASE = IS_TEST_BUILD + ? `${TEST_SERVER_BASE}/vercel/api` + : "https://api.vercel.com"; + +export async function getVercelTeamSlug( + teamId: string, +): Promise { + try { + const settings = readSettings(); + const accessToken = settings.vercelAccessToken?.value; + + if (!accessToken) { + logger.warn("No Vercel access token found when trying to get team slug"); + return null; + } + + const response = await fetch(`${VERCEL_API_BASE}/v2/teams/${teamId}`, { + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + logger.error( + `Failed to fetch team details: ${response.status} ${response.statusText}`, + ); + return null; + } + + const data = await response.json(); + + // Return the team slug if available + return data.slug || null; + } catch (error) { + logger.error("Error getting Vercel team slug:", error); + return null; + } +} diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index 261a545..875e0a3 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -136,6 +136,7 @@ export const UserSettingsSchema = z.object({ providerSettings: z.record(z.string(), ProviderSettingSchema), githubUser: GithubUserSchema.optional(), githubAccessToken: SecretSchema.optional(), + vercelAccessToken: SecretSchema.optional(), supabase: SupabaseSchema.optional(), autoApproveChanges: z.boolean().optional(), telemetryConsent: z.enum(["opted_in", "opted_out", "unset"]).optional(), diff --git a/src/main/settings.ts b/src/main/settings.ts index 2f21208..3f940e0 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -74,6 +74,13 @@ export function readSettings(): UserSettings { encryptionType, }; } + if (combinedSettings.vercelAccessToken) { + const encryptionType = combinedSettings.vercelAccessToken.encryptionType; + combinedSettings.vercelAccessToken = { + value: decrypt(combinedSettings.vercelAccessToken), + encryptionType, + }; + } for (const provider in combinedSettings.providerSettings) { if (combinedSettings.providerSettings[provider].apiKey) { const encryptionType = @@ -105,6 +112,11 @@ export function writeSettings(settings: Partial): void { newSettings.githubAccessToken.value, ); } + if (newSettings.vercelAccessToken) { + newSettings.vercelAccessToken = encrypt( + newSettings.vercelAccessToken.value, + ); + } if (newSettings.supabase) { if (newSettings.supabase.accessToken) { newSettings.supabase.accessToken = encrypt( diff --git a/src/pages/app-details.tsx b/src/pages/app-details.tsx index c78b074..d2b3d8e 100644 --- a/src/pages/app-details.tsx +++ b/src/pages/app-details.tsx @@ -342,7 +342,9 @@ export default function AppDetailsPage() { Open in Chat - +
+ +
{appId && } {appId && } diff --git a/src/pages/settings.tsx b/src/pages/settings.tsx index 14b170d..1781123 100644 --- a/src/pages/settings.tsx +++ b/src/pages/settings.tsx @@ -14,6 +14,7 @@ import { Button } from "@/components/ui/button"; import { ArrowLeft } from "lucide-react"; import { useRouter } from "@tanstack/react-router"; import { GitHubIntegration } from "@/components/GitHubIntegration"; +import { VercelIntegration } from "@/components/VercelIntegration"; import { SupabaseIntegration } from "@/components/SupabaseIntegration"; import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; @@ -109,6 +110,7 @@ export default function SettingsPage() {
+
diff --git a/src/preload.ts b/src/preload.ts index ebb16f7..58073f8 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -55,6 +55,13 @@ const validInvokeChannels = [ "github:connect-existing-repo", "github:push", "github:disconnect", + "vercel:save-token", + "vercel:list-projects", + "vercel:is-project-available", + "vercel:create-project", + "vercel:connect-existing-project", + "vercel:get-deployments", + "vercel:disconnect", "get-app-version", "reload-env-path", "get-proposal",