Neon / portal template support (#713)

TODOs:
- [x] Do restart when checkout / restore if there is a DB
- [x] List all branches (branch id, name, date)
- [x] Allow checking out versions with no DB
- [x] safeguard to never delete main branches
- [x] create app hook for neon template
- [x] weird UX with connector on configure panel
- [x] tiny neon logo in connector
- [x] deploy to vercel
- [x] build forgot password page
- [x] what about email setup
- [x] lots of imgix errors
- [x] edit file - db snapshot
- [x] DYAD_DISABLE_DB_PUSH
- [ ] update portal doc
- [x] switch preview branch to be read-only endpoint
- [x] disable supabase sys prompt if neon is enabled
- [ ] https://payloadcms.com/docs/upload/storage-adapters
- [x] need to use main branch...

Phase 2?
- [x] generate DB migrations
This commit is contained in:
Will Chen
2025-08-04 16:36:09 -07:00
committed by GitHub
parent 0f1a5c5c77
commit b0f08eaf15
50 changed files with 3525 additions and 205 deletions

View File

@@ -0,0 +1,14 @@
CREATE TABLE `versions` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`app_id` integer NOT NULL,
`commit_hash` text NOT NULL,
`neon_db_timestamp` text,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL,
FOREIGN KEY (`app_id`) REFERENCES `apps`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `versions_app_commit_unique` ON `versions` (`app_id`,`commit_hash`);--> statement-breakpoint
ALTER TABLE `apps` ADD `neon_project_id` text;--> statement-breakpoint
ALTER TABLE `apps` ADD `neon_development_branch_id` text;--> statement-breakpoint
ALTER TABLE `apps` ADD `neon_preview_branch_id` text;

View File

@@ -0,0 +1,510 @@
{
"version": "6",
"dialect": "sqlite",
"id": "4d1fc225-7395-4d56-8d0d-7f76fed4a8d8",
"prevId": "553360d1-7173-4bb0-9f31-ab49a0010279",
"tables": {
"apps": {
"name": "apps",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"path": {
"name": "path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"github_org": {
"name": "github_org",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"github_repo": {
"name": "github_repo",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"github_branch": {
"name": "github_branch",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"supabase_project_id": {
"name": "supabase_project_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"neon_project_id": {
"name": "neon_project_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"neon_development_branch_id": {
"name": "neon_development_branch_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"neon_preview_branch_id": {
"name": "neon_preview_branch_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"vercel_project_id": {
"name": "vercel_project_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"vercel_project_name": {
"name": "vercel_project_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"vercel_team_id": {
"name": "vercel_team_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"vercel_deployment_url": {
"name": "vercel_deployment_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"chat_context": {
"name": "chat_context",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"chats": {
"name": "chats",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"app_id": {
"name": "app_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"initial_commit_hash": {
"name": "initial_commit_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {
"chats_app_id_apps_id_fk": {
"name": "chats_app_id_apps_id_fk",
"tableFrom": "chats",
"tableTo": "apps",
"columnsFrom": [
"app_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"language_model_providers": {
"name": "language_model_providers",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"api_base_url": {
"name": "api_base_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"env_var_name": {
"name": "env_var_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"language_models": {
"name": "language_models",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"display_name": {
"name": "display_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"api_name": {
"name": "api_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"builtin_provider_id": {
"name": "builtin_provider_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"custom_provider_id": {
"name": "custom_provider_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"max_output_tokens": {
"name": "max_output_tokens",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"context_window": {
"name": "context_window",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {
"language_models_custom_provider_id_language_model_providers_id_fk": {
"name": "language_models_custom_provider_id_language_model_providers_id_fk",
"tableFrom": "language_models",
"tableTo": "language_model_providers",
"columnsFrom": [
"custom_provider_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"messages": {
"name": "messages",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"chat_id": {
"name": "chat_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"approval_state": {
"name": "approval_state",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"commit_hash": {
"name": "commit_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {
"messages_chat_id_chats_id_fk": {
"name": "messages_chat_id_chats_id_fk",
"tableFrom": "messages",
"tableTo": "chats",
"columnsFrom": [
"chat_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"versions": {
"name": "versions",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"app_id": {
"name": "app_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"commit_hash": {
"name": "commit_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"neon_db_timestamp": {
"name": "neon_db_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {
"versions_app_commit_unique": {
"name": "versions_app_commit_unique",
"columns": [
"app_id",
"commit_hash"
],
"isUnique": true
}
},
"foreignKeys": {
"versions_app_id_apps_id_fk": {
"name": "versions_app_id_apps_id_fk",
"tableFrom": "versions",
"tableTo": "apps",
"columnsFrom": [
"app_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -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
}
]
}

View File

@@ -481,11 +481,11 @@ export class PageObject {
}
locateLoadingAppPreview() {
return this.page.getByText("Loading app preview...");
return this.page.getByText("Preparing app preview...");
}
locateStartingAppPreview() {
return this.page.getByText("Starting up your app...");
return this.page.getByText("Starting your app server...");
}
getPreviewIframeElement() {

176
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -0,0 +1,45 @@
import { IpcClient } from "@/ipc/ipc_client";
import { v4 as uuidv4 } from "uuid";
export async function neonTemplateHook({
appId,
appName,
}: {
appId: number;
appName: string;
}) {
console.log("Creating Neon project");
const neonProject = await IpcClient.getInstance().createNeonProject({
name: appName,
appId: appId,
});
console.log("Neon project created", neonProject);
await IpcClient.getInstance().setAppEnvVars({
appId: appId,
envVars: [
{
key: "POSTGRES_URL",
value: neonProject.connectionString,
},
{
key: "PAYLOAD_SECRET",
value: uuidv4(),
},
{
key: "NEXT_PUBLIC_SERVER_URL",
value: "http://localhost:32100",
},
{
key: "GMAIL_USER",
value: "example@gmail.com",
},
{
key: "GOOGLE_APP_PASSWORD",
value: "GENERATE AT https://myaccount.google.com/apppasswords",
},
],
});
console.log("App env vars set");
}

View File

@@ -0,0 +1,137 @@
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useCreateApp } from "@/hooks/useCreateApp";
import { useCheckName } from "@/hooks/useCheckName";
import { useSetAtom } from "jotai";
import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { NEON_TEMPLATE_IDS, Template } from "@/shared/templates";
import { useRouter } from "@tanstack/react-router";
import { Loader2 } from "lucide-react";
import { neonTemplateHook } from "@/client_logic/template_hook";
import { showError } from "@/lib/toast";
interface CreateAppDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
template: Template | undefined;
}
export function CreateAppDialog({
open,
onOpenChange,
template,
}: CreateAppDialogProps) {
const setSelectedAppId = useSetAtom(selectedAppIdAtom);
const [appName, setAppName] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const { createApp } = useCreateApp();
const { data: nameCheckResult } = useCheckName(appName);
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!appName.trim()) {
return;
}
if (nameCheckResult?.exists) {
return;
}
setIsSubmitting(true);
try {
const result = await createApp({ name: appName.trim() });
if (template && NEON_TEMPLATE_IDS.has(template.id)) {
await neonTemplateHook({
appId: result.app.id,
appName: result.app.name,
});
}
setSelectedAppId(result.app.id);
// Navigate to the new app's first chat
router.navigate({
to: "/chat",
search: { id: result.chatId },
});
setAppName("");
onOpenChange(false);
} catch (error) {
showError(error as any);
// Error is already handled by createApp hook or shown above
console.error("Error creating app:", error);
} finally {
setIsSubmitting(false);
}
};
const isNameValid = appName.trim().length > 0;
const nameExists = nameCheckResult?.exists;
const canSubmit = isNameValid && !nameExists && !isSubmitting;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Create New App</DialogTitle>
<DialogDescription>
{`Create a new app using the ${template?.title} template.`}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="appName">App Name</Label>
<Input
id="appName"
value={appName}
onChange={(e) => setAppName(e.target.value)}
placeholder="Enter app name..."
className={nameExists ? "border-red-500" : ""}
disabled={isSubmitting}
/>
{nameExists && (
<p className="text-sm text-red-500">
An app with this name already exists
</p>
)}
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button
type="submit"
disabled={!canSubmit}
className="bg-indigo-600 hover:bg-indigo-700"
>
{isSubmitting && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{isSubmitting ? "Creating..." : "Create App"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,89 @@
import React from "react";
import { toast } from "sonner";
import { X, AlertTriangle } from "lucide-react";
import { Button } from "./ui/button";
interface InputRequestToastProps {
message: string;
toastId: string | number;
onResponse: (response: "y" | "n") => void;
}
export function InputRequestToast({
message,
toastId,
onResponse,
}: InputRequestToastProps) {
const handleClose = () => {
toast.dismiss(toastId);
};
const handleResponse = (response: "y" | "n") => {
onResponse(response);
toast.dismiss(toastId);
};
// Clean up the message by removing excessive newlines and whitespace
const cleanMessage = message
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0)
.join("\n");
return (
<div className="relative bg-amber-50/95 dark:bg-slate-800/95 backdrop-blur-sm border border-amber-200 dark:border-slate-600 rounded-xl shadow-lg min-w-[400px] max-w-[500px] overflow-hidden">
{/* Content */}
<div className="p-5">
<div className="flex items-start">
<div className="flex-1">
<div className="flex items-center mb-4">
<div className="flex-shrink-0">
<div className="w-6 h-6 bg-gradient-to-br from-amber-500 to-amber-600 dark:from-amber-400 dark:to-amber-500 rounded-full flex items-center justify-center shadow-sm">
<AlertTriangle className="w-3.5 h-3.5 text-white" />
</div>
</div>
<h3 className="ml-3 text-base font-semibold text-amber-900 dark:text-amber-100">
Input Required
</h3>
{/* Close button */}
<button
onClick={handleClose}
className="ml-auto flex-shrink-0 p-1.5 text-amber-500 dark:text-slate-400 hover:text-amber-700 dark:hover:text-slate-200 transition-colors duration-200 rounded-md hover:bg-amber-100/50 dark:hover:bg-slate-700/50"
aria-label="Close"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Message */}
<div className="mb-5">
<p className="text-sm text-amber-900 dark:text-slate-200 whitespace-pre-wrap leading-relaxed">
{cleanMessage}
</p>
</div>
{/* Action buttons */}
<div className="flex items-center gap-3">
<Button
onClick={() => handleResponse("y")}
size="sm"
className="bg-primary text-white dark:bg-primary dark:text-black px-6"
>
Yes
</Button>
<Button
onClick={() => handleResponse("n")}
size="sm"
variant="outline"
className="border-amber-300 dark:border-slate-500 text-amber-800 dark:text-slate-300 hover:bg-amber-100 dark:hover:bg-slate-700 px-6"
>
No
</Button>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,157 @@
import { useEffect } from "react";
import { Button } from "@/components/ui/button";
import { IpcClient } from "@/ipc/ipc_client";
import { toast } from "sonner";
import { useSettings } from "@/hooks/useSettings";
import { useDeepLink } from "@/contexts/DeepLinkContext";
import { ExternalLink } from "lucide-react";
import { useTheme } from "@/contexts/ThemeContext";
import { NeonDisconnectButton } from "@/components/NeonDisconnectButton";
export function NeonConnector() {
const { settings, refreshSettings } = useSettings();
const { lastDeepLink } = useDeepLink();
const { isDarkMode } = useTheme();
useEffect(() => {
const handleDeepLink = async () => {
if (lastDeepLink?.type === "neon-oauth-return") {
await refreshSettings();
toast.success("Successfully connected to Neon!");
}
};
handleDeepLink();
}, [lastDeepLink]);
if (settings?.neon?.accessToken) {
return (
<div className="flex flex-col space-y-4 p-4 border bg-white dark:bg-gray-800 max-w-100 rounded-md">
<div className="flex flex-col items-start justify-between">
<div className="flex items-center justify-between w-full">
<h2 className="text-lg font-medium pb-1">Neon Database</h2>
<Button
variant="outline"
onClick={() => {
IpcClient.getInstance().openExternalUrl(
"https://console.neon.tech/",
);
}}
className="ml-2 px-2 py-1 h-8 mb-2"
style={{ display: "inline-flex", alignItems: "center" }}
asChild
>
<div className="flex items-center gap-1">
Neon
<ExternalLink className="h-3 w-3" />
</div>
</Button>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 pb-3">
You are connected to Neon Database
</p>
<NeonDisconnectButton />
</div>
</div>
);
}
return (
<div className="flex flex-col space-y-4 p-4 border bg-white dark:bg-gray-800 max-w-100 rounded-md">
<div className="flex flex-col items-start justify-between">
<h2 className="text-lg font-medium pb-1">Neon Database</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 pb-3">
Neon Database has a good free tier with backups and up to 10 projects.
</p>
<div
onClick={async () => {
if (settings?.isTestMode) {
await IpcClient.getInstance().fakeHandleNeonConnect();
} else {
await IpcClient.getInstance().openExternalUrl(
"https://oauth.dyad.sh/api/integrations/neon/login",
);
}
}}
className="w-auto h-10 cursor-pointer flex items-center justify-center px-4 py-2 rounded-md border-2 transition-colors font-medium text-sm dark:bg-gray-900 dark:border-gray-700"
data-testid="connect-neon-button"
>
<span className="mr-2">Connect to</span>
<NeonSvg isDarkMode={isDarkMode} />
</div>
</div>
</div>
);
}
function NeonSvg({
isDarkMode,
className,
}: {
isDarkMode?: boolean;
className?: string;
}) {
const textColor = isDarkMode ? "#fff" : "#000";
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="68"
height="18"
fill="none"
viewBox="0 0 102 28"
className={className}
>
<path
fill="#12FFF7"
fillRule="evenodd"
d="M0 4.828C0 2.16 2.172 0 4.851 0h18.436c2.679 0 4.85 2.161 4.85 4.828V20.43c0 2.758-3.507 3.955-5.208 1.778l-5.318-6.809v8.256c0 2.4-1.955 4.345-4.367 4.345H4.851C2.172 28 0 25.839 0 23.172zm4.851-.966a.97.97 0 0 0-.97.966v18.344c0 .534.435.966.97.966h8.539c.268 0 .34-.216.34-.483v-11.07c0-2.76 3.507-3.956 5.208-1.779l5.319 6.809V4.828c0-.534.05-.966-.485-.966z"
clipRule="evenodd"
/>
<path
fill="url(#a)"
fillRule="evenodd"
d="M0 4.828C0 2.16 2.172 0 4.851 0h18.436c2.679 0 4.85 2.161 4.85 4.828V20.43c0 2.758-3.507 3.955-5.208 1.778l-5.318-6.809v8.256c0 2.4-1.955 4.345-4.367 4.345H4.851C2.172 28 0 25.839 0 23.172zm4.851-.966a.97.97 0 0 0-.97.966v18.344c0 .534.435.966.97.966h8.539c.268 0 .34-.216.34-.483v-11.07c0-2.76 3.507-3.956 5.208-1.779l5.319 6.809V4.828c0-.534.05-.966-.485-.966z"
clipRule="evenodd"
/>
<path
fill="url(#b)"
fillRule="evenodd"
d="M0 4.828C0 2.16 2.172 0 4.851 0h18.436c2.679 0 4.85 2.161 4.85 4.828V20.43c0 2.758-3.507 3.955-5.208 1.778l-5.318-6.809v8.256c0 2.4-1.955 4.345-4.367 4.345H4.851C2.172 28 0 25.839 0 23.172zm4.851-.966a.97.97 0 0 0-.97.966v18.344c0 .534.435.966.97.966h8.539c.268 0 .34-.216.34-.483v-11.07c0-2.76 3.507-3.956 5.208-1.779l5.319 6.809V4.828c0-.534.05-.966-.485-.966z"
clipRule="evenodd"
/>
<path
fill="#B9FFB3"
d="M23.287 0c2.679 0 4.85 2.161 4.85 4.828V20.43c0 2.758-3.507 3.955-5.208 1.778l-5.319-6.809v8.256c0 2.4-1.954 4.345-4.366 4.345a.484.484 0 0 0 .485-.483V12.584c0-2.758 3.508-3.955 5.21-1.777l5.318 6.808V.965a.97.97 0 0 0-.97-.965"
/>
<path
fill={textColor}
d="M48.112 7.432v8.032l-7.355-8.032H36.93v13.136h3.49v-8.632l8.01 8.632h3.173V7.432zM58.075 17.64v-2.326h7.815v-2.797h-7.815V10.36h9.48V7.432H54.514v13.136H67.75v-2.927zM77.028 21c4.909 0 8.098-2.552 8.098-7s-3.19-7-8.098-7c-4.91 0-8.081 2.552-8.081 7s3.172 7 8.08 7m0-3.115c-2.73 0-4.413-1.408-4.413-3.885s1.701-3.885 4.413-3.885c2.729 0 4.412 1.408 4.412 3.885s-1.683 3.885-4.412 3.885M98.508 7.432v8.032l-7.355-8.032h-3.828v13.136h3.491v-8.632l8.01 8.632H102V7.432z"
/>
<defs>
<linearGradient
id="a"
x1="28.138"
x2="3.533"
y1="28"
y2="-.12"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#B9FFB3" />
<stop offset="1" stopColor="#B9FFB3" stopOpacity="0" />
</linearGradient>
<linearGradient
id="b"
x1="28.138"
x2="11.447"
y1="28"
y2="21.476"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#1A1A1A" stopOpacity=".9" />
<stop offset="1" stopColor="#1A1A1A" stopOpacity="0" />
</linearGradient>
</defs>
</svg>
);
}

View File

@@ -0,0 +1,38 @@
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { useSettings } from "@/hooks/useSettings";
interface NeonDisconnectButtonProps {
className?: string;
}
export function NeonDisconnectButton({ className }: NeonDisconnectButtonProps) {
const { updateSettings, settings } = useSettings();
const handleDisconnect = async () => {
try {
await updateSettings({
neon: undefined,
});
toast.success("Disconnected from Neon successfully");
} catch (error) {
console.error("Failed to disconnect from Neon:", error);
toast.error("Failed to disconnect from Neon");
}
};
if (!settings?.neon?.accessToken) {
return null;
}
return (
<Button
variant="destructive"
onClick={handleDisconnect}
className={className}
size="sm"
>
Disconnect from Neon
</Button>
);
}

View File

@@ -0,0 +1,29 @@
import { useSettings } from "@/hooks/useSettings";
import { NeonDisconnectButton } from "@/components/NeonDisconnectButton";
export function NeonIntegration() {
const { settings } = useSettings();
const isConnected = !!settings?.neon?.accessToken;
if (!isConnected) {
return null;
}
return (
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
Neon Integration
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Your account is connected to Neon.
</p>
</div>
<div className="flex items-center gap-2">
<NeonDisconnectButton />
</div>
</div>
);
}

View File

@@ -0,0 +1,110 @@
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { IpcClient } from "@/ipc/ipc_client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { ExternalLink, Database, Loader2 } from "lucide-react";
import { showSuccess, showError } from "@/lib/toast";
import { useVersions } from "@/hooks/useVersions";
interface PortalMigrateProps {
appId: number;
}
export const PortalMigrate = ({ appId }: PortalMigrateProps) => {
const [output, setOutput] = useState<string>("");
const { refreshVersions } = useVersions(appId);
const migrateMutation = useMutation({
mutationFn: async () => {
const ipcClient = IpcClient.getInstance();
return ipcClient.portalMigrateCreate({ appId });
},
onSuccess: (result) => {
setOutput(result.output);
showSuccess(
"Database migration file generated and committed successfully!",
);
refreshVersions();
},
onError: (error) => {
const errorMessage =
error instanceof Error ? error.message : String(error);
setOutput(`Error: ${errorMessage}`);
showError(errorMessage);
},
});
const handleCreateMigration = () => {
setOutput(""); // Clear previous output
migrateMutation.mutate();
};
const openDocs = () => {
const ipcClient = IpcClient.getInstance();
ipcClient.openExternalUrl(
"https://www.dyad.sh/docs/templates/portal#create-a-database-migration",
);
};
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2">
<Database className="w-5 h-5 text-primary" />
Portal Database Migration
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
Generate a new database migration file for your Portal app.
</p>
<div className="flex items-center gap-3">
<Button
onClick={handleCreateMigration}
disabled={migrateMutation.isPending}
// className="bg-primary hover:bg-purple-700 text-white"
>
{migrateMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Generating...
</>
) : (
<>
<Database className="w-4 h-4 mr-2" />
Generate database migration
</>
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={openDocs}
className="text-sm"
>
<ExternalLink className="w-3 h-3 mr-1" />
Docs
</Button>
</div>
{output && (
<div className="mt-4">
<div className="bg-gray-50 dark:bg-gray-900 border rounded-lg p-3">
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Command Output:
</h4>
<div className="max-h-64 overflow-auto">
<pre className="text-xs text-gray-600 dark:text-gray-400 whitespace-pre-wrap font-mono">
{output}
</pre>
</div>
</div>
</div>
)}
</CardContent>
</Card>
);
};

View File

@@ -4,17 +4,22 @@ import { IpcClient } from "@/ipc/ipc_client";
import { useSettings } from "@/hooks/useSettings";
import { CommunityCodeConsentDialog } from "./CommunityCodeConsentDialog";
import type { Template } from "@/shared/templates";
import { Button } from "./ui/button";
import { cn } from "@/lib/utils";
import { showWarning } from "@/lib/toast";
interface TemplateCardProps {
template: Template;
isSelected: boolean;
onSelect: (templateId: string) => void;
onCreateApp: () => void;
}
export const TemplateCard: React.FC<TemplateCardProps> = ({
template,
isSelected,
onSelect,
onCreateApp,
}) => {
const { settings, updateSettings } = useSettings();
const [showConsentDialog, setShowConsentDialog] = useState(false);
@@ -26,6 +31,11 @@ export const TemplateCard: React.FC<TemplateCardProps> = ({
return;
}
if (template.requiresNeon && !settings?.neon?.accessToken) {
showWarning("Please connect your Neon account to use this template.");
return;
}
// Otherwise, proceed with selection
onSelect(template.id);
};
@@ -93,7 +103,7 @@ export const TemplateCard: React.FC<TemplateCardProps> = ({
>
{template.title}
</h2>
{template.isOfficial && (
{template.isOfficial && !template.isExperimental && (
<span
className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
isSelected
@@ -104,8 +114,13 @@ export const TemplateCard: React.FC<TemplateCardProps> = ({
Official
</span>
)}
{template.isExperimental && (
<span className="text-xs font-semibold px-2 py-0.5 rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-700 dark:text-yellow-200">
Experimental
</span>
)}
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3 h-8 overflow-y-auto">
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3 h-10 overflow-y-auto">
{template.description}
</p>
{template.githubUrl && (
@@ -121,6 +136,20 @@ export const TemplateCard: React.FC<TemplateCardProps> = ({
<ArrowLeft className="w-4 h-4 ml-1 transform rotate-180" />
</a>
)}
<Button
onClick={(e) => {
e.stopPropagation();
onCreateApp();
}}
size="sm"
className={cn(
"w-full bg-indigo-600 hover:bg-indigo-700 text-white font-semibold mt-2",
settings?.selectedTemplateId !== template.id && "invisible",
)}
>
Create App
</Button>
</div>
</div>

View File

@@ -120,15 +120,26 @@ export function ChatHeader({
<Tooltip>
<TooltipTrigger asChild>
<span className="flex items-center gap-1">
{isAnyCheckoutVersionInProgress ? (
<>
<span>
Please wait, switching back to latest version...
</span>
</>
) : (
<>
<strong>Warning:</strong>
<span>You are not on a branch</span>
<Info size={14} />
</>
)}
</span>
</TooltipTrigger>
<TooltipContent>
<p>
Checkout main branch, otherwise changes will not be
saved properly
{isAnyCheckoutVersionInProgress
? "Version checkout is currently in progress"
: "Checkout main branch, otherwise changes will not be saved properly"}
</p>
</TooltipContent>
</Tooltip>
@@ -152,7 +163,7 @@ export function ChatHeader({
>
{isRenamingBranch ? "Renaming..." : "Rename master to main"}
</Button>
) : (
) : isAnyCheckoutVersionInProgress && !isCheckingOutVersion ? null : (
<Button
variant="outline"
size="sm"

View File

@@ -2,7 +2,7 @@ import { useAtom, useAtomValue } from "jotai";
import { selectedAppIdAtom, selectedVersionIdAtom } from "@/atoms/appAtoms";
import { useVersions } from "@/hooks/useVersions";
import { formatDistanceToNow } from "date-fns";
import { RotateCcw, X } from "lucide-react";
import { RotateCcw, X, Database, Loader2 } from "lucide-react";
import type { Version } from "@/ipc/ipc_types";
import { cn } from "@/lib/utils";
import { useEffect, useRef, useState } from "react";
@@ -14,6 +14,8 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useRunApp } from "@/hooks/useRunApp";
interface VersionPaneProps {
isVisible: boolean;
onClose: () => void;
@@ -21,12 +23,15 @@ interface VersionPaneProps {
export function VersionPane({ isVisible, onClose }: VersionPaneProps) {
const appId = useAtomValue(selectedAppIdAtom);
const { refreshApp } = useLoadApp(appId);
const { refreshApp, app } = useLoadApp(appId);
const { restartApp } = useRunApp();
const {
versions: liveVersions,
refreshVersions,
revertVersion,
isRevertingVersion,
} = useVersions(appId);
const [selectedVersionId, setSelectedVersionId] = useAtom(
selectedVersionIdAtom,
);
@@ -49,6 +54,9 @@ export function VersionPane({ isVisible, onClose }: VersionPaneProps) {
setSelectedVersionId(null);
if (appId) {
await checkoutVersion({ appId, versionId: "main" });
if (app?.neonProjectId) {
await restartApp();
}
}
}
@@ -76,16 +84,19 @@ export function VersionPane({ isVisible, onClose }: VersionPaneProps) {
return null;
}
const handleVersionClick = async (versionOid: string) => {
const handleVersionClick = async (version: Version) => {
if (appId) {
setSelectedVersionId(versionOid);
setSelectedVersionId(version.oid);
try {
await checkoutVersion({ appId, versionId: versionOid });
await checkoutVersion({ appId, versionId: version.oid });
} catch (error) {
console.error("Could not checkout version, unselecting version", error);
setSelectedVersionId(null);
}
await refreshApp();
if (version.dbTimestamp) {
await restartApp();
}
}
};
@@ -94,7 +105,8 @@ export function VersionPane({ isVisible, onClose }: VersionPaneProps) {
return (
<div className="h-full border-t border-2 border-border w-full">
<div className="p-2 border-b border-border flex items-center justify-between">
<h2 className="text-base font-semibold pl-2">Version History</h2>
<h2 className="text-base font-medium pl-2">Version History</h2>
<div className="flex items-center gap-2">
<button
onClick={onClose}
className="p-1 hover:bg-(--background-lightest) rounded-md "
@@ -103,12 +115,13 @@ export function VersionPane({ isVisible, onClose }: VersionPaneProps) {
<X size={20} />
</button>
</div>
</div>
<div className="overflow-y-auto h-[calc(100%-60px)]">
{versions.length === 0 ? (
<div className="p-4 ">No versions available</div>
) : (
<div className="divide-y divide-border">
{versions.map((version: Version, index) => (
{versions.map((version: Version, index: number) => (
<div
key={version.oid}
className={cn(
@@ -121,20 +134,68 @@ export function VersionPane({ isVisible, onClose }: VersionPaneProps) {
)}
onClick={() => {
if (!isCheckingOutVersion) {
handleVersionClick(version.oid);
handleVersionClick(version);
}
}}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="font-medium text-xs">
Version {versions.length - index}
Version {versions.length - index} (
{version.oid.slice(0, 7)})
</span>
{/* example format: '2025-07-25T21:52:01Z' */}
{version.dbTimestamp &&
(() => {
const timestampMs = new Date(
version.dbTimestamp,
).getTime();
const isExpired =
Date.now() - timestampMs > 24 * 60 * 60 * 1000;
return (
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-md",
isExpired
? "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300",
)}
>
<Database size={10} />
<span>DB</span>
</div>
</TooltipTrigger>
<TooltipContent>
{isExpired
? "DB snapshot may have expired (older than 24 hours)"
: `Database snapshot available at timestamp ${version.dbTimestamp}`}
</TooltipContent>
</Tooltip>
);
})()}
</div>
<div className="flex items-center gap-2">
{isCheckingOutVersion &&
selectedVersionId === version.oid && (
<Loader2
size={12}
className="animate-spin text-primary"
/>
)}
<span className="text-xs opacity-90">
{formatDistanceToNow(new Date(version.timestamp * 1000), {
{isCheckingOutVersion && selectedVersionId === version.oid
? "Loading..."
: formatDistanceToNow(
new Date(version.timestamp * 1000),
{
addSuffix: true,
})}
},
)}
</span>
</div>
</div>
<div className="flex items-center justify-between gap-2">
{version.message && (
<p className="mt-1 text-sm">
@@ -158,32 +219,52 @@ export function VersionPane({ isVisible, onClose }: VersionPaneProps) {
</p>
)}
<div className="flex items-center gap-1">
{/* Restore button */}
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={async (e) => {
e.stopPropagation();
setSelectedVersionId(null);
await revertVersion({
versionId: version.oid,
});
setSelectedVersionId(null);
// Close the pane after revert to force a refresh on next open
onClose();
if (version.dbTimestamp) {
await restartApp();
}
}}
disabled={isRevertingVersion}
className={cn(
"invisible mt-1 flex items-center gap-1 px-2 py-0.5 text-sm font-medium bg-(--primary) text-(--primary-foreground) hover:bg-background-lightest rounded-md transition-colors",
selectedVersionId === version.oid && "visible",
isRevertingVersion &&
"opacity-50 cursor-not-allowed",
)}
aria-label="Restore to this version"
>
{isRevertingVersion ? (
<Loader2 size={12} className="animate-spin" />
) : (
<RotateCcw size={12} />
<span>Restore</span>
)}
<span>
{isRevertingVersion ? "Restoring..." : "Restore"}
</span>
</button>
</TooltipTrigger>
<TooltipContent>Restore to this version</TooltipContent>
<TooltipContent>
{isRevertingVersion
? "Restoring to this version..."
: "Restore to this version"}
</TooltipContent>
</Tooltip>
</div>
</div>
</div>
))}
</div>
)}

View File

@@ -23,6 +23,7 @@ import { showError, showSuccess } from "@/lib/toast";
import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { IpcClient } from "@/ipc/ipc_client";
import { useNavigate } from "@tanstack/react-router";
import { NeonConfigure } from "./NeonConfigure";
const EnvironmentVariablesTitle = () => (
<div className="flex items-center gap-2">
@@ -396,6 +397,12 @@ export const ConfigurePanel = () => {
</div>
</CardContent>
</Card>
{/* Neon Database Configuration */}
{/* Neon Connector */}
<div className="grid grid-cols-1 gap-6">
<NeonConfigure />
</div>
</div>
);
};

View File

@@ -0,0 +1,178 @@
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Database, GitBranch } from "lucide-react";
import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { useLoadApp } from "@/hooks/useLoadApp";
import { IpcClient } from "@/ipc/ipc_client";
import type { GetNeonProjectResponse, NeonBranch } from "@/ipc/ipc_types";
import { NeonDisconnectButton } from "@/components/NeonDisconnectButton";
const getBranchTypeColor = (type: NeonBranch["type"]) => {
switch (type) {
case "production":
return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300";
case "development":
return "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300";
case "snapshot":
return "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300";
case "preview":
return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300";
default:
return "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300";
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString();
};
export const NeonConfigure = () => {
const selectedAppId = useAtomValue(selectedAppIdAtom);
const { app } = useLoadApp(selectedAppId);
// Query to get Neon project information
const {
data: neonProject,
isLoading,
error,
} = useQuery<GetNeonProjectResponse, Error>({
queryKey: ["neon-project", selectedAppId],
queryFn: async () => {
if (!selectedAppId) throw new Error("No app selected");
const ipcClient = IpcClient.getInstance();
return await ipcClient.getNeonProject({ appId: selectedAppId });
},
enabled: !!selectedAppId && !!app?.neonProjectId,
meta: { showErrorToast: true },
});
// Don't show component if app doesn't have Neon project
if (!app?.neonProjectId) {
return null;
}
// Show loading state
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database size={20} />
Neon Database
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-center py-8">
<div className="text-sm text-muted-foreground">
Loading Neon project information...
</div>
</div>
</CardContent>
</Card>
);
}
// Show error state
if (error) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database size={20} />
Neon Database
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-center py-8">
<div className="text-sm text-red-500">
Error loading Neon project: {error.message}
</div>
</div>
</CardContent>
</Card>
);
}
if (!neonProject) {
return null;
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 justify-between">
<div className="flex items-center gap-2">
<Database size={20} />
Neon Database
</div>
<NeonDisconnectButton />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Project Information */}
<div className="space-y-2">
<div className="text-sm font-medium">Project Information</div>
<div className="bg-muted/50 p-3 rounded-md space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Project Name:</span>
<span className="font-medium">{neonProject.projectName}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Project ID:</span>
<span className="font-mono text-xs">{neonProject.projectId}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Organization:</span>
<span className="font-mono text-xs">{neonProject.orgId}</span>
</div>
</div>
</div>
{/* Branches */}
<div className="space-y-2">
<div className="text-sm font-medium flex items-center gap-2">
<GitBranch size={16} />
Branches ({neonProject.branches.length})
</div>
<div className="space-y-2">
{neonProject.branches.map((branch) => (
<div
key={branch.branchId}
className="flex items-center justify-between p-3 border rounded-md"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm truncate">
{branch.branchName}
</span>
<Badge
variant="secondary"
className={getBranchTypeColor(branch.type)}
>
{branch.type}
</Badge>
</div>
<div className="text-xs text-muted-foreground truncate">
ID: {branch.branchId}
</div>
{branch.parentBranchName && (
<div className="text-xs text-muted-foreground">
Parent: {branch.parentBranchName.slice(0, 20)}...
</div>
)}
<div className="text-xs text-muted-foreground">
Updated: {formatDate(branch.lastUpdated)}
</div>
</div>
</div>
))}
</div>
</div>
</CardContent>
</Card>
);
};

View File

@@ -413,7 +413,20 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
// Display loading state
if (loading) {
return <div className="p-4 dark:text-gray-300">Loading app preview...</div>;
return (
<div className="flex flex-col h-full relative">
<div className="absolute inset-0 flex flex-col items-center justify-center space-y-4 bg-gray-50 dark:bg-gray-950">
<div className="relative w-5 h-5 animate-spin">
<div className="absolute top-0 left-1/2 transform -translate-x-1/2 w-2 h-2 bg-primary rounded-full"></div>
<div className="absolute bottom-0 left-0 w-2 h-2 bg-primary rounded-full opacity-80"></div>
<div className="absolute bottom-0 right-0 w-2 h-2 bg-primary rounded-full opacity-60"></div>
</div>
<p className="text-gray-600 dark:text-gray-300">
Preparing app preview...
</p>
</div>
</div>
);
}
// Display message if no app is selected
@@ -565,7 +578,7 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
<div className="absolute inset-0 flex flex-col items-center justify-center space-y-4 bg-gray-50 dark:bg-gray-950">
<Loader2 className="w-8 h-8 animate-spin text-gray-400 dark:text-gray-500" />
<p className="text-gray-600 dark:text-gray-300">
Starting up your app...
Starting your app server...
</p>
</div>
) : (

View File

@@ -3,6 +3,7 @@ import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { useLoadApp } from "@/hooks/useLoadApp";
import { GitHubConnector } from "@/components/GitHubConnector";
import { VercelConnector } from "@/components/VercelConnector";
import { PortalMigrate } from "@/components/PortalMigrate";
import { IpcClient } from "@/ipc/ipc_client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -78,6 +79,9 @@ export const PublishPanel = () => {
</h1>
</div>
{/* Portal Section - Show only if app has neon project */}
{app.neonProjectId && <PortalMigrate appId={selectedAppId} />}
{/* GitHub Section */}
<Card>
<CardHeader className="pb-3">

View File

@@ -1,5 +1,5 @@
import { sql } from "drizzle-orm";
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { integer, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
import { relations } from "drizzle-orm";
export const apps = sqliteTable("apps", {
@@ -16,6 +16,9 @@ export const apps = sqliteTable("apps", {
githubRepo: text("github_repo"),
githubBranch: text("github_branch"),
supabaseProjectId: text("supabase_project_id"),
neonProjectId: text("neon_project_id"),
neonDevelopmentBranchId: text("neon_development_branch_id"),
neonPreviewBranchId: text("neon_preview_branch_id"),
vercelProjectId: text("vercel_project_id"),
vercelProjectName: text("vercel_project_name"),
vercelTeamId: text("vercel_team_id"),
@@ -51,9 +54,32 @@ export const messages = sqliteTable("messages", {
.default(sql`(unixepoch())`),
});
export const versions = sqliteTable(
"versions",
{
id: integer("id").primaryKey({ autoIncrement: true }),
appId: integer("app_id")
.notNull()
.references(() => apps.id, { onDelete: "cascade" }),
commitHash: text("commit_hash").notNull(),
neonDbTimestamp: text("neon_db_timestamp"),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
},
(table) => [
// Unique constraint to prevent duplicate versions
unique("versions_app_commit_unique").on(table.appId, table.commitHash),
],
);
// Define relations
export const appsRelations = relations(apps, ({ many }) => ({
chats: many(chats),
versions: many(versions),
}));
export const chatsRelations = relations(chats, ({ many, one }) => ({
@@ -124,3 +150,10 @@ export const languageModelsRelations = relations(
}),
}),
);
export const versionsRelations = relations(versions, ({ one }) => ({
app: one(apps, {
fields: [versions.appId],
references: [apps.id],
}),
}));

38
src/hooks/useCreateApp.ts Normal file
View File

@@ -0,0 +1,38 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { IpcClient } from "@/ipc/ipc_client";
import { showError } from "@/lib/toast";
import type { CreateAppParams, CreateAppResult } from "@/ipc/ipc_types";
export function useCreateApp() {
const queryClient = useQueryClient();
const mutation = useMutation<CreateAppResult, Error, CreateAppParams>({
mutationFn: async (params: CreateAppParams) => {
if (!params.name.trim()) {
throw new Error("App name is required");
}
const ipcClient = IpcClient.getInstance();
return ipcClient.createApp(params);
},
onSuccess: () => {
// Invalidate apps list to trigger refetch
queryClient.invalidateQueries({ queryKey: ["apps"] });
},
onError: (error) => {
showError(error);
},
});
const createApp = async (
params: CreateAppParams,
): Promise<CreateAppResult> => {
return mutation.mutateAsync(params);
};
return {
createApp,
isCreating: mutation.isPending,
error: mutation.error,
};
}

View File

@@ -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,22 +40,54 @@ export function useRunApp() {
const originalUrl = originalUrlMatch && originalUrlMatch[1];
setAppUrlObj({
appUrl: proxyUrl,
appId: appId!,
appId: output.appId,
originalUrl: originalUrl!,
});
}
}
};
const runApp = useCallback(async (appId: number) => {
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
}
// 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
if (appUrlObj?.appId !== appId) {
setAppUrlObj({ appUrl: null, appId: null, originalUrl: null });
setAppUrlObj((prevAppUrlObj) => {
if (prevAppUrlObj?.appId !== appId) {
return { appUrl: null, appId: null, originalUrl: null };
}
return prevAppUrlObj; // No change needed
});
setAppOutput((prev) => [
...prev,
{
@@ -66,10 +99,7 @@ export function useRunApp() {
]);
const app = await ipcClient.getApp(appId);
setApp(app);
await ipcClient.runApp(appId, (output) => {
setAppOutput((prev) => [...prev, output]);
processProxyServerOutput(output);
});
await ipcClient.runApp(appId, processAppOutput);
setPreviewErrorMessage(undefined);
} catch (error) {
console.error(`Error running app ${appId}:`, error);
@@ -79,7 +109,9 @@ export function useRunApp() {
} 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 () => {

View File

@@ -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,20 +39,28 @@ export function useVersions(appId: number | null) {
}
}, [versions, setVersionsAtom]);
const revertVersionMutation = useMutation<void, Error, { versionId: string }>(
{
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();
await ipcClient.revertVersion({
return ipcClient.revertVersion({
appId: currentAppId,
previousVersionId: versionId,
});
},
onSuccess: async () => {
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],
@@ -65,8 +74,7 @@ export function useVersions(appId: number | null) {
});
},
meta: { showErrorToast: true },
},
);
});
return {
versions: versions || [],
@@ -74,5 +82,6 @@ export function useVersions(appId: number | null) {
error,
refreshVersions,
revertVersion: revertVersionMutation.mutateAsync,
isRevertingVersion: revertVersionMutation.isPending,
};
}

View File

@@ -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);

View File

@@ -8,6 +8,7 @@ import type {
RenameBranchParams,
CopyAppParams,
EditAppFileReturnType,
RespondToAppInputParams,
} from "../ipc_types";
import fs from "node:fs";
import path from "node:path";
@@ -47,6 +48,7 @@ import { safeSend } from "../utils/safe_sender";
import { normalizePath } from "../../../shared/normalizePath";
import { isServerFunction } from "@/supabase_admin/supabase_utils";
import { getVercelTeamSlug } from "../utils/vercel_utils";
import { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils";
async function copyDir(
source: string,
@@ -80,28 +82,32 @@ async function executeApp({
appPath,
appId,
event, // Keep event for local-node case
isNeon,
}: {
appPath: string;
appId: number;
event: Electron.IpcMainInvokeEvent;
isNeon: boolean;
}): Promise<void> {
if (proxyWorker) {
proxyWorker.terminate();
proxyWorker = null;
}
await executeAppLocalNode({ appPath, appId, event });
await executeAppLocalNode({ appPath, appId, event, isNeon });
}
async function executeAppLocalNode({
appPath,
appId,
event,
isNeon,
}: {
appPath: string;
appId: number;
event: Electron.IpcMainInvokeEvent;
isNeon: boolean;
}): Promise<void> {
const process = spawn(
const spawnedProcess = spawn(
"(pnpm install && pnpm run dev --port 32100) || (npm install --legacy-peer-deps && npm run dev -- --port 32100)",
[],
{
@@ -113,11 +119,11 @@ async function executeAppLocalNode({
);
// Check if process spawned correctly
if (!process.pid) {
if (!spawnedProcess.pid) {
// Attempt to capture any immediate errors if possible
let errorOutput = "";
process.stderr?.on("data", (data) => (errorOutput += data));
await new Promise((resolve) => process.on("error", resolve)); // Wait for error event
spawnedProcess.stderr?.on("data", (data) => (errorOutput += data));
await new Promise((resolve) => spawnedProcess.on("error", resolve)); // Wait for error event
throw new Error(
`Failed to spawn process for app ${appId}. Error: ${
errorOutput || "Unknown spawn error"
@@ -127,18 +133,49 @@ 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}`,
);
// 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], {
@@ -151,11 +188,14 @@ async function executeAppLocalNode({
},
});
}
}
});
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}`);
}
},
);
}

View File

@@ -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(

View File

@@ -0,0 +1,235 @@
import log from "electron-log";
import { createTestOnlyLoggedHandler } from "./safe_handle";
import { handleNeonOAuthReturn } from "../../neon_admin/neon_return_handler";
import {
getNeonClient,
getNeonErrorMessage,
getNeonOrganizationId,
} from "../../neon_admin/neon_management_client";
import {
CreateNeonProjectParams,
NeonProject,
GetNeonProjectParams,
GetNeonProjectResponse,
NeonBranch,
} from "../ipc_types";
import { db } from "../../db";
import { apps } from "../../db/schema";
import { eq } from "drizzle-orm";
import { ipcMain } from "electron";
import { EndpointType } from "@neondatabase/api-client";
import { retryOnLocked } from "../utils/retryOnLocked";
export const logger = log.scope("neon_handlers");
const testOnlyHandle = createTestOnlyLoggedHandler(logger);
export function registerNeonHandlers() {
// Do not use log handler because there's sensitive data in the response
ipcMain.handle(
"neon:create-project",
async (
_,
{ name, appId }: CreateNeonProjectParams,
): Promise<NeonProject> => {
const neonClient = await getNeonClient();
logger.info(`Creating Neon project: ${name} for app ${appId}`);
try {
// Get the organization ID
const orgId = await getNeonOrganizationId();
// Create project with retry on locked errors
const response = await retryOnLocked(
() =>
neonClient.createProject({
project: {
name: name,
org_id: orgId,
},
}),
`Create project ${name} for app ${appId}`,
);
if (!response.data.project) {
throw new Error(
"Failed to create project: No project data returned.",
);
}
const project = response.data.project;
const developmentBranch = response.data.branch;
const previewBranchResponse = await retryOnLocked(
() =>
neonClient.createProjectBranch(project.id, {
endpoints: [{ type: EndpointType.ReadOnly }],
branch: {
name: "preview",
parent_id: developmentBranch.id,
},
}),
`Create preview branch for project ${project.id}`,
);
if (
!previewBranchResponse.data.branch ||
!previewBranchResponse.data.connection_uris
) {
throw new Error(
"Failed to create preview branch: No branch data returned.",
);
}
const previewBranch = previewBranchResponse.data.branch;
// Store project and branch info in the app's DB row
await db
.update(apps)
.set({
neonProjectId: project.id,
neonDevelopmentBranchId: developmentBranch.id,
neonPreviewBranchId: previewBranch.id,
})
.where(eq(apps.id, appId));
logger.info(
`Successfully created Neon project: ${project.id} and development branch: ${developmentBranch.id} for app ${appId}`,
);
return {
id: project.id,
name: project.name,
connectionString: response.data.connection_uris[0].connection_uri,
branchId: developmentBranch.id,
};
} catch (error: any) {
const errorMessage = getNeonErrorMessage(error);
const message = `Failed to create Neon project for app ${appId}: ${errorMessage}`;
logger.error(message);
throw new Error(message);
}
},
);
ipcMain.handle(
"neon:get-project",
async (
_,
{ appId }: GetNeonProjectParams,
): Promise<GetNeonProjectResponse> => {
logger.info(`Getting Neon project info for app ${appId}`);
try {
// Get the app from the database to find the neonProjectId and neonBranchId
const app = await db
.select()
.from(apps)
.where(eq(apps.id, appId))
.limit(1);
if (app.length === 0) {
throw new Error(`App with ID ${appId} not found`);
}
const appData = app[0];
if (!appData.neonProjectId) {
throw new Error(`No Neon project found for app ${appId}`);
}
const neonClient = await getNeonClient();
console.log("PROJECT ID", appData.neonProjectId);
// Get project info
const projectResponse = await neonClient.getProject(
appData.neonProjectId,
);
if (!projectResponse.data.project) {
throw new Error("Failed to get project: No project data returned.");
}
const project = projectResponse.data.project;
// Get list of branches
const branchesResponse = await neonClient.listProjectBranches({
projectId: appData.neonProjectId,
});
if (!branchesResponse.data.branches) {
throw new Error("Failed to get branches: No branch data returned.");
}
// Map branches to our format
const branches: NeonBranch[] = branchesResponse.data.branches.map(
(branch) => {
let type: "production" | "development" | "snapshot" | "preview";
if (branch.default) {
type = "production";
} else if (branch.id === appData.neonDevelopmentBranchId) {
type = "development";
} else if (branch.id === appData.neonPreviewBranchId) {
type = "preview";
} else {
type = "snapshot";
}
// Find parent branch name if parent_id exists
let parentBranchName: string | undefined;
if (branch.parent_id) {
const parentBranch = branchesResponse.data.branches?.find(
(b) => b.id === branch.parent_id,
);
parentBranchName = parentBranch?.name;
}
return {
type,
branchId: branch.id,
branchName: branch.name,
lastUpdated: branch.updated_at,
parentBranchId: branch.parent_id,
parentBranchName,
};
},
);
logger.info(
`Successfully retrieved Neon project info for app ${appId}`,
);
return {
projectId: project.id,
projectName: project.name,
orgId: project.org_id ?? "<unknown_org_id>",
branches,
};
} catch (error) {
logger.error(
`Failed to get Neon project info for app ${appId}:`,
error,
);
throw error;
}
},
);
testOnlyHandle("neon:fake-connect", async (event) => {
// Call handleNeonOAuthReturn with fake data
handleNeonOAuthReturn({
token: "fake-neon-access-token",
refreshToken: "fake-neon-refresh-token",
expiresIn: 3600, // 1 hour
});
logger.info("Called handleNeonOAuthReturn with fake data during testing.");
// Simulate the deep link event
event.sender.send("deep-link-received", {
type: "neon-oauth-return",
url: "https://oauth.dyad.sh/api/integrations/neon/login",
});
logger.info("Sent fake neon deep-link-received event during testing.");
});
}

View File

@@ -0,0 +1,138 @@
import { createLoggedHandler } from "./safe_handle";
import log from "electron-log";
import { db } from "../../db";
import { apps } from "../../db/schema";
import { eq } from "drizzle-orm";
import { getDyadAppPath } from "../../paths/paths";
import { spawn } from "child_process";
import fs from "node:fs";
import git from "isomorphic-git";
import { gitCommit } from "../utils/git_utils";
import { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils";
const logger = log.scope("portal_handlers");
const handle = createLoggedHandler(logger);
async function getApp(appId: number) {
const app = await db.query.apps.findFirst({
where: eq(apps.id, appId),
});
if (!app) {
throw new Error(`App with id ${appId} not found`);
}
return app;
}
export function registerPortalHandlers() {
handle(
"portal:migrate-create",
async (_, { appId }: { appId: number }): Promise<{ output: string }> => {
const app = await getApp(appId);
const appPath = getDyadAppPath(app.path);
// Run the migration command
const migrationOutput = await new Promise<string>((resolve, reject) => {
logger.info(`Running migrate:create for app ${appId} at ${appPath}`);
const process = spawn("npm run migrate:create -- --skip-empty", {
cwd: appPath,
shell: true,
stdio: "pipe",
});
let stdout = "";
let stderr = "";
process.stdout?.on("data", (data) => {
const output = data.toString();
stdout += output;
logger.info(`migrate:create stdout: ${output}`);
if (output.includes("created or renamed from another")) {
process.stdin.write(`\r\n`);
logger.info(
`App ${appId} (PID: ${process.pid}) wrote enter to stdin to automatically respond to drizzle migrate input`,
);
}
});
process.stderr?.on("data", (data) => {
const output = data.toString();
stderr += output;
logger.warn(`migrate:create stderr: ${output}`);
});
process.on("close", (code) => {
const combinedOutput =
stdout + (stderr ? `\n\nErrors/Warnings:\n${stderr}` : "");
if (code === 0) {
if (stdout.includes("Migration created at")) {
logger.info(
`migrate:create completed successfully for app ${appId}`,
);
resolve(combinedOutput);
} else {
logger.error(
`migrate:create completed successfully for app ${appId} but no migration was created`,
);
reject(
new Error(
"No migration was created because no changes were found.",
),
);
}
} else {
logger.error(
`migrate:create failed for app ${appId} with exit code ${code}`,
);
const errorMessage = `Migration creation failed (exit code ${code})\n\n${combinedOutput}`;
reject(new Error(errorMessage));
}
});
process.on("error", (err) => {
logger.error(`Failed to spawn migrate:create for app ${appId}:`, err);
const errorMessage = `Failed to run migration command: ${err.message}\n\nOutput:\n${stdout}\n\nErrors:\n${stderr}`;
reject(new Error(errorMessage));
});
});
if (app.neonProjectId && app.neonDevelopmentBranchId) {
try {
await storeDbTimestampAtCurrentVersion({
appId: app.id,
});
} catch (error) {
logger.error(
"Error storing Neon timestamp at current version:",
error,
);
throw new Error(
"Could not store Neon timestamp at current version; database versioning functionality is not working: " +
error,
);
}
}
// Stage all changes and commit
try {
await git.add({
fs,
dir: appPath,
filepath: ".",
});
const commitHash = await gitCommit({
path: appPath,
message: "[dyad] Generate database migration file",
});
logger.info(`Successfully committed migration changes: ${commitHash}`);
return { output: migrationOutput };
} catch (gitError) {
logger.error(`Migration created but failed to commit: ${gitError}`);
throw new Error(`Migration created but failed to commit: ${gitError}`);
}
},
);
}

View File

@@ -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;
}

View File

@@ -1,7 +1,12 @@
import { db } from "../../db";
import { apps, messages } from "../../db/schema";
import { apps, messages, versions } from "../../db/schema";
import { desc, eq, and, gt } from "drizzle-orm";
import type { Version, BranchResult } from "../ipc_types";
import type {
Version,
BranchResult,
RevertVersionParams,
RevertVersionResponse,
} from "../ipc_types";
import fs from "node:fs";
import path from "node:path";
import { getDyadAppPath } from "../../paths/paths";
@@ -11,10 +16,51 @@ import log from "electron-log";
import { createLoggedHandler } from "./safe_handle";
import { gitCheckout, gitCommit, gitStageToRevert } from "../utils/git_utils";
import {
getNeonClient,
getNeonErrorMessage,
} from "../../neon_admin/neon_management_client";
import {
updatePostgresUrlEnvVar,
updateDbPushEnvVar,
} from "../utils/app_env_var_utils";
import { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils";
import { retryOnLocked } from "../utils/retryOnLocked";
const logger = log.scope("version_handlers");
const handle = createLoggedHandler(logger);
async function restoreBranchForPreview({
appId,
dbTimestamp,
neonProjectId,
previewBranchId,
developmentBranchId,
}: {
appId: number;
dbTimestamp: string;
neonProjectId: string;
previewBranchId: string;
developmentBranchId: string;
}): Promise<void> {
try {
const neonClient = await getNeonClient();
await retryOnLocked(
() =>
neonClient.restoreProjectBranch(neonProjectId, previewBranchId, {
source_branch_id: developmentBranchId,
source_timestamp: dbTimestamp,
}),
`Restore preview branch ${previewBranchId} for app ${appId}`,
);
} catch (error) {
const errorMessage = getNeonErrorMessage(error);
logger.error("Error in restoreBranchForPreview:", errorMessage);
throw new Error(errorMessage);
}
}
export function registerVersionHandlers() {
handle("list-versions", async (_, { appId }: { appId: number }) => {
const app = await db.query.apps.findFirst({
@@ -40,11 +86,32 @@ export function registerVersionHandlers() {
depth: 100_000, // Limit to last 100_000 commits for performance
});
return commits.map((commit: ReadCommitResult) => ({
// 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,
})) satisfies Version[];
dbTimestamp: snapshotInfo?.neonDbTimestamp,
};
}) satisfies Version[];
});
handle(
@@ -86,12 +153,11 @@ export function registerVersionHandlers() {
"revert-version",
async (
_,
{
appId,
previousVersionId,
}: { appId: number; previousVersionId: string },
): Promise<void> => {
{ appId, previousVersionId }: RevertVersionParams,
): Promise<RevertVersionResponse> => {
return withLock(appId, async () => {
let successMessage = "Restored version";
let warningMessage: string | undefined = undefined;
const app = await db.query.apps.findFirst({
where: eq(apps.id, appId),
});
@@ -101,12 +167,26 @@ export function registerVersionHandlers() {
}
const appPath = getDyadAppPath(app.path);
// Get the current commit hash before reverting
const currentCommitHash = await git.resolveRef({
fs,
dir: appPath,
ref: "main",
});
await gitCheckout({
path: appPath,
ref: "main",
});
if (app.neonProjectId && app.neonDevelopmentBranchId) {
// We are going to add a new commit on top, so let's store
// the current timestamp at the current version.
await storeDbTimestampAtCurrentVersion({
appId,
});
}
await gitStageToRevert({
path: appPath,
targetOid: previousVersionId,
@@ -154,6 +234,87 @@ export function registerVersionHandlers() {
);
}
}
if (app.neonProjectId && app.neonDevelopmentBranchId) {
const version = await db.query.versions.findFirst({
where: and(
eq(versions.appId, appId),
eq(versions.commitHash, previousVersionId),
),
});
if (version && version.neonDbTimestamp) {
try {
const preserveBranchName = `preserve_${currentCommitHash}-${Date.now()}`;
const neonClient = await getNeonClient();
const response = await retryOnLocked(
() =>
neonClient.restoreProjectBranch(
app.neonProjectId!,
app.neonDevelopmentBranchId!,
{
source_branch_id: app.neonDevelopmentBranchId!,
source_timestamp: version.neonDbTimestamp!,
preserve_under_name: preserveBranchName,
},
),
`Restore development branch ${app.neonDevelopmentBranchId} for app ${appId}`,
);
// Update all versions which have a newer DB timestamp than the version we're restoring to
// and remove their DB timestamp.
await db
.update(versions)
.set({ neonDbTimestamp: null })
.where(
and(
eq(versions.appId, appId),
gt(versions.neonDbTimestamp, version.neonDbTimestamp),
),
);
const preserveBranchId = response.data.branch.parent_id;
if (!preserveBranchId) {
throw new Error("Preserve branch ID not found");
}
logger.info(
`Deleting preserve branch ${preserveBranchId} for app ${appId}`,
);
try {
// Intentionally do not await this because it's not
// critical for the restore operation, it's to clean up branches
// so the user doesn't hit the branch limit later.
retryOnLocked(
() =>
neonClient.deleteProjectBranch(
app.neonProjectId!,
preserveBranchId,
),
`Delete preserve branch ${preserveBranchId} for app ${appId}`,
{ retryBranchWithChildError: true },
);
} catch (error) {
const errorMessage = getNeonErrorMessage(error);
logger.error("Error in deleteProjectBranch:", errorMessage);
}
} catch (error) {
const errorMessage = getNeonErrorMessage(error);
logger.error("Error in restoreBranchForCheckout:", errorMessage);
warningMessage = `Could not restore database because of error: ${errorMessage}`;
// Do not throw, so we can finish switching the postgres branch
// It might throw because they picked a timestamp that's too old.
}
successMessage =
"Successfully restored to version (including database)";
}
await switchPostgresToDevelopmentBranch({
neonProjectId: app.neonProjectId,
neonDevelopmentBranchId: app.neonDevelopmentBranchId,
appPath: app.path,
});
}
if (warningMessage) {
return { warningMessage };
}
return { successMessage };
});
},
);
@@ -162,7 +323,7 @@ export function registerVersionHandlers() {
"checkout-version",
async (
_,
{ appId, versionId }: { appId: number; versionId: string },
{ appId, versionId: gitRef }: { appId: number; versionId: string },
): Promise<void> => {
return withLock(appId, async () => {
const app = await db.query.apps.findFirst({
@@ -173,13 +334,106 @@ export function registerVersionHandlers() {
throw new Error("App not found");
}
const appPath = getDyadAppPath(app.path);
if (
app.neonProjectId &&
app.neonDevelopmentBranchId &&
app.neonPreviewBranchId
) {
if (gitRef === "main") {
logger.info(
`Switching Postgres to development branch for app ${appId}`,
);
await switchPostgresToDevelopmentBranch({
neonProjectId: app.neonProjectId,
neonDevelopmentBranchId: app.neonDevelopmentBranchId,
appPath: app.path,
});
} else {
logger.info(
`Switching Postgres to preview branch for app ${appId}`,
);
// Regardless of whether we have a timestamp or not, we want to disable DB push
// while we're checking out an earlier version
await updateDbPushEnvVar({
appPath: app.path,
disabled: true,
});
const version = await db.query.versions.findFirst({
where: and(
eq(versions.appId, appId),
eq(versions.commitHash, gitRef),
),
});
if (version && version.neonDbTimestamp) {
// SWITCH the env var for POSTGRES_URL to the preview branch
const neonClient = await getNeonClient();
const connectionUri = await neonClient.getConnectionUri({
projectId: app.neonProjectId,
branch_id: app.neonPreviewBranchId,
// This is the default database name for Neon
database_name: "neondb",
// This is the default role name for Neon
role_name: "neondb_owner",
});
await restoreBranchForPreview({
appId,
dbTimestamp: version.neonDbTimestamp,
neonProjectId: app.neonProjectId,
previewBranchId: app.neonPreviewBranchId,
developmentBranchId: app.neonDevelopmentBranchId,
});
await updatePostgresUrlEnvVar({
appPath: app.path,
connectionUri: connectionUri.data.uri,
});
logger.info(
`Switched Postgres to preview branch for app ${appId} commit ${version.commitHash} dbTimestamp=${version.neonDbTimestamp}`,
);
}
}
}
const fullAppPath = getDyadAppPath(app.path);
await gitCheckout({
path: appPath,
ref: versionId,
path: fullAppPath,
ref: gitRef,
});
});
},
);
}
async function switchPostgresToDevelopmentBranch({
neonProjectId,
neonDevelopmentBranchId,
appPath,
}: {
neonProjectId: string;
neonDevelopmentBranchId: string;
appPath: string;
}) {
// SWITCH the env var for POSTGRES_URL to the development branch
const neonClient = await getNeonClient();
const connectionUri = await neonClient.getConnectionUri({
projectId: neonProjectId,
branch_id: neonDevelopmentBranchId,
// This is the default database name for Neon
database_name: "neondb",
// This is the default role name for Neon
role_name: "neondb_owner",
});
await updatePostgresUrlEnvVar({
appPath,
connectionUri: connectionUri.data.uri,
});
await updateDbPushEnvVar({
appPath,
disabled: false,
});
}

View File

@@ -51,6 +51,13 @@ import type {
VercelProject,
UpdateChatParams,
FileAttachment,
CreateNeonProjectParams,
NeonProject,
GetNeonProjectParams,
GetNeonProjectResponse,
RevertVersionResponse,
RevertVersionParams,
RespondToAppInputParams,
} from "./ipc_types";
import type { Template } from "../shared/templates";
import type { AppChatContext, ProposalResult } from "@/lib/schemas";
@@ -82,7 +89,6 @@ export interface GitHubDeviceFlowErrorData {
export interface DeepLinkData {
type: string;
url?: string;
}
interface DeleteCustomModelParams {
@@ -412,6 +418,18 @@ export class IpcClient {
}
}
// Respond to an app input request (y/n prompts)
public async respondToAppInput(
params: RespondToAppInputParams,
): Promise<void> {
try {
await this.ipcRenderer.invoke("respond-to-app-input", params);
} catch (error) {
showError(error);
throw error;
}
}
// Get allow-listed environment variables
public async getEnvVars(): Promise<Record<string, string | undefined>> {
try {
@@ -437,17 +455,10 @@ export class IpcClient {
}
// Revert to a specific version
public async revertVersion({
appId,
previousVersionId,
}: {
appId: number;
previousVersionId: string;
}): Promise<void> {
await this.ipcRenderer.invoke("revert-version", {
appId,
previousVersionId,
});
public async revertVersion(
params: RevertVersionParams,
): Promise<RevertVersionResponse> {
return this.ipcRenderer.invoke("revert-version", params);
}
// Checkout a specific version without creating a revert commit
@@ -794,6 +805,34 @@ export class IpcClient {
// --- End Supabase Management ---
// --- Neon Management ---
public async fakeHandleNeonConnect(): Promise<void> {
await this.ipcRenderer.invoke("neon:fake-connect");
}
public async createNeonProject(
params: CreateNeonProjectParams,
): Promise<NeonProject> {
return this.ipcRenderer.invoke("neon:create-project", params);
}
public async getNeonProject(
params: GetNeonProjectParams,
): Promise<GetNeonProjectResponse> {
return this.ipcRenderer.invoke("neon:get-project", params);
}
// --- End Neon Management ---
// --- Portal Management ---
public async portalMigrateCreate(params: {
appId: number;
}): Promise<{ output: string }> {
return this.ipcRenderer.invoke("portal:migrate-create", params);
}
// --- End Portal Management ---
public async getSystemDebugInfo(): Promise<SystemDebugInfo> {
return this.ipcRenderer.invoke("get-system-debug-info");
}

View File

@@ -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();
}

View File

@@ -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 };

View File

@@ -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[] = [];

View File

@@ -3,7 +3,109 @@
* Environment variables are sensitive and should not be logged.
*/
import { getDyadAppPath } from "@/paths/paths";
import { EnvVar } from "../ipc_types";
import path from "path";
import fs from "fs";
import log from "electron-log";
const logger = log.scope("app_env_var_utils");
export const ENV_FILE_NAME = ".env.local";
function getEnvFilePath({ appPath }: { appPath: string }): string {
return path.join(getDyadAppPath(appPath), ENV_FILE_NAME);
}
export async function updatePostgresUrlEnvVar({
appPath,
connectionUri,
}: {
appPath: string;
connectionUri: string;
}) {
// Given the connection uri, update the env var for POSTGRES_URL
const envVars = parseEnvFile(await readEnvFile({ appPath }));
// Find existing POSTGRES_URL or add it if it doesn't exist
const existingVar = envVars.find((envVar) => envVar.key === "POSTGRES_URL");
if (existingVar) {
existingVar.value = connectionUri;
} else {
envVars.push({
key: "POSTGRES_URL",
value: connectionUri,
});
}
const envFileContents = serializeEnvFile(envVars);
await fs.promises.writeFile(getEnvFilePath({ appPath }), envFileContents);
}
export async function updateDbPushEnvVar({
appPath,
disabled,
}: {
appPath: string;
disabled: boolean;
}) {
try {
// Try to read existing env file
let envVars: EnvVar[];
try {
const content = await readEnvFile({ appPath });
envVars = parseEnvFile(content);
} catch {
// If file doesn't exist, start with empty array
envVars = [];
}
// Update or add DYAD_DISABLE_DB_PUSH
const existingVar = envVars.find(
(envVar) => envVar.key === "DYAD_DISABLE_DB_PUSH",
);
if (existingVar) {
existingVar.value = disabled ? "true" : "false";
} else {
envVars.push({
key: "DYAD_DISABLE_DB_PUSH",
value: disabled ? "true" : "false",
});
}
const envFileContents = serializeEnvFile(envVars);
await fs.promises.writeFile(getEnvFilePath({ appPath }), envFileContents);
} catch (error) {
logger.error(
`Failed to update DB push environment variable for app ${appPath}: ${error}`,
);
throw error;
}
}
export async function readPostgresUrlFromEnvFile({
appPath,
}: {
appPath: string;
}): Promise<string> {
const contents = await readEnvFile({ appPath });
const envVars = parseEnvFile(contents);
const postgresUrl = envVars.find(
(envVar) => envVar.key === "POSTGRES_URL",
)?.value;
if (!postgresUrl) {
throw new Error("POSTGRES_URL not found in .env.local");
}
return postgresUrl;
}
export async function readEnvFile({
appPath,
}: {
appPath: string;
}): Promise<string> {
return fs.promises.readFile(getEnvFilePath({ appPath }), "utf8");
}
// Helper function to parse .env.local file content
export function parseEnvFile(content: string): EnvVar[] {

View File

@@ -0,0 +1,134 @@
import { db } from "../../db";
import { versions, apps } from "../../db/schema";
import { eq, and } from "drizzle-orm";
import fs from "node:fs";
import git from "isomorphic-git";
import { getDyadAppPath } from "../../paths/paths";
import { neon } from "@neondatabase/serverless";
import log from "electron-log";
import { getNeonClient } from "@/neon_admin/neon_management_client";
const logger = log.scope("neon_timestamp_utils");
/**
* Retrieves the current timestamp from a Neon database
*/
async function getLastUpdatedTimestampFromNeon({
neonConnectionUri,
}: {
neonConnectionUri: string;
}): Promise<string> {
try {
const sql = neon(neonConnectionUri);
const [{ current_timestamp }] = await sql`
SELECT TO_CHAR(NOW() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z') AS current_timestamp
`;
return current_timestamp;
} catch (error) {
logger.error("Error retrieving timestamp from Neon:", error);
throw new Error(`Failed to retrieve timestamp from Neon: ${error}`);
}
}
/**
* Stores a Neon database timestamp for the current git commit hash
* and stores it in the versions table
* @param appId - The app ID
* @param neonConnectionUri - The Neon connection URI to get the timestamp from
*/
export async function storeDbTimestampAtCurrentVersion({
appId,
}: {
appId: number;
}): Promise<{ timestamp: string }> {
try {
logger.info(`Storing DB timestamp for current version - app ${appId}`);
// 1. Get the app to find the path
const app = await db.query.apps.findFirst({
where: eq(apps.id, appId),
});
if (!app) {
throw new Error(`App with ID ${appId} not found`);
}
if (!app.neonProjectId || !app.neonDevelopmentBranchId) {
throw new Error(`App with ID ${appId} has no Neon project or branch`);
}
// 2. Get the current commit hash
const appPath = getDyadAppPath(app.path);
const currentCommitHash = await git.resolveRef({
fs,
dir: appPath,
ref: "HEAD",
});
logger.info(`Current commit hash: ${currentCommitHash}`);
const neonClient = await getNeonClient();
const connectionUri = await neonClient.getConnectionUri({
projectId: app.neonProjectId,
branch_id: app.neonDevelopmentBranchId,
database_name: "neondb",
role_name: "neondb_owner",
});
// 3. Get the current timestamp from Neon
const currentTimestamp = await getLastUpdatedTimestampFromNeon({
neonConnectionUri: connectionUri.data.uri,
});
logger.info(`Current timestamp from Neon: ${currentTimestamp}`);
// 4. Check if a version with this commit hash already exists
const existingVersion = await db.query.versions.findFirst({
where: and(
eq(versions.appId, appId),
eq(versions.commitHash, currentCommitHash),
),
});
if (existingVersion) {
// Update existing version with the new timestamp
await db
.update(versions)
.set({
neonDbTimestamp: currentTimestamp,
updatedAt: new Date(),
})
.where(
and(
eq(versions.appId, appId),
eq(versions.commitHash, currentCommitHash),
),
);
logger.info(
`Updated existing version record with timestamp ${currentTimestamp}`,
);
} else {
// Create new version record
await db.insert(versions).values({
appId,
commitHash: currentCommitHash,
neonDbTimestamp: currentTimestamp,
});
logger.info(
`Created new version record for commit ${currentCommitHash} with timestamp ${currentTimestamp}`,
);
}
logger.info(
`Successfully stored timestamp for commit ${currentCommitHash} in app ${appId}`,
);
return { timestamp: currentTimestamp };
} catch (error) {
logger.error("Error in storeDbTimestampAtCurrentVersion:", error);
throw error;
}
}

View File

@@ -0,0 +1,71 @@
import log from "electron-log";
export const logger = log.scope("retryOnLocked");
export function isLockedError(error: any): boolean {
return error.response?.status === 423;
}
// Retry configuration
const RETRY_CONFIG = {
maxRetries: 6,
baseDelay: 1000, // 1 second
maxDelay: 90_000, // 90 seconds
jitterFactor: 0.1, // 10% jitter
};
/**
* Retries an async operation with exponential backoff on locked errors (423)
*/
export async function retryOnLocked<T>(
operation: () => Promise<T>,
context: string,
{
retryBranchWithChildError = false,
}: { retryBranchWithChildError?: boolean } = {},
): Promise<T> {
let lastError: any;
for (let attempt = 0; attempt <= RETRY_CONFIG.maxRetries; attempt++) {
try {
const result = await operation();
logger.info(`${context}: Success after ${attempt + 1} attempts`);
return result;
} catch (error: any) {
lastError = error;
// Only retry on locked errors
if (!isLockedError(error)) {
if (retryBranchWithChildError && error.response?.status === 422) {
logger.info(
`${context}: Branch with child error (attempt ${attempt + 1}/${RETRY_CONFIG.maxRetries + 1})`,
);
} else {
throw error;
}
}
// Don't retry if we've exhausted all attempts
if (attempt === RETRY_CONFIG.maxRetries) {
logger.error(
`${context}: Failed after ${RETRY_CONFIG.maxRetries + 1} attempts due to locked error`,
);
throw error;
}
// Calculate delay with exponential backoff and jitter
const baseDelay = RETRY_CONFIG.baseDelay * Math.pow(2, attempt);
const jitter = baseDelay * RETRY_CONFIG.jitterFactor * Math.random();
const delay = Math.min(baseDelay + jitter, RETRY_CONFIG.maxDelay);
logger.warn(
`${context}: Locked error (attempt ${attempt + 1}/${RETRY_CONFIG.maxRetries + 1}), retrying in ${Math.round(delay)}ms`,
);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
throw lastError;
}

View File

@@ -90,6 +90,14 @@ export const SupabaseSchema = z.object({
});
export type Supabase = z.infer<typeof SupabaseSchema>;
export const NeonSchema = z.object({
accessToken: SecretSchema.optional(),
refreshToken: SecretSchema.optional(),
expiresIn: z.number().optional(),
tokenTimestamp: z.number().optional(),
});
export type Neon = z.infer<typeof NeonSchema>;
export const ExperimentsSchema = z.object({
// Deprecated
enableSupabaseIntegration: z.boolean().describe("DEPRECATED").optional(),
@@ -138,6 +146,7 @@ export const UserSettingsSchema = z.object({
githubAccessToken: SecretSchema.optional(),
vercelAccessToken: SecretSchema.optional(),
supabase: SupabaseSchema.optional(),
neon: NeonSchema.optional(),
autoApproveChanges: z.boolean().optional(),
telemetryConsent: z.enum(["opted_in", "opted_out", "unset"]).optional(),
telemetryUserId: z.string().optional(),

View File

@@ -2,6 +2,7 @@ import { toast } from "sonner";
import { PostHog } from "posthog-js";
import React from "react";
import { CustomErrorToast } from "../components/CustomErrorToast";
import { InputRequestToast } from "../components/InputRequestToast";
/**
* Toast utility functions for consistent notifications across the app
@@ -87,6 +88,29 @@ export const showInfo = (message: string) => {
toast.info(message);
};
/**
* Show an input request toast for interactive prompts (y/n)
* @param message The prompt message to display
* @param onResponse Callback function called when user responds
*/
export const showInputRequest = (
message: string,
onResponse: (response: "y" | "n") => void,
) => {
const toastId = toast.custom(
(t) => (
<InputRequestToast
message={message}
toastId={t}
onResponse={onResponse}
/>
),
{ duration: Infinity }, // Don't auto-close
);
return toastId;
};
export const showExtraFilesToast = ({
files,
error,

View File

@@ -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;
}

View File

@@ -69,6 +69,27 @@ export function readSettings(): UserSettings {
}
}
}
const neon = combinedSettings.neon;
if (neon) {
if (neon.refreshToken) {
const encryptionType = neon.refreshToken.encryptionType;
if (encryptionType) {
neon.refreshToken = {
value: decrypt(neon.refreshToken),
encryptionType,
};
}
}
if (neon.accessToken) {
const encryptionType = neon.accessToken.encryptionType;
if (encryptionType) {
neon.accessToken = {
value: decrypt(neon.accessToken),
encryptionType,
};
}
}
}
if (combinedSettings.githubAccessToken) {
const encryptionType = combinedSettings.githubAccessToken.encryptionType;
combinedSettings.githubAccessToken = {
@@ -131,6 +152,18 @@ export function writeSettings(settings: Partial<UserSettings>): void {
);
}
}
if (newSettings.neon) {
if (newSettings.neon.accessToken) {
newSettings.neon.accessToken = encrypt(
newSettings.neon.accessToken.value,
);
}
if (newSettings.neon.refreshToken) {
newSettings.neon.refreshToken = encrypt(
newSettings.neon.refreshToken.value,
);
}
}
for (const provider in newSettings.providerSettings) {
if (newSettings.providerSettings[provider].apiKey) {
newSettings.providerSettings[provider].apiKey = encrypt(

View File

@@ -0,0 +1,240 @@
import { withLock } from "../ipc/utils/lock_utils";
import { readSettings, writeSettings } from "../main/settings";
import { Api, createApiClient } from "@neondatabase/api-client";
import log from "electron-log";
import { IS_TEST_BUILD } from "../ipc/utils/test_utils";
const logger = log.scope("neon_management_client");
/**
* Checks if the Neon access token is expired or about to expire
* Returns true if token needs to be refreshed
*/
function isTokenExpired(expiresIn?: number): boolean {
if (!expiresIn) return true;
// Get when the token was saved (expiresIn is stored at the time of token receipt)
const settings = readSettings();
const tokenTimestamp = settings.neon?.tokenTimestamp || 0;
const currentTime = Math.floor(Date.now() / 1000);
// Check if the token is expired or about to expire (within 5 minutes)
return currentTime >= tokenTimestamp + expiresIn - 300;
}
/**
* Refreshes the Neon access token using the refresh token
* Updates settings with new tokens and expiration time
*/
export async function refreshNeonToken(): Promise<void> {
const settings = readSettings();
const refreshToken = settings.neon?.refreshToken?.value;
if (!isTokenExpired(settings.neon?.expiresIn)) {
return;
}
if (!refreshToken) {
throw new Error("Neon refresh token not found. Please authenticate first.");
}
try {
// Make request to Neon refresh endpoint
const response = await fetch(
"https://oauth.dyad.sh/api/integrations/neon/refresh",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ refreshToken }),
},
);
if (!response.ok) {
throw new Error(`Token refresh failed: ${response.statusText}`);
}
const {
accessToken,
refreshToken: newRefreshToken,
expiresIn,
} = await response.json();
// Update settings with new tokens
writeSettings({
neon: {
accessToken: {
value: accessToken,
},
refreshToken: {
value: newRefreshToken,
},
expiresIn,
tokenTimestamp: Math.floor(Date.now() / 1000), // Store current timestamp
},
});
} catch (error) {
logger.error("Error refreshing Neon token:", error);
throw error;
}
}
// Function to get the Neon API client
export async function getNeonClient(): Promise<Api<unknown>> {
if (IS_TEST_BUILD) {
// Return a mock client for testing
return {
createProject: async (params: any) => ({
data: {
project: {
id: "test-project-id",
name: params.project.name,
region_id: "aws-us-east-1",
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
default_branch_id: "test-branch-id",
},
connection_uris: [
{
connection_uri: "postgresql://test:test@test.neon.tech/test",
},
],
},
}),
createProjectBranch: async (projectId: string, params: any) => ({
data: {
branch: {
id: "test-dev-branch-id",
name: params.branch?.name || "development",
project_id: projectId,
parent_id: "test-branch-id",
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
connection_uris: [
{
connection_uri: "postgresql://test:test@test-dev.neon.tech/test",
},
],
},
}),
getProject: async (projectId: string) => ({
data: {
project: {
id: projectId,
name: "Test Project",
org_id: "test-org-id",
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
default_branch_id: "test-branch-id",
},
},
}),
listProjectBranches: async (projectId: string) => ({
data: {
branches: [
{
id: "test-branch-id",
name: "main",
project_id: projectId,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
default: true,
},
{
id: "test-dev-branch-id",
name: "development",
project_id: projectId,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
default: false,
},
],
},
}),
listOrganizations: async () => ({
data: {
organizations: [
{
id: "test-org-id",
name: "Test Organization",
},
],
},
}),
deleteProjectBranch: async (projectId: string, branchId: string) => ({
data: {
branch: {
id: branchId,
project_id: projectId,
},
},
}),
} as unknown as Api<unknown>;
}
const settings = readSettings();
// Check if Neon token exists in settings
const neonAccessToken = settings.neon?.accessToken?.value;
const expiresIn = settings.neon?.expiresIn;
if (!neonAccessToken) {
throw new Error("Neon access token not found. Please authenticate first.");
}
// Check if token needs refreshing
if (isTokenExpired(expiresIn)) {
await withLock("refresh-neon-token", refreshNeonToken);
// Get updated settings after refresh
const updatedSettings = readSettings();
const newAccessToken = updatedSettings.neon?.accessToken?.value;
if (!newAccessToken) {
throw new Error("Failed to refresh Neon access token");
}
return createApiClient({
apiKey: newAccessToken,
});
}
return createApiClient({
apiKey: neonAccessToken,
});
}
/**
* Get the user's first organization ID from Neon
*/
export async function getNeonOrganizationId(): Promise<string> {
const neonClient = await getNeonClient();
if (IS_TEST_BUILD) {
return "test-org-id";
}
try {
const response = await neonClient.getCurrentUserOrganizations();
if (
!response.data?.organizations ||
response.data.organizations.length === 0
) {
throw new Error("No organizations found for this Neon account");
}
// Return the first organization ID
return response.data.organizations[0].id;
} catch (error) {
logger.error("Error fetching Neon organizations:", error);
throw new Error("Failed to fetch Neon organizations");
}
}
export function getNeonErrorMessage(error: any): string {
const detailedMessage = error.response?.data?.message ?? "";
return error.message + " " + detailedMessage;
}

View File

@@ -0,0 +1,24 @@
import { writeSettings } from "../main/settings";
export function handleNeonOAuthReturn({
token,
refreshToken,
expiresIn,
}: {
token: string;
refreshToken: string;
expiresIn: number;
}) {
writeSettings({
neon: {
accessToken: {
value: token,
},
refreshToken: {
value: refreshToken,
},
expiresIn,
tokenTimestamp: Math.floor(Date.now() / 1000),
},
});
}

View File

@@ -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({

View File

@@ -1,22 +1,27 @@
import React from "react";
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import { ArrowLeft } from "lucide-react";
import { useRouter } from "@tanstack/react-router";
import { useSettings } from "@/hooks/useSettings";
import { useTemplates } from "@/hooks/useTemplates";
import { TemplateCard } from "@/components/TemplateCard";
import { CreateAppDialog } from "@/components/CreateAppDialog";
import { NeonConnector } from "@/components/NeonConnector";
const HubPage: React.FC = () => {
const { settings, updateSettings } = useSettings();
const router = useRouter();
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const { templates, isLoading } = useTemplates();
const { settings, updateSettings } = useSettings();
const selectedTemplateId = settings?.selectedTemplateId;
const handleTemplateSelect = (templateId: string) => {
updateSettings({ selectedTemplateId: templateId });
};
const handleCreateApp = () => {
setIsCreateDialogOpen(true);
};
// Separate templates into official and community
const officialTemplates =
templates?.filter((template) => template.isOfficial) || [];
@@ -25,7 +30,7 @@ const HubPage: React.FC = () => {
return (
<div className="min-h-screen px-8 py-4">
<div className="max-w-5xl mx-auto">
<div className="max-w-5xl mx-auto pb-12">
<Button
onClick={() => router.history.back()}
variant="outline"
@@ -58,6 +63,7 @@ const HubPage: React.FC = () => {
template={template}
isSelected={template.id === selectedTemplateId}
onSelect={handleTemplateSelect}
onCreateApp={handleCreateApp}
/>
))}
</div>
@@ -77,14 +83,42 @@ const HubPage: React.FC = () => {
template={template}
isSelected={template.id === selectedTemplateId}
onSelect={handleTemplateSelect}
onCreateApp={handleCreateApp}
/>
))}
</div>
</section>
)}
<BackendSection />
</div>
<CreateAppDialog
open={isCreateDialogOpen}
onOpenChange={setIsCreateDialogOpen}
template={templates.find((t) => t.id === settings?.selectedTemplateId)}
/>
</div>
);
};
function BackendSection() {
return (
<div className="">
<header className="mb-4 text-left">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
Backend Services
</h1>
<p className="text-md text-gray-600 dark:text-gray-400">
Connect to backend services for your projects.
</p>
</header>
<div className="grid grid-cols-1 gap-6">
<NeonConnector />
</div>
</div>
);
}
export default HubPage;

View File

@@ -16,11 +16,13 @@ import { useRouter } from "@tanstack/react-router";
import { GitHubIntegration } from "@/components/GitHubIntegration";
import { VercelIntegration } from "@/components/VercelIntegration";
import { SupabaseIntegration } from "@/components/SupabaseIntegration";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { AutoFixProblemsSwitch } from "@/components/AutoFixProblemsSwitch";
import { AutoUpdateSwitch } from "@/components/AutoUpdateSwitch";
import { ReleaseChannelSelector } from "@/components/ReleaseChannelSelector";
import { NeonIntegration } from "@/components/NeonIntegration";
export default function SettingsPage() {
const [isResetDialogOpen, setIsResetDialogOpen] = useState(false);
@@ -112,6 +114,7 @@ export default function SettingsPage() {
<GitHubIntegration />
<VercelIntegration />
<SupabaseIntegration />
<NeonIntegration />
</div>
</div>

View File

@@ -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

View File

@@ -5,6 +5,8 @@ export interface Template {
imageUrl: string;
githubUrl?: string;
isOfficial: boolean;
isExperimental?: boolean;
requiresNeon?: boolean;
}
// API Template interface from the external API
@@ -26,6 +28,9 @@ export const DEFAULT_TEMPLATE = {
isOfficial: true,
};
const PORTAL_MINI_STORE_ID = "portal-mini-store";
export const NEON_TEMPLATE_IDS = new Set<string>([PORTAL_MINI_STORE_ID]);
export const localTemplatesData: Template[] = [
DEFAULT_TEMPLATE,
{
@@ -37,4 +42,15 @@ export const localTemplatesData: Template[] = [
githubUrl: "https://github.com/dyad-sh/nextjs-template",
isOfficial: true,
},
{
id: PORTAL_MINI_STORE_ID,
title: "Portal: Mini Store Template",
description: "Uses Neon DB, Payload CMS, Next.js",
imageUrl:
"https://github.com/user-attachments/assets/ed86f322-40bf-4fd5-81dc-3b1d8a16e12b",
githubUrl: "https://github.com/dyad-sh/portal-mini-store-template",
isOfficial: true,
isExperimental: true,
requiresNeon: true,
},
];

View File

@@ -24,7 +24,6 @@ export const createChatCompletionHandler =
}
let messageContent = CANNED_MESSAGE;
console.error("LASTMESSAGE********", lastMessage.content);
if (
lastMessage &&