Unverified 提交 661e1438 authored 作者: Mohamed Aziz Mejri's avatar Mohamed Aziz Mejri 提交者: GitHub

Neon integration (#3178)

### Context for review bots : 1.The agent can execute sql commands directly on production , this is fine because they user may choose to start with a simple setup first and use the production database directly (in a follow up PR we should give the users the ability to sync dev branch to match production branch).The user also has the possiblity to revert database changes . 2.Right now there’s no warning for destructive changes before migration , this is fine for now as we're gonna add a follow up PR for this 3.When linking an existing neon project that doesnt have a developement database , we're setting the default branch as the active branch . This is fine because the user may prefer to start a simple setup at the beginning and work directly on the production database , in addition since the project was created outside dyad it may have a developement branch named differently so it wouldnt be wise to auto create one here . However, in a follow up PR we should allow the user to manually create branches and sync dev branch to match production . 4.neon is only available for next.js now 5.We dont have enough tests for now, i am gonna add a follow-up PR to cover this <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/3178" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end --> --------- Co-authored-by: 's avatarClaude Opus 4.6 (1M context) <noreply@anthropic.com>
上级 35f5ee4d
ALTER TABLE `apps` ADD `neon_active_branch_id` text;
\ No newline at end of file
差异被折叠。
......@@ -190,6 +190,13 @@
"when": 1771025337064,
"tag": "0026_lying_joseph",
"breakpoints": true
},
{
"idx": 27,
"version": "6",
"when": 1774487675535,
"tag": "0027_unusual_scalphunter",
"breakpoints": true
}
]
}
\ No newline at end of file
Adding neon...
<dyad-add-integration></dyad-add-integration>
Adding supabase...
<dyad-add-integration provider="supabase"></dyad-add-integration>
<dyad-add-integration></dyad-add-integration>
......@@ -106,6 +106,54 @@ export class AppManagement {
await this.page.getByTestId("connect-supabase-button").click();
}
async startDatabaseIntegrationSetup(provider: "supabase" | "neon") {
const providerLabel = provider === "supabase" ? "Supabase" : "Neon";
await this.page.getByText(providerLabel, { exact: true }).click();
const setupButton = this.page.getByRole("button", {
name: `Set up ${providerLabel}`,
});
await expect(setupButton).toBeEnabled({
timeout: Timeout.MEDIUM,
});
await setupButton.click();
}
async clickConnectNeonButton() {
await this.page.getByTestId("connect-neon-button").click();
}
async selectNeonProject(projectName: string) {
const projectSelect = this.page.getByTestId("neon-project-select");
await expect(projectSelect).toBeVisible({ timeout: Timeout.MEDIUM });
await projectSelect.click();
await this.page
.getByRole("option", {
name: new RegExp(
`^${projectName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`,
"i",
),
})
.click();
await expect(this.page.getByTestId("neon-branch-select")).toBeVisible({
timeout: Timeout.MEDIUM,
});
}
async selectNeonBranch(branchName: string) {
const branchSelect = this.page.getByTestId("neon-branch-select");
await expect(branchSelect).toBeVisible({ timeout: Timeout.MEDIUM });
await branchSelect.click();
await this.page
.getByRole("option", {
name: new RegExp(
`^${branchName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`,
"i",
),
})
.click();
}
async importApp(appDir: string) {
await this.page.getByRole("button", { name: "Import App" }).click();
await eph.stubDialog(this.electronApp, "showOpenDialog", {
......
import { expect } from "@playwright/test";
import fs from "fs";
import path from "path";
import { testSkipIfWindows, Timeout } from "./helpers/test_helper";
function readCookieSecret(envContents: string): string | undefined {
return envContents.match(/^NEON_AUTH_COOKIE_SECRET=(.+)$/m)?.[1]?.trim();
}
testSkipIfWindows("neon branch selection updates env vars", async ({ po }) => {
await po.setUp({ autoApprove: true });
await po.navigation.goToHubAndSelectTemplate("Next.js Template");
await po.chatActions.selectChatMode("build");
await po.sendPrompt("tc=basic", { timeout: Timeout.EXTRA_LONG });
await po.sendPrompt("tc=add-neon");
await po.appManagement.startDatabaseIntegrationSetup("neon");
await po.appManagement.clickConnectNeonButton();
await po.appManagement.selectNeonProject("Test Project");
const appPath = await po.appManagement.getCurrentAppPath();
const envFilePath = path.join(appPath, ".env.local");
let envBeforeSwitch = "";
await expect(async () => {
envBeforeSwitch = fs.readFileSync(envFilePath, "utf8");
expect(envBeforeSwitch).toContain(
"DATABASE_URL=postgresql://test:test@test-development.neon.tech/test",
);
expect(envBeforeSwitch).toContain(
"POSTGRES_URL=postgresql://test:test@test-development.neon.tech/test",
);
expect(envBeforeSwitch).toContain(
"NEON_AUTH_BASE_URL=https://test-development.neonauth.us-east-2.aws.neon.tech/neondb/auth",
);
expect(envBeforeSwitch).toMatch(/NEON_AUTH_COOKIE_SECRET=[a-f0-9]{64}/);
}).toPass({ timeout: Timeout.MEDIUM });
const cookieSecretBeforeSwitch = readCookieSecret(envBeforeSwitch);
expect(cookieSecretBeforeSwitch).toBeTruthy();
await po.appManagement.selectNeonBranch("main");
let envAfterSwitch = "";
await expect(async () => {
envAfterSwitch = fs.readFileSync(envFilePath, "utf8");
expect(envAfterSwitch).toContain(
"DATABASE_URL=postgresql://test:test@test-main.neon.tech/test",
);
expect(envAfterSwitch).toContain(
"POSTGRES_URL=postgresql://test:test@test-main.neon.tech/test",
);
expect(envAfterSwitch).toContain(
"NEON_AUTH_BASE_URL=https://test-main.neonauth.us-east-2.aws.neon.tech/neondb/auth",
);
expect(envAfterSwitch).toMatch(/NEON_AUTH_COOKIE_SECRET=[a-f0-9]{64}/);
}).toPass({ timeout: Timeout.MEDIUM });
const cookieSecretAfterSwitch = readCookieSecret(envAfterSwitch);
expect(cookieSecretAfterSwitch).toBeTruthy();
expect(cookieSecretAfterSwitch).not.toBe(cookieSecretBeforeSwitch);
});
import { expect } from "@playwright/test";
import { testSkipIfWindows, Timeout } from "./helpers/test_helper";
testSkipIfWindows("neon migration push from publish panel", async ({ po }) => {
await po.setUp({ autoApprove: true });
await po.navigation.goToHubAndSelectTemplate("Next.js Template");
await po.chatActions.selectChatMode("build");
await po.sendPrompt("tc=basic", { timeout: Timeout.EXTRA_LONG });
await po.sendPrompt("tc=add-neon");
// Connect to Neon with a non-default branch so migration is allowed
await po.appManagement.startDatabaseIntegrationSetup("neon");
await po.appManagement.clickConnectNeonButton();
await po.appManagement.selectNeonProject("Test Project");
// Navigate back to chat, then to the publish panel
await po.navigation.clickBackButton();
await po.previewPanel.selectPreviewMode("publish");
// Verify the MigrationPanel is visible
const migrateButton = po.page.getByRole("button", {
name: "Migrate to Production",
});
await expect(migrateButton).toBeVisible({ timeout: Timeout.MEDIUM });
// Click the migrate button
await migrateButton.click();
await expect(
po.page.getByText(
"This will modify the main schema in Test Project using the schema from development.",
),
).toBeVisible({ timeout: Timeout.MEDIUM });
await po.page
.getByRole("button", { name: "Migrate to Production" })
.last()
.click();
// Verify success message appears
await expect(
po.page.getByText("Migration applied successfully."),
).toBeVisible({ timeout: Timeout.MEDIUM });
});
testSkipIfWindows(
"neon migration stays disabled on the production branch",
async ({ po }) => {
await po.setUp({ autoApprove: true });
await po.navigation.goToHubAndSelectTemplate("Next.js Template");
await po.chatActions.selectChatMode("build");
await po.sendPrompt("tc=basic", { timeout: Timeout.EXTRA_LONG });
await po.sendPrompt("tc=add-neon");
await po.appManagement.startDatabaseIntegrationSetup("neon");
await po.appManagement.clickConnectNeonButton();
await po.appManagement.selectNeonProject("Test Project");
await po.appManagement.selectNeonBranch("main");
await po.navigation.clickBackButton();
await po.previewPanel.selectPreviewMode("publish");
const migrateButton = po.page.getByRole("button", {
name: "Migrate to Production",
});
await expect(migrateButton).toBeDisabled({ timeout: Timeout.MEDIUM });
await expect(
po.page.getByText(
"Switch to a non-production branch in the Neon panel before migrating.",
),
).toBeVisible({ timeout: Timeout.MEDIUM });
},
);
......@@ -317,6 +317,27 @@
"additionalProperties": false
}
}
},
{
"type": "function",
"function": {
"name": "read_guide",
"description": "Read a detailed instruction guide. Use this when the system prompt tells you to load a guide before implementing a feature.",
"parameters": {
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"guide": {
"type": "string",
"description": "Name of the guide to read (e.g. 'add-authentication', 'add-email-verification')"
}
},
"required": [
"guide"
],
"additionalProperties": false
}
}
}
],
"tool_choice": "auto",
......
......@@ -351,22 +351,21 @@
{
"type": "function",
"name": "add_integration",
"description": "Add an integration provider to the app (e.g., Supabase for auth, database, or server-side functions). Once you have called this tool, stop and do not call any more tools because you need to wait for the user to set up the integration.",
"description": "Prompt the user to choose and set up a database provider for the app. Do NOT set the provider parameter unless the user explicitly names a specific provider (e.g. 'Supabase' or 'Neon') in their message. Once you have called this tool, stop and do not call any more tools because you need to wait for the user to set up the integration.",
"parameters": {
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"provider": {
"description": "Optional preferred database provider. Use 'none' (or omit) if the user did not explicitly name a provider. Only use 'supabase' or 'neon' if the user specifically mentions that provider name in their prompt.",
"type": "string",
"enum": [
"supabase"
],
"description": "The integration provider to add (e.g., 'supabase')"
"none",
"supabase",
"neon"
]
}
},
"required": [
"provider"
],
"additionalProperties": false
}
},
......@@ -558,6 +557,25 @@
"additionalProperties": false
}
},
{
"type": "function",
"name": "read_guide",
"description": "Read a detailed instruction guide. Use this when the system prompt tells you to load a guide before implementing a feature.",
"parameters": {
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"guide": {
"type": "string",
"description": "Name of the guide to read (e.g. 'add-authentication', 'add-email-verification')"
}
},
"required": [
"guide"
],
"additionalProperties": false
}
},
{
"type": "function",
"name": "planning_questionnaire",
......
......@@ -358,22 +358,21 @@
"type": "function",
"function": {
"name": "add_integration",
"description": "Add an integration provider to the app (e.g., Supabase for auth, database, or server-side functions). Once you have called this tool, stop and do not call any more tools because you need to wait for the user to set up the integration.",
"description": "Prompt the user to choose and set up a database provider for the app. Do NOT set the provider parameter unless the user explicitly names a specific provider (e.g. 'Supabase' or 'Neon') in their message. Once you have called this tool, stop and do not call any more tools because you need to wait for the user to set up the integration.",
"parameters": {
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"provider": {
"description": "Optional preferred database provider. Use 'none' (or omit) if the user did not explicitly name a provider. Only use 'supabase' or 'neon' if the user specifically mentions that provider name in their prompt.",
"type": "string",
"enum": [
"supabase"
],
"description": "The integration provider to add (e.g., 'supabase')"
"none",
"supabase",
"neon"
]
}
},
"required": [
"provider"
],
"additionalProperties": false
}
}
......@@ -580,6 +579,27 @@
}
}
},
{
"type": "function",
"function": {
"name": "read_guide",
"description": "Read a detailed instruction guide. Use this when the system prompt tells you to load a guide before implementing a feature.",
"parameters": {
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"guide": {
"type": "string",
"description": "Name of the guide to read (e.g. 'add-authentication', 'add-email-verification')"
}
},
"required": [
"guide"
],
"additionalProperties": false
}
}
},
{
"type": "function",
"function": {
......
- paragraph: tc=add-supabase
- paragraph: Adding supabase...
- img
- text: Integration Integrate with supabase?
- button "Set up supabase"
- text: Integration Choose a database provider
- radiogroup "Choose a database provider":
- 'radio "Supabase Visit Supabase website Complete backend as a service: Postgres database, storage and edge functions." [checked]':
- text: ""
- link "Visit Supabase website":
- img
- paragraph: "Complete backend as a service: Postgres database, storage and edge functions."
- button "Set up Supabase"
- button "Copy":
- img
- img
......
......@@ -2,7 +2,7 @@
- paragraph: Adding supabase...
- img
- text: Integration Complete Supabase integration complete
- paragraph: "This app is connected to Supabase project: Fake Supabase Project"
- paragraph: "This app is connected to the Supabase project: Fake Supabase Project"
- button "Continue"
- button "Copy":
- img
......
- paragraph: tc=add-supabase
- paragraph: Adding supabase...
- img
- text: Integration Integrate with supabase?
- button "Set up supabase"
- text: Integration Choose a database provider
- radiogroup "Choose a database provider":
- 'radio "Supabase Visit Supabase website Complete backend as a service: Postgres database, storage and edge functions." [checked]':
- text: ""
- link "Visit Supabase website":
- img
- paragraph: "Complete backend as a service: Postgres database, storage and edge functions."
- button "Set up Supabase"
- button "Copy":
- img
- img
......
......@@ -7,7 +7,7 @@ testSkipIfWindows("supabase branch selection works", async ({ po }) => {
await po.sendPrompt("tc=add-supabase");
// Connect to Supabase
await po.page.getByText("Set up supabase").click();
await po.appManagement.startDatabaseIntegrationSetup("supabase");
await po.appManagement.clickConnectSupabaseButton();
await po.navigation.clickBackButton();
await po.toggleTokenBar();
......
......@@ -6,7 +6,7 @@ testSkipIfWindows("supabase client is generated", async ({ po }) => {
await po.sendPrompt("tc=add-supabase");
// Connect to Supabase
await po.page.getByText("Set up supabase").click();
await po.appManagement.startDatabaseIntegrationSetup("supabase");
await po.appManagement.clickConnectSupabaseButton();
await po.navigation.clickBackButton();
......
......@@ -9,7 +9,7 @@ testSkipIfWindows("supabase migrations", async ({ po }) => {
await po.sendPrompt("tc=add-supabase");
// Connect to Supabase
await po.page.getByText("Set up supabase").click();
await po.appManagement.startDatabaseIntegrationSetup("supabase");
await po.appManagement.clickConnectSupabaseButton();
await po.navigation.clickBackButton();
......@@ -75,7 +75,7 @@ testSkipIfWindows("supabase migrations with native git", async ({ po }) => {
await po.sendPrompt("tc=add-supabase");
// Connect to Supabase
await po.page.getByText("Set up supabase").click();
await po.appManagement.startDatabaseIntegrationSetup("supabase");
await po.appManagement.clickConnectSupabaseButton();
await po.navigation.clickBackButton();
......
......@@ -6,7 +6,7 @@ testSkipIfWindows("supabase - stale ui", async ({ po }) => {
await po.sendPrompt("tc=add-supabase");
await po.snapshotMessages();
await po.page.getByText("Set up supabase").click();
await po.appManagement.startDatabaseIntegrationSetup("supabase");
// On app details page:
await po.appManagement.clickConnectSupabaseButton();
// TODO: for some reason on Windows this navigates to the main (apps) page,
......
......@@ -40,6 +40,9 @@ const ignore = (file: string) => {
if (file.startsWith("/node_modules/html-to-image")) {
return false;
}
if (file.startsWith("/node_modules/drizzle-kit")) {
return false;
}
if (file.startsWith("/node_modules/better-sqlite3")) {
return false;
}
......@@ -121,7 +124,12 @@ const config: ForgeConfig = {
unpackDir: "node_modules/node-pty",
},
ignore,
extraResource: ["node_modules/dugite/git", "node_modules/@vscode"],
extraResource: [
"node_modules/dugite/git",
"node_modules/@vscode",
"node_modules/drizzle-kit",
"node_modules/drizzle-orm",
],
// ignore: [/node_modules\/(?!(better-sqlite3|bindings|file-uri-to-path)\/)/],
},
rebuildConfig: {
......
......@@ -27,7 +27,7 @@
"@lexical/react": "^0.33.1",
"@modelcontextprotocol/sdk": "^1.17.5",
"@monaco-editor/react": "^4.7.0-rc.0",
"@neondatabase/api-client": "^2.1.0",
"@neondatabase/api-client": "^2.7.1",
"@neondatabase/serverless": "^1.0.1",
"@rollup/plugin-commonjs": "^28.0.3",
"@tailwindcss/typography": "^0.5.16",
......@@ -4633,12 +4633,12 @@
}
},
"node_modules/@neondatabase/api-client": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@neondatabase/api-client/-/api-client-2.2.0.tgz",
"integrity": "sha512-KP7NWn2gdrcXmB6xtrU+RZNGbgkUm5AwIv6B78XvdTKX+jFEkYznNfWUUbFCMehH4N0x/plErIeuMh75pk0KiQ==",
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/@neondatabase/api-client/-/api-client-2.7.1.tgz",
"integrity": "sha512-hEYOJ89xIa2eEXBu9HRKYTJc9lrmszhNc0SIxzJvNE/3Av4xK7vkXWQ3LWy0DTTFY4Kn6wfM2wAjRIjf/jOu6w==",
"license": "MIT",
"dependencies": {
"axios": "^1.9.0"
"axios": "^1.13.5"
}
},
"node_modules/@neondatabase/serverless": {
......@@ -9219,14 +9219,23 @@
"license": "MIT"
},
"node_modules/axios": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz",
"integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==",
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz",
"integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^2.1.0"
}
},
"node_modules/axios/node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/b4a": {
......@@ -13510,9 +13519,9 @@
}
},
"node_modules/form-data": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
......
......@@ -67,7 +67,7 @@
"@lexical/react": "^0.33.1",
"@modelcontextprotocol/sdk": "^1.17.5",
"@monaco-editor/react": "^4.7.0-rc.0",
"@neondatabase/api-client": "^2.1.0",
"@neondatabase/api-client": "^2.7.1",
"@neondatabase/serverless": "^1.0.1",
"@rollup/plugin-commonjs": "^28.0.3",
"@tailwindcss/typography": "^0.5.16",
......
import { parseEnvFile, serializeEnvFile } from "@/ipc/utils/app_env_var_utils";
import { describe, it, expect } from "vitest";
import fs from "fs";
import {
parseEnvFile,
removeNeonEnvVars,
serializeEnvFile,
updateNeonEnvVars,
} from "@/ipc/utils/app_env_var_utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("fs", () => ({
default: {
promises: {
readFile: vi.fn(),
writeFile: vi.fn(),
},
},
}));
vi.mock("@/paths/paths", () => ({
getDyadAppPath: vi.fn((appPath: string) => `/mock/apps/${appPath}`),
}));
function createEnoentError() {
return Object.assign(new Error("File not found"), {
code: "ENOENT",
});
}
function getWrittenEnvVars() {
const writeCall = vi.mocked(fs.promises.writeFile).mock.calls.at(-1);
if (!writeCall) {
throw new Error("No env file was written");
}
return parseEnvFile(String(writeCall[1]));
}
describe("parseEnvFile", () => {
it("should parse basic key=value pairs", () => {
......@@ -532,3 +565,179 @@ describe("parseEnvFile and serializeEnvFile integration", () => {
expect(parsed).toEqual(originalEnvVars);
});
});
describe("Neon env var helpers", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("updateNeonEnvVars", () => {
it("writes initial Neon env vars when the env file does not exist", async () => {
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
createEnoentError(),
);
await updateNeonEnvVars({
appPath: "my-app",
connectionUri: "postgresql://test:test@test-development.neon.tech/test",
neonAuthBaseUrl:
"https://test-development.neonauth.us-east-2.aws.neon.tech/neondb/auth",
});
expect(fs.promises.writeFile).toHaveBeenCalledWith(
"/mock/apps/my-app/.env.local",
expect.any(String),
);
const envVars = getWrittenEnvVars();
expect(envVars).toEqual(
expect.arrayContaining([
{
key: "DATABASE_URL",
value: "postgresql://test:test@test-development.neon.tech/test",
},
{
key: "POSTGRES_URL",
value: "postgresql://test:test@test-development.neon.tech/test",
},
{
key: "NEON_AUTH_BASE_URL",
value:
"https://test-development.neonauth.us-east-2.aws.neon.tech/neondb/auth",
},
]),
);
expect(
envVars.find((envVar) => envVar.key === "NEON_AUTH_COOKIE_SECRET")
?.value,
).toMatch(/^[a-f0-9]{64}$/);
});
it("updates Neon env vars in place and preserves unrelated env vars", async () => {
const existingSecret = "a".repeat(64);
vi.mocked(fs.promises.readFile).mockResolvedValueOnce(`KEEP_ME=value
DATABASE_URL=postgresql://old.neon.tech/test
POSTGRES_URL=postgresql://old.neon.tech/test
NEON_AUTH_BASE_URL=https://old.neonauth.us-east-2.aws.neon.tech/neondb/auth
NEON_AUTH_COOKIE_SECRET=${existingSecret}`);
await updateNeonEnvVars({
appPath: "my-app",
connectionUri: "postgresql://test:test@test-development.neon.tech/test",
neonAuthBaseUrl:
"https://old.neonauth.us-east-2.aws.neon.tech/neondb/auth",
});
const envVars = getWrittenEnvVars();
expect(envVars).toEqual(
expect.arrayContaining([
{ key: "KEEP_ME", value: "value" },
{
key: "DATABASE_URL",
value: "postgresql://test:test@test-development.neon.tech/test",
},
{
key: "POSTGRES_URL",
value: "postgresql://test:test@test-development.neon.tech/test",
},
{
key: "NEON_AUTH_COOKIE_SECRET",
value: existingSecret,
},
]),
);
});
it("rotates the cookie secret when the Neon auth base URL changes", async () => {
const existingSecret = "b".repeat(64);
vi.mocked(fs.promises.readFile)
.mockResolvedValueOnce(`DATABASE_URL=postgresql://test:test@test-development.neon.tech/test
POSTGRES_URL=postgresql://test:test@test-development.neon.tech/test
NEON_AUTH_BASE_URL=https://test-development.neonauth.us-east-2.aws.neon.tech/neondb/auth
NEON_AUTH_COOKIE_SECRET=${existingSecret}`);
await updateNeonEnvVars({
appPath: "my-app",
connectionUri: "postgresql://test:test@test-preview.neon.tech/test",
neonAuthBaseUrl:
"https://test-preview.neonauth.us-east-2.aws.neon.tech/neondb/auth",
});
const envVars = getWrittenEnvVars();
expect(
envVars.find((envVar) => envVar.key === "NEON_AUTH_BASE_URL")?.value,
).toBe(
"https://test-preview.neonauth.us-east-2.aws.neon.tech/neondb/auth",
);
expect(
envVars.find((envVar) => envVar.key === "NEON_AUTH_COOKIE_SECRET")
?.value,
).toMatch(/^[a-f0-9]{64}$/);
expect(
envVars.find((envVar) => envVar.key === "NEON_AUTH_COOKIE_SECRET")
?.value,
).not.toBe(existingSecret);
});
it("preserves existing Neon auth vars when auth activation fails transiently", async () => {
const existingSecret = "c".repeat(64);
vi.mocked(fs.promises.readFile).mockResolvedValueOnce(`KEEP_ME=value
DATABASE_URL=postgresql://old.neon.tech/test
POSTGRES_URL=postgresql://old.neon.tech/test
NEON_AUTH_BASE_URL=https://old.neonauth.us-east-2.aws.neon.tech/neondb/auth
NEON_AUTH_COOKIE_SECRET=${existingSecret}`);
await updateNeonEnvVars({
appPath: "my-app",
connectionUri: "postgresql://test:test@test-development.neon.tech/test",
preserveExistingAuth: true,
});
const envVars = getWrittenEnvVars();
expect(envVars).toEqual(
expect.arrayContaining([
{ key: "KEEP_ME", value: "value" },
{
key: "DATABASE_URL",
value: "postgresql://test:test@test-development.neon.tech/test",
},
{
key: "POSTGRES_URL",
value: "postgresql://test:test@test-development.neon.tech/test",
},
{
key: "NEON_AUTH_BASE_URL",
value: "https://old.neonauth.us-east-2.aws.neon.tech/neondb/auth",
},
{
key: "NEON_AUTH_COOKIE_SECRET",
value: existingSecret,
},
]),
);
});
});
describe("removeNeonEnvVars", () => {
it("removes Neon-owned env vars while preserving unrelated values", async () => {
vi.mocked(fs.promises.readFile).mockResolvedValueOnce(`KEEP_ME=value
DATABASE_URL=postgres://localhost:5432/mydb
POSTGRES_URL=postgresql://test:test@test-preview.neon.tech/test
NEON_AUTH_BASE_URL=https://test-preview.neonauth.us-east-2.aws.neon.tech/neondb/auth
NEON_AUTH_COOKIE_SECRET=${"c".repeat(64)}`);
await removeNeonEnvVars({ appPath: "my-app" });
expect(fs.promises.writeFile).toHaveBeenCalledWith(
"/mock/apps/my-app/.env.local",
expect.any(String),
);
const envVars = getWrittenEnvVars();
expect(envVars).toEqual([
{ key: "KEEP_ME", value: "value" },
{ key: "DATABASE_URL", value: "postgres://localhost:5432/mydb" },
]);
});
});
});
import { useEffect, useId, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { ipc } from "@/ipc/types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
Database,
Loader2,
CheckCircle2,
XCircle,
ChevronDown,
AlertTriangle,
} from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { useTranslation } from "react-i18next";
import { getErrorMessage } from "@/lib/errors";
import { useLoadApp } from "@/hooks/useLoadApp";
import { useNeon } from "@/hooks/useNeon";
interface MigrationPanelProps {
appId: number;
}
export const MigrationPanel = ({ appId }: MigrationPanelProps) => {
const { t } = useTranslation("home");
const { app } = useLoadApp(appId);
const { projectInfo, branches } = useNeon(appId);
const [showErrorDetails, setShowErrorDetails] = useState(false);
const errorDetailsId = useId();
const pushMutation = useMutation({
mutationFn: () => ipc.migration.push({ appId }),
});
const productionBranch = branches.find(
(branch) => branch.type === "production",
);
const sourceBranchName = branches.find(
(branch) => branch.branchId === app?.neonActiveBranchId,
)?.branchName;
const targetBranchName = productionBranch?.branchName;
const projectName = projectInfo?.projectName ?? app?.neonProjectId ?? null;
const effectiveBranchId =
app?.neonActiveBranchId ?? app?.neonDevelopmentBranchId;
const isProductionBranchActive =
!!effectiveBranchId && effectiveBranchId === productionBranch?.branchId;
const hasBranchContext = Boolean(
projectName && sourceBranchName && targetBranchName,
);
const description = hasBranchContext
? t("integrations.migration.descriptionWithBranches", {
projectName,
sourceBranchName,
targetBranchName,
})
: t("integrations.migration.description");
const confirmDescription = hasBranchContext
? t("integrations.migration.confirmDescriptionWithBranches", {
projectName,
sourceBranchName,
targetBranchName,
})
: t("integrations.migration.confirmDescription");
// Auto-dismiss success/info banners after 5 seconds
useEffect(() => {
if (pushMutation.isSuccess && pushMutation.data?.success) {
const timer = setTimeout(() => pushMutation.reset(), 5000);
return () => clearTimeout(timer);
}
}, [pushMutation.isSuccess, pushMutation.data?.success]);
const errorSummary = pushMutation.isError
? getErrorMessage(pushMutation.error)
: t("integrations.migration.errorMessage");
const errorDetails =
pushMutation.error instanceof Error
? (pushMutation.error.stack ?? pushMutation.error.message)
: pushMutation.error
? getErrorMessage(pushMutation.error)
: null;
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2">
<Database className="w-5 h-5 text-primary" />
{t("integrations.migration.title")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
{description}
</p>
<div
role="note"
className="flex items-start gap-2 text-sm text-amber-800 dark:text-amber-200 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3"
>
<AlertTriangle className="w-4 h-4 flex-shrink-0 mt-0.5" />
<span>{t("integrations.migration.backupWarning")}</span>
</div>
<AlertDialog>
<AlertDialogTrigger
disabled={pushMutation.isPending || isProductionBranchActive}
render={
<Button
disabled={pushMutation.isPending || isProductionBranchActive}
/>
}
>
{pushMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{t("integrations.migration.migrating")}
</>
) : (
<>
<Database className="w-4 h-4 mr-2" />
{t("integrations.migration.migrateToProduction")}
</>
)}
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("integrations.migration.migrateToProduction")}
</AlertDialogTitle>
<AlertDialogDescription>
{confirmDescription}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("integrations.migration.cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setShowErrorDetails(false);
pushMutation.mutate();
}}
>
{t("integrations.migration.migrateToProduction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{isProductionBranchActive && (
<p className="text-sm text-amber-700 dark:text-amber-300">
{t("integrations.migration.switchBranchHint")}
</p>
)}
{pushMutation.isSuccess &&
pushMutation.data?.success &&
!pushMutation.data?.noChanges && (
<div
role="status"
aria-live="polite"
className="flex items-center gap-2 text-sm text-green-700 dark:text-green-400 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-3"
>
<CheckCircle2 className="w-4 h-4 flex-shrink-0" />
{t("integrations.migration.success")}
</div>
)}
{pushMutation.isSuccess && pushMutation.data?.noChanges && (
<div
role="status"
aria-live="polite"
className="flex items-center gap-2 text-sm text-blue-700 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3"
>
<CheckCircle2 className="w-4 h-4 flex-shrink-0" />
{t("integrations.migration.alreadyInSync")}
</div>
)}
{pushMutation.isError && (
<div
role="alert"
className="text-sm text-red-700 dark:text-red-400 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3 space-y-2"
>
<div className="flex items-start gap-2">
<XCircle className="w-4 h-4 flex-shrink-0 mt-0.5" />
<span>{errorSummary}</span>
</div>
{errorDetails && errorDetails !== errorSummary && (
<>
<button
onClick={() => setShowErrorDetails(!showErrorDetails)}
aria-expanded={showErrorDetails}
aria-controls={errorDetailsId}
className="flex items-center gap-1 text-xs text-red-600 dark:text-red-300 hover:underline"
>
<ChevronDown
className={`w-3 h-3 transition-transform ${showErrorDetails ? "rotate-180" : ""}`}
/>
{showErrorDetails
? t("integrations.migration.hideDetails")
: t("integrations.migration.showDetails")}
</button>
{showErrorDetails && (
<pre
id={errorDetailsId}
className="max-h-64 overflow-auto whitespace-pre-wrap rounded bg-red-100 p-2 font-mono text-xs dark:bg-red-900/40"
>
{errorDetails}
</pre>
)}
</>
)}
</div>
)}
</CardContent>
</Card>
);
};
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { CustomTagState } from "./stateTypes";
import { Database } from "lucide-react";
import {
DyadCard,
DyadCardHeader,
DyadBadge,
DyadExpandIcon,
DyadStateIndicator,
DyadCardContent,
} from "./DyadCardPrimitives";
interface DyadDbProjectInfoProps {
provider: string;
node: {
properties: {
state?: CustomTagState;
};
};
children: React.ReactNode;
}
export function DyadDbProjectInfo({
provider,
node,
children,
}: DyadDbProjectInfoProps) {
const { t } = useTranslation("home");
const [isContentVisible, setIsContentVisible] = useState(false);
const { state } = node.properties;
const isLoading = state === "pending";
const isAborted = state === "aborted";
const content = typeof children === "string" ? children : "";
return (
<DyadCard
state={state}
accentColor="teal"
isExpanded={isContentVisible}
onClick={() => setIsContentVisible(!isContentVisible)}
>
<DyadCardHeader icon={<Database size={15} />} accentColor="teal">
<DyadBadge color="teal">
{t("integrations.db.projectInfo", { provider })}
</DyadBadge>
{isLoading && (
<DyadStateIndicator
state="pending"
pendingLabel={t("integrations.db.fetching")}
/>
)}
{isAborted && (
<DyadStateIndicator
state="aborted"
abortedLabel={t("integrations.db.didNotFinish")}
/>
)}
<div className="ml-auto">
<DyadExpandIcon isExpanded={isContentVisible} />
</div>
</DyadCardHeader>
<DyadCardContent isExpanded={isContentVisible}>
{content && (
<div className="p-3 text-xs font-mono whitespace-pre-wrap max-h-80 overflow-y-auto bg-muted/20 rounded-lg">
{content}
</div>
)}
</DyadCardContent>
</DyadCard>
);
}
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { CustomTagState } from "./stateTypes";
import { Table2 } from "lucide-react";
import {
......@@ -10,7 +11,8 @@ import {
DyadCardContent,
} from "./DyadCardPrimitives";
interface DyadSupabaseTableSchemaProps {
interface DyadDbTableSchemaProps {
provider: string;
node: {
properties: {
table?: string;
......@@ -20,10 +22,12 @@ interface DyadSupabaseTableSchemaProps {
children: React.ReactNode;
}
export function DyadSupabaseTableSchema({
export function DyadDbTableSchema({
provider,
node,
children,
}: DyadSupabaseTableSchemaProps) {
}: DyadDbTableSchemaProps) {
const { t } = useTranslation("home");
const [isContentVisible, setIsContentVisible] = useState(false);
const { table, state } = node.properties;
const isLoading = state === "pending";
......@@ -39,7 +43,9 @@ export function DyadSupabaseTableSchema({
>
<DyadCardHeader icon={<Table2 size={15} />} accentColor="teal">
<DyadBadge color="teal">
{table ? "Table Schema" : "Supabase Table Schema"}
{table
? t("integrations.db.tableSchema")
: t("integrations.db.tableSchemaProvider", { provider })}
</DyadBadge>
{table && (
<span className="font-medium text-sm text-foreground truncate">
......@@ -47,10 +53,16 @@ export function DyadSupabaseTableSchema({
</span>
)}
{isLoading && (
<DyadStateIndicator state="pending" pendingLabel="Fetching..." />
<DyadStateIndicator
state="pending"
pendingLabel={t("integrations.db.fetching")}
/>
)}
{isAborted && (
<DyadStateIndicator state="aborted" abortedLabel="Did not finish" />
<DyadStateIndicator
state="aborted"
abortedLabel={t("integrations.db.didNotFinish")}
/>
)}
<div className="ml-auto">
<DyadExpandIcon isExpanded={isContentVisible} />
......
......@@ -34,14 +34,16 @@ import { DyadCodeSearch } from "./DyadCodeSearch";
import { DyadRead } from "./DyadRead";
import { DyadListFiles } from "./DyadListFiles";
import { DyadDatabaseSchema } from "./DyadDatabaseSchema";
import { DyadSupabaseTableSchema } from "./DyadSupabaseTableSchema";
import { DyadDbTableSchema } from "./DyadDbTableSchema";
import { DyadSupabaseProjectInfo } from "./DyadSupabaseProjectInfo";
import { DyadNeonProjectInfo } from "./DyadNeonProjectInfo";
import { DyadStatus } from "./DyadStatus";
import { DyadCompaction } from "./DyadCompaction";
import { DyadWritePlan } from "./DyadWritePlan";
import { DyadExitPlan } from "./DyadExitPlan";
import { DyadQuestionnaire } from "./DyadQuestionnaire";
import { DyadStepLimit } from "./DyadStepLimit";
import { DyadReadGuide } from "./DyadReadGuide";
import { mapActionToButton } from "./ChatInput";
import { SuggestedAction } from "@/lib/schemas";
import { FixAllErrorsButton } from "./FixAllErrorsButton";
......@@ -75,8 +77,12 @@ const DYAD_CUSTOM_TAGS = [
"dyad-mcp-tool-result",
"dyad-list-files",
"dyad-database-schema",
"dyad-db-table-schema",
"dyad-supabase-table-schema",
"dyad-supabase-project-info",
"dyad-neon-project-info",
"dyad-neon-table-schema",
"dyad-read-guide",
"dyad-status",
"dyad-compaction",
"dyad-copy",
......@@ -277,8 +283,12 @@ function preprocessUnclosedTags(content: string): {
function parseCustomTags(content: string): ContentPiece[] {
const { processedContent, inProgressTags } = preprocessUnclosedTags(content);
// Sort tags longest-first so e.g. "dyad-read-guide" is tried before "dyad-read".
// The (?=[\s>]) lookahead ensures a tag name like "dyad-read" won't prefix-match
// "dyad-read-guide" (the char after must be whitespace or '>').
const sortedTags = [...DYAD_CUSTOM_TAGS].sort((a, b) => b.length - a.length);
const tagPattern = new RegExp(
`<(${DYAD_CUSTOM_TAGS.join("|")})\\s*([^>]*)>(.*?)<\\/\\1>`,
`<(${sortedTags.join("|")})(?=[\\s>])\\s*([^>]*)>(.*?)<\\/\\1>`,
"gs",
);
......@@ -581,11 +591,11 @@ function renderCustomTag(
case "dyad-add-integration":
return (
<DyadAddIntegration
node={{
properties: {
provider: attributes.provider || "",
},
}}
provider={
attributes.provider === "neon" || attributes.provider === "supabase"
? attributes.provider
: undefined
}
>
{content}
</DyadAddIntegration>
......@@ -722,9 +732,19 @@ function renderCustomTag(
</DyadDatabaseSchema>
);
case "dyad-db-table-schema":
// Backward compat: old messages used provider-specific tags
case "dyad-supabase-table-schema":
case "dyad-neon-table-schema":
return (
<DyadSupabaseTableSchema
<DyadDbTableSchema
provider={
tag === "dyad-supabase-table-schema"
? "Supabase"
: tag === "dyad-neon-table-schema"
? "Neon"
: (attributes.provider as string) || ""
}
node={{
properties: {
table: attributes.table || "",
......@@ -733,7 +753,7 @@ function renderCustomTag(
}}
>
{content}
</DyadSupabaseTableSchema>
</DyadDbTableSchema>
);
case "dyad-supabase-project-info":
......@@ -749,6 +769,33 @@ function renderCustomTag(
</DyadSupabaseProjectInfo>
);
case "dyad-neon-project-info":
return (
<DyadNeonProjectInfo
node={{
properties: {
state: getState({ isStreaming, inProgress }),
},
}}
>
{content}
</DyadNeonProjectInfo>
);
case "dyad-read-guide":
return (
<DyadReadGuide
node={{
properties: {
name: attributes.name || "",
state: getState({ isStreaming, inProgress }),
},
}}
>
{content}
</DyadReadGuide>
);
case "dyad-image-generation":
return (
<DyadImageGeneration
......
import React from "react";
import { CustomTagState } from "./stateTypes";
import { DyadDbProjectInfo } from "./DyadDbProjectInfo";
interface DyadNeonProjectInfoProps {
node: {
properties: {
state?: CustomTagState;
};
};
children: React.ReactNode;
}
export function DyadNeonProjectInfo(props: DyadNeonProjectInfoProps) {
return <DyadDbProjectInfo provider="Neon" {...props} />;
}
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { CustomTagState } from "./stateTypes";
import { BookOpen } from "lucide-react";
import {
DyadCard,
DyadCardHeader,
DyadBadge,
DyadExpandIcon,
DyadStateIndicator,
DyadCardContent,
} from "./DyadCardPrimitives";
interface DyadReadGuideProps {
node: {
properties: {
name?: string;
state?: CustomTagState;
};
};
children: React.ReactNode;
}
export function DyadReadGuide({ node, children }: DyadReadGuideProps) {
const [isExpanded, setIsExpanded] = useState(false);
const { t } = useTranslation("chat");
const { name, state } = node.properties;
const isLoading = state === "pending";
const isAborted = state === "aborted";
return (
<DyadCard
state={state}
accentColor="indigo"
isExpanded={isExpanded}
onClick={() => setIsExpanded(!isExpanded)}
>
<DyadCardHeader icon={<BookOpen size={15} />} accentColor="indigo">
<DyadBadge color="indigo">{t("guide")}</DyadBadge>
{name && (
<span className="text-sm text-foreground truncate">{name}</span>
)}
{isLoading && <DyadStateIndicator state="pending" />}
{isAborted && <DyadStateIndicator state="aborted" />}
<div className="ml-auto">
<DyadExpandIcon isExpanded={isExpanded} />
</div>
</DyadCardHeader>
<DyadCardContent isExpanded={isExpanded}>
{children && (
<div className="p-3 text-xs font-mono whitespace-pre-wrap max-h-80 overflow-y-auto bg-muted/20 rounded-lg">
{children}
</div>
)}
</DyadCardContent>
</DyadCard>
);
}
import React, { useState } from "react";
import React from "react";
import { CustomTagState } from "./stateTypes";
import { Database } from "lucide-react";
import {
DyadCard,
DyadCardHeader,
DyadBadge,
DyadExpandIcon,
DyadStateIndicator,
DyadCardContent,
} from "./DyadCardPrimitives";
import { DyadDbProjectInfo } from "./DyadDbProjectInfo";
interface DyadSupabaseProjectInfoProps {
node: {
......@@ -19,42 +11,6 @@ interface DyadSupabaseProjectInfoProps {
children: React.ReactNode;
}
export function DyadSupabaseProjectInfo({
node,
children,
}: DyadSupabaseProjectInfoProps) {
const [isContentVisible, setIsContentVisible] = useState(false);
const { state } = node.properties;
const isLoading = state === "pending";
const isAborted = state === "aborted";
const content = typeof children === "string" ? children : "";
return (
<DyadCard
state={state}
accentColor="teal"
isExpanded={isContentVisible}
onClick={() => setIsContentVisible(!isContentVisible)}
>
<DyadCardHeader icon={<Database size={15} />} accentColor="teal">
<DyadBadge color="teal">Supabase Project Info</DyadBadge>
{isLoading && (
<DyadStateIndicator state="pending" pendingLabel="Fetching..." />
)}
{isAborted && (
<DyadStateIndicator state="aborted" abortedLabel="Did not finish" />
)}
<div className="ml-auto">
<DyadExpandIcon isExpanded={isContentVisible} />
</div>
</DyadCardHeader>
<DyadCardContent isExpanded={isContentVisible}>
{content && (
<div className="p-3 text-xs font-mono whitespace-pre-wrap max-h-80 overflow-y-auto bg-muted/20 rounded-lg">
{content}
</div>
)}
</DyadCardContent>
</DyadCard>
);
export function DyadSupabaseProjectInfo(props: DyadSupabaseProjectInfoProps) {
return <DyadDbProjectInfo provider="Supabase" {...props} />;
}
export type CompletedIntegrationProvider = "supabase" | "neon" | null;
export function getCompletedIntegrationProvider(
app:
| {
supabaseProjectName?: string | null;
neonProjectId?: string | null;
}
| null
| undefined,
): CompletedIntegrationProvider {
if (app?.supabaseProjectName) {
return "supabase";
}
if (app?.neonProjectId) {
return "neon";
}
return null;
}
......@@ -4,6 +4,7 @@ import { useLoadApp } from "@/hooks/useLoadApp";
import { GitHubConnector } from "@/components/GitHubConnector";
import { VercelConnector } from "@/components/VercelConnector";
import { PortalMigrate } from "@/components/PortalMigrate";
import { MigrationPanel } from "@/components/MigrationPanel";
import { ipc } from "@/ipc/types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { GithubCollaboratorManager } from "@/components/GithubCollaboratorManager";
......@@ -80,8 +81,15 @@ export const PublishPanel = () => {
</h1>
</div>
{/* Portal Section - Show only if app has neon project */}
{app.neonProjectId && <PortalMigrate appId={selectedAppId} />}
{/* Database Migration - Show MigrationPanel if app has neon project and active branch,
otherwise fall back to PortalMigrate for portal template apps. Only one is shown. */}
{app.neonProjectId &&
(app.neonActiveBranchId || app.neonDevelopmentBranchId) ? (
<MigrationPanel appId={selectedAppId} />
) : app.neonProjectId &&
app.files.some((f) => f === "payload.config.ts") ? (
<PortalMigrate appId={selectedAppId} />
) : null}
{/* GitHub Section */}
<Card>
......
......@@ -53,6 +53,7 @@ export const apps = sqliteTable("apps", {
neonProjectId: text("neon_project_id"),
neonDevelopmentBranchId: text("neon_development_branch_id"),
neonPreviewBranchId: text("neon_preview_branch_id"),
neonActiveBranchId: text("neon_active_branch_id"),
vercelProjectId: text("vercel_project_id"),
vercelProjectName: text("vercel_project_name"),
vercelTeamId: text("vercel_team_id"),
......
......@@ -137,8 +137,7 @@ export const useCopyToClipboard = () => {
}
case "dyad-add-integration": {
const provider = attributes.provider || "";
return `### Add Integration: ${provider}\n\n`;
return `### Add Database Integration\n\n`;
}
case "dyad-codebase-context": {
......
import {
ipc,
type NeonProjectListItem,
type NeonBranch,
type NeonAuthEmailAndPasswordConfig,
} from "@/ipc/types";
import { useSettings } from "@/hooks/useSettings";
import { useLoadApp } from "@/hooks/useLoadApp";
import { useQuery } from "@tanstack/react-query";
import { queryKeys } from "@/lib/queryKeys";
export function useNeon(appId: number | null) {
const { settings } = useSettings();
const { app } = useLoadApp(appId);
const isConnected = !!settings?.neon?.accessToken;
// Fetch projects list
const {
data: projectsData,
isLoading: isLoadingProjects,
isFetching: isFetchingProjects,
error: projectsError,
refetch: refetchProjects,
} = useQuery({
queryKey: queryKeys.neon.projects,
queryFn: () => ipc.neon.listProjects(),
enabled: isConnected,
});
const projects: NeonProjectListItem[] = projectsData?.projects ?? [];
// Fetch branches for the connected project
const {
data: projectInfo,
isLoading: isLoadingBranches,
error: branchesError,
} = useQuery({
queryKey: queryKeys.neon.project({ appId }),
queryFn: () => ipc.neon.getProject({ appId: appId! }),
enabled: !!appId && !!app?.neonProjectId,
});
const branches: NeonBranch[] = projectInfo?.branches ?? [];
// Fetch email and password config for the active branch
const { data: emailPasswordConfig, isLoading: isLoadingEmailConfig } =
useQuery({
queryKey: queryKeys.neon.emailPasswordConfig({
appId,
branchId:
app?.neonActiveBranchId ?? app?.neonDevelopmentBranchId ?? null,
}),
queryFn: () => ipc.neon.getEmailPasswordConfig({ appId: appId! }),
enabled:
!!appId &&
!!app?.neonProjectId &&
!!(app?.neonActiveBranchId ?? app?.neonDevelopmentBranchId),
});
return {
isConnected,
projects,
projectInfo,
branches,
emailPasswordConfig: emailPasswordConfig as
| NeonAuthEmailAndPasswordConfig
| undefined,
isLoadingEmailConfig,
isLoadingProjects,
isFetchingProjects,
projectsError,
isLoadingBranches,
branchesError,
refetchProjects,
};
}
......@@ -228,6 +228,7 @@
"requestsConsent": "requests your consent.",
"inputRequired": "Input Required"
},
"guide": "Guide",
"agentModeActivated": "Agent Mode Activated",
"agentModeTip": "Tip: Create a new chat to give the agent a clean context for better results.",
"neverShowAgain": "Never show again"
......
......@@ -557,7 +557,101 @@
"connectedSuccess": "Successfully connected to Neon!",
"disconnect": "Disconnect from Neon",
"disconnected": "Disconnected from Neon successfully",
"failedDisconnect": "Failed to disconnect from Neon"
"failedDisconnect": "Failed to disconnect from Neon",
"project": "Neon Project",
"projects": "Neon Projects",
"selectProjectDescription": "Select an existing Neon project or create a new one",
"selectAProject": "Select a project...",
"noProjectsFound": "No projects found. Create a new project to get started.",
"refreshProjects": "Refresh projects",
"retry": "Retry",
"errorLoadingProjects": "Failed to load projects: {{message}}",
"projectConnected": "Neon project connected successfully",
"failedConnectProject": "Failed to connect project: {{error}}",
"connectedToProject": "Connected to project",
"disconnectProject": "Disconnect project",
"failedDisconnectProject": "Failed to disconnect project",
"createNewProject": "Create New Project",
"projectName": "Project name",
"creating": "Creating...",
"create": "Create",
"cancel": "Cancel",
"activeBranch": "Active Branch",
"selectBranch": "Select a branch...",
"branchSwitched": "Switched to branch",
"envUpdated": "DATABASE_URL updated in .env.local.",
"failedSetBranch": "Failed to switch branch: {{error}}",
"production": "Production",
"development": "Development",
"snapshot": "Snapshot",
"preview": "Preview",
"requireEmailVerification": "Require email verification",
"emailVerificationEnabled": "Email verification enabled",
"emailVerificationDisabled": "Email verification disabled",
"failedUpdateEmailVerification": "Failed to update email verification: {{error}}",
"nextjsOnly": "Neon integration is currently available for Next.js projects.",
"disconnectConfirmation": "Are you sure you want to disconnect this Neon project? This may break your app's database connection.",
"errorLoadingBranches": "Failed to load branches: {{message}}",
"noBranchesFound": "No branches were found for this Neon project yet.",
"emailVerificationHelp": "When enabled, users must verify their email address before they can sign in to your app.",
"openInConsole": "Open in Neon Console",
"completingSignIn": "Complete sign-in in your browser…",
"projectDisconnected": "Project disconnected successfully",
"signInTimedOut": "Sign-in timed out — try again or check your browser",
"connecting": "Connecting...",
"disconnectAccountTitle": "Disconnect Neon Account",
"disconnectAccountConfirmation": "Are you sure you want to disconnect your Neon account? You will need to re-authenticate via OAuth to reconnect."
},
"db": {
"projectInfo": "{{provider}} Project Info",
"tableSchema": "Table Schema",
"tableSchemaProvider": "{{provider}} Table Schema",
"fetching": "Fetching...",
"didNotFinish": "Did not finish"
},
"databaseSetup": {
"badge": "Integration",
"integrationComplete": "Integration Complete",
"completeDescription": "{{provider}} integration complete",
"connectedToProject": "This app is connected to the {{provider}} project:",
"continue": "Continue",
"continuing": "Continuing...",
"chooseProvider": "Choose a database provider",
"setUpProvider": "Set up {{provider}}",
"setUpDatabase": "Set up database",
"experimental": "Experimental",
"providers": {
"supabase": {
"name": "Supabase",
"description": "Complete backend as a service: Postgres database, storage and edge functions."
},
"neon": {
"name": "Neon",
"description": "Serverless Postgres database with time-travel and generous free project limits."
}
}
},
"migration": {
"title": "Database Migration",
"description": "Copy the schema from your active development branch to your production branch.",
"descriptionWithBranches": "Copy the schema in {{projectName}} from {{sourceBranchName}} to {{targetBranchName}}.",
"migrateToProduction": "Migrate to Production",
"migrating": "Migrating...",
"success": "Migration applied successfully.",
"errorMessage": "Migration couldn't be applied.",
"showDetails": "Show details",
"hideDetails": "Hide details",
"alreadyInSync": "Your development and production databases are already in sync.",
"switchBranchHint": "Switch to a non-production branch in the Neon panel before migrating.",
"confirmDescription": "This will modify your production database schema. Are you sure you want to continue?",
"confirmDescriptionWithBranches": "This will modify the {{targetBranchName}} schema in {{projectName}} using the schema from {{sourceBranchName}}. Are you sure you want to continue?",
"cancel": "Cancel",
"backupWarning": "Make sure you have database backups enabled otherwise you cannot undo this."
},
"mutualExclusion": {
"supabaseUnavailable": "Supabase is unavailable because a Neon database is already connected. Only one database integration can be active at a time.",
"neonUnavailable": "Neon is unavailable because a Supabase project is already connected. Only one database integration can be active at a time.",
"chooseOne": "Choose one database integration. Connecting either Supabase or Neon will lock out the other for this app."
}
}
}
......@@ -228,6 +228,7 @@
"requestsConsent": "solicita seu consentimento.",
"inputRequired": "Entrada Necessária"
},
"guide": "Guia",
"agentModeActivated": "Modo Agente Ativado",
"agentModeTip": "Dica: Crie um novo chat para dar ao agente um contexto limpo para melhores resultados.",
"neverShowAgain": "Não mostrar novamente"
......
......@@ -554,7 +554,101 @@
"connectedSuccess": "Conectado ao Neon com sucesso!",
"disconnect": "Desconectar do Neon",
"disconnected": "Desconectado do Neon com sucesso",
"failedDisconnect": "Falha ao desconectar do Neon"
"failedDisconnect": "Falha ao desconectar do Neon",
"activeBranch": "Branch Ativa",
"branchSwitched": "Mudou para a branch",
"cancel": "Cancelar",
"connectedToProject": "Conectado ao projeto",
"create": "Criar",
"createNewProject": "Criar Novo Projeto",
"creating": "Criando...",
"development": "Desenvolvimento",
"disconnectConfirmation": "Tem certeza de que deseja desconectar este projeto Neon? Isso pode quebrar a conexão com o banco de dados do seu app.",
"disconnectProject": "Desconectar projeto",
"emailVerificationDisabled": "Verificação de e-mail desativada",
"emailVerificationEnabled": "Verificação de e-mail ativada",
"emailVerificationHelp": "Quando ativada, os usuários devem verificar seu endereço de e-mail antes de poderem fazer login no seu app.",
"envUpdated": "DATABASE_URL atualizado em .env.local.",
"errorLoadingBranches": "Falha ao carregar branches: {{message}}",
"errorLoadingProjects": "Falha ao carregar projetos: {{message}}",
"failedConnectProject": "Falha ao conectar projeto: {{error}}",
"failedDisconnectProject": "Falha ao desconectar projeto",
"failedSetBranch": "Falha ao mudar de branch: {{error}}",
"failedUpdateEmailVerification": "Falha ao atualizar verificação de e-mail: {{error}}",
"nextjsOnly": "A integração com Neon está disponível apenas para projetos Next.js.",
"noProjectsFound": "Nenhum projeto encontrado. Crie um novo projeto para começar.",
"noBranchesFound": "Ainda não foram encontradas branches para este projeto Neon.",
"preview": "Pré-visualização",
"production": "Produção",
"snapshot": "Instantâneo",
"project": "Projeto Neon",
"projectConnected": "Projeto Neon conectado com sucesso",
"projectName": "Nome do projeto",
"projects": "Projetos Neon",
"refreshProjects": "Atualizar projetos",
"requireEmailVerification": "Exigir verificação de e-mail",
"retry": "Tentar novamente",
"selectAProject": "Selecione um projeto...",
"selectBranch": "Selecione uma branch...",
"selectProjectDescription": "Selecione um projeto Neon existente ou crie um novo",
"openInConsole": "Abrir no Console Neon",
"completingSignIn": "Complete o login no seu navegador…",
"projectDisconnected": "Projeto desconectado com sucesso",
"signInTimedOut": "Login expirou — tente novamente ou verifique seu navegador",
"connecting": "Conectando...",
"disconnectAccountTitle": "Desconectar conta Neon",
"disconnectAccountConfirmation": "Tem certeza de que deseja desconectar sua conta Neon? Você precisará autenticar novamente via OAuth para reconectar."
},
"db": {
"projectInfo": "{{provider}} Informações do Projeto",
"tableSchema": "Esquema da Tabela",
"tableSchemaProvider": "{{provider}} Esquema da Tabela",
"fetching": "Buscando...",
"didNotFinish": "Não foi concluído"
},
"databaseSetup": {
"badge": "Integração",
"integrationComplete": "Integração concluída",
"completeDescription": "Integração com {{provider}} concluída",
"connectedToProject": "Este app está conectado ao projeto {{provider}}:",
"continue": "Continuar",
"continuing": "Continuando...",
"chooseProvider": "Escolha um provedor de banco de dados",
"setUpProvider": "Configurar {{provider}}",
"setUpDatabase": "Configurar banco de dados",
"experimental": "Experimental",
"providers": {
"supabase": {
"name": "Supabase",
"description": "Backend completo como serviço: banco de dados Postgres, storage e edge functions."
},
"neon": {
"name": "Neon",
"description": "Banco de dados Postgres serverless com time-travel e generoso limite de projetos gratuitos."
}
}
},
"migration": {
"title": "Migração de Banco de Dados",
"description": "Copie o schema da sua branch de desenvolvimento ativa para a branch de produção.",
"descriptionWithBranches": "Copie o schema em {{projectName}} de {{sourceBranchName}} para {{targetBranchName}}.",
"migrateToProduction": "Migrar para Produção",
"migrating": "Migrando...",
"success": "Migração aplicada com sucesso.",
"errorMessage": "Não foi possível aplicar a migração.",
"showDetails": "Mostrar detalhes",
"hideDetails": "Ocultar detalhes",
"alreadyInSync": "Os bancos de desenvolvimento e produção já estão sincronizados.",
"switchBranchHint": "Mude para uma branch que não seja de produção no painel do Neon antes de migrar.",
"confirmDescription": "Isso modificará o schema do seu banco de dados de produção. Tem certeza de que deseja continuar?",
"confirmDescriptionWithBranches": "Isso modificará o schema de {{targetBranchName}} em {{projectName}} usando o schema de {{sourceBranchName}}. Tem certeza de que deseja continuar?",
"cancel": "Cancelar",
"backupWarning": "Certifique-se de ter os backups do banco de dados habilitados, caso contrário, você não poderá desfazer esta ação."
},
"mutualExclusion": {
"supabaseUnavailable": "O Supabase não está disponível porque um banco de dados Neon já está conectado. Apenas uma integração de banco de dados pode estar ativa por vez.",
"neonUnavailable": "O Neon não está disponível porque um projeto Supabase já está conectado. Apenas uma integração de banco de dados pode estar ativa por vez.",
"chooseOne": "Escolha uma integração de banco de dados. Conectar Supabase ou Neon bloqueará o outro para este aplicativo."
}
}
}
......@@ -228,6 +228,7 @@
"requestsConsent": "请求您的同意。",
"inputRequired": "需要输入"
},
"guide": "指南",
"agentModeActivated": "Agent 模式已激活",
"agentModeTip": "提示:创建新聊天以给 Agent 一个干净的上下文,获得更好的结果。",
"neverShowAgain": "不再显示"
......
......@@ -554,7 +554,101 @@
"connectedSuccess": "已成功连接到 Neon!",
"disconnect": "断开与 Neon 的连接",
"disconnected": "已成功断开与 Neon 的连接",
"failedDisconnect": "断开与 Neon 的连接失败"
"failedDisconnect": "断开与 Neon 的连接失败",
"activeBranch": "活跃分支",
"branchSwitched": "已切换到分支",
"cancel": "取消",
"connectedToProject": "已连接到项目",
"create": "创建",
"createNewProject": "创建新项目",
"creating": "创建中...",
"development": "开发",
"snapshot": "快照",
"disconnectConfirmation": "确定要断开此 Neon 项目的连接吗?这可能会导致应用的数据库连接中断。",
"disconnectProject": "断开项目连接",
"emailVerificationDisabled": "邮箱验证已禁用",
"emailVerificationEnabled": "邮箱验证已启用",
"emailVerificationHelp": "启用后,用户必须验证其电子邮件地址才能登录您的应用。",
"envUpdated": "DATABASE_URL 已在 .env.local 中更新。",
"errorLoadingBranches": "加载分支失败:{{message}}",
"errorLoadingProjects": "加载项目失败:{{message}}",
"failedConnectProject": "连接项目失败:{{error}}",
"failedDisconnectProject": "断开项目连接失败",
"failedSetBranch": "切换分支失败:{{error}}",
"failedUpdateEmailVerification": "更新邮箱验证失败:{{error}}",
"nextjsOnly": "Neon 集成目前仅适用于 Next.js 项目。",
"noProjectsFound": "未找到项目。创建一个新项目以开始使用。",
"noBranchesFound": "这个 Neon 项目暂时还没有可用分支。",
"preview": "预览",
"production": "生产",
"project": "Neon 项目",
"projectConnected": "Neon 项目连接成功",
"projectName": "项目名称",
"projects": "Neon 项目",
"refreshProjects": "刷新项目",
"requireEmailVerification": "要求邮箱验证",
"retry": "重试",
"selectAProject": "选择一个项目...",
"selectBranch": "选择一个分支...",
"selectProjectDescription": "选择一个现有的 Neon 项目或创建一个新项目",
"openInConsole": "在 Neon 控制台中打开",
"completingSignIn": "请在浏览器中完成登录…",
"projectDisconnected": "项目已成功断开连接",
"signInTimedOut": "登录超时 — 请重试或检查浏览器",
"connecting": "连接中...",
"disconnectAccountTitle": "断开 Neon 账户",
"disconnectAccountConfirmation": "确定要断开 Neon 账户吗?您需要通过 OAuth 重新认证才能重新连接。"
},
"db": {
"projectInfo": "{{provider}} 项目信息",
"tableSchema": "表结构",
"tableSchemaProvider": "{{provider}} 表结构",
"fetching": "获取中...",
"didNotFinish": "未完成"
},
"databaseSetup": {
"badge": "集成",
"integrationComplete": "集成已完成",
"completeDescription": "{{provider}} 集成已完成",
"connectedToProject": "此应用已连接到 {{provider}} 项目:",
"continue": "继续",
"continuing": "继续中...",
"chooseProvider": "选择数据库提供商",
"setUpProvider": "设置 {{provider}}",
"setUpDatabase": "设置数据库",
"experimental": "实验性",
"providers": {
"supabase": {
"name": "Supabase",
"description": "完整的后端即服务:Postgres 数据库、存储与边缘函数。"
},
"neon": {
"name": "Neon",
"description": "无服务器 Postgres 数据库,支持时间旅行和慷慨的免费项目限额。"
}
}
},
"migration": {
"title": "数据库迁移",
"description": "将当前开发分支的 schema 复制到生产分支。",
"descriptionWithBranches": "将 {{projectName}} 中 {{sourceBranchName}} 的 schema 复制到 {{targetBranchName}}。",
"migrateToProduction": "迁移到生产环境",
"migrating": "迁移中...",
"success": "迁移已成功应用。",
"errorMessage": "无法应用此次迁移。",
"showDetails": "显示详情",
"hideDetails": "隐藏详情",
"alreadyInSync": "开发数据库和生产数据库已经同步。",
"switchBranchHint": "迁移前,请先在 Neon 面板中切换到非生产分支。",
"confirmDescription": "此操作将修改您的生产数据库 schema。确定要继续吗?",
"confirmDescriptionWithBranches": "此操作将使用 {{sourceBranchName}} 的 schema 修改 {{projectName}} 中 {{targetBranchName}} 的 schema。确定要继续吗?",
"cancel": "取消",
"backupWarning": "请确保已启用数据库备份,否则此操作将无法撤销。"
},
"mutualExclusion": {
"supabaseUnavailable": "Supabase 不可用,因为已连接 Neon 数据库。一次只能激活一个数据库集成。",
"neonUnavailable": "Neon 不可用,因为已连接 Supabase 项目。一次只能激活一个数据库集成。",
"chooseOne": "请选择一个数据库集成。连接 Supabase 或 Neon 中任一个,将锁定本应用的另一个集成。"
}
}
}
......@@ -91,6 +91,7 @@ import {
RIPGREP_EXCLUDED_GLOBS,
} from "../utils/ripgrep_utils";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import { detectFrameworkType } from "../utils/framework_utils";
const logger = log.scope("app_handlers");
const handle = createLoggedHandler(logger);
......@@ -1377,6 +1378,7 @@ export function registerAppHandlers() {
return {
...app,
files,
frameworkType: detectFrameworkType(appPath),
resolvedPath: appPath,
supabaseProjectName,
vercelTeamSlug,
......
......@@ -27,6 +27,7 @@ import {
getSupabaseAvailableSystemPrompt,
SUPABASE_NOT_AVAILABLE_SYSTEM_PROMPT,
} from "../../prompts/supabase_prompt";
import { buildNeonPromptForApp } from "../../neon_admin/neon_prompt_context";
import { getDyadAppPath } from "../../paths/paths";
import { buildDyadMediaUrl } from "../../lib/dyadMediaUrl";
import { readSettings } from "../../main/settings";
......@@ -828,12 +829,22 @@ ${componentSnippet}
organizationSlug:
updatedChat.app.supabaseOrganizationSlug ?? null,
}));
} else if (updatedChat.app?.neonProjectId) {
// Neon is connected — inject Neon prompt instead of Supabase
systemPrompt +=
"\n\n" +
(await buildNeonPromptForApp({
appPath: updatedChat.app.path,
neonProjectId: updatedChat.app.neonProjectId!,
neonActiveBranchId: updatedChat.app.neonActiveBranchId,
neonDevelopmentBranchId: updatedChat.app.neonDevelopmentBranchId,
selectedChatMode: settings.selectedChatMode ?? "",
})) +
"\n\n";
} else if (
// Neon projects don't need Supabase.
!updatedChat.app?.neonProjectId &&
// In local agent mode, we will suggest supabase as part of the add-integration tool
// In local agent mode, we will suggest integrations as part of the add-integration tool
settings.selectedChatMode !== "local-agent" &&
// If in security review mode, we don't need to mention supabase is available.
// If in security review mode, we don't need to mention integrations are available.
!isSecurityReviewIntent
) {
systemPrompt += "\n\n" + SUPABASE_NOT_AVAILABLE_SYSTEM_PROMPT;
......
import path from "node:path";
import os from "node:os";
import fs from "node:fs/promises";
import { createTypedHandler } from "./base";
import { migrationContracts } from "../types/migration";
import {
getConnectionUri,
executeNeonSql,
} from "../../neon_admin/neon_context";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import { getAppWithNeonBranch } from "../utils/neon_utils";
import { IS_TEST_BUILD } from "../utils/test_utils";
import {
logger,
getProductionBranchId,
createTempDrizzleConfig,
spawnDrizzleKit,
} from "../utils/migration_utils";
// =============================================================================
// Handler Registration
// =============================================================================
export function registerMigrationHandlers() {
// -------------------------------------------------------------------------
// migration:push
// -------------------------------------------------------------------------
createTypedHandler(migrationContracts.push, async (_, params) => {
const { appId } = params;
logger.info(`Pushing migration for app ${appId}`);
// 1. Get app data and resolve branches
const { appData, branchId: devBranchId } =
await getAppWithNeonBranch(appId);
const projectId = appData.neonProjectId!;
const { branchId: prodBranchId } = await getProductionBranchId(projectId);
logger.info(
`Resolved branches — dev: ${devBranchId}, prod: ${prodBranchId}, project: ${projectId}`,
);
// 2. Guard: dev and prod must be different branches
if (devBranchId === prodBranchId) {
throw new DyadError(
"Active branch is the production branch. Create a development branch first.",
DyadErrorKind.Precondition,
);
}
// 3. Get connection URIs for both branches
const devUri = await getConnectionUri({
projectId,
branchId: devBranchId,
});
const prodUri = await getConnectionUri({
projectId,
branchId: prodBranchId,
});
logger.info(
`Connection URIs — dev host: ${new URL(devUri).hostname}, prod host: ${new URL(prodUri).hostname}`,
);
// 4. Validate dev schema has at least one table
let tableCount: number;
if (IS_TEST_BUILD) {
tableCount = 1;
} else {
let parsed;
try {
parsed = JSON.parse(
await executeNeonSql({
projectId,
branchId: devBranchId,
query:
"SELECT count(*) as cnt FROM information_schema.tables WHERE table_schema = 'public'",
}),
);
} catch {
throw new DyadError(
"Unable to verify development table count",
DyadErrorKind.Precondition,
);
}
tableCount = parseInt(parsed?.[0]?.cnt ?? "0", 10);
}
if (!tableCount || tableCount === 0) {
throw new DyadError(
"Development database has no tables. Create at least one table before migrating.",
DyadErrorKind.Precondition,
);
}
// 5. Create temp directory with restricted permissions
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "dyad-migration-"));
try {
if (process.platform !== "win32") {
await fs.chmod(tmpDir, 0o700);
}
// 6. Write introspect config pointing at dev branch
const introspectConfigPath = await createTempDrizzleConfig({
tmpDir,
configName: "drizzle-introspect.config.js",
});
// 7. Run drizzle-kit introspect to generate schema files
const introspectResult = await spawnDrizzleKit({
args: ["introspect", `--config=${introspectConfigPath}`],
cwd: tmpDir,
connectionUri: devUri,
});
if (introspectResult.exitCode !== 0) {
throw new DyadError(
`Schema introspection failed: ${introspectResult.stderr || introspectResult.stdout}`,
DyadErrorKind.External,
);
}
// 8. Find the generated schema file
const schemaOutDir = path.join(tmpDir, "schema-out");
let schemaFiles: string[];
try {
schemaFiles = await fs.readdir(schemaOutDir);
} catch {
throw new DyadError(
"drizzle-kit introspect did not generate output. Your development database may have an unsupported schema.",
DyadErrorKind.Internal,
);
}
const tsSchemaFile =
schemaFiles.find((f) => f === "schema.ts") ??
schemaFiles.find((f) => f.endsWith(".ts") && f !== "relations.ts");
if (!tsSchemaFile) {
throw new DyadError(
"drizzle-kit introspect did not generate any schema files.",
DyadErrorKind.Internal,
);
}
logger.info(`Using introspected schema file: ${tsSchemaFile}`);
// 9. Write push config pointing introspected schema at prod branch
const pushConfigPath = await createTempDrizzleConfig({
tmpDir,
configName: "drizzle-push.config.js",
schemaPath: path.join(schemaOutDir, tsSchemaFile),
});
// 10. Run drizzle-kit push directly against production (--force skips
// interactive prompts).
// TODO: In a follow-up PR, we should add a warning for destructive changes.
const pushResult = await spawnDrizzleKit({
args: ["push", "--force", `--config=${pushConfigPath}`],
cwd: tmpDir,
connectionUri: prodUri,
});
if (pushResult.exitCode !== 0) {
throw new DyadError(
`Migration push failed: ${pushResult.stderr || pushResult.stdout}`,
DyadErrorKind.External,
);
}
// drizzle-kit does not expose a machine-readable "already in sync" flag.
const noChanges = /no\s+changes\s+detected/i.test(pushResult.stdout);
logger.info(
noChanges
? `Schemas already in sync for app ${appId}, nothing to migrate.`
: `Migration push completed successfully for app ${appId}`,
);
return { success: true, noChanges };
} finally {
// 11. Always clean up temp directory
await fs.rm(tmpDir, { recursive: true, force: true }).catch((err) => {
logger.warn(`Failed to clean up temp directory ${tmpDir}: ${err}`);
});
}
});
}
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论