From f406bbf5c59f708864e9d84a53c1e556d504f006 Mon Sep 17 00:00:00 2001 From: Jordan Junaidi Date: Wed, 11 Feb 2026 21:06:25 -0800 Subject: [PATCH 1/2] Registered Postgres as a new stack --- internal/cli/registry.go | 12 ++++--- internal/cli/root.go | 2 +- internal/stacks/postgresql/postgresql.go | 43 ++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 6 deletions(-) create mode 100644 internal/stacks/postgresql/postgresql.go diff --git a/internal/cli/registry.go b/internal/cli/registry.go index 300ca9d..2c93635 100644 --- a/internal/cli/registry.go +++ b/internal/cli/registry.go @@ -8,16 +8,18 @@ import ( "github.com/b-jonathan/taco/internal/stacks/firebase" "github.com/b-jonathan/taco/internal/stacks/mongodb" "github.com/b-jonathan/taco/internal/stacks/nextjs" + "github.com/b-jonathan/taco/internal/stacks/postgresql" ) type Stack = stacks.Stack var Registry = map[string]Stack{ - "express": express.New(), - "nextjs": nextjs.New(), - "mongodb": mongodb.New(), - "firebase": firebase.New(), // TODO: implement Firebase stack - "none": nil, + "express": express.New(), + "nextjs": nextjs.New(), + "mongodb": mongodb.New(), + "postgres": postgresql.New(), + "firebase": firebase.New(), // TODO: implement Firebase stack + "none": nil, } func GetFactory(key string) (stacks.Stack, error) { diff --git a/internal/cli/root.go b/internal/cli/root.go index 6d077f4..8526667 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -179,7 +179,7 @@ func initCmd() *cobra.Command { if err != nil { return err } - stack["database"], _ = prompt.CreateSurveySelect("Choose a Database Stack:\n", []string{"MongoDB", "None"}, prompt.AskOpts{}) + stack["database"], _ = prompt.CreateSurveySelect("Choose a Database Stack:\n", []string{"MongoDB", "Postgres", "None"}, prompt.AskOpts{}) stack["database"] = strings.ToLower(stack["database"]) database, err := GetFactory(stack["database"]) if err != nil { diff --git a/internal/stacks/postgresql/postgresql.go b/internal/stacks/postgresql/postgresql.go new file mode 100644 index 0000000..b73301e --- /dev/null +++ b/internal/stacks/postgresql/postgresql.go @@ -0,0 +1,43 @@ +package postgresql + +import ( + "context" + "fmt" + + "github.com/b-jonathan/taco/internal/stacks" +) + +type Stack = stacks.Stack +type Options = stacks.Options + +type postgresql struct{} + +func New() Stack { return &postgresql{} } + +func (postgresql) Type() string { return "database" } +func (postgresql) Name() string { return "postgres" } + +func (postgresql) Init(ctx context.Context, opts *Options) error { + // TODO: Implement connection setup prompt (local vs custom connection string) + fmt.Println("PostgreSQL Init - not yet implemented") + return nil +} + +func (postgresql) Generate(ctx context.Context, opts *Options) error { + // TODO: Implement Prisma init, schema generation, and client generation + fmt.Println("PostgreSQL Generate - not yet implemented") + return nil +} + +func (postgresql) Post(ctx context.Context, opts *Options) error { + // TODO: Append DATABASE_URL to .env, update .gitignore + fmt.Println("PostgreSQL Post - not yet implemented") + return nil +} + +// Seed implements the Seeder interface +func (postgresql) Seed(ctx context.Context, opts *Options) error { + // TODO: Implement seed route similar to MongoDB's + fmt.Println("PostgreSQL Seed - not yet implemented") + return nil +} From 766fcf0d583af2aff489cd3c23ec6cb3a69954ab Mon Sep 17 00:00:00 2001 From: Jordan Junaidi Date: Thu, 5 Mar 2026 12:20:09 -0800 Subject: [PATCH 2/2] implemented postgres --- internal/cli/root.go | 2 + internal/stacks/postgresql/postgresql.go | 241 +++++++++++++++++- internal/stacks/templates/embed.go | 2 +- .../postgresql/express/db/client.ts.tmpl | 20 ++ .../templates/postgresql/express/seed.tmpl | 8 + 5 files changed, 264 insertions(+), 9 deletions(-) create mode 100644 internal/stacks/templates/postgresql/express/db/client.ts.tmpl create mode 100644 internal/stacks/templates/postgresql/express/seed.tmpl diff --git a/internal/cli/root.go b/internal/cli/root.go index 8526667..3841f69 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -198,7 +198,9 @@ func initCmd() *cobra.Command { AppName: params.Name, Frontend: stack["frontend"], FrontendURL: "http://localhost:3000", + Backend: stack["backend"], BackendURL: "http://localhost:4000", + Database: stack["database"], Port: 4000, } diff --git a/internal/stacks/postgresql/postgresql.go b/internal/stacks/postgresql/postgresql.go index b73301e..f00e9de 100644 --- a/internal/stacks/postgresql/postgresql.go +++ b/internal/stacks/postgresql/postgresql.go @@ -3,7 +3,14 @@ package postgresql import ( "context" "fmt" + "os" + "path/filepath" + "strings" + "github.com/AlecAivazis/survey/v2" + "github.com/b-jonathan/taco/internal/execx" + "github.com/b-jonathan/taco/internal/fsutil" + "github.com/b-jonathan/taco/internal/prompt" "github.com/b-jonathan/taco/internal/stacks" ) @@ -17,27 +24,245 @@ func New() Stack { return &postgresql{} } func (postgresql) Type() string { return "database" } func (postgresql) Name() string { return "postgres" } +// EnsurePostgresURI validates that the URI starts with postgresql:// or postgres:// +func EnsurePostgresURI(uri string) error { + if !strings.HasPrefix(uri, "postgresql://") && !strings.HasPrefix(uri, "postgres://") { + return fmt.Errorf("invalid PostgreSQL URI: must start with postgresql:// or postgres://") + } + return nil +} + func (postgresql) Init(ctx context.Context, opts *Options) error { - // TODO: Implement connection setup prompt (local vs custom connection string) - fmt.Println("PostgreSQL Init - not yet implemented") + var postgresURI string + + // Step 1: Ask local vs custom + var choice string + if prompt.IsTTY() { + c, _ := prompt.CreateSurveySelect( + "How do you want to connect to PostgreSQL?", + []string{"Local (default localhost:5432)", "Custom (connection string)"}, + prompt.AskOpts{ + Default: "Local (default localhost:5432)", + PageSize: 2, + }, + ) + choice = c + } + + // Step 2: Set URI if local + if strings.HasPrefix(choice, "Local") { + postgresURI = fmt.Sprintf("postgresql://localhost:5432/%s", opts.AppName) + fmt.Println("Using default local PostgreSQL URI:", postgresURI) + } else { + // Step 3: Interactive loop for custom URI with "undo" option + for { + if prompt.IsTTY() { + uri, _ := prompt.CreateSurveyInput( + "Enter your PostgreSQL connection URI (type 'undo' to go back):", + prompt.AskOpts{ + Help: "Example: postgresql://username:password@host:5432/database", + Validator: survey.Required, + }, + ) + postgresURI = strings.TrimSpace(uri) + } + + // Allow undo: re-ask local vs custom + if postgresURI == "undo" { + c, _ := prompt.CreateSurveySelect( + "How do you want to connect to PostgreSQL?", + []string{"Local (default localhost:5432)", "Custom (connection string)"}, + prompt.AskOpts{ + Default: "Local (default localhost:5432)", + PageSize: 2, + }, + ) + choice = c + if strings.HasPrefix(choice, "Local") { + postgresURI = fmt.Sprintf("postgresql://localhost:5432/%s", opts.AppName) + fmt.Println("Using default local PostgreSQL URI:", postgresURI) + break + } + continue // go back to asking for URI + } + + if err := EnsurePostgresURI(postgresURI); err != nil { + fmt.Println("Invalid PostgreSQL URI:", err) + continue + } + break + } + } + + opts.DatabaseURI = postgresURI + fmt.Println("Final PostgreSQL URI set:", opts.DatabaseURI) + return nil } func (postgresql) Generate(ctx context.Context, opts *Options) error { - // TODO: Implement Prisma init, schema generation, and client generation - fmt.Println("PostgreSQL Generate - not yet implemented") + // Validate that the backend is compatible (Express only) + if !fsutil.ValidateDependency("postgresql", opts.Backend) { + return fmt.Errorf("postgresql can only be used with Express backend, got '%s'", opts.Backend) + } + + backendDir := filepath.Join(opts.ProjectRoot, "backend") + + // Install Prisma dependencies + if err := execx.RunCmd(ctx, backendDir, "npm install prisma @prisma/client"); err != nil { + return fmt.Errorf("npm install prisma: %w", err) + } + + // Initialize Prisma (creates prisma/schema.prisma) + if err := execx.RunCmd(ctx, backendDir, "npx prisma init"); err != nil { + return fmt.Errorf("prisma init: %w", err) + } + + // Overwrite schema.prisma with our starter schema containing User model + // Prisma 7 no longer supports url in datasource block + schemaPath := filepath.Join(backendDir, "prisma", "schema.prisma") + schemaContent := `// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + name String? + createdAt DateTime @default(now()) +} +` + if err := os.WriteFile(schemaPath, []byte(schemaContent), 0o644); err != nil { + return fmt.Errorf("write schema.prisma: %w", err) + } + + // Create prisma.config.ts for Prisma 7 migrations + configPath := filepath.Join(backendDir, "prisma.config.ts") + configContent := `import path from "node:path"; +import { defineConfig } from "prisma/config"; + +export default defineConfig({ + earlyAccess: true, + schema: path.join(__dirname, "prisma/schema.prisma"), + migrate: { + adapter: async () => { + const { PrismaPg } = await import("@prisma/adapter-pg"); + const { Pool } = await import("pg"); + const pool = new Pool({ connectionString: process.env.DATABASE_URL }); + return new PrismaPg(pool); + }, + }, +}); +` + if err := os.WriteFile(configPath, []byte(configContent), 0o644); err != nil { + return fmt.Errorf("write prisma.config.ts: %w", err) + } + + // Install pg adapter for Prisma 7 + if err := execx.RunCmd(ctx, backendDir, "npm install @prisma/adapter-pg pg"); err != nil { + return fmt.Errorf("npm install pg adapter: %w", err) + } + if err := execx.RunCmd(ctx, backendDir, "npm install -D @types/pg"); err != nil { + return fmt.Errorf("npm install @types/pg: %w", err) + } + + // Generate Prisma client + if err := execx.RunCmd(ctx, backendDir, "npx prisma generate"); err != nil { + return fmt.Errorf("prisma generate: %w", err) + } + + // Copy templates from postgresql/express/ + templateDir := "postgresql/express" + outputDir := filepath.Join(backendDir, "src") + + if err := fsutil.GenerateFromTemplateDir(templateDir, outputDir); err != nil { + return fmt.Errorf("generate postgresql templates: %w", err) + } + + // Inject database import and seed route into index.ts + indexPath := filepath.Join(backendDir, "src", "index.ts") + indexBytes, err := os.ReadFile(indexPath) + if err != nil { + return fmt.Errorf("read index.ts: %w", err) + } + + src := string(indexBytes) + + // Inject Prisma client import + if !strings.Contains(src, "prisma") { + src = strings.Replace(src, "// [DATABASE IMPORT]", ` +import { prisma } from "./db/client";`, 1) + } + + // Inject seed route + if !strings.Contains(src, "/seed") { + route, err := fsutil.RenderTemplate("postgresql/express/seed.tmpl") + if err != nil { + return fmt.Errorf("render seed route template: %w", err) + } + src = strings.Replace(src, "// [DATABASE ROUTE]", string(route), 1) + } + + updated := fsutil.FileInfo{ + Path: indexPath, + Content: []byte(src), + } + + if err := fsutil.WriteFile(updated); err != nil { + return err + } + + // Push schema to database (requires database to be running) + fmt.Println("Pushing Prisma schema to database...") + if err := execx.RunCmd(ctx, backendDir, "npx prisma db push"); err != nil { + fmt.Println("Warning: prisma db push failed. Make sure PostgreSQL is running and try manually: cd backend && npx prisma db push") + } + return nil } func (postgresql) Post(ctx context.Context, opts *Options) error { - // TODO: Append DATABASE_URL to .env, update .gitignore - fmt.Println("PostgreSQL Post - not yet implemented") + // Append DATABASE_URL to backend .env + envPath := filepath.Join(opts.ProjectRoot, "backend", ".env") + content := fmt.Sprintf("\nDATABASE_URL=%s", opts.DatabaseURI) + if err := fsutil.AppendUniqueLines(envPath, []string{content}); err != nil { + return fmt.Errorf("append DATABASE_URL to .env: %w", err) + } + + // Add prisma/ and .env to .gitignore + gitignorePath := filepath.Join(opts.ProjectRoot, ".gitignore") + if err := fsutil.EnsureFile(gitignorePath); err != nil { + return fmt.Errorf("ensure gitignore file: %w", err) + } + + if err := fsutil.AppendUniqueLines(gitignorePath, []string{ + "prisma/.env", + "backend/prisma/.env", + }); err != nil { + return fmt.Errorf("update .gitignore: %w", err) + } + return nil } // Seed implements the Seeder interface func (postgresql) Seed(ctx context.Context, opts *Options) error { - // TODO: Implement seed route similar to MongoDB's - fmt.Println("PostgreSQL Seed - not yet implemented") + if opts.DatabaseURI == "" { + return fmt.Errorf("DatabaseURI is empty — did Init() run?") + } + + // For PostgreSQL with Prisma, seeding requires the schema to exist first. + // The schema is created in Generate(), which runs after Seed() in the current flow. + // So we just log that seeding will be available after setup completes. + fmt.Println("PostgreSQL configured with URI:", opts.DatabaseURI) + fmt.Println("After setup completes, run 'npx prisma db push' in the backend directory to sync your schema.") + return nil } diff --git a/internal/stacks/templates/embed.go b/internal/stacks/templates/embed.go index 861d8e5..f6407cf 100644 --- a/internal/stacks/templates/embed.go +++ b/internal/stacks/templates/embed.go @@ -2,5 +2,5 @@ package templates import "embed" -//go:embed express/* firebase/* mongodb/* nextjs/* +//go:embed express/* firebase/* mongodb/* nextjs/* postgresql/* var FS embed.FS diff --git a/internal/stacks/templates/postgresql/express/db/client.ts.tmpl b/internal/stacks/templates/postgresql/express/db/client.ts.tmpl new file mode 100644 index 0000000..9776672 --- /dev/null +++ b/internal/stacks/templates/postgresql/express/db/client.ts.tmpl @@ -0,0 +1,20 @@ +import { PrismaClient } from "@prisma/client"; +import { PrismaPg } from "@prisma/adapter-pg"; +import { Pool } from "pg"; +import dotenv from "dotenv"; + +dotenv.config(); + +const connectionString = process.env.DATABASE_URL; +if (!connectionString) { + throw new Error("DATABASE_URL is not set in environment variables"); +} + +const pool = new Pool({ connectionString }); +const adapter = new PrismaPg(pool); + +const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }; + +export const prisma = globalForPrisma.prisma || new PrismaClient({ adapter }); + +if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma; diff --git a/internal/stacks/templates/postgresql/express/seed.tmpl b/internal/stacks/templates/postgresql/express/seed.tmpl new file mode 100644 index 0000000..441788b --- /dev/null +++ b/internal/stacks/templates/postgresql/express/seed.tmpl @@ -0,0 +1,8 @@ +app.get("/seed", async (_req, res) => { + try { + const users = await prisma.user.findMany(); + res.json(users); + } catch (err) { + res.status(500).send("Database error"); + } +});