diff --git a/.changeset/two-kiwis-push.md b/.changeset/two-kiwis-push.md new file mode 100644 index 0000000..1c261c5 --- /dev/null +++ b/.changeset/two-kiwis-push.md @@ -0,0 +1,5 @@ +--- +"create-better-t-stack": minor +--- + +implement auth add or remove logic diff --git a/apps/cli/README.md b/apps/cli/README.md index 7ea54c1..bef7592 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -1,5 +1,7 @@ # Create Better-T-Stack CLI +> **Note:** This CLI is currently a work in progress (WIP). + An interactive CLI tool to quickly scaffold full-stack applications using the Better-T-Stack framework. ## Quick Start @@ -20,9 +22,12 @@ Follow the prompts to configure your project. Usage: create-better-t-stack [project-directory] [options] Options: + -V, --version Output the version number -y, --yes Use default configuration - --database "libsql" (default) or "postgres" - --auth Enable authentication + --no-database Skip database setup + --sqlite Use SQLite database + --postgres Use PostgreSQL database + --auth Include authentication --no-auth Disable authentication --docker Include Docker setup --github-actions Add GitHub Actions workflows @@ -33,6 +38,28 @@ Options: --pnpm Use pnpm as package manager --yarn Use yarn as package manager --bun Use bun as package manager + --drizzle Use Drizzle ORM + --prisma Use Prisma ORM -h, --help Display help ``` + +## Features + +- **Project Setup**: Scaffold a full-stack TypeScript project with a monorepo structure +- **Database Options**: Choose between SQLite (via Turso), PostgreSQL, or no database +- **Authentication**: Optional auth setup with Better-Auth +- **ORM Selection**: Choose between Drizzle ORM or Prisma +- **Deployment**: Optional Docker configuration +- **CI/CD**: GitHub Actions workflows +- **Developer Experience**: Git initialization and package manager selection + +## Stack + +The generated project includes: + +- **Frontend**: React, TanStack Router, TanStack Query +- **Backend**: Hono, tRPC +- **Styling**: Tailwind CSS with shadcn/ui components +- **Database**: SQLite (Turso) or PostgreSQL with your choice of ORM + Created by [Nitish Singh](https://github.com/FgrReloaded) & [Aman Varshney](https://github.com/AmanVarshney01) diff --git a/apps/cli/src/helpers/auth-setup.ts b/apps/cli/src/helpers/auth-setup.ts new file mode 100644 index 0000000..6ead263 --- /dev/null +++ b/apps/cli/src/helpers/auth-setup.ts @@ -0,0 +1,496 @@ +import crypto from "node:crypto"; +import path from "node:path"; +import { log, spinner } from "@clack/prompts"; +import fs from "fs-extra"; +import pc from "picocolors"; +import type { ProjectConfig } from "../types"; + +export async function configureAuth( + projectDir: string, + initialEnableAuth: boolean, + hasDatabase: boolean, + options?: ProjectConfig, +) { + let enableAuth = initialEnableAuth; + + if (!hasDatabase && enableAuth) { + log.warn( + pc.yellow( + "Authentication requires a database. Disabling authentication.", + ), + ); + enableAuth = false; + } + + if (enableAuth) { + const secret = crypto.randomBytes(32).toString("hex"); + + const serverEnvPath = path.join(projectDir, "packages/server/.env"); + await fs.ensureFile(serverEnvPath); + let envContent = await fs.readFile(serverEnvPath, "utf-8").catch(() => ""); + + 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); + } else { + await fs.ensureDir(path.join(projectDir, "packages/server/src/db")); + } + + 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); + } + + const returnIndex = content.lastIndexOf("return {"); + if (returnIndex !== -1) { + const returnEnd = content.indexOf("}", returnIndex); + const returnContent = content.substring(returnIndex, returnEnd); + + if (!returnContent.includes("session")) { + const updatedReturn = returnContent.replace( + "return {", + "return {\n session,", + ); + content = + content.slice(0, returnIndex) + + updatedReturn + + content.slice(returnEnd); + } + } + } + } 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); + } + } + } +} diff --git a/apps/cli/src/helpers/create-project.ts b/apps/cli/src/helpers/create-project.ts index e0280bc..cad97f8 100644 --- a/apps/cli/src/helpers/create-project.ts +++ b/apps/cli/src/helpers/create-project.ts @@ -5,7 +5,11 @@ import { $ } from "execa"; import fs from "fs-extra"; import pc from "picocolors"; import type { ProjectConfig } from "../types"; +import { configureAuth } from "./auth-setup"; +import { createReadme } from "./create-readme"; import { setupTurso } from "./db-setup"; +import { setupFeatures } from "./feature-setup"; +import { displayPostInstallInstructions } from "./post-installation"; export async function createProject(options: ProjectConfig) { const s = spinner(); @@ -37,6 +41,29 @@ export async function createProject(options: ProjectConfig) { }, ]; + if (options.database === "none") { + tasksList.push({ + title: "Removing database configuration", + task: async () => { + await fs.remove(path.join(projectDir, "packages/server/src/db")); + }, + }); + } + + 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", @@ -48,12 +75,25 @@ export async function createProject(options: ProjectConfig) { }); } + 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") { - // Handle postgres setup + 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", + ), + ); } const installDepsResponse = await confirm({ @@ -83,9 +123,46 @@ export async function createProject(options: ProjectConfig) { } } - log.info(`${pc.dim("Next steps:")} -${pc.cyan("cd")} ${options.projectName}${!shouldInstallDeps ? `\n${pc.cyan(options.packageManager)} install` : ""} -${pc.cyan(options.packageManager === "npm" ? "npm run" : options.packageManager)} ${"dev"}`); + const rootPackageJsonPath = path.join(projectDir, "package.json"); + if (await fs.pathExists(rootPackageJsonPath)) { + const packageJson = await fs.readJson(rootPackageJsonPath); + + if (options.auth && options.database !== "none") { + packageJson.scripts["auth:generate"] = + "cd packages/server && npx @better-auth/cli generate --output ./src/db/auth-schema.ts"; + + if (options.orm === "prisma") { + packageJson.scripts["prisma:generate"] = + "cd packages/server && npx prisma generate"; + packageJson.scripts["prisma:push"] = + "cd packages/server && npx prisma db push"; + packageJson.scripts["prisma:studio"] = + "cd packages/server && npx prisma studio"; + + packageJson.scripts["db:setup"] = + "npm run auth:generate && npm run prisma:generate && npm run prisma:push"; + } else if (options.orm === "drizzle") { + packageJson.scripts["drizzle:migrate"] = + "cd packages/server && npx @better-auth/cli migrate"; + + packageJson.scripts["db:setup"] = + "npm run auth:generate && npm run drizzle:migrate"; + } + } + + await fs.writeJson(rootPackageJsonPath, packageJson, { spaces: 2 }); + } + + await createReadme(projectDir, options); + + displayPostInstallInstructions( + options.auth, + options.database, + options.projectName, + options.packageManager, + shouldInstallDeps, + options.orm, + ); } catch (error) { s.stop(pc.red("Failed")); if (error instanceof Error) { diff --git a/apps/cli/src/helpers/create-readme.ts b/apps/cli/src/helpers/create-readme.ts new file mode 100644 index 0000000..2c63313 --- /dev/null +++ b/apps/cli/src/helpers/create-readme.ts @@ -0,0 +1,95 @@ +import path from "node:path"; +import fs from "fs-extra"; +import type { ProjectConfig } from "../types"; + +export async function createReadme(projectDir: string, config: ProjectConfig) { + const readmePath = path.join(projectDir, "README.md"); + const projectName = path.basename(projectDir); + + 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`." +} +` + : ""; + + const featuresSection = + config.features.length > 0 + ? ` +## 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} + +## Getting Started + +1. Install dependencies: + \`\`\` + ${config.packageManager} install + \`\`\` + +2. Start the development server: + \`\`\` + ${config.packageManager} run dev + \`\`\` + +## Project Structure + +\`\`\` +packages/ + ├── client/ # React frontend application + └── server/ # Hono + tRPC backend server +\`\`\` + +## Commands + +- \`${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 +`; + + await fs.writeFile(readmePath, readme); +} diff --git a/apps/cli/src/helpers/feature-setup.ts b/apps/cli/src/helpers/feature-setup.ts new file mode 100644 index 0000000..35ccc0c --- /dev/null +++ b/apps/cli/src/helpers/feature-setup.ts @@ -0,0 +1,185 @@ +import path from "node:path"; +import { log } from "@clack/prompts"; +import fs from "fs-extra"; +import pc from "picocolors"; +import type { ProjectFeature } from "../types"; + +export async function setupFeatures( + projectDir: string, + features: ProjectFeature[], +) { + if (features.includes("docker")) { + await setupDocker(projectDir); + } + + if (features.includes("github-actions")) { + await setupGithubActions(projectDir); + } + + if (features.includes("SEO")) { + log.info( + pc.yellow( + "SEO feature is still a work-in-progress and will be available in a future update.", + ), + ); + } +} + +async function setupDocker(projectDir: string) { + const dockerfileContent = `FROM node:18-alpine AS base + +# Install dependencies only when needed +FROM base AS deps +RUN apk add --no-cache libc6-compat +WORKDIR /app + +# Install dependencies +COPY package.json bun.lockb* yarn.lock* package-lock.json* pnpm-lock.yaml* ./ +RUN \\ + if [ -f yarn.lock ]; then yarn --frozen-lockfile; \\ + elif [ -f package-lock.json ]; then npm ci; \\ + elif [ -f pnpm-lock.yaml ]; then yarn global add pnpm && pnpm i --frozen-lockfile; \\ + elif [ -f bun.lockb ]; then yarn global add bun && bun install --frozen-lockfile; \\ + else npm i; \\ + fi + +# Build the app +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# First build client +RUN npm run build -w @better-t/client +# Then build server +RUN npm run build -w @better-t/server + +# Production image +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV production + +# Copy necessary files from builder +COPY --from=builder /app/package.json ./package.json +COPY --from=builder /app/packages/server/dist ./packages/server/dist +COPY --from=builder /app/packages/client/dist ./packages/client/dist +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/packages/server/package.json ./packages/server/package.json +COPY --from=builder /app/packages/client/package.json ./packages/client/package.json + +EXPOSE 3000 + +CMD ["node", "packages/server/dist/index.js"] +`; + + // Create docker-compose.yml + const dockerComposeContent = `version: '3' + +services: + app: + build: . + ports: + - "3000:3000" + environment: + - NODE_ENV=production + - TURSO_DATABASE_URL=\${TURSO_DATABASE_URL} + - TURSO_AUTH_TOKEN=\${TURSO_AUTH_TOKEN} + - CORS_ORIGIN=\${CORS_ORIGIN} + restart: always +`; + + const dockerignoreContent = `.git +node_modules +**/node_modules +**/dist +.env +.env.* +`; + + await fs.writeFile(path.join(projectDir, "Dockerfile"), dockerfileContent); + await fs.writeFile( + path.join(projectDir, "docker-compose.yml"), + dockerComposeContent, + ); + await fs.writeFile( + path.join(projectDir, ".dockerignore"), + dockerignoreContent, + ); +} + +async function setupGithubActions(projectDir: string) { + const workflowsDir = path.join(projectDir, ".github/workflows"); + await fs.ensureDir(workflowsDir); + + const ciWorkflowContent = `name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: '18' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Type check + run: npm run check-types + + - name: Build + run: npm run build +`; + + const deployWorkflowContent = `name: Deploy + +on: + push: + branches: [ main ] + + # Enable manual trigger + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: '18' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + # Add your deployment steps here + # This is just a placeholder for your actual deployment logic + - name: Deploy + run: echo "Add your deployment commands here" +`; + + await fs.writeFile(path.join(workflowsDir, "ci.yml"), ciWorkflowContent); + await fs.writeFile( + path.join(workflowsDir, "deploy.yml"), + deployWorkflowContent, + ); +} diff --git a/apps/cli/src/helpers/post-installation.ts b/apps/cli/src/helpers/post-installation.ts new file mode 100644 index 0000000..fb2c76c --- /dev/null +++ b/apps/cli/src/helpers/post-installation.ts @@ -0,0 +1,50 @@ +import { log } from "@clack/prompts"; +import pc from "picocolors"; + +export function displayPostInstallInstructions( + hasAuth: boolean, + database: string, + projectName: string, + packageManager: string, + depsInstalled: boolean, + orm?: string, +) { + log.info(`${pc.cyan("Installation completed!")} Here are some next steps: + +${ + hasAuth && database !== "none" + ? `${pc.yellow("Authentication Setup:")} +${pc.cyan("1.")} Generate auth schema: ${pc.green(`cd ${projectName} && ${packageManager} run auth:generate`)} +${ + 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`)}` +} + +` + : "" +}${ + database === "postgres" + ? `${pc.yellow("PostgreSQL Configuration:")} +Make sure to update ${pc.cyan("packages/server/.env")} with your PostgreSQL connection string. + +` + : database === "sqlite" + ? `${pc.yellow("Database Configuration:")} +${pc.cyan("packages/server/.env")} contains your SQLite/Turso 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`); +} diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 92be8f0..5d0cab9 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -22,6 +22,7 @@ async function main() { try { renderTitle(); intro(pc.magenta("Creating a new Better-T-Stack project")); + program .name("create-better-t-stack") .description("Create a new Better-T Stack project") diff --git a/apps/cli/src/prompts/orm.ts b/apps/cli/src/prompts/orm.ts index fd480b4..720b8d3 100644 --- a/apps/cli/src/prompts/orm.ts +++ b/apps/cli/src/prompts/orm.ts @@ -17,6 +17,11 @@ export async function getORMChoice( label: "Drizzle", hint: "Type-safe, lightweight ORM (recommended)", }, + { + value: "prisma", + label: "Prisma", + hint: "Powerful, feature-rich ORM with schema migrations", + }, ], initialValue: "drizzle", });