From 792885b9c45eb08f3a0db9b6255524f6eebcec2a Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Tue, 4 Mar 2025 09:33:31 +0530 Subject: [PATCH] add auth, drizzle, prisma setup logic with template --- .changeset/red-times-repair.md | 5 + apps/cli/src/constants.ts | 1 + apps/cli/src/helpers/auth-setup.ts | 547 +++--------------- apps/cli/src/helpers/create-project.ts | 208 ++++--- apps/cli/src/helpers/create-readme.ts | 226 +++++--- apps/cli/src/helpers/db-setup.ts | 283 ++++----- apps/cli/src/helpers/install-dependencies.ts | 45 ++ apps/cli/src/helpers/post-installation.ts | 20 +- apps/cli/src/helpers/turso-setup.ts | 204 +++++++ apps/cli/src/index.ts | 60 +- apps/cli/src/prompts/config-prompts.ts | 15 +- apps/cli/src/prompts/install.ts | 21 + apps/cli/src/prompts/orm.ts | 8 +- apps/cli/src/prompts/turso.ts | 18 + apps/cli/src/types.ts | 22 +- apps/cli/src/utils/display-config.ts | 6 + .../utils/generate-reproducible-command.ts | 56 +- apps/cli/src/utils/turso-cli.ts | 19 - apps/cli/template/base/package.json | 23 + .../base/packages/client/.env.example | 1 + .../template/base/packages/client/.gitignore | 21 + .../template/base/packages/client/.prettierrc | 4 + .../base/packages/client/components.json | 21 + .../template/base/packages/client/index.html | 13 + .../base/packages/client/package.json | 51 ++ .../packages/client/src/components/header.tsx | 45 ++ .../packages/client/src/components/loader.tsx | 9 + .../client/src/components/mode-toggle.tsx | 37 ++ .../client/src/components/sign-up-form.tsx | 157 +++++ .../client/src/components/theme-provider.tsx | 73 +++ .../client/src/components/ui/button.tsx | 57 ++ .../src/components/ui/dropdown-menu.tsx | 199 +++++++ .../client/src/components/ui/form.tsx | 179 ++++++ .../client/src/components/ui/input.tsx | 22 + .../client/src/components/ui/label.tsx | 24 + .../client/src/components/ui/skeleton.tsx | 15 + .../client/src/components/ui/sonner.tsx | 29 + .../client/src/components/user-menu.tsx | 62 ++ .../base/packages/client/src/index.css | 127 ++++ .../packages/client/src/lib/auth-client.ts | 5 + .../base/packages/client/src/lib/schemas.ts | 12 + .../base/packages/client/src/lib/utils.ts | 6 + .../base/packages/client/src/main.tsx | 77 +++ .../packages/client/src/routes/__root.tsx | 39 ++ .../base/packages/client/src/routes/about.tsx | 13 + .../packages/client/src/routes/dashboard.tsx | 41 ++ .../base/packages/client/src/routes/index.tsx | 19 + .../base/packages/client/src/utils/trpc.ts | 4 + .../base/packages/client/tsconfig.json | 18 + .../base/packages/client/vite.config.ts | 14 + .../template/base/packages/server/.gitignore | 36 ++ .../base/packages/server/package.json | 34 ++ .../base/packages/server/src/index.ts | 47 ++ .../base/packages/server/src/lib/context.ts | 18 + .../base/packages/server/src/lib/trpc.ts | 24 + .../base/packages/server/src/routers/index.ts | 15 + .../base/packages/server/tsconfig.json | 17 + apps/cli/template/base/turbo.json | 27 + .../with-drizzle/packages/server/_env | 4 + .../packages/server/drizzle.config.ts | 11 + .../packages/server/src/db/index.ts | 9 + .../packages/server/src/db/schema.ts | 55 ++ .../packages/server/src/lib/auth.ts | 15 + .../packages/server/src/db/index.ts | 13 + .../packages/server/src/db/schema.prisma | 92 +++ .../packages/server/src/lib/auth.ts | 9 + apps/cli/tsup.config.ts | 1 + biome.json | 5 +- 68 files changed, 2692 insertions(+), 921 deletions(-) create mode 100644 .changeset/red-times-repair.md create mode 100644 apps/cli/src/helpers/install-dependencies.ts create mode 100644 apps/cli/src/helpers/turso-setup.ts create mode 100644 apps/cli/src/prompts/install.ts create mode 100644 apps/cli/src/prompts/turso.ts delete mode 100644 apps/cli/src/utils/turso-cli.ts create mode 100644 apps/cli/template/base/package.json create mode 100644 apps/cli/template/base/packages/client/.env.example create mode 100644 apps/cli/template/base/packages/client/.gitignore create mode 100644 apps/cli/template/base/packages/client/.prettierrc create mode 100644 apps/cli/template/base/packages/client/components.json create mode 100644 apps/cli/template/base/packages/client/index.html create mode 100644 apps/cli/template/base/packages/client/package.json create mode 100644 apps/cli/template/base/packages/client/src/components/header.tsx create mode 100644 apps/cli/template/base/packages/client/src/components/loader.tsx create mode 100644 apps/cli/template/base/packages/client/src/components/mode-toggle.tsx create mode 100644 apps/cli/template/base/packages/client/src/components/sign-up-form.tsx create mode 100644 apps/cli/template/base/packages/client/src/components/theme-provider.tsx create mode 100644 apps/cli/template/base/packages/client/src/components/ui/button.tsx create mode 100644 apps/cli/template/base/packages/client/src/components/ui/dropdown-menu.tsx create mode 100644 apps/cli/template/base/packages/client/src/components/ui/form.tsx create mode 100644 apps/cli/template/base/packages/client/src/components/ui/input.tsx create mode 100644 apps/cli/template/base/packages/client/src/components/ui/label.tsx create mode 100644 apps/cli/template/base/packages/client/src/components/ui/skeleton.tsx create mode 100644 apps/cli/template/base/packages/client/src/components/ui/sonner.tsx create mode 100644 apps/cli/template/base/packages/client/src/components/user-menu.tsx create mode 100644 apps/cli/template/base/packages/client/src/index.css create mode 100644 apps/cli/template/base/packages/client/src/lib/auth-client.ts create mode 100644 apps/cli/template/base/packages/client/src/lib/schemas.ts create mode 100644 apps/cli/template/base/packages/client/src/lib/utils.ts create mode 100644 apps/cli/template/base/packages/client/src/main.tsx create mode 100644 apps/cli/template/base/packages/client/src/routes/__root.tsx create mode 100644 apps/cli/template/base/packages/client/src/routes/about.tsx create mode 100644 apps/cli/template/base/packages/client/src/routes/dashboard.tsx create mode 100644 apps/cli/template/base/packages/client/src/routes/index.tsx create mode 100644 apps/cli/template/base/packages/client/src/utils/trpc.ts create mode 100644 apps/cli/template/base/packages/client/tsconfig.json create mode 100644 apps/cli/template/base/packages/client/vite.config.ts create mode 100644 apps/cli/template/base/packages/server/.gitignore create mode 100644 apps/cli/template/base/packages/server/package.json create mode 100644 apps/cli/template/base/packages/server/src/index.ts create mode 100644 apps/cli/template/base/packages/server/src/lib/context.ts create mode 100644 apps/cli/template/base/packages/server/src/lib/trpc.ts create mode 100644 apps/cli/template/base/packages/server/src/routers/index.ts create mode 100644 apps/cli/template/base/packages/server/tsconfig.json create mode 100644 apps/cli/template/base/turbo.json create mode 100644 apps/cli/template/with-drizzle/packages/server/_env create mode 100644 apps/cli/template/with-drizzle/packages/server/drizzle.config.ts create mode 100644 apps/cli/template/with-drizzle/packages/server/src/db/index.ts create mode 100644 apps/cli/template/with-drizzle/packages/server/src/db/schema.ts create mode 100644 apps/cli/template/with-drizzle/packages/server/src/lib/auth.ts create mode 100644 apps/cli/template/with-prisma/packages/server/src/db/index.ts create mode 100644 apps/cli/template/with-prisma/packages/server/src/db/schema.prisma create mode 100644 apps/cli/template/with-prisma/packages/server/src/lib/auth.ts diff --git a/.changeset/red-times-repair.md b/.changeset/red-times-repair.md new file mode 100644 index 0000000..0996891 --- /dev/null +++ b/.changeset/red-times-repair.md @@ -0,0 +1,5 @@ +--- +"create-better-t-stack": minor +--- + +add auth, drizzle, prisma setup logic diff --git a/apps/cli/src/constants.ts b/apps/cli/src/constants.ts index 5af5846..61f5b2b 100644 --- a/apps/cli/src/constants.ts +++ b/apps/cli/src/constants.ts @@ -14,4 +14,5 @@ export const DEFAULT_CONFIG: ProjectConfig = { features: [], git: true, packageManager: "npm", + noInstall: false, }; diff --git a/apps/cli/src/helpers/auth-setup.ts b/apps/cli/src/helpers/auth-setup.ts index 6ead263..12c454b 100644 --- a/apps/cli/src/helpers/auth-setup.ts +++ b/apps/cli/src/helpers/auth-setup.ts @@ -1,496 +1,97 @@ -import crypto from "node:crypto"; import path from "node:path"; -import { log, spinner } from "@clack/prompts"; +import { log } from "@clack/prompts"; import fs from "fs-extra"; import pc from "picocolors"; +import { PKG_ROOT } from "../constants"; import type { ProjectConfig } from "../types"; export async function configureAuth( projectDir: string, - initialEnableAuth: boolean, + enableAuth: boolean, hasDatabase: boolean, - options?: ProjectConfig, -) { - let enableAuth = initialEnableAuth; + options: ProjectConfig, +): Promise { + const serverDir = path.join(projectDir, "packages/server"); + const clientDir = path.join(projectDir, "packages/client"); - if (!hasDatabase && enableAuth) { - log.warn( - pc.yellow( - "Authentication requires a database. Disabling authentication.", - ), - ); - enableAuth = false; - } + try { + if (!enableAuth) { + await fs.remove(path.join(clientDir, "src/components/sign-up-form.tsx")); + await fs.remove(path.join(clientDir, "src/components/user-menu.tsx")); + await fs.remove(path.join(clientDir, "src/lib/auth-client.ts")); + await fs.remove(path.join(clientDir, "src/lib/schemas.ts")); - if (enableAuth) { - const secret = crypto.randomBytes(32).toString("hex"); + await fs.remove(path.join(serverDir, "src/lib/auth.ts")); - const serverEnvPath = path.join(projectDir, "packages/server/.env"); - await fs.ensureFile(serverEnvPath); - let envContent = await fs.readFile(serverEnvPath, "utf-8").catch(() => ""); + const indexFilePath = path.join(serverDir, "src/index.ts"); + const indexContent = await fs.readFile(indexFilePath, "utf8"); + const updatedIndexContent = indexContent + .replace(/import { auth } from "\.\/lib\/auth";\n/, "") + .replace( + /app\.on\(\["POST", "GET"\], "\/api\/auth\/\*\*", \(c\) => auth\.handler\(c\.req\.raw\)\);\n\n/, + "", + ); + await fs.writeFile(indexFilePath, updatedIndexContent, "utf8"); - if (!envContent.includes("BETTER_AUTH_SECRET")) { - envContent += `\n# Better Auth Configuration\nBETTER_AUTH_SECRET="${secret}"\nBETTER_AUTH_URL="${process.env.BETTER_AUTH_URL || "http://localhost:3000"}"\nCORS_ORIGIN="${process.env.CORS_ORIGIN || "http://localhost:3001"}"\n`; - await fs.writeFile(serverEnvPath, envContent); - } - - const orm = options?.orm || "drizzle"; - const database = options?.database || "sqlite"; - const databaseProvider = database === "sqlite" ? "sqlite" : "postgresql"; - - await updatePackageJson(projectDir, true, orm); - - const configPath = path.join( - projectDir, - "packages/server/better-auth.config.js", - ); - const adapterConfig = - orm === "prisma" - ? `{ - name: "prisma", - options: { - provider: "${databaseProvider}", - schemaPath: "./prisma/schema.prisma", - } - }` - : `{ - name: "drizzle", - options: { - provider: "${databaseProvider}", - schemaPath: "./src/db/schema.ts", - } - }`; - - const configContent = `/** @type {import('better-auth').BetterAuthConfig} */ -module.exports = { - adapter: ${adapterConfig} -};`; - - await fs.writeFile(configPath, configContent); - - await createAuthFile(projectDir, orm, databaseProvider); - await createAuthClientFile(projectDir); - - if (orm === "prisma") { - await setupBasicPrisma(projectDir, databaseProvider); + const contextFilePath = path.join(serverDir, "src/lib/context.ts"); + const contextContent = await fs.readFile(contextFilePath, "utf8"); + const updatedContextContent = contextContent + .replace(/import { auth } from "\.\/auth";\n/, "") + .replace( + /const session = await auth\.api\.getSession\({\n\s+headers: hono\.req\.raw\.headers,\n\s+}\);/, + "const session = null;", + ); + await fs.writeFile(contextFilePath, updatedContextContent, "utf8"); + } else if (!hasDatabase) { + log.warn( + pc.yellow( + "Authentication enabled but no database selected. Auth will not function properly.", + ), + ); } else { - await fs.ensureDir(path.join(projectDir, "packages/server/src/db")); - } + const envPath = path.join(serverDir, ".env"); + const envExamplePath = path.join(serverDir, "_env"); - await updateServerIndex(projectDir, true); - - await updateContext(projectDir, true, orm); - } else { - await updatePackageJson(projectDir, false); - await updateAuthImplementations(projectDir, false); - await updateServerIndex(projectDir, false); - await updateContext(projectDir, false); - } -} - -async function updateServerIndex(projectDir: string, enableAuth: boolean) { - const serverIndexPath = path.join(projectDir, "packages/server/src/index.ts"); - - if (!(await fs.pathExists(serverIndexPath))) return; - - let content = await fs.readFile(serverIndexPath, "utf-8"); - - if (enableAuth) { - if (!content.includes('import { auth } from "./lib/auth"')) { - const importLines = content - .split("\n") - .findIndex( - (line) => line.startsWith("import") || line.startsWith("// import"), - ); - - const lines = content.split("\n"); - lines.splice(importLines + 1, 0, 'import { auth } from "./lib/auth";'); - content = lines.join("\n"); - } - - if (!content.includes('app.on(["POST", "GET"], "/api/auth/**"')) { - const appCreation = content.indexOf("app.use"); - if (appCreation !== -1) { - const insertPoint = content.indexOf("\n", appCreation) + 1; - const authRouteHandler = - '\n// Auth routes\napp.on(["POST", "GET"], "/api/auth/**", (c) => auth.handler(c.req.raw));\n'; - content = - content.slice(0, insertPoint) + - authRouteHandler + - content.slice(insertPoint); - } - } - } else { - content = content.replace(/import { auth } from "\.\/lib\/auth";?\n?/g, ""); - content = content.replace(/\/\/ Auth routes\n?/g, ""); - content = content.replace( - /app\.on\(\["POST", "GET"\], "\/api\/auth\/\*\*", \(c\) => auth\.handler\(c\.req\.raw\)\);?\n?/g, - "", - ); - } - - await fs.writeFile(serverIndexPath, content); -} - -async function updateContext( - projectDir: string, - enableAuth: boolean, - _orm?: string, -) { - const contextPath = path.join( - projectDir, - "packages/server/src/lib/context.ts", - ); - - if (!(await fs.pathExists(contextPath))) return; - - let content = await fs.readFile(contextPath, "utf-8"); - - if (enableAuth) { - if (!content.includes('import { auth } from "./auth"')) { - const importLines = content - .split("\n") - .findIndex( - (line) => line.startsWith("import") || line.startsWith("// import"), - ); - - const lines = content.split("\n"); - lines.splice(importLines + 1, 0, 'import { auth } from "./auth";'); - content = lines.join("\n"); - } - - if (!content.includes("const session =")) { - const createContextBody = content.indexOf( - "export async function createContext", - ); - if (createContextBody !== -1) { - const bodyStart = content.indexOf("{", createContextBody); - const nextLine = content.indexOf("\n", bodyStart) + 1; - - const sessionExtraction = - " // Get the session from the request\n" + - " const session = await auth.api.getSession({\n" + - " headers: hono.req.raw.headers,\n" + - " });\n\n"; - - content = - content.slice(0, nextLine) + - sessionExtraction + - content.slice(nextLine); + if (await fs.pathExists(envExamplePath)) { + await fs.copy(envExamplePath, envPath); + await fs.remove(envExamplePath); } - const returnIndex = content.lastIndexOf("return {"); - if (returnIndex !== -1) { - const returnEnd = content.indexOf("}", returnIndex); - const returnContent = content.substring(returnIndex, returnEnd); + if (options.orm === "prisma") { + const prismaAuthPath = path.join(serverDir, "src/lib/auth.ts"); + const defaultPrismaAuthPath = path.join( + PKG_ROOT, + "template/with-prisma/packages/server/src/lib/auth.ts", + ); - if (!returnContent.includes("session")) { - const updatedReturn = returnContent.replace( - "return {", - "return {\n session,", - ); - content = - content.slice(0, returnIndex) + - updatedReturn + - content.slice(returnEnd); + if ( + (await fs.pathExists(defaultPrismaAuthPath)) && + !(await fs.pathExists(prismaAuthPath)) + ) { + await fs.ensureDir(path.dirname(prismaAuthPath)); + await fs.copy(defaultPrismaAuthPath, prismaAuthPath); + } + } else if (options.orm === "drizzle") { + const drizzleAuthPath = path.join(serverDir, "src/lib/auth.ts"); + const defaultDrizzleAuthPath = path.join( + PKG_ROOT, + "template/with-drizzle/packages/server/src/lib/auth.ts", + ); + + if ( + (await fs.pathExists(defaultDrizzleAuthPath)) && + !(await fs.pathExists(drizzleAuthPath)) + ) { + await fs.ensureDir(path.dirname(drizzleAuthPath)); + await fs.copy(defaultDrizzleAuthPath, drizzleAuthPath); } } } - } else { - content = content.replace(/import { auth } from "\.\/auth";?\n?/g, ""); - content = content.replace(/\/\/ Get the session from the request\n?/g, ""); - content = content.replace( - /const session = await auth\.api\.getSession\(\{\n?.*headers: hono\.req\.raw\.headers,\n?.*\}\);?\n?/g, - "const session = null;\n", - ); - - if (!content.includes("const session = null")) { - const createContextBody = content.indexOf( - "export async function createContext", - ); - if (createContextBody !== -1) { - const bodyStart = content.indexOf("{", createContextBody); - const nextLine = content.indexOf("\n", bodyStart) + 1; - content = `${content.slice(0, nextLine)} const session = null;\n\n${content.slice(nextLine)}`; - } - } - } - - await fs.writeFile(contextPath, content); -} - -async function updatePackageJson( - projectDir: string, - enableAuth: boolean, - orm?: string, -) { - const clientPackageJsonPath = path.join( - projectDir, - "packages/client/package.json", - ); - const serverPackageJsonPath = path.join( - projectDir, - "packages/server/package.json", - ); - - if (enableAuth) { - if (await fs.pathExists(clientPackageJsonPath)) { - const clientPackageJson = await fs.readJson(clientPackageJsonPath); - clientPackageJson.dependencies = clientPackageJson.dependencies || {}; - clientPackageJson.dependencies["better-auth"] = "latest"; - await fs.writeJson(clientPackageJsonPath, clientPackageJson, { - spaces: 2, - }); - } - - if (await fs.pathExists(serverPackageJsonPath)) { - const serverPackageJson = await fs.readJson(serverPackageJsonPath); - serverPackageJson.dependencies = serverPackageJson.dependencies || {}; - serverPackageJson.dependencies["better-auth"] = "latest"; - - if (orm === "prisma") { - serverPackageJson.dependencies["@prisma/client"] = "latest"; - serverPackageJson.devDependencies = - serverPackageJson.devDependencies || {}; - serverPackageJson.devDependencies.prisma = "latest"; - } else if (orm === "drizzle") { - serverPackageJson.dependencies["drizzle-orm"] = "latest"; - serverPackageJson.devDependencies = - serverPackageJson.devDependencies || {}; - serverPackageJson.devDependencies["drizzle-kit"] = "latest"; - } - - await fs.writeJson(serverPackageJsonPath, serverPackageJson, { - spaces: 2, - }); - } - } else { - // Remove auth dependencies if disabling auth - if (await fs.pathExists(clientPackageJsonPath)) { - const clientPackageJson = await fs.readJson(clientPackageJsonPath); - if (clientPackageJson.dependencies?.["better-auth"]) { - clientPackageJson.dependencies = Object.fromEntries( - Object.entries(clientPackageJson.dependencies).filter( - ([key]) => key !== "better-auth", - ), - ); - } - await fs.writeJson(clientPackageJsonPath, clientPackageJson, { - spaces: 2, - }); - } - - if (await fs.pathExists(serverPackageJsonPath)) { - const serverPackageJson = await fs.readJson(serverPackageJsonPath); - if (serverPackageJson.dependencies?.["better-auth"]) { - serverPackageJson.dependencies = Object.fromEntries( - Object.entries(serverPackageJson.dependencies).filter( - ([key]) => key !== "better-auth", - ), - ); - } - if (serverPackageJson.devDependencies?.["@better-auth/cli"]) { - serverPackageJson.devDependencies = Object.fromEntries( - Object.entries(serverPackageJson.devDependencies).filter( - ([key]) => key !== "@better-auth/cli", - ), - ); - } - await fs.writeJson(serverPackageJsonPath, serverPackageJson, { - spaces: 2, - }); - } - } -} - -async function setupBasicPrisma(projectDir: string, databaseProvider: string) { - const prismaDir = path.join(projectDir, "packages/server/prisma"); - await fs.ensureDir(prismaDir); - - const schemaPath = path.join(prismaDir, "schema.prisma"); - const 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 = "${databaseProvider}" - url = env("DATABASE_URL") -} - -// Models will be added by running: -// npx @better-auth/cli generate -`; - - await fs.writeFile(schemaPath, schemaContent); - - const clientDir = path.join(projectDir, "packages/server/src/db"); - await fs.ensureDir(clientDir); - - const clientPath = path.join(clientDir, "client.ts"); - const clientContent = `import { PrismaClient } from '@prisma/client'; - -const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined }; - -export const prisma = globalForPrisma.prisma ?? new PrismaClient({ - log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'], -}); - -if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma; -`; - - await fs.writeFile(clientPath, clientContent); - - const indexPath = path.join(clientDir, "index.ts"); - const indexContent = `export * from './client'; -`; - await fs.writeFile(indexPath, indexContent); - - const envPath = path.join(projectDir, "packages/server/.env"); - let envContent = await fs.readFile(envPath, "utf-8").catch(() => ""); - - if (!envContent.includes("DATABASE_URL")) { - const defaultUrl = - databaseProvider === "sqlite" - ? "file:./prisma/dev.db" - : "postgresql://postgres:password@localhost:5432/better-t-stack"; - - envContent += `\n# Database\nDATABASE_URL="${defaultUrl}"\n`; - await fs.writeFile(envPath, envContent); - } -} - -async function createAuthFile( - projectDir: string, - orm: string, - databaseProvider: string, -) { - const authDir = path.join(projectDir, "packages/server/src/lib"); - await fs.ensureDir(authDir); - - const authFilePath = path.join(authDir, "auth.ts"); - - let authContent = ""; - - if (orm === "prisma") { - authContent = `import { betterAuth } from "better-auth"; -import { prismaAdapter } from "better-auth/adapters/prisma"; -import { prisma } from "../db/client"; - -export const auth = betterAuth({ - database: prismaAdapter(prisma, { - provider: "${databaseProvider}", - }), - trustedOrigins: [process.env.CORS_ORIGIN!], - emailAndPassword: { - enabled: true, - }, - session: { - secret: process.env.BETTER_AUTH_SECRET!, - }, -});`; - } else { - authContent = `import { betterAuth } from "better-auth"; -import { drizzleAdapter } from "better-auth/adapters/drizzle"; -import { db } from "../db"; -import * as schema from "../db/schema"; - -export const auth = betterAuth({ - database: drizzleAdapter(db, { - provider: "${databaseProvider}", - schema: schema, - }), - trustedOrigins: [process.env.CORS_ORIGIN!], - emailAndPassword: { - enabled: true, - }, - session: { - secret: process.env.BETTER_AUTH_SECRET!, - }, -});`; - } - - await fs.writeFile(authFilePath, authContent); -} - -async function createAuthClientFile(projectDir: string) { - const libDir = path.join(projectDir, "packages/client/src/lib"); - await fs.ensureDir(libDir); - - const authClientPath = path.join(libDir, "auth-client.ts"); - const authClientContent = `import { createAuthClient } from "better-auth/react"; - -export const authClient = createAuthClient({ - baseURL: import.meta.env.VITE_SERVER_URL, -}); - -// Export specific methods if needed -export const { signIn, signUp, useSession } = authClient; -`; - - await fs.writeFile(authClientPath, authClientContent); -} - -async function updateAuthImplementations( - projectDir: string, - enableAuth: boolean, -) { - if (enableAuth) { - } else { - const filesToRemove = [ - path.join(projectDir, "packages/server/src/lib/auth.ts"), - path.join(projectDir, "packages/server/better-auth.config.js"), - path.join(projectDir, "packages/client/src/lib/auth-client.ts"), - path.join(projectDir, "packages/client/src/components/sign-up-form.tsx"), - path.join(projectDir, "packages/client/src/components/user-menu.tsx"), - ]; - - for (const file of filesToRemove) { - if (await fs.pathExists(file)) { - await fs.remove(file); - } - } - - const routeFiles = [ - path.join(projectDir, "packages/client/src/routes/index.tsx"), - path.join(projectDir, "packages/client/src/routes/dashboard.tsx"), - path.join(projectDir, "packages/client/src/components/header.tsx"), - ]; - - for (const file of routeFiles) { - if (await fs.pathExists(file)) { - let content = await fs.readFile(file, "utf-8"); - - content = content.replace( - /import SignUp from "@\/components\/sign-up-form";/, - "", - ); - content = content.replace(//, ""); - - content = content.replace( - /import { authClient } from "@\/lib\/auth-client";/, - "", - ); - content = content.replace( - /import { (?:signIn, signUp, )?useSession } from "@\/lib\/auth-client";/, - "", - ); - content = content.replace( - /const { data: session, isPending } = useSession\(\);/, - "", - ); - content = content.replace( - /useEffect\(\(\) => \{\s*if \(!session && !isPending\) \{\s*navigate\(\{\s*to: "\/",\s*\}\);\s*\}\s*\}, \[session, isPending\]\);/, - "", - ); - - content = content.replace(/import UserMenu from ".\/user-menu";/, ""); - content = content.replace(//, ""); - - await fs.writeFile(file, content); - } + } catch (error) { + log.error(pc.red("Failed to configure authentication")); + if (error instanceof Error) { + log.error(pc.red(error.message)); } + throw error; } } diff --git a/apps/cli/src/helpers/create-project.ts b/apps/cli/src/helpers/create-project.ts index cad97f8..32799cb 100644 --- a/apps/cli/src/helpers/create-project.ts +++ b/apps/cli/src/helpers/create-project.ts @@ -1,131 +1,116 @@ import path from "node:path"; -import { cancel, confirm, isCancel, log, spinner, tasks } from "@clack/prompts"; -import degit from "degit"; +import { cancel, spinner } from "@clack/prompts"; import { $ } from "execa"; import fs from "fs-extra"; import pc from "picocolors"; +import { PKG_ROOT } from "../constants"; import type { ProjectConfig } from "../types"; import { configureAuth } from "./auth-setup"; import { createReadme } from "./create-readme"; -import { setupTurso } from "./db-setup"; +import { setupDatabase } from "./db-setup"; import { setupFeatures } from "./feature-setup"; import { displayPostInstallInstructions } from "./post-installation"; -export async function createProject(options: ProjectConfig) { +export async function createProject(options: ProjectConfig): Promise { const s = spinner(); const projectDir = path.resolve(process.cwd(), options.projectName); - let shouldInstallDeps = false; try { - const tasksList = [ - { - title: "Creating project directory", - task: async () => { - await fs.ensureDir(projectDir); - }, - }, - { - title: "Cloning template repository", - task: async () => { - try { - const emitter = degit("better-t-stack/Better-T-Stack#bare"); - await emitter.clone(projectDir); - } catch (error) { - log.error(pc.red("Failed to clone template repository")); - if (error instanceof Error) { - log.error(pc.red(error.message)); - } - throw error; - } - }, - }, - ]; + await fs.ensureDir(projectDir); - if (options.database === "none") { - tasksList.push({ - title: "Removing database configuration", - task: async () => { - await fs.remove(path.join(projectDir, "packages/server/src/db")); - }, - }); + const templateDir = path.join(PKG_ROOT, "template/base"); + if (!(await fs.pathExists(templateDir))) { + throw new Error(`Template directory not found: ${templateDir}`); } + await fs.copy(templateDir, projectDir); - tasksList.push({ - title: options.auth - ? "Setting up authentication" - : "Removing authentication", - task: async () => { - await configureAuth( - projectDir, - options.auth, - options.database !== "none", - options, - ); - }, - }); - - if (options.git) { - tasksList.push({ - title: "Initializing git repository", - task: async () => { - await $({ - cwd: projectDir, - })`git init`; - }, - }); - } - - if (options.features.length > 0) { - tasksList.push({ - title: "Setting up additional features", - task: async () => { - await setupFeatures(projectDir, options.features); - }, - }); - } - - await tasks(tasksList); - - if (options.database === "sqlite") { - await setupTurso(projectDir); - } else if (options.database === "postgres") { - log.info( - pc.blue( - "PostgreSQL setup is manual. You'll need to set up your own PostgreSQL database and update the connection details in .env", - ), + if (options.orm !== "none" && options.database !== "none") { + const ormTemplateDir = path.join( + PKG_ROOT, + options.orm === "drizzle" + ? "template/with-drizzle" + : "template/with-prisma", ); - } - const installDepsResponse = await confirm({ - message: `Install dependencies with ${options.packageManager}?`, - }); - - if (isCancel(installDepsResponse)) { - cancel(pc.red("Operation cancelled")); - process.exit(0); - } - - shouldInstallDeps = installDepsResponse; - - if (shouldInstallDeps) { - s.start(`Installing dependencies using ${options.packageManager}...`); - try { - await $({ - cwd: projectDir, - })`${options.packageManager} install`; - s.stop("Dependencies installed successfully"); - } catch (error) { - s.stop(pc.red("Failed to install dependencies")); - if (error instanceof Error) { - log.error(pc.red(`Installation error: ${error.message}`)); - } - throw error; + if (await fs.pathExists(ormTemplateDir)) { + await fs.copy(ormTemplateDir, projectDir, { overwrite: true }); } } - const rootPackageJsonPath = path.join(projectDir, "package.json"); - if (await fs.pathExists(rootPackageJsonPath)) { - const packageJson = await fs.readJson(rootPackageJsonPath); + const gitignoreFiles = [ + [ + path.join(projectDir, "_gitignore"), + path.join(projectDir, ".gitignore"), + ], + [ + path.join(projectDir, "packages/client/_gitignore"), + path.join(projectDir, "packages/client/.gitignore"), + ], + [ + path.join(projectDir, "packages/server/_gitignore"), + path.join(projectDir, "packages/server/.gitignore"), + ], + ]; + + for (const [source, target] of gitignoreFiles) { + if (await fs.pathExists(source)) { + await fs.move(source, target); + } + } + + const envFiles = [ + [ + path.join(projectDir, "packages/server/_env"), + path.join(projectDir, "packages/server/.env"), + ], + ]; + + for (const [source, target] of envFiles) { + if (await fs.pathExists(source)) { + if (!(await fs.pathExists(target))) { + await fs.move(source, target); + } else { + await fs.remove(source); + } + } + } + + await setupDatabase( + projectDir, + options.database, + options.orm, + options.turso ?? options.database === "sqlite", + ); + await configureAuth( + projectDir, + options.auth, + options.database !== "none", + options, + ); + + if (options.git) { + await $({ cwd: projectDir })`git init`; + } + + if (options.features.length > 0) { + await setupFeatures(projectDir, options.features); + } + + const packageJsonPath = path.join(projectDir, "package.json"); + if (await fs.pathExists(packageJsonPath)) { + const packageJson = await fs.readJson(packageJsonPath); + packageJson.name = options.projectName; + + if (options.packageManager !== "bun") { + packageJson.packageManager = + options.packageManager === "npm" + ? "npm@10.2.4" + : options.packageManager === "pnpm" + ? "pnpm@8.15.4" + : options.packageManager === "yarn" + ? "yarn@4.1.0" + : "bun@1.2.4"; + } if (options.auth && options.database !== "none") { packageJson.scripts["auth:generate"] = @@ -150,7 +135,7 @@ export async function createProject(options: ProjectConfig) { } } - await fs.writeJson(rootPackageJsonPath, packageJson, { spaces: 2 }); + await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 }); } await createReadme(projectDir, options); @@ -160,14 +145,17 @@ export async function createProject(options: ProjectConfig) { options.database, options.projectName, options.packageManager, - shouldInstallDeps, + !options.noInstall, options.orm, ); + + return projectDir; } catch (error) { - s.stop(pc.red("Failed")); + s.message(pc.red("Failed")); if (error instanceof Error) { - log.error(pc.red(`Error during project creation: ${error.message}`)); + cancel(pc.red(`Error during project creation: ${error.message}`)); process.exit(1); } + throw error; } } diff --git a/apps/cli/src/helpers/create-readme.ts b/apps/cli/src/helpers/create-readme.ts index 2c63313..ee8d77d 100644 --- a/apps/cli/src/helpers/create-readme.ts +++ b/apps/cli/src/helpers/create-readme.ts @@ -2,94 +2,176 @@ import path from "node:path"; import fs from "fs-extra"; import type { ProjectConfig } from "../types"; -export async function createReadme(projectDir: string, config: ProjectConfig) { +export async function createReadme(projectDir: string, options: ProjectConfig) { const readmePath = path.join(projectDir, "README.md"); - const projectName = path.basename(projectDir); + const content = generateReadmeContent(options); - const authSection = config.auth - ? ` -## Authentication - -This project uses [Better-Auth](https://www.better-auth.com/) for authentication. - -To complete setup: -1. Create necessary auth tables: \`npx @better-auth/cli migrate\` -2. Configure environment variables in \`.env\` files -3. Check the auth documentation: https://www.better-auth.com/ -` - : ""; - - const databaseSection = - config.database !== "none" - ? ` -## Database - -This project uses ${config.database === "sqlite" ? "SQLite (via Turso)" : "PostgreSQL"} with ${config.orm} ORM. - -${ - config.database === "sqlite" - ? "Ensure your Turso connection details are set in `packages/server/.env`." - : "Ensure your PostgreSQL connection string is set in `packages/server/.env`." + try { + await fs.writeFile(readmePath, content); + } catch (error) { + console.error("Failed to create README.md file:", error); + } } -` - : ""; - const featuresSection = - config.features.length > 0 - ? ` +function generateReadmeContent(options: ProjectConfig): string { + const { + projectName, + packageManager, + database, + auth, + features = [], + orm = "drizzle", + } = options; + + const packageManagerRunCmd = + packageManager === "npm" ? "npm run" : packageManager; + + return `# ${projectName} + +This project was created with [Better-T-Stack](https://github.com/better-t-stack/Better-T-Stack). + ## Features -This project includes: -${config.features.map((feature) => `- ${feature}`).join("\n")} -` - : ""; - - const readme = `# ${projectName} - -A modern web application built with the Better-T Stack. - -## Tech Stack - -- **Frontend**: React, TanStack Router, TanStack Query -- **Backend**: Hono, tRPC -- **Styling**: Tailwind CSS with shadcn/ui components -${databaseSection}${authSection}${featuresSection} +${generateFeaturesList(database, auth, features, orm)} ## Getting Started -1. Install dependencies: - \`\`\` - ${config.packageManager} install - \`\`\` +First, install the dependencies: -2. Start the development server: - \`\`\` - ${config.packageManager} run dev - \`\`\` +\`\`\`bash +${packageManager} install +\`\`\` + +Then, run the development server: + +\`\`\`bash +${packageManagerRunCmd} dev +\`\`\` + +Open [http://localhost:3001](http://localhost:3001) in your browser to see the client application. +The API is running at [http://localhost:3000](http://localhost:3000). + +## Database Setup + +${generateDatabaseSetup(database, auth, packageManagerRunCmd, orm)} ## Project Structure \`\`\` -packages/ - ├── client/ # React frontend application - └── server/ # Hono + tRPC backend server +${projectName}/ +├── packages/ +│ ├── client/ # Frontend application (React, TanStack Router) +│ └── server/ # Backend API (Hono, tRPC) \`\`\` -## Commands +## Scripts -- \`${config.packageManager} run dev\`: Start development servers -- \`${config.packageManager} run build\`: Build for production -- \`${config.packageManager} run dev:client\`: Start only frontend server -- \`${config.packageManager} run dev:server\`: Start only backend server - -## Environment Variables - -Check \`.env.example\` files in each package directory for required environment variables. - -## License - -MIT +${generateScriptsList(packageManagerRunCmd)} `; - - await fs.writeFile(readmePath, readme); +} + +function generateFeaturesList( + database: string, + auth: boolean, + features: string[], + orm: string, +): string { + const featuresList = [ + "TypeScript - For type safety", + "TanStack Router - File-based routing", + `${orm === "drizzle" ? "Drizzle" : "Prisma"} - ORM`, + "TailwindCSS - Utility-first CSS", + "shadcn/ui - Reusable components", + "Hono - Lightweight, performant server", + ]; + + if (database !== "none") { + featuresList.push( + `${database === "sqlite" ? "SQLite/Turso DB" : "PostgreSQL"} - Database`, + ); + } + + if (auth) { + featuresList.push("Authentication - Email & password auth"); + } + + for (const feature of features) { + if (feature === "docker") { + featuresList.push("Docker - Containerized deployment"); + } else if (feature === "github-actions") { + featuresList.push("GitHub Actions - CI/CD"); + } else if (feature === "SEO") { + featuresList.push("SEO - Search engine optimization"); + } + } + + return featuresList.join("\n"); +} + +function generateDatabaseSetup( + database: string, + auth: boolean, + packageManagerRunCmd: string, + orm: string, +): string { + if (database === "none") { + return "This project does not include a database."; + } + + if (database === "sqlite") { + return `This project uses SQLite/Turso for the database. + +1. Start the local database: +\`\`\`bash +${packageManagerRunCmd} db:local +\`\`\` + +2. Update your \`.env\` file with the connection details. + +${ + auth + ? `3. If using authentication, generate the auth schema: +\`\`\`bash +${packageManagerRunCmd} auth:generate +\`\`\` + +4. Apply the schema to your database: +\`\`\`bash +${packageManagerRunCmd} ${orm === "drizzle" ? "drizzle:migrate" : "prisma:push"} +\`\`\`` + : "" +}`; + } + + if (database === "postgres") { + return `This project uses PostgreSQL for the database. + +1. Set up your PostgreSQL database. +2. Update your \`.env\` file with the connection details. + +${ + auth + ? `3. If using authentication, generate the auth schema: +\`\`\`bash +${packageManagerRunCmd} auth:generate +\`\`\` + +4. Apply the schema to your database: +\`\`\`bash +${packageManagerRunCmd} ${orm === "drizzle" ? "drizzle:migrate" : "prisma:push"} +\`\`\`` + : "" +}`; + } + + return ""; +} + +function generateScriptsList(packageManagerRunCmd: string): string { + return `- \`${packageManagerRunCmd} dev\`: Start both client and server in development mode +- \`${packageManagerRunCmd} build\`: Build both client and server +- \`${packageManagerRunCmd} dev:client\`: Start only the client +- \`${packageManagerRunCmd} dev:server\`: Start only the server +- \`${packageManagerRunCmd} db:local\`: Start the local SQLite database (if applicable) +- \`${packageManagerRunCmd} db:push\`: Push schema changes to the database`; } diff --git a/apps/cli/src/helpers/db-setup.ts b/apps/cli/src/helpers/db-setup.ts index 342fd72..59fedf0 100644 --- a/apps/cli/src/helpers/db-setup.ts +++ b/apps/cli/src/helpers/db-setup.ts @@ -1,195 +1,120 @@ -import os from "node:os"; import path from "node:path"; -import { cancel, confirm, isCancel, log, spinner, text } from "@clack/prompts"; -import { $ } from "execa"; +import { log, spinner } from "@clack/prompts"; import fs from "fs-extra"; import pc from "picocolors"; -import { isTursoInstalled, isTursoLoggedIn } from "../utils/turso-cli"; +import { setupTurso } from "./turso-setup"; -interface TursoConfig { - dbUrl: string; - authToken: string; -} - -async function loginToTurso() { +export async function setupDatabase( + projectDir: string, + databaseType: string, + orm: string, + setupTursoDb = true, +): Promise { const s = spinner(); - try { - s.start("Logging in to Turso..."); - await $`turso auth login`; - s.stop("Logged in to Turso successfully!"); - } catch (error) { - s.stop(pc.red("Failed to log in to Turso")); - throw error; - } -} + const serverDir = path.join(projectDir, "packages/server"); -async function installTursoCLI(isMac: boolean) { - const s = spinner(); - try { - s.start("Installing Turso CLI..."); - - if (isMac) { - await $`brew install tursodatabase/tap/turso`; - } else { - const { stdout: installScript } = - await $`curl -sSfL https://get.tur.so/install.sh`; - await $`bash -c '${installScript}'`; - } - - s.stop("Turso CLI installed successfully!"); - } catch (error) { - if (error instanceof Error && error.message.includes("User force closed")) { - s.stop(); - log.warn(pc.yellow("Turso CLI installation cancelled by user")); - throw new Error("Installation cancelled"); - } - s.stop(pc.red("Failed to install Turso CLI")); - throw error; - } -} - -async function createTursoDatabase(dbName: string): Promise { - try { - await $`turso db create ${dbName}`; - } catch (error) { - if (error instanceof Error && error.message.includes("already exists")) { - throw new Error("DATABASE_EXISTS"); - } - throw error; - } - - const { stdout: dbUrl } = await $`turso db show ${dbName} --url`; - const { stdout: authToken } = await $`turso db tokens create ${dbName}`; - - return { - dbUrl: dbUrl.trim(), - authToken: authToken.trim(), - }; -} - -async function writeEnvFile(projectDir: string, config?: TursoConfig) { - const envPath = path.join(projectDir, "packages/server", ".env"); - const envContent = config - ? `TURSO_DATABASE_URL="${config.dbUrl}" -TURSO_AUTH_TOKEN="${config.authToken}"` - : `TURSO_DATABASE_URL= -TURSO_AUTH_TOKEN=`; - - await fs.writeFile(envPath, envContent); -} - -function displayManualSetupInstructions() { - log.info(`Manual Turso Setup Instructions: - -1. Visit https://turso.tech and create an account -2. Create a new database from the dashboard -3. Get your database URL and authentication token -4. Add these credentials to the .env file in packages/server/.env - -TURSO_DATABASE_URL=your_database_url -TURSO_AUTH_TOKEN=your_auth_token`); -} - -export async function setupTurso(projectDir: string) { - const platform = os.platform(); - const isMac = platform === "darwin"; - const canInstallCLI = platform !== "win32"; - - if (!canInstallCLI) { - log.warn(pc.yellow("Automatic Turso setup is not supported on Windows.")); - await writeEnvFile(projectDir); - displayManualSetupInstructions(); + if (databaseType === "none") { + await fs.remove(path.join(serverDir, "src/db")); + log.info(pc.yellow("Database configuration removed")); return; } try { - const isCliInstalled = await isTursoInstalled(); - - if (!isCliInstalled) { - const shouldInstall = await confirm({ - message: "Would you like to install Turso CLI?", - }); - - if (isCancel(shouldInstall)) { - cancel(pc.red("Operation cancelled")); - process.exit(0); + if (databaseType === "sqlite") { + if (orm === "drizzle") { + await setupDrizzleDependencies(projectDir); + await setupTurso(projectDir, setupTursoDb); + } else if (orm === "prisma") { + await setupPrismaDependencies(projectDir); + await setupTurso(projectDir, setupTursoDb); } - - if (!shouldInstall) { - await writeEnvFile(projectDir); - displayManualSetupInstructions(); - return; - } - - const s = spinner(); - s.start("Installing Turso CLI..."); - try { - if (isMac) { - await $`brew install tursodatabase/tap/turso`; - } else { - const { stdout: installScript } = - await $`curl -sSfL https://get.tur.so/install.sh`; - await $`bash -c '${installScript}'`; - } - s.stop("Turso CLI installed successfully!"); - } catch (error) { - s.stop(pc.red("Failed to install Turso CLI")); - throw error; - } - } - - const isLoggedIn = await isTursoLoggedIn(); - if (!isLoggedIn) { - const s = spinner(); - s.start("Logging in to Turso..."); - try { - await $`turso auth login`; - s.stop("Logged in to Turso successfully!"); - } catch (error) { - s.stop(pc.red("Failed to log in to Turso")); - throw error; - } - } - - let success = false; - let dbName = ""; - let suggestedName = path.basename(projectDir); - - while (!success) { - const dbNameResponse = await text({ - message: "Enter a name for your database:", - defaultValue: suggestedName, - initialValue: suggestedName, - placeholder: suggestedName, - }); - - if (isCancel(dbNameResponse)) { - cancel(pc.red("Operation cancelled")); - process.exit(0); - } - - dbName = dbNameResponse as string; - const s = spinner(); - - try { - s.start(`Creating Turso database "${dbName}"...`); - const config = await createTursoDatabase(dbName); - await writeEnvFile(projectDir, config); - s.stop("Turso database configured successfully!"); - success = true; - } catch (error) { - if (error instanceof Error && error.message === "DATABASE_EXISTS") { - s.stop(pc.yellow(`Database "${pc.red(dbName)}" already exists`)); - suggestedName = `${dbName}-${Math.floor(Math.random() * 1000)}`; - } else { - throw error; - } + } else if (databaseType === "postgres") { + log.info( + pc.blue( + "PostgreSQL setup is coming in a future update. Using SQLite configuration for now.", + ), + ); + if (orm === "drizzle") { + await setupDrizzleDependencies(projectDir); + await setupTurso(projectDir, setupTursoDb); + } else if (orm === "prisma") { + await setupPrismaDependencies(projectDir); + await setupTurso(projectDir, setupTursoDb); } } } catch (error) { - log.error(pc.red(`Error during Turso setup: ${error}`)); - await writeEnvFile(projectDir); - displayManualSetupInstructions(); - log.success("Setup completed with manual configuration required."); + s.stop(pc.red("Failed to set up database")); + if (error instanceof Error) { + log.error(pc.red(error.message)); + } + throw error; + } +} + +async function setupDrizzleDependencies(projectDir: string): Promise { + const serverDir = path.join(projectDir, "packages/server"); + + const packageJsonPath = path.join(serverDir, "package.json"); + if (await fs.pathExists(packageJsonPath)) { + const packageJson = await fs.readJSON(packageJsonPath); + + packageJson.dependencies = { + ...packageJson.dependencies, + "drizzle-orm": "^0.38.4", + "@libsql/client": "^0.14.0", + }; + + packageJson.devDependencies = { + ...packageJson.devDependencies, + "drizzle-kit": "^0.30.4", + }; + + packageJson.scripts = { + ...packageJson.scripts, + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit push", + "db:studio": "drizzle-kit studio", + }; + + await fs.writeJSON(packageJsonPath, packageJson, { spaces: 2 }); + } +} + +async function setupPrismaDependencies(projectDir: string): Promise { + const serverDir = path.join(projectDir, "packages/server"); + + const packageJsonPath = path.join(serverDir, "package.json"); + if (await fs.pathExists(packageJsonPath)) { + const packageJson = await fs.readJSON(packageJsonPath); + + packageJson.dependencies = { + ...packageJson.dependencies, + "@prisma/client": "^5.7.1", + "@prisma/adapter-libsql": "^5.7.1", + "@libsql/client": "^0.14.0", + }; + + packageJson.devDependencies = { + ...packageJson.devDependencies, + prisma: "^5.7.1", + }; + + packageJson.scripts = { + ...packageJson.scripts, + "prisma:generate": "prisma generate", + "prisma:push": "prisma db push", + "prisma:studio": "prisma studio", + }; + + await fs.writeJSON(packageJsonPath, packageJson, { spaces: 2 }); + } + + const envPath = path.join(serverDir, ".env"); + if (await fs.pathExists(envPath)) { + const envContent = await fs.readFile(envPath, "utf8"); + if (!envContent.includes("DATABASE_URL")) { + const databaseUrlLine = `\nDATABASE_URL="file:./dev.db"`; + await fs.appendFile(envPath, databaseUrlLine); + } } } diff --git a/apps/cli/src/helpers/install-dependencies.ts b/apps/cli/src/helpers/install-dependencies.ts new file mode 100644 index 0000000..8e7d08c --- /dev/null +++ b/apps/cli/src/helpers/install-dependencies.ts @@ -0,0 +1,45 @@ +import { log, spinner } from "@clack/prompts"; +import { $ } from "execa"; +import pc from "picocolors"; +import type { PackageManager } from "../utils/get-package-manager"; + +interface InstallDependenciesOptions { + projectDir: string; + packageManager: PackageManager; +} + +export async function installDependencies({ + projectDir, + packageManager, +}: InstallDependenciesOptions) { + const s = spinner(); + log.info(pc.blue(`Installing dependencies using ${packageManager}...`)); + + try { + s.start(`Running ${packageManager} install...`); + + switch (packageManager) { + case "npm": + await $({ + cwd: projectDir, + stderr: "inherit", + })`${packageManager} install`; + break; + case "pnpm": + case "yarn": + case "bun": + await $({ + cwd: projectDir, + })`${packageManager} install`; + break; + } + + s.stop(pc.green("Dependencies installed successfully")); + } catch (error) { + s.stop(pc.red("Failed to install dependencies")); + if (error instanceof Error) { + log.error(pc.red(`Installation error: ${error.message}`)); + } + throw error; + } +} diff --git a/apps/cli/src/helpers/post-installation.ts b/apps/cli/src/helpers/post-installation.ts index fb2c76c..86fb144 100644 --- a/apps/cli/src/helpers/post-installation.ts +++ b/apps/cli/src/helpers/post-installation.ts @@ -9,6 +9,8 @@ export function displayPostInstallInstructions( depsInstalled: boolean, orm?: string, ) { + const runCmd = packageManager === "npm" ? "npm run" : packageManager; + log.info(`${pc.cyan("Installation completed!")} Here are some next steps: ${ @@ -19,7 +21,7 @@ ${ orm === "prisma" ? `${pc.cyan("2.")} Generate Prisma client: ${pc.green(`${packageManager} run prisma:generate`)} ${pc.cyan("3.")} Push schema to database: ${pc.green(`${packageManager} run prisma:push`)}` - : `${pc.cyan("2.")} Apply migrations to your database: ${pc.green(`${packageManager} run drizzle:migrate`)}` + : `${pc.cyan("2.")} Apply migrations: ${pc.green(`${packageManager} run drizzle:migrate`)}` } ` @@ -32,19 +34,11 @@ Make sure to update ${pc.cyan("packages/server/.env")} with your PostgreSQL conn ` : database === "sqlite" ? `${pc.yellow("Database Configuration:")} -${pc.cyan("packages/server/.env")} contains your SQLite/Turso connection details. Update if needed. - -` +${pc.cyan("packages/server/.env")} contains your SQLite connection details. Update if needed.` : "" -}${pc.yellow("Environment Variables:")} -Check ${pc.cyan(".env")} files in both client and server packages and update as needed. +} ${pc.yellow("Start Development:")} -${pc.cyan("cd")} ${projectName}${ - !depsInstalled - ? ` -${pc.cyan(packageManager)} install` - : "" - } -${pc.cyan(packageManager === "npm" ? "npm run" : packageManager)} dev`); +${pc.cyan("cd")} ${projectName}${!depsInstalled ? `\n${pc.cyan(packageManager)} install` : ""} +${pc.cyan(runCmd)} dev`); } diff --git a/apps/cli/src/helpers/turso-setup.ts b/apps/cli/src/helpers/turso-setup.ts new file mode 100644 index 0000000..ef13b2b --- /dev/null +++ b/apps/cli/src/helpers/turso-setup.ts @@ -0,0 +1,204 @@ +import os from "node:os"; +import path from "node:path"; +import { cancel, confirm, isCancel, log, spinner, text } from "@clack/prompts"; +import { $ } from "execa"; +import fs from "fs-extra"; +import pc from "picocolors"; + +interface TursoConfig { + dbUrl: string; + authToken: string; +} + +async function isTursoInstalled() { + try { + await $`turso --version`; + return true; + } catch { + return false; + } +} + +async function isTursoLoggedIn() { + try { + const output = await $`turso auth whoami`; + return !output.stdout.includes("You are not logged in"); + } catch { + return false; + } +} + +async function loginToTurso() { + const s = spinner(); + try { + s.start("Logging in to Turso..."); + await $`turso auth login`; + s.stop("Logged in to Turso successfully!"); + return true; + } catch (error) { + s.stop(pc.red("Failed to log in to Turso")); + throw error; + } +} + +async function installTursoCLI(isMac: boolean) { + const s = spinner(); + try { + s.start("Installing Turso CLI..."); + + if (isMac) { + await $`brew install tursodatabase/tap/turso`; + } else { + const { stdout: installScript } = + await $`curl -sSfL https://get.tur.so/install.sh`; + await $`bash -c '${installScript}'`; + } + + s.stop("Turso CLI installed successfully!"); + return true; + } catch (error) { + if (error instanceof Error && error.message.includes("User force closed")) { + s.stop(); + log.warn(pc.yellow("Turso CLI installation cancelled by user")); + throw new Error("Installation cancelled"); + } + s.stop(pc.red("Failed to install Turso CLI")); + throw error; + } +} + +async function createTursoDatabase(dbName: string): Promise { + try { + await $`turso db create ${dbName}`; + } catch (error) { + if (error instanceof Error && error.message.includes("already exists")) { + throw new Error("DATABASE_EXISTS"); + } + throw error; + } + + const { stdout: dbUrl } = await $`turso db show ${dbName} --url`; + const { stdout: authToken } = await $`turso db tokens create ${dbName}`; + + return { + dbUrl: dbUrl.trim(), + authToken: authToken.trim(), + }; +} + +async function writeEnvFile(projectDir: string, config?: TursoConfig) { + const envPath = path.join(projectDir, "packages/server", ".env"); + const envContent = config + ? `TURSO_DATABASE_URL="${config.dbUrl}" +TURSO_AUTH_TOKEN="${config.authToken}"` + : `TURSO_DATABASE_URL= +TURSO_AUTH_TOKEN=`; + + await fs.writeFile(envPath, envContent); +} + +function displayManualSetupInstructions() { + log.info(`Manual Turso Setup Instructions: + +1. Visit https://turso.tech and create an account +2. Create a new database from the dashboard +3. Get your database URL and authentication token +4. Add these credentials to the .env file in packages/server/.env + +TURSO_DATABASE_URL=your_database_url +TURSO_AUTH_TOKEN=your_auth_token`); +} + +export async function setupTurso( + projectDir: string, + shouldSetupTurso: boolean, +) { + if (!shouldSetupTurso) { + await writeEnvFile(projectDir); + log.info(pc.blue("Skipping Turso setup. Setting up empty configuration.")); + displayManualSetupInstructions(); + return; + } + + const platform = os.platform(); + const isMac = platform === "darwin"; + const canInstallCLI = platform !== "win32"; + + if (!canInstallCLI) { + log.warn(pc.yellow("Automatic Turso setup is not supported on Windows.")); + await writeEnvFile(projectDir); + displayManualSetupInstructions(); + return; + } + + try { + const isCliInstalled = await isTursoInstalled(); + + if (!isCliInstalled) { + const shouldInstall = await confirm({ + message: "Would you like to install Turso CLI?", + initialValue: true, + }); + + if (isCancel(shouldInstall)) { + cancel(pc.red("Operation cancelled")); + process.exit(0); + } + + if (!shouldInstall) { + await writeEnvFile(projectDir); + displayManualSetupInstructions(); + return; + } + + await installTursoCLI(isMac); + } + + const isLoggedIn = await isTursoLoggedIn(); + if (!isLoggedIn) { + await loginToTurso(); + } + + let success = false; + let dbName = ""; + let suggestedName = path.basename(projectDir); + + while (!success) { + const dbNameResponse = await text({ + message: "Enter a name for your database:", + defaultValue: suggestedName, + initialValue: suggestedName, + placeholder: suggestedName, + }); + + if (isCancel(dbNameResponse)) { + cancel(pc.red("Operation cancelled")); + process.exit(0); + } + + dbName = dbNameResponse as string; + const s = spinner(); + + try { + s.start(`Creating Turso database "${dbName}"...`); + const config = await createTursoDatabase(dbName); + await writeEnvFile(projectDir, config); + s.stop("Turso database configured successfully!"); + success = true; + } catch (error) { + if (error instanceof Error && error.message === "DATABASE_EXISTS") { + s.stop(pc.yellow(`Database "${pc.red(dbName)}" already exists`)); + suggestedName = `${dbName}-${Math.floor(Math.random() * 1000)}`; + } else { + s.stop(pc.red("Failed to create Turso database")); + throw error; + } + } + } + } catch (error) { + log.error(pc.red(`Error during Turso setup: ${error}`)); + await writeEnvFile(projectDir); + displayManualSetupInstructions(); + log.success("Setup completed with manual configuration required."); + } +} diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 5d0cab9..978913c 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -3,6 +3,7 @@ import { Command } from "commander"; import pc from "picocolors"; import { DEFAULT_CONFIG } from "./constants"; import { createProject } from "./helpers/create-project"; +import { installDependencies } from "./helpers/install-dependencies"; import { gatherConfig } from "./prompts/config-prompts"; import type { ProjectConfig, ProjectFeature } from "./types"; import { displayConfig } from "./utils/display-config"; @@ -37,6 +38,7 @@ async function main() { .option("--docker", "Include Docker setup") .option("--github-actions", "Include GitHub Actions") .option("--seo", "Include SEO setup") + .option("--no-features", "Skip all additional features") .option("--git", "Include git setup") .option("--no-git", "Skip git initialization") .option("--npm", "Use npm package manager") @@ -45,6 +47,10 @@ async function main() { .option("--bun", "Use bun package manager") .option("--drizzle", "Use Drizzle ORM") .option("--prisma", "Use Prisma ORM (coming soon)") + .option("--install", "Install dependencies") + .option("--no-install", "Skip installing dependencies") + .option("--turso", "Set up Turso for SQLite database") + .option("--no-turso", "Skip Turso setup for SQLite database") .parse(); const options = program.opts(); @@ -63,12 +69,20 @@ async function main() { ...(options.yarn && { packageManager: "yarn" }), ...(options.bun && { packageManager: "bun" }), ...("git" in options && { git: options.git }), - ...((options.docker || options.githubActions || options.seo) && { - features: [ - ...(options.docker ? ["docker"] : []), - ...(options.githubActions ? ["github-actions"] : []), - ...(options.seo ? ["SEO"] : []), - ] as ProjectFeature[], + ...("install" in options && { noInstall: !options.install }), + ...("turso" in options && { turso: options.turso }), + ...((options.docker || + options.githubActions || + options.seo || + options.features === false) && { + features: + options.features === false + ? [] + : ([ + ...(options.docker ? ["docker"] : []), + ...(options.githubActions ? ["github-actions"] : []), + ...(options.seo ? ["SEO"] : []), + ] as ProjectFeature[]), }), }; @@ -96,11 +110,21 @@ async function main() { : DEFAULT_CONFIG.orm, auth: options.auth ?? DEFAULT_CONFIG.auth, git: options.git ?? DEFAULT_CONFIG.git, + noInstall: + "noInstall" in options + ? options.noInstall + : DEFAULT_CONFIG.noInstall, packageManager: flagConfig.packageManager ?? DEFAULT_CONFIG.packageManager, features: flagConfig.features?.length ? flagConfig.features : DEFAULT_CONFIG.features, + turso: + "turso" in options + ? options.turso + : flagConfig.database === "sqlite" + ? DEFAULT_CONFIG.turso + : false, } : await gatherConfig(flagConfig); @@ -110,7 +134,14 @@ async function main() { log.message(""); } - await createProject(config); + const projectDir = await createProject(config); + + if (!config.noInstall) { + await installDependencies({ + projectDir, + packageManager: config.packageManager, + }); + } log.success( pc.blue( @@ -124,10 +155,21 @@ async function main() { } catch (error) { s.stop(pc.red("Failed")); if (error instanceof Error) { - cancel(pc.red("An unexpected error occurred")); + cancel(pc.red(`An unexpected error occurred: ${error.message}`)); process.exit(1); } } } -main(); +main().catch((err) => { + log.error("Aborting installation..."); + if (err instanceof Error) { + log.error(err.message); + } else { + log.error( + "An unknown error has occurred. Please open an issue on GitHub with the below:", + ); + console.log(err); + } + process.exit(1); +}); diff --git a/apps/cli/src/prompts/config-prompts.ts b/apps/cli/src/prompts/config-prompts.ts index 3955603..8db4de3 100644 --- a/apps/cli/src/prompts/config-prompts.ts +++ b/apps/cli/src/prompts/config-prompts.ts @@ -5,24 +5,28 @@ import type { ProjectConfig, ProjectDatabase, ProjectFeature, - ProjectORM, + ProjectOrm, } from "../types"; import { getAuthChoice } from "./auth"; import { getDatabaseChoice } from "./database"; import { getFeaturesChoice } from "./features"; import { getGitChoice } from "./git"; +import { getNoInstallChoice } from "./install"; import { getORMChoice } from "./orm"; import { getPackageManagerChoice } from "./package-manager"; import { getProjectName } from "./project-name"; +import { getTursoSetupChoice } from "./turso"; interface PromptGroupResults { projectName: string; database: ProjectDatabase; - orm: ProjectORM; + orm: ProjectOrm; auth: boolean; features: ProjectFeature[]; git: boolean; packageManager: PackageManager; + noInstall: boolean; + turso: boolean; } export async function gatherConfig( @@ -38,9 +42,14 @@ export async function gatherConfig( getORMChoice(flags.orm, results.database !== "none"), auth: ({ results }) => getAuthChoice(flags.auth, results.database !== "none"), + turso: ({ results }) => + results.database === "sqlite" + ? getTursoSetupChoice(flags.turso) + : Promise.resolve(false), features: () => getFeaturesChoice(flags.features), git: () => getGitChoice(flags.git), packageManager: () => getPackageManagerChoice(flags.packageManager), + noInstall: () => getNoInstallChoice(flags.noInstall), }, { onCancel: () => { @@ -58,5 +67,7 @@ export async function gatherConfig( features: result.features, git: result.git, packageManager: result.packageManager, + noInstall: result.noInstall, + turso: result.turso, }; } diff --git a/apps/cli/src/prompts/install.ts b/apps/cli/src/prompts/install.ts new file mode 100644 index 0000000..117c1dc --- /dev/null +++ b/apps/cli/src/prompts/install.ts @@ -0,0 +1,21 @@ +import { cancel, confirm, isCancel } from "@clack/prompts"; +import pc from "picocolors"; +import { DEFAULT_CONFIG } from "../constants"; + +export async function getNoInstallChoice( + noInstall?: boolean, +): Promise { + if (noInstall !== undefined) return noInstall; + + const response = await confirm({ + message: "Install dependencies after creating project?", + initialValue: !DEFAULT_CONFIG.noInstall, + }); + + if (isCancel(response)) { + cancel(pc.red("Operation cancelled")); + process.exit(0); + } + + return !response; +} diff --git a/apps/cli/src/prompts/orm.ts b/apps/cli/src/prompts/orm.ts index 720b8d3..43ec6b4 100644 --- a/apps/cli/src/prompts/orm.ts +++ b/apps/cli/src/prompts/orm.ts @@ -1,15 +1,15 @@ import { cancel, isCancel, select } from "@clack/prompts"; import pc from "picocolors"; -import type { ProjectORM } from "../types"; +import type { ProjectOrm } from "../types"; export async function getORMChoice( - orm: ProjectORM | undefined, + orm: ProjectOrm | undefined, hasDatabase: boolean, -): Promise { +): Promise { if (!hasDatabase) return "none"; if (orm !== undefined) return orm; - const response = await select({ + const response = await select({ message: "Which ORM would you like to use?", options: [ { diff --git a/apps/cli/src/prompts/turso.ts b/apps/cli/src/prompts/turso.ts new file mode 100644 index 0000000..4e52e12 --- /dev/null +++ b/apps/cli/src/prompts/turso.ts @@ -0,0 +1,18 @@ +import { cancel, confirm, isCancel } from "@clack/prompts"; +import pc from "picocolors"; + +export async function getTursoSetupChoice(turso?: boolean): Promise { + if (turso !== undefined) return turso; + + const response = await confirm({ + message: "Set up a Turso database for this project?", + initialValue: true, + }); + + if (isCancel(response)) { + cancel(pc.red("Operation cancelled")); + process.exit(0); + } + + return response; +} diff --git a/apps/cli/src/types.ts b/apps/cli/src/types.ts index fa8a7d2..cf4a527 100644 --- a/apps/cli/src/types.ts +++ b/apps/cli/src/types.ts @@ -1,18 +1,16 @@ +export type ProjectDatabase = "sqlite" | "postgres" | "none"; +export type ProjectOrm = "drizzle" | "prisma" | "none"; +export type PackageManager = "npm" | "pnpm" | "yarn" | "bun"; export type ProjectFeature = "docker" | "github-actions" | "SEO"; -export type ProjectDatabase = "sqlite" | "postgres" | "none"; - -export type PackageManager = "npm" | "yarn" | "pnpm" | "bun"; - -export type ProjectORM = "drizzle" | "prisma" | "none"; - -export type ProjectConfig = { - yes?: boolean; +export interface ProjectConfig { projectName: string; - git: boolean; database: ProjectDatabase; + orm: ProjectOrm; auth: boolean; - packageManager: PackageManager; features: ProjectFeature[]; - orm: ProjectORM; -}; + git: boolean; + packageManager: PackageManager; + noInstall?: boolean; + turso?: boolean; +} diff --git a/apps/cli/src/utils/display-config.ts b/apps/cli/src/utils/display-config.ts index 03f53bb..e497f3e 100644 --- a/apps/cli/src/utils/display-config.ts +++ b/apps/cli/src/utils/display-config.ts @@ -27,6 +27,12 @@ export function displayConfig(config: Partial) { `${pc.blue("Package Manager:")} ${config.packageManager}`, ); } + if (config.noInstall !== undefined) { + configDisplay.push(`${pc.blue("Skip Install:")} ${config.noInstall}`); + } + if (config.turso !== undefined) { + configDisplay.push(`${pc.blue("Turso Setup:")} ${config.turso}`); + } return configDisplay.join("\n"); } diff --git a/apps/cli/src/utils/generate-reproducible-command.ts b/apps/cli/src/utils/generate-reproducible-command.ts index a232612..ec9d91a 100644 --- a/apps/cli/src/utils/generate-reproducible-command.ts +++ b/apps/cli/src/utils/generate-reproducible-command.ts @@ -1,48 +1,52 @@ -import { DEFAULT_CONFIG } from "../constants"; import type { ProjectConfig } from "../types"; export function generateReproducibleCommand(config: ProjectConfig): string { const flags: string[] = []; - const isMainlyDefault = Object.entries(config).every(([key, value]) => { - if (key === "projectName") return true; - if (key === "features" && Array.isArray(value)) return value.length === 0; - return value === DEFAULT_CONFIG[key as keyof ProjectConfig]; - }); - - if (isMainlyDefault) { - flags.push("-y"); + if (config.database === "none") { + flags.push("--no-database"); + } else if (config.database === "sqlite") { + flags.push("--sqlite"); + } else if (config.database === "postgres") { + flags.push("--postgres"); } - if (config.database !== DEFAULT_CONFIG.database) { - if (config.database === "none") { - flags.push("--no-database"); - } else { - flags.push(config.database === "sqlite" ? "--sqlite" : "--postgres"); + if (config.database !== "none") { + if (config.orm === "drizzle") { + flags.push("--drizzle"); + } else if (config.orm === "prisma") { + flags.push("--prisma"); } } - if (config.database !== "none" && config.orm !== DEFAULT_CONFIG.orm) { - flags.push(config.orm === "drizzle" ? "--drizzle" : "--prisma"); - } - - if (config.auth !== DEFAULT_CONFIG.auth) { + if (config.auth) { + flags.push("--auth"); + } else { flags.push("--no-auth"); } - if (!config.git) { + if (config.git) { + flags.push("--git"); + } else { flags.push("--no-git"); } - if ( - config.packageManager && - config.packageManager !== DEFAULT_CONFIG.packageManager - ) { + if (config.noInstall) { + flags.push("--no-install"); + } else { + flags.push("--install"); + } + + if (config.packageManager) { flags.push(`--${config.packageManager}`); } - for (const feature of config.features) { - flags.push(`--${feature}`); + if (config.features.length > 0) { + for (const feature of config.features) { + flags.push(`--${feature}`); + } + } else { + flags.push("--no-features"); } const baseCommand = "npx create-better-t-stack"; diff --git a/apps/cli/src/utils/turso-cli.ts b/apps/cli/src/utils/turso-cli.ts deleted file mode 100644 index 0d5986b..0000000 --- a/apps/cli/src/utils/turso-cli.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { $ } from "execa"; - -export async function isTursoInstalled() { - try { - await $`turso --version`; - return true; - } catch { - return false; - } -} - -export async function isTursoLoggedIn() { - try { - const output = await $`turso auth whoami`; - return !output.stdout.includes("You are not logged in"); - } catch { - return false; - } -} diff --git a/apps/cli/template/base/package.json b/apps/cli/template/base/package.json new file mode 100644 index 0000000..c582994 --- /dev/null +++ b/apps/cli/template/base/package.json @@ -0,0 +1,23 @@ +{ + "name": "better-t-stack", + "private": true, + "workspaces": [ + "packages/*" + ], + "scripts": { + "dev": "turbo dev", + "build": "turbo build", + "check-types": "turbo check-types", + "dev:client": "turbo -F @better-t/client dev", + "dev:server": "turbo -F @better-t/server dev", + "db:local": "turbo -F @better-t/server db:local", + "db:push": "turbo -F @better-t/server db:push" + }, + "packageManager": "bun@1.2.4", + "dependencies": { + "drizzle-orm": "^0.38.4" + }, + "devDependencies": { + "turbo": "^2.4.2" + } +} diff --git a/apps/cli/template/base/packages/client/.env.example b/apps/cli/template/base/packages/client/.env.example new file mode 100644 index 0000000..df1d25c --- /dev/null +++ b/apps/cli/template/base/packages/client/.env.example @@ -0,0 +1 @@ +VITE_SERVER_URL=http://localhost:3000 diff --git a/apps/cli/template/base/packages/client/.gitignore b/apps/cli/template/base/packages/client/.gitignore new file mode 100644 index 0000000..c9b51df --- /dev/null +++ b/apps/cli/template/base/packages/client/.gitignore @@ -0,0 +1,21 @@ +# Local +.DS_Store +*.local +*.log* + +# Dist +node_modules +dist/ +.vinxi +.output +.vercel +.netlify +.wrangler + +# IDE +.vscode/* +!.vscode/extensions.json +.idea + +*.env* +!.env.example diff --git a/apps/cli/template/base/packages/client/.prettierrc b/apps/cli/template/base/packages/client/.prettierrc new file mode 100644 index 0000000..4189b56 --- /dev/null +++ b/apps/cli/template/base/packages/client/.prettierrc @@ -0,0 +1,4 @@ +{ + "plugins": ["prettier-plugin-tailwindcss"], + "tailwindStylesheet": "./src/index.css" +} diff --git a/apps/cli/template/base/packages/client/components.json b/apps/cli/template/base/packages/client/components.json new file mode 100644 index 0000000..1d282e6 --- /dev/null +++ b/apps/cli/template/base/packages/client/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} \ No newline at end of file diff --git a/apps/cli/template/base/packages/client/index.html b/apps/cli/template/base/packages/client/index.html new file mode 100644 index 0000000..aed7499 --- /dev/null +++ b/apps/cli/template/base/packages/client/index.html @@ -0,0 +1,13 @@ + + + + + + TanStack Router + + + +
+ + + diff --git a/apps/cli/template/base/packages/client/package.json b/apps/cli/template/base/packages/client/package.json new file mode 100644 index 0000000..1829250 --- /dev/null +++ b/apps/cli/template/base/packages/client/package.json @@ -0,0 +1,51 @@ +{ + "name": "@better-t/client", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --port=3001", + "build": "vite build", + "serve": "vite preview", + "start": "vite", + "check-types": "tsc --noEmit" + }, + "devDependencies": { + "@tanstack/router-plugin": "^1.101.0", + "@types/node": "^22.13.1", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "@vitejs/plugin-react": "^4.3.4", + "postcss": "^8.5.1", + "prettier": "^3.4.2", + "prettier-plugin-tailwindcss": "^0.6.11", + "tailwindcss": "^4.0.5", + "vite": "^6.1.0" + }, + "dependencies": { + "@hookform/resolvers": "^3.10.0", + "@radix-ui/react-dropdown-menu": "^2.1.6", + "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-slot": "^1.1.2", + "@tailwindcss/vite": "^4.0.5", + "@tanstack/react-query": "^5.66.0", + "@tanstack/react-query-devtools": "^5.66.0", + "@tanstack/react-router": "^1.101.0", + "@tanstack/router-devtools": "^1.101.0", + "@trpc/client": "^11.0.0-rc.748", + "@trpc/react-query": "^11.0.0-rc.748", + "@trpc/server": "^11.0.0-rc.748", + "better-auth": "^1.1.16", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.473.0", + "next-themes": "^0.4.4", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-hook-form": "^7.54.2", + "sonner": "^1.7.4", + "tailwind-merge": "^2.6.0", + "tailwindcss-animate": "^1.0.7", + "zod": "^3.24.1" + } +} diff --git a/apps/cli/template/base/packages/client/src/components/header.tsx b/apps/cli/template/base/packages/client/src/components/header.tsx new file mode 100644 index 0000000..daa8fab --- /dev/null +++ b/apps/cli/template/base/packages/client/src/components/header.tsx @@ -0,0 +1,45 @@ +import { Link } from "@tanstack/react-router"; +import { ModeToggle } from "./mode-toggle"; +import UserMenu from "./user-menu"; + +export default function Header() { + return ( +
+
+
+ + Home + + + Dashboard + + + About + +
+
+ + +
+
+
+
+ ); +} diff --git a/apps/cli/template/base/packages/client/src/components/loader.tsx b/apps/cli/template/base/packages/client/src/components/loader.tsx new file mode 100644 index 0000000..e17ec79 --- /dev/null +++ b/apps/cli/template/base/packages/client/src/components/loader.tsx @@ -0,0 +1,9 @@ +import { Loader2 } from "lucide-react"; + +export default function Loader() { + return ( +
+ +
+ ); +} diff --git a/apps/cli/template/base/packages/client/src/components/mode-toggle.tsx b/apps/cli/template/base/packages/client/src/components/mode-toggle.tsx new file mode 100644 index 0000000..ebd44a4 --- /dev/null +++ b/apps/cli/template/base/packages/client/src/components/mode-toggle.tsx @@ -0,0 +1,37 @@ +import { Moon, Sun } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { useTheme } from "@/components/theme-provider"; + +export function ModeToggle() { + const { setTheme, theme } = useTheme(); + + return ( + + + + + + setTheme("light")}> + Light + + setTheme("dark")}> + Dark + + setTheme("system")}> + System + + + + ); +} diff --git a/apps/cli/template/base/packages/client/src/components/sign-up-form.tsx b/apps/cli/template/base/packages/client/src/components/sign-up-form.tsx new file mode 100644 index 0000000..bd84cde --- /dev/null +++ b/apps/cli/template/base/packages/client/src/components/sign-up-form.tsx @@ -0,0 +1,157 @@ +import { authClient } from "@/lib/auth-client"; +import { signInSchema, signUpSchema } from "@/lib/schemas"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useNavigate } from "@tanstack/react-router"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import Loader from "./loader"; +import { Button } from "./ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "./ui/form"; +import { Input } from "./ui/input"; + +export default function AuthForm() { + const navigate = useNavigate({ + from: "/", + }); + const [isSignUp, setIsSignUp] = useState(false); + const { isPending } = authClient.useSession(); + + const form = useForm>({ + resolver: zodResolver(isSignUp ? signUpSchema : signInSchema), + defaultValues: { + email: "", + password: "", + name: "", + }, + }); + + const onSubmit = async (values: z.infer) => { + if (isSignUp) { + await authClient.signUp.email( + { + email: values.email, + password: values.password, + name: values.name, + }, + { + onSuccess: () => { + toast.success("Sign up successful"); + navigate({ + to: "/dashboard", + }); + }, + onError: (ctx) => { + form.setError("email", { + type: "manual", + message: ctx.error.message, + }); + }, + }, + ); + } else { + await authClient.signIn.email( + { + email: values.email, + password: values.password, + }, + { + onSuccess: () => { + toast.success("Sign in successful"); + navigate({ + to: "/dashboard", + }); + }, + onError: (ctx) => { + form.setError("email", { + type: "manual", + message: ctx.error.message, + }); + }, + }, + ); + } + }; + + if (isPending) { + return ; + } + + return ( +
+

+ {isSignUp ? "Create Account" : "Welcome Back"} +

+
+ + {isSignUp && ( + ( + + Name + + + + + + )} + /> + )} + ( + + Email + + + + + + )} + /> + ( + + Password + + + + + + )} + /> + + + +
+ +
+
+ ); +} diff --git a/apps/cli/template/base/packages/client/src/components/theme-provider.tsx b/apps/cli/template/base/packages/client/src/components/theme-provider.tsx new file mode 100644 index 0000000..e18440d --- /dev/null +++ b/apps/cli/template/base/packages/client/src/components/theme-provider.tsx @@ -0,0 +1,73 @@ +import { createContext, useContext, useEffect, useState } from "react"; + +type Theme = "dark" | "light" | "system"; + +type ThemeProviderProps = { + children: React.ReactNode; + defaultTheme?: Theme; + storageKey?: string; +}; + +type ThemeProviderState = { + theme: Theme; + setTheme: (theme: Theme) => void; +}; + +const initialState: ThemeProviderState = { + theme: "system", + setTheme: () => null, +}; + +const ThemeProviderContext = createContext(initialState); + +export function ThemeProvider({ + children, + defaultTheme = "system", + storageKey = "vite-ui-theme", + ...props +}: ThemeProviderProps) { + const [theme, setTheme] = useState( + () => (localStorage.getItem(storageKey) as Theme) || defaultTheme, + ); + + useEffect(() => { + const root = window.document.documentElement; + + root.classList.remove("light", "dark"); + + if (theme === "system") { + const systemTheme = window.matchMedia("(prefers-color-scheme: dark)") + .matches + ? "dark" + : "light"; + + root.classList.add(systemTheme); + return; + } + + root.classList.add(theme); + }, [theme]); + + const value = { + theme, + setTheme: (theme: Theme) => { + localStorage.setItem(storageKey, theme); + setTheme(theme); + }, + }; + + return ( + + {children} + + ); +} + +export const useTheme = () => { + const context = useContext(ThemeProviderContext); + + if (context === undefined) + throw new Error("useTheme must be used within a ThemeProvider"); + + return context; +}; diff --git a/apps/cli/template/base/packages/client/src/components/ui/button.tsx b/apps/cli/template/base/packages/client/src/components/ui/button.tsx new file mode 100644 index 0000000..7ce98b3 --- /dev/null +++ b/apps/cli/template/base/packages/client/src/components/ui/button.tsx @@ -0,0 +1,57 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground shadow-sm hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90", + outline: + "border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 rounded-md px-3 text-xs", + lg: "h-10 rounded-md px-8", + icon: "h-9 w-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + } +) +Button.displayName = "Button" + +export { Button, buttonVariants } diff --git a/apps/cli/template/base/packages/client/src/components/ui/dropdown-menu.tsx b/apps/cli/template/base/packages/client/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..0342df4 --- /dev/null +++ b/apps/cli/template/base/packages/client/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,199 @@ +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { Check, ChevronRight, Circle } from "lucide-react" + +import { cn } from "@/lib/utils" + +const DropdownMenu = DropdownMenuPrimitive.Root + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger + +const DropdownMenuGroup = DropdownMenuPrimitive.Group + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal + +const DropdownMenuSub = DropdownMenuPrimitive.Sub + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + svg]:size-4 [&>svg]:shrink-0", + inset && "pl-8", + className + )} + {...props} + /> +)) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +DropdownMenuShortcut.displayName = "DropdownMenuShortcut" + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +} diff --git a/apps/cli/template/base/packages/client/src/components/ui/form.tsx b/apps/cli/template/base/packages/client/src/components/ui/form.tsx new file mode 100644 index 0000000..508e642 --- /dev/null +++ b/apps/cli/template/base/packages/client/src/components/ui/form.tsx @@ -0,0 +1,179 @@ +"use client"; + +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { Slot } from "@radix-ui/react-slot"; +import { + Controller, + type ControllerProps, + type FieldPath, + type FieldValues, + FormProvider, + useFormContext, +} from "react-hook-form"; + +import { cn } from "@/lib/utils"; +import { Label } from "@/components/ui/label"; + +const Form = FormProvider; + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + name: TName; +}; + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue, +); + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ); +}; + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState, formState } = useFormContext(); + + const fieldState = getFieldState(fieldContext.name, formState); + + if (!fieldContext) { + throw new Error("useFormField should be used within "); + } + + const { id } = itemContext; + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + }; +}; + +type FormItemContextValue = { + id: string; +}; + +const FormItemContext = React.createContext( + {} as FormItemContextValue, +); + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId(); + + return ( + +
+ + ); +}); +FormItem.displayName = "FormItem"; + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField(); + + return ( +