diff --git a/drizzle/0009_previous_misty_knight.sql b/drizzle/0009_previous_misty_knight.sql new file mode 100644 index 0000000..7fd0140 --- /dev/null +++ b/drizzle/0009_previous_misty_knight.sql @@ -0,0 +1,14 @@ +CREATE TABLE `versions` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `app_id` integer NOT NULL, + `commit_hash` text NOT NULL, + `neon_db_timestamp` text, + `created_at` integer DEFAULT (unixepoch()) NOT NULL, + `updated_at` integer DEFAULT (unixepoch()) NOT NULL, + FOREIGN KEY (`app_id`) REFERENCES `apps`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `versions_app_commit_unique` ON `versions` (`app_id`,`commit_hash`);--> statement-breakpoint +ALTER TABLE `apps` ADD `neon_project_id` text;--> statement-breakpoint +ALTER TABLE `apps` ADD `neon_development_branch_id` text;--> statement-breakpoint +ALTER TABLE `apps` ADD `neon_preview_branch_id` text; \ No newline at end of file diff --git a/drizzle/meta/0009_snapshot.json b/drizzle/meta/0009_snapshot.json new file mode 100644 index 0000000..822549e --- /dev/null +++ b/drizzle/meta/0009_snapshot.json @@ -0,0 +1,510 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "4d1fc225-7395-4d56-8d0d-7f76fed4a8d8", + "prevId": "553360d1-7173-4bb0-9f31-ab49a0010279", + "tables": { + "apps": { + "name": "apps", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "github_org": { + "name": "github_org", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_repo": { + "name": "github_repo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_branch": { + "name": "github_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "supabase_project_id": { + "name": "supabase_project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "neon_project_id": { + "name": "neon_project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "neon_development_branch_id": { + "name": "neon_development_branch_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "neon_preview_branch_id": { + "name": "neon_preview_branch_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vercel_project_id": { + "name": "vercel_project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vercel_project_name": { + "name": "vercel_project_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vercel_team_id": { + "name": "vercel_team_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vercel_deployment_url": { + "name": "vercel_deployment_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "chat_context": { + "name": "chat_context", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chats": { + "name": "chats", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "app_id": { + "name": "app_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "initial_commit_hash": { + "name": "initial_commit_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": { + "chats_app_id_apps_id_fk": { + "name": "chats_app_id_apps_id_fk", + "tableFrom": "chats", + "tableTo": "apps", + "columnsFrom": [ + "app_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "language_model_providers": { + "name": "language_model_providers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "api_base_url": { + "name": "api_base_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "env_var_name": { + "name": "env_var_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "language_models": { + "name": "language_models", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "api_name": { + "name": "api_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "builtin_provider_id": { + "name": "builtin_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_provider_id": { + "name": "custom_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_output_tokens": { + "name": "max_output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "context_window": { + "name": "context_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": { + "language_models_custom_provider_id_language_model_providers_id_fk": { + "name": "language_models_custom_provider_id_language_model_providers_id_fk", + "tableFrom": "language_models", + "tableTo": "language_model_providers", + "columnsFrom": [ + "custom_provider_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "messages": { + "name": "messages", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "chat_id": { + "name": "chat_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "approval_state": { + "name": "approval_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commit_hash": { + "name": "commit_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": { + "messages_chat_id_chats_id_fk": { + "name": "messages_chat_id_chats_id_fk", + "tableFrom": "messages", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "versions": { + "name": "versions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "app_id": { + "name": "app_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "commit_hash": { + "name": "commit_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "neon_db_timestamp": { + "name": "neon_db_timestamp", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "versions_app_commit_unique": { + "name": "versions_app_commit_unique", + "columns": [ + "app_id", + "commit_hash" + ], + "isUnique": true + } + }, + "foreignKeys": { + "versions_app_id_apps_id_fk": { + "name": "versions_app_id_apps_id_fk", + "tableFrom": "versions", + "tableTo": "apps", + "columnsFrom": [ + "app_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 6576f59..d8af21d 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1752625491756, "tag": "0008_medical_vulcan", "breakpoints": true + }, + { + "idx": 9, + "version": "6", + "when": 1753473275674, + "tag": "0009_previous_misty_knight", + "breakpoints": true } ] } \ No newline at end of file diff --git a/e2e-tests/helpers/test_helper.ts b/e2e-tests/helpers/test_helper.ts index 7625f8a..b2435e4 100644 --- a/e2e-tests/helpers/test_helper.ts +++ b/e2e-tests/helpers/test_helper.ts @@ -481,11 +481,11 @@ export class PageObject { } locateLoadingAppPreview() { - return this.page.getByText("Loading app preview..."); + return this.page.getByText("Preparing app preview..."); } locateStartingAppPreview() { - return this.page.getByText("Starting up your app..."); + return this.page.getByText("Starting your app server..."); } getPreviewIframeElement() { diff --git a/package-lock.json b/package-lock.json index 33d4b53..8886cdf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@biomejs/biome": "^1.9.4", "@dyad-sh/supabase-management-js": "v1.0.0", "@monaco-editor/react": "^4.7.0-rc.0", + "@neondatabase/api-client": "^2.1.0", "@openrouter/ai-sdk-provider": "^0.4.5", "@radix-ui/react-accordion": "^1.2.4", "@radix-ui/react-alert-dialog": "^1.1.13", @@ -3227,6 +3228,30 @@ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@neondatabase/api-client": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@neondatabase/api-client/-/api-client-2.1.0.tgz", + "integrity": "sha512-2noK3Ys1MHUxSk7UA/unt+UkJasotlqDJj87ez+Aq6mXWPTcutMhIFLAp9eHazjYbho/cmYtPIawqeuFl3dDEQ==", + "license": "MIT", + "dependencies": { + "axios": "^1.9.0" + } + }, + "node_modules/@neondatabase/serverless": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@neondatabase/serverless/-/serverless-1.0.1.tgz", + "integrity": "sha512-O6yC5TT0jbw86VZVkmnzCZJB0hfxBl0JJz6f+3KHoZabjb/X08r9eFA+vuY06z1/qaovykvdkrXYq3SPUuvogA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@types/node": "^22.15.30", + "@types/pg": "^8.8.0" + }, + "engines": { + "node": ">=19.0.0" + } + }, "node_modules/@next/env": { "version": "15.3.1", "resolved": "https://registry.npmjs.org/@next/env/-/env-15.3.1.tgz", @@ -6289,9 +6314,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.15.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.3.tgz", - "integrity": "sha512-lX7HFZeHf4QG/J7tBZqrCAXwz9J5RD56Y6MpP0eJkka8p+K0RY/yBTW7CYFJ4VGCclxqOLKmiGP5juQc6MKgcw==", + "version": "22.16.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.16.5.tgz", + "integrity": "sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ==", "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -6307,6 +6332,19 @@ "form-data": "^4.0.0" } }, + "node_modules/@types/pg": { + "version": "8.15.4", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.4.tgz", + "integrity": "sha512-I6UNVBAoYbvuWkkU3oosC8yxqH21f4/Jc4DK71JLG3dT2mdlGe1z+ep/LQGXaKaOgcvUrsQoPRqfgtMcvZiJhg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/react": { "version": "19.1.2", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.2.tgz", @@ -7193,6 +7231,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/axios": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz", + "integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/bail": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", @@ -11087,6 +11136,26 @@ "node": ">= 12" } }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -16376,6 +16445,43 @@ "dev": true, "license": "MIT" }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "optional": true, + "peer": true, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz", + "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -16530,6 +16636,53 @@ "node": ">=4" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", + "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/posthog-js": { "version": "1.239.0", "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.239.0.tgz", @@ -16743,6 +16896,12 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/pump": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", @@ -20828,6 +20987,17 @@ "node": ">=8.0" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=0.4" + } + }, "node_modules/xterm": { "version": "4.19.0", "resolved": "https://registry.npmjs.org/xterm/-/xterm-4.19.0.tgz", diff --git a/package.json b/package.json index 28ed29f..d2353b8 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "@biomejs/biome": "^1.9.4", "@dyad-sh/supabase-management-js": "v1.0.0", "@monaco-editor/react": "^4.7.0-rc.0", + "@neondatabase/api-client": "^2.1.0", "@openrouter/ai-sdk-provider": "^0.4.5", "@radix-ui/react-accordion": "^1.2.4", "@radix-ui/react-alert-dialog": "^1.1.13", diff --git a/src/client_logic/template_hook.ts b/src/client_logic/template_hook.ts new file mode 100644 index 0000000..70fa28c --- /dev/null +++ b/src/client_logic/template_hook.ts @@ -0,0 +1,45 @@ +import { IpcClient } from "@/ipc/ipc_client"; + +import { v4 as uuidv4 } from "uuid"; + +export async function neonTemplateHook({ + appId, + appName, +}: { + appId: number; + appName: string; +}) { + console.log("Creating Neon project"); + const neonProject = await IpcClient.getInstance().createNeonProject({ + name: appName, + appId: appId, + }); + + console.log("Neon project created", neonProject); + await IpcClient.getInstance().setAppEnvVars({ + appId: appId, + envVars: [ + { + key: "POSTGRES_URL", + value: neonProject.connectionString, + }, + { + key: "PAYLOAD_SECRET", + value: uuidv4(), + }, + { + key: "NEXT_PUBLIC_SERVER_URL", + value: "http://localhost:32100", + }, + { + key: "GMAIL_USER", + value: "example@gmail.com", + }, + { + key: "GOOGLE_APP_PASSWORD", + value: "GENERATE AT https://myaccount.google.com/apppasswords", + }, + ], + }); + console.log("App env vars set"); +} diff --git a/src/components/CreateAppDialog.tsx b/src/components/CreateAppDialog.tsx new file mode 100644 index 0000000..165a51d --- /dev/null +++ b/src/components/CreateAppDialog.tsx @@ -0,0 +1,137 @@ +import React, { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { useCreateApp } from "@/hooks/useCreateApp"; +import { useCheckName } from "@/hooks/useCheckName"; +import { useSetAtom } from "jotai"; +import { selectedAppIdAtom } from "@/atoms/appAtoms"; +import { NEON_TEMPLATE_IDS, Template } from "@/shared/templates"; + +import { useRouter } from "@tanstack/react-router"; + +import { Loader2 } from "lucide-react"; +import { neonTemplateHook } from "@/client_logic/template_hook"; +import { showError } from "@/lib/toast"; + +interface CreateAppDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + template: Template | undefined; +} + +export function CreateAppDialog({ + open, + onOpenChange, + template, +}: CreateAppDialogProps) { + const setSelectedAppId = useSetAtom(selectedAppIdAtom); + const [appName, setAppName] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const { createApp } = useCreateApp(); + const { data: nameCheckResult } = useCheckName(appName); + const router = useRouter(); + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!appName.trim()) { + return; + } + + if (nameCheckResult?.exists) { + return; + } + + setIsSubmitting(true); + try { + const result = await createApp({ name: appName.trim() }); + if (template && NEON_TEMPLATE_IDS.has(template.id)) { + await neonTemplateHook({ + appId: result.app.id, + appName: result.app.name, + }); + } + setSelectedAppId(result.app.id); + // Navigate to the new app's first chat + router.navigate({ + to: "/chat", + search: { id: result.chatId }, + }); + setAppName(""); + onOpenChange(false); + } catch (error) { + showError(error as any); + // Error is already handled by createApp hook or shown above + console.error("Error creating app:", error); + } finally { + setIsSubmitting(false); + } + }; + + const isNameValid = appName.trim().length > 0; + const nameExists = nameCheckResult?.exists; + const canSubmit = isNameValid && !nameExists && !isSubmitting; + + return ( + + + + Create New App + + {`Create a new app using the ${template?.title} template.`} + + + +
+
+
+ + setAppName(e.target.value)} + placeholder="Enter app name..." + className={nameExists ? "border-red-500" : ""} + disabled={isSubmitting} + /> + {nameExists && ( +

+ An app with this name already exists +

+ )} +
+
+ + + + + +
+
+
+ ); +} diff --git a/src/components/InputRequestToast.tsx b/src/components/InputRequestToast.tsx new file mode 100644 index 0000000..1ef1c8c --- /dev/null +++ b/src/components/InputRequestToast.tsx @@ -0,0 +1,89 @@ +import React from "react"; +import { toast } from "sonner"; +import { X, AlertTriangle } from "lucide-react"; +import { Button } from "./ui/button"; + +interface InputRequestToastProps { + message: string; + toastId: string | number; + onResponse: (response: "y" | "n") => void; +} + +export function InputRequestToast({ + message, + toastId, + onResponse, +}: InputRequestToastProps) { + const handleClose = () => { + toast.dismiss(toastId); + }; + + const handleResponse = (response: "y" | "n") => { + onResponse(response); + toast.dismiss(toastId); + }; + + // Clean up the message by removing excessive newlines and whitespace + const cleanMessage = message + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .join("\n"); + + return ( +
+ {/* Content */} +
+
+
+
+
+
+ +
+
+

+ Input Required +

+ + {/* Close button */} + +
+ + {/* Message */} +
+

+ {cleanMessage} +

+
+ + {/* Action buttons */} +
+ + +
+
+
+
+
+ ); +} diff --git a/src/components/NeonConnector.tsx b/src/components/NeonConnector.tsx new file mode 100644 index 0000000..0ab6a84 --- /dev/null +++ b/src/components/NeonConnector.tsx @@ -0,0 +1,157 @@ +import { useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { IpcClient } from "@/ipc/ipc_client"; +import { toast } from "sonner"; +import { useSettings } from "@/hooks/useSettings"; + +import { useDeepLink } from "@/contexts/DeepLinkContext"; +import { ExternalLink } from "lucide-react"; +import { useTheme } from "@/contexts/ThemeContext"; +import { NeonDisconnectButton } from "@/components/NeonDisconnectButton"; + +export function NeonConnector() { + const { settings, refreshSettings } = useSettings(); + const { lastDeepLink } = useDeepLink(); + const { isDarkMode } = useTheme(); + + useEffect(() => { + const handleDeepLink = async () => { + if (lastDeepLink?.type === "neon-oauth-return") { + await refreshSettings(); + toast.success("Successfully connected to Neon!"); + } + }; + handleDeepLink(); + }, [lastDeepLink]); + + if (settings?.neon?.accessToken) { + return ( +
+
+
+

Neon Database

+ +
+

+ You are connected to Neon Database +

+ +
+
+ ); + } + + return ( +
+
+

Neon Database

+

+ Neon Database has a good free tier with backups and up to 10 projects. +

+
{ + if (settings?.isTestMode) { + await IpcClient.getInstance().fakeHandleNeonConnect(); + } else { + await IpcClient.getInstance().openExternalUrl( + "https://oauth.dyad.sh/api/integrations/neon/login", + ); + } + }} + className="w-auto h-10 cursor-pointer flex items-center justify-center px-4 py-2 rounded-md border-2 transition-colors font-medium text-sm dark:bg-gray-900 dark:border-gray-700" + data-testid="connect-neon-button" + > + Connect to + +
+
+
+ ); +} + +function NeonSvg({ + isDarkMode, + className, +}: { + isDarkMode?: boolean; + className?: string; +}) { + const textColor = isDarkMode ? "#fff" : "#000"; + + return ( + + + + + + + + + + + + + + + + + + ); +} diff --git a/src/components/NeonDisconnectButton.tsx b/src/components/NeonDisconnectButton.tsx new file mode 100644 index 0000000..2f4f4a7 --- /dev/null +++ b/src/components/NeonDisconnectButton.tsx @@ -0,0 +1,38 @@ +import { Button } from "@/components/ui/button"; +import { toast } from "sonner"; +import { useSettings } from "@/hooks/useSettings"; + +interface NeonDisconnectButtonProps { + className?: string; +} + +export function NeonDisconnectButton({ className }: NeonDisconnectButtonProps) { + const { updateSettings, settings } = useSettings(); + + const handleDisconnect = async () => { + try { + await updateSettings({ + neon: undefined, + }); + toast.success("Disconnected from Neon successfully"); + } catch (error) { + console.error("Failed to disconnect from Neon:", error); + toast.error("Failed to disconnect from Neon"); + } + }; + + if (!settings?.neon?.accessToken) { + return null; + } + + return ( + + ); +} diff --git a/src/components/NeonIntegration.tsx b/src/components/NeonIntegration.tsx new file mode 100644 index 0000000..81728bc --- /dev/null +++ b/src/components/NeonIntegration.tsx @@ -0,0 +1,29 @@ +import { useSettings } from "@/hooks/useSettings"; +import { NeonDisconnectButton } from "@/components/NeonDisconnectButton"; + +export function NeonIntegration() { + const { settings } = useSettings(); + + const isConnected = !!settings?.neon?.accessToken; + + if (!isConnected) { + return null; + } + + return ( +
+
+

+ Neon Integration +

+

+ Your account is connected to Neon. +

+
+ +
+ +
+
+ ); +} diff --git a/src/components/PortalMigrate.tsx b/src/components/PortalMigrate.tsx new file mode 100644 index 0000000..99eeaf6 --- /dev/null +++ b/src/components/PortalMigrate.tsx @@ -0,0 +1,110 @@ +import { useState } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { IpcClient } from "@/ipc/ipc_client"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { ExternalLink, Database, Loader2 } from "lucide-react"; +import { showSuccess, showError } from "@/lib/toast"; +import { useVersions } from "@/hooks/useVersions"; + +interface PortalMigrateProps { + appId: number; +} + +export const PortalMigrate = ({ appId }: PortalMigrateProps) => { + const [output, setOutput] = useState(""); + const { refreshVersions } = useVersions(appId); + + const migrateMutation = useMutation({ + mutationFn: async () => { + const ipcClient = IpcClient.getInstance(); + return ipcClient.portalMigrateCreate({ appId }); + }, + onSuccess: (result) => { + setOutput(result.output); + showSuccess( + "Database migration file generated and committed successfully!", + ); + refreshVersions(); + }, + onError: (error) => { + const errorMessage = + error instanceof Error ? error.message : String(error); + setOutput(`Error: ${errorMessage}`); + showError(errorMessage); + }, + }); + + const handleCreateMigration = () => { + setOutput(""); // Clear previous output + migrateMutation.mutate(); + }; + + const openDocs = () => { + const ipcClient = IpcClient.getInstance(); + ipcClient.openExternalUrl( + "https://www.dyad.sh/docs/templates/portal#create-a-database-migration", + ); + }; + + return ( + + + + + Portal Database Migration + + + +

+ Generate a new database migration file for your Portal app. +

+ +
+ + + +
+ + {output && ( +
+
+

+ Command Output: +

+
+
+                  {output}
+                
+
+
+
+ )} +
+
+ ); +}; diff --git a/src/components/TemplateCard.tsx b/src/components/TemplateCard.tsx index c165440..62c9768 100644 --- a/src/components/TemplateCard.tsx +++ b/src/components/TemplateCard.tsx @@ -4,17 +4,22 @@ import { IpcClient } from "@/ipc/ipc_client"; import { useSettings } from "@/hooks/useSettings"; import { CommunityCodeConsentDialog } from "./CommunityCodeConsentDialog"; import type { Template } from "@/shared/templates"; +import { Button } from "./ui/button"; +import { cn } from "@/lib/utils"; +import { showWarning } from "@/lib/toast"; interface TemplateCardProps { template: Template; isSelected: boolean; onSelect: (templateId: string) => void; + onCreateApp: () => void; } export const TemplateCard: React.FC = ({ template, isSelected, onSelect, + onCreateApp, }) => { const { settings, updateSettings } = useSettings(); const [showConsentDialog, setShowConsentDialog] = useState(false); @@ -26,6 +31,11 @@ export const TemplateCard: React.FC = ({ return; } + if (template.requiresNeon && !settings?.neon?.accessToken) { + showWarning("Please connect your Neon account to use this template."); + return; + } + // Otherwise, proceed with selection onSelect(template.id); }; @@ -93,7 +103,7 @@ export const TemplateCard: React.FC = ({ > {template.title} - {template.isOfficial && ( + {template.isOfficial && !template.isExperimental && ( = ({ Official )} + {template.isExperimental && ( + + Experimental + + )} -

+

{template.description}

{template.githubUrl && ( @@ -121,6 +136,20 @@ export const TemplateCard: React.FC = ({ )} + + diff --git a/src/components/chat/ChatHeader.tsx b/src/components/chat/ChatHeader.tsx index 1d42790..964279b 100644 --- a/src/components/chat/ChatHeader.tsx +++ b/src/components/chat/ChatHeader.tsx @@ -120,15 +120,26 @@ export function ChatHeader({ - Warning: - You are not on a branch - + {isAnyCheckoutVersionInProgress ? ( + <> + + Please wait, switching back to latest version... + + + ) : ( + <> + Warning: + You are not on a branch + + + )}

- Checkout main branch, otherwise changes will not be - saved properly + {isAnyCheckoutVersionInProgress + ? "Version checkout is currently in progress" + : "Checkout main branch, otherwise changes will not be saved properly"}

@@ -152,7 +163,7 @@ export function ChatHeader({ > {isRenamingBranch ? "Renaming..." : "Rename master to main"} - ) : ( + ) : isAnyCheckoutVersionInProgress && !isCheckingOutVersion ? null : ( +

Version History

+
+ +
{versions.length === 0 ? (
No versions available
) : (
- {versions.map((version: Version, index) => ( + {versions.map((version: Version, index: number) => (
{ if (!isCheckingOutVersion) { - handleVersionClick(version.oid); + handleVersionClick(version); } }} >
- - Version {versions.length - index} - - - {formatDistanceToNow(new Date(version.timestamp * 1000), { - addSuffix: true, - })} - +
+ + Version {versions.length - index} ( + {version.oid.slice(0, 7)}) + + {/* example format: '2025-07-25T21:52:01Z' */} + {version.dbTimestamp && + (() => { + const timestampMs = new Date( + version.dbTimestamp, + ).getTime(); + const isExpired = + Date.now() - timestampMs > 24 * 60 * 60 * 1000; + return ( + + +
+ + DB +
+
+ + {isExpired + ? "DB snapshot may have expired (older than 24 hours)" + : `Database snapshot available at timestamp ${version.dbTimestamp}`} + +
+ ); + })()} +
+
+ {isCheckingOutVersion && + selectedVersionId === version.oid && ( + + )} + + {isCheckingOutVersion && selectedVersionId === version.oid + ? "Loading..." + : formatDistanceToNow( + new Date(version.timestamp * 1000), + { + addSuffix: true, + }, + )} + +
{version.message && ( @@ -158,30 +219,50 @@ export function VersionPane({ isVisible, onClose }: VersionPaneProps) {

)} - - - - - Restore to this version - +
+ {/* Restore button */} + + + + + + {isRevertingVersion + ? "Restoring to this version..." + : "Restore to this version"} + + +
))} diff --git a/src/components/preview_panel/ConfigurePanel.tsx b/src/components/preview_panel/ConfigurePanel.tsx index 94c8c49..5b8f291 100644 --- a/src/components/preview_panel/ConfigurePanel.tsx +++ b/src/components/preview_panel/ConfigurePanel.tsx @@ -23,6 +23,7 @@ import { showError, showSuccess } from "@/lib/toast"; import { selectedAppIdAtom } from "@/atoms/appAtoms"; import { IpcClient } from "@/ipc/ipc_client"; import { useNavigate } from "@tanstack/react-router"; +import { NeonConfigure } from "./NeonConfigure"; const EnvironmentVariablesTitle = () => (
@@ -396,6 +397,12 @@ export const ConfigurePanel = () => {
+ + {/* Neon Database Configuration */} + {/* Neon Connector */} +
+ +
); }; diff --git a/src/components/preview_panel/NeonConfigure.tsx b/src/components/preview_panel/NeonConfigure.tsx new file mode 100644 index 0000000..c983e68 --- /dev/null +++ b/src/components/preview_panel/NeonConfigure.tsx @@ -0,0 +1,178 @@ +import { useQuery } from "@tanstack/react-query"; +import { useAtomValue } from "jotai"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; + +import { Database, GitBranch } from "lucide-react"; +import { selectedAppIdAtom } from "@/atoms/appAtoms"; +import { useLoadApp } from "@/hooks/useLoadApp"; +import { IpcClient } from "@/ipc/ipc_client"; +import type { GetNeonProjectResponse, NeonBranch } from "@/ipc/ipc_types"; +import { NeonDisconnectButton } from "@/components/NeonDisconnectButton"; + +const getBranchTypeColor = (type: NeonBranch["type"]) => { + switch (type) { + case "production": + return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300"; + case "development": + return "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300"; + case "snapshot": + return "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300"; + case "preview": + return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300"; + default: + return "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300"; + } +}; + +const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleString(); +}; + +export const NeonConfigure = () => { + const selectedAppId = useAtomValue(selectedAppIdAtom); + const { app } = useLoadApp(selectedAppId); + + // Query to get Neon project information + const { + data: neonProject, + isLoading, + error, + } = useQuery({ + queryKey: ["neon-project", selectedAppId], + queryFn: async () => { + if (!selectedAppId) throw new Error("No app selected"); + const ipcClient = IpcClient.getInstance(); + return await ipcClient.getNeonProject({ appId: selectedAppId }); + }, + enabled: !!selectedAppId && !!app?.neonProjectId, + meta: { showErrorToast: true }, + }); + + // Don't show component if app doesn't have Neon project + if (!app?.neonProjectId) { + return null; + } + + // Show loading state + if (isLoading) { + return ( + + + + + Neon Database + + + +
+
+ Loading Neon project information... +
+
+
+
+ ); + } + + // Show error state + if (error) { + return ( + + + + + Neon Database + + + +
+
+ Error loading Neon project: {error.message} +
+
+
+
+ ); + } + + if (!neonProject) { + return null; + } + + return ( + + + +
+ + Neon Database +
+ +
+
+ + {/* Project Information */} +
+
Project Information
+
+
+ Project Name: + {neonProject.projectName} +
+
+ Project ID: + {neonProject.projectId} +
+
+ Organization: + {neonProject.orgId} +
+
+
+ + {/* Branches */} +
+
+ + Branches ({neonProject.branches.length}) +
+
+ {neonProject.branches.map((branch) => ( +
+
+
+ + {branch.branchName} + + + {branch.type} + +
+
+ ID: {branch.branchId} +
+ {branch.parentBranchName && ( +
+ Parent: {branch.parentBranchName.slice(0, 20)}... +
+ )} +
+ Updated: {formatDate(branch.lastUpdated)} +
+
+
+ ))} +
+
+
+
+ ); +}; diff --git a/src/components/preview_panel/PreviewIframe.tsx b/src/components/preview_panel/PreviewIframe.tsx index d216383..1ab3dc5 100644 --- a/src/components/preview_panel/PreviewIframe.tsx +++ b/src/components/preview_panel/PreviewIframe.tsx @@ -413,7 +413,20 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => { // Display loading state if (loading) { - return
Loading app preview...
; + return ( +
+
+
+
+
+
+
+

+ Preparing app preview... +

+
+
+ ); } // Display message if no app is selected @@ -565,7 +578,7 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {

- Starting up your app... + Starting your app server...

) : ( diff --git a/src/components/preview_panel/PublishPanel.tsx b/src/components/preview_panel/PublishPanel.tsx index 38e6398..72ee3cf 100644 --- a/src/components/preview_panel/PublishPanel.tsx +++ b/src/components/preview_panel/PublishPanel.tsx @@ -3,6 +3,7 @@ import { selectedAppIdAtom } from "@/atoms/appAtoms"; import { useLoadApp } from "@/hooks/useLoadApp"; import { GitHubConnector } from "@/components/GitHubConnector"; import { VercelConnector } from "@/components/VercelConnector"; +import { PortalMigrate } from "@/components/PortalMigrate"; import { IpcClient } from "@/ipc/ipc_client"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -78,6 +79,9 @@ export const PublishPanel = () => {
+ {/* Portal Section - Show only if app has neon project */} + {app.neonProjectId && } + {/* GitHub Section */} diff --git a/src/db/schema.ts b/src/db/schema.ts index 4fae2f3..be46719 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,5 +1,5 @@ import { sql } from "drizzle-orm"; -import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { integer, sqliteTable, text, unique } from "drizzle-orm/sqlite-core"; import { relations } from "drizzle-orm"; export const apps = sqliteTable("apps", { @@ -16,6 +16,9 @@ export const apps = sqliteTable("apps", { githubRepo: text("github_repo"), githubBranch: text("github_branch"), supabaseProjectId: text("supabase_project_id"), + neonProjectId: text("neon_project_id"), + neonDevelopmentBranchId: text("neon_development_branch_id"), + neonPreviewBranchId: text("neon_preview_branch_id"), vercelProjectId: text("vercel_project_id"), vercelProjectName: text("vercel_project_name"), vercelTeamId: text("vercel_team_id"), @@ -51,9 +54,32 @@ export const messages = sqliteTable("messages", { .default(sql`(unixepoch())`), }); +export const versions = sqliteTable( + "versions", + { + id: integer("id").primaryKey({ autoIncrement: true }), + appId: integer("app_id") + .notNull() + .references(() => apps.id, { onDelete: "cascade" }), + commitHash: text("commit_hash").notNull(), + neonDbTimestamp: text("neon_db_timestamp"), + createdAt: integer("created_at", { mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), + updatedAt: integer("updated_at", { mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), + }, + (table) => [ + // Unique constraint to prevent duplicate versions + unique("versions_app_commit_unique").on(table.appId, table.commitHash), + ], +); + // Define relations export const appsRelations = relations(apps, ({ many }) => ({ chats: many(chats), + versions: many(versions), })); export const chatsRelations = relations(chats, ({ many, one }) => ({ @@ -124,3 +150,10 @@ export const languageModelsRelations = relations( }), }), ); + +export const versionsRelations = relations(versions, ({ one }) => ({ + app: one(apps, { + fields: [versions.appId], + references: [apps.id], + }), +})); diff --git a/src/hooks/useCreateApp.ts b/src/hooks/useCreateApp.ts new file mode 100644 index 0000000..735380d --- /dev/null +++ b/src/hooks/useCreateApp.ts @@ -0,0 +1,38 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { IpcClient } from "@/ipc/ipc_client"; +import { showError } from "@/lib/toast"; +import type { CreateAppParams, CreateAppResult } from "@/ipc/ipc_types"; + +export function useCreateApp() { + const queryClient = useQueryClient(); + + const mutation = useMutation({ + mutationFn: async (params: CreateAppParams) => { + if (!params.name.trim()) { + throw new Error("App name is required"); + } + + const ipcClient = IpcClient.getInstance(); + return ipcClient.createApp(params); + }, + onSuccess: () => { + // Invalidate apps list to trigger refetch + queryClient.invalidateQueries({ queryKey: ["apps"] }); + }, + onError: (error) => { + showError(error); + }, + }); + + const createApp = async ( + params: CreateAppParams, + ): Promise => { + return mutation.mutateAsync(params); + }; + + return { + createApp, + isCreating: mutation.isPending, + error: mutation.error, + }; +} diff --git a/src/hooks/useRunApp.ts b/src/hooks/useRunApp.ts index fcd0b92..216c8b6 100644 --- a/src/hooks/useRunApp.ts +++ b/src/hooks/useRunApp.ts @@ -11,6 +11,7 @@ import { } from "@/atoms/appAtoms"; import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { AppOutput } from "@/ipc/ipc_types"; +import { showInputRequest } from "@/lib/toast"; const useRunAppLoadingAtom = atom(false); @@ -18,7 +19,7 @@ export function useRunApp() { const [loading, setLoading] = useAtom(useRunAppLoadingAtom); const [app, setApp] = useAtom(currentAppAtom); const setAppOutput = useSetAtom(appOutputAtom); - const [appUrlObj, setAppUrlObj] = useAtom(appUrlAtom); + const [, setAppUrlObj] = useAtom(appUrlAtom); const setPreviewPanelKey = useSetAtom(previewPanelKeyAtom); const appId = useAtomValue(selectedAppIdAtom); const setPreviewErrorMessage = useSetAtom(previewErrorMessageAtom); @@ -39,47 +40,78 @@ export function useRunApp() { const originalUrl = originalUrlMatch && originalUrlMatch[1]; setAppUrlObj({ appUrl: proxyUrl, - appId: appId!, + appId: output.appId, originalUrl: originalUrl!, }); } } }; - const runApp = useCallback(async (appId: number) => { - setLoading(true); - try { - const ipcClient = IpcClient.getInstance(); - console.debug("Running app", appId); - // Clear the URL and add restart message - if (appUrlObj?.appId !== appId) { - setAppUrlObj({ appUrl: null, appId: null, originalUrl: null }); + const processAppOutput = useCallback( + (output: AppOutput) => { + // Handle input requests specially + if (output.type === "input-requested") { + showInputRequest(output.message, async (response) => { + try { + const ipcClient = IpcClient.getInstance(); + await ipcClient.respondToAppInput({ + appId: output.appId, + response, + }); + } catch (error) { + console.error("Failed to respond to app input:", error); + } + }); + return; // Don't add to regular output } - setAppOutput((prev) => [ - ...prev, - { - message: "Trying to restart app...", - type: "stdout", - appId, - timestamp: Date.now(), - }, - ]); - const app = await ipcClient.getApp(appId); - setApp(app); - await ipcClient.runApp(appId, (output) => { - setAppOutput((prev) => [...prev, output]); - processProxyServerOutput(output); - }); - setPreviewErrorMessage(undefined); - } catch (error) { - console.error(`Error running app ${appId}:`, error); - setPreviewErrorMessage( - error instanceof Error ? error.message : error?.toString(), - ); - } finally { - setLoading(false); - } - }, []); + + // Add to regular app output + setAppOutput((prev) => [...prev, output]); + + // Process proxy server output + processProxyServerOutput(output); + }, + [setAppOutput], + ); + const runApp = useCallback( + async (appId: number) => { + setLoading(true); + try { + const ipcClient = IpcClient.getInstance(); + console.debug("Running app", appId); + + // Clear the URL and add restart message + setAppUrlObj((prevAppUrlObj) => { + if (prevAppUrlObj?.appId !== appId) { + return { appUrl: null, appId: null, originalUrl: null }; + } + return prevAppUrlObj; // No change needed + }); + + setAppOutput((prev) => [ + ...prev, + { + message: "Trying to restart app...", + type: "stdout", + appId, + timestamp: Date.now(), + }, + ]); + const app = await ipcClient.getApp(appId); + setApp(app); + await ipcClient.runApp(appId, processAppOutput); + setPreviewErrorMessage(undefined); + } catch (error) { + console.error(`Error running app ${appId}:`, error); + setPreviewErrorMessage( + error instanceof Error ? error.message : error?.toString(), + ); + } finally { + setLoading(false); + } + }, + [processAppOutput], + ); const stopApp = useCallback(async (appId: number) => { if (appId === null) { @@ -139,15 +171,15 @@ export function useRunApp() { await ipcClient.restartApp( appId, (output) => { - setAppOutput((prev) => [...prev, output]); + // Handle HMR updates before processing if ( output.message.includes("hmr update") && output.message.includes("[vite]") ) { onHotModuleReload(); - return; } - processProxyServerOutput(output); + // Process normally (including input requests) + processAppOutput(output); }, removeNodeModules, ); @@ -161,7 +193,15 @@ export function useRunApp() { setLoading(false); } }, - [appId, setApp, setAppOutput, setAppUrlObj, setPreviewPanelKey], + [ + appId, + setApp, + setAppOutput, + setAppUrlObj, + setPreviewPanelKey, + processAppOutput, + onHotModuleReload, + ], ); const refreshAppIframe = useCallback(async () => { diff --git a/src/hooks/useVersions.ts b/src/hooks/useVersions.ts index 6a440fb..da37d35 100644 --- a/src/hooks/useVersions.ts +++ b/src/hooks/useVersions.ts @@ -5,7 +5,8 @@ import { IpcClient } from "@/ipc/ipc_client"; import { chatMessagesAtom, selectedChatIdAtom } from "@/atoms/chatAtoms"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import type { Version } from "@/ipc/ipc_types"; +import type { RevertVersionResponse, Version } from "@/ipc/ipc_types"; +import { toast } from "sonner"; export function useVersions(appId: number | null) { const [, setVersionsAtom] = useAtom(versionsListAtom); @@ -38,35 +39,42 @@ export function useVersions(appId: number | null) { } }, [versions, setVersionsAtom]); - const revertVersionMutation = useMutation( - { - mutationFn: async ({ versionId }: { versionId: string }) => { - const currentAppId = appId; - if (currentAppId === null) { - throw new Error("App ID is null"); - } - const ipcClient = IpcClient.getInstance(); - await ipcClient.revertVersion({ - appId: currentAppId, - previousVersionId: versionId, - }); - }, - onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: ["versions", appId] }); - await queryClient.invalidateQueries({ - queryKey: ["currentBranch", appId], - }); - if (selectedChatId) { - const chat = await IpcClient.getInstance().getChat(selectedChatId); - setMessages(chat.messages); - } - await queryClient.invalidateQueries({ - queryKey: ["problems", appId], - }); - }, - meta: { showErrorToast: true }, + const revertVersionMutation = useMutation< + RevertVersionResponse, + Error, + { versionId: string } + >({ + mutationFn: async ({ versionId }: { versionId: string }) => { + const currentAppId = appId; + if (currentAppId === null) { + throw new Error("App ID is null"); + } + const ipcClient = IpcClient.getInstance(); + return ipcClient.revertVersion({ + appId: currentAppId, + previousVersionId: versionId, + }); }, - ); + onSuccess: async (result) => { + if ("successMessage" in result) { + toast.success(result.successMessage); + } else if ("warningMessage" in result) { + toast.warning(result.warningMessage); + } + await queryClient.invalidateQueries({ queryKey: ["versions", appId] }); + await queryClient.invalidateQueries({ + queryKey: ["currentBranch", appId], + }); + if (selectedChatId) { + const chat = await IpcClient.getInstance().getChat(selectedChatId); + setMessages(chat.messages); + } + await queryClient.invalidateQueries({ + queryKey: ["problems", appId], + }); + }, + meta: { showErrorToast: true }, + }); return { versions: versions || [], @@ -74,5 +82,6 @@ export function useVersions(appId: number | null) { error, refreshVersions, revertVersion: revertVersionMutation.mutateAsync, + isRevertingVersion: revertVersionMutation.isPending, }; } diff --git a/src/ipc/handlers/app_env_vars_handlers.ts b/src/ipc/handlers/app_env_vars_handlers.ts index 0b1ea56..d8438ef 100644 --- a/src/ipc/handlers/app_env_vars_handlers.ts +++ b/src/ipc/handlers/app_env_vars_handlers.ts @@ -10,7 +10,11 @@ import { apps } from "../../db/schema"; import { eq } from "drizzle-orm"; import { getDyadAppPath } from "../../paths/paths"; import { GetAppEnvVarsParams, SetAppEnvVarsParams } from "../ipc_types"; -import { parseEnvFile, serializeEnvFile } from "../utils/app_env_var_utils"; +import { + ENV_FILE_NAME, + parseEnvFile, + serializeEnvFile, +} from "../utils/app_env_var_utils"; export function registerAppEnvVarsHandlers() { // Handler to get app environment variables @@ -27,7 +31,7 @@ export function registerAppEnvVarsHandlers() { } const appPath = getDyadAppPath(app.path); - const envFilePath = path.join(appPath, ".env.local"); + const envFilePath = path.join(appPath, ENV_FILE_NAME); // If .env.local doesn't exist, return empty array try { @@ -63,7 +67,7 @@ export function registerAppEnvVarsHandlers() { } const appPath = getDyadAppPath(app.path); - const envFilePath = path.join(appPath, ".env.local"); + const envFilePath = path.join(appPath, ENV_FILE_NAME); // Serialize environment variables to .env.local format const content = serializeEnvFile(envVars); diff --git a/src/ipc/handlers/app_handlers.ts b/src/ipc/handlers/app_handlers.ts index 7278f52..5c2b739 100644 --- a/src/ipc/handlers/app_handlers.ts +++ b/src/ipc/handlers/app_handlers.ts @@ -8,6 +8,7 @@ import type { RenameBranchParams, CopyAppParams, EditAppFileReturnType, + RespondToAppInputParams, } from "../ipc_types"; import fs from "node:fs"; import path from "node:path"; @@ -47,6 +48,7 @@ import { safeSend } from "../utils/safe_sender"; import { normalizePath } from "../../../shared/normalizePath"; import { isServerFunction } from "@/supabase_admin/supabase_utils"; import { getVercelTeamSlug } from "../utils/vercel_utils"; +import { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils"; async function copyDir( source: string, @@ -80,28 +82,32 @@ async function executeApp({ appPath, appId, event, // Keep event for local-node case + isNeon, }: { appPath: string; appId: number; event: Electron.IpcMainInvokeEvent; + isNeon: boolean; }): Promise { if (proxyWorker) { proxyWorker.terminate(); proxyWorker = null; } - await executeAppLocalNode({ appPath, appId, event }); + await executeAppLocalNode({ appPath, appId, event, isNeon }); } async function executeAppLocalNode({ appPath, appId, event, + isNeon, }: { appPath: string; appId: number; event: Electron.IpcMainInvokeEvent; + isNeon: boolean; }): Promise { - const process = spawn( + const spawnedProcess = spawn( "(pnpm install && pnpm run dev --port 32100) || (npm install --legacy-peer-deps && npm run dev -- --port 32100)", [], { @@ -113,11 +119,11 @@ async function executeAppLocalNode({ ); // Check if process spawned correctly - if (!process.pid) { + if (!spawnedProcess.pid) { // Attempt to capture any immediate errors if possible let errorOutput = ""; - process.stderr?.on("data", (data) => (errorOutput += data)); - await new Promise((resolve) => process.on("error", resolve)); // Wait for error event + spawnedProcess.stderr?.on("data", (data) => (errorOutput += data)); + await new Promise((resolve) => spawnedProcess.on("error", resolve)); // Wait for error event throw new Error( `Failed to spawn process for app ${appId}. Error: ${ errorOutput || "Unknown spawn error" @@ -127,35 +133,69 @@ async function executeAppLocalNode({ // Increment the counter and store the process reference with its ID const currentProcessId = processCounter.increment(); - runningApps.set(appId, { process, processId: currentProcessId }); + runningApps.set(appId, { + process: spawnedProcess, + processId: currentProcessId, + }); // Log output - process.stdout?.on("data", async (data) => { + spawnedProcess.stdout?.on("data", async (data) => { const message = util.stripVTControlCharacters(data.toString()); - logger.debug(`App ${appId} (PID: ${process.pid}) stdout: ${message}`); + logger.debug( + `App ${appId} (PID: ${spawnedProcess.pid}) stdout: ${message}`, + ); - safeSend(event.sender, "app:output", { - type: "stdout", - message, - appId, - }); - const urlMatch = message.match(/(https?:\/\/localhost:\d+\/?)/); - if (urlMatch) { - proxyWorker = await startProxy(urlMatch[1], { - onStarted: (proxyUrl) => { - safeSend(event.sender, "app:output", { - type: "stdout", - message: `[dyad-proxy-server]started=[${proxyUrl}] original=[${urlMatch[1]}]`, - appId, - }); - }, + // This is a hacky heuristic to pick up when drizzle is asking for user + // to select from one of a few choices. We automatically pick the first + // option because it's usually a good default choice. We guard this with + // isNeon because: 1) only Neon apps (for the official Dyad templates) should + // get this template and 2) it's safer to do this with Neon apps because + // their databases have point in time restore built-in. + if (isNeon && message.includes("created or renamed from another")) { + spawnedProcess.stdin.write(`\r\n`); + logger.info( + `App ${appId} (PID: ${spawnedProcess.pid}) wrote enter to stdin to automatically respond to drizzle push input`, + ); + } + + // Check if this is an interactive prompt requiring user input + const inputRequestPattern = /\s*›\s*\([yY]\/[nN]\)\s*$/; + const isInputRequest = inputRequestPattern.test(message); + if (isInputRequest) { + // Send special input-requested event for interactive prompts + safeSend(event.sender, "app:output", { + type: "input-requested", + message, + appId, }); + } else { + // Normal stdout handling + safeSend(event.sender, "app:output", { + type: "stdout", + message, + appId, + }); + + const urlMatch = message.match(/(https?:\/\/localhost:\d+\/?)/); + if (urlMatch) { + proxyWorker = await startProxy(urlMatch[1], { + onStarted: (proxyUrl) => { + safeSend(event.sender, "app:output", { + type: "stdout", + message: `[dyad-proxy-server]started=[${proxyUrl}] original=[${urlMatch[1]}]`, + appId, + }); + }, + }); + } } }); - process.stderr?.on("data", (data) => { + spawnedProcess.stderr?.on("data", (data) => { const message = util.stripVTControlCharacters(data.toString()); - logger.error(`App ${appId} (PID: ${process.pid}) stderr: ${message}`); + logger.error( + `App ${appId} (PID: ${spawnedProcess.pid}) stderr: ${message}`, + ); safeSend(event.sender, "app:output", { type: "stderr", message, @@ -164,19 +204,19 @@ async function executeAppLocalNode({ }); // Handle process exit/close - process.on("close", (code, signal) => { + spawnedProcess.on("close", (code, signal) => { logger.log( - `App ${appId} (PID: ${process.pid}) process closed with code ${code}, signal ${signal}.`, + `App ${appId} (PID: ${spawnedProcess.pid}) process closed with code ${code}, signal ${signal}.`, ); - removeAppIfCurrentProcess(appId, process); + removeAppIfCurrentProcess(appId, spawnedProcess); }); // Handle errors during process lifecycle (e.g., command not found) - process.on("error", (err) => { + spawnedProcess.on("error", (err) => { logger.error( - `Error in app ${appId} (PID: ${process.pid}) process: ${err.message}`, + `Error in app ${appId} (PID: ${spawnedProcess.pid}) process: ${err.message}`, ); - removeAppIfCurrentProcess(appId, process); + removeAppIfCurrentProcess(appId, spawnedProcess); // Note: We don't throw here as the error is asynchronous. The caller got a success response already. // Consider adding ipcRenderer event emission to notify UI of the error. }); @@ -466,7 +506,12 @@ export function registerAppHandlers() { try { // Kill any orphaned process on port 32100 (in case previous run left it) await killProcessOnPort(32100); - await executeApp({ appPath, appId, event }); + await executeApp({ + appPath, + appId, + event, + isNeon: !!app.neonProjectId, + }); return; } catch (error: any) { @@ -596,7 +641,12 @@ export function registerAppHandlers() { `Executing app ${appId} in path ${app.path} after restart request`, ); // Adjusted log - await executeApp({ appPath, appId, event }); // This will handle starting either mode + await executeApp({ + appPath, + appId, + event, + isNeon: !!app.neonProjectId, + }); // This will handle starting either mode return; } catch (error) { @@ -633,6 +683,23 @@ export function registerAppHandlers() { throw new Error("Invalid file path"); } + if (app.neonProjectId && app.neonDevelopmentBranchId) { + try { + await storeDbTimestampAtCurrentVersion({ + appId: app.id, + }); + } catch (error) { + logger.error( + "Error storing Neon timestamp at current version:", + error, + ); + throw new Error( + "Could not store Neon timestamp at current version; database versioning functionality is not working: " + + error, + ); + } + } + // Ensure directory exists const dirPath = path.dirname(fullPath); await fsPromises.mkdir(dirPath, { recursive: true }); @@ -968,4 +1035,33 @@ export function registerAppHandlers() { } }); }); + + handle( + "respond-to-app-input", + async (_, { appId, response }: RespondToAppInputParams) => { + if (response !== "y" && response !== "n") { + throw new Error(`Invalid response: ${response}`); + } + const appInfo = runningApps.get(appId); + + if (!appInfo) { + throw new Error(`App ${appId} is not running`); + } + + const { process } = appInfo; + + if (!process.stdin) { + throw new Error(`App ${appId} process has no stdin available`); + } + + try { + // Write the response to stdin with a newline + process.stdin.write(`${response}\n`); + logger.debug(`Sent response '${response}' to app ${appId} stdin`); + } catch (error: any) { + logger.error(`Error sending response to app ${appId}:`, error); + throw new Error(`Failed to send response to app: ${error.message}`); + } + }, + ); } diff --git a/src/ipc/handlers/chat_stream_handlers.ts b/src/ipc/handlers/chat_stream_handlers.ts index 16f9772..dd9b63b 100644 --- a/src/ipc/handlers/chat_stream_handlers.ts +++ b/src/ipc/handlers/chat_stream_handlers.ts @@ -456,7 +456,10 @@ ${componentSnippet} (await getSupabaseContext({ supabaseProjectId: updatedChat.app.supabaseProjectId, })); - } else { + } else if ( + // Neon projects don't need Supabase. + !updatedChat.app?.neonProjectId + ) { systemPrompt += "\n\n" + SUPABASE_NOT_AVAILABLE_SYSTEM_PROMPT; } const isSummarizeIntent = req.prompt.startsWith( diff --git a/src/ipc/handlers/neon_handlers.ts b/src/ipc/handlers/neon_handlers.ts new file mode 100644 index 0000000..10c218c --- /dev/null +++ b/src/ipc/handlers/neon_handlers.ts @@ -0,0 +1,235 @@ +import log from "electron-log"; + +import { createTestOnlyLoggedHandler } from "./safe_handle"; +import { handleNeonOAuthReturn } from "../../neon_admin/neon_return_handler"; +import { + getNeonClient, + getNeonErrorMessage, + getNeonOrganizationId, +} from "../../neon_admin/neon_management_client"; +import { + CreateNeonProjectParams, + NeonProject, + GetNeonProjectParams, + GetNeonProjectResponse, + NeonBranch, +} from "../ipc_types"; +import { db } from "../../db"; +import { apps } from "../../db/schema"; +import { eq } from "drizzle-orm"; +import { ipcMain } from "electron"; +import { EndpointType } from "@neondatabase/api-client"; +import { retryOnLocked } from "../utils/retryOnLocked"; + +export const logger = log.scope("neon_handlers"); + +const testOnlyHandle = createTestOnlyLoggedHandler(logger); + +export function registerNeonHandlers() { + // Do not use log handler because there's sensitive data in the response + ipcMain.handle( + "neon:create-project", + async ( + _, + { name, appId }: CreateNeonProjectParams, + ): Promise => { + const neonClient = await getNeonClient(); + + logger.info(`Creating Neon project: ${name} for app ${appId}`); + + try { + // Get the organization ID + const orgId = await getNeonOrganizationId(); + + // Create project with retry on locked errors + const response = await retryOnLocked( + () => + neonClient.createProject({ + project: { + name: name, + org_id: orgId, + }, + }), + `Create project ${name} for app ${appId}`, + ); + + if (!response.data.project) { + throw new Error( + "Failed to create project: No project data returned.", + ); + } + + const project = response.data.project; + const developmentBranch = response.data.branch; + + const previewBranchResponse = await retryOnLocked( + () => + neonClient.createProjectBranch(project.id, { + endpoints: [{ type: EndpointType.ReadOnly }], + branch: { + name: "preview", + parent_id: developmentBranch.id, + }, + }), + `Create preview branch for project ${project.id}`, + ); + + if ( + !previewBranchResponse.data.branch || + !previewBranchResponse.data.connection_uris + ) { + throw new Error( + "Failed to create preview branch: No branch data returned.", + ); + } + + const previewBranch = previewBranchResponse.data.branch; + + // Store project and branch info in the app's DB row + await db + .update(apps) + .set({ + neonProjectId: project.id, + neonDevelopmentBranchId: developmentBranch.id, + neonPreviewBranchId: previewBranch.id, + }) + .where(eq(apps.id, appId)); + + logger.info( + `Successfully created Neon project: ${project.id} and development branch: ${developmentBranch.id} for app ${appId}`, + ); + return { + id: project.id, + name: project.name, + connectionString: response.data.connection_uris[0].connection_uri, + branchId: developmentBranch.id, + }; + } catch (error: any) { + const errorMessage = getNeonErrorMessage(error); + const message = `Failed to create Neon project for app ${appId}: ${errorMessage}`; + logger.error(message); + throw new Error(message); + } + }, + ); + + ipcMain.handle( + "neon:get-project", + async ( + _, + { appId }: GetNeonProjectParams, + ): Promise => { + logger.info(`Getting Neon project info for app ${appId}`); + + try { + // Get the app from the database to find the neonProjectId and neonBranchId + const app = await db + .select() + .from(apps) + .where(eq(apps.id, appId)) + .limit(1); + + if (app.length === 0) { + throw new Error(`App with ID ${appId} not found`); + } + + const appData = app[0]; + if (!appData.neonProjectId) { + throw new Error(`No Neon project found for app ${appId}`); + } + + const neonClient = await getNeonClient(); + console.log("PROJECT ID", appData.neonProjectId); + + // Get project info + const projectResponse = await neonClient.getProject( + appData.neonProjectId, + ); + + if (!projectResponse.data.project) { + throw new Error("Failed to get project: No project data returned."); + } + + const project = projectResponse.data.project; + + // Get list of branches + const branchesResponse = await neonClient.listProjectBranches({ + projectId: appData.neonProjectId, + }); + + if (!branchesResponse.data.branches) { + throw new Error("Failed to get branches: No branch data returned."); + } + + // Map branches to our format + const branches: NeonBranch[] = branchesResponse.data.branches.map( + (branch) => { + let type: "production" | "development" | "snapshot" | "preview"; + + if (branch.default) { + type = "production"; + } else if (branch.id === appData.neonDevelopmentBranchId) { + type = "development"; + } else if (branch.id === appData.neonPreviewBranchId) { + type = "preview"; + } else { + type = "snapshot"; + } + + // Find parent branch name if parent_id exists + let parentBranchName: string | undefined; + if (branch.parent_id) { + const parentBranch = branchesResponse.data.branches?.find( + (b) => b.id === branch.parent_id, + ); + parentBranchName = parentBranch?.name; + } + + return { + type, + branchId: branch.id, + branchName: branch.name, + lastUpdated: branch.updated_at, + parentBranchId: branch.parent_id, + parentBranchName, + }; + }, + ); + + logger.info( + `Successfully retrieved Neon project info for app ${appId}`, + ); + + return { + projectId: project.id, + projectName: project.name, + orgId: project.org_id ?? "", + branches, + }; + } catch (error) { + logger.error( + `Failed to get Neon project info for app ${appId}:`, + error, + ); + throw error; + } + }, + ); + + testOnlyHandle("neon:fake-connect", async (event) => { + // Call handleNeonOAuthReturn with fake data + handleNeonOAuthReturn({ + token: "fake-neon-access-token", + refreshToken: "fake-neon-refresh-token", + expiresIn: 3600, // 1 hour + }); + logger.info("Called handleNeonOAuthReturn with fake data during testing."); + + // Simulate the deep link event + event.sender.send("deep-link-received", { + type: "neon-oauth-return", + url: "https://oauth.dyad.sh/api/integrations/neon/login", + }); + logger.info("Sent fake neon deep-link-received event during testing."); + }); +} diff --git a/src/ipc/handlers/portal_handlers.ts b/src/ipc/handlers/portal_handlers.ts new file mode 100644 index 0000000..cb44c81 --- /dev/null +++ b/src/ipc/handlers/portal_handlers.ts @@ -0,0 +1,138 @@ +import { createLoggedHandler } from "./safe_handle"; +import log from "electron-log"; +import { db } from "../../db"; +import { apps } from "../../db/schema"; +import { eq } from "drizzle-orm"; +import { getDyadAppPath } from "../../paths/paths"; +import { spawn } from "child_process"; +import fs from "node:fs"; +import git from "isomorphic-git"; +import { gitCommit } from "../utils/git_utils"; +import { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils"; + +const logger = log.scope("portal_handlers"); +const handle = createLoggedHandler(logger); + +async function getApp(appId: number) { + const app = await db.query.apps.findFirst({ + where: eq(apps.id, appId), + }); + if (!app) { + throw new Error(`App with id ${appId} not found`); + } + return app; +} + +export function registerPortalHandlers() { + handle( + "portal:migrate-create", + async (_, { appId }: { appId: number }): Promise<{ output: string }> => { + const app = await getApp(appId); + const appPath = getDyadAppPath(app.path); + + // Run the migration command + const migrationOutput = await new Promise((resolve, reject) => { + logger.info(`Running migrate:create for app ${appId} at ${appPath}`); + + const process = spawn("npm run migrate:create -- --skip-empty", { + cwd: appPath, + shell: true, + stdio: "pipe", + }); + + let stdout = ""; + let stderr = ""; + + process.stdout?.on("data", (data) => { + const output = data.toString(); + stdout += output; + logger.info(`migrate:create stdout: ${output}`); + if (output.includes("created or renamed from another")) { + process.stdin.write(`\r\n`); + logger.info( + `App ${appId} (PID: ${process.pid}) wrote enter to stdin to automatically respond to drizzle migrate input`, + ); + } + }); + + process.stderr?.on("data", (data) => { + const output = data.toString(); + stderr += output; + logger.warn(`migrate:create stderr: ${output}`); + }); + + process.on("close", (code) => { + const combinedOutput = + stdout + (stderr ? `\n\nErrors/Warnings:\n${stderr}` : ""); + + if (code === 0) { + if (stdout.includes("Migration created at")) { + logger.info( + `migrate:create completed successfully for app ${appId}`, + ); + resolve(combinedOutput); + } else { + logger.error( + `migrate:create completed successfully for app ${appId} but no migration was created`, + ); + reject( + new Error( + "No migration was created because no changes were found.", + ), + ); + } + } else { + logger.error( + `migrate:create failed for app ${appId} with exit code ${code}`, + ); + const errorMessage = `Migration creation failed (exit code ${code})\n\n${combinedOutput}`; + reject(new Error(errorMessage)); + } + }); + + process.on("error", (err) => { + logger.error(`Failed to spawn migrate:create for app ${appId}:`, err); + const errorMessage = `Failed to run migration command: ${err.message}\n\nOutput:\n${stdout}\n\nErrors:\n${stderr}`; + reject(new Error(errorMessage)); + }); + }); + + if (app.neonProjectId && app.neonDevelopmentBranchId) { + try { + await storeDbTimestampAtCurrentVersion({ + appId: app.id, + }); + } catch (error) { + logger.error( + "Error storing Neon timestamp at current version:", + error, + ); + throw new Error( + "Could not store Neon timestamp at current version; database versioning functionality is not working: " + + error, + ); + } + } + + // Stage all changes and commit + try { + await git.add({ + fs, + dir: appPath, + filepath: ".", + }); + + const commitHash = await gitCommit({ + path: appPath, + message: "[dyad] Generate database migration file", + }); + + logger.info(`Successfully committed migration changes: ${commitHash}`); + return { output: migrationOutput }; + } catch (gitError) { + logger.error(`Migration created but failed to commit: ${gitError}`); + throw new Error(`Migration created but failed to commit: ${gitError}`); + } + }, + ); +} diff --git a/src/ipc/handlers/token_count_handlers.ts b/src/ipc/handlers/token_count_handlers.ts index 2525a50..09268ff 100644 --- a/src/ipc/handlers/token_count_handlers.ts +++ b/src/ipc/handlers/token_count_handlers.ts @@ -65,7 +65,10 @@ export function registerTokenCountHandlers() { supabaseContext = await getSupabaseContext({ supabaseProjectId: chat.app.supabaseProjectId, }); - } else { + } else if ( + // Neon projects don't need Supabase. + !chat.app?.neonProjectId + ) { systemPrompt += "\n\n" + SUPABASE_NOT_AVAILABLE_SYSTEM_PROMPT; } diff --git a/src/ipc/handlers/version_handlers.ts b/src/ipc/handlers/version_handlers.ts index 9c84ea1..679a604 100644 --- a/src/ipc/handlers/version_handlers.ts +++ b/src/ipc/handlers/version_handlers.ts @@ -1,7 +1,12 @@ import { db } from "../../db"; -import { apps, messages } from "../../db/schema"; +import { apps, messages, versions } from "../../db/schema"; import { desc, eq, and, gt } from "drizzle-orm"; -import type { Version, BranchResult } from "../ipc_types"; +import type { + Version, + BranchResult, + RevertVersionParams, + RevertVersionResponse, +} from "../ipc_types"; import fs from "node:fs"; import path from "node:path"; import { getDyadAppPath } from "../../paths/paths"; @@ -11,10 +16,51 @@ import log from "electron-log"; import { createLoggedHandler } from "./safe_handle"; import { gitCheckout, gitCommit, gitStageToRevert } from "../utils/git_utils"; +import { + getNeonClient, + getNeonErrorMessage, +} from "../../neon_admin/neon_management_client"; +import { + updatePostgresUrlEnvVar, + updateDbPushEnvVar, +} from "../utils/app_env_var_utils"; +import { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils"; +import { retryOnLocked } from "../utils/retryOnLocked"; + const logger = log.scope("version_handlers"); const handle = createLoggedHandler(logger); +async function restoreBranchForPreview({ + appId, + dbTimestamp, + neonProjectId, + previewBranchId, + developmentBranchId, +}: { + appId: number; + dbTimestamp: string; + neonProjectId: string; + previewBranchId: string; + developmentBranchId: string; +}): Promise { + try { + const neonClient = await getNeonClient(); + await retryOnLocked( + () => + neonClient.restoreProjectBranch(neonProjectId, previewBranchId, { + source_branch_id: developmentBranchId, + source_timestamp: dbTimestamp, + }), + `Restore preview branch ${previewBranchId} for app ${appId}`, + ); + } catch (error) { + const errorMessage = getNeonErrorMessage(error); + logger.error("Error in restoreBranchForPreview:", errorMessage); + throw new Error(errorMessage); + } +} + export function registerVersionHandlers() { handle("list-versions", async (_, { appId }: { appId: number }) => { const app = await db.query.apps.findFirst({ @@ -40,11 +86,32 @@ export function registerVersionHandlers() { depth: 100_000, // Limit to last 100_000 commits for performance }); - return commits.map((commit: ReadCommitResult) => ({ - oid: commit.oid, - message: commit.commit.message, - timestamp: commit.commit.author.timestamp, - })) satisfies Version[]; + // Get all snapshots for this app to match with commits + const appSnapshots = await db.query.versions.findMany({ + where: eq(versions.appId, appId), + }); + + // Create a map of commitHash -> snapshot info for quick lookup + const snapshotMap = new Map< + string, + { neonDbTimestamp: string | null; createdAt: Date } + >(); + for (const snapshot of appSnapshots) { + snapshotMap.set(snapshot.commitHash, { + neonDbTimestamp: snapshot.neonDbTimestamp, + createdAt: snapshot.createdAt, + }); + } + + return commits.map((commit: ReadCommitResult) => { + const snapshotInfo = snapshotMap.get(commit.oid); + return { + oid: commit.oid, + message: commit.commit.message, + timestamp: commit.commit.author.timestamp, + dbTimestamp: snapshotInfo?.neonDbTimestamp, + }; + }) satisfies Version[]; }); handle( @@ -86,12 +153,11 @@ export function registerVersionHandlers() { "revert-version", async ( _, - { - appId, - previousVersionId, - }: { appId: number; previousVersionId: string }, - ): Promise => { + { appId, previousVersionId }: RevertVersionParams, + ): Promise => { return withLock(appId, async () => { + let successMessage = "Restored version"; + let warningMessage: string | undefined = undefined; const app = await db.query.apps.findFirst({ where: eq(apps.id, appId), }); @@ -101,12 +167,26 @@ export function registerVersionHandlers() { } const appPath = getDyadAppPath(app.path); + // Get the current commit hash before reverting + const currentCommitHash = await git.resolveRef({ + fs, + dir: appPath, + ref: "main", + }); await gitCheckout({ path: appPath, ref: "main", }); + if (app.neonProjectId && app.neonDevelopmentBranchId) { + // We are going to add a new commit on top, so let's store + // the current timestamp at the current version. + await storeDbTimestampAtCurrentVersion({ + appId, + }); + } + await gitStageToRevert({ path: appPath, targetOid: previousVersionId, @@ -154,6 +234,87 @@ export function registerVersionHandlers() { ); } } + + if (app.neonProjectId && app.neonDevelopmentBranchId) { + const version = await db.query.versions.findFirst({ + where: and( + eq(versions.appId, appId), + eq(versions.commitHash, previousVersionId), + ), + }); + if (version && version.neonDbTimestamp) { + try { + const preserveBranchName = `preserve_${currentCommitHash}-${Date.now()}`; + const neonClient = await getNeonClient(); + const response = await retryOnLocked( + () => + neonClient.restoreProjectBranch( + app.neonProjectId!, + app.neonDevelopmentBranchId!, + { + source_branch_id: app.neonDevelopmentBranchId!, + source_timestamp: version.neonDbTimestamp!, + preserve_under_name: preserveBranchName, + }, + ), + `Restore development branch ${app.neonDevelopmentBranchId} for app ${appId}`, + ); + // Update all versions which have a newer DB timestamp than the version we're restoring to + // and remove their DB timestamp. + await db + .update(versions) + .set({ neonDbTimestamp: null }) + .where( + and( + eq(versions.appId, appId), + gt(versions.neonDbTimestamp, version.neonDbTimestamp), + ), + ); + + const preserveBranchId = response.data.branch.parent_id; + if (!preserveBranchId) { + throw new Error("Preserve branch ID not found"); + } + logger.info( + `Deleting preserve branch ${preserveBranchId} for app ${appId}`, + ); + try { + // Intentionally do not await this because it's not + // critical for the restore operation, it's to clean up branches + // so the user doesn't hit the branch limit later. + retryOnLocked( + () => + neonClient.deleteProjectBranch( + app.neonProjectId!, + preserveBranchId, + ), + `Delete preserve branch ${preserveBranchId} for app ${appId}`, + { retryBranchWithChildError: true }, + ); + } catch (error) { + const errorMessage = getNeonErrorMessage(error); + logger.error("Error in deleteProjectBranch:", errorMessage); + } + } catch (error) { + const errorMessage = getNeonErrorMessage(error); + logger.error("Error in restoreBranchForCheckout:", errorMessage); + warningMessage = `Could not restore database because of error: ${errorMessage}`; + // Do not throw, so we can finish switching the postgres branch + // It might throw because they picked a timestamp that's too old. + } + successMessage = + "Successfully restored to version (including database)"; + } + await switchPostgresToDevelopmentBranch({ + neonProjectId: app.neonProjectId, + neonDevelopmentBranchId: app.neonDevelopmentBranchId, + appPath: app.path, + }); + } + if (warningMessage) { + return { warningMessage }; + } + return { successMessage }; }); }, ); @@ -162,7 +323,7 @@ export function registerVersionHandlers() { "checkout-version", async ( _, - { appId, versionId }: { appId: number; versionId: string }, + { appId, versionId: gitRef }: { appId: number; versionId: string }, ): Promise => { return withLock(appId, async () => { const app = await db.query.apps.findFirst({ @@ -173,13 +334,106 @@ export function registerVersionHandlers() { throw new Error("App not found"); } - const appPath = getDyadAppPath(app.path); + if ( + app.neonProjectId && + app.neonDevelopmentBranchId && + app.neonPreviewBranchId + ) { + if (gitRef === "main") { + logger.info( + `Switching Postgres to development branch for app ${appId}`, + ); + await switchPostgresToDevelopmentBranch({ + neonProjectId: app.neonProjectId, + neonDevelopmentBranchId: app.neonDevelopmentBranchId, + appPath: app.path, + }); + } else { + logger.info( + `Switching Postgres to preview branch for app ${appId}`, + ); + // Regardless of whether we have a timestamp or not, we want to disable DB push + // while we're checking out an earlier version + await updateDbPushEnvVar({ + appPath: app.path, + disabled: true, + }); + + const version = await db.query.versions.findFirst({ + where: and( + eq(versions.appId, appId), + eq(versions.commitHash, gitRef), + ), + }); + + if (version && version.neonDbTimestamp) { + // SWITCH the env var for POSTGRES_URL to the preview branch + const neonClient = await getNeonClient(); + const connectionUri = await neonClient.getConnectionUri({ + projectId: app.neonProjectId, + branch_id: app.neonPreviewBranchId, + // This is the default database name for Neon + database_name: "neondb", + // This is the default role name for Neon + role_name: "neondb_owner", + }); + + await restoreBranchForPreview({ + appId, + dbTimestamp: version.neonDbTimestamp, + neonProjectId: app.neonProjectId, + previewBranchId: app.neonPreviewBranchId, + developmentBranchId: app.neonDevelopmentBranchId, + }); + + await updatePostgresUrlEnvVar({ + appPath: app.path, + connectionUri: connectionUri.data.uri, + }); + logger.info( + `Switched Postgres to preview branch for app ${appId} commit ${version.commitHash} dbTimestamp=${version.neonDbTimestamp}`, + ); + } + } + } + const fullAppPath = getDyadAppPath(app.path); await gitCheckout({ - path: appPath, - ref: versionId, + path: fullAppPath, + ref: gitRef, }); }); }, ); } + +async function switchPostgresToDevelopmentBranch({ + neonProjectId, + neonDevelopmentBranchId, + appPath, +}: { + neonProjectId: string; + neonDevelopmentBranchId: string; + appPath: string; +}) { + // SWITCH the env var for POSTGRES_URL to the development branch + const neonClient = await getNeonClient(); + const connectionUri = await neonClient.getConnectionUri({ + projectId: neonProjectId, + branch_id: neonDevelopmentBranchId, + // This is the default database name for Neon + database_name: "neondb", + // This is the default role name for Neon + role_name: "neondb_owner", + }); + + await updatePostgresUrlEnvVar({ + appPath, + connectionUri: connectionUri.data.uri, + }); + + await updateDbPushEnvVar({ + appPath, + disabled: false, + }); +} diff --git a/src/ipc/ipc_client.ts b/src/ipc/ipc_client.ts index 087ba4a..a5f1c63 100644 --- a/src/ipc/ipc_client.ts +++ b/src/ipc/ipc_client.ts @@ -51,6 +51,13 @@ import type { VercelProject, UpdateChatParams, FileAttachment, + CreateNeonProjectParams, + NeonProject, + GetNeonProjectParams, + GetNeonProjectResponse, + RevertVersionResponse, + RevertVersionParams, + RespondToAppInputParams, } from "./ipc_types"; import type { Template } from "../shared/templates"; import type { AppChatContext, ProposalResult } from "@/lib/schemas"; @@ -82,7 +89,6 @@ export interface GitHubDeviceFlowErrorData { export interface DeepLinkData { type: string; - url?: string; } interface DeleteCustomModelParams { @@ -412,6 +418,18 @@ export class IpcClient { } } + // Respond to an app input request (y/n prompts) + public async respondToAppInput( + params: RespondToAppInputParams, + ): Promise { + try { + await this.ipcRenderer.invoke("respond-to-app-input", params); + } catch (error) { + showError(error); + throw error; + } + } + // Get allow-listed environment variables public async getEnvVars(): Promise> { try { @@ -437,17 +455,10 @@ export class IpcClient { } // Revert to a specific version - public async revertVersion({ - appId, - previousVersionId, - }: { - appId: number; - previousVersionId: string; - }): Promise { - await this.ipcRenderer.invoke("revert-version", { - appId, - previousVersionId, - }); + public async revertVersion( + params: RevertVersionParams, + ): Promise { + return this.ipcRenderer.invoke("revert-version", params); } // Checkout a specific version without creating a revert commit @@ -794,6 +805,34 @@ export class IpcClient { // --- End Supabase Management --- + // --- Neon Management --- + public async fakeHandleNeonConnect(): Promise { + await this.ipcRenderer.invoke("neon:fake-connect"); + } + + public async createNeonProject( + params: CreateNeonProjectParams, + ): Promise { + return this.ipcRenderer.invoke("neon:create-project", params); + } + + public async getNeonProject( + params: GetNeonProjectParams, + ): Promise { + return this.ipcRenderer.invoke("neon:get-project", params); + } + + // --- End Neon Management --- + + // --- Portal Management --- + public async portalMigrateCreate(params: { + appId: number; + }): Promise<{ output: string }> { + return this.ipcRenderer.invoke("portal:migrate-create", params); + } + + // --- End Portal Management --- + public async getSystemDebugInfo(): Promise { return this.ipcRenderer.invoke("get-system-debug-info"); } diff --git a/src/ipc/ipc_host.ts b/src/ipc/ipc_host.ts index de6efd6..d6827f3 100644 --- a/src/ipc/ipc_host.ts +++ b/src/ipc/ipc_host.ts @@ -10,6 +10,7 @@ import { registerNodeHandlers } from "./handlers/node_handlers"; import { registerProposalHandlers } from "./handlers/proposal_handlers"; import { registerDebugHandlers } from "./handlers/debug_handlers"; import { registerSupabaseHandlers } from "./handlers/supabase_handlers"; +import { registerNeonHandlers } from "./handlers/neon_handlers"; import { registerLocalModelHandlers } from "./handlers/local_model_handlers"; import { registerTokenCountHandlers } from "./handlers/token_count_handlers"; import { registerWindowHandlers } from "./handlers/window_handlers"; @@ -26,6 +27,7 @@ import { registerCapacitorHandlers } from "./handlers/capacitor_handlers"; import { registerProblemsHandlers } from "./handlers/problems_handlers"; import { registerAppEnvVarsHandlers } from "./handlers/app_env_vars_handlers"; import { registerTemplateHandlers } from "./handlers/template_handlers"; +import { registerPortalHandlers } from "./handlers/portal_handlers"; export function registerIpcHandlers() { // Register all IPC handlers by category @@ -42,6 +44,7 @@ export function registerIpcHandlers() { registerProposalHandlers(); registerDebugHandlers(); registerSupabaseHandlers(); + registerNeonHandlers(); registerLocalModelHandlers(); registerTokenCountHandlers(); registerWindowHandlers(); @@ -57,4 +60,5 @@ export function registerIpcHandlers() { registerCapacitorHandlers(); registerAppEnvVarsHandlers(); registerTemplateHandlers(); + registerPortalHandlers(); } diff --git a/src/ipc/ipc_types.ts b/src/ipc/ipc_types.ts index 409bce5..2d0a380 100644 --- a/src/ipc/ipc_types.ts +++ b/src/ipc/ipc_types.ts @@ -3,12 +3,17 @@ import type { ProblemReport, Problem } from "../../shared/tsc_types"; export type { ProblemReport, Problem }; export interface AppOutput { - type: "stdout" | "stderr" | "info" | "client-error"; + type: "stdout" | "stderr" | "info" | "client-error" | "input-requested"; message: string; timestamp: number; appId: number; } +export interface RespondToAppInputParams { + appId: number; + response: string; +} + export interface ListAppsResponse { apps: App[]; appBasePath: string; @@ -61,6 +66,7 @@ export interface Message { content: string; approvalState?: "approved" | "rejected" | null; commitHash?: string | null; + dbTimestamp?: string | null; } export interface Chat { @@ -68,6 +74,7 @@ export interface Chat { title: string; messages: Message[]; initialCommitHash?: string | null; + dbTimestamp?: string | null; } export interface App { @@ -82,6 +89,9 @@ export interface App { githubBranch: string | null; supabaseProjectId: string | null; supabaseProjectName: string | null; + neonProjectId: string | null; + neonDevelopmentBranchId: string | null; + neonPreviewBranchId: string | null; vercelProjectId: string | null; vercelProjectName: string | null; vercelTeamSlug: string | null; @@ -92,6 +102,7 @@ export interface Version { oid: string; message: string; timestamp: number; + dbTimestamp?: string | null; } export type BranchResult = { branch: string }; @@ -339,3 +350,45 @@ export interface FileAttachment { file: File; type: "upload-to-codebase" | "chat-context"; } + +// --- Neon Types --- +export interface CreateNeonProjectParams { + name: string; + appId: number; +} + +export interface NeonProject { + id: string; + name: string; + connectionString: string; + branchId: string; +} + +export interface NeonBranch { + type: "production" | "development" | "snapshot" | "preview"; + branchId: string; + branchName: string; + lastUpdated: string; // ISO timestamp + parentBranchId?: string; // ID of the parent branch + parentBranchName?: string; // Name of the parent branch +} + +export interface GetNeonProjectParams { + appId: number; +} + +export interface GetNeonProjectResponse { + projectId: string; + projectName: string; + orgId: string; + branches: NeonBranch[]; +} + +export interface RevertVersionParams { + appId: number; + previousVersionId: string; +} + +export type RevertVersionResponse = + | { successMessage: string } + | { warningMessage: string }; diff --git a/src/ipc/processors/response_processor.ts b/src/ipc/processors/response_processor.ts index 9edf10e..c758c0d 100644 --- a/src/ipc/processors/response_processor.ts +++ b/src/ipc/processors/response_processor.ts @@ -26,6 +26,8 @@ import { getDyadAddDependencyTags, getDyadExecuteSqlTags, } from "../utils/dyad_tag_parser"; +import { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils"; + import { FileUploadsState } from "../utils/file_uploads_state"; const readFile = fs.promises.readFile; @@ -80,6 +82,23 @@ export async function processFullResponseActions( return {}; } + if ( + chatWithApp.app.neonProjectId && + chatWithApp.app.neonDevelopmentBranchId + ) { + try { + await storeDbTimestampAtCurrentVersion({ + appId: chatWithApp.app.id, + }); + } catch (error) { + logger.error("Error creating Neon branch at current version:", error); + throw new Error( + "Could not create Neon branch; database versioning functionality is not working: " + + error, + ); + } + } + const settings: UserSettings = readSettings(); const appPath = getDyadAppPath(chatWithApp.app.path); const writtenFiles: string[] = []; diff --git a/src/ipc/utils/app_env_var_utils.ts b/src/ipc/utils/app_env_var_utils.ts index 6aa552e..619697e 100644 --- a/src/ipc/utils/app_env_var_utils.ts +++ b/src/ipc/utils/app_env_var_utils.ts @@ -3,7 +3,109 @@ * Environment variables are sensitive and should not be logged. */ +import { getDyadAppPath } from "@/paths/paths"; import { EnvVar } from "../ipc_types"; +import path from "path"; +import fs from "fs"; +import log from "electron-log"; + +const logger = log.scope("app_env_var_utils"); + +export const ENV_FILE_NAME = ".env.local"; + +function getEnvFilePath({ appPath }: { appPath: string }): string { + return path.join(getDyadAppPath(appPath), ENV_FILE_NAME); +} + +export async function updatePostgresUrlEnvVar({ + appPath, + connectionUri, +}: { + appPath: string; + connectionUri: string; +}) { + // Given the connection uri, update the env var for POSTGRES_URL + const envVars = parseEnvFile(await readEnvFile({ appPath })); + + // Find existing POSTGRES_URL or add it if it doesn't exist + const existingVar = envVars.find((envVar) => envVar.key === "POSTGRES_URL"); + if (existingVar) { + existingVar.value = connectionUri; + } else { + envVars.push({ + key: "POSTGRES_URL", + value: connectionUri, + }); + } + + const envFileContents = serializeEnvFile(envVars); + await fs.promises.writeFile(getEnvFilePath({ appPath }), envFileContents); +} + +export async function updateDbPushEnvVar({ + appPath, + disabled, +}: { + appPath: string; + disabled: boolean; +}) { + try { + // Try to read existing env file + let envVars: EnvVar[]; + try { + const content = await readEnvFile({ appPath }); + envVars = parseEnvFile(content); + } catch { + // If file doesn't exist, start with empty array + envVars = []; + } + + // Update or add DYAD_DISABLE_DB_PUSH + const existingVar = envVars.find( + (envVar) => envVar.key === "DYAD_DISABLE_DB_PUSH", + ); + if (existingVar) { + existingVar.value = disabled ? "true" : "false"; + } else { + envVars.push({ + key: "DYAD_DISABLE_DB_PUSH", + value: disabled ? "true" : "false", + }); + } + + const envFileContents = serializeEnvFile(envVars); + await fs.promises.writeFile(getEnvFilePath({ appPath }), envFileContents); + } catch (error) { + logger.error( + `Failed to update DB push environment variable for app ${appPath}: ${error}`, + ); + throw error; + } +} + +export async function readPostgresUrlFromEnvFile({ + appPath, +}: { + appPath: string; +}): Promise { + const contents = await readEnvFile({ appPath }); + const envVars = parseEnvFile(contents); + const postgresUrl = envVars.find( + (envVar) => envVar.key === "POSTGRES_URL", + )?.value; + if (!postgresUrl) { + throw new Error("POSTGRES_URL not found in .env.local"); + } + return postgresUrl; +} + +export async function readEnvFile({ + appPath, +}: { + appPath: string; +}): Promise { + return fs.promises.readFile(getEnvFilePath({ appPath }), "utf8"); +} // Helper function to parse .env.local file content export function parseEnvFile(content: string): EnvVar[] { diff --git a/src/ipc/utils/neon_timestamp_utils.ts b/src/ipc/utils/neon_timestamp_utils.ts new file mode 100644 index 0000000..b080062 --- /dev/null +++ b/src/ipc/utils/neon_timestamp_utils.ts @@ -0,0 +1,134 @@ +import { db } from "../../db"; +import { versions, apps } from "../../db/schema"; +import { eq, and } from "drizzle-orm"; +import fs from "node:fs"; +import git from "isomorphic-git"; +import { getDyadAppPath } from "../../paths/paths"; +import { neon } from "@neondatabase/serverless"; + +import log from "electron-log"; +import { getNeonClient } from "@/neon_admin/neon_management_client"; + +const logger = log.scope("neon_timestamp_utils"); + +/** + * Retrieves the current timestamp from a Neon database + */ +async function getLastUpdatedTimestampFromNeon({ + neonConnectionUri, +}: { + neonConnectionUri: string; +}): Promise { + try { + const sql = neon(neonConnectionUri); + + const [{ current_timestamp }] = await sql` + SELECT TO_CHAR(NOW() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z') AS current_timestamp + `; + + return current_timestamp; + } catch (error) { + logger.error("Error retrieving timestamp from Neon:", error); + throw new Error(`Failed to retrieve timestamp from Neon: ${error}`); + } +} + +/** + * Stores a Neon database timestamp for the current git commit hash + * and stores it in the versions table + * @param appId - The app ID + * @param neonConnectionUri - The Neon connection URI to get the timestamp from + */ +export async function storeDbTimestampAtCurrentVersion({ + appId, +}: { + appId: number; +}): Promise<{ timestamp: string }> { + try { + logger.info(`Storing DB timestamp for current version - app ${appId}`); + + // 1. Get the app to find the path + const app = await db.query.apps.findFirst({ + where: eq(apps.id, appId), + }); + + if (!app) { + throw new Error(`App with ID ${appId} not found`); + } + + if (!app.neonProjectId || !app.neonDevelopmentBranchId) { + throw new Error(`App with ID ${appId} has no Neon project or branch`); + } + + // 2. Get the current commit hash + const appPath = getDyadAppPath(app.path); + const currentCommitHash = await git.resolveRef({ + fs, + dir: appPath, + ref: "HEAD", + }); + + logger.info(`Current commit hash: ${currentCommitHash}`); + + const neonClient = await getNeonClient(); + const connectionUri = await neonClient.getConnectionUri({ + projectId: app.neonProjectId, + branch_id: app.neonDevelopmentBranchId, + database_name: "neondb", + role_name: "neondb_owner", + }); + + // 3. Get the current timestamp from Neon + const currentTimestamp = await getLastUpdatedTimestampFromNeon({ + neonConnectionUri: connectionUri.data.uri, + }); + + logger.info(`Current timestamp from Neon: ${currentTimestamp}`); + + // 4. Check if a version with this commit hash already exists + const existingVersion = await db.query.versions.findFirst({ + where: and( + eq(versions.appId, appId), + eq(versions.commitHash, currentCommitHash), + ), + }); + + if (existingVersion) { + // Update existing version with the new timestamp + await db + .update(versions) + .set({ + neonDbTimestamp: currentTimestamp, + updatedAt: new Date(), + }) + .where( + and( + eq(versions.appId, appId), + eq(versions.commitHash, currentCommitHash), + ), + ); + logger.info( + `Updated existing version record with timestamp ${currentTimestamp}`, + ); + } else { + // Create new version record + await db.insert(versions).values({ + appId, + commitHash: currentCommitHash, + neonDbTimestamp: currentTimestamp, + }); + logger.info( + `Created new version record for commit ${currentCommitHash} with timestamp ${currentTimestamp}`, + ); + } + + logger.info( + `Successfully stored timestamp for commit ${currentCommitHash} in app ${appId}`, + ); + + return { timestamp: currentTimestamp }; + } catch (error) { + logger.error("Error in storeDbTimestampAtCurrentVersion:", error); + throw error; + } +} diff --git a/src/ipc/utils/retryOnLocked.ts b/src/ipc/utils/retryOnLocked.ts new file mode 100644 index 0000000..7354bab --- /dev/null +++ b/src/ipc/utils/retryOnLocked.ts @@ -0,0 +1,71 @@ +import log from "electron-log"; + +export const logger = log.scope("retryOnLocked"); + +export function isLockedError(error: any): boolean { + return error.response?.status === 423; +} + +// Retry configuration +const RETRY_CONFIG = { + maxRetries: 6, + baseDelay: 1000, // 1 second + maxDelay: 90_000, // 90 seconds + jitterFactor: 0.1, // 10% jitter +}; + +/** + * Retries an async operation with exponential backoff on locked errors (423) + */ + +export async function retryOnLocked( + operation: () => Promise, + context: string, + { + retryBranchWithChildError = false, + }: { retryBranchWithChildError?: boolean } = {}, +): Promise { + let lastError: any; + + for (let attempt = 0; attempt <= RETRY_CONFIG.maxRetries; attempt++) { + try { + const result = await operation(); + logger.info(`${context}: Success after ${attempt + 1} attempts`); + return result; + } catch (error: any) { + lastError = error; + + // Only retry on locked errors + if (!isLockedError(error)) { + if (retryBranchWithChildError && error.response?.status === 422) { + logger.info( + `${context}: Branch with child error (attempt ${attempt + 1}/${RETRY_CONFIG.maxRetries + 1})`, + ); + } else { + throw error; + } + } + + // Don't retry if we've exhausted all attempts + if (attempt === RETRY_CONFIG.maxRetries) { + logger.error( + `${context}: Failed after ${RETRY_CONFIG.maxRetries + 1} attempts due to locked error`, + ); + throw error; + } + + // Calculate delay with exponential backoff and jitter + const baseDelay = RETRY_CONFIG.baseDelay * Math.pow(2, attempt); + const jitter = baseDelay * RETRY_CONFIG.jitterFactor * Math.random(); + const delay = Math.min(baseDelay + jitter, RETRY_CONFIG.maxDelay); + + logger.warn( + `${context}: Locked error (attempt ${attempt + 1}/${RETRY_CONFIG.maxRetries + 1}), retrying in ${Math.round(delay)}ms`, + ); + + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + throw lastError; +} diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index 544e197..9f909db 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -90,6 +90,14 @@ export const SupabaseSchema = z.object({ }); export type Supabase = z.infer; +export const NeonSchema = z.object({ + accessToken: SecretSchema.optional(), + refreshToken: SecretSchema.optional(), + expiresIn: z.number().optional(), + tokenTimestamp: z.number().optional(), +}); +export type Neon = z.infer; + export const ExperimentsSchema = z.object({ // Deprecated enableSupabaseIntegration: z.boolean().describe("DEPRECATED").optional(), @@ -138,6 +146,7 @@ export const UserSettingsSchema = z.object({ githubAccessToken: SecretSchema.optional(), vercelAccessToken: SecretSchema.optional(), supabase: SupabaseSchema.optional(), + neon: NeonSchema.optional(), autoApproveChanges: z.boolean().optional(), telemetryConsent: z.enum(["opted_in", "opted_out", "unset"]).optional(), telemetryUserId: z.string().optional(), diff --git a/src/lib/toast.tsx b/src/lib/toast.tsx index 907e384..d71a30f 100644 --- a/src/lib/toast.tsx +++ b/src/lib/toast.tsx @@ -2,6 +2,7 @@ import { toast } from "sonner"; import { PostHog } from "posthog-js"; import React from "react"; import { CustomErrorToast } from "../components/CustomErrorToast"; +import { InputRequestToast } from "../components/InputRequestToast"; /** * Toast utility functions for consistent notifications across the app @@ -87,6 +88,29 @@ export const showInfo = (message: string) => { toast.info(message); }; +/** + * Show an input request toast for interactive prompts (y/n) + * @param message The prompt message to display + * @param onResponse Callback function called when user responds + */ +export const showInputRequest = ( + message: string, + onResponse: (response: "y" | "n") => void, +) => { + const toastId = toast.custom( + (t) => ( + + ), + { duration: Infinity }, // Don't auto-close + ); + + return toastId; +}; + export const showExtraFilesToast = ({ files, error, diff --git a/src/main.ts b/src/main.ts index 14d321c..3f00dd1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -17,6 +17,7 @@ import { IS_TEST_BUILD } from "./ipc/utils/test_utils"; import { BackupManager } from "./backup_manager"; import { getDatabasePath, initializeDatabase } from "./db"; import { UserSettings } from "./lib/schemas"; +import { handleNeonOAuthReturn } from "./neon_admin/neon_return_handler"; log.errorHandler.startCatching(); log.eventLogger.startLogging(); @@ -208,6 +209,24 @@ function handleDeepLinkReturn(url: string) { ); return; } + if (parsed.hostname === "neon-oauth-return") { + const token = parsed.searchParams.get("token"); + const refreshToken = parsed.searchParams.get("refreshToken"); + const expiresIn = Number(parsed.searchParams.get("expiresIn")); + if (!token || !refreshToken || !expiresIn) { + dialog.showErrorBox( + "Invalid URL", + "Expected token, refreshToken, and expiresIn", + ); + return; + } + handleNeonOAuthReturn({ token, refreshToken, expiresIn }); + // Send message to renderer to trigger re-render + mainWindow?.webContents.send("deep-link-received", { + type: parsed.hostname, + }); + return; + } if (parsed.hostname === "supabase-oauth-return") { const token = parsed.searchParams.get("token"); const refreshToken = parsed.searchParams.get("refreshToken"); @@ -223,7 +242,6 @@ function handleDeepLinkReturn(url: string) { // Send message to renderer to trigger re-render mainWindow?.webContents.send("deep-link-received", { type: parsed.hostname, - url, }); return; } @@ -240,7 +258,6 @@ function handleDeepLinkReturn(url: string) { // Send message to renderer to trigger re-render mainWindow?.webContents.send("deep-link-received", { type: parsed.hostname, - url, }); return; } diff --git a/src/main/settings.ts b/src/main/settings.ts index 311e372..81c320d 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -69,6 +69,27 @@ export function readSettings(): UserSettings { } } } + const neon = combinedSettings.neon; + if (neon) { + if (neon.refreshToken) { + const encryptionType = neon.refreshToken.encryptionType; + if (encryptionType) { + neon.refreshToken = { + value: decrypt(neon.refreshToken), + encryptionType, + }; + } + } + if (neon.accessToken) { + const encryptionType = neon.accessToken.encryptionType; + if (encryptionType) { + neon.accessToken = { + value: decrypt(neon.accessToken), + encryptionType, + }; + } + } + } if (combinedSettings.githubAccessToken) { const encryptionType = combinedSettings.githubAccessToken.encryptionType; combinedSettings.githubAccessToken = { @@ -131,6 +152,18 @@ export function writeSettings(settings: Partial): void { ); } } + if (newSettings.neon) { + if (newSettings.neon.accessToken) { + newSettings.neon.accessToken = encrypt( + newSettings.neon.accessToken.value, + ); + } + if (newSettings.neon.refreshToken) { + newSettings.neon.refreshToken = encrypt( + newSettings.neon.refreshToken.value, + ); + } + } for (const provider in newSettings.providerSettings) { if (newSettings.providerSettings[provider].apiKey) { newSettings.providerSettings[provider].apiKey = encrypt( diff --git a/src/neon_admin/neon_management_client.ts b/src/neon_admin/neon_management_client.ts new file mode 100644 index 0000000..b741f1b --- /dev/null +++ b/src/neon_admin/neon_management_client.ts @@ -0,0 +1,240 @@ +import { withLock } from "../ipc/utils/lock_utils"; +import { readSettings, writeSettings } from "../main/settings"; +import { Api, createApiClient } from "@neondatabase/api-client"; +import log from "electron-log"; +import { IS_TEST_BUILD } from "../ipc/utils/test_utils"; + +const logger = log.scope("neon_management_client"); + +/** + * Checks if the Neon access token is expired or about to expire + * Returns true if token needs to be refreshed + */ +function isTokenExpired(expiresIn?: number): boolean { + if (!expiresIn) return true; + + // Get when the token was saved (expiresIn is stored at the time of token receipt) + const settings = readSettings(); + const tokenTimestamp = settings.neon?.tokenTimestamp || 0; + const currentTime = Math.floor(Date.now() / 1000); + + // Check if the token is expired or about to expire (within 5 minutes) + return currentTime >= tokenTimestamp + expiresIn - 300; +} + +/** + * Refreshes the Neon access token using the refresh token + * Updates settings with new tokens and expiration time + */ +export async function refreshNeonToken(): Promise { + const settings = readSettings(); + const refreshToken = settings.neon?.refreshToken?.value; + + if (!isTokenExpired(settings.neon?.expiresIn)) { + return; + } + + if (!refreshToken) { + throw new Error("Neon refresh token not found. Please authenticate first."); + } + + try { + // Make request to Neon refresh endpoint + const response = await fetch( + "https://oauth.dyad.sh/api/integrations/neon/refresh", + + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ refreshToken }), + }, + ); + + if (!response.ok) { + throw new Error(`Token refresh failed: ${response.statusText}`); + } + + const { + accessToken, + refreshToken: newRefreshToken, + expiresIn, + } = await response.json(); + + // Update settings with new tokens + writeSettings({ + neon: { + accessToken: { + value: accessToken, + }, + refreshToken: { + value: newRefreshToken, + }, + expiresIn, + tokenTimestamp: Math.floor(Date.now() / 1000), // Store current timestamp + }, + }); + } catch (error) { + logger.error("Error refreshing Neon token:", error); + throw error; + } +} + +// Function to get the Neon API client +export async function getNeonClient(): Promise> { + if (IS_TEST_BUILD) { + // Return a mock client for testing + return { + createProject: async (params: any) => ({ + data: { + project: { + id: "test-project-id", + name: params.project.name, + region_id: "aws-us-east-1", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + default_branch_id: "test-branch-id", + }, + connection_uris: [ + { + connection_uri: "postgresql://test:test@test.neon.tech/test", + }, + ], + }, + }), + createProjectBranch: async (projectId: string, params: any) => ({ + data: { + branch: { + id: "test-dev-branch-id", + name: params.branch?.name || "development", + project_id: projectId, + parent_id: "test-branch-id", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + connection_uris: [ + { + connection_uri: "postgresql://test:test@test-dev.neon.tech/test", + }, + ], + }, + }), + getProject: async (projectId: string) => ({ + data: { + project: { + id: projectId, + name: "Test Project", + org_id: "test-org-id", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + default_branch_id: "test-branch-id", + }, + }, + }), + listProjectBranches: async (projectId: string) => ({ + data: { + branches: [ + { + id: "test-branch-id", + name: "main", + project_id: projectId, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + default: true, + }, + { + id: "test-dev-branch-id", + name: "development", + project_id: projectId, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + default: false, + }, + ], + }, + }), + listOrganizations: async () => ({ + data: { + organizations: [ + { + id: "test-org-id", + name: "Test Organization", + }, + ], + }, + }), + deleteProjectBranch: async (projectId: string, branchId: string) => ({ + data: { + branch: { + id: branchId, + project_id: projectId, + }, + }, + }), + } as unknown as Api; + } + + const settings = readSettings(); + + // Check if Neon token exists in settings + const neonAccessToken = settings.neon?.accessToken?.value; + const expiresIn = settings.neon?.expiresIn; + + if (!neonAccessToken) { + throw new Error("Neon access token not found. Please authenticate first."); + } + + // Check if token needs refreshing + if (isTokenExpired(expiresIn)) { + await withLock("refresh-neon-token", refreshNeonToken); + // Get updated settings after refresh + const updatedSettings = readSettings(); + const newAccessToken = updatedSettings.neon?.accessToken?.value; + + if (!newAccessToken) { + throw new Error("Failed to refresh Neon access token"); + } + + return createApiClient({ + apiKey: newAccessToken, + }); + } + + return createApiClient({ + apiKey: neonAccessToken, + }); +} + +/** + * Get the user's first organization ID from Neon + */ +export async function getNeonOrganizationId(): Promise { + const neonClient = await getNeonClient(); + + if (IS_TEST_BUILD) { + return "test-org-id"; + } + + try { + const response = await neonClient.getCurrentUserOrganizations(); + + if ( + !response.data?.organizations || + response.data.organizations.length === 0 + ) { + throw new Error("No organizations found for this Neon account"); + } + + // Return the first organization ID + return response.data.organizations[0].id; + } catch (error) { + logger.error("Error fetching Neon organizations:", error); + throw new Error("Failed to fetch Neon organizations"); + } +} + +export function getNeonErrorMessage(error: any): string { + const detailedMessage = error.response?.data?.message ?? ""; + return error.message + " " + detailedMessage; +} diff --git a/src/neon_admin/neon_return_handler.ts b/src/neon_admin/neon_return_handler.ts new file mode 100644 index 0000000..5d09015 --- /dev/null +++ b/src/neon_admin/neon_return_handler.ts @@ -0,0 +1,24 @@ +import { writeSettings } from "../main/settings"; + +export function handleNeonOAuthReturn({ + token, + refreshToken, + expiresIn, +}: { + token: string; + refreshToken: string; + expiresIn: number; +}) { + writeSettings({ + neon: { + accessToken: { + value: token, + }, + refreshToken: { + value: refreshToken, + }, + expiresIn, + tokenTimestamp: Math.floor(Date.now() / 1000), + }, + }); +} diff --git a/src/pages/home.tsx b/src/pages/home.tsx index db864b0..35bba86 100644 --- a/src/pages/home.tsx +++ b/src/pages/home.tsx @@ -30,6 +30,8 @@ import { invalidateAppQuery } from "@/hooks/useLoadApp"; import { useQueryClient } from "@tanstack/react-query"; import type { FileAttachment } from "@/ipc/ipc_types"; +import { NEON_TEMPLATE_IDS } from "@/shared/templates"; +import { neonTemplateHook } from "@/client_logic/template_hook"; // Adding an export for attachments export interface HomeSubmitOptions { @@ -120,6 +122,15 @@ export default function HomePage() { const result = await IpcClient.getInstance().createApp({ name: generateCuteAppName(), }); + if ( + settings?.selectedTemplateId && + NEON_TEMPLATE_IDS.has(settings.selectedTemplateId) + ) { + await neonTemplateHook({ + appId: result.app.id, + appName: result.app.name, + }); + } // Stream the message with attachments streamMessage({ diff --git a/src/pages/hub.tsx b/src/pages/hub.tsx index de21554..b77c4b0 100644 --- a/src/pages/hub.tsx +++ b/src/pages/hub.tsx @@ -1,22 +1,27 @@ -import React from "react"; +import React, { useState } from "react"; import { Button } from "@/components/ui/button"; import { ArrowLeft } from "lucide-react"; import { useRouter } from "@tanstack/react-router"; import { useSettings } from "@/hooks/useSettings"; import { useTemplates } from "@/hooks/useTemplates"; import { TemplateCard } from "@/components/TemplateCard"; +import { CreateAppDialog } from "@/components/CreateAppDialog"; +import { NeonConnector } from "@/components/NeonConnector"; const HubPage: React.FC = () => { - const { settings, updateSettings } = useSettings(); const router = useRouter(); + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); const { templates, isLoading } = useTemplates(); - + const { settings, updateSettings } = useSettings(); const selectedTemplateId = settings?.selectedTemplateId; const handleTemplateSelect = (templateId: string) => { updateSettings({ selectedTemplateId: templateId }); }; + const handleCreateApp = () => { + setIsCreateDialogOpen(true); + }; // Separate templates into official and community const officialTemplates = templates?.filter((template) => template.isOfficial) || []; @@ -25,7 +30,7 @@ const HubPage: React.FC = () => { return (
-
+
@@ -77,14 +83,42 @@ const HubPage: React.FC = () => { template={template} isSelected={template.id === selectedTemplateId} onSelect={handleTemplateSelect} + onCreateApp={handleCreateApp} /> ))}
)} + +
+ + t.id === settings?.selectedTemplateId)} + /> ); }; +function BackendSection() { + return ( +
+
+

+ Backend Services +

+

+ Connect to backend services for your projects. +

+
+ +
+ +
+
+ ); +} + export default HubPage; diff --git a/src/pages/settings.tsx b/src/pages/settings.tsx index 1781123..d278abf 100644 --- a/src/pages/settings.tsx +++ b/src/pages/settings.tsx @@ -16,11 +16,13 @@ import { useRouter } from "@tanstack/react-router"; import { GitHubIntegration } from "@/components/GitHubIntegration"; import { VercelIntegration } from "@/components/VercelIntegration"; import { SupabaseIntegration } from "@/components/SupabaseIntegration"; + import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; import { AutoFixProblemsSwitch } from "@/components/AutoFixProblemsSwitch"; import { AutoUpdateSwitch } from "@/components/AutoUpdateSwitch"; import { ReleaseChannelSelector } from "@/components/ReleaseChannelSelector"; +import { NeonIntegration } from "@/components/NeonIntegration"; export default function SettingsPage() { const [isResetDialogOpen, setIsResetDialogOpen] = useState(false); @@ -112,6 +114,7 @@ export default function SettingsPage() { + diff --git a/src/preload.ts b/src/preload.ts index 7719944..8833f70 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -33,6 +33,7 @@ const validInvokeChannels = [ "run-app", "stop-app", "restart-app", + "respond-to-app-input", "list-versions", "revert-version", "checkout-version", @@ -55,6 +56,9 @@ const validInvokeChannels = [ "github:connect-existing-repo", "github:push", "github:disconnect", + "neon:create-project", + "neon:get-project", + "neon:delete-branch", "vercel:save-token", "vercel:list-projects", "vercel:is-project-available", @@ -101,6 +105,7 @@ const validInvokeChannels = [ "check-problems", "restart-dyad", "get-templates", + "portal:migrate-create", // Test-only channels // These should ALWAYS be guarded with IS_TEST_BUILD in the main process. // We can't detect with IS_TEST_BUILD in the preload script because diff --git a/src/shared/templates.ts b/src/shared/templates.ts index 956c9d3..3995831 100644 --- a/src/shared/templates.ts +++ b/src/shared/templates.ts @@ -5,6 +5,8 @@ export interface Template { imageUrl: string; githubUrl?: string; isOfficial: boolean; + isExperimental?: boolean; + requiresNeon?: boolean; } // API Template interface from the external API @@ -26,6 +28,9 @@ export const DEFAULT_TEMPLATE = { isOfficial: true, }; +const PORTAL_MINI_STORE_ID = "portal-mini-store"; +export const NEON_TEMPLATE_IDS = new Set([PORTAL_MINI_STORE_ID]); + export const localTemplatesData: Template[] = [ DEFAULT_TEMPLATE, { @@ -37,4 +42,15 @@ export const localTemplatesData: Template[] = [ githubUrl: "https://github.com/dyad-sh/nextjs-template", isOfficial: true, }, + { + id: PORTAL_MINI_STORE_ID, + title: "Portal: Mini Store Template", + description: "Uses Neon DB, Payload CMS, Next.js", + imageUrl: + "https://github.com/user-attachments/assets/ed86f322-40bf-4fd5-81dc-3b1d8a16e12b", + githubUrl: "https://github.com/dyad-sh/portal-mini-store-template", + isOfficial: true, + isExperimental: true, + requiresNeon: true, + }, ]; diff --git a/testing/fake-llm-server/chatCompletionHandler.ts b/testing/fake-llm-server/chatCompletionHandler.ts index 095ce90..f4bb39d 100644 --- a/testing/fake-llm-server/chatCompletionHandler.ts +++ b/testing/fake-llm-server/chatCompletionHandler.ts @@ -24,7 +24,6 @@ export const createChatCompletionHandler = } let messageContent = CANNED_MESSAGE; - console.error("LASTMESSAGE********", lastMessage.content); if ( lastMessage &&