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, "when": 1752625491756,
"tag": "0008_medical_vulcan", "tag": "0008_medical_vulcan",
"breakpoints": true "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() { locateLoadingAppPreview() {
return this.page.getByText("Loading app preview..."); return this.page.getByText("Preparing app preview...");
} }
locateStartingAppPreview() { locateStartingAppPreview() {
return this.page.getByText("Starting up your app..."); return this.page.getByText("Starting your app server...");
} }
getPreviewIframeElement() { getPreviewIframeElement() {

176
package-lock.json generated
View File

@@ -16,6 +16,7 @@
"@biomejs/biome": "^1.9.4", "@biomejs/biome": "^1.9.4",
"@dyad-sh/supabase-management-js": "v1.0.0", "@dyad-sh/supabase-management-js": "v1.0.0",
"@monaco-editor/react": "^4.7.0-rc.0", "@monaco-editor/react": "^4.7.0-rc.0",
"@neondatabase/api-client": "^2.1.0",
"@openrouter/ai-sdk-provider": "^0.4.5", "@openrouter/ai-sdk-provider": "^0.4.5",
"@radix-ui/react-accordion": "^1.2.4", "@radix-ui/react-accordion": "^1.2.4",
"@radix-ui/react-alert-dialog": "^1.1.13", "@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" "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": { "node_modules/@next/env": {
"version": "15.3.1", "version": "15.3.1",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.3.1.tgz", "resolved": "https://registry.npmjs.org/@next/env/-/env-15.3.1.tgz",
@@ -6289,9 +6314,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.15.3", "version": "22.16.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.3.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.16.5.tgz",
"integrity": "sha512-lX7HFZeHf4QG/J7tBZqrCAXwz9J5RD56Y6MpP0eJkka8p+K0RY/yBTW7CYFJ4VGCclxqOLKmiGP5juQc6MKgcw==", "integrity": "sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
@@ -6307,6 +6332,19 @@
"form-data": "^4.0.0" "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": { "node_modules/@types/react": {
"version": "19.1.2", "version": "19.1.2",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.2.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.2.tgz",
@@ -7193,6 +7231,17 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/bail": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
@@ -11087,6 +11136,26 @@
"node": ">= 12" "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": { "node_modules/for-each": {
"version": "0.3.5", "version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@@ -16376,6 +16445,43 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -16530,6 +16636,53 @@
"node": ">=4" "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": { "node_modules/posthog-js": {
"version": "1.239.0", "version": "1.239.0",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.239.0.tgz", "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.239.0.tgz",
@@ -16743,6 +16896,12 @@
"node": ">= 0.10" "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": { "node_modules/pump": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz",
@@ -20828,6 +20987,17 @@
"node": ">=8.0" "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": { "node_modules/xterm": {
"version": "4.19.0", "version": "4.19.0",
"resolved": "https://registry.npmjs.org/xterm/-/xterm-4.19.0.tgz", "resolved": "https://registry.npmjs.org/xterm/-/xterm-4.19.0.tgz",

View File

@@ -90,6 +90,7 @@
"@biomejs/biome": "^1.9.4", "@biomejs/biome": "^1.9.4",
"@dyad-sh/supabase-management-js": "v1.0.0", "@dyad-sh/supabase-management-js": "v1.0.0",
"@monaco-editor/react": "^4.7.0-rc.0", "@monaco-editor/react": "^4.7.0-rc.0",
"@neondatabase/api-client": "^2.1.0",
"@openrouter/ai-sdk-provider": "^0.4.5", "@openrouter/ai-sdk-provider": "^0.4.5",
"@radix-ui/react-accordion": "^1.2.4", "@radix-ui/react-accordion": "^1.2.4",
"@radix-ui/react-alert-dialog": "^1.1.13", "@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 { useSettings } from "@/hooks/useSettings";
import { CommunityCodeConsentDialog } from "./CommunityCodeConsentDialog"; import { CommunityCodeConsentDialog } from "./CommunityCodeConsentDialog";
import type { Template } from "@/shared/templates"; import type { Template } from "@/shared/templates";
import { Button } from "./ui/button";
import { cn } from "@/lib/utils";
import { showWarning } from "@/lib/toast";
interface TemplateCardProps { interface TemplateCardProps {
template: Template; template: Template;
isSelected: boolean; isSelected: boolean;
onSelect: (templateId: string) => void; onSelect: (templateId: string) => void;
onCreateApp: () => void;
} }
export const TemplateCard: React.FC<TemplateCardProps> = ({ export const TemplateCard: React.FC<TemplateCardProps> = ({
template, template,
isSelected, isSelected,
onSelect, onSelect,
onCreateApp,
}) => { }) => {
const { settings, updateSettings } = useSettings(); const { settings, updateSettings } = useSettings();
const [showConsentDialog, setShowConsentDialog] = useState(false); const [showConsentDialog, setShowConsentDialog] = useState(false);
@@ -26,6 +31,11 @@ export const TemplateCard: React.FC<TemplateCardProps> = ({
return; return;
} }
if (template.requiresNeon && !settings?.neon?.accessToken) {
showWarning("Please connect your Neon account to use this template.");
return;
}
// Otherwise, proceed with selection // Otherwise, proceed with selection
onSelect(template.id); onSelect(template.id);
}; };
@@ -93,7 +103,7 @@ export const TemplateCard: React.FC<TemplateCardProps> = ({
> >
{template.title} {template.title}
</h2> </h2>
{template.isOfficial && ( {template.isOfficial && !template.isExperimental && (
<span <span
className={`text-xs font-semibold px-2 py-0.5 rounded-full ${ className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
isSelected isSelected
@@ -104,8 +114,13 @@ export const TemplateCard: React.FC<TemplateCardProps> = ({
Official Official
</span> </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> </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} {template.description}
</p> </p>
{template.githubUrl && ( {template.githubUrl && (
@@ -121,6 +136,20 @@ export const TemplateCard: React.FC<TemplateCardProps> = ({
<ArrowLeft className="w-4 h-4 ml-1 transform rotate-180" /> <ArrowLeft className="w-4 h-4 ml-1 transform rotate-180" />
</a> </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>
</div> </div>

View File

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

View File

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

View File

@@ -23,6 +23,7 @@ import { showError, showSuccess } from "@/lib/toast";
import { selectedAppIdAtom } from "@/atoms/appAtoms"; import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { IpcClient } from "@/ipc/ipc_client"; import { IpcClient } from "@/ipc/ipc_client";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { NeonConfigure } from "./NeonConfigure";
const EnvironmentVariablesTitle = () => ( const EnvironmentVariablesTitle = () => (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -396,6 +397,12 @@ export const ConfigurePanel = () => {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
{/* Neon Database Configuration */}
{/* Neon Connector */}
<div className="grid grid-cols-1 gap-6">
<NeonConfigure />
</div>
</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 // Display loading state
if (loading) { 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 // 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"> <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" /> <Loader2 className="w-8 h-8 animate-spin text-gray-400 dark:text-gray-500" />
<p className="text-gray-600 dark:text-gray-300"> <p className="text-gray-600 dark:text-gray-300">
Starting up your app... Starting your app server...
</p> </p>
</div> </div>
) : ( ) : (

View File

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

View File

@@ -1,5 +1,5 @@
import { sql } from "drizzle-orm"; 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"; import { relations } from "drizzle-orm";
export const apps = sqliteTable("apps", { export const apps = sqliteTable("apps", {
@@ -16,6 +16,9 @@ export const apps = sqliteTable("apps", {
githubRepo: text("github_repo"), githubRepo: text("github_repo"),
githubBranch: text("github_branch"), githubBranch: text("github_branch"),
supabaseProjectId: text("supabase_project_id"), supabaseProjectId: text("supabase_project_id"),
neonProjectId: text("neon_project_id"),
neonDevelopmentBranchId: text("neon_development_branch_id"),
neonPreviewBranchId: text("neon_preview_branch_id"),
vercelProjectId: text("vercel_project_id"), vercelProjectId: text("vercel_project_id"),
vercelProjectName: text("vercel_project_name"), vercelProjectName: text("vercel_project_name"),
vercelTeamId: text("vercel_team_id"), vercelTeamId: text("vercel_team_id"),
@@ -51,9 +54,32 @@ export const messages = sqliteTable("messages", {
.default(sql`(unixepoch())`), .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 // Define relations
export const appsRelations = relations(apps, ({ many }) => ({ export const appsRelations = relations(apps, ({ many }) => ({
chats: many(chats), chats: many(chats),
versions: many(versions),
})); }));
export const chatsRelations = relations(chats, ({ many, one }) => ({ 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"; } from "@/atoms/appAtoms";
import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { AppOutput } from "@/ipc/ipc_types"; import { AppOutput } from "@/ipc/ipc_types";
import { showInputRequest } from "@/lib/toast";
const useRunAppLoadingAtom = atom(false); const useRunAppLoadingAtom = atom(false);
@@ -18,7 +19,7 @@ export function useRunApp() {
const [loading, setLoading] = useAtom(useRunAppLoadingAtom); const [loading, setLoading] = useAtom(useRunAppLoadingAtom);
const [app, setApp] = useAtom(currentAppAtom); const [app, setApp] = useAtom(currentAppAtom);
const setAppOutput = useSetAtom(appOutputAtom); const setAppOutput = useSetAtom(appOutputAtom);
const [appUrlObj, setAppUrlObj] = useAtom(appUrlAtom); const [, setAppUrlObj] = useAtom(appUrlAtom);
const setPreviewPanelKey = useSetAtom(previewPanelKeyAtom); const setPreviewPanelKey = useSetAtom(previewPanelKeyAtom);
const appId = useAtomValue(selectedAppIdAtom); const appId = useAtomValue(selectedAppIdAtom);
const setPreviewErrorMessage = useSetAtom(previewErrorMessageAtom); const setPreviewErrorMessage = useSetAtom(previewErrorMessageAtom);
@@ -39,47 +40,78 @@ export function useRunApp() {
const originalUrl = originalUrlMatch && originalUrlMatch[1]; const originalUrl = originalUrlMatch && originalUrlMatch[1];
setAppUrlObj({ setAppUrlObj({
appUrl: proxyUrl, appUrl: proxyUrl,
appId: appId!, appId: output.appId,
originalUrl: originalUrl!, originalUrl: originalUrl!,
}); });
} }
} }
}; };
const runApp = useCallback(async (appId: number) => {
setLoading(true);
try {
const ipcClient = IpcClient.getInstance();
console.debug("Running app", appId);
// Clear the URL and add restart message const processAppOutput = useCallback(
if (appUrlObj?.appId !== appId) { (output: AppOutput) => {
setAppUrlObj({ appUrl: null, appId: null, originalUrl: null }); // Handle input requests specially
if (output.type === "input-requested") {
showInputRequest(output.message, async (response) => {
try {
const ipcClient = IpcClient.getInstance();
await ipcClient.respondToAppInput({
appId: output.appId,
response,
});
} catch (error) {
console.error("Failed to respond to app input:", error);
}
});
return; // Don't add to regular output
} }
setAppOutput((prev) => [
...prev, // Add to regular app output
{ setAppOutput((prev) => [...prev, output]);
message: "Trying to restart app...",
type: "stdout", // Process proxy server output
appId, processProxyServerOutput(output);
timestamp: Date.now(), },
}, [setAppOutput],
]); );
const app = await ipcClient.getApp(appId); const runApp = useCallback(
setApp(app); async (appId: number) => {
await ipcClient.runApp(appId, (output) => { setLoading(true);
setAppOutput((prev) => [...prev, output]); try {
processProxyServerOutput(output); const ipcClient = IpcClient.getInstance();
}); console.debug("Running app", appId);
setPreviewErrorMessage(undefined);
} catch (error) { // Clear the URL and add restart message
console.error(`Error running app ${appId}:`, error); setAppUrlObj((prevAppUrlObj) => {
setPreviewErrorMessage( if (prevAppUrlObj?.appId !== appId) {
error instanceof Error ? error.message : error?.toString(), return { appUrl: null, appId: null, originalUrl: null };
); }
} finally { return prevAppUrlObj; // No change needed
setLoading(false); });
}
}, []); setAppOutput((prev) => [
...prev,
{
message: "Trying to restart app...",
type: "stdout",
appId,
timestamp: Date.now(),
},
]);
const app = await ipcClient.getApp(appId);
setApp(app);
await ipcClient.runApp(appId, processAppOutput);
setPreviewErrorMessage(undefined);
} catch (error) {
console.error(`Error running app ${appId}:`, error);
setPreviewErrorMessage(
error instanceof Error ? error.message : error?.toString(),
);
} finally {
setLoading(false);
}
},
[processAppOutput],
);
const stopApp = useCallback(async (appId: number) => { const stopApp = useCallback(async (appId: number) => {
if (appId === null) { if (appId === null) {
@@ -139,15 +171,15 @@ export function useRunApp() {
await ipcClient.restartApp( await ipcClient.restartApp(
appId, appId,
(output) => { (output) => {
setAppOutput((prev) => [...prev, output]); // Handle HMR updates before processing
if ( if (
output.message.includes("hmr update") && output.message.includes("hmr update") &&
output.message.includes("[vite]") output.message.includes("[vite]")
) { ) {
onHotModuleReload(); onHotModuleReload();
return;
} }
processProxyServerOutput(output); // Process normally (including input requests)
processAppOutput(output);
}, },
removeNodeModules, removeNodeModules,
); );
@@ -161,7 +193,15 @@ export function useRunApp() {
setLoading(false); setLoading(false);
} }
}, },
[appId, setApp, setAppOutput, setAppUrlObj, setPreviewPanelKey], [
appId,
setApp,
setAppOutput,
setAppUrlObj,
setPreviewPanelKey,
processAppOutput,
onHotModuleReload,
],
); );
const refreshAppIframe = useCallback(async () => { const refreshAppIframe = useCallback(async () => {

View File

@@ -5,7 +5,8 @@ import { IpcClient } from "@/ipc/ipc_client";
import { chatMessagesAtom, selectedChatIdAtom } from "@/atoms/chatAtoms"; import { chatMessagesAtom, selectedChatIdAtom } from "@/atoms/chatAtoms";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; 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) { export function useVersions(appId: number | null) {
const [, setVersionsAtom] = useAtom(versionsListAtom); const [, setVersionsAtom] = useAtom(versionsListAtom);
@@ -38,35 +39,42 @@ export function useVersions(appId: number | null) {
} }
}, [versions, setVersionsAtom]); }, [versions, setVersionsAtom]);
const revertVersionMutation = useMutation<void, Error, { versionId: string }>( const revertVersionMutation = useMutation<
{ RevertVersionResponse,
mutationFn: async ({ versionId }: { versionId: string }) => { Error,
const currentAppId = appId; { versionId: string }
if (currentAppId === null) { >({
throw new Error("App ID is null"); mutationFn: async ({ versionId }: { versionId: string }) => {
} const currentAppId = appId;
const ipcClient = IpcClient.getInstance(); if (currentAppId === null) {
await ipcClient.revertVersion({ throw new Error("App ID is null");
appId: currentAppId, }
previousVersionId: versionId, const ipcClient = IpcClient.getInstance();
}); return ipcClient.revertVersion({
}, appId: currentAppId,
onSuccess: async () => { previousVersionId: versionId,
await queryClient.invalidateQueries({ queryKey: ["versions", appId] }); });
await queryClient.invalidateQueries({
queryKey: ["currentBranch", appId],
});
if (selectedChatId) {
const chat = await IpcClient.getInstance().getChat(selectedChatId);
setMessages(chat.messages);
}
await queryClient.invalidateQueries({
queryKey: ["problems", appId],
});
},
meta: { showErrorToast: true },
}, },
); onSuccess: async (result) => {
if ("successMessage" in result) {
toast.success(result.successMessage);
} else if ("warningMessage" in result) {
toast.warning(result.warningMessage);
}
await queryClient.invalidateQueries({ queryKey: ["versions", appId] });
await queryClient.invalidateQueries({
queryKey: ["currentBranch", appId],
});
if (selectedChatId) {
const chat = await IpcClient.getInstance().getChat(selectedChatId);
setMessages(chat.messages);
}
await queryClient.invalidateQueries({
queryKey: ["problems", appId],
});
},
meta: { showErrorToast: true },
});
return { return {
versions: versions || [], versions: versions || [],
@@ -74,5 +82,6 @@ export function useVersions(appId: number | null) {
error, error,
refreshVersions, refreshVersions,
revertVersion: revertVersionMutation.mutateAsync, revertVersion: revertVersionMutation.mutateAsync,
isRevertingVersion: revertVersionMutation.isPending,
}; };
} }

View File

@@ -10,7 +10,11 @@ import { apps } from "../../db/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { getDyadAppPath } from "../../paths/paths"; import { getDyadAppPath } from "../../paths/paths";
import { GetAppEnvVarsParams, SetAppEnvVarsParams } from "../ipc_types"; 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() { export function registerAppEnvVarsHandlers() {
// Handler to get app environment variables // Handler to get app environment variables
@@ -27,7 +31,7 @@ export function registerAppEnvVarsHandlers() {
} }
const appPath = getDyadAppPath(app.path); 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 // If .env.local doesn't exist, return empty array
try { try {
@@ -63,7 +67,7 @@ export function registerAppEnvVarsHandlers() {
} }
const appPath = getDyadAppPath(app.path); 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 // Serialize environment variables to .env.local format
const content = serializeEnvFile(envVars); const content = serializeEnvFile(envVars);

View File

@@ -8,6 +8,7 @@ import type {
RenameBranchParams, RenameBranchParams,
CopyAppParams, CopyAppParams,
EditAppFileReturnType, EditAppFileReturnType,
RespondToAppInputParams,
} from "../ipc_types"; } from "../ipc_types";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
@@ -47,6 +48,7 @@ import { safeSend } from "../utils/safe_sender";
import { normalizePath } from "../../../shared/normalizePath"; import { normalizePath } from "../../../shared/normalizePath";
import { isServerFunction } from "@/supabase_admin/supabase_utils"; import { isServerFunction } from "@/supabase_admin/supabase_utils";
import { getVercelTeamSlug } from "../utils/vercel_utils"; import { getVercelTeamSlug } from "../utils/vercel_utils";
import { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils";
async function copyDir( async function copyDir(
source: string, source: string,
@@ -80,28 +82,32 @@ async function executeApp({
appPath, appPath,
appId, appId,
event, // Keep event for local-node case event, // Keep event for local-node case
isNeon,
}: { }: {
appPath: string; appPath: string;
appId: number; appId: number;
event: Electron.IpcMainInvokeEvent; event: Electron.IpcMainInvokeEvent;
isNeon: boolean;
}): Promise<void> { }): Promise<void> {
if (proxyWorker) { if (proxyWorker) {
proxyWorker.terminate(); proxyWorker.terminate();
proxyWorker = null; proxyWorker = null;
} }
await executeAppLocalNode({ appPath, appId, event }); await executeAppLocalNode({ appPath, appId, event, isNeon });
} }
async function executeAppLocalNode({ async function executeAppLocalNode({
appPath, appPath,
appId, appId,
event, event,
isNeon,
}: { }: {
appPath: string; appPath: string;
appId: number; appId: number;
event: Electron.IpcMainInvokeEvent; event: Electron.IpcMainInvokeEvent;
isNeon: boolean;
}): Promise<void> { }): 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)", "(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 // Check if process spawned correctly
if (!process.pid) { if (!spawnedProcess.pid) {
// Attempt to capture any immediate errors if possible // Attempt to capture any immediate errors if possible
let errorOutput = ""; let errorOutput = "";
process.stderr?.on("data", (data) => (errorOutput += data)); spawnedProcess.stderr?.on("data", (data) => (errorOutput += data));
await new Promise((resolve) => process.on("error", resolve)); // Wait for error event await new Promise((resolve) => spawnedProcess.on("error", resolve)); // Wait for error event
throw new Error( throw new Error(
`Failed to spawn process for app ${appId}. Error: ${ `Failed to spawn process for app ${appId}. Error: ${
errorOutput || "Unknown spawn error" errorOutput || "Unknown spawn error"
@@ -127,35 +133,69 @@ async function executeAppLocalNode({
// Increment the counter and store the process reference with its ID // Increment the counter and store the process reference with its ID
const currentProcessId = processCounter.increment(); const currentProcessId = processCounter.increment();
runningApps.set(appId, { process, processId: currentProcessId }); runningApps.set(appId, {
process: spawnedProcess,
processId: currentProcessId,
});
// Log output // Log output
process.stdout?.on("data", async (data) => { spawnedProcess.stdout?.on("data", async (data) => {
const message = util.stripVTControlCharacters(data.toString()); const message = util.stripVTControlCharacters(data.toString());
logger.debug(`App ${appId} (PID: ${process.pid}) stdout: ${message}`); logger.debug(
`App ${appId} (PID: ${spawnedProcess.pid}) stdout: ${message}`,
);
safeSend(event.sender, "app:output", { // This is a hacky heuristic to pick up when drizzle is asking for user
type: "stdout", // to select from one of a few choices. We automatically pick the first
message, // option because it's usually a good default choice. We guard this with
appId, // 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
const urlMatch = message.match(/(https?:\/\/localhost:\d+\/?)/); // their databases have point in time restore built-in.
if (urlMatch) { if (isNeon && message.includes("created or renamed from another")) {
proxyWorker = await startProxy(urlMatch[1], { spawnedProcess.stdin.write(`\r\n`);
onStarted: (proxyUrl) => { logger.info(
safeSend(event.sender, "app:output", { `App ${appId} (PID: ${spawnedProcess.pid}) wrote enter to stdin to automatically respond to drizzle push input`,
type: "stdout", );
message: `[dyad-proxy-server]started=[${proxyUrl}] original=[${urlMatch[1]}]`, }
appId,
}); // Check if this is an interactive prompt requiring user input
}, const inputRequestPattern = /\s*\s*\([yY]\/[nN]\)\s*$/;
const isInputRequest = inputRequestPattern.test(message);
if (isInputRequest) {
// Send special input-requested event for interactive prompts
safeSend(event.sender, "app:output", {
type: "input-requested",
message,
appId,
}); });
} else {
// Normal stdout handling
safeSend(event.sender, "app:output", {
type: "stdout",
message,
appId,
});
const urlMatch = message.match(/(https?:\/\/localhost:\d+\/?)/);
if (urlMatch) {
proxyWorker = await startProxy(urlMatch[1], {
onStarted: (proxyUrl) => {
safeSend(event.sender, "app:output", {
type: "stdout",
message: `[dyad-proxy-server]started=[${proxyUrl}] original=[${urlMatch[1]}]`,
appId,
});
},
});
}
} }
}); });
process.stderr?.on("data", (data) => { spawnedProcess.stderr?.on("data", (data) => {
const message = util.stripVTControlCharacters(data.toString()); 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", { safeSend(event.sender, "app:output", {
type: "stderr", type: "stderr",
message, message,
@@ -164,19 +204,19 @@ async function executeAppLocalNode({
}); });
// Handle process exit/close // Handle process exit/close
process.on("close", (code, signal) => { spawnedProcess.on("close", (code, signal) => {
logger.log( 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) // Handle errors during process lifecycle (e.g., command not found)
process.on("error", (err) => { spawnedProcess.on("error", (err) => {
logger.error( 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. // 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. // Consider adding ipcRenderer event emission to notify UI of the error.
}); });
@@ -466,7 +506,12 @@ export function registerAppHandlers() {
try { try {
// Kill any orphaned process on port 32100 (in case previous run left it) // Kill any orphaned process on port 32100 (in case previous run left it)
await killProcessOnPort(32100); await killProcessOnPort(32100);
await executeApp({ appPath, appId, event }); await executeApp({
appPath,
appId,
event,
isNeon: !!app.neonProjectId,
});
return; return;
} catch (error: any) { } catch (error: any) {
@@ -596,7 +641,12 @@ export function registerAppHandlers() {
`Executing app ${appId} in path ${app.path} after restart request`, `Executing app ${appId} in path ${app.path} after restart request`,
); // Adjusted log ); // 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; return;
} catch (error) { } catch (error) {
@@ -633,6 +683,23 @@ export function registerAppHandlers() {
throw new Error("Invalid file path"); 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 // Ensure directory exists
const dirPath = path.dirname(fullPath); const dirPath = path.dirname(fullPath);
await fsPromises.mkdir(dirPath, { recursive: true }); 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({ (await getSupabaseContext({
supabaseProjectId: updatedChat.app.supabaseProjectId, supabaseProjectId: updatedChat.app.supabaseProjectId,
})); }));
} else { } else if (
// Neon projects don't need Supabase.
!updatedChat.app?.neonProjectId
) {
systemPrompt += "\n\n" + SUPABASE_NOT_AVAILABLE_SYSTEM_PROMPT; systemPrompt += "\n\n" + SUPABASE_NOT_AVAILABLE_SYSTEM_PROMPT;
} }
const isSummarizeIntent = req.prompt.startsWith( 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({ supabaseContext = await getSupabaseContext({
supabaseProjectId: chat.app.supabaseProjectId, supabaseProjectId: chat.app.supabaseProjectId,
}); });
} else { } else if (
// Neon projects don't need Supabase.
!chat.app?.neonProjectId
) {
systemPrompt += "\n\n" + SUPABASE_NOT_AVAILABLE_SYSTEM_PROMPT; systemPrompt += "\n\n" + SUPABASE_NOT_AVAILABLE_SYSTEM_PROMPT;
} }

View File

@@ -1,7 +1,12 @@
import { db } from "../../db"; 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 { 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 fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { getDyadAppPath } from "../../paths/paths"; import { getDyadAppPath } from "../../paths/paths";
@@ -11,10 +16,51 @@ import log from "electron-log";
import { createLoggedHandler } from "./safe_handle"; import { createLoggedHandler } from "./safe_handle";
import { gitCheckout, gitCommit, gitStageToRevert } from "../utils/git_utils"; 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 logger = log.scope("version_handlers");
const handle = createLoggedHandler(logger); 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() { export function registerVersionHandlers() {
handle("list-versions", async (_, { appId }: { appId: number }) => { handle("list-versions", async (_, { appId }: { appId: number }) => {
const app = await db.query.apps.findFirst({ 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 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
oid: commit.oid, const appSnapshots = await db.query.versions.findMany({
message: commit.commit.message, where: eq(versions.appId, appId),
timestamp: commit.commit.author.timestamp, });
})) satisfies Version[];
// Create a map of commitHash -> snapshot info for quick lookup
const snapshotMap = new Map<
string,
{ neonDbTimestamp: string | null; createdAt: Date }
>();
for (const snapshot of appSnapshots) {
snapshotMap.set(snapshot.commitHash, {
neonDbTimestamp: snapshot.neonDbTimestamp,
createdAt: snapshot.createdAt,
});
}
return commits.map((commit: ReadCommitResult) => {
const snapshotInfo = snapshotMap.get(commit.oid);
return {
oid: commit.oid,
message: commit.commit.message,
timestamp: commit.commit.author.timestamp,
dbTimestamp: snapshotInfo?.neonDbTimestamp,
};
}) satisfies Version[];
}); });
handle( handle(
@@ -86,12 +153,11 @@ export function registerVersionHandlers() {
"revert-version", "revert-version",
async ( async (
_, _,
{ { appId, previousVersionId }: RevertVersionParams,
appId, ): Promise<RevertVersionResponse> => {
previousVersionId,
}: { appId: number; previousVersionId: string },
): Promise<void> => {
return withLock(appId, async () => { return withLock(appId, async () => {
let successMessage = "Restored version";
let warningMessage: string | undefined = undefined;
const app = await db.query.apps.findFirst({ const app = await db.query.apps.findFirst({
where: eq(apps.id, appId), where: eq(apps.id, appId),
}); });
@@ -101,12 +167,26 @@ export function registerVersionHandlers() {
} }
const appPath = getDyadAppPath(app.path); const appPath = getDyadAppPath(app.path);
// Get the current commit hash before reverting
const currentCommitHash = await git.resolveRef({
fs,
dir: appPath,
ref: "main",
});
await gitCheckout({ await gitCheckout({
path: appPath, path: appPath,
ref: "main", 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({ await gitStageToRevert({
path: appPath, path: appPath,
targetOid: previousVersionId, 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", "checkout-version",
async ( async (
_, _,
{ appId, versionId }: { appId: number; versionId: string }, { appId, versionId: gitRef }: { appId: number; versionId: string },
): Promise<void> => { ): Promise<void> => {
return withLock(appId, async () => { return withLock(appId, async () => {
const app = await db.query.apps.findFirst({ const app = await db.query.apps.findFirst({
@@ -173,13 +334,106 @@ export function registerVersionHandlers() {
throw new Error("App not found"); 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({ await gitCheckout({
path: appPath, path: fullAppPath,
ref: versionId, 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, VercelProject,
UpdateChatParams, UpdateChatParams,
FileAttachment, FileAttachment,
CreateNeonProjectParams,
NeonProject,
GetNeonProjectParams,
GetNeonProjectResponse,
RevertVersionResponse,
RevertVersionParams,
RespondToAppInputParams,
} from "./ipc_types"; } from "./ipc_types";
import type { Template } from "../shared/templates"; import type { Template } from "../shared/templates";
import type { AppChatContext, ProposalResult } from "@/lib/schemas"; import type { AppChatContext, ProposalResult } from "@/lib/schemas";
@@ -82,7 +89,6 @@ export interface GitHubDeviceFlowErrorData {
export interface DeepLinkData { export interface DeepLinkData {
type: string; type: string;
url?: string;
} }
interface DeleteCustomModelParams { 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 // Get allow-listed environment variables
public async getEnvVars(): Promise<Record<string, string | undefined>> { public async getEnvVars(): Promise<Record<string, string | undefined>> {
try { try {
@@ -437,17 +455,10 @@ export class IpcClient {
} }
// Revert to a specific version // Revert to a specific version
public async revertVersion({ public async revertVersion(
appId, params: RevertVersionParams,
previousVersionId, ): Promise<RevertVersionResponse> {
}: { return this.ipcRenderer.invoke("revert-version", params);
appId: number;
previousVersionId: string;
}): Promise<void> {
await this.ipcRenderer.invoke("revert-version", {
appId,
previousVersionId,
});
} }
// Checkout a specific version without creating a revert commit // Checkout a specific version without creating a revert commit
@@ -794,6 +805,34 @@ export class IpcClient {
// --- End Supabase Management --- // --- 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> { public async getSystemDebugInfo(): Promise<SystemDebugInfo> {
return this.ipcRenderer.invoke("get-system-debug-info"); 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 { registerProposalHandlers } from "./handlers/proposal_handlers";
import { registerDebugHandlers } from "./handlers/debug_handlers"; import { registerDebugHandlers } from "./handlers/debug_handlers";
import { registerSupabaseHandlers } from "./handlers/supabase_handlers"; import { registerSupabaseHandlers } from "./handlers/supabase_handlers";
import { registerNeonHandlers } from "./handlers/neon_handlers";
import { registerLocalModelHandlers } from "./handlers/local_model_handlers"; import { registerLocalModelHandlers } from "./handlers/local_model_handlers";
import { registerTokenCountHandlers } from "./handlers/token_count_handlers"; import { registerTokenCountHandlers } from "./handlers/token_count_handlers";
import { registerWindowHandlers } from "./handlers/window_handlers"; import { registerWindowHandlers } from "./handlers/window_handlers";
@@ -26,6 +27,7 @@ import { registerCapacitorHandlers } from "./handlers/capacitor_handlers";
import { registerProblemsHandlers } from "./handlers/problems_handlers"; import { registerProblemsHandlers } from "./handlers/problems_handlers";
import { registerAppEnvVarsHandlers } from "./handlers/app_env_vars_handlers"; import { registerAppEnvVarsHandlers } from "./handlers/app_env_vars_handlers";
import { registerTemplateHandlers } from "./handlers/template_handlers"; import { registerTemplateHandlers } from "./handlers/template_handlers";
import { registerPortalHandlers } from "./handlers/portal_handlers";
export function registerIpcHandlers() { export function registerIpcHandlers() {
// Register all IPC handlers by category // Register all IPC handlers by category
@@ -42,6 +44,7 @@ export function registerIpcHandlers() {
registerProposalHandlers(); registerProposalHandlers();
registerDebugHandlers(); registerDebugHandlers();
registerSupabaseHandlers(); registerSupabaseHandlers();
registerNeonHandlers();
registerLocalModelHandlers(); registerLocalModelHandlers();
registerTokenCountHandlers(); registerTokenCountHandlers();
registerWindowHandlers(); registerWindowHandlers();
@@ -57,4 +60,5 @@ export function registerIpcHandlers() {
registerCapacitorHandlers(); registerCapacitorHandlers();
registerAppEnvVarsHandlers(); registerAppEnvVarsHandlers();
registerTemplateHandlers(); registerTemplateHandlers();
registerPortalHandlers();
} }

View File

@@ -3,12 +3,17 @@ import type { ProblemReport, Problem } from "../../shared/tsc_types";
export type { ProblemReport, Problem }; export type { ProblemReport, Problem };
export interface AppOutput { export interface AppOutput {
type: "stdout" | "stderr" | "info" | "client-error"; type: "stdout" | "stderr" | "info" | "client-error" | "input-requested";
message: string; message: string;
timestamp: number; timestamp: number;
appId: number; appId: number;
} }
export interface RespondToAppInputParams {
appId: number;
response: string;
}
export interface ListAppsResponse { export interface ListAppsResponse {
apps: App[]; apps: App[];
appBasePath: string; appBasePath: string;
@@ -61,6 +66,7 @@ export interface Message {
content: string; content: string;
approvalState?: "approved" | "rejected" | null; approvalState?: "approved" | "rejected" | null;
commitHash?: string | null; commitHash?: string | null;
dbTimestamp?: string | null;
} }
export interface Chat { export interface Chat {
@@ -68,6 +74,7 @@ export interface Chat {
title: string; title: string;
messages: Message[]; messages: Message[];
initialCommitHash?: string | null; initialCommitHash?: string | null;
dbTimestamp?: string | null;
} }
export interface App { export interface App {
@@ -82,6 +89,9 @@ export interface App {
githubBranch: string | null; githubBranch: string | null;
supabaseProjectId: string | null; supabaseProjectId: string | null;
supabaseProjectName: string | null; supabaseProjectName: string | null;
neonProjectId: string | null;
neonDevelopmentBranchId: string | null;
neonPreviewBranchId: string | null;
vercelProjectId: string | null; vercelProjectId: string | null;
vercelProjectName: string | null; vercelProjectName: string | null;
vercelTeamSlug: string | null; vercelTeamSlug: string | null;
@@ -92,6 +102,7 @@ export interface Version {
oid: string; oid: string;
message: string; message: string;
timestamp: number; timestamp: number;
dbTimestamp?: string | null;
} }
export type BranchResult = { branch: string }; export type BranchResult = { branch: string };
@@ -339,3 +350,45 @@ export interface FileAttachment {
file: File; file: File;
type: "upload-to-codebase" | "chat-context"; 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, getDyadAddDependencyTags,
getDyadExecuteSqlTags, getDyadExecuteSqlTags,
} from "../utils/dyad_tag_parser"; } from "../utils/dyad_tag_parser";
import { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils";
import { FileUploadsState } from "../utils/file_uploads_state"; import { FileUploadsState } from "../utils/file_uploads_state";
const readFile = fs.promises.readFile; const readFile = fs.promises.readFile;
@@ -80,6 +82,23 @@ export async function processFullResponseActions(
return {}; 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 settings: UserSettings = readSettings();
const appPath = getDyadAppPath(chatWithApp.app.path); const appPath = getDyadAppPath(chatWithApp.app.path);
const writtenFiles: string[] = []; const writtenFiles: string[] = [];

View File

@@ -3,7 +3,109 @@
* Environment variables are sensitive and should not be logged. * Environment variables are sensitive and should not be logged.
*/ */
import { getDyadAppPath } from "@/paths/paths";
import { EnvVar } from "../ipc_types"; 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 // Helper function to parse .env.local file content
export function parseEnvFile(content: string): EnvVar[] { 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 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({ export const ExperimentsSchema = z.object({
// Deprecated // Deprecated
enableSupabaseIntegration: z.boolean().describe("DEPRECATED").optional(), enableSupabaseIntegration: z.boolean().describe("DEPRECATED").optional(),
@@ -138,6 +146,7 @@ export const UserSettingsSchema = z.object({
githubAccessToken: SecretSchema.optional(), githubAccessToken: SecretSchema.optional(),
vercelAccessToken: SecretSchema.optional(), vercelAccessToken: SecretSchema.optional(),
supabase: SupabaseSchema.optional(), supabase: SupabaseSchema.optional(),
neon: NeonSchema.optional(),
autoApproveChanges: z.boolean().optional(), autoApproveChanges: z.boolean().optional(),
telemetryConsent: z.enum(["opted_in", "opted_out", "unset"]).optional(), telemetryConsent: z.enum(["opted_in", "opted_out", "unset"]).optional(),
telemetryUserId: z.string().optional(), telemetryUserId: z.string().optional(),

View File

@@ -2,6 +2,7 @@ import { toast } from "sonner";
import { PostHog } from "posthog-js"; import { PostHog } from "posthog-js";
import React from "react"; import React from "react";
import { CustomErrorToast } from "../components/CustomErrorToast"; import { CustomErrorToast } from "../components/CustomErrorToast";
import { InputRequestToast } from "../components/InputRequestToast";
/** /**
* Toast utility functions for consistent notifications across the app * Toast utility functions for consistent notifications across the app
@@ -87,6 +88,29 @@ export const showInfo = (message: string) => {
toast.info(message); 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 = ({ export const showExtraFilesToast = ({
files, files,
error, error,

View File

@@ -17,6 +17,7 @@ import { IS_TEST_BUILD } from "./ipc/utils/test_utils";
import { BackupManager } from "./backup_manager"; import { BackupManager } from "./backup_manager";
import { getDatabasePath, initializeDatabase } from "./db"; import { getDatabasePath, initializeDatabase } from "./db";
import { UserSettings } from "./lib/schemas"; import { UserSettings } from "./lib/schemas";
import { handleNeonOAuthReturn } from "./neon_admin/neon_return_handler";
log.errorHandler.startCatching(); log.errorHandler.startCatching();
log.eventLogger.startLogging(); log.eventLogger.startLogging();
@@ -208,6 +209,24 @@ function handleDeepLinkReturn(url: string) {
); );
return; 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") { if (parsed.hostname === "supabase-oauth-return") {
const token = parsed.searchParams.get("token"); const token = parsed.searchParams.get("token");
const refreshToken = parsed.searchParams.get("refreshToken"); const refreshToken = parsed.searchParams.get("refreshToken");
@@ -223,7 +242,6 @@ function handleDeepLinkReturn(url: string) {
// Send message to renderer to trigger re-render // Send message to renderer to trigger re-render
mainWindow?.webContents.send("deep-link-received", { mainWindow?.webContents.send("deep-link-received", {
type: parsed.hostname, type: parsed.hostname,
url,
}); });
return; return;
} }
@@ -240,7 +258,6 @@ function handleDeepLinkReturn(url: string) {
// Send message to renderer to trigger re-render // Send message to renderer to trigger re-render
mainWindow?.webContents.send("deep-link-received", { mainWindow?.webContents.send("deep-link-received", {
type: parsed.hostname, type: parsed.hostname,
url,
}); });
return; 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) { if (combinedSettings.githubAccessToken) {
const encryptionType = combinedSettings.githubAccessToken.encryptionType; const encryptionType = combinedSettings.githubAccessToken.encryptionType;
combinedSettings.githubAccessToken = { 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) { for (const provider in newSettings.providerSettings) {
if (newSettings.providerSettings[provider].apiKey) { if (newSettings.providerSettings[provider].apiKey) {
newSettings.providerSettings[provider].apiKey = encrypt( 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 { useQueryClient } from "@tanstack/react-query";
import type { FileAttachment } from "@/ipc/ipc_types"; 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 // Adding an export for attachments
export interface HomeSubmitOptions { export interface HomeSubmitOptions {
@@ -120,6 +122,15 @@ export default function HomePage() {
const result = await IpcClient.getInstance().createApp({ const result = await IpcClient.getInstance().createApp({
name: generateCuteAppName(), 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 // Stream the message with attachments
streamMessage({ streamMessage({

View File

@@ -1,22 +1,27 @@
import React from "react"; import React, { useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import { useRouter } from "@tanstack/react-router"; import { useRouter } from "@tanstack/react-router";
import { useSettings } from "@/hooks/useSettings"; import { useSettings } from "@/hooks/useSettings";
import { useTemplates } from "@/hooks/useTemplates"; import { useTemplates } from "@/hooks/useTemplates";
import { TemplateCard } from "@/components/TemplateCard"; import { TemplateCard } from "@/components/TemplateCard";
import { CreateAppDialog } from "@/components/CreateAppDialog";
import { NeonConnector } from "@/components/NeonConnector";
const HubPage: React.FC = () => { const HubPage: React.FC = () => {
const { settings, updateSettings } = useSettings();
const router = useRouter(); const router = useRouter();
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const { templates, isLoading } = useTemplates(); const { templates, isLoading } = useTemplates();
const { settings, updateSettings } = useSettings();
const selectedTemplateId = settings?.selectedTemplateId; const selectedTemplateId = settings?.selectedTemplateId;
const handleTemplateSelect = (templateId: string) => { const handleTemplateSelect = (templateId: string) => {
updateSettings({ selectedTemplateId: templateId }); updateSettings({ selectedTemplateId: templateId });
}; };
const handleCreateApp = () => {
setIsCreateDialogOpen(true);
};
// Separate templates into official and community // Separate templates into official and community
const officialTemplates = const officialTemplates =
templates?.filter((template) => template.isOfficial) || []; templates?.filter((template) => template.isOfficial) || [];
@@ -25,7 +30,7 @@ const HubPage: React.FC = () => {
return ( return (
<div className="min-h-screen px-8 py-4"> <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 <Button
onClick={() => router.history.back()} onClick={() => router.history.back()}
variant="outline" variant="outline"
@@ -58,6 +63,7 @@ const HubPage: React.FC = () => {
template={template} template={template}
isSelected={template.id === selectedTemplateId} isSelected={template.id === selectedTemplateId}
onSelect={handleTemplateSelect} onSelect={handleTemplateSelect}
onCreateApp={handleCreateApp}
/> />
))} ))}
</div> </div>
@@ -77,14 +83,42 @@ const HubPage: React.FC = () => {
template={template} template={template}
isSelected={template.id === selectedTemplateId} isSelected={template.id === selectedTemplateId}
onSelect={handleTemplateSelect} onSelect={handleTemplateSelect}
onCreateApp={handleCreateApp}
/> />
))} ))}
</div> </div>
</section> </section>
)} )}
<BackendSection />
</div> </div>
<CreateAppDialog
open={isCreateDialogOpen}
onOpenChange={setIsCreateDialogOpen}
template={templates.find((t) => t.id === settings?.selectedTemplateId)}
/>
</div> </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; export default HubPage;

View File

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

View File

@@ -33,6 +33,7 @@ const validInvokeChannels = [
"run-app", "run-app",
"stop-app", "stop-app",
"restart-app", "restart-app",
"respond-to-app-input",
"list-versions", "list-versions",
"revert-version", "revert-version",
"checkout-version", "checkout-version",
@@ -55,6 +56,9 @@ const validInvokeChannels = [
"github:connect-existing-repo", "github:connect-existing-repo",
"github:push", "github:push",
"github:disconnect", "github:disconnect",
"neon:create-project",
"neon:get-project",
"neon:delete-branch",
"vercel:save-token", "vercel:save-token",
"vercel:list-projects", "vercel:list-projects",
"vercel:is-project-available", "vercel:is-project-available",
@@ -101,6 +105,7 @@ const validInvokeChannels = [
"check-problems", "check-problems",
"restart-dyad", "restart-dyad",
"get-templates", "get-templates",
"portal:migrate-create",
// Test-only channels // Test-only channels
// These should ALWAYS be guarded with IS_TEST_BUILD in the main process. // 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 // We can't detect with IS_TEST_BUILD in the preload script because

View File

@@ -5,6 +5,8 @@ export interface Template {
imageUrl: string; imageUrl: string;
githubUrl?: string; githubUrl?: string;
isOfficial: boolean; isOfficial: boolean;
isExperimental?: boolean;
requiresNeon?: boolean;
} }
// API Template interface from the external API // API Template interface from the external API
@@ -26,6 +28,9 @@ export const DEFAULT_TEMPLATE = {
isOfficial: true, 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[] = [ export const localTemplatesData: Template[] = [
DEFAULT_TEMPLATE, DEFAULT_TEMPLATE,
{ {
@@ -37,4 +42,15 @@ export const localTemplatesData: Template[] = [
githubUrl: "https://github.com/dyad-sh/nextjs-template", githubUrl: "https://github.com/dyad-sh/nextjs-template",
isOfficial: true, 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; let messageContent = CANNED_MESSAGE;
console.error("LASTMESSAGE********", lastMessage.content);
if ( if (
lastMessage && lastMessage &&