diff --git a/.changeset/sad-candies-like.md b/.changeset/sad-candies-like.md new file mode 100644 index 0000000..130b186 --- /dev/null +++ b/.changeset/sad-candies-like.md @@ -0,0 +1,5 @@ +--- +"create-better-t-stack": minor +--- + +Add AI chat example and update flags structures diff --git a/apps/cli/src/constants.ts b/apps/cli/src/constants.ts index 6fd897e..45ed5a5 100644 --- a/apps/cli/src/constants.ts +++ b/apps/cli/src/constants.ts @@ -19,7 +19,7 @@ export const DEFAULT_CONFIG: ProjectConfig = { packageManager: getUserPkgManager(), noInstall: false, turso: false, - backendFramework: "hono", + backend: "hono", runtime: "bun", }; @@ -59,6 +59,9 @@ export const dependencyVersionMap = { "@hono/trpc-server": "^0.3.4", hono: "^4.7.5", + + ai: "^4.2.8", + "@ai-sdk/google": "^1.2.3", } as const; export type AvailableDependencies = keyof typeof dependencyVersionMap; diff --git a/apps/cli/src/helpers/addons-setup.ts b/apps/cli/src/helpers/addons-setup.ts index 97d219d..1989804 100644 --- a/apps/cli/src/helpers/addons-setup.ts +++ b/apps/cli/src/helpers/addons-setup.ts @@ -1,14 +1,18 @@ import path from "node:path"; import fs from "fs-extra"; import { PKG_ROOT } from "../constants"; -import type { PackageManager, ProjectAddons, ProjectFrontend } from "../types"; +import type { + ProjectAddons, + ProjectFrontend, + ProjectPackageManager, +} from "../types"; import { addPackageDependency } from "../utils/add-package-deps"; import { setupTauri } from "./tauri-setup"; export async function setupAddons( projectDir: string, addons: ProjectAddons[], - packageManager: PackageManager, + packageManager: ProjectPackageManager, frontends: ProjectFrontend[], ) { const hasWebFrontend = frontends.includes("web"); diff --git a/apps/cli/src/helpers/backend-framework-setup.ts b/apps/cli/src/helpers/backend-framework-setup.ts index f76c369..fa611f6 100644 --- a/apps/cli/src/helpers/backend-framework-setup.ts +++ b/apps/cli/src/helpers/backend-framework-setup.ts @@ -1,12 +1,12 @@ import path from "node:path"; import type { AvailableDependencies } from "../constants"; -import type { BackendFramework, Runtime } from "../types"; +import type { ProjectBackend, ProjectRuntime } from "../types"; import { addPackageDependency } from "../utils/add-package-deps"; export async function setupBackendDependencies( projectDir: string, - framework: BackendFramework, - runtime: Runtime, + framework: ProjectBackend, + runtime: ProjectRuntime, ): Promise { const serverDir = path.join(projectDir, "apps/server"); diff --git a/apps/cli/src/helpers/create-project.ts b/apps/cli/src/helpers/create-project.ts index 98d6358..7abdb70 100644 --- a/apps/cli/src/helpers/create-project.ts +++ b/apps/cli/src/helpers/create-project.ts @@ -34,10 +34,10 @@ export async function createProject(options: ProjectConfig): Promise { await fixGitignoreFiles(projectDir); - await setupBackendFramework(projectDir, options.backendFramework); + await setupBackendFramework(projectDir, options.backend); await setupBackendDependencies( projectDir, - options.backendFramework, + options.backend, options.runtime, ); @@ -58,19 +58,20 @@ export async function createProject(options: ProjectConfig): Promise { await setupAuthTemplate( projectDir, options.auth, - options.backendFramework, + options.backend, options.orm, options.database, ); await setupAuth(projectDir, options.auth); - await setupRuntime(projectDir, options.runtime, options.backendFramework); + await setupRuntime(projectDir, options.runtime, options.backend); await setupExamples( projectDir, options.examples, options.orm, options.auth, + options.backend, options.frontend, ); diff --git a/apps/cli/src/helpers/create-readme.ts b/apps/cli/src/helpers/create-readme.ts index c1ec46c..20cfb2a 100644 --- a/apps/cli/src/helpers/create-readme.ts +++ b/apps/cli/src/helpers/create-readme.ts @@ -5,7 +5,7 @@ import type { ProjectConfig, ProjectDatabase, ProjectOrm, - Runtime, + ProjectRuntime, } from "../types"; export async function createReadme(projectDir: string, options: ProjectConfig) { @@ -80,7 +80,7 @@ function generateFeaturesList( auth: boolean, addons: ProjectAddons[], orm: ProjectOrm, - runtime: Runtime, + runtime: ProjectRuntime, ): string { const addonsList = [ "- **TypeScript** - For type safety and improved developer experience", diff --git a/apps/cli/src/helpers/env-setup.ts b/apps/cli/src/helpers/env-setup.ts index 25e09d5..13b358a 100644 --- a/apps/cli/src/helpers/env-setup.ts +++ b/apps/cli/src/helpers/env-setup.ts @@ -46,6 +46,13 @@ export async function setupEnvironmentVariables( } } + if ( + options.examples?.includes("ai") && + !envContent.includes("GOOGLE_GENERATIVE_AI_API_KEY") + ) { + envContent += "\nGOOGLE_GENERATIVE_AI_API_KEY="; + } + await fs.writeFile(envPath, envContent.trim()); if (options.frontend.includes("web")) { diff --git a/apps/cli/src/helpers/examples-setup.ts b/apps/cli/src/helpers/examples-setup.ts index 15783eb..041cec7 100644 --- a/apps/cli/src/helpers/examples-setup.ts +++ b/apps/cli/src/helpers/examples-setup.ts @@ -1,17 +1,19 @@ import path from "node:path"; import fs from "fs-extra"; import { PKG_ROOT } from "../constants"; -import type { ProjectFrontend, ProjectOrm } from "../types"; +import type { ProjectBackend, ProjectFrontend, ProjectOrm } from "../types"; +import { addPackageDependency } from "../utils/add-package-deps"; export async function setupExamples( projectDir: string, examples: string[], orm: ProjectOrm, auth: boolean, + backend: ProjectBackend, frontend: ProjectFrontend[] = ["web"], ): Promise { + console.log("EXAMPLEs:", examples); const hasWebFrontend = frontend.includes("web"); - const webAppExists = await fs.pathExists(path.join(projectDir, "apps/web")); if (examples.includes("todo") && hasWebFrontend && webAppExists) { @@ -19,6 +21,135 @@ export async function setupExamples( } else { await cleanupTodoFiles(projectDir, orm); } + + if ( + examples.includes("ai") && + backend === "hono" && + hasWebFrontend && + webAppExists + ) { + await setupAIExample(projectDir); + } +} + +async function setupAIExample(projectDir: string): Promise { + const aiExampleDir = path.join(PKG_ROOT, "template/examples/ai"); + + if (await fs.pathExists(aiExampleDir)) { + await fs.copy(aiExampleDir, projectDir); + + await updateHeaderWithAILink(projectDir); + + const clientDir = path.join(projectDir, "apps/web"); + addPackageDependency({ + dependencies: ["ai"], + projectDir: clientDir, + }); + + const serverDir = path.join(projectDir, "apps/server"); + addPackageDependency({ + dependencies: ["ai", "@ai-sdk/google"], + projectDir: serverDir, + }); + + await updateServerIndexWithAIRoute(projectDir); + } +} + +async function updateServerIndexWithAIRoute(projectDir: string): Promise { + 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"); + + if (isHono) { + const importSection = `import { streamText } from "ai";\nimport { google } from "@ai-sdk/google";\nimport { stream } from "hono/streaming";`; + + const aiRouteHandler = ` +app.post("/ai", async (c) => { + const body = await c.req.json(); + const messages = body.messages || []; + + const result = streamText({ + model: google("gemini-2.0-flash-exp"), + 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())); +});`; + + // Add the import section + 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}`; + } + + // Add the route handler + 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 { + // Add it near the end before export + const exportIndex = indexContent.indexOf("export default"); + if (exportIndex !== -1) { + indexContent = `${indexContent.substring(0, exportIndex)}${aiRouteHandler} + +${indexContent.substring(exportIndex)}`; + } else { + indexContent = `${indexContent} + +${aiRouteHandler}`; + } + } + + await fs.writeFile(serverIndexPath, indexContent); + } + } +} + +async function updateHeaderWithAILink(projectDir: string): Promise { + const headerPath = path.join( + projectDir, + "apps/web/src/components/header.tsx", + ); + + if (await fs.pathExists(headerPath)) { + let headerContent = await fs.readFile(headerPath, "utf8"); + + if (headerContent.includes('{ to: "/todos"')) { + headerContent = headerContent.replace( + /{ to: "\/todos", label: "Todos" },/, + `{ to: "/todos", label: "Todos" },\n { to: "/ai", label: "AI Chat" },`, + ); + } else if (headerContent.includes('{ to: "/dashboard"')) { + headerContent = headerContent.replace( + /{ to: "\/dashboard", label: "Dashboard" },/, + `{ to: "/dashboard", label: "Dashboard" },\n { to: "/ai", label: "AI Chat" },`, + ); + } else { + headerContent = headerContent.replace( + /const links = \[\s*{ to: "\/", label: "Home" },/, + `const links = [\n { to: "/", label: "Home" },\n { to: "/ai", label: "AI Chat" },`, + ); + } + + await fs.writeFile(headerPath, headerContent); + } } async function setupTodoExample( diff --git a/apps/cli/src/helpers/install-dependencies.ts b/apps/cli/src/helpers/install-dependencies.ts index ba07516..361939e 100644 --- a/apps/cli/src/helpers/install-dependencies.ts +++ b/apps/cli/src/helpers/install-dependencies.ts @@ -1,7 +1,7 @@ import { log, spinner } from "@clack/prompts"; import { $ } from "execa"; import pc from "picocolors"; -import type { PackageManager, ProjectAddons } from "../types"; +import type { ProjectAddons, ProjectPackageManager } from "../types"; export async function installDependencies({ projectDir, @@ -9,7 +9,7 @@ export async function installDependencies({ addons = [], }: { projectDir: string; - packageManager: PackageManager; + packageManager: ProjectPackageManager; addons?: ProjectAddons[]; }) { const s = spinner(); @@ -17,20 +17,10 @@ export async function installDependencies({ try { s.start(`Running ${packageManager} install...`); - switch (packageManager) { - case "npm": - await $({ - cwd: projectDir, - stderr: "inherit", - })`${packageManager} install`; - break; - case "pnpm": - case "bun": - await $({ - cwd: projectDir, - })`${packageManager} install`; - break; - } + await $({ + cwd: projectDir, + stderr: "inherit", + })`${packageManager} install`; s.stop("Dependencies installed successfully"); @@ -48,14 +38,17 @@ export async function installDependencies({ async function runBiomeCheck( projectDir: string, - packageManager: PackageManager, + packageManager: ProjectPackageManager, ) { const s = spinner(); try { s.start("Running Biome format check..."); - await $({ cwd: projectDir })`${packageManager} biome check --write .`; + await $({ + cwd: projectDir, + stderr: "inherit", + })`${packageManager} biome check --write .`; s.stop("Biome check completed successfully"); } catch (error) { diff --git a/apps/cli/src/helpers/post-installation.ts b/apps/cli/src/helpers/post-installation.ts index 5453a5c..7fda3a9 100644 --- a/apps/cli/src/helpers/post-installation.ts +++ b/apps/cli/src/helpers/post-installation.ts @@ -1,22 +1,22 @@ import { log } from "@clack/prompts"; import pc from "picocolors"; import type { - PackageManager, ProjectAddons, ProjectDatabase, ProjectFrontend, ProjectOrm, - Runtime, + ProjectPackageManager, + ProjectRuntime, } from "../types"; export function displayPostInstallInstructions( database: ProjectDatabase, projectName: string, - packageManager: PackageManager, + packageManager: ProjectPackageManager, depsInstalled: boolean, orm: ProjectOrm, addons: ProjectAddons[], - runtime: Runtime, + runtime: ProjectRuntime, frontends: ProjectFrontend[], ) { const runCmd = packageManager === "npm" ? "npm run" : packageManager; @@ -67,7 +67,7 @@ function getDatabaseInstructions( database: ProjectDatabase, orm?: ProjectOrm, runCmd?: string, - runtime?: Runtime, + runtime?: ProjectRuntime, ): string { const instructions = []; diff --git a/apps/cli/src/helpers/runtime-setup.ts b/apps/cli/src/helpers/runtime-setup.ts index bebf715..9f5986f 100644 --- a/apps/cli/src/helpers/runtime-setup.ts +++ b/apps/cli/src/helpers/runtime-setup.ts @@ -1,12 +1,12 @@ import path from "node:path"; import fs from "fs-extra"; -import type { BackendFramework, Runtime } from "../types"; +import type { ProjectBackend, ProjectRuntime } from "../types"; import { addPackageDependency } from "../utils/add-package-deps"; export async function setupRuntime( projectDir: string, - runtime: Runtime, - backendFramework: BackendFramework, + runtime: ProjectRuntime, + backendFramework: ProjectBackend, ): Promise { const serverDir = path.join(projectDir, "apps/server"); const serverIndexPath = path.join(serverDir, "src/index.ts"); @@ -34,7 +34,7 @@ async function setupBunRuntime( serverDir: string, serverIndexPath: string, indexContent: string, - backendFramework: BackendFramework, + backendFramework: ProjectBackend, ): Promise { const packageJsonPath = path.join(serverDir, "package.json"); const packageJson = await fs.readJson(packageJsonPath); @@ -62,7 +62,7 @@ async function setupNodeRuntime( serverDir: string, serverIndexPath: string, indexContent: string, - backendFramework: BackendFramework, + backendFramework: ProjectBackend, ): Promise { const packageJsonPath = path.join(serverDir, "package.json"); const packageJson = await fs.readJson(packageJsonPath); diff --git a/apps/cli/src/helpers/tauri-setup.ts b/apps/cli/src/helpers/tauri-setup.ts index a28b533..dc595d6 100644 --- a/apps/cli/src/helpers/tauri-setup.ts +++ b/apps/cli/src/helpers/tauri-setup.ts @@ -3,12 +3,12 @@ import { log, spinner } from "@clack/prompts"; import { $, execa } from "execa"; import fs from "fs-extra"; import pc from "picocolors"; -import type { PackageManager } from "../types"; +import type { ProjectPackageManager } from "../types"; import { addPackageDependency } from "../utils/add-package-deps"; export async function setupTauri( projectDir: string, - packageManager: PackageManager, + packageManager: ProjectPackageManager, ): Promise { const s = spinner(); const clientPackageDir = path.join(projectDir, "apps/web"); diff --git a/apps/cli/src/helpers/template-manager.ts b/apps/cli/src/helpers/template-manager.ts index 44c1783..b31a230 100644 --- a/apps/cli/src/helpers/template-manager.ts +++ b/apps/cli/src/helpers/template-manager.ts @@ -2,7 +2,7 @@ import path from "node:path"; import fs from "fs-extra"; import { PKG_ROOT } from "../constants"; import type { - BackendFramework, + ProjectBackend, ProjectDatabase, ProjectFrontend, ProjectOrm, @@ -42,7 +42,7 @@ export async function setupFrontendTemplates( export async function setupBackendFramework( projectDir: string, - framework: BackendFramework, + framework: ProjectBackend, ): Promise { const frameworkDir = path.join(PKG_ROOT, `template/with-${framework}`); if (await fs.pathExists(frameworkDir)) { @@ -88,7 +88,7 @@ export async function setupOrmTemplate( export async function setupAuthTemplate( projectDir: string, auth: boolean, - framework: BackendFramework, + framework: ProjectBackend, orm: ProjectOrm, database: ProjectDatabase, ): Promise { diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 220cdcc..d87e02d 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -6,12 +6,10 @@ import { createProject } from "./helpers/create-project"; import { installDependencies } from "./helpers/install-dependencies"; import { gatherConfig } from "./prompts/config-prompts"; import type { - BackendFramework, ProjectAddons, ProjectConfig, ProjectExamples, ProjectFrontend, - Runtime, } from "./types"; import { displayConfig } from "./utils/display-config"; import { generateReproducibleCommand } from "./utils/generate-reproducible-command"; @@ -34,36 +32,36 @@ async function main() { .version(getLatestCLIVersion()) .argument("[project-directory]", "Project name/directory") .option("-y, --yes", "Use default configuration") - .option("--no-database", "Skip database setup") - .option("--sqlite", "Use SQLite database") - .option("--postgres", "Use PostgreSQL database") + .option("--database ", "Database type (none, sqlite, postgres)") + .option("--orm ", "ORM type (none, drizzle, prisma)") .option("--auth", "Include authentication") .option("--no-auth", "Exclude authentication") - .option("--pwa", "Include Progressive Web App support") - .option("--tauri", "Include Tauri desktop app support") - .option("--biome", "Include Biome for linting and formatting") - .option("--husky", "Include Husky, lint-staged for Git hooks") + .option( + "--frontend ", + "Frontend types (web,native or both)", + (val) => val.split(",") as ProjectFrontend[], + ) + .option( + "--addons ", + "Additional addons (pwa,tauri,biome,husky)", + (val) => val.split(",") as ProjectAddons[], + ) .option("--no-addons", "Skip all additional addons") - .option("--examples ", "Include specified examples") + .option( + "--examples ", + "Examples to include (todo,ai)", + (val) => val.split(",") as ProjectExamples[], + ) .option("--no-examples", "Skip all examples") - .option("--git", "Include git setup") + .option("--git", "Initialize git repository") .option("--no-git", "Skip git initialization") - .option("--npm", "Use npm package manager") - .option("--pnpm", "Use pnpm package manager") - .option("--bun", "Use bun package manager") - .option("--drizzle", "Use Drizzle ORM") - .option("--prisma", "Use Prisma ORM (coming soon)") + .option("--package-manager ", "Package manager (npm, pnpm, bun)") .option("--install", "Install dependencies") .option("--no-install", "Skip installing dependencies") .option("--turso", "Set up Turso for SQLite database") - .option("--no-turso", "Skip Turso setup for SQLite database") - .option("--hono", "Use Hono backend framework") - .option("--elysia", "Use Elysia backend framework") - .option("--runtime ", "Specify runtime (bun or node)") - .option("--web", "Include web frontend") - .option("--native", "Include Expo frontend") - .option("--no-web", "Exclude web frontend") - .option("--no-native", "Exclude Expo frontend") + .option("--no-turso", "Skip Turso setup") + .option("--backend ", "Backend framework (hono, elysia)") + .option("--runtime ", "Runtime (bun, node)") .parse(); const s = spinner(); @@ -75,60 +73,88 @@ async function main() { const options = program.opts(); const projectDirectory = program.args[0]; - let backendFramework: BackendFramework | undefined; - if (options.hono) backendFramework = "hono"; - if (options.elysia) backendFramework = "elysia"; + if ( + options.database && + !["none", "sqlite", "postgres"].includes(options.database) + ) { + cancel( + pc.red( + `Invalid database type: ${options.database}. Must be none, sqlite, or postgres.`, + ), + ); + process.exit(1); + } + + if (options.orm && !["none", "drizzle", "prisma"].includes(options.orm)) { + cancel( + pc.red( + `Invalid ORM type: ${options.orm}. Must be none, drizzle, or prisma.`, + ), + ); + process.exit(1); + } + + if ( + options.packageManager && + !["npm", "pnpm", "bun"].includes(options.packageManager) + ) { + cancel( + pc.red( + `Invalid package manager: ${options.packageManager}. Must be npm, pnpm, or bun.`, + ), + ); + process.exit(1); + } + + if (options.backend && !["hono", "elysia"].includes(options.backend)) { + cancel( + pc.red( + `Invalid backend framework: ${options.backend}. Must be hono or elysia.`, + ), + ); + process.exit(1); + } + + if (options.runtime && !["bun", "node"].includes(options.runtime)) { + cancel( + pc.red(`Invalid runtime: ${options.runtime}. Must be bun or node.`), + ); + process.exit(1); + } + + if (options.examples && options.examples.length > 0) { + const validExamples = ["todo", "ai"]; + const invalidExamples = options.examples.filter( + (example: ProjectExamples) => !validExamples.includes(example), + ); + + if (invalidExamples.length > 0) { + cancel( + pc.red( + `Invalid example(s): ${invalidExamples.join(", ")}. Valid options are: ${validExamples.join(", ")}.`, + ), + ); + process.exit(1); + } + } const flagConfig: Partial = { ...(projectDirectory && { projectName: projectDirectory }), - ...(options.database === false && { database: "none" }), - ...(options.sqlite && { database: "sqlite" }), - ...(options.postgres && { database: "postgres" }), - ...(options.drizzle && { orm: "drizzle" }), - ...(options.prisma && { orm: "prisma" }), + ...(options.database && { database: options.database }), + ...(options.orm && { orm: options.orm }), ...("auth" in options && { auth: options.auth }), - ...(options.npm && { packageManager: "npm" }), - ...(options.pnpm && { packageManager: "pnpm" }), - ...(options.bun && { packageManager: "bun" }), + ...(options.packageManager && { packageManager: options.packageManager }), ...("git" in options && { git: options.git }), ...("install" in options && { noInstall: !options.install }), ...("turso" in options && { turso: options.turso }), - ...(backendFramework && { backendFramework }), - ...(options.runtime && { runtime: options.runtime as Runtime }), - ...((options.pwa || - options.tauri || - options.biome || - options.husky || - options.addons === false) && { - addons: - options.addons === false - ? [] - : ([ - ...(options.pwa ? ["pwa"] : []), - ...(options.tauri ? ["tauri"] : []), - ...(options.biome ? ["biome"] : []), - ...(options.husky ? ["husky"] : []), - ] as ProjectAddons[]), + ...(options.backend && { backend: options.backend }), + ...(options.runtime && { runtime: options.runtime }), + ...(options.frontend && { frontend: options.frontend }), + ...((options.addons || options.addons === false) && { + addons: options.addons === false ? [] : options.addons, }), ...((options.examples || options.examples === false) && { - examples: - options.examples === false - ? [] - : typeof options.examples === "string" - ? (options.examples - .split(",") - .filter((e) => e === "todo") as ProjectExamples[]) - : [], - }), - ...((options.web !== undefined || options.native !== undefined) && { - frontend: [ - ...(options.web === false ? [] : ["web"]), - ...(options.native === false - ? [] - : options.native === true - ? ["native"] - : []), - ].filter(Boolean) as ProjectFrontend[], + examples: options.examples === false ? [] : options.examples, }), }; @@ -137,55 +163,12 @@ async function main() { log.message(displayConfig(flagConfig)); log.message(""); } + const config = options.yes ? { ...DEFAULT_CONFIG, projectName: projectDirectory ?? DEFAULT_CONFIG.projectName, - database: - options.database === false - ? "none" - : options.sqlite - ? "sqlite" - : options.postgres - ? "postgres" - : DEFAULT_CONFIG.database, - orm: - options.database === false - ? "none" - : options.drizzle - ? "drizzle" - : options.prisma - ? "prisma" - : DEFAULT_CONFIG.orm, - auth: "auth" in options ? options.auth : DEFAULT_CONFIG.auth, - git: "git" in options ? options.git : DEFAULT_CONFIG.git, - noInstall: - "install" in options ? !options.install : DEFAULT_CONFIG.noInstall, - packageManager: - flagConfig.packageManager ?? DEFAULT_CONFIG.packageManager, - addons: flagConfig.addons?.length - ? flagConfig.addons - : DEFAULT_CONFIG.addons, - examples: flagConfig.examples?.length - ? flagConfig.examples - : DEFAULT_CONFIG.examples, - turso: - "turso" in options - ? options.turso - : flagConfig.database === "sqlite" - ? DEFAULT_CONFIG.turso - : false, - backendFramework: backendFramework ?? DEFAULT_CONFIG.backendFramework, - runtime: options.runtime - ? (options.runtime as Runtime) - : DEFAULT_CONFIG.runtime, - frontend: - options.web === false || options.native === true - ? ([ - ...(options.web === false ? [] : ["web"]), - ...(options.native ? ["native"] : []), - ] as ProjectFrontend[]) - : DEFAULT_CONFIG.frontend, + ...flagConfig, } : await gatherConfig(flagConfig); diff --git a/apps/cli/src/prompts/backend-framework.ts b/apps/cli/src/prompts/backend-framework.ts index acd1884..9bd673b 100644 --- a/apps/cli/src/prompts/backend-framework.ts +++ b/apps/cli/src/prompts/backend-framework.ts @@ -1,14 +1,14 @@ import { cancel, isCancel, select } from "@clack/prompts"; import pc from "picocolors"; import { DEFAULT_CONFIG } from "../constants"; -import type { BackendFramework } from "../types"; +import type { ProjectBackend } from "../types"; export async function getBackendFrameworkChoice( - backendFramework?: BackendFramework, -): Promise { + backendFramework?: ProjectBackend, +): Promise { if (backendFramework !== undefined) return backendFramework; - const response = await select({ + const response = await select({ message: "Which backend framework would you like to use?", options: [ { @@ -22,7 +22,7 @@ export async function getBackendFrameworkChoice( hint: "TypeScript framework with end-to-end type safety)", }, ], - initialValue: DEFAULT_CONFIG.backendFramework, + initialValue: DEFAULT_CONFIG.backend, }); if (isCancel(response)) { diff --git a/apps/cli/src/prompts/config-prompts.ts b/apps/cli/src/prompts/config-prompts.ts index 27113cc..61b33ca 100644 --- a/apps/cli/src/prompts/config-prompts.ts +++ b/apps/cli/src/prompts/config-prompts.ts @@ -1,15 +1,15 @@ import { cancel, group } from "@clack/prompts"; import pc from "picocolors"; import type { - BackendFramework, - PackageManager, ProjectAddons, + ProjectBackend, ProjectConfig, ProjectDatabase, ProjectExamples, ProjectFrontend, ProjectOrm, - Runtime, + ProjectPackageManager, + ProjectRuntime, } from "../types"; import { getAddonsChoice } from "./addons"; import { getAuthChoice } from "./auth"; @@ -33,11 +33,11 @@ type PromptGroupResults = { addons: ProjectAddons[]; examples: ProjectExamples[]; git: boolean; - packageManager: PackageManager; + packageManager: ProjectPackageManager; noInstall: boolean; turso: boolean; - backendFramework: BackendFramework; - runtime: Runtime; + backend: ProjectBackend; + runtime: ProjectRuntime; frontend: ProjectFrontend[]; }; @@ -50,7 +50,7 @@ export async function gatherConfig( return getProjectName(flags.projectName); }, frontend: () => getFrontendChoice(flags.frontend), - backendFramework: () => getBackendFrameworkChoice(flags.backendFramework), + backend: () => getBackendFrameworkChoice(flags.backend), runtime: () => getRuntimeChoice(flags.runtime), database: () => getDatabaseChoice(flags.database), orm: ({ results }) => @@ -67,7 +67,12 @@ export async function gatherConfig( : Promise.resolve(false), addons: ({ results }) => getAddonsChoice(flags.addons, results.frontend), examples: ({ results }) => - getExamplesChoice(flags.examples, results.database, results.frontend), + getExamplesChoice( + flags.examples, + results.database, + results.frontend, + results.backend, + ), git: () => getGitChoice(flags.git), packageManager: () => getPackageManagerChoice(flags.packageManager), noInstall: () => getNoInstallChoice(flags.noInstall), @@ -92,7 +97,7 @@ export async function gatherConfig( packageManager: result.packageManager, noInstall: result.noInstall, turso: result.turso, - backendFramework: result.backendFramework, + backend: result.backend, runtime: result.runtime, }; } diff --git a/apps/cli/src/prompts/examples.ts b/apps/cli/src/prompts/examples.ts index 7fd0b4e..490cb3f 100644 --- a/apps/cli/src/prompts/examples.ts +++ b/apps/cli/src/prompts/examples.ts @@ -2,6 +2,7 @@ import { cancel, isCancel, multiselect } from "@clack/prompts"; import pc from "picocolors"; import { DEFAULT_CONFIG } from "../constants"; import type { + ProjectBackend, ProjectDatabase, ProjectExamples, ProjectFrontend, @@ -11,6 +12,7 @@ export async function getExamplesChoice( examples?: ProjectExamples[], database?: ProjectDatabase, frontends?: ProjectFrontend[], + backend?: ProjectBackend, ): Promise { if (examples !== undefined) return examples; @@ -19,18 +21,42 @@ export async function getExamplesChoice( const hasWebFrontend = frontends?.includes("web"); if (!hasWebFrontend) return []; - const response = await multiselect({ - message: "Which examples would you like to include?", - options: [ - { - value: "todo", - label: "Todo App", - hint: "A simple CRUD example app", - }, - ], - required: false, - initialValues: DEFAULT_CONFIG.examples, - }); + let response: ProjectExamples[] | symbol = []; + + if (backend === "elysia") { + response = await multiselect({ + message: "Which examples would you like to include?", + options: [ + { + value: "todo", + label: "Todo App", + hint: "A simple CRUD example app", + }, + ], + required: false, + initialValues: DEFAULT_CONFIG.examples, + }); + } + + if (backend === "hono") { + response = await multiselect({ + message: "Which examples would you like to include?", + options: [ + { + value: "todo", + label: "Todo App", + hint: "A simple CRUD example app", + }, + { + value: "ai", + label: "AI Chat", + hint: "A simple AI chat interface using AI SDK", + }, + ], + required: false, + initialValues: DEFAULT_CONFIG.examples, + }); + } if (isCancel(response)) { cancel(pc.red("Operation cancelled")); diff --git a/apps/cli/src/prompts/package-manager.ts b/apps/cli/src/prompts/package-manager.ts index db92cfe..78dc54c 100644 --- a/apps/cli/src/prompts/package-manager.ts +++ b/apps/cli/src/prompts/package-manager.ts @@ -1,16 +1,16 @@ import { cancel, isCancel, select } from "@clack/prompts"; import pc from "picocolors"; -import type { PackageManager, Runtime } from "../types"; +import type { ProjectPackageManager, ProjectRuntime } from "../types"; import { getUserPkgManager } from "../utils/get-package-manager"; export async function getPackageManagerChoice( - packageManager?: PackageManager, -): Promise { + packageManager?: ProjectPackageManager, +): Promise { if (packageManager !== undefined) return packageManager; const detectedPackageManager = getUserPkgManager(); - const response = await select({ + const response = await select({ message: "Which package manager do you want to use?", options: [ { value: "npm", label: "npm", hint: "Node Package Manager" }, diff --git a/apps/cli/src/prompts/runtime.ts b/apps/cli/src/prompts/runtime.ts index 7d3281f..40c35dd 100644 --- a/apps/cli/src/prompts/runtime.ts +++ b/apps/cli/src/prompts/runtime.ts @@ -1,12 +1,14 @@ import { cancel, isCancel, select } from "@clack/prompts"; import pc from "picocolors"; import { DEFAULT_CONFIG } from "../constants"; -import type { Runtime } from "../types"; +import type { ProjectRuntime } from "../types"; -export async function getRuntimeChoice(runtime?: Runtime): Promise { +export async function getRuntimeChoice( + runtime?: ProjectRuntime, +): Promise { if (runtime !== undefined) return runtime; - const response = await select({ + const response = await select({ message: "Which runtime would you like to use?", options: [ { diff --git a/apps/cli/src/types.ts b/apps/cli/src/types.ts index 64a39f6..2c2b376 100644 --- a/apps/cli/src/types.ts +++ b/apps/cli/src/types.ts @@ -1,23 +1,23 @@ export type ProjectDatabase = "sqlite" | "postgres" | "none"; export type ProjectOrm = "drizzle" | "prisma" | "none"; -export type PackageManager = "npm" | "pnpm" | "bun"; +export type ProjectPackageManager = "npm" | "pnpm" | "bun"; export type ProjectAddons = "pwa" | "biome" | "tauri" | "husky"; -export type BackendFramework = "hono" | "elysia"; -export type Runtime = "node" | "bun"; -export type ProjectExamples = "todo"; +export type ProjectBackend = "hono" | "elysia"; +export type ProjectRuntime = "node" | "bun"; +export type ProjectExamples = "todo" | "ai"; export type ProjectFrontend = "web" | "native"; export interface ProjectConfig { projectName: string; - backendFramework: BackendFramework; - runtime: Runtime; + backend: ProjectBackend; + runtime: ProjectRuntime; database: ProjectDatabase; orm: ProjectOrm; auth: boolean; addons: ProjectAddons[]; examples: ProjectExamples[]; git: boolean; - packageManager: PackageManager; + packageManager: ProjectPackageManager; noInstall?: boolean; turso?: boolean; frontend: ProjectFrontend[]; diff --git a/apps/cli/src/utils/display-config.ts b/apps/cli/src/utils/display-config.ts index 6a6dcb3..6465ca4 100644 --- a/apps/cli/src/utils/display-config.ts +++ b/apps/cli/src/utils/display-config.ts @@ -14,10 +14,8 @@ export function displayConfig(config: Partial) { configDisplay.push(`${pc.blue("Frontend:")} ${frontendText}`); } - if (config.backendFramework !== undefined) { - configDisplay.push( - `${pc.blue("Backend Framework:")} ${config.backendFramework}`, - ); + if (config.backend !== undefined) { + configDisplay.push(`${pc.blue("Backend Framework:")} ${config.backend}`); } if (config.runtime !== undefined) { diff --git a/apps/cli/src/utils/generate-reproducible-command.ts b/apps/cli/src/utils/generate-reproducible-command.ts index 1dc8ef8..0868de3 100644 --- a/apps/cli/src/utils/generate-reproducible-command.ts +++ b/apps/cli/src/utils/generate-reproducible-command.ts @@ -4,12 +4,12 @@ export function generateReproducibleCommand(config: ProjectConfig): string { const flags: string[] = []; if (config.database === "none") { - flags.push("--no-database"); + flags.push("--database none"); } else { - flags.push(`--${config.database}`); + flags.push(`--database ${config.database}`); if (config.orm) { - flags.push(`--${config.orm}`); + flags.push(`--orm ${config.orm}`); } if (config.database === "sqlite") { @@ -23,36 +23,20 @@ export function generateReproducibleCommand(config: ProjectConfig): string { flags.push(config.noInstall ? "--no-install" : "--install"); - if (config.packageManager) { - flags.push(`--${config.packageManager}`); - } - - if (config.backendFramework) { - flags.push(`--${config.backendFramework}`); - } - if (config.runtime) { flags.push(`--runtime ${config.runtime}`); } - if (config.frontend) { - if (config.frontend.includes("web")) { - flags.push("--web"); - } else { - flags.push("--no-web"); - } - - if (config.frontend.includes("native")) { - flags.push("--native"); - } else { - flags.push("--no-native"); - } + if (config.backend) { + flags.push(`--backend ${config.backend}`); } - if (config.addons.length > 0) { - for (const addon of config.addons) { - flags.push(`--${addon}`); - } + if (config.frontend && config.frontend.length > 0) { + flags.push(`--frontend ${config.frontend.join(",")}`); + } + + if (config.addons && config.addons.length > 0) { + flags.push(`--addons ${config.addons.join(",")}`); } else { flags.push("--no-addons"); } @@ -63,7 +47,21 @@ export function generateReproducibleCommand(config: ProjectConfig): string { flags.push("--no-examples"); } - const baseCommand = "npx create-better-t-stack"; + if (config.packageManager) { + flags.push(`--package-manager ${config.packageManager}`); + } + + let baseCommand = ""; + const pkgManager = config.packageManager; + + if (pkgManager === "npm") { + baseCommand = "npm create better-t-stack@latest"; + } else if (pkgManager === "pnpm") { + baseCommand = "pnpm create better-t-stack@latest"; + } else if (pkgManager === "bun") { + baseCommand = "bun create better-t-stack@latest"; + } + const projectName = config.projectName ? ` ${config.projectName}` : ""; return `${baseCommand}${projectName} ${flags.join(" ")}`; diff --git a/apps/cli/src/utils/get-package-manager.ts b/apps/cli/src/utils/get-package-manager.ts index 26f739a..259271a 100644 --- a/apps/cli/src/utils/get-package-manager.ts +++ b/apps/cli/src/utils/get-package-manager.ts @@ -1,6 +1,6 @@ -import type { PackageManager } from "../types"; +import type { ProjectPackageManager } from "../types"; -export const getUserPkgManager: () => PackageManager = () => { +export const getUserPkgManager: () => ProjectPackageManager = () => { const userAgent = process.env.npm_config_user_agent; if (userAgent?.startsWith("pnpm")) { diff --git a/apps/cli/template/base/apps/native/metro.config.js b/apps/cli/template/base/apps/native/metro.config.js index 64fd2db..a5b17c9 100644 --- a/apps/cli/template/base/apps/native/metro.config.js +++ b/apps/cli/template/base/apps/native/metro.config.js @@ -2,7 +2,7 @@ const { getDefaultConfig } = require("expo/metro-config"); const { FileStore } = require("metro-cache"); const { withNativeWind } = require("nativewind/metro"); -const path = require("path"); +const path = require("node:path"); const config = withTurborepoManagedCache( withMonorepoPaths( diff --git a/apps/cli/template/base/apps/web/src/routes/__root.tsx b/apps/cli/template/base/apps/web/src/routes/__root.tsx index 4dd80b7..2b60910 100644 --- a/apps/cli/template/base/apps/web/src/routes/__root.tsx +++ b/apps/cli/template/base/apps/web/src/routes/__root.tsx @@ -46,9 +46,11 @@ function RootComponent() { <> -
- {isFetching && } - +
+
+ {isFetching && } + +
diff --git a/apps/cli/template/examples/ai/apps/web/src/routes/ai.tsx b/apps/cli/template/examples/ai/apps/web/src/routes/ai.tsx new file mode 100644 index 0000000..58418b0 --- /dev/null +++ b/apps/cli/template/examples/ai/apps/web/src/routes/ai.tsx @@ -0,0 +1,69 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useChat } from "@ai-sdk/react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Send } from "lucide-react"; +import { useRef, useEffect } from "react"; + +export const Route = createFileRoute("/ai")({ + component: RouteComponent, +}); + +function RouteComponent() { + const { messages, input, handleInputChange, handleSubmit } = useChat({ + api: `${import.meta.env.VITE_SERVER_URL}/ai`, + }); + + const messagesEndRef = useRef(null); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + return ( +
+
+ {messages.length === 0 ? ( +
+ Ask me anything to get started! +
+ ) : ( + messages.map((message) => ( +
+

+ {message.role === "user" ? "You" : "AI Assistant"} +

+
{message.content}
+
+ )) + )} +
+
+ +
+ + +
+
+ ); +} diff --git a/apps/cli/template/with-auth/apps/web/src/components/auth-forms.tsx b/apps/cli/template/with-auth/apps/web/src/components/auth-forms.tsx deleted file mode 100644 index 3ba200a..0000000 --- a/apps/cli/template/with-auth/apps/web/src/components/auth-forms.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { useState } from "react"; -import SignInForm from "./sign-in-form"; -import SignUpForm from "./sign-up-form"; - -export default function AuthForms() { - const [showSignIn, setShowSignIn] = useState(false); - - return showSignIn ? ( - setShowSignIn(false)} /> - ) : ( - setShowSignIn(true)} /> - ); -} diff --git a/apps/cli/template/with-auth/apps/web/src/components/sign-in-form.tsx b/apps/cli/template/with-auth/apps/web/src/components/sign-in-form.tsx index 1b4c779..2cfe9fa 100644 --- a/apps/cli/template/with-auth/apps/web/src/components/sign-in-form.tsx +++ b/apps/cli/template/with-auth/apps/web/src/components/sign-in-form.tsx @@ -55,7 +55,7 @@ export default function SignInForm({ } return ( -
+

Welcome Back

+

Create Account

); + const [showSignIn, setShowSignIn] = useState(false); + + return showSignIn ? ( + setShowSignIn(false)} /> + ) : ( + setShowSignIn(true)} /> + ); } diff --git a/apps/cli/template/with-drizzle-sqlite/apps/server/src/db/index.ts b/apps/cli/template/with-drizzle-sqlite/apps/server/src/db/index.ts index f06fd5c..9a1e9eb 100644 --- a/apps/cli/template/with-drizzle-sqlite/apps/server/src/db/index.ts +++ b/apps/cli/template/with-drizzle-sqlite/apps/server/src/db/index.ts @@ -1,9 +1,9 @@ import { drizzle } from "drizzle-orm/libsql"; +import { createClient } from "@libsql/client"; -export const db = drizzle({ - connection: { - url: process.env.TURSO_CONNECTION_URL || "", - authToken: process.env.TURSO_AUTH_TOKEN, - }, - // logger: true, +const client = createClient({ + url: process.env.TURSO_CONNECTION_URL || "", + authToken: process.env.TURSO_AUTH_TOKEN, }); + +export const db = drizzle({ client }); diff --git a/apps/web/src/app/(home)/_components/Testimonials.tsx b/apps/web/src/app/(home)/_components/Testimonials.tsx index 2dd76c5..6ef4a8e 100644 --- a/apps/web/src/app/(home)/_components/Testimonials.tsx +++ b/apps/web/src/app/(home)/_components/Testimonials.tsx @@ -17,6 +17,7 @@ const TWEET_IDS = [ "1904179661086556412", "1906149740095705265", "1906001923456790710", + "1906570888897777847", ]; export default function Testimonials() {