Create Publish panel to easy GitHub and Vercel push (#655)
This commit is contained in:
4
drizzle/0008_medical_vulcan.sql
Normal file
4
drizzle/0008_medical_vulcan.sql
Normal file
@@ -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;
|
||||||
412
drizzle/meta/0008_snapshot.json
Normal file
412
drizzle/meta/0008_snapshot.json
Normal file
@@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,6 +57,13 @@
|
|||||||
"when": 1750186036000,
|
"when": 1750186036000,
|
||||||
"tag": "0007_dapper_overlord",
|
"tag": "0007_dapper_overlord",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 8,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1752625491756,
|
||||||
|
"tag": "0008_medical_vulcan",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
- button "Set up your GitHub repo":
|
- button "Set up your GitHub repo"
|
||||||
- img
|
|
||||||
- button "Create new repo"
|
- button "Create new repo"
|
||||||
- button "Connect to existing repo"
|
- button "Connect to existing repo"
|
||||||
- text: Repository Name
|
- text: Repository Name
|
||||||
|
|||||||
22
package-lock.json
generated
22
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "dyad",
|
"name": "dyad",
|
||||||
"version": "0.11.1",
|
"version": "0.13.0-beta.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "dyad",
|
"name": "dyad",
|
||||||
"version": "0.11.1",
|
"version": "0.13.0-beta.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "^1.2.8",
|
"@ai-sdk/anthropic": "^1.2.8",
|
||||||
@@ -38,6 +38,7 @@
|
|||||||
"@tanstack/react-query": "^5.75.5",
|
"@tanstack/react-query": "^5.75.5",
|
||||||
"@tanstack/react-router": "^1.114.34",
|
"@tanstack/react-router": "^1.114.34",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
|
"@vercel/sdk": "^1.10.0",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"ai": "^4.3.4",
|
"ai": "^4.3.4",
|
||||||
"better-sqlite3": "^11.9.1",
|
"better-sqlite3": "^11.9.1",
|
||||||
@@ -6588,6 +6589,23 @@
|
|||||||
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
|
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "4.4.1",
|
"version": "4.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.4.1.tgz",
|
||||||
|
|||||||
@@ -111,6 +111,7 @@
|
|||||||
"@tanstack/react-query": "^5.75.5",
|
"@tanstack/react-query": "^5.75.5",
|
||||||
"@tanstack/react-router": "^1.114.34",
|
"@tanstack/react-router": "^1.114.34",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
|
"@vercel/sdk": "^1.10.0",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"ai": "^4.3.4",
|
"ai": "^4.3.4",
|
||||||
"better-sqlite3": "^11.9.1",
|
"better-sqlite3": "^11.9.1",
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ function WindowsControls() {
|
|||||||
return (
|
return (
|
||||||
<div className="ml-auto flex no-app-region-drag">
|
<div className="ml-auto flex no-app-region-drag">
|
||||||
<button
|
<button
|
||||||
className="w-12 h-11 flex items-center justify-center hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
className="w-10 h-10 flex items-center justify-center hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||||
onClick={minimizeWindow}
|
onClick={minimizeWindow}
|
||||||
aria-label="Minimize"
|
aria-label="Minimize"
|
||||||
>
|
>
|
||||||
@@ -150,7 +150,7 @@ function WindowsControls() {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="w-12 h-11 flex items-center justify-center hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
className="w-10 h-10 flex items-center justify-center hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||||
onClick={maximizeWindow}
|
onClick={maximizeWindow}
|
||||||
aria-label="Maximize"
|
aria-label="Maximize"
|
||||||
>
|
>
|
||||||
@@ -171,7 +171,7 @@ function WindowsControls() {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="w-12 h-11 flex items-center justify-center hover:bg-red-500 transition-colors"
|
className="w-10 h-10 flex items-center justify-center hover:bg-red-500 transition-colors"
|
||||||
onClick={closeWindow}
|
onClick={closeWindow}
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export const appsListAtom = atom<App[]>([]);
|
|||||||
export const appBasePathAtom = atom<string>("");
|
export const appBasePathAtom = atom<string>("");
|
||||||
export const versionsListAtom = atom<Version[]>([]);
|
export const versionsListAtom = atom<Version[]>([]);
|
||||||
export const previewModeAtom = atom<
|
export const previewModeAtom = atom<
|
||||||
"preview" | "code" | "problems" | "configure"
|
"preview" | "code" | "problems" | "configure" | "publish"
|
||||||
>("preview");
|
>("preview");
|
||||||
export const selectedVersionIdAtom = atom<string | null>(null);
|
export const selectedVersionIdAtom = atom<string | null>(null);
|
||||||
export const appOutputAtom = atom<AppOutput[]>([]);
|
export const appOutputAtom = atom<AppOutput[]>([]);
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
Clipboard,
|
Clipboard,
|
||||||
Check,
|
Check,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { IpcClient } from "@/ipc/ipc_client";
|
import { IpcClient } from "@/ipc/ipc_client";
|
||||||
@@ -32,6 +31,7 @@ import { Label } from "@/components/ui/label";
|
|||||||
interface GitHubConnectorProps {
|
interface GitHubConnectorProps {
|
||||||
appId: number | null;
|
appId: number | null;
|
||||||
folderName: string;
|
folderName: string;
|
||||||
|
expanded?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GitHubRepo {
|
interface GitHubRepo {
|
||||||
@@ -57,6 +57,7 @@ interface UnconnectedGitHubConnectorProps {
|
|||||||
settings: any;
|
settings: any;
|
||||||
refreshSettings: () => void;
|
refreshSettings: () => void;
|
||||||
refreshApp: () => void;
|
refreshApp: () => void;
|
||||||
|
expanded?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ConnectedGitHubConnector({
|
function ConnectedGitHubConnector({
|
||||||
@@ -112,10 +113,7 @@ function ConnectedGitHubConnector({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="w-full" data-testid="github-connected-repo">
|
||||||
className="mt-4 w-full border border-gray-200 rounded-md p-4"
|
|
||||||
data-testid="github-connected-repo"
|
|
||||||
>
|
|
||||||
<p>Connected to GitHub Repo:</p>
|
<p>Connected to GitHub Repo:</p>
|
||||||
<a
|
<a
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -271,9 +269,10 @@ function UnconnectedGitHubConnector({
|
|||||||
settings,
|
settings,
|
||||||
refreshSettings,
|
refreshSettings,
|
||||||
refreshApp,
|
refreshApp,
|
||||||
|
expanded,
|
||||||
}: UnconnectedGitHubConnectorProps) {
|
}: UnconnectedGitHubConnectorProps) {
|
||||||
// --- Collapsible State ---
|
// --- Collapsible State ---
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(expanded || false);
|
||||||
|
|
||||||
// --- GitHub Device Flow State ---
|
// --- GitHub Device Flow State ---
|
||||||
const [githubUserCode, setGithubUserCode] = useState<string | null>(null);
|
const [githubUserCode, setGithubUserCode] = useState<string | null>(null);
|
||||||
@@ -636,22 +635,19 @@ function UnconnectedGitHubConnector({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="w-full" data-testid="github-setup-repo">
|
||||||
className="mt-4 w-full border border-gray-200 rounded-md"
|
|
||||||
data-testid="github-setup-repo"
|
|
||||||
>
|
|
||||||
{/* Collapsible Header */}
|
{/* Collapsible Header */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
onClick={!isExpanded ? () => setIsExpanded(true) : undefined}
|
||||||
className={`cursor-pointer w-full p-4 text-left transition-colors rounded-md flex items-center justify-between ${
|
className={`w-full p-4 text-left transition-colors rounded-md flex items-center justify-between ${
|
||||||
!isExpanded ? "hover:bg-gray-50 dark:hover:bg-gray-800/50" : ""
|
!isExpanded
|
||||||
|
? "cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
||||||
|
: ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="font-medium">Set up your GitHub repo</span>
|
<span className="font-medium">Set up your GitHub repo</span>
|
||||||
{isExpanded ? (
|
{isExpanded ? undefined : (
|
||||||
<ChevronDown className="h-4 w-4 text-gray-500" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="h-4 w-4 text-gray-500" />
|
<ChevronRight className="h-4 w-4 text-gray-500" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@@ -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 { app, refreshApp } = useLoadApp(appId);
|
||||||
const { settings, refreshSettings } = useSettings();
|
const { settings, refreshSettings } = useSettings();
|
||||||
|
|
||||||
@@ -899,6 +899,7 @@ export function GitHubConnector({ appId, folderName }: GitHubConnectorProps) {
|
|||||||
settings={settings}
|
settings={settings}
|
||||||
refreshSettings={refreshSettings}
|
refreshSettings={refreshSettings}
|
||||||
refreshApp={refreshApp}
|
refreshApp={refreshApp}
|
||||||
|
expanded={expanded}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
680
src/components/VercelConnector.tsx
Normal file
680
src/components/VercelConnector.tsx
Normal file
@@ -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<string | null>(null);
|
||||||
|
const [deployments, setDeployments] = useState<VercelDeployment[]>([]);
|
||||||
|
const [isDisconnecting, setIsDisconnecting] = useState(false);
|
||||||
|
const [disconnectError, setDisconnectError] = useState<string | null>(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 (
|
||||||
|
<div
|
||||||
|
className="mt-4 w-full rounded-md"
|
||||||
|
data-testid="vercel-connected-project"
|
||||||
|
>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Connected to Vercel Project:
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
onClick={(e) => {
|
||||||
|
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}
|
||||||
|
</a>
|
||||||
|
{app.vercelDeploymentUrl && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Live URL:{" "}
|
||||||
|
<a
|
||||||
|
onClick={(e) => {
|
||||||
|
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}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-2 flex gap-2">
|
||||||
|
<Button onClick={handleGetDeployments} disabled={isLoadingDeployments}>
|
||||||
|
{isLoadingDeployments ? (
|
||||||
|
<>
|
||||||
|
<svg
|
||||||
|
className="animate-spin h-5 w-5 mr-2 inline"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
style={{ display: "inline" }}
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
></circle>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
Getting Deployments...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Get Deployments"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleDisconnectProject}
|
||||||
|
disabled={isDisconnecting}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{isDisconnecting ? "Disconnecting..." : "Disconnect from project"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{deploymentsError && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<p className="text-red-600">{deploymentsError}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{deployments.length > 0 && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<h4 className="font-medium mb-2">Recent Deployments:</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{deployments.map((deployment) => (
|
||||||
|
<div
|
||||||
|
key={deployment.uid}
|
||||||
|
className="bg-gray-50 dark:bg-gray-800 rounded-md p-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||||
|
deployment.readyState === "READY"
|
||||||
|
? "bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-300"
|
||||||
|
: deployment.readyState === "BUILDING"
|
||||||
|
? "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300"
|
||||||
|
: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{deployment.readyState}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
{new Date(deployment.createdAt * 1000).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
IpcClient.getInstance().openExternalUrl(
|
||||||
|
`https://${deployment.url}`,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className="cursor-pointer text-blue-600 hover:underline dark:text-blue-400 text-sm"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<Globe className="h-4 w-4 inline mr-1" />
|
||||||
|
View
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{disconnectError && (
|
||||||
|
<p className="text-red-600 mt-2">{disconnectError}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string | null>(null);
|
||||||
|
const [tokenSuccess, setTokenSuccess] = useState(false);
|
||||||
|
|
||||||
|
// --- Project Setup State ---
|
||||||
|
const [projectSetupMode, setProjectSetupMode] = useState<
|
||||||
|
"create" | "existing"
|
||||||
|
>("create");
|
||||||
|
const [availableProjects, setAvailableProjects] = useState<VercelProject[]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
|
||||||
|
const [selectedProject, setSelectedProject] = useState<string>("");
|
||||||
|
|
||||||
|
// Create new project state
|
||||||
|
const [projectName, setProjectName] = useState(folderName);
|
||||||
|
const [projectAvailable, setProjectAvailable] = useState<boolean | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [projectCheckError, setProjectCheckError] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [isCheckingProject, setIsCheckingProject] = useState(false);
|
||||||
|
const [isCreatingProject, setIsCreatingProject] = useState(false);
|
||||||
|
const [createProjectError, setCreateProjectError] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [createProjectSuccess, setCreateProjectSuccess] =
|
||||||
|
useState<boolean>(false);
|
||||||
|
|
||||||
|
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(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 (
|
||||||
|
<div className="mt-1 w-full" data-testid="vercel-unconnected-project">
|
||||||
|
<div className="w-ful">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<h3 className="font-medium">Connect to Vercel</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-md p-3">
|
||||||
|
<p className="text-sm text-blue-800 dark:text-blue-200 mb-2">
|
||||||
|
To connect your app to Vercel, you'll need to create an access
|
||||||
|
token:
|
||||||
|
</p>
|
||||||
|
<ol className="list-decimal list-inside text-sm text-blue-700 dark:text-blue-300 space-y-1">
|
||||||
|
<li>If you don't have a Vercel account, sign up first</li>
|
||||||
|
<li>Go to Vercel settings to create a token</li>
|
||||||
|
<li>Copy the token and paste it below</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div className="flex gap-2 mt-3">
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
IpcClient.getInstance().openExternalUrl(
|
||||||
|
"https://vercel.com/signup",
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Sign Up for Vercel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
IpcClient.getInstance().openExternalUrl(
|
||||||
|
"https://vercel.com/account/settings/tokens",
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white"
|
||||||
|
>
|
||||||
|
Open Vercel Settings
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSaveAccessToken} className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label className="block text-sm font-medium mb-1">
|
||||||
|
Vercel Access Token
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter your Vercel access token"
|
||||||
|
value={accessToken}
|
||||||
|
onChange={(e) => setAccessToken(e.target.value)}
|
||||||
|
disabled={isSavingToken}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={!accessToken.trim() || isSavingToken}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{isSavingToken ? (
|
||||||
|
<>
|
||||||
|
<svg
|
||||||
|
className="animate-spin h-4 w-4 mr-2"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
></circle>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
Saving Token...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Save Access Token"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{tokenError && (
|
||||||
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md p-3">
|
||||||
|
<p className="text-sm text-red-800 dark:text-red-200">
|
||||||
|
{tokenError}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tokenSuccess && (
|
||||||
|
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-md p-3">
|
||||||
|
<p className="text-sm text-green-800 dark:text-green-200">
|
||||||
|
Successfully connected to Vercel! You can now set up your
|
||||||
|
project below.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-4 w-full rounded-md" data-testid="vercel-setup-project">
|
||||||
|
{/* Collapsible Header */}
|
||||||
|
<div className="font-medium mb-2">Set up your Vercel project</div>
|
||||||
|
|
||||||
|
{/* Collapsible Content */}
|
||||||
|
<div
|
||||||
|
className={`overflow-hidden transition-all duration-300 ease-in-out`}
|
||||||
|
>
|
||||||
|
<div className="pt-0 space-y-4">
|
||||||
|
{/* Mode Selection */}
|
||||||
|
<div>
|
||||||
|
<div className="flex rounded-md border border-gray-200 dark:border-gray-700">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={projectSetupMode === "create" ? "default" : "ghost"}
|
||||||
|
className={`flex-1 rounded-none rounded-l-md border-0 ${
|
||||||
|
projectSetupMode === "create"
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
setProjectSetupMode("create");
|
||||||
|
setCreateProjectError(null);
|
||||||
|
setCreateProjectSuccess(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Create new project
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={projectSetupMode === "existing" ? "default" : "ghost"}
|
||||||
|
className={`flex-1 rounded-none rounded-r-md border-0 border-l border-gray-200 dark:border-gray-700 ${
|
||||||
|
projectSetupMode === "existing"
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
setProjectSetupMode("existing");
|
||||||
|
setCreateProjectError(null);
|
||||||
|
setCreateProjectSuccess(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Connect to existing project
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="space-y-4" onSubmit={handleSetupProject}>
|
||||||
|
{projectSetupMode === "create" ? (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Label className="block text-sm font-medium">
|
||||||
|
Project Name
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
data-testid="vercel-create-project-name-input"
|
||||||
|
className="w-full mt-1"
|
||||||
|
value={projectName}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newValue = e.target.value;
|
||||||
|
setProjectName(newValue);
|
||||||
|
setProjectAvailable(null);
|
||||||
|
setProjectCheckError(null);
|
||||||
|
debouncedCheckProjectAvailability(newValue);
|
||||||
|
}}
|
||||||
|
disabled={isCreatingProject}
|
||||||
|
/>
|
||||||
|
{isCheckingProject && (
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Checking availability...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{projectAvailable === true && (
|
||||||
|
<p className="text-xs text-green-600 mt-1">
|
||||||
|
Project name is available!
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{projectAvailable === false && (
|
||||||
|
<p className="text-xs text-red-600 mt-1">
|
||||||
|
{projectCheckError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Label className="block text-sm font-medium">
|
||||||
|
Select Project
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={selectedProject}
|
||||||
|
onValueChange={setSelectedProject}
|
||||||
|
disabled={isLoadingProjects}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
className="w-full mt-1"
|
||||||
|
data-testid="vercel-project-select"
|
||||||
|
>
|
||||||
|
<SelectValue
|
||||||
|
placeholder={
|
||||||
|
isLoadingProjects
|
||||||
|
? "Loading projects..."
|
||||||
|
: "Select a project"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableProjects.map((project) => (
|
||||||
|
<SelectItem key={project.id} value={project.id}>
|
||||||
|
{project.name}{" "}
|
||||||
|
{project.framework && `(${project.framework})`}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={
|
||||||
|
isCreatingProject ||
|
||||||
|
(projectSetupMode === "create" &&
|
||||||
|
(projectAvailable === false || !projectName)) ||
|
||||||
|
(projectSetupMode === "existing" && !selectedProject)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isCreatingProject
|
||||||
|
? projectSetupMode === "create"
|
||||||
|
? "Creating..."
|
||||||
|
: "Connecting..."
|
||||||
|
: projectSetupMode === "create"
|
||||||
|
? "Create Project"
|
||||||
|
: "Connect to Project"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{createProjectError && (
|
||||||
|
<p className="text-red-600 mt-2">{createProjectError}</p>
|
||||||
|
)}
|
||||||
|
{createProjectSuccess && (
|
||||||
|
<p className="text-green-600 mt-2">
|
||||||
|
{projectSetupMode === "create"
|
||||||
|
? "Project created and linked!"
|
||||||
|
: "Connected to project!"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VercelConnector({ appId, folderName }: VercelConnectorProps) {
|
||||||
|
const { app, refreshApp } = useLoadApp(appId);
|
||||||
|
const { settings, refreshSettings } = useSettings();
|
||||||
|
|
||||||
|
if (app?.vercelProjectId && appId) {
|
||||||
|
return (
|
||||||
|
<ConnectedVercelConnector
|
||||||
|
appId={appId}
|
||||||
|
app={app}
|
||||||
|
refreshApp={refreshApp}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<UnconnectedVercelConnector
|
||||||
|
appId={appId}
|
||||||
|
folderName={folderName}
|
||||||
|
settings={settings}
|
||||||
|
refreshSettings={refreshSettings}
|
||||||
|
refreshApp={refreshApp}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
61
src/components/VercelIntegration.tsx
Normal file
61
src/components/VercelIntegration.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Vercel Integration
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Your account is connected to Vercel.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleDisconnectFromVercel}
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
disabled={isDisconnecting}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{isDisconnecting ? "Disconnecting..." : "Disconnect from Vercel"}
|
||||||
|
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M24 22.525H0l12-21.05 12 21.05z" />
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Wrench,
|
Wrench,
|
||||||
|
Globe,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { useEffect, useRef, useState, useCallback } from "react";
|
import { useEffect, useRef, useState, useCallback } from "react";
|
||||||
@@ -32,7 +33,12 @@ import { useMutation } from "@tanstack/react-query";
|
|||||||
import { useCheckProblems } from "@/hooks/useCheckProblems";
|
import { useCheckProblems } from "@/hooks/useCheckProblems";
|
||||||
import { isPreviewOpenAtom } from "@/atoms/viewAtoms";
|
import { isPreviewOpenAtom } from "@/atoms/viewAtoms";
|
||||||
|
|
||||||
export type PreviewMode = "preview" | "code" | "problems" | "configure";
|
export type PreviewMode =
|
||||||
|
| "preview"
|
||||||
|
| "code"
|
||||||
|
| "problems"
|
||||||
|
| "configure"
|
||||||
|
| "publish";
|
||||||
|
|
||||||
const BUTTON_CLASS_NAME =
|
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)]";
|
"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<HTMLButtonElement>(null);
|
const codeRef = useRef<HTMLButtonElement>(null);
|
||||||
const problemsRef = useRef<HTMLButtonElement>(null);
|
const problemsRef = useRef<HTMLButtonElement>(null);
|
||||||
const configureRef = useRef<HTMLButtonElement>(null);
|
const configureRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const publishRef = useRef<HTMLButtonElement>(null);
|
||||||
const [indicatorStyle, setIndicatorStyle] = useState({ left: 0, width: 0 });
|
const [indicatorStyle, setIndicatorStyle] = useState({ left: 0, width: 0 });
|
||||||
const [windowWidth, setWindowWidth] = useState(window.innerWidth);
|
const [windowWidth, setWindowWidth] = useState(window.innerWidth);
|
||||||
const { problemReport } = useCheckProblems(selectedAppId);
|
const { problemReport } = useCheckProblems(selectedAppId);
|
||||||
const { restartApp, refreshAppIframe } = useRunApp();
|
const { restartApp, refreshAppIframe } = useRunApp();
|
||||||
|
|
||||||
const isCompact = windowWidth < 840;
|
const isCompact = windowWidth < 860;
|
||||||
|
|
||||||
// Track window width
|
// Track window width
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -128,6 +135,9 @@ export const PreviewHeader = () => {
|
|||||||
case "configure":
|
case "configure":
|
||||||
targetRef = configureRef;
|
targetRef = configureRef;
|
||||||
break;
|
break;
|
||||||
|
case "publish":
|
||||||
|
targetRef = publishRef;
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -239,6 +249,13 @@ export const PreviewHeader = () => {
|
|||||||
"Configure",
|
"Configure",
|
||||||
"configure-mode-button",
|
"configure-mode-button",
|
||||||
)}
|
)}
|
||||||
|
{renderButton(
|
||||||
|
"publish",
|
||||||
|
publishRef,
|
||||||
|
<Globe size={14} />,
|
||||||
|
"Publish",
|
||||||
|
"publish-mode-button",
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { PanelGroup, Panel, PanelResizeHandle } from "react-resizable-panels";
|
import { PanelGroup, Panel, PanelResizeHandle } from "react-resizable-panels";
|
||||||
import { Console } from "./Console";
|
import { Console } from "./Console";
|
||||||
import { useRunApp } from "@/hooks/useRunApp";
|
import { useRunApp } from "@/hooks/useRunApp";
|
||||||
|
import { PublishPanel } from "./PublishPanel";
|
||||||
|
|
||||||
interface ConsoleHeaderProps {
|
interface ConsoleHeaderProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -116,6 +117,8 @@ export function PreviewPanel() {
|
|||||||
<CodeView loading={loading} app={app} />
|
<CodeView loading={loading} app={app} />
|
||||||
) : previewMode === "configure" ? (
|
) : previewMode === "configure" ? (
|
||||||
<ConfigurePanel />
|
<ConfigurePanel />
|
||||||
|
) : previewMode === "publish" ? (
|
||||||
|
<PublishPanel />
|
||||||
) : (
|
) : (
|
||||||
<Problems />
|
<Problems />
|
||||||
)}
|
)}
|
||||||
|
|||||||
169
src/components/preview_panel/PublishPanel.tsx
Normal file
169
src/components/preview_panel/PublishPanel.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
|
||||||
|
<svg
|
||||||
|
className="w-6 h-6 text-blue-600 dark:text-blue-400 animate-spin"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="m4 12a8 8 0 0 1 8-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 0 1 4 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
Loading...
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedAppId || !app) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-gray-100 dark:bg-gray-900/30 flex items-center justify-center">
|
||||||
|
<svg
|
||||||
|
className="w-6 h-6 text-gray-600 dark:text-gray-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
No App Selected
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 max-w-md">
|
||||||
|
Select an app to view publishing options.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full overflow-y-auto">
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2">
|
||||||
|
Publish App
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* GitHub Section */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
GitHub
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Sync your code to GitHub for collaboration.
|
||||||
|
</p>
|
||||||
|
<GitHubConnector
|
||||||
|
appId={selectedAppId}
|
||||||
|
folderName={app.name}
|
||||||
|
expanded={true}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Vercel Section */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const ipcClient = IpcClient.getInstance();
|
||||||
|
ipcClient.openExternalUrl("https://vercel.com/dashboard");
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 hover:text-blue-600 dark:hover:text-blue-400 transition-colors cursor-pointer bg-transparent border-none p-0"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path d="M24 22.525H0l12-21.05 12 21.05z" />
|
||||||
|
</svg>
|
||||||
|
Vercel
|
||||||
|
</button>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Publish your app by deploying it to Vercel.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{!app?.githubOrg || !app?.githubRepo ? (
|
||||||
|
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5 text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-amber-800 dark:text-amber-200">
|
||||||
|
GitHub Required for Vercel Deployment
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-amber-700 dark:text-amber-300 mt-1">
|
||||||
|
Deploying to Vercel requires connecting to GitHub first.
|
||||||
|
Please set up your GitHub repository above.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<VercelConnector appId={selectedAppId} folderName={app.name} />
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -16,6 +16,10 @@ export const apps = sqliteTable("apps", {
|
|||||||
githubRepo: text("github_repo"),
|
githubRepo: text("github_repo"),
|
||||||
githubBranch: text("github_branch"),
|
githubBranch: text("github_branch"),
|
||||||
supabaseProjectId: text("supabase_project_id"),
|
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" }),
|
chatContext: text("chat_context", { mode: "json" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import { gitCommit } from "../utils/git_utils";
|
|||||||
import { safeSend } from "../utils/safe_sender";
|
import { safeSend } from "../utils/safe_sender";
|
||||||
import { normalizePath } from "../../../shared/normalizePath";
|
import { normalizePath } from "../../../shared/normalizePath";
|
||||||
import { isServerFunction } from "@/supabase_admin/supabase_utils";
|
import { isServerFunction } from "@/supabase_admin/supabase_utils";
|
||||||
|
import { getVercelTeamSlug } from "../utils/vercel_utils";
|
||||||
|
|
||||||
async function copyDir(
|
async function copyDir(
|
||||||
source: string,
|
source: string,
|
||||||
@@ -370,10 +371,16 @@ export function registerAppHandlers() {
|
|||||||
supabaseProjectName = await getSupabaseProjectName(app.supabaseProjectId);
|
supabaseProjectName = await getSupabaseProjectName(app.supabaseProjectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let vercelTeamSlug: string | null = null;
|
||||||
|
if (app.vercelTeamId) {
|
||||||
|
vercelTeamSlug = await getVercelTeamSlug(app.vercelTeamId);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...app,
|
...app,
|
||||||
files,
|
files,
|
||||||
supabaseProjectName,
|
supabaseProjectName,
|
||||||
|
vercelTeamSlug,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
524
src/ipc/handlers/vercel_handlers.ts
Normal file
524
src/ipc/handlers/vercel_handlers.ts
Normal file
@@ -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<boolean> {
|
||||||
|
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<string> {
|
||||||
|
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<CreateProjectFramework | undefined> {
|
||||||
|
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<void> {
|
||||||
|
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<VercelProject[]> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
await db
|
||||||
|
.update(schema.apps)
|
||||||
|
.set({
|
||||||
|
vercelProjectId: projectId,
|
||||||
|
vercelProjectName: projectName,
|
||||||
|
vercelTeamId: teamId,
|
||||||
|
vercelDeploymentUrl: deploymentUrl,
|
||||||
|
})
|
||||||
|
.where(eq(schema.apps.id, appId));
|
||||||
|
}
|
||||||
@@ -40,6 +40,15 @@ import type {
|
|||||||
EditAppFileReturnType,
|
EditAppFileReturnType,
|
||||||
GetAppEnvVarsParams,
|
GetAppEnvVarsParams,
|
||||||
SetAppEnvVarsParams,
|
SetAppEnvVarsParams,
|
||||||
|
ConnectToExistingVercelProjectParams,
|
||||||
|
IsVercelProjectAvailableResponse,
|
||||||
|
CreateVercelProjectParams,
|
||||||
|
VercelDeployment,
|
||||||
|
GetVercelDeploymentsParams,
|
||||||
|
DisconnectVercelProjectParams,
|
||||||
|
IsVercelProjectAvailableParams,
|
||||||
|
SaveVercelAccessTokenParams,
|
||||||
|
VercelProject,
|
||||||
} from "./ipc_types";
|
} from "./ipc_types";
|
||||||
import type { AppChatContext, ProposalResult } from "@/lib/schemas";
|
import type { AppChatContext, ProposalResult } from "@/lib/schemas";
|
||||||
import { showError } from "@/lib/toast";
|
import { showError } from "@/lib/toast";
|
||||||
@@ -646,6 +655,51 @@ export class IpcClient {
|
|||||||
}
|
}
|
||||||
// --- End GitHub Repo Management ---
|
// --- End GitHub Repo Management ---
|
||||||
|
|
||||||
|
// --- Vercel Token Management ---
|
||||||
|
public async saveVercelAccessToken(
|
||||||
|
params: SaveVercelAccessTokenParams,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.ipcRenderer.invoke("vercel:save-token", params);
|
||||||
|
}
|
||||||
|
// --- End Vercel Token Management ---
|
||||||
|
|
||||||
|
// --- Vercel Project Management ---
|
||||||
|
public async listVercelProjects(): Promise<VercelProject[]> {
|
||||||
|
return this.ipcRenderer.invoke("vercel:list-projects", undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async connectToExistingVercelProject(
|
||||||
|
params: ConnectToExistingVercelProjectParams,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.ipcRenderer.invoke("vercel:connect-existing-project", params);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async isVercelProjectAvailable(
|
||||||
|
params: IsVercelProjectAvailableParams,
|
||||||
|
): Promise<IsVercelProjectAvailableResponse> {
|
||||||
|
return this.ipcRenderer.invoke("vercel:is-project-available", params);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createVercelProject(
|
||||||
|
params: CreateVercelProjectParams,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.ipcRenderer.invoke("vercel:create-project", params);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get Vercel Deployments
|
||||||
|
public async getVercelDeployments(
|
||||||
|
params: GetVercelDeploymentsParams,
|
||||||
|
): Promise<VercelDeployment[]> {
|
||||||
|
return this.ipcRenderer.invoke("vercel:get-deployments", params);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async disconnectVercelProject(
|
||||||
|
params: DisconnectVercelProjectParams,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.ipcRenderer.invoke("vercel:disconnect", params);
|
||||||
|
}
|
||||||
|
// --- End Vercel Project Management ---
|
||||||
|
|
||||||
// Get the main app version
|
// Get the main app version
|
||||||
public async getAppVersion(): Promise<string> {
|
public async getAppVersion(): Promise<string> {
|
||||||
const result = await this.ipcRenderer.invoke("get-app-version");
|
const result = await this.ipcRenderer.invoke("get-app-version");
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { registerSettingsHandlers } from "./handlers/settings_handlers";
|
|||||||
import { registerShellHandlers } from "./handlers/shell_handler";
|
import { registerShellHandlers } from "./handlers/shell_handler";
|
||||||
import { registerDependencyHandlers } from "./handlers/dependency_handlers";
|
import { registerDependencyHandlers } from "./handlers/dependency_handlers";
|
||||||
import { registerGithubHandlers } from "./handlers/github_handlers";
|
import { registerGithubHandlers } from "./handlers/github_handlers";
|
||||||
|
import { registerVercelHandlers } from "./handlers/vercel_handlers";
|
||||||
import { registerNodeHandlers } from "./handlers/node_handlers";
|
import { registerNodeHandlers } from "./handlers/node_handlers";
|
||||||
import { registerProposalHandlers } from "./handlers/proposal_handlers";
|
import { registerProposalHandlers } from "./handlers/proposal_handlers";
|
||||||
import { registerDebugHandlers } from "./handlers/debug_handlers";
|
import { registerDebugHandlers } from "./handlers/debug_handlers";
|
||||||
@@ -34,6 +35,7 @@ export function registerIpcHandlers() {
|
|||||||
registerShellHandlers();
|
registerShellHandlers();
|
||||||
registerDependencyHandlers();
|
registerDependencyHandlers();
|
||||||
registerGithubHandlers();
|
registerGithubHandlers();
|
||||||
|
registerVercelHandlers();
|
||||||
registerNodeHandlers();
|
registerNodeHandlers();
|
||||||
registerProblemsHandlers();
|
registerProblemsHandlers();
|
||||||
registerProposalHandlers();
|
registerProposalHandlers();
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ export interface App {
|
|||||||
githubBranch: string | null;
|
githubBranch: string | null;
|
||||||
supabaseProjectId: string | null;
|
supabaseProjectId: string | null;
|
||||||
supabaseProjectName: string | null;
|
supabaseProjectName: string | null;
|
||||||
|
vercelProjectId: string | null;
|
||||||
|
vercelProjectName: string | null;
|
||||||
|
vercelTeamSlug: string | null;
|
||||||
|
vercelDeploymentUrl: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Version {
|
export interface Version {
|
||||||
@@ -266,3 +270,49 @@ export interface SetAppEnvVarsParams {
|
|||||||
export interface GetAppEnvVarsParams {
|
export interface GetAppEnvVarsParams {
|
||||||
appId: number;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
48
src/ipc/utils/vercel_utils.ts
Normal file
48
src/ipc/utils/vercel_utils.ts
Normal file
@@ -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<string | null> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -136,6 +136,7 @@ export const UserSettingsSchema = z.object({
|
|||||||
providerSettings: z.record(z.string(), ProviderSettingSchema),
|
providerSettings: z.record(z.string(), ProviderSettingSchema),
|
||||||
githubUser: GithubUserSchema.optional(),
|
githubUser: GithubUserSchema.optional(),
|
||||||
githubAccessToken: SecretSchema.optional(),
|
githubAccessToken: SecretSchema.optional(),
|
||||||
|
vercelAccessToken: SecretSchema.optional(),
|
||||||
supabase: SupabaseSchema.optional(),
|
supabase: SupabaseSchema.optional(),
|
||||||
autoApproveChanges: z.boolean().optional(),
|
autoApproveChanges: z.boolean().optional(),
|
||||||
telemetryConsent: z.enum(["opted_in", "opted_out", "unset"]).optional(),
|
telemetryConsent: z.enum(["opted_in", "opted_out", "unset"]).optional(),
|
||||||
|
|||||||
@@ -74,6 +74,13 @@ export function readSettings(): UserSettings {
|
|||||||
encryptionType,
|
encryptionType,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (combinedSettings.vercelAccessToken) {
|
||||||
|
const encryptionType = combinedSettings.vercelAccessToken.encryptionType;
|
||||||
|
combinedSettings.vercelAccessToken = {
|
||||||
|
value: decrypt(combinedSettings.vercelAccessToken),
|
||||||
|
encryptionType,
|
||||||
|
};
|
||||||
|
}
|
||||||
for (const provider in combinedSettings.providerSettings) {
|
for (const provider in combinedSettings.providerSettings) {
|
||||||
if (combinedSettings.providerSettings[provider].apiKey) {
|
if (combinedSettings.providerSettings[provider].apiKey) {
|
||||||
const encryptionType =
|
const encryptionType =
|
||||||
@@ -105,6 +112,11 @@ export function writeSettings(settings: Partial<UserSettings>): void {
|
|||||||
newSettings.githubAccessToken.value,
|
newSettings.githubAccessToken.value,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (newSettings.vercelAccessToken) {
|
||||||
|
newSettings.vercelAccessToken = encrypt(
|
||||||
|
newSettings.vercelAccessToken.value,
|
||||||
|
);
|
||||||
|
}
|
||||||
if (newSettings.supabase) {
|
if (newSettings.supabase) {
|
||||||
if (newSettings.supabase.accessToken) {
|
if (newSettings.supabase.accessToken) {
|
||||||
newSettings.supabase.accessToken = encrypt(
|
newSettings.supabase.accessToken = encrypt(
|
||||||
|
|||||||
@@ -342,7 +342,9 @@ export default function AppDetailsPage() {
|
|||||||
Open in Chat
|
Open in Chat
|
||||||
<MessageCircle className="h-4 w-4" />
|
<MessageCircle className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<GitHubConnector appId={appId} folderName={selectedApp.path} />
|
<div className="border border-gray-200 rounded-md p-4">
|
||||||
|
<GitHubConnector appId={appId} folderName={selectedApp.path} />
|
||||||
|
</div>
|
||||||
{appId && <SupabaseConnector appId={appId} />}
|
{appId && <SupabaseConnector appId={appId} />}
|
||||||
{appId && <CapacitorControls appId={appId} />}
|
{appId && <CapacitorControls appId={appId} />}
|
||||||
<AppUpgrades appId={appId} />
|
<AppUpgrades appId={appId} />
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
import { useRouter } from "@tanstack/react-router";
|
import { useRouter } from "@tanstack/react-router";
|
||||||
import { GitHubIntegration } from "@/components/GitHubIntegration";
|
import { GitHubIntegration } from "@/components/GitHubIntegration";
|
||||||
|
import { VercelIntegration } from "@/components/VercelIntegration";
|
||||||
import { SupabaseIntegration } from "@/components/SupabaseIntegration";
|
import { SupabaseIntegration } from "@/components/SupabaseIntegration";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -109,6 +110,7 @@ export default function SettingsPage() {
|
|||||||
</h2>
|
</h2>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<GitHubIntegration />
|
<GitHubIntegration />
|
||||||
|
<VercelIntegration />
|
||||||
<SupabaseIntegration />
|
<SupabaseIntegration />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -55,6 +55,13 @@ const validInvokeChannels = [
|
|||||||
"github:connect-existing-repo",
|
"github:connect-existing-repo",
|
||||||
"github:push",
|
"github:push",
|
||||||
"github:disconnect",
|
"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",
|
"get-app-version",
|
||||||
"reload-env-path",
|
"reload-env-path",
|
||||||
"get-proposal",
|
"get-proposal",
|
||||||
|
|||||||
Reference in New Issue
Block a user