feat: add ai chat example and update flags structure

This commit is contained in:
Aman Varshney
2025-03-31 22:52:21 +05:30
parent d6c4127bf5
commit a6ac5dc86c
32 changed files with 485 additions and 263 deletions

View File

@@ -0,0 +1,5 @@
---
"create-better-t-stack": minor
---
Add AI chat example and update flags structures

View File

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

View File

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

View File

@@ -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<void> {
const serverDir = path.join(projectDir, "apps/server");

View File

@@ -34,10 +34,10 @@ export async function createProject(options: ProjectConfig): Promise<string> {
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<string> {
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,
);

View File

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

View File

@@ -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")) {

View File

@@ -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<void> {
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<void> {
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<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");
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<void> {
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(

View File

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

View File

@@ -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 = [];

View File

@@ -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<void> {
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<void> {
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<void> {
const packageJsonPath = path.join(serverDir, "package.json");
const packageJson = await fs.readJson(packageJsonPath);

View File

@@ -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<void> {
const s = spinner();
const clientPackageDir = path.join(projectDir, "apps/web");

View File

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

View File

@@ -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 <type>", "Database type (none, sqlite, postgres)")
.option("--orm <type>", "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 <types>",
"Frontend types (web,native or both)",
(val) => val.split(",") as ProjectFrontend[],
)
.option(
"--addons <types>",
"Additional addons (pwa,tauri,biome,husky)",
(val) => val.split(",") as ProjectAddons[],
)
.option("--no-addons", "Skip all additional addons")
.option("--examples <examples>", "Include specified examples")
.option(
"--examples <types>",
"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 <pm>", "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 <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 <framework>", "Backend framework (hono, elysia)")
.option("--runtime <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<ProjectConfig> = {
...(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);

View File

@@ -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> {
backendFramework?: ProjectBackend,
): Promise<ProjectBackend> {
if (backendFramework !== undefined) return backendFramework;
const response = await select<BackendFramework>({
const response = await select<ProjectBackend>({
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)) {

View File

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

View File

@@ -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<ProjectExamples[]> {
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<ProjectExamples>({
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<ProjectExamples>({
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<ProjectExamples>({
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"));

View File

@@ -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> {
packageManager?: ProjectPackageManager,
): Promise<ProjectPackageManager> {
if (packageManager !== undefined) return packageManager;
const detectedPackageManager = getUserPkgManager();
const response = await select<PackageManager>({
const response = await select<ProjectPackageManager>({
message: "Which package manager do you want to use?",
options: [
{ value: "npm", label: "npm", hint: "Node Package Manager" },

View File

@@ -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<Runtime> {
export async function getRuntimeChoice(
runtime?: ProjectRuntime,
): Promise<ProjectRuntime> {
if (runtime !== undefined) return runtime;
const response = await select<Runtime>({
const response = await select<ProjectRuntime>({
message: "Which runtime would you like to use?",
options: [
{

View File

@@ -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[];

View File

@@ -14,10 +14,8 @@ export function displayConfig(config: Partial<ProjectConfig>) {
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) {

View File

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

View File

@@ -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")) {

View File

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

View File

@@ -46,9 +46,11 @@ function RootComponent() {
<>
<HeadContent />
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
<Header />
{isFetching && <Loader />}
<Outlet />
<div className="grid grid-rows-[auto_1fr] h-svh">
<Header />
{isFetching && <Loader />}
<Outlet />
</div>
<Toaster richColors />
</ThemeProvider>
<TanStackRouterDevtools position="bottom-left" />

View File

@@ -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<HTMLDivElement>(null);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
return (
<div className="grid grid-rows-[1fr_auto] overflow-hidden w-full mx-auto p-4">
<div className="overflow-y-auto space-y-4 pb-4">
{messages.length === 0 ? (
<div className="text-center text-muted-foreground mt-8">
Ask me anything to get started!
</div>
) : (
messages.map((message) => (
<div
key={message.id}
className={`p-3 rounded-lg ${
message.role === "user"
? "bg-primary/10 ml-8"
: "bg-secondary/20 mr-8"
}`}
>
<p className="text-sm font-semibold mb-1">
{message.role === "user" ? "You" : "AI Assistant"}
</p>
<div className="whitespace-pre-wrap">{message.content}</div>
</div>
))
)}
<div ref={messagesEndRef} />
</div>
<form
onSubmit={handleSubmit}
className="w-full flex items-center space-x-2 pt-2 border-t"
>
<Input
name="prompt"
value={input}
onChange={handleInputChange}
placeholder="Type your message..."
className="flex-1"
autoComplete="off"
autoFocus
/>
<Button type="submit" size="icon">
<Send size={18} />
</Button>
</form>
</div>
);
}

View File

@@ -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 ? (
<SignInForm onSwitchToSignUp={() => setShowSignIn(false)} />
) : (
<SignUpForm onSwitchToSignIn={() => setShowSignIn(true)} />
);
}

View File

@@ -55,7 +55,7 @@ export default function SignInForm({
}
return (
<div className="mx-auto mt-10 max-w-md p-6">
<div className="mx-auto w-full mt-10 max-w-md p-6">
<h1 className="mb-6 text-center text-3xl font-bold">Welcome Back</h1>
<form

View File

@@ -58,7 +58,7 @@ export default function SignUpForm({
}
return (
<div className="mx-auto mt-10 max-w-md p-6">
<div className="mx-auto w-full mt-10 max-w-md p-6">
<h1 className="mb-6 text-center text-3xl font-bold">Create Account</h1>
<form

View File

@@ -1,11 +1,18 @@
import SignInForm from "@/components/sign-in-form";
import SignUpForm from "@/components/sign-up-form";
import { createFileRoute } from "@tanstack/react-router";
import AuthForms from "@/components/auth-forms";
import { useState } from "react";
export const Route = createFileRoute("/login")({
component: RouteComponent,
});
function RouteComponent() {
return (
<AuthForms />);
const [showSignIn, setShowSignIn] = useState(false);
return showSignIn ? (
<SignInForm onSwitchToSignUp={() => setShowSignIn(false)} />
) : (
<SignUpForm onSwitchToSignIn={() => setShowSignIn(true)} />
);
}

View File

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

View File

@@ -17,6 +17,7 @@ const TWEET_IDS = [
"1904179661086556412",
"1906149740095705265",
"1906001923456790710",
"1906570888897777847",
];
export default function Testimonials() {