add auth, drizzle, prisma setup logic with template

This commit is contained in:
Aman Varshney
2025-03-04 09:33:31 +05:30
parent 54d63823bb
commit 792885b9c4
68 changed files with 2692 additions and 921 deletions

View File

@@ -0,0 +1,5 @@
---
"create-better-t-stack": minor
---
add auth, drizzle, prisma setup logic

View File

@@ -14,4 +14,5 @@ export const DEFAULT_CONFIG: ProjectConfig = {
features: [],
git: true,
packageManager: "npm",
noInstall: false,
};

View File

@@ -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<void> {
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(/<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);
}
} catch (error) {
log.error(pc.red("Failed to configure authentication"));
if (error instanceof Error) {
log.error(pc.red(error.message));
}
throw error;
}
}

View File

@@ -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<string> {
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;
}
}

View File

@@ -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`;
}

View File

@@ -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<void> {
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<TursoConfig> {
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<void> {
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<void> {
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);
}
}
}

View File

@@ -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;
}
}

View File

@@ -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`);
}

View File

@@ -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<TursoConfig> {
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.");
}
}

View File

@@ -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);
});

View File

@@ -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,
};
}

View File

@@ -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<boolean> {
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;
}

View File

@@ -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<ProjectORM> {
): Promise<ProjectOrm> {
if (!hasDatabase) return "none";
if (orm !== undefined) return orm;
const response = await select<ProjectORM>({
const response = await select<ProjectOrm>({
message: "Which ORM would you like to use?",
options: [
{

View File

@@ -0,0 +1,18 @@
import { cancel, confirm, isCancel } from "@clack/prompts";
import pc from "picocolors";
export async function getTursoSetupChoice(turso?: boolean): Promise<boolean> {
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;
}

View File

@@ -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;
}

View File

@@ -27,6 +27,12 @@ export function displayConfig(config: Partial<ProjectConfig>) {
`${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");
}

View File

@@ -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";

View File

@@ -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;
}
}

View File

@@ -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"
}
}

View File

@@ -0,0 +1 @@
VITE_SERVER_URL=http://localhost:3000

View File

@@ -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

View File

@@ -0,0 +1,4 @@
{
"plugins": ["prettier-plugin-tailwindcss"],
"tailwindStylesheet": "./src/index.css"
}

View File

@@ -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"
}

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TanStack Router</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -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"
}
}

View File

@@ -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 (
<div>
<div className="flex flex-row items-center justify-between px-2 py-1">
<div className="flex gap-4 text-lg">
<Link
to="/"
activeProps={{
className: "font-bold",
}}
activeOptions={{ exact: true }}
>
Home
</Link>
<Link
to="/dashboard"
activeProps={{
className: "font-bold",
}}
activeOptions={{ exact: true }}
>
Dashboard
</Link>
<Link
to="/about"
activeProps={{
className: "font-bold",
}}
>
About
</Link>
</div>
<div className="flex flex-row items-center gap-2">
<ModeToggle />
<UserMenu />
</div>
</div>
<hr />
</div>
);
}

View File

@@ -0,0 +1,9 @@
import { Loader2 } from "lucide-react";
export default function Loader() {
return (
<div className="flex h-full items-center justify-center pt-8">
<Loader2 className="animate-spin" />
</div>
);
}

View File

@@ -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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -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<z.infer<typeof signUpSchema>>({
resolver: zodResolver(isSignUp ? signUpSchema : signInSchema),
defaultValues: {
email: "",
password: "",
name: "",
},
});
const onSubmit = async (values: z.infer<typeof signUpSchema>) => {
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 <Loader />;
}
return (
<div className="mx-auto mt-10 max-w-md p-6">
<h1 className="mb-6 text-center text-3xl font-bold">
{isSignUp ? "Create Account" : "Welcome Back"}
</h1>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{isSignUp && (
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full">
{isSignUp ? "Sign Up" : "Sign In"}
</Button>
</form>
</Form>
<div className="mt-4 text-center">
<Button
variant="link"
onClick={() => {
setIsSignUp(!isSignUp);
form.reset();
}}
className="text-indigo-600 hover:text-indigo-800"
>
{isSignUp
? "Already have an account? Sign In"
: "Need an account? Sign Up"}
</Button>
</div>
</div>
);
}

View File

@@ -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<ThemeProviderState>(initialState);
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "vite-ui-theme",
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (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 (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
);
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext);
if (context === undefined)
throw new Error("useTheme must be used within a ThemeProvider");
return context;
};

View File

@@ -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<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -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<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-hidden transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-hidden transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@@ -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<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue,
);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
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 <FormField>");
}
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<FormItemContextValue>(
{} as FormItemContextValue,
);
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
);
});
FormItem.displayName = "FormItem";
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField();
return (
<Label
ref={ref}
className={cn(error && "text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
);
});
FormLabel.displayName = "FormLabel";
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
);
});
FormControl.displayName = "FormControl";
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField();
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-[0.8rem] text-muted-foreground", className)}
{...props}
/>
);
});
FormDescription.displayName = "FormDescription";
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message) : children;
if (!body) {
return null;
}
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-[0.8rem] font-medium text-destructive", className)}
{...props}
>
{body}
</p>
);
});
FormMessage.displayName = "FormMessage";
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
};

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,24 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-primary/10", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -0,0 +1,29 @@
import { useTheme } from "next-themes"
import { Toaster as Sonner } from "sonner"
type ToasterProps = React.ComponentProps<typeof Sonner>
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
)
}
export { Toaster }

View File

@@ -0,0 +1,62 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { authClient } from "@/lib/auth-client";
import { useNavigate } from "@tanstack/react-router";
import { useEffect } from "react";
import { Button } from "./ui/button";
import { Skeleton } from "./ui/skeleton";
export default function UserMenu() {
const navigate = useNavigate();
const { data: session, isPending } = authClient.useSession();
useEffect(() => {
if (!session && !isPending) {
navigate({
to: "/",
});
}
}, [session, isPending]);
if (isPending) {
return <Skeleton className="h-9 w-24" />;
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">{session?.user.name}</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="bg-card">
<DropdownMenuLabel>My Account</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>{session?.user.email}</DropdownMenuItem>
<DropdownMenuItem asChild>
<Button
variant="destructive"
className="w-full"
onClick={() => {
authClient.signOut({
fetchOptions: {
onSuccess: () => {
navigate({
to: "/",
});
},
},
});
}}
>
Sign Out
</Button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,127 @@
@import 'tailwindcss';
@plugin 'tailwindcss-animate';
@custom-variant dark (&:is(.dark *));
@theme {
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground));
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--color-chart-1: hsl(var(--chart-1));
--color-chart-2: hsl(var(--chart-2));
--color-chart-3: hsl(var(--chart-3));
--color-chart-4: hsl(var(--chart-4));
--color-chart-5: hsl(var(--chart-5));
}
/*
The default border color has changed to `currentColor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
}
}
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -0,0 +1,5 @@
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL: import.meta.env.VITE_SERVER_URL,
});

View File

@@ -0,0 +1,12 @@
import { z } from "zod";
export const signUpSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Invalid email address"),
password: z.string().min(6, "Password must be at least 6 characters"),
});
export const signInSchema = z.object({
email: z.string().email("Invalid email address"),
password: z.string().min(6, "Password must be at least 6 characters"),
});

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -0,0 +1,77 @@
import {
QueryCache,
QueryClient,
QueryClientProvider,
} from "@tanstack/react-query";
import { RouterProvider, createRouter } from "@tanstack/react-router";
import { httpBatchLink } from "@trpc/client";
import { createTRPCQueryUtils } from "@trpc/react-query";
import ReactDOM from "react-dom/client";
import { toast } from "sonner";
import Loader from "./components/loader";
import { routeTree } from "./routeTree.gen";
import { trpc } from "./utils/trpc";
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error) => {
toast.error(error.message, {
action: {
label: "retry",
onClick: () => {
queryClient.invalidateQueries();
},
},
});
},
}),
});
const trpcClient = trpc.createClient({
links: [
httpBatchLink({
url: `${import.meta.env.VITE_SERVER_URL}/trpc`,
fetch(url, options) {
return fetch(url, {
...options,
credentials: "include",
});
},
}),
],
});
export const trpcQueryUtils = createTRPCQueryUtils({
queryClient,
client: trpcClient,
});
const router = createRouter({
routeTree,
defaultPreload: "intent",
context: { trpcQueryUtils },
defaultPendingComponent: () => <Loader />,
Wrap: function WrapComponent({ children }) {
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</trpc.Provider>
);
},
});
// Register things for typesafety
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
const rootElement = document.getElementById("app")!;
if (!rootElement.innerHTML) {
const root = ReactDOM.createRoot(rootElement);
root.render(<RouterProvider router={router} />);
}

View File

@@ -0,0 +1,39 @@
import Header from "@/components/header";
import Loader from "@/components/loader";
import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/sonner";
import { trpcQueryUtils } from "@/main";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import {
Outlet,
createRootRouteWithContext,
useRouterState,
} from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/router-devtools";
import "../index.css";
export interface RouterAppContext {
trpcQueryUtils: typeof trpcQueryUtils;
}
export const Route = createRootRouteWithContext<RouterAppContext>()({
component: RootComponent,
});
function RootComponent() {
const isFetching = useRouterState({
select: (s) => s.isLoading,
});
return (
<>
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
<Header />
{isFetching && <Loader />}
<Outlet />
<Toaster richColors />
</ThemeProvider>
<TanStackRouterDevtools position="bottom-left" />
<ReactQueryDevtools position="bottom" buttonPosition="bottom-right" />
</>
);
}

View File

@@ -0,0 +1,13 @@
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/about")({
component: AboutComponent,
});
function AboutComponent() {
return (
<div className="p-2">
<h3>About</h3>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { authClient } from "@/lib/auth-client";
import { trpc } from "@/utils/trpc";
import { createFileRoute } from "@tanstack/react-router";
import { useEffect } from "react";
export const Route = createFileRoute("/dashboard")({
component: RouteComponent,
loader: async ({ context: { trpcQueryUtils } }) => {
await trpcQueryUtils.healthCheck.ensureData();
await trpcQueryUtils.privateData.ensureData();
return;
},
});
function RouteComponent() {
const { data: session, isPending } = authClient.useSession();
const navigate = Route.useNavigate();
const privateData = trpc.privateData.useQuery();
useEffect(() => {
if (!session && !isPending) {
navigate({
to: "/",
});
}
}, [session, isPending]);
if (isPending) {
return <div>Loading...</div>;
}
return (
<div>
<h1>Dashboard</h1>
<p>Welcome {session?.user.name}</p>
<p>privateData: {privateData.data?.message}</p>
</div>
);
}

View File

@@ -0,0 +1,19 @@
import SignUp from "@/components/sign-up-form";
import { trpc } from "@/utils/trpc";
import { createFileRoute, Link } from "@tanstack/react-router";
export const Route = createFileRoute("/")({
component: HomeComponent,
});
function HomeComponent() {
const healthCheck = trpc.healthCheck.useQuery();
return (
<div className="p-2">
<h3>Welcome Home!</h3>
<Link to="/dashboard">Go to Dashboard</Link>
<p>healthCheck: {healthCheck.data}</p>
<SignUp />
</div>
);
}

View File

@@ -0,0 +1,4 @@
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "../../../server/src/routers";
export const trpc = createTRPCReact<AppRouter>();

View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"strict": true,
"esModuleInterop": true,
"jsx": "react-jsx",
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"types": ["vite/client"],
"rootDirs": ["."],
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

View File

@@ -0,0 +1,14 @@
import tailwindcss from "@tailwindcss/vite";
import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
import react from "@vitejs/plugin-react";
import path from "path";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [tailwindcss(), TanStackRouterVite({}), react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});

View File

@@ -0,0 +1,36 @@
# prod
dist/
# dev
.yarn/
!.yarn/releases
.vscode/*
!.vscode/launch.json
!.vscode/*.code-snippets
.idea/workspace.xml
.idea/usage.statistics.xml
.idea/shelf
# deps
node_modules/
.wrangler
# env
.env
.env.production
.dev.vars
# logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# misc
.DS_Store
# local db
*.db*

View File

@@ -0,0 +1,34 @@
{
"name": "@better-t/server",
"main": "src/index.ts",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"dev:bun": "bun run --hot src/index.ts",
"db:local": "turso dev --db-file local.db",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"check-types": "tsc --noEmit",
"wrangler:dev": "wrangler dev",
"wrangler:deploy": "wrangler deploy --minify",
"compile": "bun build --compile --minify --sourcemap --bytecode ./src/index.ts --outfile server"
},
"dependencies": {
"@hono/node-server": "^1.13.8",
"@hono/trpc-server": "^0.3.4",
"@libsql/client": "^0.14.0",
"@trpc/server": "^11.0.0-rc.748",
"better-auth": "^1.1.16",
"dotenv": "^16.4.7",
"drizzle-orm": "^0.38.4",
"hono": "^4.7.0",
"zod": "^3.24.1"
},
"devDependencies": {
"tsx": "^4.19.2",
"drizzle-kit": "^0.30.4",
"@types/node": "^22.13.4",
"typescript": "^5.7.3"
}
}

View File

@@ -0,0 +1,47 @@
import { serve } from "@hono/node-server";
import { trpcServer } from "@hono/trpc-server";
import "dotenv/config";
import { Hono } from "hono";
import { cors } from "hono/cors";
import { logger } from "hono/logger";
import { auth } from "./lib/auth";
import { createContext } from "./lib/context";
import { appRouter } from "./routers/index";
const app = new Hono();
app.use(logger());
app.use(
"/*",
cors({
origin: process.env.CORS_ORIGIN!,
allowMethods: ["GET", "POST", "OPTIONS"],
allowHeaders: ["Content-Type", "Authorization"],
credentials: true,
}),
);
app.on(["POST", "GET"], "/api/auth/**", (c) => auth.handler(c.req.raw));
app.use(
"/trpc/*",
trpcServer({
router: appRouter,
createContext: (_opts, hono) => {
return createContext({ hono });
},
}),
);
app.get("/healthCheck", (c) => {
return c.text("OK");
});
const port = 3000;
console.log(`Server is running on http://localhost:${port}`);
serve({
fetch: app.fetch,
port,
});

View File

@@ -0,0 +1,18 @@
import type { Context as HonoContext } from "hono";
import { auth } from "./auth";
export type CreateContextOptions = {
hono: HonoContext;
};
export async function createContext({ hono }: CreateContextOptions) {
const session = await auth.api.getSession({
headers: hono.req.raw.headers,
});
return {
session,
};
}
export type Context = Awaited<ReturnType<typeof createContext>>;

View File

@@ -0,0 +1,24 @@
import { initTRPC, TRPCError } from "@trpc/server";
import type { Context } from "./context";
export const t = initTRPC.context<Context>().create();
export const router = t.router;
export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.session) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Authentication required",
cause: "No session",
});
}
return next({
ctx: {
...ctx,
session: ctx.session,
},
});
});

View File

@@ -0,0 +1,15 @@
import { router, publicProcedure, protectedProcedure } from "../lib/trpc";
export const appRouter = router({
healthCheck: publicProcedure.query(() => {
return "OK";
}),
privateData: protectedProcedure.query(({ ctx }) => {
return {
message: "This is private",
user: ctx.session.user,
};
}),
});
export type AppRouter = typeof appRouter;

View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"esModuleInterop": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"outDir": "./dist",
"types": ["node"],
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx"
}
}

View File

@@ -0,0 +1,27 @@
{
"$schema": "https://turbo.build/schema.json",
"ui": "tui",
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["$TURBO_DEFAULT$", ".env*"],
"outputs": ["dist/**"]
},
"lint": {
"dependsOn": ["^lint"]
},
"check-types": {
"dependsOn": ["^check-types"]
},
"dev": {
"cache": false,
"persistent": true
},
"db:local": {
"cache": false
},
"db:push": {
"cache": false
}
}
}

View File

@@ -0,0 +1,4 @@
BETTER_AUTH_SECRET=jdUstNuiIZLVh897KOMMS8EmTP0QkD32
BETTER_AUTH_URL=http://localhost:3000
TURSO_CONNECTION_URL=http://127.0.0.1:8080
CORS_ORIGIN=http://localhost:3001

View File

@@ -0,0 +1,11 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/db/schema.ts",
out: "./migrations",
dialect: "turso",
dbCredentials: {
url: process.env.TURSO_CONNECTION_URL!,
authToken: process.env.TURSO_AUTH_TOKEN!,
},
});

View File

@@ -0,0 +1,9 @@
import { drizzle } from "drizzle-orm/libsql";
export const db = drizzle({
connection: {
url: process.env.TURSO_CONNECTION_URL!,
authToken: process.env.TURSO_AUTH_TOKEN!,
},
// logger: true,
});

View File

@@ -0,0 +1,55 @@
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
export const user = sqliteTable("user", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: integer("email_verified", { mode: "boolean" }).notNull(),
image: text("image"),
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
});
export const session = sqliteTable("session", {
id: text("id").primaryKey(),
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
token: text("token").notNull().unique(),
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id")
.notNull()
.references(() => user.id),
});
export const account = sqliteTable("account", {
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: integer("access_token_expires_at", {
mode: "timestamp",
}),
refreshTokenExpiresAt: integer("refresh_token_expires_at", {
mode: "timestamp",
}),
scope: text("scope"),
password: text("password"),
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
});
export const verification = sqliteTable("verification", {
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
createdAt: integer("created_at", { mode: "timestamp" }),
updatedAt: integer("updated_at", { mode: "timestamp" }),
});

View File

@@ -0,0 +1,15 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "../db";
import * as schema from "../db/schema";
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: "sqlite",
schema: schema,
}),
trustedOrigins: [process.env.CORS_ORIGIN!],
emailAndPassword: {
enabled: true,
},
});

View File

@@ -0,0 +1,13 @@
import { PrismaClient } from "@prisma/client";
import { PrismaLibSQL } from "@prisma/adapter-libsql";
import { createClient } from "@libsql/client";
const libsql = createClient({
url: process.env.TURSO_DATABASE_URL,
authToken: process.env.TURSO_AUTH_TOKEN,
});
const adapter = new PrismaLibSQL(libsql);
const prisma = new PrismaClient({ adapter });
export default prisma;

View File

@@ -0,0 +1,92 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
generator client {
provider = "prisma-client-js"
previewFeatures = ["driverAdapters"]
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
id String @id @map("_id")
name String
email String
emailVerified Boolean
image String?
createdAt DateTime
updatedAt DateTime
sessions Session[]
accounts Account[]
books Book[]
@@unique([email])
@@map("user")
}
model Session {
id String @id @map("_id")
expiresAt DateTime
token String
createdAt DateTime
updatedAt DateTime
ipAddress String?
userAgent String?
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([token])
@@map("session")
}
model Account {
id String @id @map("_id")
accountId String
providerId String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
accessToken String?
refreshToken String?
idToken String?
accessTokenExpiresAt DateTime?
refreshTokenExpiresAt DateTime?
scope String?
password String?
createdAt DateTime
updatedAt DateTime
@@map("account")
}
model Verification {
id String @id @map("_id")
identifier String
value String
expiresAt DateTime
createdAt DateTime?
updatedAt DateTime?
@@map("verification")
}
model Book {
id String @id @default(auto()) @map("_id") @db.ObjectId
title String
author String
description String?
price Float @default(0)
publishedAt DateTime
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([id, userId])
@@map("book")
}

View File

@@ -0,0 +1,9 @@
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import prisma from "../db";
export const auth = betterAuth({
database: prismaAdapter(prisma, {
provider: "sqlite", // or "mysql", "postgresql", ...etc
}),
});

View File

@@ -6,6 +6,7 @@ export default defineConfig({
clean: true,
dts: true,
shims: true,
minify: true,
splitting: false,
outDir: "dist",
banner: {

View File

@@ -7,7 +7,7 @@
},
"files": {
"ignoreUnknown": false,
"ignore": [".next", "dist", ".source", "out"]
"ignore": [".next", "dist", ".source", "out", "template"]
},
"formatter": {
"enabled": true,
@@ -20,7 +20,8 @@
"enabled": true,
"rules": {
"recommended": true
}
},
"ignore": ["template"]
},
"javascript": {
"formatter": {