This commit is contained in:
Aman Varshney
2025-04-14 21:45:28 +05:30
parent 8b03441909
commit 7f441ef670
268 changed files with 3513 additions and 3039 deletions

View File

@@ -0,0 +1,5 @@
---
"create-better-t-stack": major
---
add orpc, make turborepo optional, use handlebarjs to scaffold template

8
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,8 @@
{
"files.watcherExclude": {
"**/.git/objects/**": true,
"**/.git/subtree-cache/**": true,
"**/.hg/store/**": true,
"**/templates/**": true
}
}

View File

@@ -9,7 +9,7 @@
"create-better-t-stack": "dist/index.js" "create-better-t-stack": "dist/index.js"
}, },
"files": [ "files": [
"template", "templates",
"dist" "dist"
], ],
"keywords": [ "keywords": [
@@ -57,12 +57,15 @@
"consola": "^3.4.2", "consola": "^3.4.2",
"execa": "^8.0.1", "execa": "^8.0.1",
"fs-extra": "^11.3.0", "fs-extra": "^11.3.0",
"globby": "^14.1.0",
"gradient-string": "^3.0.0", "gradient-string": "^3.0.0",
"handlebars": "^4.7.8",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"yargs": "^17.7.2" "yargs": "^17.7.2"
}, },
"devDependencies": { "devDependencies": {
"@types/fs-extra": "^11.0.4", "@types/fs-extra": "^11.0.4",
"@types/globby": "^9.1.0",
"@types/node": "^20.17.30", "@types/node": "^20.17.30",
"@types/yargs": "^17.0.33", "@types/yargs": "^17.0.33",
"tsup": "^8.4.0", "tsup": "^8.4.0",

View File

@@ -17,10 +17,11 @@ export const DEFAULT_CONFIG: ProjectConfig = {
examples: [], examples: [],
git: true, git: true,
packageManager: getUserPkgManager(), packageManager: getUserPkgManager(),
noInstall: false, install: true,
dbSetup: "none", dbSetup: "none",
backend: "hono", backend: "hono",
runtime: "bun", runtime: "bun",
api: "trpc",
}; };
export const dependencyVersionMap = { export const dependencyVersionMap = {
@@ -69,10 +70,20 @@ export const dependencyVersionMap = {
"@types/express": "^5.0.1", "@types/express": "^5.0.1",
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
turbo: "^2.4.2",
ai: "^4.2.8", ai: "^4.2.8",
"@ai-sdk/google": "^1.2.3", "@ai-sdk/google": "^1.2.3",
"@prisma/extension-accelerate": "^1.3.0", "@prisma/extension-accelerate": "^1.3.0",
"@orpc/server": "^1.0.3",
"@orpc/react-query": "^1.0.3",
"@orpc/client": "^1.0.3",
"@trpc/tanstack-react-query": "^11.0.0",
"@trpc/server": "^11.0.0",
"@trpc/client": "^11.0.0",
} as const; } as const;
export type AvailableDependencies = keyof typeof dependencyVersionMap; export type AvailableDependencies = keyof typeof dependencyVersionMap;

View File

@@ -1,29 +1,30 @@
import path from "node:path"; import path from "node:path";
import fs from "fs-extra"; import fs from "fs-extra";
import { PKG_ROOT } from "../constants"; import type { ProjectFrontend } from "../types";
import type {
ProjectAddons,
ProjectFrontend,
ProjectPackageManager,
} from "../types";
import { addPackageDependency } from "../utils/add-package-deps"; import { addPackageDependency } from "../utils/add-package-deps";
import { setupStarlight } from "./starlight-setup"; import { setupStarlight } from "./starlight-setup";
import { setupTauri } from "./tauri-setup"; import { setupTauri } from "./tauri-setup";
export async function setupAddons( import type { ProjectConfig } from "../types";
projectDir: string,
addons: ProjectAddons[], export async function setupAddons(config: ProjectConfig) {
packageManager: ProjectPackageManager, const { projectName, addons, packageManager, frontend } = config;
frontends: ProjectFrontend[], const projectDir = path.resolve(process.cwd(), projectName);
) {
const hasWebFrontend = const hasWebFrontend =
frontends.includes("react-router") || frontends.includes("tanstack-router"); frontend.includes("react-router") || frontend.includes("tanstack-router");
if (addons.includes("turborepo")) {
await addPackageDependency({
devDependencies: ["turbo"],
projectDir,
});
}
if (addons.includes("pwa") && hasWebFrontend) { if (addons.includes("pwa") && hasWebFrontend) {
await setupPwa(projectDir, frontends); await setupPwa(projectDir, frontend);
} }
if (addons.includes("tauri") && hasWebFrontend) { if (addons.includes("tauri") && hasWebFrontend) {
await setupTauri(projectDir, packageManager, frontends); await setupTauri(config);
} }
if (addons.includes("biome")) { if (addons.includes("biome")) {
await setupBiome(projectDir); await setupBiome(projectDir);
@@ -32,7 +33,7 @@ export async function setupAddons(
await setupHusky(projectDir); await setupHusky(projectDir);
} }
if (addons.includes("starlight")) { if (addons.includes("starlight")) {
await setupStarlight(projectDir, packageManager); await setupStarlight(config);
} }
} }
@@ -44,12 +45,7 @@ export function getWebAppDir(
} }
async function setupBiome(projectDir: string) { async function setupBiome(projectDir: string) {
const biomeTemplateDir = path.join(PKG_ROOT, "template/with-biome"); await addPackageDependency({
if (await fs.pathExists(biomeTemplateDir)) {
await fs.copy(biomeTemplateDir, projectDir, { overwrite: true });
}
addPackageDependency({
devDependencies: ["@biomejs/biome"], devDependencies: ["@biomejs/biome"],
projectDir, projectDir,
}); });
@@ -68,12 +64,7 @@ async function setupBiome(projectDir: string) {
} }
async function setupHusky(projectDir: string) { async function setupHusky(projectDir: string) {
const huskyTemplateDir = path.join(PKG_ROOT, "template/with-husky"); await addPackageDependency({
if (await fs.pathExists(huskyTemplateDir)) {
await fs.copy(huskyTemplateDir, projectDir, { overwrite: true });
}
addPackageDependency({
devDependencies: ["husky", "lint-staged"], devDependencies: ["husky", "lint-staged"],
projectDir, projectDir,
}); });
@@ -98,81 +89,18 @@ async function setupHusky(projectDir: string) {
} }
async function setupPwa(projectDir: string, frontends: ProjectFrontend[]) { async function setupPwa(projectDir: string, frontends: ProjectFrontend[]) {
const pwaTemplateDir = path.join(PKG_ROOT, "template/with-pwa");
if (await fs.pathExists(pwaTemplateDir)) {
await fs.copy(pwaTemplateDir, projectDir, { overwrite: true });
}
const clientPackageDir = getWebAppDir(projectDir, frontends); const clientPackageDir = getWebAppDir(projectDir, frontends);
if (!(await fs.pathExists(clientPackageDir))) { if (!(await fs.pathExists(clientPackageDir))) {
return; return;
} }
addPackageDependency({ await addPackageDependency({
dependencies: ["vite-plugin-pwa"], dependencies: ["vite-plugin-pwa"],
devDependencies: ["@vite-pwa/assets-generator"], devDependencies: ["@vite-pwa/assets-generator"],
projectDir: clientPackageDir, projectDir: clientPackageDir,
}); });
const viteConfigPath = path.join(clientPackageDir, "vite.config.ts");
if (await fs.pathExists(viteConfigPath)) {
let viteConfig = await fs.readFile(viteConfigPath, "utf8");
if (!viteConfig.includes("vite-plugin-pwa")) {
const firstImportMatch = viteConfig.match(
/^import .* from ['"](.*)['"]/m,
);
if (firstImportMatch) {
viteConfig = viteConfig.replace(
firstImportMatch[0],
`import { VitePWA } from "vite-plugin-pwa";\n${firstImportMatch[0]}`,
);
} else {
viteConfig = `import { VitePWA } from "vite-plugin-pwa";\n${viteConfig}`;
}
}
const pwaPluginCode = `VitePWA({
registerType: "autoUpdate",
manifest: {
name: "My App",
short_name: "My App",
description: "My App",
theme_color: "#0c0c0c",
},
pwaAssets: {
disabled: false,
config: true,
},
devOptions: {
enabled: true,
},
})`;
if (!viteConfig.includes("VitePWA(")) {
if (frontends.includes("react-router")) {
viteConfig = viteConfig.replace(
/plugins: \[\s*tailwindcss\(\)/,
`plugins: [\n tailwindcss(),\n ${pwaPluginCode}`,
);
} else if (frontends.includes("tanstack-router")) {
viteConfig = viteConfig.replace(
/plugins: \[\s*tailwindcss\(\)/,
`plugins: [\n tailwindcss(),\n ${pwaPluginCode}`,
);
} else {
viteConfig = viteConfig.replace(
/plugins: \[/,
`plugins: [\n ${pwaPluginCode},`,
);
}
}
await fs.writeFile(viteConfigPath, viteConfig);
}
const clientPackageJsonPath = path.join(clientPackageDir, "package.json"); const clientPackageJsonPath = path.join(clientPackageDir, "package.json");
if (await fs.pathExists(clientPackageJsonPath)) { if (await fs.pathExists(clientPackageJsonPath)) {
const packageJson = await fs.readJson(clientPackageJsonPath); const packageJson = await fs.readJson(clientPackageJsonPath);

View File

@@ -0,0 +1,37 @@
import * as path from "node:path";
import type { ProjectApi, ProjectConfig } from "../types";
import { addPackageDependency } from "../utils/add-package-deps";
export async function setupApi(config: ProjectConfig): Promise<void> {
if (config.api === "none") return;
const { api, projectName } = config;
const projectDir = path.resolve(process.cwd(), projectName);
const webDir = path.join(projectDir, "apps/web");
const serverDir = path.join(projectDir, "apps/server");
if (api === "orpc") {
await addPackageDependency({
dependencies: ["@orpc/react-query", "@orpc/server", "@orpc/client"],
projectDir: webDir,
});
await addPackageDependency({
dependencies: ["@orpc/server", "@orpc/client"],
projectDir: serverDir,
});
}
if (api === "trpc") {
await addPackageDependency({
dependencies: [
"@trpc/tanstack-react-query",
"@trpc/server",
"@trpc/client",
],
projectDir: webDir,
});
await addPackageDependency({
dependencies: ["@trpc/server", "@trpc/client"],
projectDir: serverDir,
});
}
}

View File

@@ -15,40 +15,40 @@ export function generateAuthSecret(length = 32): string {
return result; return result;
} }
export async function setupAuth( import type { ProjectConfig } from "../types";
projectDir: string,
enableAuth: boolean, export async function setupAuth(config: ProjectConfig): Promise<void> {
frontends: ProjectFrontend[] = [], const { projectName, auth, frontend } = config;
): Promise<void> { if (!auth) {
if (!enableAuth) {
return; return;
} }
const projectDir = path.resolve(process.cwd(), projectName);
const serverDir = path.join(projectDir, "apps/server"); const serverDir = path.join(projectDir, "apps/server");
const clientDir = path.join(projectDir, "apps/web"); const clientDir = path.join(projectDir, "apps/web");
const nativeDir = path.join(projectDir, "apps/native"); const nativeDir = path.join(projectDir, "apps/native");
try { try {
addPackageDependency({ await addPackageDependency({
dependencies: ["better-auth"], dependencies: ["better-auth"],
projectDir: serverDir, projectDir: serverDir,
}); });
if ( if (
frontends.includes("react-router") || frontend.includes("react-router") ||
frontends.includes("tanstack-router") || frontend.includes("tanstack-router") ||
frontends.includes("tanstack-start") frontend.includes("tanstack-start")
) { ) {
addPackageDependency({ await addPackageDependency({
dependencies: ["better-auth"], dependencies: ["better-auth"],
projectDir: clientDir, projectDir: clientDir,
}); });
} }
if (frontends.includes("native")) { if (frontend.includes("native")) {
addPackageDependency({ await addPackageDependency({
dependencies: ["better-auth", "@better-auth/expo"], dependencies: ["better-auth", "@better-auth/expo"],
projectDir: nativeDir, projectDir: nativeDir,
}); });
addPackageDependency({ await addPackageDependency({
dependencies: ["@better-auth/expo"], dependencies: ["@better-auth/expo"],
projectDir: serverDir, projectDir: serverDir,
}); });

View File

@@ -3,11 +3,14 @@ import type { AvailableDependencies } from "../constants";
import type { ProjectBackend, ProjectRuntime } from "../types"; import type { ProjectBackend, ProjectRuntime } from "../types";
import { addPackageDependency } from "../utils/add-package-deps"; import { addPackageDependency } from "../utils/add-package-deps";
import type { ProjectConfig } from "../types";
export async function setupBackendDependencies( export async function setupBackendDependencies(
projectDir: string, config: ProjectConfig,
framework: ProjectBackend,
runtime: ProjectRuntime,
): Promise<void> { ): Promise<void> {
const { projectName, backend, runtime } = config;
const projectDir = path.resolve(process.cwd(), projectName);
const framework = backend;
const serverDir = path.join(projectDir, "apps/server"); const serverDir = path.join(projectDir, "apps/server");
const dependencies: AvailableDependencies[] = []; const dependencies: AvailableDependencies[] = [];
@@ -40,7 +43,7 @@ export async function setupBackendDependencies(
devDependencies.push("@types/bun"); devDependencies.push("@types/bun");
} }
addPackageDependency({ await addPackageDependency({
dependencies, dependencies,
devDependencies, devDependencies,
projectDir: serverDir, projectDir: serverDir,

View File

@@ -1,9 +1,10 @@
import path from "node:path"; import path from "node:path";
import { cancel, spinner } from "@clack/prompts"; import { cancel, log, spinner } from "@clack/prompts";
import fs from "fs-extra"; import fs from "fs-extra";
import pc from "picocolors"; import pc from "picocolors";
import type { ProjectConfig } from "../types"; import type { ProjectConfig } from "../types";
import { setupAddons } from "./addons-setup"; import { setupAddons } from "./addons-setup";
import { setupApi } from "./api-setup";
import { setupAuth } from "./auth-setup"; import { setupAuth } from "./auth-setup";
import { setupBackendDependencies } from "./backend-framework-setup"; import { setupBackendDependencies } from "./backend-framework-setup";
import { createReadme } from "./create-readme"; import { createReadme } from "./create-readme";
@@ -17,10 +18,13 @@ import { setupRuntime } from "./runtime-setup";
import { import {
copyBaseTemplate, copyBaseTemplate,
fixGitignoreFiles, fixGitignoreFiles,
handleExtras,
setupAddonsTemplate,
setupAuthTemplate, setupAuthTemplate,
setupBackendFramework, setupBackendFramework,
setupDbOrmTemplates,
setupExamplesTemplate,
setupFrontendTemplates, setupFrontendTemplates,
setupOrmTemplate,
} from "./template-manager"; } from "./template-manager";
export async function createProject(options: ProjectConfig): Promise<string> { export async function createProject(options: ProjectConfig): Promise<string> {
@@ -30,74 +34,47 @@ export async function createProject(options: ProjectConfig): Promise<string> {
try { try {
await fs.ensureDir(projectDir); await fs.ensureDir(projectDir);
await copyBaseTemplate(projectDir); await copyBaseTemplate(projectDir, options);
await setupFrontendTemplates(projectDir, options.frontend); await setupFrontendTemplates(projectDir, options);
await fixGitignoreFiles(projectDir); await setupBackendFramework(projectDir, options);
await setupBackendDependencies(options);
await setupBackendFramework(projectDir, options.backend); await setupDbOrmTemplates(projectDir, options);
await setupBackendDependencies(
projectDir,
options.backend,
options.runtime,
);
await setupOrmTemplate( await setupDatabase(options);
projectDir,
options.orm,
options.database,
options.auth,
);
await setupDatabase( await setupAuthTemplate(projectDir, options);
projectDir, await setupAuth(options);
options.database,
options.orm,
options.packageManager,
options.dbSetup === "turso",
options.dbSetup === "prisma-postgres",
options.dbSetup === "mongodb-atlas",
options.dbSetup === "neon",
);
await setupAuthTemplate( await setupAddonsTemplate(projectDir, options);
projectDir, if (options.addons.length > 0 && options.addons[0] !== "none") {
options.auth, await setupAddons(options);
options.backend,
options.orm,
options.database,
options.frontend,
);
await setupAuth(projectDir, options.auth, options.frontend);
await setupRuntime(projectDir, options.runtime, options.backend);
await setupExamples(
projectDir,
options.examples,
options.orm,
options.auth,
options.backend,
options.frontend,
);
await setupEnvironmentVariables(projectDir, options);
await initializeGit(projectDir, options.git);
if (options.addons.length > 0) {
await setupAddons(
projectDir,
options.addons,
options.packageManager,
options.frontend,
);
} }
await setupExamplesTemplate(projectDir, options);
await handleExtras(projectDir, options);
if (options.examples.length > 0 && options.examples[0] !== "none") {
await setupExamples(options);
}
await setupApi(options);
await setupRuntime(options);
await setupEnvironmentVariables(options);
await updatePackageConfigurations(projectDir, options); await updatePackageConfigurations(projectDir, options);
await createReadme(projectDir, options); await createReadme(projectDir, options);
if (!options.noInstall) { await initializeGit(projectDir, options.git);
await fixGitignoreFiles(projectDir, options);
log.success("Project template successfully scaffolded!");
if (options.install) {
await installDependencies({ await installDependencies({
projectDir, projectDir,
packageManager: options.packageManager, packageManager: options.packageManager,
@@ -105,22 +82,17 @@ export async function createProject(options: ProjectConfig): Promise<string> {
}); });
} }
displayPostInstallInstructions( displayPostInstallInstructions({
options.database, ...options,
options.projectName, depsInstalled: options.install,
options.packageManager, });
!options.noInstall,
options.orm,
options.addons,
options.runtime,
options.frontend,
);
return projectDir; return projectDir;
} catch (error) { } catch (error) {
s.message(pc.red("Failed")); s.stop(pc.red("Failed"));
if (error instanceof Error) { if (error instanceof Error) {
cancel(pc.red(`Error during project creation: ${error.message}`)); cancel(pc.red(`Error during project creation: ${error.message}`));
console.error(error.stack);
process.exit(1); process.exit(1);
} }
throw error; throw error;

View File

@@ -10,50 +10,46 @@ import type {
} from "../types"; } from "../types";
import { addPackageDependency } from "../utils/add-package-deps"; import { addPackageDependency } from "../utils/add-package-deps";
import { setupMongoDBAtlas } from "./mongodb-atlas-setup"; import { setupMongoDBAtlas } from "./mongodb-atlas-setup";
import { setupNeonPostgres } from "./neon-setup";
import { setupPrismaPostgres } from "./prisma-postgres-setup"; import { setupPrismaPostgres } from "./prisma-postgres-setup";
import { setupTurso } from "./turso-setup"; import { setupTurso } from "./turso-setup";
export async function setupDatabase( import { setupNeonPostgres } from "./neon-setup";
projectDir: string,
databaseType: ProjectDatabase, import type { ProjectConfig } from "../types";
orm: ProjectOrm,
packageManager: ProjectPackageManager, export async function setupDatabase(config: ProjectConfig): Promise<void> {
setupTursoDb: boolean, const { projectName, database, orm, packageManager, dbSetup } = config;
setupPrismaPostgresDb: boolean, const projectDir = path.resolve(process.cwd(), projectName);
setupMongoDBAtlasDb: boolean,
setupNeonPostgresDb: boolean,
): Promise<void> {
const s = spinner(); const s = spinner();
const serverDir = path.join(projectDir, "apps/server"); const serverDir = path.join(projectDir, "apps/server");
if (databaseType === "none") { if (database === "none") {
await fs.remove(path.join(serverDir, "src/db")); await fs.remove(path.join(serverDir, "src/db"));
return; return;
} }
try { try {
if (orm === "prisma") { if (orm === "prisma") {
addPackageDependency({ await addPackageDependency({
dependencies: ["@prisma/client"], dependencies: ["@prisma/client"],
devDependencies: ["prisma"], devDependencies: ["prisma"],
projectDir: serverDir, projectDir: serverDir,
}); });
} else if (orm === "drizzle") { } else if (orm === "drizzle") {
if (databaseType === "sqlite") { if (database === "sqlite") {
addPackageDependency({ await addPackageDependency({
dependencies: ["drizzle-orm", "@libsql/client"], dependencies: ["drizzle-orm", "@libsql/client"],
devDependencies: ["drizzle-kit"], devDependencies: ["drizzle-kit"],
projectDir: serverDir, projectDir: serverDir,
}); });
} else if (databaseType === "postgres") { } else if (database === "postgres") {
addPackageDependency({ await addPackageDependency({
dependencies: ["drizzle-orm", "pg"], dependencies: ["drizzle-orm", "pg"],
devDependencies: ["drizzle-kit", "@types/pg"], devDependencies: ["drizzle-kit", "@types/pg"],
projectDir: serverDir, projectDir: serverDir,
}); });
} else if (databaseType === "mysql") { } else if (database === "mysql") {
addPackageDependency({ await addPackageDependency({
dependencies: ["drizzle-orm", "mysql2"], dependencies: ["drizzle-orm", "mysql2"],
devDependencies: ["drizzle-kit"], devDependencies: ["drizzle-kit"],
projectDir: serverDir, projectDir: serverDir,
@@ -61,16 +57,16 @@ export async function setupDatabase(
} }
} }
if (databaseType === "sqlite" && setupTursoDb) { if (database === "sqlite" && dbSetup === "turso") {
await setupTurso(projectDir, orm === "drizzle"); await setupTurso(config);
} else if (databaseType === "postgres") { } else if (database === "postgres") {
if (orm === "prisma" && setupPrismaPostgresDb) { if (orm === "prisma" && dbSetup === "prisma-postgres") {
await setupPrismaPostgres(projectDir, packageManager); await setupPrismaPostgres(config);
} else if (setupNeonPostgresDb) { } else if (dbSetup === "neon") {
await setupNeonPostgres(projectDir, packageManager); await setupNeonPostgres(config);
} }
} else if (databaseType === "mongodb" && setupMongoDBAtlasDb) { } else if (database === "mongodb" && dbSetup === "mongodb-atlas") {
await setupMongoDBAtlas(projectDir); await setupMongoDBAtlas(config);
} }
} catch (error) { } catch (error) {
s.stop(pc.red("Failed to set up database")); s.stop(pc.red("Failed to set up database"));

View File

@@ -42,9 +42,11 @@ async function addEnvVariablesToFile(
} }
export async function setupEnvironmentVariables( export async function setupEnvironmentVariables(
projectDir: string, config: ProjectConfig,
options: ProjectConfig,
): Promise<void> { ): Promise<void> {
const { projectName } = config;
const projectDir = path.resolve(process.cwd(), projectName);
const options = config;
const serverDir = path.join(projectDir, "apps/server"); const serverDir = path.join(projectDir, "apps/server");
const envPath = path.join(serverDir, ".env"); const envPath = path.join(serverDir, ".env");

View File

@@ -1,387 +1,35 @@
import path from "node:path"; import path from "node:path";
import fs from "fs-extra"; import fs from "fs-extra";
import { PKG_ROOT } from "../constants"; import { PKG_ROOT } from "../constants";
import type { ProjectBackend, ProjectFrontend, ProjectOrm } from "../types"; import type {
ProjectBackend,
ProjectConfig,
ProjectFrontend,
ProjectOrm,
} from "../types";
import { addPackageDependency } from "../utils/add-package-deps"; import { addPackageDependency } from "../utils/add-package-deps";
export async function setupExamples( export async function setupExamples(config: ProjectConfig): Promise<void> {
projectDir: string, const {
examples: string[], projectName,
orm: ProjectOrm, examples,
auth: boolean, orm,
backend: ProjectBackend, auth,
frontend: ProjectFrontend[] = ["tanstack-router"], backend,
): Promise<void> { frontend = ["tanstack-router"],
const hasTanstackRouter = frontend.includes("tanstack-router"); } = config;
const hasTanstackStart = frontend.includes("tanstack-start"); const projectDir = path.resolve(process.cwd(), projectName);
const hasReactRouter = frontend.includes("react-router");
const hasWebFrontend =
hasTanstackRouter || hasReactRouter || hasTanstackStart;
let routerType: string;
if (hasTanstackRouter) {
routerType = "web-tanstack-router";
} else if (hasTanstackStart) {
routerType = "web-tanstack-start";
} else {
routerType = "web-react-router";
}
const webAppExists = await fs.pathExists(path.join(projectDir, "apps/web"));
if (examples.includes("todo") && hasWebFrontend && webAppExists) {
await setupTodoExample(projectDir, orm, auth, routerType);
} else {
await cleanupTodoFiles(projectDir, orm);
}
if (
examples.includes("ai") &&
(backend === "hono" || backend === "express") &&
hasWebFrontend &&
webAppExists
) {
await setupAIExample(projectDir, routerType);
}
}
async function setupAIExample(
projectDir: string,
routerType: string,
): Promise<void> {
const aiExampleDir = path.join(PKG_ROOT, "template/examples/ai");
if (await fs.pathExists(aiExampleDir)) {
const aiRouteSourcePath = path.join(
aiExampleDir,
`apps/${routerType}/src/routes/ai.tsx`,
);
const aiRouteTargetPath = path.join(
projectDir,
"apps/web/src/routes/ai.tsx",
);
if (await fs.pathExists(aiRouteSourcePath)) {
await fs.copy(aiRouteSourcePath, aiRouteTargetPath, { overwrite: true });
}
await updateHeaderWithAILink(projectDir, routerType);
if (examples.includes("ai")) {
const clientDir = path.join(projectDir, "apps/web"); const clientDir = path.join(projectDir, "apps/web");
addPackageDependency({ await addPackageDependency({
dependencies: ["ai"], dependencies: ["ai"],
projectDir: clientDir, projectDir: clientDir,
}); });
const serverDir = path.join(projectDir, "apps/server"); const serverDir = path.join(projectDir, "apps/server");
addPackageDependency({ await addPackageDependency({
dependencies: ["ai", "@ai-sdk/google"], dependencies: ["ai", "@ai-sdk/google"],
projectDir: serverDir, projectDir: serverDir,
}); });
await updateServerIndexWithAIRoute(projectDir);
}
}
async function updateServerIndexWithAIRoute(projectDir: string): Promise<void> {
const serverIndexPath = path.join(projectDir, "apps/server/src/index.ts");
if (await fs.pathExists(serverIndexPath)) {
let indexContent = await fs.readFile(serverIndexPath, "utf8");
const isHono = indexContent.includes("hono");
const isExpress = indexContent.includes("express");
if (isHono) {
const importSection = `import { streamText } from "ai";\nimport { google } from "@ai-sdk/google";\nimport { stream } from "hono/streaming";`;
const aiRouteHandler = `
// AI chat endpoint
app.post("/ai", async (c) => {
const body = await c.req.json();
const messages = body.messages || [];
const result = streamText({
model: google("gemini-1.5-flash"),
messages,
});
c.header("X-Vercel-AI-Data-Stream", "v1");
c.header("Content-Type", "text/plain; charset=utf-8");
return stream(c, (stream) => stream.pipe(result.toDataStream()));
});`;
if (indexContent.includes("import {")) {
const lastImportIndex = indexContent.lastIndexOf("import");
const endOfLastImport = indexContent.indexOf("\n", lastImportIndex);
indexContent = `${indexContent.substring(0, endOfLastImport + 1)}
${importSection}
${indexContent.substring(endOfLastImport + 1)}`;
} else {
indexContent = `${importSection}
${indexContent}`;
}
const trpcHandlerIndex =
indexContent.indexOf('app.use("/trpc"') ||
indexContent.indexOf("app.use(trpc(");
if (trpcHandlerIndex !== -1) {
indexContent = `${indexContent.substring(0, trpcHandlerIndex)}${aiRouteHandler}
${indexContent.substring(trpcHandlerIndex)}`;
} else {
const exportIndex = indexContent.indexOf("export default");
if (exportIndex !== -1) {
indexContent = `${indexContent.substring(0, exportIndex)}${aiRouteHandler}
${indexContent.substring(exportIndex)}`;
} else {
indexContent = `${indexContent}
${aiRouteHandler}`;
}
}
} else if (isExpress) {
const importSection = `import { streamText } from "ai";\nimport { google } from "@ai-sdk/google";`;
const aiRouteHandler = `
// AI chat endpoint
app.post("/ai", async (req, res) => {
const { messages = [] } = req.body;
const result = streamText({
model: google("gemini-1.5-flash"),
messages,
});
result.pipeDataStreamToResponse(res);
});`;
if (
indexContent.includes("import {") ||
indexContent.includes("import ")
) {
const lastImportIndex = indexContent.lastIndexOf("import");
const endOfLastImport = indexContent.indexOf("\n", lastImportIndex);
indexContent = `${indexContent.substring(0, endOfLastImport + 1)}
${importSection}
${indexContent.substring(endOfLastImport + 1)}`;
} else {
indexContent = `${importSection}
${indexContent}`;
}
const trpcHandlerIndex = indexContent.indexOf('app.use("/trpc"');
if (trpcHandlerIndex !== -1) {
indexContent = `${indexContent.substring(0, trpcHandlerIndex)}${aiRouteHandler}
${indexContent.substring(trpcHandlerIndex)}`;
} else {
const appListenIndex = indexContent.indexOf("app.listen(");
if (appListenIndex !== -1) {
const prevNewlineIndex = indexContent.lastIndexOf(
"\n",
appListenIndex,
);
indexContent = `${indexContent.substring(0, prevNewlineIndex)}${aiRouteHandler}
${indexContent.substring(prevNewlineIndex)}`;
} else {
indexContent = `${indexContent}
${aiRouteHandler}`;
}
}
}
await fs.writeFile(serverIndexPath, indexContent);
}
}
async function updateHeaderWithAILink(
projectDir: string,
routerType: string,
): Promise<void> {
const headerPath = path.join(
projectDir,
"apps/web/src/components/header.tsx",
);
if (await fs.pathExists(headerPath)) {
let headerContent = await fs.readFile(headerPath, "utf8");
const linksPattern = /const links = \[\s*([^;]*?)\s*\];/s;
const linksMatch = headerContent.match(linksPattern);
if (linksMatch) {
const linksContent = linksMatch[1];
if (!linksContent.includes('"/ai"')) {
const updatedLinks = `const links = [\n ${linksContent}${
linksContent.trim().endsWith(",") ? "" : ","
}\n { to: "/ai", label: "AI Chat" },\n ];`;
headerContent = headerContent.replace(linksPattern, updatedLinks);
await fs.writeFile(headerPath, headerContent);
}
}
}
}
async function setupTodoExample(
projectDir: string,
orm: ProjectOrm,
auth: boolean,
routerType: string,
): Promise<void> {
const todoExampleDir = path.join(PKG_ROOT, "template/examples/todo");
if (await fs.pathExists(todoExampleDir)) {
const todoRouteSourcePath = path.join(
todoExampleDir,
`apps/${routerType}/src/routes/todos.tsx`,
);
const todoRouteTargetPath = path.join(
projectDir,
"apps/web/src/routes/todos.tsx",
);
if (await fs.pathExists(todoRouteSourcePath)) {
await fs.copy(todoRouteSourcePath, todoRouteTargetPath, {
overwrite: true,
});
}
if (orm !== "none") {
const todoRouterSourceFile = path.join(
todoExampleDir,
`apps/server/src/routers/with-${orm}-todo.ts`,
);
const todoRouterTargetFile = path.join(
projectDir,
"apps/server/src/routers/todo.ts",
);
if (await fs.pathExists(todoRouterSourceFile)) {
await fs.copy(todoRouterSourceFile, todoRouterTargetFile, {
overwrite: true,
});
}
await updateRouterIndexToIncludeTodo(projectDir);
}
await updateHeaderWithTodoLink(projectDir, routerType);
}
}
async function updateRouterIndexToIncludeTodo(
projectDir: string,
): Promise<void> {
const routerFile = path.join(projectDir, "apps/server/src/routers/index.ts");
if (await fs.pathExists(routerFile)) {
let routerContent = await fs.readFile(routerFile, "utf8");
if (!routerContent.includes("import { todoRouter }")) {
const lastImportIndex = routerContent.lastIndexOf("import");
const endOfImports = routerContent.indexOf("\n\n", lastImportIndex);
if (endOfImports !== -1) {
routerContent = `${routerContent.slice(0, endOfImports)}
import { todoRouter } from "./todo";${routerContent.slice(endOfImports)}`;
} else {
routerContent = `import { todoRouter } from "./todo";\n${routerContent}`;
}
const routerDefIndex = routerContent.indexOf(
"export const appRouter = router({",
);
if (routerDefIndex !== -1) {
const routerContentStart =
routerContent.indexOf("{", routerDefIndex) + 1;
routerContent = `${routerContent.slice(0, routerContentStart)}
todo: todoRouter,${routerContent.slice(routerContentStart)}`;
}
await fs.writeFile(routerFile, routerContent);
}
}
}
async function updateHeaderWithTodoLink(
projectDir: string,
routerType: string,
): Promise<void> {
const headerPath = path.join(
projectDir,
"apps/web/src/components/header.tsx",
);
if (await fs.pathExists(headerPath)) {
let headerContent = await fs.readFile(headerPath, "utf8");
const linksPattern = /const links = \[\s*([^;]*?)\s*\];/s;
const linksMatch = headerContent.match(linksPattern);
if (linksMatch) {
const linksContent = linksMatch[1];
if (!linksContent.includes('"/todos"')) {
const updatedLinks = `const links = [\n ${linksContent}${
linksContent.trim().endsWith(",") ? "" : ","
}\n { to: "/todos", label: "Todos" },\n ];`;
headerContent = headerContent.replace(linksPattern, updatedLinks);
await fs.writeFile(headerPath, headerContent);
}
}
}
}
async function cleanupTodoFiles(
projectDir: string,
orm: ProjectOrm,
): Promise<void> {
if (orm === "drizzle") {
const todoSchemaFile = path.join(
projectDir,
"apps/server/src/db/schema/todo.ts",
);
if (await fs.pathExists(todoSchemaFile)) {
await fs.remove(todoSchemaFile);
}
} else if (orm === "prisma") {
const todoPrismaFile = path.join(
projectDir,
"apps/server/prisma/schema/todo.prisma",
);
if (await fs.pathExists(todoPrismaFile)) {
await fs.remove(todoPrismaFile);
}
}
const todoRouterFile = path.join(
projectDir,
"apps/server/src/routers/todo.ts",
);
if (await fs.pathExists(todoRouterFile)) {
await fs.remove(todoRouterFile);
}
await updateRouterIndex(projectDir);
}
async function updateRouterIndex(projectDir: string): Promise<void> {
const routerFile = path.join(projectDir, "apps/server/src/routers/index.ts");
if (await fs.pathExists(routerFile)) {
let routerContent = await fs.readFile(routerFile, "utf8");
routerContent = routerContent.replace(
/import { todoRouter } from ".\/todo";/,
"",
);
routerContent = routerContent.replace(/todo: todoRouter,/, "");
await fs.writeFile(routerFile, routerContent);
} }
} }

View File

@@ -4,6 +4,7 @@ import consola from "consola";
import { execa } from "execa"; import { execa } from "execa";
import fs from "fs-extra"; import fs from "fs-extra";
import pc from "picocolors"; import pc from "picocolors";
import type { ProjectConfig } from "../types";
import { commandExists } from "../utils/command-exists"; import { commandExists } from "../utils/command-exists";
type MongoDBConfig = { type MongoDBConfig = {
@@ -129,7 +130,9 @@ ${pc.green("MongoDB Atlas Manual Setup Instructions:")}
`); `);
} }
export async function setupMongoDBAtlas(projectDir: string) { export async function setupMongoDBAtlas(config: ProjectConfig) {
const { projectName } = config;
const projectDir = path.resolve(process.cwd(), projectName);
const mainSpinner = spinner(); const mainSpinner = spinner();
mainSpinner.start("Setting up MongoDB Atlas"); mainSpinner.start("Setting up MongoDB Atlas");

View File

@@ -5,6 +5,7 @@ import { execa } from "execa";
import fs from "fs-extra"; import fs from "fs-extra";
import pc from "picocolors"; import pc from "picocolors";
import type { ProjectPackageManager } from "../types"; import type { ProjectPackageManager } from "../types";
import { getPackageExecutionCommand } from "../utils/get-package-execution-command";
type NeonConfig = { type NeonConfig = {
connectionString: string; connectionString: string;
@@ -13,41 +14,20 @@ type NeonConfig = {
roleName: string; roleName: string;
}; };
function buildNeonCommand(
packageManager: string,
args: string[],
): { cmd: string; cmdArgs: string[] } {
let cmd: string;
let cmdArgs: string[];
switch (packageManager) {
case "pnpm":
cmd = "pnpm";
cmdArgs = ["dlx", "neonctl", ...args];
break;
case "bun":
cmd = "bunx";
cmdArgs = ["neonctl", ...args];
break;
default:
cmd = "npx";
cmdArgs = ["neonctl", ...args];
}
return { cmd, cmdArgs };
}
async function executeNeonCommand( async function executeNeonCommand(
packageManager: string, packageManager: ProjectPackageManager,
args: string[], commandArgsString: string,
spinnerText?: string, spinnerText?: string,
) { ) {
const s = spinnerText ? spinner() : null; const s = spinner();
try { try {
const { cmd, cmdArgs } = buildNeonCommand(packageManager, args); const fullCommand = getPackageExecutionCommand(
packageManager,
commandArgsString,
);
if (s) s.start(spinnerText); if (s) s.start(spinnerText);
const result = await execa(cmd, cmdArgs); const result = await execa(fullCommand, { shell: true });
if (s) s.stop(spinnerText); if (s) s.stop(spinnerText);
return result; return result;
@@ -57,13 +37,10 @@ async function executeNeonCommand(
} }
} }
async function isNeonAuthenticated(packageManager: string) { async function isNeonAuthenticated(packageManager: ProjectPackageManager) {
try { try {
const { cmd, cmdArgs } = buildNeonCommand(packageManager, [ const commandArgsString = "neonctl projects list";
"projects", const result = await executeNeonCommand(packageManager, commandArgsString);
"list",
]);
const result = await execa(cmd, cmdArgs);
return ( return (
!result.stdout.includes("not authenticated") && !result.stdout.includes("not authenticated") &&
!result.stdout.includes("error") !result.stdout.includes("error")
@@ -73,11 +50,11 @@ async function isNeonAuthenticated(packageManager: string) {
} }
} }
async function authenticateWithNeon(packageManager: string) { async function authenticateWithNeon(packageManager: ProjectPackageManager) {
try { try {
await executeNeonCommand( await executeNeonCommand(
packageManager, packageManager,
["auth"], "neonctl auth",
"Authenticating with Neon...", "Authenticating with Neon...",
); );
log.success("Authenticated with Neon successfully!"); log.success("Authenticated with Neon successfully!");
@@ -90,12 +67,13 @@ async function authenticateWithNeon(packageManager: string) {
async function createNeonProject( async function createNeonProject(
projectName: string, projectName: string,
packageManager: string, packageManager: ProjectPackageManager,
): Promise<NeonConfig | null> { ): Promise<NeonConfig | null> {
try { try {
const commandArgsString = `neonctl projects create --name "${projectName}" --output json`;
const { stdout } = await executeNeonCommand( const { stdout } = await executeNeonCommand(
packageManager, packageManager,
["projects", "create", "--name", projectName, "--output", "json"], commandArgsString,
`Creating Neon project "${projectName}"...`, `Creating Neon project "${projectName}"...`,
); );
@@ -150,10 +128,11 @@ function displayManualSetupInstructions() {
DATABASE_URL="your_connection_string"`); DATABASE_URL="your_connection_string"`);
} }
export async function setupNeonPostgres( import type { ProjectConfig } from "../types";
projectDir: string,
packageManager: ProjectPackageManager, export async function setupNeonPostgres(config: ProjectConfig): Promise<void> {
) { const { projectName, packageManager } = config;
const projectDir = path.resolve(process.cwd(), projectName);
const setupSpinner = spinner(); const setupSpinner = spinner();
setupSpinner.start("Setting up Neon PostgreSQL"); setupSpinner.start("Setting up Neon PostgreSQL");

View File

@@ -9,18 +9,24 @@ import type {
ProjectPackageManager, ProjectPackageManager,
ProjectRuntime, ProjectRuntime,
} from "../types"; } from "../types";
import { getPackageExecutionCommand } from "../utils/get-package-execution-command";
import type { ProjectConfig } from "../types";
export function displayPostInstallInstructions( export function displayPostInstallInstructions(
database: ProjectDatabase, config: ProjectConfig & { depsInstalled: boolean },
projectName: string,
packageManager: ProjectPackageManager,
depsInstalled: boolean,
orm: ProjectOrm,
addons: ProjectAddons[],
runtime: ProjectRuntime,
frontends: ProjectFrontend[],
dbSetup?: ProjectDBSetup,
) { ) {
const {
database,
projectName,
packageManager,
depsInstalled,
orm,
addons,
runtime,
frontend,
dbSetup,
} = config;
const runCmd = packageManager === "npm" ? "npm run" : packageManager; const runCmd = packageManager === "npm" ? "npm run" : packageManager;
const cdCmd = `cd ${projectName}`; const cdCmd = `cd ${projectName}`;
const hasHuskyOrBiome = const hasHuskyOrBiome =
@@ -36,38 +42,59 @@ export function displayPostInstallInstructions(
const lintingInstructions = hasHuskyOrBiome const lintingInstructions = hasHuskyOrBiome
? getLintingInstructions(runCmd) ? getLintingInstructions(runCmd)
: ""; : "";
const nativeInstructions = frontends?.includes("native") const nativeInstructions = frontend?.includes("native")
? getNativeInstructions() ? getNativeInstructions()
: ""; : "";
const pwaInstructions = const pwaInstructions =
addons?.includes("pwa") && frontends?.includes("react-router") addons?.includes("pwa") && frontend?.includes("react-router")
? getPwaInstructions() ? getPwaInstructions()
: ""; : "";
const starlightInstructions = addons?.includes("starlight") const starlightInstructions = addons?.includes("starlight")
? getStarlightInstructions(runCmd) ? getStarlightInstructions(runCmd)
: ""; : "";
const hasTanstackRouter = frontends?.includes("tanstack-router"); const hasTanstackRouter = frontend?.includes("tanstack-router");
const hasTanstackStart = frontends?.includes("tanstack-start"); const hasTanstackStart = frontend?.includes("tanstack-start");
const hasReactRouter = frontends?.includes("react-router"); const hasReactRouter = frontend?.includes("react-router");
const hasWebFrontend = const hasWebFrontend =
hasTanstackRouter || hasReactRouter || hasTanstackStart; hasTanstackRouter || hasReactRouter || hasTanstackStart;
const hasNativeFrontend = frontends?.includes("native"); const hasNativeFrontend = frontend?.includes("native");
const hasFrontend = hasWebFrontend || hasNativeFrontend; const hasFrontend = hasWebFrontend || hasNativeFrontend;
const webPort = hasReactRouter ? "5173" : "3001"; const webPort = hasReactRouter ? "5173" : "3001";
const tazeCommand = getPackageExecutionCommand(packageManager, "taze -r");
consola.box( consola.box(
`${pc.bold("Next steps")}\n${pc.cyan("1.")} ${cdCmd} `${pc.bold("Next steps")}\n${pc.cyan("1.")} ${cdCmd}
${!depsInstalled ? `${pc.cyan("2.")} ${packageManager} install\n` : ""}${pc.cyan(depsInstalled ? "2." : "3.")} ${runCmd} dev ${
!depsInstalled ? `${pc.cyan("2.")} ${packageManager} install\n` : ""
}${pc.cyan(depsInstalled ? "2." : "3.")} ${runCmd} dev
${pc.bold("Your project will be available at:")} ${pc.bold("Your project will be available at:")}
${ ${
hasFrontend hasFrontend
? `${hasWebFrontend ? `${pc.cyan("•")} Frontend: http://localhost:${webPort}\n` : ""}` ? `${
: `${pc.yellow("NOTE:")} You are creating a backend-only app (no frontend selected)\n` hasWebFrontend
}${pc.cyan("•")} API: http://localhost:3000 ? `${pc.cyan("•")} Frontend: http://localhost:${webPort}\n`
${addons?.includes("starlight") ? `${pc.cyan("•")} Docs: http://localhost:4321\n` : ""}${nativeInstructions ? `\n${nativeInstructions.trim()}` : ""}${databaseInstructions ? `\n${databaseInstructions.trim()}` : ""}${tauriInstructions ? `\n${tauriInstructions.trim()}` : ""}${lintingInstructions ? `\n${lintingInstructions.trim()}` : ""}${pwaInstructions ? `\n${pwaInstructions.trim()}` : ""}${starlightInstructions ? `\n${starlightInstructions.trim()}` : ""} : ""
}`
: `${pc.yellow(
"NOTE:",
)} You are creating a backend-only app (no frontend selected)\n`
}${pc.cyan("•")} Backend: http://localhost:3000
${
addons?.includes("starlight")
? `${pc.cyan("•")} Docs: http://localhost:4321\n`
: ""
}${nativeInstructions ? `\n${nativeInstructions.trim()}` : ""}${
databaseInstructions ? `\n${databaseInstructions.trim()}` : ""
}${tauriInstructions ? `\n${tauriInstructions.trim()}` : ""}${
lintingInstructions ? `\n${lintingInstructions.trim()}` : ""
}${pwaInstructions ? `\n${pwaInstructions.trim()}` : ""}${
starlightInstructions ? `\n${starlightInstructions.trim()}` : ""
}
${pc.bold("Update all dependencies:\n")}${pc.cyan(tazeCommand)}
${pc.bold("Like Better-T Stack?")} Please consider giving us a star on GitHub: ${pc.bold("Like Better-T Stack?")} Please consider giving us a star on GitHub:
${pc.cyan("https://github.com/AmanVarshney01/create-better-t-stack")}`, ${pc.cyan("https://github.com/AmanVarshney01/create-better-t-stack")}`,
@@ -75,11 +102,15 @@ ${pc.cyan("https://github.com/AmanVarshney01/create-better-t-stack")}`,
} }
function getNativeInstructions(): string { function getNativeInstructions(): string {
return `${pc.yellow("NOTE:")} For Expo connectivity issues, update apps/native/.env \nwith your local IP:\n${"EXPO_PUBLIC_SERVER_URL=http://192.168.0.103:3000"}\n`; return `${pc.yellow(
"NOTE:",
)} For Expo connectivity issues, update apps/native/.env \nwith your local IP:\n${"EXPO_PUBLIC_SERVER_URL=http://192.168.0.103:3000"}\n`;
} }
function getLintingInstructions(runCmd?: string): string { function getLintingInstructions(runCmd?: string): string {
return `${pc.bold("Linting and formatting:")}\n${pc.cyan("•")} Format and lint fix: ${`${runCmd} check`}\n\n`; return `${pc.bold("Linting and formatting:")}\n${pc.cyan(
"•",
)} Format and lint fix: ${`${runCmd} check`}\n\n`;
} }
function getDatabaseInstructions( function getDatabaseInstructions(
@@ -93,14 +124,18 @@ function getDatabaseInstructions(
if (orm === "prisma") { if (orm === "prisma") {
if (database === "sqlite") { if (database === "sqlite") {
instructions.push( instructions.push(
`${pc.yellow("NOTE:")} Turso support with Prisma is in Early Access and requires additional setup.`, `${pc.yellow(
"NOTE:",
)} Turso support with Prisma is in Early Access and requires additional setup.`,
`${"Learn more at: https://www.prisma.io/docs/orm/overview/databases/turso"}`, `${"Learn more at: https://www.prisma.io/docs/orm/overview/databases/turso"}`,
); );
} }
if (runtime === "bun") { if (runtime === "bun") {
instructions.push( instructions.push(
`${pc.yellow("NOTE:")} Prisma with Bun may require additional configuration. If you encounter errors,\nfollow the guidance provided in the error messages`, `${pc.yellow(
"NOTE:",
)} Prisma with Bun may require additional configuration. If you encounter errors,\nfollow the guidance provided in the error messages`,
); );
} }
@@ -117,13 +152,25 @@ function getDatabaseInstructions(
} }
function getTauriInstructions(runCmd?: string): string { function getTauriInstructions(runCmd?: string): string {
return `\n${pc.bold("Desktop app with Tauri:")}\n${pc.cyan("•")} Start desktop app: ${`cd apps/web && ${runCmd} desktop:dev`}\n${pc.cyan("•")} Build desktop app: ${`cd apps/web && ${runCmd} desktop:build`}\n${pc.yellow("NOTE:")} Tauri requires Rust and platform-specific dependencies.\nSee: ${"https://v2.tauri.app/start/prerequisites/"}\n\n`; return `\n${pc.bold("Desktop app with Tauri:")}\n${pc.cyan(
"•",
)} Start desktop app: ${`cd apps/web && ${runCmd} desktop:dev`}\n${pc.cyan(
"•",
)} Build desktop app: ${`cd apps/web && ${runCmd} desktop:build`}\n${pc.yellow(
"NOTE:",
)} Tauri requires Rust and platform-specific dependencies.\nSee: ${"https://v2.tauri.app/start/prerequisites/"}\n\n`;
} }
function getPwaInstructions(): string { function getPwaInstructions(): string {
return `${pc.bold("PWA with React Router v7:")}\n${pc.yellow("NOTE:")} There is a known compatibility issue between VitePWA and React Router v7.\nSee: https://github.com/vite-pwa/vite-plugin-pwa/issues/809\n`; return `${pc.bold("PWA with React Router v7:")}\n${pc.yellow(
"NOTE:",
)} There is a known compatibility issue between VitePWA and React Router v7.\nSee: https://github.com/vite-pwa/vite-plugin-pwa/issues/809\n`;
} }
function getStarlightInstructions(runCmd?: string): string { function getStarlightInstructions(runCmd?: string): string {
return `${pc.bold("Documentation with Starlight:")}\n${pc.cyan("•")} Start docs site: ${`cd apps/docs && ${runCmd} dev`}\n${pc.cyan("•")} Build docs site: ${`cd apps/docs && ${runCmd} build`}\n`; return `${pc.bold("Documentation with Starlight:")}\n${pc.cyan(
"•",
)} Start docs site: ${`cd apps/docs && ${runCmd} dev`}\n${pc.cyan(
"•",
)} Build docs site: ${`cd apps/docs && ${runCmd} build`}\n`;
} }

View File

@@ -6,6 +6,7 @@ import fs from "fs-extra";
import pc from "picocolors"; import pc from "picocolors";
import type { ProjectPackageManager } from "../types"; import type { ProjectPackageManager } from "../types";
import { addPackageDependency } from "../utils/add-package-deps"; import { addPackageDependency } from "../utils/add-package-deps";
import { getPackageExecutionCommand } from "../utils/get-package-execution-command";
type PrismaConfig = { type PrismaConfig = {
databaseUrl: string; databaseUrl: string;
@@ -22,18 +23,17 @@ async function initPrismaDatabase(
const prismaDir = path.join(serverDir, "prisma"); const prismaDir = path.join(serverDir, "prisma");
await fs.ensureDir(prismaDir); await fs.ensureDir(prismaDir);
const initCmd =
packageManager === "npm"
? "npx"
: packageManager === "pnpm"
? "pnpm dlx"
: "bunx";
s.stop("Initializing Prisma. Follow the prompts below:"); s.stop("Initializing Prisma. Follow the prompts below:");
await execa(initCmd, ["prisma", "init", "--db"], { const prismaInitCommand = getPackageExecutionCommand(
packageManager,
"prisma init --db",
);
await execa(prismaInitCommand, {
cwd: serverDir, cwd: serverDir,
stdio: "inherit", stdio: "inherit",
shell: true,
}); });
log.info( log.info(
@@ -112,7 +112,7 @@ DATABASE_URL="your_database_url"`);
async function addPrismaAccelerateExtension(serverDir: string) { async function addPrismaAccelerateExtension(serverDir: string) {
try { try {
addPackageDependency({ await addPackageDependency({
dependencies: ["@prisma/extension-accelerate"], dependencies: ["@prisma/extension-accelerate"],
projectDir: serverDir, projectDir: serverDir,
}); });
@@ -152,10 +152,11 @@ export default prisma;
} }
} }
export async function setupPrismaPostgres( import type { ProjectConfig } from "../types";
projectDir: string,
packageManager: ProjectPackageManager = "npm", export async function setupPrismaPostgres(config: ProjectConfig) {
) { const { projectName, packageManager } = config;
const projectDir = path.resolve(process.cwd(), projectName);
const serverDir = path.join(projectDir, "apps/server"); const serverDir = path.join(projectDir, "apps/server");
const s = spinner(); const s = spinner();
s.start("Setting up Prisma PostgreSQL"); s.start("Setting up Prisma PostgreSQL");
@@ -184,7 +185,9 @@ export async function setupPrismaPostgres(
s.stop(pc.red("Prisma PostgreSQL setup failed")); s.stop(pc.red("Prisma PostgreSQL setup failed"));
consola.error( consola.error(
pc.red( pc.red(
`Error during Prisma PostgreSQL setup: ${error instanceof Error ? error.message : String(error)}`, `Error during Prisma PostgreSQL setup: ${
error instanceof Error ? error.message : String(error)
}`,
), ),
); );

View File

@@ -3,7 +3,6 @@ import { log } from "@clack/prompts";
import { $, execa } from "execa"; import { $, execa } from "execa";
import fs from "fs-extra"; import fs from "fs-extra";
import pc from "picocolors"; import pc from "picocolors";
import { PKG_ROOT } from "../constants";
import type { ProjectConfig } from "../types"; import type { ProjectConfig } from "../types";
export async function updatePackageConfigurations( export async function updatePackageConfigurations(
@@ -23,24 +22,71 @@ async function updateRootPackageJson(
const packageJson = await fs.readJson(rootPackageJsonPath); const packageJson = await fs.readJson(rootPackageJsonPath);
packageJson.name = options.projectName; packageJson.name = options.projectName;
// Define script sets
const turboScripts = {
dev: "turbo dev",
build: "turbo build",
"check-types": "turbo check-types",
"dev:native": "turbo -F native dev",
"dev:web": "turbo -F web dev",
"dev:server": "turbo -F server dev",
"db:push": "turbo -F server db:push",
"db:studio": "turbo -F server db:studio",
};
const pnpmScripts = {
dev: "pnpm -r --parallel dev",
build: "pnpm -r build",
"check-types": "pnpm -r check-types",
"dev:native": "pnpm --filter native dev",
"dev:web": "pnpm --filter web dev",
"dev:server": "pnpm --filter server dev",
"db:push": "pnpm --filter server db:push",
"db:studio": "pnpm --filter server db:studio",
};
const npmScripts = {
dev: "npm run dev --workspaces",
build: "npm run build --workspaces",
"check-types": "npm run check-types --workspaces",
"dev:native": "npm run dev --workspace native",
"dev:web": "npm run dev --workspace web",
"dev:server": "npm run dev --workspace server",
"db:push": "npm run db:push --workspace server",
"db:studio": "npm run db:studio --workspace server",
};
const bunScripts = {
dev: "bun run --filter '*' dev",
build: "bun run --filter '*' build",
"check-types": "bun run --filter '*' check-types",
"dev:native": "bun run --filter native dev",
"dev:web": "bun run --filter web dev",
"dev:server": "bun run --filter server dev",
"db:push": "bun run --filter server db:push",
"db:studio": "bun run --filter server db:studio",
};
if (options.addons.includes("turborepo")) {
packageJson.scripts = turboScripts;
} else {
if (options.packageManager === "pnpm") {
packageJson.scripts = pnpmScripts;
} else if (options.packageManager === "npm") {
packageJson.scripts = npmScripts;
} else if (options.packageManager === "bun") {
packageJson.scripts = bunScripts;
} else {
packageJson.scripts = {};
}
}
const { stdout } = await execa(options.packageManager, ["-v"], { const { stdout } = await execa(options.packageManager, ["-v"], {
cwd: projectDir, cwd: projectDir,
}); });
packageJson.packageManager = `${options.packageManager}@${stdout.trim()}`; packageJson.packageManager = `${options.packageManager}@${stdout.trim()}`;
await fs.writeJson(rootPackageJsonPath, packageJson, { spaces: 2 }); await fs.writeJson(rootPackageJsonPath, packageJson, { spaces: 2 });
if (options.packageManager === "pnpm") {
const pnpmWorkspaceTemplatePath = path.join(
PKG_ROOT,
"template/with-pnpm/pnpm-workspace.yaml",
);
const targetWorkspacePath = path.join(projectDir, "pnpm-workspace.yaml");
if (await fs.pathExists(pnpmWorkspaceTemplatePath)) {
await fs.copy(pnpmWorkspaceTemplatePath, targetWorkspacePath);
}
}
} }
} }
@@ -57,7 +103,7 @@ async function updateServerPackageJson(
const serverPackageJson = await fs.readJson(serverPackageJsonPath); const serverPackageJson = await fs.readJson(serverPackageJsonPath);
if (options.database !== "none") { if (options.database !== "none") {
if (options.database === "sqlite") { if (options.database === "sqlite" && options.orm === "drizzle") {
serverPackageJson.scripts["db:local"] = "turso dev --db-file local.db"; serverPackageJson.scripts["db:local"] = "turso dev --db-file local.db";
} }

View File

@@ -1,44 +1,27 @@
import path from "node:path"; import path from "node:path";
import fs from "fs-extra"; import fs from "fs-extra";
import type { ProjectBackend, ProjectRuntime } from "../types"; import type { ProjectBackend, ProjectConfig, ProjectRuntime } from "../types";
import { addPackageDependency } from "../utils/add-package-deps"; import { addPackageDependency } from "../utils/add-package-deps";
export async function setupRuntime( export async function setupRuntime(config: ProjectConfig): Promise<void> {
projectDir: string, const { projectName, runtime, backend } = config;
runtime: ProjectRuntime, const projectDir = path.resolve(process.cwd(), projectName);
backendFramework: ProjectBackend, if (backend === "next") {
): Promise<void> {
if (backendFramework === "next") {
return; return;
} }
const serverDir = path.join(projectDir, "apps/server"); const serverDir = path.join(projectDir, "apps/server");
const serverIndexPath = path.join(serverDir, "src/index.ts");
const indexContent = await fs.readFile(serverIndexPath, "utf-8");
if (runtime === "bun") { if (runtime === "bun") {
await setupBunRuntime( await setupBunRuntime(serverDir, backend);
serverDir,
serverIndexPath,
indexContent,
backendFramework,
);
} else if (runtime === "node") { } else if (runtime === "node") {
await setupNodeRuntime( await setupNodeRuntime(serverDir, backend);
serverDir,
serverIndexPath,
indexContent,
backendFramework,
);
} }
} }
async function setupBunRuntime( async function setupBunRuntime(
serverDir: string, serverDir: string,
serverIndexPath: string, backend: ProjectBackend,
indexContent: string,
backendFramework: ProjectBackend,
): Promise<void> { ): Promise<void> {
const packageJsonPath = path.join(serverDir, "package.json"); const packageJsonPath = path.join(serverDir, "package.json");
const packageJson = await fs.readJson(packageJsonPath); const packageJson = await fs.readJson(packageJsonPath);
@@ -51,22 +34,15 @@ async function setupBunRuntime(
await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 }); await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 });
addPackageDependency({ await addPackageDependency({
devDependencies: ["@types/bun"], devDependencies: ["@types/bun"],
projectDir: serverDir, projectDir: serverDir,
}); });
if (backendFramework === "hono") {
const updatedContent = `${indexContent}\n\nexport default app;\n`;
await fs.writeFile(serverIndexPath, updatedContent);
}
} }
async function setupNodeRuntime( async function setupNodeRuntime(
serverDir: string, serverDir: string,
serverIndexPath: string, backend: ProjectBackend,
indexContent: string,
backendFramework: ProjectBackend,
): Promise<void> { ): Promise<void> {
const packageJsonPath = path.join(serverDir, "package.json"); const packageJsonPath = path.join(serverDir, "package.json");
const packageJson = await fs.readJson(packageJsonPath); const packageJson = await fs.readJson(packageJsonPath);
@@ -79,62 +55,20 @@ async function setupNodeRuntime(
await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 }); await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 });
addPackageDependency({ await addPackageDependency({
devDependencies: ["tsx", "@types/node"], devDependencies: ["tsx", "@types/node"],
projectDir: serverDir, projectDir: serverDir,
}); });
if (backendFramework === "hono") { if (backend === "hono") {
addPackageDependency({ await addPackageDependency({
dependencies: ["@hono/node-server"], dependencies: ["@hono/node-server"],
projectDir: serverDir, projectDir: serverDir,
}); });
} else if (backend === "elysia") {
const importLine = 'import { serve } from "@hono/node-server";\n'; await addPackageDependency({
const serverCode = `
serve(
{
fetch: app.fetch,
port: 3000,
},
(info) => {
console.log(\`Server is running on http://localhost:\${info.port}\`);
},
);\n`;
if (!indexContent.includes("@hono/node-server")) {
const importEndIndex = indexContent.lastIndexOf("import");
const importSection = indexContent.substring(0, importEndIndex);
const restOfFile = indexContent.substring(importEndIndex);
const updatedContent =
importSection + importLine + restOfFile + serverCode;
await fs.writeFile(serverIndexPath, updatedContent);
}
} else if (backendFramework === "elysia") {
addPackageDependency({
dependencies: ["@elysiajs/node"], dependencies: ["@elysiajs/node"],
projectDir: serverDir, projectDir: serverDir,
}); });
if (!indexContent.includes("@elysiajs/node")) {
const nodeImport = 'import { node } from "@elysiajs/node";\n';
const firstImportEnd = indexContent.indexOf(
"\n",
indexContent.indexOf("import"),
);
const before = indexContent.substring(0, firstImportEnd + 1);
const after = indexContent.substring(firstImportEnd + 1);
let updatedContent = before + nodeImport + after;
updatedContent = updatedContent.replace(
/const app = new Elysia\([^)]*\)/,
"const app = new Elysia({ adapter: node() })",
);
await fs.writeFile(serverIndexPath, updatedContent);
}
} }
} }

View File

@@ -1,42 +1,20 @@
import path from "node:path"; import path from "node:path";
import { log, spinner } from "@clack/prompts"; import { spinner } from "@clack/prompts";
import consola from "consola"; import consola from "consola";
import { execa } from "execa"; import { execa } from "execa";
import pc from "picocolors"; import pc from "picocolors";
import type { ProjectPackageManager } from "../types"; import type { ProjectConfig } from "../types";
import { getPackageExecutionCommand } from "../utils/get-package-execution-command";
export async function setupStarlight( export async function setupStarlight(config: ProjectConfig): Promise<void> {
projectDir: string, const { projectName, packageManager } = config;
packageManager: ProjectPackageManager, const projectDir = path.resolve(process.cwd(), projectName);
): Promise<void> {
const s = spinner(); const s = spinner();
try { try {
s.start("Setting up Starlight documentation site..."); s.start("Setting up Starlight docs...");
let cmd: string; const starlightArgs = [
let args: string[];
switch (packageManager) {
case "npm":
cmd = "npx";
args = ["create-astro@latest"];
break;
case "pnpm":
cmd = "pnpm";
args = ["dlx", "create-astro@latest"];
break;
case "bun":
cmd = "bunx";
args = ["create-astro@latest"];
break;
default:
cmd = "npx";
args = ["create-astro@latest"];
}
args = [
...args,
"docs", "docs",
"--template", "--template",
"starlight", "starlight",
@@ -46,17 +24,26 @@ export async function setupStarlight(
"--no-git", "--no-git",
"--skip-houston", "--skip-houston",
]; ];
const starlightArgsString = starlightArgs.join(" ");
await execa(cmd, args, { const commandWithArgs = `create-astro@latest ${starlightArgsString}`;
const starlightInitCommand = getPackageExecutionCommand(
packageManager,
commandWithArgs,
);
await execa(starlightInitCommand, {
cwd: path.join(projectDir, "apps"), cwd: path.join(projectDir, "apps"),
env: { env: {
CI: "true", CI: "true",
}, },
shell: true,
}); });
s.stop("Starlight documentation site setup successfully!"); s.stop("Starlight docs setup successfully!");
} catch (error) { } catch (error) {
s.stop(pc.red("Failed to set up Starlight documentation site")); s.stop(pc.red("Failed to set up Starlight docs"));
if (error instanceof Error) { if (error instanceof Error) {
consola.error(pc.red(error.message)); consola.error(pc.red(error.message));
} }

View File

@@ -1,17 +1,17 @@
import path from "node:path"; import path from "node:path";
import { log, spinner } from "@clack/prompts"; import { spinner } from "@clack/prompts";
import { consola } from "consola"; import { consola } from "consola";
import { execa } from "execa"; import { execa } from "execa";
import fs from "fs-extra"; import fs from "fs-extra";
import pc from "picocolors"; import pc from "picocolors";
import type { ProjectFrontend, ProjectPackageManager } from "../types";
import { addPackageDependency } from "../utils/add-package-deps"; import { addPackageDependency } from "../utils/add-package-deps";
import { getPackageExecutionCommand } from "../utils/get-package-execution-command";
export async function setupTauri( import type { ProjectConfig } from "../types";
projectDir: string,
packageManager: ProjectPackageManager, export async function setupTauri(config: ProjectConfig): Promise<void> {
frontends: ProjectFrontend[], const { projectName, packageManager, frontend } = config;
): Promise<void> { const projectDir = path.resolve(process.cwd(), projectName);
const s = spinner(); const s = spinner();
const clientPackageDir = path.join(projectDir, "apps/web"); const clientPackageDir = path.join(projectDir, "apps/web");
@@ -22,7 +22,7 @@ export async function setupTauri(
try { try {
s.start("Setting up Tauri desktop app support..."); s.start("Setting up Tauri desktop app support...");
addPackageDependency({ await addPackageDependency({
devDependencies: ["@tauri-apps/cli"], devDependencies: ["@tauri-apps/cli"],
projectDir: clientPackageDir, projectDir: clientPackageDir,
}); });
@@ -41,48 +41,35 @@ export async function setupTauri(
await fs.writeJson(clientPackageJsonPath, packageJson, { spaces: 2 }); await fs.writeJson(clientPackageJsonPath, packageJson, { spaces: 2 });
} }
let cmd: string; const hasReactRouter = frontend.includes("react-router");
let args: string[];
switch (packageManager) {
case "npm":
cmd = "npx";
args = ["@tauri-apps/cli@latest"];
break;
case "pnpm":
cmd = "pnpm";
args = ["dlx", "@tauri-apps/cli@latest"];
break;
case "bun":
cmd = "bunx";
args = ["@tauri-apps/cli@latest"];
break;
default:
cmd = "npx";
args = ["@tauri-apps/cli@latest"];
}
const hasReactRouter = frontends.includes("react-router");
const devUrl = hasReactRouter const devUrl = hasReactRouter
? "http://localhost:5173" ? "http://localhost:5173"
: "http://localhost:3001"; : "http://localhost:3001";
args = [ const tauriArgs = [
...args,
"init", "init",
`--app-name=${path.basename(projectDir)}`, `--app-name=${path.basename(projectDir)}`,
`--window-title=${path.basename(projectDir)}`, `--window-title=${path.basename(projectDir)}`,
"--frontend-dist=../dist", "--frontend-dist=../dist",
`--dev-url=${devUrl}`, `--dev-url=${devUrl}`,
`--before-dev-command=${packageManager} run dev`, `--before-dev-command=\"${packageManager} run dev\"`,
`--before-build-command=${packageManager} run build`, `--before-build-command=\"${packageManager} run build\"`,
]; ];
const tauriArgsString = tauriArgs.join(" ");
await execa(cmd, args, { const commandWithArgs = `@tauri-apps/cli@latest ${tauriArgsString}`;
const tauriInitCommand = getPackageExecutionCommand(
packageManager,
commandWithArgs,
);
await execa(tauriInitCommand, {
cwd: clientPackageDir, cwd: clientPackageDir,
env: { env: {
CI: "true", CI: "true",
}, },
shell: true,
}); });
s.stop("Tauri desktop app support configured successfully!"); s.stop("Tauri desktop app support configured successfully!");

View File

@@ -1,493 +1,538 @@
import path from "node:path"; import path from "node:path";
import consola from "consola";
import fs from "fs-extra"; import fs from "fs-extra";
import { globby } from "globby";
import pc from "picocolors";
import { PKG_ROOT } from "../constants"; import { PKG_ROOT } from "../constants";
import type { import type { ProjectConfig } from "../types";
ProjectBackend, import { processTemplate } from "../utils/template-processor";
ProjectDatabase,
ProjectFrontend,
ProjectOrm,
} from "../types";
import { addPackageDependency } from "../utils/add-package-deps";
/** async function processAndCopyFiles(
* Copy base template structure but exclude app-specific folders that will be added based on options sourcePattern: string | string[],
*/ baseSourceDir: string,
export async function copyBaseTemplate(projectDir: string): Promise<void> { destDir: string,
const templateDir = path.join(PKG_ROOT, "template/base"); context: ProjectConfig,
overwrite = true,
): Promise<void> {
const sourceFiles = await globby(sourcePattern, {
cwd: baseSourceDir,
dot: true,
onlyFiles: true,
absolute: false,
});
if (!(await fs.pathExists(templateDir))) { for (const relativeSrcPath of sourceFiles) {
throw new Error(`Template directory not found: ${templateDir}`); const srcPath = path.join(baseSourceDir, relativeSrcPath);
let relativeDestPath = relativeSrcPath;
if (relativeSrcPath.endsWith(".hbs")) {
relativeDestPath = relativeSrcPath.slice(0, -4);
} }
await fs.ensureDir(projectDir); const destPath = path.join(destDir, relativeDestPath);
const rootFiles = await fs.readdir(templateDir); await fs.ensureDir(path.dirname(destPath));
for (const file of rootFiles) {
const srcPath = path.join(templateDir, file);
const destPath = path.join(projectDir, file);
if (file === "apps") continue; if (srcPath.endsWith(".hbs")) {
await processTemplate(srcPath, destPath, context);
if (await fs.stat(srcPath).then((stat) => stat.isDirectory())) {
await fs.copy(srcPath, destPath);
} else { } else {
await fs.copy(srcPath, destPath); if (!overwrite && (await fs.pathExists(destPath))) {
continue;
}
await fs.copy(srcPath, destPath, { overwrite: true });
} }
} }
}
await fs.ensureDir(path.join(projectDir, "apps")); export async function copyBaseTemplate(
projectDir: string,
const serverSrcDir = path.join(templateDir, "apps/server"); context: ProjectConfig,
const serverDestDir = path.join(projectDir, "apps/server"); ): Promise<void> {
if (await fs.pathExists(serverSrcDir)) { const templateDir = path.join(PKG_ROOT, "templates/base");
await fs.copy(serverSrcDir, serverDestDir); await processAndCopyFiles(
} ["package.json", "_gitignore"],
templateDir,
projectDir,
context,
);
} }
export async function setupFrontendTemplates( export async function setupFrontendTemplates(
projectDir: string, projectDir: string,
frontends: ProjectFrontend[], context: ProjectConfig,
): Promise<void> { ): Promise<void> {
const hasTanstackWeb = frontends.includes("tanstack-router"); const webFrontends = context.frontend.filter(
const hasTanstackStart = frontends.includes("tanstack-start"); (f) =>
const hasReactRouterWeb = frontends.includes("react-router"); f === "tanstack-router" ||
const hasNextWeb = frontends.includes("next"); f === "react-router" ||
const hasNative = frontends.includes("native"); f === "tanstack-start" ||
f === "next",
);
const hasNative = context.frontend.includes("native");
if (hasTanstackWeb || hasReactRouterWeb || hasTanstackStart || hasNextWeb) { if (webFrontends.length > 0) {
const webDir = path.join(projectDir, "apps/web"); const webAppDir = path.join(projectDir, "apps/web");
await fs.ensureDir(webDir); await fs.ensureDir(webAppDir);
const webBaseDir = path.join(PKG_ROOT, "template/base/apps/web-base"); const webBaseDir = path.join(PKG_ROOT, "templates/frontend/web-base");
if (await fs.pathExists(webBaseDir)) { if (await fs.pathExists(webBaseDir)) {
await fs.copy(webBaseDir, webDir); await processAndCopyFiles("**/*", webBaseDir, webAppDir, context);
} }
if (hasTanstackWeb) { for (const framework of webFrontends) {
const frameworkDir = path.join( const frameworkSrcDir = path.join(
PKG_ROOT, PKG_ROOT,
"template/base/apps/web-tanstack-router", `templates/frontend/${framework}`,
); );
if (await fs.pathExists(frameworkDir)) { if (await fs.pathExists(frameworkSrcDir)) {
await fs.copy(frameworkDir, webDir, { overwrite: true }); await processAndCopyFiles("**/*", frameworkSrcDir, webAppDir, context);
}
} else if (hasTanstackStart) {
const frameworkDir = path.join(
PKG_ROOT,
"template/base/apps/web-tanstack-start",
);
if (await fs.pathExists(frameworkDir)) {
await fs.copy(frameworkDir, webDir, { overwrite: true });
}
} else if (hasReactRouterWeb) {
const frameworkDir = path.join(
PKG_ROOT,
"template/base/apps/web-react-router",
);
if (await fs.pathExists(frameworkDir)) {
await fs.copy(frameworkDir, webDir, { overwrite: true });
}
} else if (hasNextWeb) {
const frameworkDir = path.join(PKG_ROOT, "template/base/apps/web-next");
if (await fs.pathExists(frameworkDir)) {
await fs.copy(frameworkDir, webDir, { overwrite: true });
} }
} }
const packageJsonPath = path.join(webDir, "package.json"); if (context.api !== "none") {
if (await fs.pathExists(packageJsonPath)) { const webFramework = webFrontends[0];
const packageJson = await fs.readJson(packageJsonPath);
packageJson.name = "web"; const apiWebBaseDir = path.join(
await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 }); PKG_ROOT,
`templates/api/${context.api}/web/base`,
);
if (await fs.pathExists(apiWebBaseDir)) {
await processAndCopyFiles("**/*", apiWebBaseDir, webAppDir, context);
}
const apiWebFrameworkDir = path.join(
PKG_ROOT,
`templates/api/${context.api}/web/${webFramework}`,
);
if (await fs.pathExists(apiWebFrameworkDir)) {
await processAndCopyFiles(
"**/*",
apiWebFrameworkDir,
webAppDir,
context,
);
}
} }
} }
if (hasNative) { if (hasNative) {
const nativeSrcDir = path.join(PKG_ROOT, "template/base/apps/native"); const nativeAppDir = path.join(projectDir, "apps/native");
const nativeDestDir = path.join(projectDir, "apps/native"); await fs.ensureDir(nativeAppDir);
if (await fs.pathExists(nativeSrcDir)) { const nativeBaseDir = path.join(PKG_ROOT, "templates/frontend/native");
await fs.copy(nativeSrcDir, nativeDestDir); if (await fs.pathExists(nativeBaseDir)) {
await processAndCopyFiles("**/*", nativeBaseDir, nativeAppDir, context);
} }
await fs.writeFile( if (context.api !== "none") {
path.join(projectDir, ".npmrc"), const apiNativeSrcDir = path.join(
"node-linker=hoisted\n", PKG_ROOT,
`templates/api/${context.api}/native`,
); );
if (await fs.pathExists(apiNativeSrcDir)) {
await processAndCopyFiles(
"**/*",
apiNativeSrcDir,
nativeAppDir,
context,
);
} else {
}
}
} }
} }
export async function setupBackendFramework( export async function setupBackendFramework(
projectDir: string, projectDir: string,
framework: ProjectBackend, context: ProjectConfig,
): Promise<void> { ): Promise<void> {
if (framework === "next") { if ((context.backend as string) === "none") return;
const serverDir = path.join(projectDir, "apps/server");
const nextTemplateDir = path.join( const serverAppDir = path.join(projectDir, "apps/server");
PKG_ROOT, await fs.ensureDir(serverAppDir);
"template/with-next/apps/server",
const serverBaseDir = path.join(PKG_ROOT, "templates/backend/server-base");
if (await fs.pathExists(serverBaseDir)) {
await processAndCopyFiles("**/*", serverBaseDir, serverAppDir, context);
} else {
consola.warn(
pc.yellow(`Warning: server-base template not found at ${serverBaseDir}`),
); );
await fs.ensureDir(serverDir);
if (await fs.pathExists(nextTemplateDir)) {
await fs.copy(nextTemplateDir, serverDir, { overwrite: true });
const packageJsonPath = path.join(serverDir, "package.json");
if (await fs.pathExists(packageJsonPath)) {
const packageJson = await fs.readJson(packageJsonPath);
packageJson.name = "server";
await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 });
}
}
return;
} }
const frameworkDir = path.join(PKG_ROOT, `template/with-${framework}`); const frameworkSrcDir = path.join(
if (await fs.pathExists(frameworkDir)) { PKG_ROOT,
await fs.copy(frameworkDir, projectDir, { overwrite: true }); `templates/backend/${context.backend}`,
);
if (await fs.pathExists(frameworkSrcDir)) {
await processAndCopyFiles("**/*", frameworkSrcDir, serverAppDir, context);
} else {
consola.warn(
pc.yellow(
`Warning: Backend template directory not found, skipping: ${frameworkSrcDir}`,
),
);
}
if (context.api !== "none") {
const apiServerBaseDir = path.join(
PKG_ROOT,
`templates/api/${context.api}/server/base`,
);
if (await fs.pathExists(apiServerBaseDir)) {
await processAndCopyFiles(
"**/*",
apiServerBaseDir,
serverAppDir,
context,
);
}
const apiServerFrameworkDir = path.join(
PKG_ROOT,
`templates/api/${context.api}/server/${context.backend}`,
);
if (await fs.pathExists(apiServerFrameworkDir)) {
await processAndCopyFiles(
"**/*",
apiServerFrameworkDir,
serverAppDir,
context,
);
}
} }
} }
export async function setupOrmTemplate( export async function setupDbOrmTemplates(
projectDir: string, projectDir: string,
orm: ProjectOrm, context: ProjectConfig,
database: ProjectDatabase,
auth: boolean,
): Promise<void> { ): Promise<void> {
if (orm === "none" || database === "none") return; if (context.orm === "none" || context.database === "none") return;
const ormTemplateDir = path.join(PKG_ROOT, getOrmTemplateDir(orm, database)); const serverAppDir = path.join(projectDir, "apps/server");
await fs.ensureDir(serverAppDir);
if (await fs.pathExists(ormTemplateDir)) { const dbOrmSrcDir = path.join(
await fs.copy(ormTemplateDir, projectDir, { overwrite: true }); PKG_ROOT,
`templates/db/${context.orm}/${context.database}`,
if (!auth) {
if (orm === "prisma") {
const authSchemaPath = path.join(
projectDir,
"apps/server/prisma/schema/auth.prisma",
); );
if (await fs.pathExists(authSchemaPath)) {
await fs.remove(authSchemaPath); if (await fs.pathExists(dbOrmSrcDir)) {
} await processAndCopyFiles("**/*", dbOrmSrcDir, serverAppDir, context);
} else if (orm === "drizzle") { } else {
const authSchemaPath = path.join( consola.warn(
projectDir, pc.yellow(
"apps/server/src/db/schema/auth.ts", `Warning: Database/ORM template directory not found, skipping: ${dbOrmSrcDir}`,
),
); );
if (await fs.pathExists(authSchemaPath)) {
await fs.remove(authSchemaPath);
}
}
}
} }
} }
export async function setupAuthTemplate( export async function setupAuthTemplate(
projectDir: string, projectDir: string,
auth: boolean, context: ProjectConfig,
framework: ProjectBackend,
orm: ProjectOrm,
database: ProjectDatabase,
frontends: ProjectFrontend[],
): Promise<void> { ): Promise<void> {
if (!auth) return; if (!context.auth) return;
const authTemplateDir = path.join(PKG_ROOT, "template/with-auth"); const serverAppDir = path.join(projectDir, "apps/server");
if (await fs.pathExists(authTemplateDir)) { const webAppDir = path.join(projectDir, "apps/web");
const hasReactRouter = frontends.includes("react-router"); const nativeAppDir = path.join(projectDir, "apps/native");
const hasTanStackRouter = frontends.includes("tanstack-router"); const webFrontends = context.frontend.filter(
const hasTanStackStart = frontends.includes("tanstack-start"); (f) =>
const hasNextRouter = frontends.includes("next"); f === "tanstack-router" ||
f === "react-router" ||
if ( f === "tanstack-start" ||
hasReactRouter || f === "next",
hasTanStackRouter ||
hasTanStackStart ||
hasNextRouter
) {
const webDir = path.join(projectDir, "apps/web");
const webBaseAuthDir = path.join(authTemplateDir, "apps/web-base");
if (await fs.pathExists(webBaseAuthDir)) {
await fs.copy(webBaseAuthDir, webDir, { overwrite: true });
}
if (hasReactRouter) {
const reactRouterAuthDir = path.join(
authTemplateDir,
"apps/web-react-router",
); );
if (await fs.pathExists(reactRouterAuthDir)) { const hasNative = context.frontend.includes("native");
await fs.copy(reactRouterAuthDir, webDir, { overwrite: true });
}
}
if (hasTanStackRouter) { if (await fs.pathExists(serverAppDir)) {
const tanstackAuthDir = path.join( const authServerBaseSrc = path.join(PKG_ROOT, "templates/auth/server/base");
authTemplateDir, if (await fs.pathExists(authServerBaseSrc)) {
"apps/web-tanstack-router", await processAndCopyFiles(
"**/*",
authServerBaseSrc,
serverAppDir,
context,
); );
if (await fs.pathExists(tanstackAuthDir)) { } else {
await fs.copy(tanstackAuthDir, webDir, { overwrite: true }); consola.warn(
} pc.yellow(
} `Warning: Base auth server template not found at ${authServerBaseSrc}`,
),
if (hasTanStackStart) {
const tanstackStartAuthDir = path.join(
authTemplateDir,
"apps/web-tanstack-start",
); );
if (await fs.pathExists(tanstackStartAuthDir)) {
await fs.copy(tanstackStartAuthDir, webDir, { overwrite: true });
}
} }
if (hasNextRouter) { const authServerNextSrc = path.join(PKG_ROOT, "templates/auth/server/next");
const nextAuthDir = path.join(authTemplateDir, "apps/web-next"); if (await fs.pathExists(authServerNextSrc)) {
if (await fs.pathExists(nextAuthDir)) { await processAndCopyFiles(
await fs.copy(nextAuthDir, webDir, { overwrite: true }); "**/*",
} authServerNextSrc,
} serverAppDir,
} context,
const serverAuthDir = path.join(authTemplateDir, "apps/server/src");
const projectServerDir = path.join(projectDir, "apps/server/src");
await fs.copy(
path.join(serverAuthDir, "lib/trpc.ts"),
path.join(projectServerDir, "lib/trpc.ts"),
{ overwrite: true },
); );
} else {
await fs.copy( consola.warn(
path.join(serverAuthDir, "routers/index.ts"), pc.yellow(
path.join(projectServerDir, "routers/index.ts"), `Warning: Next auth server template not found at ${authServerNextSrc}`,
{ overwrite: true }, ),
); );
if (framework === "next") {
if (
await fs.pathExists(
path.join(authTemplateDir, "apps/server/src/with-next-app"),
)
) {
const nextAppAuthDir = path.join(
authTemplateDir,
"apps/server/src/with-next-app",
);
const nextAppDestDir = path.join(projectDir, "apps/server/src/app");
await fs.ensureDir(nextAppDestDir);
const files = await fs.readdir(nextAppAuthDir);
for (const file of files) {
const srcPath = path.join(nextAppAuthDir, file);
const destPath = path.join(nextAppDestDir, file);
await fs.copy(srcPath, destPath, { overwrite: true });
}
} }
const contextFileName = "with-next-context.ts"; if (context.orm !== "none" && context.database !== "none") {
await fs.copy( const orm = context.orm;
path.join(serverAuthDir, "lib", contextFileName), const db = context.database;
path.join(projectServerDir, "lib/context.ts"), let authDbSrc = "";
{ overwrite: true }, if (orm === "drizzle") {
authDbSrc = path.join(
PKG_ROOT,
`templates/auth/server/db/drizzle/${db}`,
); );
} else if (orm === "prisma") {
const authLibFileName = getAuthLibDir(orm, database); authDbSrc = path.join(
const authLibSourceDir = path.join(serverAuthDir, authLibFileName); PKG_ROOT,
if (await fs.pathExists(authLibSourceDir)) { `templates/auth/server/db/prisma/${db}`,
const files = await fs.readdir(authLibSourceDir); );
for (const file of files) { }
await fs.copy( if (authDbSrc && (await fs.pathExists(authDbSrc))) {
path.join(authLibSourceDir, file), await processAndCopyFiles("**/*", authDbSrc, serverAppDir, context);
path.join(projectServerDir, "lib", file), } else {
{ overwrite: true }, consola.warn(
pc.yellow(
`Warning: Auth template for ${orm}/${db} not found at ${authDbSrc}`,
),
); );
} }
} }
} else { } else {
const contextFileName = `with-${framework}-context.ts`; consola.warn(
await fs.copy( pc.yellow(
path.join(serverAuthDir, "lib", contextFileName), "Warning: apps/server directory does not exist, skipping server-side auth setup.",
path.join(projectServerDir, "lib/context.ts"), ),
{ overwrite: true },
); );
}
const indexFileName = `with-${framework}-index.ts`; if (webFrontends.length > 0 && (await fs.pathExists(webAppDir))) {
await fs.copy( const authWebBaseSrc = path.join(PKG_ROOT, "templates/auth/web/base");
path.join(serverAuthDir, indexFileName), if (await fs.pathExists(authWebBaseSrc)) {
path.join(projectServerDir, "index.ts"), await processAndCopyFiles("**/*", authWebBaseSrc, webAppDir, context);
{ overwrite: true }, } else {
consola.warn(
pc.yellow(
`Warning: Base auth web template not found at ${authWebBaseSrc}`,
),
); );
}
const authLibFileName = getAuthLibDir(orm, database); for (const framework of webFrontends) {
const authLibSourceDir = path.join(serverAuthDir, authLibFileName); const authWebFrameworkSrc = path.join(
if (await fs.pathExists(authLibSourceDir)) { PKG_ROOT,
const files = await fs.readdir(authLibSourceDir); `templates/auth/web/${framework}`,
for (const file of files) { );
await fs.copy( if (await fs.pathExists(authWebFrameworkSrc)) {
path.join(authLibSourceDir, file), await processAndCopyFiles(
path.join(projectServerDir, "lib", file), "**/*",
{ overwrite: true }, authWebFrameworkSrc,
webAppDir,
context,
);
} else {
consola.warn(
pc.yellow(
`Warning: Auth web template for ${framework} not found at ${authWebFrameworkSrc}`,
),
); );
} }
} }
} }
if (frontends.includes("native")) { if (hasNative && (await fs.pathExists(nativeAppDir))) {
const nativeAuthDir = path.join(authTemplateDir, "apps/native"); const authNativeSrc = path.join(PKG_ROOT, "templates/auth/native");
const projectNativeDir = path.join(projectDir, "apps/native"); if (await fs.pathExists(authNativeSrc)) {
await processAndCopyFiles("**/*", authNativeSrc, nativeAppDir, context);
} else {
consola.warn(
pc.yellow(
`Warning: Auth native template not found at ${authNativeSrc}`,
),
);
}
}
}
if (await fs.pathExists(nativeAuthDir)) { export async function setupAddonsTemplate(
await fs.copy(nativeAuthDir, projectNativeDir, { overwrite: true }); projectDir: string,
context: ProjectConfig,
): Promise<void> {
if (context.addons.includes("turborepo")) {
const turboSrcDir = path.join(PKG_ROOT, "templates/addons/turborepo");
if (await fs.pathExists(turboSrcDir)) {
await processAndCopyFiles("**/*", turboSrcDir, projectDir, context);
} else {
consola.warn(pc.yellow("Warning: Turborepo addon template not found."));
}
} }
addPackageDependency({ if (context.addons.includes("husky")) {
dependencies: ["@better-auth/expo"], const huskySrcDir = path.join(PKG_ROOT, "templates/addons/husky");
projectDir: path.join(projectDir, "apps/server"), if (await fs.pathExists(huskySrcDir)) {
await processAndCopyFiles("**/*", huskySrcDir, projectDir, context);
} else {
consola.warn(pc.yellow("Warning: Husky addon template not found."));
}
}
if (context.addons.includes("biome")) {
const biomeSrcDir = path.join(PKG_ROOT, "templates/addons/biome");
if (await fs.pathExists(biomeSrcDir)) {
await processAndCopyFiles("**/*", biomeSrcDir, projectDir, context);
} else {
consola.warn(pc.yellow("Warning: Biome addon template not found."));
}
}
if (context.addons.includes("pwa")) {
const pwaSrcDir = path.join(PKG_ROOT, "templates/addons/pwa/apps/web");
const webAppDir = path.join(projectDir, "apps/web");
if (await fs.pathExists(pwaSrcDir)) {
if (await fs.pathExists(webAppDir)) {
await processAndCopyFiles("**/*", pwaSrcDir, webAppDir, context);
} else {
consola.warn(
pc.yellow(
"Warning: apps/web directory not found, cannot setup PWA addon.",
),
);
}
} else {
consola.warn(pc.yellow("Warning: PWA addon template not found."));
}
}
}
export async function setupExamplesTemplate(
projectDir: string,
context: ProjectConfig,
): Promise<void> {
if (!context.examples || context.examples.length === 0) return;
const serverAppDir = path.join(projectDir, "apps/server");
const webAppDir = path.join(projectDir, "apps/web");
for (const example of context.examples) {
const exampleBaseDir = path.join(PKG_ROOT, `templates/examples/${example}`);
if (await fs.pathExists(serverAppDir)) {
const exampleServerSrc = path.join(exampleBaseDir, "server");
if (await fs.pathExists(exampleServerSrc)) {
if (context.orm !== "none") {
const exampleOrmBaseSrc = path.join(
exampleServerSrc,
context.orm,
"base",
);
if (await fs.pathExists(exampleOrmBaseSrc)) {
await processAndCopyFiles(
"**/*",
exampleOrmBaseSrc,
serverAppDir,
context,
false,
);
}
if (context.database !== "none") {
const exampleDbSchemaSrc = path.join(
exampleServerSrc,
context.orm,
context.database,
);
if (await fs.pathExists(exampleDbSchemaSrc)) {
await processAndCopyFiles(
"**/*",
exampleDbSchemaSrc,
serverAppDir,
context,
false,
);
}
}
}
}
}
if (await fs.pathExists(webAppDir)) {
const exampleWebSrc = path.join(exampleBaseDir, "web");
if (await fs.pathExists(exampleWebSrc)) {
const webFrameworks = context.frontend.filter((f) =>
[
"next",
"react-router",
"tanstack-router",
"tanstack-start",
].includes(f),
);
for (const framework of webFrameworks) {
const exampleWebFrameworkSrc = path.join(exampleWebSrc, framework);
if (await fs.pathExists(exampleWebFrameworkSrc)) {
await processAndCopyFiles(
"**/*",
exampleWebFrameworkSrc,
webAppDir,
context,
false,
);
}
}
}
}
}
}
export async function fixGitignoreFiles(
projectDir: string,
context: ProjectConfig,
): Promise<void> {
const gitignoreFiles = await globby(["**/.gitignore.hbs", "**/_gitignore"], {
cwd: projectDir,
dot: true,
onlyFiles: true,
absolute: true,
ignore: ["**/node_modules/**", "**/.git/**"],
}); });
await updateAuthConfigWithExpoPlugin(projectDir, orm, database); for (const currentPath of gitignoreFiles) {
} const dir = path.dirname(currentPath);
} const filename = path.basename(currentPath);
} const destPath = path.join(dir, ".gitignore");
// Need to find a better way to handle this
async function updateAuthConfigWithExpoPlugin(
projectDir: string,
orm: ProjectOrm,
database: ProjectDatabase,
): Promise<void> {
const serverDir = path.join(projectDir, "apps/server");
let authFilePath: string | undefined;
if (orm === "drizzle") {
if (database === "sqlite") {
authFilePath = path.join(serverDir, "src/lib/auth.ts");
} else if (database === "postgres") {
authFilePath = path.join(serverDir, "src/lib/auth.ts");
}
} else if (orm === "prisma") {
if (database === "sqlite") {
authFilePath = path.join(serverDir, "src/lib/auth.ts");
} else if (database === "postgres") {
authFilePath = path.join(serverDir, "src/lib/auth.ts");
}
}
if (authFilePath && (await fs.pathExists(authFilePath))) {
let authFileContent = await fs.readFile(authFilePath, "utf8");
if (!authFileContent.includes("@better-auth/expo")) {
const importLine = 'import { expo } from "@better-auth/expo";\n';
const lastImportIndex = authFileContent.lastIndexOf("import");
const afterLastImport =
authFileContent.indexOf("\n", lastImportIndex) + 1;
authFileContent =
authFileContent.substring(0, afterLastImport) +
importLine +
authFileContent.substring(afterLastImport);
}
if (!authFileContent.includes("plugins:")) {
authFileContent = authFileContent.replace(
/}\);/,
" plugins: [expo()],\n});",
);
} else if (!authFileContent.includes("expo()")) {
authFileContent = authFileContent.replace(
/plugins: \[(.*?)\]/s,
(match, plugins) => {
return `plugins: [${plugins}${plugins.trim() ? ", " : ""}expo()]`;
},
);
}
if (!authFileContent.includes("my-better-t-app://")) {
authFileContent = authFileContent.replace(
/trustedOrigins: \[(.*?)\]/s,
(match, origins) => {
return `trustedOrigins: [${origins}${origins.trim() ? ", " : ""}"my-better-t-app://"]`;
},
);
}
await fs.writeFile(authFilePath, authFileContent);
}
}
export async function fixGitignoreFiles(projectDir: string): Promise<void> {
const gitignorePaths = await findGitignoreFiles(projectDir);
for (const gitignorePath of gitignorePaths) {
if (await fs.pathExists(gitignorePath)) {
const targetPath = path.join(path.dirname(gitignorePath), ".gitignore");
await fs.move(gitignorePath, targetPath, { overwrite: true });
}
}
}
/**
* Find all _gitignore files in the project recursively
*/
async function findGitignoreFiles(dir: string): Promise<string[]> {
const gitignoreFiles: string[] = [];
const gitignorePath = path.join(dir, "_gitignore");
if (await fs.pathExists(gitignorePath)) {
gitignoreFiles.push(gitignorePath);
}
try { try {
const entries = await fs.readdir(dir, { withFileTypes: true }); if (filename === ".gitignore.hbs") {
await processTemplate(currentPath, destPath, context);
for (const entry of entries) { await fs.remove(currentPath);
if (entry.isDirectory() && entry.name !== "node_modules") { } else if (filename === "_gitignore") {
const subDirPath = path.join(dir, entry.name); await fs.move(currentPath, destPath, { overwrite: true });
const subDirFiles = await findGitignoreFiles(subDirPath); }
gitignoreFiles.push(...subDirFiles); } catch (error) {
consola.error(`Error processing gitignore file ${currentPath}:`, error);
} }
} }
} catch (error) {}
return gitignoreFiles;
} }
function getOrmTemplateDir(orm: ProjectOrm, database: ProjectDatabase): string { export async function handleExtras(
if (orm === "drizzle") { projectDir: string,
if (database === "sqlite") return "template/with-drizzle-sqlite"; context: ProjectConfig,
if (database === "postgres") return "template/with-drizzle-postgres"; ): Promise<void> {
if (database === "mysql") return "template/with-drizzle-mysql"; if (context.packageManager === "pnpm") {
const src = path.join(PKG_ROOT, "templates/extras/pnpm-workspace.yaml");
const dest = path.join(projectDir, "pnpm-workspace.yaml");
if (await fs.pathExists(src)) {
await fs.copy(src, dest);
} else {
consola.warn(
pc.yellow("Warning: pnpm-workspace.yaml template not found."),
);
} }
if (orm === "prisma") {
if (database === "sqlite") return "template/with-prisma-sqlite";
if (database === "postgres") return "template/with-prisma-postgres";
if (database === "mysql") return "template/with-prisma-mysql";
if (database === "mongodb") return "template/with-prisma-mongodb";
} }
return "template/base";
}
function getAuthLibDir(orm: ProjectOrm, database: ProjectDatabase): string {
if (orm === "drizzle") {
if (database === "sqlite") return "with-drizzle-sqlite-lib";
if (database === "postgres") return "with-drizzle-postgres-lib";
if (database === "mysql") return "with-drizzle-mysql-lib";
}
if (orm === "prisma") {
if (database === "sqlite") return "with-prisma-sqlite-lib";
if (database === "postgres") return "with-prisma-postgres-lib";
if (database === "mysql") return "with-prisma-mysql-lib";
if (database === "mongodb") return "with-prisma-mongodb-lib";
}
throw new Error("Invalid ORM or database configuration for auth setup");
} }

View File

@@ -202,29 +202,22 @@ DATABASE_URL=your_database_url
DATABASE_AUTH_TOKEN=your_auth_token`); DATABASE_AUTH_TOKEN=your_auth_token`);
} }
export async function setupTurso( import type { ProjectConfig } from "../types";
projectDir: string,
shouldSetupTurso: boolean, export async function setupTurso(config: ProjectConfig): Promise<void> {
) { const { projectName, orm } = config;
const projectDir = path.resolve(process.cwd(), projectName);
const isDrizzle = orm === "drizzle";
const setupSpinner = spinner(); const setupSpinner = spinner();
setupSpinner.start("Setting up Turso database"); setupSpinner.start("Setting up Turso database");
try { try {
if (!shouldSetupTurso) {
setupSpinner.stop("Skipping Turso setup");
await writeEnvFile(projectDir);
log.info(
pc.blue("Skipping Turso setup. Setting up empty configuration."),
);
displayManualSetupInstructions();
return;
}
const platform = os.platform(); const platform = os.platform();
const isMac = platform === "darwin"; const isMac = platform === "darwin";
const canInstallCLI = platform !== "win32"; const isLinux = platform === "linux";
const isWindows = platform === "win32";
if (!canInstallCLI) { if (isWindows) {
setupSpinner.stop(pc.yellow("Turso setup not supported on Windows")); setupSpinner.stop(pc.yellow("Turso setup not supported on Windows"));
log.warn(pc.yellow("Automatic Turso setup is not supported on Windows.")); log.warn(pc.yellow("Automatic Turso setup is not supported on Windows."));
await writeEnvFile(projectDir); await writeEnvFile(projectDir);

View File

@@ -8,6 +8,7 @@ import { createProject } from "./helpers/create-project";
import { gatherConfig } from "./prompts/config-prompts"; import { gatherConfig } from "./prompts/config-prompts";
import type { import type {
ProjectAddons, ProjectAddons,
ProjectApi,
ProjectBackend, ProjectBackend,
ProjectConfig, ProjectConfig,
ProjectDBSetup, ProjectDBSetup,
@@ -17,33 +18,13 @@ import type {
ProjectOrm, ProjectOrm,
ProjectPackageManager, ProjectPackageManager,
ProjectRuntime, ProjectRuntime,
YargsArgv,
} from "./types"; } from "./types";
import { displayConfig } from "./utils/display-config"; import { displayConfig } from "./utils/display-config";
import { generateReproducibleCommand } from "./utils/generate-reproducible-command"; import { generateReproducibleCommand } from "./utils/generate-reproducible-command";
import { getLatestCLIVersion } from "./utils/get-latest-cli-version"; import { getLatestCLIVersion } from "./utils/get-latest-cli-version";
import { renderTitle } from "./utils/render-title"; import { renderTitle } from "./utils/render-title";
type YargsArgv = {
projectDirectory?: string;
yes?: boolean;
database?: ProjectDatabase;
orm?: ProjectOrm;
auth?: boolean;
frontend?: ProjectFrontend[];
addons?: ProjectAddons[];
examples?: ProjectExamples[];
git?: boolean;
packageManager?: ProjectPackageManager;
install?: boolean;
dbSetup?: ProjectDBSetup;
backend?: ProjectBackend;
runtime?: ProjectRuntime;
_: (string | number)[];
$0: string;
};
const exit = () => process.exit(0); const exit = () => process.exit(0);
process.on("SIGINT", exit); process.on("SIGINT", exit);
process.on("SIGTERM", exit); process.on("SIGTERM", exit);
@@ -99,7 +80,15 @@ async function main() {
type: "array", type: "array",
string: true, string: true,
describe: "Additional addons", describe: "Additional addons",
choices: ["pwa", "tauri", "starlight", "biome", "husky", "none"], choices: [
"pwa",
"tauri",
"starlight",
"biome",
"husky",
"turborepo",
"none",
],
}) })
.option("examples", { .option("examples", {
type: "array", type: "array",
@@ -119,7 +108,7 @@ async function main() {
}) })
.option("install", { .option("install", {
type: "boolean", type: "boolean",
describe: "Install dependencies (use --no-install to explicitly skip)", describe: "Install dependencies",
}) })
.option("db-setup", { .option("db-setup", {
type: "string", type: "string",
@@ -136,6 +125,11 @@ async function main() {
describe: "Runtime", describe: "Runtime",
choices: ["bun", "node"], choices: ["bun", "node"],
}) })
.option("api", {
type: "string",
describe: "API type",
choices: ["trpc", "orpc"],
})
.completion() .completion()
.recommendCommands() .recommendCommands()
.version(getLatestCLIVersion()) .version(getLatestCLIVersion())
@@ -188,7 +182,9 @@ async function main() {
const elapsedTimeInSeconds = ((Date.now() - startTime) / 1000).toFixed(2); const elapsedTimeInSeconds = ((Date.now() - startTime) / 1000).toFixed(2);
outro( outro(
pc.magenta( pc.magenta(
`Project created successfully in ${pc.bold(elapsedTimeInSeconds)} seconds!`, `Project created successfully in ${pc.bold(
elapsedTimeInSeconds,
)} seconds!`,
), ),
); );
} catch (error) { } catch (error) {
@@ -214,10 +210,10 @@ function processAndValidateFlags(
): Partial<ProjectConfig> { ): Partial<ProjectConfig> {
const config: Partial<ProjectConfig> = {}; const config: Partial<ProjectConfig> = {};
// --- Database and ORM validation ---
if (options.database) { if (options.database) {
config.database = options.database as ProjectDatabase; config.database = options.database as ProjectDatabase;
} }
if (options.orm) { if (options.orm) {
if (options.orm === "none") { if (options.orm === "none") {
config.orm = "none"; config.orm = "none";
@@ -225,7 +221,6 @@ function processAndValidateFlags(
config.orm = options.orm as ProjectOrm; config.orm = options.orm as ProjectOrm;
} }
} }
if ( if (
(config.database ?? options.database) === "mongodb" && (config.database ?? options.database) === "mongodb" &&
(config.orm ?? options.orm) === "drizzle" (config.orm ?? options.orm) === "drizzle"
@@ -332,7 +327,6 @@ function processAndValidateFlags(
if (options.backend) { if (options.backend) {
config.backend = options.backend as ProjectBackend; config.backend = options.backend as ProjectBackend;
} }
if (options.runtime) { if (options.runtime) {
config.runtime = options.runtime as ProjectRuntime; config.runtime = options.runtime as ProjectRuntime;
} }
@@ -353,12 +347,13 @@ function processAndValidateFlags(
(f) => (f) =>
f === "tanstack-router" || f === "tanstack-router" ||
f === "react-router" || f === "react-router" ||
f === "tanstack-start", f === "tanstack-start" ||
f === "next",
); );
if (webFrontends.length > 1) { if (webFrontends.length > 1) {
consola.fatal( consola.fatal(
"Cannot select multiple web frameworks. Choose only one of: tanstack-router, tanstack-start, react-router", "Cannot select multiple web frameworks. Choose only one of: tanstack-router, tanstack-start, react-router, next",
); );
process.exit(1); process.exit(1);
} }
@@ -366,6 +361,34 @@ function processAndValidateFlags(
} }
} }
if (options.api) {
config.api = options.api as ProjectApi;
}
const effectiveFrontend =
config.frontend ??
(options.frontend?.filter((f) => f !== "none") as ProjectFrontend[]) ??
(options.yes ? DEFAULT_CONFIG.frontend : undefined);
const includesNative = effectiveFrontend?.includes("native");
const effectiveApi =
config.api ?? (options.yes ? DEFAULT_CONFIG.api : undefined);
if (includesNative && effectiveApi === "orpc") {
consola.fatal(
`oRPC API is not supported when using the 'native' frontend. Please use --api trpc or remove 'native' from --frontend.`,
);
process.exit(1);
}
if (includesNative && effectiveApi !== "trpc") {
if (!options.api || (options.yes && options.api !== "orpc")) {
config.api = "trpc";
}
}
// --- Addons validation ---
if (options.addons && options.addons.length > 0) { if (options.addons && options.addons.length > 0) {
if (options.addons.includes("none")) { if (options.addons.includes("none")) {
if (options.addons.length > 1) { if (options.addons.length > 1) {
@@ -383,9 +406,6 @@ function processAndValidateFlags(
webSpecificAddons.includes(addon), webSpecificAddons.includes(addon),
); );
const effectiveFrontend =
config.frontend ?? (options.yes ? DEFAULT_CONFIG.frontend : undefined);
const hasCompatibleWebFrontend = effectiveFrontend?.some( const hasCompatibleWebFrontend = effectiveFrontend?.some(
(f) => f === "tanstack-router" || f === "react-router", (f) => f === "tanstack-router" || f === "react-router",
); );
@@ -413,6 +433,7 @@ function processAndValidateFlags(
} }
} }
// --- Examples validation ---
if (options.examples && options.examples.length > 0) { if (options.examples && options.examples.length > 0) {
if (options.examples.includes("none")) { if (options.examples.includes("none")) {
if (options.examples.length > 1) { if (options.examples.length > 1) {
@@ -437,25 +458,22 @@ function processAndValidateFlags(
process.exit(1); process.exit(1);
} }
const effectiveFrontend = const hasWebFrontendForExamples = effectiveFrontend?.some((f) =>
config.frontend ?? ["tanstack-router", "react-router", "tanstack-start", "next"].includes(
(options.frontend?.filter((f) => f !== "none") as ProjectFrontend[]) ?? f,
(options.yes ? DEFAULT_CONFIG.frontend : undefined); ),
const hasWebFrontend = effectiveFrontend?.some((f) =>
["tanstack-router", "react-router", "tanstack-start"].includes(f),
); );
if (!hasWebFrontend) { if (!hasWebFrontendForExamples) {
if (options.frontend) { if (options.frontend) {
consola.fatal( consola.fatal(
"Examples require a web frontend (tanstack-router, react-router, or tanstack-start). Cannot use --examples with your frontend selection.", "Examples require a web frontend (tanstack-router, react-router, tanstack-start, or next). Cannot use --examples with your frontend selection.",
); );
process.exit(1); process.exit(1);
} else if (!options.yes) { } else if (!options.yes) {
} else { } else {
consola.fatal( consola.fatal(
"Examples require a web frontend (tanstack-router, react-router, or tanstack-start) (default frontend incompatible).", "Examples require a web frontend (tanstack-router, react-router, tanstack-start, or next) (default frontend incompatible).",
); );
process.exit(1); process.exit(1);
} }
@@ -465,18 +483,16 @@ function processAndValidateFlags(
} }
} }
// --- Other flags ---
if (options.packageManager) { if (options.packageManager) {
config.packageManager = options.packageManager as ProjectPackageManager; config.packageManager = options.packageManager as ProjectPackageManager;
} }
if (options.git !== undefined) { if (options.git !== undefined) {
config.git = options.git; config.git = options.git;
} }
if (options.install !== undefined) { if (options.install !== undefined) {
config.noInstall = !options.install; config.install = options.install;
} }
if (projectDirectory) { if (projectDirectory) {
config.projectName = projectDirectory; config.projectName = projectDirectory;
} }

View File

@@ -29,6 +29,11 @@ export async function getAddonsChoice(
label: "Husky", label: "Husky",
hint: "Add Git hooks with Husky, lint-staged (requires Biome)", hint: "Add Git hooks with Husky, lint-staged (requires Biome)",
}, },
{
value: "turborepo" as const,
label: "Turborepo",
hint: "Optimize builds for monorepos",
},
]; ];
const webAddonOptions = [ const webAddonOptions = [

View File

@@ -0,0 +1,58 @@
import { cancel, isCancel, select } from "@clack/prompts";
import pc from "picocolors";
import { DEFAULT_CONFIG } from "../constants";
import type { ProjectApi, ProjectFrontend } from "../types";
export async function getApiChoice(
Api?: ProjectApi | undefined,
frontend?: ProjectFrontend[],
): Promise<ProjectApi> {
if (Api) return Api;
const includesNative = frontend?.includes("native");
let apiOptions = [
{
value: "trpc" as const,
label: "tRPC",
hint: "End-to-end typesafe APIs made easy",
},
{
value: "orpc" as const,
label: "oRPC",
hint: "End-to-end type-safe APIs that adhere to OpenAPI standards",
},
{
value: "none" as const,
label: "None",
hint: "No API integration (skip API setup)",
},
];
if (includesNative) {
apiOptions = [
{
value: "trpc" as const,
label: "tRPC",
hint: "End-to-end typesafe APIs made easy (Required for Native frontend)",
},
];
}
const apiType = await select<ProjectApi>({
message: "Select API type",
options: apiOptions,
initialValue: includesNative ? "trpc" : DEFAULT_CONFIG.api,
});
if (isCancel(apiType)) {
cancel(pc.red("Operation cancelled"));
process.exit(0);
}
if (includesNative && apiType !== "trpc") {
return "trpc";
}
return apiType;
}

View File

@@ -6,7 +6,6 @@ import type { ProjectFrontend } from "../types";
export async function getAuthChoice( export async function getAuthChoice(
auth: boolean | undefined, auth: boolean | undefined,
hasDatabase: boolean, hasDatabase: boolean,
frontends?: ProjectFrontend[],
): Promise<boolean> { ): Promise<boolean> {
if (!hasDatabase) return false; if (!hasDatabase) return false;

View File

@@ -1,7 +1,8 @@
import { cancel, group, log } from "@clack/prompts"; import { cancel, group } from "@clack/prompts";
import pc from "picocolors"; import pc from "picocolors";
import type { import type {
ProjectAddons, ProjectAddons,
ProjectApi,
ProjectBackend, ProjectBackend,
ProjectConfig, ProjectConfig,
ProjectDBSetup, ProjectDBSetup,
@@ -13,6 +14,7 @@ import type {
ProjectRuntime, ProjectRuntime,
} from "../types"; } from "../types";
import { getAddonsChoice } from "./addons"; import { getAddonsChoice } from "./addons";
import { getApiChoice } from "./api";
import { getAuthChoice } from "./auth"; import { getAuthChoice } from "./auth";
import { getBackendFrameworkChoice } from "./backend-framework"; import { getBackendFrameworkChoice } from "./backend-framework";
import { getDatabaseChoice } from "./database"; import { getDatabaseChoice } from "./database";
@@ -20,7 +22,7 @@ import { getDBSetupChoice } from "./db-setup";
import { getExamplesChoice } from "./examples"; import { getExamplesChoice } from "./examples";
import { getFrontendChoice } from "./frontend-option"; import { getFrontendChoice } from "./frontend-option";
import { getGitChoice } from "./git"; import { getGitChoice } from "./git";
import { getNoInstallChoice } from "./install"; import { getinstallChoice } from "./install";
import { getORMChoice } from "./orm"; import { getORMChoice } from "./orm";
import { getPackageManagerChoice } from "./package-manager"; import { getPackageManagerChoice } from "./package-manager";
import { getProjectName } from "./project-name"; import { getProjectName } from "./project-name";
@@ -35,11 +37,12 @@ type PromptGroupResults = {
examples: ProjectExamples[]; examples: ProjectExamples[];
git: boolean; git: boolean;
packageManager: ProjectPackageManager; packageManager: ProjectPackageManager;
noInstall: boolean; install: boolean;
dbSetup: ProjectDBSetup; dbSetup: ProjectDBSetup;
backend: ProjectBackend; backend: ProjectBackend;
runtime: ProjectRuntime; runtime: ProjectRuntime;
frontend: ProjectFrontend[]; frontend: ProjectFrontend[];
api: ProjectApi;
}; };
export async function gatherConfig( export async function gatherConfig(
@@ -57,12 +60,9 @@ export async function gatherConfig(
database: () => getDatabaseChoice(flags.database), database: () => getDatabaseChoice(flags.database),
orm: ({ results }) => orm: ({ results }) =>
getORMChoice(flags.orm, results.database !== "none", results.database), getORMChoice(flags.orm, results.database !== "none", results.database),
api: ({ results }) => getApiChoice(flags.api, results.frontend),
auth: ({ results }) => auth: ({ results }) =>
getAuthChoice( getAuthChoice(flags.auth, results.database !== "none"),
flags.auth,
results.database !== "none",
results.frontend,
),
addons: ({ results }) => getAddonsChoice(flags.addons, results.frontend), addons: ({ results }) => getAddonsChoice(flags.addons, results.frontend),
examples: ({ results }) => examples: ({ results }) =>
getExamplesChoice( getExamplesChoice(
@@ -79,7 +79,7 @@ export async function gatherConfig(
), ),
git: () => getGitChoice(flags.git), git: () => getGitChoice(flags.git),
packageManager: () => getPackageManagerChoice(flags.packageManager), packageManager: () => getPackageManagerChoice(flags.packageManager),
noInstall: () => getNoInstallChoice(flags.noInstall), install: () => getinstallChoice(flags.install),
}, },
{ {
onCancel: () => { onCancel: () => {
@@ -99,9 +99,10 @@ export async function gatherConfig(
examples: result.examples, examples: result.examples,
git: result.git, git: result.git,
packageManager: result.packageManager, packageManager: result.packageManager,
noInstall: result.noInstall, install: result.install,
dbSetup: result.dbSetup, dbSetup: result.dbSetup,
backend: result.backend, backend: result.backend,
runtime: result.runtime, runtime: result.runtime,
api: result.api,
}; };
} }

View File

@@ -2,14 +2,12 @@ import { cancel, confirm, isCancel } from "@clack/prompts";
import pc from "picocolors"; import pc from "picocolors";
import { DEFAULT_CONFIG } from "../constants"; import { DEFAULT_CONFIG } from "../constants";
export async function getNoInstallChoice( export async function getinstallChoice(install?: boolean): Promise<boolean> {
noInstall?: boolean, if (install !== undefined) return install;
): Promise<boolean> {
if (noInstall !== undefined) return noInstall;
const response = await confirm({ const response = await confirm({
message: "Install dependencies?", message: "Install dependencies?",
initialValue: !DEFAULT_CONFIG.noInstall, initialValue: DEFAULT_CONFIG.install,
}); });
if (isCancel(response)) { if (isCancel(response)) {
@@ -17,5 +15,5 @@ export async function getNoInstallChoice(
process.exit(0); process.exit(0);
} }
return !response; return response;
} }

View File

@@ -12,6 +12,7 @@ export type ProjectAddons =
| "tauri" | "tauri"
| "husky" | "husky"
| "starlight" | "starlight"
| "turborepo"
| "none"; | "none";
export type ProjectBackend = "hono" | "elysia" | "express" | "next"; export type ProjectBackend = "hono" | "elysia" | "express" | "next";
export type ProjectRuntime = "node" | "bun"; export type ProjectRuntime = "node" | "bun";
@@ -29,6 +30,7 @@ export type ProjectDBSetup =
| "mongodb-atlas" | "mongodb-atlas"
| "neon" | "neon"
| "none"; | "none";
export type ProjectApi = "trpc" | "orpc" | "none";
export interface ProjectConfig { export interface ProjectConfig {
projectName: string; projectName: string;
@@ -41,23 +43,30 @@ export interface ProjectConfig {
examples: ProjectExamples[]; examples: ProjectExamples[];
git: boolean; git: boolean;
packageManager: ProjectPackageManager; packageManager: ProjectPackageManager;
noInstall: boolean; install: boolean;
dbSetup: ProjectDBSetup; dbSetup: ProjectDBSetup;
frontend: ProjectFrontend[]; frontend: ProjectFrontend[];
api: ProjectApi;
} }
export type CLIOptions = { export type YargsArgv = {
projectDirectory?: string;
yes?: boolean; yes?: boolean;
database?: string; database?: ProjectDatabase;
orm?: string; orm?: ProjectOrm;
auth?: boolean; auth?: boolean;
frontend?: string[]; frontend?: ProjectFrontend[];
addons?: string[]; addons?: ProjectAddons[];
examples?: string[] | boolean; examples?: ProjectExamples[];
git?: boolean; git?: boolean;
packageManager?: string; packageManager?: ProjectPackageManager;
install?: boolean; install?: boolean;
dbSetup?: string; dbSetup?: ProjectDBSetup;
backend?: string; backend?: ProjectBackend;
runtime?: string; runtime?: ProjectRuntime;
api?: ProjectApi;
_: (string | number)[];
$0: string;
}; };

View File

@@ -3,30 +3,41 @@ import fs from "fs-extra";
import { type AvailableDependencies, dependencyVersionMap } from "../constants"; import { type AvailableDependencies, dependencyVersionMap } from "../constants";
export const addPackageDependency = (opts: { export const addPackageDependency = async (opts: {
dependencies?: AvailableDependencies[]; dependencies?: AvailableDependencies[];
devDependencies?: AvailableDependencies[]; devDependencies?: AvailableDependencies[];
projectDir: string; projectDir: string;
}) => { }): Promise<void> => {
const { dependencies = [], devDependencies = [], projectDir } = opts; const { dependencies = [], devDependencies = [], projectDir } = opts;
const pkgJsonPath = path.join(projectDir, "package.json"); const pkgJsonPath = path.join(projectDir, "package.json");
const pkgJson = fs.readJSONSync(pkgJsonPath);
const pkgJson = await fs.readJson(pkgJsonPath);
if (!pkgJson.dependencies) pkgJson.dependencies = {}; if (!pkgJson.dependencies) pkgJson.dependencies = {};
if (!pkgJson.devDependencies) pkgJson.devDependencies = {}; if (!pkgJson.devDependencies) pkgJson.devDependencies = {};
for (const pkgName of dependencies) { for (const pkgName of dependencies) {
const version = dependencyVersionMap[pkgName]; const version = dependencyVersionMap[pkgName];
if (version) {
pkgJson.dependencies[pkgName] = version; pkgJson.dependencies[pkgName] = version;
} else {
console.warn(`Warning: Dependency ${pkgName} not found in version map.`);
}
} }
for (const pkgName of devDependencies) { for (const pkgName of devDependencies) {
const version = dependencyVersionMap[pkgName]; const version = dependencyVersionMap[pkgName];
if (version) {
pkgJson.devDependencies[pkgName] = version; pkgJson.devDependencies[pkgName] = version;
} else {
console.warn(
`Warning: Dev dependency ${pkgName} not found in version map.`,
);
}
} }
fs.writeJSONSync(pkgJsonPath, pkgJson, { await fs.writeJson(pkgJsonPath, pkgJson, {
spaces: 2, spaces: 2,
}); });
}; };

View File

@@ -2,66 +2,109 @@ import pc from "picocolors";
import type { ProjectConfig } from "../types"; import type { ProjectConfig } from "../types";
export function displayConfig(config: Partial<ProjectConfig>) { export function displayConfig(config: Partial<ProjectConfig>) {
const configDisplay = []; const configDisplay: string[] = [];
if (config.projectName) { if (config.projectName) {
configDisplay.push(`${pc.blue("Project Name:")} ${config.projectName}`); configDisplay.push(`${pc.blue("Project Name:")} ${config.projectName}`);
} }
if (config.frontend !== undefined) { if (config.frontend !== undefined) {
const frontend = Array.isArray(config.frontend)
? config.frontend
: [config.frontend];
const frontendText = const frontendText =
config.frontend.length > 0 ? config.frontend.join(", ") : "none"; frontend.length > 0 && frontend[0] !== undefined && frontend[0] !== ""
? frontend.join(", ")
: "none";
configDisplay.push(`${pc.blue("Frontend:")} ${frontendText}`); configDisplay.push(`${pc.blue("Frontend:")} ${frontendText}`);
} }
if (config.backend !== undefined) { if (config.backend !== undefined) {
configDisplay.push(`${pc.blue("Backend Framework:")} ${config.backend}`); configDisplay.push(
`${pc.blue("Backend Framework:")} ${String(config.backend)}`,
);
} }
if (config.runtime !== undefined) { if (config.runtime !== undefined) {
configDisplay.push(`${pc.blue("Runtime:")} ${config.runtime}`); configDisplay.push(`${pc.blue("Runtime:")} ${String(config.runtime)}`);
}
if (config.api !== undefined) {
configDisplay.push(`${pc.blue("API:")} ${String(config.api)}`);
} }
if (config.database !== undefined) { if (config.database !== undefined) {
configDisplay.push(`${pc.blue("Database:")} ${config.database}`); configDisplay.push(`${pc.blue("Database:")} ${String(config.database)}`);
} }
if (config.orm !== undefined) { if (config.orm !== undefined) {
configDisplay.push(`${pc.blue("ORM:")} ${config.orm}`); configDisplay.push(`${pc.blue("ORM:")} ${String(config.orm)}`);
} }
if (config.auth !== undefined) { if (config.auth !== undefined) {
configDisplay.push(`${pc.blue("Authentication:")} ${config.auth}`); const authText =
typeof config.auth === "boolean"
? config.auth
? "Yes"
: "No"
: String(config.auth);
configDisplay.push(`${pc.blue("Authentication:")} ${authText}`);
} }
if (config.addons !== undefined) { if (config.addons !== undefined) {
const addons = Array.isArray(config.addons)
? config.addons
: [config.addons];
const addonsText = const addonsText =
config.addons.length > 0 ? config.addons.join(", ") : "none"; addons.length > 0 && addons[0] !== undefined ? addons.join(", ") : "none";
configDisplay.push(`${pc.blue("Addons:")} ${addonsText}`); configDisplay.push(`${pc.blue("Addons:")} ${addonsText}`);
} }
if (config.examples !== undefined) { if (config.examples !== undefined) {
const examples = Array.isArray(config.examples)
? config.examples
: [config.examples];
const examplesText = const examplesText =
config.examples.length > 0 ? config.examples.join(", ") : "none"; examples.length > 0 && examples[0] !== undefined
? examples.join(", ")
: "none";
configDisplay.push(`${pc.blue("Examples:")} ${examplesText}`); configDisplay.push(`${pc.blue("Examples:")} ${examplesText}`);
} }
if (config.git !== undefined) { if (config.git !== undefined) {
configDisplay.push(`${pc.blue("Git Init:")} ${config.git}`); const gitText =
typeof config.git === "boolean"
? config.git
? "Yes"
: "No"
: String(config.git);
configDisplay.push(`${pc.blue("Git Init:")} ${gitText}`);
} }
if (config.packageManager !== undefined) { if (config.packageManager !== undefined) {
configDisplay.push( configDisplay.push(
`${pc.blue("Package Manager:")} ${config.packageManager}`, `${pc.blue("Package Manager:")} ${String(config.packageManager)}`,
); );
} }
if (config.noInstall !== undefined) { if (config.install !== undefined) {
configDisplay.push(`${pc.blue("Skip Install:")} ${config.noInstall}`); const installText =
typeof config.install === "boolean"
? config.install
? "Yes"
: "No"
: String(config.install);
configDisplay.push(`${pc.blue("Install Dependencies:")} ${installText}`);
} }
if (config.dbSetup !== undefined) { if (config.dbSetup !== undefined) {
configDisplay.push(`${pc.blue("Database Setup:")} ${config.dbSetup}`); configDisplay.push(
`${pc.blue("Database Setup:")} ${String(config.dbSetup)}`,
);
}
if (configDisplay.length === 0) {
return pc.yellow("No configuration selected.");
} }
return configDisplay.join("\n"); return configDisplay.join("\n");

View File

@@ -17,9 +17,13 @@ export function generateReproducibleCommand(config: ProjectConfig): string {
} }
} }
if (config.api) {
flags.push(`--api ${config.api}`);
}
flags.push(config.auth ? "--auth" : "--no-auth"); flags.push(config.auth ? "--auth" : "--no-auth");
flags.push(config.git ? "--git" : "--no-git"); flags.push(config.git ? "--git" : "--no-git");
flags.push(config.noInstall ? "--no-install" : "--install"); flags.push(config.install ? "--install" : "--no-install");
if (config.runtime) { if (config.runtime) {
flags.push(`--runtime ${config.runtime}`); flags.push(`--runtime ${config.runtime}`);

View File

@@ -0,0 +1,23 @@
import type { ProjectPackageManager } from "../types";
/**
* Returns the appropriate command for running a package without installing it globally,
* based on the selected package manager.
*
* @param packageManager - The selected package manager (e.g., 'npm', 'yarn', 'pnpm', 'bun').
* @param commandWithArgs - The command to run, including arguments (e.g., "prisma generate --schema=./prisma/schema.prisma").
* @returns The full command string (e.g., "npx prisma generate --schema=./prisma/schema.prisma").
*/
export function getPackageExecutionCommand(
packageManager: ProjectPackageManager | null | undefined,
commandWithArgs: string,
): string {
switch (packageManager) {
case "pnpm":
return `pnpm dlx ${commandWithArgs}`;
case "bun":
return `bunx ${commandWithArgs}`;
default:
return `npx ${commandWithArgs}`;
}
}

View File

@@ -0,0 +1,37 @@
import path from "node:path";
import fs from "fs-extra";
import handlebars from "handlebars";
import type { ProjectConfig } from "../types";
/**
* Processes a Handlebars template file and writes the output to the destination.
* @param srcPath Path to the source .hbs template file.
* @param destPath Path to write the processed file.
* @param context Data to be passed to the Handlebars template.
*/
export async function processTemplate(
srcPath: string,
destPath: string,
context: ProjectConfig,
): Promise<void> {
try {
const templateContent = await fs.readFile(srcPath, "utf-8");
const template = handlebars.compile(templateContent);
const processedContent = template(context);
await fs.ensureDir(path.dirname(destPath));
await fs.writeFile(destPath, processedContent);
} catch (error) {
console.error(`Error processing template ${srcPath}:`, error);
throw new Error(`Failed to process template ${srcPath}`);
}
}
handlebars.registerHelper("or", (a, b) => a || b);
handlebars.registerHelper("eq", (a, b) => a === b);
handlebars.registerHelper(
"includes",
(array, value) => Array.isArray(array) && array.includes(value),
);

View File

@@ -1,8 +0,0 @@
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;

View File

@@ -1,11 +0,0 @@
import { router, publicProcedure } from "../lib/trpc";
import { todoRouter } from "./todo";
export const appRouter = router({
healthCheck: publicProcedure.query(() => {
return "OK";
}),
todo: todoRouter,
});
export type AppRouter = typeof appRouter;

View File

@@ -1,33 +0,0 @@
import type { AppRouter } from "../../../server/src/routers";
import { QueryCache, QueryClient } from "@tanstack/react-query";
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query";
import { toast } from "sonner";
export const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error) => {
toast.error(error.message, {
action: {
label: "retry",
onClick: () => {
queryClient.invalidateQueries();
},
},
});
},
}),
});
export const trpcClient = createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: `${import.meta.env.VITE_SERVER_URL}/trpc`,
}),
],
});
export const trpc = createTRPCOptionsProxy<AppRouter>({
client: trpcClient,
queryClient,
});

View File

@@ -1,30 +0,0 @@
"use client"
import Link from "next/link";
import { ModeToggle } from "./mode-toggle";
export default function Header() {
const links = [
{ to: "/", label: "Home" },
];
return (
<div>
<div className="flex flex-row items-center justify-between px-2 py-1">
<nav className="flex gap-4 text-lg">
{links.map(({ to, label }) => (
<Link
key={to}
href={to}
>
{label}
</Link>
))}
</nav>
<div className="flex items-center gap-2">
<ModeToggle />
</div>
</div>
<hr />
</div>
);
}

View File

@@ -1,33 +0,0 @@
import { QueryCache, QueryClient } from '@tanstack/react-query';
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import { createTRPCOptionsProxy } from '@trpc/tanstack-react-query';
import type { AppRouter } from '../../../server/src/routers';
import { toast } from 'sonner';
export const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error) => {
toast.error(error.message, {
action: {
label: "retry",
onClick: () => {
queryClient.invalidateQueries();
},
},
});
},
}),
});
const trpcClient = createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: `${process.env.NEXT_PUBLIC_SERVER_URL}/trpc`,
}),
],
})
export const trpc = createTRPCOptionsProxy<AppRouter>({
client: trpcClient,
queryClient,
});

View File

@@ -1,31 +0,0 @@
import { NavLink } from "react-router";
import { ModeToggle } from "./mode-toggle";
export default function Header() {
const links = [
{ to: "/", label: "Home" },
];
return (
<div>
<div className="flex flex-row items-center justify-between px-2 py-1">
<nav className="flex gap-4 text-lg">
{links.map(({ to, label }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) => (isActive ? "font-bold" : "")}
end
>
{label}
</NavLink>
))}
</nav>
<div className="flex items-center gap-2">
<ModeToggle />
</div>
</div>
<hr />
</div>
);
}

View File

@@ -1,8 +0,0 @@
import { reactRouter } from "@react-router/dev/vite";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [tailwindcss(), reactRouter(), tsconfigPaths()],
});

View File

@@ -1,31 +0,0 @@
import { Link } from "@tanstack/react-router";
import { ModeToggle } from "./mode-toggle";
export default function Header() {
const links = [
{ to: "/", label: "Home" },
];
return (
<div>
<div className="flex flex-row items-center justify-between px-2 py-1">
<nav className="flex gap-4 text-lg">
{links.map(({ to, label }) => (
<Link
key={to}
to={to}
activeProps={{ className: "font-bold" }}
activeOptions={{ exact: true }}
>
{label}
</Link>
))}
</nav>
<div className="flex items-center gap-2">
<ModeToggle />
</div>
</div>
<hr />
</div>
);
}

View File

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

View File

@@ -1,27 +0,0 @@
import { Link } from "@tanstack/react-router";
export default function Header() {
const links = [
{ to: "/", label: "Home" },
];
return (
<div>
<div className="flex flex-row items-center justify-between px-2 py-1">
<nav className="flex gap-4 text-lg">
{links.map(({ to, label }) => (
<Link
key={to}
to={to}
activeProps={{ className: "font-bold" }}
activeOptions={{ exact: true }}
>
{label}
</Link>
))}
</nav>
</div>
<hr />
</div>
);
}

View File

@@ -1,70 +0,0 @@
import {
QueryCache,
QueryClient,
QueryClientProvider,
} from "@tanstack/react-query";
import { createRouter as createTanstackRouter } from "@tanstack/react-router";
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query";
import { toast } from "sonner";
import type { AppRouter } from "../../server/src/routers";
import Loader from "./components/loader";
import "./index.css";
import { routeTree } from "./routeTree.gen";
import { TRPCProvider } from "./utils/trpc";
export const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error) => {
toast.error(error.message, {
action: {
label: "retry",
onClick: () => {
queryClient.invalidateQueries();
},
},
});
},
}),
defaultOptions: { queries: { staleTime: 60 * 1000 } },
});
const trpcClient = createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: `${import.meta.env.VITE_SERVER_URL}/trpc`,
}),
],
});
const trpc = createTRPCOptionsProxy({
client: trpcClient,
queryClient: queryClient,
});
export const createRouter = () => {
const router = createTanstackRouter({
routeTree,
scrollRestoration: true,
defaultPreloadStaleTime: 0,
context: { trpc, queryClient },
defaultPendingComponent: () => <Loader />,
defaultNotFoundComponent: () => <div>Not Found</div>,
Wrap: ({ children }) => (
<QueryClientProvider client={queryClient}>
<TRPCProvider trpcClient={trpcClient} queryClient={queryClient}>
{children}
</TRPCProvider>
</QueryClientProvider>
),
});
return router;
};
// Register the router instance for type safety
declare module "@tanstack/react-router" {
interface Register {
router: ReturnType<typeof createRouter>;
}
}

View File

@@ -1,5 +0,0 @@
import { createTRPCContext } from "@trpc/tanstack-react-query";
import type { AppRouter } from "../../../server/src/routers";
export const { TRPCProvider, useTRPC, useTRPCClient } =
createTRPCContext<AppRouter>();

View File

@@ -1,18 +0,0 @@
{
"name": "better-t-stack",
"private": true,
"workspaces": ["apps/*"],
"scripts": {
"dev": "turbo dev",
"build": "turbo build",
"check-types": "turbo check-types",
"dev:native": "turbo -F native dev",
"dev:web": "turbo -F web dev",
"dev:server": "turbo -F server dev",
"db:push": "turbo -F server db:push",
"db:studio": "turbo -F server db:studio"
},
"devDependencies": {
"turbo": "^2.4.2"
}
}

View File

@@ -1,34 +0,0 @@
import { z } from "zod";
import { router, publicProcedure } from "../lib/trpc";
import { todo } from "../db/schema/todo";
import { eq } from "drizzle-orm";
import { db } from "../db";
export const todoRouter = router({
getAll: publicProcedure.query(async () => {
return await db.select().from(todo);
}),
create: publicProcedure
.input(z.object({ text: z.string().min(1) }))
.mutation(async ({ input }) => {
return await db.insert(todo).values({
text: input.text,
});
}),
toggle: publicProcedure
.input(z.object({ id: z.number(), completed: z.boolean() }))
.mutation(async ({ input }) => {
return await db
.update(todo)
.set({ completed: input.completed })
.where(eq(todo.id, input.id));
}),
delete: publicProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
return await db.delete(todo).where(eq(todo.id, input.id));
}),
});

View File

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

View File

@@ -1,14 +0,0 @@
import type { CreateExpressContextOptions } from "@trpc/server/adapters/express";
import { fromNodeHeaders } from "better-auth/node";
import { auth } from "./auth";
export async function createContext(opts: CreateExpressContextOptions) {
const session = await auth.api.getSession({
headers: fromNodeHeaders(opts.req.headers),
});
return {
session,
};
}
export type Context = Awaited<ReturnType<typeof createContext>>;

View File

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

View File

@@ -1,14 +0,0 @@
import type { NextRequest } from "next/server";
import { auth } from "./auth";
export async function createContext(req: NextRequest) {
const session = await auth.api.getSession({
headers: req.headers,
});
return {
session,
};
}
export type Context = Awaited<ReturnType<typeof createContext>>;

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,34 +0,0 @@
import "dotenv/config";
import { createExpressMiddleware } from "@trpc/server/adapters/express";
import { toNodeHandler } from "better-auth/node";
import cors from "cors";
import express from "express";
import { auth } from "./lib/auth";
import { createContext } from "./lib/context";
import { appRouter } from "./routers/index";
const app = express();
app.use(
cors({
origin: process.env.CORS_ORIGIN || "",
methods: ["GET", "POST", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization"],
credentials: true,
}),
);
app.all("/api/auth{/*path}", toNodeHandler(auth));
app.use(express.json());
app.use("/trpc", createExpressMiddleware({ router: appRouter, createContext }));
app.get("/", (_req, res) => {
res.status(200).send("OK");
});
app.listen(3000, () => {
console.log("Server is running on port 3000");
});

View File

@@ -1,39 +0,0 @@
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, context) => {
return createContext({ context });
},
}),
);
app.get("/", (c) => {
return c.text("OK");
});

View File

@@ -1,11 +0,0 @@
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import prisma from "../../prisma";
export const auth = betterAuth({
database: prismaAdapter(prisma, {
provider: "mongodb",
}),
trustedOrigins: [process.env.CORS_ORIGIN || ""],
emailAndPassword: { enabled: true },
});

View File

@@ -1,11 +0,0 @@
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import prisma from "../../prisma";
export const auth = betterAuth({
database: prismaAdapter(prisma, {
provider: "mysql",
}),
trustedOrigins: [process.env.CORS_ORIGIN || ""],
emailAndPassword: { enabled: true },
});

View File

@@ -1,11 +0,0 @@
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import prisma from "../../prisma";
export const auth = betterAuth({
database: prismaAdapter(prisma, {
provider: "postgresql",
}),
trustedOrigins: [process.env.CORS_ORIGIN || ""],
emailAndPassword: { enabled: true },
});

View File

@@ -1,11 +0,0 @@
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import prisma from "../../prisma";
export const auth = betterAuth({
database: prismaAdapter(prisma, {
provider: "sqlite",
}),
trustedOrigins: [process.env.CORS_ORIGIN || ""],
emailAndPassword: { enabled: true },
});

View File

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

View File

@@ -1,33 +0,0 @@
"use client"
import Link from "next/link";
import { ModeToggle } from "./mode-toggle";
import UserMenu from "./user-menu";
export default function Header() {
const links = [
{ to: "/", label: "Home" },
{ to: "/dashboard", label: "Dashboard" },
];
return (
<div>
<div className="flex flex-row items-center justify-between px-2 py-1">
<nav className="flex gap-4 text-lg">
{links.map(({ to, label }) => (
<Link
key={to}
href={to}
>
{label}
</Link>
))}
</nav>
<div className="flex items-center gap-2">
<ModeToggle />
<UserMenu />
</div>
</div>
<hr />
</div>
);
}

View File

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

View File

@@ -1,39 +0,0 @@
import { QueryCache, QueryClient } from '@tanstack/react-query';
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import { createTRPCOptionsProxy } from '@trpc/tanstack-react-query';
import type { AppRouter } from '../../../server/src/routers';
import { toast } from 'sonner';
export const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error) => {
toast.error(error.message, {
action: {
label: "retry",
onClick: () => {
queryClient.invalidateQueries();
},
},
});
},
}),
});
const trpcClient = createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: `${process.env.NEXT_PUBLIC_SERVER_URL}/trpc`,
fetch(url, options) {
return fetch(url, {
...options,
credentials: "include",
});
},
}),
],
})
export const trpc = createTRPCOptionsProxy<AppRouter>({
client: trpcClient,
queryClient,
});

View File

@@ -1,34 +0,0 @@
import { NavLink } from "react-router";
import { ModeToggle } from "./mode-toggle";
import UserMenu from "./user-menu";
export default function Header() {
const links = [
{ to: "/", label: "Home" },
{ to: "/dashboard", label: "Dashboard" },
];
return (
<div>
<div className="flex flex-row items-center justify-between px-2 py-1">
<nav className="flex gap-4 text-lg">
{links.map(({ to, label }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) => (isActive ? "font-bold" : "")}
end
>
{label}
</NavLink>
))}
</nav>
<div className="flex items-center gap-2">
<ModeToggle />
<UserMenu />
</div>
</div>
<hr />
</div>
);
}

View File

@@ -1,39 +0,0 @@
import type { AppRouter } from "../../../server/src/routers";
import { QueryCache, QueryClient } from "@tanstack/react-query";
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query";
import { toast } from "sonner";
export const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error) => {
toast.error(error.message, {
action: {
label: "retry",
onClick: () => {
queryClient.invalidateQueries();
},
},
});
},
}),
});
export const trpcClient = createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: `${import.meta.env.VITE_SERVER_URL}/trpc`,
fetch(url, options) {
return fetch(url, {
...options,
credentials: "include",
});
},
}),
],
});
export const trpc = createTRPCOptionsProxy<AppRouter>({
client: trpcClient,
queryClient,
});

View File

@@ -1,34 +0,0 @@
import { Link } from "@tanstack/react-router";
import { ModeToggle } from "./mode-toggle";
import UserMenu from "./user-menu";
export default function Header() {
const links = [
{ to: "/", label: "Home" },
{ to: "/dashboard", label: "Dashboard" },
];
return (
<div>
<div className="flex flex-row items-center justify-between px-2 py-1">
<nav className="flex gap-4 text-lg">
{links.map(({ to, label }) => (
<Link
key={to}
to={to}
activeProps={{ className: "font-bold" }}
activeOptions={{ exact: true }}
>
{label}
</Link>
))}
</nav>
<div className="flex items-center gap-2">
<ModeToggle />
<UserMenu />
</div>
</div>
<hr />
</div>
);
}

View File

@@ -1,39 +0,0 @@
import type { AppRouter } from "../../../server/src/routers";
import { QueryCache, QueryClient } from "@tanstack/react-query";
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query";
import { toast } from "sonner";
export const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error) => {
toast.error(error.message, {
action: {
label: "retry",
onClick: () => {
queryClient.invalidateQueries();
},
},
});
},
}),
});
export const trpcClient = createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: `${import.meta.env.VITE_SERVER_URL}/trpc`,
fetch(url, options) {
return fetch(url, {
...options,
credentials: "include",
});
},
}),
],
});
export const trpc = createTRPCOptionsProxy<AppRouter>({
client: trpcClient,
queryClient,
});

View File

@@ -1,32 +0,0 @@
import { Link } from "@tanstack/react-router";
import UserMenu from "./user-menu";
export default function Header() {
const links = [
{ to: "/", label: "Home" },
{ to: "/dashboard", label: "Dashboard" },
];
return (
<div>
<div className="flex flex-row items-center justify-between px-2 py-1">
<nav className="flex gap-4 text-lg">
{links.map(({ to, label }) => (
<Link
key={to}
to={to}
activeProps={{ className: "font-bold" }}
activeOptions={{ exact: true }}
>
{label}
</Link>
))}
</nav>
<div className="flex items-center gap-2">
<UserMenu />
</div>
</div>
<hr />
</div>
);
}

View File

@@ -1,27 +0,0 @@
import "dotenv/config";
import { Elysia } from "elysia";
import { cors } from "@elysiajs/cors";
import { createContext } from "./lib/context";
import { appRouter } from "./routers/index";
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
const app = new Elysia()
.use(
cors({
origin: process.env.CORS_ORIGIN || "",
methods: ["GET", "POST", "OPTIONS"],
}),
)
.all("/trpc/*", async (context) => {
const res = await fetchRequestHandler({
endpoint: "/trpc",
router: appRouter,
req: context.request,
createContext: () => createContext({ context }),
});
return res;
})
.get("/", () => "OK")
.listen(3000, () => {
console.log(`Server is running on http://localhost:3000`);
});

View File

@@ -1,13 +0,0 @@
import type { Context as ElysiaContext } from "elysia";
export type CreateContextOptions = {
context: ElysiaContext;
};
export async function createContext({ context }: CreateContextOptions) {
return {
session: null,
};
}
export type Context = Awaited<ReturnType<typeof createContext>>;

View File

@@ -1,29 +0,0 @@
import "dotenv/config";
import { createExpressMiddleware } from "@trpc/server/adapters/express";
import cors from "cors";
import express from "express";
import { createContext } from "./lib/context";
import { appRouter } from "./routers/index";
const app = express();
app.use(
cors({
origin: process.env.CORS_ORIGIN || "",
methods: ["GET", "POST", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization"],
credentials: true,
}),
);
app.use(express.json());
app.use("/trpc", createExpressMiddleware({ router: appRouter, createContext }));
app.get("/", (_req, res) => {
res.status(200).send("OK");
});
app.listen(3000, () => {
console.log("Server is running on port 3000");
});

View File

@@ -1,9 +0,0 @@
import type { CreateExpressContextOptions } from "@trpc/server/adapters/express";
export async function createContext(opts: CreateExpressContextOptions) {
return {
session: null,
};
}
export type Context = Awaited<ReturnType<typeof createContext>>;

View File

@@ -1,33 +0,0 @@
import { trpcServer } from "@hono/trpc-server";
import "dotenv/config";
import { Hono } from "hono";
import { cors } from "hono/cors";
import { logger } from "hono/logger";
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"],
}),
);
app.use(
"/trpc/*",
trpcServer({
router: appRouter,
createContext: (_opts, context) => {
return createContext({ context });
},
}),
);
app.get("/", (c) => {
return c.text("OK");
});

View File

@@ -1,13 +0,0 @@
import type { Context as HonoContext } from "hono";
export type CreateContextOptions = {
context: HonoContext;
};
export async function createContext({ context }: CreateContextOptions) {
return {
session: null,
};
}
export type Context = Awaited<ReturnType<typeof createContext>>;

View File

@@ -1,9 +0,0 @@
import type { NextRequest } from "next/server";
export async function createContext(req: NextRequest) {
return {
session: null,
};
}
export type Context = Awaited<ReturnType<typeof createContext>>;

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1,105 @@
{{#if (eq backend 'next')}}
import type { NextRequest } from "next/server";
{{#if auth}}
import { auth } from "./auth";
{{/if}}
export async function createContext(req: NextRequest) {
{{#if auth}}
const session = await auth.api.getSession({
headers: req.headers,
});
return {
session,
};
{{else}}
return {}
{{/if}}
}
{{else if (eq backend 'hono')}}
import type { Context as HonoContext } from "hono";
{{#if auth}}
import { auth } from "./auth";
{{/if}}
export type CreateContextOptions = {
context: HonoContext;
};
export async function createContext({ context }: CreateContextOptions) {
{{#if auth}}
const session = await auth.api.getSession({
headers: context.req.raw.headers,
});
return {
session,
};
{{else}}
// No auth configured
return {
session: null,
};
{{/if}}
}
{{else if (eq backend 'elysia')}}
import type { Context as ElysiaContext } from "elysia";
{{#if auth}}
import { auth } from "./auth";
{{/if}}
export type CreateContextOptions = {
context: ElysiaContext;
};
export async function createContext({ context }: CreateContextOptions) {
{{#if auth}}
const session = await auth.api.getSession({
headers: context.request.headers,
});
return {
session,
};
{{else}}
// No auth configured
return {
session: null,
};
{{/if}}
}
{{else if (eq backend 'express')}}
import type { CreateExpressContextOptions } from "@trpc/server/adapters/express";
{{#if auth}}
import { fromNodeHeaders } from "better-auth/node";
import { auth } from "./auth";
{{/if}}
export async function createContext(opts: CreateExpressContextOptions) {
{{#if auth}}
const session = await auth.api.getSession({
headers: fromNodeHeaders(opts.req.headers),
});
return {
session,
};
{{else}}
// No auth configured
return {
session: null,
};
{{/if}}
}
{{else}}
// Default or fallback context if backend is not recognized or none
// This might need adjustment based on your default behavior
export async function createContext() {
return {
session: null,
};
}
{{/if}}
export type Context = Awaited<ReturnType<typeof createContext>>;

View File

@@ -0,0 +1,17 @@
import { ORPCError, os } from "@orpc/server";
import type { Context } from "./context";
export const o = os.$context<Context>();
export const publicProcedure = o;
{{#if auth}}
const requireAuth = o.middleware(async ({ context, next }) => {
if (!context.session?.user) {
throw new ORPCError("UNAUTHORIZED");
}
return next({ context });
});
export const protectedProcedure = publicProcedure.use(requireAuth);
{{/if}}

View File

@@ -0,0 +1,23 @@
{{#if auth}}
import { createContext } from '@/lib/context'
{{/if}}
import { appRouter } from '@/routers'
import { RPCHandler } from '@orpc/server/fetch'
import { NextRequest } from 'next/server'
const handler = new RPCHandler(appRouter)
async function handleRequest(req: NextRequest) {
const { response } = await handler.handle(req, {
prefix: '/rpc',
context: {{#if auth}}await createContext(req){{else}}{}{{/if}},
})
return response ?? new Response('Not found', { status: 404 })
}
export const GET = handleRequest
export const POST = handleRequest
export const PUT = handleRequest
export const PATCH = handleRequest
export const DELETE = handleRequest

View File

@@ -0,0 +1,57 @@
import { createORPCClient } from "@orpc/client";
import { RPCLink } from "@orpc/client/fetch";
import { createORPCReactQueryUtils } from "@orpc/react-query";
import { QueryCache, QueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import type { appRouter } from "../../../server/src/routers/index";
import type { RouterClient } from "@orpc/server";
import { createContext, use } from 'react'
import type { RouterUtils } from '@orpc/react-query'
type ORPCReactUtils = RouterUtils<RouterClient<typeof appRouter>>
export const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error) => {
toast.error(`Error: ${error.message}`, {
action: {
label: "retry",
onClick: () => {
queryClient.invalidateQueries();
},
},
});
},
}),
});
export const link = new RPCLink({
{{#if (includes frontend "next")}}
url: `${process.env.NEXT_PUBLIC_SERVER_URL}/rpc`,
{{else}}
url: `${import.meta.env.VITE_SERVER_URL}/rpc`,
{{/if}}
{{#if auth}}
fetch(url, options) {
return fetch(url, {
...options,
credentials: "include",
});
},
{{/if}}
});
export const client: RouterClient<typeof appRouter> = createORPCClient(link)
export const orpc = createORPCReactQueryUtils(client)
export const ORPCContext = createContext<ORPCReactUtils | undefined>(undefined)
export function useORPC(): ORPCReactUtils {
const orpc = use(ORPCContext)
if (!orpc) {
throw new Error('ORPCContext is not set up properly')
}
return orpc
}

View File

@@ -0,0 +1,108 @@
{{#if (eq backend 'next')}}
import type { NextRequest } from "next/server";
{{#if auth}}
import { auth } from "./auth";
{{/if}}
export async function createContext(req: NextRequest) {
{{#if auth}}
const session = await auth.api.getSession({
headers: req.headers,
});
return {
session,
};
{{else}}
// No auth configured
return {
session: null,
};
{{/if}}
}
{{else if (eq backend 'hono')}}
import type { Context as HonoContext } from "hono";
{{#if auth}}
import { auth } from "./auth";
{{/if}}
export type CreateContextOptions = {
context: HonoContext;
};
export async function createContext({ context }: CreateContextOptions) {
{{#if auth}}
const session = await auth.api.getSession({
headers: context.req.raw.headers,
});
return {
session,
};
{{else}}
// No auth configured
return {
session: null,
};
{{/if}}
}
{{else if (eq backend 'elysia')}}
import type { Context as ElysiaContext } from "elysia";
{{#if auth}}
import { auth } from "./auth";
{{/if}}
export type CreateContextOptions = {
context: ElysiaContext;
};
export async function createContext({ context }: CreateContextOptions) {
{{#if auth}}
const session = await auth.api.getSession({
headers: context.request.headers,
});
return {
session,
};
{{else}}
// No auth configured
return {
session: null,
};
{{/if}}
}
{{else if (eq backend 'express')}}
import type { CreateExpressContextOptions } from "@trpc/server/adapters/express";
{{#if auth}}
import { fromNodeHeaders } from "better-auth/node";
import { auth } from "./auth";
{{/if}}
export async function createContext(opts: CreateExpressContextOptions) {
{{#if auth}}
const session = await auth.api.getSession({
headers: fromNodeHeaders(opts.req.headers),
});
return {
session,
};
{{else}}
// No auth configured
return {
session: null,
};
{{/if}}
}
{{else}}
// Default or fallback context if backend is not recognized or none
// This might need adjustment based on your default behavior
export async function createContext() {
return {
session: null,
};
}
{{/if}}
export type Context = Awaited<ReturnType<typeof createContext>>;

View File

@@ -7,6 +7,7 @@ export const router = t.router;
export const publicProcedure = t.procedure; export const publicProcedure = t.procedure;
{{#if auth}}
export const protectedProcedure = t.procedure.use(({ ctx, next }) => { export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.session) { if (!ctx.session) {
throw new TRPCError({ throw new TRPCError({
@@ -22,3 +23,4 @@ export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
}, },
}); });
}); });
{{/if}}

View File

@@ -0,0 +1,100 @@
{{#if (includes frontend 'next')}}
{{!-- Next.js tRPC Client Setup --}}
import { QueryCache, QueryClient } from '@tanstack/react-query';
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import { createTRPCOptionsProxy } from '@trpc/tanstack-react-query';
import type { AppRouter } from '../../../server/src/routers'; {{! Adjust path if necessary }}
import { toast } from 'sonner';
export const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error) => {
toast.error(error.message, {
action: {
label: "retry",
onClick: () => {
queryClient.invalidateQueries();
},
},
});
},
}),
});
const trpcClient = createTRPCClient<AppRouter>({
links: [
httpBatchLink({
{{#if (includes frontend 'next')}}
url: `${process.env.NEXT_PUBLIC_SERVER_URL}/trpc`,
{{else}}
url: `${import.meta.env.VITE_SERVER_URL}/trpc`,
{{/if}}
{{#if auth}}
fetch(url, options) {
return fetch(url, {
...options,
credentials: "include",
});
},
{{/if}}
}),
],
})
export const trpc = createTRPCOptionsProxy<AppRouter>({
client: trpcClient,
queryClient,
});
{{else if (includes frontend 'tanstack-start')}}
{{!-- TanStack Start tRPC Client Setup --}}
import { createTRPCContext } from "@trpc/tanstack-react-query";
import type { AppRouter } from "../../../server/src/routers"; {{! Adjust path if necessary }}
export const { TRPCProvider, useTRPC, useTRPCClient } =
createTRPCContext<AppRouter>();
{{else}}
{{!-- Default Web tRPC Client Setup (TanStack Router, React Router, etc.) --}}
import type { AppRouter } from "../../../server/src/routers"; {{! Adjust path if necessary }}
import { QueryCache, QueryClient } from "@tanstack/react-query";
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query";
import { toast } from "sonner";
export const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error) => {
toast.error(error.message, {
action: {
label: "retry",
onClick: () => {
queryClient.invalidateQueries();
},
},
});
},
}),
});
export const trpcClient = createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: `${import.meta.env.VITE_SERVER_URL}/trpc`,
{{#if auth}}
fetch(url, options) {
return fetch(url, {
...options,
credentials: "include",
});
},
{{/if}}
}),
],
});
export const trpc = createTRPCOptionsProxy<AppRouter>({
client: trpcClient,
queryClient,
});
{{/if}}

View File

@@ -0,0 +1,30 @@
import { betterAuth } from "better-auth";
{{#if (eq orm "prisma")}}
import { prismaAdapter } from "better-auth/adapters/prisma";
import prisma from "../../prisma";
export const auth = betterAuth({
database: prismaAdapter(prisma, {
provider: "{{database}}"
}),
trustedOrigins: [process.env.CORS_ORIGIN || ""],
emailAndPassword: { enabled: true }
});
{{else if (eq orm "drizzle")}}
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "../db";
import * as schema from "../db/schema/auth";
export const auth = betterAuth({
database: drizzleAdapter(db, {
{{#if (eq database "postgresql")}}provider: "pg",{{/if}}
{{#if (eq database "sqlite")}}provider: "sqlite",{{/if}}
{{#if (eq database "mysql")}}provider: "mysql",{{/if}}
schema: schema
}),
trustedOrigins: [process.env.CORS_ORIGIN || ""],
emailAndPassword: { enabled: true }
});
{{/if}}

Some files were not shown because too many files have changed in this diff Show More