mirror of
https://github.com/FranP-code/create-better-t-stack.git
synced 2025-10-12 23:52:15 +00:00
implement auth add or remove logic
This commit is contained in:
5
.changeset/two-kiwis-push.md
Normal file
5
.changeset/two-kiwis-push.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"create-better-t-stack": minor
|
||||
---
|
||||
|
||||
implement auth add or remove logic
|
||||
@@ -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 <type> "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)
|
||||
|
||||
496
apps/cli/src/helpers/auth-setup.ts
Normal file
496
apps/cli/src/helpers/auth-setup.ts
Normal file
@@ -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(/<SignUp \/>/, "");
|
||||
|
||||
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(/<UserMenu \/>/, "");
|
||||
|
||||
await fs.writeFile(file, content);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
95
apps/cli/src/helpers/create-readme.ts
Normal file
95
apps/cli/src/helpers/create-readme.ts
Normal file
@@ -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);
|
||||
}
|
||||
185
apps/cli/src/helpers/feature-setup.ts
Normal file
185
apps/cli/src/helpers/feature-setup.ts
Normal file
@@ -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,
|
||||
);
|
||||
}
|
||||
50
apps/cli/src/helpers/post-installation.ts
Normal file
50
apps/cli/src/helpers/post-installation.ts
Normal file
@@ -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`);
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user