diff --git a/drizzle/0009_previous_misty_knight.sql b/drizzle/0009_previous_misty_knight.sql
new file mode 100644
index 0000000..7fd0140
--- /dev/null
+++ b/drizzle/0009_previous_misty_knight.sql
@@ -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;
\ No newline at end of file
diff --git a/drizzle/meta/0009_snapshot.json b/drizzle/meta/0009_snapshot.json
new file mode 100644
index 0000000..822549e
--- /dev/null
+++ b/drizzle/meta/0009_snapshot.json
@@ -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": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
index 6576f59..d8af21d 100644
--- a/drizzle/meta/_journal.json
+++ b/drizzle/meta/_journal.json
@@ -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
}
]
}
\ No newline at end of file
diff --git a/e2e-tests/helpers/test_helper.ts b/e2e-tests/helpers/test_helper.ts
index 7625f8a..b2435e4 100644
--- a/e2e-tests/helpers/test_helper.ts
+++ b/e2e-tests/helpers/test_helper.ts
@@ -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() {
diff --git a/package-lock.json b/package-lock.json
index 33d4b53..8886cdf 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 28ed29f..d2353b8 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/client_logic/template_hook.ts b/src/client_logic/template_hook.ts
new file mode 100644
index 0000000..70fa28c
--- /dev/null
+++ b/src/client_logic/template_hook.ts
@@ -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");
+}
diff --git a/src/components/CreateAppDialog.tsx b/src/components/CreateAppDialog.tsx
new file mode 100644
index 0000000..165a51d
--- /dev/null
+++ b/src/components/CreateAppDialog.tsx
@@ -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 (
+
+
+
+ Create New App
+
+ {`Create a new app using the ${template?.title} template.`}
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/InputRequestToast.tsx b/src/components/InputRequestToast.tsx
new file mode 100644
index 0000000..1ef1c8c
--- /dev/null
+++ b/src/components/InputRequestToast.tsx
@@ -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 (
+
+ {/* Content */}
+
+
+
+
+
+
+ Input Required
+
+
+ {/* Close button */}
+
+
+
+
+
+ {/* Message */}
+
+
+ {/* Action buttons */}
+
+ handleResponse("y")}
+ size="sm"
+ className="bg-primary text-white dark:bg-primary dark:text-black px-6"
+ >
+ Yes
+
+ 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
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/NeonConnector.tsx b/src/components/NeonConnector.tsx
new file mode 100644
index 0000000..0ab6a84
--- /dev/null
+++ b/src/components/NeonConnector.tsx
@@ -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 (
+
+
+
+
Neon Database
+
{
+ 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
+ >
+
+ Neon
+
+
+
+
+
+ You are connected to Neon Database
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
Neon Database
+
+ Neon Database has a good free tier with backups and up to 10 projects.
+
+
{
+ 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"
+ >
+ Connect to
+
+
+
+
+ );
+}
+
+function NeonSvg({
+ isDarkMode,
+ className,
+}: {
+ isDarkMode?: boolean;
+ className?: string;
+}) {
+ const textColor = isDarkMode ? "#fff" : "#000";
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/NeonDisconnectButton.tsx b/src/components/NeonDisconnectButton.tsx
new file mode 100644
index 0000000..2f4f4a7
--- /dev/null
+++ b/src/components/NeonDisconnectButton.tsx
@@ -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 (
+
+ Disconnect from Neon
+
+ );
+}
diff --git a/src/components/NeonIntegration.tsx b/src/components/NeonIntegration.tsx
new file mode 100644
index 0000000..81728bc
--- /dev/null
+++ b/src/components/NeonIntegration.tsx
@@ -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 (
+
+
+
+ Neon Integration
+
+
+ Your account is connected to Neon.
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/PortalMigrate.tsx b/src/components/PortalMigrate.tsx
new file mode 100644
index 0000000..99eeaf6
--- /dev/null
+++ b/src/components/PortalMigrate.tsx
@@ -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("");
+ 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 (
+
+
+
+
+ Portal Database Migration
+
+
+
+
+ Generate a new database migration file for your Portal app.
+
+
+
+
+ {migrateMutation.isPending ? (
+ <>
+
+ Generating...
+ >
+ ) : (
+ <>
+
+ Generate database migration
+ >
+ )}
+
+
+
+
+ Docs
+
+
+
+ {output && (
+
+
+
+ Command Output:
+
+
+
+
+ )}
+
+
+ );
+};
diff --git a/src/components/TemplateCard.tsx b/src/components/TemplateCard.tsx
index c165440..62c9768 100644
--- a/src/components/TemplateCard.tsx
+++ b/src/components/TemplateCard.tsx
@@ -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 = ({
template,
isSelected,
onSelect,
+ onCreateApp,
}) => {
const { settings, updateSettings } = useSettings();
const [showConsentDialog, setShowConsentDialog] = useState(false);
@@ -26,6 +31,11 @@ export const TemplateCard: React.FC = ({
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 = ({
>
{template.title}
- {template.isOfficial && (
+ {template.isOfficial && !template.isExperimental && (
= ({
Official
)}
+ {template.isExperimental && (
+
+ Experimental
+
+ )}
-
+
{template.description}
{template.githubUrl && (
@@ -121,6 +136,20 @@ export const TemplateCard: React.FC = ({
)}
+
+ {
+ 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
+
diff --git a/src/components/chat/ChatHeader.tsx b/src/components/chat/ChatHeader.tsx
index 1d42790..964279b 100644
--- a/src/components/chat/ChatHeader.tsx
+++ b/src/components/chat/ChatHeader.tsx
@@ -120,15 +120,26 @@ export function ChatHeader({
- Warning:
- You are not on a branch
-
+ {isAnyCheckoutVersionInProgress ? (
+ <>
+
+ Please wait, switching back to latest version...
+
+ >
+ ) : (
+ <>
+ Warning:
+ You are not on a branch
+
+ >
+ )}
- 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"}
@@ -152,7 +163,7 @@ export function ChatHeader({
>
{isRenamingBranch ? "Renaming..." : "Rename master to main"}
- ) : (
+ ) : isAnyCheckoutVersionInProgress && !isCheckingOutVersion ? null : (
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 (
-
Version History
-
-
-
+
Version History
+
+
+
+
+
{versions.length === 0 ? (
No versions available
) : (
- {versions.map((version: Version, index) => (
+ {versions.map((version: Version, index: number) => (
{
if (!isCheckingOutVersion) {
- handleVersionClick(version.oid);
+ handleVersionClick(version);
}
}}
>
-
- Version {versions.length - index}
-
-
- {formatDistanceToNow(new Date(version.timestamp * 1000), {
- addSuffix: true,
- })}
-
+
+
+ Version {versions.length - index} (
+ {version.oid.slice(0, 7)})
+
+ {/* 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 (
+
+
+
+
+ DB
+
+
+
+ {isExpired
+ ? "DB snapshot may have expired (older than 24 hours)"
+ : `Database snapshot available at timestamp ${version.dbTimestamp}`}
+
+
+ );
+ })()}
+
+
+ {isCheckingOutVersion &&
+ selectedVersionId === version.oid && (
+
+ )}
+
+ {isCheckingOutVersion && selectedVersionId === version.oid
+ ? "Loading..."
+ : formatDistanceToNow(
+ new Date(version.timestamp * 1000),
+ {
+ addSuffix: true,
+ },
+ )}
+
+
{version.message && (
@@ -158,30 +219,50 @@ export function VersionPane({ isVisible, onClose }: VersionPaneProps) {
)}
-
-
- {
- 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"
- >
-
- Restore
-
-
- Restore to this version
-
+
+ {/* Restore button */}
+
+
+ {
+ 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 ? (
+
+ ) : (
+
+ )}
+
+ {isRevertingVersion ? "Restoring..." : "Restore"}
+
+
+
+
+ {isRevertingVersion
+ ? "Restoring to this version..."
+ : "Restore to this version"}
+
+
+
))}
diff --git a/src/components/preview_panel/ConfigurePanel.tsx b/src/components/preview_panel/ConfigurePanel.tsx
index 94c8c49..5b8f291 100644
--- a/src/components/preview_panel/ConfigurePanel.tsx
+++ b/src/components/preview_panel/ConfigurePanel.tsx
@@ -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 = () => (
@@ -396,6 +397,12 @@ export const ConfigurePanel = () => {
+
+ {/* Neon Database Configuration */}
+ {/* Neon Connector */}
+
+
+
);
};
diff --git a/src/components/preview_panel/NeonConfigure.tsx b/src/components/preview_panel/NeonConfigure.tsx
new file mode 100644
index 0000000..c983e68
--- /dev/null
+++ b/src/components/preview_panel/NeonConfigure.tsx
@@ -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
({
+ 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 (
+
+
+
+
+ Neon Database
+
+
+
+
+
+ Loading Neon project information...
+
+
+
+
+ );
+ }
+
+ // Show error state
+ if (error) {
+ return (
+
+
+
+
+ Neon Database
+
+
+
+
+
+ Error loading Neon project: {error.message}
+
+
+
+
+ );
+ }
+
+ if (!neonProject) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+ Neon Database
+
+
+
+
+
+ {/* Project Information */}
+
+
Project Information
+
+
+ Project Name:
+ {neonProject.projectName}
+
+
+ Project ID:
+ {neonProject.projectId}
+
+
+ Organization:
+ {neonProject.orgId}
+
+
+
+
+ {/* Branches */}
+
+
+
+ Branches ({neonProject.branches.length})
+
+
+ {neonProject.branches.map((branch) => (
+
+
+
+
+ {branch.branchName}
+
+
+ {branch.type}
+
+
+
+ ID: {branch.branchId}
+
+ {branch.parentBranchName && (
+
+ Parent: {branch.parentBranchName.slice(0, 20)}...
+
+ )}
+
+ Updated: {formatDate(branch.lastUpdated)}
+
+
+
+ ))}
+
+
+
+
+ );
+};
diff --git a/src/components/preview_panel/PreviewIframe.tsx b/src/components/preview_panel/PreviewIframe.tsx
index d216383..1ab3dc5 100644
--- a/src/components/preview_panel/PreviewIframe.tsx
+++ b/src/components/preview_panel/PreviewIframe.tsx
@@ -413,7 +413,20 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
// Display loading state
if (loading) {
- return Loading app preview...
;
+ return (
+
+
+
+
+ Preparing app preview...
+
+
+
+ );
}
// Display message if no app is selected
@@ -565,7 +578,7 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
- Starting up your app...
+ Starting your app server...
) : (
diff --git a/src/components/preview_panel/PublishPanel.tsx b/src/components/preview_panel/PublishPanel.tsx
index 38e6398..72ee3cf 100644
--- a/src/components/preview_panel/PublishPanel.tsx
+++ b/src/components/preview_panel/PublishPanel.tsx
@@ -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 = () => {
+ {/* Portal Section - Show only if app has neon project */}
+ {app.neonProjectId &&
}
+
{/* GitHub Section */}
diff --git a/src/db/schema.ts b/src/db/schema.ts
index 4fae2f3..be46719 100644
--- a/src/db/schema.ts
+++ b/src/db/schema.ts
@@ -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],
+ }),
+}));
diff --git a/src/hooks/useCreateApp.ts b/src/hooks/useCreateApp.ts
new file mode 100644
index 0000000..735380d
--- /dev/null
+++ b/src/hooks/useCreateApp.ts
@@ -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({
+ 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 => {
+ return mutation.mutateAsync(params);
+ };
+
+ return {
+ createApp,
+ isCreating: mutation.isPending,
+ error: mutation.error,
+ };
+}
diff --git a/src/hooks/useRunApp.ts b/src/hooks/useRunApp.ts
index fcd0b92..216c8b6 100644
--- a/src/hooks/useRunApp.ts
+++ b/src/hooks/useRunApp.ts
@@ -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 () => {
diff --git a/src/hooks/useVersions.ts b/src/hooks/useVersions.ts
index 6a440fb..da37d35 100644
--- a/src/hooks/useVersions.ts
+++ b/src/hooks/useVersions.ts
@@ -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(
- {
- 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,
};
}
diff --git a/src/ipc/handlers/app_env_vars_handlers.ts b/src/ipc/handlers/app_env_vars_handlers.ts
index 0b1ea56..d8438ef 100644
--- a/src/ipc/handlers/app_env_vars_handlers.ts
+++ b/src/ipc/handlers/app_env_vars_handlers.ts
@@ -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);
diff --git a/src/ipc/handlers/app_handlers.ts b/src/ipc/handlers/app_handlers.ts
index 7278f52..5c2b739 100644
--- a/src/ipc/handlers/app_handlers.ts
+++ b/src/ipc/handlers/app_handlers.ts
@@ -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 {
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 {
- 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}`);
+ }
+ },
+ );
}
diff --git a/src/ipc/handlers/chat_stream_handlers.ts b/src/ipc/handlers/chat_stream_handlers.ts
index 16f9772..dd9b63b 100644
--- a/src/ipc/handlers/chat_stream_handlers.ts
+++ b/src/ipc/handlers/chat_stream_handlers.ts
@@ -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(
diff --git a/src/ipc/handlers/neon_handlers.ts b/src/ipc/handlers/neon_handlers.ts
new file mode 100644
index 0000000..10c218c
--- /dev/null
+++ b/src/ipc/handlers/neon_handlers.ts
@@ -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 => {
+ 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 => {
+ 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 ?? "",
+ 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.");
+ });
+}
diff --git a/src/ipc/handlers/portal_handlers.ts b/src/ipc/handlers/portal_handlers.ts
new file mode 100644
index 0000000..cb44c81
--- /dev/null
+++ b/src/ipc/handlers/portal_handlers.ts
@@ -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((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}`);
+ }
+ },
+ );
+}
diff --git a/src/ipc/handlers/token_count_handlers.ts b/src/ipc/handlers/token_count_handlers.ts
index 2525a50..09268ff 100644
--- a/src/ipc/handlers/token_count_handlers.ts
+++ b/src/ipc/handlers/token_count_handlers.ts
@@ -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;
}
diff --git a/src/ipc/handlers/version_handlers.ts b/src/ipc/handlers/version_handlers.ts
index 9c84ea1..679a604 100644
--- a/src/ipc/handlers/version_handlers.ts
+++ b/src/ipc/handlers/version_handlers.ts
@@ -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 {
+ 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 => {
+ { appId, previousVersionId }: RevertVersionParams,
+ ): Promise => {
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 => {
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,
+ });
+}
diff --git a/src/ipc/ipc_client.ts b/src/ipc/ipc_client.ts
index 087ba4a..a5f1c63 100644
--- a/src/ipc/ipc_client.ts
+++ b/src/ipc/ipc_client.ts
@@ -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 {
+ 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> {
try {
@@ -437,17 +455,10 @@ export class IpcClient {
}
// Revert to a specific version
- public async revertVersion({
- appId,
- previousVersionId,
- }: {
- appId: number;
- previousVersionId: string;
- }): Promise {
- await this.ipcRenderer.invoke("revert-version", {
- appId,
- previousVersionId,
- });
+ public async revertVersion(
+ params: RevertVersionParams,
+ ): Promise {
+ 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 {
+ await this.ipcRenderer.invoke("neon:fake-connect");
+ }
+
+ public async createNeonProject(
+ params: CreateNeonProjectParams,
+ ): Promise {
+ return this.ipcRenderer.invoke("neon:create-project", params);
+ }
+
+ public async getNeonProject(
+ params: GetNeonProjectParams,
+ ): Promise {
+ 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 {
return this.ipcRenderer.invoke("get-system-debug-info");
}
diff --git a/src/ipc/ipc_host.ts b/src/ipc/ipc_host.ts
index de6efd6..d6827f3 100644
--- a/src/ipc/ipc_host.ts
+++ b/src/ipc/ipc_host.ts
@@ -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();
}
diff --git a/src/ipc/ipc_types.ts b/src/ipc/ipc_types.ts
index 409bce5..2d0a380 100644
--- a/src/ipc/ipc_types.ts
+++ b/src/ipc/ipc_types.ts
@@ -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 };
diff --git a/src/ipc/processors/response_processor.ts b/src/ipc/processors/response_processor.ts
index 9edf10e..c758c0d 100644
--- a/src/ipc/processors/response_processor.ts
+++ b/src/ipc/processors/response_processor.ts
@@ -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[] = [];
diff --git a/src/ipc/utils/app_env_var_utils.ts b/src/ipc/utils/app_env_var_utils.ts
index 6aa552e..619697e 100644
--- a/src/ipc/utils/app_env_var_utils.ts
+++ b/src/ipc/utils/app_env_var_utils.ts
@@ -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 {
+ 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 {
+ return fs.promises.readFile(getEnvFilePath({ appPath }), "utf8");
+}
// Helper function to parse .env.local file content
export function parseEnvFile(content: string): EnvVar[] {
diff --git a/src/ipc/utils/neon_timestamp_utils.ts b/src/ipc/utils/neon_timestamp_utils.ts
new file mode 100644
index 0000000..b080062
--- /dev/null
+++ b/src/ipc/utils/neon_timestamp_utils.ts
@@ -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 {
+ 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;
+ }
+}
diff --git a/src/ipc/utils/retryOnLocked.ts b/src/ipc/utils/retryOnLocked.ts
new file mode 100644
index 0000000..7354bab
--- /dev/null
+++ b/src/ipc/utils/retryOnLocked.ts
@@ -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(
+ operation: () => Promise,
+ context: string,
+ {
+ retryBranchWithChildError = false,
+ }: { retryBranchWithChildError?: boolean } = {},
+): Promise {
+ 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;
+}
diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts
index 544e197..9f909db 100644
--- a/src/lib/schemas.ts
+++ b/src/lib/schemas.ts
@@ -90,6 +90,14 @@ export const SupabaseSchema = z.object({
});
export type Supabase = z.infer;
+export const NeonSchema = z.object({
+ accessToken: SecretSchema.optional(),
+ refreshToken: SecretSchema.optional(),
+ expiresIn: z.number().optional(),
+ tokenTimestamp: z.number().optional(),
+});
+export type Neon = z.infer;
+
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(),
diff --git a/src/lib/toast.tsx b/src/lib/toast.tsx
index 907e384..d71a30f 100644
--- a/src/lib/toast.tsx
+++ b/src/lib/toast.tsx
@@ -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) => (
+
+ ),
+ { duration: Infinity }, // Don't auto-close
+ );
+
+ return toastId;
+};
+
export const showExtraFilesToast = ({
files,
error,
diff --git a/src/main.ts b/src/main.ts
index 14d321c..3f00dd1 100644
--- a/src/main.ts
+++ b/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;
}
diff --git a/src/main/settings.ts b/src/main/settings.ts
index 311e372..81c320d 100644
--- a/src/main/settings.ts
+++ b/src/main/settings.ts
@@ -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): 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(
diff --git a/src/neon_admin/neon_management_client.ts b/src/neon_admin/neon_management_client.ts
new file mode 100644
index 0000000..b741f1b
--- /dev/null
+++ b/src/neon_admin/neon_management_client.ts
@@ -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 {
+ 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> {
+ 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;
+ }
+
+ 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 {
+ 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;
+}
diff --git a/src/neon_admin/neon_return_handler.ts b/src/neon_admin/neon_return_handler.ts
new file mode 100644
index 0000000..5d09015
--- /dev/null
+++ b/src/neon_admin/neon_return_handler.ts
@@ -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),
+ },
+ });
+}
diff --git a/src/pages/home.tsx b/src/pages/home.tsx
index db864b0..35bba86 100644
--- a/src/pages/home.tsx
+++ b/src/pages/home.tsx
@@ -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({
diff --git a/src/pages/hub.tsx b/src/pages/hub.tsx
index de21554..b77c4b0 100644
--- a/src/pages/hub.tsx
+++ b/src/pages/hub.tsx
@@ -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 (
-
+
router.history.back()}
variant="outline"
@@ -58,6 +63,7 @@ const HubPage: React.FC = () => {
template={template}
isSelected={template.id === selectedTemplateId}
onSelect={handleTemplateSelect}
+ onCreateApp={handleCreateApp}
/>
))}
@@ -77,14 +83,42 @@ const HubPage: React.FC = () => {
template={template}
isSelected={template.id === selectedTemplateId}
onSelect={handleTemplateSelect}
+ onCreateApp={handleCreateApp}
/>
))}
)}
+
+
+
+ t.id === settings?.selectedTemplateId)}
+ />
);
};
+function BackendSection() {
+ return (
+
+ );
+}
+
export default HubPage;
diff --git a/src/pages/settings.tsx b/src/pages/settings.tsx
index 1781123..d278abf 100644
--- a/src/pages/settings.tsx
+++ b/src/pages/settings.tsx
@@ -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() {
+
diff --git a/src/preload.ts b/src/preload.ts
index 7719944..8833f70 100644
--- a/src/preload.ts
+++ b/src/preload.ts
@@ -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
diff --git a/src/shared/templates.ts b/src/shared/templates.ts
index 956c9d3..3995831 100644
--- a/src/shared/templates.ts
+++ b/src/shared/templates.ts
@@ -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([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,
+ },
];
diff --git a/testing/fake-llm-server/chatCompletionHandler.ts b/testing/fake-llm-server/chatCompletionHandler.ts
index 095ce90..f4bb39d 100644
--- a/testing/fake-llm-server/chatCompletionHandler.ts
+++ b/testing/fake-llm-server/chatCompletionHandler.ts
@@ -24,7 +24,6 @@ export const createChatCompletionHandler =
}
let messageContent = CANNED_MESSAGE;
- console.error("LASTMESSAGE********", lastMessage.content);
if (
lastMessage &&