diff --git a/.changeset/tricky-turtles-teach.md b/.changeset/tricky-turtles-teach.md new file mode 100644 index 0000000..27fdc12 --- /dev/null +++ b/.changeset/tricky-turtles-teach.md @@ -0,0 +1,5 @@ +--- +"create-better-t-stack": minor +--- + +add neon postgres setup diff --git a/apps/cli/src/helpers/create-project.ts b/apps/cli/src/helpers/create-project.ts index 76ac0d9..e033cba 100644 --- a/apps/cli/src/helpers/create-project.ts +++ b/apps/cli/src/helpers/create-project.ts @@ -57,6 +57,7 @@ export async function createProject(options: ProjectConfig): Promise { options.dbSetup === "turso", options.dbSetup === "prisma-postgres", options.dbSetup === "mongodb-atlas", + options.dbSetup === "neon", ); await setupAuthTemplate( diff --git a/apps/cli/src/helpers/db-setup.ts b/apps/cli/src/helpers/db-setup.ts index 8d520cc..826df1a 100644 --- a/apps/cli/src/helpers/db-setup.ts +++ b/apps/cli/src/helpers/db-setup.ts @@ -9,6 +9,7 @@ import type { } from "../types"; import { addPackageDependency } from "../utils/add-package-deps"; import { setupMongoDBAtlas } from "./mongodb-atlas-setup"; +import { setupNeonPostgres } from "./neon-setup"; import { setupPrismaPostgres } from "./prisma-postgres-setup"; import { setupTurso } from "./turso-setup"; @@ -20,6 +21,7 @@ export async function setupDatabase( setupTursoDb: boolean, setupPrismaPostgresDb: boolean, setupMongoDBAtlasDb: boolean, + setupNeonPostgresDb: boolean, ): Promise { const s = spinner(); const serverDir = path.join(projectDir, "apps/server"); @@ -60,12 +62,12 @@ export async function setupDatabase( if (databaseType === "sqlite" && setupTursoDb) { await setupTurso(projectDir, orm === "drizzle"); - } else if ( - databaseType === "postgres" && - orm === "prisma" && - setupPrismaPostgresDb - ) { - await setupPrismaPostgres(projectDir, packageManager); + } else if (databaseType === "postgres") { + if (orm === "prisma" && setupPrismaPostgresDb) { + await setupPrismaPostgres(projectDir, packageManager); + } else if (setupNeonPostgresDb) { + await setupNeonPostgres(projectDir, packageManager); + } } else if (databaseType === "mongodb" && setupMongoDBAtlasDb) { await setupMongoDBAtlas(projectDir); } diff --git a/apps/cli/src/helpers/env-setup.ts b/apps/cli/src/helpers/env-setup.ts index 9e774d0..c3d071c 100644 --- a/apps/cli/src/helpers/env-setup.ts +++ b/apps/cli/src/helpers/env-setup.ts @@ -65,7 +65,8 @@ export async function setupEnvironmentVariables( const specializedSetup = options.dbSetup === "turso" || options.dbSetup === "prisma-postgres" || - options.dbSetup === "mongodb-atlas"; + options.dbSetup === "mongodb-atlas" || + options.dbSetup === "neon"; if (!specializedSetup) { if (options.database === "postgres") { diff --git a/apps/cli/src/helpers/neon-setup.ts b/apps/cli/src/helpers/neon-setup.ts new file mode 100644 index 0000000..a3ee85e --- /dev/null +++ b/apps/cli/src/helpers/neon-setup.ts @@ -0,0 +1,194 @@ +import path from "node:path"; +import { cancel, isCancel, log, spinner, text } from "@clack/prompts"; +import { execa } from "execa"; +import fs from "fs-extra"; +import pc from "picocolors"; +import type { ProjectPackageManager } from "../types"; + +type NeonConfig = { + connectionString: string; + projectId: string; + dbName: string; + roleName: string; +}; + +function buildNeonCommand( + packageManager: string, + args: string[], +): { cmd: string; cmdArgs: string[] } { + let cmd: string; + let cmdArgs: string[]; + + switch (packageManager) { + case "pnpm": + cmd = "pnpm"; + cmdArgs = ["dlx", "neonctl", ...args]; + break; + case "bun": + cmd = "bunx"; + cmdArgs = ["neonctl", ...args]; + break; + default: + cmd = "npx"; + cmdArgs = ["neonctl", ...args]; + } + + return { cmd, cmdArgs }; +} + +async function executeNeonCommand( + packageManager: string, + args: string[], + spinnerText?: string, +) { + const s = spinnerText ? spinner() : null; + try { + const { cmd, cmdArgs } = buildNeonCommand(packageManager, args); + + if (s) s.start(spinnerText); + const result = await execa(cmd, cmdArgs); + if (s) s.stop(); + + return result; + } catch (error) { + if (s) s.stop(pc.red(`Failed: ${spinnerText}`)); + throw error; + } +} + +async function isNeonAuthenticated(packageManager: string) { + try { + const { stdout } = await executeNeonCommand(packageManager, [ + "projects", + "list", + ]); + return !stdout.includes("not authenticated") && !stdout.includes("error"); + } catch { + return false; + } +} + +async function authenticateWithNeon(packageManager: string) { + try { + await executeNeonCommand( + packageManager, + ["auth"], + "Authenticating with Neon...", + ); + log.success("Authenticated with Neon successfully!"); + return true; + } catch (error) { + log.error(pc.red("Failed to authenticate with Neon")); + throw error; + } +} + +async function createNeonProject( + projectName: string, + packageManager: string, +): Promise { + try { + const { stdout } = await executeNeonCommand( + packageManager, + ["projects", "create", "--name", projectName, "--output", "json"], + `Creating Neon project "${projectName}"...`, + ); + + const response = JSON.parse(stdout); + + if ( + response.project && + response.connection_uris && + response.connection_uris.length > 0 + ) { + const projectId = response.project.id; + const connectionUri = response.connection_uris[0].connection_uri; + const params = response.connection_uris[0].connection_parameters; + + return { + connectionString: connectionUri, + projectId: projectId, + dbName: params.database, + roleName: params.role, + }; + } + log.error(pc.red("Failed to extract connection information from response")); + return null; + } catch (error) { + log.error(pc.red("Failed to create Neon project")); + throw error; + } +} + +async function writeEnvFile(projectDir: string, config?: NeonConfig) { + const envPath = path.join(projectDir, "apps/server", ".env"); + const envContent = config + ? `DATABASE_URL="${config.connectionString}"` + : `DATABASE_URL="postgresql://postgres:postgres@localhost:5432/mydb?schema=public"`; + + await fs.ensureDir(path.dirname(envPath)); + await fs.writeFile(envPath, envContent); + + return true; +} + +function displayManualSetupInstructions() { + log.info(`Manual Neon PostgreSQL Setup Instructions: + +1. Visit https://neon.tech and create an account +2. Create a new project from the dashboard +3. Get your connection string +4. Add the database URL to the .env file in apps/server/.env + +DATABASE_URL="your_connection_string"`); +} + +export async function setupNeonPostgres( + projectDir: string, + packageManager: ProjectPackageManager, +) { + const s = spinner(); + + try { + const isAuthenticated = await isNeonAuthenticated(packageManager); + + if (!isAuthenticated) { + log.info("Please authenticate with Neon to continue:"); + await authenticateWithNeon(packageManager); + } + + const suggestedProjectName = path.basename(projectDir); + const projectName = await text({ + message: "Enter a name for your Neon project:", + defaultValue: suggestedProjectName, + initialValue: suggestedProjectName, + }); + + if (isCancel(projectName)) { + cancel(pc.red("Operation cancelled")); + process.exit(0); + } + + const config = await createNeonProject( + projectName as string, + packageManager, + ); + + if (!config) { + throw new Error( + "Failed to create project - couldn't get connection information", + ); + } + + await fs.ensureDir(path.join(projectDir, "apps/server")); + await writeEnvFile(projectDir, config); + log.success("Neon database configured successfully!"); + } catch (error) { + s.stop(pc.red("Neon PostgreSQL setup failed")); + if (error instanceof Error) { + log.error(pc.red(error.message)); + } + await writeEnvFile(projectDir); + displayManualSetupInstructions(); + } +} diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index cedd094..fd9cc28 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -61,7 +61,7 @@ async function main() { .option("--no-install", "Skip installing dependencies") .option( "--db-setup ", - "Database setup (turso, prisma-postgres, mongodb-atlas, none)", + "Database setup (turso, neon, prisma-postgres, mongodb-atlas, none)", ) .option( "--backend ", @@ -169,13 +169,13 @@ function processAndValidateFlags( if (options.dbSetup) { if ( - !["turso", "prisma-postgres", "mongodb-atlas", "none"].includes( + !["turso", "prisma-postgres", "mongodb-atlas", "neon", "none"].includes( options.dbSetup, ) ) { cancel( pc.red( - `Invalid database setup: ${options.dbSetup}. Must be turso, prisma-postgres, mongodb-atlas, or none.`, + `Invalid database setup: ${options.dbSetup}. Must be turso, prisma-postgres, mongodb-atlas, neon, or none.`, ), ); process.exit(1); @@ -235,6 +235,16 @@ function processAndValidateFlags( } config.database = "mongodb"; config.orm = "prisma"; + } else if (options.dbSetup === "neon") { + if (options.database && options.database !== "postgres") { + cancel( + pc.red( + "Neon PostgreSQL setup requires PostgreSQL database. Cannot use --db-setup neon with a different database type.", + ), + ); + process.exit(1); + } + config.database = "postgres"; } } else { config.dbSetup = "none"; diff --git a/apps/cli/src/prompts/db-setup.ts b/apps/cli/src/prompts/db-setup.ts index 050b619..e3c7430 100644 --- a/apps/cli/src/prompts/db-setup.ts +++ b/apps/cli/src/prompts/db-setup.ts @@ -25,13 +25,22 @@ export async function getDBSetupChoice( }, { value: "none" as const, label: "None", hint: "Manual setup" }, ]; - } else if (databaseType === "postgres" && orm === "prisma") { + } else if (databaseType === "postgres") { options = [ { - value: "prisma-postgres" as const, - label: "Prisma Postgres", - hint: "Instant Postgres for Global Applications", + value: "neon" as const, + label: "Neon Postgres", + hint: "Serverless Postgres with branching capability", }, + ...(orm === "prisma" + ? [ + { + value: "prisma-postgres" as const, + label: "Prisma Postgres", + hint: "Instant Postgres for Global Applications", + }, + ] + : []), { value: "none" as const, label: "None", hint: "Manual setup" }, ]; } else if (databaseType === "mongodb") { diff --git a/apps/cli/src/types.ts b/apps/cli/src/types.ts index b010ce5..2f4d563 100644 --- a/apps/cli/src/types.ts +++ b/apps/cli/src/types.ts @@ -19,6 +19,7 @@ export type ProjectDBSetup = | "turso" | "prisma-postgres" | "mongodb-atlas" + | "neon" | "none"; export interface ProjectConfig { diff --git a/apps/web/src/app/(home)/_components/StackArchitech.tsx b/apps/web/src/app/(home)/_components/StackArchitech.tsx index de62872..2cb42a2 100644 --- a/apps/web/src/app/(home)/_components/StackArchitech.tsx +++ b/apps/web/src/app/(home)/_components/StackArchitech.tsx @@ -111,6 +111,10 @@ const StackArchitect = () => { if (stack.orm !== "prisma") { setStack((prev) => ({ ...prev, orm: "prisma" })); } + } else if (stack.dbSetup === "neon") { + if (stack.database !== "postgres") { + setStack((prev) => ({ ...prev, database: "postgres" })); + } } }, [stack.database, stack.orm, stack.dbSetup, stack.auth]); @@ -150,6 +154,10 @@ const StackArchitect = () => { if (stack.database !== "mongodb") { notes.dbSetup.push("MongoDB Atlas setup requires MongoDB database."); } + } else if (stack.dbSetup === "neon") { + if (stack.database !== "postgres") { + notes.dbSetup.push("Neon setup requires PostgreSQL database."); + } } } @@ -394,7 +402,8 @@ const StackArchitect = () => { (updatedState.dbSetup === "prisma-postgres" && techId !== "postgres") || (updatedState.dbSetup === "mongodb-atlas" && - techId !== "mongodb") + techId !== "mongodb") || + (updatedState.dbSetup === "neon" && techId !== "postgres") ) { updatedState.dbSetup = "none"; } @@ -446,6 +455,8 @@ const StackArchitect = () => { } else if (techId === "mongodb-atlas") { updatedState.database = "mongodb"; updatedState.orm = "prisma"; + } else if (techId === "neon") { + updatedState.database = "postgres"; } return updatedState; @@ -726,7 +737,9 @@ const StackArchitect = () => { (stack.database !== "postgres" || stack.orm !== "prisma")) || (tech.id === "mongodb-atlas" && - stack.database !== "mongodb"))) || + stack.database !== "mongodb") || + (tech.id === "neon" && + stack.database !== "postgres"))) || (activeTab === "examples" && (((tech.id === "todo" || tech.id === "ai") && !hasWebFrontendSelected) || diff --git a/apps/web/src/lib/constant.ts b/apps/web/src/lib/constant.ts index 1e27ba1..8494022 100644 --- a/apps/web/src/lib/constant.ts +++ b/apps/web/src/lib/constant.ts @@ -152,6 +152,13 @@ export const TECH_OPTIONS = { icon: "☁️", color: "from-pink-400 to-pink-600", }, + { + id: "neon", + name: "Neon Postgres", + description: "Serverless PostgreSQL with Neon", + icon: "⚡", + color: "from-blue-400 to-blue-600", + }, { id: "prisma-postgres", name: "Prisma PostgreSQL", diff --git a/bun.lock b/bun.lock index 78fd7d3..3e3383e 100644 --- a/bun.lock +++ b/bun.lock @@ -14,7 +14,7 @@ }, "apps/cli": { "name": "create-better-t-stack", - "version": "1.10.0", + "version": "1.11.0", "bin": { "create-better-t-stack": "dist/index.js", },