From c9b7e25e1d6f572b5abc72229e21a53a17163461 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Wed, 9 Apr 2025 11:54:01 +0530 Subject: [PATCH] feat: add mysql database --- .changeset/clever-dryers-search.md | 5 + apps/cli/src/constants.ts | 2 + apps/cli/src/helpers/db-setup.ts | 60 +- apps/cli/src/helpers/env-setup.ts | 200 +++--- apps/cli/src/helpers/template-manager.ts | 14 +- apps/cli/src/index.ts | 571 ++++++++---------- apps/cli/src/prompts/config-prompts.ts | 27 +- apps/cli/src/prompts/database.ts | 5 + apps/cli/src/prompts/db-setup.ts | 9 +- apps/cli/src/types.ts | 7 +- apps/cli/template/base/turbo.json | 6 +- .../server/src/routers/with-drizzle-todo.ts | 17 +- .../server/src/with-drizzle-mysql-lib/auth.ts | 15 + .../src/with-prisma-mongodb-lib/auth.ts | 6 - .../server/src/with-prisma-mysql-lib/auth.ts | 11 + .../src/with-prisma-postgres-lib/auth.ts | 6 - .../server/src/with-prisma-sqlite-lib/auth.ts | 6 - .../apps/server/drizzle.config.ts | 10 + .../apps/server/src/db/index.ts | 3 + .../apps/server/src/db/schema/auth.ts | 58 ++ .../apps/server/src/db/schema/todo.ts | 7 + .../apps/server/prisma/index.ts | 5 + .../apps/server/prisma/schema/auth.prisma | 59 ++ .../apps/server/prisma/schema/schema.prisma | 9 + .../apps/server/prisma/schema/todo.prisma | 7 + 25 files changed, 607 insertions(+), 518 deletions(-) create mode 100644 .changeset/clever-dryers-search.md create mode 100644 apps/cli/template/with-auth/apps/server/src/with-drizzle-mysql-lib/auth.ts create mode 100644 apps/cli/template/with-auth/apps/server/src/with-prisma-mysql-lib/auth.ts create mode 100644 apps/cli/template/with-drizzle-mysql/apps/server/drizzle.config.ts create mode 100644 apps/cli/template/with-drizzle-mysql/apps/server/src/db/index.ts create mode 100644 apps/cli/template/with-drizzle-mysql/apps/server/src/db/schema/auth.ts create mode 100644 apps/cli/template/with-drizzle-mysql/apps/server/src/db/schema/todo.ts create mode 100644 apps/cli/template/with-prisma-mysql/apps/server/prisma/index.ts create mode 100644 apps/cli/template/with-prisma-mysql/apps/server/prisma/schema/auth.prisma create mode 100644 apps/cli/template/with-prisma-mysql/apps/server/prisma/schema/schema.prisma create mode 100644 apps/cli/template/with-prisma-mysql/apps/server/prisma/schema/todo.prisma diff --git a/.changeset/clever-dryers-search.md b/.changeset/clever-dryers-search.md new file mode 100644 index 0000000..82c8aee --- /dev/null +++ b/.changeset/clever-dryers-search.md @@ -0,0 +1,5 @@ +--- +"create-better-t-stack": minor +--- + +add mysql database diff --git a/apps/cli/src/constants.ts b/apps/cli/src/constants.ts index 2b503f3..5ad167a 100644 --- a/apps/cli/src/constants.ts +++ b/apps/cli/src/constants.ts @@ -33,6 +33,8 @@ export const dependencyVersionMap = { "@libsql/client": "^0.14.0", postgres: "^3.4.5", + mysql2: "^3.14.0", + "@prisma/client": "^6.5.0", prisma: "^6.5.0", diff --git a/apps/cli/src/helpers/db-setup.ts b/apps/cli/src/helpers/db-setup.ts index c1d6fd0..381e934 100644 --- a/apps/cli/src/helpers/db-setup.ts +++ b/apps/cli/src/helpers/db-setup.ts @@ -30,58 +30,44 @@ export async function setupDatabase( } try { - if (databaseType === "sqlite") { - if (orm === "drizzle") { + if (orm === "prisma") { + addPackageDependency({ + dependencies: ["@prisma/client"], + devDependencies: ["prisma"], + projectDir: serverDir, + }); + } else if (orm === "drizzle") { + if (databaseType === "sqlite") { addPackageDependency({ dependencies: ["drizzle-orm", "@libsql/client"], devDependencies: ["drizzle-kit"], projectDir: serverDir, }); - } else if (orm === "prisma") { - addPackageDependency({ - dependencies: ["@prisma/client"], - devDependencies: ["prisma"], - projectDir: serverDir, - }); - } - - if (setupTursoDb) { - await setupTurso(projectDir, true); - } - } else if (databaseType === "postgres") { - if (orm === "drizzle") { + } else if (databaseType === "postgres") { addPackageDependency({ dependencies: ["drizzle-orm", "postgres"], devDependencies: ["drizzle-kit"], projectDir: serverDir, }); - } else if (orm === "prisma") { + } else if (databaseType === "mysql") { addPackageDependency({ - dependencies: ["@prisma/client"], - devDependencies: ["prisma"], - projectDir: serverDir, - }); - - if ( - databaseType === "postgres" && - orm === "prisma" && - setupPrismaPostgresDb - ) { - await setupPrismaPostgres(projectDir, packageManager); - } - } - } else if (databaseType === "mongodb") { - if (orm === "prisma") { - addPackageDependency({ - dependencies: ["@prisma/client"], - devDependencies: ["prisma"], + dependencies: ["drizzle-orm", "mysql2"], + devDependencies: ["drizzle-kit"], projectDir: serverDir, }); } + } - if (setupMongoDBAtlasDb) { - await setupMongoDBAtlas(projectDir); - } + if (databaseType === "sqlite" && setupTursoDb) { + await setupTurso(projectDir, orm === "drizzle"); + } else if ( + databaseType === "postgres" && + orm === "prisma" && + setupPrismaPostgresDb + ) { + await setupPrismaPostgres(projectDir, packageManager); + } else if (databaseType === "mongodb" && setupMongoDBAtlasDb) { + await setupMongoDBAtlas(projectDir); } } catch (error) { s.stop(pc.red("Failed to set up database")); diff --git a/apps/cli/src/helpers/env-setup.ts b/apps/cli/src/helpers/env-setup.ts index 04eece0..9e774d0 100644 --- a/apps/cli/src/helpers/env-setup.ts +++ b/apps/cli/src/helpers/env-setup.ts @@ -3,73 +3,50 @@ import fs from "fs-extra"; import type { ProjectConfig } from "../types"; import { generateAuthSecret } from "./auth-setup"; +interface EnvVariable { + key: string; + value: string; + condition: boolean; +} + +async function addEnvVariablesToFile( + filePath: string, + variables: EnvVariable[], +): Promise { + await fs.ensureDir(path.dirname(filePath)); + + let envContent = ""; + if (await fs.pathExists(filePath)) { + envContent = await fs.readFile(filePath, "utf8"); + } + + let modified = false; + for (const { key, value, condition } of variables) { + if (condition) { + const regex = new RegExp(`^${key}=.*$`, "m"); + if (regex.test(envContent)) { + if (value) { + envContent = envContent.replace(regex, `${key}=${value}`); + modified = true; + } + } else { + envContent += `\n${key}=${value}`; + modified = true; + } + } + } + + if (modified) { + await fs.writeFile(filePath, envContent.trim()); + } +} + export async function setupEnvironmentVariables( projectDir: string, options: ProjectConfig, ): Promise { const serverDir = path.join(projectDir, "apps/server"); - const envPath = path.join(serverDir, ".env"); - let envContent = ""; - - if (await fs.pathExists(envPath)) { - envContent = await fs.readFile(envPath, "utf8"); - } - - if (!envContent.includes("CORS_ORIGIN")) { - const hasReactRouter = options.frontend.includes("react-router"); - const hasTanStackRouter = options.frontend.includes("tanstack-router"); - const hasTanStackStart = options.frontend.includes("tanstack-start"); - - let corsOrigin = "http://localhost:3000"; - - if (hasReactRouter) { - corsOrigin = "http://localhost:5173"; - } else if (hasTanStackRouter || hasTanStackStart) { - corsOrigin = "http://localhost:3001"; - } - - envContent += `\nCORS_ORIGIN=${corsOrigin}`; - } - - if (options.auth) { - if (!envContent.includes("BETTER_AUTH_SECRET")) { - envContent += `\nBETTER_AUTH_SECRET=${generateAuthSecret()}`; - } - - if (!envContent.includes("BETTER_AUTH_URL")) { - envContent += "\nBETTER_AUTH_URL=http://localhost:3000"; - } - } - - if (options.database !== "none") { - if (options.orm === "prisma" && !envContent.includes("DATABASE_URL")) { - let databaseUrlLine = ""; - if (options.database === "sqlite") { - databaseUrlLine = ""; - } else if (options.database === "postgres") { - databaseUrlLine = `\nDATABASE_URL="postgresql://postgres:postgres@localhost:5432/mydb?schema=public"`; - } else if (options.database === "mongodb") { - databaseUrlLine = `\nDATABASE_URL="mongodb://localhost:27017/mydatabase"`; - } - envContent += databaseUrlLine; - } - - if (options.database === "sqlite" && options.dbSetup !== "turso") { - if (!envContent.includes("DATABASE_URL")) { - envContent += "\nDATABASE_URL=file:./local.db"; - } - } - } - - if ( - options.examples?.includes("ai") && - !envContent.includes("GOOGLE_GENERATIVE_AI_API_KEY") - ) { - envContent += "\nGOOGLE_GENERATIVE_AI_API_KEY="; - } - - await fs.writeFile(envPath, envContent.trim()); const hasReactRouter = options.frontend.includes("react-router"); const hasTanStackRouter = options.frontend.includes("tanstack-router"); @@ -77,39 +54,84 @@ export async function setupEnvironmentVariables( const hasWebFrontend = hasReactRouter || hasTanStackRouter || hasTanStackStart; + let corsOrigin = "http://localhost:3000"; + if (hasReactRouter) { + corsOrigin = "http://localhost:5173"; + } else if (hasTanStackRouter || hasTanStackStart) { + corsOrigin = "http://localhost:3001"; + } + + let databaseUrl = ""; + const specializedSetup = + options.dbSetup === "turso" || + options.dbSetup === "prisma-postgres" || + options.dbSetup === "mongodb-atlas"; + + if (!specializedSetup) { + if (options.database === "postgres") { + databaseUrl = + "postgresql://postgres:postgres@localhost:5432/mydb?schema=public"; + } else if (options.database === "mysql") { + databaseUrl = "mysql://root:password@localhost:3306/mydb"; + } else if (options.database === "mongodb") { + databaseUrl = "mongodb://localhost:27017/mydatabase"; + } else if (options.database === "sqlite") { + databaseUrl = "file:./local.db"; + } + } + + const serverVars: EnvVariable[] = [ + { + key: "CORS_ORIGIN", + value: corsOrigin, + condition: true, + }, + { + key: "BETTER_AUTH_SECRET", + value: generateAuthSecret(), + condition: !!options.auth, + }, + { + key: "BETTER_AUTH_URL", + value: "http://localhost:3000", + condition: !!options.auth, + }, + { + key: "DATABASE_URL", + value: databaseUrl, + condition: + options.database !== "none" && databaseUrl !== "" && !specializedSetup, + }, + { + key: "GOOGLE_GENERATIVE_AI_API_KEY", + value: "", + condition: options.examples?.includes("ai") || false, + }, + ]; + + await addEnvVariablesToFile(envPath, serverVars); + if (hasWebFrontend) { const clientDir = path.join(projectDir, "apps/web"); - await setupClientEnvFile(clientDir); + const clientVars: EnvVariable[] = [ + { + key: "VITE_SERVER_URL", + value: "http://localhost:3000", + condition: true, + }, + ]; + await addEnvVariablesToFile(path.join(clientDir, ".env"), clientVars); } if (options.frontend.includes("native")) { const nativeDir = path.join(projectDir, "apps/native"); - const nativeEnvPath = path.join(nativeDir, ".env"); - let nativeEnvContent = ""; - - if (await fs.pathExists(nativeEnvPath)) { - nativeEnvContent = await fs.readFile(nativeEnvPath, "utf8"); - } - - if (!nativeEnvContent.includes("EXPO_PUBLIC_SERVER_URL")) { - nativeEnvContent += "EXPO_PUBLIC_SERVER_URL=http://localhost:3000\n"; - } - - await fs.writeFile(nativeEnvPath, nativeEnvContent.trim()); + const nativeVars: EnvVariable[] = [ + { + key: "EXPO_PUBLIC_SERVER_URL", + value: "http://localhost:3000", + condition: true, + }, + ]; + await addEnvVariablesToFile(path.join(nativeDir, ".env"), nativeVars); } } - -async function setupClientEnvFile(clientDir: string) { - const clientEnvPath = path.join(clientDir, ".env"); - let clientEnvContent = ""; - - if (await fs.pathExists(clientEnvPath)) { - clientEnvContent = await fs.readFile(clientEnvPath, "utf8"); - } - - if (!clientEnvContent.includes("VITE_SERVER_URL")) { - clientEnvContent += "VITE_SERVER_URL=http://localhost:3000\n"; - } - - await fs.writeFile(clientEnvPath, clientEnvContent.trim()); -} diff --git a/apps/cli/src/helpers/template-manager.ts b/apps/cli/src/helpers/template-manager.ts index 4e1665d..f4b6236 100644 --- a/apps/cli/src/helpers/template-manager.ts +++ b/apps/cli/src/helpers/template-manager.ts @@ -366,14 +366,15 @@ async function findGitignoreFiles(dir: string): Promise { function getOrmTemplateDir(orm: ProjectOrm, database: ProjectDatabase): string { if (orm === "drizzle") { - return database === "sqlite" - ? "template/with-drizzle-sqlite" - : "template/with-drizzle-postgres"; + if (database === "sqlite") return "template/with-drizzle-sqlite"; + if (database === "postgres") return "template/with-drizzle-postgres"; + if (database === "mysql") return "template/with-drizzle-mysql"; } if (orm === "prisma") { if (database === "sqlite") return "template/with-prisma-sqlite"; if (database === "postgres") return "template/with-prisma-postgres"; + if (database === "mysql") return "template/with-prisma-mysql"; if (database === "mongodb") return "template/with-prisma-mongodb"; } @@ -382,14 +383,15 @@ function getOrmTemplateDir(orm: ProjectOrm, database: ProjectDatabase): string { function getAuthLibDir(orm: ProjectOrm, database: ProjectDatabase): string { if (orm === "drizzle") { - return database === "sqlite" - ? "with-drizzle-sqlite-lib" - : "with-drizzle-postgres-lib"; + if (database === "sqlite") return "with-drizzle-sqlite-lib"; + if (database === "postgres") return "with-drizzle-postgres-lib"; + if (database === "mysql") return "with-drizzle-mysql-lib"; } if (orm === "prisma") { if (database === "sqlite") return "with-prisma-sqlite-lib"; if (database === "postgres") return "with-prisma-postgres-lib"; + if (database === "mysql") return "with-prisma-mysql-lib"; if (database === "mongodb") return "with-prisma-mongodb-lib"; } diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index d6a3c85..b6ed581 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -40,7 +40,7 @@ async function main() { .option("-y, --yes", "Use default configuration") .option( "--database ", - "Database type (none, sqlite, postgres, mongodb)", + "Database type (none, sqlite, postgres, mysql, mongodb)", ) .option("--orm ", "ORM type (drizzle, prisma)") .option("--auth", "Include authentication") @@ -80,9 +80,7 @@ async function main() { const options = program.opts() as CLIOptions; const projectDirectory = program.args[0]; - validateOptions(options); - - const flagConfig = processFlags(options, projectDirectory); + const flagConfig = processAndValidateFlags(options, projectDirectory); if (!options.yes && Object.keys(flagConfig).length > 0) { log.info(pc.yellow("Using these pre-selected options:")); @@ -129,41 +127,39 @@ async function main() { } } -function validateOptions(options: CLIOptions): void { - if ( - options.database && - !["none", "sqlite", "postgres", "mongodb"].includes(options.database) - ) { - cancel( - pc.red( - `Invalid database type: ${options.database}. Must be none, sqlite, postgres, or mongodb.`, - ), - ); - process.exit(1); +function processAndValidateFlags( + options: CLIOptions, + projectDirectory?: string, +): Partial { + const config: Partial = {}; + + if (options.database) { + if ( + !["none", "sqlite", "postgres", "mysql", "mongodb"].includes( + options.database, + ) + ) { + cancel( + pc.red( + `Invalid database type: ${options.database}. Must be none, sqlite, postgres, mysql, or mongodb.`, + ), + ); + process.exit(1); + } + config.database = options.database as ProjectDatabase; } - if (options.orm && !["drizzle", "prisma"].includes(options.orm)) { - cancel( - pc.red(`Invalid ORM type: ${options.orm}. Must be drizzle or prisma.`), - ); - process.exit(1); + if (options.orm) { + if (!["drizzle", "prisma"].includes(options.orm)) { + cancel( + pc.red(`Invalid ORM type: ${options.orm}. Must be drizzle or prisma.`), + ); + process.exit(1); + } + config.orm = options.orm as ProjectOrm; } - if ( - options.dbSetup && - !["turso", "prisma-postgres", "mongodb-atlas", "none"].includes( - options.dbSetup, - ) - ) { - cancel( - pc.red( - `Invalid database setup: ${options.dbSetup}. Must be turso, prisma-postgres, mongodb-atlas, or none.`, - ), - ); - process.exit(1); - } - - if (options.database === "mongodb" && options.orm === "drizzle") { + if (config.database === "mongodb" && config.orm === "drizzle") { cancel( pc.red( "MongoDB is only available with Prisma. Cannot use --database mongodb with --orm drizzle", @@ -172,7 +168,81 @@ function validateOptions(options: CLIOptions): void { process.exit(1); } - if (options.database === "none") { + if (options.dbSetup) { + if ( + !["turso", "prisma-postgres", "mongodb-atlas", "none"].includes( + options.dbSetup, + ) + ) { + cancel( + pc.red( + `Invalid database setup: ${options.dbSetup}. Must be turso, prisma-postgres, mongodb-atlas, or none.`, + ), + ); + process.exit(1); + } + + if (options.dbSetup !== "none") { + config.dbSetup = options.dbSetup as ProjectDBSetup; + + if (options.dbSetup === "turso") { + if (options.database && options.database !== "sqlite") { + cancel( + pc.red( + `Turso setup requires a SQLite database. Cannot use --db-setup turso with --database ${options.database}`, + ), + ); + process.exit(1); + } + config.database = "sqlite"; + + if (options.orm === "prisma") { + cancel( + pc.red( + "Turso setup is not compatible with Prisma. Cannot use --db-setup turso with --orm prisma", + ), + ); + process.exit(1); + } + config.orm = "drizzle"; + } else if (options.dbSetup === "prisma-postgres") { + if (options.database && options.database !== "postgres") { + cancel( + pc.red( + "Prisma PostgreSQL setup requires PostgreSQL database. Cannot use --db-setup prisma-postgres with a different database type.", + ), + ); + process.exit(1); + } + config.database = "postgres"; + + if (options.orm && options.orm !== "prisma") { + cancel( + pc.red( + "Prisma PostgreSQL setup requires Prisma ORM. Cannot use --db-setup prisma-postgres with a different ORM.", + ), + ); + process.exit(1); + } + config.orm = "prisma"; + } else if (options.dbSetup === "mongodb-atlas") { + if (options.database && options.database !== "mongodb") { + cancel( + pc.red( + "MongoDB Atlas setup requires MongoDB database. Cannot use --db-setup mongodb-atlas with a different database type.", + ), + ); + process.exit(1); + } + config.database = "mongodb"; + config.orm = "prisma"; + } + } else { + config.dbSetup = "none"; + } + } + + if (config.database === "none") { if (options.auth === true) { cancel( pc.red( @@ -201,128 +271,30 @@ function validateOptions(options: CLIOptions): void { } } - if (options.dbSetup === "turso") { - if (options.database && options.database !== "sqlite") { - cancel( - pc.red( - `Turso setup requires a SQLite database. Cannot use --db-setup turso with --database ${options.database}`, - ), - ); - process.exit(1); - } - - if (options.orm === "prisma") { - cancel( - pc.red( - "Turso setup is not compatible with Prisma. Cannot use --db-setup turso with --orm prisma", - ), - ); - process.exit(1); - } + if ("auth" in options) { + config.auth = options.auth; } - if (options.dbSetup === "prisma-postgres") { - if (options.database && options.database !== "postgres") { + if (options.backend) { + if (!["hono", "elysia", "express"].includes(options.backend)) { cancel( pc.red( - "Prisma PostgreSQL setup requires PostgreSQL database. Cannot use --db-setup prisma-postgres with a different database type.", - ), - ); - process.exit(1); - } - - if (options.orm && options.orm !== "prisma") { - cancel( - pc.red( - "Prisma PostgreSQL setup requires Prisma ORM. Cannot use --db-setup prisma-postgres with a different ORM.", + `Invalid backend framework: ${options.backend}. Must be hono, elysia, or express.`, ), ); process.exit(1); } + config.backend = options.backend as ProjectBackend; } - if (options.dbSetup === "mongodb-atlas") { - if (options.database && options.database !== "mongodb") { + if (options.runtime) { + if (!["bun", "node"].includes(options.runtime)) { cancel( - pc.red( - "MongoDB Atlas setup requires MongoDB database. Cannot use --db-setup mongodb-atlas with a different database type.", - ), - ); - process.exit(1); - } - } - - if ( - options.packageManager && - !["npm", "pnpm", "bun"].includes(options.packageManager) - ) { - cancel( - pc.red( - `Invalid package manager: ${options.packageManager}. Must be npm, pnpm, or bun.`, - ), - ); - process.exit(1); - } - - if ( - options.backend && - !["hono", "elysia", "express"].includes(options.backend) - ) { - cancel( - pc.red( - `Invalid backend framework: ${options.backend}. Must be hono, elysia, or express.`, - ), - ); - process.exit(1); - } - - if (options.runtime && !["bun", "node"].includes(options.runtime)) { - cancel(pc.red(`Invalid runtime: ${options.runtime}. Must be bun or node.`)); - process.exit(1); - } - - if ( - options.examples && - Array.isArray(options.examples) && - options.examples.length > 0 - ) { - const validExamples = ["todo", "ai"]; - const invalidExamples = options.examples.filter( - (example: string) => !validExamples.includes(example), - ); - - if (invalidExamples.length > 0) { - cancel( - pc.red( - `Invalid example(s): ${invalidExamples.join(", ")}. Valid options are: ${validExamples.join(", ")}.`, - ), - ); - process.exit(1); - } - - if (options.examples.includes("ai") && options.backend === "elysia") { - cancel( - pc.red( - "AI example is only compatible with Hono backend. Cannot use --examples ai with --backend elysia", - ), - ); - process.exit(1); - } - - if ( - options.frontend && - !options.frontend.some((f) => - ["tanstack-router", "react-router", "tanstack-start"].includes(f), - ) && - !options.frontend.includes("none") - ) { - cancel( - pc.red( - "Examples require a web frontend. Cannot use --examples with --frontend native only", - ), + pc.red(`Invalid runtime: ${options.runtime}. Must be bun or node.`), ); process.exit(1); } + config.runtime = options.runtime as ProjectRuntime; } if (options.frontend && options.frontend.length > 0) { @@ -346,33 +318,42 @@ function validateOptions(options: CLIOptions): void { process.exit(1); } - const webFrontends = options.frontend.filter( - (f) => - f === "tanstack-router" || - f === "react-router" || - f === "tanstack-start", - ); - - if (webFrontends.length > 1) { - cancel( - pc.red( - "Cannot select multiple web frameworks. Choose only one of: tanstack-router, tanstack-start, react-router", - ), + if (options.frontend.includes("none")) { + if (options.frontend.length > 1) { + cancel(pc.red(`Cannot combine 'none' with other frontend options.`)); + process.exit(1); + } + config.frontend = []; + } else { + const validOptions = options.frontend.filter( + (f): f is ProjectFrontend => + f === "tanstack-router" || + f === "react-router" || + f === "tanstack-start" || + f === "native", ); - process.exit(1); - } - if (options.frontend.includes("none") && options.frontend.length > 1) { - cancel(pc.red(`Cannot combine 'none' with other frontend options.`)); - process.exit(1); + const webFrontends = validOptions.filter( + (f) => + f === "tanstack-router" || + f === "react-router" || + f === "tanstack-start", + ); + + if (webFrontends.length > 1) { + cancel( + pc.red( + "Cannot select multiple web frameworks. Choose only one of: tanstack-router, tanstack-start, react-router", + ), + ); + process.exit(1); + } + + config.frontend = validOptions; } } - if ( - options.addons && - Array.isArray(options.addons) && - options.addons.length > 0 - ) { + if (options.addons && options.addons.length > 0) { const validAddons = ["pwa", "tauri", "biome", "husky", "none"]; const invalidAddons = options.addons.filter( (addon: string) => !validAddons.includes(addon), @@ -387,108 +368,14 @@ function validateOptions(options: CLIOptions): void { process.exit(1); } - if (options.addons.includes("none") && options.addons.length > 1) { - cancel(pc.red(`Cannot combine 'none' with other addons.`)); - process.exit(1); - } - - const webSpecificAddons = ["pwa", "tauri"]; - const hasWebSpecificAddons = options.addons.some((addon) => - webSpecificAddons.includes(addon), - ); - - if ( - hasWebSpecificAddons && - options.frontend && - !options.frontend.some((f) => - ["tanstack-router", "react-router"].includes(f), - ) - ) { - cancel( - pc.red( - `PWA and Tauri addons require tanstack-router or react-router. Cannot use --addons ${options.addons - .filter((a) => webSpecificAddons.includes(a)) - .join(", ")} with incompatible frontend options.`, - ), - ); - process.exit(1); - } - } -} - -function processFlags( - options: CLIOptions, - projectDirectory?: string, -): Partial { - let frontend: ProjectFrontend[] | undefined = undefined; - - if (options.frontend) { - if (options.frontend.includes("none")) { - frontend = []; - } else { - frontend = options.frontend.filter( - (f): f is ProjectFrontend => - f === "tanstack-router" || - f === "react-router" || - f === "tanstack-start" || - f === "native", - ); - - const webFrontends = frontend.filter( - (f) => - f === "tanstack-router" || - f === "react-router" || - f === "tanstack-start", - ); - - if (webFrontends.length > 1) { - const firstWebFrontend = webFrontends[0]; - frontend = frontend.filter( - (f) => f === "native" || f === firstWebFrontend, - ); - } - } - } - - let examples: ProjectExamples[] | undefined; - if ("examples" in options) { - if (options.examples === false) { - examples = []; - } else if (Array.isArray(options.examples)) { - examples = options.examples.filter( - (ex): ex is ProjectExamples => ex === "todo" || ex === "ai", - ); - - if ( - frontend && - frontend.length > 0 && - !frontend.some((f) => - ["tanstack-router", "react-router", "tanstack-start"].includes(f), - ) - ) { - examples = []; - log.warn( - pc.yellow("Examples require web frontend - ignoring examples flag"), - ); - } - - if (examples.includes("ai") && options.backend === "elysia") { - examples = examples.filter((ex) => ex !== "ai"); - log.warn( - pc.yellow( - "AI example is not compatible with Elysia - removing AI example", - ), - ); - } - } - } - - let addons: ProjectAddons[] | undefined; - if (options.addons && Array.isArray(options.addons)) { if (options.addons.includes("none")) { - addons = []; + if (options.addons.length > 1) { + cancel(pc.red(`Cannot combine 'none' with other addons.`)); + process.exit(1); + } + config.addons = []; } else { - addons = options.addons.filter( + const validOptions = options.addons.filter( (addon): addon is ProjectAddons => addon === "pwa" || addon === "tauri" || @@ -496,85 +383,111 @@ function processFlags( addon === "husky", ); - const hasCompatibleWebFrontend = frontend?.some( + const webSpecificAddons = ["pwa", "tauri"]; + const hasWebSpecificAddons = validOptions.some((addon) => + webSpecificAddons.includes(addon), + ); + + const hasCompatibleWebFrontend = config.frontend?.some( (f) => f === "tanstack-router" || f === "react-router", ); - if (!hasCompatibleWebFrontend) { - const webSpecificAddons = ["pwa", "tauri"]; - const filteredAddons = addons.filter( - (addon) => !webSpecificAddons.includes(addon), + if (hasWebSpecificAddons && !hasCompatibleWebFrontend) { + cancel( + pc.red( + "PWA and Tauri addons require tanstack-router or react-router. Cannot use these addons with your frontend selection.", + ), ); - - if (filteredAddons.length !== addons.length) { - log.warn( - pc.yellow( - "PWA and Tauri addons require tanstack-router or react-router - removing these addons", - ), - ); - addons = filteredAddons; - } + process.exit(1); } - if (addons.includes("husky") && !addons.includes("biome")) { - addons.push("biome"); + if (validOptions.includes("husky") && !validOptions.includes("biome")) { + validOptions.push("biome"); } + + config.addons = validOptions; } } - let database = options.database as ProjectDatabase | undefined; - let orm = options.orm as ProjectOrm | undefined; - const auth = "auth" in options ? options.auth : undefined; + if ("examples" in options) { + if (options.examples === false) { + config.examples = []; + } else if (Array.isArray(options.examples)) { + const validExamples = ["todo", "ai"]; + const invalidExamples = options.examples.filter( + (example: string) => !validExamples.includes(example), + ); - const backend = options.backend as ProjectBackend | undefined; - const runtime = options.runtime as ProjectRuntime | undefined; - const packageManager = options.packageManager as - | ProjectPackageManager - | undefined; - - let dbSetup: ProjectDBSetup | undefined = undefined; - if (options.dbSetup) { - if (options.dbSetup === "none") { - dbSetup = "none"; - } else { - dbSetup = options.dbSetup as ProjectDBSetup; - - if (dbSetup === "turso") { - database = "sqlite"; - - if (orm === "prisma") { - log.warn( - pc.yellow( - "Turso is not compatible with Prisma - switching to Drizzle", - ), - ); - orm = "drizzle"; - } - } else if (dbSetup === "prisma-postgres") { - database = "postgres"; - orm = "prisma"; - } else if (dbSetup === "mongodb-atlas") { - database = "mongodb"; - orm = "prisma"; + if (invalidExamples.length > 0) { + cancel( + pc.red( + `Invalid example(s): ${invalidExamples.join(", ")}. Valid options are: ${validExamples.join(", ")}.`, + ), + ); + process.exit(1); } + + if ( + options.examples.includes("ai") && + (options.backend === "elysia" || config.backend === "elysia") + ) { + cancel( + pc.red( + "AI example is only compatible with Hono backend. Cannot use --examples ai with --backend elysia", + ), + ); + process.exit(1); + } + + const hasWebFrontend = config.frontend?.some((f) => + ["tanstack-router", "react-router", "tanstack-start"].includes(f), + ); + + if ( + options.examples.length > 0 && + !hasWebFrontend && + (!options.frontend || + !options.frontend.some((f) => + ["tanstack-router", "react-router", "tanstack-start"].includes(f), + )) + ) { + cancel( + pc.red( + "Examples require a web frontend (tanstack-router, react-router, or tanstack-start). Cannot use --examples without a compatible frontend.", + ), + ); + process.exit(1); + } + + config.examples = options.examples.filter( + (ex): ex is ProjectExamples => ex === "todo" || ex === "ai", + ); } } - const config: Partial = {}; + if (options.packageManager) { + if (!["npm", "pnpm", "bun"].includes(options.packageManager)) { + cancel( + pc.red( + `Invalid package manager: ${options.packageManager}. Must be npm, pnpm, or bun.`, + ), + ); + process.exit(1); + } + config.packageManager = options.packageManager as ProjectPackageManager; + } - if (projectDirectory) config.projectName = projectDirectory; - if (database !== undefined) config.database = database; - if (orm !== undefined) config.orm = orm; - if (auth !== undefined) config.auth = auth; - if (packageManager) config.packageManager = packageManager; - if ("git" in options) config.git = options.git; - if ("install" in options) config.noInstall = !options.install; - if (dbSetup !== undefined) config.dbSetup = dbSetup; - if (backend) config.backend = backend; - if (runtime) config.runtime = runtime; - if (frontend !== undefined) config.frontend = frontend; - if (addons !== undefined) config.addons = addons; - if (examples !== undefined) config.examples = examples; + if ("git" in options) { + config.git = options.git; + } + + if ("install" in options) { + config.noInstall = !options.install; + } + + if (projectDirectory) { + config.projectName = projectDirectory; + } return config; } diff --git a/apps/cli/src/prompts/config-prompts.ts b/apps/cli/src/prompts/config-prompts.ts index 03ca9a2..2516cea 100644 --- a/apps/cli/src/prompts/config-prompts.ts +++ b/apps/cli/src/prompts/config-prompts.ts @@ -45,27 +45,6 @@ type PromptGroupResults = { export async function gatherConfig( flags: Partial, ): Promise { - if (flags.dbSetup) { - if (flags.dbSetup === "turso") { - flags.database = "sqlite"; - - if (flags.orm === "prisma") { - log.warn( - pc.yellow( - "Turso is not compatible with Prisma - switching to Drizzle", - ), - ); - flags.orm = "drizzle"; - } - } else if (flags.dbSetup === "prisma-postgres") { - flags.database = "postgres"; - flags.orm = "prisma"; - } else if (flags.dbSetup === "mongodb-atlas") { - flags.database = "mongodb"; - flags.orm = "prisma"; - } - } - const result = await group( { projectName: async () => { @@ -84,7 +63,11 @@ export async function gatherConfig( results.frontend, ), dbSetup: ({ results }) => - getDBSetupChoice(results.database ?? "none", flags.dbSetup), + getDBSetupChoice( + results.database ?? "none", + flags.dbSetup, + results.orm, + ), addons: ({ results }) => getAddonsChoice(flags.addons, results.frontend), examples: ({ results }) => getExamplesChoice( diff --git a/apps/cli/src/prompts/database.ts b/apps/cli/src/prompts/database.ts index 484b7ab..31c220d 100644 --- a/apps/cli/src/prompts/database.ts +++ b/apps/cli/src/prompts/database.ts @@ -26,6 +26,11 @@ export async function getDatabaseChoice( label: "PostgreSQL", hint: "powerful, open source object-relational database system", }, + { + value: "mysql", + label: "MySQL", + hint: "popular open-source relational database system", + }, { value: "mongodb", label: "MongoDB", diff --git a/apps/cli/src/prompts/db-setup.ts b/apps/cli/src/prompts/db-setup.ts index 147bb16..330a0ca 100644 --- a/apps/cli/src/prompts/db-setup.ts +++ b/apps/cli/src/prompts/db-setup.ts @@ -1,13 +1,18 @@ import { cancel, isCancel, select } from "@clack/prompts"; import pc from "picocolors"; -import type { ProjectDBSetup } from "../types"; +import type { ProjectDBSetup, ProjectOrm } from "../types"; export async function getDBSetupChoice( databaseType: string, dbSetup: ProjectDBSetup | undefined, + orm?: ProjectOrm, ): Promise { if (dbSetup !== undefined) return dbSetup as ProjectDBSetup; + if (databaseType === "sqlite" && orm === "prisma") { + return "none"; + } + let options: Array<{ value: ProjectDBSetup; label: string; hint: string }> = []; @@ -16,7 +21,7 @@ export async function getDBSetupChoice( { value: "turso" as const, label: "Turso", - hint: "SQLite for Production. Powered by libSQL.", + hint: "SQLite for Production. Powered by libSQL", }, { value: "none" as const, label: "None", hint: "Manual setup" }, ]; diff --git a/apps/cli/src/types.ts b/apps/cli/src/types.ts index b265637..c2725fd 100644 --- a/apps/cli/src/types.ts +++ b/apps/cli/src/types.ts @@ -1,4 +1,9 @@ -export type ProjectDatabase = "sqlite" | "postgres" | "mongodb" | "none"; +export type ProjectDatabase = + | "sqlite" + | "postgres" + | "mongodb" + | "mysql" + | "none"; export type ProjectOrm = "drizzle" | "prisma" | "none"; export type ProjectPackageManager = "npm" | "pnpm" | "bun"; export type ProjectAddons = "pwa" | "biome" | "tauri" | "husky"; diff --git a/apps/cli/template/base/turbo.json b/apps/cli/template/base/turbo.json index 487d7dc..f146972 100644 --- a/apps/cli/template/base/turbo.json +++ b/apps/cli/template/base/turbo.json @@ -18,10 +18,12 @@ "persistent": true }, "db:push": { - "cache": false + "cache": false, + "persistent": true }, "db:studio": { - "cache": false + "cache": false, + "persistent": true } } } diff --git a/apps/cli/template/examples/todo/apps/server/src/routers/with-drizzle-todo.ts b/apps/cli/template/examples/todo/apps/server/src/routers/with-drizzle-todo.ts index 9cfd484..52a1b1b 100644 --- a/apps/cli/template/examples/todo/apps/server/src/routers/with-drizzle-todo.ts +++ b/apps/cli/template/examples/todo/apps/server/src/routers/with-drizzle-todo.ts @@ -12,12 +12,9 @@ export const todoRouter = router({ create: publicProcedure .input(z.object({ text: z.string().min(1) })) .mutation(async ({ input }) => { - return await db - .insert(todo) - .values({ - text: input.text, - }) - .returning(); + return await db.insert(todo).values({ + text: input.text, + }); }), toggle: publicProcedure @@ -26,16 +23,12 @@ export const todoRouter = router({ return await db .update(todo) .set({ completed: input.completed }) - .where(eq(todo.id, input.id)) - .returning(); + .where(eq(todo.id, input.id)); }), delete: publicProcedure .input(z.object({ id: z.number() })) .mutation(async ({ input }) => { - return await db - .delete(todo) - .where(eq(todo.id, input.id)) - .returning(); + return await db.delete(todo).where(eq(todo.id, input.id)); }), }); diff --git a/apps/cli/template/with-auth/apps/server/src/with-drizzle-mysql-lib/auth.ts b/apps/cli/template/with-auth/apps/server/src/with-drizzle-mysql-lib/auth.ts new file mode 100644 index 0000000..c1b8e59 --- /dev/null +++ b/apps/cli/template/with-auth/apps/server/src/with-drizzle-mysql-lib/auth.ts @@ -0,0 +1,15 @@ +import { betterAuth } from "better-auth"; +import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import { db } from "../db"; +import * as schema from "../db/schema/auth"; + +export const auth = betterAuth({ + database: drizzleAdapter(db, { + provider: "mysql", + schema: schema, + }), + trustedOrigins: [process.env.CORS_ORIGIN || ""], + emailAndPassword: { + enabled: true, + }, +}); diff --git a/apps/cli/template/with-auth/apps/server/src/with-prisma-mongodb-lib/auth.ts b/apps/cli/template/with-auth/apps/server/src/with-prisma-mongodb-lib/auth.ts index 4ef6689..c966478 100644 --- a/apps/cli/template/with-auth/apps/server/src/with-prisma-mongodb-lib/auth.ts +++ b/apps/cli/template/with-auth/apps/server/src/with-prisma-mongodb-lib/auth.ts @@ -8,10 +8,4 @@ export const auth = betterAuth({ }), trustedOrigins: [process.env.CORS_ORIGIN || ""], emailAndPassword: { enabled: true }, - advanced: { - defaultCookieAttributes: { - sameSite: "none", - secure: true, - }, - }, }); diff --git a/apps/cli/template/with-auth/apps/server/src/with-prisma-mysql-lib/auth.ts b/apps/cli/template/with-auth/apps/server/src/with-prisma-mysql-lib/auth.ts new file mode 100644 index 0000000..896d8a0 --- /dev/null +++ b/apps/cli/template/with-auth/apps/server/src/with-prisma-mysql-lib/auth.ts @@ -0,0 +1,11 @@ +import { betterAuth } from "better-auth"; +import { prismaAdapter } from "better-auth/adapters/prisma"; +import prisma from "../../prisma"; + +export const auth = betterAuth({ + database: prismaAdapter(prisma, { + provider: "mysql", + }), + trustedOrigins: [process.env.CORS_ORIGIN || ""], + emailAndPassword: { enabled: true }, +}); diff --git a/apps/cli/template/with-auth/apps/server/src/with-prisma-postgres-lib/auth.ts b/apps/cli/template/with-auth/apps/server/src/with-prisma-postgres-lib/auth.ts index 7d2c026..2e62bff 100644 --- a/apps/cli/template/with-auth/apps/server/src/with-prisma-postgres-lib/auth.ts +++ b/apps/cli/template/with-auth/apps/server/src/with-prisma-postgres-lib/auth.ts @@ -8,10 +8,4 @@ export const auth = betterAuth({ }), trustedOrigins: [process.env.CORS_ORIGIN || ""], emailAndPassword: { enabled: true }, - advanced: { - defaultCookieAttributes: { - sameSite: "none", - secure: true, - }, - }, }); diff --git a/apps/cli/template/with-auth/apps/server/src/with-prisma-sqlite-lib/auth.ts b/apps/cli/template/with-auth/apps/server/src/with-prisma-sqlite-lib/auth.ts index e549c72..c72c675 100644 --- a/apps/cli/template/with-auth/apps/server/src/with-prisma-sqlite-lib/auth.ts +++ b/apps/cli/template/with-auth/apps/server/src/with-prisma-sqlite-lib/auth.ts @@ -8,10 +8,4 @@ export const auth = betterAuth({ }), trustedOrigins: [process.env.CORS_ORIGIN || ""], emailAndPassword: { enabled: true }, - advanced: { - defaultCookieAttributes: { - sameSite: "none", - secure: true, - }, - }, }); diff --git a/apps/cli/template/with-drizzle-mysql/apps/server/drizzle.config.ts b/apps/cli/template/with-drizzle-mysql/apps/server/drizzle.config.ts new file mode 100644 index 0000000..5188a24 --- /dev/null +++ b/apps/cli/template/with-drizzle-mysql/apps/server/drizzle.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + schema: "./src/db/schema", + out: "./src/db/migrations", + dialect: "mysql", + dbCredentials: { + url: process.env.DATABASE_URL || "", + }, +}); diff --git a/apps/cli/template/with-drizzle-mysql/apps/server/src/db/index.ts b/apps/cli/template/with-drizzle-mysql/apps/server/src/db/index.ts new file mode 100644 index 0000000..4cd1cea --- /dev/null +++ b/apps/cli/template/with-drizzle-mysql/apps/server/src/db/index.ts @@ -0,0 +1,3 @@ +import { drizzle } from "drizzle-orm/mysql2"; + +export const db = drizzle({ connection: { uri: process.env.DATABASE_URL } }); diff --git a/apps/cli/template/with-drizzle-mysql/apps/server/src/db/schema/auth.ts b/apps/cli/template/with-drizzle-mysql/apps/server/src/db/schema/auth.ts new file mode 100644 index 0000000..1215597 --- /dev/null +++ b/apps/cli/template/with-drizzle-mysql/apps/server/src/db/schema/auth.ts @@ -0,0 +1,58 @@ +import { + mysqlTable, + varchar, + text, + timestamp, + boolean, +} from "drizzle-orm/mysql-core"; + +export const user = mysqlTable("user", { + id: varchar("id", { length: 36 }).primaryKey(), + name: text("name").notNull(), + email: varchar("email", { length: 255 }).notNull().unique(), + emailVerified: boolean("email_verified").notNull(), + image: text("image"), + createdAt: timestamp("created_at").notNull(), + updatedAt: timestamp("updated_at").notNull(), +}); + +export const session = mysqlTable("session", { + id: varchar("id", { length: 36 }).primaryKey(), + expiresAt: timestamp("expires_at").notNull(), + token: varchar("token", { length: 255 }).notNull().unique(), + createdAt: timestamp("created_at").notNull(), + updatedAt: timestamp("updated_at").notNull(), + ipAddress: text("ip_address"), + userAgent: text("user_agent"), + userId: varchar("user_id", { length: 36 }) + .notNull() + .references(() => user.id, { onDelete: "cascade" }), +}); + +export const account = mysqlTable("account", { + id: varchar("id", { length: 36 }).primaryKey(), + accountId: text("account_id").notNull(), + providerId: text("provider_id").notNull(), + userId: varchar("user_id", { length: 36 }) + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + accessToken: text("access_token"), + refreshToken: text("refresh_token"), + idToken: text("id_token"), + + accessTokenExpiresAt: timestamp("access_token_expires_at"), + refreshTokenExpiresAt: timestamp("refresh_token_expires_at"), + scope: text("scope"), + password: text("password"), + createdAt: timestamp("created_at").notNull(), + updatedAt: timestamp("updated_at").notNull(), +}); + +export const verification = mysqlTable("verification", { + id: varchar("id", { length: 36 }).primaryKey(), + identifier: text("identifier").notNull(), + value: text("value").notNull(), + expiresAt: timestamp("expires_at").notNull(), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), +}); diff --git a/apps/cli/template/with-drizzle-mysql/apps/server/src/db/schema/todo.ts b/apps/cli/template/with-drizzle-mysql/apps/server/src/db/schema/todo.ts new file mode 100644 index 0000000..f705357 --- /dev/null +++ b/apps/cli/template/with-drizzle-mysql/apps/server/src/db/schema/todo.ts @@ -0,0 +1,7 @@ +import { mysqlTable, varchar, int, boolean } from "drizzle-orm/mysql-core"; + +export const todo = mysqlTable("todo", { + id: int("id").primaryKey().autoincrement(), + text: varchar("text", { length: 255 }).notNull(), + completed: boolean("completed").default(false).notNull(), +}); diff --git a/apps/cli/template/with-prisma-mysql/apps/server/prisma/index.ts b/apps/cli/template/with-prisma-mysql/apps/server/prisma/index.ts new file mode 100644 index 0000000..34ab1b5 --- /dev/null +++ b/apps/cli/template/with-prisma-mysql/apps/server/prisma/index.ts @@ -0,0 +1,5 @@ +import { PrismaClient } from "@prisma/client"; + +let prisma = new PrismaClient(); + +export default prisma; diff --git a/apps/cli/template/with-prisma-mysql/apps/server/prisma/schema/auth.prisma b/apps/cli/template/with-prisma-mysql/apps/server/prisma/schema/auth.prisma new file mode 100644 index 0000000..f136d23 --- /dev/null +++ b/apps/cli/template/with-prisma-mysql/apps/server/prisma/schema/auth.prisma @@ -0,0 +1,59 @@ +model User { + id String @id + name String @db.Text + email String + emailVerified Boolean + image String? @db.Text + createdAt DateTime + updatedAt DateTime + sessions Session[] + accounts Account[] + + @@unique([email]) + @@map("user") +} + +model Session { + id String @id + expiresAt DateTime + token String + createdAt DateTime + updatedAt DateTime + ipAddress String? @db.Text + userAgent String? @db.Text + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([token]) + @@map("session") +} + +model Account { + id String @id + accountId String @db.Text + providerId String @db.Text + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + accessToken String? @db.Text + refreshToken String? @db.Text + idToken String? @db.Text + accessTokenExpiresAt DateTime? + refreshTokenExpiresAt DateTime? + scope String? @db.Text + password String? @db.Text + createdAt DateTime + updatedAt DateTime + + @@map("account") +} + +model Verification { + id String @id + identifier String @db.Text + value String @db.Text + expiresAt DateTime + createdAt DateTime? + updatedAt DateTime? + + @@map("verification") +} diff --git a/apps/cli/template/with-prisma-mysql/apps/server/prisma/schema/schema.prisma b/apps/cli/template/with-prisma-mysql/apps/server/prisma/schema/schema.prisma new file mode 100644 index 0000000..16ad822 --- /dev/null +++ b/apps/cli/template/with-prisma-mysql/apps/server/prisma/schema/schema.prisma @@ -0,0 +1,9 @@ +generator client { + provider = "prisma-client-js" + previewFeatures = ["prismaSchemaFolder"] +} + +datasource db { + provider = "mysql" + url = env("DATABASE_URL") +} diff --git a/apps/cli/template/with-prisma-mysql/apps/server/prisma/schema/todo.prisma b/apps/cli/template/with-prisma-mysql/apps/server/prisma/schema/todo.prisma new file mode 100644 index 0000000..4f23d6f --- /dev/null +++ b/apps/cli/template/with-prisma-mysql/apps/server/prisma/schema/todo.prisma @@ -0,0 +1,7 @@ +model Todo { + id Int @id @default(autoincrement()) + text String + completed Boolean @default(false) + + @@map("todo") +}