Neon / portal template support (#713)
TODOs: - [x] Do restart when checkout / restore if there is a DB - [x] List all branches (branch id, name, date) - [x] Allow checking out versions with no DB - [x] safeguard to never delete main branches - [x] create app hook for neon template - [x] weird UX with connector on configure panel - [x] tiny neon logo in connector - [x] deploy to vercel - [x] build forgot password page - [x] what about email setup - [x] lots of imgix errors - [x] edit file - db snapshot - [x] DYAD_DISABLE_DB_PUSH - [ ] update portal doc - [x] switch preview branch to be read-only endpoint - [x] disable supabase sys prompt if neon is enabled - [ ] https://payloadcms.com/docs/upload/storage-adapters - [x] need to use main branch... Phase 2? - [x] generate DB migrations
This commit is contained in:
14
drizzle/0009_previous_misty_knight.sql
Normal file
14
drizzle/0009_previous_misty_knight.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE `versions` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`app_id` integer NOT NULL,
|
||||
`commit_hash` text NOT NULL,
|
||||
`neon_db_timestamp` text,
|
||||
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
`updated_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
FOREIGN KEY (`app_id`) REFERENCES `apps`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `versions_app_commit_unique` ON `versions` (`app_id`,`commit_hash`);--> statement-breakpoint
|
||||
ALTER TABLE `apps` ADD `neon_project_id` text;--> statement-breakpoint
|
||||
ALTER TABLE `apps` ADD `neon_development_branch_id` text;--> statement-breakpoint
|
||||
ALTER TABLE `apps` ADD `neon_preview_branch_id` text;
|
||||
510
drizzle/meta/0009_snapshot.json
Normal file
510
drizzle/meta/0009_snapshot.json
Normal file
@@ -0,0 +1,510 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "4d1fc225-7395-4d56-8d0d-7f76fed4a8d8",
|
||||
"prevId": "553360d1-7173-4bb0-9f31-ab49a0010279",
|
||||
"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
|
||||
},
|
||||
"neon_project_id": {
|
||||
"name": "neon_project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"neon_development_branch_id": {
|
||||
"name": "neon_development_branch_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"neon_preview_branch_id": {
|
||||
"name": "neon_preview_branch_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": {}
|
||||
},
|
||||
"versions": {
|
||||
"name": "versions",
|
||||
"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
|
||||
},
|
||||
"commit_hash": {
|
||||
"name": "commit_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"neon_db_timestamp": {
|
||||
"name": "neon_db_timestamp",
|
||||
"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": {
|
||||
"versions_app_commit_unique": {
|
||||
"name": "versions_app_commit_unique",
|
||||
"columns": [
|
||||
"app_id",
|
||||
"commit_hash"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"versions_app_id_apps_id_fk": {
|
||||
"name": "versions_app_id_apps_id_fk",
|
||||
"tableFrom": "versions",
|
||||
"tableTo": "apps",
|
||||
"columnsFrom": [
|
||||
"app_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,13 @@
|
||||
"when": 1752625491756,
|
||||
"tag": "0008_medical_vulcan",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "6",
|
||||
"when": 1753473275674,
|
||||
"tag": "0009_previous_misty_knight",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -481,11 +481,11 @@ export class PageObject {
|
||||
}
|
||||
|
||||
locateLoadingAppPreview() {
|
||||
return this.page.getByText("Loading app preview...");
|
||||
return this.page.getByText("Preparing app preview...");
|
||||
}
|
||||
|
||||
locateStartingAppPreview() {
|
||||
return this.page.getByText("Starting up your app...");
|
||||
return this.page.getByText("Starting your app server...");
|
||||
}
|
||||
|
||||
getPreviewIframeElement() {
|
||||
|
||||
176
package-lock.json
generated
176
package-lock.json
generated
@@ -16,6 +16,7 @@
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@dyad-sh/supabase-management-js": "v1.0.0",
|
||||
"@monaco-editor/react": "^4.7.0-rc.0",
|
||||
"@neondatabase/api-client": "^2.1.0",
|
||||
"@openrouter/ai-sdk-provider": "^0.4.5",
|
||||
"@radix-ui/react-accordion": "^1.2.4",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.13",
|
||||
@@ -3227,6 +3228,30 @@
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@neondatabase/api-client": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@neondatabase/api-client/-/api-client-2.1.0.tgz",
|
||||
"integrity": "sha512-2noK3Ys1MHUxSk7UA/unt+UkJasotlqDJj87ez+Aq6mXWPTcutMhIFLAp9eHazjYbho/cmYtPIawqeuFl3dDEQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@neondatabase/serverless": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@neondatabase/serverless/-/serverless-1.0.1.tgz",
|
||||
"integrity": "sha512-O6yC5TT0jbw86VZVkmnzCZJB0hfxBl0JJz6f+3KHoZabjb/X08r9eFA+vuY06z1/qaovykvdkrXYq3SPUuvogA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/node": "^22.15.30",
|
||||
"@types/pg": "^8.8.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "15.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.3.1.tgz",
|
||||
@@ -6289,9 +6314,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.15.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.3.tgz",
|
||||
"integrity": "sha512-lX7HFZeHf4QG/J7tBZqrCAXwz9J5RD56Y6MpP0eJkka8p+K0RY/yBTW7CYFJ4VGCclxqOLKmiGP5juQc6MKgcw==",
|
||||
"version": "22.16.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.16.5.tgz",
|
||||
"integrity": "sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
@@ -6307,6 +6332,19 @@
|
||||
"form-data": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pg": {
|
||||
"version": "8.15.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.4.tgz",
|
||||
"integrity": "sha512-I6UNVBAoYbvuWkkU3oosC8yxqH21f4/Jc4DK71JLG3dT2mdlGe1z+ep/LQGXaKaOgcvUrsQoPRqfgtMcvZiJhg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"pg-protocol": "*",
|
||||
"pg-types": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.2.tgz",
|
||||
@@ -7193,6 +7231,17 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz",
|
||||
"integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.0",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bail": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
|
||||
@@ -11087,6 +11136,26 @@
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.9",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
|
||||
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/for-each": {
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||
@@ -16376,6 +16445,43 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.10.3",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz",
|
||||
"integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/pg-types": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pg-int8": "1.0.1",
|
||||
"postgres-array": "~2.0.0",
|
||||
"postgres-bytea": "~1.0.0",
|
||||
"postgres-date": "~1.0.4",
|
||||
"postgres-interval": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -16530,6 +16636,53 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-bytea": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz",
|
||||
"integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-date": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-interval": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"xtend": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/posthog-js": {
|
||||
"version": "1.239.0",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.239.0.tgz",
|
||||
@@ -16743,6 +16896,12 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pump": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz",
|
||||
@@ -20828,6 +20987,17 @@
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/xterm": {
|
||||
"version": "4.19.0",
|
||||
"resolved": "https://registry.npmjs.org/xterm/-/xterm-4.19.0.tgz",
|
||||
|
||||
@@ -90,6 +90,7 @@
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@dyad-sh/supabase-management-js": "v1.0.0",
|
||||
"@monaco-editor/react": "^4.7.0-rc.0",
|
||||
"@neondatabase/api-client": "^2.1.0",
|
||||
"@openrouter/ai-sdk-provider": "^0.4.5",
|
||||
"@radix-ui/react-accordion": "^1.2.4",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.13",
|
||||
|
||||
45
src/client_logic/template_hook.ts
Normal file
45
src/client_logic/template_hook.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
export async function neonTemplateHook({
|
||||
appId,
|
||||
appName,
|
||||
}: {
|
||||
appId: number;
|
||||
appName: string;
|
||||
}) {
|
||||
console.log("Creating Neon project");
|
||||
const neonProject = await IpcClient.getInstance().createNeonProject({
|
||||
name: appName,
|
||||
appId: appId,
|
||||
});
|
||||
|
||||
console.log("Neon project created", neonProject);
|
||||
await IpcClient.getInstance().setAppEnvVars({
|
||||
appId: appId,
|
||||
envVars: [
|
||||
{
|
||||
key: "POSTGRES_URL",
|
||||
value: neonProject.connectionString,
|
||||
},
|
||||
{
|
||||
key: "PAYLOAD_SECRET",
|
||||
value: uuidv4(),
|
||||
},
|
||||
{
|
||||
key: "NEXT_PUBLIC_SERVER_URL",
|
||||
value: "http://localhost:32100",
|
||||
},
|
||||
{
|
||||
key: "GMAIL_USER",
|
||||
value: "example@gmail.com",
|
||||
},
|
||||
{
|
||||
key: "GOOGLE_APP_PASSWORD",
|
||||
value: "GENERATE AT https://myaccount.google.com/apppasswords",
|
||||
},
|
||||
],
|
||||
});
|
||||
console.log("App env vars set");
|
||||
}
|
||||
137
src/components/CreateAppDialog.tsx
Normal file
137
src/components/CreateAppDialog.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useCreateApp } from "@/hooks/useCreateApp";
|
||||
import { useCheckName } from "@/hooks/useCheckName";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { selectedAppIdAtom } from "@/atoms/appAtoms";
|
||||
import { NEON_TEMPLATE_IDS, Template } from "@/shared/templates";
|
||||
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { neonTemplateHook } from "@/client_logic/template_hook";
|
||||
import { showError } from "@/lib/toast";
|
||||
|
||||
interface CreateAppDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
template: Template | undefined;
|
||||
}
|
||||
|
||||
export function CreateAppDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
template,
|
||||
}: CreateAppDialogProps) {
|
||||
const setSelectedAppId = useSetAtom(selectedAppIdAtom);
|
||||
const [appName, setAppName] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { createApp } = useCreateApp();
|
||||
const { data: nameCheckResult } = useCheckName(appName);
|
||||
const router = useRouter();
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!appName.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (nameCheckResult?.exists) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const result = await createApp({ name: appName.trim() });
|
||||
if (template && NEON_TEMPLATE_IDS.has(template.id)) {
|
||||
await neonTemplateHook({
|
||||
appId: result.app.id,
|
||||
appName: result.app.name,
|
||||
});
|
||||
}
|
||||
setSelectedAppId(result.app.id);
|
||||
// Navigate to the new app's first chat
|
||||
router.navigate({
|
||||
to: "/chat",
|
||||
search: { id: result.chatId },
|
||||
});
|
||||
setAppName("");
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
showError(error as any);
|
||||
// Error is already handled by createApp hook or shown above
|
||||
console.error("Error creating app:", error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isNameValid = appName.trim().length > 0;
|
||||
const nameExists = nameCheckResult?.exists;
|
||||
const canSubmit = isNameValid && !nameExists && !isSubmitting;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New App</DialogTitle>
|
||||
<DialogDescription>
|
||||
{`Create a new app using the ${template?.title} template.`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="appName">App Name</Label>
|
||||
<Input
|
||||
id="appName"
|
||||
value={appName}
|
||||
onChange={(e) => setAppName(e.target.value)}
|
||||
placeholder="Enter app name..."
|
||||
className={nameExists ? "border-red-500" : ""}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
{nameExists && (
|
||||
<p className="text-sm text-red-500">
|
||||
An app with this name already exists
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
className="bg-indigo-600 hover:bg-indigo-700"
|
||||
>
|
||||
{isSubmitting && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
{isSubmitting ? "Creating..." : "Create App"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
89
src/components/InputRequestToast.tsx
Normal file
89
src/components/InputRequestToast.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import React from "react";
|
||||
import { toast } from "sonner";
|
||||
import { X, AlertTriangle } from "lucide-react";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
interface InputRequestToastProps {
|
||||
message: string;
|
||||
toastId: string | number;
|
||||
onResponse: (response: "y" | "n") => void;
|
||||
}
|
||||
|
||||
export function InputRequestToast({
|
||||
message,
|
||||
toastId,
|
||||
onResponse,
|
||||
}: InputRequestToastProps) {
|
||||
const handleClose = () => {
|
||||
toast.dismiss(toastId);
|
||||
};
|
||||
|
||||
const handleResponse = (response: "y" | "n") => {
|
||||
onResponse(response);
|
||||
toast.dismiss(toastId);
|
||||
};
|
||||
|
||||
// Clean up the message by removing excessive newlines and whitespace
|
||||
const cleanMessage = message
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
.join("\n");
|
||||
|
||||
return (
|
||||
<div className="relative bg-amber-50/95 dark:bg-slate-800/95 backdrop-blur-sm border border-amber-200 dark:border-slate-600 rounded-xl shadow-lg min-w-[400px] max-w-[500px] overflow-hidden">
|
||||
{/* Content */}
|
||||
<div className="p-5">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center mb-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-6 h-6 bg-gradient-to-br from-amber-500 to-amber-600 dark:from-amber-400 dark:to-amber-500 rounded-full flex items-center justify-center shadow-sm">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="ml-3 text-base font-semibold text-amber-900 dark:text-amber-100">
|
||||
Input Required
|
||||
</h3>
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="ml-auto flex-shrink-0 p-1.5 text-amber-500 dark:text-slate-400 hover:text-amber-700 dark:hover:text-slate-200 transition-colors duration-200 rounded-md hover:bg-amber-100/50 dark:hover:bg-slate-700/50"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div className="mb-5">
|
||||
<p className="text-sm text-amber-900 dark:text-slate-200 whitespace-pre-wrap leading-relaxed">
|
||||
{cleanMessage}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
onClick={() => handleResponse("y")}
|
||||
size="sm"
|
||||
className="bg-primary text-white dark:bg-primary dark:text-black px-6"
|
||||
>
|
||||
Yes
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleResponse("n")}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-amber-300 dark:border-slate-500 text-amber-800 dark:text-slate-300 hover:bg-amber-100 dark:hover:bg-slate-700 px-6"
|
||||
>
|
||||
No
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
157
src/components/NeonConnector.tsx
Normal file
157
src/components/NeonConnector.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import { toast } from "sonner";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
|
||||
import { useDeepLink } from "@/contexts/DeepLinkContext";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { NeonDisconnectButton } from "@/components/NeonDisconnectButton";
|
||||
|
||||
export function NeonConnector() {
|
||||
const { settings, refreshSettings } = useSettings();
|
||||
const { lastDeepLink } = useDeepLink();
|
||||
const { isDarkMode } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
const handleDeepLink = async () => {
|
||||
if (lastDeepLink?.type === "neon-oauth-return") {
|
||||
await refreshSettings();
|
||||
toast.success("Successfully connected to Neon!");
|
||||
}
|
||||
};
|
||||
handleDeepLink();
|
||||
}, [lastDeepLink]);
|
||||
|
||||
if (settings?.neon?.accessToken) {
|
||||
return (
|
||||
<div className="flex flex-col space-y-4 p-4 border bg-white dark:bg-gray-800 max-w-100 rounded-md">
|
||||
<div className="flex flex-col items-start justify-between">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<h2 className="text-lg font-medium pb-1">Neon Database</h2>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
IpcClient.getInstance().openExternalUrl(
|
||||
"https://console.neon.tech/",
|
||||
);
|
||||
}}
|
||||
className="ml-2 px-2 py-1 h-8 mb-2"
|
||||
style={{ display: "inline-flex", alignItems: "center" }}
|
||||
asChild
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
Neon
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 pb-3">
|
||||
You are connected to Neon Database
|
||||
</p>
|
||||
<NeonDisconnectButton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-4 p-4 border bg-white dark:bg-gray-800 max-w-100 rounded-md">
|
||||
<div className="flex flex-col items-start justify-between">
|
||||
<h2 className="text-lg font-medium pb-1">Neon Database</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 pb-3">
|
||||
Neon Database has a good free tier with backups and up to 10 projects.
|
||||
</p>
|
||||
<div
|
||||
onClick={async () => {
|
||||
if (settings?.isTestMode) {
|
||||
await IpcClient.getInstance().fakeHandleNeonConnect();
|
||||
} else {
|
||||
await IpcClient.getInstance().openExternalUrl(
|
||||
"https://oauth.dyad.sh/api/integrations/neon/login",
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="w-auto h-10 cursor-pointer flex items-center justify-center px-4 py-2 rounded-md border-2 transition-colors font-medium text-sm dark:bg-gray-900 dark:border-gray-700"
|
||||
data-testid="connect-neon-button"
|
||||
>
|
||||
<span className="mr-2">Connect to</span>
|
||||
<NeonSvg isDarkMode={isDarkMode} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NeonSvg({
|
||||
isDarkMode,
|
||||
className,
|
||||
}: {
|
||||
isDarkMode?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const textColor = isDarkMode ? "#fff" : "#000";
|
||||
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="68"
|
||||
height="18"
|
||||
fill="none"
|
||||
viewBox="0 0 102 28"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
fill="#12FFF7"
|
||||
fillRule="evenodd"
|
||||
d="M0 4.828C0 2.16 2.172 0 4.851 0h18.436c2.679 0 4.85 2.161 4.85 4.828V20.43c0 2.758-3.507 3.955-5.208 1.778l-5.318-6.809v8.256c0 2.4-1.955 4.345-4.367 4.345H4.851C2.172 28 0 25.839 0 23.172zm4.851-.966a.97.97 0 0 0-.97.966v18.344c0 .534.435.966.97.966h8.539c.268 0 .34-.216.34-.483v-11.07c0-2.76 3.507-3.956 5.208-1.779l5.319 6.809V4.828c0-.534.05-.966-.485-.966z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<path
|
||||
fill="url(#a)"
|
||||
fillRule="evenodd"
|
||||
d="M0 4.828C0 2.16 2.172 0 4.851 0h18.436c2.679 0 4.85 2.161 4.85 4.828V20.43c0 2.758-3.507 3.955-5.208 1.778l-5.318-6.809v8.256c0 2.4-1.955 4.345-4.367 4.345H4.851C2.172 28 0 25.839 0 23.172zm4.851-.966a.97.97 0 0 0-.97.966v18.344c0 .534.435.966.97.966h8.539c.268 0 .34-.216.34-.483v-11.07c0-2.76 3.507-3.956 5.208-1.779l5.319 6.809V4.828c0-.534.05-.966-.485-.966z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<path
|
||||
fill="url(#b)"
|
||||
fillRule="evenodd"
|
||||
d="M0 4.828C0 2.16 2.172 0 4.851 0h18.436c2.679 0 4.85 2.161 4.85 4.828V20.43c0 2.758-3.507 3.955-5.208 1.778l-5.318-6.809v8.256c0 2.4-1.955 4.345-4.367 4.345H4.851C2.172 28 0 25.839 0 23.172zm4.851-.966a.97.97 0 0 0-.97.966v18.344c0 .534.435.966.97.966h8.539c.268 0 .34-.216.34-.483v-11.07c0-2.76 3.507-3.956 5.208-1.779l5.319 6.809V4.828c0-.534.05-.966-.485-.966z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<path
|
||||
fill="#B9FFB3"
|
||||
d="M23.287 0c2.679 0 4.85 2.161 4.85 4.828V20.43c0 2.758-3.507 3.955-5.208 1.778l-5.319-6.809v8.256c0 2.4-1.954 4.345-4.366 4.345a.484.484 0 0 0 .485-.483V12.584c0-2.758 3.508-3.955 5.21-1.777l5.318 6.808V.965a.97.97 0 0 0-.97-.965"
|
||||
/>
|
||||
<path
|
||||
fill={textColor}
|
||||
d="M48.112 7.432v8.032l-7.355-8.032H36.93v13.136h3.49v-8.632l8.01 8.632h3.173V7.432zM58.075 17.64v-2.326h7.815v-2.797h-7.815V10.36h9.48V7.432H54.514v13.136H67.75v-2.927zM77.028 21c4.909 0 8.098-2.552 8.098-7s-3.19-7-8.098-7c-4.91 0-8.081 2.552-8.081 7s3.172 7 8.08 7m0-3.115c-2.73 0-4.413-1.408-4.413-3.885s1.701-3.885 4.413-3.885c2.729 0 4.412 1.408 4.412 3.885s-1.683 3.885-4.412 3.885M98.508 7.432v8.032l-7.355-8.032h-3.828v13.136h3.491v-8.632l8.01 8.632H102V7.432z"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="a"
|
||||
x1="28.138"
|
||||
x2="3.533"
|
||||
y1="28"
|
||||
y2="-.12"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#B9FFB3" />
|
||||
<stop offset="1" stopColor="#B9FFB3" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="b"
|
||||
x1="28.138"
|
||||
x2="11.447"
|
||||
y1="28"
|
||||
y2="21.476"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#1A1A1A" stopOpacity=".9" />
|
||||
<stop offset="1" stopColor="#1A1A1A" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
38
src/components/NeonDisconnectButton.tsx
Normal file
38
src/components/NeonDisconnectButton.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
|
||||
interface NeonDisconnectButtonProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function NeonDisconnectButton({ className }: NeonDisconnectButtonProps) {
|
||||
const { updateSettings, settings } = useSettings();
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
try {
|
||||
await updateSettings({
|
||||
neon: undefined,
|
||||
});
|
||||
toast.success("Disconnected from Neon successfully");
|
||||
} catch (error) {
|
||||
console.error("Failed to disconnect from Neon:", error);
|
||||
toast.error("Failed to disconnect from Neon");
|
||||
}
|
||||
};
|
||||
|
||||
if (!settings?.neon?.accessToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDisconnect}
|
||||
className={className}
|
||||
size="sm"
|
||||
>
|
||||
Disconnect from Neon
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
29
src/components/NeonIntegration.tsx
Normal file
29
src/components/NeonIntegration.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import { NeonDisconnectButton } from "@/components/NeonDisconnectButton";
|
||||
|
||||
export function NeonIntegration() {
|
||||
const { settings } = useSettings();
|
||||
|
||||
const isConnected = !!settings?.neon?.accessToken;
|
||||
|
||||
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">
|
||||
Neon Integration
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Your account is connected to Neon.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<NeonDisconnectButton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
110
src/components/PortalMigrate.tsx
Normal file
110
src/components/PortalMigrate.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ExternalLink, Database, Loader2 } from "lucide-react";
|
||||
import { showSuccess, showError } from "@/lib/toast";
|
||||
import { useVersions } from "@/hooks/useVersions";
|
||||
|
||||
interface PortalMigrateProps {
|
||||
appId: number;
|
||||
}
|
||||
|
||||
export const PortalMigrate = ({ appId }: PortalMigrateProps) => {
|
||||
const [output, setOutput] = useState<string>("");
|
||||
const { refreshVersions } = useVersions(appId);
|
||||
|
||||
const migrateMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const ipcClient = IpcClient.getInstance();
|
||||
return ipcClient.portalMigrateCreate({ appId });
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
setOutput(result.output);
|
||||
showSuccess(
|
||||
"Database migration file generated and committed successfully!",
|
||||
);
|
||||
refreshVersions();
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
setOutput(`Error: ${errorMessage}`);
|
||||
showError(errorMessage);
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreateMigration = () => {
|
||||
setOutput(""); // Clear previous output
|
||||
migrateMutation.mutate();
|
||||
};
|
||||
|
||||
const openDocs = () => {
|
||||
const ipcClient = IpcClient.getInstance();
|
||||
ipcClient.openExternalUrl(
|
||||
"https://www.dyad.sh/docs/templates/portal#create-a-database-migration",
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Database className="w-5 h-5 text-primary" />
|
||||
Portal Database Migration
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Generate a new database migration file for your Portal app.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
onClick={handleCreateMigration}
|
||||
disabled={migrateMutation.isPending}
|
||||
// className="bg-primary hover:bg-purple-700 text-white"
|
||||
>
|
||||
{migrateMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Database className="w-4 h-4 mr-2" />
|
||||
Generate database migration
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={openDocs}
|
||||
className="text-sm"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3 mr-1" />
|
||||
Docs
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{output && (
|
||||
<div className="mt-4">
|
||||
<div className="bg-gray-50 dark:bg-gray-900 border rounded-lg p-3">
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Command Output:
|
||||
</h4>
|
||||
<div className="max-h-64 overflow-auto">
|
||||
<pre className="text-xs text-gray-600 dark:text-gray-400 whitespace-pre-wrap font-mono">
|
||||
{output}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -4,17 +4,22 @@ import { IpcClient } from "@/ipc/ipc_client";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import { CommunityCodeConsentDialog } from "./CommunityCodeConsentDialog";
|
||||
import type { Template } from "@/shared/templates";
|
||||
import { Button } from "./ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { showWarning } from "@/lib/toast";
|
||||
|
||||
interface TemplateCardProps {
|
||||
template: Template;
|
||||
isSelected: boolean;
|
||||
onSelect: (templateId: string) => void;
|
||||
onCreateApp: () => void;
|
||||
}
|
||||
|
||||
export const TemplateCard: React.FC<TemplateCardProps> = ({
|
||||
template,
|
||||
isSelected,
|
||||
onSelect,
|
||||
onCreateApp,
|
||||
}) => {
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const [showConsentDialog, setShowConsentDialog] = useState(false);
|
||||
@@ -26,6 +31,11 @@ export const TemplateCard: React.FC<TemplateCardProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (template.requiresNeon && !settings?.neon?.accessToken) {
|
||||
showWarning("Please connect your Neon account to use this template.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, proceed with selection
|
||||
onSelect(template.id);
|
||||
};
|
||||
@@ -93,7 +103,7 @@ export const TemplateCard: React.FC<TemplateCardProps> = ({
|
||||
>
|
||||
{template.title}
|
||||
</h2>
|
||||
{template.isOfficial && (
|
||||
{template.isOfficial && !template.isExperimental && (
|
||||
<span
|
||||
className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
|
||||
isSelected
|
||||
@@ -104,8 +114,13 @@ export const TemplateCard: React.FC<TemplateCardProps> = ({
|
||||
Official
|
||||
</span>
|
||||
)}
|
||||
{template.isExperimental && (
|
||||
<span className="text-xs font-semibold px-2 py-0.5 rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-700 dark:text-yellow-200">
|
||||
Experimental
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3 h-8 overflow-y-auto">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3 h-10 overflow-y-auto">
|
||||
{template.description}
|
||||
</p>
|
||||
{template.githubUrl && (
|
||||
@@ -121,6 +136,20 @@ export const TemplateCard: React.FC<TemplateCardProps> = ({
|
||||
<ArrowLeft className="w-4 h-4 ml-1 transform rotate-180" />
|
||||
</a>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCreateApp();
|
||||
}}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"w-full bg-indigo-600 hover:bg-indigo-700 text-white font-semibold mt-2",
|
||||
settings?.selectedTemplateId !== template.id && "invisible",
|
||||
)}
|
||||
>
|
||||
Create App
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -120,15 +120,26 @@ export function ChatHeader({
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex items-center gap-1">
|
||||
<strong>Warning:</strong>
|
||||
<span>You are not on a branch</span>
|
||||
<Info size={14} />
|
||||
{isAnyCheckoutVersionInProgress ? (
|
||||
<>
|
||||
<span>
|
||||
Please wait, switching back to latest version...
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<strong>Warning:</strong>
|
||||
<span>You are not on a branch</span>
|
||||
<Info size={14} />
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Checkout main branch, otherwise changes will not be
|
||||
saved properly
|
||||
{isAnyCheckoutVersionInProgress
|
||||
? "Version checkout is currently in progress"
|
||||
: "Checkout main branch, otherwise changes will not be saved properly"}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -152,7 +163,7 @@ export function ChatHeader({
|
||||
>
|
||||
{isRenamingBranch ? "Renaming..." : "Rename master to main"}
|
||||
</Button>
|
||||
) : (
|
||||
) : isAnyCheckoutVersionInProgress && !isCheckingOutVersion ? null : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useAtom, useAtomValue } from "jotai";
|
||||
import { selectedAppIdAtom, selectedVersionIdAtom } from "@/atoms/appAtoms";
|
||||
import { useVersions } from "@/hooks/useVersions";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { RotateCcw, X } from "lucide-react";
|
||||
import { RotateCcw, X, Database, Loader2 } from "lucide-react";
|
||||
import type { Version } from "@/ipc/ipc_types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
import { useRunApp } from "@/hooks/useRunApp";
|
||||
|
||||
interface VersionPaneProps {
|
||||
isVisible: boolean;
|
||||
onClose: () => void;
|
||||
@@ -21,12 +23,15 @@ interface VersionPaneProps {
|
||||
|
||||
export function VersionPane({ isVisible, onClose }: VersionPaneProps) {
|
||||
const appId = useAtomValue(selectedAppIdAtom);
|
||||
const { refreshApp } = useLoadApp(appId);
|
||||
const { refreshApp, app } = useLoadApp(appId);
|
||||
const { restartApp } = useRunApp();
|
||||
const {
|
||||
versions: liveVersions,
|
||||
refreshVersions,
|
||||
revertVersion,
|
||||
isRevertingVersion,
|
||||
} = useVersions(appId);
|
||||
|
||||
const [selectedVersionId, setSelectedVersionId] = useAtom(
|
||||
selectedVersionIdAtom,
|
||||
);
|
||||
@@ -49,6 +54,9 @@ export function VersionPane({ isVisible, onClose }: VersionPaneProps) {
|
||||
setSelectedVersionId(null);
|
||||
if (appId) {
|
||||
await checkoutVersion({ appId, versionId: "main" });
|
||||
if (app?.neonProjectId) {
|
||||
await restartApp();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,16 +84,19 @@ export function VersionPane({ isVisible, onClose }: VersionPaneProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleVersionClick = async (versionOid: string) => {
|
||||
const handleVersionClick = async (version: Version) => {
|
||||
if (appId) {
|
||||
setSelectedVersionId(versionOid);
|
||||
setSelectedVersionId(version.oid);
|
||||
try {
|
||||
await checkoutVersion({ appId, versionId: versionOid });
|
||||
await checkoutVersion({ appId, versionId: version.oid });
|
||||
} catch (error) {
|
||||
console.error("Could not checkout version, unselecting version", error);
|
||||
setSelectedVersionId(null);
|
||||
}
|
||||
await refreshApp();
|
||||
if (version.dbTimestamp) {
|
||||
await restartApp();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -94,21 +105,23 @@ export function VersionPane({ isVisible, onClose }: VersionPaneProps) {
|
||||
return (
|
||||
<div className="h-full border-t border-2 border-border w-full">
|
||||
<div className="p-2 border-b border-border flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold pl-2">Version History</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-(--background-lightest) rounded-md "
|
||||
aria-label="Close version pane"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
<h2 className="text-base font-medium pl-2">Version History</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-(--background-lightest) rounded-md "
|
||||
aria-label="Close version pane"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-y-auto h-[calc(100%-60px)]">
|
||||
{versions.length === 0 ? (
|
||||
<div className="p-4 ">No versions available</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{versions.map((version: Version, index) => (
|
||||
{versions.map((version: Version, index: number) => (
|
||||
<div
|
||||
key={version.oid}
|
||||
className={cn(
|
||||
@@ -121,19 +134,67 @@ export function VersionPane({ isVisible, onClose }: VersionPaneProps) {
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!isCheckingOutVersion) {
|
||||
handleVersionClick(version.oid);
|
||||
handleVersionClick(version);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-xs">
|
||||
Version {versions.length - index}
|
||||
</span>
|
||||
<span className="text-xs opacity-90">
|
||||
{formatDistanceToNow(new Date(version.timestamp * 1000), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-xs">
|
||||
Version {versions.length - index} (
|
||||
{version.oid.slice(0, 7)})
|
||||
</span>
|
||||
{/* example format: '2025-07-25T21:52:01Z' */}
|
||||
{version.dbTimestamp &&
|
||||
(() => {
|
||||
const timestampMs = new Date(
|
||||
version.dbTimestamp,
|
||||
).getTime();
|
||||
const isExpired =
|
||||
Date.now() - timestampMs > 24 * 60 * 60 * 1000;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-md",
|
||||
isExpired
|
||||
? "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
|
||||
: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300",
|
||||
)}
|
||||
>
|
||||
<Database size={10} />
|
||||
<span>DB</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isExpired
|
||||
? "DB snapshot may have expired (older than 24 hours)"
|
||||
: `Database snapshot available at timestamp ${version.dbTimestamp}`}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isCheckingOutVersion &&
|
||||
selectedVersionId === version.oid && (
|
||||
<Loader2
|
||||
size={12}
|
||||
className="animate-spin text-primary"
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs opacity-90">
|
||||
{isCheckingOutVersion && selectedVersionId === version.oid
|
||||
? "Loading..."
|
||||
: formatDistanceToNow(
|
||||
new Date(version.timestamp * 1000),
|
||||
{
|
||||
addSuffix: true,
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{version.message && (
|
||||
@@ -158,30 +219,50 @@ export function VersionPane({ isVisible, onClose }: VersionPaneProps) {
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedVersionId(null);
|
||||
await revertVersion({
|
||||
versionId: version.oid,
|
||||
});
|
||||
// Close the pane after revert to force a refresh on next open
|
||||
onClose();
|
||||
}}
|
||||
className={cn(
|
||||
"invisible mt-1 flex items-center gap-1 px-2 py-0.5 text-sm font-medium bg-(--primary) text-(--primary-foreground) hover:bg-background-lightest rounded-md transition-colors",
|
||||
selectedVersionId === version.oid && "visible",
|
||||
)}
|
||||
aria-label="Restore to this version"
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
<span>Restore</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Restore to this version</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Restore button */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
await revertVersion({
|
||||
versionId: version.oid,
|
||||
});
|
||||
setSelectedVersionId(null);
|
||||
// Close the pane after revert to force a refresh on next open
|
||||
onClose();
|
||||
if (version.dbTimestamp) {
|
||||
await restartApp();
|
||||
}
|
||||
}}
|
||||
disabled={isRevertingVersion}
|
||||
className={cn(
|
||||
"invisible mt-1 flex items-center gap-1 px-2 py-0.5 text-sm font-medium bg-(--primary) text-(--primary-foreground) hover:bg-background-lightest rounded-md transition-colors",
|
||||
selectedVersionId === version.oid && "visible",
|
||||
isRevertingVersion &&
|
||||
"opacity-50 cursor-not-allowed",
|
||||
)}
|
||||
aria-label="Restore to this version"
|
||||
>
|
||||
{isRevertingVersion ? (
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
) : (
|
||||
<RotateCcw size={12} />
|
||||
)}
|
||||
<span>
|
||||
{isRevertingVersion ? "Restoring..." : "Restore"}
|
||||
</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isRevertingVersion
|
||||
? "Restoring to this version..."
|
||||
: "Restore to this version"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { showError, showSuccess } from "@/lib/toast";
|
||||
import { selectedAppIdAtom } from "@/atoms/appAtoms";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { NeonConfigure } from "./NeonConfigure";
|
||||
|
||||
const EnvironmentVariablesTitle = () => (
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -396,6 +397,12 @@ export const ConfigurePanel = () => {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Neon Database Configuration */}
|
||||
{/* Neon Connector */}
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
<NeonConfigure />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
178
src/components/preview_panel/NeonConfigure.tsx
Normal file
178
src/components/preview_panel/NeonConfigure.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
import { Database, GitBranch } from "lucide-react";
|
||||
import { selectedAppIdAtom } from "@/atoms/appAtoms";
|
||||
import { useLoadApp } from "@/hooks/useLoadApp";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import type { GetNeonProjectResponse, NeonBranch } from "@/ipc/ipc_types";
|
||||
import { NeonDisconnectButton } from "@/components/NeonDisconnectButton";
|
||||
|
||||
const getBranchTypeColor = (type: NeonBranch["type"]) => {
|
||||
switch (type) {
|
||||
case "production":
|
||||
return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300";
|
||||
case "development":
|
||||
return "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300";
|
||||
case "snapshot":
|
||||
return "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300";
|
||||
case "preview":
|
||||
return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300";
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
|
||||
export const NeonConfigure = () => {
|
||||
const selectedAppId = useAtomValue(selectedAppIdAtom);
|
||||
const { app } = useLoadApp(selectedAppId);
|
||||
|
||||
// Query to get Neon project information
|
||||
const {
|
||||
data: neonProject,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery<GetNeonProjectResponse, Error>({
|
||||
queryKey: ["neon-project", selectedAppId],
|
||||
queryFn: async () => {
|
||||
if (!selectedAppId) throw new Error("No app selected");
|
||||
const ipcClient = IpcClient.getInstance();
|
||||
return await ipcClient.getNeonProject({ appId: selectedAppId });
|
||||
},
|
||||
enabled: !!selectedAppId && !!app?.neonProjectId,
|
||||
meta: { showErrorToast: true },
|
||||
});
|
||||
|
||||
// Don't show component if app doesn't have Neon project
|
||||
if (!app?.neonProjectId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Database size={20} />
|
||||
Neon Database
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-center py-8">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Loading Neon project information...
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Show error state
|
||||
if (error) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Database size={20} />
|
||||
Neon Database
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-center py-8">
|
||||
<div className="text-sm text-red-500">
|
||||
Error loading Neon project: {error.message}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!neonProject) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database size={20} />
|
||||
Neon Database
|
||||
</div>
|
||||
<NeonDisconnectButton />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Project Information */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Project Information</div>
|
||||
<div className="bg-muted/50 p-3 rounded-md space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Project Name:</span>
|
||||
<span className="font-medium">{neonProject.projectName}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Project ID:</span>
|
||||
<span className="font-mono text-xs">{neonProject.projectId}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Organization:</span>
|
||||
<span className="font-mono text-xs">{neonProject.orgId}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Branches */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium flex items-center gap-2">
|
||||
<GitBranch size={16} />
|
||||
Branches ({neonProject.branches.length})
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{neonProject.branches.map((branch) => (
|
||||
<div
|
||||
key={branch.branchId}
|
||||
className="flex items-center justify-between p-3 border rounded-md"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm truncate">
|
||||
{branch.branchName}
|
||||
</span>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={getBranchTypeColor(branch.type)}
|
||||
>
|
||||
{branch.type}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
ID: {branch.branchId}
|
||||
</div>
|
||||
{branch.parentBranchName && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Parent: {branch.parentBranchName.slice(0, 20)}...
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Updated: {formatDate(branch.lastUpdated)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -413,7 +413,20 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
||||
|
||||
// Display loading state
|
||||
if (loading) {
|
||||
return <div className="p-4 dark:text-gray-300">Loading app preview...</div>;
|
||||
return (
|
||||
<div className="flex flex-col h-full relative">
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center space-y-4 bg-gray-50 dark:bg-gray-950">
|
||||
<div className="relative w-5 h-5 animate-spin">
|
||||
<div className="absolute top-0 left-1/2 transform -translate-x-1/2 w-2 h-2 bg-primary rounded-full"></div>
|
||||
<div className="absolute bottom-0 left-0 w-2 h-2 bg-primary rounded-full opacity-80"></div>
|
||||
<div className="absolute bottom-0 right-0 w-2 h-2 bg-primary rounded-full opacity-60"></div>
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
Preparing app preview...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Display message if no app is selected
|
||||
@@ -565,7 +578,7 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center space-y-4 bg-gray-50 dark:bg-gray-950">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-gray-400 dark:text-gray-500" />
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
Starting up your app...
|
||||
Starting your app server...
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -3,6 +3,7 @@ import { selectedAppIdAtom } from "@/atoms/appAtoms";
|
||||
import { useLoadApp } from "@/hooks/useLoadApp";
|
||||
import { GitHubConnector } from "@/components/GitHubConnector";
|
||||
import { VercelConnector } from "@/components/VercelConnector";
|
||||
import { PortalMigrate } from "@/components/PortalMigrate";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
@@ -78,6 +79,9 @@ export const PublishPanel = () => {
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Portal Section - Show only if app has neon project */}
|
||||
{app.neonProjectId && <PortalMigrate appId={selectedAppId} />}
|
||||
|
||||
{/* GitHub Section */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
import { integer, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
|
||||
import { relations } from "drizzle-orm";
|
||||
|
||||
export const apps = sqliteTable("apps", {
|
||||
@@ -16,6 +16,9 @@ export const apps = sqliteTable("apps", {
|
||||
githubRepo: text("github_repo"),
|
||||
githubBranch: text("github_branch"),
|
||||
supabaseProjectId: text("supabase_project_id"),
|
||||
neonProjectId: text("neon_project_id"),
|
||||
neonDevelopmentBranchId: text("neon_development_branch_id"),
|
||||
neonPreviewBranchId: text("neon_preview_branch_id"),
|
||||
vercelProjectId: text("vercel_project_id"),
|
||||
vercelProjectName: text("vercel_project_name"),
|
||||
vercelTeamId: text("vercel_team_id"),
|
||||
@@ -51,9 +54,32 @@ export const messages = sqliteTable("messages", {
|
||||
.default(sql`(unixepoch())`),
|
||||
});
|
||||
|
||||
export const versions = sqliteTable(
|
||||
"versions",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
appId: integer("app_id")
|
||||
.notNull()
|
||||
.references(() => apps.id, { onDelete: "cascade" }),
|
||||
commitHash: text("commit_hash").notNull(),
|
||||
neonDbTimestamp: text("neon_db_timestamp"),
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
},
|
||||
(table) => [
|
||||
// Unique constraint to prevent duplicate versions
|
||||
unique("versions_app_commit_unique").on(table.appId, table.commitHash),
|
||||
],
|
||||
);
|
||||
|
||||
// Define relations
|
||||
export const appsRelations = relations(apps, ({ many }) => ({
|
||||
chats: many(chats),
|
||||
versions: many(versions),
|
||||
}));
|
||||
|
||||
export const chatsRelations = relations(chats, ({ many, one }) => ({
|
||||
@@ -124,3 +150,10 @@ export const languageModelsRelations = relations(
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
export const versionsRelations = relations(versions, ({ one }) => ({
|
||||
app: one(apps, {
|
||||
fields: [versions.appId],
|
||||
references: [apps.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
38
src/hooks/useCreateApp.ts
Normal file
38
src/hooks/useCreateApp.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import { showError } from "@/lib/toast";
|
||||
import type { CreateAppParams, CreateAppResult } from "@/ipc/ipc_types";
|
||||
|
||||
export function useCreateApp() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation<CreateAppResult, Error, CreateAppParams>({
|
||||
mutationFn: async (params: CreateAppParams) => {
|
||||
if (!params.name.trim()) {
|
||||
throw new Error("App name is required");
|
||||
}
|
||||
|
||||
const ipcClient = IpcClient.getInstance();
|
||||
return ipcClient.createApp(params);
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate apps list to trigger refetch
|
||||
queryClient.invalidateQueries({ queryKey: ["apps"] });
|
||||
},
|
||||
onError: (error) => {
|
||||
showError(error);
|
||||
},
|
||||
});
|
||||
|
||||
const createApp = async (
|
||||
params: CreateAppParams,
|
||||
): Promise<CreateAppResult> => {
|
||||
return mutation.mutateAsync(params);
|
||||
};
|
||||
|
||||
return {
|
||||
createApp,
|
||||
isCreating: mutation.isPending,
|
||||
error: mutation.error,
|
||||
};
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "@/atoms/appAtoms";
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { AppOutput } from "@/ipc/ipc_types";
|
||||
import { showInputRequest } from "@/lib/toast";
|
||||
|
||||
const useRunAppLoadingAtom = atom(false);
|
||||
|
||||
@@ -18,7 +19,7 @@ export function useRunApp() {
|
||||
const [loading, setLoading] = useAtom(useRunAppLoadingAtom);
|
||||
const [app, setApp] = useAtom(currentAppAtom);
|
||||
const setAppOutput = useSetAtom(appOutputAtom);
|
||||
const [appUrlObj, setAppUrlObj] = useAtom(appUrlAtom);
|
||||
const [, setAppUrlObj] = useAtom(appUrlAtom);
|
||||
const setPreviewPanelKey = useSetAtom(previewPanelKeyAtom);
|
||||
const appId = useAtomValue(selectedAppIdAtom);
|
||||
const setPreviewErrorMessage = useSetAtom(previewErrorMessageAtom);
|
||||
@@ -39,47 +40,78 @@ export function useRunApp() {
|
||||
const originalUrl = originalUrlMatch && originalUrlMatch[1];
|
||||
setAppUrlObj({
|
||||
appUrl: proxyUrl,
|
||||
appId: appId!,
|
||||
appId: output.appId,
|
||||
originalUrl: originalUrl!,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
const runApp = useCallback(async (appId: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const ipcClient = IpcClient.getInstance();
|
||||
console.debug("Running app", appId);
|
||||
|
||||
// Clear the URL and add restart message
|
||||
if (appUrlObj?.appId !== appId) {
|
||||
setAppUrlObj({ appUrl: null, appId: null, originalUrl: null });
|
||||
const processAppOutput = useCallback(
|
||||
(output: AppOutput) => {
|
||||
// Handle input requests specially
|
||||
if (output.type === "input-requested") {
|
||||
showInputRequest(output.message, async (response) => {
|
||||
try {
|
||||
const ipcClient = IpcClient.getInstance();
|
||||
await ipcClient.respondToAppInput({
|
||||
appId: output.appId,
|
||||
response,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to respond to app input:", error);
|
||||
}
|
||||
});
|
||||
return; // Don't add to regular output
|
||||
}
|
||||
setAppOutput((prev) => [
|
||||
...prev,
|
||||
{
|
||||
message: "Trying to restart app...",
|
||||
type: "stdout",
|
||||
appId,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
const app = await ipcClient.getApp(appId);
|
||||
setApp(app);
|
||||
await ipcClient.runApp(appId, (output) => {
|
||||
setAppOutput((prev) => [...prev, output]);
|
||||
processProxyServerOutput(output);
|
||||
});
|
||||
setPreviewErrorMessage(undefined);
|
||||
} catch (error) {
|
||||
console.error(`Error running app ${appId}:`, error);
|
||||
setPreviewErrorMessage(
|
||||
error instanceof Error ? error.message : error?.toString(),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Add to regular app output
|
||||
setAppOutput((prev) => [...prev, output]);
|
||||
|
||||
// Process proxy server output
|
||||
processProxyServerOutput(output);
|
||||
},
|
||||
[setAppOutput],
|
||||
);
|
||||
const runApp = useCallback(
|
||||
async (appId: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const ipcClient = IpcClient.getInstance();
|
||||
console.debug("Running app", appId);
|
||||
|
||||
// Clear the URL and add restart message
|
||||
setAppUrlObj((prevAppUrlObj) => {
|
||||
if (prevAppUrlObj?.appId !== appId) {
|
||||
return { appUrl: null, appId: null, originalUrl: null };
|
||||
}
|
||||
return prevAppUrlObj; // No change needed
|
||||
});
|
||||
|
||||
setAppOutput((prev) => [
|
||||
...prev,
|
||||
{
|
||||
message: "Trying to restart app...",
|
||||
type: "stdout",
|
||||
appId,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
const app = await ipcClient.getApp(appId);
|
||||
setApp(app);
|
||||
await ipcClient.runApp(appId, processAppOutput);
|
||||
setPreviewErrorMessage(undefined);
|
||||
} catch (error) {
|
||||
console.error(`Error running app ${appId}:`, error);
|
||||
setPreviewErrorMessage(
|
||||
error instanceof Error ? error.message : error?.toString(),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[processAppOutput],
|
||||
);
|
||||
|
||||
const stopApp = useCallback(async (appId: number) => {
|
||||
if (appId === null) {
|
||||
@@ -139,15 +171,15 @@ export function useRunApp() {
|
||||
await ipcClient.restartApp(
|
||||
appId,
|
||||
(output) => {
|
||||
setAppOutput((prev) => [...prev, output]);
|
||||
// Handle HMR updates before processing
|
||||
if (
|
||||
output.message.includes("hmr update") &&
|
||||
output.message.includes("[vite]")
|
||||
) {
|
||||
onHotModuleReload();
|
||||
return;
|
||||
}
|
||||
processProxyServerOutput(output);
|
||||
// Process normally (including input requests)
|
||||
processAppOutput(output);
|
||||
},
|
||||
removeNodeModules,
|
||||
);
|
||||
@@ -161,7 +193,15 @@ export function useRunApp() {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[appId, setApp, setAppOutput, setAppUrlObj, setPreviewPanelKey],
|
||||
[
|
||||
appId,
|
||||
setApp,
|
||||
setAppOutput,
|
||||
setAppUrlObj,
|
||||
setPreviewPanelKey,
|
||||
processAppOutput,
|
||||
onHotModuleReload,
|
||||
],
|
||||
);
|
||||
|
||||
const refreshAppIframe = useCallback(async () => {
|
||||
|
||||
@@ -5,7 +5,8 @@ import { IpcClient } from "@/ipc/ipc_client";
|
||||
|
||||
import { chatMessagesAtom, selectedChatIdAtom } from "@/atoms/chatAtoms";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Version } from "@/ipc/ipc_types";
|
||||
import type { RevertVersionResponse, Version } from "@/ipc/ipc_types";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function useVersions(appId: number | null) {
|
||||
const [, setVersionsAtom] = useAtom(versionsListAtom);
|
||||
@@ -38,35 +39,42 @@ export function useVersions(appId: number | null) {
|
||||
}
|
||||
}, [versions, setVersionsAtom]);
|
||||
|
||||
const revertVersionMutation = useMutation<void, Error, { versionId: string }>(
|
||||
{
|
||||
mutationFn: async ({ versionId }: { versionId: string }) => {
|
||||
const currentAppId = appId;
|
||||
if (currentAppId === null) {
|
||||
throw new Error("App ID is null");
|
||||
}
|
||||
const ipcClient = IpcClient.getInstance();
|
||||
await ipcClient.revertVersion({
|
||||
appId: currentAppId,
|
||||
previousVersionId: versionId,
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["versions", appId] });
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["currentBranch", appId],
|
||||
});
|
||||
if (selectedChatId) {
|
||||
const chat = await IpcClient.getInstance().getChat(selectedChatId);
|
||||
setMessages(chat.messages);
|
||||
}
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["problems", appId],
|
||||
});
|
||||
},
|
||||
meta: { showErrorToast: true },
|
||||
const revertVersionMutation = useMutation<
|
||||
RevertVersionResponse,
|
||||
Error,
|
||||
{ versionId: string }
|
||||
>({
|
||||
mutationFn: async ({ versionId }: { versionId: string }) => {
|
||||
const currentAppId = appId;
|
||||
if (currentAppId === null) {
|
||||
throw new Error("App ID is null");
|
||||
}
|
||||
const ipcClient = IpcClient.getInstance();
|
||||
return ipcClient.revertVersion({
|
||||
appId: currentAppId,
|
||||
previousVersionId: versionId,
|
||||
});
|
||||
},
|
||||
);
|
||||
onSuccess: async (result) => {
|
||||
if ("successMessage" in result) {
|
||||
toast.success(result.successMessage);
|
||||
} else if ("warningMessage" in result) {
|
||||
toast.warning(result.warningMessage);
|
||||
}
|
||||
await queryClient.invalidateQueries({ queryKey: ["versions", appId] });
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["currentBranch", appId],
|
||||
});
|
||||
if (selectedChatId) {
|
||||
const chat = await IpcClient.getInstance().getChat(selectedChatId);
|
||||
setMessages(chat.messages);
|
||||
}
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["problems", appId],
|
||||
});
|
||||
},
|
||||
meta: { showErrorToast: true },
|
||||
});
|
||||
|
||||
return {
|
||||
versions: versions || [],
|
||||
@@ -74,5 +82,6 @@ export function useVersions(appId: number | null) {
|
||||
error,
|
||||
refreshVersions,
|
||||
revertVersion: revertVersionMutation.mutateAsync,
|
||||
isRevertingVersion: revertVersionMutation.isPending,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,7 +10,11 @@ import { apps } from "../../db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getDyadAppPath } from "../../paths/paths";
|
||||
import { GetAppEnvVarsParams, SetAppEnvVarsParams } from "../ipc_types";
|
||||
import { parseEnvFile, serializeEnvFile } from "../utils/app_env_var_utils";
|
||||
import {
|
||||
ENV_FILE_NAME,
|
||||
parseEnvFile,
|
||||
serializeEnvFile,
|
||||
} from "../utils/app_env_var_utils";
|
||||
|
||||
export function registerAppEnvVarsHandlers() {
|
||||
// Handler to get app environment variables
|
||||
@@ -27,7 +31,7 @@ export function registerAppEnvVarsHandlers() {
|
||||
}
|
||||
|
||||
const appPath = getDyadAppPath(app.path);
|
||||
const envFilePath = path.join(appPath, ".env.local");
|
||||
const envFilePath = path.join(appPath, ENV_FILE_NAME);
|
||||
|
||||
// If .env.local doesn't exist, return empty array
|
||||
try {
|
||||
@@ -63,7 +67,7 @@ export function registerAppEnvVarsHandlers() {
|
||||
}
|
||||
|
||||
const appPath = getDyadAppPath(app.path);
|
||||
const envFilePath = path.join(appPath, ".env.local");
|
||||
const envFilePath = path.join(appPath, ENV_FILE_NAME);
|
||||
|
||||
// Serialize environment variables to .env.local format
|
||||
const content = serializeEnvFile(envVars);
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
RenameBranchParams,
|
||||
CopyAppParams,
|
||||
EditAppFileReturnType,
|
||||
RespondToAppInputParams,
|
||||
} from "../ipc_types";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
@@ -47,6 +48,7 @@ import { safeSend } from "../utils/safe_sender";
|
||||
import { normalizePath } from "../../../shared/normalizePath";
|
||||
import { isServerFunction } from "@/supabase_admin/supabase_utils";
|
||||
import { getVercelTeamSlug } from "../utils/vercel_utils";
|
||||
import { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils";
|
||||
|
||||
async function copyDir(
|
||||
source: string,
|
||||
@@ -80,28 +82,32 @@ async function executeApp({
|
||||
appPath,
|
||||
appId,
|
||||
event, // Keep event for local-node case
|
||||
isNeon,
|
||||
}: {
|
||||
appPath: string;
|
||||
appId: number;
|
||||
event: Electron.IpcMainInvokeEvent;
|
||||
isNeon: boolean;
|
||||
}): Promise<void> {
|
||||
if (proxyWorker) {
|
||||
proxyWorker.terminate();
|
||||
proxyWorker = null;
|
||||
}
|
||||
await executeAppLocalNode({ appPath, appId, event });
|
||||
await executeAppLocalNode({ appPath, appId, event, isNeon });
|
||||
}
|
||||
|
||||
async function executeAppLocalNode({
|
||||
appPath,
|
||||
appId,
|
||||
event,
|
||||
isNeon,
|
||||
}: {
|
||||
appPath: string;
|
||||
appId: number;
|
||||
event: Electron.IpcMainInvokeEvent;
|
||||
isNeon: boolean;
|
||||
}): Promise<void> {
|
||||
const process = spawn(
|
||||
const spawnedProcess = spawn(
|
||||
"(pnpm install && pnpm run dev --port 32100) || (npm install --legacy-peer-deps && npm run dev -- --port 32100)",
|
||||
[],
|
||||
{
|
||||
@@ -113,11 +119,11 @@ async function executeAppLocalNode({
|
||||
);
|
||||
|
||||
// Check if process spawned correctly
|
||||
if (!process.pid) {
|
||||
if (!spawnedProcess.pid) {
|
||||
// Attempt to capture any immediate errors if possible
|
||||
let errorOutput = "";
|
||||
process.stderr?.on("data", (data) => (errorOutput += data));
|
||||
await new Promise((resolve) => process.on("error", resolve)); // Wait for error event
|
||||
spawnedProcess.stderr?.on("data", (data) => (errorOutput += data));
|
||||
await new Promise((resolve) => spawnedProcess.on("error", resolve)); // Wait for error event
|
||||
throw new Error(
|
||||
`Failed to spawn process for app ${appId}. Error: ${
|
||||
errorOutput || "Unknown spawn error"
|
||||
@@ -127,35 +133,69 @@ async function executeAppLocalNode({
|
||||
|
||||
// Increment the counter and store the process reference with its ID
|
||||
const currentProcessId = processCounter.increment();
|
||||
runningApps.set(appId, { process, processId: currentProcessId });
|
||||
runningApps.set(appId, {
|
||||
process: spawnedProcess,
|
||||
processId: currentProcessId,
|
||||
});
|
||||
|
||||
// Log output
|
||||
process.stdout?.on("data", async (data) => {
|
||||
spawnedProcess.stdout?.on("data", async (data) => {
|
||||
const message = util.stripVTControlCharacters(data.toString());
|
||||
logger.debug(`App ${appId} (PID: ${process.pid}) stdout: ${message}`);
|
||||
logger.debug(
|
||||
`App ${appId} (PID: ${spawnedProcess.pid}) stdout: ${message}`,
|
||||
);
|
||||
|
||||
safeSend(event.sender, "app:output", {
|
||||
type: "stdout",
|
||||
message,
|
||||
appId,
|
||||
});
|
||||
const urlMatch = message.match(/(https?:\/\/localhost:\d+\/?)/);
|
||||
if (urlMatch) {
|
||||
proxyWorker = await startProxy(urlMatch[1], {
|
||||
onStarted: (proxyUrl) => {
|
||||
safeSend(event.sender, "app:output", {
|
||||
type: "stdout",
|
||||
message: `[dyad-proxy-server]started=[${proxyUrl}] original=[${urlMatch[1]}]`,
|
||||
appId,
|
||||
});
|
||||
},
|
||||
// This is a hacky heuristic to pick up when drizzle is asking for user
|
||||
// to select from one of a few choices. We automatically pick the first
|
||||
// option because it's usually a good default choice. We guard this with
|
||||
// isNeon because: 1) only Neon apps (for the official Dyad templates) should
|
||||
// get this template and 2) it's safer to do this with Neon apps because
|
||||
// their databases have point in time restore built-in.
|
||||
if (isNeon && message.includes("created or renamed from another")) {
|
||||
spawnedProcess.stdin.write(`\r\n`);
|
||||
logger.info(
|
||||
`App ${appId} (PID: ${spawnedProcess.pid}) wrote enter to stdin to automatically respond to drizzle push input`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check if this is an interactive prompt requiring user input
|
||||
const inputRequestPattern = /\s*›\s*\([yY]\/[nN]\)\s*$/;
|
||||
const isInputRequest = inputRequestPattern.test(message);
|
||||
if (isInputRequest) {
|
||||
// Send special input-requested event for interactive prompts
|
||||
safeSend(event.sender, "app:output", {
|
||||
type: "input-requested",
|
||||
message,
|
||||
appId,
|
||||
});
|
||||
} else {
|
||||
// Normal stdout handling
|
||||
safeSend(event.sender, "app:output", {
|
||||
type: "stdout",
|
||||
message,
|
||||
appId,
|
||||
});
|
||||
|
||||
const urlMatch = message.match(/(https?:\/\/localhost:\d+\/?)/);
|
||||
if (urlMatch) {
|
||||
proxyWorker = await startProxy(urlMatch[1], {
|
||||
onStarted: (proxyUrl) => {
|
||||
safeSend(event.sender, "app:output", {
|
||||
type: "stdout",
|
||||
message: `[dyad-proxy-server]started=[${proxyUrl}] original=[${urlMatch[1]}]`,
|
||||
appId,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
process.stderr?.on("data", (data) => {
|
||||
spawnedProcess.stderr?.on("data", (data) => {
|
||||
const message = util.stripVTControlCharacters(data.toString());
|
||||
logger.error(`App ${appId} (PID: ${process.pid}) stderr: ${message}`);
|
||||
logger.error(
|
||||
`App ${appId} (PID: ${spawnedProcess.pid}) stderr: ${message}`,
|
||||
);
|
||||
safeSend(event.sender, "app:output", {
|
||||
type: "stderr",
|
||||
message,
|
||||
@@ -164,19 +204,19 @@ async function executeAppLocalNode({
|
||||
});
|
||||
|
||||
// Handle process exit/close
|
||||
process.on("close", (code, signal) => {
|
||||
spawnedProcess.on("close", (code, signal) => {
|
||||
logger.log(
|
||||
`App ${appId} (PID: ${process.pid}) process closed with code ${code}, signal ${signal}.`,
|
||||
`App ${appId} (PID: ${spawnedProcess.pid}) process closed with code ${code}, signal ${signal}.`,
|
||||
);
|
||||
removeAppIfCurrentProcess(appId, process);
|
||||
removeAppIfCurrentProcess(appId, spawnedProcess);
|
||||
});
|
||||
|
||||
// Handle errors during process lifecycle (e.g., command not found)
|
||||
process.on("error", (err) => {
|
||||
spawnedProcess.on("error", (err) => {
|
||||
logger.error(
|
||||
`Error in app ${appId} (PID: ${process.pid}) process: ${err.message}`,
|
||||
`Error in app ${appId} (PID: ${spawnedProcess.pid}) process: ${err.message}`,
|
||||
);
|
||||
removeAppIfCurrentProcess(appId, process);
|
||||
removeAppIfCurrentProcess(appId, spawnedProcess);
|
||||
// Note: We don't throw here as the error is asynchronous. The caller got a success response already.
|
||||
// Consider adding ipcRenderer event emission to notify UI of the error.
|
||||
});
|
||||
@@ -466,7 +506,12 @@ export function registerAppHandlers() {
|
||||
try {
|
||||
// Kill any orphaned process on port 32100 (in case previous run left it)
|
||||
await killProcessOnPort(32100);
|
||||
await executeApp({ appPath, appId, event });
|
||||
await executeApp({
|
||||
appPath,
|
||||
appId,
|
||||
event,
|
||||
isNeon: !!app.neonProjectId,
|
||||
});
|
||||
|
||||
return;
|
||||
} catch (error: any) {
|
||||
@@ -596,7 +641,12 @@ export function registerAppHandlers() {
|
||||
`Executing app ${appId} in path ${app.path} after restart request`,
|
||||
); // Adjusted log
|
||||
|
||||
await executeApp({ appPath, appId, event }); // This will handle starting either mode
|
||||
await executeApp({
|
||||
appPath,
|
||||
appId,
|
||||
event,
|
||||
isNeon: !!app.neonProjectId,
|
||||
}); // This will handle starting either mode
|
||||
|
||||
return;
|
||||
} catch (error) {
|
||||
@@ -633,6 +683,23 @@ export function registerAppHandlers() {
|
||||
throw new Error("Invalid file path");
|
||||
}
|
||||
|
||||
if (app.neonProjectId && app.neonDevelopmentBranchId) {
|
||||
try {
|
||||
await storeDbTimestampAtCurrentVersion({
|
||||
appId: app.id,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"Error storing Neon timestamp at current version:",
|
||||
error,
|
||||
);
|
||||
throw new Error(
|
||||
"Could not store Neon timestamp at current version; database versioning functionality is not working: " +
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
const dirPath = path.dirname(fullPath);
|
||||
await fsPromises.mkdir(dirPath, { recursive: true });
|
||||
@@ -968,4 +1035,33 @@ export function registerAppHandlers() {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
handle(
|
||||
"respond-to-app-input",
|
||||
async (_, { appId, response }: RespondToAppInputParams) => {
|
||||
if (response !== "y" && response !== "n") {
|
||||
throw new Error(`Invalid response: ${response}`);
|
||||
}
|
||||
const appInfo = runningApps.get(appId);
|
||||
|
||||
if (!appInfo) {
|
||||
throw new Error(`App ${appId} is not running`);
|
||||
}
|
||||
|
||||
const { process } = appInfo;
|
||||
|
||||
if (!process.stdin) {
|
||||
throw new Error(`App ${appId} process has no stdin available`);
|
||||
}
|
||||
|
||||
try {
|
||||
// Write the response to stdin with a newline
|
||||
process.stdin.write(`${response}\n`);
|
||||
logger.debug(`Sent response '${response}' to app ${appId} stdin`);
|
||||
} catch (error: any) {
|
||||
logger.error(`Error sending response to app ${appId}:`, error);
|
||||
throw new Error(`Failed to send response to app: ${error.message}`);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -456,7 +456,10 @@ ${componentSnippet}
|
||||
(await getSupabaseContext({
|
||||
supabaseProjectId: updatedChat.app.supabaseProjectId,
|
||||
}));
|
||||
} else {
|
||||
} else if (
|
||||
// Neon projects don't need Supabase.
|
||||
!updatedChat.app?.neonProjectId
|
||||
) {
|
||||
systemPrompt += "\n\n" + SUPABASE_NOT_AVAILABLE_SYSTEM_PROMPT;
|
||||
}
|
||||
const isSummarizeIntent = req.prompt.startsWith(
|
||||
|
||||
235
src/ipc/handlers/neon_handlers.ts
Normal file
235
src/ipc/handlers/neon_handlers.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import log from "electron-log";
|
||||
|
||||
import { createTestOnlyLoggedHandler } from "./safe_handle";
|
||||
import { handleNeonOAuthReturn } from "../../neon_admin/neon_return_handler";
|
||||
import {
|
||||
getNeonClient,
|
||||
getNeonErrorMessage,
|
||||
getNeonOrganizationId,
|
||||
} from "../../neon_admin/neon_management_client";
|
||||
import {
|
||||
CreateNeonProjectParams,
|
||||
NeonProject,
|
||||
GetNeonProjectParams,
|
||||
GetNeonProjectResponse,
|
||||
NeonBranch,
|
||||
} from "../ipc_types";
|
||||
import { db } from "../../db";
|
||||
import { apps } from "../../db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { ipcMain } from "electron";
|
||||
import { EndpointType } from "@neondatabase/api-client";
|
||||
import { retryOnLocked } from "../utils/retryOnLocked";
|
||||
|
||||
export const logger = log.scope("neon_handlers");
|
||||
|
||||
const testOnlyHandle = createTestOnlyLoggedHandler(logger);
|
||||
|
||||
export function registerNeonHandlers() {
|
||||
// Do not use log handler because there's sensitive data in the response
|
||||
ipcMain.handle(
|
||||
"neon:create-project",
|
||||
async (
|
||||
_,
|
||||
{ name, appId }: CreateNeonProjectParams,
|
||||
): Promise<NeonProject> => {
|
||||
const neonClient = await getNeonClient();
|
||||
|
||||
logger.info(`Creating Neon project: ${name} for app ${appId}`);
|
||||
|
||||
try {
|
||||
// Get the organization ID
|
||||
const orgId = await getNeonOrganizationId();
|
||||
|
||||
// Create project with retry on locked errors
|
||||
const response = await retryOnLocked(
|
||||
() =>
|
||||
neonClient.createProject({
|
||||
project: {
|
||||
name: name,
|
||||
org_id: orgId,
|
||||
},
|
||||
}),
|
||||
`Create project ${name} for app ${appId}`,
|
||||
);
|
||||
|
||||
if (!response.data.project) {
|
||||
throw new Error(
|
||||
"Failed to create project: No project data returned.",
|
||||
);
|
||||
}
|
||||
|
||||
const project = response.data.project;
|
||||
const developmentBranch = response.data.branch;
|
||||
|
||||
const previewBranchResponse = await retryOnLocked(
|
||||
() =>
|
||||
neonClient.createProjectBranch(project.id, {
|
||||
endpoints: [{ type: EndpointType.ReadOnly }],
|
||||
branch: {
|
||||
name: "preview",
|
||||
parent_id: developmentBranch.id,
|
||||
},
|
||||
}),
|
||||
`Create preview branch for project ${project.id}`,
|
||||
);
|
||||
|
||||
if (
|
||||
!previewBranchResponse.data.branch ||
|
||||
!previewBranchResponse.data.connection_uris
|
||||
) {
|
||||
throw new Error(
|
||||
"Failed to create preview branch: No branch data returned.",
|
||||
);
|
||||
}
|
||||
|
||||
const previewBranch = previewBranchResponse.data.branch;
|
||||
|
||||
// Store project and branch info in the app's DB row
|
||||
await db
|
||||
.update(apps)
|
||||
.set({
|
||||
neonProjectId: project.id,
|
||||
neonDevelopmentBranchId: developmentBranch.id,
|
||||
neonPreviewBranchId: previewBranch.id,
|
||||
})
|
||||
.where(eq(apps.id, appId));
|
||||
|
||||
logger.info(
|
||||
`Successfully created Neon project: ${project.id} and development branch: ${developmentBranch.id} for app ${appId}`,
|
||||
);
|
||||
return {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
connectionString: response.data.connection_uris[0].connection_uri,
|
||||
branchId: developmentBranch.id,
|
||||
};
|
||||
} catch (error: any) {
|
||||
const errorMessage = getNeonErrorMessage(error);
|
||||
const message = `Failed to create Neon project for app ${appId}: ${errorMessage}`;
|
||||
logger.error(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
"neon:get-project",
|
||||
async (
|
||||
_,
|
||||
{ appId }: GetNeonProjectParams,
|
||||
): Promise<GetNeonProjectResponse> => {
|
||||
logger.info(`Getting Neon project info for app ${appId}`);
|
||||
|
||||
try {
|
||||
// Get the app from the database to find the neonProjectId and neonBranchId
|
||||
const app = await db
|
||||
.select()
|
||||
.from(apps)
|
||||
.where(eq(apps.id, appId))
|
||||
.limit(1);
|
||||
|
||||
if (app.length === 0) {
|
||||
throw new Error(`App with ID ${appId} not found`);
|
||||
}
|
||||
|
||||
const appData = app[0];
|
||||
if (!appData.neonProjectId) {
|
||||
throw new Error(`No Neon project found for app ${appId}`);
|
||||
}
|
||||
|
||||
const neonClient = await getNeonClient();
|
||||
console.log("PROJECT ID", appData.neonProjectId);
|
||||
|
||||
// Get project info
|
||||
const projectResponse = await neonClient.getProject(
|
||||
appData.neonProjectId,
|
||||
);
|
||||
|
||||
if (!projectResponse.data.project) {
|
||||
throw new Error("Failed to get project: No project data returned.");
|
||||
}
|
||||
|
||||
const project = projectResponse.data.project;
|
||||
|
||||
// Get list of branches
|
||||
const branchesResponse = await neonClient.listProjectBranches({
|
||||
projectId: appData.neonProjectId,
|
||||
});
|
||||
|
||||
if (!branchesResponse.data.branches) {
|
||||
throw new Error("Failed to get branches: No branch data returned.");
|
||||
}
|
||||
|
||||
// Map branches to our format
|
||||
const branches: NeonBranch[] = branchesResponse.data.branches.map(
|
||||
(branch) => {
|
||||
let type: "production" | "development" | "snapshot" | "preview";
|
||||
|
||||
if (branch.default) {
|
||||
type = "production";
|
||||
} else if (branch.id === appData.neonDevelopmentBranchId) {
|
||||
type = "development";
|
||||
} else if (branch.id === appData.neonPreviewBranchId) {
|
||||
type = "preview";
|
||||
} else {
|
||||
type = "snapshot";
|
||||
}
|
||||
|
||||
// Find parent branch name if parent_id exists
|
||||
let parentBranchName: string | undefined;
|
||||
if (branch.parent_id) {
|
||||
const parentBranch = branchesResponse.data.branches?.find(
|
||||
(b) => b.id === branch.parent_id,
|
||||
);
|
||||
parentBranchName = parentBranch?.name;
|
||||
}
|
||||
|
||||
return {
|
||||
type,
|
||||
branchId: branch.id,
|
||||
branchName: branch.name,
|
||||
lastUpdated: branch.updated_at,
|
||||
parentBranchId: branch.parent_id,
|
||||
parentBranchName,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`Successfully retrieved Neon project info for app ${appId}`,
|
||||
);
|
||||
|
||||
return {
|
||||
projectId: project.id,
|
||||
projectName: project.name,
|
||||
orgId: project.org_id ?? "<unknown_org_id>",
|
||||
branches,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to get Neon project info for app ${appId}:`,
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
testOnlyHandle("neon:fake-connect", async (event) => {
|
||||
// Call handleNeonOAuthReturn with fake data
|
||||
handleNeonOAuthReturn({
|
||||
token: "fake-neon-access-token",
|
||||
refreshToken: "fake-neon-refresh-token",
|
||||
expiresIn: 3600, // 1 hour
|
||||
});
|
||||
logger.info("Called handleNeonOAuthReturn with fake data during testing.");
|
||||
|
||||
// Simulate the deep link event
|
||||
event.sender.send("deep-link-received", {
|
||||
type: "neon-oauth-return",
|
||||
url: "https://oauth.dyad.sh/api/integrations/neon/login",
|
||||
});
|
||||
logger.info("Sent fake neon deep-link-received event during testing.");
|
||||
});
|
||||
}
|
||||
138
src/ipc/handlers/portal_handlers.ts
Normal file
138
src/ipc/handlers/portal_handlers.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { createLoggedHandler } from "./safe_handle";
|
||||
import log from "electron-log";
|
||||
import { db } from "../../db";
|
||||
import { apps } from "../../db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getDyadAppPath } from "../../paths/paths";
|
||||
import { spawn } from "child_process";
|
||||
import fs from "node:fs";
|
||||
import git from "isomorphic-git";
|
||||
import { gitCommit } from "../utils/git_utils";
|
||||
import { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils";
|
||||
|
||||
const logger = log.scope("portal_handlers");
|
||||
const handle = createLoggedHandler(logger);
|
||||
|
||||
async function getApp(appId: number) {
|
||||
const app = await db.query.apps.findFirst({
|
||||
where: eq(apps.id, appId),
|
||||
});
|
||||
if (!app) {
|
||||
throw new Error(`App with id ${appId} not found`);
|
||||
}
|
||||
return app;
|
||||
}
|
||||
|
||||
export function registerPortalHandlers() {
|
||||
handle(
|
||||
"portal:migrate-create",
|
||||
async (_, { appId }: { appId: number }): Promise<{ output: string }> => {
|
||||
const app = await getApp(appId);
|
||||
const appPath = getDyadAppPath(app.path);
|
||||
|
||||
// Run the migration command
|
||||
const migrationOutput = await new Promise<string>((resolve, reject) => {
|
||||
logger.info(`Running migrate:create for app ${appId} at ${appPath}`);
|
||||
|
||||
const process = spawn("npm run migrate:create -- --skip-empty", {
|
||||
cwd: appPath,
|
||||
shell: true,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
process.stdout?.on("data", (data) => {
|
||||
const output = data.toString();
|
||||
stdout += output;
|
||||
logger.info(`migrate:create stdout: ${output}`);
|
||||
if (output.includes("created or renamed from another")) {
|
||||
process.stdin.write(`\r\n`);
|
||||
logger.info(
|
||||
`App ${appId} (PID: ${process.pid}) wrote enter to stdin to automatically respond to drizzle migrate input`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
process.stderr?.on("data", (data) => {
|
||||
const output = data.toString();
|
||||
stderr += output;
|
||||
logger.warn(`migrate:create stderr: ${output}`);
|
||||
});
|
||||
|
||||
process.on("close", (code) => {
|
||||
const combinedOutput =
|
||||
stdout + (stderr ? `\n\nErrors/Warnings:\n${stderr}` : "");
|
||||
|
||||
if (code === 0) {
|
||||
if (stdout.includes("Migration created at")) {
|
||||
logger.info(
|
||||
`migrate:create completed successfully for app ${appId}`,
|
||||
);
|
||||
resolve(combinedOutput);
|
||||
} else {
|
||||
logger.error(
|
||||
`migrate:create completed successfully for app ${appId} but no migration was created`,
|
||||
);
|
||||
reject(
|
||||
new Error(
|
||||
"No migration was created because no changes were found.",
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
logger.error(
|
||||
`migrate:create failed for app ${appId} with exit code ${code}`,
|
||||
);
|
||||
const errorMessage = `Migration creation failed (exit code ${code})\n\n${combinedOutput}`;
|
||||
reject(new Error(errorMessage));
|
||||
}
|
||||
});
|
||||
|
||||
process.on("error", (err) => {
|
||||
logger.error(`Failed to spawn migrate:create for app ${appId}:`, err);
|
||||
const errorMessage = `Failed to run migration command: ${err.message}\n\nOutput:\n${stdout}\n\nErrors:\n${stderr}`;
|
||||
reject(new Error(errorMessage));
|
||||
});
|
||||
});
|
||||
|
||||
if (app.neonProjectId && app.neonDevelopmentBranchId) {
|
||||
try {
|
||||
await storeDbTimestampAtCurrentVersion({
|
||||
appId: app.id,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"Error storing Neon timestamp at current version:",
|
||||
error,
|
||||
);
|
||||
throw new Error(
|
||||
"Could not store Neon timestamp at current version; database versioning functionality is not working: " +
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Stage all changes and commit
|
||||
try {
|
||||
await git.add({
|
||||
fs,
|
||||
dir: appPath,
|
||||
filepath: ".",
|
||||
});
|
||||
|
||||
const commitHash = await gitCommit({
|
||||
path: appPath,
|
||||
message: "[dyad] Generate database migration file",
|
||||
});
|
||||
|
||||
logger.info(`Successfully committed migration changes: ${commitHash}`);
|
||||
return { output: migrationOutput };
|
||||
} catch (gitError) {
|
||||
logger.error(`Migration created but failed to commit: ${gitError}`);
|
||||
throw new Error(`Migration created but failed to commit: ${gitError}`);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -65,7 +65,10 @@ export function registerTokenCountHandlers() {
|
||||
supabaseContext = await getSupabaseContext({
|
||||
supabaseProjectId: chat.app.supabaseProjectId,
|
||||
});
|
||||
} else {
|
||||
} else if (
|
||||
// Neon projects don't need Supabase.
|
||||
!chat.app?.neonProjectId
|
||||
) {
|
||||
systemPrompt += "\n\n" + SUPABASE_NOT_AVAILABLE_SYSTEM_PROMPT;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { db } from "../../db";
|
||||
import { apps, messages } from "../../db/schema";
|
||||
import { apps, messages, versions } from "../../db/schema";
|
||||
import { desc, eq, and, gt } from "drizzle-orm";
|
||||
import type { Version, BranchResult } from "../ipc_types";
|
||||
import type {
|
||||
Version,
|
||||
BranchResult,
|
||||
RevertVersionParams,
|
||||
RevertVersionResponse,
|
||||
} from "../ipc_types";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { getDyadAppPath } from "../../paths/paths";
|
||||
@@ -11,10 +16,51 @@ import log from "electron-log";
|
||||
import { createLoggedHandler } from "./safe_handle";
|
||||
import { gitCheckout, gitCommit, gitStageToRevert } from "../utils/git_utils";
|
||||
|
||||
import {
|
||||
getNeonClient,
|
||||
getNeonErrorMessage,
|
||||
} from "../../neon_admin/neon_management_client";
|
||||
import {
|
||||
updatePostgresUrlEnvVar,
|
||||
updateDbPushEnvVar,
|
||||
} from "../utils/app_env_var_utils";
|
||||
import { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils";
|
||||
import { retryOnLocked } from "../utils/retryOnLocked";
|
||||
|
||||
const logger = log.scope("version_handlers");
|
||||
|
||||
const handle = createLoggedHandler(logger);
|
||||
|
||||
async function restoreBranchForPreview({
|
||||
appId,
|
||||
dbTimestamp,
|
||||
neonProjectId,
|
||||
previewBranchId,
|
||||
developmentBranchId,
|
||||
}: {
|
||||
appId: number;
|
||||
dbTimestamp: string;
|
||||
neonProjectId: string;
|
||||
previewBranchId: string;
|
||||
developmentBranchId: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const neonClient = await getNeonClient();
|
||||
await retryOnLocked(
|
||||
() =>
|
||||
neonClient.restoreProjectBranch(neonProjectId, previewBranchId, {
|
||||
source_branch_id: developmentBranchId,
|
||||
source_timestamp: dbTimestamp,
|
||||
}),
|
||||
`Restore preview branch ${previewBranchId} for app ${appId}`,
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage = getNeonErrorMessage(error);
|
||||
logger.error("Error in restoreBranchForPreview:", errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerVersionHandlers() {
|
||||
handle("list-versions", async (_, { appId }: { appId: number }) => {
|
||||
const app = await db.query.apps.findFirst({
|
||||
@@ -40,11 +86,32 @@ export function registerVersionHandlers() {
|
||||
depth: 100_000, // Limit to last 100_000 commits for performance
|
||||
});
|
||||
|
||||
return commits.map((commit: ReadCommitResult) => ({
|
||||
oid: commit.oid,
|
||||
message: commit.commit.message,
|
||||
timestamp: commit.commit.author.timestamp,
|
||||
})) satisfies Version[];
|
||||
// Get all snapshots for this app to match with commits
|
||||
const appSnapshots = await db.query.versions.findMany({
|
||||
where: eq(versions.appId, appId),
|
||||
});
|
||||
|
||||
// Create a map of commitHash -> snapshot info for quick lookup
|
||||
const snapshotMap = new Map<
|
||||
string,
|
||||
{ neonDbTimestamp: string | null; createdAt: Date }
|
||||
>();
|
||||
for (const snapshot of appSnapshots) {
|
||||
snapshotMap.set(snapshot.commitHash, {
|
||||
neonDbTimestamp: snapshot.neonDbTimestamp,
|
||||
createdAt: snapshot.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
return commits.map((commit: ReadCommitResult) => {
|
||||
const snapshotInfo = snapshotMap.get(commit.oid);
|
||||
return {
|
||||
oid: commit.oid,
|
||||
message: commit.commit.message,
|
||||
timestamp: commit.commit.author.timestamp,
|
||||
dbTimestamp: snapshotInfo?.neonDbTimestamp,
|
||||
};
|
||||
}) satisfies Version[];
|
||||
});
|
||||
|
||||
handle(
|
||||
@@ -86,12 +153,11 @@ export function registerVersionHandlers() {
|
||||
"revert-version",
|
||||
async (
|
||||
_,
|
||||
{
|
||||
appId,
|
||||
previousVersionId,
|
||||
}: { appId: number; previousVersionId: string },
|
||||
): Promise<void> => {
|
||||
{ appId, previousVersionId }: RevertVersionParams,
|
||||
): Promise<RevertVersionResponse> => {
|
||||
return withLock(appId, async () => {
|
||||
let successMessage = "Restored version";
|
||||
let warningMessage: string | undefined = undefined;
|
||||
const app = await db.query.apps.findFirst({
|
||||
where: eq(apps.id, appId),
|
||||
});
|
||||
@@ -101,12 +167,26 @@ export function registerVersionHandlers() {
|
||||
}
|
||||
|
||||
const appPath = getDyadAppPath(app.path);
|
||||
// Get the current commit hash before reverting
|
||||
const currentCommitHash = await git.resolveRef({
|
||||
fs,
|
||||
dir: appPath,
|
||||
ref: "main",
|
||||
});
|
||||
|
||||
await gitCheckout({
|
||||
path: appPath,
|
||||
ref: "main",
|
||||
});
|
||||
|
||||
if (app.neonProjectId && app.neonDevelopmentBranchId) {
|
||||
// We are going to add a new commit on top, so let's store
|
||||
// the current timestamp at the current version.
|
||||
await storeDbTimestampAtCurrentVersion({
|
||||
appId,
|
||||
});
|
||||
}
|
||||
|
||||
await gitStageToRevert({
|
||||
path: appPath,
|
||||
targetOid: previousVersionId,
|
||||
@@ -154,6 +234,87 @@ export function registerVersionHandlers() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (app.neonProjectId && app.neonDevelopmentBranchId) {
|
||||
const version = await db.query.versions.findFirst({
|
||||
where: and(
|
||||
eq(versions.appId, appId),
|
||||
eq(versions.commitHash, previousVersionId),
|
||||
),
|
||||
});
|
||||
if (version && version.neonDbTimestamp) {
|
||||
try {
|
||||
const preserveBranchName = `preserve_${currentCommitHash}-${Date.now()}`;
|
||||
const neonClient = await getNeonClient();
|
||||
const response = await retryOnLocked(
|
||||
() =>
|
||||
neonClient.restoreProjectBranch(
|
||||
app.neonProjectId!,
|
||||
app.neonDevelopmentBranchId!,
|
||||
{
|
||||
source_branch_id: app.neonDevelopmentBranchId!,
|
||||
source_timestamp: version.neonDbTimestamp!,
|
||||
preserve_under_name: preserveBranchName,
|
||||
},
|
||||
),
|
||||
`Restore development branch ${app.neonDevelopmentBranchId} for app ${appId}`,
|
||||
);
|
||||
// Update all versions which have a newer DB timestamp than the version we're restoring to
|
||||
// and remove their DB timestamp.
|
||||
await db
|
||||
.update(versions)
|
||||
.set({ neonDbTimestamp: null })
|
||||
.where(
|
||||
and(
|
||||
eq(versions.appId, appId),
|
||||
gt(versions.neonDbTimestamp, version.neonDbTimestamp),
|
||||
),
|
||||
);
|
||||
|
||||
const preserveBranchId = response.data.branch.parent_id;
|
||||
if (!preserveBranchId) {
|
||||
throw new Error("Preserve branch ID not found");
|
||||
}
|
||||
logger.info(
|
||||
`Deleting preserve branch ${preserveBranchId} for app ${appId}`,
|
||||
);
|
||||
try {
|
||||
// Intentionally do not await this because it's not
|
||||
// critical for the restore operation, it's to clean up branches
|
||||
// so the user doesn't hit the branch limit later.
|
||||
retryOnLocked(
|
||||
() =>
|
||||
neonClient.deleteProjectBranch(
|
||||
app.neonProjectId!,
|
||||
preserveBranchId,
|
||||
),
|
||||
`Delete preserve branch ${preserveBranchId} for app ${appId}`,
|
||||
{ retryBranchWithChildError: true },
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage = getNeonErrorMessage(error);
|
||||
logger.error("Error in deleteProjectBranch:", errorMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = getNeonErrorMessage(error);
|
||||
logger.error("Error in restoreBranchForCheckout:", errorMessage);
|
||||
warningMessage = `Could not restore database because of error: ${errorMessage}`;
|
||||
// Do not throw, so we can finish switching the postgres branch
|
||||
// It might throw because they picked a timestamp that's too old.
|
||||
}
|
||||
successMessage =
|
||||
"Successfully restored to version (including database)";
|
||||
}
|
||||
await switchPostgresToDevelopmentBranch({
|
||||
neonProjectId: app.neonProjectId,
|
||||
neonDevelopmentBranchId: app.neonDevelopmentBranchId,
|
||||
appPath: app.path,
|
||||
});
|
||||
}
|
||||
if (warningMessage) {
|
||||
return { warningMessage };
|
||||
}
|
||||
return { successMessage };
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -162,7 +323,7 @@ export function registerVersionHandlers() {
|
||||
"checkout-version",
|
||||
async (
|
||||
_,
|
||||
{ appId, versionId }: { appId: number; versionId: string },
|
||||
{ appId, versionId: gitRef }: { appId: number; versionId: string },
|
||||
): Promise<void> => {
|
||||
return withLock(appId, async () => {
|
||||
const app = await db.query.apps.findFirst({
|
||||
@@ -173,13 +334,106 @@ export function registerVersionHandlers() {
|
||||
throw new Error("App not found");
|
||||
}
|
||||
|
||||
const appPath = getDyadAppPath(app.path);
|
||||
if (
|
||||
app.neonProjectId &&
|
||||
app.neonDevelopmentBranchId &&
|
||||
app.neonPreviewBranchId
|
||||
) {
|
||||
if (gitRef === "main") {
|
||||
logger.info(
|
||||
`Switching Postgres to development branch for app ${appId}`,
|
||||
);
|
||||
await switchPostgresToDevelopmentBranch({
|
||||
neonProjectId: app.neonProjectId,
|
||||
neonDevelopmentBranchId: app.neonDevelopmentBranchId,
|
||||
appPath: app.path,
|
||||
});
|
||||
} else {
|
||||
logger.info(
|
||||
`Switching Postgres to preview branch for app ${appId}`,
|
||||
);
|
||||
|
||||
// Regardless of whether we have a timestamp or not, we want to disable DB push
|
||||
// while we're checking out an earlier version
|
||||
await updateDbPushEnvVar({
|
||||
appPath: app.path,
|
||||
disabled: true,
|
||||
});
|
||||
|
||||
const version = await db.query.versions.findFirst({
|
||||
where: and(
|
||||
eq(versions.appId, appId),
|
||||
eq(versions.commitHash, gitRef),
|
||||
),
|
||||
});
|
||||
|
||||
if (version && version.neonDbTimestamp) {
|
||||
// SWITCH the env var for POSTGRES_URL to the preview branch
|
||||
const neonClient = await getNeonClient();
|
||||
const connectionUri = await neonClient.getConnectionUri({
|
||||
projectId: app.neonProjectId,
|
||||
branch_id: app.neonPreviewBranchId,
|
||||
// This is the default database name for Neon
|
||||
database_name: "neondb",
|
||||
// This is the default role name for Neon
|
||||
role_name: "neondb_owner",
|
||||
});
|
||||
|
||||
await restoreBranchForPreview({
|
||||
appId,
|
||||
dbTimestamp: version.neonDbTimestamp,
|
||||
neonProjectId: app.neonProjectId,
|
||||
previewBranchId: app.neonPreviewBranchId,
|
||||
developmentBranchId: app.neonDevelopmentBranchId,
|
||||
});
|
||||
|
||||
await updatePostgresUrlEnvVar({
|
||||
appPath: app.path,
|
||||
connectionUri: connectionUri.data.uri,
|
||||
});
|
||||
logger.info(
|
||||
`Switched Postgres to preview branch for app ${appId} commit ${version.commitHash} dbTimestamp=${version.neonDbTimestamp}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const fullAppPath = getDyadAppPath(app.path);
|
||||
await gitCheckout({
|
||||
path: appPath,
|
||||
ref: versionId,
|
||||
path: fullAppPath,
|
||||
ref: gitRef,
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function switchPostgresToDevelopmentBranch({
|
||||
neonProjectId,
|
||||
neonDevelopmentBranchId,
|
||||
appPath,
|
||||
}: {
|
||||
neonProjectId: string;
|
||||
neonDevelopmentBranchId: string;
|
||||
appPath: string;
|
||||
}) {
|
||||
// SWITCH the env var for POSTGRES_URL to the development branch
|
||||
const neonClient = await getNeonClient();
|
||||
const connectionUri = await neonClient.getConnectionUri({
|
||||
projectId: neonProjectId,
|
||||
branch_id: neonDevelopmentBranchId,
|
||||
// This is the default database name for Neon
|
||||
database_name: "neondb",
|
||||
// This is the default role name for Neon
|
||||
role_name: "neondb_owner",
|
||||
});
|
||||
|
||||
await updatePostgresUrlEnvVar({
|
||||
appPath,
|
||||
connectionUri: connectionUri.data.uri,
|
||||
});
|
||||
|
||||
await updateDbPushEnvVar({
|
||||
appPath,
|
||||
disabled: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -51,6 +51,13 @@ import type {
|
||||
VercelProject,
|
||||
UpdateChatParams,
|
||||
FileAttachment,
|
||||
CreateNeonProjectParams,
|
||||
NeonProject,
|
||||
GetNeonProjectParams,
|
||||
GetNeonProjectResponse,
|
||||
RevertVersionResponse,
|
||||
RevertVersionParams,
|
||||
RespondToAppInputParams,
|
||||
} from "./ipc_types";
|
||||
import type { Template } from "../shared/templates";
|
||||
import type { AppChatContext, ProposalResult } from "@/lib/schemas";
|
||||
@@ -82,7 +89,6 @@ export interface GitHubDeviceFlowErrorData {
|
||||
|
||||
export interface DeepLinkData {
|
||||
type: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
interface DeleteCustomModelParams {
|
||||
@@ -412,6 +418,18 @@ export class IpcClient {
|
||||
}
|
||||
}
|
||||
|
||||
// Respond to an app input request (y/n prompts)
|
||||
public async respondToAppInput(
|
||||
params: RespondToAppInputParams,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.ipcRenderer.invoke("respond-to-app-input", params);
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Get allow-listed environment variables
|
||||
public async getEnvVars(): Promise<Record<string, string | undefined>> {
|
||||
try {
|
||||
@@ -437,17 +455,10 @@ export class IpcClient {
|
||||
}
|
||||
|
||||
// Revert to a specific version
|
||||
public async revertVersion({
|
||||
appId,
|
||||
previousVersionId,
|
||||
}: {
|
||||
appId: number;
|
||||
previousVersionId: string;
|
||||
}): Promise<void> {
|
||||
await this.ipcRenderer.invoke("revert-version", {
|
||||
appId,
|
||||
previousVersionId,
|
||||
});
|
||||
public async revertVersion(
|
||||
params: RevertVersionParams,
|
||||
): Promise<RevertVersionResponse> {
|
||||
return this.ipcRenderer.invoke("revert-version", params);
|
||||
}
|
||||
|
||||
// Checkout a specific version without creating a revert commit
|
||||
@@ -794,6 +805,34 @@ export class IpcClient {
|
||||
|
||||
// --- End Supabase Management ---
|
||||
|
||||
// --- Neon Management ---
|
||||
public async fakeHandleNeonConnect(): Promise<void> {
|
||||
await this.ipcRenderer.invoke("neon:fake-connect");
|
||||
}
|
||||
|
||||
public async createNeonProject(
|
||||
params: CreateNeonProjectParams,
|
||||
): Promise<NeonProject> {
|
||||
return this.ipcRenderer.invoke("neon:create-project", params);
|
||||
}
|
||||
|
||||
public async getNeonProject(
|
||||
params: GetNeonProjectParams,
|
||||
): Promise<GetNeonProjectResponse> {
|
||||
return this.ipcRenderer.invoke("neon:get-project", params);
|
||||
}
|
||||
|
||||
// --- End Neon Management ---
|
||||
|
||||
// --- Portal Management ---
|
||||
public async portalMigrateCreate(params: {
|
||||
appId: number;
|
||||
}): Promise<{ output: string }> {
|
||||
return this.ipcRenderer.invoke("portal:migrate-create", params);
|
||||
}
|
||||
|
||||
// --- End Portal Management ---
|
||||
|
||||
public async getSystemDebugInfo(): Promise<SystemDebugInfo> {
|
||||
return this.ipcRenderer.invoke("get-system-debug-info");
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { registerNodeHandlers } from "./handlers/node_handlers";
|
||||
import { registerProposalHandlers } from "./handlers/proposal_handlers";
|
||||
import { registerDebugHandlers } from "./handlers/debug_handlers";
|
||||
import { registerSupabaseHandlers } from "./handlers/supabase_handlers";
|
||||
import { registerNeonHandlers } from "./handlers/neon_handlers";
|
||||
import { registerLocalModelHandlers } from "./handlers/local_model_handlers";
|
||||
import { registerTokenCountHandlers } from "./handlers/token_count_handlers";
|
||||
import { registerWindowHandlers } from "./handlers/window_handlers";
|
||||
@@ -26,6 +27,7 @@ import { registerCapacitorHandlers } from "./handlers/capacitor_handlers";
|
||||
import { registerProblemsHandlers } from "./handlers/problems_handlers";
|
||||
import { registerAppEnvVarsHandlers } from "./handlers/app_env_vars_handlers";
|
||||
import { registerTemplateHandlers } from "./handlers/template_handlers";
|
||||
import { registerPortalHandlers } from "./handlers/portal_handlers";
|
||||
|
||||
export function registerIpcHandlers() {
|
||||
// Register all IPC handlers by category
|
||||
@@ -42,6 +44,7 @@ export function registerIpcHandlers() {
|
||||
registerProposalHandlers();
|
||||
registerDebugHandlers();
|
||||
registerSupabaseHandlers();
|
||||
registerNeonHandlers();
|
||||
registerLocalModelHandlers();
|
||||
registerTokenCountHandlers();
|
||||
registerWindowHandlers();
|
||||
@@ -57,4 +60,5 @@ export function registerIpcHandlers() {
|
||||
registerCapacitorHandlers();
|
||||
registerAppEnvVarsHandlers();
|
||||
registerTemplateHandlers();
|
||||
registerPortalHandlers();
|
||||
}
|
||||
|
||||
@@ -3,12 +3,17 @@ import type { ProblemReport, Problem } from "../../shared/tsc_types";
|
||||
export type { ProblemReport, Problem };
|
||||
|
||||
export interface AppOutput {
|
||||
type: "stdout" | "stderr" | "info" | "client-error";
|
||||
type: "stdout" | "stderr" | "info" | "client-error" | "input-requested";
|
||||
message: string;
|
||||
timestamp: number;
|
||||
appId: number;
|
||||
}
|
||||
|
||||
export interface RespondToAppInputParams {
|
||||
appId: number;
|
||||
response: string;
|
||||
}
|
||||
|
||||
export interface ListAppsResponse {
|
||||
apps: App[];
|
||||
appBasePath: string;
|
||||
@@ -61,6 +66,7 @@ export interface Message {
|
||||
content: string;
|
||||
approvalState?: "approved" | "rejected" | null;
|
||||
commitHash?: string | null;
|
||||
dbTimestamp?: string | null;
|
||||
}
|
||||
|
||||
export interface Chat {
|
||||
@@ -68,6 +74,7 @@ export interface Chat {
|
||||
title: string;
|
||||
messages: Message[];
|
||||
initialCommitHash?: string | null;
|
||||
dbTimestamp?: string | null;
|
||||
}
|
||||
|
||||
export interface App {
|
||||
@@ -82,6 +89,9 @@ export interface App {
|
||||
githubBranch: string | null;
|
||||
supabaseProjectId: string | null;
|
||||
supabaseProjectName: string | null;
|
||||
neonProjectId: string | null;
|
||||
neonDevelopmentBranchId: string | null;
|
||||
neonPreviewBranchId: string | null;
|
||||
vercelProjectId: string | null;
|
||||
vercelProjectName: string | null;
|
||||
vercelTeamSlug: string | null;
|
||||
@@ -92,6 +102,7 @@ export interface Version {
|
||||
oid: string;
|
||||
message: string;
|
||||
timestamp: number;
|
||||
dbTimestamp?: string | null;
|
||||
}
|
||||
|
||||
export type BranchResult = { branch: string };
|
||||
@@ -339,3 +350,45 @@ export interface FileAttachment {
|
||||
file: File;
|
||||
type: "upload-to-codebase" | "chat-context";
|
||||
}
|
||||
|
||||
// --- Neon Types ---
|
||||
export interface CreateNeonProjectParams {
|
||||
name: string;
|
||||
appId: number;
|
||||
}
|
||||
|
||||
export interface NeonProject {
|
||||
id: string;
|
||||
name: string;
|
||||
connectionString: string;
|
||||
branchId: string;
|
||||
}
|
||||
|
||||
export interface NeonBranch {
|
||||
type: "production" | "development" | "snapshot" | "preview";
|
||||
branchId: string;
|
||||
branchName: string;
|
||||
lastUpdated: string; // ISO timestamp
|
||||
parentBranchId?: string; // ID of the parent branch
|
||||
parentBranchName?: string; // Name of the parent branch
|
||||
}
|
||||
|
||||
export interface GetNeonProjectParams {
|
||||
appId: number;
|
||||
}
|
||||
|
||||
export interface GetNeonProjectResponse {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
orgId: string;
|
||||
branches: NeonBranch[];
|
||||
}
|
||||
|
||||
export interface RevertVersionParams {
|
||||
appId: number;
|
||||
previousVersionId: string;
|
||||
}
|
||||
|
||||
export type RevertVersionResponse =
|
||||
| { successMessage: string }
|
||||
| { warningMessage: string };
|
||||
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
getDyadAddDependencyTags,
|
||||
getDyadExecuteSqlTags,
|
||||
} from "../utils/dyad_tag_parser";
|
||||
import { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils";
|
||||
|
||||
import { FileUploadsState } from "../utils/file_uploads_state";
|
||||
|
||||
const readFile = fs.promises.readFile;
|
||||
@@ -80,6 +82,23 @@ export async function processFullResponseActions(
|
||||
return {};
|
||||
}
|
||||
|
||||
if (
|
||||
chatWithApp.app.neonProjectId &&
|
||||
chatWithApp.app.neonDevelopmentBranchId
|
||||
) {
|
||||
try {
|
||||
await storeDbTimestampAtCurrentVersion({
|
||||
appId: chatWithApp.app.id,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Error creating Neon branch at current version:", error);
|
||||
throw new Error(
|
||||
"Could not create Neon branch; database versioning functionality is not working: " +
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const settings: UserSettings = readSettings();
|
||||
const appPath = getDyadAppPath(chatWithApp.app.path);
|
||||
const writtenFiles: string[] = [];
|
||||
|
||||
@@ -3,7 +3,109 @@
|
||||
* Environment variables are sensitive and should not be logged.
|
||||
*/
|
||||
|
||||
import { getDyadAppPath } from "@/paths/paths";
|
||||
import { EnvVar } from "../ipc_types";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import log from "electron-log";
|
||||
|
||||
const logger = log.scope("app_env_var_utils");
|
||||
|
||||
export const ENV_FILE_NAME = ".env.local";
|
||||
|
||||
function getEnvFilePath({ appPath }: { appPath: string }): string {
|
||||
return path.join(getDyadAppPath(appPath), ENV_FILE_NAME);
|
||||
}
|
||||
|
||||
export async function updatePostgresUrlEnvVar({
|
||||
appPath,
|
||||
connectionUri,
|
||||
}: {
|
||||
appPath: string;
|
||||
connectionUri: string;
|
||||
}) {
|
||||
// Given the connection uri, update the env var for POSTGRES_URL
|
||||
const envVars = parseEnvFile(await readEnvFile({ appPath }));
|
||||
|
||||
// Find existing POSTGRES_URL or add it if it doesn't exist
|
||||
const existingVar = envVars.find((envVar) => envVar.key === "POSTGRES_URL");
|
||||
if (existingVar) {
|
||||
existingVar.value = connectionUri;
|
||||
} else {
|
||||
envVars.push({
|
||||
key: "POSTGRES_URL",
|
||||
value: connectionUri,
|
||||
});
|
||||
}
|
||||
|
||||
const envFileContents = serializeEnvFile(envVars);
|
||||
await fs.promises.writeFile(getEnvFilePath({ appPath }), envFileContents);
|
||||
}
|
||||
|
||||
export async function updateDbPushEnvVar({
|
||||
appPath,
|
||||
disabled,
|
||||
}: {
|
||||
appPath: string;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
try {
|
||||
// Try to read existing env file
|
||||
let envVars: EnvVar[];
|
||||
try {
|
||||
const content = await readEnvFile({ appPath });
|
||||
envVars = parseEnvFile(content);
|
||||
} catch {
|
||||
// If file doesn't exist, start with empty array
|
||||
envVars = [];
|
||||
}
|
||||
|
||||
// Update or add DYAD_DISABLE_DB_PUSH
|
||||
const existingVar = envVars.find(
|
||||
(envVar) => envVar.key === "DYAD_DISABLE_DB_PUSH",
|
||||
);
|
||||
if (existingVar) {
|
||||
existingVar.value = disabled ? "true" : "false";
|
||||
} else {
|
||||
envVars.push({
|
||||
key: "DYAD_DISABLE_DB_PUSH",
|
||||
value: disabled ? "true" : "false",
|
||||
});
|
||||
}
|
||||
|
||||
const envFileContents = serializeEnvFile(envVars);
|
||||
await fs.promises.writeFile(getEnvFilePath({ appPath }), envFileContents);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to update DB push environment variable for app ${appPath}: ${error}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readPostgresUrlFromEnvFile({
|
||||
appPath,
|
||||
}: {
|
||||
appPath: string;
|
||||
}): Promise<string> {
|
||||
const contents = await readEnvFile({ appPath });
|
||||
const envVars = parseEnvFile(contents);
|
||||
const postgresUrl = envVars.find(
|
||||
(envVar) => envVar.key === "POSTGRES_URL",
|
||||
)?.value;
|
||||
if (!postgresUrl) {
|
||||
throw new Error("POSTGRES_URL not found in .env.local");
|
||||
}
|
||||
return postgresUrl;
|
||||
}
|
||||
|
||||
export async function readEnvFile({
|
||||
appPath,
|
||||
}: {
|
||||
appPath: string;
|
||||
}): Promise<string> {
|
||||
return fs.promises.readFile(getEnvFilePath({ appPath }), "utf8");
|
||||
}
|
||||
|
||||
// Helper function to parse .env.local file content
|
||||
export function parseEnvFile(content: string): EnvVar[] {
|
||||
|
||||
134
src/ipc/utils/neon_timestamp_utils.ts
Normal file
134
src/ipc/utils/neon_timestamp_utils.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { db } from "../../db";
|
||||
import { versions, apps } from "../../db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import fs from "node:fs";
|
||||
import git from "isomorphic-git";
|
||||
import { getDyadAppPath } from "../../paths/paths";
|
||||
import { neon } from "@neondatabase/serverless";
|
||||
|
||||
import log from "electron-log";
|
||||
import { getNeonClient } from "@/neon_admin/neon_management_client";
|
||||
|
||||
const logger = log.scope("neon_timestamp_utils");
|
||||
|
||||
/**
|
||||
* Retrieves the current timestamp from a Neon database
|
||||
*/
|
||||
async function getLastUpdatedTimestampFromNeon({
|
||||
neonConnectionUri,
|
||||
}: {
|
||||
neonConnectionUri: string;
|
||||
}): Promise<string> {
|
||||
try {
|
||||
const sql = neon(neonConnectionUri);
|
||||
|
||||
const [{ current_timestamp }] = await sql`
|
||||
SELECT TO_CHAR(NOW() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z') AS current_timestamp
|
||||
`;
|
||||
|
||||
return current_timestamp;
|
||||
} catch (error) {
|
||||
logger.error("Error retrieving timestamp from Neon:", error);
|
||||
throw new Error(`Failed to retrieve timestamp from Neon: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores a Neon database timestamp for the current git commit hash
|
||||
* and stores it in the versions table
|
||||
* @param appId - The app ID
|
||||
* @param neonConnectionUri - The Neon connection URI to get the timestamp from
|
||||
*/
|
||||
export async function storeDbTimestampAtCurrentVersion({
|
||||
appId,
|
||||
}: {
|
||||
appId: number;
|
||||
}): Promise<{ timestamp: string }> {
|
||||
try {
|
||||
logger.info(`Storing DB timestamp for current version - app ${appId}`);
|
||||
|
||||
// 1. Get the app to find the path
|
||||
const app = await db.query.apps.findFirst({
|
||||
where: eq(apps.id, appId),
|
||||
});
|
||||
|
||||
if (!app) {
|
||||
throw new Error(`App with ID ${appId} not found`);
|
||||
}
|
||||
|
||||
if (!app.neonProjectId || !app.neonDevelopmentBranchId) {
|
||||
throw new Error(`App with ID ${appId} has no Neon project or branch`);
|
||||
}
|
||||
|
||||
// 2. Get the current commit hash
|
||||
const appPath = getDyadAppPath(app.path);
|
||||
const currentCommitHash = await git.resolveRef({
|
||||
fs,
|
||||
dir: appPath,
|
||||
ref: "HEAD",
|
||||
});
|
||||
|
||||
logger.info(`Current commit hash: ${currentCommitHash}`);
|
||||
|
||||
const neonClient = await getNeonClient();
|
||||
const connectionUri = await neonClient.getConnectionUri({
|
||||
projectId: app.neonProjectId,
|
||||
branch_id: app.neonDevelopmentBranchId,
|
||||
database_name: "neondb",
|
||||
role_name: "neondb_owner",
|
||||
});
|
||||
|
||||
// 3. Get the current timestamp from Neon
|
||||
const currentTimestamp = await getLastUpdatedTimestampFromNeon({
|
||||
neonConnectionUri: connectionUri.data.uri,
|
||||
});
|
||||
|
||||
logger.info(`Current timestamp from Neon: ${currentTimestamp}`);
|
||||
|
||||
// 4. Check if a version with this commit hash already exists
|
||||
const existingVersion = await db.query.versions.findFirst({
|
||||
where: and(
|
||||
eq(versions.appId, appId),
|
||||
eq(versions.commitHash, currentCommitHash),
|
||||
),
|
||||
});
|
||||
|
||||
if (existingVersion) {
|
||||
// Update existing version with the new timestamp
|
||||
await db
|
||||
.update(versions)
|
||||
.set({
|
||||
neonDbTimestamp: currentTimestamp,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(versions.appId, appId),
|
||||
eq(versions.commitHash, currentCommitHash),
|
||||
),
|
||||
);
|
||||
logger.info(
|
||||
`Updated existing version record with timestamp ${currentTimestamp}`,
|
||||
);
|
||||
} else {
|
||||
// Create new version record
|
||||
await db.insert(versions).values({
|
||||
appId,
|
||||
commitHash: currentCommitHash,
|
||||
neonDbTimestamp: currentTimestamp,
|
||||
});
|
||||
logger.info(
|
||||
`Created new version record for commit ${currentCommitHash} with timestamp ${currentTimestamp}`,
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Successfully stored timestamp for commit ${currentCommitHash} in app ${appId}`,
|
||||
);
|
||||
|
||||
return { timestamp: currentTimestamp };
|
||||
} catch (error) {
|
||||
logger.error("Error in storeDbTimestampAtCurrentVersion:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
71
src/ipc/utils/retryOnLocked.ts
Normal file
71
src/ipc/utils/retryOnLocked.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import log from "electron-log";
|
||||
|
||||
export const logger = log.scope("retryOnLocked");
|
||||
|
||||
export function isLockedError(error: any): boolean {
|
||||
return error.response?.status === 423;
|
||||
}
|
||||
|
||||
// Retry configuration
|
||||
const RETRY_CONFIG = {
|
||||
maxRetries: 6,
|
||||
baseDelay: 1000, // 1 second
|
||||
maxDelay: 90_000, // 90 seconds
|
||||
jitterFactor: 0.1, // 10% jitter
|
||||
};
|
||||
|
||||
/**
|
||||
* Retries an async operation with exponential backoff on locked errors (423)
|
||||
*/
|
||||
|
||||
export async function retryOnLocked<T>(
|
||||
operation: () => Promise<T>,
|
||||
context: string,
|
||||
{
|
||||
retryBranchWithChildError = false,
|
||||
}: { retryBranchWithChildError?: boolean } = {},
|
||||
): Promise<T> {
|
||||
let lastError: any;
|
||||
|
||||
for (let attempt = 0; attempt <= RETRY_CONFIG.maxRetries; attempt++) {
|
||||
try {
|
||||
const result = await operation();
|
||||
logger.info(`${context}: Success after ${attempt + 1} attempts`);
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
lastError = error;
|
||||
|
||||
// Only retry on locked errors
|
||||
if (!isLockedError(error)) {
|
||||
if (retryBranchWithChildError && error.response?.status === 422) {
|
||||
logger.info(
|
||||
`${context}: Branch with child error (attempt ${attempt + 1}/${RETRY_CONFIG.maxRetries + 1})`,
|
||||
);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Don't retry if we've exhausted all attempts
|
||||
if (attempt === RETRY_CONFIG.maxRetries) {
|
||||
logger.error(
|
||||
`${context}: Failed after ${RETRY_CONFIG.maxRetries + 1} attempts due to locked error`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Calculate delay with exponential backoff and jitter
|
||||
const baseDelay = RETRY_CONFIG.baseDelay * Math.pow(2, attempt);
|
||||
const jitter = baseDelay * RETRY_CONFIG.jitterFactor * Math.random();
|
||||
const delay = Math.min(baseDelay + jitter, RETRY_CONFIG.maxDelay);
|
||||
|
||||
logger.warn(
|
||||
`${context}: Locked error (attempt ${attempt + 1}/${RETRY_CONFIG.maxRetries + 1}), retrying in ${Math.round(delay)}ms`,
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
@@ -90,6 +90,14 @@ export const SupabaseSchema = z.object({
|
||||
});
|
||||
export type Supabase = z.infer<typeof SupabaseSchema>;
|
||||
|
||||
export const NeonSchema = z.object({
|
||||
accessToken: SecretSchema.optional(),
|
||||
refreshToken: SecretSchema.optional(),
|
||||
expiresIn: z.number().optional(),
|
||||
tokenTimestamp: z.number().optional(),
|
||||
});
|
||||
export type Neon = z.infer<typeof NeonSchema>;
|
||||
|
||||
export const ExperimentsSchema = z.object({
|
||||
// Deprecated
|
||||
enableSupabaseIntegration: z.boolean().describe("DEPRECATED").optional(),
|
||||
@@ -138,6 +146,7 @@ export const UserSettingsSchema = z.object({
|
||||
githubAccessToken: SecretSchema.optional(),
|
||||
vercelAccessToken: SecretSchema.optional(),
|
||||
supabase: SupabaseSchema.optional(),
|
||||
neon: NeonSchema.optional(),
|
||||
autoApproveChanges: z.boolean().optional(),
|
||||
telemetryConsent: z.enum(["opted_in", "opted_out", "unset"]).optional(),
|
||||
telemetryUserId: z.string().optional(),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { toast } from "sonner";
|
||||
import { PostHog } from "posthog-js";
|
||||
import React from "react";
|
||||
import { CustomErrorToast } from "../components/CustomErrorToast";
|
||||
import { InputRequestToast } from "../components/InputRequestToast";
|
||||
|
||||
/**
|
||||
* Toast utility functions for consistent notifications across the app
|
||||
@@ -87,6 +88,29 @@ export const showInfo = (message: string) => {
|
||||
toast.info(message);
|
||||
};
|
||||
|
||||
/**
|
||||
* Show an input request toast for interactive prompts (y/n)
|
||||
* @param message The prompt message to display
|
||||
* @param onResponse Callback function called when user responds
|
||||
*/
|
||||
export const showInputRequest = (
|
||||
message: string,
|
||||
onResponse: (response: "y" | "n") => void,
|
||||
) => {
|
||||
const toastId = toast.custom(
|
||||
(t) => (
|
||||
<InputRequestToast
|
||||
message={message}
|
||||
toastId={t}
|
||||
onResponse={onResponse}
|
||||
/>
|
||||
),
|
||||
{ duration: Infinity }, // Don't auto-close
|
||||
);
|
||||
|
||||
return toastId;
|
||||
};
|
||||
|
||||
export const showExtraFilesToast = ({
|
||||
files,
|
||||
error,
|
||||
|
||||
21
src/main.ts
21
src/main.ts
@@ -17,6 +17,7 @@ import { IS_TEST_BUILD } from "./ipc/utils/test_utils";
|
||||
import { BackupManager } from "./backup_manager";
|
||||
import { getDatabasePath, initializeDatabase } from "./db";
|
||||
import { UserSettings } from "./lib/schemas";
|
||||
import { handleNeonOAuthReturn } from "./neon_admin/neon_return_handler";
|
||||
|
||||
log.errorHandler.startCatching();
|
||||
log.eventLogger.startLogging();
|
||||
@@ -208,6 +209,24 @@ function handleDeepLinkReturn(url: string) {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (parsed.hostname === "neon-oauth-return") {
|
||||
const token = parsed.searchParams.get("token");
|
||||
const refreshToken = parsed.searchParams.get("refreshToken");
|
||||
const expiresIn = Number(parsed.searchParams.get("expiresIn"));
|
||||
if (!token || !refreshToken || !expiresIn) {
|
||||
dialog.showErrorBox(
|
||||
"Invalid URL",
|
||||
"Expected token, refreshToken, and expiresIn",
|
||||
);
|
||||
return;
|
||||
}
|
||||
handleNeonOAuthReturn({ token, refreshToken, expiresIn });
|
||||
// Send message to renderer to trigger re-render
|
||||
mainWindow?.webContents.send("deep-link-received", {
|
||||
type: parsed.hostname,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (parsed.hostname === "supabase-oauth-return") {
|
||||
const token = parsed.searchParams.get("token");
|
||||
const refreshToken = parsed.searchParams.get("refreshToken");
|
||||
@@ -223,7 +242,6 @@ function handleDeepLinkReturn(url: string) {
|
||||
// Send message to renderer to trigger re-render
|
||||
mainWindow?.webContents.send("deep-link-received", {
|
||||
type: parsed.hostname,
|
||||
url,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -240,7 +258,6 @@ function handleDeepLinkReturn(url: string) {
|
||||
// Send message to renderer to trigger re-render
|
||||
mainWindow?.webContents.send("deep-link-received", {
|
||||
type: parsed.hostname,
|
||||
url,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -69,6 +69,27 @@ export function readSettings(): UserSettings {
|
||||
}
|
||||
}
|
||||
}
|
||||
const neon = combinedSettings.neon;
|
||||
if (neon) {
|
||||
if (neon.refreshToken) {
|
||||
const encryptionType = neon.refreshToken.encryptionType;
|
||||
if (encryptionType) {
|
||||
neon.refreshToken = {
|
||||
value: decrypt(neon.refreshToken),
|
||||
encryptionType,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (neon.accessToken) {
|
||||
const encryptionType = neon.accessToken.encryptionType;
|
||||
if (encryptionType) {
|
||||
neon.accessToken = {
|
||||
value: decrypt(neon.accessToken),
|
||||
encryptionType,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
if (combinedSettings.githubAccessToken) {
|
||||
const encryptionType = combinedSettings.githubAccessToken.encryptionType;
|
||||
combinedSettings.githubAccessToken = {
|
||||
@@ -131,6 +152,18 @@ export function writeSettings(settings: Partial<UserSettings>): void {
|
||||
);
|
||||
}
|
||||
}
|
||||
if (newSettings.neon) {
|
||||
if (newSettings.neon.accessToken) {
|
||||
newSettings.neon.accessToken = encrypt(
|
||||
newSettings.neon.accessToken.value,
|
||||
);
|
||||
}
|
||||
if (newSettings.neon.refreshToken) {
|
||||
newSettings.neon.refreshToken = encrypt(
|
||||
newSettings.neon.refreshToken.value,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const provider in newSettings.providerSettings) {
|
||||
if (newSettings.providerSettings[provider].apiKey) {
|
||||
newSettings.providerSettings[provider].apiKey = encrypt(
|
||||
|
||||
240
src/neon_admin/neon_management_client.ts
Normal file
240
src/neon_admin/neon_management_client.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { withLock } from "../ipc/utils/lock_utils";
|
||||
import { readSettings, writeSettings } from "../main/settings";
|
||||
import { Api, createApiClient } from "@neondatabase/api-client";
|
||||
import log from "electron-log";
|
||||
import { IS_TEST_BUILD } from "../ipc/utils/test_utils";
|
||||
|
||||
const logger = log.scope("neon_management_client");
|
||||
|
||||
/**
|
||||
* Checks if the Neon access token is expired or about to expire
|
||||
* Returns true if token needs to be refreshed
|
||||
*/
|
||||
function isTokenExpired(expiresIn?: number): boolean {
|
||||
if (!expiresIn) return true;
|
||||
|
||||
// Get when the token was saved (expiresIn is stored at the time of token receipt)
|
||||
const settings = readSettings();
|
||||
const tokenTimestamp = settings.neon?.tokenTimestamp || 0;
|
||||
const currentTime = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Check if the token is expired or about to expire (within 5 minutes)
|
||||
return currentTime >= tokenTimestamp + expiresIn - 300;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the Neon access token using the refresh token
|
||||
* Updates settings with new tokens and expiration time
|
||||
*/
|
||||
export async function refreshNeonToken(): Promise<void> {
|
||||
const settings = readSettings();
|
||||
const refreshToken = settings.neon?.refreshToken?.value;
|
||||
|
||||
if (!isTokenExpired(settings.neon?.expiresIn)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!refreshToken) {
|
||||
throw new Error("Neon refresh token not found. Please authenticate first.");
|
||||
}
|
||||
|
||||
try {
|
||||
// Make request to Neon refresh endpoint
|
||||
const response = await fetch(
|
||||
"https://oauth.dyad.sh/api/integrations/neon/refresh",
|
||||
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Token refresh failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const {
|
||||
accessToken,
|
||||
refreshToken: newRefreshToken,
|
||||
expiresIn,
|
||||
} = await response.json();
|
||||
|
||||
// Update settings with new tokens
|
||||
writeSettings({
|
||||
neon: {
|
||||
accessToken: {
|
||||
value: accessToken,
|
||||
},
|
||||
refreshToken: {
|
||||
value: newRefreshToken,
|
||||
},
|
||||
expiresIn,
|
||||
tokenTimestamp: Math.floor(Date.now() / 1000), // Store current timestamp
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Error refreshing Neon token:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Function to get the Neon API client
|
||||
export async function getNeonClient(): Promise<Api<unknown>> {
|
||||
if (IS_TEST_BUILD) {
|
||||
// Return a mock client for testing
|
||||
return {
|
||||
createProject: async (params: any) => ({
|
||||
data: {
|
||||
project: {
|
||||
id: "test-project-id",
|
||||
name: params.project.name,
|
||||
region_id: "aws-us-east-1",
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
default_branch_id: "test-branch-id",
|
||||
},
|
||||
connection_uris: [
|
||||
{
|
||||
connection_uri: "postgresql://test:test@test.neon.tech/test",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
createProjectBranch: async (projectId: string, params: any) => ({
|
||||
data: {
|
||||
branch: {
|
||||
id: "test-dev-branch-id",
|
||||
name: params.branch?.name || "development",
|
||||
project_id: projectId,
|
||||
parent_id: "test-branch-id",
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
connection_uris: [
|
||||
{
|
||||
connection_uri: "postgresql://test:test@test-dev.neon.tech/test",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
getProject: async (projectId: string) => ({
|
||||
data: {
|
||||
project: {
|
||||
id: projectId,
|
||||
name: "Test Project",
|
||||
org_id: "test-org-id",
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
default_branch_id: "test-branch-id",
|
||||
},
|
||||
},
|
||||
}),
|
||||
listProjectBranches: async (projectId: string) => ({
|
||||
data: {
|
||||
branches: [
|
||||
{
|
||||
id: "test-branch-id",
|
||||
name: "main",
|
||||
project_id: projectId,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
id: "test-dev-branch-id",
|
||||
name: "development",
|
||||
project_id: projectId,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
default: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
listOrganizations: async () => ({
|
||||
data: {
|
||||
organizations: [
|
||||
{
|
||||
id: "test-org-id",
|
||||
name: "Test Organization",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
deleteProjectBranch: async (projectId: string, branchId: string) => ({
|
||||
data: {
|
||||
branch: {
|
||||
id: branchId,
|
||||
project_id: projectId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
} as unknown as Api<unknown>;
|
||||
}
|
||||
|
||||
const settings = readSettings();
|
||||
|
||||
// Check if Neon token exists in settings
|
||||
const neonAccessToken = settings.neon?.accessToken?.value;
|
||||
const expiresIn = settings.neon?.expiresIn;
|
||||
|
||||
if (!neonAccessToken) {
|
||||
throw new Error("Neon access token not found. Please authenticate first.");
|
||||
}
|
||||
|
||||
// Check if token needs refreshing
|
||||
if (isTokenExpired(expiresIn)) {
|
||||
await withLock("refresh-neon-token", refreshNeonToken);
|
||||
// Get updated settings after refresh
|
||||
const updatedSettings = readSettings();
|
||||
const newAccessToken = updatedSettings.neon?.accessToken?.value;
|
||||
|
||||
if (!newAccessToken) {
|
||||
throw new Error("Failed to refresh Neon access token");
|
||||
}
|
||||
|
||||
return createApiClient({
|
||||
apiKey: newAccessToken,
|
||||
});
|
||||
}
|
||||
|
||||
return createApiClient({
|
||||
apiKey: neonAccessToken,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user's first organization ID from Neon
|
||||
*/
|
||||
export async function getNeonOrganizationId(): Promise<string> {
|
||||
const neonClient = await getNeonClient();
|
||||
|
||||
if (IS_TEST_BUILD) {
|
||||
return "test-org-id";
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await neonClient.getCurrentUserOrganizations();
|
||||
|
||||
if (
|
||||
!response.data?.organizations ||
|
||||
response.data.organizations.length === 0
|
||||
) {
|
||||
throw new Error("No organizations found for this Neon account");
|
||||
}
|
||||
|
||||
// Return the first organization ID
|
||||
return response.data.organizations[0].id;
|
||||
} catch (error) {
|
||||
logger.error("Error fetching Neon organizations:", error);
|
||||
throw new Error("Failed to fetch Neon organizations");
|
||||
}
|
||||
}
|
||||
|
||||
export function getNeonErrorMessage(error: any): string {
|
||||
const detailedMessage = error.response?.data?.message ?? "";
|
||||
return error.message + " " + detailedMessage;
|
||||
}
|
||||
24
src/neon_admin/neon_return_handler.ts
Normal file
24
src/neon_admin/neon_return_handler.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { writeSettings } from "../main/settings";
|
||||
|
||||
export function handleNeonOAuthReturn({
|
||||
token,
|
||||
refreshToken,
|
||||
expiresIn,
|
||||
}: {
|
||||
token: string;
|
||||
refreshToken: string;
|
||||
expiresIn: number;
|
||||
}) {
|
||||
writeSettings({
|
||||
neon: {
|
||||
accessToken: {
|
||||
value: token,
|
||||
},
|
||||
refreshToken: {
|
||||
value: refreshToken,
|
||||
},
|
||||
expiresIn,
|
||||
tokenTimestamp: Math.floor(Date.now() / 1000),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -30,6 +30,8 @@ import { invalidateAppQuery } from "@/hooks/useLoadApp";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import type { FileAttachment } from "@/ipc/ipc_types";
|
||||
import { NEON_TEMPLATE_IDS } from "@/shared/templates";
|
||||
import { neonTemplateHook } from "@/client_logic/template_hook";
|
||||
|
||||
// Adding an export for attachments
|
||||
export interface HomeSubmitOptions {
|
||||
@@ -120,6 +122,15 @@ export default function HomePage() {
|
||||
const result = await IpcClient.getInstance().createApp({
|
||||
name: generateCuteAppName(),
|
||||
});
|
||||
if (
|
||||
settings?.selectedTemplateId &&
|
||||
NEON_TEMPLATE_IDS.has(settings.selectedTemplateId)
|
||||
) {
|
||||
await neonTemplateHook({
|
||||
appId: result.app.id,
|
||||
appName: result.app.name,
|
||||
});
|
||||
}
|
||||
|
||||
// Stream the message with attachments
|
||||
streamMessage({
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import { useTemplates } from "@/hooks/useTemplates";
|
||||
import { TemplateCard } from "@/components/TemplateCard";
|
||||
import { CreateAppDialog } from "@/components/CreateAppDialog";
|
||||
import { NeonConnector } from "@/components/NeonConnector";
|
||||
|
||||
const HubPage: React.FC = () => {
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const router = useRouter();
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
const { templates, isLoading } = useTemplates();
|
||||
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const selectedTemplateId = settings?.selectedTemplateId;
|
||||
|
||||
const handleTemplateSelect = (templateId: string) => {
|
||||
updateSettings({ selectedTemplateId: templateId });
|
||||
};
|
||||
|
||||
const handleCreateApp = () => {
|
||||
setIsCreateDialogOpen(true);
|
||||
};
|
||||
// Separate templates into official and community
|
||||
const officialTemplates =
|
||||
templates?.filter((template) => template.isOfficial) || [];
|
||||
@@ -25,7 +30,7 @@ const HubPage: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen px-8 py-4">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="max-w-5xl mx-auto pb-12">
|
||||
<Button
|
||||
onClick={() => router.history.back()}
|
||||
variant="outline"
|
||||
@@ -58,6 +63,7 @@ const HubPage: React.FC = () => {
|
||||
template={template}
|
||||
isSelected={template.id === selectedTemplateId}
|
||||
onSelect={handleTemplateSelect}
|
||||
onCreateApp={handleCreateApp}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -77,14 +83,42 @@ const HubPage: React.FC = () => {
|
||||
template={template}
|
||||
isSelected={template.id === selectedTemplateId}
|
||||
onSelect={handleTemplateSelect}
|
||||
onCreateApp={handleCreateApp}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<BackendSection />
|
||||
</div>
|
||||
|
||||
<CreateAppDialog
|
||||
open={isCreateDialogOpen}
|
||||
onOpenChange={setIsCreateDialogOpen}
|
||||
template={templates.find((t) => t.id === settings?.selectedTemplateId)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function BackendSection() {
|
||||
return (
|
||||
<div className="">
|
||||
<header className="mb-4 text-left">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Backend Services
|
||||
</h1>
|
||||
<p className="text-md text-gray-600 dark:text-gray-400">
|
||||
Connect to backend services for your projects.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
<NeonConnector />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HubPage;
|
||||
|
||||
@@ -16,11 +16,13 @@ import { useRouter } from "@tanstack/react-router";
|
||||
import { GitHubIntegration } from "@/components/GitHubIntegration";
|
||||
import { VercelIntegration } from "@/components/VercelIntegration";
|
||||
import { SupabaseIntegration } from "@/components/SupabaseIntegration";
|
||||
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AutoFixProblemsSwitch } from "@/components/AutoFixProblemsSwitch";
|
||||
import { AutoUpdateSwitch } from "@/components/AutoUpdateSwitch";
|
||||
import { ReleaseChannelSelector } from "@/components/ReleaseChannelSelector";
|
||||
import { NeonIntegration } from "@/components/NeonIntegration";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [isResetDialogOpen, setIsResetDialogOpen] = useState(false);
|
||||
@@ -112,6 +114,7 @@ export default function SettingsPage() {
|
||||
<GitHubIntegration />
|
||||
<VercelIntegration />
|
||||
<SupabaseIntegration />
|
||||
<NeonIntegration />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ const validInvokeChannels = [
|
||||
"run-app",
|
||||
"stop-app",
|
||||
"restart-app",
|
||||
"respond-to-app-input",
|
||||
"list-versions",
|
||||
"revert-version",
|
||||
"checkout-version",
|
||||
@@ -55,6 +56,9 @@ const validInvokeChannels = [
|
||||
"github:connect-existing-repo",
|
||||
"github:push",
|
||||
"github:disconnect",
|
||||
"neon:create-project",
|
||||
"neon:get-project",
|
||||
"neon:delete-branch",
|
||||
"vercel:save-token",
|
||||
"vercel:list-projects",
|
||||
"vercel:is-project-available",
|
||||
@@ -101,6 +105,7 @@ const validInvokeChannels = [
|
||||
"check-problems",
|
||||
"restart-dyad",
|
||||
"get-templates",
|
||||
"portal:migrate-create",
|
||||
// Test-only channels
|
||||
// These should ALWAYS be guarded with IS_TEST_BUILD in the main process.
|
||||
// We can't detect with IS_TEST_BUILD in the preload script because
|
||||
|
||||
@@ -5,6 +5,8 @@ export interface Template {
|
||||
imageUrl: string;
|
||||
githubUrl?: string;
|
||||
isOfficial: boolean;
|
||||
isExperimental?: boolean;
|
||||
requiresNeon?: boolean;
|
||||
}
|
||||
|
||||
// API Template interface from the external API
|
||||
@@ -26,6 +28,9 @@ export const DEFAULT_TEMPLATE = {
|
||||
isOfficial: true,
|
||||
};
|
||||
|
||||
const PORTAL_MINI_STORE_ID = "portal-mini-store";
|
||||
export const NEON_TEMPLATE_IDS = new Set<string>([PORTAL_MINI_STORE_ID]);
|
||||
|
||||
export const localTemplatesData: Template[] = [
|
||||
DEFAULT_TEMPLATE,
|
||||
{
|
||||
@@ -37,4 +42,15 @@ export const localTemplatesData: Template[] = [
|
||||
githubUrl: "https://github.com/dyad-sh/nextjs-template",
|
||||
isOfficial: true,
|
||||
},
|
||||
{
|
||||
id: PORTAL_MINI_STORE_ID,
|
||||
title: "Portal: Mini Store Template",
|
||||
description: "Uses Neon DB, Payload CMS, Next.js",
|
||||
imageUrl:
|
||||
"https://github.com/user-attachments/assets/ed86f322-40bf-4fd5-81dc-3b1d8a16e12b",
|
||||
githubUrl: "https://github.com/dyad-sh/portal-mini-store-template",
|
||||
isOfficial: true,
|
||||
isExperimental: true,
|
||||
requiresNeon: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -24,7 +24,6 @@ export const createChatCompletionHandler =
|
||||
}
|
||||
|
||||
let messageContent = CANNED_MESSAGE;
|
||||
console.error("LASTMESSAGE********", lastMessage.content);
|
||||
|
||||
if (
|
||||
lastMessage &&
|
||||
|
||||
Reference in New Issue
Block a user